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
+207 -37
View File
@@ -1,9 +1,26 @@
const form = document.getElementById('device-settings-form');
const saveButton = document.getElementById('device-settings-save');
const message = document.getElementById('device-settings-message');
const configVersion = document.getElementById('config-version');
const avatarPreview = document.getElementById('avatar-preview');
const previewCaption = document.getElementById('preview-caption');
const deviceList = document.getElementById('device-list');
const deviceCount = document.getElementById('device-count');
const createDeviceButton = document.getElementById('create-device-button');
const newDeviceName = document.getElementById('new-device-name');
const rotateKeyButton = document.getElementById('rotate-key-button');
const requestSyncButton = document.getElementById('request-sync-button');
const copyCredentialsButton = document.getElementById('copy-credentials-button');
const selectedSummary = {
name: document.getElementById('selected-device-name'),
portalDefault: document.getElementById('selected-device-default'),
lastSeen: document.getElementById('selected-device-last-seen'),
lastSync: document.getElementById('selected-device-last-sync'),
};
const credentialFields = {
serverUrl: document.getElementById('credential-server-url'),
deviceName: document.getElementById('credential-device-name'),
apiKey: document.getElementById('credential-api-key'),
};
const fields = {
owner_name: document.getElementById('owner_name'),
@@ -12,7 +29,10 @@ const fields = {
avatar_path: document.getElementById('avatar_path'),
};
let devices = [];
let selectedDeviceId = null;
let initialState = null;
let latestApiKey = '';
function showMessage(text, kind) {
message.className = `alert alert-${kind}`;
@@ -24,6 +44,11 @@ function hideMessage() {
message.hidden = true;
}
function formatDate(isoString) {
if (!isoString) return '—';
return new Date(isoString).toLocaleString();
}
function currentState() {
return {
owner_name: fields.owner_name.value.trim(),
@@ -33,89 +58,234 @@ function currentState() {
};
}
function avatarUrl(path) {
if (!path) return '/static/img/yaris.png';
if (/^https?:\/\//.test(path)) return path;
return `/static/${path.replace(/^\/+/, '')}`;
}
function updatePreview() {
const state = currentState();
avatarPreview.src = avatarUrl(state.avatar_path);
previewCaption.textContent = state.owner_name && state.car_model
? `${state.owner_name} • ${state.car_model}`
: 'Preview updates as you type.';
function setFormEnabled(enabled) {
for (const field of Object.values(fields)) {
field.disabled = !enabled;
}
rotateKeyButton.disabled = !enabled;
requestSyncButton.disabled = !enabled;
updateDirtyState();
}
function updateDirtyState() {
if (!initialState) {
if (!initialState || selectedDeviceId === null) {
saveButton.disabled = true;
return;
}
saveButton.disabled = JSON.stringify(currentState()) === JSON.stringify(initialState);
}
function populate(config) {
function renderDeviceList() {
deviceCount.textContent = String(devices.length);
if (!devices.length) {
deviceList.innerHTML = '<div class="list-group-item text-muted small">No devices registered yet.</div>';
selectedDeviceId = null;
setFormEnabled(false);
return;
}
deviceList.innerHTML = devices.map(device => `
<button type="button" class="list-group-item list-group-item-action${device.id === selectedDeviceId ? ' active' : ''}" data-device-id="${device.id}">
<div class="d-flex w-100 justify-content-between align-items-start gap-2">
<div class="text-start">
<div class="fw-semibold">${device.device_name || device.name}</div>
<div class="small ${device.id === selectedDeviceId ? 'text-white-50' : 'text-muted'}">${device.name}</div>
</div>
${device.portal_default ? '<span class="badge text-bg-warning">Default</span>' : ''}
</div>
</button>
`).join('');
deviceList.querySelectorAll('[data-device-id]').forEach(button => {
button.addEventListener('click', () => selectDevice(Number(button.dataset.deviceId)));
});
}
function populateDevice(device, config) {
selectedSummary.name.textContent = device.name;
selectedSummary.portalDefault.textContent = device.portal_default ? 'Yes' : 'No';
selectedSummary.lastSeen.textContent = formatDate(device.last_seen_at);
selectedSummary.lastSync.textContent = device.last_config_sync_at
? `${formatDate(device.last_config_sync_at)} (v${device.last_config_sync_version ?? '—'})`
: '—';
fields.owner_name.value = config.owner_name || '';
fields.car_model.value = config.car_model || '';
fields.device_name.value = config.device_name || '';
fields.avatar_path.value = config.avatar_path || '';
configVersion.textContent = config.config_version ?? '—';
initialState = currentState();
updatePreview();
updateDirtyState();
credentialFields.deviceName.value = device.name;
renderDeviceList();
setFormEnabled(true);
}
async function loadConfig() {
async function loadDevices(preferredDeviceId = null) {
const response = await fetch('/api/v1/portal/devices');
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || 'Could not load devices.');
}
devices = payload.devices || [];
credentialFields.serverUrl.value = window.location.origin;
const nextDevice = preferredDeviceId && devices.some(device => device.id === preferredDeviceId)
? preferredDeviceId
: selectedDeviceId && devices.some(device => device.id === selectedDeviceId)
? selectedDeviceId
: devices[0]?.id ?? null;
selectedDeviceId = nextDevice;
renderDeviceList();
if (selectedDeviceId !== null) {
await loadSelectedDevice();
}
}
async function loadSelectedDevice() {
if (selectedDeviceId === null) {
return;
}
const response = await fetch(`/api/v1/portal/devices/${selectedDeviceId}/config`);
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || 'Could not load device config.');
}
const device = devices.find(candidate => candidate.id === selectedDeviceId) || payload.device;
populateDevice(device, payload.config);
}
async function selectDevice(deviceId) {
hideMessage();
selectedDeviceId = deviceId;
latestApiKey = '';
credentialFields.apiKey.value = '';
copyCredentialsButton.disabled = true;
renderDeviceList();
await loadSelectedDevice();
}
async function createDevice() {
hideMessage();
createDeviceButton.disabled = true;
try {
const response = await fetch('/api/v1/portal/device-config');
const response = await fetch('/api/v1/portal/devices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newDeviceName.value.trim() }),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || 'Could not load device config.');
throw new Error(payload.fields?.name || payload.error || 'Could not create device.');
}
populate(payload);
latestApiKey = payload.api_key;
credentialFields.apiKey.value = payload.api_key;
copyCredentialsButton.disabled = false;
newDeviceName.value = '';
await loadDevices(payload.device.id);
showMessage('Device created. Copy the credentials now; the raw API key will not be shown again.', 'success');
} catch (error) {
showMessage(error.message, 'danger');
} finally {
createDeviceButton.disabled = false;
}
}
async function rotateKey() {
if (selectedDeviceId === null) return;
if (!window.confirm('Rotate the API key for this device? The old Pi config will stop authenticating until updated.')) return;
hideMessage();
rotateKeyButton.disabled = true;
try {
const response = await fetch(`/api/v1/portal/devices/${selectedDeviceId}/rotate-key`, {
method: 'POST',
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || 'Could not rotate API key.');
}
latestApiKey = payload.api_key;
credentialFields.apiKey.value = payload.api_key;
copyCredentialsButton.disabled = false;
await loadDevices(selectedDeviceId);
showMessage('API key rotated. Copy the new credentials to the Pi now.', 'warning');
} catch (error) {
showMessage(error.message, 'danger');
} finally {
rotateKeyButton.disabled = false;
}
}
async function requestSync() {
if (selectedDeviceId === null) return;
hideMessage();
requestSyncButton.disabled = true;
try {
const response = await fetch(`/api/v1/portal/devices/${selectedDeviceId}/request-sync`, {
method: 'POST',
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || 'Could not request sync.');
}
await loadDevices(selectedDeviceId);
showMessage(payload.message, 'info');
} catch (error) {
showMessage(error.message, 'danger');
} finally {
requestSyncButton.disabled = false;
}
}
async function saveConfig(event) {
event.preventDefault();
if (selectedDeviceId === null) return;
hideMessage();
saveButton.disabled = true;
try {
const response = await fetch('/api/device-config', {
const response = await fetch(`/api/v1/portal/devices/${selectedDeviceId}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(currentState()),
});
const payload = await response.json();
if (!response.ok) {
const detail = payload.fields
? Object.values(payload.fields).join(' ')
: (payload.error || 'Could not save device config.');
throw new Error(detail);
throw new Error(payload.error || 'Could not save device config.');
}
populate(payload);
showMessage('Device settings saved. The Pi will pick them up on its next sync.', 'success');
await loadDevices(selectedDeviceId);
showMessage('Device settings saved.', 'success');
} catch (error) {
showMessage(error.message, 'danger');
updateDirtyState();
}
}
async function copyCredentials() {
if (!latestApiKey || selectedDeviceId === null) return;
const text = [
`YARIS_SERVER_URL=${window.location.origin}`,
`YARIS_DEVICE_NAME=${credentialFields.deviceName.value}`,
`YARIS_API_KEY=${latestApiKey}`,
].join('\n');
await navigator.clipboard.writeText(text);
showMessage('Credentials copied. Paste them into the Pi Settings page.', 'success');
}
for (const field of Object.values(fields)) {
field.addEventListener('input', () => {
hideMessage();
updatePreview();
updateDirtyState();
});
}
avatarPreview.addEventListener('error', () => {
previewCaption.textContent = 'Preview image not found on the server static path.';
form.addEventListener('submit', saveConfig);
createDeviceButton.addEventListener('click', createDevice);
rotateKeyButton.addEventListener('click', rotateKey);
requestSyncButton.addEventListener('click', requestSync);
copyCredentialsButton.addEventListener('click', () => {
copyCredentials().catch(error => showMessage(`Could not copy credentials: ${error.message}`, 'danger'));
});
form.addEventListener('submit', saveConfig);
loadConfig();
loadDevices().catch(error => showMessage(error.message, 'danger'));