Files
PC-Monitor/static/js/dashboard.js
2026-03-24 07:17:48 +01:00

1157 lines
39 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Configuration et état
let config = null;
let charts = new Map(); // Map des graphiques Chart.js
let updateInterval = null;
let currentTabIndex = 0;
let totalTabs = 0;
let consecutiveErrors = 0;
const MAX_CONSECUTIVE_ERRORS = 3;
// Variables pour les apps
let appsRendered = false;
let lastAppsTabId = null;
// Variables pour le swipe
let touchStartX = 0;
let touchEndX = 0;
let touchStartY = 0;
let touchEndY = 0;
const SWIPE_THRESHOLD = 50; // Minimum de pixels pour déclencher un swipe
// Initialisation
document.addEventListener('DOMContentLoaded', async () => {
await loadConfig();
await loadData();
// Initialiser le swipe
initSwipeGestures();
// Démarrer les mises à jour automatiques (toutes les 2 secondes)
updateInterval = setInterval(loadData, 2000);
});
// === Chargement configuration ===
async function loadConfig() {
try {
const response = await fetch('/api/config');
config = await response.json();
// Appliquer le thème
document.documentElement.setAttribute('data-theme', config.theme || 'dark');
// Réinitialiser le flag de rendu des apps (la config peut avoir changé)
appsRendered = false;
// Les apps seront rendues dans loadData() après création des panes
} catch (error) {
console.error('Erreur chargement config:', error);
showError('Impossible de charger la configuration');
}
}
// === Chargement données ===
async function loadData() {
try {
const response = await fetch('/api/lhm/data');
const data = await response.json();
if (data.error) {
consecutiveErrors++;
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
showError(data.error);
}
return;
}
// Réinitialiser le compteur d'erreurs en cas de succès
consecutiveErrors = 0;
// Cacher les états d'erreur/vide
document.getElementById('error-state').style.display = 'none';
// Vérifier s'il y a des capteurs OU des apps configurés
const hasSensors = data.tabs.some(tab => tab.sensors.length > 0);
const hasApps = config && config.apps && config.apps.length > 0;
if (!hasSensors && !hasApps) {
document.getElementById('empty-state').style.display = 'block';
document.getElementById('tabs-nav').style.display = 'none';
document.getElementById('tabs-content').style.display = 'none';
return;
}
document.getElementById('empty-state').style.display = 'none';
// Render les onglets et données
renderTabs(data.tabs);
renderSensors(data.tabs);
// Render les apps (dans le bon onglet ou section séparée)
try {
renderApps();
} catch (appError) {
console.error('Erreur rendu apps:', appError);
}
// Mettre à jour le timestamp
updateTimestamp();
} catch (error) {
console.error('Erreur chargement données:', error);
consecutiveErrors++;
// N'afficher l'erreur que si plusieurs échecs consécutifs
// et qu'il n'y a pas déjà du contenu affiché
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
const tabsContent = document.getElementById('tabs-content');
if (!tabsContent || tabsContent.children.length === 0) {
showError('Erreur de connexion à LibreHardwareMonitor');
}
}
}
}
// === Render onglets ===
function renderTabs(tabs) {
const tabsNav = document.getElementById('tabs-nav');
// Mettre à jour le nombre total d'onglets
totalTabs = tabs.length;
// Si un seul onglet, on ne l'affiche pas mais on crée quand même le swipe indicator
if (tabs.length === 1) {
tabsNav.style.display = 'none';
removeSwipeIndicators();
return;
}
tabsNav.style.display = 'flex';
// Ne re-render que si nécessaire
if (tabsNav.dataset.rendered !== 'true') {
tabsNav.innerHTML = '';
tabs.forEach((tab, index) => {
const button = document.createElement('button');
button.className = 'tab-button' + (index === 0 ? ' active' : '');
button.textContent = tab.name;
button.dataset.tabId = tab.id;
button.dataset.tabIndex = index;
button.addEventListener('click', () => switchTab(tab.id));
tabsNav.appendChild(button);
});
tabsNav.dataset.rendered = 'true';
// Créer les indicateurs de swipe
createSwipeIndicators(tabs.length);
}
}
function createSwipeIndicators(count) {
// Supprimer les anciens indicateurs
removeSwipeIndicators();
if (count <= 1) return;
const container = document.createElement('div');
container.className = 'swipe-indicators';
container.id = 'swipe-indicators';
for (let i = 0; i < count; i++) {
const dot = document.createElement('span');
dot.className = 'swipe-dot' + (i === 0 ? ' active' : '');
dot.dataset.index = i;
dot.addEventListener('click', () => switchTabByIndex(i));
container.appendChild(dot);
}
// Ajouter après les tabs-nav
const tabsNav = document.getElementById('tabs-nav');
tabsNav.parentNode.insertBefore(container, tabsNav.nextSibling);
}
function removeSwipeIndicators() {
const existing = document.getElementById('swipe-indicators');
if (existing) {
existing.remove();
}
}
function switchTab(tabId) {
// Désactiver tous les tabs
document.querySelectorAll('.tab-button').forEach(btn => {
btn.classList.remove('active');
});
// Activer le bon
const activeBtn = document.querySelector(`.tab-button[data-tab-id="${tabId}"]`);
if (activeBtn) {
activeBtn.classList.add('active');
// Mettre à jour l'index courant
currentTabIndex = parseInt(activeBtn.dataset.tabIndex) || 0;
}
// Basculer les panes
document.querySelectorAll('.tab-pane').forEach(pane => {
pane.classList.remove('active');
});
const targetPane = document.getElementById(`pane-${tabId}`);
if (targetPane) {
targetPane.classList.add('active');
}
// Mettre à jour les indicateurs
updateSwipeIndicators();
}
function switchTabByIndex(index) {
const buttons = document.querySelectorAll('.tab-button');
if (index >= 0 && index < buttons.length) {
const tabId = buttons[index].dataset.tabId;
switchTab(tabId);
}
}
function updateSwipeIndicators() {
const indicators = document.querySelectorAll('.swipe-dot');
indicators.forEach((dot, index) => {
dot.classList.toggle('active', index === currentTabIndex);
});
}
// === Render capteurs ===
function renderSensors(tabs) {
const tabsContent = document.getElementById('tabs-content');
tabsContent.style.display = 'block';
// Créer les panes si nécessaire
tabs.forEach((tab, index) => {
let pane = document.getElementById(`pane-${tab.id}`);
if (!pane) {
pane = document.createElement('div');
pane.id = `pane-${tab.id}`;
pane.className = 'tab-pane' + (index === 0 ? ' active' : '');
tabsContent.appendChild(pane);
}
// Créer le grid de capteurs
let grid = pane.querySelector('.sensors-grid');
if (!grid) {
grid = document.createElement('div');
grid.className = 'sensors-grid';
pane.appendChild(grid);
}
// Render chaque capteur
tab.sensors.forEach(sensor => {
console.log('--- Render sensor:', sensor.name, 'show_graph:', sensor.show_graph, 'viz_type:', sensor.viz_type);
let card = grid.querySelector(`[data-sensor-id="${CSS.escape(sensor.id)}"]`);
// Déterminer le viz_type à utiliser
const vizType = sensor.show_graph ? (sensor.viz_type || 'line') : 'none';
// Vérifier si on doit recréer la carte
let needsRecreation = false;
if (card) {
const currentVizType = card.dataset.vizType || 'none';
const currentShowTypeBadge = card.dataset.showTypeBadge !== 'false';
const newShowTypeBadge = sensor.show_type_badge !== false;
const currentFontFamily = card.dataset.fontFamily || 'system';
const newFontFamily = sensor.font_family || 'system';
const currentFontBold = card.dataset.fontBold !== 'false';
const newFontBold = sensor.font_bold !== false;
// Recréer si le type de visualisation a changé
if (currentVizType !== vizType) {
console.log(`⚠️ Changement de visualisation: ${currentVizType} → ${vizType}, recréation carte`);
needsRecreation = true;
// Supprimer l'ancien graphique du Map si existant
if (charts.has(sensor.id)) {
const oldChart = charts.get(sensor.id);
// Détruire proprement si c'est un Chart.js
if (oldChart && typeof oldChart.destroy === 'function') {
oldChart.destroy();
}
charts.delete(sensor.id);
}
}
// Recréer si l'option badge a changé
else if (currentShowTypeBadge !== newShowTypeBadge) {
console.log(`⚠️ Changement badge type: ${currentShowTypeBadge} → ${newShowTypeBadge}, recréation carte`);
needsRecreation = true;
}
// Recréer si la police a changé
else if (currentFontFamily !== newFontFamily) {
console.log(`⚠️ Changement police: ${currentFontFamily} → ${newFontFamily}, recréation carte`);
needsRecreation = true;
}
// Recréer si le bold a changé
else if (currentFontBold !== newFontBold) {
console.log(`⚠️ Changement bold: ${currentFontBold} → ${newFontBold}, recréation carte`);
needsRecreation = true;
}
// Vérifier aussi si le canvas manque
else if (sensor.show_graph) {
if (vizType === 'gauge' && !card.querySelector('.sensor-gauge')) {
console.log('⚠️ Jauge manquante, recréation carte');
needsRecreation = true;
} else if (vizType === 'line' && !card.querySelector('.sensor-chart')) {
console.log('⚠️ Graphique manquant, recréation carte');
needsRecreation = true;
}
}
}
if (!card || needsRecreation) {
console.log(needsRecreation ? 'Recréation carte...' : 'Carte n\'existe pas, création...');
// Supprimer l'ancienne si elle existe
if (card) {
card.remove();
}
// Créer un objet sensor modifié pour l'affichage
const sensorForDisplay = {
...sensor,
viz_type: sensor.show_graph ? (sensor.viz_type || 'line') : 'none'
};
// Créer la carte
card = createSensorCard(sensorForDisplay);
grid.appendChild(card);
// Créer la visualisation si nécessaire
if (sensor.show_graph) {
console.log('show_graph = true, création visualisation...');
if (vizType === 'gauge') {
console.log('→ Appel createGauge');
createGauge(sensorForDisplay);
} else {
console.log('→ Appel createChart');
createChart(sensorForDisplay);
}
} else {
console.log('show_graph = false, pas de visualisation');
}
} else {
// Mettre à jour la valeur
updateSensorCard(card, sensor);
}
// Mettre à jour la visualisation
if (sensor.show_graph && charts.has(sensor.id)) {
if (vizType === 'gauge') {
updateGauge(sensor);
} else {
updateChart(sensor);
}
}
});
});
}
function createSensorCard(sensor) {
console.log('=== createSensorCard ===');
console.log('Sensor:', sensor);
console.log('show_graph:', sensor.show_graph);
console.log('viz_type:', sensor.viz_type);
const card = document.createElement('div');
card.className = 'sensor-card';
// Ajouter la classe de taille
const cardSize = sensor.card_size || 'medium';
card.classList.add(`card-${cardSize}`);
// Ajouter la classe de police
const fontFamily = sensor.font_family || 'system';
card.classList.add(`font-family-${fontFamily}`);
// Ajouter la classe bold/normal
const fontBold = sensor.font_bold !== false; // true par défaut
card.classList.add(fontBold ? 'font-bold' : 'font-normal');
// Ajouter la classe de taille de police
const fontSize = sensor.font_size || 'small';
card.classList.add(`font-${fontSize}`);
// Ajouter la classe pour masquer la valeur sur mobile si activé
if (sensor.hide_value_mobile) {
card.classList.add('hide-value-mobile');
}
card.dataset.sensorId = sensor.id;
card.dataset.vizType = sensor.show_graph ? (sensor.viz_type || 'line') : 'none';
card.dataset.showTypeBadge = sensor.show_type_badge !== false ? 'true' : 'false';
card.dataset.fontFamily = fontFamily;
card.dataset.fontBold = fontBold ? 'true' : 'false';
const valueClass = getSensorValueClass(sensor);
// Déterminer quel type de visualisation afficher
let vizHtml = '';
if (sensor.show_graph && sensor.viz_type === 'gauge') {
console.log('→ Création HTML jauge DIRECTE');
const gaugeSize = (sensor.gauge_options && sensor.gauge_options.size) || 'medium';
// Définir les dimensions selon la taille
const gaugeSizes = {
tiny: 60,
xs: 90,
small: 120,
medium: 180,
large: 240
};
const size = gaugeSizes[gaugeSize] || 180;
// Canvas DIRECT avec dimensions explicites
vizHtml = `<canvas class="sensor-gauge gauge-${gaugeSize}" width="${size}" height="${size}"></canvas>`;
} else if (sensor.show_graph && (sensor.viz_type === 'line' || !sensor.viz_type)) {
console.log('→ Création HTML courbe DIRECTE');
const chartHeight = (sensor.chart_options && sensor.chart_options.height) || 'medium';
// Définir les hauteurs selon la taille
const chartHeights = {
tiny: 50,
xs: 70,
small: 90,
medium: 120,
large: 180
};
const height = chartHeights[chartHeight] || 120;
// Canvas DIRECT avec dimensions explicites (width sera 100%)
vizHtml = `<canvas class="sensor-chart chart-${chartHeight}" height="${height}"></canvas>`;
} else {
console.log('→ Pas de visualisation');
}
// Badge de type (affiché par défaut, peut être masqué)
const showTypeBadge = sensor.show_type_badge !== false;
const typeBadgeHtml = showTypeBadge ? `<div class="sensor-type-badge">${sensor.type}</div>` : '';
card.innerHTML = `
<div class="sensor-header">
<div class="sensor-name">${sensor.name}</div>
${typeBadgeHtml}
</div>
<div class="sensor-value ${valueClass}" data-value>${sensor.value}</div>
${vizHtml}
`;
console.log('HTML carte créée avec canvas DIRECT');
return card;
}
function updateSensorCard(card, sensor) {
const valueDiv = card.querySelector('[data-value]');
valueDiv.textContent = sensor.value;
valueDiv.className = 'sensor-value ' + getSensorValueClass(sensor);
}
function getSensorValueClass(sensor) {
let classes = [sensor.type];
// Ajouter des classes selon la valeur
if (sensor.type === 'temperature') {
const temp = parseFloat(sensor.value);
if (temp < 40) classes.push('cold');
else if (temp > 70) classes.push('hot');
} else if (sensor.type === 'percentage') {
const percent = parseFloat(sensor.value);
if (percent > 90) classes.push('critical');
else if (percent > 70) classes.push('high');
}
return classes.join(' ');
}
// === Graphiques Chart.js ===
function createChart(sensor) {
console.log('=== createChart appelé ===');
console.log('Sensor:', sensor);
const sensorId = sensor.id;
const card = document.querySelector(`[data-sensor-id="${CSS.escape(sensorId)}"]`);
console.log('Card trouvée:', card);
if (!card) {
console.error('ERREUR: Card non trouvée pour', sensorId);
return;
}
const canvas = card.querySelector('.sensor-chart');
console.log('Canvas trouvé:', canvas);
if (!canvas) {
console.error('ERREUR: Canvas .sensor-chart non trouvé dans la carte');
return;
}
const ctx = canvas.getContext('2d');
console.log('Context 2D:', ctx);
// Obtenir le type de capteur
const sensorType = sensor.type || 'generic';
// Couleurs selon le type
let borderColor, backgroundColor;
switch(sensorType) {
case 'temperature':
borderColor = 'rgba(255, 152, 0, 1)'; // Orange
backgroundColor = 'rgba(255, 152, 0, 0.1)';
break;
case 'percentage':
case 'load':
borderColor = 'rgba(74, 158, 255, 1)'; // Bleu
backgroundColor = 'rgba(74, 158, 255, 0.1)';
break;
case 'fan':
borderColor = 'rgba(76, 175, 80, 1)'; // Vert
backgroundColor = 'rgba(76, 175, 80, 0.1)';
break;
case 'power':
borderColor = 'rgba(244, 67, 54, 1)'; // Rouge
backgroundColor = 'rgba(244, 67, 54, 0.1)';
break;
case 'voltage':
borderColor = 'rgba(156, 39, 176, 1)'; // Violet
backgroundColor = 'rgba(156, 39, 176, 0.1)';
break;
case 'frequency':
case 'clock':
borderColor = 'rgba(255, 193, 7, 1)'; // Jaune
backgroundColor = 'rgba(255, 193, 7, 0.1)';
break;
default:
borderColor = 'rgba(74, 158, 255, 1)';
backgroundColor = 'rgba(74, 158, 255, 0.1)';
}
console.log('Création Chart.js...');
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [{
data: [],
borderColor: borderColor,
backgroundColor: backgroundColor,
borderWidth: 2,
tension: 0.4,
pointRadius: 0,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { enabled: false }
},
scales: {
x: { display: false },
y: {
display: true,
grid: {
color: 'rgba(255, 255, 255, 0.05)',
drawBorder: false
},
ticks: {
color: 'rgba(255, 255, 255, 0.3)',
font: { size: 10 }
}
}
},
animation: { duration: 0 }
}
});
console.log('Chart créé:', chart);
charts.set(sensorId, chart);
console.log('Chart stocké dans Map');
// Charger l'historique initial
console.log('Chargement historique initial...');
updateChart(sensor);
}
async function updateChart(sensor) {
console.log('=== updateChart appelé ===', sensor.name);
const sensorId = sensor.id;
const chart = charts.get(sensorId);
if (!chart) {
console.error('ERREUR: Chart non trouvé dans Map pour', sensorId);
return;
}
// Vérifier si le chart est dans un onglet actif
const canvas = chart.canvas;
if (canvas) {
const pane = canvas.closest('.tab-pane');
if (pane && !pane.classList.contains('active')) {
// Le chart est dans un onglet inactif, ne pas mettre à jour
return;
}
}
try {
console.log('Fetch historique pour', sensorId);
const response = await fetch(`/api/lhm/history/${encodeURIComponent(sensorId)}?count=60`);
const data = await response.json();
console.log('Historique reçu:', data);
if (data.values && data.values.length > 0) {
console.log('→ Mise à jour chart avec', data.values.length, 'valeurs');
chart.data.labels = data.values.map((_, i) => i);
chart.data.datasets[0].data = data.values;
chart.update('none');
console.log('→ Chart mis à jour');
} else {
console.warn('→ Pas de valeurs dans l\'historique');
}
} catch (error) {
console.error('Erreur mise à jour graphique:', sensorId, error);
}
}
// === Jauges circulaires ===
function createGauge(sensor) {
console.log('=== createGauge appelé ===');
console.log('Sensor:', sensor);
const sensorId = sensor.id;
const card = document.querySelector(`[data-sensor-id="${CSS.escape(sensorId)}"]`);
console.log('Card trouvée:', card);
if (!card) {
console.error('ERREUR: Card non trouvée pour', sensorId);
return;
}
const canvas = card.querySelector('.sensor-gauge');
console.log('Canvas trouvé:', canvas);
if (!canvas) {
console.error('ERREUR: Canvas .sensor-gauge non trouvé dans la carte');
console.log('HTML de la card:', card.innerHTML);
return;
}
// Stocker les infos de la jauge
charts.set(sensorId, {
type: 'gauge',
canvas: canvas,
sensor: sensor
});
console.log('Jauge stockée dans Map, dessin initial...');
// Dessiner la jauge initiale
updateGauge(sensor);
}
function updateGauge(sensor) {
console.log('=== updateGauge appelé ===', sensor.name);
const sensorId = sensor.id;
const gaugeInfo = charts.get(sensorId);
if (!gaugeInfo || gaugeInfo.type !== 'gauge') {
console.error('ERREUR: gaugeInfo non trouvé ou pas de type gauge');
return;
}
const canvas = gaugeInfo.canvas;
if (!canvas) {
console.error('ERREUR: canvas non trouvé');
return;
}
// Vérifier si le canvas est visible (dans un onglet actif)
const pane = canvas.closest('.tab-pane');
if (pane && !pane.classList.contains('active')) {
// Le canvas est dans un onglet inactif, ne pas dessiner
return;
}
const ctx = canvas.getContext('2d');
console.log('Canvas:', canvas, 'offsetWidth:', canvas.offsetWidth);
// Définir la taille - protection contre taille nulle ou trop petite
const size = canvas.offsetWidth || canvas.width || 60;
if (size < 20) {
console.warn('Canvas trop petit, skip updateGauge');
return;
}
canvas.width = size;
canvas.height = size;
console.log('Taille canvas:', size);
// Extraire la valeur numérique
const valueStr = sensor.value.replace(/[^\d.,]/g, '').replace(',', '.');
let value = parseFloat(valueStr);
if (isNaN(value)) value = 0;
console.log('Valeur extraite:', value);
// Déterminer min, max selon le type
let min = 0, max = 100;
const sensorType = sensor.type || 'generic';
switch(sensorType) {
case 'temperature':
min = 0;
max = 100; // 0-100°C
break;
case 'percentage':
case 'load':
min = 0;
max = 100;
break;
case 'fan':
min = 0;
max = 2000; // 0-2000 RPM
break;
case 'power':
min = 0;
max = 200; // 0-200W
break;
case 'voltage':
min = 0;
max = 15; // 0-15V
break;
case 'frequency':
case 'clock':
min = 0;
max = 6000; // 0-6000 MHz
break;
default:
min = 0;
max = 100;
}
// Calculer le pourcentage
const percentage = Math.min(Math.max((value - min) / (max - min), 0), 1);
// Couleur selon le type et la valeur
let color;
if (sensorType === 'temperature') {
if (value < 40) color = '#4a9eff'; // Bleu (froid)
else if (value < 70) color = '#ff9800'; // Orange (tiède)
else color = '#f44336'; // Rouge (chaud)
} else if (sensorType === 'percentage' || sensorType === 'load') {
if (value < 50) color = '#4caf50'; // Vert
else if (value < 80) color = '#ff9800'; // Orange
else color = '#f44336'; // Rouge
} else if (sensorType === 'fan') {
color = '#4caf50'; // Vert
} else if (sensorType === 'power') {
color = '#f44336'; // Rouge
} else if (sensorType === 'voltage') {
color = '#9c27b0'; // Violet
} else if (sensorType === 'frequency' || sensorType === 'clock') {
color = '#ffc107'; // Jaune
} else {
color = '#4a9eff'; // Bleu par défaut
}
// Effacer le canvas
ctx.clearRect(0, 0, size, size);
// Centre et rayon
const centerX = size / 2;
const centerY = size / 2;
const radius = Math.max(size / 2 - 10, 5); // Minimum 5px de rayon
const lineWidth = Math.max(size / 10, 2); // Minimum 2px d'épaisseur
// Fond de la jauge (gris)
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0.75 * Math.PI, 2.25 * Math.PI);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
ctx.lineWidth = lineWidth;
ctx.lineCap = 'round';
ctx.stroke();
// Arc de la valeur
const startAngle = 0.75 * Math.PI;
const endAngle = startAngle + (1.5 * Math.PI * percentage);
ctx.beginPath();
ctx.arc(centerX, centerY, radius, startAngle, endAngle);
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.lineCap = 'round';
ctx.stroke();
// Récupérer les options personnalisées
const gaugeOptions = sensor.gauge_options || {};
const showValue = gaugeOptions.show_value !== false; // true par défaut
// Texte de la valeur au centre (si activé)
if (showValue) {
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
ctx.font = `bold ${size / 4}px -apple-system, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(sensor.value, centerX, centerY);
}
}
// === Applications ===
function renderApps() {
const appsSection = document.getElementById('apps-section');
if (!config || !config.apps || config.apps.length === 0) {
if (appsSection) appsSection.style.display = 'none';
cleanAppsFromTabs();
appsRendered = false;
return;
}
const appsTabId = config.apps_tab || '';
// Si les apps ont déjà été rendues dans le même onglet, ne pas re-rendre
if (appsRendered && lastAppsTabId === appsTabId) {
return;
}
// Créer le HTML des boutons d'apps
const appsHtml = config.apps.map((app, index) => `
<button class="app-button" data-app-index="${index}">
<div class="app-icon">${app.icon || '🚀'}</div>
<div class="app-name">${app.name}</div>
</button>
`).join('');
// Nettoyer d'abord les apps existantes dans les onglets
cleanAppsFromTabs();
// Si un onglet est spécifié, mettre les apps dans cet onglet
if (appsTabId) {
// Cacher la section séparée
if (appsSection) appsSection.style.display = 'none';
// Trouver le pane cible
const targetPane = document.getElementById(`pane-${appsTabId}`);
if (targetPane) {
// Créer la nouvelle grille d'apps
const appsContainer = document.createElement('div');
appsContainer.className = 'apps-section-in-tab';
appsContainer.innerHTML = `
<h3 class="apps-title-in-tab">🚀 Applications</h3>
<div class="apps-grid apps-grid-in-tab">${appsHtml}</div>
`;
// Ajouter à la fin du pane
targetPane.appendChild(appsContainer);
// Ajouter les event listeners
appsContainer.querySelectorAll('.app-button').forEach(btn => {
btn.addEventListener('click', () => launchApp(parseInt(btn.dataset.appIndex)));
});
appsRendered = true;
lastAppsTabId = appsTabId;
} else {
// L'onglet cible n'existe pas encore, afficher dans la section séparée
console.warn(`Onglet apps "${appsTabId}" non trouvé, affichage en section séparée`);
renderAppsInSection(appsHtml);
appsRendered = true;
lastAppsTabId = '';
}
} else {
// Afficher dans la section séparée (comportement par défaut)
renderAppsInSection(appsHtml);
appsRendered = true;
lastAppsTabId = '';
}
}
function renderAppsInSection(appsHtml) {
const appsSection = document.getElementById('apps-section');
const appsGrid = document.getElementById('apps-grid');
if (appsSection && appsGrid) {
appsSection.style.display = 'block';
appsGrid.innerHTML = appsHtml;
// Ajouter les event listeners
appsGrid.querySelectorAll('.app-button').forEach(btn => {
btn.addEventListener('click', () => launchApp(parseInt(btn.dataset.appIndex)));
});
}
}
// Nettoyer les apps des onglets (appelé lors du re-render)
function cleanAppsFromTabs() {
document.querySelectorAll('.apps-section-in-tab').forEach(el => el.remove());
}
async function launchApp(appId) {
try {
const response = await fetch(`/api/apps/launch/${appId}`, {
method: 'POST'
});
const result = await response.json();
if (!result.success) {
alert('Erreur: ' + result.error);
}
} catch (error) {
console.error('Erreur lancement app:', error);
alert('Erreur de lancement');
}
}
// === Utilitaires ===
function updateTimestamp() {
const now = new Date();
const timeStr = now.toLocaleTimeString('fr-FR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
document.getElementById('last-update').textContent = `🔄 ${timeStr}`;
}
function showError(message) {
const errorState = document.getElementById('error-state');
const errorMessage = document.getElementById('error-message');
errorMessage.textContent = message;
errorState.style.display = 'block';
document.getElementById('empty-state').style.display = 'none';
document.getElementById('tabs-nav').style.display = 'none';
document.getElementById('tabs-content').style.display = 'none';
}
// Nettoyage à la fermeture
window.addEventListener('beforeunload', () => {
if (updateInterval) {
clearInterval(updateInterval);
}
charts.forEach((chart, key) => {
// Chart.js a une méthode destroy, les jauges n'en ont pas
if (chart && typeof chart.destroy === 'function') {
chart.destroy();
}
});
});
// === Swipe Gestures ===
function initSwipeGestures() {
const tabsContent = document.getElementById('tabs-content');
if (!tabsContent) return;
// Touch events
tabsContent.addEventListener('touchstart', handleTouchStart, { passive: true });
tabsContent.addEventListener('touchend', handleTouchEnd, { passive: true });
// Optionnel: support souris pour test sur desktop
tabsContent.addEventListener('mousedown', handleMouseDown);
tabsContent.addEventListener('mouseup', handleMouseUp);
}
function handleTouchStart(e) {
touchStartX = e.changedTouches[0].screenX;
touchStartY = e.changedTouches[0].screenY;
}
function handleTouchEnd(e) {
touchEndX = e.changedTouches[0].screenX;
touchEndY = e.changedTouches[0].screenY;
handleSwipe();
}
function handleMouseDown(e) {
touchStartX = e.screenX;
touchStartY = e.screenY;
}
function handleMouseUp(e) {
touchEndX = e.screenX;
touchEndY = e.screenY;
handleSwipe();
}
function handleSwipe() {
const deltaX = touchEndX - touchStartX;
const deltaY = touchEndY - touchStartY;
// Vérifier que c'est bien un swipe horizontal (pas vertical)
if (Math.abs(deltaX) < SWIPE_THRESHOLD) return;
if (Math.abs(deltaY) > Math.abs(deltaX)) return; // Scroll vertical, ignorer
if (deltaX > 0) {
// Swipe vers la droite → onglet précédent
navigateToPreviousTab();
} else {
// Swipe vers la gauche → onglet suivant
navigateToNextTab();
}
}
function navigateToNextTab() {
if (totalTabs <= 1) return;
const nextIndex = (currentTabIndex + 1) % totalTabs;
switchTabByIndex(nextIndex);
// Feedback visuel
showSwipeFeedback('next');
}
function navigateToPreviousTab() {
if (totalTabs <= 1) return;
const prevIndex = (currentTabIndex - 1 + totalTabs) % totalTabs;
switchTabByIndex(prevIndex);
// Feedback visuel
showSwipeFeedback('prev');
}
function showSwipeFeedback(direction) {
const content = document.getElementById('tabs-content');
content.classList.add('swipe-' + direction);
setTimeout(() => {
content.classList.remove('swipe-' + direction);
}, 200);
}
// ==================== Plexamp Widget ====================
let plexampEnabled = false;
let plexampInterval = null;
async function initPlexamp() {
try {
const response = await fetch('/api/plexamp/config');
const config = await response.json();
plexampEnabled = config.enabled && config.has_token;
const widget = document.getElementById('plexamp-widget');
if (plexampEnabled && widget) {
widget.style.display = 'flex';
setupPlexampControls();
updatePlexampStatus();
// Polling toutes les 2 secondes
plexampInterval = setInterval(updatePlexampStatus, 2000);
}
} catch (error) {
console.error('Erreur init Plexamp:', error);
}
}
async function updatePlexampStatus() {
try {
const response = await fetch('/api/plexamp/status');
const data = await response.json();
const widget = document.getElementById('plexamp-widget');
const cover = document.getElementById('plexamp-cover');
const noMusic = document.getElementById('plexamp-no-music');
const title = document.getElementById('plexamp-title');
const artist = document.getElementById('plexamp-artist');
const album = document.getElementById('plexamp-album');
const progressFill = document.getElementById('plexamp-progress-fill');
const timeCurrent = document.getElementById('plexamp-time-current');
const timeTotal = document.getElementById('plexamp-time-total');
const playPauseBtn = document.getElementById('plexamp-playpause');
if (!data.enabled || data.state === 'stopped' || !data.title) {
// Pas de lecture
cover.style.display = 'none';
noMusic.style.display = 'flex';
title.textContent = '-';
artist.textContent = '-';
album.textContent = '-';
progressFill.style.width = '0%';
timeCurrent.textContent = '0:00';
timeTotal.textContent = '0:00';
playPauseBtn.textContent = '▶️';
return;
}
// Afficher les infos
cover.style.display = 'block';
noMusic.style.display = 'none';
if (data.artwork_url) {
cover.src = data.artwork_url;
}
title.textContent = data.title || '-';
artist.textContent = data.artist || '-';
album.textContent = data.album || '-';
// Progression
progressFill.style.width = `${data.progress || 0}%`;
timeCurrent.textContent = formatTime(data.time || 0);
timeTotal.textContent = formatTime(data.duration || 0);
// Bouton play/pause
playPauseBtn.textContent = data.playing ? '⏸️' : '▶️';
} catch (error) {
console.error('Erreur update Plexamp:', error);
}
}
function formatTime(ms) {
const seconds = Math.floor(ms / 1000);
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function setupPlexampControls() {
const prevBtn = document.getElementById('plexamp-prev');
const playPauseBtn = document.getElementById('plexamp-playpause');
const nextBtn = document.getElementById('plexamp-next');
if (prevBtn) {
prevBtn.addEventListener('click', () => sendPlexampCommand('prev'));
}
if (playPauseBtn) {
playPauseBtn.addEventListener('click', () => sendPlexampCommand('playpause'));
}
if (nextBtn) {
nextBtn.addEventListener('click', () => sendPlexampCommand('next'));
}
}
async function sendPlexampCommand(command) {
try {
await fetch(`/api/plexamp/${command}`, { method: 'POST' });
// Mettre à jour immédiatement
setTimeout(updatePlexampStatus, 300);
} catch (error) {
console.error(`Erreur commande Plexamp ${command}:`, error);
}
}
// Initialiser Plexamp au chargement
document.addEventListener('DOMContentLoaded', () => {
// Attendre un peu que le reste soit chargé
setTimeout(initPlexamp, 500);
});