diff --git a/core/gallery/upgrade.go b/core/gallery/upgrade.go index aec75852c..dde33300f 100644 --- a/core/gallery/upgrade.go +++ b/core/gallery/upgrade.go @@ -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 diff --git a/core/http/react-ui/src/pages/Backends.jsx b/core/http/react-ui/src/pages/Backends.jsx index 3cbb71872..1644f0692 100644 --- a/core/http/react-ui/src/pages/Backends.jsx +++ b/core/http/react-ui/src/pages/Backends.jsx @@ -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() { + {/* Upgrade Banner */} + {Object.keys(upgrades).length > 0 && ( +