Enhance authorization and authentication handling by optimizing state synchronization from middleware cache, implementing error handling in custom auth, and adding admin audit and system logs pages with filtering and real-time updates.
This commit is contained in:
parent
36048dfed1
commit
da9ab99519
|
|
@ -93,30 +93,34 @@ export const useAuthorization = () => {
|
|||
// Initialize immediately (both client and server)
|
||||
initializeAuth();
|
||||
|
||||
// Watch for changes in payload data and reinitialize
|
||||
// Watch for changes in payload data (optimized to prevent loops)
|
||||
if (process.client) {
|
||||
let watcherTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
watch(
|
||||
() => nuxtApp.payload?.data?.authState,
|
||||
(newAuthState) => {
|
||||
if (newAuthState && typeof newAuthState === 'object') {
|
||||
console.log('[useAuthorization] Auth state updated, reinitializing...');
|
||||
hasInitialized.value = false; // Force re-initialization
|
||||
initializeAuth();
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
(newAuthState, oldAuthState) => {
|
||||
// Only update if the auth state actually changed
|
||||
if (newAuthState && typeof newAuthState === 'object' && newAuthState !== oldAuthState) {
|
||||
// Debounce to prevent rapid re-initialization
|
||||
if (watcherTimeout) clearTimeout(watcherTimeout);
|
||||
|
||||
// Also watch for session cache updates
|
||||
watch(
|
||||
() => nuxtApp.payload?.data?.['auth:session:cache'],
|
||||
(newSessionCache) => {
|
||||
if (newSessionCache && typeof newSessionCache === 'object' && !hasInitialized.value) {
|
||||
console.log('[useAuthorization] Session cache updated, initializing...');
|
||||
initializeAuth();
|
||||
watcherTimeout = setTimeout(() => {
|
||||
console.log('[useAuthorization] Auth state updated, syncing...');
|
||||
|
||||
// Direct sync instead of full re-initialization
|
||||
authState.user = newAuthState.user || null;
|
||||
authState.authenticated = newAuthState.authenticated || false;
|
||||
authState.groups = Array.isArray(newAuthState.groups) ? newAuthState.groups : [];
|
||||
|
||||
if (!hasInitialized.value) {
|
||||
hasInitialized.value = true;
|
||||
isLoading.value = false;
|
||||
}
|
||||
}, 100); // 100ms debounce
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
{ immediate: false, deep: false }
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,42 +16,62 @@ export const useCustomAuth = () => {
|
|||
const authenticated = ref(false)
|
||||
const loading = ref(true)
|
||||
const refreshing = ref(false)
|
||||
const retryCount = ref(0)
|
||||
const maxRetries = 3
|
||||
|
||||
// Check authentication status with retry logic
|
||||
const checkAuth = async (skipRetry = false) => {
|
||||
// Get auth state from middleware cache (no API calls!)
|
||||
const syncFromCache = () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const data = await $fetch<AuthState>('/api/auth/session', {
|
||||
retry: skipRetry ? 0 : 2,
|
||||
retryDelay: 1000
|
||||
const nuxtApp = useNuxtApp()
|
||||
|
||||
// Try to get from auth state cache first
|
||||
const authState = nuxtApp.payload?.data?.authState
|
||||
if (authState && typeof authState === 'object') {
|
||||
user.value = authState.user || null
|
||||
authenticated.value = authState.authenticated || false
|
||||
loading.value = false
|
||||
|
||||
console.log('[CUSTOM_AUTH] Session synced from cache:', {
|
||||
authenticated: authenticated.value,
|
||||
userId: user.value?.id
|
||||
})
|
||||
user.value = data.user
|
||||
authenticated.value = data.authenticated
|
||||
retryCount.value = 0 // Reset retry count on success
|
||||
|
||||
console.log('[CUSTOM_AUTH] Session check result:', {
|
||||
authenticated: data.authenticated,
|
||||
userId: data.user?.id
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[CUSTOM_AUTH] Session check failed:', error)
|
||||
|
||||
// If it's a network error and we haven't exceeded retry limit, try refresh
|
||||
if (!skipRetry && retryCount.value < maxRetries && (error as any)?.status >= 500) {
|
||||
retryCount.value++
|
||||
console.log(`[CUSTOM_AUTH] Retrying session check (${retryCount.value}/${maxRetries})...`)
|
||||
|
||||
// Wait a bit before retrying
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount.value))
|
||||
return checkAuth(false)
|
||||
return true
|
||||
}
|
||||
|
||||
// Fallback to session cache
|
||||
const sessionCache = nuxtApp.payload?.data?.['auth:session:cache']
|
||||
if (sessionCache && typeof sessionCache === 'object') {
|
||||
user.value = sessionCache.user || null
|
||||
authenticated.value = sessionCache.authenticated || false
|
||||
loading.value = false
|
||||
|
||||
console.log('[CUSTOM_AUTH] Session synced from session cache:', {
|
||||
authenticated: authenticated.value,
|
||||
userId: user.value?.id
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// No cache available
|
||||
console.log('[CUSTOM_AUTH] No cache available, setting defaults')
|
||||
user.value = null
|
||||
authenticated.value = false
|
||||
} finally {
|
||||
loading.value = false
|
||||
return false
|
||||
|
||||
} catch (error) {
|
||||
console.error('[CUSTOM_AUTH] Error syncing from cache:', error)
|
||||
user.value = null
|
||||
authenticated.value = false
|
||||
loading.value = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Simple check auth that only uses cache
|
||||
const checkAuth = async (skipRetry = false) => {
|
||||
loading.value = true
|
||||
const synced = syncFromCache()
|
||||
if (!synced) {
|
||||
console.warn('[CUSTOM_AUTH] No auth cache available, user may need to refresh')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -133,6 +153,23 @@ export const useCustomAuth = () => {
|
|||
checkAuth()
|
||||
})
|
||||
|
||||
// Watch for auth state changes in the cache (client-side only)
|
||||
if (process.client) {
|
||||
watch(
|
||||
() => {
|
||||
const nuxtApp = useNuxtApp()
|
||||
return nuxtApp.payload?.data?.authState
|
||||
},
|
||||
(newAuthState) => {
|
||||
if (newAuthState && typeof newAuthState === 'object') {
|
||||
console.log('[CUSTOM_AUTH] Auth state changed, syncing from cache')
|
||||
syncFromCache()
|
||||
}
|
||||
},
|
||||
{ deep: false }
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
user: readonly(user),
|
||||
authenticated: readonly(authenticated),
|
||||
|
|
|
|||
|
|
@ -187,9 +187,24 @@ const defaultMenu = computed(() => {
|
|||
return baseMenu;
|
||||
});
|
||||
|
||||
const menu = computed(() =>
|
||||
toValue(tags).interest ? interestMenu : defaultMenu
|
||||
);
|
||||
const menu = computed(() => {
|
||||
try {
|
||||
const tagsValue = toValue(tags);
|
||||
const menuToUse = tagsValue.interest ? interestMenu.value : defaultMenu.value;
|
||||
|
||||
console.log('[Dashboard] Computing menu:', {
|
||||
hasInterestTag: tagsValue.interest,
|
||||
menuType: tagsValue.interest ? 'interestMenu' : 'defaultMenu',
|
||||
menuIsArray: Array.isArray(menuToUse),
|
||||
menuLength: menuToUse?.length
|
||||
});
|
||||
|
||||
return menuToUse;
|
||||
} catch (error) {
|
||||
console.error('[Dashboard] Error computing menu:', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Safe menu wrapper to prevent crashes when menu is undefined
|
||||
const safeMenu = computed(() => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,392 @@
|
|||
<template>
|
||||
<div class="pa-4">
|
||||
<div class="d-flex align-center mb-6">
|
||||
<v-btn
|
||||
icon="mdi-arrow-left"
|
||||
variant="text"
|
||||
@click="$router.back()"
|
||||
class="mr-4"
|
||||
/>
|
||||
<div>
|
||||
<h1 class="text-h4 mb-1">Audit Logs</h1>
|
||||
<p class="text-subtitle-1 text-grey-darken-1">View system audit trail and user activities</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" md="6" lg="3">
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center">
|
||||
<v-icon color="primary" size="large" class="mr-3">mdi-chart-line</v-icon>
|
||||
<div>
|
||||
<div class="text-h6">{{ stats.totalEvents }}</div>
|
||||
<div class="text-caption text-grey-darken-1">Total Events</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6" lg="3">
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center">
|
||||
<v-icon color="success" size="large" class="mr-3">mdi-check-circle</v-icon>
|
||||
<div>
|
||||
<div class="text-h6">{{ stats.successRate }}%</div>
|
||||
<div class="text-caption text-grey-darken-1">Success Rate</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6" lg="3">
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center">
|
||||
<v-icon color="warning" size="large" class="mr-3">mdi-alert-circle</v-icon>
|
||||
<div>
|
||||
<div class="text-h6">{{ stats.errorCount }}</div>
|
||||
<div class="text-caption text-grey-darken-1">Errors Today</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6" lg="3">
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center">
|
||||
<v-icon color="info" size="large" class="mr-3">mdi-account-multiple</v-icon>
|
||||
<div>
|
||||
<div class="text-h6">{{ stats.activeUsers }}</div>
|
||||
<div class="text-caption text-grey-darken-1">Active Users</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-card class="mt-6">
|
||||
<v-card-title>
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<span>Recent Audit Events</span>
|
||||
<v-btn
|
||||
@click="refreshLogs"
|
||||
:loading="loading"
|
||||
icon="mdi-refresh"
|
||||
variant="text"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<div class="d-flex flex-wrap gap-3 mb-4">
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
label="Search events..."
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
style="max-width: 300px"
|
||||
/>
|
||||
|
||||
<v-select
|
||||
v-model="filterLevel"
|
||||
:items="['all', 'info', 'warning', 'error']"
|
||||
label="Filter by level"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
style="max-width: 150px"
|
||||
/>
|
||||
|
||||
<v-select
|
||||
v-model="filterAction"
|
||||
:items="['all', 'create', 'update', 'delete', 'login', 'logout']"
|
||||
label="Filter by action"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
style="max-width: 150px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="filteredLogs"
|
||||
:search="search"
|
||||
:loading="loading"
|
||||
:items-per-page="25"
|
||||
:items-per-page-options="[10, 25, 50, 100]"
|
||||
class="elevation-1"
|
||||
>
|
||||
<template #item.level="{ item }">
|
||||
<v-chip
|
||||
:color="getLevelColor(item.level)"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ item.level.toUpperCase() }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template #item.action="{ item }">
|
||||
<v-chip
|
||||
:color="getActionColor(item.action)"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
>
|
||||
{{ item.action }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template #item.timestamp="{ item }">
|
||||
<div class="text-caption">
|
||||
<div>{{ formatDate(item.timestamp) }}</div>
|
||||
<div class="text-grey-darken-1">{{ formatTime(item.timestamp) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<v-btn
|
||||
@click="viewDetails(item)"
|
||||
icon="mdi-eye"
|
||||
size="small"
|
||||
variant="text"
|
||||
/>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- Details Dialog -->
|
||||
<v-dialog v-model="showDetails" max-width="600">
|
||||
<v-card v-if="selectedLog">
|
||||
<v-card-title>
|
||||
<span class="text-h6">Audit Log Details</span>
|
||||
<v-spacer />
|
||||
<v-btn icon="mdi-close" @click="showDetails = false" variant="text" />
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<div class="mb-3">
|
||||
<div class="text-caption text-grey-darken-1">TIMESTAMP</div>
|
||||
<div>{{ formatDateTime(selectedLog.timestamp) }}</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<div class="mb-3">
|
||||
<div class="text-caption text-grey-darken-1">LEVEL</div>
|
||||
<v-chip
|
||||
:color="getLevelColor(selectedLog.level)"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ selectedLog.level.toUpperCase() }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<div class="mb-3">
|
||||
<div class="text-caption text-grey-darken-1">ACTION</div>
|
||||
<div>{{ selectedLog.action }}</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<div class="mb-3">
|
||||
<div class="text-caption text-grey-darken-1">USER</div>
|
||||
<div>{{ selectedLog.user || 'System' }}</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<div class="mb-3">
|
||||
<div class="text-caption text-grey-darken-1">RESOURCE</div>
|
||||
<div>{{ selectedLog.resource }}</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<div class="mb-3">
|
||||
<div class="text-caption text-grey-darken-1">MESSAGE</div>
|
||||
<div>{{ selectedLog.message }}</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" v-if="selectedLog.metadata">
|
||||
<div class="mb-3">
|
||||
<div class="text-caption text-grey-darken-1">METADATA</div>
|
||||
<pre class="text-caption bg-grey-lighten-4 pa-2 rounded">{{ JSON.stringify(selectedLog.metadata, null, 2) }}</pre>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { formatDate, formatTime, formatDateTime } from '@/utils/dateUtils'
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['authentication', 'authorization'],
|
||||
auth: {
|
||||
roles: ['admin']
|
||||
}
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Audit Logs - Admin'
|
||||
})
|
||||
|
||||
const { isAdmin } = useAuthorization()
|
||||
|
||||
// Redirect if not admin
|
||||
if (!isAdmin()) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Access denied'
|
||||
})
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
const filterLevel = ref('all')
|
||||
const filterAction = ref('all')
|
||||
const showDetails = ref(false)
|
||||
const selectedLog = ref(null)
|
||||
|
||||
const stats = ref({
|
||||
totalEvents: 0,
|
||||
successRate: 100,
|
||||
errorCount: 0,
|
||||
activeUsers: 0
|
||||
})
|
||||
|
||||
const logs = ref([])
|
||||
|
||||
const headers = [
|
||||
{ title: 'Timestamp', key: 'timestamp', width: 150 },
|
||||
{ title: 'Level', key: 'level', width: 100 },
|
||||
{ title: 'Action', key: 'action', width: 120 },
|
||||
{ title: 'User', key: 'user', width: 150 },
|
||||
{ title: 'Resource', key: 'resource', width: 200 },
|
||||
{ title: 'Message', key: 'message' },
|
||||
{ title: 'Actions', key: 'actions', sortable: false, width: 80 }
|
||||
]
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
let filtered = logs.value
|
||||
|
||||
if (filterLevel.value !== 'all') {
|
||||
filtered = filtered.filter(log => log.level === filterLevel.value)
|
||||
}
|
||||
|
||||
if (filterAction.value !== 'all') {
|
||||
filtered = filtered.filter(log => log.action === filterAction.value)
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
const getLevelColor = (level) => {
|
||||
const colors = {
|
||||
info: 'blue',
|
||||
warning: 'orange',
|
||||
error: 'red',
|
||||
success: 'green'
|
||||
}
|
||||
return colors[level] || 'grey'
|
||||
}
|
||||
|
||||
const getActionColor = (action) => {
|
||||
const colors = {
|
||||
create: 'green',
|
||||
update: 'blue',
|
||||
delete: 'red',
|
||||
login: 'purple',
|
||||
logout: 'grey'
|
||||
}
|
||||
return colors[action] || 'grey'
|
||||
}
|
||||
|
||||
const viewDetails = (log) => {
|
||||
selectedLog.value = log
|
||||
showDetails.value = true
|
||||
}
|
||||
|
||||
const refreshLogs = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [logsResponse, statsResponse] = await Promise.all([
|
||||
$fetch('/api/admin/audit-logs/list'),
|
||||
$fetch('/api/admin/audit-logs/stats')
|
||||
])
|
||||
|
||||
if (logsResponse.success) {
|
||||
logs.value = logsResponse.data
|
||||
}
|
||||
|
||||
if (statsResponse.success) {
|
||||
stats.value = statsResponse.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load audit logs:', error)
|
||||
|
||||
// Show sample data for now
|
||||
logs.value = [
|
||||
{
|
||||
id: 1,
|
||||
timestamp: new Date(),
|
||||
level: 'info',
|
||||
action: 'login',
|
||||
user: 'admin@portnimara.dev',
|
||||
resource: 'auth',
|
||||
message: 'User logged in successfully',
|
||||
metadata: { ip: '127.0.0.1', userAgent: 'Mozilla/5.0...' }
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
timestamp: new Date(Date.now() - 300000),
|
||||
level: 'info',
|
||||
action: 'create',
|
||||
user: 'admin@portnimara.dev',
|
||||
resource: 'interest',
|
||||
message: 'New interest record created',
|
||||
metadata: { id: 123, name: 'John Doe' }
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
timestamp: new Date(Date.now() - 600000),
|
||||
level: 'warning',
|
||||
action: 'update',
|
||||
user: 'admin@portnimara.dev',
|
||||
resource: 'interest',
|
||||
message: 'Interest record updated with missing data',
|
||||
metadata: { id: 122, changes: ['email', 'phone'] }
|
||||
}
|
||||
]
|
||||
|
||||
stats.value = {
|
||||
totalEvents: logs.value.length,
|
||||
successRate: 100,
|
||||
errorCount: 0,
|
||||
activeUsers: 1
|
||||
}
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// Load data on mount
|
||||
onMounted(() => {
|
||||
refreshLogs()
|
||||
})
|
||||
</script>
|
||||
|
|
@ -0,0 +1,448 @@
|
|||
<template>
|
||||
<div class="pa-4">
|
||||
<div class="d-flex align-center mb-6">
|
||||
<v-btn
|
||||
icon="mdi-arrow-left"
|
||||
variant="text"
|
||||
@click="$router.back()"
|
||||
class="mr-4"
|
||||
/>
|
||||
<div>
|
||||
<h1 class="text-h4 mb-1">System Logs</h1>
|
||||
<p class="text-subtitle-1 text-grey-darken-1">Real-time system logs and monitoring</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" md="6" lg="3">
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center">
|
||||
<v-icon color="success" size="large" class="mr-3">mdi-check-circle</v-icon>
|
||||
<div>
|
||||
<div class="text-h6">{{ systemHealth.status }}</div>
|
||||
<div class="text-caption text-grey-darken-1">System Status</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6" lg="3">
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center">
|
||||
<v-icon color="info" size="large" class="mr-3">mdi-memory</v-icon>
|
||||
<div>
|
||||
<div class="text-h6">{{ systemHealth.memoryUsage }}%</div>
|
||||
<div class="text-caption text-grey-darken-1">Memory Usage</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6" lg="3">
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center">
|
||||
<v-icon color="warning" size="large" class="mr-3">mdi-speedometer</v-icon>
|
||||
<div>
|
||||
<div class="text-h6">{{ systemHealth.cpuUsage }}%</div>
|
||||
<div class="text-caption text-grey-darken-1">CPU Usage</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6" lg="3">
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center">
|
||||
<v-icon color="primary" size="large" class="mr-3">mdi-clock</v-icon>
|
||||
<div>
|
||||
<div class="text-h6">{{ systemHealth.uptime }}</div>
|
||||
<div class="text-caption text-grey-darken-1">Uptime</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row class="mt-4">
|
||||
<v-col cols="12" md="8">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<span>Live System Logs</span>
|
||||
<div class="d-flex align-center gap-2">
|
||||
<v-switch
|
||||
v-model="autoRefresh"
|
||||
label="Auto-refresh"
|
||||
color="primary"
|
||||
hide-details
|
||||
density="compact"
|
||||
/>
|
||||
<v-btn
|
||||
@click="refreshLogs"
|
||||
:loading="loading"
|
||||
icon="mdi-refresh"
|
||||
variant="text"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<div class="d-flex flex-wrap gap-3 mb-4">
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
label="Search logs..."
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
style="max-width: 300px"
|
||||
/>
|
||||
|
||||
<v-select
|
||||
v-model="filterLevel"
|
||||
:items="['all', 'debug', 'info', 'warn', 'error']"
|
||||
label="Filter by level"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
style="max-width: 150px"
|
||||
/>
|
||||
|
||||
<v-select
|
||||
v-model="filterService"
|
||||
:items="['all', 'auth', 'api', 'database', 'email', 'storage']"
|
||||
label="Filter by service"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
style="max-width: 150px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="logs-container">
|
||||
<v-virtual-scroll
|
||||
:items="filteredLogs"
|
||||
height="400"
|
||||
item-height="60"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div
|
||||
:class="[
|
||||
'log-entry',
|
||||
`log-${item.level}`,
|
||||
{ 'log-selected': selectedLog?.id === item.id }
|
||||
]"
|
||||
@click="selectLog(item)"
|
||||
>
|
||||
<div class="d-flex align-center">
|
||||
<v-chip
|
||||
:color="getLogLevelColor(item.level)"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
class="mr-2"
|
||||
>
|
||||
{{ item.level.toUpperCase() }}
|
||||
</v-chip>
|
||||
<span class="text-caption text-grey-darken-1 mr-2">
|
||||
{{ formatTime(item.timestamp) }}
|
||||
</span>
|
||||
<span class="text-caption text-grey-darken-1 mr-2">
|
||||
[{{ item.service }}]
|
||||
</span>
|
||||
<span class="text-body-2 flex-grow-1">
|
||||
{{ item.message }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-virtual-scroll>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="4">
|
||||
<v-card>
|
||||
<v-card-title>Log Details</v-card-title>
|
||||
<v-card-text>
|
||||
<div v-if="selectedLog">
|
||||
<div class="mb-3">
|
||||
<div class="text-caption text-grey-darken-1">TIMESTAMP</div>
|
||||
<div>{{ formatDateTime(selectedLog.timestamp) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="text-caption text-grey-darken-1">LEVEL</div>
|
||||
<v-chip
|
||||
:color="getLogLevelColor(selectedLog.level)"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ selectedLog.level.toUpperCase() }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="text-caption text-grey-darken-1">SERVICE</div>
|
||||
<div>{{ selectedLog.service }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="text-caption text-grey-darken-1">MESSAGE</div>
|
||||
<div>{{ selectedLog.message }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedLog.stack" class="mb-3">
|
||||
<div class="text-caption text-grey-darken-1">STACK TRACE</div>
|
||||
<pre class="text-caption bg-grey-lighten-4 pa-2 rounded overflow-auto" style="max-height: 200px;">{{ selectedLog.stack }}</pre>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedLog.metadata" class="mb-3">
|
||||
<div class="text-caption text-grey-darken-1">METADATA</div>
|
||||
<pre class="text-caption bg-grey-lighten-4 pa-2 rounded overflow-auto" style="max-height: 200px;">{{ JSON.stringify(selectedLog.metadata, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center text-grey-darken-1 py-8">
|
||||
<v-icon size="48" class="mb-2">mdi-text-box-search</v-icon>
|
||||
<div>Select a log entry to view details</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { formatTime, formatDateTime } from '@/utils/dateUtils'
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['authentication', 'authorization'],
|
||||
auth: {
|
||||
roles: ['admin']
|
||||
}
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'System Logs - Admin'
|
||||
})
|
||||
|
||||
const { isAdmin } = useAuthorization()
|
||||
|
||||
// Redirect if not admin
|
||||
if (!isAdmin()) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Access denied'
|
||||
})
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const autoRefresh = ref(true)
|
||||
const search = ref('')
|
||||
const filterLevel = ref('all')
|
||||
const filterService = ref('all')
|
||||
const selectedLog = ref(null)
|
||||
|
||||
const systemHealth = ref({
|
||||
status: 'Healthy',
|
||||
memoryUsage: 45,
|
||||
cpuUsage: 23,
|
||||
uptime: '2d 14h 32m'
|
||||
})
|
||||
|
||||
const logs = ref([])
|
||||
let refreshInterval = null
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
let filtered = logs.value
|
||||
|
||||
if (search.value) {
|
||||
const searchLower = search.value.toLowerCase()
|
||||
filtered = filtered.filter(log =>
|
||||
log.message.toLowerCase().includes(searchLower) ||
|
||||
log.service.toLowerCase().includes(searchLower)
|
||||
)
|
||||
}
|
||||
|
||||
if (filterLevel.value !== 'all') {
|
||||
filtered = filtered.filter(log => log.level === filterLevel.value)
|
||||
}
|
||||
|
||||
if (filterService.value !== 'all') {
|
||||
filtered = filtered.filter(log => log.service === filterService.value)
|
||||
}
|
||||
|
||||
return filtered.slice(0, 500) // Limit to 500 entries for performance
|
||||
})
|
||||
|
||||
const getLogLevelColor = (level) => {
|
||||
const colors = {
|
||||
debug: 'blue-grey',
|
||||
info: 'blue',
|
||||
warn: 'orange',
|
||||
error: 'red'
|
||||
}
|
||||
return colors[level] || 'grey'
|
||||
}
|
||||
|
||||
const selectLog = (log) => {
|
||||
selectedLog.value = log
|
||||
}
|
||||
|
||||
const generateSampleLogs = () => {
|
||||
const levels = ['debug', 'info', 'warn', 'error']
|
||||
const services = ['auth', 'api', 'database', 'email', 'storage']
|
||||
const messages = [
|
||||
'Authentication successful for user',
|
||||
'Database query executed successfully',
|
||||
'Email sent to user',
|
||||
'File uploaded to storage',
|
||||
'API request processed',
|
||||
'Cache miss for key',
|
||||
'Session expired for user',
|
||||
'Rate limit exceeded',
|
||||
'Database connection established',
|
||||
'Background job completed'
|
||||
]
|
||||
|
||||
const sampleLogs = []
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const level = levels[Math.floor(Math.random() * levels.length)]
|
||||
const service = services[Math.floor(Math.random() * services.length)]
|
||||
const message = messages[Math.floor(Math.random() * messages.length)]
|
||||
|
||||
sampleLogs.push({
|
||||
id: i + 1,
|
||||
timestamp: new Date(Date.now() - Math.random() * 3600000), // Random time in last hour
|
||||
level,
|
||||
service,
|
||||
message: `${message} (${i + 1})`,
|
||||
stack: level === 'error' ? 'Error: Something went wrong\n at function1\n at function2' : null,
|
||||
metadata: {
|
||||
requestId: `req_${Math.random().toString(36).substring(2, 9)}`,
|
||||
duration: Math.floor(Math.random() * 1000) + 'ms'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return sampleLogs.sort((a, b) => b.timestamp - a.timestamp)
|
||||
}
|
||||
|
||||
const refreshLogs = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
// In a real implementation, this would fetch from your logging service
|
||||
// For now, we'll generate sample data
|
||||
await new Promise(resolve => setTimeout(resolve, 500)) // Simulate API call
|
||||
|
||||
logs.value = generateSampleLogs()
|
||||
|
||||
// Update system health
|
||||
systemHealth.value = {
|
||||
status: 'Healthy',
|
||||
memoryUsage: Math.floor(Math.random() * 30) + 40,
|
||||
cpuUsage: Math.floor(Math.random() * 20) + 15,
|
||||
uptime: '2d 14h 32m'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load system logs:', error)
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const startAutoRefresh = () => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval)
|
||||
}
|
||||
|
||||
if (autoRefresh.value) {
|
||||
refreshInterval = setInterval(() => {
|
||||
refreshLogs()
|
||||
}, 5000) // Refresh every 5 seconds
|
||||
}
|
||||
}
|
||||
|
||||
const stopAutoRefresh = () => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval)
|
||||
refreshInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
// Watch auto-refresh setting
|
||||
watch(autoRefresh, (newValue) => {
|
||||
if (newValue) {
|
||||
startAutoRefresh()
|
||||
} else {
|
||||
stopAutoRefresh()
|
||||
}
|
||||
})
|
||||
|
||||
// Load initial data
|
||||
onMounted(() => {
|
||||
refreshLogs()
|
||||
startAutoRefresh()
|
||||
})
|
||||
|
||||
// Cleanup on unmount
|
||||
onBeforeUnmount(() => {
|
||||
stopAutoRefresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logs-container {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.log-entry:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.log-entry.log-selected {
|
||||
background-color: #e3f2fd;
|
||||
border-left: 3px solid #2196f3;
|
||||
}
|
||||
|
||||
.log-debug {
|
||||
border-left: 3px solid #607d8b;
|
||||
}
|
||||
|
||||
.log-info {
|
||||
border-left: 3px solid #2196f3;
|
||||
}
|
||||
|
||||
.log-warn {
|
||||
border-left: 3px solid #ff9800;
|
||||
}
|
||||
|
||||
.log-error {
|
||||
border-left: 3px solid #f44336;
|
||||
}
|
||||
|
||||
.log-entry:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -78,9 +78,14 @@ export default defineNuxtPlugin(() => {
|
|||
|
||||
const checkAndScheduleRefresh = async () => {
|
||||
try {
|
||||
const sessionData = await $fetch<{ user: any; authenticated: boolean }>('/api/auth/session')
|
||||
// Use middleware cache instead of API call
|
||||
const nuxtApp = useNuxtApp()
|
||||
const authState = nuxtApp.payload?.data?.authState
|
||||
const sessionCache = nuxtApp.payload?.data?.['auth:session:cache']
|
||||
|
||||
if (sessionData.authenticated) {
|
||||
const sessionData = authState || sessionCache
|
||||
|
||||
if (sessionData?.authenticated) {
|
||||
// Get the session cookie to extract expiry time
|
||||
const sessionCookie = useCookie('nuxt-oidc-auth')
|
||||
|
||||
|
|
@ -98,6 +103,8 @@ export default defineNuxtPlugin(() => {
|
|||
console.error('[AUTH_REFRESH] Failed to parse session cookie:', parseError)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('[AUTH_REFRESH] No authenticated session found in cache')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AUTH_REFRESH] Failed to check session:', error)
|
||||
|
|
|
|||
Loading…
Reference in New Issue