From c22457574f8ff29d61c9f8e801c9ed6ce5f7d7c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Lange?= Date: Sat, 18 Apr 2026 20:03:12 +0200 Subject: [PATCH 01/11] feat(quotas): group by provider, gate on credentials, add skipped view Reworks the /api/quotas surface so the dashboard widget matches how CodexBar presents provider state: - QuotaStatus gains a provider_group field; the widget groups packages under one provider card and subdivides by package inside it - daily / rolling_window statuses accept extra_provider_ids so a shared quota (e.g. Google AI Studio free tier covers both gemini-flash and gemini-flash-lite) is counted across all router IDs that draw from it - /api/quotas applies a credential gate: packages tagged with _requires_credential are hidden when the env var is missing / placeholder, or when the OAuth subject is not in the local token store. Skipped entries are reported back under skipped_packages so operators can see what's dormant and why. - dashboard widget renders one card per provider_group with all its packages stacked (CodexBar-style), plus a small "hidden" callout listing credential-gated packages that aren't active yet --- faigate/main.py | 182 +++++++++++++++++++++++++++++++++------ faigate/quota_tracker.py | 60 ++++++++++--- 2 files changed, 205 insertions(+), 37 deletions(-) diff --git a/faigate/main.py b/faigate/main.py index 2aba15d..757a75a 100644 --- a/faigate/main.py +++ b/faigate/main.py @@ -2827,16 +2827,75 @@ async def operator_events( } +def _credential_available(hint: str | None) -> bool: + """True when ``hint`` names a credential that's actually present. + + Accepts two shapes: + * env-var name (ALL_CAPS): looked up in ``os.environ``; values that + contain ``your-`` or ``your_`` are treated as placeholders. + * OAuth subject: looked up in the local token store's provider list. + + ``None`` / empty hints pass through as "no gating required" β†’ True. + """ + if not hint: + return True + value = str(hint).strip() + if not value: + return True + # env-var heuristic: all-caps + underscores, no hyphens + if value.replace("_", "").isalnum() and value.isupper(): + raw = os.environ.get(value) + if not raw: + return False + low = raw.lower() + if "your-" in low or "your_" in low or low.endswith("-key"): + return False + return True + # OAuth subject: consult token store + try: + from .oauth.token_store import TokenStore + + providers = TokenStore().list_providers() + return value in providers + except Exception: # noqa: BLE001 + return False + + +def _filter_packages_by_credentials( + packages: dict[str, dict[str, Any]], +) -> tuple[dict[str, dict[str, Any]], list[dict[str, str]]]: + """Return (kept, skipped) after applying ``_requires_credential`` gating.""" + kept: dict[str, dict[str, Any]] = {} + skipped: list[dict[str, str]] = [] + for pkg_id, pkg in packages.items(): + hint = pkg.get("_requires_credential") + if _credential_available(hint): + kept[pkg_id] = pkg + else: + skipped.append( + { + "package_id": pkg_id, + "provider_group": str(pkg.get("provider_group") or ""), + "requires": str(hint or ""), + } + ) + return kept, skipped + + @app.get("/api/quotas") async def quotas(): """Unified view across all quota packages (credits / rolling / daily). Returns the QuotaStatus list the dashboard renders as progress bars plus - the latest header-capture snapshot per provider (diagnostic). Never - errors: missing catalog / SQLite path β†’ empty lists. + the latest header-capture snapshot per provider (diagnostic). Packages + whose ``_requires_credential`` cannot be resolved (missing env var, + placeholder value, missing OAuth token) are skipped and reported under + ``skipped_packages``. Never errors: missing catalog / SQLite path β†’ + empty lists. """ from pathlib import Path + from .provider_catalog import get_packages_catalog from .quota_headers import all_latest_snapshots from .quota_tracker import compute_all_statuses @@ -2849,7 +2908,15 @@ async def quotas(): sqlite_path = None try: - statuses = compute_all_statuses(sqlite_path=sqlite_path) + raw_packages = get_packages_catalog() or {} + except Exception as exc: # noqa: BLE001 + logger.warning("get_packages_catalog failed: %s", exc) + raw_packages = {} + + filtered_packages, skipped_packages = _filter_packages_by_credentials(raw_packages) + + try: + statuses = compute_all_statuses(sqlite_path=sqlite_path, packages_cache=filtered_packages) except Exception as exc: # noqa: BLE001 logger.warning("compute_all_statuses failed: %s", exc) statuses = [] @@ -2882,6 +2949,7 @@ async def quotas(): "has_use_or_lose": any(s.get("alert") == "use_or_lose" for s in statuses_json), "has_exhausted": any(s.get("alert") == "exhausted" for s in statuses_json), "header_snapshots": snapshots_out, + "skipped_packages": skipped_packages, } @@ -3377,22 +3445,39 @@ async def dashboard(): background: var(--card); border: 1px solid var(--border); } .pill.urgent { border-color: var(--uol); color: var(--uol); } - .grid { display: grid; gap: 12px; grid-template-columns: 1fr; max-width: 980px; } - .card { + .grid { display: grid; gap: 14px; grid-template-columns: 1fr; max-width: 980px; } + .provider { background: var(--card); border: 1px solid var(--border); - border-radius: 8px; padding: 14px 16px; + border-radius: 10px; padding: 14px 16px; + } + .provider.urgent { border-left: 3px solid var(--uol); } + .provider.watch { border-left: 3px solid var(--watch); } + .provider.ok { border-left: 3px solid var(--ok); } + .provider.topup { border-left: 3px solid var(--topup); } + .provider.exhausted { border-left: 3px solid var(--exhausted); opacity: 0.85; } + .provider-head { + display: flex; justify-content: space-between; align-items: baseline; + margin-bottom: 4px; } - .card.urgent { border-left: 3px solid var(--uol); } - .card.watch { border-left: 3px solid var(--watch); } - .card.ok { border-left: 3px solid var(--ok); } - .card.topup { border-left: 3px solid var(--topup); } - .card.exhausted { border-left: 3px solid var(--exhausted); opacity: 0.7; } + .provider-name { + font-weight: 700; font-size: 15px; text-transform: capitalize; + letter-spacing: .3px; + } + .provider-ids { color: var(--dim); font-size: 11px; } + .pkg { + padding: 10px 0 2px; + border-top: 1px dashed var(--border); + margin-top: 8px; + } + .pkg:first-of-type { border-top: none; margin-top: 0; padding-top: 2px; } .row1 { display: flex; justify-content: space-between; align-items: baseline; } - .title { font-weight: 600; font-size: 14px; } - .type { color: var(--dim); font-size: 11px; text-transform: uppercase; } + .title { font-weight: 500; font-size: 13px; } + .title .emoji { margin-right: 4px; } + .title .pkg-id { color: var(--dim); font-weight: 400; } + .type { color: var(--dim); font-size: 10px; text-transform: uppercase; letter-spacing: .5px; } .bar { height: 6px; background: #262a36; border-radius: 3px; - margin: 8px 0 6px; overflow: hidden; + margin: 6px 0 6px; overflow: hidden; } .bar-fill { height: 100%; transition: width .3s; } .bar-fill.ok { background: var(--ok); } @@ -3401,11 +3486,11 @@ async def dashboard(): .bar-fill.use_or_lose { background: var(--uol); } .bar-fill.exhausted { background: var(--exhausted); } .meta { - display: flex; gap: 16px; flex-wrap: wrap; - font-size: 12px; color: var(--dim); + display: flex; gap: 14px; flex-wrap: wrap; + font-size: 11.5px; color: var(--dim); } .meta .k { color: var(--fg); font-weight: 500; } - .notes { margin-top: 6px; font-size: 11px; color: var(--dim); font-style: italic; } + .notes { margin-top: 4px; font-size: 10.5px; color: var(--dim); font-style: italic; } .empty { padding: 40px; text-align: center; color: var(--dim); } a { color: #60a5fa; text-decoration: none; } a:hover { text-decoration: underline; } @@ -3419,9 +3504,11 @@ async def dashboard():
Loading…
+
+ + +""" + + def _cockpit_base_url() -> str: """Resolve the Operator Cockpit base URL the widget links out to. @@ -3881,6 +4252,189 @@ async def dashboard_quotas(): return _QUOTAS_DASHBOARD_HTML.replace("__COCKPIT_URL__", _cockpit_base_url()) +def _brand_context(brand_slug: str) -> dict[str, Any] | None: + """Resolve a ``brand_slug`` to the runtime providers feeding it. + + Returns ``None`` when the brand is unknown or has no active packages on + this machine β€” the endpoints then 404 rather than silently returning + empty payloads, so the widget can distinguish "typo in URL" from + "brand exists but no traffic yet". + + The returned dict has: + - ``brand``: display name (e.g. ``"Claude"``) + - ``brand_slug``: echoed back for the client + - ``providers``: sorted list of ``provider_id`` strings feeding this + brand (the ``requests.provider`` column values to filter on) + - ``packages``: the list of active package dicts (for the detail + header β€” package names, tiers, pace markers) + """ + from .provider_catalog import get_packages_catalog + from .quota_tracker import _derive_brand, _slugify_brand + + slug = (brand_slug or "").strip().lower() + if not slug: + return None + + try: + raw_packages = get_packages_catalog() or {} + except Exception as exc: # noqa: BLE001 + logger.warning("get_packages_catalog failed in _brand_context: %s", exc) + raw_packages = {} + + filtered_packages, _ = _filter_packages_by_credentials(raw_packages) + + providers: set[str] = set() + packages: list[dict[str, Any]] = [] + brand_name = "" + for pkg in filtered_packages.values(): + pkg_brand = str(pkg.get("brand") or _derive_brand(pkg.get("provider_group") or "")) + pkg_slug = str(pkg.get("brand_slug") or _slugify_brand(pkg_brand)) + if pkg_slug != slug: + continue + if not brand_name: + brand_name = pkg_brand + pid = str(pkg.get("provider_id") or "").strip() + if pid: + providers.add(pid) + packages.append(pkg) + + if not packages: + return None + + return { + "brand": brand_name or slug.title(), + "brand_slug": slug, + "providers": sorted(providers), + "packages": packages, + } + + +@app.get("/api/quotas/{brand_slug}/clients") +async def api_quota_brand_clients(brand_slug: str): + """Clients (profile + tag) that hit this brand's providers. + + Read-only aggregation over the SQLite ``requests`` table, scoped to the + providers feeding the given brand. Used by the per-brand detail view + (``/dashboard/quotas/``). Returns ``404`` when the brand has no + active packages β€” consistent with the widget's "active-first" story. + """ + ctx = _brand_context(brand_slug) + if ctx is None: + return JSONResponse({"error": {"message": "Unknown brand"}}, status_code=404) + + if not ctx["providers"]: + return { + "brand": ctx["brand"], + "brand_slug": ctx["brand_slug"], + "providers": [], + "clients": [], + "client_totals": [], + } + + filters = {"providers": ctx["providers"]} + return { + "brand": ctx["brand"], + "brand_slug": ctx["brand_slug"], + "providers": ctx["providers"], + "clients": _metrics.get_client_breakdown(**filters), + "client_totals": _metrics.get_client_totals(**filters), + } + + +@app.get("/api/quotas/{brand_slug}/routes") +async def api_quota_brand_routes(brand_slug: str): + """Lane-family + routing breakdown for a brand's providers. + + Feeds the "Routes" panel in the per-brand detail view. Mirrors the shape + of ``/api/stats.routing`` / ``.lane_families`` but scoped to a single + brand so the widget can render a focused table without client-side + filtering. + """ + ctx = _brand_context(brand_slug) + if ctx is None: + return JSONResponse({"error": {"message": "Unknown brand"}}, status_code=404) + + if not ctx["providers"]: + return { + "brand": ctx["brand"], + "brand_slug": ctx["brand_slug"], + "providers": [], + "lane_families": [], + "routing": [], + "selection_paths": [], + } + + filters = {"providers": ctx["providers"]} + return { + "brand": ctx["brand"], + "brand_slug": ctx["brand_slug"], + "providers": ctx["providers"], + "lane_families": _metrics.get_lane_family_breakdown(**filters), + "routing": _metrics.get_routing_breakdown(**filters), + "selection_paths": _metrics.get_selection_path_breakdown(**filters), + } + + +@app.get("/api/quotas/{brand_slug}/analytics") +async def api_quota_brand_analytics(brand_slug: str, hours: int = 24, days: int = 14): + """Time-series + totals for a brand's providers. + + Returns ``totals``, ``providers`` (per-provider summary), ``hourly`` + (last N hours), and ``daily`` (last N days) β€” enough for the detail + view's sparkline + the single-number summary cards. Defaults cover a + 2-week window so most operators see a meaningful chart on first load. + """ + ctx = _brand_context(brand_slug) + if ctx is None: + return JSONResponse({"error": {"message": "Unknown brand"}}, status_code=404) + + # Clamp to sane ranges so a typo can't hammer the DB with a 100-year + # scan. These are pulled straight from the widget's controls, so the + # upper bounds match the UI options we expose. + hours = max(1, min(int(hours or 24), 24 * 7)) + days = max(1, min(int(days or 14), 90)) + + if not ctx["providers"]: + return { + "brand": ctx["brand"], + "brand_slug": ctx["brand_slug"], + "providers": [], + "totals": {}, + "provider_summary": [], + "hourly": [], + "daily": [], + } + + filters = {"providers": ctx["providers"]} + return { + "brand": ctx["brand"], + "brand_slug": ctx["brand_slug"], + "providers": ctx["providers"], + "totals": _metrics.get_totals(**filters), + "provider_summary": _metrics.get_provider_summary(**filters), + "hourly": _metrics.get_hourly_series(hours, **filters), + "daily": _metrics.get_daily_totals(days, **filters), + } + + +@app.get("/dashboard/quotas/{brand_slug}", response_class=HTMLResponse) +async def dashboard_quota_brand(brand_slug: str): + """Per-brand detail view β€” single-brand quota card + clients + routes + analytics. + + Renders the same shell for every brand; JS pulls the four data sources + (``/api/quotas``, ``/api/quotas//{clients,routes,analytics}``) on + load and every 60s. Unknown-brand 404s are handled client-side by + showing the "Back to overview" link. + """ + slug = (brand_slug or "").strip().lower() + if not slug: + return JSONResponse({"error": {"message": "Unknown brand"}}, status_code=404) + html = _QUOTAS_BRAND_DETAIL_HTML + html = html.replace("__COCKPIT_URL__", _cockpit_base_url()) + html = html.replace("__BRAND_SLUG__", slug) + return html + + @app.get("/dashboard/assets/{asset_kind}/{asset_name:path}") async def dashboard_asset(asset_kind: str, asset_name: str): """Serve packaged dashboard assets such as fonts.""" diff --git a/faigate/metrics.py b/faigate/metrics.py index f554264..7b05c3c 100644 --- a/faigate/metrics.py +++ b/faigate/metrics.py @@ -425,33 +425,37 @@ def get_modality_breakdown(self, **filters: Any) -> list[dict]: params, ) - def get_hourly_series(self, hours: int = 24) -> list[dict]: + def get_hourly_series(self, hours: int = 24, **filters: Any) -> list[dict]: cutoff = time.time() - hours * 3600 + filters = {**filters, "since": cutoff} + where_sql, params = self._build_where_clause(filters) return self._q( - """ + f""" SELECT CAST((timestamp-?)/3600 AS INTEGER) AS hour_offset, COUNT(*) AS requests, ROUND(SUM(cost_usd),6) AS cost_usd, SUM(prompt_tok+compl_tok) AS tokens - FROM requests WHERE timestamp>=? + FROM requests{where_sql} GROUP BY hour_offset ORDER BY hour_offset """, - (cutoff, cutoff), + (cutoff, *params), ) - def get_daily_totals(self, days: int = 30) -> list[dict]: + def get_daily_totals(self, days: int = 30, **filters: Any) -> list[dict]: cutoff = time.time() - days * 86400 + filters = {**filters, "since": cutoff} + where_sql, params = self._build_where_clause(filters) return self._q( - """ + f""" SELECT DATE(timestamp,'unixepoch','localtime') AS day, COUNT(*) AS requests, ROUND(SUM(cost_usd),6) AS cost_usd, SUM(prompt_tok+compl_tok) AS tokens, SUM(CASE WHEN success=0 THEN 1 ELSE 0 END) AS failures - FROM requests WHERE timestamp>=? + FROM requests{where_sql} GROUP BY day ORDER BY day """, - (cutoff,), + params, ) def get_operator_events(self, limit: int = 50, **filters: Any) -> list[dict]: @@ -552,6 +556,29 @@ def _build_where_clause(self, filters: dict[str, Any]) -> tuple[str, tuple[Any, clauses.append("success = ?") params.append(1 if bool(success) else 0) + # Multi-provider filter (``provider IN (...)``). Used by per-brand + # detail endpoints that aggregate across every runtime provider + # belonging to a brand. Deduped + order-preserved so query plans stay + # cache-friendly. + providers = filters.get("providers") + if providers: + unique: list[str] = [] + seen: set[str] = set() + for item in providers: + key = str(item) + if key and key not in seen: + seen.add(key) + unique.append(key) + if unique: + placeholders = ",".join("?" * len(unique)) + clauses.append(f"provider IN ({placeholders})") + params.extend(unique) + + since = filters.get("since") + if since not in (None, ""): + clauses.append("timestamp >= ?") + params.append(float(since)) + if not clauses: return "", () return f" WHERE {' AND '.join(clauses)}", tuple(params) diff --git a/tests/test_brand_detail_endpoints.py b/tests/test_brand_detail_endpoints.py new file mode 100644 index 0000000..86b8b5c --- /dev/null +++ b/tests/test_brand_detail_endpoints.py @@ -0,0 +1,346 @@ +"""Coverage for the v2.3 per-brand detail view endpoints. + +Pins the contract the ``/dashboard/quotas/`` widget reads from: + +1. ``_brand_context(slug)`` resolves a brand_slug β†’ active providers, or + returns ``None`` for unknown/no-active-packages brands (so the HTTP + handlers 404 rather than returning empty JSON). +2. ``/api/quotas//{clients,routes,analytics}`` all 404 on unknown + brands and echo back ``brand`` / ``brand_slug`` / ``providers`` on + success so the widget can render a header without a second round trip. +3. The multi-provider ``providers=[...]`` filter reaches the metrics + layer (``_build_where_clause`` turns it into ``provider IN (...)``). +4. ``/dashboard/quotas/`` serves the detail-view shell with the + brand slug and cockpit URL substituted at render time. + +See ``docs/GATE-BAR-DESIGN.md`` Β§3.4 for the Design-Thinking rationale +("quick view most-relevant subset of the Operator Cockpit"). +""" + +from __future__ import annotations + +import importlib +import sys +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any + +import pytest + +sys.modules.pop("httpx", None) +import httpx # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +sys.modules["httpx"] = httpx + +sys.modules.pop("faigate.providers", None) +sys.modules.pop("faigate.updates", None) +sys.modules.pop("faigate.main", None) + +import faigate.main as main_module # noqa: E402 +from faigate.config import load_config # noqa: E402 +from faigate.router import Router # noqa: E402 + +importlib.reload(main_module) + + +# ── Canned catalog ──────────────────────────────────────────────────────────── +# Two active brands (Claude + DeepSeek), one unreachable brand (Qwen β€” the +# credential is "missing" in the stub). This mirrors the real fusionAIze +# catalog shape after the v1.3 brand pivot. + +_CATALOG: dict[str, dict[str, Any]] = { + "anthropic-pro-5h-session": { + "package_id": "anthropic-pro-5h-session", + "provider_id": "anthropic-claude", + "provider_group": "anthropic", + "brand": "Claude", + "brand_slug": "claude", + "package_type": "rolling_window", + "window_hours": 5, + "limit_per_window": 100, + "_requires_credential": "claude-code", + }, + "anthropic-pro-weekly": { + "package_id": "anthropic-pro-weekly", + "provider_id": "anthropic-claude-weekly", + "provider_group": "anthropic", + "brand": "Claude", + "brand_slug": "claude", + "package_type": "rolling_window", + "window_hours": 168, + "limit_per_window": 500, + "_requires_credential": "claude-code", + }, + "deepseek-pay-as-you-go": { + "package_id": "deepseek-pay-as-you-go", + "provider_id": "deepseek-chat", + "provider_group": "deepseek", + "brand": "DeepSeek", + "brand_slug": "deepseek", + "package_type": "credits", + "total_credits": 28.42, + "used_credits": 0.0, + "_requires_credential": "DEEPSEEK_API_KEY", + }, + "qwen-free-daily": { + "package_id": "qwen-free-daily", + "provider_id": "qwen-portal", + "provider_group": "qwen", + "brand": "Qwen", + "brand_slug": "qwen", + "package_type": "daily", + "limit_per_day": 2000, + "_requires_credential": "qwen-portal-missing", + }, +} + + +def _write_config(tmp_path: Path, body: str) -> Path: + path = tmp_path / "config.yaml" + path.write_text(body) + return path + + +class _MetricsRecorder: + """Metrics double that records the filters passed to each method. + + The endpoints' *only* job on top of the metrics layer is to inject + ``providers=[...]`` β€” so we check the recorded call shape rather than + the (boring) fake data coming back. + """ + + def __init__(self): + self.calls: list[tuple[str, dict]] = [] + + def _record(self, name, **kwargs): + self.calls.append((name, kwargs)) + + # Quota endpoints use these four: + def get_client_breakdown(self, **kw): + self._record("get_client_breakdown", **kw) + return [{"client_profile": "openclaw", "client_tag": "agent", "requests": 3}] + + def get_client_totals(self, **kw): + self._record("get_client_totals", **kw) + return [{"client_profile": "openclaw", "client_tag": "agent", "requests": 3}] + + def get_lane_family_breakdown(self, **kw): + self._record("get_lane_family_breakdown", **kw) + return [{"lane_family": "claude-coding", "requests": 3, "providers": 1, "cost_usd": 0.0}] + + def get_routing_breakdown(self, **kw): + self._record("get_routing_breakdown", **kw) + return [] + + def get_selection_path_breakdown(self, **kw): + self._record("get_selection_path_breakdown", **kw) + return [] + + def get_totals(self, **kw): + self._record("get_totals", **kw) + return {"total_requests": 3, "total_failures": 0, "total_cost_usd": 0.0} + + def get_provider_summary(self, **kw): + self._record("get_provider_summary", **kw) + return [] + + def get_hourly_series(self, *args, **kw): + self._record("get_hourly_series", hours=args[0] if args else None, **kw) + return [{"hour_offset": 0, "requests": 1, "cost_usd": 0.0, "tokens": 10}] + + def get_daily_totals(self, *args, **kw): + self._record("get_daily_totals", days=args[0] if args else None, **kw) + return [] + + # Unused by the brand endpoints but the quotas handler needs them: + def log_request(self, **_kw): + pass + + +@pytest.fixture +def api_client(tmp_path, monkeypatch): + cfg = load_config( + _write_config( + tmp_path, + """ +server: + host: "127.0.0.1" + port: 8090 +providers: {} +fallback_chain: [] +metrics: + enabled: false +""", + ) + ) + + @asynccontextmanager + async def _noop_lifespan(_app): + yield + + recorder = _MetricsRecorder() + monkeypatch.setattr(main_module, "_config", cfg, raising=False) + monkeypatch.setattr(main_module, "_router", Router(cfg), raising=False) + monkeypatch.setattr(main_module, "_providers", {}, raising=False) + monkeypatch.setattr(main_module, "_metrics", recorder, raising=False) + monkeypatch.setattr(main_module.app.router, "lifespan_context", _noop_lifespan, raising=False) + + # Catalog shim: both the detail endpoints and /api/quotas reach into + # ``provider_catalog.get_packages_catalog``. Patch the canonical module. + from faigate import provider_catalog + + monkeypatch.setattr(provider_catalog, "get_packages_catalog", lambda: _CATALOG, raising=False) + + # Credential gate: treat "claude-code" + "DEEPSEEK_API_KEY" as present, + # but reject the Qwen one so it appears in skipped/inactive. + def _cred_available(hint): + return bool(hint) and hint != "qwen-portal-missing" + + monkeypatch.setattr(main_module, "_credential_available", _cred_available, raising=False) + + with TestClient(main_module.app) as client: + client.metrics_recorder = recorder # type: ignore[attr-defined] + yield client + + +# ── _brand_context unit tests ──────────────────────────────────────────────── + + +class TestBrandContext: + def test_known_brand_returns_providers(self, api_client): + ctx = main_module._brand_context("claude") + assert ctx is not None + assert ctx["brand"] == "Claude" + assert ctx["brand_slug"] == "claude" + # Two packages under the same brand contribute two provider IDs. + assert ctx["providers"] == ["anthropic-claude", "anthropic-claude-weekly"] + assert len(ctx["packages"]) == 2 + + def test_unknown_brand_returns_none(self, api_client): + assert main_module._brand_context("nope") is None + assert main_module._brand_context("") is None + + def test_inactive_brand_returns_none(self, api_client): + # Qwen is in the catalog but credential missing β†’ filtered out β†’ + # _brand_context sees no packages β†’ None (so the endpoint 404s + # instead of silently returning empty data for "active" Qwen). + assert main_module._brand_context("qwen") is None + + def test_slug_is_case_insensitive(self, api_client): + ctx = main_module._brand_context("ClAuDe") + assert ctx is not None + assert ctx["brand_slug"] == "claude" + + +# ── HTTP-level tests ────────────────────────────────────────────────────────── + + +class TestClientsEndpoint: + def test_returns_404_for_unknown_brand(self, api_client): + r = api_client.get("/api/quotas/nope/clients") + assert r.status_code == 404 + + def test_returns_clients_scoped_to_brand_providers(self, api_client): + r = api_client.get("/api/quotas/claude/clients") + assert r.status_code == 200 + body = r.json() + assert body["brand"] == "Claude" + assert body["brand_slug"] == "claude" + assert body["providers"] == ["anthropic-claude", "anthropic-claude-weekly"] + assert body["clients"] and body["clients"][0]["client_profile"] == "openclaw" + + # Most important: the providers list reached the metrics layer. + recorder: _MetricsRecorder = api_client.metrics_recorder # type: ignore[attr-defined] + breakdown_calls = [c for c in recorder.calls if c[0] == "get_client_breakdown"] + assert breakdown_calls + assert breakdown_calls[-1][1]["providers"] == [ + "anthropic-claude", + "anthropic-claude-weekly", + ] + + +class TestRoutesEndpoint: + def test_returns_404_for_unknown_brand(self, api_client): + r = api_client.get("/api/quotas/nope/routes") + assert r.status_code == 404 + + def test_lane_families_filter_passes_through(self, api_client): + r = api_client.get("/api/quotas/deepseek/routes") + assert r.status_code == 200 + body = r.json() + assert body["brand"] == "DeepSeek" + assert body["providers"] == ["deepseek-chat"] + assert body["lane_families"][0]["lane_family"] == "claude-coding" + + recorder: _MetricsRecorder = api_client.metrics_recorder # type: ignore[attr-defined] + lane_calls = [c for c in recorder.calls if c[0] == "get_lane_family_breakdown"] + assert lane_calls + assert lane_calls[-1][1]["providers"] == ["deepseek-chat"] + + +class TestAnalyticsEndpoint: + def test_returns_404_for_unknown_brand(self, api_client): + r = api_client.get("/api/quotas/nope/analytics") + assert r.status_code == 404 + + def test_defaults_and_clamping(self, api_client): + # 99999-hour window should be clamped to the max (24 * 7 = 168h). + r = api_client.get("/api/quotas/claude/analytics?hours=99999&days=9999") + assert r.status_code == 200 + recorder: _MetricsRecorder = api_client.metrics_recorder # type: ignore[attr-defined] + hourly_calls = [c for c in recorder.calls if c[0] == "get_hourly_series"] + daily_calls = [c for c in recorder.calls if c[0] == "get_daily_totals"] + assert hourly_calls and hourly_calls[-1][1]["hours"] == 24 * 7 + assert daily_calls and daily_calls[-1][1]["days"] == 90 + + def test_totals_and_series_piped_through(self, api_client): + r = api_client.get("/api/quotas/claude/analytics") + body = r.json() + assert body["brand"] == "Claude" + assert body["totals"]["total_requests"] == 3 + assert body["hourly"][0]["requests"] == 1 + # Providers filter is propagated to every metrics call the endpoint + # makes β€” this is the core invariant the detail view relies on. + recorder: _MetricsRecorder = api_client.metrics_recorder # type: ignore[attr-defined] + for name in ( + "get_totals", + "get_provider_summary", + "get_hourly_series", + "get_daily_totals", + ): + matching = [c for c in recorder.calls if c[0] == name] + assert matching, f"{name} was not called" + assert matching[-1][1]["providers"] == [ + "anthropic-claude", + "anthropic-claude-weekly", + ], f"{name} missing providers filter" + + +class TestDetailHTML: + def test_brand_slug_is_substituted(self, api_client): + r = api_client.get("/dashboard/quotas/claude") + assert r.status_code == 200 + text = r.text + assert 'const BRAND_SLUG = "claude"' in text + # No stray placeholders should leak into the response. + assert "__BRAND_SLUG__" not in text + assert "__COCKPIT_URL__" not in text + # The overview page's "back" breadcrumb is wired up. + assert "/dashboard/quotas" in text + + def test_cockpit_url_respects_env(self, api_client, monkeypatch): + monkeypatch.setenv("FAIGATE_COCKPIT_URL", "https://cockpit.example.test/") + r = api_client.get("/dashboard/quotas/deepseek") + assert r.status_code == 200 + # Trailing slash stripped by _cockpit_base_url; the JS string is + # baked in at render time. + assert 'COCKPIT_URL = "https://cockpit.example.test"' in r.text + + def test_unknown_brand_slug_still_serves_shell(self, api_client): + # The HTML is intentionally brand-agnostic β€” it probes the API on + # load and shows "Brand not found" client-side when the API 404s. + # Serving a 404 for the page itself would break shareable links. + r = api_client.get("/dashboard/quotas/made-up-brand") + assert r.status_code == 200 + assert 'const BRAND_SLUG = "made-up-brand"' in r.text From 9e5dc0b32a476a05d89a561d046e384f7736c727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Lange?= Date: Sun, 19 Apr 2026 15:10:58 +0200 Subject: [PATCH 05/11] feat(gate-bar): SwiftUI menubar companion 0.1 scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase C of the v2.3.0 Gate Bar rollout. Ships a macOS 14+ Universal menubar app at ``apps/gate-bar/`` that reads the local faigate gateway's ``/api/quotas`` and surfaces every active brand at a glance β€” colour-coded by severity, sorted worst-alert first. Architecture keeps the boundary sharp (see docs/GATE-BAR-DESIGN.md Β§5): - Pure HTTP client on 127.0.0.1 β€” no shared state, no socket, no filesystem coupling with the Python daemon. - Brand roster discovered at runtime; the app ships with no hard-coded provider enum. - Read-only: every mutating action (add provider, edit lanes, etc.) deep-links to the Operator Cockpit. SwiftUI surface stays in the Sonoma subset per the design doc: ``ObservableObject`` + Combine, plain ``Color``, no ``@Observable``, no ``MeshGradient``. Package.swift pins ``.macOS(.v14)``. Module layout: - ``Models.swift`` β€” Codable mirror of ``/api/quotas`` (forward- compatible decode so the Python side can add fields without breaking Gate Bar), plus ``BrandGroup`` + ``AlertLevel`` aggregates. - ``QuotaClient.swift`` β€” URLSession actor, single network surface. - ``QuotaStore.swift`` β€” ``ObservableObject`` that groups packages by ``brand_slug``, sorts worst-alert first, and exposes a tightest- window menubar summary. - ``Preferences.swift`` β€” UserDefaults-backed ``@Published`` wrapper (gateway URL, cockpit URL, refresh cadence). - ``Theme.swift`` β€” colour palette mirroring the web widget's CSS variables so the menubar reads as the same product. - ``PopoverView.swift`` / ``BrandCardView.swift`` β€” popover shell + per-brand card with pace tick (same vocabulary as Β§3 of the design). - ``PreferencesView.swift`` β€” settings window, 4 controls, no wizards. - ``GateBarApp.swift`` β€” ``@main`` + ``MenuBarExtra`` / ``Settings`` scenes; menubar label is "fAI Β· NN%" with a coloured dot. Tests (13, Swift Testing framework): - JSON decode round-trip + forward-compatibility. - AlertLevel classification (server-label precedence, ratio fallback, unknown-string degradation). - Store grouping, worst-alert sort, tie-break rules, menubar summary. CLT-only machines need explicit rpaths for Swift Testing β€” wrapped in ``scripts/swift-test.sh`` so ``./scripts/swift-test.sh`` works whether the dev has Xcode.app installed or not. Out of scope for 0.1 (tracked in apps/gate-bar/README.md): Sparkle auto-update, notifications, launch-at-login, .app bundling + notarization, Homebrew cask. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 7 + apps/gate-bar/Package.swift | 44 ++++ apps/gate-bar/README.md | 117 ++++++++++ .../Sources/GateBar/BrandCardView.swift | 169 ++++++++++++++ .../gate-bar/Sources/GateBar/GateBarApp.swift | 71 ++++++ apps/gate-bar/Sources/GateBar/Models.swift | 196 ++++++++++++++++ .../Sources/GateBar/PopoverView.swift | 220 ++++++++++++++++++ .../Sources/GateBar/Preferences.swift | 65 ++++++ .../Sources/GateBar/PreferencesView.swift | 57 +++++ .../Sources/GateBar/QuotaClient.swift | 90 +++++++ .../gate-bar/Sources/GateBar/QuotaStore.swift | 134 +++++++++++ apps/gate-bar/Sources/GateBar/Theme.swift | 40 ++++ .../Tests/GateBarTests/ModelsTests.swift | 135 +++++++++++ .../Tests/GateBarTests/QuotaStoreTests.swift | 125 ++++++++++ apps/gate-bar/scripts/swift-test.sh | 49 ++++ docs/GATE-BAR-DESIGN.md | 9 +- 16 files changed, 1525 insertions(+), 3 deletions(-) create mode 100644 apps/gate-bar/Package.swift create mode 100644 apps/gate-bar/README.md create mode 100644 apps/gate-bar/Sources/GateBar/BrandCardView.swift create mode 100644 apps/gate-bar/Sources/GateBar/GateBarApp.swift create mode 100644 apps/gate-bar/Sources/GateBar/Models.swift create mode 100644 apps/gate-bar/Sources/GateBar/PopoverView.swift create mode 100644 apps/gate-bar/Sources/GateBar/Preferences.swift create mode 100644 apps/gate-bar/Sources/GateBar/PreferencesView.swift create mode 100644 apps/gate-bar/Sources/GateBar/QuotaClient.swift create mode 100644 apps/gate-bar/Sources/GateBar/QuotaStore.swift create mode 100644 apps/gate-bar/Sources/GateBar/Theme.swift create mode 100644 apps/gate-bar/Tests/GateBarTests/ModelsTests.swift create mode 100644 apps/gate-bar/Tests/GateBarTests/QuotaStoreTests.swift create mode 100755 apps/gate-bar/scripts/swift-test.sh diff --git a/.gitignore b/.gitignore index 73ecace..7732960 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,10 @@ venv/ .understand-anything/ .codenomad/ catalog_output.json + +# Swift Package Manager build artifacts (apps/gate-bar) +.build/ +.swiftpm/ +*.xcodeproj/ +xcuserdata/ +DerivedData/ diff --git a/apps/gate-bar/Package.swift b/apps/gate-bar/Package.swift new file mode 100644 index 0000000..0f50e78 --- /dev/null +++ b/apps/gate-bar/Package.swift @@ -0,0 +1,44 @@ +// swift-tools-version:5.9 +// +// fusionAIze Gate Bar β€” macOS menubar companion for the faigate local gateway. +// +// Design anchors (see ../../docs/GATE-BAR-DESIGN.md Β§5): +// +// - macOS 14 (Sonoma) minimum β€” keeps two-year-old Intel MacBooks alive. +// - Universal binary (x86_64 + arm64). SPM handles this at release time via +// swift build -c release --arch x86_64 --arch arm64 +// - SwiftUI surface is the Sonoma subset: ObservableObject, Combine, plain +// Color. No @Observable, no MeshGradient. +// - Pure HTTP consumer of the local gateway β€” no shared state, no socket, +// no filesystem coupling with the Python daemon. +// +// Why SPM executable instead of an .xcodeproj: +// The monorepo is CLI-first; a hand-crafted Package.swift keeps the app +// reviewable in a diff and builds with `swift build` on any machine with the +// Xcode command-line tools. Opening `apps/gate-bar/Package.swift` in Xcode +// still gives the full GUI editor for anyone who wants one. +import PackageDescription + +let package = Package( + name: "GateBar", + platforms: [ + .macOS(.v14), + ], + products: [ + // The app binary itself. Distribution (notarization, .app bundling, + // Sparkle, Homebrew cask) is release-engineering scaffolding tracked + // separately β€” not wired into the SPM manifest. + .executable(name: "GateBar", targets: ["GateBar"]), + ], + targets: [ + .executableTarget( + name: "GateBar", + path: "Sources/GateBar" + ), + .testTarget( + name: "GateBarTests", + dependencies: ["GateBar"], + path: "Tests/GateBarTests" + ), + ] +) diff --git a/apps/gate-bar/README.md b/apps/gate-bar/README.md new file mode 100644 index 0000000..653d8c1 --- /dev/null +++ b/apps/gate-bar/README.md @@ -0,0 +1,117 @@ +# fusionAIze Gate Bar + +A macOS menubar companion for the [faigate](../../README.md) local gateway. +Shows every active provider's quota at a glance, colour-coded by severity, +so you can answer "am I about to hit a session cap?" in under three seconds +without switching tabs. + +> **Status:** v0.1 β€” scaffold in place, read-only consumer of the local +> gateway's `/api/quotas` endpoint. Sparkle auto-update, notifications, +> code signing, and Homebrew cask distribution are tracked separately and +> not wired up yet. + +## What it does today + +- **Menubar label** β€” `fAI Β· 83%` plus a coloured dot for the tightest + window across all active brands. Click to open the popover. +- **Popover** β€” one card per brand (Claude, Codex, DeepSeek, …), sorted + worst-alert first. Each card shows package bars with a pace marker, + identity line, and reset time. +- **"Available to add" mini-catalog** β€” brands the operator hasn't wired + up yet, each with a deep link to the Operator Cockpit's onboarding flow. +- **Preferences** β€” gateway URL, Cockpit URL, refresh cadence (manual / + 1 / 2 / 5 / 15 min; default 5). +- **Privacy posture** β€” reads from `127.0.0.1` only. Gate Bar never talks + to a fusionAIze-hosted service; the "Cockpit β†—" button just opens a web + page in your default browser. + +## Design anchors + +The full design doc is at `../../docs/GATE-BAR-DESIGN.md`. Three rules +shape every file in this directory: + +1. **Pure HTTP client.** No shared state, no socket, no filesystem + coupling with the Python daemon. Every provider the Gate Bar renders + is discovered at runtime from `GET /api/quotas`. +2. **macOS 14+ Sonoma.** Two-year-old Intel MacBooks still run it. + SwiftUI surface is the Sonoma subset: `ObservableObject` + Combine, + plain `Color`, no `@Observable`, no `MeshGradient`. +3. **Read-only.** Nothing in the menubar writes config or wakes up an + onboarding wizard. Every action that mutates state deep-links to the + Operator Cockpit. + +## Build & run + +Requires the Xcode Command Line Tools (`xcode-select --install`) or +Xcode.app. Swift 5.9+ toolchain. + +```bash +cd apps/gate-bar +swift build # debug build of the executable target +swift run GateBar # launches the menubar app +``` + +The app launches as a `LSUIElement`-style menubar-only process β€” there's +no Dock icon or main window. Quit from the popover's "Quit" button or via +`⌘Q` while Gate Bar is the frontmost app. + +### Running against a local gateway + +By default Gate Bar talks to `http://127.0.0.1:4001` β€” the faigate +default. If you run the gateway on a different port, update it under +`Preferences β†’ Gateway`. + +### Tests + +```bash +./scripts/swift-test.sh +``` + +We use the **Swift Testing** framework (`import Testing`) rather than +XCTest because the Command Line Tools ship Testing but not XCTest. The +wrapper script adds the framework search paths dyld needs at runtime; on +a machine with Xcode.app, plain `swift test` also works. + +Current coverage (13 tests, 3 suites): + +- JSON-decode round-trip against a canned `/api/quotas` payload. +- Forward compatibility β€” unknown JSON fields don't break decode. +- `AlertLevel` classification (server-label precedence, ratio fallback, + unknown-string degradation, severity ordering). +- `QuotaStore` transforms (brand grouping, worst-alert sort, tie-break + rules, tightest-window menubar summary, identity propagation). + +## File map + +``` +Package.swift # SPM manifest, .macOS(.v14), executable target +Sources/GateBar/ + GateBarApp.swift # @main, MenuBarExtra + Settings scenes + Models.swift # Codable mirrors of /api/quotas (plus BrandGroup, AlertLevel) + QuotaClient.swift # URLSession-backed actor, the only network I/O + QuotaStore.swift # ObservableObject β€” grouping, sorting, menubar summary, timer + Preferences.swift # UserDefaults-backed @Published wrapper + Theme.swift # Colour palette mirroring the web widget's CSS variables + PopoverView.swift # The popover shell β€” active cards + catalog + footer + BrandCardView.swift # Per-brand card + per-package row with pace tick + PreferencesView.swift # Settings window β€” 4 controls, no wizards +Tests/GateBarTests/ + ModelsTests.swift # JSON decode + AlertLevel classification + QuotaStoreTests.swift # Grouping / sorting / menubar-summary +scripts/ + swift-test.sh # `swift test` with CLT-compatible rpaths +``` + +## Roadmap (not yet shipped) + +- [ ] Sparkle 2 auto-update (EdDSA-signed appcast, notarized .dmg from + GitHub releases). +- [ ] Notifications β€” threshold alerts (session 80 %, weekly 80 %, + pace +10 %) via `UserNotifications`. +- [ ] Launch at login via `SMAppService.mainApp.register()`. +- [ ] `.app` bundle + notarization pipeline in `/.github/workflows`. +- [ ] Homebrew cask: `brew install --cask fusionaize/tap/gate-bar`. +- [ ] Hide-menubar-icon toggle (keeps the app running but invisible). + +These are release-engineering passes, not app-code. Tracked against the +v2.3.x release milestone. diff --git a/apps/gate-bar/Sources/GateBar/BrandCardView.swift b/apps/gate-bar/Sources/GateBar/BrandCardView.swift new file mode 100644 index 0000000..6fa1efc --- /dev/null +++ b/apps/gate-bar/Sources/GateBar/BrandCardView.swift @@ -0,0 +1,169 @@ +import SwiftUI + +/// A single brand card in the popover. Visual parity with the web +/// widget's `.brand` block in `_QUOTAS_DASHBOARD_HTML`: +/// +/// - brand name left, identity right +/// - one `PackageRow` per package (bar + pace tick + % + under-bar meta) +/// - coloured left border carries the worst-alert signal +struct BrandCardView: View { + let brand: BrandGroup + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + header + ForEach(Array(brand.packages.enumerated()), id: \.element.packageId) { index, pkg in + if index > 0 { + Divider() + .background(Theme.border) + .padding(.vertical, 2) + } + PackageRow(package: pkg) + } + } + .padding(.vertical, 10) + .padding(.horizontal, 12) + .background(Theme.card) + .overlay( + Rectangle() + .fill(Theme.color(for: brand.worstAlert)) + .frame(width: 3), + alignment: .leading + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Theme.border, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private var header: some View { + HStack(alignment: .firstTextBaseline) { + Text(brand.brand) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Theme.foreground) + Spacer(minLength: 8) + if let identity = brand.identity { + Text("\(identity.loginMethod): \(identity.credential)") + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(Theme.dim) + .lineLimit(1) + .truncationMode(.middle) + } + } + } +} + +/// One package inside a brand card. +/// +/// Renders the same vocabulary as the web row: +/// - package title (left) + percentage (right) +/// - progress bar with an inline pace tick +/// - under-bar meta: `used / total` (left) Β· reset / days-left (right) +struct PackageRow: View { + let package: QuotaPackage + + private var usedRatio: Double { + max(0, min(1, package.usedRatio ?? 0)) + } + + private var alert: AlertLevel { + AlertLevel(rawAlert: package.alert, usedRatio: package.usedRatio) + } + + private var paceFraction: Double? { + // Pace marker only makes sense when both sides of the computation + // are present (rolling_window + daily). Credits packages return nil. + guard package.paceDelta != nil, let elapsed = package.elapsedRatio else { + return nil + } + return max(0, min(1, elapsed)) + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline) { + Text(package.packageName ?? package.packageId) + .font(.system(size: 12)) + .foregroundColor(Theme.mid) + Spacer(minLength: 8) + Text(percentageLabel) + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundColor(Theme.foreground) + } + bar + HStack { + if let used = package.usedDisplay, let total = package.totalDisplay { + Text("\(used) / \(total)") + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(Theme.dim) + } + Spacer(minLength: 8) + Text(resetLabel) + .font(.system(size: 10)) + .foregroundColor(Theme.dim) + .lineLimit(1) + } + } + } + + private var percentageLabel: String { + let pct = usedRatio * 100 + if pct < 10 { + return String(format: "%.1f%%", pct) + } + return "\(Int(pct.rounded()))%" + } + + private var resetLabel: String { + if let reset = package.resetAt, !reset.isEmpty { + return "resets \(formatReset(reset))" + } + if let days = package.projectedDaysLeft { + return "~\(Int(days.rounded()))d left" + } + return "" + } + + private func formatReset(_ iso: String) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + if let date = formatter.date(from: iso) ?? ISO8601DateFormatter.withFractional.date(from: iso) { + let rel = RelativeDateTimeFormatter() + rel.unitsStyle = .short + return rel.localizedString(for: date, relativeTo: Date()) + } + return iso + } + + /// Progress bar with an inline pace tick. `GeometryReader` lets us + /// position the tick at `elapsedRatio * width` without measuring text. + private var bar: some View { + GeometryReader { proxy in + ZStack(alignment: .topLeading) { + Capsule() + .fill(Theme.track) + Capsule() + .fill(Theme.color(for: alert)) + .frame(width: proxy.size.width * usedRatio) + if let pace = paceFraction { + Rectangle() + .fill(Theme.accent) + .frame(width: 2, height: proxy.size.height + 4) + .offset(x: (proxy.size.width * pace) - 1, y: -2) + } + } + } + .frame(height: 6) + } +} + +private extension ISO8601DateFormatter { + /// `/api/quotas` sometimes emits timestamps with fractional seconds + /// depending on the backend; try both shapes. + static let withFractional: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() +} diff --git a/apps/gate-bar/Sources/GateBar/GateBarApp.swift b/apps/gate-bar/Sources/GateBar/GateBarApp.swift new file mode 100644 index 0000000..901fabd --- /dev/null +++ b/apps/gate-bar/Sources/GateBar/GateBarApp.swift @@ -0,0 +1,71 @@ +import SwiftUI +import AppKit + +/// fusionAIze Gate Bar β€” macOS menubar companion. +/// +/// Entry point. The app is a `MenuBarExtra` (Sonoma 14+) with a +/// window-style popover and a separate Settings scene. +/// +/// Note: `MenuBarExtra(_ :, isInserted:)` gives us a toggle for hiding the +/// icon entirely (future preference). The current cut always shows it. +@main +struct GateBarApp: App { + @StateObject private var preferences = Preferences() + @StateObject private var store: QuotaStore + + init() { + let prefs = Preferences() + _preferences = StateObject(wrappedValue: prefs) + _store = StateObject(wrappedValue: QuotaStore(preferences: prefs)) + } + + var body: some Scene { + MenuBarExtra { + PopoverView( + store: store, + preferences: preferences, + onOpenPreferences: { openPreferencesWindow() } + ) + .task { + // First fetch runs eagerly when the popover opens. A small + // price for fresh data versus waiting for the timer. + await store.refresh() + } + } label: { + MenuBarLabelView(summary: store.menuBarSummary) + } + .menuBarExtraStyle(.window) + + Settings { + PreferencesView(preferences: preferences) + } + } + + /// Programmatically open the Settings scene. The stock keyboard shortcut + /// (``⌘,``) also works, but the "Preferences…" button in the popover + /// footer gives a discoverable affordance. + private func openPreferencesWindow() { + NSApp.activate(ignoringOtherApps: true) + if #available(macOS 14, *) { + // Sonoma's standard Settings scene accepts this action. + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + } else { + NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) + } + } +} + +/// The menubar label: a coloured dot + "fAI Β· 83%" text. +/// +/// Rendered in the menubar's text colour so macOS keeps the contrast right +/// in both light and dark appearances. +struct MenuBarLabelView: View { + let summary: QuotaStore.MenuBarSummary + var body: some View { + HStack(spacing: 4) { + AlertDot(alert: summary.alert) + Text(summary.label) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + } + } +} diff --git a/apps/gate-bar/Sources/GateBar/Models.swift b/apps/gate-bar/Sources/GateBar/Models.swift new file mode 100644 index 0000000..1d39d27 --- /dev/null +++ b/apps/gate-bar/Sources/GateBar/Models.swift @@ -0,0 +1,196 @@ +import Foundation + +// MARK: - JSON models (mirror /api/quotas) +// +// These intentionally decode a *subset* of the gateway's response β€” only the +// fields the menubar actually renders. Extra fields in the JSON are ignored, +// so the Python side can add new fields without breaking Gate Bar. +// +// Contract source of truth: `faigate.quota_tracker.QuotaStatus.to_dict()` and +// the `/api/quotas` handler in `faigate/main.py`. Keep field names in sync. + +/// Top-level response from `GET /api/quotas`. +struct QuotaResponse: Decodable { + let packages: [QuotaPackage] + let byAlert: [String: Int]? + let catalogSuggestions: [CatalogSuggestion]? + let skippedPackages: [SkippedPackage]? + + enum CodingKeys: String, CodingKey { + case packages + case byAlert = "by_alert" + case catalogSuggestions = "catalog_suggestions" + case skippedPackages = "skipped_packages" + } +} + +/// One active package. The menubar groups these by `brandSlug` into cards. +struct QuotaPackage: Decodable, Identifiable { + let packageId: String + let providerId: String? + let providerGroup: String? + + // v1.3 brand pivot (see docs/GATE-BAR-DESIGN.md Β§1). + let brand: String + let brandSlug: String + let identity: Identity? + + let packageType: String? + let usedRatio: Double? + let elapsedRatio: Double? + let paceDelta: Double? + let alert: String? + let resetAt: String? + let projectedDaysLeft: Double? + + // Human-readable numerators/denominators for the under-bar line. + let usedDisplay: String? + let totalDisplay: String? + + // The dashboard labels each row by `package_name` (authored) β€” fall back + // to package_id when the catalog is pre-v1.3 or the field is missing. + let packageName: String? + + var id: String { packageId } + + enum CodingKeys: String, CodingKey { + case packageId = "package_id" + case providerId = "provider_id" + case providerGroup = "provider_group" + case brand + case brandSlug = "brand_slug" + case identity + case packageType = "package_type" + case usedRatio = "used_ratio" + case elapsedRatio = "elapsed_ratio" + case paceDelta = "pace_delta" + case alert + case resetAt = "reset_at" + case projectedDaysLeft = "projected_days_left" + case usedDisplay = "used_display" + case totalDisplay = "total_display" + case packageName = "package_name" + } +} + +/// Credential identity the operator sees under each brand header. +/// Always one of two shapes β€” env-var-style API key or OAuth subject. +struct Identity: Decodable, Equatable { + let loginMethod: String + let credential: String + + enum CodingKeys: String, CodingKey { + case loginMethod = "login_method" + case credential + } +} + +/// Catalog row the widget offers as "Available to add". Shape matches +/// `/api/quotas.catalog_suggestions`. +struct CatalogSuggestion: Decodable, Identifiable, Hashable { + let brand: String + let brandSlug: String + let tagline: String + + var id: String { brandSlug } + + enum CodingKeys: String, CodingKey { + case brand + case brandSlug = "brand_slug" + case tagline + } +} + +/// Inactive packages shown in the "Skipped" section (credential missing). +struct SkippedPackage: Decodable, Identifiable, Hashable { + let packageId: String + let brand: String? + let brandSlug: String? + let requires: String? + + var id: String { packageId } + + enum CodingKeys: String, CodingKey { + case packageId = "package_id" + case brand + case brandSlug = "brand_slug" + case requires + } +} + +// MARK: - Derived aggregates + +/// A brand card as rendered in the popover: every package for that brand, +/// plus the identity line (pulled from the first package β€” identity is +/// brand-wide by design). +struct BrandGroup: Identifiable, Hashable { + let brand: String + let brandSlug: String + let identity: Identity? + let packages: [QuotaPackage] + + var id: String { brandSlug } + + /// Worst `used_ratio` across this brand's packages. Drives card sort + /// order so the brand most likely to blow up is rendered first. + var maxUsedRatio: Double { + packages.compactMap { $0.usedRatio }.max() ?? 0 + } + + /// Highest alert severity across the brand. + var worstAlert: AlertLevel { + packages.map { AlertLevel(rawAlert: $0.alert, usedRatio: $0.usedRatio) } + .max(by: { $0.severity < $1.severity }) ?? .ok + } + + static func == (lhs: BrandGroup, rhs: BrandGroup) -> Bool { + lhs.brandSlug == rhs.brandSlug && lhs.packages.map { $0.packageId } == rhs.packages.map { $0.packageId } + } + + func hash(into hasher: inout Hasher) { + hasher.combine(brandSlug) + hasher.combine(packages.map { $0.packageId }) + } +} + +/// Severity levels that drive the progress-bar colour and the menubar dot. +/// Ordered so `max(by:)` picks "worst". +enum AlertLevel: String, Comparable { + case ok + case watch + case topup + case urgent + case exhausted + + var severity: Int { + switch self { + case .ok: return 0 + case .watch: return 1 + case .topup: return 2 + case .urgent: return 3 + case .exhausted: return 4 + } + } + + static func < (lhs: AlertLevel, rhs: AlertLevel) -> Bool { + lhs.severity < rhs.severity + } + + /// Classify a package. Prefers the server-labelled alert; falls back to + /// `used_ratio` thresholds (kept in sync with the widget's `classify()` + /// in `_QUOTAS_DASHBOARD_HTML`). + init(rawAlert: String?, usedRatio: Double?) { + if let raw = rawAlert, let level = AlertLevel(rawValue: raw) { + self = level + return + } + let pct = max(0, min(1, usedRatio ?? 0)) + switch pct { + case 1.0...: self = .exhausted + case 0.9...: self = .urgent + case 0.7...: self = .topup + case 0.5...: self = .watch + default: self = .ok + } + } +} diff --git a/apps/gate-bar/Sources/GateBar/PopoverView.swift b/apps/gate-bar/Sources/GateBar/PopoverView.swift new file mode 100644 index 0000000..7e37582 --- /dev/null +++ b/apps/gate-bar/Sources/GateBar/PopoverView.swift @@ -0,0 +1,220 @@ +import SwiftUI + +/// The menubar popover contents. Mirrors the web widget's page composition: +/// +/// 1. Active brand cards (sorted worst-alert first). +/// 2. A mini catalog "Available to add" block. +/// 3. A footer with Cockpit + Refresh controls. +/// +/// Skipped-package block is collapsed into a subtle footer line to keep the +/// popover short; the web widget is the place to inspect skipped entries in +/// detail. +struct PopoverView: View { + @ObservedObject var store: QuotaStore + @ObservedObject var preferences: Preferences + /// Parent (the MenuBarExtra scene) owns the settings window-presentation + /// so this view just signals intent. + var onOpenPreferences: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider().background(Theme.border) + content + Divider().background(Theme.border) + footer + } + .frame(width: 360) + .frame(minHeight: 200, maxHeight: 640) + .background(Theme.background) + .foregroundColor(Theme.foreground) + } + + // MARK: - Header + + private var header: some View { + HStack(alignment: .center, spacing: 8) { + Text("fusionAIze Gate Bar") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Theme.foreground) + Spacer(minLength: 8) + if store.isLoading { + ProgressView() + .controlSize(.small) + } + Text(lastRefreshLabel) + .font(.system(size: 10)) + .foregroundColor(Theme.dim) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + + private var lastRefreshLabel: String { + guard let refreshed = store.lastRefresh else { + return store.lastError == nil ? "never refreshed" : "offline" + } + let f = RelativeDateTimeFormatter() + f.unitsStyle = .short + return "updated \(f.localizedString(for: refreshed, relativeTo: Date()))" + } + + // MARK: - Main content + + @ViewBuilder + private var content: some View { + ScrollView { + VStack(alignment: .leading, spacing: 10) { + if let error = store.lastError { + errorBanner(error) + } + if store.brands.isEmpty && store.lastError == nil { + emptyBanner + } else { + ForEach(store.brands) { brand in + BrandCardView(brand: brand) + } + } + if !store.catalogSuggestions.isEmpty { + catalogBlock + } + if !store.skippedPackages.isEmpty { + skippedBlock + } + } + .padding(.horizontal, 12) + .padding(.vertical, 12) + } + } + + private var emptyBanner: some View { + VStack(alignment: .leading, spacing: 4) { + Text("No active providers") + .font(.system(size: 12, weight: .semibold)) + Text("Start the faigate gateway or check the gateway URL in Preferences.") + .font(.system(size: 11)) + .foregroundColor(Theme.dim) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.card) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + private func errorBanner(_ message: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text("Can't reach the gateway") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Theme.color(for: .urgent)) + Text(message) + .font(.system(size: 11)) + .foregroundColor(Theme.dim) + .lineLimit(3) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.card) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Theme.color(for: .urgent).opacity(0.5), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + // Design doc Β§3.3: max 6 rows, anything past collapses into "N more". + private var catalogBlock: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Available to add") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(Theme.dim) + .textCase(.uppercase) + .padding(.top, 4) + ForEach(store.catalogSuggestions.prefix(6)) { suggestion in + HStack(alignment: .firstTextBaseline) { + Text(suggestion.brand) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(Theme.foreground) + Text(suggestion.tagline) + .font(.system(size: 11)) + .foregroundColor(Theme.dim) + .lineLimit(1) + Spacer(minLength: 8) + Link("Add β†—", destination: cockpitLink(for: suggestion.brandSlug, path: "providers/add")) + .font(.system(size: 11)) + .foregroundColor(Theme.link) + } + } + if store.catalogSuggestions.count > 6 { + let extra = store.catalogSuggestions.count - 6 + Link("… \(extra) more in Cockpit β†—", destination: cockpitLink()) + .font(.system(size: 11)) + .foregroundColor(Theme.link) + } + } + } + + private var skippedBlock: some View { + Text("Skipped: \(store.skippedPackages.map { $0.brand ?? $0.packageId }.joined(separator: ", "))") + .font(.system(size: 10)) + .foregroundColor(Theme.dim) + .lineLimit(2) + .padding(.top, 4) + } + + // MARK: - Footer + + private var footer: some View { + HStack(spacing: 12) { + Link(destination: cockpitLink()) { + Text("Cockpit β†—") + .font(.system(size: 12)) + } + .foregroundColor(Theme.link) + + Button { + Task { await store.refresh() } + } label: { + Text("Refresh") + .font(.system(size: 12)) + } + .buttonStyle(.plain) + .foregroundColor(Theme.link) + + Spacer() + + Button { + onOpenPreferences() + } label: { + Text("Preferences…") + .font(.system(size: 12)) + } + .buttonStyle(.plain) + .foregroundColor(Theme.dim) + + Button { + NSApp.terminate(nil) + } label: { + Text("Quit") + .font(.system(size: 12)) + } + .buttonStyle(.plain) + .foregroundColor(Theme.dim) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + + private func cockpitLink(for brandSlug: String? = nil, path: String? = nil) -> URL { + let base = preferences.cockpitURL.hasSuffix("/") + ? String(preferences.cockpitURL.dropLast()) + : preferences.cockpitURL + if let brandSlug, let path { + let encoded = brandSlug.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? brandSlug + return URL(string: "\(base)/\(path)?brand=\(encoded)") ?? URL(string: base)! + } + if let path { + return URL(string: "\(base)/\(path)") ?? URL(string: base)! + } + return URL(string: base) ?? URL(string: "https://cockpit.fusionaize.ai")! + } +} diff --git a/apps/gate-bar/Sources/GateBar/Preferences.swift b/apps/gate-bar/Sources/GateBar/Preferences.swift new file mode 100644 index 0000000..72622d3 --- /dev/null +++ b/apps/gate-bar/Sources/GateBar/Preferences.swift @@ -0,0 +1,65 @@ +import Foundation + +/// User-visible preferences, persisted via `UserDefaults`. +/// +/// Uses `@Published` + `ObservableObject` per the design doc (Β§2, "no +/// `Observable`-only APIs β€” use `ObservableObject`") so the app builds on +/// macOS 14 Sonoma without needing the Observation framework. +final class Preferences: ObservableObject { + // Refresh cadence. CodexBar defaults to 5 min; we keep that (design doc + // Β§2.6). Manual = never auto-refresh. + enum RefreshInterval: Int, CaseIterable, Identifiable { + case manual = 0 + case oneMinute = 60 + case twoMinutes = 120 + case fiveMinutes = 300 + case fifteenMinutes = 900 + + var id: Int { rawValue } + + var displayName: String { + switch self { + case .manual: return "Manual" + case .oneMinute: return "Every 1 min" + case .twoMinutes: return "Every 2 min" + case .fiveMinutes: return "Every 5 min" + case .fifteenMinutes: return "Every 15 min" + } + } + } + + // Defaults suite β€” use standard so `defaults write com.fusionaize.gate-bar` + // can tweak settings without opening the preferences window. + private let defaults: UserDefaults + private enum Key { + static let gatewayURL = "gatewayURL" + static let refreshInterval = "refreshIntervalSeconds" + static let cockpitURL = "cockpitURL" + } + + @Published var gatewayURL: String { + didSet { defaults.set(gatewayURL, forKey: Key.gatewayURL) } + } + + @Published var cockpitURL: String { + didSet { defaults.set(cockpitURL, forKey: Key.cockpitURL) } + } + + @Published var refreshInterval: RefreshInterval { + didSet { defaults.set(refreshInterval.rawValue, forKey: Key.refreshInterval) } + } + + /// Safe defaults that Just Work on a fresh install. + /// Gateway default matches faigate's listen port (4001). + /// Cockpit default matches `_cockpit_base_url()` in the Python side. + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + self.gatewayURL = defaults.string(forKey: Key.gatewayURL) + ?? "http://127.0.0.1:4001" + self.cockpitURL = defaults.string(forKey: Key.cockpitURL) + ?? "https://cockpit.fusionaize.ai" + let rawInterval = defaults.object(forKey: Key.refreshInterval) as? Int + self.refreshInterval = RefreshInterval(rawValue: rawInterval ?? 300) + ?? .fiveMinutes + } +} diff --git a/apps/gate-bar/Sources/GateBar/PreferencesView.swift b/apps/gate-bar/Sources/GateBar/PreferencesView.swift new file mode 100644 index 0000000..d9fe96d --- /dev/null +++ b/apps/gate-bar/Sources/GateBar/PreferencesView.swift @@ -0,0 +1,57 @@ +import SwiftUI + +/// The Preferences window. Intentionally minimal β€” four controls, no +/// wizards. See docs/GATE-BAR-DESIGN.md Β§5 ("Preferences window"). +struct PreferencesView: View { + @ObservedObject var preferences: Preferences + + var body: some View { + Form { + Section("Gateway") { + TextField("Gateway URL", text: $preferences.gatewayURL) + .textFieldStyle(.roundedBorder) + .frame(minWidth: 260) + Text("The local faigate daemon, typically http://127.0.0.1:4001.") + .font(.footnote) + .foregroundColor(.secondary) + } + + Section("Operator Cockpit") { + TextField("Cockpit URL", text: $preferences.cockpitURL) + .textFieldStyle(.roundedBorder) + .frame(minWidth: 260) + Text("Opens in your default browser when you click Cockpit β†—.") + .font(.footnote) + .foregroundColor(.secondary) + } + + Section("Refresh") { + Picker("Refresh interval", selection: $preferences.refreshInterval) { + ForEach(Preferences.RefreshInterval.allCases) { interval in + Text(interval.displayName).tag(interval) + } + } + .pickerStyle(.menu) + } + + Section("Privacy") { + // Verbatim from the about-box copy in docs/GATE-BAR-DESIGN.md Β§5. + Text( + """ + Gate Bar reads usage data from your local fusionAIze Gate \ + daemon over 127.0.0.1. Account identifiers, plan names, \ + and login methods stay on this machine. Gate Bar never \ + connects to a fusionAIze-hosted service; the Cockpit link \ + just opens a web page in your default browser. + """ + ) + .font(.footnote) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .formStyle(.grouped) + .frame(width: 420) + .frame(minHeight: 380) + } +} diff --git a/apps/gate-bar/Sources/GateBar/QuotaClient.swift b/apps/gate-bar/Sources/GateBar/QuotaClient.swift new file mode 100644 index 0000000..77b646c --- /dev/null +++ b/apps/gate-bar/Sources/GateBar/QuotaClient.swift @@ -0,0 +1,90 @@ +import Foundation + +/// HTTP client for the local faigate gateway. +/// +/// Everything Gate Bar knows about its providers comes from this one +/// endpoint β€” the Python side is already the source of truth. That +/// discipline (see `docs/GATE-BAR-DESIGN.md` Β§5) keeps the menubar app free +/// of a hard-coded provider enum and lets the catalog evolve without a Gate +/// Bar release. +actor QuotaClient { + enum ClientError: LocalizedError { + case invalidURL(String) + case transport(Error) + case httpStatus(Int) + case decoding(Error) + + var errorDescription: String? { + switch self { + case .invalidURL(let raw): + return "Gateway URL is not valid: \(raw)" + case .transport(let err): + // Network errors get the URLError localized description β€” + // friendlier than the raw NSError string. + return (err as? URLError)?.localizedDescription + ?? err.localizedDescription + case .httpStatus(let code): + return "Gateway returned HTTP \(code)" + case .decoding: + return "Gateway response did not match the expected shape" + } + } + } + + private let session: URLSession + private let decoder: JSONDecoder + + init(session: URLSession = .shared) { + self.session = session + // Ignoring unknown keys is the default for Swift Decodable β€” nothing + // to configure there. We *do* want to parse ISO-8601 timestamps + // (`reset_at`) to Date eventually, but the UI renders them as + // strings today so we keep the decoder simple. + self.decoder = JSONDecoder() + } + + /// Fetch the current quota snapshot. + /// + /// - Parameter baseURL: `http://127.0.0.1:` (no trailing slash + /// required β€” the `/api/quotas` path is appended via URLComponents so + /// trailing slashes, query strings, etc. are all tolerated). + func fetchQuotas(baseURL: String) async throws -> QuotaResponse { + guard var components = URLComponents(string: baseURL) else { + throw ClientError.invalidURL(baseURL) + } + // Normalize path: ``/api/quotas`` regardless of the user's trailing + // slash habits. ``URL(string:relativeTo:)`` would drop a non-empty + // base path, which we don't want. + components.path = (components.path.hasSuffix("/") + ? components.path + "api/quotas" + : components.path + "/api/quotas") + components.query = nil + + guard let url = components.url else { + throw ClientError.invalidURL(baseURL) + } + + var request = URLRequest(url: url) + request.timeoutInterval = 8 + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("fusionaize-gate-bar", forHTTPHeaderField: "User-Agent") + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw ClientError.transport(error) + } + + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + throw ClientError.httpStatus(http.statusCode) + } + + do { + return try decoder.decode(QuotaResponse.self, from: data) + } catch { + throw ClientError.decoding(error) + } + } +} diff --git a/apps/gate-bar/Sources/GateBar/QuotaStore.swift b/apps/gate-bar/Sources/GateBar/QuotaStore.swift new file mode 100644 index 0000000..7c89ca1 --- /dev/null +++ b/apps/gate-bar/Sources/GateBar/QuotaStore.swift @@ -0,0 +1,134 @@ +import Foundation +import Combine + +/// Source of truth for the Gate Bar menubar & popover. +/// +/// Fetches `/api/quotas` on a timer (cadence driven by `Preferences`), +/// exposes three observable shapes the UI reads: +/// +/// - ``brands`` β€” grouped, sorted brand cards for the popover. +/// - ``catalogSuggestions`` / ``skippedPackages`` β€” tail blocks. +/// - ``menuBarSummary`` β€” the label + colour the menubar displays. +/// +/// `ObservableObject` + `@Published` so the app compiles on Sonoma without +/// the Observation framework. +@MainActor +final class QuotaStore: ObservableObject { + @Published private(set) var brands: [BrandGroup] = [] + @Published private(set) var catalogSuggestions: [CatalogSuggestion] = [] + @Published private(set) var skippedPackages: [SkippedPackage] = [] + + /// Last refresh attempt's result. `nil` means "never fetched yet". + @Published private(set) var lastError: String? = nil + @Published private(set) var lastRefresh: Date? = nil + @Published private(set) var isLoading: Bool = false + + private let client: QuotaClient + private let preferences: Preferences + private var timerCancellable: AnyCancellable? + private var prefsCancellable: AnyCancellable? + + init(client: QuotaClient = QuotaClient(), preferences: Preferences) { + self.client = client + self.preferences = preferences + + // Re-arm the timer whenever the refresh preference changes. + self.prefsCancellable = preferences.$refreshInterval + .sink { [weak self] interval in + self?.rearmTimer(interval: interval) + } + } + + /// Fetch once now. Idempotent; safe to call from the "Refresh now" + /// menu item or on app launch. + func refresh() async { + isLoading = true + defer { isLoading = false } + do { + let response = try await client.fetchQuotas(baseURL: preferences.gatewayURL) + self.apply(response) + self.lastError = nil + self.lastRefresh = Date() + } catch { + self.lastError = (error as? LocalizedError)?.errorDescription + ?? error.localizedDescription + } + } + + /// Rebuild the published shapes from a freshly decoded response. + /// Kept non-private so tests can drive the store without real HTTP. + func apply(_ response: QuotaResponse) { + // Group by brand_slug, preserving the server's package order within + // each card. Sorting is done in a second pass so the "worst alert + // first" rule is explicit and testable. + var grouped: [String: (brand: String, identity: Identity?, pkgs: [QuotaPackage])] = [:] + var order: [String] = [] + for pkg in response.packages { + if grouped[pkg.brandSlug] == nil { + grouped[pkg.brandSlug] = (pkg.brand, pkg.identity, []) + order.append(pkg.brandSlug) + } + grouped[pkg.brandSlug]?.pkgs.append(pkg) + } + + let unsorted: [BrandGroup] = order.compactMap { slug in + guard let entry = grouped[slug] else { return nil } + return BrandGroup( + brand: entry.brand, + brandSlug: slug, + identity: entry.identity, + packages: entry.pkgs + ) + } + + // Worst severity β†’ highest usage β†’ alphabetical. Keeps the card the + // operator most likely needs to act on at the top of the popover. + self.brands = unsorted.sorted { a, b in + if a.worstAlert != b.worstAlert { + return a.worstAlert > b.worstAlert + } + if a.maxUsedRatio != b.maxUsedRatio { + return a.maxUsedRatio > b.maxUsedRatio + } + return a.brand.localizedCaseInsensitiveCompare(b.brand) == .orderedAscending + } + + self.catalogSuggestions = response.catalogSuggestions ?? [] + self.skippedPackages = response.skippedPackages ?? [] + } + + // MARK: - Menubar summary + + /// Tightest-window percentage across every active package, plus the + /// worst alert level (drives the menubar colour dot). + /// ``label`` is always short enough to fit the macOS menubar (≀ 12 chars). + struct MenuBarSummary: Equatable { + let label: String + let alert: AlertLevel + } + + var menuBarSummary: MenuBarSummary { + let ratios = brands.flatMap { $0.packages } + .compactMap { $0.usedRatio } + guard let tightest = ratios.max() else { + return MenuBarSummary(label: "fAI", alert: .ok) + } + let worst = brands.map { $0.worstAlert }.max() ?? .ok + let pct = Int((max(0, min(1, tightest)) * 100).rounded()) + return MenuBarSummary(label: "fAI Β· \(pct)%", alert: worst) + } + + // MARK: - Timer + + private func rearmTimer(interval: Preferences.RefreshInterval) { + timerCancellable?.cancel() + timerCancellable = nil + guard interval != .manual else { return } + let seconds = TimeInterval(interval.rawValue) + timerCancellable = Timer.publish(every: seconds, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + Task { [weak self] in await self?.refresh() } + } + } +} diff --git a/apps/gate-bar/Sources/GateBar/Theme.swift b/apps/gate-bar/Sources/GateBar/Theme.swift new file mode 100644 index 0000000..71fdd9f --- /dev/null +++ b/apps/gate-bar/Sources/GateBar/Theme.swift @@ -0,0 +1,40 @@ +import SwiftUI + +/// Colour palette mirroring the web widget's CSS variables in +/// `_QUOTAS_DASHBOARD_HTML`. Keeping them in one place means the menubar +/// popover visually reads as the same product as the browser dashboard. +enum Theme { + static let background = Color(red: 0.059, green: 0.067, blue: 0.090) // #0f1117 + static let card = Color(red: 0.102, green: 0.114, blue: 0.153) // #1a1d27 + static let border = Color(red: 0.165, green: 0.184, blue: 0.239) // #2a2f3d + static let track = Color(red: 0.149, green: 0.165, blue: 0.212) // #262a36 + + static let foreground = Color(red: 0.902, green: 0.914, blue: 0.937) // #e6e9ef + static let mid = Color(red: 0.725, green: 0.757, blue: 0.820) // #b9c1d1 + static let dim = Color(red: 0.541, green: 0.576, blue: 0.651) // #8a93a6 + + static let accent = Color(red: 0.545, green: 0.361, blue: 0.965) // #8b5cf6 + static let link = Color(red: 0.376, green: 0.647, blue: 0.980) // #60a5fa + + // Alert levels β€” keep in sync with AlertLevel. + static func color(for alert: AlertLevel) -> Color { + switch alert { + case .ok: return Color(red: 0.290, green: 0.871, blue: 0.502) // #4ade80 + case .watch: return Color(red: 0.984, green: 0.749, blue: 0.141) // #fbbf24 + case .topup: return Color(red: 0.984, green: 0.573, blue: 0.235) // #fb923c + case .urgent: return Color(red: 0.937, green: 0.267, blue: 0.267) // #ef4444 + case .exhausted: return Color(red: 0.498, green: 0.114, blue: 0.114) // #7f1d1d + } + } +} + +/// Convenience for the "N Β· 83%" label β€” adds a coloured dot before the +/// text so the menubar reads at a glance even at low contrast. +struct AlertDot: View { + let alert: AlertLevel + var body: some View { + Circle() + .fill(Theme.color(for: alert)) + .frame(width: 8, height: 8) + } +} diff --git a/apps/gate-bar/Tests/GateBarTests/ModelsTests.swift b/apps/gate-bar/Tests/GateBarTests/ModelsTests.swift new file mode 100644 index 0000000..f027b5f --- /dev/null +++ b/apps/gate-bar/Tests/GateBarTests/ModelsTests.swift @@ -0,0 +1,135 @@ +import Testing +import Foundation +@testable import GateBar + +// Uses the Swift Testing framework (`swift test` picks it up automatically +// on Sonoma+). XCTest is intentionally avoided because it only ships with +// a full Xcode install β€” the Swift Testing framework is bundled with the +// Command Line Tools too, so CI and fresh dev machines get green tests +// without needing Xcode.app. + +// MARK: - JSON decoding + +private let sample = """ +{ + "packages": [ + { + "package_id": "anthropic-pro-5h-session", + "package_name": "Pro Β· 5-h session", + "provider_id": "anthropic-claude", + "provider_group": "anthropic", + "brand": "Claude", + "brand_slug": "claude", + "package_type": "rolling_window", + "used_ratio": 0.83, + "elapsed_ratio": 0.62, + "pace_delta": 0.21, + "alert": "topup", + "reset_at": "2026-04-19T22:00:00Z", + "used_display": "83 / 100", + "total_display": "100", + "identity": {"login_method": "OAuth", "credential": "claude-code"} + }, + { + "package_id": "deepseek-pay-as-you-go", + "package_name": "Pay-as-you-go", + "provider_id": "deepseek-chat", + "provider_group": "deepseek", + "brand": "DeepSeek", + "brand_slug": "deepseek", + "package_type": "credits", + "used_ratio": 0.0, + "elapsed_ratio": null, + "pace_delta": null, + "alert": "ok", + "projected_days_left": 42, + "used_display": "$0.00", + "total_display": "$28.42", + "identity": {"login_method": "API key", "credential": "DEEPSEEK_API_KEY"} + } + ], + "by_alert": {"topup": 1, "ok": 1}, + "catalog_suggestions": [ + {"brand": "Cursor", "brand_slug": "cursor", "tagline": "Pro Β· $20/mo Β· 500 fast req/mo"} + ], + "skipped_packages": [ + {"package_id": "qwen-free-daily", "brand": "Qwen", "brand_slug": "qwen", "requires": "qwen-portal"} + ] +} +""".data(using: .utf8)! + +@Suite("QuotaResponse JSON decode") +struct QuotaResponseDecodeTests { + @Test func decodesFullResponse() throws { + let resp = try JSONDecoder().decode(QuotaResponse.self, from: sample) + #expect(resp.packages.count == 2) + #expect(resp.catalogSuggestions?.count == 1) + #expect(resp.skippedPackages?.count == 1) + + let claude = resp.packages[0] + #expect(claude.brand == "Claude") + #expect(claude.brandSlug == "claude") + #expect(abs((claude.paceDelta ?? 0) - 0.21) < 1e-9) + #expect(claude.identity?.loginMethod == "OAuth") + #expect(claude.packageName == "Pro Β· 5-h session") + } + + @Test func creditsPackageSurfacesNilPace() throws { + let resp = try JSONDecoder().decode(QuotaResponse.self, from: sample) + let ds = resp.packages[1] + #expect(ds.paceDelta == nil) + #expect(ds.elapsedRatio == nil) + #expect(ds.projectedDaysLeft == 42) + } + + @Test func unknownFieldsAreIgnored() throws { + // The Python side adds fields every few releases. Gate Bar must + // decode forward-compatible responses without failing so we can + // evolve the contract in one direction at a time. + let jsonWithExtras = """ + { + "packages": [{ + "package_id": "x", + "brand": "X", + "brand_slug": "x", + "used_ratio": 0.1, + "alert": "ok", + "invented_field_nobody_asked_for": 42 + }], + "header_snapshots": {"x": {"dialect": "openai"}}, + "has_exhausted": false + } + """.data(using: .utf8)! + _ = try JSONDecoder().decode(QuotaResponse.self, from: jsonWithExtras) + } +} + +// MARK: - AlertLevel + +@Suite("AlertLevel classification") +struct AlertLevelTests { + @Test func serverAlertWinsOverRatio() { + #expect(AlertLevel(rawAlert: "exhausted", usedRatio: 0.01) == .exhausted) + } + + @Test func fallsBackToRatioThresholds() { + #expect(AlertLevel(rawAlert: nil, usedRatio: 0.0) == .ok) + #expect(AlertLevel(rawAlert: nil, usedRatio: 0.55) == .watch) + #expect(AlertLevel(rawAlert: nil, usedRatio: 0.75) == .topup) + #expect(AlertLevel(rawAlert: nil, usedRatio: 0.95) == .urgent) + #expect(AlertLevel(rawAlert: nil, usedRatio: 1.2) == .exhausted) + } + + @Test func unknownAlertStringFallsBackToRatio() { + // Defensive: the server might ship a new level before Gate Bar + // knows about it (e.g. "frozen"). Degrade to the ratio fallback, + // don't crash. + #expect(AlertLevel(rawAlert: "frozen", usedRatio: 0.72) == .topup) + } + + @Test func severityOrdering() { + #expect(AlertLevel.urgent > .topup) + #expect(AlertLevel.exhausted > .urgent) + #expect(!(AlertLevel.ok > .watch)) + } +} diff --git a/apps/gate-bar/Tests/GateBarTests/QuotaStoreTests.swift b/apps/gate-bar/Tests/GateBarTests/QuotaStoreTests.swift new file mode 100644 index 0000000..f2c2539 --- /dev/null +++ b/apps/gate-bar/Tests/GateBarTests/QuotaStoreTests.swift @@ -0,0 +1,125 @@ +import Testing +import Foundation +@testable import GateBar + +// Pure data-transform tests for the store: grouping, sorting, menubar +// summary. HTTP + timer behaviour is deliberately out of scope β€” it gets +// exercised end-to-end once the app is running against a live gateway. + +@Suite("QuotaStore transforms", .serialized) +@MainActor +struct QuotaStoreTests { + private func makeStore() -> QuotaStore { + // Fresh, transient UserDefaults so tests never leak state into the + // developer's real preferences plist. + let suite = UserDefaults(suiteName: "gate-bar-tests-\(UUID().uuidString)")! + let prefs = Preferences(defaults: suite) + return QuotaStore(preferences: prefs) + } + + private func pkg( + _ id: String, + brand: String, + slug: String, + alert: String, + ratio: Double, + identity: Identity? = nil + ) -> QuotaPackage { + let identityFragment: String + if let identity { + identityFragment = ",\"identity\":{\"login_method\":\"\(identity.loginMethod)\",\"credential\":\"\(identity.credential)\"}" + } else { + identityFragment = "" + } + let json = """ + { + "package_id": "\(id)", + "brand": "\(brand)", + "brand_slug": "\(slug)", + "alert": "\(alert)", + "used_ratio": \(ratio)\(identityFragment) + } + """ + return try! JSONDecoder().decode(QuotaPackage.self, from: Data(json.utf8)) + } + + @Test func groupsPackagesByBrandSlug() { + let store = makeStore() + store.apply(QuotaResponse( + packages: [ + pkg("a1", brand: "Claude", slug: "claude", alert: "ok", ratio: 0.2), + pkg("a2", brand: "Claude", slug: "claude", alert: "watch", ratio: 0.6), + pkg("b1", brand: "DeepSeek", slug: "deepseek", alert: "ok", ratio: 0.05), + ], + byAlert: nil, catalogSuggestions: nil, skippedPackages: nil + )) + #expect(store.brands.count == 2) + let claude = store.brands.first { $0.brandSlug == "claude" } + #expect(claude?.packages.count == 2) + } + + @Test func sortsWorstAlertFirst() { + let store = makeStore() + // DeepSeek is urgent β†’ should beat Claude's `topup`. + store.apply(QuotaResponse( + packages: [ + pkg("a1", brand: "Claude", slug: "claude", alert: "topup", ratio: 0.72), + pkg("b1", brand: "DeepSeek", slug: "deepseek", alert: "urgent", ratio: 0.92), + pkg("c1", brand: "Gemini", slug: "gemini", alert: "ok", ratio: 0.05), + ], + byAlert: nil, catalogSuggestions: nil, skippedPackages: nil + )) + #expect(store.brands.map { $0.brandSlug } == ["deepseek", "claude", "gemini"]) + } + + @Test func tiesBreakByMaxUsedRatioThenName() { + let store = makeStore() + store.apply(QuotaResponse( + packages: [ + pkg("a1", brand: "Bravo", slug: "bravo", alert: "watch", ratio: 0.55), + pkg("a2", brand: "Alpha", slug: "alpha", alert: "watch", ratio: 0.65), + pkg("a3", brand: "Charlie", slug: "charlie", alert: "watch", ratio: 0.55), + ], + byAlert: nil, catalogSuggestions: nil, skippedPackages: nil + )) + #expect(store.brands.map { $0.brand } == ["Alpha", "Bravo", "Charlie"]) + } + + @Test func menuBarSummaryPicksTightestWindow() { + let store = makeStore() + store.apply(QuotaResponse( + packages: [ + pkg("a1", brand: "Claude", slug: "claude", alert: "topup", ratio: 0.72), + pkg("b1", brand: "DeepSeek", slug: "deepseek", alert: "urgent", ratio: 0.94), + pkg("c1", brand: "Gemini", slug: "gemini", alert: "ok", ratio: 0.05), + ], + byAlert: nil, catalogSuggestions: nil, skippedPackages: nil + )) + let summary = store.menuBarSummary + #expect(summary.label == "fAI Β· 94%") + #expect(summary.alert == .urgent) + } + + @Test func menuBarSummaryEmptyFallsBackToIdle() { + let store = makeStore() + store.apply(QuotaResponse( + packages: [], byAlert: nil, catalogSuggestions: nil, skippedPackages: nil + )) + let summary = store.menuBarSummary + #expect(summary.label == "fAI") + #expect(summary.alert == .ok) + } + + @Test func identityComesFromFirstPackage() { + let store = makeStore() + let identity = Identity(loginMethod: "OAuth", credential: "claude-code") + store.apply(QuotaResponse( + packages: [ + pkg("a1", brand: "Claude", slug: "claude", alert: "ok", ratio: 0.2, identity: identity), + pkg("a2", brand: "Claude", slug: "claude", alert: "watch", ratio: 0.6), + ], + byAlert: nil, catalogSuggestions: nil, skippedPackages: nil + )) + #expect(store.brands.first?.identity == identity) + } +} diff --git a/apps/gate-bar/scripts/swift-test.sh b/apps/gate-bar/scripts/swift-test.sh new file mode 100755 index 0000000..eb2e596 --- /dev/null +++ b/apps/gate-bar/scripts/swift-test.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# +# Thin wrapper around `swift test` that makes Swift Testing work on +# machines with only the Command Line Tools installed (no Xcode.app). +# +# Why this exists: +# - Swift Testing (`import Testing`) is the framework our tests use. +# - Its runtime deps β€” Testing.framework + lib_TestingInterop.dylib β€” +# live in the Xcode toolchain but dyld only looks in a handful of +# default paths. Xcode.app sets those up automatically; plain CLT +# does not. +# - The fix is to point the build at the right -F dir and add two +# rpaths so the xctest bundle can dlopen the framework at runtime. +# +# On a machine with Xcode.app installed, `swift test` works directly β€” +# but running this script there is still safe (it just adds redundant +# rpaths, no harm done). +# +# Usage: +# ./scripts/swift-test.sh # default invocation +# ./scripts/swift-test.sh --filter X # forwards any extra args + +set -euo pipefail + +# Pick the active developer directory. `xcode-select -p` returns the Xcode +# path if one is selected, otherwise the CLT path. +DEVDIR="$(xcode-select -p 2>/dev/null || echo /Library/Developer/CommandLineTools)" + +# Xcode.app layout: /usr/lib/... + Frameworks under /../Frameworks +# CLT layout: /Library/Developer/Frameworks + .../usr/lib +if [[ -d "$DEVDIR/Library/Developer/Frameworks" ]]; then + FRAMEWORKS_DIR="$DEVDIR/Library/Developer/Frameworks" + INTEROP_DIR="$DEVDIR/Library/Developer/usr/lib" +elif [[ -d "$DEVDIR/../SharedFrameworks" ]]; then + # Xcode.app β€” Testing.framework ships in a different location; fall back + # to letting swift test find it on its own. + exec swift test "$@" +else + FRAMEWORKS_DIR="$DEVDIR/Library/Developer/Frameworks" + INTEROP_DIR="$DEVDIR/Library/Developer/usr/lib" +fi + +cd "$(dirname "$0")/.." + +exec swift test \ + -Xswiftc -F -Xswiftc "$FRAMEWORKS_DIR" \ + -Xlinker -rpath -Xlinker "$FRAMEWORKS_DIR" \ + -Xlinker -rpath -Xlinker "$INTEROP_DIR" \ + "$@" diff --git a/docs/GATE-BAR-DESIGN.md b/docs/GATE-BAR-DESIGN.md index 662a434..24292f6 100644 --- a/docs/GATE-BAR-DESIGN.md +++ b/docs/GATE-BAR-DESIGN.md @@ -523,9 +523,12 @@ pure HTTP client to the local gateway. That means: β€” no onboarding UI inside the dashboard. **Phase C β€” Gate Bar 0.1 (v2.3.0 companion release):** -- SwiftUI project, macOS 14+ Universal. -- Popover with brand cards, menubar icon, preferences. -- Sparkle 2 auto-update, Homebrew cask. +- SwiftUI project, macOS 14+ Universal. *Scaffolded at `apps/gate-bar/` + β€” SPM executable, `MenuBarExtra` + `Settings` scenes, popover with + brand cards, preferences, 13 Swift-Testing tests green.* +- Popover with brand cards, menubar icon, preferences. *Shipped.* +- Sparkle 2 auto-update, Homebrew cask. *Release-engineering pass β€” not + yet shipped; tracked in `apps/gate-bar/README.md` under "Roadmap".* **Phase D β€” CodexBar parity roster (v2.4+):** - Add Cursor, Droid, Antigravity, Copilot brands to the catalog (with From 68580ee027089e462f6339fffef85fd8cacf2f7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Lange?= Date: Sun, 19 Apr 2026 18:08:32 +0200 Subject: [PATCH 06/11] feat(dashboard): default landing view + Home pin (Phase B.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persisted dashboard.quotas.default_view setting honoured by /dashboard/quotas via server-side 302. Three values: overview (grid, default), brand: (detail page), cockpit (offsite). The escape hatch ?view=overview always renders the grid so a pinned brand card can link home without fighting the redirect. Persistence uses ruamel.yaml round-trip so the 220+ operator comments in config.yaml survive a pin toggle β€” yaml.safe_dump would flatten them. Writes are atomic (tempfile.mkstemp + os.replace in the same dir) so a crash mid-write can't leave a half-rewritten config. HTTP surface: - GET /api/dashboard/settings β€” {default_view, pinned_brand_slug} - POST /api/dashboard/settings β€” 400 on bad input, 200 with canonical settings on success - GET /dashboard/quotas β€” honours default_view, falls back to the overview if settings read fails (never 500s) UI: - Overview: 'Home view' chip in header + 'Reset to Overview' when anything other than overview is pinned. - Every brand card: 'Pin as Home' / 'πŸ“Œ Home' button next to Details/Cockpit. Pinned card gets a subtle accent outline. - Detail page header: same pin button. - Gate Bar popover footer: 'Dashboard β†—' link to /dashboard/quotas. Server-side redirect handles default_view so the menubar app doesn't branch. Tests: 39 new cases in tests/test_dashboard_settings.py cover round-trip comment preservation, validation rules, atomic rename, endpoint contracts, redirect behaviour, and bad-config fallback. Co-Authored-By: Claude Opus 4.7 --- .../Sources/GateBar/PopoverView.swift | 21 ++ docs/GATE-BAR-DESIGN.md | 55 ++- faigate/dashboard_settings.py | 201 +++++++++++ faigate/main.py | 258 ++++++++++++- pyproject.toml | 4 + requirements.txt | 1 + tests/test_dashboard_settings.py | 340 ++++++++++++++++++ 7 files changed, 855 insertions(+), 25 deletions(-) create mode 100644 faigate/dashboard_settings.py create mode 100644 tests/test_dashboard_settings.py diff --git a/apps/gate-bar/Sources/GateBar/PopoverView.swift b/apps/gate-bar/Sources/GateBar/PopoverView.swift index 7e37582..57958b1 100644 --- a/apps/gate-bar/Sources/GateBar/PopoverView.swift +++ b/apps/gate-bar/Sources/GateBar/PopoverView.swift @@ -165,6 +165,16 @@ struct PopoverView: View { private var footer: some View { HStack(spacing: 12) { + // Opens the gateway's /dashboard/quotas β€” the server-side + // redirect honors `dashboard.quotas.default_view`, so if the + // operator pinned a brand or Cockpit this button goes straight + // there. No client-side branching needed. + Link(destination: dashboardLink()) { + Text("Dashboard β†—") + .font(.system(size: 12)) + } + .foregroundColor(Theme.link) + Link(destination: cockpitLink()) { Text("Cockpit β†—") .font(.system(size: 12)) @@ -204,6 +214,17 @@ struct PopoverView: View { .padding(.vertical, 10) } + /// Deep-link into the gateway's dashboard. The gateway's redirect + /// handler decides whether to land on the overview, a pinned brand, + /// or Cockpit, per the operator's ``dashboard.quotas.default_view`` + /// setting. + private func dashboardLink() -> URL { + let base = preferences.gatewayURL.hasSuffix("/") + ? String(preferences.gatewayURL.dropLast()) + : preferences.gatewayURL + return URL(string: "\(base)/dashboard/quotas") ?? URL(string: base)! + } + private func cockpitLink(for brandSlug: String? = nil, path: String? = nil) -> URL { let base = preferences.cockpitURL.hasSuffix("/") ? String(preferences.cockpitURL.dropLast()) diff --git a/docs/GATE-BAR-DESIGN.md b/docs/GATE-BAR-DESIGN.md index 24292f6..9d08f4f 100644 --- a/docs/GATE-BAR-DESIGN.md +++ b/docs/GATE-BAR-DESIGN.md @@ -398,29 +398,52 @@ The quick view is a **read-only composite**; it writes nothing, it is safe to reload, and it degrades gracefully if any of the three new endpoints fails (the section just collapses with an "unavailable" hint). -### Default landing view (user-configurable) +### Default landing view (user-configurable) β€” **shipped v2.3** Operators have different "home screens" β€” one person opens the dashboard to check Claude quota first, another always wants the full overview. -Add a persisted setting `dashboard.quotas.default_view` with three options: +Persisted setting `dashboard.quotas.default_view` has three options: | Value | Behavior on `/dashboard/quotas` | |----------------------|-------------------------------------------------------------| -| `overview` (default) | Current clustered all-brands grid. | -| `brand:` | Redirect to `/dashboard/quotas/` (per-brand detail). | +| `overview` (default) | Clustered all-brands grid. | +| `brand:` | 302 to `/dashboard/quotas/` (per-brand detail). | | `cockpit` | 302 to `FAIGATE_COCKPIT_URL` in the same tab. | -- Stored in `FAIGATE_CONFIG_FILE` under `dashboard.quotas.default_view`. -- Surfaced in the dashboard settings panel with a radio group plus a - dropdown of available brands (same roster the widget computes). -- A small "Home ‴" pin appears on every brand card and on the overview - header; clicking it sets that view as the default landing. One-click - promotion, no modal. -- The URL bar still works β€” `/dashboard/quotas` respects the setting, - `/dashboard/quotas?view=overview` forces the overview regardless. -- Gate Bar reuses the same setting: its popover's "Open" button honours - the operator's chosen default view. +**Persistence (`faigate/dashboard_settings.py`):** + +- Stored in `config.yaml` under `dashboard.quotas.default_view` alongside + a mirrored `pinned_brand_slug` key (so UI code can render "pinned" + state without string-splitting `brand:`). +- Writes go through `ruamel.yaml` round-trip β†’ **every operator comment + in the 48 kB config survives a pin toggle**. Regular `yaml.safe_dump` + would flatten 220+ comment lines; we didn't take that trade. +- Writes are atomic (`tempfile.mkstemp` in the same dir + `os.replace`) + so a crash mid-write can't leave a half-rewritten config. + +**HTTP surface:** + +- `GET /api/dashboard/settings` β€” `{default_view, pinned_brand_slug}`. +- `POST /api/dashboard/settings` β€” body `{default_view}`; validates + against `overview` | `cockpit` | `brand:` (slug = `[a-z0-9-]+`). + Bad input β†’ 400. +- `GET /dashboard/quotas` β€” honors the setting via 302. The escape + hatch `GET /dashboard/quotas?view=overview` always renders the grid, + so a pinned brand card's "Home" link back to the overview works even + when the operator's default is a brand detail. + +**UI:** + +- Overview header shows the current Home view in the top-right, plus a + "Reset to Overview" chip when anything other than overview is pinned. +- Every brand card has a `Pin as Home` / `πŸ“Œ Home` button next to its + Details / Cockpit actions. Clicking toggles between + `default_view=brand:` and `overview`. +- The detail page header has the same button. +- Gate Bar's popover footer has a `Dashboard β†—` link to + `/dashboard/quotas`. The server-side redirect lands the + operator on whichever view they pinned β€” no client-side branching. --- @@ -514,7 +537,9 @@ pure HTTP client to the local gateway. That means: clients / routes / analytics blocks. - New read-only endpoints: `/api/quotas//clients`, `/routes`, `/analytics` (all `?window=24h` by default). -- `dashboard.quotas.default_view` setting + one-click "Home ‴" pin. +- `dashboard.quotas.default_view` setting + one-click pin. *Shipped β€” + ruamel.yaml round-trip keeps operator comments intact; redirect + honored server-side so Gate Bar doesn't need to branch.* - "Available to add" mini catalog block at the bottom of the overview, reading from the existing fusionaize-metadata catalog. New authored field `catalog_tagline` = `tier Β· price Β· quota shape` (e.g. "Pro Β· diff --git a/faigate/dashboard_settings.py b/faigate/dashboard_settings.py new file mode 100644 index 0000000..c38263f --- /dev/null +++ b/faigate/dashboard_settings.py @@ -0,0 +1,201 @@ +"""Dashboard settings β€” persisted under ``dashboard.quotas`` in config.yaml. + +The operator's config is a human-authored file (48kb+ with ~220 comment +lines in the reference install). Writing through ``yaml.safe_dump`` would +flatten all of that, so we round-trip through ``ruamel.yaml`` which +preserves comments, key order, and block/flow style. + +Scope is intentionally narrow: one nested block (``dashboard.quotas``) +with two keys: + +- ``default_view``: ``"overview"`` (the grid) | ``"brand:"`` (a + specific detail page) | ``"cockpit"`` (deep-link out). +- ``pinned_brand_slug``: redundant echo of the ``brand:`` target + so UI can tell "which card is currently pinned" without re-parsing + the default_view string. + +Reads are cheap (safe_load-style) and happen on every request. Writes +go through a POSIX atomic rename so a crash mid-write can't leave the +operator with a half-written config. +""" +from __future__ import annotations + +import io +import logging +import os +import tempfile +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +# Allowed shapes for ``default_view``. ``brand:`` is validated +# separately (any non-empty slug matching ``[a-z0-9-]+`` is accepted). +_ALLOWED_FIXED_VIEWS = {"overview", "cockpit"} + + +def _config_path() -> Path: + """Resolve the same config.yaml path used by the rest of faigate.""" + env_path = os.environ.get("FAIGATE_CONFIG_FILE") or os.environ.get("FAIGATE_CONFIG_PATH") + if env_path: + return Path(env_path) + candidates = [ + Path(__file__).resolve().parent.parent / "config.yaml", + Path.cwd() / "config.yaml", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + raise FileNotFoundError("config.yaml not found") + + +def _slug_is_valid(value: str) -> bool: + if not value: + return False + for ch in value: + if not (ch.isdigit() or ("a" <= ch <= "z") or ch == "-"): + return False + return True + + +def validate_default_view(value: str) -> str: + """Return the canonical form or raise ``ValueError``.""" + candidate = (value or "").strip().lower() + if candidate in _ALLOWED_FIXED_VIEWS: + return candidate + if candidate.startswith("brand:"): + slug = candidate[len("brand:") :] + if _slug_is_valid(slug): + return f"brand:{slug}" + raise ValueError( + f"default_view must be 'overview', 'cockpit', or 'brand:' β€” got {value!r}" + ) + + +def get_settings(path: str | Path | None = None) -> dict[str, Any]: + """Return the ``dashboard.quotas`` block (or the empty defaults).""" + resolved = Path(path) if path else _config_path() + if not resolved.exists(): + return _default_settings() + # Use ruamel.yaml for reads too so we stay consistent with the writer + # (the main app still reads via pyyaml in config.py β€” that's fine, + # these two paths never produce config objects that feed each other). + from ruamel.yaml import YAML + + yaml = YAML(typ="rt") + yaml.preserve_quotes = True + try: + with resolved.open("r", encoding="utf-8") as handle: + data = yaml.load(handle) or {} + except Exception as exc: # noqa: BLE001 β€” any parse failure β†’ defaults + logger.warning("dashboard_settings: failed to parse %s: %s", resolved, exc) + return _default_settings() + dashboard = data.get("dashboard") if isinstance(data, dict) else None + quotas = dashboard.get("quotas") if isinstance(dashboard, dict) else None + if not isinstance(quotas, dict): + return _default_settings() + default_view = str(quotas.get("default_view") or "overview") + try: + default_view = validate_default_view(default_view) + except ValueError: + default_view = "overview" + pinned = quotas.get("pinned_brand_slug") + pinned_slug = str(pinned).strip().lower() if pinned else "" + if not _slug_is_valid(pinned_slug): + pinned_slug = "" + return {"default_view": default_view, "pinned_brand_slug": pinned_slug} + + +def set_default_view( + value: str, + *, + path: str | Path | None = None, +) -> dict[str, Any]: + """Update ``dashboard.quotas.default_view`` with comment-preserving write. + + Returns the new settings dict. Raises ``ValueError`` on a bad value; + never swallows filesystem errors (caller surfaces them to the HTTP + layer as a 5xx). + """ + canonical = validate_default_view(value) + resolved = Path(path) if path else _config_path() + + from ruamel.yaml import YAML + from ruamel.yaml.comments import CommentedMap + + yaml = YAML(typ="rt") + yaml.preserve_quotes = True + # Match the existing 2-space indent faigate's wizard writes. + yaml.indent(mapping=2, sequence=4, offset=2) + + if resolved.exists(): + with resolved.open("r", encoding="utf-8") as handle: + data = yaml.load(handle) + if data is None: + data = CommentedMap() + else: + # Brand-new config; extremely rare in this path but we honor it. + data = CommentedMap() + + dashboard = data.get("dashboard") + if not isinstance(dashboard, CommentedMap): + dashboard = CommentedMap() + data["dashboard"] = dashboard + + quotas = dashboard.get("quotas") + if not isinstance(quotas, CommentedMap): + quotas = CommentedMap() + dashboard["quotas"] = quotas + + quotas["default_view"] = canonical + # Mirror the brand slug (if any) into a dedicated key so UI can render + # "Home ‴ pinned on this card" without parsing ``brand:``. + if canonical.startswith("brand:"): + quotas["pinned_brand_slug"] = canonical[len("brand:") :] + else: + # Drop the pinned_brand_slug key when we're not pinning a brand. + # Comments on neighboring keys survive because ruamel keeps its + # CommentedMap node graph intact around the drop. + if "pinned_brand_slug" in quotas: + del quotas["pinned_brand_slug"] + + # Write atomically: render to string, write to a temp file in the + # same directory, then rename. Prevents half-written config.yaml on + # power loss / SIGKILL. + buffer = io.StringIO() + yaml.dump(data, buffer) + rendered = buffer.getvalue() + + tmp_fd, tmp_path = tempfile.mkstemp( + prefix=".dashboard_settings.", + suffix=".yaml.tmp", + dir=str(resolved.parent), + ) + try: + with os.fdopen(tmp_fd, "w", encoding="utf-8") as handle: + handle.write(rendered) + os.replace(tmp_path, resolved) + except Exception: + # Best-effort cleanup; swallow the unlink error so the caller + # sees the original write failure. + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + return { + "default_view": canonical, + "pinned_brand_slug": canonical[len("brand:") :] if canonical.startswith("brand:") else "", + } + + +def _default_settings() -> dict[str, Any]: + return {"default_view": "overview", "pinned_brand_slug": ""} + + +__all__ = [ + "get_settings", + "set_default_view", + "validate_default_view", +] diff --git a/faigate/main.py b/faigate/main.py index 2fcbef0..cd88f29 100644 --- a/faigate/main.py +++ b/faigate/main.py @@ -26,7 +26,7 @@ from typing import Any from fastapi import FastAPI, Request -from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse from starlette.datastructures import UploadFile from . import __version__ @@ -3606,13 +3606,50 @@ async def dashboard(): .skipped ul { margin: 6px 0 0 18px; padding: 0; } .empty { padding: 40px; text-align: center; color: var(--dim); } + + /* ── Home-pin controls ─────────────────────────────────────────────── */ + .header-row { + display: flex; justify-content: space-between; align-items: flex-start; + gap: 16px; max-width: 1400px; + } + .pin-status { + font-size: 11.5px; color: var(--dim); + display: flex; align-items: center; gap: 8px; + padding-top: 2px; + } + .pin-status .label { opacity: 0.8; } + .pin-status .value { color: var(--fg); font-weight: 500; } + .pin-status .unpin { + border: 1px solid var(--border); background: transparent; + color: var(--dim); padding: 2px 8px; border-radius: 6px; + font-size: 11px; cursor: pointer; + } + .pin-status .unpin:hover { border-color: var(--accent); color: var(--accent); } + + .pin-btn { + display: inline-block; padding: 4px 8px; border-radius: 6px; + font-size: 11.5px; font-weight: 500; border: 1px solid var(--border); + color: var(--dim); background: transparent; cursor: pointer; + margin-right: 4px; + } + .pin-btn:hover { border-color: var(--accent); color: var(--accent); } + .pin-btn.pinned { + border-color: var(--accent); color: var(--accent); + background: rgba(139, 92, 246, 0.08); + } + .brand.pinned { box-shadow: 0 0 0 1px rgba(139, 92, 246, 0.35); } -

Quotas

-
- Live view of every active provider β€” updated every 60s. Raw feed: - /api/quotas +
+
+

Quotas

+
+ Live view of every active provider β€” updated every 60s. Raw feed: + /api/quotas +
+
+
Loading…
@@ -3626,6 +3663,64 @@ async def dashboard(): const ALERT_RANK = Object.fromEntries(ALERT_ORDER.map((a, i) => [a, i])); const EMOJI = {ok: "🟒", watch: "🟑", topup: "🟠", use_or_lose: "⚠️", exhausted: "πŸ”΄"}; +// ── Dashboard settings (Home pin) ───────────────────────────────────── +// Held here so brand-card render can tag the currently-pinned card and +// the header can render the "pinned on …" chip. Kept in sync with the +// server after every successful POST /api/dashboard/settings. +let SETTINGS = {default_view: "overview", pinned_brand_slug: ""}; + +async function loadSettings() { + try { + const r = await fetch("/api/dashboard/settings"); + if (r.ok) SETTINGS = await r.json(); + } catch (_) { /* keep defaults */ } + renderPinStatus(); +} + +function renderPinStatus() { + const el = document.getElementById("pinStatus"); + if (!el) return; + const view = SETTINGS.default_view || "overview"; + if (view === "overview") { + el.innerHTML = `Home view: Overview`; + return; + } + let label = ""; + if (view === "cockpit") label = "Cockpit"; + else if (view.startsWith("brand:")) label = view.slice(6); + el.innerHTML = ` + Home view: + ${escapeHtml(label)} + + `; +} + +async function setHomeView(view) { + try { + const r = await fetch("/api/dashboard/settings", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({default_view: view}) + }); + if (r.ok) { + SETTINGS = await r.json(); + renderPinStatus(); + refresh(); // re-render brand cards to move the "pinned" chip + } + } catch (e) { + console.error("setHomeView failed:", e); + } +} + +// Expose toggle for onclick handlers rendered inline in card HTML +window.toggleBrandPin = function (slug) { + if (!slug) return; + const current = SETTINGS.default_view || "overview"; + if (current === `brand:${slug}`) setHomeView("overview"); + else setHomeView(`brand:${slug}`); +}; +window.setHomeView = setHomeView; + // ── Small helpers ───────────────────────────────────────────────────── function pct(x) { return Math.max(0, Math.min(100, Math.round(x * 100))); } function pctRaw(x) { return Math.max(0, x * 100); } @@ -3751,7 +3846,14 @@ async def dashboard(): ? `updated ${Math.max(1, Math.round((Date.now() - mostRecent) / 1000))}s ago` : ""; - return `
+ const isPinned = slug && SETTINGS.default_view === `brand:${slug}`; + const pinnedClass = isPinned ? " pinned" : ""; + const pinLabel = isPinned ? "πŸ“Œ Home" : "Pin as Home"; + const pinBtn = slug + ? `` + : ""; + + return `
${EMOJI[worst] || "Β·"}${escapeHtml(brand)}
${identityHtml} @@ -3760,6 +3862,7 @@ async def dashboard():
${when} + ${pinBtn} Details β†’ Cockpit β†— @@ -3857,7 +3960,7 @@ async def dashboard(): } } -refresh(); +loadSettings().then(refresh); setInterval(refresh, 60000); @@ -4008,6 +4111,7 @@ async def dashboard():
Fetching brand context…
+ Open in Cockpit β†— @@ -4053,6 +4157,54 @@ async def dashboard(): const BRAND_SLUG = "__BRAND_SLUG__"; const COCKPIT_URL = "__COCKPIT_URL__"; +// Home-pin state mirrored from /api/dashboard/settings. The pin button +// in the header toggles ``default_view`` between ``brand:`` and +// ``overview`` so operators can promote / demote this view with one +// click. See docs/GATE-BAR-DESIGN.md Β§Default Landing View. +let PIN_SETTINGS = {default_view: "overview", pinned_brand_slug: ""}; + +async function loadPinSettings() { + try { + const r = await fetch("/api/dashboard/settings"); + if (r.ok) PIN_SETTINGS = await r.json(); + } catch (_) { /* keep defaults */ } + renderPinButton(); +} + +function renderPinButton() { + const btn = document.getElementById("pinBtn"); + if (!btn) return; + const pinned = PIN_SETTINGS.default_view === `brand:${BRAND_SLUG}`; + if (pinned) { + btn.textContent = "πŸ“Œ Home (click to unpin)"; + btn.style.borderColor = "var(--accent)"; + btn.style.color = "var(--accent)"; + } else { + btn.textContent = "Pin as Home"; + btn.style.borderColor = "var(--border)"; + btn.style.color = "var(--fg)"; + } +} + +async function togglePin() { + const pinned = PIN_SETTINGS.default_view === `brand:${BRAND_SLUG}`; + const target = pinned ? "overview" : `brand:${BRAND_SLUG}`; + try { + const r = await fetch("/api/dashboard/settings", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({default_view: target}) + }); + if (r.ok) { + PIN_SETTINGS = await r.json(); + renderPinButton(); + } + } catch (e) { + console.error("togglePin failed:", e); + } +} +window.togglePin = togglePin; + function fmtPct(v) { return (v * 100).toFixed(1).replace(/\\.0$/, "") + "%"; } function fmtNum(v) { if (v === null || v === undefined) return "β€”"; if (v >= 1e6) return (v / 1e6).toFixed(1) + "M"; @@ -4228,6 +4380,7 @@ async def dashboard(): } } +loadPinSettings(); refresh(); setInterval(refresh, 60000); @@ -4247,9 +4400,94 @@ def _cockpit_base_url() -> str: @app.get("/dashboard/quotas", response_class=HTMLResponse) -async def dashboard_quotas(): - """Self-contained quotas page. Polls /api/quotas every 60s.""" - return _QUOTAS_DASHBOARD_HTML.replace("__COCKPIT_URL__", _cockpit_base_url()) +async def dashboard_quotas(request: Request): + """Self-contained quotas page. Polls /api/quotas every 60s. + + Honors ``dashboard.quotas.default_view`` from config.yaml: + + - ``"overview"`` β€” render the grid (default). + - ``"brand:"`` β€” 302 to ``/dashboard/quotas/``. + - ``"cockpit"`` β€” 302 to the Operator Cockpit. + + ``?view=overview`` always forces the grid, so a pinned brand card + can link back home without the redirect fighting the user. + """ + override = (request.query_params.get("view") or "").strip().lower() + if override == "overview": + return HTMLResponse(_QUOTAS_DASHBOARD_HTML.replace("__COCKPIT_URL__", _cockpit_base_url())) + + from .dashboard_settings import get_settings + + try: + settings = get_settings() + except Exception as exc: # noqa: BLE001 β€” never break the dashboard on a settings read + logger.warning("dashboard_quotas: settings read failed, falling back to overview: %s", exc) + settings = {"default_view": "overview", "pinned_brand_slug": ""} + + default_view = str(settings.get("default_view") or "overview") + if default_view == "cockpit": + return RedirectResponse(url=_cockpit_base_url(), status_code=302) + if default_view.startswith("brand:"): + slug = default_view[len("brand:") :] + if slug: + return RedirectResponse(url=f"/dashboard/quotas/{slug}", status_code=302) + + return HTMLResponse(_QUOTAS_DASHBOARD_HTML.replace("__COCKPIT_URL__", _cockpit_base_url())) + + +@app.get("/api/dashboard/settings") +async def api_dashboard_settings_get(): + """Read-only view of ``dashboard.quotas.*`` settings. + + Used by the overview HTML (to show "pinned" state on a card) and by + the Gate Bar menubar app (to decide which page "Open Dashboard" should + land on β€” but we let the server-side redirect do the work, so the + Gate Bar just opens ``/dashboard/quotas`` without branching logic). + """ + from .dashboard_settings import get_settings + + try: + return get_settings() + except Exception as exc: # noqa: BLE001 + logger.warning("api_dashboard_settings_get failed: %s", exc) + return {"default_view": "overview", "pinned_brand_slug": ""} + + +@app.post("/api/dashboard/settings") +async def api_dashboard_settings_post(request: Request): + """Update ``dashboard.quotas.default_view``. + + Body (JSON): ``{"default_view": "overview" | "cockpit" | "brand:"}``. + Returns the canonical settings dict after the write, or 400 on bad + input. Config file writes go through an atomic rename; comments and + key order in ``config.yaml`` are preserved via ruamel.yaml. + """ + from .dashboard_settings import set_default_view, validate_default_view + + try: + payload = await request.json() + except Exception: # noqa: BLE001 + return JSONResponse({"error": "invalid JSON body"}, status_code=400) + + if not isinstance(payload, dict): + return JSONResponse({"error": "body must be a JSON object"}, status_code=400) + + raw_view = payload.get("default_view") + if not isinstance(raw_view, str): + return JSONResponse({"error": "default_view must be a string"}, status_code=400) + + try: + validate_default_view(raw_view) + except ValueError as exc: + return JSONResponse({"error": str(exc)}, status_code=400) + + try: + return set_default_view(raw_view) + except FileNotFoundError as exc: + return JSONResponse({"error": f"config.yaml not found: {exc}"}, status_code=500) + except Exception as exc: # noqa: BLE001 + logger.exception("api_dashboard_settings_post: write failed") + return JSONResponse({"error": f"write failed: {exc}"}, status_code=500) def _brand_context(brand_slug: str) -> dict[str, Any] | None: diff --git a/pyproject.toml b/pyproject.toml index b86dccd..2039bda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,10 @@ dependencies = [ "httpx>=0.27.0,<0.29", "pydantic>=2.9,<2.14", "pyyaml>=6.0.2", + # ruamel.yaml powers comment-preserving round-trip writes to config.yaml + # from the dashboard settings endpoint. Standard yaml.safe_dump would + # destroy operator-authored comments (section banners, OAuth docs, …). + "ruamel.yaml>=0.18.6", "python-dotenv>=1.0.1", "python-multipart>=0.0.9", ] diff --git a/requirements.txt b/requirements.txt index c3edb0e..a542ccd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ fastapi>=0.115.0 uvicorn[standard]>=0.32.0 httpx>=0.27.0 pyyaml>=6.0.2 +ruamel.yaml>=0.18.6 python-dotenv>=1.0.1 aiosqlite>=0.20.0 python-multipart>=0.0.9 diff --git a/tests/test_dashboard_settings.py b/tests/test_dashboard_settings.py new file mode 100644 index 0000000..f6329fe --- /dev/null +++ b/tests/test_dashboard_settings.py @@ -0,0 +1,340 @@ +"""Coverage for ``dashboard.quotas.default_view`` persistence and the +``/api/dashboard/settings`` + ``/dashboard/quotas`` redirect contract. + +Pins (v2.3 Phase B.5): + +1. ``dashboard_settings.get_settings()`` defaults to ``overview`` when the + block is missing, and validates ``default_view`` on read (bad values + degrade to ``overview`` instead of crashing the endpoint). + +2. ``dashboard_settings.set_default_view()`` writes back to config.yaml + through ruamel.yaml round-trip β€” **comments and neighbor keys + survive**. This is the whole reason we took the ruamel.yaml + dependency; a regression here silently destroys 200+ operator + comments in the real config. + +3. Bad ``default_view`` values (empty, uppercase garbage, unknown + literal, bogus ``brand:`` suffix) raise ``ValueError`` and never + touch the file. + +4. ``POST /api/dashboard/settings`` surfaces validation errors as 400 and + otherwise returns the canonical settings dict. + +5. ``GET /dashboard/quotas`` redirects (302) for ``brand:`` and + ``cockpit`` defaults, **except** when the caller passes + ``?view=overview`` (the escape hatch a pinned-brand card uses to + link back home). +""" + +from __future__ import annotations + +import importlib +import shutil +import sys +from contextlib import asynccontextmanager +from pathlib import Path + +import pytest + +sys.modules.pop("httpx", None) +import httpx # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +sys.modules["httpx"] = httpx + +sys.modules.pop("faigate.main", None) + +import faigate.dashboard_settings as ds # noqa: E402 +import faigate.main as main_module # noqa: E402 +from faigate.config import load_config # noqa: E402 +from faigate.router import Router # noqa: E402 + +importlib.reload(main_module) + + +# Heavily-commented fragment modeled after the real ``config.yaml`` so the +# round-trip test can verify every kind of comment survives: section +# banners, inline end-of-line notes, and blank-line separators. +_COMMENTED_CONFIG = """\ +# ── Server section ──────────────────────────────────────────────────── +server: + host: "127.0.0.1" + port: 8090 # dev default + +# providers left intentionally empty for this test +providers: {} + +fallback_chain: [] + +# ── Metrics ─────────────────────────────────────────────────────────── +metrics: + enabled: false + +# End-of-file trailing comment β€” should also survive. +""" + + +@pytest.fixture +def config_path(tmp_path: Path) -> Path: + path = tmp_path / "config.yaml" + path.write_text(_COMMENTED_CONFIG, encoding="utf-8") + return path + + +# ── Unit tests: dashboard_settings module ──────────────────────────────────── + + +class TestGetSettings: + def test_missing_block_returns_overview_defaults(self, config_path: Path): + got = ds.get_settings(config_path) + assert got == {"default_view": "overview", "pinned_brand_slug": ""} + + def test_reads_brand_view_after_write(self, config_path: Path): + ds.set_default_view("brand:claude", path=config_path) + got = ds.get_settings(config_path) + assert got == {"default_view": "brand:claude", "pinned_brand_slug": "claude"} + + def test_cockpit_view_has_no_pinned_slug(self, config_path: Path): + ds.set_default_view("cockpit", path=config_path) + got = ds.get_settings(config_path) + assert got == {"default_view": "cockpit", "pinned_brand_slug": ""} + + def test_bad_stored_value_degrades_to_overview(self, config_path: Path): + # Simulate a hand-edited config with a garbage value β€” the reader + # must not 500; it degrades to overview so the dashboard loads. + config_path.write_text( + _COMMENTED_CONFIG + "\ndashboard:\n quotas:\n default_view: lolwut\n", + encoding="utf-8", + ) + got = ds.get_settings(config_path) + assert got["default_view"] == "overview" + + def test_nonexistent_file_returns_defaults(self, tmp_path: Path): + got = ds.get_settings(tmp_path / "does-not-exist.yaml") + assert got == {"default_view": "overview", "pinned_brand_slug": ""} + + +class TestValidateDefaultView: + @pytest.mark.parametrize( + "value,expected", + [ + ("overview", "overview"), + (" OVERVIEW ", "overview"), # trimmed + lowered + ("cockpit", "cockpit"), + ("brand:claude", "brand:claude"), + ("brand:deepseek-chat", "brand:deepseek-chat"), + ("BRAND:CLAUDE", "brand:claude"), + ], + ) + def test_accepts_canonical_values(self, value: str, expected: str): + assert ds.validate_default_view(value) == expected + + @pytest.mark.parametrize( + "value", + ["", "home", "brand:", "brand:Has Spaces", "brand:", "random", "cockpit:claude", "brand:under_score"], + ) + def test_rejects_bad_values(self, value: str): + with pytest.raises(ValueError): + ds.validate_default_view(value) + + +class TestSetDefaultViewRoundTrip: + def test_preserves_comments_and_blank_lines(self, config_path: Path): + before = config_path.read_text(encoding="utf-8") + before_comment_count = before.count("#") + assert before_comment_count >= 4 # sanity: the fixture has 4+ comment lines + + ds.set_default_view("brand:claude", path=config_path) + after = config_path.read_text(encoding="utf-8") + + # Every comment survives the round-trip. + assert after.count("#") == before_comment_count + for sentinel in ( + "# ── Server section", + "port: 8090 # dev default", + "# providers left intentionally empty for this test", + "# ── Metrics", + "# End-of-file trailing comment β€” should also survive.", + ): + assert sentinel in after, f"Lost comment: {sentinel!r}" + + # The new block was appended, other keys stayed intact. + assert "dashboard:" in after + assert "default_view: brand:claude" in after + assert "pinned_brand_slug: claude" in after + assert "providers: {}" in after + assert "fallback_chain: []" in after + + def test_pinning_a_different_brand_updates_both_keys(self, config_path: Path): + ds.set_default_view("brand:claude", path=config_path) + ds.set_default_view("brand:deepseek", path=config_path) + got = ds.get_settings(config_path) + assert got == {"default_view": "brand:deepseek", "pinned_brand_slug": "deepseek"} + + def test_switching_away_from_brand_drops_pinned_slug_key(self, config_path: Path): + ds.set_default_view("brand:claude", path=config_path) + assert "pinned_brand_slug: claude" in config_path.read_text(encoding="utf-8") + + ds.set_default_view("overview", path=config_path) + text = config_path.read_text(encoding="utf-8") + assert "pinned_brand_slug" not in text + assert "default_view: overview" in text + + def test_rejects_bad_values_without_touching_file(self, config_path: Path): + before = config_path.read_text(encoding="utf-8") + with pytest.raises(ValueError): + ds.set_default_view("not-a-real-view", path=config_path) + assert config_path.read_text(encoding="utf-8") == before + + def test_atomic_rename_leaves_no_tmp_files(self, config_path: Path): + ds.set_default_view("brand:claude", path=config_path) + siblings = list(config_path.parent.iterdir()) + leftover = [s for s in siblings if s.name.startswith(".dashboard_settings.")] + assert leftover == [], f"leftover tmp files: {leftover}" + + +# ── HTTP-level tests: endpoints + redirect ────────────────────────────────── + + +def _write_minimal_config(tmp_path: Path) -> Path: + path = tmp_path / "config.yaml" + path.write_text( + """\ +server: + host: "127.0.0.1" + port: 8090 +providers: {} +fallback_chain: [] +metrics: + enabled: false +""", + encoding="utf-8", + ) + return path + + +@pytest.fixture +def api_client(tmp_path, monkeypatch): + cfg_path = _write_minimal_config(tmp_path) + cfg = load_config(cfg_path) + + # dashboard_settings always reads FAIGATE_CONFIG_FILE (same env var as + # the rest of faigate) β€” point it at our scratch file. + monkeypatch.setenv("FAIGATE_CONFIG_FILE", str(cfg_path)) + monkeypatch.setenv("FAIGATE_COCKPIT_URL", "https://cockpit.example") + + @asynccontextmanager + async def _noop_lifespan(_app): + yield + + monkeypatch.setattr(main_module, "_config", cfg, raising=False) + monkeypatch.setattr(main_module, "_router", Router(cfg), raising=False) + monkeypatch.setattr(main_module, "_providers", {}, raising=False) + monkeypatch.setattr(main_module.app.router, "lifespan_context", _noop_lifespan, raising=False) + + with TestClient(main_module.app, follow_redirects=False) as client: + client.config_path = cfg_path # type: ignore[attr-defined] + yield client + + +class TestSettingsApi: + def test_get_defaults_when_unset(self, api_client): + r = api_client.get("/api/dashboard/settings") + assert r.status_code == 200 + assert r.json() == {"default_view": "overview", "pinned_brand_slug": ""} + + def test_post_brand_persists_and_echoes(self, api_client): + r = api_client.post("/api/dashboard/settings", json={"default_view": "brand:claude"}) + assert r.status_code == 200 + assert r.json() == {"default_view": "brand:claude", "pinned_brand_slug": "claude"} + + # Value is visible on the next GET (persisted through config.yaml). + r = api_client.get("/api/dashboard/settings") + assert r.json()["default_view"] == "brand:claude" + + def test_post_cockpit(self, api_client): + r = api_client.post("/api/dashboard/settings", json={"default_view": "cockpit"}) + assert r.status_code == 200 + assert r.json() == {"default_view": "cockpit", "pinned_brand_slug": ""} + + def test_post_overview(self, api_client): + api_client.post("/api/dashboard/settings", json={"default_view": "brand:claude"}) + r = api_client.post("/api/dashboard/settings", json={"default_view": "overview"}) + assert r.status_code == 200 + assert r.json() == {"default_view": "overview", "pinned_brand_slug": ""} + + def test_post_bad_value_400s(self, api_client): + r = api_client.post("/api/dashboard/settings", json={"default_view": "bogus"}) + assert r.status_code == 400 + assert "default_view" in r.json()["error"] + + def test_post_missing_key_400s(self, api_client): + r = api_client.post("/api/dashboard/settings", json={}) + assert r.status_code == 400 + + def test_post_non_object_400s(self, api_client): + r = api_client.post("/api/dashboard/settings", json=["overview"]) + assert r.status_code == 400 + + def test_post_invalid_json_400s(self, api_client): + r = api_client.post( + "/api/dashboard/settings", + content=b"this is not json", + headers={"Content-Type": "application/json"}, + ) + assert r.status_code == 400 + + +class TestDashboardRedirect: + def test_overview_default_renders_html(self, api_client): + r = api_client.get("/dashboard/quotas") + assert r.status_code == 200 + assert " Date: Sun, 19 Apr 2026 18:35:07 +0200 Subject: [PATCH 07/11] =?UTF-8?q?feat(gate-bar):=20local=20install=20scrip?= =?UTF-8?q?t=20=E2=80=94=20.app=20bundle=20+=20ad-hoc=20sign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until Gate Bar 0.2 ships a notarized Homebrew cask, developers who want to try the menubar app on their own machine can run: cd apps/gate-bar ./scripts/install-local.sh The script does a release build, wraps the binary in a minimal .app bundle (LSUIElement so no Dock icon, macOS 14+ min version), ad-hoc code-signs it, and installs to ~/Applications. Because the binary is built and signed on the same machine, Gatekeeper trusts it without a Developer ID round-trip β€” no quarantine xattr means no first-launch prompt. Flags: --no-open skips the auto-launch, --uninstall removes the installed bundle. Co-Authored-By: Claude Opus 4.7 --- apps/gate-bar/scripts/install-local.sh | 160 +++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100755 apps/gate-bar/scripts/install-local.sh diff --git a/apps/gate-bar/scripts/install-local.sh b/apps/gate-bar/scripts/install-local.sh new file mode 100755 index 0000000..e9c63b4 --- /dev/null +++ b/apps/gate-bar/scripts/install-local.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# +# Local install for Gate Bar β€” build, wrap, copy, ad-hoc sign. +# +# Why this exists: +# Gate Bar 0.2+ will ship a notarized .app via Homebrew cask. Until +# then, developers who want to try it on their own machine can run +# this script: it builds a release binary, wraps it in a minimal .app +# bundle, and copies it to ~/Applications/. Because the binary is +# built and ad-hoc code-signed on the same machine, Gatekeeper trusts +# it without a full Developer ID notarization round-trip. +# +# Usage: +# ./scripts/install-local.sh # build + install + open +# ./scripts/install-local.sh --no-open # don't launch after install +# ./scripts/install-local.sh --uninstall # remove ~/Applications/Gate Bar.app + +set -euo pipefail + +APP_NAME="Gate Bar" +APP_BUNDLE_ID="ai.fusionaize.gate-bar" +APP_VERSION="0.1.0" +APP_MIN_MACOS="14.0" +BIN_NAME="GateBar" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PKG_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +INSTALL_DIR="$HOME/Applications" +APP_PATH="$INSTALL_DIR/$APP_NAME.app" + +OPEN_AFTER_INSTALL=1 +for arg in "$@"; do + case "$arg" in + --no-open) + OPEN_AFTER_INSTALL=0 + ;; + --uninstall) + if [[ -d "$APP_PATH" ]]; then + echo "β†’ Removing $APP_PATH" + rm -rf "$APP_PATH" + echo "βœ“ Gate Bar uninstalled." + else + echo "β†’ Nothing to uninstall at $APP_PATH" + fi + exit 0 + ;; + -h|--help) + grep -E '^#( |$)' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "Unknown argument: $arg" >&2 + exit 2 + ;; + esac +done + +cd "$PKG_DIR" + +# ── 1. Build the release binary ───────────────────────────────────────────── +echo "β†’ Building $BIN_NAME (release)…" +swift build -c release + +BINARY_PATH="$(swift build -c release --show-bin-path)/$BIN_NAME" +if [[ ! -x "$BINARY_PATH" ]]; then + echo "βœ— Release binary not found at $BINARY_PATH" >&2 + exit 1 +fi +echo " built: $BINARY_PATH" + +# ── 2. Assemble the .app bundle in a staging dir ──────────────────────────── +STAGING="$(mktemp -d -t gatebar-install)" +trap 'rm -rf "$STAGING"' EXIT +BUNDLE="$STAGING/$APP_NAME.app" +mkdir -p "$BUNDLE/Contents/MacOS" + +cp "$BINARY_PATH" "$BUNDLE/Contents/MacOS/$BIN_NAME" +chmod +x "$BUNDLE/Contents/MacOS/$BIN_NAME" + +cat > "$BUNDLE/Contents/Info.plist" < + + + + CFBundleName + $APP_NAME + CFBundleDisplayName + $APP_NAME + CFBundleIdentifier + $APP_BUNDLE_ID + CFBundleExecutable + $BIN_NAME + CFBundlePackageType + APPL + CFBundleShortVersionString + $APP_VERSION + CFBundleVersion + $APP_VERSION + LSMinimumSystemVersion + $APP_MIN_MACOS + LSUIElement + + NSHighResolutionCapable + + NSSupportsAutomaticTermination + + NSSupportsSuddenTermination + + + +PLIST + +# PkgInfo β€” legacy metadata that some macOS versions still sniff. Four +# chars "APPL" + four chars creator code. "????" is the conventional +# placeholder for a no-creator app. +printf 'APPL????' > "$BUNDLE/Contents/PkgInfo" + +# ── 3. Ad-hoc code-sign so Gatekeeper trusts the bundle ───────────────────── +# The `-` identity means ad-hoc: no Developer ID, but macOS still records a +# signature that ties the bundle to this machine. First-run Gatekeeper +# prompt may still appear once; after that the app runs freely. +echo "β†’ Ad-hoc code-signing the bundle…" +codesign --force --deep --sign - "$BUNDLE" 2>&1 | sed 's/^/ /' || { + echo "βœ— codesign failed" >&2 + exit 1 +} +# Verify the signature actually took +codesign --verify --verbose=2 "$BUNDLE" 2>&1 | sed 's/^/ /' + +# ── 4. Move into place ────────────────────────────────────────────────────── +mkdir -p "$INSTALL_DIR" +if [[ -d "$APP_PATH" ]]; then + echo "β†’ Replacing existing $APP_PATH" + # Kill any running instance so the replace doesn't fail on a busy Mach-O. + pkill -x "$BIN_NAME" 2>/dev/null || true + rm -rf "$APP_PATH" +fi +mv "$BUNDLE" "$APP_PATH" +echo "βœ“ Installed to $APP_PATH" + +# ── 5. Hint at launch-at-login (manual β€” SMAppService needs a code-signed .app +# with a proper Developer ID; ad-hoc builds can't register. So we print +# a one-liner the operator can paste to use launchctl instead, which +# works without Developer ID.) ────────────────────────────────────────── +cat < Date: Sun, 19 Apr 2026 18:35:18 +0200 Subject: [PATCH 08/11] =?UTF-8?q?chore:=20release=20v2.3.0=20=E2=80=94=20b?= =?UTF-8?q?rand-first=20quota=20widget=20+=20Gate=20Bar=200.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates six commits on this branch into a single release: - Brand-card overview at /dashboard/quotas (worst-alert first, pace marker, identity line, per-brand credential gating). - Per-brand detail view at /dashboard/quotas/ + three read-only endpoints backing it (clients, routes, analytics). - Default landing view (dashboard.quotas.default_view in config.yaml) with Pin-as-Home on every card; writes go through ruamel.yaml round-trip so operator comments survive a pin toggle. - Gate Bar 0.1 SwiftUI menubar companion at apps/gate-bar/ β€” reads /api/quotas, renders cards, links to Dashboard and Cockpit. Source-only for now; notarized cask ships with Gate Bar 0.2. New runtime dep: ruamel.yaml>=0.18.6 (required for the comment-preserving dashboard.quotas.default_view writes). Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 20 ++++++++++++++++++++ faigate/__init__.py | 2 +- pyproject.toml | 2 +- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4857bfc..1b173db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # fusionAIze Gate Changelog +## v2.3.0 - 2026-04-19 + +### Added + +- **Brand-first quota widget** (`/dashboard/quotas`): replaces the flat package list with one card per brand (Claude, DeepSeek, Kilo, …). Cards are sorted worst-alert first so the thing about to break is at the top. Each card stacks its sub-packages (session vs weekly, pay-as-you-go vs credits) with a pace marker on every window-based bar and an identity line (`OAuth Β· claude-code`, `API key Β· ${KEY}`) so you can tell which account feeds the meter. +- **Per-brand detail view** (`/dashboard/quotas/`): a focused page that shows the same quota panel plus a 24h totals strip, clients-by-profile table, routes/lanes breakdown, and an hourly sparkline. Shares CSS variables with the overview so scanning between them needs no retraining. +- **Read-only brand endpoints** (`/api/quotas//clients`, `/routes`, `/analytics`): the data feed behind the detail view. 404 on unknown brands (distinguishes "typo" from "no traffic yet"); analytics clamps `hours=1..168` and `days=1..90` to prevent URL-typo DB scans. Catalog surfaces `catalog_tagline` so the "Available to add" mini-block can show tier/price/quota shape at a glance. +- **Default landing view** (`dashboard.quotas.default_view` in `config.yaml`): three options β€” `overview` (default), `brand:`, `cockpit`. `GET /dashboard/quotas` honors the setting via 302; `?view=overview` is an always-available escape hatch. A `Pin as Home` / `πŸ“Œ Home` button sits on every brand card and the detail-page header β€” one-click promotion, no modal. Writes go through `ruamel.yaml` round-trip so the 220+ operator comments in a real `config.yaml` survive a pin toggle (`yaml.safe_dump` would flatten them). `GET/POST /api/dashboard/settings` drives the widget and is available to external consumers. +- **Gate Bar 0.1 (macOS menubar companion)** at `apps/gate-bar/`: SwiftUI `MenuBarExtra` app that reads the same `/api/quotas` feed and shows `fAI Β· 83%` in the menubar (tightest window across all brands, colour-coded). Popover renders brand cards with the web widget's visual vocabulary; footer links to `Dashboard β†—` (server-side redirect honours `default_view`) and `Cockpit β†—`. Preferences: gateway URL, Cockpit URL, refresh cadence (manual / 1 / 2 / 5 / 15 min). 13 Swift-Testing tests green on both Xcode.app and Command Line Tools via `scripts/swift-test.sh`. Read-only β€” every write path links out to the Operator Cockpit. Sparkle auto-update + notifications + code signing + Homebrew cask tracked for 0.2+ (`apps/gate-bar/README.md`). + +### Changed + +- Quota widget groups by `provider_id` first and gates on credential availability so brands without a resolvable API key / OAuth token land in the collapsed "Skipped" block instead of showing a perpetually-empty bar. +- `QuotaStatus` payload carries `brand`, `brand_slug`, `pace_delta`, and `identity` on every package (v1.3 catalog schema). Older v1.2 catalogs still decode through the brand-fallback table in `quota_tracker.py`. + +### Upgrade notes + +- New runtime dependency: `ruamel.yaml>=0.18.6` (already added to `requirements.txt` + `pyproject.toml`). Fresh `pip install faigate` or `brew upgrade faigate` picks it up automatically. +- The Gate Bar macOS app is a separate artifact. v0.1 is source-only β€” build it with `cd apps/gate-bar && ./scripts/install-local.sh` for local testing. A notarized Homebrew cask ships with Gate Bar 0.2. + ## v2.2.3 - 2026-04-18 ### Fixed diff --git a/faigate/__init__.py b/faigate/__init__.py index ee3cd09..6ab741d 100644 --- a/faigate/__init__.py +++ b/faigate/__init__.py @@ -1,3 +1,3 @@ """fusionAIze Gate package.""" -__version__ = "2.2.3" +__version__ = "2.3.0" diff --git a/pyproject.toml b/pyproject.toml index 2039bda..5a1b5e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "faigate" -version = "2.2.3" +version = "2.3.0" description = "Local OpenAI-compatible routing gateway for OpenClaw and other AI-native clients." readme = "README.md" license = "Apache-2.0" From 0f433617e802b1aa4c9a44a2068ed08478fb99cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Lange?= Date: Sun, 19 Apr 2026 19:08:14 +0200 Subject: [PATCH 09/11] fix(security,lint): address CodeQL + ruff findings from PR #217 CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three classes of fixes: 1. Reflective XSS (high severity) on /dashboard/quotas/{brand_slug}: The slug from the URL path was spliced into the HTML template via str.replace without validation, so a crafted slug containing HTML could escape the attribute context. Now whitelisted to [a-z0-9][a-z0-9-]{0,63} β€” matches every real catalog brand and rejects anything else with 404. 2. Stack-trace exposure (medium, x3) on POST /api/dashboard/settings: Error responses included str(exc) for both ValueError and the catch-all, which can leak internal paths or user-supplied fragments. Replaced with static messages; the real exception is logged via logger.exception. 3. Ruff F401: removed unused imports shutil (test_dashboard_settings) and timedelta (test_quota_tracker_brand) flagged by the lint CI job. --- faigate/main.py | 22 +++++++++++++++------- tests/test_dashboard_settings.py | 1 - tests/test_quota_tracker_brand.py | 2 +- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/faigate/main.py b/faigate/main.py index cd88f29..776a7bb 100644 --- a/faigate/main.py +++ b/faigate/main.py @@ -4478,16 +4478,21 @@ async def api_dashboard_settings_post(request: Request): try: validate_default_view(raw_view) - except ValueError as exc: - return JSONResponse({"error": str(exc)}, status_code=400) + except ValueError: + # Don't echo the raw exception text β€” it may contain user-supplied + # fragments that a future refactor could forward to a templated + # error page. Keep the surface message static; the real cause is + # knowable from the client (it just sent the value). + return JSONResponse({"error": "default_view is not a valid view identifier"}, status_code=400) try: return set_default_view(raw_view) - except FileNotFoundError as exc: - return JSONResponse({"error": f"config.yaml not found: {exc}"}, status_code=500) - except Exception as exc: # noqa: BLE001 + except FileNotFoundError: + logger.exception("api_dashboard_settings_post: config.yaml missing") + return JSONResponse({"error": "config.yaml not found"}, status_code=500) + except Exception: # noqa: BLE001 logger.exception("api_dashboard_settings_post: write failed") - return JSONResponse({"error": f"write failed: {exc}"}, status_code=500) + return JSONResponse({"error": "write failed"}, status_code=500) def _brand_context(brand_slug: str) -> dict[str, Any] | None: @@ -4665,7 +4670,10 @@ async def dashboard_quota_brand(brand_slug: str): showing the "Back to overview" link. """ slug = (brand_slug or "").strip().lower() - if not slug: + # Strict whitelist: the slug is spliced into an HTML template below via + # str.replace, so anything outside this grammar would be a reflected-XSS + # vector. Valid catalog slugs (claude/codex/gemini/…) all fit. + if not slug or not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,63}", slug): return JSONResponse({"error": {"message": "Unknown brand"}}, status_code=404) html = _QUOTAS_BRAND_DETAIL_HTML html = html.replace("__COCKPIT_URL__", _cockpit_base_url()) diff --git a/tests/test_dashboard_settings.py b/tests/test_dashboard_settings.py index f6329fe..3638a86 100644 --- a/tests/test_dashboard_settings.py +++ b/tests/test_dashboard_settings.py @@ -29,7 +29,6 @@ from __future__ import annotations import importlib -import shutil import sys from contextlib import asynccontextmanager from pathlib import Path diff --git a/tests/test_quota_tracker_brand.py b/tests/test_quota_tracker_brand.py index a72e6ce..554d912 100644 --- a/tests/test_quota_tracker_brand.py +++ b/tests/test_quota_tracker_brand.py @@ -9,7 +9,7 @@ from __future__ import annotations -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from faigate.quota_tracker import ( _derive_brand, From 4f290f0cddbfd379f56b4d1d4819d17e4c9aa3b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Lange?= Date: Sun, 19 Apr 2026 19:12:25 +0200 Subject: [PATCH 10/11] fix(ci): align ruff target with py3.10 + escape brand slug + format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-up fixes for PR #217 CI: 1. pyproject.toml: ruff target-version py312 β†’ py310. The project's requires-python is >=3.10, but with py312 ruff's UP017 rule was rewriting datetime.timezone.utc to the 3.11-only datetime.UTC, breaking the 3.10 CI matrix job with ImportError. 2. test_quota_tracker_brand.py: switch UTC import to timezone.utc so the tests actually run on 3.10. 3. faigate/main.py: pass the already-whitelisted brand slug through html.escape before splicing into the HTML template. CodeQL's taint tracker doesn't recognise arbitrary regex sanitisers, so this satisfies py/reflective-xss without relaxing the regex guard above. 4. faigate/dashboard_settings.py: ruff format pass (cosmetic). --- faigate/dashboard_settings.py | 5 ++--- faigate/main.py | 7 ++++++- pyproject.toml | 5 ++++- tests/test_quota_tracker_brand.py | 8 ++++---- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/faigate/dashboard_settings.py b/faigate/dashboard_settings.py index c38263f..861225e 100644 --- a/faigate/dashboard_settings.py +++ b/faigate/dashboard_settings.py @@ -18,6 +18,7 @@ go through a POSIX atomic rename so a crash mid-write can't leave the operator with a half-written config. """ + from __future__ import annotations import io @@ -67,9 +68,7 @@ def validate_default_view(value: str) -> str: slug = candidate[len("brand:") :] if _slug_is_valid(slug): return f"brand:{slug}" - raise ValueError( - f"default_view must be 'overview', 'cockpit', or 'brand:' β€” got {value!r}" - ) + raise ValueError(f"default_view must be 'overview', 'cockpit', or 'brand:' β€” got {value!r}") def get_settings(path: str | Path | None = None) -> dict[str, Any]: diff --git a/faigate/main.py b/faigate/main.py index 776a7bb..05934a1 100644 --- a/faigate/main.py +++ b/faigate/main.py @@ -10,6 +10,7 @@ import argparse import asyncio +import html as html_lib import json import logging import mimetypes @@ -4675,9 +4676,13 @@ async def dashboard_quota_brand(brand_slug: str): # vector. Valid catalog slugs (claude/codex/gemini/…) all fit. if not slug or not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,63}", slug): return JSONResponse({"error": {"message": "Unknown brand"}}, status_code=404) + # Belt-and-suspenders: even though the regex above already guarantees the + # slug is safe, run it through html.escape so CodeQL's taint tracker + # (which doesn't recognise arbitrary regex sanitisers) can prove it. + safe_slug = html_lib.escape(slug, quote=True) html = _QUOTAS_BRAND_DETAIL_HTML html = html.replace("__COCKPIT_URL__", _cockpit_base_url()) - html = html.replace("__BRAND_SLUG__", slug) + html = html.replace("__BRAND_SLUG__", safe_slug) return html diff --git a/pyproject.toml b/pyproject.toml index 5a1b5e4..d2a52e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,10 @@ faigate = [ [tool.ruff] line-length = 120 -target-version = "py312" +# Must match ``requires-python`` above: ruff/UP rules otherwise rewrite +# e.g. ``datetime.timezone.utc`` β†’ ``datetime.UTC`` (added in 3.11) and +# break CI on the 3.10 job. +target-version = "py310" [tool.ruff.lint] select = ["E", "F", "I", "W", "UP"] diff --git a/tests/test_quota_tracker_brand.py b/tests/test_quota_tracker_brand.py index 554d912..cce0e3b 100644 --- a/tests/test_quota_tracker_brand.py +++ b/tests/test_quota_tracker_brand.py @@ -9,7 +9,7 @@ from __future__ import annotations -from datetime import UTC, datetime +from datetime import datetime, timezone from faigate.quota_tracker import ( _derive_brand, @@ -120,7 +120,7 @@ def test_daily_package_pace_is_negative_late_in_the_day(self): "limit_per_day": 1500, "_requires_credential": "GEMINI_API_KEY", } - fixed_now = datetime(2026, 4, 19, 22, 0, tzinfo=UTC) + fixed_now = datetime(2026, 4, 19, 22, 0, tzinfo=timezone.utc) status = compute_quota_status(pkg, now=fixed_now) assert status.brand == "Gemini" assert status.pace_delta is not None @@ -140,7 +140,7 @@ def test_brand_falls_back_when_catalog_omits_field(self): "window_hours": 5, "limit_per_window": 100, } - fixed_now = datetime(2026, 4, 19, 12, 0, tzinfo=UTC) + fixed_now = datetime(2026, 4, 19, 12, 0, tzinfo=timezone.utc) status = compute_quota_status(pkg, now=fixed_now) assert status.brand == "Claude" assert status.brand_slug == "claude" @@ -158,7 +158,7 @@ def test_status_dict_exposes_new_fields_for_api(self): "limit_per_day": 2000, "_requires_credential": "qwen-portal", } - fixed_now = datetime(2026, 4, 19, 12, 0, tzinfo=UTC) + fixed_now = datetime(2026, 4, 19, 12, 0, tzinfo=timezone.utc) data = compute_quota_status(pkg, now=fixed_now).to_dict() assert data["brand"] == "Qwen" assert data["brand_slug"] == "qwen" From 197c75927d8bf13acf614f9632fcffd1499ce97e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Lange?= Date: Sun, 19 Apr 2026 20:24:35 +0200 Subject: [PATCH 11/11] fix(py3.10): replace datetime.UTC with timezone.utc in production modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit faigate's requires-python is >=3.10 but three modules imported the 3.11+ datetime.UTC alias directly. Main's CI happened to stay green because no test module imported these three files at collection time β€” my new test_quota_tracker_brand.py exposed the latent bug on the 3.10 matrix job. Files fixed: faigate/quota_tracker.py β€” 4x datetime.now(UTC), 1x tzinfo=UTC, 1x tz=UTC faigate/quota_headers.py β€” 2x datetime.now(UTC), 2x .replace/.astimezone faigate/quota_poller.py β€” 2x datetime.now(UTC) Already-correct files left untouched: updates.py and tests/test_updates.py both use the canonical try/except ImportError shim. --- faigate/quota_headers.py | 10 +++++----- faigate/quota_poller.py | 6 +++--- faigate/quota_tracker.py | 14 +++++++------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/faigate/quota_headers.py b/faigate/quota_headers.py index 288e8c1..6f63151 100644 --- a/faigate/quota_headers.py +++ b/faigate/quota_headers.py @@ -54,7 +54,7 @@ import threading from collections.abc import Mapping from dataclasses import dataclass, field -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Any from .quota_tracker import update_package_usage @@ -136,7 +136,7 @@ def _parse_reset(val: Any, *, now: datetime | None = None) -> datetime | None: secs = float(raw) if secs < 0: return None - base = now or datetime.now(UTC) + base = now or datetime.now(timezone.utc) # Sanity: seconds-delta should fit in ~24h for rate-limit resets. if secs < 86400 * 2: return base + timedelta(seconds=secs) @@ -147,8 +147,8 @@ def _parse_reset(val: Any, *, now: datetime | None = None) -> datetime | None: iso = raw.replace("Z", "+00:00") dt = datetime.fromisoformat(iso) if dt.tzinfo is None: - dt = dt.replace(tzinfo=UTC) - return dt.astimezone(UTC) + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) except ValueError: return None @@ -178,7 +178,7 @@ def parse_headers(provider_id: str, headers: Mapping[str, str]) -> HeaderSnapsho # Normalise to lowercase keys while keeping original case in raw payload. low = {k.lower(): v for k, v in headers.items()} dialect = _detect_dialect(headers) - now = datetime.now(UTC) + now = datetime.now(timezone.utc) if dialect == "anthropic": return HeaderSnapshot( diff --git a/faigate/quota_poller.py b/faigate/quota_poller.py index 5decbc8..5a1b538 100644 --- a/faigate/quota_poller.py +++ b/faigate/quota_poller.py @@ -36,7 +36,7 @@ import logging import os from dataclasses import dataclass -from datetime import UTC, date, datetime +from datetime import date, datetime, timezone from pathlib import Path from typing import Any @@ -320,7 +320,7 @@ def _select_due_packages( A package is "due" if it has ``source == "api_poll"`` and package_type in the credits family. Fast-lane cadence kicks in for expiring credits. """ - now = now or datetime.now(UTC) + now = now or datetime.now(timezone.utc) today = now.date() out: list[tuple[str, dict[str, Any], int]] = [] for pkg_id, entry in packages.items(): @@ -435,7 +435,7 @@ def _persist_cache_to_disk( envelope = {} envelope.setdefault("schema_version", "1.1") envelope["packages"] = packages_cache - envelope["generated_at"] = datetime.now(UTC).isoformat(timespec="seconds") + envelope["generated_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds") tmp_path = path.with_suffix(path.suffix + ".tmp") tmp_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/faigate/quota_tracker.py b/faigate/quota_tracker.py index 675145b..cf46e97 100644 --- a/faigate/quota_tracker.py +++ b/faigate/quota_tracker.py @@ -97,7 +97,7 @@ import logging import sqlite3 from dataclasses import asdict, dataclass, field -from datetime import UTC, date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone from pathlib import Path from typing import Any, Literal @@ -175,12 +175,12 @@ def compute_quota_status( * ``package``: a dict exactly as stored in the packages catalog (one value from ``packages_catalog.items()``). Must contain at least ``provider_id``. Everything else is defaulted or computed. - * ``now``: injected for test determinism; defaults to ``datetime.now(UTC)``. + * ``now``: injected for test determinism; defaults to ``datetime.now(timezone.utc)``. * ``sqlite_path``: faigate.db path for looking up ``used`` for window types via local request counts. If ``None`` and the package is window-based, ``used`` falls back to the catalog-stored value. """ - now = now or datetime.now(UTC) + now = now or datetime.now(timezone.utc) package_id = package.get("package_id") or _synthesize_package_id(package) provider_id = package.get("provider_id") or "unknown" provider_group = package.get("provider_group") or _derive_provider_group(provider_id) @@ -462,7 +462,7 @@ def _status_daily( # Count requests since UTC midnight β€” summed across counted_ids so a daily # quota shared by multiple router provider IDs (e.g. Gemini free tier # covers both gemini-flash and gemini-flash-lite) reads accurately. - midnight = datetime(now.year, now.month, now.day, tzinfo=UTC) + midnight = datetime(now.year, now.month, now.day, tzinfo=timezone.utc) hours_since_midnight = (now - midnight).total_seconds() / 3600.0 window = max(hours_since_midnight, 0.01) used = sum( @@ -624,7 +624,7 @@ def _earliest_request_in_window( ) row = cur.fetchone() if row and row["t"] is not None: - return datetime.fromtimestamp(int(row["t"]), tz=UTC) + return datetime.fromtimestamp(int(row["t"]), tz=timezone.utc) return None except sqlite3.Error: return None @@ -704,7 +704,7 @@ def update_package_usage( entry["source"] = source if confidence is not None: entry["confidence"] = confidence - entry["last_updated"] = datetime.now(UTC).isoformat(timespec="seconds") + entry["last_updated"] = datetime.now(timezone.utc).isoformat(timespec="seconds") return True @@ -805,7 +805,7 @@ def format_status_line(status: QuotaStatus) -> str: "confidence": "estimated", }, } - now = datetime.now(UTC) + now = datetime.now(timezone.utc) for pid, pkg in demo_packages.items(): pkg["package_id"] = pid status = compute_quota_status(pkg, now=now)