mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-24 08:38:02 -04:00
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:
@@ -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>
|
||||
|
||||
150
core/http/react-ui/src/pages/Usage/SourcesTab.jsx
Normal file
150
core/http/react-ui/src/pages/Usage/SourcesTab.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user