From f0637da946a70d994230c0a9a538a1e98e2c0f64 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Wed, 20 May 2026 23:14:02 +0000 Subject: [PATCH] feat(ui): add Sources tab skeleton with data fetch Adds Usage page tab that fetches /api/auth/usage/sources (or the admin variant). Renders raw totals plus a placeholder key list; real visualisations land in subsequent commits. Restructures the existing tab button block so Models and Sources are visible to non-admins (Users remains admin-only). Refs: #9862 Signed-off-by: Ettore Di Giacinto --- core/http/react-ui/src/pages/Usage.jsx | 39 +++-- .../react-ui/src/pages/Usage/SourcesTab.jsx | 150 ++++++++++++++++++ 2 files changed, 174 insertions(+), 15 deletions(-) create mode 100644 core/http/react-ui/src/pages/Usage/SourcesTab.jsx diff --git a/core/http/react-ui/src/pages/Usage.jsx b/core/http/react-ui/src/pages/Usage.jsx index 9d5b51f69..468c6dd07 100644 --- a/core/http/react-ui/src/pages/Usage.jsx +++ b/core/http/react-ui/src/pages/Usage.jsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next' import { useAuth } from '../context/AuthContext' import { apiUrl } from '../utils/basePath' import LoadingSpinner from '../components/LoadingSpinner' +import SourcesTab from './Usage/SourcesTab' const PERIODS = [ { key: 'day', label: 'Day' }, @@ -724,23 +725,27 @@ export default function Usage() { {p.label} ))} +
+ {isAdmin && ( - <> -
- - - + )} +
) )} + + {activeTab === 'sources' && ( + + )} )}
diff --git a/core/http/react-ui/src/pages/Usage/SourcesTab.jsx b/core/http/react-ui/src/pages/Usage/SourcesTab.jsx new file mode 100644 index 000000000..4cc71849e --- /dev/null +++ b/core/http/react-ui/src/pages/Usage/SourcesTab.jsx @@ -0,0 +1,150 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { usageApi } from '../../utils/api' +import { useAuth } from '../../context/AuthContext' +import LoadingSpinner from '../../components/LoadingSpinner' + +const EMPTY_DATA = { + buckets: [], + totals: { by_source: {}, by_key: [], grand_total: { tokens: 0, requests: 0 } }, + truncated: false, +} + +// 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. +export default function SourcesTab({ period, adminUserId }) { + const { t } = useTranslation('admin') + const { isAdmin } = useAuth() + + const [data, setData] = useState(EMPTY_DATA) + 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') + + useEffect(() => { + let cancelled = false + setLoading(true) + setError(null) + const p = isAdmin + ? usageApi.getAdminSources(period, adminUserId) + : usageApi.getMySources(period) + p + .then((d) => { if (!cancelled) setData(d || EMPTY_DATA) }) + .catch((e) => { if (!cancelled) setError(e) }) + .finally(() => { if (!cancelled) setLoading(false) }) + return () => { cancelled = true } + }, [isAdmin, period, adminUserId]) + + const totals = data.totals || EMPTY_DATA.totals + const grandT = totals.grand_total || { tokens: 0, requests: 0 } + const truncated = data.truncated || false + + const isEmpty = !loading && (grandT.tokens || 0) === 0 && (grandT.requests || 0) === 0 + + if (loading) { + return ( +
+ +
+ ) + } + + if (error) { + return ( +
+
+

Failed to load

+

{String(error.message || error)}

+
+ ) + } + + if (isEmpty) { + return ( +
+
+

{t('usage.sources.noTrafficShort')}

+

{t('usage.sources.noKeysYet')}

+
+ ) + } + + // 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)}
+
+ +
+
{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 && ( +
+ {t('usage.sources.truncatedWarning')} +
+ )} +
+ ) +}