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

@@ -0,0 +1,126 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { CircleAlert } from '@lucide/vue'
import { api } from '../api/client'
import type { InstalledPlugin, PluginsOverview } from '../api/types'
import { t } from '../app/i18n'
// The read-only Plugins screen (Settings → System, backend.view_plugins).
// Installed lists the plugins the binary was built with, as summer build
// baked them (GET /plugins). Every string from the server is rendered as
// text, never as HTML.
const overview = ref<PluginsOverview | null>(null)
const failed = ref(false)
const loading = ref(true)
const tab = ref('installed')
const SOURCE_LABELS: Record<string, string> = {
module: 'backend::lang.plugins.source_module',
submodule: 'backend::lang.plugins.source_submodule',
local: 'backend::lang.plugins.source_local',
}
function sourceLabel(plugin: InstalledPlugin): string {
const key = SOURCE_LABELS[plugin.source]
return key ? t(key) : plugin.source
}
async function load(): Promise<void> {
loading.value = true
failed.value = false
try {
const { data, response } = await api.GET('/plugins')
if (response.ok && data) {
overview.value = data.data
} else {
failed.value = true
}
} catch {
failed.value = true
} finally {
loading.value = false
}
}
onMounted(load)
const tabClass =
'inline-flex h-[34px] items-center gap-2 rounded-tab px-4 font-semibold text-muted transition-colors duration-150 ease-out hover:text-text data-[state=active]:bg-surface data-[state=active]:text-text data-[state=active]:shadow-tab'
</script>
<template>
<section class="flex flex-col gap-5" data-plugins-screen>
<header class="flex flex-col gap-1">
<h1 class="text-[26px] font-bold tracking-[-0.02em]">{{ t('backend::lang.plugins.title') }}</h1>
<p v-if="overview?.framework" class="text-[13px] text-muted" data-plugins-framework>
{{ t('backend::lang.plugins.framework', { version: overview.framework }) }}
</p>
</header>
<p v-if="failed" role="alert" class="flex items-center gap-2 text-danger" data-plugins-failed>
<CircleAlert :size="16" class="shrink-0" aria-hidden="true" />
<span>{{ t('backend::lang.plugins.load_failed') }}</span>
</p>
<TabsRoot v-else-if="overview" v-model="tab" class="flex flex-col gap-4">
<TabsList
:aria-label="t('backend::lang.plugins.tabs_label')"
class="inline-flex gap-1 self-start rounded-inner border border-border bg-subtle p-1"
>
<TabsTrigger value="installed" data-tab="installed" :class="tabClass">
{{ t('backend::lang.plugins.tab_installed') }}
</TabsTrigger>
<TabsTrigger v-if="overview.registry.enabled" value="marketplace" data-tab="marketplace" :class="tabClass">
{{ t('backend::lang.plugins.tab_marketplace') }}
</TabsTrigger>
</TabsList>
<TabsContent value="installed" class="flex flex-col gap-3">
<p v-if="overview.plugins.length === 0" class="text-muted">{{ t('backend::lang.plugins.empty') }}</p>
<div v-else class="overflow-x-auto rounded-card border border-border bg-surface shadow-card">
<table class="w-full border-collapse text-left">
<thead>
<tr class="border-b border-border text-[13px] text-muted">
<th scope="col" class="px-5 py-3 font-semibold">{{ t('backend::lang.plugins.column_plugin') }}</th>
<th scope="col" class="px-5 py-3 font-semibold">{{ t('backend::lang.plugins.column_version') }}</th>
<th scope="col" class="px-5 py-3 font-semibold">{{ t('backend::lang.plugins.column_source') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="plugin in overview.plugins"
:key="plugin.id"
:data-plugin="plugin.id"
class="border-b border-border align-top last:border-b-0"
>
<td class="px-5 py-3">
<div class="flex min-w-0 flex-col">
<span class="font-semibold" data-plugin-name>{{ plugin.name || plugin.id }}</span>
<span v-if="plugin.name" class="font-mono text-[12px] text-muted">{{ plugin.id }}</span>
<span v-if="plugin.description" class="text-[13px] text-muted" data-plugin-description>{{
plugin.description
}}</span>
</div>
</td>
<td class="px-5 py-3 whitespace-nowrap">
<span v-if="plugin.version" class="font-mono text-[13px]" data-plugin-version>{{ plugin.version }}</span>
<span v-else class="text-[13px] text-muted" data-plugin-version>{{
t('backend::lang.plugins.no_version')
}}</span>
</td>
<td class="px-5 py-3 text-[13px] whitespace-nowrap" data-plugin-source>
{{ plugin.source ? sourceLabel(plugin) : '' }}
</td>
</tr>
</tbody>
</table>
</div>
</TabsContent>
<TabsContent v-if="overview.registry.enabled" value="marketplace" class="flex flex-col gap-4" />
</TabsRoot>
<p v-else-if="loading" class="text-muted" aria-busy="true">…</p>
</section>
</template>