fix(admin): repair system maintenance controls

Implement server-side searchable user filtering for usage records, including backend search parameters and a shared frontend selector with loading, empty, and pinned-selected states.

Fix announcement deletion by cascading announcement read rows through a Postgres migration and defensive repository cleanup across supported backends.

Wire admin system purge and cleanup endpoints to real data deletion/maintenance flows, improve DataManagement messages, rebuild stats after stats purge, and add runtime OAuth token refresh maintenance when enabled.

Verified with rust-ci equivalent checks: cargo fmt, split clippy, split cargo tests, SQLite/Postgres/MySQL smoke tests, plus frontend npm ci, build, pages build, type-check, and targeted usage selector tests.
This commit is contained in:
Entropy.Xu
2026-05-07 21:26:40 +08:00
parent 2b439094c5
commit dd8c2ebec6
37 changed files with 2836 additions and 103 deletions
@@ -0,0 +1,150 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
import ServerUserSelector from '../ServerUserSelector.vue'
const getAllUsersMock = vi.hoisted(() => vi.fn())
vi.mock('@/api/users', () => ({
usersApi: {
getAllUsers: getAllUsersMock,
},
}))
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
return {
Input: defineComponent({
name: 'InputStub',
props: { modelValue: String },
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
value: props.modelValue ?? '',
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
})
},
}),
}
})
vi.mock('lucide-vue-next', async () => {
const { defineComponent, h } = await import('vue')
const Icon = defineComponent({
name: 'IconStub',
setup() {
return () => h('span')
},
})
return {
Check: Icon,
ChevronDown: Icon,
Search: Icon,
}
})
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function flushPromises() {
return Promise.resolve().then(() => undefined)
}
function mountSelector(props: Record<string, unknown> = {}) {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup() {
return () => h(ServerUserSelector, {
modelValue: '__all__',
initialUsers: [],
dropdown: true,
...props,
})
},
}))
app.mount(root)
mountedApps.push({ app, root })
return root
}
beforeEach(() => {
vi.useRealTimers()
getAllUsersMock.mockReset()
})
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
vi.useRealTimers()
})
describe('ServerUserSelector', () => {
it('loads the initial user batch when opened', async () => {
getAllUsersMock.mockResolvedValue([
{ id: 'user-1', username: 'alice', email: '[email protected]' },
])
const root = mountSelector()
root.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
await flushPromises()
expect(getAllUsersMock).toHaveBeenCalledWith({
search: '',
skip: 0,
limit: 50,
cacheTtlMs: 30_000,
})
expect(root.textContent).toContain('alice')
})
it('debounces remote search and bypasses cache for typed queries', async () => {
vi.useFakeTimers()
getAllUsersMock.mockResolvedValue([])
const root = mountSelector()
root.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
const input = root.querySelector('input') as HTMLInputElement
input.value = 'bob'
input.dispatchEvent(new Event('input', { bubbles: true }))
await vi.advanceTimersByTimeAsync(299)
expect(getAllUsersMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
await flushPromises()
expect(getAllUsersMock).toHaveBeenLastCalledWith({
search: 'bob',
skip: 0,
limit: 50,
cacheTtlMs: 0,
})
})
it('keeps the selected user pinned when search results do not include it', async () => {
getAllUsersMock.mockResolvedValue([
{ id: 'user-1', username: 'alice', email: '[email protected]' },
])
const root = mountSelector({
modelValue: 'user-99',
initialUsers: [
{ id: 'user-99', username: 'pinned', email: '[email protected]' },
],
})
root.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
await flushPromises()
expect(root.textContent).toContain('pinned')
expect(root.textContent).toContain('alice')
})
})
@@ -76,6 +76,8 @@ vi.mock('lucide-vue-next', async () => {
return {
RefreshCcw: Icon,
Search: Icon,
ChevronDown: Icon,
Check: Icon,
}
})
@@ -88,6 +90,15 @@ vi.mock('../ElapsedTimeText.vue', () => ({
}),
}))
vi.mock('../ServerUserSelector.vue', () => ({
default: defineComponent({
name: 'ServerUserSelectorStub',
setup() {
return () => h('div', 'user selector')
},
}),
}))
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function buildRecord(overrides: Partial<UsageRecord> = {}): UsageRecord {