app.js 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. const dataNode = document.getElementById('initial-status');
  2. if (dataNode) {
  3. let currentStatus = JSON.parse(dataNode.textContent || '{}');
  4. let activeMachine = 'all';
  5. const gridNode = document.getElementById('machine-grid');
  6. const filterNode = document.getElementById('machine-filter');
  7. const alertNode = document.getElementById('alert-list');
  8. const generatedAtNode = document.getElementById('generated-at');
  9. const escapeHtml = (value) =>
  10. String(value)
  11. .replaceAll('&', '&')
  12. .replaceAll('<', '&lt;')
  13. .replaceAll('>', '&gt;')
  14. .replaceAll('"', '&quot;')
  15. .replaceAll("'", '&#039;');
  16. const formatTime = (isoValue) => {
  17. if (!isoValue) {
  18. return 'Noch keine Messung';
  19. }
  20. const parsed = new Date(isoValue);
  21. if (Number.isNaN(parsed.getTime())) {
  22. return isoValue;
  23. }
  24. return parsed.toLocaleString('de-DE', {
  25. day: '2-digit',
  26. month: '2-digit',
  27. year: 'numeric',
  28. hour: '2-digit',
  29. minute: '2-digit',
  30. });
  31. };
  32. const renderFilters = () => {
  33. const machines = currentStatus.machines || [];
  34. const buttons = [
  35. `<button class="chip ${activeMachine === 'all' ? 'chip--active' : ''}" data-machine="all">Alle</button>`,
  36. ...machines.map(
  37. (machine) =>
  38. `<button class="chip ${activeMachine === machine.id ? 'chip--active' : ''}" data-machine="${escapeHtml(
  39. machine.id
  40. )}">${escapeHtml(machine.name)}</button>`
  41. ),
  42. ];
  43. filterNode.innerHTML = buttons.join('');
  44. };
  45. const slotCard = (slot) => {
  46. const fillPercent = slot.fill_percent ?? 0;
  47. const units = slot.units_estimated ?? '–';
  48. const maxUnits = slot.max_units ?? '–';
  49. const state = slot.state || 'unknown';
  50. const stateLabel =
  51. state === 'critical' ? 'Kritisch' : state === 'ok' ? 'Stabil' : 'Unbekannt';
  52. return `
  53. <article class="slot-card slot-card--${escapeHtml(state)}">
  54. <div class="slot-card__head">
  55. <div>
  56. <p class="slot-card__label">${escapeHtml(slot.slot_label || slot.sensor_id)}</p>
  57. <h3>${escapeHtml(slot.product_name || 'Nicht zugeordnet')}</h3>
  58. </div>
  59. <span class="status-pill status-pill--${escapeHtml(state)}">${stateLabel}</span>
  60. </div>
  61. <div class="slot-card__body">
  62. <div class="fill-tube" style="--fill:${fillPercent}%">
  63. <div class="fill-tube__liquid" style="height:${fillPercent}%"></div>
  64. <div class="fill-tube__gloss"></div>
  65. </div>
  66. <div class="slot-card__metrics">
  67. <p><strong>${fillPercent}%</strong> Fuellstand</p>
  68. <p><strong>${units}</strong> / ${maxUnits} Flaschen</p>
  69. <p>Alarm unter <strong>${slot.alert_below_units ?? 0}</strong></p>
  70. <p>Messwert: <strong>${slot.distance_mm ?? '–'} mm</strong></p>
  71. <p>Update: <strong>${formatTime(slot.measured_at)}</strong></p>
  72. </div>
  73. </div>
  74. </article>
  75. `;
  76. };
  77. const renderMachines = () => {
  78. const machines = (currentStatus.machines || []).filter(
  79. (machine) => activeMachine === 'all' || machine.id === activeMachine
  80. );
  81. if (!machines.length) {
  82. gridNode.innerHTML = '<p class="empty-state">Keine Automaten fuer die aktuelle Auswahl gefunden.</p>';
  83. return;
  84. }
  85. gridNode.innerHTML = machines
  86. .map(
  87. (machine) => `
  88. <section class="machine-panel">
  89. <div class="machine-panel__head">
  90. <div>
  91. <p class="eyebrow">Automat</p>
  92. <h2>${escapeHtml(machine.name)}</h2>
  93. </div>
  94. <p>${escapeHtml(machine.location || 'Kein Standort hinterlegt')}</p>
  95. </div>
  96. <div class="slot-grid">
  97. ${(machine.slots || []).map(slotCard).join('')}
  98. </div>
  99. </section>
  100. `
  101. )
  102. .join('');
  103. };
  104. const renderAlerts = () => {
  105. const alerts = currentStatus.alerts || [];
  106. if (!alerts.length) {
  107. alertNode.innerHTML =
  108. '<p class="empty-state">Noch keine Zustandswechsel registriert.</p>';
  109. return;
  110. }
  111. alertNode.innerHTML = alerts
  112. .slice(0, 12)
  113. .map((entry) => {
  114. const payload = entry.payload || {};
  115. const stateClass = payload.event === 'critical' ? 'critical' : 'ok';
  116. const stateText = payload.event === 'critical' ? 'Alarm' : 'Entwarnung';
  117. return `
  118. <article class="alert-entry alert-entry--${escapeHtml(stateClass)}">
  119. <div>
  120. <p class="alert-entry__title">${stateText}: ${escapeHtml(
  121. payload.machine_name || payload.machine_id || 'Automat'
  122. )} / ${escapeHtml(payload.slot_label || payload.sensor_id || 'Fach')}</p>
  123. <p>${escapeHtml(payload.product_name || 'Ohne Produktname')} • Bestand ${
  124. payload.units_estimated ?? '–'
  125. } / ${payload.max_units ?? '–'} • ${payload.fill_percent ?? '–'}%</p>
  126. </div>
  127. <time>${formatTime(entry.created_at)}</time>
  128. </article>
  129. `;
  130. })
  131. .join('');
  132. };
  133. const updateSummary = () => {
  134. const summary = currentStatus.summary || {};
  135. Object.entries(summary).forEach(([key, value]) => {
  136. const node = document.querySelector(`[data-summary="${key}"]`);
  137. if (node) {
  138. node.textContent = value;
  139. }
  140. });
  141. if (generatedAtNode) {
  142. generatedAtNode.textContent = formatTime(currentStatus.generated_at);
  143. }
  144. };
  145. const render = () => {
  146. renderFilters();
  147. renderMachines();
  148. renderAlerts();
  149. updateSummary();
  150. };
  151. filterNode.addEventListener('click', (event) => {
  152. const target = event.target.closest('[data-machine]');
  153. if (!target) {
  154. return;
  155. }
  156. activeMachine = target.dataset.machine || 'all';
  157. render();
  158. });
  159. const refresh = async () => {
  160. try {
  161. const response = await fetch('/api/v1/status.php', { cache: 'no-store' });
  162. if (!response.ok) {
  163. return;
  164. }
  165. currentStatus = await response.json();
  166. render();
  167. } catch (error) {
  168. console.error('Status-Aktualisierung fehlgeschlagen', error);
  169. }
  170. };
  171. render();
  172. const refreshSeconds = currentStatus.app?.dashboard_refresh_seconds || 15;
  173. window.setInterval(refresh, refreshSeconds * 1000);
  174. }