app.js 6.3 KB

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