mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 00:21:34 +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,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))
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export default {
|
||||
title: 'Prompt Audit',
|
||||
description: 'Review user input asynchronously or block it synchronously through OpenAI-compatible Qwen3Guard nodes. Full prompts never enter the database or UI.',
|
||||
configVersion: 'Config version v{version}',
|
||||
tabs: { config: 'Configuration', events: 'Events' },
|
||||
actions: { refresh: 'Refresh runtime', retry: 'Retry' },
|
||||
common: { actions: 'Actions', never: 'Never' },
|
||||
mode: { off: 'Off', async_audit: 'Async audit only', blocking: 'Synchronous audit and block' },
|
||||
@@ -40,6 +41,7 @@ export default {
|
||||
title: 'Audit events', description: 'Review redacted events by identity, route, risk, hash, and time.', decision: 'Decision', risk: 'Risk level', endpoint: 'Endpoint', groupId: 'Group ID', userId: 'User ID', apiKeyId: 'API Key ID', keyword: 'Keyword',
|
||||
startAt: 'Start time', endAt: 'End time', deleteSelected: 'Delete selected ({count})', deleteByFilter: 'Delete by filter', deleteRangeHint: 'Filter deletion requires explicit start and end times and a server-generated preview.',
|
||||
selectAll: 'Select all events on this page', selectEvent: 'Select event {id}', time: 'Time', identity: 'User / email / API Key', user: 'Username', email: 'User email', apiKey: 'API Key name', group: 'Group', route: 'Endpoint / model', result: 'Decision / risk', preview: 'Redacted preview', empty: 'No matching events.',
|
||||
passEventsDisabled: '“Store Pass events” is off. Safe requests are still audited, but they do not appear in this list. Enable and save that option in Configuration to review redacted audit content for every request.', openConfiguration: 'Open configuration',
|
||||
detailTitle: 'Prompt audit event details', tabs: { summary: 'Audit summary', risks: 'Specific risks', technical: 'Technical details' }, redactedPreview: 'Irreversible redacted preview', categories: 'Categories', model: 'Model', stage: 'Request stage', noRisks: 'No derived risk summaries for this event.',
|
||||
deleteConfirmTitle: 'Delete audit events?', deleteConfirmMessage: 'This permanently deletes {count} events and eligible orphan jobs.', filterDeleteTitle: 'Confirm filter deletion', filterDeleteCount: 'The server snapshot matches {count} events.', snapshotMax: 'Snapshot maximum event ID', expiresAt: 'Confirmation token expires', filterDeleteWarning: 'Only events at or below the preview high-water mark are deleted. Newer events survive. Any filter change requires a new preview.', confirmFilterDelete: 'Permanently delete',
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ export default {
|
||||
title: '提示词审计',
|
||||
description: '通过 OpenAI 兼容 Qwen3Guard 节点异步复核或同步阻止用户输入;完整提示词不会进入数据库或页面。',
|
||||
configVersion: '配置版本 v{version}',
|
||||
tabs: { config: '配置', events: '事件' },
|
||||
actions: { refresh: '刷新运行态', retry: '重试' },
|
||||
common: { actions: '操作', never: '从未' },
|
||||
mode: { off: '已关闭', async_audit: '异步只审计', blocking: '同步审计并阻止' },
|
||||
@@ -40,6 +41,7 @@ export default {
|
||||
title: '审计事件', description: '按身份、入口、风险、Hash 和时间复核脱敏事件。', decision: '判定', risk: '风险等级', endpoint: '入口', groupId: '分组 ID', userId: '用户 ID', apiKeyId: 'API Key ID', keyword: '关键词',
|
||||
startAt: '开始时间', endAt: '结束时间', deleteSelected: '删除选中项({count})', deleteByFilter: '按筛选删除', deleteRangeHint: '按筛选删除必须明确选择开始和结束时间,并先取得服务端删除预览。',
|
||||
selectAll: '选择当前页全部事件', selectEvent: '选择事件 {id}', time: '时间', identity: '用户 / 邮箱 / API Key', user: '用户名', email: '用户邮箱', apiKey: 'API Key 名称', group: '分组', route: '入口 / 模型', result: '判定 / 风险', preview: '脱敏预览', empty: '没有符合条件的事件。',
|
||||
passEventsDisabled: '当前未开启“保存 Pass 事件”:安全请求仍会完成审计,但不会出现在事件列表中。若要查看每次请求的脱敏审计内容,请在配置中开启该选项并保存。', openConfiguration: '前往配置',
|
||||
detailTitle: '提示词审计事件详情', tabs: { summary: '审计摘要', risks: '具体风险', technical: '技术信息' }, redactedPreview: '不可逆脱敏预览', categories: '分类', model: '模型', stage: '请求阶段', noRisks: '本事件没有派生风险摘要。',
|
||||
deleteConfirmTitle: '删除审计事件?', deleteConfirmMessage: '将永久删除 {count} 条事件及符合条件的孤立任务。', filterDeleteTitle: '确认按筛选删除', filterDeleteCount: '服务端快照匹配 {count} 条事件。', snapshotMax: '快照最大事件 ID', expiresAt: '确认令牌过期时间', filterDeleteWarning: '只删除预览高水位内的事件;预览后产生的新事件会保留。筛选一旦变化,必须重新预览。', confirmFilterDelete: '确认永久删除',
|
||||
},
|
||||
|
||||
@@ -170,6 +170,14 @@
|
||||
@apply disabled:cursor-not-allowed disabled:bg-gray-100 dark:disabled:bg-dark-900;
|
||||
}
|
||||
|
||||
.dark .input[type='date'],
|
||||
.dark .input[type='time'],
|
||||
.dark .input[type='datetime-local'],
|
||||
.dark .input[type='month'],
|
||||
.dark .input[type='week'] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
@apply border-red-500 focus:border-red-500 focus:ring-red-500/30;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user