push changes

This commit is contained in:
ponzischeme89
2026-04-18 07:15:39 +12:00
parent 51c23d10e7
commit 3d547deb99
6 changed files with 5326 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(python -c ' *)"
]
}
}
Binary file not shown.
+555
View File
@@ -0,0 +1,555 @@
"""
CaddyBuddy — Caddy Log Dashboard
Reads a Caddy JSON access log and surfaces useful insights.
Run: python app.py
Open: http://127.0.0.1:5000
Optional Emby API integration (resolves device IDs to usernames):
EMBY_URL=http://localhost:8096 EMBY_KEY=<admin_api_key> python app.py
"""
import gzip
import glob
import io
import json
import re
import os
import tarfile
from collections import Counter, defaultdict
from datetime import datetime, timezone
from urllib.parse import unquote_plus, parse_qs, urlparse
from flask import Flask, render_template, jsonify
# Point CADDY_LOG at a single file OR a directory.
# When it's a directory, CaddyBuddy discovers every .json / .log / .gz / .tar.gz
# inside it automatically.
LOG_PATH = os.environ.get("CADDY_LOG", r"C:\Caddy")
EMBY_URL = os.environ.get("EMBY_URL", "http://10.0.0.2:8096")
EMBY_KEY = os.environ.get("EMBY_KEY", "b9af54b630f6448289ab96422add567a")
app = Flask(__name__)
# ---------------------------------------------------------------------------
# Header / URI parsing helpers
# ---------------------------------------------------------------------------
def _first(headers, key):
"""Caddy stores header values as lists; return the first non-empty value."""
if not headers:
return None
vals = headers.get(key)
if isinstance(vals, list):
return next((v for v in vals if v), None)
return vals or None
# Emby auth-header regexes
# Header format: MediaBrowser Client="x", Device="y", DeviceId="z", Version="v", Token="t"
_RE = re.IGNORECASE
EMBY_CLIENT_RE = re.compile(r'Client="?([^,"\n]+)"?', _RE)
EMBY_VERSION_RE = re.compile(r'Version="?([0-9][^,"\s]*)"?', _RE)
EMBY_DEVICE_RE = re.compile(r'Device(?!Id)"?[=\s]"?([^,"\n]+)"?', _RE)
EMBY_DEVICE_ID_RE = re.compile(r'DeviceId"?[=\s]"?([^,"\s&]+)"?', _RE)
EMBY_TOKEN_RE = re.compile(r'Token"?[=\s]"?([a-fA-F0-9]{24,})"?', _RE)
def _parse_auth_header(val):
"""
Parse a MediaBrowser/Emby authorization header value.
Returns (client, version, device, device_id, token).
"""
if not val:
return None, None, None, None, None
def _g(m):
return m.group(1).strip() if m else None
client = _g(EMBY_CLIENT_RE.search(val))
version = _g(EMBY_VERSION_RE.search(val))
device = _g(EMBY_DEVICE_RE.search(val))
device_id = _g(EMBY_DEVICE_ID_RE.search(val))
token = _g(EMBY_TOKEN_RE.search(val))
# Device names are sometimes URL-encoded in headers
if device:
device = unquote_plus(device)
return client, version, device, device_id, token
def parse_query_string(uri):
"""Return a flat {key: first_value} dict from a URI's query string."""
if not uri or "?" not in uri:
return {}
try:
qs = parse_qs(urlparse(uri).query, keep_blank_values=False)
return {k: v[0] for k, v in qs.items() if v}
except Exception:
return {}
def classify_emby(entry):
"""
Best-effort identification of an Emby client from a log entry.
Returns (client, version, device, device_id, token).
All fields may be None if this is not an Emby request.
"""
req = entry.get("request", {}) or {}
headers = req.get("headers", {}) or {}
uri = req.get("uri", "")
# 1. Try X-Emby-Authorization header (most complete source)
auth_val = _first(headers, "X-Emby-Authorization")
client, version, device, device_id, token = _parse_auth_header(auth_val)
# 2. Try individual X-Emby-* headers (some clients send these instead)
if not client:
client = _first(headers, "X-Emby-Client")
if not version:
version = _first(headers, "X-Emby-Client-Version")
if not device:
raw = _first(headers, "X-Emby-Device-Name")
device = unquote_plus(raw) if raw else None
if not device_id:
device_id = _first(headers, "X-Emby-Device-Id")
if not token:
token = _first(headers, "X-Emby-Token") or _first(headers, "X-MediaBrowser-Token")
# 3. Fall back to query-string params (streaming URLs embed them)
if not any([client, version, device, device_id]):
qs = parse_query_string(uri)
if not client and "X-Emby-Client" in qs:
client = unquote_plus(qs["X-Emby-Client"])
if not version:
version = qs.get("X-Emby-Client-Version")
if not device and "X-Emby-Device-Name" in qs:
device = unquote_plus(qs["X-Emby-Device-Name"])
if not device_id:
device_id = qs.get("X-Emby-Device-Id")
if not token:
token = qs.get("X-Emby-Token")
if not any([client, version, device, device_id]):
return None, None, None, None, None
return client, version, device, device_id, token
# ---------------------------------------------------------------------------
# Emby API integration (optional)
# ---------------------------------------------------------------------------
def fetch_emby_device_users():
"""
Call the Emby /Devices endpoint and return {device_id: last_user_name}.
Returns an empty dict if the API is not configured or unreachable.
"""
if not EMBY_URL or not EMBY_KEY:
return {}
try:
import urllib.request
url = f"{EMBY_URL.rstrip('/')}/emby/Devices?api_key={EMBY_KEY}"
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=4) as resp:
data = json.loads(resp.read())
result = {}
for item in data.get("Items", []):
did = item.get("Id") or item.get("DeviceId")
user = item.get("LastUserName") or item.get("UserName")
if did and user:
result[did] = user
return result
except Exception:
return {}
# ---------------------------------------------------------------------------
# Log ingestion — file discovery + multi-format streaming
# ---------------------------------------------------------------------------
# Filename suffixes we recognise, in priority order (most-specific first so
# .tar.gz isn't accidentally matched by the plain .gz rule).
_LOG_GLOBS = ["*.tar.gz", "*.tgz", "*.json.gz", "*.log.gz", "*.gz", "*.json", "*.log"]
def find_log_files(path):
"""
Given a file path or a directory, return a sorted list of log file paths.
Files are sorted oldest-first by modification time so merged data is in
chronological order.
"""
if os.path.isfile(path):
return [path]
if not os.path.isdir(path):
return []
found = set()
for pattern in _LOG_GLOBS:
found.update(glob.glob(os.path.join(path, pattern)))
# one level of sub-directories (e.g. logs/2024/)
found.update(glob.glob(os.path.join(path, "*", pattern)))
return sorted(found, key=os.path.getmtime)
def _iter_lines(filepath):
"""
Yield raw text lines from a log file regardless of compression format.
Handles: plain text, .gz, .tar.gz / .tgz (any members inside the archive).
"""
name = os.path.basename(filepath).lower()
if name.endswith(".tar.gz") or name.endswith(".tgz"):
with tarfile.open(filepath, "r:gz") as tar:
for member in tar.getmembers():
if not member.isfile():
continue
fobj = tar.extractfile(member)
if fobj is None:
continue
# Member itself might be gzip-compressed
raw = fobj.read()
if raw[:2] == b"\x1f\x8b":
raw = gzip.decompress(raw)
yield from io.TextIOWrapper(io.BytesIO(raw), encoding="utf-8", errors="replace")
elif name.endswith(".gz"):
with gzip.open(filepath, "rt", encoding="utf-8", errors="replace") as f:
yield from f
else:
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
yield from f
def _parse_line(raw):
"""Parse one JSON log line into an entry dict, or return None."""
raw = raw.strip()
if not raw or raw[0] != "{":
return None
try:
rec = json.loads(raw)
except json.JSONDecodeError:
return None
req = rec.get("request", {}) or {}
host = req.get("host", "")
h = req.get("headers", {}) or {}
ua = _first(h, "User-Agent") or ""
client, version, device, device_id, token = classify_emby(rec)
return {
"ts": rec.get("ts"),
"host": host,
"method": req.get("method", ""),
"uri": req.get("uri", ""),
"status": rec.get("status", 0),
"size": rec.get("size", 0),
"duration": rec.get("duration", 0),
"remote_ip": req.get("remote_ip", ""),
"user_agent": ua,
"emby_client": client,
"emby_version": version,
"emby_device": device,
"emby_device_id": device_id,
"emby_token": token,
"referer": _first(h, "Referer"),
}
def parse_log(path):
"""
Parse all log files found at *path* (file or directory).
Returns (entries, log_stats) where log_stats is a dict with:
files_found, files_ok, files_error, total_bytes, formats
"""
files = find_log_files(path)
entries = []
stats = {"files_found": len(files), "files_ok": 0, "files_error": 0,
"total_bytes": 0, "formats": Counter()}
for filepath in files:
try:
stats["total_bytes"] += os.path.getsize(filepath)
name = os.path.basename(filepath).lower()
if name.endswith(".tar.gz") or name.endswith(".tgz"): fmt = "tar.gz"
elif name.endswith(".gz"): fmt = "gz"
else: fmt = "plain"
stats["formats"][fmt] += 1
count_before = len(entries)
for raw in _iter_lines(filepath):
entry = _parse_line(raw)
if entry:
entries.append(entry)
if len(entries) > count_before:
stats["files_ok"] += 1
except Exception:
stats["files_error"] += 1
stats["formats"] = dict(stats["formats"])
return entries, stats
# ---------------------------------------------------------------------------
# Aggregations
# ---------------------------------------------------------------------------
def summarize(entries, device_users=None):
"""Aggregate log entries into a summary dict for the template."""
if device_users is None:
device_users = {}
total = len(entries)
by_host = Counter()
by_status_class = Counter()
by_ip = Counter()
bytes_by_host = defaultdict(int)
status_by_host = defaultdict(Counter)
blocked_403 = []
errors_5xx = []
slow_requests = []
emby_versions = Counter()
emby_clients = Counter()
emby_devices = Counter()
emby_version_by_client = defaultdict(Counter)
device_registry = {}
auth_events = []
first_ts = last_ts = None
SLOW_THRESHOLD = 2.0
for e in entries:
ts = e["ts"]
if ts:
if first_ts is None or ts < first_ts: first_ts = ts
if last_ts is None or ts > last_ts: last_ts = ts
host = e["host"] or "(none)"
by_host[host] += 1
bytes_by_host[host] += e["size"] or 0
status = e["status"] or 0
klass = f"{status // 100}xx" if status else "0xx"
by_status_class[klass] += 1
status_by_host[host][klass] += 1
ip = e["remote_ip"] or "(none)"
by_ip[ip] += 1
if status == 403: blocked_403.append(e)
if 500 <= status < 600: errors_5xx.append(e)
if (e["duration"] or 0) >= SLOW_THRESHOLD: slow_requests.append(e)
if e["emby_version"]: emby_versions[e["emby_version"]] += 1
if e["emby_client"]: emby_clients[e["emby_client"]] += 1
if e["emby_device"]: emby_devices[e["emby_device"]] += 1
if e["emby_client"] and e["emby_version"]:
emby_version_by_client[e["emby_client"]][e["emby_version"]] += 1
# Device registry keyed by DeviceId
did = e.get("emby_device_id")
if did:
if did not in device_registry:
device_registry[did] = {
"device_id": did,
"device": e["emby_device"] or did[:12],
"client": e["emby_client"],
"version": e["emby_version"],
"token": e.get("emby_token"),
"ips": set(),
"hits": 0,
"last_ts": None,
}
rec = device_registry[did]
rec["hits"] += 1
if e["remote_ip"]:
rec["ips"].add(e["remote_ip"])
if ts and (rec["last_ts"] is None or ts > rec["last_ts"]):
rec["last_ts"] = ts
if e["emby_version"]: rec["version"] = e["emby_version"]
if e["emby_client"]: rec["client"] = e["emby_client"]
if e["emby_device"]: rec["device"] = e["emby_device"]
if e.get("emby_token"): rec["token"] = e["emby_token"]
if host == "auth.mattcohen.net":
auth_events.append(e)
slow_requests.sort(key=lambda x: x["duration"] or 0, reverse=True)
blocked_403.sort(key=lambda x: x["ts"] or 0, reverse=True)
max_hits = max(by_host.values(), default=1)
host_summary = [
{
"host": host,
"hits": count,
"bytes_human": human_bytes(bytes_by_host[host]),
"status_mix": dict(status_by_host[host]),
"pct": round(count / max_hits * 100),
}
for host, count in by_host.most_common()
]
emby_breakdown = sorted(
[
{"client": c, "total": sum(vs.values()), "versions": dict(vs.most_common())}
for c, vs in emby_version_by_client.items()
],
key=lambda x: x["total"], reverse=True,
)
device_list = []
for rec in device_registry.values():
did = rec["device_id"]
user = device_users.get(did)
device_list.append({
**rec,
"ips": sorted(rec["ips"]),
"last_seen": _fmt_ts(rec["last_ts"]),
"username": user,
})
device_list.sort(key=lambda x: x["hits"], reverse=True)
blocked_ip_counter = Counter(e["remote_ip"] for e in blocked_403 if e["remote_ip"])
return {
"total": total,
"first_ts": _fmt_ts(first_ts),
"last_ts": _fmt_ts(last_ts),
"span_hours": round((last_ts - first_ts) / 3600, 2) if first_ts and last_ts else 0,
"by_host": host_summary,
"by_status_class": dict(by_status_class.most_common()),
"by_ip": by_ip.most_common(25),
"blocked_403": [_entry_view(x) for x in blocked_403[:100]],
"blocked_403_total": len(blocked_403),
"blocked_403_by_trigger": classify_403_triggers(blocked_403),
"blocked_ips_top": blocked_ip_counter.most_common(10),
"errors_5xx": [_entry_view(x) for x in errors_5xx[:50]],
"errors_5xx_total": len(errors_5xx),
"slow_requests": [_entry_view(x) for x in slow_requests[:50]],
"slow_total": len(slow_requests),
"emby_versions": emby_versions.most_common(),
"emby_clients": emby_clients.most_common(),
"emby_devices": emby_devices.most_common(),
"emby_breakdown": emby_breakdown,
"device_registry": device_list,
"emby_api_enabled": bool(EMBY_URL and EMBY_KEY),
"auth_summary": summarize_auth(auth_events),
}
def classify_403_triggers(blocked):
"""Heuristically guess which Caddy matcher fired on each 403."""
triggers = Counter()
for e in blocked:
tags = []
uri = e.get("uri") or ""
ua = e.get("user_agent") or ""
qs = parse_query_string(uri)
ver = qs.get("X-Emby-Client-Version") or e.get("emby_version")
if ver in ("2.2.51", "3.5.52"):
tags.append(f"emby_version:{ver}")
if qs.get("X-Emby-Client") in ("Emby for iOS", "Emby+for+iOS"):
tags.append("emby_client:ios")
did = qs.get("X-Emby-Device-Id") or e.get("emby_device_id")
if did == "9F318B1F-6E72-4962-BE37-7F8843EA497A":
tags.append("emby_device_id:known")
if re.search(r"Emby/[\d.]+ CFNetwork/.* Darwin/", ua):
tags.append("ua:emby_ios_native")
if not tags:
tags.append("other")
for t in tags:
triggers[t] += 1
return triggers.most_common()
def summarize_auth(events):
if not events:
return {"total": 0}
by_status = Counter(e["status"] for e in events)
by_ip = Counter(e["remote_ip"] for e in events)
by_path = Counter((e["uri"] or "").split("?")[0] for e in events)
recent = sorted(events, key=lambda x: x["ts"] or 0, reverse=True)[:30]
return {
"total": len(events),
"by_status": by_status.most_common(),
"by_ip": by_ip.most_common(10),
"by_path": by_path.most_common(15),
"recent": [_entry_view(x) for x in recent],
}
# ---------------------------------------------------------------------------
# Formatting helpers
# ---------------------------------------------------------------------------
def human_bytes(n):
for unit in ["B", "KB", "MB", "GB", "TB"]:
if n < 1024:
return f"{n:.1f} {unit}"
n /= 1024
return f"{n:.1f} PB"
def _fmt_ts(ts):
if not ts:
return ""
return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S")
def _entry_view(e):
return {
"time": _fmt_ts(e["ts"]),
"host": e["host"],
"method": e["method"],
"uri": (e["uri"] or "")[:120],
"status": e["status"],
"ip": e["remote_ip"],
"ua": (e["user_agent"] or "")[:80],
"duration": round(e["duration"] or 0, 3),
"size": e["size"],
"emby_client": e["emby_client"],
"emby_version": e["emby_version"],
"emby_device": e["emby_device"],
"emby_device_id": e.get("emby_device_id"),
}
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.route("/")
def index():
device_users = fetch_emby_device_users()
entries, lstats = parse_log(LOG_PATH)
summary = summarize(entries, device_users)
if lstats["files_found"] == 1:
log_label = os.path.basename(LOG_PATH)
else:
log_label = os.path.basename(os.path.abspath(LOG_PATH))
return render_template(
"dashboard.html",
s=summary,
log_path=LOG_PATH,
log_label=log_label,
log_size=human_bytes(lstats["total_bytes"]),
log_stats=lstats,
)
@app.route("/api/raw/<int:n>")
def api_raw(n):
"""Return the N most-recent parsed entries as JSON."""
entries, _ = parse_log(LOG_PATH)
entries.sort(key=lambda x: x["ts"] or 0, reverse=True)
return jsonify([_entry_view(e) for e in entries[:n]])
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000, debug=False)
+3915
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
Flask>=3.0
+848
View File
@@ -0,0 +1,848 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CaddyBuddy</title>
<style>
/* ─── Reset & Variables ──────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #07090f;
--s0: #0d1117;
--s1: #111826;
--s2: #182030;
--s3: #1e2a3e;
--border: #1d2d44;
--border2: #253554;
--text: #dde5f3;
--text2: #8fa3c0;
--muted: #4a5c73;
--accent: #4d8ef5;
--indigo: #7c5af5;
--good: #10d3a0;
--warn: #f5a623;
--bad: #f04060;
--info: #38c0f8;
--grad: linear-gradient(135deg, #4d8ef5, #7c5af5);
}
html { scroll-behavior: smooth; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
font-size: 14px;
line-height: 1.6;
}
/* ─── Sticky header ──────────────────────────────────────────────────── */
.app-header {
position: sticky; top: 0; z-index: 100;
background: rgba(7,9,15,0.9);
backdrop-filter: blur(14px);
border-bottom: 1px solid var(--border);
padding: 0 1.5rem;
height: 54px;
display: flex; align-items: center; gap: 1.25rem;
}
.logo { display: flex; align-items: center; gap: 0.55rem; text-decoration: none; flex-shrink: 0; }
.logo-icon {
width: 30px; height: 30px; border-radius: 7px;
background: var(--grad);
display: flex; align-items: center; justify-content: center;
}
.logo-icon svg { width: 16px; height: 16px; fill: #fff; }
.logo-text {
font-size: 0.95rem; font-weight: 700; letter-spacing: -0.02em;
background: var(--grad);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
}
.header-pills { flex: 1; display: flex; gap: 0.5rem; overflow: hidden; }
.pill {
background: var(--s2); border: 1px solid var(--border);
border-radius: 20px; padding: 2px 10px;
font-size: 0.72rem; color: var(--text2); white-space: nowrap;
}
.pill.good { border-color: rgba(16,211,160,0.4); color: var(--good); }
.header-nav { display: flex; gap: 0.15rem; flex-shrink: 0; }
.nav-a {
color: var(--text2); text-decoration: none; font-size: 0.78rem;
padding: 0.3rem 0.65rem; border-radius: 5px;
transition: background 0.15s, color 0.15s;
}
.nav-a:hover { background: var(--s2); color: var(--text); }
.btn-refresh {
background: var(--s2); border: 1px solid var(--border2);
color: var(--accent); text-decoration: none; font-size: 0.78rem;
padding: 0.3rem 0.8rem; border-radius: 6px;
display: flex; align-items: center; gap: 0.35rem;
transition: background 0.15s;
}
.btn-refresh:hover { background: var(--s3); }
/* ─── Main ───────────────────────────────────────────────────────────── */
main { max-width: 1440px; margin: 0 auto; padding: 1.5rem; }
/* ─── KPI strip ──────────────────────────────────────────────────────── */
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(155px, 1fr));
gap: 0.85rem;
margin-bottom: 1.5rem;
}
.kpi {
background: var(--s1); border: 1px solid var(--border); border-radius: 10px;
padding: 1rem 1.1rem;
transition: border-color 0.2s, box-shadow 0.2s;
cursor: default;
}
.kpi:hover { border-color: var(--border2); box-shadow: 0 0 20px rgba(77,142,245,0.08); }
.kpi-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.55rem; }
.kpi-label {
font-size: 0.68rem; font-weight: 600; letter-spacing: 0.07em;
text-transform: uppercase; color: var(--text2);
}
.kpi-ico {
width: 26px; height: 26px; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
}
.kpi-ico svg { width: 13px; height: 13px; }
.ico-blue { background: rgba(77,142,245,0.14); } .ico-blue svg { stroke: var(--accent); }
.ico-green { background: rgba(16,211,160,0.12); } .ico-green svg { stroke: var(--good); }
.ico-orange { background: rgba(245,166,35,0.12); } .ico-orange svg { stroke: var(--warn); }
.ico-red { background: rgba(240,64,96,0.12); } .ico-red svg { stroke: var(--bad); }
.ico-purple { background: rgba(124,90,245,0.12); } .ico-purple svg { stroke: var(--indigo); }
.ico-cyan { background: rgba(56,192,248,0.12); } .ico-cyan svg { stroke: var(--info); }
.kpi-val {
font-size: 1.9rem; font-weight: 700; letter-spacing: -0.03em;
line-height: 1; font-variant-numeric: tabular-nums;
}
.kpi-sub { font-size: 0.7rem; color: var(--muted); margin-top: 0.3rem; }
/* ─── Sections ───────────────────────────────────────────────────────── */
.sec-head {
display: flex; align-items: center; gap: 0.55rem;
margin-bottom: 1rem; margin-top: 0.25rem;
}
.sec-ico {
width: 26px; height: 26px; background: var(--s2); border: 1px solid var(--border);
border-radius: 6px; display: flex; align-items: center; justify-content: center;
}
.sec-ico svg { width: 13px; height: 13px; }
.sec-title {
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.07em; color: var(--text2);
}
.sec-badge {
margin-left: auto; font-size: 0.7rem; color: var(--muted);
background: var(--s2); border: 1px solid var(--border);
border-radius: 10px; padding: 1px 8px;
}
.divider { height: 1px; background: var(--border); margin: 1.75rem 0; }
/* ─── Cards ──────────────────────────────────────────────────────────── */
.card {
background: var(--s1); border: 1px solid var(--border);
border-radius: 10px; overflow: hidden;
}
.card-head {
padding: 0.75rem 1rem; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 0.5rem;
}
.card-title {
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--text2); flex: 1;
}
.card-badge {
font-size: 0.68rem; color: var(--muted);
background: var(--s2); border: 1px solid var(--border);
border-radius: 10px; padding: 1px 7px;
}
.card-body { padding: 0.9rem 1rem; }
/* ─── Grid layouts ───────────────────────────────────────────────────── */
.g2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.g3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem; }
@media (max-width: 1100px) { .g3 { grid-template-columns: 1fr 1fr; } }
@media (max-width: 700px) { .g2, .g3 { grid-template-columns: 1fr; } }
/* ─── Tables ─────────────────────────────────────────────────────────── */
.tbl-wrap { overflow-x: auto; }
.scrollable { max-height: 380px; overflow-y: auto; }
.scrollable::-webkit-scrollbar { width: 4px; }
.scrollable::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
th {
text-align: left; padding: 0.5rem 0.75rem;
color: var(--muted); font-weight: 600; font-size: 0.65rem;
text-transform: uppercase; letter-spacing: 0.07em;
border-bottom: 1px solid var(--border); white-space: nowrap;
position: sticky; top: 0; background: var(--s1);
}
td {
padding: 0.45rem 0.75rem;
border-bottom: 1px solid rgba(29,45,68,0.6);
vertical-align: middle;
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(24,32,48,0.6); }
td.r, th.r { text-align: right; font-variant-numeric: tabular-nums; }
td.mono { font-family: "SF Mono","Cascadia Code",Consolas,monospace; font-size: 0.76rem; }
.trunc {
max-width: 280px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; display: block;
}
/* ─── Chips / badges ─────────────────────────────────────────────────── */
.chip {
display: inline-flex; align-items: center;
padding: 1px 6px; border-radius: 4px;
font-size: 0.68rem; font-weight: 500; white-space: nowrap;
}
.c2 { background: rgba(16,211,160,0.12); color: var(--good); }
.c3 { background: rgba(77,142,245,0.12); color: var(--accent); }
.c4 { background: rgba(245,166,35,0.12); color: var(--warn); }
.c5 { background: rgba(240,64,96,0.12); color: var(--bad); }
.c0 { background: var(--s2); color: var(--muted); }
.ctag{ background: var(--s3); border: 1px solid var(--border2); color: var(--text2); }
.cnum{ background: var(--s2); border: 1px solid var(--border); color: var(--text2); }
.method {
font-size: 0.65rem; font-weight: 700; padding: 1px 5px;
border-radius: 3px; font-family: Consolas,monospace;
}
.mGET { background: rgba(16,211,160,0.12); color: var(--good); }
.mPOST { background: rgba(77,142,245,0.12); color: var(--accent); }
.mPUT { background: rgba(245,166,35,0.12); color: var(--warn); }
.mDELETE { background: rgba(240,64,96,0.12); color: var(--bad); }
.mOTHER { background: var(--s2); color: var(--muted); }
/* ─── Bar chart rows ─────────────────────────────────────────────────── */
.bar-row {
display: flex; align-items: center; gap: 0.65rem;
padding: 0.38rem 0;
border-bottom: 1px solid rgba(29,45,68,0.5);
}
.bar-row:last-child { border-bottom: none; }
.bar-lbl {
font-family: "SF Mono",Consolas,monospace; font-size: 0.76rem;
color: var(--text); flex-shrink: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.bar-track { flex: 1; height: 5px; background: var(--s3); border-radius: 3px; overflow: hidden; min-width: 40px; }
.bar-fill { height: 100%; border-radius: 3px; background: var(--grad); }
.bar-warn { background: linear-gradient(90deg, var(--warn), var(--bad)); }
.bar-auth { background: linear-gradient(90deg, var(--indigo), var(--accent)); }
.bar-cnt { font-size: 0.76rem; color: var(--text2); font-variant-numeric: tabular-nums; flex-shrink: 0; min-width: 40px; text-align: right; }
.bar-extra { font-size: 0.7rem; color: var(--muted); flex-shrink: 0; min-width: 55px; text-align: right; }
/* ─── Status composite bar ───────────────────────────────────────────── */
.stat-bar { height: 7px; border-radius: 4px; overflow: hidden; display: flex; margin-bottom: 1rem; }
.stat-bar-seg { height: 100%; }
/* ─── Emby device cards ──────────────────────────────────────────────── */
.device-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 0.75rem;
}
.dev-card {
background: var(--s2); border: 1px solid var(--border);
border-radius: 10px; padding: 1rem;
transition: border-color 0.15s, box-shadow 0.15s;
}
.dev-card:hover { border-color: var(--border2); box-shadow: 0 4px 18px rgba(0,0,0,0.25); }
.dev-top { display: flex; align-items: flex-start; gap: 0.65rem; margin-bottom: 0.65rem; }
.dev-ico {
width: 38px; height: 38px; border-radius: 9px;
display: flex; align-items: center; justify-content: center;
font-size: 1.15rem; flex-shrink: 0;
}
.dev-name {
font-weight: 700; font-size: 0.88rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.dev-client { font-size: 0.73rem; color: var(--text2); margin-top: 1px; }
.dev-tags { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.55rem; }
.ip-row { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-bottom: 0.55rem; }
.ip-chip {
font-family: Consolas,monospace; font-size: 0.66rem;
padding: 1px 6px; border-radius: 4px;
background: var(--s0); border: 1px solid var(--border);
color: var(--info);
}
.dev-foot {
display: flex; justify-content: space-between; align-items: center;
font-size: 0.68rem; color: var(--muted);
border-top: 1px solid var(--border); padding-top: 0.45rem; margin-top: 0.3rem;
}
/* ─── Empty states ───────────────────────────────────────────────────── */
.empty { text-align: center; padding: 2rem; color: var(--muted); font-size: 0.82rem; }
.empty .e-ico { font-size: 1.6rem; margin-bottom: 0.4rem; }
</style>
</head>
<body>
<!-- ═══ HEADER ════════════════════════════════════════════════════════ -->
<header class="app-header">
<a class="logo" href="/">
<div class="logo-icon">
<svg viewBox="0 0 24 24"><path d="M12 2L3 7v5c0 5.25 3.75 10.15 9 11.25C17.25 22.15 21 17.25 21 12V7z"/></svg>
</div>
<span class="logo-text">CaddyBuddy</span>
</a>
<div class="header-pills">
<span class="pill">
{% if log_stats.files_found != 1 %}{{ log_stats.files_ok }}/{{ log_stats.files_found }} files · {% endif %}{{ log_label }} · {{ log_size }}
</span>
<span class="pill">{{ s.first_ts }} → {{ s.last_ts }}</span>
<span class="pill">{{ s.span_hours }}h window</span>
{% if log_stats.files_error %}<span class="pill" style="color:var(--warn)">{{ log_stats.files_error }} file errors</span>{% endif %}
{% if s.emby_api_enabled %}<span class="pill good">Emby API connected</span>{% endif %}
</div>
<nav class="header-nav">
<a href="#traffic" class="nav-a">Traffic</a>
<a href="#emby" class="nav-a">Emby</a>
<a href="#security" class="nav-a">Security</a>
<a href="#errors" class="nav-a">Errors</a>
<a href="#auth" class="nav-a">Auth</a>
</nav>
<a href="/" class="btn-refresh">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>
</svg>
Refresh
</a>
</header>
<!-- ═══ MAIN ══════════════════════════════════════════════════════════ -->
<main>
<!-- ─── KPI strip ─────────────────────────────────────────────────── -->
<div class="kpi-grid">
<div class="kpi">
<div class="kpi-row">
<span class="kpi-label">Total Requests</span>
<div class="kpi-ico ico-blue">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
</div>
</div>
<div class="kpi-val">{{ "{:,}".format(s.total) }}</div>
<div class="kpi-sub">{{ s.span_hours }}h window</div>
</div>
<div class="kpi">
<div class="kpi-row">
<span class="kpi-label">Blocked (403)</span>
<div class="kpi-ico ico-orange">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
</div>
</div>
<div class="kpi-val" style="color:var(--warn)">{{ "{:,}".format(s.blocked_403_total) }}</div>
<div class="kpi-sub">{{ "%.1f"|format(s.blocked_403_total / s.total * 100 if s.total else 0) }}% of traffic</div>
</div>
<div class="kpi">
<div class="kpi-row">
<span class="kpi-label">Server Errors</span>
<div class="kpi-ico ico-red">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</div>
</div>
<div class="kpi-val" style="color:var(--bad)">{{ "{:,}".format(s.errors_5xx_total) }}</div>
<div class="kpi-sub">5xx responses</div>
</div>
<div class="kpi">
<div class="kpi-row">
<span class="kpi-label">Slow Requests</span>
<div class="kpi-ico ico-purple">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</div>
</div>
<div class="kpi-val">{{ "{:,}".format(s.slow_total) }}</div>
<div class="kpi-sub">≥ 2 second latency</div>
</div>
<div class="kpi">
<div class="kpi-row">
<span class="kpi-label">Unique IPs</span>
<div class="kpi-ico ico-cyan">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/></svg>
</div>
</div>
<div class="kpi-val">{{ "{:,}".format(s.by_ip|length) }}</div>
<div class="kpi-sub">distinct sources</div>
</div>
<div class="kpi">
<div class="kpi-row">
<span class="kpi-label">Emby Devices</span>
<div class="kpi-ico ico-green">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
</div>
<div class="kpi-val" style="color:var(--good)">{{ "{:,}".format(s.device_registry|length) }}</div>
<div class="kpi-sub">tracked by device ID</div>
</div>
</div>
<!-- ═══ TRAFFIC ═══════════════════════════════════════════════════════ -->
<div id="traffic" style="scroll-margin-top:62px">
<div class="sec-head">
<div class="sec-ico"><svg viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></div>
<span class="sec-title">Traffic</span>
</div>
<div class="g2" style="margin-bottom:1rem">
<!-- Status distribution -->
<div class="card">
<div class="card-head"><span class="card-title">Status Distribution</span></div>
<div class="card-body">
{% set ns = namespace(tot=0) %}
{% for k,v in s.by_status_class.items() %}{% set ns.tot = ns.tot + v %}{% endfor %}
<div class="stat-bar">
{% for klass, count in s.by_status_class.items() %}
{% set pct = (count / ns.tot * 100)|round(1) if ns.tot else 0 %}
{% if klass=='2xx' %}{% set col='var(--good)' %}
{% elif klass=='3xx' %}{% set col='var(--accent)' %}
{% elif klass=='4xx' %}{% set col='var(--warn)' %}
{% elif klass=='5xx' %}{% set col='var(--bad)' %}
{% else %}{% set col='var(--muted)' %}{% endif %}
<div class="stat-bar-seg" style="width:{{ pct }}%;background:{{ col }}" title="{{ klass }}: {{ count }}"></div>
{% endfor %}
</div>
<table>
<thead><tr><th>Class</th><th class="r">Count</th><th class="r">Share</th></tr></thead>
<tbody>
{% for klass, count in s.by_status_class.items() %}
{% set pct = (count / ns.tot * 100)|round(1) if ns.tot else 0 %}
<tr>
<td><span class="chip c{{ klass[0] }}">{{ klass }}</span></td>
<td class="r">{{ "{:,}".format(count) }}</td>
<td class="r" style="color:var(--muted)">{{ pct }}%</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Top IPs -->
<div class="card">
<div class="card-head">
<span class="card-title">Top Source IPs</span>
<span class="card-badge">{{ s.by_ip|length }} unique</span>
</div>
<div class="card-body">
{% set max_ip = s.by_ip[0][1] if s.by_ip else 1 %}
{% for ip, count in s.by_ip[:15] %}
<div class="bar-row">
<span class="bar-lbl" style="width:140px">{{ ip }}</span>
<div class="bar-track"><div class="bar-fill" style="width:{{ (count/max_ip*100)|round }}%"></div></div>
<span class="bar-cnt">{{ "{:,}".format(count) }}</span>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Per-host -->
<div class="card">
<div class="card-head">
<span class="card-title">Per-Host Traffic</span>
<span class="card-badge">{{ s.by_host|length }} hosts</span>
</div>
<div class="card-body">
{% for h in s.by_host %}
<div class="bar-row">
<span class="bar-lbl" style="width:210px">{{ h.host }}</span>
<div class="bar-track"><div class="bar-fill" style="width:{{ h.pct }}%"></div></div>
<span class="bar-cnt">{{ "{:,}".format(h.hits) }}</span>
<span class="bar-extra">{{ h.bytes_human }}</span>
<div style="display:flex;gap:3px;flex-wrap:wrap;min-width:160px">
{% for klass, count in h.status_mix.items() %}
<span class="chip c{{ klass[0] }}">{{ klass }}:{{ count }}</span>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
</div><!-- /traffic -->
<div class="divider"></div>
<!-- ═══ EMBY ══════════════════════════════════════════════════════════ -->
<div id="emby" style="scroll-margin-top:62px">
<div class="sec-head">
<div class="sec-ico"><svg viewBox="0 0 24 24" fill="none" stroke="var(--good)" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></div>
<span class="sec-title">Emby Intelligence</span>
<span class="sec-badge">{{ s.device_registry|length }} devices · {{ s.emby_clients|length }} clients</span>
</div>
{% if s.device_registry %}
<div class="card" style="margin-bottom:1rem">
<div class="card-head">
<span class="card-title">Device Registry</span>
<span class="card-badge">identified by DeviceId{% if s.emby_api_enabled %} · usernames via Emby API{% endif %}</span>
</div>
<div class="card-body">
<div class="device-grid">
{% for dev in s.device_registry %}
{% set cl = (dev.client or '')|lower %}
{% if 'fire' in cl or 'amazon' in cl %}
{% set ico='🔥' %}{% set ibg='rgba(245,166,35,0.12)' %}
{% elif 'ios' in cl or 'iphone' in cl or 'ipad' in cl %}
{% set ico='📱' %}{% set ibg='rgba(56,192,248,0.12)' %}
{% elif 'android' in cl %}
{% set ico='📱' %}{% set ibg='rgba(77,142,245,0.14)' %}
{% elif 'web' in cl or 'browser' in cl %}
{% set ico='🌐' %}{% set ibg='rgba(124,90,245,0.12)' %}
{% elif 'tv' in cl or 'roku' in cl or 'bravia' in (dev.device or '')|lower %}
{% set ico='📺' %}{% set ibg='rgba(16,211,160,0.12)' %}
{% elif 'windows' in cl or 'desktop' in cl %}
{% set ico='🖥️' %}{% set ibg='rgba(245,166,35,0.12)' %}
{% else %}
{% set ico='📡' %}{% set ibg='rgba(255,255,255,0.05)' %}
{% endif %}
<div class="dev-card">
<div class="dev-top">
<div class="dev-ico" style="background:{{ ibg }}">{{ ico }}</div>
<div style="min-width:0">
<div class="dev-name" title="{{ dev.device }}">{{ dev.device or 'Unknown Device' }}</div>
<div class="dev-client">{{ dev.client or 'Unknown Client' }}</div>
</div>
</div>
<div class="dev-tags">
{% if dev.version %}<span class="chip ctag">v{{ dev.version }}</span>{% endif %}
{% if dev.username %}
<span class="chip" style="background:rgba(16,211,160,0.12);color:var(--good)">👤 {{ dev.username }}</span>
{% endif %}
<span class="chip cnum">{{ "{:,}".format(dev.hits) }} req</span>
</div>
{% if dev.ips %}
<div class="ip-row">
{% for ip in dev.ips[:4] %}<span class="ip-chip">{{ ip }}</span>{% endfor %}
{% if dev.ips|length > 4 %}<span class="ip-chip" style="color:var(--muted)">+{{ dev.ips|length - 4 }}</span>{% endif %}
</div>
{% endif %}
<div class="dev-foot">
<span title="{{ dev.device_id }}">{{ dev.device_id[:16] }}…</span>
<span>{{ dev.last_seen }}</span>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<div class="g2">
<div class="card">
<div class="card-head"><span class="card-title">Client Applications</span></div>
<div class="card-body">
{% if s.emby_breakdown %}
{% for b in s.emby_breakdown %}
<div class="bar-row" style="flex-direction:column;align-items:flex-start;gap:0.35rem;padding:0.5rem 0">
<div style="display:flex;align-items:center;gap:0.5rem;width:100%">
<span style="font-size:0.82rem;flex:1">{{ b.client }}</span>
<span class="chip cnum">{{ "{:,}".format(b.total) }}</span>
</div>
<div style="display:flex;gap:0.25rem;flex-wrap:wrap">
{% for ver, count in b.versions.items() %}
<span class="chip ctag">{{ ver }}: {{ count }}</span>
{% endfor %}
</div>
</div>
{% endfor %}
{% else %}
<div class="empty"><div class="e-ico">📡</div>No Emby client data in this window.</div>
{% endif %}
</div>
</div>
<div class="card">
<div class="card-head"><span class="card-title">Device Names</span></div>
<div class="card-body">
{% if s.emby_devices %}
{% set max_d = s.emby_devices[0][1] %}
{% for d, count in s.emby_devices[:20] %}
<div class="bar-row">
<span class="bar-lbl" style="width:165px" title="{{ d }}">{{ d }}</span>
<div class="bar-track"><div class="bar-fill" style="width:{{ (count/max_d*100)|round }}%"></div></div>
<span class="bar-cnt">{{ "{:,}".format(count) }}</span>
</div>
{% endfor %}
{% else %}
<div class="empty"><div class="e-ico">🔍</div>No device data found.</div>
{% endif %}
</div>
</div>
</div>
</div><!-- /emby -->
<div class="divider"></div>
<!-- ═══ SECURITY ══════════════════════════════════════════════════════ -->
<div id="security" style="scroll-margin-top:62px">
<div class="sec-head">
<div class="sec-ico"><svg viewBox="0 0 24 24" fill="none" stroke="var(--warn)" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></div>
<span class="sec-title">Security</span>
<span class="sec-badge">{{ s.blocked_403_total }} blocked</span>
</div>
<div class="g2" style="margin-bottom:1rem">
<div class="card">
<div class="card-head"><span class="card-title">Block Trigger Analysis</span></div>
<div class="card-body">
{% if s.blocked_403_by_trigger %}
{% set max_t = s.blocked_403_by_trigger[0][1] %}
{% for trigger, count in s.blocked_403_by_trigger %}
<div class="bar-row">
<span class="bar-lbl" style="width:200px;font-size:0.73rem">{{ trigger }}</span>
<div class="bar-track"><div class="bar-fill bar-warn" style="width:{{ (count/max_t*100)|round }}%"></div></div>
<span class="bar-cnt">{{ count }}</span>
</div>
{% endfor %}
{% else %}
<div class="empty"><div class="e-ico"></div>No triggers identified.</div>
{% endif %}
</div>
</div>
<div class="card">
<div class="card-head"><span class="card-title">Top Blocked IPs</span></div>
<div class="card-body">
{% if s.blocked_ips_top %}
{% set max_bi = s.blocked_ips_top[0][1] %}
{% for ip, count in s.blocked_ips_top %}
<div class="bar-row">
<span class="bar-lbl" style="width:140px">{{ ip }}</span>
<div class="bar-track"><div class="bar-fill bar-warn" style="width:{{ (count/max_bi*100)|round }}%"></div></div>
<span class="bar-cnt">{{ count }}</span>
</div>
{% endfor %}
{% else %}
<div class="empty"><div class="e-ico"></div>No blocked IPs.</div>
{% endif %}
</div>
</div>
</div>
<div class="card">
<div class="card-head">
<span class="card-title">Recent 403 Blocks</span>
<span class="card-badge">{{ s.blocked_403_total }} total · showing {{ [s.blocked_403|length, 30]|min }}</span>
</div>
<div class="tbl-wrap scrollable">
<table>
<thead>
<tr>
<th>Time</th><th>Host</th><th>Method</th><th>URI</th>
<th>Emby Identity</th><th>IP</th><th>User-Agent</th>
</tr>
</thead>
<tbody>
{% for e in s.blocked_403[:30] %}
{% set mcls = 'm' + e.method if e.method in ['GET','POST','PUT','DELETE'] else 'mOTHER' %}
<tr>
<td class="mono" style="white-space:nowrap;color:var(--text2)">{{ e.time }}</td>
<td class="mono" style="color:var(--accent)">{{ e.host }}</td>
<td><span class="method {{ mcls }}">{{ e.method }}</span></td>
<td class="mono"><span class="trunc" title="{{ e.uri }}">{{ e.uri }}</span></td>
<td>
{% if e.emby_client or e.emby_device %}
<div style="font-size:0.76rem;line-height:1.5">
{% if e.emby_client %}<div>{{ e.emby_client }}</div>{% endif %}
{% if e.emby_device %}<div style="color:var(--text2)">{{ e.emby_device }}</div>{% endif %}
{% if e.emby_version %}<span class="chip ctag">v{{ e.emby_version }}</span>{% endif %}
{% if e.emby_device_id %}<span class="chip ctag" style="font-size:0.63rem" title="{{ e.emby_device_id }}"> {{ e.emby_device_id[:8] }}…</span>{% endif %}
</div>
{% else %}<span style="color:var(--muted)"></span>{% endif %}
</td>
<td class="mono" style="color:var(--info)">{{ e.ip }}</td>
<td class="mono"><span class="trunc" style="color:var(--text2)" title="{{ e.ua }}">{{ e.ua }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div><!-- /security -->
<div class="divider"></div>
<!-- ═══ ERRORS & PERFORMANCE ══════════════════════════════════════════ -->
<div id="errors" style="scroll-margin-top:62px">
<div class="sec-head">
<div class="sec-ico"><svg viewBox="0 0 24 24" fill="none" stroke="var(--bad)" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></div>
<span class="sec-title">Errors & Performance</span>
</div>
<div class="g2">
<div class="card">
<div class="card-head">
<span class="card-title">Server Errors (5xx)</span>
<span class="card-badge">{{ s.errors_5xx_total }} total</span>
</div>
{% if s.errors_5xx_total == 0 %}
<div class="empty"><div class="e-ico"></div>No server errors in this window.</div>
{% else %}
<div class="tbl-wrap scrollable">
<table>
<thead><tr><th>Time</th><th>Host</th><th>URI</th><th class="r">Status</th></tr></thead>
<tbody>
{% for e in s.errors_5xx[:20] %}
<tr>
<td class="mono" style="color:var(--text2);white-space:nowrap">{{ e.time }}</td>
<td class="mono" style="color:var(--accent)">{{ e.host }}</td>
<td class="mono"><span class="trunc" title="{{ e.uri }}">{{ e.uri }}</span></td>
<td class="r"><span class="chip c5">{{ e.status }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
<div class="card">
<div class="card-head">
<span class="card-title">Slow Requests (≥ 2s)</span>
<span class="card-badge">{{ s.slow_total }} total</span>
</div>
{% if s.slow_total == 0 %}
<div class="empty"><div class="e-ico"></div>Everything's snappy.</div>
{% else %}
<div class="tbl-wrap scrollable">
<table>
<thead><tr><th>Time</th><th>Host</th><th>URI</th><th class="r">Duration</th></tr></thead>
<tbody>
{% for e in s.slow_requests[:20] %}
<tr>
<td class="mono" style="color:var(--text2);white-space:nowrap">{{ e.time }}</td>
<td class="mono" style="color:var(--accent)">{{ e.host }}</td>
<td class="mono"><span class="trunc" title="{{ e.uri }}">{{ e.uri }}</span></td>
<td class="r" style="color:var(--warn)">{{ e.duration }}s</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
</div><!-- /errors -->
<div class="divider"></div>
<!-- ═══ AUTH ══════════════════════════════════════════════════════════ -->
<div id="auth" style="scroll-margin-top:62px">
<div class="sec-head">
<div class="sec-ico"><svg viewBox="0 0 24 24" fill="none" stroke="var(--indigo)" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg></div>
<span class="sec-title">Auth Service</span>
<span class="sec-badge">{{ s.auth_summary.total }} events</span>
</div>
{% if s.auth_summary.total == 0 %}
<div class="card"><div class="empty" style="padding:2rem"><div class="e-ico">🔒</div>No auth events in this log window.</div></div>
{% else %}
<div class="g2" style="margin-bottom:1rem">
<div class="card">
<div class="card-head"><span class="card-title">By Path</span></div>
<div class="card-body">
{% set max_ap = s.auth_summary.by_path[0][1] if s.auth_summary.by_path else 1 %}
{% for path, count in s.auth_summary.by_path %}
<div class="bar-row">
<span class="bar-lbl" style="width:190px;font-size:0.73rem" title="{{ path }}">{{ path }}</span>
<div class="bar-track"><div class="bar-fill bar-auth" style="width:{{ (count/max_ap*100)|round }}%"></div></div>
<span class="bar-cnt">{{ count }}</span>
</div>
{% endfor %}
</div>
</div>
<div class="card">
<div class="card-head"><span class="card-title">By Source IP</span></div>
<div class="card-body">
{% set max_aip = s.auth_summary.by_ip[0][1] if s.auth_summary.by_ip else 1 %}
{% for ip, count in s.auth_summary.by_ip %}
<div class="bar-row">
<span class="bar-lbl" style="width:140px">{{ ip }}</span>
<div class="bar-track"><div class="bar-fill bar-auth" style="width:{{ (count/max_aip*100)|round }}%"></div></div>
<span class="bar-cnt">{{ count }}</span>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="card">
<div class="card-head"><span class="card-title">Recent Auth Requests</span></div>
<div class="tbl-wrap scrollable">
<table>
<thead><tr><th>Time</th><th>Method</th><th>Path</th><th class="r">Status</th><th>IP</th></tr></thead>
<tbody>
{% for e in s.auth_summary.recent %}
{% set mcls = 'm' + e.method if e.method in ['GET','POST','PUT','DELETE'] else 'mOTHER' %}
{% set scls = 'c' + (e.status // 100)|string %}
<tr>
<td class="mono" style="color:var(--text2);white-space:nowrap">{{ e.time }}</td>
<td><span class="method {{ mcls }}">{{ e.method }}</span></td>
<td class="mono"><span class="trunc" title="{{ e.uri }}">{{ e.uri }}</span></td>
<td class="r"><span class="chip {{ scls }}">{{ e.status }}</span></td>
<td class="mono" style="color:var(--info)">{{ e.ip }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div><!-- /auth -->
<div style="height:3rem"></div>
</main>
</body>
</html>