fix(settings): stop the Settings page reverting changes made elsewhere (issue #2716)

While the Settings page was mounted it held its own copy of every
setting and synced it from the server exactly once, on first load
(:887-900). A debounced effect then diffed the live ['settings']
cache against that copy and PUT all 77 keys it manages on any
difference, with no way to tell a user edit from a value that had
changed on the server. Anything written server-side while the page
sat open was silently reverted ~500ms later (#2716, reporter
@jmoore-skild).

No interaction was needed to trigger it. The query inherits a 60s
staleTime and react-query's default refetchOnWindowFocus, and ~30
other observers share the key, so a window refocus or a refetch from
any of them moved the cache and the page wrote its page-load snapshot
back over all 77 keys -- showing "Settings saved" while doing it.

The page now tracks the last server snapshot it reconciled with. A
field still equal to that baseline has not been touched since, so a
newer server value is adopted; a field the user has edited keeps
their value and is saved over the top, so the newer of the two writes
wins either way. Typing into a text field while a refetch lands stays
safe, which is what the previous behaviour was protecting -- an
in-progress edit is by definition different from the baseline.

The baseline is seeded from the raw server row rather than from the
copy the page patches a browser-detected external_url into, so that
detection still reads as a local change and is still persisted.

The payload builder and the comparison key lists are unchanged. The
diff simply measures against the baseline instead of the live cache,
so no field can silently stop saving.

Removing the adoption step was verified to reintroduce the revert,
and removing the post-save baseline advance to reintroduce a resend
loop; both are covered by frontend tests asserting on the request
bodies rather than on rendered values.
This commit is contained in:
maziggy 2026-08-01 10:55:08 +02:00
parent 18938a10ee
commit 43cb216ae9
5 changed files with 271 additions and 81 deletions

View file

@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
## [1.2.6b1] - Unreleased
### Fixed
- **The Settings page no longer reverts settings changed from anywhere else (#2716, reporter @jmoore-skild)** — While the Settings page was open it held its own copy of every setting and only ever took one from the server, on first load. A background effect then compared that copy against the server's and saved the whole thing back on any difference — with no way to tell "the user edited this field" from "this field changed on the server". So anything written while the page sat open was silently undone: a change made in a second tab, another user's change on a shared install, a restore from a backup. It needed no click to trigger. The page's data goes stale after a minute and refreshes when the window regains focus, and around thirty other places in the app read the same settings, so a refresh from any of them was enough — after which the page wrote its page-load copy back over all 77 settings it manages, and showed **Settings saved** while doing it. The page now keeps track of the last server state it reconciled with. A field still matching that state has not been touched, so a newer value from the server is adopted and displayed; a field the user has edited keeps their value and is saved over the top, so the newer of the two writes wins either way. Typing into a text field while a refresh lands is still safe, which is what the old behaviour was protecting. Covered by frontend tests.
- **A rejected K-profile write is now reported as rejected (#2718, reporter @jmoore-skild)** — Saving a K-profile was fire-and-forget: Bambuddy published the command and reported success the moment the bytes left the process. The printer does answer, and the answer was received, matched, and thrown away at debug level — so a write the printer refused for a real reason still told you it was saved. The complication was that the answer itself was wrong: on single-nozzle printers it came back `result: "fail", reason: "invalid tray_id"` on writes that demonstrably applied, which made gating on it look impossible. Measuring against an X1C and an H2D found the cause — the `tray_id: -1` Bambuddy itself put in the payload. The X1C's firmware validates that field and rejects the value while applying the write anyway; the H2D ignores it. Sending `0`, as BambuStudio does, makes the acknowledgement honest, and the printer echoes back the sequence number we sent, so it can be matched to the write that caused it. Saving or deleting a profile now waits for that answer and surfaces a genuine rejection as an error instead of a success toast. A printer that stays silent is still treated as success — no answer is not evidence of refusal. The acknowledgement is also logged at INFO now, so it appears in a support bundle. Covered by backend tests.
- **The K-profile flow type is a real choice again** — On most printers the calibration table comes back with no nozzle identity at all, and Bambuddy had started showing "Not reported by printer" in the Flow Type field as a result. That is not a value you can save, and it isn't what the slicer does: BambuStudio treats a missing nozzle identity as **Standard** and leaves the choice editable. Bambuddy now does the same. The field is hidden only on models sold with a single nozzle variant — the A1, A1 Mini and A2L — using the same rule the slicer applies. This is not the single-versus-dual-nozzle split: the P1P, P1S, P2S, X1, X1 Carbon, X1E and H2S are all single-nozzle and all offer both flows. Editing a profile also no longer strips the nozzle identity from what it writes back.
- **Dialogs no longer act after they have closed** — The AMS slot configuration and K-Profile dialogs hold their success state briefly and then close themselves, between 1.5 and 4 seconds after the command is sent so the printer has time to process it. That timer ran whether or not the dialog was still open, so dismissing it — or the printer card refreshing underneath it — within that window left a pending close that fired later, dismissing whatever dialog happened to be open by then. The deferred close is now cancelled when the dialog goes away. Covered by frontend tests.

View file

@ -3,9 +3,14 @@
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import { act, fireEvent, render as rtlRender, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { render } from '../utils';
import { ThemeProvider } from '../../contexts/ThemeContext';
import { ToastProvider } from '../../contexts/ToastContext';
import { AuthProvider } from '../../contexts/AuthContext';
import { SettingsPage } from '../../pages/SettingsPage';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
@ -1457,3 +1462,145 @@ describe('SettingsPage — sponsor banner audience', () => {
expect(screen.getByText(/6 printers/i)).toBeInTheDocument();
});
});
describe('SettingsPage — settings changed outside the page (#2716)', () => {
const restoreLabel = 'Restore plate for finish photo';
// external_url is deliberately populated: when the server has none the page
// detects one from the browser and saves it unprompted, which would show up
// as a PUT in tests that assert none was made. That behaviour has its own
// test at the end of this block.
const baseSettings = { ...mockSettings, external_url: window.location.origin };
let queryClient: QueryClient;
let puts: Record<string, unknown>[];
let served: Record<string, unknown>;
function renderPage() {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } },
});
return rtlRender(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<ThemeProvider>
<ToastProvider>
<SettingsPage />
</ToastProvider>
</ThemeProvider>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
);
}
/** Change the settings row server-side and let the page's query observe it. */
async function changeOnServer(patch: Record<string, unknown>) {
served = { ...served, ...patch };
await act(async () => {
await queryClient.invalidateQueries({ queryKey: ['settings'] });
});
}
/** Wait out the 100ms initial-load suppression, then flip a checkbox. */
async function toggleRestorePlate() {
const label = await screen.findByText(restoreLabel);
await new Promise((resolve) => setTimeout(resolve, 200));
const row = label.closest('div')!.parentElement!;
await userEvent.click(within(row).getByRole('checkbox'));
}
beforeEach(() => {
window.history.replaceState({}, '', '/');
localStorage.clear();
setAuthToken(null);
puts = [];
served = { ...baseSettings };
server.use(
http.get('/api/v1/settings/', () => HttpResponse.json(served)),
http.put('/api/v1/settings/', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
puts.push(body);
served = { ...served, ...body };
return HttpResponse.json(served);
})
);
});
it('does not write its stale copy back over a server-side change', async () => {
// The defect: the page diffed the live query cache against its own copy, so
// a refetch that carried someone else's change read as a local edit and was
// reverted ~500ms later with no user interaction at all.
renderPage();
await screen.findByText(restoreLabel);
await new Promise((resolve) => setTimeout(resolve, 200));
await changeOnServer({ currency: 'EUR' });
// Well past the 500ms debounce.
await new Promise((resolve) => setTimeout(resolve, 1200));
expect(puts).toEqual([]);
});
it('adopts the server value, so a later save carries it rather than the stale one', async () => {
renderPage();
await new Promise((resolve) => setTimeout(resolve, 200));
await changeOnServer({ currency: 'EUR' });
await toggleRestorePlate();
await waitFor(() => expect(puts).toHaveLength(1), { timeout: 3000 });
// The user's edit is saved...
expect(puts[0].finish_photo_restore_plate).toBe(false);
// ...and the field they never touched goes back as the server's value, not
// the USD the page loaded with.
expect(puts[0].currency).toBe('EUR');
});
it('never reverts a pending user edit that the server changed too', async () => {
renderPage();
await toggleRestorePlate();
// Lands while the edit is still sitting in the 500ms debounce, i.e. before
// the page has committed it. Adopting the server's value here would throw
// the edit away silently.
await changeOnServer({ finish_photo_restore_plate: true });
await waitFor(() => expect(puts.length).toBeGreaterThan(0), { timeout: 3000 });
await new Promise((resolve) => setTimeout(resolve, 1200));
// Asserted over every request rather than a particular one: whichever order
// the refetch and the debounce happen to land in, no write may carry the
// server's value back over the user's.
expect(puts.map((p) => p.finish_photo_restore_plate)).toEqual(puts.map(() => false));
});
it('saves once per edit — the baseline moves with the saved row', async () => {
// Guards the failure mode the baseline introduces if it is not advanced on
// save: every render would diff against the pre-save snapshot and re-send.
renderPage();
await toggleRestorePlate();
await waitFor(() => expect(puts).toHaveLength(1), { timeout: 3000 });
await new Promise((resolve) => setTimeout(resolve, 1500));
expect(puts).toHaveLength(1);
});
it('still persists the external_url it detects from the browser', async () => {
// The page seeds external_url from window.location.origin when the server
// has none and relies on the auto-save to persist it. That only works
// because the baseline is the raw server row: seed the baseline from the
// adjusted copy instead and the detected URL matches it, so nothing ever
// marks it as needing a save.
served = { ...mockSettings };
renderPage();
await screen.findByText(restoreLabel);
await new Promise((resolve) => setTimeout(resolve, 200));
// A refetch carrying a field this page does not manage. It is enough to
// re-run the diff, and the only thing that differs is the detected URL.
await changeOnServer({ spoolman_url: 'http://spoolman.example' });
await waitFor(() => expect(puts).toHaveLength(1), { timeout: 3000 });
expect(puts[0].external_url).toBe(window.location.origin);
});
});

View file

@ -882,6 +882,16 @@ export function SettingsPage() {
const pendingGcodeSnippetsRef = useRef<string | null>(null);
const isSavingRef = useRef(false);
const isInitialLoadRef = useRef(true);
// #2716: the last server snapshot this page reconciled with. It is what
// makes "the user edited this field" a well-defined question: a field where
// localSettings still equals the baseline has not been touched since that
// reconcile, so a newer server value can be taken instead of the page's stale
// copy being written back over it. Before this the debounced save diffed
// against the live ['settings'] cache, which made a value changed on the
// server -- another tab, another user, a backup restore, a refetch driven by
// any of the ~30 other observers of the key -- indistinguishable from an edit,
// and reverted it a few hundred ms later with no user interaction at all.
const serverBaselineRef = useRef<AppSettings | null>(null);
// Sync local state when settings load
useEffect(() => {
@ -891,6 +901,9 @@ export function SettingsPage() {
...settings,
external_url: settings.external_url || window.location.origin,
};
// The baseline is the raw server row, not this adjusted copy: a detected
// external_url has to read as a local change so it still gets persisted.
serverBaselineRef.current = settings;
setLocalSettings(settingsWithExternalUrl);
// Mark initial load complete after a short delay
setTimeout(() => {
@ -899,9 +912,37 @@ export function SettingsPage() {
}
}, [settings, localSettings]);
// #2716: reconcile a moved server snapshot into the local copy. A field the
// user has not touched since the last reconcile takes the server's value; a
// field they have edited keeps theirs and is saved over it by the debounced
// effect below, so the newer of the two writes wins either way. Declared
// before that effect so the baseline has already moved by the time it
// computes its diff in the same commit.
useEffect(() => {
const baseline = serverBaselineRef.current;
if (!settings || !localSettings || !baseline || settings === baseline) {
return;
}
const adopted: Record<string, unknown> = {};
for (const key of Object.keys(settings) as (keyof AppSettings)[]) {
if (settings[key] !== baseline[key] && localSettings[key] === baseline[key]) {
adopted[key] = settings[key];
}
}
serverBaselineRef.current = settings;
if (Object.keys(adopted).length > 0) {
setLocalSettings(prev => (prev ? { ...prev, ...(adopted as Partial<AppSettings>) } : prev));
}
}, [settings, localSettings]);
const updateMutation = useMutation({
mutationFn: api.updateSettings,
onSuccess: (data) => {
// #2716: the row we just saved becomes the snapshot to diff against.
// The setQueryData below would normally get the effect above to do this,
// but only if react-query hands back a new object; setting it here means
// the baseline never lags behind a save regardless.
serverBaselineRef.current = data;
queryClient.setQueryData(['settings'], data);
// Don't call setLocalSettings(data) here — it would overwrite in-progress
// user input (e.g. typing a hostname) with the stale saved snapshot,
@ -942,7 +983,8 @@ export function SettingsPage() {
// Debounced auto-save when localSettings change
useEffect(() => {
// Skip if initial load or no settings
if (isInitialLoadRef.current || !localSettings || !settings) {
const baseline = serverBaselineRef.current;
if (isInitialLoadRef.current || !localSettings || !settings || !baseline) {
return;
}
@ -956,83 +998,83 @@ export function SettingsPage() {
// Check if there are actual changes
const hasChanges =
settings.auto_archive !== localSettings.auto_archive ||
settings.save_thumbnails !== localSettings.save_thumbnails ||
settings.capture_finish_photo !== localSettings.capture_finish_photo ||
(settings.finish_photo_restore_plate ?? true) !== (localSettings.finish_photo_restore_plate ?? true) ||
settings.default_filament_cost !== localSettings.default_filament_cost ||
settings.currency !== localSettings.currency ||
settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh ||
settings.energy_tracking_mode !== localSettings.energy_tracking_mode ||
settings.check_updates !== localSettings.check_updates ||
(settings.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) ||
(settings.include_beta_updates ?? false) !== (localSettings.include_beta_updates ?? false) ||
(settings.local_login_enabled ?? true) !== (localSettings.local_login_enabled ?? true) ||
settings.notification_language !== localSettings.notification_language ||
(settings.bed_cooled_threshold ?? 35) !== (localSettings.bed_cooled_threshold ?? 35) ||
settings.ams_humidity_good !== localSettings.ams_humidity_good ||
settings.ams_humidity_fair !== localSettings.ams_humidity_fair ||
settings.ams_temp_good !== localSettings.ams_temp_good ||
settings.ams_temp_fair !== localSettings.ams_temp_fair ||
settings.ams_history_retention_days !== localSettings.ams_history_retention_days ||
settings.disable_filament_warnings !== localSettings.disable_filament_warnings ||
settings.prefer_lowest_filament !== localSettings.prefer_lowest_filament ||
(settings.queue_drying_enabled ?? false) !== (localSettings.queue_drying_enabled ?? false) ||
(settings.queue_drying_block ?? false) !== (localSettings.queue_drying_block ?? false) ||
(settings.ambient_drying_enabled ?? false) !== (localSettings.ambient_drying_enabled ?? false) ||
(settings.print_drying_enabled ?? false) !== (localSettings.print_drying_enabled ?? false) ||
(settings.drying_presets ?? '') !== (localSettings.drying_presets ?? '') ||
(settings.ams_humidity_thresholds ?? '') !== (localSettings.ams_humidity_thresholds ?? '') ||
settings.per_printer_mapping_expanded !== localSettings.per_printer_mapping_expanded ||
settings.date_format !== localSettings.date_format ||
settings.time_format !== localSettings.time_format ||
settings.default_printer_id !== localSettings.default_printer_id ||
settings.ftp_retry_enabled !== localSettings.ftp_retry_enabled ||
settings.ftp_retry_count !== localSettings.ftp_retry_count ||
settings.ftp_retry_delay !== localSettings.ftp_retry_delay ||
settings.ftp_timeout !== localSettings.ftp_timeout ||
settings.mqtt_enabled !== localSettings.mqtt_enabled ||
settings.mqtt_broker !== localSettings.mqtt_broker ||
settings.mqtt_port !== localSettings.mqtt_port ||
settings.mqtt_username !== localSettings.mqtt_username ||
settings.mqtt_password !== localSettings.mqtt_password ||
settings.mqtt_topic_prefix !== localSettings.mqtt_topic_prefix ||
settings.mqtt_use_tls !== localSettings.mqtt_use_tls ||
settings.external_url !== localSettings.external_url ||
settings.ha_enabled !== localSettings.ha_enabled ||
settings.ha_url !== localSettings.ha_url ||
settings.ha_token !== localSettings.ha_token ||
(settings.library_archive_mode ?? 'ask') !== (localSettings.library_archive_mode ?? 'ask') ||
Number(settings.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5) ||
(settings.camera_view_mode ?? 'window') !== (localSettings.camera_view_mode ?? 'window') ||
(settings.preferred_slicer ?? 'bambu_studio') !== (localSettings.preferred_slicer ?? 'bambu_studio') ||
(settings.open_in_slicer ?? null) !== (localSettings.open_in_slicer ?? null) ||
(settings.use_slicer_api ?? false) !== (localSettings.use_slicer_api ?? false) ||
(settings.orcaslicer_api_url ?? '') !== (localSettings.orcaslicer_api_url ?? '') ||
(settings.slicer_stall_timeout_minutes ?? 15) !== (localSettings.slicer_stall_timeout_minutes ?? 15) ||
(settings.bambu_studio_api_url ?? '') !== (localSettings.bambu_studio_api_url ?? '') ||
settings.prometheus_enabled !== localSettings.prometheus_enabled ||
settings.prometheus_token !== localSettings.prometheus_token ||
(settings.user_notifications_enabled ?? true) !== (localSettings.user_notifications_enabled ?? true) ||
(settings.default_bed_levelling ?? 'auto') !== (localSettings.default_bed_levelling ?? 'auto') ||
(settings.default_flow_cali ?? 'auto') !== (localSettings.default_flow_cali ?? 'auto') ||
(settings.default_vibration_cali ?? true) !== (localSettings.default_vibration_cali ?? true) ||
(settings.default_layer_inspect ?? false) !== (localSettings.default_layer_inspect ?? false) ||
(settings.default_timelapse ?? false) !== (localSettings.default_timelapse ?? false) ||
(settings.default_nozzle_offset_cali ?? 'auto') !== (localSettings.default_nozzle_offset_cali ?? 'auto') ||
(settings.stagger_group_size ?? 2) !== (localSettings.stagger_group_size ?? 2) ||
(settings.stagger_interval_minutes ?? 5) !== (localSettings.stagger_interval_minutes ?? 5) ||
(settings.require_plate_clear ?? false) !== (localSettings.require_plate_clear ?? false) ||
(settings.queue_max_concurrent_uploads ?? 4) !== (localSettings.queue_max_concurrent_uploads ?? 4) ||
(settings.preheat_enabled ?? false) !== (localSettings.preheat_enabled ?? false) ||
(settings.preheat_filament_targets ?? '') !== (localSettings.preheat_filament_targets ?? '') ||
(settings.preheat_max_wait_seconds ?? 900) !== (localSettings.preheat_max_wait_seconds ?? 900) ||
(settings.preheat_soak_seconds ?? 300) !== (localSettings.preheat_soak_seconds ?? 300) ||
(settings.nozzle_temp_presets ?? '') !== (localSettings.nozzle_temp_presets ?? '') ||
(settings.bed_temp_presets ?? '') !== (localSettings.bed_temp_presets ?? '') ||
(settings.chamber_temp_presets ?? '') !== (localSettings.chamber_temp_presets ?? '') ||
(settings.fan_speed_presets ?? '') !== (localSettings.fan_speed_presets ?? '') ||
(settings.session_max_hours ?? 24) !== (localSettings.session_max_hours ?? 24);
baseline.auto_archive !== localSettings.auto_archive ||
baseline.save_thumbnails !== localSettings.save_thumbnails ||
baseline.capture_finish_photo !== localSettings.capture_finish_photo ||
(baseline.finish_photo_restore_plate ?? true) !== (localSettings.finish_photo_restore_plate ?? true) ||
baseline.default_filament_cost !== localSettings.default_filament_cost ||
baseline.currency !== localSettings.currency ||
baseline.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh ||
baseline.energy_tracking_mode !== localSettings.energy_tracking_mode ||
baseline.check_updates !== localSettings.check_updates ||
(baseline.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) ||
(baseline.include_beta_updates ?? false) !== (localSettings.include_beta_updates ?? false) ||
(baseline.local_login_enabled ?? true) !== (localSettings.local_login_enabled ?? true) ||
baseline.notification_language !== localSettings.notification_language ||
(baseline.bed_cooled_threshold ?? 35) !== (localSettings.bed_cooled_threshold ?? 35) ||
baseline.ams_humidity_good !== localSettings.ams_humidity_good ||
baseline.ams_humidity_fair !== localSettings.ams_humidity_fair ||
baseline.ams_temp_good !== localSettings.ams_temp_good ||
baseline.ams_temp_fair !== localSettings.ams_temp_fair ||
baseline.ams_history_retention_days !== localSettings.ams_history_retention_days ||
baseline.disable_filament_warnings !== localSettings.disable_filament_warnings ||
baseline.prefer_lowest_filament !== localSettings.prefer_lowest_filament ||
(baseline.queue_drying_enabled ?? false) !== (localSettings.queue_drying_enabled ?? false) ||
(baseline.queue_drying_block ?? false) !== (localSettings.queue_drying_block ?? false) ||
(baseline.ambient_drying_enabled ?? false) !== (localSettings.ambient_drying_enabled ?? false) ||
(baseline.print_drying_enabled ?? false) !== (localSettings.print_drying_enabled ?? false) ||
(baseline.drying_presets ?? '') !== (localSettings.drying_presets ?? '') ||
(baseline.ams_humidity_thresholds ?? '') !== (localSettings.ams_humidity_thresholds ?? '') ||
baseline.per_printer_mapping_expanded !== localSettings.per_printer_mapping_expanded ||
baseline.date_format !== localSettings.date_format ||
baseline.time_format !== localSettings.time_format ||
baseline.default_printer_id !== localSettings.default_printer_id ||
baseline.ftp_retry_enabled !== localSettings.ftp_retry_enabled ||
baseline.ftp_retry_count !== localSettings.ftp_retry_count ||
baseline.ftp_retry_delay !== localSettings.ftp_retry_delay ||
baseline.ftp_timeout !== localSettings.ftp_timeout ||
baseline.mqtt_enabled !== localSettings.mqtt_enabled ||
baseline.mqtt_broker !== localSettings.mqtt_broker ||
baseline.mqtt_port !== localSettings.mqtt_port ||
baseline.mqtt_username !== localSettings.mqtt_username ||
baseline.mqtt_password !== localSettings.mqtt_password ||
baseline.mqtt_topic_prefix !== localSettings.mqtt_topic_prefix ||
baseline.mqtt_use_tls !== localSettings.mqtt_use_tls ||
baseline.external_url !== localSettings.external_url ||
baseline.ha_enabled !== localSettings.ha_enabled ||
baseline.ha_url !== localSettings.ha_url ||
baseline.ha_token !== localSettings.ha_token ||
(baseline.library_archive_mode ?? 'ask') !== (localSettings.library_archive_mode ?? 'ask') ||
Number(baseline.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5) ||
(baseline.camera_view_mode ?? 'window') !== (localSettings.camera_view_mode ?? 'window') ||
(baseline.preferred_slicer ?? 'bambu_studio') !== (localSettings.preferred_slicer ?? 'bambu_studio') ||
(baseline.open_in_slicer ?? null) !== (localSettings.open_in_slicer ?? null) ||
(baseline.use_slicer_api ?? false) !== (localSettings.use_slicer_api ?? false) ||
(baseline.orcaslicer_api_url ?? '') !== (localSettings.orcaslicer_api_url ?? '') ||
(baseline.slicer_stall_timeout_minutes ?? 15) !== (localSettings.slicer_stall_timeout_minutes ?? 15) ||
(baseline.bambu_studio_api_url ?? '') !== (localSettings.bambu_studio_api_url ?? '') ||
baseline.prometheus_enabled !== localSettings.prometheus_enabled ||
baseline.prometheus_token !== localSettings.prometheus_token ||
(baseline.user_notifications_enabled ?? true) !== (localSettings.user_notifications_enabled ?? true) ||
(baseline.default_bed_levelling ?? 'auto') !== (localSettings.default_bed_levelling ?? 'auto') ||
(baseline.default_flow_cali ?? 'auto') !== (localSettings.default_flow_cali ?? 'auto') ||
(baseline.default_vibration_cali ?? true) !== (localSettings.default_vibration_cali ?? true) ||
(baseline.default_layer_inspect ?? false) !== (localSettings.default_layer_inspect ?? false) ||
(baseline.default_timelapse ?? false) !== (localSettings.default_timelapse ?? false) ||
(baseline.default_nozzle_offset_cali ?? 'auto') !== (localSettings.default_nozzle_offset_cali ?? 'auto') ||
(baseline.stagger_group_size ?? 2) !== (localSettings.stagger_group_size ?? 2) ||
(baseline.stagger_interval_minutes ?? 5) !== (localSettings.stagger_interval_minutes ?? 5) ||
(baseline.require_plate_clear ?? false) !== (localSettings.require_plate_clear ?? false) ||
(baseline.queue_max_concurrent_uploads ?? 4) !== (localSettings.queue_max_concurrent_uploads ?? 4) ||
(baseline.preheat_enabled ?? false) !== (localSettings.preheat_enabled ?? false) ||
(baseline.preheat_filament_targets ?? '') !== (localSettings.preheat_filament_targets ?? '') ||
(baseline.preheat_max_wait_seconds ?? 900) !== (localSettings.preheat_max_wait_seconds ?? 900) ||
(baseline.preheat_soak_seconds ?? 300) !== (localSettings.preheat_soak_seconds ?? 300) ||
(baseline.nozzle_temp_presets ?? '') !== (localSettings.nozzle_temp_presets ?? '') ||
(baseline.bed_temp_presets ?? '') !== (localSettings.bed_temp_presets ?? '') ||
(baseline.chamber_temp_presets ?? '') !== (localSettings.chamber_temp_presets ?? '') ||
(baseline.fan_speed_presets ?? '') !== (localSettings.fan_speed_presets ?? '') ||
(baseline.session_max_hours ?? 24) !== (localSettings.session_max_hours ?? 24);
if (!hasChanges) {
return;

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-D27DV9N0.js"></script>
<script type="module" crossorigin src="/assets/index-CbDmTKuP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C_6BSgrK.css">
</head>
<body>