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).
This commit is contained in:
KooshaPari
2026-06-29 06:15:00 -07:00
committed by GitHub
parent e0bd7a519f
commit 604397ec0f
10 changed files with 143 additions and 35 deletions

View File

@@ -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<Action | null>(null);
const [error, setError] = useState<string | null>(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 (
<Button size="sm" disabled={busy} onClick={() => action("install")}>
{pending === "install" ? "Installing…" : "Install"}
</Button>
);
return <LifecycleButtonGroup error={error} />;
}
return (
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={busy || running} onClick={() => action("start")}>
{pending === "start" ? "Starting…" : "Start"}
</Button>
<Button
size="sm"
variant="outline"
disabled={busy || !running}
onClick={() => action("stop")}
>
{pending === "stop" ? "Stopping…" : "Stop"}
</Button>
<Button
size="sm"
variant="outline"
disabled={busy || !running}
onClick={() => action("restart")}
>
{pending === "restart" ? "Restarting…" : "Restart"}
</Button>
<Button size="sm" variant="outline" disabled={busy} onClick={() => action("update")}>
{pending === "update" ? "Updating…" : "Update"}
</Button>
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={busy || running} onClick={() => action("start")}>
{pending === "start" ? "Starting…" : "Start"}
</Button>
<Button
size="sm"
variant="outline"
disabled={busy || !running}
onClick={() => action("stop")}
>
{pending === "stop" ? "Stopping…" : "Stop"}
</Button>
<Button
size="sm"
variant="outline"
disabled={busy || !running}
onClick={() => action("restart")}
>
{pending === "restart" ? "Restarting…" : "Restart"}
</Button>
<Button size="sm" variant="outline" disabled={busy} onClick={() => action("update")}>
{pending === "update" ? "Updating…" : "Update"}
</Button>
</div>
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
</div>
);
function LifecycleButtonGroup({ error }: { error: string | null }) {
return (
<div className="space-y-2">
<Button size="sm" disabled={busy} onClick={() => action("install")}>
{pending === "install" ? "Installing…" : "Install"}
</Button>
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
</div>
);
}
}

View File

@@ -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<HTMLDivElement>(null);
// Auto-scroll to bottom on new lines unless paused
@@ -89,7 +91,9 @@ export function ServiceLogsPanel({ name }: ServiceLogsPanelProps) {
</button>
</div>
<div className="h-80 overflow-y-auto bg-bg-main py-1">
{lines.length === 0 ? (
{error ? (
<p className="text-xs text-red-600 dark:text-red-400 px-4 py-4">{error}</p>
) : lines.length === 0 ? (
<p className="text-xs text-text-muted px-4 py-4">No log output yet.</p>
) : (
lines.map((l, i) => <LogLineRow key={i} line={l} />)

View File

@@ -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<LogLine[]>([]);
const [isPaused, setIsPaused] = useState(false);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState(options.filter ?? "");
const pauseRef = useRef(false);
const esRef = useRef<EventSource | null>(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 };
}

View File

@@ -11,7 +11,7 @@ const BodySchema = z.object({
export async function POST(request: Request): Promise<Response> {
let body: unknown;
try {
body = await request.json();
body = request.body === null ? {} : await request.json();
} catch {
return createErrorResponse({ status: 400, message: "Invalid JSON body" });
}

View File

@@ -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` });
}

View File

@@ -11,7 +11,7 @@ const BodySchema = z.object({
export async function POST(request: Request): Promise<Response> {
let body: unknown;
try {
body = await request.json();
body = request.body === null ? {} : await request.json();
} catch {
return createErrorResponse({ status: 400, message: "Invalid JSON body" });
}

View File

@@ -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;

View File

@@ -139,6 +139,7 @@ export function classifyHostLocality(ip: string | null): "loopback" | "lan" | "r
*/
const PRIVATE_LAN_PATTERNS: ReadonlyArray<RegExp> = [
/^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

View File

@@ -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");

View File

@@ -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",