mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-22 23:58:25 -04:00
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:
83
core/http/react-ui/src/pages/Usage/SourceMixRibbon.jsx
Normal file
83
core/http/react-ui/src/pages/Usage/SourceMixRibbon.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
200
core/http/react-ui/src/pages/Usage/SourcesTable.jsx
Normal file
200
core/http/react-ui/src/pages/Usage/SourcesTable.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user