mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-03 00:33:13 +00:00
feat(security-audit): allow admin-managed audit node targets and polish pool UI
Let admins configure private/intranet Guard endpoints without destination-class blocking, and fix prompt-audit switch layout so thumbs and labels no longer overlap. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
df9d9e2e40
commit
18e698bed6
16 files changed
+342
-345
No files matched your search
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="mx-auto max-w-[1600px] pb-28">
|
||||
<div class="mx-auto max-w-[1600px]" :class="activeTab === 'config' && draft ? 'pb-28' : 'pb-8'">
|
||||
<header class="mb-6 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-primary-600 dark:text-primary-400">{{ t('nav.securityAudit') }}</p>
|
||||
@@ -18,44 +18,79 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm mt-3" @click="loadConfig">{{ t('admin.promptAudit.actions.retry') }}</button>
|
||||
</div>
|
||||
|
||||
<main v-else class="rounded-2xl border border-gray-200 bg-white px-4 shadow-sm dark:border-dark-700 dark:bg-dark-850 sm:px-6 lg:px-8">
|
||||
<RuntimeOverview :runtime="runtime" :loading="loading.runtime" :error="loadErrors.runtime" @refresh="loadRuntime" />
|
||||
<template v-else>
|
||||
<div class="mb-4" role="tablist" :aria-label="t('admin.promptAudit.title')">
|
||||
<div class="tabs inline-flex">
|
||||
<button
|
||||
v-for="tab in pageTabs"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
role="tab"
|
||||
class="tab"
|
||||
:class="{ 'tab-active': activeTab === tab.id }"
|
||||
:aria-selected="activeTab === tab.id"
|
||||
:data-test="`tab-${tab.id}`"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="draft">
|
||||
<EndpointPool
|
||||
:endpoints="draft.endpoints"
|
||||
:probe-results="probeResults"
|
||||
:probing-ids="probingIds"
|
||||
@update:endpoints="updateEndpoints"
|
||||
@probe="runProbe"
|
||||
/>
|
||||
<div v-if="loadErrors.groups" role="alert" class="mt-5 rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:bg-amber-950/30 dark:text-amber-200">{{ loadErrors.groups }}</div>
|
||||
<PolicyPanel :draft="draft" :groups="groups" @update:draft="replaceDraft" />
|
||||
</template>
|
||||
<main class="card px-4 sm:px-6 lg:px-8">
|
||||
<div v-show="activeTab === 'config'" data-test="tab-panel-config">
|
||||
<RuntimeOverview :runtime="runtime" :loading="loading.runtime" :error="loadErrors.runtime" @refresh="loadRuntime" />
|
||||
|
||||
<EventWorkspace
|
||||
:events="events.items"
|
||||
:total="events.total"
|
||||
:page="events.page"
|
||||
:page-size="events.page_size"
|
||||
:filters="filters"
|
||||
:selected-ids="selectedEventIds"
|
||||
:loading="loading.events"
|
||||
:error="loadErrors.events"
|
||||
@filters-change="handleFiltersChanged"
|
||||
@search="applyEventFilters"
|
||||
@selection="selectedEventIds = $event"
|
||||
@page="changePage"
|
||||
@page-size="changePageSize"
|
||||
@view="openEvent"
|
||||
@delete="requestSingleDelete"
|
||||
@batch-delete="requestBatchDelete"
|
||||
@preview-delete="requestFilterDeletePreview"
|
||||
/>
|
||||
</main>
|
||||
<template v-if="draft">
|
||||
<EndpointPool
|
||||
:endpoints="draft.endpoints"
|
||||
:probe-results="probeResults"
|
||||
:probing-ids="probingIds"
|
||||
@update:endpoints="updateEndpoints"
|
||||
@probe="runProbe"
|
||||
/>
|
||||
<div v-if="loadErrors.groups" role="alert" class="mt-5 rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:bg-amber-950/30 dark:text-amber-200">{{ loadErrors.groups }}</div>
|
||||
<PolicyPanel :draft="draft" :groups="groups" @update:draft="replaceDraft" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'events'" data-test="tab-panel-events">
|
||||
<div
|
||||
v-if="draft?.enabled && !draft.store_pass_events"
|
||||
data-test="pass-events-disabled-notice"
|
||||
role="status"
|
||||
class="mt-6 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/30 dark:text-amber-200"
|
||||
>
|
||||
<span>{{ t('admin.promptAudit.events.passEventsDisabled') }}</span>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="activeTab = 'config'">
|
||||
{{ t('admin.promptAudit.events.openConfiguration') }}
|
||||
</button>
|
||||
</div>
|
||||
<EventWorkspace
|
||||
:events="events.items"
|
||||
:total="events.total"
|
||||
:page="events.page"
|
||||
:page-size="events.page_size"
|
||||
:filters="filters"
|
||||
:selected-ids="selectedEventIds"
|
||||
:loading="loading.events"
|
||||
:error="loadErrors.events"
|
||||
@filters-change="handleFiltersChanged"
|
||||
@search="applyEventFilters"
|
||||
@selection="selectedEventIds = $event"
|
||||
@page="changePage"
|
||||
@page-size="changePageSize"
|
||||
@view="openEvent"
|
||||
@delete="requestSingleDelete"
|
||||
@batch-delete="requestBatchDelete"
|
||||
@preview-delete="requestFilterDeletePreview"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="draft" class="fixed inset-x-0 bottom-0 z-30 border-t border-gray-200 bg-white/95 px-4 py-3 shadow-[0_-12px_35px_rgba(15,23,42,0.08)] backdrop-blur dark:border-dark-700 dark:bg-dark-900/95 lg:left-64">
|
||||
<div v-if="draft && activeTab === 'config'" class="fixed inset-x-0 bottom-0 z-30 border-t border-gray-200 bg-white/95 px-4 py-3 shadow-[0_-12px_35px_rgba(15,23,42,0.08)] backdrop-blur dark:border-dark-700/80 dark:bg-dark-900/95 dark:shadow-[0_-12px_35px_rgba(0,0,0,0.35)] lg:left-64">
|
||||
<div class="mx-auto flex max-w-[1600px] flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-x-5 gap-y-2">
|
||||
<SaveToggle :label="t('admin.promptAudit.saveBar.enabled')" :model-value="draft.enabled" data-test="enabled-toggle" @update:model-value="setEnabled" />
|
||||
@@ -143,6 +178,12 @@ import { buildUpdateRequest, cloneData, configToDraft, draftFingerprint, emptyEv
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
type PromptAuditPageTab = 'config' | 'events'
|
||||
const activeTab = ref<PromptAuditPageTab>('config')
|
||||
const pageTabs = computed(() => [
|
||||
{ id: 'events' as const, label: t('admin.promptAudit.tabs.events') },
|
||||
{ id: 'config' as const, label: t('admin.promptAudit.tabs.config') },
|
||||
])
|
||||
const serverConfig = ref<PromptAuditDraft | null>(null)
|
||||
const draft = ref<PromptAuditDraft | null>(null)
|
||||
const runtime = ref<PromptAuditRuntime | null>(null)
|
||||
@@ -167,13 +208,32 @@ const SaveToggle = defineComponent({
|
||||
props: { label: { type: String, required: true }, modelValue: { type: Boolean, required: true }, disabled: { type: Boolean, default: false } },
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit, attrs }) {
|
||||
return () => h('label', { class: ['flex items-center gap-2 text-sm', props.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'] }, [
|
||||
return () => h('label', { class: ['flex items-center gap-2.5 text-sm', props.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'] }, [
|
||||
h('button', {
|
||||
...attrs, type: 'button', role: 'switch', 'aria-checked': props.modelValue, 'aria-label': props.label, disabled: props.disabled,
|
||||
class: ['relative h-6 w-11 rounded-full transition-colors', props.modelValue ? 'bg-primary-600' : 'bg-gray-300 dark:bg-dark-600'],
|
||||
onClick: () => !props.disabled && emit('update:modelValue', !props.modelValue),
|
||||
}, [h('span', { class: ['absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform', props.modelValue ? 'translate-x-5' : 'translate-x-0.5'] })]),
|
||||
h('span', { class: 'text-gray-700 dark:text-dark-200' }, props.label),
|
||||
...attrs,
|
||||
type: 'button',
|
||||
role: 'switch',
|
||||
'aria-checked': props.modelValue,
|
||||
'aria-label': props.label,
|
||||
disabled: props.disabled,
|
||||
class: [
|
||||
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2',
|
||||
props.modelValue ? 'bg-primary-600' : 'bg-gray-300 dark:bg-dark-600',
|
||||
props.disabled ? 'cursor-not-allowed' : 'cursor-pointer',
|
||||
],
|
||||
onClick: (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
if (!props.disabled) emit('update:modelValue', !props.modelValue)
|
||||
},
|
||||
}, [
|
||||
h('span', {
|
||||
class: [
|
||||
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transition-transform duration-200 ease-in-out',
|
||||
props.modelValue ? 'translate-x-5' : 'translate-x-0',
|
||||
],
|
||||
}),
|
||||
]),
|
||||
h('span', { class: 'select-none text-gray-700 dark:text-dark-200' }, props.label),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
@@ -82,6 +82,36 @@ describe('PromptAuditView', () => {
|
||||
expect(wrapper.find('[data-test="events"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('separates configuration and audit events into page tabs', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('[data-test="tab-config"]').attributes('aria-selected')).toBe('true')
|
||||
expect(wrapper.get('[data-test="tab-events"]').attributes('aria-selected')).toBe('false')
|
||||
expect(wrapper.get('[data-test="tab-panel-config"]').attributes('style') || '').not.toContain('display: none')
|
||||
expect(wrapper.get('[data-test="tab-panel-events"]').attributes('style') || '').toContain('display: none')
|
||||
expect(wrapper.find('[data-test="save-config"]').exists()).toBe(true)
|
||||
expect(wrapper.get('[data-test="tab-events"]').text()).toContain('admin.promptAudit.tabs.events')
|
||||
expect(wrapper.get('[data-test="tab-config"]').text()).toContain('admin.promptAudit.tabs.config')
|
||||
|
||||
await wrapper.get('[data-test="tab-events"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[data-test="tab-events"]').attributes('aria-selected')).toBe('true')
|
||||
expect(wrapper.get('[data-test="tab-panel-config"]').attributes('style') || '').toContain('display: none')
|
||||
expect(wrapper.get('[data-test="tab-panel-events"]').attributes('style') || '').not.toContain('display: none')
|
||||
expect(wrapper.find('[data-test="save-config"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-test="events"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-test="pass-events-disabled-notice"]').exists()).toBe(true)
|
||||
|
||||
await wrapper.get('[data-test="pass-events-disabled-notice"] button').trigger('click')
|
||||
expect(wrapper.get('[data-test="tab-config"]').attributes('aria-selected')).toBe('true')
|
||||
|
||||
await wrapper.get('[data-test="tab-config"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-test="save-config"]').exists()).toBe(true)
|
||||
expect(wrapper.get('[data-test="tab-panel-config"]').attributes('style') || '').not.toContain('display: none')
|
||||
})
|
||||
|
||||
it('requires confirmation for blocking and disables it when audit is turned off', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<section aria-labelledby="prompt-pool-title" class="border-b border-gray-200 py-6 dark:border-dark-700">
|
||||
<section aria-labelledby="prompt-pool-title" class="border-b border-gray-200 py-6 dark:border-dark-700/60">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 id="prompt-pool-title" class="text-base font-semibold text-gray-950 dark:text-white">{{ t('admin.promptAudit.pool.title') }}</h2>
|
||||
@@ -10,65 +10,86 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="endpoints.length === 0" class="mt-5 rounded-lg border border-dashed border-gray-300 px-5 py-8 text-center text-sm text-gray-500 dark:border-dark-600 dark:text-dark-300">
|
||||
<div v-if="endpoints.length === 0" class="mt-5 rounded-xl border border-dashed border-gray-300 px-5 py-10 text-center text-sm text-gray-500 dark:border-dark-600 dark:bg-dark-900/20 dark:text-dark-300">
|
||||
{{ t('admin.promptAudit.pool.empty') }}
|
||||
</div>
|
||||
<div v-else class="mt-5 overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead class="border-b border-gray-200 text-xs uppercase tracking-wide text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||
<tr>
|
||||
<th class="px-3 py-2 font-medium">{{ t('admin.promptAudit.pool.node') }}</th>
|
||||
<th class="px-3 py-2 font-medium">{{ t('admin.promptAudit.pool.model') }}</th>
|
||||
<th class="px-3 py-2 font-medium">{{ t('admin.promptAudit.pool.limits') }}</th>
|
||||
<th class="px-3 py-2 font-medium">{{ t('admin.promptAudit.pool.credential') }}</th>
|
||||
<th class="px-3 py-2 text-right font-medium">{{ t('admin.promptAudit.common.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-dark-800">
|
||||
<tr v-for="endpoint in endpoints" :key="endpoint.id" :data-test="`endpoint-${endpoint.id}`">
|
||||
<td class="px-3 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="endpoint.enabled"
|
||||
:aria-label="t('admin.promptAudit.pool.toggleNode', { name: endpoint.name })"
|
||||
class="relative h-5 w-9 rounded-full transition-colors"
|
||||
:class="endpoint.enabled ? 'bg-primary-600' : 'bg-gray-300 dark:bg-dark-600'"
|
||||
@click="toggleEndpoint(endpoint.id)"
|
||||
>
|
||||
<span class="absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform" :class="endpoint.enabled ? 'translate-x-4' : 'translate-x-0.5'" />
|
||||
</button>
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium text-gray-900 dark:text-white">{{ endpoint.name }}</p>
|
||||
<p class="max-w-xs truncate text-xs text-gray-500 dark:text-dark-400">{{ endpoint.base_url }}</p>
|
||||
</div>
|
||||
<div v-else class="mt-5 overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-dark-700/60 dark:bg-dark-900/20">
|
||||
<div class="hidden grid-cols-[minmax(260px,1.45fr)_minmax(210px,1fr)_minmax(190px,.8fr)_minmax(230px,1.15fr)_auto] gap-5 border-b border-l-[3px] border-b-gray-200 border-l-transparent bg-gray-50/80 px-5 py-2.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-gray-500 dark:border-b-dark-700/60 dark:bg-dark-900/70 dark:text-dark-400 xl:grid">
|
||||
<span>{{ t('admin.promptAudit.pool.node') }}</span>
|
||||
<span>{{ t('admin.promptAudit.pool.model') }}</span>
|
||||
<span>{{ t('admin.promptAudit.pool.limits') }}</span>
|
||||
<span>{{ t('admin.promptAudit.pool.credential') }}</span>
|
||||
<span class="text-right">{{ t('admin.promptAudit.common.actions') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-gray-100 dark:divide-dark-800">
|
||||
<article
|
||||
v-for="endpoint in endpoints"
|
||||
:key="endpoint.id"
|
||||
:data-test="`endpoint-${endpoint.id}`"
|
||||
class="group grid gap-4 border-l-[3px] border-l-transparent px-4 py-4 transition-[background-color,border-color] duration-200 hover:border-l-primary-500 hover:bg-gray-50/80 dark:hover:bg-dark-800/55 sm:px-5 xl:grid-cols-[minmax(260px,1.45fr)_minmax(210px,1fr)_minmax(190px,.8fr)_minmax(230px,1.15fr)_auto] xl:items-center xl:gap-5"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="endpoint.enabled"
|
||||
:aria-label="t('admin.promptAudit.pool.toggleNode', { name: endpoint.name })"
|
||||
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
|
||||
:class="endpoint.enabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'"
|
||||
@click="toggleEndpoint(endpoint.id)"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transition-transform duration-200 ease-in-out"
|
||||
:class="endpoint.enabled ? 'translate-x-5' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
<div class="min-w-0">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<p class="truncate font-semibold text-gray-950 dark:text-white">{{ endpoint.name }}</p>
|
||||
<span class="h-1.5 w-1.5 shrink-0 rounded-full" :class="endpoint.enabled ? 'bg-emerald-500' : 'bg-gray-300 dark:bg-dark-500'" aria-hidden="true" />
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-3 text-gray-700 dark:text-dark-200">{{ endpoint.model }}</td>
|
||||
<td class="whitespace-nowrap px-3 py-3 text-gray-600 dark:text-dark-300">{{ endpoint.timeout_ms }} ms · {{ endpoint.input_limit }} chars</td>
|
||||
<td class="px-3 py-3">
|
||||
<span :class="hasCredential(endpoint) ? 'text-emerald-600 dark:text-emerald-300' : 'text-gray-500 dark:text-dark-400'">
|
||||
{{ hasCredential(endpoint) ? t('admin.promptAudit.pool.configured') : t('admin.promptAudit.pool.missing') }}
|
||||
</span>
|
||||
<p v-if="probingIds.includes(endpoint.id)" class="mt-1 text-xs text-primary-600 dark:text-primary-300">
|
||||
{{ t('admin.promptAudit.pool.probeProgress') }}
|
||||
</p>
|
||||
<p v-if="probeResults[endpoint.id]" class="mt-1 text-xs" :class="probeResults[endpoint.id].ok ? 'text-emerald-600' : 'text-red-600'">
|
||||
{{ t('admin.promptAudit.pool.probeResult', { status: probeResults[endpoint.id].status, http: probeResults[endpoint.id].http_status || '—', latency: probeResults[endpoint.id].latency_ms }) }}
|
||||
· {{ probeResults[endpoint.id].message }}
|
||||
</p>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-3 text-right">
|
||||
<button type="button" class="btn btn-ghost btn-sm" :disabled="probingIds.includes(endpoint.id)" @click="$emit('probe', endpoint)">
|
||||
{{ probingIds.includes(endpoint.id) ? t('admin.promptAudit.pool.probing') : t('admin.promptAudit.pool.probe') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="openEdit(endpoint)">{{ t('common.edit') }}</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm text-red-600" @click="removeEndpoint(endpoint)">{{ t('common.delete') }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="mt-0.5 truncate font-mono text-[11px] text-gray-500 dark:text-dark-400" :title="endpoint.base_url">{{ endpoint.base_url }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 xl:block">
|
||||
<p class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 xl:hidden">{{ t('admin.promptAudit.pool.model') }}</p>
|
||||
<p class="truncate text-sm font-medium text-gray-700 dark:text-dark-200" :title="endpoint.model">{{ endpoint.model }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 xl:hidden">{{ t('admin.promptAudit.pool.limits') }}</p>
|
||||
<div class="flex flex-wrap gap-1.5 text-xs text-gray-600 dark:text-dark-300">
|
||||
<span class="rounded-md bg-gray-100 px-2 py-1 tabular-nums dark:bg-dark-800">{{ endpoint.timeout_ms }} ms</span>
|
||||
<span class="rounded-md bg-gray-100 px-2 py-1 tabular-nums dark:bg-dark-800">{{ endpoint.input_limit }} chars</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<p class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 xl:hidden">{{ t('admin.promptAudit.pool.credential') }}</p>
|
||||
<div class="flex items-center gap-1.5 text-xs font-medium" :class="hasCredential(endpoint) ? 'text-emerald-700 dark:text-emerald-300' : 'text-gray-500 dark:text-dark-400'">
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="hasCredential(endpoint) ? 'bg-emerald-500' : 'bg-gray-300 dark:bg-dark-500'" aria-hidden="true" />
|
||||
{{ hasCredential(endpoint) ? t('admin.promptAudit.pool.configured') : t('admin.promptAudit.pool.missing') }}
|
||||
</div>
|
||||
<p v-if="probingIds.includes(endpoint.id)" class="mt-1.5 text-xs text-primary-600 dark:text-primary-300">
|
||||
{{ t('admin.promptAudit.pool.probeProgress') }}
|
||||
</p>
|
||||
<p v-if="probeResults[endpoint.id]" class="mt-1.5 line-clamp-2 text-xs leading-5" :class="probeResults[endpoint.id].ok ? 'text-emerald-600 dark:text-emerald-300' : 'text-red-600 dark:text-red-300'">
|
||||
{{ t('admin.promptAudit.pool.probeResult', { status: probeResults[endpoint.id].status, http: probeResults[endpoint.id].http_status || '—', latency: probeResults[endpoint.id].latency_ms }) }}
|
||||
· {{ probeResults[endpoint.id].message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-end gap-1 border-t border-gray-100 pt-3 dark:border-dark-800 xl:flex-nowrap xl:border-0 xl:pt-0">
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="probingIds.includes(endpoint.id)" @click="$emit('probe', endpoint)">
|
||||
{{ probingIds.includes(endpoint.id) ? t('admin.promptAudit.pool.probing') : t('admin.promptAudit.pool.probe') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="openEdit(endpoint)">{{ t('common.edit') }}</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-950/30" @click="removeEndpoint(endpoint)">{{ t('common.delete') }}</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseDialog :show="Boolean(editing)" :title="editingIndex < 0 ? t('admin.promptAudit.pool.add') : t('admin.promptAudit.pool.edit')" width="wide" @close="closeEditor">
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
</div>
|
||||
|
||||
<form class="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5" @submit.prevent="applyFilters">
|
||||
<label class="text-xs text-gray-600 dark:text-dark-300">
|
||||
<label class="text-xs text-gray-600 dark:text-dark-200">
|
||||
<span>{{ t('admin.promptAudit.events.decision') }}</span>
|
||||
<select v-model="localFilters.decision" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.decision')" @change="filtersChanged">
|
||||
<option value="">{{ t('common.all') }}</option><option value="pass">pass</option><option value="flag">flag</option><option value="critical">critical</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-dark-300">
|
||||
<label class="text-xs text-gray-600 dark:text-dark-200">
|
||||
<span>{{ t('admin.promptAudit.events.risk') }}</span>
|
||||
<select v-model="localFilters.risk_level" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.risk')" @change="filtersChanged">
|
||||
<option value="">{{ t('common.all') }}</option><option value="low">low</option><option value="medium">medium</option><option value="high">high</option><option value="critical">critical</option>
|
||||
@@ -35,11 +35,11 @@
|
||||
<FilterInput v-model="localFilters.request_id" label="Request ID" @change="filtersChanged" />
|
||||
<FilterInput v-model="localFilters.prompt_hash" label="Prompt SHA-256" @change="filtersChanged" />
|
||||
<FilterInput v-model="localFilters.keyword" :label="t('admin.promptAudit.events.keyword')" @change="filtersChanged" />
|
||||
<label class="text-xs text-gray-600 dark:text-dark-300">
|
||||
<label class="text-xs text-gray-600 dark:text-dark-200">
|
||||
<span>{{ t('admin.promptAudit.events.startAt') }}</span>
|
||||
<input v-model="localFilters.start_at" type="datetime-local" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.startAt')" @change="filtersChanged" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-dark-300">
|
||||
<label class="text-xs text-gray-600 dark:text-dark-200">
|
||||
<span>{{ t('admin.promptAudit.events.endAt') }}</span>
|
||||
<input v-model="localFilters.end_at" type="datetime-local" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.endAt')" @change="filtersChanged" />
|
||||
</label>
|
||||
@@ -51,9 +51,9 @@
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.events.deleteRangeHint') }}</p>
|
||||
|
||||
<div v-if="error" role="alert" class="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-300">{{ error }}</div>
|
||||
<div class="mt-5 overflow-x-auto rounded-lg border border-gray-200 dark:border-dark-700">
|
||||
<div class="mt-5 overflow-x-auto rounded-xl border border-gray-200 dark:border-dark-700/60">
|
||||
<table class="min-w-[1120px] w-full text-left text-sm">
|
||||
<thead class="bg-gray-50 text-xs uppercase tracking-wide text-gray-500 dark:bg-dark-900 dark:text-dark-400">
|
||||
<thead class="bg-gray-50 text-xs uppercase tracking-wide text-gray-500 dark:bg-dark-900/70 dark:text-dark-400">
|
||||
<tr>
|
||||
<th class="w-10 px-3 py-3"><input type="checkbox" :checked="allSelected" :aria-label="t('admin.promptAudit.events.selectAll')" @change="toggleAll" /></th>
|
||||
<th class="px-3 py-3 font-medium">{{ t('admin.promptAudit.events.time') }}</th>
|
||||
@@ -65,7 +65,7 @@
|
||||
<th class="px-3 py-3 text-right font-medium">{{ t('admin.promptAudit.common.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 bg-white dark:divide-dark-800 dark:bg-dark-850">
|
||||
<tbody class="divide-y divide-gray-100 bg-white dark:divide-dark-700 dark:bg-transparent">
|
||||
<tr v-if="loading"><td colspan="8" class="px-4 py-12 text-center text-gray-500" aria-busy="true">{{ t('common.loading') }}</td></tr>
|
||||
<tr v-else-if="events.length === 0"><td colspan="8" class="px-4 py-12 text-center text-gray-500">{{ t('admin.promptAudit.events.empty') }}</td></tr>
|
||||
<tr v-for="event in events" v-else :key="event.id" :data-test="`event-${event.id}`" class="align-top hover:bg-gray-50/70 dark:hover:bg-dark-800/70">
|
||||
@@ -129,7 +129,7 @@ const FilterInput = defineComponent({
|
||||
props: { modelValue: { type: String, required: true }, label: { type: String, required: true }, type: { type: String, default: 'text' } },
|
||||
emits: ['update:modelValue', 'change'],
|
||||
setup(componentProps, { emit: componentEmit }) {
|
||||
return () => h('label', { class: 'text-xs text-gray-600 dark:text-dark-300' }, [
|
||||
return () => h('label', { class: 'text-xs text-gray-600 dark:text-dark-200' }, [
|
||||
h('span', componentProps.label),
|
||||
h('input', {
|
||||
value: componentProps.modelValue, type: componentProps.type, class: 'input mt-1 w-full', 'aria-label': componentProps.label,
|
||||
@@ -144,7 +144,7 @@ const CopyLine = defineComponent({
|
||||
props: { label: { type: String, required: true }, value: { type: String, default: '' } },
|
||||
setup(componentProps) {
|
||||
return () => h('div', { class: 'flex max-w-56 items-center gap-1 text-xs' }, [
|
||||
h('span', { class: 'w-16 flex-none text-gray-500' }, componentProps.label),
|
||||
h('span', { class: 'w-16 flex-none text-gray-500 dark:text-dark-400' }, componentProps.label),
|
||||
h('span', { class: 'min-w-0 flex-1 truncate text-gray-800 dark:text-dark-100' }, componentProps.value || '—'),
|
||||
componentProps.value ? h('button', {
|
||||
type: 'button', class: 'text-primary-600 hover:underline', 'aria-label': `${t('common.copy')} ${componentProps.label}`,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<section aria-labelledby="prompt-policy-title" class="border-b border-gray-200 py-6 dark:border-dark-700">
|
||||
<section aria-labelledby="prompt-policy-title" class="py-6">
|
||||
<div>
|
||||
<h2 id="prompt-policy-title" class="text-base font-semibold text-gray-950 dark:text-white">{{ t('admin.promptAudit.policy.title') }}</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-dark-300">{{ t('admin.promptAudit.policy.description') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-7 lg:grid-cols-[minmax(0,1fr)_minmax(280px,0.55fr)]">
|
||||
<div>
|
||||
<div class="mt-5 grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(260px,0.45fr)]">
|
||||
<div class="rounded-xl border border-gray-200 p-4 dark:border-dark-700/60 dark:bg-dark-900/20 sm:p-5">
|
||||
<fieldset>
|
||||
<legend class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.policy.scope') }}</legend>
|
||||
<div class="mt-3 flex flex-wrap gap-5 text-sm text-gray-700 dark:text-dark-200">
|
||||
@@ -42,7 +42,7 @@
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.policy.selectedCount', { count: draft.group_ids.length }) }}</p>
|
||||
</div>
|
||||
|
||||
<fieldset class="mt-6">
|
||||
<fieldset class="mt-5 border-t border-gray-100 pt-5 dark:border-dark-800">
|
||||
<legend class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.policy.scanners') }}</legend>
|
||||
<div class="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
<label v-for="scanner in SCANNER_CATALOG" :key="scanner.id" class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-700 hover:bg-gray-50 dark:text-dark-200 dark:hover:bg-dark-800">
|
||||
@@ -53,7 +53,7 @@
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5 border-gray-200 lg:border-l lg:pl-7 dark:border-dark-700">
|
||||
<div class="space-y-4 rounded-xl border border-gray-200 p-4 dark:border-dark-700/60 dark:bg-dark-900/20 sm:p-5">
|
||||
<label class="block text-sm text-gray-700 dark:text-dark-200">
|
||||
<span>{{ t('admin.promptAudit.policy.workerCount') }}</span>
|
||||
<input :value="draft.worker_count" type="number" min="1" max="32" class="input mt-1.5 w-full" :aria-label="t('admin.promptAudit.policy.workerCount')" @input="patch({ worker_count: Number(($event.target as HTMLInputElement).value) })" />
|
||||
@@ -62,7 +62,7 @@
|
||||
<span>{{ t('admin.promptAudit.policy.queueCapacity') }}</span>
|
||||
<input :value="draft.queue_capacity" type="number" min="1" max="100000" class="input mt-1.5 w-full" :aria-label="t('admin.promptAudit.policy.queueCapacity')" @input="patch({ queue_capacity: Number(($event.target as HTMLInputElement).value) })" />
|
||||
</label>
|
||||
<div class="rounded-lg bg-gray-50 px-4 py-3 text-sm text-gray-600 dark:bg-dark-800 dark:text-dark-300">
|
||||
<div class="rounded-lg bg-gray-50 px-4 py-3 text-sm text-gray-600 dark:bg-dark-900/50 dark:text-dark-300">
|
||||
<p class="font-medium text-gray-800 dark:text-dark-100">{{ t('admin.promptAudit.policy.strategy') }}</p>
|
||||
<p class="mt-1">priority · {{ t('admin.promptAudit.policy.strategyHint') }}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<section aria-labelledby="prompt-runtime-title" class="border-b border-gray-200 pb-6 dark:border-dark-700">
|
||||
<section aria-labelledby="prompt-runtime-title" class="border-b border-gray-200 py-6 dark:border-dark-700/60">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 id="prompt-runtime-title" class="text-base font-semibold text-gray-950 dark:text-white">
|
||||
@@ -14,59 +14,33 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" role="alert" class="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-300">
|
||||
<div v-if="error" role="alert" class="mt-5 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-300">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div v-else-if="loading && !runtime" class="mt-5 grid grid-cols-2 gap-4 lg:grid-cols-6" aria-busy="true">
|
||||
<div v-for="index in 6" :key="index" class="h-14 animate-pulse rounded-lg bg-gray-100 dark:bg-dark-800" />
|
||||
<div v-else-if="loading && !runtime" class="mt-5 grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6" aria-busy="true">
|
||||
<div v-for="index in 6" :key="index" class="h-16 animate-pulse rounded-xl bg-gray-100 dark:bg-dark-800" />
|
||||
</div>
|
||||
<template v-else-if="runtime">
|
||||
<dl class="mt-5 grid grid-cols-2 gap-x-6 gap-y-5 lg:grid-cols-6">
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wide text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.runtime.process') }}</dt>
|
||||
<dd class="mt-1 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<span class="h-2 w-2 rounded-full" :class="statusDot(runtime.process_status)" />
|
||||
{{ t(`admin.promptAudit.status.${runtime.process_status}`) }}
|
||||
<dl class="mt-5 grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||
<div v-for="item in statusItems" :key="item.label" class="rounded-xl border border-gray-100 bg-gray-50/80 px-3 py-3 dark:border-dark-700/60 dark:bg-dark-900/40">
|
||||
<dt class="text-xs text-gray-500 dark:text-dark-400">{{ item.label }}</dt>
|
||||
<dd class="mt-1.5 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<span v-if="item.dot" class="h-2 w-2 shrink-0 rounded-full" :class="item.dot" />
|
||||
<span class="min-w-0 truncate">{{ item.value }}</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wide text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.runtime.mode') }}</dt>
|
||||
<dd class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">{{ t(`admin.promptAudit.mode.${runtime.effective_mode}`) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wide text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.runtime.version') }}</dt>
|
||||
<dd class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ runtime.active_config_version }} / {{ runtime.expected_config_version }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wide text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.runtime.workers') }}</dt>
|
||||
<dd class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">{{ runtime.worker_active }} / {{ runtime.worker_total }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wide text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.runtime.queue') }}</dt>
|
||||
<dd class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">{{ runtime.queue.active }} / {{ runtime.queue_capacity }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wide text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.runtime.dependencies') }}</dt>
|
||||
<dd class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">DB {{ runtime.database_status }} · Redis {{ runtime.redis_status }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="mt-5 grid gap-4 border-t border-gray-100 pt-5 dark:border-dark-800 lg:grid-cols-[1.2fr_1fr]">
|
||||
<div class="min-w-0">
|
||||
<div class="mt-4 grid gap-3 lg:grid-cols-[minmax(0,1.4fr)_minmax(220px,0.6fr)]">
|
||||
<div class="rounded-xl border border-gray-100 px-4 py-3 dark:border-dark-700/60 dark:bg-dark-900/20">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.runtime.guardMetrics') }}</h3>
|
||||
<div class="mt-3 flex flex-wrap gap-x-5 gap-y-2 text-sm text-gray-600 dark:text-dark-300">
|
||||
<span>{{ t('admin.promptAudit.metrics.total') }} <strong>{{ runtime.guard_metrics.total }}</strong></span>
|
||||
<span>{{ t('admin.promptAudit.metrics.allowed') }} <strong>{{ runtime.guard_metrics.allowed }}</strong></span>
|
||||
<span>{{ t('admin.promptAudit.metrics.flagged') }} <strong>{{ runtime.guard_metrics.flagged }}</strong></span>
|
||||
<span>{{ t('admin.promptAudit.metrics.blocked') }} <strong>{{ runtime.guard_metrics.blocked }}</strong></span>
|
||||
<span>{{ t('admin.promptAudit.metrics.unavailable') }} <strong>{{ runtime.guard_metrics.unavailable }}</strong></span>
|
||||
<span>{{ t('admin.promptAudit.metrics.timeouts') }} <strong>{{ runtime.guard_metrics.timeouts }}</strong></span>
|
||||
<span>{{ t('admin.promptAudit.metrics.failovers') }} <strong>{{ runtime.guard_metrics.failovers }}</strong></span>
|
||||
<span v-if="runtime.guard_metrics.latency_p95_ms != null">P95 <strong>{{ runtime.guard_metrics.latency_p95_ms }} ms</strong></span>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<div v-for="metric in guardMetricItems" :key="metric.label" class="rounded-lg bg-gray-50 px-2.5 py-2 dark:bg-dark-900/60">
|
||||
<p class="text-[11px] text-gray-500 dark:text-dark-400">{{ metric.label }}</p>
|
||||
<p class="mt-0.5 text-sm font-semibold tabular-nums text-gray-900 dark:text-white">{{ metric.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-gray-500 dark:text-dark-400">
|
||||
<p class="mt-3 text-xs leading-5 text-gray-500 dark:text-dark-400">
|
||||
{{ t('admin.promptAudit.runtime.queueBreakdown', {
|
||||
queued: runtime.queue.queued,
|
||||
processing: runtime.queue.processing,
|
||||
@@ -74,12 +48,11 @@
|
||||
done: runtime.queue.done,
|
||||
failed: runtime.queue.failed,
|
||||
}) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-dark-400">
|
||||
<span class="mx-1.5 text-gray-300 dark:text-dark-600">·</span>
|
||||
{{ t('admin.promptAudit.runtime.deliveryTotals', { enqueued: runtime.enqueued_total, dropped: runtime.dropped_total, processed: runtime.processed_total, failed: runtime.failed_total }) }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="rounded-xl border border-gray-100 px-4 py-3 dark:border-dark-700/60 dark:bg-dark-900/20">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.runtime.latest') }}</h3>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-dark-300">
|
||||
{{ runtime.last_processed_at ? formatDate(runtime.last_processed_at) : t('admin.promptAudit.common.never') }}
|
||||
@@ -88,7 +61,7 @@
|
||||
{{ runtime.last_error_code }}<span v-if="runtime.last_error_message"> · {{ runtime.last_error_message }}</span>
|
||||
</p>
|
||||
<div v-if="Object.keys(runtime.endpoints).length" class="mt-3 flex flex-wrap gap-2">
|
||||
<span v-for="(probe, id) in runtime.endpoints" :key="id" class="rounded-full px-2 py-1 text-xs" :class="probe.ok ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300' : 'bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-300'">
|
||||
<span v-for="(probe, id) in runtime.endpoints" :key="id" class="rounded-md px-2 py-1 text-xs" :class="probe.ok ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300' : 'bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-300'">
|
||||
{{ id }} · {{ probe.status }} · {{ probe.latency_ms }} ms
|
||||
</span>
|
||||
</div>
|
||||
@@ -99,13 +72,42 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { PromptAuditRuntime } from '../types'
|
||||
|
||||
defineProps<{ runtime: PromptAuditRuntime | null; loading: boolean; error: string }>()
|
||||
const props = defineProps<{ runtime: PromptAuditRuntime | null; loading: boolean; error: string }>()
|
||||
defineEmits<{ (event: 'refresh'): void }>()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const statusItems = computed(() => {
|
||||
const runtime = props.runtime
|
||||
if (!runtime) return []
|
||||
return [
|
||||
{ label: t('admin.promptAudit.runtime.process'), value: t(`admin.promptAudit.status.${runtime.process_status}`), dot: statusDot(runtime.process_status) },
|
||||
{ label: t('admin.promptAudit.runtime.mode'), value: t(`admin.promptAudit.mode.${runtime.effective_mode}`) },
|
||||
{ label: t('admin.promptAudit.runtime.version'), value: `${runtime.active_config_version} / ${runtime.expected_config_version}` },
|
||||
{ label: t('admin.promptAudit.runtime.workers'), value: `${runtime.worker_active} / ${runtime.worker_total}` },
|
||||
{ label: t('admin.promptAudit.runtime.queue'), value: `${runtime.queue.active} / ${runtime.queue_capacity}` },
|
||||
{ label: t('admin.promptAudit.runtime.dependencies'), value: `DB ${runtime.database_status} · Redis ${runtime.redis_status}` },
|
||||
]
|
||||
})
|
||||
|
||||
const guardMetricItems = computed(() => {
|
||||
const metrics = props.runtime?.guard_metrics
|
||||
if (!metrics) return []
|
||||
return [
|
||||
{ label: t('admin.promptAudit.metrics.total'), value: metrics.total },
|
||||
{ label: t('admin.promptAudit.metrics.allowed'), value: metrics.allowed },
|
||||
{ label: t('admin.promptAudit.metrics.flagged'), value: metrics.flagged },
|
||||
{ label: t('admin.promptAudit.metrics.blocked'), value: metrics.blocked },
|
||||
{ label: t('admin.promptAudit.metrics.unavailable'), value: metrics.unavailable },
|
||||
{ label: t('admin.promptAudit.metrics.timeouts'), value: metrics.timeouts },
|
||||
{ label: t('admin.promptAudit.metrics.failovers'), value: metrics.failovers },
|
||||
{ label: 'P95', value: metrics.latency_p95_ms != null ? `${metrics.latency_p95_ms} ms` : '—' },
|
||||
]
|
||||
})
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium', timeStyle: 'medium' }).format(new Date(value))
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user