Add initial version of the table

initial-version
Simon Stürz 2025-11-11 09:46:33 +01:00
parent 1467445d86
commit 842a4510f0
2 changed files with 276 additions and 22 deletions

View File

@ -13,7 +13,9 @@ class DashboardApp {
logoutButton: document.getElementById('logoutButton'),
requestTemplate: document.getElementById('requestTemplate'),
responseTemplate: document.getElementById('responseTemplate'),
incomingMessage: document.getElementById('incomingMessage')
incomingMessage: document.getElementById('incomingMessage'),
chargerTableBody: document.getElementById('chargerTableBody'),
chargerEmptyRow: document.getElementById('chargerEmptyRow')
};
this.sessionKey = 'evdash.session';
@ -25,10 +27,25 @@ class DashboardApp {
this.reconnectTimer = null;
this.tokenRefreshTimer = null;
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.attachEventListeners();
this.restoreSession();
this.toggleChargerEmptyState();
}
attachEventListeners() {
@ -240,6 +257,8 @@ class DashboardApp {
clearTimeout(this.tokenRefreshTimer);
this.tokenRefreshTimer = null;
this.refreshInFlight = false;
this.chargers.clear();
this.resetChargerTable();
try {
window.localStorage.removeItem(this.sessionKey);
@ -299,17 +318,9 @@ class DashboardApp {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
return;
const requestId = this.generateRequestId();
const message = {
requestId,
action: 'authenticate',
payload: {
token: this.token
}
};
this.pendingRequests.set(requestId, { type: 'authenticate' });
this.socket.send(JSON.stringify(message));
this.sendAction('authenticate', {
token: this.token
});
}
onSocketMessage(event) {
@ -325,28 +336,72 @@ class DashboardApp {
if (this.elements.incomingMessage)
this.elements.incomingMessage.textContent = JSON.stringify(data, null, 2);
let handled = false;
if (data.requestId && this.pendingRequests.has(data.requestId)) {
const pending = this.pendingRequests.get(data.requestId);
this.pendingRequests.delete(data.requestId);
if (pending.type === 'authenticate') {
if (data.success) {
this.onAuthenticationSucceeded();
} else {
this.onAuthenticationFailed(data.error || 'unauthorized');
}
return;
}
handled = this.handlePendingResponse(pending, data);
} else {
handled = this.handleUnsolicitedMessage(data);
}
if (data.success === false && data.error === 'unauthenticated') {
if (!handled && data.success === false && data.error === '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() {
this.updateConnectionStatus('Connected', 'connected');
this.updateSessionSummary();
this.sendGetChargers();
}
onAuthenticationFailed(reason) {
@ -395,6 +450,9 @@ class DashboardApp {
payload
};
const normalizedAction = typeof action === 'string' ? action.toLowerCase() : '';
this.pendingRequests.set(requestId, { type: normalizedAction });
this.socket.send(JSON.stringify(message));
return requestId;
}
@ -415,6 +473,135 @@ class DashboardApp {
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) {
if (this.elements.connectionStatus)
this.elements.connectionStatus.textContent = text;

View File

@ -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 {
margin: 0;
background: #f8fafc;
@ -306,6 +345,34 @@
</div>
</header>
<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">
<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>