import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; import { type MetaFunction, useFetcher, useNavigation, useLocation, Form } from "@remix-run/react"; import { XMarkIcon } from "@heroicons/react/20/solid"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { TypedAwait, typeddefer, type UseDataFunctionReturn, useTypedLoaderData, } from "remix-typedjson"; import { requireUser } from "~/services/session.server"; import { getCurrentPlan } from "~/services/platform.v3.server"; import { EnvironmentParamSchema } from "~/utils/pathBuilder"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server"; import { LogsListPresenter } from "~/presenters/v3/LogsListPresenter.server"; import type { LogLevel } from "~/utils/logUtils"; import { $replica, prisma } from "~/db.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"; import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; import { Spinner } from "~/components/primitives/Spinner"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Callout } from "~/components/primitives/Callout"; import { LogsTable } from "~/components/logs/LogsTable"; import { LogDetailView } from "~/components/logs/LogDetailView"; import { SearchInput } from "~/components/primitives/SearchInput"; import { LogsLevelFilter } from "~/components/logs/LogsLevelFilter"; import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter"; import { LogsRunIdFilter } from "~/components/logs/LogsRunIdFilter"; import { TimeFilter } from "~/components/runs/v3/SharedFilters"; import { RESIZABLE_PANEL_ANIMATION, ResizableHandle, ResizablePanel, ResizablePanelGroup, collapsibleHandleClassName, useFrozenValue, } from "~/components/primitives/Resizable"; import { Button } from "~/components/primitives/Buttons"; import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags"; // Valid log levels for filtering const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]; function parseLevelsFromUrl(url: URL): LogLevel[] | undefined { const levelParams = url.searchParams.getAll("levels").filter((v) => v.length > 0); if (levelParams.length === 0) return undefined; return levelParams.filter((l): l is LogLevel => validLevels.includes(l as LogLevel)); } export const meta: MetaFunction = () => { return [ { title: `Logs | Trigger.dev`, }, ]; }; // TODO: Move this to a more appropriate shared location async function hasLogsPageAccess( userId: string, isAdmin: boolean, isImpersonating: boolean, organizationSlug: string ): Promise { if (isAdmin || isImpersonating) { return true; } // Check organization feature flags const organization = await prisma.organization.findFirst({ where: { slug: organizationSlug, members: { some: { userId } }, }, select: { featureFlags: true, }, }); if (!organization?.featureFlags) { return false; } const flags = organization.featureFlags as Record; const hasLogsPageAccessResult = validateFeatureFlagValue( FEATURE_FLAG.hasLogsPageAccess, flags.hasLogsPageAccess ); return hasLogsPageAccessResult.success && hasLogsPageAccessResult.data === true; } export const loader = async ({ request, params }: LoaderFunctionArgs) => { const user = await requireUser(request); const userId = user.id; const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params); const canAccess = await hasLogsPageAccess( userId, user.admin, user.isImpersonating, organizationSlug ); if (!canAccess) { throw redirect("/"); } const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) { throw new Response("Project not found", { status: 404 }); } const environment = await findEnvironmentBySlug(project.id, envParam, userId); if (!environment) { throw new Response("Environment not found", { status: 404 }); } // Get filters from query params const url = new URL(request.url); const tasks = url.searchParams.getAll("tasks").filter((t) => t.length > 0); const runId = url.searchParams.get("runId") ?? undefined; const search = url.searchParams.get("search") ?? undefined; const levels = parseLevelsFromUrl(url); const period = url.searchParams.get("period") ?? undefined; const fromStr = url.searchParams.get("from"); const toStr = url.searchParams.get("to"); const from = fromStr ? parseInt(fromStr, 10) : undefined; const to = toStr ? parseInt(toStr, 10) : undefined; // Get the user's plan to determine log retention limit const plan = await getCurrentPlan(project.organizationId); const retentionLimitDays = plan?.v3Subscription?.plan?.limits.logRetentionDays.number ?? 30; const logsClickhouse = await clickhouseFactory.getClickhouseForOrganization( project.organizationId, "logs" ); const presenter = new LogsListPresenter($replica, logsClickhouse); const listPromise = presenter .call(project.organizationId, environment.id, { userId, projectId: project.id, tasks: tasks.length > 0 ? tasks : undefined, runId, search, levels, period, from, to, defaultPeriod: "1h", retentionLimitDays, }) .catch((error) => { if (error instanceof ServiceValidationError) { return { error: error.message }; } throw error; }); return typeddefer({ data: listPromise, defaultPeriod: "1h", retentionLimitDays, }); }; export default function Page() { const { data, defaultPeriod, retentionLimitDays } = useTypedLoaderData(); return (
Loading logs…
} >
Unable to load your logs. Please refresh the page or try again in a moment.
} > {(result) => { // Check if result contains an error if ("error" in result) { return (
{result.error}
); } return (
); }}
); } function FiltersBar({ list, defaultPeriod, retentionLimitDays, }: { list?: Exclude["data"]>, { error: string }>; defaultPeriod?: string; retentionLimitDays: number; }) { const location = useOptimisticLocation(); const searchParams = new URLSearchParams(location.search); const hasFilters = searchParams.has("tasks") || searchParams.has("runId") || searchParams.has("search") || searchParams.has("levels") || searchParams.has("period") || searchParams.has("from") || searchParams.has("to"); return (
{list ? ( <> {hasFilters && (
); } function LogsList({ list, }: { list: Exclude["data"]>, { error: string }>; //exclude error, it is handled defaultPeriod?: string; }) { const navigation = useNavigation(); const location = useLocation(); const fetcher = useFetcher<{ logs: LogEntry[]; pagination: { next?: string } }>(); const [, startTransition] = useTransition(); const isLoading = navigation.state !== "idle"; // Accumulated logs state const [accumulatedLogs, setAccumulatedLogs] = useState(list.logs); const [nextCursor, setNextCursor] = useState(list.pagination.next); // Selected log state - managed locally to avoid triggering navigation const [selectedLogId, setSelectedLogId] = useState(() => { const params = new URLSearchParams(location.search); return params.get("log") ?? undefined; }); // Track which filter state (search params) the current fetcher request corresponds to const fetcherFilterStateRef = useRef(location.search); // Track whether the current fetch is a "check for new" request vs "load more" const isCheckingForNewRef = useRef(false); // Clear accumulated logs immediately when filters change (for instant visual feedback) useEffect(() => { setAccumulatedLogs([]); setNextCursor(undefined); // Preserve log selection from URL param, clear if not present const params = new URLSearchParams(location.search); setSelectedLogId(params.get("log") ?? undefined); }, [location.search]); // Populate accumulated logs when new data arrives useEffect(() => { setAccumulatedLogs(list.logs); setNextCursor(list.pagination.next); }, [list.logs, list.pagination.next]); // Clear log parameter from URL when selectedLogId is cleared useEffect(() => { if (!selectedLogId) { const url = new URL(window.location.href); if (url.searchParams.has("log")) { url.searchParams.delete("log"); window.history.replaceState(null, "", url.toString()); } } }, [selectedLogId]); // Append/prepend new logs when fetcher completes (with deduplication) useEffect(() => { if (fetcher.data && fetcher.state === "idle") { // Ignore fetcher data if it was loaded for a different filter state if (fetcherFilterStateRef.current !== location.search) { return; } if (isCheckingForNewRef.current) { // "Check for new" - prepend new logs, don't update cursor setAccumulatedLogs((prev) => { const existingIds = new Set(prev.map((log) => log.id)); const newLogs = fetcher.data!.logs.filter((log) => !existingIds.has(log.id)); return newLogs.length > 0 ? [...newLogs, ...prev] : prev; }); isCheckingForNewRef.current = false; } else { // "Load more" - append logs and update cursor setAccumulatedLogs((prev) => { const existingIds = new Set(prev.map((log) => log.id)); const newLogs = fetcher.data!.logs.filter((log) => !existingIds.has(log.id)); return newLogs.length > 0 ? [...prev, ...newLogs] : prev; }); setNextCursor(fetcher.data.pagination.next); } } }, [fetcher.data, fetcher.state, location.search]); // Build resource URL for loading more const loadMoreUrl = useMemo(() => { if (!nextCursor) return null; const resourcePath = `/resources${location.pathname}`; const params = new URLSearchParams(location.search); params.set("cursor", nextCursor); params.delete("log"); return `${resourcePath}?${params.toString()}`; }, [location.pathname, location.search, nextCursor]); const handleLoadMore = useCallback(() => { if (loadMoreUrl && fetcher.state === "idle") { // Store the current filter state before loading fetcherFilterStateRef.current = location.search; fetcher.load(loadMoreUrl); } }, [loadMoreUrl, fetcher, location.search]); const selectedLog = useMemo(() => { if (!selectedLogId) return undefined; return accumulatedLogs.find((log) => log.id === selectedLogId); }, [selectedLogId, accumulatedLogs]); const frozenLogId = useFrozenValue(selectedLogId); const frozenLog = useFrozenValue(selectedLog); const displayLogId = selectedLogId ?? frozenLogId; const displayLog = selectedLog ?? frozenLog ?? undefined; const updateUrlWithLog = useCallback((logId: string | undefined) => { const url = new URL(window.location.href); if (logId) { url.searchParams.set("log", logId); } else { url.searchParams.delete("log"); } window.history.replaceState(null, "", url.toString()); }, []); const handleLogSelect = useCallback( (logId: string) => { startTransition(() => { setSelectedLogId(logId); }); updateUrlWithLog(logId); }, [updateUrlWithLog, startTransition] ); const handleClosePanel = useCallback(() => { startTransition(() => { setSelectedLogId(undefined); }); updateUrlWithLog(undefined); }, [updateUrlWithLog, startTransition]); const handleCheckForMore = useCallback(() => { if (fetcher.state !== "idle") return; // Fetch without cursor to check for new logs const resourcePath = `/resources${location.pathname}`; const params = new URLSearchParams(location.search); params.delete("cursor"); params.delete("log"); fetcherFilterStateRef.current = location.search; isCheckingForNewRef.current = true; fetcher.load(`${resourcePath}?${params.toString()}`); }, [fetcher, location.pathname, location.search]); return ( {}} collapsedSize="0px" collapseAnimation={RESIZABLE_PANEL_ANIMATION} >
{displayLogId && (
} > )}
); }