Initial project scaffold
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type {
|
||||
BaseModelCatalogItem,
|
||||
CatalogProvider,
|
||||
IntegrationPlatform,
|
||||
PlatformModel,
|
||||
PricingRule,
|
||||
RateLimitWindow,
|
||||
} from '@easyai-ai-gateway/contracts';
|
||||
import {
|
||||
getHealth,
|
||||
listBaseModels,
|
||||
listCatalogProviders,
|
||||
listModels,
|
||||
listPlatforms,
|
||||
listPricingRules,
|
||||
listRateLimitWindows,
|
||||
type HealthResponse,
|
||||
} from './api';
|
||||
|
||||
type LoadState = 'idle' | 'loading' | 'ready' | 'error';
|
||||
|
||||
export function App() {
|
||||
const [token, setToken] = useState('');
|
||||
const [health, setHealth] = useState<HealthResponse | null>(null);
|
||||
const [platforms, setPlatforms] = useState<IntegrationPlatform[]>([]);
|
||||
const [models, setModels] = useState<PlatformModel[]>([]);
|
||||
const [providers, setProviders] = useState<CatalogProvider[]>([]);
|
||||
const [baseModels, setBaseModels] = useState<BaseModelCatalogItem[]>([]);
|
||||
const [pricingRules, setPricingRules] = useState<PricingRule[]>([]);
|
||||
const [rateLimitWindows, setRateLimitWindows] = useState<RateLimitWindow[]>([]);
|
||||
const [state, setState] = useState<LoadState>('idle');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getHealth()
|
||||
.then(setHealth)
|
||||
.catch((err: Error) => setError(err.message));
|
||||
}, []);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const enabledPlatforms = platforms.filter((item) => item.status === 'enabled').length;
|
||||
const enabledModels = models.filter((item) => item.enabled).length;
|
||||
const activeProviders = providers.filter((item) => item.status === 'active').length;
|
||||
const activeRateWindows = rateLimitWindows.filter((item) => item.resetAt >= new Date().toISOString()).length;
|
||||
return [
|
||||
{ label: '平台', value: platforms.length, tone: 'blue' },
|
||||
{ label: '启用平台', value: enabledPlatforms, tone: 'green' },
|
||||
{ label: '基准模型', value: baseModels.length, tone: 'violet' },
|
||||
{ label: 'Provider', value: activeProviders || providers.length || enabledModels, tone: 'amber' },
|
||||
{ label: '定价规则', value: pricingRules.length, tone: 'cyan' },
|
||||
{ label: '限流窗口', value: activeRateWindows, tone: 'rose' },
|
||||
];
|
||||
}, [baseModels.length, models, platforms, pricingRules.length, providers, rateLimitWindows]);
|
||||
|
||||
async function refresh() {
|
||||
setState('loading');
|
||||
setError('');
|
||||
try {
|
||||
const [
|
||||
platformResponse,
|
||||
modelResponse,
|
||||
providerResponse,
|
||||
baseModelResponse,
|
||||
pricingRuleResponse,
|
||||
rateLimitWindowResponse,
|
||||
] = await Promise.all([
|
||||
listPlatforms(token),
|
||||
listModels(token),
|
||||
listCatalogProviders(token),
|
||||
listBaseModels(token),
|
||||
listPricingRules(token),
|
||||
listRateLimitWindows(token),
|
||||
]);
|
||||
setPlatforms(platformResponse.items);
|
||||
setModels(modelResponse.items);
|
||||
setProviders(providerResponse.items);
|
||||
setBaseModels(baseModelResponse.items);
|
||||
setPricingRules(pricingRuleResponse.items);
|
||||
setRateLimitWindows(rateLimitWindowResponse.items);
|
||||
setState('ready');
|
||||
} catch (err) {
|
||||
setState('error');
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<p className="eyebrow">EasyAI</p>
|
||||
<h1>AI Gateway Console</h1>
|
||||
</div>
|
||||
<div className="health" data-ok={health?.ok === true}>
|
||||
<span />
|
||||
{health?.service ?? 'API 未连接'}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="toolbar" aria-label="授权与刷新">
|
||||
<label className="tokenField">
|
||||
<span>Server Main JWT</span>
|
||||
<input
|
||||
value={token}
|
||||
onChange={(event) => setToken(event.target.value)}
|
||||
placeholder="粘贴 server-main access_token"
|
||||
/>
|
||||
</label>
|
||||
<button type="button" onClick={refresh} disabled={!token || state === 'loading'}>
|
||||
{state === 'loading' ? '加载中' : '刷新'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{error && <div className="notice">{error}</div>}
|
||||
|
||||
<section className="metrics" aria-label="概览">
|
||||
{stats.map((item) => (
|
||||
<div className="metric" data-tone={item.tone} key={item.label}>
|
||||
<span>{item.label}</span>
|
||||
<strong>{item.value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="split">
|
||||
<div className="panel">
|
||||
<div className="panelHeader">
|
||||
<h2>平台</h2>
|
||||
<span>{platforms.length}</span>
|
||||
</div>
|
||||
<div className="table" role="table">
|
||||
<div className="row head" role="row">
|
||||
<span>Provider</span>
|
||||
<span>名称</span>
|
||||
<span>状态</span>
|
||||
<span>优先级</span>
|
||||
</div>
|
||||
{platforms.map((item) => (
|
||||
<div className="row" role="row" key={item.id}>
|
||||
<span>{item.provider}</span>
|
||||
<span>{item.name}</span>
|
||||
<span>{item.status}</span>
|
||||
<span>{item.priority}</span>
|
||||
</div>
|
||||
))}
|
||||
{!platforms.length && <p className="empty">暂无平台数据</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="panelHeader">
|
||||
<h2>模型</h2>
|
||||
<span>{models.length}</span>
|
||||
</div>
|
||||
<div className="table" role="table">
|
||||
<div className="row head" role="row">
|
||||
<span>模型</span>
|
||||
<span>类型</span>
|
||||
<span>平台</span>
|
||||
<span>启用</span>
|
||||
</div>
|
||||
{models.map((item) => (
|
||||
<div className="row" role="row" key={item.id}>
|
||||
<span>{item.modelName}</span>
|
||||
<span>{item.modelType}</span>
|
||||
<span>{item.provider ?? item.platformName}</span>
|
||||
<span>{item.enabled ? '是' : '否'}</span>
|
||||
</div>
|
||||
))}
|
||||
{!models.length && <p className="empty">暂无模型数据</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="split secondary">
|
||||
<div className="panel">
|
||||
<div className="panelHeader">
|
||||
<h2>基准模型库</h2>
|
||||
<span>{baseModels.length}</span>
|
||||
</div>
|
||||
<div className="table catalogTable" role="table">
|
||||
<div className="row head" role="row">
|
||||
<span>Provider</span>
|
||||
<span>模型</span>
|
||||
<span>类型</span>
|
||||
<span>版本</span>
|
||||
</div>
|
||||
{baseModels.map((item) => (
|
||||
<div className="row" role="row" key={item.id}>
|
||||
<span>{item.providerKey}</span>
|
||||
<span>{item.canonicalModelKey}</span>
|
||||
<span>{item.modelType}</span>
|
||||
<span>{item.pricingVersion}</span>
|
||||
</div>
|
||||
))}
|
||||
{!baseModels.length && <p className="empty">暂无基准模型</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="panelHeader">
|
||||
<h2>TPM/RPM 窗口</h2>
|
||||
<span>{rateLimitWindows.length}</span>
|
||||
</div>
|
||||
<div className="table rateTable" role="table">
|
||||
<div className="row head" role="row">
|
||||
<span>Scope</span>
|
||||
<span>指标</span>
|
||||
<span>使用</span>
|
||||
<span>预占</span>
|
||||
</div>
|
||||
{rateLimitWindows.map((item) => (
|
||||
<div className="row" role="row" key={`${item.scopeType}:${item.scopeKey}:${item.metric}:${item.windowStart}`}>
|
||||
<span>{item.scopeKey}</span>
|
||||
<span>{item.metric}</span>
|
||||
<span>{item.usedValue}/{item.limitValue}</span>
|
||||
<span>{item.reservedValue}</span>
|
||||
</div>
|
||||
))}
|
||||
{!rateLimitWindows.length && <p className="empty">暂无限流窗口</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type {
|
||||
BaseModelCatalogItem,
|
||||
CatalogProvider,
|
||||
IntegrationPlatform,
|
||||
ListResponse,
|
||||
PlatformModel,
|
||||
PricingRule,
|
||||
RateLimitWindow,
|
||||
} from '@easyai-ai-gateway/contracts';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088';
|
||||
|
||||
export interface HealthResponse {
|
||||
ok: boolean;
|
||||
service: string;
|
||||
env: string;
|
||||
}
|
||||
|
||||
export async function getHealth(): Promise<HealthResponse> {
|
||||
return request<HealthResponse>('/healthz', { auth: false });
|
||||
}
|
||||
|
||||
export async function listPlatforms(token: string): Promise<ListResponse<IntegrationPlatform>> {
|
||||
return request<ListResponse<IntegrationPlatform>>('/api/v1/platforms', { token });
|
||||
}
|
||||
|
||||
export async function listModels(token: string): Promise<ListResponse<PlatformModel>> {
|
||||
return request<ListResponse<PlatformModel>>('/api/v1/models', { token });
|
||||
}
|
||||
|
||||
export async function listCatalogProviders(token: string): Promise<ListResponse<CatalogProvider>> {
|
||||
return request<ListResponse<CatalogProvider>>('/api/v1/catalog/providers', { token });
|
||||
}
|
||||
|
||||
export async function listBaseModels(token: string): Promise<ListResponse<BaseModelCatalogItem>> {
|
||||
return request<ListResponse<BaseModelCatalogItem>>('/api/v1/catalog/base-models', { token });
|
||||
}
|
||||
|
||||
export async function listPricingRules(token: string): Promise<ListResponse<PricingRule>> {
|
||||
return request<ListResponse<PricingRule>>('/api/v1/pricing/rules', { token });
|
||||
}
|
||||
|
||||
export async function listRateLimitWindows(token: string): Promise<ListResponse<RateLimitWindow>> {
|
||||
return request<ListResponse<RateLimitWindow>>('/api/v1/runtime/rate-limit-windows', { token });
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: { token?: string; auth?: boolean } = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (options.auth !== false && options.token) {
|
||||
headers.Authorization = `Bearer ${options.token}`;
|
||||
}
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
headers,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(body || `Request failed: ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,286 @@
|
||||
:root {
|
||||
color: #172033;
|
||||
background: #f5f7fb;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.page {
|
||||
width: min(1180px, calc(100vw - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 28px 0 48px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 4px;
|
||||
color: #667085;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 30px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.health {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #667085;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.health span {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #c43f3f;
|
||||
}
|
||||
|
||||
.health[data-ok="true"] span {
|
||||
background: #1b8a5a;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
padding: 16px;
|
||||
margin-bottom: 18px;
|
||||
border: 1px solid #dde3ee;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.tokenField {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: #4a5568;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tokenField input {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
color: #172033;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tokenField input:focus {
|
||||
border-color: #2b6cb0;
|
||||
box-shadow: 0 0 0 3px rgba(43, 108, 176, 0.14);
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 42px;
|
||||
padding: 0 18px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: #214e8a;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.notice {
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 18px;
|
||||
border: 1px solid #f0b8b8;
|
||||
border-radius: 8px;
|
||||
background: #fff1f1;
|
||||
color: #9b2c2c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
min-height: 96px;
|
||||
padding: 16px;
|
||||
border: 1px solid #dde3ee;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.metric span {
|
||||
color: #667085;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.metric[data-tone="blue"] {
|
||||
border-top: 3px solid #2b6cb0;
|
||||
}
|
||||
|
||||
.metric[data-tone="green"] {
|
||||
border-top: 3px solid #1b8a5a;
|
||||
}
|
||||
|
||||
.metric[data-tone="violet"] {
|
||||
border-top: 3px solid #6b46c1;
|
||||
}
|
||||
|
||||
.metric[data-tone="amber"] {
|
||||
border-top: 3px solid #b7791f;
|
||||
}
|
||||
|
||||
.metric[data-tone="cyan"] {
|
||||
border-top: 3px solid #087f8c;
|
||||
}
|
||||
|
||||
.metric[data-tone="rose"] {
|
||||
border-top: 3px solid #b8325f;
|
||||
}
|
||||
|
||||
.split {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.split.secondary {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
overflow: hidden;
|
||||
border: 1px solid #dde3ee;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e7ecf4;
|
||||
}
|
||||
|
||||
.panelHeader span {
|
||||
color: #667085;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.table {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1.2fr 0.8fr 0.6fr;
|
||||
gap: 12px;
|
||||
min-height: 46px;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid #edf1f7;
|
||||
color: #2d3748;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.row span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row.head {
|
||||
min-height: 38px;
|
||||
background: #f8fafc;
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 22px 16px;
|
||||
color: #667085;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.topbar,
|
||||
.toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.metrics,
|
||||
.split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.row {
|
||||
grid-template-columns: 1fr 0.8fr;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 861px) and (max-width: 1180px) {
|
||||
.metrics {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user