Show actual API error details in modal save/create failures

Previously modals showed generic "Failed to add/save" messages. Now they
extract and display the actual error detail from the API response (e.g.,
"URL is required", "Name already exists"). Handles Pydantic validation
arrays by joining msg fields.

Fixed in 8 files: device-discovery, devices, calibration,
advanced-calibration, scene-presets, automations, command-palette.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 15:19:08 +03:00
parent cdba98813b
commit f4647027d2
7 changed files with 61 additions and 31 deletions

View File

@@ -61,8 +61,8 @@ function _buildItems(results, states = {}) {
name: tgt.name, detail: t('search.action.stop'), group: 'actions', icon: '■',
action: async () => {
const resp = await fetchWithAuth(`/output-targets/${tgt.id}/stop`, { method: 'POST' });
if (resp.ok) showToast(t('device.stopped'), 'success');
else showToast(t('target.error.stop_failed'), 'error');
if (resp.ok) { showToast(t('device.stopped'), 'success'); }
else { const err = await resp.json().catch(() => ({})); const d = err.detail || err.message || ''; const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d); showToast(ds || t('target.error.stop_failed'), 'error'); }
},
});
} else {
@@ -70,8 +70,8 @@ function _buildItems(results, states = {}) {
name: tgt.name, detail: t('search.action.start'), group: 'actions', icon: '▶',
action: async () => {
const resp = await fetchWithAuth(`/output-targets/${tgt.id}/start`, { method: 'POST' });
if (resp.ok) showToast(t('device.started'), 'success');
else showToast(t('target.error.start_failed'), 'error');
if (resp.ok) { showToast(t('device.started'), 'success'); }
else { const err = await resp.json().catch(() => ({})); const d = err.detail || err.message || ''; const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d); showToast(ds || t('target.error.start_failed'), 'error'); }
},
});
}
@@ -92,8 +92,8 @@ function _buildItems(results, states = {}) {
name: a.name, detail: t('search.action.disable'), group: 'actions', icon: ICON_AUTOMATION,
action: async () => {
const resp = await fetchWithAuth(`/automations/${a.id}/disable`, { method: 'POST' });
if (resp.ok) showToast(t('search.action.disable') + ': ' + a.name, 'success');
else showToast(t('search.action.disable') + ' failed', 'error');
if (resp.ok) { showToast(t('search.action.disable') + ': ' + a.name, 'success'); }
else { const err = await resp.json().catch(() => ({})); const d = err.detail || err.message || ''; const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d); showToast(ds || (t('search.action.disable') + ' failed'), 'error'); }
},
});
} else {
@@ -101,8 +101,8 @@ function _buildItems(results, states = {}) {
name: a.name, detail: t('search.action.enable'), group: 'actions', icon: ICON_AUTOMATION,
action: async () => {
const resp = await fetchWithAuth(`/automations/${a.id}/enable`, { method: 'POST' });
if (resp.ok) showToast(t('search.action.enable') + ': ' + a.name, 'success');
else showToast(t('search.action.enable') + ' failed', 'error');
if (resp.ok) { showToast(t('search.action.enable') + ': ' + a.name, 'success'); }
else { const err = await resp.json().catch(() => ({})); const d = err.detail || err.message || ''; const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d); showToast(ds || (t('search.action.enable') + ' failed'), 'error'); }
},
});
}
@@ -153,8 +153,8 @@ function _buildItems(results, states = {}) {
name: sp.name, detail: t('search.action.activate'), group: 'actions', icon: '⚡',
action: async () => {
const resp = await fetchWithAuth(`/scene-presets/${sp.id}/activate`, { method: 'POST' });
if (resp.ok) showToast(t('scenes.activated'), 'success');
else showToast(t('scenes.error.activate_failed'), 'error');
if (resp.ok) { showToast(t('scenes.activated'), 'success'); }
else { const err = await resp.json().catch(() => ({})); const d = err.detail || err.message || ''; const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d); showToast(ds || t('scenes.error.activate_failed'), 'error'); }
},
});
});

View File

@@ -172,11 +172,13 @@ export async function saveAdvancedCalibration() {
_modal.forceClose();
} else {
const err = await resp.json().catch(() => ({}));
showToast(err.message || t('calibration.error.save_failed'), 'error');
const detail = err.detail || err.message || '';
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
showToast(detailStr || t('calibration.error.save_failed'), 'error');
}
} catch (error) {
if (error.isAuth) return;
showToast(t('calibration.error.save_failed'), 'error');
showToast(error.message || t('calibration.error.save_failed'), 'error');
}
}

View File

@@ -721,7 +721,10 @@ export async function toggleAutomationEnabled(automationId, enable) {
const resp = await fetchWithAuth(`/automations/${automationId}/${action}`, {
method: 'POST',
});
if (!resp.ok) throw new Error(`Failed to ${action} automation`);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `Failed to ${action} automation`);
}
automationsCacheObj.invalidate();
loadAutomations();
} catch (e) {
@@ -768,7 +771,10 @@ export async function deleteAutomation(automationId, automationName) {
const resp = await fetchWithAuth(`/automations/${automationId}`, {
method: 'DELETE',
});
if (!resp.ok) throw new Error('Failed to delete automation');
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to delete automation');
}
showToast(t('automations.deleted'), 'success');
automationsCacheObj.invalidate();
loadAutomations();

View File

@@ -815,13 +815,15 @@ export async function toggleTestEdge(edge) {
});
if (!response.ok) {
const errorData = await response.json();
error.textContent = t('calibration.error.test_toggle_failed');
const detail = errorData.detail || errorData.message || '';
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
error.textContent = detailStr || t('calibration.error.test_toggle_failed');
error.style.display = 'block';
}
} catch (err) {
if (err.isAuth) return;
console.error('Failed to toggle CSS test edge:', err);
error.textContent = t('calibration.error.test_toggle_failed');
error.textContent = err.message || t('calibration.error.test_toggle_failed');
error.style.display = 'block';
}
return;
@@ -845,13 +847,15 @@ export async function toggleTestEdge(edge) {
});
if (!response.ok) {
const errorData = await response.json();
error.textContent = t('calibration.error.test_toggle_failed');
const detail = errorData.detail || errorData.message || '';
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
error.textContent = detailStr || t('calibration.error.test_toggle_failed');
error.style.display = 'block';
}
} catch (err) {
if (err.isAuth) return;
console.error('Failed to toggle test edge:', err);
error.textContent = t('calibration.error.test_toggle_failed');
error.textContent = err.message || t('calibration.error.test_toggle_failed');
error.style.display = 'block';
}
}
@@ -948,13 +952,15 @@ export async function saveCalibration() {
}
} else {
const errorData = await response.json();
error.textContent = t('calibration.error.save_failed');
const detail = errorData.detail || errorData.message || '';
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
error.textContent = detailStr || t('calibration.error.save_failed');
error.style.display = 'block';
}
} catch (err) {
if (err.isAuth) return;
console.error('Failed to save calibration:', err);
error.textContent = t('calibration.error.save_failed');
error.textContent = err.message || t('calibration.error.save_failed');
error.style.display = 'block';
}
}

View File

@@ -871,13 +871,15 @@ export async function handleAddDevice(event) {
} else {
const errorData = await response.json();
console.error('Failed to add device:', errorData);
error.textContent = t('device_discovery.error.add_failed');
const detail = errorData.detail || errorData.message || '';
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
error.textContent = detailStr || t('device_discovery.error.add_failed');
error.style.display = 'block';
}
} catch (err) {
if (err.isAuth) return;
console.error('Failed to add device:', err);
showToast(t('device_discovery.error.add_failed'), 'error');
showToast(err.message || t('device_discovery.error.add_failed'), 'error');
}
}

View File

@@ -516,7 +516,9 @@ export async function saveDeviceSettings() {
if (!deviceResponse.ok) {
const errorData = await deviceResponse.json();
settingsModal.showError(t('device.error.update'));
const detail = errorData.detail || errorData.message || '';
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
settingsModal.showError(detailStr || t('device.error.update'));
return;
}
@@ -527,7 +529,7 @@ export async function saveDeviceSettings() {
} catch (err) {
if (err.isAuth) return;
console.error('Failed to save device settings:', err);
settingsModal.showError(t('device.error.save'));
settingsModal.showError(err.message || t('device.error.save'));
}
}
@@ -546,11 +548,14 @@ export async function saveCardBrightness(deviceId, value) {
body: JSON.stringify({ brightness: bri })
});
if (!resp.ok) {
showToast(t('device.error.brightness'), 'error');
const errData = await resp.json().catch(() => ({}));
const detail = errData.detail || errData.message || '';
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
showToast(detailStr || t('device.error.brightness'), 'error');
}
} catch (err) {
if (err.isAuth) return;
showToast(t('device.error.brightness'), 'error');
showToast(err.message || t('device.error.brightness'), 'error');
}
}

View File

@@ -319,7 +319,10 @@ export async function activateScenePreset(presetId) {
method: 'POST',
});
if (!resp.ok) {
showToast(t('scenes.error.activate_failed'), 'error');
const errData = await resp.json().catch(() => ({}));
const detail = errData.detail || errData.message || '';
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
showToast(detailStr || t('scenes.error.activate_failed'), 'error');
return;
}
const result = await resp.json();
@@ -352,11 +355,14 @@ export async function recaptureScenePreset(presetId) {
scenePresetsCache.invalidate();
_reloadScenesTab();
} else {
showToast(t('scenes.error.recapture_failed'), 'error');
const errData = await resp.json().catch(() => ({}));
const detail = errData.detail || errData.message || '';
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
showToast(detailStr || t('scenes.error.recapture_failed'), 'error');
}
} catch (error) {
if (error.isAuth) return;
showToast(t('scenes.error.recapture_failed'), 'error');
showToast(error.message || t('scenes.error.recapture_failed'), 'error');
}
}
@@ -425,11 +431,14 @@ export async function deleteScenePreset(presetId) {
scenePresetsCache.invalidate();
_reloadScenesTab();
} else {
showToast(t('scenes.error.delete_failed'), 'error');
const errData = await resp.json().catch(() => ({}));
const detail = errData.detail || errData.message || '';
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
showToast(detailStr || t('scenes.error.delete_failed'), 'error');
}
} catch (error) {
if (error.isAuth) return;
showToast(t('scenes.error.delete_failed'), 'error');
showToast(error.message || t('scenes.error.delete_failed'), 'error');
}
}