feat(usage): attribute Sources rows to user accounts in admin view (#9935)

The merged feature (#9920) let admins see per-API-key and per-source
totals but did not surface which user owned each key, and lumped
every user's Web UI traffic into a single global Web UI row. This
makes the admin Sources tab properly per-user attributable:

- KeyTotal gains UserID + UserName, populated from the snapshot the
  usage middleware already records. The by_key roll-up now groups by
  (api_key_id, api_key_name, user_id, user_name).
- New SourceTotals.ByUserSource roll-up groups (source, user_id,
  user_name) for sources without a key identity (web, legacy). Only
  populated on the admin path (includeLegacy=true); the non-admin
  endpoint stays unchanged for backwards compatibility.
- SourcesTable accepts showUserColumn={isAdmin}; admin view renders
  a User column, makes the search match user name/id, and expands
  Web UI / legacy pseudo-rows from the global aggregate to one row
  per user using by_user_source.

Refs: #9862

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
LocalAI [bot]
2026-05-21 23:23:06 +02:00
committed by GitHub
parent a39e025d64
commit f0cb02afb8
5 changed files with 185 additions and 19 deletions

View File

@@ -198,20 +198,37 @@ type TotalsEntry struct {
Requests int64 `json:"requests"`
}
// KeyTotal is the per-key roll-up returned by sources endpoints.
// KeyTotal is the per-key roll-up returned by sources endpoints. UserID and
// UserName are snapshotted from the UsageRecord so revoked-and-deleted keys
// still carry their owner attribution in admin views.
type KeyTotal struct {
APIKeyID string `json:"api_key_id"`
APIKeyName string `json:"api_key_name"`
UserID string `json:"user_id"`
UserName string `json:"user_name"`
Tokens int64 `json:"tokens"`
Requests int64 `json:"requests"`
LastUsed time.Time `json:"last_used"`
}
// UserSourceTotal is a per-(user, source) roll-up for sources that don't carry
// a named API key identity (web, legacy). It exists so admin views can show
// which user generated each block of Web UI / legacy traffic; the per-apikey
// breakdown for source=apikey already lives in KeyTotal.
type UserSourceTotal struct {
Source string `json:"source"`
UserID string `json:"user_id"`
UserName string `json:"user_name"`
Tokens int64 `json:"tokens"`
Requests int64 `json:"requests"`
}
// SourceTotals summarises a per-source breakdown.
type SourceTotals struct {
BySource map[string]TotalsEntry `json:"by_source"`
ByKey []KeyTotal `json:"by_key"` // server-sorted desc by tokens, capped
GrandTotal TotalsEntry `json:"grand_total"`
BySource map[string]TotalsEntry `json:"by_source"`
ByKey []KeyTotal `json:"by_key"` // server-sorted desc by tokens, capped
ByUserSource []UserSourceTotal `json:"by_user_source,omitempty"` // populated only when includeLegacy=true
GrandTotal TotalsEntry `json:"grand_total"`
}
const maxKeyTotals = 200
@@ -275,9 +292,10 @@ func computeSourceTotals(db *gorm.DB, userID, apiKeyID string, since time.Time,
byKeyQ := db.Model(&UsageRecord{}).
Select("COALESCE(api_key_id, '') as api_key_id, api_key_name, "+
"user_id, user_name, "+
"SUM(total_tokens) as tokens, COUNT(*) as requests, MAX(created_at) as last_used").
Where("api_key_id IS NOT NULL AND api_key_id <> ''").
Group("api_key_id, api_key_name").
Group("api_key_id, api_key_name, user_id, user_name").
Order("tokens DESC").
Limit(maxKeyTotals)
byKeyQ = applyFilters(byKeyQ, userID, apiKeyID, since, includeLegacy)
@@ -294,15 +312,17 @@ func computeSourceTotals(db *gorm.DB, userID, apiKeyID string, since time.Time,
out := make([]KeyTotal, 0)
for rows.Next() {
var (
apiKeyID, apiKeyName, lastUsedRaw string
tokens, requests int64
apiKeyID, apiKeyName, userIDCol, userName, lastUsedRaw string
tokens, requests int64
)
if scanErr := rows.Scan(&apiKeyID, &apiKeyName, &tokens, &requests, &lastUsedRaw); scanErr != nil {
if scanErr := rows.Scan(&apiKeyID, &apiKeyName, &userIDCol, &userName, &tokens, &requests, &lastUsedRaw); scanErr != nil {
continue
}
out = append(out, KeyTotal{
APIKeyID: apiKeyID,
APIKeyName: apiKeyName,
UserID: userIDCol,
UserName: userName,
Tokens: tokens,
Requests: requests,
LastUsed: parseLastUsedString(lastUsedRaw),
@@ -314,6 +334,27 @@ func computeSourceTotals(db *gorm.DB, userID, apiKeyID string, since time.Time,
totals.ByKey = out
}
// by_user_source: only populated for admin callers (includeLegacy=true) so
// they can attribute Web UI / legacy traffic to specific users. Per-apikey
// rows already carry user info via KeyTotal above, so this query only
// covers source != apikey.
if includeLegacy {
byUserSourceQ := db.Model(&UsageRecord{}).
Select("source, user_id, user_name, "+
"SUM(total_tokens) as tokens, COUNT(*) as requests").
Where("source <> ?", UsageSourceAPIKey).
Group("source, user_id, user_name").
Order("tokens DESC")
byUserSourceQ = applyFilters(byUserSourceQ, userID, apiKeyID, since, includeLegacy)
var byUserSourceRows []UserSourceTotal
if scanErr := byUserSourceQ.Scan(&byUserSourceRows).Error; scanErr != nil {
xlog.Warn("computeSourceTotals: by-user-source Scan failed", "error", scanErr)
} else {
totals.ByUserSource = byUserSourceRows
}
}
return totals
}

View File

@@ -349,5 +349,86 @@ var _ = Describe("Usage", func() {
Expect(totals.ByKey).To(HaveLen(200))
Expect(totals.ByKey[0].Tokens > totals.ByKey[199].Tokens).To(BeTrue())
})
// insertNamed records a row with explicit user_id, user_name, source,
// and optional api key snapshot. Used by the user-attribution tests
// below which the older insert helper can't express.
insertNamed := func(db *gorm.DB, userID, userName, source, keyID, keyName string, tokens int64) {
rec := &auth.UsageRecord{
UserID: userID,
UserName: userName,
Source: source,
Model: "gpt-4",
TotalTokens: tokens,
CreatedAt: time.Now(),
}
if keyID != "" {
rec.APIKeyID = &keyID
rec.APIKeyName = keyName
}
Expect(auth.RecordUsage(db, rec)).To(Succeed())
}
It("attributes each KeyTotal to its owner user", func() {
db := testDB()
insertNamed(db, "alice", "Alice", auth.UsageSourceAPIKey, "k1", "ci-runner", 100)
insertNamed(db, "bob", "Bob", auth.UsageSourceAPIKey, "k2", "lap", 50)
_, totals, _, err := auth.GetAllUsageBySource(db, "month", "", "")
Expect(err).ToNot(HaveOccurred())
Expect(totals.ByKey).To(HaveLen(2))
byID := map[string]auth.KeyTotal{}
for _, k := range totals.ByKey {
byID[k.APIKeyID] = k
}
Expect(byID["k1"].UserID).To(Equal("alice"))
Expect(byID["k1"].UserName).To(Equal("Alice"))
Expect(byID["k2"].UserID).To(Equal("bob"))
Expect(byID["k2"].UserName).To(Equal("Bob"))
})
It("breaks Web UI and legacy traffic out per user in by_user_source for admin", func() {
db := testDB()
// Alice and Bob both have Web UI traffic; a synthetic legacy user
// also contributes. ByUserSource should expose one row per
// (source, user) pair, never for source=apikey.
insertNamed(db, "alice", "Alice", auth.UsageSourceWeb, "", "", 30)
insertNamed(db, "bob", "Bob", auth.UsageSourceWeb, "", "", 70)
insertNamed(db, "legacy-api-key", "API Key User", auth.UsageSourceLegacy, "", "", 10)
insertNamed(db, "alice", "Alice", auth.UsageSourceAPIKey, "k1", "ci-runner", 5)
_, totals, _, err := auth.GetAllUsageBySource(db, "month", "", "")
Expect(err).ToNot(HaveOccurred())
Expect(totals.ByUserSource).ToNot(BeEmpty())
for _, r := range totals.ByUserSource {
Expect(r.Source).ToNot(Equal(auth.UsageSourceAPIKey))
}
webByUser := map[string]int64{}
legacyByUser := map[string]int64{}
for _, r := range totals.ByUserSource {
switch r.Source {
case auth.UsageSourceWeb:
webByUser[r.UserID] = r.Tokens
case auth.UsageSourceLegacy:
legacyByUser[r.UserID] = r.Tokens
}
}
Expect(webByUser["alice"]).To(Equal(int64(30)))
Expect(webByUser["bob"]).To(Equal(int64(70)))
Expect(legacyByUser["legacy-api-key"]).To(Equal(int64(10)))
})
It("does NOT populate by_user_source in the non-admin path", func() {
db := testDB()
insertNamed(db, "alice", "Alice", auth.UsageSourceWeb, "", "", 30)
_, totals, err := auth.GetUserUsageBySource(db, "alice", "month")
Expect(err).ToNot(HaveOccurred())
// Non-admin path uses includeLegacy=false, so by_user_source stays nil.
Expect(totals.ByUserSource).To(BeNil())
})
})
})

View File

@@ -65,6 +65,7 @@
"sortRequests": "Requests",
"sortLastUsed": "Last used",
"sortName": "Name",
"sortUser": "User",
"webUI": "Web UI",
"legacy": "Legacy",
"revoked": "revoked",

View File

@@ -162,6 +162,7 @@ export default function SourcesTab({ period, adminUserId }) {
sortKey={sortKey}
setSortKey={setSortKey}
existingKeyIds={existingKeyIds}
showUserColumn={isAdmin}
/>
</div>

View File

@@ -6,6 +6,7 @@ const SORT_FNS = {
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 || ''),
user: (a, b) => (a.userName || '').localeCompare(b.userName || ''),
}
function formatTokens(n) {
@@ -41,6 +42,8 @@ function formatRelative(iso) {
// when the parent hasn't yet learned which keys exist. Null suppresses the
// revoked badge entirely so live keys aren't dimmed during the fetch or
// after a failure.
// showUserColumn: render the User column. Admin views set this true so the
// reader can attribute each key (and each Web UI row) to its owner.
export default function SourcesTable({
totals,
selectedKey,
@@ -50,6 +53,7 @@ export default function SourcesTable({
sortKey,
setSortKey,
existingKeyIds = null,
showUserColumn = false,
}) {
const { t } = useTranslation('admin')
@@ -58,39 +62,70 @@ export default function SourcesTable({
kind: 'apikey',
id: k.api_key_id,
name: k.api_key_name || k.api_key_id,
userID: k.user_id || '',
userName: k.user_name || '',
prefix: '',
tokens: k.tokens,
requests: k.requests,
last_used: k.last_used,
revoked: existingKeyIds != null && !existingKeyIds.has(k.api_key_id),
}))
const web = totals?.by_source?.web
? [{
// Pseudo-rows for sources that don't have a named key identity.
// In admin view (showUserColumn=true), prefer the per-user breakdown
// from totals.by_user_source so each user's Web UI / legacy traffic
// gets its own row. Otherwise fall back to the global by_source aggregate.
let unkeyed = []
if (showUserColumn && Array.isArray(totals?.by_user_source) && totals.by_user_source.length > 0) {
unkeyed = totals.by_user_source.map((r) => ({
kind: r.source,
id: r.source + ':' + (r.user_id || ''),
name: r.source === 'legacy' ? t('usage.sources.legacy') : t('usage.sources.webUI'),
userID: r.user_id || '',
userName: r.user_name || '',
prefix: '-',
tokens: r.tokens,
requests: r.requests,
}))
} else {
if (totals?.by_source?.web) {
unkeyed.push({
kind: 'web',
id: 'web',
name: t('usage.sources.webUI'),
userID: '',
userName: '',
prefix: '-',
tokens: totals.by_source.web.tokens,
requests: totals.by_source.web.requests,
}]
: []
const leg = totals?.by_source?.legacy
? [{
})
}
if (totals?.by_source?.legacy) {
unkeyed.push({
kind: 'legacy',
id: 'legacy',
name: t('usage.sources.legacy'),
userID: '',
userName: '',
prefix: '-',
tokens: totals.by_source.legacy.tokens,
requests: totals.by_source.legacy.requests,
}]
: []
return [...named, ...web, ...leg]
}, [totals, existingKeyIds, t])
})
}
}
return [...named, ...unkeyed]
}, [totals, existingKeyIds, showUserColumn, 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.filter((r) =>
(r.name || '').toLowerCase().includes(q) ||
(r.prefix || '').toLowerCase().includes(q) ||
(r.userName || '').toLowerCase().includes(q) ||
(r.userID || '').toLowerCase().includes(q)
)
: rows
return [...list].sort(SORT_FNS[sortKey] || SORT_FNS.tokens)
}, [rows, search, sortKey])
@@ -134,6 +169,7 @@ export default function SourcesTable({
<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>
{showUserColumn && <option value="user">{t('usage.sources.sortUser')}</option>}
</select>
</label>
</div>
@@ -143,6 +179,7 @@ export default function SourcesTable({
<thead>
<tr>
<th>{t('usage.sources.sortName')}</th>
{showUserColumn && <th style={{ width: 180 }}>{t('usage.sources.sortUser')}</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>
@@ -182,6 +219,11 @@ export default function SourcesTable({
)}
</span>
</td>
{showUserColumn && (
<td style={{ color: 'var(--color-text-secondary)', fontSize: '0.8125rem' }}>
{r.userName || r.userID || '-'}
</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()}