mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-03 00:33:13 +00:00
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:
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
|
||||
}
|
||||
Reference in new issue
Block a user