feat: web UI + SQLite storage + FastAPI backend
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>
This commit is contained in:
LogWatch
2026-03-23 17:02:11 +01:00
parent 42534e0894
commit 02968c6288
7 changed files with 956 additions and 4 deletions

694
static/index.html Normal file
View File

@@ -0,0 +1,694 @@
<!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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── 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>