Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions open-sse/handlers/chatCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -825,6 +825,7 @@ export async function handleChatCore({
// once so the 16 call sites keep passing only the per-attempt args (byte-identical).
const persistAttemptLogs = (args: PersistAttemptLogsArgs) =>
persistAttemptLogsFor(args, {
traceId,
provider,
connectionId,
model,
Expand Down
84 changes: 84 additions & 0 deletions open-sse/handlers/chatCore/attemptLogging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

import { extractProviderWarnings } from "@/lib/compliance/providerAudit";
import { logAuditEvent } from "@/lib/compliance";
import { emit } from "@/lib/events/eventBus";
import type { RequestCompletedPayload, RequestFailedPayload } from "@/lib/events/types";
import { saveCallLog } from "@/lib/usageDb";
import { cloneBoundedChatLogPayload, truncateForLog } from "./logTruncation.ts";
import { attachLogMeta } from "./cacheUsageMeta.ts";
Expand All @@ -30,6 +32,9 @@ export type PersistAttemptLogsArgs = {
};

export type PersistAttemptLogsContext = {
/** Per-attempt trace id — MUST match the id emitted in `request.started` so the live
* dashboard can pair the terminal event and clear the topology node's active pulse. */
traceId: string;
provider: string | null | undefined;
connectionId: string | null | undefined;
model: string | null | undefined;
Expand Down Expand Up @@ -74,6 +79,61 @@ function buildAccountRotationMeta(
};
}

/**
* Pure resolver for the terminal request-lifecycle dashboard event. Extracted so the
* "stuck green" latch fix (emitting request.completed/failed to clear the live topology
* node) is unit-testable without the DB write in persistAttemptLogs. A 2xx/3xx status
* with no error is a completion; everything else (including a missing/odd status) is a
* failure. `id` mirrors the `traceId` used by the paired `request.started`.
*/
export function resolveRequestLifecycleEvent(input: {
traceId: string;
status: number;
error?: string | null;
model?: string | null;
provider?: string | null;
comboName?: unknown;
tokens?: unknown;
latencyMs: number;
}):
| { name: "request.completed"; payload: RequestCompletedPayload }
| { name: "request.failed"; payload: RequestFailedPayload } {
const { traceId, status, error, model, provider, comboName, tokens, latencyMs } = input;
const succeeded = typeof status === "number" && status >= 200 && status < 400 && !error;
const resolvedComboName = typeof comboName === "string" && comboName ? comboName : undefined;
if (succeeded) {
const tokenBag = (tokens && typeof tokens === "object" ? tokens : {}) as Record<
string,
unknown
>;
const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
return {
name: "request.completed",
payload: {
id: traceId,
status: "success",
model: model || "unknown",
provider: provider || "unknown",
tokensInput: num(tokenBag.input ?? tokenBag.prompt_tokens ?? tokenBag.inputTokens),
tokensOutput: num(tokenBag.output ?? tokenBag.completion_tokens ?? tokenBag.outputTokens),
latencyMs,
comboName: resolvedComboName,
},
};
}
return {
name: "request.failed",
payload: {
id: traceId,
error: error || `HTTP ${status}`,
statusCode: typeof status === "number" ? status : undefined,
latencyMs,
model: model || undefined,
provider: provider || undefined,
},
};
}

export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAttemptLogsContext) {
const {
status,
Expand All @@ -88,6 +148,7 @@ export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAtt
cacheSource,
} = args;
const {
traceId,
provider,
connectionId,
model,
Expand Down Expand Up @@ -210,4 +271,27 @@ export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAtt
correlationId,
modelPinned: modelPinned || false,
}).catch(() => {});

// Emit the terminal request-lifecycle event to the live dashboard bus. `request.started`
// is emitted in chatCore with this same `traceId`; without a matching completed/failed the
// client's active-request map never drains, so the topology node stays green forever (the
// "stuck green" latch — request.completed/failed were declared + consumed but never emitted).
// Deferred via setImmediate to keep it off the response hot path, mirroring request.started.
setImmediate(() => {
const lifecycle = resolveRequestLifecycleEvent({
traceId,
status,
error,
model,
provider,
comboName,
tokens,
latencyMs: Date.now() - startTime,
});
if (lifecycle.name === "request.completed") {
emit("request.completed", lifecycle.payload);
} else {
emit("request.failed", lifecycle.payload);
}
});
}
21 changes: 20 additions & 1 deletion src/app/(dashboard)/dashboard/HomePageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -464,9 +464,27 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
}, [selectedProvider, models]);

const topologyProviders = useMemo(() => {
const byProvider = new Map<string, { id: string; provider: string; name?: string }>();
type ProviderHealth = "active" | "error" | "idle";
const byProvider = new Map<
string,
{ id: string; provider: string; name?: string; status: ProviderHealth }
>();
const providerConfig = AI_PROVIDERS as Record<string, { name?: string }>;

// Connection-health per provider, so the topology node reflects "what is connected"
// at rest (green healthy / red error) instead of going blank between requests. A
// provider with ≥1 healthy connection is "active"; if none are healthy but some are
// errored it is "error"; otherwise "idle". Live/recent traffic still overrides this.
const healthByProvider = new Map<string, ProviderHealth>();
for (const stat of providerStats) {
const canonical = normalizeProviderId(stat.id);
if (!canonical) continue;
healthByProvider.set(
canonical,
stat.connected > 0 ? "active" : stat.errors > 0 ? "error" : "idle"
);
}

const addProvider = (providerId?: string | null, name?: string) => {
const rawProviderId = typeof providerId === "string" ? providerId.trim() : "";
if (!rawProviderId) return;
Expand All @@ -484,6 +502,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
id: canonicalProviderId,
provider: canonicalProviderId,
name: resolvedName,
status: healthByProvider.get(canonicalProviderId) ?? "idle",
});
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ type TopologyProvider = {
id: string;
provider: string;
name?: string;
/** Connection-health base state, so the topology can colour a node at rest. */
status?: "active" | "error" | "idle";
};

export function HomeProviderTopologySection({
Expand Down
60 changes: 42 additions & 18 deletions src/app/(dashboard)/home/ProviderTopology.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { AI_PROVIDERS } from "@/shared/constants/providers";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { FlowCanvas } from "@/shared/components/flow/FlowCanvas";
import { StatusDot } from "@/shared/components/flow/StatusDot";
import { edgeStyle } from "@/shared/components/flow/edgeStyles";
import { edgeStyle, FLOW_EDGE_COLORS } from "@/shared/components/flow/edgeStyles";
import { resolveTopologyNodeLabel } from "./topologyLabel";

// Rings: [capacity, rx, ry]. Each successive ring fits ~6 more nodes.
Expand Down Expand Up @@ -37,17 +37,27 @@ type ProviderNodeData = {
providerId: string;
active: boolean;
error: boolean;
/** Connection-health base state: a healthy connection with no in-flight traffic. */
healthy: boolean;
};

function ProviderNode({ data }: { data: ProviderNodeData }) {
const { label, color, providerId, active, error } = data;
const { label, color, providerId, active, error, healthy } = data;
const GREEN = FLOW_EDGE_COLORS.active;
const RED = FLOW_EDGE_COLORS.error;

return (
<div
className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg border-2 transition-all duration-300 bg-bg"
style={{
borderColor: error ? "#ef4444" : active ? color : "var(--color-border)",
boxShadow: error ? `0 0 12px #ef444430` : active ? `0 0 12px ${color}30` : "none",
borderColor: error ? RED : active ? color : healthy ? GREEN : "var(--color-border)",
boxShadow: error
? `0 0 12px ${RED}30`
: active
? `0 0 12px ${color}30`
: healthy
? `0 0 10px ${GREEN}20`
: "none",
minWidth: "136px",
}}
>
Expand Down Expand Up @@ -85,12 +95,16 @@ function ProviderNode({ data }: { data: ProviderNodeData }) {

<span
className="text-xs font-medium truncate flex-1"
style={{ color: active ? color : error ? "#ef4444" : "var(--color-text-main)" }}
style={{
color: active ? color : error ? RED : healthy ? GREEN : "var(--color-text-main)",
}}
>
{label}
</span>

{(active || error) && <StatusDot color={color} error={error} />}
{(active || error || healthy) && (
<StatusDot color={active ? color : GREEN} error={error} pulse={active || error} />
)}
</div>
);
}
Expand Down Expand Up @@ -143,7 +157,8 @@ const nodeTypes: NodeTypes = {
router: RouterNode as any,
};

type ProviderEntry = { id?: string; provider: string; name?: string };
type ProviderHealth = "active" | "error" | "idle";
type ProviderEntry = { id?: string; provider: string; name?: string; status?: ProviderHealth };

function getHandles(angle: number, cx: number): { sourceHandle: string; targetHandle: string } {
const rel = (((angle + Math.PI / 2) % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
Expand Down Expand Up @@ -180,18 +195,20 @@ function buildLayout(

if (providers.length === 0) return { nodes, edges };

// Sort: active → error → last-used → rest (alpha within groups)
// Sort: active → error → last-used → healthy(connected) → rest (alpha within groups)
const sorted = [...providers].sort((a, b) => {
const aId = a.provider.toLowerCase();
const bId = b.provider.toLowerCase();
const rank = (id: string) => {
const rank = (p: ProviderEntry) => {
const id = p.provider.toLowerCase();
if (activeSet.has(id)) return 0;
if (errorSet.has(id)) return 1;
if (errorSet.has(id) || p.status === "error") return 1;
if (lastSet.has(id)) return 2;
return 3;
if (p.status === "active") return 3;
return 4;
};
const d = rank(aId) - rank(bId);
return d !== 0 ? d : aId.localeCompare(bId); // teknik sıralama: ASCII kasıtlı
const d = rank(a) - rank(b);
return d !== 0
? d
: a.provider.toLowerCase().localeCompare(b.provider.toLowerCase()); // ASCII kasıtlı
});

let provIdx = 0;
Expand All @@ -203,8 +220,14 @@ function buildLayout(
const p = sorted[provIdx++];
const pid = p.provider.toLowerCase();
const active = activeSet.has(pid);
const error = !active && errorSet.has(pid);
const last = !active && !error && lastSet.has(pid);
// Traffic signals (live/recent request) take precedence; connection health is the
// base state shown when a provider has no in-flight or recent traffic, so the map
// still reflects "what is connected" at rest instead of going blank after a restart.
const trafficError = !active && errorSet.has(pid);
const last = !active && !trafficError && lastSet.has(pid);
const healthError = !active && !trafficError && !last && p.status === "error";
const healthy = !active && !trafficError && !last && !healthError && p.status === "active";
const error = trafficError || healthError;
const config = getProviderConfig(p.provider);
const nodeId = `provider-${p.provider}`;

Expand All @@ -223,6 +246,7 @@ function buildLayout(
providerId: p.provider,
active,
error,
healthy,
} satisfies ProviderNodeData,
draggable: false,
});
Expand All @@ -234,7 +258,7 @@ function buildLayout(
target: nodeId,
targetHandle,
animated: active,
style: edgeStyle(active, last, error),
style: edgeStyle(active, last, error, healthy),
});
}
}
Expand Down
29 changes: 21 additions & 8 deletions src/shared/components/flow/StatusDot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,34 @@ type StatusDotProps = {
* value used by ProviderTopology so the home pulse is pixel-identical.
*/
sizeClass?: string;
/**
* Whether to render the `animate-ping` halo. Defaults to true (live/active pulse).
* Pass false for a static presence dot — e.g. a connection that is healthy but has
* no in-flight traffic, which should read as "connected" without implying activity.
*/
pulse?: boolean;
};

/**
* The pulsing presence indicator extracted from `ProviderTopology` (U0). Renders
* an `animate-ping` halo plus a solid dot. Callers decide *whether* to show it
* (e.g. only when a node is active or errored); this component only draws it.
* The presence indicator extracted from `ProviderTopology` (U0). Renders an optional
* `animate-ping` halo plus a solid dot. Callers decide *whether* to show it (e.g. only
* when a node is active, healthy, or errored); this component only draws it.
*/
export function StatusDot({ color, error = false, sizeClass = "size-1.5" }: StatusDotProps) {
export function StatusDot({
color,
error = false,
sizeClass = "size-1.5",
pulse = true,
}: StatusDotProps) {
const dotColor = error ? FLOW_EDGE_COLORS.error : color;
return (
<span className={`relative flex ${sizeClass} shrink-0`}>
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-70"
style={{ backgroundColor: dotColor }}
/>
{pulse && (
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-70"
style={{ backgroundColor: dotColor }}
/>
)}
<span
className={`relative inline-flex rounded-full ${sizeClass}`}
style={{ backgroundColor: dotColor }}
Expand Down
16 changes: 13 additions & 3 deletions src/shared/components/flow/edgeStyles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,22 @@ export interface FlowEdgeStyle {

/**
* Resolve the stroke style for an edge given its state. Precedence is
* error > active > last-used > idle — identical to the original ProviderTopology
* implementation (do not reorder without updating the home regression).
* error > active > last-used > healthy > idle — the first three are identical to the
* original ProviderTopology implementation (do not reorder without updating the home
* regression). `healthy` is the connection-health base state (a configured provider with
* a live/healthy connection but no in-flight traffic): a static, dimmer green that makes
* the map meaningful at rest, distinct from the animated `active` pulse. It is an optional
* trailing param so existing callers (Combo/Compression studios) stay unaffected.
*/
export function edgeStyle(active: boolean, last: boolean, error: boolean): FlowEdgeStyle {
export function edgeStyle(
active: boolean,
last: boolean,
error: boolean,
healthy = false
): FlowEdgeStyle {
if (error) return { stroke: FLOW_EDGE_COLORS.error, strokeWidth: 2, opacity: 0.85 };
if (active) return { stroke: FLOW_EDGE_COLORS.active, strokeWidth: 2.5, opacity: 1 };
if (last) return { stroke: FLOW_EDGE_COLORS.last, strokeWidth: 1.5, opacity: 0.6 };
if (healthy) return { stroke: FLOW_EDGE_COLORS.active, strokeWidth: 1.5, opacity: 0.4 };
return { stroke: FLOW_EDGE_COLORS.idle, strokeWidth: 1, opacity: 0.3 };
}
Loading
Loading