feat: Claude subscription usage via OCPlatform gateway + System Monitor redesign
All checks were successful
Build & Deploy Dashboard / deploy (push) Successful in 1m17s

This commit is contained in:
Cosmo
2026-04-16 09:34:20 +00:00
parent 490149b669
commit 35f6456363
3 changed files with 287 additions and 55 deletions

View File

@@ -0,0 +1,119 @@
export const dynamic = "force-dynamic";
import { NextResponse } from "next/server";
const GATEWAY_URL = "ws://192.168.31.103:18789";
const GATEWAY_TOKEN = "c55292f854e8308c4fed926c40c3a8995a7213fde79fed72";
async function gatewayRequest(method: string, params: object = {}): Promise<unknown> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close();
reject(new Error("Gateway timeout"));
}, 8000);
const ws = new (require("ws"))(`${GATEWAY_URL}`);
let connected = false;
let reqId = 1;
ws.on("open", () => {});
ws.on("message", (data: Buffer) => {
try {
const msg = JSON.parse(data.toString());
// Auth challenge
if (msg.type === "event" && msg.event === "connect.challenge") {
ws.send(JSON.stringify({
type: "request",
id: String(reqId++),
method: "connect",
params: { token: GATEWAY_TOKEN },
}));
return;
}
// Connect response
if (msg.type === "response" && !connected) {
if (msg.ok) {
connected = true;
// Send actual request
ws.send(JSON.stringify({
type: "request",
id: String(reqId++),
method,
params,
}));
} else {
clearTimeout(timeout);
ws.close();
reject(new Error("Auth failed"));
}
return;
}
// Method response
if (msg.type === "response" && connected) {
clearTimeout(timeout);
ws.close();
if (msg.ok) {
resolve(msg.payload);
} else {
reject(new Error(msg.error || "Request failed"));
}
}
} catch {}
});
ws.on("error", (err: Error) => {
clearTimeout(timeout);
reject(err);
});
});
}
export async function GET() {
try {
const [statusResult, costResult] = await Promise.allSettled([
gatewayRequest("usage.status", {}),
gatewayRequest("usage.cost", {}),
]);
const status = statusResult.status === "fulfilled" ? statusResult.value : null;
const cost = costResult.status === "fulfilled" ? costResult.value : null;
// Извлечь данные Anthropic
const anthropic = (status as any)?.providers?.find((p: any) => p.provider === "anthropic");
const windows = anthropic?.windows || [];
const window5h = windows.find((w: any) => w.label?.includes("5") || w.label === "5h");
const windowWeek = windows.find((w: any) => w.label?.toLowerCase().includes("week") || w.label === "Week");
const windowSonnet = windows.find((w: any) => w.label?.toLowerCase().includes("sonnet"));
const todayCost = (cost as any)?.daily?.[0]?.totalCost || 0;
const todayTokens = (cost as any)?.daily?.[0]?.totalTokens || 0;
const totalCost = (cost as any)?.totals?.totalCost || 0;
return NextResponse.json({
ok: true,
anthropic: {
window5h: window5h || null,
windowWeek: windowWeek || null,
windowSonnet: windowSonnet || null,
error: anthropic?.error || null,
},
cost: {
today: todayCost,
todayTokens,
total: totalCost,
},
raw: { status, cost },
});
} catch (e) {
return NextResponse.json({
ok: false,
error: String(e),
anthropic: null,
cost: null,
});
}
}

View File

@@ -1,49 +1,160 @@
"use client";
import { RefreshCw, Loader2 } from "lucide-react";
import { useState } from "react";
import { useEffect, useState, useCallback } from "react";
import { RefreshCw } from "lucide-react";
export function ClaudeUsageWidget() {
const [refreshing, setRefreshing] = useState(false);
interface UsageWindow {
label: string;
usedPercent: number;
resetAt?: number;
}
const handleRefresh = () => {
setRefreshing(true);
setTimeout(() => setRefreshing(false), 1000);
};
interface ClaudeData {
ok: boolean;
anthropic: {
window5h: UsageWindow | null;
windowWeek: UsageWindow | null;
windowSonnet: UsageWindow | null;
error: string | null;
} | null;
cost: {
today: number;
todayTokens: number;
total: number;
} | null;
error?: string;
}
function formatTimeLeft(ms: number): string {
const mins = Math.round(ms / 60000);
if (mins < 60) return `${mins}м`;
return `${Math.round(ms / 3600000)}ч`;
}
function UsageBar({ pct, color }: { pct: number; color: string }) {
const p = Math.min(100, pct);
const barColor = p > 80 ? "#f43f5e" : p > 60 ? "#f59e0b" : color;
return (
<div className="card card-amber p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-amber-500/15 flex items-center justify-center">
<span className="text-sm"></span>
</div>
<div>
<div className="text-sm font-semibold text-white">Claude Sub</div>
<div className="text-[10px] text-slate-500">Usage</div>
</div>
</div>
<button onClick={handleRefresh} className="text-slate-600 hover:text-slate-300 transition-colors p-1 rounded-lg hover:bg-white/5">
{refreshing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
</button>
</div>
<div className="space-y-3">
{[
{ label: "Использовано", value: "—", color: "text-amber-400" },
{ label: "Лимит", value: "—", color: "text-white" },
{ label: "Сброс", value: "—", color: "text-white" },
].map((item) => (
<div key={item.label} className="flex justify-between items-center">
<span className="text-xs text-slate-500">{item.label}</span>
<span className={`text-xs font-medium ${item.color}`}>{item.value}</span>
</div>
))}
</div>
<div className="mt-4 flex items-center gap-2 text-xs text-slate-600">
<div className="w-1.5 h-1.5 rounded-full bg-amber-500/50" />
<span>Автополучение недоступно</span>
</div>
<div className="h-1.5 rounded-full w-full" style={{ background: "rgba(255,255,255,0.06)" }}>
<div
className="h-full rounded-full transition-all duration-700"
style={{ width: `${p}%`, background: barColor, boxShadow: `0 0 6px ${barColor}60` }}
/>
</div>
);
}
export function ClaudeUsageWidget() {
const [data, setData] = useState<ClaudeData | null>(null);
const [loading, setLoading] = useState(true);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/claude-sub", { cache: "no-store" });
const json = await res.json();
setData(json);
} catch {
setData({ ok: false, anthropic: null, cost: null, error: "Fetch failed" });
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchData(); }, [fetchData]);
const a = data?.anthropic;
const cost = data?.cost;
return (
<div className="card p-5 space-y-3" style={{ borderTop: "2px solid #8b5cf6" }}>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-lg flex items-center justify-center text-sm" style={{ background: "rgba(139,92,246,0.15)" }}></div>
<span className="text-sm font-medium text-slate-300">Claude Subscription</span>
</div>
<button onClick={fetchData} className="text-slate-600 hover:text-slate-300 transition-colors">
<RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
</button>
</div>
{loading ? (
<div className="space-y-2 animate-pulse">
{[1,2,3].map(i => <div key={i} className="h-8 bg-white/5 rounded-lg" />)}
</div>
) : !data?.ok || !a ? (
<div className="text-xs text-slate-600 py-2 text-center">
{data?.error || "Нет данных"}<br/>
<span className="text-slate-700">Gateway недоступен</span>
</div>
) : (
<>
{/* Today cost */}
{cost && (
<div className="flex justify-between items-center py-1.5 px-2 rounded-lg" style={{ background: "rgba(255,255,255,0.03)" }}>
<span className="text-xs text-slate-500">Сегодня</span>
<div className="text-right">
<span className="text-sm font-semibold text-white">${cost.today.toFixed(2)}</span>
<span className="text-xs text-slate-600 ml-1">/ {(cost.todayTokens / 1000).toFixed(0)}k токенов</span>
</div>
</div>
)}
{/* Error from anthropic */}
{a.error && (
<div className="text-xs text-amber-500/70 bg-amber-500/10 rounded-lg px-2 py-1.5">
{a.error}
</div>
)}
{/* 5h window */}
{a.window5h && (
<div className="space-y-1">
<div className="flex justify-between text-xs">
<span className="text-slate-400">Сессия (5ч)</span>
<div className="flex items-center gap-1.5">
<span className="text-slate-300 font-medium">{a.window5h.usedPercent}%</span>
{a.window5h.resetAt && (
<span className="text-slate-600"> {formatTimeLeft(a.window5h.resetAt - Date.now())}</span>
)}
</div>
</div>
<UsageBar pct={a.window5h.usedPercent} color="#8b5cf6" />
</div>
)}
{/* Week window */}
{a.windowWeek && (
<div className="space-y-1">
<div className="flex justify-between text-xs">
<span className="text-slate-400">Неделя</span>
<div className="flex items-center gap-1.5">
<span className="text-slate-300 font-medium">{a.windowWeek.usedPercent}%</span>
{a.windowWeek.resetAt && (
<span className="text-slate-600"> {formatTimeLeft(a.windowWeek.resetAt - Date.now())}</span>
)}
</div>
</div>
<UsageBar pct={a.windowWeek.usedPercent} color="#6366f1" />
</div>
)}
{/* Sonnet window */}
{a.windowSonnet && (
<div className="space-y-1">
<div className="flex justify-between text-xs">
<span className="text-slate-400">Sonnet</span>
<span className="text-slate-300 font-medium">{a.windowSonnet.usedPercent}%</span>
</div>
<UsageBar pct={a.windowSonnet.usedPercent} color="#10b981" />
</div>
)}
{!a.window5h && !a.windowWeek && !a.windowSonnet && (
<div className="text-xs text-slate-600 text-center py-2">Данные о лимитах недоступны</div>
)}
</>
)}
</div>
);
}