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:
mt21625457andCursor committed 2026-07-17 11:45:14 +08:00
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))
}