Skip to content

Commit

Permalink
feat: add real log viewer page with pako integration
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
WilsonNet committed Mar 7, 2025
1 parent 8d0ba3b commit 0a60750
Show file tree
Hide file tree
Showing 16 changed files with 347 additions and 198 deletions.
24 changes: 11 additions & 13 deletions backend/kernelCI_app/views/proxyView.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(
Expand All @@ -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,
)
18 changes: 18 additions & 0 deletions backend/schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions dashboard/src/api/logViewer.ts
Original file line number Diff line number Diff line change
@@ -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<FetchAndDecompressLogsResponse> {
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<ArrayBuffer>(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<string>(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<FetchAndDecompressLogsResponse> => {
return useQuery({
queryKey: ['logs', url],
queryFn: () => fetchAndDecompressLog(url),
enabled: !!url,
staleTime: STALE_DURATION_MS,
refetchOnWindowFocus: false,
});
};
122 changes: 88 additions & 34 deletions dashboard/src/components/Filter/CodeBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
};

Expand Down Expand Up @@ -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}
<pre
className={cn(
'w-full max-w-[100vw] overflow-x-auto rounded-md bg-[#DDDDDD] p-4 font-mono text-sm leading-4 text-[#767676]',
className,
)}
>
<div dangerouslySetInnerHTML={{ __html: highlightedCode }}></div>
{disableHighlight ? (
<>parsedCode</>
) : (
<div dangerouslySetInnerHTML={{ __html: parsedCode }}></div>
)}
</pre>

{footer}
{variant !== 'log-viewer' && statsElement}
</>
);
};
Expand Down Expand Up @@ -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 ? (
<HighlightCounts
highlightedCode={highlightedCode}
highlightedCode={parsedCode}
highlightsClassnames={highlightsClassnames}
/>
) : undefined;

return (
<div>
<div className="py-4 pl-3">
<span className="font-bold">
<FormattedMessage
id="global.logExcerpt"
defaultMessage="Log Excerpt"
/>
</span>
</div>
<>
<div className="h-full">
<div className="pl-3">
{variant === 'log-viewer' && (
<h3 className="py-1 text-2xl font-bold">
<FormattedMessage
id="global.fullLogs"
defaultMessage="Log Excerpt"
/>
</h3>
)}
{variant === 'default' && (
<h3 className="py-4 font-bold">
<FormattedMessage
id="global.logExcerpt"
defaultMessage="Log Excerpt"
/>
</h3>
)}
</div>

{variant !== 'log-viewer' && (
<CodeBlockDialog>
<MemoizedCode
className="max-h-[100%]"
parsedCode={
typeof parsedCode === 'string'
? parsedCode
: parsedCode.highlightedCode
}
statsElement={statsElement}
variant={variant}
/>
</CodeBlockDialog>
)}

<CodeBlockDialog>
<MemoizedCode
className="max-h-[100%]"
highlightedCode={highlightedCode.highlightedCode}
footer={footerElement}
className={cn('', {
'max-h-[425px]': variant !== 'log-viewer',
})}
variant={variant}
parsedCode={
typeof parsedCode === 'string'
? parsedCode
: parsedCode.highlightedCode
}
statsElement={statsElement}
/>
</CodeBlockDialog>

<MemoizedCode
className="max-h-[425px]"
highlightedCode={highlightedCode.highlightedCode}
footer={footerElement}
/>
</div>
</div>
</>
);
};

Expand Down
13 changes: 11 additions & 2 deletions dashboard/src/components/Log/LogExcerpt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
Expand All @@ -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 (
<Skeleton className="grid h-[400px] place-items-center">
<Skeleton
className={cn('grid h-[400px] place-items-center', {
'flex-1': variant === 'log-viewer',
})}
>
<FormattedMessage id="global.loading" />
</Skeleton>
);
}

return <CodeBlock code={logExcerpt ?? FallbackLog} />;
// Use OR instead of ?? to handle empty strings
return <CodeBlock code={logExcerpt || FallbackLog} variant={variant} />;
};
Loading

0 comments on commit 0a60750

Please sign in to comment.