5 Commits
v0.1.0 ... main

Author SHA1 Message Date
LogWatch
02968c6288 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>
2026-03-23 17:02:11 +01:00
LogWatch
42534e0894 fix: use (connect, None) timeout for streaming connection
Some checks failed
Build & Push Docker Image / build (push) Failing after 6s
A read timeout of 90s caused unnecessary reconnects during idle periods.
(10, None) = 10s connect timeout, no read timeout on the open stream.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 09:10:34 +01:00
LogWatch
2e4c7eed58 refactor: use env var substitution for Portainer Repository stack
Some checks failed
Build & Push Docker Image / build (push) Failing after 7s
Variables now use \${VAR:-default} syntax so they can be set
via Portainer's environment editor without modifying the compose file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 09:05:26 +01:00
LogWatch
df15adc572 feat: Gitea Actions CI + env file + Gitea registry image
Some checks failed
Build & Push Docker Image / build (push) Failing after 1m50s
- .gitea/workflows/docker.yml: builds and pushes image to
  git.albert-zangerl.com/al/logwatch on every push to main
  and on version tags (v*)
- stack.env.example: template for environment variables
- stack.env: gitignored, lives on docker.lan next to the stack
- portainer-stack.yml: updated to use Gitea registry image
  and env_file reference

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 08:52:00 +01:00
LogWatch
ab640de08f docs: add Portainer stack YAML
For deployment on docker.lan via Portainer Web Editor.
Requires image built locally: docker build -t logwatch:latest .

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 08:49:22 +01:00
10 changed files with 1019 additions and 11 deletions

View File

@@ -0,0 +1,40 @@
name: Build & Push Docker Image
on:
push:
branches:
- main
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: git.albert-zangerl.com
username: ${{ gitea.actor }}
password: ${{ secrets.GITEA_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: git.albert-zangerl.com/al/logwatch
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

1
.gitignore vendored
View File

@@ -3,4 +3,5 @@ __pycache__/
*.pyo
.env
.env.local
stack.env
*.log

View File

@@ -5,12 +5,18 @@ WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY filter.py .
COPY app.py filter.py ./
COPY static/ static/
VOLUME ["/data"]
ENV SOURCE_URL=http://192.168.123.77/albert
ENV TARGET_URL=
ENV TARGET_TOKEN=
ENV LOG_LEVEL=INFO
ENV RECONNECT_DELAY=10
ENV DB_PATH=/data/logwatch.db
CMD ["python", "-u", "filter.py"]
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

229
app.py Normal file
View File

@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""
logwatch unified app
- background thread: subscribes to ntfy, filters, stores, forwards
- FastAPI: REST API + static file serving
"""
import json
import logging
import os
import re
import sqlite3
import threading
import time
from contextlib import asynccontextmanager
from pathlib import Path
import requests
from fastapi import FastAPI
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
# ── Config ────────────────────────────────────────────────
SOURCE_URL = os.environ.get("SOURCE_URL", "http://192.168.123.77/albert")
TARGET_URL = os.environ.get("TARGET_URL", "")
TARGET_TOKEN = os.environ.get("TARGET_TOKEN", "")
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
RECONNECT_DELAY = int(os.environ.get("RECONNECT_DELAY", "10"))
DB_PATH = os.environ.get("DB_PATH", "/data/logwatch.db")
logging.basicConfig(
level=getattr(logging, LOG_LEVEL, logging.INFO),
format="%(asctime)s %(levelname)-7s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger("logwatch")
# ── Database ──────────────────────────────────────────────
def get_db():
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
conn.row_factory = sqlite3.Row
return conn
def init_db():
db = get_db()
db.execute("""
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ntfy_id TEXT,
received_at TEXT NOT NULL,
ntfy_time INTEGER,
topic TEXT,
title TEXT,
message TEXT,
priority INTEGER,
tags TEXT,
forwarded INTEGER DEFAULT 0
)
""")
db.commit()
db.close()
_db_lock = threading.Lock()
def store_message(event: dict, forwarded: bool) -> None:
received_at = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime())
tags = json.dumps(event.get("tags", []))
with _db_lock:
db = get_db()
db.execute("""
INSERT INTO messages
(ntfy_id, received_at, ntfy_time, topic, title, message, priority, tags, forwarded)
VALUES (?,?,?,?,?,?,?,?,?)
""", (
event.get("id"),
received_at,
event.get("time"),
event.get("topic"),
event.get("title", ""),
event.get("message", ""),
event.get("priority", 3),
tags,
1 if forwarded else 0,
))
db.commit()
db.close()
# ── Filter logic ──────────────────────────────────────────
IMPORTANT_PATTERNS = [re.compile(r, re.IGNORECASE) for r in [
r"updated?\s+\d+",
r"\d+\s+updated",
r"fail(ed|ure)?",
r"error",
r"could not",
r"unable to",
r"pull(ed)?\s+\w+",
r"restart(ed|ing)?",
r"stopped",
r"warn(ing)?",
]]
NOISE_PATTERNS = [re.compile(r, re.IGNORECASE) for r in [
r"no\s+(new\s+)?updates?\s+(found|available)",
r"checked\s+\d+\s+container",
r"session\s+done",
r"^watchtower\s+started",
r"scanning\s+for\s+updates",
r"0\s+updated.*0\s+failed",
]]
def is_important(title: str, message: str) -> bool:
text = f"{title} {message}"
for p in NOISE_PATTERNS:
if p.search(text):
return False
for p in IMPORTANT_PATTERNS:
if p.search(text):
return True
return False
def classify(title: str, message: str) -> tuple[str, list[str]]:
text = f"{title} {message}".lower()
if re.search(r"fail|error|could not|unable", text):
return "high", ["rotating_light", "watchtower"]
if re.search(r"updated|pulled|restart", text):
return "default", ["arrow_up", "watchtower"]
return "low", ["watchtower"]
def forward(title: str, message: str, priority: str, tags: list[str]) -> None:
if not TARGET_URL:
return
headers = {"Title": title, "Priority": priority, "Tags": ",".join(tags)}
if TARGET_TOKEN:
headers["Authorization"] = f"Bearer {TARGET_TOKEN}"
try:
resp = requests.post(TARGET_URL, data=message.encode(), headers=headers, timeout=10)
resp.raise_for_status()
log.info("FORWARDED | [%s] %s", title, message[:100])
except requests.RequestException as e:
log.error("Forward failed: %s", e)
# ── Subscriber thread ─────────────────────────────────────
def listen_once():
sse_url = SOURCE_URL.rstrip("/") + "/json"
log.info("Connecting to %s", sse_url)
with requests.get(sse_url, stream=True, timeout=(10, None)) as resp:
resp.raise_for_status()
for raw_line in resp.iter_lines():
if not raw_line:
continue
try:
event = json.loads(raw_line)
except json.JSONDecodeError:
continue
if event.get("event", "message") != "message":
continue
title = event.get("title", "")
message = event.get("message", "")
if title and "watchtower" not in title.lower():
log.debug("SKIP (not watchtower) | [%s]", title)
continue
log.debug("RECEIVED | [%s] %s", title, message[:120])
important = is_important(title, message)
store_message(event, forwarded=important)
if important:
priority, tags = classify(title, message)
forward(title, message, priority, tags)
else:
log.debug("FILTERED | [%s] %s", title, message[:80])
def subscriber_loop():
log.info("Subscriber started source: %s target: %s",
SOURCE_URL, TARGET_URL or "(dry-run)")
while True:
try:
listen_once()
log.warning("Stream ended, reconnecting in %ds…", RECONNECT_DELAY)
except requests.RequestException as e:
log.error("Connection error: %s retry in %ds", e, RECONNECT_DELAY)
time.sleep(RECONNECT_DELAY)
# ── FastAPI ───────────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
t = threading.Thread(target=subscriber_loop, daemon=True, name="subscriber")
t.start()
yield
app = FastAPI(title="logwatch", lifespan=lifespan)
@app.get("/api/messages")
def api_messages():
with _db_lock:
db = get_db()
rows = db.execute(
"SELECT * FROM messages ORDER BY id DESC LIMIT 1000"
).fetchall()
db.close()
result = []
for r in rows:
result.append({
"id": r["id"],
"ntfy_id": r["ntfy_id"],
"received_at": r["received_at"],
"ntfy_time": r["ntfy_time"],
"topic": r["topic"],
"title": r["title"],
"message": r["message"],
"priority": r["priority"],
"tags": json.loads(r["tags"] or "[]"),
"forwarded": bool(r["forwarded"]),
})
return JSONResponse(result)
@app.get("/api/health")
def health():
return {"status": "ok"}
# static files
STATIC_DIR = Path(__file__).parent / "static"
app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static")

View File

@@ -4,12 +4,17 @@ services:
image: logwatch:latest
container_name: logwatch
restart: unless-stopped
ports:
- "${PORT:-8000}:8000"
volumes:
- logwatch_data:/data
environment:
# ntfy source channel (your Watchtower notifications)
- SOURCE_URL=http://192.168.123.77/albert
# ntfy target channel (where important stuff goes)
- TARGET_URL=http://192.168.123.77/wichtig
# optional: Bearer token if target channel needs auth
# - TARGET_TOKEN=your-token-here
- LOG_LEVEL=INFO
- RECONNECT_DELAY=10
- SOURCE_URL=${SOURCE_URL:-https://ntfy.albert-zangerl.com/albert}
- TARGET_URL=${TARGET_URL:-https://ntfy.albert-zangerl.com/wichtig}
- TARGET_TOKEN=${TARGET_TOKEN:-}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- RECONNECT_DELAY=${RECONNECT_DELAY:-10}
- DB_PATH=/data/logwatch.db
volumes:
logwatch_data:

View File

@@ -118,7 +118,7 @@ def listen_once():
sse_url = SOURCE_URL.rstrip("/") + "/json"
log.info("Connecting to %s", sse_url)
with requests.get(sse_url, stream=True, timeout=90) as resp:
with requests.get(sse_url, stream=True, timeout=(10, None)) as resp:
resp.raise_for_status()
for raw_line in resp.iter_lines():
if not raw_line:

19
portainer-stack.yml Normal file
View File

@@ -0,0 +1,19 @@
services:
logwatch:
image: git.albert-zangerl.com/al/logwatch:latest
container_name: logwatch
restart: unless-stopped
ports:
- "8000:8000"
volumes:
- logwatch_data:/data
environment:
- SOURCE_URL=https://ntfy.albert-zangerl.com/albert
- TARGET_URL=https://ntfy.albert-zangerl.com/wichtig
# - TARGET_TOKEN=
- LOG_LEVEL=INFO
- RECONNECT_DELAY=10
- DB_PATH=/data/logwatch.db
volumes:
logwatch_data:

View File

@@ -1 +1,3 @@
requests>=2.31.0
fastapi>=0.110.0
uvicorn>=0.29.0

12
stack.env.example Normal file
View File

@@ -0,0 +1,12 @@
# ntfy source channel (Watchtower notifications)
SOURCE_URL=https://ntfy.albert-zangerl.com/albert
# ntfy target channel (important notifications only)
TARGET_URL=https://ntfy.albert-zangerl.com/wichtig
# Optional: Bearer token for protected ntfy target channel
# TARGET_TOKEN=
LOG_LEVEL=INFO
RECONNECT_DELAY=10
PORT=8000

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>