mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-20 00:18:55 +00:00
feat(security-audit): persist full prompts on audit events and polish event review UI
- Add prompt_audit_events.full_prompt (migration 182) so admins can review the exact unredacted prompt that triggered a finding; blocking mode writes it from the snapshot, async mode reconstructs it from the Redis scan payload so jobs rows stay redaction-only - Event detail API returns full_prompt (list endpoint stays lean); text is NUL-stripped and capped at 65536 runes - Detail dialog shows the full prompt in a scrollable pane with fallback to the legacy redacted preview; page copy updated to match the new behavior - Rework filter deletion into a dedicated dialog with time-range presets and criteria-change preview invalidation; localize decision/risk/category labels across the events workspace - Fix pre-existing i18n message-compile spec by declaring the @intlify/message-compiler dev dependency
This commit is contained in:
@@ -103,7 +103,7 @@ func (r *PostgreSQLRepository) ListEvents(ctx context.Context, filter EventFilte
|
||||
}
|
||||
|
||||
func (r *PostgreSQLRepository) GetEvent(ctx context.Context, id int64) (*Event, error) {
|
||||
event, err := scanEvent(r.db.QueryRowContext(ctx, `SELECT `+eventColumns("e")+` FROM prompt_audit_events e WHERE e.id=$1`, id))
|
||||
event, err := scanEvent(r.db.QueryRowContext(ctx, `SELECT `+eventDetailColumns("e")+` FROM prompt_audit_events e WHERE e.id=$1`, id), true)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrEventNotFound
|
||||
}
|
||||
@@ -320,18 +320,28 @@ func eventColumns(alias string) string {
|
||||
%[1]s.chunk_total,%[1]s.latency_ms,%[1]s.created_at`, alias)
|
||||
}
|
||||
|
||||
func scanEvent(row rowScanner) (*Event, error) {
|
||||
// eventDetailColumns adds the full prompt, which can be large, so it is only
|
||||
// loaded for single-event detail reads and never for list pages.
|
||||
func eventDetailColumns(alias string) string {
|
||||
return eventColumns(alias) + fmt.Sprintf(",%[1]s.full_prompt", alias)
|
||||
}
|
||||
|
||||
func scanEvent(row rowScanner, withFullPrompt ...bool) (*Event, error) {
|
||||
event := &Event{}
|
||||
var userID, apiKeyID, groupID sql.NullInt64
|
||||
var categories, matched, scores, evidence []byte
|
||||
err := row.Scan(&event.ID, &event.JobID, &event.Snapshot.RequestID, &userID,
|
||||
dest := []any{&event.ID, &event.JobID, &event.Snapshot.RequestID, &userID,
|
||||
&event.Snapshot.UsernameSnapshot, &event.Snapshot.UserEmailSnapshot, &apiKeyID,
|
||||
&event.Snapshot.APIKeyNameSnapshot, &groupID, &event.Snapshot.GroupName,
|
||||
&event.Snapshot.Provider, &event.Snapshot.Endpoint, &event.Snapshot.Protocol, &event.Snapshot.Model,
|
||||
&event.Snapshot.PromptHash, &event.Snapshot.RedactedPreview, &event.Snapshot.Stage, &event.Decision,
|
||||
&event.RiskLevel, &event.Action, &categories, &matched, &scores, &evidence, &event.ScannerBackend,
|
||||
&event.ScannerVersion, &event.GuardEndpointID, &event.PolicyID, &event.PolicyVersion,
|
||||
&event.ConfigVersion, &event.ChunkTotal, &event.LatencyMS, &event.CreatedAt)
|
||||
&event.ConfigVersion, &event.ChunkTotal, &event.LatencyMS, &event.CreatedAt}
|
||||
if len(withFullPrompt) > 0 && withFullPrompt[0] {
|
||||
dest = append(dest, &event.Snapshot.FullPrompt)
|
||||
}
|
||||
err := row.Scan(dest...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -69,12 +69,12 @@ type JobRepository interface {
|
||||
MarkStagingFailed(ctx context.Context, jobID int64, code, message string) error
|
||||
ClaimNextJob(ctx context.Context, now time.Time) (*Job, bool, error)
|
||||
RefreshLease(ctx context.Context, jobID, claimVersion int64, now time.Time) error
|
||||
Complete(ctx context.Context, job *Job, result *NormalizedResult, storePass bool) (*Event, error)
|
||||
Complete(ctx context.Context, job *Job, result *NormalizedResult, storePassEvents bool) (*Event, error)
|
||||
Retry(ctx context.Context, jobID, claimVersion int64, next time.Time, code, message string) error
|
||||
Fail(ctx context.Context, jobID, claimVersion int64, code, message string) error
|
||||
ReclaimStale(ctx context.Context, stagingBefore, processingBefore time.Time, limit int) (int64, error)
|
||||
QueueStats(ctx context.Context) (QueueStats, error)
|
||||
RecordBlocking(ctx context.Context, snapshot PromptSnapshot, configVersion int64, result *NormalizedResult, storePass bool) (*Event, error)
|
||||
RecordBlocking(ctx context.Context, snapshot PromptSnapshot, configVersion int64, result *NormalizedResult, storePassEvents bool) (*Event, error)
|
||||
}
|
||||
|
||||
type PostgreSQLRepository struct {
|
||||
@@ -169,7 +169,7 @@ func (r *PostgreSQLRepository) RefreshLease(ctx context.Context, jobID, claimVer
|
||||
return requireOneRow(result, err, ErrLeaseLost)
|
||||
}
|
||||
|
||||
func (r *PostgreSQLRepository) Complete(ctx context.Context, job *Job, result *NormalizedResult, storePass bool) (*Event, error) {
|
||||
func (r *PostgreSQLRepository) Complete(ctx context.Context, job *Job, result *NormalizedResult, storePassEvents bool) (*Event, error) {
|
||||
if job == nil || result == nil {
|
||||
return nil, errors.New("prompt audit completion requires job and result")
|
||||
}
|
||||
@@ -186,7 +186,7 @@ func (r *PostgreSQLRepository) Complete(ctx context.Context, job *Job, result *N
|
||||
return nil, err
|
||||
}
|
||||
var event *Event
|
||||
if storePass || result.Decision != EventPass {
|
||||
if shouldStorePromptAuditEvent(result.Decision, storePassEvents) {
|
||||
event, err = insertEvent(ctx, tx, job.ID, job.Snapshot.Redacted(), job.ConfigVersion, result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -278,7 +278,7 @@ func (r *PostgreSQLRepository) QueueStats(ctx context.Context) (QueueStats, erro
|
||||
return stats, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PostgreSQLRepository) RecordBlocking(ctx context.Context, snapshot PromptSnapshot, configVersion int64, result *NormalizedResult, storePass bool) (*Event, error) {
|
||||
func (r *PostgreSQLRepository) RecordBlocking(ctx context.Context, snapshot PromptSnapshot, configVersion int64, result *NormalizedResult, storePassEvents bool) (*Event, error) {
|
||||
if result == nil {
|
||||
return nil, errors.New("prompt guard result required")
|
||||
}
|
||||
@@ -292,7 +292,7 @@ func (r *PostgreSQLRepository) RecordBlocking(ctx context.Context, snapshot Prom
|
||||
return nil, err
|
||||
}
|
||||
var event *Event
|
||||
if storePass || result.Decision != EventPass {
|
||||
if shouldStorePromptAuditEvent(result.Decision, storePassEvents) {
|
||||
event, err = insertEvent(ctx, tx, job.ID, snapshot.Redacted(), configVersion, result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -304,6 +304,12 @@ func (r *PostgreSQLRepository) RecordBlocking(ctx context.Context, snapshot Prom
|
||||
return event, nil
|
||||
}
|
||||
|
||||
// shouldStorePromptAuditEvent keeps store_pass_events scoped to safe results.
|
||||
// Risk events are always persisted while prompt auditing itself is enabled.
|
||||
func shouldStorePromptAuditEvent(decision EventDecision, storePassEvents bool) bool {
|
||||
return decision != EventPass || storePassEvents
|
||||
}
|
||||
|
||||
type sqlQueryer interface {
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
}
|
||||
@@ -342,17 +348,19 @@ func insertEvent(ctx context.Context, queryer sqlQueryer, jobID int64, snapshot
|
||||
job_id,request_id,user_id,username_snapshot,user_email_snapshot,api_key_id,api_key_name_snapshot,
|
||||
group_id,group_name,provider,endpoint,protocol,model,prompt_hash,redacted_preview,stage,
|
||||
decision,risk_level,action,categories,matched_scanners,scanner_scores,scanner_evidence,
|
||||
scanner_backend,scanner_version,guard_endpoint_id,policy_id,policy_version,config_version,chunk_total,latency_ms
|
||||
scanner_backend,scanner_version,guard_endpoint_id,policy_id,policy_version,config_version,chunk_total,latency_ms,
|
||||
full_prompt
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,
|
||||
$20::jsonb,$21::jsonb,$22::jsonb,$23::jsonb,$24,$25,$26,$27,$28,$29,$30,$31)
|
||||
RETURNING `+eventColumns("prompt_audit_events"),
|
||||
$20::jsonb,$21::jsonb,$22::jsonb,$23::jsonb,$24,$25,$26,$27,$28,$29,$30,$31,$32)
|
||||
RETURNING `+eventDetailColumns("prompt_audit_events"),
|
||||
jobID, snapshot.RequestID, nullableID(snapshot.UserID), snapshot.UsernameSnapshot, snapshot.UserEmailSnapshot,
|
||||
nullableID(snapshot.APIKeyID), snapshot.APIKeyNameSnapshot, snapshot.GroupID, snapshot.GroupName,
|
||||
snapshot.Provider, snapshot.Endpoint, snapshot.Protocol, snapshot.Model, snapshot.PromptHash,
|
||||
snapshot.RedactedPreview, normalizeStage(snapshot.Stage), string(result.Decision), string(result.RiskLevel),
|
||||
string(result.Action), categories, matched, scores, evidenceJSON, result.ScannerBackend, result.ScannerVersion,
|
||||
result.GuardEndpointID, result.PolicyID, result.PolicyVersion, configVersion, result.ChunkTotal, result.LatencyMS)
|
||||
return scanEvent(row)
|
||||
result.GuardEndpointID, result.PolicyID, result.PolicyVersion, configVersion, result.ChunkTotal, result.LatencyMS,
|
||||
snapshot.FullPrompt)
|
||||
return scanEvent(row, true)
|
||||
}
|
||||
|
||||
type rowScanner interface{ Scan(...any) error }
|
||||
|
||||
@@ -43,15 +43,16 @@ func openPromptAuditIntegrationDB(t *testing.T) *sql.DB {
|
||||
);
|
||||
`)
|
||||
require.NoError(t, err)
|
||||
migrationPath := filepath.Join("..", "..", "migrations", "181_prompt_audit.sql")
|
||||
migration, err := os.ReadFile(migrationPath)
|
||||
require.NoError(t, err)
|
||||
// The migration runner can retry an interrupted deployment; the migration
|
||||
// must therefore be safe to execute more than once.
|
||||
_, err = db.ExecContext(ctx, string(migration))
|
||||
require.NoError(t, err)
|
||||
_, err = db.ExecContext(ctx, string(migration))
|
||||
require.NoError(t, err)
|
||||
for _, name := range []string{"181_prompt_audit.sql", "182_prompt_audit_full_prompt.sql"} {
|
||||
migration, err := os.ReadFile(filepath.Join("..", "..", "migrations", name))
|
||||
require.NoError(t, err)
|
||||
// The migration runner can retry an interrupted deployment; the migration
|
||||
// must therefore be safe to execute more than once.
|
||||
_, err = db.ExecContext(ctx, string(migration))
|
||||
require.NoError(t, err)
|
||||
_, err = db.ExecContext(ctx, string(migration))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
t.Cleanup(func() { require.NoError(t, db.Close()) })
|
||||
resetPromptAuditIntegrationDB(t, db)
|
||||
return db
|
||||
@@ -150,7 +151,7 @@ func TestPromptAuditMigrationSchemaAndLeakageGate(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestPromptAuditDatabaseAndAdminJSONNeverPersistCanaryPromptOrRawErrors(t *testing.T) {
|
||||
func TestPromptAuditDatabasePersistsFullPromptOnEventsOnly(t *testing.T) {
|
||||
db := openPromptAuditIntegrationDB(t)
|
||||
repo := NewPostgreSQLRepository(db)
|
||||
ctx := context.Background()
|
||||
@@ -163,11 +164,23 @@ func TestPromptAuditDatabaseAndAdminJSONNeverPersistCanaryPromptOrRawErrors(t *t
|
||||
snapshot, err := ExtractPromptSnapshot(request)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, snapshot.RedactedPreview, promptCanary)
|
||||
require.Contains(t, snapshot.FullPrompt, promptCanary)
|
||||
event, err := repo.RecordBlocking(ctx, snapshot.Redacted(), 1, integrationResult(EventCritical), true)
|
||||
require.NoError(t, err)
|
||||
// The event intentionally retains the full prompt for admin review; the
|
||||
// redacted preview and transient job row still never contain it.
|
||||
adminJSON, err := json.Marshal(event)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(adminJSON), promptCanary)
|
||||
require.Contains(t, string(adminJSON), promptCanary)
|
||||
require.NotContains(t, event.Snapshot.RedactedPreview, promptCanary)
|
||||
|
||||
var storedFullPrompt string
|
||||
require.NoError(t, db.QueryRow(`SELECT full_prompt FROM prompt_audit_events WHERE id=$1`, event.ID).Scan(&storedFullPrompt))
|
||||
require.Contains(t, storedFullPrompt, promptCanary)
|
||||
|
||||
detail, err := repo.GetEvent(ctx, event.ID)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, detail.Snapshot.FullPrompt, promptCanary)
|
||||
|
||||
var jobJSON string
|
||||
require.NoError(t, db.QueryRow(`SELECT row_to_json(j)::text FROM prompt_audit_jobs j WHERE id=$1`, event.JobID).Scan(&jobJSON))
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package securityaudit
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestShouldStorePromptAuditEvent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
storePassEvents bool
|
||||
decision EventDecision
|
||||
want bool
|
||||
}{
|
||||
{name: "pass disabled", storePassEvents: false, decision: EventPass, want: false},
|
||||
{name: "flag disabled", storePassEvents: false, decision: EventFlag, want: true},
|
||||
{name: "critical disabled", storePassEvents: false, decision: EventCritical, want: true},
|
||||
{name: "pass enabled", storePassEvents: true, decision: EventPass, want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := shouldStorePromptAuditEvent(tt.decision, tt.storePassEvents); got != tt.want {
|
||||
t.Fatalf("shouldStorePromptAuditEvent(%q, %t) = %t, want %t", tt.decision, tt.storePassEvents, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ func ExtractPromptSnapshot(req Request) (PromptSnapshot, error) {
|
||||
GroupID: cloneInt64Ptr(req.GroupID), GroupName: req.GroupName, Provider: req.Provider,
|
||||
Endpoint: req.Endpoint, Protocol: req.Protocol, Model: req.Model,
|
||||
PromptHash: hex.EncodeToString(digest[:]), RedactedPreview: BuildPromptPreview(metadataText, DefaultPromptPreviewMaxRunes),
|
||||
FullPrompt: BuildFullPrompt(metadataText, DefaultFullPromptMaxRunes),
|
||||
PromptLength: utf8.RuneCountInString(metadataText), MessageCount: len(segments), Stage: stage,
|
||||
ScanText: scanText,
|
||||
}, nil
|
||||
@@ -59,6 +60,11 @@ func ExtractPromptSnapshot(req Request) (PromptSnapshot, error) {
|
||||
// considered before BuildPromptPreview withholds the majority for storage/UI.
|
||||
const DefaultPromptPreviewMaxRunes = 96
|
||||
|
||||
// DefaultFullPromptMaxRunes caps how much unredacted prompt text is persisted
|
||||
// on an audit event for admin review. It is deliberately generous so realistic
|
||||
// prompts are kept intact while bounding per-row storage.
|
||||
const DefaultFullPromptMaxRunes = 65536
|
||||
|
||||
func extractProtocolSegments(protocol string, document any) []promptSegment {
|
||||
root, _ := document.(map[string]any)
|
||||
protocol = strings.ToLower(strings.TrimSpace(protocol))
|
||||
@@ -520,6 +526,25 @@ func BuildPromptPreview(value string, maxRunes int) string {
|
||||
return preview
|
||||
}
|
||||
|
||||
// BuildFullPrompt returns the complete prompt text for audit-event storage and
|
||||
// admin review, without redaction. NUL bytes are stripped because PostgreSQL
|
||||
// TEXT rejects them, and the result is capped at maxRunes.
|
||||
func BuildFullPrompt(value string, maxRunes int) string {
|
||||
if maxRunes <= 0 {
|
||||
maxRunes = DefaultFullPromptMaxRunes
|
||||
}
|
||||
value = strings.ReplaceAll(value, "\x00", "")
|
||||
return TrimRunes(strings.TrimSpace(value), maxRunes)
|
||||
}
|
||||
|
||||
// FullPromptFromScanText reconstructs the display prompt from the worker scan
|
||||
// payload. buildPrioritizedScanText inserts exactly one priority separator
|
||||
// between the prioritized segment and the remainder, so replacing it with the
|
||||
// metadata joiner yields the original multi-segment text.
|
||||
func FullPromptFromScanText(scanText string) string {
|
||||
return BuildFullPrompt(strings.ReplaceAll(scanText, promptAuditPrioritySeparator, "\n\n"), DefaultFullPromptMaxRunes)
|
||||
}
|
||||
|
||||
func TrimRunes(value string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
|
||||
@@ -54,6 +54,34 @@ func TestSnapshotRedactsCanariesAndPreservesHashOfScanText(t *testing.T) {
|
||||
require.Empty(t, snapshot.Redacted().ScanText)
|
||||
}
|
||||
|
||||
func TestSnapshotFullPromptKeepsUnredactedText(t *testing.T) {
|
||||
body := `{"messages":[{"role":"user","content":"PROMPT_CANARY_ABC123 email@example.com sk-secretvalue123"}]}`
|
||||
snapshot, err := ExtractPromptSnapshot(Request{Protocol: "openai_chat_completions", Body: []byte(body)})
|
||||
require.NoError(t, err)
|
||||
// The full prompt is stored verbatim for admin review, unlike the preview.
|
||||
require.Contains(t, snapshot.FullPrompt, "PROMPT_CANARY_ABC123 email@example.com sk-secretvalue123")
|
||||
require.NotContains(t, snapshot.RedactedPreview, "PROMPT_CANARY_ABC123")
|
||||
require.Equal(t, snapshot.FullPrompt, snapshot.Redacted().FullPrompt)
|
||||
}
|
||||
|
||||
func TestBuildFullPromptStripsNULAndTruncates(t *testing.T) {
|
||||
require.Equal(t, "abcd", BuildFullPrompt("ab\x00cd", 0))
|
||||
long := strings.Repeat("长", DefaultFullPromptMaxRunes+10)
|
||||
trimmed := BuildFullPrompt(long, DefaultFullPromptMaxRunes)
|
||||
require.Equal(t, DefaultFullPromptMaxRunes+1, utf8.RuneCountInString(trimmed))
|
||||
require.True(t, strings.HasSuffix(trimmed, "…"))
|
||||
}
|
||||
|
||||
func TestFullPromptFromScanTextRestoresMultiSegmentLayout(t *testing.T) {
|
||||
scanText, metadataText := buildPrioritizedScanText([]string{"latest user", "system policy", "earlier user"})
|
||||
require.Contains(t, scanText, promptAuditPrioritySeparator)
|
||||
require.Equal(t, metadataText, FullPromptFromScanText(scanText))
|
||||
|
||||
singleScan, singleMeta := buildPrioritizedScanText([]string{"only"})
|
||||
require.NotContains(t, singleScan, promptAuditPrioritySeparator)
|
||||
require.Equal(t, singleMeta, FullPromptFromScanText(singleScan))
|
||||
}
|
||||
|
||||
func TestSplitRunesDoesNotSplitUTF8(t *testing.T) {
|
||||
chunks := SplitRunes("中文😀éabc", 2)
|
||||
require.Equal(t, []string{"中文", "😀e", "́a", "bc"}, chunks)
|
||||
|
||||
@@ -105,6 +105,7 @@ type PromptSnapshot struct {
|
||||
Model string `json:"model"`
|
||||
PromptHash string `json:"prompt_hash"`
|
||||
RedactedPreview string `json:"redacted_preview"`
|
||||
FullPrompt string `json:"full_prompt"`
|
||||
PromptLength int `json:"prompt_length"`
|
||||
MessageCount int `json:"message_count"`
|
||||
Stage string `json:"stage"`
|
||||
|
||||
@@ -140,6 +140,9 @@ func (r *Runner) processJob(ctx context.Context, workerID int, cfg ActiveConfig,
|
||||
if err != nil {
|
||||
return r.finishFailure(ctx, job, &GuardError{Code: "payload_missing", Retryable: false, Cause: err})
|
||||
}
|
||||
// The job row only carries redacted metadata; the full prompt for the audit
|
||||
// event is reconstructed here from the transient scan payload.
|
||||
job.Snapshot.FullPrompt = FullPromptFromScanText(scanText)
|
||||
endpoints := cfg.EnabledEndpoints()
|
||||
if len(endpoints) == 0 {
|
||||
return r.finishFailure(ctx, job, &GuardError{Code: "no_enabled_endpoint", Retryable: true})
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Retain the full (unredacted) prompt text on audit events so admins can
|
||||
-- review the exact content that triggered a finding. Scoped to events only:
|
||||
-- transient processing jobs keep storing redacted metadata.
|
||||
ALTER TABLE prompt_audit_events
|
||||
ADD COLUMN IF NOT EXISTS full_prompt TEXT NOT NULL DEFAULT '';
|
||||
@@ -36,6 +36,7 @@
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@intlify/message-compiler": "9.14.5",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/mdx": "^2.0.13",
|
||||
|
||||
Generated
+3
@@ -70,6 +70,9 @@ importers:
|
||||
specifier: ^0.18.5
|
||||
version: 0.18.5
|
||||
devDependencies:
|
||||
'@intlify/message-compiler':
|
||||
specifier: 9.14.5
|
||||
version: 9.14.5
|
||||
'@types/dompurify':
|
||||
specifier: ^3.0.5
|
||||
version: 3.2.0
|
||||
|
||||
@@ -127,23 +127,17 @@
|
||||
@confirm="confirmIDDelete"
|
||||
@cancel="clearDeleteRequest"
|
||||
/>
|
||||
<BaseDialog :show="Boolean(deletePreview)" :title="t('admin.promptAudit.events.filterDeleteTitle')" width="normal" @close="deletePreview = null">
|
||||
<div v-if="deletePreview" class="space-y-4 text-sm text-gray-600 dark:text-dark-300">
|
||||
<p>{{ t('admin.promptAudit.events.filterDeleteCount', { count: deletePreview.matched_count }) }}</p>
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2">
|
||||
<dt>{{ t('admin.promptAudit.events.snapshotMax') }}</dt><dd>{{ deletePreview.snapshot_max_id }}</dd>
|
||||
<dt>Filter SHA-256</dt><dd class="break-all font-mono text-xs">{{ deletePreview.filter_hash }}</dd>
|
||||
<dt>{{ t('admin.promptAudit.events.expiresAt') }}</dt><dd>{{ formatDate(deletePreview.expires_at) }}</dd>
|
||||
</dl>
|
||||
<p class="rounded-lg bg-amber-50 px-3 py-2 text-amber-800 dark:bg-amber-950/30 dark:text-amber-200">{{ t('admin.promptAudit.events.filterDeleteWarning') }}</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" class="btn btn-secondary" @click="deletePreview = null">{{ t('common.cancel') }}</button>
|
||||
<button type="button" class="btn btn-danger" :disabled="loading.deleting" data-test="confirm-filter-delete" @click="confirmFilterDelete">{{ t('admin.promptAudit.events.confirmFilterDelete') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
<FilterDeleteDialog
|
||||
:show="showFilterDelete"
|
||||
:initial-filters="filters"
|
||||
:preview="deletePreview"
|
||||
:previewing="loading.previewing"
|
||||
:deleting="loading.deleting"
|
||||
@close="closeFilterDelete"
|
||||
@preview="runFilterDeletePreview"
|
||||
@confirm="confirmFilterDelete"
|
||||
@criteria-change="clearDeletePreview"
|
||||
/>
|
||||
<EventDetailDialog :show="showEventDetail" :event="activeEvent" :loading="loading.detail" @close="closeEventDetail" />
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -152,7 +146,6 @@
|
||||
import { computed, defineComponent, h, onMounted, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { extractApiErrorCode, extractApiErrorMessage } from '@/utils/apiError'
|
||||
@@ -161,6 +154,7 @@ import EndpointPool from './components/EndpointPool.vue'
|
||||
import PolicyPanel from './components/PolicyPanel.vue'
|
||||
import EventWorkspace from './components/EventWorkspace.vue'
|
||||
import EventDetailDialog from './components/EventDetailDialog.vue'
|
||||
import FilterDeleteDialog from './components/FilterDeleteDialog.vue'
|
||||
import promptAuditAPI from './api'
|
||||
import type {
|
||||
PromptAuditDraft,
|
||||
@@ -179,7 +173,7 @@ import { buildUpdateRequest, cloneData, configToDraft, draftFingerprint, emptyEv
|
||||
const { t, locale } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
type PromptAuditPageTab = 'config' | 'events'
|
||||
const activeTab = ref<PromptAuditPageTab>('config')
|
||||
const activeTab = ref<PromptAuditPageTab>('events')
|
||||
const pageTabs = computed(() => [
|
||||
{ id: 'events' as const, label: t('admin.promptAudit.tabs.events') },
|
||||
{ id: 'config' as const, label: t('admin.promptAudit.tabs.config') },
|
||||
@@ -196,10 +190,12 @@ const activeEvent = ref<PromptAuditEvent | null>(null)
|
||||
const showEventDetail = ref(false)
|
||||
const probeResults = reactive<Record<string, PromptProbeResult>>({})
|
||||
const probingIds = ref<string[]>([])
|
||||
const showFilterDelete = ref(false)
|
||||
const deletePreview = ref<PromptDeletePreview | null>(null)
|
||||
const deletePreviewFilters = ref<PromptEventFilters | null>(null)
|
||||
const showBlockingConfirmation = ref(false)
|
||||
const deleteRequest = reactive<{ mode: '' | 'single' | 'batch'; ids: number[] }>({ mode: '', ids: [] })
|
||||
const loading = reactive({ config: false, runtime: false, groups: false, events: false, saving: false, detail: false, deleting: false })
|
||||
const loading = reactive({ config: false, runtime: false, groups: false, events: false, saving: false, detail: false, deleting: false, previewing: false })
|
||||
const loadErrors = reactive<PromptLoadErrors>({ config: '', runtime: '', groups: '', events: '' })
|
||||
const dirty = computed(() => draftFingerprint(draft.value) !== draftFingerprint(serverConfig.value))
|
||||
|
||||
@@ -346,13 +342,13 @@ async function runProbe(endpoint: PromptAuditEndpointDraft) {
|
||||
|
||||
function handleFiltersChanged(value: PromptEventFilters) {
|
||||
filters.value = cloneData(value)
|
||||
deletePreview.value = null
|
||||
clearDeletePreview()
|
||||
}
|
||||
function applyEventFilters(value: PromptEventFilters) {
|
||||
filters.value = cloneData(value)
|
||||
appliedFilters.value = cloneData(value)
|
||||
events.page = 1
|
||||
deletePreview.value = null
|
||||
clearDeletePreview()
|
||||
void loadEvents()
|
||||
}
|
||||
function changePage(value: number) { events.page = value; void loadEvents() }
|
||||
@@ -382,23 +378,40 @@ async function confirmIDDelete() {
|
||||
} catch (error) { appStore.showError(errorMessage(error, 'admin.promptAudit.errors.delete')) }
|
||||
finally { loading.deleting = false }
|
||||
}
|
||||
async function requestFilterDeletePreview() {
|
||||
loading.deleting = true
|
||||
try { deletePreview.value = await promptAuditAPI.previewDelete(filters.value) }
|
||||
catch (error) { appStore.showError(errorMessage(error, 'admin.promptAudit.errors.previewDelete')) }
|
||||
finally { loading.deleting = false }
|
||||
function clearDeletePreview() {
|
||||
deletePreview.value = null
|
||||
deletePreviewFilters.value = null
|
||||
}
|
||||
function requestFilterDeletePreview() {
|
||||
clearDeletePreview()
|
||||
showFilterDelete.value = true
|
||||
}
|
||||
function closeFilterDelete() {
|
||||
showFilterDelete.value = false
|
||||
clearDeletePreview()
|
||||
}
|
||||
async function runFilterDeletePreview(value: PromptEventFilters) {
|
||||
loading.previewing = true
|
||||
try {
|
||||
deletePreview.value = await promptAuditAPI.previewDelete(value)
|
||||
deletePreviewFilters.value = cloneData(value)
|
||||
} catch (error) {
|
||||
clearDeletePreview()
|
||||
appStore.showError(errorMessage(error, 'admin.promptAudit.errors.previewDelete'))
|
||||
} finally { loading.previewing = false }
|
||||
}
|
||||
async function confirmFilterDelete() {
|
||||
if (!deletePreview.value) return
|
||||
if (!deletePreview.value || !deletePreviewFilters.value) return
|
||||
const preview = deletePreview.value
|
||||
const previewFilters = cloneData(deletePreviewFilters.value)
|
||||
loading.deleting = true
|
||||
try {
|
||||
const result = await promptAuditAPI.deleteEventsByFilter(filters.value, preview)
|
||||
deletePreview.value = null
|
||||
const result = await promptAuditAPI.deleteEventsByFilter(previewFilters, preview)
|
||||
closeFilterDelete()
|
||||
appStore.showSuccess(t('admin.promptAudit.messages.deleted', { count: result.deleted_events }))
|
||||
await Promise.allSettled([loadEvents(), loadRuntime()])
|
||||
} catch (error) {
|
||||
deletePreview.value = null
|
||||
clearDeletePreview()
|
||||
appStore.showError(errorMessage(error, 'admin.promptAudit.errors.deleteConfirmation'))
|
||||
} finally { loading.deleting = false }
|
||||
}
|
||||
|
||||
@@ -45,12 +45,16 @@ const EventsStub = defineComponent({
|
||||
template: '<div data-test="events"><button data-test="preview" @click="$emit(\'preview-delete\')">preview</button><button data-test="change-filter" @click="$emit(\'filters-change\', { ...filters, keyword: \'changed\' })">change</button><button data-test="delete-one" @click="$emit(\'delete\', 5)">delete</button><button data-test="select-batch" @click="$emit(\'selection\', [5, 6])">select</button><button data-test="delete-batch" @click="$emit(\'batch-delete\')">batch</button></div>',
|
||||
})
|
||||
const DetailStub = defineComponent({ props: ['show', 'event', 'loading'], emits: ['close'], template: '<div data-test="detail" />' })
|
||||
const DialogStub = defineComponent({ props: ['show', 'title'], emits: ['close'], template: '<div v-if="show" data-test="dialog"><slot /><slot name="footer" /></div>' })
|
||||
const ConfirmStub = defineComponent({ props: ['show', 'title', 'message'], emits: ['confirm', 'cancel'], template: '<div v-if="show" data-test="confirm"><button data-test="confirm-action" @click="$emit(\'confirm\')">confirm</button></div>' })
|
||||
const FilterDeleteStub = defineComponent({
|
||||
props: ['show', 'initialFilters', 'preview', 'previewing', 'deleting'],
|
||||
emits: ['close', 'preview', 'confirm', 'criteria-change'],
|
||||
template: '<div v-if="show" data-test="filter-delete-dialog"><button data-test="dialog-preview" @click="$emit(\'preview\', { ...initialFilters, start_at: \'2026-07-15T00:00\', end_at: \'2026-07-16T00:00\' })">run</button><button data-test="dialog-confirm" @click="$emit(\'confirm\')">confirm</button><span data-test="dialog-preview-state">{{ preview ? preview.matched_count : \'none\' }}</span></div>',
|
||||
})
|
||||
|
||||
function mountView() {
|
||||
return mount(PromptAuditView, {
|
||||
global: { stubs: { AppLayout: AppLayoutStub, RuntimeOverview: RuntimeStub, EndpointPool: EndpointStub, PolicyPanel: PolicyStub, EventWorkspace: EventsStub, EventDetailDialog: DetailStub, BaseDialog: DialogStub, ConfirmDialog: ConfirmStub } },
|
||||
global: { stubs: { AppLayout: AppLayoutStub, RuntimeOverview: RuntimeStub, EndpointPool: EndpointStub, PolicyPanel: PolicyStub, EventWorkspace: EventsStub, EventDetailDialog: DetailStub, FilterDeleteDialog: FilterDeleteStub, ConfirmDialog: ConfirmStub } },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -86,28 +90,30 @@ describe('PromptAuditView', () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('[data-test="tab-events"]').attributes('aria-selected')).toBe('true')
|
||||
expect(wrapper.get('[data-test="tab-config"]').attributes('aria-selected')).toBe('false')
|
||||
expect(wrapper.get('[data-test="tab-panel-events"]').attributes('style') || '').not.toContain('display: none')
|
||||
expect(wrapper.get('[data-test="tab-panel-config"]').attributes('style') || '').toContain('display: none')
|
||||
expect(wrapper.find('[data-test="save-config"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-test="events"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-test="pass-events-disabled-notice"]').exists()).toBe(true)
|
||||
expect(wrapper.get('[data-test="tab-events"]').text()).toContain('admin.promptAudit.tabs.events')
|
||||
expect(wrapper.get('[data-test="tab-config"]').text()).toContain('admin.promptAudit.tabs.config')
|
||||
|
||||
await wrapper.get('[data-test="tab-config"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[data-test="tab-config"]').attributes('aria-selected')).toBe('true')
|
||||
expect(wrapper.get('[data-test="tab-events"]').attributes('aria-selected')).toBe('false')
|
||||
expect(wrapper.get('[data-test="tab-panel-config"]').attributes('style') || '').not.toContain('display: none')
|
||||
expect(wrapper.get('[data-test="tab-panel-events"]').attributes('style') || '').toContain('display: none')
|
||||
expect(wrapper.find('[data-test="save-config"]').exists()).toBe(true)
|
||||
expect(wrapper.get('[data-test="tab-events"]').text()).toContain('admin.promptAudit.tabs.events')
|
||||
expect(wrapper.get('[data-test="tab-config"]').text()).toContain('admin.promptAudit.tabs.config')
|
||||
|
||||
await wrapper.get('[data-test="tab-events"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[data-test="tab-events"]').attributes('aria-selected')).toBe('true')
|
||||
expect(wrapper.get('[data-test="tab-panel-config"]').attributes('style') || '').toContain('display: none')
|
||||
expect(wrapper.get('[data-test="tab-panel-events"]').attributes('style') || '').not.toContain('display: none')
|
||||
expect(wrapper.find('[data-test="save-config"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-test="events"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-test="pass-events-disabled-notice"]').exists()).toBe(true)
|
||||
|
||||
await wrapper.get('[data-test="pass-events-disabled-notice"] button').trigger('click')
|
||||
expect(wrapper.get('[data-test="tab-config"]').attributes('aria-selected')).toBe('true')
|
||||
|
||||
await wrapper.get('[data-test="tab-config"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-test="save-config"]').exists()).toBe(true)
|
||||
expect(wrapper.get('[data-test="tab-panel-config"]').attributes('style') || '').not.toContain('display: none')
|
||||
})
|
||||
@@ -115,6 +121,7 @@ describe('PromptAuditView', () => {
|
||||
it('requires confirmation for blocking and disables it when audit is turned off', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-test="tab-config"]').trigger('click')
|
||||
await wrapper.get('[data-test="blocking-toggle"]').trigger('click')
|
||||
expect(wrapper.find('[data-test="confirm"]').exists()).toBe(true)
|
||||
await wrapper.get('[data-test="confirm-action"]').trigger('click')
|
||||
@@ -128,6 +135,7 @@ describe('PromptAuditView', () => {
|
||||
it('clears plaintext token state after a successful save', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-test="tab-config"]').trigger('click')
|
||||
await wrapper.get('[data-test="inject-secret"]').trigger('click')
|
||||
expect(wrapper.text()).toContain('admin.promptAudit.saveBar.dirty')
|
||||
await wrapper.get('[data-test="save-config"]').trigger('click')
|
||||
@@ -141,22 +149,31 @@ describe('PromptAuditView', () => {
|
||||
it('reports real probe progress/results and invalidates filter confirmation when filters change', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-test="tab-config"]').trigger('click')
|
||||
await wrapper.get('[data-test="probe"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(mocks.probeEndpoint).toHaveBeenCalledOnce()
|
||||
expect((wrapper.getComponent(EndpointStub).props('probeResults') as Record<string, unknown>)).toHaveProperty('guard-1')
|
||||
|
||||
await wrapper.get('[data-test="tab-events"]').trigger('click')
|
||||
await wrapper.get('[data-test="preview"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-test="dialog"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-test="filter-delete-dialog"]').exists()).toBe(true)
|
||||
expect(mocks.previewDelete).not.toHaveBeenCalled()
|
||||
await wrapper.get('[data-test="dialog-preview"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(mocks.previewDelete).toHaveBeenCalledOnce()
|
||||
expect(wrapper.get('[data-test="dialog-preview-state"]').text()).toBe('2')
|
||||
await wrapper.get('[data-test="change-filter"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-test="dialog"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-test="filter-delete-dialog"]').exists()).toBe(true)
|
||||
expect(wrapper.get('[data-test="dialog-preview-state"]').text()).toBe('none')
|
||||
})
|
||||
|
||||
it('uses native labeled switches and a responsive fixed save surface', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-test="tab-config"]').trigger('click')
|
||||
const switches = wrapper.findAll('[role="switch"]')
|
||||
expect(switches).toHaveLength(3)
|
||||
expect(switches.every((item) => Boolean(item.attributes('aria-label')))).toBe(true)
|
||||
@@ -181,11 +198,18 @@ describe('PromptAuditView', () => {
|
||||
|
||||
await wrapper.get('[data-test="preview"]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-test="confirm-filter-delete"]').trigger('click')
|
||||
await wrapper.get('[data-test="dialog-preview"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(mocks.deleteEventsByFilter).toHaveBeenCalledWith(expect.any(Object), expect.objectContaining({
|
||||
expect(mocks.previewDelete).toHaveBeenCalledWith(expect.objectContaining({ start_at: '2026-07-15T00:00', end_at: '2026-07-16T00:00' }))
|
||||
await wrapper.get('[data-test="dialog-confirm"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(mocks.deleteEventsByFilter).toHaveBeenCalledWith(expect.objectContaining({
|
||||
start_at: '2026-07-15T00:00',
|
||||
end_at: '2026-07-16T00:00',
|
||||
}), expect.objectContaining({
|
||||
snapshot_max_id: 10,
|
||||
confirmation_token: 'opaque-confirmation',
|
||||
}))
|
||||
expect(wrapper.find('[data-test="filter-delete-dialog"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,8 +4,10 @@ import { mount } from '@vue/test-utils'
|
||||
import EndpointPool from '../components/EndpointPool.vue'
|
||||
import PolicyPanel from '../components/PolicyPanel.vue'
|
||||
import EventWorkspace from '../components/EventWorkspace.vue'
|
||||
import type { PromptAuditDraft, PromptAuditEndpointDraft, PromptAuditEvent } from '../types'
|
||||
import { emptyEventFilters, SCANNER_CATALOG } from '../viewModel'
|
||||
import EventDetailDialog from '../components/EventDetailDialog.vue'
|
||||
import FilterDeleteDialog from '../components/FilterDeleteDialog.vue'
|
||||
import type { PromptAuditDraft, PromptAuditEndpointDraft, PromptAuditEvent, PromptEventFilters } from '../types'
|
||||
import { emptyEventFilters, resolveDeleteRangeFilters, SCANNER_CATALOG } from '../viewModel'
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
@@ -58,7 +60,7 @@ describe('Prompt Audit components', () => {
|
||||
props: { draft, groups: [{ id: 1, name: 'Alpha', platform: 'openai', status: 'active' }, { id: 2, name: 'Beta', platform: 'claude', status: 'inactive' }] },
|
||||
})
|
||||
expect(wrapper.text()).toContain('99')
|
||||
expect(wrapper.findAll('input[type="checkbox"]').filter((input) => SCANNER_CATALOG.some((scanner) => input.attributes('aria-label') === scanner.label))).toHaveLength(9)
|
||||
expect(wrapper.findAll('input[type="checkbox"]').filter((input) => SCANNER_CATALOG.some((scanner) => input.attributes('aria-label') === `admin.promptAudit.scanners.${scanner.id}`))).toHaveLength(9)
|
||||
await wrapper.get('[aria-label="admin.promptAudit.policy.searchGroups"]').setValue('Beta')
|
||||
expect(wrapper.text()).toContain('Beta')
|
||||
expect(wrapper.text()).not.toContain('Alpha')
|
||||
@@ -67,10 +69,10 @@ describe('Prompt Audit components', () => {
|
||||
expect(emitted.worker_count).toBe(6)
|
||||
})
|
||||
|
||||
it('keeps identity fields separate, supports selection, and gates filter deletion on a time range', async () => {
|
||||
it('keeps identity fields separate, supports selection, and opens filter deletion from the toolbar', async () => {
|
||||
const event: PromptAuditEvent = {
|
||||
id: 1, job_id: 1, decision: 'critical', risk_level: 'critical', action: 'Block', categories: ['pii'], matched_scanners: ['pii'], scanner_scores: { pii: 1 }, scanner_evidence: { pii: 'redacted' }, scanner_backend: 'qwen3guard-openai', scanner_version: '1', guard_endpoint_id: 'guard-1', policy_id: 'priority', policy_version: 1, config_version: 1, chunk_total: 1, latency_ms: 10, issue_summaries: [], created_at: '2026-07-16T00:00:00Z',
|
||||
snapshot: { request_id: 'req-1', user_id: 1, username: 'alice', user_email: 'alice@example.test', api_key_id: 2, api_key_name: 'alice-key', group_id: 3, group_name: 'Alpha', provider: 'openai', endpoint: '/v1/chat/completions', protocol: 'openai_chat', model: 'gpt-test', prompt_hash: 'a'.repeat(64), redacted_preview: 'redacted preview', prompt_length: 10, message_count: 1, stage: 'http' },
|
||||
snapshot: { request_id: 'req-1', user_id: 1, username: 'alice', user_email: 'alice@example.test', api_key_id: 2, api_key_name: 'alice-key', group_id: 3, group_name: 'Alpha', provider: 'openai', endpoint: '/v1/chat/completions', protocol: 'openai_chat', model: 'gpt-test', prompt_hash: 'a'.repeat(64), redacted_preview: 'redacted preview', full_prompt: 'full prompt text', prompt_length: 10, message_count: 1, stage: 'http' },
|
||||
}
|
||||
const wrapper = mount(EventWorkspace, {
|
||||
props: { events: [event], total: 1, page: 1, pageSize: 20, filters: emptyEventFilters(), selectedIds: [], loading: false, error: '' },
|
||||
@@ -79,11 +81,143 @@ describe('Prompt Audit components', () => {
|
||||
expect(wrapper.text()).toContain('alice')
|
||||
expect(wrapper.text()).toContain('alice@example.test')
|
||||
expect(wrapper.text()).toContain('alice-key')
|
||||
expect(wrapper.get('[data-test="filter-delete"]').attributes()).toHaveProperty('disabled')
|
||||
await wrapper.get('[aria-label="admin.promptAudit.events.startAt"]').setValue('2026-07-15T00:00')
|
||||
await wrapper.get('[aria-label="admin.promptAudit.events.endAt"]').setValue('2026-07-16T00:00')
|
||||
expect(wrapper.text()).toContain('admin.promptAudit.decisions.critical · admin.promptAudit.riskLevels.critical')
|
||||
expect(wrapper.text()).toContain('admin.promptAudit.scanners.pii')
|
||||
expect(wrapper.get('[data-test="filter-delete"]').attributes()).not.toHaveProperty('disabled')
|
||||
await wrapper.get('[data-test="filter-delete"]').trigger('click')
|
||||
expect(wrapper.emitted('preview-delete')).toHaveLength(1)
|
||||
await wrapper.get('[aria-label="admin.promptAudit.events.selectEvent"]').setValue(true)
|
||||
expect(wrapper.emitted('selection')?.at(-1)?.[0]).toEqual([1])
|
||||
})
|
||||
|
||||
it('resolves delete range presets to an epoch start and a cutoff end', () => {
|
||||
const now = Date.parse('2026-07-17T12:00:00.000Z')
|
||||
const sevenDays = resolveDeleteRangeFilters(emptyEventFilters(), '7d', now)
|
||||
expect(sevenDays.start_at).toBe('1970-01-01T00:00:00.000Z')
|
||||
expect(sevenDays.end_at).toBe('2026-07-10T12:00:00.000Z')
|
||||
const all = resolveDeleteRangeFilters(emptyEventFilters(), 'all', now)
|
||||
expect(all.start_at).toBe('1970-01-01T00:00:00.000Z')
|
||||
expect(all.end_at).toBe('2026-07-17T12:00:00.000Z')
|
||||
const customSource = { ...emptyEventFilters(), start_at: '2026-07-01T00:00', end_at: '2026-07-02T00:00' }
|
||||
expect(resolveDeleteRangeFilters(customSource, 'custom', now)).toEqual(customSource)
|
||||
})
|
||||
|
||||
it('drives filter deletion through presets, custom validation, preview, and confirm', async () => {
|
||||
const wrapper = mount(FilterDeleteDialog, {
|
||||
props: { show: true, initialFilters: emptyEventFilters(), preview: null, previewing: false, deleting: false },
|
||||
global: { stubs: { BaseDialog: DialogStub } },
|
||||
})
|
||||
expect(wrapper.get<HTMLInputElement>('[data-test="range-preset-7d"]').element.checked).toBe(true)
|
||||
expect(wrapper.find('[data-test="custom-range"]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-test="delete-preview-empty"]').exists()).toBeTruthy()
|
||||
expect(wrapper.get('[data-test="confirm-filter-delete"]').attributes()).toHaveProperty('disabled')
|
||||
|
||||
await wrapper.get('[data-test="range-preset-30d"]').setValue()
|
||||
expect(wrapper.emitted('criteria-change')?.length).toBeGreaterThan(0)
|
||||
await wrapper.get('[data-test="delete-risk"]').setValue('high')
|
||||
await wrapper.get('[data-test="run-delete-preview"]').trigger('click')
|
||||
const presetPreview = wrapper.emitted('preview')?.at(-1)?.[0] as PromptEventFilters
|
||||
expect(presetPreview.risk_level).toBe('high')
|
||||
expect(presetPreview.start_at).toBe('1970-01-01T00:00:00.000Z')
|
||||
expect(Date.now() - new Date(presetPreview.end_at).getTime()).toBeGreaterThanOrEqual(30 * 24 * 60 * 60 * 1000)
|
||||
|
||||
await wrapper.get('[data-test="range-preset-custom"]').setValue()
|
||||
expect(wrapper.find('[data-test="custom-range"]').exists()).toBe(true)
|
||||
expect(wrapper.get('[data-test="run-delete-preview"]').attributes()).toHaveProperty('disabled')
|
||||
await wrapper.get('[data-test="custom-range"] [aria-label="admin.promptAudit.events.startAt"]').setValue('2026-07-01T00:00')
|
||||
await wrapper.get('[data-test="custom-range"] [aria-label="admin.promptAudit.events.endAt"]').setValue('2026-07-02T00:00')
|
||||
expect(wrapper.get('[data-test="run-delete-preview"]').attributes()).not.toHaveProperty('disabled')
|
||||
await wrapper.get('[data-test="run-delete-preview"]').trigger('click')
|
||||
const customPreview = wrapper.emitted('preview')?.at(-1)?.[0] as PromptEventFilters
|
||||
expect(customPreview.start_at).toBe('2026-07-01T00:00')
|
||||
expect(customPreview.end_at).toBe('2026-07-02T00:00')
|
||||
|
||||
await wrapper.setProps({
|
||||
preview: { matched_count: 3, filter_summary: {}, snapshot_max_id: 9, filter_hash: 'b'.repeat(64), confirmation_token: 'tok', expires_at: '2026-07-16T00:05:00Z' },
|
||||
})
|
||||
expect(wrapper.get('[data-test="delete-preview-result"]').text()).toContain('admin.promptAudit.events.filterDeleteCount')
|
||||
expect(wrapper.get('[data-test="confirm-filter-delete"]').attributes()).not.toHaveProperty('disabled')
|
||||
await wrapper.get('[data-test="confirm-filter-delete"]').trigger('click')
|
||||
expect(wrapper.emitted('confirm')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('inherits an explicit list-filter range as the custom preset', async () => {
|
||||
const initialFilters = { ...emptyEventFilters(), start_at: '2026-07-01T00:00', end_at: '2026-07-02T00:00', decision: 'critical' }
|
||||
const wrapper = mount(FilterDeleteDialog, {
|
||||
props: { show: true, initialFilters, preview: null, previewing: false, deleting: false },
|
||||
global: { stubs: { BaseDialog: DialogStub } },
|
||||
})
|
||||
expect(wrapper.get<HTMLInputElement>('[data-test="range-preset-custom"]').element.checked).toBe(true)
|
||||
expect(wrapper.get<HTMLInputElement>('[data-test="custom-range"] [aria-label="admin.promptAudit.events.startAt"]').element.value).toBe('2026-07-01T00:00')
|
||||
expect(wrapper.get<HTMLSelectElement>('[data-test="delete-decision"]').element.value).toBe('critical')
|
||||
expect(wrapper.get('[data-test="run-delete-preview"]').attributes()).not.toHaveProperty('disabled')
|
||||
})
|
||||
|
||||
it('shows the full unredacted prompt and structured guard return on the risks tab', async () => {
|
||||
const event: PromptAuditEvent = {
|
||||
id: 1, job_id: 1, decision: 'critical', risk_level: 'critical', action: 'Block',
|
||||
categories: ['sexual_content_or_sexual_acts'], matched_scanners: ['sexual_content_or_sexual_acts'],
|
||||
scanner_scores: { sexual_content_or_sexual_acts: 1 },
|
||||
scanner_evidence: { sexual_content_or_sexual_acts: 'Sexual Content or Sexual Acts' },
|
||||
scanner_backend: 'qwen3guard-openai', scanner_version: 'qwen3guard', guard_endpoint_id: 'guard-1',
|
||||
policy_id: 'priority', policy_version: 1, config_version: 1, chunk_total: 1, latency_ms: 12,
|
||||
issue_summaries: [{
|
||||
category: 'sexual_content_or_sexual_acts', scanner_id: 'sexual_content_or_sexual_acts',
|
||||
title: '性内容或性行为', description: 'Sexual content or sexual acts', severity: 'critical',
|
||||
severity_label: '严重', action: 'Block', action_label: '阻止',
|
||||
code: 'prompt_audit_sexual_content_or_sexual_acts', score: 1,
|
||||
evidence: 'Sexual Content or Sexual Acts', evidence_hash: 'abc',
|
||||
}],
|
||||
created_at: '2026-07-16T00:00:00Z',
|
||||
snapshot: {
|
||||
request_id: 'req-1', user_id: 1, username: 'alice', user_email: 'alice@example.test',
|
||||
api_key_id: 2, api_key_name: 'alice-key', group_id: 3, group_name: 'Alpha', provider: 'openai',
|
||||
endpoint: '/v1/chat/completions', protocol: 'openai_chat', model: 'gpt-test',
|
||||
prompt_hash: 'a'.repeat(64), redacted_preview: 'redacted prompt body', full_prompt: 'complete unmasked prompt body', prompt_length: 20,
|
||||
message_count: 1, stage: 'http',
|
||||
},
|
||||
}
|
||||
const wrapper = mount(EventDetailDialog, {
|
||||
props: { show: true, event, loading: false },
|
||||
global: { stubs: { BaseDialog: DialogStub } },
|
||||
})
|
||||
const panel = wrapper.get('[data-test="event-detail-tab-panel"]')
|
||||
expect(panel.classes()).toContain('h-[min(62vh,36rem)]')
|
||||
expect(panel.classes()).toContain('overflow-y-auto')
|
||||
|
||||
const riskTab = wrapper.findAll('[role="tab"]').find((tab) => tab.text().includes('admin.promptAudit.events.tabs.risks'))
|
||||
expect(riskTab).toBeTruthy()
|
||||
await riskTab!.trigger('click')
|
||||
expect(wrapper.get('[data-test="event-detail-tab-panel"]').classes()).toContain('h-[min(62vh,36rem)]')
|
||||
expect(wrapper.get('[data-test="risk-prompt-preview"]').text()).toContain('complete unmasked prompt body')
|
||||
expect(wrapper.get('[data-test="risk-prompt-preview"]').text()).not.toContain('redacted prompt body')
|
||||
expect(wrapper.get('[data-test="risk-prompt-full"]').classes()).toContain('overflow-auto')
|
||||
expect(wrapper.get('[data-test="risk-guard-return"]').text()).toContain('"decision": "admin.promptAudit.decisions.critical"')
|
||||
expect(wrapper.get('[data-test="risk-guard-return"]').text()).toContain('admin.promptAudit.scanners.sexual_content_or_sexual_acts')
|
||||
expect(wrapper.get('[data-test="risk-issue"]').text()).toContain('admin.promptAudit.scanners.sexual_content_or_sexual_acts')
|
||||
})
|
||||
|
||||
it('falls back to the redacted preview for events stored before full prompts were kept', async () => {
|
||||
const event: PromptAuditEvent = {
|
||||
id: 2, job_id: 2, decision: 'flag', risk_level: 'medium', action: 'Warn',
|
||||
categories: ['pii'], matched_scanners: ['pii'], scanner_scores: {}, scanner_evidence: {},
|
||||
scanner_backend: 'qwen3guard-openai', scanner_version: '1', guard_endpoint_id: 'guard-1',
|
||||
policy_id: 'priority', policy_version: 1, config_version: 1, chunk_total: 1, latency_ms: 5,
|
||||
issue_summaries: [], created_at: '2026-07-16T00:00:00Z',
|
||||
snapshot: {
|
||||
request_id: 'req-2', user_id: 1, username: 'bob', user_email: '', api_key_id: 2,
|
||||
api_key_name: 'bob-key', group_id: 3, group_name: 'Alpha', provider: 'openai',
|
||||
endpoint: '/v1/chat/completions', protocol: 'openai_chat', model: 'gpt-test',
|
||||
prompt_hash: 'b'.repeat(64), redacted_preview: 'legacy redacted preview', full_prompt: '', prompt_length: 20,
|
||||
message_count: 1, stage: 'http',
|
||||
},
|
||||
}
|
||||
const wrapper = mount(EventDetailDialog, {
|
||||
props: { show: true, event, loading: false },
|
||||
global: { stubs: { BaseDialog: DialogStub } },
|
||||
})
|
||||
const riskTab = wrapper.findAll('[role="tab"]').find((tab) => tab.text().includes('admin.promptAudit.events.tabs.risks'))
|
||||
await riskTab!.trigger('click')
|
||||
expect(wrapper.get('[data-test="risk-prompt-full"]').text()).toContain('legacy redacted preview')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,53 +1,76 @@
|
||||
<template>
|
||||
<BaseDialog :show="show" :title="t('admin.promptAudit.events.detailTitle')" width="extra-wide" @close="$emit('close')">
|
||||
<div v-if="loading" class="py-12 text-center text-sm text-gray-500" aria-busy="true">{{ t('common.loading') }}</div>
|
||||
<div v-else-if="event" class="space-y-5">
|
||||
<div v-else-if="event" class="flex flex-col">
|
||||
<div class="flex flex-wrap gap-2 border-b border-gray-200 pb-3 dark:border-dark-700" role="tablist">
|
||||
<button v-for="tab in tabs" :key="tab" type="button" role="tab" :aria-selected="activeTab === tab" class="rounded-md px-3 py-1.5 text-sm" :class="activeTab === tab ? 'bg-primary-50 text-primary-700 dark:bg-primary-950/40 dark:text-primary-300' : 'text-gray-600 dark:text-dark-300'" @click="activeTab = tab">
|
||||
{{ t(`admin.promptAudit.events.tabs.${tab}`) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'summary'" class="grid gap-5 lg:grid-cols-2">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.events.redactedPreview') }}</h4>
|
||||
<pre class="mt-2 max-h-56 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-gray-50 p-4 text-sm text-gray-700 dark:bg-dark-900 dark:text-dark-200">{{ event.snapshot.redacted_preview || '—' }}</pre>
|
||||
<!-- Fixed panel height so switching tabs does not resize the dialog -->
|
||||
<div class="mt-5 h-[min(62vh,36rem)] overflow-y-auto" data-test="event-detail-tab-panel">
|
||||
<div v-show="activeTab === 'summary'" class="grid gap-5 lg:grid-cols-2" role="tabpanel">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.events.promptFull') }}</h4>
|
||||
<pre class="mt-2 max-h-[min(46vh,26rem)] overflow-auto whitespace-pre-wrap break-words rounded-lg bg-gray-50 p-4 text-sm text-gray-700 dark:bg-dark-900 dark:text-dark-200" data-test="summary-prompt-full">{{ displayPrompt(event) }}</pre>
|
||||
</div>
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
|
||||
<dt class="text-gray-500">{{ t('admin.promptAudit.events.decision') }}</dt><dd class="font-medium text-gray-900 dark:text-white">{{ formatDecisionAction(event.decision, event.action) }}</dd>
|
||||
<dt class="text-gray-500">{{ t('admin.promptAudit.events.user') }}</dt><dd>{{ event.snapshot.username || '—' }}</dd>
|
||||
<dt class="text-gray-500">{{ t('admin.promptAudit.events.email') }}</dt><dd>{{ event.snapshot.user_email || '—' }}</dd>
|
||||
<dt class="text-gray-500">{{ t('admin.promptAudit.events.apiKey') }}</dt><dd>{{ event.snapshot.api_key_name || '—' }}</dd>
|
||||
<dt class="text-gray-500">{{ t('admin.promptAudit.events.group') }}</dt><dd>{{ event.snapshot.group_name || '—' }}</dd>
|
||||
<dt class="text-gray-500">{{ t('admin.promptAudit.events.model') }}</dt><dd>{{ event.snapshot.model || '—' }}</dd>
|
||||
<dt class="text-gray-500">{{ t('admin.promptAudit.events.categories') }}</dt><dd>{{ formatCategories(event.categories) }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
|
||||
<dt class="text-gray-500">{{ t('admin.promptAudit.events.decision') }}</dt><dd class="font-medium text-gray-900 dark:text-white">{{ event.decision }} · {{ event.action }}</dd>
|
||||
<dt class="text-gray-500">{{ t('admin.promptAudit.events.user') }}</dt><dd>{{ event.snapshot.username || '—' }}</dd>
|
||||
<dt class="text-gray-500">{{ t('admin.promptAudit.events.email') }}</dt><dd>{{ event.snapshot.user_email || '—' }}</dd>
|
||||
<dt class="text-gray-500">{{ t('admin.promptAudit.events.apiKey') }}</dt><dd>{{ event.snapshot.api_key_name || '—' }}</dd>
|
||||
<dt class="text-gray-500">{{ t('admin.promptAudit.events.group') }}</dt><dd>{{ event.snapshot.group_name || '—' }}</dd>
|
||||
<dt class="text-gray-500">{{ t('admin.promptAudit.events.model') }}</dt><dd>{{ event.snapshot.model || '—' }}</dd>
|
||||
<dt class="text-gray-500">{{ t('admin.promptAudit.events.categories') }}</dt><dd>{{ event.categories.join(', ') || '—' }}</dd>
|
||||
|
||||
<div v-show="activeTab === 'risks'" class="space-y-5" role="tabpanel">
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<section data-test="risk-prompt-preview">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.events.promptFull') }}</h4>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.events.promptFullHint') }}</p>
|
||||
<pre class="mt-2 h-[min(46vh,26rem)] overflow-auto whitespace-pre-wrap break-words rounded-lg bg-gray-50 p-4 text-sm text-gray-700 dark:bg-dark-900 dark:text-dark-200" data-test="risk-prompt-full">{{ displayPrompt(event) }}</pre>
|
||||
</section>
|
||||
<section data-test="risk-guard-return">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.events.guardReturn') }}</h4>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.events.guardReturnHint') }}</p>
|
||||
<pre class="mt-2 h-[min(46vh,26rem)] overflow-auto whitespace-pre-wrap break-words rounded-lg bg-gray-50 p-4 font-mono text-xs text-gray-700 dark:bg-dark-900 dark:text-dark-200">{{ formatGuardReturn(event) }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.events.riskSummaries') }}</h4>
|
||||
<article v-for="issue in event.issue_summaries" :key="`${issue.scanner_id}-${issue.code}`" class="border-l-2 border-red-400 pl-4" data-test="risk-issue">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h5 class="font-medium text-gray-900 dark:text-white">{{ issueTitle(issue) }}</h5>
|
||||
<span class="text-xs text-red-600 dark:text-red-300">{{ issueSeverity(issue) }} · {{ issueAction(issue) }}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-dark-300">{{ issueDescription(issue) }}</p>
|
||||
<dl class="mt-2 grid gap-1 text-xs text-gray-500 dark:text-dark-400 sm:grid-cols-2">
|
||||
<div><dt class="inline text-gray-400">{{ t('admin.promptAudit.events.categories') }} · </dt><dd class="inline">{{ translateCategory(issue.category || issue.scanner_id) }}</dd></div>
|
||||
<div><dt class="inline text-gray-400">{{ t('admin.promptAudit.events.score') }} · </dt><dd class="inline">{{ issue.score }}</dd></div>
|
||||
<div class="sm:col-span-2"><dt class="inline text-gray-400">{{ t('admin.promptAudit.events.evidence') }} · </dt><dd class="inline break-words">{{ issue.evidence ? translateEvidence(issue.evidence) : '—' }}</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
<p v-if="event.issue_summaries.length === 0" class="py-6 text-center text-sm text-gray-500">{{ t('admin.promptAudit.events.noRisks') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl v-show="activeTab === 'technical'" class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 text-sm" role="tabpanel">
|
||||
<dt class="text-gray-500">{{ t('admin.promptAudit.events.requestId') }}</dt><dd class="break-all font-mono">{{ event.snapshot.request_id || '—' }}</dd>
|
||||
<dt class="text-gray-500">{{ t('admin.promptAudit.events.promptHash') }}</dt><dd class="break-all font-mono">{{ event.snapshot.prompt_hash }}</dd>
|
||||
<dt class="text-gray-500">{{ t('admin.promptAudit.events.technical.scanner') }}</dt><dd>{{ event.scanner_backend }} · {{ event.scanner_version }}</dd>
|
||||
<dt class="text-gray-500">{{ t('admin.promptAudit.events.technical.policy') }}</dt><dd>{{ event.policy_id }} · v{{ event.policy_version }}</dd>
|
||||
<dt class="text-gray-500">{{ t('admin.promptAudit.events.technical.guardEndpoint') }}</dt><dd>{{ event.guard_endpoint_id }}</dd>
|
||||
<dt class="text-gray-500">{{ t('admin.promptAudit.events.technical.config') }}</dt><dd>v{{ event.config_version }}</dd>
|
||||
<dt class="text-gray-500">{{ t('admin.promptAudit.events.technical.chunks') }}</dt><dd>{{ event.chunk_total }}</dd>
|
||||
<dt class="text-gray-500">{{ t('admin.promptAudit.events.technical.latency') }}</dt><dd>{{ event.latency_ms }} ms</dd>
|
||||
<dt class="text-gray-500">{{ t('admin.promptAudit.events.stage') }}</dt><dd>{{ event.snapshot.stage || 'http' }}</dd>
|
||||
<dt class="text-gray-500">{{ t('admin.promptAudit.events.technical.protocol') }}</dt><dd>{{ event.snapshot.protocol }} · {{ event.snapshot.endpoint }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTab === 'risks'" class="space-y-3">
|
||||
<article v-for="issue in event.issue_summaries" :key="`${issue.scanner_id}-${issue.code}`" class="border-l-2 border-red-400 pl-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h4 class="font-medium text-gray-900 dark:text-white">{{ issue.title }}</h4>
|
||||
<span class="text-xs uppercase text-red-600 dark:text-red-300">{{ issue.severity_label }} · {{ issue.action_label }}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-dark-300">{{ issue.description }}</p>
|
||||
<p class="mt-2 break-words text-xs text-gray-500 dark:text-dark-400">{{ issue.scanner_id }} · {{ issue.code }} · {{ issue.score }} · {{ issue.evidence }}</p>
|
||||
</article>
|
||||
<p v-if="event.issue_summaries.length === 0" class="py-8 text-center text-sm text-gray-500">{{ t('admin.promptAudit.events.noRisks') }}</p>
|
||||
</div>
|
||||
|
||||
<dl v-else class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 text-sm">
|
||||
<dt class="text-gray-500">Request ID</dt><dd class="break-all font-mono">{{ event.snapshot.request_id || '—' }}</dd>
|
||||
<dt class="text-gray-500">Prompt SHA-256</dt><dd class="break-all font-mono">{{ event.snapshot.prompt_hash }}</dd>
|
||||
<dt class="text-gray-500">Scanner</dt><dd>{{ event.scanner_backend }} · {{ event.scanner_version }}</dd>
|
||||
<dt class="text-gray-500">Policy</dt><dd>{{ event.policy_id }} · v{{ event.policy_version }}</dd>
|
||||
<dt class="text-gray-500">Guard endpoint</dt><dd>{{ event.guard_endpoint_id }}</dd>
|
||||
<dt class="text-gray-500">Config</dt><dd>v{{ event.config_version }}</dd>
|
||||
<dt class="text-gray-500">Chunks</dt><dd>{{ event.chunk_total }}</dd>
|
||||
<dt class="text-gray-500">Latency</dt><dd>{{ event.latency_ms }} ms</dd>
|
||||
<dt class="text-gray-500">{{ t('admin.promptAudit.events.stage') }}</dt><dd>{{ event.snapshot.stage || 'http' }}</dd>
|
||||
<dt class="text-gray-500">Protocol</dt><dd>{{ event.snapshot.protocol }} · {{ event.snapshot.endpoint }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
@@ -56,7 +79,8 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import type { PromptAuditEvent } from '../types'
|
||||
import type { PromptAuditEvent, PromptIssueSummary } from '../types'
|
||||
import { SCANNER_CATALOG } from '../viewModel'
|
||||
|
||||
const props = defineProps<{ show: boolean; event: PromptAuditEvent | null; loading: boolean }>()
|
||||
defineEmits<{ (event: 'close'): void }>()
|
||||
@@ -64,4 +88,69 @@ const { t } = useI18n()
|
||||
const tabs = ['summary', 'risks', 'technical'] as const
|
||||
const activeTab = ref<(typeof tabs)[number]>('summary')
|
||||
watch(() => props.event?.id, () => { activeTab.value = 'summary' })
|
||||
|
||||
const DECISIONS = new Set(['pass', 'flag', 'critical'])
|
||||
const ACTIONS = new Set(['Allow', 'Warn', 'Block'])
|
||||
const RISK_LEVELS = new Set(['low', 'medium', 'high', 'critical'])
|
||||
|
||||
function displayPrompt(event: PromptAuditEvent): string {
|
||||
return event.snapshot.full_prompt || event.snapshot.redacted_preview || '—'
|
||||
}
|
||||
|
||||
function formatDecisionAction(decision: string, action: string): string {
|
||||
const decisionLabel = DECISIONS.has(decision) ? t(`admin.promptAudit.decisions.${decision}`) : decision
|
||||
const actionLabel = ACTIONS.has(action) ? t(`admin.promptAudit.actions.${action}`) : action
|
||||
return `${decisionLabel} · ${actionLabel}`
|
||||
}
|
||||
function translateCategory(category: string): string {
|
||||
return SCANNER_CATALOG.some((scanner) => scanner.id === category)
|
||||
? t(`admin.promptAudit.scanners.${category}`)
|
||||
: category
|
||||
}
|
||||
function formatCategories(categories: string[]): string {
|
||||
if (!categories.length) return '—'
|
||||
return categories.map(translateCategory).join(', ')
|
||||
}
|
||||
function translateEvidence(value: string): string {
|
||||
const byId = SCANNER_CATALOG.find((scanner) => scanner.id === value)
|
||||
if (byId) return t(`admin.promptAudit.scanners.${byId.id}`)
|
||||
const byLabel = SCANNER_CATALOG.find((scanner) => scanner.label === value)
|
||||
if (byLabel) return t(`admin.promptAudit.scanners.${byLabel.id}`)
|
||||
return value
|
||||
}
|
||||
function formatGuardReturn(event: PromptAuditEvent): string {
|
||||
const evidence: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(event.scanner_evidence || {})) {
|
||||
evidence[key] = translateEvidence(value)
|
||||
}
|
||||
return JSON.stringify({
|
||||
decision: DECISIONS.has(event.decision) ? t(`admin.promptAudit.decisions.${event.decision}`) : event.decision,
|
||||
risk_level: RISK_LEVELS.has(event.risk_level) ? t(`admin.promptAudit.riskLevels.${event.risk_level}`) : event.risk_level,
|
||||
action: ACTIONS.has(event.action) ? t(`admin.promptAudit.actions.${event.action}`) : event.action,
|
||||
categories: event.categories.map(translateCategory),
|
||||
matched_scanners: event.matched_scanners.map(translateCategory),
|
||||
scanner_scores: event.scanner_scores,
|
||||
scanner_evidence: evidence,
|
||||
scanner_backend: event.scanner_backend,
|
||||
scanner_version: event.scanner_version,
|
||||
guard_endpoint_id: event.guard_endpoint_id,
|
||||
chunk_total: event.chunk_total,
|
||||
latency_ms: event.latency_ms,
|
||||
}, null, 2)
|
||||
}
|
||||
function issueTitle(issue: PromptIssueSummary): string {
|
||||
return translateCategory(issue.category || issue.scanner_id) || issue.title
|
||||
}
|
||||
function issueDescription(issue: PromptIssueSummary): string {
|
||||
const category = issue.category || issue.scanner_id
|
||||
const key = `admin.promptAudit.scannerDescriptions.${category}`
|
||||
const label = t(key)
|
||||
return label === key ? issue.description : label
|
||||
}
|
||||
function issueSeverity(issue: PromptIssueSummary): string {
|
||||
return RISK_LEVELS.has(issue.severity) ? t(`admin.promptAudit.riskLevels.${issue.severity}`) : issue.severity_label || issue.severity
|
||||
}
|
||||
function issueAction(issue: PromptIssueSummary): string {
|
||||
return ACTIONS.has(issue.action) ? t(`admin.promptAudit.actions.${issue.action}`) : issue.action_label || issue.action
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="selectedIds.length === 0" @click="$emit('batch-delete')">
|
||||
{{ t('admin.promptAudit.events.deleteSelected', { count: selectedIds.length }) }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger btn-sm" :disabled="!hasExplicitDeleteRange(localFilters)" data-test="filter-delete" @click="$emit('preview-delete')">
|
||||
<button type="button" class="btn btn-danger btn-sm" data-test="filter-delete" @click="$emit('preview-delete')">
|
||||
{{ t('admin.promptAudit.events.deleteByFilter') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -19,21 +19,28 @@
|
||||
<label class="text-xs text-gray-600 dark:text-dark-200">
|
||||
<span>{{ t('admin.promptAudit.events.decision') }}</span>
|
||||
<select v-model="localFilters.decision" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.decision')" @change="filtersChanged">
|
||||
<option value="">{{ t('common.all') }}</option><option value="pass">pass</option><option value="flag">flag</option><option value="critical">critical</option>
|
||||
<option value="">{{ t('common.all') }}</option>
|
||||
<option value="pass">{{ t('admin.promptAudit.decisions.pass') }}</option>
|
||||
<option value="flag">{{ t('admin.promptAudit.decisions.flag') }}</option>
|
||||
<option value="critical">{{ t('admin.promptAudit.decisions.critical') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-dark-200">
|
||||
<span>{{ t('admin.promptAudit.events.risk') }}</span>
|
||||
<select v-model="localFilters.risk_level" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.risk')" @change="filtersChanged">
|
||||
<option value="">{{ t('common.all') }}</option><option value="low">low</option><option value="medium">medium</option><option value="high">high</option><option value="critical">critical</option>
|
||||
<option value="">{{ t('common.all') }}</option>
|
||||
<option value="low">{{ t('admin.promptAudit.riskLevels.low') }}</option>
|
||||
<option value="medium">{{ t('admin.promptAudit.riskLevels.medium') }}</option>
|
||||
<option value="high">{{ t('admin.promptAudit.riskLevels.high') }}</option>
|
||||
<option value="critical">{{ t('admin.promptAudit.riskLevels.critical') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<FilterInput v-model="localFilters.endpoint" :label="t('admin.promptAudit.events.endpoint')" @change="filtersChanged" />
|
||||
<FilterInput v-model="localFilters.group_id" :label="t('admin.promptAudit.events.groupId')" type="number" @change="filtersChanged" />
|
||||
<FilterInput v-model="localFilters.user_id" :label="t('admin.promptAudit.events.userId')" type="number" @change="filtersChanged" />
|
||||
<FilterInput v-model="localFilters.api_key_id" :label="t('admin.promptAudit.events.apiKeyId')" type="number" @change="filtersChanged" />
|
||||
<FilterInput v-model="localFilters.request_id" label="Request ID" @change="filtersChanged" />
|
||||
<FilterInput v-model="localFilters.prompt_hash" label="Prompt SHA-256" @change="filtersChanged" />
|
||||
<FilterInput v-model="localFilters.request_id" :label="t('admin.promptAudit.events.requestId')" @change="filtersChanged" />
|
||||
<FilterInput v-model="localFilters.prompt_hash" :label="t('admin.promptAudit.events.promptHash')" @change="filtersChanged" />
|
||||
<FilterInput v-model="localFilters.keyword" :label="t('admin.promptAudit.events.keyword')" @change="filtersChanged" />
|
||||
<label class="text-xs text-gray-600 dark:text-dark-200">
|
||||
<span>{{ t('admin.promptAudit.events.startAt') }}</span>
|
||||
@@ -48,8 +55,6 @@
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="resetFilters">{{ t('common.reset') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.events.deleteRangeHint') }}</p>
|
||||
|
||||
<div v-if="error" role="alert" class="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-300">{{ error }}</div>
|
||||
<div class="mt-5 overflow-x-auto rounded-xl border border-gray-200 dark:border-dark-700/60">
|
||||
<table class="min-w-[1120px] w-full text-left text-sm">
|
||||
@@ -82,8 +87,8 @@
|
||||
<p class="mt-1 text-xs text-gray-500">{{ event.snapshot.model }} · {{ event.snapshot.protocol }} · {{ event.snapshot.stage || 'http' }}</p>
|
||||
</td>
|
||||
<td class="px-3 py-3">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium" :class="decisionClass(event.decision)">{{ event.decision }} · {{ event.risk_level }}</span>
|
||||
<p class="mt-2 max-w-48 truncate text-xs text-gray-500">{{ event.categories.join(', ') || '—' }}</p>
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium" :class="decisionClass(event.decision)">{{ formatDecisionRisk(event.decision, event.risk_level) }}</span>
|
||||
<p class="mt-2 max-w-48 truncate text-xs text-gray-500" :title="formatCategories(event.categories)">{{ formatCategories(event.categories) }}</p>
|
||||
</td>
|
||||
<td class="max-w-xs px-3 py-3"><p class="line-clamp-2 break-words text-gray-600 dark:text-dark-300">{{ event.snapshot.redacted_preview || '—' }}</p></td>
|
||||
<td class="whitespace-nowrap px-3 py-3 text-right">
|
||||
@@ -103,7 +108,7 @@ import { computed, defineComponent, h, reactive, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import type { PromptAuditEvent, PromptEventFilters } from '../types'
|
||||
import { cloneData, emptyEventFilters, hasExplicitDeleteRange } from '../viewModel'
|
||||
import { cloneData, emptyEventFilters, SCANNER_CATALOG } from '../viewModel'
|
||||
|
||||
const props = defineProps<{
|
||||
events: PromptAuditEvent[]; total: number; page: number; pageSize: number
|
||||
@@ -183,4 +188,25 @@ function decisionClass(decision: string): string {
|
||||
if (decision === 'flag') return 'bg-amber-100 text-amber-700 dark:bg-amber-950/50 dark:text-amber-300'
|
||||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300'
|
||||
}
|
||||
const DECISIONS = new Set(['pass', 'flag', 'critical'])
|
||||
const RISK_LEVELS = new Set(['low', 'medium', 'high', 'critical'])
|
||||
|
||||
function translateDecision(decision: string): string {
|
||||
return DECISIONS.has(decision) ? t(`admin.promptAudit.decisions.${decision}`) : decision
|
||||
}
|
||||
function translateRiskLevel(riskLevel: string): string {
|
||||
return RISK_LEVELS.has(riskLevel) ? t(`admin.promptAudit.riskLevels.${riskLevel}`) : riskLevel
|
||||
}
|
||||
function translateCategory(category: string): string {
|
||||
return SCANNER_CATALOG.some((scanner) => scanner.id === category)
|
||||
? t(`admin.promptAudit.scanners.${category}`)
|
||||
: category
|
||||
}
|
||||
function formatDecisionRisk(decision: string, riskLevel: string): string {
|
||||
return `${translateDecision(decision)} · ${translateRiskLevel(riskLevel)}`
|
||||
}
|
||||
function formatCategories(categories: string[]): string {
|
||||
if (!categories.length) return '—'
|
||||
return categories.map(translateCategory).join(', ')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<BaseDialog :show="show" :title="t('admin.promptAudit.events.filterDeleteDialogTitle')" width="wide" @close="$emit('close')">
|
||||
<div class="space-y-5 text-sm">
|
||||
<p class="text-gray-500 dark:text-dark-300">{{ t('admin.promptAudit.events.filterDeleteDialogDesc') }}</p>
|
||||
|
||||
<fieldset>
|
||||
<legend class="text-xs font-medium text-gray-600 dark:text-dark-200">{{ t('admin.promptAudit.events.filterTimeRange') }}</legend>
|
||||
<div class="mt-2 flex flex-wrap gap-2" role="radiogroup" :aria-label="t('admin.promptAudit.events.filterTimeRange')">
|
||||
<label
|
||||
v-for="option in DELETE_RANGE_PRESETS"
|
||||
:key="option.id"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<input v-model="preset" type="radio" name="prompt-delete-range" :value="option.id" class="peer sr-only" :data-test="`range-preset-${option.id}`" @change="criteriaChanged" />
|
||||
<span class="inline-flex items-center rounded-full border border-gray-200 px-3 py-1.5 text-xs font-medium text-gray-600 transition-colors peer-checked:border-red-500 peer-checked:bg-red-50 peer-checked:text-red-700 peer-focus-visible:ring-2 peer-focus-visible:ring-red-500/30 dark:border-dark-600 dark:text-dark-300 dark:peer-checked:border-red-500 dark:peer-checked:bg-red-950/40 dark:peer-checked:text-red-300">
|
||||
{{ t(`admin.promptAudit.events.timePresets.${option.id}`) }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.events.filterTimeRangeHint') }}</p>
|
||||
<div v-if="preset === 'custom'" class="mt-3 grid gap-3 sm:grid-cols-2" data-test="custom-range">
|
||||
<label class="text-xs text-gray-600 dark:text-dark-200">
|
||||
<span>{{ t('admin.promptAudit.events.startAt') }}</span>
|
||||
<input v-model="local.start_at" type="datetime-local" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.startAt')" @change="criteriaChanged" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-dark-200">
|
||||
<span>{{ t('admin.promptAudit.events.endAt') }}</span>
|
||||
<input v-model="local.end_at" type="datetime-local" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.endAt')" @change="criteriaChanged" />
|
||||
</label>
|
||||
<p v-if="!canPreview" class="text-xs text-red-600 dark:text-red-400 sm:col-span-2">{{ t('admin.promptAudit.events.customRangeInvalid') }}</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<label class="text-xs text-gray-600 dark:text-dark-200">
|
||||
<span>{{ t('admin.promptAudit.events.decision') }}</span>
|
||||
<select v-model="local.decision" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.decision')" data-test="delete-decision" @change="criteriaChanged">
|
||||
<option value="">{{ t('common.all') }}</option>
|
||||
<option value="pass">{{ t('admin.promptAudit.decisions.pass') }}</option>
|
||||
<option value="flag">{{ t('admin.promptAudit.decisions.flag') }}</option>
|
||||
<option value="critical">{{ t('admin.promptAudit.decisions.critical') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-dark-200">
|
||||
<span>{{ t('admin.promptAudit.events.risk') }}</span>
|
||||
<select v-model="local.risk_level" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.risk')" data-test="delete-risk" @change="criteriaChanged">
|
||||
<option value="">{{ t('common.all') }}</option>
|
||||
<option value="low">{{ t('admin.promptAudit.riskLevels.low') }}</option>
|
||||
<option value="medium">{{ t('admin.promptAudit.riskLevels.medium') }}</option>
|
||||
<option value="high">{{ t('admin.promptAudit.riskLevels.high') }}</option>
|
||||
<option value="critical">{{ t('admin.promptAudit.riskLevels.critical') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<details class="rounded-xl border border-gray-200 px-4 py-3 dark:border-dark-700/60" data-test="more-conditions">
|
||||
<summary class="cursor-pointer select-none text-xs font-medium text-gray-600 dark:text-dark-200">{{ t('admin.promptAudit.events.moreConditions') }}</summary>
|
||||
<div class="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<label class="text-xs text-gray-600 dark:text-dark-200">
|
||||
<span>{{ t('admin.promptAudit.events.endpoint') }}</span>
|
||||
<input v-model="local.endpoint" type="text" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.endpoint')" @input="criteriaChanged" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-dark-200">
|
||||
<span>{{ t('admin.promptAudit.events.keyword') }}</span>
|
||||
<input v-model="local.keyword" type="text" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.keyword')" @input="criteriaChanged" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-dark-200">
|
||||
<span>{{ t('admin.promptAudit.events.groupId') }}</span>
|
||||
<input v-model="local.group_id" type="number" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.groupId')" @input="criteriaChanged" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-dark-200">
|
||||
<span>{{ t('admin.promptAudit.events.userId') }}</span>
|
||||
<input v-model="local.user_id" type="number" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.userId')" @input="criteriaChanged" />
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div v-if="preview" class="rounded-xl border border-red-200 bg-red-50/60 px-4 py-3 dark:border-red-900/60 dark:bg-red-950/20" data-test="delete-preview-result">
|
||||
<p class="text-sm font-semibold text-red-700 dark:text-red-300">{{ t('admin.promptAudit.events.filterDeleteCount', { count: preview.matched_count }) }}</p>
|
||||
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs text-gray-600 dark:text-dark-300">
|
||||
<dt>{{ t('admin.promptAudit.events.snapshotMax') }}</dt>
|
||||
<dd>{{ preview.snapshot_max_id }}</dd>
|
||||
<dt>Filter SHA-256</dt>
|
||||
<dd class="break-all font-mono">{{ preview.filter_hash }}</dd>
|
||||
<dt>{{ t('admin.promptAudit.events.expiresAt') }}</dt>
|
||||
<dd>{{ formatDate(preview.expires_at) }}</dd>
|
||||
</dl>
|
||||
<p class="mt-2 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:bg-amber-950/30 dark:text-amber-200">{{ t('admin.promptAudit.events.filterDeleteWarning') }}</p>
|
||||
</div>
|
||||
<p v-else class="rounded-xl border border-dashed border-gray-300 px-4 py-3 text-xs text-gray-500 dark:border-dark-600 dark:text-dark-400" data-test="delete-preview-empty">
|
||||
{{ t('admin.promptAudit.events.filterDeleteNeedPreview') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex flex-wrap items-center justify-end gap-3">
|
||||
<button type="button" class="btn btn-secondary" @click="$emit('close')">{{ t('common.cancel') }}</button>
|
||||
<button type="button" class="btn btn-secondary" :disabled="!canPreview || previewing || deleting" data-test="run-delete-preview" @click="requestPreview">
|
||||
{{ previewing ? t('admin.promptAudit.events.filterDeletePreviewing') : t('admin.promptAudit.events.filterDeletePreviewAction') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" :disabled="!preview || preview.matched_count === 0 || previewing || deleting" data-test="confirm-filter-delete" @click="$emit('confirm')">
|
||||
{{ deleting ? t('common.submitting') : t('admin.promptAudit.events.confirmFilterDelete') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import type { PromptDeletePreview, PromptEventFilters } from '../types'
|
||||
import {
|
||||
DELETE_RANGE_PRESETS,
|
||||
cloneData,
|
||||
emptyEventFilters,
|
||||
hasExplicitDeleteRange,
|
||||
resolveDeleteRangeFilters,
|
||||
type DeleteRangePreset,
|
||||
} from '../viewModel'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
initialFilters: PromptEventFilters
|
||||
preview: PromptDeletePreview | null
|
||||
previewing: boolean
|
||||
deleting: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'preview', value: PromptEventFilters): void
|
||||
(event: 'confirm'): void
|
||||
(event: 'criteria-change'): void
|
||||
}>()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const preset = ref<DeleteRangePreset>('7d')
|
||||
const local = reactive<PromptEventFilters>(emptyEventFilters())
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(visible) => {
|
||||
if (!visible) return
|
||||
const initial = cloneData(props.initialFilters)
|
||||
// Only inherit an explicit list-filter range; otherwise default to the
|
||||
// seven-day preset so a careless click can never target everything.
|
||||
preset.value = hasExplicitDeleteRange(initial) ? 'custom' : '7d'
|
||||
Object.assign(local, initial)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const canPreview = computed(() => preset.value !== 'custom' || hasExplicitDeleteRange(local))
|
||||
|
||||
function criteriaChanged() {
|
||||
emit('criteria-change')
|
||||
}
|
||||
function requestPreview() {
|
||||
if (!canPreview.value) return
|
||||
emit('preview', resolveDeleteRangeFilters(local, preset.value))
|
||||
}
|
||||
function formatDate(value: string): string {
|
||||
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium', timeStyle: 'medium' }).format(new Date(value))
|
||||
}
|
||||
</script>
|
||||
@@ -46,8 +46,8 @@
|
||||
<legend class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.policy.scanners') }}</legend>
|
||||
<div class="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
<label v-for="scanner in SCANNER_CATALOG" :key="scanner.id" class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-700 hover:bg-gray-50 dark:text-dark-200 dark:hover:bg-dark-800">
|
||||
<input type="checkbox" :checked="draft.scanners.includes(scanner.id)" :aria-label="scanner.label" @change="toggleScanner(scanner.id)" />
|
||||
<span>{{ scanner.label }}</span>
|
||||
<input type="checkbox" :checked="draft.scanners.includes(scanner.id)" :aria-label="scannerLabel(scanner.id)" @change="toggleScanner(scanner.id)" />
|
||||
<span>{{ scannerLabel(scanner.id) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -105,4 +105,7 @@ function toggleScanner(id: string) {
|
||||
else selected.add(id)
|
||||
patch({ scanners: SCANNER_CATALOG.map((item) => item.id).filter((item) => selected.has(item)) })
|
||||
}
|
||||
function scannerLabel(id: string): string {
|
||||
return t(`admin.promptAudit.scanners.${id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -147,6 +147,7 @@ export interface PromptSnapshot {
|
||||
model: string
|
||||
prompt_hash: string
|
||||
redacted_preview: string
|
||||
full_prompt: string
|
||||
prompt_length: number
|
||||
message_count: number
|
||||
stage: string
|
||||
|
||||
@@ -137,3 +137,32 @@ export function hasExplicitDeleteRange(filters: PromptEventFilters): boolean {
|
||||
const end = toISO(filters.end_at)
|
||||
return Boolean(start && end && new Date(start).getTime() < new Date(end).getTime())
|
||||
}
|
||||
|
||||
export type DeleteRangePreset = '1d' | '7d' | '30d' | '90d' | 'all' | 'custom'
|
||||
|
||||
export const DELETE_RANGE_PRESETS: ReadonlyArray<{ id: DeleteRangePreset; days: number | null }> = [
|
||||
{ id: '1d', days: 1 },
|
||||
{ id: '7d', days: 7 },
|
||||
{ id: '30d', days: 30 },
|
||||
{ id: '90d', days: 90 },
|
||||
{ id: 'all', days: null },
|
||||
{ id: 'custom', days: null },
|
||||
]
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
// Presets delete events older than the chosen cutoff: the range always starts
|
||||
// at the epoch and ends at (now - days) so the backend's explicit-range
|
||||
// requirement is satisfied without asking the user for a begin date.
|
||||
export function resolveDeleteRangeFilters(
|
||||
filters: PromptEventFilters,
|
||||
preset: DeleteRangePreset,
|
||||
now: number = Date.now(),
|
||||
): PromptEventFilters {
|
||||
const resolved = cloneData(filters)
|
||||
if (preset === 'custom') return resolved
|
||||
const days = DELETE_RANGE_PRESETS.find((item) => item.id === preset)?.days ?? null
|
||||
resolved.start_at = new Date(0).toISOString()
|
||||
resolved.end_at = new Date(days === null ? now : now - days * DAY_MS).toISOString()
|
||||
return resolved
|
||||
}
|
||||
|
||||
@@ -1,13 +1,37 @@
|
||||
export default {
|
||||
promptAudit: {
|
||||
title: 'Prompt Audit',
|
||||
description: 'Review user input asynchronously or block it synchronously through OpenAI-compatible Qwen3Guard nodes. Full prompts never enter the database or UI.',
|
||||
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' },
|
||||
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.',
|
||||
@@ -31,19 +55,38 @@ export default {
|
||||
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 Pass events', dirty: 'Unsaved changes', synced: 'Configuration synced' },
|
||||
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 redacted events by identity, route, risk, hash, and time.', decision: 'Decision', risk: 'Risk level', endpoint: 'Endpoint', groupId: 'Group ID', userId: 'User ID', apiKeyId: 'API Key ID', keyword: 'Keyword',
|
||||
startAt: 'Start time', endAt: 'End time', deleteSelected: 'Delete selected ({count})', deleteByFilter: 'Delete by filter', deleteRangeHint: 'Filter deletion requires explicit start and end times and a server-generated preview.',
|
||||
title: 'Audit events', description: 'Review events by identity, route, risk, hash, and time; the detail view shows the full prompt.', decision: 'Decision', risk: 'Risk level', endpoint: 'Endpoint', groupId: 'Group ID', userId: 'User ID', apiKeyId: 'API Key ID', keyword: 'Keyword',
|
||||
startAt: 'Start time', endAt: 'End time', deleteSelected: 'Delete selected ({count})', deleteByFilter: 'Delete by filter',
|
||||
filterDeleteDialogTitle: 'Delete audit events by filter', filterDeleteDialogDesc: 'Choose the time range and risk criteria, generate a server-side delete preview, then confirm the matched count. Deletion is permanent.',
|
||||
filterTimeRange: 'Deletion time range', filterTimeRangeHint: 'Deletes events created before the selected cutoff. Events created after the preview are not affected.',
|
||||
timePresets: { '1d': 'Older than 1 day', '7d': 'Older than 7 days', '30d': 'Older than 30 days', '90d': 'Older than 90 days', all: 'All time', custom: 'Custom range' },
|
||||
customRangeInvalid: 'A custom range needs a valid start and end time, with the start before the end.',
|
||||
moreConditions: 'More conditions (endpoint / keyword / group / user)',
|
||||
filterDeletePreviewAction: 'Generate delete preview', filterDeletePreviewing: 'Generating preview…', filterDeleteNeedPreview: 'Generate a delete preview first. Any criteria change requires a fresh preview.',
|
||||
selectAll: 'Select all events on this page', selectEvent: 'Select event {id}', time: 'Time', identity: 'User / email / API Key', user: 'Username', email: 'User email', apiKey: 'API Key name', group: 'Group', route: 'Endpoint / model', result: 'Decision / risk', preview: 'Redacted preview', empty: 'No matching events.',
|
||||
passEventsDisabled: '“Store Pass events” is off. Safe requests are still audited, but they do not appear in this list. Enable and save that option in Configuration to review redacted audit content for every request.', openConfiguration: 'Open configuration',
|
||||
detailTitle: 'Prompt audit event details', tabs: { summary: 'Audit summary', risks: 'Specific risks', technical: 'Technical details' }, redactedPreview: 'Irreversible redacted preview', categories: 'Categories', model: 'Model', stage: 'Request stage', noRisks: 'No derived risk summaries for this event.',
|
||||
deleteConfirmTitle: 'Delete audit events?', deleteConfirmMessage: 'This permanently deletes {count} events and eligible orphan jobs.', filterDeleteTitle: 'Confirm filter deletion', filterDeleteCount: 'The server snapshot matches {count} events.', snapshotMax: 'Snapshot maximum event ID', expiresAt: 'Confirmation token expires', filterDeleteWarning: 'Only events at or below the preview high-water mark are deleted. Newer events survive. Any filter change requires a new preview.', confirmFilterDelete: 'Permanently delete',
|
||||
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: {
|
||||
|
||||
@@ -1,13 +1,37 @@
|
||||
export default {
|
||||
promptAudit: {
|
||||
title: '提示词审计',
|
||||
description: '通过 OpenAI 兼容 Qwen3Guard 节点异步复核或同步阻止用户输入;完整提示词不会进入数据库或页面。',
|
||||
description: '通过 OpenAI 兼容 Qwen3Guard 节点异步复核或同步阻止用户输入;事件的完整提示词会入库保存,仅供管理员复核。',
|
||||
configVersion: '配置版本 v{version}',
|
||||
tabs: { config: '配置', events: '事件' },
|
||||
actions: { refresh: '刷新运行态', retry: '重试' },
|
||||
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: '显示服务端当前生效状态;未保存的草稿不会改变这些数值。',
|
||||
@@ -31,19 +55,38 @@ export default {
|
||||
searchGroups: '搜索分组', noGroups: '没有匹配分组', missingGroups: '配置中包含已删除的分组 ID', selectedCount: '已选择 {count} 个分组',
|
||||
scanners: 'Qwen3Guard 输入风险分类', workerCount: 'Worker 数量', queueCapacity: '持久队列容量', strategy: '节点策略', strategyHint: '按配置顺序优先尝试,必要时故障切换。',
|
||||
},
|
||||
saveBar: { enabled: '启用提示词审计', blocking: '同步阻止', storePass: '保存 Pass 事件', dirty: '有未保存的更改', synced: '配置已同步' },
|
||||
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: '按筛选删除', deleteRangeHint: '按筛选删除必须明确选择开始和结束时间,并先取得服务端删除预览。',
|
||||
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: '请先获取删除预览;任何条件变化后都需要重新获取。',
|
||||
selectAll: '选择当前页全部事件', selectEvent: '选择事件 {id}', time: '时间', identity: '用户 / 邮箱 / API Key', user: '用户名', email: '用户邮箱', apiKey: 'API Key 名称', group: '分组', route: '入口 / 模型', result: '判定 / 风险', preview: '脱敏预览', empty: '没有符合条件的事件。',
|
||||
passEventsDisabled: '当前未开启“保存 Pass 事件”:安全请求仍会完成审计,但不会出现在事件列表中。若要查看每次请求的脱敏审计内容,请在配置中开启该选项并保存。', openConfiguration: '前往配置',
|
||||
detailTitle: '提示词审计事件详情', tabs: { summary: '审计摘要', risks: '具体风险', technical: '技术信息' }, redactedPreview: '不可逆脱敏预览', categories: '分类', model: '模型', stage: '请求阶段', noRisks: '本事件没有派生风险摘要。',
|
||||
deleteConfirmTitle: '删除审计事件?', deleteConfirmMessage: '将永久删除 {count} 条事件及符合条件的孤立任务。', filterDeleteTitle: '确认按筛选删除', filterDeleteCount: '服务端快照匹配 {count} 条事件。', snapshotMax: '快照最大事件 ID', expiresAt: '确认令牌过期时间', filterDeleteWarning: '只删除预览高水位内的事件;预览后产生的新事件会保留。筛选一旦变化,必须重新预览。', confirmFilterDelete: '确认永久删除',
|
||||
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: {
|
||||
|
||||
Reference in New Issue
Block a user