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 (
);
}
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 (
{summary}
{LOG_LEVELS.map((level) => (
{level}
))}
);
}
function SearchInput({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
return (
onChange(e.target.value)}
className="block w-full rounded-md bg-panel py-1.5 pl-8 pr-2.5 text-xs text-fg outline-1 -outline-offset-1 outline-line-strong placeholder:text-fg-muted focus:outline-2 focus:-outline-offset-1 focus:outline-teal-500 max-sm:text-base/5"
/>
);
}
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`;
}