Session 20: Provider intelligence — quota tracker, gateway with fallback cascade, admin quota dashboard (1476 tests)
This commit is contained in:
@@ -36,6 +36,18 @@ interface AdminStats {
|
||||
sports: Array<{ sport: string; status: 'ok' | 'error' | 'empty'; quota?: number | null; props?: number; error?: string }>;
|
||||
odds_quota_remaining: number | null;
|
||||
};
|
||||
provider_quotas?: Array<{
|
||||
provider: string;
|
||||
name?: string;
|
||||
used: number;
|
||||
limit: number;
|
||||
remaining: number;
|
||||
pct: number;
|
||||
period: string;
|
||||
quotaType: string;
|
||||
allowed: boolean;
|
||||
degraded?: boolean;
|
||||
}>;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
@@ -255,6 +267,62 @@ export default function AdminPage() {
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{/* Session 20 — Provider Quotas. Pulled from
|
||||
/api/internal/quota; rendered as a per-provider table with
|
||||
a usage bar + status indicator. When the array is empty,
|
||||
the section auto-hides (likely VYNDR_INTERNAL_KEY unset
|
||||
on the Next.js side — surfaced in the notes section). */}
|
||||
{!!stats?.provider_quotas?.length && (
|
||||
<section style={{ ...cellStyle, marginTop: 24 }}>
|
||||
<h2 style={{ fontSize: 14, fontWeight: 700, marginBottom: 14, letterSpacing: '-0.01em' }}>Provider quotas</h2>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', color: 'var(--text-tertiary, #6B6B7B)', fontSize: 10, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
<th style={{ padding: '6px 0' }}>Provider</th>
|
||||
<th style={{ padding: '6px 0' }}>Used</th>
|
||||
<th style={{ padding: '6px 0' }}>Type</th>
|
||||
<th style={{ padding: '6px 0', textAlign: 'right' }}>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.provider_quotas.map((p) => {
|
||||
const pctNum = Math.round((p.pct || 0) * 100);
|
||||
const color = !p.allowed
|
||||
? 'var(--grade-d, #FF6B6B)'
|
||||
: pctNum >= 80
|
||||
? 'var(--grade-c, #FFD93D)'
|
||||
: 'var(--grade-a, #00D4A0)';
|
||||
const indicator = !p.allowed
|
||||
? `❌ BLOCKED ${pctNum}%`
|
||||
: pctNum >= 80
|
||||
? `⚠️ ${pctNum}%`
|
||||
: `✅ ${pctNum}%`;
|
||||
return (
|
||||
<tr key={p.provider} style={{ borderBottom: '1px solid var(--border, #1A1A24)' }}>
|
||||
<td style={{ padding: '8px 0', color: 'var(--text-0, #F0F0F5)' }}>
|
||||
<div style={{ fontWeight: 600 }}>{p.name || p.provider}</div>
|
||||
<div className="mono" style={{ fontSize: 10, color: 'var(--text-tertiary, #6B6B7B)', marginTop: 2 }}>{p.period}</div>
|
||||
</td>
|
||||
<td className="mono" style={{ padding: '8px 0', color: 'var(--text-0, #F0F0F5)', fontVariantNumeric: 'tabular-nums' }}>
|
||||
<div>{p.used}/{p.limit}</div>
|
||||
<div style={{ marginTop: 4, background: 'var(--bg-2, #15151F)', height: 4, borderRadius: 2, overflow: 'hidden', width: 90 }}>
|
||||
<div style={{ width: `${Math.min(100, Math.max(2, pctNum))}%`, height: '100%', background: color }} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="mono" style={{ padding: '8px 0', color: 'var(--text-secondary, #8A8A9A)', fontSize: 11, textTransform: 'uppercase' }}>
|
||||
{p.quotaType}
|
||||
</td>
|
||||
<td className="mono" style={{ padding: '8px 0', textAlign: 'right', color, fontSize: 12 }}>
|
||||
{indicator}{p.degraded ? ' (degraded)' : ''}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!!stats?.notes?.length && (
|
||||
<section style={{ ...cellStyle, marginTop: 24, fontSize: 12, color: 'var(--grade-c, #FFD93D)' }}>
|
||||
<h2 style={{ fontSize: 12, fontWeight: 700, marginBottom: 8, letterSpacing: '-0.01em' }}>Query notes</h2>
|
||||
|
||||
@@ -48,6 +48,21 @@ interface AdminStats {
|
||||
sports: Array<{ sport: string; status: 'ok' | 'error' | 'empty'; quota?: number | null; props?: number; error?: string }>;
|
||||
odds_quota_remaining: number | null;
|
||||
};
|
||||
// Session 20 — per-provider quota snapshot. Pulled through the
|
||||
// internal /quota endpoint so the admin page sees the same view
|
||||
// the gateway makes routing decisions against.
|
||||
provider_quotas: Array<{
|
||||
provider: string;
|
||||
name?: string;
|
||||
used: number;
|
||||
limit: number;
|
||||
remaining: number;
|
||||
pct: number;
|
||||
period: string;
|
||||
quotaType: string;
|
||||
allowed: boolean;
|
||||
degraded?: boolean;
|
||||
}>;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
@@ -186,6 +201,40 @@ export async function GET(req: NextRequest): Promise<Response> {
|
||||
// same quota number for every sport since they share the account.
|
||||
const oddsQuotaRemaining = sports.map((s) => s.quota).find((q) => typeof q === 'number') ?? null;
|
||||
|
||||
// Session 20 — fetch the per-provider quota snapshot from the
|
||||
// backend's internal endpoint. Best-effort: a failure to reach
|
||||
// the backend or a missing internal key leaves provider_quotas
|
||||
// empty and surfaces a note instead of blanking the dashboard.
|
||||
const internalKey = process.env.VYNDR_INTERNAL_KEY || '';
|
||||
let providerQuotas: AdminStats['provider_quotas'] = [];
|
||||
if (internalKey) {
|
||||
try {
|
||||
const quotaController = new AbortController();
|
||||
const quotaTimer = setTimeout(() => quotaController.abort(), HEALTH_PROBE_TIMEOUT_MS);
|
||||
try {
|
||||
const quotaRes = await fetch(`${BACKEND_URL}/api/internal/quota`, {
|
||||
signal: quotaController.signal,
|
||||
headers: { 'x-internal-key': internalKey, Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (quotaRes.ok) {
|
||||
const quotaBody = (await quotaRes.json().catch(() => null)) as { providers?: AdminStats['provider_quotas'] } | null;
|
||||
if (quotaBody && Array.isArray(quotaBody.providers)) {
|
||||
providerQuotas = quotaBody.providers;
|
||||
}
|
||||
} else {
|
||||
notes.push(`quota fetch returned ${quotaRes.status}`);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(quotaTimer);
|
||||
}
|
||||
} catch (err) {
|
||||
notes.push(`quota fetch failed: ${err instanceof Error ? err.message : 'unknown'}`);
|
||||
}
|
||||
} else {
|
||||
notes.push('VYNDR_INTERNAL_KEY unset — provider quotas hidden');
|
||||
}
|
||||
|
||||
const payload: AdminStats = {
|
||||
generated_at: new Date().toISOString(),
|
||||
users: {
|
||||
@@ -201,6 +250,7 @@ export async function GET(req: NextRequest): Promise<Response> {
|
||||
sports,
|
||||
odds_quota_remaining: oddsQuotaRemaining,
|
||||
},
|
||||
provider_quotas: providerQuotas,
|
||||
notes,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user