fix(dashboard): trust provider topology live state (#6322)

Trust live provider-topology state on the Home dashboard. Integrated into release/v3.8.46.
This commit is contained in:
Xiangzhe
2026-07-07 03:25:05 +08:00
committed by GitHub
parent 0785cd2e55
commit 8853b257aa
4 changed files with 54 additions and 58 deletions

View File

@@ -20,6 +20,7 @@
### 🐛 Bug Fixes
- **fix(dashboard):** the Home provider-topology widget now trusts the live provider-metrics snapshot — it uses `topology.errorProvider` and live `activeRequests` directly instead of re-deriving state from a stale `lastErrorAt` or applying a frontend timeout filter, so the topology reflects real-time provider health. Regression guard: `tests/unit/home-provider-topology-live-state.test.ts`. (thanks @xz-dev)
- **fix(sse):** strip zero-width markers from streamed **tool-call arguments** — a follow-up to [#5857](https://github.com/diegosouzapw/OmniRoute/pull/5857). That PR removed injected zero-width joiners (U+200D) from streamed assistant text/reasoning but deliberately left tool-call argument JSON byte-exact. The request-side obfuscation (`open-sse/services/claudeCodeObfuscation.ts`) injects ZWJ into agent words — including the temp path inside the Bash tool description — and Claude models copy that verbatim into generated commands, which are delivered as tool-call arguments rather than assistant text. As a result the ZWJ survived and corrupted code blocks (e.g. a temp path rendered with an invisible joiner). Now `open-sse/handlers/responseSanitizer.ts` strips zero-width code points from tool-call argument strings at every emit site (OpenAI non-stream/stream chat `tool_calls` + legacy `function_call`, native Responses `function_call` items, the OpenAI→Responses conversion, and the native Responses streaming `response.function_call_arguments.delta/.done` events). Only zero-width code points are removed; JSON structure and all other bytes stay identical (no parse/restringify), so normal arguments remain byte-exact. Regression guard: 6 new cases in `tests/unit/response-sanitizer.test.ts` (suite 50/50).
- **fix(nodejs):** the default app log path now resolves under `DATA_DIR` (`~/.omniroute/logs/application/app.log`) instead of `process.cwd()` ([#6197](https://github.com/diegosouzapw/OmniRoute/issues/6197)) — the globally-installed CLI runs from an arbitrary working directory, so anchoring the default to cwd made file logging silently write to (or no-op under) an unrelated directory, contradicting the documented `.env.example` default. `getAppLogFilePath()` now computes the default lazily via the pure `resolveDataDir()` resolver (honours a per-process `DATA_DIR`, no directory-creation side effect); an explicit `APP_LOG_FILE_PATH` still wins. Regression guard: `tests/unit/logenv-datadir-path-6197.test.ts` (3).

View File

@@ -112,6 +112,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
const [baseUrl, setBaseUrl] = useState("/v1");
const [selectedProvider, setSelectedProvider] = useState(null);
const [providerMetrics, setProviderMetrics] = useState<Record<string, ProviderMetricSummary>>({});
const [providerTopology, setProviderTopology] = useState({ lastProvider: "", errorProvider: "" });
const [providerNodes, setProviderNodes] = useState<
Array<{ id?: string; prefix?: string; name?: string }>
>([]);
@@ -304,6 +305,10 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
const data = await metricsRes.json();
if (!cancelled) {
setProviderMetrics(data.metrics || {});
setProviderTopology({
lastProvider: normalizeProviderId(data.topology?.lastProvider),
errorProvider: normalizeProviderId(data.topology?.errorProvider),
});
}
}
} catch (error) {
@@ -494,28 +499,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
return Array.from(byProvider.values());
}, [providerStats, providerMetrics, providerNodes]);
const { lastProvider, errorProvider } = useMemo(() => {
let recentProvider = "";
let recentTimestamp = 0;
let recentErrorProvider = "";
let recentErrorTimestamp = 0;
for (const [provider, metrics] of Object.entries(providerMetrics)) {
const requestTimestamp = metrics.lastRequestAt ? Date.parse(metrics.lastRequestAt) : 0;
if (Number.isFinite(requestTimestamp) && requestTimestamp > recentTimestamp) {
recentProvider = normalizeProviderId(provider);
recentTimestamp = requestTimestamp;
}
const errorTimestamp = metrics.lastErrorAt ? Date.parse(metrics.lastErrorAt) : 0;
if (Number.isFinite(errorTimestamp) && errorTimestamp > recentErrorTimestamp) {
recentErrorProvider = normalizeProviderId(provider);
recentErrorTimestamp = errorTimestamp;
}
}
return { lastProvider: recentProvider, errorProvider: recentErrorProvider };
}, [providerMetrics]);
const { lastProvider, errorProvider } = providerTopology;
const pollBackgroundUpdate = useCallback(
async ({

View File

@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState, useEffect, useRef } from "react";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { Handle, Position, type Node, type Edge, type NodeTypes } from "@xyflow/react";
import { AI_PROVIDERS } from "@/shared/constants/providers";
@@ -10,9 +10,6 @@ import { StatusDot } from "@/shared/components/flow/StatusDot";
import { edgeStyle } from "@/shared/components/flow/edgeStyles";
import { resolveTopologyNodeLabel } from "./topologyLabel";
const FE_ACTIVE_TIMEOUT_MS = 60_000;
const FE_ACTIVE_TICK_MS = 1_000;
// Rings: [capacity, rx, ry]. Each successive ring fits ~6 more nodes.
const RINGS: [number, number, number][] = [
[8, 210, 132],
@@ -271,44 +268,13 @@ export default function ProviderTopology({
const lastKey = lastProvider.toLowerCase();
const errorKey = errorProvider.toLowerCase();
const rawActiveSet = useMemo(
const activeSet = useMemo(
() => new Set<string>(activeKey ? activeKey.split(",") : []),
[activeKey]
);
const lastSet = useMemo(() => new Set<string>(lastKey ? [lastKey] : []), [lastKey]);
const errorSet = useMemo(() => new Set<string>(errorKey ? [errorKey] : []), [errorKey]);
const firstSeenRef = useRef<Record<string, number>>({});
const [tick, setTick] = useState(0);
useEffect(() => {
const seen = firstSeenRef.current;
const now = Date.now();
for (const p of rawActiveSet) {
if (!seen[p]) seen[p] = now;
}
for (const p of Object.keys(seen)) {
if (!rawActiveSet.has(p)) delete seen[p];
}
}, [rawActiveSet]);
useEffect(() => {
if (rawActiveSet.size === 0) return;
const id = setInterval(() => setTick((t) => t + 1), FE_ACTIVE_TICK_MS);
return () => clearInterval(id);
}, [rawActiveSet]);
const activeSet = useMemo(() => {
const now = Date.now();
const filtered = new Set<string>();
for (const p of rawActiveSet) {
const ts = firstSeenRef.current[p];
if (!ts || now - ts < FE_ACTIVE_TIMEOUT_MS) filtered.add(p);
}
return filtered;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rawActiveSet, tick]);
const { nodes, edges } = useMemo(
() => buildLayout(providers, activeSet, lastSet, errorSet),
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@@ -0,0 +1,45 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
const homePageClientSrc = readFileSync(
fileURLToPath(new URL("../../src/app/(dashboard)/dashboard/HomePageClient.tsx", import.meta.url)),
"utf8"
);
const providerTopologySrc = readFileSync(
fileURLToPath(new URL("../../src/app/(dashboard)/home/ProviderTopology.tsx", import.meta.url)),
"utf8"
);
test("home topology uses provider-metrics topology.errorProvider instead of re-deriving from stale lastErrorAt", () => {
assert.match(
homePageClientSrc,
/errorProvider:\s*normalizeProviderId\(data\.topology\?\.errorProvider\)/,
"HomePageClient should trust /api/provider-metrics topology.errorProvider"
);
const localTopologyDerivation = homePageClientSrc.match(
/const \{ lastProvider, errorProvider \} = useMemo[\s\S]*?\}, \[providerMetrics\]\);/
);
assert.equal(
localTopologyDerivation,
null,
"HomePageClient must not re-derive topology error state from providerMetrics.lastErrorAt"
);
});
test("ProviderTopology treats live activeRequests as the current snapshot without frontend timeout filtering", () => {
assert.doesNotMatch(
providerTopologySrc,
/FE_ACTIVE_TIMEOUT_MS|FE_ACTIVE_TICK_MS|firstSeenRef|setInterval\(/,
"ProviderTopology must not expire long-running live requests on its own"
);
assert.match(
providerTopologySrc,
/const activeSet = useMemo\(\s*\(\) => new Set<string>\(activeKey \? activeKey\.split\(","\) : \[\]\),\s*\[activeKey\]\s*\);/,
"activeSet should be derived directly from activeRequests/current live snapshot"
);
});