feat: allow one-click audit event filter deletion

This commit is contained in:
mt21625457
2026-07-17 15:50:31 +08:00
parent ac685ccaf5
commit e69e46ba02
6 changed files with 107 additions and 13 deletions
@@ -400,12 +400,20 @@ async function runFilterDeletePreview(value: PromptEventFilters) {
appStore.showError(errorMessage(error, 'admin.promptAudit.errors.previewDelete'))
} finally { loading.previewing = false }
}
async function confirmFilterDelete() {
if (!deletePreview.value || !deletePreviewFilters.value) return
const preview = deletePreview.value
const previewFilters = cloneData(deletePreviewFilters.value)
async function confirmFilterDelete(filters?: PromptEventFilters) {
if (loading.deleting) return
loading.deleting = true
try {
let preview = deletePreview.value
let previewFilters = deletePreviewFilters.value ? cloneData(deletePreviewFilters.value) : null
// One-click path: no fresh preview (never requested, or cleared by a
// criteria change) — mint the confirmation token on the fly from the
// criteria the dialog just emitted, then delete in the same action.
if ((!preview || !previewFilters) && filters) {
preview = await promptAuditAPI.previewDelete(filters)
previewFilters = cloneData(filters)
}
if (!preview || !previewFilters) return
const result = await promptAuditAPI.deleteEventsByFilter(previewFilters, preview)
closeFilterDelete()
appStore.showSuccess(t('admin.promptAudit.messages.deleted', { count: result.deleted_events }))
@@ -49,7 +49,7 @@ const ConfirmStub = defineComponent({ props: ['show', 'title', 'message'], emits
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>',
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\', { ...initialFilters, start_at: \'2026-07-15T00:00\', end_at: \'2026-07-16T00:00\' })">confirm</button><span data-test="dialog-preview-state">{{ preview ? preview.matched_count : \'none\' }}</span></div>',
})
function mountView() {
@@ -212,4 +212,27 @@ describe('PromptAuditView', () => {
}))
expect(wrapper.find('[data-test="filter-delete-dialog"]').exists()).toBe(false)
})
it('mints the confirmation token on the fly for one-click filter deletion without a manual preview', async () => {
const wrapper = mountView()
await flushPromises()
await wrapper.get('[data-test="preview"]').trigger('click')
await flushPromises()
expect(wrapper.find('[data-test="filter-delete-dialog"]').exists()).toBe(true)
expect(mocks.previewDelete).not.toHaveBeenCalled()
await wrapper.get('[data-test="dialog-confirm"]').trigger('click')
await flushPromises()
expect(mocks.previewDelete).toHaveBeenCalledOnce()
expect(mocks.previewDelete).toHaveBeenCalledWith(expect.objectContaining({ start_at: '2026-07-15T00:00', end_at: '2026-07-16T00:00' }))
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)
})
})
@@ -110,7 +110,14 @@ describe('Prompt Audit components', () => {
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')
// A valid preset is enough: confirm is armed immediately (one-click flow)
// and needs no disabled-reason hint.
expect(wrapper.get('[data-test="confirm-filter-delete"]').attributes()).not.toHaveProperty('disabled')
expect(wrapper.find('[data-test="confirm-disabled-reason"]').exists()).toBe(false)
await wrapper.get('[data-test="confirm-filter-delete"]').trigger('click')
const directConfirm = wrapper.emitted('confirm')?.at(-1)?.[0] as PromptEventFilters
expect(directConfirm.start_at).toBe('1970-01-01T00:00:00.000Z')
expect(Date.now() - new Date(directConfirm.end_at).getTime()).toBeGreaterThanOrEqual(7 * 24 * 60 * 60 * 1000)
await wrapper.get('[data-test="range-preset-30d"]').setValue()
expect(wrapper.emitted('criteria-change')?.length).toBeGreaterThan(0)
@@ -124,9 +131,14 @@ describe('Prompt Audit components', () => {
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')
expect(wrapper.get('[data-test="confirm-filter-delete"]').attributes()).toHaveProperty('disabled')
expect(wrapper.get('[data-test="confirm-disabled-reason"]').text()).toBe('admin.promptAudit.events.filterDeleteConfirmInvalidRange')
expect(wrapper.get('[data-test="confirm-filter-delete"]').attributes('title')).toBe('admin.promptAudit.events.filterDeleteConfirmInvalidRange')
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')
expect(wrapper.get('[data-test="confirm-filter-delete"]').attributes()).not.toHaveProperty('disabled')
expect(wrapper.find('[data-test="confirm-disabled-reason"]').exists()).toBe(false)
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')
@@ -136,9 +148,30 @@ describe('Prompt Audit components', () => {
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.find('[data-test="confirm-disabled-reason"]').exists()).toBe(false)
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)
const confirmed = wrapper.emitted('confirm')?.at(-1)?.[0] as PromptEventFilters
expect(confirmed.start_at).toBe('2026-07-01T00:00')
expect(confirmed.end_at).toBe('2026-07-02T00:00')
})
it('explains that a zero-match preview leaves nothing to delete', async () => {
const wrapper = mount(FilterDeleteDialog, {
props: {
show: true,
initialFilters: emptyEventFilters(),
preview: { matched_count: 0, filter_summary: {}, snapshot_max_id: 0, filter_hash: 'c'.repeat(64), confirmation_token: 'tok', expires_at: '2026-07-16T00:05:00Z' },
previewing: false,
deleting: false,
},
global: { stubs: { BaseDialog: DialogStub } },
})
expect(wrapper.get('[data-test="confirm-filter-delete"]').attributes()).toHaveProperty('disabled')
expect(wrapper.get('[data-test="confirm-disabled-reason"]').text()).toBe('admin.promptAudit.events.filterDeleteConfirmNoMatches')
await wrapper.setProps({ previewing: true })
expect(wrapper.find('[data-test="confirm-disabled-reason"]').exists()).toBe(false)
expect(wrapper.get('[data-test="confirm-filter-delete"]').attributes()).toHaveProperty('disabled')
})
it('inherits an explicit list-filter range as the custom preset', async () => {
@@ -94,11 +94,21 @@
<template #footer>
<div class="flex flex-wrap items-center justify-end gap-3">
<p v-if="confirmDisabledReason" class="mr-auto text-xs text-gray-500 dark:text-dark-400" data-test="confirm-disabled-reason">
{{ t(confirmDisabledReason) }}
</p>
<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')">
<button
type="button"
class="btn btn-danger"
:disabled="confirmDisabled"
:title="confirmDisabledReason ? t(confirmDisabledReason) : undefined"
data-test="confirm-filter-delete"
@click="requestConfirm"
>
{{ deleting ? t('common.submitting') : t('admin.promptAudit.events.confirmFilterDelete') }}
</button>
</div>
@@ -130,7 +140,7 @@ const props = defineProps<{
const emit = defineEmits<{
(event: 'close'): void
(event: 'preview', value: PromptEventFilters): void
(event: 'confirm'): void
(event: 'confirm', value: PromptEventFilters): void
(event: 'criteria-change'): void
}>()
const { t, locale } = useI18n()
@@ -153,6 +163,20 @@ watch(
const canPreview = computed(() => preset.value !== 'custom' || hasExplicitDeleteRange(local))
// One-click flow: a valid criteria selection is enough to confirm — the parent
// mints the server-side confirmation token on the fly. The button stays
// disabled only when the range is invalid, work is in flight, or a fresh
// preview already proved there is nothing to delete.
const confirmDisabled = computed(
() => !canPreview.value || props.previewing || props.deleting || (props.preview !== null && props.preview.matched_count === 0),
)
const confirmDisabledReason = computed(() => {
if (props.previewing || props.deleting) return ''
if (!canPreview.value) return 'admin.promptAudit.events.filterDeleteConfirmInvalidRange'
if (props.preview && props.preview.matched_count === 0) return 'admin.promptAudit.events.filterDeleteConfirmNoMatches'
return ''
})
function criteriaChanged() {
emit('criteria-change')
}
@@ -160,6 +184,10 @@ function requestPreview() {
if (!canPreview.value) return
emit('preview', resolveDeleteRangeFilters(local, preset.value))
}
function requestConfirm() {
if (confirmDisabled.value) return
emit('confirm', resolveDeleteRangeFilters(local, preset.value))
}
function formatDate(value: string): string {
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium', timeStyle: 'medium' }).format(new Date(value))
}
@@ -64,12 +64,13 @@ export default {
events: {
title: 'Audit events', description: 'Review events by identity, route, risk, hash, and time; the detail view shows the full prompt.', decision: 'Decision', risk: 'Risk level', endpoint: 'Endpoint', groupId: 'Group ID', userId: 'User ID', apiKeyId: 'API Key ID', keyword: 'Keyword',
startAt: 'Start time', endAt: 'End time', deleteSelected: 'Delete selected ({count})', deleteByFilter: 'Delete by filter',
filterDeleteDialogTitle: 'Delete audit events by filter', filterDeleteDialogDesc: 'Choose the time range and risk criteria, generate a server-side delete preview, then confirm the matched count. Deletion is permanent.',
filterDeleteDialogTitle: 'Delete audit events by filter', filterDeleteDialogDesc: 'Choose the time range and risk criteria, then delete directly. Deletion is permanent. Generate a preview first if you want to see the match count.',
filterTimeRange: 'Deletion time range', filterTimeRangeHint: 'Deletes events created before the selected cutoff. Events created after the preview are not affected.',
timePresets: { '1d': 'Older than 1 day', '7d': 'Older than 7 days', '30d': 'Older than 30 days', '90d': 'Older than 90 days', all: 'All time', custom: 'Custom range' },
customRangeInvalid: 'A custom range needs a valid start and end time, with the start before the end.',
moreConditions: 'More conditions (endpoint / keyword / group / user)',
filterDeletePreviewAction: 'Generate delete preview', filterDeletePreviewing: 'Generating preview…', filterDeleteNeedPreview: 'Generate a delete preview first. Any criteria change requires a fresh preview.',
filterDeletePreviewAction: 'Generate delete preview', filterDeletePreviewing: 'Generating preview…', filterDeleteNeedPreview: 'You can delete directly, or generate a preview first to see the match count.',
filterDeleteConfirmInvalidRange: 'Select a valid deletion time range first (a custom range needs a start before the end).', filterDeleteConfirmNoMatches: 'The current filters matched 0 events, so there is nothing to delete.',
selectAll: 'Select all events on this page', selectEvent: 'Select event {id}', time: 'Time', identity: 'User / email / API Key', user: 'Username', email: 'User email', apiKey: 'API Key name', group: 'Group', route: 'Endpoint / model', result: 'Decision / risk', preview: 'Redacted preview', empty: 'No matching events.',
passEventsDisabled: '“Store safe events” is off. Safe requests are still audited but do not appear in this list; Flag and Critical risk events are still stored.', openConfiguration: 'Open configuration',
detailTitle: 'Prompt audit event details', tabs: { summary: 'Audit summary', risks: 'Specific risks', technical: 'Technical details' },
@@ -64,12 +64,13 @@ export default {
events: {
title: '审计事件', description: '按身份、入口、风险、Hash 和时间复核事件,详情中可查看完整提示词。', decision: '判定', risk: '风险等级', endpoint: '入口', groupId: '分组 ID', userId: '用户 ID', apiKeyId: 'API Key ID', keyword: '关键词',
startAt: '开始时间', endAt: '结束时间', deleteSelected: '删除选中项({count})', deleteByFilter: '按筛选删除',
filterDeleteDialogTitle: '按筛选删除审计事件', filterDeleteDialogDesc: '选择删除的时间范围与风险条件,先生成服务端删除预览,确认匹配数量后再执行;删除不可恢复。',
filterDeleteDialogTitle: '按筛选删除审计事件', filterDeleteDialogDesc: '选择删除的时间范围与风险条件后即可执行删除;删除不可恢复。如需提前查看匹配数量,可先获取删除预览。',
filterTimeRange: '删除时间范围', filterTimeRangeHint: '将删除所选截止时间之前产生的事件;预览后新产生的事件不受影响。',
timePresets: { '1d': '1 天前', '7d': '7 天前', '30d': '30 天前', '90d': '90 天前', all: '全部时间', custom: '自定义范围' },
customRangeInvalid: '自定义范围需要有效的开始与结束时间,且开始早于结束。',
moreConditions: '更多条件(入口 / 关键词 / 分组 / 用户)',
filterDeletePreviewAction: '获取删除预览', filterDeletePreviewing: '正在生成预览…', filterDeleteNeedPreview: '请先获取删除预览;任何条件变化后都需要重新获取。',
filterDeletePreviewAction: '获取删除预览', filterDeletePreviewing: '正在生成预览…', filterDeleteNeedPreview: '可直接确认删除;如需提前查看匹配数量,可先获取删除预览。',
filterDeleteConfirmInvalidRange: '请先选择有效的删除时间范围(自定义范围需开始早于结束)。', filterDeleteConfirmNoMatches: '当前筛选匹配 0 条事件,没有可删除的内容。',
selectAll: '选择当前页全部事件', selectEvent: '选择事件 {id}', time: '时间', identity: '用户 / 邮箱 / API Key', user: '用户名', email: '用户邮箱', apiKey: 'API Key 名称', group: '分组', route: '入口 / 模型', result: '判定 / 风险', preview: '脱敏预览', empty: '没有符合条件的事件。',
passEventsDisabled: '当前未开启“保存安全事件”:安全请求仍会完成审计,但不会出现在事件列表中;Flag 和 Critical 风险事件仍会保存。', openConfiguration: '前往配置',
detailTitle: '提示词审计事件详情', tabs: { summary: '审计摘要', risks: '具体风险', technical: '技术信息' },