feat(09-03): extract AdminAiView + AdminAuditView from tab components

- Created frontend/src/views/admin/AdminAiView.vue (extracted from AdminAiConfigTab.vue)
  - Import path for SearchableModelSelect rewritten to ../../components/ui/SearchableModelSelect.vue
  - System AI Providers (Global) section preserved above per-user table (Phase 7 constraint)
- Created frontend/src/views/admin/AdminAuditView.vue (verbatim from AuditLogTab.vue per D-13)
  - actionTypeClass() helper preserved verbatim with all bg-amber-50, bg-purple-50 dynamic classes
  - Filter bar, paginated table, CSV download, daily-export list all preserved as-is
- Both files have no defineProps, no top-level padding, identical line counts to sources
- Build succeeds: 143 modules transformed, no errors
This commit is contained in:
curo1305
2026-06-12 16:01:29 +02:00
parent d690a445b9
commit 164003b19b
2 changed files with 737 additions and 0 deletions
+391
View File
@@ -0,0 +1,391 @@
<template>
<div>
<!-- System AI Providers (Global) -->
<section class="bg-white rounded-xl border border-gray-200 overflow-hidden mb-6">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-sm font-semibold text-gray-900">System AI Providers (Global)</h2>
<p class="text-xs text-gray-500 mt-0.5">
These settings apply to all classification jobs. Per-user overrides below take precedence when set.
</p>
</div>
<!-- Loading spinner -->
<div v-if="loadingSystem" class="p-6 text-center">
<div class="flex items-center justify-center gap-2 text-gray-400 text-sm">
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-4 h-4"></span>
Loading AI providers
</div>
</div>
<!-- System error banner -->
<div v-if="systemError" class="mx-6 mt-4 px-4 py-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{{ systemError }}
</div>
<!-- Provider accordion list -->
<div v-if="!loadingSystem" class="divide-y divide-gray-100">
<div
v-for="prov in systemProviders"
:key="prov.provider_id"
:class="['transition-colors', prov.is_active ? 'border-l-4 border-l-green-500' : '']"
>
<!-- Accordion header -->
<button
class="w-full flex items-center justify-between px-6 py-3 text-left hover:bg-gray-50 transition-colors"
@click="toggleProvider(prov.provider_id)"
>
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-gray-900 capitalize">{{ prov.provider_id }}</span>
<span
v-if="prov.is_active"
class="bg-green-100 text-green-700 text-xs font-semibold px-2 py-0.5 rounded-full"
>Active</span>
</div>
<svg
:class="['w-4 h-4 text-gray-400 transition-transform', openProviderId === prov.provider_id ? 'rotate-180' : '']"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- Accordion body -->
<div v-if="openProviderId === prov.provider_id" class="px-6 pb-5 pt-2 bg-gray-50 space-y-3">
<!-- API Key -->
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">API Key</label>
<input
v-model="formByProvider[prov.provider_id].api_key"
type="password"
:placeholder="prov.has_api_key ? '(unchanged)' : '(not set)'"
autocomplete="off"
class="block w-full rounded-lg px-3 py-2 text-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
</div>
<!-- Base URL -->
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Base URL</label>
<input
v-model="formByProvider[prov.provider_id].base_url"
type="text"
:placeholder="providerDefaultBaseUrl(prov.provider_id) || '(default)'"
class="block w-full rounded-lg px-3 py-2 text-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
</div>
<!-- Model name searchable dropdown populated from provider's /models endpoint -->
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Model Name</label>
<SearchableModelSelect
v-model="formByProvider[prov.provider_id].model_name"
:provider-id="prov.provider_id"
:placeholder="providerDefaultModel(prov.provider_id)"
/>
</div>
<!-- Context chars -->
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Context Characters</label>
<input
v-model.number="formByProvider[prov.provider_id].context_chars"
type="number"
:placeholder="String(providerDefaultContextChars(prov.provider_id))"
min="1000"
max="2000000"
class="block w-full rounded-lg px-3 py-2 text-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-2 pt-1">
<!-- Set Active -->
<button
@click="saveSystemProvider(prov.provider_id, { activate: true })"
:disabled="savingProvider === prov.provider_id || prov.is_active"
class="text-xs bg-green-600 text-white font-semibold px-3 py-1.5 rounded-lg hover:bg-green-700 disabled:opacity-50 transition-colors"
>
Set Active
</button>
<!-- Save -->
<button
@click="saveSystemProvider(prov.provider_id)"
:disabled="savingProvider === prov.provider_id"
class="text-xs bg-indigo-600 text-white font-semibold px-3 py-1.5 rounded-lg hover:bg-indigo-700 disabled:opacity-50 flex items-center gap-1 transition-colors"
>
<span v-if="savingProvider === prov.provider_id" class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"></span>
{{ savingProvider === prov.provider_id ? 'Saving' : 'Save' }}
</button>
<!-- Test Connection -->
<button
@click="runTestConnection(prov.provider_id)"
:disabled="testingProvider === prov.provider_id || savingProvider === prov.provider_id"
class="text-xs border border-gray-300 text-gray-700 font-semibold px-3 py-1.5 rounded-lg hover:bg-gray-100 disabled:opacity-50 flex items-center gap-1.5 transition-colors"
>
<span
v-if="testingProvider === prov.provider_id"
class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"
></span>
{{ testingProvider === prov.provider_id ? 'Testing' : 'Test Connection' }}
</button>
<!-- Test result badge -->
<span
v-if="testResults[prov.provider_id] === 'ok'"
class="text-xs bg-green-100 text-green-700 font-semibold px-2 py-1 rounded-full"
>OK</span>
<span
v-else-if="testResults[prov.provider_id] === 'failed'"
class="text-xs bg-red-100 text-red-700 font-semibold px-2 py-1 rounded-full"
>Failed</span>
</div>
</div>
</div>
</div>
</section>
<!-- ── Per-user AI provider assignment (existing — DO NOT MODIFY) ─────── -->
<!-- Loading state -->
<div v-if="loading" class="bg-white rounded-xl border border-gray-200 p-8 text-center">
<div class="flex items-center justify-center gap-2 text-gray-400 text-sm">
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-4 h-4"></span>
Loading AI config…
</div>
</div>
<!-- Empty state -->
<div v-else-if="users.length === 0" class="bg-white rounded-xl border border-gray-200 p-12 text-center">
<h3 class="text-sm font-semibold text-gray-900 mb-1">No users yet</h3>
<p class="text-sm text-gray-500">Create the first user account to get started.</p>
</div>
<!-- AI config table -->
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden divide-y divide-gray-200">
<table class="w-full">
<thead>
<tr class="bg-gray-50 text-left">
<th class="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Email</th>
<th class="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">AI Provider</th>
<th class="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">AI Model</th>
<th class="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="user in users" :key="user.id" class="bg-white text-sm">
<td class="px-4 py-3 text-gray-900">{{ user.email }}</td>
<td class="px-4 py-3">
<select
v-model="configs[user.id].provider"
class="block w-36 rounded-lg px-2 py-1.5 text-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors bg-white"
>
<option value="">— None —</option>
<option v-for="p in providers" :key="p.value" :value="p.value">{{ p.label }}</option>
</select>
</td>
<td class="px-4 py-3">
<input
v-model="configs[user.id].model"
type="text"
placeholder="e.g. gpt-4o"
class="block w-40 rounded-lg px-2 py-1.5 text-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<button
@click="saveConfig(user.id)"
:disabled="savingId === user.id"
class="text-sm text-indigo-600 hover:text-indigo-700 font-semibold disabled:opacity-50 flex items-center gap-1"
>
<span v-if="savingId === user.id" class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"></span>
{{ savingId === user.id ? 'Saving' : 'Save' }}
</button>
<span
v-if="savedId === user.id"
class="text-xs text-green-600 font-semibold"
>
Saved
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Error message -->
<p v-if="loadError" class="mt-3 text-sm text-red-600">{{ loadError }}</p>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import * as api from '../../api/client.js'
import { getAiConfig, saveAiConfig, testAiConnection } from '../../api/client.js'
import SearchableModelSelect from '../../components/ui/SearchableModelSelect.vue'
// ── Per-provider default lookup (mirrors backend PROVIDER_DEFAULTS) ───────────
const _PROVIDER_DEFAULTS = {
openai: { base_url: null, model: 'gpt-4o', context_chars: 120000 },
anthropic: { base_url: null, model: 'claude-sonnet-4-6', context_chars: 180000 },
gemini: { base_url: 'https://generativelanguage.googleapis.com/v1beta/openai/', model: 'gemini-2.0-flash', context_chars: 800000 },
groq: { base_url: 'https://api.groq.com/openai/v1', model: 'llama-3.3-70b-versatile', context_chars: 128000 },
xai: { base_url: 'https://api.x.ai/v1', model: 'grok-3-mini', context_chars: 128000 },
deepseek: { base_url: 'https://api.deepseek.com', model: 'deepseek-chat', context_chars: 60000 },
openrouter: { base_url: 'https://openrouter.ai/api/v1', model: 'anthropic/claude-3.5-sonnet', context_chars: 180000 },
mistral: { base_url: 'https://api.mistral.ai/v1', model: 'mistral-large-latest', context_chars: 128000 },
ollama: { base_url: 'http://host.docker.internal:11434/v1', model: 'llama3.2', context_chars: 8000 },
lmstudio: { base_url: 'http://host.docker.internal:1234/v1', model: 'gemma-4-e4b-it', context_chars: 8000 },
}
function providerDefaultBaseUrl(pid) { return _PROVIDER_DEFAULTS[pid]?.base_url || '' }
function providerDefaultModel(pid) { return _PROVIDER_DEFAULTS[pid]?.model || '' }
function providerDefaultContextChars(pid) { return _PROVIDER_DEFAULTS[pid]?.context_chars || 8000 }
// ── System provider state ─────────────────────────────────────────────────────
const systemProviders = ref([])
const loadingSystem = ref(false)
const systemError = ref(null)
const openProviderId = ref(null)
const formByProvider = reactive({})
const testResults = reactive({})
const savingProvider = ref(null)
const testingProvider = ref(null)
function toggleProvider(pid) {
openProviderId.value = openProviderId.value === pid ? null : pid
}
function _initForm(prov) {
formByProvider[prov.provider_id] = {
api_key: '', // write-only: never pre-filled from server
base_url: prov.base_url || '',
model_name: prov.model_name || '',
context_chars: prov.context_chars || 0,
}
}
async function loadSystemProviders() {
loadingSystem.value = true
systemError.value = null
try {
const data = await getAiConfig()
systemProviders.value = data.providers || []
for (const prov of systemProviders.value) {
_initForm(prov)
}
} catch (e) {
systemError.value = e.message || 'Failed to load AI provider config'
} finally {
loadingSystem.value = false
}
}
async function saveSystemProvider(providerId, opts = {}) {
savingProvider.value = providerId
const form = formByProvider[providerId] || {}
const body = { provider_id: providerId }
// Only include fields the admin actually modified (api_key only if user typed something)
if (form.api_key !== '') body.api_key = form.api_key
if (form.base_url !== '') body.base_url = form.base_url
if (form.model_name !== '') body.model_name = form.model_name
if (form.context_chars && form.context_chars !== 0) body.context_chars = form.context_chars
if (opts.activate) body.is_active = true
try {
await saveAiConfig(body)
await loadSystemProviders()
} catch (e) {
systemError.value = e.message || 'Failed to save provider config'
} finally {
savingProvider.value = null
}
}
async function runTestConnection(providerId) {
testingProvider.value = providerId
testResults[providerId] = null
const form = formByProvider[providerId] || {}
// Pass unsaved form values so admins can test credentials before saving
const overrides = {}
if (form.api_key) overrides.api_key = form.api_key
if (form.base_url) overrides.base_url = form.base_url
if (form.model_name) overrides.model_name = form.model_name
try {
const r = await testAiConnection(providerId, overrides)
testResults[providerId] = r.ok ? 'ok' : 'failed'
} catch {
testResults[providerId] = 'failed'
} finally {
testingProvider.value = null
}
setTimeout(() => { testResults[providerId] = null }, 3000)
}
// ── Per-user assignment state (existing — DO NOT MODIFY) ──────────────────────
const users = ref([])
const loading = ref(false)
const loadError = ref(null)
const savingId = ref(null)
const savedId = ref(null)
// Per-user config state: { [userId]: { provider, model } }
const configs = reactive({})
const providers = [
{ value: 'openai', label: 'OpenAI' },
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'ollama', label: 'Ollama' },
{ value: 'lmstudio', label: 'LM Studio' },
]
async function saveConfig(userId) {
savingId.value = userId
savedId.value = null
try {
await api.adminUpdateAiConfig(
userId,
configs[userId].provider || null,
configs[userId].model || null
)
savedId.value = userId
setTimeout(() => {
if (savedId.value === userId) savedId.value = null
}, 1500)
} catch (e) {
loadError.value = e.message
} finally {
savingId.value = null
}
}
onMounted(async () => {
// Load system providers AND per-user list in parallel
loadSystemProviders()
loading.value = true
loadError.value = null
try {
const data = await api.adminListUsers()
users.value = data.items || []
// Initialize per-user config state
for (const user of users.value) {
configs[user.id] = {
provider: user.ai_provider || '',
model: user.ai_model || '',
}
}
} catch (e) {
loadError.value = e.message
} finally {
loading.value = false
}
})
</script>
+346
View File
@@ -0,0 +1,346 @@
<template>
<div>
<!-- Filter bar -->
<div class="flex flex-wrap gap-3 mb-4 items-end">
<div>
<label class="block text-xs font-semibold text-gray-500 mb-1">From</label>
<input
v-model="filters.start"
type="date"
class="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label class="block text-xs font-semibold text-gray-500 mb-1">To</label>
<input
v-model="filters.end"
type="date"
class="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label class="block text-xs font-semibold text-gray-500 mb-1">User handle</label>
<input
v-model="filters.user_handle"
type="text"
placeholder="All users"
class="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 w-36"
/>
</div>
<div>
<label class="block text-xs font-semibold text-gray-500 mb-1">Action</label>
<select
v-model="filters.event_type"
class="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white"
>
<option value="">All actions</option>
<option value="auth">Auth</option>
<option value="document">Document</option>
<option value="folder">Folder</option>
<option value="share">Share</option>
<option value="admin">Admin</option>
</select>
</div>
<button
@click="applyFilters"
class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors"
>
Apply filters
</button>
<button
v-if="activeFilterCount > 0"
@click="clearFilters"
class="border border-gray-300 text-gray-500 text-sm px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
>
Clear filters
</button>
<div class="relative inline-flex flex-col items-start gap-1">
<button
@click="exportCsv"
:disabled="exportingCsv"
class="border border-gray-300 text-gray-700 text-sm px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
>
<span v-if="exportingCsv" class="flex items-center gap-1">
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"></span>
Exporting
</span>
<span v-else>Export CSV</span>
</button>
<span
v-if="activeFilterCount > 0"
class="text-xs text-amber-600"
>
{{ activeFilterCount }} filter{{ activeFilterCount !== 1 ? 's' : '' }} active
</span>
</div>
<p v-if="exportError" class="text-xs text-red-600 self-center">{{ exportError }}</p>
</div>
<!-- Loading state -->
<div v-if="loading" class="bg-white rounded-xl border border-gray-200 p-8 text-center">
<div class="flex items-center justify-center gap-2 text-gray-400 text-sm">
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-4 h-4"></span>
Loading audit log
</div>
</div>
<!-- Fetch error state -->
<p v-else-if="fetchError" class="text-xs text-red-600 mt-1">{{ fetchError }}</p>
<!-- Empty state -->
<div v-else-if="entries.length === 0" class="text-center py-12 text-gray-400 text-sm">
No audit log entries match the selected filters.
</div>
<!-- Table -->
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table class="w-full text-sm border-collapse">
<thead>
<tr class="bg-gray-50 border-b border-gray-200">
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Timestamp</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">User</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Email</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Action Type</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">IP Address</th>
</tr>
</thead>
<tbody>
<tr
v-for="entry in entries"
:key="entry.id"
class="border-b border-gray-100 hover:bg-gray-50 transition-colors"
>
<td class="px-4 py-3 font-mono text-xs text-gray-500">{{ formatTimestamp(entry.created_at) }}</td>
<td class="px-4 py-3 text-sm text-gray-700">{{ entry.user_handle || entry.user_id || '—' }}</td>
<td class="px-4 py-3 text-sm text-gray-500">
<span v-if="entry.user_email">{{ entry.user_email }}</span>
<span v-else-if="entry.metadata_?.attempted_email_hash" class="text-amber-600 font-mono text-xs">hash:{{ entry.metadata_.attempted_email_hash }} <span class="text-xs not-italic">(attempted)</span></span>
<span v-else></span>
</td>
<td class="px-4 py-3">
<span
class="text-xs px-2 py-1 rounded-full font-medium"
:class="actionTypeClass(entry.event_type)"
>
{{ entry.event_type }}
</span>
</td>
<td class="px-4 py-3 text-sm text-gray-700 font-mono text-xs">{{ entry.ip_address || '—' }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div v-if="!loading && entries.length > 0" class="flex items-center justify-between mt-4">
<button
@click="prevPage"
:disabled="page <= 1"
class="text-sm text-gray-600 hover:text-gray-900 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
Previous
</button>
<span class="text-sm text-gray-500">Page {{ page }}</span>
<button
@click="nextPage"
:disabled="page * perPage >= total"
class="text-sm text-gray-600 hover:text-gray-900 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
Next
</button>
</div>
<!-- Daily exports section (D-17, C-4) -->
<div class="border-t border-gray-100 mt-6 pt-6">
<h3 class="text-sm font-semibold text-gray-700 mb-3">Daily exports</h3>
<p v-if="loadingExports" class="text-sm text-gray-400">Loading exports</p>
<p v-else-if="dailyExports.length === 0" class="text-sm text-gray-400 italic">
No daily exports available.
</p>
<div v-else class="flex items-end gap-3">
<select
v-model="selectedExportDate"
class="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white"
>
<option value="" disabled>Choose a date</option>
<option
v-for="exp in dailyExports"
:key="exp.date"
:value="exp.date"
>
{{ exp.date }}
</option>
</select>
<button
@click="downloadDailyExport"
:disabled="!selectedExportDate || downloadingExport"
class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm px-4 py-2 rounded-lg disabled:opacity-50 transition-colors flex items-center gap-1"
>
<span v-if="downloadingExport" class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"></span>
Download
</button>
</div>
<p v-if="exportsError" class="text-xs text-red-600 mt-2">{{ exportsError }}</p>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import * as api from '../../api/client.js'
const entries = ref([])
const total = ref(0)
const page = ref(1)
const perPage = 50
const loading = ref(false)
const fetchError = ref(null)
const exportingCsv = ref(false)
const exportError = ref(null)
// Daily exports state (D-17)
const dailyExports = ref([])
const loadingExports = ref(false)
const selectedExportDate = ref('')
const downloadingExport = ref(false)
const exportsError = ref(null)
const filters = reactive({
start: '',
end: '',
user_handle: '',
event_type: '',
})
onMounted(() => {
fetchLog()
loadDailyExports()
})
async function fetchLog() {
loading.value = true
fetchError.value = null
try {
const data = await api.adminListAuditLog({
start: filters.start || undefined,
end: filters.end || undefined,
user_handle: filters.user_handle || undefined,
event_type: filters.event_type || undefined,
page: page.value,
per_page: perPage,
})
entries.value = Array.isArray(data) ? data : (data.items ?? [])
total.value = data.total ?? entries.value.length
} catch (e) {
entries.value = []
fetchError.value = 'Failed to load audit log. Please try again.'
} finally {
loading.value = false
}
}
function applyFilters() {
page.value = 1
fetchLog()
}
function clearFilters() {
filters.start = ''
filters.end = ''
filters.user_handle = ''
filters.event_type = ''
page.value = 1
fetchLog()
}
const activeFilterCount = computed(() => {
let count = 0
if (filters.start) count++
if (filters.end) count++
if (filters.user_handle) count++
if (filters.event_type) count++
return count
})
function prevPage() {
if (page.value > 1) {
page.value--
fetchLog()
}
}
function nextPage() {
if (page.value * perPage < total.value) {
page.value++
fetchLog()
}
}
async function exportCsv() {
exportingCsv.value = true
exportError.value = null
try {
await api.adminExportAuditLogCsv({
start: filters.start || undefined,
end: filters.end || undefined,
user_handle: filters.user_handle || undefined,
event_type: filters.event_type || undefined,
})
} catch (e) {
exportError.value = 'Export failed. Please try again.'
} finally {
exportingCsv.value = false
}
}
async function loadDailyExports() {
loadingExports.value = true
exportsError.value = null
try {
const data = await api.adminListDailyExports()
dailyExports.value = data.items ?? []
} catch (e) {
dailyExports.value = []
exportsError.value = 'Failed to load daily exports. Please try again.'
} finally {
loadingExports.value = false
}
}
async function downloadDailyExport() {
if (!selectedExportDate.value) return
downloadingExport.value = true
exportsError.value = null
try {
await api.adminDownloadDailyExport(selectedExportDate.value)
} catch (e) {
exportsError.value = 'Download failed. Please try again.'
} finally {
downloadingExport.value = false
}
}
function formatTimestamp(iso) {
if (!iso) return '—'
try {
return new Date(iso).toISOString().replace('T', ' ').slice(0, 19)
} catch {
return iso
}
}
function actionTypeClass(eventType) {
if (!eventType) return 'bg-gray-100 text-gray-600'
const t = eventType.toLowerCase()
if (t.startsWith('auth')) return 'bg-blue-50 text-blue-600'
if (t.startsWith('document')) return 'bg-gray-100 text-gray-600'
if (t.startsWith('folder') || t.startsWith('share')) return 'bg-purple-50 text-purple-600'
if (t.startsWith('admin')) return 'bg-amber-50 text-amber-700'
return 'bg-gray-100 text-gray-600'
}
</script>