mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-19 06:09:07 -04:00
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:
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "저장 중...",
|
||||
|
||||
@@ -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": "保存中...",
|
||||
|
||||
@@ -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;
|
||||
|
||||
40
core/http/react-ui/src/components/ResponsiveTable.jsx
Normal file
40
core/http/react-ui/src/components/ResponsiveTable.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
core/http/react-ui/src/components/UnsavedChangesGuard.jsx
Normal file
36
core/http/react-ui/src/components/UnsavedChangesGuard.jsx
Normal 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?.()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user