import { useMemo, useState } from "react"; import { useParams } from "react-router"; import { Listbox, ListboxButton, ListboxOption, ListboxOptions, } from "@headlessui/react"; import { CheckIcon, ChevronUpDownIcon, FunnelIcon, MagnifyingGlassIcon, } from "@heroicons/react/16/solid"; import { EmptyState, ErrorState, LoadingState } from "../components/state"; import { StageSidebar } from "../components/stage-sidebar"; import { CopyButton } from "../components/ui"; import { useRun, useRunLogs, useRunStages } from "../lib/queries"; import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; export const handle = { wide: true }; const LIVE_REFRESH_MS = 5000; export default function RunLogs() { const { id } = useParams(); const runQuery = useRun(id); const stagesQuery = useRunStages(id); const isLive = runQuery.data?.status?.kind === "running"; const logsQuery = useRunLogs(id, isLive ? LIVE_REFRESH_MS : undefined); const stages = useMemo( () => mapRunStagesToSidebarStages(stagesQuery.data), [stagesQuery.data], ); return (
{renderBody(logsQuery)}
); } function renderBody(logsQuery: ReturnType) { if (logsQuery.error) { return ( void logsQuery.mutate()} /> ); } if (logsQuery.data === undefined) { return ; } if (logsQuery.data === null) { return ( ); } return ; } const LOG_LEVELS = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"] as const; type LogLevel = (typeof LOG_LEVELS)[number]; const LEVEL_COLOR: Record = { ERROR: "text-coral", WARN: "text-amber", INFO: "text-teal-500", DEBUG: "text-fg-3", TRACE: "text-fg-muted", }; const LOG_LINE_RE = /^(\S+)(\s+)(TRACE|DEBUG|INFO|WARN|ERROR)(\s+)(.*)$/; interface LogRecord { level: LogLevel | null; lines: string[]; } function parseRecords(lines: string[]): LogRecord[] { const records: LogRecord[] = []; let current: LogRecord | null = null; for (const line of lines) { const match = LOG_LINE_RE.exec(line); if (match) { if (current) records.push(current); current = { level: match[3] as LogLevel, lines: [line] }; } else if (current) { current.lines.push(line); } else { current = { level: null, lines: [line] }; } } if (current) records.push(current); return records; } function LogPanel({ text }: { text: string }) { const byteCount = new Blob([text]).size; const lines = useMemo(() => text.split("\n"), [text]); const records = useMemo(() => parseRecords(lines), [lines]); const [selectedLevels, setSelectedLevels] = useState([ ...LOG_LEVELS, ]); const [search, setSearch] = useState(""); const allLevelsSelected = selectedLevels.length === LOG_LEVELS.length; const isFiltering = !allLevelsSelected || search.length > 0; const filteredLines = useMemo(() => { if (!isFiltering) return lines; const levelSet = new Set(selectedLevels); const needle = search.toLowerCase(); const kept = records.filter((record) => { if (record.level !== null && !levelSet.has(record.level)) return false; if (needle && !record.lines.some((l) => l.toLowerCase().includes(needle))) { return false; } return true; }); return kept.flatMap((r) => r.lines); }, [lines, records, selectedLevels, search, isFiltering]); function clearFilters() { setSelectedLevels([...LOG_LEVELS]); setSearch(""); } return (
{isFiltering && ( )}
{isFiltering ? `${filteredLines.length.toLocaleString()} of ${lines.length.toLocaleString()} lines` : formatWholeBytes(byteCount)}
        {filteredLines.length === 0 ? (
          No lines match these filters.
        ) : (
          filteredLines.map((line, i) => (
            
          ))
        )}
      
); } function LevelFilter({ selected, onChange, }: { selected: LogLevel[]; onChange: (levels: LogLevel[]) => void; }) { const summary = useMemo(() => { if (selected.length === LOG_LEVELS.length) return "All levels"; if (selected.length === 0) return "No levels"; if (selected.length <= 2) { return LOG_LEVELS.filter((l) => selected.includes(l)).join(", "); } return `${selected.length} levels`; }, [selected]); return ( {LOG_LEVELS.map((level) => ( {level} ))} ); } function SearchInput({ value, onChange, }: { value: string; onChange: (value: string) => void; }) { return (
); } function LogLine({ line, trailingNewline }: { line: string; trailingNewline: boolean }) { const newline = trailingNewline ? "\n" : ""; const match = LOG_LINE_RE.exec(line); if (!match) { return {line}{newline}; } const [, timestamp, gap1, level, gap2, rest] = match; return ( {timestamp} {gap1} {level} {gap2} {newline} ); } const LOG_REST_RE = /^([a-zA-Z_][\w:]*):(\s+)(.*)$/; function LogRest({ text }: { text: string }) { const match = LOG_REST_RE.exec(text); if (!match) return <>{text}; const [, target, gap, message] = match; return ( <> {target} : {gap} {message} ); } function errorMessage(error: unknown): string | undefined { return error instanceof Error ? error.message : undefined; } function formatWholeBytes(bytes: number): string { if (bytes >= 1e9) return `${Math.round(bytes / 1e9)} GB`; if (bytes >= 1e6) return `${Math.round(bytes / 1e6)} MB`; if (bytes >= 1e3) return `${Math.round(bytes / 1e3)} KB`; return `${bytes} B`; }