#!/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())