feat(security-audit): persist full prompts on audit events and polish event review UI

- Add prompt_audit_events.full_prompt (migration 182) so admins can review
  the exact unredacted prompt that triggered a finding; blocking mode writes
  it from the snapshot, async mode reconstructs it from the Redis scan
  payload so jobs rows stay redaction-only
- Event detail API returns full_prompt (list endpoint stays lean); text is
  NUL-stripped and capped at 65536 runes
- Detail dialog shows the full prompt in a scrollable pane with fallback to
  the legacy redacted preview; page copy updated to match the new behavior
- Rework filter deletion into a dedicated dialog with time-range presets and
  criteria-change preview invalidation; localize decision/risk/category
  labels across the events workspace
- Fix pre-existing i18n message-compile spec by declaring the
  @intlify/message-compiler dev dependency
This commit is contained in:
mt21625457 committed 2026-07-17 14:38:10 +08:00
1 parent 7ed4e7e5e3
commit ac685ccaf5
22 files changed
+841 -148

No files matched your search

@@ -127,23 +127,17 @@
@confirm="confirmIDDelete"
@cancel="clearDeleteRequest"
/>
<BaseDialog :show="Boolean(deletePreview)" :title="t('admin.promptAudit.events.filterDeleteTitle')" width="normal" @close="deletePreview = null">
<div v-if="deletePreview" class="space-y-4 text-sm text-gray-600 dark:text-dark-300">
<p>{{ t('admin.promptAudit.events.filterDeleteCount', { count: deletePreview.matched_count }) }}</p>
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2">
<dt>{{ t('admin.promptAudit.events.snapshotMax') }}</dt><dd>{{ deletePreview.snapshot_max_id }}</dd>
<dt>Filter SHA-256</dt><dd class="break-all font-mono text-xs">{{ deletePreview.filter_hash }}</dd>
<dt>{{ t('admin.promptAudit.events.expiresAt') }}</dt><dd>{{ formatDate(deletePreview.expires_at) }}</dd>
</dl>
<p class="rounded-lg bg-amber-50 px-3 py-2 text-amber-800 dark:bg-amber-950/30 dark:text-amber-200">{{ t('admin.promptAudit.events.filterDeleteWarning') }}</p>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button type="button" class="btn btn-secondary" @click="deletePreview = null">{{ t('common.cancel') }}</button>
<button type="button" class="btn btn-danger" :disabled="loading.deleting" data-test="confirm-filter-delete" @click="confirmFilterDelete">{{ t('admin.promptAudit.events.confirmFilterDelete') }}</button>
</div>
</template>
</BaseDialog>
<FilterDeleteDialog
:show="showFilterDelete"
:initial-filters="filters"
:preview="deletePreview"
:previewing="loading.previewing"
:deleting="loading.deleting"
@close="closeFilterDelete"
@preview="runFilterDeletePreview"
@confirm="confirmFilterDelete"
@criteria-change="clearDeletePreview"
/>
<EventDetailDialog :show="showEventDetail" :event="activeEvent" :loading="loading.detail" @close="closeEventDetail" />
</AppLayout>
</template>
@@ -152,7 +146,6 @@
import { computed, defineComponent, h, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import AppLayout from '@/components/layout/AppLayout.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import { useAppStore } from '@/stores/app'
import { extractApiErrorCode, extractApiErrorMessage } from '@/utils/apiError'
@@ -161,6 +154,7 @@ import EndpointPool from './components/EndpointPool.vue'
import PolicyPanel from './components/PolicyPanel.vue'
import EventWorkspace from './components/EventWorkspace.vue'
import EventDetailDialog from './components/EventDetailDialog.vue'
import FilterDeleteDialog from './components/FilterDeleteDialog.vue'
import promptAuditAPI from './api'
import type {
PromptAuditDraft,
@@ -179,7 +173,7 @@ import { buildUpdateRequest, cloneData, configToDraft, draftFingerprint, emptyEv
const { t, locale } = useI18n()
const appStore = useAppStore()
type PromptAuditPageTab = 'config' | 'events'
const activeTab = ref<PromptAuditPageTab>('config')
const activeTab = ref<PromptAuditPageTab>('events')
const pageTabs = computed(() => [
{ id: 'events' as const, label: t('admin.promptAudit.tabs.events') },
{ id: 'config' as const, label: t('admin.promptAudit.tabs.config') },
@@ -196,10 +190,12 @@ const activeEvent = ref<PromptAuditEvent | null>(null)
const showEventDetail = ref(false)
const probeResults = reactive<Record<string, PromptProbeResult>>({})
const probingIds = ref<string[]>([])
const showFilterDelete = ref(false)
const deletePreview = ref<PromptDeletePreview | null>(null)
const deletePreviewFilters = ref<PromptEventFilters | null>(null)
const showBlockingConfirmation = ref(false)
const deleteRequest = reactive<{ mode: '' | 'single' | 'batch'; ids: number[] }>({ mode: '', ids: [] })
const loading = reactive({ config: false, runtime: false, groups: false, events: false, saving: false, detail: false, deleting: false })
const loading = reactive({ config: false, runtime: false, groups: false, events: false, saving: false, detail: false, deleting: false, previewing: false })
const loadErrors = reactive<PromptLoadErrors>({ config: '', runtime: '', groups: '', events: '' })
const dirty = computed(() => draftFingerprint(draft.value) !== draftFingerprint(serverConfig.value))
@@ -346,13 +342,13 @@ async function runProbe(endpoint: PromptAuditEndpointDraft) {
function handleFiltersChanged(value: PromptEventFilters) {
filters.value = cloneData(value)
deletePreview.value = null
clearDeletePreview()
}
function applyEventFilters(value: PromptEventFilters) {
filters.value = cloneData(value)
appliedFilters.value = cloneData(value)
events.page = 1
deletePreview.value = null
clearDeletePreview()
void loadEvents()
}
function changePage(value: number) { events.page = value; void loadEvents() }
@@ -382,23 +378,40 @@ async function confirmIDDelete() {
} catch (error) { appStore.showError(errorMessage(error, 'admin.promptAudit.errors.delete')) }
finally { loading.deleting = false }
}
async function requestFilterDeletePreview() {
loading.deleting = true
try { deletePreview.value = await promptAuditAPI.previewDelete(filters.value) }
catch (error) { appStore.showError(errorMessage(error, 'admin.promptAudit.errors.previewDelete')) }
finally { loading.deleting = false }
function clearDeletePreview() {
deletePreview.value = null
deletePreviewFilters.value = null
}
function requestFilterDeletePreview() {
clearDeletePreview()
showFilterDelete.value = true
}
function closeFilterDelete() {
showFilterDelete.value = false
clearDeletePreview()
}
async function runFilterDeletePreview(value: PromptEventFilters) {
loading.previewing = true
try {
deletePreview.value = await promptAuditAPI.previewDelete(value)
deletePreviewFilters.value = cloneData(value)
} catch (error) {
clearDeletePreview()
appStore.showError(errorMessage(error, 'admin.promptAudit.errors.previewDelete'))
} finally { loading.previewing = false }
}
async function confirmFilterDelete() {
if (!deletePreview.value) return
if (!deletePreview.value || !deletePreviewFilters.value) return
const preview = deletePreview.value
const previewFilters = cloneData(deletePreviewFilters.value)
loading.deleting = true
try {
const result = await promptAuditAPI.deleteEventsByFilter(filters.value, preview)
deletePreview.value = null
const result = await promptAuditAPI.deleteEventsByFilter(previewFilters, preview)
closeFilterDelete()
appStore.showSuccess(t('admin.promptAudit.messages.deleted', { count: result.deleted_events }))
await Promise.allSettled([loadEvents(), loadRuntime()])
} catch (error) {
deletePreview.value = null
clearDeletePreview()
appStore.showError(errorMessage(error, 'admin.promptAudit.errors.deleteConfirmation'))
} finally { loading.deleting = false }
}
@@ -45,12 +45,16 @@ const EventsStub = defineComponent({
template: '<div data-test="events"><button data-test="preview" @click="$emit(\'preview-delete\')">preview</button><button data-test="change-filter" @click="$emit(\'filters-change\', { ...filters, keyword: \'changed\' })">change</button><button data-test="delete-one" @click="$emit(\'delete\', 5)">delete</button><button data-test="select-batch" @click="$emit(\'selection\', [5, 6])">select</button><button data-test="delete-batch" @click="$emit(\'batch-delete\')">batch</button></div>',
})
const DetailStub = defineComponent({ props: ['show', 'event', 'loading'], emits: ['close'], template: '<div data-test="detail" />' })
const DialogStub = defineComponent({ props: ['show', 'title'], emits: ['close'], template: '<div v-if="show" data-test="dialog"><slot /><slot name="footer" /></div>' })
const ConfirmStub = defineComponent({ props: ['show', 'title', 'message'], emits: ['confirm', 'cancel'], template: '<div v-if="show" data-test="confirm"><button data-test="confirm-action" @click="$emit(\'confirm\')">confirm</button></div>' })
const FilterDeleteStub = defineComponent({
props: ['show', 'initialFilters', 'preview', 'previewing', 'deleting'],
emits: ['close', 'preview', 'confirm', 'criteria-change'],
template: '<div v-if="show" data-test="filter-delete-dialog"><button data-test="dialog-preview" @click="$emit(\'preview\', { ...initialFilters, start_at: \'2026-07-15T00:00\', end_at: \'2026-07-16T00:00\' })">run</button><button data-test="dialog-confirm" @click="$emit(\'confirm\')">confirm</button><span data-test="dialog-preview-state">{{ preview ? preview.matched_count : \'none\' }}</span></div>',
})
function mountView() {
return mount(PromptAuditView, {
global: { stubs: { AppLayout: AppLayoutStub, RuntimeOverview: RuntimeStub, EndpointPool: EndpointStub, PolicyPanel: PolicyStub, EventWorkspace: EventsStub, EventDetailDialog: DetailStub, BaseDialog: DialogStub, ConfirmDialog: ConfirmStub } },
global: { stubs: { AppLayout: AppLayoutStub, RuntimeOverview: RuntimeStub, EndpointPool: EndpointStub, PolicyPanel: PolicyStub, EventWorkspace: EventsStub, EventDetailDialog: DetailStub, FilterDeleteDialog: FilterDeleteStub, ConfirmDialog: ConfirmStub } },
})
}
@@ -86,28 +90,30 @@ describe('PromptAuditView', () => {
const wrapper = mountView()
await flushPromises()
expect(wrapper.get('[data-test="tab-events"]').attributes('aria-selected')).toBe('true')
expect(wrapper.get('[data-test="tab-config"]').attributes('aria-selected')).toBe('false')
expect(wrapper.get('[data-test="tab-panel-events"]').attributes('style') || '').not.toContain('display: none')
expect(wrapper.get('[data-test="tab-panel-config"]').attributes('style') || '').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)
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-config"]').trigger('click')
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')
})
@@ -115,6 +121,7 @@ describe('PromptAuditView', () => {
it('requires confirmation for blocking and disables it when audit is turned off', async () => {
const wrapper = mountView()
await flushPromises()
await wrapper.get('[data-test="tab-config"]').trigger('click')
await wrapper.get('[data-test="blocking-toggle"]').trigger('click')
expect(wrapper.find('[data-test="confirm"]').exists()).toBe(true)
await wrapper.get('[data-test="confirm-action"]').trigger('click')
@@ -128,6 +135,7 @@ describe('PromptAuditView', () => {
it('clears plaintext token state after a successful save', async () => {
const wrapper = mountView()
await flushPromises()
await wrapper.get('[data-test="tab-config"]').trigger('click')
await wrapper.get('[data-test="inject-secret"]').trigger('click')
expect(wrapper.text()).toContain('admin.promptAudit.saveBar.dirty')
await wrapper.get('[data-test="save-config"]').trigger('click')
@@ -141,22 +149,31 @@ describe('PromptAuditView', () => {
it('reports real probe progress/results and invalidates filter confirmation when filters change', async () => {
const wrapper = mountView()
await flushPromises()
await wrapper.get('[data-test="tab-config"]').trigger('click')
await wrapper.get('[data-test="probe"]').trigger('click')
await flushPromises()
expect(mocks.probeEndpoint).toHaveBeenCalledOnce()
expect((wrapper.getComponent(EndpointStub).props('probeResults') as Record<string, unknown>)).toHaveProperty('guard-1')
await wrapper.get('[data-test="tab-events"]').trigger('click')
await wrapper.get('[data-test="preview"]').trigger('click')
await flushPromises()
expect(wrapper.find('[data-test="dialog"]').exists()).toBe(true)
expect(wrapper.find('[data-test="filter-delete-dialog"]').exists()).toBe(true)
expect(mocks.previewDelete).not.toHaveBeenCalled()
await wrapper.get('[data-test="dialog-preview"]').trigger('click')
await flushPromises()
expect(mocks.previewDelete).toHaveBeenCalledOnce()
expect(wrapper.get('[data-test="dialog-preview-state"]').text()).toBe('2')
await wrapper.get('[data-test="change-filter"]').trigger('click')
await flushPromises()
expect(wrapper.find('[data-test="dialog"]').exists()).toBe(false)
expect(wrapper.find('[data-test="filter-delete-dialog"]').exists()).toBe(true)
expect(wrapper.get('[data-test="dialog-preview-state"]').text()).toBe('none')
})
it('uses native labeled switches and a responsive fixed save surface', async () => {
const wrapper = mountView()
await flushPromises()
await wrapper.get('[data-test="tab-config"]').trigger('click')
const switches = wrapper.findAll('[role="switch"]')
expect(switches).toHaveLength(3)
expect(switches.every((item) => Boolean(item.attributes('aria-label')))).toBe(true)
@@ -181,11 +198,18 @@ describe('PromptAuditView', () => {
await wrapper.get('[data-test="preview"]').trigger('click')
await flushPromises()
await wrapper.get('[data-test="confirm-filter-delete"]').trigger('click')
await wrapper.get('[data-test="dialog-preview"]').trigger('click')
await flushPromises()
expect(mocks.deleteEventsByFilter).toHaveBeenCalledWith(expect.any(Object), expect.objectContaining({
expect(mocks.previewDelete).toHaveBeenCalledWith(expect.objectContaining({ start_at: '2026-07-15T00:00', end_at: '2026-07-16T00:00' }))
await wrapper.get('[data-test="dialog-confirm"]').trigger('click')
await flushPromises()
expect(mocks.deleteEventsByFilter).toHaveBeenCalledWith(expect.objectContaining({
start_at: '2026-07-15T00:00',
end_at: '2026-07-16T00:00',
}), expect.objectContaining({
snapshot_max_id: 10,
confirmation_token: 'opaque-confirmation',
}))
expect(wrapper.find('[data-test="filter-delete-dialog"]').exists()).toBe(false)
})
})
@@ -4,8 +4,10 @@ import { mount } from '@vue/test-utils'
import EndpointPool from '../components/EndpointPool.vue'
import PolicyPanel from '../components/PolicyPanel.vue'
import EventWorkspace from '../components/EventWorkspace.vue'
import type { PromptAuditDraft, PromptAuditEndpointDraft, PromptAuditEvent } from '../types'
import { emptyEventFilters, SCANNER_CATALOG } from '../viewModel'
import EventDetailDialog from '../components/EventDetailDialog.vue'
import FilterDeleteDialog from '../components/FilterDeleteDialog.vue'
import type { PromptAuditDraft, PromptAuditEndpointDraft, PromptAuditEvent, PromptEventFilters } from '../types'
import { emptyEventFilters, resolveDeleteRangeFilters, SCANNER_CATALOG } from '../viewModel'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
@@ -58,7 +60,7 @@ describe('Prompt Audit components', () => {
props: { draft, groups: [{ id: 1, name: 'Alpha', platform: 'openai', status: 'active' }, { id: 2, name: 'Beta', platform: 'claude', status: 'inactive' }] },
})
expect(wrapper.text()).toContain('99')
expect(wrapper.findAll('input[type="checkbox"]').filter((input) => SCANNER_CATALOG.some((scanner) => input.attributes('aria-label') === scanner.label))).toHaveLength(9)
expect(wrapper.findAll('input[type="checkbox"]').filter((input) => SCANNER_CATALOG.some((scanner) => input.attributes('aria-label') === `admin.promptAudit.scanners.${scanner.id}`))).toHaveLength(9)
await wrapper.get('[aria-label="admin.promptAudit.policy.searchGroups"]').setValue('Beta')
expect(wrapper.text()).toContain('Beta')
expect(wrapper.text()).not.toContain('Alpha')
@@ -67,10 +69,10 @@ describe('Prompt Audit components', () => {
expect(emitted.worker_count).toBe(6)
})
it('keeps identity fields separate, supports selection, and gates filter deletion on a time range', async () => {
it('keeps identity fields separate, supports selection, and opens filter deletion from the toolbar', async () => {
const event: PromptAuditEvent = {
id: 1, job_id: 1, decision: 'critical', risk_level: 'critical', action: 'Block', categories: ['pii'], matched_scanners: ['pii'], scanner_scores: { pii: 1 }, scanner_evidence: { pii: 'redacted' }, scanner_backend: 'qwen3guard-openai', scanner_version: '1', guard_endpoint_id: 'guard-1', policy_id: 'priority', policy_version: 1, config_version: 1, chunk_total: 1, latency_ms: 10, issue_summaries: [], created_at: '2026-07-16T00:00:00Z',
snapshot: { request_id: 'req-1', user_id: 1, username: 'alice', user_email: 'alice@example.test', api_key_id: 2, api_key_name: 'alice-key', group_id: 3, group_name: 'Alpha', provider: 'openai', endpoint: '/v1/chat/completions', protocol: 'openai_chat', model: 'gpt-test', prompt_hash: 'a'.repeat(64), redacted_preview: 'redacted preview', prompt_length: 10, message_count: 1, stage: 'http' },
snapshot: { request_id: 'req-1', user_id: 1, username: 'alice', user_email: 'alice@example.test', api_key_id: 2, api_key_name: 'alice-key', group_id: 3, group_name: 'Alpha', provider: 'openai', endpoint: '/v1/chat/completions', protocol: 'openai_chat', model: 'gpt-test', prompt_hash: 'a'.repeat(64), redacted_preview: 'redacted preview', full_prompt: 'full prompt text', prompt_length: 10, message_count: 1, stage: 'http' },
}
const wrapper = mount(EventWorkspace, {
props: { events: [event], total: 1, page: 1, pageSize: 20, filters: emptyEventFilters(), selectedIds: [], loading: false, error: '' },
@@ -79,11 +81,143 @@ describe('Prompt Audit components', () => {
expect(wrapper.text()).toContain('alice')
expect(wrapper.text()).toContain('alice@example.test')
expect(wrapper.text()).toContain('alice-key')
expect(wrapper.get('[data-test="filter-delete"]').attributes()).toHaveProperty('disabled')
await wrapper.get('[aria-label="admin.promptAudit.events.startAt"]').setValue('2026-07-15T00:00')
await wrapper.get('[aria-label="admin.promptAudit.events.endAt"]').setValue('2026-07-16T00:00')
expect(wrapper.text()).toContain('admin.promptAudit.decisions.critical · admin.promptAudit.riskLevels.critical')
expect(wrapper.text()).toContain('admin.promptAudit.scanners.pii')
expect(wrapper.get('[data-test="filter-delete"]').attributes()).not.toHaveProperty('disabled')
await wrapper.get('[data-test="filter-delete"]').trigger('click')
expect(wrapper.emitted('preview-delete')).toHaveLength(1)
await wrapper.get('[aria-label="admin.promptAudit.events.selectEvent"]').setValue(true)
expect(wrapper.emitted('selection')?.at(-1)?.[0]).toEqual([1])
})
it('resolves delete range presets to an epoch start and a cutoff end', () => {
const now = Date.parse('2026-07-17T12:00:00.000Z')
const sevenDays = resolveDeleteRangeFilters(emptyEventFilters(), '7d', now)
expect(sevenDays.start_at).toBe('1970-01-01T00:00:00.000Z')
expect(sevenDays.end_at).toBe('2026-07-10T12:00:00.000Z')
const all = resolveDeleteRangeFilters(emptyEventFilters(), 'all', now)
expect(all.start_at).toBe('1970-01-01T00:00:00.000Z')
expect(all.end_at).toBe('2026-07-17T12:00:00.000Z')
const customSource = { ...emptyEventFilters(), start_at: '2026-07-01T00:00', end_at: '2026-07-02T00:00' }
expect(resolveDeleteRangeFilters(customSource, 'custom', now)).toEqual(customSource)
})
it('drives filter deletion through presets, custom validation, preview, and confirm', async () => {
const wrapper = mount(FilterDeleteDialog, {
props: { show: true, initialFilters: emptyEventFilters(), preview: null, previewing: false, deleting: false },
global: { stubs: { BaseDialog: DialogStub } },
})
expect(wrapper.get<HTMLInputElement>('[data-test="range-preset-7d"]').element.checked).toBe(true)
expect(wrapper.find('[data-test="custom-range"]').exists()).toBe(false)
expect(wrapper.get('[data-test="delete-preview-empty"]').exists()).toBeTruthy()
expect(wrapper.get('[data-test="confirm-filter-delete"]').attributes()).toHaveProperty('disabled')
await wrapper.get('[data-test="range-preset-30d"]').setValue()
expect(wrapper.emitted('criteria-change')?.length).toBeGreaterThan(0)
await wrapper.get('[data-test="delete-risk"]').setValue('high')
await wrapper.get('[data-test="run-delete-preview"]').trigger('click')
const presetPreview = wrapper.emitted('preview')?.at(-1)?.[0] as PromptEventFilters
expect(presetPreview.risk_level).toBe('high')
expect(presetPreview.start_at).toBe('1970-01-01T00:00:00.000Z')
expect(Date.now() - new Date(presetPreview.end_at).getTime()).toBeGreaterThanOrEqual(30 * 24 * 60 * 60 * 1000)
await wrapper.get('[data-test="range-preset-custom"]').setValue()
expect(wrapper.find('[data-test="custom-range"]').exists()).toBe(true)
expect(wrapper.get('[data-test="run-delete-preview"]').attributes()).toHaveProperty('disabled')
await wrapper.get('[data-test="custom-range"] [aria-label="admin.promptAudit.events.startAt"]').setValue('2026-07-01T00:00')
await wrapper.get('[data-test="custom-range"] [aria-label="admin.promptAudit.events.endAt"]').setValue('2026-07-02T00:00')
expect(wrapper.get('[data-test="run-delete-preview"]').attributes()).not.toHaveProperty('disabled')
await wrapper.get('[data-test="run-delete-preview"]').trigger('click')
const customPreview = wrapper.emitted('preview')?.at(-1)?.[0] as PromptEventFilters
expect(customPreview.start_at).toBe('2026-07-01T00:00')
expect(customPreview.end_at).toBe('2026-07-02T00:00')
await wrapper.setProps({
preview: { matched_count: 3, filter_summary: {}, snapshot_max_id: 9, filter_hash: 'b'.repeat(64), confirmation_token: 'tok', expires_at: '2026-07-16T00:05:00Z' },
})
expect(wrapper.get('[data-test="delete-preview-result"]').text()).toContain('admin.promptAudit.events.filterDeleteCount')
expect(wrapper.get('[data-test="confirm-filter-delete"]').attributes()).not.toHaveProperty('disabled')
await wrapper.get('[data-test="confirm-filter-delete"]').trigger('click')
expect(wrapper.emitted('confirm')).toHaveLength(1)
})
it('inherits an explicit list-filter range as the custom preset', async () => {
const initialFilters = { ...emptyEventFilters(), start_at: '2026-07-01T00:00', end_at: '2026-07-02T00:00', decision: 'critical' }
const wrapper = mount(FilterDeleteDialog, {
props: { show: true, initialFilters, preview: null, previewing: false, deleting: false },
global: { stubs: { BaseDialog: DialogStub } },
})
expect(wrapper.get<HTMLInputElement>('[data-test="range-preset-custom"]').element.checked).toBe(true)
expect(wrapper.get<HTMLInputElement>('[data-test="custom-range"] [aria-label="admin.promptAudit.events.startAt"]').element.value).toBe('2026-07-01T00:00')
expect(wrapper.get<HTMLSelectElement>('[data-test="delete-decision"]').element.value).toBe('critical')
expect(wrapper.get('[data-test="run-delete-preview"]').attributes()).not.toHaveProperty('disabled')
})
it('shows the full unredacted prompt and structured guard return on the risks tab', async () => {
const event: PromptAuditEvent = {
id: 1, job_id: 1, decision: 'critical', risk_level: 'critical', action: 'Block',
categories: ['sexual_content_or_sexual_acts'], matched_scanners: ['sexual_content_or_sexual_acts'],
scanner_scores: { sexual_content_or_sexual_acts: 1 },
scanner_evidence: { sexual_content_or_sexual_acts: 'Sexual Content or Sexual Acts' },
scanner_backend: 'qwen3guard-openai', scanner_version: 'qwen3guard', guard_endpoint_id: 'guard-1',
policy_id: 'priority', policy_version: 1, config_version: 1, chunk_total: 1, latency_ms: 12,
issue_summaries: [{
category: 'sexual_content_or_sexual_acts', scanner_id: 'sexual_content_or_sexual_acts',
title: '性内容或性行为', description: 'Sexual content or sexual acts', severity: 'critical',
severity_label: '严重', action: 'Block', action_label: '阻止',
code: 'prompt_audit_sexual_content_or_sexual_acts', score: 1,
evidence: 'Sexual Content or Sexual Acts', evidence_hash: 'abc',
}],
created_at: '2026-07-16T00:00:00Z',
snapshot: {
request_id: 'req-1', user_id: 1, username: 'alice', user_email: 'alice@example.test',
api_key_id: 2, api_key_name: 'alice-key', group_id: 3, group_name: 'Alpha', provider: 'openai',
endpoint: '/v1/chat/completions', protocol: 'openai_chat', model: 'gpt-test',
prompt_hash: 'a'.repeat(64), redacted_preview: 'redacted prompt body', full_prompt: 'complete unmasked prompt body', prompt_length: 20,
message_count: 1, stage: 'http',
},
}
const wrapper = mount(EventDetailDialog, {
props: { show: true, event, loading: false },
global: { stubs: { BaseDialog: DialogStub } },
})
const panel = wrapper.get('[data-test="event-detail-tab-panel"]')
expect(panel.classes()).toContain('h-[min(62vh,36rem)]')
expect(panel.classes()).toContain('overflow-y-auto')
const riskTab = wrapper.findAll('[role="tab"]').find((tab) => tab.text().includes('admin.promptAudit.events.tabs.risks'))
expect(riskTab).toBeTruthy()
await riskTab!.trigger('click')
expect(wrapper.get('[data-test="event-detail-tab-panel"]').classes()).toContain('h-[min(62vh,36rem)]')
expect(wrapper.get('[data-test="risk-prompt-preview"]').text()).toContain('complete unmasked prompt body')
expect(wrapper.get('[data-test="risk-prompt-preview"]').text()).not.toContain('redacted prompt body')
expect(wrapper.get('[data-test="risk-prompt-full"]').classes()).toContain('overflow-auto')
expect(wrapper.get('[data-test="risk-guard-return"]').text()).toContain('"decision": "admin.promptAudit.decisions.critical"')
expect(wrapper.get('[data-test="risk-guard-return"]').text()).toContain('admin.promptAudit.scanners.sexual_content_or_sexual_acts')
expect(wrapper.get('[data-test="risk-issue"]').text()).toContain('admin.promptAudit.scanners.sexual_content_or_sexual_acts')
})
it('falls back to the redacted preview for events stored before full prompts were kept', async () => {
const event: PromptAuditEvent = {
id: 2, job_id: 2, decision: 'flag', risk_level: 'medium', action: 'Warn',
categories: ['pii'], matched_scanners: ['pii'], scanner_scores: {}, scanner_evidence: {},
scanner_backend: 'qwen3guard-openai', scanner_version: '1', guard_endpoint_id: 'guard-1',
policy_id: 'priority', policy_version: 1, config_version: 1, chunk_total: 1, latency_ms: 5,
issue_summaries: [], created_at: '2026-07-16T00:00:00Z',
snapshot: {
request_id: 'req-2', user_id: 1, username: 'bob', user_email: '', api_key_id: 2,
api_key_name: 'bob-key', group_id: 3, group_name: 'Alpha', provider: 'openai',
endpoint: '/v1/chat/completions', protocol: 'openai_chat', model: 'gpt-test',
prompt_hash: 'b'.repeat(64), redacted_preview: 'legacy redacted preview', full_prompt: '', prompt_length: 20,
message_count: 1, stage: 'http',
},
}
const wrapper = mount(EventDetailDialog, {
props: { show: true, event, loading: false },
global: { stubs: { BaseDialog: DialogStub } },
})
const riskTab = wrapper.findAll('[role="tab"]').find((tab) => tab.text().includes('admin.promptAudit.events.tabs.risks'))
await riskTab!.trigger('click')
expect(wrapper.get('[data-test="risk-prompt-full"]').text()).toContain('legacy redacted preview')
})
})
@@ -1,53 +1,76 @@
<template>
<BaseDialog :show="show" :title="t('admin.promptAudit.events.detailTitle')" width="extra-wide" @close="$emit('close')">
<div v-if="loading" class="py-12 text-center text-sm text-gray-500" aria-busy="true">{{ t('common.loading') }}</div>
<div v-else-if="event" class="space-y-5">
<div v-else-if="event" class="flex flex-col">
<div class="flex flex-wrap gap-2 border-b border-gray-200 pb-3 dark:border-dark-700" role="tablist">
<button v-for="tab in tabs" :key="tab" type="button" role="tab" :aria-selected="activeTab === tab" class="rounded-md px-3 py-1.5 text-sm" :class="activeTab === tab ? 'bg-primary-50 text-primary-700 dark:bg-primary-950/40 dark:text-primary-300' : 'text-gray-600 dark:text-dark-300'" @click="activeTab = tab">
{{ t(`admin.promptAudit.events.tabs.${tab}`) }}
</button>
</div>
<div v-if="activeTab === 'summary'" class="grid gap-5 lg:grid-cols-2">
<div>
<h4 class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.events.redactedPreview') }}</h4>
<pre class="mt-2 max-h-56 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-gray-50 p-4 text-sm text-gray-700 dark:bg-dark-900 dark:text-dark-200">{{ event.snapshot.redacted_preview || '—' }}</pre>
<!-- Fixed panel height so switching tabs does not resize the dialog -->
<div class="mt-5 h-[min(62vh,36rem)] overflow-y-auto" data-test="event-detail-tab-panel">
<div v-show="activeTab === 'summary'" class="grid gap-5 lg:grid-cols-2" role="tabpanel">
<div>
<h4 class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.events.promptFull') }}</h4>
<pre class="mt-2 max-h-[min(46vh,26rem)] overflow-auto whitespace-pre-wrap break-words rounded-lg bg-gray-50 p-4 text-sm text-gray-700 dark:bg-dark-900 dark:text-dark-200" data-test="summary-prompt-full">{{ displayPrompt(event) }}</pre>
</div>
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
<dt class="text-gray-500">{{ t('admin.promptAudit.events.decision') }}</dt><dd class="font-medium text-gray-900 dark:text-white">{{ formatDecisionAction(event.decision, event.action) }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.user') }}</dt><dd>{{ event.snapshot.username || '—' }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.email') }}</dt><dd>{{ event.snapshot.user_email || '—' }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.apiKey') }}</dt><dd>{{ event.snapshot.api_key_name || '—' }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.group') }}</dt><dd>{{ event.snapshot.group_name || '—' }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.model') }}</dt><dd>{{ event.snapshot.model || '—' }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.categories') }}</dt><dd>{{ formatCategories(event.categories) }}</dd>
</dl>
</div>
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
<dt class="text-gray-500">{{ t('admin.promptAudit.events.decision') }}</dt><dd class="font-medium text-gray-900 dark:text-white">{{ event.decision }} · {{ event.action }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.user') }}</dt><dd>{{ event.snapshot.username || '—' }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.email') }}</dt><dd>{{ event.snapshot.user_email || '—' }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.apiKey') }}</dt><dd>{{ event.snapshot.api_key_name || '—' }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.group') }}</dt><dd>{{ event.snapshot.group_name || '—' }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.model') }}</dt><dd>{{ event.snapshot.model || '—' }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.categories') }}</dt><dd>{{ event.categories.join(', ') || '—' }}</dd>
<div v-show="activeTab === 'risks'" class="space-y-5" role="tabpanel">
<div class="grid gap-4 lg:grid-cols-2">
<section data-test="risk-prompt-preview">
<h4 class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.events.promptFull') }}</h4>
<p class="mt-1 text-xs text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.events.promptFullHint') }}</p>
<pre class="mt-2 h-[min(46vh,26rem)] overflow-auto whitespace-pre-wrap break-words rounded-lg bg-gray-50 p-4 text-sm text-gray-700 dark:bg-dark-900 dark:text-dark-200" data-test="risk-prompt-full">{{ displayPrompt(event) }}</pre>
</section>
<section data-test="risk-guard-return">
<h4 class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.events.guardReturn') }}</h4>
<p class="mt-1 text-xs text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.events.guardReturnHint') }}</p>
<pre class="mt-2 h-[min(46vh,26rem)] overflow-auto whitespace-pre-wrap break-words rounded-lg bg-gray-50 p-4 font-mono text-xs text-gray-700 dark:bg-dark-900 dark:text-dark-200">{{ formatGuardReturn(event) }}</pre>
</section>
</div>
<div class="space-y-3">
<h4 class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.events.riskSummaries') }}</h4>
<article v-for="issue in event.issue_summaries" :key="`${issue.scanner_id}-${issue.code}`" class="border-l-2 border-red-400 pl-4" data-test="risk-issue">
<div class="flex flex-wrap items-center gap-2">
<h5 class="font-medium text-gray-900 dark:text-white">{{ issueTitle(issue) }}</h5>
<span class="text-xs text-red-600 dark:text-red-300">{{ issueSeverity(issue) }} · {{ issueAction(issue) }}</span>
</div>
<p class="mt-1 text-sm text-gray-600 dark:text-dark-300">{{ issueDescription(issue) }}</p>
<dl class="mt-2 grid gap-1 text-xs text-gray-500 dark:text-dark-400 sm:grid-cols-2">
<div><dt class="inline text-gray-400">{{ t('admin.promptAudit.events.categories') }} · </dt><dd class="inline">{{ translateCategory(issue.category || issue.scanner_id) }}</dd></div>
<div><dt class="inline text-gray-400">{{ t('admin.promptAudit.events.score') }} · </dt><dd class="inline">{{ issue.score }}</dd></div>
<div class="sm:col-span-2"><dt class="inline text-gray-400">{{ t('admin.promptAudit.events.evidence') }} · </dt><dd class="inline break-words">{{ issue.evidence ? translateEvidence(issue.evidence) : '—' }}</dd></div>
</dl>
</article>
<p v-if="event.issue_summaries.length === 0" class="py-6 text-center text-sm text-gray-500">{{ t('admin.promptAudit.events.noRisks') }}</p>
</div>
</div>
<dl v-show="activeTab === 'technical'" class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 text-sm" role="tabpanel">
<dt class="text-gray-500">{{ t('admin.promptAudit.events.requestId') }}</dt><dd class="break-all font-mono">{{ event.snapshot.request_id || '—' }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.promptHash') }}</dt><dd class="break-all font-mono">{{ event.snapshot.prompt_hash }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.technical.scanner') }}</dt><dd>{{ event.scanner_backend }} · {{ event.scanner_version }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.technical.policy') }}</dt><dd>{{ event.policy_id }} · v{{ event.policy_version }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.technical.guardEndpoint') }}</dt><dd>{{ event.guard_endpoint_id }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.technical.config') }}</dt><dd>v{{ event.config_version }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.technical.chunks') }}</dt><dd>{{ event.chunk_total }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.technical.latency') }}</dt><dd>{{ event.latency_ms }} ms</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.stage') }}</dt><dd>{{ event.snapshot.stage || 'http' }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.technical.protocol') }}</dt><dd>{{ event.snapshot.protocol }} · {{ event.snapshot.endpoint }}</dd>
</dl>
</div>
<div v-else-if="activeTab === 'risks'" class="space-y-3">
<article v-for="issue in event.issue_summaries" :key="`${issue.scanner_id}-${issue.code}`" class="border-l-2 border-red-400 pl-4">
<div class="flex flex-wrap items-center gap-2">
<h4 class="font-medium text-gray-900 dark:text-white">{{ issue.title }}</h4>
<span class="text-xs uppercase text-red-600 dark:text-red-300">{{ issue.severity_label }} · {{ issue.action_label }}</span>
</div>
<p class="mt-1 text-sm text-gray-600 dark:text-dark-300">{{ issue.description }}</p>
<p class="mt-2 break-words text-xs text-gray-500 dark:text-dark-400">{{ issue.scanner_id }} · {{ issue.code }} · {{ issue.score }} · {{ issue.evidence }}</p>
</article>
<p v-if="event.issue_summaries.length === 0" class="py-8 text-center text-sm text-gray-500">{{ t('admin.promptAudit.events.noRisks') }}</p>
</div>
<dl v-else class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 text-sm">
<dt class="text-gray-500">Request ID</dt><dd class="break-all font-mono">{{ event.snapshot.request_id || '—' }}</dd>
<dt class="text-gray-500">Prompt SHA-256</dt><dd class="break-all font-mono">{{ event.snapshot.prompt_hash }}</dd>
<dt class="text-gray-500">Scanner</dt><dd>{{ event.scanner_backend }} · {{ event.scanner_version }}</dd>
<dt class="text-gray-500">Policy</dt><dd>{{ event.policy_id }} · v{{ event.policy_version }}</dd>
<dt class="text-gray-500">Guard endpoint</dt><dd>{{ event.guard_endpoint_id }}</dd>
<dt class="text-gray-500">Config</dt><dd>v{{ event.config_version }}</dd>
<dt class="text-gray-500">Chunks</dt><dd>{{ event.chunk_total }}</dd>
<dt class="text-gray-500">Latency</dt><dd>{{ event.latency_ms }} ms</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.stage') }}</dt><dd>{{ event.snapshot.stage || 'http' }}</dd>
<dt class="text-gray-500">Protocol</dt><dd>{{ event.snapshot.protocol }} · {{ event.snapshot.endpoint }}</dd>
</dl>
</div>
</BaseDialog>
</template>
@@ -56,7 +79,8 @@
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import type { PromptAuditEvent } from '../types'
import type { PromptAuditEvent, PromptIssueSummary } from '../types'
import { SCANNER_CATALOG } from '../viewModel'
const props = defineProps<{ show: boolean; event: PromptAuditEvent | null; loading: boolean }>()
defineEmits<{ (event: 'close'): void }>()
@@ -64,4 +88,69 @@ const { t } = useI18n()
const tabs = ['summary', 'risks', 'technical'] as const
const activeTab = ref<(typeof tabs)[number]>('summary')
watch(() => props.event?.id, () => { activeTab.value = 'summary' })
const DECISIONS = new Set(['pass', 'flag', 'critical'])
const ACTIONS = new Set(['Allow', 'Warn', 'Block'])
const RISK_LEVELS = new Set(['low', 'medium', 'high', 'critical'])
function displayPrompt(event: PromptAuditEvent): string {
return event.snapshot.full_prompt || event.snapshot.redacted_preview || '—'
}
function formatDecisionAction(decision: string, action: string): string {
const decisionLabel = DECISIONS.has(decision) ? t(`admin.promptAudit.decisions.${decision}`) : decision
const actionLabel = ACTIONS.has(action) ? t(`admin.promptAudit.actions.${action}`) : action
return `${decisionLabel} · ${actionLabel}`
}
function translateCategory(category: string): string {
return SCANNER_CATALOG.some((scanner) => scanner.id === category)
? t(`admin.promptAudit.scanners.${category}`)
: category
}
function formatCategories(categories: string[]): string {
if (!categories.length) return '—'
return categories.map(translateCategory).join(', ')
}
function translateEvidence(value: string): string {
const byId = SCANNER_CATALOG.find((scanner) => scanner.id === value)
if (byId) return t(`admin.promptAudit.scanners.${byId.id}`)
const byLabel = SCANNER_CATALOG.find((scanner) => scanner.label === value)
if (byLabel) return t(`admin.promptAudit.scanners.${byLabel.id}`)
return value
}
function formatGuardReturn(event: PromptAuditEvent): string {
const evidence: Record<string, string> = {}
for (const [key, value] of Object.entries(event.scanner_evidence || {})) {
evidence[key] = translateEvidence(value)
}
return JSON.stringify({
decision: DECISIONS.has(event.decision) ? t(`admin.promptAudit.decisions.${event.decision}`) : event.decision,
risk_level: RISK_LEVELS.has(event.risk_level) ? t(`admin.promptAudit.riskLevels.${event.risk_level}`) : event.risk_level,
action: ACTIONS.has(event.action) ? t(`admin.promptAudit.actions.${event.action}`) : event.action,
categories: event.categories.map(translateCategory),
matched_scanners: event.matched_scanners.map(translateCategory),
scanner_scores: event.scanner_scores,
scanner_evidence: evidence,
scanner_backend: event.scanner_backend,
scanner_version: event.scanner_version,
guard_endpoint_id: event.guard_endpoint_id,
chunk_total: event.chunk_total,
latency_ms: event.latency_ms,
}, null, 2)
}
function issueTitle(issue: PromptIssueSummary): string {
return translateCategory(issue.category || issue.scanner_id) || issue.title
}
function issueDescription(issue: PromptIssueSummary): string {
const category = issue.category || issue.scanner_id
const key = `admin.promptAudit.scannerDescriptions.${category}`
const label = t(key)
return label === key ? issue.description : label
}
function issueSeverity(issue: PromptIssueSummary): string {
return RISK_LEVELS.has(issue.severity) ? t(`admin.promptAudit.riskLevels.${issue.severity}`) : issue.severity_label || issue.severity
}
function issueAction(issue: PromptIssueSummary): string {
return ACTIONS.has(issue.action) ? t(`admin.promptAudit.actions.${issue.action}`) : issue.action_label || issue.action
}
</script>
@@ -9,7 +9,7 @@
<button type="button" class="btn btn-secondary btn-sm" :disabled="selectedIds.length === 0" @click="$emit('batch-delete')">
{{ t('admin.promptAudit.events.deleteSelected', { count: selectedIds.length }) }}
</button>
<button type="button" class="btn btn-danger btn-sm" :disabled="!hasExplicitDeleteRange(localFilters)" data-test="filter-delete" @click="$emit('preview-delete')">
<button type="button" class="btn btn-danger btn-sm" data-test="filter-delete" @click="$emit('preview-delete')">
{{ t('admin.promptAudit.events.deleteByFilter') }}
</button>
</div>
@@ -19,21 +19,28 @@
<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>
<option value="">{{ t('common.all') }}</option>
<option value="pass">{{ t('admin.promptAudit.decisions.pass') }}</option>
<option value="flag">{{ t('admin.promptAudit.decisions.flag') }}</option>
<option value="critical">{{ t('admin.promptAudit.decisions.critical') }}</option>
</select>
</label>
<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>
<option value="">{{ t('common.all') }}</option>
<option value="low">{{ t('admin.promptAudit.riskLevels.low') }}</option>
<option value="medium">{{ t('admin.promptAudit.riskLevels.medium') }}</option>
<option value="high">{{ t('admin.promptAudit.riskLevels.high') }}</option>
<option value="critical">{{ t('admin.promptAudit.riskLevels.critical') }}</option>
</select>
</label>
<FilterInput v-model="localFilters.endpoint" :label="t('admin.promptAudit.events.endpoint')" @change="filtersChanged" />
<FilterInput v-model="localFilters.group_id" :label="t('admin.promptAudit.events.groupId')" type="number" @change="filtersChanged" />
<FilterInput v-model="localFilters.user_id" :label="t('admin.promptAudit.events.userId')" type="number" @change="filtersChanged" />
<FilterInput v-model="localFilters.api_key_id" :label="t('admin.promptAudit.events.apiKeyId')" type="number" @change="filtersChanged" />
<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.request_id" :label="t('admin.promptAudit.events.requestId')" @change="filtersChanged" />
<FilterInput v-model="localFilters.prompt_hash" :label="t('admin.promptAudit.events.promptHash')" @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-200">
<span>{{ t('admin.promptAudit.events.startAt') }}</span>
@@ -48,8 +55,6 @@
<button type="button" class="btn btn-ghost btn-sm" @click="resetFilters">{{ t('common.reset') }}</button>
</div>
</form>
<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-xl border border-gray-200 dark:border-dark-700/60">
<table class="min-w-[1120px] w-full text-left text-sm">
@@ -82,8 +87,8 @@
<p class="mt-1 text-xs text-gray-500">{{ event.snapshot.model }} · {{ event.snapshot.protocol }} · {{ event.snapshot.stage || 'http' }}</p>
</td>
<td class="px-3 py-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium" :class="decisionClass(event.decision)">{{ event.decision }} · {{ event.risk_level }}</span>
<p class="mt-2 max-w-48 truncate text-xs text-gray-500">{{ event.categories.join(', ') || '—' }}</p>
<span class="rounded-full px-2 py-0.5 text-xs font-medium" :class="decisionClass(event.decision)">{{ formatDecisionRisk(event.decision, event.risk_level) }}</span>
<p class="mt-2 max-w-48 truncate text-xs text-gray-500" :title="formatCategories(event.categories)">{{ formatCategories(event.categories) }}</p>
</td>
<td class="max-w-xs px-3 py-3"><p class="line-clamp-2 break-words text-gray-600 dark:text-dark-300">{{ event.snapshot.redacted_preview || '—' }}</p></td>
<td class="whitespace-nowrap px-3 py-3 text-right">
@@ -103,7 +108,7 @@ import { computed, defineComponent, h, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Pagination from '@/components/common/Pagination.vue'
import type { PromptAuditEvent, PromptEventFilters } from '../types'
import { cloneData, emptyEventFilters, hasExplicitDeleteRange } from '../viewModel'
import { cloneData, emptyEventFilters, SCANNER_CATALOG } from '../viewModel'
const props = defineProps<{
events: PromptAuditEvent[]; total: number; page: number; pageSize: number
@@ -183,4 +188,25 @@ function decisionClass(decision: string): string {
if (decision === 'flag') return 'bg-amber-100 text-amber-700 dark:bg-amber-950/50 dark:text-amber-300'
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300'
}
const DECISIONS = new Set(['pass', 'flag', 'critical'])
const RISK_LEVELS = new Set(['low', 'medium', 'high', 'critical'])
function translateDecision(decision: string): string {
return DECISIONS.has(decision) ? t(`admin.promptAudit.decisions.${decision}`) : decision
}
function translateRiskLevel(riskLevel: string): string {
return RISK_LEVELS.has(riskLevel) ? t(`admin.promptAudit.riskLevels.${riskLevel}`) : riskLevel
}
function translateCategory(category: string): string {
return SCANNER_CATALOG.some((scanner) => scanner.id === category)
? t(`admin.promptAudit.scanners.${category}`)
: category
}
function formatDecisionRisk(decision: string, riskLevel: string): string {
return `${translateDecision(decision)} · ${translateRiskLevel(riskLevel)}`
}
function formatCategories(categories: string[]): string {
if (!categories.length) return '—'
return categories.map(translateCategory).join(', ')
}
</script>
@@ -0,0 +1,166 @@
<template>
<BaseDialog :show="show" :title="t('admin.promptAudit.events.filterDeleteDialogTitle')" width="wide" @close="$emit('close')">
<div class="space-y-5 text-sm">
<p class="text-gray-500 dark:text-dark-300">{{ t('admin.promptAudit.events.filterDeleteDialogDesc') }}</p>
<fieldset>
<legend class="text-xs font-medium text-gray-600 dark:text-dark-200">{{ t('admin.promptAudit.events.filterTimeRange') }}</legend>
<div class="mt-2 flex flex-wrap gap-2" role="radiogroup" :aria-label="t('admin.promptAudit.events.filterTimeRange')">
<label
v-for="option in DELETE_RANGE_PRESETS"
:key="option.id"
class="cursor-pointer"
>
<input v-model="preset" type="radio" name="prompt-delete-range" :value="option.id" class="peer sr-only" :data-test="`range-preset-${option.id}`" @change="criteriaChanged" />
<span class="inline-flex items-center rounded-full border border-gray-200 px-3 py-1.5 text-xs font-medium text-gray-600 transition-colors peer-checked:border-red-500 peer-checked:bg-red-50 peer-checked:text-red-700 peer-focus-visible:ring-2 peer-focus-visible:ring-red-500/30 dark:border-dark-600 dark:text-dark-300 dark:peer-checked:border-red-500 dark:peer-checked:bg-red-950/40 dark:peer-checked:text-red-300">
{{ t(`admin.promptAudit.events.timePresets.${option.id}`) }}
</span>
</label>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.events.filterTimeRangeHint') }}</p>
<div v-if="preset === 'custom'" class="mt-3 grid gap-3 sm:grid-cols-2" data-test="custom-range">
<label class="text-xs text-gray-600 dark:text-dark-200">
<span>{{ t('admin.promptAudit.events.startAt') }}</span>
<input v-model="local.start_at" type="datetime-local" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.startAt')" @change="criteriaChanged" />
</label>
<label class="text-xs text-gray-600 dark:text-dark-200">
<span>{{ t('admin.promptAudit.events.endAt') }}</span>
<input v-model="local.end_at" type="datetime-local" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.endAt')" @change="criteriaChanged" />
</label>
<p v-if="!canPreview" class="text-xs text-red-600 dark:text-red-400 sm:col-span-2">{{ t('admin.promptAudit.events.customRangeInvalid') }}</p>
</div>
</fieldset>
<div class="grid gap-3 sm:grid-cols-2">
<label class="text-xs text-gray-600 dark:text-dark-200">
<span>{{ t('admin.promptAudit.events.decision') }}</span>
<select v-model="local.decision" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.decision')" data-test="delete-decision" @change="criteriaChanged">
<option value="">{{ t('common.all') }}</option>
<option value="pass">{{ t('admin.promptAudit.decisions.pass') }}</option>
<option value="flag">{{ t('admin.promptAudit.decisions.flag') }}</option>
<option value="critical">{{ t('admin.promptAudit.decisions.critical') }}</option>
</select>
</label>
<label class="text-xs text-gray-600 dark:text-dark-200">
<span>{{ t('admin.promptAudit.events.risk') }}</span>
<select v-model="local.risk_level" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.risk')" data-test="delete-risk" @change="criteriaChanged">
<option value="">{{ t('common.all') }}</option>
<option value="low">{{ t('admin.promptAudit.riskLevels.low') }}</option>
<option value="medium">{{ t('admin.promptAudit.riskLevels.medium') }}</option>
<option value="high">{{ t('admin.promptAudit.riskLevels.high') }}</option>
<option value="critical">{{ t('admin.promptAudit.riskLevels.critical') }}</option>
</select>
</label>
</div>
<details class="rounded-xl border border-gray-200 px-4 py-3 dark:border-dark-700/60" data-test="more-conditions">
<summary class="cursor-pointer select-none text-xs font-medium text-gray-600 dark:text-dark-200">{{ t('admin.promptAudit.events.moreConditions') }}</summary>
<div class="mt-3 grid gap-3 sm:grid-cols-2">
<label class="text-xs text-gray-600 dark:text-dark-200">
<span>{{ t('admin.promptAudit.events.endpoint') }}</span>
<input v-model="local.endpoint" type="text" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.endpoint')" @input="criteriaChanged" />
</label>
<label class="text-xs text-gray-600 dark:text-dark-200">
<span>{{ t('admin.promptAudit.events.keyword') }}</span>
<input v-model="local.keyword" type="text" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.keyword')" @input="criteriaChanged" />
</label>
<label class="text-xs text-gray-600 dark:text-dark-200">
<span>{{ t('admin.promptAudit.events.groupId') }}</span>
<input v-model="local.group_id" type="number" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.groupId')" @input="criteriaChanged" />
</label>
<label class="text-xs text-gray-600 dark:text-dark-200">
<span>{{ t('admin.promptAudit.events.userId') }}</span>
<input v-model="local.user_id" type="number" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.userId')" @input="criteriaChanged" />
</label>
</div>
</details>
<div v-if="preview" class="rounded-xl border border-red-200 bg-red-50/60 px-4 py-3 dark:border-red-900/60 dark:bg-red-950/20" data-test="delete-preview-result">
<p class="text-sm font-semibold text-red-700 dark:text-red-300">{{ t('admin.promptAudit.events.filterDeleteCount', { count: preview.matched_count }) }}</p>
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs text-gray-600 dark:text-dark-300">
<dt>{{ t('admin.promptAudit.events.snapshotMax') }}</dt>
<dd>{{ preview.snapshot_max_id }}</dd>
<dt>Filter SHA-256</dt>
<dd class="break-all font-mono">{{ preview.filter_hash }}</dd>
<dt>{{ t('admin.promptAudit.events.expiresAt') }}</dt>
<dd>{{ formatDate(preview.expires_at) }}</dd>
</dl>
<p class="mt-2 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:bg-amber-950/30 dark:text-amber-200">{{ t('admin.promptAudit.events.filterDeleteWarning') }}</p>
</div>
<p v-else class="rounded-xl border border-dashed border-gray-300 px-4 py-3 text-xs text-gray-500 dark:border-dark-600 dark:text-dark-400" data-test="delete-preview-empty">
{{ t('admin.promptAudit.events.filterDeleteNeedPreview') }}
</p>
</div>
<template #footer>
<div class="flex flex-wrap items-center justify-end gap-3">
<button type="button" class="btn btn-secondary" @click="$emit('close')">{{ t('common.cancel') }}</button>
<button type="button" class="btn btn-secondary" :disabled="!canPreview || previewing || deleting" data-test="run-delete-preview" @click="requestPreview">
{{ previewing ? t('admin.promptAudit.events.filterDeletePreviewing') : t('admin.promptAudit.events.filterDeletePreviewAction') }}
</button>
<button type="button" class="btn btn-danger" :disabled="!preview || preview.matched_count === 0 || previewing || deleting" data-test="confirm-filter-delete" @click="$emit('confirm')">
{{ deleting ? t('common.submitting') : t('admin.promptAudit.events.confirmFilterDelete') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import type { PromptDeletePreview, PromptEventFilters } from '../types'
import {
DELETE_RANGE_PRESETS,
cloneData,
emptyEventFilters,
hasExplicitDeleteRange,
resolveDeleteRangeFilters,
type DeleteRangePreset,
} from '../viewModel'
const props = defineProps<{
show: boolean
initialFilters: PromptEventFilters
preview: PromptDeletePreview | null
previewing: boolean
deleting: boolean
}>()
const emit = defineEmits<{
(event: 'close'): void
(event: 'preview', value: PromptEventFilters): void
(event: 'confirm'): void
(event: 'criteria-change'): void
}>()
const { t, locale } = useI18n()
const preset = ref<DeleteRangePreset>('7d')
const local = reactive<PromptEventFilters>(emptyEventFilters())
watch(
() => props.show,
(visible) => {
if (!visible) return
const initial = cloneData(props.initialFilters)
// Only inherit an explicit list-filter range; otherwise default to the
// seven-day preset so a careless click can never target everything.
preset.value = hasExplicitDeleteRange(initial) ? 'custom' : '7d'
Object.assign(local, initial)
},
{ immediate: true },
)
const canPreview = computed(() => preset.value !== 'custom' || hasExplicitDeleteRange(local))
function criteriaChanged() {
emit('criteria-change')
}
function requestPreview() {
if (!canPreview.value) return
emit('preview', resolveDeleteRangeFilters(local, preset.value))
}
function formatDate(value: string): string {
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium', timeStyle: 'medium' }).format(new Date(value))
}
</script>
@@ -46,8 +46,8 @@
<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">
<input type="checkbox" :checked="draft.scanners.includes(scanner.id)" :aria-label="scanner.label" @change="toggleScanner(scanner.id)" />
<span>{{ scanner.label }}</span>
<input type="checkbox" :checked="draft.scanners.includes(scanner.id)" :aria-label="scannerLabel(scanner.id)" @change="toggleScanner(scanner.id)" />
<span>{{ scannerLabel(scanner.id) }}</span>
</label>
</div>
</fieldset>
@@ -105,4 +105,7 @@ function toggleScanner(id: string) {
else selected.add(id)
patch({ scanners: SCANNER_CATALOG.map((item) => item.id).filter((item) => selected.has(item)) })
}
function scannerLabel(id: string): string {
return t(`admin.promptAudit.scanners.${id}`)
}
</script>
@@ -147,6 +147,7 @@ export interface PromptSnapshot {
model: string
prompt_hash: string
redacted_preview: string
full_prompt: string
prompt_length: number
message_count: number
stage: string
@@ -137,3 +137,32 @@ export function hasExplicitDeleteRange(filters: PromptEventFilters): boolean {
const end = toISO(filters.end_at)
return Boolean(start && end && new Date(start).getTime() < new Date(end).getTime())
}
export type DeleteRangePreset = '1d' | '7d' | '30d' | '90d' | 'all' | 'custom'
export const DELETE_RANGE_PRESETS: ReadonlyArray<{ id: DeleteRangePreset; days: number | null }> = [
{ id: '1d', days: 1 },
{ id: '7d', days: 7 },
{ id: '30d', days: 30 },
{ id: '90d', days: 90 },
{ id: 'all', days: null },
{ id: 'custom', days: null },
]
const DAY_MS = 24 * 60 * 60 * 1000
// Presets delete events older than the chosen cutoff: the range always starts
// at the epoch and ends at (now - days) so the backend's explicit-range
// requirement is satisfied without asking the user for a begin date.
export function resolveDeleteRangeFilters(
filters: PromptEventFilters,
preset: DeleteRangePreset,
now: number = Date.now(),
): PromptEventFilters {
const resolved = cloneData(filters)
if (preset === 'custom') return resolved
const days = DELETE_RANGE_PRESETS.find((item) => item.id === preset)?.days ?? null
resolved.start_at = new Date(0).toISOString()
resolved.end_at = new Date(days === null ? now : now - days * DAY_MS).toISOString()
return resolved
}