chore: import upstream snapshot with attribution
This commit is contained in:
+511
@@ -0,0 +1,511 @@
|
||||
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<boolean> {
|
||||
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<string, unknown>;
|
||||
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<typeof loader>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Logs" />
|
||||
</NavBar>
|
||||
|
||||
<PageBody scrollable={false}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto] overflow-hidden">
|
||||
<div className="border-b border-grid-bright" />
|
||||
<div className="my-2 flex items-center justify-center">
|
||||
<div className="mx-auto flex items-center gap-2">
|
||||
<Spinner />
|
||||
<Paragraph variant="small">Loading logs…</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TypedAwait
|
||||
resolve={data}
|
||||
errorElement={
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto_1fr] overflow-hidden">
|
||||
<FiltersBar defaultPeriod={defaultPeriod} retentionLimitDays={retentionLimitDays} />
|
||||
<div className="flex items-center justify-center px-3 py-12">
|
||||
<Callout variant="error" className="max-w-fit">
|
||||
Unable to load your logs. Please refresh the page or try again in a moment.
|
||||
</Callout>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(result) => {
|
||||
// Check if result contains an error
|
||||
if ("error" in result) {
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto_1fr] overflow-hidden">
|
||||
<FiltersBar
|
||||
defaultPeriod={defaultPeriod}
|
||||
retentionLimitDays={retentionLimitDays}
|
||||
/>
|
||||
<div className="flex items-center justify-center px-3 py-12">
|
||||
<Callout variant="error" className="max-w-fit">
|
||||
{result.error}
|
||||
</Callout>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden">
|
||||
<FiltersBar
|
||||
list={result}
|
||||
defaultPeriod={defaultPeriod}
|
||||
retentionLimitDays={retentionLimitDays}
|
||||
/>
|
||||
<LogsList list={result} defaultPeriod={defaultPeriod} />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function FiltersBar({
|
||||
list,
|
||||
defaultPeriod,
|
||||
retentionLimitDays,
|
||||
}: {
|
||||
list?: Exclude<Awaited<UseDataFunctionReturn<typeof loader>["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 (
|
||||
<div className="flex items-start justify-between gap-x-2 border-b border-grid-bright p-2">
|
||||
<div className="flex flex-row flex-wrap items-center gap-1.5">
|
||||
{list ? (
|
||||
<>
|
||||
<SearchInput />
|
||||
<LogsTaskFilter possibleTasks={list.possibleTasks} />
|
||||
<LogsRunIdFilter />
|
||||
<TimeFilter defaultPeriod={defaultPeriod} maxPeriodDays={retentionLimitDays} />
|
||||
<LogsLevelFilter />
|
||||
{hasFilters && (
|
||||
<Form className="-ml-1 h-6">
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
LeadingIcon={XMarkIcon}
|
||||
tooltip="Clear all filters"
|
||||
className="group-hover/button:bg-transparent"
|
||||
leadingIconClassName="group-hover/button:text-text-bright"
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogsTaskFilter possibleTasks={[]} />
|
||||
<LogsRunIdFilter />
|
||||
<TimeFilter defaultPeriod={defaultPeriod} maxPeriodDays={retentionLimitDays} />
|
||||
<LogsLevelFilter />
|
||||
<SearchInput />
|
||||
{hasFilters && (
|
||||
<Form className="-ml-1 h-6">
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
LeadingIcon={XMarkIcon}
|
||||
tooltip="Clear all filters"
|
||||
className="group-hover/button:bg-transparent"
|
||||
leadingIconClassName="group-hover/button:text-text-bright"
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogsList({
|
||||
list,
|
||||
}: {
|
||||
list: Exclude<Awaited<UseDataFunctionReturn<typeof loader>["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<LogEntry[]>(list.logs);
|
||||
const [nextCursor, setNextCursor] = useState<string | undefined>(list.pagination.next);
|
||||
|
||||
// Selected log state - managed locally to avoid triggering navigation
|
||||
const [selectedLogId, setSelectedLogId] = useState<string | undefined>(() => {
|
||||
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<string>(location.search);
|
||||
// Track whether the current fetch is a "check for new" request vs "load more"
|
||||
const isCheckingForNewRef = useRef<boolean>(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 (
|
||||
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
|
||||
<ResizablePanel id="logs-main" min="200px">
|
||||
<LogsTable
|
||||
key={location.search}
|
||||
logs={accumulatedLogs}
|
||||
searchTerm={list.searchTerm}
|
||||
isLoading={isLoading}
|
||||
isLoadingMore={fetcher.state === "loading"}
|
||||
hasMore={!!nextCursor}
|
||||
onLoadMore={handleLoadMore}
|
||||
onCheckForMore={handleCheckForMore}
|
||||
selectedLogId={selectedLogId}
|
||||
onLogSelect={handleLogSelect}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle id="logs-handle" className={collapsibleHandleClassName(!!selectedLogId)} />
|
||||
<ResizablePanel
|
||||
id="log-detail"
|
||||
default="430px"
|
||||
min="430px"
|
||||
max="600px"
|
||||
className="overflow-hidden"
|
||||
collapsible
|
||||
collapsed={!selectedLogId}
|
||||
onCollapseChange={() => {}}
|
||||
collapsedSize="0px"
|
||||
collapseAnimation={RESIZABLE_PANEL_ANIMATION}
|
||||
>
|
||||
<div className="h-full" style={{ minWidth: 430 }}>
|
||||
{displayLogId && (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LogDetailView
|
||||
logId={displayLogId}
|
||||
initialLog={displayLog}
|
||||
onClose={handleClosePanel}
|
||||
searchTerm={list.searchTerm}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user