feat: realtime WebSocket layer for live events and streaming logs
- Backend: ConnectionManager, Redis-backed bus with in-process fallback, channel authorization, and typed emit helpers wired into workers (ai_runtime, sandbox_gateway), workforce/sandbox/billing/audit/error services. - Frontend: single shared WS client with backoff + heartbeat, RealtimeProvider, useLiveChannel/useLiveEvent hooks, live indicators on task detail, manager chat, dashboard, traces, sandbox terminal, admin errors, and billing. - Contracts: WsEvent/WsServerFrame/WsClientFrame types mirroring the port. All 255 backend tests pass; web typecheck and build pass.
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user