Skip to content

Commit 97fd6b9

Browse files
feat: Client-Side Structured Logs (#801)
1 parent 038ce9e commit 97fd6b9

File tree

15 files changed

+718
-626
lines changed

15 files changed

+718
-626
lines changed

src/app/runs/[id]/_Tabs/LogTab/logs.tsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import { EnhancedLogsViewer } from "@/components/logs/enhanced-log-viewer";
44
import { LoadingLogs } from "@/components/logs/loading-logs";
55
import { usePipelineRun } from "@/data/pipeline-runs/pipeline-run-detail-query";
66
import { useRunLogs } from "@/data/pipeline-runs/run-logs";
7-
import { parseLogString } from "@/lib/logs";
87
import { Skeleton } from "@zenml-io/react-component-library/components/server";
98
import { useMemo, useState } from "react";
109
import { useParams } from "react-router-dom";
1110
import { LogCombobox } from "./combobox";
11+
import { buildInternalLogEntries } from "@/lib/logs";
1212

1313
export function LogTab() {
1414
const { runId } = useParams() as { runId: string };
@@ -39,14 +39,18 @@ function LogTabContent({ sources, runId }: { sources: string[]; runId: string })
3939
const [selectedSource, setSelectedSource] = useState<string>(sources[0]);
4040
return (
4141
<section className="space-y-5">
42-
{sources.length > 1 && (
42+
{sources.length > 0 && (
4343
<div className="flex items-center gap-2">
4444
<span className="text-theme-text-secondary">Logs source:</span>
45-
<LogCombobox
46-
sources={sources}
47-
selectedSource={selectedSource}
48-
setSelectedSource={setSelectedSource}
49-
/>
45+
{sources.length > 1 ? (
46+
<LogCombobox
47+
sources={sources}
48+
selectedSource={selectedSource}
49+
setSelectedSource={setSelectedSource}
50+
/>
51+
) : (
52+
<span className="font-semibold capitalize">{selectedSource}</span>
53+
)}
5054
</div>
5155
)}
5256
<LogDisplay selectedSource={selectedSource} runId={runId} />
@@ -63,7 +67,7 @@ function LogDisplay({ selectedSource, runId }: LogTabContentProps) {
6367

6468
const parsedLogs = useMemo(() => {
6569
if (!runLogs.data) return [];
66-
return parseLogString(runLogs.data);
70+
return buildInternalLogEntries(runLogs.data);
6771
}, [runLogs.data]);
6872

6973
if (runLogs.isPending) return <LoadingLogs />;

src/components/logs/enhanced-log-viewer.tsx

Lines changed: 52 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import Collapse from "@/assets/icons/collapse-text.svg?react";
22
import Expand from "@/assets/icons/expand-full.svg?react";
3-
import { LogEntry as LogEntryType } from "@/types/logs"; // Assuming types are in src/types/logs.ts
4-
import { Button } from "@zenml-io/react-component-library/components/server";
5-
import React, { useState } from "react";
3+
import { LogEntryInternal } from "@/types/logs"; // Assuming types are in src/types/logs.ts
4+
import { Button, Input } from "@zenml-io/react-component-library/components/server";
5+
import React, { useCallback, useState } from "react";
6+
import { EmptyStateLogs } from "./empty-state-logs";
67
import LogLine from "./log-line"; // Import the LogLine component
78
import { LogToolbar } from "./toolbar";
89
import { useLogSearch } from "./use-log-search";
9-
import { EmptyStateLogs } from "./empty-state-logs";
10+
import { useLogLevelFilter } from "./use-loglevel-filter";
11+
import { useLogPageInput } from "./use-logpage-input";
1012

1113
interface EnhancedLogsViewerProps {
12-
logs: LogEntryType[];
14+
logs: LogEntryInternal[];
1315
itemsPerPage?: number; // Optional prop for items per page
1416
}
1517

@@ -19,10 +21,15 @@ export function EnhancedLogsViewer({
1921
logs,
2022
itemsPerPage = DEFAULT_ITEMS_PER_PAGE
2123
}: EnhancedLogsViewerProps) {
22-
const [currentPage, setCurrentPage] = useState(1);
24+
const [currentPage, setCurrentPageState] = useState(1);
2325
const [textWrapEnabled, setTextWrapEnabled] = useState(true);
2426

25-
// Initialize search functionality on filtered logs
27+
const {
28+
filteredLogs: filteredLogsByLogLevel,
29+
selectedLogLevel,
30+
setSelectedLogLevel
31+
} = useLogLevelFilter(logs);
32+
2633
const {
2734
searchQuery,
2835
setSearchQuery,
@@ -32,27 +39,42 @@ export function EnhancedLogsViewer({
3239
goToNextMatch,
3340
goToPreviousMatch,
3441
highlightText
35-
} = useLogSearch(logs);
42+
} = useLogSearch(filteredLogsByLogLevel);
3643

3744
// Use search + filtered logs for pagination
3845
const logsToDisplay = searchAndFilteredLogs;
3946

4047
// Reset to first page when search or filters change
41-
React.useEffect(() => {
42-
setCurrentPage(1);
43-
}, [searchQuery]);
4448

4549
const totalPages = Math.ceil(logsToDisplay.length / itemsPerPage);
4650
const startIndex = (currentPage - 1) * itemsPerPage;
4751
const endIndex = startIndex + itemsPerPage;
4852
const currentLogs = logsToDisplay.slice(startIndex, endIndex);
4953

54+
const { form } = useLogPageInput(currentPage, totalPages);
55+
56+
const setCurrentPage = useCallback(
57+
(page: number) => {
58+
setCurrentPageState(page);
59+
form.setValue("page", page);
60+
},
61+
[form]
62+
);
63+
64+
function handlePageSubmit(data: { page: number }) {
65+
setCurrentPage(data.page);
66+
}
67+
68+
React.useEffect(() => {
69+
setCurrentPage(1);
70+
}, [searchQuery]);
71+
5072
const handlePreviousPage = () => {
51-
setCurrentPage((prev) => Math.max(prev - 1, 1));
73+
setCurrentPage(Math.max(currentPage - 1, 1));
5274
};
5375

5476
const handleNextPage = () => {
55-
setCurrentPage((prev) => Math.min(prev + 1, totalPages));
77+
setCurrentPage(Math.min(currentPage + 1, totalPages));
5678
};
5779

5880
const handleFirstPage = () => {
@@ -64,15 +86,14 @@ export function EnhancedLogsViewer({
6486
};
6587

6688
const handleCopyAllLogs = () => {
67-
const logText = getOriginalLogText(logsToDisplay);
68-
89+
const logText = getOriginalLogText(logs);
6990
navigator.clipboard.writeText(logText).catch((err) => {
7091
console.error("Failed to copy logs:", err);
7192
});
7293
};
7394

7495
const handleDownloadLogs = () => {
75-
const logText = getOriginalLogText(logsToDisplay);
96+
const logText = getOriginalLogText(logs);
7697
const blob = new Blob([logText], { type: "text/plain" });
7798
const url = URL.createObjectURL(blob);
7899
const a = document.createElement("a");
@@ -89,10 +110,12 @@ export function EnhancedLogsViewer({
89110
};
90111

91112
// Empty state - no logs at all
92-
if (!logs) {
113+
if (!logs || searchAndFilteredLogs.length === 0) {
93114
return (
94115
<div className="flex h-full flex-col space-y-5">
95116
<LogToolbar
117+
logLevel={selectedLogLevel}
118+
setLogLevel={setSelectedLogLevel}
96119
onSearchChange={setSearchQuery}
97120
onCopyAll={handleCopyAllLogs}
98121
onDownload={handleDownloadLogs}
@@ -113,6 +136,8 @@ export function EnhancedLogsViewer({
113136
return (
114137
<div className="flex h-full flex-col space-y-5">
115138
<LogToolbar
139+
logLevel={selectedLogLevel}
140+
setLogLevel={setSelectedLogLevel}
116141
onSearchChange={setSearchQuery}
117142
onCopyAll={handleCopyAllLogs}
118143
onDownload={handleDownloadLogs}
@@ -129,7 +154,7 @@ export function EnhancedLogsViewer({
129154
{/* Table-style header with fixed structure */}
130155
<div className="flex w-full min-w-[600px] space-x-3 bg-theme-surface-tertiary px-4 py-1 font-medium text-theme-text-secondary">
131156
{/* Type column header - match LogLine badge area */}
132-
<div className="flex w-8 flex-shrink-0 items-center">
157+
<div className="flex w-12 flex-shrink-0 items-center">
133158
<span className="text-text-sm font-semibold">Type</span>
134159
</div>
135160

@@ -216,9 +241,14 @@ export function EnhancedLogsViewer({
216241
>
217242
Previous
218243
</Button>
219-
<span className="text-sm text-theme-text-secondary">
220-
Page {currentPage} of {totalPages}
221-
</span>
244+
<form
245+
onSubmit={form.handleSubmit(handlePageSubmit)}
246+
className="text-sm flex items-center gap-1 text-theme-text-secondary"
247+
>
248+
Page
249+
<Input {...form.register("page")} className="w-10" />
250+
of {totalPages}
251+
</form>
222252
<Button
223253
className="bg-theme-surface-primary"
224254
size="md"
@@ -248,6 +278,6 @@ export function EnhancedLogsViewer({
248278
);
249279
}
250280

251-
function getOriginalLogText(logs: LogEntryType[]) {
281+
function getOriginalLogText(logs: LogEntryInternal[]) {
252282
return logs.map((log) => log.originalEntry).join("\n");
253283
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import Warning from "@/assets/icons/alert-triangle.svg?react";
2+
import Running from "@/assets/icons/dots-circle.svg?react";
3+
import Info from "@/assets/icons/info.svg?react";
4+
import AlertCircle from "@/assets/icons/alert-circle.svg?react";
5+
import {
6+
ScrollArea,
7+
Select,
8+
SelectContent,
9+
SelectItem,
10+
SelectTrigger,
11+
SelectValue
12+
} from "@zenml-io/react-component-library/components/client";
13+
import { ComponentPropsWithoutRef, ElementRef, forwardRef } from "react";
14+
15+
const logLevels = [
16+
{
17+
value: 10,
18+
label: "Debug",
19+
icon: <Running className="size-3 shrink-0 fill-neutral-400" />
20+
},
21+
{
22+
value: 20,
23+
label: "Info",
24+
icon: <Info className="size-3 shrink-0 fill-blue-500" />
25+
},
26+
{
27+
value: 30,
28+
label: "Warning",
29+
icon: <Warning className="size-3 shrink-0 fill-warning-500" />
30+
},
31+
{
32+
value: 40,
33+
label: "Error",
34+
icon: <AlertCircle className="size-3 shrink-0 fill-error-500" />
35+
},
36+
{
37+
value: 50,
38+
label: "Critical",
39+
icon: <AlertCircle className="size-3 shrink-0 fill-error-700" />
40+
}
41+
] as const;
42+
43+
export const LogLevelSelect = forwardRef<
44+
ElementRef<typeof SelectTrigger>,
45+
ComponentPropsWithoutRef<typeof Select>
46+
>(({ ...props }, ref) => {
47+
return (
48+
<Select {...props}>
49+
<SelectTrigger
50+
ref={ref}
51+
className="h-7 w-full truncate border border-[#D0D5DD] bg-theme-surface-primary p-2 text-left text-text-md"
52+
>
53+
<SelectValue
54+
className="data-[placeholder]:text-theme-text-secondary"
55+
placeholder="Select a role..."
56+
/>
57+
</SelectTrigger>
58+
<SelectContent className="">
59+
<ScrollArea viewportClassName="max-h-[300px]">
60+
{logLevels.map((logLevel, idx) => (
61+
<SelectItem key={idx} className="rounded-sm p-2" value={logLevel.value.toString()}>
62+
<div className="flex items-center gap-1">
63+
{logLevel.icon}
64+
{logLevel.label}
65+
</div>
66+
</SelectItem>
67+
))}
68+
</ScrollArea>
69+
</SelectContent>
70+
</Select>
71+
);
72+
});
73+
74+
LogLevelSelect.displayName = "LogLevelSelect";

src/components/logs/log-line.tsx

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,27 @@
11
import React from "react";
2-
import { LogEntry, LogLevel } from "@/types/logs";
2+
import { LogEntryInternal, LoggingLevel } from "@/types/logs";
33
import { CopyButton } from "../CopyButton";
4+
import { LOG_LEVEL_NAMES } from "@/lib/logs";
45

56
interface LogLineProps {
6-
entry: LogEntry;
7+
entry: LogEntryInternal;
78
searchTerm?: string;
89
isCurrentMatch?: boolean;
910
textWrapEnabled?: boolean;
1011
highlightedMessage?: React.ReactNode;
1112
}
1213

13-
const getLogLevelColor = (level: LogLevel): string => {
14+
const getLogLevelColor = (level: LoggingLevel | undefined): string => {
1415
switch (level) {
15-
case "INFO":
16+
case 10:
17+
return "bg-neutral-400";
18+
case 20:
1619
return "bg-blue-500";
17-
case "ERROR":
18-
return "bg-error-500";
19-
case "WARN":
20+
case 30:
2021
return "bg-warning-500";
21-
case "DEBUG":
22-
return "bg-neutral-400";
23-
case "CRITICAL":
22+
case 40:
23+
return "bg-error-500";
24+
case 50:
2425
return "bg-error-700";
2526
default:
2627
return "bg-neutral-400";
@@ -41,7 +42,7 @@ export function LogLine({
4142
}: LogLineProps) {
4243
const { timestamp, level, message, originalEntry } = entry;
4344
const formattedTimestamp = timestamp ? formatTimestamp(timestamp) : "";
44-
const levelColorClass = getLogLevelColor(level);
45+
const levelColorClass = getLogLevelColor(level ?? undefined);
4546

4647
const highlightSearchTerm = (text: string) => {
4748
if (!searchTerm) return text;
@@ -70,9 +71,11 @@ export function LogLine({
7071
return (
7172
<div className="group/copybutton flex w-full items-start space-x-3 border-b border-theme-border-minimal px-4 py-1 font-mono text-text-sm transition-colors hover:bg-theme-surface-secondary">
7273
{/* Compact log level badge */}
73-
<div className="flex max-h-6 w-8 flex-shrink-0 items-center">
74+
<div className="flex max-h-6 w-12 flex-shrink-0 items-center">
7475
<div className={`h-4 w-[2px] rounded-sm ${levelColorClass} mr-2`}></div>
75-
<span className="text-xs font-medium text-theme-text-tertiary">{level}</span>
76+
<span className="text-xs font-medium text-theme-text-tertiary">
77+
{LOG_LEVEL_NAMES[level ?? 20]}
78+
</span>
7679
</div>
7780

7881
{/* Timestamp */}

0 commit comments

Comments
 (0)