328 lines
12 KiB
JavaScript
328 lines
12 KiB
JavaScript
const form = document.getElementById('device-settings-form');
|
|
const saveButton = document.getElementById('device-settings-save');
|
|
const message = document.getElementById('device-settings-message');
|
|
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 deleteDeviceButton = document.getElementById('delete-device-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'),
|
|
car_model: document.getElementById('car_model'),
|
|
device_name: document.getElementById('device_name'),
|
|
avatar_path: document.getElementById('avatar_path'),
|
|
odometer_baseline: document.getElementById('odometer_baseline'),
|
|
};
|
|
|
|
let devices = [];
|
|
let selectedDeviceId = null;
|
|
let initialState = null;
|
|
let latestApiKey = '';
|
|
|
|
function showMessage(text, kind) {
|
|
message.className = `alert alert-${kind}`;
|
|
message.textContent = text;
|
|
message.hidden = false;
|
|
}
|
|
|
|
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(),
|
|
car_model: fields.car_model.value.trim(),
|
|
device_name: fields.device_name.value.trim(),
|
|
avatar_path: fields.avatar_path.value.trim(),
|
|
odometer_baseline: Number(fields.odometer_baseline.value) || 0,
|
|
};
|
|
}
|
|
|
|
function setFormEnabled(enabled) {
|
|
for (const field of Object.values(fields)) {
|
|
field.disabled = !enabled;
|
|
}
|
|
rotateKeyButton.disabled = !enabled;
|
|
requestSyncButton.disabled = !enabled;
|
|
deleteDeviceButton.disabled = !enabled;
|
|
updateDirtyState();
|
|
}
|
|
|
|
function updateDirtyState() {
|
|
if (!initialState || selectedDeviceId === null) {
|
|
saveButton.disabled = true;
|
|
return;
|
|
}
|
|
saveButton.disabled = JSON.stringify(currentState()) === JSON.stringify(initialState);
|
|
}
|
|
|
|
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 || '';
|
|
fields.odometer_baseline.value = (config.odometer_baseline || 0).toFixed(1);
|
|
initialState = currentState();
|
|
credentialFields.deviceName.value = device.name;
|
|
renderDeviceList();
|
|
setFormEnabled(true);
|
|
}
|
|
|
|
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/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.fields?.name || payload.error || 'Could not create device.');
|
|
}
|
|
|
|
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 deleteDevice() {
|
|
if (selectedDeviceId === null) return;
|
|
const device = devices.find(candidate => candidate.id === selectedDeviceId);
|
|
if (!device) return;
|
|
if (!window.confirm(`Delete device "${device.name}" and all of its server-side trips, tracking logs, and dashcam files?`)) {
|
|
return;
|
|
}
|
|
hideMessage();
|
|
deleteDeviceButton.disabled = true;
|
|
try {
|
|
const response = await fetch(`/api/v1/portal/devices/${selectedDeviceId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
const payload = await response.json();
|
|
if (!response.ok) {
|
|
throw new Error(payload.error || 'Could not delete device.');
|
|
}
|
|
latestApiKey = '';
|
|
credentialFields.apiKey.value = '';
|
|
copyCredentialsButton.disabled = true;
|
|
selectedDeviceId = null;
|
|
await loadDevices();
|
|
showMessage('Device deleted from the server.', 'success');
|
|
} catch (error) {
|
|
showMessage(error.message, 'danger');
|
|
} finally {
|
|
deleteDeviceButton.disabled = selectedDeviceId === null;
|
|
}
|
|
}
|
|
|
|
async function saveConfig(event) {
|
|
event.preventDefault();
|
|
if (selectedDeviceId === null) return;
|
|
hideMessage();
|
|
saveButton.disabled = true;
|
|
try {
|
|
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) {
|
|
throw new Error(payload.error || 'Could not save device config.');
|
|
}
|
|
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();
|
|
updateDirtyState();
|
|
});
|
|
}
|
|
|
|
form.addEventListener('submit', saveConfig);
|
|
createDeviceButton.addEventListener('click', createDevice);
|
|
rotateKeyButton.addEventListener('click', rotateKey);
|
|
requestSyncButton.addEventListener('click', requestSync);
|
|
deleteDeviceButton.addEventListener('click', deleteDevice);
|
|
copyCredentialsButton.addEventListener('click', () => {
|
|
copyCredentials().catch(error => showMessage(`Could not copy credentials: ${error.message}`, 'danger'));
|
|
});
|
|
|
|
loadDevices().catch(error => showMessage(error.message, 'danger'));
|