Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02968c6288 | ||
|
|
42534e0894 | ||
|
|
2e4c7eed58 | ||
|
|
df15adc572 | ||
|
|
ab640de08f |
40
.gitea/workflows/docker.yml
Normal file
40
.gitea/workflows/docker.yml
Normal 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
1
.gitignore
vendored
@@ -3,4 +3,5 @@ __pycache__/
|
|||||||
*.pyo
|
*.pyo
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
stack.env
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
10
Dockerfile
10
Dockerfile
@@ -5,12 +5,18 @@ WORKDIR /app
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r 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 SOURCE_URL=http://192.168.123.77/albert
|
||||||
ENV TARGET_URL=
|
ENV TARGET_URL=
|
||||||
ENV TARGET_TOKEN=
|
ENV TARGET_TOKEN=
|
||||||
ENV LOG_LEVEL=INFO
|
ENV LOG_LEVEL=INFO
|
||||||
ENV RECONNECT_DELAY=10
|
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,12 +4,17 @@ services:
|
|||||||
image: logwatch:latest
|
image: logwatch:latest
|
||||||
container_name: logwatch
|
container_name: logwatch
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${PORT:-8000}:8000"
|
||||||
|
volumes:
|
||||||
|
- logwatch_data:/data
|
||||||
environment:
|
environment:
|
||||||
# ntfy source channel (your Watchtower notifications)
|
- SOURCE_URL=${SOURCE_URL:-https://ntfy.albert-zangerl.com/albert}
|
||||||
- SOURCE_URL=http://192.168.123.77/albert
|
- TARGET_URL=${TARGET_URL:-https://ntfy.albert-zangerl.com/wichtig}
|
||||||
# ntfy target channel (where important stuff goes)
|
- TARGET_TOKEN=${TARGET_TOKEN:-}
|
||||||
- TARGET_URL=http://192.168.123.77/wichtig
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
# optional: Bearer token if target channel needs auth
|
- RECONNECT_DELAY=${RECONNECT_DELAY:-10}
|
||||||
# - TARGET_TOKEN=your-token-here
|
- DB_PATH=/data/logwatch.db
|
||||||
- LOG_LEVEL=INFO
|
|
||||||
- RECONNECT_DELAY=10
|
volumes:
|
||||||
|
logwatch_data:
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ def listen_once():
|
|||||||
sse_url = SOURCE_URL.rstrip("/") + "/json"
|
sse_url = SOURCE_URL.rstrip("/") + "/json"
|
||||||
log.info("Connecting to %s", sse_url)
|
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()
|
resp.raise_for_status()
|
||||||
for raw_line in resp.iter_lines():
|
for raw_line in resp.iter_lines():
|
||||||
if not raw_line:
|
if not raw_line:
|
||||||
|
|||||||
19
portainer-stack.yml
Normal file
19
portainer-stack.yml
Normal 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:
|
||||||
@@ -1 +1,3 @@
|
|||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
|
fastapi>=0.110.0
|
||||||
|
uvicorn>=0.29.0
|
||||||
|
|||||||
12
stack.env.example
Normal file
12
stack.env.example
Normal 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
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