diff --git a/core/http/react-ui/src/pages/Usage.jsx b/core/http/react-ui/src/pages/Usage.jsx index f63ca8a6f..ea0f042c4 100644 --- a/core/http/react-ui/src/pages/Usage.jsx +++ b/core/http/react-ui/src/pages/Usage.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from 'react' +import { useState, useEffect, useCallback, useRef, Fragment } from 'react' import { useOutletContext } from 'react-router-dom' import { useAuth } from '../context/AuthContext' import { apiUrl } from '../utils/basePath' @@ -11,6 +11,9 @@ const PERIODS = [ { key: 'all', label: 'All' }, ] +const TOTAL_BUCKETS = { day: 24, week: 7, month: 30 } +const HOURS_PER_BUCKET = { day: 1, week: 24, month: 24, all: 730 } + function formatNumber(n) { if (n == null) return '0' if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M' @@ -18,15 +21,15 @@ function formatNumber(n) { return String(n) } -function StatCard({ icon, label, value }) { +function StatCard({ icon, label, value, muted }) { return ( -
+
{label}
-
- {formatNumber(value)} +
+ {muted ? '~' : ''}{formatNumber(value)}
) @@ -94,6 +97,20 @@ function aggregateByBucket(buckets) { return Object.values(map).sort((a, b) => a.bucket.localeCompare(b.bucket)) } +function aggregateByBucketForUser(buckets, userId) { + return aggregateByBucket(buckets.filter(b => b.user_id === userId)) +} + +function generateUserPredictions(adminUsage, userRows, period) { + const result = {} + for (const u of userRows) { + const ts = aggregateByBucketForUser(adminUsage, u.user_id) + const preds = generatePredictions(ts, period) + result[u.user_id] = { timeSeries: ts, predictions: preds } + } + return result +} + function formatBucket(bucket, period) { if (!bucket) return '' if (period === 'day') { @@ -118,7 +135,243 @@ function formatYLabel(n) { return String(n) } -function UsageTimeChart({ data, period }) { +// --- Prediction helpers --- + +function linearRegression(values) { + const n = values.length + if (n < 2) return null + let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0 + for (let i = 0; i < n; i++) { + sumX += i + sumY += values[i] + sumXY += i * values[i] + sumX2 += i * i + } + const denom = n * sumX2 - sumX * sumX + if (denom === 0) return { slope: 0, intercept: sumY / n } + const slope = (n * sumXY - sumX * sumY) / denom + const intercept = (sumY - slope * sumX) / n + return { slope, intercept } +} + +function generateFutureBucketLabels(lastBucket, count, period) { + const labels = [] + if (period === 'day') { + // lastBucket like "2026-03-21 14:00" + const parts = lastBucket.split(' ') + const datePart = parts[0] || '' + const hourStr = (parts[1] || '00:00').split(':')[0] + let hour = parseInt(hourStr, 10) + for (let i = 0; i < count; i++) { + hour++ + if (hour >= 24) hour = 0 + labels.push(`${datePart} ${String(hour).padStart(2, '0')}:00`) + } + } else if (period === 'week' || period === 'month') { + // lastBucket like "2026-03-21" + const d = new Date(lastBucket + 'T00:00:00') + for (let i = 0; i < count; i++) { + d.setDate(d.getDate() + 1) + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + labels.push(`${y}-${m}-${day}`) + } + } else { + // all: lastBucket like "2026-03" + const [y, m] = lastBucket.split('-').map(Number) + let year = y, month = m + for (let i = 0; i < count; i++) { + month++ + if (month > 12) { month = 1; year++ } + labels.push(`${year}-${String(month).padStart(2, '0')}`) + } + } + return labels +} + +function generatePredictions(timeSeries, period) { + if (!timeSeries || timeSeries.length < 2) return null + + const n = timeSeries.length + const totalBuckets = TOTAL_BUCKETS[period] + const remaining = totalBuckets ? Math.max(totalBuckets - n, 0) : 3 // 'all' gets 3 extra months + if (remaining === 0) return null + + const metrics = ['prompt_tokens', 'completion_tokens', 'total_tokens', 'request_count'] + const regressions = {} + for (const m of metrics) { + regressions[m] = linearRegression(timeSeries.map(d => d[m])) + } + + const lastBucket = timeSeries[n - 1].bucket + const futureLabels = generateFutureBucketLabels(lastBucket, remaining, period) + + const predictedBuckets = futureLabels.map((label, i) => { + const idx = n + i + const entry = { bucket: label, predicted: true } + for (const m of metrics) { + const reg = regressions[m] + entry[m] = reg ? Math.max(0, Math.round(reg.intercept + reg.slope * idx)) : 0 + } + return entry + }) + + const existingTotals = { + prompt_tokens: timeSeries.reduce((s, d) => s + d.prompt_tokens, 0), + completion_tokens: timeSeries.reduce((s, d) => s + d.completion_tokens, 0), + total_tokens: timeSeries.reduce((s, d) => s + d.total_tokens, 0), + request_count: timeSeries.reduce((s, d) => s + d.request_count, 0), + } + const projectedTotals = { ...existingTotals } + for (const b of predictedBuckets) { + for (const m of metrics) { + projectedTotals[m] += b[m] + } + } + + return { predictedBuckets, projectedTotals } +} + +function formatDuration(hours) { + if (!isFinite(hours) || hours < 0) return 'N/A' + if (hours < 1) return '< 1 hour' + if (hours < 48) return `~${Math.round(hours)} hours` + const days = Math.round(hours / 24) + if (days < 60) return `~${days} days` + return `~${Math.round(days / 30)} months` +} + +function computeQuotaExhaustion(quotas, timeSeries, period) { + if (!quotas?.length || !timeSeries?.length) return [] + + const totalTokens = timeSeries.reduce((s, b) => s + b.total_tokens, 0) + const totalRequests = timeSeries.reduce((s, b) => s + b.request_count, 0) + const bucketCount = timeSeries.length + const hpb = HOURS_PER_BUCKET[period] || 24 + const tokensPerHour = bucketCount > 0 ? (totalTokens / bucketCount) / hpb : 0 + const requestsPerHour = bucketCount > 0 ? (totalRequests / bucketCount) / hpb : 0 + + const results = [] + for (const q of quotas) { + const items = [] + + if (q.max_total_tokens != null) { + const remaining = q.max_total_tokens - (q.current_tokens || 0) + const hoursLeft = tokensPerHour > 0 ? remaining / tokensPerHour : Infinity + const resetsAt = q.resets_at ? new Date(q.resets_at) : null + const hoursUntilReset = resetsAt ? Math.max(0, (resetsAt - Date.now()) / 3600000) : Infinity + items.push({ + label: 'Tokens', + current: q.current_tokens || 0, + max: q.max_total_tokens, + hoursLeft: Math.min(hoursLeft, hoursUntilReset), + withinLimits: hoursLeft >= hoursUntilReset, + }) + } + + if (q.max_requests != null) { + const remaining = q.max_requests - (q.current_requests || 0) + const hoursLeft = requestsPerHour > 0 ? remaining / requestsPerHour : Infinity + const resetsAt = q.resets_at ? new Date(q.resets_at) : null + const hoursUntilReset = resetsAt ? Math.max(0, (resetsAt - Date.now()) / 3600000) : Infinity + items.push({ + label: 'Requests', + current: q.current_requests || 0, + max: q.max_requests, + hoursLeft: Math.min(hoursLeft, hoursUntilReset), + withinLimits: hoursLeft >= hoursUntilReset, + }) + } + + if (items.length > 0) { + results.push({ model: q.model || 'All models', window: q.window, items }) + } + } + return results +} + +// --- Components --- + +function PredictionCards({ predictions, quotaExhaustion, period }) { + if (!predictions) { + return ( +
+
+ + Not enough data to predict trends (need at least 2 data points) +
+
+ ) + } + + const { projectedTotals } = predictions + const periodLabel = period === 'all' ? '(next 3 months)' : `end of ${period}` + + return ( +
+
+
+ + + Projected {periodLabel} + + + based on linear trend + +
+
+ + + + +
+
+ + {quotaExhaustion.length > 0 && ( +
+
+ + Quota forecast +
+
+ {quotaExhaustion.map((q, qi) => ( +
+
+ {q.model} ({q.window} window) +
+ {q.items.map((item, ii) => ( +
+ + {item.label} + +
+ +
+ + {formatNumber(item.current)}/{formatNumber(item.max)} + + {item.withinLimits ? ( + + Within limits + + ) : ( + + {formatDuration(item.hoursLeft)} left + + )} +
+ ))} +
+ ))} +
+
+ )} +
+ ) +} + +function UsageTimeChart({ data, predictedData, period }) { const containerRef = useRef(null) const [width, setWidth] = useState(600) const [tooltip, setTooltip] = useState(null) @@ -136,14 +389,17 @@ function UsageTimeChart({ data, period }) { if (!data || data.length === 0) return null + const allData = predictedData ? [...data, ...predictedData] : data + const actualCount = data.length + const height = 200 const margin = { top: 16, right: 16, bottom: 40, left: 56 } const chartW = width - margin.left - margin.right const chartH = height - margin.top - margin.bottom - const maxVal = Math.max(...data.map(d => d.total_tokens), 1) - const barWidth = Math.max(Math.min(chartW / data.length - 2, 40), 4) - const barGap = (chartW - barWidth * data.length) / (data.length + 1) + const maxVal = Math.max(...allData.map(d => d.total_tokens), 1) + const barWidth = Math.max(Math.min(chartW / allData.length - 2, 40), 4) + const barGap = (chartW - barWidth * allData.length) / (allData.length + 1) // Y-axis ticks (4 ticks) const ticks = [0, 1, 2, 3, 4].map(i => Math.round(maxVal * i / 4)) @@ -155,6 +411,16 @@ function UsageTimeChart({ data, period }) {
Prompt Completion + {predictedData && predictedData.length > 0 && ( + + + Predicted + + )}
@@ -172,7 +438,7 @@ function UsageTimeChart({ data, period }) { ) })} - {/* Bars */} + {/* Actual bars */} {data.map((d, i) => { const x = barGap + i * (barWidth + barGap) const promptH = (d.prompt_tokens / maxVal) * chartH @@ -207,14 +473,73 @@ function UsageTimeChart({ data, period }) { ) })} + {/* Separator line between actual and predicted */} + {predictedData && predictedData.length > 0 && (() => { + const sepX = barGap + actualCount * (barWidth + barGap) - barGap / 2 + return ( + + ) + })()} + {/* Predicted bars */} + {predictedData && predictedData.map((d, i) => { + const idx = actualCount + i + const x = barGap + idx * (barWidth + barGap) + const promptH = (d.prompt_tokens / maxVal) * chartH + const compH = (d.completion_tokens / maxVal) * chartH + const totalH = promptH + compH + return ( + { + const rect = containerRef.current.getBoundingClientRect() + setTooltip({ + x: e.clientX - rect.left, + y: e.clientY - rect.top, + data: d, + predicted: true, + }) + }} + onMouseMove={(e) => { + const rect = containerRef.current.getBoundingClientRect() + setTooltip(prev => prev ? { + ...prev, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + } : null) + }} + onMouseLeave={() => setTooltip(null)} + style={{ cursor: 'default' }} + > + {/* Invisible hit area */} + + {/* Predicted bar outline */} + {totalH > 0 && ( + + )} + {/* Prompt fill (faded) */} + + {/* Completion fill (more faded) */} + + + ) + })} {/* X-axis labels */} - {data.map((d, i) => { + {allData.map((d, i) => { const x = barGap + i * (barWidth + barGap) + barWidth / 2 // Skip some labels if too many - const skip = data.length > 20 ? Math.ceil(data.length / 12) : 1 + const skip = allData.length > 20 ? Math.ceil(allData.length / 12) : 1 if (i % skip !== 0) return null return ( - + {formatBucket(d.bucket, period)} ) @@ -238,11 +563,14 @@ function UsageTimeChart({ data, period }) { boxShadow: 'var(--shadow-md)', whiteSpace: 'nowrap', }}> -
{formatBucket(tooltip.data.bucket, period)}
-
Prompt: {tooltip.data.prompt_tokens.toLocaleString()}
-
Completion: {tooltip.data.completion_tokens.toLocaleString()}
+
+ {tooltip.predicted && Predicted} + {formatBucket(tooltip.data.bucket, period)} +
+
Prompt: {tooltip.predicted ? '~' : ''}{tooltip.data.prompt_tokens.toLocaleString()}
+
Completion: {tooltip.predicted ? '~' : ''}{tooltip.data.completion_tokens.toLocaleString()}
- {tooltip.data.request_count} requests + {tooltip.predicted ? '~' : ''}{tooltip.data.request_count} requests
)} @@ -308,16 +636,27 @@ export default function Usage() { const [adminUsage, setAdminUsage] = useState([]) const [adminTotals, setAdminTotals] = useState({}) const [activeTab, setActiveTab] = useState('models') + const [quotas, setQuotas] = useState([]) + const [selectedUserId, setSelectedUserId] = useState(null) const fetchUsage = useCallback(async () => { setLoading(true) try { - const res = await fetch(apiUrl(`/api/auth/usage?period=${period}`)) + const usagePromise = fetch(apiUrl(`/api/auth/usage?period=${period}`)) + const quotaPromise = fetch(apiUrl('/api/auth/quota')) + + const [res, quotaRes] = await Promise.all([usagePromise, quotaPromise]) + if (!res.ok) throw new Error(`HTTP ${res.status}`) const data = await res.json() setUsage(data.usage || []) setTotals(data.totals || {}) + if (quotaRes.ok) { + const quotaData = await quotaRes.json() + setQuotas(quotaData.quotas || []) + } + if (isAdmin) { const adminRes = await fetch(apiUrl(`/api/auth/admin/usage?period=${period}`)) if (adminRes.ok) { @@ -359,6 +698,10 @@ export default function Usage() { const displayUsage = isAdmin ? adminUsage : usage const timeSeries = aggregateByBucket(displayUsage) + const predictions = generatePredictions(timeSeries, period) + const quotaExhaustion = computeQuotaExhaustion(quotas, timeSeries, period) + const userPredictions = isAdmin && userRows.length > 0 ? generateUserPredictions(adminUsage, userRows, period) : {} + const monoCell = { fontFamily: 'JetBrains Mono, monospace', fontSize: '0.8125rem' } return ( @@ -416,8 +759,13 @@ export default function Usage() {
+ {/* Predictions */} + {timeSeries.length > 0 && ( + + )} + {/* Charts */} - + {activeTab === 'models' && } {/* Table */} @@ -470,25 +818,65 @@ export default function Usage() { + + - {userRows.map(row => ( - - - - - - - - - ))} + {userRows.map(row => { + const up = userPredictions[row.user_id] + const isExpanded = selectedUserId === row.user_id + return ( + + setSelectedUserId(isExpanded ? null : row.user_id)} + style={{ cursor: 'pointer' }} + > + + + + + + + + + + {isExpanded && up && ( + + + + )} + + ) + })}
User Requests Prompt Completion TotalProj. Total
{row.user_name}{formatNumber(row.request_count)}{formatNumber(row.prompt_tokens)}{formatNumber(row.completion_tokens)}{formatNumber(row.total_tokens)}
+ + {row.user_name}{formatNumber(row.request_count)}{formatNumber(row.prompt_tokens)}{formatNumber(row.completion_tokens)}{formatNumber(row.total_tokens)} + {up?.predictions ? `~${formatNumber(up.predictions.projectedTotals.total_tokens)}` : '-'} +
+
+ {up.predictions && ( +
+ + + + +
+ )} + {up.timeSeries.length > 0 ? ( + + ) : ( +
+ No time series data for this user. +
+ )} +
+
diff --git a/core/http/react-ui/src/utils/api.js b/core/http/react-ui/src/utils/api.js index 0861183df..430f2f6ae 100644 --- a/core/http/react-ui/src/utils/api.js +++ b/core/http/react-ui/src/utils/api.js @@ -329,6 +329,7 @@ export const usageApi = { if (userId) url += `&user_id=${encodeURIComponent(userId)}` return fetchJSON(url) }, + getMyQuotas: () => fetchJSON('/api/auth/quota'), } // Admin Users API