feat(ux): backend management enhancement (#9325)

* feat: add PreferDevelopmentBackends setting, expose isMeta/isDevelopment in API

- Add PreferDevelopmentBackends config field, CLI flag, runtime setting
- Add IsDevelopment() method to GalleryBackend
- Use AvailableBackendsUnfiltered in UI API to show all backends
- Expose isMeta, isDevelopment, preferDevelopmentBackends in backend API response

* 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 authored and GitHub committed 2026-04-12 00:35:22 +02:00
1 parent 6fbda277c5
commit 2865f0f8d3
11 files changed
+378 -101

No files matched your search

+3
View File
@@ -338,6 +338,9 @@ func readRuntimeSettingsJson(startupAppConfig config.ApplicationConfig) fileHand
if settings.AutoUpgradeBackends != nil {
appConfig.AutoUpgradeBackends = *settings.AutoUpgradeBackends
}
if settings.PreferDevelopmentBackends != nil {
appConfig.PreferDevelopmentBackends = *settings.PreferDevelopmentBackends
}
if settings.ApiKeys != nil {
// API keys from env vars (startup) should be kept, runtime settings keys replace all runtime keys
// If runtime_settings.json specifies ApiKeys (even if empty), it replaces all runtime keys
+7 -2
View File
@@ -47,8 +47,9 @@ type RunCMD struct {
BackendImagesReleaseTag string `env:"LOCALAI_BACKEND_IMAGES_RELEASE_TAG,BACKEND_IMAGES_RELEASE_TAG" help:"Fallback release tag for backend images" group:"backends" default:"latest"`
BackendImagesBranchTag string `env:"LOCALAI_BACKEND_IMAGES_BRANCH_TAG,BACKEND_IMAGES_BRANCH_TAG" help:"Fallback branch tag for backend images" group:"backends" default:"master"`
BackendDevSuffix string `env:"LOCALAI_BACKEND_DEV_SUFFIX,BACKEND_DEV_SUFFIX" help:"Development suffix for backend images" group:"backends" default:"development"`
AutoUpgradeBackends bool `env:"LOCALAI_AUTO_UPGRADE_BACKENDS,AUTO_UPGRADE_BACKENDS" help:"Automatically upgrade backends when new versions are detected" group:"backends" default:"false"`
PreloadModels string `env:"LOCALAI_PRELOAD_MODELS,PRELOAD_MODELS" help:"A List of models to apply in JSON at start" group:"models"`
AutoUpgradeBackends bool `env:"LOCALAI_AUTO_UPGRADE_BACKENDS,AUTO_UPGRADE_BACKENDS" help:"Automatically upgrade backends when new versions are detected" group:"backends" default:"false"`
PreferDevelopmentBackends bool `env:"LOCALAI_PREFER_DEV_BACKENDS,PREFER_DEV_BACKENDS" help:"Prefer development backend versions (shows development backends by default in UI)" group:"backends" default:"false"`
PreloadModels string `env:"LOCALAI_PRELOAD_MODELS,PRELOAD_MODELS" help:"A List of models to apply in JSON at start" group:"models"`
Models []string `env:"LOCALAI_MODELS,MODELS" help:"A List of model configuration URLs to load" group:"models"`
PreloadModelsConfig string `env:"LOCALAI_PRELOAD_MODELS_CONFIG,PRELOAD_MODELS_CONFIG" help:"A List of models to apply at startup. Path to a YAML config file" group:"models"`
@@ -495,6 +496,10 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
opts = append(opts, config.WithAutoUpgradeBackends(r.AutoUpgradeBackends))
}
if r.PreferDevelopmentBackends {
opts = append(opts, config.WithPreferDevelopmentBackends(r.PreferDevelopmentBackends))
}
if r.PreloadBackendOnly {
_, err := application.New(opts...)
return err
+10
View File
@@ -58,6 +58,7 @@ type ApplicationConfig struct {
AutoloadGalleries, AutoloadBackendGalleries bool
AutoUpgradeBackends bool
PreferDevelopmentBackends bool
SingleBackend bool // Deprecated: use MaxActiveBackends = 1 instead
MaxActiveBackends int // Maximum number of active backends (0 = unlimited, 1 = single backend mode)
@@ -395,6 +396,10 @@ func WithAutoUpgradeBackends(v bool) AppOption {
return func(o *ApplicationConfig) { o.AutoUpgradeBackends = v }
}
func WithPreferDevelopmentBackends(v bool) AppOption {
return func(o *ApplicationConfig) { o.PreferDevelopmentBackends = v }
}
var EnableFederated = func(o *ApplicationConfig) {
o.Federated = true
}
@@ -868,6 +873,7 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
autoloadGalleries := o.AutoloadGalleries
autoloadBackendGalleries := o.AutoloadBackendGalleries
autoUpgradeBackends := o.AutoUpgradeBackends
preferDevelopmentBackends := o.PreferDevelopmentBackends
apiKeys := o.ApiKeys
agentJobRetentionDays := o.AgentJobRetentionDays
@@ -942,6 +948,7 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
AutoloadGalleries: &autoloadGalleries,
AutoloadBackendGalleries: &autoloadBackendGalleries,
AutoUpgradeBackends: &autoUpgradeBackends,
PreferDevelopmentBackends: &preferDevelopmentBackends,
ApiKeys: &apiKeys,
AgentJobRetentionDays: &agentJobRetentionDays,
OpenResponsesStoreTTL: &openResponsesStoreTTL,
@@ -1093,6 +1100,9 @@ func (o *ApplicationConfig) ApplyRuntimeSettings(settings *RuntimeSettings) (req
if settings.AutoUpgradeBackends != nil {
o.AutoUpgradeBackends = *settings.AutoUpgradeBackends
}
if settings.PreferDevelopmentBackends != nil {
o.PreferDevelopmentBackends = *settings.PreferDevelopmentBackends
}
if settings.AgentJobRetentionDays != nil {
o.AgentJobRetentionDays = *settings.AgentJobRetentionDays
}
+2 -1
View File
@@ -20,7 +20,8 @@ type RuntimeSettings struct {
// Backend management
SingleBackend *bool `json:"single_backend,omitempty"` // Deprecated: use MaxActiveBackends = 1 instead
MaxActiveBackends *int `json:"max_active_backends,omitempty"` // Maximum number of active backends (0 = unlimited, 1 = single backend mode)
AutoUpgradeBackends *bool `json:"auto_upgrade_backends,omitempty"` // Automatically upgrade backends when new versions are detected
AutoUpgradeBackends *bool `json:"auto_upgrade_backends,omitempty"` // Automatically upgrade backends when new versions are detected
PreferDevelopmentBackends *bool `json:"prefer_development_backends,omitempty"` // Prefer development backend versions by default in UI
// Memory Reclaimer settings (works with GPU if available, otherwise RAM)
MemoryReclaimerEnabled *bool `json:"memory_reclaimer_enabled,omitempty"` // Enable memory threshold monitoring
MemoryReclaimerThreshold *float64 `json:"memory_reclaimer_threshold,omitempty"` // Threshold 0.0-1.0 (e.g., 0.95 = 95%)
+8
View File
@@ -2,6 +2,7 @@ package gallery
import (
"fmt"
"strings"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/pkg/system"
@@ -70,6 +71,13 @@ func (m *GalleryBackend) IsMeta() bool {
return len(m.CapabilitiesMap) > 0 && m.URI == ""
}
func (m *GalleryBackend) IsDevelopment(devSuffix string) bool {
if devSuffix == "" {
devSuffix = defaultDevSuffix
}
return strings.HasSuffix(m.Name, "-"+devSuffix)
}
// IsCompatibleWith checks if the backend is compatible with the current system capability.
// For meta backends, it checks if any of the capabilities in the map match the system capability.
// For concrete backends, it delegates to SystemState.IsBackendCompatible.
+18 -4
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
+164 -1
View File
@@ -8,7 +8,16 @@
"name": "localai-react-ui",
"version": "0.0.1",
"dependencies": {
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/commands": "^6.8.1",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.0",
"@codemirror/lint": "^6.8.5",
"@codemirror/search": "^6.5.10",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.8",
"@fortawesome/fontawesome-free": "^6.7.2",
"@lezer/highlight": "^1.2.1",
"@modelcontextprotocol/ext-apps": "^1.2.2",
"@modelcontextprotocol/sdk": "^1.25.1",
"dompurify": "^3.2.5",
@@ -16,7 +25,8 @@
"marked": "^15.0.7",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.1"
"react-router-dom": "^7.6.1",
"yaml": "^2.8.3"
},
"devDependencies": {
"@eslint/js": "^9.27.0",
@@ -292,6 +302,94 @@
"node": ">=6.9.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz",
"integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.3",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-yaml": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz",
"integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.2.0",
"@lezer/lr": "^1.0.0",
"@lezer/yaml": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.5",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz",
"integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.35.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz",
"integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.41.0",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.0.tgz",
"integrity": "sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA==",
"dependencies": {
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -966,6 +1064,42 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz",
"integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/yaml": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz",
"integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.4.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="
},
"node_modules/@modelcontextprotocol/ext-apps": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.2.2.tgz",
@@ -1829,6 +1963,11 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3537,6 +3676,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -3727,6 +3871,11 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -3761,6 +3910,20 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+99 -12
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,32 @@ 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} />
<i className="fas fa-cubes" style={{ fontSize: '0.625rem' }} />
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} />
<i className="fas fa-flask" style={{ fontSize: '0.625rem' }} />
Development
</label>
</div>
</div>
{/* Table */}
+40 -66
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom'
import ResourceMonitor from '../components/ResourceMonitor'
import ConfirmDialog from '../components/ConfirmDialog'
import Toggle from '../components/Toggle'
import { useModels } from '../hooks/useModels'
import { backendControlApi, modelsApi, backendsApi, systemApi, nodesApi } from '../utils/api'
@@ -278,6 +279,7 @@ export default function Manage() {
<table className="table">
<thead>
<tr>
<th style={{ width: 36 }}>Enabled</th>
<th>Name</th>
<th>Status</th>
<th>Backend</th>
@@ -288,34 +290,49 @@ export default function Manage() {
<tbody>
{models.map(model => (
<tr key={model.id} style={{ opacity: model.disabled ? 0.55 : 1, transition: 'opacity 0.2s' }}>
{/* Enable/Disable toggle */}
<td>
<Toggle
checked={!model.disabled}
onChange={() => handleToggleModel(model.id, model.disabled)}
disabled={togglingModels.has(model.id)}
/>
</td>
{/* Name */}
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
<i className="fas fa-brain" style={{ color: model.disabled ? 'var(--color-text-muted)' : 'var(--color-accent)' }} />
<span className={`badge ${model.disabled ? '' : 'badge-success'}`} style={{ width: 6, height: 6, padding: 0, borderRadius: '50%', minWidth: 'auto', background: model.disabled ? 'var(--color-text-muted)' : undefined }} />
<span style={{ fontWeight: 500 }}>{model.id}</span>
<a
href="#"
onClick={(e) => { e.preventDefault(); navigate(`/app/model-editor/${encodeURIComponent(model.id)}`) }}
style={{ fontSize: '0.75rem', color: 'var(--color-primary)' }}
title="Edit config"
>
<i className="fas fa-pen-to-square" />
</a>
{!distributedMode && (
{model.pinned && (
<i className="fas fa-thumbtack" style={{ fontSize: '0.625rem', color: 'var(--color-warning)' }} title="Pinned — won't be idle-unloaded" />
)}
<div style={{ display: 'flex', gap: '2px', marginLeft: 'auto' }}>
<a
href="#"
onClick={(e) => { e.preventDefault(); navigate(`/app/backend-logs/${encodeURIComponent(model.id)}`) }}
style={{ fontSize: '0.75rem', color: 'var(--color-primary)' }}
title="Backend logs"
onClick={(e) => { e.preventDefault(); navigate(`/app/model-editor/${encodeURIComponent(model.id)}`) }}
className="btn btn-secondary btn-sm"
style={{ padding: '2px 5px', fontSize: '0.625rem' }}
title="Edit config"
>
<i className="fas fa-terminal" />
<i className="fas fa-pen-to-square" />
</a>
)}
{!distributedMode && (
<a
href="#"
onClick={(e) => { e.preventDefault(); navigate(`/app/backend-logs/${encodeURIComponent(model.id)}`) }}
className="btn btn-secondary btn-sm"
style={{ padding: '2px 5px', fontSize: '0.625rem' }}
title="Backend logs"
>
<i className="fas fa-terminal" />
</a>
)}
</div>
</div>
</td>
{/* Status */}
<td>
{model.disabled ? (
<span className="badge" style={{ background: 'var(--color-danger, #ef4444)', color: 'white' }}>
<span className="badge" style={{ background: 'var(--color-bg-tertiary)', color: 'var(--color-text-muted)' }}>
<i className="fas fa-ban" style={{ fontSize: '6px' }} /> Disabled
</span>
) : loadedModelIds.has(model.id) ? (
@@ -328,17 +345,19 @@ export default function Manage() {
</span>
)}
</td>
{/* Backend */}
<td>
<span className="badge badge-info">{model.backend || 'Auto'}</span>
</td>
{/* Use Cases */}
<td>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
<a href="#" onClick={(e) => { e.preventDefault(); navigate(`/app/chat/${encodeURIComponent(model.id)}`) }} className="badge badge-info" style={{ textDecoration: 'none', cursor: 'pointer' }}>Chat</a>
</div>
</td>
{/* Actions */}
<td>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', justifyContent: 'flex-end', alignItems: 'center' }}>
{/* Stop button - shown when model is loaded */}
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', justifyContent: 'flex-end', alignItems: 'center' }}>
{loadedModelIds.has(model.id) && (
<button
className="btn btn-secondary btn-sm"
@@ -348,62 +367,17 @@ export default function Manage() {
<i className="fas fa-stop" />
</button>
)}
{/* Pin button - prevents model from being unloaded */}
<button
className="btn btn-sm"
className="btn btn-secondary btn-sm"
onClick={() => handleTogglePinned(model.id, model.pinned)}
disabled={pinningModels.has(model.id) || model.disabled}
title={model.pinned ? 'Unpin model (allow idle unloading)' : 'Pin model (prevent idle unloading)'}
style={{
padding: '2px 6px',
minWidth: 28,
color: model.pinned ? 'var(--color-warning, #f59e0b)' : 'var(--color-text-muted)',
opacity: model.disabled ? 0.3 : (pinningModels.has(model.id) ? 0.5 : 1),
cursor: pinningModels.has(model.id) ? 'wait' : (model.disabled ? 'not-allowed' : 'pointer'),
color: model.pinned ? 'var(--color-warning)' : undefined,
}}
>
<i className={`fas fa-thumbtack${pinningModels.has(model.id) ? ' fa-spin' : ''}`} />
</button>
{/* Toggle switch for enabling/disabling model loading on demand */}
<label
title={model.disabled ? 'Model is disabled — click to enable loading on demand' : 'Model is enabled — click to disable loading on demand'}
style={{
position: 'relative',
display: 'inline-block',
width: 36,
height: 20,
cursor: togglingModels.has(model.id) ? 'wait' : 'pointer',
opacity: togglingModels.has(model.id) ? 0.5 : 1,
flexShrink: 0,
}}
>
<input
type="checkbox"
checked={!model.disabled}
onChange={() => handleToggleModel(model.id, model.disabled)}
disabled={togglingModels.has(model.id)}
style={{ opacity: 0, width: 0, height: 0 }}
/>
<span style={{
position: 'absolute',
top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: model.disabled ? 'var(--color-bg-tertiary)' : 'var(--color-success, #22c55e)',
borderRadius: 20,
transition: 'background-color 0.2s',
}}>
<span style={{
position: 'absolute',
content: '""',
height: 14,
width: 14,
left: model.disabled ? 3 : 19,
bottom: 3,
backgroundColor: 'white',
borderRadius: '50%',
transition: 'left 0.2s',
}} />
</span>
</label>
<button
className="btn btn-danger btn-sm"
onClick={() => handleDeleteModel(model.id)}
@@ -269,6 +269,9 @@ export default function Settings() {
<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>
<SettingRow label="Prefer Development Backends" description="Default to showing development backend versions in the backends gallery">
<Toggle checked={settings.prefer_development_backends} onChange={(v) => update('prefer_development_backends', v)} />
</SettingRow>
</div>
</div>
+24 -15
View File
@@ -816,7 +816,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
items = "9"
}
backends, err := gallery.AvailableBackends(appConfig.BackendGalleries, appConfig.SystemState)
backends, err := gallery.AvailableBackendsUnfiltered(appConfig.BackendGalleries, appConfig.SystemState)
if err != nil {
xlog.Error("could not list backends from galleries", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]any{
@@ -894,6 +894,12 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
backends = backends.Paginate(pageNum, itemsNum)
}
// Get dev suffix from SystemState for development backend detection
devSuffix := ""
if appConfig.SystemState != nil {
devSuffix = appConfig.SystemState.BackendDevSuffix
}
// Convert backends to JSON-friendly format and deduplicate by ID
backendsJSON := make([]map[string]any, 0, len(backends))
seenBackendIDs := make(map[string]bool)
@@ -920,19 +926,21 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
}
backendsJSON = append(backendsJSON, map[string]any{
"id": backendID,
"name": b.Name,
"description": b.Description,
"icon": b.Icon,
"license": b.License,
"urls": b.URLs,
"tags": b.Tags,
"gallery": b.Gallery.Name,
"installed": b.Installed,
"version": b.Version,
"processing": currentlyProcessing,
"jobID": jobID,
"isDeletion": isDeletionOp,
"id": backendID,
"name": b.Name,
"description": b.Description,
"icon": b.Icon,
"license": b.License,
"urls": b.URLs,
"tags": b.Tags,
"gallery": b.Gallery.Name,
"installed": b.Installed,
"version": b.Version,
"processing": currentlyProcessing,
"jobID": jobID,
"isDeletion": isDeletionOp,
"isMeta": b.IsMeta(),
"isDevelopment": b.IsDevelopment(devSuffix),
})
}
@@ -974,7 +982,8 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
"totalPages": totalPages,
"prevPage": prevPage,
"nextPage": nextPage,
"systemCapability": detectedCapability,
"systemCapability": detectedCapability,
"preferDevelopmentBackends": appConfig.PreferDevelopmentBackends,
})
}, adminMiddleware)