diff --git a/web/app/api/services/[id]/logs/route.ts b/web/app/api/services/[id]/logs/route.ts index f4d3f314..99a620d7 100644 --- a/web/app/api/services/[id]/logs/route.ts +++ b/web/app/api/services/[id]/logs/route.ts @@ -5,8 +5,16 @@ import { isLoggingEnabled, type LogType, queryLogsByService, + type StoredLog, } from "@/lib/victoria-logs"; +function streamOf(log: StoredLog): string { + if (log.stream) return log.stream; + if (log.log_type === "http") return "http"; + if (log.log_type === "cron") return "cron"; + return "stdout"; +} + export async function GET( request: Request, { params }: { params: Promise<{ id: string }> }, @@ -36,7 +44,8 @@ export async function GET( const logType = logTypeParam === "container" || logTypeParam === "http" || - logTypeParam === "cron" + logTypeParam === "cron" || + logTypeParam === "container-cron" ? (logTypeParam as LogType) : undefined; @@ -49,9 +58,11 @@ export async function GET( }); const logs = result.logs.map((log) => ({ - id: `${log.deployment_id || log.service_id}-${log._time}`, + id: [log.deployment_id || log.service_id, log.cron_id, log._time] + .filter(Boolean) + .join("-"), deploymentId: log.deployment_id, - stream: log.stream || (log.log_type === "http" ? "http" : "stdout"), + stream: streamOf(log), message: log._msg, timestamp: log._time, logType: log.log_type || "container", @@ -60,6 +71,7 @@ export async function GET( path: log.path, duration: log.duration_ms, clientIp: log.client_ip, + error: log.error, })); return Response.json({ diff --git a/web/components/logs/log-viewer.tsx b/web/components/logs/log-viewer.tsx index 68af3c10..5b6cb61f 100644 --- a/web/components/logs/log-viewer.tsx +++ b/web/components/logs/log-viewer.tsx @@ -53,8 +53,12 @@ interface BaseEntry { interface ServiceLogEntry extends BaseEntry { id: string; deploymentId?: string; - stream: "stdout" | "stderr"; + stream: "stdout" | "stderr" | "cron"; message: string; + path?: string; + status?: number | null; + duration?: number | null; + error?: string | null; } interface RequestEntry extends BaseEntry { @@ -194,7 +198,7 @@ function buildLogEndpoint( case "service-logs": path = `/api/services/${props.serviceId}/logs`; params.set("limit", "500"); - params.set("type", "container"); + params.set("type", "container-cron"); if (filterServerId) params.set("serverId", filterServerId); break; case "requests": @@ -271,6 +275,8 @@ function ServiceLogsFilters({ onShowStdoutChange, showStderr, onShowStderrChange, + showCron, + onShowCronChange, }: { levels: Set; onLevelsChange: (levels: Set) => void; @@ -278,6 +284,8 @@ function ServiceLogsFilters({ onShowStdoutChange: (show: boolean) => void; showStderr: boolean; onShowStderrChange: (show: boolean) => void; + showCron: boolean; + onShowCronChange: (show: boolean) => void; }) { const toggleLevel = (level: LogLevel) => { const newLevels = new Set(levels); @@ -360,6 +368,13 @@ function ServiceLogsFilters({ > stderr + ); @@ -544,6 +559,16 @@ function ServerFilter({ ); } +function formatCronMessage(entry: ServiceLogEntry): string { + const parts = [entry.message]; + if (entry.path) parts.push(entry.path); + if (entry.status != null) parts.push(String(entry.status)); + if (entry.duration != null) + parts.push(`${Math.round(Number(entry.duration) || 0)}ms`); + if (entry.error) parts.push(entry.error); + return parts.join(" ยท "); +} + function ServiceLogRow({ entry, search, @@ -551,6 +576,9 @@ function ServiceLogRow({ entry: ServiceLogEntry; search: string; }) { + if (entry.stream === "cron") + return ; + const level = detectLevel(entry.message); return ( @@ -592,6 +620,41 @@ function ServiceLogRow({ ); } +function CronLogRow({ + entry, + search, +}: { + entry: ServiceLogEntry; + search: string; +}) { + const failed = !!entry.error; + + return ( +
+
+ + {formatTime(entry.timestamp)} + + + cron + +
+ + {highlightMatches(formatCronMessage(entry), search)} + +
+ ); +} + function RequestRow({ entry, search, @@ -697,7 +760,9 @@ function serializeLogs( .map((log) => { if (variant === "service-logs") { const entry = log as ServiceLogEntry; - return `[${entry.timestamp}] [${entry.stream}] ${entry.message}`; + const message = + entry.stream === "cron" ? formatCronMessage(entry) : entry.message; + return `[${entry.timestamp}] [${entry.stream}] ${message}`; } if (variant === "requests") { const entry = log as RequestEntry; @@ -754,6 +819,10 @@ export function LogViewer(props: LogViewerProps) { "stderr", parseAsBoolean.withDefault(true), ); + const [showCron, setShowCron] = useQueryState( + "cron", + parseAsBoolean.withDefault(true), + ); const [statusParam, setStatusParam] = useQueryState( "status", @@ -931,6 +1000,7 @@ export function LogViewer(props: LogViewerProps) { const entry = log as ServiceLogEntry; if (entry.stream === "stdout" && !showStdout) return false; if (entry.stream === "stderr" && !showStderr) return false; + if (entry.stream === "cron") return showCron; const level = detectLevel(entry.message); if (level && !levels.has(level)) return false; @@ -945,7 +1015,15 @@ export function LogViewer(props: LogViewerProps) { return true; }); - }, [logs, props.variant, levels, showStdout, showStderr, statusFilter]); + }, [ + logs, + props.variant, + levels, + showStdout, + showStderr, + showCron, + statusFilter, + ]); const logCount = logs.length; const filteredLogCount = filteredLogs.length; const newestFilteredLogTimestamp = ( @@ -1083,6 +1161,8 @@ export function LogViewer(props: LogViewerProps) { onShowStdoutChange={setShowStdout} showStderr={showStderr} onShowStderrChange={setShowStderr} + showCron={showCron} + onShowCronChange={setShowCron} /> )} diff --git a/web/lib/victoria-logs.ts b/web/lib/victoria-logs.ts index 5e4111f3..540332c8 100644 --- a/web/lib/victoria-logs.ts +++ b/web/lib/victoria-logs.ts @@ -20,8 +20,14 @@ function getQueryEndpoint(): EndpointConfig | undefined { return parseEndpoint(endpoint); } -export type LogType = "container" | "http" | "cron"; -type LogSearchField = "_msg" | "path" | "method" | "status" | "client_ip"; +export type LogType = "container" | "http" | "cron" | "container-cron"; +type LogSearchField = + | "_msg" + | "path" + | "method" + | "status" + | "client_ip" + | "error"; export type StoredLog = { _msg: string; @@ -35,10 +41,12 @@ export type StoredLog = { host?: string; method?: string; path?: string; - status?: number; + status?: number | null; duration_ms?: number; size?: number; client_ip?: string; + cron_id?: string; + error?: string | null; }; const publicServiceLogEventIdPattern = /^e[0-9]{19}[a-z]{26}$/; @@ -109,6 +117,16 @@ type PublicServiceLogsOptions = Omit< cursor?: PublicServiceLogCursor; }; +function serviceLogSearchFields( + logType: LogType | undefined, +): readonly LogSearchField[] { + if (logType === "http") + return ["_msg", "path", "method", "status", "client_ip"]; + if (logType === "cron" || logType === "container-cron") + return ["_msg", "path", "status", "error"]; + return ["_msg"]; +} + function buildServiceLogFilter(options: QueryLogsByServiceOptions): string { const { serviceId, logType, serverId, search, range } = options; let query = formatLogSqlExactFilter("service_id", serviceId); @@ -118,6 +136,8 @@ function buildServiceLogFilter(options: QueryLogsByServiceOptions): string { query += ` log_type:cron`; } else if (logType === "container") { query += ` -log_type:http -log_type:build -log_type:rollout -log_type:cron`; + } else if (logType === "container-cron") { + query += ` -log_type:http -log_type:build -log_type:rollout`; } else { query += ` -log_type:build -log_type:rollout`; } @@ -129,9 +149,7 @@ function buildServiceLogFilter(options: QueryLogsByServiceOptions): string { } const searchFilter = formatLogSqlSearchFilter( search, - logType === "http" - ? ["_msg", "path", "method", "status", "client_ip"] - : ["_msg"], + serviceLogSearchFields(logType), ); if (searchFilter) { query += ` ${searchFilter}`; diff --git a/web/tests/victoria-logs.test.ts b/web/tests/victoria-logs.test.ts index fee38e43..560fc1a8 100644 --- a/web/tests/victoria-logs.test.ts +++ b/web/tests/victoria-logs.test.ts @@ -387,6 +387,29 @@ describe("VictoriaLogs queries", () => { expect(queries[2]).toContain("-log_type:cron"); }); + it("keeps cron logs and drops HTTP logs for the combined service log filter", async () => { + const { queryLogsByService } = await loadVictoriaLogs(); + const queries: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + queries.push(new URL(String(input)).searchParams.get("query") || ""); + return jsonLinesResponse([]); + }), + ); + await queryLogsByService({ + serviceId: "service-1", + limit: 1, + logType: "container-cron", + search: "500", + }); + expect(queries[0]).toContain("-log_type:http"); + expect(queries[0]).not.toContain("-log_type:cron"); + for (const field of ["_msg", "path", "status", "error"]) { + expect(queries[0]).toContain(`${field}:~`); + } + }); + it("ingests only supplied cron metadata with a five-second deadline", async () => { const { ingestCronLog } = await loadVictoriaLogs(); const fetchMock = vi.fn(