diff --git a/docs/infrastructure/logging.mdx b/docs/infrastructure/logging.mdx index 911752f5..0f060c3c 100644 --- a/docs/infrastructure/logging.mdx +++ b/docs/infrastructure/logging.mdx @@ -40,3 +40,5 @@ The control plane exposes logs at `https://logs.` with basic auth. ## Accessing Logs Logs are accessible from the web UI for each service, deployment, build, and server. The control plane queries Victoria Logs using LogSQL with filters for `service_id`, `deployment_id`, `server_id`, and time ranges. + +Searches run against Victoria Logs rather than only the entries currently loaded in the browser. Continuous service, request, and server log views default to the last 24 hours and support 1-hour, 6-hour, 24-hour, and 7-day ranges. These query ranges do not change the separate `VL_RETENTION` storage setting. diff --git a/web/app/api/builds/[buildId]/logs/route.ts b/web/app/api/builds/[buildId]/logs/route.ts index a35ceeaf..eb68ca32 100644 --- a/web/app/api/builds/[buildId]/logs/route.ts +++ b/web/app/api/builds/[buildId]/logs/route.ts @@ -1,4 +1,5 @@ -import { NextRequest, NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; +import { invalidLogQueryResponse, normalizeLogSearch } from "@/lib/log-query"; import { isLoggingEnabled, queryLogsByBuild } from "@/lib/victoria-logs"; export async function GET( @@ -6,13 +7,19 @@ export async function GET( { params }: { params: Promise<{ buildId: string }> }, ) { const { buildId } = await params; + let search: string | undefined; + try { + search = normalizeLogSearch(request.nextUrl.searchParams.get("q")); + } catch (error) { + return invalidLogQueryResponse(error); + } if (!isLoggingEnabled()) { return NextResponse.json({ logs: [] }); } try { - const { logs: rawLogs } = await queryLogsByBuild(buildId); + const { logs: rawLogs } = await queryLogsByBuild(buildId, { search }); const logs = rawLogs.map((log) => ({ timestamp: log._time, @@ -22,6 +29,9 @@ export async function GET( return NextResponse.json({ logs }); } catch (error) { console.error("Failed to fetch build logs:", error); - return NextResponse.json({ logs: [] }); + return NextResponse.json( + { message: "Failed to query build logs" }, + { status: 502 }, + ); } } diff --git a/web/app/api/deployments/[id]/logs/route.ts b/web/app/api/deployments/[id]/logs/route.ts index cafcfc55..f7c7c328 100644 --- a/web/app/api/deployments/[id]/logs/route.ts +++ b/web/app/api/deployments/[id]/logs/route.ts @@ -1,5 +1,10 @@ -import { auth } from "@/lib/auth"; import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { + invalidLogQueryResponse, + normalizeLogCursor, + parseLogLimit, +} from "@/lib/log-query"; import { isLoggingEnabled, queryLogsByDeployment } from "@/lib/victoria-logs"; export async function GET( @@ -20,11 +25,14 @@ export async function GET( const { id: deploymentId } = await params; const url = new URL(request.url); - const limit = Math.min( - Number.parseInt(url.searchParams.get("limit") || "100", 10), - 1000, - ); - const after = url.searchParams.get("after") || undefined; + let limit: number; + let after: string | undefined; + try { + limit = parseLogLimit(url.searchParams.get("limit"), 100); + after = normalizeLogCursor(url.searchParams.get("after")); + } catch (error) { + return invalidLogQueryResponse(error); + } try { const result = await queryLogsByDeployment(deploymentId, limit, after); @@ -43,6 +51,9 @@ export async function GET( }); } catch (error) { console.error("[logs:deployment] failed to query logs:", error); - return Response.json({ logs: [], hasMore: false }); + return Response.json( + { message: "Failed to query deployment logs" }, + { status: 502 }, + ); } } diff --git a/web/app/api/rollouts/[rolloutId]/logs/route.ts b/web/app/api/rollouts/[rolloutId]/logs/route.ts index 68a4050a..664c12bf 100644 --- a/web/app/api/rollouts/[rolloutId]/logs/route.ts +++ b/web/app/api/rollouts/[rolloutId]/logs/route.ts @@ -1,4 +1,5 @@ -import { NextRequest, NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; +import { invalidLogQueryResponse, normalizeLogSearch } from "@/lib/log-query"; import { isLoggingEnabled, queryLogsByRollout } from "@/lib/victoria-logs"; export async function GET( @@ -6,13 +7,19 @@ export async function GET( { params }: { params: Promise<{ rolloutId: string }> }, ) { const { rolloutId } = await params; + let search: string | undefined; + try { + search = normalizeLogSearch(request.nextUrl.searchParams.get("q")); + } catch (error) { + return invalidLogQueryResponse(error); + } if (!isLoggingEnabled()) { return NextResponse.json({ logs: [] }); } try { - const { logs: rawLogs } = await queryLogsByRollout(rolloutId); + const { logs: rawLogs } = await queryLogsByRollout(rolloutId, { search }); const logs = rawLogs.map((log) => ({ timestamp: log._time, @@ -23,6 +30,9 @@ export async function GET( return NextResponse.json({ logs }); } catch (error) { console.error("Failed to fetch rollout logs:", error); - return NextResponse.json({ logs: [] }); + return NextResponse.json( + { message: "Failed to query rollout logs" }, + { status: 502 }, + ); } } diff --git a/web/app/api/servers/[id]/logs/route.ts b/web/app/api/servers/[id]/logs/route.ts index 4bbe8b97..a3f59d16 100644 --- a/web/app/api/servers/[id]/logs/route.ts +++ b/web/app/api/servers/[id]/logs/route.ts @@ -1,5 +1,6 @@ -import { auth } from "@/lib/auth"; import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { invalidLogQueryResponse, parseLogListParams } from "@/lib/log-query"; import { isLoggingEnabled, queryLogsByServer } from "@/lib/victoria-logs"; export async function GET( @@ -20,14 +21,18 @@ export async function GET( const { id: serverId } = await params; const url = new URL(request.url); - const limit = Math.min( - Number.parseInt(url.searchParams.get("limit") || "500", 10), - 1000, - ); - const before = url.searchParams.get("before") || undefined; + let queryParams: ReturnType; + try { + queryParams = parseLogListParams(url.searchParams, 500); + } catch (error) { + return invalidLogQueryResponse(error); + } try { - const result = await queryLogsByServer(serverId, limit, before); + const result = await queryLogsByServer({ + serverId, + ...queryParams, + }); const logs = result.logs.map((log, index) => ({ id: `${log.server_id}-${log._time}-${index}`, @@ -42,6 +47,9 @@ export async function GET( }); } catch (error) { console.error("[logs:server] failed to query logs:", error); - return Response.json({ logs: [], hasMore: false }); + return Response.json( + { message: "Failed to query server logs" }, + { status: 502 }, + ); } } diff --git a/web/app/api/services/[id]/logs/route.ts b/web/app/api/services/[id]/logs/route.ts index 3aa37f43..49bcd40b 100644 --- a/web/app/api/services/[id]/logs/route.ts +++ b/web/app/api/services/[id]/logs/route.ts @@ -1,9 +1,10 @@ -import { auth } from "@/lib/auth"; import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { invalidLogQueryResponse, parseLogListParams } from "@/lib/log-query"; import { isLoggingEnabled, - queryLogsByService, type LogType, + queryLogsByService, } from "@/lib/victoria-logs"; export async function GET( @@ -24,11 +25,12 @@ export async function GET( const { id: serviceId } = await params; const url = new URL(request.url); - const limit = Math.min( - Number.parseInt(url.searchParams.get("limit") || "100", 10), - 1000, - ); - const before = url.searchParams.get("before") || undefined; + let queryParams: ReturnType; + try { + queryParams = parseLogListParams(url.searchParams, 100); + } catch (error) { + return invalidLogQueryResponse(error); + } const serverId = url.searchParams.get("serverId") || undefined; const logTypeParam = url.searchParams.get("type"); const logType = @@ -39,8 +41,7 @@ export async function GET( try { const result = await queryLogsByService({ serviceId, - limit, - before, + ...queryParams, logType, serverId, }); @@ -65,6 +66,9 @@ export async function GET( }); } catch (error) { console.error("[logs:service] failed to query logs:", error); - return Response.json({ logs: [], hasMore: false }); + return Response.json( + { message: "Failed to query service logs" }, + { status: 502 }, + ); } } diff --git a/web/app/api/services/[id]/requests/route.ts b/web/app/api/services/[id]/requests/route.ts index 08387349..bd03415c 100644 --- a/web/app/api/services/[id]/requests/route.ts +++ b/web/app/api/services/[id]/requests/route.ts @@ -1,5 +1,6 @@ -import { auth } from "@/lib/auth"; import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { invalidLogQueryResponse, parseLogListParams } from "@/lib/log-query"; import { isLoggingEnabled, queryLogsByService } from "@/lib/victoria-logs"; export async function GET( @@ -20,17 +21,17 @@ export async function GET( const { id: serviceId } = await params; const url = new URL(request.url); - const limit = Math.min( - Number.parseInt(url.searchParams.get("limit") || "100", 10), - 1000, - ); - const before = url.searchParams.get("before") || undefined; + let queryParams: ReturnType; + try { + queryParams = parseLogListParams(url.searchParams, 100); + } catch (error) { + return invalidLogQueryResponse(error); + } try { const result = await queryLogsByService({ serviceId, - limit, - before, + ...queryParams, logType: "http", }); @@ -50,6 +51,9 @@ export async function GET( }); } catch (error) { console.error("[logs:requests] failed to query HTTP logs:", error); - return Response.json({ logs: [], hasMore: false }); + return Response.json( + { message: "Failed to query request logs" }, + { status: 502 }, + ); } } diff --git a/web/app/api/v1/manifest/logs/route.ts b/web/app/api/v1/manifest/logs/route.ts index 8cfc7592..0404405c 100644 --- a/web/app/api/v1/manifest/logs/route.ts +++ b/web/app/api/v1/manifest/logs/route.ts @@ -3,6 +3,7 @@ export const dynamic = "force-dynamic"; import { z } from "zod"; import { requireRequestRole } from "@/lib/api-auth"; import { getManifestStatus } from "@/lib/cli-service"; +import { isLogCursor } from "@/lib/log-query"; import { slugify } from "@/lib/utils"; import { isLoggingEnabled, queryLogsByService } from "@/lib/victoria-logs"; @@ -10,7 +11,12 @@ const querySchema = z.object({ project: z.string().trim().min(1), environment: z.string().trim().min(1), service: z.string().trim().min(1), - after: z.string().trim().min(1).optional(), + after: z + .string() + .trim() + .min(1) + .refine(isLogCursor, { message: "Invalid log cursor" }) + .optional(), tail: z.coerce.number().int().min(1).max(1000).default(100), }); diff --git a/web/components/logs/log-viewer.tsx b/web/components/logs/log-viewer.tsx index e4a881ac..17537024 100644 --- a/web/components/logs/log-viewer.tsx +++ b/web/components/logs/log-viewer.tsx @@ -13,7 +13,8 @@ import { parseAsStringLiteral, useQueryState, } from "nuqs"; -import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; import useSWR from "swr"; import { Button } from "@/components/ui/button"; import { @@ -22,14 +23,23 @@ import { DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Empty, EmptyTitle } from "@/components/ui/empty"; +import { Empty, EmptyContent, EmptyTitle } from "@/components/ui/empty"; import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; import { formatDateTime, formatTime } from "@/lib/date"; import { fetcher } from "@/lib/fetcher"; +import { + DEFAULT_LOG_TIME_RANGE, + LOG_TIME_RANGES, + type LogTimeRange, + MAX_LOG_SEARCH_LENGTH, + splitLogSearchMatches, +} from "@/lib/log-query"; type LogLevel = "error" | "warn" | "info" | "debug"; type StatusCategory = "2xx" | "3xx" | "4xx" | "5xx"; @@ -85,6 +95,13 @@ const SERVER_LOG_LEVEL_COLORS: Record = { const EMPTY_LOGS: unknown[] = []; +const LOG_TIME_RANGE_LABELS: Record = { + "1h": "Last hour", + "6h": "Last 6 hours", + "24h": "Last 24 hours", + "7d": "Last 7 days", +}; + type LogViewerProps = | { variant: "service-logs"; serviceId: string; servers?: Server[] } | { variant: "requests"; serviceId: string } @@ -124,20 +141,14 @@ function getStatusCategory(status: number): StatusCategory { } function highlightMatches(text: string, search: string): React.ReactNode { - if (!search) return text; - - const regex = new RegExp( - `(${search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, - "gi", - ); - const parts = text.split(regex); + const parts = splitLogSearchMatches(text, search); let offset = 0; - return parts.map((part) => { + return parts.map(({ text: part, isMatch }) => { const start = offset; offset += part.length; - return regex.test(part) ? ( + return isMatch ? ( { - const serverParam = filterServerId ? `&serverId=${filterServerId}` : ""; - switch (props.variant) { - case "service-logs": - return `/api/services/${props.serviceId}/logs?limit=500&type=container${serverParam}`; - case "requests": - return `/api/services/${props.serviceId}/requests?limit=500`; - case "build-logs": - return `/api/builds/${props.buildId}/logs`; - case "server-logs": - return `/api/servers/${props.serverId}/logs?limit=500`; - case "rollout-logs": - return `/api/rollouts/${props.rolloutId}/logs`; - } - }, [props, filterServerId]); +type LogDataResponse = { logs: unknown[]; hasMore?: boolean }; - const pollingInterval = useMemo(() => { - if (props.variant === "build-logs") { - return props.isLive ? 2000 : 0; - } - if (props.variant === "rollout-logs") { - return props.isLive ? 2000 : 0; - } - if (props.variant === "server-logs") { - return 5000; +type LogEndpointOptions = { + search: string; + range: LogTimeRange; + filterServerId?: string; + before?: string; +}; + +function supportsTimeRange(variant: LogViewerProps["variant"]): boolean { + return ( + variant === "service-logs" || + variant === "requests" || + variant === "server-logs" + ); +} + +function buildLogEndpoint( + props: LogViewerProps, + { search, range, filterServerId, before }: LogEndpointOptions, +): string { + const params = new URLSearchParams(); + if (search) params.set("q", search); + if (supportsTimeRange(props.variant)) params.set("range", range); + if (before) params.set("before", before); + + let path: string; + switch (props.variant) { + case "service-logs": + path = `/api/services/${props.serviceId}/logs`; + params.set("limit", "500"); + params.set("type", "container"); + if (filterServerId) params.set("serverId", filterServerId); + break; + case "requests": + path = `/api/services/${props.serviceId}/requests`; + params.set("limit", "500"); + break; + case "build-logs": + path = `/api/builds/${props.buildId}/logs`; + break; + case "server-logs": + path = `/api/servers/${props.serverId}/logs`; + params.set("limit", "500"); + break; + case "rollout-logs": + path = `/api/rollouts/${props.rolloutId}/logs`; + break; + } + + const query = params.toString(); + return query ? `${path}?${query}` : path; +} + +function useDebouncedValue(value: string, delay: number): string { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + if (!value) { + setDebouncedValue(""); + return; } - return 2000; - }, [props]); - return useSWR<{ logs: unknown[]; hasMore?: boolean }>(endpoint, fetcher, { - refreshInterval: pollingInterval, - }); + const timeout = window.setTimeout(() => setDebouncedValue(value), delay); + return () => window.clearTimeout(timeout); + }, [value, delay]); + + return debouncedValue; +} + +function TimeRangeFilter({ + range, + onRangeChange, +}: { + range: LogTimeRange; + onRangeChange: (range: LogTimeRange) => void; +}) { + return ( + + + {LOG_TIME_RANGE_LABELS[range]} + + + + + Time range + + + onRangeChange(value as LogTimeRange)} + > + {LOG_TIME_RANGES.map((option) => ( + + {LOG_TIME_RANGE_LABELS[option]} + + ))} + + + + ); } function ServiceLogsFilters({ @@ -604,6 +684,7 @@ const logLevelParser = parseAsArrayOf( const statusParser = parseAsArrayOf( parseAsStringLiteral(["2xx", "3xx", "4xx", "5xx"] as const), ); +const rangeParser = parseAsStringLiteral(LOG_TIME_RANGES); export function LogViewer(props: LogViewerProps) { const containerRef = useRef(null); @@ -611,7 +692,13 @@ export function LogViewer(props: LogViewerProps) { scrollTop: number; scrollHeight: number; } | null>(null); + const paginationAbortRef = useRef(null); const [search, setSearch] = useQueryState("q", { defaultValue: "" }); + const debouncedSearch = useDebouncedValue(search, 300).trim(); + const [range, setRange] = useQueryState( + "range", + rangeParser.withDefault(DEFAULT_LOG_TIME_RANGE), + ); const [autoScroll, setAutoScroll] = useState(true); const [selectedServerId, setSelectedServerId] = useQueryState("server"); @@ -651,20 +738,59 @@ export function LogViewer(props: LogViewerProps) { const setStatusFilter = (newStatus: Set) => setStatusParam(Array.from(newStatus) as typeof statusParam); - const [olderLogs, setOlderLogs] = useState([]); - const [isLoadingOlder, setIsLoadingOlder] = useState(false); - const [olderHasMore, setOlderHasMore] = useState(true); - const prevSelectedServerIdRef = useRef(selectedServerId); - - if (selectedServerId !== prevSelectedServerIdRef.current) { - prevSelectedServerIdRef.current = selectedServerId; - setOlderLogs([]); - setOlderHasMore(true); - } - const servers = props.variant === "service-logs" ? props.servers : undefined; + const logEndpointOptions: LogEndpointOptions = { + search: debouncedSearch, + range, + filterServerId: selectedServerId ?? undefined, + }; + const paginationKey = buildLogEndpoint(props, logEndpointOptions); + let pollingInterval = 2000; + if (props.variant === "build-logs" || props.variant === "rollout-logs") { + pollingInterval = props.isLive ? 2000 : 0; + } else if (props.variant === "server-logs") { + pollingInterval = 5000; + } + const [olderState, setOlderState] = useState<{ + key: string; + logs: unknown[]; + hasMore: boolean; + }>({ key: "", logs: [], hasMore: true }); + const [loadingOlderKey, setLoadingOlderKey] = useState(null); + const olderLogs = + olderState.key === paginationKey ? olderState.logs : EMPTY_LOGS; + const olderHasMore = + olderState.key === paginationKey ? olderState.hasMore : true; + const isLoadingOlder = loadingOlderKey === paginationKey; + + useEffect(() => { + void paginationKey; + paginationAbortRef.current?.abort(); + paginationAbortRef.current = null; + scrollRestorationRef.current = null; + setLoadingOlderKey(null); + + return () => paginationAbortRef.current?.abort(); + }, [paginationKey]); + + const { data, error, isLoading, mutate } = useSWR( + paginationKey, + fetcher, + { + keepPreviousData: true, + refreshInterval: pollingInterval, + }, + ); + const hasResolvedData = data !== undefined; + useEffect(() => { + if (error && hasResolvedData) { + toast.error( + error instanceof Error ? error.message : "Failed to refresh logs", + { id: `logs-refresh-${paginationKey}` }, + ); + } + }, [error, hasResolvedData, paginationKey]); - const { data, isLoading } = useLogData(props, selectedServerId ?? undefined); const recentLogs = (data?.logs as unknown[] | undefined) || EMPTY_LOGS; const logs = useMemo(() => { const seenIds = new Set(); @@ -702,7 +828,7 @@ export function LogViewer(props: LogViewerProps) { olderLogs.length === 0 ? data?.hasMore || false : olderHasMore; const loadOlderLogs = async () => { - if (isLoadingOlder || !hasMore) return; + if (isLoadingOlder || isLoading || !hasMore) return; const oldestLog = logs[0] as { timestamp?: string } | undefined; if (!oldestLog?.timestamp) return; @@ -715,44 +841,60 @@ export function LogViewer(props: LogViewerProps) { }; } - setIsLoadingOlder(true); + const requestKey = paginationKey; + const controller = new AbortController(); + paginationAbortRef.current?.abort(); + paginationAbortRef.current = controller; + setLoadingOlderKey(requestKey); try { const cursor = oldestLog.timestamp; - let endpoint: string; - const beforeParam = `&before=${encodeURIComponent(cursor)}`; - const serverParam = selectedServerId - ? `&serverId=${selectedServerId}` - : ""; - - switch (props.variant) { - case "service-logs": - endpoint = `/api/services/${props.serviceId}/logs?limit=500&type=container${beforeParam}${serverParam}`; - break; - case "requests": - endpoint = `/api/services/${props.serviceId}/requests?limit=500${beforeParam}`; - break; - case "server-logs": - endpoint = `/api/servers/${props.serverId}/logs?limit=500${beforeParam}`; - break; - default: - return; + const endpoint = buildLogEndpoint(props, { + ...logEndpointOptions, + before: cursor, + }); + const response = await fetch(endpoint, { + cache: "no-store", + signal: controller.signal, + }); + const result = (await response.json()) as LogDataResponse & { + message?: string; + }; + if (paginationAbortRef.current !== controller) return; + if (!response.ok) { + throw new Error(result.message || "Failed to load older logs"); } - const response = await fetch(endpoint); - const result = await response.json(); - if (result.logs && result.logs.length > 0) { - setOlderLogs((prev) => [...result.logs, ...prev]); - setOlderHasMore(result.hasMore || false); + setOlderState((current) => ({ + key: requestKey, + logs: [ + ...result.logs, + ...(current.key === requestKey ? current.logs : []), + ], + hasMore: result.hasMore || false, + })); } else { - setOlderHasMore(false); + setOlderState((current) => ({ + key: requestKey, + logs: current.key === requestKey ? current.logs : [], + hasMore: false, + })); scrollRestorationRef.current = null; } } catch (error) { - console.error("Failed to load older logs:", error); + if (paginationAbortRef.current !== controller) return; scrollRestorationRef.current = null; + if (!(error instanceof DOMException && error.name === "AbortError")) { + console.error("Failed to load older logs:", error); + toast.error( + error instanceof Error ? error.message : "Failed to load older logs", + ); + } } finally { - setIsLoadingOlder(false); + if (paginationAbortRef.current === controller) { + paginationAbortRef.current = null; + setLoadingOlderKey(null); + } } }; @@ -765,68 +907,28 @@ export function LogViewer(props: LogViewerProps) { const level = detectLevel(entry.message); if (level && !levels.has(level)) return false; - - if ( - search && - !entry.message.toLowerCase().includes(search.toLowerCase()) - ) - return false; } else if (props.variant === "requests") { const entry = log as RequestEntry; const category = getStatusCategory(entry.status); if (!statusFilter.has(category)) return false; - - if (search) { - const searchLower = search.toLowerCase(); - const matchesPath = entry.path.toLowerCase().includes(searchLower); - const matchesMethod = entry.method - .toLowerCase() - .includes(searchLower); - const matchesStatus = entry.status.toString().includes(search); - const matchesIp = entry.clientIp.includes(search); - if (!matchesPath && !matchesMethod && !matchesStatus && !matchesIp) { - return false; - } - } - } else if (props.variant === "build-logs") { - const entry = log as BuildLogEntry; - if ( - search && - !entry.message.toLowerCase().includes(search.toLowerCase()) - ) - return false; } else if (props.variant === "server-logs") { const entry = log as ServerLogEntry; if (!levels.has(entry.level as LogLevel)) return false; - if ( - search && - !entry.message.toLowerCase().includes(search.toLowerCase()) - ) - return false; - } else if (props.variant === "rollout-logs") { - const entry = log as BuildLogEntry; - if ( - search && - !entry.message.toLowerCase().includes(search.toLowerCase()) - ) - return false; } return true; }); - }, [ - logs, - props.variant, - search, - levels, - showStdout, - showStderr, - statusFilter, - ]); + }, [logs, props.variant, levels, showStdout, showStderr, statusFilter]); + const logCount = logs.length; const filteredLogCount = filteredLogs.length; + const newestFilteredLogTimestamp = ( + filteredLogs.at(-1) as BaseEntry | undefined + )?.timestamp; useLayoutEffect(() => { + void logCount; void filteredLogCount; + void newestFilteredLogTimestamp; const container = containerRef.current; const restoration = scrollRestorationRef.current; @@ -838,7 +940,7 @@ export function LogViewer(props: LogViewerProps) { } else if (autoScroll && container) { container.scrollTop = container.scrollHeight; } - }, [filteredLogCount, autoScroll]); + }, [logCount, filteredLogCount, newestFilteredLogTimestamp, autoScroll]); const config = useMemo(() => { switch (props.variant) { @@ -894,6 +996,7 @@ export function LogViewer(props: LogViewerProps) { placeholder={config.searchPlaceholder} value={search} onChange={(e) => setSearch(e.target.value)} + maxLength={MAX_LOG_SEARCH_LENGTH} className="pl-8 pr-8 h-8" /> {search && ( @@ -929,6 +1032,13 @@ export function LogViewer(props: LogViewerProps) { )} + {supportsTimeRange(props.variant) && ( + void setRange(nextRange)} + /> + )} + {servers && servers.length > 1 && ( setAutoScroll(!autoScroll)} + onClick={() => setAutoScroll((current) => !current)} title={autoScroll ? "Auto-scroll on" : "Auto-scroll off"} > @@ -959,7 +1069,7 @@ export function LogViewer(props: LogViewerProps) { variant="ghost" size="sm" onClick={loadOlderLogs} - disabled={isLoadingOlder} + disabled={isLoadingOlder || isLoading} className="gap-1 text-muted-foreground" > {isLoadingOlder ? ( @@ -972,14 +1082,25 @@ export function LogViewer(props: LogViewerProps) { )} - {isLoading ? ( + {error && logs.length === 0 ? ( + + Unable to load logs + + + + + ) : isLoading && logs.length === 0 ? (
) : filteredLogs.length === 0 ? ( - {logs.length === 0 ? config.emptyMessage : config.noMatchMessage} + {debouncedSearch.trim() || logs.length > 0 + ? config.noMatchMessage + : config.emptyMessage} ) : ( @@ -987,25 +1108,43 @@ export function LogViewer(props: LogViewerProps) { {filteredLogs.map((entry) => { if (props.variant === "service-logs") { const e = entry as ServiceLogEntry; - return ; + return ( + + ); } if (props.variant === "requests") { const e = entry as RequestEntry; - return ; + return ( + + ); } if (props.variant === "server-logs") { const e = entry as ServerLogEntry; - return ; + return ( + + ); } if (props.variant === "rollout-logs") { const e = entry as BuildLogEntry; return ( - + ); } const e = entry as BuildLogEntry; return ( - + ); })} diff --git a/web/lib/log-query.ts b/web/lib/log-query.ts new file mode 100644 index 00000000..baaffd70 --- /dev/null +++ b/web/lib/log-query.ts @@ -0,0 +1,146 @@ +export const LOG_TIME_RANGES = ["1h", "6h", "24h", "7d"] as const; +export type LogTimeRange = (typeof LOG_TIME_RANGES)[number]; + +export const DEFAULT_LOG_TIME_RANGE: LogTimeRange = "24h"; +export const MAX_LOG_SEARCH_LENGTH = 200; + +const LOG_CURSOR_PATTERN = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(?:Z|[+-](\d{2}):(\d{2}))$/; + +export function normalizeLogSearch( + value: string | null | undefined, +): string | undefined { + const search = value?.trim(); + if (!search) return undefined; + if (search.length > MAX_LOG_SEARCH_LENGTH) { + throw new RangeError( + `Search must be ${MAX_LOG_SEARCH_LENGTH} characters or fewer`, + ); + } + return search; +} + +export function isLogCursor(value: string): boolean { + const match = LOG_CURSOR_PATTERN.exec(value); + if (!match) return false; + + const [ + , + yearValue, + monthValue, + dayValue, + hourValue, + minuteValue, + secondValue, + ] = match; + const year = Number(yearValue); + const month = Number(monthValue); + const day = Number(dayValue); + const hour = Number(hourValue); + const minute = Number(minuteValue); + const second = Number(secondValue); + const offsetHour = Number(match[7] || 0); + const offsetMinute = Number(match[8] || 0); + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const daysInMonth = [ + 31, + leapYear ? 29 : 28, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ][month - 1]; + + return ( + daysInMonth !== undefined && + day >= 1 && + day <= daysInMonth && + hour <= 23 && + minute <= 59 && + second <= 59 && + offsetHour <= 23 && + offsetMinute <= 59 + ); +} + +export function normalizeLogCursor( + value: string | null | undefined, +): string | undefined { + const cursor = value?.trim(); + if (!cursor) return undefined; + if (!isLogCursor(cursor)) { + throw new RangeError("Invalid log cursor"); + } + return cursor; +} + +export function parseLogLimit( + value: string | null | undefined, + defaultValue: number, + maxValue: number = 1000, +): number { + if (value === null || value === undefined || value === "") { + return defaultValue; + } + if (!/^\d+$/.test(value)) { + throw new RangeError("Invalid log limit"); + } + + const limit = Number(value); + if (!Number.isSafeInteger(limit) || limit < 1) { + throw new RangeError("Invalid log limit"); + } + return Math.min(limit, maxValue); +} + +export function parseLogListParams( + searchParams: URLSearchParams, + defaultLimit: number, +): { + search: string | undefined; + before: string | undefined; + limit: number; + range: LogTimeRange; +} { + const search = normalizeLogSearch(searchParams.get("q")); + const before = normalizeLogCursor(searchParams.get("before")); + const limit = parseLogLimit(searchParams.get("limit"), defaultLimit); + const rangeValue = searchParams.get("range") || DEFAULT_LOG_TIME_RANGE; + const range = LOG_TIME_RANGES.find((option) => option === rangeValue); + if (!range) { + throw new RangeError("Invalid log range"); + } + + return { search, before, limit, range }; +} + +export function invalidLogQueryResponse(error: unknown): Response { + return Response.json( + { message: error instanceof Error ? error.message : "Invalid log query" }, + { status: 400 }, + ); +} + +export function escapeLogRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function splitLogSearchMatches( + text: string, + search: string, +): Array<{ text: string; isMatch: boolean }> { + if (!search) return [{ text, isMatch: false }]; + + const regex = new RegExp(`(${escapeLogRegex(search)})`, "gi"); + const normalizedSearch = search.toLowerCase(); + return text.split(regex).map((part) => ({ + text: part, + isMatch: part.toLowerCase() === normalizedSearch, + })); +} diff --git a/web/lib/victoria-logs.ts b/web/lib/victoria-logs.ts index 9540568d..784d6f13 100644 --- a/web/lib/victoria-logs.ts +++ b/web/lib/victoria-logs.ts @@ -1,3 +1,10 @@ +import { + escapeLogRegex, + type LogTimeRange, + normalizeLogCursor, + normalizeLogSearch, +} from "@/lib/log-query"; + const VICTORIA_LOGS_URL = process.env.VICTORIA_LOGS_URL; const VICTORIA_LOGS_PRIVATE_URL = process.env.VICTORIA_LOGS_PRIVATE_URL; @@ -33,6 +40,7 @@ function buildFetchOptions(config: EndpointConfig): RequestInit { } export type LogType = "container" | "http"; +type LogSearchField = "_msg" | "path" | "method" | "status" | "client_ip"; export type StoredLog = { _msg: string; @@ -64,6 +72,20 @@ function formatLogSqlExactFilter(field: string, value: string): string { return `${field}:${trimmed}`; } +export function formatLogSqlSearchFilter( + value: string | null | undefined, + fields: readonly LogSearchField[] = ["_msg"], +): string | undefined { + const search = normalizeLogSearch(value); + if (!search) return undefined; + + const pattern = `(?i)${escapeLogRegex(search)}`; + const quotedPattern = JSON.stringify(pattern); + const filters = fields.map((field) => `${field}:~${quotedPattern}`); + + return filters.length === 1 ? filters[0] : `(${filters.join(" OR ")})`; +} + type QueryLogsByServiceOptions = { serviceId: string; limit: number; @@ -71,12 +93,15 @@ type QueryLogsByServiceOptions = { before?: string; logType?: LogType; serverId?: string; + search?: string; + range?: LogTimeRange; }; export async function queryLogsByService( options: QueryLogsByServiceOptions, ): Promise<{ logs: StoredLog[]; hasMore: boolean }> { - const { serviceId, limit, after, before, logType, serverId } = options; + const { serviceId, limit, after, before, logType, serverId, search, range } = + options; const endpoint = getQueryEndpoint(); if (!endpoint) { @@ -94,11 +119,25 @@ export async function queryLogsByService( if (serverId) { query += ` ${formatLogSqlExactFilter("server_id", serverId)}`; } - if (after) { - query += ` _time:>${after}`; + if (range) { + query += ` _time:${range}`; + } + const afterCursor = normalizeLogCursor(after); + if (afterCursor) { + query += ` _time:>${afterCursor}`; + } + const beforeCursor = normalizeLogCursor(before); + if (beforeCursor) { + query += ` _time:<${beforeCursor}`; } - if (before) { - query += ` _time:<${before}`; + const searchFilter = formatLogSqlSearchFilter( + search, + logType === "http" + ? ["_msg", "path", "method", "status", "client_ip"] + : ["_msg"], + ); + if (searchFilter) { + query += ` ${searchFilter}`; } query += " | sort by (_time desc)"; @@ -127,7 +166,7 @@ export async function queryLogsByService( export async function queryLogsByDeployment( deploymentId: string, limit: number, - before?: string, + after?: string, ): Promise<{ logs: StoredLog[]; hasMore: boolean }> { const endpoint = getQueryEndpoint(); if (!endpoint) { @@ -135,8 +174,9 @@ export async function queryLogsByDeployment( } let query = formatLogSqlExactFilter("deployment_id", deploymentId); - if (before) { - query += ` _time:<${before}`; + const afterCursor = normalizeLogCursor(after); + if (afterCursor) { + query += ` _time:>${afterCursor}`; } query += " | sort by (_time desc)"; @@ -179,19 +219,40 @@ export type AgentLog = { log_type: "agent"; }; -export async function queryLogsByServer( - serverId: string, - limit: number = 500, - before?: string, -): Promise<{ logs: AgentLog[]; hasMore: boolean }> { +type QueryLogsByServerOptions = { + serverId: string; + limit?: number; + before?: string; + search?: string; + range?: LogTimeRange; +}; + +export async function queryLogsByServer({ + serverId, + limit = 500, + before, + search, + range, +}: QueryLogsByServerOptions): Promise<{ + logs: AgentLog[]; + hasMore: boolean; +}> { const endpoint = getQueryEndpoint(); if (!endpoint) { throw new Error("VICTORIA_LOGS_URL is not configured"); } let query = `${formatLogSqlExactFilter("server_id", serverId)} log_type:agent`; - if (before) { - query += ` _time:<${before}`; + if (range) { + query += ` _time:${range}`; + } + const beforeCursor = normalizeLogCursor(before); + if (beforeCursor) { + query += ` _time:<${beforeCursor}`; + } + const searchFilter = formatLogSqlSearchFilter(search); + if (searchFilter) { + query += ` ${searchFilter}`; } query += " | sort by (_time desc)"; @@ -264,14 +325,19 @@ export async function ingestRolloutLog( export async function queryLogsByRollout( rolloutId: string, - limit: number = 1000, + { limit = 1000, search }: { limit?: number; search?: string } = {}, ): Promise<{ logs: RolloutLog[] }> { const endpoint = getQueryEndpoint(); if (!endpoint) { throw new Error("VICTORIA_LOGS_URL is not configured"); } - const query = `${formatLogSqlExactFilter("rollout_id", rolloutId)} log_type:rollout | sort by (_time)`; + let query = `${formatLogSqlExactFilter("rollout_id", rolloutId)} log_type:rollout`; + const searchFilter = formatLogSqlSearchFilter(search); + if (searchFilter) { + query += ` ${searchFilter}`; + } + query += " | sort by (_time)"; const url = new URL(`${endpoint.url}/select/logsql/query`); url.searchParams.set("query", query); @@ -294,14 +360,19 @@ export async function queryLogsByRollout( export async function queryLogsByBuild( buildId: string, - limit: number = 1000, + { limit = 1000, search }: { limit?: number; search?: string } = {}, ): Promise<{ logs: BuildLog[] }> { const endpoint = getQueryEndpoint(); if (!endpoint) { throw new Error("VICTORIA_LOGS_URL is not configured"); } - const query = `build_id:${buildId} log_type:build | sort by (_time)`; + let query = `${formatLogSqlExactFilter("build_id", buildId)} log_type:build`; + const searchFilter = formatLogSqlSearchFilter(search); + if (searchFilter) { + query += ` ${searchFilter}`; + } + query += " | sort by (_time)"; const url = new URL(`${endpoint.url}/select/logsql/query`); url.searchParams.set("query", query); diff --git a/web/tests/log-routes.test.ts b/web/tests/log-routes.test.ts new file mode 100644 index 00000000..a50131d8 --- /dev/null +++ b/web/tests/log-routes.test.ts @@ -0,0 +1,196 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("log routes", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.doUnmock("next/headers"); + vi.doUnmock("@/lib/api-auth"); + vi.doUnmock("@/lib/auth"); + vi.doUnmock("@/lib/cli-service"); + vi.doUnmock("@/lib/victoria-logs"); + }); + + it.each([ + { + name: "an oversized search", + params: { q: "x".repeat(201) }, + message: "Search must be 200 characters or fewer", + }, + { + name: "an injected cursor", + params: { + before: "2026-07-10T01:02:03Z OR service_id:service-2", + }, + message: "Invalid log cursor", + }, + { + name: "a non-numeric limit", + params: { limit: "abc" }, + message: "Invalid log limit", + }, + { + name: "an unsupported range", + params: { range: "30d" }, + message: "Invalid log range", + }, + ])("rejects $name before querying VictoriaLogs", async ({ + params, + message, + }) => { + const { GET, queryLogsByService } = await loadServiceLogsRoute(); + const url = new URL("http://localhost/api/services/service-1/logs"); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + + const response = await GET(new Request(url), { + params: Promise.resolve({ id: "service-1" }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ message }); + expect(queryLogsByService).not.toHaveBeenCalled(); + }); + + it("returns a gateway error when VictoriaLogs fails", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const queryLogsByService = vi.fn(async () => { + throw new Error("VictoriaLogs unavailable"); + }); + const { GET } = await loadServiceLogsRoute(queryLogsByService); + + const response = await GET( + new Request("http://localhost/api/services/service-1/logs"), + { params: Promise.resolve({ id: "service-1" }) }, + ); + + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ + message: "Failed to query service logs", + }); + }); + + it("passes a validated after cursor to the deployment query", async () => { + const { GET, queryLogsByDeployment } = await loadDeploymentLogsRoute(); + const cursor = "2026-07-10T01:02:03Z"; + const response = await GET( + new Request( + `http://localhost/api/deployments/deployment-1/logs?after=${cursor}`, + ), + { params: Promise.resolve({ id: "deployment-1" }) }, + ); + + expect(response.status).toBe(200); + expect(queryLogsByDeployment).toHaveBeenCalledWith( + "deployment-1", + 100, + cursor, + ); + }); + + it("rejects an invalid deployment cursor before querying logs", async () => { + const { GET, queryLogsByDeployment } = await loadDeploymentLogsRoute(); + const url = new URL("http://localhost/api/deployments/deployment-1/logs"); + url.searchParams.set("after", "2026-07-10T01:02:03Z | stats count()"); + + const response = await GET(new Request(url), { + params: Promise.resolve({ id: "deployment-1" }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ message: "Invalid log cursor" }); + expect(queryLogsByDeployment).not.toHaveBeenCalled(); + }); + + it("returns a gateway error when deployment logs fail", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const queryLogsByDeployment = vi.fn(async () => { + throw new Error("VictoriaLogs unavailable"); + }); + const { GET } = await loadDeploymentLogsRoute(queryLogsByDeployment); + + const response = await GET( + new Request("http://localhost/api/deployments/deployment-1/logs"), + { params: Promise.resolve({ id: "deployment-1" }) }, + ); + + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ + message: "Failed to query deployment logs", + }); + }); + + it("rejects an injected manifest cursor before resolving the service", async () => { + vi.resetModules(); + const getManifestStatus = vi.fn(); + const queryLogsByService = vi.fn(); + vi.doMock("@/lib/api-auth", () => ({ + requireRequestRole: async () => ({ ok: true }), + })); + vi.doMock("@/lib/cli-service", () => ({ getManifestStatus })); + vi.doMock("@/lib/victoria-logs", () => ({ + isLoggingEnabled: () => true, + queryLogsByService, + })); + const { GET } = await import("@/app/api/v1/manifest/logs/route"); + const url = new URL("http://localhost/api/v1/manifest/logs"); + url.searchParams.set("project", "project-1"); + url.searchParams.set("environment", "production"); + url.searchParams.set("service", "web"); + url.searchParams.set("after", "2026-07-10T01:02:03Z | stats count()"); + + const response = await GET(new Request(url)); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "Invalid log cursor" }); + expect(getManifestStatus).not.toHaveBeenCalled(); + expect(queryLogsByService).not.toHaveBeenCalled(); + }); +}); + +async function loadServiceLogsRoute( + queryLogsByService = vi.fn(async () => ({ logs: [], hasMore: false })), +) { + vi.resetModules(); + vi.doMock("next/headers", () => ({ + headers: async () => new Headers(), + })); + vi.doMock("@/lib/auth", () => ({ + auth: { + api: { + getSession: async () => ({ user: { id: "user-1" } }), + }, + }, + })); + vi.doMock("@/lib/victoria-logs", () => ({ + isLoggingEnabled: () => true, + queryLogsByService, + })); + + const { GET } = await import("@/app/api/services/[id]/logs/route"); + return { GET, queryLogsByService }; +} + +async function loadDeploymentLogsRoute( + queryLogsByDeployment = vi.fn(async () => ({ logs: [], hasMore: false })), +) { + vi.resetModules(); + vi.doMock("next/headers", () => ({ + headers: async () => new Headers(), + })); + vi.doMock("@/lib/auth", () => ({ + auth: { + api: { + getSession: async () => ({ user: { id: "user-1" } }), + }, + }, + })); + vi.doMock("@/lib/victoria-logs", () => ({ + isLoggingEnabled: () => true, + queryLogsByDeployment, + })); + + const { GET } = await import("@/app/api/deployments/[id]/logs/route"); + return { GET, queryLogsByDeployment }; +} diff --git a/web/tests/victoria-logs.test.ts b/web/tests/victoria-logs.test.ts new file mode 100644 index 00000000..2592ccf2 --- /dev/null +++ b/web/tests/victoria-logs.test.ts @@ -0,0 +1,244 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + DEFAULT_LOG_TIME_RANGE, + escapeLogRegex, + isLogCursor, + MAX_LOG_SEARCH_LENGTH, + normalizeLogCursor, + normalizeLogSearch, + parseLogLimit, + parseLogListParams, + splitLogSearchMatches, +} from "@/lib/log-query"; + +describe("VictoriaLogs queries", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it("validates log search and time range inputs", () => { + expect(DEFAULT_LOG_TIME_RANGE).toBe("24h"); + expect(normalizeLogSearch(" database error ")).toBe("database error"); + expect(normalizeLogSearch(" ")).toBeUndefined(); + expect(() => + normalizeLogSearch("x".repeat(MAX_LOG_SEARCH_LENGTH + 1)), + ).toThrow(`Search must be ${MAX_LOG_SEARCH_LENGTH} characters or fewer`); + + expect(isLogCursor("2026-07-10T01:02:03Z")).toBe(true); + expect(isLogCursor("2026-07-10T01:02:03.123456789Z")).toBe(true); + expect(isLogCursor("2026-07-10T01:02:03+10:00")).toBe(true); + expect(isLogCursor("2026-02-29T01:02:03Z")).toBe(false); + expect(isLogCursor("2024-02-29T01:02:03Z")).toBe(true); + expect(isLogCursor("2026-07-10T01:02:03Z OR service_id:service-2")).toBe( + false, + ); + expect(isLogCursor("2026-07-10T01:02:03Z | stats count()")).toBe(false); + expect(normalizeLogCursor(" 2026-07-10T01:02:03Z ")).toBe( + "2026-07-10T01:02:03Z", + ); + expect(() => + normalizeLogCursor("2026-07-10T01:02:03Z | stats count()"), + ).toThrow("Invalid log cursor"); + + expect(parseLogLimit(null, 100)).toBe(100); + expect(parseLogLimit("500", 100)).toBe(500); + expect(parseLogLimit("1001", 100)).toBe(1000); + expect(() => parseLogLimit("0", 100)).toThrow("Invalid log limit"); + expect(() => parseLogLimit("abc", 100)).toThrow("Invalid log limit"); + + expect( + parseLogListParams( + new URLSearchParams({ + q: " database error ", + before: "2026-07-10T01:02:03Z", + limit: "500", + range: "7d", + }), + 100, + ), + ).toEqual({ + search: "database error", + before: "2026-07-10T01:02:03Z", + limit: 500, + range: "7d", + }); + expect(parseLogListParams(new URLSearchParams(), 100).range).toBe("24h"); + expect(() => + parseLogListParams(new URLSearchParams({ range: "30d" }), 100), + ).toThrow("Invalid log range"); + + expect(splitLogSearchMatches("error and ERROR", "error")).toEqual([ + { text: "", isMatch: false }, + { text: "error", isMatch: true }, + { text: " and ", isMatch: false }, + { text: "ERROR", isMatch: true }, + { text: "", isMatch: false }, + ]); + }); + + it("escapes search text as a literal case-insensitive regex", async () => { + const { formatLogSqlSearchFilter } = await loadVictoriaLogs(); + const search = 'error ") OR log_type:build .* [db] \\path'; + const filter = formatLogSqlSearchFilter(search); + + expect(filter).toBeDefined(); + const encodedPattern = filter?.slice("_msg:~".length) || ""; + expect(JSON.parse(encodedPattern)).toBe(`(?i)${escapeLogRegex(search)}`); + }); + + it("rejects service-log cursors before issuing a LogsQL request", async () => { + const { queryLogsByService } = await loadVictoriaLogs(); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect( + queryLogsByService({ + serviceId: "service-1", + limit: 100, + before: "2026-07-10T01:02:03Z OR service_id:service-2", + }), + ).rejects.toThrow("Invalid log cursor"); + await expect( + queryLogsByService({ + serviceId: "service-1", + limit: 100, + after: "2026-07-10T01:02:03Z | stats count()", + }), + ).rejects.toThrow("Invalid log cursor"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("searches service logs before applying the result limit and range", async () => { + const { queryLogsByService } = await loadVictoriaLogs(); + const urls: URL[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + urls.push(new URL(String(input))); + return jsonLinesResponse([ + storedLog("2026-07-10T01:00:00Z", "first match"), + storedLog("2026-07-10T00:00:00Z", "older match"), + ]); + }), + ); + + const result = await queryLogsByService({ + serviceId: "service-1", + limit: 1, + before: "2026-07-10T02:00:00Z", + logType: "container", + search: "first match", + range: "24h", + }); + + expect(result.logs).toHaveLength(1); + expect(result.hasMore).toBe(true); + const url = urls[0]; + expect(url?.searchParams.get("limit")).toBe("2"); + const query = url?.searchParams.get("query") || ""; + expect(query).toContain("service_id:service-1"); + expect(query).toContain("_time:24h"); + expect(query).toContain("_time:<2026-07-10T02:00:00Z"); + expect(query).toContain('_msg:~"(?i)first match"'); + expect(query.indexOf('_msg:~"(?i)first match"')).toBeLessThan( + query.indexOf("| sort"), + ); + }); + + it("queries deployment logs after the supplied cursor", async () => { + const { queryLogsByDeployment } = await loadVictoriaLogs(); + let query = ""; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + query = new URL(String(input)).searchParams.get("query") || ""; + return jsonLinesResponse([]); + }), + ); + + await queryLogsByDeployment("deployment-1", 100, "2026-07-10T01:02:03Z"); + + expect(query).toContain("deployment_id:deployment-1"); + expect(query).toContain("_time:>2026-07-10T01:02:03Z"); + expect(query).not.toContain("_time:<2026-07-10T01:02:03Z"); + }); + + it("searches every field exposed by the request-log search box", async () => { + const { queryLogsByService } = await loadVictoriaLogs(); + let query = ""; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + query = new URL(String(input)).searchParams.get("query") || ""; + return jsonLinesResponse([]); + }), + ); + + await queryLogsByService({ + serviceId: "service-1", + limit: 500, + logType: "http", + search: "10.0.0.1", + range: "6h", + }); + + expect(query).toContain( + '(_msg:~"(?i)10\\\\.0\\\\.0\\\\.1" OR path:~"(?i)10\\\\.0\\\\.0\\\\.1" OR method:~"(?i)10\\\\.0\\\\.0\\\\.1" OR status:~"(?i)10\\\\.0\\\\.0\\\\.1" OR client_ip:~"(?i)10\\\\.0\\\\.0\\\\.1")', + ); + expect(query).toContain("_time:6h"); + }); + + it("applies search to server, build, and rollout log queries", async () => { + const { queryLogsByBuild, queryLogsByRollout, queryLogsByServer } = + 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 queryLogsByServer({ + serverId: "server-1", + search: "connection lost", + range: "7d", + }); + await queryLogsByBuild("build-1", { search: "connection lost" }); + await queryLogsByRollout("rollout-1", { search: "connection lost" }); + + expect(queries[0]).toContain("server_id:server-1"); + expect(queries[0]).toContain("_time:7d"); + expect(queries[1]).toContain("build_id:build-1"); + expect(queries[2]).toContain("rollout_id:rollout-1"); + for (const query of queries) { + expect(query).toContain('_msg:~"(?i)connection lost"'); + } + }); +}); + +async function loadVictoriaLogs() { + vi.resetModules(); + vi.stubEnv("VICTORIA_LOGS_URL", "http://victoria.test"); + vi.stubEnv("VICTORIA_LOGS_PRIVATE_URL", ""); + return import("@/lib/victoria-logs"); +} + +function storedLog(time: string, message: string) { + return { + _msg: message, + _time: time, + service_id: "service-1", + log_type: "container", + }; +} + +function jsonLinesResponse(logs: unknown[]) { + return new Response(logs.map((log) => JSON.stringify(log)).join("\n"), { + status: 200, + }); +}