Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions core/http/react-ui/public/locales/de/common.json
Original file line number Diff line number Diff line change
@@ -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...",
Expand Down
5 changes: 5 additions & 0 deletions core/http/react-ui/public/locales/en/common.json
Original file line number Diff line number Diff line change
@@ -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...",
Expand Down
5 changes: 5 additions & 0 deletions core/http/react-ui/public/locales/es/common.json
Original file line number Diff line number Diff line change
@@ -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...",
Expand Down
5 changes: 5 additions & 0 deletions core/http/react-ui/public/locales/id/common.json
Original file line number Diff line number Diff line change
@@ -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...",
Expand Down
5 changes: 5 additions & 0 deletions core/http/react-ui/public/locales/it/common.json
Original file line number Diff line number Diff line change
@@ -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...",
Expand Down
5 changes: 5 additions & 0 deletions core/http/react-ui/public/locales/ko/common.json
Original file line number Diff line number Diff line change
@@ -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": "저장 중...",
Expand Down
5 changes: 5 additions & 0 deletions core/http/react-ui/public/locales/zh-CN/common.json
Original file line number Diff line number Diff line change
@@ -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": "保存中...",
Expand Down
34 changes: 34 additions & 0 deletions core/http/react-ui/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
40 changes: 40 additions & 0 deletions core/http/react-ui/src/components/ResponsiveTable.jsx
Original file line number Diff line number Diff line change
@@ -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 changes: 36 additions & 0 deletions core/http/react-ui/src/components/UnsavedChangesGuard.jsx
Original file line number Diff line number Diff line change
@@ -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?.()}
/>
)
}
10 changes: 9 additions & 1 deletion core/http/react-ui/src/pages/AgentCreate.jsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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([])
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions core/http/react-ui/src/pages/FineTune.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down Expand Up @@ -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('')
Expand Down Expand Up @@ -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() }
Expand Down Expand Up @@ -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"
Expand Down
13 changes: 5 additions & 8 deletions core/http/react-ui/src/pages/Manage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -686,8 +686,7 @@ export default function Manage() {
)
})}
</tbody>
</table>
</div>
</ResponsiveTable>
)}
</div>
)
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -987,8 +985,7 @@ export default function Manage() {
)
})}
</tbody>
</table>
</div>
</ResponsiveTable>
</>
)
})()}
Expand Down
9 changes: 3 additions & 6 deletions core/http/react-ui/src/pages/Models.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'


Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -575,9 +574,7 @@ export default function Models() {
)
})}
</tbody>
</table>
</div>
</div>
</ResponsiveTable>
)}

{/* Pagination */}
Expand Down
13 changes: 5 additions & 8 deletions core/http/react-ui/src/pages/Nodes.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -1533,8 +1533,7 @@ export default function Nodes() {
)
})}
</tbody>
</table>
</div>
</ResponsiveTable>
)}
</>}

Expand All @@ -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>
Expand Down Expand Up @@ -1667,8 +1665,7 @@ export default function Nodes() {
)
})}
</tbody>
</table>
</div>
</ResponsiveTable>
)}
</div>
)}
Expand Down
Loading
Loading