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:
@@ -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'));
|
||||
|
||||
+92
-1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user