mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-23 16:20:01 -04:00
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"sortRequests": "Requests",
|
||||
"sortLastUsed": "Last used",
|
||||
"sortName": "Name",
|
||||
"sortUser": "User",
|
||||
"webUI": "Web UI",
|
||||
"legacy": "Legacy",
|
||||
"revoked": "revoked",
|
||||
|
||||
@@ -162,6 +162,7 @@ export default function SourcesTab({ period, adminUserId }) {
|
||||
sortKey={sortKey}
|
||||
setSortKey={setSortKey}
|
||||
existingKeyIds={existingKeyIds}
|
||||
showUserColumn={isAdmin}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user