From 0a607505771e8bfda1a17a446db59da1c71c1fa0 Mon Sep 17 00:00:00 2001 From: Wilson Neto Date: Thu, 6 Mar 2025 19:00:04 -0300 Subject: [PATCH] feat: add real log viewer page with pako integration - Created logViewer.ts to handle log fetching and decompression using pako - Updated CodeBlock component to support log viewer variant - Added LogViewerCard with link to the new log viewer page - Implemented LogViewer page to display full logs - Updated routeTree.gen.ts to include the new log viewer route - Added new messages for log viewer in locales - Updated string utility to use URL object for truncation - Modified QuerySwitcher to display UnexpectedError component Closes #997 --- backend/kernelCI_app/views/proxyView.py | 24 ++- backend/schema.yml | 18 +++ dashboard/src/api/logViewer.ts | 59 +++++++ dashboard/src/components/Filter/CodeBlock.tsx | 122 +++++++++++---- dashboard/src/components/Log/LogExcerpt.tsx | 13 +- .../src/components/Log/LogViewerCard.tsx | 148 ++++-------------- .../QuerySwitcher/QuerySwitcher.tsx | 3 +- .../Sheet/LogOrJsonSheetContent.tsx | 1 + .../UnexpectedError/UnexpectedError.tsx | 4 +- dashboard/src/lib/string.ts | 21 +-- dashboard/src/locales/messages/index.ts | 2 + dashboard/src/pages/LogViewer.tsx | 50 ++++++ dashboard/src/routeTree.gen.ts | 28 +++- dashboard/src/routes/__root.tsx | 20 ++- dashboard/src/routes/_main/route.tsx | 19 +-- dashboard/src/routes/log-viewer.tsx | 13 ++ 16 files changed, 347 insertions(+), 198 deletions(-) create mode 100644 dashboard/src/api/logViewer.ts create mode 100644 dashboard/src/pages/LogViewer.tsx create mode 100644 dashboard/src/routes/log-viewer.tsx diff --git a/backend/kernelCI_app/views/proxyView.py b/backend/kernelCI_app/views/proxyView.py index c632d297..b68ce6e9 100644 --- a/backend/kernelCI_app/views/proxyView.py +++ b/backend/kernelCI_app/views/proxyView.py @@ -1,10 +1,10 @@ from urllib.parse import urlparse import requests from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework import status from django.http import HttpResponse +from http import HTTPStatus from drf_spectacular.utils import extend_schema +from kernelCI_app.helpers.errorHandling import create_api_error_response # TIMEOUT to avoid people sending very large files through the proxy TIMEOUT_TIME_IN_SECONDS = 45 @@ -13,22 +13,20 @@ class ProxyView(APIView): @extend_schema( description="Proxy endpoint to fetch from external sources and handle CORS issues", - responses={200: bytes}, + responses={HTTPStatus.OK: bytes}, ) def get(self, request): url = request.GET.get("url") parsed_url = urlparse(url) if not parsed_url.scheme or not parsed_url.netloc: - return Response( - {"error": "Invalid URL"}, status=status.HTTP_400_BAD_REQUEST - ) + return create_api_error_response(error_message="Invalid URL") try: response = requests.get(url, stream=True, timeout=TIMEOUT_TIME_IN_SECONDS) - if response.status_code != 200: - return Response( - {"error": f"Failed to fetch resource: {response.reason}"}, - status=response.status_code, + if response.status_code != HTTPStatus.OK: + return create_api_error_response( + error_message=f"Failed to fetch resource: {response.reason}", + status_code=HTTPStatus(response.status_code), ) proxy_response = HttpResponse( @@ -48,7 +46,7 @@ def get(self, request): return proxy_response except requests.RequestException: - return Response( - {"error": "Error fetching the resource"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + return create_api_error_response( + error_message="Error fetching the resource", + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, ) diff --git a/backend/schema.yml b/backend/schema.yml index f85060f4..2169935c 100644 --- a/backend/schema.yml +++ b/backend/schema.yml @@ -480,6 +480,24 @@ paths: schema: $ref: '#/components/schemas/LogDownloaderResponse' description: '' + /api/proxy/: + get: + operationId: proxy_retrieve + description: Proxy endpoint to fetch from external sources and handle CORS issues + tags: + - proxy + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: string + format: binary + description: '' /api/schema/: get: operationId: schema_retrieve diff --git a/dashboard/src/api/logViewer.ts b/dashboard/src/api/logViewer.ts new file mode 100644 index 00000000..17d53a30 --- /dev/null +++ b/dashboard/src/api/logViewer.ts @@ -0,0 +1,59 @@ +import pako from 'pako'; +import type { UseQueryResult } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; +import { minutesToMilliseconds } from 'date-fns'; + +import { RequestData } from './commonRequest'; + +// eslint-disable-next-line no-magic-numbers +const STALE_DURATION_MS = minutesToMilliseconds(60); + +type FetchAndDecompressLogsResponse = { + content: string; +}; +async function fetchAndDecompressLog( + url: string, +): Promise { + const proxyUrl = `/api/proxy/?url=${encodeURIComponent(url)}`; + const urlPathname = new URL(url).pathname; + const isGzipped = urlPathname.endsWith('.gz'); + + try { + if (isGzipped) { + const response = await RequestData.get(proxyUrl, { + responseType: 'arraybuffer', + }); + + const uint8ArrayResponse = new Uint8Array(response); + const decompressedData = pako.inflate(uint8ArrayResponse); + const textDecoder = new TextDecoder('utf-8'); + const decompressedText = textDecoder.decode(decompressedData); + + return { content: decompressedText }; + } else { + // For non-gzipped files, request as text + const response = await RequestData.get(proxyUrl, { + responseType: 'text', + }); + + return { content: response }; + } + } catch (error) { + console.error(error); + throw new Error( + `Failed to fetch logs: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } +} + +export const useLogViewer = ( + url: string, +): UseQueryResult => { + return useQuery({ + queryKey: ['logs', url], + queryFn: () => fetchAndDecompressLog(url), + enabled: !!url, + staleTime: STALE_DURATION_MS, + refetchOnWindowFocus: false, + }); +}; diff --git a/dashboard/src/components/Filter/CodeBlock.tsx b/dashboard/src/components/Filter/CodeBlock.tsx index f4aa809a..bb55f48f 100644 --- a/dashboard/src/components/Filter/CodeBlock.tsx +++ b/dashboard/src/components/Filter/CodeBlock.tsx @@ -6,6 +6,8 @@ import { FormattedMessage } from 'react-intl'; import type { PropsWithChildren, JSX } from 'react'; import { memo, useMemo } from 'react'; +import { useSearch } from '@tanstack/react-router'; + import { cn } from '@/lib/utils'; import ColoredCircle from '@/components/ColoredCircle/ColoredCircle'; @@ -19,10 +21,14 @@ import { DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; +import { zOrigin } from '@/types/general'; + +export type CodeBlockVariant = 'default' | 'log-viewer'; type TCodeBlockProps = { code: string; className?: string; + variant?: CodeBlockVariant; highlightsClassnames?: string; }; @@ -60,26 +66,35 @@ const CodeBlockDialog = ({ children }: PropsWithChildren): JSX.Element => { }; const Code = ({ - highlightedCode, + parsedCode, className, - footer, + statsElement, + variant, + disableHighlight, }: { - highlightedCode: string; + parsedCode: string; className?: string; - footer?: JSX.Element; + statsElement?: JSX.Element; + variant: CodeBlockVariant; + disableHighlight?: boolean; }): JSX.Element => { return ( <> + {variant === 'log-viewer' && statsElement}
-        
+ {disableHighlight ? ( + <>parsedCode + ) : ( +
+ )}
- {footer} + {variant !== 'log-viewer' && statsElement} ); }; @@ -193,45 +208,84 @@ const generateHighlightedCode = (code: string): IHighlightedCode => { const CodeBlock = ({ code, highlightsClassnames, + variant = 'default', }: TCodeBlockProps): JSX.Element => { - const highlightedCode: IHighlightedCode = useMemo( - () => generateHighlightedCode(code), - [code], - ); + const { origin: unsafeOrigin } = useSearch({ strict: false }); - const footerElement = - highlightedCode.highlightCount > 0 ? ( + const parsedOrigin = zOrigin.parse(unsafeOrigin); + // TODO Disable highlight based on filesize + const disableHighlight = + parsedOrigin === 'redhat' && variant === 'log-viewer'; + + const parsedCode: IHighlightedCode | string = useMemo(() => { + if (disableHighlight) { + return code; + } + + return generateHighlightedCode(code); + }, [code, disableHighlight]); + + const statsElement = + !disableHighlight && + typeof parsedCode !== 'string' && + parsedCode.highlightCount > 0 ? ( ) : undefined; return ( -
-
- - - -
+ <> +
+
+ {variant === 'log-viewer' && ( +

+ +

+ )} + {variant === 'default' && ( +

+ +

+ )} +
+ + {variant !== 'log-viewer' && ( + + + + )} - - - - -
+
+ ); }; diff --git a/dashboard/src/components/Log/LogExcerpt.tsx b/dashboard/src/components/Log/LogExcerpt.tsx index 30d22bbb..b1c20b75 100644 --- a/dashboard/src/components/Log/LogExcerpt.tsx +++ b/dashboard/src/components/Log/LogExcerpt.tsx @@ -3,7 +3,9 @@ import { FormattedMessage } from 'react-intl'; import type { JSX } from 'react'; import { Skeleton } from '@/components/ui/skeleton'; +import type { CodeBlockVariant } from '@/components/Filter/CodeBlock'; import CodeBlock from '@/components/Filter/CodeBlock'; +import { cn } from '@/lib/utils'; //TODO Localize the fallback string const FallbackLog = ` @@ -20,19 +22,26 @@ const FallbackLog = ` interface ILogExcerpt { isLoading?: boolean; logExcerpt?: string; + variant: CodeBlockVariant; } export const LogExcerpt = ({ isLoading, logExcerpt, + variant = 'default', }: ILogExcerpt): JSX.Element => { if (isLoading) { return ( - + ); } - return ; + // Use OR instead of ?? to handle empty strings + return ; }; diff --git a/dashboard/src/components/Log/LogViewerCard.tsx b/dashboard/src/components/Log/LogViewerCard.tsx index 3aa7bc3b..ff46c031 100644 --- a/dashboard/src/components/Log/LogViewerCard.tsx +++ b/dashboard/src/components/Log/LogViewerCard.tsx @@ -1,81 +1,11 @@ -import { GrDocumentDownload } from 'react-icons/gr'; - import { FormattedMessage } from 'react-intl'; +import { LuFileJson } from 'react-icons/lu'; -import { memo, type JSX } from 'react'; - -import { useLogFiles } from '@/api/treeDetails'; -import BaseCard from '@/components/Cards/BaseCard'; -import { truncateUrl } from '@/lib/string'; - -import QuerySwitcher from '@/components/QuerySwitcher/QuerySwitcher'; -import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table'; -import type { LogFile } from '@/types/tree/TreeDetails'; -import { DumbTableHeader, TableHead } from '@/components/Table/BaseTable'; +import { useMemo, type JSX } from 'react'; -const LogLink = ({ - combinedLogUrl, - children, -}: { - combinedLogUrl: string; - children: string; -}): JSX.Element => ( - - {children} - -); +import { Link } from '@tanstack/react-router'; -type TLogFilesTable = { - logFiles: LogFile[]; - logUrl: string; -}; - -const LogFilesTable = ({ logFiles, logUrl }: TLogFilesTable): JSX.Element => { - return ( - - - - - - - - - - - - - - {logFiles.map(logFile => { - const combinedLogUrl = `${logUrl}/${logFile.specific_log_url}`; - return ( - - - - {logFile.file_name} - - - - - {logFile.file_size} - - - - - {logFile.date} - - - - ); - })} - -
- ); -}; - -const MemoizedLogFilesTable = memo(LogFilesTable); +import BaseCard from '@/components/Cards/BaseCard'; interface ILogViewerCard { isLoading?: boolean; @@ -86,59 +16,45 @@ export const LogViewerCard = ({ isLoading, logUrl, }: ILogViewerCard): JSX.Element => { - const { data: logFilesData, status } = useLogFiles( - { logUrl: logUrl ?? '' }, - { enabled: !!logUrl }, - ); - + const fileName = useMemo(() => { + try { + return new URL(logUrl ?? '').pathname.split('/').pop(); + } catch { + console.error(`Invalid URL: ${logUrl}`); + return logUrl ?? ''; + } + }, [logUrl]); return ( } > -
+
{isLoading ? ( ) : ( - + {logUrl ? ( + <> + ({ url: logUrl ?? '', origin: s.origin })} + title="Log Viewer" > - {truncateUrl(logUrl)} - - - ), - }} - /> + + + + + ) : ( + + )} + )}
- {logUrl ? ( - - -
- } - > - - {logFilesData?.log_files && !!logUrl && ( - - )} -
- - ) : ( -
- )}
); }; diff --git a/dashboard/src/components/QuerySwitcher/QuerySwitcher.tsx b/dashboard/src/components/QuerySwitcher/QuerySwitcher.tsx index 97560a14..fb3f7b57 100644 --- a/dashboard/src/components/QuerySwitcher/QuerySwitcher.tsx +++ b/dashboard/src/components/QuerySwitcher/QuerySwitcher.tsx @@ -6,6 +6,7 @@ import { FormattedMessage } from 'react-intl'; import { cn } from '@/lib/utils'; import { Skeleton } from '@/components/ui/skeleton'; +import UnexpectedError from '@/components/UnexpectedError/UnexpectedError'; export type QuerySelectorStatus = UseQueryResult['status']; @@ -46,7 +47,7 @@ const QuerySwitcher = ({ customError ) : (
- +
)} diff --git a/dashboard/src/components/Sheet/LogOrJsonSheetContent.tsx b/dashboard/src/components/Sheet/LogOrJsonSheetContent.tsx index 9db49615..00cdd65a 100644 --- a/dashboard/src/components/Sheet/LogOrJsonSheetContent.tsx +++ b/dashboard/src/components/Sheet/LogOrJsonSheetContent.tsx @@ -67,6 +67,7 @@ export const LogOrJsonSheetContent = ({ {!hideIssueSection && ( diff --git a/dashboard/src/components/UnexpectedError/UnexpectedError.tsx b/dashboard/src/components/UnexpectedError/UnexpectedError.tsx index 47db503e..27222fbd 100644 --- a/dashboard/src/components/UnexpectedError/UnexpectedError.tsx +++ b/dashboard/src/components/UnexpectedError/UnexpectedError.tsx @@ -3,9 +3,9 @@ import { FormattedMessage } from 'react-intl'; import type { JSX } from 'react'; const UnexpectedError = (): JSX.Element => ( -

+

-

+ ); export default UnexpectedError; diff --git a/dashboard/src/lib/string.ts b/dashboard/src/lib/string.ts index b33c47d9..cc9802e8 100644 --- a/dashboard/src/lib/string.ts +++ b/dashboard/src/lib/string.ts @@ -36,16 +36,17 @@ export const truncateUrl = ( if (!shouldTruncate(url, domainLength + endPathLength)) { return url; } - const searchParamsRegex = /\?.*$/; - const replacedUrl = url - .replace(protocolRegex, '') - .replace(searchParamsRegex, ''); - const splittedUrl = replacedUrl.split('/'); - const hostname = splittedUrl[0]; - const pathname = splittedUrl?.pop(); - const domain = hostname ? hostname.slice(0, domainLength) : ''; - const lastPath = pathname ? pathname.slice(-endPathLength) : ''; - return `${domain}...${lastPath}`; + + try { + const urlObject = new URL(url); + + const domain = urlObject.hostname.slice(0, domainLength); + const lastPath = urlObject.pathname.slice(-endPathLength); + return `${domain}...${lastPath}`; + } catch { + console.error('Non URL passing to truncateUrl', url); + return url; + } }; export const matchesRegexOrIncludes = ( diff --git a/dashboard/src/locales/messages/index.ts b/dashboard/src/locales/messages/index.ts index 11202c7a..457225c1 100644 --- a/dashboard/src/locales/messages/index.ts +++ b/dashboard/src/locales/messages/index.ts @@ -211,6 +211,8 @@ export const messages = { 'This log url is not supported in the log viewer yet, but you can still download the log in the link above', 'logSheet.noLogFound': 'No logs available', 'logSheet.title': 'Logs Viewer', + 'logViewer.download': 'Download full log', + 'logViewer.viewFullLog': 'View full log for {fileName}', 'logspec.info': "This is the same logspec data that's in the misc data section", 'routes.buildDetails': 'Build', diff --git a/dashboard/src/pages/LogViewer.tsx b/dashboard/src/pages/LogViewer.tsx new file mode 100644 index 00000000..24682677 --- /dev/null +++ b/dashboard/src/pages/LogViewer.tsx @@ -0,0 +1,50 @@ +import { useSearch } from '@tanstack/react-router'; +import type { JSX } from 'react'; + +const Constants = { + URL_DOMAIN_SIZE: 90, + URL_END_PATH_SIZE: 50, +} as const; + +import { FormattedMessage } from 'react-intl'; + +import { LogExcerpt } from '@/components/Log/LogExcerpt'; +import { truncateUrl } from '@/lib/string'; +import { useLogViewer } from '@/api/logViewer'; +import QuerySwitcher from '@/components/QuerySwitcher/QuerySwitcher'; + +export function LogViewer(): JSX.Element { + const { url } = useSearch({ from: '/log-viewer' }); + + const { data, status } = useLogViewer(url); + + return ( +
+ +
+ + + +
+
+ ); +} diff --git a/dashboard/src/routeTree.gen.ts b/dashboard/src/routeTree.gen.ts index 348d5873..53b7c1ee 100644 --- a/dashboard/src/routeTree.gen.ts +++ b/dashboard/src/routeTree.gen.ts @@ -11,6 +11,7 @@ // Import Routes import { Route as rootRoute } from './routes/__root' +import { Route as LogViewerImport } from './routes/log-viewer' import { Route as MainRouteImport } from './routes/_main/route' import { Route as MainIndexImport } from './routes/_main/index' import { Route as MainTreeRouteImport } from './routes/_main/tree/route' @@ -49,6 +50,12 @@ import { Route as MainHardwareHardwareIdBootBootIdIndexImport } from './routes/_ // Create/Update Routes +const LogViewerRoute = LogViewerImport.update({ + id: '/log-viewer', + path: '/log-viewer', + getParentRoute: () => rootRoute, +} as any) + const MainRouteRoute = MainRouteImport.update({ id: '/_main', getParentRoute: () => rootRoute, @@ -286,6 +293,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MainRouteImport parentRoute: typeof rootRoute } + '/log-viewer': { + id: '/log-viewer' + path: '/log-viewer' + fullPath: '/log-viewer' + preLoaderRoute: typeof LogViewerImport + parentRoute: typeof rootRoute + } '/_main/hardware': { id: '/_main/hardware' path: '/hardware' @@ -746,6 +760,7 @@ const MainRouteRouteWithChildren = MainRouteRoute._addFileChildren( export interface FileRoutesByFullPath { '': typeof MainRouteRouteWithChildren + '/log-viewer': typeof LogViewerRoute '/hardware': typeof MainHardwareRouteRouteWithChildren '/issues': typeof MainIssuesRouteRouteWithChildren '/tree': typeof MainTreeRouteRouteWithChildren @@ -783,6 +798,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { + '/log-viewer': typeof LogViewerRoute '/': typeof MainIndexRoute '/hardware': typeof MainHardwareIndexRoute '/issues': typeof MainIssuesIndexRoute @@ -810,6 +826,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRoute '/_main': typeof MainRouteRouteWithChildren + '/log-viewer': typeof LogViewerRoute '/_main/hardware': typeof MainHardwareRouteRouteWithChildren '/_main/issues': typeof MainIssuesRouteRouteWithChildren '/_main/tree': typeof MainTreeRouteRouteWithChildren @@ -850,6 +867,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '' + | '/log-viewer' | '/hardware' | '/issues' | '/tree' @@ -886,6 +904,7 @@ export interface FileRouteTypes { | '/tree/$treeId/test/$testId' fileRoutesByTo: FileRoutesByTo to: + | '/log-viewer' | '/' | '/hardware' | '/issues' @@ -911,6 +930,7 @@ export interface FileRouteTypes { id: | '__root__' | '/_main' + | '/log-viewer' | '/_main/hardware' | '/_main/issues' | '/_main/tree' @@ -950,10 +970,12 @@ export interface FileRouteTypes { export interface RootRouteChildren { MainRouteRoute: typeof MainRouteRouteWithChildren + LogViewerRoute: typeof LogViewerRoute } const rootRouteChildren: RootRouteChildren = { MainRouteRoute: MainRouteRouteWithChildren, + LogViewerRoute: LogViewerRoute, } export const routeTree = rootRoute @@ -966,7 +988,8 @@ export const routeTree = rootRoute "__root__": { "filePath": "__root.tsx", "children": [ - "/_main" + "/_main", + "/log-viewer" ] }, "/_main": { @@ -984,6 +1007,9 @@ export const routeTree = rootRoute "/_main/(alternatives)/t/$testId" ] }, + "/log-viewer": { + "filePath": "log-viewer.tsx" + }, "/_main/hardware": { "filePath": "_main/hardware/route.tsx", "parent": "/_main", diff --git a/dashboard/src/routes/__root.tsx b/dashboard/src/routes/__root.tsx index abd8fd38..3bb38475 100644 --- a/dashboard/src/routes/__root.tsx +++ b/dashboard/src/routes/__root.tsx @@ -1,10 +1,28 @@ -import { createRootRoute, Outlet } from '@tanstack/react-router'; +import { + createRootRoute, + Outlet, + stripSearchParams, +} from '@tanstack/react-router'; import type { JSX } from 'react'; +import { z } from 'zod'; + +import { DEFAULT_ORIGIN, type SearchSchema, zOrigin } from '@/types/general'; + +const defaultValues = { + origin: DEFAULT_ORIGIN, +}; + +const RouteSchema = z.object({ + origin: zOrigin, +} satisfies SearchSchema); + const RouteComponent = (): JSX.Element => { return ; }; export const Route = createRootRoute({ + validateSearch: RouteSchema, + search: { middlewares: [stripSearchParams(defaultValues)] }, component: RouteComponent, }); diff --git a/dashboard/src/routes/_main/route.tsx b/dashboard/src/routes/_main/route.tsx index 0a143458..41d3478b 100644 --- a/dashboard/src/routes/_main/route.tsx +++ b/dashboard/src/routes/_main/route.tsx @@ -1,10 +1,4 @@ -import { z } from 'zod'; - -import { - createFileRoute, - Outlet, - stripSearchParams, -} from '@tanstack/react-router'; +import { createFileRoute, Outlet } from '@tanstack/react-router'; // Uncomment for TanStack Router devtools @@ -16,15 +10,6 @@ import type { JSX } from 'react'; import SideMenu from '@/components/SideMenu/SideMenu'; import TopBar from '@/components/TopBar/TopBar'; -import { DEFAULT_ORIGIN, type SearchSchema, zOrigin } from '@/types/general'; - -const defaultValues = { - origin: DEFAULT_ORIGIN, -}; - -const RouteSchema = z.object({ - origin: zOrigin, -} satisfies SearchSchema); const RouteComponent = (): JSX.Element => { const { formatMessage } = useIntl(); @@ -47,7 +32,5 @@ const RouteComponent = (): JSX.Element => { }; export const Route = createFileRoute('/_main')({ - validateSearch: RouteSchema, - search: { middlewares: [stripSearchParams(defaultValues)] }, component: RouteComponent, }); diff --git a/dashboard/src/routes/log-viewer.tsx b/dashboard/src/routes/log-viewer.tsx new file mode 100644 index 00000000..2cc4763e --- /dev/null +++ b/dashboard/src/routes/log-viewer.tsx @@ -0,0 +1,13 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { z } from 'zod'; + +import { LogViewer } from '@/pages/LogViewer'; + +const logViewerSchema = z.object({ + url: z.string(), +}); + +export const Route = createFileRoute('/log-viewer')({ + validateSearch: logViewerSchema, + component: LogViewer, +});