2420 lines
89 KiB
JavaScript
2420 lines
89 KiB
JavaScript
class DashboardApp {
|
|
constructor() {
|
|
this.locale = this.resolveLocale();
|
|
this.translations = this.buildTranslations();
|
|
|
|
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'),
|
|
brandLogo: document.getElementById('brandLogo'),
|
|
statusDot: document.getElementById('statusDot'),
|
|
connectionStatus: document.getElementById('connectionStatus'),
|
|
sessionUsername: document.getElementById('sessionUsername'),
|
|
logoutButton: document.getElementById('logoutButton'),
|
|
requestTemplate: document.getElementById('requestTemplate'),
|
|
responseTemplate: document.getElementById('responseTemplate'),
|
|
incomingMessage: document.getElementById('incomingMessage'),
|
|
easterEggOverlay: document.getElementById('easterEggOverlay'),
|
|
easterEggCanvas: document.getElementById('easterEggCanvas'),
|
|
easterEggClose: document.getElementById('easterEggClose'),
|
|
easterEggScore: document.getElementById('easterEggScore'),
|
|
chargerTableBody: document.getElementById('chargerTableBody'),
|
|
chargerEmptyRow: document.getElementById('chargerEmptyRow'),
|
|
fetchSessionsButton: document.getElementById('fetchSessionsButton'),
|
|
downloadSessionsButton: document.getElementById('downloadSessionsButton'),
|
|
carFilter: document.getElementById('carFilter'),
|
|
sessionStartFilter: document.getElementById('sessionStartFilter'),
|
|
sessionEndFilter: document.getElementById('sessionEndFilter'),
|
|
chargingSessionsTableBody: document.getElementById('chargingSessionsTableBody'),
|
|
chargingSessionsEmptyRow: document.getElementById('chargingSessionsEmptyRow'),
|
|
chargingSessionsOutput: document.getElementById('chargingSessionsOutput'),
|
|
panelButtons: Array.from(document.querySelectorAll('[data-panel-target]')),
|
|
contentPanels: Array.from(document.querySelectorAll('[data-panel]'))
|
|
};
|
|
|
|
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.expandedChargers = new Set();
|
|
this.cars = new Map();
|
|
this.sessions = [];
|
|
this.activePanel = null;
|
|
this.easterEggClickCount = 0;
|
|
this.easterEggClickResetTimer = null;
|
|
this.easterEggGame = {
|
|
running: false,
|
|
frameId: null,
|
|
player: { x: 30, y: 30, size: 16, speed: 3.2 },
|
|
target: { x: 200, y: 140, size: 10 },
|
|
score: 0,
|
|
keys: {}
|
|
};
|
|
this.chargerColumns = [
|
|
{ key: 'id', label: 'ID', hidden: true },
|
|
{ key: 'name', label: 'Name' },
|
|
{ key: 'assignedCar', label: 'Car' },
|
|
{ key: 'energyManagerMode', label: 'Energy manager mode' },
|
|
{ key: 'connected', label: 'Connected' },
|
|
{ key: 'status', label: 'Status' },
|
|
{ key: 'chargingCurrent', label: 'Charging current' },
|
|
{ key: 'chargingPhases', label: 'Charging phases' },
|
|
{ key: 'currentPower', label: 'Current power' },
|
|
{ key: 'sessionEnergy', label: 'Session energy' }
|
|
];
|
|
|
|
this.translateDocument();
|
|
this.updateEasterEggScore();
|
|
this.renderStaticTemplates();
|
|
this.attachEventListeners();
|
|
this.initializePanelNavigation();
|
|
this.restoreSession();
|
|
this.toggleChargerEmptyState();
|
|
this.updateCarSelector();
|
|
}
|
|
|
|
resolveLocale() {
|
|
const overrideKey = 'evdash.language';
|
|
try {
|
|
const stored = window.localStorage.getItem(overrideKey);
|
|
if (stored && typeof stored === 'string')
|
|
return stored.trim().toLowerCase().startsWith('de') ? 'de' : 'en';
|
|
} catch (error) {
|
|
// ignore
|
|
}
|
|
|
|
const candidates = Array.isArray(navigator.languages) && navigator.languages.length
|
|
? navigator.languages
|
|
: [navigator.language || 'en'];
|
|
|
|
const normalized = candidates
|
|
.filter(Boolean)
|
|
.map(value => String(value).toLowerCase());
|
|
|
|
if (normalized.some(value => value === 'de' || value.startsWith('de-')))
|
|
return 'de';
|
|
|
|
return 'en';
|
|
}
|
|
|
|
buildTranslations() {
|
|
return {
|
|
en: {
|
|
'header.tagline': 'Monitor & troubleshoot EV chargers.',
|
|
'header.awaitingLogin': 'Awaiting login…',
|
|
'header.authenticateHint': 'Load the dashboard to authenticate.',
|
|
'header.logout': 'Logout',
|
|
|
|
'sidebar.dashboardSections': 'Dashboard sections',
|
|
'sidebar.sections': 'Sections',
|
|
'sidebar.workspace': 'Workspace',
|
|
'sidebar.overview': 'Overview',
|
|
'sidebar.chargers': 'Chargers',
|
|
'sidebar.chargersSubtitle': 'Live table & telemetry',
|
|
'sidebar.sessions': 'Charging sessions',
|
|
'sidebar.sessionsSubtitle': 'History of charging sessions',
|
|
'sidebar.help': 'Help',
|
|
'sidebar.helpSubtitle': 'API concept & debug logs',
|
|
'sidebar.version': 'Version',
|
|
|
|
'chargers.liveOverview': 'Live overview',
|
|
'chargers.title': 'Chargers',
|
|
'chargers.empty': 'No chargers loaded yet.',
|
|
'chargers.columns.name': 'Name',
|
|
'chargers.columns.car': 'Car',
|
|
'chargers.columns.energyManagerMode': 'Energy manager mode',
|
|
'chargers.columns.connected': 'Connected',
|
|
'chargers.columns.status': 'Status',
|
|
'chargers.columns.chargingCurrent': 'Charging current',
|
|
'chargers.columns.chargingPhases': 'Charging phases',
|
|
'chargers.columns.currentPower': 'Current power',
|
|
'chargers.columns.sessionEnergy': 'Session energy',
|
|
'chargers.columns.version': 'Version',
|
|
'chargers.columns.temperature': 'Temperature',
|
|
'chargers.columns.digitalInputMode': 'Digital input',
|
|
'chargerStatus.Init': 'Initializing',
|
|
'chargerStatus.A1': 'Charger ready',
|
|
'chargerStatus.A2': 'Charger ready',
|
|
'chargerStatus.B1': 'Car connected, autorization required',
|
|
'chargerStatus.B2': 'Car connected',
|
|
'chargerStatus.C1': 'Charging pause, car ready',
|
|
'chargerStatus.C2': 'Charging',
|
|
'chargerStatus.F': 'Error',
|
|
|
|
'sessions.history': 'History',
|
|
'sessions.title': 'Charging sessions',
|
|
'sessions.filterCar': 'Car',
|
|
'sessions.allCars': 'All cars',
|
|
'sessions.filterStartDate': 'Start date',
|
|
'sessions.filterEndDate': 'End date',
|
|
'sessions.fetch': 'Fetch sessions',
|
|
'sessions.downloadCsv': 'Download CSV',
|
|
'sessions.helper': 'Optionally filter charging sessions by car and time range before downloading.',
|
|
'sessions.columns.name': 'Name',
|
|
'sessions.columns.charger': 'Charger',
|
|
'sessions.columns.car': 'Car',
|
|
'sessions.columns.start': 'Start',
|
|
'sessions.columns.end': 'End',
|
|
'sessions.columns.energy': 'Energy (kWh)',
|
|
'sessions.emptyFetched': 'No charging sessions fetched yet.',
|
|
'sessions.noneFound': 'No charging sessions found.',
|
|
'sessions.noneInRange': 'No charging sessions match the selected time range.',
|
|
'sessions.fetchFailed': 'Failed to fetch charging sessions.',
|
|
'sessions.requestFailed': 'Unable to request charging sessions. Check the connection status.',
|
|
'sessions.displayFailed': 'Unable to display charging sessions.',
|
|
'sessions.startBeforeEnd': 'Start date must be earlier than end date.',
|
|
'sessions.sessionIdLabel': 'Session {id}',
|
|
|
|
'help.guides': 'Guides',
|
|
'help.title': 'API Contract',
|
|
'help.helper': 'Use <code>app.sendAction(action, payload)</code> after authenticating.',
|
|
'help.requestTemplate': 'Request template',
|
|
'help.responses': 'Responses',
|
|
'help.diagnostics': 'Diagnostics',
|
|
'help.lastMessage': 'Last WebSocket message',
|
|
'help.diagnosticsHelper': 'Sign in to reuse the stored session and inspect backend payloads.',
|
|
'help.noMessagesYet': 'No messages received yet.',
|
|
'help.debugging': 'Debugging',
|
|
'help.sessionsPayload': 'Charging sessions payload',
|
|
'help.sessionsPayloadHelper': 'Raw session JSON for troubleshooting.',
|
|
'help.reference': 'Reference',
|
|
'help.chargerTableBasics': 'Charger table basics',
|
|
'help.referenceBullet1': 'The dashboard keeps one row per charger ID and updates it with backend notifications.',
|
|
'help.referenceBullet2': 'Columns follow the order defined by <code>EvDashEngine::packCharger</code> so new properties show up automatically.',
|
|
'help.referenceBullet3': 'Branding (colours, fonts) is managed via CSS variables at the top of this file for easy overrides.',
|
|
'help.referenceBullet4': 'Select a charger row to expand additional charger details.',
|
|
|
|
'easterEgg.hiddenTreat': 'Hidden treat',
|
|
'easterEgg.title': 'Grid Dash',
|
|
'easterEgg.close': 'Close',
|
|
'easterEgg.instructions': 'Use arrow keys or WASD to drive the tiny EV and catch lightning bolts. Press Esc or close to exit.',
|
|
'easterEgg.score': 'Score: {score}',
|
|
'easterEgg.hint': 'Stay inside the grid!',
|
|
'easterEgg.canvasLabel': 'Mini game canvas',
|
|
|
|
'login.title': 'Sign in',
|
|
'login.required': 'Authorization required.',
|
|
'login.username': 'Username',
|
|
'login.password': 'Password',
|
|
'login.helper': 'Contact the administrator to receive valid credentials.',
|
|
'login.signIn': 'Sign in',
|
|
'login.signingIn': 'Signing in…',
|
|
'login.emptyCredentials': 'Username and password are required.',
|
|
'login.failed': 'Login failed. Please try again.',
|
|
'login.networkError': 'Unable to reach the login endpoint. Please check your connection.',
|
|
'login.unexpectedResponse': 'Received an unexpected response from the server.',
|
|
'login.invalidResponse': 'Invalid response from server.',
|
|
'login.invalidRequest': 'The login request was malformed. Please reload the page and try again.',
|
|
'login.unauthorized': 'The provided credentials were not accepted.',
|
|
|
|
'connection.connecting': 'Connecting…',
|
|
'connection.authenticating': 'Authenticating…',
|
|
'connection.connected': 'Connected',
|
|
'connection.disconnected': 'Disconnected',
|
|
'connection.error': 'Connection error',
|
|
'connection.authenticationRequired': 'Authentication required',
|
|
'connection.loggedOut': 'Logged out',
|
|
'connection.sessionExpired': 'Session expired. Please sign in again.',
|
|
'connection.sessionExpiredRestore': 'Your session has expired. Please sign in again.',
|
|
'connection.restoreFailed': 'We could not restore your previous session. Please sign in again.',
|
|
'connection.loggedOutOverlay': 'You have been logged out.',
|
|
'connection.authFailed': 'Authentication failed. Please try again.',
|
|
|
|
'value.true': 'True',
|
|
'value.false': 'False',
|
|
'value.yes': 'Yes',
|
|
'value.no': 'No',
|
|
'value.unknownWithValue': 'Unknown ({value})',
|
|
|
|
'energyManagerMode.quick': 'Quick',
|
|
'energyManagerMode.eco': 'Eco',
|
|
'energyManagerMode.ecoTime': 'Eco + Time',
|
|
|
|
'digitalInputMode.chargingAllowed': 'Charging allowed',
|
|
'digitalInputMode.chargingAllowedInverted': 'Charging allowed inverted',
|
|
'digitalInputMode.pwmAndS0': 'PWM and S0 signaling',
|
|
'digitalInputMode.limitAndS0': 'Limit and S0 signaling',
|
|
|
|
'csv.sessionId': 'Session ID',
|
|
'csv.chargerName': 'Charger name',
|
|
'csv.chargerSerialNumber': 'Charger serial number',
|
|
'csv.car': 'Car',
|
|
'csv.start': 'Start',
|
|
'csv.end': 'End',
|
|
'csv.energyKwh': 'Energy [kWh]',
|
|
'csv.meterStartKwh': 'Meter start [kWh]',
|
|
'csv.meterEndKwh': 'Meter end [kWh]',
|
|
|
|
'ws.parseFailed': 'Failed to parse message: {error}'
|
|
},
|
|
de: {
|
|
'header.tagline': 'Überwachen & Fehleranalyse von EV-Ladestationen.',
|
|
'header.awaitingLogin': 'Warte auf Anmeldung…',
|
|
'header.authenticateHint': 'Dashboard laden, um dich zu authentifizieren.',
|
|
'header.logout': 'Abmelden',
|
|
|
|
'sidebar.dashboardSections': 'Dashboard-Bereiche',
|
|
'sidebar.sections': 'Bereiche',
|
|
'sidebar.workspace': 'Arbeitsbereich',
|
|
'sidebar.overview': 'Übersicht',
|
|
'sidebar.chargers': 'Ladestationen',
|
|
'sidebar.chargersSubtitle': 'Live-Tabelle & Telemetrie',
|
|
'sidebar.sessions': 'Ladevorgänge',
|
|
'sidebar.sessionsSubtitle': 'Historie der Ladevorgänge',
|
|
'sidebar.help': 'Hilfe',
|
|
'sidebar.helpSubtitle': 'API-Konzept & Debug-Logs',
|
|
'sidebar.version': 'Version',
|
|
|
|
'chargers.liveOverview': 'Live-Übersicht',
|
|
'chargers.title': 'Ladestationen',
|
|
'chargers.empty': 'Noch keine Ladestationen geladen.',
|
|
'chargers.columns.name': 'Name',
|
|
'chargers.columns.car': 'Fahrzeug',
|
|
'chargers.columns.energyManagerMode': 'Energiemanager-Modus',
|
|
'chargers.columns.connected': 'Verbunden',
|
|
'chargers.columns.status': 'Status',
|
|
'chargers.columns.chargingCurrent': 'Ladestrom',
|
|
'chargers.columns.chargingPhases': 'Ladephasen',
|
|
'chargers.columns.currentPower': 'Aktuelle Leistung',
|
|
'chargers.columns.sessionEnergy': 'Energie (Sitzung)',
|
|
'chargers.columns.version': 'Version',
|
|
'chargers.columns.temperature': 'Temperatur',
|
|
'chargers.columns.digitalInputMode': 'Digitaler Eingang',
|
|
'chargerStatus.Init': 'Initialisierung',
|
|
'chargerStatus.A1': 'Ladestation bereit',
|
|
'chargerStatus.A2': 'Ladestation bereit',
|
|
'chargerStatus.B1': 'Fahrzeug verbunden, Autorisierung erforderlich',
|
|
'chargerStatus.B2': 'Fahrzeug verbunden',
|
|
'chargerStatus.C1': 'Ladepause, Fahrzeug bereit',
|
|
'chargerStatus.C2': 'Laden',
|
|
'chargerStatus.F': 'Fehler',
|
|
|
|
'sessions.history': 'Historie',
|
|
'sessions.title': 'Ladevorgänge',
|
|
'sessions.filterCar': 'Fahrzeug',
|
|
'sessions.allCars': 'Alle Fahrzeuge',
|
|
'sessions.filterStartDate': 'Startdatum',
|
|
'sessions.filterEndDate': 'Enddatum',
|
|
'sessions.fetch': 'Ladevorgänge laden',
|
|
'sessions.downloadCsv': 'CSV herunterladen',
|
|
'sessions.helper': 'Optional nach Fahrzeug und Zeitraum filtern, bevor du die CSV herunterlädst.',
|
|
'sessions.columns.name': 'Name',
|
|
'sessions.columns.charger': 'Ladestation',
|
|
'sessions.columns.car': 'Fahrzeug',
|
|
'sessions.columns.start': 'Start',
|
|
'sessions.columns.end': 'Ende',
|
|
'sessions.columns.energy': 'Energie (kWh)',
|
|
'sessions.emptyFetched': 'Noch keine Ladevorgänge geladen.',
|
|
'sessions.noneFound': 'Keine Ladevorgänge gefunden.',
|
|
'sessions.noneInRange': 'Keine Ladevorgänge im ausgewählten Zeitraum.',
|
|
'sessions.fetchFailed': 'Ladevorgänge konnten nicht geladen werden.',
|
|
'sessions.requestFailed': 'Ladevorgänge konnten nicht angefragt werden. Bitte Verbindungsstatus prüfen.',
|
|
'sessions.displayFailed': 'Ladevorgänge können nicht angezeigt werden.',
|
|
'sessions.startBeforeEnd': 'Das Startdatum muss vor dem Enddatum liegen.',
|
|
'sessions.sessionIdLabel': 'Sitzung {id}',
|
|
|
|
'help.guides': 'Leitfäden',
|
|
'help.title': 'API-Vertrag',
|
|
'help.helper': 'Nach der Authentifizierung kannst du <code>app.sendAction(action, payload)</code> verwenden.',
|
|
'help.requestTemplate': 'Request-Vorlage',
|
|
'help.responses': 'Antworten',
|
|
'help.diagnostics': 'Diagnose',
|
|
'help.lastMessage': 'Letzte WebSocket-Nachricht',
|
|
'help.diagnosticsHelper': 'Anmelden, um die gespeicherte Sitzung zu nutzen und Backend-Payloads zu prüfen.',
|
|
'help.noMessagesYet': 'Noch keine Nachrichten empfangen.',
|
|
'help.debugging': 'Debugging',
|
|
'help.sessionsPayload': 'Ladevorgänge-Payload',
|
|
'help.sessionsPayloadHelper': 'Rohes Session-JSON zur Fehlersuche.',
|
|
'help.reference': 'Referenz',
|
|
'help.chargerTableBasics': 'Grundlagen der Ladestationen-Tabelle',
|
|
'help.referenceBullet1': 'Das Dashboard hält eine Zeile pro Ladestations-ID und aktualisiert sie über Backend-Benachrichtigungen.',
|
|
'help.referenceBullet2': 'Die Spalten folgen der Reihenfolge aus <code>EvDashEngine::packCharger</code>, sodass neue Eigenschaften automatisch erscheinen.',
|
|
'help.referenceBullet3': 'Branding (Farben, Schrift) wird über CSS-Variablen am Anfang dieser Datei gesteuert.',
|
|
'help.referenceBullet4': 'Wähle eine Ladestationszeile aus, um zusätzliche Details einzublenden.',
|
|
|
|
'easterEgg.hiddenTreat': 'Verstecktes Extra',
|
|
'easterEgg.title': 'Grid Dash',
|
|
'easterEgg.close': 'Schließen',
|
|
'easterEgg.instructions': 'Mit Pfeiltasten oder WASD fahren, Blitze einsammeln. Mit Esc oder Schließen beenden.',
|
|
'easterEgg.score': 'Punkte: {score}',
|
|
'easterEgg.hint': 'Bleib im Raster!',
|
|
'easterEgg.canvasLabel': 'Mini-Spiel-Leinwand',
|
|
|
|
'login.title': 'Anmelden',
|
|
'login.required': 'Autorisierung erforderlich.',
|
|
'login.username': 'Benutzername',
|
|
'login.password': 'Passwort',
|
|
'login.helper': 'Wende dich an den Administrator, um gültige Zugangsdaten zu erhalten.',
|
|
'login.signIn': 'Anmelden',
|
|
'login.signingIn': 'Anmeldung…',
|
|
'login.emptyCredentials': 'Benutzername und Passwort sind erforderlich.',
|
|
'login.failed': 'Anmeldung fehlgeschlagen. Bitte erneut versuchen.',
|
|
'login.networkError': 'Login-Endpunkt nicht erreichbar. Bitte Verbindung prüfen.',
|
|
'login.unexpectedResponse': 'Unerwartete Antwort vom Server erhalten.',
|
|
'login.invalidResponse': 'Ungültige Serverantwort.',
|
|
'login.invalidRequest': 'Die Login-Anfrage war fehlerhaft. Bitte Seite neu laden und erneut versuchen.',
|
|
'login.unauthorized': 'Die eingegebenen Zugangsdaten wurden nicht akzeptiert.',
|
|
|
|
'connection.connecting': 'Verbinden…',
|
|
'connection.authenticating': 'Authentifizierung…',
|
|
'connection.connected': 'Verbunden',
|
|
'connection.disconnected': 'Getrennt',
|
|
'connection.error': 'Verbindungsfehler',
|
|
'connection.authenticationRequired': 'Authentifizierung erforderlich',
|
|
'connection.loggedOut': 'Abgemeldet',
|
|
'connection.sessionExpired': 'Sitzung abgelaufen. Bitte erneut anmelden.',
|
|
'connection.sessionExpiredRestore': 'Deine Sitzung ist abgelaufen. Bitte erneut anmelden.',
|
|
'connection.restoreFailed': 'Die vorige Sitzung konnte nicht wiederhergestellt werden. Bitte erneut anmelden.',
|
|
'connection.loggedOutOverlay': 'Du wurdest abgemeldet.',
|
|
'connection.authFailed': 'Authentifizierung fehlgeschlagen. Bitte erneut versuchen.',
|
|
|
|
'value.true': 'Wahr',
|
|
'value.false': 'Falsch',
|
|
'value.yes': 'Ja',
|
|
'value.no': 'Nein',
|
|
'value.unknownWithValue': 'Unbekannt ({value})',
|
|
|
|
'energyManagerMode.quick': 'Schnell',
|
|
'energyManagerMode.eco': 'Eco',
|
|
'energyManagerMode.ecoTime': 'Eco + Zeit',
|
|
|
|
'digitalInputMode.chargingAllowed': 'Laden erlaubt',
|
|
'digitalInputMode.chargingAllowedInverted': 'Laden erlaubt (invertiert)',
|
|
'digitalInputMode.pwmAndS0': 'PWM- und S0-Signalisierung',
|
|
'digitalInputMode.limitAndS0': 'Limit- und S0-Signalisierung',
|
|
|
|
'csv.sessionId': 'Sitzungs-ID',
|
|
'csv.chargerName': 'Ladestationsname',
|
|
'csv.chargerSerialNumber': 'Seriennummer der Ladestation',
|
|
'csv.car': 'Fahrzeug',
|
|
'csv.start': 'Start',
|
|
'csv.end': 'Ende',
|
|
'csv.energyKwh': 'Energie [kWh]',
|
|
'csv.meterStartKwh': 'Zählerstand Start [kWh]',
|
|
'csv.meterEndKwh': 'Zählerstand Ende [kWh]',
|
|
|
|
'ws.parseFailed': 'Nachricht konnte nicht gelesen werden: {error}'
|
|
}
|
|
};
|
|
}
|
|
|
|
t(key, variables) {
|
|
const locale = this.locale in this.translations ? this.locale : 'en';
|
|
const table = this.translations[locale] || {};
|
|
const fallback = this.translations.en || {};
|
|
let text = (key && key in table) ? table[key] : (key in fallback ? fallback[key] : String(key));
|
|
|
|
if (variables && typeof variables === 'object') {
|
|
Object.entries(variables).forEach(([name, value]) => {
|
|
text = text.replaceAll(`{${name}}`, value === undefined || value === null ? '' : String(value));
|
|
});
|
|
}
|
|
|
|
return text;
|
|
}
|
|
|
|
translateDocument() {
|
|
try {
|
|
document.documentElement.lang = this.locale;
|
|
} catch (error) {
|
|
// ignore
|
|
}
|
|
|
|
const nodes = document.querySelectorAll('[data-i18n]');
|
|
nodes.forEach(node => {
|
|
const key = node.dataset.i18n;
|
|
if (!key)
|
|
return;
|
|
const attr = node.dataset.i18nAttr;
|
|
const text = this.t(key);
|
|
if (attr)
|
|
node.setAttribute(attr, text);
|
|
else if (node.dataset.i18nMode === 'html')
|
|
node.innerHTML = text;
|
|
else
|
|
node.textContent = text;
|
|
});
|
|
}
|
|
|
|
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();
|
|
});
|
|
}
|
|
|
|
if (this.elements.fetchSessionsButton) {
|
|
this.elements.fetchSessionsButton.addEventListener('click', () => {
|
|
this.fetchChargingSessions();
|
|
});
|
|
}
|
|
|
|
if (this.elements.carFilter) {
|
|
this.elements.carFilter.addEventListener('change', () => {
|
|
const carId = this.elements.carFilter.value;
|
|
if (carId)
|
|
this.fetchChargingSessions();
|
|
});
|
|
}
|
|
|
|
if (this.elements.downloadSessionsButton) {
|
|
this.elements.downloadSessionsButton.addEventListener('click', () => {
|
|
this.downloadChargingSessionsCsv();
|
|
});
|
|
}
|
|
|
|
if (this.elements.sessionStartFilter) {
|
|
this.elements.sessionStartFilter.addEventListener('change', () => {
|
|
this.renderChargingSessionsTable(this.sessions);
|
|
});
|
|
}
|
|
|
|
if (this.elements.sessionEndFilter) {
|
|
this.elements.sessionEndFilter.addEventListener('change', () => {
|
|
this.renderChargingSessionsTable(this.sessions);
|
|
});
|
|
}
|
|
|
|
if (this.elements.brandLogo) {
|
|
this.elements.brandLogo.addEventListener('click', () => {
|
|
this.handleBrandLogoClick();
|
|
});
|
|
}
|
|
|
|
if (this.elements.easterEggClose) {
|
|
this.elements.easterEggClose.addEventListener('click', () => {
|
|
this.stopEasterEggGame();
|
|
});
|
|
}
|
|
|
|
if (this.elements.easterEggOverlay) {
|
|
this.elements.easterEggOverlay.addEventListener('click', event => {
|
|
if (event.target === this.elements.easterEggOverlay)
|
|
this.stopEasterEggGame();
|
|
});
|
|
}
|
|
|
|
if (this.elements.chargerTableBody) {
|
|
this.elements.chargerTableBody.addEventListener('click', event => {
|
|
const targetRow = event.target ? event.target.closest('tr[data-charger-id]') : null;
|
|
if (!targetRow || !targetRow.dataset || !targetRow.dataset.chargerId)
|
|
return;
|
|
this.toggleChargerDetails(targetRow.dataset.chargerId);
|
|
});
|
|
|
|
this.elements.chargerTableBody.addEventListener('keydown', event => {
|
|
if (!event || (event.key !== 'Enter' && event.key !== ' '))
|
|
return;
|
|
|
|
const targetRow = event.target ? event.target.closest('tr[data-charger-id]') : null;
|
|
if (!targetRow || !targetRow.dataset || !targetRow.dataset.chargerId)
|
|
return;
|
|
|
|
event.preventDefault();
|
|
this.toggleChargerDetails(targetRow.dataset.chargerId);
|
|
});
|
|
}
|
|
}
|
|
|
|
initializePanelNavigation() {
|
|
const buttons = Array.isArray(this.elements.panelButtons) ? this.elements.panelButtons : [];
|
|
const panels = Array.isArray(this.elements.contentPanels) ? this.elements.contentPanels : [];
|
|
if (!buttons.length || !panels.length)
|
|
return;
|
|
|
|
const activatePanel = target => {
|
|
if (!target)
|
|
return;
|
|
|
|
const hasTarget = panels.some(panel => panel.dataset.panel === target);
|
|
if (!hasTarget)
|
|
return;
|
|
|
|
this.activePanel = target;
|
|
panels.forEach(panel => {
|
|
const isActive = panel.dataset.panel === target;
|
|
panel.classList.toggle('active', isActive);
|
|
panel.setAttribute('aria-hidden', isActive ? 'false' : 'true');
|
|
});
|
|
|
|
buttons.forEach(button => {
|
|
const isActive = button.dataset.panelTarget === target;
|
|
button.classList.toggle('active', isActive);
|
|
button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
|
|
});
|
|
|
|
const desiredHash = `#${target}`;
|
|
if (window.location.hash !== desiredHash) {
|
|
try {
|
|
window.history.replaceState(null, '', desiredHash);
|
|
} catch (error) {
|
|
window.location.hash = target;
|
|
}
|
|
}
|
|
};
|
|
|
|
buttons.forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
activatePanel(button.dataset.panelTarget);
|
|
});
|
|
});
|
|
|
|
const hashPanel = this.normalizePanelTargetFromHash(window.location.hash);
|
|
const preselected = buttons.find(button => button.classList.contains('active'));
|
|
const fallback = buttons[0];
|
|
const initialTarget = hashPanel
|
|
|| (preselected ? preselected.dataset.panelTarget : null)
|
|
|| (fallback ? fallback.dataset.panelTarget : null);
|
|
|
|
if (initialTarget)
|
|
activatePanel(initialTarget);
|
|
|
|
window.addEventListener('hashchange', () => {
|
|
const target = this.normalizePanelTargetFromHash(window.location.hash);
|
|
if (target && target !== this.activePanel)
|
|
activatePanel(target);
|
|
});
|
|
}
|
|
|
|
normalizePanelTargetFromHash(hash) {
|
|
if (!hash || hash.length < 2)
|
|
return null;
|
|
|
|
const lookup = hash.replace('#', '').trim().toLowerCase();
|
|
if (!lookup)
|
|
return null;
|
|
|
|
const panels = Array.isArray(this.elements.contentPanels) ? this.elements.contentPanels : [];
|
|
const match = panels.find(panel => {
|
|
const id = panel.dataset.panel || '';
|
|
return id.toLowerCase() === lookup;
|
|
});
|
|
|
|
return match ? match.dataset.panel : null;
|
|
}
|
|
|
|
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(this.t('connection.sessionExpiredRestore'));
|
|
return;
|
|
}
|
|
|
|
this.token = parsed.token;
|
|
this.tokenExpiry = expiresAt;
|
|
this.username = parsed.username || null;
|
|
this.scheduleTokenRefresh();
|
|
this.updateSessionUser();
|
|
this.hideLoginOverlay();
|
|
this.connectWebSocket();
|
|
} catch (error) {
|
|
console.warn('Failed to restore session', error);
|
|
this.clearSession();
|
|
this.showLoginOverlay(this.t('connection.restoreFailed'));
|
|
}
|
|
}
|
|
|
|
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(this.t('login.emptyCredentials'));
|
|
return;
|
|
}
|
|
|
|
this.setLoginLoading(true);
|
|
this.performLoginRequest(username, password)
|
|
.then(session => {
|
|
this.persistSession({ ...session, username });
|
|
this.hideLoginOverlay();
|
|
this.updateSessionUser();
|
|
this.connectWebSocket(true);
|
|
})
|
|
.catch(error => {
|
|
const message = error && error.message ? error.message : this.t('login.failed');
|
|
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(this.t('login.networkError'));
|
|
}
|
|
|
|
let data;
|
|
try {
|
|
data = await response.json();
|
|
} catch (parseError) {
|
|
console.warn('Failed to parse login response', parseError);
|
|
throw new Error(this.t('login.unexpectedResponse'));
|
|
}
|
|
|
|
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(this.t('login.invalidResponse'));
|
|
|
|
return {
|
|
token: data.token,
|
|
expiresAt: data.expiresAt
|
|
};
|
|
}
|
|
|
|
describeLoginError(code) {
|
|
switch (code) {
|
|
case 'invalidRequest':
|
|
return this.t('login.invalidRequest');
|
|
case 'unauthorized':
|
|
return this.t('login.unauthorized');
|
|
default:
|
|
return this.t('login.failed');
|
|
}
|
|
}
|
|
|
|
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.cars.clear();
|
|
this.resetChargerTable();
|
|
this.updateCarSelector();
|
|
this.renderChargingSessions([], this.t('sessions.emptyFetched'));
|
|
|
|
try {
|
|
window.localStorage.removeItem(this.sessionKey);
|
|
} catch (error) {
|
|
console.warn('Failed to clear session', error);
|
|
}
|
|
|
|
this.updateSessionUser();
|
|
}
|
|
|
|
connectWebSocket(resetPending = false) {
|
|
if (!this.token) {
|
|
this.updateConnectionStatus(this.t('header.awaitingLogin'), 'connecting');
|
|
return;
|
|
}
|
|
|
|
if (this.tokenExpiry && this.tokenExpiry <= new Date()) {
|
|
this.clearSession();
|
|
this.showLoginOverlay(this.t('connection.sessionExpiredRestore'));
|
|
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(this.t('connection.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(this.t('connection.authenticating'), 'authenticating');
|
|
this.sendAuthenticate();
|
|
});
|
|
|
|
this.socket.addEventListener('message', event => {
|
|
this.onSocketMessage(event);
|
|
});
|
|
|
|
this.socket.addEventListener('error', () => {
|
|
this.updateConnectionStatus(this.t('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;
|
|
}
|
|
|
|
if (type === 'getcars') {
|
|
if (data.success) {
|
|
const payload = data && data.payload ? data.payload : {};
|
|
const cars = Array.isArray(payload.cars) ? payload.cars : [];
|
|
this.processCarList(cars);
|
|
} else if (data.error === 'unauthenticated') {
|
|
this.onAuthenticationFailed('unauthenticated');
|
|
} else {
|
|
console.warn('GetCars request failed', data.error || 'unknownError');
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (type === 'getchargingsessions') {
|
|
if (data.success) {
|
|
const payload = data && data.payload ? data.payload : {};
|
|
const sessions = Array.isArray(payload.sessions) ? payload.sessions : [];
|
|
this.renderChargingSessions(sessions, this.t('sessions.noneFound'));
|
|
} else if (data.error === 'unauthenticated') {
|
|
this.onAuthenticationFailed('unauthenticated');
|
|
} else {
|
|
console.warn('GetChargingSessions request failed', data.error || 'unknownError');
|
|
this.renderChargingSessions([], this.t('sessions.fetchFailed'));
|
|
}
|
|
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 (Array.isArray(payload.cars)) {
|
|
this.processCarList(payload.cars);
|
|
return true;
|
|
}
|
|
|
|
if (Array.isArray(payload.sessions)) {
|
|
this.renderChargingSessions(payload.sessions);
|
|
return true;
|
|
}
|
|
|
|
if (payload.charger) {
|
|
this.upsertCharger(payload.charger);
|
|
return true;
|
|
}
|
|
|
|
if (payload.car) {
|
|
this.upsertCar(payload.car);
|
|
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;
|
|
case 'caradded':
|
|
case 'carchanged':
|
|
this.upsertCar(payload);
|
|
return true;
|
|
case 'carremoved':
|
|
this.removeCar(payload);
|
|
return true;
|
|
case 'chargingsessionsupdated':
|
|
if (payload && Array.isArray(payload.sessions))
|
|
this.renderChargingSessions(payload.sessions);
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
onAuthenticationSucceeded() {
|
|
this.updateConnectionStatus(this.t('connection.connected'), 'connected');
|
|
this.updateSessionUser();
|
|
this.sendGetCars();
|
|
this.sendGetChargers();
|
|
this.fetchChargingSessions();
|
|
}
|
|
|
|
onAuthenticationFailed(reason) {
|
|
const message = reason === 'unauthenticated'
|
|
? this.t('connection.sessionExpired')
|
|
: this.t('connection.authFailed');
|
|
|
|
console.warn('Authentication failed', reason);
|
|
this.clearSession();
|
|
this.showLoginOverlay(message);
|
|
this.updateConnectionStatus(this.t('connection.authenticationRequired'), 'error');
|
|
|
|
if (this.socket && this.socket.readyState === WebSocket.OPEN)
|
|
this.socket.close();
|
|
}
|
|
|
|
onSocketClosed() {
|
|
this.pendingRequests.clear();
|
|
this.updateConnectionStatus(this.t('connection.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() });
|
|
}
|
|
|
|
sendGetCars() {
|
|
return this.sendAction('GetCars', { });
|
|
}
|
|
|
|
sendGetChargers() {
|
|
return this.sendAction('GetChargers', { });
|
|
}
|
|
|
|
fetchChargingSessions() {
|
|
const payload = {};
|
|
const carId = this.elements.carFilter ? this.elements.carFilter.value : '';
|
|
if (carId)
|
|
payload.carId = carId;
|
|
|
|
const requestId = this.sendAction('GetChargingSessions', payload);
|
|
if (!requestId)
|
|
this.renderChargingSessions([], this.t('sessions.requestFailed'));
|
|
|
|
return requestId;
|
|
}
|
|
|
|
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) {
|
|
const detailsRow = this.findChargerDetailsRow(key);
|
|
if (detailsRow && detailsRow.parentElement)
|
|
detailsRow.parentElement.removeChild(detailsRow);
|
|
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.syncChargerDetailsVisibility(key);
|
|
this.toggleChargerEmptyState();
|
|
}
|
|
|
|
buildChargerRow(charger) {
|
|
const row = document.createElement('tr');
|
|
row.classList.add('charger-row');
|
|
row.dataset.chargerId = this.getChargerKey(charger) || '';
|
|
row.tabIndex = 0;
|
|
row.setAttribute('role', 'button');
|
|
row.setAttribute('aria-expanded', 'false');
|
|
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;
|
|
}
|
|
|
|
toggleChargerDetails(chargerId) {
|
|
if (!chargerId)
|
|
return;
|
|
|
|
const key = this.getChargerKey(chargerId);
|
|
if (!key)
|
|
return;
|
|
|
|
if (this.expandedChargers.has(key))
|
|
this.collapseChargerDetails(key);
|
|
else
|
|
this.expandChargerDetails(key);
|
|
}
|
|
|
|
expandChargerDetails(chargerId) {
|
|
if (!chargerId)
|
|
return;
|
|
|
|
const key = this.getChargerKey(chargerId);
|
|
if (!key)
|
|
return;
|
|
|
|
this.expandedChargers.add(key);
|
|
this.syncChargerDetailsVisibility(key);
|
|
}
|
|
|
|
collapseChargerDetails(chargerId) {
|
|
if (!chargerId)
|
|
return;
|
|
|
|
const key = this.getChargerKey(chargerId);
|
|
if (!key)
|
|
return;
|
|
|
|
this.expandedChargers.delete(key);
|
|
this.syncChargerDetailsVisibility(key);
|
|
}
|
|
|
|
syncChargerDetailsVisibility(chargerId) {
|
|
if (!chargerId || !this.elements.chargerTableBody)
|
|
return;
|
|
|
|
const key = this.getChargerKey(chargerId);
|
|
if (!key)
|
|
return;
|
|
|
|
const isExpanded = this.expandedChargers.has(key);
|
|
const row = this.findChargerRow(key);
|
|
if (row) {
|
|
row.classList.toggle('is-expanded', isExpanded);
|
|
row.setAttribute('aria-expanded', isExpanded ? 'true' : 'false');
|
|
}
|
|
|
|
const detailsRow = this.findChargerDetailsRow(key);
|
|
if (!isExpanded) {
|
|
if (detailsRow && detailsRow.parentElement)
|
|
detailsRow.parentElement.removeChild(detailsRow);
|
|
return;
|
|
}
|
|
|
|
if (!row)
|
|
return;
|
|
|
|
const ensured = detailsRow || this.buildChargerDetailsRow(key);
|
|
if (ensured && ensured !== detailsRow) {
|
|
this.elements.chargerTableBody.insertBefore(ensured, row.nextSibling);
|
|
}
|
|
|
|
this.updateChargerDetailsRow(key);
|
|
}
|
|
|
|
buildChargerDetailsRow(chargerId) {
|
|
const row = document.createElement('tr');
|
|
row.classList.add('charger-details-row');
|
|
row.dataset.chargerDetailsFor = this.getChargerKey(chargerId) || '';
|
|
|
|
const cell = document.createElement('td');
|
|
cell.colSpan = this.getVisibleChargerColumnCount();
|
|
const list = document.createElement('dl');
|
|
list.className = 'charger-details-list';
|
|
cell.appendChild(list);
|
|
row.appendChild(cell);
|
|
return row;
|
|
}
|
|
|
|
updateChargerDetailsRow(chargerId) {
|
|
if (!chargerId)
|
|
return;
|
|
|
|
const key = this.getChargerKey(chargerId);
|
|
if (!key || !this.expandedChargers.has(key))
|
|
return;
|
|
|
|
const charger = this.chargers && this.chargers.get(key) ? this.chargers.get(key) : null;
|
|
const detailsRow = this.findChargerDetailsRow(key);
|
|
if (!detailsRow)
|
|
return;
|
|
|
|
const list = detailsRow.querySelector('dl.charger-details-list');
|
|
if (!list)
|
|
return;
|
|
|
|
const items = [
|
|
{ label: this.t('chargers.columns.version'), key: 'version' },
|
|
{ label: this.t('chargers.columns.temperature'), key: 'temperature' },
|
|
{ label: this.t('chargers.columns.digitalInputMode'), key: 'digitalInputMode' }
|
|
];
|
|
|
|
list.innerHTML = '';
|
|
items.forEach(item => {
|
|
const term = document.createElement('dt');
|
|
term.textContent = item.label;
|
|
const description = document.createElement('dd');
|
|
const value = charger && Object.prototype.hasOwnProperty.call(charger, item.key) ? charger[item.key] : null;
|
|
description.textContent = this.formatChargerValue(item.key, value);
|
|
list.appendChild(term);
|
|
list.appendChild(description);
|
|
});
|
|
}
|
|
|
|
findChargerDetailsRow(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-details-for="${normalizedId}"]`);
|
|
}
|
|
|
|
getVisibleChargerColumnCount() {
|
|
return this.chargerColumns.filter(column => !column.hidden).length;
|
|
}
|
|
|
|
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 ? this.t('value.true') : this.t('value.false'));
|
|
dot.title = value ? this.t('value.true') : this.t('value.false');
|
|
const srText = document.createElement('span');
|
|
srText.className = 'sr-only';
|
|
srText.textContent = value ? this.t('value.true') : this.t('value.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.expandedChargers.delete(key);
|
|
this.chargers.delete(key);
|
|
const row = this.findChargerRow(key);
|
|
const detailsRow = this.findChargerDetailsRow(key);
|
|
if (row && row.parentElement)
|
|
row.parentElement.removeChild(row);
|
|
if (detailsRow && detailsRow.parentElement)
|
|
detailsRow.parentElement.removeChild(detailsRow);
|
|
|
|
this.toggleChargerEmptyState();
|
|
}
|
|
|
|
resetChargerTable() {
|
|
if (!this.elements.chargerTableBody)
|
|
return;
|
|
|
|
this.expandedChargers.clear();
|
|
const rows = this.elements.chargerTableBody.querySelectorAll('tr[data-charger-id], tr[data-charger-details-for]');
|
|
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);
|
|
}
|
|
|
|
processCarList(cars = []) {
|
|
if (!Array.isArray(cars)) {
|
|
console.warn('Expected cars array in payload.');
|
|
return;
|
|
}
|
|
|
|
const seen = new Set();
|
|
cars.forEach(car => {
|
|
const key = this.getCarKey(car);
|
|
if (!key)
|
|
return;
|
|
seen.add(key);
|
|
this.upsertCar(car);
|
|
});
|
|
|
|
for (const existingId of Array.from(this.cars.keys())) {
|
|
if (!seen.has(existingId))
|
|
this.removeCar(existingId);
|
|
}
|
|
}
|
|
|
|
upsertCar(car) {
|
|
const key = this.getCarKey(car);
|
|
if (!key)
|
|
return;
|
|
|
|
const hasExisting = this.cars.has(key);
|
|
const previous = hasExisting ? this.cars.get(key) : {};
|
|
const merged = { ...previous, ...car };
|
|
merged.thingId = key;
|
|
this.cars.set(key, merged);
|
|
this.updateCarSelector();
|
|
}
|
|
|
|
removeCar(identifier) {
|
|
const key = this.getCarKey(identifier);
|
|
if (!key)
|
|
return;
|
|
|
|
this.cars.delete(key);
|
|
this.updateCarSelector();
|
|
}
|
|
|
|
getCarKey(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;
|
|
}
|
|
|
|
updateCarSelector() {
|
|
const select = this.elements.carFilter;
|
|
if (!select)
|
|
return;
|
|
|
|
const currentValue = select.value;
|
|
while (select.options.length > 0)
|
|
select.remove(0);
|
|
|
|
const defaultOption = document.createElement('option');
|
|
defaultOption.value = '';
|
|
defaultOption.textContent = this.t('sessions.allCars');
|
|
select.appendChild(defaultOption);
|
|
|
|
const cars = Array.from(this.cars.values())
|
|
.sort((a, b) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' }));
|
|
|
|
cars.forEach(car => {
|
|
const option = document.createElement('option');
|
|
option.value = this.getCarKey(car) || '';
|
|
option.textContent = car.name || option.value;
|
|
select.appendChild(option);
|
|
});
|
|
|
|
const hasValue = currentValue && select.querySelector
|
|
&& typeof CSS !== 'undefined' && CSS.escape
|
|
&& select.querySelector(`option[value="${CSS.escape(currentValue)}"]`);
|
|
|
|
select.value = hasValue ? currentValue : '';
|
|
}
|
|
|
|
formatNumber(value, unit) {
|
|
if (!Number.isFinite(value))
|
|
return '—';
|
|
|
|
const rounded = Number.parseFloat(value.toFixed(2));
|
|
return unit ? `${rounded} ${unit}` : String(rounded);
|
|
}
|
|
|
|
formatNumberMaxDecimals(value, unit, decimals = 2) {
|
|
if (!Number.isFinite(value))
|
|
return '—';
|
|
|
|
const factor = 10 ** decimals;
|
|
const rounded = Math.round(value * factor) / factor;
|
|
let text = rounded.toFixed(decimals).replace(/\.?0+$/, '');
|
|
if (text === '-0')
|
|
text = '0';
|
|
return unit ? `${text} ${unit}` : text;
|
|
}
|
|
|
|
coerceFiniteNumber(value) {
|
|
if (typeof value === 'number')
|
|
return Number.isFinite(value) ? value : null;
|
|
|
|
if (typeof value !== 'string')
|
|
return null;
|
|
|
|
const normalized = value.trim().replace(',', '.');
|
|
if (!normalized)
|
|
return null;
|
|
|
|
const parsed = Number.parseFloat(normalized);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
formatChargerValue(key, value) {
|
|
if (value === null || value === undefined || value === '')
|
|
return '—';
|
|
|
|
if (key === 'status') {
|
|
const code = String(value).trim();
|
|
const statusKeys = {
|
|
Init: 'chargerStatus.Init',
|
|
A1: 'chargerStatus.A1',
|
|
A2: 'chargerStatus.A2',
|
|
B1: 'chargerStatus.B1',
|
|
B2: 'chargerStatus.B2',
|
|
C1: 'chargerStatus.C1',
|
|
C2: 'chargerStatus.C2',
|
|
F: 'chargerStatus.F'
|
|
};
|
|
if (code in statusKeys)
|
|
return `${code}: ${this.t(statusKeys[code])}`;
|
|
return code || '—';
|
|
}
|
|
|
|
if (key === 'energyManagerMode') {
|
|
const modes = {
|
|
0: this.t('energyManagerMode.quick'),
|
|
1: this.t('energyManagerMode.eco'),
|
|
2: this.t('energyManagerMode.ecoTime')
|
|
};
|
|
if (value in modes)
|
|
return modes[value];
|
|
return Number.isFinite(value) ? this.t('value.unknownWithValue', { value }) : '—';
|
|
}
|
|
|
|
if (key === 'digitalInputMode') {
|
|
const modes = {
|
|
0: this.t('digitalInputMode.chargingAllowed'),
|
|
1: this.t('digitalInputMode.chargingAllowedInverted'),
|
|
2: this.t('digitalInputMode.pwmAndS0'),
|
|
3: this.t('digitalInputMode.limitAndS0')
|
|
};
|
|
if (value in modes)
|
|
return modes[value];
|
|
return Number.isFinite(value) ? this.t('value.unknownWithValue', { value }) : '—';
|
|
}
|
|
|
|
if (key === 'currentPower' || key === 'sessionEnergy') {
|
|
const numericValue = this.coerceFiniteNumber(value);
|
|
if (numericValue === null)
|
|
return typeof value === 'string' ? value : '—';
|
|
|
|
const unit = key === 'currentPower' ? 'kW' : 'kWh';
|
|
if (key === 'currentPower')
|
|
return numericValue >= 0 && numericValue < 50 ? `0 ${unit}` : this.formatNumberMaxDecimals(numericValue / 1000, unit, 2);
|
|
|
|
return this.formatNumber(numericValue, unit);
|
|
}
|
|
|
|
if (typeof value === 'boolean')
|
|
return value ? this.t('value.yes') : this.t('value.no');
|
|
|
|
if (typeof value === 'number')
|
|
return this.formatNumber(value);
|
|
|
|
if (typeof value === 'string')
|
|
return value;
|
|
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch (error) {
|
|
console.warn(`Failed to stringify value for ${key}`, error);
|
|
return '—';
|
|
}
|
|
}
|
|
|
|
renderChargingSessions(sessions, fallbackMessage) {
|
|
const normalizedSessions = Array.isArray(sessions) ? sessions : [];
|
|
this.sessions = normalizedSessions;
|
|
|
|
this.renderChargingSessionsTable(normalizedSessions, fallbackMessage);
|
|
|
|
if (!this.elements.chargingSessionsOutput)
|
|
return;
|
|
|
|
if (!normalizedSessions.length) {
|
|
this.elements.chargingSessionsOutput.textContent = fallbackMessage || this.t('sessions.noneFound');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.elements.chargingSessionsOutput.textContent = JSON.stringify(normalizedSessions, null, 2);
|
|
} catch (error) {
|
|
console.warn('Failed to render charging sessions', error);
|
|
this.elements.chargingSessionsOutput.textContent = this.t('sessions.displayFailed');
|
|
}
|
|
}
|
|
|
|
renderChargingSessionsTable(sessions, fallbackMessage) {
|
|
const body = this.elements.chargingSessionsTableBody;
|
|
const emptyRow = this.elements.chargingSessionsEmptyRow;
|
|
if (!body)
|
|
return;
|
|
|
|
const normalizedSessions = Array.isArray(sessions) ? sessions : [];
|
|
const filteredSessions = this.filterChargingSessionsByTimeRange(normalizedSessions);
|
|
const hasTimeRangeFilter = this.hasChargingSessionTimeRangeFilter();
|
|
|
|
const rows = body.querySelectorAll('tr[data-session-id]');
|
|
rows.forEach(row => {
|
|
if (row.parentElement)
|
|
row.parentElement.removeChild(row);
|
|
});
|
|
|
|
if (!normalizedSessions.length || !filteredSessions.length) {
|
|
if (emptyRow) {
|
|
const cell = emptyRow.querySelector('td');
|
|
if (cell) {
|
|
if (!normalizedSessions.length) {
|
|
cell.textContent = fallbackMessage || this.t('sessions.emptyFetched');
|
|
} else if (hasTimeRangeFilter) {
|
|
cell.textContent = this.t('sessions.noneInRange');
|
|
} else {
|
|
cell.textContent = fallbackMessage || this.t('sessions.noneFound');
|
|
}
|
|
}
|
|
emptyRow.classList.remove('hidden');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (emptyRow)
|
|
emptyRow.classList.add('hidden');
|
|
|
|
filteredSessions.forEach(session => {
|
|
body.appendChild(this.buildChargingSessionRow(session));
|
|
});
|
|
}
|
|
|
|
hasChargingSessionTimeRangeFilter() {
|
|
const start = this.elements.sessionStartFilter ? this.elements.sessionStartFilter.value : '';
|
|
const end = this.elements.sessionEndFilter ? this.elements.sessionEndFilter.value : '';
|
|
return !!start || !!end;
|
|
}
|
|
|
|
getChargingSessionTimeRangeMs() {
|
|
const startValue = this.elements.sessionStartFilter ? this.elements.sessionStartFilter.value : '';
|
|
const endValue = this.elements.sessionEndFilter ? this.elements.sessionEndFilter.value : '';
|
|
const startMs = this.parseDateInputToMs(startValue, { endOfDay: false });
|
|
const endMs = this.parseDateInputToMs(endValue, { endOfDay: true });
|
|
|
|
if (this.elements.sessionStartFilter)
|
|
this.elements.sessionStartFilter.setCustomValidity('');
|
|
if (this.elements.sessionEndFilter)
|
|
this.elements.sessionEndFilter.setCustomValidity('');
|
|
|
|
if (Number.isFinite(startMs) && Number.isFinite(endMs) && startMs > endMs) {
|
|
const message = this.t('sessions.startBeforeEnd');
|
|
if (this.elements.sessionStartFilter)
|
|
this.elements.sessionStartFilter.setCustomValidity(message);
|
|
if (this.elements.sessionEndFilter)
|
|
this.elements.sessionEndFilter.setCustomValidity(message);
|
|
return { startMs: null, endMs: null };
|
|
}
|
|
|
|
return {
|
|
startMs: Number.isFinite(startMs) ? startMs : null,
|
|
endMs: Number.isFinite(endMs) ? endMs : null
|
|
};
|
|
}
|
|
|
|
parseDateInputToMs(value, options = {}) {
|
|
if (!value)
|
|
return null;
|
|
|
|
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
if (!match)
|
|
return null;
|
|
|
|
const year = Number.parseInt(match[1], 10);
|
|
const month = Number.parseInt(match[2], 10);
|
|
const day = Number.parseInt(match[3], 10);
|
|
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day))
|
|
return null;
|
|
|
|
const date = new Date(year, month - 1, day);
|
|
let ms = date.getTime();
|
|
if (!Number.isFinite(ms))
|
|
return null;
|
|
|
|
if (options && options.endOfDay)
|
|
ms += 24 * 60 * 60 * 1000 - 1;
|
|
|
|
return ms;
|
|
}
|
|
|
|
normalizeTimestampToMs(timestamp) {
|
|
const numeric = typeof timestamp === 'string' ? Number.parseFloat(timestamp) : timestamp;
|
|
if (!Number.isFinite(numeric))
|
|
return null;
|
|
|
|
const ms = numeric > 1e12 ? numeric : numeric * 1000;
|
|
return Number.isFinite(ms) ? ms : null;
|
|
}
|
|
|
|
filterChargingSessionsByTimeRange(sessions) {
|
|
if (!Array.isArray(sessions) || !sessions.length)
|
|
return [];
|
|
|
|
const { startMs, endMs } = this.getChargingSessionTimeRangeMs();
|
|
if (!Number.isFinite(startMs) && !Number.isFinite(endMs))
|
|
return sessions;
|
|
|
|
return sessions.filter(session => {
|
|
const sessionStart = this.normalizeTimestampToMs(session ? session.startTimestamp : null);
|
|
const sessionEnd = this.normalizeTimestampToMs(session ? session.endTimestamp : null);
|
|
const effectiveStart = Number.isFinite(sessionStart) ? sessionStart : null;
|
|
const effectiveEnd = Number.isFinite(sessionEnd)
|
|
? sessionEnd
|
|
: (Number.isFinite(sessionStart) ? sessionStart : null);
|
|
|
|
if (!Number.isFinite(effectiveStart) && !Number.isFinite(effectiveEnd))
|
|
return true;
|
|
|
|
if (Number.isFinite(startMs) && Number.isFinite(effectiveEnd) && effectiveEnd < startMs)
|
|
return false;
|
|
|
|
if (Number.isFinite(endMs) && Number.isFinite(effectiveStart) && effectiveStart > endMs)
|
|
return false;
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
buildChargingSessionRow(session) {
|
|
const row = document.createElement('tr');
|
|
row.dataset.sessionId = session && session.sessionId ? session.sessionId : '';
|
|
|
|
const cells = [
|
|
this.deriveSessionName(session),
|
|
session && session.chargerName ? session.chargerName : '—',
|
|
session && session.carName ? session.carName : '—',
|
|
this.formatTimestamp(session ? session.startTimestamp : null),
|
|
this.formatTimestamp(session ? session.endTimestamp : null),
|
|
this.formatSessionEnergy(session)
|
|
];
|
|
|
|
cells.forEach((value, index) => {
|
|
const cell = document.createElement('td');
|
|
if (index === 5)
|
|
cell.classList.add('numeric');
|
|
cell.textContent = value;
|
|
row.appendChild(cell);
|
|
});
|
|
|
|
return row;
|
|
}
|
|
|
|
deriveSessionName(session) {
|
|
if (!session)
|
|
return '—';
|
|
|
|
if (session.name)
|
|
return session.name;
|
|
|
|
if (session.property)
|
|
return session.property;
|
|
|
|
if (session.sessionId)
|
|
return this.t('sessions.sessionIdLabel', { id: session.sessionId });
|
|
|
|
return '—';
|
|
}
|
|
|
|
formatTimestamp(timestamp) {
|
|
const numeric = typeof timestamp === 'string' ? Number.parseFloat(timestamp) : timestamp;
|
|
if (!Number.isFinite(numeric))
|
|
return '—';
|
|
|
|
const ms = numeric > 1e12 ? numeric : numeric * 1000;
|
|
const date = new Date(ms);
|
|
if (Number.isNaN(date.getTime()))
|
|
return '—';
|
|
|
|
const pad = value => String(value).padStart(2, '0');
|
|
const day = pad(date.getDate());
|
|
const month = pad(date.getMonth() + 1);
|
|
const year = date.getFullYear();
|
|
const hours = pad(date.getHours());
|
|
const minutes = pad(date.getMinutes());
|
|
|
|
return `${day}.${month}.${year} ${hours}:${minutes}`;
|
|
}
|
|
|
|
formatSessionEnergy(session) {
|
|
const value = session && Number.isFinite(session.sessionEnergy)
|
|
? session.sessionEnergy
|
|
: this.calculateSessionEnergyFromRange(session);
|
|
if (!Number.isFinite(value))
|
|
return '—';
|
|
|
|
return `${value.toFixed(2)} kWh`;
|
|
}
|
|
|
|
calculateSessionEnergyFromRange(session) {
|
|
if (!session)
|
|
return null;
|
|
|
|
const end = typeof session.energyEnd === 'string' ? Number.parseFloat(session.energyEnd) : session.energyEnd;
|
|
const start = typeof session.energyStart === 'string' ? Number.parseFloat(session.energyStart) : session.energyStart;
|
|
|
|
if (!Number.isFinite(end) || !Number.isFinite(start))
|
|
return null;
|
|
|
|
return end - start;
|
|
}
|
|
|
|
downloadChargingSessionsCsv() {
|
|
const sessions = this.filterChargingSessionsByTimeRange(this.sessions);
|
|
if (!sessions.length) {
|
|
console.warn('No charging sessions to download.');
|
|
return;
|
|
}
|
|
|
|
const csvContent = this.buildSessionsCsv(sessions);
|
|
if (!csvContent) {
|
|
console.warn('Failed to build CSV for charging sessions.');
|
|
return;
|
|
}
|
|
|
|
const carId = this.elements.carFilter ? this.elements.carFilter.value : '';
|
|
const carName = carId && this.cars.has(carId) ? this.cars.get(carId).name : '';
|
|
const carSuffix = carName ? `-${this.sanitizeFilename(carName)}` : '';
|
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = `charging-sessions${carSuffix}-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
setTimeout(() => {
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
}, 0);
|
|
}
|
|
|
|
buildSessionsCsv(sessions) {
|
|
if (!Array.isArray(sessions) || !sessions.length)
|
|
return '';
|
|
|
|
const columns = [
|
|
{ label: this.t('csv.sessionId'), key: 'sessionId' },
|
|
{ label: this.t('csv.chargerName'), key: 'chargerName' },
|
|
{ label: this.t('csv.chargerSerialNumber'), key: 'chargerSerialNumber' },
|
|
{ label: this.t('csv.car'), key: 'carName' },
|
|
{ label: this.t('csv.start'), key: 'startTimestamp', formatter: value => this.formatCsvTimestamp(value) },
|
|
{ label: this.t('csv.end'), key: 'endTimestamp', formatter: value => this.formatCsvTimestamp(value) },
|
|
{ label: this.t('csv.energyKwh'), key: 'sessionEnergy' },
|
|
{ label: this.t('csv.meterStartKwh'), key: 'energyStart' },
|
|
{ label: this.t('csv.meterEndKwh'), key: 'energyEnd' }
|
|
];
|
|
|
|
const lines = [];
|
|
lines.push(columns.map(column => column.label).join(';'));
|
|
sessions.forEach(session => {
|
|
const row = columns.map(column => {
|
|
const raw = session ? session[column.key] : '';
|
|
const formatted = typeof column.formatter === 'function'
|
|
? column.formatter(raw)
|
|
: this.formatCsvPrimitive(raw);
|
|
return this.escapeCsvValue(formatted);
|
|
});
|
|
lines.push(row.join(';'));
|
|
});
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
formatCsvPrimitive(value) {
|
|
if (value === null || value === undefined)
|
|
return '';
|
|
|
|
if (typeof value === 'number')
|
|
return Number.isFinite(value) ? String(value) : '';
|
|
|
|
if (typeof value === 'string') {
|
|
const trimmed = value.trim();
|
|
return trimmed.length ? trimmed : '';
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
if (!value.length)
|
|
return '';
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch (error) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
if (typeof value === 'object') {
|
|
if (!Object.keys(value).length)
|
|
return '';
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch (error) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
return String(value);
|
|
}
|
|
|
|
formatCsvTimestamp(value) {
|
|
const numeric = typeof value === 'string' ? Number.parseFloat(value) : value;
|
|
if (!Number.isFinite(numeric))
|
|
return '';
|
|
|
|
const ms = numeric > 1e12 ? numeric : numeric * 1000;
|
|
const date = new Date(ms);
|
|
if (Number.isNaN(date.getTime()))
|
|
return '';
|
|
|
|
const pad = part => String(part).padStart(2, '0');
|
|
const day = pad(date.getDate());
|
|
const month = pad(date.getMonth() + 1);
|
|
const year = date.getFullYear();
|
|
const hours = pad(date.getHours());
|
|
const minutes = pad(date.getMinutes());
|
|
|
|
return `${day}.${month}.${year} ${hours}:${minutes}`;
|
|
}
|
|
|
|
escapeCsvValue(value) {
|
|
if (value === null || value === undefined)
|
|
return '';
|
|
|
|
if (typeof value === 'number' && !Number.isFinite(value))
|
|
return '';
|
|
|
|
if (typeof value === 'object') {
|
|
try {
|
|
value = JSON.stringify(value);
|
|
} catch (error) {
|
|
value = String(value);
|
|
}
|
|
}
|
|
|
|
let stringValue = String(value);
|
|
if (stringValue.includes('"'))
|
|
stringValue = stringValue.replace(/"/g, '""');
|
|
|
|
if (stringValue.search(/[;\n"]/g) !== -1)
|
|
stringValue = `"${stringValue}"`;
|
|
|
|
return stringValue;
|
|
}
|
|
|
|
handleBrandLogoClick() {
|
|
clearTimeout(this.easterEggClickResetTimer);
|
|
this.easterEggClickCount += 1;
|
|
this.easterEggClickResetTimer = setTimeout(() => {
|
|
this.easterEggClickCount = 0;
|
|
}, 1200);
|
|
|
|
if (this.easterEggClickCount >= 10) {
|
|
this.easterEggClickCount = 0;
|
|
this.startEasterEggGame();
|
|
}
|
|
}
|
|
|
|
startEasterEggGame() {
|
|
if (!this.elements.easterEggOverlay || !this.elements.easterEggCanvas)
|
|
return;
|
|
|
|
this.elements.easterEggOverlay.classList.remove('hidden');
|
|
this.elements.easterEggOverlay.setAttribute('aria-hidden', 'false');
|
|
|
|
const game = this.easterEggGame;
|
|
const canvas = this.elements.easterEggCanvas;
|
|
game.running = true;
|
|
game.score = 0;
|
|
game.keys = {};
|
|
game.lastTime = null;
|
|
game.player.x = canvas.width * 0.2;
|
|
game.player.y = canvas.height * 0.5;
|
|
this.spawnEasterEggTarget();
|
|
this.updateEasterEggScore();
|
|
this.toggleEasterEggListeners(true);
|
|
|
|
const loop = timestamp => {
|
|
if (!game.running)
|
|
return;
|
|
|
|
if (!game.lastTime)
|
|
game.lastTime = timestamp;
|
|
const delta = Math.min((timestamp - game.lastTime) / 16.67, 3);
|
|
game.lastTime = timestamp;
|
|
|
|
this.updateEasterEggPhysics(delta);
|
|
this.drawEasterEggFrame();
|
|
game.frameId = window.requestAnimationFrame(loop);
|
|
};
|
|
|
|
game.frameId = window.requestAnimationFrame(loop);
|
|
}
|
|
|
|
stopEasterEggGame() {
|
|
const game = this.easterEggGame;
|
|
game.running = false;
|
|
game.keys = {};
|
|
if (game.frameId) {
|
|
window.cancelAnimationFrame(game.frameId);
|
|
game.frameId = null;
|
|
}
|
|
|
|
if (this.elements.easterEggOverlay) {
|
|
this.elements.easterEggOverlay.classList.add('hidden');
|
|
this.elements.easterEggOverlay.setAttribute('aria-hidden', 'true');
|
|
}
|
|
|
|
this.toggleEasterEggListeners(false);
|
|
}
|
|
|
|
toggleEasterEggListeners(enable) {
|
|
if (!this._easterEggKeyDownHandler) {
|
|
this._easterEggKeyDownHandler = event => this.handleEasterEggKey(event, true);
|
|
this._easterEggKeyUpHandler = event => this.handleEasterEggKey(event, false);
|
|
}
|
|
|
|
if (enable) {
|
|
document.addEventListener('keydown', this._easterEggKeyDownHandler);
|
|
document.addEventListener('keyup', this._easterEggKeyUpHandler);
|
|
} else {
|
|
document.removeEventListener('keydown', this._easterEggKeyDownHandler);
|
|
document.removeEventListener('keyup', this._easterEggKeyUpHandler);
|
|
}
|
|
}
|
|
|
|
handleEasterEggKey(event, isDown) {
|
|
if (!this.easterEggGame.running)
|
|
return;
|
|
|
|
const key = event.key ? event.key.toLowerCase() : '';
|
|
const movableKeys = ['arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'w', 'a', 's', 'd'];
|
|
if (key === 'escape') {
|
|
this.stopEasterEggGame();
|
|
return;
|
|
}
|
|
|
|
if (movableKeys.includes(key)) {
|
|
event.preventDefault();
|
|
this.easterEggGame.keys[key] = isDown;
|
|
}
|
|
}
|
|
|
|
updateEasterEggPhysics(delta) {
|
|
const canvas = this.elements.easterEggCanvas;
|
|
if (!canvas)
|
|
return;
|
|
|
|
const game = this.easterEggGame;
|
|
const { player, target } = game;
|
|
const input = {
|
|
x: (game.keys.arrowright || game.keys.d ? 1 : 0) - (game.keys.arrowleft || game.keys.a ? 1 : 0),
|
|
y: (game.keys.arrowdown || game.keys.s ? 1 : 0) - (game.keys.arrowup || game.keys.w ? 1 : 0)
|
|
};
|
|
|
|
if (input.x !== 0 || input.y !== 0) {
|
|
const length = Math.hypot(input.x, input.y) || 1;
|
|
const speed = player.speed * delta;
|
|
player.x += (input.x / length) * speed;
|
|
player.y += (input.y / length) * speed;
|
|
}
|
|
|
|
const minX = player.size;
|
|
const maxX = canvas.width - player.size;
|
|
const minY = player.size;
|
|
const maxY = canvas.height - player.size;
|
|
player.x = Math.min(Math.max(player.x, minX), maxX);
|
|
player.y = Math.min(Math.max(player.y, minY), maxY);
|
|
|
|
const dx = player.x - target.x;
|
|
const dy = player.y - target.y;
|
|
const distance = Math.hypot(dx, dy);
|
|
if (distance <= player.size + target.size) {
|
|
game.score += 1;
|
|
player.speed = Math.min(player.speed + 0.15, 7.5);
|
|
this.updateEasterEggScore();
|
|
this.spawnEasterEggTarget();
|
|
}
|
|
}
|
|
|
|
drawEasterEggFrame() {
|
|
const canvas = this.elements.easterEggCanvas;
|
|
if (!canvas)
|
|
return;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
const game = this.easterEggGame;
|
|
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);
|
|
gradient.addColorStop(0, '#0f1c3d');
|
|
gradient.addColorStop(1, '#092037');
|
|
ctx.fillStyle = gradient;
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
|
|
ctx.lineWidth = 1;
|
|
for (let x = 20; x < canvas.width; x += 40) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, 0);
|
|
ctx.lineTo(x, canvas.height);
|
|
ctx.stroke();
|
|
}
|
|
for (let y = 20; y < canvas.height; y += 40) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
ctx.lineTo(canvas.width, y);
|
|
ctx.stroke();
|
|
}
|
|
|
|
const { player, target } = game;
|
|
ctx.fillStyle = '#f4b400';
|
|
ctx.beginPath();
|
|
ctx.moveTo(target.x, target.y - target.size);
|
|
ctx.lineTo(target.x - target.size * 0.6, target.y + target.size * 0.2);
|
|
ctx.lineTo(target.x - target.size * 0.2, target.y + target.size * 0.2);
|
|
ctx.lineTo(target.x - target.size, target.y + target.size);
|
|
ctx.lineTo(target.x + target.size * 0.2, target.y + target.size * 0.2);
|
|
ctx.lineTo(target.x + target.size * 0.6, target.y - target.size * 0.8);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.strokeStyle = 'rgba(0,0,0,0.2)';
|
|
ctx.lineWidth = 1.2;
|
|
ctx.stroke();
|
|
|
|
const carWidth = player.size * 2.4;
|
|
const carHeight = player.size * 1.4;
|
|
const carX = player.x - carWidth / 2;
|
|
const carY = player.y - carHeight / 2;
|
|
|
|
const carGradient = ctx.createLinearGradient(carX, carY, carX + carWidth, carY + carHeight);
|
|
carGradient.addColorStop(0, '#e30a18');
|
|
carGradient.addColorStop(1, '#f48221');
|
|
ctx.fillStyle = carGradient;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(carX + carWidth * 0.15, carY + carHeight);
|
|
ctx.lineTo(carX + carWidth * 0.15, carY + carHeight * 0.55);
|
|
ctx.lineTo(carX + carWidth * 0.35, carY + carHeight * 0.25);
|
|
ctx.lineTo(carX + carWidth * 0.65, carY + carHeight * 0.25);
|
|
ctx.lineTo(carX + carWidth * 0.85, carY + carHeight * 0.55);
|
|
ctx.lineTo(carX + carWidth * 0.85, carY + carHeight);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
|
|
const wheelRadius = player.size * 0.35;
|
|
const wheelY = carY + carHeight;
|
|
ctx.fillStyle = '#0d1221';
|
|
ctx.beginPath();
|
|
ctx.arc(carX + carWidth * 0.28, wheelY, wheelRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.beginPath();
|
|
ctx.arc(carX + carWidth * 0.72, wheelY, wheelRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.fillStyle = 'rgba(255,255,255,0.35)';
|
|
ctx.fillRect(carX + carWidth * 0.42, carY + carHeight * 0.32, carWidth * 0.22, carHeight * 0.2);
|
|
}
|
|
|
|
spawnEasterEggTarget() {
|
|
const canvas = this.elements.easterEggCanvas;
|
|
if (!canvas)
|
|
return;
|
|
|
|
const target = this.easterEggGame.target;
|
|
const player = this.easterEggGame.player;
|
|
const padding = target.size + 12;
|
|
let attempts = 0;
|
|
do {
|
|
target.x = padding + Math.random() * (canvas.width - padding * 2);
|
|
target.y = padding + Math.random() * (canvas.height - padding * 2);
|
|
attempts++;
|
|
} while (Math.hypot(player.x - target.x, player.y - target.y) < player.size * 2 && attempts < 12);
|
|
}
|
|
|
|
updateEasterEggScore() {
|
|
if (!this.elements.easterEggScore)
|
|
return;
|
|
this.elements.easterEggScore.textContent = this.t('easterEgg.score', { score: this.easterEggGame.score });
|
|
}
|
|
|
|
sanitizeFilename(value) {
|
|
if (typeof value !== 'string')
|
|
return '';
|
|
|
|
const trimmed = value.trim();
|
|
if (!trimmed.length)
|
|
return '';
|
|
|
|
return trimmed
|
|
.replace(/[^a-z0-9-_]+/gi, '-')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-|-$/g, '')
|
|
.toLowerCase();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
updateSessionUser() {
|
|
if (!this.elements.sessionUsername)
|
|
return;
|
|
|
|
const defaultLabel = document.body && document.body.dataset.mode === 'help'
|
|
? this.t('header.authenticateHint')
|
|
: this.t('header.awaitingLogin');
|
|
|
|
if (!this.token || !this.username) {
|
|
this.elements.sessionUsername.textContent = defaultLabel;
|
|
this.toggleLogoutButton(false);
|
|
return;
|
|
}
|
|
|
|
this.elements.sessionUsername.textContent = this.username;
|
|
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 ? this.t('login.signingIn') : this.t('login.signIn');
|
|
}
|
|
|
|
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)
|
|
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(this.t('connection.loggedOut'), 'connecting');
|
|
this.updateSessionUser();
|
|
this.showLoginOverlay(this.t('connection.loggedOutOverlay'));
|
|
}
|
|
|
|
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(this.t('login.invalidResponse'));
|
|
|
|
this.persistSession({
|
|
token: data.token,
|
|
expiresAt: data.expiresAt,
|
|
username: this.username
|
|
});
|
|
this.updateSessionUser();
|
|
} catch (error) {
|
|
console.warn('Token refresh failed', error);
|
|
this.clearSession();
|
|
this.updateConnectionStatus(this.t('connection.authenticationRequired'), 'error');
|
|
if (this.socket && this.socket.readyState === WebSocket.OPEN)
|
|
this.socket.close();
|
|
this.showLoginOverlay(this.t('connection.sessionExpired'));
|
|
} finally {
|
|
this.refreshInFlight = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
window.app = new DashboardApp();
|