Spaces:
Runtime error
Runtime error
File size: 5,268 Bytes
cd8bd0a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 | /**
* CLI Log Stream API β GET /api/cli-tools/logs
*
* Reads the application log file and returns matching entries.
* Called by the CLI `omniroute logs` command via
* `src/lib/cli-helper/log-streamer.ts`.
*
* Query params:
* - follow: boolean β kept for forward-compat; ignored in this
* implementation (streaming follow-mode is handled client-side
* by the log-streamer's ReadableStream).
* - filter: comma-separated strings β entries whose `component`,
* `module`, or `msg` fields match ANY token are included.
* Case-insensitive substring match.
* - limit: max number of entries to return β default 500, max 2000.
*
* Auth: Tier 3 MANAGEMENT β requireCliToolsAuth (same shared guard as all
* other /api/cli-tools/* routes).
*
* Note: this route reads logs only and spawns no child processes,
* so it does NOT require isLocalOnlyPath() classification.
*/
import { NextRequest, NextResponse } from "next/server";
import { readFileSync, existsSync } from "fs";
import { getAppLogFilePath } from "@/lib/logEnv";
import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
// Map pino numeric levels to string levels
const NUMERIC_LEVEL_MAP: Record<number, string> = {
10: "trace",
20: "debug",
30: "info",
40: "warn",
50: "error",
60: "fatal",
};
function parseLevel(raw: string | number): string {
if (typeof raw === "number") {
return NUMERIC_LEVEL_MAP[raw] || "info";
}
return String(raw).toLowerCase();
}
function stringifyLogValue(value: unknown): string {
if (value === undefined || value === null) return "";
if (typeof value === "string") return value;
if (value instanceof Error) return value.message || value.name;
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
try {
const json = JSON.stringify(value);
return typeof json === "string" ? json : String(value);
} catch {
return String(value);
}
}
/**
* GET /api/cli-tools/logs
*/
export async function GET(request: NextRequest) {
const authError = await requireCliToolsAuth(request);
if (authError) return authError;
try {
const { searchParams } = new URL(request.url);
// `follow` is accepted for forward-compat but not used server-side;
// the CLI's ReadableStream already handles reconnection client-side.
const _follow = searchParams.get("follow") === "true";
// Comma-separated filter tokens (e.g. "router,oauth")
const filterRaw = searchParams.get("filter") || "";
const filterTokens = filterRaw
.split(",")
.map((t) => t.trim().toLowerCase())
.filter(Boolean);
const rawLimit = parseInt(searchParams.get("limit") || "500", 10);
const limit = Math.min(Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : 500, 2000);
const logPath = getAppLogFilePath();
if (!existsSync(logPath)) {
return NextResponse.json([], { status: 200 });
}
const raw = readFileSync(logPath, "utf-8");
const lines = raw.trim().split("\n").filter(Boolean);
const oneHourAgo = Date.now() - 60 * 60 * 1000;
const entries: Record<string, unknown>[] = [];
for (const line of lines) {
try {
const entry = JSON.parse(line) as Record<string, unknown>;
// Filter by time (last 1 hour)
const ts = entry.time || entry.timestamp;
if (ts) {
const entryTime = new Date(ts as string | number).getTime();
if (entryTime < oneHourAgo) continue;
}
// Normalize fields
entry.level = parseLevel(entry.level as string | number);
entry.msg = stringifyLogValue(entry.msg ?? entry.message ?? "");
entry.message = stringifyLogValue(entry.message ?? entry.msg);
if (entry.component !== undefined) entry.component = stringifyLogValue(entry.component);
if (entry.module !== undefined) entry.module = stringifyLogValue(entry.module);
// Normalize timestamp field
if (entry.time && !entry.timestamp) {
entry.timestamp = entry.time;
}
// Apply filter tokens β entry is included if ANY token matches
// component, module, or msg (case-insensitive substring)
if (filterTokens.length > 0) {
const haystack = [
String(entry.component || ""),
String(entry.module || ""),
String(entry.msg || ""),
]
.join(" ")
.toLowerCase();
const matches = filterTokens.some((token) => haystack.includes(token));
if (!matches) continue;
}
entries.push(entry);
} catch {
// Skip unparseable lines
}
}
// Return last N entries (most recent)
const result = entries.slice(-limit);
return NextResponse.json(result, {
status: 200,
headers: {
"Cache-Control": "no-store, no-cache, must-revalidate",
},
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return NextResponse.json(
{ error: sanitizeErrorMessage(message) || "Failed to read logs" },
{ status: 500 }
);
}
}
|