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"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.3",
|
||||
"next-auth": "5.0.0-beta.19",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"typescript": "^5",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"postcss": "^8",
|
||||
"@types/ws": "^8.18.1",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"lucide-react": "^0.379.0",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
"next": "14.2.3",
|
||||
"next-auth": "5.0.0-beta.19",
|
||||
"postcss": "^8",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"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";
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user