diff --git a/apps/webapp/app/components/TimezoneSetter.tsx b/apps/webapp/app/components/TimezoneSetter.tsx new file mode 100644 index 0000000000..3481af6571 --- /dev/null +++ b/apps/webapp/app/components/TimezoneSetter.tsx @@ -0,0 +1,30 @@ +import { useFetcher } from "@remix-run/react"; +import { useEffect, useRef } from "react"; +import { useTypedLoaderData } from "remix-typedjson"; +import type { loader } from "~/root"; + +export function TimezoneSetter() { + const { timezone: storedTimezone } = useTypedLoaderData(); + const fetcher = useFetcher(); + const hasSetTimezone = useRef(false); + + useEffect(() => { + if (hasSetTimezone.current) return; + + const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + if (browserTimezone && browserTimezone !== storedTimezone) { + hasSetTimezone.current = true; + fetcher.submit( + { timezone: browserTimezone }, + { + method: "POST", + action: "/resources/timezone", + encType: "application/json", + } + ); + } + }, [storedTimezone, fetcher]); + + return null; +} diff --git a/apps/webapp/app/components/code/TSQLEditor.tsx b/apps/webapp/app/components/code/TSQLEditor.tsx index 998fd2da71..1699b48f8c 100644 --- a/apps/webapp/app/components/code/TSQLEditor.tsx +++ b/apps/webapp/app/components/code/TSQLEditor.tsx @@ -1,7 +1,7 @@ import { sql, StandardSQL } from "@codemirror/lang-sql"; import { autocompletion, startCompletion } from "@codemirror/autocomplete"; import { linter, lintGutter } from "@codemirror/lint"; -import { EditorView } from "@codemirror/view"; +import { EditorView, keymap } from "@codemirror/view"; import type { ViewUpdate } from "@codemirror/view"; import { CheckIcon, ClipboardIcon, SparklesIcon, TrashIcon } from "@heroicons/react/20/solid"; import { @@ -60,6 +60,32 @@ const defaultProps: TSQLEditorDefaultProps = { schema: [], }; +// Toggle comment on current line with -- comment symbol +const toggleLineComment = (view: EditorView): boolean => { + const { from } = view.state.selection.main; + const line = view.state.doc.lineAt(from); + const lineText = line.text; + const trimmed = lineText.trimStart(); + const indent = lineText.length - trimmed.length; + + if (trimmed.startsWith("--")) { + // Remove comment: strip "-- " or just "--" + const afterComment = trimmed.slice(2); + const newText = lineText.slice(0, indent) + afterComment.replace(/^\s/, ""); + view.dispatch({ + changes: { from: line.from, to: line.to, insert: newText }, + }); + } else { + // Add comment: prepend "-- " to the line content + const newText = lineText.slice(0, indent) + "-- " + trimmed; + view.dispatch({ + changes: { from: line.from, to: line.to, insert: newText }, + }); + } + + return true; +}; + export function TSQLEditor(opts: TSQLEditorProps) { const { defaultValue = "", @@ -133,6 +159,14 @@ export function TSQLEditor(opts: TSQLEditorProps) { ); } + // Add keyboard shortcut for toggling comments + exts.push( + keymap.of([ + { key: "Cmd-/", run: toggleLineComment }, + { key: "Ctrl-/", run: toggleLineComment }, + ]) + ); + return exts; }, [schema, linterEnabled]); @@ -218,6 +252,9 @@ export function TSQLEditor(opts: TSQLEditorProps) { "min-h-0 flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600" )} ref={editor} + onClick={() => { + view?.focus(); + }} onBlur={() => { if (!onBlur) return; if (!view) return; diff --git a/apps/webapp/app/components/code/codeMirrorSetup.ts b/apps/webapp/app/components/code/codeMirrorSetup.ts index 811a6ebc29..52a8e12a4d 100644 --- a/apps/webapp/app/components/code/codeMirrorSetup.ts +++ b/apps/webapp/app/components/code/codeMirrorSetup.ts @@ -1,5 +1,5 @@ import { closeBrackets } from "@codemirror/autocomplete"; -import { indentWithTab } from "@codemirror/commands"; +import { indentWithTab, history, historyKeymap, undo, redo } from "@codemirror/commands"; import { bracketMatching } from "@codemirror/language"; import { lintKeymap } from "@codemirror/lint"; import { highlightSelectionMatches } from "@codemirror/search"; @@ -18,6 +18,7 @@ export function getEditorSetup(showLineNumbers = true, showHighlights = true): A const options = [ drawSelection(), dropCursor(), + history(), bracketMatching(), closeBrackets(), Prec.highest( @@ -31,7 +32,15 @@ export function getEditorSetup(showLineNumbers = true, showHighlights = true): A }, ]) ), - keymap.of([indentWithTab, ...lintKeymap]), + // Explicit undo/redo keybindings with high precedence + Prec.high( + keymap.of([ + { key: "Mod-z", run: undo }, + { key: "Mod-Shift-z", run: redo }, + { key: "Mod-y", run: redo }, + ]) + ), + keymap.of([indentWithTab, ...historyKeymap, ...lintKeymap]), ]; if (showLineNumbers) { diff --git a/apps/webapp/app/components/logs/LogDetailView.tsx b/apps/webapp/app/components/logs/LogDetailView.tsx index 22e2e288ac..6b3a76b8a8 100644 --- a/apps/webapp/app/components/logs/LogDetailView.tsx +++ b/apps/webapp/app/components/logs/LogDetailView.tsx @@ -8,7 +8,7 @@ import { useEffect, useState } from "react"; import { useTypedFetcher } from "remix-typedjson"; import { cn } from "~/utils/cn"; import { Button } from "~/components/primitives/Buttons"; -import { DateTime } from "~/components/primitives/DateTime"; +import { DateTimeAccurate } from "~/components/primitives/DateTime"; import { Header2, Header3 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Spinner } from "~/components/primitives/Spinner"; @@ -234,7 +234,7 @@ function DetailsTab({ log, runPath, searchTerm }: { log: LogEntry; runPath: stri
Timestamp
- +
diff --git a/apps/webapp/app/components/logs/LogsTable.tsx b/apps/webapp/app/components/logs/LogsTable.tsx index e8e785ae79..ca1dd8672c 100644 --- a/apps/webapp/app/components/logs/LogsTable.tsx +++ b/apps/webapp/app/components/logs/LogsTable.tsx @@ -1,4 +1,5 @@ import { ArrowPathIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid"; +import { Link } from "@remix-run/react"; import { useEffect, useRef, useState } from "react"; import { cn } from "~/utils/cn"; import { Button } from "~/components/primitives/Buttons"; @@ -8,7 +9,7 @@ import { useProject } from "~/hooks/useProject"; import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server"; import { getLevelColor, highlightSearchText } from "~/utils/logUtils"; import { v3RunSpanPath } from "~/utils/pathBuilder"; -import { DateTime } from "../primitives/DateTime"; +import { DateTimeAccurate } from "../primitives/DateTime"; import { Paragraph } from "../primitives/Paragraph"; import { Spinner } from "../primitives/Spinner"; import { TruncatedCopyableValue } from "../primitives/TruncatedCopyableValue"; @@ -24,8 +25,6 @@ import { TableRow, type TableVariant, } from "../primitives/Table"; -import { PopoverMenuItem } from "~/components/primitives/Popover"; -import { Link } from "@remix-run/react"; type LogsTableProps = { logs: LogEntry[]; @@ -34,6 +33,7 @@ type LogsTableProps = { isLoadingMore?: boolean; hasMore?: boolean; onLoadMore?: () => void; + onCheckForMore?: () => void; variant?: TableVariant; selectedLogId?: string; onLogSelect?: (logId: string) => void; @@ -63,6 +63,7 @@ export function LogsTable({ isLoadingMore = false, hasMore = false, onLoadMore, + onCheckForMore, selectedLogId, onLogSelect, }: LogsTableProps) { @@ -161,7 +162,7 @@ export function LogsTable({ boxShadow: getLevelBoxShadow(log.level), }} > - + @@ -203,21 +204,26 @@ export function LogsTable({ {/* Infinite scroll trigger */} {hasMore && logs.length > 0 && (
-
+
Loading more…
)} - {/* Show all logs message */} + {/* Show all logs message with check for more button */} {!hasMore && logs.length > 0 && (
-
+
Showing all {logs.length} logs + {onCheckForMore && ( + + )}
)} diff --git a/apps/webapp/app/components/primitives/DateTime.tsx b/apps/webapp/app/components/primitives/DateTime.tsx index d1bbbffb4a..906bbf8b21 100644 --- a/apps/webapp/app/components/primitives/DateTime.tsx +++ b/apps/webapp/app/components/primitives/DateTime.tsx @@ -1,4 +1,5 @@ import { GlobeAltIcon, GlobeAmericasIcon } from "@heroicons/react/20/solid"; +import { useRouteLoaderData } from "@remix-run/react"; import { Laptop } from "lucide-react"; import { memo, type ReactNode, useMemo, useSyncExternalStore } from "react"; import { CopyButton } from "./CopyButton"; @@ -19,7 +20,7 @@ function getLocalTimeZone(): string { // For SSR compatibility: returns "UTC" on server, actual timezone on client function subscribeToTimeZone() { // No-op - timezone doesn't change - return () => { }; + return () => {}; } function getTimeZoneSnapshot(): string { @@ -39,6 +40,18 @@ export function useLocalTimeZone(): string { return useSyncExternalStore(subscribeToTimeZone, getTimeZoneSnapshot, getServerTimeZoneSnapshot); } +/** + * Hook to get the user's preferred timezone. + * Returns the timezone stored in the user's preferences cookie (from root loader), + * falling back to the browser's local timezone if not set. + */ +export function useUserTimeZone(): string { + const rootData = useRouteLoaderData("root") as { timezone?: string } | undefined; + const localTimeZone = useLocalTimeZone(); + // Use stored timezone from cookie, or fall back to browser's local timezone + return rootData?.timezone && rootData.timezone !== "UTC" ? rootData.timezone : localTimeZone; +} + type DateTimeProps = { date: Date | string; timeZone?: string; @@ -63,7 +76,7 @@ export const DateTime = ({ hour12 = true, }: DateTimeProps) => { const locales = useLocales(); - const localTimeZone = useLocalTimeZone(); + const userTimeZone = useUserTimeZone(); const realDate = useMemo(() => (typeof date === "string" ? new Date(date) : date), [date]); @@ -71,7 +84,7 @@ export const DateTime = ({ {formatDateTime( realDate, - timeZone ?? localTimeZone, + timeZone ?? userTimeZone, locales, includeSeconds, includeTime, @@ -91,7 +104,7 @@ export const DateTime = ({ } @@ -167,7 +180,7 @@ export function formatDateTimeISO(date: Date, timeZone: string): string { // New component that only shows date when it changes export const SmartDateTime = ({ date, previousDate = null, hour12 = true }: DateTimeProps) => { const locales = useLocales(); - const localTimeZone = useLocalTimeZone(); + const userTimeZone = useUserTimeZone(); const realDate = typeof date === "string" ? new Date(date) : date; const realPrevDate = previousDate ? typeof previousDate === "string" @@ -180,8 +193,8 @@ export const SmartDateTime = ({ date, previousDate = null, hour12 = true }: Date // Format with appropriate function const formattedDateTime = showDatePart - ? formatSmartDateTime(realDate, localTimeZone, locales, hour12) - : formatTimeOnly(realDate, localTimeZone, locales, hour12); + ? formatSmartDateTime(realDate, userTimeZone, locales, hour12) + : formatTimeOnly(realDate, userTimeZone, locales, hour12); return {formattedDateTime.replace(/\s/g, String.fromCharCode(32))}; }; @@ -235,14 +248,16 @@ function formatTimeOnly( const DateTimeAccurateInner = ({ date, - timeZone = "UTC", + timeZone, previousDate = null, showTooltip = true, hideDate = false, hour12 = true, }: DateTimeProps) => { const locales = useLocales(); - const localTimeZone = useLocalTimeZone(); + const userTimeZone = useUserTimeZone(); + // Use provided timeZone prop if available, otherwise fall back to user's preferred timezone + const displayTimeZone = timeZone ?? userTimeZone; const realDate = typeof date === "string" ? new Date(date) : date; const realPrevDate = previousDate ? typeof previousDate === "string" @@ -253,13 +268,13 @@ const DateTimeAccurateInner = ({ // Smart formatting based on whether date changed const formattedDateTime = useMemo(() => { return hideDate - ? formatTimeOnly(realDate, localTimeZone, locales, hour12) + ? formatTimeOnly(realDate, displayTimeZone, locales, hour12) : realPrevDate ? isSameDay(realDate, realPrevDate) - ? formatTimeOnly(realDate, localTimeZone, locales, hour12) - : formatDateTimeAccurate(realDate, localTimeZone, locales, hour12) - : formatDateTimeAccurate(realDate, localTimeZone, locales, hour12); - }, [realDate, localTimeZone, locales, hour12, hideDate, previousDate]); + ? formatTimeOnly(realDate, displayTimeZone, locales, hour12) + : formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12) + : formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12); + }, [realDate, displayTimeZone, locales, hour12, hideDate, previousDate]); if (!showTooltip) return {formattedDateTime.replace(/\s/g, String.fromCharCode(32))}; @@ -268,7 +283,7 @@ const DateTimeAccurateInner = ({ ); @@ -328,9 +343,9 @@ function formatDateTimeAccurate( export const DateTimeShort = ({ date, hour12 = true }: DateTimeProps) => { const locales = useLocales(); - const localTimeZone = useLocalTimeZone(); + const userTimeZone = useUserTimeZone(); const realDate = typeof date === "string" ? new Date(date) : date; - const formattedDateTime = formatDateTimeShort(realDate, localTimeZone, locales, hour12); + const formattedDateTime = formatDateTimeShort(realDate, userTimeZone, locales, hour12); return {formattedDateTime.replace(/\s/g, String.fromCharCode(32))}; }; diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index dcbcac079a..69ced77356 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -1176,7 +1176,7 @@ const EnvironmentSchema = z CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"), // Logs List Query Settings (for paginated log views) - CLICKHOUSE_LOGS_LIST_MAX_MEMORY_USAGE: z.coerce.number().int().default(256_000_000), + CLICKHOUSE_LOGS_LIST_MAX_MEMORY_USAGE: z.coerce.number().int().default(1_000_000_000), CLICKHOUSE_LOGS_LIST_MAX_BYTES_BEFORE_EXTERNAL_SORT: z.coerce .number() .int() diff --git a/apps/webapp/app/hooks/useShortcutKeys.tsx b/apps/webapp/app/hooks/useShortcutKeys.tsx index 0674b5bc0b..eb6213a4e0 100644 --- a/apps/webapp/app/hooks/useShortcutKeys.tsx +++ b/apps/webapp/app/hooks/useShortcutKeys.tsx @@ -23,6 +23,7 @@ type useShortcutKeysProps = { action: (event: KeyboardEvent) => void; disabled?: boolean; enabledOnInputElements?: boolean; + scopes?: string | string[]; }; export function useShortcutKeys({ @@ -30,6 +31,7 @@ export function useShortcutKeys({ action, disabled = false, enabledOnInputElements, + scopes = "global", }: useShortcutKeysProps) { const { platform } = useOperatingSystem(); const { areShortcutsEnabled } = useShortcuts(); @@ -43,11 +45,14 @@ export function useShortcutKeys({ useHotkeys( keys, - (event, hotkeysEvent) => { - action(event); + (event) => { + if (!event.repeat) { + action(event); + } }, { enabled: isEnabled, + scopes, enableOnFormTags: isEnabled && (enabledOnInputElements ?? relevantShortcut?.enabledOnInputElements), enableOnContentEditable: diff --git a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts index 69a84932a3..b1c03f8b74 100644 --- a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts @@ -354,6 +354,8 @@ export class LogsListPresenter extends BasePresenter { queryBuilder.where("kind NOT IN {debugKinds: Array(String)}", { debugKinds: ["DEBUG_EVENT"], }); + + queryBuilder.where("NOT ((kind = 'LOG_INFO') AND (attributes_text = '{}'))"); } queryBuilder.where("kind NOT IN {debugSpans: Array(String)}", { diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index fb5fef9c84..c6027b1a6d 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -10,10 +10,12 @@ import { RouteErrorDisplay } from "./components/ErrorDisplay"; import { AppContainer, MainCenteredContainer } from "./components/layout/AppLayout"; import { ShortcutsProvider } from "./components/primitives/ShortcutsProvider"; import { Toast } from "./components/primitives/Toast"; +import { TimezoneSetter } from "./components/TimezoneSetter"; import { env } from "./env.server"; import { featuresForRequest } from "./features.server"; import { usePostHog } from "./hooks/usePostHog"; import { getUser } from "./services/session.server"; +import { getTimezonePreference } from "./services/preferences/uiPreferences.server"; import { appEnvTitleTag } from "./utils"; export const links: LinksFunction = () => { @@ -50,6 +52,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const toastMessage = session.get("toastMessage") as ToastMessage; const posthogProjectKey = env.POSTHOG_PROJECT_KEY; const features = featuresForRequest(request); + const timezone = await getTimezonePreference(request); const kapa = { websiteId: env.KAPA_AI_WEBSITE_ID, @@ -65,6 +68,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { appOrigin: env.APP_ORIGIN, triggerCliTag: env.TRIGGER_CLI_TAG, kapa, + timezone, }, { headers: { "Set-Cookie": await commitSession(session) } } ); @@ -118,6 +122,7 @@ export default function App() { + diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx index 6237d699b3..478652ba47 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx @@ -10,11 +10,10 @@ import { } 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 { LogsListPresenter } from "~/presenters/v3/LogsListPresenter.server"; +import { LogsListPresenter, LogEntry } from "~/presenters/v3/LogsListPresenter.server"; import type { LogLevel } from "~/utils/logUtils"; import { $replica, prisma } from "~/db.server"; import { clickhouseClient } from "~/services/clickhouseInstance.server"; @@ -26,7 +25,6 @@ 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 type { LogEntry } from "~/presenters/v3/LogsListPresenter.server"; import { LogDetailView } from "~/components/logs/LogDetailView"; import { LogsSearchInput } from "~/components/logs/LogsSearchInput"; import { LogsLevelFilter } from "~/components/logs/LogsLevelFilter"; @@ -154,7 +152,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { to, includeDebugLogs: isAdmin && showDebug, defaultPeriod: "1h", - retentionLimitDays, + retentionLimitDays }) .catch((error) => { if (error instanceof ServiceValidationError) { @@ -168,11 +166,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { isAdmin, showDebug, defaultPeriod: "1h", + retentionLimitDays, }); }; export default function Page() { - const { data, isAdmin, showDebug, defaultPeriod } = + const { data, isAdmin, showDebug, defaultPeriod, retentionLimitDays } = useTypedLoaderData(); return ( @@ -203,6 +202,7 @@ export default function Page() { isAdmin={isAdmin} showDebug={showDebug} defaultPeriod={defaultPeriod} + retentionLimitDays={retentionLimitDays} />
@@ -221,6 +221,7 @@ export default function Page() { isAdmin={isAdmin} showDebug={showDebug} defaultPeriod={defaultPeriod} + retentionLimitDays={retentionLimitDays} />
@@ -237,6 +238,7 @@ export default function Page() { isAdmin={isAdmin} showDebug={showDebug} defaultPeriod={defaultPeriod} + retentionLimitDays={retentionLimitDays} /> - - Showing last {retentionDays} {retentionDays === 1 ? 'day' : 'days'} - - - Upgrade - - - ); -} - function FiltersBar({ list, isAdmin, showDebug, defaultPeriod, + retentionLimitDays, }: { list?: Exclude["data"]>, { error: string }>; isAdmin: boolean; showDebug: boolean; defaultPeriod?: string; + retentionLimitDays: number; }) { const location = useOptimisticLocation(); const searchParams = new URLSearchParams(location.search); @@ -317,12 +297,16 @@ function FiltersBar({ <> - - + + {hasFilters && (
-
- {list?.retention?.wasClamped && ( - - )} {isAdmin && ( (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(() => { @@ -410,7 +394,7 @@ function LogsList({ } }, [selectedLogId]); - // Append new logs when fetcher completes (with deduplication) + // 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 @@ -420,10 +404,20 @@ function LogsList({ const existingIds = new Set(accumulatedLogs.map((log) => log.id)); const newLogs = fetcher.data.logs.filter((log) => !existingIds.has(log.id)); - if (newLogs.length > 0) { - setAccumulatedLogs((prev) => [...prev, ...newLogs]); + + if (isCheckingForNewRef.current) { + // "Check for new" - prepend new logs, don't update cursor + if (newLogs.length > 0) { + setAccumulatedLogs((prev) => [...newLogs, ...prev]); + } + isCheckingForNewRef.current = false; + } else { + // "Load more" - append logs and update cursor + if (newLogs.length > 0) { + setAccumulatedLogs((prev) => [...prev, ...newLogs]); + } + setNextCursor(fetcher.data.pagination.next); } - setNextCursor(fetcher.data.pagination.next); } }, [fetcher.data, fetcher.state, accumulatedLogs, location.search]); @@ -477,6 +471,18 @@ function LogsList({ 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 ( @@ -488,6 +494,7 @@ function LogsList({ isLoadingMore={fetcher.state === "loading"} hasMore={!!nextCursor} onLoadMore={handleLoadMore} + onCheckForMore={handleCheckForMore} selectedLogId={selectedLogId} onLogSelect={handleLogSelect} /> diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts index 656e20472e..c282e80d5a 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts @@ -8,6 +8,7 @@ import { LogsListPresenter, type LogLevel, LogsListOptionsSchema } from "~/prese import { $replica } from "~/db.server"; import { clickhouseClient } from "~/services/clickhouseInstance.server"; import { getCurrentPlan } from "~/services/platform.v3.server"; +import { getTimezonePreference } from "~/services/preferences/uiPreferences.server"; // Valid log levels for filtering const validLevels: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"]; diff --git a/apps/webapp/app/routes/resources.timezone.ts b/apps/webapp/app/routes/resources.timezone.ts new file mode 100644 index 0000000000..f06b44e614 --- /dev/null +++ b/apps/webapp/app/routes/resources.timezone.ts @@ -0,0 +1,43 @@ +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { + setTimezonePreference, + uiPreferencesStorage, +} from "~/services/preferences/uiPreferences.server"; + +const schema = z.object({ + timezone: z.string().min(1).max(100), +}); + +// Cache the supported timezones to avoid repeated calls +const supportedTimezones = new Set(Intl.supportedValuesOf("timeZone")); + +export async function action({ request }: ActionFunctionArgs) { + let data: unknown; + try { + data = await request.json(); + } catch { + return json({ success: false, error: "Invalid JSON" }, { status: 400 }); + } + + const result = schema.safeParse(data); + + if (!result.success) { + return json({ success: false, error: "Invalid timezone" }, { status: 400 }); + } + + if (!supportedTimezones.has(result.data.timezone)) { + return json({ success: false, error: "Invalid timezone" }, { status: 400 }); + } + + const session = await setTimezonePreference(result.data.timezone, request); + + return json( + { success: true }, + { + headers: { + "Set-Cookie": await uiPreferencesStorage.commitSession(session), + }, + } + ); +} diff --git a/apps/webapp/app/services/preferences/uiPreferences.server.ts b/apps/webapp/app/services/preferences/uiPreferences.server.ts index 0d23a546c2..44282499db 100644 --- a/apps/webapp/app/services/preferences/uiPreferences.server.ts +++ b/apps/webapp/app/services/preferences/uiPreferences.server.ts @@ -42,3 +42,15 @@ export async function setRootOnlyFilterPreference(rootOnly: boolean, request: Re session.set("rootOnly", rootOnly); return session; } + +export async function getTimezonePreference(request: Request): Promise { + const session = await getUiPreferencesSession(request); + const timezone = session.get("timezone"); + return typeof timezone === "string" ? timezone : "UTC"; +} + +export async function setTimezonePreference(timezone: string, request: Request) { + const session = await getUiPreferencesSession(request); + session.set("timezone", timezone); + return session; +}