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 (
+
+
+
+ );
+};
+
+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 (
-
- );
-});
-
-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);
});