From 49b2b151b773122a72d44da965b31d136cd394fc Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Wed, 21 Oct 2026 18:15:15 +0000 Subject: [PATCH] feat(ui): show the kind of library entries and install backends from them The Browse tab lists the entries of every kind this host reads and marks each one as an add-on or a backend. A backend installs through the backend route and not through the add-on lifecycle: the confirmation says that the backend gallery is not changed and that a backend runs native code, and a refusal of the host, such as a name installed from another origin, is shown with its own reason. The details give the kind, the alias and the signer pattern. The library editor can name the signer with a pattern. Without this a save would drop the pattern of a backend library. The Libraries tab reports the entries a refresh skipped because their kind is not read. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto --- .../e2e/addons-backend-library.spec.js | 114 ++++++++++++++++++ .../react-ui/public/locales/en/addons.json | 28 ++++- .../src/components/addons/BrowseView.jsx | 56 +++++++-- .../src/components/catalog/SourcesView.jsx | 6 +- .../src/components/catalog/catalogState.js | 12 +- core/http/react-ui/src/utils/addonsApi.js | 18 ++- core/http/react-ui/tests/unit/addons.test.mjs | 39 +++++- 7 files changed, 253 insertions(+), 20 deletions(-) create mode 100644 core/http/react-ui/e2e/addons-backend-library.spec.js diff --git a/core/http/react-ui/e2e/addons-backend-library.spec.js b/core/http/react-ui/e2e/addons-backend-library.spec.js new file mode 100644 index 000000000..d360681c7 --- /dev/null +++ b/core/http/react-ui/e2e/addons-backend-library.spec.js @@ -0,0 +1,114 @@ +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 +// 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/.+)$' } +const backend = (over = {}) => release('cpu-local-store', { + kind: 'backend', name: 'cpu-local-store', alias: 'local-store', description: 'A vector store.', version: '0.0.0-latest.0a1056fd8633', + sourceId: 'official-backends', artifact: 'oci://registry.example/backends:latest-cpu-local-store', policy: POLICY, ...over, +}) + +async function signIn(page) { + await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN) })) +} + +// The backend routes of the host. `installed` is the mutable list GET /backends answers. +async function mockBackends(page, { installed = [], install } = {}) { + const state = { installed: [...installed], installs: [] } + await page.route(/\/backends$/, route => route.fulfill({ json: state.installed })) + await page.route(/\/backends\/library\/install$/, route => { + const body = route.request().postDataJSON() + state.installs.push(body) + if (install) { + const custom = install(body) + if (custom) return route.fulfill(custom) + } + state.installed.push({ Name: body.name, Metadata: { library: body.sourceId } }) + return route.fulfill({ json: { status: 'installed', name: body.name, sourceId: body.sourceId } }) + }) + return state +} + +test.describe('Library backends', () => { + test('browse: an entry shows its kind, and add-ons are still add-ons', 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) + await expect(page.locator('.cat-item', { hasText: 'Tables' }).getByTestId('entry-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() + await expect(row.getByText('Installed as a backend on this node')).toBeVisible() + 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' })).toBeDisabled() + }) + + 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, { + install: () => ({ status: 409, json: { error: { type: 'backend_library', code: 409, message: '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() + }) + + 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' }).getByRole('button', { name: 'Details' }).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() + }) + + test('libraries: a signer pattern is edited and saved without an exact identity, and skipped entries are reported', async ({ page }) => { + await signIn(page) + const state = await mockAddons(page, { + sources: [{ id: 'official-backends', name: 'Official backends', url: 'https://catalog.example/backends.yaml', enabled: true, policy: POLICY, refresh: { checkedAt: new Date().toISOString(), lastSuccess: new Date().toISOString(), count: 1, skipped: 2, skippedKinds: ['model'], failed: false } }], + }) + await mockBackends(page) + await page.goto('/app/addons?tab=libraries') + await expect(page.getByTestId('skipped-kinds')).toContainText('2 entries skipped: kind model') + await page.getByRole('button', { name: 'Edit' }).click() + const pattern = page.getByLabel('Signer identity pattern (optional)') + await expect(pattern).toHaveValue(POLICY.identityRegex) + await page.getByRole('button', { name: 'Save' }).click() + await expect.poll(() => state.log.dispatch.filter(d => d.action === 'catalog-source-save').length).toBe(1) + const saved = state.log.dispatch.find(d => d.action === 'catalog-source-save').arguments + expect(saved.policy).toEqual(POLICY) + }) +}) diff --git a/core/http/react-ui/public/locales/en/addons.json b/core/http/react-ui/public/locales/en/addons.json index 043de95a3..15574ba6c 100644 --- a/core/http/react-ui/public/locales/en/addons.json +++ b/core/http/react-ui/public/locales/en/addons.json @@ -67,7 +67,7 @@ "details": "Details", "detailsLead": "Library entry for {{id}}", "emptyBody": "Add a library on the Libraries tab, then refresh it.", - "emptyTitle": "No add-ons found", + "emptyTitle": "No library entries found", "fromLibrary": "from {{library}}", "install": "Install", "installedDetail": "Version {{version}}, generation {{generation}}", @@ -76,7 +76,18 @@ "installedSame": "Installed {{version}}", "installedState": "Installed", "installing": "Installing", - "search": "Search the libraries" + "search": "Search the libraries", + "kind": { + "addon": "Add-on", + "backend": "Backend" + }, + "installedBackend": "Installed", + "installedBackendNote": "Installed as a backend on this node. Models can use it now.", + "confirmBackendBody": "Version {{version}} of the backend from the library {{library}} will be downloaded, checked against its digest and signature, and installed on this node. The backend gallery is not changed.", + "confirmBackendNote": "A backend runs native code with the rights of LocalAI. Install it only from a library you trust.", + "backendFailed": "The backend was not installed.", + "alias": "Alias", + "kindLabel": "Kind" }, "catalog": { "allLibraries": "All libraries", @@ -156,7 +167,8 @@ "required": "Required when a signature is required.", "url": "Enter an http or https address.", "urlCredentials": "The address must not contain a user name or password.", - "urlQuery": "The address must not contain a query string or fragment." + "urlQuery": "The address must not contain a query string or fragment.", + "either": "Use the exact identity or the pattern, not both." }, "fields": { "enabled": "Enabled", @@ -166,12 +178,14 @@ "name": "Name", "reference": "Credential reference (optional)", "requireSignature": "Require a signature", - "url": "Catalog URL" + "url": "Catalog URL", + "identityRegex": "Signer identity pattern (optional)" }, "hints": { "id": "Lowercase letters, digits and hyphens. It cannot change later.", "reference": "The name of a credential the owner configured. Never paste a token or password here.", - "url": "An https address without credentials or a query string." + "url": "An https address without credentials or a query string.", + "identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify." }, "lastRefresh": "Refreshed {{when}}, {{count}} entries", "lead": "A library is a catalog document that lists add-ons. Credentials are never typed here: a private library names a credential the owner configured.", @@ -187,7 +201,9 @@ "removeTitle": "Remove {{name}}?", "removed": "Removed {{name}}.", "save": "Save", - "saved": "Saved {{name}}." + "saved": "Saved {{name}}.", + "skipped_one": "{{count}} entry skipped: kind {{kinds}} is not read by this version", + "skipped_other": "{{count}} entries skipped: kind {{kinds}} is not read by this version" }, "manage": { "api": "Add-on API", diff --git a/core/http/react-ui/src/components/addons/BrowseView.jsx b/core/http/react-ui/src/components/addons/BrowseView.jsx index 8f70f38fb..d921a1fea 100644 --- a/core/http/react-ui/src/components/addons/BrowseView.jsx +++ b/core/http/react-ui/src/components/addons/BrowseView.jsx @@ -20,6 +20,10 @@ 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 [details, setDetails] = useState(null) @@ -43,9 +47,31 @@ 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) }) + const installBackend = async (entry) => { + setConfirm(null) + const key = `install:${entry.key}` + setBackendWork(w => ({ ...w, [key]: { phase: 'working' } })) + try { + await api.backends.install({ sourceId: entry.sourceId, name: entry.id, version: entry.version }) + setBackendWork(w => ({ ...w, [key]: { phase: 'done' } })) + } catch (error) { + setBackendWork(w => ({ ...w, [key]: { phase: 'failed', error } })) + } + setReload(n => n + 1) + } + const install = (entry) => { + if (entry.kind === 'backend') return installBackend(entry) setConfirm(null) actions.run(`install:${entry.key}`, { addonId: entry.id, @@ -57,11 +83,13 @@ export default function BrowseView({ api, adapter }) { } const renderEntry = (entry) => { - const have = installed[entry.id] + const isBackend = entry.kind === 'backend' + const have = isBackend ? installedBackends[entry.id] : installed[entry.id] const key = `install:${entry.key}` - const st = actions.state[key] - const pending = Boolean(actions.unknown[entry.id]) - const working = st?.phase === 'working' + const bw = isBackend ? backendWork[key] : null + const st = isBackend ? null : actions.state[key] + const pending = !isBackend && Boolean(actions.unknown[entry.id]) + const working = isBackend ? bw?.phase === 'working' : st?.phase === 'working' return (
@@ -76,19 +104,27 @@ export default function BrowseView({ api, adapter }) {

{entry.description &&

{entry.description}

}
+ {t(`browse.kind.${entry.kind}`, { defaultValue: entry.kind })} {entry.platform.os}/{entry.platform.arch} {entry.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')} {entry.channel && {entry.channel}} - {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 })}} + {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 })}}
{pending && } {!pending && st && !working && st.outcome !== 'completed' && (

{describeOutcome(st.outcome, t, st.error)}

)} {st && st.outcome === 'completed' &&

{t('browse.installedNotEnabled')}

} + {bw?.phase === 'failed' && ( +

+ {t('browse.backendFailed')} {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')}

}
@@ -214,6 +217,7 @@ function SourceEditor({ draft, onSave, onClose, saving, t }) { {form.requireSignature && field('issuer', t('libraries.fields.issuer'), { autoComplete: 'off' })} {form.requireSignature && field('identity', t('libraries.fields.identity'), { autoComplete: 'off' })} + {form.requireSignature && field('identityRegex', t('libraries.fields.identityRegex'), { autoComplete: 'off', spellCheck: false }, t('libraries.hints.identityRegex'))} {field('authReference', t('libraries.fields.reference'), { autoComplete: 'off', spellCheck: false }, t('libraries.hints.reference'))} diff --git a/core/http/react-ui/src/components/catalog/catalogState.js b/core/http/react-ui/src/components/catalog/catalogState.js index 3987688cb..c8dc5e333 100644 --- a/core/http/react-ui/src/components/catalog/catalogState.js +++ b/core/http/react-ui/src/components/catalog/catalogState.js @@ -142,7 +142,11 @@ export function validateSourceDraft(draft = {}) { if (ref && !REFERENCE.test(ref)) errors.authReference = 'reference' if (draft.requireSignature) { if (!String(draft.issuer || '').trim()) errors.issuer = 'required' - if (!String(draft.identity || '').trim()) errors.identity = 'required' + // A signer is named exactly or by a pattern (backend libraries), not both. + const exact = String(draft.identity || '').trim() + const pattern = String(draft.identityRegex || '').trim() + if (!exact && !pattern) errors.identity = 'required' + else if (exact && pattern) errors.identityRegex = 'either' } return errors } @@ -159,7 +163,10 @@ export function draftToSource(draft) { } if (draft.requireSignature) { source.policy.issuer = String(draft.issuer).trim() - source.policy.identity = String(draft.identity).trim() + const exact = String(draft.identity || '').trim() + const pattern = String(draft.identityRegex || '').trim() + if (exact) source.policy.identity = exact + if (pattern) source.policy.identityRegex = pattern } const ref = String(draft.authReference || '').trim() if (ref) source.authReference = ref @@ -177,6 +184,7 @@ export function sourceToDraft(source = {}) { requireSignature: Boolean(source.policy?.requireSignature), issuer: source.policy?.issuer || '', identity: source.policy?.identity || '', + identityRegex: source.policy?.identityRegex || '', authReference: source.authReference || '', allowLoopbackHTTP: Boolean(source.allowLoopbackHTTP), } diff --git a/core/http/react-ui/src/utils/addonsApi.js b/core/http/react-ui/src/utils/addonsApi.js index a98a06ea1..90ba13bce 100644 --- a/core/http/react-ui/src/utils/addonsApi.js +++ b/core/http/react-ui/src/utils/addonsApi.js @@ -128,6 +128,19 @@ export function createAddonsApi({ fetch: fetchImpl, base = p => p } = {}) { prune(id, { operationId }, signal) { return api.recovery.mutate(id, 'recovery/prune', { operationId }, signal) }, purge(id, body, signal) { return api.recovery.mutate(id, 'purge', body, signal) }, }, + // Backends of the libraries. The library entry is resolved by the server; + // the request names the entry and nothing else. The install runs inside + // the request and answers when the backend is in place. + backends: { + async installed(signal) { + const out = await call('/backends', { signal }) + const list = Array.isArray(out) ? out : (Array.isArray(out?.backends) ? out.backends : []) + return list.map(b => ({ id: b.Name || b.name || '', library: b.Metadata?.library || '' })).filter(b => b.id) + }, + install({ sourceId, name, version }, signal) { + return call('/backends/library/install', { method: 'POST', body: { sourceId, name, version }, signal }) + }, + }, // Update detection and the explicit upgrade. The status is the owner's // answer and the page never derives it. Check and upgrade are the // administrator's own action: the operation ID is also the confirmation @@ -174,6 +187,9 @@ export function createAddonsCatalogAdapter(api, { pageSize = 24 } = {}) { description: release.description || release.ui?.description || '', version: release.version, sourceId: release.sourceId, + // The kind of the entry: addon when the library does not say. + kind: release.kind || 'addon', + alias: release.alias || '', channel: release.channel || '', digest: release.digest || '', platform: { os: release.os, arch: release.arch }, @@ -188,7 +204,7 @@ export function createAddonsCatalogAdapter(api, { pageSize = 24 } = {}) { const payload = await api.dispatch({ operationId: newOperationId(), action: 'catalog-search', - args: { text: query?.text || '', sourceId: query?.sourceId || '', offset, limit: pageSize }, + args: { text: query?.text || '', kind: 'all', sourceId: query?.sourceId || '', offset, limit: pageSize }, }, signal) const releases = Array.isArray(payload?.releases) ? payload.releases : [] return { entries: releases.map(toEntry), total: Number(payload?.total) || 0, offset } diff --git a/core/http/react-ui/tests/unit/addons.test.mjs b/core/http/react-ui/tests/unit/addons.test.mjs index 455da2e96..b158da001 100644 --- a/core/http/react-ui/tests/unit/addons.test.mjs +++ b/core/http/react-ui/tests/unit/addons.test.mjs @@ -153,11 +153,12 @@ 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', sourceId: 'main', offset: 10, limit: 10 }) + assert.deepEqual(sent.arguments, { text: 'no', kind: 'all', sourceId: 'main', offset: 10, limit: 10 }) assert.equal(sent.action, 'catalog-search') assert.ok(sent.operationId) assert.equal(page.total, 31) assert.equal(page.entries[0].key, 'main/notes/1.0.0') + assert.equal(page.entries[0].kind, 'addon') await assert.rejects(adapter.listSources(), e => e instanceof AddonsError && e.kind === 'denied') await assert.rejects( adapter.submitLifecycle({ operationId: 'op-1', action: 'install', addonId: 'notes', sourceId: 'main', version: '1.0.0' }), @@ -278,3 +279,39 @@ test('a refusal shows the server reason and falls back to the generic text', () // Other kinds keep their own words. 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 () => { + const calls = [] + const api = createAddonsApi({ + fetch: async (url, init) => { + calls.push({ url, init }) + const body = init.body ? JSON.parse(init.body) : null + if (body?.action === 'catalog-search') return json(200, { payload: { releases: [{ kind: 'backend', addonId: 'cpu-local-store', alias: 'local-store', name: 'cpu-local-store', version: '0.0.0-latest.aaaaaaaaaaaa', sourceId: 'official-backends', os: 'linux', arch: 'amd64', digest: 'sha256:a' }], total: 1 } }) + if (url === '/backends/library/install') return json(409, { error: { type: 'backend_library', message: 'backend is already installed from another origin: "cpu-local-store"' } }) + if (url === '/backends') return json(200, [{ Name: 'cpu-local-store', Metadata: { library: 'official-backends' } }, { Name: 'llama-cpp' }]) + return json(200, {}) + }, + }) + 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(await api.backends.installed(), [{ id: 'cpu-local-store', library: 'official-backends' }, { id: 'llama-cpp', library: '' }]) + await assert.rejects( + api.backends.install({ sourceId: 'official-backends', name: 'cpu-local-store', version: 'v' }), + e => e instanceof AddonsError && e.kind === 'unavailable' && e.status === 409 && /another origin/.test(e.message), + ) + const sent = JSON.parse(calls.at(-1).init.body) + assert.deepEqual(sent, { sourceId: 'official-backends', name: 'cpu-local-store', version: 'v' }) +}) + +test('a library signer is named exactly or by a pattern, never both', () => { + const ok = { id: 'b', name: 'B', url: 'https://library.example/i.yaml', enabled: true, requireSignature: true, issuer: 'i' } + assert.equal(validateSourceDraft({ ...ok }).identity, 'required') + assert.deepEqual(validateSourceDraft({ ...ok, identityRegex: '^a$' }), {}) + assert.equal(validateSourceDraft({ ...ok, identity: 'x', identityRegex: '^a$' }).identityRegex, 'either') + const wire = draftToSource({ ...ok, identityRegex: ' ^a$ ' }) + assert.deepEqual(wire.policy, { requireSignature: true, issuer: 'i', identityRegex: '^a$' }) + assert.equal(sourceToDraft({ policy: { requireSignature: true, issuer: 'i', identityRegex: '^a$' } }).identityRegex, '^a$') +})