From 29dbba7a2515eb466cadaff9523e630be67542c4 Mon Sep 17 00:00:00 2001 From: "LocalAI [bot]" <139863280+localai-bot@users.noreply.github.com> Date: Fri, 19 Jun 2026 00:56:17 +0200 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 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 * 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 * 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 --------- Signed-off-by: Ettore Di Giacinto Co-authored-by: Ettore Di Giacinto --- .../react-ui/public/locales/de/common.json | 5 ++ .../react-ui/public/locales/en/common.json | 5 ++ .../react-ui/public/locales/es/common.json | 5 ++ .../react-ui/public/locales/id/common.json | 5 ++ .../react-ui/public/locales/it/common.json | 5 ++ .../react-ui/public/locales/ko/common.json | 5 ++ .../react-ui/public/locales/zh-CN/common.json | 5 ++ core/http/react-ui/src/App.css | 34 ++++++++++ .../src/components/ResponsiveTable.jsx | 40 +++++++++++ .../src/components/UnsavedChangesGuard.jsx | 36 ++++++++++ core/http/react-ui/src/pages/AgentCreate.jsx | 10 ++- core/http/react-ui/src/pages/FineTune.jsx | 10 +++ core/http/react-ui/src/pages/Manage.jsx | 13 ++-- core/http/react-ui/src/pages/Models.jsx | 9 +-- core/http/react-ui/src/pages/Nodes.jsx | 13 ++-- core/http/react-ui/src/pages/Settings.jsx | 2 + core/http/react-ui/src/pages/Traces.jsx | 66 ++++++++++++++----- core/http/react-ui/src/pages/Usage.jsx | 10 +-- .../src/pages/Usage/SourceTimeChart.jsx | 3 +- core/http/react-ui/src/pages/Users.jsx | 54 ++++++++++----- 20 files changed, 274 insertions(+), 61 deletions(-) create mode 100644 core/http/react-ui/src/components/ResponsiveTable.jsx create mode 100644 core/http/react-ui/src/components/UnsavedChangesGuard.jsx diff --git a/core/http/react-ui/public/locales/de/common.json b/core/http/react-ui/public/locales/de/common.json index 8a7bea3da..0fe553c9f 100644 --- a/core/http/react-ui/public/locales/de/common.json +++ b/core/http/react-ui/public/locales/de/common.json @@ -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...", diff --git a/core/http/react-ui/public/locales/en/common.json b/core/http/react-ui/public/locales/en/common.json index 7767ebbae..0086aea81 100644 --- a/core/http/react-ui/public/locales/en/common.json +++ b/core/http/react-ui/public/locales/en/common.json @@ -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...", diff --git a/core/http/react-ui/public/locales/es/common.json b/core/http/react-ui/public/locales/es/common.json index 97d8fd62f..f362c2a1a 100644 --- a/core/http/react-ui/public/locales/es/common.json +++ b/core/http/react-ui/public/locales/es/common.json @@ -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...", diff --git a/core/http/react-ui/public/locales/id/common.json b/core/http/react-ui/public/locales/id/common.json index 80dc59c70..711b056df 100644 --- a/core/http/react-ui/public/locales/id/common.json +++ b/core/http/react-ui/public/locales/id/common.json @@ -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...", diff --git a/core/http/react-ui/public/locales/it/common.json b/core/http/react-ui/public/locales/it/common.json index 3b1ced411..ae912a3f1 100644 --- a/core/http/react-ui/public/locales/it/common.json +++ b/core/http/react-ui/public/locales/it/common.json @@ -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...", diff --git a/core/http/react-ui/public/locales/ko/common.json b/core/http/react-ui/public/locales/ko/common.json index e781c986a..3b5c7eed8 100644 --- a/core/http/react-ui/public/locales/ko/common.json +++ b/core/http/react-ui/public/locales/ko/common.json @@ -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": "저장 중...", diff --git a/core/http/react-ui/public/locales/zh-CN/common.json b/core/http/react-ui/public/locales/zh-CN/common.json index 8d5860d85..06578ad87 100644 --- a/core/http/react-ui/public/locales/zh-CN/common.json +++ b/core/http/react-ui/public/locales/zh-CN/common.json @@ -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": "保存中...", diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index 735afe360..81d225080 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -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 + 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; diff --git a/core/http/react-ui/src/components/ResponsiveTable.jsx b/core/http/react-ui/src/components/ResponsiveTable.jsx new file mode 100644 index 000000000..50b194a7f --- /dev/null +++ b/core/http/react-ui/src/components/ResponsiveTable.jsx @@ -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 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 . +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 ( +
+ + {children} +
+
+ ) +} diff --git a/core/http/react-ui/src/components/UnsavedChangesGuard.jsx b/core/http/react-ui/src/components/UnsavedChangesGuard.jsx new file mode 100644 index 000000000..cda5ab1d7 --- /dev/null +++ b/core/http/react-ui/src/components/UnsavedChangesGuard.jsx @@ -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 ( + blocker.proceed?.()} + onCancel={() => blocker.reset?.()} + /> + ) +} diff --git a/core/http/react-ui/src/pages/AgentCreate.jsx b/core/http/react-ui/src/pages/AgentCreate.jsx index 825a590ca..533b45e0f 100644 --- a/core/http/react-ui/src/pages/AgentCreate.jsx +++ b/core/http/react-ui/src/pages/AgentCreate.jsx @@ -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 (
+