Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/src/dev-guide/contributing-validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,6 @@ still be tested manually:
* Toggling tabbed panels in the sidebar
* Using keyboard shortcuts
* Toggling "Prettify" in both the status bar and the address bar
* Select different timezone in the "Timezone" dropdown menu in the status bar or URL

[gh-workflow-test]: https://github.com/y-scope/yscope-log-viewer/blob/main/.github/workflows/test.yaml
4 changes: 2 additions & 2 deletions src/components/AppController.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,9 @@ const AppController = ({children}: AppControllerProps) => {
code: CURSOR_CODE.EVENT_NUM,
args: {eventNum: clampedLogEventNum},
};
const {isPrettified} = useViewStore.getState();
const {isPrettified, logTimezone} = useViewStore.getState();

const pageData = await logFileManagerProxy.loadPage(cursor, isPrettified);
const pageData = await logFileManagerProxy.loadPage(cursor, isPrettified, logTimezone);
const {updatePageData} = useViewStore.getState();
updatePageData(pageData);
})().catch(handleErrorWithNotification);
Expand Down
31 changes: 31 additions & 0 deletions src/components/StatusBar/TimezoneSelect/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/* Since timezone is not multi select, and multi select has a different style in Joy components, so we have to manually
tune the style
*/
.timezone-select {
margin-right: 1px;
}

.timezone-select-render-value-box {
display: flex;
gap: 2px;
}

.timezone-select-render-value-box-label {
padding: 0 !important;

font-size: 0.95rem !important;
font-weight: 500 !important;
line-height: 1.5 !important;

background-color: initial !important;
border: none !important;
box-shadow: none !important;
}

.timezone-select-render-value-box-label-disabled {
color: #686f76 !important;
}

.timezone-select-listbox {
max-height: calc(100vh - var(--ylv-menu-bar-height) - var(--ylv-status-bar-height)) !important;
}
222 changes: 222 additions & 0 deletions src/components/StatusBar/TimezoneSelect/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import React, {
useCallback,
useEffect,
useState,
} from "react";

import {SelectValue} from "@mui/base/useSelect";
import {
Box,
Chip,
ListDivider,
ListItemContent,
Option,
Select,
SelectOption,
} from "@mui/joy";

import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";

import useUiStore from "../../../stores/uiStore";
import useViewStore from "../../../stores/viewStore.ts";
import {UI_ELEMENT} from "../../../typings/states";
import {HASH_PARAM_NAMES} from "../../../typings/url";
import {isDisabled} from "../../../utils/states";
import {updateWindowUrlHashParams} from "../../../utils/url";

import "./index.css";


const LOGGER_TIMEZONE = "Logger Timezone";
const COMMON_TIMEZONES = [
"America/New_York",
"Asia/Shanghai",
"Asia/Tokyo",
"Australia/Sydney",
"Pacific/Honolulu",
"America/Los_Angeles",
"America/Chicago",
"America/Denver",
"Asia/Kolkata",
"Europe/Berlin",
"Europe/Moscow",
"Asia/Dubai",
"Asia/Singapore",
"Asia/Seoul",
"Pacific/Auckland",
];

/**
* Convert the timezone string to GMT +/- minutes
*
* @param tz
* @return The GMT +/- minutes shown before the timezone string
*/
const getLongOffsetOfTimezone = (tz: string): string => {
return new Intl.DateTimeFormat("default", {
timeZone: tz,
timeZoneName: "longOffset",
}).formatToParts()
.find((p) => "timeZoneName" === p.type)?.value ?? "Unknown timezone";
};
Comment on lines +55 to +61
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add error handling for invalid timezone strings.

The function doesn't handle invalid timezone strings gracefully. While there's a fallback to "Unknown timezone", it would be better to validate the timezone before attempting to use it.

const getLongOffsetOfTimezone = (tz: string): string => {
+    // Validate timezone before using it
+    try {
+        // Check if timezone is valid by attempting to use it
+        Intl.DateTimeFormat("default", { timeZone: tz });
+        
        return new Intl.DateTimeFormat("default", {
            timeZone: tz,
            timeZoneName: "longOffset",
        }).formatToParts()
            .find((p) => "timeZoneName" === p.type)?.value ?? "Unknown timezone";
+    } catch (error) {
+        console.warn(`Invalid timezone: ${tz}`);
+        return "Invalid timezone";
+    }
};
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const getLongOffsetOfTimezone = (tz: string): string => {
return new Intl.DateTimeFormat("default", {
timeZone: tz,
timeZoneName: "longOffset",
}).formatToParts()
.find((p) => "timeZoneName" === p.type)?.value ?? "Unknown timezone";
};
const getLongOffsetOfTimezone = (tz: string): string => {
// Validate timezone before using it
try {
// Check if timezone is valid by attempting to use it
Intl.DateTimeFormat("default", { timeZone: tz });
return new Intl.DateTimeFormat("default", {
timeZone: tz,
timeZoneName: "longOffset",
}).formatToParts()
.find((p) => "timeZoneName" === p.type)?.value ?? "Unknown timezone";
} catch (error) {
console.warn(`Invalid timezone: ${tz}`);
return "Invalid timezone";
}
};


/**
* Render the selected timezone option in the status bar
*
* @param selected
* @return The selected timezone shown in the status bar
*/
const handleRenderValue = (selected: SelectValue<SelectOption<string>, false>) => (
<Box className={"timezone-select-render-value-box"}>
<Chip className={"timezone-select-render-value-box-label"}>Timezone</Chip>
<Chip>
{selected?.label}
</Chip>
</Box>
);

/**
* Render the timezone options in the dropdown menu
*
* @param value
* @param label
* @param onClick
* @param suffix
* @return An option box in the dropdown menu
*/
const renderTimezoneOption = (
value: string,
label: string,
onClick: React.MouseEventHandler,
suffix?: string
) => (
<Option
data-value={value}
key={value}
value={value}
onClick={onClick}
>
{LOGGER_TIMEZONE !== value &&
<ListItemContent>
(
{getLongOffsetOfTimezone(value)}
)
{" "}
{label}
{" "}
{suffix ?? ""}
</ListItemContent>}

{LOGGER_TIMEZONE === value &&
<ListItemContent>
{LOGGER_TIMEZONE}
</ListItemContent>}
</Option>
);

/**
* The timezone select dropdown menu, the selectable options can be classified as three types:
* - Default (use the origin timezone of the log events)
* - Browser Timezone (use the timezone that the browser is currently using)
* - Frequently-used Timezone
*
* @return A timezone select dropdown menu
*/
const TimezoneSelect = () => {
const uiState = useUiStore((state) => state.uiState);

const {logTimezone} = useViewStore.getState();
const updateLogTimezone = useViewStore((state) => state.updateLogTimezone);

const [browserTimezone, setBrowserTimezone] = useState<string | null>(null);
const [selectedTimezone, setSelectedTimezone] = useState<string | null>(null);

const disabled = isDisabled(uiState, UI_ELEMENT.TIMEZONE_SETTER);

useEffect(() => {
const tz = new Intl.DateTimeFormat().resolvedOptions().timeZone;
setBrowserTimezone(tz);
}, []);

useEffect(() => {
if (!disabled && selectedTimezone !== logTimezone) {
const updatedTimezone = (LOGGER_TIMEZONE === selectedTimezone) ?
"" :
(selectedTimezone ?? "");

updateWindowUrlHashParams({
[HASH_PARAM_NAMES.LOG_TIMEZONE]: updatedTimezone,
});
updateLogTimezone(updatedTimezone);
}
}, [
disabled,
logTimezone,
selectedTimezone,
updateLogTimezone,
]);

const handleOptionClick = useCallback((ev: React.MouseEvent) => {
const currentTarget = ev.currentTarget as HTMLElement;
const value = currentTarget.dataset.value ?? LOGGER_TIMEZONE;
setSelectedTimezone(value);
}, []);

return (
<Select
className={"timezone-select"}
disabled={disabled}
indicator={<KeyboardArrowUpIcon/>}
renderValue={handleRenderValue}
size={"sm"}
value={selectedTimezone}
variant={"soft"}
placeholder={
<Box className={"timezone-select-render-value-box"}>
<Chip
className={`timezone-select-render-value-box-label ${disabled ?
"timezone-select-render-value-box-label-disabled" :
""}`}
>
Timezone
</Chip>
</Box>
}
slotProps={{
listbox: {
className: "timezone-select-listbox",
placement: "top-end",
modifiers: [
{name: "equalWidth", enabled: false},
{name: "offset", enabled: false},
],
},
}}
onChange={(_, value) => {
if (value) {
setSelectedTimezone(value);
}
}}
>
{renderTimezoneOption(LOGGER_TIMEZONE, LOGGER_TIMEZONE, handleOptionClick)}

{browserTimezone &&
renderTimezoneOption(
browserTimezone,
browserTimezone,
handleOptionClick,
"(Browser Timezone)"
)}

<ListDivider
inset={"gutter"}
role={"separator"}/>

{COMMON_TIMEZONES.map(
(label) => renderTimezoneOption(label, label, handleOptionClick)
)}
</Select>
);
};

export default TimezoneSelect;
2 changes: 2 additions & 0 deletions src/components/StatusBar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from "../../utils/url";
import LogLevelSelect from "./LogLevelSelect";
import StatusBarToggleButton from "./StatusBarToggleButton";
import TimezoneSelect from "./TimezoneSelect";

import "./index.css";

Expand Down Expand Up @@ -70,6 +71,7 @@ const StatusBar = () => {
{/* This is left blank intentionally until status messages are implemented. */}
</Typography>

<TimezoneSelect/>
<Tooltip title={"Copy link to clipboard"}>
<span>
<Button
Expand Down
16 changes: 11 additions & 5 deletions src/services/LogFileManager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ class LogFileManager {
beginLogEventIdx,
endLogEventIdx,
false,
null
);

if (null === results) {
Expand All @@ -276,11 +277,16 @@ class LogFileManager {
*
* @param cursor The cursor indicating the page to load. See {@link CursorType}.
* @param isPrettified Are the log messages pretty printed.
* @param logTimezone Format the log timestamp to specified timezone.
* @return An object containing the logs as a string, a map of line numbers to log event
* numbers, and the line number of the first line in the cursor identified event.
* @throws {Error} if any error occurs during decode.
*/
loadPage (cursor: CursorType, isPrettified: boolean): PageData {
loadPage (
cursor: CursorType,
isPrettified: boolean,
logTimezone: string | null
): PageData {
console.debug(`loadPage: cursor=${JSON.stringify(cursor)}`);
const filteredLogEventMap = this.#decoder.getFilteredLogEventMap();
const numActiveEvents: number = filteredLogEventMap ?
Expand All @@ -299,12 +305,11 @@ class LogFileManager {
pageBegin,
pageEnd,
null !== filteredLogEventMap,
logTimezone
);

if (null === results) {
throw new Error("Error occurred during decoding. " +
`pageBegin=${pageBegin}, ` +
`pageEnd=${pageEnd}`);
throw new Error(`Failed decoding, pageBegin=${pageBegin}, pageEnd=${pageEnd}`);
}
const messages: string[] = [];
const beginLineNumToLogEventNum: BeginLineNumToLogEventNumMap = new Map();
Expand Down Expand Up @@ -417,7 +422,8 @@ class LogFileManager {
const decodedEvents = this.#decoder.decodeRange(
chunkBeginIdx,
chunkEndIdx,
null !== filteredLogEventMap
null !== filteredLogEventMap,
null,
);

if (null === decodedEvents) {
Expand Down
13 changes: 9 additions & 4 deletions src/services/LogFileManagerProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,25 @@ class LogFileManagerProxy {
};
}

loadPage (cursor: CursorType, isPrettified: boolean): PageData {
loadPage (
cursor: CursorType,
isPrettified: boolean,
logTimezone: string
): PageData {
const logFileManager = this.#getLogFileManager();
return logFileManager.loadPage(cursor, isPrettified);
return logFileManager.loadPage(cursor, isPrettified, logTimezone);
}

setFilter (
cursor: CursorType,
isPrettified: boolean,
logLevelFilter: LogLevelFilter
logLevelFilter: LogLevelFilter,
logTimezone: string
): PageData {
const logFileManager = this.#getLogFileManager();
logFileManager.setLogLevelFilter(logLevelFilter);

return this.loadPage(cursor, isPrettified);
return this.loadPage(cursor, isPrettified, logTimezone);
}

exportLogs (): void {
Expand Down
Loading