From bfd16870d591f9fcf101c4bf0faddd82d29a292d Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Wed, 20 May 2026 23:19:47 +0000 Subject: [PATCH] feat(ui): source mix ribbon + searchable/sortable sources table Replaces the SourcesTab placeholder rendering with two reusable components: SourceMixRibbon (one segmented bar per source class) and SourcesTable (search + sort + revoked-key dim). Pulls the current API key list to detect revoked keys. Refs: #9862 Signed-off-by: Ettore Di Giacinto --- .../src/pages/Usage/SourceMixRibbon.jsx | 83 ++++++++ .../react-ui/src/pages/Usage/SourcesTab.jsx | 95 +++------ .../react-ui/src/pages/Usage/SourcesTable.jsx | 200 ++++++++++++++++++ 3 files changed, 316 insertions(+), 62 deletions(-) create mode 100644 core/http/react-ui/src/pages/Usage/SourceMixRibbon.jsx create mode 100644 core/http/react-ui/src/pages/Usage/SourcesTable.jsx diff --git a/core/http/react-ui/src/pages/Usage/SourceMixRibbon.jsx b/core/http/react-ui/src/pages/Usage/SourceMixRibbon.jsx new file mode 100644 index 000000000..007f54939 --- /dev/null +++ b/core/http/react-ui/src/pages/Usage/SourceMixRibbon.jsx @@ -0,0 +1,83 @@ +import { useTranslation } from 'react-i18next' + +const SEGMENT_COLORS = { + apikey: 'var(--color-primary)', + web: 'var(--color-info, #3b82f6)', + legacy: 'var(--color-warning, #f59e0b)', +} + +// SourceMixRibbon renders one segmented horizontal bar showing the share of +// tokens by source class (apikey / web / legacy). Clicking a segment invokes +// onSelectSourceClass with the segment key so the parent can filter the view. +// +// Props: +// bySource: { apikey?: {tokens, requests}, web?: {...}, legacy?: {...} } +// keyCount: number of distinct API keys in the dataset (for the legend) +// onSelectSourceClass: (cls: 'apikey'|'web'|'legacy') => void (optional) +export default function SourceMixRibbon({ bySource = {}, keyCount = 0, onSelectSourceClass }) { + const { t } = useTranslation('admin') + + const apikey = (bySource.apikey?.tokens) || 0 + const web = (bySource.web?.tokens) || 0 + const legacy = (bySource.legacy?.tokens) || 0 + const total = apikey + web + legacy || 1 + + const pct = (n) => Math.round((n / total) * 100) + const apiPct = pct(apikey) + const webPct = pct(web) + const legacyPct = pct(legacy) + + const segments = [ + { key: 'apikey', label: `${apiPct}% API keys (${keyCount})`, pct: apiPct, color: SEGMENT_COLORS.apikey }, + { key: 'web', label: `${webPct}% ${t('usage.sources.webUI')}`, pct: webPct, color: SEGMENT_COLORS.web }, + { key: 'legacy', label: `${legacyPct}% ${t('usage.sources.legacy')}`, pct: legacyPct, color: SEGMENT_COLORS.legacy }, + ].filter((s) => s.pct > 0) + + return ( +
+
+ {t('usage.sources.mixTitle')} +
+
+ {segments.map((s) => ( +
+
+ {segments.map((s) => ( + + + {s.label} + + ))} +
+
+ ) +} diff --git a/core/http/react-ui/src/pages/Usage/SourcesTab.jsx b/core/http/react-ui/src/pages/Usage/SourcesTab.jsx index 4cc71849e..7682ce5d9 100644 --- a/core/http/react-ui/src/pages/Usage/SourcesTab.jsx +++ b/core/http/react-ui/src/pages/Usage/SourcesTab.jsx @@ -1,8 +1,10 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { usageApi } from '../../utils/api' +import { usageApi, apiKeysApi } from '../../utils/api' import { useAuth } from '../../context/AuthContext' import LoadingSpinner from '../../components/LoadingSpinner' +import SourceMixRibbon from './SourceMixRibbon' +import SourcesTable from './SourcesTable' const EMPTY_DATA = { buckets: [], @@ -11,9 +13,8 @@ const EMPTY_DATA = { } // SourcesTab fetches and renders per-source / per-API-key usage breakdown. -// Task 9 ships a minimal skeleton (raw totals + key list) so the data path is -// exercised end to end. Tasks 10 and 11 replace the placeholders with the -// SourceMixRibbon, SourceTimeChart and SourcesTable visualisations. +// Task 10 replaces the raw JSON / list placeholders with SourceMixRibbon and +// SourcesTable. Task 11 will add the time chart and drill-in chip. export default function SourcesTab({ period, adminUserId }) { const { t } = useTranslation('admin') const { isAdmin } = useAuth() @@ -22,13 +23,23 @@ export default function SourcesTab({ period, adminUserId }) { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) - // State held now even though Tasks 10/11 will use it visually. const [selectedKey, setSelectedKey] = useState(null) - // eslint-disable-next-line no-unused-vars const [search, setSearch] = useState('') - // eslint-disable-next-line no-unused-vars const [sortKey, setSortKey] = useState('tokens') + // Pull the current set of API key ids so the table can mark unknown keys as + // revoked. Failure is non-fatal: the revoked badge just won't render. + const [existingKeyIds, setExistingKeyIds] = useState(new Set()) + useEffect(() => { + apiKeysApi + .list() + .then((resp) => { + const list = Array.isArray(resp) ? resp : (resp?.keys || []) + setExistingKeyIds(new Set(list.map((k) => k.id))) + }) + .catch(() => { /* revoked detection is best-effort */ }) + }, []) + useEffect(() => { let cancelled = false setLoading(true) @@ -77,67 +88,27 @@ export default function SourcesTab({ period, adminUserId }) { ) } - // Skeleton placeholders: Tasks 10 and 11 replace these with SourceMixRibbon, - // SourceTimeChart, and SourcesTable. return (
-
{t('usage.sources.mixTitle')}
-
{JSON.stringify(totals.by_source, null, 2)}
+ setSelectedKey(cls)} + />
-
{t('usage.sources.topSources')}
-
    - {(totals.by_key || []).map((k) => { - const isSelected = selectedKey === k.api_key_id - return ( -
  • setSelectedKey(isSelected ? null : k.api_key_id)} - style={{ - padding: 'var(--spacing-xs) var(--spacing-sm)', - cursor: 'pointer', - fontWeight: isSelected ? 600 : 400, - fontSize: '0.8125rem', - borderRadius: 'var(--radius-sm)', - background: isSelected ? 'var(--color-bg-secondary)' : 'transparent', - display: 'flex', - alignItems: 'center', - gap: 6, - }} - > - - {k.api_key_name || k.api_key_id} - - {Number(k.tokens || 0).toLocaleString()} - -
  • - ) - })} -
+
{truncated && ( diff --git a/core/http/react-ui/src/pages/Usage/SourcesTable.jsx b/core/http/react-ui/src/pages/Usage/SourcesTable.jsx new file mode 100644 index 000000000..23fb9f1c6 --- /dev/null +++ b/core/http/react-ui/src/pages/Usage/SourcesTable.jsx @@ -0,0 +1,200 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +const SORT_FNS = { + tokens: (a, b) => (b.tokens || 0) - (a.tokens || 0), + requests: (a, b) => (b.requests || 0) - (a.requests || 0), + last_used: (a, b) => new Date(b.last_used || 0).getTime() - new Date(a.last_used || 0).getTime(), + name: (a, b) => (a.name || '').localeCompare(b.name || ''), +} + +function formatTokens(n) { + if (!n) return '0' + if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M' + if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k' + return String(n) +} + +function formatRelative(iso) { + if (!iso) return '-' + const t = new Date(iso).getTime() + if (Number.isNaN(t) || t <= 0) return '-' + const diff = Date.now() - t + if (diff < 60_000) return 'just now' + if (diff < 3_600_000) return Math.round(diff / 60_000) + 'm ago' + if (diff < 86_400_000) return Math.round(diff / 3_600_000) + 'h ago' + return Math.round(diff / 86_400_000) + 'd ago' +} + +// SourcesTable is the searchable, sortable list of key totals plus pseudo-rows +// for the web UI and legacy (unkeyed) source classes. Clicking a row selects +// it; the parent decides what to do with the selection (the drill-in panel +// will be wired in Task 11). +// +// Props: +// totals: SourceTotals payload (from /api/auth/usage/sources) +// selectedKey: currently-selected row id (api_key_id | 'web' | 'legacy' | null) +// onSelectKey: (id|null) => void +// search / setSearch: free-text filter state lifted to the parent +// sortKey / setSortKey: sort column state lifted to the parent +// existingKeyIds: Set of current (non-revoked) api key ids +export default function SourcesTable({ + totals, + selectedKey, + onSelectKey, + search, + setSearch, + sortKey, + setSortKey, + existingKeyIds = new Set(), +}) { + const { t } = useTranslation('admin') + + const rows = useMemo(() => { + const named = (totals?.by_key || []).map((k) => ({ + kind: 'apikey', + id: k.api_key_id, + name: k.api_key_name || k.api_key_id, + prefix: '', + tokens: k.tokens, + requests: k.requests, + last_used: k.last_used, + revoked: !existingKeyIds.has(k.api_key_id), + })) + const web = totals?.by_source?.web + ? [{ + kind: 'web', + id: 'web', + name: t('usage.sources.webUI'), + prefix: '-', + tokens: totals.by_source.web.tokens, + requests: totals.by_source.web.requests, + }] + : [] + const leg = totals?.by_source?.legacy + ? [{ + kind: 'legacy', + id: 'legacy', + name: t('usage.sources.legacy'), + prefix: '-', + tokens: totals.by_source.legacy.tokens, + requests: totals.by_source.legacy.requests, + }] + : [] + return [...named, ...web, ...leg] + }, [totals, existingKeyIds, t]) + + const filtered = useMemo(() => { + const q = (search || '').trim().toLowerCase() + const list = q + ? rows.filter((r) => (r.name || '').toLowerCase().includes(q) || (r.prefix || '').toLowerCase().includes(q)) + : rows + return [...list].sort(SORT_FNS[sortKey] || SORT_FNS.tokens) + }, [rows, search, sortKey]) + + const iconFor = (kind) => + kind === 'apikey' ? 'fas fa-key' : kind === 'web' ? 'fas fa-globe' : 'fas fa-gear' + + return ( +
+
+ setSearch(e.target.value)} + placeholder={t('usage.sources.searchPlaceholder')} + aria-label={t('usage.sources.searchPlaceholder')} + style={{ + flex: '1 1 12rem', + minWidth: 160, + padding: 'var(--spacing-xs) var(--spacing-sm)', + border: '1px solid var(--color-border-subtle)', + borderRadius: 'var(--radius-sm)', + background: 'var(--color-bg-primary)', + color: 'var(--color-text-primary)', + }} + /> + +
+ +
+ + + + + + + + + + + + {filtered.map((r) => { + const isSel = selectedKey === r.id + return ( + onSelectKey?.(isSel ? null : r.id)} + style={{ + cursor: 'pointer', + background: isSel ? 'var(--color-bg-secondary)' : undefined, + opacity: r.revoked ? 0.5 : 1, + }} + > + + + + + + + ) + })} + +
{t('usage.sources.sortName')}Prefix{t('usage.sources.sortRequests')}{t('usage.sources.sortTokens')}{t('usage.sources.sortLastUsed')}
+ + + {r.name} + {r.revoked && ( + + ({t('usage.sources.revoked')}) + + )} + + {r.prefix || '-'} + {Number(r.requests || 0).toLocaleString()} + + {formatTokens(r.tokens || 0)} + + {formatRelative(r.last_used)} +
+
+
+ ) +}