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
390 lines
9.1 KiB
Vue
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">×</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>
|