mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />)
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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` });
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user