feat(cabana): plugin updates and marketplace search on the Plugins screen

- GET /plugins looks up each versioned plugin's releases through
  kiosk.Client and applies plugin:update's rule (NewestCompatible above
  the BaseVersion), with the update command and the framework a newer
  release needs
- GET /plugins/marketplace forwards q and category (validated like the
  registry), marks installed and incompatible plugins and returns the
  install command; ids outside the plugin ID shape are dropped
- 4 s per-call timeout, at most 4 lookups at once, in-process cache
  (searches 5 min, releases 15 min, failures 1 min); a failing registry
  gives status unavailable, an empty plugins.registry_url sends nothing
- SPA: update badges and Marketplace tab with copy buttons (clipboard
  fallback), hidden when the registry is disabled; no install button
- OpenAPI, SPA types, inventories, conformance case, en/pl strings, docs
This commit is contained in:
Jakub Zych
2026-10-29 19:20:14 +01:00
parent c4b1ac4076
commit 7e8a19399d
18 changed files with 1624 additions and 79 deletions

View File

@@ -1,14 +1,19 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { CircleAlert } from '@lucide/vue'
import { CircleArrowUp, Check, CircleAlert, Copy, Search, Star, TriangleAlert } from '@lucide/vue'
import { api } from '../api/client'
import type { InstalledPlugin, PluginsOverview } from '../api/types'
import type { InstalledPlugin, MarketplaceResponse, PluginsOverview } from '../api/types'
import { t } from '../app/i18n'
import Button from '../components/ui/Button.vue'
// 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
// baked them, with the newest release the built framework allows (GET
// /plugins). Marketplace searches the plugin registry through cabana (GET
// /plugins/marketplace); the SPA never calls the registry itself (D-17).
// Nothing is installed from here: each update and install shows its summer
// command with a copy button. Every string from the server is rendered as
// text, never as HTML.
const overview = ref<PluginsOverview | null>(null)
const failed = ref(false)
@@ -43,10 +48,136 @@ async function load(): Promise<void> {
}
}
// Marketplace: loaded when its tab first opens, then on every (debounced)
// change of the search or the category.
const SEARCH_DEBOUNCE_MS = 300
const query = ref('')
const category = ref('')
const market = ref<MarketplaceResponse | null>(null)
const marketLoading = ref(false)
const marketFailed = ref(false)
const categories = ref<string[]>([])
let searchTimer: ReturnType<typeof setTimeout> | null = null
let marketRequest = 0
async function loadMarket(): Promise<void> {
const request = ++marketRequest
marketLoading.value = true
marketFailed.value = false
const params: { q?: string; category?: string } = {}
if (query.value.trim() !== '') {
params.q = query.value.trim()
}
if (category.value !== '') {
params.category = category.value
}
try {
const { data, response } = await api.GET('/plugins/marketplace', { params: { query: params } })
if (request !== marketRequest) {
return
}
if (response.ok && data) {
market.value = data
if (data.meta.categories.length > 0) {
categories.value = [...data.meta.categories]
}
} else {
marketFailed.value = true
}
} catch {
if (request === marketRequest) {
marketFailed.value = true
}
} finally {
if (request === marketRequest) {
marketLoading.value = false
}
}
}
function clearSearchTimer(): void {
if (searchTimer !== null) {
clearTimeout(searchTimer)
searchTimer = null
}
}
function onSearch(value: string): void {
query.value = value
clearSearchTimer()
searchTimer = setTimeout(() => {
searchTimer = null
void loadMarket()
}, SEARCH_DEBOUNCE_MS)
}
function onCategory(value: string): void {
category.value = value
clearSearchTimer()
void loadMarket()
}
watch(tab, (next) => {
if (next === 'marketplace' && market.value === null && !marketLoading.value) {
void loadMarket()
}
})
// Copy buttons (the ApiTokenCreateDialog pattern): the copied state shows
// for two seconds; without a working clipboard the command is selected and
// a hint asks the administrator to copy it.
const copied = ref('')
const copyFailed = ref('')
const announce = ref('')
let copyTimer: ReturnType<typeof setTimeout> | null = null
function selectCommand(key: string): void {
const input = document.querySelector<HTMLInputElement>(`[data-command="${CSS.escape(key)}"]`)
input?.focus()
input?.select()
}
async function copyCommand(key: string, command: string): Promise<void> {
try {
if (!navigator.clipboard?.writeText) {
throw new Error('clipboard unavailable')
}
await navigator.clipboard.writeText(command)
copyFailed.value = ''
copied.value = key
announce.value = t('backend::lang.plugins.copied_announce')
if (copyTimer !== null) {
clearTimeout(copyTimer)
}
copyTimer = setTimeout(() => {
copied.value = ''
copyTimer = null
}, 2000)
} catch {
copied.value = ''
copyFailed.value = key
selectCommand(key)
}
}
function onCommandFocus(event: FocusEvent): void {
;(event.target as HTMLInputElement).select()
}
onMounted(load)
onBeforeUnmount(() => {
clearSearchTimer()
if (copyTimer !== null) {
clearTimeout(copyTimer)
}
})
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'
const commandClass =
'h-[38px] min-w-0 flex-1 rounded-control border border-border bg-subtle px-3 font-mono text-[13px] text-text'
const selectClass = 'h-button rounded-control border border-border-strong bg-surface px-3 text-text'
</script>
<template>
@@ -63,63 +194,249 @@ const tabClass =
<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"
<template v-else-if="overview">
<p
v-if="overview.registry.status === 'unavailable'"
role="status"
class="flex items-center gap-2 rounded-card border border-border bg-subtle px-4 py-3 text-[13px]"
data-registry-unavailable
>
<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>
<TriangleAlert :size="16" class="shrink-0 text-muted" aria-hidden="true" />
<span>{{ t('backend::lang.plugins.registry_unavailable') }}</span>
</p>
<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
<TabsRoot 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 gap-0.5">
<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
v-if="overview.registry.enabled && plugin.updateAvailable"
class="mt-2 flex max-w-[560px] flex-col gap-1.5"
data-plugin-update
>
<span
class="inline-flex items-center gap-1.5 self-start rounded-pill bg-sel px-2.5 py-0.5 text-[12px] font-semibold"
data-update-badge
>
<CircleArrowUp :size="14" aria-hidden="true" />
{{ t('backend::lang.plugins.update_available', { version: plugin.latest }) }}
</span>
<div class="flex items-center gap-2">
<label :for="`plugin-command-update-${plugin.id}`" class="sr-only">{{
t('backend::lang.plugins.command_label')
}}</label>
<input
:id="`plugin-command-update-${plugin.id}`"
:data-command="`update:${plugin.id}`"
:value="plugin.updateCommand"
type="text"
readonly
spellcheck="false"
:class="commandClass"
@focus="onCommandFocus"
/>
<Button
variant="outline"
size="sm"
:icon="copied === `update:${plugin.id}` ? Check : Copy"
data-action="copy-command"
@click="copyCommand(`update:${plugin.id}`, plugin.updateCommand)"
>
{{
copied === `update:${plugin.id}`
? t('backend::lang.plugins.copied')
: t('backend::lang.plugins.copy')
}}
</Button>
</div>
<p v-if="copyFailed === `update:${plugin.id}`" role="alert" class="text-[13px] text-danger" data-copy-failed>
{{ t('backend::lang.plugins.copy_failed') }}
</p>
</div>
<span
v-if="overview.registry.enabled && plugin.latestFramework"
class="text-[12px] text-muted"
data-plugin-needs-framework
>{{ t('backend::lang.plugins.needs_framework', { version: plugin.latestFramework }) }}</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>
</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>
<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>
<TabsContent v-if="overview.registry.enabled" value="marketplace" class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-3">
<label class="relative block w-[360px] max-w-full">
<span class="sr-only">{{ t('backend::lang.plugins.search_label') }}</span>
<Search
:size="18"
class="pointer-events-none absolute top-1/2 left-3.5 -translate-y-1/2 text-muted"
aria-hidden="true"
/>
<input
type="search"
maxlength="100"
data-market-search
:value="query"
:placeholder="t('backend::lang.plugins.search_placeholder')"
class="h-button w-full rounded-control border border-transparent bg-subtle pr-3.5 pl-10"
@input="onSearch(($event.target as HTMLInputElement).value)"
/>
</label>
<label class="flex flex-col gap-1 text-[13px]">
<span class="font-semibold text-muted">{{ t('backend::lang.plugins.category_label') }}</span>
<select
data-market-category
:value="category"
:class="selectClass"
@change="onCategory(($event.target as HTMLSelectElement).value)"
>
<option value="">{{ t('backend::lang.plugins.category_all') }}</option>
<option v-for="name in categories" :key="name" :value="name">{{ name }}</option>
</select>
</label>
</div>
<p class="text-[13px] text-muted">{{ t('backend::lang.plugins.install_hint') }}</p>
<p
v-if="marketFailed || market?.meta.registry.status === 'unavailable'"
role="status"
class="flex items-center gap-2 text-[13px]"
data-market-unavailable
>
<TriangleAlert :size="16" class="shrink-0 text-muted" aria-hidden="true" />
<span>{{ t('backend::lang.plugins.registry_unavailable') }}</span>
</p>
<p v-else-if="market && market.data.length === 0" class="text-muted" data-market-empty>
{{ t('backend::lang.plugins.marketplace_empty') }}
</p>
<div v-if="market && market.data.length > 0" class="grid grid-cols-1 gap-3 min-[1100px]:grid-cols-2">
<article
v-for="item in market.data"
:key="item.id"
:data-market-plugin="item.id"
class="flex flex-col gap-3 rounded-card border border-border bg-surface p-5 shadow-card"
>
<div class="flex items-start gap-3.5">
<img
v-if="item.iconUrl"
:src="item.iconUrl"
alt=""
loading="lazy"
referrerpolicy="no-referrer"
class="size-10 shrink-0 rounded-inner object-cover"
/>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<div class="flex flex-wrap items-center gap-2">
<span class="font-semibold" data-market-name>{{ item.name || item.id }}</span>
<span v-if="item.latestVersion" class="font-mono text-[12px] text-muted" data-market-version>{{
item.latestVersion
}}</span>
<Star v-if="item.featured" :size="14" class="text-muted" :aria-label="t('backend::lang.plugins.featured')" />
</div>
<span class="font-mono text-[12px] text-muted">{{ item.id }}</span>
<span v-if="item.description" class="text-[13px] text-muted" data-market-description>{{
item.description
}}</span>
</div>
<span
v-if="item.installed"
class="shrink-0 rounded-pill bg-subtle px-2.5 py-0.5 text-[12px] font-semibold"
data-market-marker="installed"
>{{ t('backend::lang.plugins.marker_installed') }}</span
>
<span
v-else-if="!item.compatible"
class="shrink-0 rounded-pill bg-subtle px-2.5 py-0.5 text-[12px] font-semibold text-muted"
data-market-marker="needs-framework"
>{{ t('backend::lang.plugins.marker_needs_framework', { version: item.latestFramework }) }}</span
>
</div>
<div class="flex items-center gap-2">
<label :for="`plugin-command-install-${item.id}`" class="sr-only">{{
t('backend::lang.plugins.command_label')
}}</label>
<input
:id="`plugin-command-install-${item.id}`"
:data-command="`install:${item.id}`"
:value="item.installCommand"
type="text"
readonly
spellcheck="false"
:class="commandClass"
@focus="onCommandFocus"
/>
<Button
variant="outline"
size="sm"
:icon="copied === `install:${item.id}` ? Check : Copy"
data-action="copy-command"
@click="copyCommand(`install:${item.id}`, item.installCommand)"
>
{{
copied === `install:${item.id}` ? t('backend::lang.plugins.copied') : t('backend::lang.plugins.copy')
}}
</Button>
</div>
<p v-if="copyFailed === `install:${item.id}`" role="alert" class="text-[13px] text-danger" data-copy-failed>
{{ t('backend::lang.plugins.copy_failed') }}
</p>
</article>
</div>
</TabsContent>
</TabsRoot>
<span class="sr-only" aria-live="polite">{{ announce }}</span>
</template>
<p v-else-if="loading" class="text-muted" aria-busy="true">…</p>
</section>