From 604397ec0f4b1d84fd9b003d9f0ea680f1a7e9ab Mon Sep 17 00:00:00 2001 From: KooshaPari <42529354+KooshaPari@users.noreply.github.com> Date: Mon, 29 Jun 2026 06:15:00 -0700 Subject: [PATCH] fix service console failures (#5299) Integrated into release/v3.8.41. Service-console fixes + Tailscale CGNAT LAN range; dropped the regressive context/page.tsx stub (release version retained). --- .../components/ServiceLifecycleButtons.tsx | 82 ++++++++++++------- .../services/components/ServiceLogsPanel.tsx | 8 +- .../services/hooks/useServiceLogs.ts | 12 ++- src/app/api/services/9router/install/route.ts | 2 +- src/app/api/services/[name]/logs/route.ts | 19 ++++- .../api/services/cliproxy/install/route.ts | 2 +- .../v1/providers/[provider]/models/route.ts | 15 ++++ src/server/authz/routeGuard.ts | 1 + tests/unit/provider-models-v1-route.test.ts | 23 ++++++ tests/unit/route-guard-private-lan.test.ts | 14 ++++ 10 files changed, 143 insertions(+), 35 deletions(-) diff --git a/src/app/(dashboard)/dashboard/providers/services/components/ServiceLifecycleButtons.tsx b/src/app/(dashboard)/dashboard/providers/services/components/ServiceLifecycleButtons.tsx index fde8a4b4d6..3cf9d09725 100644 --- a/src/app/(dashboard)/dashboard/providers/services/components/ServiceLifecycleButtons.tsx +++ b/src/app/(dashboard)/dashboard/providers/services/components/ServiceLifecycleButtons.tsx @@ -13,6 +13,7 @@ type Action = "start" | "stop" | "restart" | "update" | "install"; export function ServiceLifecycleButtons({ name }: ServiceLifecycleButtonsProps) { const { data, mutate } = useServiceStatus(name); const [pending, setPending] = useState(null); + const [error, setError] = useState(null); const running = data?.state === "running"; const starting = data?.state === "starting"; @@ -21,46 +22,69 @@ export function ServiceLifecycleButtons({ name }: ServiceLifecycleButtonsProps) async function action(verb: Action) { setPending(verb); + setError(null); try { - await fetch(`/api/services/${name}/${verb}`, { method: "POST" }); + const res = await fetch(`/api/services/${name}/${verb}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + if (!res.ok) { + const payload = (await res.json().catch(() => null)) as { + error?: { message?: string }; + } | null; + throw new Error(payload?.error?.message || `HTTP ${res.status}`); + } mutate(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); } finally { setPending(null); } } if (notInstalled) { - return ( - - ); + return ; } return ( -
- - - - +
+
+ + + + +
+ {error &&

{error}

}
); + + function LifecycleButtonGroup({ error }: { error: string | null }) { + return ( +
+ + {error &&

{error}

} +
+ ); + } } diff --git a/src/app/(dashboard)/dashboard/providers/services/components/ServiceLogsPanel.tsx b/src/app/(dashboard)/dashboard/providers/services/components/ServiceLogsPanel.tsx index 556ce6e699..f0f5825a6e 100644 --- a/src/app/(dashboard)/dashboard/providers/services/components/ServiceLogsPanel.tsx +++ b/src/app/(dashboard)/dashboard/providers/services/components/ServiceLogsPanel.tsx @@ -29,7 +29,9 @@ function LogLineRow({ line }: { line: LogLine }) { export function ServiceLogsPanel({ name }: ServiceLogsPanelProps) { const [filterInput, setFilterInput] = useState(""); - const { lines, isPaused, togglePause, clear, setFilter } = useServiceLogs(name, { tail: 200 }); + const { lines, isPaused, error, togglePause, clear, setFilter } = useServiceLogs(name, { + tail: 200, + }); const bottomRef = useRef(null); // Auto-scroll to bottom on new lines unless paused @@ -89,7 +91,9 @@ export function ServiceLogsPanel({ name }: ServiceLogsPanelProps) {
- {lines.length === 0 ? ( + {error ? ( +

{error}

+ ) : lines.length === 0 ? (

No log output yet.

) : ( lines.map((l, i) => ) diff --git a/src/app/(dashboard)/dashboard/providers/services/hooks/useServiceLogs.ts b/src/app/(dashboard)/dashboard/providers/services/hooks/useServiceLogs.ts index ff348a4bdf..5e7c00a3f5 100644 --- a/src/app/(dashboard)/dashboard/providers/services/hooks/useServiceLogs.ts +++ b/src/app/(dashboard)/dashboard/providers/services/hooks/useServiceLogs.ts @@ -16,6 +16,7 @@ interface UseServiceLogsOptions { interface UseServiceLogsResult { lines: LogLine[]; isPaused: boolean; + error: string | null; togglePause: () => void; clear: () => void; setFilter: (filter: string) => void; @@ -29,6 +30,7 @@ export function useServiceLogs( ): UseServiceLogsResult { const [lines, setLines] = useState([]); const [isPaused, setIsPaused] = useState(false); + const [error, setError] = useState(null); const [filter, setFilter] = useState(options.filter ?? ""); const pauseRef = useRef(false); const esRef = useRef(null); @@ -50,11 +52,13 @@ export function useServiceLogs( const url = `/api/services/${name}/logs?${params.toString()}`; const es = new EventSource(url); esRef.current = es; + setError(null); es.addEventListener("snapshot", (e) => { try { const snapshot = JSON.parse(e.data) as LogLine[]; setLines(snapshot.slice(-MAX_LINES)); + setError(null); } catch {} }); @@ -66,14 +70,20 @@ export function useServiceLogs( const next = [...prev, line]; return next.length > MAX_LINES ? next.slice(-MAX_LINES) : next; }); + setError(null); } catch {} }); + es.onerror = () => { + setError(`Unable to stream ${name} logs. Check local access and service status.`); + es.close(); + }; + return () => { es.close(); esRef.current = null; }; }, [name, filter, options.tail]); - return { lines, isPaused, togglePause, clear, setFilter }; + return { lines, isPaused, error, togglePause, clear, setFilter }; } diff --git a/src/app/api/services/9router/install/route.ts b/src/app/api/services/9router/install/route.ts index 7708062153..a2df024eef 100644 --- a/src/app/api/services/9router/install/route.ts +++ b/src/app/api/services/9router/install/route.ts @@ -11,7 +11,7 @@ const BodySchema = z.object({ export async function POST(request: Request): Promise { let body: unknown; try { - body = await request.json(); + body = request.body === null ? {} : await request.json(); } catch { return createErrorResponse({ status: 400, message: "Invalid JSON body" }); } diff --git a/src/app/api/services/[name]/logs/route.ts b/src/app/api/services/[name]/logs/route.ts index fe2602b180..c3c3a93a9a 100644 --- a/src/app/api/services/[name]/logs/route.ts +++ b/src/app/api/services/[name]/logs/route.ts @@ -23,6 +23,23 @@ const MAX_FILTER_LEN = 200; const encoder = new TextEncoder(); +async function getOrInitNamedSupervisor(name: string) { + const existing = getSupervisor(name); + if (existing) return existing; + + if (name === "cliproxy") { + const { getOrInitSupervisor } = await import("../../cliproxy/_lib"); + return getOrInitSupervisor(); + } + + if (name === "9router") { + const { getOrInitSupervisor } = await import("../../9router/_lib"); + return getOrInitSupervisor(); + } + + return null; +} + function sseChunk(event: string, data: unknown): Uint8Array { return encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } @@ -30,7 +47,7 @@ function sseChunk(event: string, data: unknown): Uint8Array { export async function GET(request: NextRequest, { params }: { params: Promise<{ name: string }> }) { const { name } = await params; - const supervisor = getSupervisor(name); + const supervisor = await getOrInitNamedSupervisor(name); if (!supervisor) { return createErrorResponse({ status: 404, message: `Service '${name}' not found` }); } diff --git a/src/app/api/services/cliproxy/install/route.ts b/src/app/api/services/cliproxy/install/route.ts index 765323d2de..591489cc24 100644 --- a/src/app/api/services/cliproxy/install/route.ts +++ b/src/app/api/services/cliproxy/install/route.ts @@ -11,7 +11,7 @@ const BodySchema = z.object({ export async function POST(request: Request): Promise { let body: unknown; try { - body = await request.json(); + body = request.body === null ? {} : await request.json(); } catch { return createErrorResponse({ status: 400, message: "Invalid JSON body" }); } diff --git a/src/app/api/v1/providers/[provider]/models/route.ts b/src/app/api/v1/providers/[provider]/models/route.ts index 6dd209c7f9..0accaf2ac4 100644 --- a/src/app/api/v1/providers/[provider]/models/route.ts +++ b/src/app/api/v1/providers/[provider]/models/route.ts @@ -1,4 +1,5 @@ import { getUnifiedModelsResponse } from "@/app/api/v1/models/catalog"; +import { getServiceModels } from "@/lib/db/serviceModels"; import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts"; /** @@ -19,6 +20,20 @@ export async function OPTIONS() { */ export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) { const { provider: rawProvider } = await params; + if (rawProvider === "cliproxyapi" || rawProvider === "9router") { + const models = getServiceModels(rawProvider).filter((model) => model.available !== false); + return Response.json({ + object: "list", + data: models.map((model) => ({ + object: model.object || "model", + owned_by: rawProvider, + ...model, + id: model.id, + parent: null, + })), + }); + } + const providerEntry = getRegistryEntry(rawProvider); let providerId = rawProvider; let providerAlias = rawProvider; diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index e04daccf1f..10dc695e8d 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -139,6 +139,7 @@ export function classifyHostLocality(ip: string | null): "loopback" | "lan" | "r */ const PRIVATE_LAN_PATTERNS: ReadonlyArray = [ /^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, + /^100\.(6[4-9]|[78]\d|9\d|1[01]\d|12[0-7])\.\d{1,3}\.\d{1,3}$/, /^192\.168\.\d{1,3}\.\d{1,3}$/, /^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/, /^f[cd][0-9a-f]{2}:/i, // IPv6 ULA fc00::/7 diff --git a/tests/unit/provider-models-v1-route.test.ts b/tests/unit/provider-models-v1-route.test.ts index 2acd4b721e..4e92af04cf 100644 --- a/tests/unit/provider-models-v1-route.test.ts +++ b/tests/unit/provider-models-v1-route.test.ts @@ -15,6 +15,7 @@ const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-v1-provid process.env.DATA_DIR = TEST_DATA_DIR; const core = await import("../../src/lib/db/core.ts"); +const serviceModelsDb = await import("../../src/lib/db/serviceModels.ts"); const routeModule = await import( "../../src/app/api/v1/providers/[provider]/models/route.ts" ); @@ -71,6 +72,28 @@ test("GET /v1/providers/:provider/models accepts anthropic-compatible connection } }); +test("GET /v1/providers/:provider/models returns synced embedded service models", async () => { + serviceModelsDb.saveServiceModels("cliproxyapi", [ + { id: "cli/gpt-5", name: "GPT-5 via CLIProxyAPI" }, + { id: "old-model" }, + ]); + serviceModelsDb.saveServiceModels("cliproxyapi", [ + { id: "cli/gpt-5", name: "GPT-5 via CLIProxyAPI" }, + ]); + + const res = await callGET("cliproxyapi"); + const body = await res.json(); + + assert.equal(res.status, 200); + assert.equal(body.object, "list"); + assert.deepEqual( + body.data.map((model: any) => model.id), + ["cli/gpt-5"] + ); + assert.equal(body.data[0].owned_by, "cliproxyapi"); + assert.equal(body.data[0].parent, null); +}); + test("GET /v1/providers/:provider/models rejects non-matching connection-like strings", async () => { // Looks like a connection ID but with wrong prefix const res = await callGET("custom-compatible-chat-a1b2c3d4-e5f6-7890-abcd-ef1234567890"); diff --git a/tests/unit/route-guard-private-lan.test.ts b/tests/unit/route-guard-private-lan.test.ts index fd2fc97d51..ba9a9f3036 100644 --- a/tests/unit/route-guard-private-lan.test.ts +++ b/tests/unit/route-guard-private-lan.test.ts @@ -23,6 +23,18 @@ test("isPrivateLanHost: accepts RFC1918 IPv4 (incl. :port and ::ffff: mapped)", } }); +test("isPrivateLanHost: accepts Tailscale CGNAT IPv4 range", () => { + for (const h of [ + "100.64.0.1", + "100.96.135.160", + "100.127.255.254", + "100.96.135.160:20128", + "::ffff:100.96.135.160", + ]) { + assert.equal(isPrivateLanHost(h), true, `expected Tailscale LAN: ${h}`); + } +}); + test("isPrivateLanHost: accepts IPv6 ULA / link-local", () => { assert.equal(isPrivateLanHost("fd12:3456::1"), true); assert.equal(isPrivateLanHost("fe80::1"), true); @@ -32,6 +44,8 @@ test("isPrivateLanHost: rejects public IPs, loopback and junk", () => { for (const h of [ "8.8.8.8", "69.164.221.35", // public VPS + "100.63.255.255", // just outside Tailscale 100.64/10 + "100.128.0.1", // just outside Tailscale 100.64/10 "172.32.0.1", // just outside 172.16/12 "127.0.0.1", "::1",