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:
126
admin/src/views/PluginsView.vue
Normal file
126
admin/src/views/PluginsView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user