mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 16:23:31 +00:00
Merge pull request #4485 from Sub2API-Devs/dev
feat(security-audit): 新增 OpenAI 兼容提示词审计能力与安全审计控制台
This commit is contained in:
@@ -771,7 +771,18 @@ const adminNavItems = computed((): NavItem[] => {
|
||||
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
|
||||
{ path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon },
|
||||
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
|
||||
{ path: '/admin/risk-control', label: t('nav.riskControl'), icon: ShieldIcon, hideInSimpleMode: true, featureFlag: flagRiskControl },
|
||||
{
|
||||
path: '/admin/security-audit',
|
||||
label: t('nav.securityAudit'),
|
||||
icon: ShieldIcon,
|
||||
hideInSimpleMode: true,
|
||||
expandOnly: true,
|
||||
featureFlag: flagRiskControl,
|
||||
children: [
|
||||
{ path: '/admin/risk-control', label: t('nav.contentModeration'), icon: ShieldIcon },
|
||||
{ path: '/admin/prompt-audit', label: t('nav.promptAudit'), icon: ShieldIcon },
|
||||
],
|
||||
},
|
||||
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
|
||||
{ path: '/admin/promo-codes', label: t('nav.promoCodes'), icon: GiftIcon, hideInSimpleMode: true },
|
||||
{
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="mx-auto max-w-[1600px]" :class="activeTab === 'config' && draft ? 'pb-28' : 'pb-8'">
|
||||
<header class="mb-6 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-primary-600 dark:text-primary-400">{{ t('nav.securityAudit') }}</p>
|
||||
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-gray-950 dark:text-white">{{ t('admin.promptAudit.title') }}</h1>
|
||||
<p class="mt-2 max-w-3xl text-sm text-gray-500 dark:text-dark-300">{{ t('admin.promptAudit.description') }}</p>
|
||||
</div>
|
||||
<div v-if="draft" class="text-right text-xs text-gray-500 dark:text-dark-400">
|
||||
<p>{{ t('admin.promptAudit.configVersion', { version: draft.config_version }) }}</p>
|
||||
<p v-if="draft.updated_at" class="mt-1">{{ formatDate(draft.updated_at) }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="loadErrors.config && !draft" role="alert" class="rounded-xl border border-red-200 bg-red-50 p-5 dark:border-red-900 dark:bg-red-950/30">
|
||||
<p class="text-sm text-red-700 dark:text-red-300">{{ loadErrors.config }}</p>
|
||||
<button type="button" class="btn btn-secondary btn-sm mt-3" @click="loadConfig">{{ t('admin.promptAudit.actions.retry') }}</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="mb-4" role="tablist" :aria-label="t('admin.promptAudit.title')">
|
||||
<div class="tabs inline-flex">
|
||||
<button
|
||||
v-for="tab in pageTabs"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
role="tab"
|
||||
class="tab"
|
||||
:class="{ 'tab-active': activeTab === tab.id }"
|
||||
:aria-selected="activeTab === tab.id"
|
||||
:data-test="`tab-${tab.id}`"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="card px-4 sm:px-6 lg:px-8">
|
||||
<div v-show="activeTab === 'config'" data-test="tab-panel-config">
|
||||
<RuntimeOverview :runtime="runtime" :loading="loading.runtime" :error="loadErrors.runtime" @refresh="loadRuntime" />
|
||||
|
||||
<template v-if="draft">
|
||||
<EndpointPool
|
||||
:endpoints="draft.endpoints"
|
||||
:probe-results="probeResults"
|
||||
:probing-ids="probingIds"
|
||||
@update:endpoints="updateEndpoints"
|
||||
@probe="runProbe"
|
||||
/>
|
||||
<div v-if="loadErrors.groups" role="alert" class="mt-5 rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:bg-amber-950/30 dark:text-amber-200">{{ loadErrors.groups }}</div>
|
||||
<PolicyPanel :draft="draft" :groups="groups" @update:draft="replaceDraft" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'events'" data-test="tab-panel-events">
|
||||
<div
|
||||
v-if="draft?.enabled && !draft.store_pass_events"
|
||||
data-test="pass-events-disabled-notice"
|
||||
role="status"
|
||||
class="mt-6 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/30 dark:text-amber-200"
|
||||
>
|
||||
<span>{{ t('admin.promptAudit.events.passEventsDisabled') }}</span>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="activeTab = 'config'">
|
||||
{{ t('admin.promptAudit.events.openConfiguration') }}
|
||||
</button>
|
||||
</div>
|
||||
<EventWorkspace
|
||||
:events="events.items"
|
||||
:total="events.total"
|
||||
:page="events.page"
|
||||
:page-size="events.page_size"
|
||||
:filters="filters"
|
||||
:selected-ids="selectedEventIds"
|
||||
:loading="loading.events"
|
||||
:error="loadErrors.events"
|
||||
@filters-change="handleFiltersChanged"
|
||||
@search="applyEventFilters"
|
||||
@selection="selectedEventIds = $event"
|
||||
@page="changePage"
|
||||
@page-size="changePageSize"
|
||||
@view="openEvent"
|
||||
@delete="requestSingleDelete"
|
||||
@batch-delete="requestBatchDelete"
|
||||
@preview-delete="requestFilterDeletePreview"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="draft && activeTab === 'config'" class="fixed inset-x-0 bottom-0 z-30 border-t border-gray-200 bg-white/95 px-4 py-3 shadow-[0_-12px_35px_rgba(15,23,42,0.08)] backdrop-blur dark:border-dark-700/80 dark:bg-dark-900/95 dark:shadow-[0_-12px_35px_rgba(0,0,0,0.35)] lg:left-64">
|
||||
<div class="mx-auto flex max-w-[1600px] flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-x-5 gap-y-2">
|
||||
<SaveToggle :label="t('admin.promptAudit.saveBar.enabled')" :model-value="draft.enabled" data-test="enabled-toggle" @update:model-value="setEnabled" />
|
||||
<SaveToggle :label="t('admin.promptAudit.saveBar.blocking')" :model-value="draft.blocking_enabled" :disabled="!draft.enabled" data-test="blocking-toggle" @update:model-value="setBlocking" />
|
||||
<SaveToggle :label="t('admin.promptAudit.saveBar.storePass')" :model-value="draft.store_pass_events" data-test="store-pass-toggle" @update:model-value="replaceDraft({ ...draft!, store_pass_events: $event })" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm" :class="dirty ? 'text-amber-700 dark:text-amber-300' : 'text-gray-500 dark:text-dark-400'">
|
||||
{{ dirty ? t('admin.promptAudit.saveBar.dirty') : t('admin.promptAudit.saveBar.synced') }}
|
||||
</span>
|
||||
<button type="button" class="btn btn-secondary" :disabled="!dirty || loading.saving" @click="resetDraft">{{ t('common.reset') }}</button>
|
||||
<button type="button" class="btn btn-primary" :disabled="!dirty || loading.saving" data-test="save-config" @click="saveConfig">
|
||||
{{ loading.saving ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
:show="showBlockingConfirmation"
|
||||
:title="t('admin.promptAudit.blockingConfirm.title')"
|
||||
:message="t('admin.promptAudit.blockingConfirm.message')"
|
||||
:confirm-text="t('admin.promptAudit.blockingConfirm.confirm')"
|
||||
danger
|
||||
@confirm="confirmBlocking"
|
||||
@cancel="showBlockingConfirmation = false"
|
||||
/>
|
||||
<ConfirmDialog
|
||||
:show="deleteRequest.mode !== ''"
|
||||
:title="t('admin.promptAudit.events.deleteConfirmTitle')"
|
||||
:message="t('admin.promptAudit.events.deleteConfirmMessage', { count: deleteRequest.ids.length })"
|
||||
:confirm-text="t('common.delete')"
|
||||
danger
|
||||
@confirm="confirmIDDelete"
|
||||
@cancel="clearDeleteRequest"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, h, onMounted, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { extractApiErrorCode, extractApiErrorMessage } from '@/utils/apiError'
|
||||
import RuntimeOverview from './components/RuntimeOverview.vue'
|
||||
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,
|
||||
PromptAuditEndpointDraft,
|
||||
PromptAuditEvent,
|
||||
PromptAuditGroup,
|
||||
PromptAuditRuntime,
|
||||
PromptDeletePreview,
|
||||
PromptEventFilters,
|
||||
PromptEventPage,
|
||||
PromptLoadErrors,
|
||||
PromptProbeResult,
|
||||
} from './types'
|
||||
import { buildUpdateRequest, cloneData, configToDraft, draftFingerprint, emptyEventFilters } from './viewModel'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
type PromptAuditPageTab = 'config' | 'events'
|
||||
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') },
|
||||
])
|
||||
const serverConfig = ref<PromptAuditDraft | null>(null)
|
||||
const draft = ref<PromptAuditDraft | null>(null)
|
||||
const runtime = ref<PromptAuditRuntime | null>(null)
|
||||
const groups = ref<PromptAuditGroup[]>([])
|
||||
const events = reactive<PromptEventPage>({ items: [], total: 0, page: 1, page_size: 20, pages: 0 })
|
||||
const filters = ref<PromptEventFilters>(emptyEventFilters())
|
||||
const appliedFilters = ref<PromptEventFilters>(emptyEventFilters())
|
||||
const selectedEventIds = ref<number[]>([])
|
||||
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, previewing: false })
|
||||
const loadErrors = reactive<PromptLoadErrors>({ config: '', runtime: '', groups: '', events: '' })
|
||||
const dirty = computed(() => draftFingerprint(draft.value) !== draftFingerprint(serverConfig.value))
|
||||
|
||||
const SaveToggle = defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: { label: { type: String, required: true }, modelValue: { type: Boolean, required: true }, disabled: { type: Boolean, default: false } },
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit, attrs }) {
|
||||
return () => h('label', { class: ['flex items-center gap-2.5 text-sm', props.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'] }, [
|
||||
h('button', {
|
||||
...attrs,
|
||||
type: 'button',
|
||||
role: 'switch',
|
||||
'aria-checked': props.modelValue,
|
||||
'aria-label': props.label,
|
||||
disabled: props.disabled,
|
||||
class: [
|
||||
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2',
|
||||
props.modelValue ? 'bg-primary-600' : 'bg-gray-300 dark:bg-dark-600',
|
||||
props.disabled ? 'cursor-not-allowed' : 'cursor-pointer',
|
||||
],
|
||||
onClick: (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
if (!props.disabled) emit('update:modelValue', !props.modelValue)
|
||||
},
|
||||
}, [
|
||||
h('span', {
|
||||
class: [
|
||||
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transition-transform duration-200 ease-in-out',
|
||||
props.modelValue ? 'translate-x-5' : 'translate-x-0',
|
||||
],
|
||||
}),
|
||||
]),
|
||||
h('span', { class: 'select-none text-gray-700 dark:text-dark-200' }, props.label),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
function errorMessage(error: unknown, fallbackKey: string): string {
|
||||
const code = extractApiErrorCode(error)
|
||||
if (code) {
|
||||
const key = `admin.promptAudit.errors.${code}`
|
||||
const translated = t(key)
|
||||
if (translated !== key) return translated
|
||||
}
|
||||
return extractApiErrorMessage(error, t(fallbackKey))
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
loading.config = true
|
||||
loadErrors.config = ''
|
||||
try {
|
||||
const config = await promptAuditAPI.getConfig()
|
||||
serverConfig.value = configToDraft(config)
|
||||
draft.value = configToDraft(config)
|
||||
} catch (error) {
|
||||
loadErrors.config = errorMessage(error, 'admin.promptAudit.errors.loadConfig')
|
||||
} finally {
|
||||
loading.config = false
|
||||
}
|
||||
}
|
||||
async function loadRuntime() {
|
||||
loading.runtime = true
|
||||
loadErrors.runtime = ''
|
||||
try { runtime.value = await promptAuditAPI.getRuntime() }
|
||||
catch (error) { loadErrors.runtime = errorMessage(error, 'admin.promptAudit.errors.loadRuntime') }
|
||||
finally { loading.runtime = false }
|
||||
}
|
||||
async function loadGroups() {
|
||||
loading.groups = true
|
||||
loadErrors.groups = ''
|
||||
try { groups.value = await promptAuditAPI.listGroups() }
|
||||
catch (error) { loadErrors.groups = errorMessage(error, 'admin.promptAudit.errors.loadGroups') }
|
||||
finally { loading.groups = false }
|
||||
}
|
||||
async function loadEvents() {
|
||||
loading.events = true
|
||||
loadErrors.events = ''
|
||||
try {
|
||||
const result = await promptAuditAPI.listEvents(appliedFilters.value, events.page, events.page_size)
|
||||
Object.assign(events, result)
|
||||
selectedEventIds.value = []
|
||||
} catch (error) {
|
||||
loadErrors.events = errorMessage(error, 'admin.promptAudit.errors.loadEvents')
|
||||
} finally {
|
||||
loading.events = false
|
||||
}
|
||||
}
|
||||
async function loadInitial() {
|
||||
await Promise.allSettled([loadConfig(), loadRuntime(), loadGroups(), loadEvents()])
|
||||
}
|
||||
|
||||
function replaceDraft(value: PromptAuditDraft) { draft.value = cloneData(value) }
|
||||
function updateEndpoints(value: PromptAuditEndpointDraft[]) {
|
||||
if (!draft.value) return
|
||||
replaceDraft({ ...draft.value, endpoints: value })
|
||||
}
|
||||
function setEnabled(value: boolean) {
|
||||
if (!draft.value) return
|
||||
replaceDraft({ ...draft.value, enabled: value, blocking_enabled: value ? draft.value.blocking_enabled : false })
|
||||
}
|
||||
function setBlocking(value: boolean) {
|
||||
if (!draft.value || !draft.value.enabled) return
|
||||
if (value && !draft.value.blocking_enabled) { showBlockingConfirmation.value = true; return }
|
||||
replaceDraft({ ...draft.value, blocking_enabled: value })
|
||||
}
|
||||
function confirmBlocking() {
|
||||
showBlockingConfirmation.value = false
|
||||
if (draft.value) replaceDraft({ ...draft.value, blocking_enabled: true })
|
||||
}
|
||||
function resetDraft() {
|
||||
if (serverConfig.value) draft.value = cloneData(serverConfig.value)
|
||||
}
|
||||
async function saveConfig() {
|
||||
if (!draft.value || !dirty.value) return
|
||||
loading.saving = true
|
||||
try {
|
||||
const saved = await promptAuditAPI.updateConfig(buildUpdateRequest(draft.value))
|
||||
serverConfig.value = configToDraft(saved)
|
||||
draft.value = configToDraft(saved)
|
||||
appStore.showSuccess(t('admin.promptAudit.messages.saved'))
|
||||
await loadRuntime()
|
||||
} catch (error) {
|
||||
const code = extractApiErrorCode(error)
|
||||
appStore.showError(errorMessage(error, code === 'prompt_audit_config_conflict' ? 'admin.promptAudit.errors.prompt_audit_config_conflict' : 'admin.promptAudit.errors.saveConfig'))
|
||||
} finally {
|
||||
loading.saving = false
|
||||
}
|
||||
}
|
||||
async function runProbe(endpoint: PromptAuditEndpointDraft) {
|
||||
if (probingIds.value.includes(endpoint.id)) return
|
||||
probingIds.value = [...probingIds.value, endpoint.id]
|
||||
try {
|
||||
const result = await promptAuditAPI.probeEndpoint(endpoint)
|
||||
probeResults[endpoint.id] = result
|
||||
if (result.ok) appStore.showSuccess(t('admin.promptAudit.messages.probeSucceeded'))
|
||||
else appStore.showError(`${result.error_code || result.status}: ${result.message}`)
|
||||
} catch (error) {
|
||||
appStore.showError(errorMessage(error, 'admin.promptAudit.errors.probe'))
|
||||
} finally {
|
||||
probingIds.value = probingIds.value.filter((id) => id !== endpoint.id)
|
||||
}
|
||||
}
|
||||
|
||||
function handleFiltersChanged(value: PromptEventFilters) {
|
||||
filters.value = cloneData(value)
|
||||
clearDeletePreview()
|
||||
}
|
||||
function applyEventFilters(value: PromptEventFilters) {
|
||||
filters.value = cloneData(value)
|
||||
appliedFilters.value = cloneData(value)
|
||||
events.page = 1
|
||||
clearDeletePreview()
|
||||
void loadEvents()
|
||||
}
|
||||
function changePage(value: number) { events.page = value; void loadEvents() }
|
||||
function changePageSize(value: number) { events.page_size = value; events.page = 1; void loadEvents() }
|
||||
async function openEvent(id: number) {
|
||||
showEventDetail.value = true
|
||||
loading.detail = true
|
||||
activeEvent.value = null
|
||||
try { activeEvent.value = await promptAuditAPI.getEvent(id) }
|
||||
catch (error) { appStore.showError(errorMessage(error, 'admin.promptAudit.errors.loadDetail')); showEventDetail.value = false }
|
||||
finally { loading.detail = false }
|
||||
}
|
||||
function closeEventDetail() { showEventDetail.value = false; activeEvent.value = null }
|
||||
function requestSingleDelete(id: number) { deleteRequest.mode = 'single'; deleteRequest.ids = [id] }
|
||||
function requestBatchDelete() { if (selectedEventIds.value.length) { deleteRequest.mode = 'batch'; deleteRequest.ids = [...selectedEventIds.value] } }
|
||||
function clearDeleteRequest() { deleteRequest.mode = ''; deleteRequest.ids = [] }
|
||||
async function confirmIDDelete() {
|
||||
const mode = deleteRequest.mode
|
||||
const ids = [...deleteRequest.ids]
|
||||
clearDeleteRequest()
|
||||
if (!mode || ids.length === 0) return
|
||||
loading.deleting = true
|
||||
try {
|
||||
const result = mode === 'single' ? await promptAuditAPI.deleteEvent(ids[0]) : await promptAuditAPI.batchDeleteEvents(ids)
|
||||
appStore.showSuccess(t('admin.promptAudit.messages.deleted', { count: result.deleted_events }))
|
||||
await Promise.allSettled([loadEvents(), loadRuntime()])
|
||||
} catch (error) { appStore.showError(errorMessage(error, 'admin.promptAudit.errors.delete')) }
|
||||
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(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 }))
|
||||
await Promise.allSettled([loadEvents(), loadRuntime()])
|
||||
} catch (error) {
|
||||
clearDeletePreview()
|
||||
appStore.showError(errorMessage(error, 'admin.promptAudit.errors.deleteConfirmation'))
|
||||
} finally { loading.deleting = false }
|
||||
}
|
||||
function formatDate(value: string): string {
|
||||
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium', timeStyle: 'medium' }).format(new Date(value))
|
||||
}
|
||||
|
||||
onMounted(loadInitial)
|
||||
</script>
|
||||
@@ -0,0 +1,238 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import type { PromptAuditConfig, PromptAuditRuntime } from '../types'
|
||||
import { SCANNER_CATALOG } from '../viewModel'
|
||||
import PromptAuditView from '../PromptAuditView.vue'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getConfig: vi.fn(), updateConfig: vi.fn(), probeEndpoint: vi.fn(), getRuntime: vi.fn(), listEvents: vi.fn(),
|
||||
getEvent: vi.fn(), deleteEvent: vi.fn(), batchDeleteEvents: vi.fn(), previewDelete: vi.fn(), deleteEventsByFilter: vi.fn(), listGroups: vi.fn(),
|
||||
showSuccess: vi.fn(), showError: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../api', () => ({ default: mocks }))
|
||||
vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showSuccess: mocks.showSuccess, showError: mocks.showError }) }))
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return { ...actual, useI18n: () => ({ locale: { value: 'en' }, t: (key: string, params?: Record<string, unknown>) => key.replace(/\{(\w+)\}/g, (_, token) => String(params?.[token] ?? `{${token}}`)) }) }
|
||||
})
|
||||
|
||||
const baseConfig = (): PromptAuditConfig => ({
|
||||
enabled: true, blocking_enabled: false, store_pass_events: false, effective_mode: 'async_audit', strategy: 'priority',
|
||||
worker_count: 4, queue_capacity: 100, scanners: SCANNER_CATALOG.map((item) => item.id), all_groups: true, group_ids: [],
|
||||
endpoints: [{ id: 'guard-1', name: 'Guard One', protocol: 'openai_compatible', base_url: 'http://127.0.0.1:8000', model: 'guard-model', timeout_ms: 3000, input_limit: 4000, enabled: true, has_token: true, token_status: 'configured' }],
|
||||
config_version: 7, updated_at: '2026-07-16T00:00:00Z', updated_by: 1, change_summary: '{}',
|
||||
})
|
||||
const runtime = (): PromptAuditRuntime => ({
|
||||
process_status: 'running', effective_mode: 'async_audit', expected_config_version: 7, active_config_version: 7,
|
||||
worker_total: 4, worker_active: 1, queue_capacity: 100,
|
||||
queue: { staging: 0, queued: 0, processing: 1, retry: 0, done: 5, failed: 0, active: 1 },
|
||||
processed_total: 5, failed_total: 0, enqueued_total: 5, dropped_total: 0, database_status: 'ok', redis_status: 'ok', endpoints: {},
|
||||
guard_metrics: { total: 1, allowed: 1, flagged: 0, blocked: 0, unavailable: 0, invalid: 0, timeouts: 0, failovers: 0, bulkhead_full: 0, record_failed: 0 },
|
||||
})
|
||||
|
||||
const AppLayoutStub = { template: '<div><slot /></div>' }
|
||||
const RuntimeStub = defineComponent({ props: ['runtime', 'loading', 'error'], emits: ['refresh'], template: '<div data-test="runtime">{{ error }}</div>' })
|
||||
const EndpointStub = defineComponent({
|
||||
props: ['endpoints', 'probeResults', 'probingIds'], emits: ['update:endpoints', 'probe'],
|
||||
template: '<div data-test="endpoint"><button data-test="inject-secret" @click="$emit(\'update:endpoints\', endpoints.map((e) => ({ ...e, token: \'PROMPT_AUDIT_CANARY_SECRET_DO_NOT_PERSIST\' })))">secret</button><button data-test="probe" @click="$emit(\'probe\', endpoints[0])">probe</button></div>',
|
||||
})
|
||||
const PolicyStub = defineComponent({ props: ['draft', 'groups'], emits: ['update:draft'], template: '<div data-test="policy" />' })
|
||||
const EventsStub = defineComponent({
|
||||
props: ['events', 'filters', 'selectedIds', 'loading', 'error', 'total', 'page', 'pageSize'],
|
||||
emits: ['filters-change', 'search', 'selection', 'page', 'page-size', 'view', 'delete', 'batch-delete', 'preview-delete'],
|
||||
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 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\', { ...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() {
|
||||
return mount(PromptAuditView, {
|
||||
global: { stubs: { AppLayout: AppLayoutStub, RuntimeOverview: RuntimeStub, EndpointPool: EndpointStub, PolicyPanel: PolicyStub, EventWorkspace: EventsStub, EventDetailDialog: DetailStub, FilterDeleteDialog: FilterDeleteStub, ConfirmDialog: ConfirmStub } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('PromptAuditView', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(mocks).forEach((mock) => mock.mockReset())
|
||||
mocks.getConfig.mockResolvedValue(baseConfig())
|
||||
mocks.getRuntime.mockResolvedValue(runtime())
|
||||
mocks.listGroups.mockResolvedValue([])
|
||||
mocks.listEvents.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20, pages: 0 })
|
||||
mocks.updateConfig.mockImplementation(async () => ({ ...baseConfig(), config_version: 8 }))
|
||||
mocks.probeEndpoint.mockResolvedValue({ ok: true, status: 'healthy', message: 'ok', latency_ms: 2, http_status: 200, retryable: false, checked_at: '2026-07-16T00:00:00Z', token_applied: true })
|
||||
mocks.previewDelete.mockResolvedValue({ matched_count: 2, filter_summary: {}, snapshot_max_id: 10, filter_hash: 'a'.repeat(64), confirmation_token: 'opaque-confirmation', expires_at: '2026-07-16T00:05:00Z' })
|
||||
mocks.deleteEventsByFilter.mockResolvedValue({ deleted_events: 2, deleted_jobs: 2 })
|
||||
mocks.deleteEvent.mockResolvedValue({ deleted_events: 1, deleted_jobs: 1 })
|
||||
mocks.batchDeleteEvents.mockResolvedValue({ deleted_events: 2, deleted_jobs: 2 })
|
||||
})
|
||||
|
||||
it('starts config, runtime, groups, and events loads independently', async () => {
|
||||
mocks.getRuntime.mockRejectedValue(new Error('runtime offline'))
|
||||
const wrapper = mountView()
|
||||
expect(mocks.getConfig).toHaveBeenCalledOnce()
|
||||
expect(mocks.getRuntime).toHaveBeenCalledOnce()
|
||||
expect(mocks.listGroups).toHaveBeenCalledOnce()
|
||||
expect(mocks.listEvents).toHaveBeenCalledOnce()
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[data-test="runtime"]').text()).toContain('runtime offline')
|
||||
expect(wrapper.find('[data-test="endpoint"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-test="events"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('separates configuration and audit events into page tabs', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('[data-test="tab-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-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)
|
||||
|
||||
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.find('[data-test="save-config"]').exists()).toBe(false)
|
||||
|
||||
await wrapper.get('[data-test="pass-events-disabled-notice"] button').trigger('click')
|
||||
expect(wrapper.get('[data-test="tab-config"]').attributes('aria-selected')).toBe('true')
|
||||
expect(wrapper.find('[data-test="save-config"]').exists()).toBe(true)
|
||||
expect(wrapper.get('[data-test="tab-panel-config"]').attributes('style') || '').not.toContain('display: none')
|
||||
})
|
||||
|
||||
it('requires confirmation for blocking and disables it when audit is turned off', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
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')
|
||||
expect(wrapper.get('[data-test="blocking-toggle"]').attributes('aria-checked')).toBe('true')
|
||||
await wrapper.get('[data-test="enabled-toggle"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="enabled-toggle"]').attributes('aria-checked')).toBe('false')
|
||||
expect(wrapper.get('[data-test="blocking-toggle"]').attributes('aria-checked')).toBe('false')
|
||||
expect(wrapper.get('[data-test="blocking-toggle"]').attributes()).toHaveProperty('disabled')
|
||||
})
|
||||
|
||||
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')
|
||||
await flushPromises()
|
||||
expect(mocks.updateConfig).toHaveBeenCalledWith(expect.objectContaining({ endpoints: [expect.objectContaining({ token: 'PROMPT_AUDIT_CANARY_SECRET_DO_NOT_PERSIST' })] }))
|
||||
const endpointProps = wrapper.getComponent(EndpointStub).props('endpoints') as Array<{ token: string }>
|
||||
expect(endpointProps[0].token).toBe('')
|
||||
expect(wrapper.html()).not.toContain('PROMPT_AUDIT_CANARY_SECRET_DO_NOT_PERSIST')
|
||||
})
|
||||
|
||||
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="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="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)
|
||||
expect(wrapper.html()).toContain('fixed inset-x-0 bottom-0')
|
||||
expect(wrapper.html()).toContain('flex-wrap')
|
||||
})
|
||||
|
||||
it('executes single, selected-batch, and preview-confirmed filter deletion flows', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-test="delete-one"]').trigger('click')
|
||||
await wrapper.get('[data-test="confirm-action"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(mocks.deleteEvent).toHaveBeenCalledWith(5)
|
||||
|
||||
await wrapper.get('[data-test="select-batch"]').trigger('click')
|
||||
await wrapper.get('[data-test="delete-batch"]').trigger('click')
|
||||
await wrapper.get('[data-test="confirm-action"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(mocks.batchDeleteEvents).toHaveBeenCalledWith([5, 6])
|
||||
|
||||
await wrapper.get('[data-test="preview"]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-test="dialog-preview"]').trigger('click')
|
||||
await flushPromises()
|
||||
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)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { emptyEventFilters } from '../viewModel'
|
||||
|
||||
const client = vi.hoisted(() => ({ get: vi.fn(), put: vi.fn(), post: vi.fn(), delete: vi.fn() }))
|
||||
vi.mock('@/api/client', () => ({ apiClient: client }))
|
||||
|
||||
import promptAuditAPI from '../api'
|
||||
|
||||
describe('Prompt Audit API', () => {
|
||||
beforeEach(() => Object.values(client).forEach((mock) => mock.mockReset()))
|
||||
|
||||
it('uses the independent admin route namespace', async () => {
|
||||
client.get.mockResolvedValue({ data: { config_version: 1 } })
|
||||
await promptAuditAPI.getConfig()
|
||||
expect(client.get).toHaveBeenCalledWith('/admin/prompt-audit/config')
|
||||
|
||||
client.get.mockResolvedValue({ data: { process_status: 'running' } })
|
||||
await promptAuditAPI.getRuntime()
|
||||
expect(client.get).toHaveBeenCalledWith('/admin/prompt-audit/runtime')
|
||||
})
|
||||
|
||||
it('sends a temporary probe token only in the request and never invents response credentials', async () => {
|
||||
client.post.mockResolvedValue({ data: { ok: true, token_applied: true } })
|
||||
const result = await promptAuditAPI.probeEndpoint({
|
||||
id: 'guard-1', name: 'Guard', protocol: 'openai_compatible', base_url: 'http://127.0.0.1:8000', model: 'guard',
|
||||
token: 'api-canary-secret', clear_token: false, timeout_ms: 1000, input_limit: 1000, enabled: true, has_token: false, token_status: 'missing',
|
||||
})
|
||||
expect(client.post).toHaveBeenCalledWith('/admin/prompt-audit/endpoints/probe', expect.objectContaining({ endpoint: expect.objectContaining({ token: 'api-canary-secret' }) }))
|
||||
expect(JSON.stringify(result)).not.toContain('api-canary-secret')
|
||||
})
|
||||
|
||||
it('passes a server preview token through the confirmed filter-delete contract', async () => {
|
||||
client.post.mockResolvedValue({ data: { deleted_events: 2, deleted_jobs: 2 } })
|
||||
const filters = emptyEventFilters()
|
||||
filters.start_at = '2026-07-15T00:00'
|
||||
filters.end_at = '2026-07-16T00:00'
|
||||
await promptAuditAPI.deleteEventsByFilter(filters, {
|
||||
matched_count: 2, filter_summary: {}, snapshot_max_id: 10, filter_hash: 'a'.repeat(64), confirmation_token: 'opaque-token', expires_at: '2026-07-16T00:05:00Z',
|
||||
})
|
||||
expect(client.post).toHaveBeenCalledWith('/admin/prompt-audit/events/delete-by-filter', expect.objectContaining({
|
||||
snapshot_max_id: 10, filter_hash: 'a'.repeat(64), confirmation_token: 'opaque-token', confirm: true,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,256 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
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 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')
|
||||
return { ...actual, useI18n: () => ({ locale: { value: 'en' }, t: (key: string, params?: Record<string, unknown>) => key.replace(/\{(\w+)\}/g, (_, token) => String(params?.[token] ?? `{${token}}`)) }) }
|
||||
})
|
||||
|
||||
const DialogStub = defineComponent({ props: ['show', 'title'], emits: ['close'], template: '<div v-if="show" data-test="dialog"><slot /><slot name="footer" /></div>' })
|
||||
const PaginationStub = defineComponent({ props: ['total', 'page', 'pageSize'], emits: ['update:page', 'update:pageSize'], template: '<div data-test="pagination" />' })
|
||||
|
||||
const endpoint = (): PromptAuditEndpointDraft => ({
|
||||
id: 'guard-1', name: 'Guard One', protocol: 'openai_compatible', base_url: 'http://127.0.0.1:8000',
|
||||
model: 'guard-model', timeout_ms: 3000, input_limit: 4000, enabled: true,
|
||||
has_token: true, token_status: 'configured', token: '', clear_token: false,
|
||||
})
|
||||
|
||||
describe('Prompt Audit components', () => {
|
||||
beforeEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('edits a saved endpoint with blank-secret keep, explicit clear, replacement, and probe actions', async () => {
|
||||
const wrapper = mount(EndpointPool, {
|
||||
props: { endpoints: [endpoint()], probeResults: {}, probingIds: [] },
|
||||
global: { stubs: { BaseDialog: DialogStub } },
|
||||
})
|
||||
expect(wrapper.text()).toContain('admin.promptAudit.pool.configured')
|
||||
const edit = wrapper.findAll('button').find((button) => button.text().includes('common.edit'))
|
||||
expect(edit).toBeTruthy()
|
||||
await edit!.trigger('click')
|
||||
const token = wrapper.get<HTMLInputElement>('[aria-label="admin.promptAudit.pool.apiKey"]')
|
||||
expect(token.element.value).toBe('')
|
||||
expect(token.attributes('placeholder')).toContain('admin.promptAudit.pool.keepSecret')
|
||||
|
||||
await wrapper.get<HTMLInputElement>('[aria-label="admin.promptAudit.pool.clearSecret"]').setValue(true)
|
||||
await token.setValue('replacement-canary')
|
||||
await wrapper.get('[data-test="save-endpoint"]').trigger('click')
|
||||
const updated = wrapper.emitted('update:endpoints')?.at(-1)?.[0] as PromptAuditEndpointDraft[]
|
||||
expect(updated[0]).toMatchObject({ token: 'replacement-canary', clear_token: false })
|
||||
|
||||
const probe = wrapper.findAll('button').find((button) => button.text().includes('admin.promptAudit.pool.probe'))
|
||||
await probe!.trigger('click')
|
||||
expect(wrapper.emitted('probe')?.[0]?.[0]).toMatchObject({ id: 'guard-1' })
|
||||
})
|
||||
|
||||
it('supports group search, stale configured groups, nine scanners, and bounded worker inputs', async () => {
|
||||
const draft: PromptAuditDraft = {
|
||||
enabled: true, blocking_enabled: false, store_pass_events: false, effective_mode: 'async_audit', strategy: 'priority',
|
||||
worker_count: 4, queue_capacity: 100, scanners: SCANNER_CATALOG.map((item) => item.id), all_groups: false, group_ids: [1, 99],
|
||||
endpoints: [endpoint()], config_version: 1, updated_at: '', updated_by: 0, change_summary: '',
|
||||
}
|
||||
const wrapper = mount(PolicyPanel, {
|
||||
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') === `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')
|
||||
await wrapper.get('[aria-label="admin.promptAudit.policy.workerCount"]').setValue('6')
|
||||
const emitted = wrapper.emitted('update:draft')?.at(-1)?.[0] as PromptAuditDraft
|
||||
expect(emitted.worker_count).toBe(6)
|
||||
})
|
||||
|
||||
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', 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: '' },
|
||||
global: { stubs: { Pagination: PaginationStub } },
|
||||
})
|
||||
expect(wrapper.text()).toContain('alice')
|
||||
expect(wrapper.text()).toContain('alice@example.test')
|
||||
expect(wrapper.text()).toContain('alice-key')
|
||||
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()
|
||||
// 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)
|
||||
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')
|
||||
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')
|
||||
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.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')
|
||||
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 () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import en from '@/i18n/locales/en'
|
||||
import zh from '@/i18n/locales/zh'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const read = (path: string) => readFileSync(resolve(here, path), 'utf8')
|
||||
|
||||
describe('Prompt Audit integration surface', () => {
|
||||
it('registers an admin and risk-control guarded route', () => {
|
||||
const router = read('../../../router/index.ts')
|
||||
expect(router).toContain("path: '/admin/prompt-audit'")
|
||||
const route = router.slice(router.indexOf("path: '/admin/prompt-audit'"), router.indexOf("path: '/admin/usage'"))
|
||||
expect(route).toContain('requiresAuth: true')
|
||||
expect(route).toContain('requiresAdmin: true')
|
||||
expect(route).toContain('requiresRiskControl: true')
|
||||
})
|
||||
|
||||
it('keeps the legacy content moderation route and adds both pages under an expand-only security group', () => {
|
||||
const sidebar = read('../../../components/layout/AppSidebar.vue')
|
||||
const group = sidebar.slice(sidebar.indexOf("path: '/admin/security-audit'"), sidebar.indexOf("path: '/admin/redeem'"))
|
||||
expect(group).toContain('expandOnly: true')
|
||||
expect(group).toContain("path: '/admin/risk-control'")
|
||||
expect(group).toContain("path: '/admin/prompt-audit'")
|
||||
})
|
||||
|
||||
it('keeps Prompt Audit locale trees symmetric and all operational controls named', () => {
|
||||
expect(Object.keys(zh.admin.promptAudit)).toEqual(Object.keys(en.admin.promptAudit))
|
||||
expect(zh.nav.securityAudit).toBeTruthy()
|
||||
expect(en.nav.securityAudit).toBeTruthy()
|
||||
const endpoint = read('../components/EndpointPool.vue')
|
||||
const events = read('../components/EventWorkspace.vue')
|
||||
expect(endpoint).toContain('aria-label')
|
||||
expect(events).toContain('aria-label')
|
||||
expect(events).toContain('overflow-x-auto')
|
||||
expect(events).toContain('sm:grid-cols-2')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PromptAuditConfig } from '../types'
|
||||
import {
|
||||
buildUpdateRequest,
|
||||
configToDraft,
|
||||
draftFingerprint,
|
||||
emptyEventFilters,
|
||||
eventFilterPayload,
|
||||
hasExplicitDeleteRange,
|
||||
SCANNER_CATALOG,
|
||||
} from '../viewModel'
|
||||
|
||||
const config = (): PromptAuditConfig => ({
|
||||
enabled: true,
|
||||
blocking_enabled: false,
|
||||
store_pass_events: false,
|
||||
effective_mode: 'async_audit',
|
||||
strategy: 'priority',
|
||||
worker_count: 4,
|
||||
queue_capacity: 100,
|
||||
scanners: SCANNER_CATALOG.map((item) => item.id),
|
||||
all_groups: true,
|
||||
group_ids: [],
|
||||
endpoints: [{
|
||||
id: 'guard-1', name: 'Guard One', protocol: 'openai_compatible', base_url: 'http://127.0.0.1:8000',
|
||||
model: 'sileader/qwen3guard:0.6b', timeout_ms: 3000, input_limit: 4000, enabled: true,
|
||||
has_token: true, token_status: 'configured',
|
||||
}],
|
||||
config_version: 7,
|
||||
updated_at: '2026-07-16T00:00:00Z',
|
||||
updated_by: 1,
|
||||
change_summary: '{}',
|
||||
})
|
||||
|
||||
describe('Prompt Audit view model', () => {
|
||||
it('normalizes legacy null collections from the public config', () => {
|
||||
const legacy = { ...config(), group_ids: null, scanners: null, endpoints: null } as unknown as PromptAuditConfig
|
||||
expect(configToDraft(legacy)).toMatchObject({ group_ids: [], scanners: [], endpoints: [] })
|
||||
})
|
||||
|
||||
it('models all nine official input scanners', () => {
|
||||
expect(SCANNER_CATALOG).toHaveLength(9)
|
||||
expect(SCANNER_CATALOG.map((item) => item.id)).toContain('suicide_and_self_harm')
|
||||
})
|
||||
|
||||
it('keeps, replaces, or explicitly clears a saved token without copying plaintext from the server', () => {
|
||||
const draft = configToDraft(config())
|
||||
expect(draft.endpoints[0].token).toBe('')
|
||||
expect(buildUpdateRequest(draft).endpoints[0]).toMatchObject({ token: undefined, clear_token: false })
|
||||
|
||||
draft.endpoints[0].token = 'temporary-canary-token'
|
||||
expect(buildUpdateRequest(draft).endpoints[0]).toMatchObject({ token: 'temporary-canary-token', clear_token: false })
|
||||
|
||||
draft.endpoints[0].token = ''
|
||||
draft.endpoints[0].clear_token = true
|
||||
expect(buildUpdateRequest(draft).endpoints[0]).toMatchObject({ token: undefined, clear_token: true })
|
||||
})
|
||||
|
||||
it('tracks dirty state from the full normalized save payload', () => {
|
||||
const original = configToDraft(config())
|
||||
const changed = configToDraft(config())
|
||||
expect(draftFingerprint(changed)).toBe(draftFingerprint(original))
|
||||
changed.queue_capacity += 1
|
||||
expect(draftFingerprint(changed)).not.toBe(draftFingerprint(original))
|
||||
})
|
||||
|
||||
it('requires a valid explicit range and sends canonical ISO timestamps for filter deletion', () => {
|
||||
const filters = emptyEventFilters()
|
||||
expect(hasExplicitDeleteRange(filters)).toBe(false)
|
||||
filters.start_at = '2026-07-15T10:00'
|
||||
filters.end_at = '2026-07-16T10:00'
|
||||
filters.group_id = '9'
|
||||
expect(hasExplicitDeleteRange(filters)).toBe(true)
|
||||
expect(eventFilterPayload(filters)).toMatchObject({
|
||||
group_id: 9,
|
||||
start_at: new Date(filters.start_at).toISOString(),
|
||||
end_at: new Date(filters.end_at).toISOString(),
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { apiClient } from '@/api/client'
|
||||
import type {
|
||||
PromptAuditConfig,
|
||||
PromptAuditEvent,
|
||||
PromptAuditGroup,
|
||||
PromptAuditRuntime,
|
||||
PromptAuditUpdateRequest,
|
||||
PromptDeletePreview,
|
||||
PromptDeleteResult,
|
||||
PromptEventFilters,
|
||||
PromptEventPage,
|
||||
PromptProbeResult,
|
||||
PromptAuditEndpointDraft,
|
||||
} from './types'
|
||||
import { eventFilterPayload, eventQueryParams } from './viewModel'
|
||||
|
||||
const basePath = '/admin/prompt-audit'
|
||||
|
||||
export async function getConfig(): Promise<PromptAuditConfig> {
|
||||
const { data } = await apiClient.get<PromptAuditConfig>(`${basePath}/config`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateConfig(payload: PromptAuditUpdateRequest): Promise<PromptAuditConfig> {
|
||||
const { data } = await apiClient.put<PromptAuditConfig>(`${basePath}/config`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function probeEndpoint(endpoint: PromptAuditEndpointDraft): Promise<PromptProbeResult> {
|
||||
const { data } = await apiClient.post<PromptProbeResult>(`${basePath}/endpoints/probe`, {
|
||||
endpoint: {
|
||||
id: endpoint.id,
|
||||
name: endpoint.name,
|
||||
protocol: 'openai_compatible',
|
||||
base_url: endpoint.base_url,
|
||||
model: endpoint.model,
|
||||
token: endpoint.token || undefined,
|
||||
timeout_ms: endpoint.timeout_ms,
|
||||
input_limit: endpoint.input_limit,
|
||||
enabled: endpoint.enabled,
|
||||
},
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getRuntime(): Promise<PromptAuditRuntime> {
|
||||
const { data } = await apiClient.get<PromptAuditRuntime>(`${basePath}/runtime`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listEvents(
|
||||
filters: PromptEventFilters,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): Promise<PromptEventPage> {
|
||||
const { data } = await apiClient.get<PromptEventPage>(`${basePath}/events`, {
|
||||
params: { page, page_size: pageSize, ...eventQueryParams(filters) },
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getEvent(id: number): Promise<PromptAuditEvent> {
|
||||
const { data } = await apiClient.get<PromptAuditEvent>(`${basePath}/events/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteEvent(id: number): Promise<PromptDeleteResult> {
|
||||
const { data } = await apiClient.delete<PromptDeleteResult>(`${basePath}/events/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function batchDeleteEvents(ids: number[]): Promise<PromptDeleteResult> {
|
||||
const { data } = await apiClient.post<PromptDeleteResult>(`${basePath}/events/batch-delete`, { ids })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function previewDelete(filters: PromptEventFilters): Promise<PromptDeletePreview> {
|
||||
const { data } = await apiClient.post<PromptDeletePreview>(
|
||||
`${basePath}/events/delete-preview`,
|
||||
eventFilterPayload(filters),
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteEventsByFilter(
|
||||
filters: PromptEventFilters,
|
||||
preview: PromptDeletePreview,
|
||||
): Promise<PromptDeleteResult> {
|
||||
const { data } = await apiClient.post<PromptDeleteResult>(`${basePath}/events/delete-by-filter`, {
|
||||
filter: eventFilterPayload(filters),
|
||||
snapshot_max_id: preview.snapshot_max_id,
|
||||
filter_hash: preview.filter_hash,
|
||||
confirmation_token: preview.confirmation_token,
|
||||
confirm: true,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listGroups(): Promise<PromptAuditGroup[]> {
|
||||
const { data } = await apiClient.get<PromptAuditGroup[]>('/admin/groups/all', {
|
||||
params: { include_inactive: true },
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export const promptAuditAPI = {
|
||||
getConfig,
|
||||
updateConfig,
|
||||
probeEndpoint,
|
||||
getRuntime,
|
||||
listEvents,
|
||||
getEvent,
|
||||
deleteEvent,
|
||||
batchDeleteEvents,
|
||||
previewDelete,
|
||||
deleteEventsByFilter,
|
||||
listGroups,
|
||||
}
|
||||
|
||||
export default promptAuditAPI
|
||||
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<section aria-labelledby="prompt-pool-title" class="border-b border-gray-200 py-6 dark:border-dark-700/60">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 id="prompt-pool-title" class="text-base font-semibold text-gray-950 dark:text-white">{{ t('admin.promptAudit.pool.title') }}</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-dark-300">{{ t('admin.promptAudit.pool.description') }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary btn-sm" data-test="add-endpoint" @click="openCreate">
|
||||
{{ t('admin.promptAudit.pool.add') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="endpoints.length === 0" class="mt-5 rounded-xl border border-dashed border-gray-300 px-5 py-10 text-center text-sm text-gray-500 dark:border-dark-600 dark:bg-dark-900/20 dark:text-dark-300">
|
||||
{{ t('admin.promptAudit.pool.empty') }}
|
||||
</div>
|
||||
<div v-else class="mt-5 overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-dark-700/60 dark:bg-dark-900/20">
|
||||
<div class="hidden grid-cols-[minmax(260px,1.45fr)_minmax(210px,1fr)_minmax(190px,.8fr)_minmax(230px,1.15fr)_auto] gap-5 border-b border-l-[3px] border-b-gray-200 border-l-transparent bg-gray-50/80 px-5 py-2.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-gray-500 dark:border-b-dark-700/60 dark:bg-dark-900/70 dark:text-dark-400 xl:grid">
|
||||
<span>{{ t('admin.promptAudit.pool.node') }}</span>
|
||||
<span>{{ t('admin.promptAudit.pool.model') }}</span>
|
||||
<span>{{ t('admin.promptAudit.pool.limits') }}</span>
|
||||
<span>{{ t('admin.promptAudit.pool.credential') }}</span>
|
||||
<span class="text-right">{{ t('admin.promptAudit.common.actions') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-gray-100 dark:divide-dark-800">
|
||||
<article
|
||||
v-for="endpoint in endpoints"
|
||||
:key="endpoint.id"
|
||||
:data-test="`endpoint-${endpoint.id}`"
|
||||
class="group grid gap-4 border-l-[3px] border-l-transparent px-4 py-4 transition-[background-color,border-color] duration-200 hover:border-l-primary-500 hover:bg-gray-50/80 dark:hover:bg-dark-800/55 sm:px-5 xl:grid-cols-[minmax(260px,1.45fr)_minmax(210px,1fr)_minmax(190px,.8fr)_minmax(230px,1.15fr)_auto] xl:items-center xl:gap-5"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="endpoint.enabled"
|
||||
:aria-label="t('admin.promptAudit.pool.toggleNode', { name: endpoint.name })"
|
||||
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
|
||||
:class="endpoint.enabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'"
|
||||
@click="toggleEndpoint(endpoint.id)"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transition-transform duration-200 ease-in-out"
|
||||
:class="endpoint.enabled ? 'translate-x-5' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
<div class="min-w-0">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<p class="truncate font-semibold text-gray-950 dark:text-white">{{ endpoint.name }}</p>
|
||||
<span class="h-1.5 w-1.5 shrink-0 rounded-full" :class="endpoint.enabled ? 'bg-emerald-500' : 'bg-gray-300 dark:bg-dark-500'" aria-hidden="true" />
|
||||
</div>
|
||||
<p class="mt-0.5 truncate font-mono text-[11px] text-gray-500 dark:text-dark-400" :title="endpoint.base_url">{{ endpoint.base_url }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 xl:block">
|
||||
<p class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 xl:hidden">{{ t('admin.promptAudit.pool.model') }}</p>
|
||||
<p class="truncate text-sm font-medium text-gray-700 dark:text-dark-200" :title="endpoint.model">{{ endpoint.model }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 xl:hidden">{{ t('admin.promptAudit.pool.limits') }}</p>
|
||||
<div class="flex flex-wrap gap-1.5 text-xs text-gray-600 dark:text-dark-300">
|
||||
<span class="rounded-md bg-gray-100 px-2 py-1 tabular-nums dark:bg-dark-800">{{ endpoint.timeout_ms }} ms</span>
|
||||
<span class="rounded-md bg-gray-100 px-2 py-1 tabular-nums dark:bg-dark-800">{{ endpoint.input_limit }} chars</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<p class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 xl:hidden">{{ t('admin.promptAudit.pool.credential') }}</p>
|
||||
<div class="flex items-center gap-1.5 text-xs font-medium" :class="hasCredential(endpoint) ? 'text-emerald-700 dark:text-emerald-300' : 'text-gray-500 dark:text-dark-400'">
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="hasCredential(endpoint) ? 'bg-emerald-500' : 'bg-gray-300 dark:bg-dark-500'" aria-hidden="true" />
|
||||
{{ hasCredential(endpoint) ? t('admin.promptAudit.pool.configured') : t('admin.promptAudit.pool.missing') }}
|
||||
</div>
|
||||
<p v-if="probingIds.includes(endpoint.id)" class="mt-1.5 text-xs text-primary-600 dark:text-primary-300">
|
||||
{{ t('admin.promptAudit.pool.probeProgress') }}
|
||||
</p>
|
||||
<p v-if="probeResults[endpoint.id]" class="mt-1.5 line-clamp-2 text-xs leading-5" :class="probeResults[endpoint.id].ok ? 'text-emerald-600 dark:text-emerald-300' : 'text-red-600 dark:text-red-300'">
|
||||
{{ t('admin.promptAudit.pool.probeResult', { status: probeResults[endpoint.id].status, http: probeResults[endpoint.id].http_status || '—', latency: probeResults[endpoint.id].latency_ms }) }}
|
||||
· {{ probeResults[endpoint.id].message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-end gap-1 border-t border-gray-100 pt-3 dark:border-dark-800 xl:flex-nowrap xl:border-0 xl:pt-0">
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="probingIds.includes(endpoint.id)" @click="$emit('probe', endpoint)">
|
||||
{{ probingIds.includes(endpoint.id) ? t('admin.promptAudit.pool.probing') : t('admin.promptAudit.pool.probe') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="openEdit(endpoint)">{{ t('common.edit') }}</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-950/30" @click="removeEndpoint(endpoint)">{{ t('common.delete') }}</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseDialog :show="Boolean(editing)" :title="editingIndex < 0 ? t('admin.promptAudit.pool.add') : t('admin.promptAudit.pool.edit')" width="wide" @close="closeEditor">
|
||||
<form v-if="editing" class="grid gap-4 sm:grid-cols-2" @submit.prevent="saveEditor">
|
||||
<label class="space-y-1 text-sm text-gray-700 dark:text-dark-200">
|
||||
<span>{{ t('admin.promptAudit.pool.name') }}</span>
|
||||
<input v-model="editing.name" class="input w-full" required :aria-label="t('admin.promptAudit.pool.name')" />
|
||||
</label>
|
||||
<label class="space-y-1 text-sm text-gray-700 dark:text-dark-200">
|
||||
<span>{{ t('admin.promptAudit.pool.id') }}</span>
|
||||
<input v-model="editing.id" class="input w-full" required :disabled="editingIndex >= 0" :aria-label="t('admin.promptAudit.pool.id')" />
|
||||
</label>
|
||||
<label class="space-y-1 text-sm text-gray-700 dark:text-dark-200 sm:col-span-2">
|
||||
<span>{{ t('admin.promptAudit.pool.baseUrl') }}</span>
|
||||
<input v-model="editing.base_url" class="input w-full" required inputmode="url" :aria-label="t('admin.promptAudit.pool.baseUrl')" />
|
||||
</label>
|
||||
<label class="space-y-1 text-sm text-gray-700 dark:text-dark-200 sm:col-span-2">
|
||||
<span>{{ t('admin.promptAudit.pool.apiKey') }}</span>
|
||||
<input v-model="editing.token" class="input w-full" type="password" autocomplete="new-password" :placeholder="editing.has_token ? t('admin.promptAudit.pool.keepSecret') : ''" :aria-label="t('admin.promptAudit.pool.apiKey')" />
|
||||
<span class="block text-xs text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.pool.secretHint') }}</span>
|
||||
</label>
|
||||
<label v-if="editing.has_token" class="flex items-center gap-2 text-sm text-red-600 dark:text-red-300 sm:col-span-2">
|
||||
<input v-model="editing.clear_token" type="checkbox" :aria-label="t('admin.promptAudit.pool.clearSecret')" />
|
||||
{{ t('admin.promptAudit.pool.clearSecret') }}
|
||||
</label>
|
||||
<label class="space-y-1 text-sm text-gray-700 dark:text-dark-200 sm:col-span-2">
|
||||
<span>{{ t('admin.promptAudit.pool.model') }}</span>
|
||||
<input v-model="editing.model" class="input w-full" :aria-label="t('admin.promptAudit.pool.model')" />
|
||||
</label>
|
||||
<label class="space-y-1 text-sm text-gray-700 dark:text-dark-200">
|
||||
<span>{{ t('admin.promptAudit.pool.timeout') }}</span>
|
||||
<input v-model.number="editing.timeout_ms" class="input w-full" type="number" min="100" max="30000" required :aria-label="t('admin.promptAudit.pool.timeout')" />
|
||||
</label>
|
||||
<label class="space-y-1 text-sm text-gray-700 dark:text-dark-200">
|
||||
<span>{{ t('admin.promptAudit.pool.inputLimit') }}</span>
|
||||
<input v-model.number="editing.input_limit" class="input w-full" type="number" min="128" max="100000" required :aria-label="t('admin.promptAudit.pool.inputLimit')" />
|
||||
</label>
|
||||
</form>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" class="btn btn-secondary" @click="closeEditor">{{ t('common.cancel') }}</button>
|
||||
<button type="button" class="btn btn-primary" data-test="save-endpoint" @click="saveEditor">{{ t('common.save') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import type { PromptAuditEndpointDraft, PromptProbeResult } from '../types'
|
||||
import { cloneData, createDefaultEndpoint } from '../viewModel'
|
||||
|
||||
const props = defineProps<{
|
||||
endpoints: PromptAuditEndpointDraft[]
|
||||
probeResults: Record<string, PromptProbeResult>
|
||||
probingIds: string[]
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:endpoints', value: PromptAuditEndpointDraft[]): void
|
||||
(event: 'probe', endpoint: PromptAuditEndpointDraft): void
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
const editing = ref<PromptAuditEndpointDraft | null>(null)
|
||||
const editingIndex = ref(-1)
|
||||
|
||||
function openCreate() {
|
||||
editingIndex.value = -1
|
||||
editing.value = createDefaultEndpoint(props.endpoints.length + 1)
|
||||
}
|
||||
function openEdit(endpoint: PromptAuditEndpointDraft) {
|
||||
editingIndex.value = props.endpoints.findIndex((item) => item.id === endpoint.id)
|
||||
editing.value = cloneData(endpoint)
|
||||
}
|
||||
function closeEditor() {
|
||||
editing.value = null
|
||||
editingIndex.value = -1
|
||||
}
|
||||
function saveEditor() {
|
||||
if (!editing.value?.id.trim() || !editing.value.name.trim() || !editing.value.base_url.trim()) return
|
||||
const next = props.endpoints.map((item) => cloneData(item))
|
||||
const value = cloneData(editing.value)
|
||||
if (value.token.trim()) value.clear_token = false
|
||||
if (editingIndex.value < 0) next.push(value)
|
||||
else next.splice(editingIndex.value, 1, value)
|
||||
emit('update:endpoints', next)
|
||||
closeEditor()
|
||||
}
|
||||
function toggleEndpoint(id: string) {
|
||||
emit('update:endpoints', props.endpoints.map((item) => item.id === id ? { ...item, enabled: !item.enabled } : cloneData(item)))
|
||||
}
|
||||
function removeEndpoint(endpoint: PromptAuditEndpointDraft) {
|
||||
if (!window.confirm(t('admin.promptAudit.pool.deleteConfirm', { name: endpoint.name }))) return
|
||||
emit('update:endpoints', props.endpoints.filter((item) => item.id !== endpoint.id).map((item) => cloneData(item)))
|
||||
}
|
||||
function hasCredential(endpoint: PromptAuditEndpointDraft): boolean {
|
||||
return Boolean(endpoint.token.trim() || (endpoint.has_token && !endpoint.clear_token))
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,156 @@
|
||||
<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="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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
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 }>()
|
||||
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>
|
||||
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<section aria-labelledby="prompt-events-title" class="py-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 id="prompt-events-title" class="text-base font-semibold text-gray-950 dark:text-white">{{ t('admin.promptAudit.events.title') }}</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-dark-300">{{ t('admin.promptAudit.events.description') }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<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" data-test="filter-delete" @click="$emit('preview-delete')">
|
||||
{{ t('admin.promptAudit.events.deleteByFilter') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5" @submit.prevent="applyFilters">
|
||||
<label class="text-xs text-gray-600 dark:text-dark-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">{{ 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">{{ 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="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>
|
||||
<input v-model="localFilters.start_at" type="datetime-local" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.startAt')" @change="filtersChanged" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-dark-200">
|
||||
<span>{{ t('admin.promptAudit.events.endAt') }}</span>
|
||||
<input v-model="localFilters.end_at" type="datetime-local" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.endAt')" @change="filtersChanged" />
|
||||
</label>
|
||||
<div class="flex items-end gap-2 sm:col-span-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm">{{ t('common.search') }}</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="resetFilters">{{ t('common.reset') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
<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">
|
||||
<thead class="bg-gray-50 text-xs uppercase tracking-wide text-gray-500 dark:bg-dark-900/70 dark:text-dark-400">
|
||||
<tr>
|
||||
<th class="w-10 px-3 py-3"><input type="checkbox" :checked="allSelected" :aria-label="t('admin.promptAudit.events.selectAll')" @change="toggleAll" /></th>
|
||||
<th class="px-3 py-3 font-medium">{{ t('admin.promptAudit.events.time') }}</th>
|
||||
<th class="px-3 py-3 font-medium">{{ t('admin.promptAudit.events.identity') }}</th>
|
||||
<th class="px-3 py-3 font-medium">{{ t('admin.promptAudit.events.group') }}</th>
|
||||
<th class="px-3 py-3 font-medium">{{ t('admin.promptAudit.events.route') }}</th>
|
||||
<th class="px-3 py-3 font-medium">{{ t('admin.promptAudit.events.result') }}</th>
|
||||
<th class="px-3 py-3 font-medium">{{ t('admin.promptAudit.events.preview') }}</th>
|
||||
<th class="px-3 py-3 text-right font-medium">{{ t('admin.promptAudit.common.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 bg-white dark:divide-dark-700 dark:bg-transparent">
|
||||
<tr v-if="loading"><td colspan="8" class="px-4 py-12 text-center text-gray-500" aria-busy="true">{{ t('common.loading') }}</td></tr>
|
||||
<tr v-else-if="events.length === 0"><td colspan="8" class="px-4 py-12 text-center text-gray-500">{{ t('admin.promptAudit.events.empty') }}</td></tr>
|
||||
<tr v-for="event in events" v-else :key="event.id" :data-test="`event-${event.id}`" class="align-top hover:bg-gray-50/70 dark:hover:bg-dark-800/70">
|
||||
<td class="px-3 py-3"><input type="checkbox" :checked="selectedIds.includes(event.id)" :aria-label="t('admin.promptAudit.events.selectEvent', { id: event.id })" @change="toggleOne(event.id)" /></td>
|
||||
<td class="whitespace-nowrap px-3 py-3 text-xs text-gray-600 dark:text-dark-300">{{ formatDate(event.created_at) }}</td>
|
||||
<td class="px-3 py-3">
|
||||
<CopyLine :label="t('admin.promptAudit.events.user')" :value="event.snapshot.username" />
|
||||
<CopyLine :label="t('admin.promptAudit.events.email')" :value="event.snapshot.user_email" />
|
||||
<CopyLine :label="t('admin.promptAudit.events.apiKey')" :value="event.snapshot.api_key_name" />
|
||||
</td>
|
||||
<td class="px-3 py-3 text-gray-700 dark:text-dark-200">{{ event.snapshot.group_name || '—' }}</td>
|
||||
<td class="px-3 py-3">
|
||||
<p class="font-medium text-gray-900 dark:text-white">{{ event.snapshot.endpoint }}</p>
|
||||
<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)">{{ 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">
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="$emit('view', event.id)">{{ t('common.view') }}</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm text-red-600" @click="$emit('delete', event.id)">{{ t('common.delete') }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<Pagination :total="total" :page="page" :page-size="pageSize" @update:page="$emit('page', $event)" @update:page-size="$emit('page-size', $event)" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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, SCANNER_CATALOG } from '../viewModel'
|
||||
|
||||
const props = defineProps<{
|
||||
events: PromptAuditEvent[]; total: number; page: number; pageSize: number
|
||||
filters: PromptEventFilters; selectedIds: number[]; loading: boolean; error: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(event: 'filters-change', value: PromptEventFilters): void
|
||||
(event: 'search', value: PromptEventFilters): void
|
||||
(event: 'selection', value: number[]): void
|
||||
(event: 'page', value: number): void
|
||||
(event: 'page-size', value: number): void
|
||||
(event: 'view', id: number): void
|
||||
(event: 'delete', id: number): void
|
||||
(event: 'batch-delete'): void
|
||||
(event: 'preview-delete'): void
|
||||
}>()
|
||||
const { t, locale } = useI18n()
|
||||
const localFilters = reactive<PromptEventFilters>(cloneData(props.filters))
|
||||
watch(() => props.filters, (value) => Object.assign(localFilters, cloneData(value)), { deep: true })
|
||||
const allSelected = computed(() => props.events.length > 0 && props.events.every((event) => props.selectedIds.includes(event.id)))
|
||||
|
||||
const FilterInput = defineComponent({
|
||||
props: { modelValue: { type: String, required: true }, label: { type: String, required: true }, type: { type: String, default: 'text' } },
|
||||
emits: ['update:modelValue', 'change'],
|
||||
setup(componentProps, { emit: componentEmit }) {
|
||||
return () => h('label', { class: 'text-xs text-gray-600 dark:text-dark-200' }, [
|
||||
h('span', componentProps.label),
|
||||
h('input', {
|
||||
value: componentProps.modelValue, type: componentProps.type, class: 'input mt-1 w-full', 'aria-label': componentProps.label,
|
||||
onInput: (event: Event) => componentEmit('update:modelValue', (event.target as HTMLInputElement).value),
|
||||
onChange: () => componentEmit('change'),
|
||||
}),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const CopyLine = defineComponent({
|
||||
props: { label: { type: String, required: true }, value: { type: String, default: '' } },
|
||||
setup(componentProps) {
|
||||
return () => h('div', { class: 'flex max-w-56 items-center gap-1 text-xs' }, [
|
||||
h('span', { class: 'w-16 flex-none text-gray-500 dark:text-dark-400' }, componentProps.label),
|
||||
h('span', { class: 'min-w-0 flex-1 truncate text-gray-800 dark:text-dark-100' }, componentProps.value || '—'),
|
||||
componentProps.value ? h('button', {
|
||||
type: 'button', class: 'text-primary-600 hover:underline', 'aria-label': `${t('common.copy')} ${componentProps.label}`,
|
||||
onClick: () => navigator.clipboard?.writeText(componentProps.value),
|
||||
}, t('common.copy')) : null,
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
function filtersChanged() {
|
||||
emit('filters-change', cloneData(localFilters))
|
||||
}
|
||||
function applyFilters() {
|
||||
const value = cloneData(localFilters)
|
||||
emit('filters-change', value)
|
||||
emit('search', value)
|
||||
}
|
||||
function resetFilters() {
|
||||
Object.assign(localFilters, emptyEventFilters())
|
||||
applyFilters()
|
||||
}
|
||||
function toggleOne(id: number) {
|
||||
const selected = new Set(props.selectedIds)
|
||||
if (selected.has(id)) selected.delete(id)
|
||||
else selected.add(id)
|
||||
emit('selection', [...selected])
|
||||
}
|
||||
function toggleAll() {
|
||||
emit('selection', allSelected.value ? [] : props.events.map((event) => event.id))
|
||||
}
|
||||
function formatDate(value: string): string {
|
||||
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'short', timeStyle: 'medium' }).format(new Date(value))
|
||||
}
|
||||
function decisionClass(decision: string): string {
|
||||
if (decision === 'critical') return 'bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300'
|
||||
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,194 @@
|
||||
<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">
|
||||
<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="confirmDisabled"
|
||||
:title="confirmDisabledReason ? t(confirmDisabledReason) : undefined"
|
||||
data-test="confirm-filter-delete"
|
||||
@click="requestConfirm"
|
||||
>
|
||||
{{ 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', value: PromptEventFilters): 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))
|
||||
|
||||
// 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')
|
||||
}
|
||||
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))
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<section aria-labelledby="prompt-policy-title" class="py-6">
|
||||
<div>
|
||||
<h2 id="prompt-policy-title" class="text-base font-semibold text-gray-950 dark:text-white">{{ t('admin.promptAudit.policy.title') }}</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-dark-300">{{ t('admin.promptAudit.policy.description') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(260px,0.45fr)]">
|
||||
<div class="rounded-xl border border-gray-200 p-4 dark:border-dark-700/60 dark:bg-dark-900/20 sm:p-5">
|
||||
<fieldset>
|
||||
<legend class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.policy.scope') }}</legend>
|
||||
<div class="mt-3 flex flex-wrap gap-5 text-sm text-gray-700 dark:text-dark-200">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="radio" name="prompt-audit-scope" :checked="draft.all_groups" @change="patch({ all_groups: true, group_ids: [] })" />
|
||||
{{ t('admin.promptAudit.policy.allGroups') }}
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="radio" name="prompt-audit-scope" :checked="!draft.all_groups" @change="patch({ all_groups: false })" />
|
||||
{{ t('admin.promptAudit.policy.selectedGroups') }}
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div v-if="!draft.all_groups" class="mt-4">
|
||||
<label class="block text-sm text-gray-700 dark:text-dark-200">
|
||||
<span>{{ t('admin.promptAudit.policy.searchGroups') }}</span>
|
||||
<input v-model="groupSearch" type="search" class="input mt-1.5 w-full" :aria-label="t('admin.promptAudit.policy.searchGroups')" />
|
||||
</label>
|
||||
<div class="mt-3 max-h-52 overflow-y-auto rounded-lg border border-gray-200 p-2 dark:border-dark-700">
|
||||
<label v-for="group in filteredGroups" :key="group.id" class="flex cursor-pointer items-center justify-between gap-3 rounded-md px-2 py-2 text-sm hover:bg-gray-50 dark:hover:bg-dark-800">
|
||||
<span class="flex items-center gap-2 text-gray-800 dark:text-dark-100">
|
||||
<input type="checkbox" :checked="draft.group_ids.includes(group.id)" @change="toggleGroup(group.id)" />
|
||||
{{ group.name }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 dark:text-dark-400">{{ group.platform }} · {{ group.status }}</span>
|
||||
</label>
|
||||
<p v-if="filteredGroups.length === 0" class="px-2 py-4 text-center text-sm text-gray-500">{{ t('admin.promptAudit.policy.noGroups') }}</p>
|
||||
</div>
|
||||
<div v-if="missingGroupIds.length" class="mt-3 rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
{{ t('admin.promptAudit.policy.missingGroups') }}: {{ missingGroupIds.join(', ') }}
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.policy.selectedCount', { count: draft.group_ids.length }) }}</p>
|
||||
</div>
|
||||
|
||||
<fieldset class="mt-5 border-t border-gray-100 pt-5 dark:border-dark-800">
|
||||
<legend class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.policy.scanners') }}</legend>
|
||||
<div class="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
<label v-for="scanner in SCANNER_CATALOG" :key="scanner.id" class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-700 hover:bg-gray-50 dark:text-dark-200 dark:hover:bg-dark-800">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 rounded-xl border border-gray-200 p-4 dark:border-dark-700/60 dark:bg-dark-900/20 sm:p-5">
|
||||
<label class="block text-sm text-gray-700 dark:text-dark-200">
|
||||
<span>{{ t('admin.promptAudit.policy.workerCount') }}</span>
|
||||
<input :value="draft.worker_count" type="number" min="1" max="32" class="input mt-1.5 w-full" :aria-label="t('admin.promptAudit.policy.workerCount')" @input="patch({ worker_count: Number(($event.target as HTMLInputElement).value) })" />
|
||||
</label>
|
||||
<label class="block text-sm text-gray-700 dark:text-dark-200">
|
||||
<span>{{ t('admin.promptAudit.policy.queueCapacity') }}</span>
|
||||
<input :value="draft.queue_capacity" type="number" min="1" max="100000" class="input mt-1.5 w-full" :aria-label="t('admin.promptAudit.policy.queueCapacity')" @input="patch({ queue_capacity: Number(($event.target as HTMLInputElement).value) })" />
|
||||
</label>
|
||||
<div class="rounded-lg bg-gray-50 px-4 py-3 text-sm text-gray-600 dark:bg-dark-900/50 dark:text-dark-300">
|
||||
<p class="font-medium text-gray-800 dark:text-dark-100">{{ t('admin.promptAudit.policy.strategy') }}</p>
|
||||
<p class="mt-1">priority · {{ t('admin.promptAudit.policy.strategyHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { PromptAuditDraft, PromptAuditGroup } from '../types'
|
||||
import { cloneData, SCANNER_CATALOG } from '../viewModel'
|
||||
|
||||
const props = defineProps<{ draft: PromptAuditDraft; groups: PromptAuditGroup[] }>()
|
||||
const emit = defineEmits<{ (event: 'update:draft', value: PromptAuditDraft): void }>()
|
||||
const { t } = useI18n()
|
||||
const groupSearch = ref('')
|
||||
|
||||
const filteredGroups = computed(() => {
|
||||
const query = groupSearch.value.trim().toLowerCase()
|
||||
if (!query) return props.groups
|
||||
return props.groups.filter((group) => `${group.name} ${group.id} ${group.platform}`.toLowerCase().includes(query))
|
||||
})
|
||||
const knownGroupIds = computed(() => new Set(props.groups.map((group) => group.id)))
|
||||
const missingGroupIds = computed(() => props.draft.group_ids.filter((id) => !knownGroupIds.value.has(id)))
|
||||
|
||||
function patch(value: Partial<PromptAuditDraft>) {
|
||||
emit('update:draft', { ...cloneData(props.draft), ...value })
|
||||
}
|
||||
function toggleGroup(id: number) {
|
||||
const selected = new Set(props.draft.group_ids)
|
||||
if (selected.has(id)) selected.delete(id)
|
||||
else selected.add(id)
|
||||
patch({ group_ids: [...selected].sort((a, b) => a - b) })
|
||||
}
|
||||
function toggleScanner(id: string) {
|
||||
const selected = new Set(props.draft.scanners)
|
||||
if (selected.has(id)) selected.delete(id)
|
||||
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>
|
||||
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<section aria-labelledby="prompt-runtime-title" class="border-b border-gray-200 py-6 dark:border-dark-700/60">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 id="prompt-runtime-title" class="text-base font-semibold text-gray-950 dark:text-white">
|
||||
{{ t('admin.promptAudit.runtime.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-dark-300">
|
||||
{{ t('admin.promptAudit.runtime.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="loading" @click="$emit('refresh')">
|
||||
{{ t('admin.promptAudit.actions.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" role="alert" class="mt-5 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-300">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div v-else-if="loading && !runtime" class="mt-5 grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6" aria-busy="true">
|
||||
<div v-for="index in 6" :key="index" class="h-16 animate-pulse rounded-xl bg-gray-100 dark:bg-dark-800" />
|
||||
</div>
|
||||
<template v-else-if="runtime">
|
||||
<dl class="mt-5 grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||
<div v-for="item in statusItems" :key="item.label" class="rounded-xl border border-gray-100 bg-gray-50/80 px-3 py-3 dark:border-dark-700/60 dark:bg-dark-900/40">
|
||||
<dt class="text-xs text-gray-500 dark:text-dark-400">{{ item.label }}</dt>
|
||||
<dd class="mt-1.5 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<span v-if="item.dot" class="h-2 w-2 shrink-0 rounded-full" :class="item.dot" />
|
||||
<span class="min-w-0 truncate">{{ item.value }}</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="mt-4 grid gap-3 lg:grid-cols-[minmax(0,1.4fr)_minmax(220px,0.6fr)]">
|
||||
<div class="rounded-xl border border-gray-100 px-4 py-3 dark:border-dark-700/60 dark:bg-dark-900/20">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.runtime.guardMetrics') }}</h3>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<div v-for="metric in guardMetricItems" :key="metric.label" class="rounded-lg bg-gray-50 px-2.5 py-2 dark:bg-dark-900/60">
|
||||
<p class="text-[11px] text-gray-500 dark:text-dark-400">{{ metric.label }}</p>
|
||||
<p class="mt-0.5 text-sm font-semibold tabular-nums text-gray-900 dark:text-white">{{ metric.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-xs leading-5 text-gray-500 dark:text-dark-400">
|
||||
{{ t('admin.promptAudit.runtime.queueBreakdown', {
|
||||
queued: runtime.queue.queued,
|
||||
processing: runtime.queue.processing,
|
||||
retry: runtime.queue.retry,
|
||||
done: runtime.queue.done,
|
||||
failed: runtime.queue.failed,
|
||||
}) }}
|
||||
<span class="mx-1.5 text-gray-300 dark:text-dark-600">·</span>
|
||||
{{ t('admin.promptAudit.runtime.deliveryTotals', { enqueued: runtime.enqueued_total, dropped: runtime.dropped_total, processed: runtime.processed_total, failed: runtime.failed_total }) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-gray-100 px-4 py-3 dark:border-dark-700/60 dark:bg-dark-900/20">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.runtime.latest') }}</h3>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-dark-300">
|
||||
{{ runtime.last_processed_at ? formatDate(runtime.last_processed_at) : t('admin.promptAudit.common.never') }}
|
||||
</p>
|
||||
<p v-if="runtime.last_error_code" class="mt-1 break-words text-sm text-red-600 dark:text-red-300">
|
||||
{{ runtime.last_error_code }}<span v-if="runtime.last_error_message"> · {{ runtime.last_error_message }}</span>
|
||||
</p>
|
||||
<div v-if="Object.keys(runtime.endpoints).length" class="mt-3 flex flex-wrap gap-2">
|
||||
<span v-for="(probe, id) in runtime.endpoints" :key="id" class="rounded-md px-2 py-1 text-xs" :class="probe.ok ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300' : 'bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-300'">
|
||||
{{ id }} · {{ probe.status }} · {{ probe.latency_ms }} ms
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { PromptAuditRuntime } from '../types'
|
||||
|
||||
const props = defineProps<{ runtime: PromptAuditRuntime | null; loading: boolean; error: string }>()
|
||||
defineEmits<{ (event: 'refresh'): void }>()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const statusItems = computed(() => {
|
||||
const runtime = props.runtime
|
||||
if (!runtime) return []
|
||||
return [
|
||||
{ label: t('admin.promptAudit.runtime.process'), value: t(`admin.promptAudit.status.${runtime.process_status}`), dot: statusDot(runtime.process_status) },
|
||||
{ label: t('admin.promptAudit.runtime.mode'), value: t(`admin.promptAudit.mode.${runtime.effective_mode}`) },
|
||||
{ label: t('admin.promptAudit.runtime.version'), value: `${runtime.active_config_version} / ${runtime.expected_config_version}` },
|
||||
{ label: t('admin.promptAudit.runtime.workers'), value: `${runtime.worker_active} / ${runtime.worker_total}` },
|
||||
{ label: t('admin.promptAudit.runtime.queue'), value: `${runtime.queue.active} / ${runtime.queue_capacity}` },
|
||||
{ label: t('admin.promptAudit.runtime.dependencies'), value: `DB ${runtime.database_status} · Redis ${runtime.redis_status}` },
|
||||
]
|
||||
})
|
||||
|
||||
const guardMetricItems = computed(() => {
|
||||
const metrics = props.runtime?.guard_metrics
|
||||
if (!metrics) return []
|
||||
return [
|
||||
{ label: t('admin.promptAudit.metrics.total'), value: metrics.total },
|
||||
{ label: t('admin.promptAudit.metrics.allowed'), value: metrics.allowed },
|
||||
{ label: t('admin.promptAudit.metrics.flagged'), value: metrics.flagged },
|
||||
{ label: t('admin.promptAudit.metrics.blocked'), value: metrics.blocked },
|
||||
{ label: t('admin.promptAudit.metrics.unavailable'), value: metrics.unavailable },
|
||||
{ label: t('admin.promptAudit.metrics.timeouts'), value: metrics.timeouts },
|
||||
{ label: t('admin.promptAudit.metrics.failovers'), value: metrics.failovers },
|
||||
{ label: 'P95', value: metrics.latency_p95_ms != null ? `${metrics.latency_p95_ms} ms` : '—' },
|
||||
]
|
||||
})
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium', timeStyle: 'medium' }).format(new Date(value))
|
||||
}
|
||||
|
||||
function statusDot(status: string): string {
|
||||
if (status === 'running') return 'bg-emerald-500'
|
||||
if (status === 'disabled') return 'bg-gray-400'
|
||||
if (status === 'degraded') return 'bg-amber-500'
|
||||
return 'bg-red-500'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,244 @@
|
||||
export type PromptAuditMode = 'off' | 'async_audit' | 'blocking'
|
||||
export type PromptDecision = 'pass' | 'flag' | 'critical'
|
||||
export type PromptRiskLevel = 'low' | 'medium' | 'high' | 'critical'
|
||||
|
||||
export interface PromptAuditEndpoint {
|
||||
id: string
|
||||
name: string
|
||||
protocol: 'openai_compatible'
|
||||
base_url: string
|
||||
model: string
|
||||
timeout_ms: number
|
||||
input_limit: number
|
||||
enabled: boolean
|
||||
has_token: boolean
|
||||
token_status: 'configured' | 'missing' | string
|
||||
}
|
||||
|
||||
export interface PromptAuditEndpointDraft extends PromptAuditEndpoint {
|
||||
token: string
|
||||
clear_token: boolean
|
||||
}
|
||||
|
||||
export interface PromptAuditConfig {
|
||||
enabled: boolean
|
||||
blocking_enabled: boolean
|
||||
store_pass_events: boolean
|
||||
effective_mode: PromptAuditMode
|
||||
strategy: 'priority'
|
||||
worker_count: number
|
||||
queue_capacity: number
|
||||
scanners: string[]
|
||||
all_groups: boolean
|
||||
group_ids: number[]
|
||||
endpoints: PromptAuditEndpoint[]
|
||||
config_version: number
|
||||
updated_at: string
|
||||
updated_by: number
|
||||
change_summary: string
|
||||
}
|
||||
|
||||
export interface PromptAuditDraft extends Omit<PromptAuditConfig, 'endpoints'> {
|
||||
endpoints: PromptAuditEndpointDraft[]
|
||||
}
|
||||
|
||||
export interface PromptAuditUpdateRequest {
|
||||
expected_config_version: number
|
||||
enabled: boolean
|
||||
blocking_enabled: boolean
|
||||
store_pass_events: boolean
|
||||
strategy: 'priority'
|
||||
worker_count: number
|
||||
queue_capacity: number
|
||||
scanners: string[]
|
||||
all_groups: boolean
|
||||
group_ids: number[]
|
||||
endpoints: Array<{
|
||||
id: string
|
||||
name: string
|
||||
protocol: 'openai_compatible'
|
||||
base_url: string
|
||||
model: string
|
||||
token?: string
|
||||
clear_token: boolean
|
||||
timeout_ms: number
|
||||
input_limit: number
|
||||
enabled: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
export interface PromptProbeResult {
|
||||
ok: boolean
|
||||
status: string
|
||||
error_code?: string
|
||||
message: string
|
||||
latency_ms: number
|
||||
http_status: number
|
||||
retryable: boolean
|
||||
checked_at: string
|
||||
token_applied: boolean
|
||||
}
|
||||
|
||||
export interface PromptQueueStats {
|
||||
staging: number
|
||||
queued: number
|
||||
processing: number
|
||||
retry: number
|
||||
done: number
|
||||
failed: number
|
||||
active: number
|
||||
}
|
||||
|
||||
export interface PromptGuardMetrics {
|
||||
total: number
|
||||
allowed: number
|
||||
flagged: number
|
||||
blocked: number
|
||||
unavailable: number
|
||||
invalid: number
|
||||
timeouts: number
|
||||
failovers: number
|
||||
bulkhead_full: number
|
||||
record_failed: number
|
||||
latency_avg_ms?: number
|
||||
latency_p50_ms?: number
|
||||
latency_p95_ms?: number
|
||||
latency_p99_ms?: number
|
||||
latency_max_ms?: number
|
||||
}
|
||||
|
||||
export interface PromptAuditRuntime {
|
||||
process_status: 'disabled' | 'running' | 'degraded' | 'error' | string
|
||||
effective_mode: PromptAuditMode
|
||||
expected_config_version: number
|
||||
active_config_version: number
|
||||
config_loaded_at?: string
|
||||
config_load_error?: string
|
||||
worker_total: number
|
||||
worker_active: number
|
||||
worker_heartbeat_at?: string
|
||||
queue_capacity: number
|
||||
queue: PromptQueueStats
|
||||
processed_total: number
|
||||
failed_total: number
|
||||
enqueued_total: number
|
||||
dropped_total: number
|
||||
last_processed_at?: string
|
||||
last_error_code?: string
|
||||
last_error_message?: string
|
||||
database_status: string
|
||||
redis_status: string
|
||||
endpoints: Record<string, PromptProbeResult>
|
||||
guard_metrics: PromptGuardMetrics
|
||||
}
|
||||
|
||||
export interface PromptSnapshot {
|
||||
request_id: string
|
||||
user_id: number
|
||||
username: string
|
||||
user_email: string
|
||||
api_key_id: number
|
||||
api_key_name: string
|
||||
group_id?: number
|
||||
group_name: string
|
||||
provider: string
|
||||
endpoint: string
|
||||
protocol: string
|
||||
model: string
|
||||
prompt_hash: string
|
||||
redacted_preview: string
|
||||
full_prompt: string
|
||||
prompt_length: number
|
||||
message_count: number
|
||||
stage: string
|
||||
}
|
||||
|
||||
export interface PromptIssueSummary {
|
||||
category: string
|
||||
scanner_id: string
|
||||
title: string
|
||||
description: string
|
||||
severity: string
|
||||
severity_label: string
|
||||
action: string
|
||||
action_label: string
|
||||
code: string
|
||||
score: number
|
||||
evidence: string
|
||||
evidence_hash: string
|
||||
start_rune?: number
|
||||
end_rune?: number
|
||||
}
|
||||
|
||||
export interface PromptAuditEvent {
|
||||
id: number
|
||||
job_id: number
|
||||
snapshot: PromptSnapshot
|
||||
decision: PromptDecision
|
||||
risk_level: PromptRiskLevel
|
||||
action: 'Allow' | 'Warn' | 'Block' | string
|
||||
categories: string[]
|
||||
matched_scanners: string[]
|
||||
scanner_scores: Record<string, number>
|
||||
scanner_evidence: Record<string, string>
|
||||
scanner_backend: string
|
||||
scanner_version: string
|
||||
guard_endpoint_id: string
|
||||
policy_id: string
|
||||
policy_version: number
|
||||
config_version: number
|
||||
chunk_total: number
|
||||
latency_ms: number
|
||||
issue_summaries: PromptIssueSummary[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface PromptEventFilters {
|
||||
decision: string
|
||||
risk_level: string
|
||||
endpoint: string
|
||||
group_id: string
|
||||
user_id: string
|
||||
api_key_id: string
|
||||
request_id: string
|
||||
prompt_hash: string
|
||||
keyword: string
|
||||
start_at: string
|
||||
end_at: string
|
||||
}
|
||||
|
||||
export interface PromptEventPage {
|
||||
items: PromptAuditEvent[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface PromptDeleteResult {
|
||||
deleted_events: number
|
||||
deleted_jobs: number
|
||||
}
|
||||
|
||||
export interface PromptDeletePreview {
|
||||
matched_count: number
|
||||
filter_summary: Record<string, unknown>
|
||||
snapshot_max_id: number
|
||||
filter_hash: string
|
||||
confirmation_token: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export interface PromptAuditGroup {
|
||||
id: number
|
||||
name: string
|
||||
status: 'active' | 'inactive'
|
||||
platform: string
|
||||
}
|
||||
|
||||
export interface PromptLoadErrors {
|
||||
config: string
|
||||
runtime: string
|
||||
groups: string
|
||||
events: string
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import type {
|
||||
PromptAuditConfig,
|
||||
PromptAuditDraft,
|
||||
PromptAuditEndpointDraft,
|
||||
PromptAuditUpdateRequest,
|
||||
PromptEventFilters,
|
||||
} from './types'
|
||||
|
||||
export const DEFAULT_GUARD_MODEL = 'sileader/qwen3guard:0.6b'
|
||||
|
||||
export const SCANNER_CATALOG = [
|
||||
{ id: 'violent', label: 'Violent' },
|
||||
{ id: 'non_violent_illegal_acts', label: 'Non-violent Illegal Acts' },
|
||||
{ id: 'sexual_content_or_sexual_acts', label: 'Sexual Content or Sexual Acts' },
|
||||
{ id: 'pii', label: 'PII' },
|
||||
{ id: 'suicide_and_self_harm', label: 'Suicide & Self-Harm' },
|
||||
{ id: 'unethical_acts', label: 'Unethical Acts' },
|
||||
{ id: 'politically_sensitive_topics', label: 'Politically Sensitive Topics' },
|
||||
{ id: 'copyright_violation', label: 'Copyright Violation' },
|
||||
{ id: 'jailbreak', label: 'Jailbreak' },
|
||||
] as const
|
||||
|
||||
// Vue props/refs are proxies and cannot be passed to structuredClone in every
|
||||
// browser. Prompt Audit state is JSON-only, so this produces a detached draft
|
||||
// without retaining reactive proxies or browser storage references.
|
||||
export function cloneData<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T
|
||||
}
|
||||
|
||||
export function configToDraft(config: PromptAuditConfig): PromptAuditDraft {
|
||||
return {
|
||||
...cloneData(config),
|
||||
group_ids: [...(config.group_ids ?? [])],
|
||||
scanners: [...(config.scanners ?? [])],
|
||||
endpoints: (config.endpoints ?? []).map((endpoint) => ({
|
||||
...endpoint,
|
||||
token: '',
|
||||
clear_token: false,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function createDefaultEndpoint(index = 1): PromptAuditEndpointDraft {
|
||||
return {
|
||||
id: `guard-${Date.now()}-${index}`,
|
||||
name: `Guard ${index}`,
|
||||
protocol: 'openai_compatible',
|
||||
base_url: 'http://127.0.0.1:8000',
|
||||
model: DEFAULT_GUARD_MODEL,
|
||||
timeout_ms: 3000,
|
||||
input_limit: 4000,
|
||||
enabled: true,
|
||||
has_token: false,
|
||||
token_status: 'missing',
|
||||
token: '',
|
||||
clear_token: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildUpdateRequest(draft: PromptAuditDraft): PromptAuditUpdateRequest {
|
||||
return {
|
||||
expected_config_version: draft.config_version,
|
||||
enabled: draft.enabled,
|
||||
blocking_enabled: draft.enabled && draft.blocking_enabled,
|
||||
store_pass_events: draft.store_pass_events,
|
||||
strategy: 'priority',
|
||||
worker_count: Number(draft.worker_count),
|
||||
queue_capacity: Number(draft.queue_capacity),
|
||||
scanners: [...draft.scanners],
|
||||
all_groups: draft.all_groups,
|
||||
group_ids: draft.all_groups ? [] : [...draft.group_ids].sort((a, b) => a - b),
|
||||
endpoints: draft.endpoints.map((endpoint) => ({
|
||||
id: endpoint.id.trim(),
|
||||
name: endpoint.name.trim(),
|
||||
protocol: 'openai_compatible',
|
||||
base_url: endpoint.base_url.trim(),
|
||||
model: endpoint.model.trim() || DEFAULT_GUARD_MODEL,
|
||||
token: endpoint.token.trim() || undefined,
|
||||
clear_token: endpoint.clear_token,
|
||||
timeout_ms: Number(endpoint.timeout_ms),
|
||||
input_limit: Number(endpoint.input_limit),
|
||||
enabled: endpoint.enabled,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function draftFingerprint(draft: PromptAuditDraft | null): string {
|
||||
if (!draft) return ''
|
||||
return JSON.stringify(buildUpdateRequest(draft))
|
||||
}
|
||||
|
||||
export function emptyEventFilters(): PromptEventFilters {
|
||||
return {
|
||||
decision: '',
|
||||
risk_level: '',
|
||||
endpoint: '',
|
||||
group_id: '',
|
||||
user_id: '',
|
||||
api_key_id: '',
|
||||
request_id: '',
|
||||
prompt_hash: '',
|
||||
keyword: '',
|
||||
start_at: '',
|
||||
end_at: '',
|
||||
}
|
||||
}
|
||||
|
||||
function toISO(value: string): string | undefined {
|
||||
if (!value.trim()) return undefined
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
|
||||
}
|
||||
|
||||
export function eventQueryParams(filters: PromptEventFilters): Record<string, string | number> {
|
||||
const result: Record<string, string | number> = {}
|
||||
for (const key of ['decision', 'risk_level', 'endpoint', 'request_id', 'prompt_hash', 'keyword'] as const) {
|
||||
const value = filters[key].trim()
|
||||
if (value) result[key] = value
|
||||
}
|
||||
for (const key of ['group_id', 'user_id', 'api_key_id'] as const) {
|
||||
const value = Number(filters[key])
|
||||
if (Number.isInteger(value) && value > 0) result[key] = value
|
||||
}
|
||||
const start = toISO(filters.start_at)
|
||||
const end = toISO(filters.end_at)
|
||||
if (start) result.start_at = start
|
||||
if (end) result.end_at = end
|
||||
return result
|
||||
}
|
||||
|
||||
export function eventFilterPayload(filters: PromptEventFilters): Record<string, unknown> {
|
||||
return eventQueryParams(filters)
|
||||
}
|
||||
|
||||
export function hasExplicitDeleteRange(filters: PromptEventFilters): boolean {
|
||||
const start = toISO(filters.start_at)
|
||||
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
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import resources from './resources'
|
||||
import ops from './ops'
|
||||
import settings from './settings'
|
||||
import audit from './audit'
|
||||
import promptAudit from './promptAudit'
|
||||
|
||||
export default {
|
||||
...overview,
|
||||
@@ -14,4 +15,5 @@ export default {
|
||||
...ops,
|
||||
...settings,
|
||||
...audit,
|
||||
...promptAudit,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
export default {
|
||||
promptAudit: {
|
||||
title: 'Prompt Audit',
|
||||
description: 'Review user input asynchronously or block it synchronously through OpenAI-compatible Qwen3Guard nodes. Full prompts are stored with events for admin review.',
|
||||
configVersion: 'Config version v{version}',
|
||||
tabs: { config: 'Configuration', events: 'Events' },
|
||||
actions: { refresh: 'Refresh runtime', retry: 'Retry', Allow: 'Allow', Warn: 'Warn', Block: 'Block' },
|
||||
common: { actions: 'Actions', never: 'Never' },
|
||||
mode: { off: 'Off', async_audit: 'Async audit only', blocking: 'Synchronous audit and block' },
|
||||
status: { disabled: 'Disabled', running: 'Running', degraded: 'Degraded', error: 'Error', healthy: 'Healthy', failed: 'Failed', stale: 'Stale heartbeat' },
|
||||
decisions: { pass: 'Pass', flag: 'Flag', critical: 'Critical' },
|
||||
riskLevels: { low: 'Low', medium: 'Medium', high: 'High', critical: 'Critical' },
|
||||
scanners: {
|
||||
violent: 'Violent',
|
||||
non_violent_illegal_acts: 'Non-violent Illegal Acts',
|
||||
sexual_content_or_sexual_acts: 'Sexual Content or Sexual Acts',
|
||||
pii: 'PII',
|
||||
suicide_and_self_harm: 'Suicide & Self-Harm',
|
||||
unethical_acts: 'Unethical Acts',
|
||||
politically_sensitive_topics: 'Politically Sensitive Topics',
|
||||
copyright_violation: 'Copyright Violation',
|
||||
jailbreak: 'Jailbreak',
|
||||
},
|
||||
scannerDescriptions: {
|
||||
violent: 'Violence or threats of violence',
|
||||
non_violent_illegal_acts: 'Non-violent illegal activity',
|
||||
sexual_content_or_sexual_acts: 'Sexual content or sexual acts',
|
||||
pii: 'Personal identifying information',
|
||||
suicide_and_self_harm: 'Suicide or self-harm',
|
||||
unethical_acts: 'Unethical behavior',
|
||||
politically_sensitive_topics: 'Politically sensitive topics',
|
||||
copyright_violation: 'Copyright infringement',
|
||||
jailbreak: 'Prompt injection or jailbreak attempt',
|
||||
},
|
||||
runtime: {
|
||||
title: 'Runtime overview',
|
||||
description: 'Shows the configuration currently active on the server. Unsaved draft changes do not affect these values.',
|
||||
process: 'Process status', mode: 'Effective mode', version: 'Active / expected version', workers: 'Active / total workers',
|
||||
queue: 'Active jobs / capacity', dependencies: 'Dependencies', guardMetrics: 'Synchronous Guard metrics', latest: 'Latest processing and error',
|
||||
queueBreakdown: 'queued {queued} · processing {processing} · retry {retry} · done {done} · failed {failed}',
|
||||
deliveryTotals: 'Total enqueued {enqueued} · dropped {dropped} · processed {processed} · failed {failed}',
|
||||
},
|
||||
metrics: { total: 'Total', allowed: 'Allowed', flagged: 'Flagged', blocked: 'Blocked', unavailable: 'Unavailable', timeouts: 'Timeouts', failovers: 'Failovers' },
|
||||
pool: {
|
||||
title: 'Audit pool', description: 'Enabled OpenAI-compatible nodes are tried in order. Probes run from the server network.',
|
||||
add: 'Add node', edit: 'Edit node', empty: 'No audit nodes configured.', node: 'Node', model: 'Model', limits: 'Timeout / chunk limit', credential: 'Credential and probe',
|
||||
configured: 'API Key configured', missing: 'API Key missing', probe: 'Test connection', probing: 'Probing…',
|
||||
probeProgress: 'Config validated ✓ · request sent · awaiting service response…', probeResult: 'Config ✓ · request ✓ · HTTP {http} · {status} · {latency} ms',
|
||||
name: 'Node name', id: 'Stable node ID', baseUrl: 'Base URL', apiKey: 'API Key', keepSecret: 'Leave blank to keep the saved API Key',
|
||||
secretHint: 'Plaintext exists only in this editor and is cleared immediately after a successful save.', clearSecret: 'Explicitly clear the saved API Key', timeout: 'Total timeout (ms)', inputLimit: 'Unicode characters per chunk',
|
||||
toggleNode: 'Toggle node {name}', deleteConfirm: 'Remove “{name}” from the draft? It takes effect after saving.',
|
||||
},
|
||||
policy: {
|
||||
title: 'Audit policy', description: 'Configure group scope, nine input-risk categories, workers, and queue bounds.', scope: 'Scope', allGroups: 'All groups', selectedGroups: 'Selected groups',
|
||||
searchGroups: 'Search groups', noGroups: 'No matching groups', missingGroups: 'Configured IDs for groups that no longer exist', selectedCount: '{count} groups selected',
|
||||
scanners: 'Qwen3Guard input-risk categories', workerCount: 'Worker count', queueCapacity: 'Persistent queue capacity', strategy: 'Node strategy', strategyHint: 'Try nodes in configuration order and fail over when allowed.',
|
||||
},
|
||||
saveBar: { enabled: 'Enable prompt audit', blocking: 'Synchronous blocking', storePass: 'Store safe events', dirty: 'Unsaved changes', synced: 'Configuration synced' },
|
||||
blockingConfirm: {
|
||||
title: 'Enable synchronous blocking?',
|
||||
message: 'Applicable requests wait for Guard before account selection, billing, or upstream access. Block, unavailable Guard, and invalid responses all prevent upstream access.',
|
||||
confirm: 'I understand; enable it',
|
||||
},
|
||||
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, 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: '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' },
|
||||
promptFull: 'Full prompt (unredacted)',
|
||||
promptFullHint: 'The full prompt is stored with this event for admin review only. Treat it as sensitive data and do not share it.',
|
||||
guardReturn: 'Model audit return',
|
||||
guardReturnHint: 'Normalized Guard result (decision, categories, scores, and redacted evidence). Raw response bodies are not stored.',
|
||||
riskSummaries: 'Risk summaries',
|
||||
evidence: 'Redacted evidence',
|
||||
score: 'Score',
|
||||
categories: 'Categories', model: 'Model', stage: 'Request stage', noRisks: 'No derived risk summaries for this event.',
|
||||
requestId: 'Request ID', promptHash: 'Prompt SHA-256',
|
||||
technical: {
|
||||
scanner: 'Scanner', policy: 'Policy', guardEndpoint: 'Guard endpoint', config: 'Config',
|
||||
chunks: 'Chunks', latency: 'Latency', protocol: 'Protocol',
|
||||
},
|
||||
deleteConfirmTitle: 'Delete audit events?', deleteConfirmMessage: 'This permanently deletes {count} events and eligible orphan jobs.', filterDeleteCount: 'The server snapshot matches {count} events.', snapshotMax: 'Snapshot maximum event ID', expiresAt: 'Confirmation token expires', filterDeleteWarning: 'Only events at or below the preview high-water mark are deleted. Newer events survive. Any filter change requires a new preview.', confirmFilterDelete: 'Permanently delete',
|
||||
},
|
||||
messages: { saved: 'Prompt Audit configuration saved; plaintext API Key state was cleared.', probeSucceeded: 'The audit node is reachable.', deleted: 'Deleted {count} audit events.' },
|
||||
errors: {
|
||||
loadConfig: 'Unable to load Prompt Audit configuration.', loadRuntime: 'Unable to load Prompt Audit runtime.', loadGroups: 'Unable to load groups.', loadEvents: 'Unable to load audit events.', loadDetail: 'Unable to load event details.', saveConfig: 'Unable to save the configuration.', probe: 'Node probe failed.', delete: 'Unable to delete events.', previewDelete: 'Unable to create a deletion preview. Check the time range.', deleteConfirmation: 'The deletion confirmation is invalid or expired. Preview again.',
|
||||
prompt_audit_config_conflict: 'Another administrator updated this configuration. Reload the server version before deciding how to merge your draft.',
|
||||
prompt_guard_requires_audit_enabled: 'Enable Prompt Audit before synchronous blocking.', prompt_audit_invalid_endpoint: 'The audit node configuration is invalid.', prompt_audit_endpoint_required: 'Enable at least one audit node before enabling Prompt Audit.', prompt_audit_groups_required: 'Select at least one group in selected-group mode.', prompt_audit_scanners_required: 'Enable at least one risk category.',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -190,6 +190,9 @@ export default {
|
||||
channelMonitor: 'Channel Monitor',
|
||||
channelStatus: 'Channel Status',
|
||||
riskControl: 'Risk Control',
|
||||
securityAudit: 'Security Audit',
|
||||
contentModeration: 'Content Moderation',
|
||||
promptAudit: 'Prompt Audit',
|
||||
auditLogs: 'Audit Logs',
|
||||
},
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import resources from './resources'
|
||||
import ops from './ops'
|
||||
import settings from './settings'
|
||||
import audit from './audit'
|
||||
import promptAudit from './promptAudit'
|
||||
|
||||
export default {
|
||||
...overview,
|
||||
@@ -14,4 +15,5 @@ export default {
|
||||
...ops,
|
||||
...settings,
|
||||
...audit,
|
||||
...promptAudit,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
export default {
|
||||
promptAudit: {
|
||||
title: '提示词审计',
|
||||
description: '通过 OpenAI 兼容 Qwen3Guard 节点异步复核或同步阻止用户输入;事件的完整提示词会入库保存,仅供管理员复核。',
|
||||
configVersion: '配置版本 v{version}',
|
||||
tabs: { config: '配置', events: '事件' },
|
||||
actions: { refresh: '刷新运行态', retry: '重试', Allow: '放行', Warn: '警告', Block: '阻止' },
|
||||
common: { actions: '操作', never: '从未' },
|
||||
mode: { off: '已关闭', async_audit: '异步只审计', blocking: '同步审计并阻止' },
|
||||
status: { disabled: '未启用', running: '运行中', degraded: '降级', error: '错误', healthy: '健康', failed: '失败', stale: '心跳过期' },
|
||||
decisions: { pass: '通过', flag: '标记', critical: '严重' },
|
||||
riskLevels: { low: '低', medium: '中', high: '高', critical: '严重' },
|
||||
scanners: {
|
||||
violent: '暴力',
|
||||
non_violent_illegal_acts: '非暴力违法行为',
|
||||
sexual_content_or_sexual_acts: '色情内容或性行为',
|
||||
pii: '个人身份信息',
|
||||
suicide_and_self_harm: '自杀与自残',
|
||||
unethical_acts: '不道德行为',
|
||||
politically_sensitive_topics: '政治敏感话题',
|
||||
copyright_violation: '版权侵犯',
|
||||
jailbreak: '越狱',
|
||||
},
|
||||
scannerDescriptions: {
|
||||
violent: '暴力或暴力威胁',
|
||||
non_violent_illegal_acts: '非暴力违法活动',
|
||||
sexual_content_or_sexual_acts: '色情内容或性行为',
|
||||
pii: '个人身份信息',
|
||||
suicide_and_self_harm: '自杀或自残',
|
||||
unethical_acts: '不道德行为',
|
||||
politically_sensitive_topics: '政治敏感话题',
|
||||
copyright_violation: '版权侵权',
|
||||
jailbreak: '提示注入或越狱尝试',
|
||||
},
|
||||
runtime: {
|
||||
title: '运行概览',
|
||||
description: '显示服务端当前生效状态;未保存的草稿不会改变这些数值。',
|
||||
process: '进程状态', mode: '生效模式', version: '生效 / 期望版本', workers: '活动 / 总 Worker',
|
||||
queue: '活动任务 / 容量', dependencies: '依赖', guardMetrics: '同步 Guard 指标', latest: '最近处理与错误',
|
||||
queueBreakdown: 'queued {queued} · processing {processing} · retry {retry} · done {done} · failed {failed}',
|
||||
deliveryTotals: '累计入队 {enqueued} · 丢弃 {dropped} · 处理 {processed} · 失败 {failed}',
|
||||
},
|
||||
metrics: { total: '总计', allowed: '放行', flagged: '标记', blocked: '阻止', unavailable: '不可用', timeouts: '超时', failovers: '故障切换' },
|
||||
pool: {
|
||||
title: '审计池', description: '按顺序使用启用的 OpenAI 兼容节点;探测由服务端真实网络环境发起。',
|
||||
add: '新增节点', edit: '编辑节点', empty: '尚未配置审计节点。', node: '节点', model: '模型', limits: '超时 / 单片上限', credential: '凭据与探测',
|
||||
configured: 'API Key 已配置', missing: '未配置 API Key', probe: '连接测试', probing: '探测中…',
|
||||
probeProgress: '配置校验 ✓ · 请求已发送 · 等待服务响应…', probeResult: '配置校验 ✓ · 请求 ✓ · HTTP {http} · {status} · {latency} ms',
|
||||
name: '节点名称', id: '稳定节点 ID', baseUrl: 'Base URL', apiKey: 'API Key', keepSecret: '留空以保留已保存的 API Key',
|
||||
secretHint: '明文只在本次编辑内存中存在;保存成功后会立即清除。', clearSecret: '显式清除已保存的 API Key', timeout: '总超时(毫秒)', inputLimit: '单片 Unicode 字符上限',
|
||||
toggleNode: '切换节点 {name}', deleteConfirm: '从草稿中删除节点“{name}”?保存配置后生效。',
|
||||
},
|
||||
policy: {
|
||||
title: '审计策略', description: '配置适用分组、九类输入风险、Worker 与队列边界。', scope: '适用范围', allGroups: '全部分组', selectedGroups: '指定分组',
|
||||
searchGroups: '搜索分组', noGroups: '没有匹配分组', missingGroups: '配置中包含已删除的分组 ID', selectedCount: '已选择 {count} 个分组',
|
||||
scanners: 'Qwen3Guard 输入风险分类', workerCount: 'Worker 数量', queueCapacity: '持久队列容量', strategy: '节点策略', strategyHint: '按配置顺序优先尝试,必要时故障切换。',
|
||||
},
|
||||
saveBar: { enabled: '启用提示词审计', blocking: '同步阻止', storePass: '保存安全事件', dirty: '有未保存的更改', synced: '配置已同步' },
|
||||
blockingConfirm: {
|
||||
title: '开启同步阻止?',
|
||||
message: '适用请求会在账号选择、计费和访问上游之前等待 Guard。命中 Block、Guard 不可用或响应非法时,请求都不会访问上游。',
|
||||
confirm: '理解风险并开启',
|
||||
},
|
||||
events: {
|
||||
title: '审计事件', description: '按身份、入口、风险、Hash 和时间复核事件,详情中可查看完整提示词。', decision: '判定', risk: '风险等级', endpoint: '入口', groupId: '分组 ID', userId: '用户 ID', apiKeyId: 'API Key ID', keyword: '关键词',
|
||||
startAt: '开始时间', endAt: '结束时间', deleteSelected: '删除选中项({count})', deleteByFilter: '按筛选删除',
|
||||
filterDeleteDialogTitle: '按筛选删除审计事件', filterDeleteDialogDesc: '选择删除的时间范围与风险条件后即可执行删除;删除不可恢复。如需提前查看匹配数量,可先获取删除预览。',
|
||||
filterTimeRange: '删除时间范围', filterTimeRangeHint: '将删除所选截止时间之前产生的事件;预览后新产生的事件不受影响。',
|
||||
timePresets: { '1d': '1 天前', '7d': '7 天前', '30d': '30 天前', '90d': '90 天前', all: '全部时间', custom: '自定义范围' },
|
||||
customRangeInvalid: '自定义范围需要有效的开始与结束时间,且开始早于结束。',
|
||||
moreConditions: '更多条件(入口 / 关键词 / 分组 / 用户)',
|
||||
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: '技术信息' },
|
||||
promptFull: '完整提示词(未脱敏)',
|
||||
promptFullHint: '完整提示词已随事件入库,仅供管理员复核触发内容;请按敏感数据妥善处理,切勿外泄。',
|
||||
guardReturn: '模型审计返回',
|
||||
guardReturnHint: '展示 Guard 归一化后的结构化结果(判定、分类、分数与脱敏证据),不含原始响应体。',
|
||||
riskSummaries: '风险摘要',
|
||||
evidence: '脱敏证据',
|
||||
score: '分数',
|
||||
categories: '分类', model: '模型', stage: '请求阶段', noRisks: '本事件没有派生风险摘要。',
|
||||
requestId: 'Request ID', promptHash: 'Prompt SHA-256',
|
||||
technical: {
|
||||
scanner: '扫描器', policy: '策略', guardEndpoint: 'Guard 节点', config: '配置版本',
|
||||
chunks: '分片数', latency: '耗时', protocol: '协议',
|
||||
},
|
||||
deleteConfirmTitle: '删除审计事件?', deleteConfirmMessage: '将永久删除 {count} 条事件及符合条件的孤立任务。', filterDeleteCount: '服务端快照匹配 {count} 条事件。', snapshotMax: '快照最大事件 ID', expiresAt: '确认令牌过期时间', filterDeleteWarning: '只删除预览高水位内的事件;预览后产生的新事件会保留。筛选一旦变化,必须重新预览。', confirmFilterDelete: '确认永久删除',
|
||||
},
|
||||
messages: { saved: '提示词审计配置已保存,明文 API Key 状态已清除。', probeSucceeded: '审计节点连接正常。', deleted: '已删除 {count} 条审计事件。' },
|
||||
errors: {
|
||||
loadConfig: '无法加载提示词审计配置。', loadRuntime: '无法加载提示词审计运行态。', loadGroups: '无法加载分组列表。', loadEvents: '无法加载审计事件。', loadDetail: '无法加载事件详情。', saveConfig: '配置保存失败。', probe: '节点探测失败。', delete: '事件删除失败。', previewDelete: '无法生成删除预览,请检查时间范围。', deleteConfirmation: '删除确认无效或已过期,请重新预览。',
|
||||
prompt_audit_config_conflict: '配置已被其他管理员更新。请重新加载服务端配置,再决定如何合并本地草稿。',
|
||||
prompt_guard_requires_audit_enabled: '开启同步阻止前必须先启用提示词审计。', prompt_audit_invalid_endpoint: '审计节点配置无效。', prompt_audit_endpoint_required: '启用审计前至少需要一个启用节点。', prompt_audit_groups_required: '指定分组模式至少需要选择一个分组。', prompt_audit_scanners_required: '至少需要启用一个风险分类。',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -190,6 +190,9 @@ export default {
|
||||
channelMonitor: '渠道监控',
|
||||
channelStatus: '渠道状态',
|
||||
riskControl: '风控中心',
|
||||
securityAudit: '安全审计',
|
||||
contentModeration: '内容审核',
|
||||
promptAudit: '提示词审计',
|
||||
auditLogs: '操作日志',
|
||||
},
|
||||
|
||||
|
||||
@@ -587,6 +587,19 @@ const routes: RouteRecordRaw[] = [
|
||||
requiresRiskControl: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/prompt-audit',
|
||||
name: 'AdminPromptAudit',
|
||||
component: () => import('@/features/prompt-audit/PromptAuditView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'Prompt Audit',
|
||||
titleKey: 'admin.promptAudit.title',
|
||||
descriptionKey: 'admin.promptAudit.description',
|
||||
requiresRiskControl: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/usage',
|
||||
name: 'AdminUsage',
|
||||
|
||||
@@ -170,6 +170,14 @@
|
||||
@apply disabled:cursor-not-allowed disabled:bg-gray-100 dark:disabled:bg-dark-900;
|
||||
}
|
||||
|
||||
.dark .input[type='date'],
|
||||
.dark .input[type='time'],
|
||||
.dark .input[type='datetime-local'],
|
||||
.dark .input[type='month'],
|
||||
.dark .input[type='week'] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
@apply border-red-500 focus:border-red-500 focus:ring-red-500/30;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user