mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(admin): manual request-records cleanup with typed confirmation
This commit is contained in:
@@ -1,8 +1,20 @@
|
||||
import apiClient from './client'
|
||||
import axios from 'axios'
|
||||
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
||||
import type { BillingSummary } from './auth'
|
||||
import type { ApiKeyInstallSession, InstallSessionTargetSystem, InstallTargetCli } from './me'
|
||||
|
||||
function extractConflictPayload(error: unknown): ManualUsageCleanupConflict | null {
|
||||
if (!axios.isAxiosError(error) || error.response?.status !== 409) {
|
||||
return null
|
||||
}
|
||||
const data = error.response.data as ManualUsageCleanupConflict | undefined
|
||||
if (!data || data.detail !== 'usage_cleanup_already_running') {
|
||||
return null
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// LDAP 配置导出结构
|
||||
export interface LDAPConfigExport {
|
||||
server_url: string
|
||||
@@ -280,6 +292,43 @@ export interface CleanupTaskResponse {
|
||||
task: CleanupRunRecord
|
||||
}
|
||||
|
||||
export interface ManualUsageCleanupSummary {
|
||||
body_externalized: number
|
||||
legacy_body_refs_migrated: number
|
||||
body_cleaned: number
|
||||
header_cleaned: number
|
||||
keys_cleaned: number
|
||||
records_deleted: number
|
||||
}
|
||||
|
||||
export interface ManualUsageCleanupResponse {
|
||||
message: string
|
||||
requested_older_than_days: number | null
|
||||
summary: ManualUsageCleanupSummary
|
||||
total_affected: number
|
||||
}
|
||||
|
||||
export interface ManualUsageCleanupPreview {
|
||||
requested_older_than_days: number | null
|
||||
effective_cutoffs: {
|
||||
detail: string
|
||||
compressed: string
|
||||
header: string
|
||||
log: string
|
||||
}
|
||||
counts: {
|
||||
detail: number
|
||||
compressed: number
|
||||
header: number
|
||||
log: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface ManualUsageCleanupConflict {
|
||||
detail: 'usage_cleanup_already_running'
|
||||
message: string
|
||||
}
|
||||
|
||||
// 检查更新响应
|
||||
export interface CheckUpdateResponse {
|
||||
current_version: string
|
||||
@@ -1132,6 +1181,42 @@ export const adminApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async runManualUsageCleanup(
|
||||
params: { older_than_days?: number } = {}
|
||||
): Promise<ManualUsageCleanupResponse | ManualUsageCleanupConflict> {
|
||||
const body: Record<string, number> = {}
|
||||
if (typeof params.older_than_days === 'number') {
|
||||
body.older_than_days = params.older_than_days
|
||||
}
|
||||
try {
|
||||
const response = await apiClient.post<ManualUsageCleanupResponse>(
|
||||
'/api/admin/system/cleanup/usage/manual',
|
||||
body
|
||||
)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const conflict = extractConflictPayload(error)
|
||||
if (conflict) {
|
||||
return conflict
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
async previewManualUsageCleanup(
|
||||
params: { older_than_days?: number } = {}
|
||||
): Promise<ManualUsageCleanupPreview> {
|
||||
const query: Record<string, number> = {}
|
||||
if (typeof params.older_than_days === 'number') {
|
||||
query.older_than_days = params.older_than_days
|
||||
}
|
||||
const response = await apiClient.get<ManualUsageCleanupPreview>(
|
||||
'/api/admin/system/cleanup/usage/preview',
|
||||
{ params: query }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getTimeSeries(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
|
||||
@@ -23,6 +23,15 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
:disabled="manualCleanupRunning"
|
||||
@click="openManualCleanupDialog"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5 mr-1.5" />
|
||||
{{ manualCleanupRunning ? '清理中…' : '立即清理' }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="loading || !hasChanges"
|
||||
@@ -276,6 +285,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ManualCleanupConfirmDialog
|
||||
:open="manualCleanupDialogOpen"
|
||||
@update:open="manualCleanupDialogOpen = $event"
|
||||
@confirm="handleManualCleanupConfirm"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="manualCleanupResult"
|
||||
class="mt-4 rounded-md border border-border bg-muted/30 px-4 py-3 text-sm"
|
||||
>
|
||||
<div class="font-medium">
|
||||
{{ manualCleanupResult.title }}
|
||||
</div>
|
||||
<div
|
||||
v-if="manualCleanupResult.description"
|
||||
class="mt-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ manualCleanupResult.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 border border-border rounded-lg overflow-hidden">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<div>
|
||||
@@ -371,13 +401,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { RefreshCw } from 'lucide-vue-next'
|
||||
import { adminApi, type CleanupRunRecord } from '@/api/admin'
|
||||
import { RefreshCw, Trash2 } from 'lucide-vue-next'
|
||||
import { adminApi, type CleanupRunRecord, type ManualUsageCleanupResponse } from '@/api/admin'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import Switch from '@/components/ui/switch.vue'
|
||||
import { CardSection } from '@/components/layout'
|
||||
import ManualCleanupConfirmDialog from './ManualCleanupConfirmDialog.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
|
||||
defineProps<{
|
||||
enableAutoCleanup: boolean
|
||||
@@ -416,6 +449,64 @@ const cleanupRuns = ref<CleanupRunRecord[]>([])
|
||||
const cleanupRunsLoading = ref(false)
|
||||
let cleanupRunsTimer: ReturnType<typeof window.setInterval> | null = null
|
||||
|
||||
const manualCleanupDialogOpen = ref(false)
|
||||
const manualCleanupRunning = ref(false)
|
||||
const manualCleanupResult = ref<{ title: string; description?: string } | null>(null)
|
||||
const toast = useToast()
|
||||
|
||||
function openManualCleanupDialog() {
|
||||
if (manualCleanupRunning.value) return
|
||||
manualCleanupDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleManualCleanupConfirm(olderThanDays: number | undefined) {
|
||||
manualCleanupRunning.value = true
|
||||
try {
|
||||
const response = await adminApi.runManualUsageCleanup(
|
||||
typeof olderThanDays === 'number' ? { older_than_days: olderThanDays } : {},
|
||||
)
|
||||
if ('detail' in response && response.detail === 'usage_cleanup_already_running') {
|
||||
manualCleanupResult.value = {
|
||||
title: '已有一次清理正在进行中',
|
||||
description: response.message,
|
||||
}
|
||||
toast.warning(response.message)
|
||||
} else {
|
||||
const completed = response as ManualUsageCleanupResponse
|
||||
manualCleanupResult.value = {
|
||||
title: completed.message,
|
||||
description: summarizeManualCleanup(completed),
|
||||
}
|
||||
toast.success(completed.message)
|
||||
}
|
||||
manualCleanupDialogOpen.value = false
|
||||
} catch (error) {
|
||||
const message = parseApiError(error).message
|
||||
manualCleanupResult.value = {
|
||||
title: '请求记录清理失败',
|
||||
description: message,
|
||||
}
|
||||
toast.error(message)
|
||||
} finally {
|
||||
manualCleanupRunning.value = false
|
||||
void loadCleanupRuns()
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeManualCleanup(response: ManualUsageCleanupResponse): string {
|
||||
const { summary } = response
|
||||
const parts: string[] = []
|
||||
if (summary.records_deleted > 0) parts.push(`删除整条记录 ${summary.records_deleted}`)
|
||||
if (summary.body_externalized > 0) parts.push(`压缩 body ${summary.body_externalized}`)
|
||||
if (summary.body_cleaned > 0) parts.push(`清除过期 body ${summary.body_cleaned}`)
|
||||
if (summary.header_cleaned > 0) parts.push(`清除 headers ${summary.header_cleaned}`)
|
||||
if (summary.legacy_body_refs_migrated > 0) {
|
||||
parts.push(`迁移遗留引用 ${summary.legacy_body_refs_migrated}`)
|
||||
}
|
||||
if (summary.keys_cleaned > 0) parts.push(`回收 Key ${summary.keys_cleaned}`)
|
||||
return parts.length > 0 ? parts.join(' / ') : '无数据变更'
|
||||
}
|
||||
|
||||
async function loadCleanupRuns() {
|
||||
cleanupRunsLoading.value = true
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:open="open"
|
||||
size="lg"
|
||||
title="立即清理请求记录"
|
||||
description="按现有分级保留策略主动清理请求记录,可选指定清理更早时间的数据。操作不可逆。"
|
||||
:persistent="submitting"
|
||||
@update:open="handleOpenChange"
|
||||
>
|
||||
<div class="px-4 sm:px-6 py-4 space-y-4">
|
||||
<div>
|
||||
<Label
|
||||
for="manual-cleanup-older-than-days"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
清理 N 天前的记录(可选)
|
||||
</Label>
|
||||
<Input
|
||||
id="manual-cleanup-older-than-days"
|
||||
:model-value="olderThanDays ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="留空代表按当前保留策略"
|
||||
class="mt-1"
|
||||
:disabled="submitting"
|
||||
@update:model-value="handleDaysChange"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
留空代表按当前保留策略清理。填入数字代表清理 N 天前的记录;该值只能比策略更宽松,不会删除更新的数据。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-border bg-muted/30 px-4 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-medium">
|
||||
预计影响
|
||||
</h4>
|
||||
<button
|
||||
v-if="!previewLoading"
|
||||
type="button"
|
||||
class="text-xs text-muted-foreground hover:text-foreground"
|
||||
:disabled="submitting"
|
||||
@click="loadPreview"
|
||||
>
|
||||
刷新预估
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
正在计算…
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="previewError"
|
||||
class="mt-2 text-xs text-destructive"
|
||||
>
|
||||
{{ previewError }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="preview"
|
||||
class="mt-2 grid grid-cols-2 gap-y-1 gap-x-4 text-xs text-muted-foreground"
|
||||
>
|
||||
<div>详细记录待压缩</div>
|
||||
<div class="text-right text-foreground">
|
||||
{{ formatCount(preview.counts.detail) }}
|
||||
</div>
|
||||
<div>压缩记录待清体</div>
|
||||
<div class="text-right text-foreground">
|
||||
{{ formatCount(preview.counts.compressed) }}
|
||||
</div>
|
||||
<div>请求头待清空</div>
|
||||
<div class="text-right text-foreground">
|
||||
{{ formatCount(preview.counts.header) }}
|
||||
</div>
|
||||
<div>整条记录待删除</div>
|
||||
<div class="text-right text-destructive font-medium">
|
||||
{{ formatCount(preview.counts.log) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!previewLoading"
|
||||
class="mt-2 text-xs text-muted-foreground"
|
||||
>
|
||||
尚未计算预估数据
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="manual-cleanup-confirm-phrase"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
输入「{{ confirmPhrase }}」以确认清理
|
||||
</Label>
|
||||
<Input
|
||||
id="manual-cleanup-confirm-phrase"
|
||||
:model-value="typedPhrase"
|
||||
class="mt-1"
|
||||
autocomplete="off"
|
||||
:placeholder="confirmPhrase"
|
||||
:disabled="submitting"
|
||||
@update:model-value="typedPhrase = String($event)"
|
||||
@keydown.enter.prevent="maybeSubmitOnEnter"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
确认操作后会立刻执行清理,且不可撤销。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="destructive"
|
||||
:disabled="!canSubmit"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ submitting ? '清理中…' : '确认清理' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="submitting"
|
||||
@click="handleCancel"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import { adminApi, type ManualUsageCleanupPreview } from '@/api/admin'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE,
|
||||
isConfirmPhraseMatched,
|
||||
normalizeOlderThanDaysInput,
|
||||
} from './manualCleanupForm'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
confirm: [olderThanDays: number | undefined]
|
||||
}>()
|
||||
|
||||
const confirmPhrase = MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE
|
||||
|
||||
const olderThanDays = ref<number | null>(null)
|
||||
const typedPhrase = ref('')
|
||||
const preview = ref<ManualUsageCleanupPreview | null>(null)
|
||||
const previewLoading = ref(false)
|
||||
const previewError = ref<string | null>(null)
|
||||
const submitting = ref(false)
|
||||
|
||||
let previewDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let previewSeq = 0
|
||||
|
||||
const normalizedPhrase = computed(() => typedPhrase.value)
|
||||
|
||||
const canSubmit = computed(
|
||||
() =>
|
||||
!submitting.value &&
|
||||
!previewLoading.value &&
|
||||
isConfirmPhraseMatched(normalizedPhrase.value),
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(isOpen) => {
|
||||
if (isOpen) {
|
||||
resetForm()
|
||||
void loadPreview()
|
||||
} else if (previewDebounceTimer) {
|
||||
clearTimeout(previewDebounceTimer)
|
||||
previewDebounceTimer = null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function resetForm() {
|
||||
olderThanDays.value = null
|
||||
typedPhrase.value = ''
|
||||
preview.value = null
|
||||
previewError.value = null
|
||||
previewLoading.value = false
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
function handleDaysChange(value: string | number) {
|
||||
olderThanDays.value = normalizeOlderThanDaysInput(value)
|
||||
schedulePreview()
|
||||
}
|
||||
|
||||
function schedulePreview() {
|
||||
if (previewDebounceTimer) {
|
||||
clearTimeout(previewDebounceTimer)
|
||||
}
|
||||
previewDebounceTimer = setTimeout(() => {
|
||||
previewDebounceTimer = null
|
||||
void loadPreview()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
async function loadPreview() {
|
||||
const seq = ++previewSeq
|
||||
previewLoading.value = true
|
||||
previewError.value = null
|
||||
try {
|
||||
const params: { older_than_days?: number } = {}
|
||||
if (olderThanDays.value !== null) {
|
||||
params.older_than_days = olderThanDays.value
|
||||
}
|
||||
const result = await adminApi.previewManualUsageCleanup(params)
|
||||
if (seq === previewSeq) {
|
||||
preview.value = result
|
||||
}
|
||||
} catch (error) {
|
||||
if (seq === previewSeq) {
|
||||
preview.value = null
|
||||
previewError.value = parseApiError(error).message
|
||||
}
|
||||
} finally {
|
||||
if (seq === previewSeq) {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenChange(value: boolean) {
|
||||
if (!value && submitting.value) {
|
||||
return
|
||||
}
|
||||
emit('update:open', value)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (submitting.value) return
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
function maybeSubmitOnEnter() {
|
||||
if (canSubmit.value) {
|
||||
void handleConfirm()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
if (!canSubmit.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
emit('confirm', olderThanDays.value ?? undefined)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatCount(value: number): string {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE,
|
||||
isConfirmPhraseMatched,
|
||||
normalizeConfirmPhraseInput,
|
||||
normalizeOlderThanDaysInput,
|
||||
} from '../manualCleanupForm'
|
||||
|
||||
describe('manualCleanupForm', () => {
|
||||
describe('normalizeConfirmPhraseInput', () => {
|
||||
it('trims leading and trailing whitespace', () => {
|
||||
expect(normalizeConfirmPhraseInput(' 确认清理 ')).toBe(MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE)
|
||||
})
|
||||
|
||||
it('strips newlines typed or pasted into the input', () => {
|
||||
expect(normalizeConfirmPhraseInput('确认清理\n')).toBe(MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE)
|
||||
expect(normalizeConfirmPhraseInput('确认\n清理')).toBe('确认清理')
|
||||
})
|
||||
|
||||
it('leaves non-whitespace content untouched', () => {
|
||||
expect(normalizeConfirmPhraseInput('取消')).toBe('取消')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isConfirmPhraseMatched', () => {
|
||||
it('matches the exact phrase including pasted whitespace', () => {
|
||||
expect(isConfirmPhraseMatched('确认清理')).toBe(true)
|
||||
expect(isConfirmPhraseMatched(' 确认清理 ')).toBe(true)
|
||||
expect(isConfirmPhraseMatched('确认清理\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects partial prefixes and unrelated strings', () => {
|
||||
expect(isConfirmPhraseMatched('确认')).toBe(false)
|
||||
expect(isConfirmPhraseMatched('确认清理了')).toBe(false)
|
||||
expect(isConfirmPhraseMatched('')).toBe(false)
|
||||
expect(isConfirmPhraseMatched('取消')).toBe(false)
|
||||
})
|
||||
|
||||
it('is case/character sensitive', () => {
|
||||
expect(isConfirmPhraseMatched('Confirm')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeOlderThanDaysInput', () => {
|
||||
it('returns null for empty or non-positive inputs', () => {
|
||||
expect(normalizeOlderThanDaysInput('')).toBeNull()
|
||||
expect(normalizeOlderThanDaysInput(null)).toBeNull()
|
||||
expect(normalizeOlderThanDaysInput(undefined)).toBeNull()
|
||||
expect(normalizeOlderThanDaysInput(0)).toBeNull()
|
||||
expect(normalizeOlderThanDaysInput(-3)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns an integer for positive numeric inputs', () => {
|
||||
expect(normalizeOlderThanDaysInput(7)).toBe(7)
|
||||
expect(normalizeOlderThanDaysInput('30')).toBe(30)
|
||||
expect(normalizeOlderThanDaysInput('7.9')).toBe(7)
|
||||
})
|
||||
|
||||
it('returns null for non-numeric strings', () => {
|
||||
expect(normalizeOlderThanDaysInput('abc')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
export const MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE = '确认清理'
|
||||
|
||||
export function normalizeConfirmPhraseInput(raw: string): string {
|
||||
return raw.replace(/\r?\n/g, '').trim()
|
||||
}
|
||||
|
||||
export function isConfirmPhraseMatched(raw: string): boolean {
|
||||
return normalizeConfirmPhraseInput(raw) === MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE
|
||||
}
|
||||
|
||||
export function normalizeOlderThanDaysInput(raw: string | number | null | undefined): number | null {
|
||||
if (raw === null || raw === undefined || raw === '') return null
|
||||
const parsed = typeof raw === 'number' ? raw : Number(raw)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return null
|
||||
return Math.floor(parsed)
|
||||
}
|
||||
Reference in New Issue
Block a user