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:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { api } from "@/lib/api";
|
||||
import { CommandPalette } from "@/components/CommandPalette";
|
||||
import { NAV_ITEMS } from "@/lib/nav";
|
||||
import { APP_VERSION_LABEL } from "@/lib/version";
|
||||
import { useRealtime } from "@/lib/realtime";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
/**
|
||||
@@ -38,6 +39,38 @@ function VersionBadge() {
|
||||
);
|
||||
}
|
||||
|
||||
/** A tiny dot that tells the user whether the live feed is connected. */
|
||||
function LiveIndicator() {
|
||||
const { status } = useRealtime();
|
||||
const tone =
|
||||
status === "live"
|
||||
? "success"
|
||||
: status === "connecting" || status === "reconnecting"
|
||||
? "warning"
|
||||
: "neutral";
|
||||
const label =
|
||||
status === "live"
|
||||
? "زنده"
|
||||
: status === "connecting"
|
||||
? "در حال اتصال…"
|
||||
: status === "reconnecting"
|
||||
? "در حال اتصال مجدد…"
|
||||
: "آفلاین";
|
||||
return (
|
||||
<span className="flex items-center gap-1.5" title={label}>
|
||||
<span
|
||||
className={cx(
|
||||
"inline-block h-1.5 w-1.5 rounded-full",
|
||||
tone === "success" && "bg-emerald-400",
|
||||
tone === "warning" && "bg-amber-400 animate-pulse",
|
||||
tone === "neutral" && "bg-slate-500",
|
||||
)}
|
||||
/>
|
||||
<span className="text-[10px] text-slate-500">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Credit badge — reads the organization's real balance, never a placeholder. */
|
||||
function CreditBadge({ organizationId }: { organizationId: string | undefined }) {
|
||||
const [balance, setBalance] = React.useState<number | null>(null);
|
||||
@@ -195,6 +228,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
<span className="hidden sm:inline-flex">
|
||||
<CreditBadge organizationId={activeOrganization?.id} />
|
||||
</span>
|
||||
<span className="hidden sm:inline-flex">
|
||||
<LiveIndicator />
|
||||
</span>
|
||||
<CommandPalette isPlatformOwner={user?.is_platform_owner} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
Stat,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { useLiveChannel } from "@/lib/realtime";
|
||||
|
||||
const STATUS_TONES = {
|
||||
ready: "success",
|
||||
@@ -316,6 +317,53 @@ function SessionPanel({
|
||||
const [snapshotName, setSnapshotName] = React.useState("");
|
||||
const [snapshotBusy, setSnapshotBusy] = React.useState(false);
|
||||
|
||||
// Live feed: command output and session lifecycle events push live.
|
||||
useLiveChannel(
|
||||
[`session:${session.id}`],
|
||||
(event) => {
|
||||
switch (event.type) {
|
||||
case "sandbox.command": {
|
||||
const data = event.data as {
|
||||
run_id: string;
|
||||
command: string;
|
||||
status: string;
|
||||
exit_code: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
duration_ms: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
setRuns((current) => {
|
||||
if (current.some((r) => r.id === data.run_id)) return current;
|
||||
return [
|
||||
...current,
|
||||
{
|
||||
id: data.run_id,
|
||||
command: data.command,
|
||||
exit_code: data.exit_code,
|
||||
stdout: data.stdout,
|
||||
stderr: data.stderr,
|
||||
duration_ms: data.duration_ms,
|
||||
truncated: data.truncated,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "sandbox.session": {
|
||||
const data = event.data as { session_id: string; status: string };
|
||||
if (data.session_id === session.id) {
|
||||
onSessionChange({ ...session, status: data.status });
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const [previewPort, setPreviewPort] = React.useState("8080");
|
||||
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
|
||||
|
||||
|
||||
+70
-11
@@ -7,9 +7,32 @@
|
||||
*/
|
||||
|
||||
import type { ApiErrorBody } from "@agy/contracts";
|
||||
import { getRealtimeClient } from "@/lib/ws";
|
||||
|
||||
export const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
|
||||
/**
|
||||
* Resolves the API base URL.
|
||||
* In a browser:
|
||||
* - If NEXT_PUBLIC_API_URL is configured and not pointing to localhost on a remote host, use it.
|
||||
* - Otherwise default to relative path "" so requests go directly to the current origin (e.g. /api/*)
|
||||
* behind reverse proxies (Caddy), preventing ERR_CONNECTION_REFUSED on clients.
|
||||
* In SSR / Node / tests:
|
||||
* - Fall back to NEXT_PUBLIC_API_URL or "http://localhost:8000".
|
||||
*/
|
||||
export function getApiBaseUrl(): string {
|
||||
const configured = process.env.NEXT_PUBLIC_API_URL?.trim();
|
||||
if (typeof window !== "undefined") {
|
||||
const isLocalhost =
|
||||
window.location.hostname === "localhost" ||
|
||||
window.location.hostname === "127.0.0.1";
|
||||
if (!configured || (!isLocalhost && configured.includes("localhost"))) {
|
||||
return "";
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
return configured || "http://localhost:8000";
|
||||
}
|
||||
|
||||
export const API_BASE_URL = getApiBaseUrl();
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
@@ -67,15 +90,34 @@ export interface RequestOptions {
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { method = "GET", body, token, signal } = options;
|
||||
const { method = "GET", body, signal } = options;
|
||||
|
||||
// Liveness and readiness probes stay on plain HTTP: they answer during a DB
|
||||
// outage and are used by deploy orchestration (and the browser status check),
|
||||
// so they must not depend on the authenticated socket. Every application call
|
||||
// travels over the WebSocket as an RPC instead (see lib/ws.ts) — one
|
||||
// connection for both live feeds and request/response.
|
||||
if (path === "/health" || path === "/health/ready") {
|
||||
return fetchHealth<T>(path, method, body, signal);
|
||||
}
|
||||
|
||||
const client = getRealtimeClient();
|
||||
return client.rpc<T>(method, path, body === undefined ? null : body, { signal });
|
||||
}
|
||||
|
||||
/** Plain-HTTP implementation kept only for the two health probes. */
|
||||
async function fetchHealth<T>(
|
||||
path: string,
|
||||
method: string,
|
||||
body: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (body !== undefined) headers["Content-Type"] = "application/json";
|
||||
const authToken = token ?? getStoredToken();
|
||||
if (authToken) headers.Authorization = `Bearer ${authToken}`;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
response = await fetch(`${getApiBaseUrl()}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
@@ -95,8 +137,6 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
|
||||
const envelope = payload as ApiErrorBody | null;
|
||||
const code = envelope?.error?.code ?? "http_error";
|
||||
const message = envelope?.error?.message ?? `خطای سرور (${response.status})`;
|
||||
// Fall back to the header: a failure raised outside the error handlers
|
||||
// (a CORS rejection, a proxy error) still carries the id.
|
||||
const requestId =
|
||||
envelope?.error?.request_id ?? response.headers.get("X-Request-ID") ?? undefined;
|
||||
throw new ApiError(response.status, code, message, envelope?.error?.details, requestId);
|
||||
@@ -458,9 +498,24 @@ export const api = {
|
||||
output_tokens: number;
|
||||
}>(`/api/admin/agy/accounts/${accountId}/test`, { method: "POST" }),
|
||||
// Live model catalog of one account — fetched from Google on every call, so
|
||||
// the model dropdown always matches that subscription's entitlement.
|
||||
agyAccountModels: (accountId: string) =>
|
||||
apiRequest<string[]>(`/api/admin/agy/accounts/${accountId}/models`),
|
||||
// the model dropdown always matches that subscription's entitlement along with
|
||||
// its supported reasoning efforts.
|
||||
agyAccountModels: async (accountId: string) => {
|
||||
const raw = await apiRequest<(import("@agy/contracts").AgyAccountModel | string)[]>(
|
||||
`/api/admin/agy/accounts/${accountId}/models`,
|
||||
);
|
||||
return raw.map((item): import("@agy/contracts").AgyAccountModel => {
|
||||
if (typeof item === "string") {
|
||||
return {
|
||||
id: item,
|
||||
display_name: item,
|
||||
supports_thinking: false,
|
||||
supported_efforts: [],
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
},
|
||||
adminModels: () => apiRequest<import("@agy/contracts").AdminModel[]>("/api/admin/models"),
|
||||
adminCreateModel: (body: {
|
||||
internal_name: string;
|
||||
@@ -474,6 +529,10 @@ export const api = {
|
||||
method: "POST",
|
||||
body,
|
||||
}),
|
||||
adminDeleteModel: (modelId: string) =>
|
||||
apiRequest<void>(`/api/admin/models/${encodeURIComponent(modelId)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
adminRoutingPolicies: () =>
|
||||
apiRequest<import("@agy/contracts").AdminRoutingPolicy[]>("/api/admin/routing-policies"),
|
||||
adminCreateRoutingPolicy: (body: {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Realtime session state.
|
||||
*
|
||||
* The provider owns the one socket for the whole app and exposes two hooks:
|
||||
*
|
||||
* * ``useRealtime()`` — the connection status (for the live indicator) and
|
||||
* the subscribe primitives, for the rare component that needs raw access.
|
||||
* * ``useLiveChannel(channels, onEvent)`` — what every page actually uses:
|
||||
* subscribe for as long as the component is mounted or the channels change,
|
||||
* and keep the callback stable so an inline arrow does not resubscribe on
|
||||
* every render.
|
||||
*
|
||||
* The socket follows the session: it connects once a token exists and drops
|
||||
* when the user logs out, so no page has to think about auth.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import type { WsEvent, WsEventType } from "@agy/contracts";
|
||||
import { getRealtimeClient, type RealtimeStatus } from "@/lib/ws";
|
||||
|
||||
interface RealtimeContextValue {
|
||||
status: RealtimeStatus;
|
||||
subscribe: (channels: string[]) => void;
|
||||
unsubscribe: (channels: string[]) => void;
|
||||
}
|
||||
|
||||
const RealtimeContext = React.createContext<RealtimeContextValue | null>(null);
|
||||
|
||||
export function RealtimeProvider({ children }: { children: React.ReactNode }) {
|
||||
const client = React.useMemo(() => getRealtimeClient(), []);
|
||||
const [status] = useRealtimeState(client);
|
||||
|
||||
// The connection follows the session: one socket for the app, opened even
|
||||
// before login (anonymous) so the first ``login``/``signup`` RPC can ride it.
|
||||
// ``client.connect()`` is a no-op when the socket already matches the current
|
||||
// token, and reconnects (anonymous <-> authenticated) when the token changes.
|
||||
React.useEffect(() => {
|
||||
const connect = () => client.connect();
|
||||
connect();
|
||||
// ``storage`` fires when another tab logs in or out; polling covers the
|
||||
// same-tab case where the token is written and read within one tick.
|
||||
const interval = setInterval(connect, 2_000);
|
||||
window.addEventListener("storage", connect);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener("storage", connect);
|
||||
client.disconnect();
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
const value = React.useMemo<RealtimeContextValue>(
|
||||
() => ({
|
||||
status,
|
||||
subscribe: (channels) => client.subscribe(channels),
|
||||
unsubscribe: (channels) => client.unsubscribe(channels),
|
||||
}),
|
||||
[status, client],
|
||||
);
|
||||
|
||||
return <RealtimeContext.Provider value={value}>{children}</RealtimeContext.Provider>;
|
||||
}
|
||||
|
||||
function useRealtimeState(client: ReturnType<typeof getRealtimeClient>): [RealtimeStatus] {
|
||||
const [status, setStatus] = React.useState<RealtimeStatus>(() => client.getStatus());
|
||||
React.useEffect(() => client.onStatus(setStatus), [client]);
|
||||
return [status];
|
||||
}
|
||||
|
||||
export function useRealtime(): RealtimeContextValue {
|
||||
const value = React.useContext(RealtimeContext);
|
||||
// A missing provider is a wiring bug, not a runtime condition: fail loudly
|
||||
// in development rather than silently showing a page that never updates.
|
||||
if (!value) {
|
||||
throw new Error("useRealtime must be used inside <RealtimeProvider>");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to channels for the lifetime of the component.
|
||||
*
|
||||
* ``onEvent`` is kept in a ref, so the subscription is not re-established
|
||||
* because the callback identity changed — the classic cause of a live feed
|
||||
* that resubscribes on every render and drops events in between.
|
||||
*/
|
||||
export function useLiveChannel(
|
||||
channels: string[],
|
||||
onEvent: (event: WsEvent) => void,
|
||||
): void {
|
||||
const client = React.useMemo(() => getRealtimeClient(), []);
|
||||
const handler = React.useRef(onEvent);
|
||||
React.useEffect(() => {
|
||||
handler.current = onEvent;
|
||||
}, [onEvent]);
|
||||
|
||||
// A stable key: the effect re-runs when the channel *set* changes, not when
|
||||
// a new array with the same contents is passed.
|
||||
const channelKey = channels.join("\u0000");
|
||||
|
||||
React.useEffect(() => {
|
||||
const active = channelKey ? channelKey.split("\u0000") : [];
|
||||
if (active.length === 0) return;
|
||||
client.subscribe(active);
|
||||
const off = client.onEvent((event) => handler.current(event));
|
||||
return () => {
|
||||
off();
|
||||
client.unsubscribe(active);
|
||||
};
|
||||
}, [client, channelKey]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to one event type only — the common case for a page that renders
|
||||
* exactly one live surface (a timeline, a terminal, a counter).
|
||||
*/
|
||||
export function useLiveEvent(
|
||||
channels: string[],
|
||||
type: WsEventType,
|
||||
onEvent: (event: WsEvent) => void,
|
||||
): void {
|
||||
const handler = React.useRef(onEvent);
|
||||
React.useEffect(() => {
|
||||
handler.current = onEvent;
|
||||
}, [onEvent]);
|
||||
|
||||
useLiveChannel(
|
||||
channels,
|
||||
React.useCallback((event: WsEvent) => {
|
||||
if (event.type === type) handler.current(event);
|
||||
}, [type]),
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
* "the app is on the wrong version" bug into a failing test, which is the
|
||||
* whole point of keeping them separate.
|
||||
*/
|
||||
export const APP_VERSION = "0.1.0";
|
||||
export const APP_VERSION = "0.1.4";
|
||||
|
||||
/** What the footer pill renders — ``v`` prefix keeps it unambiguous in the UI. */
|
||||
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* The realtime client — one WebSocket for the whole app.
|
||||
*
|
||||
* Design decisions worth knowing before editing:
|
||||
*
|
||||
* * **One socket, shared.** Pages subscribe and unsubscribe through
|
||||
* ``RealtimeProvider``; they never own a connection. A dashboard and a task
|
||||
* page both being live must not mean two sockets and two auth round-trips.
|
||||
* * **The token is a function, not a value.** ``getToken`` is read at connect
|
||||
* time, so a login/logout cycle in another tab reconnects correctly instead
|
||||
* of pushing on a dead credential.
|
||||
* * **Anonymous before login.** The socket opens without a token so a visitor
|
||||
* can call ``login``/``signup`` over RPC. Once a token exists the socket is
|
||||
* re-established (still the one socket) carrying the bearer token, and every
|
||||
* later RPC rides that authenticated connection.
|
||||
* * **Reconnect with backoff, capped.** Networks flap; a reconnect storm is how
|
||||
* a flapping network becomes an outage. The cap is deliberately low enough
|
||||
* that "live" feels live again within seconds.
|
||||
* * **Heartbeat.** A silent middlebox will happily hold a dead socket open
|
||||
* forever. A ping every ``HEARTBEAT_INTERVAL_MS`` plus a pong timeout moves
|
||||
* that failure from "the page quietly stops updating" to "the socket
|
||||
* reconnects and updates resume".
|
||||
*/
|
||||
|
||||
import type { ApiErrorBody } from "@agy/contracts";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import type { WsClientFrame, WsEvent, WsServerFrame } from "@agy/contracts";
|
||||
import { getStoredToken } from "@/lib/api";
|
||||
|
||||
/** Where the socket lives: the API origin, upgraded from http(s) to ws(s). */
|
||||
export function websocketUrl(path = "/api/ws"): string {
|
||||
const base =
|
||||
process.env.NEXT_PUBLIC_API_URL ??
|
||||
(typeof window !== "undefined" ? window.location.origin : "http://localhost:8000");
|
||||
const url = new URL(base);
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
url.pathname = path;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export type RealtimeStatus = "idle" | "connecting" | "live" | "reconnecting" | "offline";
|
||||
|
||||
const MIN_BACKOFF_MS = 500;
|
||||
const MAX_BACKOFF_MS = 8_000;
|
||||
const HEARTBEAT_INTERVAL_MS = 25_000;
|
||||
/** If no pong arrives within this window the socket is considered dead. */
|
||||
const PONG_TIMEOUT_MS = 10_000;
|
||||
/** Upper bound on a single RPC before it is failed locally. Long enough for a
|
||||
* slow DB call, short enough that the UI does not hang forever. */
|
||||
const RPC_TIMEOUT_MS = 60_000;
|
||||
/** A socket that has not received any inbound frame for this long is presumed
|
||||
* half-open: a proxy dropped the server→client direction without closing the
|
||||
* socket, so it will never answer. We reconnect before sending on it. */
|
||||
const SOCKET_STALE_MS = 30_000;
|
||||
|
||||
type EventListener = (event: WsEvent) => void;
|
||||
type StatusListener = (status: RealtimeStatus) => void;
|
||||
|
||||
interface PendingRpc {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason: ApiError) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export class RealtimeClient {
|
||||
private socket: WebSocket | null = null;
|
||||
private status: RealtimeStatus = "idle";
|
||||
private readonly listeners = new Set<EventListener>();
|
||||
private readonly statusListeners = new Set<StatusListener>();
|
||||
private readonly subscriptions = new Set<string>();
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private pongTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private closedByUs = false;
|
||||
/** The token the current socket authenticated with; null for anonymous. */
|
||||
private connectedToken: string | null = null;
|
||||
/** Resolved when the socket transitions to OPEN for the current attempt. */
|
||||
private openPromise: Promise<void> | null = null;
|
||||
/** In-flight RPCs keyed by their client-generated id. */
|
||||
private readonly pendingRpc = new Map<string, PendingRpc>();
|
||||
private rpcSeq = 0;
|
||||
/** Timestamp of the last frame we received; proves the link is bidirectional. */
|
||||
private lastActivityAt = 0;
|
||||
|
||||
constructor(private readonly getToken: () => string | null = getStoredToken) {}
|
||||
|
||||
// -- lifecycle -----------------------------------------------------------
|
||||
|
||||
/** Open (or reopen) the socket for the token the getter currently returns. */
|
||||
connect(): void {
|
||||
const token = this.getToken();
|
||||
// Already open for this exact token: nothing to do.
|
||||
if (
|
||||
this.socket &&
|
||||
(this.socket.readyState === WebSocket.OPEN ||
|
||||
this.socket.readyState === WebSocket.CONNECTING) &&
|
||||
this.connectedToken === token
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.openWith(token);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.closedByUs = true;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
this.stopHeartbeat();
|
||||
this.rejectPending(new ApiError(0, "connection_closed", "اتصال قطع شد."));
|
||||
this.reconnectAttempts = 0;
|
||||
if (this.socket) {
|
||||
try {
|
||||
this.socket.close();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
this.socket = null;
|
||||
}
|
||||
this.connectedToken = null;
|
||||
this.setStatus("idle");
|
||||
}
|
||||
|
||||
private openWith(token: string | null): void {
|
||||
this.closedByUs = false;
|
||||
this.setStatus(this.reconnectAttempts > 0 ? "reconnecting" : "connecting");
|
||||
|
||||
// Replace any in-flight socket (e.g. a token change mid-connect).
|
||||
if (this.socket) {
|
||||
try {
|
||||
this.socket.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
const url = token
|
||||
? `${websocketUrl()}?token=${encodeURIComponent(token)}`
|
||||
: websocketUrl();
|
||||
const socket = new WebSocket(url);
|
||||
this.socket = socket;
|
||||
// Record the token of THIS attempt immediately, not only on open. While the
|
||||
// socket is CONNECTING, ``connectedToken`` must already equal the token we
|
||||
// are dialling, so concurrent callers (the provider's poll, another RPC)
|
||||
// wait on ``openPromise`` instead of tearing the socket down and dialling a
|
||||
// second one — which is what produced "closed before established" + a
|
||||
// reconnect storm.
|
||||
this.connectedToken = token;
|
||||
|
||||
this.openPromise = new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
socket.onopen = () => {
|
||||
// This socket may have been replaced (token change) before its handshake
|
||||
// finished; if so, its late "open" belongs to a link we already dropped.
|
||||
if (this.socket !== socket) return;
|
||||
settled = true;
|
||||
this.lastActivityAt = Date.now();
|
||||
this.reconnectAttempts = 0;
|
||||
this.setStatus("live");
|
||||
this.startHeartbeat();
|
||||
// Re-play subscriptions: a new socket knows nothing about what this
|
||||
// client cared about, and pages do not (and should not) re-run their
|
||||
// effects to fix it.
|
||||
if (this.subscriptions.size > 0) {
|
||||
this.send({ action: "subscribe", channels: [...this.subscriptions] });
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
socket.onmessage = (message) => {
|
||||
// Ignore frames from a socket that was already replaced (e.g. a token
|
||||
// change opened a new one): its late frames belong to a dead link.
|
||||
if (this.socket !== socket) return;
|
||||
this.handleFrame(message.data);
|
||||
};
|
||||
socket.onclose = () => {
|
||||
// A replaced socket's late close must not clobber its successor: without
|
||||
// this guard, closing the old socket nulls ``this.socket``/token and
|
||||
// fires a spurious reconnect -- the "closed before established" storm.
|
||||
if (this.socket !== socket) return;
|
||||
if (!settled) {
|
||||
reject(
|
||||
new ApiError(0, "connection_closed", "اتصال پیش از برقراری قطع شد."),
|
||||
);
|
||||
}
|
||||
this.onSocketClosed();
|
||||
};
|
||||
socket.onerror = () => {
|
||||
// A close always follows an error; the close handler does the work.
|
||||
// This only stops an unhandled rejection in stricter runtimes.
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
this.setStatus("reconnecting");
|
||||
const delay = Math.min(
|
||||
MAX_BACKOFF_MS,
|
||||
MIN_BACKOFF_MS * 2 ** this.reconnectAttempts,
|
||||
);
|
||||
this.reconnectAttempts += 1;
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), delay);
|
||||
}
|
||||
|
||||
// -- RPC -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Call an application endpoint over the socket.
|
||||
*
|
||||
* Returns the parsed response body on 2xx, and rejects with an ``ApiError``
|
||||
* (status + server error envelope) on any non-2xx — identical to the old
|
||||
* ``fetch``-based client, so every ``api.*`` method is unchanged.
|
||||
*/
|
||||
rpc<T>(
|
||||
method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE",
|
||||
path: string,
|
||||
body: unknown,
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<T> {
|
||||
const run = (allowRetry: boolean): Promise<T> =>
|
||||
this.ensureLive().then(
|
||||
() =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
const id = `r${++this.rpcSeq}-${Date.now().toString(36)}`;
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingRpc.delete(id);
|
||||
if (allowRetry) {
|
||||
// The link looked alive but never answered: it is probably
|
||||
// half-open (a proxy dropped the server→client direction without
|
||||
// closing the socket, so onclose never fires). Recycle it and
|
||||
// retry once on a fresh connection so login/load does not hard-fail.
|
||||
this.forceReconnect();
|
||||
run(false).then(resolve, reject);
|
||||
return;
|
||||
}
|
||||
reject(
|
||||
new ApiError(
|
||||
0,
|
||||
"timeout",
|
||||
"درخواست از طریق وبسوکت زمانبر شد.",
|
||||
),
|
||||
);
|
||||
}, RPC_TIMEOUT_MS);
|
||||
this.pendingRpc.set(id, {
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject,
|
||||
timer,
|
||||
});
|
||||
this.send({ action: "rpc", id, method, path, body });
|
||||
const signal = options.signal;
|
||||
if (signal) {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
const pending = this.pendingRpc.get(id);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingRpc.delete(id);
|
||||
pending.reject(
|
||||
new ApiError(0, "aborted", "درخواست لغو شد."),
|
||||
);
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return run(true);
|
||||
}
|
||||
|
||||
/** Open (reconnecting first if the socket has gone silent) and resolve when
|
||||
* ready to send. A socket with no inbound frame for ``SOCKET_STALE_MS`` is
|
||||
* treated as half-open and replaced before we send on it. */
|
||||
private ensureLive(): Promise<void> {
|
||||
const token = this.getToken();
|
||||
const stale =
|
||||
!!this.socket &&
|
||||
this.socket.readyState === WebSocket.OPEN &&
|
||||
Date.now() - this.lastActivityAt > SOCKET_STALE_MS;
|
||||
if (stale) this.openWith(token);
|
||||
return this.whenOpen();
|
||||
}
|
||||
|
||||
/** Tear down the current socket unconditionally and open a fresh one. Used
|
||||
* when a request times out on a link that never reported a close. */
|
||||
private forceReconnect(): void {
|
||||
this.closedByUs = false;
|
||||
if (this.socket) {
|
||||
try {
|
||||
this.socket.close();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
this.socket = null;
|
||||
}
|
||||
this.openWith(this.getToken());
|
||||
}
|
||||
|
||||
/** Resolve once the socket is OPEN for the current token (opening if needed). */
|
||||
private whenOpen(): Promise<void> {
|
||||
const token = this.getToken();
|
||||
if (
|
||||
this.socket &&
|
||||
(this.socket.readyState === WebSocket.OPEN ||
|
||||
this.socket.readyState === WebSocket.CONNECTING) &&
|
||||
this.connectedToken === token
|
||||
) {
|
||||
return this.openPromise ?? Promise.resolve();
|
||||
}
|
||||
this.openWith(token);
|
||||
return this.openPromise ?? Promise.resolve();
|
||||
}
|
||||
|
||||
// -- subscriptions ---------------------------------------------------------
|
||||
|
||||
subscribe(channels: string[]): void {
|
||||
const fresh = channels.filter((channel) => !this.subscriptions.has(channel));
|
||||
if (fresh.length === 0) return;
|
||||
fresh.forEach((channel) => this.subscriptions.add(channel));
|
||||
this.send({ action: "subscribe", channels: fresh });
|
||||
}
|
||||
|
||||
unsubscribe(channels: string[]): void {
|
||||
const active = channels.filter((channel) => this.subscriptions.has(channel));
|
||||
if (active.length === 0) return;
|
||||
active.forEach((channel) => this.subscriptions.delete(channel));
|
||||
this.send({ action: "unsubscribe", channels: active });
|
||||
}
|
||||
|
||||
activeSubscriptions(): string[] {
|
||||
return [...this.subscriptions];
|
||||
}
|
||||
|
||||
// -- listeners --------------------------------------------------------------
|
||||
|
||||
onEvent(listener: EventListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
onStatus(listener: StatusListener): () => void {
|
||||
this.statusListeners.add(listener);
|
||||
return () => this.statusListeners.delete(listener);
|
||||
}
|
||||
|
||||
getStatus(): RealtimeStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
// -- frames -----------------------------------------------------------------
|
||||
|
||||
private send(frame: WsClientFrame): void {
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.socket.send(JSON.stringify(frame));
|
||||
}
|
||||
// A frame sent before the socket is open is not an error: on open, the
|
||||
// full subscription set is replayed anyway, and RPCs await whenOpen().
|
||||
}
|
||||
|
||||
private handleFrame(raw: unknown): void {
|
||||
if (typeof raw !== "string") return;
|
||||
let frame: WsServerFrame;
|
||||
try {
|
||||
frame = JSON.parse(raw) as WsServerFrame;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// Any inbound frame proves the server→client direction is alive.
|
||||
this.lastActivityAt = Date.now();
|
||||
|
||||
switch (frame.type) {
|
||||
case "event":
|
||||
this.emitEvent(frame.event);
|
||||
break;
|
||||
case "pong":
|
||||
this.stopPongTimer();
|
||||
break;
|
||||
case "subscribed":
|
||||
// A rejected channel is a server answer, not a client error: the page
|
||||
// keeps its initial data and the console shows why it is not live.
|
||||
if (frame.rejected.length > 0) {
|
||||
console.warn("realtime: channels rejected by the server", frame.rejected);
|
||||
}
|
||||
break;
|
||||
case "error":
|
||||
console.warn("realtime: server error frame", frame.message);
|
||||
break;
|
||||
case "rpc_result":
|
||||
this.resolveRpc(frame);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveRpc(frame: Extract<WsServerFrame, { type: "rpc_result" }>): void {
|
||||
const pending = this.pendingRpc.get(frame.id);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingRpc.delete(frame.id);
|
||||
|
||||
if (frame.status >= 200 && frame.status < 300) {
|
||||
pending.resolve(frame.body);
|
||||
return;
|
||||
}
|
||||
const envelope = frame.body as ApiErrorBody | null;
|
||||
const code = envelope?.error?.code ?? "http_error";
|
||||
const message = envelope?.error?.message ?? `خطای سرور (${frame.status})`;
|
||||
pending.reject(
|
||||
new ApiError(
|
||||
frame.status,
|
||||
code,
|
||||
message,
|
||||
envelope?.error?.details,
|
||||
envelope?.error?.request_id,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private emitEvent(event: WsEvent): void {
|
||||
for (const listener of [...this.listeners]) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (error) {
|
||||
// One broken page must not silence every other page's feed.
|
||||
console.error("realtime: event listener failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onSocketClosed(): void {
|
||||
this.stopHeartbeat();
|
||||
this.socket = null;
|
||||
this.connectedToken = null;
|
||||
this.rejectPending(new ApiError(0, "connection_closed", "اتصال قطع شد."));
|
||||
if (this.closedByUs) {
|
||||
this.setStatus("idle");
|
||||
return;
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
private setStatus(status: RealtimeStatus): void {
|
||||
if (this.status === status) return;
|
||||
this.status = status;
|
||||
for (const listener of [...this.statusListeners]) {
|
||||
try {
|
||||
listener(status);
|
||||
} catch (error) {
|
||||
console.error("realtime: status listener failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private rejectPending(error: ApiError): void {
|
||||
for (const pending of this.pendingRpc.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pendingRpc.clear();
|
||||
}
|
||||
|
||||
// -- heartbeat -----------------------------------------------------------------
|
||||
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat();
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
this.send({ action: "ping" });
|
||||
this.pongTimer = setTimeout(() => {
|
||||
// No pong: the socket is a zombie. Close it so the reconnect path runs.
|
||||
this.socket?.close();
|
||||
}, PONG_TIMEOUT_MS);
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
this.stopPongTimer();
|
||||
}
|
||||
|
||||
private stopPongTimer(): void {
|
||||
if (this.pongTimer) {
|
||||
clearTimeout(this.pongTimer);
|
||||
this.pongTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sharedClient: RealtimeClient | null = null;
|
||||
|
||||
/** The process-wide client the provider owns. */
|
||||
export function getRealtimeClient(): RealtimeClient {
|
||||
if (!sharedClient) {
|
||||
sharedClient = new RealtimeClient();
|
||||
}
|
||||
return sharedClient;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError, api, apiRequest, getStoredToken, setStoredToken } from "@/lib/api";
|
||||
import { getRealtimeClient } from "@/lib/ws";
|
||||
|
||||
describe("token storage", () => {
|
||||
it("round-trips a token through localStorage", () => {
|
||||
@@ -25,22 +26,14 @@ describe("ApiError", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("error correlation", () => {
|
||||
describe("error correlation over rpc", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("reads the request id out of the error envelope", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: { code: "internal_error", message: "خطا", request_id: "req-from-body" },
|
||||
}),
|
||||
{ status: 500, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
),
|
||||
vi.spyOn(getRealtimeClient(), "rpc").mockRejectedValue(
|
||||
new ApiError(500, "internal_error", "خطا", undefined, "req-from-body"),
|
||||
);
|
||||
|
||||
await expect(apiRequest("/api/whatever")).rejects.toMatchObject({
|
||||
@@ -49,142 +42,126 @@ describe("error correlation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the X-Request-ID header", async () => {
|
||||
// A failure raised outside the error handlers — a CORS rejection or a proxy
|
||||
// error — still carries the header, and the user should still get an id.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
new Response(JSON.stringify({ detail: "Disallowed CORS origin" }), {
|
||||
status: 400,
|
||||
headers: { "X-Request-ID": "req-from-header" },
|
||||
}),
|
||||
),
|
||||
it("rejects an unauthenticated call with the server's 401", async () => {
|
||||
vi.spyOn(getRealtimeClient(), "rpc").mockRejectedValue(
|
||||
new ApiError(401, "unauthenticated", "نیاز به ورود دارید."),
|
||||
);
|
||||
|
||||
await expect(apiRequest("/api/whatever")).rejects.toMatchObject({
|
||||
requestId: "req-from-header",
|
||||
await expect(apiRequest("/api/organizations")).rejects.toMatchObject({
|
||||
status: 401,
|
||||
code: "unauthenticated",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("error console client", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("omits unset filters from the query string", async () => {
|
||||
const calls: string[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
calls.push(url);
|
||||
return new Response(JSON.stringify({ items: [], total: 0, limit: 50, offset: 0 }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const rpc = vi
|
||||
.spyOn(getRealtimeClient(), "rpc")
|
||||
.mockResolvedValue({ items: [], total: 0, limit: 50, offset: 0 } as unknown as never);
|
||||
await api.adminErrors({ status: "open", severity: "critical" });
|
||||
expect(calls[0]).toContain("status=open");
|
||||
expect(calls[0]).toContain("severity=critical");
|
||||
expect(rpc).toHaveBeenCalledTimes(1);
|
||||
const [method, path] = rpc.mock.calls[0];
|
||||
expect(method).toBe("GET");
|
||||
expect(path).toContain("status=open");
|
||||
expect(path).toContain("severity=critical");
|
||||
// An empty string means "all", and must not be sent as a filter value.
|
||||
expect(calls[0]).not.toContain("source=");
|
||||
expect(calls[0]).not.toContain("search=");
|
||||
expect(path).not.toContain("source=");
|
||||
expect(path).not.toContain("search=");
|
||||
});
|
||||
|
||||
it("sends no query string at all when there are no filters", async () => {
|
||||
const calls: string[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
calls.push(url);
|
||||
return new Response(JSON.stringify([]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const rpc = vi
|
||||
.spyOn(getRealtimeClient(), "rpc")
|
||||
.mockResolvedValue([] as unknown as never);
|
||||
await api.adminErrorGroups();
|
||||
expect(calls[0]).not.toContain("?");
|
||||
const [, path] = rpc.mock.calls[0];
|
||||
expect(path).not.toContain("?");
|
||||
});
|
||||
});
|
||||
|
||||
describe("agy account client", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function stubJson(body: unknown) {
|
||||
const calls: { method: string; url: string; body: string }[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
calls.push({
|
||||
method: init?.method ?? "GET",
|
||||
url,
|
||||
body: String(init?.body ?? ""),
|
||||
});
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
return calls;
|
||||
function stubRpc(body: unknown) {
|
||||
return vi
|
||||
.spyOn(getRealtimeClient(), "rpc")
|
||||
.mockResolvedValue(body as unknown as never);
|
||||
}
|
||||
|
||||
it("posts to the login start endpoint", async () => {
|
||||
const calls = stubJson({ login_id: "l1", auth_url: "https://accounts.google.com/..." });
|
||||
const rpc = stubRpc({ login_id: "l1", auth_url: "https://accounts.google.com/..." });
|
||||
await api.agyStartLogin();
|
||||
expect(calls[0].method).toBe("POST");
|
||||
expect(calls[0].url).toContain("/api/admin/agy/accounts/login/start");
|
||||
const [method, path] = rpc.mock.calls[0];
|
||||
expect(method).toBe("POST");
|
||||
expect(path).toContain("/api/admin/agy/accounts/login/start");
|
||||
});
|
||||
|
||||
it("sends the pasted callback URL back verbatim", async () => {
|
||||
const calls = stubJson({ id: "a1", email: "x@gmail.com" });
|
||||
const rpc = stubRpc({ id: "a1", email: "x@gmail.com" });
|
||||
await api.agyCompleteLogin({
|
||||
login_id: "l1",
|
||||
callback_url: "http://localhost:51121/oauth-callback?code=c&state=s",
|
||||
});
|
||||
expect(calls[0].url).toContain("/api/admin/agy/accounts/login/complete");
|
||||
expect(calls[0].body).toContain("callback_url");
|
||||
const [method, path, sentBody] = rpc.mock.calls[0];
|
||||
expect(path).toContain("/api/admin/agy/accounts/login/complete");
|
||||
expect(JSON.stringify(sentBody)).toContain("callback_url");
|
||||
});
|
||||
|
||||
it("routes activate/deactivate to the right verb", async () => {
|
||||
const calls = stubJson({ id: "a1" });
|
||||
const rpc = stubRpc({ id: "a1" });
|
||||
await api.agySetAccountActive("a1", false);
|
||||
await api.agySetAccountActive("a1", true);
|
||||
expect(calls[0].url).toContain("/api/admin/agy/accounts/a1/deactivate");
|
||||
expect(calls[1].url).toContain("/api/admin/agy/accounts/a1/activate");
|
||||
expect(rpc.mock.calls[0][1]).toContain("/api/admin/agy/accounts/a1/deactivate");
|
||||
expect(rpc.mock.calls[1][1]).toContain("/api/admin/agy/accounts/a1/activate");
|
||||
});
|
||||
|
||||
it("deletes with the DELETE verb", async () => {
|
||||
const calls = stubJson(null);
|
||||
const rpc = stubRpc(null);
|
||||
await api.agyDeleteAccount("a1");
|
||||
expect(calls[0].method).toBe("DELETE");
|
||||
expect(calls[0].url).toContain("/api/admin/agy/accounts/a1");
|
||||
expect(rpc.mock.calls[0][0]).toBe("DELETE");
|
||||
expect(rpc.mock.calls[0][1]).toContain("/api/admin/agy/accounts/a1");
|
||||
});
|
||||
|
||||
it("fetches the live model catalog for one account", async () => {
|
||||
const calls = stubJson(["gemini-3-flash", "gemini-3-pro-high"]);
|
||||
const rpc = stubRpc([
|
||||
{
|
||||
id: "gemini-3-flash",
|
||||
display_name: "Gemini 3 Flash",
|
||||
supports_thinking: true,
|
||||
supported_efforts: ["low", "medium", "high"],
|
||||
},
|
||||
{
|
||||
id: "gemini-3-pro-high",
|
||||
display_name: "Gemini 3 Pro High",
|
||||
supports_thinking: true,
|
||||
supported_efforts: ["high"],
|
||||
},
|
||||
]);
|
||||
const models = await api.agyAccountModels("a1");
|
||||
expect(calls[0].method).toBe("GET");
|
||||
expect(calls[0].url).toContain("/api/admin/agy/accounts/a1/models");
|
||||
expect(models).toContain("gemini-3-pro-high");
|
||||
const [method, path] = rpc.mock.calls[0];
|
||||
expect(method).toBe("GET");
|
||||
expect(path).toContain("/api/admin/agy/accounts/a1/models");
|
||||
expect(models.map((m) => m.id)).toContain("gemini-3-pro-high");
|
||||
expect(models[0].supported_efforts).toEqual(["low", "medium", "high"]);
|
||||
});
|
||||
|
||||
it("creates a model from the cascade — no manual names", async () => {
|
||||
const calls = stubJson({ id: "m1", internal_name: "gemini-3-pro-high" });
|
||||
const rpc = stubRpc({ id: "m1", internal_name: "gemini-3-pro-high" });
|
||||
await api.adminCreateModel({
|
||||
internal_name: "gemini-3-pro-high",
|
||||
capabilities: ["text", "reasoning"],
|
||||
reasoning_effort: "high",
|
||||
});
|
||||
expect(calls[0].url).toContain("/api/admin/models");
|
||||
expect(calls[0].body).toContain("gemini-3-pro-high");
|
||||
expect(calls[0].body).toContain("reasoning_effort");
|
||||
const [method, path, sentBody] = rpc.mock.calls[0];
|
||||
expect(path).toContain("/api/admin/models");
|
||||
expect(JSON.stringify(sentBody)).toContain("gemini-3-pro-high");
|
||||
expect(JSON.stringify(sentBody)).toContain("reasoning_effort");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { RealtimeProvider } from "@/lib/realtime";
|
||||
import { APP_VERSION, APP_VERSION_LABEL } from "@/lib/version";
|
||||
|
||||
/**
|
||||
@@ -30,6 +31,32 @@ vi.mock("@/lib/session", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// The shell renders a live-feed indicator, so it must be mounted the way the
|
||||
// app wires it in ``app/layout.tsx`` — inside ``RealtimeProvider``. The socket
|
||||
// itself is stubbed out: a test must not open a real WebSocket.
|
||||
vi.mock("@/lib/ws", () => {
|
||||
const listener = () => () => {};
|
||||
const client = {
|
||||
getStatus: () => "offline" as const,
|
||||
onStatus: listener,
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
subscribe: () => () => {},
|
||||
rpc: vi.fn(),
|
||||
};
|
||||
return { getRealtimeClient: () => client, websocketUrl: () => "ws://localhost/api/ws" };
|
||||
});
|
||||
|
||||
function renderShell() {
|
||||
return render(
|
||||
<RealtimeProvider>
|
||||
<AppShell>
|
||||
<p>بدنه</p>
|
||||
</AppShell>
|
||||
</RealtimeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(join(process.cwd(), "package.json"), "utf-8"),
|
||||
) as { version: string };
|
||||
@@ -52,12 +79,12 @@ describe("app version", () => {
|
||||
|
||||
describe("sidebar version badge", () => {
|
||||
it("shows the version in the shell footer", () => {
|
||||
render(<AppShell><p>بدنه</p></AppShell>);
|
||||
renderShell();
|
||||
expect(screen.getByText(APP_VERSION_LABEL)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sets the direction explicitly so the RTL shell cannot reorder it", () => {
|
||||
render(<AppShell><p>بدنه</p></AppShell>);
|
||||
renderShell();
|
||||
const badge = screen.getByText(APP_VERSION_LABEL);
|
||||
expect(badge.className).toContain("ltr-inline");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user