diff --git a/public/profile-presets.json b/public/profile-presets.json new file mode 100644 index 00000000..0eb0ca5e --- /dev/null +++ b/public/profile-presets.json @@ -0,0 +1,38 @@ +{ + "Athena": { + "config": { + "decoderOptions/formatString": "{ts:timestamp:YYYY-MM-DD HH\\:mm\\:ss.SSS} {level} [{thread}] {class}.{method}({file}:{line}): {message}", + "decoderOptions/logLevelKey": "level", + "decoderOptions/timestampKey": "ts", + "pageSize": 10000 + }, + "filePathPrefixes": [ + "/prod/logging/athena" + ], + "lastModificationTimestampMillis": 0 + }, + "Kafka": { + "config": { + "decoderOptions/formatString": "{d:timestamp} {p} {c}:{L} - {m}", + "decoderOptions/logLevelKey": "p", + "decoderOptions/timestampKey": "d", + "pageSize": 10000 + }, + "filePathPrefixes": [ + "/prod/logging/up" + ], + "lastModificationTimestampMillis": 1732881600000 + }, + "Test": { + "config": { + "decoderOptions/formatString": "{@timestamp} {log\\.level} {message}", + "decoderOptions/logLevelKey": "log.level", + "decoderOptions/timestampKey": "@timestamp", + "pageSize": 10000 + }, + "filePathPrefixes": [ + "/test" + ], + "lastModificationTimestampMillis": 1732881600000 + } +} diff --git a/src/components/CentralContainer/Sidebar/SidebarTabs/CustomTabPanel.css b/src/components/CentralContainer/Sidebar/SidebarTabs/CustomTabPanel.css index f6b1f720..1ce73469 100644 --- a/src/components/CentralContainer/Sidebar/SidebarTabs/CustomTabPanel.css +++ b/src/components/CentralContainer/Sidebar/SidebarTabs/CustomTabPanel.css @@ -1,5 +1,6 @@ .sidebar-tab-panel { - padding: 0.75rem; + padding: 0.75rem !important; + padding-right: 0.5rem !important; } .sidebar-tab-panel-container { diff --git a/src/components/CentralContainer/Sidebar/SidebarTabs/SettingsTabPanel/ThemeSwitchField.tsx b/src/components/CentralContainer/Sidebar/SidebarTabs/SettingsTabPanel/ThemeSwitchField.tsx new file mode 100644 index 00000000..e0250c83 --- /dev/null +++ b/src/components/CentralContainer/Sidebar/SidebarTabs/SettingsTabPanel/ThemeSwitchField.tsx @@ -0,0 +1,58 @@ +import { + Button, + FormControl, + FormHelperText, + FormLabel, + ToggleButtonGroup, + useColorScheme, +} from "@mui/joy"; +import type {Mode} from "@mui/system/cssVars/useCurrentColorScheme"; + +import DarkModeIcon from "@mui/icons-material/DarkMode"; +import LightModeIcon from "@mui/icons-material/LightMode"; + +import {THEME_NAME} from "../../../../../typings/config"; + + +/** + * Renders a theme selection field in the settings form. + * + * @return + */ +const ThemeSwitchField = () => { + const {setMode, mode} = useColorScheme(); + + return ( + + + Theme override + + { + setMode(newValue as Mode); + }} + > + + + + + {`Current mode: ${mode}`} + + + + ); +}; + +export default ThemeSwitchField; diff --git a/src/components/CentralContainer/Sidebar/SidebarTabs/SettingsTabPanel/index.css b/src/components/CentralContainer/Sidebar/SidebarTabs/SettingsTabPanel/index.css new file mode 100644 index 00000000..e8308d01 --- /dev/null +++ b/src/components/CentralContainer/Sidebar/SidebarTabs/SettingsTabPanel/index.css @@ -0,0 +1,9 @@ +.settings-tab-container { + overflow: hidden; + display: flex; + flex-direction: column; + height: 100%; +} + +.settings-options-container { +} diff --git a/src/components/CentralContainer/Sidebar/SidebarTabs/SettingsTabPanel/index.tsx b/src/components/CentralContainer/Sidebar/SidebarTabs/SettingsTabPanel/index.tsx new file mode 100644 index 00000000..847513fb --- /dev/null +++ b/src/components/CentralContainer/Sidebar/SidebarTabs/SettingsTabPanel/index.tsx @@ -0,0 +1,403 @@ +import React, { + useCallback, + useContext, + useEffect, + useState, +} from "react"; + +import { + Box, + Button, + Divider, + Dropdown, + FormControl, + FormHelperText, + FormLabel, + IconButton, + Input, + Link, + Menu, + MenuButton, + MenuItem, + Option, + Select, + Stack, + ToggleButtonGroup, + Tooltip, + Typography, +} from "@mui/joy"; + +import AddIcon from "@mui/icons-material/Add"; +import CheckIcon from "@mui/icons-material/Check"; +import CheckBoxIcon from "@mui/icons-material/CheckBox"; +import DeleteIcon from "@mui/icons-material/Delete"; +import LockIcon from "@mui/icons-material/Lock"; +import SdStorageIcon from "@mui/icons-material/SdStorage"; + +import {NotificationContext} from "../../../../../contexts/NotificationContextProvider"; +import {StateContext} from "../../../../../contexts/StateContextProvider"; +import { + CONFIG_KEY, + ProfileName, +} from "../../../../../typings/config"; +import {LOG_LEVEL} from "../../../../../typings/logs"; +import {DO_NOT_TIMEOUT_VALUE} from "../../../../../typings/notifications"; +import { + TAB_DISPLAY_NAMES, + TAB_NAME, +} from "../../../../../typings/tab"; +import {ACTION_NAME} from "../../../../../utils/actions"; +import { + createProfile, + DEFAULT_PROFILE_NAME, + deleteLocalStorageProfile, + forceProfile, + getConfig, + listProfiles, + ProfileMetadata, + updateConfig, +} from "../../../../../utils/config"; +import {defer} from "../../../../../utils/time"; +import CustomTabPanel from "../CustomTabPanel"; +import ThemeSwitchField from "./ThemeSwitchField"; + +import "./index.css"; + + +/** + * + * @param profileName + */ +const getConfigFormFields = (profileName: ProfileName) => { + return [ + { + helperText: ( + + {"[JSON] Format string for formatting a JSON log event as plain text. See the "} + + format string syntax docs + + {" or leave this blank to display the entire log event."} + + ), + initialValue: getConfig(CONFIG_KEY.DECODER_OPTIONS_FORMAT_STRING, profileName), + key: CONFIG_KEY.DECODER_OPTIONS_FORMAT_STRING, + label: "Decoder: Format string", + type: "text", + }, + { + helperText: "[JSON] Key to extract the log level from.", + initialValue: getConfig(CONFIG_KEY.DECODER_OPTIONS_LOG_LEVEL_KEY, profileName), + key: CONFIG_KEY.DECODER_OPTIONS_LOG_LEVEL_KEY, + label: "Decoder: Log level key", + type: "text", + }, + { + helperText: "[JSON] Key to extract the log timestamp from.", + initialValue: getConfig(CONFIG_KEY.DECODER_OPTIONS_TIMESTAMP_KEY, profileName), + key: CONFIG_KEY.DECODER_OPTIONS_TIMESTAMP_KEY, + label: "Decoder: Timestamp key", + type: "text", + }, + ]; +}; + +/** + * Displays a panel for FIXME + * + * @return + */ +const SettingsTabPanel = () => { + const {postPopUp} = useContext(NotificationContext); + const {activatedProfileName, loadPageByAction} = useContext(StateContext); + const [newProfileName, setNewProfileName] = useState(""); + const [selectedProfileName, setSelectedProfileName] = useState( + activatedProfileName ?? DEFAULT_PROFILE_NAME, + ); + const [profilesMetadata, setProfilesMetadata] = + useState>(listProfiles()); + const [canApply, setCanApply] = useState(false); + + const handleConfigFormSubmit = useCallback((ev: React.FormEvent) => { + ev.preventDefault(); + const formData = new FormData(ev.target as HTMLFormElement); + const getFormDataValue = (key: string) => formData.get(key) as string; + + const formatString = getFormDataValue(CONFIG_KEY.DECODER_OPTIONS_FORMAT_STRING); + const logLevelKey = getFormDataValue(CONFIG_KEY.DECODER_OPTIONS_LOG_LEVEL_KEY); + const timestampKey = getFormDataValue(CONFIG_KEY.DECODER_OPTIONS_TIMESTAMP_KEY); + const pageSize = Number(getFormDataValue(CONFIG_KEY.PAGE_SIZE)); + + const errorList = updateConfig( + { + [CONFIG_KEY.DECODER_OPTIONS_FORMAT_STRING]: formatString, + [CONFIG_KEY.DECODER_OPTIONS_LOG_LEVEL_KEY]: logLevelKey, + [CONFIG_KEY.DECODER_OPTIONS_TIMESTAMP_KEY]: timestampKey, + [CONFIG_KEY.PAGE_SIZE]: pageSize, + }, + selectedProfileName, + ); + + for (const error of errorList) { + postPopUp({ + level: LOG_LEVEL.ERROR, + message: error, + timeoutMillis: DO_NOT_TIMEOUT_VALUE, + title: "Unable to apply config.", + }); + } + + setProfilesMetadata(listProfiles()); + setCanApply(false); + setSelectedProfileName(DEFAULT_PROFILE_NAME); + loadPageByAction({code: ACTION_NAME.RELOAD, args: null}); + }, [ + loadPageByAction, + postPopUp, + selectedProfileName, + ]); + + useEffect(() => { + if (null === activatedProfileName) { + return; + } + + // The activated profile changes when the profile system is initialized / re-initialized. + setSelectedProfileName(activatedProfileName); + + // Which means the profiles' metadata may have changed. + setProfilesMetadata(listProfiles()); + }, [activatedProfileName]); + + const isSelectedProfileLocalStorage = profilesMetadata.get(selectedProfileName)?.isLocalStorage ?? false; + const isSelectedProfileForced = profilesMetadata.get(selectedProfileName)?.isForced ?? false; + + return ( + +
{ + setCanApply(true); + }} + > + + + + View: Page Size + + Number of log messages to display per page. + + + + + + Profile + + + + + + + + + + { + setNewProfileName(ev.target.value); + defer(() => { + // Prevent the confirm button from receiving + // focus when the input value changes. + ev.target.focus(); + }); + }}/> + + { + const result = createProfile(newProfileName); + if (result) { + setProfilesMetadata(listProfiles); + setSelectedProfileName(newProfileName); + } + }} + > + + + + + + + + Below fields are managed by the selected profile. + + + + {getConfigFormFields(selectedProfileName).map((field, index) => ( + + + {field.label} + + + + {field.helperText} + + + ))} + + + + +
+ ); +}; + +export default SettingsTabPanel; diff --git a/src/components/CentralContainer/Sidebar/SidebarTabs/index.tsx b/src/components/CentralContainer/Sidebar/SidebarTabs/index.tsx index 6bc7343a..26e36704 100644 --- a/src/components/CentralContainer/Sidebar/SidebarTabs/index.tsx +++ b/src/components/CentralContainer/Sidebar/SidebarTabs/index.tsx @@ -1,7 +1,4 @@ -import { - forwardRef, - useContext, -} from "react"; +import {forwardRef} from "react"; import { TabList, @@ -14,12 +11,11 @@ import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import SearchIcon from "@mui/icons-material/Search"; import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined"; -import {StateContext} from "../../../../contexts/StateContextProvider"; import {TAB_NAME} from "../../../../typings/tab"; import {openInNewTab} from "../../../../utils/url"; -import SettingsModal from "../../../modals/SettingsModal"; import FileInfoTabPanel from "./FileInfoTabPanel"; import SearchTabPanel from "./SearchTabPanel"; +import SettingsTabPanel from "./SettingsTabPanel"; import TabButton from "./TabButton"; import "./index.css"; @@ -56,17 +52,8 @@ const SidebarTabs = forwardRef(( }, tabListRef ) => { - const {isSettingsModalOpen, setIsSettingsModalOpen} = useContext(StateContext); - - const handleSettingsModalClose = () => { - setIsSettingsModalOpen(false); - }; - const handleTabButtonClick = (tabName: TAB_NAME) => { switch (tabName) { - case TAB_NAME.SETTINGS: - setIsSettingsModalOpen(true); - break; case TAB_NAME.DOCUMENTATION: openInNewTab(DOCUMENTATION_URL); break; @@ -76,45 +63,41 @@ const SidebarTabs = forwardRef(( }; return ( - <> - + - - {TABS_INFO_LIST.map(({tabName, Icon}) => ( - - ))} - - {/* Forces the help and settings tabs to the bottom of the sidebar. */} -
- - - + {TABS_INFO_LIST.map(({tabName, Icon}) => ( - - - - - - + ))} + + {/* Forces the help and settings tabs to the bottom of the sidebar. */} +
+ + + + + + + + + ); }); diff --git a/src/components/CentralContainer/Sidebar/index.tsx b/src/components/CentralContainer/Sidebar/index.tsx index 66a62f36..9c2977fb 100644 --- a/src/components/CentralContainer/Sidebar/index.tsx +++ b/src/components/CentralContainer/Sidebar/index.tsx @@ -10,7 +10,7 @@ import {CONFIG_KEY} from "../../../typings/config"; import {TAB_NAME} from "../../../typings/tab"; import { getConfig, - setConfig, + updateConfig, } from "../../../utils/config"; import {clamp} from "../../../utils/math"; import ResizeHandle from "./ResizeHandle"; @@ -63,14 +63,14 @@ const Sidebar = () => { if (activeTabName === tabName) { // Close the panel setActiveTabName(TAB_NAME.NONE); - setConfig({key: CONFIG_KEY.INITIAL_TAB_NAME, value: TAB_NAME.NONE}); + updateConfig({[CONFIG_KEY.INITIAL_TAB_NAME]: TAB_NAME.NONE}); setPanelWidth(tabListRef.current.clientWidth); return; } setActiveTabName(tabName); - setConfig({key: CONFIG_KEY.INITIAL_TAB_NAME, value: tabName}); + updateConfig({[CONFIG_KEY.INITIAL_TAB_NAME]: tabName}); setPanelWidth( clamp( window.innerWidth - EDITOR_MIN_WIDTH_IN_PIXELS, diff --git a/src/components/Editor/index.tsx b/src/components/Editor/index.tsx index 045e71b8..c05b4ee9 100644 --- a/src/components/Editor/index.tsx +++ b/src/components/Editor/index.tsx @@ -27,7 +27,7 @@ import { import { CONFIG_DEFAULT, getConfig, - setConfig, + updateConfig, } from "../../utils/config"; import { getMapKeyByValue, @@ -44,12 +44,10 @@ import "./index.css"; * will be restored when {@link restoreCachedPageSize} is called. */ const resetCachedPageSize = () => { - const error = setConfig( - {key: CONFIG_KEY.PAGE_SIZE, value: CONFIG_DEFAULT[CONFIG_KEY.PAGE_SIZE]} - ); + const errors = updateConfig({[CONFIG_KEY.PAGE_SIZE]: CONFIG_DEFAULT[CONFIG_KEY.PAGE_SIZE]}); - if (null !== error) { - console.error(`Unexpected error returned by setConfig(): ${error}`); + if (0 !== errors.length) { + console.error(`Unexpected errors returned by updateConfig(): ${JSON.stringify(errors)}`); } }; @@ -118,10 +116,11 @@ const Editor = () => { * Restores the cached page size that was unset in {@link resetCachedPageSize}; */ const restoreCachedPageSize = useCallback(() => { - const error = setConfig({key: CONFIG_KEY.PAGE_SIZE, value: pageSizeRef.current}); + const errors = updateConfig({[CONFIG_KEY.PAGE_SIZE]: pageSizeRef.current}); - if (null !== error) { - console.error(`Unexpected error returned by setConfig(): ${error}`); + if (0 !== errors.length) { + console.error(`Unexpected errors returned by updateConfig(): ${ + JSON.stringify(errors)}`); } }, []); diff --git a/src/components/StatusBar/LogLevelSelect/index.css b/src/components/StatusBar/LogLevelSelect/index.css index da2e8831..595a406d 100644 --- a/src/components/StatusBar/LogLevelSelect/index.css +++ b/src/components/StatusBar/LogLevelSelect/index.css @@ -23,9 +23,3 @@ .log-level-select-listbox { max-height: calc(100vh - var(--ylv-menu-bar-height) - var(--ylv-status-bar-height)) !important; } - -.log-level-select-option-text-tooltip { - /* Disable pointer events to prevent tooltips from blocking interaction with underlying - elements. */ - pointer-events: none; -} diff --git a/src/components/StatusBar/LogLevelSelect/index.tsx b/src/components/StatusBar/LogLevelSelect/index.tsx index 8413f43e..57287c04 100644 --- a/src/components/StatusBar/LogLevelSelect/index.tsx +++ b/src/components/StatusBar/LogLevelSelect/index.tsx @@ -104,7 +104,6 @@ const LogSelectOption = ({ - [JSON] Format string for formatting a JSON log event as plain text. See the - {" "} - - format string syntax docs - - {" "} - or leave this blank to display the entire log event. - - ), - initialValue: getConfig(CONFIG_KEY.DECODER_OPTIONS).formatString, - label: "Decoder: Format string", - name: LOCAL_STORAGE_KEY.DECODER_OPTIONS_FORMAT_STRING, - type: "text", - }, - { - helperText: "[JSON] Key to extract the log level from.", - initialValue: getConfig(CONFIG_KEY.DECODER_OPTIONS).logLevelKey, - label: "Decoder: Log level key", - name: LOCAL_STORAGE_KEY.DECODER_OPTIONS_LOG_LEVEL_KEY, - type: "text", - }, - { - helperText: "[JSON] Key to extract the log timestamp from.", - initialValue: getConfig(CONFIG_KEY.DECODER_OPTIONS).timestampKey, - label: "Decoder: Timestamp key", - name: LOCAL_STORAGE_KEY.DECODER_OPTIONS_TIMESTAMP_KEY, - type: "text", - }, - { - helperText: "Number of log messages to display per page.", - initialValue: getConfig(CONFIG_KEY.PAGE_SIZE), - label: "View: Page size", - name: LOCAL_STORAGE_KEY.PAGE_SIZE, - type: "number", - }, -]; - -/** - * Handles the reset event for the configuration form. - * - * @param ev - */ -const handleConfigFormReset = (ev: React.FormEvent) => { - ev.preventDefault(); - window.localStorage.clear(); - window.location.reload(); -}; - -/** - * Renders a settings dialog for configurations. - * - * @return - */ -const SettingsDialog = forwardRef((_, ref) => { - const {postPopUp} = useContext(NotificationContext); - - const handleConfigFormSubmit = useCallback((ev: React.FormEvent) => { - ev.preventDefault(); - const formData = new FormData(ev.target as HTMLFormElement); - const getFormDataValue = (key: string) => formData.get(key) as string; - - const formatString = getFormDataValue(LOCAL_STORAGE_KEY.DECODER_OPTIONS_FORMAT_STRING); - const logLevelKey = getFormDataValue(LOCAL_STORAGE_KEY.DECODER_OPTIONS_LOG_LEVEL_KEY); - const timestampKey = getFormDataValue(LOCAL_STORAGE_KEY.DECODER_OPTIONS_TIMESTAMP_KEY); - const pageSize = getFormDataValue(LOCAL_STORAGE_KEY.PAGE_SIZE); - - let error: Nullable = null; - error ||= setConfig({ - key: CONFIG_KEY.DECODER_OPTIONS, - value: {formatString, logLevelKey, timestampKey}, - }); - error ||= setConfig({ - key: CONFIG_KEY.PAGE_SIZE, - value: Number(pageSize), - }); - - if (null !== error) { - postPopUp({ - level: LOG_LEVEL.ERROR, - message: error, - timeoutMillis: DO_NOT_TIMEOUT_VALUE, - title: "Unable to apply config.", - }); - } else { - window.location.reload(); - } - }, [postPopUp]); - - return ( -
- - - - Settings - - - - - {CONFIG_FORM_FIELDS.map((field, index) => ( - - - {field.label} - - - - {field.helperText} - - - ))} - - - - - - -
- ); -}); - -SettingsDialog.displayName = "SettingsDialog"; - -export default SettingsDialog; diff --git a/src/components/modals/SettingsModal/ThemeSwitchToggle.tsx b/src/components/modals/SettingsModal/ThemeSwitchToggle.tsx deleted file mode 100644 index fbc90c45..00000000 --- a/src/components/modals/SettingsModal/ThemeSwitchToggle.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { - Button, - ToggleButtonGroup, - useColorScheme, -} from "@mui/joy"; -import type {Mode} from "@mui/system/cssVars/useCurrentColorScheme"; - -import DarkModeIcon from "@mui/icons-material/DarkMode"; -import LightModeIcon from "@mui/icons-material/LightMode"; -import SettingsBrightnessIcon from "@mui/icons-material/SettingsBrightness"; - -import {THEME_NAME} from "../../../typings/config"; - - -/** - * Renders a toggle button group for theme selection. - * - * @return - */ -const ThemeSwitchToggle = () => { - const {setMode, mode} = useColorScheme(); - - return ( - { - setMode(newValue as Mode); - }} - > - - - - - ); -}; - -export default ThemeSwitchToggle; diff --git a/src/components/modals/SettingsModal/index.css b/src/components/modals/SettingsModal/index.css deleted file mode 100644 index e043b379..00000000 --- a/src/components/modals/SettingsModal/index.css +++ /dev/null @@ -1,15 +0,0 @@ -.settings-dialog-title { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; -} - -.settings-dialog-title-text { - flex-grow: 1; - font-size: 2rem; -} - -.config-form-control { - margin-top: 16px; -} diff --git a/src/components/modals/SettingsModal/index.tsx b/src/components/modals/SettingsModal/index.tsx deleted file mode 100644 index c2d8b768..00000000 --- a/src/components/modals/SettingsModal/index.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import {Modal} from "@mui/joy"; - -import SettingsDialog from "./SettingsDialog"; - -import "./index.css"; - - -interface SettingsModalProps { - isOpen: boolean; - onClose: () => void; -} - -/** - * Renders a modal for setting configurations. - * - * @param props - * @param props.isOpen - * @param props.onClose - * @return - */ -const SettingsModal = ({isOpen, onClose}: SettingsModalProps) => { - return ( - - - - ); -}; - -export default SettingsModal; diff --git a/src/components/theme.tsx b/src/components/theme.tsx index 99df13d7..6eedb3b8 100644 --- a/src/components/theme.tsx +++ b/src/components/theme.tsx @@ -60,6 +60,11 @@ const APP_THEME = extendTheme({ arrow: true, variant: "outlined", }, + styleOverrides: { + root: { + pointerEvents: "none", + }, + }, }, }, fontFamily: { diff --git a/src/contexts/StateContextProvider.tsx b/src/contexts/StateContextProvider.tsx index d65e391c..854f6e5c 100644 --- a/src/contexts/StateContextProvider.tsx +++ b/src/contexts/StateContextProvider.tsx @@ -15,7 +15,10 @@ import LogExportManager, { EXPORT_LOG_PROGRESS_VALUE_MIN, } from "../services/LogExportManager"; import {Nullable} from "../typings/common"; -import {CONFIG_KEY} from "../typings/config"; +import { + CONFIG_KEY, + ProfileName, +} from "../typings/config"; import { LOG_LEVEL, LogLevelFilter, @@ -49,6 +52,7 @@ import { import { EXPORT_LOGS_CHUNK_SIZE, getConfig, + initProfiles, } from "../utils/config"; import { findNearestLessThanOrEqualElement, @@ -66,10 +70,10 @@ import { interface StateContextType { + activatedProfileName: Nullable; beginLineNumToLogEventNum: BeginLineNumToLogEventNumMap; exportProgress: Nullable; fileName: string; - isSettingsModalOpen: boolean; uiState: UI_STATE; logData: string; numEvents: number; @@ -81,9 +85,8 @@ interface StateContextType { exportLogs: () => void; filterLogs: (filter: LogLevelFilter) => void; - loadFile: (fileSrc: FileSrcType, cursor: CursorType) => void; + loadFile: (fileSrc: FileSrcType, cursor: CursorType) => Promise; loadPageByAction: (navAction: NavigationAction) => void; - setIsSettingsModalOpen: (isOpen: boolean) => void; startQuery: (queryArgs: QueryArgs) => void; } @@ -93,10 +96,10 @@ const StateContext = createContext({} as StateContextType); * Default values of the state object. */ const STATE_DEFAULT: Readonly = Object.freeze({ + activatedProfileName: null, beginLineNumToLogEventNum: new Map(), exportProgress: null, fileName: "", - isSettingsModalOpen: false, logData: "No file is open.", numEvents: 0, numPages: 0, @@ -108,9 +111,9 @@ const STATE_DEFAULT: Readonly = Object.freeze({ exportLogs: () => null, filterLogs: () => null, - loadFile: () => null, + loadFile: async () => { + }, loadPageByAction: () => null, - setIsSettingsModalOpen: () => null, startQuery: () => null, }); @@ -250,10 +253,10 @@ const StateContextProvider = ({children}: StateContextProviderProps) => { const {filePath, logEventNum} = useContext(UrlContext); // States + const [activatedProfileName, setActivatedProfileName] = + useState>(STATE_DEFAULT.activatedProfileName); const [exportProgress, setExportProgress] = useState>(STATE_DEFAULT.exportProgress); - const [isSettingsModalOpen, setIsSettingsModalOpen] = - useState(STATE_DEFAULT.isSettingsModalOpen); const [fileName, setFileName] = useState(STATE_DEFAULT.fileName); const [logData, setLogData] = useState(STATE_DEFAULT.logData); const [numEvents, setNumEvents] = useState(STATE_DEFAULT.numEvents); @@ -268,6 +271,7 @@ const StateContextProvider = ({children}: StateContextProviderProps) => { // Refs const beginLineNumToLogEventNumRef = useRef(STATE_DEFAULT.beginLineNumToLogEventNum); + const fileSrcRef = useRef>(null); const logEventNumRef = useRef(logEventNum); const logExportManagerRef = useRef(null); const mainWorkerRef = useRef(null); @@ -297,7 +301,7 @@ const StateContextProvider = ({children}: StateContextProviderProps) => { children: "Settings", startDecorator: , onClick: () => { - setIsSettingsModalOpen(true); + alert("fix open settings button"); }, }, timeoutMillis: LONG_AUTO_DISMISS_TIMEOUT_MILLIS, @@ -399,16 +403,26 @@ const StateContextProvider = ({children}: StateContextProviderProps) => { fileName, ]); - const loadFile = useCallback((fileSrc: FileSrcType, cursor: CursorType) => { + const loadFile = useCallback(async (fileSrc: FileSrcType, cursor: CursorType) => { setUiState(UI_STATE.FILE_LOADING); setFileName("Loading..."); setLogData("Loading..."); setOnDiskFileSizeInBytes(STATE_DEFAULT.onDiskFileSizeInBytes); setExportProgress(STATE_DEFAULT.exportProgress); + setActivatedProfileName(null); + + // Cache `fileSrc` for future reloads. + fileSrcRef.current = fileSrc; - if ("string" !== typeof fileSrc) { + let initResult: ProfileName; + if ("string" === typeof fileSrc) { + initResult = await initProfiles({filePath: fileSrc}); + } else { + initResult = await initProfiles({filePath: null}); updateWindowUrlSearchParams({[SEARCH_PARAM_NAMES.FILE_PATH]: null}); } + setActivatedProfileName(initResult); + if (null !== mainWorkerRef.current) { mainWorkerRef.current.terminate(); } @@ -420,7 +434,11 @@ const StateContextProvider = ({children}: StateContextProviderProps) => { fileSrc: fileSrc, pageSize: getConfig(CONFIG_KEY.PAGE_SIZE), cursor: cursor, - decoderOptions: getConfig(CONFIG_KEY.DECODER_OPTIONS), + decoderOptions: { + formatString: getConfig(CONFIG_KEY.DECODER_OPTIONS_FORMAT_STRING), + logLevelKey: getConfig(CONFIG_KEY.DECODER_OPTIONS_LOG_LEVEL_KEY), + timestampKey: getConfig(CONFIG_KEY.DECODER_OPTIONS_TIMESTAMP_KEY), + }, }); }, [ handleMainWorkerResp, @@ -432,6 +450,20 @@ const StateContextProvider = ({children}: StateContextProviderProps) => { return; } + if (navAction.code === ACTION_NAME.RELOAD) { + if (null === fileSrcRef.current || null === logEventNumRef.current) { + throw new Error(`Expected fileSrc=${JSON.stringify(fileSrcRef.current) + }, logEventNum=${logEventNumRef.current} when reloading.`); + } + loadFile(fileSrcRef.current, { + code: CURSOR_CODE.EVENT_NUM, + args: {eventNum: logEventNumRef.current}, + }).catch((e:unknown) => { + console.error(e); + }); + + return; + } const cursor = getPageNumCursor(navAction, pageNumRef.current, numPagesRef.current); if (null === cursor) { @@ -513,6 +545,13 @@ const StateContextProvider = ({children}: StateContextProviderProps) => { // On `filePath` update, load file. useEffect(() => { + initProfiles({filePath: null}) + .then((initResult) => { + setActivatedProfileName(initResult); + }) + .catch((e:unknown) => { + console.error("Error occurred when initializing profiles:", e); + }); if (URL_SEARCH_PARAMS_DEFAULT.filePath === filePath) { return; } @@ -524,7 +563,12 @@ const StateContextProvider = ({children}: StateContextProviderProps) => { args: {eventNum: logEventNumRef.current}, }; } - loadFile(filePath, cursor); + loadFile(filePath, cursor) + .then() + .catch((e:unknown) => { + console.error(`Error occurred when loading file "${filePath}" with cursor=${ + JSON.stringify(cursor)}:`, e); + }); }, [ filePath, loadFile, @@ -533,10 +577,10 @@ const StateContextProvider = ({children}: StateContextProviderProps) => { return ( { filterLogs: filterLogs, loadFile: loadFile, loadPageByAction: loadPageByAction, - setIsSettingsModalOpen: setIsSettingsModalOpen, startQuery: startQuery, }} > diff --git a/src/services/LogFileManager/utils.ts b/src/services/LogFileManager/utils.ts index aa7b734c..3182a237 100644 --- a/src/services/LogFileManager/utils.ts +++ b/src/services/LogFileManager/utils.ts @@ -139,7 +139,7 @@ const loadFile = async (fileSrc: FileSrcType) let fileData: Uint8Array; if ("string" === typeof fileSrc) { fileName = getBasenameFromUrlOrDefault(fileSrc); - fileData = await getUint8ArrayFrom(fileSrc, () => null); + fileData = await getUint8ArrayFrom(fileSrc); } else { fileName = fileSrc.name; fileData = new Uint8Array(await fileSrc.arrayBuffer()); diff --git a/src/typings/config.ts b/src/typings/config.ts index cb650431..34723e5b 100644 --- a/src/typings/config.ts +++ b/src/typings/config.ts @@ -8,44 +8,116 @@ enum THEME_NAME { LIGHT = "light", } -enum CONFIG_KEY { - DECODER_OPTIONS = "decoderOptions", +enum GLOBAL_CONFIG_KEY { INITIAL_TAB_NAME = "initialTabName", - THEME = "theme", PAGE_SIZE = "pageSize", + THEME = "theme", +} + +enum PROFILE_MANAGED_CONFIG_KEY { + DECODER_OPTIONS_FORMAT_STRING = "decoderOptions/formatString", + DECODER_OPTIONS_LOG_LEVEL_KEY = "decoderOptions/logLevelKey", + DECODER_OPTIONS_TIMESTAMP_KEY = "decoderOptions/timestampKey", } -/* eslint-disable @typescript-eslint/prefer-literal-enum-member */ +/** + * + */ +const CONFIG_KEY = Object.freeze({ + ...GLOBAL_CONFIG_KEY, + ...PROFILE_MANAGED_CONFIG_KEY, +}); + +type CONFIG_KEY = GLOBAL_CONFIG_KEY | PROFILE_MANAGED_CONFIG_KEY; + + +// FIXME: make use of it +const APP_SPECIFIC_LOCAL_STORAGE_KEY_PREFIX = "com.yscope.logviewer/"; + +const LOCAL_STORAGE_KEY_PROFILE_PREFIX = "profile:"; + +/** + * + * @param key + */ +const isLocalStorageKeyProfile = (key: string): boolean => key.startsWith(LOCAL_STORAGE_KEY_PROFILE_PREFIX); + +/** + * + * @param key + */ +const getProfileNameFromLocalStorageKey = (key: string): string => key.substring(LOCAL_STORAGE_KEY_PROFILE_PREFIX.length); + +/** + * + * @param profileName + */ +const getLocalStorageKeyFromProfileName = (profileName: string): string => `${LOCAL_STORAGE_KEY_PROFILE_PREFIX}${profileName}`; + enum LOCAL_STORAGE_KEY { - DECODER_OPTIONS_FORMAT_STRING = `${CONFIG_KEY.DECODER_OPTIONS}/formatString`, - DECODER_OPTIONS_LOG_LEVEL_KEY = `${CONFIG_KEY.DECODER_OPTIONS}/logLevelKey`, - DECODER_OPTIONS_TIMESTAMP_KEY = `${CONFIG_KEY.DECODER_OPTIONS}/timestampKey`, - INITIAL_TAB_NAME = CONFIG_KEY.INITIAL_TAB_NAME, - THEME = CONFIG_KEY.THEME, - PAGE_SIZE = CONFIG_KEY.PAGE_SIZE, + FORCED_PROFILE = "forcedProfile", + INITIAL_TAB_NAME = "initialTabName", + PAGE_SIZE = "pageSize", + + // This key should only be used by Joy UI. + THEME = "theme", +} + +interface ProfileManagedConfigMap { + [PROFILE_MANAGED_CONFIG_KEY.DECODER_OPTIONS_FORMAT_STRING]: DecoderOptions["formatString"]; + [PROFILE_MANAGED_CONFIG_KEY.DECODER_OPTIONS_LOG_LEVEL_KEY]: DecoderOptions["logLevelKey"]; + [PROFILE_MANAGED_CONFIG_KEY.DECODER_OPTIONS_TIMESTAMP_KEY]: DecoderOptions["timestampKey"]; } -/* eslint-enable @typescript-eslint/prefer-literal-enum-member */ -interface ConfigMap { - [CONFIG_KEY.DECODER_OPTIONS]: DecoderOptions; - [CONFIG_KEY.INITIAL_TAB_NAME]: TAB_NAME; - [CONFIG_KEY.THEME]: THEME_NAME; - [CONFIG_KEY.PAGE_SIZE]: number; +interface ConfigMap extends ProfileManagedConfigMap { + [GLOBAL_CONFIG_KEY.INITIAL_TAB_NAME]: TAB_NAME; + [GLOBAL_CONFIG_KEY.PAGE_SIZE]: number; + [GLOBAL_CONFIG_KEY.THEME]: THEME_NAME; } -type ConfigUpdate = { +type ConfigUpdates = { + [T in keyof ConfigMap]?: ConfigMap[T]; +}; + +type ConfigUpdateEntry = { [T in keyof ConfigMap]: { key: T; value: ConfigMap[T]; } }[keyof ConfigMap]; +/** + * + * @param key + * @param value + */ +const createUpdateEntry = ( + key: K, + value: ConfigMap[K] +): ConfigUpdateEntry => ({key, value} as ConfigUpdateEntry); + +interface Profile { + config: ProfileManagedConfigMap; + filePathPrefixes: string[]; + lastModificationTimestampMillis: number; +} + +type ProfileName = string; + export { CONFIG_KEY, + createUpdateEntry, + getLocalStorageKeyFromProfileName, + getProfileNameFromLocalStorageKey, + isLocalStorageKeyProfile, LOCAL_STORAGE_KEY, + PROFILE_MANAGED_CONFIG_KEY, THEME_NAME, }; export type { ConfigMap, - ConfigUpdate, + ConfigUpdateEntry, + ConfigUpdates, + Profile, + ProfileName, }; diff --git a/src/utils/actions.ts b/src/utils/actions.ts index cf0c132d..4d53a747 100644 --- a/src/utils/actions.ts +++ b/src/utils/actions.ts @@ -4,6 +4,7 @@ import {Nullable} from "../typings/common"; enum ACTION_NAME { + RELOAD = "reload", SPECIFIC_PAGE = "specificPage", FIRST_PAGE = "firstPage", PREV_PAGE = "prevPage", @@ -62,6 +63,7 @@ const EDITOR_ACTIONS : EditorAction[] = [ ]; type NavigationActionsMap = { + [ACTION_NAME.RELOAD]: null; [ACTION_NAME.SPECIFIC_PAGE]: { pageNum: number; }; diff --git a/src/utils/config.ts b/src/utils/config.ts index 0007576e..4e4693c8 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -2,12 +2,19 @@ import {Nullable} from "../typings/common"; import { CONFIG_KEY, ConfigMap, - ConfigUpdate, + ConfigUpdateEntry, + ConfigUpdates, + getLocalStorageKeyFromProfileName, + getProfileNameFromLocalStorageKey, + isLocalStorageKeyProfile, LOCAL_STORAGE_KEY, + Profile, + PROFILE_MANAGED_CONFIG_KEY, + ProfileName, THEME_NAME, } from "../typings/config"; -import {DecoderOptions} from "../typings/decoders"; import {TAB_NAME} from "../typings/tab"; +import {getJsonObjectFrom} from "./http"; const EXPORT_LOGS_CHUNK_SIZE = 10_000; @@ -17,23 +24,157 @@ const QUERY_CHUNK_SIZE = 10_000; /** * Exception to be thrown when the "THEME" configuration is specified. */ -const UNMANAGED_THEME_THROWABLE = - new Error(`"${CONFIG_KEY.THEME}" cannot be managed using these utilities.`); +const UNMANAGED_THEME_THROWABLE = new Error( + `"${CONFIG_KEY.THEME}" cannot be managed using these utilities.`, +); /** * The default configuration values. */ const CONFIG_DEFAULT: ConfigMap = Object.freeze({ - [CONFIG_KEY.DECODER_OPTIONS]: { - formatString: "", - logLevelKey: "log.level", - timestampKey: "@timestamp", - }, + // Global [CONFIG_KEY.INITIAL_TAB_NAME]: TAB_NAME.FILE_INFO, - [CONFIG_KEY.THEME]: THEME_NAME.SYSTEM, [CONFIG_KEY.PAGE_SIZE]: 10_000, + [CONFIG_KEY.THEME]: THEME_NAME.SYSTEM, + + // Profile managed + [CONFIG_KEY.DECODER_OPTIONS_FORMAT_STRING]: "", + [CONFIG_KEY.DECODER_OPTIONS_LOG_LEVEL_KEY]: "log.level", + [CONFIG_KEY.DECODER_OPTIONS_TIMESTAMP_KEY]: "@timestamp", }); +interface ProfileMetadata { + isLocalStorage: boolean; + isForced: boolean; +} + +// Global variables +const DEFAULT_PROFILE_NAME = "Default"; +const DEFAULT_PROFILE: Profile = { + config: structuredClone(CONFIG_DEFAULT), + filePathPrefixes: [], + lastModificationTimestampMillis: -1, +}; +const DEFAULT_PROFILE_METADATA: ProfileMetadata = { + isLocalStorage: false, + isForced: false, +}; + +let activatedProfileName: ProfileName = DEFAULT_PROFILE_NAME; +const PROFILES: Map = new Map([[DEFAULT_PROFILE_NAME, + DEFAULT_PROFILE]]); +const PROFILES_METADATA: Map = new Map([[ + DEFAULT_PROFILE_NAME, + DEFAULT_PROFILE_METADATA, +]]); + + +/** + * + * @param profileName + * @param metadataUpdate + */ +const updateProfileMetadata = (profileName: ProfileName, metadataUpdate: Partial) => { + const metadata = PROFILES_METADATA.get(profileName); + if ("undefined" === typeof metadata) { + throw new Error(`Profile "${profileName}" is not found`); + } + Object.assign(metadata, metadataUpdate); +}; + +/** + * Initializes the profile system, loads profiles, and activates the appropriate profile. + * + * @param initProps Initialization properties containing the file path. + * @param initProps.filePath + * @return The name of the activated profile. + */ +const initProfiles = async (initProps: {filePath: Nullable}): Promise => { + PROFILES.clear(); + PROFILES_METADATA.clear(); + PROFILES.set(DEFAULT_PROFILE_NAME, DEFAULT_PROFILE); + PROFILES_METADATA.set(DEFAULT_PROFILE_NAME, DEFAULT_PROFILE_METADATA); + + // Load profiles from profile-presets.json + try { + const profilePresets = + await getJsonObjectFrom>("profile-presets.json"); + + Object.entries(profilePresets).forEach(([profileName, profileData]) => { + PROFILES.set(profileName, profileData); + PROFILES_METADATA.set(profileName, {isForced: false, isLocalStorage: false}); + }); + } catch (e) { + console.error(`Failed to fetch profile-presets.json: ${JSON.stringify(e)}`); + } + + Object.keys(window.localStorage).forEach((key: string) => { + if (false === isLocalStorageKeyProfile(key)) { + return; + } + const profileName = getProfileNameFromLocalStorageKey(key); + const profileStr = window.localStorage.getItem(key); + if (null === profileStr) { + return; + } + try { + // eslint-disable-next-line no-warning-comments + // TODO: Validate parsed profile. + const profile = JSON.parse(profileStr) as Profile; + const existingProfile = PROFILES.get(profileName); + + // Insert the profile. If a duplicated profile name is found and the localStorage + // profile is newer than the existing one, replace with the localStorage profile. + if ( + "undefined" === typeof existingProfile || + existingProfile.lastModificationTimestampMillis < + profile.lastModificationTimestampMillis + ) { + PROFILES.set(profileName, profile); + PROFILES_METADATA.set(profileName, {isForced: false, isLocalStorage: true}); + } + } catch (e) { + console.error(`Error parsing profile ${profileName} from localStorage: ${String(e)}`); + } + }); + + // Preset and localStorage profiles loading is completed. + // Check for forced profile override. + const forcedProfileName = window.localStorage.getItem(LOCAL_STORAGE_KEY.FORCED_PROFILE); + if (null !== forcedProfileName) { + console.log("Forcing profile:", forcedProfileName); + activatedProfileName = forcedProfileName; + updateProfileMetadata(forcedProfileName, {isForced: true}); + + return activatedProfileName; + } + + // Determine profile based on filePath + const {filePath} = initProps; + if (null !== filePath) { + let bestMatchEndIdx = 0; + PROFILES.forEach((profile, profileName) => { + for (const prefix of profile.filePathPrefixes) { + const matchBeginIdx = filePath.indexOf(prefix); + if (-1 !== matchBeginIdx) { + const matchEndIdx = matchBeginIdx + prefix.length; + if (matchEndIdx > bestMatchEndIdx) { + bestMatchEndIdx = matchBeginIdx; + activatedProfileName = profileName; + } + } + } + }); + } + + console.log("Activated profile:", activatedProfileName); + + return activatedProfileName; +}; + +// Helpers + + /** * Validates the config denoted by the given key and value. * @@ -43,16 +184,19 @@ const CONFIG_DEFAULT: ConfigMap = Object.freeze({ * @return `null` if the value is valid, or an error message otherwise. * @throws {Error} If the config item cannot be managed by these config utilities. */ -const testConfig = ({key, value}: ConfigUpdate): Nullable => { +const testConfig = ({key, value}: ConfigUpdateEntry): Nullable => { let result = null; switch (key) { - case CONFIG_KEY.DECODER_OPTIONS: - if (0 === value.timestampKey.length) { - result = "Timestamp key cannot be empty."; - } else if (0 === value.logLevelKey.length) { + case CONFIG_KEY.DECODER_OPTIONS_LOG_LEVEL_KEY: + if (0 === value.length) { result = "Log level key cannot be empty."; } break; + case CONFIG_KEY.DECODER_OPTIONS_TIMESTAMP_KEY: + if (0 === value.length) { + result = "Timestamp key cannot be empty."; + } + break; case CONFIG_KEY.INITIAL_TAB_NAME: // This config option is not intended for direct user input. break; @@ -64,125 +208,244 @@ const testConfig = ({key, value}: ConfigUpdate): Nullable => { case CONFIG_KEY.THEME: throw UNMANAGED_THEME_THROWABLE; /* c8 ignore next */ - default: break; + default: + break; } return result; }; +/** + * + * @param profileName + */ +const getProfile = (profileName: ProfileName): Profile => { + const profile = PROFILES.get(profileName); + if ("undefined" === typeof profile) { + throw new Error(`Profile "${profileName}" is not found`); + } + + return profile; +}; /** - * Updates the config denoted by the given key and value. * - * @param props - * @param props.key - * @param props.value - * @return `null` if the update succeeds, or an error message otherwise. - * @throws {Error} If the config item cannot be managed by these config utilities. + * @param profileName + * @param profile + */ +const saveProfile = (profileName: ProfileName, profile: Profile): void => { + const profileKey = getLocalStorageKeyFromProfileName(profileName); + profile.lastModificationTimestampMillis = Date.now(); + window.localStorage.setItem(profileKey, JSON.stringify(profile)); + updateProfileMetadata(profileName, {isLocalStorage: true}); +}; + + +/** + * + * @param updates + * @param profileName */ -const setConfig = ({key, value}: ConfigUpdate): Nullable => { - const error = testConfig({key, value} as ConfigUpdate); - if (null !== error) { - console.error(`Unable to set ${key}=${JSON.stringify(value)}: ${error}`); +const updateConfig = ( + updates: ConfigUpdates, + profileName: Nullable = activatedProfileName +): string[] => { + if (null === profileName) { + profileName = activatedProfileName; + } - return error; + const errorList = []; + let profile = null; + let isProfileUpdated = false; + for (const [key, value] of Object.entries(updates)) { + const updateEntry = { + key: key as CONFIG_KEY, + value: value, + } as ConfigUpdateEntry; + const error = testConfig(updateEntry); + if (null !== error) { + errorList.push(error); + } + switch (updateEntry.key) { + // Global + case CONFIG_KEY.INITIAL_TAB_NAME: + window.localStorage.setItem( + LOCAL_STORAGE_KEY.INITIAL_TAB_NAME, + updateEntry.value.toString() + ); + break; + case CONFIG_KEY.THEME: + throw UNMANAGED_THEME_THROWABLE; + case CONFIG_KEY.PAGE_SIZE: + window.localStorage.setItem( + LOCAL_STORAGE_KEY.PAGE_SIZE, + updateEntry.value.toString() + ); + break; + + // Profile managed + case CONFIG_KEY.DECODER_OPTIONS_FORMAT_STRING: + case CONFIG_KEY.DECODER_OPTIONS_LOG_LEVEL_KEY: + case CONFIG_KEY.DECODER_OPTIONS_TIMESTAMP_KEY: { + if (null === profile) { + profile = getProfile(profileName); + } + const oldValue = profile.config[updateEntry.key as PROFILE_MANAGED_CONFIG_KEY]; + if (oldValue !== updateEntry.value) { + profile.config[updateEntry.key as PROFILE_MANAGED_CONFIG_KEY] = + updateEntry.value; + isProfileUpdated = true; + } + break; + } default: break; + } } - switch (key) { - case CONFIG_KEY.DECODER_OPTIONS: - window.localStorage.setItem( - LOCAL_STORAGE_KEY.DECODER_OPTIONS_FORMAT_STRING, - value.formatString - ); - window.localStorage.setItem( - LOCAL_STORAGE_KEY.DECODER_OPTIONS_LOG_LEVEL_KEY, - value.logLevelKey - ); - window.localStorage.setItem( - LOCAL_STORAGE_KEY.DECODER_OPTIONS_TIMESTAMP_KEY, - value.timestampKey - ); - break; - case CONFIG_KEY.INITIAL_TAB_NAME: - window.localStorage.setItem(CONFIG_KEY.INITIAL_TAB_NAME, value.toString()); - break; - case CONFIG_KEY.PAGE_SIZE: - window.localStorage.setItem(LOCAL_STORAGE_KEY.PAGE_SIZE, value.toString()); - break; - /* c8 ignore start */ - case CONFIG_KEY.THEME: - // Unexpected execution path. - break; - /* c8 ignore end */ - /* c8 ignore next */ - default: break; + if (isProfileUpdated && null !== profile) { + saveProfile(profileName, profile); } - return null; + return errorList; }; /** * Retrieves the config value for the specified key. * * @param key + * @param profileName * @return The value. * @throws {Error} If the config item cannot be managed by these config utilities. */ -const getConfig = (key: T): ConfigMap[T] => { +const getConfig = ( + key: T, + profileName: Nullable = activatedProfileName +): ConfigMap[T] => { + if (null === profileName) { + profileName = activatedProfileName; + } + let value = null; - // Read values from `localStorage`. + // Global switch (key) { - case CONFIG_KEY.DECODER_OPTIONS: - value = { - formatString: window.localStorage.getItem( - LOCAL_STORAGE_KEY.DECODER_OPTIONS_FORMAT_STRING - ), - logLevelKey: window.localStorage.getItem( - LOCAL_STORAGE_KEY.DECODER_OPTIONS_LOG_LEVEL_KEY - ), - timestampKey: window.localStorage.getItem( - LOCAL_STORAGE_KEY.DECODER_OPTIONS_TIMESTAMP_KEY - ), - } as DecoderOptions; - break; - case CONFIG_KEY.INITIAL_TAB_NAME: - value = window.localStorage.getItem(LOCAL_STORAGE_KEY.INITIAL_TAB_NAME); - break; - case CONFIG_KEY.PAGE_SIZE: - value = window.localStorage.getItem(LOCAL_STORAGE_KEY.PAGE_SIZE); + case CONFIG_KEY.INITIAL_TAB_NAME: { + const storedValue = window.localStorage.getItem(LOCAL_STORAGE_KEY.INITIAL_TAB_NAME); + value = (null === storedValue) ? + CONFIG_DEFAULT[CONFIG_KEY.INITIAL_TAB_NAME] : + storedValue as TAB_NAME; break; + } case CONFIG_KEY.THEME: throw UNMANAGED_THEME_THROWABLE; - /* c8 ignore next */ + case CONFIG_KEY.PAGE_SIZE: { + const storedValue = window.localStorage.getItem(LOCAL_STORAGE_KEY.PAGE_SIZE); + value = (null === storedValue) ? + CONFIG_DEFAULT[CONFIG_KEY.PAGE_SIZE] : + Number(storedValue); + break; + } default: break; } - // Fallback to default values if the config is absent from `localStorage`. - if (null === value || - ("object" === typeof value && Object.values(value).includes(null))) { - value = CONFIG_DEFAULT[key]; - setConfig({key, value} as ConfigUpdate); - } - - // Process values read from `localStorage`. + // Profile managed switch (key) { - case CONFIG_KEY.PAGE_SIZE: - value = Number(value); + case CONFIG_KEY.DECODER_OPTIONS_FORMAT_STRING: + case CONFIG_KEY.DECODER_OPTIONS_LOG_LEVEL_KEY: + case CONFIG_KEY.DECODER_OPTIONS_TIMESTAMP_KEY: { + const {config} = getProfile(profileName); + value = config[key as PROFILE_MANAGED_CONFIG_KEY]; break; + } default: break; } return value as ConfigMap[T]; }; +/** + * Sets a profile override and activates the specified profile. + * If profileName is null, removes the override. + * + * @param profileName The name of the profile to activate or null to remove override. + */ +const forceProfile = (profileName: string | null): void => { + const forcedProfileName = window.localStorage.getItem(LOCAL_STORAGE_KEY.FORCED_PROFILE); + if (null !== forcedProfileName) { + updateProfileMetadata(forcedProfileName, {isForced: false}); + } + + if (null === profileName) { + // Remove override + window.localStorage.removeItem(LOCAL_STORAGE_KEY.FORCED_PROFILE); + + return; + } + + window.localStorage.setItem(LOCAL_STORAGE_KEY.FORCED_PROFILE, profileName); + updateProfileMetadata(profileName, {isForced: true}); +}; + +/** + * + * @param profileName + */ +const createProfile = (profileName: ProfileName): boolean => { + if (PROFILES.has(profileName)) { + console.log("Profile already exists:", profileName); + + return false; + } + + PROFILES.set(profileName, DEFAULT_PROFILE); + PROFILES_METADATA.set(profileName, {isForced: false, isLocalStorage: true}); + saveProfile(profileName, DEFAULT_PROFILE); + forceProfile(profileName); + + return true; +}; + +/** + * Deletes a profile by name. FIXME + * + * @param profileName The name of the profile to delete. + * @throws Error if the specified profile is currently activated. + */ +const deleteLocalStorageProfile = (profileName: string) => { + const profileMetadata = PROFILES_METADATA.get(profileName); + if ("undefined" === typeof profileMetadata) { + throw new Error(`Deleting an unknown profile: ${profileName}`); + } + if (profileMetadata.isForced) { + forceProfile(null); + } + + const profileKey = getLocalStorageKeyFromProfileName(profileName); + window.localStorage.removeItem(profileKey); +}; + +/** + * + */ +const listProfiles = (): ReadonlyMap => { + return Object.freeze(structuredClone(PROFILES_METADATA)); +}; + + +export type {ProfileMetadata}; export { CONFIG_DEFAULT, + createProfile, + DEFAULT_PROFILE_METADATA, + DEFAULT_PROFILE_NAME, + deleteLocalStorageProfile, EXPORT_LOGS_CHUNK_SIZE, + forceProfile, getConfig, + initProfiles, + listProfiles, MAX_PAGE_SIZE, QUERY_CHUNK_SIZE, - setConfig, testConfig, UNMANAGED_THEME_THROWABLE, + updateConfig, }; diff --git a/src/utils/http.ts b/src/utils/http.ts index 4ebefa46..d1464cd6 100644 --- a/src/utils/http.ts +++ b/src/utils/http.ts @@ -1,4 +1,7 @@ -import axios, {AxiosError} from "axios"; +import axios, { + AxiosError, + AxiosProgressEvent, +} from "axios"; type ProgressCallback = (numBytesDownloaded:number, numBytesTotal:number) => void; @@ -32,25 +35,68 @@ const convertAxiosError = (e: AxiosError): Error => { ); }; + +/** + * Normalizes total size if undefined and calls the provided onProgress callback with loaded and + * total sizes. + * + * @param onProgress + * @return The handler that wraps `onProgress`. + */ +const normalizeTotalSize = (onProgress: ProgressCallback) => ({ + loaded, + total, +}: AxiosProgressEvent) => { + if ("undefined" === typeof total) { + total = loaded; + } + onProgress(loaded, total); +}; + +/** + * Retrieves an object that is stored as JSON in a remote location. + * + * @param remoteUrl + * @param onProgress + * @return The parsed JSON object. + * @throws {Error} if the download fails. + */ +const getJsonObjectFrom = async ( + remoteUrl: string, + onProgress: ProgressCallback = () => null +) +: Promise => { + try { + const {data} = await axios.get(remoteUrl, { + responseType: "json", + onDownloadProgress: normalizeTotalSize(onProgress), + }); + + return data; + } catch (e) { + throw (e instanceof AxiosError) ? + convertAxiosError(e) : + e; + } +}; + /** * Downloads (bypassing any caching) a file as a Uint8Array. * * @param fileUrl - * @param progressCallback + * @param onProgress * @return The file's content. * @throws {Error} if the download fails. */ -const getUint8ArrayFrom = async (fileUrl: string, progressCallback: ProgressCallback) +const getUint8ArrayFrom = async ( + fileUrl: string, + onProgress: ProgressCallback = () => null +) : Promise => { try { const {data} = await axios.get(fileUrl, { responseType: "arraybuffer", - onDownloadProgress: ({loaded, total}) => { - if ("undefined" === typeof total) { - total = loaded; - } - progressCallback(loaded, total); - }, + onDownloadProgress: normalizeTotalSize(onProgress), headers: { "Cache-Control": "no-cache", "Pragma": "no-cache", @@ -66,5 +112,7 @@ const getUint8ArrayFrom = async (fileUrl: string, progressCallback: ProgressCall } }; - -export {getUint8ArrayFrom}; +export { + getJsonObjectFrom, + getUint8ArrayFrom, +}; diff --git a/test/utils/config.test.ts b/test/utils/config.test.ts index a096cdaa..d21c6f09 100644 --- a/test/utils/config.test.ts +++ b/test/utils/config.test.ts @@ -4,7 +4,7 @@ import {Nullable} from "../../src/typings/common"; import { CONFIG_KEY, - ConfigUpdate, + ConfigUpdateEntry, LOCAL_STORAGE_KEY, THEME_NAME, } from "../../src/typings/config"; @@ -40,7 +40,7 @@ const VALID_PAGE_SIZE = 5000; * * @param func */ -const runNegativeCases = (func: (input: ConfigUpdate) => Nullable) => { +const runNegativeCases = (func: (input: ConfigUpdateEntry) => Nullable) => { it("should return an error message for any empty decoder option except `formatString`", () => { // Generate negative test cases for decoder options. const cases = ( @@ -52,7 +52,7 @@ const runNegativeCases = (func: (input: ConfigUpdate) => Nullable) => { ...VALID_DECODER_OPTIONS, [key]: "", }, - } as ConfigUpdate, + } as ConfigUpdateEntry, expected: { formatString: null, logLevelKey: "Log level key cannot be empty.", @@ -74,7 +74,7 @@ const runNegativeCases = (func: (input: ConfigUpdate) => Nullable) => { const input = { key: CONFIG_KEY.THEME, value: THEME_NAME.SYSTEM, - } as ConfigUpdate; + } as ConfigUpdateEntry; expect(() => func(input)).toThrow(UNMANAGED_THEME_THROWABLE); });