feat(ui): surface backend upgrades in the System page

The System page (Manage.jsx) only showed updates as a tiny inline arrow,
so operators routinely missed them. Port the Backend Gallery's upgrade UX
so System speaks the same visual language:

- Yellow banner at the top of the Backends tab when upgrades are pending,
  with an "Upgrade all" button (serial fan-out, matches the gallery) and a
  "Updates only" filter toggle.
- Warning pill (↑ N) next to the tab label so the count is glanceable even
  when the banner is scrolled out of view.
- Per-row labeled "Upgrade to vX.Y" button (replaces the icon-only button
  that silently flipped semantics between Reinstall and Upgrade), plus an
  "Update available" badge in the new Version column.
- New columns: Version (with upgrade + drift chips), Nodes (per-node
  attribution badges for distributed mode, degrading to a compact
  "on N nodes · M offline" chip above three nodes), Installed (relative
  time).
- System backends render a "Protected" chip instead of a bare "—" so rows
  still align and the reason is obvious.
- Delete uses the softer btn-danger-ghost so rows don't scream red; the
  ConfirmDialog still owns the "are you sure".

The upgrade checker also needed the same per-worker fix as the previous
commit: NewUpgradeChecker now takes a BackendManager getter so its
periodic runs call the distributed CheckUpgrades (which asks workers)
instead of the empty frontend filesystem. Without this the /api/backends/
upgrades endpoint stayed empty in distributed mode even with the protocol
change in place.

New CSS primitives — .upgrade-banner, .tab-pill, .badge-row, .cell-stack,
.cell-mono, .cell-muted, .row-actions, .btn-danger-ghost — all live in
App.css so other pages can adopt them without duplicating styles.
This commit is contained in:
Ettore Di Giacinto
2026-04-19 08:14:49 +00:00
parent 1f43762655
commit 1b3c951c85
5 changed files with 385 additions and 83 deletions

View File

@@ -235,7 +235,12 @@ func New(opts ...config.AppOption) (*Application, error) {
// In distributed mode, uses PostgreSQL advisory lock so only one frontend
// instance runs periodic checks (avoids duplicate upgrades across replicas).
if len(options.BackendGalleries) > 0 {
uc := NewUpgradeChecker(options, application.ModelLoader(), application.distributedDB())
// Pass a lazy getter for the backend manager so the checker always
// uses the active one — DistributedBackendManager is swapped in above
// and asks workers for their installed backends, which is what
// upgrade detection needs in distributed mode.
bmFn := func() galleryop.BackendManager { return application.GalleryService().BackendManager() }
uc := NewUpgradeChecker(options, application.ModelLoader(), application.distributedDB(), bmFn)
application.upgradeChecker = uc
go uc.Run(options.Context)
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/services/advisorylock"
"github.com/mudler/LocalAI/core/services/galleryop"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/system"
"github.com/mudler/xlog"
@@ -26,6 +27,12 @@ type UpgradeChecker struct {
galleries []config.Gallery
systemState *system.SystemState
db *gorm.DB // non-nil in distributed mode
// backendManagerFn lazily returns the current backend manager (may be
// swapped from Local to Distributed after startup). Pulled through each
// check so the UpgradeChecker uses whichever is active. In distributed
// mode this ensures CheckUpgrades asks workers instead of the (empty)
// frontend filesystem — fixing the bug where upgrades never surfaced.
backendManagerFn func() galleryop.BackendManager
checkInterval time.Duration
stop chan struct{}
@@ -40,18 +47,22 @@ type UpgradeChecker struct {
// NewUpgradeChecker creates a new UpgradeChecker service.
// Pass db=nil for standalone mode, or a *gorm.DB for distributed mode
// (uses advisory locks so only one instance runs periodic checks).
func NewUpgradeChecker(appConfig *config.ApplicationConfig, ml *model.ModelLoader, db *gorm.DB) *UpgradeChecker {
// backendManagerFn is optional; when set, CheckUpgrades is routed through
// the active backend manager — required in distributed mode so the check
// aggregates from workers rather than the empty frontend filesystem.
func NewUpgradeChecker(appConfig *config.ApplicationConfig, ml *model.ModelLoader, db *gorm.DB, backendManagerFn func() galleryop.BackendManager) *UpgradeChecker {
return &UpgradeChecker{
appConfig: appConfig,
modelLoader: ml,
galleries: appConfig.BackendGalleries,
systemState: appConfig.SystemState,
db: db,
checkInterval: 6 * time.Hour,
stop: make(chan struct{}),
done: make(chan struct{}),
triggerCh: make(chan struct{}, 1),
lastUpgrades: make(map[string]gallery.UpgradeInfo),
appConfig: appConfig,
modelLoader: ml,
galleries: appConfig.BackendGalleries,
systemState: appConfig.SystemState,
db: db,
backendManagerFn: backendManagerFn,
checkInterval: 6 * time.Hour,
stop: make(chan struct{}),
done: make(chan struct{}),
triggerCh: make(chan struct{}, 1),
lastUpgrades: make(map[string]gallery.UpgradeInfo),
}
}
@@ -64,13 +75,16 @@ func NewUpgradeChecker(appConfig *config.ApplicationConfig, ml *model.ModelLoade
func (uc *UpgradeChecker) Run(ctx context.Context) {
defer close(uc.done)
// Initial delay: don't slow down startup
// Initial delay: don't slow down startup. Short enough that operators
// don't stare at an empty upgrade banner for long; long enough that
// workers have registered and reported their installed backends.
initialDelay := 10 * time.Second
select {
case <-ctx.Done():
return
case <-uc.stop:
return
case <-time.After(30 * time.Second):
case <-time.After(initialDelay):
}
// First check always runs locally (to warm the cache on this instance)
@@ -144,7 +158,18 @@ func (uc *UpgradeChecker) GetAvailableUpgrades() map[string]gallery.UpgradeInfo
}
func (uc *UpgradeChecker) runCheck(ctx context.Context) {
upgrades, err := gallery.CheckBackendUpgrades(ctx, uc.galleries, uc.systemState)
var (
upgrades map[string]gallery.UpgradeInfo
err error
)
if uc.backendManagerFn != nil {
if bm := uc.backendManagerFn(); bm != nil {
upgrades, err = bm.CheckUpgrades(ctx)
}
}
if upgrades == nil && err == nil {
upgrades, err = gallery.CheckBackendUpgrades(ctx, uc.galleries, uc.systemState)
}
uc.mu.Lock()
uc.lastCheckTime = time.Now()

View File

@@ -1529,6 +1529,123 @@ select.input {
background: var(--color-warning-light);
color: var(--color-warning);
}
.badge-accent {
background: var(--color-accent-light);
color: var(--color-accent);
}
/* Horizontal row of badges used inside table cells — consistent spacing so
cells line up regardless of how many badges are present. */
.badge-row {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
/* Vertically stacked cell content (e.g. version + update chip + drift chip).
Keeps rows readable at scale without inline style={{...}} everywhere. */
.cell-stack {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
}
.cell-mono {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: var(--text-xs);
color: var(--color-text-primary);
}
.cell-muted {
color: var(--color-text-muted);
font-size: var(--text-xs);
}
.cell-subtle {
color: var(--color-text-muted);
font-size: var(--text-xs);
font-weight: 400;
margin-left: 8px;
}
.cell-name {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
font-weight: 500;
}
.cell-name > i {
color: var(--color-accent);
font-size: var(--text-xs);
}
.row-actions {
display: flex;
gap: var(--spacing-xs);
justify-content: flex-end;
align-items: center;
}
/* Softer delete button for dense tables — the destructive confirm dialog
already owns the "are you sure" affordance, so the button itself doesn't
need to scream. Keeps the delete red readable without dominating rows. */
.btn.btn-danger-ghost {
background: transparent;
color: var(--color-error);
border-color: transparent;
}
.btn.btn-danger-ghost:hover:not(:disabled) {
background: var(--color-error-light);
color: var(--color-error);
border-color: var(--color-error-light);
}
/* Small count pill used inside tabs ("(3) ↑ 2") so update counts are
glanceable without extra rows of UI. */
.tab-pill {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: 6px;
padding: 1px 6px;
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 600;
line-height: 1.4;
}
.tab-pill--warning {
background: var(--color-warning-light);
color: var(--color-warning);
}
/* Upgrade banner — the yellow strip operators see when updates are available.
Mirrors the gallery so both pages speak the same visual language. */
.upgrade-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-md);
padding: var(--spacing-sm) var(--spacing-md);
margin-bottom: var(--spacing-md);
background: var(--color-warning-light);
border: 1px solid var(--color-warning);
border-radius: var(--radius-md);
color: var(--color-warning);
}
.upgrade-banner__text {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
font-weight: 500;
font-size: var(--text-sm);
}
.upgrade-banner__actions {
display: inline-flex;
gap: var(--spacing-xs);
align-items: center;
}
/* Tabs */
.tabs {

View File

@@ -11,6 +11,22 @@ const TABS = [
{ key: 'backends', label: 'Backends', icon: 'fa-server' },
]
// formatInstalledAt renders an installed_at timestamp as a short relative/abs
// string suitable for dense tables. Returns the raw value if parsing fails so
// we never display "Invalid Date".
function formatInstalledAt(value) {
if (!value) return '—'
const d = new Date(value)
if (isNaN(d.getTime())) return value
const now = Date.now()
const diffMin = Math.floor((now - d.getTime()) / 60000)
if (diffMin < 1) return 'just now'
if (diffMin < 60) return `${diffMin}m ago`
if (diffMin < 60 * 24) return `${Math.floor(diffMin / 60)}h ago`
if (diffMin < 60 * 24 * 30) return `${Math.floor(diffMin / (60 * 24))}d ago`
return d.toISOString().slice(0, 10)
}
export default function Manage() {
const { addToast } = useOutletContext()
const navigate = useNavigate()
@@ -196,6 +212,29 @@ export default function Manage() {
}
}
const [upgradingAll, setUpgradingAll] = useState(false)
const [showOnlyUpgradable, setShowOnlyUpgradable] = useState(false)
const handleUpgradeAll = async () => {
const names = Object.keys(upgrades)
if (names.length === 0) return
setUpgradingAll(true)
try {
// Serial upgrade — matches the gallery's Upgrade All behavior.
// Each backend upgrade is itself a cluster-wide fan-out, so parallel
// calls would multiply load on every worker.
for (const name of names) {
try {
await backendsApi.upgrade(name)
} catch (err) {
addToast(`Upgrade failed for ${name}: ${err.message}`, 'error')
}
}
addToast(`Upgrade started for ${names.length} backend${names.length === 1 ? '' : 's'}`, 'info')
} finally {
setUpgradingAll(false)
}
}
const handleDeleteBackend = (name) => {
setConfirmDialog({
title: 'Delete Backend',
@@ -227,18 +266,26 @@ export default function Manage() {
{/* Tabs */}
<div className="tabs" style={{ marginTop: 'var(--spacing-lg)', marginBottom: 'var(--spacing-md)' }}>
{TABS.map(t => (
<button
key={t.key}
className={`tab ${activeTab === t.key ? 'tab-active' : ''}`}
onClick={() => handleTabChange(t.key)}
>
<i className={`fas ${t.icon}`} style={{ marginRight: 6 }} />
{t.label}
{t.key === 'models' && !modelsLoading && ` (${models.length})`}
{t.key === 'backends' && !backendsLoading && ` (${backends.length})`}
</button>
))}
{TABS.map(t => {
const upgradeCount = t.key === 'backends' ? Object.keys(upgrades).length : 0
return (
<button
key={t.key}
className={`tab ${activeTab === t.key ? 'tab-active' : ''}`}
onClick={() => handleTabChange(t.key)}
>
<i className={`fas ${t.icon}`} style={{ marginRight: 6 }} />
{t.label}
{t.key === 'models' && !modelsLoading && ` (${models.length})`}
{t.key === 'backends' && !backendsLoading && ` (${backends.length})`}
{upgradeCount > 0 && (
<span className="tab-pill tab-pill--warning" title={`${upgradeCount} update${upgradeCount === 1 ? '' : 's'} available`}>
<i className="fas fa-arrow-up" /> {upgradeCount}
</span>
)}
</button>
)
})}
</div>
{/* Models Tab */}
@@ -399,6 +446,36 @@ export default function Manage() {
{/* Backends Tab */}
{activeTab === 'backends' && (
<div>
{/* Upgrade banner — mirrors the gallery so operators can't miss updates */}
{!backendsLoading && Object.keys(upgrades).length > 0 && (
<div className="upgrade-banner">
<div className="upgrade-banner__text">
<i className="fas fa-arrow-up" />
<span>
{Object.keys(upgrades).length} backend{Object.keys(upgrades).length === 1 ? ' has' : 's have'} updates available
</span>
</div>
<div className="upgrade-banner__actions">
<button
className="btn btn-ghost btn-sm"
onClick={() => setShowOnlyUpgradable(v => !v)}
title={showOnlyUpgradable ? 'Show all backends' : 'Show only backends with updates'}
>
<i className={`fas ${showOnlyUpgradable ? 'fa-filter-circle-xmark' : 'fa-filter'}`} />
{showOnlyUpgradable ? ' Show all' : ' Updates only'}
</button>
<button
className="btn btn-primary btn-sm"
onClick={handleUpgradeAll}
disabled={upgradingAll}
>
<i className={`fas ${upgradingAll ? 'fa-spinner fa-spin' : 'fa-arrow-up'}`} />
{upgradingAll ? ' Upgrading...' : ' Upgrade all'}
</button>
</div>
</div>
)}
{backendsLoading ? (
<div style={{ textAlign: 'center', padding: 'var(--spacing-md)', color: 'var(--color-text-muted)', fontSize: '0.875rem' }}>
Loading backends...
@@ -419,109 +496,177 @@ export default function Manage() {
</a>
</div>
</div>
) : (
) : (() => {
const visibleBackends = showOnlyUpgradable
? backends.filter(b => upgrades[b.Name])
: backends
if (visibleBackends.length === 0) {
return (
<div className="empty-state">
<i className="fas fa-filter" />
<p>No backends match the current filter.</p>
<button className="btn btn-ghost btn-sm" onClick={() => setShowOnlyUpgradable(false)}>Clear filter</button>
</div>
)
}
return (
<div className="table-container">
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Metadata</th>
<th>Version</th>
{distributedMode && <th>Nodes</th>}
<th>Installed</th>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{backends.map((backend, i) => (
{visibleBackends.map((backend, i) => {
const upgradeInfo = upgrades[backend.Name]
const hasDrift = upgradeInfo?.node_drift?.length > 0
const nodes = backend.Nodes || backend.nodes || []
return (
<tr key={backend.Name || i}>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
<i className="fas fa-cog" style={{ color: 'var(--color-accent)', fontSize: '0.75rem' }} />
<span style={{ fontWeight: 500 }}>{backend.Name}</span>
<div className="cell-name">
<i className="fas fa-cog" />
<span>{backend.Name}</span>
{backend.Metadata?.alias && (
<span className="cell-subtle">alias: {backend.Metadata.alias}</span>
)}
{backend.Metadata?.meta_backend_for && (
<span className="cell-subtle">for: {backend.Metadata.meta_backend_for}</span>
)}
</div>
</td>
<td>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
<div className="badge-row">
{backend.IsSystem ? (
<span className="badge badge-info" style={{ fontSize: '0.625rem' }}>
<i className="fas fa-shield-alt" style={{ fontSize: '0.5rem', marginRight: 2 }} />System
<span className="badge badge-info">
<i className="fas fa-shield-alt" /> System
</span>
) : (
<span className="badge badge-success" style={{ fontSize: '0.625rem' }}>
<i className="fas fa-download" style={{ fontSize: '0.5rem', marginRight: 2 }} />User
<span className="badge badge-success">
<i className="fas fa-download" /> User
</span>
)}
{backend.IsMeta && (
<span className="badge" style={{ background: 'var(--color-accent-light)', color: 'var(--color-accent)', fontSize: '0.625rem' }}>
<i className="fas fa-layer-group" style={{ fontSize: '0.5rem', marginRight: 2 }} />Meta
<span className="badge badge-accent">
<i className="fas fa-layer-group" /> Meta
</span>
)}
</div>
</td>
<td>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, fontSize: '0.75rem', color: 'var(--color-text-secondary)' }}>
{backend.Metadata?.alias && (
<span>
<i className="fas fa-tag" style={{ fontSize: '0.5rem', marginRight: 4 }} />
Alias: <span style={{ color: 'var(--color-text-primary)' }}>{backend.Metadata.alias}</span>
<div className="cell-stack">
{backend.Metadata?.version ? (
<span className="cell-mono">v{backend.Metadata.version}</span>
) : (
<span className="cell-muted"></span>
)}
{upgradeInfo && (
<span className="badge badge-warning" title={upgradeInfo.available_version ? `Upgrade to v${upgradeInfo.available_version}` : 'Update available'}>
<i className="fas fa-arrow-up" />
{upgradeInfo.available_version ? ` v${upgradeInfo.available_version}` : ' Update available'}
</span>
)}
{backend.Metadata?.meta_backend_for && (
<span>
<i className="fas fa-link" style={{ fontSize: '0.5rem', marginRight: 4 }} />
For: <span style={{ color: 'var(--color-accent)' }}>{backend.Metadata.meta_backend_for}</span>
{hasDrift && (
<span
className="badge badge-warning"
title={`Drift: ${upgradeInfo.node_drift.map(d => `${d.node_name}${d.version ? ' v' + d.version : ''}`).join(', ')}`}
>
<i className="fas fa-code-branch" />
{' '}Drift: {upgradeInfo.node_drift.length} node{upgradeInfo.node_drift.length === 1 ? '' : 's'}
</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}
</div>
</td>
{distributedMode && (
<td>
{nodes.length === 0 ? (
<span className="cell-muted"></span>
) : nodes.length <= 3 ? (
<div className="badge-row">
{nodes.map(n => (
<span
key={n.node_id || n.NodeID}
className={`badge ${(n.node_status || n.NodeStatus) === 'healthy' ? 'badge-success' : 'badge-warning'}`}
title={`${n.node_name || n.NodeName}${n.node_status || n.NodeStatus}`}
>
<i className="fas fa-server" /> {n.node_name || n.NodeName}
</span>
)}
</span>
)}
{backend.Metadata?.installed_at && (
<span>
<i className="fas fa-calendar" style={{ fontSize: '0.5rem', marginRight: 4 }} />
{backend.Metadata.installed_at}
</span>
)}
{!backend.Metadata?.alias && !backend.Metadata?.meta_backend_for && !backend.Metadata?.installed_at && '—'}
</div>
))}
</div>
) : (() => {
const total = nodes.length
const offline = nodes.filter(n => {
const s = n.node_status || n.NodeStatus
return s !== 'healthy' && s !== 'draining'
}).length
return (
<span
className={`badge ${offline > 0 ? 'badge-warning' : 'badge-info'}`}
title={nodes.map(n => `${n.node_name || n.NodeName} (${n.node_status || n.NodeStatus})`).join('\n')}
>
<i className="fas fa-server" /> on {total} nodes{offline > 0 ? ` · ${offline} offline` : ''}
</span>
)
})()}
</td>
)}
<td>
<span className="cell-muted cell-mono">
{backend.Metadata?.installed_at ? formatInstalledAt(backend.Metadata.installed_at) : '—'}
</span>
</td>
<td>
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', justifyContent: 'flex-end' }}>
{!backend.IsSystem ? (
<div className="row-actions">
{backend.IsSystem ? (
<span className="badge" title="System backends are managed outside the gallery">
<i className="fas fa-lock" /> Protected
</span>
) : (
<>
{upgradeInfo ? (
<button
className="btn btn-primary btn-sm"
onClick={() => handleUpgradeBackend(backend.Name)}
disabled={reinstallingBackends.has(backend.Name)}
>
<i className={`fas ${reinstallingBackends.has(backend.Name) ? 'fa-spinner fa-spin' : 'fa-arrow-up'}`} />
{' '}Upgrade{upgradeInfo.available_version ? ` to v${upgradeInfo.available_version}` : ''}
</button>
) : (
<button
className="btn btn-secondary btn-sm"
onClick={() => handleReinstallBackend(backend.Name)}
disabled={reinstallingBackends.has(backend.Name)}
>
<i className={`fas ${reinstallingBackends.has(backend.Name) ? 'fa-spinner fa-spin' : 'fa-rotate'}`} />
{' '}Reinstall
</button>
)}
<button
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={upgrades[backend.Name] ? `Upgrade to v${upgrades[backend.Name]?.available_version || 'latest'}` : 'Reinstall'}
>
<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"
className="btn btn-danger-ghost btn-sm"
onClick={() => handleDeleteBackend(backend.Name)}
title="Delete"
title="Delete backend (removes from all nodes)"
>
<i className="fas fa-trash" />
</button>
</>
) : (
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}></span>
)}
</div>
</td>
</tr>
))}
)
})}
</tbody>
</table>
</div>
)}
)
})()}
</div>
)}

View File

@@ -57,6 +57,16 @@ func (g *GalleryService) SetBackendManager(b BackendManager) {
g.backendManager = b
}
// BackendManager returns the current backend manager. Callers like the
// periodic upgrade checker need this so they run CheckUpgrades through the
// distributed implementation (which asks workers) instead of the frontend's
// local filesystem — the latter is always empty in distributed deployments.
func (g *GalleryService) BackendManager() BackendManager {
g.Lock()
defer g.Unlock()
return g.backendManager
}
// SetNATSClient sets the NATS client for distributed progress publishing.
func (g *GalleryService) SetNATSClient(nc messaging.Publisher) {
g.Lock()