- Settings page: SSID + password fields in In-car access card - GET /api/hotspot reads current SSID (password never returned) - POST /api/hotspot updates via nmcli (Hotspot-local profile on wlan0) - deploy/yaris-hotspot.sudoers for privileged nmcli access - Password is write-only with placeholder hint
536 lines
22 KiB
JavaScript
536 lines
22 KiB
JavaScript
const form = document.getElementById('settings-form');
|
|
const saveButtons = Array.from(document.querySelectorAll('.settings-save'));
|
|
const message = document.getElementById('settings-message');
|
|
const uplinkStatus = document.getElementById('uplink-status');
|
|
const uplinkMessage = document.getElementById('uplink-message');
|
|
const phoneSsid = document.getElementById('phone-hotspot-ssid');
|
|
const phonePassword = document.getElementById('phone-hotspot-password');
|
|
const phoneSave = document.getElementById('phone-hotspot-save');
|
|
const phoneConnect = document.getElementById('phone-hotspot-connect');
|
|
const phoneRemove = document.getElementById('phone-hotspot-remove');
|
|
const remoteConfigMessage = document.getElementById('remote-config-message');
|
|
const remoteConfigTestButton = document.getElementById('remote-config-test');
|
|
const remoteConfigSyncNowButton = document.getElementById('remote-config-sync-now');
|
|
const lastPhoneAddress = document.getElementById('last-phone-address');
|
|
const lastPhoneAddressLink = document.getElementById('last-phone-address-link');
|
|
let initialUplinkStatusLoaded = false;
|
|
|
|
const fields = {
|
|
tripsUpload: document.getElementById('trips-upload-enabled'),
|
|
dashcamUpload: document.getElementById('dashcam-upload-enabled'),
|
|
retention: document.getElementById('retention-days'),
|
|
videoConvert: document.getElementById('video-convert-enabled'),
|
|
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'),
|
|
deviceSyncEnabled: document.getElementById('device-sync-enabled'),
|
|
deviceOwnerName: document.getElementById('device-owner-name'),
|
|
deviceCarModel: document.getElementById('device-car-model'),
|
|
deviceAvatarPath: document.getElementById('device-avatar-path'),
|
|
deviceName: document.getElementById('device-name'),
|
|
odometerBaseline: document.getElementById('odometer-baseline'),
|
|
remoteServerUrl: document.getElementById('remote-server-url'),
|
|
remoteDeviceName: document.getElementById('remote-device-name'),
|
|
remoteApiKey: document.getElementById('remote-api-key'),
|
|
clearRemoteApiKey: document.getElementById('clear-remote-api-key'),
|
|
};
|
|
|
|
// ── Pi hotspot (separate from main settings form) ──────────────────────────
|
|
const apSsid = document.getElementById('ap-ssid');
|
|
const apPassword = document.getElementById('ap-password');
|
|
const apSave = document.getElementById('ap-save');
|
|
const apMessage = document.getElementById('ap-message');
|
|
|
|
function showApMessage(text, kind) {
|
|
apMessage.textContent = text;
|
|
apMessage.className = `alert alert-${kind} py-2 mb-3`;
|
|
apMessage.hidden = false;
|
|
}
|
|
|
|
async function loadApConfig() {
|
|
try {
|
|
const r = await fetch('/api/hotspot');
|
|
if (!r.ok) throw new Error('Cannot load hotspot config');
|
|
const d = await r.json();
|
|
apSsid.value = d.ssid || '';
|
|
} catch (e) {
|
|
apSsid.placeholder = '(cannot read)';
|
|
}
|
|
}
|
|
|
|
apSave.addEventListener('click', async function () {
|
|
apSave.disabled = true;
|
|
apMessage.hidden = true;
|
|
try {
|
|
const r = await fetch('/api/hotspot', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
ssid: apSsid.value.trim(),
|
|
password: apPassword.value,
|
|
}),
|
|
});
|
|
const d = await r.json();
|
|
if (!r.ok) throw new Error(d.error || 'Save failed');
|
|
apPassword.value = '';
|
|
showApMessage(d.message || 'Hotspot updated. Change takes effect on next restart or mode switch.', 'success');
|
|
} catch (e) {
|
|
showApMessage(e.message, 'danger');
|
|
} finally {
|
|
apSave.disabled = false;
|
|
}
|
|
});
|
|
|
|
function showMessage(text, kind) {
|
|
message.textContent = text;
|
|
message.className = `alert alert-${kind}`;
|
|
message.hidden = false;
|
|
}
|
|
|
|
function showUplinkMessage(text, kind) {
|
|
uplinkMessage.textContent = text;
|
|
uplinkMessage.className = `alert alert-${kind} mt-3 mb-0`;
|
|
uplinkMessage.hidden = false;
|
|
}
|
|
|
|
function showRemoteConfigMessage(text, kind) {
|
|
remoteConfigMessage.textContent = text;
|
|
remoteConfigMessage.className = `alert alert-${kind} py-2 mb-3`;
|
|
remoteConfigMessage.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,
|
|
owner_name: fields.deviceOwnerName,
|
|
car_model: fields.deviceCarModel,
|
|
avatar_path: fields.deviceAvatarPath,
|
|
device_name: fields.deviceName,
|
|
odometer_baseline: Number(fields.odometerBaseline.value) || 0,
|
|
server_url: fields.remoteServerUrl,
|
|
remote_device_name: fields.remoteDeviceName,
|
|
api_key: fields.remoteApiKey,
|
|
};
|
|
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.tripsUpload.checked = settings.trips_upload_enabled;
|
|
fields.dashcamUpload.checked = settings.dashcam_upload_enabled;
|
|
fields.retention.value = settings.local_video_retention_days;
|
|
fields.videoConvert.checked = settings.video_convert_enabled;
|
|
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;
|
|
fields.odometerBaseline.value = (settings.odometer_baseline || 0).toFixed(1);
|
|
}
|
|
|
|
function populateDeviceConfig(deviceConfig) {
|
|
fields.deviceSyncEnabled.checked = deviceConfig.sync_enabled;
|
|
fields.deviceOwnerName.value = deviceConfig.owner_name;
|
|
fields.deviceCarModel.value = deviceConfig.car_model;
|
|
fields.deviceAvatarPath.value = deviceConfig.avatar_path;
|
|
fields.deviceName.value = deviceConfig.device_name || '';
|
|
fields.odometerBaseline.value = (deviceConfig.odometer_baseline || 0).toFixed(1);
|
|
updateDeviceConfigFieldsState();
|
|
}
|
|
|
|
function populateRemoteConfig(remoteConfig) {
|
|
fields.remoteServerUrl.value = remoteConfig.server_url || '';
|
|
fields.remoteDeviceName.value = remoteConfig.device_name || '';
|
|
fields.remoteApiKey.value = '';
|
|
fields.clearRemoteApiKey.checked = false;
|
|
document.getElementById('remote-api-key-state').textContent = remoteConfig.api_key_configured
|
|
? 'An API key is saved. Leave blank to keep it unchanged.'
|
|
: 'No API key is saved yet.';
|
|
}
|
|
|
|
function updateDeviceConfigFieldsState() {
|
|
const syncEnabled = fields.deviceSyncEnabled.checked;
|
|
const configFields = [fields.deviceOwnerName, fields.deviceCarModel, fields.deviceAvatarPath, fields.deviceName];
|
|
configFields.forEach(field => {
|
|
field.disabled = syncEnabled;
|
|
});
|
|
}
|
|
|
|
function payload() {
|
|
return {
|
|
trips_upload_enabled: fields.tripsUpload.checked,
|
|
dashcam_upload_enabled: fields.dashcamUpload.checked,
|
|
local_video_retention_days: Number(fields.retention.value),
|
|
video_convert_enabled: fields.videoConvert.checked,
|
|
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),
|
|
},
|
|
odometer_baseline: Number(fields.odometerBaseline.value) || 0,
|
|
};
|
|
}
|
|
|
|
function deviceConfigPayload() {
|
|
return {
|
|
sync_enabled: fields.deviceSyncEnabled.checked,
|
|
owner_name: fields.deviceOwnerName.value,
|
|
car_model: fields.deviceCarModel.value,
|
|
avatar_path: fields.deviceAvatarPath.value,
|
|
device_name: fields.deviceName.value,
|
|
};
|
|
}
|
|
|
|
function remoteConfigPayload() {
|
|
return {
|
|
server_url: fields.remoteServerUrl.value,
|
|
device_name: fields.remoteDeviceName.value,
|
|
api_key: fields.remoteApiKey.value,
|
|
clear_api_key: fields.clearRemoteApiKey.checked,
|
|
};
|
|
}
|
|
|
|
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';
|
|
}
|
|
}
|
|
|
|
function describeUplink(status) {
|
|
const modeNames = {
|
|
phone: 'Phone hotspot',
|
|
local_hotspot: 'Pi local hotspot',
|
|
wifi: status.active_connection || 'WiFi',
|
|
disconnected: 'Disconnected',
|
|
};
|
|
const parts = [
|
|
`Mode: ${modeNames[status.mode] || status.mode}.`,
|
|
status.ip_address ? `IP: ${status.ip_address}.` : '',
|
|
status.internet_reachable ? 'Internet reachable.' : 'Internet not verified.',
|
|
];
|
|
if (status.last_transition && status.last_transition.error) {
|
|
parts.push(`Last error: ${status.last_transition.error}`);
|
|
}
|
|
return parts.filter(Boolean).join(' ');
|
|
}
|
|
|
|
async function loadUplinkStatus() {
|
|
try {
|
|
const response = await fetch('/api/uplink/status');
|
|
const status = await response.json();
|
|
if (!response.ok) throw new Error(status.error || `HTTP ${response.status}`);
|
|
uplinkStatus.textContent = describeUplink(status);
|
|
|
|
// In-car access card
|
|
const hotspotInfo = document.getElementById('in-car-hotspot-info');
|
|
const wifiInfo = document.getElementById('in-car-wifi-info');
|
|
const disconnectedInfo = document.getElementById('in-car-disconnected-info');
|
|
const hotspotUrl = document.getElementById('hotspot-url');
|
|
const copyBtn = document.getElementById('copy-hotspot-url');
|
|
if (status.ap_active && status.ap_ip) {
|
|
hotspotUrl.textContent = `http://${status.ap_ip}`;
|
|
hotspotInfo.hidden = false;
|
|
wifiInfo.hidden = true;
|
|
disconnectedInfo.hidden = true;
|
|
if (copyBtn) copyBtn.onclick = () => navigator.clipboard.writeText(`http://${status.ap_ip}`);
|
|
} else if (status.mode === 'wifi' || status.mode === 'phone') {
|
|
hotspotInfo.hidden = true;
|
|
wifiInfo.hidden = false;
|
|
disconnectedInfo.hidden = true;
|
|
} else {
|
|
hotspotInfo.hidden = true;
|
|
wifiInfo.hidden = true;
|
|
disconnectedInfo.hidden = false;
|
|
}
|
|
const phoneAddress = status.mode === 'phone'
|
|
? status.ip_address
|
|
: status.last_transition && status.last_transition.last_phone_ip;
|
|
lastPhoneAddress.hidden = !phoneAddress;
|
|
if (phoneAddress) {
|
|
const url = `http://${phoneAddress}/`;
|
|
lastPhoneAddressLink.href = url;
|
|
lastPhoneAddressLink.textContent = url;
|
|
}
|
|
if (
|
|
!initialUplinkStatusLoaded
|
|
&& status.phone_configured
|
|
&& !phoneSsid.value
|
|
) {
|
|
phoneSsid.value = status.phone_ssid;
|
|
}
|
|
initialUplinkStatusLoaded = true;
|
|
phoneConnect.disabled = !status.phone_configured;
|
|
phoneRemove.disabled = !status.phone_configured;
|
|
} catch (error) {
|
|
uplinkStatus.textContent = `Could not load network status: ${error.message}`;
|
|
uplinkStatus.className = 'text-danger mb-3';
|
|
}
|
|
}
|
|
|
|
phoneSave.addEventListener('click', async () => {
|
|
uplinkMessage.hidden = true;
|
|
phoneSave.disabled = true;
|
|
try {
|
|
const response = await fetch('/api/uplink/config', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
ssid: phoneSsid.value,
|
|
password: phonePassword.value,
|
|
}),
|
|
});
|
|
const result = await response.json();
|
|
if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`);
|
|
phonePassword.value = '';
|
|
showUplinkMessage(
|
|
'Phone hotspot saved. Select Wait for hotspot, then enable the hotspot on your phone within two minutes.',
|
|
'success',
|
|
);
|
|
await loadUplinkStatus();
|
|
} catch (error) {
|
|
showUplinkMessage(`Could not save phone hotspot: ${error.message}`, 'danger');
|
|
} finally {
|
|
phoneSave.disabled = false;
|
|
}
|
|
});
|
|
|
|
phoneConnect.addEventListener('click', async () => {
|
|
phoneConnect.disabled = true;
|
|
try {
|
|
const response = await fetch('/api/uplink/connect', {method: 'POST'});
|
|
const result = await response.json();
|
|
if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`);
|
|
showUplinkMessage(
|
|
'Waiting for the saved hotspot. Enable it on your phone within two minutes. This page may disconnect while networks switch.',
|
|
'info',
|
|
);
|
|
} catch (error) {
|
|
showUplinkMessage(`Could not request connection: ${error.message}`, 'danger');
|
|
} finally {
|
|
phoneConnect.disabled = false;
|
|
}
|
|
});
|
|
|
|
phoneRemove.addEventListener('click', async () => {
|
|
if (!window.confirm('Remove the saved phone hotspot from the Pi?')) return;
|
|
phoneRemove.disabled = true;
|
|
try {
|
|
const response = await fetch('/api/uplink/config', {method: 'DELETE'});
|
|
const result = await response.json();
|
|
if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`);
|
|
phoneSsid.value = '';
|
|
phonePassword.value = '';
|
|
showUplinkMessage('Saved phone hotspot removed.', 'success');
|
|
await loadUplinkStatus();
|
|
} catch (error) {
|
|
showUplinkMessage(`Could not remove phone hotspot: ${error.message}`, 'danger');
|
|
phoneRemove.disabled = false;
|
|
}
|
|
});
|
|
|
|
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');
|
|
}
|
|
}
|
|
|
|
async function loadDeviceConfig() {
|
|
try {
|
|
const response = await fetch('/api/device-config');
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
populateDeviceConfig(await response.json());
|
|
} catch (error) {
|
|
showMessage(`Could not load device config: ${error.message}`, 'danger');
|
|
}
|
|
}
|
|
|
|
async function loadRemoteConfig() {
|
|
try {
|
|
const response = await fetch('/api/remote-config');
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
populateRemoteConfig(await response.json());
|
|
} catch (error) {
|
|
showMessage(`Could not load remote config: ${error.message}`, 'danger');
|
|
}
|
|
}
|
|
|
|
fields.deviceSyncEnabled.addEventListener('change', updateDeviceConfigFieldsState);
|
|
|
|
remoteConfigTestButton.addEventListener('click', async () => {
|
|
remoteConfigMessage.hidden = true;
|
|
remoteConfigTestButton.disabled = true;
|
|
try {
|
|
const response = await fetch('/api/remote-config/test', {method: 'POST'});
|
|
const result = await response.json();
|
|
if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`);
|
|
showRemoteConfigMessage(result.message || 'Remote server connection looks good.', 'success');
|
|
} catch (error) {
|
|
showRemoteConfigMessage(error.message, 'danger');
|
|
} finally {
|
|
remoteConfigTestButton.disabled = false;
|
|
}
|
|
});
|
|
|
|
remoteConfigSyncNowButton.addEventListener('click', async () => {
|
|
remoteConfigMessage.hidden = true;
|
|
remoteConfigSyncNowButton.disabled = true;
|
|
try {
|
|
const response = await fetch('/api/remote-config/sync-now', {method: 'POST'});
|
|
const result = await response.json();
|
|
if (!response.ok) throw new Error(result.message || `HTTP ${response.status}`);
|
|
await loadDeviceConfig();
|
|
showRemoteConfigMessage(result.message || 'Sync completed.', 'success');
|
|
} catch (error) {
|
|
showRemoteConfigMessage(error.message, 'danger');
|
|
} finally {
|
|
remoteConfigSyncNowButton.disabled = false;
|
|
}
|
|
});
|
|
|
|
form.addEventListener('submit', async event => {
|
|
event.preventDefault();
|
|
clearErrors();
|
|
message.hidden = true;
|
|
remoteConfigMessage.hidden = true;
|
|
setSaving(true);
|
|
try {
|
|
// Save main settings
|
|
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);
|
|
|
|
// Save remote server credentials before device sync toggle validation
|
|
const remoteConfigResponse = await fetch('/api/remote-config', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify(remoteConfigPayload()),
|
|
});
|
|
const remoteConfigResult = await remoteConfigResponse.json();
|
|
if (!remoteConfigResponse.ok) {
|
|
applyErrors(remoteConfigResult.fields || {});
|
|
throw new Error(remoteConfigResult.error || `HTTP ${remoteConfigResponse.status}`);
|
|
}
|
|
populateRemoteConfig(remoteConfigResult);
|
|
|
|
// Save device config (always send — backend validates locked fields when sync is on)
|
|
const deviceConfigResponse = await fetch('/api/device-config', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify(deviceConfigPayload()),
|
|
});
|
|
const deviceConfigResult = await deviceConfigResponse.json();
|
|
if (!deviceConfigResponse.ok) {
|
|
applyErrors(deviceConfigResult.fields || {});
|
|
throw new Error(deviceConfigResult.error || `HTTP ${deviceConfigResponse.status}`);
|
|
}
|
|
populateDeviceConfig(deviceConfigResult);
|
|
|
|
showMessage('Settings saved.', 'success');
|
|
} catch (error) {
|
|
if (message.hidden) showMessage(`Could not save settings: ${error.message}`, 'danger');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
});
|
|
|
|
loadSettings();
|
|
loadDeviceConfig();
|
|
loadRemoteConfig();
|
|
loadUploadHealth();
|
|
loadUplinkStatus();
|
|
loadApConfig();
|
|
setInterval(loadUplinkStatus, 15000);
|