Merge pull request 'feat(operations): show real claude.ai subscription usage % on /operations' (#244) from worktree-operations-usage-ui into main
This commit was merged in pull request #244.
This commit is contained in:
@@ -34,6 +34,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
_pkg_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
_pkg_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||||
@@ -93,6 +94,41 @@ async def _pm2_run(*args: str, timeout: float = 10) -> tuple[int, bytes, bytes]:
|
|||||||
return proc.returncode or 0, out, err
|
return proc.returncode or 0, out, err
|
||||||
|
|
||||||
|
|
||||||
|
# claude.ai subscription usage — the 5-hour / weekly utilization % the Claude
|
||||||
|
# Code status bar shows, from the (undocumented) OAuth usage endpoint. Host-only:
|
||||||
|
# the OAuth token lives in the CLI credentials file on the host, never in the
|
||||||
|
# container. Read-only (no auth), like /pm2. The claude-code User-Agent is
|
||||||
|
# REQUIRED — without it the request lands in an aggressively rate-limited bucket.
|
||||||
|
_CLAUDE_CRED_PATH = "/home/chaim/.claude/.credentials.json"
|
||||||
|
_OAUTH_USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
|
||||||
|
_USAGE_UA = "claude-code/2.1.177"
|
||||||
|
|
||||||
|
|
||||||
|
async def usage_status(request: web.Request) -> web.Response:
|
||||||
|
"""Proxy the claude.ai subscription usage % (host-only — needs the local
|
||||||
|
OAuth token). Returns the endpoint's JSON, or a 502 with an error string."""
|
||||||
|
try:
|
||||||
|
with open(_CLAUDE_CRED_PATH) as f:
|
||||||
|
token = json.load(f)["claudeAiOauth"]["accessToken"]
|
||||||
|
except Exception as e:
|
||||||
|
return web.json_response({"error": f"no claude credentials: {e}"}, status=502)
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"User-Agent": _USAGE_UA,
|
||||||
|
"anthropic-beta": "oauth-2025-04-20",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
timeout = aiohttp.ClientTimeout(total=15)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
async with session.get(_OAUTH_USAGE_URL, headers=headers) as r:
|
||||||
|
if r.status != 200:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": f"usage endpoint {r.status}"}, status=502)
|
||||||
|
return web.json_response(await r.json())
|
||||||
|
except Exception as e: # never throw
|
||||||
|
return web.json_response({"error": f"usage fetch failed: {e}"}, status=502)
|
||||||
|
|
||||||
|
|
||||||
async def pm2_status(request: web.Request) -> web.Response:
|
async def pm2_status(request: web.Request) -> web.Response:
|
||||||
"""Return a trimmed ``pm2 jlist`` for the legal-ai background services."""
|
"""Return a trimmed ``pm2 jlist`` for the legal-ai background services."""
|
||||||
try:
|
try:
|
||||||
@@ -235,6 +271,7 @@ def build_app() -> web.Application:
|
|||||||
app = web.Application(client_max_size=64 * 1024 * 1024)
|
app = web.Application(client_max_size=64 * 1024 * 1024)
|
||||||
app.router.add_get("/health", health)
|
app.router.add_get("/health", health)
|
||||||
app.router.add_get("/pm2", pm2_status)
|
app.router.add_get("/pm2", pm2_status)
|
||||||
|
app.router.add_get("/usage", usage_status)
|
||||||
app.router.add_post("/pm2/control", pm2_control)
|
app.router.add_post("/pm2/control", pm2_control)
|
||||||
app.router.add_post("/fetch", fetch)
|
app.router.add_post("/fetch", fetch)
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -33,12 +33,68 @@ import {
|
|||||||
type OperationsSnapshot,
|
type OperationsSnapshot,
|
||||||
type PipelineStats,
|
type PipelineStats,
|
||||||
type AgentRun,
|
type AgentRun,
|
||||||
|
type SubscriptionUsage,
|
||||||
|
type UsageWindow,
|
||||||
} from "@/lib/api/operations";
|
} from "@/lib/api/operations";
|
||||||
|
|
||||||
function mb(bytes: number): string {
|
function mb(bytes: number): string {
|
||||||
return `${Math.round((bytes || 0) / 1024 / 1024)}MB`;
|
return `${Math.round((bytes || 0) / 1024 / 1024)}MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// claude.ai subscription usage — the fuel gauge for every agent/drain.
|
||||||
|
// Bar turns amber > 75% and red > 90%; the halacha drain auto-pauses at 100%
|
||||||
|
// and resumes on its own when a window resets.
|
||||||
|
function usageResetLabel(iso: string | null): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `איפוס ${d.toLocaleTimeString("he-IL", { hour: "2-digit", minute: "2-digit" })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsageMeter({ label, w }: { label: string; w?: UsageWindow | null }) {
|
||||||
|
const util = w?.utilization;
|
||||||
|
const active = typeof util === "number";
|
||||||
|
const pct = active ? Math.min(100, Math.max(0, util as number)) : 0;
|
||||||
|
const fill = pct >= 90 ? "bg-danger" : pct >= 75 ? "bg-warn" : "bg-gold";
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between text-[13px] text-ink-soft mb-1.5">
|
||||||
|
<span>{label}</span>
|
||||||
|
{active ? (
|
||||||
|
<b className="text-navy tabular-nums text-[15px] font-bold">{Math.round(pct)}%</b>
|
||||||
|
) : (
|
||||||
|
<b className="text-ink-muted text-sm font-semibold">—</b>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="h-[7px] overflow-hidden rounded-full bg-rule-soft">
|
||||||
|
<span className={`block h-full rounded-full ${fill}`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 text-[11.5px] text-ink-muted">
|
||||||
|
{active ? usageResetLabel(w?.resets_at ?? null) : "לא פעיל כרגע"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubscriptionUsagePanel({ usage }: { usage: SubscriptionUsage }) {
|
||||||
|
if (!usage) return null; // undocumented endpoint unreachable — show nothing
|
||||||
|
return (
|
||||||
|
<Card className="border-rule">
|
||||||
|
<CardContent className="px-5 py-4">
|
||||||
|
<div className="grid gap-6 sm:grid-cols-3">
|
||||||
|
<UsageMeter label="חלון 5-שעות" w={usage.five_hour} />
|
||||||
|
<UsageMeter label="שבועי · כל המודלים" w={usage.seven_day} />
|
||||||
|
<UsageMeter label="שבועי · Opus" w={usage.seven_day_opus} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 border-t border-rule-soft pt-2.5 text-[11.5px] text-ink-muted">
|
||||||
|
המנוי המשותף שמפעיל את כל הסוכנים והדריינרים. הפס נצבע בענבר מעל 75%
|
||||||
|
ובאדום מעל 90%. דריינר-ההלכות משתהה אוטומטית כשחלון מגיע ל-100% וממשיך
|
||||||
|
לבד כשהוא מתאפס.
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// mockup 02: every region opens with a navy section heading (h2, 18px) — the
|
// mockup 02: every region opens with a navy section heading (h2, 18px) — the
|
||||||
// page reads as a sequence of titled sections rather than a stack of cards.
|
// page reads as a sequence of titled sections rather than a stack of cards.
|
||||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||||
@@ -777,6 +833,9 @@ export default function OperationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<SectionHeader>מכסת claude.ai</SectionHeader>
|
||||||
|
<SubscriptionUsagePanel usage={data.subscription_usage} />
|
||||||
|
|
||||||
<SectionHeader>סוכנים פעילים</SectionHeader>
|
<SectionHeader>סוכנים פעילים</SectionHeader>
|
||||||
<LiveAgentsPanel />
|
<LiveAgentsPanel />
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ function ProposalReview({ pairId }: { pairId: string }) {
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-md bg-gold-wash/30 border border-gold/40 p-3 space-y-3 text-[0.8rem]">
|
<div className="rounded-md bg-gold-wash/30 border border-gold/40 p-3 space-y-3 text-[0.8rem]">
|
||||||
<p className="text-ink-muted text-[0.75rem]">
|
<p className="text-ink-muted text-[0.75rem]">
|
||||||
הצעת הדיסטילציה (סגנון/שיטה בלבד — INV-LRN5). בחר/י מה לאמץ; ייכתב לערוצים שהכותב צורך (שער-יו"ר).
|
הצעת הדיסטילציה (סגנון/שיטה בלבד — INV-LRN5). בחר/י מה לאמץ; ייכתב לערוצים שהכותב צורך (שער-יו"ר).
|
||||||
</p>
|
</p>
|
||||||
{data.overall_assessment && (
|
{data.overall_assessment && (
|
||||||
<p className="italic text-ink-muted">{data.overall_assessment}</p>
|
<p className="italic text-ink-muted">{data.overall_assessment}</p>
|
||||||
|
|||||||
@@ -42,9 +42,26 @@ export type PipelineStats = {
|
|||||||
by_status: Record<string, number>; // raw counts, for the curious
|
by_status: Record<string, number>; // raw counts, for the curious
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** One claude.ai usage window (5-hour / weekly / weekly-per-model). */
|
||||||
|
export type UsageWindow = {
|
||||||
|
utilization: number | null; // 0-100; null when the window is inactive
|
||||||
|
resets_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** claude.ai subscription usage — the same %s the Claude Code status bar shows,
|
||||||
|
* via the (undocumented) OAuth usage endpoint proxied by the host bridge.
|
||||||
|
* null when the endpoint is unreachable. */
|
||||||
|
export type SubscriptionUsage = {
|
||||||
|
five_hour?: UsageWindow | null;
|
||||||
|
seven_day?: UsageWindow | null;
|
||||||
|
seven_day_opus?: UsageWindow | null;
|
||||||
|
seven_day_sonnet?: UsageWindow | null;
|
||||||
|
} | null;
|
||||||
|
|
||||||
export type OperationsSnapshot = {
|
export type OperationsSnapshot = {
|
||||||
services: OpsService[];
|
services: OpsService[];
|
||||||
services_error: string | null;
|
services_error: string | null;
|
||||||
|
subscription_usage: SubscriptionUsage;
|
||||||
pipelines: {
|
pipelines: {
|
||||||
court_fetch: PipelineStats & { recent: CourtFetchJob[] };
|
court_fetch: PipelineStats & { recent: CourtFetchJob[] };
|
||||||
metadata_extraction: PipelineStats;
|
metadata_extraction: PipelineStats;
|
||||||
|
|||||||
17
web/app.py
17
web/app.py
@@ -6525,6 +6525,21 @@ async def _ops_pm2_services() -> dict:
|
|||||||
return {"services": [], "error": f"לא ניתן להגיע לשירות-המארח: {e}"}
|
return {"services": [], "error": f"לא ניתן להגיע לשירות-המארח: {e}"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _ops_subscription_usage() -> dict | None:
|
||||||
|
"""Proxy the host court-fetch-service /usage — the claude.ai subscription
|
||||||
|
utilization % (5-hour / weekly). Host-only (the OAuth token lives on the
|
||||||
|
host). Returns the endpoint JSON, or None if unavailable (undocumented
|
||||||
|
endpoint — the dashboard shows nothing when this is None)."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=18.0) as client:
|
||||||
|
r = await client.get(f"{_COURT_FETCH_SERVICE_URL}/usage")
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _ops_pm2_control(name: str, action: str) -> dict:
|
async def _ops_pm2_control(name: str, action: str) -> dict:
|
||||||
"""Proxy a mutating pm2 action to the host bridge (Bearer-authenticated)."""
|
"""Proxy a mutating pm2 action to the host bridge (Bearer-authenticated)."""
|
||||||
secret = os.environ.get("COURT_FETCH_SHARED_SECRET", "").strip()
|
secret = os.environ.get("COURT_FETCH_SHARED_SECRET", "").strip()
|
||||||
@@ -6636,6 +6651,7 @@ async def operations_snapshot():
|
|||||||
]
|
]
|
||||||
|
|
||||||
pm2 = await _ops_pm2_services()
|
pm2 = await _ops_pm2_services()
|
||||||
|
subscription_usage = await _ops_subscription_usage()
|
||||||
controls = await db.get_drain_controls()
|
controls = await db.get_drain_controls()
|
||||||
bursts = await db.get_drain_bursts()
|
bursts = await db.get_drain_bursts()
|
||||||
for svc in pm2["services"]:
|
for svc in pm2["services"]:
|
||||||
@@ -6652,6 +6668,7 @@ async def operations_snapshot():
|
|||||||
return {
|
return {
|
||||||
"services": pm2["services"],
|
"services": pm2["services"],
|
||||||
"services_error": pm2["error"],
|
"services_error": pm2["error"],
|
||||||
|
"subscription_usage": subscription_usage,
|
||||||
"pipelines": {
|
"pipelines": {
|
||||||
"court_fetch": {
|
"court_fetch": {
|
||||||
**_norm_pipeline(
|
**_norm_pipeline(
|
||||||
|
|||||||
Reference in New Issue
Block a user