feat: upgrade banner with Upgrade All button, detect pre-existing backends

- Add upgrade banner on Backends page showing count and Upgrade All button
- Fix upgrade detection for backends installed before version tracking:
  flag as upgradeable when gallery has a version but installed has none
- Fix OCI digest check to flag backends with no stored digest as upgradeable
This commit is contained in:
Ettore Di Giacinto
2026-04-11 22:11:03 +00:00
parent 6dd37a95c4
commit 5fe87cb0d5
2 changed files with 115 additions and 16 deletions

View File

@@ -72,14 +72,28 @@ func CheckBackendUpgrades(ctx context.Context, galleries []config.Gallery, syste
continue
}
// If either version is empty, fall back to OCI digest comparison
if installed.Metadata.Digest != "" && downloader.URI(galleryEntry.URI).LooksLikeOCI() {
// Gallery has a version but installed doesn't — this happens for backends
// installed before version tracking was added. Flag as upgradeable so
// users can re-install to pick up version metadata.
if galleryVersion != "" && installedVersion == "" {
result[installed.Metadata.Name] = UpgradeInfo{
BackendName: installed.Metadata.Name,
InstalledVersion: "",
AvailableVersion: galleryVersion,
}
continue
}
// Fall back to OCI digest comparison when versions are unavailable
if downloader.URI(galleryEntry.URI).LooksLikeOCI() {
remoteDigest, err := oci.GetImageDigest(galleryEntry.URI, "", nil, nil)
if err != nil {
xlog.Warn("Failed to get remote OCI digest for upgrade check", "backend", installed.Metadata.Name, "error", err)
continue
}
if remoteDigest != installed.Metadata.Digest {
// If we have a stored digest, compare; otherwise any remote digest
// means we can't confirm we're up to date — flag as upgradeable
if installed.Metadata.Digest == "" || remoteDigest != installed.Metadata.Digest {
result[installed.Metadata.Name] = UpgradeInfo{
BackendName: installed.Metadata.Name,
InstalledDigest: installed.Metadata.Digest,
@@ -87,7 +101,7 @@ func CheckBackendUpgrades(ctx context.Context, galleries []config.Gallery, syste
}
}
}
// No version info and no digest to compare — skip
// No version info and non-OCI URI — cannot determine, skip
}
return result, nil

View File

@@ -7,6 +7,7 @@ import { useOperations } from '../hooks/useOperations'
import LoadingSpinner from '../components/LoadingSpinner'
import { renderMarkdown } from '../utils/markdown'
import ConfirmDialog from '../components/ConfirmDialog'
import Toggle from '../components/Toggle'
export default function Backends() {
const { addToast } = useOutletContext()
@@ -27,6 +28,10 @@ export default function Backends() {
const [confirmDialog, setConfirmDialog] = useState(null)
const [allBackends, setAllBackends] = useState([])
const [upgrades, setUpgrades] = useState({})
const [upgradingAll, setUpgradingAll] = useState(false)
const [showAllBackends, setShowAllBackends] = useState(false)
const [showDevelopment, setShowDevelopment] = useState(false)
const [preferDevLoaded, setPreferDevLoaded] = useState(false)
const fetchBackends = useCallback(async () => {
try {
@@ -37,6 +42,11 @@ export default function Backends() {
const list = Array.isArray(data?.backends) ? data.backends : Array.isArray(data) ? data : []
setAllBackends(list)
setInstalledCount(list.filter(b => b.installed).length)
// On first load, use server preference for development toggle
if (!preferDevLoaded && data?.preferDevelopmentBackends) {
setShowDevelopment(true)
setPreferDevLoaded(true)
}
} catch (err) {
addToast(`Failed to load backends: ${err.message}`, 'error')
} finally {
@@ -60,17 +70,33 @@ export default function Backends() {
.catch(() => {})
}, [operations.length])
// Client-side filtering by tag
const filteredBackends = filter
? allBackends.filter(b => {
// Client-side filtering by meta/development toggles and tag
const filteredBackends = (() => {
let result = allBackends
// Show only meta backends unless "Show all" is toggled
if (!showAllBackends) {
result = result.filter(b => b.isMeta)
}
// Hide development backends unless toggled on
if (!showDevelopment) {
result = result.filter(b => !b.isDevelopment)
}
// Apply tag filter
if (filter) {
result = result.filter(b => {
const tags = (b.tags || []).map(t => t.toLowerCase())
const name = (b.name || '').toLowerCase()
const desc = (b.description || '').toLowerCase()
const f = filter.toLowerCase()
// Match against tags, or name/description containing the filter keyword
return tags.some(t => t.includes(f)) || name.includes(f) || desc.includes(f)
})
: allBackends
}
return result
})()
// Client-side pagination
const ITEMS_PER_PAGE = 21
@@ -131,6 +157,22 @@ export default function Backends() {
}
}
const handleUpgradeAll = async () => {
const names = Object.keys(upgrades)
if (names.length === 0) return
setUpgradingAll(true)
try {
for (const name of names) {
await backendsApi.upgrade(name)
}
addToast(`Upgrading ${names.length} backend${names.length > 1 ? 's' : ''}...`, 'info')
} catch (err) {
addToast(`Upgrade failed: ${err.message}`, 'error')
} finally {
setUpgradingAll(false)
}
}
const handleManualInstall = async (e) => {
e.preventDefault()
if (!manualUri.trim()) { addToast('Please enter a URI', 'warning'); return }
@@ -154,6 +196,9 @@ export default function Backends() {
return operations.find(op => op.name === backend.name || op.name === backend.id) || null
}
const handleToggleAllBackends = () => { setShowAllBackends(v => !v); setPage(1) }
const handleToggleDev = () => { setShowDevelopment(v => !v); setPage(1) }
const FILTERS = [
{ key: '', label: 'All', icon: 'fa-layer-group' },
{ key: 'llm', label: 'LLM', icon: 'fa-brain' },
@@ -211,6 +256,33 @@ export default function Backends() {
</div>
</div>
{/* Upgrade Banner */}
{Object.keys(upgrades).length > 0 && (
<div className="card" style={{
marginBottom: 'var(--spacing-md)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: 'var(--spacing-sm) var(--spacing-md)',
background: 'var(--color-warning-bg, #fef3cd)',
border: '1px solid var(--color-warning, #ffc107)',
borderRadius: 'var(--radius-md)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
<i className="fas fa-arrow-up" style={{ color: 'var(--color-warning, #856404)' }} />
<span style={{ color: 'var(--color-warning, #856404)', fontWeight: 500, fontSize: '0.875rem' }}>
{Object.keys(upgrades).length} backend{Object.keys(upgrades).length > 1 ? 's have' : ' has'} updates available
</span>
</div>
<button
className="btn btn-primary btn-sm"
onClick={handleUpgradeAll}
disabled={upgradingAll}
>
<i className={`fas ${upgradingAll ? 'fa-spinner fa-spin' : 'fa-arrow-up'}`} style={{ marginRight: 4 }} />
Upgrade All
</button>
</div>
)}
{/* Manual Install */}
<div style={{ marginBottom: 'var(--spacing-md)' }}>
<button className="btn btn-secondary btn-sm" onClick={() => setShowManualInstall(!showManualInstall)}>
@@ -252,17 +324,30 @@ export default function Backends() {
</div>
</div>
<div className="filter-bar" style={{ marginBottom: 'var(--spacing-md)' }}>
{FILTERS.map(f => (
<button
key={f.key}
className={`filter-btn ${filter === f.key ? 'active' : ''}`}
onClick={() => { setFilter(f.key); setPage(1) }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-md)', marginBottom: 'var(--spacing-md)', flexWrap: 'wrap' }}>
<div className="filter-bar" style={{ margin: 0, flex: 1 }}>
{FILTERS.map(f => (
<button
key={f.key}
className={`filter-btn ${filter === f.key ? 'active' : ''}`}
onClick={() => { setFilter(f.key); setPage(1) }}
>
<i className={`fas ${f.icon}`} style={{ marginRight: 4 }} />
{f.label}
</button>
))}
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-md)', alignItems: 'center', borderLeft: '1px solid var(--color-border-subtle)', paddingLeft: 'var(--spacing-md)' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)', fontSize: '0.75rem', color: 'var(--color-text-secondary)', cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' }}>
<Toggle checked={showAllBackends} onChange={handleToggleAllBackends} />
Show all
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)', fontSize: '0.75rem', color: 'var(--color-text-secondary)', cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' }}>
<Toggle checked={showDevelopment} onChange={handleToggleDev} />
Development
</label>
</div>
</div>
{/* Table */}