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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user