feat(ui): editorial overhaul ops/admin data-viz, sortable tables, mobile reflow, unsaved-changes guards (#10398)

* feat(ui): legible Usage charts - distinct prompt/completion hues + chart a11y

Prompt and completion were the same color (primary at 0.35 opacity), so the
stacked token charts read as one blurry blob. Completion now uses a distinct
data-viz hue (--color-data-3) at full opacity across the time chart, the
per-model distribution bars, and the tooltip. The source-mix chart is no longer
aria-hidden: it exposes role="img" with a label.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): sortable Users table

The admin Users table is now sortable by name, email, provider, role, status,
and created date - clickable headers with an aria-sort state, a direction
caret, and keyboard activation (Enter/Space). Permissions and Actions stay
non-sortable.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): unsaved-changes guard on Settings and Agent create/edit

Add a reusable UnsavedChangesGuard (router useBlocker + beforeunload) that
prompts before navigating away or closing the tab with unsaved edits. Wired to
Settings (existing isDirty) and AgentCreate (snapshot the loaded form, compare;
suppressed while saving so the post-save redirect is not blocked). Adds the
common.unsaved i18n keys.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): sortable Traces tables

Both trace tables are now sortable: the API table by method/path/status and the
backend table by type/time/model/duration, with aria-sort, a direction caret,
and keyboard activation. Sort and the expanded row reset when switching tabs
(the two tables have different columns).

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): responsive table reflow (cards on mobile), applied to Users

Dense admin tables sideways-scroll on phones. Add a reusable ResponsiveTable
that mirrors the <thead> labels onto each body cell (data-label) and a
<=640px stylesheet that stacks rows into label/value cards. Wired to both
Users tables; reusable for the other dense tables next.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): roll responsive table reflow to Traces, Models, Manage, Nodes

Apply ResponsiveTable to the remaining dense tables so they stack into
label/value cards on phones instead of scrolling sideways. Harden the
component for these tables: scope label-mirroring and the card CSS to direct
children (nested detail tables render normally), override inline min-width on
mobile, and pass through table/container inline styles. Nested expansion
tables in Nodes/Models/Manage are intentionally left as-is.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): unsaved-changes guard on the Fine-Tuning form

Editing the long fine-tune job form and navigating away silently discarded
everything. Snapshot the assembled getFormConfig() as a baseline, treat the
open form as dirty when it diverges, and reuse UnsavedChangesGuard to prompt
before leaving. The baseline is rebased after a job is submitted so leaving
afterward does not warn.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

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-06-19 00:56:17 +02:00
committed by GitHub
parent 4ad754eea3
commit 29dbba7a25
20 changed files with 274 additions and 61 deletions

View File

@@ -1,4 +1,9 @@
{
"unsaved": {
"title": "Discard unsaved changes?",
"message": "You have unsaved changes that will be lost if you leave this page.",
"leave": "Leave"
},
"actions": {
"save": "Speichern",
"saving": "Speichern...",

View File

@@ -1,4 +1,9 @@
{
"unsaved": {
"title": "Discard unsaved changes?",
"message": "You have unsaved changes that will be lost if you leave this page.",
"leave": "Leave"
},
"actions": {
"save": "Save",
"saving": "Saving...",

View File

@@ -1,4 +1,9 @@
{
"unsaved": {
"title": "Discard unsaved changes?",
"message": "You have unsaved changes that will be lost if you leave this page.",
"leave": "Leave"
},
"actions": {
"save": "Guardar",
"saving": "Guardando...",

View File

@@ -1,4 +1,9 @@
{
"unsaved": {
"title": "Discard unsaved changes?",
"message": "You have unsaved changes that will be lost if you leave this page.",
"leave": "Leave"
},
"actions": {
"save": "Simpan",
"saving": "Menyimpan...",

View File

@@ -1,4 +1,9 @@
{
"unsaved": {
"title": "Scartare le modifiche non salvate?",
"message": "Hai modifiche non salvate che andranno perse se esci da questa pagina.",
"leave": "Esci"
},
"actions": {
"save": "Salva",
"saving": "Salvataggio...",

View File

@@ -1,4 +1,9 @@
{
"unsaved": {
"title": "Discard unsaved changes?",
"message": "You have unsaved changes that will be lost if you leave this page.",
"leave": "Leave"
},
"actions": {
"save": "저장",
"saving": "저장 중...",

View File

@@ -1,4 +1,9 @@
{
"unsaved": {
"title": "Discard unsaved changes?",
"message": "You have unsaved changes that will be lost if you leave this page.",
"leave": "Leave"
},
"actions": {
"save": "保存",
"saving": "保存中...",

View File

@@ -2427,6 +2427,40 @@ select.input {
border-radius: var(--radius-lg);
}
/* ResponsiveTable: stack dense tables into label/value cards on narrow screens
instead of a sideways scroll. Labels come from data-label (mirrored from the
<thead> by the ResponsiveTable component). */
@media (max-width: 640px) {
/* Direct-child selectors only: a nested table inside a cell renders normally.
min-width override defeats any inline min-width set for the desktop layout. */
.table--responsive { border: none; min-width: 0 !important; }
.table--responsive > thead { display: none; }
.table--responsive > tbody > tr {
display: block;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
margin: var(--spacing-sm);
padding: var(--spacing-xs) var(--spacing-sm);
}
.table--responsive > tbody > tr > td {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-md);
border: none;
padding: var(--spacing-xs) 0;
text-align: right;
}
.table--responsive > tbody > tr > td[data-label]::before {
content: attr(data-label);
font-weight: var(--font-weight-semibold);
color: var(--color-text-muted);
text-align: left;
margin-right: auto;
}
}
.table {
width: 100%;
border-collapse: collapse;

View File

@@ -0,0 +1,40 @@
import { useRef, useEffect } from 'react'
// Wraps a standard .table and makes it reflow into stacked label/value cards on
// narrow screens. Column labels are derived from the <thead> and mirrored onto
// each body cell via data-label (read by CSS ::before in the mobile layout), so
// any table becomes responsive without hand-labelling every <td>.
export default function ResponsiveTable({ children, className = '', style, containerStyle }) {
const ref = useRef(null)
useEffect(() => {
const table = ref.current
if (!table) return
const apply = () => {
// Direct children only, so a nested table inside a cell is left alone.
const heads = [...table.querySelectorAll(':scope > thead > tr > th')].map(th => th.textContent.trim())
table.querySelectorAll(':scope > tbody > tr').forEach(tr => {
const cells = [...tr.children]
// Skip detail/expansion rows (a single cell spanning the table).
if (cells.length === 1 && cells[0].colSpan > 1) return
cells.forEach((td, i) => {
if (heads[i]) td.setAttribute('data-label', heads[i])
})
})
}
apply()
// Re-apply when rows change (sort, paging, live data). setAttribute touches
// attributes only, so a childList/subtree observer won't retrigger itself.
const obs = new MutationObserver(apply)
obs.observe(table, { childList: true, subtree: true })
return () => obs.disconnect()
}, [])
return (
<div className="table-container" style={containerStyle}>
<table ref={ref} className={`table table--responsive ${className}`.trim()} style={style}>
{children}
</table>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import { useEffect, useCallback } from 'react'
import { useBlocker } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import ConfirmDialog from './ConfirmDialog'
// Guards against losing unsaved work: blocks in-app route changes (via the
// router's useBlocker) and warns on tab close/reload (beforeunload) whenever
// `when` is true. Drop into any page that has a dirty-state signal.
export default function UnsavedChangesGuard({ when }) {
const { t } = useTranslation('common')
const blocker = useBlocker(
useCallback(
({ currentLocation, nextLocation }) => when && currentLocation.pathname !== nextLocation.pathname,
[when]
)
)
useEffect(() => {
if (!when) return
const handler = (e) => { e.preventDefault(); e.returnValue = '' }
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [when])
return (
<ConfirmDialog
open={blocker.state === 'blocked'}
title={t('unsaved.title')}
message={t('unsaved.message')}
confirmLabel={t('unsaved.leave')}
danger
onConfirm={() => blocker.proceed?.()}
onCancel={() => blocker.reset?.()}
/>
)
}

View File

@@ -1,8 +1,9 @@
import { useState, useEffect, useMemo } from 'react'
import { useState, useEffect, useMemo, useRef } from 'react'
import { useParams, useNavigate, useLocation, useOutletContext, useSearchParams } from 'react-router-dom'
import { agentsApi, skillsApi } from '../utils/api'
import SearchableModelSelect from '../components/SearchableModelSelect'
import PageHeader from '../components/PageHeader'
import UnsavedChangesGuard from '../components/UnsavedChangesGuard'
import { CAP_CHAT, CAP_TRANSCRIPT, CAP_TTS } from '../utils/capabilities'
import Toggle from '../components/Toggle'
import SettingRow from '../components/SettingRow'
@@ -296,6 +297,8 @@ export default function AgentCreate() {
const [activeSection, setActiveSection] = useState('BasicInfo')
const [meta, setMeta] = useState(null)
const [form, setForm] = useState({})
// Snapshot of the form as first loaded, for the unsaved-changes guard.
const initialFormRef = useRef(null)
const [connectors, setConnectors] = useState([])
const [actions, setActions] = useState([])
const [filters, setFilters] = useState([])
@@ -374,6 +377,7 @@ export default function AgentCreate() {
if (Array.isArray(sourceConfig.selected_skills)) setSelectedSkills(sourceConfig.selected_skills)
}
initialFormRef.current = initialForm
setForm(initialForm)
} catch (err) {
addToast(`Failed to load configuration: ${err.message}`, 'error')
@@ -819,8 +823,12 @@ export default function AgentCreate() {
)
}
const dirty = initialFormRef.current != null &&
JSON.stringify(form) !== JSON.stringify(initialFormRef.current)
return (
<div className="page page--narrow">
<UnsavedChangesGuard when={dirty && !saving} />
<style>{`
.agent-form-container {
display: flex;

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
import { fineTuneApi } from '../utils/api'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
import UnsavedChangesGuard from '../components/UnsavedChangesGuard'
const TRAINING_METHODS = ['sft', 'dpo', 'grpo', 'rloo', 'reward', 'kto', 'orpo']
const TRAINING_TYPES = ['lora', 'loha', 'lokr', 'full']
@@ -705,6 +706,8 @@ export default function FineTune() {
const [error, setError] = useState('')
const [backends, setBackends] = useState([])
const [exportCheckpoint, setExportCheckpoint] = useState(null)
// Baseline of the assembled config for the unsaved-changes guard.
const initialConfigRef = useRef(null)
// Form state
const [model, setModel] = useState('')
@@ -845,6 +848,8 @@ export default function FineTune() {
const resp = await fineTuneApi.startJob(req)
setShowForm(false)
setResumeFromCheckpoint('')
// Job submitted: rebaseline so leaving the page no longer warns.
initialConfigRef.current = JSON.stringify(getFormConfig())
await loadJobs()
const newJob = { ...req, id: resp.id, status: 'queued', created_at: new Date().toISOString() }
@@ -1057,8 +1062,13 @@ export default function FineTune() {
setExportCheckpoint(checkpoint)
}
// Lazy-init the baseline on first render; dirty when the open form diverges.
if (initialConfigRef.current === null) initialConfigRef.current = JSON.stringify(getFormConfig())
const dirty = JSON.stringify(getFormConfig()) !== initialConfigRef.current
return (
<div className="page page--wide">
<UnsavedChangesGuard when={dirty && showForm && !loading} />
<PageHeader
title={<>Fine-Tuning <span className="badge badge-warning" style={{ fontSize: '0.45em', verticalAlign: 'middle' }}>Experimental</span></>}
supporting="Create and manage fine-tuning jobs"

View File

@@ -12,6 +12,7 @@ import ManageSummary from '../components/ManageSummary'
import MetaBadgeRow from '../components/MetaBadgeRow'
import ActionMenu from '../components/ActionMenu'
import ResourceRow, { ChevronCell, IconCell, StopPropagationCell } from '../components/ResourceRow'
import ResponsiveTable from '../components/ResponsiveTable'
import { useModels } from '../hooks/useModels'
import { useGalleryEnrichment } from '../hooks/useGalleryEnrichment'
import { useOperations } from '../hooks/useOperations'
@@ -560,8 +561,7 @@ export default function Manage() {
<button className="btn btn-ghost btn-sm" onClick={() => { setModelsSearch(''); setModelsFilter('all') }}>Clear filters</button>
</div>
) : (
<div className="table-container">
<table className="table">
<ResponsiveTable>
<thead>
<tr>
<th style={{ width: 30 }}></th>
@@ -686,8 +686,7 @@ export default function Manage() {
)
})}
</tbody>
</table>
</div>
</ResponsiveTable>
)}
</div>
)
@@ -855,8 +854,7 @@ export default function Manage() {
return (
<>
{filterBar}
<div className="table-container">
<table className="table">
<ResponsiveTable>
<thead>
<tr>
<th style={{ width: 30 }}></th>
@@ -987,8 +985,7 @@ export default function Manage() {
)
})}
</tbody>
</table>
</div>
</ResponsiveTable>
</>
)
})()}

View File

@@ -12,6 +12,7 @@ import PageHeader from '../components/PageHeader'
import ConfirmDialog from '../components/ConfirmDialog'
import GalleryLoader from '../components/GalleryLoader'
import Toggle from '../components/Toggle'
import ResponsiveTable from '../components/ResponsiveTable'
import React from 'react'
@@ -389,9 +390,7 @@ export default function Models() {
)}
</div>
) : (
<div className="table-container" style={{ background: 'var(--color-bg-secondary)', borderRadius: 'var(--radius-lg)', overflow: 'hidden' }}>
<div style={{ overflowX: 'auto' }}>
<table className="table" style={{ minWidth: '800px' }}>
<ResponsiveTable containerStyle={{ background: 'var(--color-bg-secondary)', borderRadius: 'var(--radius-lg)', overflow: 'hidden' }} style={{ minWidth: '800px' }}>
<thead>
<tr>
<th style={{ width: '30px' }}></th>
@@ -575,9 +574,7 @@ export default function Models() {
)
})}
</tbody>
</table>
</div>
</div>
</ResponsiveTable>
)}
{/* Pagination */}

View File

@@ -9,6 +9,7 @@ import ActionMenu from '../components/ActionMenu'
import SearchableModelSelect from '../components/SearchableModelSelect'
import ImageSelector, { useImageSelector, dockerImage, dockerFlags } from '../components/ImageSelector'
import StatCard from '../components/StatCard'
import ResponsiveTable from '../components/ResponsiveTable'
function timeAgo(dateString) {
if (!dateString) return 'never'
@@ -1086,8 +1087,7 @@ export default function Nodes() {
{/* Node table */}
{filteredNodes.length > 0 && (
<div className="table-container">
<table className="table">
<ResponsiveTable>
<thead>
<tr>
<th>Name</th>
@@ -1533,8 +1533,7 @@ export default function Nodes() {
)
})}
</tbody>
</table>
</div>
</ResponsiveTable>
)}
</>}
@@ -1560,8 +1559,7 @@ export default function Nodes() {
No scheduling rules configured. Add a rule to control how models are placed on nodes.
</p>
) : schedulingConfigs.length > 0 && (
<div className="table-container">
<table className="table">
<ResponsiveTable>
<thead><tr>
<th>Model</th>
<th>Mode</th>
@@ -1667,8 +1665,7 @@ export default function Nodes() {
)
})}
</tbody>
</table>
</div>
</ResponsiveTable>
)}
</div>
)}

View File

@@ -5,6 +5,7 @@ import { settingsApi, resourcesApi, brandingApi } from '../utils/api'
import { useBranding } from '../contexts/BrandingContext'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
import UnsavedChangesGuard from '../components/UnsavedChangesGuard'
import SearchableModelSelect from '../components/SearchableModelSelect'
import { CAP_CHAT } from '../utils/capabilities'
import Toggle from '../components/Toggle'
@@ -159,6 +160,7 @@ export default function Settings() {
return (
<div className="page page--medium" style={{ padding: 0 }}>
<UnsavedChangesGuard when={isDirty} />
{/* Header */}
<div style={{ padding: 'var(--spacing-lg) var(--spacing-lg) 0' }}>
<PageHeader

View File

@@ -5,6 +5,7 @@ import { tracesApi, settingsApi } from '../utils/api'
import { formatTimestamp } from '../utils/format'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
import ResponsiveTable from '../components/ResponsiveTable'
import Toggle from '../components/Toggle'
import SettingRow from '../components/SettingRow'
import WaveformPlayer from '../components/audio/WaveformPlayer'
@@ -317,7 +318,35 @@ export default function Traces() {
const [backendCount, setBackendCount] = useState(0)
const [loading, setLoading] = useState(true)
const [expandedRow, setExpandedRow] = useState(null)
const [sort, setSort] = useState({ key: null, dir: 'asc' })
const [tracingEnabled, setTracingEnabled] = useState(null)
const TRACE_SORT = {
method: (a, b) => (a.request?.method || '').localeCompare(b.request?.method || ''),
path: (a, b) => (a.request?.path || '').localeCompare(b.request?.path || ''),
status: (a, b) => (a.response?.status || 0) - (b.response?.status || 0),
type: (a, b) => (a.type || '').localeCompare(b.type || ''),
time: (a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0),
model: (a, b) => (a.model_name || '').localeCompare(b.model_name || ''),
duration: (a, b) => (a.duration || 0) - (b.duration || 0),
}
const toggleSort = (key) => {
setExpandedRow(null)
setSort(s => s.key === key ? { key, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'asc' })
}
const sortableTh = (key, label, props = {}) => (
<th
{...props}
role="button"
tabIndex={0}
aria-sort={sort.key === key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : 'none'}
onClick={() => toggleSort(key)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSort(key) } }}
style={{ cursor: 'pointer', userSelect: 'none', ...(props.style || {}) }}
>
{label}{sort.key === key && <i className={`fas fa-caret-${sort.dir === 'asc' ? 'up' : 'down'}`} style={{ marginLeft: 4, opacity: 0.7 }} aria-hidden="true" />}
</th>
)
const [backendLoggingEnabled, setBackendLoggingEnabled] = useState(null)
const [settings, setSettings] = useState(null)
const [settingsExpanded, setSettingsExpanded] = useState(false)
@@ -406,6 +435,13 @@ export default function Traces() {
URL.revokeObjectURL(url)
}
// Reset sort + expansion when switching trace tabs (columns differ).
useEffect(() => { setSort({ key: null, dir: 'asc' }); setExpandedRow(null) }, [activeTab])
const sortedTraces = sort.key && TRACE_SORT[sort.key]
? [...traces].sort((a, b) => sort.dir === 'asc' ? TRACE_SORT[sort.key](a, b) : TRACE_SORT[sort.key](b, a))
: traces
return (
<div className="page page--wide">
<PageHeader title={t('traces.title')} supporting={t('traces.subtitle')} />
@@ -537,19 +573,18 @@ export default function Traces() {
</p>
</div>
) : activeTab === 'api' ? (
<div className="table-container">
<table className="table">
<ResponsiveTable>
<thead>
<tr>
<th style={{ width: '30px' }}></th>
<th>Method</th>
<th>Path</th>
<th>Status</th>
{sortableTh('method', 'Method')}
{sortableTh('path', 'Path')}
{sortableTh('status', 'Status')}
<th style={{ width: '40px' }}>Result</th>
</tr>
</thead>
<tbody>
{traces.map((trace, i) => (
{sortedTraces.map((trace, i) => (
<React.Fragment key={i}>
<tr onClick={() => setExpandedRow(expandedRow === i ? null : i)} style={{ cursor: 'pointer' }}>
<td><i className={`fas fa-chevron-${expandedRow === i ? 'down' : 'right'}`} style={{ fontSize: '0.7rem' }} /></td>
@@ -572,24 +607,22 @@ export default function Traces() {
</React.Fragment>
))}
</tbody>
</table>
</div>
</ResponsiveTable>
) : (
<div className="table-container">
<table className="table">
<ResponsiveTable>
<thead>
<tr>
<th style={{ width: '30px' }}></th>
<th>Type</th>
<th>Time</th>
<th>Model</th>
{sortableTh('type', 'Type')}
{sortableTh('time', 'Time')}
{sortableTh('model', 'Model')}
<th>Summary</th>
<th>Duration</th>
{sortableTh('duration', 'Duration')}
<th style={{ width: '40px' }}>Status</th>
</tr>
</thead>
<tbody>
{traces.map((trace, i) => (
{sortedTraces.map((trace, i) => (
<React.Fragment key={i}>
<tr onClick={() => setExpandedRow(expandedRow === i ? null : i)} style={{ cursor: 'pointer' }}>
<td><i className={`fas fa-chevron-${expandedRow === i ? 'down' : 'right'}`} style={{ fontSize: '0.7rem' }} /></td>
@@ -616,8 +649,7 @@ export default function Traces() {
</React.Fragment>
))}
</tbody>
</table>
</div>
</ResponsiveTable>
)}
</div>
)

View File

@@ -413,7 +413,7 @@ function UsageTimeChart({ data, predictedData, period }) {
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: 'var(--color-text-primary)' }}>Tokens over time</span>
<div style={{ display: 'flex', gap: 'var(--spacing-md)', fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
<span><span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: "var(--radius-sm)", background: 'var(--color-primary)', marginRight: 4, verticalAlign: 'middle' }} />Prompt</span>
<span><span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: "var(--radius-sm)", background: 'var(--color-primary)', opacity: 0.35, marginRight: 4, verticalAlign: 'middle' }} />Completion</span>
<span><span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: "var(--radius-sm)", background: 'var(--color-data-3)', marginRight: 4, verticalAlign: 'middle' }} />Completion</span>
{predictedData && predictedData.length > 0 && (
<span>
<span style={{
@@ -472,7 +472,7 @@ function UsageTimeChart({ data, predictedData, period }) {
{/* Prompt tokens (bottom) */}
<rect x={x} y={chartH - promptH - compH} width={barWidth} height={promptH} fill="var(--color-primary)" rx={2} />
{/* Completion tokens (top) */}
<rect x={x} y={chartH - compH} width={barWidth} height={compH} fill="var(--color-primary)" opacity={0.35} rx={2} />
<rect x={x} y={chartH - compH} width={barWidth} height={compH} fill="var(--color-data-3)" rx={2} />
</g>
)
})}
@@ -571,7 +571,7 @@ function UsageTimeChart({ data, predictedData, period }) {
{formatBucket(tooltip.data.bucket, period)}
</div>
<div><span style={{ color: 'var(--color-primary)' }}>Prompt:</span> {tooltip.predicted ? '~' : ''}{tooltip.data.prompt_tokens.toLocaleString()}</div>
<div><span style={{ color: 'var(--color-text-secondary)' }}>Completion:</span> {tooltip.predicted ? '~' : ''}{tooltip.data.completion_tokens.toLocaleString()}</div>
<div><span style={{ color: 'var(--color-data-3)' }}>Completion:</span> {tooltip.predicted ? '~' : ''}{tooltip.data.completion_tokens.toLocaleString()}</div>
<div style={{ color: 'var(--color-text-muted)', borderTop: '1px solid var(--color-border)', marginTop: 2, paddingTop: 2 }}>
{tooltip.predicted ? '~' : ''}{tooltip.data.request_count} requests
</div>
@@ -596,7 +596,7 @@ function ModelDistChart({ rows }) {
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: 'var(--color-text-primary)' }}>Token distribution by model</span>
<div style={{ display: 'flex', gap: 'var(--spacing-md)', fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
<span><span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: "var(--radius-sm)", background: 'var(--color-primary)', marginRight: 4, verticalAlign: 'middle' }} />Prompt</span>
<span><span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: "var(--radius-sm)", background: 'var(--color-primary)', opacity: 0.35, marginRight: 4, verticalAlign: 'middle' }} />Completion</span>
<span><span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: "var(--radius-sm)", background: 'var(--color-data-3)', marginRight: 4, verticalAlign: 'middle' }} />Completion</span>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: gap }}>
@@ -613,7 +613,7 @@ function ModelDistChart({ rows }) {
</div>
<div style={{ flex: 1, height: barH, background: 'var(--color-bg-primary)', borderRadius: "var(--radius-sm)", overflow: 'hidden', display: 'flex' }}>
<div style={{ width: `${promptPct}%`, height: '100%', background: 'var(--color-primary)', transition: 'width 0.3s ease' }} />
<div style={{ width: `${compPct}%`, height: '100%', background: 'var(--color-primary)', opacity: 0.35, transition: 'width 0.3s ease' }} />
<div style={{ width: `${compPct}%`, height: '100%', background: 'var(--color-data-3)', transition: 'width 0.3s ease' }} />
</div>
<div style={{
minWidth: 60, textAlign: 'right', fontSize: '0.75rem', fontFamily: 'var(--font-mono)',

View File

@@ -101,7 +101,8 @@ export default function SourceTimeChart({ buckets = [], selectedKey, totals }) {
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
style={{ width: '100%', height: 160, display: 'block' }}
aria-hidden
role="img"
aria-label={t('usage.sources.topSources')}
>
{series.map((row, i) => {
let y = height

View File

@@ -5,6 +5,7 @@ import { useAuth } from '../context/AuthContext'
import { adminUsersApi, adminInvitesApi } from '../utils/api'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
import ResponsiveTable from '../components/ResponsiveTable'
import Modal from '../components/Modal'
import ConfirmDialog from '../components/ConfirmDialog'
import Toggle from '../components/Toggle'
@@ -570,8 +571,7 @@ function InvitesTab({ addToast }) {
<p className="empty-state-text">Generate an invite link to let someone register.</p>
</div>
) : (
<div className="table-container">
<table className="table">
<ResponsiveTable>
<thead>
<tr>
<th>Invite Link</th>
@@ -638,8 +638,7 @@ function InvitesTab({ addToast }) {
</tr>
))}
</tbody>
</table>
</div>
</ResponsiveTable>
)}
<ConfirmDialog
open={!!confirmDialog}
@@ -797,6 +796,33 @@ export default function Users() {
return (u.name || '').toLowerCase().includes(q) || (u.email || '').toLowerCase().includes(q)
})
const [sort, setSort] = useState({ key: null, dir: 'asc' })
const USER_SORT = {
name: (a, b) => (a.name || '').localeCompare(b.name || ''),
email: (a, b) => (a.email || '').localeCompare(b.email || ''),
provider: (a, b) => (a.provider || '').localeCompare(b.provider || ''),
role: (a, b) => (a.role || '').localeCompare(b.role || ''),
status: (a, b) => (a.status || '').localeCompare(b.status || ''),
created: (a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0),
}
const sortedUsers = sort.key
? [...filtered].sort((a, b) => sort.dir === 'asc' ? USER_SORT[sort.key](a, b) : USER_SORT[sort.key](b, a))
: filtered
const toggleSort = (key) => setSort(s => s.key === key ? { key, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'asc' })
const sortableTh = (key, label, className) => (
<th
className={className}
role="button"
tabIndex={0}
aria-sort={sort.key === key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : 'none'}
onClick={() => toggleSort(key)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSort(key) } }}
style={{ cursor: 'pointer', userSelect: 'none' }}
>
{label}{sort.key === key && <i className={`fas fa-caret-${sort.dir === 'asc' ? 'up' : 'down'}`} style={{ marginLeft: 4, opacity: 0.7 }} aria-hidden="true" />}
</th>
)
const handlePermissionSave = (userId, newPerms, newModels, newQuotas) => {
setUsers(prev => prev.map(u => u.id === userId ? { ...u, permissions: newPerms, allowed_models: newModels, quotas: newQuotas } : u))
}
@@ -854,22 +880,21 @@ export default function Users() {
<p className="empty-state-text">{search ? 'Try a different search term.' : 'No registered users found.'}</p>
</div>
) : (
<div className="table-container">
<table className="table">
<ResponsiveTable>
<thead>
<tr>
<th>User</th>
<th>Email</th>
<th>Provider</th>
<th>Role</th>
{sortableTh('name', 'User')}
{sortableTh('email', 'Email')}
{sortableTh('provider', 'Provider')}
{sortableTh('role', 'Role')}
<th>Permissions</th>
<th>Status</th>
<th>Created</th>
{sortableTh('status', 'Status')}
{sortableTh('created', 'Created')}
<th className="cell-actions">Actions</th>
</tr>
</thead>
<tbody>
{filtered.map(u => (
{sortedUsers.map(u => (
<tr key={u.id}>
<td>
<div className="user-identity">
@@ -943,8 +968,7 @@ export default function Users() {
</tr>
))}
</tbody>
</table>
</div>
</ResponsiveTable>
)}
</>
)}