Some checks failed
Build & Push Docker Image / build (push) Failing after 6s
- app.py: unified app – ntfy subscriber runs as background thread, FastAPI serves REST API (/api/messages) and static frontend - static/index.html: terminal-style notification table with sortable columns, drag & drop column reorder, column visibility toggle (localStorage), text search, forwarded-only filter, auto-refresh every 30s - All ntfy fields stored: id, received_at, ntfy_time, topic, title, message, priority, tags, forwarded - /data volume for persistent SQLite DB - Port 8000 exposed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
695 lines
21 KiB
HTML
695 lines
21 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>logwatch</title>
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;600&family=IBM+Plex+Sans:wght@300;400;500&display=swap');
|
|
|
|
:root {
|
|
--bg0: #0a0c0f;
|
|
--bg1: #0f1217;
|
|
--bg2: #161b22;
|
|
--bg3: #1e252f;
|
|
--border: #2a333f;
|
|
--border-hi: #3d4f63;
|
|
--amber: #e8a020;
|
|
--amber-dim: #7a5410;
|
|
--green: #3fb950;
|
|
--red: #f85149;
|
|
--blue: #58a6ff;
|
|
--muted: #4a5568;
|
|
--text: #c9d1d9;
|
|
--text-dim: #6b7280;
|
|
--text-hi: #f0f6fc;
|
|
--pill-bg: #21262d;
|
|
--row-hover: #161d27;
|
|
}
|
|
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
html, body {
|
|
height: 100%;
|
|
background: var(--bg0);
|
|
color: var(--text);
|
|
font-family: 'IBM Plex Sans', sans-serif;
|
|
font-size: 13px;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
/* ── Header ──────────────────────────────────────────────── */
|
|
header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 20px;
|
|
padding: 12px 20px;
|
|
background: var(--bg1);
|
|
border-bottom: 1px solid var(--border);
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 100;
|
|
}
|
|
|
|
.logo {
|
|
font-family: 'IBM Plex Mono', monospace;
|
|
font-weight: 600;
|
|
font-size: 16px;
|
|
color: var(--amber);
|
|
letter-spacing: 0.08em;
|
|
white-space: nowrap;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
.logo::before {
|
|
content: '▶';
|
|
font-size: 10px;
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.status-dot {
|
|
width: 7px; height: 7px;
|
|
border-radius: 50%;
|
|
background: var(--green);
|
|
box-shadow: 0 0 6px var(--green);
|
|
flex-shrink: 0;
|
|
animation: pulse 2.5s ease-in-out infinite;
|
|
}
|
|
.status-dot.error { background: var(--red); box-shadow: 0 0 6px var(--red); animation: none; }
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.4; }
|
|
}
|
|
|
|
.header-meta {
|
|
font-family: 'IBM Plex Mono', monospace;
|
|
font-size: 11px;
|
|
color: var(--text-dim);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.header-spacer { flex: 1; }
|
|
|
|
/* ── Toolbar ─────────────────────────────────────────────── */
|
|
.toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 8px 20px;
|
|
background: var(--bg1);
|
|
border-bottom: 1px solid var(--border);
|
|
position: sticky;
|
|
top: 45px;
|
|
z-index: 99;
|
|
}
|
|
|
|
.search-wrap {
|
|
position: relative;
|
|
flex: 1;
|
|
max-width: 380px;
|
|
}
|
|
.search-wrap::before {
|
|
content: '⌕';
|
|
position: absolute;
|
|
left: 9px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
color: var(--muted);
|
|
font-size: 15px;
|
|
pointer-events: none;
|
|
}
|
|
input[type=search] {
|
|
width: 100%;
|
|
padding: 5px 10px 5px 28px;
|
|
background: var(--bg2);
|
|
border: 1px solid var(--border);
|
|
color: var(--text);
|
|
font-family: 'IBM Plex Mono', monospace;
|
|
font-size: 12px;
|
|
outline: none;
|
|
transition: border-color .15s;
|
|
}
|
|
input[type=search]:focus { border-color: var(--amber-dim); }
|
|
input[type=search]::placeholder { color: var(--muted); }
|
|
|
|
.btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
padding: 5px 11px;
|
|
background: var(--bg2);
|
|
border: 1px solid var(--border);
|
|
color: var(--text-dim);
|
|
font-family: 'IBM Plex Mono', monospace;
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
transition: border-color .15s, color .15s, background .15s;
|
|
user-select: none;
|
|
}
|
|
.btn:hover { border-color: var(--border-hi); color: var(--text); }
|
|
.btn.active { border-color: var(--amber-dim); color: var(--amber); background: #1a1200; }
|
|
|
|
.count-badge {
|
|
font-family: 'IBM Plex Mono', monospace;
|
|
font-size: 11px;
|
|
color: var(--text-dim);
|
|
margin-left: auto;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* ── Column panel ────────────────────────────────────────── */
|
|
.col-panel-wrap { position: relative; }
|
|
|
|
.col-panel {
|
|
display: none;
|
|
position: absolute;
|
|
top: calc(100% + 6px);
|
|
right: 0;
|
|
background: var(--bg2);
|
|
border: 1px solid var(--border-hi);
|
|
padding: 8px 0;
|
|
min-width: 170px;
|
|
z-index: 200;
|
|
box-shadow: 0 8px 24px rgba(0,0,0,.5);
|
|
}
|
|
.col-panel.open { display: block; }
|
|
|
|
.col-panel-title {
|
|
padding: 2px 14px 8px;
|
|
font-size: 10px;
|
|
letter-spacing: .1em;
|
|
text-transform: uppercase;
|
|
color: var(--text-dim);
|
|
border-bottom: 1px solid var(--border);
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.col-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 5px 14px;
|
|
cursor: pointer;
|
|
transition: background .1s;
|
|
font-size: 12px;
|
|
}
|
|
.col-item:hover { background: var(--bg3); }
|
|
|
|
.col-item input[type=checkbox] {
|
|
accent-color: var(--amber);
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* ── Table wrapper ───────────────────────────────────────── */
|
|
.table-wrap {
|
|
overflow-x: auto;
|
|
overflow-y: auto;
|
|
height: calc(100vh - 92px);
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 12px;
|
|
}
|
|
|
|
/* ── Column headers ──────────────────────────────────────── */
|
|
thead {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 50;
|
|
}
|
|
|
|
th {
|
|
background: var(--bg2);
|
|
border-bottom: 1px solid var(--border);
|
|
border-right: 1px solid var(--border);
|
|
padding: 0;
|
|
white-space: nowrap;
|
|
user-select: none;
|
|
}
|
|
th:last-child { border-right: none; }
|
|
|
|
.th-inner {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
padding: 7px 10px;
|
|
cursor: pointer;
|
|
font-family: 'IBM Plex Mono', monospace;
|
|
font-size: 10px;
|
|
font-weight: 500;
|
|
letter-spacing: .08em;
|
|
text-transform: uppercase;
|
|
color: var(--text-dim);
|
|
transition: color .15s;
|
|
}
|
|
.th-inner:hover { color: var(--amber); }
|
|
|
|
th.sort-asc .th-inner,
|
|
th.sort-desc .th-inner { color: var(--amber); }
|
|
|
|
.sort-icon {
|
|
font-size: 9px;
|
|
opacity: 0;
|
|
transition: opacity .15s;
|
|
}
|
|
th.sort-asc .sort-icon { opacity: 1; content: '▲'; }
|
|
th.sort-desc .sort-icon { opacity: 1; }
|
|
|
|
.sort-icon::after {
|
|
content: '▲';
|
|
}
|
|
th.sort-desc .sort-icon::after { content: '▼'; }
|
|
|
|
/* drag handles */
|
|
th.drag-over { border-left: 2px solid var(--amber); }
|
|
|
|
/* ── Rows ────────────────────────────────────────────────── */
|
|
tbody tr {
|
|
border-bottom: 1px solid var(--border);
|
|
transition: background .1s;
|
|
}
|
|
tbody tr:hover { background: var(--row-hover); }
|
|
|
|
/* alternating very subtle tint */
|
|
tbody tr:nth-child(even) { background: #0d1117; }
|
|
tbody tr:nth-child(even):hover { background: var(--row-hover); }
|
|
|
|
td {
|
|
padding: 5px 10px;
|
|
vertical-align: middle;
|
|
border-right: 1px solid var(--border);
|
|
max-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
td:last-child { border-right: none; }
|
|
|
|
/* column-specific widths */
|
|
td.col-time { width: 140px; min-width: 120px; font-family: 'IBM Plex Mono', monospace; font-size: 11px; color: var(--text-dim); }
|
|
td.col-host { width: 120px; min-width: 80px; font-family: 'IBM Plex Mono', monospace; font-size: 11px; color: var(--blue); }
|
|
td.col-title { width: 140px; min-width: 100px; }
|
|
td.col-message { min-width: 200px; font-family: 'IBM Plex Mono', monospace; font-size: 11px; }
|
|
td.col-priority{ width: 60px; min-width: 50px; text-align: center; }
|
|
td.col-tags { width: 160px; min-width: 100px; }
|
|
td.col-fwd { width: 60px; min-width: 50px; text-align: center; }
|
|
|
|
/* priority icons */
|
|
.prio { font-size: 13px; }
|
|
.prio-5 { color: #f85149; } /* urgent */
|
|
.prio-4 { color: #e8a020; } /* high */
|
|
.prio-3 { color: var(--text-dim); }
|
|
.prio-2 { color: var(--muted); }
|
|
.prio-1 { color: var(--muted); opacity: .6; }
|
|
|
|
/* forwarded icon */
|
|
.fwd-yes { color: var(--green); font-size: 14px; }
|
|
.fwd-no { color: var(--muted); font-size: 12px; }
|
|
|
|
/* tags */
|
|
.tag-pill {
|
|
display: inline-block;
|
|
padding: 1px 6px;
|
|
background: var(--pill-bg);
|
|
border: 1px solid var(--border);
|
|
font-family: 'IBM Plex Mono', monospace;
|
|
font-size: 10px;
|
|
color: var(--text-dim);
|
|
margin: 1px 2px 1px 0;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
/* empty state */
|
|
.empty {
|
|
text-align: center;
|
|
padding: 60px 20px;
|
|
color: var(--muted);
|
|
font-family: 'IBM Plex Mono', monospace;
|
|
font-size: 12px;
|
|
}
|
|
.empty-icon { font-size: 32px; margin-bottom: 10px; opacity: .3; }
|
|
|
|
/* refresh indicator */
|
|
.refresh-bar {
|
|
position: fixed;
|
|
bottom: 0; left: 0; right: 0;
|
|
height: 2px;
|
|
background: var(--amber-dim);
|
|
transform-origin: left;
|
|
transform: scaleX(0);
|
|
transition: transform 30s linear;
|
|
}
|
|
.refresh-bar.running { transform: scaleX(1); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<header>
|
|
<span class="logo">logwatch</span>
|
|
<span class="status-dot" id="statusDot"></span>
|
|
<span class="header-meta" id="headerMeta">connecting…</span>
|
|
<span class="header-spacer"></span>
|
|
<span class="header-meta" id="nextRefresh"></span>
|
|
</header>
|
|
|
|
<div class="toolbar">
|
|
<div class="search-wrap">
|
|
<input type="search" id="searchInput" placeholder="search title, message, host…">
|
|
</div>
|
|
<button class="btn" id="fwdToggle">forwarded only</button>
|
|
<span class="count-badge" id="countBadge"></span>
|
|
<div class="col-panel-wrap">
|
|
<button class="btn" id="colBtn">⊞ columns</button>
|
|
<div class="col-panel" id="colPanel">
|
|
<div class="col-panel-title">show / hide</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="table-wrap" id="tableWrap">
|
|
<table id="mainTable">
|
|
<thead id="thead"></thead>
|
|
<tbody id="tbody"></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="refresh-bar" id="refreshBar"></div>
|
|
|
|
<script>
|
|
// ── Column definitions ────────────────────────────────────
|
|
const DEFAULT_COLS = [
|
|
{ key: 'time', label: 'Time', visible: true, sortable: true },
|
|
{ key: 'host', label: 'Host', visible: true, sortable: true },
|
|
{ key: 'title', label: 'Title', visible: true, sortable: true },
|
|
{ key: 'message', label: 'Message', visible: true, sortable: true },
|
|
{ key: 'priority', label: 'Priority', visible: true, sortable: true },
|
|
{ key: 'tags', label: 'Tags', visible: true, sortable: false },
|
|
{ key: 'fwd', label: 'Fwd', visible: true, sortable: true },
|
|
];
|
|
|
|
const LS_KEY_COLS = 'lw_columns';
|
|
const LS_KEY_SORT = 'lw_sort';
|
|
const REFRESH_SEC = 30;
|
|
|
|
// ── State ─────────────────────────────────────────────────
|
|
let cols = loadCols();
|
|
let allData = [];
|
|
let sortKey = null;
|
|
let sortDir = 'asc';
|
|
let filterText = '';
|
|
let fwdOnly = false;
|
|
let refreshTimer = null;
|
|
let countdownId = null;
|
|
|
|
// ── Persistence ───────────────────────────────────────────
|
|
function loadCols() {
|
|
try {
|
|
const saved = JSON.parse(localStorage.getItem(LS_KEY_COLS));
|
|
if (saved && Array.isArray(saved)) {
|
|
// merge: keep saved order/visibility, add new cols from default
|
|
const merged = saved.filter(s => DEFAULT_COLS.find(d => d.key === s.key))
|
|
.map(s => ({ ...DEFAULT_COLS.find(d => d.key === s.key), visible: s.visible }));
|
|
DEFAULT_COLS.forEach(d => { if (!merged.find(m => m.key === d.key)) merged.push({...d}); });
|
|
return merged;
|
|
}
|
|
} catch(e) {}
|
|
return DEFAULT_COLS.map(c => ({...c}));
|
|
}
|
|
function saveCols() {
|
|
localStorage.setItem(LS_KEY_COLS, JSON.stringify(cols.map(c => ({ key: c.key, visible: c.visible }))));
|
|
}
|
|
|
|
// ── Data helpers ──────────────────────────────────────────
|
|
function extractHost(msg) {
|
|
// patterns: "on docker.lan", "on server01", "@hostname"
|
|
const m = msg.match(/\bon\s+([\w][\w.-]+)/i) || msg.match(/@([\w][\w.-]+)/);
|
|
return m ? m[1] : null;
|
|
}
|
|
|
|
function formatTime(iso) {
|
|
if (!iso) return '—';
|
|
const d = new Date(iso);
|
|
const pad = n => String(n).padStart(2, '0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} `
|
|
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
}
|
|
|
|
const PRIO_MAP = {
|
|
5: { icon: '⬆⬆', label: 'urgent', cls: 'prio-5' },
|
|
4: { icon: '⬆', label: 'high', cls: 'prio-4' },
|
|
3: { icon: '—', label: 'default',cls: 'prio-3' },
|
|
2: { icon: '⬇', label: 'low', cls: 'prio-2' },
|
|
1: { icon: '⬇⬇', label: 'min', cls: 'prio-1' },
|
|
};
|
|
|
|
function cellValue(row, key) {
|
|
switch(key) {
|
|
case 'time': return row.received_at || '';
|
|
case 'host': return extractHost(row.message || '') || '—';
|
|
case 'title': return row.title || '';
|
|
case 'message': return row.message || '';
|
|
case 'priority': return row.priority ?? 3;
|
|
case 'tags': return (row.tags || []).join(', ');
|
|
case 'fwd': return row.forwarded ? 1 : 0;
|
|
}
|
|
}
|
|
|
|
function renderCell(row, key) {
|
|
switch(key) {
|
|
case 'time':
|
|
return `<td class="col-time">${formatTime(row.received_at)}</td>`;
|
|
case 'host': {
|
|
const h = extractHost(row.message || '');
|
|
return `<td class="col-host">${h || '<span style="color:var(--muted)">—</span>'}</td>`;
|
|
}
|
|
case 'title':
|
|
return `<td class="col-title">${esc(row.title || '')}</td>`;
|
|
case 'message':
|
|
return `<td class="col-message" title="${esc(row.message || '')}">${esc(row.message || '')}</td>`;
|
|
case 'priority': {
|
|
const p = PRIO_MAP[row.priority] || PRIO_MAP[3];
|
|
return `<td class="col-priority"><span class="prio ${p.cls}" title="${p.label}">${p.icon}</span></td>`;
|
|
}
|
|
case 'tags': {
|
|
const tags = (row.tags || []).filter(t => t !== 'watchtower');
|
|
return `<td class="col-tags">${tags.map(t => `<span class="tag-pill">${esc(t)}</span>`).join('')}</td>`;
|
|
}
|
|
case 'fwd':
|
|
return `<td class="col-fwd">${row.forwarded
|
|
? '<span class="fwd-yes" title="forwarded">✓</span>'
|
|
: '<span class="fwd-no" title="filtered">·</span>'}</td>`;
|
|
}
|
|
}
|
|
|
|
function esc(s) {
|
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
// ── Filtering & sorting ───────────────────────────────────
|
|
function applyFilters(data) {
|
|
let rows = data;
|
|
if (fwdOnly) rows = rows.filter(r => r.forwarded);
|
|
if (filterText) {
|
|
const q = filterText.toLowerCase();
|
|
rows = rows.filter(r =>
|
|
(r.title || '').toLowerCase().includes(q) ||
|
|
(r.message || '').toLowerCase().includes(q) ||
|
|
(extractHost(r.message || '') || '').toLowerCase().includes(q)
|
|
);
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function applySort(rows) {
|
|
if (!sortKey) return rows;
|
|
return [...rows].sort((a, b) => {
|
|
let va = cellValue(a, sortKey);
|
|
let vb = cellValue(b, sortKey);
|
|
if (typeof va === 'string') va = va.toLowerCase();
|
|
if (typeof vb === 'string') vb = vb.toLowerCase();
|
|
if (va < vb) return sortDir === 'asc' ? -1 : 1;
|
|
if (va > vb) return sortDir === 'asc' ? 1 : -1;
|
|
return 0;
|
|
});
|
|
}
|
|
|
|
// ── Rendering ─────────────────────────────────────────────
|
|
function renderHeader() {
|
|
const thead = document.getElementById('thead');
|
|
const visibleCols = cols.filter(c => c.visible);
|
|
thead.innerHTML = `<tr>${visibleCols.map(c => `
|
|
<th data-key="${c.key}"
|
|
class="${sortKey === c.key ? 'sort-' + sortDir : ''}"
|
|
draggable="true">
|
|
<div class="th-inner">
|
|
${esc(c.label)}
|
|
${c.sortable ? `<span class="sort-icon"></span>` : ''}
|
|
</div>
|
|
</th>`).join('')}</tr>`;
|
|
|
|
// sort click
|
|
thead.querySelectorAll('th').forEach(th => {
|
|
const key = th.dataset.key;
|
|
const colDef = cols.find(c => c.key === key);
|
|
if (!colDef?.sortable) return;
|
|
th.querySelector('.th-inner').addEventListener('click', () => {
|
|
if (sortKey === key) {
|
|
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
sortKey = key;
|
|
sortDir = 'asc';
|
|
}
|
|
renderHeader();
|
|
renderBody();
|
|
});
|
|
});
|
|
|
|
// drag & drop reorder
|
|
let dragSrc = null;
|
|
thead.querySelectorAll('th').forEach(th => {
|
|
th.addEventListener('dragstart', e => {
|
|
dragSrc = th.dataset.key;
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
});
|
|
th.addEventListener('dragover', e => {
|
|
e.preventDefault();
|
|
th.classList.add('drag-over');
|
|
});
|
|
th.addEventListener('dragleave', () => th.classList.remove('drag-over'));
|
|
th.addEventListener('drop', e => {
|
|
e.preventDefault();
|
|
th.classList.remove('drag-over');
|
|
const targetKey = th.dataset.key;
|
|
if (dragSrc && dragSrc !== targetKey) {
|
|
const srcIdx = cols.findIndex(c => c.key === dragSrc);
|
|
const tgtIdx = cols.findIndex(c => c.key === targetKey);
|
|
const [moved] = cols.splice(srcIdx, 1);
|
|
cols.splice(tgtIdx, 0, moved);
|
|
saveCols();
|
|
renderHeader();
|
|
renderBody();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderBody() {
|
|
const tbody = document.getElementById('tbody');
|
|
const visibleCols = cols.filter(c => c.visible);
|
|
const rows = applySort(applyFilters(allData));
|
|
|
|
document.getElementById('countBadge').textContent =
|
|
`${rows.length} / ${allData.length} messages`;
|
|
|
|
if (rows.length === 0) {
|
|
tbody.innerHTML = `<tr><td colspan="${visibleCols.length}" class="empty">
|
|
<div class="empty-icon">◈</div>
|
|
no messages match current filters
|
|
</td></tr>`;
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = rows.map(row =>
|
|
`<tr>${visibleCols.map(c => renderCell(row, c.key)).join('')}</tr>`
|
|
).join('');
|
|
}
|
|
|
|
function renderColPanel() {
|
|
const panel = document.getElementById('colPanel');
|
|
// remove existing items
|
|
panel.querySelectorAll('.col-item').forEach(el => el.remove());
|
|
cols.forEach(c => {
|
|
const item = document.createElement('label');
|
|
item.className = 'col-item';
|
|
item.innerHTML = `<input type="checkbox" ${c.visible ? 'checked' : ''}> ${esc(c.label)}`;
|
|
item.querySelector('input').addEventListener('change', e => {
|
|
c.visible = e.target.checked;
|
|
saveCols();
|
|
renderHeader();
|
|
renderBody();
|
|
});
|
|
panel.appendChild(item);
|
|
});
|
|
}
|
|
|
|
// ── Fetch ─────────────────────────────────────────────────
|
|
async function fetchData() {
|
|
const dot = document.getElementById('statusDot');
|
|
const meta = document.getElementById('headerMeta');
|
|
try {
|
|
const res = await fetch('/api/messages');
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
allData = await res.json();
|
|
dot.className = 'status-dot';
|
|
const now = new Date();
|
|
meta.textContent = `updated ${now.toTimeString().slice(0,8)} · ${allData.length} total`;
|
|
renderBody();
|
|
} catch(e) {
|
|
dot.className = 'status-dot error';
|
|
meta.textContent = `error: ${e.message}`;
|
|
}
|
|
}
|
|
|
|
// ── Auto-refresh ──────────────────────────────────────────
|
|
function startRefresh() {
|
|
const bar = document.getElementById('refreshBar');
|
|
const next = document.getElementById('nextRefresh');
|
|
|
|
function tick() {
|
|
// reset bar animation
|
|
bar.classList.remove('running');
|
|
void bar.offsetWidth; // reflow
|
|
bar.classList.add('running');
|
|
|
|
let remaining = REFRESH_SEC;
|
|
clearInterval(countdownId);
|
|
countdownId = setInterval(() => {
|
|
remaining--;
|
|
next.textContent = `refresh in ${remaining}s`;
|
|
if (remaining <= 0) clearInterval(countdownId);
|
|
}, 1000);
|
|
|
|
clearTimeout(refreshTimer);
|
|
refreshTimer = setTimeout(() => {
|
|
fetchData();
|
|
tick();
|
|
}, REFRESH_SEC * 1000);
|
|
}
|
|
|
|
tick();
|
|
}
|
|
|
|
// ── Event wiring ──────────────────────────────────────────
|
|
document.getElementById('searchInput').addEventListener('input', e => {
|
|
filterText = e.target.value.trim();
|
|
renderBody();
|
|
});
|
|
|
|
document.getElementById('fwdToggle').addEventListener('click', function() {
|
|
fwdOnly = !fwdOnly;
|
|
this.classList.toggle('active', fwdOnly);
|
|
renderBody();
|
|
});
|
|
|
|
const colBtn = document.getElementById('colBtn');
|
|
const colPanel = document.getElementById('colPanel');
|
|
colBtn.addEventListener('click', e => {
|
|
e.stopPropagation();
|
|
colPanel.classList.toggle('open');
|
|
if (colPanel.classList.contains('open')) renderColPanel();
|
|
});
|
|
document.addEventListener('click', () => colPanel.classList.remove('open'));
|
|
colPanel.addEventListener('click', e => e.stopPropagation());
|
|
|
|
// ── Init ──────────────────────────────────────────────────
|
|
renderHeader();
|
|
fetchData().then(startRefresh);
|
|
</script>
|
|
</body>
|
|
</html>
|