app.js 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  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 inputErrorHint = (slot) => {
  35. const error = slot.input_error;
  36. if (!error) {
  37. return '';
  38. }
  39. const lastGood = error.last_good || null;
  40. const lines = [
  41. `Client-Messfehler empfangen: ${formatTime(error.received_at)}`,
  42. `Gemeldeter Wert: ${error.reported_value ?? '–'}`,
  43. lastGood
  44. ? `Letzter guter Wert: ${lastGood.distance_mm ?? '–'} mm, ${
  45. lastGood.units_estimated ?? '–'
  46. } / ${lastGood.max_units ?? '–'} Flaschen, ${lastGood.fill_percent ?? '–'}%, gemessen ${formatTime(
  47. lastGood.measured_at
  48. )}`
  49. : 'Noch kein guter Messwert vorhanden.',
  50. ];
  51. const popupText = lines.map(escapeHtml).join('<br>');
  52. const label = lines.join(' ');
  53. return `
  54. <span class="input-error-hint" tabindex="0" title="${escapeHtml(label)}" aria-label="${escapeHtml(label)}">
  55. <span aria-hidden="true">❗</span>
  56. <span class="input-error-hint__popup">${popupText}</span>
  57. </span>
  58. `;
  59. };
  60. const renderFilters = () => {
  61. const machines = currentStatus.machines || [];
  62. const buttons = [
  63. `<button class="chip ${activeMachine === 'all' ? 'chip--active' : ''}" data-machine="all">Alle</button>`,
  64. ...machines.map(
  65. (machine) =>
  66. `<button class="chip ${activeMachine === machine.id ? 'chip--active' : ''}" data-machine="${escapeHtml(
  67. machine.id
  68. )}">${escapeHtml(machine.name)}</button>`
  69. ),
  70. ];
  71. filterNode.innerHTML = buttons.join('');
  72. };
  73. const slotCard = (slot) => {
  74. const fillPercent = slot.fill_percent ?? 0;
  75. const units = slot.units_estimated ?? '–';
  76. const maxUnits = slot.max_units ?? '–';
  77. const state = slot.state || 'unknown';
  78. const stateLabel =
  79. state === 'critical' ? 'Kritisch' : state === 'ok' ? 'Stabil' : 'Unbekannt';
  80. return `
  81. <article class="slot-card slot-card--${escapeHtml(state)}">
  82. <div class="slot-card__head">
  83. <div>
  84. <p class="slot-card__label">${escapeHtml(slot.slot_label || slot.sensor_id)}</p>
  85. <h3>${escapeHtml(slot.product_name || 'Nicht zugeordnet')}</h3>
  86. </div>
  87. <div class="slot-card__status">
  88. ${inputErrorHint(slot)}
  89. <span class="status-pill status-pill--${escapeHtml(state)}">${stateLabel}</span>
  90. </div>
  91. </div>
  92. <div class="slot-card__body">
  93. <div class="fill-tube" style="--fill:${fillPercent}%">
  94. <div class="fill-tube__liquid" style="height:${fillPercent}%"></div>
  95. <div class="fill-tube__gloss"></div>
  96. </div>
  97. <div class="slot-card__metrics">
  98. <p><strong>${fillPercent}%</strong> Füllstand</p>
  99. <p><strong>${units}</strong> / ${maxUnits} Flaschen</p>
  100. <p>Alarm unter <strong>${slot.alert_below_units ?? 0}</strong></p>
  101. <p>Messwert: <strong>${slot.distance_mm ?? '–'} mm</strong></p>
  102. <p>Update: <strong>${formatTime(slot.measured_at)}</strong></p>
  103. </div>
  104. </div>
  105. </article>
  106. `;
  107. };
  108. const renderMachines = () => {
  109. const machines = (currentStatus.machines || []).filter(
  110. (machine) => activeMachine === 'all' || machine.id === activeMachine
  111. );
  112. if (!machines.length) {
  113. gridNode.innerHTML = '<p class="empty-state">Keine Automaten für die aktuelle Auswahl gefunden.</p>';
  114. return;
  115. }
  116. gridNode.innerHTML = machines
  117. .map(
  118. (machine) => `
  119. <section class="machine-panel">
  120. <div class="machine-panel__head">
  121. <div>
  122. <p class="eyebrow">Automat</p>
  123. <h2>${escapeHtml(machine.name)}</h2>
  124. </div>
  125. <p>${escapeHtml(machine.location || 'Kein Standort hinterlegt')}</p>
  126. </div>
  127. <div class="slot-grid">
  128. ${(machine.slots || []).map(slotCard).join('')}
  129. </div>
  130. </section>
  131. `
  132. )
  133. .join('');
  134. };
  135. const renderAlerts = () => {
  136. const alerts = currentStatus.alerts || [];
  137. if (!alerts.length) {
  138. alertNode.innerHTML =
  139. '<p class="empty-state">Noch keine Zustandswechsel registriert.</p>';
  140. return;
  141. }
  142. alertNode.innerHTML = alerts
  143. .slice(0, 12)
  144. .map((entry) => {
  145. const payload = entry.payload || {};
  146. const stateClass = payload.event === 'critical' ? 'critical' : 'ok';
  147. const stateText = payload.event === 'critical' ? 'Alarm' : 'Entwarnung';
  148. return `
  149. <article class="alert-entry alert-entry--${escapeHtml(stateClass)}">
  150. <div>
  151. <p class="alert-entry__title">${stateText}: ${escapeHtml(
  152. payload.machine_name || payload.machine_id || 'Automat'
  153. )} / ${escapeHtml(payload.slot_label || payload.sensor_id || 'Fach')}</p>
  154. <p>${escapeHtml(payload.product_name || 'Ohne Produktname')} • Bestand ${
  155. payload.units_estimated ?? '–'
  156. } / ${payload.max_units ?? '–'} • ${payload.fill_percent ?? '–'}%</p>
  157. </div>
  158. <time>${formatTime(entry.created_at)}</time>
  159. </article>
  160. `;
  161. })
  162. .join('');
  163. };
  164. const updateSummary = () => {
  165. const summary = currentStatus.summary || {};
  166. Object.entries(summary).forEach(([key, value]) => {
  167. const node = document.querySelector(`[data-summary="${key}"]`);
  168. if (node) {
  169. node.textContent = value;
  170. }
  171. });
  172. if (generatedAtNode) {
  173. generatedAtNode.textContent = formatTime(currentStatus.generated_at);
  174. }
  175. };
  176. const render = () => {
  177. renderFilters();
  178. renderMachines();
  179. renderAlerts();
  180. updateSummary();
  181. };
  182. filterNode.addEventListener('click', (event) => {
  183. const target = event.target.closest('[data-machine]');
  184. if (!target) {
  185. return;
  186. }
  187. activeMachine = target.dataset.machine || 'all';
  188. render();
  189. });
  190. const refresh = async () => {
  191. try {
  192. const response = await fetch(appUrl('/api/v1/status.php'), { cache: 'no-store' });
  193. if (!response.ok) {
  194. return;
  195. }
  196. currentStatus = await response.json();
  197. render();
  198. } catch (error) {
  199. console.error('Status-Aktualisierung fehlgeschlagen', error);
  200. }
  201. };
  202. render();
  203. const refreshSeconds = currentStatus.app?.dashboard_refresh_seconds || 15;
  204. window.setInterval(refresh, refreshSeconds * 1000);
  205. }