Files
shopdb-flask/frontend/src/views/settings/AuditLogs.vue
Cam Proudlock a9d0dfff4a Initial import: shopdb-flask multi-site asset platform
Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin
framework so sister GE Aerospace sites can adopt it. Product version
0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests.

Key entry points:
- docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook
  (own-site and subpath deployment methods)
- docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify
- docs/IMPORT-API.md - schema-agnostic legacy data import contract
- scripts/site_imports/wjf/ - West Jefferson reference import loader
- docs/adr/ - twelve accepted architecture decision records
2026-07-14 07:58:51 -04:00

390 lines
9.1 KiB
Vue

<template>
<div class="page-header">
<h1>Audit Logs</h1>
</div>
<div class="filters">
<input
type="text"
v-model="search"
placeholder="Search by name or user..."
@input="debouncedSearch"
>
<select v-model="filterAction" @change="loadLogs">
<option value="">All Actions</option>
<option value="created">Created</option>
<option value="updated">Updated</option>
<option value="deleted">Deleted</option>
</select>
<select v-model="filterEntity" @change="loadLogs">
<option value="">All Entities</option>
<option v-for="e in entityTypes" :key="e" :value="e">{{ e }}</option>
</select>
</div>
<div class="card">
<div class="table-container">
<table>
<thead>
<tr>
<th style="width: 180px">Timestamp</th>
<th style="width: 90px">Action</th>
<th style="width: 110px">Entity</th>
<th>Name/ID</th>
<th style="width: 118px">User</th>
<th style="width: 108px">IP Address</th>
<th style="width: 80px">Changes</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="7" class="loading">Loading...</td>
</tr>
<tr v-else-if="!logs.length">
<td colspan="7" class="empty">No audit logs found</td>
</tr>
<tr v-for="log in logs" :key="log.auditlogid">
<td class="timestamp" :title="formatDateFull(log.timestamp)">{{ formatDate(log.timestamp) }}</td>
<td>
<span class="badge" :class="actionClass(log.action)">
{{ log.action }}
</span>
</td>
<td>{{ log.entitytype }}</td>
<td class="name-cell" :title="log.entityname || `#${log.entityid}`">
<router-link
v-if="getEntityLink(log)"
:to="getEntityLink(log)"
class="entity-link"
>
{{ log.entityname || `#${log.entityid}` }}
</router-link>
<span v-else>{{ log.entityname || `#${log.entityid}` }}</span>
</td>
<td :title="log.userfullname || ''">{{ log.username || '-' }}</td>
<td class="ip">{{ log.ipaddress || '-' }}</td>
<td>
<button
v-if="log.changes && Object.keys(log.changes).length"
class="changes-btn"
@click="showChanges(log)"
>
{{ Object.keys(log.changes).length }} field(s)
</button>
<span v-else class="no-changes">-</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" v-if="totalPages > 1">
<button :disabled="page <= 1" @click="page--; loadLogs()">Prev</button>
<span>Page {{ page }} of {{ totalPages }}</span>
<button :disabled="page >= totalPages" @click="page++; loadLogs()">Next</button>
</div>
</div>
<!-- Changes Modal -->
<div v-if="selectedLog" class="modal-overlay" @click.self="selectedLog = null">
<div class="modal">
<div class="modal-header">
<h3>Changes - {{ selectedLog.entitytype }} {{ selectedLog.entityname }}</h3>
<button class="close-btn" @click="selectedLog = null">&times;</button>
</div>
<div class="modal-body">
<table class="changes-table">
<thead>
<tr>
<th>Field</th>
<th>Old Value</th>
<th>New Value</th>
</tr>
</thead>
<tbody>
<tr v-for="(change, field) in selectedLog.changes" :key="field">
<td>{{ field }}</td>
<td class="old-value">{{ formatValue(change.old) }}</td>
<td class="new-value">{{ formatValue(change.new) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { auditLogsApi } from '../../api'
const logs = ref([])
const loading = ref(true)
const page = ref(1)
const perpage = 50
const total = ref(0)
const search = ref('')
const filterAction = ref('')
const filterEntity = ref('')
const selectedLog = ref(null)
const entityTypes = ['Asset', 'Printer', 'Computer', 'Machine', 'Network', 'Setting', 'User', 'Application', 'KnowledgeBase']
const totalPages = computed(() => Math.ceil(total.value / perpage))
let debounceTimer = null
function debouncedSearch() {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
page.value = 1
loadLogs()
}, 300)
}
async function loadLogs() {
loading.value = true
try {
const params = {
page: page.value,
perpage
}
if (search.value) params.search = search.value
if (filterAction.value) params.action = filterAction.value
if (filterEntity.value) params.entitytype = filterEntity.value
const { data } = await auditLogsApi.list(params)
logs.value = data.data
total.value = data.meta.total
} catch (e) {
console.error('Failed to load audit logs:', e)
} finally {
loading.value = false
}
}
function formatDate(isoString) {
if (!isoString) return '-'
const d = new Date(isoString)
// compact for the fixed-width column; the cell title carries the full form
return d.toLocaleString(undefined, {
year: 'numeric', month: 'numeric', day: 'numeric',
hour: 'numeric', minute: '2-digit'
})
}
function formatDateFull(isoString) {
if (!isoString) return ''
return new Date(isoString).toLocaleString()
}
function actionClass(action) {
switch (action) {
case 'created': return 'badge-success'
case 'updated': return 'badge-warning'
case 'deleted': return 'badge-danger'
default: return ''
}
}
function getEntityLink(log) {
if (!log.entityid) return null
const type = log.entitytype?.toLowerCase()
switch (type) {
case 'printer': return `/printers/${log.entityid}`
case 'computer': return `/pcs/${log.entityid}`
case 'machine': return `/machines/${log.entityid}`
case 'asset': return `/assets/${log.entityid}`
case 'application': return `/applications/${log.entityid}`
case 'knowledgebase': return `/kb/${log.entityid}`
default: return null
}
}
function showChanges(log) {
selectedLog.value = log
}
function formatValue(val) {
if (val === null || val === undefined) return '(empty)'
if (typeof val === 'boolean') return val ? 'Yes' : 'No'
if (typeof val === 'object') return JSON.stringify(val)
return String(val)
}
onMounted(loadLogs)
</script>
<style scoped>
.filters {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filters input,
.filters select {
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-card);
color: var(--text);
}
.filters input {
min-width: 250px;
}
.timestamp {
font-size: 0.85rem;
color: var(--text-light);
white-space: nowrap;
}
.ip {
font-family: monospace;
font-size: 0.85rem;
}
.badge {
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
text-transform: capitalize;
}
.badge-success { background: var(--success); color: white; }
.badge-warning { background: var(--warning); color: #000; }
.badge-danger { background: var(--danger); color: white; }
.entity-link {
color: var(--link);
text-decoration: none;
}
.entity-link:hover {
text-decoration: underline;
}
.changes-btn {
padding: 0.2rem 0.5rem;
font-size: 0.8rem;
background: var(--primary);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.changes-btn:hover {
background: var(--primary-dark);
}
.table-container > table {
table-layout: fixed;
width: 100%;
}
.table-container > table td {
overflow: hidden;
text-overflow: ellipsis;
}
.name-cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.no-changes {
color: var(--text-light);
}
.loading, .empty {
text-align: center;
color: var(--text-light);
padding: 2rem;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
min-width: 500px;
max-width: 90vw;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.modal-header h3 {
margin: 0;
font-size: 1rem;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-light);
padding: 0;
line-height: 1;
}
.close-btn:hover {
color: var(--text);
}
.modal-body {
padding: 1rem;
overflow-y: auto;
}
.changes-table {
width: 100%;
border-collapse: collapse;
}
.changes-table th,
.changes-table td {
padding: 0.5rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.changes-table th {
background: var(--bg);
font-weight: 600;
}
.old-value {
color: var(--danger);
text-decoration: line-through;
}
.new-value {
color: var(--success);
}
</style>