Skip to content

refactor: rework the gui/gcodehistory store #2424

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

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

Conversation

@meteyou
Copy link
Copy Markdown
Member

@meteyou meteyou commented Feb 3, 2026

Description

This PR refactor the complete gui/gcodehistory 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 06:05
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 3, 2026

📝 Walkthrough

Walkthrough

This pull request refactors server initialization from HTTP-based to WebSocket-driven async patterns, introducing progress tracking and staged initialization steps. It adds comprehensive Moonraker RPC type definitions, centralizes config access through getters, and enhances error handling with new initialization state management throughout the application.

Changes

Cohort / File(s) Summary
i18n Translation Keys
src/components/.i18nignore, src/locales/en.json, src/locales/de.json
Added new ConnectionDialog translation keys for initialization states (CheckingKlipper, IdentifyingClient, InitComponents with per-component entries, InitializationFailed, LoadingComponent).
Connection Dialog UI
src/components/TheConnectingDialog.vue, src/components/mixins/base.ts
Integrated initialization state observables (progress, error, step text); updated guiIsReady condition to check initialization state; enhanced error message rendering with neutral/red variants; added progress bar with indeterminate support.
Server Store - Core Initialization
src/store/server/actions.ts, src/store/server/mutations.ts, src/store/server/index.ts, src/store/server/types.ts
Refactored monolithic init into staged async actions (identifyClient, initServerInfo, initServerConfig, initSystemInfo, initProcStats, initDatabases, initServerComponents, initGcodeStore, initKlippyConnection); added Klippy polling with timeout handling; introduced connection_id, config_orig, config_files state fields; replaced timers with klippy_polling_timer; added ServerStateEvent type; reworked error handling with initialization progress tracking.
Server Store - Config Access
src/store/server/getters.ts
Replaced single getConfig getter with two-step pattern: getConfigSection (returns entire section) and getConfigValue (returns specific attribute with fallback); enables safer, more flexible config lookups.
GUI Store - Persistence & Defaults
src/store/gui/actions.ts, src/store/gui/mutations.ts, src/store/gui/index.ts, src/store/gui/types.ts
Introduced socket-based saveSetting and deleteSetting actions; added getDefaults and restoreValues for backup/restore flows; refactored init to use socket operations; added initVersion state field; introduced DeepPartial utility type and GuiViewport union; changed dashboard.nonExpandPanels to strongly-typed Record<GuiViewport, string[]>; consolidated mutations with guarded deepSet logic.
GUI Store - Getters & Panels
src/store/gui/getters.ts, src/store/gui/console/getters.ts
Renamed getDatasetAdditionalSensorValue to getChartDataAdditionalSensorValue with optional chaining; updated getPanelExpand to accept GuiViewport instead of string; added return type annotations to getConsolefilterRules.
Component Panels - Config Access Refactor
src/components/panels/Gcodefiles/GcodefilesPanelHeaderSettings.vue, src/components/panels/HistoryListPanel.vue, src/components/panels/Miscellaneous/MoonrakerSensorValue.vue, src/components/panels/Temperature/TemperaturePanelListItem*.vue
Updated components to use new server/getConfigSection and server/getConfigValue getters; changed changeMetadataVisible and changeStatusVisible signatures; introduced toggleArrayItem helper for array mutations; replaced direct state mutations with gui/saveSetting dispatches; refactored metadata/status visibility to use centralized persistence.
Component Panels - Layout Access
src/components/panels/WebcamPanel.vue, src/components/panels/Timelapse/TimelapseStatusPanel.vue
Updated webcam panel with WebcamPages type alias for currentPage prop; switched from gui/setCurrentWebcam to gui/saveSetting pattern; refactored Timelapse panel to use getConfigSection getter instead of direct state access with optional chaining.
Component Mixins
src/components/mixins/history.ts, src/components/mixins/webcam.ts
Updated history mixin to read config directly from state.server.config (not nested .config.config); refactored webcam mixin to use server/getConfigValue getter instead of direct nested state access for port retrieval.
Component Settings
src/components/settings/SettingsMacrosTabSimple.vue
Replaced manual toggle logic with toggleArrayItem helper for hiddenMacros management.
Socket Store - Initialization State
src/store/socket/actions.ts, src/store/socket/mutations.ts, src/store/socket/index.ts, src/store/socket/types.ts
Added new actions setInitializationStep, setInitializationStepComponent, setInitializationProgress, setInitializationError, resetInitialization; introduced corresponding mutations; extended SocketState with initializationStep, initializationProgress, initializationError fields; added safeguards to addInitModule/removeInitModule; updated initializationList initialization to empty array.
Printer Store
src/store/printer/actions.ts, src/store/printer/tempHistory/getters.ts
Removed gcode store initialization and webhooks handling during printer init; updated getTemperatureStoreSize to use server/getConfigValue getter with default fallback.
GCode History Store
src/store/gui/gcodehistory/actions.ts, src/store/gui/gcodehistory/mutations.ts
Simplified addToHistory to deduplicate, slice, and persist via gui/saveSetting; removed upload action and updateHistory mutation; eliminated Vue.set usage in mutations.
WebSocket Client
src/plugins/webSocketClient.ts
Enhanced error handling with JsonRpcError/JsonRpcResponse types; added timeout support via emitOptionsWithTimeout with WebSocketTimeoutError; updated Wait interface with unknown resolve type and optional JsonRpcError reject; added guarded error checking and wait lifecycle improvements.
Utilities
src/plugins/helpers.ts
Added toggleArrayItem utility function for immutable array mutations (add if absent, remove if present).
Moonraker RPC 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
Added comprehensive new RPC endpoint definitions: DatabaseRPC (list, get_item, post_item, delete_item, compact, backup, restore); HistoryRPC (list, totals, reset_totals, get_job, delete_job); MachineRPC (system_info, proc_stats, shutdown, services, sudo, peripherals, canbus); PrinterRPC additions (info, emergency_stop, restart, query_endstops, gcode operations, print control); ServerRPC additions (info, config, temperature_store, gcode_store, logs.rollover, restart); MoonrakerRPCInterface now extends all five categories; added JsonRpcError and JsonRpcResponse types.
Tests
tests/helpers.spec.ts
Replaced hello world test with comprehensive toggleArrayItem test suite covering addition, removal, non-mutation, and edge cases.
Macro Store
src/store/gui/macros/actions.ts
Made groupDelete async with socket-based delete; replaced hard-coded layout list with dynamic set from rootState.gui.dashboard; refactored layout updates to use gui/saveSetting with type-safe DashboardLayoutKey.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client/UI
    participant Socket as WebSocket
    participant Server as Server
    participant DB as Database
    participant Comp as Components

    Client->>Socket: emit init
    activate Socket
    
    Socket->>Server: identifyClient
    activate Server
    Server-->>Socket: connection_id
    deactivate Server
    Socket->>Client: setInitializationStep("Identifying Client")
    
    Socket->>Server: initServerInfo
    activate Server
    Server-->>Socket: server info
    deactivate Server
    Socket->>Client: setInitializationProgress(20)
    
    Socket->>Server: initServerConfig
    activate Server
    Server-->>Socket: config
    deactivate Server
    Socket->>Client: setInitializationProgress(40)
    
    Socket->>Server: initDatabases
    activate Server
    Server->>DB: list namespaces
    DB-->>Server: namespaces
    Server-->>Socket: database list
    deactivate Server
    Socket->>Client: setInitializationProgress(60)
    
    Socket->>Server: initServerComponents
    activate Server
    loop for each component
        Server->>Comp: initialize
        Comp-->>Server: ready
        Server->>Socket: setInitializationStepComponent(component)
    end
    Server-->>Socket: components ready
    deactivate Server
    Socket->>Client: setInitializationProgress(100)
    
    Socket->>Server: initKlippyConnection
    activate Server
    Server-->>Socket: klippy ready
    deactivate Server
    Socket->>Client: resetInitialization
    
    deactivate Socket
    Client->>Client: guiIsReady = true
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Poem

🐰 Hops with glee through async streams,
WebSocket dreams and progress beams,
No more fetches clogging the way,
Initialization tracking each day!
Config getters, helpers so neat,
This refactor makes the code complete!

🚥 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 'refactor: rework the gui/gcodehistory store' clearly and accurately describes the primary change in the changeset, which is a comprehensive refactor of the gui/gcodehistory store as confirmed by the raw summary and PR objectives.
Description check ✅ Passed The description explains that the PR refactors the complete gui/gcodehistory store and is based on a related PR, which is directly related to the changeset and provides relevant context about the refactoring scope.

✏️ 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

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: 6

🤖 Fix all issues with AI agents
In `@src/components/panels/Timelapse/TimelapseStatusPanel.vue`:
- Around line 135-141: The getter existsSnapshoturlInMoonrakerConfig currently
does "'snapshoturl' in (this.$store.state.server.config_orig?.timelapse ?? {})"
which can throw if timelapse is a non-object primitive; update it to first read
the timelapse value (e.g. assign to a local like timelapse =
this.$store.state.server.config_orig?.timelapse) and only perform the "in" check
when typeof timelapse === 'object' && timelapse !== null (or use
Object.prototype.hasOwnProperty.call) so the "in" operator is guarded and no
TypeError occurs.

In `@src/locales/de.json`:
- Line 174: The "Sensor" translation value uses inconsistent capitalization;
update the value for the "Sensor" key from "Moonraker sensoren" to title case
"Moonraker Sensoren" so it matches the casing style of other entries (e.g.,
"Prozess-Statistiken", "System Informationen") in the locales file.

In `@src/locales/en.json`:
- Line 173: The InitComponents translation entry currently maps "Power" to
"Power-Devices" which uses a hyphen and is inconsistent with other compound
labels and the TopCornerMenu.PowerDevices entry; update the value for the
InitComponents "Power" key to "Power Devices" (matching
TopCornerMenu.PowerDevices and the spacing convention) so the displayed label is
consistent across the UI.

In `@src/store/gui/actions.ts`:
- Around line 24-68: The async restore step is not awaited and can race with the
subsequent exclude-key deletion loop; change the call to
dispatch('restoreValues', values) inside the init action (function init) to
await dispatch('restoreValues', values) so the restore completes before running
the excludeKeys loop and deleteSetting calls.

In `@src/store/gui/gcodehistory/actions.ts`:
- Around line 11-15: In addToHistory replace the unsupported Array.prototype.at
usage by computing the last element via indexing: check state.entries.length > 0
and compare state.entries[state.entries.length - 1] to gcode (handle empty
array) before pushing; then build newHistory the same way and dispatch
'gui/saveSetting' as before (symbols: addToHistory, state.entries,
maxGcodeHistory, dispatch 'gui/saveSetting').

In `@src/store/server/actions.ts`:
- Around line 313-343: Guard against null/non-object payloads before using "in"
and ensure event.type is always a string: in the addEvent action, replace checks
like "typeof payload === 'object' && 'type' in payload" with a fast guard
(payload !== null && typeof payload === 'object') before any "'prop' in payload"
tests, and similarly guard the other `'message'`, `'result'`, and `'error'`
checks; when building the event via formatGcodeEvent({ message, type }) ensure
you coerce or default type to a string (e.g., derive type only when payload is a
non-null object and fallback to 'response' otherwise) so later uses like
event.type and the isErrorMessage check cannot throw.
🧹 Nitpick comments (5)
src/store/gui/gcodehistory/actions.ts (1)

11-12: Check Array.prototype.at browser support

Array.prototype.at is supported in Chrome 92+, Firefox 90+, Safari 15.4+, Edge 92+ and Node 16.6+ but not in older Safari/WebViews; if you need legacy support, replace with state.entries[state.entries.length - 1] or include a polyfill.

🔧 Fallback example
-        if (state.entries.at(-1) === gcode) return
+        if (state.entries[state.entries.length - 1] === gcode) return
src/components/panels/Gcodefiles/GcodefilesPanelHeaderSettings.vue (1)

76-84: Variable shadowing in findIndex callback.

The value parameter in the findIndex callback shadows the outer value variable, which could cause confusion. Consider renaming the callback parameter.

♻️ Suggested fix
 changeMetadataVisible(name: string) {
     const value = [...(this.$store.state.gui.view.gcodefiles.hideMetadataColumns ?? [])]
-    const index = value.findIndex((value: string) => value === name)
+    const index = value.findIndex((item: string) => item === name)

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

     this.$store.dispatch('gui/saveSetting', { name: 'view.gcodefiles.hideMetadataColumns', value })
 }
src/store/socket/actions.ts (1)

158-171: Consider tracking deprecation with an issue.

The TODO comments indicate these actions should be removed after converting to async init server components. Consider creating a tracking issue to ensure this cleanup happens.

Would you like me to help create an issue to track the removal of these deprecated actions?

src/components/panels/HistoryListPanel.vue (1)

523-533: Minor inconsistency in state access patterns.

changeColumnVisible uses this.hideColums (a getter), while changeStatusVisible accesses state directly via this.$store.state.gui.view.history.hidePrintStatus. For consistency, consider adding a getter for hidePrintStatus similar to hideColums.

♻️ Suggested getter for consistency
get hidePrintStatus() {
    return this.$store.state.gui.view.history.hidePrintStatus ?? []
}

Then update the method:

 changeStatusVisible(status: string) {
-    const value = toggleArrayItem(this.$store.state.gui.view.history.hidePrintStatus, status)
+    const value = toggleArrayItem(this.hidePrintStatus, status)

     this.$store.dispatch('gui/saveSetting', { name: 'view.history.hidePrintStatus', value })
 }
src/store/server/mutations.ts (1)

101-105: Consider clearing throttled_state when payload is null to avoid stale UI.

If null indicates “not available,” keeping prior values can leave stale flags after reconnect or hardware changes.

♻️ Optional clear-on-null update
 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
+    }

Comment on lines 135 to 141
get existsSnapshoturlInMoonrakerConfig() {
return 'snapshoturl' in this.$store.state.server.config.orig.timelapse
return 'snapshoturl' in (this.$store.state.server.config_orig?.timelapse ?? {})
}

get moonrakerTimelapseConfig() {
return this.$store.state.server.config.config.timelapse ?? {}
return this.$store.getters['server/getConfigSection']('timelapse', {})
}
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 the in check to avoid runtime TypeError.

'snapshoturl' in ... throws if timelapse is a non-object primitive. Add a type guard before using in.

💡 Suggested fix
 get existsSnapshoturlInMoonrakerConfig() {
-    return 'snapshoturl' in (this.$store.state.server.config_orig?.timelapse ?? {})
+    const timelapseConfig = this.$store.state.server.config_orig?.timelapse
+    return !!timelapseConfig && typeof timelapseConfig === 'object' && 'snapshoturl' in timelapseConfig
 }
📝 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
get existsSnapshoturlInMoonrakerConfig() {
return 'snapshoturl' in this.$store.state.server.config.orig.timelapse
return 'snapshoturl' in (this.$store.state.server.config_orig?.timelapse ?? {})
}
get moonrakerTimelapseConfig() {
return this.$store.state.server.config.config.timelapse ?? {}
return this.$store.getters['server/getConfigSection']('timelapse', {})
}
get existsSnapshoturlInMoonrakerConfig() {
const timelapseConfig = this.$store.state.server.config_orig?.timelapse
return !!timelapseConfig && typeof timelapseConfig === 'object' && 'snapshoturl' in timelapseConfig
}
get moonrakerTimelapseConfig() {
return this.$store.getters['server/getConfigSection']('timelapse', {})
}
🤖 Prompt for AI Agents
In `@src/components/panels/Timelapse/TimelapseStatusPanel.vue` around lines 135 -
141, The getter existsSnapshoturlInMoonrakerConfig currently does "'snapshoturl'
in (this.$store.state.server.config_orig?.timelapse ?? {})" which can throw if
timelapse is a non-object primitive; update it to first read the timelapse value
(e.g. assign to a local like timelapse =
this.$store.state.server.config_orig?.timelapse) and only perform the "in" check
when typeof timelapse === 'object' && timelapse !== null (or use
Object.prototype.hasOwnProperty.call) so the "in" operator is guarded and no
TypeError occurs.

Comment thread src/locales/de.json
"JobQueue": "Job-Warteschlange",
"Power": "Stromgeräte",
"ProcStats": "Prozess-Statistiken",
"Sensor": "Moonraker sensoren",
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

Minor inconsistency in capitalization.

The value "Moonraker sensoren" uses lowercase 's' while other entries use title case (e.g., "Prozess-Statistiken", "System Informationen", "Server Informationen"). Consider changing to "Moonraker Sensoren" for consistency.

🤖 Prompt for AI Agents
In `@src/locales/de.json` at line 174, The "Sensor" translation value uses
inconsistent capitalization; update the value for the "Sensor" key from
"Moonraker sensoren" to title case "Moonraker Sensoren" so it matches the casing
style of other entries (e.g., "Prozess-Statistiken", "System Informationen") in
the locales file.

Comment thread src/locales/en.json
"GcodeStore": "G-Code Store",
"History": "History",
"JobQueue": "Job Queue",
"Power": "Power-Devices",
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

Minor inconsistency: "Power-Devices" uses a hyphen.

Other compound terms in InitComponents use spaces (e.g., "Job Queue", "Process Statistics", "System Info"). Additionally, the existing key TopCornerMenu.PowerDevices displays as "Power Devices" (line 118). Consider changing to "Power Devices" for consistency.

🤖 Prompt for AI Agents
In `@src/locales/en.json` at line 173, The InitComponents translation entry
currently maps "Power" to "Power-Devices" which uses a hyphen and is
inconsistent with other compound labels and the TopCornerMenu.PowerDevices
entry; update the value for the InitComponents "Power" key to "Power Devices"
(matching TopCornerMenu.PowerDevices and the spacing convention) so the
displayed label is consistent across the UI.

Comment thread src/store/gui/actions.ts
Comment on lines +24 to 68
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)
}

// delete currentPath if exists
if (payload.value?.view?.configfiles?.currentPath) {
window.console.debug('remove currentPath from gui namespace')
await fetch(mainsailUrl + '&key=view.configfiles.currentPath', { method: 'DELETE' })
if ('remoteprinters' in values) {
if (rootState.instancesDB === 'moonraker') {
dispatch('remoteprinters/initStore', values.remoteprinters?.printers ?? {})
}
delete values.remoteprinters
}

//update cooldownGcode from V2.0.1 to V2.1.0
if ('cooldownGcode' in payload.value) {
window.console.debug('update cooldownGcode to new namespace')
dispatch('saveSetting', { name: 'presets.cooldownGcode', value: payload.value.cooldownGcode })
for (const key of excludeKeys) {
const parts = key.split('.')
let value: any = values

await fetch(mainsailUrl + '&key=cooldownGcode', { method: 'DELETE' })
delete payload.value.cooldownGcode
}

//update presets from V2.0.1 to V2.1.0
if ('presets' in payload.value && Array.isArray(payload.value.presets)) {
window.console.debug('update presets to new namespace')
for (const part of parts) {
if (value === undefined || value === null) break
value = value[part]
}

payload.value.presets.forEach((preset: any) => {
dispatch('presets/store', { values: preset })
})
if (value === undefined || value === null) continue

delete payload.value.presets
logDebug(`remove ${key} from gui namespace`)
await dispatch('deleteSetting', key)
deletePath(values, key)
}

//update nonExpandPanels from V2.1.x to V2.2.0
if (
'dashboard' in payload.value &&
'nonExpandPanels' in payload.value.dashboard &&
Array.isArray(payload.value.dashboard.nonExpandPanels)
) {
await fetch(mainsailUrl + '&key=dashboard.nonExpandPanels', { method: 'DELETE' })
dispatch('saveSetting', {
name: 'dashboard.nonExpandPanels.widescreen',
value: payload.value.dashboard.nonExpandPanels,
})
delete payload.value.dashboard.nonExpandPanels
}
commit('setData', values)

//update tools to temperatures panel from V2.1.x to V2.2.0
if ('dashboard' in payload.value) {
const dashboard = payload.value.dashboard
const layouts = [
'mobileLayout',
'tabletLayout1',
'tabletLayout2',
'desktopLayout1',
'desktopLayout2',
'widescreenLayout1',
'widescreenLayout2',
'widescreenLayout3',
]

layouts.forEach((layout) => {
if (layout in dashboard) {
const index = dashboard[layout].findIndex((entry: GuiStateLayoutoption) => entry.name === 'tools')

if (index !== -1) {
dashboard[layout][index].name = 'temperature'

dispatch('saveSetting', {
name: 'dashboard.' + layout,
value: dashboard[layout],
})
}
}
})
}

await commit('setData', payload.value)
await dispatch('socket/removeInitModule', 'gui/init', { root: true })
// TODO: convert to async module initialization
dispatch('socket/addInitModule', 'gui/webcam/init', { root: true })
dispatch('gui/webcams/init', null, { root: true })
},
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

Await restoreValues to avoid races with deleteSetting.
Without awaiting, the async restore can re‑post keys after the exclude‑key deletion loop, leaving unwanted keys in the DB.

🛠️ Proposed 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)
}
// delete currentPath if exists
if (payload.value?.view?.configfiles?.currentPath) {
window.console.debug('remove currentPath from gui namespace')
await fetch(mainsailUrl + '&key=view.configfiles.currentPath', { method: 'DELETE' })
if ('remoteprinters' in values) {
if (rootState.instancesDB === 'moonraker') {
dispatch('remoteprinters/initStore', values.remoteprinters?.printers ?? {})
}
delete values.remoteprinters
}
//update cooldownGcode from V2.0.1 to V2.1.0
if ('cooldownGcode' in payload.value) {
window.console.debug('update cooldownGcode to new namespace')
dispatch('saveSetting', { name: 'presets.cooldownGcode', value: payload.value.cooldownGcode })
for (const key of excludeKeys) {
const parts = key.split('.')
let value: any = values
await fetch(mainsailUrl + '&key=cooldownGcode', { method: 'DELETE' })
delete payload.value.cooldownGcode
}
//update presets from V2.0.1 to V2.1.0
if ('presets' in payload.value && Array.isArray(payload.value.presets)) {
window.console.debug('update presets to new namespace')
for (const part of parts) {
if (value === undefined || value === null) break
value = value[part]
}
payload.value.presets.forEach((preset: any) => {
dispatch('presets/store', { values: preset })
})
if (value === undefined || value === null) continue
delete payload.value.presets
logDebug(`remove ${key} from gui namespace`)
await dispatch('deleteSetting', key)
deletePath(values, key)
}
//update nonExpandPanels from V2.1.x to V2.2.0
if (
'dashboard' in payload.value &&
'nonExpandPanels' in payload.value.dashboard &&
Array.isArray(payload.value.dashboard.nonExpandPanels)
) {
await fetch(mainsailUrl + '&key=dashboard.nonExpandPanels', { method: 'DELETE' })
dispatch('saveSetting', {
name: 'dashboard.nonExpandPanels.widescreen',
value: payload.value.dashboard.nonExpandPanels,
})
delete payload.value.dashboard.nonExpandPanels
}
commit('setData', values)
//update tools to temperatures panel from V2.1.x to V2.2.0
if ('dashboard' in payload.value) {
const dashboard = payload.value.dashboard
const layouts = [
'mobileLayout',
'tabletLayout1',
'tabletLayout2',
'desktopLayout1',
'desktopLayout2',
'widescreenLayout1',
'widescreenLayout2',
'widescreenLayout3',
]
layouts.forEach((layout) => {
if (layout in dashboard) {
const index = dashboard[layout].findIndex((entry: GuiStateLayoutoption) => entry.name === 'tools')
if (index !== -1) {
dashboard[layout][index].name = 'temperature'
dispatch('saveSetting', {
name: 'dashboard.' + layout,
value: dashboard[layout],
})
}
}
})
}
await commit('setData', payload.value)
await dispatch('socket/removeInitModule', 'gui/init', { root: true })
// TODO: convert to async module initialization
dispatch('socket/addInitModule', 'gui/webcam/init', { root: true })
dispatch('gui/webcams/init', null, { root: true })
},
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)
}
if ('remoteprinters' in values) {
if (rootState.instancesDB === 'moonraker') {
dispatch('remoteprinters/initStore', values.remoteprinters?.printers ?? {})
}
delete values.remoteprinters
}
for (const key of excludeKeys) {
const parts = key.split('.')
let value: any = values
for (const part of parts) {
if (value === undefined || value === null) break
value = value[part]
}
if (value === undefined || value === null) continue
logDebug(`remove ${key} from gui namespace`)
await dispatch('deleteSetting', key)
deletePath(values, key)
}
commit('setData', values)
// TODO: convert to async module initialization
dispatch('socket/addInitModule', 'gui/webcam/init', { root: true })
dispatch('gui/webcams/init', null, { root: true })
},
🤖 Prompt for AI Agents
In `@src/store/gui/actions.ts` around lines 24 - 68, The async restore step is not
awaited and can race with the subsequent exclude-key deletion loop; change the
call to dispatch('restoreValues', values) inside the init action (function init)
to await dispatch('restoreValues', values) so the restore completes before
running the excludeKeys loop and deleteSetting calls.

Comment on lines +11 to +15
async addToHistory({ dispatch, state }, gcode: string) {
if (state.entries.at(-1) === gcode) return

await commit('updateHistory', newHistory)
await dispatch('upload')
const newHistory = [...state.entries, gcode].slice(-maxGcodeHistory)
await dispatch('gui/saveSetting', { name: `gcodehistory.entries`, value: newHistory }, { root: true })
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

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the full file to understand the module structure
cat -n src/store/gui/gcodehistory/actions.ts

Repository: mainsail-crew/mainsail

Length of output: 819


🏁 Script executed:

# Check if setEntries mutation exists in this module
fd -t f "gcodehistory" --exec cat {} \; | rg -n "setEntries|mutations" -A 3 -B 3

Repository: mainsail-crew/mainsail

Length of output: 48


🏁 Script executed:

# Search for gui/saveSetting implementation to understand what it does
rg -n "saveSetting" --type=ts -A 10 | head -100

Repository: mainsail-crew/mainsail

Length of output: 6488


🏁 Script executed:

# Check for browser target configuration (package.json, .browserslistrc, tsconfig.json, etc.)
fd -t f "package.json|.browserslistrc|tsconfig.json" -exec cat {} \; | head -200

Repository: mainsail-crew/mainsail

Length of output: 253


🏁 Script executed:

# Search for Array.prototype.at usage and any polyfills in the codebase
rg -n "\.at\(" --type=ts | head -30

Repository: mainsail-crew/mainsail

Length of output: 157


🏁 Script executed:

cat -n package.json | grep -A 20 "browserslist"

Repository: mainsail-crew/mainsail

Length of output: 499


🏁 Script executed:

# Also check tsconfig for target
rg -n "target.*ES" --type=json src/tsconfig.json tsconfig.json

Repository: mainsail-crew/mainsail

Length of output: 123


🏁 Script executed:

# Check if there are any polyfills or compatibility notes
rg -n "polyfill|at\(" --type=ts -C 2 | head -50

Repository: mainsail-crew/mainsail

Length of output: 3804


Replace Array.prototype.at() with a compatible alternative for Safari 12 support.

This action uses Array.prototype.at(), which is an ES2022 feature not supported in Safari 12 (currently in your browserslist). Use array indexing instead:

-    async addToHistory({ dispatch, state }, gcode: string) {
+    async addToHistory({ dispatch, state }, gcode: string) {
-        if (state.entries.at(-1) === gcode) return
+        if (state.entries[state.entries.length - 1] === gcode) return
📝 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 addToHistory({ dispatch, state }, gcode: string) {
if (state.entries.at(-1) === gcode) return
await commit('updateHistory', newHistory)
await dispatch('upload')
const newHistory = [...state.entries, gcode].slice(-maxGcodeHistory)
await dispatch('gui/saveSetting', { name: `gcodehistory.entries`, value: newHistory }, { root: true })
async addToHistory({ dispatch, state }, gcode: string) {
if (state.entries[state.entries.length - 1] === gcode) return
const newHistory = [...state.entries, gcode].slice(-maxGcodeHistory)
await dispatch('gui/saveSetting', { name: `gcodehistory.entries`, value: newHistory }, { root: true })
🤖 Prompt for AI Agents
In `@src/store/gui/gcodehistory/actions.ts` around lines 11 - 15, In addToHistory
replace the unsupported Array.prototype.at usage by computing the last element
via indexing: check state.entries.length > 0 and compare
state.entries[state.entries.length - 1] to gcode (handle empty array) before
pushing; then build newHistory the same way and dispatch 'gui/saveSetting' as
before (symbols: addToHistory, state.entries, maxGcodeHistory, dispatch
'gui/saveSetting').

Comment on lines 313 to 343
addEvent({ commit, rootGetters }, payload) {
let message = payload
let type = 'response'

if (typeof payload === 'object' && 'type' in payload) type = payload.type

if ('message' in payload) message = payload.message
else if ('result' in payload) message = payload.result
else if ('error' in payload) message = message.error.message

let formatMessage = formatConsoleMessage(message)
if (type === 'response') {
if (message.startsWith('// action:')) type = 'action'
else if (message.startsWith('// debug:')) type = 'debug'
}

const filters = rootGetters['gui/console/getConsolefilterRules']
let boolImport = true
filters.every((filter: string) => {
const type = typeof payload === 'object' && 'type' in payload ? payload.type : 'response'
let message: string
if (typeof payload === 'string') {
message = payload
} else if ('message' in payload) {
message = payload.message
} else if ('result' in payload) {
message = payload.result
} else if ('error' in payload) {
message = payload.error.message
} else return

const filters = rootGetters['gui/console/getConsolefilterRules'] as string[]
const isFiltered = filters.some((filter) => {
try {
const regex = new RegExp(filter)
if (regex.test(formatMessage)) boolImport = false
return new RegExp(filter).test(message)
} catch {
window.console.error("Custom console filter '" + filter + "' doesn't work!")
logError('invalid console filter:', filter)
return false
}

return boolImport
})
if (isFiltered) return

if (boolImport) {
if (payload.type === 'command') formatMessage = '<a class="command text--blue">' + formatMessage + '</a>'

commit('addEvent', {
date: new Date(),
message: message,
formatMessage: formatMessage,
type: type,
})
const event = formatGcodeEvent({ message, type })
commit('addEvent', event)

if (
['error', 'response'].includes(type) &&
!['/', '/console'].includes(router.currentRoute.path) &&
message.startsWith('!! ')
) {
Vue.$toast.error(formatMessage)
}
}
const isErrorMessage = ['error', 'response'].includes(event.type) && message.startsWith('!! ')
const isOnConsolePage = ['/', '/console'].includes(router.currentRoute.path)
if (isErrorMessage && !isOnConsolePage) Vue.$toast.error(event.formatMessage)
},
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 null/non-object payloads before using in.
'type' in payload and similar checks will throw on null/primitive payloads. Add a fast guard and tighten the type check to keep event.type a string.

🛠️ Proposed fix
 addEvent({ commit, rootGetters }, payload) {
-        const type = typeof payload === 'object' && 'type' in payload ? payload.type : 'response'
+        if (payload == null) return
+        const type =
+            typeof payload === 'object' &&
+            'type' in payload &&
+            typeof (payload as { type?: unknown }).type === 'string'
+                ? (payload as { type: string }).type
+                : 'response'
         let message: string
         if (typeof payload === 'string') {
             message = payload
-        } else if ('message' in payload) {
+        } else if (typeof payload === 'object' && 'message' in payload) {
             message = payload.message
-        } else if ('result' in payload) {
+        } else if (typeof payload === 'object' && 'result' in payload) {
             message = payload.result
-        } else if ('error' in payload) {
+        } else if (typeof payload === 'object' && 'error' in payload) {
             message = payload.error.message
         } else return
📝 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
addEvent({ commit, rootGetters }, payload) {
let message = payload
let type = 'response'
if (typeof payload === 'object' && 'type' in payload) type = payload.type
if ('message' in payload) message = payload.message
else if ('result' in payload) message = payload.result
else if ('error' in payload) message = message.error.message
let formatMessage = formatConsoleMessage(message)
if (type === 'response') {
if (message.startsWith('// action:')) type = 'action'
else if (message.startsWith('// debug:')) type = 'debug'
}
const filters = rootGetters['gui/console/getConsolefilterRules']
let boolImport = true
filters.every((filter: string) => {
const type = typeof payload === 'object' && 'type' in payload ? payload.type : 'response'
let message: string
if (typeof payload === 'string') {
message = payload
} else if ('message' in payload) {
message = payload.message
} else if ('result' in payload) {
message = payload.result
} else if ('error' in payload) {
message = payload.error.message
} else return
const filters = rootGetters['gui/console/getConsolefilterRules'] as string[]
const isFiltered = filters.some((filter) => {
try {
const regex = new RegExp(filter)
if (regex.test(formatMessage)) boolImport = false
return new RegExp(filter).test(message)
} catch {
window.console.error("Custom console filter '" + filter + "' doesn't work!")
logError('invalid console filter:', filter)
return false
}
return boolImport
})
if (isFiltered) return
if (boolImport) {
if (payload.type === 'command') formatMessage = '<a class="command text--blue">' + formatMessage + '</a>'
commit('addEvent', {
date: new Date(),
message: message,
formatMessage: formatMessage,
type: type,
})
const event = formatGcodeEvent({ message, type })
commit('addEvent', event)
if (
['error', 'response'].includes(type) &&
!['/', '/console'].includes(router.currentRoute.path) &&
message.startsWith('!! ')
) {
Vue.$toast.error(formatMessage)
}
}
const isErrorMessage = ['error', 'response'].includes(event.type) && message.startsWith('!! ')
const isOnConsolePage = ['/', '/console'].includes(router.currentRoute.path)
if (isErrorMessage && !isOnConsolePage) Vue.$toast.error(event.formatMessage)
},
addEvent({ commit, rootGetters }, payload) {
if (payload == null) return
const type =
typeof payload === 'object' &&
'type' in payload &&
typeof (payload as { type?: unknown }).type === 'string'
? (payload as { type: string }).type
: 'response'
let message: string
if (typeof payload === 'string') {
message = payload
} else if (typeof payload === 'object' && 'message' in payload) {
message = payload.message
} else if (typeof payload === 'object' && 'result' in payload) {
message = payload.result
} else if (typeof payload === 'object' && 'error' in payload) {
message = payload.error.message
} else return
const filters = rootGetters['gui/console/getConsolefilterRules'] as string[]
const isFiltered = filters.some((filter) => {
try {
return new RegExp(filter).test(message)
} catch {
logError('invalid console filter:', filter)
return false
}
})
if (isFiltered) return
const event = formatGcodeEvent({ message, type })
commit('addEvent', event)
const isErrorMessage = ['error', 'response'].includes(event.type) && message.startsWith('!! ')
const isOnConsolePage = ['/', '/console'].includes(router.currentRoute.path)
if (isErrorMessage && !isOnConsolePage) Vue.$toast.error(event.formatMessage)
},
🧰 Tools
🪛 ast-grep (0.40.5)

[warning] 328-328: 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 313 - 343, Guard against
null/non-object payloads before using "in" and ensure event.type is always a
string: in the addEvent action, replace checks like "typeof payload === 'object'
&& 'type' in payload" with a fast guard (payload !== null && typeof payload ===
'object') before any "'prop' in payload" tests, and similarly guard the other
`'message'`, `'result'`, and `'error'` checks; when building the event via
formatGcodeEvent({ message, type }) ensure you coerce or default type to a
string (e.g., derive type only when payload is a non-null object and fallback to
'response' otherwise) so later uses like event.type and the isErrorMessage check
cannot throw.

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