feat: web UI + SQLite storage + FastAPI backend
- 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:
10
Dockerfile
10
Dockerfile
@@ -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
229
app.py
Normal 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")
|
||||
@@ -4,9 +4,17 @@ services:
|
||||
image: logwatch:latest
|
||||
container_name: logwatch
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-8000}:8000"
|
||||
volumes:
|
||||
- logwatch_data:/data
|
||||
environment:
|
||||
- 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:
|
||||
|
||||
@@ -3,5 +3,17 @@ services:
|
||||
image: git.albert-zangerl.com/al/logwatch:latest
|
||||
container_name: logwatch
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- stack.env
|
||||
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:
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
requests>=2.31.0
|
||||
fastapi>=0.110.0
|
||||
uvicorn>=0.29.0
|
||||
|
||||
@@ -9,3 +9,4 @@ TARGET_URL=https://ntfy.albert-zangerl.com/wichtig
|
||||
|
||||
LOG_LEVEL=INFO
|
||||
RECONNECT_DELAY=10
|
||||
PORT=8000
|
||||
|
||||
694
static/index.html
Normal file
694
static/index.html
Normal 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,'&').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>
|
||||
Reference in New Issue
Block a user