diff --git a/untangle-vue-ui/source/package.json b/untangle-vue-ui/source/package.json index cf7b9326eb..67e5ca136c 100644 --- a/untangle-vue-ui/source/package.json +++ b/untangle-vue-ui/source/package.json @@ -19,6 +19,7 @@ "highcharts": "^9.1.2", "lodash": "^4.17.21", "nanomatch": "^1.2.13", + "v-code-diff": "^1.13.1", "vee-validate": "^3.4.10", "vue": "2.7.16", "vue-grid-layout": "^2.3.12", @@ -28,7 +29,7 @@ "vuetify": "^2.7.2", "vuex": "^3.6.2", "vuex-persist": "^3.1.3", - "vuntangle": "git+ssh://git@github.com/untangle/vuntangle.git#ngfw-17.5-release" + "vuntangle": "untangle/vuntangle" }, "devDependencies": { "@sentry/webpack-plugin": "^1.15.1", diff --git a/untangle-vue-ui/source/src/App.vue b/untangle-vue-ui/source/src/App.vue index f4b8eb7352..2122626007 100644 --- a/untangle-vue-ui/source/src/App.vue +++ b/untangle-vue-ui/source/src/App.vue @@ -9,14 +9,16 @@ $route.name === 'setup' " /> + + diff --git a/untangle-vue-ui/source/src/components/Apps/AppsLayout.vue b/untangle-vue-ui/source/src/components/Apps/AppsLayout.vue new file mode 100644 index 0000000000..8760ed11a7 --- /dev/null +++ b/untangle-vue-ui/source/src/components/Apps/AppsLayout.vue @@ -0,0 +1,33 @@ + + + + + + + + + + diff --git a/untangle-vue-ui/source/src/components/Apps/AppsView.vue b/untangle-vue-ui/source/src/components/Apps/AppsView.vue new file mode 100644 index 0000000000..63102aefd2 --- /dev/null +++ b/untangle-vue-ui/source/src/components/Apps/AppsView.vue @@ -0,0 +1,392 @@ + + + + + + + + + {{ $vuntangle.$t('apps') }} + + + + + + + + + + + + + + + + diff --git a/untangle-vue-ui/source/src/components/Apps/apps/ApplicationControlLite/ApplicationControlLite.vue b/untangle-vue-ui/source/src/components/Apps/apps/ApplicationControlLite/ApplicationControlLite.vue new file mode 100644 index 0000000000..8ab4563ebb --- /dev/null +++ b/untangle-vue-ui/source/src/components/Apps/apps/ApplicationControlLite/ApplicationControlLite.vue @@ -0,0 +1,75 @@ + + + + + + + + + + {{ $t('refresh') }} + {{ $t('save') }} + + + + + + diff --git a/untangle-vue-ui/source/src/components/Apps/apps/ApplicationControlLite/index.js b/untangle-vue-ui/source/src/components/Apps/apps/ApplicationControlLite/index.js new file mode 100644 index 0000000000..37f59a1441 --- /dev/null +++ b/untangle-vue-ui/source/src/components/Apps/apps/ApplicationControlLite/index.js @@ -0,0 +1 @@ +export { default } from './ApplicationControlLite.vue' diff --git a/untangle-vue-ui/source/src/components/Apps/apps/CaptivePortal/CaptivePortal.vue b/untangle-vue-ui/source/src/components/Apps/apps/CaptivePortal/CaptivePortal.vue new file mode 100644 index 0000000000..b695667749 --- /dev/null +++ b/untangle-vue-ui/source/src/components/Apps/apps/CaptivePortal/CaptivePortal.vue @@ -0,0 +1,197 @@ + + + + + + + + + + {{ $t('refresh') }} + {{ $t('save') }} + + + + + + diff --git a/untangle-vue-ui/source/src/components/Apps/apps/PhishBlocker/PhishBlocker.vue b/untangle-vue-ui/source/src/components/Apps/apps/PhishBlocker/PhishBlocker.vue new file mode 100644 index 0000000000..9782d4b72d --- /dev/null +++ b/untangle-vue-ui/source/src/components/Apps/apps/PhishBlocker/PhishBlocker.vue @@ -0,0 +1,88 @@ + + + + + + + + + + {{ $t('refresh') }} + {{ $t('save') }} + + + + + + diff --git a/untangle-vue-ui/source/src/components/Apps/apps/PhishBlocker/index.js b/untangle-vue-ui/source/src/components/Apps/apps/PhishBlocker/index.js new file mode 100644 index 0000000000..8ea399316c --- /dev/null +++ b/untangle-vue-ui/source/src/components/Apps/apps/PhishBlocker/index.js @@ -0,0 +1 @@ +export { default } from './PhishBlocker.vue' diff --git a/untangle-vue-ui/source/src/components/Apps/apps/SpamBlocker/SpamBlocker.vue b/untangle-vue-ui/source/src/components/Apps/apps/SpamBlocker/SpamBlocker.vue new file mode 100644 index 0000000000..95f65fe717 --- /dev/null +++ b/untangle-vue-ui/source/src/components/Apps/apps/SpamBlocker/SpamBlocker.vue @@ -0,0 +1,110 @@ + + + + + + + + + + {{ $t('refresh') }} + {{ $t('save') }} + + + + + + diff --git a/untangle-vue-ui/source/src/components/Apps/apps/SpamBlocker/index.js b/untangle-vue-ui/source/src/components/Apps/apps/SpamBlocker/index.js new file mode 100644 index 0000000000..724faf594e --- /dev/null +++ b/untangle-vue-ui/source/src/components/Apps/apps/SpamBlocker/index.js @@ -0,0 +1 @@ +export { default } from './SpamBlocker.vue' diff --git a/untangle-vue-ui/source/src/components/Apps/apps/VirusBlocker/VirusBlocker.vue b/untangle-vue-ui/source/src/components/Apps/apps/VirusBlocker/VirusBlocker.vue new file mode 100644 index 0000000000..0e7873f284 --- /dev/null +++ b/untangle-vue-ui/source/src/components/Apps/apps/VirusBlocker/VirusBlocker.vue @@ -0,0 +1,100 @@ + + + + + + + + + + {{ $t('refresh') }} + {{ $t('save') }} + + + + + + diff --git a/untangle-vue-ui/source/src/components/Apps/apps/VirusBlocker/index.js b/untangle-vue-ui/source/src/components/Apps/apps/VirusBlocker/index.js new file mode 100644 index 0000000000..8388519d47 --- /dev/null +++ b/untangle-vue-ui/source/src/components/Apps/apps/VirusBlocker/index.js @@ -0,0 +1 @@ +export { default } from './VirusBlocker.vue' diff --git a/untangle-vue-ui/source/src/components/Apps/apps/WebCache/WebCache.vue b/untangle-vue-ui/source/src/components/Apps/apps/WebCache/WebCache.vue new file mode 100644 index 0000000000..cd1e8c96b3 --- /dev/null +++ b/untangle-vue-ui/source/src/components/Apps/apps/WebCache/WebCache.vue @@ -0,0 +1,95 @@ + + + + + + + + + + {{ $t('refresh') }} + {{ $t('save') }} + + + + + + diff --git a/untangle-vue-ui/source/src/components/Apps/apps/WebFilter/WebFilter.vue b/untangle-vue-ui/source/src/components/Apps/apps/WebFilter/WebFilter.vue new file mode 100644 index 0000000000..f0d013f6dc --- /dev/null +++ b/untangle-vue-ui/source/src/components/Apps/apps/WebFilter/WebFilter.vue @@ -0,0 +1,190 @@ + + + + + + + + + {{ $t('refresh') }} + {{ $t('save') }} + + + + + + diff --git a/untangle-vue-ui/source/src/components/Apps/apps/WebFilter/capabilities.js b/untangle-vue-ui/source/src/components/Apps/apps/WebFilter/capabilities.js new file mode 100644 index 0000000000..c78d739fb2 --- /dev/null +++ b/untangle-vue-ui/source/src/components/Apps/apps/WebFilter/capabilities.js @@ -0,0 +1,96 @@ +import { webFilterDefaultCapabilities } from 'vuntangle' + +export const ngfwCapabilities = { + ...webFilterDefaultCapabilities, + main: { + policyManagerAlert: { render: false }, + enableToggle: { render: false }, + appIcon: { render: true }, + powerStatus: { render: true }, + description: { render: true, key: 'app_web_filter_description' }, + }, + categories: { + listGroup: { + mode: 'direct', + actions: [ + { value: 'blocked', text: 'block', forceEnabledFor: [] }, + { value: 'flagged', text: 'flag', forceEnabledFor: [] }, + ], + }, + }, + searchTerms: { + ...webFilterDefaultCapabilities.searchTerms, + importExport: { + render: true, + exportId: 'web-filter-search-terms', + formats: [ + { text: 'comma_delimited', value: 'comma' }, + { text: 'newline_delimited', value: 'newline' }, + { text: 'json_array', value: 'json' }, + ], + defaultActions: [ + { key: 'blocked', label: 'block', default: true }, + { key: 'flagged', label: 'flag', default: true }, + ], + }, + defaultItem: { + javaClass: 'com.untangle.uvm.app.GenericRule', + string: '', + description: '', + blocked: true, + flagged: true, + }, + }, + blockSites: { + ...webFilterDefaultCapabilities.blockSites, + siteName: { key: 'string', label: 'enter_site_domain', validator: null }, + enabled: { render: false }, + blocked: { render: true }, + rejected: { render: false }, + flagged: { render: true, label: 'flag' }, + logged: { render: false }, + exact: { render: false }, + global: { render: true }, + importExport: { render: true, exportId: 'web-filter-block-sites' }, + defaultItem: { + javaClass: 'com.untangle.uvm.app.GenericRule', + string: '', + description: '', + blocked: true, + flagged: true, + isGlobal: false, + }, + }, + passSites: { + ...webFilterDefaultCapabilities.passSites, + siteName: { key: 'string', label: 'enter_site_domain', validator: null }, + flagged: { render: false }, + logged: { render: false }, + exact: { render: false }, + global: { render: true }, + importExport: { render: true, exportId: 'web-filter-pass-sites' }, + defaultItem: { + javaClass: 'com.untangle.uvm.app.GenericRule', + string: '', + description: '', + enabled: true, + isGlobal: false, + }, + }, + passClients: { + ...webFilterDefaultCapabilities.passClients, + defaultItem: { + javaClass: 'com.untangle.uvm.app.GenericRule', + string: '1.2.3.4', + description: '', + enabled: true, + }, + }, + siteLookup: { + ...webFilterDefaultCapabilities.siteLookup, + recategorize: { render: true }, + }, + advanced: { + ...webFilterDefaultCapabilities.advanced, + }, +} diff --git a/untangle-vue-ui/source/src/components/Apps/apps/appMixin.js b/untangle-vue-ui/source/src/components/Apps/apps/appMixin.js new file mode 100644 index 0000000000..fef2640d13 --- /dev/null +++ b/untangle-vue-ui/source/src/components/Apps/apps/appMixin.js @@ -0,0 +1,321 @@ +import { mapGetters } from 'vuex' +import { getReportUrl, getReportIcon } from '@/util/reports' +import util from '@/util/util' +import { EVENT_ACTIONS } from '@/constants/actions' +import { sendEvent } from '@/utils/event' +import Util from '@/util/setupUtil' + +export default { + data() { + return { + appManager: null, // Cached app manager instance for RPC calls + toggling: false, // Local state to track if app is currently toggling (starting/stopping) + saveDisabled: false, // Flag to disable save button on settings error + sessionHistory: this.initializeSessionsData(), // Rolling 7-point history for the sessions chart + } + }, + + provide() { + return { + isReportsInstalled: this.isReportsInstalled, + isRestricted: this.isRestricted, + } + }, + + inject: ['embedded'], + + /** + * Common computed properties and methods related to app management, metrics, and reports integration. + * Can be overridden in individual app components as needed, but provides a shared baseline implementation for all apps + * to reduce code duplication and ensure consistent behavior across app components. + */ + computed: { + ...mapGetters('metrics', ['getFormattedMetrics', 'getLiveSessions', 'lastUpdateTime']), + ...mapGetters('apps', ['getAppPowerState']), + + /** + * App display name for reports filtering and UI labels. + * Falls back to defaultDisplayName data property defined in each component. + */ + appDisplayName: ({ appData, defaultDisplayName }) => + appData?.appProperties?.displayName || defaultDisplayName || '', + + // App Instance ID. + instanceId: ({ appData }) => appData?.instance?.id || null, + + /** + * Check if Reports app is installed + * @returns {Boolean} True if reports is installed, else false + * TODO Remove Util Method Call Once Reports App and its flows are Fully Migrated + */ + isReportsInstalled: ({ $store }) => $store.getters['reports/isReportsInstalled'] || !!util.isReportsInstalled(), + + /** + * Check if the license instance is restricted + * @returns {Boolean} True if restricted, else false + */ + isRestricted: () => !!util.isRestricted(), + + /** + * Power state of the app, derived from Vuex store with local toggling state overlay + * @returns {Object} Power state object with 'on' boolean and 'toggling' boolean + */ + powerState: ({ appData, appManager, getAppPowerState, toggling }) => { + const vuexPowerState = getAppPowerState({ + policyId: appData?.policyId, + appName: appData?.appName, + appManager, + }) + + return { + ...vuexPowerState, + power: toggling, + } + }, + + /** + * Get reports for this app from global store + * Filters by appDisplayName and transforms to UI format + * @returns {Array} - Formatted reports array + */ + appReports: ({ $store, appDisplayName }) => { + if (!appDisplayName) return [] + + const rawReports = $store.getters['reports/getReportsByCategory'](appDisplayName) + + return rawReports.map(report => ({ + key: report.uniqueId, + label: report.title || report.uniqueId, + url: getReportUrl(report.category, report.title), + icon: getReportIcon(report.type), + })) + }, + + /** + * Formatted metrics for this app instance from Vuex store + * @param {String} instanceId - App instance ID for metrics lookup + * @returns {Array} - Formatted metrics array or empty array if no instanceId + */ + formattedMetrics: ({ instanceId, getFormattedMetrics }) => (instanceId ? getFormattedMetrics(instanceId) : []), + + /** + * Sessions chart data — returns the rolling 7-point history maintained by the watcher. + * Falls back to the zero-initialized baseline when the app is off or instanceId is unavailable. + */ + sessionsData() { + if (!this.powerState?.on) return [] + if (!this.instanceId) return this.initializeSessionsData() + return this.sessionHistory + }, + + settings: ({ $store, appData }) => + $store.getters['apps/getSettings'](`${appData?.appName}-${appData?.instance?.id}`)?.settings || {}, + }, + + watch: { + /** + * Fires on every poll tick (lastUpdateTime advances even when sessions value is unchanged), + * ensuring the x-axis timestamp always moves forward. + */ + lastUpdateTime(timestamp) { + if (!this.powerState?.on || !this.instanceId) return + + const sessions = this.getLiveSessions(this.instanceId) + + if (this.sessionHistory.length === 0) { + const init = this.initializeSessionsData() + init[init.length - 1].sessions = sessions + this.sessionHistory = init + return + } + + this.sessionHistory = [...this.sessionHistory.slice(1), { timestamp, sessions }] + }, + + /** + * Clear history when the app is turned off so the chart resets on next start. + */ + 'powerState.on'(isOn) { + if (!isOn) this.sessionHistory = this.initializeSessionsData() + }, + }, + + async created() { + if (!this.appData?.instance?.id) return + this.appManager = await this.$store.dispatch('apps/getAppById', { + appId: this.appData.instance.id, + }) + this.loadAppSettings() + }, + + /** + * Common methods for app components - toggleAppState, removeApp, initializeSessionsData, loadAppSettings, saveSettings + * These methods can be overridden or extended in individual app components as needed, but provide a shared baseline implementation + * for all apps to reduce code duplication and ensure consistent behavior across app components. + */ + methods: { + /** + * Toggle app state. Starts or stops the app based on the 'enabled' parameter. + * @param {boolean} enabled - Target state (true = starting, false = stopping) + */ + async toggleAppState(enabled) { + if (!this.appManager) return + + this.toggling = true + try { + const rpcMethod = enabled ? 'start' : 'stop' + + await new Promise((resolve, reject) => { + this.appManager[rpcMethod]((ex, res) => { + if (ex || res?.code) { + reject(ex || new Error(res?.message || `Failed to ${rpcMethod} app`)) + } else { + resolve(res) + } + }) + }) + this.$store.dispatch('apps/getAppsViews', true) + this.refreshLicenseStatus() + if (this.embedded) { + // Send event to parent window to refresh app status. + // TODO Remove this once all apps are migrated to Vue UI and Parent Layout is changed + this.sendEventToParentWindow(EVENT_ACTIONS.REFRESH_APP_STATUS) + } + } catch (error) { + Util.handleException(error) + } finally { + this.toggling = false + } + }, + + /** + * Remove/Uninstall the app + */ + async removeApp() { + this.$store.commit('SET_LOADER', true) + try { + await this.$store.dispatch('apps/destroyApp', { + instanceId: this.appData?.instance?.id, + policyId: this.appData?.policyId, + }) + if (this.embedded) { + // If embedded, just send event to parent + // TODO Remove this once all apps are migrated to Vue UI and Parent Layout is changed + this.sendEventToParentWindow(EVENT_ACTIONS.REMOVE_APP) + } else { + this.$router.push({ name: 'apps', params: { policyId: this.appData?.policyId } }) + } + } catch (error) { + } finally { + this.$store.commit('SET_LOADER', false) + } + }, + + /** + * Send event to parent window + * @param {String} type - Event type from EVENT_ACTIONS + */ + sendEventToParentWindow(type) { + const eventData = { + appName: this.appData?.appName, + type, + } + + if (type === EVENT_ACTIONS.REFRESH_APP_STATUS) { + eventData.targetState = this.powerState.on ? 'RUNNING' : 'INITIALIZED' + } + + sendEvent(eventData) + }, + + /** + * Builds the base consolidated app data passed to each app component. + * Merges appData and powerState with any app-specific extras. + * @param {Object} extras - Additional app-specific properties to merge + * @returns {Object} Consolidated app data + */ + buildConsolidatedAppData(extras = {}) { + return { + ...this.appData, + powerState: this.powerState || {}, + ...extras, + } + }, + + /** + * Initialize sessions chart with 7 zero points + * Creates baseline data for chart display when no metrics available yet + * @returns {Array} Array of 7 data points with timestamps and zero sessions + */ + initializeSessionsData() { + const data = [] + const now = Date.now() + const roundedNow = Math.round(now / 1000) * 1000 + + for (let i = -6; i <= 0; i++) { + data.push({ + timestamp: roundedNow + i * 10000, + sessions: 0, + }) + } + + return data + }, + + /** + * Load app settings from backend via RPC call to app manager and store in Vuex + */ + loadAppSettings() { + this.$store.commit('SET_LOADER', true) + this.$store + .dispatch('apps/loadAppData', { + appName: this.appData?.appName, + appId: this.appData?.instance?.id, + app: this.appManager, + }) + .finally(() => { + this.$store.commit('SET_LOADER', false) + }) + }, + + /** + * Save app settings by sending updated settings to backend via RPC call to app manager, then refresh app data and license status + * @param {Object} newSettings - Settings object to save + */ + saveSettings(newSettings) { + this.$store.commit('SET_LOADER', true) + this.$store + .dispatch('apps/setAppSettings', { + appName: this.appData?.appName, + settings: newSettings, + appId: this.appData?.instance?.id, + app: this.appManager, + }) + .catch(error => { + this.saveDisabled = true + Util.handleException(error) + }) + .finally(() => { + this.$store.commit('SET_LOADER', false) + }) + }, + + /** + * Refresh app data - reload settings and app view data. + * Triggers computed properties (powerState and appReports) to update with latest data. + * Also refreshes license status in case app state change affects licensing. + */ + refreshData() { + this.refreshLicenseStatus() + this.loadAppSettings() + this.$store.dispatch('apps/getAppsView', this.appData?.policyId) + }, + + /** + * Emit event to refresh license status in parent component + */ + refreshLicenseStatus() { + this.$emit('refreshLicenseStatus') + }, + }, +} diff --git a/untangle-vue-ui/source/src/components/Apps/apps/conditionDataMixin.js b/untangle-vue-ui/source/src/components/Apps/apps/conditionDataMixin.js new file mode 100644 index 0000000000..c8272a7e2b --- /dev/null +++ b/untangle-vue-ui/source/src/components/Apps/apps/conditionDataMixin.js @@ -0,0 +1,34 @@ +import { usernameOptions, directoryConnectorGroupOptions, directoryConnectorDomainOptions } from 'vuntangle' + +/** + * Factory mixin for app components that need runtime condition dropdown data. + * + * @param {string[]} appNames - Service names whose condition data to fetch (e.g. ['directory-connector']) + * + * Usage: + * import conditionDataMixin from '../conditionDataMixin' + * mixins: [appMixin, conditionDataMixin(['directory-connector'])] + * + * Then include the needed keys in provide() → $remoteData: + * $remoteData: () => ({ ..., directoryGroups: this.directoryGroups, directoryDomains: this.directoryDomains, directoryUsers: this.directoryUsers }) + */ +export default function conditionDataMixin(appNames = []) { + return { + computed: { + conditionData: ({ $store }) => $store.getters['apps/conditionData'], + directoryGroups: ({ conditionData }) => + conditionData?.directoryGroups?.length + ? [...directoryConnectorGroupOptions, ...conditionData.directoryGroups] + : [], + directoryDomains: ({ conditionData }) => + conditionData?.directoryDomains?.length + ? [...directoryConnectorDomainOptions, ...conditionData.directoryDomains] + : [], + directoryUsers: ({ conditionData }) => + conditionData?.directoryUsers?.length ? [...usernameOptions, ...conditionData.directoryUsers] : [], + }, + created() { + this.$store.dispatch('apps/fetchConditionData', appNames) + }, + } +} diff --git a/untangle-vue-ui/source/src/components/Apps/components/AppCard.vue b/untangle-vue-ui/source/src/components/Apps/components/AppCard.vue new file mode 100644 index 0000000000..15db30d628 --- /dev/null +++ b/untangle-vue-ui/source/src/components/Apps/components/AppCard.vue @@ -0,0 +1,318 @@ + + + + + + {{ app.licenseMessage }} + + + + + + + + + + + + + + + + + + + {{ $vuntangle.$t(app.displayName) }} + + + + [{{ app.parentPolicy }}] + + + + + + + + diff --git a/untangle-vue-ui/source/src/components/Apps/components/AppsToolbar.vue b/untangle-vue-ui/source/src/components/Apps/components/AppsToolbar.vue new file mode 100644 index 0000000000..950d085f80 --- /dev/null +++ b/untangle-vue-ui/source/src/components/Apps/components/AppsToolbar.vue @@ -0,0 +1,330 @@ + + + + + + + mdi-file-document-outline + {{ selectedPolicy.name }} + mdi-chevron-down + + + + + + + + + + mdi-plus + {{ $vuntangle.$t('install_apps') }} + + + + + mdi-arrow-left + {{ $vuntangle.$t('back_to_apps') }} [{{ selectedPolicy.name }}] + + + + + + {{ $vuntangle.$t('installing_recommended_apps') }} + + + + + {{ $vuntangle.$t('available_apps_for') }} + + mdi-file-document-outline + {{ selectedPolicy.name }} + + + + + + + + + mdi-cog + {{ $vuntangle.$t('manage_policies') }} + + + + + + + diff --git a/untangle-vue-ui/source/src/components/Apps/components/PowerButton.vue b/untangle-vue-ui/source/src/components/Apps/components/PowerButton.vue new file mode 100644 index 0000000000..68faca1edc --- /dev/null +++ b/untangle-vue-ui/source/src/components/Apps/components/PowerButton.vue @@ -0,0 +1,55 @@ + + + + mdi-power + + + + + + + diff --git a/untangle-vue-ui/source/src/components/Apps/containers/InstallableApps.vue b/untangle-vue-ui/source/src/components/Apps/containers/InstallableApps.vue new file mode 100644 index 0000000000..d3033d1c98 --- /dev/null +++ b/untangle-vue-ui/source/src/components/Apps/containers/InstallableApps.vue @@ -0,0 +1,373 @@ + + + + + + + + + + + + + + + + + + + + + mdi-download + + + + + mdi-check-bold + + + + + + + + + + + + {{ $vuntangle.$t(app.displayName) }} + + + + + + + + {{ $vuntangle.$t(app.description) }} + + + + + + + + + + + diff --git a/untangle-vue-ui/source/src/components/Apps/containers/InstalledApps.vue b/untangle-vue-ui/source/src/components/Apps/containers/InstalledApps.vue new file mode 100644 index 0000000000..61300bf59d --- /dev/null +++ b/untangle-vue-ui/source/src/components/Apps/containers/InstalledApps.vue @@ -0,0 +1,54 @@ + + + + + + + + + + + diff --git a/untangle-vue-ui/source/src/components/reports/ReportDetails.vue b/untangle-vue-ui/source/src/components/reports/ReportDetails.vue new file mode 100644 index 0000000000..f50b3a80f7 --- /dev/null +++ b/untangle-vue-ui/source/src/components/reports/ReportDetails.vue @@ -0,0 +1,276 @@ + + + + + + + + + + diff --git a/untangle-vue-ui/source/src/components/reports/Reports.vue b/untangle-vue-ui/source/src/components/reports/Reports.vue new file mode 100644 index 0000000000..f265a1ef73 --- /dev/null +++ b/untangle-vue-ui/source/src/components/reports/Reports.vue @@ -0,0 +1,60 @@ + + + + {{ $t('export') }} + + + + diff --git a/untangle-vue-ui/source/src/components/reports/SettingsDiffDialog.vue b/untangle-vue-ui/source/src/components/reports/SettingsDiffDialog.vue new file mode 100644 index 0000000000..efe14a3139 --- /dev/null +++ b/untangle-vue-ui/source/src/components/reports/SettingsDiffDialog.vue @@ -0,0 +1,156 @@ + + + + + + + + + + + {{ errorMessage }} + + + + + + + + + + diff --git a/untangle-vue-ui/source/src/components/reports/reportsMixin.js b/untangle-vue-ui/source/src/components/reports/reportsMixin.js new file mode 100644 index 0000000000..c57b98c8ac --- /dev/null +++ b/untangle-vue-ui/source/src/components/reports/reportsMixin.js @@ -0,0 +1,89 @@ +import { mapGetters } from 'vuex' +import { urlEncode, tableContainsColumns } from '@/util/reports' +import { globalOperatorOptions, globalConditionColumns, protocolNameMap, conditionValueOptions } from '@/constants' + +export default { + provide() { + return { + $conditions: () => this.globalConditions, + $conditionOperators: () => this.globalConditionOperators, + $conditionColumns: () => this.globalConditionColumns, + $conditionFormatValue: () => this.conditionFormatValue, + $conditionValueOptions: () => conditionValueOptions, + $disabledReportIds: () => this.disabledReportIds, + } + }, + + computed: { + ...mapGetters('reports', ['allReports', 'globalConditions']), + + globalConditionOperators() { + return globalOperatorOptions.map(op => ({ value: op.value, text: this.$t(op.text) })) + }, + + globalConditionColumns() { + return globalConditionColumns.map(col => ({ value: col.value, text: this.$t(col.text) })) + }, + + /** Unique column names currently used across all active global conditions. */ + conditionColumns() { + return [...new Set(this.globalConditions.map(c => c.column))] + }, + + /** + * Report uniqueIds that should be disabled because their database table + * does not contain one or more columns required by the active global conditions. + */ + disabledReportIds() { + if (!this.conditionColumns.length) return [] + return this.allReports + .filter(r => r.table && !tableContainsColumns(r.table, this.conditionColumns)) + .map(r => r.uniqueId) + }, + + /** + * Returns a formatter function that resolves raw column values to display names. + * Currently handles protocol numbers → "TCP [6]" style names. + */ + conditionFormatValue() { + return (column, value) => { + if (column === 'protocol') return protocolNameMap[value] || value + return value + } + }, + }, + + methods: { + /** + * Navigates to the report details page for the given uniqueId. + * Resolves the report from the store, then builds cat/rep route params. + * @param {String} uniqueId - the report's uniqueId from the backend + */ + onViewReport(uniqueId) { + const report = this.allReports.find(r => r.uniqueId === uniqueId) + if (!report) return + this.$router.push({ + name: 'report-details', + params: { + cat: urlEncode(report.category), + rep: urlEncode(report.title), + }, + }) + }, + + /** Adds a new global condition to the Vuex store. */ + onAddCondition(condition) { + this.$store.commit('reports/ADD_GLOBAL_CONDITION', condition) + }, + + /** Removes a global condition by index from the Vuex store. */ + onRemoveCondition(index) { + this.$store.commit('reports/REMOVE_GLOBAL_CONDITION', index) + }, + + /** Clears all active global conditions from the Vuex store. */ + onClearConditions() { + this.$store.commit('reports/CLEAR_GLOBAL_CONDITIONS') + }, + }, +} diff --git a/untangle-vue-ui/source/src/components/settings/administration/Admin.vue b/untangle-vue-ui/source/src/components/settings/administration/Admin.vue deleted file mode 100644 index 0f5b88d0d2..0000000000 --- a/untangle-vue-ui/source/src/components/settings/administration/Admin.vue +++ /dev/null @@ -1,102 +0,0 @@ - - - - - {{ - $t('save') - }} - - - - - diff --git a/untangle-vue-ui/source/src/components/settings/advanced/Advanced.vue b/untangle-vue-ui/source/src/components/settings/advanced/Advanced.vue index 6f353e79a4..5cdfd9d36b 100644 --- a/untangle-vue-ui/source/src/components/settings/advanced/Advanced.vue +++ b/untangle-vue-ui/source/src/components/settings/advanced/Advanced.vue @@ -38,12 +38,12 @@ computed: { /* network settings from the store */ - networkSettings: ({ $store }) => $store.getters['settings/networkSetting'], + networkSettings: ({ $store }) => $store.getters['config/networkSetting'], /* Gets the expert mode status from the settings store */ - isExpertMode: ({ $store }) => $store.getters['settings/isExpertMode'], + isExpertMode: ({ $store }) => $store.getters['config/isExpertMode'], /* Get list of interfaces from the settings store */ - interfaces: ({ $store }) => $store.getters['settings/interfaces'], + interfaces: ({ $store }) => $store.getters['config/interfaces'], /** * returns the interfaces for condition value from network settings * @param {Object} vm.networkSettings @@ -64,7 +64,7 @@ * @param {boolean} refetch - Whether to force a re-fetch of the settings. */ async fetchSettings(refetch) { - await this.$store.dispatch('settings/getNetworkSettings', refetch) + await this.$store.dispatch('config/getNetworkSettings', refetch) }, /** @@ -77,7 +77,7 @@ if (!isValid) return this.$store.commit('SET_LOADER', true) - await Promise.all([this.$store.dispatch('settings/setNetworkSettingV2', advancedSettings)]).finally(() => { + await Promise.all([this.$store.dispatch('config/setNetworkSettingV2', advancedSettings)]).finally(() => { this.$store.commit('SET_LOADER', false) }) }, diff --git a/untangle-vue-ui/source/src/components/settings/firewall/DenialOfService.vue b/untangle-vue-ui/source/src/components/settings/firewall/DenialOfService.vue index 0a74241c51..c425b8300c 100644 --- a/untangle-vue-ui/source/src/components/settings/firewall/DenialOfService.vue +++ b/untangle-vue-ui/source/src/components/settings/firewall/DenialOfService.vue @@ -39,13 +39,13 @@ computed: { // the shield settings from store - shieldSettings: ({ $store }) => $store.getters['settings/shieldSettings'], + shieldSettings: ({ $store }) => $store.getters['config/shieldSettings'], // the network settings from the store - networkSettings: ({ $store }) => $store.getters['settings/networkSetting'], + networkSettings: ({ $store }) => $store.getters['config/networkSetting'], /** - * eturns the interfaces for condition value from network settings + * returns the interfaces for condition value from network settings * @param {Object} vm.networkSettings */ interfaces: ({ networkSettings }) => { @@ -68,7 +68,7 @@ this.$store.commit('SET_LOADER', true) await store - .dispatch('settings/setShieldSettings', newSettings) + .dispatch('config/setShieldSettings', newSettings) .finally(() => this.$store.commit('SET_LOADER', false)) }, @@ -88,8 +88,8 @@ this.$store.commit('SET_LOADER', true) try { await Promise.all([ - this.$store.dispatch('settings/getShieldSettings', shieldRefetch), - this.$store.dispatch('settings/getNetworkSettings', networkRefetch), + this.$store.dispatch('config/getShieldSettings', shieldRefetch), + this.$store.dispatch('config/getNetworkSettings', networkRefetch), ]) } finally { this.$store.commit('SET_LOADER', false) diff --git a/untangle-vue-ui/source/src/components/settings/interface/DeleteInterfaceDialog.vue b/untangle-vue-ui/source/src/components/settings/interface/DeleteInterfaceDialog.vue index 6031d548fb..ae92efe149 100644 --- a/untangle-vue-ui/source/src/components/settings/interface/DeleteInterfaceDialog.vue +++ b/untangle-vue-ui/source/src/components/settings/interface/DeleteInterfaceDialog.vue @@ -29,7 +29,7 @@ *deleteInterface will handle for remove selected interface from all the interfaces * Updated Interfaces will be passed to the Network Setting */ - const response = await this.$store.dispatch('settings/deleteInterface', this.intf) + const response = await this.$store.dispatch('config/deleteInterface', this.intf) if (response) { this.$vuntangle.toast.add(this.$t('deleted_successfully', [this.$t('interface')])) // notify consumer of success diff --git a/untangle-vue-ui/source/src/components/settings/interface/Ipv4Aliases.vue b/untangle-vue-ui/source/src/components/settings/interface/Ipv4Aliases.vue index 792ce8c7ed..9f2e47c1d0 100644 --- a/untangle-vue-ui/source/src/components/settings/interface/Ipv4Aliases.vue +++ b/untangle-vue-ui/source/src/components/settings/interface/Ipv4Aliases.vue @@ -90,7 +90,7 @@ */ validateUniqueIpAddress(value) { // check aliases from other network interfaces - for (const networkInterface of this.$store.getters['settings/interfaces']) { + for (const networkInterface of this.$store.getters['config/interfaces']) { // ignore current interface, those will be checked from the current editable data if (networkInterface.interfaceId === this.interfaceId) { continue diff --git a/untangle-vue-ui/source/src/components/settings/network/Dhcp.vue b/untangle-vue-ui/source/src/components/settings/network/Dhcp.vue index 6a135c714d..f52f26106c 100644 --- a/untangle-vue-ui/source/src/components/settings/network/Dhcp.vue +++ b/untangle-vue-ui/source/src/components/settings/network/Dhcp.vue @@ -25,7 +25,7 @@ }, computed: { - dhcpSettings: ({ $store }) => $store.getters['settings/networkSetting'], + dhcpSettings: ({ $store }) => $store.getters['config/networkSetting'], }, created() { @@ -40,7 +40,7 @@ * @param {boolean} refetch - Whether to force a re-fetch of the settings. */ async fetchSettings(refetch) { - await this.$store.dispatch('settings/getNetworkSettings', refetch) + await this.$store.dispatch('config/getNetworkSettings', refetch) }, /** @@ -92,7 +92,7 @@ const isValid = await validate() if (!isValid) return this.$store.commit('SET_LOADER', true) - await Promise.all([this.$store.dispatch('settings/setNetworkSettingV2', newDhcpSettings)]).finally(() => { + await Promise.all([this.$store.dispatch('config/setNetworkSettingV2', newDhcpSettings)]).finally(() => { this.$store.commit('SET_LOADER', false) }) }, diff --git a/untangle-vue-ui/source/src/components/settings/network/Dns.vue b/untangle-vue-ui/source/src/components/settings/network/Dns.vue index 5a5104c6d9..9763ac9ac2 100644 --- a/untangle-vue-ui/source/src/components/settings/network/Dns.vue +++ b/untangle-vue-ui/source/src/components/settings/network/Dns.vue @@ -28,9 +28,9 @@ * @property {Array} dnsSettings.localServers - List of local DNS servers. * @property {Array} dnsSettings.staticEntries - List of static DNS entries. */ - dnsSettings: ({ $store }) => $store.getters['settings/dnsSettings'], + dnsSettings: ({ $store }) => $store.getters['config/dnsSettings'], // the network settings from the store - networkSettings: ({ $store }) => $store.getters['settings/networkSetting'], + networkSettings: ({ $store }) => $store.getters['config/networkSetting'], }, created() { @@ -43,14 +43,14 @@ * @param {boolean} refetch - Whether to force a re-fetch of the settings. */ async fetchSettings(refetch) { - await this.$store.dispatch('settings/getNetworkSettings', refetch) + await this.$store.dispatch('config/getNetworkSettings', refetch) }, async onSaveSettings(newDnsSettings) { this.$store.commit('SET_LOADER', true) const networkSettingsCopy = cloneDeep(this.networkSettings) networkSettingsCopy.dnsSettings = newDnsSettings - await Promise.all([this.$store.dispatch('settings/setNetworkSettingV2', networkSettingsCopy)]).finally(() => { + await Promise.all([this.$store.dispatch('config/setNetworkSettingV2', networkSettingsCopy)]).finally(() => { this.$store.commit('SET_LOADER', false) }) }, diff --git a/untangle-vue-ui/source/src/components/settings/network/Interface.vue b/untangle-vue-ui/source/src/components/settings/network/Interface.vue index 1791f8d3f3..31c6559eae 100644 --- a/untangle-vue-ui/source/src/components/settings/network/Interface.vue +++ b/untangle-vue-ui/source/src/components/settings/network/Interface.vue @@ -39,12 +39,12 @@ computed: { // interfaces filered and grouped (by category) interfaces({ $store }) { - return $store.getters['settings/networkSetting'].interfaces || [] + return $store.getters['config/networkSetting'].interfaces || [] }, }, created() { - this.$store.dispatch('settings/getNetworkSettings') + this.$store.dispatch('config/getNetworkSettings') }, mounted() { @@ -132,7 +132,7 @@ async setRemappedInterfaces(interfaces) { this.$store.commit('SET_LOADER', true) const response = await this.$store - .dispatch('settings/setInterfaces', interfaces) + .dispatch('config/setInterfaces', interfaces) .finally(() => this.$store.commit('SET_LOADER', false)) if (response?.success) { this.$vuntangle.toast.add(this.$t('interfaces_remapped_successfully')) @@ -144,7 +144,7 @@ async onRefresh() { this.$store.commit('SET_LOADER', true) - await this.$store.dispatch('settings/getInterfaces').finally(() => this.$store.commit('SET_LOADER', false)) + await this.$store.dispatch('config/getInterfaces').finally(() => this.$store.commit('SET_LOADER', false)) this.getInterfacesStatus() }, @@ -197,7 +197,7 @@ * Fetches updated network settings and updates the store. */ onBrowserRefresh() { - this.$store.dispatch('settings/getNetworkSettings', true) + this.$store.dispatch('config/getNetworkSettings', true) }, }, } diff --git a/untangle-vue-ui/source/src/components/settings/network/InterfaceEdit.vue b/untangle-vue-ui/source/src/components/settings/network/InterfaceEdit.vue index 96a96dfef3..e6c5c2a6a2 100644 --- a/untangle-vue-ui/source/src/components/settings/network/InterfaceEdit.vue +++ b/untangle-vue-ui/source/src/components/settings/network/InterfaceEdit.vue @@ -48,7 +48,7 @@ computed: { device: ({ $route }) => $route.params.device, type: ({ $route }) => $route.params.type, - interfaces: ({ $store }) => $store.getters['settings/interfaces'], + interfaces: ({ $store }) => $store.getters['config/interfaces'], intfSetting: ({ interfaces, device }) => interfaces.find(intf => intf.device === device), }, async mounted() { @@ -60,17 +60,21 @@ methods: { // check if network is available async isNetworkAvailable(network, cb) { - const result = await new Promise((resolve, reject) => { - window.rpc.UvmContext.netspaceManager().isNetworkAvailable( - (res, err) => (err ? reject(err) : resolve(res)), - 'networking', - network, - ) - }) - if (cb) { - cb(result) + try { + const result = await new Promise((resolve, reject) => { + window.rpc.UvmContext.netspaceManager().isNetworkAvailable( + (res, err) => (err ? reject(err) : resolve(res)), + 'networking', + network, + ) + }) + if (cb) { + cb(result) + } + return (this.status = result) + } catch (ex) { + Util.handleException(ex) } - return (this.status = result) }, // get the interface status @@ -100,37 +104,57 @@ /** returns box Wi-Fi channels */ async onGetWifiChannels(countryCode, cb) { - if (countryCode === '') { - countryCode = await window.rpc.networkManager.getWirelessRegulatoryCountryCode(this.intfSetting?.systemDev) + try { + if (countryCode === '') { + countryCode = await window.rpc.networkManager.getWirelessRegulatoryCountryCode(this.intfSetting?.systemDev) + } + const response = (await window.rpc.networkManager.getWirelessChannels( + this.intfSetting?.systemDev, + countryCode, + )) || [{ frequency: this.$t('no_channel_match'), channel: -1 }] + cb(response ?? null) + } catch (ex) { + Util.handleException(ex) } - const response = (await window.rpc.networkManager.getWirelessChannels( - this.intfSetting?.systemDev, - countryCode, - )) || [{ frequency: this.$t('no_channel_match'), channel: -1 }] - cb(response ?? null) }, /** returns country codes */ async onGetCountryItems(systemDev, cb) { - const response = await window.rpc.networkManager.getWirelessValidRegulatoryCountryCodes(systemDev) - cb(response ?? null) + try { + const response = await window.rpc.networkManager.getWirelessValidRegulatoryCountryCodes(systemDev) + cb(response ?? null) + } catch (ex) { + Util.handleException(ex) + } }, /** returns wireless channels */ async onGetWirelessChannels(systemDev, newValue, cb) { - const response = await window.rpc.networkManager.getWirelessChannels(systemDev, newValue) - cb(response ?? null) + try { + const response = await window.rpc.networkManager.getWirelessChannels(systemDev, newValue) + cb(response ?? null) + } catch (ex) { + Util.handleException(ex) + } }, /** returns wireless regulatory compliant */ async onWirelessRegulatoryCompliant(systemDev, cb) { - const response = await window.rpc.networkManager.isWirelessRegulatoryCompliant(systemDev) - cb(response ?? null) + try { + const response = await window.rpc.networkManager.isWirelessRegulatoryCompliant(systemDev) + cb(response ?? null) + } catch (ex) { + Util.handleException(ex) + } }, /** fetches and returns whether the given interface is the VRRP master */ async getVrrpMaster(interfaceId, cb) { - const response = await window.rpc.networkManager.isVrrpMaster(interfaceId) - cb(response ?? null) + try { + const response = await window.rpc.networkManager.isVrrpMaster(interfaceId) + cb(response ?? null) + } catch (ex) { + Util.handleException(ex) + } }, async onSave(newSettings, validate) { @@ -141,7 +165,7 @@ this.$store.commit('SET_LOADER', true) this.isSaving = true // Save interface settings by updating the current/new interface- newSettings - const resultIntf = await this.$store.dispatch('settings/setInterfaces', [newSettings]) + const resultIntf = await this.$store.dispatch('config/setInterfaces', [newSettings]) if (resultIntf?.success) { this.$vuntangle.toast.add(this.$t('network_settings_saved_successfully')) } else { diff --git a/untangle-vue-ui/source/src/components/settings/network/SettingsInterface/index.vue b/untangle-vue-ui/source/src/components/settings/network/SettingsInterface/index.vue index a4f5b27413..b34aad41a0 100644 --- a/untangle-vue-ui/source/src/components/settings/network/SettingsInterface/index.vue +++ b/untangle-vue-ui/source/src/components/settings/network/SettingsInterface/index.vue @@ -60,7 +60,7 @@ }, computed: { device: ({ $route }) => $route.params.device, - // interfaceStatuses: ({ $store }) => $store.getters['settings/interfaceStatuses'], + // interfaceStatuses: ({ $store }) => $store.getters['config/interfaceStatuses'], title() { if (!this.device && this.type === 'VLAN') { return this.$t('add_x_interface', [this.$t('vlan')]) diff --git a/untangle-vue-ui/source/src/components/settings/network/Troubleshooting.vue b/untangle-vue-ui/source/src/components/settings/network/Troubleshooting.vue index d25e37d2ef..267f3304e8 100644 --- a/untangle-vue-ui/source/src/components/settings/network/Troubleshooting.vue +++ b/untangle-vue-ui/source/src/components/settings/network/Troubleshooting.vue @@ -23,10 +23,10 @@ computed: { // uri settings from store - uriSettings: ({ $store }) => $store.getters['settings/uriSettings'], + uriSettings: ({ $store }) => $store.getters['config/uriSettings'], // network settings from the store - networkSettings: ({ $store }) => $store.getters['settings/networkSetting'], + networkSettings: ({ $store }) => $store.getters['config/networkSetting'], // dns test host from uri settings for Connectivity test dnsTestHost: ({ uriSettings }) => uriSettings?.dnsTestHost, @@ -46,8 +46,8 @@ }, created() { - this.$store.dispatch('settings/getUriSettings', false) - this.$store.dispatch('settings/getNetworkSettings', false) + this.$store.dispatch('config/getUriSettings', false) + this.$store.dispatch('config/getNetworkSettings', false) }, methods: { @@ -144,7 +144,7 @@ * Optional hook triggered on browser refresh. refetches the settings. */ onBrowserRefresh() { - this.$store.dispatch('settings/getNetworkSettings', true) + this.$store.dispatch('config/getNetworkSettings', true) }, }, } diff --git a/untangle-vue-ui/source/src/components/settings/network/interfaceMixin.js b/untangle-vue-ui/source/src/components/settings/network/interfaceMixin.js index fa65e35a40..4483ddd35b 100644 --- a/untangle-vue-ui/source/src/components/settings/network/interfaceMixin.js +++ b/untangle-vue-ui/source/src/components/settings/network/interfaceMixin.js @@ -16,7 +16,7 @@ export default { // Still added this check to prevent accidental deletion if (intf.type === 'NIC' || intf.type === 'WIFI') return - const interfacesCopy = cloneDeep(this.$store.getters['settings/interfaces']) + const interfacesCopy = cloneDeep(this.$store.getters['config/interfaces']) const affectedInterfaces = this.getAffectedBridgedInterfaces(intf, interfacesCopy) if (affectedInterfaces.length) { this.$vuntangle.dialog.show({ diff --git a/untangle-vue-ui/source/src/components/settings/routing/DynamicRoutes.vue b/untangle-vue-ui/source/src/components/settings/routing/DynamicRoutes.vue index 037da9b069..2a71ba15e8 100644 --- a/untangle-vue-ui/source/src/components/settings/routing/DynamicRoutes.vue +++ b/untangle-vue-ui/source/src/components/settings/routing/DynamicRoutes.vue @@ -34,8 +34,8 @@ }, computed: { - networkSettings: ({ $store }) => $store.getters['settings/networkSetting'], - dynamicRoutingSettings: ({ $store }) => $store.getters['settings/dynamicRoutingSettings'], + networkSettings: ({ $store }) => $store.getters['config/networkSetting'], + dynamicRoutingSettings: ({ $store }) => $store.getters['config/dynamicRoutingSettings'], }, created() { @@ -45,7 +45,7 @@ methods: { async fetchNetworkSettings(refetch) { - await this.$store.dispatch('settings/getNetworkSettings', refetch) + await this.$store.dispatch('config/getNetworkSettings', refetch) }, /** fetch Dynamic Routing Status */ @@ -238,7 +238,7 @@ try { const networkdynamicRoutingSettings = cloneDeep(this.networkSettings) networkdynamicRoutingSettings.dynamicRoutingSettings = updatedSettings - await this.$store.dispatch('settings/setNetworkSettingV2', networkdynamicRoutingSettings) + await this.$store.dispatch('config/setNetworkSettingV2', networkdynamicRoutingSettings) await this.getDynamicRoutingOverview() } catch (ex) { Util.handleException(ex) diff --git a/untangle-vue-ui/source/src/components/settings/routing/StaticRoutes.vue b/untangle-vue-ui/source/src/components/settings/routing/StaticRoutes.vue index f5a2e0e7a2..3d4d9d3239 100644 --- a/untangle-vue-ui/source/src/components/settings/routing/StaticRoutes.vue +++ b/untangle-vue-ui/source/src/components/settings/routing/StaticRoutes.vue @@ -36,11 +36,11 @@ computed: { // Routes is coming from the networkSettings from store - routes: ({ $store }) => $store.getters['settings/staticRoutes'], + routes: ({ $store }) => $store.getters['config/staticRoutes'], // Interfaces from store which is need to be shown in the Next Hop - interfaces: ({ $store }) => $store.getters['settings/interfaces'], + interfaces: ({ $store }) => $store.getters['config/interfaces'], // the network settings from the store - networkSettings: ({ $store }) => $store.getters['settings/networkSetting'], + networkSettings: ({ $store }) => $store.getters['config/networkSetting'], }, created() { @@ -72,7 +72,7 @@ * @param {boolean} refetch - Whether to force a re-fetch of the settings. */ async fetchSettings(refetch) { - await this.$store.dispatch('settings/getNetworkSettings', refetch) + await this.$store.dispatch('config/getNetworkSettings', refetch) }, onRefresh() { @@ -97,7 +97,7 @@ }) const networkSettingsCopy = cloneDeep(this.networkSettings) networkSettingsCopy.staticRoutes = routes - await Promise.all([this.$store.dispatch('settings/setNetworkSettingV2', networkSettingsCopy)]).finally(() => { + await Promise.all([this.$store.dispatch('config/setNetworkSettingV2', networkSettingsCopy)]).finally(() => { this.$store.commit('SET_LOADER', false) }) }, diff --git a/untangle-vue-ui/source/src/components/settings/rules/EventRulesList.vue b/untangle-vue-ui/source/src/components/settings/rules/EventRulesList.vue new file mode 100644 index 0000000000..e5516d5c99 --- /dev/null +++ b/untangle-vue-ui/source/src/components/settings/rules/EventRulesList.vue @@ -0,0 +1,289 @@ + + + + diff --git a/untangle-vue-ui/source/src/components/settings/rules/RulesList.vue b/untangle-vue-ui/source/src/components/settings/rules/RulesList.vue index ad7021415a..01aba2b048 100644 --- a/untangle-vue-ui/source/src/components/settings/rules/RulesList.vue +++ b/untangle-vue-ui/source/src/components/settings/rules/RulesList.vue @@ -109,10 +109,10 @@ }, // the network settings from the store - networkSettings: ({ $store }) => $store.getters['settings/networkSetting'], + networkSettings: ({ $store }) => $store.getters['config/networkSetting'], // the system settings from the store - systemSettings: ({ $store }) => $store.getters['settings/systemSetting'], + systemSettings: ({ $store }) => $store.getters['config/systemSetting'], // rule configuration names associated with a given rule type ruleConfigs: ({ rulesMap, ruleType }) => rulesMap[ruleType], @@ -198,10 +198,10 @@ this.ruleConfigs.map(async confName => { // For Network Settings Rules Refresh Network Settings if (this.networkRules.includes(confName)) { - await store.dispatch('settings/getNetworkSettings', refetch) + await store.dispatch('config/getNetworkSettings', refetch) } if (confName === 'port-forward-rules') { - await store.dispatch('settings/getSystemSettings', false) + await store.dispatch('config/getSystemSettings', false) } }), ).finally(() => { @@ -233,7 +233,7 @@ networkSettingsCopy[key.replace(/-/g, '_')] = rules }) - await store.dispatch('settings/setNetworkSettingV2', networkSettingsCopy) + await store.dispatch('config/setNetworkSettingV2', networkSettingsCopy) } }), ).finally(() => { @@ -370,8 +370,8 @@ const accessRules = updatedRules['access-rules'] const isBlockAllEnabled = util.getRuleEnabledStatus(accessRules, 'Block All') const isSshEnabled = util.getRuleEnabledStatus(accessRules, 'Allow SSH') - const prevSshEnabled = store.getters['settings/accessRuleSshEnabled'] - const prevAccessRules = store.getters['settings/networkSetting']?.access_rules || [] + const prevSshEnabled = store.getters['config/accessRuleSshEnabled'] + const prevAccessRules = store.getters['config/networkSetting']?.access_rules || [] // Case 1: Block All rule cannot be disabled if (!isBlockAllEnabled) { diff --git a/untangle-vue-ui/source/src/components/settings/services/BrandingManager.vue b/untangle-vue-ui/source/src/components/settings/services/BrandingManager.vue new file mode 100644 index 0000000000..055bfae997 --- /dev/null +++ b/untangle-vue-ui/source/src/components/settings/services/BrandingManager.vue @@ -0,0 +1,123 @@ + + + + {{ $t('not_licensed_service', [$t('branding_manager')]) }} + + {{ $t('view_system_license') }} + + {{ $t('manage_licenses') }} + mdi-open-in-new + + + + + + + + + + + {{ $vuntangle.$t('refresh') }} + {{ $vuntangle.$t('save') }} + + + + + + + + + + diff --git a/untangle-vue-ui/source/src/components/settings/services/ConfigurationBackup.vue b/untangle-vue-ui/source/src/components/settings/services/ConfigurationBackup.vue new file mode 100644 index 0000000000..bf76cedd8a --- /dev/null +++ b/untangle-vue-ui/source/src/components/settings/services/ConfigurationBackup.vue @@ -0,0 +1,116 @@ + + + + {{ $t('not_licensed_service', [$t('configuration_backup')]) }} + + {{ $t('view_system_license') }} + + {{ $t('manage_licenses') }} + mdi-open-in-new + + + + + + + + + + + + {{ $vuntangle.$t('refresh') }} + + + {{ $vuntangle.$t('save') }} + + + + + + + + + + + diff --git a/untangle-vue-ui/source/src/components/settings/services/DirectoryConnector.vue b/untangle-vue-ui/source/src/components/settings/services/DirectoryConnector.vue new file mode 100644 index 0000000000..0a64e1f479 --- /dev/null +++ b/untangle-vue-ui/source/src/components/settings/services/DirectoryConnector.vue @@ -0,0 +1,161 @@ + + + + {{ $t('not_licensed_service', [$t('directory_connector')]) }} + + {{ $t('view_system_license') }} + + {{ $t('manage_licenses') }} + mdi-open-in-new + + + + + + + + + + + + {{ $vuntangle.$t('refresh') }} + + + {{ $vuntangle.$t('save') }} + + + + + + + + + + + diff --git a/untangle-vue-ui/source/src/components/settings/services/DynamicBlockLists.vue b/untangle-vue-ui/source/src/components/settings/services/DynamicBlockLists.vue index b9cf877dce..bf900dc002 100644 --- a/untangle-vue-ui/source/src/components/settings/services/DynamicBlockLists.vue +++ b/untangle-vue-ui/source/src/components/settings/services/DynamicBlockLists.vue @@ -1,6 +1,6 @@ - - + + {{ $t('not_licensed_service', [$t('dynamic_blocklist')]) }} {{ $t('view_system_license') }} @@ -12,10 +12,12 @@ - - {{ $vuntangle.$t('reset_to_defaults') }} - - - {{ $vuntangle.$t('save') }} - + + + + + + + {{ $vuntangle.$t('reset_to_defaults') }} + + + {{ $vuntangle.$t('save') }} + + + + + diff --git a/untangle-vue-ui/source/src/components/settings/services/LiveSupport.vue b/untangle-vue-ui/source/src/components/settings/services/LiveSupport.vue new file mode 100644 index 0000000000..10e193a0f2 --- /dev/null +++ b/untangle-vue-ui/source/src/components/settings/services/LiveSupport.vue @@ -0,0 +1,137 @@ + + + + {{ $t('not_licensed_service', [$t('live_support')]) }} + + {{ $t('view_system_license') }} + + {{ $t('manage_licenses') }} + mdi-open-in-new + + + + + + + + + + + + + + + + + diff --git a/untangle-vue-ui/source/src/components/settings/services/NotificationUserapiPage.vue b/untangle-vue-ui/source/src/components/settings/services/NotificationUserapiPage.vue new file mode 100644 index 0000000000..e7503ce8be --- /dev/null +++ b/untangle-vue-ui/source/src/components/settings/services/NotificationUserapiPage.vue @@ -0,0 +1,27 @@ + + + + + + + diff --git a/untangle-vue-ui/source/src/components/settings/services/PolicyManager.vue b/untangle-vue-ui/source/src/components/settings/services/PolicyManager.vue new file mode 100644 index 0000000000..a79c0c797c --- /dev/null +++ b/untangle-vue-ui/source/src/components/settings/services/PolicyManager.vue @@ -0,0 +1,415 @@ + + + + {{ $t('not_licensed_service', [$t('policy_manager')]) }} + + {{ $t('view_system_license') }} + + {{ $t('manage_licenses') }} + mdi-open-in-new + + + + + + + + + + + + {{ $vuntangle.$t('refresh') }} + + {{ $t('save') }} + + + + + + + + + + diff --git a/untangle-vue-ui/source/src/components/settings/services/Reports.vue b/untangle-vue-ui/source/src/components/settings/services/Reports.vue new file mode 100644 index 0000000000..9afdeaa6d1 --- /dev/null +++ b/untangle-vue-ui/source/src/components/settings/services/Reports.vue @@ -0,0 +1,235 @@ + + + + + + + + + + {{ $t('refresh') }} + {{ $t('save') }} + + + + + + + + + + diff --git a/untangle-vue-ui/source/src/components/settings/services/WanBalancer.vue b/untangle-vue-ui/source/src/components/settings/services/WanBalancer.vue new file mode 100644 index 0000000000..602f00f427 --- /dev/null +++ b/untangle-vue-ui/source/src/components/settings/services/WanBalancer.vue @@ -0,0 +1,147 @@ + + + + {{ $t('not_licensed_service', [$t('wan_balancer')]) }} + + {{ $t('view_system_license') }} + + {{ $t('manage_licenses') }} + mdi-open-in-new + + + + + + + + + + + + {{ $vuntangle.$t('refresh') }} + + + {{ $vuntangle.$t('save') }} + + + + + + + + + + + diff --git a/untangle-vue-ui/source/src/components/settings/services/WanFailover.vue b/untangle-vue-ui/source/src/components/settings/services/WanFailover.vue new file mode 100644 index 0000000000..33a34da0fb --- /dev/null +++ b/untangle-vue-ui/source/src/components/settings/services/WanFailover.vue @@ -0,0 +1,144 @@ + + + + {{ $t('not_licensed_service', [$t('wan_failover')]) }} + + {{ $t('view_system_license') }} + + {{ $t('manage_licenses') }} + mdi-open-in-new + + + + + + + + + + + + {{ $vuntangle.$t('refresh') }} + + + {{ $vuntangle.$t('save') }} + + + + + + + + + + diff --git a/untangle-vue-ui/source/src/components/settings/services/ipCapabilities.js b/untangle-vue-ui/source/src/components/settings/services/ipCapabilities.js new file mode 100644 index 0000000000..56e5ced923 --- /dev/null +++ b/untangle-vue-ui/source/src/components/settings/services/ipCapabilities.js @@ -0,0 +1,17 @@ +import { ipDefaultCapabilities } from 'vuntangle' + +export const ngfwCapabilities = { + ...ipDefaultCapabilities, + main: { + ...ipDefaultCapabilities.main, + appIcon: { render: true }, + powerStatus: { render: true }, + enableToggles: { render: false }, + }, + rule: { + genericRule: { render: true }, + }, + signature: { + customSignature: { render: true }, + }, +} diff --git a/untangle-vue-ui/source/src/components/settings/services/serviceMixin.js b/untangle-vue-ui/source/src/components/settings/services/serviceMixin.js index 17cc59ce7a..0efcb90913 100644 --- a/untangle-vue-ui/source/src/components/settings/services/serviceMixin.js +++ b/untangle-vue-ui/source/src/components/settings/services/serviceMixin.js @@ -1,24 +1,246 @@ +import { mapGetters } from 'vuex' +import { getReportUrl, getReportIcon } from '@/util/reports' +import util from '@/util/setupUtil' import uris from '@/util/uris' +import { EVENT_ACTIONS } from '@/constants/actions' +import { sendEvent } from '@/utils/event' + export default { + inject: ['embedded'], + data() { return { isLicensed: undefined, manageLicenseUri: undefined, + appManager: null, + instanceId: null, + toggling: false, + // Controls whether app settings are loaded via getSettingsV2 RPC on mount. + // Set to false in components whose appManager does not support getSettingsV2 (e.g. LiveSupport). + hasAppSettings: true, + displayNameFallback: '', + } + }, + + provide() { + return { + isReportsInstalled: this.isReportsInstalled, } }, - created() { + + computed: { + ...mapGetters('apps', ['getServiceAppStatus']), + ...mapGetters('metrics', ['getFormattedMetrics']), + + formattedMetrics: ({ instanceId, getFormattedMetrics }) => (instanceId ? getFormattedMetrics(instanceId) : []), + + // Get the apps view for the selected policy from the store + appsViewByPolicy: ({ $store }) => $store.getters['apps/getAppsViewByPolicy'](1), + + // Determine if the app is currently installed based on the apps view for the selected policy + isInstalled() { + return this.appsViewByPolicy?.instances?.some(i => i.appName === this.serviceName) ?? false + }, + + settings: ({ $store, licenseNodeName }) => $store.getters['apps/getSettings'](licenseNodeName)?.settings || {}, + + /** + * Check if Reports app is installed + * @returns {Boolean} True if reports is installed, else false + * TODO Remove Util Method Call Once Reports App and its flows are Fully Migrated + */ + isReportsInstalled: ({ $store }) => $store.getters['reports/isReportsInstalled'] || !!util.isReportsInstalled(), + + /** + * Display name sourced from the app manager; falls back to displayNameFallback set by each component. + * @returns {string} + */ + appDisplayName: ({ appManager, displayNameFallback }) => + appManager?.getAppProperties?.()?.displayName || displayNameFallback, + + /** + * Bundles powerState, appDisplayName, and iconPath into the shape expected by u-app-status-state. + * @returns {{ powerState: Object, appDisplayName: string, iconPath: string|null }} + */ + consolidatedAppData: ({ powerState, appDisplayName, iconPath }) => ({ + powerState: powerState || {}, + appDisplayName, + iconPath, + }), + + powerState: ({ appManager, getServiceAppStatus, toggling }) => { + const vuexPowerState = getServiceAppStatus({ + appManager, + }) + + return { + ...vuexPowerState, + power: toggling, + } + }, + + /** + * Icon path for the app using webpack require + * @returns {string|null} Icon path or null if appName is not available + */ + iconPath: ({ serviceName }) => (serviceName ? require(`@/assets/icons/apps/${serviceName}.svg`) : null), + + /** + * Get reports for this app from global store + * Filters by appDisplayName and transforms to UI format + * @returns {Array} - Formatted reports array + */ + appReports: ({ $store, appDisplayName }) => { + if (!appDisplayName) return [] + + const rawReports = $store.getters['reports/getReportsByCategory'](appDisplayName) + return rawReports.map(report => ({ + key: report.uniqueId, + label: report.title || report.uniqueId, + url: getReportUrl(report.category, report.title), + icon: getReportIcon(report.type), + })) + }, + }, + + async created() { this.checkLicense() this.getManageLicenseUri() + await this.setAppManager() + const app = await this.$store.dispatch('apps/getApp', { appName: this.licenseNodeName }) + this.appManager = app || null + await this.loadInstanceId() + if (this.hasAppSettings) { + this.loadAppSettings() + } + if (!this.$store.getters['reports/isLoaded'] && !this.$store.getters['reports/loading']) { + this.$store.dispatch('reports/loadReports') + } }, + methods: { + /** + * Refresh app data - reload settings. + */ + refreshData() { + this.loadAppSettings() + }, + + /** + * Fetches the app instance ID from the app manager and stores it in the component's state. + * Used to link metrics polling to the correct app instance via formattedMetrics. + * Called on initial load and after reinstall to ensure instanceId stays current. + */ + async loadInstanceId() { + if (!this.appManager) return + try { + const appSettings = await this.appManager.getAppSettings() + this.instanceId = appSettings?.id || null + } catch (err) { + util.handleException(err) + } + }, + + /** + * Fetches the app manager instance for the licensed app and stores it in the component's state. + * This allows the component to interact with the app manager for operations like starting/stopping the app and fetching settings. + */ + async setAppManager() { + const app = await this.$store.dispatch('apps/getApp', { appName: this.licenseNodeName }) + this.appManager = app || null + }, + + /** + * Save app settings via Vuex dispatch + * @param {Object} newSettings - Settings object to save + */ + async saveSettings(newSettings) { + this.$store.commit('SET_LOADER', true) + try { + await this.$store.dispatch('apps/setAppSettings', { + appName: this.licenseNodeName, + settings: newSettings, + }) + } catch (error) { + util.handleException(error) + } finally { + this.$store.commit('SET_LOADER', false) + } + }, + + /** + * Load app settings from backend via RPC call to app manager and store in Vuex + */ + loadAppSettings() { + if (!this.hasAppSettings) return + this.$store.commit('SET_LOADER', true) + this.$store + .dispatch('apps/loadAppData', { + appName: this.licenseNodeName, + app: this.appManager, + }) + .finally(() => { + this.$store.commit('SET_LOADER', false) + }) + }, + + /** + * Toggle app state. Starts or stops the app based on the 'enabled' parameter. + * @param {boolean} enabled - Target state (true = starting, false = stopping) + */ + async toggleAppState(enabled) { + if (!this.appManager) return + + this.toggling = true + try { + const rpcMethod = enabled ? 'start' : 'stop' + + await new Promise((resolve, reject) => { + this.appManager[rpcMethod]((ex, res) => { + if (ex || res?.code) { + reject(ex || new Error(res?.message || `Failed to ${rpcMethod} app`)) + } else { + resolve(res) + } + }) + }) + // Re-fetch so getRunState() reflects the updated state + const app = await this.$store.dispatch('apps/getApp', { appName: this.licenseNodeName }) + this.appManager = app || this.appManager + if (this.embedded) { + // Send event to parent window to refresh app status. + // TODO Remove this once all apps are migrated to Vue UI and Parent Layout is changed + this.sendEventToParentWindow(EVENT_ACTIONS.REFRESH_APP_STATUS) + } + } finally { + this.toggling = false + } + }, + + /** + * Send event to parent window + * @param {String} type - Event type from EVENT_ACTIONS + */ + sendEventToParentWindow(type) { + const eventData = { + appName: this.licenseNodeName, + type, + } + + if (type === EVENT_ACTIONS.REFRESH_APP_STATUS) { + eventData.targetState = this.powerState.on ? 'RUNNING' : 'INITIALIZED' + } + + sendEvent(eventData) + }, + /** * PROP: is-licensed * Checks for TP license status */ async checkLicense() { if (!this.licenseNodeName) return - const response = await window.rpc.UvmContext.licenseManager().getLicense(this.licenseNodeName).valid - this.isLicensed = response || response + this.isLicensed = await this.$store.dispatch('apps/checkLicenseStatus', this.licenseNodeName) }, /** @@ -26,7 +248,64 @@ export default { * Fetches the URI for license management */ async getManageLicenseUri() { - this.manageLicenseUri = await window.rpc.uriManager.getUriWithPath(uris.list.subscriptions) + this.manageLicenseUri = await this.$store.dispatch('apps/fetchManageLicenseUri', uris.list.subscriptions) + }, + + /** + * Check if a specific app's license is valid + * @param {String} appName - The app name to check + * @returns {Boolean} true if license is valid, false otherwise + */ + isAppLicenseValid(appName) { + return this.$store.dispatch('apps/checkAppLicense', appName) + }, + /** + * Check if a daemon is running + * @param {String} daemonName - The daemon name to check + * @returns {Boolean} true if daemon is running, false otherwise + */ + isDaemonRunning(daemonName) { + return this.$store.dispatch('apps/checkDaemonStatus', daemonName) + }, + + /** Installs the apps */ + async onInstallService() { + this.$store.commit('SET_LOADER', true) + try { + await this.$store.dispatch('apps/installApp', { appName: this.serviceName }) + await this.setAppManager() + await this.loadInstanceId() + await this.checkLicense() + await this.loadAppSettings() + await this.$store.dispatch('reports/loadReports') + } catch (error) { + } finally { + this.$store.commit('SET_LOADER', false) + } + }, + + /** + * Handles the removal of the service app. It first sets a loader state to true, then finds the app instance + * corresponding to the serviceName in the current policy's apps view. It dispatches an action to destroy the app instance, + * and if the component is embedded, it sends an event to the parent indicating that the app has been removed. Finally, it sets the loader state back to false. + */ + async onRemoveService() { + this.$store.commit('SET_LOADER', true) + try { + const instance = this.appsViewByPolicy?.instances?.find(i => i.appName === this.serviceName) + await this.$store.dispatch('apps/destroyApp', { + instanceId: instance?.id, + policyId: 1, + }) + if (this.embedded) { + // If embedded, just send event to parent + // TODO Remove this once all apps are migrated to Vue UI and Parent Layout is changed + sendEvent({ type: EVENT_ACTIONS.REMOVE_APP, appName: this.serviceName }) + } + } catch (error) { + } finally { + this.$store.commit('SET_LOADER', false) + } }, }, } diff --git a/untangle-vue-ui/source/src/components/settings/system/About.vue b/untangle-vue-ui/source/src/components/settings/system/About.vue index 9a5e27b1a0..9116971603 100644 --- a/untangle-vue-ui/source/src/components/settings/system/About.vue +++ b/untangle-vue-ui/source/src/components/settings/system/About.vue @@ -51,10 +51,10 @@ }, }, - async created() { - await this.getServerData() - await this.loadLicenses() - await this.getLegalUrl() + created() { + this.getServerData() + this.loadLicenses() + this.getLegalUrl() }, // Component methods diff --git a/untangle-vue-ui/source/src/components/settings/system/Administration.vue b/untangle-vue-ui/source/src/components/settings/system/Administration.vue new file mode 100644 index 0000000000..35162bb994 --- /dev/null +++ b/untangle-vue-ui/source/src/components/settings/system/Administration.vue @@ -0,0 +1,585 @@ + + + + + {{ $vuntangle.$t('refresh') }} + + {{ $t('save') }} + + + + + diff --git a/untangle-vue-ui/source/src/components/settings/system/Email.vue b/untangle-vue-ui/source/src/components/settings/system/Email.vue new file mode 100644 index 0000000000..77940d5361 --- /dev/null +++ b/untangle-vue-ui/source/src/components/settings/system/Email.vue @@ -0,0 +1,346 @@ + + + + diff --git a/untangle-vue-ui/source/src/components/settings/system/Events.vue b/untangle-vue-ui/source/src/components/settings/system/Events.vue new file mode 100644 index 0000000000..0f3f2f4371 --- /dev/null +++ b/untangle-vue-ui/source/src/components/settings/system/Events.vue @@ -0,0 +1,310 @@ + + + + + {{ $t('refresh') }} + {{ $t('save') }} + + + + + + + {{ $vuntangle.$t('alerts') }} + + + {{ $vuntangle.$t('triggers') }} + + + {{ $vuntangle.$t('syslog') }} + + + {{ $vuntangle.$t('email_template') }} + + + + + + + + + + + + + + + + + + {{ $vuntangle.$t('rules') }} + + + + + + + + + + + + + + diff --git a/untangle-vue-ui/source/src/components/settings/system/LocalDirectory.vue b/untangle-vue-ui/source/src/components/settings/system/LocalDirectory.vue new file mode 100644 index 0000000000..1d6a32f0db --- /dev/null +++ b/untangle-vue-ui/source/src/components/settings/system/LocalDirectory.vue @@ -0,0 +1,203 @@ + + + + {{ $t('save') }} + + + + diff --git a/untangle-vue-ui/source/src/components/settings/system/Logging.vue b/untangle-vue-ui/source/src/components/settings/system/Logging.vue index 4d26cccd4d..e0da7a16cc 100644 --- a/untangle-vue-ui/source/src/components/settings/system/Logging.vue +++ b/untangle-vue-ui/source/src/components/settings/system/Logging.vue @@ -33,8 +33,8 @@ * Fetches the initial 'uvm' log when the component is created. */ async created() { - await this.$store.dispatch('settings/getLogsByName', { logName: 'uvm', refetch: true }) - this.logs = this.$store.getters['settings/getLogsByName']('uvm') + await this.$store.dispatch('config/getLogsByName', { logName: 'uvm', refetch: true }) + this.logs = this.$store.getters['config/getLogsByName']('uvm') }, methods: { @@ -45,8 +45,8 @@ */ async onLogsTabChanged(tabName) { try { - await this.$store.dispatch('settings/getLogsByName', { logName: tabName, refetch: false }) - this.logs = this.$store.getters['settings/getLogsByName'](tabName) + await this.$store.dispatch('config/getLogsByName', { logName: tabName, refetch: false }) + this.logs = this.$store.getters['config/getLogsByName'](tabName) return { success: true } } catch (ex) { util.handleException(ex) @@ -60,8 +60,8 @@ */ async onReload(tabName) { try { - await this.$store.dispatch('settings/getLogsByName', { logName: tabName, refetch: true }) - this.logs = this.$store.getters['settings/getLogsByName'](tabName) + await this.$store.dispatch('config/getLogsByName', { logName: tabName, refetch: true }) + this.logs = this.$store.getters['config/getLogsByName'](tabName) return { success: true } } catch (ex) { util.handleException(ex) diff --git a/untangle-vue-ui/source/src/components/settings/system/QuarantineDigestInbox.vue b/untangle-vue-ui/source/src/components/settings/system/QuarantineDigestInbox.vue new file mode 100644 index 0000000000..b47d99a149 --- /dev/null +++ b/untangle-vue-ui/source/src/components/settings/system/QuarantineDigestInbox.vue @@ -0,0 +1,348 @@ + + + + + + + + mdi-inbox-multiple + {{ $t('manage_user_quarantine') }} + + + mdi-refresh + {{ $t('refresh') }} + + + + + + + + + + mdi-magnify + + + + {{ $t('search') }} + + + + + + + + + {{ successMessage }} + + + + + {{ errorMessage }} + + + + + + + + + + mdi-inbox + + {{ currentUserEmail ? $t('no_quarantined_emails_for_user') : $t('enter_email_to_view_quarantine') }} + + + + + + + + + + + + {{ item.subject }} + + + + + + mdi-email-send + + + mdi-delete + + + + + + {{ $t('quarantine_for') }}: {{ currentUserEmail }} + + + mdi-email-send + {{ $t('release_selected') }} + + + mdi-delete + {{ $t('purge_selected') }} + + + + + + + + + + + + + + + diff --git a/untangle-vue-ui/source/src/components/settings/system/QuarantineDigestRequest.vue b/untangle-vue-ui/source/src/components/settings/system/QuarantineDigestRequest.vue new file mode 100644 index 0000000000..df0dd85796 --- /dev/null +++ b/untangle-vue-ui/source/src/components/settings/system/QuarantineDigestRequest.vue @@ -0,0 +1,80 @@ + + + + + + + diff --git a/untangle-vue-ui/source/src/components/settings/system/Settings.vue b/untangle-vue-ui/source/src/components/settings/system/Settings.vue index 5abe9b3f88..0cb6d75853 100644 --- a/untangle-vue-ui/source/src/components/settings/system/Settings.vue +++ b/untangle-vue-ui/source/src/components/settings/system/Settings.vue @@ -35,19 +35,22 @@ mixins: [settingsMixin], inject: ['embedded'], + data() { + return { + companyName: '', + } + }, + computed: { - systemSettings: ({ $store }) => $store.getters['settings/systemSetting'], - deviceTemperatureInfo: ({ $store }) => $store.getters['settings/deviceTemperatureInfo'], - httpSettings: ({ $store }) => $store.getters['apps/getSettings']('http'), - ftpSettings: ({ $store }) => $store.getters['apps/getSettings']('ftp'), - smtpSettings: ({ $store }) => $store.getters['apps/getSettings']('smtp'), - isExpertMode: ({ $store }) => $store.getters['settings/isExpertMode'], - systemTimeZones: ({ $store }) => $store.getters['settings/systemTimeZones'], - networkSetting: ({ $store }) => $store.getters['settings/networkSetting'], - enabledWanInterfaces: ({ $store }) => $store.getters['settings/enabledWanInterfaces'], - companyName() { - return window?.rpc?.companyName || null - }, + systemSettings: ({ $store }) => $store.getters['config/systemSetting'], + deviceTemperatureInfo: ({ $store }) => $store.getters['config/deviceTemperatureInfo'], + httpSettings: ({ $store }) => $store.getters['apps/getSettings']('http')?.settings || {}, + ftpSettings: ({ $store }) => $store.getters['apps/getSettings']('ftp')?.settings || {}, + smtpSettings: ({ $store }) => $store.getters['apps/getSettings']('smtp')?.settings || {}, + isExpertMode: ({ $store }) => $store.getters['config/isExpertMode'], + systemTimeZones: ({ $store }) => $store.getters['config/systemTimeZones'], + networkSetting: ({ $store }) => $store.getters['config/networkSetting'], + enabledWanInterfaces: ({ $store }) => $store.getters['config/enabledWanInterfaces'], }, watch: { @@ -62,19 +65,20 @@ }, async created() { + this.companyName = await this.$store.dispatch('apps/getCompanyName') await this.$store.dispatch('withLoader', { needsRaf: true, asyncFn: async () => { // update current system setting from store await Promise.all([ - this.$store.dispatch('settings/getSystemSettings', false), - this.$store.dispatch('settings/getDeviceTemperatureInfo'), - this.$store.dispatch('apps/getAndCommitAppSettings', 'http'), - this.$store.dispatch('apps/getAndCommitAppSettings', 'smtp'), - this.$store.dispatch('apps/getAndCommitAppSettings', 'ftp'), + this.$store.dispatch('config/getSystemSettings', false), + this.$store.dispatch('config/getDeviceTemperatureInfo'), + this.$store.dispatch('apps/loadAppData', { appName: 'http' }), + this.$store.dispatch('apps/loadAppData', { appName: 'smtp' }), + this.$store.dispatch('apps/loadAppData', { appName: 'ftp' }), // get list of all wan interfaces which is used to show in the hostname interface selection - this.$store.dispatch('settings/getEnabledInterfaces'), - this.$store.dispatch('settings/getSystemTimeZones'), + this.$store.dispatch('config/getEnabledInterfaces'), + this.$store.dispatch('config/getSystemTimeZones'), ]) }, }) @@ -87,7 +91,7 @@ */ async onSaveSettings({ system, cb }) { this.$store.commit('SET_LOADER', true) - const response = await this.$store.dispatch('settings/setSystemSettings', system) + const response = await this.$store.dispatch('config/setSystemSettings', system) await this.$store.dispatch('apps/setAppSettings', { appName: 'http', settings: system.httpSettings, @@ -109,7 +113,7 @@ async onForceTimeSync() { this.$store.commit('SET_LOADER', true) const response = await this.$store - .dispatch('settings/doForceTimeSync') + .dispatch('config/doForceTimeSync') .finally(() => this.$store.commit('SET_LOADER', false)) if (!response.success) { this.$vuntangle.toast.add(this.$vuntangle.$t('force_time_sync_failed'), 'error') @@ -136,28 +140,28 @@ await new Promise(resolve => setTimeout(resolve, 1000)) this.$store.commit('SET_LOADER', false) document.location.href = '/error/factoryDefaults' - await this.$store.dispatch('settings/factoryReset') + await this.$store.dispatch('config/factoryReset') }, /* * System reboot handler */ async onReboot() { - await this.$store.dispatch('settings/reboot') + await this.$store.dispatch('config/reboot') }, /* * Device temperature refresh handler */ async onRefreshDeviceTemperatureInfo() { - await this.$store.dispatch('settings/getDeviceTemperatureInfo') + await this.$store.dispatch('config/getDeviceTemperatureInfo') }, /* * System reboot handler */ async onShutdown() { - await this.$store.dispatch('settings/shutdown') + await this.$store.dispatch('config/shutdown') }, /* @@ -195,9 +199,9 @@ needsRaf: true, asyncFn: async () => { return await Promise.all([ - this.$store.dispatch('settings/getSystemSettings', true), - this.$store.dispatch('settings/getEnabledInterfaces'), - this.$store.dispatch('settings/getSystemTimeZones'), + this.$store.dispatch('config/getSystemSettings', true), + this.$store.dispatch('config/getEnabledInterfaces'), + this.$store.dispatch('config/getSystemTimeZones'), ]) }, }) @@ -211,7 +215,7 @@ * Fetches updated system settings and updates the store. */ onBrowserRefresh() { - this.$store.dispatch('settings/getSystemSettings', true) + this.$store.dispatch('config/getSystemSettings', true) }, }, } diff --git a/untangle-vue-ui/source/src/components/settings/system/Upgrade.vue b/untangle-vue-ui/source/src/components/settings/system/Upgrade.vue index f93ff902d9..d871e3557a 100644 --- a/untangle-vue-ui/source/src/components/settings/system/Upgrade.vue +++ b/untangle-vue-ui/source/src/components/settings/system/Upgrade.vue @@ -1,8 +1,12 @@ + - + + + + {{ $t('system_running_latest_version') }} @@ -64,12 +68,13 @@ diff --git a/untangle-vue-ui/source/src/layouts/UserApiLayout.vue b/untangle-vue-ui/source/src/layouts/UserApiLayout.vue new file mode 100644 index 0000000000..a8e7891d7e --- /dev/null +++ b/untangle-vue-ui/source/src/layouts/UserApiLayout.vue @@ -0,0 +1,75 @@ + + + + + + + + + + NGFW + + + + + {{ computedHeader }} + + + + + + + + + + + + + + + + + + + + diff --git a/untangle-vue-ui/source/src/layouts/index.js b/untangle-vue-ui/source/src/layouts/index.js index c48ea1491d..6a5f7a4c90 100644 --- a/untangle-vue-ui/source/src/layouts/index.js +++ b/untangle-vue-ui/source/src/layouts/index.js @@ -1,5 +1,7 @@ import DynamicLayout from './DynamicLayout.vue' import DefaultLayout from './DefaultLayout.vue' import SetupLayout from './SetupLayout.vue' +import QuarantineLayout from './QuarantineLayout.vue' +import UserApiLayout from './UserApiLayout.vue' -export { DynamicLayout, DefaultLayout, SetupLayout } +export { DynamicLayout, DefaultLayout, SetupLayout, QuarantineLayout, UserApiLayout } diff --git a/untangle-vue-ui/source/src/router/apps.js b/untangle-vue-ui/source/src/router/apps.js new file mode 100644 index 0000000000..da82913f5c --- /dev/null +++ b/untangle-vue-ui/source/src/router/apps.js @@ -0,0 +1,71 @@ +/** + * Apps Module Routes + * + * This module handles routing for the Apps management interface, + * supporting policy-based app management with dynamic app loading. + * + * Route Structure: + * - /apps/:policyId - Main apps view for a specific policy + * - /apps/:policyId/:appName - Individual app settings view + * + * Lifecycle Hooks: + * - beforeEnter: Starts metrics polling when entering /apps section + * - Polling stops automatically via global router guard when leaving /apps + */ + +import AppsLayout from '@/components/Apps/AppsLayout.vue' +import AppSettingsLayout from '@/components/Apps/AppSettingsLayout.vue' +import AppsView from '@/components/Apps/AppsView.vue' +import MetricsPollingService from '@/services/MetricsPollingService' + +export default [ + { + // Root path for all apps-related routes + path: '/apps', + component: AppsLayout, + meta: { + // Add metadata for route guards or analytics if needed + requiresAuth: true, + module: 'apps', + }, + + beforeEnter: (_to, _from, next) => { + MetricsPollingService.start() + next() + }, + + children: [ + { + // Main apps view - displays installed and installable apps for a policy + // Example: /apps/1 (shows apps for policy ID 1) + path: ':policyId', + name: 'apps', + component: AppsView, + meta: { + title: 'Apps', + description: 'Manage installed and installable apps', + }, + props: route => ({ + // Convert policyId to number for component prop + policyId: Number(route.params.policyId), + }), + }, + { + // Individual app settings view - dynamically loads app-specific components + // Example: /apps/1/web-filter (loads WebFilter component for policy 1) + path: ':policyId/:appName', + name: 'app-settings', + component: AppSettingsLayout, + meta: { + title: 'App Settings', + description: 'Configure app-specific settings', + }, + props: route => ({ + // Convert policyId to number and pass appName as string + policyId: Number(route.params.policyId), + appName: route.params.appName, + }), + }, + ], + }, +] diff --git a/untangle-vue-ui/source/src/router/index.js b/untangle-vue-ui/source/src/router/index.js index 5979134ac8..01ddec4a3f 100644 --- a/untangle-vue-ui/source/src/router/index.js +++ b/untangle-vue-ui/source/src/router/index.js @@ -4,7 +4,13 @@ import { UPageNotFound } from 'vuntangle' import auth from './auth' import setting from './setting' import wizard from './wizard' +import quarantine from './quarantine' +import userapi from './userapi' +import appRouter from './apps' +import reports from './reports' import Dashboard from '@/components/Dashboard/Main' +import store from '@/store' +import MetricsPollingService from '@/services/MetricsPollingService' /** * Override .push() to catch navigation failures. @@ -40,7 +46,7 @@ const baseRoutes = [ }, ] -const routes = baseRoutes.concat(auth, setting, wizard, { +const routes = baseRoutes.concat(auth, setting, wizard, quarantine, userapi, appRouter, reports, { path: '*', name: 'page-not-found', component: UPageNotFound, @@ -59,9 +65,20 @@ const router = new VueRouter({ * This ensures the `window.rpc` is only initialized once per session, * even when Vue is loaded inside an iframe that reloads on tab changes. * The client is reused across iframe loads to prevent redundant `getNonce` or `listMethods` calls. + * + * Note: Quarantine routes are public and should NOT initialize admin RPC + * to avoid authentication calls. They use /quarantine/JSON-RPC instead. */ router.beforeEach((to, from, next) => { try { + // Skip admin RPC initialization for public quarantine routes + // These routes use /quarantine/JSON-RPC endpoint without authentication + const isQuarantineRoute = to.path?.startsWith('/quarantine') + + if (isQuarantineRoute) { + return next() + } + const rpcOwner = window.top || window.parent // Reuse shared RPC client if available from parent @@ -81,6 +98,31 @@ router.beforeEach((to, from, next) => { window.rpc = rpcClient } + // Initialize admin context on first admin route navigation + // Session module is NOT persisted, so it resets on every page load/hard refresh + // This ensures RPC calls run on every session start + // But skips re-initialization during route navigation within same session + const nonAdminSegments = ['setup', 'login', 'wizard'] + const isAdminRoute = !nonAdminSegments.some( + segment => to.name?.includes(segment) || to.path?.startsWith(`/${segment}`), + ) + + if (isAdminRoute) { + // Initialize admin context (loads apps, policy-manager, reports) + // Fire and forget - don't block navigation + store.dispatch('session/initializeAdminContext') + } + + // Start/stop metrics polling based on whether the route requires it + // /apps routes always require metrics; other routes opt-in via meta.requiresMetrics + const fromRequiresMetrics = from?.path?.startsWith('/apps') || from?.meta?.requiresMetrics + const toRequiresMetrics = to?.path?.startsWith('/apps') || to?.meta?.requiresMetrics + if (toRequiresMetrics && !fromRequiresMetrics) { + MetricsPollingService.start() + } else if (fromRequiresMetrics && !toRequiresMetrics) { + MetricsPollingService.stop() + } + // Redirect logic if (!from?.name && to?.name?.includes('setup-') && to?.name !== 'setup-wizard') { return next({ name: 'wizard' }) diff --git a/untangle-vue-ui/source/src/router/quarantine.js b/untangle-vue-ui/source/src/router/quarantine.js new file mode 100644 index 0000000000..42c9352bc2 --- /dev/null +++ b/untangle-vue-ui/source/src/router/quarantine.js @@ -0,0 +1,24 @@ +import QuarantineLayout from '@/layouts/QuarantineLayout.vue' +import QuarantineDigestRequest from '@/components/settings/system/QuarantineDigestRequest.vue' +import QuarantineDigestInbox from '@/components/settings/system/QuarantineDigestInbox.vue' + +export default [ + { + name: 'quarantine', + path: '/quarantine', + component: QuarantineDigestRequest, + meta: { + layout: QuarantineLayout, + helpContext: 'quarantine', + }, + }, + { + name: 'quarantine-manageuser', + path: '/quarantine/manageuser', + component: QuarantineDigestInbox, + meta: { + layout: QuarantineLayout, + helpContext: 'quarantine', + }, + }, +] diff --git a/untangle-vue-ui/source/src/router/reports.js b/untangle-vue-ui/source/src/router/reports.js new file mode 100644 index 0000000000..d94d340057 --- /dev/null +++ b/untangle-vue-ui/source/src/router/reports.js @@ -0,0 +1,17 @@ +import Reports from '@/components/reports/Reports.vue' +import ReportDetails from '@/components/reports/ReportDetails.vue' + +export default [ + { + name: 'reports', + path: '/reports', + component: Reports, + meta: { helpContext: 'reports' }, + }, + { + name: 'report-details', + path: '/reports/:cat/:rep', + component: ReportDetails, + meta: { helpContext: 'report-details' }, + }, +] diff --git a/untangle-vue-ui/source/src/router/setting.js b/untangle-vue-ui/source/src/router/setting.js index 578ca932ae..5ea03b4b33 100644 --- a/untangle-vue-ui/source/src/router/setting.js +++ b/untangle-vue-ui/source/src/router/setting.js @@ -16,16 +16,27 @@ import StaticRoutes from '@/components/settings/routing/StaticRoutes.vue' // services import DynamicBlockLists from '@/components/settings/services/DynamicBlockLists.vue' // system \ +import Email from '@/components/settings/system/Email' import About from '@/components/settings/system/About' import Settings from '@/components/settings/system/Settings.vue' import Upgrade from '@/components/settings/system/Upgrade' +import LocalDirectory from '@/components/settings/system/LocalDirectory.vue' import RulesList from '@/components/settings/rules/RulesList.vue' import Troubleshooting from '@/components/settings/network/Troubleshooting.vue' import DenialOfService from '@/components/settings/firewall/DenialOfService.vue' - -// Administration -import Admin from '@/components/settings/administration/Admin.vue' import Logging from '@/components/settings/system/Logging.vue' +import Administration from '@/components/settings/system/Administration.vue' +import Events from '@/components/settings/system/Events.vue' +import PolicyManager from '@/components/settings/services/PolicyManager.vue' +import BrandingManager from '@/components/settings/services/BrandingManager.vue' +import ConfigurationBackup from '@/components/settings/services/ConfigurationBackup.vue' +import DirectoryConnector from '@/components/settings/services/DirectoryConnector.vue' +import WanFailover from '@/components/settings/services/WanFailover.vue' +import WanBalancer from '@/components/settings/services/WanBalancer.vue' +import Reports from '@/components/settings/services/Reports.vue' +import LiveSupport from '@/components/settings/services/LiveSupport.vue' +import IntrusionPrevesion from '@/components/settings/services/IntrusionPrevesion.vue' + export default [ { name: 'settings', @@ -91,11 +102,61 @@ export default [ path: 'firewall/denial-of-service', component: DenialOfService, }, + { + path: 'services/branding-manager', + component: BrandingManager, + meta: { helpContext: 'branding_manager' }, + }, + { + path: 'services/configuration-backup', + component: ConfigurationBackup, + meta: { helpContext: 'configuration_backup' }, + }, + { + path: 'services/directory-connector', + component: DirectoryConnector, + meta: { helpContext: 'directory_connector' }, + }, + { + path: 'services/wan-failover', + component: WanFailover, + meta: { helpContext: 'wan_failover', requiresMetrics: true }, + }, + { + path: 'services/wan-balancer', + component: WanBalancer, + meta: { helpContext: 'wan_balancer', requiresMetrics: true }, + }, { path: 'services/dynamic-blocklist', component: DynamicBlockLists, meta: { helpContext: 'dynamic_lists' }, }, + { + path: 'services/live-support', + component: LiveSupport, + meta: { helpContext: 'live-support' }, + }, + { + path: 'services/intrusion-prevention', + component: IntrusionPrevesion, + meta: { helpContext: 'intrusion_prevention', requiresMetrics: true }, + }, + { + path: 'services/policy-manager', + component: PolicyManager, + meta: { helpContext: 'policy_manager' }, + }, + { + path: 'services/reports', + component: Reports, + meta: { helpContext: 'reports' }, + }, + { + path: 'system/email', + component: Email, + meta: { helpContext: 'system_settings' }, + }, { path: 'system/about', component: About, @@ -106,15 +167,25 @@ export default [ component: Logging, meta: { helpContext: 'system_settings' }, }, + { + path: 'system/local-directory', + component: LocalDirectory, + }, { path: 'system/settings', component: Settings, meta: { helpContext: 'system_settings' }, }, + { - path: 'administration/admin', - component: Admin, - meta: { helpContext: 'administration' }, + path: 'system/administration', + component: Administration, + meta: { helpContext: 'system_settings' }, + }, + { + path: 'system/events', + component: Events, + meta: { helpContext: 'system_settings' }, }, { path: 'system/upgrade', diff --git a/untangle-vue-ui/source/src/router/userapi.js b/untangle-vue-ui/source/src/router/userapi.js new file mode 100644 index 0000000000..af1e0c9713 --- /dev/null +++ b/untangle-vue-ui/source/src/router/userapi.js @@ -0,0 +1,14 @@ +import UserApiLayout from '@/layouts/UserApiLayout.vue' +import NotificationUserapiPage from '@/components/settings/services/NotificationUserapiPage.vue' + +export default [ + { + name: 'userapi', + path: '/userapi', + component: NotificationUserapiPage, + meta: { + layout: UserApiLayout, + helpContext: 'userapi', + }, + }, +] diff --git a/untangle-vue-ui/source/src/scss/common.scss b/untangle-vue-ui/source/src/scss/common.scss index 008128bb16..251452f62b 100644 --- a/untangle-vue-ui/source/src/scss/common.scss +++ b/untangle-vue-ui/source/src/scss/common.scss @@ -3,18 +3,34 @@ // import roboto fonts @import 'vuntangle/roboto-fonts.scss'; +// ============================================ +// Global Layout & Theme Styles +// ============================================ + .ut-app-bar, .ut-app-menu { background-color: $arista-blue !important; } -// vertically center chips on v-autocomplete +.drawer-content-menu { + box-shadow: none; +} + +// ============================================ +// Vuetify Component Overrides +// ============================================ + +// Vertically center chips on v-autocomplete .v-select--chips--small { .v-select__selections { padding-top: 9px !important; } } +// ============================================ +// Global Loaders +// ============================================ + .arista-loader { height: 32px; width: 32px; @@ -26,6 +42,219 @@ color: $aristaMediumBlue; } -.drawer-content-menu { - box-shadow: none; +// ============================================ +// App Management - Shared SCSS Mixins +// ============================================ + +// Mixin: Spinning loader overlay +// Used in: AppCard, InstallableApps +@mixin app-loader($size, $spinner-size) { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: block; + width: $size; + height: $size; + background-color: $app-loader-bg; + border-radius: 4px; + + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: $spinner-size; + height: $spinner-size; + margin-top: -($spinner-size / 2); + margin-left: -($spinner-size / 2); + border: 3px solid white; + border-top-color: $arista-blue; + border-radius: 50%; + animation: spin 1s linear infinite; + } +} + +// Mixin: Flex grid container +// Used in: InstalledApps, InstallableApps +@mixin app-grid-container($gap: $app-card-gap) { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: $gap; +} + +// Mixin: Centered flex container +// Used in: PowerButton, icon wrappers +@mixin flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +// Mixin: Absolute centered overlay +// Used in: Action icons, loaders +@mixin absolute-center { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +// Mixin: Text truncation with ellipsis +// Used in: App names, descriptions +@mixin text-truncate { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +// ============================================ +// App Management - Utility Classes +// ============================================ + +// Flex grid for app cards +.app-grid { + @include app-grid-container; +} + +// Flex column layout +.app-column { + display: flex; + flex-direction: column; +} + +// Centered content +.app-center { + @include flex-center; +} + +// Text truncation +.app-text-truncate { + @include text-truncate; + max-width: 100%; +} + +// ============================================ +// App Management - Power Button States +// ============================================ + +.power-button-wrapper { + position: absolute; + top: 0; + right: 0; + z-index: 2; +} + +.power-button { + position: relative; + @include flex-center; + width: $app-power-button-size; + height: $app-power-button-size; + border-radius: 50%; + transform: translate(25%, -25%); + transition: background-color 0.3s ease; + background-color: $power-state-off; // Default + + &--on { + background-color: $power-state-on; + } + + &--off { + background-color: $power-state-off; + } + + &--powering { + background-color: $power-state-powering; + } + + &--inconsistent { + background-color: $power-state-inconsistent; + } + + &__icon { + color: #fff !important; + font-size: $app-power-icon-size !important; + } +} + +// ============================================ +// App Management - Shared Loader Styles +// ============================================ + +// Standard app loader (90px icon) +.app-loader { + @include app-loader($app-icon-size, $app-loader-spinner-size); +} + +// Small app loader (80px icon) +.app-loader--small { + @include app-loader($app-icon-size-small, $app-loader-spinner-size); +} + +// ============================================ +// App Management - Card States +// ============================================ + +.app-card-state { + &--installing { + opacity: 0.8; + pointer-events: none; + } + + &--finished { + background-color: $app-success-bg; + } + + &--disabled { + pointer-events: none; + cursor: default; + } + + &--hover { + background-color: $app-card-bg-hover; + } +} + +// ============================================ +// App Management - Icon Wrappers +// ============================================ + +.app-icon-wrapper { + position: relative; + display: inline-block; +} + +.app-icon { + transition: opacity 0.2s ease, filter 0.2s ease; + + &--grayscale { + filter: grayscale(1); + } + + &--dimmed { + opacity: 0.2; + } + + &--blurred { + filter: blur(2px); + } + + &--hover { + opacity: 0.6; + } + + &--installing { + opacity: 0.1; + } +} + +// ============================================ +// Animations +// ============================================ + +@keyframes spin { + to { + transform: rotate(360deg); + } } diff --git a/untangle-vue-ui/source/src/scss/variables.scss b/untangle-vue-ui/source/src/scss/variables.scss index 3ba3d70dc3..0deb514527 100644 --- a/untangle-vue-ui/source/src/scss/variables.scss +++ b/untangle-vue-ui/source/src/scss/variables.scss @@ -1,6 +1,40 @@ @import 'vuntangle/variables.scss'; +// ============================================ +// Vuetify Overrides +// ============================================ $list-dense-min-height: 36px !default; $list-dense-subheader-height: 36px !default; +// ============================================ +// Brand Colors +// ============================================ $arista-blue: #16325B; + +// ============================================ +// App Power State Colors +// ============================================ +$power-state-on: #69be4d; // Green - running +$power-state-off: #858585; // Gray - stopped +$power-state-powering: #ff9800; // Orange - transitioning +$power-state-inconsistent: #ff6347; // Tomato Red - error state + +// ============================================ +// App UI Colors +// ============================================ +$app-card-bg-hover: #f5f5f5; // Light gray for hover +$app-license-color: #f44336; // Red for license warnings +$app-success-bg: rgba(76, 175, 80, 0.1); // Light green for success states +$app-loader-bg: rgba(0, 0, 0, 0.1); // Semi-transparent black for loader overlay + +// ============================================ +// App Component Sizes +// ============================================ +$app-card-width: 150px; +$app-card-height: 170px; +$app-card-gap: 0.875rem; // 14px +$app-icon-size: 90px; +$app-icon-size-small: 80px; +$app-loader-spinner-size: 30px; +$app-power-button-size: 20px; +$app-power-icon-size: 14px; diff --git a/untangle-vue-ui/source/src/services/MetricsPollingService.js b/untangle-vue-ui/source/src/services/MetricsPollingService.js new file mode 100644 index 0000000000..02b2d9ebc5 --- /dev/null +++ b/untangle-vue-ui/source/src/services/MetricsPollingService.js @@ -0,0 +1,160 @@ +/** + * MetricsPollingService - Singleton + * + * Centralized polling manager for all app metrics + * + * Lifecycle: + * - Start: Called on router beforeEnter to /apps + * - Stop: Called on router before leave from /apps + * - Cleanup: Automatic via router guards + * + * Behavior: + * - Single RPC call for ALL apps (not per-app) + * - Frequency: 10 seconds + * - First call: Immediate (before interval starts) + * - Stops polling on error (session timeout, connection lost, etc.) + * - Cancels in-flight requests on stop + */ + +import Rpc from '@/util/Rpc' +import Util from '@/util/setupUtil' +import store from '@/store' + +class MetricsPollingService { + constructor() { + // Singleton pattern - return existing instance if it exists + if (MetricsPollingService.instance) { + return MetricsPollingService.instance + } + + // Configuration + this.frequency = 10000 // 10 seconds + this.intervalId = null + this.isRunning = false + this.abortController = null + + // Store singleton instance + MetricsPollingService.instance = this + } + + /** + * Start polling + * + * Behavior: + * 1. Stops any existing polling + * 2. Clears existing data + * 3. Makes immediate first call + * 4. Sets up 10-second interval + * 5. Updates Vuex polling state + */ + start() { + this.stop() + + store.dispatch('metrics/clearMetrics') + + this.run() + + this.intervalId = setInterval(() => { + this.run() + }, this.frequency) + + this.isRunning = true + store.dispatch('metrics/setPollingState', true) + } + + /** + * Stop polling + * + * Behavior: + * 1. Clears interval + * 2. Cancels any in-flight requests + * 3. Updates Vuex polling state + */ + stop() { + if (this.intervalId) { + clearInterval(this.intervalId) + this.intervalId = null + } + + if (this.abortController) { + this.abortController.abort() + this.abortController = null + } + + this.isRunning = false + store.dispatch('metrics/setPollingState', false) + } + + /** + * Execute metrics fetch + * + * Makes RPC call to backend and updates Vuex store + * Errors are handled gracefully via Util.handleException + */ + async run() { + try { + // Create abort controller for request cancellation + this.abortController = new AbortController() + + // Make RPC call to backend + const result = await Rpc.asyncData('rpc.metricManager.getMetricsAndStatsV2') + if (!result) { + return + } + + // Extract data from response + const appMetrics = result.metrics || {} + const systemStats = result.systemStats || {} + + // Update Vuex store (triggers component reactivity) + store.dispatch('metrics/updateMetrics', { + appMetrics, + systemStats, + }) + } catch (err) { + // Check if error is due to abort (not a real error) + if (err.name === 'AbortError') { + return + } + Util.handleException(err) + this.stop() + } finally { + this.abortController = null + } + } + + /** + * Get current polling status + * + * @returns {Object} Status object + */ + getStatus() { + return { + isRunning: this.isRunning, + frequency: this.frequency, + hasInterval: this.intervalId !== null, + } + } + + /** + * Update polling frequency (advanced usage) + * + * CAUTION: Default is 10s - changing may affect performance + * Restarts polling if currently running + * + * @param {number} newFrequency - New frequency in milliseconds + */ + setFrequency(newFrequency) { + if (newFrequency < 1000) { + return + } + this.frequency = newFrequency + if (this.isRunning) { + this.start() + } + } +} + +// Export singleton instance +const instance = new MetricsPollingService() +export default instance diff --git a/untangle-vue-ui/source/src/services/reportsService.js b/untangle-vue-ui/source/src/services/reportsService.js new file mode 100644 index 0000000000..fda212cb50 --- /dev/null +++ b/untangle-vue-ui/source/src/services/reportsService.js @@ -0,0 +1,93 @@ +/** + * Reports Service + * + * Provides abstraction layer for accessing reports data from components. + * + * This service wraps Vuex store access and provides formatted data + * ready for UI consumption. + */ + +import store from '@/store' +import { formatReportsForUI, getReportUrl, getReportIcon } from '@/util/reports' + +export default { + /** + * Get reports for a specific app category + * @param {String} category - App display name (e.g., 'Application Control Lite') + * @returns {Array} - Formatted reports array ready for UI + */ + getReportsByCategory(category) { + const rawReports = store.getters['reports/getReportsByCategory'](category) + return formatReportsForUI(rawReports) + }, + + /** + * Check if Reports app is installed + * @returns {Boolean} + */ + isReportsInstalled() { + return store.getters['reports/isReportsInstalled'] + }, + + /** + * Get all reports + * @returns {Array} - All formatted reports + */ + getAllReports() { + const rawReports = store.getters['reports/allReports'] + return formatReportsForUI(rawReports) + }, + + /** + * Refresh reports from backend + * @returns {Promise} - Result object with success status + */ + refreshReports() { + return store.dispatch('reports/loadReports') + }, + + /** + * Get report URL + * Direct access to utility function for convenience + * @param {String} category - Report category + * @param {String} title - Report title + * @returns {String} - Report URL + */ + getReportUrl(category, title) { + return getReportUrl(category, title) + }, + + /** + * Get report icon + * Direct access to utility function for convenience + * @param {String} type - Report type (TEXT, PIE_GRAPH, etc.) + * @returns {String} - MDI icon name + */ + getReportIcon(type) { + return getReportIcon(type) + }, + + /** + * Check if reports are loaded + * @returns {Boolean} + */ + isLoaded() { + return store.getters['reports/isLoaded'] + }, + + /** + * Get loading state + * @returns {Boolean} + */ + isLoading() { + return store.getters['reports/loading'] + }, + + /** + * Get error state + * @returns {String|null} + */ + getError() { + return store.getters['reports/error'] + }, +} diff --git a/untangle-vue-ui/source/src/store/apps.js b/untangle-vue-ui/source/src/store/apps.js index 870909132e..788d9c93d7 100644 --- a/untangle-vue-ui/source/src/store/apps.js +++ b/untangle-vue-ui/source/src/store/apps.js @@ -1,35 +1,410 @@ import { set } from 'vue' import Util from '@/util/setupUtil' +import Rpc from '@/util/Rpc' +/** + * Constants for app installation status + * @readonly + * @enum {string} + */ +export const APP_INSTALL_STATUS = Object.freeze({ + PROGRESS: 'progress', // App is currently being installed + FINISH: 'finish', // App installation completed successfully + ERROR: 'error', // App installation failed +}) + +/** + * Why do we need a Bootstrap Registry? + * + * The Bootstrap Registry defines API calls that are automatically invoked + * when a screen loads or when a refresh operation is triggered, without + * any specific user interaction. + * + * For example: + * - The `smtp` app requires multiple API calls (safelists, inbox data, etc.) + * to fully initialize its UI when the screen loads. + * These bootstrap calls happen automatically to prepare the view. + * + * - Other apps (e.g. dynamic-blocklist) only need basic settings + * and do not require additional bootstrap entries. + * + * Important: + * - This registry contains ONLY auto-load/refresh APIs + * - APIs triggered by specific user actions (button clicks, form submissions) + * should NOT be added here + * + * Benefits: + * - Centralizes initial data loading logic per app + * - Keeps API logic organized and extensible + * - Allows different apps to have different bootstrap requirements + * - Clear separation between auto-load and user-triggered operations + */ +const APP_BOOTSTRAP_REGISTRY = { + smtp: [ + { + key: 'globalSafeList', + call: app => app.getSafelistAdminView().getSafelistContents('GLOBAL'), + }, + { + key: 'userSafeList', + call: app => app.getSafelistAdminView().getUserSafelistCountsV2(), + }, + { + key: 'inboxSummary', + call: app => app.getQuarantineMaintenenceView().listInboxesV2(), + }, + ], +} + +/** + * Registry of per-service fetchers for condition runtime data. + * Each entry receives the Vuex action context and commits its results via SET_CONDITION_DATA. + * Add a new entry here when another service needs to supply condition dropdown values. + */ +const CONDITION_DATA_FETCHERS = { + 'directory-connector': async ({ commit, dispatch }) => { + const app = await dispatch('getApp', { appName: 'directory-connector' }) + if (!app) { + // Clear any stale data left from a previous installation so static fallback options are shown + commit('SET_CONDITION_DATA', { directoryGroups: [], directoryDomains: [], directoryUsers: [] }) + return + } + + const [groupResult, domainResult, userResult] = await Promise.all([ + new Promise(resolve => app.getRuleConditionalGroupEntriesV2((r, ex) => resolve(ex ? null : r))), + new Promise(resolve => app.getRuleConditionalDomainEntriesV2((r, ex) => resolve(ex ? null : r))), + new Promise(resolve => app.getRuleConditionalUserEntriesV2((r, ex) => resolve(ex ? null : r))), + ]) + + commit('SET_CONDITION_DATA', { + directoryGroups: (groupResult || []).map(g => ({ + text: `${g.CN} [${g.SAMAccountName}]`, + value: g.SAMAccountName, + })), + directoryDomains: (domainResult || []).map(d => ({ text: d, value: d })), + directoryUsers: (userResult || []).map(u => ({ + text: [u.firstName, u.lastName].filter(Boolean).join(' ') || u.uid, + value: u.uid, + })), + }) + }, +} + +/** + * Apps store + */ const getDefaultState = () => ({ - settings: {}, // all app settings stored by appName + store: {}, // all app settings stored by appName + appsViews: null, // list of per-policy apps view states (kept for backward compatibility) + appsViewByPolicy: {}, // normalized apps view by policyId for O(1) lookup + installingApps: {}, // tracks apps currently being installed { appName: { policyId, status } } + selectedPolicyId: null, // currently selected policy ID + autoInstallApps: false, // tracks if recommended apps are being auto-installed on initial setup + conditionData: {}, // cross-app runtime data for condition dropdowns (groups, domains, etc.) }) +/** + * Getters + */ const getters = { /** * Get settings for a given app name. * Usage: getters.getSettings('http') */ - getSettings: state => appName => state.settings[appName] || null, + getSettings: state => appName => state.store[appName] || null, + /** + * Get all app views (list of per-policy app view states) + * Usage: getters['apps/appsViews'] + */ + appsViews: state => state.appsViews || [], + /** + * Get all app views normalized by policyId (O(1) lookup) + * Usage: getters['apps/appsViewByPolicy'] + */ + appsViewByPolicy: state => state.appsViewByPolicy || {}, + /** + * Get app view for a specific policy (O(1) lookup) + * Usage: getters['apps/getAppsViewByPolicy'](policyId) + */ + getAppsViewByPolicy: state => policyId => state.appsViewByPolicy[policyId] || null, + /** + * Get installing apps + * Usage: getters['apps/installingApps'] + */ + installingApps: state => state.installingApps || {}, + /** + * Check if a specific app is installing + * Usage: getters['apps/isAppInstalling'](appName) + */ + isAppInstalling: state => appName => { + const installing = state.installingApps[appName] + return installing ? installing.status === 'progress' : false + }, + /** + * Get selected policy ID + * Usage: getters['apps/selectedPolicyId'] + */ + selectedPolicyId: state => state.selectedPolicyId, + /** + * Check if auto install is currently running + * Usage: getters['apps/autoInstallApps'] + */ + autoInstallApps: state => state.autoInstallApps, + /** + * Get runtime condition data fetched from external services (directory-connector, etc.) + * Usage: getters['apps/conditionData'] + */ + conditionData: state => state.conditionData, + /** + * Get app data for a specific policy and app combination + * Returns an object containing: policyId, appName, license, instance, and appProperties + * Usage: getters['apps/getAppData']({ policyId: 1, appName: 'web-filter' }) + */ + getAppData: + state => + ({ policyId, appName }) => { + if (!policyId || !appName) return null + + // Get the app view for this policy + const appsView = state.appsViewByPolicy[policyId] + if (!appsView) return null + + // Extract license for this app + const license = appsView.licenseMap?.[appName] || null + + // Find instance for this app (matching policyId and appName) + const instance = appsView.instances?.find(inst => inst.appName === appName && inst.policyId === policyId) || null + + // Find appProperties for this app + const appProperties = appsView.appProperties?.find(prop => prop.name === appName) || null + + // Get appMetrics if instance exists + const appMetrics = instance?.id ? appsView.appMetrics?.[instance.id] || [] : [] + + return { + policyId, + appName, + license, + instance, + appProperties, + appMetrics, + } + }, + /** + * Get computed power state for an app + * Computes: enabled, running, inconsistent, expired, statusText, colorState + * Usage: getters['apps/getAppPowerState']({ policyId: 1, appName: 'web-filter', appRunState, appTargetState }) + */ + getAppPowerState: + (state, getters) => + ({ policyId, appName, appManager }) => { + const appData = getters.getAppData({ policyId, appName }) + + if (!appData || !appData.instance) { + return { + on: false, + inconsistent: false, + } + } + + const { appProperties, instance } = appData + const appsView = state.appsViewByPolicy[policyId] + + const appRunState = appManager ? appManager.getRunState() : null + const runState = appRunState || appsView?.runStates?.[instance.id] + const on = runState === 'RUNNING' + + const daemonRunning = + on && appProperties?.daemon + ? Rpc.directData('rpc.UvmContext.daemonManager.isRunning', appProperties.daemon) + : true + const inconsistent = on && !daemonRunning + + return { + on, + inconsistent, + } + }, + + /** + * Get service app status - detects on/off state, inconsistency + * @param {Object} appManager - The app manager object (has getRunState, getAppProperties methods) + * @returns {Object} { on: boolean, inconsistent: boolean } + */ + getServiceAppStatus: + () => + ({ appManager }) => { + let on = false + let inconsistent = false + + if (appManager) { + const runState = appManager.getRunState() + + // Determine if app is running + on = runState === 'RUNNING' + + // Get app properties to check for daemon + let daemon = null + try { + const appProperties = appManager.getAppProperties ? appManager.getAppProperties() : null + daemon = appProperties?.daemon + } catch (error) { + Util.handleException(error) + } + + // Check daemon status if app is running and has a daemon + const daemonRunning = + on && daemon != null ? Rpc.directData('rpc.UvmContext.daemonManager.isRunning', daemon) : true + + inconsistent = on && !daemonRunning + } + + return { + on, + inconsistent, + } + }, } const mutations = { /** * Dynamically set settings for an app - * Usage: commit('SET_SETTINGS', { appName: 'http', value: data }) + * Usage: commit('SET_SETTINGS', { appKey: 'http', value: data }) + */ + SET_SETTINGS(state, { appKey, value }) { + if (!state.store) { + set(state, 'store', {}) + } + set(state.store, appKey, value) + }, + /** + * Set the list of app views for all policies + * Usage: commit('SET_APPS_VIEWS', appsViews) + */ + SET_APPS_VIEWS: (state, appsViews) => { + set(state, 'appsViews', appsViews) + // Normalize appsViews by policyId for O(1) lookup + const normalized = (appsViews || []).reduce((acc, view) => { + acc[view.policyId] = view + return acc + }, {}) + set(state, 'appsViewByPolicy', normalized) + }, + /** + * Set the app view for a specific policy + * Usage: commit('SET_APPS_VIEW', { policyId, appsView }) */ - SET_SETTINGS(state, { appName, value }) { - if (!state.settings) { - set(state, 'settings', {}) + SET_APPS_VIEW: (state, { policyId, appsView }) => { + // Update array (backward compatibility) + if (!state.appsViews) { + set(state, 'appsViews', []) } - set(state.settings, appName, value) + const index = state.appsViews.findIndex(av => String(av.policyId) === policyId) + if (index >= 0) { + state.appsViews.splice(index, 1, appsView) + } else { + state.appsViews.push(appsView) + } + // Update normalized object (O(1) lookup) + if (!state.appsViewByPolicy) { + set(state, 'appsViewByPolicy', {}) + } + set(state.appsViewByPolicy, policyId, appsView) + }, + /** + * Set app installation status + * Usage: commit('SET_APP_INSTALL_STATUS', { appName: 'web-filter', policyId: 1, status: 'progress' }) + * Status can be: 'progress', 'finish', or null (to clear) + */ + SET_APP_INSTALL_STATUS(state, { appName, policyId, status }) { + if (!state.installingApps) { + set(state, 'installingApps', {}) + } + if (status === null) { + // Clear the installation status + const newInstallingApps = { ...state.installingApps } + delete newInstallingApps[appName] + set(state, 'installingApps', newInstallingApps) + } else { + set(state.installingApps, appName, { policyId, status }) + } + }, + /** + * Set selected policy ID + * Usage: commit('SET_SELECTED_POLICY_ID', policyId) + */ + SET_SELECTED_POLICY_ID(state, policyId) { + set(state, 'selectedPolicyId', policyId) + }, + /** + * Set auto install apps flag + * Usage: commit('SET_AUTO_INSTALL_APPS', true/false) + */ + SET_AUTO_INSTALL_APPS(state, value) { + set(state, 'autoInstallApps', value) + }, + /** + * Merge new condition data into the conditionData state slot. + * Usage: commit('SET_CONDITION_DATA', { directoryGroups: [...], directoryDomains: [...] }) + */ + SET_CONDITION_DATA(state, data) { + set(state, 'conditionData', { ...state.conditionData, ...data }) }, } +/** + * Actions + */ const actions = { - getApp(_, appName) { + async getApp(_, { appId, appName }) { + try { + let app = null + if (appId) { + app = await window.rpc.appManager.app(appId) + } else if (appName) { + app = await window.rpc.appManager.app(appName) + } + return app + } catch (err) { + Util.handleException(err) + return null + } + }, + + /** + * Get app instance for a specific policy + * This is critical for start/stop operations - we need the policy-specific instance + * @param {*} _ + * @param {Object} payload - { appName, policyId } + * @returns {Promise} App instance or null + */ + async getAppForPolicy(_, { appName, policyId }) { try { - const app = window.rpc.appManager.app(appName) + const apps = await window.rpc.appManager.appInstances(appName, policyId, false) + + // Handle both array response and { list: [...] } response + if (Array.isArray(apps)) { + return apps.length > 0 ? apps[0] : null + } else if (apps && apps.list && Array.isArray(apps.list)) { + return apps.list.length > 0 ? apps.list[0] : null + } + + return null + } catch (err) { + Util.handleException(err) + return null + } + }, + + /** + * Get app instance by instance ID + * @param {*} _ + * @param {Object} payload - { appId } + * @returns {Promise} App instance or null + */ + async getAppById(_, { appId }) { + try { + const app = await window.rpc.appManager.app(appId) return app } catch (err) { Util.handleException(err) @@ -37,57 +412,430 @@ const actions = { } }, - async getAppSettings({ dispatch }, appName) { - const app = await dispatch('getApp', appName) + /** + * Get settings for a given app using getSettingsV2(). + * @param {string} appName - The name of the app (e.g., 'smtp', 'http') + * @param {number} appId - Optional app instance ID (used for policy-specific apps) + * @param {Object|null} app - Optional app object. If not provided, it will be fetched via getApp + * @returns {Promise} The app settings object or null if app is unavailable + * + * Usage: + * - With app object: dispatch('getAppSettings', { appName: 'smtp', appId: 1, app: appObject }) + * - Without app object: dispatch('getAppSettings', { appName: 'smtp', appId: 1 }) + */ + async getAppSettings({ dispatch }, { appName, appId, app }) { + if (!app) { + app = await dispatch('getApp', { appName, appId }) + } if (app) { return await app.getSettingsV2() } return null }, + /** + * Load initial/bootstrap data for an app. + * This includes the app's canonical settings plus any bootstrap APIs defined in APP_BOOTSTRAP_REGISTRY. + * Typically called when a screen loads or when refresh is triggered. + * + * @param {Object} payload - { appName, appId, app } + * @param {string} payload.appName - The name of the app (e.g., 'smtp') + * @param {number} payload.appId - Optional app instance ID + * @param {Object} payload.app - Optional app manager instance + * @returns {Promise} Object containing settings and all bootstrap data + */ + async loadAppData({ commit, dispatch }, { appName, appId, app }) { + const registry = APP_BOOTSTRAP_REGISTRY[appName] + if (!app) { + app = await dispatch('getApp', { appName, appId }) + } + if (!app) return - /* setAppSettings will update system regarding configurations */ - async setAppSettings({ dispatch }, { appName, settings }) { - if (!settings) { - return + // ALWAYS load canonical backend settings + const baseSettings = (await dispatch('getAppSettings', { appName, appId, app })) || {} + + const result = { settings: baseSettings } + + // Optionally argument with bootstrap registry data + if (registry) { + for (const item of registry) { + try { + const value = await item.call(app) + result[item.key] = value + } catch (err) { + result[item.key] = null + } + } + } + + const key = appId ? `${appName}-${appId}` : appName + commit('SET_SETTINGS', { appKey: key, value: result }) + return result + }, + + /** + * Fetch the company URL. + * If branding-manager settings are already available in the Vuex store, + * return the companyUrl from those settings. + * Otherwise, fall back to fetching the value from the backend via RPC. + */ + async getCompanyUrl({ state }) { + try { + const brandingData = state.store['branding-manager'] + if (brandingData) { + return brandingData.settings?.companyUrl + } + return await window.rpc.companyUrl + } catch (err) { + Util.handleException(err) + } + }, + + /** + * Fetch the company name. + * If branding-manager settings are already available in the Vuex store, + * return the companyName from those settings. + * Otherwise, fall back to fetching the value from the backend via RPC. + */ + async getCompanyName({ state }) { + try { + const brandingData = state.store['branding-manager'] + if (brandingData) { + return brandingData.settings?.companyName + } + return await window.rpc.companyName + } catch (err) { + Util.handleException(err) + } + }, + + async setAppSettings({ dispatch }, { appName, settings, appId, app }) { + if (!settings) return + if (!app) { + app = await dispatch('getApp', { appName, appId }) } + if (!app) return + + return new Promise((resolve, reject) => { + app.setSettingsV2(async (ex, res) => { + if (ex || res?.code) { + Util.handleException(ex || res.message) + const error = new Error(ex?.message || res?.message || 'Failed to set app settings') + error.details = { success: false } + return reject(error) + } + + await dispatch('loadAppData', { appName, appId, app }) + resolve({ success: true }) + }, settings) + }) + }, + + /** + * Gets the app views for all policies. + * @param {*} param0 commit + * @returns Promise that resolves to the list of app views + */ + getAppsViews({ state, commit }, refetch) { try { - const app = await dispatch('getApp', appName) - if (!app) { + if (state.appsViews && !refetch) { return } - const result = await new Promise(resolve => { - app.setSettingsV2(async (ex, res) => { - if (ex) return resolve({ success: false, message: ex?.toString()?.slice(0, 100) || 'Unknown error' }) - if (res?.code && res?.message) return resolve({ success: false, message: res.message.slice(0, 100) }) + const data = window.rpc.appManager.getAppsViewsV2() + commit('SET_APPS_VIEWS', data || []) + } catch (err) { + Util.handleException(err) + } + }, + + /** + * Gets the app views for policy with given Id. + * Updates the appsViews state with the fetched data. + * @param {*} param0 commit + * @param {*} policyId + */ + getAppsView({ commit }, policyId) { + try { + const data = window.rpc.appManager.getAppsViewV2(policyId) + commit('SET_APPS_VIEW', { policyId, appsView: data || [] }) + } catch (err) { + Util.handleException(err) + } + }, - // Fetch updated settings after successful save - await dispatch('getAndCommitAppSettings', appName) + /** + * Install an app for a specific policy + * @param {Object} context - Vuex context + * @param {Object} payload - { appName, policyId } + * @returns {Promise} - { success: boolean, instance?: Object, error?: string } + */ + async installApp({ commit, dispatch }, { appName, policyId }) { + try { + // Set installing status to 'progress' + commit('SET_APP_INSTALL_STATUS', { appName, policyId, status: 'progress' }) - return resolve({ success: true }) - }, settings) - }) + // Call RPC to instantiate app + const instance = await Rpc.asyncData('rpc.appManager.instantiate', appName, policyId) + + // Refresh app views to get updated data + await dispatch('getAppsViews', true) + // Refresh reports to load any new reports related to the installed app + dispatch('reports/loadReports', null, { root: true }) + // Set installing status to 'finish' + commit('SET_APP_INSTALL_STATUS', { appName, policyId, status: 'finish' }) + + return { success: true, instance } + } catch (error) { + // Clear installing status on error + commit('SET_APP_INSTALL_STATUS', { appName, policyId, status: null }) + Util.handleException(error) + return { success: false, error: error.message || 'Installation failed' } + } + }, + + /** + * Check if auto install is currently running + * Fetches the isAutoInstallAppsFlag from backend and updates state + * @param {Object} context - Vuex context + * @returns {boolean} - true if auto install is running, false otherwise + */ + checkAutoInstallFlag({ commit }) { + try { + const isAutoInstalling = window.rpc.appManager.isAutoInstallAppsFlag() + commit('SET_AUTO_INSTALL_APPS', isAutoInstalling) + return isAutoInstalling + } catch (err) { + Util.handleException(err) + commit('SET_AUTO_INSTALL_APPS', false) + return false + } + }, + + /** + * Make a registry API call for a specific bootstrap API by key. + * Looks up the registry for the given app and API key, then makes the corresponding API call. + * Patches the result into the store, preserving existing data for other keys. + * + * @param {Object} context - Vuex context + * @param {Object} payload - { appName, apiKey } + * @param {string} payload.appName - The name of the app (e.g., 'smtp') + * @param {string} payload.apiKey - The key from APP_BOOTSTRAP_REGISTRY (e.g., 'globalSafeList') + * @returns {Promise} - The result of the API call, or null if registry entry not found + * + * Usage: + * dispatch('makeRegistryCall', { appName: 'smtp', apiKey: 'globalSafeList' }) + */ + async makeRegistryCall({ state, commit, dispatch }, { appName, apiKey }) { + const registry = APP_BOOTSTRAP_REGISTRY[appName] + if (!registry) { + return null + } + + const registryEntry = registry.find(item => item.key === apiKey) + if (!registryEntry) { + return null + } + + const app = await dispatch('getApp', { appName }) + if (!app) return null + + try { + const result = await registryEntry.call(app) + + // Get existing settings for this app + const existingSettings = state.store[appName] || {} - // Handle RPC-level errors (optional logging) - if (!result.success) { - Util.handleException(result.message) + // Merge the new result with existing settings + const updatedSettings = { + ...existingSettings, + [apiKey]: result, } + // Commit the merged settings to the store + commit('SET_SETTINGS', { appKey: appName, value: updatedSettings }) + return result } catch (err) { Util.handleException(err) - return { success: false, message: err?.toString()?.slice(0, 100) || 'Unknown error' } + return null + } + }, + + /** + * Destroy/remove an app instance + * @param {Object} context - Vuex context + * @param {number} instanceId - The app instance ID + * @returns {Promise} + */ + async destroyApp({ dispatch }, { instanceId, policyId }) { + try { + await Rpc.asyncData('rpc.appManager.destroy', instanceId) + + // Refresh the app view after destroying, same as ExtJS getAppsView + await dispatch('getAppsView', policyId) + // Refresh reports to clear out any app-specific reports after removal + dispatch('reports/loadReports', null, { root: true }) + } catch (error) { + Util.handleException(error) + throw error + } + }, + + /** + * Start an app instance + * @param {Object} context - Vuex context + * @param {Object} payload - { instanceId, policyId } + * @returns {Promise} + */ + async startApp({ dispatch }, { instanceId, policyId }) { + try { + const appManager = await window.rpc.appManager.app(instanceId) + + await new Promise((resolve, reject) => { + appManager.start((err, res) => { + if (err) { + reject(err) + } else { + resolve(res) + } + }) + }) + + // Refresh the app view after starting, same as ExtJS getAppsView + await dispatch('getAppsView', policyId) + } catch (error) { + Util.handleException(error) + throw error + } + }, + + /** + * Stop an app instance + * @param {Object} context - Vuex context + * @param {Object} payload - { instanceId, policyId } + * @returns {Promise} + */ + async stopApp({ dispatch }, { instanceId, policyId }) { + try { + const appManager = await window.rpc.appManager.app(instanceId) + + await new Promise((resolve, reject) => { + appManager.stop((err, res) => { + if (err) { + reject(err) + } else { + resolve(res) + } + }) + }) + + // Refresh the app view after stopping, same as ExtJS getAppsView + await dispatch('getAppsView', policyId) + } catch (error) { + Util.handleException(error) + throw error + } + }, + + /** + * Check if a specific app's license is valid + * @param {Object} context - Vuex context + * @param {String} appName - The app name to check + * @returns {Boolean} true if license is valid, false otherwise + */ + checkAppLicense(_, appName) { + try { + const isValid = window.rpc.UvmContext.licenseManager().isLicenseValid(appName) + return isValid + } catch (error) { + Util.handleException(error) + return false } }, - async getAndCommitAppSettings({ commit, dispatch }, appName) { + /** + * Check if a daemon is running + * @param {Object} context - Vuex context + * @param {String} daemonName - The daemon name to check + * @returns {Boolean} true if daemon is running, false otherwise + */ + checkDaemonStatus(_, daemonName) { try { - const data = await dispatch('getAppSettings', appName) - commit('SET_SETTINGS', { appName, value: data }) - return { success: true, data } + const isRunning = window.rpc.UvmContext.daemonManager().isRunning(daemonName) + return isRunning + } catch (error) { + Util.handleException(error) + return false + } + }, + + /** + * Run a fixed report for the given template and date range. + * @param {Object} payload - { templateId, startDate, stopDate } + * @returns {Promise<{ success: boolean }>} + */ + async runFixedReport(_, { templateId, startDate, stopDate }) { + try { + const appManager = await Rpc.asyncData('rpc.appManager.app', 'reports') + await Rpc.asyncData(appManager, 'runFixedReport', templateId, startDate, stopDate) + return { success: true } } catch (err) { Util.handleException(err) + return { success: false } + } + }, + + /** + * Check license status for a specific license node + * @param {Object} context - Vuex context + * @param {String} licenseNodeName - The license node name to check + * @returns {Promise} true if license is valid, false otherwise + */ + async checkLicenseStatus(_, licenseNodeName) { + try { + const response = await window.rpc.UvmContext.licenseManager().getLicense(licenseNodeName).valid + return response || false + } catch (error) { + Util.handleException(error) + return false } }, + + /** + * Fetch the URI for license management + * @param {Object} context - Vuex context + * @param {String} uriPath - The URI path to fetch (e.g., 'subscriptions') + * @returns {Promise} The manage license URI or null on error + */ + async fetchManageLicenseUri(_, uriPath) { + try { + const uri = await window.rpc.uriManager.getUriWithPath(uriPath) + return uri + } catch (error) { + Util.handleException(error) + return null + } + }, + + /** + * Fetch runtime condition data from one or more services and store in conditionData. + * Callers pass the service name(s) they need; only the matching registry entries are invoked. + * @param {Object} context - Vuex context + * @param {string|string[]} appNames - Service name(s) to fetch data from (e.g. 'directory-connector') + */ + async fetchConditionData(context, appNames) { + const names = Array.isArray(appNames) ? appNames : [appNames] + await Promise.all( + names.map(async name => { + const fetcher = CONDITION_DATA_FETCHERS[name] + if (!fetcher) return + try { + await fetcher(context) + } catch (e) { + // Silently degrade — static fallback options remain in the UI + } + }), + ) + }, } export default { diff --git a/untangle-vue-ui/source/src/store/auth.js b/untangle-vue-ui/source/src/store/auth.js index 778eb2f8d8..417514e8d7 100644 --- a/untangle-vue-ui/source/src/store/auth.js +++ b/untangle-vue-ui/source/src/store/auth.js @@ -18,7 +18,7 @@ const actions = { * @param {Function} dispatch */ async checkAuth({ rootState, commit, dispatch }) { - await dispatch('settings/getSettings', true, { root: true }) + await dispatch('config/getSettings', true, { root: true }) commit('SET_IS_AUTH', !!rootState.settings.settings) if (rootState.settings.settings) { @@ -61,10 +61,15 @@ const actions = { * Logout the user. * * @param {Function} commit + * @param {Function} dispatch */ - async logout({ commit }) { - await api.get('/account/logout') + async logout({ commit, dispatch }) { + await api.get('/auth/logout?url=/admin&realm=Administrator') commit('SET_IS_AUTH', false) + + // Reset session and reports state so admin context re-initializes on next login + await dispatch('session/reset', null, { root: true }) + commit('reports/RESET', null, { root: true }) }, } diff --git a/untangle-vue-ui/source/src/store/config.js b/untangle-vue-ui/source/src/store/config.js new file mode 100644 index 0000000000..04a26476a5 --- /dev/null +++ b/untangle-vue-ui/source/src/store/config.js @@ -0,0 +1,935 @@ +import { set } from 'vue' +import { cloneDeep } from 'lodash' +import Util from '@/util/setupUtil' +import util from '@/util/util' +import Rpc from '@/util/Rpc' + +const getDefaultState = () => ({ + editCallback: null, + mailSender: {}, + networkSetting: null, + systemSetting: null, + eventSettings: null, + templateParameters: null, + defaultEmailSettings: null, + classFieldsData: null, + deviceTemperatureInfo: '', + radiusLogsInfo: null, + enabledWanInterfaces: [], + uriSettings: null, + systemTimeZones: [], + shieldSettings: null, + languageSettings: null, + accessRuleSshEnabled: false, + adminSetting: null, + googleSettings: null, + isGoogleDriveConnected: false, + googleDriveRootPath: '', + systemLogs: {}, // system logs stored by logName + certificatesInformation: null, + rootCertificates: null, + serverCertificateVerification: null, + serverCertificates: null, + serverClockOffsetMs: 0, + publicUrl: null, +}) + +const getters = { + publicUrl: state => state.publicUrl, + mailSender: state => state.mailSender || {}, + languageSettings: state => state.languageSettings, + networkSetting: state => state.networkSetting || [], + interfaces: state => state?.networkSetting?.interfaces || [], + interface: state => device => { + return state.networkSetting?.interfaces?.find(intf => intf.device === device) + }, + interfaceNameMap: state => { + const map = {} + ;(state.networkSetting?.interfaces || []).forEach(intf => { + if (intf.interfaceId != null) { + map[intf.interfaceId] = intf.name ? `${intf.name} [${intf.interfaceId}]` : String(intf.interfaceId) + } + }) + return map + }, + systemSetting: state => state.systemSetting || {}, + eventSettings: state => state.eventSettings || {}, + templateParameters: state => state.templateParameters || [], + defaultEmailSettings: state => state.defaultEmailSettings || {}, + classFieldsData: state => state.classFieldsData || {}, + deviceTemperatureInfo: state => state.deviceTemperatureInfo || '', + users: state => state.users || [], + timeZoneOffset: state => state.timeZoneOffset || 0, + serverClockOffsetMs: state => state.serverClockOffsetMs || 0, + radiusLogsInfo: state => state.radiusLogsInfo || '', + systemTimeZones: state => state.systemTimeZones || [], + enabledWanInterfaces: state => state.enabledWanInterfaces || [], + staticRoutes: state => state?.networkSetting?.staticRoutes || [], + dnsSettings: state => state?.networkSetting?.dnsSettings || {}, + uriSettings: state => state?.uriSettings || {}, + /** + * Check expert mode is enabled via RPC. + * Avoids using Rpc.directData method by getting the value directly from window.rpc. + */ + isExpertMode: () => window?.rpc?.isExpertMode || false, + dynamicRoutingSettings: state => state?.networkSetting?.dynamicRoutingSettings || {}, + shieldSettings: state => state?.shieldSettings || {}, + accessRuleSshEnabled: state => state.accessRuleSshEnabled, + adminSetting: state => state.adminSetting || {}, + googleSettings: state => state.googleSettings || {}, + isGoogleDriveConnected: state => state.isGoogleDriveConnected || false, + googleDriveRootPath: state => state.googleDriveRootPath || '', + /** + * Get logs for a given log. + * Usage: getters.getLogs('uvm') + */ + getLogsByName: state => logName => state.systemLogs[logName] || null, + + /* Retrieves the certificates information from the state. */ + certificatesInformation: state => state.certificatesInformation || {}, + /* Retrieves the list of root certificates from the state. */ + rootCertificates: state => state.rootCertificates || [], + /* Retrieves the server certificate verification from the state. */ + serverCertificateVerification: state => state.serverCertificateVerification || [], + /* Retrieves the server certificates from the state. */ + serverCertificates: state => state.serverCertificates || [], +} + +const mutations = { + setEditCallback(state, cb) { + state.editCallback = cb + }, + SET_PUBLIC_URL: (state, value) => set(state, 'publicUrl', value), + SET_MAIL_SENDER: (state, value) => set(state, 'mailSender', value), + SET_INTERFACES: (state, value) => set(state.networkSetting, 'interfaces', value), + SET_NETWORK_SETTINGS: (state, value) => set(state, 'networkSetting', value), + SET_SYSTEM_SETTINGS: (state, value) => set(state, 'systemSetting', value), + SET_EVENT_SETTINGS: (state, value) => set(state, 'eventSettings', value), + SET_TEMPLATE_PARAMETERS: (state, value) => set(state, 'templateParameters', value), + SET_DEFAULT_EMAIL_SETTINGS: (state, value) => set(state, 'defaultEmailSettings', value), + SET_CLASS_FIELDS_DATA: (state, value) => set(state, 'classFieldsData', value), + SET_USERS: (state, value) => set(state, 'users', value), + SET_TIME_ZONE_OFF_SET: (state, value) => set(state, 'timeZoneOffset', value), + SET_SERVER_CLOCK_OFFSET_MS: (state, value) => set(state, 'serverClockOffsetMs', value), + SET_DEVICE_TEMP_INFO: (state, value) => set(state, 'deviceTemperatureInfo', value), + SET_RADIUS_LOGS: (state, value) => set(state, 'radiusLogsInfo', value), + SET_SYSTEM_TIMEZONES: (state, value) => set(state, 'systemTimeZones', value), + SET_ENABLED_WAN_INTERFACES: (state, value) => set(state, 'enabledWanInterfaces', value), + SET_URI_SETTINGS: (state, value) => set(state, 'uriSettings', value), + SET_SHIELD_SETTINGS: (state, value) => set(state, 'shieldSettings', value), + SET_LANGUAGE_SETTINGS: (state, value) => set(state, 'languageSettings', value), + SET_ACCESS_RULE_SSH_ENABLED: (state, value) => set(state, 'accessRuleSshEnabled', value), + SET_ADMIN_SETTINGS: (state, value) => set(state, 'adminSetting', value), + SET_GOOGLE_SETTINGS: (state, value) => set(state, 'googleSettings', value), + SET_IS_GOOGLE_DRIVE_CONNECTED: (state, value) => set(state, 'isGoogleDriveConnected', value), + SET_GOOGLE_DRIVE_ROOT_PATH: (state, value) => set(state, 'googleDriveRootPath', value), + /** + * Dynamically set logs for an app + * Usage: commit('SET_LOGS', { logName: 'uvm', value: data }) + */ + SET_LOGS(state, { logName, value }) { + if (!state.systemLogs) { + set(state, 'systemLogs', {}) + } + set(state.systemLogs, logName, value) + }, + SET_CERTIFICATES_INFORMATION: (state, value) => set(state, 'certificatesInformation', value), + /* Set Root Certificates */ + SET_ROOT_CERTIFICATES: (state, value) => set(state, 'rootCertificates', value), + SET_SERVER_CERTIFICATE_VERIFICATION: (state, value) => set(state, 'serverCertificateVerification', value), + SET_SERVER_CERTIFICATES: (state, value) => set(state, 'serverCertificates', value), +} + +const actions = { + /** + * Fetches the Google Drive root directory from the stored Google settings. + * If the Google settings are not already available in the Vuex state, + * it dispatches `getGoogleSettings` to retrieve them from the backend. + */ + async getRootDirectory({ dispatch, state }) { + try { + if (!state.googleSettings) { + await dispatch('getGoogleSettings', true) + } + const rootDirectory = state.googleSettings?.googleDriveRootDirectory + return rootDirectory + } catch (err) { + Util.handleException(err) + } + }, + + async getPublicUrl({ commit }, refetch) { + try { + if (!refetch) { + return + } + const data = await window.rpc.UvmContext.networkManager().getPublicUrl() + commit('SET_PUBLIC_URL', data) + } catch (err) { + Util.handleException(err) + } + }, + + async getMailSender({ state, commit }, refetch) { + try { + if (state.mailSender && !refetch) { + return + } + const data = await window.rpc.UvmContext.mailSender().getSettingsV2() + commit('SET_MAIL_SENDER', data) + } catch (err) { + Util.handleException(err) + } + }, + + async setMailSender({ dispatch }, emailServerSettings) { + try { + const apiMethod = Rpc.asyncPromise('rpc.UvmContext.mailSender().setSettingsV2', emailServerSettings) + const result = await apiMethod() + if (result?.code && result?.message) { + Util.handleException(result.message) + return { success: false, message: result.message.slice(0, 100) } + } + // fetch updated settings after successful save + await dispatch('getMailSender', true) + return { success: true } + } catch (err) { + Util.handleException(err) + return { success: false, message: err?.toString()?.slice(0, 100) || 'Unknown error' } + } + }, + + async getLanguageSettings({ commit }) { + if (!window.rpc || !window.rpc.languageManager) { + return + } + try { + const data = await window.rpc.languageManager.getLanguageSettings() + commit('SET_LANGUAGE_SETTINGS', data) + } catch (err) { + Util.handleException(err) + } + }, + + async getInterfaces({ commit }) { + try { + const data = await window.rpc.networkManager.getNetworkSettingsV2().interfaces + commit('SET_INTERFACES', await data) + } catch (err) { + Util.handleException(err) + } + }, + + async getNetworkSettings({ state, commit, dispatch }, refetch) { + try { + if (state.networkSetting && !refetch) { + return + } + const data = await window.rpc.networkManager.getNetworkSettingsV2() + commit('SET_NETWORK_SETTINGS', data) + dispatch('setAccessRuleSshEnabled') + } catch (err) { + Util.handleException(err) + } + }, + + /* get system settings configuration */ + getSystemSettings({ state, commit }, refetch) { + try { + if (state.systemSetting && !refetch) { + return + } + const data = window.rpc.systemManager.getSystemSettingsV2() + commit('SET_SYSTEM_SETTINGS', data) + return { success: true, message: null, data } // success + } catch (err) { + Util.handleException(err) + } + }, + + /* get event settings configuration */ + getEventSettings({ state, commit }, refetch) { + try { + if (state.eventSettings && !refetch) { + return + } + const data = window.rpc.eventManager.getSettingsV2() + commit('SET_EVENT_SETTINGS', data) + return { success: true, message: null, data } // success + } catch (err) { + Util.handleException(err) + } + }, + + /* Get the class fields condition data for event rules */ + getClassFieldsData({ commit }) { + try { + const data = window.rpc.eventManager.getClassFields() + commit('SET_CLASS_FIELDS_DATA', data) + return { success: true, message: null, data } // success + } catch (err) { + Util.handleException(err) + } + }, + + /* get template parameters */ + getTemplateParameters({ state, commit }, refetch) { + try { + if (state.templateParameters && !refetch) { + return + } + const data = window.rpc.eventManager.getTemplateParameters() + commit('SET_TEMPLATE_PARAMETERS', data) + return { success: true, message: null, data } // success + } catch (err) { + Util.handleException(err) + } + }, + + /* get default email settings */ + getDefaultEmailSettings({ state, commit }, refetch) { + try { + if (state.defaultEmailSettings && !refetch) { + return + } + const data = window.rpc.eventManager.defaultEmailSettingsV2() + commit('SET_DEFAULT_EMAIL_SETTINGS', data) + return { success: true, message: null, data } // success + } catch (err) { + Util.handleException(err) + } + }, + + /** get timeZoneOffset */ + getTimeZoneOffSet({ commit }) { + try { + const data = window.rpc.timeZoneOffset + commit('SET_TIME_ZONE_OFF_SET', data) + return { success: true, message: null, data } // success + } catch (err) { + Util.handleException(err) + } + }, + + /** Compute server-vs-browser wall-clock delta (ms). Stored once at boot; used by DateTimeRangePresets for server-clock-accurate maxDate and preset times. */ + getServerClockOffsetMs({ commit }) { + try { + const serverMs = util.getMilliseconds() + commit('SET_SERVER_CLOCK_OFFSET_MS', serverMs - Date.now()) + return { success: true } + } catch (err) { + commit('SET_SERVER_CLOCK_OFFSET_MS', 0) + } + }, + + /* get users configuration */ + getUsers({ state, commit }, refetch) { + try { + if (state.users && !refetch) { + return + } + const result = window.rpc.UvmContext.localDirectory() + const data = result.getUsersV2() + commit('SET_USERS', data) + return { success: true, message: null, data } // success + } catch (err) { + Util.handleException(err) + } + }, + + /* get device temperature information configuration */ + async getDeviceTemperatureInfo({ commit }) { + try { + const data = await window.rpc.systemManager.getDeviceTemperatureInfo() + commit('SET_DEVICE_TEMP_INFO', data) + return { success: true, message: null, data } // success + } catch (err) { + Util.handleException(err) + } + }, + + /** get radius logs information */ + async getRadiusLogFile({ commit }) { + try { + const data = await window.rpc.UvmContext.localDirectory().getRadiusLogFile() + commit('SET_RADIUS_LOGS', data) + return { success: true, message: null, data } + } catch (err) { + Util.handleException(err) + } + }, + + /* + * Retrieve system timezones and transform them into the list item format + * required by Vuntangle components. + */ + getSystemTimeZones({ commit }) { + try { + const timeZones = [] + const systemTimeZones = window.rpc.systemManager.getTimeZones() + const jsonStr = systemTimeZones.replace(/'/g, '"') + const timezonesArray = JSON.parse(jsonStr) + + if (timezonesArray && Array.isArray(timezonesArray)) { + for (let i = 0; i < timezonesArray.length; i++) { + const [zone, offset] = timezonesArray[i] + + const tzObject = { + text: `(${offset}) ${zone}`, + value: zone, + } + timeZones.push(tzObject) + } + } + commit('SET_SYSTEM_TIMEZONES', timeZones) + return { success: true, message: null, timeZones } + } catch (err) { + Util.handleException(err) + } + }, + + /* + * get list of all enabled interfaces + * it is used in the hostname for listing interface list + */ + async getEnabledInterfaces({ commit }) { + try { + const enabledWanname = ['Default'] + const interfaces = await window.rpc.networkManager.getEnabledInterfaces() + interfaces.list.forEach(intf => { + if (intf.isWan) { + enabledWanname.push(intf.name) + } + }) + commit('SET_ENABLED_WAN_INTERFACES', enabledWanname) + return { success: true, message: null, enabledWanname } // success + } catch (err) { + Util.handleException(err) + } + }, + + /* get Admin settings configuration */ + async getAdminSettings({ state, commit }, refetch) { + try { + if (state.adminSetting && !refetch) { + return + } + const data = await window.rpc.adminManager.getSettingsV2() + commit('SET_ADMIN_SETTINGS', data) + return { success: true, message: null, data } // success + } catch (err) { + Util.handleException(err) + } + }, + + /* get Google Settings configuration */ + async getGoogleSettings({ state, commit }, refetch) { + try { + if (state.googleSettings && !refetch) { + return + } + const result = Rpc.asyncPromise('rpc.UvmContext.googleManager.getSettings') + const data = await result() + commit('SET_GOOGLE_SETTINGS', data) + return { success: true, message: null, data } + } catch (err) { + Util.handleException(err) + } + }, + + /* check google drive connection status */ + /* fetch the app-specific Google Drive root path — matches ExtJS getAppSpecificGoogleDrivePath */ + async getGoogleDriveRootPath({ commit }) { + try { + const result = Rpc.asyncPromise('rpc.UvmContext.googleManager.getAppSpecificGoogleDrivePath', null) + const rootPath = await result() + commit('SET_GOOGLE_DRIVE_ROOT_PATH', rootPath || '') + } catch { + commit('SET_GOOGLE_DRIVE_ROOT_PATH', '') + } + }, + + async getIsGoogleDriveConnected({ commit }) { + try { + const result = Rpc.asyncPromise('rpc.UvmContext.googleManager.isGoogleDriveConnected') + const isConnected = await result() + commit('SET_IS_GOOGLE_DRIVE_CONNECTED', isConnected) + return { success: true, message: null, isConnected } + } catch (err) { + Util.handleException(err) + } + }, + + /* setUsers will update users regarding configurations */ + setUsersSettings({ dispatch }, usersSettings) { + try { + const data = new Promise(resolve => { + window.rpc.UvmContext.localDirectory().setUsersV2(async (ex, result) => { + if (ex) { + Util.handleException(ex) + return resolve({ success: false, message: ex?.toString()?.slice(0, 100) || 'Unknown error' }) + } + + if (result?.code && result?.message) { + Util.handleException(result.message) + return resolve({ success: false, message: result.message.slice(0, 100) }) + } + // fetch updated settings after successful save + await dispatch('getUsers', true) + return resolve({ success: true }) + }, usersSettings) + }) + return data + } catch (err) { + Util.handleException(err) + return { success: false, message: err?.toString()?.slice(0, 100) || 'Unknown error' } + } + }, + + /** + * setSystemSettings will update system regarding configurations + * @param {object} { dispatch } + * @param {object} systemSettings + * @returns {Promise} + */ + async setSystemSettings({ dispatch }, systemSettings) { + try { + const result = await new Promise(resolve => { + window.rpc.systemManager.setSystemSettingsV2((ex, res) => { + if (ex) { + return resolve({ + success: false, + message: ex?.toString()?.slice(0, 100) || 'Unknown error', + }) + } + if (res?.code && res?.message) { + return resolve({ + success: false, + message: res.message.slice(0, 100), + }) + } + return resolve({ success: true }) + }, systemSettings) + }) + + if (result.success) { + // fetch updated settings after successful save + await dispatch('getSystemSettings', true) + } else { + Util.handleException(result.message) + } + + return result + } catch (err) { + Util.handleException(err) + return { + success: false, + message: err?.toString()?.slice(0, 100) || 'Unknown error', + } + } + }, + + /** + * setSystemSettings will update system regarding configurations + * @param {object} { dispatch } + * @param {object} systemSettings + * @param {boolean} dirtyRadiusFields + * @returns {Promise} + */ + async setSystemSettingsWithRadius({ dispatch }, { systemSettings, dirtyRadiusFields }) { + try { + const result = await new Promise(resolve => { + window.rpc.systemManager.setSystemSettingsV2( + (ex, res) => { + if (ex) { + return resolve({ + success: false, + message: ex?.toString()?.slice(0, 100) || 'Unknown error', + }) + } + if (res?.code && res?.message) { + return resolve({ + success: false, + message: res.message.slice(0, 100), + }) + } + return resolve({ success: true }) + }, + systemSettings, + dirtyRadiusFields, + ) + }) + + if (result.success) { + // fetch updated settings after successful save + await dispatch('getSystemSettings', true) + } else { + Util.handleException(result.message) + } + + return result + } catch (err) { + Util.handleException(err) + return { + success: false, + message: err?.toString()?.slice(0, 100) || 'Unknown error', + } + } + }, + /** + * Persists the updated list of network interfaces to the backend using RPC. + * This action sends a payload to the backend containing all network interfaces + * and a required Java class identifier. It handles errors from both RPC exceptions + * and response objects with error codes. On success, it commits the updated interfaces + * to the Vuex state. + */ + setNetworkSettingV2({ dispatch }, payload) { + try { + payload.javaClass = 'com.untangle.uvm.network.generic.NetworkSettingsGeneric' + const data = new Promise(resolve => { + window.rpc.networkManager.setNetworkSettingsV2((ex, result) => { + if (ex) { + Util.handleException(ex) + return resolve({ success: false, message: ex?.toString()?.slice(0, 100) || 'Unknown error' }) + } + if (result?.code && result?.message) { + Util.handleException(result.message) + return resolve({ + success: false, + message: result.message.slice(0, 100), + }) + } + dispatch('getNetworkSettings', true) + return resolve({ success: true }) + }, payload) + }) + return data + } catch (err) { + Util.handleException(err) + } + }, + + /** + * Dispatches admin settings to the backend via RPC. + * This action sends the provided admin settings to the 'adminManager' service. + * returning a success or failure status along with an error message if applicable. + * Upon successful saving, it triggers a refetch of the admin settings to ensure + * @param {object} { dispatch } - Vuex action context for dispatching other actions. + * @param {object} adminettings - The administration settings object to be saved. + * @returns {Promise} A promise that resolves with an object indicating + */ + setAdminSettings({ dispatch }, adminettings) { + try { + const data = new Promise(resolve => { + window.rpc.adminManager.setSettingsV2(async (ex, result) => { + if (ex) { + Util.handleException(ex) + return resolve({ success: false, message: ex?.toString()?.slice(0, 100) || 'Unknown error' }) + } + + if (result?.code && result?.message) { + Util.handleException(result.message) + return resolve({ success: false, message: result.message.slice(0, 100) }) + } + // fetch updated settings after successful save + await dispatch('getAdminSettings', true) + return resolve({ success: true }) + }, adminettings) + }) + return data + } catch (err) { + Util.handleException(err) + return { success: false, message: err?.toString()?.slice(0, 100) || 'Unknown error' } + } + }, + + /** Saves Event Settings */ + async setEventSettings({ dispatch }, eventSettings) { + try { + const result = await new Promise(resolve => { + window.rpc.eventManager.setSettingsV2(async (ex, res) => { + if (ex) return resolve({ success: false, message: ex?.toString()?.slice(0, 100) || 'Unknown error' }) + if (res?.code && res?.message) return resolve({ success: false, message: res.message.slice(0, 100) }) + + // Fetch updated settings after successful save + await dispatch('getEventSettings', true) + return resolve({ success: true }) + }, eventSettings) + }) + + // Handle RPC-level errors (optional logging) + if (!result.success) { + Util.handleException(result.message) + } + + return result + } catch (err) { + Util.handleException(err) + return { success: false, message: err?.toString()?.slice(0, 100) || 'Unknown error' } + } + }, + + /** + * Dispatches google settings to the backend via RPC. + * @param {object} { dispatch } - Vuex action context for dispatching other actions. + * @param {object} googleSettings - The googleSettings settings object to be saved. + * @returns {object} An object indicating success status with message + */ + async setGoogleSettings({ dispatch }, googleSettings) { + try { + const apiMethod = Rpc.asyncPromise('rpc.UvmContext.googleManager.setSettings', googleSettings) + const result = await apiMethod() + if (result?.code && result?.message) { + Util.handleException(result.message) + return { success: false, message: result.message.slice(0, 100) } + } + // fetch updated settings after successful save + await dispatch('getGoogleSettings', true) + await dispatch('getIsGoogleDriveConnected') + return { success: true } + } catch (err) { + Util.handleException(err) + return { success: false, message: err?.toString()?.slice(0, 100) || 'Unknown error' } + } + }, + + /** + * Synchronize the system time using the NTP (Network Time Protocol) service + */ + async doForceTimeSync() { + try { + return await new Promise(resolve => { + window.rpc.UvmContext.forceTimeSync((ex, result) => { + if (ex) { + Util.handleException(ex) + return resolve({ success: false, message: ex?.toString()?.slice(0, 100) || 'Unknown error' }) + } + if (result?.code && result?.message) { + Util.handleException(result.message) + return resolve({ + success: false, + message: result.message.slice(0, 100), + }) + } + return resolve({ success: true }) + }) + }) + } catch (err) { + Util.handleException(err) + return { success: false, message: err?.toString()?.slice(0, 100) || 'Unknown error' } + } + }, + + /* factory reset */ + factoryReset() { + try { + window.rpc.UvmContext.configManager().doFactoryReset() + return { success: true } + } catch (err) { + Util.handleException(err) + return { success: false, message: err?.toString()?.slice(0, 100) || 'Unknown error' } + } + }, + + /* reboot */ + reboot() { + try { + window.rpc.UvmContext.rebootBox() + return { success: true } + } catch (err) { + Util.handleException(err) + return { success: false, message: err?.toString()?.slice(0, 100) || 'Unknown error' } + } + }, + + /* shutdown */ + shutdown() { + try { + window.rpc.UvmContext.shutdownBox() + return { success: true } + } catch (err) { + Util.handleException(err) + return { success: false, message: err?.toString()?.slice(0, 100) || 'Unknown error' } + } + }, + + /* Get Certificate */ + + async getRootCertificateInformation({ state, commit }, refetch) { + if (state.certificatesInformation && !refetch) { + return + } + try { + const data = await window.rpc.UvmContext.certificateManager().getRootCertificateInformation() + commit('SET_CERTIFICATES_INFORMATION', data) + return { success: true, data } + } catch (err) { + Util.handleException(err) + return { success: false, message: err?.toString()?.slice(0, 100) || 'Unknown error' } + } + }, + + /* Get Root Certificate List */ + async getRootCertificateList({ state, commit }, refetch) { + if (state.rootCertificates && !refetch) { + return + } + try { + const data = await window.rpc.UvmContext.certificateManager().getRootCertificateListV2() + commit('SET_ROOT_CERTIFICATES', data) + return { success: true, data } + } catch (err) { + Util.handleException(err) + return { success: false, message: err?.toString()?.slice(0, 100) || 'Unknown error' } + } + }, + + /* Get ServerCertificateVerification details */ + async getServerCertificateVerification({ state, commit }, refetch) { + if (state.serverCertificateVerification && !refetch) { + return + } + try { + const data = await window.rpc.UvmContext.certificateManager().validateActiveInspectorCertificates() + commit('SET_SERVER_CERTIFICATE_VERIFICATION', data) + return { success: true, data } + } catch (err) { + Util.handleException(err) + return { success: false, message: err?.toString()?.slice(0, 100) || 'Unknown error' } + } + }, + + /* Get Server Certificate List */ + async getServerCertificateList({ state, commit }, refetch) { + if (state.serverCertificates && !refetch) { + return + } + try { + const data = await window.rpc.UvmContext.certificateManager().getServerCertificateListV2() + commit('SET_SERVER_CERTIFICATES', data) + return { success: true, data } + } catch (err) { + Util.handleException(err) + return { success: false, message: err?.toString()?.slice(0, 100) || 'Unknown error' } + } + }, + + /** update all interfaces */ + async setInterfaces({ state, dispatch }, interfaces) { + const networkSettings = cloneDeep(state.networkSetting) + + interfaces.forEach(intf => { + const existing = networkSettings.interfaces.find(i => i.interfaceId === intf.interfaceId) + if (existing) { + Object.assign(existing, intf) // update existing + } else { + networkSettings.interfaces.push(intf) // add new + } + }) + // Save updated interface list + return await dispatch('setNetworkSettingV2', networkSettings) + }, + + /* Delete selected Interface and update all interfaces */ + async deleteInterface({ state, dispatch }, intf) { + try { + const networkSettings = cloneDeep(state.networkSetting) + const index = networkSettings.interfaces.findIndex(i => i.interfaceId === intf.interfaceId) + + /* Selected interfaces will be removed from the list of interfaces */ + if (index >= 0) { + networkSettings.interfaces.splice(index, 1) + } + return await dispatch('setNetworkSettingV2', networkSettings) + } catch (err) { + Util.handleException(err) + return false + } + }, + + // fetch uri settings + async getUriSettings({ state, commit }, refetch) { + try { + if (state.uriSettings && !refetch) { + return + } + const data = await window.rpc.uriManager.getSettings() + commit('SET_URI_SETTINGS', data) + } catch (err) { + Util.handleException(err) + return false + } + }, + + // Fetch Shield Settings + async getShieldSettings({ state, commit }, refetch) { + try { + if (state.shieldSettings && !refetch) { + return + } + const data = await window.rpc.appManager.app('shield').getSettingsV2() + commit('SET_SHIELD_SETTINGS', data) + } catch (err) { + Util.handleException(err) + return false + } + }, + + /* setShieldSettings will update system regarding configurations */ + async setShieldSettings({ dispatch }, shieldSettings) { + try { + const result = await new Promise(resolve => { + window.rpc.appManager.app('shield').setSettingsV2(async (ex, res) => { + if (ex) return resolve({ success: false, message: ex?.toString()?.slice(0, 100) || 'Unknown error' }) + if (res?.code && res?.message) return resolve({ success: false, message: res.message.slice(0, 100) }) + + // Fetch updated settings after successful save + await dispatch('getShieldSettings', true) + return resolve({ success: true }) + }, shieldSettings) + }) + + // Handle RPC-level errors (optional logging) + if (!result.success) { + Util.handleException(result.message) + } + + return result + } catch (err) { + Util.handleException(err) + return { success: false, message: err?.toString()?.slice(0, 100) || 'Unknown error' } + } + }, + + setAccessRuleSshEnabled({ state, commit }) { + const accessRules = state?.networkSetting?.access_rules || [] + commit('SET_ACCESS_RULE_SSH_ENABLED', util.getRuleEnabledStatus(accessRules, 'Allow SSH')) + }, + + getLogsByName({ commit, state }, { logName, refetch }) { + let logs + try { + if (state.systemLogs && state.systemLogs[logName] && !refetch) { + // just return logs from store + logs = state.systemLogs[logName] + return { success: true, logs } + } + + if (logName === 'uvm') { + logs = window.rpc.systemManager.getUvmLogs() + } + commit('SET_LOGS', { logName, value: logs }) + return { success: true, logs } + } catch (err) { + Util.handleException(err) + } + }, +} + +export default { + namespaced: true, + state: getDefaultState, + getters, + mutations, + actions, +} diff --git a/untangle-vue-ui/source/src/store/index.js b/untangle-vue-ui/source/src/store/index.js index 287dea4810..f1e358a802 100644 --- a/untangle-vue-ui/source/src/store/index.js +++ b/untangle-vue-ui/source/src/store/index.js @@ -3,8 +3,11 @@ import Vuex, { Store } from 'vuex' import VuexPersistence from 'vuex-persist' import setup from './setup' import auth from './auth' -import settings from './settings' +import config from './config' import apps from './apps' +import reports from './reports' +import session from './session' +import metrics from './metrics' Vue.use(Vuex) @@ -103,14 +106,38 @@ const getters = {} const vuexPersistence = new VuexPersistence({ storage: window.localStorage, + reducer: state => { + // Destructure to exclude autoInstallApps from apps module + // eslint-disable-next-line no-unused-vars + const { autoInstallApps, ...appsStateWithoutAutoInstall } = state.apps || {} + + // CRITICAL: Exclude session and metrics modules from persistence + // - session: Must reset on every page load/hard refresh + // - metrics: Transient polling data, should not be persisted + // eslint-disable-next-line no-unused-vars + const { session, metrics, ...stateWithoutSessionAndMetrics } = state + + // Exclude globalConditions from reports — transient, should reset on page refresh + // eslint-disable-next-line no-unused-vars + const { globalConditions, ...reportsWithoutConditions } = state.reports || {} + + return { + ...stateWithoutSessionAndMetrics, + apps: appsStateWithoutAutoInstall, + reports: reportsWithoutConditions, + } + }, }) export default new Store({ modules: { auth, setup, - settings, + config, apps, + reports, + session, + metrics, }, state: getDefaultState, getters, diff --git a/untangle-vue-ui/source/src/store/metrics.js b/untangle-vue-ui/source/src/store/metrics.js new file mode 100644 index 0000000000..41b0c0d822 --- /dev/null +++ b/untangle-vue-ui/source/src/store/metrics.js @@ -0,0 +1,256 @@ +/** + * Metrics Store Module + * + * Manages real-time metrics polling + * + * Architecture: + * - Centralized state for all app metrics + * - Updated by MetricsPollingService every 10 seconds + * - Components subscribe via Vuex getters + * - NOT persisted to localStorage (transient data) + * + * Key Difference from apps.js: + * - apps.js: On-demand app configuration (persisted) + * - metrics.js: Continuous polling data (transient) + */ + +const state = () => ({ + /** + * App metrics keyed by instance.id + * Structure matches backend response from metricManager.getMetricsAndStatsV2() + */ + appMetrics: {}, + + systemStats: {}, // System level statistics + isPolling: false, // Polling state flags + lastUpdate: null, + error: null, + expertMode: false, // When false, metrics with expert: true are hidden +}) + +const getters = { + /** + * Get raw metrics for a specific app instance + * + * @param {string|number} instanceId - App instance ID (e.g., 12345) + * @returns {Object|null} Metrics object with list array, or null if not found + */ + getAppMetrics: state => instanceId => { + if (!instanceId) return null + return state.appMetrics[String(instanceId)] || null + }, + + /** + * Get a specific metric value for an app + * + * @param {string|number} instanceId - App instance ID + * @param {string} metricName - Metric name (e.g., 'live-sessions') + * @returns {Object|null} Metric object or null + */ + getAppMetric: state => (instanceId, metricName) => { + if (!instanceId || !metricName) return null + + const appMetrics = state.appMetrics[String(instanceId)] + if (!appMetrics) return null + + return appMetrics.find(m => m.name === metricName) || null + }, + + /** + * Get formatted metrics for UI display + * + * Transforms backend metrics into UI-ready format: + * - Filters out expert metrics if expertMode is false + * - Concatenates value + displayUnits + * - Returns array in UAppStatusMetrics component format + * + * @param {string|number} instanceId - App instance ID + * @returns {Array} Array of {key, value, formatter} objects for UAppStatusMetrics + */ + getFormattedMetrics: state => instanceId => { + if (!instanceId) return [] + + const appMetrics = state.appMetrics[String(instanceId)] + if (!appMetrics) return [] + + return appMetrics + .filter(metric => !metric.expert || state.expertMode) + .map(metric => ({ + key: metric.displayName, + value: metric.value + (metric.displayUnits ? ' ' + metric.displayUnits : ''), + formatter: null, + })) + }, + + /** + * Get live sessions count for an app + * Convenience getter for the 'live-sessions' metric + * Used by sessions chart component + * + * @param {string|number} instanceId - App instance ID + * @returns {number} Live sessions count (0 if not found) + */ + getLiveSessions: (state, getters) => instanceId => { + if (!instanceId) return 0 + + const metric = getters.getAppMetric(instanceId, 'live-sessions') + return metric ? metric.value : 0 + }, + + /** + * Check if polling is currently active + * @returns {boolean} True if polling is running + */ + isPollingActive: state => state.isPolling, + + /** + * Get last update timestamp + * @returns {number|null} Timestamp of last successful update, or null + */ + lastUpdateTime: state => state.lastUpdate, + + /** + * Get system statistics + * @returns {Object} System stats object + */ + systemStats: state => state.systemStats, + + /** + * Get expert mode state + * @returns {boolean} True if expert mode is enabled + */ + expertMode: state => state.expertMode, + + /** + * Get current error (if any) + * @returns {string|null} Error message or null + */ + error: state => state.error, +} + +const mutations = { + /** + * Set all metrics data (called by polling service) + * This is the primary mutation for updating metrics + * Called by MetricsPollingService every 10 seconds + * + * @param {Object} state - Vuex state + * @param {Object} payload - { appMetrics, systemStats } + */ + SET_METRICS_DATA(state, { appMetrics, systemStats }) { + // Update both metrics and stats + state.appMetrics = appMetrics || {} + state.systemStats = systemStats || {} + + // Update metadata + state.lastUpdate = Date.now() + state.error = null + }, + + /** + * Set polling state + * @param {Object} state - Vuex state + * @param {boolean} isPolling - True if polling is active + */ + SET_POLLING_STATE(state, isPolling) { + state.isPolling = isPolling + }, + + /** + * Set error state + * @param {Object} state - Vuex state + * @param {string} error - Error message + */ + SET_ERROR(state, error) { + state.error = error + }, + + /** + * Set expert mode + * @param {Object} state - Vuex state + * @param {boolean} expertMode - True if expert mode is enabled + */ + SET_EXPERT_MODE(state, expertMode) { + state.expertMode = expertMode + }, + + /** + * Clear all metrics (on logout/cleanup) + * Resets state to initial values + */ + CLEAR_METRICS(state) { + state.appMetrics = {} + state.systemStats = {} + state.lastUpdate = null + state.error = null + }, +} + +const actions = { + /** + * Initialize expert mode from RPC + * + * Should be called once during app initialization + * Fetches expert mode state from backend + */ + async initExpertMode({ commit }) { + try { + const expertMode = await window.rpc.isExpertMode + commit('SET_EXPERT_MODE', expertMode) + return { success: true, expertMode } + } catch (err) { + commit('SET_EXPERT_MODE', false) + return { success: false, error: err } + } + }, + + /** + * Update metrics data (called by polling service) + * + * This is the primary action for updating metrics + * Should ONLY be called by MetricsPollingService + * + * @param {Object} context - Vuex context + * @param {Object} data - { appMetrics, systemStats } + */ + updateMetrics({ commit }, data) { + commit('SET_METRICS_DATA', data) + }, + + /** + * Set polling state + * + * @param {Object} context - Vuex context + * @param {boolean} isPolling - True if polling is active + */ + setPollingState({ commit }, isPolling) { + commit('SET_POLLING_STATE', isPolling) + }, + + /** + * Handle error + * + * @param {Object} context - Vuex context + * @param {string} error - Error message + */ + setError({ commit }, error) { + commit('SET_ERROR', error) + }, + + /** + * Clear metrics + * + * Should be called on logout or when stopping polling + */ + clearMetrics({ commit }) { + commit('CLEAR_METRICS') + }, +} + +export default { + namespaced: true, + state, + getters, + mutations, + actions, +} diff --git a/untangle-vue-ui/source/src/store/reports.js b/untangle-vue-ui/source/src/store/reports.js new file mode 100644 index 0000000000..29b7f9458a --- /dev/null +++ b/untangle-vue-ui/source/src/store/reports.js @@ -0,0 +1,306 @@ +/** + * Reports Module - Global state for all reports + * + * Loads reports once at boot, stores globally, filters per-app by category. + */ + +import Rpc from '@/util/Rpc' +import Util from '@/util/setupUtil' +import { baseCategories, urlEncode } from '@/util/reports' + +const getDefaultState = () => ({ + // Raw reports array from backend + allReports: [], + + // Normalized map for O(1) lookup by category + // { 'Application Control Lite': [...reports], 'Web Filter': [...reports] } + reportsByCategory: {}, + + // Categories list (for future use) + categories: [], + + // Loading state + loading: false, + + // Error state + error: null, + + // Whether Reports app is installed + isReportsInstalled: false, + + // Cached reports manager instance — acquired once at boot, reused on every data fetch + reportsManager: null, + + // Timestamp of last load (for cache invalidation if needed) + lastLoaded: null, + + // Policy list from backend — populated lazily on first EVENT_LIST view + policiesInfo: [], + + // Global conditions applied across all reports — persists across route navigation + // Each condition: { column, operator, value, autoFormatValue } + globalConditions: [], +}) + +const getters = { + allReports: state => state.allReports, + + // O(1) lookup by category name + getReportsByCategory: state => category => { + return state.reportsByCategory[category] || [] + }, + + // Merges built-in system categories with installed app categories from the backend, + // then sorts the combined list by viewPosition so categories render in the correct order. + allCategories: state => { + const appCategories = state.categories.map(cat => ({ ...cat, type: 'app' })) + return [...baseCategories, ...appCategories].sort((a, b) => a.viewPosition - b.viewPosition) + }, + + // Normalized categories shape for the ReportsNav dropdown. + // Filters out empty categories and projects only the fields the dropdown needs. + categoriesForNav: (state, getters) => { + return getters.allCategories + .filter(cat => (state.reportsByCategory[cat.displayName] || []).length) + .map(cat => ({ + id: urlEncode(cat.displayName), + displayName: cat.displayName, + reports: (state.reportsByCategory[cat.displayName] || []).map(r => ({ + id: r.uniqueId, + name: r.title, + type: r.type, + timeStyle: r.timeStyle, + pieStyle: r.pieStyle, + description: r.description, + })), + })) + }, + + isReportsInstalled: state => state.isReportsInstalled, + categories: state => state.categories, + loading: state => state.loading, + error: state => state.error, + isLoaded: state => state.allReports.length > 0 || state.lastLoaded !== null, + + /** + * Maps policy ID (integer) → "name [id]" display string. + * Populated lazily by fetchPoliciesInfo when an EVENT_LIST report is opened. + * Returns an empty map when no policy-manager is installed. + */ + globalConditions: state => state.globalConditions, + + policyNameMap: state => { + const map = {} + state.policiesInfo.forEach(p => { + if (p.policyId != null) { + map[p.policyId] = p.name ? `${p.name} [${p.policyId}]` : String(p.policyId) + } + }) + return map + }, +} + +const mutations = { + SET_REPORTS(state, reports) { + state.allReports = reports + + // Normalize by category for O(1) filtering + state.reportsByCategory = reports.reduce((acc, report) => { + const category = report.category + if (!acc[category]) { + acc[category] = [] + } + acc[category].push(report) + return acc + }, {}) + }, + + SET_CATEGORIES(state, categories) { + state.categories = categories + }, + + SET_LOADING(state, loading) { + state.loading = loading + }, + + SET_ERROR(state, error) { + state.error = error + }, + + SET_REPORTS_INSTALLED(state, installed) { + state.isReportsInstalled = installed + }, + + SET_REPORTS_MANAGER(state, manager) { + state.reportsManager = manager + }, + + SET_LAST_LOADED(state, timestamp) { + state.lastLoaded = timestamp + }, + + SET_POLICIES_INFO(state, policies) { + state.policiesInfo = policies + }, + + SET_GLOBAL_CONDITIONS(state, conditions) { + state.globalConditions = conditions + }, + + ADD_GLOBAL_CONDITION(state, condition) { + state.globalConditions.push(condition) + }, + + REMOVE_GLOBAL_CONDITION(state, index) { + state.globalConditions.splice(index, 1) + }, + + CLEAR_GLOBAL_CONDITIONS(state) { + state.globalConditions = [] + }, + + RESET(state) { + Object.assign(state, getDefaultState()) + }, +} + +const actions = { + /** + * Load all reports from backend + * Called once at application boot via router guard + */ + async loadReports({ commit }) { + commit('SET_LOADING', true) + commit('SET_ERROR', null) + + try { + // Check if Reports app installed + const reportsApp = await Rpc.asyncData('rpc.appManager.app', 'reports') + + if (!reportsApp) { + commit('SET_REPORTS_INSTALLED', false) + commit('SET_LOADING', false) + return { success: false, reason: 'not_installed' } + } + + commit('SET_REPORTS_INSTALLED', true) + + const reportsManager = await Rpc.asyncData(reportsApp, 'getReportsManager') + commit('SET_REPORTS_MANAGER', reportsManager) + + // Parallel RPC calls + const [reportsResult, categoriesResult] = await Promise.all([ + Rpc.asyncData(reportsManager, 'getReportEntriesV2'), + Rpc.asyncData(reportsManager, 'getCurrentApplications').catch(() => null), + ]) + + if (reportsResult) { + commit('SET_REPORTS', reportsResult) + } + + if (categoriesResult && categoriesResult.list) { + commit('SET_CATEGORIES', categoriesResult.list) + } + + commit('SET_LAST_LOADED', Date.now()) + commit('SET_LOADING', false) + + return { success: true } + } catch (error) { + commit('SET_ERROR', error.message || 'Failed to load reports') + commit('SET_LOADING', false) + Util.handleException(error, 'Failed to load reports') + return { success: false, error } + } + }, + + refreshReports({ dispatch }) { + return dispatch('loadReports') + }, + + resetReports({ commit }) { + commit('RESET') + }, + + /** + * Fetches policy name/ID pairs from the backend for EVENT_LIST detail rendering. + * Only runs once — returns immediately if policiesInfo is already populated. + * Returns an empty list when no policy-manager is installed. + */ + async fetchPoliciesInfo({ state, commit }) { + if (state.policiesInfo.length) return + if (!state.reportsManager) return + try { + const result = await Rpc.asyncData(state.reportsManager, 'getPoliciesInfo') + if (result?.list) commit('SET_POLICIES_INFO', result.list) + } catch (error) { + Util.handleException(error) + } + }, + + /** + * Fetches report data from the backend for a given report entry and time range. + * + * The backend returns a type-aware JSONObject: + * { series: [...] } for TIME_GRAPH / TIME_GRAPH_DYNAMIC + * { slices: [...] } for PIE_GRAPH + * { list: [...] } for EVENT_LIST / TEXT + * + * @param {Object} payload + * @param {Object} payload.entry - full ReportEntry object + * @param {Array} payload.conditions - SQL filter conditions excluding time range entries + * @param {Date} payload.startDate - query start date + * @param {Date} payload.endDate - query end date; null means open-ended + * @param {Number} payload.limit - maximum rows to return; -1 for unlimited + * @returns {Object} backend payload with either a `data` key (chart) or `list` key (events) + */ + async fetchReportData({ state, dispatch }, { entry, conditions = [], startDate, endDate, limit = -1 }) { + const callRpc = manager => + Rpc.asyncData(manager, 'getDataForReportEntryV2', entry, startDate, endDate, null, conditions, null, limit) + + let result + try { + result = await callRpc(state.reportsManager) + } catch (error) { + // Jabsorb session can expire after inactivity, making the cached reportsManager + // proxy invalid ("No such method"). Re-acquire the manager and retry once. + const isStaleProxy = error?.message?.includes('No such method') || error?.message?.includes('no such object') + + if (!isStaleProxy) { + Util.handleException(error) + return { list: [] } + } + + // Re-initialise the reports manager and retry the request + await dispatch('loadReports') + try { + result = await callRpc(state.reportsManager) + } catch (retryError) { + Util.handleException(retryError) + return { list: [] } + } + } + + if (!result) return { list: [] } + + // Chart types return { series: [...] } or { slices: [...] } at the root level + if (result.series || result.slices) { + return { data: result } + } + + // TEXT returns a pre-substituted string from the backend + if (result.text !== undefined) { + return { text: result.text } + } + + return { list: result.list ?? [] } + }, +} + +export default { + namespaced: true, + state: getDefaultState, + getters, + mutations, + actions, +} diff --git a/untangle-vue-ui/source/src/store/session.js b/untangle-vue-ui/source/src/store/session.js new file mode 100644 index 0000000000..075755f742 --- /dev/null +++ b/untangle-vue-ui/source/src/store/session.js @@ -0,0 +1,69 @@ +/** + * Session Module - NOT persisted to localStorage + * + * Tracks initialization state per session. Resets on page load/hard refresh, + * ensuring RPC calls run fresh on every session. + */ + +import Util from '@/util/setupUtil' + +const state = () => ({ + // Tracks if admin context initialized this session + adminContextInitialized: false, +}) + +const getters = { + isAdminContextInitialized: state => state.adminContextInitialized, +} + +const mutations = { + SET_ADMIN_CONTEXT_INITIALIZED(state, value) { + state.adminContextInitialized = value + }, + + RESET(state) { + state.adminContextInitialized = false + }, +} + +const actions = { + /** + * Initialize admin context - loads apps, policy-manager, reports + */ + async initializeAdminContext({ commit, state, dispatch }) { + if (state.adminContextInitialized) { + return { success: true, cached: true } + } + + try { + await Promise.all([ + dispatch('apps/getAppsViews', true, { root: true }), + dispatch('apps/loadAppData', { appName: 'policy-manager' }, { root: true }), + dispatch('reports/loadReports', null, { root: true }), + // Required for server-timezone-aware date/time in Reports date picker. + // Must run at boot so DateTimeRangePresets receives the correct offset via $serverTimezoneOffsetMs. + dispatch('config/getTimeZoneOffSet', null, { root: true }), + // Compute server-vs-browser wall-clock delta so maxDate and presets use server time, not browser time. + dispatch('config/getServerClockOffsetMs', null, { root: true }), + ]) + + commit('SET_ADMIN_CONTEXT_INITIALIZED', true) + return { success: true } + } catch (error) { + Util.handleException(error, 'Failed to initialize admin context') + return { success: false, error } + } + }, + + reset({ commit }) { + commit('RESET') + }, +} + +export default { + namespaced: true, + state, + getters, + mutations, + actions, +} diff --git a/untangle-vue-ui/source/src/store/settings.js b/untangle-vue-ui/source/src/store/settings.js deleted file mode 100644 index fc6df6d583..0000000000 --- a/untangle-vue-ui/source/src/store/settings.js +++ /dev/null @@ -1,471 +0,0 @@ -import { set } from 'vue' -import { cloneDeep } from 'lodash' -import Util from '@/util/setupUtil' -import util from '@/util/util' - -const getDefaultState = () => ({ - editCallback: null, - networkSetting: null, - systemSetting: null, - deviceTemperatureInfo: null, - enabledWanInterfaces: [], - uriSettings: null, - systemTimeZones: [], - shieldSettings: null, - languageSettings: null, - accessRuleSshEnabled: false, - adminSetting: null, - systemLogs: {}, // system logs stored by logName -}) - -const getters = { - languageSettings: state => state.languageSettings, - networkSetting: state => state.networkSetting || [], - interfaces: state => state?.networkSetting?.interfaces || [], - interface: state => device => { - return state.networkSetting?.interfaces?.find(intf => intf.device === device) - }, - systemSetting: state => state.systemSetting || {}, - deviceTemperatureInfo: state => state.deviceTemperatureInfo || '', - systemTimeZones: state => state.systemTimeZones || [], - enabledWanInterfaces: state => state.enabledWanInterfaces || [], - staticRoutes: state => state?.networkSetting?.staticRoutes || [], - dnsSettings: state => state?.networkSetting?.dnsSettings || {}, - uriSettings: state => state?.uriSettings || {}, - /** - * Check expert mode is enabled via RPC. - * Avoids using Rpc.directData method by getting the value directly from window.rpc. - */ - isExpertMode: () => window?.rpc?.isExpertMode || false, - dynamicRoutingSettings: state => state?.networkSetting?.dynamicRoutingSettings || {}, - shieldSettings: state => state?.shieldSettings || {}, - accessRuleSshEnabled: state => state.accessRuleSshEnabled, - adminSetting: state => state.adminSetting || {}, - /** - * Get logs for a given log. - * Usage: getters.getLogs('uvm') - */ - getLogsByName: state => logName => state.systemLogs[logName] || null, -} - -const mutations = { - setEditCallback(state, cb) { - state.editCallback = cb - }, - SET_INTERFACES: (state, value) => set(state.networkSetting, 'interfaces', value), - SET_NETWORK_SETTINGS: (state, value) => set(state, 'networkSetting', value), - SET_SYSTEM_SETTINGS: (state, value) => set(state, 'systemSetting', value), - SET_DEVICE_TEMP_INFO: (state, value) => set(state, 'deviceTemperatureInfo', value), - SET_SYSTEM_TIMEZONES: (state, value) => set(state, 'systemTimeZones', value), - SET_ENABLED_WAN_INTERFACES: (state, value) => set(state, 'enabledWanInterfaces', value), - SET_URI_SETTINGS: (state, value) => set(state, 'uriSettings', value), - SET_SHIELD_SETTINGS: (state, value) => set(state, 'shieldSettings', value), - SET_LANGUAGE_SETTINGS: (state, value) => set(state, 'languageSettings', value), - SET_ACCESS_RULE_SSH_ENABLED: (state, value) => set(state, 'accessRuleSshEnabled', value), - SET_ADMIN_SETTINGS: (state, value) => set(state, 'adminSetting', value), - /** - * Dynamically set logs for an app - * Usage: commit('SET_LOGS', { logName: 'uvm', value: data }) - */ - SET_LOGS(state, { logName, value }) { - if (!state.systemLogs) { - set(state, 'systemLogs', {}) - } - set(state.systemLogs, logName, value) - }, -} - -const actions = { - async getLanguageSettings({ commit }) { - if (!window.rpc || !window.rpc.languageManager) { - return - } - try { - const data = await window.rpc.languageManager.getLanguageSettings() - commit('SET_LANGUAGE_SETTINGS', data) - } catch (err) { - Util.handleException(err) - } - }, - - async getInterfaces({ commit }) { - try { - const data = await window.rpc.networkManager.getNetworkSettingsV2().interfaces - commit('SET_INTERFACES', await data) - } catch (err) { - Util.handleException(err) - } - }, - - async getNetworkSettings({ state, commit, dispatch }, refetch) { - try { - if (state.networkSetting && !refetch) { - return - } - const data = await window.rpc.networkManager.getNetworkSettingsV2() - commit('SET_NETWORK_SETTINGS', data) - dispatch('setAccessRuleSshEnabled') - } catch (err) { - Util.handleException(err) - } - }, - - /* get system settings configuration */ - getSystemSettings({ state, commit }, refetch) { - try { - if (state.systemSetting && !refetch) { - return - } - const data = window.rpc.systemManager.getSystemSettingsV2() - commit('SET_SYSTEM_SETTINGS', data) - return { success: true, message: null, data } // success - } catch (err) { - Util.handleException(err) - } - }, - - /* get device temperature information configuration */ - async getDeviceTemperatureInfo({ commit }) { - try { - const data = await window.rpc.systemManager.getDeviceTemperatureInfo() - commit('SET_DEVICE_TEMP_INFO', data) - return { success: true, message: null, data } // success - } catch (err) { - Util.handleException(err) - } - }, - - /* - * Retrieve system timezones and transform them into the list item format - * required by Vuntangle components. - */ - getSystemTimeZones({ commit }) { - try { - const timeZones = [] - const systemTimeZones = window.rpc.systemManager.getTimeZones() - const jsonStr = systemTimeZones.replace(/'/g, '"') - const timezonesArray = JSON.parse(jsonStr) - - if (timezonesArray && Array.isArray(timezonesArray)) { - for (let i = 0; i < timezonesArray.length; i++) { - const [zone, offset] = timezonesArray[i] - - const tzObject = { - text: `(${offset}) ${zone}`, - value: zone, - } - timeZones.push(tzObject) - } - } - commit('SET_SYSTEM_TIMEZONES', timeZones) - return { success: true, message: null, timeZones } - } catch (err) { - Util.handleException(err) - } - }, - - /* - * get list of all enabled interfaces - * it is used in the hostname for listing interface list - */ - async getEnabledInterfaces({ commit }) { - try { - const enabledWanname = ['Default'] - const interfaces = await window.rpc.networkManager.getEnabledInterfaces() - interfaces.list.forEach(intf => { - if (intf.isWan) { - enabledWanname.push(intf.name) - } - }) - commit('SET_ENABLED_WAN_INTERFACES', enabledWanname) - return { success: true, message: null, enabledWanname } // success - } catch (err) { - Util.handleException(err) - } - }, - - /* get Admin settings configuration */ - async getAdminSettings({ state, commit }, refetch) { - try { - if (state.adminSetting && !refetch) { - return - } - const data = await window.rpc.adminManager.getSettingsV2() - commit('SET_ADMIN_SETTINGS', data) - return { success: true, message: null, data } // success - } catch (err) { - Util.handleException(err) - } - }, - - /* setSystemSettings will update system regarding configurations */ - setSystemSettings({ dispatch }, systemSettings) { - try { - const data = new Promise(resolve => { - window.rpc.systemManager.setSystemSettingsV2(async (ex, result) => { - if (ex) { - Util.handleException(ex) - return resolve({ success: false, message: ex?.toString()?.slice(0, 100) || 'Unknown error' }) - } - - if (result?.code && result?.message) { - Util.handleException(result.message) - return resolve({ success: false, message: result.message.slice(0, 100) }) - } - // fetch updated settings after successful save - await dispatch('getSystemSettings', true) - return resolve({ success: true }) - }, systemSettings) - }) - return data - } catch (err) { - Util.handleException(err) - return { success: false, message: err?.toString()?.slice(0, 100) || 'Unknown error' } - } - }, - /** - * Persists the updated list of network interfaces to the backend using RPC. - * This action sends a payload to the backend containing all network interfaces - * and a required Java class identifier. It handles errors from both RPC exceptions - * and response objects with error codes. On success, it commits the updated interfaces - * to the Vuex state. - */ - setNetworkSettingV2({ dispatch }, payload) { - try { - payload.javaClass = 'com.untangle.uvm.network.generic.NetworkSettingsGeneric' - const data = new Promise(resolve => { - window.rpc.networkManager.setNetworkSettingsV2((ex, result) => { - if (ex) { - Util.handleException(ex) - return resolve({ success: false, message: ex?.toString()?.slice(0, 100) || 'Unknown error' }) - } - if (result?.code && result?.message) { - Util.handleException(result.message) - return resolve({ - success: false, - message: result.message.slice(0, 100), - }) - } - dispatch('getNetworkSettings', true) - return resolve({ success: true }) - }, payload) - }) - return data - } catch (err) { - Util.handleException(err) - } - }, - - /** - * Dispatches admin settings to the backend via RPC. - * This action sends the provided admin settings to the 'adminManager' service. - * returning a success or failure status along with an error message if applicable. - * Upon successful saving, it triggers a refetch of the admin settings to ensure - * @param {object} { dispatch } - Vuex action context for dispatching other actions. - * @param {object} adminettings - The administration settings object to be saved. - * @returns {Promise} A promise that resolves with an object indicating - */ - setAdminSettings({ dispatch }, adminettings) { - try { - const data = new Promise(resolve => { - window.rpc.adminManager.setSettingsV2(async (ex, result) => { - if (ex) { - Util.handleException(ex) - return resolve({ success: false, message: ex?.toString()?.slice(0, 100) || 'Unknown error' }) - } - - if (result?.code && result?.message) { - Util.handleException(result.message) - return resolve({ success: false, message: result.message.slice(0, 100) }) - } - // fetch updated settings after successful save - await dispatch('getAdminSettings', true) - return resolve({ success: true }) - }, adminettings) - }) - return data - } catch (err) { - Util.handleException(err) - return { success: false, message: err?.toString()?.slice(0, 100) || 'Unknown error' } - } - }, - - /** - * Synchronize the system time using the NTP (Network Time Protocol) service - */ - async doForceTimeSync() { - try { - return await new Promise(resolve => { - window.rpc.UvmContext.forceTimeSync((ex, result) => { - if (ex) { - Util.handleException(ex) - return resolve({ success: false, message: ex?.toString()?.slice(0, 100) || 'Unknown error' }) - } - if (result?.code && result?.message) { - Util.handleException(result.message) - return resolve({ - success: false, - message: result.message.slice(0, 100), - }) - } - return resolve({ success: true }) - }) - }) - } catch (err) { - Util.handleException(err) - return { success: false, message: err?.toString()?.slice(0, 100) || 'Unknown error' } - } - }, - - /* factory reset */ - factoryReset() { - try { - window.rpc.UvmContext.configManager().doFactoryReset() - return { success: true } - } catch (err) { - Util.handleException(err) - return { success: false, message: err?.toString()?.slice(0, 100) || 'Unknown error' } - } - }, - - /* reboot */ - reboot() { - try { - window.rpc.UvmContext.rebootBox() - return { success: true } - } catch (err) { - Util.handleException(err) - return { success: false, message: err?.toString()?.slice(0, 100) || 'Unknown error' } - } - }, - - /* shutdown */ - shutdown() { - try { - window.rpc.UvmContext.shutdownBox() - return { success: true } - } catch (err) { - Util.handleException(err) - return { success: false, message: err?.toString()?.slice(0, 100) || 'Unknown error' } - } - }, - - /** update all interfaces */ - async setInterfaces({ state, dispatch }, interfaces) { - const networkSettings = cloneDeep(state.networkSetting) - - interfaces.forEach(intf => { - const existing = networkSettings.interfaces.find(i => i.interfaceId === intf.interfaceId) - if (existing) { - Object.assign(existing, intf) // update existing - } else { - networkSettings.interfaces.push(intf) // add new - } - }) - // Save updated interface list - return await dispatch('setNetworkSettingV2', networkSettings) - }, - - /* Delete selected Interface and update all interfaces */ - async deleteInterface({ state, dispatch }, intf) { - try { - const networkSettings = cloneDeep(state.networkSetting) - const index = networkSettings.interfaces.findIndex(i => i.interfaceId === intf.interfaceId) - - /* Selected interfaces will be removed from the list of interfaces */ - if (index >= 0) { - networkSettings.interfaces.splice(index, 1) - } - return await dispatch('setNetworkSettingV2', networkSettings) - } catch (err) { - Util.handleException(err) - return false - } - }, - - // fetch uri settings - async getUriSettings({ state, commit }, refetch) { - try { - if (state.uriSettings && !refetch) { - return - } - const data = await window.rpc.uriManager.getSettings() - commit('SET_URI_SETTINGS', data) - } catch (err) { - Util.handleException(err) - return false - } - }, - - // Fetch Shield Settings - async getShieldSettings({ state, commit }, refetch) { - try { - if (state.shieldSettings && !refetch) { - return - } - const data = await window.rpc.appManager.app('shield').getSettingsV2() - commit('SET_SHIELD_SETTINGS', data) - } catch (err) { - Util.handleException(err) - return false - } - }, - - /* setShieldSettings will update system regarding configurations */ - async setShieldSettings({ dispatch }, shieldSettings) { - try { - const result = await new Promise(resolve => { - window.rpc.appManager.app('shield').setSettingsV2(async (ex, res) => { - if (ex) return resolve({ success: false, message: ex?.toString()?.slice(0, 100) || 'Unknown error' }) - if (res?.code && res?.message) return resolve({ success: false, message: res.message.slice(0, 100) }) - - // Fetch updated settings after successful save - await dispatch('getShieldSettings', true) - return resolve({ success: true }) - }, shieldSettings) - }) - - // Handle RPC-level errors (optional logging) - if (!result.success) { - Util.handleException(result.message) - } - - return result - } catch (err) { - Util.handleException(err) - return { success: false, message: err?.toString()?.slice(0, 100) || 'Unknown error' } - } - }, - - setAccessRuleSshEnabled({ state, commit }) { - const accessRules = state?.networkSetting?.access_rules || [] - commit('SET_ACCESS_RULE_SSH_ENABLED', util.getRuleEnabledStatus(accessRules, 'Allow SSH')) - }, - - getLogsByName({ commit, state }, { logName, refetch }) { - let logs - try { - if (state.systemLogs && state.systemLogs[logName] && !refetch) { - // just return logs from store - logs = state.systemLogs[logName] - return { success: true, logs } - } - - if (logName === 'uvm') { - logs = window.rpc.systemManager.getUvmLogs() - } - commit('SET_LOGS', { logName, value: logs }) - return { success: true, logs } - } catch (err) { - Util.handleException(err) - } - }, -} - -export default { - namespaced: true, - state: getDefaultState, - getters, - mutations, - actions, -} diff --git a/untangle-vue-ui/source/src/util/eventTableColumns.js b/untangle-vue-ui/source/src/util/eventTableColumns.js new file mode 100644 index 0000000000..768279640e --- /dev/null +++ b/untangle-vue-ui/source/src/util/eventTableColumns.js @@ -0,0 +1,1107 @@ +/** + * NGFW event table column registry. + * + * Provides: + * - tableFields — ordered field list per table + * - FIELD_META — rendering metadata per field (width, valueFormatter, sort) + * - buildEventColumnDefs(table, defaultColumns) — builds AG-Grid column def array + * + * Column headers are resolved via i18n.t(field) at build time. + */ + +import i18n from '@/plugins/vue-i18n' +import { protocolNameMap } from '@/constants' + +// ─── Static code→name maps ─────────────────────────────────────────────────── + +// bandwidth_control_priority 0–7 → en.js i18n keys; 0 = no priority assigned → null (formatter returns '') +export const priorityMap = { + 0: null, + 1: 'very_high', + 2: 'high', + 3: 'medium', + 4: 'low', + 5: 'limited', + 6: 'limited_more', + 7: 'limited_severely', +} + +// admin_logins.reason codes → en.js i18n keys. +// Not in FIELD_META because reason also appears in quotas table as a readable string. +// Applied table-specifically inside buildEventColumnDefs. +export const loginReasonMap = { + U: 'invalid_username', + P: 'invalid_password', + T: 'invalid_totp', + I: 'logged_in_successfully', + O: 'logged_out_successfully', +} + +// spam/phish blocker action codes → en.js i18n keys +export const emailActionMap = { + P: 'pass_message', + M: 'mark_message', + D: 'drop_message', + B: 'block_message', + Q: 'quarantine_message', + S: 'pass_safelist_message', + Z: 'pass_oversize_message', + O: 'pass_outbound_message', + F: 'block_message_scan_failure', + G: 'pass_message_scan_failure', + Y: 'block_message_greylist', +} + +// web_filter_reason single-char codes → en.js i18n keys +// Unknown codes fall back to 'no_rule_applied' +export const webFilterReasonMap = { + D: 'in_categories_block_list', + U: 'in_site_block_list', + T: 'in_search_term_list', + E: 'in_file_block_list', + M: 'in_mime_types_block_list', + H: 'hostname_is_an_ip_address', + I: 'in_site_pass_list', + R: 'referer_in_site_pass_list', + C: 'in_clients_pass_list', + B: 'in_temporary_unblocked_list', + F: 'in_rules_list', + K: 'kid_friendly_redirect', +} + +// HTTP method single-char code → full name; unknown → raw value +export const httpMethodMap = { + O: 'OPTIONS (O)', + G: 'GET (G)', + H: 'HEAD (H)', + P: 'POST (P)', + U: 'PUT (U)', + D: 'DELETE (D)', + T: 'TRACE (T)', + C: 'CONNECT (C)', + X: 'NON-STANDARD (X)', +} + +// captive_portal_user_events.auth_type → readable string +export const authTypeMap = { + NONE: 'None', + LOCAL_DIRECTORY: 'Local Directory', + ACTIVE_DIRECTORY: 'Active Directory', + RADIUS: 'RADIUS', + GOOGLE: 'Google Account', + MICROSOFT: 'Microsoft Account', + CUSTOM: 'Custom', +} + +// captive_portal_user_events.event_info → readable string +export const captivePortalEventMap = { + LOGIN: 'Login Success', + FAILED: 'Login Failure', + TIMEOUT: 'Session Timeout', + INACTIVE: 'Idle Timeout', + USER_LOGOUT: 'User Logout', + ADMIN_LOGOUT: 'Admin Logout', + HOST_CHANGE: 'Host Change Logout', +} + +// quotas.action — 1→'Given', 2→'Exceeded' (table-specific: applied via tableMeta for 'quotas') +// Note: wan_failover_action_events also has an 'action' field with different string values +export const quotaActionMap = { + 1: 'Given', + 2: 'Exceeded', +} + +// directory_connector_login_events.type — I/U/O/A → login/update/logout/authenticate (table-specific) +// Note: other VPN event tables use 'type' with already-readable values — no conversion needed +export const directoryConnectorActionMap = { + I: 'login', + U: 'update', + O: 'logout', + A: 'authenticate', +} + +// Threat prevention category bitmask — each key is the BIT INDEX (not the bitmask value). +// Use Math.pow(2, key) & value to test membership. +export const threatCategoryMap = { + 0: 'Spam Sources', + 1: 'Windows Exploits', + 2: 'Web Attacks', + 3: 'Botnets', + 4: 'Scanners', + 5: 'Denial of Service', + 6: 'Reputation', + 7: 'Phishing', + 8: 'Proxy', + 11: 'Mobile Threats', + 13: 'Tor Proxy', + 16: 'Keyloggers', + 17: 'Malware', + 18: 'Spyware', +} + +// Decodes threat category bitmask into comma-separated category names; 0 → '' +export function decodeThreatCategories(value) { + if (!value) return '' + const bits = Object.keys(threatCategoryMap) + return bits + .filter(bit => Math.pow(2, Number(bit)) & value) + .map(bit => threatCategoryMap[bit]) + .join(', ') +} + +// icmp_type integer → IANA ICMP type name; exported so detailFormatters share the same map. +// Fallback 'Unassigned' for null/unknown values. +export const icmpTypeMap = { + 0: 'Echo Reply', + 1: 'Unassigned', + 2: 'Unassigned', + 3: 'Destination Unreachable', + 4: 'Source Quench (Deprecated)', + 5: 'Redirect', + 6: 'Alternate Host Address (Deprecated)', + 7: 'Unassigned', + 8: 'Echo', + 9: 'Router Advertisement', + 10: 'Router Solicitation', + 11: 'Time Exceeded', + 12: 'Parameter Problem', + 13: 'Timestamp', + 14: 'Timestamp Reply', + 15: 'Information Request (Deprecated)', + 16: 'Information Reply (Deprecated)', + 17: 'Address Mask Request (Deprecated)', + 18: 'Address Mask Reply (Deprecated)', + 19: 'Reserved (for Security)', + 20: 'Reserved (for Robustness Experiment)', + 21: 'Reserved (for Robustness Experiment)', + 22: 'Reserved (for Robustness Experiment)', + 23: 'Reserved (for Robustness Experiment)', + 24: 'Reserved (for Robustness Experiment)', + 25: 'Reserved (for Robustness Experiment)', + 26: 'Reserved (for Robustness Experiment)', + 27: 'Reserved (for Robustness Experiment)', + 28: 'Reserved (for Robustness Experiment)', + 29: 'Reserved (for Robustness Experiment)', + 30: 'Traceroute (Deprecated)', + 31: 'Datagram Conversion Error (Deprecated)', + 32: 'Mobile Host Redirect (Deprecated)', + 33: 'IPv6 Where-Are-You (Deprecated)', + 34: 'IPv6 I-Am-Here (Deprecated)', + 35: 'Mobile Registration Request (Deprecated)', + 36: 'Mobile Registration Reply (Deprecated)', + 37: 'Domain Name Request (Deprecated)', + 38: 'Domain Name Reply (Deprecated)', + 39: 'SKIP (Deprecated)', + 40: 'Photuris', + 41: 'ICMP messages utilized by experimental mobility protocols', + 253: 'RFC3692-style Experiment 1', + 254: 'RFC3692-style Experiment 2', +} + +// Static lookup table that maps web filter category IDs (integers) to human-readable category names. +// Used by the web_filter_category_id column formatter and details panel renderer. +// Unknown IDs fall back to the raw numeric string; null is handled by the caller. +export const webCategoryMap = { + 0: 'Uncategorized', + 1: 'Real Estate', + 2: 'Computer and Internet Security', + 3: 'Financial Services', + 4: 'Business and Economy', + 5: 'Computer and Internet Info', + 6: 'Auctions', + 7: 'Shopping', + 8: 'Cult and Occult', + 9: 'Travel', + 10: 'Abused Drugs', + 11: 'Adult and Pornography', + 12: 'Home and Garden', + 13: 'Military', + 14: 'Social Networking', + 15: 'Dead Sites', + 16: 'Individual Stock Advice and Tools', + 17: 'Training and Tools', + 18: 'Dating', + 19: 'Sex Education', + 20: 'Religion', + 21: 'Entertainment and Arts', + 22: 'Personal sites and Blogs', + 23: 'Legal', + 24: 'Local Information', + 25: 'Streaming Media', + 26: 'Job Search', + 27: 'Gambling', + 28: 'Translation', + 29: 'Reference and Research', + 30: 'Shareware and Freeware', + 31: 'Peer to Peer', + 32: 'Marijuana', + 33: 'Hacking', + 34: 'Games', + 35: 'Philosophy and Political Advocacy', + 36: 'Weapons', + 37: 'Pay to Surf', + 38: 'Hunting and Fishing', + 39: 'Society', + 40: 'Educational Institutions', + 41: 'Online Greeting Cards', + 42: 'Sports', + 43: 'Swimsuits and Intimate Apparel', + 44: 'Questionable', + 45: 'Kids', + 46: 'Hate and Racism', + 47: 'Personal Storage', + 48: 'Violence', + 49: 'Keyloggers and Monitoring', + 50: 'Search Engines', + 51: 'Internet Portals', + 52: 'Web Advertisements', + 53: 'Cheating', + 54: 'Gross', + 55: 'Web-based Email', + 56: 'Malware Sites', + 57: 'Phishing and Other Frauds', + 58: 'Proxy Avoidance and Anonymizers', + 59: 'Spyware and Adware', + 60: 'Music', + 61: 'Government', + 62: 'Nudity', + 63: 'News and Media', + 64: 'Illegal', + 65: 'Content Delivery Networks', + 66: 'Internet Communications', + 67: 'Bot Nets', + 68: 'Abortion', + 69: 'Health and Medicine', + 71: 'SPAM URLs', + 74: 'Dynamically Generated Content', + 75: 'Parked Domains', + 76: 'Alcohol and Tobacco', + 78: 'Image and Video Search', + 79: 'Fashion and Beauty', + 80: 'Recreation and Hobbies', + 81: 'Motor Vehicles', + 82: 'Web Hosting', +} + +// Boolean formatter — null/undefined (SQL NULL absent from row) → 'false', true → 'true', false → 'false' +const boolFormatter = ({ value }) => (value == null ? 'false' : value ? 'true' : 'false') + +// ─── Rendering metadata ───────────────────────────────────────────────────── +// Only fields that need special width, sort, or valueFormatter are listed. +// All other fields get { flex: 1 } from buildEventColumnDefs. + +const FIELD_META = { + // time_stamp and second-based timestamps have timezone-aware formatters + // built dynamically in buildEventColumnDefs using tzOffsetMs. + time_stamp: { width: 160, sort: 'desc' }, + // Byte fields — human-readable sizes + c2p_bytes: { width: 100, valueFormatter: ({ value }) => formatBytes(value) }, + c2s_bytes: { width: 100, valueFormatter: ({ value }) => formatBytes(value) }, + s2c_bytes: { width: 100, valueFormatter: ({ value }) => formatBytes(value) }, + s2p_bytes: { width: 100, valueFormatter: ({ value }) => formatBytes(value) }, + p2c_bytes: { width: 100, valueFormatter: ({ value }) => formatBytes(value) }, + p2s_bytes: { width: 100, valueFormatter: ({ value }) => formatBytes(value) }, + in_bytes: { width: 100, valueFormatter: ({ value }) => formatBytes(value) }, + out_bytes: { width: 100, valueFormatter: ({ value }) => formatBytes(value) }, + rx_bytes: { width: 100, valueFormatter: ({ value }) => formatBytes(value) }, + tx_bytes: { width: 100, valueFormatter: ({ value }) => formatBytes(value) }, + hit_bytes: { width: 100, valueFormatter: ({ value }) => formatBytes(value) }, + miss_bytes: { width: 100, valueFormatter: ({ value }) => formatBytes(value) }, + // Protocol — number → display name + protocol: { + width: 90, + valueFormatter: ({ value }) => protocolNameMap[value] ?? String(value ?? ''), + }, + // Generic boolean fields — null/undefined (SQL NULL) → 'false', true → 'true' + blocked: { width: 80, valueFormatter: boolFormatter }, + flagged: { width: 80, valueFormatter: boolFormatter }, + bypassed: { width: 80, valueFormatter: boolFormatter }, + entitled: { width: 80, valueFormatter: boolFormatter }, + success: { width: 80, valueFormatter: boolFormatter }, + + // ICMP type number → IANA name; null/unknown → 'Unassigned' (even for TCP/UDP rows) + icmp_type: { width: 150, valueFormatter: ({ value }) => icmpTypeMap[value] ?? 'Unassigned' }, + + // HTTP method single-char code → full name; used in http_events, ftp_events, http_query_events + method: { width: 80, valueFormatter: ({ value }) => (value == null ? '' : httpMethodMap[value] ?? String(value)) }, + + // captive_portal auth_type → readable string; unknown → 'Unknown' + auth_type: { width: 150, valueFormatter: ({ value }) => (value == null ? '' : authTypeMap[value] ?? 'Unknown') }, + + // captive_portal event_info → readable string; null/'' → '', unknown → 'Unknown' + event_info: { + width: 150, + valueFormatter: ({ value }) => (value == null || value === '' ? '' : captivePortalEventMap[value] ?? 'Unknown'), + }, + + // App-specific boolean fields — null/undefined → 'false', true → 'true' + firewall_blocked: { width: 80, valueFormatter: boolFormatter }, + firewall_flagged: { width: 80, valueFormatter: boolFormatter }, + application_control_blocked: { width: 80, valueFormatter: boolFormatter }, + application_control_flagged: { width: 80, valueFormatter: boolFormatter }, + application_control_lite_blocked: { width: 80, valueFormatter: boolFormatter }, + captive_portal_blocked: { width: 80, valueFormatter: boolFormatter }, + phish_blocker_is_spam: { width: 80, valueFormatter: boolFormatter }, + spam_blocker_is_spam: { width: 80, valueFormatter: boolFormatter }, + spam_blocker_lite_is_spam: { width: 80, valueFormatter: boolFormatter }, + virus_blocker_clean: { width: 80, valueFormatter: boolFormatter }, + virus_blocker_lite_clean: { width: 80, valueFormatter: boolFormatter }, + web_filter_blocked: { width: 80, valueFormatter: boolFormatter }, + web_filter_flagged: { width: 80, valueFormatter: boolFormatter }, + threat_prevention_blocked: { width: 80, valueFormatter: boolFormatter }, + threat_prevention_flagged: { width: 80, valueFormatter: boolFormatter }, + // Server load fields + load_1: { width: 100 }, + load_5: { width: 80 }, + load_15: { width: 80 }, + // Numeric rate fields + rx_rate: { width: 100 }, + tx_rate: { width: 100 }, + // JSON field (alerts table) — backend HTML-encodes embedded quotes; decode explicitly for AG-Grid text nodes + json: { + flex: 1, + valueFormatter: ({ value }) => { + if (!value) return '' + const s = String(value) + return s + .replace(/"/g, '"') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/'/g, "'") + }, + }, + // Wide text fields + uri: { width: 250 }, + host: { width: 200 }, + domain: { width: 180 }, + description: { width: 200 }, + summary_text: { width: 200 }, + msg: { width: 180 }, + operation: { width: 200 }, + // connect/goodbye/start/end are java.sql.Timestamp in DB — normalizeTimestampsInRow converts to epoch ms + connect_stamp: { width: 150 }, + goodbye_stamp: { width: 150 }, + start_time: { width: 150 }, + end_time: { width: 150 }, + // Boolean fields with human-readable labels + local: { width: 80, valueFormatter: ({ value }) => (value ? 'local' : 'remote') }, + succeeded: { width: 80, valueFormatter: ({ value }) => (value ? 'success' : 'failed') }, + + // bandwidth_control_priority 0-7 → i18n Title Case name; 0/null → '' (no priority assigned) + bandwidth_control_priority: { + width: 120, + valueFormatter: ({ value }) => (priorityMap[value] ? i18n.t(priorityMap[value]) : ''), + }, + + // bandwidth_control_rule — 0/null → 'None', else raw rule number + bandwidth_control_rule: { + width: 120, + valueFormatter: ({ value }) => (value == null || value === 0 ? i18n.t('none') : String(value)), + }, + + // ad_blocker_action — 'B' → 'Block', else 'Pass'; null/'' → '' + ad_blocker_action: { + width: 80, + valueFormatter: ({ value }) => + value == null || value === '' ? '' : value === 'B' ? i18n.t('block') : i18n.t('pass'), + }, + + // spam/phish blocker action codes → i18n label + code (e.g. "Pass Message [P]"); null/'' → '', unknown → 'Unknown Action [code]' + spam_blocker_action: { + valueFormatter: ({ value }) => + value == null || value === '' + ? '' + : emailActionMap[value] + ? `${i18n.t(emailActionMap[value])} [${value}]` + : `${i18n.t('unknown_action')} [${value}]`, + }, + spam_blocker_lite_action: { + valueFormatter: ({ value }) => + value == null || value === '' + ? '' + : emailActionMap[value] + ? `${i18n.t(emailActionMap[value])} [${value}]` + : `${i18n.t('unknown_action')} [${value}]`, + }, + phish_blocker_action: { + valueFormatter: ({ value }) => + value == null || value === '' + ? '' + : emailActionMap[value] + ? `${i18n.t(emailActionMap[value])} [${value}]` + : `${i18n.t('unknown_action')} [${value}]`, + }, + + // Memory fields — always MB, no auto-scaling + mem_free: { width: 100, valueFormatter: ({ value }) => formatMemoryMB(value) }, + mem_total: { width: 100, valueFormatter: ({ value }) => formatMemoryMB(value) }, + swap_free: { width: 100, valueFormatter: ({ value }) => formatMemoryMB(value) }, + swap_total: { width: 100, valueFormatter: ({ value }) => formatMemoryMB(value) }, + + // Disk fields — always GB, no auto-scaling + disk_free: { width: 100, valueFormatter: ({ value }) => formatDiskGB(value) }, + disk_total: { width: 100, valueFormatter: ({ value }) => formatDiskGB(value) }, + + // web_filter_reason code → readable label; null/unknown → 'No Rule Applied' + web_filter_reason: { + valueFormatter: ({ value }) => + webFilterReasonMap[value] ? i18n.t(webFilterReasonMap[value]) : i18n.t('no_rule_applied'), + }, + + // Renders web_filter_category_id as a human-readable category name using webCategoryMap. + // null renders as empty string; unknown IDs fall back to the raw numeric string. + web_filter_category_id: { + valueFormatter: ({ value }) => (value == null ? '' : webCategoryMap[value] ?? String(value)), + }, + + // Threat prevention reason — same lookup as web_filter_reason; null/unknown → 'No Rule Applied' + threat_prevention_reason: { + valueFormatter: ({ value }) => + webFilterReasonMap[value] ? i18n.t(webFilterReasonMap[value]) : i18n.t('no_rule_applied'), + }, + + // Threat prevention rule ID — 0/null → '' (no rule assigned); else raw numeric ID + threat_prevention_rule_id: { + width: 120, + valueFormatter: ({ value }) => (value == null || value === 0 ? '' : String(value)), + }, + + // Threat prevention category bitmask → comma-separated category names + threat_prevention_client_categories: { + width: 150, + valueFormatter: ({ value }) => decodeThreatCategories(value), + }, + threat_prevention_server_categories: { + width: 150, + valueFormatter: ({ value }) => decodeThreatCategories(value), + }, + + // Threat prevention reputation score — 0/null → '', else raw score + threat_prevention_client_reputation: { + width: 150, + valueFormatter: ({ value }) => (value == null || value === 0 ? '' : String(value)), + }, + threat_prevention_server_reputation: { + width: 150, + valueFormatter: ({ value }) => (value == null || value === 0 ? '' : String(value)), + }, + + // Tags — JSON object { list: [{name}] } or raw JSON string; join tag names with space + tags: { + valueFormatter: ({ value }) => { + if (!value) return '' + try { + const parsed = typeof value === 'string' ? JSON.parse(value) : value + if (parsed?.list?.length > 0) { + return parsed.list + .map(t => String(t.name || '').trim()) + .filter(Boolean) + .join(' ') + } + } catch {} + return '' + }, + }, + + // Settings file path — strip the leading /usr/share/untangle/settings/ prefix + settings_file: { + width: 200, + valueFormatter: ({ value }) => + String(value ?? '') + .replace(/^.*\/settings\//, '') + .replace(/^.*\/conf\//, ''), + }, + + // Address fields + c_client_addr: { width: 130 }, + c_server_addr: { width: 130 }, + s_client_addr: { width: 130 }, + s_server_addr: { width: 130 }, + c_client_port: { width: 110 }, + c_server_port: { width: 110 }, + s_client_port: { width: 110 }, + s_server_port: { width: 110 }, + source_addr: { width: 130 }, + dest_addr: { width: 130 }, + remote_addr: { width: 130 }, + local_addr: { width: 130 }, + peer_address: { width: 130 }, + pool_address: { width: 130 }, + client_addr: { width: 130 }, + address: { width: 130 }, +} + +// ─── Null defaults ────────────────────────────────────────────────────────── +// When a field is SQL NULL it is absent from the row object. +// These defaults ensure the details panel shows false/0 for boolean/integer fields +// instead of empty string, matching the model field type definitions. +export const fieldNullDefaults = { + // Boolean fields (fld: { type: 'boolean' }) → false + blocked: false, + bypassed: false, + entitled: false, + flagged: false, + local: false, + succeeded: false, + success: false, + application_control_blocked: false, + application_control_flagged: false, + application_control_lite_blocked: false, + captive_portal_blocked: false, + firewall_blocked: false, + firewall_flagged: false, + phish_blocker_is_spam: false, + spam_blocker_is_spam: false, + spam_blocker_lite_is_spam: false, + virus_blocker_clean: false, + virus_blocker_lite_clean: false, + web_filter_blocked: false, + web_filter_flagged: false, + threat_prevention_blocked: false, + threat_prevention_flagged: false, + // Integer/number app rule ID and score fields (fld: { type: 'integer' }) → 0 + application_control_ruleid: 0, + application_control_confidence: 0, + firewall_rule_index: 0, + captive_portal_rule_index: 0, + bandwidth_control_priority: 0, + bandwidth_control_rule: 0, + ssl_inspector_ruleid: 0, + web_filter_rule_id: 0, + policy_id: 0, + policy_rule_id: 0, + threat_prevention_rule_id: 0, + threat_prevention_client_reputation: 0, + threat_prevention_client_categories: 0, + threat_prevention_server_reputation: 0, + threat_prevention_server_categories: 0, +} + +// ─── Table → ordered field list ───────────────────────────────────────────── +// Field names are actual DB column names. + +export const tableFields = { + admin_logins: ['time_stamp', 'login', 'local', 'client_addr', 'succeeded', 'reason'], + alerts: ['time_stamp', 'description', 'summary_text', 'json'], + captive_portal_user_events: [ + 'time_stamp', + 'policy_id', + 'event_id', + 'login_name', + 'event_info', + 'auth_type', + 'client_addr', + ], + configuration_backup_events: ['time_stamp', 'success', 'description', 'destination', 'event_id'], + device_table_updates: ['time_stamp', 'mac_address', 'key', 'value', 'old_value'], + directory_connector_login_events: ['time_stamp', 'login_name', 'domain', 'type', 'client_addr'], + ftp_events: [ + 'time_stamp', + 'event_id', + 'session_id', + 'client_intf', + 'server_intf', + 'c_client_addr', + 's_client_addr', + 'c_server_addr', + 's_server_addr', + 'policy_id', + 'username', + 'hostname', + 'request_id', + 'method', + 'uri', + ], + host_table_updates: ['time_stamp', 'address', 'key', 'value', 'old_value'], + interface_stat_events: ['time_stamp', 'interface_id', 'rx_rate', 'rx_bytes', 'tx_rate', 'tx_bytes'], + ipsec_user_events: [ + 'event_id', + 'time_stamp', + 'connect_stamp', + 'goodbye_stamp', + 'client_address', + 'client_protocol', + 'client_username', + 'net_process', + 'net_interface', + 'elapsed_time', + 'rx_bytes', + 'tx_bytes', + ], + ipsec_vpn_events: ['event_id', 'time_stamp', 'local_address', 'remote_address', 'tunnel_description', 'event_type'], + ipsec_tunnel_stats: ['time_stamp', 'tunnel_name', 'in_bytes', 'out_bytes', 'event_id'], + wireguard_vpn_stats: ['time_stamp', 'tunnel_name', 'peer_address', 'in_bytes', 'out_bytes', 'event_id'], + wireguard_vpn_events: ['event_id', 'time_stamp', 'tunnel_name', 'event_type'], + intrusion_prevention_events: [ + 'time_stamp', + 'sig_id', + 'gen_id', + 'class_id', + 'source_addr', + 'source_port', + 'dest_addr', + 'dest_port', + 'protocol_name', + 'blocked', + 'category', + 'classtype', + 'msg', + 'rid', + 'rule_id', + ], + openvpn_events: ['time_stamp', 'remote_address', 'pool_address', 'client_name', 'type'], + openvpn_stats: [ + 'time_stamp', + 'start_time', + 'end_time', + 'rx_bytes', + 'tx_bytes', + 'remote_address', + 'pool_address', + 'remote_port', + 'client_name', + 'event_id', + ], + quotas: ['time_stamp', 'entity', 'action', 'size', 'reason'], + server_events: [ + 'time_stamp', + 'load_1', + 'load_5', + 'load_15', + 'cpu_user', + 'cpu_system', + 'mem_total', + 'mem_free', + 'disk_total', + 'disk_free', + 'swap_total', + 'swap_free', + 'active_hosts', + ], + settings_changes: ['time_stamp', 'settings_file', 'username', 'hostname'], + system_operations: ['time_stamp', 'operation', 'username', 'hostname'], + critical_alerts: ['time_stamp', 'component', 'message', 'problem'], + smtp_tarpit_events: ['time_stamp', 'ipaddr', 'hostname', 'policy_id', 'vendor_name', 'event_id'], + tunnel_vpn_events: ['event_id', 'time_stamp', 'tunnel_name', 'server_address', 'local_address', 'event_type'], + tunnel_vpn_stats: ['time_stamp', 'tunnel_name', 'in_bytes', 'out_bytes', 'event_id'], + user_table_updates: ['time_stamp', 'username', 'key', 'value', 'old_value'], + wan_failover_action_events: ['time_stamp', 'interface_id', 'action', 'os_name', 'name', 'event_id'], + wan_failover_test_events: ['time_stamp', 'interface_id', 'name', 'description', 'success', 'event_id'], + web_cache_stats: ['time_stamp', 'hits', 'misses', 'bypasses', 'systems', 'hit_bytes', 'miss_bytes', 'event_id'], + http_events: [ + 'request_id', + 'time_stamp', + 'session_id', + 'client_intf', + 'server_intf', + 'c_client_addr', + 's_client_addr', + 'c_server_addr', + 's_server_addr', + 'c_client_port', + 's_client_port', + 'c_server_port', + 's_server_port', + 'client_country', + 'client_latitude', + 'client_longitude', + 'server_country', + 'server_latitude', + 'server_longitude', + 'policy_id', + 'username', + 'hostname', + 'method', + 'host', + 'uri', + 'domain', + 'referer', + 'c2s_content_length', + 's2c_content_length', + 's2c_content_type', + 's2c_content_filename', + 'ad_blocker_cookie_ident', + 'ad_blocker_action', + 'web_filter_reason', + 'web_filter_category_id', + 'web_filter_rule_id', + 'web_filter_blocked', + 'web_filter_flagged', + 'virus_blocker_lite_clean', + 'virus_blocker_lite_name', + 'virus_blocker_clean', + 'virus_blocker_name', + // Threat Prevention fields + 'threat_prevention_blocked', + 'threat_prevention_flagged', + 'threat_prevention_reason', + 'threat_prevention_rule_id', + 'threat_prevention_client_reputation', + 'threat_prevention_client_categories', + 'threat_prevention_server_reputation', + 'threat_prevention_server_categories', + ], + http_query_events: [ + 'event_id', + 'time_stamp', + 'session_id', + 'client_intf', + 'server_intf', + 'c_client_addr', + 's_client_addr', + 'c_server_addr', + 's_server_addr', + 'c_client_port', + 's_client_port', + 'c_server_port', + 's_server_port', + 'policy_id', + 'username', + 'hostname', + 'request_id', + 'method', + 'term', + 'host', + 'uri', + 'c2s_content_length', + 's2c_content_length', + 's2c_content_type', + 'blocked', + 'flagged', + 'web_filter_reason', + ], + mail_addrs: [ + 'time_stamp', + 'session_id', + 'server_intf', + 'c_client_addr', + 'c_server_addr', + 'c_client_port', + 'c_server_port', + 's_client_addr', + 's_server_addr', + 's_client_port', + 's_server_port', + 'policy_id', + 'username', + 'msg_id', + 'subject', + 'addr', + 'addr_name', + 'addr_kind', + 'hostname', + 'event_id', + 'sender', + 'virus_blocker_lite_clean', + 'virus_blocker_lite_name', + 'virus_blocker_clean', + 'virus_blocker_name', + 'spam_blocker_lite_score', + 'spam_blocker_lite_is_spam', + 'spam_blocker_lite_action', + 'spam_blocker_lite_tests_string', + 'spam_blocker_score', + 'spam_blocker_is_spam', + 'spam_blocker_action', + 'spam_blocker_tests_string', + 'phish_blocker_score', + 'phish_blocker_is_spam', + 'phish_blocker_tests_string', + 'phish_blocker_action', + ], + session_minutes: [ + 'session_id', + 'time_stamp', + 'c2s_bytes', + 's2c_bytes', + 'start_time', + 'end_time', + 'bypassed', + 'entitled', + 'protocol', + 'icmp_type', + 'hostname', + 'username', + 'policy_id', + 'policy_rule_id', + 'local_addr', + 'remote_addr', + 'c_client_addr', + 'c_server_addr', + 'c_client_port', + 'c_server_port', + 's_client_addr', + 's_server_addr', + 's_client_port', + 's_server_port', + 'client_intf', + 'server_intf', + 'client_country', + 'client_latitude', + 'client_longitude', + 'server_country', + 'server_latitude', + 'server_longitude', + 'filter_prefix', + 'firewall_blocked', + 'firewall_flagged', + 'firewall_rule_index', + 'application_control_lite_protocol', + 'application_control_lite_blocked', + 'captive_portal_blocked', + 'captive_portal_rule_index', + 'application_control_application', + 'application_control_protochain', + 'application_control_category', + 'application_control_blocked', + 'application_control_flagged', + 'application_control_confidence', + 'application_control_ruleid', + 'application_control_detail', + 'bandwidth_control_priority', + 'bandwidth_control_rule', + 'ssl_inspector_ruleid', + 'ssl_inspector_status', + 'ssl_inspector_detail', + // Threat Prevention fields + 'threat_prevention_blocked', + 'threat_prevention_flagged', + 'threat_prevention_reason', + 'threat_prevention_rule_id', + 'threat_prevention_client_reputation', + 'threat_prevention_client_categories', + 'threat_prevention_server_reputation', + 'threat_prevention_server_categories', + 'tags', + ], + sessions: [ + 'session_id', + 'time_stamp', + 'end_time', + 'bypassed', + 'entitled', + 'protocol', + 'icmp_type', + 'hostname', + 'username', + 'policy_id', + 'policy_rule_id', + 'local_addr', + 'remote_addr', + 'c_client_addr', + 'c_server_addr', + 'c_client_port', + 'c_server_port', + 's_client_addr', + 's_server_addr', + 's_client_port', + 's_server_port', + 'client_intf', + 'server_intf', + 'client_country', + 'client_latitude', + 'client_longitude', + 'server_country', + 'server_latitude', + 'server_longitude', + 'c2p_bytes', + 'p2c_bytes', + 's2p_bytes', + 'p2s_bytes', + 'filter_prefix', + 'firewall_blocked', + 'firewall_flagged', + 'firewall_rule_index', + 'application_control_lite_protocol', + 'application_control_lite_blocked', + 'captive_portal_blocked', + 'captive_portal_rule_index', + 'application_control_application', + 'application_control_protochain', + 'application_control_category', + 'application_control_blocked', + 'application_control_flagged', + 'application_control_confidence', + 'application_control_ruleid', + 'application_control_detail', + 'bandwidth_control_priority', + 'bandwidth_control_rule', + 'ssl_inspector_ruleid', + 'ssl_inspector_status', + 'ssl_inspector_detail', + // Threat Prevention fields + 'threat_prevention_blocked', + 'threat_prevention_flagged', + 'threat_prevention_reason', + 'threat_prevention_rule_id', + 'threat_prevention_client_reputation', + 'threat_prevention_client_categories', + 'threat_prevention_server_reputation', + 'threat_prevention_server_categories', + 'tags', + ], +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function formatBytes(value) { + if (value === null || value === undefined) return '' + const n = Number(value) + if (n === 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(Math.abs(n)) / Math.log(1024)) + return (n / Math.pow(1024, i)).toFixed(1) + ' ' + units[Math.min(i, units.length - 1)] +} + +/** + * Formats a byte value as megabytes — always MB, no auto-scaling. + * Used for mem_free, mem_total, swap_free, swap_total. + */ +export function formatMemoryMB(value) { + if (value == null) return '' + const meg = Number(value) / 1024 / 1024 + return `${Math.round(meg * 10) / 10} MB` +} + +/** + * Formats a byte value as gigabytes — always GB, no auto-scaling. + * Used for disk_free, disk_total. + */ +export function formatDiskGB(value) { + if (value == null) return '' + const gig = Number(value) / 1024 / 1024 / 1024 + return `${Math.round(gig * 10) / 10} GB` +} + +/** + * Formats an epoch-ms timestamp as YYYY-MM-DD hh:mm:ss am/pm in server local time. + * Applies tzOffsetMs so the displayed time matches the server's timezone. + * + * @param {number} ms - epoch milliseconds from the backend + * @param {number} tzOffsetMs - server timezone offset in ms + */ +function formatTimestamp(ms, tzOffsetMs = 0) { + if (!ms) return '' + const d = new Date(ms + tzOffsetMs) + const Y = d.getUTCFullYear() + const m = String(d.getUTCMonth() + 1).padStart(2, '0') + const dd = String(d.getUTCDate()).padStart(2, '0') + let h = d.getUTCHours() + const min = String(d.getUTCMinutes()).padStart(2, '0') + const s = String(d.getUTCSeconds()).padStart(2, '0') + const ampm = h >= 12 ? 'pm' : 'am' + h = h % 12 || 12 + return `${Y}-${m}-${dd} ${String(h).padStart(2, '0')}:${min}:${s} ${ampm}` +} + +// ─── Public API ───────────────────────────────────────────────────────────── + +/** + * Builds an AG-Grid columnDefs array for the given event table. + * + * - Column order follows tableFields[table]. + * - Columns in entry.defaultColumns are visible; all others are hidden. + * - headerName is resolved via i18n.t(field) at call time. + * - Timestamp formatters use tzOffsetMs to show server local time. + * - Special rendering (formatters, widths) comes from FIELD_META[field]. + * - interfaceNameMap / policyNameMap are runtime-resolved maps for displaying + * interface and policy names instead of raw IDs. + * + * @param {string} table - DB table name, e.g. 'sessions', 'http_events' + * @param {string[]} defaultColumns - from entry.defaultColumns + * @param {number} tzOffsetMs - server timezone offset in ms + * @param {Object} interfaceNameMap - { interfaceId: 'name [id]' } from config store + * @param {Object} policyNameMap - { policyId: 'name [id]' } from reports store + * @returns {Array} AG-Grid columnDef objects + */ +export function buildEventColumnDefs(table, defaultColumns, tzOffsetMs = 0, interfaceNameMap = {}, policyNameMap = {}) { + const fields = tableFields[table] || [] + const visible = new Set(defaultColumns || []) + + // Build timestamp field meta with timezone-aware formatters + const timestampMeta = { + time_stamp: { + width: 160, + sort: 'desc', + valueFormatter: ({ value }) => formatTimestamp(value, tzOffsetMs), + }, + connect_stamp: { width: 150, valueFormatter: ({ value }) => (value ? formatTimestamp(value, tzOffsetMs) : '') }, + goodbye_stamp: { width: 150, valueFormatter: ({ value }) => (value ? formatTimestamp(value, tzOffsetMs) : '') }, + start_time: { width: 150, valueFormatter: ({ value }) => (value ? formatTimestamp(value, tzOffsetMs) : '') }, + end_time: { width: 150, valueFormatter: ({ value }) => (value ? formatTimestamp(value, tzOffsetMs) : '') }, + } + + // Dynamic formatters that close over runtime maps — applied after FIELD_META so they take precedence. + const dynamicMeta = { + // 0 and -1 are treated as unset → 'None' + client_intf: { + width: 100, + valueFormatter: ({ value }) => + !value || value === -1 ? i18n.t('none') : interfaceNameMap[value] ?? String(value), + }, + server_intf: { + width: 100, + valueFormatter: ({ value }) => + !value || value === -1 ? i18n.t('none') : interfaceNameMap[value] ?? String(value), + }, + interface_id: { + width: 100, + valueFormatter: ({ value }) => + !value || value === -1 ? i18n.t('none') : interfaceNameMap[value] ?? String(value), + }, + // 0 → 'None', null/not-in-map → '', else mapped name + policy_id: { + width: 120, + valueFormatter: ({ value }) => (value == null || value === 0 ? i18n.t('none') : policyNameMap[value] || ''), + }, + } + + // Table-specific formatters — fields whose meaning differs across tables. + // Only injected for the specific table where the converter applies. + const tableMeta = {} + + // admin_logins.reason — single-char login reason codes → i18n label + // quotas.reason is already human-readable, so this only applies for admin_logins. + if (table === 'admin_logins') { + tableMeta.reason = { + valueFormatter: ({ value }) => (loginReasonMap[value] ? i18n.t(loginReasonMap[value]) : ''), + } + } + + // quotas.action — 1→'Given', 2→'Exceeded' + // wan_failover_action_events.action contains different string values — not converted. + if (table === 'quotas') { + tableMeta.action = { + valueFormatter: ({ value }) => (value == null ? '' : quotaActionMap[value] ?? 'Unknown'), + } + } + + // directory_connector_login_events.type — I/U/O/A → login/update/logout/authenticate + // Other VPN event tables use 'type' with already-readable values — no conversion needed. + if (table === 'directory_connector_login_events') { + tableMeta.type = { + valueFormatter: ({ value }) => (value == null ? '' : directoryConnectorActionMap[value] ?? 'unknown'), + } + } + + return fields.map(field => { + const colDef = { + field, + headerName: i18n.t(field), + hide: !visible.has(field), + flex: 1, + ...(FIELD_META[field] || {}), + ...(dynamicMeta[field] || {}), // runtime-resolved overrides static FIELD_META + ...(tableMeta[field] || {}), // table-specific overrides (e.g. reason for admin_logins) + ...(timestampMeta[field] || {}), // timestamp formatters always win + } + + // AG-Grid ignores `width` when `flex` is also set. + // If an explicit width was contributed by any meta layer, drop flex so the + // width is actually respected in the grid layout. + if (colDef.width) colDef.flex = 0 + + return colDef + }) +} diff --git a/untangle-vue-ui/source/src/util/reportViews.js b/untangle-vue-ui/source/src/util/reportViews.js new file mode 100644 index 0000000000..5a7d94cc69 --- /dev/null +++ b/untangle-vue-ui/source/src/util/reportViews.js @@ -0,0 +1,336 @@ +/** + * Report view translation layer. + * Converts backend report entries into view configs consumed by the display components. + */ + +import { + buildEventColumnDefs, + tableFields, + fieldNullDefaults, + loginReasonMap, + emailActionMap, + priorityMap, + webFilterReasonMap, + webCategoryMap, + icmpTypeMap, + httpMethodMap, + authTypeMap, + captivePortalEventMap, + quotaActionMap, + directoryConnectorActionMap, + decodeThreatCategories, + formatMemoryMB, + formatDiskGB, +} from './eventTableColumns' +import i18n from '@/plugins/vue-i18n' +import { protocolNameMap } from '@/constants' + +// ─── Static code→name maps for detail panel formatters ───────────────────── +// These maps are shared with the column registry — single source of truth. +// Map values are i18n keys resolved to Title Case display strings at render time. + +/** + * Maps a time-based chart style to the corresponding chart type. + * Bar variants map to 'column'; area variants to 'areaspline'; line to 'spline'. + */ +function timeStyleToChartType(timeStyle) { + if (!timeStyle) return 'column' + if (timeStyle === 'LINE') return 'spline' + if (timeStyle.startsWith('AREA')) return 'areaspline' + return 'column' // BAR, BAR_OVERLAPPED, BAR_STACKED, BAR_3D* +} + +/** + * Maps a pie/distribution chart style to the corresponding chart type. + */ +function pieStyleToChartType(pieStyle) { + if (!pieStyle) return 'pie' + if (pieStyle === 'COLUMN' || pieStyle === 'COLUMN_3D') return 'column' + return 'pie' // PIE, PIE_3D, DONUT, DONUT_3D +} + +/** + * Builds the report config for a text-type report. + * Sets the key from the unique identifier so data fetch events can match the entry. + * + * @param {Object} entry - report entry definition + * @returns {Object} report config for the text display component + */ +function translateTextEntry(entry) { + return { + ...entry, + key: entry.uniqueId, + query: {}, // no pagination — TEXT reports are not paginated + } +} + +/** + * Builds the rendering config for a time-series chart report. + * Maps time style variants to chart type, stacking, and data grouping options. + */ +function translateTimeGraphEntry(entry) { + const timeStyle = entry.timeStyle || '' + return { + key: entry.uniqueId, + title: entry.title, + query: {}, + rendering: { + type: timeStyleToChartType(timeStyle), + units: entry.units, + stacking: timeStyle.includes('STACKED') ? 'normal' : undefined, + overlapped: timeStyle.includes('OVERLAPPED'), + dataGroupingApproximation: entry.approximation || 'sum', + groupPixelWidth: 8, + colors: Array.isArray(entry.colors) ? entry.colors.join(',') : entry.colors, + }, + } +} + +/** + * Builds the rendering config for a dynamic time-series chart report. + * Extends the base time-series config with a grouping column for dynamic series discovery. + */ +function translateTimeGraphDynamicEntry(entry) { + const base = translateTimeGraphEntry(entry) + return { + ...base, + query: { + queryCategories: { + groupColumn: entry.timeDataDynamicColumn, + }, + }, + } +} + +/** + * Builds the config for an event list report. + * Column defs are assembled from the column registry based on entry.table and entry.defaultColumns. + * + * @param {Object} entry - report entry definition + * @param {number} tzOffsetMs - server timezone offset in ms + * @param {Object} interfaceNameMap - { interfaceId: 'name [id]' } — from config store + * @param {Object} policyNameMap - { policyId: 'name [id]' } — from reports store + * @param {Object} callbacks - host-app supplied action callbacks keyed by table/action + * callbacks.onShowDiff(fileName) — called when user clicks 🔍 on a settings_changes row + * @returns {Object} report config for GenericEventList + */ +function translateEventListEntry(entry, tzOffsetMs, interfaceNameMap = {}, policyNameMap = {}, callbacks = {}) { + // Table-specific row actions — only injected when the host app supplies the relevant callback. + // 'differences' is a virtual non-SQL action column for row-level diff inspection. + const rowActions = [] + if (entry.table === 'settings_changes' && typeof callbacks.onShowDiff === 'function') { + rowActions.push({ + icon: 'mdi-magnify', + tooltip: 'Show difference between previous version', + handler: ({ data }) => callbacks.onShowDiff(data.settings_file), + }) + } + + return { + key: entry.uniqueId, + title: entry.title, + category: entry.category, // needed for export filename: "{category}-{title}_date.csv" + columns: buildEventColumnDefs(entry.table, entry.defaultColumns, tzOffsetMs, interfaceNameMap, policyNameMap), + tzOffsetMs: tzOffsetMs || 0, + // Row actions — undefined when empty so UGrid skips the action column setup + rowActions: rowActions.length ? rowActions : undefined, + // Full ordered field list for this table — used by GenericEventList to iterate ALL + // schema fields in the details panel, including those that are SQL NULL in the row + // (SQL NULLs are stripped by org.json on the backend so they are absent from the + // row object; iterating tableFields instead ensures app groups always appear). + tableFields: tableFields[entry.table] || [], + // Type-aware null defaults: boolean→false, integer→0. + // Used in GenericEventList formatDetailValue so null fields show false/0 not empty. + fieldNullDefaults, + // Host-app field renderers passed to GenericEventList details panel. + // Priority order: custom formatter > built-in byte/timestamp/boolean handlers > String(). + // Mirrors Map.fields[key].col.renderer logic in PropertyGridController. + detailFormatters: { + // Protocol number → IANA display name (e.g. 6 → "TCP [6]") + protocol: v => protocolNameMap[v] ?? String(v ?? ''), + + // ICMP type number → IANA name; null/unknown → 'Unassigned' + icmp_type: v => icmpTypeMap[v] ?? 'Unassigned', + + // HTTP method single-char code → full name + method: v => (v == null ? '' : httpMethodMap[v] ?? String(v)), + + // captive_portal auth_type → readable string + auth_type: v => (v == null ? '' : authTypeMap[v] ?? 'Unknown'), + + // captive_portal event_info → readable string; null/'' → '', unknown → 'Unknown' + event_info: v => (v == null || v === '' ? '' : captivePortalEventMap[v] ?? 'Unknown'), + + // quotas.action — 1→'Given', 2→'Exceeded'; null/'' → '', unknown → 'Unknown' + action: v => (v == null || v === '' ? '' : quotaActionMap[v] ?? 'Unknown'), + + // directory_connector_login_events.type — I/U/O/A → login/update/logout/authenticate + // Other tables use 'type' with already-readable string values so the map has no effect. + type: v => (v == null ? '' : directoryConnectorActionMap[v] ?? String(v)), + + // Interface ID → "name [id]"; 0 and -1 treated as unset → 'None' + client_intf: v => (!v || v === -1 ? i18n.t('none') : interfaceNameMap[v] ?? String(v)), + server_intf: v => (!v || v === -1 ? i18n.t('none') : interfaceNameMap[v] ?? String(v)), + interface_id: v => (!v || v === -1 ? i18n.t('none') : interfaceNameMap[v] ?? String(v)), + + // Policy ID → mapped name; null/0 → 'None' + policy_id: v => (v == null || v === 0 ? i18n.t('none') : policyNameMap[v] || ''), + + // admin_logins: local (true=local login, false=remote login) + local: v => (v ? 'local' : 'remote'), + + // admin_logins: succeeded (true=success, false=failed) + succeeded: v => (v ? 'success' : 'failed'), + + // admin_logins: reason code → i18n Title Case label; unknown codes return '' + reason: v => (loginReasonMap[v] ? i18n.t(loginReasonMap[v]) : ''), + + // bandwidth_control_priority 0-7 → i18n Title Case name; 0 falls back to raw integer + bandwidth_control_priority: v => (priorityMap[v] ? i18n.t(priorityMap[v]) : String(v ?? '')), + + // bandwidth_control_rule: 0/null → 'None', else raw rule number + bandwidth_control_rule: v => (v == null || v === 0 ? i18n.t('none') : String(v)), + + // ad_blocker_action: 'B' → 'Block', else 'Pass'; null/'' → '' + ad_blocker_action: v => (v == null || v === '' ? '' : v === 'B' ? i18n.t('block') : i18n.t('pass')), + + // Spam / phish blocker action codes → i18n label + code (e.g. "Pass Message [P]"); null/'' → '' + spam_blocker_action: v => + v == null || v === '' + ? '' + : emailActionMap[v] + ? `${i18n.t(emailActionMap[v])} [${v}]` + : `${i18n.t('unknown_action')} [${v}]`, + spam_blocker_lite_action: v => + v == null || v === '' + ? '' + : emailActionMap[v] + ? `${i18n.t(emailActionMap[v])} [${v}]` + : `${i18n.t('unknown_action')} [${v}]`, + phish_blocker_action: v => + v == null || v === '' + ? '' + : emailActionMap[v] + ? `${i18n.t(emailActionMap[v])} [${v}]` + : `${i18n.t('unknown_action')} [${v}]`, + + // Memory fields — always MB + mem_free: v => formatMemoryMB(v), + mem_total: v => formatMemoryMB(v), + swap_free: v => formatMemoryMB(v), + swap_total: v => formatMemoryMB(v), + + // Disk fields — always GB + disk_free: v => formatDiskGB(v), + disk_total: v => formatDiskGB(v), + + // Web filter reason code → readable label; null/unknown → 'No Rule Applied' + web_filter_reason: v => (webFilterReasonMap[v] ? i18n.t(webFilterReasonMap[v]) : i18n.t('no_rule_applied')), + + // web_filter_category_id → category name; null → '', unknown → raw ID string + web_filter_category_id: v => (v == null ? '' : webCategoryMap[v] ?? String(v)), + + // Threat prevention reason — same lookup as web_filter_reason; null/unknown → 'No Rule Applied' + threat_prevention_reason: v => + webFilterReasonMap[v] ? i18n.t(webFilterReasonMap[v]) : i18n.t('no_rule_applied'), + + // Threat prevention rule ID — 0/null → '' (no rule); else raw ID + threat_prevention_rule_id: v => (v == null || v === 0 ? '' : String(v)), + + // Threat prevention category bitmask → comma-separated category names + threat_prevention_client_categories: v => decodeThreatCategories(v), + threat_prevention_server_categories: v => decodeThreatCategories(v), + + // Threat prevention reputation score — 0/null → '', else raw score + threat_prevention_client_reputation: v => (v == null || v === 0 ? '' : String(v)), + threat_prevention_server_reputation: v => (v == null || v === 0 ? '' : String(v)), + + // JSON field (alerts) — decode HTML entities encoded by the backend/Jabsorb layer + json: v => { + if (!v) return '' + return String(v) + .replace(/"/g, '"') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/'/g, "'") + }, + + // Settings file path — strip the /usr/share/untangle/settings/ prefix + settings_file: v => + String(v ?? '') + .replace(/^.*\/settings\//, '') + .replace(/^.*\/conf\//, ''), + }, + query: {}, + } +} + +/** + * Builds the rendering config for a pie or column-distribution chart report. + * Maps pie style variants to chart type and donut/slice display options. + */ +function translatePieGraphEntry(entry) { + const pieStyle = entry.pieStyle || '' + return { + key: entry.uniqueId, + title: entry.title, + query: {}, + rendering: { + type: pieStyleToChartType(pieStyle), + units: entry.units, + donutInnerSize: pieStyle.includes('DONUT') ? 40 : 0, + slicesNumber: entry.pieNumSlices || 10, + column: entry.pieGroupColumn, // category column used for web category label resolution + colors: Array.isArray(entry.colors) ? entry.colors.join(',') : entry.colors, + }, + } +} + +/** + * Builds the view config object for a given report entry based on its type. + * Selects the correct display component and rendering options at runtime. + * verticalLayout expands the chart to fill available height instead of a fixed minimum. + * + * @param {Object} entry - report entry definition + * @param {number} tzOffsetMs - server timezone offset in ms + * @param {Object} interfaceNameMap - { interfaceId: 'name [id]' } — from config store getter + * @param {Object} policyNameMap - { policyId: 'name [id]' } — from reports store getter + * @param {Object} callbacks - host-app action callbacks (e.g. { onShowDiff }) + * @returns {Object|null} view config, or null if the type is not yet supported + */ +export function buildReportView(entry, tzOffsetMs, interfaceNameMap = {}, policyNameMap = {}, callbacks = {}) { + if (!entry) return null + switch (entry.type) { + case 'TIME_GRAPH': + return { + component: 'GenericChart', + verticalLayout: true, + reports: [translateTimeGraphEntry(entry)], + } + case 'TIME_GRAPH_DYNAMIC': + return { + component: 'GenericChart', + verticalLayout: true, + reports: [translateTimeGraphDynamicEntry(entry)], + } + case 'PIE_GRAPH': + return { + component: 'GenericChart', + verticalLayout: true, + reports: [translatePieGraphEntry(entry)], + } + case 'TEXT': + return { + component: 'GenericText', + reports: [translateTextEntry(entry)], + } + case 'EVENT_LIST': + return { + component: 'GenericEventList', + reports: [translateEventListEntry(entry, tzOffsetMs, interfaceNameMap, policyNameMap, callbacks)], + } + default: + return null + } +} diff --git a/untangle-vue-ui/source/src/util/reports.js b/untangle-vue-ui/source/src/util/reports.js new file mode 100644 index 0000000000..91ee25bf77 --- /dev/null +++ b/untangle-vue-ui/source/src/util/reports.js @@ -0,0 +1,136 @@ +/** + * Report utilities - URL encoding, icons, URL generation, category helpers + */ + +import util from '@/util/util' +import { tableFields } from '@/util/eventTableColumns' + +/** + * System report categories that are always present regardless of which apps are installed. + */ +export const baseCategories = [ + { name: 'hosts', type: 'system', displayName: 'Hosts', viewPosition: 1 }, + { name: 'devices', type: 'system', displayName: 'Devices', viewPosition: 2 }, + { name: 'users', type: 'system', displayName: 'Users', viewPosition: 3 }, + { name: 'network', type: 'system', displayName: 'Network', viewPosition: 4 }, + { name: 'administration', type: 'system', displayName: 'Administration', viewPosition: 5 }, + { name: 'events', type: 'system', displayName: 'Events', viewPosition: 6 }, + { name: 'system', type: 'system', displayName: 'System', viewPosition: 7 }, + { name: 'shield', type: 'system', displayName: 'Shield', viewPosition: 8 }, +] + +/** + * URL encode for report URLs + * Spaces → dashes, lowercase, encode non-ASCII + */ +export function urlEncode(url) { + let encodedUrl = url.replace(/\s+/g, '-').toLowerCase() + encodedUrl = encodeURIComponent(encodedUrl) + encodedUrl = encodedUrl.replace(/%2D/g, '-') + return encodedUrl +} + +/** + * Map report type to MDI icon + * Types: TEXT, PIE_GRAPH, TIME_GRAPH, TIME_GRAPH_DYNAMIC, EVENT_LIST + */ +export function getReportIcon(type) { + const iconMap = { + TEXT: 'mdi-file-document-outline', + PIE_GRAPH: 'mdi-chart-pie', + TIME_GRAPH: 'mdi-chart-line', + TIME_GRAPH_DYNAMIC: 'mdi-chart-areaspline', + EVENT_LIST: 'mdi-table-large', + } + return iconMap[type] || 'mdi-file-chart' +} + +/** + * Construct report URL for given category and title + */ +export function getReportUrl(category, title) { + return `https://${window.location.host}/admin/index.do#reports?cat=${urlEncode(category)}&rep=${urlEncode(title)}` +} + +/** + * Transforms a single backend report entry into the UI display format. + */ +export function formatReportForUI(report) { + return { + key: report.uniqueId, + label: report.title || report.uniqueId, + url: getReportUrl(report.category, report.title), + icon: getReportIcon(report.type), + } +} + +/** + * Transform reports array to UI format + */ +export function formatReportsForUI(reports) { + if (!Array.isArray(reports)) { + return [] + } + return reports.map(formatReportForUI) +} + +/** + * Converts a client-side epoch ms to a server-local Date for backend queries. + * + * The time picker displays times in server timezone. Before sending to the backend + * the epoch ms must be adjusted so PostgreSQL (which stores timestamps in server + * local time) receives the correct moment to filter on. + * + * Formula: serverDate = clientDate - browserOffsetMs - serverOffsetMs + * browserOffsetMs = new Date().getTimezoneOffset() * 60000 (positive for UTC- zones) + * serverOffsetMs = rpc.timeZoneOffset (positive for UTC+ zones) + * + * @param {number|null} dateMs - epoch ms from the time range picker + * @param {number} serverOffsetMs - server UTC offset in ms (from config/timeZoneOffset) + * @returns {Date|null} + */ +export function clientToServerDate(dateMs, serverOffsetMs = 0) { + if (!dateMs) return null + const browserOffsetMs = new Date().getTimezoneOffset() * 60000 + return new Date(dateMs - browserOffsetMs - serverOffsetMs) +} + +/** + * Exports report definitions for a category (or all reports) as a downloadable JSON file. + * Strips UI-computed fields and triggers a client-side Blob download. + * @param {Array} allReports - full reports array from the store + * @param {string} categoryName - category to export; if omitted exports all reports + */ +/** + * Checks whether a database table contains all the given condition columns. + * Used to determine if a report is compatible with active global conditions. + * + * @param {string} table - database table name (e.g. 'sessions') + * @param {string[]} conditionColumns - column names from active global conditions + * @returns {boolean} true if every condition column exists in the table's field list + */ +export function tableContainsColumns(table, conditionColumns) { + const fields = tableFields[table] + if (!fields) return false + return conditionColumns.every(col => fields.includes(col)) +} + +export async function exportCategoryReports(allReports, categoryName) { + const COMPUTED_FIELDS = ['localizedTitle', 'localizedDescription', 'slug', 'categorySlug', 'url', 'icon', '_id'] + + const source = categoryName ? allReports.filter(r => r.category === categoryName) : allReports + + const data = source.map(report => { + const rep = { ...report } + COMPUTED_FIELDS.forEach(field => delete rep[field]) + return rep + }) + + const gridName = `AllReports${categoryName ? '_' + categoryName.replace(/ /g, '_') : ''}` + + await util.downloadFile('/admin/gridSettings', { + gridName, + gridData: JSON.stringify(data), + type: 'export', + }) +} diff --git a/untangle-vue-ui/source/src/util/setupUtil.js b/untangle-vue-ui/source/src/util/setupUtil.js index f739a235a8..d97d0b1bd1 100644 --- a/untangle-vue-ui/source/src/util/setupUtil.js +++ b/untangle-vue-ui/source/src/util/setupUtil.js @@ -66,10 +66,66 @@ const Util = { return rpcResponse }, + getDecryptedPassword(encryptedPassword) { - const rpc = this.setRpcJsonrpc('admin') - return rpc.systemManager.getDecryptedPassword(encryptedPassword) + return window.rpc.systemManager.getDecryptedPassword(encryptedPassword) + }, + + keyStr: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=', + + utf8Encode(string) { + string = string.replace(/\r\n/g, '\n') + let utftext = '' + + for (let n = 0; n < string.length; n++) { + const c = string.charCodeAt(n) + + if (c < 128) { + utftext += String.fromCharCode(c) + } else if (c < 2048) { + utftext += String.fromCharCode((c >> 6) | 192) + utftext += String.fromCharCode((c & 63) | 128) + } else { + utftext += String.fromCharCode((c >> 12) | 224) + utftext += String.fromCharCode(((c >> 6) & 63) | 128) + utftext += String.fromCharCode((c & 63) | 128) + } + } + + return utftext }, + + base64encode(input) { + let output = '' + let chr1, chr2, chr3 + let enc1, enc2, enc3, enc4 + let i = 0 + + input = this.utf8Encode(input) + + while (i < input.length) { + chr1 = input.charCodeAt(i++) + chr2 = input.charCodeAt(i++) + chr3 = input.charCodeAt(i++) + + enc1 = chr1 >> 2 + enc2 = ((chr1 & 3) << 4) | (chr2 >> 4) + enc3 = ((chr2 & 15) << 2) | (chr3 >> 6) + enc4 = chr3 & 63 + + if (isNaN(chr2)) { + enc3 = enc4 = 64 + } else if (isNaN(chr3)) { + enc4 = 64 + } + + output += + this.keyStr.charAt(enc1) + this.keyStr.charAt(enc2) + this.keyStr.charAt(enc3) + this.keyStr.charAt(enc4) + } + + return output + }, + authenticate(password, cb) { const url = '/auth/login?url=/admin&realm=Administrator' @@ -134,6 +190,43 @@ const Util = { } }, + /** Validates multiple tabs within a component using ValidationObserver. */ + /** + * Asynchronously validates multiple tabs within a component. + * It iterates through each tab, validates it using its ref, and tracks the first invalid tab. + * If an invalid tab is found and a setSelectedTab function is provided, it sets the active tab to the invalid one. + * + * @param {Array} params.tabs - An array of tab objects, each expected to have a 'key' and 'valid' property. + * @param {Object} params.refs - An object containing references to the validation components for each tab, keyed by tab.key. + * @param {Function} [params.setSelectedTab] - An optional function to set the currently selected tab. + * @returns {Promise} A promise that resolves to true if all tabs are valid, false otherwise. + */ + async validateTabs({ tabs, refs, setSelectedTab }) { + let invalidTab = null + + const promises = tabs.map(async tab => { + if (!refs[tab.key]) { + tab.valid = true + return true + } + const valid = await refs[tab.key].validate() + tab.valid = valid + + if (!valid && !invalidTab) { + invalidTab = tab.key + } + return valid + }) + + const results = await Promise.all(promises) + + if (invalidTab && setSelectedTab) { + setSelectedTab(invalidTab) + } + + return !results.includes(false) + }, + isDestroyed(...args) { return args.some(arg => typeof arg === 'object' && arg?.$isUnmounted) }, diff --git a/untangle-vue-ui/source/src/util/transform.js b/untangle-vue-ui/source/src/util/transform.js index 43c1f960b1..b4726d28b0 100644 --- a/untangle-vue-ui/source/src/util/transform.js +++ b/untangle-vue-ui/source/src/util/transform.js @@ -25,14 +25,14 @@ const applyJavaClass = function (items, javaClass) { const dns = { get: () => { /** @type {NetworkSettings} */ - const ns = store.getters['settings/networkSettings'] + const ns = store.getters['config/networkSettings'] return { staticEntries: ns?.dnsSettings?.staticEntries.list, localServers: ns?.dnsSettings?.localServers.list, } }, set: dnsSettings => { - const ns = cloneDeep(store.getters['settings/networkSettings']) + const ns = cloneDeep(store.getters['config/networkSettings']) ns.dnsSettings.staticEntries.list = applyJavaClass(dnsSettings.staticEntries, 'DnsStaticEntry') ns.dnsSettings.localServers.list = applyJavaClass(dnsSettings.localServers, 'DnsLocalServer') @@ -42,14 +42,14 @@ const dns = { const dhcp = { get: () => { - const ns = store.getters['settings/networkSettings'] + const ns = store.getters['config/networkSettings'] return { dhcpAuthoritative: ns?.dhcpAuthoritative, staticDhcpEntries: ns?.staticDhcpEntries.list, } }, set: dhcpSettings => { - const ns = cloneDeep(store.getters['settings/networkSettings']) + const ns = cloneDeep(store.getters['config/networkSettings']) ns.dhcpAuthoritative = dhcpSettings.dhcpAuthoritative ns.staticDhcpEntries.list = applyJavaClass(dhcpSettings.staticDhcpEntries, 'DhcpStaticEntry') return ns @@ -58,11 +58,11 @@ const dhcp = { const staticRoutes = { get: () => { - const ns = store.getters['settings/networkSettings'] + const ns = store.getters['config/networkSettings'] return ns?.staticRoutes.list }, set: staticRoutes => { - const ns = cloneDeep(store.getters['settings/networkSettings']) + const ns = cloneDeep(store.getters['config/networkSettings']) ns.staticRoutes.list = applyJavaClass(staticRoutes, 'StaticRoute') return ns }, diff --git a/untangle-vue-ui/source/src/util/util.js b/untangle-vue-ui/source/src/util/util.js index 77f77f350f..f70d9ad0ed 100644 --- a/untangle-vue-ui/source/src/util/util.js +++ b/untangle-vue-ui/source/src/util/util.js @@ -15,6 +15,8 @@ import i18n from '@/plugins/vue-i18n' import http from '@/plugins/http' import store from '@/store' import Rpc from '@/util/Rpc' +import { VTypes } from '@/util/VTypes' +import Util from '@/util/setupUtil' export const hourInMilliseconds = 60 * 60 * 1000 @@ -35,6 +37,17 @@ const util = { } }, + // returns milliseconds depending of the servlet ADMIN or REPORTS + getMilliseconds() { + if (window.rpc?.systemManager) { + return Rpc.directData('rpc.systemManager.getMilliseconds') + } + if (window.rpc?.ReportsContext) { + return Rpc.directData('rpc.ReportsContext.getMilliseconds') + } + return Date.now() + }, + /** * checks if box is reachable while rebooting or upgrading * using a 3s interval to avoid flooding with calls @@ -75,7 +88,7 @@ const util = { value = i18n.t(addressTypeOptions.find(o => o.value === value)?.text) } if (c.type.includes('_INTERFACE_ZONE') && c.value !== null) { - value = store.getters['settings/interfaceById'](parseInt(value))?.name + value = store.getters['config/interfaceById'](parseInt(value))?.name } if (c.type === 'IP_PROTOCOL' && c.value !== null) { const strVal = c.value + '' // make sure it's a string @@ -165,7 +178,7 @@ const util = { break } case 'WAN_POLICY': { - const policy = store.getters['settings/policyById'](rule.action?.policy) + const policy = store.getters['config/policyById'](rule.action?.policy) actionStr = ` ${i18n.t('action_wan_policy_is')} ${policy?.description || '?'}` break @@ -519,12 +532,31 @@ const util = { return url }, + getAbout() { + let about = null + if (about === null) { + about = [ + 'uid=' + window.rpc.serverUID, + 'version=' + window.rpc.fullVersion, + 'webui=true', + 'lang=' + window.rpc.languageSettings.language, + 'applianceModel=' + window.rpc.applianceModel, + 'installType=' + window.rpc.installType, + ].join('&') + return about + } + }, + /** * Reloads licenses by making a direct RPC call. * @returns {Promise} */ async reloadLicenses() { - await Rpc.directData('rpc.UvmContext.licenseManager.reloadLicenses', true) + try { + await Rpc.directData('rpc.UvmContext.licenseManager.reloadLicenses', true) + } catch (ex) { + Util.handleException(ex) + } }, /** @@ -550,6 +582,176 @@ const util = { document.body.appendChild(script) }) }, + + /** + * Constructs the certificate subject string from certificate details. + * @param {Object} details - Object containing certificate details like commonName, country, etc. + * @returns {string} The formatted certificate subject string. + */ + createCertSubject(details) { + const certSubject = [ + '/CN=' + details.commonName, + '/C=' + details.country, + '/ST=' + details.state, + '/L=' + details.locality, + '/O=' + details.organization, + ] + + if (details.organizationalUnit && details.organizationalUnit.length > 0) { + certSubject.push('/OU=' + details.organizationalUnit) + } + + return certSubject.join('') + }, + + /** + * Creates a comma-separated string of Subject Alternative Names (SANs). + * @param {string} altNames - A string of comma-separated alternative names. + * @returns {string} A formatted string of SANs for use in certificate generation. + */ + createAltNames(altNames) { + if (!altNames || altNames.length === 0) { + return '' + } + + const hostnameRegex = + /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/ + const altNameTokens = altNames.split(',') + const altNamesArray = [] + + for (let i = 0; i < altNameTokens.length; i++) { + const altName = altNameTokens[i].trim() + if (VTypes.ipAddress.validate(altName)) { + altNamesArray.push('IP:' + altName + ',DNS:' + altName) + } else if (hostnameRegex.test(altName)) { + altNamesArray.push('DNS:' + altName) + } else { + altNamesArray.push(altName) + } + } + return altNamesArray.join(',') + }, + + /** + * Determines if a certificate can be deleted based on certain conditions. + * A certificate cannot be deleted if it's the system's Apache certificate ('apache.pem') + * or if it is assigned to one or more services (HTTPS, SMTPS, IPsec, RADIUS). + * + * @param {object} data - The certificate data object. + * @param {string} data.fileName - The file name of the certificate. + * @returns {{allowed: boolean, message?: string}} An object indicating if deletion is allowed and an optional error message. + */ + canDeleteCertificate(data) { + if (data.fileName === 'apache.pem') { + return { + allowed: false, + message: 'system_certificate_cannot_removed_error_message', + } + } + + if (data.httpsServer || data.smtpsServer || data.ipsecServer || data.radiusServer) { + return { + allowed: false, + message: 'certificate_assigned_to_one_or_more_services_error_message', + } + } + + return { allowed: true } + }, + + /** + * Returns the Quarantine RPC client instance (singleton) + * Creates and caches the client in window.quarantineRpc + * @returns {Object} Quarantine RPC client + */ + getQuarantineRpc() { + if (!window.quarantineRpc) { + window.quarantineRpc = new window.JSONRpcClient('/quarantine/JSON-RPC').Quarantine + } + return window.quarantineRpc + }, + + /** + * Checks if the Policy Manager app is installed by making a direct RPC call. + * @returns + */ + isPolicyManagerInstalled() { + try { + return Rpc.directData('rpc.appManager.app', 'policy-manager') + } catch (ex) { + Util.handleException(ex) + } + }, + + /** + * Checks if the Reports app is installed by making a direct RPC call. + * @returns + */ + isReportsInstalled() { + try { + return Rpc.directData('rpc.appManager.app', 'reports') + } catch (ex) { + Util.handleException(ex) + } + }, + + isRestricted() { + try { + return Rpc.directData('rpc.UvmContext.licenseManager.isRestricted') + } catch (ex) { + Util.handleException(ex) + } + }, + + isRegistered() { + return window.rpc.isRegistered + }, + + getLicenseServerConnectivity() { + try { + return Rpc.directData('rpc.UvmContext.licenseManager.getLicenseServerConnectivity') + } catch (ex) { + Util.handleException(ex) + } + }, + + isCCHidden() { + return window.rpc.isCCHidden + }, + + getLicenseMessage(license, vuntangle) { + let message = '' + if (!license) { + return message + } + if (license.trial) { + if (license.expired) { + message = vuntangle.$t('free_trial_expired') + } else if (license.daysRemaining < 2) { + message = vuntangle.$t('free_trial_expires_today') + } else if (license.daysRemaining < 32) { + message = vuntangle.$t('free_trial_days_remaining', [license.daysRemaining]) + } else { + message = vuntangle.$t('free_trial') + } + } else if (!license.valid) { + message = vuntangle.$t(license.status?.toLowerCase().replace(/\s+/g, '_')) + } + return message + }, + + /** + * Converts kebab-case string to PascalCase + * @param {string} str - kebab-case string + * @returns {string} PascalCase string + * @example kebabToPascal('web-filter') // returns 'WebFilter' + */ + kebabToPascal(str) { + return str + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join('') + }, } export default util diff --git a/untangle-vue-ui/source/src/utils/appFiltering.js b/untangle-vue-ui/source/src/utils/appFiltering.js new file mode 100644 index 0000000000..cf29775896 --- /dev/null +++ b/untangle-vue-ui/source/src/utils/appFiltering.js @@ -0,0 +1,465 @@ +/** + * App Filtering Utilities + * + * This module provides utility functions for filtering, organizing, and managing + * apps within the policy-based app management system. + * + * Key Features: + * - Build policy hierarchies from flat lists + * - Filter installed apps with parent policy inheritance + * - Filter installable apps (FILTER type only) + * - Calculate power state indicators + * - Sort apps by view position and policy hierarchy + */ + +import util from '@/util/util' +import { appDescription } from '@/constants/index' + +// ============================================ +// Constants +// ============================================ + +/** + * App run states from backend + * @readonly + * @enum {string} + */ +const RUN_STATE = { + RUNNING: 'RUNNING', + STOPPED: 'STOPPED', +} + +/** + * Power state CSS classes for UI + * @readonly + * @enum {string} + */ +const POWER_CLASS = { + ON: 'on', + OFF: '', + INCONSISTENT: 'inconsistent', +} + +/** + * App types from backend + * Only FILTER apps are shown in installable apps view + * SERVICE apps are excluded from installation UI + * @readonly + * @enum {string} + */ +const APP_TYPE = { + FILTER: 'FILTER', + SERVICE: 'SERVICE', +} + +/** + * Installation status values + * @readonly + * @enum {string} + */ +const INSTALL_STATUS = { + PROGRESS: 'progress', + FINISH: 'finish', +} + +/** + * Default view position for apps without explicit position + * Used for sorting - ensures unprioritized apps appear last + * @constant {number} + */ +const DEFAULT_VIEW_POSITION = 999 + +// ============================================ +// Policy Hierarchy Functions +// ============================================ + +/** + * Builds a hierarchical policy tree from a flat policy list + * + * Converts a flat array of policies with parent-child relationships + * into a nested tree structure suitable for tree view components. + * + * @param {Array} policies - Flat list of policy objects + * @param {number} policies[].policyId - Unique policy identifier + * @param {number} [policies[].parentId] - Parent policy ID (null for root policies) + * @returns {Array} Hierarchical policy tree with children arrays + * + * @example + * const policies = [ + * { policyId: 1, name: 'Root', parentId: null }, + * { policyId: 2, name: 'Child', parentId: 1 } + * ] + * const tree = buildPolicyHierarchy(policies) + * // Returns: [{ policyId: 1, name: 'Root', parentId: null, children: [{ policyId: 2, ... }] }] + */ +export function buildPolicyHierarchy(policies) { + // Guard clause for invalid input + if (!policies || !Array.isArray(policies)) { + return [] + } + + // Create a map for O(1) policy lookup + const policyMap = {} + policies.forEach(policy => { + policyMap[policy.policyId] = { ...policy, children: [] } + }) + + // Build tree by connecting parents and children + const tree = [] + policies.forEach(policy => { + if (!policy.parentId) { + // Root policy - no parent + tree.push(policyMap[policy.policyId]) + } else if (policyMap[policy.parentId]) { + // Child policy - add to parent's children array + policyMap[policy.parentId].children.push(policyMap[policy.policyId]) + } + }) + + return tree +} + +/** + * Builds the policy hierarchy chain from a policy up to the root + * + * Traverses from the selected policy up through parent policies to build + * a complete chain for inheritance calculations. + * + * @param {Object} selectedPolicy - Starting policy object + * @param {Object} policyMap - Map of policyId to policy object + * @returns {Array} Array of policy IDs from selected to root + * + * @example + * // If policy 3 has parent 2, which has parent 1 (root) + * buildPolicyChain(policy3, policyMap) // Returns: [3, 2, 1] + */ +function buildPolicyChain(selectedPolicy, policyMap) { + const policyHierarchy = [selectedPolicy.policyId] + let currentPolicy = selectedPolicy + + // Traverse up the hierarchy until we reach a root policy + while (currentPolicy && currentPolicy.parentId) { + currentPolicy = policyMap[currentPolicy.parentId] + if (currentPolicy) { + policyHierarchy.push(currentPolicy.policyId) + } + } + + return policyHierarchy +} + +// ============================================ +// Power State Functions +// ============================================ + +/** + * Determines the power state CSS class for an app instance + * + * Calculates the power state by comparing run state, target state, and daemon status. + * Returns appropriate CSS class for visual indicator. + * + * States: + * - 'inconsistent': App is transitioning or daemon has issues + * - 'on': App is running normally + * - '': App is stopped (empty string for 'off' state) + * + * @param {Object} app - App instance object + * @param {string} runState - Current run state (RUNNING/STOPPED) + * @param {string} [daemon] - Daemon name to check if running (optional) + * @returns {string} Power state CSS class + */ +export function getPowerClass(runState, daemon) { + // Determine if app should be running + const isRunning = runState === RUN_STATE.RUNNING + + // Check daemon status if daemon name is provided and app is running + const daemonRunning = + isRunning && daemon != null ? window.rpc.directData('rpc.UvmContext.daemonManager.isRunning', daemon) : true + + // App is inconsistent if App is running but daemon is not running + const isInconsistent = isRunning && !daemonRunning + + if (isInconsistent) { + return POWER_CLASS.INCONSISTENT + } else if (isRunning) { + return POWER_CLASS.ON + } else { + return POWER_CLASS.OFF + } +} + +// ============================================ +// App Enrichment Functions +// ============================================ + +/** + * Enriches an app instance with UI metadata + * + * Adds display properties, license information, power state, and installation status + * to the raw app instance from the backend. + * + * @param {Object} app - Raw app instance + * @param {Object} view - App view containing properties and states + * @param {Object} appProperties - App metadata (displayName, daemon, etc.) + * @param {string} [parentPolicyName] - Name of parent policy if inherited + * @param {Object} installingApps - Map of apps currently being installed + * @param {Object} $vuntangle - Vue i18n instance + * @returns {Object} Enriched app object with UI properties + */ +function enrichAppInstance(app, view, appProperties, parentPolicyName, installingApps, $vuntangle) { + const enrichedApp = { ...app } + + // Mark if inherited from parent policy + if (parentPolicyName) { + enrichedApp.parentPolicy = parentPolicyName + } + + // Add app metadata + enrichedApp.hasPowerButton = appProperties ? appProperties.hasPowerButton : false + enrichedApp.displayName = appProperties ? appProperties.displayName : app.appName + enrichedApp.viewPosition = appProperties ? appProperties.viewPosition : DEFAULT_VIEW_POSITION + + // Add license information + const license = view.licenseMap[app.appName] + enrichedApp.licenseMessage = util.getLicenseMessage(license, $vuntangle) + + // Calculate power state + enrichedApp.powerCls = getPowerClass(view.runStates[enrichedApp.id], appProperties?.daemon) + + // Check if currently being installed + const installing = installingApps[app.appName] + enrichedApp.installing = installing ? installing.status === INSTALL_STATUS.PROGRESS : false + + // Store all properties for reference + enrichedApp.props = appProperties || {} + + return enrichedApp +} + +// ============================================ +// App Filtering Functions +// ============================================ + +/** + * Gets installed apps for a policy including apps inherited from parent policies + * + * Retrieves all apps for the selected policy and traverses up the policy hierarchy + * to include apps from parent policies. Inherited apps are marked with parent policy name. + * + * Installation State Handling: + * This function merges two sources of state to handle the race condition during app installation: + * 1. Backend instances (from view.instances) - Apps created by backend + * 2. UI state (from installingApps) - Apps in 'progress' status + * + * When user installs an app and navigates back quickly, the backend may not have created + * the instance yet. By checking installingApps and creating placeholder objects, we ensure + * installing apps always appear with loader, regardless of backend timing. + * + * @param {Object} params - Function parameters + * @param {Object} params.appsViewByPolicy - Normalized app views keyed by policyId + * @param {Object} params.selectedPolicy - Currently selected policy object + * @param {Array} params.policies - Complete list of all policies + * @param {Object} params.$vuntangle - Vue i18n instance for translations + * @param {Object} [params.installingApps={}] - Map of apps currently being installed + * @returns {Array} Sorted array of enriched app objects + * + * @example + * const apps = getInstalledApps({ + * appsViewByPolicy: { 1: {...}, 2: {...} }, + * selectedPolicy: { policyId: 2, parentId: 1 }, + * policies: [...], + * $vuntangle, + * installingApps: {} + * }) + */ +export function getInstalledApps({ appsViewByPolicy, selectedPolicy, policies, $vuntangle, installingApps = {} }) { + // Guard clauses + if (!appsViewByPolicy || !selectedPolicy) { + return [] + } + + // Create policy map for O(1) lookups + const policyMap = policies.reduce((acc, policy) => { + acc[policy.policyId] = policy + return acc + }, {}) + + // Build policy hierarchy chain (selected -> parent -> grandparent -> root) + const policyHierarchy = buildPolicyChain(selectedPolicy, policyMap) + + // Collect all app instances from the policy hierarchy + const allApps = policyHierarchy.reduce((acc, policyId) => { + const view = appsViewByPolicy[policyId] // O(1) lookup + return acc.concat(view ? view.instances : []) + }, []) + + // Get unique app names (exclude null policyId which are service apps) + const uniqueAppNames = [...new Set(allApps.filter(app => app.policyId !== null).map(app => app.appName))] + + // Build enriched app objects + let apps = uniqueAppNames + .map(appName => { + // Find the app in the policy hierarchy (check selected policy first, then parents) + for (const policyId of policyHierarchy) { + const view = appsViewByPolicy[policyId] // O(1) lookup + + if (!view) continue + + const app = view.instances.find(a => a.appName === appName) + + if (app) { + // Determine if this app is from a parent policy + const parentPolicyName = + app.policyId && app.policyId !== selectedPolicy.policyId ? policyMap[app.policyId]?.name : null + + // Get app properties + const appProperties = view.appProperties.find(prop => prop.name === appName) + + // Enrich app with UI metadata + return enrichAppInstance(app, view, appProperties, parentPolicyName, installingApps, $vuntangle) + } + } + return null + }) + .filter(Boolean) // Remove null entries + + // Add apps that are currently being installed but not yet instantiated by backend + // This ensures installing apps always show with loader, even if backend hasn't created instance yet + const currentPolicyView = appsViewByPolicy[selectedPolicy.policyId] + if (currentPolicyView) { + Object.keys(installingApps).forEach(appName => { + const installing = installingApps[appName] + + // Only add if: + // 1. App is in 'progress' status (currently installing) + // 2. App is being installed for the current policy + // 3. App is not already in the apps list (avoid duplicates) + if ( + installing.status === INSTALL_STATUS.PROGRESS && + installing.policyId === selectedPolicy.policyId && + !apps.find(a => a.appName === appName) + ) { + // Get app properties to build placeholder app object + const appProperties = currentPolicyView.appProperties.find(prop => prop.name === appName) + + if (appProperties) { + // Create placeholder app object for installing app + const placeholderApp = { + appName, + policyId: selectedPolicy.policyId, + displayName: appProperties.displayName || appName, + viewPosition: appProperties.viewPosition || DEFAULT_VIEW_POSITION, + hasPowerButton: false, // No power button while installing + powerCls: '', // No power state while installing + licenseMessage: '', // No license message needed during install + installing: true, // Mark as installing + parentPolicy: null, // Not inherited + props: appProperties, + } + + apps.push(placeholderApp) + } + } + }) + } + + // Sort apps by view position and policy hierarchy + apps = apps.sort((a, b) => { + // Primary sort: View position (lower numbers first) + if (a.viewPosition !== b.viewPosition) { + return a.viewPosition - b.viewPosition + } + + // Secondary sort: Policy hierarchy (apps from parent policies first) + // Lower policyId = higher in hierarchy = should appear first + return a.policyId - b.policyId + }) + + return apps +} + +/** + * Gets installable apps for a policy + * + * Returns apps that can be installed for the selected policy. + * Only FILTER type apps are included (SERVICE apps are excluded). + * Apps with 'finish' installation status are included to show success state. + * + * @param {Object} params - Function parameters + * @param {Object} params.appsViewByPolicy - Normalized app views keyed by policyId + * @param {Object} params.selectedPolicy - Currently selected policy object + * @param {Object} [params.installingApps={}] - Map of apps currently being installed + * @returns {Array} Sorted array of installable app objects + * + * @example + * const apps = getInstallableApps({ + * appsViewByPolicy: { 1: {...} }, + * selectedPolicy: { policyId: 1 }, + * installingApps: { 'web-filter': { status: 'progress', policyId: 1 } } + * }) + */ +export function getInstallableApps({ appsViewByPolicy, selectedPolicy, installingApps = {} }) { + // Guard clauses + if (!appsViewByPolicy || !selectedPolicy) { + return [] + } + + const policyView = appsViewByPolicy[selectedPolicy.policyId] // O(1) lookup + + if (!policyView) { + return [] + } + + const installableApps = [] + const addedApps = new Set() // Track apps to avoid duplicates + + // Add apps from backend's installable list + // Only FILTER type apps are shown (SERVICE apps are excluded) + policyView.installable.forEach(appName => { + const app = policyView.appProperties.find(a => a.name === appName) + + if (app && app.type === APP_TYPE.FILTER) { + installableApps.push({ + name: app.name, + displayName: app.displayName, + description: appDescription[app.name] || '', + route: `#apps/${policyView.policyId}/${app.name}`, + viewPosition: app.viewPosition || DEFAULT_VIEW_POSITION, + }) + addedApps.add(app.name) + } + }) + + // Add apps with 'finish' status that were just installed + // These apps may no longer be in installable list but need to show success icon + Object.keys(installingApps).forEach(appName => { + const installing = installingApps[appName] + + // Only add if: + // 1. Status is 'finish' (installation completed) + // 2. Policy matches selected policy + // 3. Not already added from installable list + if ( + installing.status === INSTALL_STATUS.FINISH && + installing.policyId === selectedPolicy.policyId && + !addedApps.has(appName) + ) { + const app = policyView.appProperties.find(a => a.name === appName) + + if (app && app.type === APP_TYPE.FILTER) { + installableApps.push({ + name: app.name, + displayName: app.displayName, + description: appDescription[app.name] || '', + route: `#apps/${policyView.policyId}/${app.name}`, + viewPosition: app.viewPosition || DEFAULT_VIEW_POSITION, + }) + } + } + }) + + // Sort by view position (ascending) + const sortedApps = installableApps.sort((a, b) => a.viewPosition - b.viewPosition) + + return sortedApps +} diff --git a/untangle-vue-ui/source/src/views/Component.vue b/untangle-vue-ui/source/src/views/Component.vue index 2ae5466c37..8755eb657b 100644 --- a/untangle-vue-ui/source/src/views/Component.vue +++ b/untangle-vue-ui/source/src/views/Component.vue @@ -12,7 +12,7 @@ components: { About, Dns, Dhcp, StaticRoutes, Settings, DynamicBlockLists }, async beforeRouteEnter(to, from, next) { - await store.dispatch('settings/fetchNetworkSettings') + await store.dispatch('config/fetchNetworkSettings') next() }, diff --git a/untangle-vue-ui/source/vue.config.js b/untangle-vue-ui/source/vue.config.js index 04e14a5328..a06ff20d6b 100644 --- a/untangle-vue-ui/source/vue.config.js +++ b/untangle-vue-ui/source/vue.config.js @@ -18,6 +18,9 @@ module.exports = { '/setup': { target: `http://ngfw.untangle.com/`, }, + '/quarantine': { + target: `http://ngfw.untangle.com/`, + }, }, }, configureWebpack: { diff --git a/untangle-vue-ui/source/yarn.lock b/untangle-vue-ui/source/yarn.lock index d7d6767b8f..29b83e5d84 100644 --- a/untangle-vue-ui/source/yarn.lock +++ b/untangle-vue-ui/source/yarn.lock @@ -49,7 +49,7 @@ dependencies: "@babel/highlight" "^7.10.4" -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0", "@babel/code-frame@^7.29.7": +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.7.tgz#f2fbbfea87c44a21590ec515b778b2c26d8866e7" integrity sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw== @@ -68,18 +68,18 @@ picocolors "^1.1.1" "@babel/code-frame@^7.27.1": - version "7.29.0" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c" - integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.28.6.tgz#72499312ec58b1e2245ba4a4f550c132be4982f7" + integrity sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q== dependencies: "@babel/helper-validator-identifier" "^7.28.5" js-tokens "^4.0.0" picocolors "^1.1.1" "@babel/compat-data@^7.27.2": - version "7.29.0" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.0.tgz#00d03e8c0ac24dd9be942c5370990cbe1f17d88d" - integrity sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg== + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.28.6.tgz#103f466803fa0f059e82ccac271475470570d74c" + integrity sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg== "@babel/compat-data@^7.27.7", "@babel/compat-data@^7.28.0": version "7.28.0" @@ -143,7 +143,18 @@ "@jridgewell/trace-mapping" "^0.3.28" jsesc "^3.0.2" -"@babel/generator@^7.29.0", "@babel/generator@^7.29.7": +"@babel/generator@^7.28.6": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.0.tgz#4cba5a76b3c71d8be31761b03329d5dc7768447f" + integrity sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ== + dependencies: + "@babel/parser" "^7.29.0" + "@babel/types" "^7.29.0" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" + +"@babel/generator@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.7.tgz#cca0b8827e6bcf3ba176788e7f3b180ad6db2fa3" integrity sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ== @@ -380,11 +391,11 @@ "@babel/types" "^7.28.0" "@babel/parser@^7.27.2", "@babel/parser@^7.28.5": - version "7.29.0" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.0.tgz#669ef345add7d057e92b7ed15f0bac07611831b6" - integrity sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww== + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.6.tgz#f01a8885b7fa1e56dd8a155130226cd698ef13fd" + integrity sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ== dependencies: - "@babel/types" "^7.29.0" + "@babel/types" "^7.28.6" "@babel/parser@^7.28.0": version "7.28.3" @@ -1051,16 +1062,16 @@ debug "^4.3.1" "@babel/traverse@^7.27.1": - version "7.29.0" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.0.tgz#f323d05001440253eead3c9c858adbe00b90310a" - integrity sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA== + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.6.tgz#871ddc79a80599a5030c53b1cc48cbe3a5583c2e" + integrity sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg== dependencies: - "@babel/code-frame" "^7.29.0" - "@babel/generator" "^7.29.0" + "@babel/code-frame" "^7.28.6" + "@babel/generator" "^7.28.6" "@babel/helper-globals" "^7.28.0" - "@babel/parser" "^7.29.0" + "@babel/parser" "^7.28.6" "@babel/template" "^7.28.6" - "@babel/types" "^7.29.0" + "@babel/types" "^7.28.6" debug "^4.3.1" "@babel/traverse@^7.27.3", "@babel/traverse@^7.28.0": @@ -1098,9 +1109,9 @@ "@babel/helper-validator-identifier" "^7.27.1" "@babel/types@^7.27.1", "@babel/types@^7.28.5": - version "7.29.0" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.0.tgz#9f5b1e838c446e72cf3cd4b918152b8c605e37c7" - integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A== + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.6.tgz#c3e9377f1b155005bcc4c46020e7e394e13089df" + integrity sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg== dependencies: "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" @@ -2638,10 +2649,10 @@ base@^0.11.1: mixin-deep "^1.2.0" pascalcase "^0.1.1" -baseline-browser-mapping@^2.10.12: - version "2.10.32" - resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz#b6b553a4285fdd606327a617de36a5351e3aaa64" - integrity sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg== +baseline-browser-mapping@^2.10.42: + version "2.10.43" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz#7b5d11590ce5acdbe4859443e3c940e81ce8c02d" + integrity sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ== batch-processor@1.0.0: version "1.0.0" @@ -2709,9 +2720,9 @@ boolbase@^1.0.0: integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== brace-expansion@^1.1.7: - version "1.1.15" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.15.tgz#a6d90d54067236e5f42570a3b7378d594d9b7738" - integrity sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg== + version "1.1.16" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.16.tgz#723d3a30c0558c225abc9fc479a73e14e26c3c2f" + integrity sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw== dependencies: balanced-match "^1.0.0" concat-map "0.0.1" @@ -2734,14 +2745,14 @@ browserslist@^4.0.0, browserslist@^4.16.3, browserslist@^4.21.4, browserslist@^4 update-browserslist-db "^1.1.3" browserslist@^4.24.0: - version "4.28.2" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.2.tgz#f50b65362ef48974ca9f50b3680566d786b811d2" - integrity sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg== - dependencies: - baseline-browser-mapping "^2.10.12" - caniuse-lite "^1.0.30001782" - electron-to-chromium "^1.5.328" - node-releases "^2.0.36" + version "4.28.6" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.6.tgz#7cf83afcd69c55fde6fb2dcc5039ff0f4ba42610" + integrity sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw== + dependencies: + baseline-browser-mapping "^2.10.42" + caniuse-lite "^1.0.30001803" + electron-to-chromium "^1.5.389" + node-releases "^2.0.51" update-browserslist-db "^1.2.3" buffer-from@^1.0.0: @@ -2846,10 +2857,10 @@ caniuse-lite@^1.0.30001726: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001734.tgz#f97e08599e2d75664543ae4b6ef25dc2183c5cc6" integrity sha512-uhE1Ye5vgqju6OI71HTQqcBCZrvHugk0MjLak7Q+HfoBgoq5Bi+5YnwjP4fjDgrtYr/l8MVRBvzz9dPD4KyK0A== -caniuse-lite@^1.0.30001782: - version "1.0.30001793" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz#238887ddf5fcfc8c36d872394d0a78a517312a72" - integrity sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA== +caniuse-lite@^1.0.30001803: + version "1.0.30001805" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz#78d5d5968a69b7ff81af87a96d7ddc7ea6670b1e" + integrity sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA== case-sensitive-paths-webpack-plugin@^2.3.0: version "2.4.0" @@ -3500,6 +3511,16 @@ detect-node@^2.0.4: resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== +diff-match-patch@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/diff-match-patch/-/diff-match-patch-1.0.5.tgz#abb584d5f10cd1196dfc55aa03701592ae3f7b37" + integrity sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw== + +diff@^5.2.0: + version "5.2.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.2.tgz#0a4742797281d09cfa699b79ea32d27723623bad" + integrity sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A== + dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" @@ -3612,10 +3633,10 @@ electron-to-chromium@^1.5.173: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.200.tgz#adffa5db97390ce9d48987f528117a608ed0d7c9" integrity sha512-rFCxROw7aOe4uPTfIAx+rXv9cEcGx+buAF4npnhtTqCJk5KDFRnh3+KYj7rdVh6lsFt5/aPs+Irj9rZ33WMA7w== -electron-to-chromium@^1.5.328: - version "1.5.361" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz#b993bc7b34ea83f348aa1787a608ecf12e39b909" - integrity sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA== +electron-to-chromium@^1.5.389: + version "1.5.389" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz#538be9ebec78026d4daba6be321ab854dfac2a8f" + integrity sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg== element-resize-detector@^1.2.1: version "1.2.4" @@ -3691,7 +3712,17 @@ error-stack-parser@^2.0.6: dependencies: stackframe "^1.3.4" -es-abstract@^1.23.2, es-abstract@^1.23.5, es-abstract@^1.23.9, es-abstract@^1.24.0: +es-abstract-get@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-abstract-get/-/es-abstract-get-1.0.0.tgz#1eae87101f42bedeb6a740e8c5051271aef89088" + integrity sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg== + dependencies: + es-errors "^1.3.0" + es-object-atoms "^1.1.2" + is-callable "^1.2.7" + object-inspect "^1.13.4" + +es-abstract@^1.23.2, es-abstract@^1.23.5, es-abstract@^1.23.9, es-abstract@^1.24.0, es-abstract@^1.24.2: version "1.24.2" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.24.2.tgz#2dbd38c180735ee983f77585140a2706a963ed9a" integrity sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg== @@ -3766,7 +3797,7 @@ es-module-lexer@^1.2.1: resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== -es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1, es-object-atoms@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz#a2d0b373205724dfa525d23b0c3e1b1ca582c99b" integrity sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw== @@ -3791,13 +3822,16 @@ es-shim-unscopables@^1.0.2, es-shim-unscopables@^1.1.0: hasown "^2.0.2" es-to-primitive@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.3.0.tgz#96c89c82cc49fd8794a24835ba3e1ff87f214e18" - integrity sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g== + version "1.3.4" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.3.4.tgz#0c854291cf0d7b439d6b9e5771ea837ffaf1f991" + integrity sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw== dependencies: + es-abstract-get "^1.0.0" + es-define-property "^1.0.1" + es-errors "^1.3.0" is-callable "^1.2.7" - is-date-object "^1.0.5" - is-symbol "^1.0.4" + is-date-object "^1.1.0" + is-symbol "^1.1.1" escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" @@ -3841,9 +3875,9 @@ eslint-import-resolver-node@^0.3.9: resolve "^2.0.0-next.6" eslint-module-utils@^2.12.1: - version "2.12.1" - resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz#f76d3220bfb83c057651359295ab5854eaad75ff" - integrity sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw== + version "2.14.0" + resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz#611f8e1c6ceb29a93eb949e1cc670b8287764819" + integrity sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig== dependencies: debug "^3.2.7" @@ -4442,16 +4476,19 @@ function-bind@^1.1.2: integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== function.prototype.name@^1.1.6, function.prototype.name@^1.1.8: - version "1.1.8" - resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.8.tgz#e68e1df7b259a5c949eeef95cdbde53edffabb78" - integrity sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q== + version "1.2.0" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.2.0.tgz#758f3e84fa542672454bd5e14cb081a5ce07f70c" + integrity sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew== dependencies: - call-bind "^1.0.8" - call-bound "^1.0.3" - define-properties "^1.2.1" + call-bind "^1.0.9" + call-bound "^1.0.4" + es-define-property "^1.0.1" + es-errors "^1.3.0" functions-have-names "^1.2.3" - hasown "^2.0.2" + has-property-descriptors "^1.0.2" + hasown "^2.0.4" is-callable "^1.2.7" + is-document.all "^1.0.0" functional-red-black-tree@^1.0.1: version "1.0.1" @@ -4714,10 +4751,10 @@ hasown@^2.0.0: dependencies: function-bind "^1.1.2" -hasown@^2.0.2, hasown@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.3.tgz#5e5c2b15b60370a4c7930c383dfb76bf17bc403c" - integrity sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg== +hasown@^2.0.2, hasown@^2.0.3, hasown@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" + integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== dependencies: function-bind "^1.1.2" @@ -4741,6 +4778,11 @@ highlight.js@^10.7.1: resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531" integrity sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A== +highlight.js@^11.10.0: + version "11.11.1" + resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.11.1.tgz#fca06fa0e5aeecf6c4d437239135fabc15213585" + integrity sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w== + hosted-git-info@^2.1.4: version "2.8.9" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" @@ -5059,7 +5101,7 @@ is-data-view@^1.0.1, is-data-view@^1.0.2: get-intrinsic "^1.2.6" is-typed-array "^1.1.13" -is-date-object@^1.0.5, is-date-object@^1.1.0: +is-date-object@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.1.0.tgz#ad85541996fc7aa8b2729701d27b7319f95d82f7" integrity sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg== @@ -5088,6 +5130,13 @@ is-docker@^2.0.0, is-docker@^2.1.1: resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== +is-document.all@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-document.all/-/is-document.all-1.0.0.tgz#163a4bfb362c6ed7b118ce46cdecc4e37dee3195" + integrity sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g== + dependencies: + call-bound "^1.0.4" + is-extendable@^0.1.0, is-extendable@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" @@ -5234,7 +5283,7 @@ is-string@^1.1.1: call-bound "^1.0.3" has-tostringtag "^1.0.2" -is-symbol@^1.0.4, is-symbol@^1.1.1: +is-symbol@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.1.1.tgz#f47761279f532e2b05a7024a7506dbbedacd0634" integrity sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w== @@ -5896,9 +5945,9 @@ no-case@^3.0.4: tslib "^2.0.3" node-exports-info@^1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/node-exports-info/-/node-exports-info-1.6.0.tgz#1aedafb01a966059c9a5e791a94a94d93f5c2a13" - integrity sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw== + version "1.6.2" + resolved "https://registry.yarnpkg.com/node-exports-info/-/node-exports-info-1.6.2.tgz#243a3105677d6781cd26e6a72350fd928a5cb46f" + integrity sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag== dependencies: array.prototype.flatmap "^1.3.3" es-errors "^1.3.0" @@ -5922,10 +5971,10 @@ node-releases@^2.0.19: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.21.tgz#f59b018bc0048044be2d4c4c04e4c8b18160894c" integrity sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw== -node-releases@^2.0.36: - version "2.0.46" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.46.tgz#d188a129a83f5e03a101aacb58f260f2ee8faaa1" - integrity sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ== +node-releases@^2.0.51: + version "2.0.51" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.51.tgz#cdc08433577f5b32ad01694481726e22eeb54aef" + integrity sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ== normalize-package-data@^2.5.0: version "2.5.0" @@ -6330,7 +6379,7 @@ posix-character-classes@^1.0.0: resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-1.0.0.tgz#86917ab2d252f7ea78e157bf009b9b6ea35c6cad" integrity sha512-YXx/VWf+oJ08fmz0nwXPAQ7lxUe/HgQO2gmT0KLaX9oAWEYclXUOuZD+jDaTlqMED5OMoqOzeyOpSEt83D0HDA== -possible-typed-array-names@^1.0.0: +possible-typed-array-names@^1.0.0, possible-typed-array-names@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz#93e3582bc0e5426586d9d07b79ee40fc841de4ae" integrity sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg== @@ -6771,7 +6820,7 @@ rechoir@^0.6.2: dependencies: resolve "^1.1.6" -reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9: +reflect.getprototypeof@^1.0.10, reflect.getprototypeof@^1.0.9: version "1.0.10" resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz#c629219e78a3316d8b604c765ef68996964e7bf9" integrity sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw== @@ -7110,9 +7159,9 @@ semver@^7.1.2, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@^7.5.4: integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== semver@^7.3.5: - version "7.8.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.1.tgz#bf4970b5e70fda0686363cc18bfe8805d5ed957e" - integrity sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg== + version "7.8.5" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" + integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA== send@0.19.0: version "0.19.0" @@ -7266,7 +7315,7 @@ shelljs@^0.8.3: interpret "^1.0.0" rechoir "^0.6.2" -side-channel-list@^1.0.0: +side-channel-list@^1.0.0, side-channel-list@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.1.tgz#c2e0b5a14a540aebee3bbc6c3f8666cc9b509127" integrity sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w== @@ -7295,7 +7344,7 @@ side-channel-weakmap@^1.0.2: object-inspect "^1.13.3" side-channel-map "^1.0.1" -side-channel@^1.0.6, side-channel@^1.1.0: +side-channel@^1.0.6: version "1.1.0" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== @@ -7306,6 +7355,17 @@ side-channel@^1.0.6, side-channel@^1.1.0: side-channel-map "^1.0.1" side-channel-weakmap "^1.0.2" +side-channel@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.1.tgz#ea02c62e05dc4bea67d4442f0fb71ee192f8e0ab" + integrity sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.4" + side-channel-list "^1.0.1" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" @@ -7580,27 +7640,28 @@ string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: strip-ansi "^6.0.1" string.prototype.trim@^1.2.10: - version "1.2.10" - resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz#40b2dd5ee94c959b4dcfb1d65ce72e90da480c81" - integrity sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA== + version "1.2.11" + resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz#e6bd19cda3985d05a42dda31f3ddf4d35d3430e3" + integrity sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w== dependencies: - call-bind "^1.0.8" - call-bound "^1.0.2" + call-bind "^1.0.9" + call-bound "^1.0.4" define-data-property "^1.1.4" define-properties "^1.2.1" - es-abstract "^1.23.5" - es-object-atoms "^1.0.0" + es-abstract "^1.24.2" + es-object-atoms "^1.1.2" has-property-descriptors "^1.0.2" + safe-regex-test "^1.1.0" string.prototype.trimend@^1.0.9: - version "1.0.9" - resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz#62e2731272cd285041b36596054e9f66569b6942" - integrity sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ== + version "1.0.10" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz#be6bcf4f3fe0460bdeccdb2cf4f971b310f8346e" + integrity sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw== dependencies: - call-bind "^1.0.8" - call-bound "^1.0.2" + call-bind "^1.0.9" + call-bound "^1.0.4" define-properties "^1.2.1" - es-object-atoms "^1.0.0" + es-object-atoms "^1.1.2" string.prototype.trimstart@^1.0.8: version "1.0.8" @@ -7911,16 +7972,16 @@ typed-array-byte-offset@^1.0.4: reflect.getprototypeof "^1.0.9" typed-array-length@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.7.tgz#ee4deff984b64be1e118b0de8c9c877d5ce73d3d" - integrity sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg== + version "1.0.8" + resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.8.tgz#0b70e982c9e9dafe2def6d6458ff4b3f2d2b6d70" + integrity sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g== dependencies: - call-bind "^1.0.7" - for-each "^0.3.3" - gopd "^1.0.1" - is-typed-array "^1.1.13" - possible-typed-array-names "^1.0.0" - reflect.getprototypeof "^1.0.6" + call-bind "^1.0.9" + for-each "^0.3.5" + gopd "^1.2.0" + is-typed-array "^1.1.15" + possible-typed-array-names "^1.1.0" + reflect.getprototypeof "^1.0.10" unbox-primitive@^1.1.0: version "1.1.0" @@ -8041,6 +8102,16 @@ uuid@^8.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== +v-code-diff@^1.13.1: + version "1.13.1" + resolved "https://registry.yarnpkg.com/v-code-diff/-/v-code-diff-1.13.1.tgz#acead9aeec90ecf0769fdd23dc2e6ca2ecaa631f" + integrity sha512-9LTV1dZhC1oYTntyB94vfumGgsfIX5u0fEDSI2Txx4vCE5sI5LkgeLJRRy2SsTVZmDcV+R73sBr0GpPn0TJxMw== + dependencies: + diff "^5.2.0" + diff-match-patch "^1.0.5" + highlight.js "^11.10.0" + vue-demi "^0.14.6" + v8-compile-cache@^2.0.3: version "2.4.0" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz#cdada8bec61e15865f05d097c5f4fd30e94dc128" @@ -8083,6 +8154,11 @@ vue-cli-plugin-vuetify@^2.4.1: semver "^7.1.2" shelljs "^0.8.3" +vue-demi@^0.14.6: + version "0.14.10" + resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.10.tgz#afc78de3d6f9e11bf78c55e8510ee12814522f04" + integrity sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg== + vue-eslint-parser@^7.10.0: version "7.11.0" resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-7.11.0.tgz#214b5dea961007fcffb2ee65b8912307628d0daf" @@ -8200,9 +8276,9 @@ vuex@^3.6.2: resolved "https://registry.yarnpkg.com/vuex/-/vuex-3.6.2.tgz#236bc086a870c3ae79946f107f16de59d5895e71" integrity sha512-ETW44IqCgBpVomy520DT5jf8n0zoCac+sxWnn+hMe/CzaSejb/eVw2YToiXYX+Ex/AuHHia28vWTq4goAexFbw== -"vuntangle@git+ssh://git@github.com/untangle/vuntangle.git#ngfw-17.5-release": - version "1.60.10" - resolved "git+ssh://git@github.com/untangle/vuntangle.git#e8d6cf89eb710b5130e235e59b0b70d3c50e4577" +vuntangle@untangle/vuntangle: + version "1.62.21" + resolved "git+ssh://git@github.com/untangle/vuntangle.git#4be2185a9a07d56bab089c4ea14f94a96521893e" dependencies: "@ag-grid-community/client-side-row-model" "^25.3.0" "@ag-grid-community/core" "^25.3.0" @@ -8234,6 +8310,7 @@ vuex@^3.6.2: validator "^13.11.0" vee-validate "^3.4.10" vue-class-component "^7.2.6" + vue-grid-layout "^2.3.12" vue-i18n "^8.24.5" vue-property-decorator "^9.1.2" vue-template-compiler "2.6.14" @@ -8457,12 +8534,12 @@ which-collection@^1.0.2: is-weakset "^2.0.3" which-typed-array@^1.1.16, which-typed-array@^1.1.19: - version "1.1.20" - resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.20.tgz#3fdb7adfafe0ea69157b1509f3a1cd892bd1d122" - integrity sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg== + version "1.1.22" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.22.tgz#8f3cc78aefb40b437346dd40a1dbfa5d1da43fe9" + integrity sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw== dependencies: available-typed-arrays "^1.0.7" - call-bind "^1.0.8" + call-bind "^1.0.9" call-bound "^1.0.4" for-each "^0.3.5" get-proto "^1.0.1"
+ {{ $vuntangle.$t('available_apps_for') }} + + mdi-file-document-outline + {{ selectedPolicy.name }} + +
+ {{ currentUserEmail ? $t('no_quarantined_emails_for_user') : $t('enter_email_to_view_quarantine') }} +