diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 80b7fde..83e27e6 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -6,6 +6,7 @@ import { useToastStore } from "@/stores/toast"; import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client"; import { usePushStore } from "@/stores/push"; import type { User } from "@/types/auth"; +import PaginationBar from "@/components/PaginationBar.vue"; const store = useSettingsStore(); const authStore = useAuthStore(); @@ -495,7 +496,119 @@ async function loadUsersPanel() { usersLoading.value = false; } -async function loadLogsPanel() { /* implemented in Task 4 */ } +// ── Logs panel ── +interface LogEntry { + id: number; + category: string; + user_id: number | null; + username: string | null; + action: string | null; + endpoint: string | null; + method: string | null; + status_code: number | null; + duration_ms: number | null; + ip_address: string | null; + details: string | null; + created_at: string; +} + +interface LogStats { + audit: number; + usage: number; + error: number; + total: number; +} + +const logs = ref([]); +const logStats = ref({ audit: 0, usage: 0, error: 0, total: 0 }); +const logTotal = ref(0); +const logsLoading = ref(false); +const logsLoaded = ref(false); +const expandedLogId = ref(null); +const logCategory = ref(""); +const logSearch = ref(""); +const logDateFrom = ref(""); +const logDateTo = ref(""); +const logLimit = 50; +const logOffset = ref(0); +let logSearchTimeout: ReturnType | null = null; + +watch([logCategory, logDateFrom, logDateTo], () => { + logOffset.value = 0; + if (logsLoaded.value) fetchLogs(); +}); +watch(logSearch, () => { + if (logSearchTimeout) clearTimeout(logSearchTimeout); + logSearchTimeout = setTimeout(() => { + logOffset.value = 0; + if (logsLoaded.value) fetchLogs(); + }, 300); +}); +watch(logOffset, () => { + if (logsLoaded.value) fetchLogs(); +}); + +async function fetchLogs() { + try { + const params = new URLSearchParams(); + if (logCategory.value) params.set("category", logCategory.value); + if (logSearch.value) params.set("search", logSearch.value); + if (logDateFrom.value) params.set("date_from", logDateFrom.value); + if (logDateTo.value) params.set("date_to", logDateTo.value); + params.set("limit", String(logLimit)); + params.set("offset", String(logOffset.value)); + const data = await apiGet<{ logs: LogEntry[]; total: number }>(`/api/admin/logs?${params}`); + logs.value = data.logs; + logTotal.value = data.total; + } catch { + toastStore.show("Failed to load logs", "error"); + } +} + +async function fetchLogStats() { + try { + logStats.value = await apiGet("/api/admin/logs/stats"); + } catch { /* ignore */ } +} + +async function loadLogsPanel() { + if (logsLoaded.value) return; + logsLoading.value = true; + await Promise.all([fetchLogs(), fetchLogStats()]); + logsLoaded.value = true; + logsLoading.value = false; +} + +function toggleLogExpand(id: number) { + expandedLogId.value = expandedLogId.value === id ? null : id; +} + +function formatLogTime(iso: string): string { + const d = new Date(iso); + return d.toLocaleString(undefined, { + month: "short", day: "numeric", + hour: "2-digit", minute: "2-digit", second: "2-digit", + }); +} + +function formatLogDetails(details: string | null): string { + if (!details) return ""; + try { return JSON.stringify(JSON.parse(details), null, 2); } catch { return details; } +} + +function logDisplayLabel(entry: LogEntry): string { + if (entry.category === "audit" && entry.action) return entry.action; + if (entry.endpoint) return entry.endpoint; + return "—"; +} + +function clearLogFilters() { + logCategory.value = ""; + logSearch.value = ""; + logDateFrom.value = ""; + logDateTo.value = ""; + logOffset.value = 0; +} async function sendInvite() { const email = inviteEmail.value.trim().toLowerCase(); @@ -1172,6 +1285,106 @@ function formatUserDate(iso: string): string { + +
+ +
+

Overview

+
+
+ {{ logStats.total.toLocaleString() }} + Total +
+
+ {{ logStats.audit.toLocaleString() }} + Audit +
+
+ {{ logStats.usage.toLocaleString() }} + Usage +
+
+ {{ logStats.error.toLocaleString() }} + Error +
+
+
+ +
+

Log Entries

+
+ + + + + +
+ +
Loading logs...
+
No log entries found.
+ +
+ +
+ @@ -1764,4 +1977,83 @@ function formatUserDate(iso: string): string { font-size: 0.9rem; padding: 1rem 0; } + +/* Logs panel */ +.stats-section { padding: 1rem 1.25rem; } +.stats-grid { display: flex; gap: 1rem; flex-wrap: wrap; } +.stat-card { + flex: 1; + min-width: 80px; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.15rem; +} +.stat-count { font-size: 1.5rem; font-weight: 700; color: var(--color-text); } +.stat-label { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-muted); +} +.stat-audit { color: var(--color-primary); } +.stat-usage { color: var(--color-success); } +.stat-error { color: var(--color-danger); } + +.filter-bar { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.75rem; } +.filter-select { min-width: 140px; } +.filter-input { flex: 1; min-width: 150px; } +.filter-date { width: 140px; } + +.logs-table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; } +.logs-table th { + text-align: left; + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-muted); + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--color-border); +} +.logs-table td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--color-border); + font-size: 0.85rem; +} +.logs-table tbody tr:last-child td { border-bottom: none; } +.log-row { cursor: pointer; transition: background 0.1s; } +.log-row:hover { background: var(--color-bg-secondary); } +.row-expanded { background: var(--color-bg-secondary); } +.cell-time { white-space: nowrap; color: var(--color-text-muted); font-size: 0.8rem; } +.cell-user { color: var(--color-text-secondary); } +.cell-action { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cell-status { font-family: monospace; font-size: 0.85rem; } +.cell-duration { color: var(--color-text-muted); font-size: 0.8rem; white-space: nowrap; } +.text-error { color: var(--color-danger); } +.detail-row td { padding: 0 0.75rem 0.75rem; border-bottom: 1px solid var(--color-border); } +.detail-ip { font-family: monospace; font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.4rem; } +.detail-json { + margin: 0; padding: 0.75rem; + background: var(--color-bg); border: 1px solid var(--color-border); + border-radius: var(--radius-sm); font-size: 0.8rem; + overflow-x: auto; white-space: pre-wrap; word-break: break-all; max-height: 300px; +} +.category-badge { + display: inline-block; + font-size: 0.65rem; font-weight: 700; + text-transform: uppercase; letter-spacing: 0.05em; + padding: 0.1rem 0.35rem; border-radius: var(--radius-sm); +} +.cat-audit { color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 15%, transparent); } +.cat-usage { color: var(--color-success); background: color-mix(in srgb, var(--color-success) 15%, transparent); } +.cat-error { color: var(--color-danger); background: color-mix(in srgb, var(--color-danger) 15%, transparent); } +.method-tag { + display: inline-block; + font-size: 0.65rem; font-weight: 700; font-family: monospace; + padding: 0.05rem 0.25rem; border-radius: 3px; + background: var(--color-bg-secondary); color: var(--color-text-muted); + margin-right: 0.25rem; +}