diff --git a/src/dashboard/Dashboard.js b/src/dashboard/Dashboard.js index 19dbdf54de..b10e798fec 100644 --- a/src/dashboard/Dashboard.js +++ b/src/dashboard/Dashboard.js @@ -56,6 +56,7 @@ import Playground from './Data/Playground/Playground.react'; import DashboardSettings from './Settings/DashboardSettings/DashboardSettings.react'; import Security from './Settings/Security/Security.react'; import KeyboardShortcutsSettings from './Settings/KeyboardShortcutsSettings.react'; +import CloudConfigSettings from './Settings/CloudConfigSettings.react'; import semver from 'semver'; import packageInfo from '../../package.json'; @@ -237,6 +238,7 @@ export default class Dashboard extends React.Component { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/dashboard/DashboardView.react.js b/src/dashboard/DashboardView.react.js index 1240d5c901..3b9e3443c0 100644 --- a/src/dashboard/DashboardView.react.js +++ b/src/dashboard/DashboardView.react.js @@ -230,6 +230,10 @@ export default class DashboardView extends React.Component { name: 'Keyboard Shortcuts', link: '/settings/keyboard-shortcuts', }, + { + name: 'Cloud Config', + link: '/settings/cloud-config', + }, ]; if (this.context.enableSecurityChecks) { diff --git a/src/dashboard/Data/Config/Config.react.js b/src/dashboard/Data/Config/Config.react.js index f32018daf9..75b34a3fe8 100644 --- a/src/dashboard/Data/Config/Config.react.js +++ b/src/dashboard/Data/Config/Config.react.js @@ -27,6 +27,8 @@ import { CurrentApp } from 'context/currentApp'; import Modal from 'components/Modal/Modal.react'; import equal from 'fast-deep-equal'; import Notification from 'dashboard/Data/Browser/Notification.react'; +import ServerConfigStorage from 'lib/ServerConfigStorage'; +import { prefersServerStorage } from 'lib/StoragePreferences'; @subscribeTo('Config', 'config') class Config extends TableView { @@ -53,8 +55,11 @@ class Config extends TableView { showRemoveEntryDialog: false, removeEntryParam: '', removeEntryArrayValue: [], + serverHistoryLimit: undefined, + currentParamHistory: null, }; this.noteTimeout = null; + this.serverStorage = null; } componentWillMount() { @@ -76,11 +81,88 @@ class Config extends TableView { try { await this.props.config.dispatch(ActionTypes.FETCH); this.cacheData = new Map(this.props.config.data); + await this.loadCloudConfigSettings(); } finally { this.setState({ loading: false }); } } + async loadCloudConfigSettings() { + try { + this.serverStorage = new ServerConfigStorage(this.context); + if (!this.serverStorage.isServerConfigEnabled()) { + return; + } + const settings = await this.serverStorage.getConfig( + 'config.settings', + this.context.applicationId + ); + if (settings) { + if (settings.historyLimit !== undefined) { + this.setState({ serverHistoryLimit: settings.historyLimit }); + } + } + } catch { + // Fall back to existing context value + } + } + + async addToConfigHistory(name, value) { + const limit = this.context.cloudConfigHistoryLimit ?? this.state.serverHistoryLimit; + const newHistoryEntry = { time: new Date(), value }; + const applicationId = this.context.applicationId; + + if (prefersServerStorage(applicationId) && this.serverStorage) { + try { + const key = `config.history.parameters.${name}`; + const existing = await this.serverStorage.getConfig(key, applicationId); + const values = existing?.values || []; + const history = [newHistoryEntry, ...values].slice(0, limit || 100); + await this.serverStorage.setConfig(key, { values: history }, applicationId); + } catch { + // Fall back silently + } + } else { + const configHistory = localStorage.getItem(`${applicationId}_configHistory`); + if (!configHistory) { + localStorage.setItem( + `${applicationId}_configHistory`, + JSON.stringify({ [name]: [newHistoryEntry] }) + ); + } else { + const oldConfigHistory = JSON.parse(configHistory); + const updatedHistory = !oldConfigHistory[name] + ? [newHistoryEntry] + : [newHistoryEntry, ...oldConfigHistory[name]].slice(0, limit || 100); + + localStorage.setItem( + `${applicationId}_configHistory`, + JSON.stringify({ ...oldConfigHistory, [name]: updatedHistory }) + ); + } + } + } + + async loadConfigHistory(name) { + const applicationId = this.context.applicationId; + + if (prefersServerStorage(applicationId) && this.serverStorage) { + try { + const key = `config.history.parameters.${name}`; + const result = await this.serverStorage.getConfig(key, applicationId); + return result?.values || []; + } catch { + return []; + } + } + + const raw = localStorage.getItem(`${applicationId}_configHistory`); + if (!raw) { + return []; + } + return JSON.parse(raw)[name] || []; + } + renderToolbar() { return ( @@ -110,6 +192,7 @@ class Config extends TableView { masterKeyOnly={this.state.modalMasterKeyOnly} parseServerVersion={this.context.serverInfo?.parseServerVersion} loading={this.state.loading} + configHistory={this.state.currentParamHistory} /> ); } else if (this.state.showDeleteParameterDialog) { @@ -251,10 +334,14 @@ class Config extends TableView { modalType: type, modalValue: modalValue, modalMasterKeyOnly: data.masterKeyOnly, + currentParamHistory: null, }); - // Fetch config data - await this.loadData(); + // Fetch config data and history in parallel + const [, history] = await Promise.all([ + this.loadData(), + this.loadConfigHistory(data.param), + ]); // Get latest param values const fetchedParams = this.props.config.data.get('params'); @@ -269,6 +356,7 @@ class Config extends TableView { this.setState({ modalValue: fetchedModalValue, modalMasterKeyOnly: fetchedMasterKeyOnly, + currentParamHistory: history, loading: false, }); }; @@ -438,44 +526,14 @@ class Config extends TableView { this.setState({ modalOpen: false }); // Update config history in localStorage - const limit = this.context.cloudConfigHistoryLimit; - const applicationId = this.context.applicationId; let transformedValue = value; - if (type === 'Date') { transformedValue = { __type: 'Date', iso: value }; } if (type === 'File') { transformedValue = { name: value._name, url: value._url }; } - - const configHistory = localStorage.getItem(`${applicationId}_configHistory`); - const newHistoryEntry = { - time: new Date(), - value: transformedValue, - }; - - if (!configHistory) { - localStorage.setItem( - `${applicationId}_configHistory`, - JSON.stringify({ - [name]: [newHistoryEntry], - }) - ); - } else { - const oldConfigHistory = JSON.parse(configHistory); - const updatedHistory = !oldConfigHistory[name] - ? [newHistoryEntry] - : [newHistoryEntry, ...oldConfigHistory[name]].slice(0, limit || 100); - - localStorage.setItem( - `${applicationId}_configHistory`, - JSON.stringify({ - ...oldConfigHistory, - [name]: updatedHistory, - }) - ); - } + this.addToConfigHistory(name, transformedValue); } catch (error) { this.context.showError?.( `Failed to save parameter: ${error.message || 'Unknown error occurred'}` @@ -489,14 +547,26 @@ class Config extends TableView { this.props.config.dispatch(ActionTypes.DELETE, { param: name }).then(() => { this.setState({ showDeleteParameterDialog: false }); }); + + // Delete history from server + if (prefersServerStorage(this.context.applicationId) && this.serverStorage) { + this.serverStorage.deleteConfig( + `config.history.parameters.${name}`, + this.context.applicationId + ).catch(() => {}); + } + + // Delete history from localStorage + const applicationId = this.context.applicationId; const configHistory = - localStorage.getItem('configHistory') && JSON.parse(localStorage.getItem('configHistory')); + localStorage.getItem(`${applicationId}_configHistory`) && + JSON.parse(localStorage.getItem(`${applicationId}_configHistory`)); if (configHistory) { delete configHistory[name]; if (Object.keys(configHistory).length === 0) { - localStorage.removeItem('configHistory'); + localStorage.removeItem(`${applicationId}_configHistory`); } else { - localStorage.setItem('configHistory', JSON.stringify(configHistory)); + localStorage.setItem(`${applicationId}_configHistory`, JSON.stringify(configHistory)); } } } @@ -508,6 +578,7 @@ class Config extends TableView { modalType: 'String', modalValue: '', modalMasterKeyOnly: false, + currentParamHistory: null, }); } @@ -585,37 +656,7 @@ class Config extends TableView { await this.props.config.dispatch(ActionTypes.FETCH); // Update config history - const limit = this.context.cloudConfigHistoryLimit; - const applicationId = this.context.applicationId; - const params = this.props.config.data.get('params'); - const updatedValue = params.get(param); - const configHistory = localStorage.getItem(`${applicationId}_configHistory`); - const newHistoryEntry = { - time: new Date(), - value: updatedValue, - }; - - if (!configHistory) { - localStorage.setItem( - `${applicationId}_configHistory`, - JSON.stringify({ - [param]: [newHistoryEntry], - }) - ); - } else { - const oldConfigHistory = JSON.parse(configHistory); - const updatedHistory = !oldConfigHistory[param] - ? [newHistoryEntry] - : [newHistoryEntry, ...oldConfigHistory[param]].slice(0, limit || 100); - - localStorage.setItem( - `${applicationId}_configHistory`, - JSON.stringify({ - ...oldConfigHistory, - [param]: updatedHistory, - }) - ); - } + this.addToConfigHistory(param, this.props.config.data.get('params').get(param)); this.showNote(`Entry added to ${param}`); } catch (e) { @@ -697,37 +738,7 @@ class Config extends TableView { await this.props.config.dispatch(ActionTypes.FETCH); // Update config history - const limit = this.context.cloudConfigHistoryLimit; - const applicationId = this.context.applicationId; - const params = this.props.config.data.get('params'); - const updatedValue = params.get(param); - const configHistory = localStorage.getItem(`${applicationId}_configHistory`); - const newHistoryEntry = { - time: new Date(), - value: updatedValue, - }; - - if (!configHistory) { - localStorage.setItem( - `${applicationId}_configHistory`, - JSON.stringify({ - [param]: [newHistoryEntry], - }) - ); - } else { - const oldConfigHistory = JSON.parse(configHistory); - const updatedHistory = !oldConfigHistory[param] - ? [newHistoryEntry] - : [newHistoryEntry, ...oldConfigHistory[param]].slice(0, limit || 100); - - localStorage.setItem( - `${applicationId}_configHistory`, - JSON.stringify({ - ...oldConfigHistory, - [param]: updatedHistory, - }) - ); - } + this.addToConfigHistory(param, this.props.config.data.get('params').get(param)); const removedCount = objectsToRemove.length; const message = removedCount === 1 diff --git a/src/dashboard/Data/Config/ConfigDialog.react.js b/src/dashboard/Data/Config/ConfigDialog.react.js index 0eecb3dc22..92b7be729f 100644 --- a/src/dashboard/Data/Config/ConfigDialog.react.js +++ b/src/dashboard/Data/Config/ConfigDialog.react.js @@ -214,11 +214,7 @@ export default class ConfigDialog extends React.Component { ))} ); - const configHistory = - localStorage.getItem(`${this.context.applicationId}_configHistory`) && - JSON.parse(localStorage.getItem(`${this.context.applicationId}_configHistory`))[ - this.state.name - ]; + const configHistory = this.props.configHistory; const handleIndexChange = index => { if (this.state.type === 'Date') { return; diff --git a/src/dashboard/Data/Playground/Playground.react.js b/src/dashboard/Data/Playground/Playground.react.js index 459cc684f1..8af5e953f1 100644 --- a/src/dashboard/Data/Playground/Playground.react.js +++ b/src/dashboard/Data/Playground/Playground.react.js @@ -11,6 +11,7 @@ import Icon from 'components/Icon/Icon.react'; import { CurrentApp } from 'context/currentApp'; import browserStyles from 'dashboard/Data/Browser/Browser.scss'; import Separator from 'components/BrowserMenu/Separator.react'; +import Notification from 'dashboard/Data/Browser/Notification.react'; import ScriptManager from 'lib/ScriptManager'; import styles from './Playground.scss'; @@ -187,6 +188,7 @@ export default function Playground() { const [savedTabs, setSavedTabs] = useState([]); // All saved tabs including closed ones const [, setCurrentMenu] = useState(null); // Track which menu is currently open const [, setForceUpdate] = useState({}); // Force re-render for unsaved changes detection + const [noteMessage, setNoteMessage] = useState(null); const renamingInputRef = useRef(null); // Drag and drop state @@ -992,8 +994,13 @@ export default function Playground() { if (window.localStorage) { try { window.localStorage.setItem(historyKey, JSON.stringify(newHistory)); - } catch (e) { - console.warn('Failed to save execution history:', e); + } catch { + window.localStorage.removeItem(historyKey); + try { + window.localStorage.setItem(historyKey, JSON.stringify([code])); + } catch { + setNoteMessage('Unable to save execution history: browser storage is full.'); + } } } } catch (e) { @@ -1504,6 +1511,7 @@ export default function Playground() { + ); } diff --git a/src/dashboard/Settings/CloudConfigSettings.react.js b/src/dashboard/Settings/CloudConfigSettings.react.js new file mode 100644 index 0000000000..0f56bdfad9 --- /dev/null +++ b/src/dashboard/Settings/CloudConfigSettings.react.js @@ -0,0 +1,169 @@ +import DashboardView from 'dashboard/DashboardView.react'; +import Field from 'components/Field/Field.react'; +import Fieldset from 'components/Fieldset/Fieldset.react'; +import Label from 'components/Label/Label.react'; +import React from 'react'; +import TextInput from 'components/TextInput/TextInput.react'; +import Toolbar from 'components/Toolbar/Toolbar.react'; +import Notification from 'dashboard/Data/Browser/Notification.react'; +import ServerConfigStorage from 'lib/ServerConfigStorage'; +import styles from 'dashboard/Settings/Settings.scss'; + +const CONFIG_KEY = 'config.settings'; + +export default class CloudConfigSettings extends DashboardView { + constructor() { + super(); + this.section = 'App Settings'; + this.subsection = 'Cloud Config'; + this.serverStorage = null; + + this.state = { + cloudConfigHistoryLimit: '', + message: undefined, + loading: true, + }; + } + + componentDidMount() { + if (this.context) { + this.serverStorage = new ServerConfigStorage(this.context); + this.loadSettings(); + } + } + + componentWillUnmount() { + clearTimeout(this.noteTimeout); + } + + async loadSettings() { + try { + const settings = await this.serverStorage.getConfig( + CONFIG_KEY, + this.context.applicationId + ); + if (settings) { + if (settings.historyLimit !== undefined) { + this.setState({ cloudConfigHistoryLimit: String(settings.historyLimit) }); + } + } + } catch { + this.showNote('Failed to load Cloud Config settings.', true); + } finally { + this.setState({ loading: false }); + } + } + + async saveSettings(updates) { + try { + const current = await this.serverStorage.getConfig( + CONFIG_KEY, + this.context.applicationId + ); + const settings = { ...(current || {}), ...updates }; + await this.serverStorage.setConfig( + CONFIG_KEY, + settings, + this.context.applicationId + ); + return true; + } catch { + return false; + } + } + + handleCloudConfigHistoryLimitChange(value) { + this.setState({ cloudConfigHistoryLimit: value }); + } + + async saveCloudConfigHistoryLimit() { + const value = this.state.cloudConfigHistoryLimit.trim(); + + if (value === '') { + if (await this.saveSettings({ historyLimit: undefined })) { + this.showNote('Cloud Config history limit reset to default.'); + } else { + this.showNote('Failed to reset setting.', true); + } + return; + } + + const parsed = parseInt(value, 10); + if (isNaN(parsed) || parsed < 1) { + this.showNote('Please enter a valid positive number.', true); + return; + } + + if (await this.saveSettings({ historyLimit: parsed })) { + this.setState({ cloudConfigHistoryLimit: String(parsed) }); + this.showNote(`Cloud Config history limit set to ${parsed}.`); + } else { + this.showNote('Failed to save setting.', true); + } + } + + showNote(message, isError = false) { + if (!message) { + return; + } + + clearTimeout(this.noteTimeout); + + this.setState({ message: { text: message, isError } }); + + this.noteTimeout = setTimeout(() => { + this.setState({ message: undefined }); + }, 3500); + } + + renderContent() { + const message = this.state.message; + const serverConfigEnabled = this.serverStorage && this.serverStorage.isServerConfigEnabled(); + const notAvailableMessage = !this.state.loading && !serverConfigEnabled + ? 'Server configuration is not enabled for this app. Please add a \'config\' section to your app configuration.' + : null; + + const configFileLimit = this.context.cloudConfigHistoryLimit; + const isOverriddenByConfigFile = configFileLimit !== undefined && configFileLimit !== null; + + let limitDescription = 'Maximum number of history entries stored per Cloud Config parameter. Leave empty to use the default (100).'; + if (isOverriddenByConfigFile) { + limitDescription = `This value is overridden by the dashboard config file (${configFileLimit}). Remove the "cloudConfigHistoryLimit" option from the config file to use this setting.`; + } + + return ( +
+ + +
+
+ + } + input={ + + } + /> +
+
+
+ ); + } +} diff --git a/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js b/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js index 3ecc08033a..023498dda6 100644 --- a/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js +++ b/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js @@ -21,6 +21,7 @@ import * as ClassPreferences from 'lib/ClassPreferences'; import ViewPreferencesManager from 'lib/ViewPreferencesManager'; import FilterPreferencesManager from 'lib/FilterPreferencesManager'; import ScriptManager from 'lib/ScriptManager'; +import ServerConfigStorage from 'lib/ServerConfigStorage'; import bcrypt from 'bcryptjs'; import * as OTPAuth from 'otpauth'; import QRCode from 'qrcode'; @@ -72,6 +73,7 @@ export default class DashboardSettings extends DashboardView { this.viewPreferencesManager = new ViewPreferencesManager(this.context); this.filterPreferencesManager = new FilterPreferencesManager(this.context); this.scriptManager = new ScriptManager(this.context); + this.serverStorage = new ServerConfigStorage(this.context); this.loadStoragePreference(); } } @@ -131,6 +133,9 @@ export default class DashboardSettings extends DashboardView { // Migrate scripts const scriptsResult = await this.scriptManager.migrateToServer(this.context.applicationId, overwriteConflicts); + // Migrate Cloud Config history + const configHistoryResult = await this.migrateConfigHistoryToServer(); + // Check for conflicts const allConflicts = [ ...(viewsResult.conflicts || []), @@ -144,7 +149,7 @@ export default class DashboardSettings extends DashboardView { return; } - const totalItems = viewsResult.viewCount + filtersResult.filterCount + scriptsResult.scriptCount; + const totalItems = viewsResult.viewCount + filtersResult.filterCount + scriptsResult.scriptCount + configHistoryResult.paramCount; if (viewsResult.success && filtersResult.success && scriptsResult.success) { if (totalItems > 0) { @@ -158,9 +163,12 @@ export default class DashboardSettings extends DashboardView { if (scriptsResult.scriptCount > 0) { messages.push(`${scriptsResult.scriptCount} script(s)`); } + if (configHistoryResult.paramCount > 0) { + messages.push(`${configHistoryResult.paramCount} config history parameter(s)`); + } this.showNote(`Successfully migrated ${messages.join(', ')} to server storage.`); } else { - this.showNote('No views, filters, or scripts found to migrate.'); + this.showNote('No views, filters, scripts, or config history found to migrate.'); } } } catch (error) { @@ -189,6 +197,38 @@ export default class DashboardSettings extends DashboardView { this.showNote('Migration aborted. Server settings were not modified.'); } + async migrateConfigHistoryToServer() { + const applicationId = this.context.applicationId; + const raw = localStorage.getItem(`${applicationId}_configHistory`); + if (!raw) { + return { paramCount: 0 }; + } + + let localHistory; + try { + localHistory = JSON.parse(raw); + } catch { + this.showNote('Failed to parse Cloud Config history from browser storage.'); + return { paramCount: 0 }; + } + const paramNames = Object.keys(localHistory); + let paramCount = 0; + + for (const name of paramNames) { + const entries = localHistory[name]; + if (entries && entries.length > 0) { + await this.serverStorage.setConfig( + `config.history.parameters.${name}`, + { values: entries }, + applicationId + ); + paramCount++; + } + } + + return { paramCount }; + } + async deleteFromBrowser() { if (!window.confirm('Are you sure you want to delete all dashboard settings from browser storage? This action cannot be undone.')) { return; @@ -212,6 +252,7 @@ export default class DashboardSettings extends DashboardView { const viewsSuccess = this.viewPreferencesManager.deleteFromBrowser(this.context.applicationId); const filtersSuccess = this.filterPreferencesManager.deleteFromBrowser(this.context.applicationId); const scriptsSuccess = this.scriptManager.deleteFromBrowser(this.context.applicationId); + localStorage.removeItem(`${this.context.applicationId}_configHistory`); if (viewsSuccess && filtersSuccess && scriptsSuccess) { this.showNote('Successfully deleted dashboard settings from browser storage.'); @@ -553,7 +594,7 @@ export default class DashboardSettings extends DashboardView { {this.viewPreferencesManager && this.scriptManager && this.viewPreferencesManager.isServerConfigEnabled() && (
- Storing dashboard settings on the server rather than locally in the browser storage makes the settings available across devices and browsers. It also prevents them from getting lost when resetting the browser website data. Settings that can be stored on the server are currently Data Browser Filters, Data Browser Graphs, Views, Keyboard Shortcuts and JS Console scripts. + Storing dashboard settings on the server rather than locally in the browser storage makes the settings available across devices and browsers. It also prevents them from getting lost when resetting the browser website data. Settings that can be stored on the server are currently Data Browser Filters, Data Browser Graphs, Views, Keyboard Shortcuts, JS Console scripts, and Cloud Config history.