Add initial version of the table
This commit is contained in:
parent
1467445d86
commit
842a4510f0
231
dashboard/app.js
231
dashboard/app.js
@ -13,7 +13,9 @@ class DashboardApp {
|
|||||||
logoutButton: document.getElementById('logoutButton'),
|
logoutButton: document.getElementById('logoutButton'),
|
||||||
requestTemplate: document.getElementById('requestTemplate'),
|
requestTemplate: document.getElementById('requestTemplate'),
|
||||||
responseTemplate: document.getElementById('responseTemplate'),
|
responseTemplate: document.getElementById('responseTemplate'),
|
||||||
incomingMessage: document.getElementById('incomingMessage')
|
incomingMessage: document.getElementById('incomingMessage'),
|
||||||
|
chargerTableBody: document.getElementById('chargerTableBody'),
|
||||||
|
chargerEmptyRow: document.getElementById('chargerEmptyRow')
|
||||||
};
|
};
|
||||||
|
|
||||||
this.sessionKey = 'evdash.session';
|
this.sessionKey = 'evdash.session';
|
||||||
@ -25,10 +27,25 @@ class DashboardApp {
|
|||||||
this.reconnectTimer = null;
|
this.reconnectTimer = null;
|
||||||
this.tokenRefreshTimer = null;
|
this.tokenRefreshTimer = null;
|
||||||
this.refreshInFlight = false;
|
this.refreshInFlight = false;
|
||||||
|
this.chargers = new Map();
|
||||||
|
this.chargerColumns = [
|
||||||
|
{ key: 'id', label: 'ID' },
|
||||||
|
{ key: 'name', label: 'Name' },
|
||||||
|
{ key: 'connected', label: 'Connected' },
|
||||||
|
{ key: 'chargingCurrent', label: 'Charging current' },
|
||||||
|
{ key: 'chargingAllowed', label: 'Charging allowed' },
|
||||||
|
{ key: 'currentPower', label: 'Current power' },
|
||||||
|
{ key: 'pluggedIn', label: 'Plugged in' },
|
||||||
|
{ key: 'version', label: 'Version' },
|
||||||
|
{ key: 'sessionEnergy', label: 'Session energy' },
|
||||||
|
{ key: 'temperature', label: 'Temperature' },
|
||||||
|
{ key: 'chargingPhases', label: 'Charging phases' }
|
||||||
|
];
|
||||||
|
|
||||||
this.renderStaticTemplates();
|
this.renderStaticTemplates();
|
||||||
this.attachEventListeners();
|
this.attachEventListeners();
|
||||||
this.restoreSession();
|
this.restoreSession();
|
||||||
|
this.toggleChargerEmptyState();
|
||||||
}
|
}
|
||||||
|
|
||||||
attachEventListeners() {
|
attachEventListeners() {
|
||||||
@ -240,6 +257,8 @@ class DashboardApp {
|
|||||||
clearTimeout(this.tokenRefreshTimer);
|
clearTimeout(this.tokenRefreshTimer);
|
||||||
this.tokenRefreshTimer = null;
|
this.tokenRefreshTimer = null;
|
||||||
this.refreshInFlight = false;
|
this.refreshInFlight = false;
|
||||||
|
this.chargers.clear();
|
||||||
|
this.resetChargerTable();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
window.localStorage.removeItem(this.sessionKey);
|
window.localStorage.removeItem(this.sessionKey);
|
||||||
@ -299,17 +318,9 @@ class DashboardApp {
|
|||||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const requestId = this.generateRequestId();
|
this.sendAction('authenticate', {
|
||||||
const message = {
|
token: this.token
|
||||||
requestId,
|
});
|
||||||
action: 'authenticate',
|
|
||||||
payload: {
|
|
||||||
token: this.token
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.pendingRequests.set(requestId, { type: 'authenticate' });
|
|
||||||
this.socket.send(JSON.stringify(message));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onSocketMessage(event) {
|
onSocketMessage(event) {
|
||||||
@ -325,28 +336,72 @@ class DashboardApp {
|
|||||||
if (this.elements.incomingMessage)
|
if (this.elements.incomingMessage)
|
||||||
this.elements.incomingMessage.textContent = JSON.stringify(data, null, 2);
|
this.elements.incomingMessage.textContent = JSON.stringify(data, null, 2);
|
||||||
|
|
||||||
|
let handled = false;
|
||||||
if (data.requestId && this.pendingRequests.has(data.requestId)) {
|
if (data.requestId && this.pendingRequests.has(data.requestId)) {
|
||||||
const pending = this.pendingRequests.get(data.requestId);
|
const pending = this.pendingRequests.get(data.requestId);
|
||||||
this.pendingRequests.delete(data.requestId);
|
this.pendingRequests.delete(data.requestId);
|
||||||
|
handled = this.handlePendingResponse(pending, data);
|
||||||
if (pending.type === 'authenticate') {
|
} else {
|
||||||
if (data.success) {
|
handled = this.handleUnsolicitedMessage(data);
|
||||||
this.onAuthenticationSucceeded();
|
|
||||||
} else {
|
|
||||||
this.onAuthenticationFailed(data.error || 'unauthorized');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.success === false && data.error === 'unauthenticated') {
|
if (!handled && data.success === false && data.error === 'unauthenticated')
|
||||||
this.onAuthenticationFailed('unauthenticated');
|
this.onAuthenticationFailed('unauthenticated');
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePendingResponse(pending, data) {
|
||||||
|
if (!pending)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const type = typeof pending.type === 'string' ? pending.type.toLowerCase() : '';
|
||||||
|
|
||||||
|
if (type === 'authenticate') {
|
||||||
|
if (data.success)
|
||||||
|
this.onAuthenticationSucceeded();
|
||||||
|
else
|
||||||
|
this.onAuthenticationFailed(data.error || 'unauthorized');
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === 'getchargers') {
|
||||||
|
if (data.success) {
|
||||||
|
const payload = data && data.payload ? data.payload : {};
|
||||||
|
const chargers = Array.isArray(payload.chargers) ? payload.chargers : [];
|
||||||
|
this.processChargerList(chargers);
|
||||||
|
} else if (data.error === 'unauthenticated') {
|
||||||
|
this.onAuthenticationFailed('unauthenticated');
|
||||||
|
} else {
|
||||||
|
console.warn('GetChargers request failed', data.error || 'unknownError');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleUnsolicitedMessage(data) {
|
||||||
|
if (!data || !data.payload)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const payload = data.payload;
|
||||||
|
|
||||||
|
if (Array.isArray(payload.chargers)) {
|
||||||
|
this.processChargerList(payload.chargers);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.charger && payload.charger.id) {
|
||||||
|
this.upsertCharger(payload.charger);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
onAuthenticationSucceeded() {
|
onAuthenticationSucceeded() {
|
||||||
this.updateConnectionStatus('Connected', 'connected');
|
this.updateConnectionStatus('Connected', 'connected');
|
||||||
this.updateSessionSummary();
|
this.updateSessionSummary();
|
||||||
|
this.sendGetChargers();
|
||||||
}
|
}
|
||||||
|
|
||||||
onAuthenticationFailed(reason) {
|
onAuthenticationFailed(reason) {
|
||||||
@ -395,6 +450,9 @@ class DashboardApp {
|
|||||||
payload
|
payload
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizedAction = typeof action === 'string' ? action.toLowerCase() : '';
|
||||||
|
this.pendingRequests.set(requestId, { type: normalizedAction });
|
||||||
|
|
||||||
this.socket.send(JSON.stringify(message));
|
this.socket.send(JSON.stringify(message));
|
||||||
return requestId;
|
return requestId;
|
||||||
}
|
}
|
||||||
@ -415,6 +473,135 @@ class DashboardApp {
|
|||||||
return this.sendAction('GetChargers', { });
|
return this.sendAction('GetChargers', { });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
processChargerList(chargers = []) {
|
||||||
|
if (!Array.isArray(chargers)) {
|
||||||
|
console.warn('Expected chargers array in payload.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
chargers.forEach(charger => {
|
||||||
|
if (!charger || !charger.id)
|
||||||
|
return;
|
||||||
|
seen.add(charger.id);
|
||||||
|
this.upsertCharger(charger);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const existingId of Array.from(this.chargers.keys())) {
|
||||||
|
if (!seen.has(existingId))
|
||||||
|
this.removeCharger(existingId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertCharger(charger) {
|
||||||
|
if (!charger || !charger.id)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const hasExisting = this.chargers.has(charger.id);
|
||||||
|
const previous = hasExisting ? this.chargers.get(charger.id) : {};
|
||||||
|
const merged = { ...previous, ...charger };
|
||||||
|
this.chargers.set(charger.id, merged);
|
||||||
|
this.syncChargerRow(merged, !hasExisting);
|
||||||
|
}
|
||||||
|
|
||||||
|
syncChargerRow(charger, forceCreate = false) {
|
||||||
|
if (!charger || !charger.id || !this.elements.chargerTableBody)
|
||||||
|
return;
|
||||||
|
|
||||||
|
let row = this.findChargerRow(charger.id);
|
||||||
|
if (!row || forceCreate) {
|
||||||
|
if (row && row.parentElement)
|
||||||
|
row.parentElement.removeChild(row);
|
||||||
|
row = this.buildChargerRow(charger);
|
||||||
|
this.elements.chargerTableBody.appendChild(row);
|
||||||
|
} else {
|
||||||
|
this.chargerColumns.forEach(column => {
|
||||||
|
const cell = row.querySelector(`td[data-column="${column.key}"]`);
|
||||||
|
if (!cell)
|
||||||
|
return;
|
||||||
|
cell.textContent = this.formatChargerValue(column.key, charger[column.key]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.toggleChargerEmptyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
buildChargerRow(charger) {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.dataset.chargerId = charger.id;
|
||||||
|
this.chargerColumns.forEach(column => {
|
||||||
|
const cell = document.createElement('td');
|
||||||
|
cell.dataset.column = column.key;
|
||||||
|
cell.textContent = this.formatChargerValue(column.key, charger[column.key]);
|
||||||
|
row.appendChild(cell);
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
findChargerRow(chargerId) {
|
||||||
|
if (!this.elements.chargerTableBody || !chargerId)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const normalizedId = typeof CSS !== 'undefined' && CSS.escape
|
||||||
|
? CSS.escape(String(chargerId))
|
||||||
|
: String(chargerId).replace(/"/g, '\\"');
|
||||||
|
return this.elements.chargerTableBody.querySelector(`tr[data-charger-id="${normalizedId}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeCharger(chargerId) {
|
||||||
|
if (!chargerId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.chargers.delete(chargerId);
|
||||||
|
const row = this.findChargerRow(chargerId);
|
||||||
|
if (row && row.parentElement)
|
||||||
|
row.parentElement.removeChild(row);
|
||||||
|
|
||||||
|
this.toggleChargerEmptyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
resetChargerTable() {
|
||||||
|
if (!this.elements.chargerTableBody)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const rows = this.elements.chargerTableBody.querySelectorAll('tr[data-charger-id]');
|
||||||
|
rows.forEach(row => {
|
||||||
|
if (row.parentElement)
|
||||||
|
row.parentElement.removeChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.toggleChargerEmptyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleChargerEmptyState() {
|
||||||
|
if (!this.elements.chargerEmptyRow)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const hasChargers = this.chargers && this.chargers.size > 0;
|
||||||
|
this.elements.chargerEmptyRow.classList.toggle('hidden', hasChargers);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatChargerValue(key, value) {
|
||||||
|
if (value === null || value === undefined || value === '')
|
||||||
|
return '—';
|
||||||
|
|
||||||
|
if (typeof value === 'boolean')
|
||||||
|
return value ? 'Yes' : 'No';
|
||||||
|
|
||||||
|
if (typeof value === 'number')
|
||||||
|
return Number.isFinite(value) ? String(value) : '—';
|
||||||
|
|
||||||
|
if (typeof value === 'string')
|
||||||
|
return value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to stringify value for ${key}`, error);
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updateConnectionStatus(text, state) {
|
updateConnectionStatus(text, state) {
|
||||||
if (this.elements.connectionStatus)
|
if (this.elements.connectionStatus)
|
||||||
this.elements.connectionStatus.textContent = text;
|
this.elements.connectionStatus.textContent = text;
|
||||||
|
|||||||
@ -125,6 +125,45 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.chargers-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chargers-table th,
|
||||||
|
.chargers-table td {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 1px solid #e4e9f2;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chargers-table th {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--muted-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chargers-table tbody tr:nth-child(even) {
|
||||||
|
background: #f9fbfd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chargers-table td[data-column="id"] {
|
||||||
|
font-family: "Fira Code", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chargers-table .empty-row td {
|
||||||
|
text-align: center;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--muted-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
@ -306,6 +345,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
|
<section class="card">
|
||||||
|
<h2>Chargers</h2>
|
||||||
|
<p class="helper-text">Live data from your configured nymea EV chargers.</p>
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="chargers-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">ID</th>
|
||||||
|
<th scope="col">Name</th>
|
||||||
|
<th scope="col">Connected</th>
|
||||||
|
<th scope="col">Charging current</th>
|
||||||
|
<th scope="col">Charging allowed</th>
|
||||||
|
<th scope="col">Current power</th>
|
||||||
|
<th scope="col">Plugged in</th>
|
||||||
|
<th scope="col">Version</th>
|
||||||
|
<th scope="col">Session energy</th>
|
||||||
|
<th scope="col">Temperature</th>
|
||||||
|
<th scope="col">Charging phases</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="chargerTableBody">
|
||||||
|
<tr id="chargerEmptyRow" class="empty-row">
|
||||||
|
<td colspan="11">No chargers loaded yet.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>API Contract</h2>
|
<h2>API Contract</h2>
|
||||||
<p class="helper-text">All requests follow the structure below. Use <code>app.sendAction(action, payload)</code> from the browser console after authentication.</p>
|
<p class="helper-text">All requests follow the structure below. Use <code>app.sendAction(action, payload)</code> from the browser console after authentication.</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user