feat(ui): add backend version display and upgrade support

- Add upgrade check/trigger API endpoints to config and api module
- Backends page: version badge, upgrade indicator, upgrade button
- Manage page: version in metadata, context-aware upgrade/reinstall button
- Settings page: auto-upgrade backends toggle
This commit is contained in:
Ettore Di Giacinto
2026-04-11 08:02:49 +00:00
parent b19e60d03a
commit 1e083cd870
5 changed files with 100 additions and 10 deletions

View File

@@ -26,6 +26,7 @@ export default function Backends() {
const [expandedRow, setExpandedRow] = useState(null)
const [confirmDialog, setConfirmDialog] = useState(null)
const [allBackends, setAllBackends] = useState([])
const [upgrades, setUpgrades] = useState({})
const fetchBackends = useCallback(async () => {
try {
@@ -52,6 +53,13 @@ export default function Backends() {
if (!loading) fetchBackends()
}, [operations.length])
// Fetch available upgrades
useEffect(() => {
backendsApi.checkUpgrades()
.then(data => setUpgrades(data || {}))
.catch(() => {})
}, [operations.length])
// Client-side filtering by tag
const filteredBackends = filter
? allBackends.filter(b => {
@@ -114,6 +122,15 @@ export default function Backends() {
})
}
const handleUpgrade = async (id) => {
try {
await backendsApi.upgrade(id)
addToast(`Upgrading ${id}...`, 'info')
} catch (err) {
addToast(`Upgrade failed: ${err.message}`, 'error')
}
}
const handleManualInstall = async (e) => {
e.preventDefault()
if (!manualUri.trim()) { addToast('Please enter a URI', 'warning'); return }
@@ -179,6 +196,14 @@ export default function Backends() {
<div style={{ color: 'var(--color-text-muted)' }}>Installed</div>
</a>
</div>
{Object.keys(upgrades).length > 0 && (
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-warning)' }}>
{Object.keys(upgrades).length}
</div>
<div style={{ color: 'var(--color-text-muted)' }}>Updates</div>
</div>
)}
</div>
<a className="btn btn-secondary btn-sm" href="https://localai.io/docs/getting-started/manual/" target="_blank" rel="noopener noreferrer">
<i className="fas fa-book" /> Docs
@@ -300,6 +325,11 @@ export default function Backends() {
{/* Name */}
<td>
<span style={{ fontWeight: 500 }}>{b.name || b.id}</span>
{b.version && (
<span className="badge" style={{ fontSize: '0.625rem', marginLeft: 4, background: 'var(--color-bg-tertiary)', color: 'var(--color-text-secondary)' }}>
v{b.version}
</span>
)}
</td>
{/* Description */}
@@ -346,9 +376,17 @@ export default function Backends() {
</span>
</div>
) : b.installed ? (
<span className="badge badge-success">
<i className="fas fa-check" style={{ fontSize: '0.5rem', marginRight: 2 }} /> Installed
</span>
<div style={{ display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap' }}>
<span className="badge badge-success">
<i className="fas fa-check" style={{ fontSize: '0.5rem', marginRight: 2 }} /> Installed
</span>
{upgrades[b.name] && (
<span className="badge" style={{ fontSize: '0.625rem', background: '#fef3cd', color: '#856404' }}>
<i className="fas fa-arrow-up" style={{ fontSize: '0.5rem', marginRight: 2 }} />
{upgrades[b.name].available_version ? `v${upgrades[b.name].available_version}` : 'Update'}
</span>
)}
</div>
) : (
<span className="badge" style={{ background: 'var(--color-bg-tertiary)', color: 'var(--color-text-muted)' }}>
<i className="fas fa-circle" style={{ fontSize: '0.5rem', marginRight: 2 }} /> Not Installed
@@ -361,9 +399,15 @@ export default function Backends() {
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', justifyContent: 'flex-end' }} onClick={e => e.stopPropagation()}>
{b.installed ? (
<>
<button className="btn btn-secondary btn-sm" onClick={() => handleInstall(b.name || b.id)} title="Reinstall" disabled={isProcessing}>
<i className={`fas ${isProcessing ? 'fa-spinner fa-spin' : 'fa-rotate'}`} />
</button>
{upgrades[b.name] ? (
<button className="btn btn-primary btn-sm" onClick={() => handleUpgrade(b.name || b.id)} title={`Upgrade to ${upgrades[b.name]?.available_version ? 'v' + upgrades[b.name].available_version : 'latest'}`} disabled={isProcessing}>
<i className={`fas ${isProcessing ? 'fa-spinner fa-spin' : 'fa-arrow-up'}`} />
</button>
) : (
<button className="btn btn-secondary btn-sm" onClick={() => handleInstall(b.name || b.id)} title="Reinstall" disabled={isProcessing}>
<i className={`fas ${isProcessing ? 'fa-spinner fa-spin' : 'fa-rotate'}`} />
</button>
)}
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(b.name || b.id)} title="Delete" disabled={isProcessing}>
<i className="fas fa-trash" />
</button>

View File

@@ -22,6 +22,7 @@ export default function Manage() {
const [backendsLoading, setBackendsLoading] = useState(true)
const [reloading, setReloading] = useState(false)
const [reinstallingBackends, setReinstallingBackends] = useState(new Set())
const [upgrades, setUpgrades] = useState({})
const [confirmDialog, setConfirmDialog] = useState(null)
const [distributedMode, setDistributedMode] = useState(false)
const [togglingModels, setTogglingModels] = useState(new Set())
@@ -62,6 +63,15 @@ export default function Manage() {
nodesApi.list().then(() => setDistributedMode(true)).catch(() => {})
}, [fetchLoadedModels, fetchBackends])
// Fetch available backend upgrades
useEffect(() => {
if (activeTab === 'backends') {
backendsApi.checkUpgrades()
.then(data => setUpgrades(data || {}))
.catch(() => {})
}
}, [activeTab])
const handleStopModel = (modelName) => {
setConfirmDialog({
title: 'Stop Model',
@@ -169,6 +179,22 @@ export default function Manage() {
}
}
const handleUpgradeBackend = async (name) => {
try {
setReinstallingBackends(prev => new Set(prev).add(name))
await backendsApi.upgrade(name)
addToast(`Upgrading ${name}...`, 'info')
} catch (err) {
addToast(`Failed to upgrade: ${err.message}`, 'error')
} finally {
setReinstallingBackends(prev => {
const next = new Set(prev)
next.delete(name)
return next
})
}
}
const handleDeleteBackend = (name) => {
setConfirmDialog({
title: 'Delete Backend',
@@ -471,6 +497,17 @@ export default function Manage() {
For: <span style={{ color: 'var(--color-accent)' }}>{backend.Metadata.meta_backend_for}</span>
</span>
)}
{backend.Metadata?.version && (
<span>
<i className="fas fa-code-branch" style={{ fontSize: '0.5rem', marginRight: 4 }} />
Version: <span style={{ color: 'var(--color-text-primary)' }}>v{backend.Metadata.version}</span>
{upgrades[backend.Name] && (
<span style={{ color: '#856404', marginLeft: 4 }}>
v{upgrades[backend.Name].available_version}
</span>
)}
</span>
)}
{backend.Metadata?.installed_at && (
<span>
<i className="fas fa-calendar" style={{ fontSize: '0.5rem', marginRight: 4 }} />
@@ -485,12 +522,12 @@ export default function Manage() {
{!backend.IsSystem ? (
<>
<button
className="btn btn-secondary btn-sm"
onClick={() => handleReinstallBackend(backend.Name)}
className={`btn ${upgrades[backend.Name] ? 'btn-primary' : 'btn-secondary'} btn-sm`}
onClick={() => upgrades[backend.Name] ? handleUpgradeBackend(backend.Name) : handleReinstallBackend(backend.Name)}
disabled={reinstallingBackends.has(backend.Name)}
title="Reinstall"
title={upgrades[backend.Name] ? `Upgrade to v${upgrades[backend.Name]?.available_version || 'latest'}` : 'Reinstall'}
>
<i className={`fas ${reinstallingBackends.has(backend.Name) ? 'fa-spinner fa-spin' : 'fa-rotate'}`} />
<i className={`fas ${reinstallingBackends.has(backend.Name) ? 'fa-spinner fa-spin' : upgrades[backend.Name] ? 'fa-arrow-up' : 'fa-rotate'}`} />
</button>
<button
className="btn btn-danger btn-sm"

View File

@@ -266,6 +266,9 @@ export default function Settings() {
<SettingRow label="Max Active Backends" description="Maximum models to keep loaded simultaneously (0 = unlimited)">
<input className="input" type="number" style={{ width: 120 }} value={settings.max_active_backends ?? ''} onChange={(e) => update('max_active_backends', parseInt(e.target.value) || 0)} placeholder="0" />
</SettingRow>
<SettingRow label="Auto-upgrade Backends" description="Automatically upgrade backends when new versions are detected">
<Toggle checked={settings.auto_upgrade_backends} onChange={(v) => update('auto_upgrade_backends', v)} />
</SettingRow>
</div>
</div>

View File

@@ -120,6 +120,9 @@ export const backendsApi = {
installExternal: (body) => postJSON(API_CONFIG.endpoints.installExternalBackend, body),
getJob: (uid) => fetchJSON(API_CONFIG.endpoints.backendJob(uid)),
deleteInstalled: (name) => postJSON(API_CONFIG.endpoints.deleteInstalledBackend(name), {}),
checkUpgrades: () => fetchJSON(API_CONFIG.endpoints.backendsUpgrades),
forceCheckUpgrades: () => postJSON(API_CONFIG.endpoints.backendsUpgradesCheck, {}),
upgrade: (name) => postJSON(API_CONFIG.endpoints.upgradeBackend(name), {}),
}
// Chat API (non-streaming)

View File

@@ -23,6 +23,9 @@ export const API_CONFIG = {
installExternalBackend: '/api/backends/install-external',
backendJob: (uid) => `/api/backends/job/${uid}`,
deleteInstalledBackend: (name) => `/api/backends/system/delete/${name}`,
backendsUpgrades: '/api/backends/upgrades',
backendsUpgradesCheck: '/api/backends/upgrades/check',
upgradeBackend: (name) => `/api/backends/upgrade/${name}`,
// Resources
resources: '/api/resources',