Skip to content

refactor: rework the gui/heightmap store#2425

Open
meteyou wants to merge 78 commits intomainsail-crew:developfrom
meteyou:refactor/init-gui-heightmap-store
Open

refactor: rework the gui/heightmap store#2425
meteyou wants to merge 78 commits intomainsail-crew:developfrom
meteyou:refactor/init-gui-heightmap-store

Conversation

@meteyou
Copy link
Copy Markdown
Member

@meteyou meteyou commented Feb 3, 2026

Description

This PR refactor the complete gui/heightmap store and is based on #2421 .

Related Tickets & Documents

none

Mobile & Desktop Screenshots/Recordings

none

[optional] Are there any post-deployment tasks we need to perform?

none

Signed-off-by: Stefan Dej <meteyou@gmail.com>
Signed-off-by: Stefan Dej <meteyou@gmail.com>
Signed-off-by: Stefan Dej <meteyou@gmail.com>
Signed-off-by: Stefan Dej <meteyou@gmail.com>
…/init-server-process

# Conflicts:
#	src/plugins/webSocketClient.ts
Signed-off-by: Stefan Dej <meteyou@gmail.com>
…/init-server-process

# Conflicts:
#	src/components/dialogs/SpoolmanChangeSpoolDialog.vue
#	src/components/panels/SpoolmanPanel.vue
…he moonraker docs

Signed-off-by: Stefan Dej <meteyou@gmail.com>
Signed-off-by: Stefan Dej <meteyou@gmail.com>
…/init-server-process

# Conflicts:
#	src/types/MoonrakerRPCInterface.ts
…files

Signed-off-by: Stefan Dej <meteyou@gmail.com>
Signed-off-by: Stefan Dej <meteyou@gmail.com>
…sponses

Signed-off-by: Stefan Dej <meteyou@gmail.com>
Signed-off-by: Stefan Dej <meteyou@gmail.com>
Replace complex dual-timer polling system with unified async approach:
- Consolidate 7 actions into 4 by extracting shared checkAndUpdateKlippyState()
- Replace callback-based emit() with async emitAndWait()
- Merge klippy_connected_timer and klippy_state_timer into single klippy_polling_timer
- Load printer/initGcodes only when gcode.commands is empty
- Add printer.info RPC type definition

Signed-off-by: Stefan Dej <meteyou@gmail.com>
Signed-off-by: Stefan Dej <meteyou@gmail.com>
Signed-off-by: Stefan Dej <meteyou@gmail.com>
Signed-off-by: Stefan Dej <meteyou@gmail.com>
Signed-off-by: Stefan Dej <meteyou@gmail.com>
…cessing

Signed-off-by: Stefan Dej <meteyou@gmail.com>
Signed-off-by: Stefan Dej <meteyou@gmail.com>
…rver-process

# Conflicts:
#	src/plugins/webSocketClient.ts
#	src/types/moonraker/PrinterRPC.ts
#	src/types/moonraker/ServerRPC.ts
#	src/types/moonraker/index.ts
Signed-off-by: Stefan Dej <meteyou@gmail.com>
Signed-off-by: Stefan Dej <meteyou@gmail.com>
… conversion

Signed-off-by: Stefan Dej <meteyou@gmail.com>
Signed-off-by: Stefan Dej <meteyou@gmail.com>
Signed-off-by: Stefan Dej <meteyou@gmail.com>
…ity toggle logic

Signed-off-by: Stefan Dej <meteyou@gmail.com>
Signed-off-by: Stefan Dej <meteyou@gmail.com>
…tion

Signed-off-by: Stefan Dej <meteyou@gmail.com>
…ssion

Signed-off-by: Stefan Dej <meteyou@gmail.com>
…ity handling

Signed-off-by: Stefan Dej <meteyou@gmail.com>
…ate state management

Signed-off-by: Stefan Dej <meteyou@gmail.com>
…lity handling

Signed-off-by: Stefan Dej <meteyou@gmail.com>
…setter

Signed-off-by: Stefan Dej <meteyou@gmail.com>
Signed-off-by: Stefan Dej <meteyou@gmail.com>
Signed-off-by: Stefan Dej <meteyou@gmail.com>
Signed-off-by: Stefan Dej <meteyou@gmail.com>
Signed-off-by: Stefan Dej <meteyou@gmail.com>
…ations

Signed-off-by: Stefan Dej <meteyou@gmail.com>
…nd streamline layout handling

Signed-off-by: Stefan Dej <meteyou@gmail.com>
…mutations

Signed-off-by: Stefan Dej <meteyou@gmail.com>
…operations

Signed-off-by: Stefan Dej <meteyou@gmail.com>
…operations

Signed-off-by: Stefan Dej <meteyou@gmail.com>
…ult values

Signed-off-by: Stefan Dej <meteyou@gmail.com>
Signed-off-by: Stefan Dej <meteyou@gmail.com>
@meteyou meteyou requested review from dw-0, mryel00 and rackrick February 3, 2026 20:45
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 3, 2026

📝 Walkthrough

Walkthrough

This pull request introduces a comprehensive initialization system redesign with multi-stage startup flow, modernizes Vuex store architecture by consolidating mutations and socket-based persistence, refactors component state management to use store getters and actions instead of direct mutations, expands Moonraker RPC type definitions with database and history contracts, enhances WebSocket client with timeout support and improved error handling, and adds localization for initialization progress tracking.

Changes

Cohort / File(s) Summary
Initialization UI & Connection State
src/components/TheConnectingDialog.vue, src/components/.i18nignore
Adds initialization error state handling, progress tracking with indeterminate/numeric modes, and step text display; reorganizes i18n keys for initialization component labels.
Socket Initialization Actions & State
src/store/socket/actions.ts, src/store/socket/mutations.ts, src/store/socket/types.ts, src/store/socket/index.ts
Introduces new initialization lifecycle management with step, progress, and error tracking; adds mutations for initialization state transitions and resets; replaces hard-coded initialization list with empty default and TODO for async migration.
Server Initialization Architecture
src/store/server/actions.ts, src/store/server/mutations.ts, src/store/server/index.ts, src/store/server/types.ts, src/store/server/getters.ts
Refactors monolithic init into modular async-driven actions (initIdentifyClient, initServerInfo, initServerConfig, etc.); introduces connection_id and klippy_polling_timer; replaces config structure with typed ServerConfigResponse; adds getConfigSection and getConfigValue getters replacing getConfig; enhances event handling with ServerStateEvent typing and standardized formatting.
GUI Store Modernization
src/store/gui/actions.ts, src/store/gui/mutations.ts, src/store/gui/getters.ts, src/store/gui/types.ts, src/store/gui/index.ts
Consolidates multiple mutations into unified deepSet-based saveSetting; switches to socket-based persistence instead of HTTP; adds GuiViewport type; introduces initVersion field; replaces per-mutation patterns with getters (getChartDataAdditionalSensorValue); adds async getDefaults and restoreValues for state initialization.
Heightmap Store Type Standardization
src/store/gui/heightmap/actions.ts, src/store/gui/heightmap/getters.ts, src/store/gui/heightmap/index.ts, src/store/gui/heightmap/types.ts
Renames HeightmapState to GuiHeightmapState across module; updates saveSetting to async with typed generic parameters and socket-based persistence.
Component State Refactoring
src/components/mixins/base.ts, src/components/mixins/history.ts, src/components/mixins/webcam.ts
Extends guiIsReady to require initialization state nulls; types viewport getter; shifts history config access to direct top-level; replaces nested port access with server/getConfigValue getter.
Temperature Panel Updates
src/components/panels/Temperature/TemperaturePanelListItem*.vue
Replaces Vuex getters from getDatasetAdditionalSensorValue to getChartDataAdditionalSensorValue; consolidates chart color and dataset actions to gui/setChartData; updates additional sensor status actions.
File & View Panel Refactoring
src/components/panels/Gcodefiles/GcodefilesPanelHeaderSettings.vue, src/components/panels/HistoryListPanel.vue, src/components/panels/WebcamPanel.vue
Refactors metadata visibility toggle to derive state from settings; adds toggleArrayItem helper for array-based visibility; removes setter for hideColums; updates currentPage prop typing to WebcamPages; changes webcam persist action from gui/setCurrentWebcam to gui/saveSetting.
Sensor & Config Access Consolidation
src/components/panels/Miscellaneous/MoonrakerSensorValue.vue, src/components/panels/Timelapse/TimelapseStatusPanel.vue
Replaces direct server.config access with server/getConfigSection and getConfigValue getters; removes explicit existence checks; updates timelapse config access path.
Settings & Helper Updates
src/components/settings/SettingsMacrosTabSimple.vue, src/plugins/helpers.ts, src/store/gui/macros/actions.ts
Adds toggleArrayItem utility function for array toggling; uses it in macro and settings components; updates groupDelete to async with dynamic layout derivation.
WebSocket Client Enhancement
src/plugins/webSocketClient.ts
Introduces JsonRpcError/JsonRpcResponse typing; adds timeout support with WebSocketTimeoutError class; strengthens handleMessage typing; expands getWaitById/removeWaitById signatures; adds emitOptionsWithTimeout for timeout-enabled waits; improves error handling paths with null-safety.
Temperature History & Printer Actions
src/store/printer/actions.ts, src/store/printer/tempHistory/getters.ts
Removes G-code store initialization and webhooks handling; simplifies temperature store size getter to use server/getConfigValue with default.
Moonraker RPC Type Definitions
src/types/moonraker/DatabaseRPC.ts, src/types/moonraker/HistoryRPC.ts, src/types/moonraker/MachineRPC.ts, src/types/moonraker/PrinterRPC.ts, src/types/moonraker/ServerRPC.ts, src/types/moonraker/index.ts
Adds comprehensive TypeScript contracts for database CRUD, job history with totals, system machine info, printer operations, and server configuration endpoints; introduces JsonRpcError and JsonRpcResponse interfaces for JSON-RPC 2.0 compliance.
Localization
src/locales/en.json, src/locales/de.json
Adds ConnectionDialog keys for CheckingKlipper, IdentifyingClient, InitComponents with 14 component names, InitializationFailed, and LoadingComponent.
Test Coverage
tests/helpers.spec.ts
Adds test suite for toggleArrayItem covering addition, removal, immutability, and various data types.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant UI as TheConnectingDialog
    participant Socket
    participant Server
    participant Store as Vuex Store

    Client->>Socket: connect()
    Socket->>Store: emit initialize event
    activate Store
    Store->>Server: initIdentifyClient
    Store->>UI: setInitializationStep('Identifying client...')
    UI->>Client: display: "Identifying client..."
    
    Server-->>Store: client identified
    Store->>Server: initServerInfo
    Store->>UI: setInitializationStep('Checking Klipper...')
    UI->>Client: display: "Checking Klipper..."
    Server-->>Store: server info loaded
    
    Store->>Server: initServerConfig
    Store->>UI: setInitializationStepComponent('Database')
    UI->>Client: display: "Loading Database..."
    UI->>Client: setInitializationProgress(25)
    Server-->>Store: config loaded
    
    Store->>Server: initServerComponents(['Announcements', 'History', ...])
    loop for each component
        Store->>UI: setInitializationStepComponent(component)
        UI->>Client: display: "Loading {component}..."
        UI->>Client: setInitializationProgress(updated)
        Server-->>Store: component ready
    end
    
    Store->>Server: initKlippyConnection
    Server-->>Store: Klippy ready
    Store->>UI: resetInitialization()
    UI->>Client: display: fully connected
    deactivate Store
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐰 Initialization flows in stages so grand,
With sockets and stores across all the land,
Types now so strict, no mysteries left,
Progress we track from startup to cleft,
The refactor hops forward with purpose and grace!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: refactoring the gui/heightmap store. It is specific, directly related to the primary objective of the PR.
Description check ✅ Passed The description is related to the changeset, explaining that this PR refactors the complete gui/heightmap store and references a related PR. It provides context about the work being done.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Important

Action Needed: IP Allowlist Update

If your organization protects your Git platform with IP whitelisting, please add the new CodeRabbit IP address to your allowlist:

  • 136.113.208.247/32 (new)
  • 34.170.211.100/32
  • 35.222.179.152/32

Reviews will stop working after February 8, 2026 if the new IP is not added to your allowlist.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/components/mixins/history.ts (1)

31-37: ⚠️ Potential issue | 🟠 Major

Store config refactoring incomplete - base.ts still uses old nested pattern.

The change from server.config?.config to server.config in history.ts is correct and aligns with the flattened store structure. However, this refactoring is inconsistent across the codebase: src/components/mixins/base.ts (line 152) still uses the old nested pattern this.$store.state.server.config.config?.spoolman?.server, which will break at runtime.

Either base.ts needs to be updated to use the new flat pattern, or history.ts should revert to match. Verify the store structure definition and update the remaining file to maintain consistency.

src/plugins/webSocketClient.ts (1)

70-74: ⚠️ Potential issue | 🟡 Minor

Add a guard to prevent late RPC responses from reaching the notification handler.

When a timed-out request eventually responds, wait will be missing and the message is dispatched to socket/onMessage. The handler checks payload.method for notification patterns and has no explicit handling for responses with an id field—these fall through to a debug log. Add a guard to drop responses before dispatching them.

Suggested fix
-        if (!wait) {
-            this.store?.dispatch('socket/onMessage', data)
-            return
-        }
+        if (!wait) {
+            if (data.id !== undefined) return
+            this.store?.dispatch('socket/onMessage', data)
+            return
+        }
src/store/socket/mutations.ts (1)

70-85: ⚠️ Potential issue | 🟠 Major

Fix multi‑removal bug in removeInitComponent.

Removing by ascending indexes shifts the array and can skip targets or delete the wrong item when multiple matches exist. Prefer filtering or splicing from the end.

✅ Safer removal via filter
     removeInitComponent(state, payload) {
-        const list = [...state.initializationList]
-
-        // remove all components witch starts with payload
-        const indexes = list.reduce((acc: number[], item, index) => {
-            if (item.startsWith(payload)) acc.push(index)
-            return acc
-        }, [])
-
-        // stop if no items found
-        if (!indexes.length) return
-
-        // remove all items
-        indexes.forEach((index) => list.splice(index, 1))
-
-        Vue.set(state, 'initializationList', list)
+        const list = state.initializationList.filter((item) => !item.startsWith(payload))
+        if (list.length === state.initializationList.length) return
+        Vue.set(state, 'initializationList', list)
     },
🤖 Fix all issues with AI agents
In `@src/plugins/webSocketClient.ts`:
- Around line 199-203: The code is creating WebSocketTimeoutError with a string
that already includes "Timeout:" causing a double prefix; update the timeout
rejection to pass the raw method (or other plain identifier) to the
WebSocketTimeoutError constructor instead of a preformatted message (so replace
new WebSocketTimeoutError(`Timeout: ${method}`) with new
WebSocketTimeoutError(method)), and make the same change in the other timeout
block later in the file (the one that also calls this.removeWaitById and rejects
with WebSocketTimeoutError) so the constructor's own prefix is the only
"Timeout:" present.

In `@src/store/gui/actions.ts`:
- Around line 24-38: The init action dispatches restoreValues without awaiting
it, causing concurrent mutations to values (e.g., deletion of
values.remoteprinters and calls to deletePath) to race with restoreValues;
change the call to await the dispatch so restoreValues finishes before mutating
values (await dispatch('restoreValues', values) or await
this.dispatch('restoreValues', values)), keeping the rest of init (mutations and
deletePath calls) after that await to prevent inconsistent persistence and
unhandled rejections.

In `@src/store/gui/mutations.ts`:
- Around line 16-26: The deepSet helper inside saveSetting creates intermediate
objects even when payload.value is undefined, leading to unintended state
mutations; update saveSetting to guard early by returning immediately when
payload.value === undefined (so deletions are handled by deleteSetting instead)
or modify deepSet to not call Vue.set(...) to create new nodes when value is
undefined — ensure the check prevents creating empty objects while still
allowing valid assignments in saveSetting/deepSet.

In `@src/store/server/actions.ts`:
- Around line 187-196: The current creation of regexFilters via new
RegExp(filter) in initGcodeStore and the socket message handler risks ReDoS from
user-supplied patterns; validate patterns before compiling by using a safe-regex
check (e.g., add the safe-regex library or an equivalent timeout/AST-based
validator) when filters are persisted or immediately before constructing RegExp
in the arrays mapped to regexFilters and the socket handler, reject or sanitize
patterns that fail the safety check and log the rejected pattern via logError
(reference symbols: regexFilters, filters, initGcodeStore and the socket event
handler where incoming messages are filtered) so only safe, pre-validated
regexes are compiled and used during high-frequency .test() calls.

In `@src/store/server/mutations.ts`:
- Around line 101-105: The early return in setThrottledState when payload ===
null leaves stale values in state.throttled_state; change the handler so that
when payload is null it explicitly resets/clears the throttled state (e.g.
Vue.set(state.throttled_state, 'bits', null/0/empty) and
Vue.set(state.throttled_state, 'flags', null/0/empty) or restores your chosen
defaults) and otherwise keep the existing conditional Vue.set updates for 'bits'
and 'flags'; update the function setThrottledState and the uses of
state.throttled_state to ensure the cleared default values represent "no
throttling info."
🧹 Nitpick comments (5)
src/components/panels/Gcodefiles/GcodefilesPanelHeaderSettings.vue (1)

76-83: Consider reusing the shared toggle helper.

This manual toggle logic can be simplified and kept consistent with other panels by using toggleArrayItem.

♻️ Suggested refactor
-import { Component, Mixins } from 'vue-property-decorator'
+import { Component, Mixins } from 'vue-property-decorator'
+import { toggleArrayItem } from '@/plugins/helpers'
@@
     changeMetadataVisible(name: string) {
-        const value = [...(this.$store.state.gui.view.gcodefiles.hideMetadataColumns ?? [])]
-        const index = value.findIndex((value: string) => value === name)
-
-        if (index !== -1) value.splice(index, 1)
-        else value.push(name)
+        const current = this.$store.state.gui.view.gcodefiles.hideMetadataColumns ?? []
+        const value = toggleArrayItem(current, name)
 
         this.$store.dispatch('gui/saveSetting', { name: 'view.gcodefiles.hideMetadataColumns', value })
     }
src/store/gui/types.ts (1)

10-12: Consider array handling in DeepPartial.

The current implementation treats arrays as objects, making their elements optional. This is typically fine for state initialization payloads, but if you need to preserve array types exactly, consider adding an array check:

export type DeepPartial<T> = {
    [P in keyof T]?: T[P] extends (infer U)[]
        ? U[]
        : T[P] extends object
          ? DeepPartial<T[P]>
          : T[P]
}

This is a minor edge case and the current implementation should work for the intended use cases in this PR.

src/store/gui/getters.ts (1)

32-35: Consider guarding datasetSettings itself to avoid null/undefined access.

If datasetSettings can be unset during early init, optional chaining on the map access prevents a runtime error.

💡 Suggested defensive tweak
-        const additionalSensors = state.view.tempchart.datasetSettings[payload.name]?.additionalSensors ?? {}
+        const additionalSensors =
+            state.view.tempchart.datasetSettings?.[payload.name]?.additionalSensors ?? {}
src/store/gui/macros/actions.ts (1)

61-82: Handle RPC failure to avoid local/remote divergence.

groupDelete mutates local state before awaiting the server delete; if the RPC fails, the UI will show the group removed while the server still has it. Consider deleting first (or add rollback handling).

🛠️ Suggested reorder
 async groupDelete({ commit, dispatch, rootState }, id) {
-        commit('groupDelete', id)
-        await Vue.$socket.emitAndWait('server.database.delete_item', {
+        await Vue.$socket.emitAndWait('server.database.delete_item', {
             namespace: 'mainsail',
             key: `macros.macrogroups.${id}`,
         })
+        commit('groupDelete', id)
 
         type DashboardLayoutKey = Exclude<keyof GuiState['dashboard'], 'nonExpandPanels'>
         const layouts = Object.keys(rootState.gui?.dashboard ?? {}).filter(
             (key) => key !== 'nonExpandPanels'
         ) as DashboardLayoutKey[]
src/store/gui/heightmap/actions.ts (1)

6-8: Consider unifying persistence patterns.

saveActiveColorScheme commits directly to the local mutation, while saveSetting dispatches to the centralized gui/saveSetting action. For consistency, consider whether saveActiveColorScheme should also persist to the database through the centralized action.

Comment on lines +199 to +203
if (options.timeout) {
timeoutId = window.setTimeout(() => {
this.removeWaitById(id)
reject(new WebSocketTimeoutError(`Timeout: ${method}`))
}, options.timeout)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Avoid double “Timeout:” prefix in WebSocketTimeoutError.
Constructor already prefixes; passing a preformatted string yields “Timeout: Timeout: …”.

🐛 Proposed fix
-                    reject(new WebSocketTimeoutError(`Timeout: ${method}`))
+                    reject(new WebSocketTimeoutError(method))

Also applies to: 325-329

🤖 Prompt for AI Agents
In `@src/plugins/webSocketClient.ts` around lines 199 - 203, The code is creating
WebSocketTimeoutError with a string that already includes "Timeout:" causing a
double prefix; update the timeout rejection to pass the raw method (or other
plain identifier) to the WebSocketTimeoutError constructor instead of a
preformatted message (so replace new WebSocketTimeoutError(`Timeout: ${method}`)
with new WebSocketTimeoutError(method)), and make the same change in the other
timeout block later in the file (the one that also calls this.removeWaitById and
rejects with WebSocketTimeoutError) so the constructor's own prefix is the only
"Timeout:" present.

Comment on lines +24 to 38
async init({ commit, dispatch, rootGetters, rootState }) {
logDebug('init')

async initStore({ commit, dispatch, rootGetters, rootState }, payload) {
const baseUrl = rootGetters['socket/getUrl'] + '/server/database/item'
const mainsailUrl = baseUrl + '?namespace=mainsail'
let values: GuiStateInitPayload = {}
try {
const payload = await Vue.$socket.emitAndWait('server.database.get_item', { namespace: 'mainsail' })
values = payload.value as GuiStateInitPayload
} catch (error) {
logDebug('create Mainsail namespace')

if ('remoteprinters' in payload.value) {
if (rootState.instancesDB === 'moonraker')
dispatch('remoteprinters/initStore', payload.value.remoteprinters.printers)
delete payload.value.remoteprinters
}
values = await this.dispatch('gui/getDefaults')
values.initVersion = rootGetters['getVersion']

// delete currentPath if exists
if (payload.value?.view?.gcodefiles?.currentPath) {
window.console.debug('remove currentPath from gui namespace')
await fetch(mainsailUrl + '&key=view.gcodefiles.currentPath', { method: 'DELETE' })
dispatch('restoreValues', values)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the full init function to see the complete context
cat -n src/store/gui/actions.ts | head -50

Repository: mainsail-crew/mainsail

Length of output: 2182


🏁 Script executed:

# Let's also search for the restoreValues action definition
rg "restoreValues" src/store/gui/actions.ts -A 10 -B 2

Repository: mainsail-crew/mainsail

Length of output: 1047


🏁 Script executed:

# Get file size to understand how much content we're dealing with
wc -l src/store/gui/actions.ts

Repository: mainsail-crew/mainsail

Length of output: 94


🏁 Script executed:

rg "async restoreValues" src/store/gui/actions.ts -A 30

Repository: mainsail-crew/mainsail

Length of output: 1305


🏁 Script executed:

# Also check if there are any other async operations after line 37
sed -n '37,100p' src/store/gui/actions.ts

Repository: mainsail-crew/mainsail

Length of output: 2403


Await restoreValues to prevent race condition with value mutations.

restoreValues is dispatched without await but the values object is mutated immediately afterward (line 44: delete values.remoteprinters, lines 47-59: deletePath(values, key)). Since restoreValues is async and iterates through the values object while making socket calls, mutations during this process can cause inconsistent data to be persisted and hide errors as unhandled rejections.

🛠️ Suggested fix
-            dispatch('restoreValues', values)
+            await dispatch('restoreValues', values)
📝 Committable suggestion

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

Suggested change
async init({ commit, dispatch, rootGetters, rootState }) {
logDebug('init')
async initStore({ commit, dispatch, rootGetters, rootState }, payload) {
const baseUrl = rootGetters['socket/getUrl'] + '/server/database/item'
const mainsailUrl = baseUrl + '?namespace=mainsail'
let values: GuiStateInitPayload = {}
try {
const payload = await Vue.$socket.emitAndWait('server.database.get_item', { namespace: 'mainsail' })
values = payload.value as GuiStateInitPayload
} catch (error) {
logDebug('create Mainsail namespace')
if ('remoteprinters' in payload.value) {
if (rootState.instancesDB === 'moonraker')
dispatch('remoteprinters/initStore', payload.value.remoteprinters.printers)
delete payload.value.remoteprinters
}
values = await this.dispatch('gui/getDefaults')
values.initVersion = rootGetters['getVersion']
// delete currentPath if exists
if (payload.value?.view?.gcodefiles?.currentPath) {
window.console.debug('remove currentPath from gui namespace')
await fetch(mainsailUrl + '&key=view.gcodefiles.currentPath', { method: 'DELETE' })
dispatch('restoreValues', values)
}
async init({ commit, dispatch, rootGetters, rootState }) {
logDebug('init')
let values: GuiStateInitPayload = {}
try {
const payload = await Vue.$socket.emitAndWait('server.database.get_item', { namespace: 'mainsail' })
values = payload.value as GuiStateInitPayload
} catch (error) {
logDebug('create Mainsail namespace')
values = await this.dispatch('gui/getDefaults')
values.initVersion = rootGetters['getVersion']
await dispatch('restoreValues', values)
}
🤖 Prompt for AI Agents
In `@src/store/gui/actions.ts` around lines 24 - 38, The init action dispatches
restoreValues without awaiting it, causing concurrent mutations to values (e.g.,
deletion of values.remoteprinters and calls to deletePath) to race with
restoreValues; change the call to await the dispatch so restoreValues finishes
before mutating values (await dispatch('restoreValues', values) or await
this.dispatch('restoreValues', values)), keeping the rest of init (mutations and
deletePath calls) after that await to prevent inconsistent persistence and
unhandled rejections.

Comment on lines 16 to +26
saveSetting(state, payload: { name: string; value: any }) {
// eslint-disable-next-line
const deepSet = (obj: any, is: string[] | string, value: any): any => {
if (is !== undefined && typeof is === 'string') return deepSet(obj, is.split('.'), value)
else if (is.length == 1 && value !== undefined) return (obj[is[0]] = value)
else if (is.length == 0) return obj
else if (!(is[0] in obj)) obj[is[0]] = {}
return deepSet(obj[is[0]], is.slice(1), value)
}

deepSet(state, payload.name, payload.value)
},

setHeaterChartVisibility(state, payload) {
const index = state.view.tempchart.hiddenDataset.indexOf(payload.name.toUpperCase())

if (payload.hidden && index === -1) state.view.tempchart.hiddenDataset.push(payload.name.toUpperCase())
else if (payload.hidden !== true && index > -1) state.view.tempchart.hiddenDataset.splice(index, 1)
},

setGcodefilesMetadata(state, data) {
const array = [...state.view.gcodefiles.hideMetadataColumns]
const index = array.findIndex((value: string) => value === data.name)

if (data.value && index !== -1) array.splice(index, 1)
else if (!data.value && index === -1) array.push(data.name)

Vue.set(state.view.gcodefiles, 'hideMetadataColumns', array)
},
else if (is?.length === 1 && value !== undefined) return Vue.set(obj, is[0], value)
else if (is?.length === 0) return obj
else if (!(is[0] in obj)) Vue.set(obj, is[0], {})

setGcodefilesShowHiddenFiles(state, value) {
Vue.set(state.view.gcodefiles, 'showHiddenFiles', value)
},

setCurrentWebcam(state, payload) {
Vue.set(state.view.webcam.currentCam, payload.page, payload.value)
},

setHistoryColumns(state, data) {
if (data.value && state.view.history.hideColums.includes(data.name)) {
state.view.history.hideColums.splice(state.view.history.hideColums.indexOf(data.name), 1)
} else if (!data.value && !state.view.history.hideColums.includes(data.name)) {
state.view.history.hideColums.push(data.name)
return deepSet(obj[is[0]], is?.slice(1), value)
}
},

setHistoryHidePrintStatus(state, payload) {
Vue.set(state.view.history, 'hidePrintStatus', payload)
},

addClosePanel(state, payload) {
const nonExpandPanels = [...state.dashboard.nonExpandPanels[payload.viewport]]

if (!nonExpandPanels.includes(payload.name)) {
nonExpandPanels.push(payload.name)

Vue.set(state.dashboard.nonExpandPanels, payload.viewport, nonExpandPanels)
}
},

removeClosePanel(state, payload) {
const nonExpandPanels = [...state.dashboard.nonExpandPanels[payload.viewport]]
const index = nonExpandPanels.indexOf(payload.name)
if (index > -1) {
nonExpandPanels.splice(index, 1)

Vue.set(state.dashboard.nonExpandPanels, payload.viewport, nonExpandPanels)
}
},

deleteFromDashboardLayout(state, payload) {
// @ts-ignore
const layoutArray = [...state.dashboard[payload.layoutname]]
layoutArray.splice(payload.index, 1)
Vue.set(state.dashboard, payload.layoutname, layoutArray)
},

setChartDatasetStatus(state, payload: { objectName: string; dataset: string; value: boolean }) {
// set new value if object doesn't exist in view.tempchart.datasetSettings
if (!(payload.objectName in state.view.tempchart.datasetSettings)) {
const newVal: { [key: string]: any } = {}
newVal[payload.dataset] = payload.value

Vue.set(state.view.tempchart.datasetSettings, payload.objectName, newVal)
return
}

Vue.set(state.view.tempchart.datasetSettings[payload.objectName], payload.dataset, payload.value)
},

setDatasetAdditionalSensorStatus(state, payload: { objectName: string; dataset: string; value: boolean }) {
// set new value if object doesn't exist in view.tempchart.datasetSettings
if (!(payload.objectName in state.view.tempchart.datasetSettings)) {
const newVal: { additionalSensors: { [key: string]: any } } = { additionalSensors: {} }
newVal.additionalSensors[payload.dataset] = payload.value

Vue.set(state.view.tempchart.datasetSettings, payload.objectName, newVal)
return
}

// set new value if additionalSensor object doesn't exist in view.tempchart.datasetSettings
if (!('additionalSensors' in state.view.tempchart.datasetSettings[payload.objectName])) {
const newVal: { [key: string]: any } = {}
newVal[payload.dataset] = payload.value

Vue.set(state.view.tempchart.datasetSettings[payload.objectName], 'additionalSensors', newVal)
return
}

Vue.set(
state.view.tempchart.datasetSettings[payload.objectName].additionalSensors,
payload.dataset,
payload.value
)
deepSet(state, payload.name, payload.value)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Guard against unintended state mutations when value is undefined.

If saveSetting is called with value: undefined, deepSet still creates intermediate objects and leaves empty nodes in state. An early return keeps state stable and pushes removals to deleteSetting.

🛠️ Suggested fix
         const deepSet = (obj: any, is: string[] | string, value: any): any => {
+            if (value === undefined) return obj
             if (is !== undefined && typeof is === 'string') return deepSet(obj, is.split('.'), value)
             else if (is?.length === 1 && value !== undefined) return Vue.set(obj, is[0], value)
             else if (is?.length === 0) return obj
             else if (!(is[0] in obj)) Vue.set(obj, is[0], {})
📝 Committable suggestion

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

Suggested change
saveSetting(state, payload: { name: string; value: any }) {
// eslint-disable-next-line
const deepSet = (obj: any, is: string[] | string, value: any): any => {
if (is !== undefined && typeof is === 'string') return deepSet(obj, is.split('.'), value)
else if (is.length == 1 && value !== undefined) return (obj[is[0]] = value)
else if (is.length == 0) return obj
else if (!(is[0] in obj)) obj[is[0]] = {}
return deepSet(obj[is[0]], is.slice(1), value)
}
deepSet(state, payload.name, payload.value)
},
setHeaterChartVisibility(state, payload) {
const index = state.view.tempchart.hiddenDataset.indexOf(payload.name.toUpperCase())
if (payload.hidden && index === -1) state.view.tempchart.hiddenDataset.push(payload.name.toUpperCase())
else if (payload.hidden !== true && index > -1) state.view.tempchart.hiddenDataset.splice(index, 1)
},
setGcodefilesMetadata(state, data) {
const array = [...state.view.gcodefiles.hideMetadataColumns]
const index = array.findIndex((value: string) => value === data.name)
if (data.value && index !== -1) array.splice(index, 1)
else if (!data.value && index === -1) array.push(data.name)
Vue.set(state.view.gcodefiles, 'hideMetadataColumns', array)
},
else if (is?.length === 1 && value !== undefined) return Vue.set(obj, is[0], value)
else if (is?.length === 0) return obj
else if (!(is[0] in obj)) Vue.set(obj, is[0], {})
setGcodefilesShowHiddenFiles(state, value) {
Vue.set(state.view.gcodefiles, 'showHiddenFiles', value)
},
setCurrentWebcam(state, payload) {
Vue.set(state.view.webcam.currentCam, payload.page, payload.value)
},
setHistoryColumns(state, data) {
if (data.value && state.view.history.hideColums.includes(data.name)) {
state.view.history.hideColums.splice(state.view.history.hideColums.indexOf(data.name), 1)
} else if (!data.value && !state.view.history.hideColums.includes(data.name)) {
state.view.history.hideColums.push(data.name)
return deepSet(obj[is[0]], is?.slice(1), value)
}
},
setHistoryHidePrintStatus(state, payload) {
Vue.set(state.view.history, 'hidePrintStatus', payload)
},
addClosePanel(state, payload) {
const nonExpandPanels = [...state.dashboard.nonExpandPanels[payload.viewport]]
if (!nonExpandPanels.includes(payload.name)) {
nonExpandPanels.push(payload.name)
Vue.set(state.dashboard.nonExpandPanels, payload.viewport, nonExpandPanels)
}
},
removeClosePanel(state, payload) {
const nonExpandPanels = [...state.dashboard.nonExpandPanels[payload.viewport]]
const index = nonExpandPanels.indexOf(payload.name)
if (index > -1) {
nonExpandPanels.splice(index, 1)
Vue.set(state.dashboard.nonExpandPanels, payload.viewport, nonExpandPanels)
}
},
deleteFromDashboardLayout(state, payload) {
// @ts-ignore
const layoutArray = [...state.dashboard[payload.layoutname]]
layoutArray.splice(payload.index, 1)
Vue.set(state.dashboard, payload.layoutname, layoutArray)
},
setChartDatasetStatus(state, payload: { objectName: string; dataset: string; value: boolean }) {
// set new value if object doesn't exist in view.tempchart.datasetSettings
if (!(payload.objectName in state.view.tempchart.datasetSettings)) {
const newVal: { [key: string]: any } = {}
newVal[payload.dataset] = payload.value
Vue.set(state.view.tempchart.datasetSettings, payload.objectName, newVal)
return
}
Vue.set(state.view.tempchart.datasetSettings[payload.objectName], payload.dataset, payload.value)
},
setDatasetAdditionalSensorStatus(state, payload: { objectName: string; dataset: string; value: boolean }) {
// set new value if object doesn't exist in view.tempchart.datasetSettings
if (!(payload.objectName in state.view.tempchart.datasetSettings)) {
const newVal: { additionalSensors: { [key: string]: any } } = { additionalSensors: {} }
newVal.additionalSensors[payload.dataset] = payload.value
Vue.set(state.view.tempchart.datasetSettings, payload.objectName, newVal)
return
}
// set new value if additionalSensor object doesn't exist in view.tempchart.datasetSettings
if (!('additionalSensors' in state.view.tempchart.datasetSettings[payload.objectName])) {
const newVal: { [key: string]: any } = {}
newVal[payload.dataset] = payload.value
Vue.set(state.view.tempchart.datasetSettings[payload.objectName], 'additionalSensors', newVal)
return
}
Vue.set(
state.view.tempchart.datasetSettings[payload.objectName].additionalSensors,
payload.dataset,
payload.value
)
deepSet(state, payload.name, payload.value)
saveSetting(state, payload: { name: string; value: any }) {
const deepSet = (obj: any, is: string[] | string, value: any): any => {
if (value === undefined) return obj
if (is !== undefined && typeof is === 'string') return deepSet(obj, is.split('.'), value)
else if (is?.length === 1 && value !== undefined) return Vue.set(obj, is[0], value)
else if (is?.length === 0) return obj
else if (!(is[0] in obj)) Vue.set(obj, is[0], {})
return deepSet(obj[is[0]], is?.slice(1), value)
}
deepSet(state, payload.name, payload.value)
}
🤖 Prompt for AI Agents
In `@src/store/gui/mutations.ts` around lines 16 - 26, The deepSet helper inside
saveSetting creates intermediate objects even when payload.value is undefined,
leading to unintended state mutations; update saveSetting to guard early by
returning immediately when payload.value === undefined (so deletions are handled
by deleteSetting instead) or modify deepSet to not call Vue.set(...) to create
new nodes when value is undefined — ensure the check prevents creating empty
objects while still allowing valid assignments in saveSetting/deepSet.

Comment on lines +187 to +196
const regexFilters = filters
.map((filter) => {
try {
return new RegExp(filter)
} catch {
logError('invalid console filter:', filter)
return null
}
})
.filter((r): r is RegExp => r !== null)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the file and the specific lines mentioned
head -200 src/store/server/actions.ts | tail -30

Repository: mainsail-crew/mainsail

Length of output: 1455


🏁 Script executed:

# Also check lines 327-333
sed -n '320,340p' src/store/server/actions.ts

Repository: mainsail-crew/mainsail

Length of output: 829


🏁 Script executed:

# Search for any existing regex validation or safe-regex imports
rg -i 'safe-regex|safeRegex|recheck' src/store/server/actions.ts

Repository: mainsail-crew/mainsail

Length of output: 48


🏁 Script executed:

# Check if safe-regex is in dependencies
cat package.json | jq '.dependencies, .devDependencies' | grep -i 'safe\|regex'

Repository: mainsail-crew/mainsail

Length of output: 48


🏁 Script executed:

# Search for how filters are used/applied throughout the file
rg 'filter' src/store/server/actions.ts -A 2 -B 2 | head -50

Repository: mainsail-crew/mainsail

Length of output: 1497


🏁 Script executed:

# Find where console filters are set/validated
rg 'setConsolefilterRules|getConsolefilterRules' --type ts --type tsx -B 5 -A 5

Repository: mainsail-crew/mainsail

Length of output: 93


🏁 Script executed:

# Look for the gui/console module to understand filter management
fd -e ts -e tsx gui | grep -i console

Repository: mainsail-crew/mainsail

Length of output: 48


🏁 Script executed:

# Check the full context of how filters are persisted
rg 'ConsolefilterRules' --type ts --type tsx -B 2 -A 2

Repository: mainsail-crew/mainsail

Length of output: 93


🏁 Script executed:

# Search for console filter rules without file type flag
rg 'setConsolefilterRules|getConsolefilterRules' -A 5 -B 5

Repository: mainsail-crew/mainsail

Length of output: 2270


🏁 Script executed:

# Find files related to console module
fd -name '*.ts' -path '*/console*' -o -name 'console.ts'

Repository: mainsail-crew/mainsail

Length of output: 296


🏁 Script executed:

# Search for where filters might be saved/validated
rg 'Consolefilter' -A 3 -B 3

Repository: mainsail-crew/mainsail

Length of output: 2549


🏁 Script executed:

# Check for any RegExp validation patterns in the codebase
rg 'ReDoS|catastrophic|backtrack'

Repository: mainsail-crew/mainsail

Length of output: 48


🏁 Script executed:

# Check the console getter implementation more fully
sed -n '1,100p' src/store/gui/console/getters.ts

Repository: mainsail-crew/mainsail

Length of output: 1501


🏁 Script executed:

# Look for console mutations/actions where filters are saved
fd -type f -name '*.ts' -path '*/console/*' | head -20

Repository: mainsail-crew/mainsail

Length of output: 236


🏁 Script executed:

# Search for where user-supplied filters are created or edited
rg 'consolefilters\[' src/store/gui/console/ -A 3 -B 3

Repository: mainsail-crew/mainsail

Length of output: 2419


🏁 Script executed:

# Check if there are any other regex pattern compilations in the codebase that apply to user input
rg 'new RegExp' src/store/server/actions.ts -B 5 -A 5

Repository: mainsail-crew/mainsail

Length of output: 896


🏁 Script executed:

# Check if safe-regex or similar is listed in package.json
cat package.json | grep -A 50 '"dependencies"' | grep -E 'safe|regex|redos'

Repository: mainsail-crew/mainsail

Length of output: 48


🏁 Script executed:

# Look for any validation of regex patterns before they're used
rg 'test.*regex|regex.*test' src/store/gui/console/ -B 3 -A 3

Repository: mainsail-crew/mainsail

Length of output: 48


Add ReDoS validation for user-supplied console filters to prevent UI lockups.

new RegExp(filter) is compiled from persisted user input and applied to every GCode event in high-frequency operations. A catastrophic regex pattern (e.g., (a+)+b) will compile without error but hang the UI during .test() execution. Current try-catch blocks only handle syntax errors, not catastrophic backtracking.

This occurs in two places:

  1. Line 187-196: initGcodeStore — filters all stored gcode events
  2. Line 327-333: socket event handler — filters every incoming message

Validate filter patterns at save time (preferred) or use a runtime safety check (e.g., safe-regex library) before regex compilation.

🧰 Tools
🪛 ast-grep (0.40.5)

[warning] 189-189: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(filter)
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html

(regexp-from-variable)

🤖 Prompt for AI Agents
In `@src/store/server/actions.ts` around lines 187 - 196, The current creation of
regexFilters via new RegExp(filter) in initGcodeStore and the socket message
handler risks ReDoS from user-supplied patterns; validate patterns before
compiling by using a safe-regex check (e.g., add the safe-regex library or an
equivalent timeout/AST-based validator) when filters are persisted or
immediately before constructing RegExp in the arrays mapped to regexFilters and
the socket handler, reject or sanitize patterns that fail the safety check and
log the rejected pattern via logError (reference symbols: regexFilters, filters,
initGcodeStore and the socket event handler where incoming messages are
filtered) so only safe, pre-validated regexes are compiled and used during
high-frequency .test() calls.

Comment on lines +101 to +105
setThrottledState(state, payload: ServerState['throttled_state'] | null) {
if (payload === null) return

setSystemBootAt(state, payload) {
Vue.set(state, 'system_boot_at', payload)
if ('bits' in payload) Vue.set(state.throttled_state, 'bits', payload.bits)
if ('flags' in payload) Vue.set(state.throttled_state, 'flags', payload.flags)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Null payload should probably clear throttled state.

Returning early on null can leave old bits/flags in place and misrepresent current throttling. If null means “no throttling info,” consider resetting to defaults.

🧹 Suggested reset on null
     setThrottledState(state, payload: ServerState['throttled_state'] | null) {
-        if (payload === null) return
+        if (payload === null) {
+            Vue.set(state.throttled_state, 'bits', 0)
+            Vue.set(state.throttled_state, 'flags', [])
+            return
+        }
 
         if ('bits' in payload) Vue.set(state.throttled_state, 'bits', payload.bits)
         if ('flags' in payload) Vue.set(state.throttled_state, 'flags', payload.flags)
     },
📝 Committable suggestion

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

Suggested change
setThrottledState(state, payload: ServerState['throttled_state'] | null) {
if (payload === null) return
setSystemBootAt(state, payload) {
Vue.set(state, 'system_boot_at', payload)
if ('bits' in payload) Vue.set(state.throttled_state, 'bits', payload.bits)
if ('flags' in payload) Vue.set(state.throttled_state, 'flags', payload.flags)
setThrottledState(state, payload: ServerState['throttled_state'] | null) {
if (payload === null) {
Vue.set(state.throttled_state, 'bits', 0)
Vue.set(state.throttled_state, 'flags', [])
return
}
if ('bits' in payload) Vue.set(state.throttled_state, 'bits', payload.bits)
if ('flags' in payload) Vue.set(state.throttled_state, 'flags', payload.flags)
}
🤖 Prompt for AI Agents
In `@src/store/server/mutations.ts` around lines 101 - 105, The early return in
setThrottledState when payload === null leaves stale values in
state.throttled_state; change the handler so that when payload is null it
explicitly resets/clears the throttled state (e.g.
Vue.set(state.throttled_state, 'bits', null/0/empty) and
Vue.set(state.throttled_state, 'flags', null/0/empty) or restores your chosen
defaults) and otherwise keep the existing conditional Vue.set updates for 'bits'
and 'flags'; update the function setThrottledState and the uses of
state.throttled_state to ensure the cleared default values represent "no
throttling info."

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant