242 lines
8.3 KiB
TypeScript
242 lines
8.3 KiB
TypeScript
import { ArrowPathIcon } from "@heroicons/react/20/solid";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
|
import { LogLevelTooltipInfo } from "~/components/LogLevelTooltipInfo";
|
|
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
|
import { useEnvironment } from "~/hooks/useEnvironment";
|
|
import { useOrganization } from "~/hooks/useOrganizations";
|
|
import { useProject } from "~/hooks/useProject";
|
|
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
|
|
import { cn } from "~/utils/cn";
|
|
import { highlightSearchText } from "~/utils/logUtils";
|
|
import { v3RunSpanPath } from "~/utils/pathBuilder";
|
|
import { DateTimeAccurate } from "../primitives/DateTime";
|
|
import { Paragraph } from "../primitives/Paragraph";
|
|
import { Spinner } from "../primitives/Spinner";
|
|
import {
|
|
Table,
|
|
TableBlankRow,
|
|
TableBody,
|
|
TableCell,
|
|
TableCellMenu,
|
|
TableHeader,
|
|
TableHeaderCell,
|
|
TableRow,
|
|
type TableVariant,
|
|
} from "../primitives/Table";
|
|
import { LogLevel } from "./LogLevel";
|
|
|
|
type LogsTableProps = {
|
|
logs: LogEntry[];
|
|
searchTerm?: string;
|
|
isLoading?: boolean;
|
|
isLoadingMore?: boolean;
|
|
hasMore?: boolean;
|
|
onLoadMore?: () => void;
|
|
onCheckForMore?: () => void;
|
|
variant?: TableVariant;
|
|
selectedLogId?: string;
|
|
onLogSelect?: (logId: string) => void;
|
|
};
|
|
|
|
// Inner shadow for level highlighting (better scroll performance than border-l)
|
|
function getLevelBoxShadow(level: LogEntry["level"]): string {
|
|
switch (level) {
|
|
case "ERROR":
|
|
return "inset 2px 0 0 0 rgb(239, 68, 68)";
|
|
case "WARN":
|
|
return "inset 2px 0 0 0 rgb(234, 179, 8)";
|
|
case "INFO":
|
|
return "inset 2px 0 0 0 rgb(59, 130, 246)";
|
|
case "TRACE":
|
|
return "inset 2px 0 0 0 rgb(168, 85, 247)";
|
|
case "DEBUG":
|
|
default:
|
|
return "none";
|
|
}
|
|
}
|
|
|
|
export function LogsTable({
|
|
logs,
|
|
searchTerm,
|
|
isLoading = false,
|
|
isLoadingMore = false,
|
|
hasMore = false,
|
|
onLoadMore,
|
|
onCheckForMore,
|
|
selectedLogId,
|
|
onLogSelect,
|
|
}: LogsTableProps) {
|
|
const organization = useOrganization();
|
|
const project = useProject();
|
|
const environment = useEnvironment();
|
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
|
const [showLoadMoreSpinner, setShowLoadMoreSpinner] = useState(false);
|
|
|
|
// Show load more spinner only after 0.2 seconds of loading time
|
|
useEffect(() => {
|
|
if (!isLoadingMore) {
|
|
setShowLoadMoreSpinner(false);
|
|
return;
|
|
}
|
|
|
|
const timer = setTimeout(() => {
|
|
setShowLoadMoreSpinner(true);
|
|
}, 200);
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [isLoadingMore]);
|
|
|
|
// Intersection observer for infinite scroll
|
|
useEffect(() => {
|
|
if (!hasMore || isLoadingMore || !onLoadMore) return;
|
|
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
if (entries[0].isIntersecting) {
|
|
onLoadMore();
|
|
}
|
|
},
|
|
{ threshold: 0.1 }
|
|
);
|
|
|
|
const currentRef = loadMoreRef.current;
|
|
if (currentRef) {
|
|
observer.observe(currentRef);
|
|
}
|
|
|
|
return () => {
|
|
if (currentRef) {
|
|
observer.unobserve(currentRef);
|
|
}
|
|
};
|
|
}, [hasMore, isLoadingMore, onLoadMore]);
|
|
|
|
return (
|
|
<div className="relative h-full overflow-auto border-t scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
|
<Table variant="compact/mono" containerClassName="overflow-visible" showTopBorder={false}>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow>
|
|
<TableHeaderCell className="min-w-48 whitespace-nowrap">Time</TableHeaderCell>
|
|
<TableHeaderCell className="min-w-24 whitespace-nowrap">Run</TableHeaderCell>
|
|
<TableHeaderCell className="min-w-32 whitespace-nowrap">Task</TableHeaderCell>
|
|
<TableHeaderCell
|
|
className="min-w-24 whitespace-nowrap"
|
|
tooltip={<LogLevelTooltipInfo />}
|
|
disableTooltipHoverableContent
|
|
>
|
|
Level
|
|
</TableHeaderCell>
|
|
<TableHeaderCell className="w-full min-w-0">Message</TableHeaderCell>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{logs.length === 0 ? (
|
|
<BlankState isLoading={isLoading} onRefresh={() => window.location.reload()} />
|
|
) : (
|
|
logs.map((log) => {
|
|
const isSelected = selectedLogId === log.id;
|
|
const runPath = v3RunSpanPath(
|
|
organization,
|
|
project,
|
|
environment,
|
|
{ friendlyId: log.runId },
|
|
{ spanId: log.spanId }
|
|
);
|
|
|
|
const handleRowClick = () => onLogSelect?.(log.id);
|
|
|
|
return (
|
|
<TableRow
|
|
key={log.id}
|
|
className={cn(
|
|
"cursor-pointer transition-colors",
|
|
isSelected ? "bg-background-hover" : "hover:bg-background-dimmed"
|
|
)}
|
|
isSelected={isSelected}
|
|
>
|
|
<TableCell
|
|
className="whitespace-nowrap tabular-nums"
|
|
onClick={handleRowClick}
|
|
hasAction
|
|
style={{
|
|
boxShadow: getLevelBoxShadow(log.level),
|
|
}}
|
|
>
|
|
<DateTimeAccurate date={log.triggeredTimestamp} hour12={false} />
|
|
</TableCell>
|
|
<TableCell className="min-w-24 cursor-pointer" onClick={handleRowClick}>
|
|
<span className="font-mono text-xs">{log.runId}</span>
|
|
</TableCell>
|
|
<TableCell className="min-w-32" onClick={handleRowClick} hasAction>
|
|
<span className="font-mono text-xs">{log.taskIdentifier}</span>
|
|
</TableCell>
|
|
<TableCell onClick={handleRowClick} hasAction>
|
|
<LogLevel level={log.level} />
|
|
</TableCell>
|
|
<TableCell className="max-w-0 truncate" onClick={handleRowClick} hasAction>
|
|
<span className="block truncate font-mono text-xs" title={log.message}>
|
|
{highlightSearchText(log.message, searchTerm)}
|
|
</span>
|
|
</TableCell>
|
|
<TableCellMenu
|
|
className="pl-32"
|
|
hiddenButtons={
|
|
<LinkButton
|
|
to={runPath}
|
|
variant="minimal/small"
|
|
TrailingIcon={RunsIcon}
|
|
trailingIconClassName="text-text-bright"
|
|
className="h-5.5 pl-1.5 pr-2"
|
|
>
|
|
<span className="text-[0.6875rem] text-text-bright">View run</span>
|
|
</LinkButton>
|
|
}
|
|
/>
|
|
</TableRow>
|
|
);
|
|
})
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
{/* Infinite scroll trigger */}
|
|
{hasMore && logs.length > 0 && (
|
|
<div ref={loadMoreRef} className="flex items-center justify-center py-12">
|
|
<div className={cn("flex items-center gap-2", !showLoadMoreSpinner && "invisible")}>
|
|
<Spinner /> <span className="text-text-dimmed">Loading more…</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* Show all logs message with check for more button */}
|
|
{!hasMore && logs.length > 0 && (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<span className="text-text-dimmed">Showing all {logs.length} logs</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BlankState({ isLoading, onRefresh }: { isLoading?: boolean; onRefresh?: () => void }) {
|
|
if (isLoading) return <TableBlankRow colSpan={6}></TableBlankRow>;
|
|
|
|
const handleRefresh = onRefresh ?? (() => window.location.reload());
|
|
|
|
return (
|
|
<TableBlankRow colSpan={6}>
|
|
<div className="flex flex-col items-center justify-center gap-6">
|
|
<Paragraph className="w-auto" variant="base/bright">
|
|
No logs match your filters. Try refreshing or modifying your filters.
|
|
</Paragraph>
|
|
<div className="flex items-center gap-2">
|
|
<Button LeadingIcon={ArrowPathIcon} variant="tertiary/medium" onClick={handleRefresh}>
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</TableBlankRow>
|
|
);
|
|
}
|