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 <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2026-05-20 23:19:47 +00:00
parent f0637da946
commit bfd16870d5
3 changed files with 316 additions and 62 deletions

View File

@@ -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 (
<div
role="group"
aria-label={t('usage.sources.ribbonAria', { apikey: apiPct, web: webPct, legacy: legacyPct })}
style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-xs)' }}
>
<div style={{ fontSize: '0.875rem', fontWeight: 600, color: 'var(--color-text-primary)' }}>
{t('usage.sources.mixTitle')}
</div>
<div
style={{
display: 'flex',
height: 12,
borderRadius: 'var(--radius-sm)',
overflow: 'hidden',
border: '1px solid var(--color-border-subtle)',
}}
>
{segments.map((s) => (
<button
key={s.key}
type="button"
onClick={() => onSelectSourceClass?.(s.key)}
aria-label={s.label}
style={{
width: `${s.pct}%`,
background: s.color,
border: 'none',
padding: 0,
cursor: onSelectSourceClass ? 'pointer' : 'default',
}}
/>
))}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--spacing-sm)', fontSize: '0.75rem' }}>
{segments.map((s) => (
<span key={s.key} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span
style={{ width: 10, height: 10, borderRadius: 2, background: s.color, display: 'inline-block' }}
aria-hidden
/>
{s.label}
</span>
))}
</div>
</div>
)
}

View File

@@ -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 (
<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>
<SourceMixRibbon
bySource={totals.by_source}
keyCount={(totals.by_key || []).length}
onSelectSourceClass={(cls) => setSelectedKey(cls)}
/>
</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>
<SourcesTable
totals={totals}
selectedKey={selectedKey}
onSelectKey={setSelectedKey}
search={search}
setSearch={setSearch}
sortKey={sortKey}
setSortKey={setSortKey}
existingKeyIds={existingKeyIds}
/>
</div>
{truncated && (

View File

@@ -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<string> 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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-sm)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', flexWrap: 'wrap' }}>
<input
type="search"
value={search}
onChange={(e) => 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)',
}}
/>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.75rem' }}>
{t('usage.sources.sortBy')}:
<select
value={sortKey}
onChange={(e) => setSortKey(e.target.value)}
style={{
padding: 'calc(var(--spacing-xs) / 2) var(--spacing-xs)',
border: '1px solid var(--color-border-subtle)',
borderRadius: 'var(--radius-sm)',
background: 'var(--color-bg-primary)',
color: 'var(--color-text-primary)',
}}
>
<option value="tokens">{t('usage.sources.sortTokens')}</option>
<option value="requests">{t('usage.sources.sortRequests')}</option>
<option value="last_used">{t('usage.sources.sortLastUsed')}</option>
<option value="name">{t('usage.sources.sortName')}</option>
</select>
</label>
</div>
<div className="table-container">
<table className="table">
<thead>
<tr>
<th>{t('usage.sources.sortName')}</th>
<th style={{ width: 110 }}>Prefix</th>
<th style={{ width: 100, textAlign: 'right' }}>{t('usage.sources.sortRequests')}</th>
<th style={{ width: 100, textAlign: 'right' }}>{t('usage.sources.sortTokens')}</th>
<th style={{ width: 120, textAlign: 'right' }}>{t('usage.sources.sortLastUsed')}</th>
</tr>
</thead>
<tbody>
{filtered.map((r) => {
const isSel = selectedKey === r.id
return (
<tr
key={r.id}
onClick={() => onSelectKey?.(isSel ? null : r.id)}
style={{
cursor: 'pointer',
background: isSel ? 'var(--color-bg-secondary)' : undefined,
opacity: r.revoked ? 0.5 : 1,
}}
>
<td>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<i
className={iconFor(r.kind)}
style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}
/>
<span>{r.name}</span>
{r.revoked && (
<span
style={{
fontSize: '0.6875rem',
textTransform: 'uppercase',
color: 'var(--color-text-muted)',
}}
>
({t('usage.sources.revoked')})
</span>
)}
</span>
</td>
<td style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem' }}>{r.prefix || '-'}</td>
<td style={{ textAlign: 'right', fontFamily: 'var(--font-mono)' }}>
{Number(r.requests || 0).toLocaleString()}
</td>
<td style={{ textAlign: 'right', fontFamily: 'var(--font-mono)' }}>
{formatTokens(r.tokens || 0)}
</td>
<td style={{ textAlign: 'right', fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
{formatRelative(r.last_used)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}