feat: add multi-device provisioning and local remote-config UI

- add multi-device portal device management, key rotation, and sync request APIs
- add Pi settings UI for remote server URL, device name, and API key
- add local test-connection and sync-now actions
- make upload and config sync reuse dynamic shared credentials
- add Phase 25.5c migration and docs updates

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-10 18:02:09 -04:00
co-authored by Copilot
parent fbc41a5db5
commit ba682fbf3a
23 changed files with 1357 additions and 167 deletions
+92 -1
View File
@@ -8,6 +8,9 @@ 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;
@@ -35,6 +38,10 @@ const fields = {
deviceCarModel: document.getElementById('device-car-model'),
deviceAvatarPath: document.getElementById('device-avatar-path'),
deviceName: document.getElementById('device-name'),
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'),
};
function showMessage(text, kind) {
@@ -49,6 +56,12 @@ function showUplinkMessage(text, kind) {
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;
@@ -78,6 +91,9 @@ function applyErrors(errors) {
car_model: fields.deviceCarModel,
avatar_path: fields.deviceAvatarPath,
device_name: fields.deviceName,
server_url: fields.remoteServerUrl,
remote_device_name: fields.remoteDeviceName,
api_key: fields.remoteApiKey,
};
Object.entries(errors).forEach(([name, text]) => {
const input = errorElements[name];
@@ -123,6 +139,16 @@ function populateDeviceConfig(deviceConfig) {
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];
@@ -165,6 +191,15 @@ function deviceConfigPayload() {
};
}
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 {
@@ -321,12 +356,54 @@ async function loadDeviceConfig() {
}
}
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
@@ -341,7 +418,20 @@ form.addEventListener('submit', async event => {
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
const deviceConfigResponse = await fetch('/api/device-config', {
method: 'POST',
@@ -365,6 +455,7 @@ form.addEventListener('submit', async event => {
loadSettings();
loadDeviceConfig();
loadRemoteConfig();
loadUploadHealth();
loadUplinkStatus();
setInterval(loadUplinkStatus, 15000);