Add local settings management
Add cellular-first ntfy delivery with WiFi fallback and safe SIM868 HTTP header framing. Co-authored-by: Copilot <[email protected]>
This commit is contained in:
@@ -285,59 +285,3 @@ scanForDashcam();
|
||||
refreshDashcamFiles();
|
||||
|
||||
// --- Remote server upload status/toggle ---
|
||||
|
||||
const uploadStatusEl = document.getElementById('upload-status');
|
||||
const dashcamToggle = document.getElementById('dashcam-upload-toggle');
|
||||
|
||||
function describeUploadStatus(status) {
|
||||
if (!status.configured) {
|
||||
return 'Remote server not configured on this device (see db/upload_creds.env).';
|
||||
}
|
||||
let text = `Pending trip-log rows to upload: ${status.pending_tracking_rows}.`;
|
||||
if (status.last_success_at) {
|
||||
text += ` Last successful upload: ${status.last_success_at}`;
|
||||
if (status.last_network_path) {
|
||||
text += ` (via ${status.last_network_path === 'cellular' ? 'cellular fallback' : 'WiFi/Ethernet'})`;
|
||||
}
|
||||
text += '.';
|
||||
}
|
||||
if (status.cellular_fallback_configured) {
|
||||
text += ' Cellular (SIM868) fallback is enabled for trip logs if WiFi/Ethernet is unreachable.';
|
||||
}
|
||||
if (status.last_error) {
|
||||
text += ` Last error: ${status.last_error}.`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function refreshUploadStatus() {
|
||||
fetch('/api/upload/status')
|
||||
.then(response => response.json())
|
||||
.then(status => {
|
||||
uploadStatusEl.textContent = describeUploadStatus(status);
|
||||
dashcamToggle.checked = status.dashcam_upload_enabled;
|
||||
dashcamToggle.disabled = !status.configured;
|
||||
})
|
||||
.catch(err => {
|
||||
uploadStatusEl.textContent = 'Error while checking upload status: ' + err;
|
||||
});
|
||||
}
|
||||
|
||||
dashcamToggle.addEventListener('change', () => {
|
||||
fetch('/api/upload/dashcam_toggle', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: dashcamToggle.checked }),
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(status => {
|
||||
uploadStatusEl.textContent = describeUploadStatus(status);
|
||||
dashcamToggle.checked = status.dashcam_upload_enabled;
|
||||
})
|
||||
.catch(err => {
|
||||
uploadStatusEl.textContent = 'Error while updating toggle: ' + err;
|
||||
});
|
||||
});
|
||||
|
||||
refreshUploadStatus();
|
||||
setInterval(refreshUploadStatus, 15000);
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
const form = document.getElementById('settings-form');
|
||||
const saveButtons = Array.from(document.querySelectorAll('.settings-save'));
|
||||
const message = document.getElementById('settings-message');
|
||||
|
||||
const fields = {
|
||||
dashcamUpload: document.getElementById('dashcam-upload-enabled'),
|
||||
retention: document.getElementById('retention-days'),
|
||||
cellularEnabled: document.getElementById('cellular-enabled'),
|
||||
cellularApn: document.getElementById('cellular-apn'),
|
||||
ntfyEnabled: document.getElementById('ntfy-enabled'),
|
||||
ntfyUrl: document.getElementById('ntfy-url'),
|
||||
ntfyToken: document.getElementById('ntfy-token'),
|
||||
clearNtfyUrl: document.getElementById('clear-ntfy-url'),
|
||||
clearNtfyToken: document.getElementById('clear-ntfy-token'),
|
||||
temperature: document.getElementById('temperature-c'),
|
||||
disk: document.getElementById('disk-percent'),
|
||||
trackingBacklog: document.getElementById('tracking-backlog'),
|
||||
videoBacklog: document.getElementById('video-backlog'),
|
||||
gpsLoss: document.getElementById('gps-loss-seconds'),
|
||||
repeat: document.getElementById('repeat-seconds'),
|
||||
};
|
||||
|
||||
function showMessage(text, kind) {
|
||||
message.textContent = text;
|
||||
message.className = `alert alert-${kind}`;
|
||||
message.hidden = false;
|
||||
}
|
||||
|
||||
function setSaving(disabled) {
|
||||
saveButtons.forEach(button => {
|
||||
button.disabled = disabled;
|
||||
});
|
||||
}
|
||||
|
||||
function clearErrors() {
|
||||
form.querySelectorAll('.is-invalid').forEach(element => element.classList.remove('is-invalid'));
|
||||
form.querySelectorAll('[data-error-for]').forEach(element => {
|
||||
element.textContent = '';
|
||||
});
|
||||
}
|
||||
|
||||
function applyErrors(errors) {
|
||||
const errorElements = {
|
||||
local_video_retention_days: fields.retention,
|
||||
cellular_apn: fields.cellularApn,
|
||||
ntfy_url: fields.ntfyUrl,
|
||||
ntfy_token: fields.ntfyToken,
|
||||
temperature_c: fields.temperature,
|
||||
disk_percent: fields.disk,
|
||||
tracking_backlog: fields.trackingBacklog,
|
||||
video_backlog: fields.videoBacklog,
|
||||
gps_loss_seconds: fields.gpsLoss,
|
||||
repeat_seconds: fields.repeat,
|
||||
};
|
||||
Object.entries(errors).forEach(([name, text]) => {
|
||||
const input = errorElements[name];
|
||||
const feedback = form.querySelector(`[data-error-for="${name}"]`);
|
||||
if (input) input.classList.add('is-invalid');
|
||||
if (feedback) feedback.textContent = text;
|
||||
});
|
||||
}
|
||||
|
||||
function populate(settings) {
|
||||
const thresholds = settings.alert_thresholds;
|
||||
fields.dashcamUpload.checked = settings.dashcam_upload_enabled;
|
||||
fields.retention.value = settings.local_video_retention_days;
|
||||
fields.cellularEnabled.checked = settings.cellular_enabled;
|
||||
fields.cellularApn.value = settings.cellular_apn;
|
||||
fields.ntfyEnabled.checked = settings.ntfy_enabled;
|
||||
fields.temperature.value = thresholds.temperature_c;
|
||||
fields.disk.value = thresholds.disk_percent;
|
||||
fields.trackingBacklog.value = thresholds.tracking_backlog;
|
||||
fields.videoBacklog.value = thresholds.video_backlog;
|
||||
fields.gpsLoss.value = thresholds.gps_loss_seconds;
|
||||
fields.repeat.value = thresholds.repeat_seconds;
|
||||
document.getElementById('ntfy-url-state').textContent = settings.ntfy_url_configured
|
||||
? 'A URL is saved. Leave blank to keep it unchanged.'
|
||||
: 'No URL is saved.';
|
||||
document.getElementById('ntfy-token-state').textContent = settings.ntfy_token_configured
|
||||
? 'A token is saved. Leave blank to keep it unchanged.'
|
||||
: 'No token is saved; public ntfy topics do not require one.';
|
||||
fields.ntfyUrl.value = '';
|
||||
fields.ntfyToken.value = '';
|
||||
fields.clearNtfyUrl.checked = false;
|
||||
fields.clearNtfyToken.checked = false;
|
||||
}
|
||||
|
||||
function payload() {
|
||||
return {
|
||||
dashcam_upload_enabled: fields.dashcamUpload.checked,
|
||||
local_video_retention_days: Number(fields.retention.value),
|
||||
cellular_enabled: fields.cellularEnabled.checked,
|
||||
cellular_apn: fields.cellularApn.value,
|
||||
ntfy_enabled: fields.ntfyEnabled.checked,
|
||||
ntfy_url: fields.ntfyUrl.value,
|
||||
ntfy_token: fields.ntfyToken.value,
|
||||
clear_ntfy_url: fields.clearNtfyUrl.checked,
|
||||
clear_ntfy_token: fields.clearNtfyToken.checked,
|
||||
alert_thresholds: {
|
||||
temperature_c: Number(fields.temperature.value),
|
||||
disk_percent: Number(fields.disk.value),
|
||||
tracking_backlog: Number(fields.trackingBacklog.value),
|
||||
video_backlog: Number(fields.videoBacklog.value),
|
||||
gps_loss_seconds: Number(fields.gpsLoss.value),
|
||||
repeat_seconds: Number(fields.repeat.value),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function loadUploadHealth() {
|
||||
const element = document.getElementById('upload-health');
|
||||
try {
|
||||
const response = await fetch('/api/upload/status');
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const status = await response.json();
|
||||
if (!status.configured) {
|
||||
element.textContent = 'Remote server upload is not configured on this Pi.';
|
||||
element.className = 'small text-muted mb-3';
|
||||
return;
|
||||
}
|
||||
const parts = [`${status.pending_tracking_rows} tracking row(s) pending.`];
|
||||
if (status.last_success_at) {
|
||||
parts.push(`Last success: ${status.last_success_at}${status.last_network_path ? ` via ${status.last_network_path}.` : '.'}`);
|
||||
}
|
||||
if (status.last_error) parts.push(`Last error: ${status.last_error}`);
|
||||
element.textContent = parts.join(' ');
|
||||
element.className = `small mb-3 ${status.last_error ? 'text-danger' : 'text-muted'}`;
|
||||
} catch (error) {
|
||||
element.textContent = `Could not load upload status: ${error.message}`;
|
||||
element.className = 'small text-danger mb-3';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await fetch('/api/settings');
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
populate(await response.json());
|
||||
setSaving(false);
|
||||
} catch (error) {
|
||||
showMessage(`Could not load settings: ${error.message}`, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
clearErrors();
|
||||
message.hidden = true;
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload()),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
applyErrors(result.fields || {});
|
||||
throw new Error(result.error || `HTTP ${response.status}`);
|
||||
}
|
||||
populate(result);
|
||||
showMessage('Settings saved.', 'success');
|
||||
} catch (error) {
|
||||
if (message.hidden) showMessage(`Could not save settings: ${error.message}`, 'danger');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
});
|
||||
|
||||
loadSettings();
|
||||
loadUploadHealth();
|
||||
Reference in New Issue
Block a user