add pictures to statcounter
This commit is contained in:
+640
@@ -0,0 +1,640 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Terminal-Dashboard für den Statserver.
|
||||
|
||||
Liest dieselbe Datei wie `web/app.py` (`page_counts.json`) und zeigt
|
||||
Seitenzahl, Bildanzahl, Wachstum, einen Sparkline-Verlauf und die
|
||||
letzten Messwerte direkt im Terminal. Startet keinen Webserver und
|
||||
benötigt keine zusätzlichen Pakete (nur curses aus der Standardlib).
|
||||
|
||||
Aufruf:
|
||||
python3 web/tui.py # interaktiv (q beendet)
|
||||
python3 web/tui.py --once # Momentaufnahme für Skripte/Logs
|
||||
python3 web/tui.py --window 7d # 24h (Default) | 7d | 30d | all
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import curses
|
||||
import json
|
||||
import locale
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
DB_DATA = Path(__file__).parent / "page_counts.json"
|
||||
|
||||
# Muss zu EXACT_IMAGE_INTERVAL in web/app.py passen (dort per Test
|
||||
# abgesichert): Bilder werden nur alle 2 Stunden exakt gezählt.
|
||||
EXACT_IMAGE_INTERVAL = 2 * 60 * 60 # Sekunden
|
||||
|
||||
REFRESH_SECONDS = 2.0
|
||||
DEFAULT_WINDOW = "24h"
|
||||
WINDOWS = {
|
||||
"24h": timedelta(hours=24),
|
||||
"7d": timedelta(days=7),
|
||||
"30d": timedelta(days=30),
|
||||
"all": None,
|
||||
}
|
||||
WINDOW_ORDER = ("24h", "7d", "30d", "all")
|
||||
WINDOW_LABELS = {
|
||||
"24h": "last 24 h",
|
||||
"7d": "last 7 days",
|
||||
"30d": "last 30 days",
|
||||
"all": "all data",
|
||||
}
|
||||
|
||||
SPARK_BLOCKS = "▁▂▃▄▅▆▇█"
|
||||
ASCII_BLOCKS = ".:-=+*#%"
|
||||
|
||||
TABLE_ROWS_ONCE = 15
|
||||
|
||||
|
||||
class StatsDataError(Exception):
|
||||
"""Die Statistikdatei fehlt nicht, ist aber nicht lesbar/parsebar."""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Daten
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def parse_timestamp(value):
|
||||
"""ISO-Zeitstempel -> naive lokale datetime (oder None)."""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
|
||||
text = value.strip()
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
|
||||
try:
|
||||
timestamp = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if timestamp.tzinfo is not None:
|
||||
timestamp = timestamp.astimezone().replace(tzinfo=None)
|
||||
|
||||
return timestamp
|
||||
|
||||
|
||||
def load_entries(path=DB_DATA):
|
||||
"""
|
||||
Messwerte laden, sortiert nach Zeitstempel.
|
||||
|
||||
Akzeptiert sowohl die Liste aus `page_counts.json` als auch das
|
||||
Format der API (`{"values": [...]}`). Einträge ohne gültigen
|
||||
Zeitstempel oder ohne Seitenzahl werden übersprungen.
|
||||
"""
|
||||
path = Path(path)
|
||||
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
except OSError as exc:
|
||||
raise StatsDataError(f"{path}: {exc}") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise StatsDataError(
|
||||
f"{path}: invalid JSON (line {exc.lineno}: {exc.msg})"
|
||||
) from exc
|
||||
|
||||
if isinstance(raw, dict):
|
||||
raw = raw.get("values", [])
|
||||
|
||||
if not isinstance(raw, list):
|
||||
raise StatsDataError(f"{path}: expected a JSON list of samples")
|
||||
|
||||
entries = []
|
||||
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
timestamp = parse_timestamp(item.get("timestamp"))
|
||||
if timestamp is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
count = int(item.get("count"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
entry = dict(item)
|
||||
entry["timestamp"] = timestamp
|
||||
entry["count"] = count
|
||||
|
||||
images = item.get("images")
|
||||
if images is None:
|
||||
entry["images"] = None
|
||||
else:
|
||||
try:
|
||||
entry["images"] = int(images)
|
||||
except (TypeError, ValueError):
|
||||
entry["images"] = None
|
||||
|
||||
entries.append(entry)
|
||||
|
||||
entries.sort(key=lambda entry: entry["timestamp"])
|
||||
return entries
|
||||
|
||||
|
||||
def select_window(entries, window, now=None):
|
||||
"""Nur Messwerte innerhalb des gewählten Zeitfensters."""
|
||||
delta = WINDOWS.get(window, WINDOWS[DEFAULT_WINDOW])
|
||||
if delta is None:
|
||||
return list(entries)
|
||||
|
||||
now = now or datetime.now()
|
||||
cutoff = now - delta
|
||||
return [entry for entry in entries if entry["timestamp"] >= cutoff]
|
||||
|
||||
|
||||
def latest_images(entries):
|
||||
"""
|
||||
(images, images_exact, timestamp) des neuesten Eintrags mit
|
||||
Bildanzahl. Ältere Dateien haben kein `images`-Feld -> None.
|
||||
"""
|
||||
for entry in reversed(entries):
|
||||
if entry.get("images") is not None:
|
||||
return (
|
||||
entry["images"],
|
||||
entry.get("images_exact") is True,
|
||||
entry["timestamp"],
|
||||
)
|
||||
return None, False, None
|
||||
|
||||
|
||||
def next_exact_text(entries, now):
|
||||
"""Wann die nächste exakte Bildzählung fällig ist."""
|
||||
last_exact = None
|
||||
|
||||
for entry in reversed(entries):
|
||||
if entry.get("images_exact") is True:
|
||||
last_exact = entry["timestamp"]
|
||||
break
|
||||
|
||||
if last_exact is None:
|
||||
return "no exact count yet"
|
||||
|
||||
remaining = EXACT_IMAGE_INTERVAL - (now - last_exact).total_seconds()
|
||||
if remaining <= 0:
|
||||
return "exact count due now"
|
||||
return f"next exact in {fmt_duration(remaining)}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Formatierung
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def fmt_int(value):
|
||||
if value is None:
|
||||
return "-"
|
||||
return f"{int(value):,}".replace(",", ".")
|
||||
|
||||
|
||||
def fmt_delta(value):
|
||||
if value is None:
|
||||
return "-"
|
||||
value = int(value)
|
||||
if value == 0:
|
||||
return "0"
|
||||
if value > 0:
|
||||
return "+" + fmt_int(value)
|
||||
return "-" + fmt_int(abs(value))
|
||||
|
||||
|
||||
def fmt_duration(seconds):
|
||||
seconds = max(0, int(round(seconds)))
|
||||
|
||||
if seconds < 60:
|
||||
return f"{seconds} s"
|
||||
|
||||
minutes = seconds // 60
|
||||
if minutes < 60:
|
||||
return f"{minutes} min"
|
||||
|
||||
hours, minutes = divmod(minutes, 60)
|
||||
if hours < 24:
|
||||
return f"{hours} h {minutes} min"
|
||||
|
||||
days, hours = divmod(hours, 24)
|
||||
return f"{days} d {hours} h"
|
||||
|
||||
|
||||
def relative_time(timestamp, now):
|
||||
seconds = (now - timestamp).total_seconds()
|
||||
if seconds < 5:
|
||||
return "just now"
|
||||
return f"{fmt_duration(seconds)} ago"
|
||||
|
||||
|
||||
def sparkline(values, width, blocks=SPARK_BLOCKS):
|
||||
"""Sparkline aus Zahlen; links mit Leerzeichen auf `width` aufgefüllt."""
|
||||
if width <= 0 or not values:
|
||||
return ""
|
||||
|
||||
values = list(values)[-width:]
|
||||
low, high = min(values), max(values)
|
||||
|
||||
if high == low:
|
||||
chars = [blocks[len(blocks) // 2]] * len(values)
|
||||
else:
|
||||
span = high - low
|
||||
last = len(blocks) - 1
|
||||
chars = [
|
||||
blocks[min(last, int((value - low) / span * last + 0.5))]
|
||||
for value in values
|
||||
]
|
||||
|
||||
return " " * (width - len(values)) + "".join(chars)
|
||||
|
||||
|
||||
def _fit(text, width):
|
||||
return text[:max(0, width)]
|
||||
|
||||
|
||||
def _samples_label(count):
|
||||
return f"{count} sample" if count == 1 else f"{count} samples"
|
||||
|
||||
|
||||
def _rate(window_entries):
|
||||
"""Wachstum in Seiten pro Stunde (None bei < 2 Messwerten)."""
|
||||
if len(window_entries) < 2:
|
||||
return None
|
||||
|
||||
first, last = window_entries[0], window_entries[-1]
|
||||
hours = (last["timestamp"] - first["timestamp"]).total_seconds() / 3600
|
||||
|
||||
if hours <= 0:
|
||||
return None
|
||||
|
||||
return (last["count"] - first["count"]) / hours
|
||||
|
||||
|
||||
def _deltas(entries):
|
||||
"""Änderung der Seitenzahl gegenüber dem vorherigen Messwert."""
|
||||
result = {}
|
||||
previous = None
|
||||
|
||||
for entry in entries:
|
||||
result[id(entry)] = (
|
||||
None if previous is None else entry["count"] - previous
|
||||
)
|
||||
previous = entry["count"]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _time_label(timestamp, now, compact):
|
||||
if compact:
|
||||
return timestamp.strftime("%H:%M")
|
||||
if timestamp.date() == now.date():
|
||||
return timestamp.strftime("%H:%M")
|
||||
return timestamp.strftime("%d.%m. %H:%M")
|
||||
|
||||
|
||||
def _table_line(time_text, pages, delta, images, flag, compact):
|
||||
if compact:
|
||||
return (
|
||||
f" {time_text:<6} {pages:>10} {delta:>8}"
|
||||
f" {images:>6} {flag}"
|
||||
)
|
||||
return (
|
||||
f" {time_text:<13} {pages:>12} {delta:>9}"
|
||||
f" {images:>10} {flag}"
|
||||
)
|
||||
|
||||
|
||||
def _build_table(window_entries, delta_by_id, now, width, limit):
|
||||
"""Neueste Messwerte zuerst; Kopfzeile als erste Zeile."""
|
||||
compact = width < 62
|
||||
header = _table_line(
|
||||
"Time",
|
||||
"Pages",
|
||||
"Chg" if compact else "Change",
|
||||
"Images",
|
||||
"",
|
||||
compact,
|
||||
)
|
||||
|
||||
rows = []
|
||||
newest_first = list(reversed(window_entries))
|
||||
|
||||
for entry in newest_first[:limit]:
|
||||
flag = ""
|
||||
if entry.get("images") is not None:
|
||||
flag = "exact" if entry.get("images_exact") is True else "est."
|
||||
|
||||
rows.append(_table_line(
|
||||
_time_label(entry["timestamp"], now, compact),
|
||||
fmt_int(entry["count"]),
|
||||
fmt_delta(delta_by_id.get(id(entry))),
|
||||
fmt_int(entry.get("images")),
|
||||
flag,
|
||||
compact,
|
||||
))
|
||||
|
||||
return header, rows
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Darstellung
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def render_dashboard(entries, window=DEFAULT_WINDOW, now=None, width=80,
|
||||
rows=None, source=None, error=None,
|
||||
blocks=SPARK_BLOCKS):
|
||||
"""
|
||||
Dashboard als Liste von Textzeilen.
|
||||
|
||||
Reine Funktion ohne curses: dadurch testbar und auch für `--once`
|
||||
bzw. Umleitungen nutzbar. `rows` begrenzt die Höhe (Terminal);
|
||||
ohne Angabe werden bis zu 15 Messwerte angezeigt.
|
||||
"""
|
||||
now = now or datetime.now()
|
||||
width = max(24, int(width))
|
||||
source = str(source or DB_DATA)
|
||||
|
||||
lines = [_fit(" JonasSearch stats", width)]
|
||||
|
||||
if error:
|
||||
lines.append("")
|
||||
lines.append(_fit(f" ! {error}", width))
|
||||
lines.append("")
|
||||
lines.append(_fit(" q quit | r retry", width))
|
||||
return lines[:rows] if rows else lines
|
||||
|
||||
if not entries:
|
||||
lines.append("")
|
||||
lines.append(_fit(" No samples yet.", width))
|
||||
lines.append(_fit(f" Waiting for the collector to write {source}", width))
|
||||
lines.append("")
|
||||
lines.append(_fit(" q quit | r reload", width))
|
||||
return lines[:rows] if rows else lines
|
||||
|
||||
latest = entries[-1]
|
||||
previous = entries[-2] if len(entries) > 1 else None
|
||||
delta = None if previous is None else latest["count"] - previous["count"]
|
||||
|
||||
window_entries = select_window(entries, window, now)
|
||||
window_note = ""
|
||||
if not window_entries:
|
||||
window_entries = [latest]
|
||||
window_note = " (no recent samples)"
|
||||
|
||||
rate = _rate(window_entries)
|
||||
images, images_exact, images_ts = latest_images(entries)
|
||||
|
||||
lines.append(_fit(
|
||||
f" updated {latest['timestamp']:%d.%m.%Y %H:%M:%S}"
|
||||
f" ({relative_time(latest['timestamp'], now)})",
|
||||
width,
|
||||
))
|
||||
lines.append("")
|
||||
|
||||
lines.append(_fit(f" Pages {fmt_int(latest['count'])}", width))
|
||||
|
||||
growth = [fmt_delta(delta) + " since previous" if delta is not None
|
||||
else "first sample"]
|
||||
if rate is not None:
|
||||
growth.append(
|
||||
f"{fmt_delta(round(rate))}/h over {WINDOW_LABELS[window]}"
|
||||
f" ({_samples_label(len(window_entries))})"
|
||||
)
|
||||
lines.append(_fit(" " + " | ".join(growth), width))
|
||||
lines.append("")
|
||||
|
||||
lines.append(_fit(f" Images {fmt_int(images)}", width))
|
||||
|
||||
if images is None:
|
||||
images_detail = "not counted yet"
|
||||
else:
|
||||
kind = "exact" if images_exact else "estimated"
|
||||
images_detail = (
|
||||
f"{kind} | counted {images_ts:%H:%M} | exact every 2 h"
|
||||
f" | {next_exact_text(entries, now)}"
|
||||
)
|
||||
lines.append(_fit(" " + images_detail, width))
|
||||
lines.append("")
|
||||
|
||||
values = [entry["count"] for entry in window_entries]
|
||||
low, high = min(values), max(values)
|
||||
lines.append(_fit(
|
||||
f" Pages | {WINDOW_LABELS[window]}{window_note}"
|
||||
f" | {_samples_label(len(window_entries))}",
|
||||
width,
|
||||
))
|
||||
|
||||
range_text = f" ({fmt_int(low)} - {fmt_int(high)})"
|
||||
spark_width = max(8, min(64, width - len(range_text) - 2))
|
||||
spark = sparkline(values, spark_width, blocks)
|
||||
lines.append(_fit(" " + spark + range_text, width))
|
||||
|
||||
delta_by_id = _deltas(entries)
|
||||
footer = ["", _fit(
|
||||
f" q quit | r reload | t window ({window}) | file: {source}",
|
||||
width,
|
||||
)]
|
||||
|
||||
if rows is None:
|
||||
limit = TABLE_ROWS_ONCE
|
||||
else:
|
||||
# 3 Zeilen Overhead: Leerzeile, "Recent samples", Tabellenkopf
|
||||
limit = max(0, rows - len(lines) - len(footer) - 3)
|
||||
|
||||
if limit > 0:
|
||||
header, table = _build_table(
|
||||
window_entries, delta_by_id, now, width, limit
|
||||
)
|
||||
lines += ["", _fit(" Recent samples", width), _fit(header, width)]
|
||||
lines += [_fit(row, width) for row in table]
|
||||
|
||||
lines += footer
|
||||
|
||||
if rows is not None and len(lines) > rows:
|
||||
# Sehr kleines Terminal: Kerninfo kürzen, Footer unten halten.
|
||||
lines = lines[:max(0, rows - 1)] + [lines[-1]]
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Interaktiv / einmalig
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _blocks_for(args):
|
||||
if args.ascii:
|
||||
return ASCII_BLOCKS
|
||||
|
||||
encoding = (getattr(sys.stdout, "encoding", None) or "").lower()
|
||||
return SPARK_BLOCKS if "utf" in encoding else ASCII_BLOCKS
|
||||
|
||||
|
||||
def _render(args, entries, now, width, rows=None):
|
||||
return render_dashboard(
|
||||
entries,
|
||||
window=args.window,
|
||||
now=now,
|
||||
width=width,
|
||||
rows=rows,
|
||||
source=args.file,
|
||||
blocks=_blocks_for(args),
|
||||
)
|
||||
|
||||
|
||||
def emit_once(args, stream=None, errors=None):
|
||||
"""Momentaufnahme ausgeben (kein curses). Rückgabe: Exit-Code."""
|
||||
stream = stream or sys.stdout
|
||||
errors = errors or sys.stderr
|
||||
|
||||
try:
|
||||
entries = load_entries(args.file)
|
||||
except StatsDataError as exc:
|
||||
errors.write(f"error: {exc}\n")
|
||||
return 1
|
||||
|
||||
width = args.width or shutil.get_terminal_size((80, 24)).columns
|
||||
lines = _render(args, entries, datetime.now(), width)
|
||||
stream.write("\n".join(lines) + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
def next_window(window):
|
||||
try:
|
||||
index = WINDOW_ORDER.index(window)
|
||||
except ValueError:
|
||||
index = 0
|
||||
return WINDOW_ORDER[(index + 1) % len(WINDOW_ORDER)]
|
||||
|
||||
|
||||
def run_interactive(args):
|
||||
try:
|
||||
curses.wrapper(_interactive_loop, args)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except curses.error as exc:
|
||||
print(
|
||||
f"Terminal-Dashboard nicht möglich ({exc}); "
|
||||
f"gebe Momentaufnahme aus.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return emit_once(args)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _interactive_loop(stdscr, args):
|
||||
try:
|
||||
curses.curs_set(0)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
stdscr.timeout(max(50, int(args.interval * 1000)))
|
||||
|
||||
entries = []
|
||||
error = None
|
||||
loaded_at = 0.0
|
||||
window = args.window
|
||||
|
||||
while True:
|
||||
monotonic_now = time.monotonic()
|
||||
|
||||
if monotonic_now - loaded_at >= args.interval:
|
||||
try:
|
||||
entries = load_entries(args.file)
|
||||
error = None
|
||||
except StatsDataError as exc:
|
||||
error = str(exc)
|
||||
loaded_at = monotonic_now
|
||||
|
||||
height, width = stdscr.getmaxyx()
|
||||
lines = render_dashboard(
|
||||
entries,
|
||||
window=window,
|
||||
now=datetime.now(),
|
||||
width=width,
|
||||
rows=height,
|
||||
source=args.file,
|
||||
error=error,
|
||||
blocks=_blocks_for(args),
|
||||
)
|
||||
|
||||
stdscr.erase()
|
||||
for index, line in enumerate(lines[:height]):
|
||||
attributes = curses.A_BOLD if index == 0 else 0
|
||||
try:
|
||||
stdscr.addnstr(index, 0, line, max(0, width - 1), attributes)
|
||||
except curses.error:
|
||||
pass
|
||||
stdscr.refresh()
|
||||
|
||||
key = stdscr.getch()
|
||||
|
||||
if key in (ord("q"), 27):
|
||||
return
|
||||
if key == ord("r"):
|
||||
loaded_at = 0.0
|
||||
elif key == ord("t"):
|
||||
window = next_window(window)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Terminal-Dashboard für den Statserver "
|
||||
"(liest web/page_counts.json)."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--file",
|
||||
default=str(DB_DATA),
|
||||
help=f"Statistikdatei (Default: {DB_DATA})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--window",
|
||||
choices=WINDOW_ORDER,
|
||||
default=DEFAULT_WINDOW,
|
||||
help="Zeitfenster für Verlauf und Tabelle (Default: 24h)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--once",
|
||||
action="store_true",
|
||||
help="nur einmal ausgeben und beenden",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--width",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Breite für --once (Default: Terminalbreite)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--interval",
|
||||
type=float,
|
||||
default=REFRESH_SECONDS,
|
||||
help=f"Reload-Intervall in Sekunden (Default: {REFRESH_SECONDS})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ascii",
|
||||
action="store_true",
|
||||
help="ASCII-Sparkline statt Unicode-Blöcke",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
locale.setlocale(locale.LC_ALL, "")
|
||||
except locale.Error:
|
||||
pass
|
||||
|
||||
if args.once or not (sys.stdin.isatty() and sys.stdout.isatty()):
|
||||
return emit_once(args)
|
||||
|
||||
return run_interactive(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user