"""Public, read-only aggregate reports from the mounted statistics bucket. The module name is retained for compatibility with existing UI fixtures. """ import json import logging from html import escape from datetime import datetime, timedelta from pathlib import Path import gradio as gr from generation_stats import JST LOGGER = logging.getLogger(__name__) MAX_BYTES = 10_000_000 def make_report(raw, registered, now=None, show_past=False): now = (now or datetime.now(JST)).astimezone(JST) today = now.date() yesterday = today - timedelta(days=1) current_month = today.strftime("%Y-%m") previous_month = (today.replace(day=1) - timedelta(days=1)).strftime("%Y-%m") if (not isinstance(raw, dict) or raw.get("version") != 1 or raw.get("timezone") != "Asia/Tokyo" or not isinstance(raw.get("counts"), dict)): raise ValueError("invalid statistics schema") counts = raw["counts"] current = {(family, name) for family, names in registered.items() for name in names} known = set(current) monthly = {} for day, families in counts.items(): parsed = datetime.strptime(day, "%Y-%m-%d").date() if parsed.isoformat() != day or parsed > today or not isinstance(families, dict): raise ValueError("invalid statistics day") month = day[:7] for family, models in families.items(): if family not in ("anima", "illustrious") or not isinstance(models, dict): raise ValueError("invalid statistics family") for name, count in models.items(): if (not isinstance(name, str) or not name or len(name) > 256 or type(count) is not int or not 0 <= count <= 10**12): raise ValueError("invalid statistics count") known.add((family, name)) key = (month, family, name) monthly[key] = monthly.get(key, 0) + count labels = {"anima": "Anima", "illustrious": "Illustrious"} models = sorted(known if show_past else current) daily_rows = [[labels[family], name, counts.get(yesterday.isoformat(), {}).get(family, {}).get(name, 0), counts.get(today.isoformat(), {}).get(family, {}).get(name, 0)] for family, name in models] daily_rows.append(["表示中のモデル合計", "", sum(row[2] for row in daily_rows), sum(row[3] for row in daily_rows)]) month_rows = [[labels[family], name, monthly.get((previous_month, family, name), 0), monthly.get((current_month, family, name), 0)] for family, name in models] month_rows.append(["表示中のモデル合計", "", sum(row[2] for row in month_rows), sum(row[3] for row in month_rows)]) return { "daily_headers": ["モード", "モデル", f"昨日 {yesterday.isoformat()}", f"今日 {today.isoformat()}"], "monthly_headers": ["モード", "モデル", f"前月合計 {previous_month}", f"当月合計 {current_month}"], "daily": daily_rows, "monthly": month_rows, "fetched_at": now.strftime("%Y-%m-%d %H:%M:%S JST"), } class AdminStatistics: def __init__(self, path, registered): self.path = Path(path) if path is not None else None self.registered = registered def fetch(self, show_past=False): # Public aggregates only. No flush, GPU call or Hub upload. if self.path is None: raise ValueError("GENERATION_STATS_DIR is not configured") stat = self.path.stat() if stat.st_size > MAX_BYTES: raise ValueError("statistics file too large") contents = self.path.read_bytes() if len(contents) > MAX_BYTES: raise ValueError("statistics file too large") report = make_report(json.loads(contents), self.registered, show_past=show_past) report["saved_at"] = datetime.fromtimestamp(stat.st_mtime, JST).strftime("%Y-%m-%d %H:%M:%S JST") return report TABLE_CSS = """ width: 100%; max-height: none; overflow: visible; color: var(--body-text-color, #111); background: var(--block-background-fill, #fff); table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: .88rem; } th, td { padding: .45rem; border: 1px solid var(--border-color-primary, #aaa); overflow-wrap: anywhere; vertical-align: top; } tbody td { background: var(--block-background-fill, #fff); } th { text-align: left; } thead th, tfoot th, tfoot td { background: var(--background-fill-secondary, #eee); } td:nth-child(n+3), th:nth-child(n+3) { text-align: right; font-variant-numeric: tabular-nums; } col:first-child { width: 17%; } col:nth-child(2) { width: 45%; } col:nth-child(n+3) { width: 19%; } tfoot td { text-align: right; font-weight: bold; font-variant-numeric: tabular-nums; } @media(max-width: 640px) { th, td { padding: .3rem; } table { font-size: .78rem; } } """ def render_table(headers, rows, caption): """Escape all bucket-derived text; full-height table without inner scrolling.""" head = ''.join(f'{escape(str(h))}' for h in headers) body = ''.join('' + ''.join(f'{escape(format(v, ",") if type(v) is int else str(v))}' for v in row) + '' for row in rows[:-1]) total = rows[-1] foot = (f'{escape(total[0])}' f'{total[2]:,}{total[3]:,}') return (f'' f'{head}{body}{foot}
') def build_admin_statistics(blocks, counter, registered): service = AdminStatistics(counter.path, registered) with gr.Accordion("利用状況・生成統計", open=False, elem_classes=["admin-statistics"]): gr.Markdown("モデル入れ替えの参考として、モデル別の生成件数を公開しています。ログイン不要で閲覧できます。プロンプト・画像・利用者情報は含みません。") with gr.Column(elem_classes=["statistics-panel"]) as panel: show_past = gr.Checkbox(label="過去のモデルも表示", value=False) status = gr.Markdown() gr.Markdown("### 昨日・今日のモデル別生成件数") daily = gr.HTML(value="", css_template=TABLE_CSS, apply_default_css=False, elem_classes=["statistics-table", "statistics-daily"]) gr.Markdown("### 月別合計(前月・当月)") monthly = gr.HTML(value="", css_template=TABLE_CSS, apply_default_css=False, elem_classes=["statistics-table", "statistics-monthly"]) refresh = gr.Button("生成統計を更新") def refresh_statistics(include_past=False): try: report = service.fetch(show_past=include_past) except Exception: LOGGER.warning("Public statistics read failed", exc_info=True) # Do not render exception details, filesystem paths or stale counts. return gr.update(visible=True), "保存済み統計を読み込めません。バケットの接続・保存状態を確認して更新してください。", "", "" text = (f"取得日時:{report['fetched_at']} \n統計ファイル更新日時:{report['saved_at']}\n\n" "日本時間・全利用者のWebUI/MCP経由の成功した生成を集計しています。" "接続済みの統計バケットを読み取っています。通常約5分間隔で保存されるため、未保存分は含みません。" "保存障害時は遅れが大きくなる場合があります。月別合計は前月・当月の記録済み件数で、当月は途中経過です。" "記録のない日・モデルは0として表示します。" "初期表示は現行Checkpointのみです。過去の記録は削除せず、チェックで表示を切り替えられます。" "合計は表示中のモデルだけを対象とし、旧モデル名の件数を現行名へ統合しません。") return (gr.update(visible=True), text, render_table(report["daily_headers"], report["daily"], "昨日・今日のモデル別生成件数"), render_table(report["monthly_headers"], report["monthly"], "前月・当月のモデル別生成件数")) outputs = [panel, status, daily, monthly] refresh.click(refresh_statistics, inputs=show_past, outputs=outputs, api_visibility="private", queue=False, show_progress="hidden") show_past.change(refresh_statistics, inputs=show_past, outputs=outputs, api_visibility="private", queue=False, show_progress="hidden") blocks.load(refresh_statistics, inputs=show_past, outputs=outputs, api_visibility="private", queue=False, show_progress="hidden")