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 <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2026-05-20 23:14:02 +00:00
parent 5159c2864a
commit f0637da946
2 changed files with 174 additions and 15 deletions

View File

@@ -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}
</button>
))}
<div style={{ width: 1, height: 20, background: 'var(--color-border-subtle)', margin: '0 var(--spacing-xs)' }} />
<button
className={`btn btn-sm ${activeTab === 'models' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setActiveTab('models')}
>
<i className="fas fa-cube" style={{ fontSize: '0.7rem' }} /> Models
</button>
{isAdmin && (
<>
<div style={{ width: 1, height: 20, background: 'var(--color-border-subtle)', margin: '0 var(--spacing-xs)' }} />
<button
className={`btn btn-sm ${activeTab === 'models' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setActiveTab('models')}
>
<i className="fas fa-cube" style={{ fontSize: '0.7rem' }} /> Models
</button>
<button
className={`btn btn-sm ${activeTab === 'users' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setActiveTab('users')}
>
<i className="fas fa-users" style={{ fontSize: '0.7rem' }} /> Users
</button>
</>
<button
className={`btn btn-sm ${activeTab === 'users' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setActiveTab('users')}
>
<i className="fas fa-users" style={{ fontSize: '0.7rem' }} /> Users
</button>
)}
<button
className={`btn btn-sm ${activeTab === 'sources' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setActiveTab('sources')}
>
<i className="fas fa-key" style={{ fontSize: '0.7rem' }} /> {t('usage.sources.tab')}
</button>
<div style={{ flex: 1 }} />
<button className="btn btn-secondary btn-sm" onClick={fetchUsage} disabled={loading} style={{ gap: 4 }}>
<i className={`fas fa-rotate${loading ? ' fa-spin' : ''}`} /> Refresh
@@ -884,6 +889,10 @@ export default function Usage() {
</div>
)
)}
{activeTab === 'sources' && (
<SourcesTab period={period} adminUserId={selectedUserId} />
)}
</>
)}
</div>

View File

@@ -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 (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
<LoadingSpinner size="lg" />
</div>
)
}
if (error) {
return (
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-triangle-exclamation" /></div>
<h2 className="empty-state-title">Failed to load</h2>
<p className="empty-state-text">{String(error.message || error)}</p>
</div>
)
}
if (isEmpty) {
return (
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-key" /></div>
<h2 className="empty-state-title">{t('usage.sources.noTrafficShort')}</h2>
<p className="empty-state-text">{t('usage.sources.noKeysYet')}</p>
</div>
)
}
// Skeleton placeholders: Tasks 10 and 11 replace these with SourceMixRibbon,
// SourceTimeChart, and SourcesTable.
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-md)' }}>
<div className="card" style={{ padding: 'var(--spacing-md)' }}>
<div style={{
fontSize: '0.6875rem',
color: 'var(--color-text-muted)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.03em',
marginBottom: 'var(--spacing-xs)',
}}>{t('usage.sources.mixTitle')}</div>
<pre style={{
fontSize: '0.75rem',
background: 'var(--color-bg-secondary)',
padding: 'var(--spacing-sm)',
borderRadius: 'var(--radius-sm)',
overflow: 'auto',
margin: 0,
fontFamily: 'var(--font-mono)',
}}>{JSON.stringify(totals.by_source, null, 2)}</pre>
</div>
<div className="card" style={{ padding: 'var(--spacing-md)' }}>
<div style={{
fontSize: '0.6875rem',
color: 'var(--color-text-muted)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.03em',
marginBottom: 'var(--spacing-xs)',
}}>{t('usage.sources.topSources')}</div>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{(totals.by_key || []).map((k) => {
const isSelected = selectedKey === k.api_key_id
return (
<li
key={k.api_key_id}
onClick={() => 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,
}}
>
<i className="fas fa-key" style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem' }} />
<span style={{ fontFamily: 'var(--font-mono)' }}>{k.api_key_name || k.api_key_id}</span>
<span style={{ color: 'var(--color-text-muted)', marginLeft: 'auto', fontFamily: 'var(--font-mono)' }}>
{Number(k.tokens || 0).toLocaleString()}
</span>
</li>
)
})}
</ul>
</div>
{truncated && (
<div style={{ fontSize: '0.75rem', color: 'var(--color-warning)' }}>
{t('usage.sources.truncatedWarning')}
</div>
)}
</div>
)
}