feat(cabana): read-only Plugins screen with installed plugins

- GET /plugins (backend.view_plugins, superusers always pass) lists the
  plugins from party.Installed, or party.Activated IDs for older builds,
  with the built framework version and whether the registry is configured
- Settings -> System -> Plugins entry with the new SettingsEntry.path,
  added only for principals allowed to open it
- PluginsView at /system/plugins with an Installed tab (reka-ui Tabs);
  settings panel, rail, breadcrumbs and page title know the route
- OpenAPI and SPA types regenerated; contract, token-class and
  conformance inventories list the route; en/pl strings; docs
This commit is contained in:
Jakub Zych
2026-10-29 19:05:46 +01:00
parent 0418900122
commit c4b1ac4076
27 changed files with 998 additions and 12 deletions

View File

@@ -645,6 +645,57 @@
"backend::lang.page.not_found": {
"other": "Nie znaleziono strony."
},
"backend::lang.permissions.view_plugins": {
"other": "Podgląd wtyczek"
},
"backend::lang.plugins.column_plugin": {
"other": "Wtyczka"
},
"backend::lang.plugins.column_source": {
"other": "Źródło"
},
"backend::lang.plugins.column_version": {
"other": "Wersja"
},
"backend::lang.plugins.empty": {
"other": "Brak zainstalowanych wtyczek."
},
"backend::lang.plugins.framework": {
"other": "Framework :version"
},
"backend::lang.plugins.load_failed": {
"other": "Nie udało się wczytać listy wtyczek."
},
"backend::lang.plugins.menu_description": {
"other": "Zainstalowane wtyczki, ich wersje i dostępne aktualizacje oraz katalog wtyczek."
},
"backend::lang.plugins.menu_label": {
"other": "Wtyczki"
},
"backend::lang.plugins.no_version": {
"other": "Brak wersji"
},
"backend::lang.plugins.source_local": {
"other": "W aplikacji"
},
"backend::lang.plugins.source_module": {
"other": "Moduł Go"
},
"backend::lang.plugins.source_submodule": {
"other": "Submoduł git"
},
"backend::lang.plugins.tab_installed": {
"other": "Zainstalowane"
},
"backend::lang.plugins.tab_marketplace": {
"other": "Katalog"
},
"backend::lang.plugins.tabs_label": {
"other": "Sekcje ekranu Wtyczki"
},
"backend::lang.plugins.title": {
"other": "Wtyczki"
},
"backend::lang.relation.add": {
"other": "Dodaj"
},

View File

@@ -0,0 +1,110 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount } from '@vue/test-utils'
import PluginsView from '../../src/views/PluginsView.vue'
import { settingsPath } from '../../src/state/useSettings'
import type { InstalledPlugin, PluginsOverview, SettingsEntry } from '../../src/api/types'
import { API, mountApp, requestsTo, resetState, type Route } from '../helpers'
const PLUGINS = `${API}/plugins`
function installed(overrides: Partial<InstalledPlugin> & { id: string }): InstalledPlugin {
return {
name: '',
description: '',
version: '',
source: '',
module: '',
package: '',
listed: false,
latest: '',
latestFramework: '',
updateAvailable: false,
updateCommand: '',
...overrides,
}
}
function overview(overrides: Partial<PluginsOverview> = {}): PluginsOverview {
return {
framework: 'v0.6.0',
registry: { enabled: false, status: 'disabled' },
plugins: [
installed({
id: 'acme.blog',
name: 'Blog',
description: 'Posts <b>and</b> categories.',
version: 'v0.1.0',
source: 'module',
module: 'example.test/acme-blog',
package: 'example.test/acme-blog',
}),
installed({ id: 'acme.local', source: 'local' }),
],
...overrides,
}
}
const envelope = (data: unknown) => ({ body: { data, meta: {} } })
async function openPlugins(data: PluginsOverview = overview(), routes: Record<string, Route> = {}) {
return mountApp('/system/plugins', { [`GET ${PLUGINS}`]: envelope(data), ...routes }, { attach: true })
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('PluginsView', () => {
it('renders the installed plugins from GET /plugins', async () => {
const { wrapper, calls } = await openPlugins()
expect(wrapper.findComponent(PluginsView).exists()).toBe(true)
expect(requestsTo(calls, 'GET', PLUGINS)).toHaveLength(1)
expect(wrapper.find('h1').text()).toBe('Wtyczki')
expect(wrapper.find('[data-plugins-framework]').text()).toBe('Framework v0.6.0')
const rows = wrapper.findAll('[data-plugin]')
expect(rows.map((row) => row.attributes('data-plugin'))).toEqual(['acme.blog', 'acme.local'])
const blog = rows[0]!
expect(blog.find('[data-plugin-name]').text()).toBe('Blog')
expect(blog.find('[data-plugin-version]').text()).toBe('v0.1.0')
expect(blog.find('[data-plugin-source]').text()).toBe('Moduł Go')
// Registry and plugin text is text, never HTML.
expect(blog.find('[data-plugin-description]').text()).toBe('Posts <b>and</b> categories.')
expect(blog.find('b').exists()).toBe(false)
const local = rows[1]!
expect(local.find('[data-plugin-name]').text()).toBe('acme.local')
expect(local.find('[data-plugin-version]').text()).toBe('Brak wersji')
expect(local.find('[data-plugin-source]').text()).toBe('W aplikacji')
})
it('shows an error when the list cannot be loaded', async () => {
const { wrapper } = await openPlugins(overview(), {
[`GET ${PLUGINS}`]: { status: 403, body: { error: { code: 'forbidden', message: 'Forbidden', details: {} } } },
})
expect(wrapper.find('[data-plugins-failed]').exists()).toBe(true)
expect(wrapper.findAll('[data-plugin]')).toHaveLength(0)
})
it('opens from the settings entry path', () => {
const entry: SettingsEntry = {
code: 'plugins',
label: 'Wtyczki',
description: '',
category: 'System',
icon: 'puzzle',
order: 120,
keywords: [],
model: '',
controller: '',
path: '/system/plugins',
}
expect(settingsPath(entry)).toBe('/system/plugins')
})
})