Aus dem Backup und Live-Pull vom Pi (10.11.3.144): - dashboard.py: Grid-Linien nur im freien Hintergrund (nicht durch Widgets) - templates/index.html: komplett redesigned mit Sidebar + Topbar + Toast + Modal - plugins/clock.py: responsive Layout (1x1 bis 4x4) - plugins/system.py, weather.py, minimax.py: mit Threshold-Bars und Color-Variants - plugins/base.py: NEU — fetch_with_retry Helper (3x retry mit backoff) + render_error_banner für fehlgeschlagene API-Plugins (grosses rotes "!" Icon mit Plugin-Name und Fehler statt Crash) Cleanup: Helfer-Chaos (renderer.py/2/3, design_a/b/c.html, clock_classic.py, 23x clock_*.png, alte test_*.py) wurde bereits im vorherigen Commit entfernt. Co-Authored-By: Hermes <noreply@hermes.local>
272 lines
11 KiB
Python
272 lines
11 KiB
Python
"""MiniMax Token-Usage Widget - responsive."""
|
|
import os, sys, json
|
|
import urllib.request, urllib.error
|
|
from datetime import datetime
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
from plugins.base import Widget
|
|
from palette import FG, INFO, OK, WARN, ALERT, ORANGE, BG, measure, fit_font, hbar, centered_text, is_small, is_wide, parse_thresholds, DEFAULT_THRESHOLDS
|
|
|
|
|
|
def _fmt_timedelta_short(seconds):
|
|
if seconds is None: return ""
|
|
s = int(seconds)
|
|
if s <= 0: return "now"
|
|
days, rem = divmod(s, 86400)
|
|
h, rem = divmod(rem, 3600)
|
|
m = rem // 60
|
|
if days: return f"{days}d {h}h"
|
|
if h: return f"{h}h {m}m"
|
|
return f"{m}m"
|
|
|
|
|
|
def _deep_get(d, *keys, default=None):
|
|
for k in keys:
|
|
if isinstance(d, dict) and k in d:
|
|
v = d[k]
|
|
if v is not None:
|
|
return v
|
|
return default
|
|
|
|
|
|
def _fetch_remains(subscription_key, base_url="https://api.minimax.io"):
|
|
url = f"{base_url.rstrip('/')}/v1/token_plan/remains"
|
|
req = urllib.request.Request(url, headers={
|
|
"Authorization": f"Bearer {subscription_key}",
|
|
"Content-Type": "application/json", "Accept": "application/json",
|
|
})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=10) as r:
|
|
raw = r.read()
|
|
try:
|
|
return {"ok": True, "data": json.loads(raw), "raw": raw.decode("utf-8", "ignore")}
|
|
except json.JSONDecodeError as e:
|
|
return {"ok": False, "error": f"invalid json: {e}",
|
|
"raw": raw.decode("utf-8", "ignore")[:500]}
|
|
except urllib.error.HTTPError as e:
|
|
body = ""
|
|
try: body = e.read().decode("utf-8", "ignore")[:300]
|
|
except Exception: pass
|
|
return {"ok": False, "error": f"HTTP {e.code} {e.reason}: {body}".strip()}
|
|
|
|
|
|
def _check_base_resp(data):
|
|
base = _deep_get(data, "base_resp")
|
|
if not isinstance(base, dict): return None
|
|
code = base.get("status_code", 0)
|
|
if code in (0, None, "0", ""): return None
|
|
return f"{base.get('status_msg') or base.get('message') or 'API-Fehler'} (code {code})"
|
|
|
|
|
|
def _parse_model_remains(items):
|
|
now_ms = int(datetime.now().timestamp() * 1000)
|
|
parsed = []
|
|
for entry in items:
|
|
if not isinstance(entry, dict): continue
|
|
model_name = entry.get("model_name", "model")
|
|
for win_label, rem_pct, end_time_raw, status_raw in [
|
|
("5-Hour", entry.get("current_interval_remaining_percent"),
|
|
entry.get("end_time"), entry.get("current_interval_status")),
|
|
("Weekly", entry.get("current_weekly_remaining_percent"),
|
|
entry.get("weekly_end_time"), entry.get("current_weekly_status")),
|
|
]:
|
|
if rem_pct is None: continue
|
|
try: status = int(status_raw) if status_raw is not None else 1
|
|
except (TypeError, ValueError): status = 1
|
|
if status != 1: continue
|
|
reset_sec = None
|
|
if isinstance(end_time_raw, (int, float)) and end_time_raw > 1e12:
|
|
reset_sec = max(0, int((end_time_raw - now_ms) / 1000))
|
|
elif isinstance(end_time_raw, (int, float)):
|
|
reset_sec = max(0, int(end_time_raw))
|
|
used_pct = max(0.0, min(100.0, 100.0 - float(rem_pct)))
|
|
parsed.append({
|
|
"label": win_label,
|
|
"used_pct": used_pct,
|
|
"remaining_pct": float(rem_pct),
|
|
"reset_seconds": reset_sec,
|
|
"raw": entry,
|
|
"model_name": model_name,
|
|
})
|
|
return parsed
|
|
|
|
|
|
def _parse_windows(data):
|
|
model_remains = data.get("model_remains")
|
|
if isinstance(model_remains, list) and model_remains:
|
|
return _parse_model_remains(model_remains)
|
|
candidates = [
|
|
data.get("remains"),
|
|
_deep_get(data, "data", "remains"),
|
|
_deep_get(data, "data", "windows"),
|
|
data.get("windows"),
|
|
_deep_get(data, "plan", "windows"),
|
|
]
|
|
raw_windows = None
|
|
for c in candidates:
|
|
if isinstance(c, list) and c:
|
|
raw_windows = c; break
|
|
if not isinstance(raw_windows, list):
|
|
return []
|
|
parsed = []
|
|
for w in raw_windows:
|
|
if not isinstance(w, dict): continue
|
|
reset_sec = w.get("reset_in") or w.get("reset_in_seconds")
|
|
if reset_sec is None:
|
|
reset_iso = w.get("reset_at") or w.get("resets_at")
|
|
if isinstance(reset_iso, str):
|
|
try:
|
|
dt = datetime.fromisoformat(reset_iso.replace("Z", "+00:00"))
|
|
reset_sec = (dt - datetime.now(dt.tzinfo)).total_seconds()
|
|
except Exception:
|
|
reset_sec = None
|
|
usage_raw = w.get("usage") or w.get("used_pct") or w.get("utilization")
|
|
used_pct = None
|
|
if isinstance(usage_raw, (int, float)):
|
|
used_pct = float(usage_raw) * 100 if float(usage_raw) <= 1.0 else float(usage_raw)
|
|
if used_pct is None: continue
|
|
name = (w.get("name") or w.get("window") or w.get("label") or "window").lower()
|
|
parsed.append({
|
|
"label": name.upper(),
|
|
"used_pct": min(100.0, max(0.0, used_pct)),
|
|
"remaining_pct": max(0.0, 100.0 - used_pct),
|
|
"reset_seconds": int(reset_sec) if reset_sec else None,
|
|
})
|
|
return parsed
|
|
|
|
|
|
def _parse_credits(data):
|
|
points = _deep_get(data, "points_balance", "credits_balance", "balance", "points", "credits")
|
|
if isinstance(points, (int, float)):
|
|
return {"points": float(points)}
|
|
nested = _deep_get(data, "plan", "credits")
|
|
if isinstance(nested, (int, float)):
|
|
return {"points": float(nested)}
|
|
return None
|
|
|
|
|
|
class Widget(Widget):
|
|
name = "minimax"
|
|
label = "MiniMax Token Usage"
|
|
description = "Quota für MiniMax Token-Plan (5-Hour + Weekly). Responsive."
|
|
category = "info"
|
|
|
|
config_schema = [
|
|
{"key": "subscription_key", "label": "Subscription Key", "type": "secret",
|
|
"help": "Token-Plan Subscription Key. NICHT der pay-as-you-go API-Key."},
|
|
{"key": "show_credits", "label": "Credit-Balance anzeigen", "type": "bool", "default": True},
|
|
{"key": "api_base", "label": "API Base URL", "type": "string", "default": "https://api.minimax.io"},
|
|
{"key": "bar_thresholds", "label": "Quota-Schwellen (ok,warn,alert)",
|
|
"type": "string", "default": "ok@50,warn@80,alert@95",
|
|
"help": "Format: 'ok@50,warn@80,alert@95' oder 'ok,warn,alert' (default 50/80/95)"},
|
|
{"key": "bar_gradient", "label": "Verlaufsmodus",
|
|
"type": "bool", "default": True,
|
|
"help": "Wenn aus, einfarbige Bar in der Farbe der aktuellen Schwelle."},
|
|
]
|
|
default_config = {"subscription_key": "", "show_credits": True,
|
|
"api_base": "https://api.minimax.io",
|
|
"bar_thresholds": "ok@50,warn@80,alert@95",
|
|
"bar_gradient": True}
|
|
|
|
def _bar_args(self):
|
|
return (parse_thresholds(self.cfg("bar_thresholds")),
|
|
bool(self.cfg("bar_gradient", True)))
|
|
|
|
def fetch(self):
|
|
key = self.cfg("subscription_key")
|
|
if not key:
|
|
return {"_error": "Subscription Key fehlt — in der Admin-UI setzen."}
|
|
try:
|
|
r = _fetch_remains(key, self.cfg("api_base", "https://api.minimax.io"))
|
|
if not r.get("ok"):
|
|
return {"_error": r.get("error", "unbekannter Fehler")}
|
|
data = r["data"]
|
|
base_err = _check_base_resp(data)
|
|
if base_err:
|
|
return {"_error": base_err,
|
|
"_raw_keys": list(data.keys()) if isinstance(data, dict) else None}
|
|
windows = _parse_windows(data)
|
|
credits = _parse_credits(data) if self.cfg("show_credits") else None
|
|
if not windows and not credits:
|
|
return {"_error": "Schema unbekannt.", "_raw_keys": list(data.keys()) if isinstance(data, dict) else None}
|
|
return {"windows": windows, "credits": credits}
|
|
except Exception as e:
|
|
return {"_error": str(e)}
|
|
|
|
def _render_window(self, draw, fonts, x, y, w, h, wn, compact=False):
|
|
from palette import measure
|
|
# Label + Reset rechts
|
|
label = wn["label"]
|
|
reset_txt = ("R " + _fmt_timedelta_short(wn["reset_seconds"])) if wn.get("reset_seconds") else ""
|
|
font_label = fit_font(draw, label, fonts, w - 60, 22)
|
|
draw.text((x, y), label, font=font_label, fill=FG)
|
|
if reset_txt and not compact:
|
|
font_r = fit_font(draw, reset_txt, fonts, 60, 20)
|
|
rw, rh = measure(draw, reset_txt, font_r)
|
|
draw.text((x + w - rw - 4, y + 2), reset_txt, font=font_r, fill=FG)
|
|
# Bar
|
|
bar_y = y + (22 if not compact else 18)
|
|
bar_h = max(10, h - (bar_y - y) - 4)
|
|
pct = wn["used_pct"]
|
|
thresholds, gradient = self._bar_args()
|
|
hbar(draw, x, bar_y, w, bar_h, pct,
|
|
thresholds=thresholds, gradient=gradient)
|
|
# Prozent overlay
|
|
if not compact:
|
|
pct_txt = f"{int(pct + 0.5)}%"
|
|
font_pct = fit_font(draw, pct_txt, fonts, 60, bar_h - 4)
|
|
tw, th = measure(draw, pct_txt, font_pct)
|
|
draw.text((x + w - tw - 8, bar_y + 2), pct_txt, font=font_pct, fill=BG)
|
|
|
|
def render(self, draw, fonts, x, y, w, h):
|
|
from palette import measure
|
|
pad = 8
|
|
draw.text((x + pad, y + pad), "MINIMAX", font=fonts.get("24", fonts.get("20")), fill=INFO)
|
|
|
|
d = self.fetch()
|
|
if "_error" in d:
|
|
draw.text((x + pad, y + 60), "Konfiguration fehlt", font=fonts.get("24", fonts.get("20")), fill=WARN)
|
|
err = d["_error"][:50]
|
|
draw.text((x + pad, y + 90), err, font=fonts.get("20", fonts.get("16")), fill=FG)
|
|
if d.get("_raw_keys"):
|
|
draw.text((x + pad, y + 120), f"raw keys: {','.join(d['_raw_keys'][:6])}",
|
|
font=fonts.get("16", fonts.get("default")), fill=FG)
|
|
return
|
|
|
|
windows = d.get("windows", [])
|
|
|
|
if is_small(w, h):
|
|
# mini: nur "5h: 38%" etc kompakt
|
|
line_y = y + 50
|
|
for wn in windows[:2]:
|
|
label = f"{wn['label'][:1]}{wn['remaining_pct']:.0f}%"
|
|
font = fit_font(draw, label, fonts, w - 2 * pad, h // 3)
|
|
draw.text((x + pad, line_y), label, font=font, fill=FG)
|
|
line_y += h // 3
|
|
return
|
|
|
|
if is_wide(w, h):
|
|
# Wide strip: alle windows nebeneinander
|
|
n = max(1, len(windows))
|
|
col_w = w // n
|
|
for i, wn in enumerate(windows):
|
|
cx = x + i * col_w
|
|
self._render_window(draw, fonts, cx + pad // 2, y + 30,
|
|
col_w - pad, h - 35, wn, compact=True)
|
|
return
|
|
|
|
# Standard: vertikale Liste mit Bars
|
|
row_h = max(40, (h - 60) // max(1, len(windows)))
|
|
for i, wn in enumerate(windows):
|
|
row_y = y + 50 + i * row_h
|
|
self._render_window(draw, fonts, x + pad, row_y,
|
|
w - 2 * pad, row_h - 6, wn)
|
|
|
|
# Credits
|
|
credits = d.get("credits")
|
|
if credits and self.cfg("show_credits") and h > 220:
|
|
pts = credits.get("points")
|
|
if isinstance(pts, (int, float)):
|
|
txt = f"Credits: {pts:,.0f}"
|
|
font = fit_font(draw, txt, fonts, w - 2 * pad, 22)
|
|
draw.text((x + pad, y + h - 28), txt, font=font, fill=ORANGE)
|