From bc4cf89a4b8b5e16610223d8cf9b3c6b29bd9f0f Mon Sep 17 00:00:00 2001 From: jerem Date: Mon, 15 Jun 2026 10:49:31 +0200 Subject: [PATCH] =?UTF-8?q?Backend=20Monitorink:=20serveur=20PNG=20(Claude?= =?UTF-8?q?=20usage=20+=20m=C3=A9t=C3=A9o=20+=20HA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 24 +++++ .gitignore | 29 ++++++ backend/Dockerfile | 18 ++++ backend/app.py | 49 +++++++++ backend/config.py | 81 +++++++++++++++ backend/integrations/__init__.py | 0 backend/integrations/claude_usage.py | 133 +++++++++++++++++++++++++ backend/integrations/homeassistant.py | 55 ++++++++++ backend/integrations/weather.py | 86 ++++++++++++++++ backend/render.py | 103 +++++++++++++++++++ backend/requirements.txt | 7 ++ backend/static/.gitkeep | 0 backend/templates/dashboard.html | 138 ++++++++++++++++++++++++++ dev/probe_usage.py | 44 ++++++++ docker-compose.yml | 25 +++++ kobo/bin/.gitkeep | 0 16 files changed, 792 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/app.py create mode 100644 backend/config.py create mode 100644 backend/integrations/__init__.py create mode 100644 backend/integrations/claude_usage.py create mode 100644 backend/integrations/homeassistant.py create mode 100644 backend/integrations/weather.py create mode 100644 backend/render.py create mode 100644 backend/requirements.txt create mode 100644 backend/static/.gitkeep create mode 100644 backend/templates/dashboard.html create mode 100644 dev/probe_usage.py create mode 100644 docker-compose.yml create mode 100644 kobo/bin/.gitkeep diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a4a4caf --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# ── Monitorink — copier en .env et compléter (ne jamais versionner .env) ── + +# Token Claude longue durée, généré par `claude setup-token` sur le homelab. +MONITORINK_CLAUDE_TOKEN=sk-ant-oat01-xxxxxxxx +# User-Agent attendu par l'endpoint /usage (doit ressembler à claude-code/) +MONITORINK_CLAUDE_UA=claude-code/2.1.172 +# Burn rate via ccusage (nécessite ccusage installé + ~/.claude/projects monté). 0/1 +MONITORINK_CCUSAGE=0 + +# Affichage +MONITORINK_TZ=Europe/Paris +MONITORINK_WIDTH=1264 +MONITORINK_HEIGHT=1680 +MONITORINK_CACHE_TTL=120 + +# Météo (Open-Meteo, sans clé) — coordonnées +MONITORINK_LAT=48.8566 +MONITORINK_LON=2.3522 + +# Home Assistant (optionnel) — laisser vide pour désactiver +MONITORINK_HA_URL=http://homeassistant.local:8123 +MONITORINK_HA_TOKEN= +# Entités : "entity_id|Libellé|unité" séparées par des virgules +MONITORINK_HA_ENTITIES=sensor.salon_temperature|Salon|°C, binary_sensor.porte_entree|Porte, person.jerem|Jerem diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c83ad5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Secrets & config locale +.env +*.local +backend/.credentials.json +backend/.credentials.json.* + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +.mypy_cache/ +.pytest_cache/ + +# Rendu / cache +*.png +!backend/static/*.png +backend/out/ +last_usage.json + +# Binaires Kobo (téléchargés depuis trmnl-kobo, pas versionnés) +kobo/bin/* +!kobo/bin/.gitkeep + +# OS / éditeurs +.DS_Store +*.swp +.idea/ +.vscode/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..9b84150 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,18 @@ +# Image Playwright officielle : Chromium + polices + deps déjà présents. +FROM mcr.microsoft.com/playwright/python:v1.49.1-noble + +WORKDIR /app + +# Polices incluant les emoji (météo/icônes) pour le rendu e-ink. +RUN apt-get update && apt-get install -y --no-install-recommends fonts-noto-color-emoji \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +ENV PYTHONUNBUFFERED=1 +EXPOSE 8080 + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..0044ecd --- /dev/null +++ b/backend/app.py @@ -0,0 +1,49 @@ +"""Serveur Monitorink : expose le dashboard en PNG pour la Kobo. + +Endpoints : + GET /image.png -> dashboard 1264x1680 niveaux de gris (avec cache TTL) + GET /debug.html -> HTML brut (itération design, pas de screenshot) + GET /health -> sonde de vie +""" +from __future__ import annotations + +import time + +from fastapi import FastAPI, Response +from fastapi.responses import HTMLResponse + +import render +from config import config + +app = FastAPI(title="Monitorink", docs_url=None, redoc_url=None) + +_cache: dict[str, object] = {"png": None, "ts": 0.0} + + +@app.get("/health") +async def health() -> dict: + return {"status": "ok"} + + +@app.get("/image.png") +async def image(fresh: int = 0) -> Response: + now = time.time() + cached = _cache["png"] + age = now - float(_cache["ts"]) + if cached and not fresh and age < config.cache_ttl_seconds: + png = cached # type: ignore[assignment] + else: + png = await render.render_png() + _cache["png"] = png + _cache["ts"] = now + return Response( + content=png, # type: ignore[arg-type] + media_type="image/png", + headers={"Cache-Control": "no-store"}, + ) + + +@app.get("/debug.html", response_class=HTMLResponse) +async def debug_html() -> str: + context = await render.build_context() + return render.render_html(context) diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..dc92dad --- /dev/null +++ b/backend/config.py @@ -0,0 +1,81 @@ +"""Configuration centralisée de Monitorink, chargée depuis l'environnement. + +Toutes les valeurs sensibles (token Claude, token Home Assistant) viennent de variables +d'environnement / `.env` et ne sont jamais versionnées. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass, field + +from dotenv import load_dotenv + +load_dotenv() + + +def _get(name: str, default: str = "") -> str: + return os.environ.get(name, default).strip() + + +def _get_list(name: str) -> list[str]: + raw = _get(name) + return [item.strip() for item in raw.split(",") if item.strip()] + + +@dataclass(frozen=True) +class HAEntity: + """Une entité Home Assistant à afficher. Format env: `entity_id|Libellé|unité`.""" + + entity_id: str + label: str + unit: str = "" + + @classmethod + def parse(cls, spec: str) -> "HAEntity": + parts = [p.strip() for p in spec.split("|")] + entity_id = parts[0] + label = parts[1] if len(parts) > 1 and parts[1] else entity_id + unit = parts[2] if len(parts) > 2 else "" + return cls(entity_id=entity_id, label=label, unit=unit) + + +@dataclass(frozen=True) +class Config: + # --- Affichage --- + timezone: str = field(default_factory=lambda: _get("MONITORINK_TZ", "Europe/Paris")) + locale: str = field(default_factory=lambda: _get("MONITORINK_LOCALE", "fr_FR")) + width: int = field(default_factory=lambda: int(_get("MONITORINK_WIDTH", "1264"))) + height: int = field(default_factory=lambda: int(_get("MONITORINK_HEIGHT", "1680"))) + + # --- Claude --- + claude_token: str = field(default_factory=lambda: _get("MONITORINK_CLAUDE_TOKEN")) + claude_ua: str = field( + default_factory=lambda: _get("MONITORINK_CLAUDE_UA", "claude-code/2.1.172") + ) + ccusage_enabled: bool = field( + default_factory=lambda: _get("MONITORINK_CCUSAGE", "0") in ("1", "true", "yes") + ) + + # --- Météo (Open-Meteo, sans clé) --- + weather_lat: float = field(default_factory=lambda: float(_get("MONITORINK_LAT", "48.8566"))) + weather_lon: float = field(default_factory=lambda: float(_get("MONITORINK_LON", "2.3522"))) + + # --- Home Assistant --- + ha_base_url: str = field(default_factory=lambda: _get("MONITORINK_HA_URL").rstrip("/")) + ha_token: str = field(default_factory=lambda: _get("MONITORINK_HA_TOKEN")) + + # --- Cache / rafraîchissement serveur --- + cache_ttl_seconds: int = field( + default_factory=lambda: int(_get("MONITORINK_CACHE_TTL", "120")) + ) + + @property + def ha_entities(self) -> list[HAEntity]: + return [HAEntity.parse(s) for s in _get_list("MONITORINK_HA_ENTITIES")] + + @property + def ha_enabled(self) -> bool: + return bool(self.ha_base_url and self.ha_token) + + +config = Config() diff --git a/backend/integrations/__init__.py b/backend/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/integrations/claude_usage.py b/backend/integrations/claude_usage.py new file mode 100644 index 0000000..4b93b08 --- /dev/null +++ b/backend/integrations/claude_usage.py @@ -0,0 +1,133 @@ +"""Récupération de l'usage de l'abonnement Claude via l'endpoint OAuth `/usage`. + +Validé empiriquement (compte Max 5x) : + GET https://api.anthropic.com/api/oauth/usage + headers: Authorization: Bearer , anthropic-beta: oauth-2025-04-20, + User-Agent: claude-code/, Content-Type: application/json + réponse: { five_hour:{utilization,resets_at}, seven_day:{...}, + seven_day_opus, seven_day_sonnet, extra_usage } + +Le token utilisé est un token longue durée dédié généré par `claude setup-token` +(env MONITORINK_CLAUDE_TOKEN). Aucun refresh/écriture n'est effectué ici : en cas de 401, +on remonte un état d'erreur pour affichage (« token à régénérer »). +""" +from __future__ import annotations + +import json +import subprocess +from dataclasses import dataclass +from datetime import datetime, timezone + +import httpx + +from config import config + +USAGE_URL = "https://api.anthropic.com/api/oauth/usage" + + +@dataclass +class Window: + """Une fenêtre glissante d'usage (5h ou 7j).""" + + utilization: float # 0..100 + resets_at: datetime | None + + @property + def remaining_pct(self) -> float: + return max(0.0, 100.0 - self.utilization) + + @property + def resets_in_human(self) -> str: + if not self.resets_at: + return "" + delta = self.resets_at - datetime.now(timezone.utc) + secs = int(delta.total_seconds()) + if secs <= 0: + return "bientôt" + h, m = divmod(secs // 60, 60) + if h >= 24: + return f"{h // 24}j {h % 24}h" + if h: + return f"{h}h{m:02d}" + return f"{m}min" + + +@dataclass +class ClaudeUsage: + ok: bool + error: str | None = None + five_hour: Window | None = None + seven_day: Window | None = None + seven_day_opus: Window | None = None + seven_day_sonnet: Window | None = None + burn_rate: float | None = None # tokens/min (ccusage, optionnel) + + +def _parse_window(raw: dict | None) -> Window | None: + if not raw: + return None + util = float(raw.get("utilization", 0) or 0) + resets = raw.get("resets_at") + dt = None + if resets: + try: + dt = datetime.fromisoformat(str(resets).replace("Z", "+00:00")) + except ValueError: + dt = None + return Window(utilization=util, resets_at=dt) + + +def _burn_rate_from_ccusage() -> float | None: + """Burn rate du bloc 5h actif via `ccusage blocks --json` (best-effort).""" + try: + proc = subprocess.run( + ["ccusage", "blocks", "--active", "--json"], + capture_output=True, + text=True, + timeout=20, + ) + if proc.returncode != 0: + return None + data = json.loads(proc.stdout) + blocks = data.get("blocks") or [] + for b in blocks: + if b.get("isActive"): + bp = b.get("burnRate") or {} + rate = bp.get("tokensPerMinute") or bp.get("tokensPerMinuteForIndicator") + return float(rate) if rate is not None else None + except (subprocess.SubprocessError, json.JSONDecodeError, ValueError, FileNotFoundError): + return None + return None + + +async def fetch_usage() -> ClaudeUsage: + if not config.claude_token: + return ClaudeUsage(ok=False, error="MONITORINK_CLAUDE_TOKEN manquant") + + headers = { + "Authorization": f"Bearer {config.claude_token}", + "anthropic-beta": "oauth-2025-04-20", + "User-Agent": config.claude_ua, + "Content-Type": "application/json", + } + try: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(USAGE_URL, headers=headers) + except httpx.HTTPError as exc: + return ClaudeUsage(ok=False, error=f"réseau: {exc}") + + if resp.status_code == 401: + return ClaudeUsage(ok=False, error="token expiré — relancer `claude setup-token`") + if resp.status_code != 200: + return ClaudeUsage(ok=False, error=f"HTTP {resp.status_code}") + + data = resp.json() + burn = _burn_rate_from_ccusage() if config.ccusage_enabled else None + return ClaudeUsage( + ok=True, + five_hour=_parse_window(data.get("five_hour")), + seven_day=_parse_window(data.get("seven_day")), + seven_day_opus=_parse_window(data.get("seven_day_opus")), + seven_day_sonnet=_parse_window(data.get("seven_day_sonnet")), + burn_rate=burn, + ) diff --git a/backend/integrations/homeassistant.py b/backend/integrations/homeassistant.py new file mode 100644 index 0000000..ea79077 --- /dev/null +++ b/backend/integrations/homeassistant.py @@ -0,0 +1,55 @@ +"""Statuts Home Assistant via l'API REST (`GET /api/states/`).""" +from __future__ import annotations + +from dataclasses import dataclass + +import httpx + +from config import HAEntity, config + + +@dataclass +class HAState: + label: str + state: str + unit: str = "" + ok: bool = True + + @property + def display(self) -> str: + if not self.ok: + return "—" + s = self.state + if s in ("on", "home", "open", "unlocked", "playing"): + s = "ON" + elif s in ("off", "away", "not_home", "closed", "locked", "idle", "paused"): + s = "OFF" + elif s in ("unavailable", "unknown"): + s = "n/d" + return f"{s}{(' ' + self.unit) if self.unit and s not in ('ON', 'OFF', 'n/d') else ''}" + + +async def fetch_states() -> list[HAState]: + entities = config.ha_entities + if not config.ha_enabled or not entities: + return [] + + headers = {"Authorization": f"Bearer {config.ha_token}", "Content-Type": "application/json"} + results: list[HAState] = [] + async with httpx.AsyncClient(timeout=15, headers=headers) as client: + for ent in entities: + results.append(await _fetch_one(client, ent)) + return results + + +async def _fetch_one(client: httpx.AsyncClient, ent: HAEntity) -> HAState: + url = f"{config.ha_base_url}/api/states/{ent.entity_id}" + try: + resp = await client.get(url) + if resp.status_code != 200: + return HAState(label=ent.label, state=f"HTTP {resp.status_code}", ok=False) + data = resp.json() + unit = ent.unit or data.get("attributes", {}).get("unit_of_measurement", "") + return HAState(label=ent.label, state=str(data.get("state", "")), unit=unit) + except httpx.HTTPError: + return HAState(label=ent.label, state="erreur", ok=False) diff --git a/backend/integrations/weather.py b/backend/integrations/weather.py new file mode 100644 index 0000000..0d793cc --- /dev/null +++ b/backend/integrations/weather.py @@ -0,0 +1,86 @@ +"""Météo via Open-Meteo (gratuit, sans clé API).""" +from __future__ import annotations + +from dataclasses import dataclass + +import httpx + +from config import config + +API_URL = "https://api.open-meteo.com/v1/forecast" + +# Codes WMO -> (libellé court FR, emoji). Suffisant pour un dashboard e-ink. +WMO = { + 0: ("Dégagé", "☀"), + 1: ("Peu nuageux", "🌤"), + 2: ("Nuageux", "⛅"), + 3: ("Couvert", "☁"), + 45: ("Brouillard", "🌫"), + 48: ("Brouillard givrant", "🌫"), + 51: ("Bruine légère", "🌦"), + 53: ("Bruine", "🌦"), + 55: ("Bruine forte", "🌦"), + 61: ("Pluie faible", "🌧"), + 63: ("Pluie", "🌧"), + 65: ("Pluie forte", "🌧"), + 71: ("Neige faible", "🌨"), + 73: ("Neige", "🌨"), + 75: ("Neige forte", "🌨"), + 80: ("Averses", "🌦"), + 81: ("Averses", "🌧"), + 82: ("Fortes averses", "⛈"), + 95: ("Orage", "⛈"), + 96: ("Orage + grêle", "⛈"), + 99: ("Orage + grêle", "⛈"), +} + + +@dataclass +class Weather: + ok: bool + error: str | None = None + temp: float | None = None + feels_like: float | None = None + label: str = "" + icon: str = "" + temp_min: float | None = None + temp_max: float | None = None + precip_prob: int | None = None + + +async def fetch_weather() -> Weather: + params = { + "latitude": config.weather_lat, + "longitude": config.weather_lon, + "current": "temperature_2m,apparent_temperature,weather_code", + "daily": "temperature_2m_max,temperature_2m_min,precipitation_probability_max", + "timezone": config.timezone, + "forecast_days": 1, + } + try: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(API_URL, params=params) + resp.raise_for_status() + data = resp.json() + except httpx.HTTPError as exc: + return Weather(ok=False, error=f"réseau: {exc}") + + cur = data.get("current", {}) + daily = data.get("daily", {}) + code = int(cur.get("weather_code", -1)) + label, icon = WMO.get(code, ("—", "·")) + + def _first(key: str): + vals = daily.get(key) or [] + return vals[0] if vals else None + + return Weather( + ok=True, + temp=cur.get("temperature_2m"), + feels_like=cur.get("apparent_temperature"), + label=label, + icon=icon, + temp_min=_first("temperature_2m_min"), + temp_max=_first("temperature_2m_max"), + precip_prob=_first("precipitation_probability_max"), + ) diff --git a/backend/render.py b/backend/render.py new file mode 100644 index 0000000..3566b53 --- /dev/null +++ b/backend/render.py @@ -0,0 +1,103 @@ +"""Construit le contexte, rend le HTML puis le capture en PNG niveaux de gris (1264x1680).""" +from __future__ import annotations + +import asyncio +import io +from datetime import datetime, timezone +from pathlib import Path +from zoneinfo import ZoneInfo + +from jinja2 import Environment, FileSystemLoader, select_autoescape +from PIL import Image +from playwright.async_api import async_playwright + +from config import config +from integrations import claude_usage, homeassistant, weather + +TEMPLATES = Path(__file__).parent / "templates" + +JOURS = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"] +MOIS = [ + "janvier", "février", "mars", "avril", "mai", "juin", + "juillet", "août", "septembre", "octobre", "novembre", "décembre", +] + +_env = Environment( + loader=FileSystemLoader(str(TEMPLATES)), + autoescape=select_autoescape(["html"]), +) + + +def _gauges(usage: claude_usage.ClaudeUsage) -> list[dict]: + """Transforme l'usage Claude en jauges affichables (la fonctionnalité phare).""" + out: list[dict] = [] + if usage.five_hour: + out.append({ + "name": "Session (5 h)", + "remaining": usage.five_hour.remaining_pct, + "resets_in": usage.five_hour.resets_in_human, + "extra": "", + }) + if usage.seven_day: + extra = "" + if usage.seven_day_opus and usage.seven_day_opus.utilization: + extra = f"Opus {usage.seven_day_opus.remaining_pct:.0f}% rest." + out.append({ + "name": "Hebdo (7 j)", + "remaining": usage.seven_day.remaining_pct, + "resets_in": usage.seven_day.resets_in_human, + "extra": extra, + }) + return out + + +async def build_context() -> dict: + """Récupère toutes les sources en parallèle et assemble le contexte du template.""" + usage, wx, ha = await asyncio.gather( + claude_usage.fetch_usage(), + weather.fetch_weather(), + homeassistant.fetch_states(), + ) + + now = datetime.now(ZoneInfo(config.timezone)) + return { + "width": config.width, + "height": config.height, + "time": now.strftime("%H:%M"), + "dow": JOURS[now.weekday()], + "date": f"{now.day} {MOIS[now.month - 1]}", + "weather": wx, + "claude": usage, + "gauges": _gauges(usage), + "ha_states": ha, + "updated": now.strftime("%H:%M"), + "stale": False, + } + + +def render_html(context: dict) -> str: + return _env.get_template("dashboard.html").render(**context) + + +async def render_png() -> bytes: + """Rend le dashboard en PNG niveaux de gris prêt pour l'e-ink de la Kobo.""" + context = await build_context() + html = render_html(context) + + async with async_playwright() as p: + browser = await p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"]) + page = await browser.new_page( + viewport={"width": config.width, "height": config.height}, + device_scale_factor=1, + ) + await page.set_content(html, wait_until="networkidle") + png_bytes = await page.screenshot(type="png", clip={ + "x": 0, "y": 0, "width": config.width, "height": config.height, + }) + await browser.close() + + # Conversion niveaux de gris (mode 'L') -> e-ink friendly, fichier plus léger. + img = Image.open(io.BytesIO(png_bytes)).convert("L") + out = io.BytesIO() + img.save(out, format="PNG", optimize=True) + return out.getvalue() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..1a6b55d --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +httpx==0.28.1 +jinja2==3.1.5 +playwright==1.49.1 +pillow==11.1.0 +python-dotenv==1.0.1 diff --git a/backend/static/.gitkeep b/backend/static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/templates/dashboard.html b/backend/templates/dashboard.html new file mode 100644 index 0000000..feb39d4 --- /dev/null +++ b/backend/templates/dashboard.html @@ -0,0 +1,138 @@ + + + + + + + + +
+
{{ time }}
+
+
{{ dow }}
+
{{ date }}
+
+
+
+ +
+ {% if weather.ok %} +
{{ weather.icon }}
+
+
{{ weather.temp | round | int }}°
+
+ {{ weather.label }} · ressenti {{ weather.feels_like | round | int }}° +
+
+
+
min {{ weather.temp_min | round | int }}° / max {{ weather.temp_max | round | int }}°
+ {% if weather.precip_prob is not none %}
pluie {{ weather.precip_prob }}%
{% endif %} +
+ {% else %} +
Météo indisponible
+ {% endif %} +
+
+ +
+
Abonnement Claude{% if claude.ok %} · Max 5x{% endif %}
+ {% if not claude.ok %} +
⚠ {{ claude.error }}
+ {% else %} + {% for g in gauges %} +
+
+ {{ g.name }} + {{ g.remaining | round | int }}% restant +
+
+
+
+
reset dans {{ g.resets_in }}{% if g.extra %} · {{ g.extra }}{% endif %}
+
+ {% endfor %} + {% if claude.burn_rate %} +
Burn rate : {{ claude.burn_rate | round | int }} tok/min
+ {% endif %} + {% endif %} +
+ + {% if ha_states %} +
+
+
Maison
+
+ {% for s in ha_states %} +
{{ s.label }}{{ s.display }}
+ {% endfor %} +
+
+ {% endif %} + + + + + diff --git a/dev/probe_usage.py b/dev/probe_usage.py new file mode 100644 index 0000000..d90ee9d --- /dev/null +++ b/dev/probe_usage.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Sonde de l'endpoint /usage Claude pour valider le parser de Monitorink. + +Usage (sur le homelab, après `claude setup-token`) : + MONITORINK_CLAUDE_TOKEN="sk-ant-oat01-..." python3 dev/probe_usage.py + +Affiche la structure JSON brute renvoyée par l'API (uniquement des % d'usage, aucun secret) ++ l'interprétation que Monitorink en fait. Sortie partageable sans risque. +""" +import json +import os +import sys +import urllib.error +import urllib.request + +URL = "https://api.anthropic.com/api/oauth/usage" +token = os.environ.get("MONITORINK_CLAUDE_TOKEN", "").strip() +if not token: + sys.exit("MONITORINK_CLAUDE_TOKEN manquant (export-le ou préfixe la commande)") + +req = urllib.request.Request(URL, headers={ + "Authorization": f"Bearer {token}", + "anthropic-beta": "oauth-2025-04-20", + "User-Agent": "claude-code/2.1.172", + "Content-Type": "application/json", +}) +try: + with urllib.request.urlopen(req, timeout=15) as r: + data = json.load(r) + print("HTTP", r.status) +except urllib.error.HTTPError as e: + sys.exit(f"HTTP {e.code}: {e.read().decode()[:300]}") + +print("=== RÉPONSE BRUTE ===") +print(json.dumps(data, indent=2, ensure_ascii=False)) + +print("\n=== INTERPRÉTATION MONITORINK ===") +for key in ("five_hour", "seven_day", "seven_day_opus", "seven_day_sonnet"): + w = data.get(key) + if isinstance(w, dict): + util = w.get("utilization", 0) + print(f"{key:18s}: {100 - util:.0f}% restant (util={util}, reset={w.get('resets_at')})") + else: + print(f"{key:18s}: {w!r}") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..19d5c53 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + monitorink: + build: ./backend + image: monitorink:latest + container_name: monitorink + restart: unless-stopped + env_file: .env + # Optionnel : burn rate via ccusage (lecture seule des logs Claude Code du homelab). + # Décommenter et passer MONITORINK_CCUSAGE=1 si voulu. + # volumes: + # - /home/jerem/.claude/projects:/root/.claude/projects:ro + networks: + - nestorr + labels: + caddy: http://monitorink.homelab.nestor-server.fr + caddy.reverse_proxy: "{{upstreams 8080}}" + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0) if urllib.request.urlopen('http://localhost:8080/health').status==200 else sys.exit(1)"] + interval: 60s + timeout: 10s + retries: 3 + +networks: + nestorr: + external: true diff --git a/kobo/bin/.gitkeep b/kobo/bin/.gitkeep new file mode 100644 index 0000000..e69de29