ops.html 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593
  1. <!doctype html>
  2. <html lang="pl">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <meta name="robots" content="noindex,nofollow">
  7. <title>EKSRelay — panel operacyjny</title>
  8. <style>
  9. :root {
  10. --bg: #f6f7f9; --panel: #ffffff; --border: #dfe3e8; --text: #1b1f24;
  11. --muted: #6a737d; --accent: #2f6feb; --accent-soft: #e8f0fe;
  12. --ok: #1a7f37; --warn: #9a6700; --bad: #b42318; --code-bg: #f2f4f7;
  13. }
  14. @media (prefers-color-scheme: dark) {
  15. :root {
  16. --bg: #0f1419; --panel: #171d24; --border: #2a323c; --text: #e6edf3;
  17. --muted: #8b949e; --accent: #4c8dff; --accent-soft: #16263f;
  18. --ok: #3fb950; --warn: #d29922; --bad: #f85149; --code-bg: #10161d;
  19. }
  20. }
  21. * { box-sizing: border-box; }
  22. body {
  23. margin: 0; background: var(--bg); color: var(--text);
  24. font: 14px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  25. }
  26. header {
  27. display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
  28. padding: 12px 20px; background: var(--panel); border-bottom: 1px solid var(--border);
  29. position: sticky; top: 0; z-index: 10;
  30. }
  31. header h1 { font-size: 16px; margin: 0; font-weight: 650; letter-spacing: -0.01em; }
  32. .spacer { flex: 1; }
  33. .badge {
  34. display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px;
  35. border-radius: 999px; font-size: 12px; font-weight: 600;
  36. background: var(--accent-soft); color: var(--accent); white-space: nowrap;
  37. }
  38. .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; }
  39. .dot.ok { background: var(--ok); } .dot.bad { background: var(--bad); }
  40. button {
  41. font: inherit; padding: 6px 12px; border-radius: 6px; cursor: pointer;
  42. border: 1px solid var(--border); background: var(--panel); color: var(--text);
  43. }
  44. button:hover { border-color: var(--accent); color: var(--accent); }
  45. button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
  46. button.primary:hover { opacity: .9; color: #fff; }
  47. input, select {
  48. font: inherit; padding: 6px 10px; border-radius: 6px;
  49. border: 1px solid var(--border); background: var(--panel); color: var(--text);
  50. }
  51. main { padding: 20px; max-width: 1500px; margin: 0 auto; }
  52. .tabs { display: flex; gap: 4px; margin-bottom: 16px; flex-wrap: wrap; }
  53. .tabs button.active { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
  54. .filters {
  55. display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap;
  56. padding: 14px; background: var(--panel); border: 1px solid var(--border);
  57. border-radius: 8px; margin-bottom: 16px;
  58. }
  59. .field { display: flex; flex-direction: column; gap: 4px; }
  60. .field label { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); font-weight: 600; }
  61. .card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
  62. .card-head {
  63. padding: 10px 14px; border-bottom: 1px solid var(--border);
  64. display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 13px;
  65. }
  66. .table-wrap { overflow-x: auto; }
  67. table { width: 100%; border-collapse: collapse; font-size: 13px; }
  68. th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--border); vertical-align: top; }
  69. th { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); font-weight: 600; white-space: nowrap; }
  70. tbody tr:last-child td { border-bottom: none; }
  71. tbody tr:hover { background: var(--accent-soft); }
  72. td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
  73. .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
  74. .ts { white-space: nowrap; color: var(--muted); font-variant-numeric: tabular-nums; }
  75. .pill {
  76. display: inline-block; padding: 2px 8px; border-radius: 999px;
  77. font-size: 11px; font-weight: 600; background: var(--code-bg); white-space: nowrap;
  78. }
  79. .pill.ok { color: var(--ok); } .pill.warn { color: var(--warn); } .pill.bad { color: var(--bad); }
  80. .meta { color: var(--muted); font-size: 12px; word-break: break-word; max-width: 420px; }
  81. .stats { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
  82. .stat {
  83. background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
  84. padding: 10px 14px; min-width: 96px;
  85. }
  86. .stat .k { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); font-weight: 600; }
  87. .stat .v { font-size: 20px; font-weight: 650; font-variant-numeric: tabular-nums; }
  88. .empty, .error { padding: 28px; text-align: center; color: var(--muted); }
  89. .error { color: var(--bad); }
  90. #gate {
  91. position: fixed; inset: 0; background: var(--bg); display: flex;
  92. align-items: center; justify-content: center; padding: 20px; z-index: 100;
  93. }
  94. #gate .box {
  95. background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
  96. padding: 28px; width: 100%; max-width: 400px;
  97. }
  98. #gate h2 { margin: 0 0 6px; font-size: 17px; }
  99. #gate p { margin: 0 0 18px; color: var(--muted); font-size: 13px; }
  100. #gate input { width: 100%; margin-bottom: 12px; }
  101. #gate button { width: 100%; }
  102. #gate .err { color: var(--bad); font-size: 13px; margin-top: 12px; display: none; }
  103. .hint { color: var(--muted); font-size: 12px; margin-top: 14px; }
  104. [hidden] { display: none !important; }
  105. </style>
  106. </head>
  107. <body>
  108. <div id="gate">
  109. <div class="box">
  110. <h2>EKSRelay — panel operacyjny</h2>
  111. <p>Podgląd tylko do odczytu. Podaj <code>ADMIN_TOKEN</code>, żeby wczytać dane.</p>
  112. <div id="gate-form" role="group" aria-label="Logowanie do panelu" autocomplete="off">
  113. <input type="password" id="token-input" placeholder="ADMIN_TOKEN" autocomplete="off" spellcheck="false" required>
  114. <button type="button" id="login-button" class="primary">Zaloguj</button>
  115. </div>
  116. <div class="err" id="gate-err"></div>
  117. <div class="hint">
  118. Token trzymany jest wyłącznie w <code>sessionStorage</code> tej karty i wysyłany
  119. w nagłówku <code>Authorization</code>. Nigdy nie trafia do adresu URL ani do logów.
  120. </div>
  121. </div>
  122. </div>
  123. <div id="app" hidden>
  124. <header>
  125. <h1>EKSRelay</h1>
  126. <span class="badge" id="mode-badge">—</span>
  127. <span class="badge"><span class="dot" id="health-dot"></span><span id="health-text">health</span></span>
  128. <span class="badge"><span class="dot" id="ready-dot"></span><span id="ready-text">ready</span></span>
  129. <span class="badge" id="bot-badge">AI —</span>
  130. <button id="panic-off" title="Natychmiast zatrzymaj kolejkowanie AI">AI OFF</button>
  131. <button id="panic-on" class="primary" title="Włącz AI tylko dla nowych wiadomości od teraz">AI ON od teraz</button>
  132. <span class="spacer"></span>
  133. <label class="badge" style="cursor:pointer">
  134. <input type="checkbox" id="autorefresh"> auto 15&nbsp;s
  135. </label>
  136. <button id="refresh">Odśwież</button>
  137. <button id="logout">Wyloguj</button>
  138. </header>
  139. <main>
  140. <div class="stats" id="stats"></div>
  141. <div class="tabs" id="tabs">
  142. <button data-tab="events" class="active">Audyt</button>
  143. <button data-tab="jobs">Kolejka</button>
  144. <button data-tab="messages">Wiadomości</button>
  145. <button data-tab="tickets">Tickety</button>
  146. <button data-tab="kb">KB</button>
  147. <button data-tab="ready">Zależności</button>
  148. </div>
  149. <div class="filters">
  150. <div class="field">
  151. <label for="f-conv">conversationId</label>
  152. <input id="f-conv" type="number" min="1" placeholder="wszystkie" style="width:140px">
  153. </div>
  154. <div class="field" id="f-type-wrap">
  155. <label for="f-type">typ eventu</label>
  156. <select id="f-type" style="width:200px"><option value="">wszystkie</option></select>
  157. </div>
  158. <div class="field" id="f-status-wrap" hidden>
  159. <label for="f-status">status</label>
  160. <select id="f-status" style="width:160px"><option value="">wszystkie</option></select>
  161. </div>
  162. <div class="field">
  163. <label for="f-limit">limit</label>
  164. <select id="f-limit" style="width:100px">
  165. <option>25</option><option selected>50</option><option>100</option><option>200</option>
  166. </select>
  167. </div>
  168. <button class="primary" id="apply">Zastosuj</button>
  169. <span class="spacer"></span>
  170. <span class="badge" id="updated">—</span>
  171. </div>
  172. <div class="card">
  173. <div class="card-head"><span id="card-title">Audyt</span><span class="spacer"></span><span class="pill" id="card-count"></span></div>
  174. <div class="table-wrap"><div id="content"><div class="empty">Ładowanie…</div></div></div>
  175. </div>
  176. </main>
  177. </div>
  178. <script data-cfasync="false">
  179. (function () {
  180. 'use strict';
  181. var KEY = 'eksrelay.adminToken';
  182. var tab = 'events';
  183. var timer = null;
  184. var $ = function (id) { return document.getElementById(id); };
  185. function token() { return sessionStorage.getItem(KEY) || ''; }
  186. // Every value rendered below goes through this. Audit summaries and job
  187. // errors are partly derived from customer-supplied text, so nothing is
  188. // injected as markup.
  189. function el(tag, cls, text) {
  190. var n = document.createElement(tag);
  191. if (cls) n.className = cls;
  192. if (text !== undefined && text !== null) n.textContent = String(text);
  193. return n;
  194. }
  195. function fmtTime(iso) {
  196. if (!iso) return '—';
  197. var d = new Date(iso);
  198. if (isNaN(d)) return String(iso);
  199. var p = function (n) { return String(n).padStart(2, '0'); };
  200. return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) +
  201. ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
  202. }
  203. function shorten(v, max) {
  204. if (v === null || v === undefined) return '—';
  205. var s = typeof v === 'string' ? v : JSON.stringify(v);
  206. return s.length > max ? s.slice(0, max) + '…' : s;
  207. }
  208. // Token travels in the header only — never as a query parameter, so it can
  209. // never end up in an access log, a proxy log or the browser history.
  210. function api(path, params) {
  211. var url = new URL(path, window.location.origin);
  212. Object.keys(params || {}).forEach(function (k) {
  213. if (params[k] !== '' && params[k] !== undefined && params[k] !== null) {
  214. url.searchParams.set(k, params[k]);
  215. }
  216. });
  217. return fetch(url.toString(), {
  218. headers: { Authorization: 'Bearer ' + token() },
  219. cache: 'no-store'
  220. }).then(function (r) {
  221. if (r.status === 401) { logout('Token odrzucony przez serwer.'); throw new Error('unauthorized'); }
  222. if (!r.ok) throw new Error('HTTP ' + r.status);
  223. return r.json();
  224. });
  225. }
  226. function apiPatch(path, body) {
  227. return fetch(path, {
  228. method: 'PATCH',
  229. headers: { Authorization: 'Bearer ' + token(), 'Content-Type': 'application/json' },
  230. body: JSON.stringify(body || {}),
  231. cache: 'no-store'
  232. }).then(function (r) {
  233. if (r.status === 401) { logout('Token odrzucony przez serwer.'); throw new Error('unauthorized'); }
  234. if (!r.ok) throw new Error('HTTP ' + r.status);
  235. return r.json();
  236. });
  237. }
  238. function logout(msg) {
  239. sessionStorage.removeItem(KEY);
  240. stopAuto();
  241. $('app').hidden = true;
  242. $('gate').hidden = false;
  243. if (msg) { $('gate-err').textContent = msg; $('gate-err').style.display = 'block'; }
  244. }
  245. // ---------------------------------------------------------------- renderers
  246. function table(cols, rows, build) {
  247. if (!rows.length) return el('div', 'empty', 'Brak rekordów dla tych filtrów.');
  248. var t = el('table');
  249. var thead = el('thead'), tr = el('tr');
  250. cols.forEach(function (c) {
  251. var th = el('th', c.num ? 'num' : null, c.label);
  252. tr.appendChild(th);
  253. });
  254. thead.appendChild(tr); t.appendChild(thead);
  255. var tb = el('tbody');
  256. rows.forEach(function (row) { tb.appendChild(build(row)); });
  257. t.appendChild(tb);
  258. return t;
  259. }
  260. function statusPill(status) {
  261. var cls = 'pill';
  262. if (status === 'done' || status === 'replied') cls += ' ok';
  263. else if (status === 'dead' || status === 'failed') cls += ' bad';
  264. else if (status === 'spam' || status === 'skipped' || status === 'ticket') cls += ' warn';
  265. return el('span', cls, status);
  266. }
  267. function eventPill(type) {
  268. var cls = 'pill';
  269. if (type === 'reply_sent' || type === 'webhook_accepted') cls += ' ok';
  270. else if (type === 'job_dead' || type === 'no_reply') cls += ' bad';
  271. else if (type.indexOf('ticket') !== -1 || type.indexOf('spam') !== -1 || type.indexOf('skipped') !== -1) cls += ' warn';
  272. return el('span', cls, type);
  273. }
  274. var VIEWS = {
  275. events: {
  276. title: 'Audyt decyzji bramki',
  277. filters: ['conv', 'type', 'limit'],
  278. load: function (f) { return api('/admin/events', { conversationId: f.conv, eventType: f.type, limit: f.limit }); },
  279. render: function (d) {
  280. return table(
  281. [{ label: 'Czas' }, { label: 'Typ' }, { label: 'conversationId', num: true },
  282. { label: 'messageId' }, { label: 'jobId' }, { label: 'Podsumowanie' }, { label: 'Meta' }],
  283. d.events,
  284. function (e) {
  285. var tr = el('tr');
  286. tr.appendChild(el('td', 'ts', fmtTime(e.createdAt)));
  287. var td = el('td'); td.appendChild(eventPill(e.eventType)); tr.appendChild(td);
  288. tr.appendChild(el('td', 'num mono', e.conversationId === null ? '—' : e.conversationId));
  289. tr.appendChild(el('td', 'mono', shorten(e.messageId, 24)));
  290. tr.appendChild(el('td', 'mono', shorten(e.jobId, 14)));
  291. tr.appendChild(el('td', null, e.summary));
  292. tr.appendChild(el('td', 'meta mono', e.meta ? shorten(e.meta, 160) : '—'));
  293. return tr;
  294. }
  295. );
  296. }
  297. },
  298. jobs: {
  299. title: 'Kolejka zadań',
  300. filters: ['status', 'limit'],
  301. statuses: ['queued', 'processing', 'done', 'dead'],
  302. load: function (f) { return api('/admin/jobs', { status: f.status, limit: f.limit }); },
  303. render: function (d) {
  304. return table(
  305. [{ label: 'Zaktualizowano' }, { label: 'Status' }, { label: 'Typ' },
  306. { label: 'Próby', num: true }, { label: 'Ponów po' }, { label: 'Ostatni błąd' }, { label: 'id' }],
  307. d.jobs,
  308. function (j) {
  309. var tr = el('tr');
  310. tr.appendChild(el('td', 'ts', fmtTime(j.updatedAt)));
  311. var td = el('td'); td.appendChild(statusPill(j.status)); tr.appendChild(td);
  312. tr.appendChild(el('td', null, j.type));
  313. tr.appendChild(el('td', 'num mono', j.attempts + '/' + j.maxAttempts));
  314. tr.appendChild(el('td', 'ts', j.status === 'queued' ? fmtTime(j.runAfter) : '—'));
  315. tr.appendChild(el('td', 'meta mono', j.lastError ? shorten(j.lastError, 200) : '—'));
  316. tr.appendChild(el('td', 'mono', shorten(j.id, 14)));
  317. return tr;
  318. }
  319. );
  320. }
  321. },
  322. messages: {
  323. title: 'Przetworzone wiadomości (idempotencja)',
  324. filters: ['conv', 'status', 'limit'],
  325. statuses: ['queued', 'processing', 'replied', 'skipped', 'ticket', 'failed', 'spam'],
  326. load: function (f) { return api('/admin/messages', { conversationId: f.conv, status: f.status, limit: f.limit }); },
  327. render: function (d) {
  328. return table(
  329. [{ label: 'Utworzono' }, { label: 'Status' }, { label: 'Powód' },
  330. { label: 'conversationId', num: true }, { label: 'messageId' }, { label: 'Źródło' }, { label: 'Zmieniono' }],
  331. d.messages,
  332. function (m) {
  333. var tr = el('tr');
  334. tr.appendChild(el('td', 'ts', fmtTime(m.createdAt)));
  335. var td = el('td'); td.appendChild(statusPill(m.status)); tr.appendChild(td);
  336. tr.appendChild(el('td', null, m.reason || '—'));
  337. tr.appendChild(el('td', 'num mono', m.conversationId));
  338. tr.appendChild(el('td', 'mono', shorten(m.messageId, 28)));
  339. tr.appendChild(el('td', null, m.source));
  340. tr.appendChild(el('td', 'ts', fmtTime(m.updatedAt)));
  341. return tr;
  342. }
  343. );
  344. }
  345. },
  346. tickets: {
  347. title: 'Tickety / handoff',
  348. filters: ['conv', 'limit'],
  349. load: function (f) { return api('/admin/tickets', { conversationId: f.conv, limit: f.limit }); },
  350. render: function (d) {
  351. return table(
  352. [{ label: 'Utworzono' }, { label: 'Numer ticketu' }, { label: 'conversationId', num: true },
  353. { label: 'Powód' }, { label: 'Ostatni event' }, { label: 'Kiedy' }, { label: 'Pominiętych od', num: true }],
  354. d.tickets,
  355. function (t) {
  356. var tr = el('tr');
  357. tr.appendChild(el('td', 'ts', fmtTime(t.createdAt)));
  358. tr.appendChild(el('td', 'mono', t.ticketNumber));
  359. tr.appendChild(el('td', 'num mono', t.conversationId));
  360. tr.appendChild(el('td', 'meta', shorten(t.reason, 90)));
  361. var td = el('td');
  362. if (t.lastEventType) td.appendChild(eventPill(t.lastEventType)); else td.textContent = '—';
  363. tr.appendChild(td);
  364. tr.appendChild(el('td', 'ts', fmtTime(t.lastEventAt)));
  365. tr.appendChild(el('td', 'num mono', t.messagesSkippedSince));
  366. return tr;
  367. }
  368. );
  369. }
  370. },
  371. kb: {
  372. title: 'Knowledge Base',
  373. filters: ['limit'],
  374. load: function () { return api('/admin/kb', { path: '/kb/status' }); },
  375. render: function (d) {
  376. var box = el('div', 'meta');
  377. box.style.maxWidth = 'none';
  378. box.style.padding = '14px';
  379. box.appendChild(el('div', null, d.ok ? 'KB API działa. Dane poniżej są bez embeddingów/sekretów.' : 'KB API niedostępne.'));
  380. box.appendChild(el('pre', 'mono', shorten(d.data, 4000)));
  381. return box;
  382. }
  383. },
  384. ready: {
  385. title: 'Zależności (/ready)',
  386. filters: [],
  387. load: function () { return fetch('/ready', { cache: 'no-store' }).then(function (r) { return r.json(); }); },
  388. render: function (d) {
  389. var deps = d.dependencies || {};
  390. var rows = Object.keys(deps).map(function (k) { return [k, deps[k]]; });
  391. return table(
  392. [{ label: 'Zależność' }, { label: 'Stan' }, { label: 'HTTP', num: true }, { label: 'Cel' }],
  393. rows,
  394. function (pair) {
  395. var tr = el('tr');
  396. tr.appendChild(el('td', null, pair[0]));
  397. var td = el('td');
  398. td.appendChild(el('span', 'pill ' + (pair[1].ok ? 'ok' : 'bad'), pair[1].ok ? 'ok' : 'błąd'));
  399. tr.appendChild(td);
  400. tr.appendChild(el('td', 'num mono', pair[1].status));
  401. tr.appendChild(el('td', 'mono', pair[1].target));
  402. return tr;
  403. }
  404. );
  405. }
  406. }
  407. };
  408. // -------------------------------------------------------------------- state
  409. function currentFilters() {
  410. return {
  411. conv: $('f-conv').value.trim(),
  412. type: $('f-type').value,
  413. status: $('f-status').value,
  414. limit: $('f-limit').value
  415. };
  416. }
  417. function syncFilterVisibility() {
  418. var v = VIEWS[tab];
  419. $('f-conv').parentElement.hidden = v.filters.indexOf('conv') === -1;
  420. $('f-type-wrap').hidden = v.filters.indexOf('type') === -1;
  421. $('f-status-wrap').hidden = v.filters.indexOf('status') === -1;
  422. $('f-limit').parentElement.hidden = v.filters.indexOf('limit') === -1;
  423. var sel = $('f-status');
  424. sel.innerHTML = '';
  425. sel.appendChild(new Option('wszystkie', ''));
  426. (v.statuses || []).forEach(function (s) { sel.appendChild(new Option(s, s)); });
  427. }
  428. function setContent(node) {
  429. var c = $('content');
  430. c.innerHTML = '';
  431. c.appendChild(node);
  432. }
  433. function loadTab() {
  434. var v = VIEWS[tab];
  435. $('card-title').textContent = v.title;
  436. setContent(el('div', 'empty', 'Ładowanie…'));
  437. v.load(currentFilters()).then(function (d) {
  438. setContent(v.render(d));
  439. var n = d.count !== undefined ? d.count : (d.dependencies ? Object.keys(d.dependencies).length : 0);
  440. $('card-count').textContent = n + ' rekordów';
  441. $('updated').textContent = 'odświeżono ' + fmtTime(new Date().toISOString());
  442. }).catch(function (err) {
  443. if (err.message === 'unauthorized') return;
  444. setContent(el('div', 'error', 'Błąd pobierania: ' + err.message));
  445. });
  446. }
  447. function loadHeader() {
  448. fetch('/health', { cache: 'no-store' })
  449. .then(function (r) { return r.ok ? r.json() : Promise.reject(new Error(r.status)); })
  450. .then(function (d) {
  451. $('health-dot').className = 'dot ok';
  452. $('health-text').textContent = 'health ' + d.uptimeSeconds + 's';
  453. $('mode-badge').textContent = 'tryb: ' + d.mode + ' · v' + d.version;
  454. })
  455. .catch(function () { $('health-dot').className = 'dot bad'; $('health-text').textContent = 'health błąd'; });
  456. fetch('/ready', { cache: 'no-store' })
  457. .then(function (r) { return r.json(); })
  458. .then(function (d) {
  459. $('ready-dot').className = 'dot ' + (d.ok ? 'ok' : 'bad');
  460. var bad = Object.keys(d.dependencies || {}).filter(function (k) { return !d.dependencies[k].ok; });
  461. $('ready-text').textContent = bad.length ? 'ready: ' + bad.join(', ') : 'ready ok';
  462. })
  463. .catch(function () { $('ready-dot').className = 'dot bad'; $('ready-text').textContent = 'ready błąd'; });
  464. api('/admin/meta').then(function (d) {
  465. var sel = $('f-type'), keep = sel.value;
  466. sel.innerHTML = '';
  467. sel.appendChild(new Option('wszystkie', ''));
  468. (d.eventTypes || []).forEach(function (t) { sel.appendChild(new Option(t, t)); });
  469. sel.value = keep;
  470. var s = $('stats');
  471. s.innerHTML = '';
  472. var jobs = d.counts.jobs || {};
  473. [['Eventy', d.counts.auditEvents], ['Tickety', d.counts.tickets],
  474. ['W kolejce', jobs.queued || 0], ['Wykonane', jobs.done || 0], ['Dead', jobs.dead || 0]]
  475. .forEach(function (pair) {
  476. var box = el('div', 'stat');
  477. box.appendChild(el('div', 'k', pair[0]));
  478. var v = el('div', 'v', pair[1]);
  479. if (pair[0] === 'Dead' && pair[1] > 0) v.style.color = 'var(--bad)';
  480. box.appendChild(v);
  481. s.appendChild(box);
  482. });
  483. var st = d.settings || {};
  484. var rt = d.runtime || {};
  485. $('bot-badge').textContent = rt.botEnabled ? 'AI ON · max ' + rt.maxMessageAgeMinutes + 'm' : 'AI OFF';
  486. $('bot-badge').style.color = rt.botEnabled ? 'var(--ok)' : 'var(--bad)';
  487. var box = el('div', 'stat');
  488. box.appendChild(el('div', 'k', 'Ustawienia'));
  489. box.appendChild(el('div', 'meta',
  490. 'label=' + st.ticketLabel + ' · unassign=' + st.unassignOnTicket +
  491. ' · spamGate=' + st.spamGate + ' · worker=' + st.worker + ' · logPii=' + st.logPii));
  492. s.appendChild(box);
  493. }).catch(function () { /* handled by api() */ });
  494. }
  495. function refresh() { loadHeader(); loadTab(); }
  496. function startAuto() { stopAuto(); timer = setInterval(refresh, 15000); }
  497. function stopAuto() { if (timer) { clearInterval(timer); timer = null; } }
  498. function enterApp() {
  499. $('gate').hidden = true;
  500. $('app').hidden = false;
  501. syncFilterVisibility();
  502. refresh();
  503. }
  504. // ------------------------------------------------------------------- wiring
  505. function submitLogin() {
  506. var value = $('token-input').value.trim();
  507. if (!value) return;
  508. sessionStorage.setItem(KEY, value);
  509. $('token-input').value = '';
  510. $('gate-err').style.display = 'none';
  511. // Probe with a real request so a wrong token fails at the gate, not later.
  512. api('/admin/meta').then(enterApp).catch(function (err) {
  513. if (err.message !== 'unauthorized') {
  514. $('gate-err').textContent = 'Błąd połączenia: ' + err.message;
  515. $('gate-err').style.display = 'block';
  516. }
  517. });
  518. }
  519. $('login-button').addEventListener('click', submitLogin);
  520. $('token-input').addEventListener('keydown', function (e) {
  521. if (e.key === 'Enter') { e.preventDefault(); submitLogin(); }
  522. });
  523. $('tabs').addEventListener('click', function (e) {
  524. var btn = e.target.closest('button[data-tab]');
  525. if (!btn) return;
  526. Array.prototype.forEach.call($('tabs').children, function (b) { b.classList.remove('active'); });
  527. btn.classList.add('active');
  528. tab = btn.dataset.tab;
  529. syncFilterVisibility();
  530. loadTab();
  531. });
  532. $('apply').addEventListener('click', loadTab);
  533. $('f-conv').addEventListener('keydown', function (e) { if (e.key === 'Enter') loadTab(); });
  534. $('refresh').addEventListener('click', refresh);
  535. $('panic-off').addEventListener('click', function () {
  536. if (!confirm('Wyłączyć AI? Nowe wiadomości nie będą kolejkowane.')) return;
  537. apiPatch('/admin/runtime', { botEnabled: false }).then(refresh).catch(function (err) { alert('Błąd: ' + err.message); });
  538. });
  539. $('panic-on').addEventListener('click', function () {
  540. if (!confirm('Włączyć AI tylko dla wiadomości od teraz? Backlog zostanie odcięty.')) return;
  541. apiPatch('/admin/runtime', { botEnabled: true, enableFromNow: true, maxMessageAgeMinutes: 240 }).then(refresh).catch(function (err) { alert('Błąd: ' + err.message); });
  542. });
  543. $('logout').addEventListener('click', function () { logout(); });
  544. $('autorefresh').addEventListener('change', function (e) { e.target.checked ? startAuto() : stopAuto(); });
  545. if (token()) enterApp(); else $('token-input').focus();
  546. })();
  547. </script>
  548. </body>
  549. </html>