|
@@ -0,0 +1,547 @@
|
|
|
|
|
+<!doctype html>
|
|
|
|
|
+<html lang="pl">
|
|
|
|
|
+<head>
|
|
|
|
|
+<meta charset="utf-8">
|
|
|
|
|
+<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
|
|
+<meta name="robots" content="noindex,nofollow">
|
|
|
|
|
+<title>EKSRelay — panel operacyjny</title>
|
|
|
|
|
+<style>
|
|
|
|
|
+ :root {
|
|
|
|
|
+ --bg: #f6f7f9; --panel: #ffffff; --border: #dfe3e8; --text: #1b1f24;
|
|
|
|
|
+ --muted: #6a737d; --accent: #2f6feb; --accent-soft: #e8f0fe;
|
|
|
|
|
+ --ok: #1a7f37; --warn: #9a6700; --bad: #b42318; --code-bg: #f2f4f7;
|
|
|
|
|
+ }
|
|
|
|
|
+ @media (prefers-color-scheme: dark) {
|
|
|
|
|
+ :root {
|
|
|
|
|
+ --bg: #0f1419; --panel: #171d24; --border: #2a323c; --text: #e6edf3;
|
|
|
|
|
+ --muted: #8b949e; --accent: #4c8dff; --accent-soft: #16263f;
|
|
|
|
|
+ --ok: #3fb950; --warn: #d29922; --bad: #f85149; --code-bg: #10161d;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ * { box-sizing: border-box; }
|
|
|
|
|
+ body {
|
|
|
|
|
+ margin: 0; background: var(--bg); color: var(--text);
|
|
|
|
|
+ font: 14px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
|
|
|
+ }
|
|
|
|
|
+ header {
|
|
|
|
|
+ display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
|
|
|
|
+ padding: 12px 20px; background: var(--panel); border-bottom: 1px solid var(--border);
|
|
|
|
|
+ position: sticky; top: 0; z-index: 10;
|
|
|
|
|
+ }
|
|
|
|
|
+ header h1 { font-size: 16px; margin: 0; font-weight: 650; letter-spacing: -0.01em; }
|
|
|
|
|
+ .spacer { flex: 1; }
|
|
|
|
|
+ .badge {
|
|
|
|
|
+ display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px;
|
|
|
|
|
+ border-radius: 999px; font-size: 12px; font-weight: 600;
|
|
|
|
|
+ background: var(--accent-soft); color: var(--accent); white-space: nowrap;
|
|
|
|
|
+ }
|
|
|
|
|
+ .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; }
|
|
|
|
|
+ .dot.ok { background: var(--ok); } .dot.bad { background: var(--bad); }
|
|
|
|
|
+ button {
|
|
|
|
|
+ font: inherit; padding: 6px 12px; border-radius: 6px; cursor: pointer;
|
|
|
|
|
+ border: 1px solid var(--border); background: var(--panel); color: var(--text);
|
|
|
|
|
+ }
|
|
|
|
|
+ button:hover { border-color: var(--accent); color: var(--accent); }
|
|
|
|
|
+ button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
|
|
|
+ button.primary:hover { opacity: .9; color: #fff; }
|
|
|
|
|
+ input, select {
|
|
|
|
|
+ font: inherit; padding: 6px 10px; border-radius: 6px;
|
|
|
|
|
+ border: 1px solid var(--border); background: var(--panel); color: var(--text);
|
|
|
|
|
+ }
|
|
|
|
|
+ main { padding: 20px; max-width: 1500px; margin: 0 auto; }
|
|
|
|
|
+ .tabs { display: flex; gap: 4px; margin-bottom: 16px; flex-wrap: wrap; }
|
|
|
|
|
+ .tabs button.active { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
|
|
|
|
|
+ .filters {
|
|
|
|
|
+ display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap;
|
|
|
|
|
+ padding: 14px; background: var(--panel); border: 1px solid var(--border);
|
|
|
|
|
+ border-radius: 8px; margin-bottom: 16px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .field { display: flex; flex-direction: column; gap: 4px; }
|
|
|
|
|
+ .field label { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); font-weight: 600; }
|
|
|
|
|
+ .card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
|
|
|
|
+ .card-head {
|
|
|
|
|
+ padding: 10px 14px; border-bottom: 1px solid var(--border);
|
|
|
|
|
+ display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 13px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .table-wrap { overflow-x: auto; }
|
|
|
|
|
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
|
|
|
+ th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--border); vertical-align: top; }
|
|
|
|
|
+ th { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); font-weight: 600; white-space: nowrap; }
|
|
|
|
|
+ tbody tr:last-child td { border-bottom: none; }
|
|
|
|
|
+ tbody tr:hover { background: var(--accent-soft); }
|
|
|
|
|
+ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
|
|
|
+ .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
|
|
|
|
+ .ts { white-space: nowrap; color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
|
|
|
+ .pill {
|
|
|
|
|
+ display: inline-block; padding: 2px 8px; border-radius: 999px;
|
|
|
|
|
+ font-size: 11px; font-weight: 600; background: var(--code-bg); white-space: nowrap;
|
|
|
|
|
+ }
|
|
|
|
|
+ .pill.ok { color: var(--ok); } .pill.warn { color: var(--warn); } .pill.bad { color: var(--bad); }
|
|
|
|
|
+ .meta { color: var(--muted); font-size: 12px; word-break: break-word; max-width: 420px; }
|
|
|
|
|
+ .stats { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
|
|
|
|
|
+ .stat {
|
|
|
|
|
+ background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
|
|
|
|
|
+ padding: 10px 14px; min-width: 96px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .stat .k { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); font-weight: 600; }
|
|
|
|
|
+ .stat .v { font-size: 20px; font-weight: 650; font-variant-numeric: tabular-nums; }
|
|
|
|
|
+ .empty, .error { padding: 28px; text-align: center; color: var(--muted); }
|
|
|
|
|
+ .error { color: var(--bad); }
|
|
|
|
|
+ #gate {
|
|
|
|
|
+ position: fixed; inset: 0; background: var(--bg); display: flex;
|
|
|
|
|
+ align-items: center; justify-content: center; padding: 20px; z-index: 100;
|
|
|
|
|
+ }
|
|
|
|
|
+ #gate .box {
|
|
|
|
|
+ background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
|
|
|
|
|
+ padding: 28px; width: 100%; max-width: 400px;
|
|
|
|
|
+ }
|
|
|
|
|
+ #gate h2 { margin: 0 0 6px; font-size: 17px; }
|
|
|
|
|
+ #gate p { margin: 0 0 18px; color: var(--muted); font-size: 13px; }
|
|
|
|
|
+ #gate input { width: 100%; margin-bottom: 12px; }
|
|
|
|
|
+ #gate button { width: 100%; }
|
|
|
|
|
+ #gate .err { color: var(--bad); font-size: 13px; margin-top: 12px; display: none; }
|
|
|
|
|
+ .hint { color: var(--muted); font-size: 12px; margin-top: 14px; }
|
|
|
|
|
+ [hidden] { display: none !important; }
|
|
|
|
|
+</style>
|
|
|
|
|
+</head>
|
|
|
|
|
+<body>
|
|
|
|
|
+
|
|
|
|
|
+<div id="gate">
|
|
|
|
|
+ <div class="box">
|
|
|
|
|
+ <h2>EKSRelay — panel operacyjny</h2>
|
|
|
|
|
+ <p>Podgląd tylko do odczytu. Podaj <code>ADMIN_TOKEN</code>, żeby wczytać dane.</p>
|
|
|
|
|
+ <form id="gate-form" autocomplete="off">
|
|
|
|
|
+ <input type="password" id="token-input" placeholder="ADMIN_TOKEN" autocomplete="off" spellcheck="false" required>
|
|
|
|
|
+ <button type="submit" class="primary">Zaloguj</button>
|
|
|
|
|
+ </form>
|
|
|
|
|
+ <div class="err" id="gate-err"></div>
|
|
|
|
|
+ <div class="hint">
|
|
|
|
|
+ Token trzymany jest wyłącznie w <code>sessionStorage</code> tej karty i wysyłany
|
|
|
|
|
+ w nagłówku <code>Authorization</code>. Nigdy nie trafia do adresu URL ani do logów.
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+<div id="app" hidden>
|
|
|
|
|
+ <header>
|
|
|
|
|
+ <h1>EKSRelay</h1>
|
|
|
|
|
+ <span class="badge" id="mode-badge">—</span>
|
|
|
|
|
+ <span class="badge"><span class="dot" id="health-dot"></span><span id="health-text">health</span></span>
|
|
|
|
|
+ <span class="badge"><span class="dot" id="ready-dot"></span><span id="ready-text">ready</span></span>
|
|
|
|
|
+ <span class="spacer"></span>
|
|
|
|
|
+ <label class="badge" style="cursor:pointer">
|
|
|
|
|
+ <input type="checkbox" id="autorefresh"> auto 15 s
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <button id="refresh">Odśwież</button>
|
|
|
|
|
+ <button id="logout">Wyloguj</button>
|
|
|
|
|
+ </header>
|
|
|
|
|
+
|
|
|
|
|
+ <main>
|
|
|
|
|
+ <div class="stats" id="stats"></div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="tabs" id="tabs">
|
|
|
|
|
+ <button data-tab="events" class="active">Audyt</button>
|
|
|
|
|
+ <button data-tab="jobs">Kolejka</button>
|
|
|
|
|
+ <button data-tab="messages">Wiadomości</button>
|
|
|
|
|
+ <button data-tab="tickets">Tickety</button>
|
|
|
|
|
+ <button data-tab="ready">Zależności</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="filters">
|
|
|
|
|
+ <div class="field">
|
|
|
|
|
+ <label for="f-conv">conversationId</label>
|
|
|
|
|
+ <input id="f-conv" type="number" min="1" placeholder="wszystkie" style="width:140px">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="field" id="f-type-wrap">
|
|
|
|
|
+ <label for="f-type">typ eventu</label>
|
|
|
|
|
+ <select id="f-type" style="width:200px"><option value="">wszystkie</option></select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="field" id="f-status-wrap" hidden>
|
|
|
|
|
+ <label for="f-status">status</label>
|
|
|
|
|
+ <select id="f-status" style="width:160px"><option value="">wszystkie</option></select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="field">
|
|
|
|
|
+ <label for="f-limit">limit</label>
|
|
|
|
|
+ <select id="f-limit" style="width:100px">
|
|
|
|
|
+ <option>25</option><option selected>50</option><option>100</option><option>200</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button class="primary" id="apply">Zastosuj</button>
|
|
|
|
|
+ <span class="spacer"></span>
|
|
|
|
|
+ <span class="badge" id="updated">—</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="card">
|
|
|
|
|
+ <div class="card-head"><span id="card-title">Audyt</span><span class="spacer"></span><span class="pill" id="card-count"></span></div>
|
|
|
|
|
+ <div class="table-wrap"><div id="content"><div class="empty">Ładowanie…</div></div></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </main>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+<script>
|
|
|
|
|
+(function () {
|
|
|
|
|
+ 'use strict';
|
|
|
|
|
+
|
|
|
|
|
+ var KEY = 'eksrelay.adminToken';
|
|
|
|
|
+ var tab = 'events';
|
|
|
|
|
+ var timer = null;
|
|
|
|
|
+
|
|
|
|
|
+ var $ = function (id) { return document.getElementById(id); };
|
|
|
|
|
+ function token() { return sessionStorage.getItem(KEY) || ''; }
|
|
|
|
|
+
|
|
|
|
|
+ // Every value rendered below goes through this. Audit summaries and job
|
|
|
|
|
+ // errors are partly derived from customer-supplied text, so nothing is
|
|
|
|
|
+ // injected as markup.
|
|
|
|
|
+ function el(tag, cls, text) {
|
|
|
|
|
+ var n = document.createElement(tag);
|
|
|
|
|
+ if (cls) n.className = cls;
|
|
|
|
|
+ if (text !== undefined && text !== null) n.textContent = String(text);
|
|
|
|
|
+ return n;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function fmtTime(iso) {
|
|
|
|
|
+ if (!iso) return '—';
|
|
|
|
|
+ var d = new Date(iso);
|
|
|
|
|
+ if (isNaN(d)) return String(iso);
|
|
|
|
|
+ var p = function (n) { return String(n).padStart(2, '0'); };
|
|
|
|
|
+ return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) +
|
|
|
|
|
+ ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function shorten(v, max) {
|
|
|
|
|
+ if (v === null || v === undefined) return '—';
|
|
|
|
|
+ var s = typeof v === 'string' ? v : JSON.stringify(v);
|
|
|
|
|
+ return s.length > max ? s.slice(0, max) + '…' : s;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Token travels in the header only — never as a query parameter, so it can
|
|
|
|
|
+ // never end up in an access log, a proxy log or the browser history.
|
|
|
|
|
+ function api(path, params) {
|
|
|
|
|
+ var url = new URL(path, window.location.origin);
|
|
|
|
|
+ Object.keys(params || {}).forEach(function (k) {
|
|
|
|
|
+ if (params[k] !== '' && params[k] !== undefined && params[k] !== null) {
|
|
|
|
|
+ url.searchParams.set(k, params[k]);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ return fetch(url.toString(), {
|
|
|
|
|
+ headers: { Authorization: 'Bearer ' + token() },
|
|
|
|
|
+ cache: 'no-store'
|
|
|
|
|
+ }).then(function (r) {
|
|
|
|
|
+ if (r.status === 401) { logout('Token odrzucony przez serwer.'); throw new Error('unauthorized'); }
|
|
|
|
|
+ if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
|
|
|
+ return r.json();
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function logout(msg) {
|
|
|
|
|
+ sessionStorage.removeItem(KEY);
|
|
|
|
|
+ stopAuto();
|
|
|
|
|
+ $('app').hidden = true;
|
|
|
|
|
+ $('gate').hidden = false;
|
|
|
|
|
+ if (msg) { $('gate-err').textContent = msg; $('gate-err').style.display = 'block'; }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---------------------------------------------------------------- renderers
|
|
|
|
|
+
|
|
|
|
|
+ function table(cols, rows, build) {
|
|
|
|
|
+ if (!rows.length) return el('div', 'empty', 'Brak rekordów dla tych filtrów.');
|
|
|
|
|
+ var t = el('table');
|
|
|
|
|
+ var thead = el('thead'), tr = el('tr');
|
|
|
|
|
+ cols.forEach(function (c) {
|
|
|
|
|
+ var th = el('th', c.num ? 'num' : null, c.label);
|
|
|
|
|
+ tr.appendChild(th);
|
|
|
|
|
+ });
|
|
|
|
|
+ thead.appendChild(tr); t.appendChild(thead);
|
|
|
|
|
+ var tb = el('tbody');
|
|
|
|
|
+ rows.forEach(function (row) { tb.appendChild(build(row)); });
|
|
|
|
|
+ t.appendChild(tb);
|
|
|
|
|
+ return t;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function statusPill(status) {
|
|
|
|
|
+ var cls = 'pill';
|
|
|
|
|
+ if (status === 'done' || status === 'replied') cls += ' ok';
|
|
|
|
|
+ else if (status === 'dead' || status === 'failed') cls += ' bad';
|
|
|
|
|
+ else if (status === 'spam' || status === 'skipped' || status === 'ticket') cls += ' warn';
|
|
|
|
|
+ return el('span', cls, status);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function eventPill(type) {
|
|
|
|
|
+ var cls = 'pill';
|
|
|
|
|
+ if (type === 'reply_sent' || type === 'webhook_accepted') cls += ' ok';
|
|
|
|
|
+ else if (type === 'job_dead' || type === 'no_reply') cls += ' bad';
|
|
|
|
|
+ else if (type.indexOf('ticket') !== -1 || type.indexOf('spam') !== -1 || type.indexOf('skipped') !== -1) cls += ' warn';
|
|
|
|
|
+ return el('span', cls, type);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var VIEWS = {
|
|
|
|
|
+ events: {
|
|
|
|
|
+ title: 'Audyt decyzji bramki',
|
|
|
|
|
+ filters: ['conv', 'type', 'limit'],
|
|
|
|
|
+ load: function (f) { return api('/admin/events', { conversationId: f.conv, eventType: f.type, limit: f.limit }); },
|
|
|
|
|
+ render: function (d) {
|
|
|
|
|
+ return table(
|
|
|
|
|
+ [{ label: 'Czas' }, { label: 'Typ' }, { label: 'conversationId', num: true },
|
|
|
|
|
+ { label: 'messageId' }, { label: 'jobId' }, { label: 'Podsumowanie' }, { label: 'Meta' }],
|
|
|
|
|
+ d.events,
|
|
|
|
|
+ function (e) {
|
|
|
|
|
+ var tr = el('tr');
|
|
|
|
|
+ tr.appendChild(el('td', 'ts', fmtTime(e.createdAt)));
|
|
|
|
|
+ var td = el('td'); td.appendChild(eventPill(e.eventType)); tr.appendChild(td);
|
|
|
|
|
+ tr.appendChild(el('td', 'num mono', e.conversationId === null ? '—' : e.conversationId));
|
|
|
|
|
+ tr.appendChild(el('td', 'mono', shorten(e.messageId, 24)));
|
|
|
|
|
+ tr.appendChild(el('td', 'mono', shorten(e.jobId, 14)));
|
|
|
|
|
+ tr.appendChild(el('td', null, e.summary));
|
|
|
|
|
+ tr.appendChild(el('td', 'meta mono', e.meta ? shorten(e.meta, 160) : '—'));
|
|
|
|
|
+ return tr;
|
|
|
|
|
+ }
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ jobs: {
|
|
|
|
|
+ title: 'Kolejka zadań',
|
|
|
|
|
+ filters: ['status', 'limit'],
|
|
|
|
|
+ statuses: ['queued', 'processing', 'done', 'dead'],
|
|
|
|
|
+ load: function (f) { return api('/admin/jobs', { status: f.status, limit: f.limit }); },
|
|
|
|
|
+ render: function (d) {
|
|
|
|
|
+ return table(
|
|
|
|
|
+ [{ label: 'Zaktualizowano' }, { label: 'Status' }, { label: 'Typ' },
|
|
|
|
|
+ { label: 'Próby', num: true }, { label: 'Ponów po' }, { label: 'Ostatni błąd' }, { label: 'id' }],
|
|
|
|
|
+ d.jobs,
|
|
|
|
|
+ function (j) {
|
|
|
|
|
+ var tr = el('tr');
|
|
|
|
|
+ tr.appendChild(el('td', 'ts', fmtTime(j.updatedAt)));
|
|
|
|
|
+ var td = el('td'); td.appendChild(statusPill(j.status)); tr.appendChild(td);
|
|
|
|
|
+ tr.appendChild(el('td', null, j.type));
|
|
|
|
|
+ tr.appendChild(el('td', 'num mono', j.attempts + '/' + j.maxAttempts));
|
|
|
|
|
+ tr.appendChild(el('td', 'ts', j.status === 'queued' ? fmtTime(j.runAfter) : '—'));
|
|
|
|
|
+ tr.appendChild(el('td', 'meta mono', j.lastError ? shorten(j.lastError, 200) : '—'));
|
|
|
|
|
+ tr.appendChild(el('td', 'mono', shorten(j.id, 14)));
|
|
|
|
|
+ return tr;
|
|
|
|
|
+ }
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ messages: {
|
|
|
|
|
+ title: 'Przetworzone wiadomości (idempotencja)',
|
|
|
|
|
+ filters: ['conv', 'status', 'limit'],
|
|
|
|
|
+ statuses: ['queued', 'processing', 'replied', 'skipped', 'ticket', 'failed', 'spam'],
|
|
|
|
|
+ load: function (f) { return api('/admin/messages', { conversationId: f.conv, status: f.status, limit: f.limit }); },
|
|
|
|
|
+ render: function (d) {
|
|
|
|
|
+ return table(
|
|
|
|
|
+ [{ label: 'Utworzono' }, { label: 'Status' }, { label: 'Powód' },
|
|
|
|
|
+ { label: 'conversationId', num: true }, { label: 'messageId' }, { label: 'Źródło' }, { label: 'Zmieniono' }],
|
|
|
|
|
+ d.messages,
|
|
|
|
|
+ function (m) {
|
|
|
|
|
+ var tr = el('tr');
|
|
|
|
|
+ tr.appendChild(el('td', 'ts', fmtTime(m.createdAt)));
|
|
|
|
|
+ var td = el('td'); td.appendChild(statusPill(m.status)); tr.appendChild(td);
|
|
|
|
|
+ tr.appendChild(el('td', null, m.reason || '—'));
|
|
|
|
|
+ tr.appendChild(el('td', 'num mono', m.conversationId));
|
|
|
|
|
+ tr.appendChild(el('td', 'mono', shorten(m.messageId, 28)));
|
|
|
|
|
+ tr.appendChild(el('td', null, m.source));
|
|
|
|
|
+ tr.appendChild(el('td', 'ts', fmtTime(m.updatedAt)));
|
|
|
|
|
+ return tr;
|
|
|
|
|
+ }
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ tickets: {
|
|
|
|
|
+ title: 'Tickety / handoff',
|
|
|
|
|
+ filters: ['conv', 'limit'],
|
|
|
|
|
+ load: function (f) { return api('/admin/tickets', { conversationId: f.conv, limit: f.limit }); },
|
|
|
|
|
+ render: function (d) {
|
|
|
|
|
+ return table(
|
|
|
|
|
+ [{ label: 'Utworzono' }, { label: 'Numer ticketu' }, { label: 'conversationId', num: true },
|
|
|
|
|
+ { label: 'Powód' }, { label: 'Ostatni event' }, { label: 'Kiedy' }, { label: 'Pominiętych od', num: true }],
|
|
|
|
|
+ d.tickets,
|
|
|
|
|
+ function (t) {
|
|
|
|
|
+ var tr = el('tr');
|
|
|
|
|
+ tr.appendChild(el('td', 'ts', fmtTime(t.createdAt)));
|
|
|
|
|
+ tr.appendChild(el('td', 'mono', t.ticketNumber));
|
|
|
|
|
+ tr.appendChild(el('td', 'num mono', t.conversationId));
|
|
|
|
|
+ tr.appendChild(el('td', 'meta', shorten(t.reason, 90)));
|
|
|
|
|
+ var td = el('td');
|
|
|
|
|
+ if (t.lastEventType) td.appendChild(eventPill(t.lastEventType)); else td.textContent = '—';
|
|
|
|
|
+ tr.appendChild(td);
|
|
|
|
|
+ tr.appendChild(el('td', 'ts', fmtTime(t.lastEventAt)));
|
|
|
|
|
+ tr.appendChild(el('td', 'num mono', t.messagesSkippedSince));
|
|
|
|
|
+ return tr;
|
|
|
|
|
+ }
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ ready: {
|
|
|
|
|
+ title: 'Zależności (/ready)',
|
|
|
|
|
+ filters: [],
|
|
|
|
|
+ load: function () { return fetch('/ready', { cache: 'no-store' }).then(function (r) { return r.json(); }); },
|
|
|
|
|
+ render: function (d) {
|
|
|
|
|
+ var deps = d.dependencies || {};
|
|
|
|
|
+ var rows = Object.keys(deps).map(function (k) { return [k, deps[k]]; });
|
|
|
|
|
+ return table(
|
|
|
|
|
+ [{ label: 'Zależność' }, { label: 'Stan' }, { label: 'HTTP', num: true }, { label: 'Cel' }],
|
|
|
|
|
+ rows,
|
|
|
|
|
+ function (pair) {
|
|
|
|
|
+ var tr = el('tr');
|
|
|
|
|
+ tr.appendChild(el('td', null, pair[0]));
|
|
|
|
|
+ var td = el('td');
|
|
|
|
|
+ td.appendChild(el('span', 'pill ' + (pair[1].ok ? 'ok' : 'bad'), pair[1].ok ? 'ok' : 'błąd'));
|
|
|
|
|
+ tr.appendChild(td);
|
|
|
|
|
+ tr.appendChild(el('td', 'num mono', pair[1].status));
|
|
|
|
|
+ tr.appendChild(el('td', 'mono', pair[1].target));
|
|
|
|
|
+ return tr;
|
|
|
|
|
+ }
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // -------------------------------------------------------------------- state
|
|
|
|
|
+
|
|
|
|
|
+ function currentFilters() {
|
|
|
|
|
+ return {
|
|
|
|
|
+ conv: $('f-conv').value.trim(),
|
|
|
|
|
+ type: $('f-type').value,
|
|
|
|
|
+ status: $('f-status').value,
|
|
|
|
|
+ limit: $('f-limit').value
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function syncFilterVisibility() {
|
|
|
|
|
+ var v = VIEWS[tab];
|
|
|
|
|
+ $('f-conv').parentElement.hidden = v.filters.indexOf('conv') === -1;
|
|
|
|
|
+ $('f-type-wrap').hidden = v.filters.indexOf('type') === -1;
|
|
|
|
|
+ $('f-status-wrap').hidden = v.filters.indexOf('status') === -1;
|
|
|
|
|
+ $('f-limit').parentElement.hidden = v.filters.indexOf('limit') === -1;
|
|
|
|
|
+
|
|
|
|
|
+ var sel = $('f-status');
|
|
|
|
|
+ sel.innerHTML = '';
|
|
|
|
|
+ sel.appendChild(new Option('wszystkie', ''));
|
|
|
|
|
+ (v.statuses || []).forEach(function (s) { sel.appendChild(new Option(s, s)); });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function setContent(node) {
|
|
|
|
|
+ var c = $('content');
|
|
|
|
|
+ c.innerHTML = '';
|
|
|
|
|
+ c.appendChild(node);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function loadTab() {
|
|
|
|
|
+ var v = VIEWS[tab];
|
|
|
|
|
+ $('card-title').textContent = v.title;
|
|
|
|
|
+ setContent(el('div', 'empty', 'Ładowanie…'));
|
|
|
|
|
+ v.load(currentFilters()).then(function (d) {
|
|
|
|
|
+ setContent(v.render(d));
|
|
|
|
|
+ var n = d.count !== undefined ? d.count : (d.dependencies ? Object.keys(d.dependencies).length : 0);
|
|
|
|
|
+ $('card-count').textContent = n + ' rekordów';
|
|
|
|
|
+ $('updated').textContent = 'odświeżono ' + fmtTime(new Date().toISOString());
|
|
|
|
|
+ }).catch(function (err) {
|
|
|
|
|
+ if (err.message === 'unauthorized') return;
|
|
|
|
|
+ setContent(el('div', 'error', 'Błąd pobierania: ' + err.message));
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function loadHeader() {
|
|
|
|
|
+ fetch('/health', { cache: 'no-store' })
|
|
|
|
|
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(new Error(r.status)); })
|
|
|
|
|
+ .then(function (d) {
|
|
|
|
|
+ $('health-dot').className = 'dot ok';
|
|
|
|
|
+ $('health-text').textContent = 'health ' + d.uptimeSeconds + 's';
|
|
|
|
|
+ $('mode-badge').textContent = 'tryb: ' + d.mode + ' · v' + d.version;
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch(function () { $('health-dot').className = 'dot bad'; $('health-text').textContent = 'health błąd'; });
|
|
|
|
|
+
|
|
|
|
|
+ fetch('/ready', { cache: 'no-store' })
|
|
|
|
|
+ .then(function (r) { return r.json(); })
|
|
|
|
|
+ .then(function (d) {
|
|
|
|
|
+ $('ready-dot').className = 'dot ' + (d.ok ? 'ok' : 'bad');
|
|
|
|
|
+ var bad = Object.keys(d.dependencies || {}).filter(function (k) { return !d.dependencies[k].ok; });
|
|
|
|
|
+ $('ready-text').textContent = bad.length ? 'ready: ' + bad.join(', ') : 'ready ok';
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch(function () { $('ready-dot').className = 'dot bad'; $('ready-text').textContent = 'ready błąd'; });
|
|
|
|
|
+
|
|
|
|
|
+ api('/admin/meta').then(function (d) {
|
|
|
|
|
+ var sel = $('f-type'), keep = sel.value;
|
|
|
|
|
+ sel.innerHTML = '';
|
|
|
|
|
+ sel.appendChild(new Option('wszystkie', ''));
|
|
|
|
|
+ (d.eventTypes || []).forEach(function (t) { sel.appendChild(new Option(t, t)); });
|
|
|
|
|
+ sel.value = keep;
|
|
|
|
|
+
|
|
|
|
|
+ var s = $('stats');
|
|
|
|
|
+ s.innerHTML = '';
|
|
|
|
|
+ var jobs = d.counts.jobs || {};
|
|
|
|
|
+ [['Eventy', d.counts.auditEvents], ['Tickety', d.counts.tickets],
|
|
|
|
|
+ ['W kolejce', jobs.queued || 0], ['Wykonane', jobs.done || 0], ['Dead', jobs.dead || 0]]
|
|
|
|
|
+ .forEach(function (pair) {
|
|
|
|
|
+ var box = el('div', 'stat');
|
|
|
|
|
+ box.appendChild(el('div', 'k', pair[0]));
|
|
|
|
|
+ var v = el('div', 'v', pair[1]);
|
|
|
|
|
+ if (pair[0] === 'Dead' && pair[1] > 0) v.style.color = 'var(--bad)';
|
|
|
|
|
+ box.appendChild(v);
|
|
|
|
|
+ s.appendChild(box);
|
|
|
|
|
+ });
|
|
|
|
|
+ var st = d.settings || {};
|
|
|
|
|
+ var box = el('div', 'stat');
|
|
|
|
|
+ box.appendChild(el('div', 'k', 'Ustawienia'));
|
|
|
|
|
+ box.appendChild(el('div', 'meta',
|
|
|
|
|
+ 'label=' + st.ticketLabel + ' · unassign=' + st.unassignOnTicket +
|
|
|
|
|
+ ' · spamGate=' + st.spamGate + ' · worker=' + st.worker + ' · logPii=' + st.logPii));
|
|
|
|
|
+ s.appendChild(box);
|
|
|
|
|
+ }).catch(function () { /* handled by api() */ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function refresh() { loadHeader(); loadTab(); }
|
|
|
|
|
+
|
|
|
|
|
+ function startAuto() { stopAuto(); timer = setInterval(refresh, 15000); }
|
|
|
|
|
+ function stopAuto() { if (timer) { clearInterval(timer); timer = null; } }
|
|
|
|
|
+
|
|
|
|
|
+ function enterApp() {
|
|
|
|
|
+ $('gate').hidden = true;
|
|
|
|
|
+ $('app').hidden = false;
|
|
|
|
|
+ syncFilterVisibility();
|
|
|
|
|
+ refresh();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------- wiring
|
|
|
|
|
+
|
|
|
|
|
+ $('gate-form').addEventListener('submit', function (e) {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ var value = $('token-input').value.trim();
|
|
|
|
|
+ if (!value) return;
|
|
|
|
|
+ sessionStorage.setItem(KEY, value);
|
|
|
|
|
+ $('token-input').value = '';
|
|
|
|
|
+ $('gate-err').style.display = 'none';
|
|
|
|
|
+ // Probe with a real request so a wrong token fails at the gate, not later.
|
|
|
|
|
+ api('/admin/meta').then(enterApp).catch(function (err) {
|
|
|
|
|
+ if (err.message !== 'unauthorized') {
|
|
|
|
|
+ $('gate-err').textContent = 'Błąd połączenia: ' + err.message;
|
|
|
|
|
+ $('gate-err').style.display = 'block';
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ $('tabs').addEventListener('click', function (e) {
|
|
|
|
|
+ var btn = e.target.closest('button[data-tab]');
|
|
|
|
|
+ if (!btn) return;
|
|
|
|
|
+ Array.prototype.forEach.call($('tabs').children, function (b) { b.classList.remove('active'); });
|
|
|
|
|
+ btn.classList.add('active');
|
|
|
|
|
+ tab = btn.dataset.tab;
|
|
|
|
|
+ syncFilterVisibility();
|
|
|
|
|
+ loadTab();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ $('apply').addEventListener('click', loadTab);
|
|
|
|
|
+ $('f-conv').addEventListener('keydown', function (e) { if (e.key === 'Enter') loadTab(); });
|
|
|
|
|
+ $('refresh').addEventListener('click', refresh);
|
|
|
|
|
+ $('logout').addEventListener('click', function () { logout(); });
|
|
|
|
|
+ $('autorefresh').addEventListener('change', function (e) { e.target.checked ? startAuto() : stopAuto(); });
|
|
|
|
|
+
|
|
|
|
|
+ if (token()) enterApp(); else $('token-input').focus();
|
|
|
|
|
+})();
|
|
|
|
|
+</script>
|
|
|
|
|
+</body>
|
|
|
|
|
+</html>
|