feat: Claude subscription usage via OCPlatform gateway + System Monitor redesign
All checks were successful
Build & Deploy Dashboard / deploy (push) Successful in 1m17s
All checks were successful
Build & Deploy Dashboard / deploy (push) Successful in 1m17s
This commit is contained in:
30
package.json
30
package.json
@@ -9,27 +9,29 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "14.2.3",
|
"@radix-ui/react-avatar": "^1.0.4",
|
||||||
"next-auth": "5.0.0-beta.19",
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
"react": "^18",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
"react-dom": "^18",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"typescript": "^5",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"tailwindcss": "^3.4.1",
|
"@types/ws": "^8.18.1",
|
||||||
"postcss": "^8",
|
|
||||||
"autoprefixer": "^10.0.1",
|
"autoprefixer": "^10.0.1",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"tailwind-merge": "^2.3.0",
|
|
||||||
"lucide-react": "^0.379.0",
|
"lucide-react": "^0.379.0",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"next": "14.2.3",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
"next-auth": "5.0.0-beta.19",
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
"postcss": "^8",
|
||||||
"@radix-ui/react-avatar": "^1.0.4",
|
"react": "^18",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"react-dom": "^18",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwind-merge": "^2.3.0",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"typescript": "^5",
|
||||||
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
|
|||||||
119
src/app/api/claude-sub/route.ts
Normal file
119
src/app/api/claude-sub/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,49 +1,160 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { RefreshCw, Loader2 } from "lucide-react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { useState } from "react";
|
import { RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
export function ClaudeUsageWidget() {
|
interface UsageWindow {
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
label: string;
|
||||||
|
usedPercent: number;
|
||||||
|
resetAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
const handleRefresh = () => {
|
interface ClaudeData {
|
||||||
setRefreshing(true);
|
ok: boolean;
|
||||||
setTimeout(() => setRefreshing(false), 1000);
|
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 (
|
return (
|
||||||
<div className="card card-amber p-5">
|
<div className="h-1.5 rounded-full w-full" style={{ background: "rgba(255,255,255,0.06)" }}>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div
|
||||||
<div className="flex items-center gap-2">
|
className="h-full rounded-full transition-all duration-700"
|
||||||
<div className="w-7 h-7 rounded-lg bg-amber-500/15 flex items-center justify-center">
|
style={{ width: `${p}%`, background: barColor, boxShadow: `0 0 6px ${barColor}60` }}
|
||||||
<span className="text-sm">✨</span>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
);
|
||||||
<div className="text-sm font-semibold text-white">Claude Sub</div>
|
}
|
||||||
<div className="text-[10px] text-slate-500">Usage</div>
|
|
||||||
</div>
|
export function ClaudeUsageWidget() {
|
||||||
</div>
|
const [data, setData] = useState<ClaudeData | null>(null);
|
||||||
<button onClick={handleRefresh} className="text-slate-600 hover:text-slate-300 transition-colors p-1 rounded-lg hover:bg-white/5">
|
const [loading, setLoading] = useState(true);
|
||||||
{refreshing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
|
||||||
</button>
|
const fetchData = useCallback(async () => {
|
||||||
</div>
|
setLoading(true);
|
||||||
|
try {
|
||||||
<div className="space-y-3">
|
const res = await fetch("/api/claude-sub", { cache: "no-store" });
|
||||||
{[
|
const json = await res.json();
|
||||||
{ label: "Использовано", value: "—", color: "text-amber-400" },
|
setData(json);
|
||||||
{ label: "Лимит", value: "—", color: "text-white" },
|
} catch {
|
||||||
{ label: "Сброс", value: "—", color: "text-white" },
|
setData({ ok: false, anthropic: null, cost: null, error: "Fetch failed" });
|
||||||
].map((item) => (
|
} finally {
|
||||||
<div key={item.label} className="flex justify-between items-center">
|
setLoading(false);
|
||||||
<span className="text-xs text-slate-500">{item.label}</span>
|
}
|
||||||
<span className={`text-xs font-medium ${item.color}`}>{item.value}</span>
|
}, []);
|
||||||
</div>
|
|
||||||
))}
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
</div>
|
|
||||||
|
const a = data?.anthropic;
|
||||||
<div className="mt-4 flex items-center gap-2 text-xs text-slate-600">
|
const cost = data?.cost;
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-500/50" />
|
|
||||||
<span>Автополучение недоступно</span>
|
return (
|
||||||
</div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user