500 lines
20 KiB
Vue

<template>
<div>
<LayoutsBreadcrumb />
<!-- Page Info Card -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-list-alt"></Icon>
<h1 class="text-xl font-bold text-primary">Audit Logs</h1>
</div>
</template>
<template #body>
<p class="text-gray-600">
Comprehensive view of all notification logs and audit trail entries.
Filter, search, and analyze notification activities with detailed information.
</p>
</template>
</rs-card>
<!-- Summary Stats -->
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
<rs-card
v-for="(item, index) in summaryStats"
:key="index"
class="transition-all duration-300 hover:shadow-lg"
>
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
<div
class="p-5 flex justify-center items-center bg-primary/20 rounded-2xl transition-all duration-300 hover:bg-primary/30"
>
<Icon class="text-primary text-3xl" :name="item.icon"></Icon>
</div>
<div class="flex-1 truncate">
<span class="block font-bold text-2xl leading-tight text-primary">
{{ item.value }}
</span>
<span class="text-sm font-medium text-gray-600">
{{ item.title }}
</span>
</div>
</div>
</rs-card>
</div>
<!-- Main Content Card -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-history"></Icon>
<h3 class="text-lg font-semibold text-primary">All Audit Logs</h3>
</div>
<div class="flex items-center gap-2">
<rs-button variant="secondary-outline" size="sm" @click="exportData('csv')">
<Icon name="ic:outline-file-download" class="mr-1"/> Export
</rs-button>
<rs-button variant="primary-outline" size="sm" @click="refreshLogs">
<Icon name="ic:outline-refresh" class="mr-1"/> Refresh
</rs-button>
</div>
</div>
</template>
<template #body>
<!-- Filters Section -->
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
<div class="xl:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Date Range</label>
<rs-button variant="primary-outline" class="w-full justify-start">
<Icon name="ic:outline-date-range" class="mr-2"/>
{{ selectedDateRange.start && selectedDateRange.end ?
`${formatDate(selectedDateRange.start)} - ${formatDate(selectedDateRange.end)}` :
'Select date range' }}
</rs-button>
</div>
<FormKit
type="text"
label="User or Actor"
v-model="filters.actor"
placeholder="Enter user or actor"
outer-class="mb-0"
/>
<FormKit
type="select"
label="Channel"
v-model="filters.channel"
placeholder="Select channel"
:options="channelOptionsForFormKit"
outer-class="mb-0"
/>
<FormKit
type="select"
label="Status"
v-model="filters.status"
placeholder="Select status"
:options="statusOptionsForFormKit"
outer-class="mb-0"
/>
<FormKit
type="search"
label="Search Content"
v-model="filters.keyword"
placeholder="Search in logs..."
outer-class="mb-0"
/>
</div>
<div class="flex justify-between items-center mt-4">
<div class="text-sm text-gray-600">
Showing {{ filteredLogs.length }} of {{ dummyLogs.length }} entries
</div>
<div class="flex gap-2">
<rs-button @click="clearFilters" variant="secondary-outline" size="sm">
<Icon name="ic:outline-clear" class="mr-1"/> Clear Filters
</rs-button>
<rs-button @click="applyFilters" variant="primary">
<Icon name="ic:outline-search" class="mr-1"/> Apply Filters
</rs-button>
</div>
</div>
</div>
<!-- Log Table -->
<rs-table
v-if="filteredLogs && filteredLogs.length > 0"
:data="filteredLogs"
:field="logTableFields"
:options="{
variant: 'default',
striped: true,
borderless: false,
hover: true
}"
:options-advanced="{
sortable: true,
filterable: true,
responsive: true,
outsideBorder: true
}"
advanced
>
<template #timestamp="{ value }">
<div class="text-sm">
<div class="font-medium">{{ formatDate(value.timestamp, true) }}</div>
<div class="text-gray-500 text-xs">{{ new Date(value.timestamp).toLocaleTimeString() }}</div>
</div>
</template>
<template #status="{ value }">
<rs-badge
:variant="value.status === 'Sent' ? 'success' :
value.status === 'Failed' ? 'danger' :
value.status === 'Opened' ? 'info' :
value.status === 'Queued' ? 'warning' : 'secondary'"
size="sm"
>
{{ value.status }}
</rs-badge>
</template>
<template #actions="{ value }">
<rs-button variant="primary-text" size="sm" @click="viewLogDetails(value)">
<Icon name="ic:outline-visibility" class="mr-1"/> View Details
</rs-button>
</template>
</rs-table>
<div v-else class="text-center py-8 text-gray-500">
<Icon name="ic:outline-search-off" class="text-4xl mb-2 mx-auto"/>
<p>No log entries found matching your filters.</p>
<rs-button variant="primary-outline" @click="clearFilters" class="mt-4">
Clear Filters
</rs-button>
</div>
</template>
</rs-card>
<!-- Log Details Modal -->
<rs-modal
v-model="isLogDetailModalOpen"
title="Log Entry Details"
size="lg"
:overlay-close="true"
>
<template #body>
<div v-if="selectedLog" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Log ID</label>
<p class="font-mono text-sm">{{ selectedLog.id }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Timestamp</label>
<p class="text-sm">{{ formatDate(selectedLog.timestamp, true) }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Action</label>
<p class="text-sm">{{ selectedLog.action }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Actor</label>
<p class="text-sm">{{ selectedLog.actor }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Channel</label>
<p class="text-sm">{{ selectedLog.channel }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Status</label>
<rs-badge
:variant="selectedLog.status === 'Sent' ? 'success' :
selectedLog.status === 'Failed' ? 'danger' :
selectedLog.status === 'Opened' ? 'info' :
selectedLog.status === 'Queued' ? 'warning' : 'secondary'"
size="sm"
>
{{ selectedLog.status }}
</rs-badge>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Source IP</label>
<p class="text-sm font-mono">{{ selectedLog.sourceIp }}</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Details</label>
<p class="text-sm p-3 bg-gray-50 dark:bg-gray-800 rounded">{{ selectedLog.details }}</p>
</div>
<template v-if="selectedLog.status === 'Failed'">
<div class="border-t pt-4">
<h4 class="text-lg font-medium text-red-600 dark:text-red-400 mb-3">Error Information</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Error Code</label>
<p class="text-sm font-mono text-red-600 dark:text-red-400">{{ selectedLog.errorCode }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Error Message</label>
<p class="text-sm text-red-600 dark:text-red-400">{{ selectedLog.errorMessage }}</p>
</div>
</div>
<div class="mt-4">
<label class="block text-sm font-medium text-gray-500 mb-2">Failed Payload</label>
<pre class="bg-gray-100 dark:bg-gray-800 p-3 rounded text-xs max-h-32 overflow-y-auto">{{ selectedLog.failedPayload }}</pre>
</div>
<div class="mt-4">
<label class="block text-sm font-medium text-gray-500 mb-1">Troubleshooting</label>
<div class="p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded border-l-4 border-yellow-400">
<p class="text-sm text-yellow-800 dark:text-yellow-200">{{ selectedLog.troubleshooting }}</p>
</div>
</div>
</div>
</template>
</div>
</template>
<template #footer>
<rs-button @click="isLogDetailModalOpen = false" variant="primary-outline">
Close
</rs-button>
</template>
</rs-modal>
</div>
</template>
<script setup>
definePageMeta({
title: "Audit Logs",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Dashboard",
path: "/dashboard",
},
{
name: "Notification",
path: "/notification",
},
{
name: "Logs & Audit Trail",
path: "/notification/log-audit",
},
{
name: "Audit Logs",
path: "/notification/log-audit/logs",
type: "current"
},
],
});
import { ref, computed } from 'vue'
// --- Log Filters ---
const selectedDateRange = ref({ start: null, end: null });
const filters = ref({
actor: '',
channel: null,
status: null,
keyword: ''
})
const channelOptions = ['Email', 'SMS', 'Push Notification', 'Webhook']
const statusOptions = ['Sent', 'Failed', 'Bounced', 'Opened', 'Queued', 'Created', 'Updated']
// Prepare options for FormKit select
const channelOptionsForFormKit = computed(() => [
{ label: 'All Channels', value: null },
...channelOptions.map(opt => ({ label: opt, value: opt }))
])
const statusOptionsForFormKit = computed(() => [
{ label: 'All Status', value: null },
...statusOptions.map(opt => ({ label: opt, value: opt }))
])
const formatDate = (date, includeTime = false) => {
if (!date) return ''
const options = { year: 'numeric', month: 'short', day: 'numeric' }
if (includeTime) {
options.hour = '2-digit';
options.minute = '2-digit';
}
return new Date(date).toLocaleDateString(undefined, options)
}
// --- Log Data & Table ---
const dummyLogs = ref([
{ id: '1', timestamp: new Date('2023-10-01T10:00:00Z'), action: 'Notification Created', actor: 'Admin User', channel: 'Email', status: 'Created', sourceIp: '192.168.1.100', details: 'Welcome email template for new user signup.', errorCode: null, errorMessage: null, failedPayload: null, troubleshooting: null },
{ id: '2', timestamp: new Date('2023-10-01T10:05:00Z'), action: 'Notification Sent', actor: 'System', channel: 'Email', status: 'Sent', sourceIp: 'N/A', details: 'To: user@example.com, Subject: Important Account Update', errorCode: null, errorMessage: null, failedPayload: null, troubleshooting: null },
{ id: '3', timestamp: new Date('2023-10-01T10:15:00Z'), action: 'Delivery Failed', actor: 'System', channel: 'SMS', status: 'Failed', sourceIp: 'N/A', details: 'To: +1234560000 (Invalid Number)', errorCode: 'SMS-404', errorMessage: 'The recipient phone number is not valid or not reachable.', failedPayload: JSON.stringify({ to: "+1234560000", message: "Your verification code is 789012."}), troubleshooting: 'Check number validity and country code. Ensure the number can receive SMS.' },
{ id: '4', timestamp: new Date('2023-10-02T11:00:00Z'), action: 'Notification Opened', actor: 'user@example.com', channel: 'Email', status: 'Opened', sourceIp: '203.0.113.45', details: 'Subject: Important Account Update - Opened via Webmail on Chrome.', errorCode: null, errorMessage: null, failedPayload: null, troubleshooting: null },
{ id: '5', timestamp: new Date('2023-10-03T14:30:00Z'), action: 'Template Updated', actor: 'marketing_mgr', channel: 'N/A', status: 'Updated', sourceIp: '192.168.1.150', details: 'Promotional Email Template "SummerSale2023" updated with new banner.', errorCode: null, errorMessage: null, failedPayload: null, troubleshooting: null },
{ id: '6', timestamp: new Date('2023-10-04T09:00:00Z'), action: 'Notification Queued', actor: 'System', channel: 'Push Notification', status: 'Queued', sourceIp: 'N/A', details: 'Targeting segment: "Active Android Users"', errorCode: null, errorMessage: null, failedPayload: null, troubleshooting: null },
{ id: '7', timestamp: new Date('2023-10-04T09:05:00Z'), action: 'Notification Sent', actor: 'System', channel: 'Push Notification', status: 'Sent', sourceIp: 'N/A', details: 'Successfully sent to 5,032 devices.', errorCode: null, errorMessage: null, failedPayload: null, troubleshooting: null },
{ id: '8', timestamp: new Date('2023-10-05T16:20:00Z'), action: 'Delivery Bounced', actor: 'System', channel: 'Email', status: 'Bounced', sourceIp: 'N/A', details: 'To: nonexist@example.com, Hard Bounce', errorCode: 'SMTP-550', errorMessage: 'User mailbox does not exist.', failedPayload: JSON.stringify({to: "nonexist@example.com", subject: "Your Weekly Digest"}), troubleshooting: 'Remove email from list. Verify email collection process.' },
// Add more dummy logs for pagination and filtering
...Array.from({ length: 42 }, (_, i) => ({
id: (i + 9).toString(),
timestamp: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000),
action: ['Notification Created', 'Notification Sent', 'Delivery Failed', 'Notification Opened', 'Template Updated', 'Notification Queued', 'Delivery Bounced'][Math.floor(Math.random() * 7)],
actor: ['Admin User', 'System', 'john.doe@example.com', 'jane.doe@example.com', 'support_agent', 'marketing_mgr'][Math.floor(Math.random() * 6)],
channel: channelOptions[Math.floor(Math.random() * channelOptions.length)],
status: statusOptions[Math.floor(Math.random() * statusOptions.length)],
sourceIp: `192.168.1.${Math.floor(Math.random() * 254) + 1}`,
details: `Log entry detail for event id ${i + 9}. This notification was processed with standard parameters.`,
errorCode: Math.random() > 0.85 ? `ERR-${Math.floor(Math.random() * 100) + 200}` : null,
errorMessage: Math.random() > 0.85 ? 'A simulated intermittent error occurred during processing.' : null,
failedPayload: Math.random() > 0.85 ? JSON.stringify({ data: "some example payload data", attempt: Math.floor(Math.random()*3)+1 }) : null,
troubleshooting: Math.random() > 0.85 ? 'Retry the operation. If persistent, check downstream service status.' : null,
}))
])
// This computed property will be passed to RsTable's `data` prop
const filteredLogs = computed(() => {
return dummyLogs.value.filter(log => {
const logDate = new Date(log.timestamp);
let dateMatch = true;
if (selectedDateRange.value && selectedDateRange.value.start && selectedDateRange.value.end) {
const startDate = new Date(selectedDateRange.value.start);
startDate.setHours(0, 0, 0, 0);
const endDate = new Date(selectedDateRange.value.end);
endDate.setHours(23, 59, 59, 999);
dateMatch = logDate >= startDate && logDate <= endDate;
} else if (selectedDateRange.value && selectedDateRange.value.start) {
const startDate = new Date(selectedDateRange.value.start);
startDate.setHours(0,0,0,0);
dateMatch = logDate >= startDate;
} else if (selectedDateRange.value && selectedDateRange.value.end) {
const endDate = new Date(selectedDateRange.value.end);
endDate.setHours(23,59,59,999);
dateMatch = logDate <= endDate;
}
const actorMatch = !filters.value.actor || (log.actor && log.actor.toLowerCase().includes(filters.value.actor.toLowerCase()));
const channelMatch = !filters.value.channel || log.channel === filters.value.channel;
const statusMatch = !filters.value.status || log.status === filters.value.status;
const keywordMatch = !filters.value.keyword ||
(log.details && log.details.toLowerCase().includes(filters.value.keyword.toLowerCase())) ||
(log.action && log.action.toLowerCase().includes(filters.value.keyword.toLowerCase())) ||
(log.errorMessage && log.errorMessage.toLowerCase().includes(filters.value.keyword.toLowerCase()));
return dateMatch && actorMatch && channelMatch && statusMatch && keywordMatch;
});
})
// Fields for RsTable
const logTableFields = ['timestamp', 'action', 'actor', 'channel', 'status', 'actions']
const applyFilters = () => {
console.log('Applying filters with values:', filters.value, selectedDateRange.value);
}
const clearFilters = () => {
filters.value = {
actor: '',
channel: null,
status: null,
keyword: ''
}
selectedDateRange.value = { start: null, end: null }
}
const refreshLogs = () => {
console.log('Refreshing logs...')
// Simulate refresh
}
const exportData = (format) => {
console.log(`Exporting ${filteredLogs.value.length} logs to ${format}...`)
alert(`Exporting ${filteredLogs.value.length} log entries to ${format}. (Implementation pending)`)
}
// --- Log Detail Modal ---
const isLogDetailModalOpen = ref(false)
const selectedLog = ref(null)
const viewLogDetails = (log) => {
selectedLog.value = log
isLogDetailModalOpen.value = true
}
// Summary stats for quick overview
const summaryStats = computed(() => [
{
title: "Total Log Entries",
value: filteredLogs.value.length.toLocaleString(),
icon: "ic:outline-list-alt"
},
{
title: "Failed Deliveries",
value: filteredLogs.value.filter(log => log.status === 'Failed').length.toLocaleString(),
icon: "ic:outline-error"
},
{
title: "Successful Deliveries",
value: filteredLogs.value.filter(log => log.status === 'Sent').length.toLocaleString(),
icon: "ic:outline-check-circle"
},
{
title: "Success Rate",
value: filteredLogs.value.length > 0 ?
Math.round((filteredLogs.value.filter(log => log.status === 'Sent').length / filteredLogs.value.length) * 100) + '%' : '0%',
icon: "ic:outline-trending-up"
},
])
</script>
<style lang="scss" scoped>
// Custom styles for FormKit consistency
:deep(.formkit-outer) {
margin-bottom: 0;
}
:deep(.formkit-label) {
font-weight: 500;
color: rgb(107 114 128);
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
:deep(.formkit-input) {
border-radius: 0.5rem;
}
// Badge component styles (if RsBadge doesn't exist, these can be adjusted)
.rs-badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.rs-badge.variant-success {
@apply bg-green-100 text-green-800;
}
.rs-badge.variant-danger {
@apply bg-red-100 text-red-800;
}
.rs-badge.variant-warning {
@apply bg-yellow-100 text-yellow-800;
}
.rs-badge.variant-info {
@apply bg-blue-100 text-blue-800;
}
.rs-badge.variant-secondary {
@apply bg-gray-100 text-gray-800;
}
</style>