All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 46s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 6s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 1s
452 lines
14 KiB
Vue
452 lines
14 KiB
Vue
<template>
|
|
<div class="p-6">
|
|
<!-- Header -->
|
|
<div class="flex justify-between items-center mb-6">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-gray-900">Audit Logs</h1>
|
|
<p class="text-gray-600 mt-1">ประวัติการใช้งานระบบและกิจกรรมต่างๆ</p>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<q-btn
|
|
color="primary"
|
|
outline
|
|
icon="refresh"
|
|
label="รีเฟรช"
|
|
@click="refreshData"
|
|
/>
|
|
<q-btn
|
|
color="negative"
|
|
flat
|
|
icon="delete_sweep"
|
|
label="ล้างประวัติเก่า"
|
|
@click="openCleanupDialog"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6 mb-8">
|
|
<q-card class="bg-primary text-white">
|
|
<q-card-section>
|
|
<div class="text-h6">Logs ทั้งหมด</div>
|
|
<div class="text-h3 text-weight-bold">{{ stats.totalLogs }}</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
|
|
<q-card class="bg-secondary text-white">
|
|
<q-card-section>
|
|
<div class="text-h6">Logs วันนี้</div>
|
|
<div class="text-h3 text-weight-bold">{{ stats.todayLogs }}</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
|
|
|
|
</div>
|
|
|
|
<!-- Filter Bar -->
|
|
<q-card class="mb-6">
|
|
<q-card-section>
|
|
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
|
<q-input
|
|
v-model.number="filters.userId"
|
|
label="User ID"
|
|
outlined
|
|
dense
|
|
type="number"
|
|
clearable
|
|
/>
|
|
<q-select
|
|
v-model="filters.action"
|
|
label="Action"
|
|
outlined
|
|
dense
|
|
:options="actionOptions"
|
|
use-input
|
|
fill-input
|
|
hide-selected
|
|
input-debounce="0"
|
|
@filter="filterActions"
|
|
clearable
|
|
/>
|
|
<q-input
|
|
v-model="filters.entityType"
|
|
label="Entity Type"
|
|
outlined
|
|
dense
|
|
clearable
|
|
/>
|
|
<q-input
|
|
v-model="filters.startDate"
|
|
label="Start Date"
|
|
outlined
|
|
dense
|
|
type="date"
|
|
stack-label
|
|
clearable
|
|
/>
|
|
<q-input
|
|
v-model="filters.endDate"
|
|
label="End Date"
|
|
outlined
|
|
dense
|
|
type="date"
|
|
stack-label
|
|
clearable
|
|
/>
|
|
</div>
|
|
<div class="flex justify-end mt-4 gap-2">
|
|
<q-btn flat label="ล้างตัวกรอง" color="grey" @click="resetFilters" />
|
|
<q-btn color="primary" icon="search" label="ค้นหา" @click="fetchLogs" />
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
|
|
<!-- Data Table -->
|
|
<q-table
|
|
:rows="logs"
|
|
:columns="columns"
|
|
row-key="id"
|
|
:loading="loading"
|
|
v-model:pagination="pagination"
|
|
@request="onRequest"
|
|
:rows-per-page-options="[5, 10, 20, 50, 0]"
|
|
flat
|
|
bordered
|
|
>
|
|
<!-- Action Custom Column -->
|
|
<template v-slot:body-cell-action="props">
|
|
<q-td :props="props">
|
|
<q-badge :color="getActionColor(props.value)">
|
|
{{ props.value }}
|
|
</q-badge>
|
|
</q-td>
|
|
</template>
|
|
|
|
<!-- User Custom Column -->
|
|
<template v-slot:body-cell-user="props">
|
|
<q-td :props="props">
|
|
<div v-if="props.row.user">
|
|
<div class="font-bold">{{ props.row.user.username }}</div>
|
|
<div class="text-xs text-gray-500">{{ props.row.user.email }} (ID: {{ props.row.user.id }})</div>
|
|
</div>
|
|
<span v-else class="text-gray-400">-</span>
|
|
</q-td>
|
|
</template>
|
|
|
|
<!-- Created At Custom Column -->
|
|
<template v-slot:body-cell-created_at="props">
|
|
<q-td :props="props">
|
|
{{ formatDate(props.value) }}
|
|
</q-td>
|
|
</template>
|
|
|
|
<!-- Actions Column -->
|
|
<template v-slot:body-cell-actions="props">
|
|
<q-td :props="props" align="center">
|
|
<q-btn flat round color="primary" icon="visibility" @click="viewDetails(props.row)">
|
|
<q-tooltip>ดูรายละเอียด</q-tooltip>
|
|
</q-btn>
|
|
</q-td>
|
|
</template>
|
|
</q-table>
|
|
|
|
<!-- Details Dialog -->
|
|
<q-dialog v-model="detailsDialog" full-width>
|
|
<q-card v-if="selectedLog">
|
|
<q-card-section>
|
|
<div class="text-h6">รายละเอียด Audit Log #{{ selectedLog.id }}</div>
|
|
</q-card-section>
|
|
|
|
<q-card-section class="q-pt-none">
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<div class="text-subtitle2 text-grey">User</div>
|
|
<div>{{ selectedLog.user?.username }} ({{ selectedLog.user?.email }})</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-subtitle2 text-grey">Action</div>
|
|
<q-badge :color="getActionColor(selectedLog.action)">{{ selectedLog.action }}</q-badge>
|
|
</div>
|
|
<div>
|
|
<div class="text-subtitle2 text-grey">Time</div>
|
|
<div>{{ formatDate(selectedLog.created_at) }}</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div class="text-subtitle2 text-grey">Entity</div>
|
|
<div>{{ selectedLog.entity_type }} #{{ selectedLog.entity_id }}</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<q-separator class="my-4" />
|
|
|
|
<!-- Changes -->
|
|
<div v-if="selectedLog.old_value || selectedLog.new_value" class="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<div class="text-subtitle2 text-grey mb-1">Old Value</div>
|
|
<pre class="bg-red-50 p-2 rounded text-xs overflow-auto max-h-40 border border-red-100">{{ tryFormatJson(selectedLog.old_value) }}</pre>
|
|
</div>
|
|
<div>
|
|
<div class="text-subtitle2 text-grey mb-1">New Value</div>
|
|
<pre class="bg-green-50 p-2 rounded text-xs overflow-auto max-h-40 border border-green-100">{{ tryFormatJson(selectedLog.new_value) }}</pre>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Metadata -->
|
|
<div v-if="selectedLog.metadata" class="mt-4">
|
|
<div class="text-subtitle2 text-grey mb-1">Metadata</div>
|
|
<pre class="bg-gray-50 p-2 rounded text-xs overflow-auto max-h-40 border border-gray-200">{{ tryFormatJson(selectedLog.metadata) }}</pre>
|
|
</div>
|
|
|
|
</q-card-section>
|
|
|
|
<q-card-actions align="right" class="bg-white text-primary">
|
|
<q-btn flat label="ปิด" v-close-popup />
|
|
</q-card-actions>
|
|
</q-card>
|
|
</q-dialog>
|
|
|
|
<!-- Cleanup Dialog -->
|
|
<q-dialog v-model="cleanupDialog">
|
|
<q-card style="min-width: 350px">
|
|
<q-card-section>
|
|
<div class="text-h6">ล้างประวัติเก่า</div>
|
|
</q-card-section>
|
|
|
|
<q-card-section class="q-pt-none">
|
|
<p>เลือกระยะเวลาที่ต้องการเก็บไว้ (ลบข้อมูลที่เก่ากว่ากำหนด):</p>
|
|
<q-select
|
|
v-model="cleanupDays"
|
|
:options="[7, 15, 30, 60, 90, 180, 365]"
|
|
label="จำนวนวัน"
|
|
suffix="วัน"
|
|
outlined
|
|
dense
|
|
/>
|
|
<div class="bg-red-50 text-red-800 p-3 rounded mt-4 text-sm">
|
|
<q-icon name="warning" class="mr-1"/>
|
|
การกระทำนี้ไม่สามารถเรียกคืนข้อมูลได้
|
|
</div>
|
|
</q-card-section>
|
|
|
|
<q-card-actions align="right" class="text-primary">
|
|
<q-btn flat label="ยกเลิก" v-close-popup />
|
|
<q-btn flat label="ลบข้อมูล" color="negative" @click="confirmCleanup" />
|
|
</q-card-actions>
|
|
</q-card>
|
|
</q-dialog>
|
|
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { useQuasar } from 'quasar';
|
|
import { adminService, type AuditLog, type AuditLogStats } from '~/services/admin.service';
|
|
|
|
definePageMeta({
|
|
layout: 'admin',
|
|
middleware: 'auth'
|
|
});
|
|
|
|
const $q = useQuasar();
|
|
|
|
// State
|
|
const logs = ref<AuditLog[]>([]);
|
|
const stats = ref<AuditLogStats>({
|
|
totalLogs: 0,
|
|
todayLogs: 0,
|
|
actionSummary: [],
|
|
recentActivity: []
|
|
});
|
|
const loading = ref(false);
|
|
const detailsDialog = ref(false);
|
|
const selectedLog = ref<AuditLog | null>(null);
|
|
const cleanupDialog = ref(false);
|
|
const cleanupDays = ref(90);
|
|
|
|
// Filters
|
|
const filters = reactive({
|
|
userId: undefined as number | undefined,
|
|
action: null as string | null,
|
|
entityType: '',
|
|
startDate: '',
|
|
endDate: ''
|
|
});
|
|
|
|
// Pagination for q-table
|
|
const pagination = ref({
|
|
sortBy: 'created_at',
|
|
descending: true,
|
|
page: 1,
|
|
rowsPerPage: 20,
|
|
rowsNumber: 0
|
|
});
|
|
|
|
// Table setup
|
|
const columns = [
|
|
{ name: 'id', label: 'ID', field: 'id', align: 'left', style: 'width: 60px' },
|
|
{ name: 'action', label: 'Action', field: 'action', align: 'left' },
|
|
{ name: 'user', label: 'User', field: 'user', align: 'left' },
|
|
{ name: 'entity_type', label: 'Entity Type', field: 'entity_type', align: 'left' },
|
|
{ name: 'entity_id', label: 'Entity ID', field: 'entity_id', align: 'left' },
|
|
|
|
{ name: 'created_at', label: 'Time', field: 'created_at', align: 'left' },
|
|
{ name: 'actions', label: '', field: 'actions', align: 'center' }
|
|
];
|
|
|
|
// Actions options (for filtering)
|
|
const actionOptionsList = [
|
|
'CREATE', 'UPDATE', 'DELETE', 'LOGIN', 'LOGOUT', 'ERROR',
|
|
'ENROLL', 'UNENROLL', 'SUBMIT_QUIZ',
|
|
'APPROVE_COURSE', 'REJECT_COURSE',
|
|
'UPLOAD_FILE', 'DELETE_FILE',
|
|
'CHANGE_PASSWORD', 'RESET_PASSWORD',
|
|
'VERIFY_EMAIL', 'DEACTIVATE_USER', 'ACTIVATE_USER'
|
|
];
|
|
const actionOptions = ref(actionOptionsList);
|
|
|
|
const filterActions = (val: string, update: Function) => {
|
|
if (val === '') {
|
|
update(() => {
|
|
actionOptions.value = actionOptionsList;
|
|
});
|
|
return;
|
|
}
|
|
update(() => {
|
|
const needle = val.toLowerCase();
|
|
actionOptions.value = actionOptionsList.filter(v => v.toLowerCase().indexOf(needle) > -1);
|
|
});
|
|
};
|
|
|
|
// Methods
|
|
const fetchLogs = async () => {
|
|
loading.value = true;
|
|
try {
|
|
const { page, rowsPerPage } = pagination.value;
|
|
|
|
const response = await adminService.getAuditLogs(page, rowsPerPage, {
|
|
userId: filters.userId,
|
|
action: filters.action || undefined,
|
|
entityType: filters.entityType || undefined,
|
|
startDate: filters.startDate ? new Date(filters.startDate).toISOString() : undefined,
|
|
endDate: filters.endDate ? new Date(filters.endDate).toISOString() : undefined
|
|
});
|
|
|
|
logs.value = response.data;
|
|
pagination.value.rowsNumber = response.pagination.total;
|
|
|
|
} catch (error) {
|
|
console.error('Failed to fetch logs:', error);
|
|
$q.notify({
|
|
type: 'negative',
|
|
message: 'ไม่สามารถโหลดข้อมูล Logs ได้',
|
|
position: 'top'
|
|
});
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const fetchStats = async () => {
|
|
try {
|
|
stats.value = await adminService.getAuditLogStats();
|
|
} catch (error) {
|
|
console.error('Failed to fetch stats:', error);
|
|
}
|
|
};
|
|
|
|
const refreshData = () => {
|
|
fetchStats();
|
|
fetchLogs();
|
|
};
|
|
|
|
const onRequest = (props: any) => {
|
|
const { page, rowsPerPage } = props.pagination;
|
|
pagination.value.page = page;
|
|
pagination.value.rowsPerPage = rowsPerPage;
|
|
fetchLogs();
|
|
};
|
|
|
|
const resetFilters = () => {
|
|
filters.userId = undefined;
|
|
filters.action = null;
|
|
filters.entityType = '';
|
|
filters.startDate = '';
|
|
filters.endDate = '';
|
|
fetchLogs();
|
|
};
|
|
|
|
const viewDetails = (log: AuditLog) => {
|
|
selectedLog.value = log;
|
|
detailsDialog.value = true;
|
|
};
|
|
|
|
const openCleanupDialog = () => {
|
|
cleanupDialog.value = true;
|
|
};
|
|
|
|
const confirmCleanup = async () => {
|
|
try {
|
|
await adminService.cleanupAuditLogs(cleanupDays.value);
|
|
$q.notify({
|
|
type: 'positive',
|
|
message: `ลบข้อมูลที่เก่ากว่า ${cleanupDays.value} วันเรียบร้อยแล้ว`,
|
|
position: 'top'
|
|
});
|
|
cleanupDialog.value = false;
|
|
refreshData();
|
|
} catch (error) {
|
|
$q.notify({
|
|
type: 'negative',
|
|
message: 'ไม่สามารถลบข้อมูลได้',
|
|
position: 'top'
|
|
});
|
|
}
|
|
};
|
|
|
|
// Utilities
|
|
const tryFormatJson = (str: string | null) => {
|
|
if (!str) return '-';
|
|
try {
|
|
const obj = JSON.parse(str);
|
|
return JSON.stringify(obj, null, 2);
|
|
} catch (e) {
|
|
return str;
|
|
}
|
|
};
|
|
|
|
const formatDate = (date: string) => {
|
|
if (!date) return '-';
|
|
return new Date(date).toLocaleString('th-TH');
|
|
};
|
|
|
|
const getActionColor = (action: string) => {
|
|
if (!action) return 'grey';
|
|
if (action.includes('DELETE') || action.includes('REJECT') || action.includes('DEACTIVATE') || action.includes('ERROR')) return 'negative';
|
|
if (action.includes('UPDATE') || action.includes('CHANGE')) return 'warning';
|
|
if (action.includes('CREATE') || action.includes('APPROVE') || action.includes('ACTIVATE')) return 'positive';
|
|
if (action.includes('LOGIN')) return 'info';
|
|
return 'grey-8';
|
|
};
|
|
|
|
// Check for deep link to detail
|
|
const route = useRoute();
|
|
onMounted(() => {
|
|
fetchStats();
|
|
fetchLogs();
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Hide scrollbar for number input */
|
|
:deep(input[type=number]::-webkit-outer-spin-button),
|
|
:deep(input[type=number]::-webkit-inner-spin-button) {
|
|
-webkit-appearance: none;
|
|
margin: 0;
|
|
}
|
|
|
|
:deep(input[type=number]) {
|
|
-moz-appearance: textfield;
|
|
}
|
|
</style>
|