|
|
@@ -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;
|