mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 06:12:10 +03:00
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:
@@ -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).
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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
|
||||
|
||||
45
tests/unit/home-provider-topology-live-state.test.ts
Normal file
45
tests/unit/home-provider-topology-live-state.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user