This repository has been archived on 2026-05-31. You can view files and clone it, but cannot push or open issues or pull requests.
2025-11-11 12:39:40 +01:00

847 lines
27 KiB
JavaScript

class DashboardApp {
constructor() {
this.elements = {
loginOverlay: document.getElementById('loginOverlay'),
loginForm: document.getElementById('loginForm'),
loginButton: document.getElementById('loginButton'),
loginError: document.getElementById('loginError'),
username: document.getElementById('username'),
password: document.getElementById('password'),
statusDot: document.getElementById('statusDot'),
connectionStatus: document.getElementById('connectionStatus'),
sessionSummary: document.getElementById('sessionSummary'),
logoutButton: document.getElementById('logoutButton'),
requestTemplate: document.getElementById('requestTemplate'),
responseTemplate: document.getElementById('responseTemplate'),
incomingMessage: document.getElementById('incomingMessage'),
chargerTableBody: document.getElementById('chargerTableBody'),
chargerEmptyRow: document.getElementById('chargerEmptyRow')
};
this.sessionKey = 'evdash.session';
this.socket = null;
this.token = null;
this.tokenExpiry = null;
this.username = null;
this.pendingRequests = new Map();
this.reconnectTimer = null;
this.tokenRefreshTimer = null;
this.refreshInFlight = false;
this.chargers = new Map();
this.chargerColumns = [
{ key: 'id', label: 'ID', hidden: true },
{ 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() {
if (this.elements.loginForm) {
this.elements.loginForm.addEventListener('submit', event => {
event.preventDefault();
this.submitLogin();
});
}
if (this.elements.logoutButton) {
this.elements.logoutButton.addEventListener('click', () => {
this.logout();
});
}
}
renderStaticTemplates() {
const contract = {
login: {
method: 'POST /evdash/api/login',
payload: {
username: 'user',
password: 'secret'
}
},
websocket: {
request: {
requestId: 'uuid',
action: 'ActionName',
payload: {}
},
authenticate: {
requestId: 'uuid',
action: 'authenticate',
payload: {
token: 'issued-token'
}
}
}
};
const responses = {
success: {
requestId: 'uuid',
success: true,
payload: {}
},
failure: {
requestId: 'uuid',
success: false,
error: 'Error message'
},
examplePing: {
requestId: 'uuid',
success: true,
payload: {
timestamp: '2025-01-12T09:30:00Z'
}
}
};
if (this.elements.requestTemplate)
this.elements.requestTemplate.textContent = JSON.stringify(contract, null, 2);
if (this.elements.responseTemplate)
this.elements.responseTemplate.textContent = JSON.stringify(responses, null, 2);
}
restoreSession() {
const stored = window.localStorage.getItem(this.sessionKey);
if (!stored) {
this.showLoginOverlay();
return;
}
try {
const parsed = JSON.parse(stored);
if (!parsed || !parsed.token || !parsed.expiresAt) {
this.clearSession();
this.showLoginOverlay();
return;
}
const expiresAt = new Date(parsed.expiresAt);
if (Number.isNaN(expiresAt.getTime()) || expiresAt <= new Date()) {
this.clearSession();
this.showLoginOverlay('Your session has expired. Please sign in again.');
return;
}
this.token = parsed.token;
this.tokenExpiry = expiresAt;
this.username = parsed.username || null;
this.scheduleTokenRefresh();
this.updateSessionSummary();
this.hideLoginOverlay();
this.connectWebSocket();
} catch (error) {
console.warn('Failed to restore session', error);
this.clearSession();
this.showLoginOverlay('We could not restore your previous session. Please sign in again.');
}
}
submitLogin() {
if (!this.elements.username || !this.elements.password || !this.elements.loginButton)
return;
const username = this.elements.username.value.trim();
const password = this.elements.password.value;
if (!username || !password) {
this.showLoginError('Username and password are required.');
return;
}
this.setLoginLoading(true);
this.performLoginRequest(username, password)
.then(session => {
this.persistSession({ ...session, username });
this.hideLoginOverlay();
this.updateSessionSummary();
this.connectWebSocket(true);
})
.catch(error => {
const message = error && error.message ? error.message : 'Login failed. Please try again.';
this.showLoginError(message);
})
.finally(() => {
this.setLoginLoading(false);
if (this.elements.password)
this.elements.password.value = '';
});
}
async performLoginRequest(username, password) {
let response;
try {
response = await fetch('/evdash/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
} catch (networkError) {
console.warn('Login request failed', networkError);
throw new Error('Unable to reach the login endpoint. Please check your connection.');
}
let data;
try {
data = await response.json();
} catch (parseError) {
console.warn('Failed to parse login response', parseError);
throw new Error('Received an unexpected response from the server.');
}
if (!response.ok || !data.success) {
const errorCode = data && data.error ? data.error : 'unauthorized';
throw new Error(this.describeLoginError(errorCode));
}
if (!data.token || !data.expiresAt)
throw new Error('Invalid response from server.');
return {
token: data.token,
expiresAt: data.expiresAt
};
}
describeLoginError(code) {
switch (code) {
case 'invalidRequest':
return 'The login request was malformed. Please reload the page and try again.';
case 'unauthorized':
return 'The provided credentials were not accepted.';
default:
return 'Login failed. Please try again.';
}
}
persistSession(session) {
this.token = session.token;
this.tokenExpiry = new Date(session.expiresAt);
this.username = session.username || null;
try {
window.localStorage.setItem(this.sessionKey, JSON.stringify({
token: this.token,
expiresAt: this.tokenExpiry.toISOString(),
username: this.username
}));
} catch (error) {
console.warn('Failed to persist session', error);
}
this.scheduleTokenRefresh();
}
clearSession() {
this.token = null;
this.tokenExpiry = null;
this.username = null;
this.pendingRequests.clear();
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
clearTimeout(this.tokenRefreshTimer);
this.tokenRefreshTimer = null;
this.refreshInFlight = false;
this.chargers.clear();
this.resetChargerTable();
try {
window.localStorage.removeItem(this.sessionKey);
} catch (error) {
console.warn('Failed to clear session', error);
}
this.updateSessionSummary();
}
connectWebSocket(resetPending = false) {
if (!this.token) {
this.updateConnectionStatus('Awaiting login…', 'connecting');
return;
}
if (this.tokenExpiry && this.tokenExpiry <= new Date()) {
this.clearSession();
this.showLoginOverlay('Your session has expired. Please sign in again.');
return;
}
if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING))
return;
if (resetPending)
this.pendingRequests.clear();
clearTimeout(this.reconnectTimer);
this.updateConnectionStatus('Connecting…', 'connecting');
const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const host = window.location.hostname || 'localhost';
const port = 4449;
const normalizedHost = host.includes(':') ? `[${host}]` : host;
const url = `${protocol}${normalizedHost}:${port}`;
this.socket = new WebSocket(url);
this.socket.addEventListener('open', () => {
this.updateConnectionStatus('Authenticating…', 'authenticating');
this.sendAuthenticate();
});
this.socket.addEventListener('message', event => {
this.onSocketMessage(event);
});
this.socket.addEventListener('error', () => {
this.updateConnectionStatus('Connection error', 'error');
});
this.socket.addEventListener('close', () => {
this.onSocketClosed();
});
}
sendAuthenticate() {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
return;
this.sendAction('authenticate', {
token: this.token
});
}
onSocketMessage(event) {
let data;
try {
data = JSON.parse(event.data);
} catch (error) {
console.warn('Failed to parse WebSocket message', error);
this.elements.incomingMessage.textContent = `Failed to parse message: ${error.message}`;
return;
}
console.log('<--', data);
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);
handled = this.handlePendingResponse(pending, data);
} else {
handled = this.handleUnsolicitedMessage(data);
}
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)
return false;
if (data.event && this.handleNotificationEvent(data.event, data.payload))
return true;
if (!data.payload)
return false;
const payload = data.payload;
if (Array.isArray(payload.chargers)) {
this.processChargerList(payload.chargers);
return true;
}
if (payload.charger) {
this.upsertCharger(payload.charger);
return true;
}
return false;
}
handleNotificationEvent(eventName, payload) {
if (!eventName)
return false;
const normalizedEvent = typeof eventName === 'string' ? eventName.toLowerCase() : '';
switch (normalizedEvent) {
case 'chargeradded':
case 'chargerchanged':
this.upsertCharger(payload);
return true;
case 'chargerremoved':
this.removeCharger(payload);
return true;
default:
return false;
}
}
onAuthenticationSucceeded() {
this.updateConnectionStatus('Connected', 'connected');
this.updateSessionSummary();
this.sendGetChargers();
}
onAuthenticationFailed(reason) {
const message = reason === 'unauthenticated'
? 'Your session expired. Please sign in again.'
: 'Authentication failed. Please try again.';
console.warn('Authentication failed', reason);
this.clearSession();
this.showLoginOverlay(message);
this.updateConnectionStatus('Authentication required', 'error');
if (this.socket && this.socket.readyState === WebSocket.OPEN)
this.socket.close();
}
onSocketClosed() {
this.pendingRequests.clear();
this.updateConnectionStatus('Disconnected', 'error');
if (!this.token) {
this.showLoginOverlay();
return;
}
clearTimeout(this.reconnectTimer);
this.reconnectTimer = setTimeout(() => {
this.connectWebSocket();
}, 3000);
}
sendAction(action, payload = {}) {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
console.warn('Cannot send action. WebSocket not connected.');
return null;
}
if (action !== 'authenticate' && this.pendingRequests.size && !this.isAuthenticated()) {
console.warn('Cannot send action before authentication succeeded.');
return null;
}
const requestId = this.generateRequestId();
const message = {
requestId,
action,
payload
};
const normalizedAction = typeof action === 'string' ? action.toLowerCase() : '';
this.pendingRequests.set(requestId, { type: normalizedAction });
this.socket.send(JSON.stringify(message));
return requestId;
}
isAuthenticated() {
for (const pending of this.pendingRequests.values()) {
if (pending.type === 'authenticate')
return false;
}
return !!this.token && !!this.socket && this.socket.readyState === WebSocket.OPEN;
}
sendPing() {
return this.sendAction('ping', { timestamp: new Date().toISOString() });
}
sendGetChargers() {
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 => {
const key = this.getChargerKey(charger);
if (!key)
return;
seen.add(key);
this.upsertCharger(charger);
});
for (const existingId of Array.from(this.chargers.keys())) {
if (!seen.has(existingId))
this.removeCharger(existingId);
}
}
upsertCharger(charger) {
const key = this.getChargerKey(charger);
if (!key)
return;
const hasExisting = this.chargers.has(key);
const previous = hasExisting ? this.chargers.get(key) : {};
const merged = { ...previous, ...charger };
merged.thingId = key;
this.chargers.set(key, merged);
this.syncChargerRow(merged, !hasExisting);
}
syncChargerRow(charger, forceCreate = false) {
const key = this.getChargerKey(charger);
if (!charger || !key || !this.elements.chargerTableBody)
return;
let row = this.findChargerRow(key);
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 => {
if (column.hidden)
return;
const cell = row.querySelector(`td[data-column="${column.key}"]`);
if (!cell)
return;
this.renderCellValue(cell, column.key, charger[column.key]);
});
}
this.toggleChargerEmptyState();
}
buildChargerRow(charger) {
const row = document.createElement('tr');
row.dataset.chargerId = this.getChargerKey(charger) || '';
this.chargerColumns.forEach(column => {
if (column.hidden)
return;
const cell = document.createElement('td');
cell.dataset.column = column.key;
this.renderCellValue(cell, column.key, charger[column.key]);
row.appendChild(cell);
});
return row;
}
renderCellValue(cell, key, value) {
if (!cell)
return;
if (typeof value === 'boolean') {
cell.innerHTML = '';
const dot = document.createElement('span');
dot.className = `value-dot ${value ? 'value-dot-true' : 'value-dot-false'}`;
dot.setAttribute('role', 'img');
dot.setAttribute('aria-label', value ? 'True' : 'False');
dot.title = value ? 'True' : 'False';
const srText = document.createElement('span');
srText.className = 'sr-only';
srText.textContent = value ? 'True' : 'False';
cell.appendChild(dot);
cell.appendChild(srText);
return;
}
cell.textContent = this.formatChargerValue(key, value);
}
removeCharger(identifier) {
const key = this.getChargerKey(identifier);
if (!key)
return;
this.chargers.delete(key);
const row = this.findChargerRow(key);
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();
}
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}"]`);
}
getChargerKey(source) {
if (!source)
return null;
if (typeof source === 'string')
return source;
if (source.thingId)
return source.thingId;
if (source.id)
return source.id;
return null;
}
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 ((key === 'currentPower' || key === 'sessionEnergy') && typeof value === 'number') {
if (!Number.isFinite(value))
return '—';
const unit = key === 'currentPower' ? 'kW' : 'kWh';
if (key === 'currentPower') {
value = value / 1000;
return `${value.toFixed(2)} ${unit}`;
}
return `${value.toFixed(2)} ${unit}`;
}
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;
if (!this.elements.statusDot)
return;
const dot = this.elements.statusDot;
dot.classList.remove('connecting', 'connected', 'authenticating', 'error');
dot.classList.add(state);
}
updateSessionSummary() {
if (!this.elements.sessionSummary)
return;
if (!this.token) {
this.elements.sessionSummary.textContent = 'Please sign in to start the WebSocket session.';
this.toggleLogoutButton(false);
return;
}
const expires = this.tokenExpiry ? this.tokenExpiry.toISOString() : 'unknown';
const username = this.username ? this.username : 'user';
this.elements.sessionSummary.textContent = `Signed in as ${username}. Token valid until ${expires}.`;
this.toggleLogoutButton(true);
}
showLoginOverlay(message) {
this.setAuthLayout(true);
if (this.elements.loginOverlay)
this.elements.loginOverlay.classList.remove('hidden');
if (typeof message === 'string' && message.length > 0)
this.showLoginError(message);
else
this.hideLoginError();
if (this.elements.username)
setTimeout(() => this.elements.username.focus(), 50);
}
hideLoginOverlay() {
this.setAuthLayout(false);
if (this.elements.loginOverlay)
this.elements.loginOverlay.classList.add('hidden');
this.hideLoginError();
}
showLoginError(message) {
if (!this.elements.loginError)
return;
this.elements.loginError.textContent = message;
this.elements.loginError.classList.remove('hidden');
}
hideLoginError() {
if (!this.elements.loginError)
return;
this.elements.loginError.textContent = '';
this.elements.loginError.classList.add('hidden');
}
setLoginLoading(loading) {
if (!this.elements.loginButton)
return;
this.elements.loginButton.disabled = loading;
this.elements.loginButton.textContent = loading ? 'Signing in…' : 'Sign in';
}
generateRequestId() {
if (window.crypto && window.crypto.randomUUID)
return window.crypto.randomUUID();
return `req-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
setAuthLayout(requireAuth) {
const body = document.body;
if (!body || body.dataset.mode === 'help')
return;
body.classList.toggle('needs-auth', requireAuth);
}
toggleLogoutButton(visible) {
if (!this.elements.logoutButton)
return;
this.elements.logoutButton.classList.toggle('hidden', !visible);
}
logout() {
this.clearSession();
if (this.socket && this.socket.readyState === WebSocket.OPEN)
this.socket.close();
this.updateConnectionStatus('Logged out', 'connecting');
this.updateSessionSummary();
this.showLoginOverlay('You have been logged out.');
}
scheduleTokenRefresh() {
clearTimeout(this.tokenRefreshTimer);
this.tokenRefreshTimer = null;
if (!this.token || !this.tokenExpiry)
return;
const now = Date.now();
const expiryTime = this.tokenExpiry.getTime();
const leadTimeMs = 60 * 1000; // refresh one minute before expiry
const delay = Math.max(expiryTime - leadTimeMs - now, 5 * 1000);
this.tokenRefreshTimer = setTimeout(() => {
this.refreshToken();
}, delay);
}
async refreshToken() {
if (!this.token || this.refreshInFlight)
return;
this.refreshInFlight = true;
try {
const response = await fetch('/evdash/api/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ token: this.token })
});
const data = await response.json();
if (!response.ok || !data.success)
throw new Error(data && data.error ? data.error : 'refreshFailed');
if (!data.token || !data.expiresAt)
throw new Error('Invalid response from server.');
this.persistSession({
token: data.token,
expiresAt: data.expiresAt,
username: this.username
});
this.updateSessionSummary();
} catch (error) {
console.warn('Token refresh failed', error);
this.clearSession();
this.updateConnectionStatus('Authentication required', 'error');
if (this.socket && this.socket.readyState === WebSocket.OPEN)
this.socket.close();
this.showLoginOverlay('Session expired. Please sign in again.');
} finally {
this.refreshInFlight = false;
}
}
}
window.app = new DashboardApp();