From 9c4a8b2a87cf868332c024d8aedb305022e6966b Mon Sep 17 00:00:00 2001
From: mudler-agent
Date: Sat, 24 Oct 2026 15:56:39 +0200
Subject: [PATCH] fix(ui): keep backends off the add-on browse list (#12878)
The add-on Browse list showed backend entries next to add-ons, with
their own install flow. Backends belong on the Backends page, where
the library chips already cover them.
Ask the library for add-ons only and drop any entry that still says
it is a backend. Entries with no kind stay add-ons. Remove the
backend-only install flow from the Browse view.
Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto
Co-authored-by: Ettore Di Giacinto
---
.../e2e/addons-backend-library.spec.js | 92 ++--------------
.../src/components/addons/BrowseView.jsx | 100 +++---------------
core/http/react-ui/src/utils/addonsApi.js | 6 +-
core/http/react-ui/tests/unit/addons.test.mjs | 24 ++++-
4 files changed, 49 insertions(+), 173 deletions(-)
diff --git a/core/http/react-ui/e2e/addons-backend-library.spec.js b/core/http/react-ui/e2e/addons-backend-library.spec.js
index 1546f61c7..7a690365b 100644
--- a/core/http/react-ui/e2e/addons-backend-library.spec.js
+++ b/core/http/react-ui/e2e/addons-backend-library.spec.js
@@ -1,9 +1,8 @@
import { test, expect } from '@playwright/test'
import { ADMIN, release, sessionFor, mockAddons } from './addons-fixtures.js'
-// Backends in the library (phase 1): an entry has a kind, the Browse tab shows
-// it, and a backend installs through the backend route, not the add-on
-// lifecycle. The server is the authority, so these specs drive the page
+// Backends in the library: an entry has a kind, the add-on Browse tab lists
+// add-ons only, and backends are handled on the Backends page. The server is the authority, so these specs drive the page
// against a stand-in and assert what the page sent.
const POLICY = { requireSignature: true, issuer: 'https://token.actions.githubusercontent.com', identityRegex: '^https://github\\.com/o/r/\\.github/workflows/w\\.yml@refs/(heads/master|tags/.+)$' }
@@ -46,89 +45,18 @@ async function mockBackends(page, { installed = [], phases = ['resolving', 'size
}
test.describe('Library backends', () => {
- test('browse: an entry shows its kind, and add-ons are still add-ons', async ({ page }) => {
+ test('browse: a backend entry is not listed, and add-ons are', async ({ page }) => {
await signIn(page)
const state = await mockAddons(page, { installed: [], releases: [release('tables'), backend()] })
await mockBackends(page)
await page.goto('/app/addons?tab=browse')
- await expect(page.locator('.cat-item')).toHaveCount(2)
- // A backend says what it is on the entry; an add-on is the default and says it under Details.
- const tables = page.locator('.cat-item', { hasText: 'Tables' })
- await expect(tables.getByTestId('entry-kind')).toHaveCount(0)
- await tables.getByText('Details', { exact: true }).click()
- await expect(tables.getByTestId('details-kind')).toHaveText('Add-on')
- const row = page.locator('.cat-item', { hasText: 'cpu-local-store' })
- await expect(row.getByTestId('entry-kind')).toHaveText('Backend')
- await expect(row.getByTestId('entry-kind')).toHaveAttribute('data-kind', 'backend')
- // The page asks for every kind this host reads.
- expect(state.log.dispatch.find(d => d.action === 'catalog-search').arguments.kind).toBe('all')
- })
-
- test('browse: a backend installs through the backend route and never through the add-on lifecycle', async ({ page }) => {
- await signIn(page)
- const state = await mockAddons(page, { installed: [], releases: [backend()] })
- const backends = await mockBackends(page)
- await page.goto('/app/addons?tab=browse')
- const row = page.locator('.cat-item', { hasText: 'cpu-local-store' })
- await row.getByRole('button', { name: 'Install' }).click()
- const dialog = page.getByRole('alertdialog')
- await expect(dialog).toContainText('Install cpu-local-store?')
- await expect(dialog).toContainText('backend gallery is not changed')
- await expect(dialog).toContainText('native code')
- expect(backends.installs).toHaveLength(0)
- await dialog.getByRole('button', { name: 'Install' }).click()
- // The steps show while the job runs: a signed library lists the signature step.
- const steps = row.getByTestId('install-steps')
- await expect(steps.locator('[data-step]')).toHaveCount(5)
- await expect(steps.locator('[data-step="fetching"]')).toHaveAttribute('data-state', /active|done/)
- await expect(row.getByText('Installed as a backend on this node')).toBeVisible()
- await expect(steps).toHaveCount(0)
- expect(backends.installs).toEqual([{ sourceId: 'official-backends', name: 'cpu-local-store', version: '0.0.0-latest.0a1056fd8633' }])
- expect(state.log.dispatch.filter(d => d.action === 'install')).toHaveLength(0)
- // Once installed the entry says so and offers no second install.
- await expect(row.getByText('Installed', { exact: true })).toBeVisible()
- await expect(row.getByRole('button', { name: 'Install' })).toHaveCount(0)
- })
-
- test('browse: a refusal of the host is shown with its own reason', async ({ page }) => {
- await signIn(page)
- await mockAddons(page, { installed: [], releases: [backend()] })
- await mockBackends(page, {
- phases: ['resolving'],
- fail: 'backend is already installed from another origin: "cpu-local-store"',
- })
- await page.goto('/app/addons?tab=browse')
- const row = page.locator('.cat-item', { hasText: 'cpu-local-store' })
- await row.getByRole('button', { name: 'Install' }).click()
- await page.getByRole('alertdialog').getByRole('button', { name: 'Install' }).click()
- await expect(row.getByRole('alert')).toContainText('The backend was not installed.')
- await expect(row.getByRole('alert')).toContainText('already installed from another origin')
- await expect(row.getByRole('button', { name: 'Install' })).toBeEnabled()
- // The step that failed stays marked.
- await expect(row.locator('[data-step="resolving"]')).toHaveAttribute('data-state', 'failed')
- await expect(row.locator('[data-step="fetching"]')).toHaveAttribute('data-state', 'pending')
- })
-
- test('browse: a request the host refuses is shown with its reason', async ({ page }) => {
- await signIn(page)
- await mockAddons(page, { installed: [], releases: [backend()] })
- await mockBackends(page, { refuse: { status: 409, json: { error: { type: 'backend_library', code: 409, message: 'this backend is already being installed' } } } })
- await page.goto('/app/addons?tab=browse')
- const row = page.locator('.cat-item', { hasText: 'cpu-local-store' })
- await row.getByRole('button', { name: 'Install' }).click()
- await page.getByRole('alertdialog').getByRole('button', { name: 'Install' }).click()
- await expect(row.getByRole('alert')).toContainText('already being installed')
- })
-
- test('browse: the details give the kind, the alias and the signer pattern', async ({ page }) => {
- await signIn(page)
- await mockAddons(page, { installed: [], releases: [backend()] })
- await mockBackends(page)
- await page.goto('/app/addons?tab=browse')
- await page.locator('.cat-item', { hasText: 'cpu-local-store' }).getByText('Details', { exact: true }).click()
- await expect(page.getByTestId('details-kind')).toHaveText('Backend')
- await expect(page.getByText('local-store', { exact: true })).toBeVisible()
- await expect(page.getByText('workflows/w', { exact: false })).toBeVisible()
+ // Backends belong to the Backends page: only the add-on is listed.
+ await expect(page.locator('.cat-item')).toHaveCount(1)
+ await expect(page.locator('.cat-item', { hasText: 'Tables' })).toBeVisible()
+ await expect(page.locator('.cat-item', { hasText: 'cpu-local-store' })).toHaveCount(0)
+ await expect(page.getByTestId('entry-kind')).toHaveCount(0)
+ // The page asks for add-ons only.
+ expect(state.log.dispatch.find(d => d.action === 'catalog-search').arguments.kind).toBe('addon')
})
test('libraries: a signer pattern is edited and saved without an exact identity, and skipped entries are reported', async ({ page }) => {
diff --git a/core/http/react-ui/src/components/addons/BrowseView.jsx b/core/http/react-ui/src/components/addons/BrowseView.jsx
index 1545e77a4..5ae8ad25e 100644
--- a/core/http/react-ui/src/components/addons/BrowseView.jsx
+++ b/core/http/react-ui/src/components/addons/BrowseView.jsx
@@ -3,31 +3,24 @@ import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import Icon from '../Icon'
-import ConfirmDialog from '../ConfirmDialog'
import CatalogView from '../catalog/CatalogView'
import { paramsToQuery, queryToParams } from '../catalog/catalogState'
-import { AppIcon, Fact, InstallSteps, UnknownNotice } from './AddonBits'
+import { AppIcon, Fact, UnknownNotice } from './AddonBits'
import FlowStatus from './InstallFlow'
import InstallDialog from './InstallDialog'
-import { shortDigest } from './addonState'
import { updateView } from './updateState'
import { describeError, describeOutcome } from './errors'
import { useAddonActions } from './useAddonActions'
// Library entries for this host. An add-on entry has one button, Install and
// turn on: one confirmation, then the install and the turn on run one after
-// the other. An add-on that is installed but off offers Turn on. A backend has
-// no on and off, so its button stays Install.
+// the other. An add-on that is installed but off offers Turn on. Backends are
+// not listed here: they belong to the Backends page.
export default function BrowseView({ api, adapter }) {
const { t } = useTranslation('addons')
const [params, setParams] = useSearchParams()
const [installed, setInstalled] = useState({})
- // Backends are installed by the backend code of the host, not by the add-on
- // owner, so they have their own installed list and their own install state.
- const [installedBackends, setInstalledBackends] = useState({})
- const [backendWork, setBackendWork] = useState({})
const [reload, setReload] = useState(0)
- const [confirm, setConfirm] = useState(null)
const [dialog, setDialog] = useState(null)
const query = paramsToQuery(k => params.get(k))
@@ -49,47 +42,17 @@ export default function BrowseView({ api, adapter }) {
return () => ctl.abort()
}, [api, reload])
- useEffect(() => {
- const ctl = new AbortController()
- api.backends.installed(ctl.signal)
- .then(list => setInstalledBackends(Object.fromEntries(list.map(b => [b.id, b]))))
- .catch(() => { if (!ctl.signal.aborted) setInstalledBackends({}) })
- return () => ctl.abort()
- }, [api, reload])
-
const actions = useAddonActions(api, { onSettled: () => setReload(n => n + 1), adapter })
- const installBackend = async (entry) => {
- setConfirm(null)
- const key = `install:${entry.key}`
- setBackendWork(w => ({ ...w, [key]: { phase: 'working', job: { phase: 'resolving' } } }))
- try {
- const job = await api.backends.install(
- { sourceId: entry.sourceId, name: entry.id, version: entry.version }, undefined,
- { onStatus: status => setBackendWork(w => ({ ...w, [key]: { phase: 'working', job: status } })) },
- )
- setBackendWork(w => ({ ...w, [key]: { phase: 'done', job } }))
- } catch (error) {
- setBackendWork(w => ({ ...w, [key]: { phase: 'failed', error, job: { phase: error.phase, processed: true, error: error.message } } }))
- }
- setReload(n => n + 1)
- }
-
- // A backend has its own job and its own confirmation. An add-on goes through
- // the one dialog of Install and turn on, which stays open for the steps.
- const install = (entry) => installBackend(entry)
- const openInstall = (entry) => { if (entry.kind === 'backend') setConfirm(entry); else setDialog(entry) }
+ const openInstall = (entry) => setDialog(entry)
const renderEntry = (entry) => {
- const isBackend = entry.kind === 'backend'
- const have = isBackend ? installedBackends[entry.id] : installed[entry.id]
- const key = `install:${entry.key}`
- const bw = isBackend ? backendWork[key] : null
- const flow = isBackend ? null : actions.flows[`flow:${entry.key}`]
- const pending = !isBackend && Boolean(actions.unknown[entry.id])
- const working = isBackend ? bw?.phase === 'working' : ['install', 'turn-on'].includes(flow?.step)
+ const have = installed[entry.id]
+ const flow = actions.flows[`flow:${entry.key}`]
+ const pending = Boolean(actions.unknown[entry.id])
+ const working = ['install', 'turn-on'].includes(flow?.step)
const addsPage = Boolean(entry.ui && entry.ui.mainPage)
- const off = !isBackend && have && have.enabled === false
+ const off = have && have.enabled === false
return (
@@ -104,31 +67,17 @@ export default function BrowseView({ api, adapter }) {
{entry.description && {entry.description}
}
- {isBackend && {t(`browse.kind.${entry.kind}`, { defaultValue: entry.kind })} }
- {!isBackend && addsPage && {t('card.addsPage')} }
- {isBackend && have && {t('browse.installedBackend')} }
- {!isBackend && have && {have.version === entry.version ? t('browse.installedSame', { version: have.version }) : t('browse.installedOther', { version: have.version })} }
- {!isBackend && have && updateView(have.update).state === 'update-available' && {t('updates.badge.available', { version: updateView(have.update).version })} }
+ {addsPage && {t('card.addsPage')} }
+ {have && {have.version === entry.version ? t('browse.installedSame', { version: have.version }) : t('browse.installedOther', { version: have.version })} }
+ {have && updateView(have.update).state === 'update-available' && {t('updates.badge.available', { version: updateView(have.update).version })} }
-
+
{pending && }
{!pending && !(dialog && dialog.key === entry.key) && actions.installAndTurnOn(entry, { skipInstall: flow.failedStep === 'turn-on' }) : undefined} />}
{flow && flow.step === 'done' && !(dialog && dialog.key === entry.key) && {t('browse.installedOn', { name: entry.title })}
}
- {bw && bw.phase !== 'done' && }
- {bw?.phase === 'failed' && (
-
- {t('browse.backendFailed')} {(bw.error?.kind === 'install' || bw.error?.status >= 400) && bw.error.message && bw.error.message !== bw.error.kind ? bw.error.message : describeError(bw.error, t)}
-
- )}
- {bw?.phase === 'done' && {t('browse.installedBackendNote')}
}
- {isBackend && !have && (
-
setConfirm(entry)}>
- {working ? t('browse.installing') : t('browse.install')}
-
- )}
- {!isBackend && !have && (
+ {!have && (
openInstall(entry)}>
{t('flow.installAndTurnOn')}
@@ -157,23 +106,6 @@ export default function BrowseView({ api, adapter }) {
emptyText={t('browse.emptyBody')}
/>
setDialog(null)} />
-
- {t('browse.confirmBackendBody', { version: confirm.version, library: confirm.sourceId })}
-
- {t('trust.digest')} {shortDigest(confirm.digest)}
- {t('trust.verification')} {confirm.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}
-
- {t('browse.confirmBackendNote')}
-
- ) : null}
- confirmLabel={t('browse.install')}
- onConfirm={() => { const e = confirm; setConfirm(null); install(e) }}
- onCancel={() => setConfirm(null)}
- />
>
)
}
@@ -181,7 +113,7 @@ export default function BrowseView({ api, adapter }) {
// The details of an entry, folded into the entry itself: what it is made for,
// how it is verified and which build it is. They come from the library entry
// the list already has.
-function EntryFacts({ entry: e, have, isBackend }) {
+function EntryFacts({ entry: e, have }) {
const { t } = useTranslation('addons')
return (
@@ -196,7 +128,7 @@ function EntryFacts({ entry: e, have, isBackend }) {
{t('trust.verification')} {e.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}
{e.policy.requireSignature && (<>{t('trust.issuer')} {e.policy.issuer} {t('trust.identity')} {e.policy.identity || e.policy.identityRegex} >)}
{t('trust.digest')} {e.digest}
- {!isBackend && have && (<>{t('browse.installedState')} {t('browse.installedDetail', { version: have.version, generation: have.generation })} >)}
+ {have && (<>{t('browse.installedState')} {t('browse.installedDetail', { version: have.version, generation: have.generation })} >)}
)
diff --git a/core/http/react-ui/src/utils/addonsApi.js b/core/http/react-ui/src/utils/addonsApi.js
index 1d06984aa..364fb3816 100644
--- a/core/http/react-ui/src/utils/addonsApi.js
+++ b/core/http/react-ui/src/utils/addonsApi.js
@@ -269,9 +269,11 @@ export function createAddonsCatalogAdapter(api, { pageSize = 24 } = {}) {
const payload = await api.dispatch({
operationId: newOperationId(),
action: 'catalog-search',
- args: { text: query?.text || '', kind: 'all', sourceId: query?.sourceId || '', offset, limit: pageSize },
+ args: { text: query?.text || '', kind: 'addon', sourceId: query?.sourceId || '', offset, limit: pageSize },
}, signal)
- const releases = Array.isArray(payload?.releases) ? payload.releases : []
+ // Backends belong to the Backends page. The request asks for add-ons
+ // only; an entry that still says it is a backend is dropped here too.
+ const releases = (Array.isArray(payload?.releases) ? payload.releases : []).filter(r => !r.kind || r.kind === 'addon')
return { entries: releases.map(toEntry), total: Number(payload?.total) || 0, offset }
},
async details(key, signal) {
diff --git a/core/http/react-ui/tests/unit/addons.test.mjs b/core/http/react-ui/tests/unit/addons.test.mjs
index 10dc708c4..1b6267fea 100644
--- a/core/http/react-ui/tests/unit/addons.test.mjs
+++ b/core/http/react-ui/tests/unit/addons.test.mjs
@@ -143,6 +143,22 @@ test('update status comes only from an authoritative check', () => {
assert.equal(parseEntryKey('a/b'), null)
})
+test('the add-on browse list drops backend entries and keeps entries with no kind', async () => {
+ const rel = (id, kind) => ({ id, name: id, version: '1.0.0', sourceId: 'main', os: 'linux', arch: 'amd64', digest: 'sha256:a', ...(kind ? { kind } : {}) })
+ let sent
+ const api = createAddonsApi({
+ base: p => p,
+ fetch: async (url, init) => {
+ sent = JSON.parse(init.body)
+ return json(200, { payload: { releases: [rel('notes'), rel('tables', 'addon'), rel('cpu-local-store', 'backend')], total: 3 } })
+ },
+ })
+ const page = await createAddonsCatalogAdapter(api).search({ text: '', sourceId: '', page: 1 })
+ assert.equal(sent.arguments.kind, 'addon')
+ assert.deepEqual(page.entries.map(e => e.id), ['notes', 'tables'])
+ assert.ok(page.entries.every(e => e.kind === 'addon'))
+})
+
test('the api sends one dispatch shape and classifies refusals', async () => {
const calls = []
const api = createAddonsApi({
@@ -162,7 +178,7 @@ test('the api sends one dispatch shape and classifies refusals', async () => {
assert.equal(calls[0].init.method, 'POST')
assert.equal(calls[0].init.credentials, 'same-origin')
const sent = JSON.parse(calls[0].init.body)
- assert.deepEqual(sent.arguments, { text: 'no', kind: 'all', sourceId: 'main', offset: 10, limit: 10 })
+ assert.deepEqual(sent.arguments, { text: 'no', kind: 'addon', sourceId: 'main', offset: 10, limit: 10 })
assert.equal(sent.action, 'catalog-search')
assert.ok(sent.operationId)
assert.equal(page.total, 31)
@@ -289,7 +305,7 @@ test('a refusal shows the server reason and falls back to the generic text', ()
assert.equal(describeError(new AddonsError(classifyResponse(401, { error: { message: 'x' } })), t), 'T:errors.auth')
})
-test('a backend entry keeps its kind and installs through the backend route', async () => {
+test('the add-on list drops a backend entry, and the backend route still installs one', async () => {
const calls = []
const api = createAddonsApi({
fetch: async (url, init) => {
@@ -303,9 +319,7 @@ test('a backend entry keeps its kind and installs through the backend route', as
})
const adapter = createAddonsCatalogAdapter(api, { pageSize: 10 })
const page = await adapter.search({})
- assert.equal(page.entries[0].kind, 'backend')
- assert.equal(page.entries[0].alias, 'local-store')
- assert.equal(page.entries[0].key, 'official-backends/cpu-local-store/0.0.0-latest.aaaaaaaaaaaa')
+ assert.deepEqual(page.entries, [])
assert.deepEqual(await api.backends.installed(), [{ id: 'cpu-local-store', library: 'official-backends', version: '', digest: '' }, { id: 'llama-cpp', library: '', version: '', digest: '' }])
await assert.rejects(
api.backends.install({ sourceId: 'official-backends', name: 'cpu-local-store', version: 'v' }),