Updated Logging System to Include Application Logs
This commit is contained in:
@@ -5,6 +5,7 @@ moment.locale('en');
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { DB_PATH, initialize } = require('./db');
|
const { DB_PATH, initialize } = require('./db');
|
||||||
|
const { logEvent } = require('./logger');
|
||||||
const { createCorsMiddleware } = require('./middleware/cors');
|
const { createCorsMiddleware } = require('./middleware/cors');
|
||||||
const { requireAuth } = require('./middleware/auth');
|
const { requireAuth } = require('./middleware/auth');
|
||||||
const { resolveCompany } = require('./middleware/company');
|
const { resolveCompany } = require('./middleware/company');
|
||||||
@@ -40,6 +41,23 @@ function createApp() {
|
|||||||
app.use(express.json({ limit: '12mb' }));
|
app.use(express.json({ limit: '12mb' }));
|
||||||
console.log(`${moment().format()} ✅ Express app initialized with JSON body parsing and CORS middleware`);
|
console.log(`${moment().format()} ✅ Express app initialized with JSON body parsing and CORS middleware`);
|
||||||
|
|
||||||
|
// HTTP access log — one event per API request (method, path, status, duration). Skips the health
|
||||||
|
// probe and the log-viewer endpoint itself so the viewer doesn't generate noise about itself.
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (!req.path.startsWith('/api') || req.path === '/api/health' || req.path.startsWith('/api/app-logs')) return next();
|
||||||
|
const started = Date.now();
|
||||||
|
res.on('finish', () => {
|
||||||
|
logEvent('http', `${req.method} ${req.originalUrl.split('?')[0]} → ${res.statusCode}`, {
|
||||||
|
method: req.method,
|
||||||
|
path: req.originalUrl.split('?')[0],
|
||||||
|
status: res.statusCode,
|
||||||
|
duration_ms: Date.now() - started,
|
||||||
|
ip: req.ip
|
||||||
|
}, res.statusCode >= 500 ? 'error' : (res.statusCode >= 400 ? 'warn' : 'info'));
|
||||||
|
});
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
res.json({ ok: true, app: 'DepreCore', database: DB_PATH });
|
res.json({ ok: true, app: 'DepreCore', database: DB_PATH });
|
||||||
});
|
});
|
||||||
|
|||||||
37
server/logger.js
Normal file
37
server/logger.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const winston = require('winston');
|
||||||
|
const moment = require('moment');
|
||||||
|
|
||||||
|
// Application/event logger (distinct from the DB audit trail, which records user data changes). This
|
||||||
|
// captures operational events — HTTP requests, and outbound integration activity (SMTP, webhook, Teams,
|
||||||
|
// ServiceNow, Workday) — as JSON lines in data/logs/app.log (size-rotated), readable by the Application
|
||||||
|
// Logs admin screen. A console transport keeps a readable line on stdout for local development.
|
||||||
|
|
||||||
|
const DATA_DIR = process.env.DEPRECORE_DATA_DIR || path.join(process.cwd(), 'data');
|
||||||
|
const LOG_DIR = path.join(DATA_DIR, 'logs');
|
||||||
|
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||||
|
const LOG_FILE = path.join(LOG_DIR, 'app.log');
|
||||||
|
|
||||||
|
const logger = winston.createLogger({
|
||||||
|
level: process.env.DEPRECORE_LOG_LEVEL || 'info',
|
||||||
|
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
|
||||||
|
transports: [
|
||||||
|
new winston.transports.File({ filename: LOG_FILE, maxsize: 5 * 1024 * 1024, maxFiles: 5, tailable: true }),
|
||||||
|
new winston.transports.Console({
|
||||||
|
format: winston.format.printf((info) => `${moment().format()} [${info.category || 'system'}] ${info.level}: ${info.message}`)
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Structured event logger. `category` groups the event (http | smtp | webhook | teams | servicenow |
|
||||||
|
// workday | system); `meta` is arbitrary structured detail. Never throws — logging must not break a flow.
|
||||||
|
function logEvent(category, message, meta = {}, level = 'info') {
|
||||||
|
try {
|
||||||
|
logger.log({ level, category: category || 'system', message, ...meta });
|
||||||
|
} catch {
|
||||||
|
// swallow — a logging failure should never affect the request it describes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { logger, logEvent, LOG_FILE, LOG_DIR };
|
||||||
@@ -6,6 +6,7 @@ const { CAPABILITIES } = require('../services/accessControl');
|
|||||||
const { createRole, deleteRole, listRoles, roleExists, updateRole } = require('../services/roles');
|
const { createRole, deleteRole, listRoles, roleExists, updateRole } = require('../services/roles');
|
||||||
const passwordPolicy = require('../services/passwordPolicy');
|
const passwordPolicy = require('../services/passwordPolicy');
|
||||||
const auditTrail = require('../services/auditTrail');
|
const auditTrail = require('../services/auditTrail');
|
||||||
|
const appLogs = require('../services/appLogs');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -269,6 +270,12 @@ router.get('/audit-logs', requireCapability('admin.audit'), (req, res) => {
|
|||||||
res.json({ ...result, facets: auditTrail.facets() });
|
res.json({ ...result, facets: auditTrail.facets() });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Application / event logs (Winston) -------------------------------------
|
||||||
|
|
||||||
|
router.get('/app-logs', requireCapability('admin.logs'), (req, res) => {
|
||||||
|
res.json(appLogs.readLogs({ category: req.query.category || null, level: req.query.level || null, q: req.query.q || null }, req.query.limit));
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/audit-logs/export', requireCapability('admin.audit'), (req, res) => {
|
router.get('/audit-logs/export', requireCapability('admin.audit'), (req, res) => {
|
||||||
const rows = auditTrail.queryAll(auditFilters(req.query));
|
const rows = auditTrail.queryAll(auditFilters(req.query));
|
||||||
audit(req.user.id, 'export', 'audit_logs', 'csv', null, { count: rows.length, filters: auditFilters(req.query) });
|
audit(req.user.id, 'export', 'audit_logs', 'csv', null, { count: rows.length, filters: auditFilters(req.query) });
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const CAPABILITIES = [
|
|||||||
{ key: 'admin.roles', label: 'Manage roles', group: 'Administration' },
|
{ key: 'admin.roles', label: 'Manage roles', group: 'Administration' },
|
||||||
{ key: 'admin.security', label: 'Manage password & lockout policy', group: 'Administration' },
|
{ key: 'admin.security', label: 'Manage password & lockout policy', group: 'Administration' },
|
||||||
{ key: 'admin.audit', label: 'View activity & audit trail', group: 'Administration' },
|
{ key: 'admin.audit', label: 'View activity & audit trail', group: 'Administration' },
|
||||||
|
{ key: 'admin.logs', label: 'View application logs', group: 'Administration' },
|
||||||
{ key: 'admin.settings', label: 'Manage application settings', group: 'Administration' },
|
{ key: 'admin.settings', label: 'Manage application settings', group: 'Administration' },
|
||||||
{ key: 'admin.integrations', label: 'Manage integrations (email, webhook, ServiceNow, Workday)', group: 'Administration' }
|
{ key: 'admin.integrations', label: 'Manage integrations (email, webhook, ServiceNow, Workday)', group: 'Administration' }
|
||||||
];
|
];
|
||||||
|
|||||||
58
server/services/appLogs.js
Normal file
58
server/services/appLogs.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const { LOG_FILE } = require('../logger');
|
||||||
|
|
||||||
|
// Read the application/event log (JSON lines written by the Winston file transport) for the admin
|
||||||
|
// Application Logs viewer. Reads the current log file, parses the most recent lines, and returns them
|
||||||
|
// newest-first with optional category/level/search filtering plus the available filter facets.
|
||||||
|
|
||||||
|
const SCAN_LINES = 8000; // how many trailing lines to parse (the file is size-rotated, ~5MB)
|
||||||
|
|
||||||
|
function readLogs(filters = {}, limit = 500) {
|
||||||
|
let lines = [];
|
||||||
|
try {
|
||||||
|
lines = fs.readFileSync(LOG_FILE, 'utf8').split('\n').filter(Boolean);
|
||||||
|
} catch {
|
||||||
|
return { logs: [], total: 0, categories: [], levels: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the trailing window, newest first.
|
||||||
|
const parsed = [];
|
||||||
|
for (let i = lines.length - 1, scanned = 0; i >= 0 && scanned < SCAN_LINES; i -= 1, scanned += 1) {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(lines[i]);
|
||||||
|
parsed.push({
|
||||||
|
timestamp: obj.timestamp || null,
|
||||||
|
level: obj.level || 'info',
|
||||||
|
category: obj.category || 'system',
|
||||||
|
message: obj.message || '',
|
||||||
|
meta: stripMeta(obj)
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// skip malformed lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = [...new Set(parsed.map((p) => p.category))].sort();
|
||||||
|
const levels = [...new Set(parsed.map((p) => p.level))].sort();
|
||||||
|
|
||||||
|
let filtered = parsed;
|
||||||
|
if (filters.category) filtered = filtered.filter((p) => p.category === filters.category);
|
||||||
|
if (filters.level) filtered = filtered.filter((p) => p.level === filters.level);
|
||||||
|
if (filters.q) {
|
||||||
|
const needle = String(filters.q).toLowerCase();
|
||||||
|
filtered = filtered.filter((p) => `${p.message} ${JSON.stringify(p.meta)}`.toLowerCase().includes(needle));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { logs: filtered.slice(0, Math.min(2000, Number(limit) || 500)), total: filtered.length, categories, levels };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything on a log line except the standard fields is "meta" (the structured detail).
|
||||||
|
function stripMeta(obj) {
|
||||||
|
const meta = {};
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
if (!['timestamp', 'level', 'category', 'message'].includes(key)) meta[key] = value;
|
||||||
|
}
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { readLogs };
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require('nodemailer');
|
||||||
const { all, audit, now, run } = require('../db');
|
const { all, audit, now, run } = require('../db');
|
||||||
|
const { logEvent } = require('../logger');
|
||||||
|
|
||||||
function readSettings() {
|
function readSettings() {
|
||||||
const rows = all('SELECT key, value FROM app_settings');
|
const rows = all('SELECT key, value FROM app_settings');
|
||||||
@@ -95,13 +96,20 @@ async function sendMail({ to, subject, html, text }) {
|
|||||||
throw Object.assign(new Error('No recipients are configured'), { status: 400 });
|
throw Object.assign(new Error('No recipients are configured'), { status: 400 });
|
||||||
}
|
}
|
||||||
const transport = getTransport();
|
const transport = getTransport();
|
||||||
return transport.sendMail({
|
try {
|
||||||
from: c.from || c.user,
|
const info = await transport.sendMail({
|
||||||
to: recipients.join(', '),
|
from: c.from || c.user,
|
||||||
subject,
|
to: recipients.join(', '),
|
||||||
html,
|
subject,
|
||||||
text
|
html,
|
||||||
});
|
text
|
||||||
|
});
|
||||||
|
logEvent('smtp', `Email sent: "${subject}" to ${recipients.length} recipient(s)`, { subject, recipients: recipients.length, host: c.host, messageId: info.messageId });
|
||||||
|
return info;
|
||||||
|
} catch (error) {
|
||||||
|
logEvent('smtp', `Email send failed: "${subject}" — ${error.message}`, { subject, recipients: recipients.length, host: c.host }, 'error');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendTestEmail(to, userId) {
|
async function sendTestEmail(to, userId) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// Office 365 connector ("MessageCard"). Best-effort delivery — failures are counted, never thrown,
|
// Office 365 connector ("MessageCard"). Best-effort delivery — failures are counted, never thrown,
|
||||||
// so a bad Teams endpoint can't break the alert cycle.
|
// so a bad Teams endpoint can't break the alert cycle.
|
||||||
const { all, audit, now, run } = require('../db');
|
const { all, audit, now, run } = require('../db');
|
||||||
|
const { logEvent } = require('../logger');
|
||||||
|
|
||||||
const TIMEOUT_MS = 10000;
|
const TIMEOUT_MS = 10000;
|
||||||
const FORMATS = ['adaptive', 'messagecard'];
|
const FORMATS = ['adaptive', 'messagecard'];
|
||||||
@@ -161,8 +162,10 @@ async function deliverAlerts(alerts) {
|
|||||||
if (!relevant.length) return { attempted: false, delivered: 0, failed: 0 };
|
if (!relevant.length) return { attempted: false, delivered: 0, failed: 0 };
|
||||||
try {
|
try {
|
||||||
const result = await postPayload(c.webhookUrl, buildPayload(relevant, c));
|
const result = await postPayload(c.webhookUrl, buildPayload(relevant, c));
|
||||||
|
logEvent('teams', `Posted ${relevant.length} alert(s) to Teams (HTTP ${result.status})`, { count: relevant.length, status: result.status, format: c.format }, result.ok ? 'info' : 'warn');
|
||||||
return { attempted: true, delivered: result.ok ? 1 : 0, failed: result.ok ? 0 : 1, status: result.status, count: relevant.length };
|
return { attempted: true, delivered: result.ok ? 1 : 0, failed: result.ok ? 0 : 1, status: result.status, count: relevant.length };
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
logEvent('teams', `Teams alert post failed: ${error.message}`, { count: relevant.length }, 'error');
|
||||||
return { attempted: true, delivered: 0, failed: 1 };
|
return { attempted: true, delivered: 0, failed: 1 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,8 +180,10 @@ async function sendTest(userId) {
|
|||||||
try {
|
try {
|
||||||
result = await postPayload(c.webhookUrl, payload);
|
result = await postPayload(c.webhookUrl, payload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logEvent('teams', `Test card request failed: ${error.message}`, {}, 'error');
|
||||||
throw Object.assign(new Error(`Teams request failed: ${error.message}`), { status: 400 });
|
throw Object.assign(new Error(`Teams request failed: ${error.message}`), { status: 400 });
|
||||||
}
|
}
|
||||||
|
logEvent('teams', `Test card sent (HTTP ${result.status})`, { status: result.status, format: c.format }, result.ok ? 'info' : 'warn');
|
||||||
audit(userId, 'send', 'teams_test', null, null, { status: result.status });
|
audit(userId, 'send', 'teams_test', null, null, { status: result.status });
|
||||||
if (!result.ok) throw Object.assign(new Error(`Teams endpoint returned HTTP ${result.status}`), { status: 400 });
|
if (!result.ok) throw Object.assign(new Error(`Teams endpoint returned HTTP ${result.status}`), { status: 400 });
|
||||||
return { ok: true, status: result.status };
|
return { ok: true, status: result.status };
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
const moment = require('moment');
|
const moment = require('moment');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { audit } = require('../db');
|
const { audit } = require('../db');
|
||||||
|
const { logEvent } = require('../logger');
|
||||||
const { getConfig } = require('./notifications');
|
const { getConfig } = require('./notifications');
|
||||||
|
|
||||||
const TIMEOUT_MS = 10000;
|
const TIMEOUT_MS = 10000;
|
||||||
@@ -74,11 +75,12 @@ async function deliverAlerts(alerts) {
|
|||||||
try {
|
try {
|
||||||
// Note: we build the payload for the alert using the buildPayload function, which creates a stable and flat JSON object with relevant information about the alert, and then we post it to the webhook URL using the postJson function; if the postJson function returns an OK response, we increment the delivered count, otherwise we increment the failed count, allowing us to track the success and failure of each delivery attempt.
|
// Note: we build the payload for the alert using the buildPayload function, which creates a stable and flat JSON object with relevant information about the alert, and then we post it to the webhook URL using the postJson function; if the postJson function returns an OK response, we increment the delivered count, otherwise we increment the failed count, allowing us to track the success and failure of each delivery attempt.
|
||||||
const result = await postJson(c.webhookUrl, c.webhookSecret, buildPayload(alert));
|
const result = await postJson(c.webhookUrl, c.webhookSecret, buildPayload(alert));
|
||||||
if (result.ok) delivered += 1;
|
if (result.ok) { delivered += 1; logEvent('webhook', `Alert delivered: ${alert.title}`, { url: c.webhookUrl, status: result.status, alert_id: alert.id }); }
|
||||||
else failed += 1;
|
else { failed += 1; logEvent('webhook', `Alert delivery returned HTTP ${result.status}: ${alert.title}`, { url: c.webhookUrl, status: result.status, alert_id: alert.id }, 'warn'); }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(`${moment().format()} ❌ Failed to deliver alert to webhook: ${error.message}`);
|
console.log(`${moment().format()} ❌ Failed to deliver alert to webhook: ${error.message}`);
|
||||||
failed += 1;
|
failed += 1;
|
||||||
|
logEvent('webhook', `Alert delivery failed: ${alert.title} — ${error.message}`, { url: c.webhookUrl, alert_id: alert.id }, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(`${moment().format()} 🚚 Webhook delivery complete: ${delivered} delivered, ${failed} failed`);
|
console.log(`${moment().format()} 🚚 Webhook delivery complete: ${delivered} delivered, ${failed} failed`);
|
||||||
@@ -108,9 +110,11 @@ async function sendTestWebhook(userId) {
|
|||||||
result = await postJson(c.webhookUrl, c.webhookSecret, payload);
|
result = await postJson(c.webhookUrl, c.webhookSecret, payload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(`${moment().format()} ❌ Test webhook failed: ${error.message}`);
|
console.log(`${moment().format()} ❌ Test webhook failed: ${error.message}`);
|
||||||
|
logEvent('webhook', `Test webhook request failed: ${error.message}`, { url: c.webhookUrl }, 'error');
|
||||||
throw Object.assign(new Error(`Webhook request failed: ${error.message}`), { status: 400 });
|
throw Object.assign(new Error(`Webhook request failed: ${error.message}`), { status: 400 });
|
||||||
}
|
}
|
||||||
console.log(`${moment().format()} 📡 Test webhook sent successfully to: ${c.webhookUrl}`) ;
|
console.log(`${moment().format()} 📡 Test webhook sent successfully to: ${c.webhookUrl}`) ;
|
||||||
|
logEvent('webhook', `Test webhook sent (HTTP ${result.status})`, { url: c.webhookUrl, status: result.status }, result.ok ? 'info' : 'warn');
|
||||||
audit(userId, 'send', 'test_webhook', null, null, { url: c.webhookUrl, status: result.status });
|
audit(userId, 'send', 'test_webhook', null, null, { url: c.webhookUrl, status: result.status });
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
// Note: if the response from the webhook endpoint is not OK (i.e., not in the 200-299 range), we log an error message with the HTTP status code returned by the endpoint and throw an error indicating that the webhook endpoint returned a non-OK status; this allows us to provide clear feedback about why the test webhook failed and helps users understand that their webhook endpoint may be rejecting the request or encountering an error when processing it.
|
// Note: if the response from the webhook endpoint is not OK (i.e., not in the 200-299 range), we log an error message with the HTTP status code returned by the endpoint and throw an error indicating that the webhook endpoint returned a non-OK status; this allows us to provide clear feedback about why the test webhook failed and helps users understand that their webhook endpoint may be rejecting the request or encountering an error when processing it.
|
||||||
|
|||||||
@@ -1869,6 +1869,18 @@ async function main() {
|
|||||||
if (!acctsB.length) throw new Error('Company B chart of accounts was not seeded');
|
if (!acctsB.length) throw new Error('Company B chart of accounts was not seeded');
|
||||||
if (acctsB.some((a) => a.code === `1501-${suffix}`)) throw new Error('Company B sees company A GL account');
|
if (acctsB.some((a) => a.code === `1501-${suffix}`)) throw new Error('Company B sees company A GL account');
|
||||||
|
|
||||||
|
// ---- Application (Winston) event logs --------------------------------------
|
||||||
|
const appLog = await request('/api/app-logs', { headers: auth });
|
||||||
|
if (!appLog.logs.length) throw new Error('Application logs were empty');
|
||||||
|
if (!appLog.categories.includes('http')) throw new Error('Application logs did not capture HTTP events');
|
||||||
|
const logSample = appLog.logs[0];
|
||||||
|
if (!logSample.timestamp || !logSample.level || !logSample.category) throw new Error('Application log entry was missing fields');
|
||||||
|
const httpLogs = await request('/api/app-logs?category=http', { headers: auth });
|
||||||
|
if (!httpLogs.logs.length || httpLogs.logs.some((l) => l.category !== 'http')) throw new Error('Application log category filter failed');
|
||||||
|
// Requires the admin.logs capability — a viewer-role user is refused.
|
||||||
|
const logForbidden = await fetch(`${baseUrl}/api/app-logs`, { headers: limitedAuth });
|
||||||
|
if (logForbidden.status !== 403) throw new Error('Application logs should require the admin.logs capability');
|
||||||
|
|
||||||
console.log('Smoke test passed');
|
console.log('Smoke test passed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -540,6 +540,7 @@ export default {
|
|||||||
'admin-users': 'User accounts, teams, and role permissions',
|
'admin-users': 'User accounts, teams, and role permissions',
|
||||||
'admin-security': 'Password rules, account lockout, and complexity policy',
|
'admin-security': 'Password rules, account lockout, and complexity policy',
|
||||||
'admin-audit': 'Complete audit trail of all user activity, with export',
|
'admin-audit': 'Complete audit trail of all user activity, with export',
|
||||||
|
'admin-logs': 'Application/event log — HTTP, SMTP, webhook, and Teams activity',
|
||||||
'admin-config': 'Application settings, maintenance defaults, and tax rule sets',
|
'admin-config': 'Application settings, maintenance defaults, and tax rule sets',
|
||||||
'admin-integrations': 'Email, webhooks, ServiceNow, and Workday connectors'
|
'admin-integrations': 'Email, webhooks, ServiceNow, and Workday connectors'
|
||||||
}[this.activeView];
|
}[this.activeView];
|
||||||
@@ -563,6 +564,7 @@ export default {
|
|||||||
'admin-users': 'Users & Teams',
|
'admin-users': 'Users & Teams',
|
||||||
'admin-security': 'Password & Security',
|
'admin-security': 'Password & Security',
|
||||||
'admin-audit': 'Activity & Audit Trail',
|
'admin-audit': 'Activity & Audit Trail',
|
||||||
|
'admin-logs': 'Application Logs',
|
||||||
'admin-config': 'Application Configuration',
|
'admin-config': 'Application Configuration',
|
||||||
'admin-integrations': 'Integrations'
|
'admin-integrations': 'Integrations'
|
||||||
}[this.activeView];
|
}[this.activeView];
|
||||||
@@ -627,6 +629,7 @@ export default {
|
|||||||
'admin-users': ['admin.users'],
|
'admin-users': ['admin.users'],
|
||||||
'admin-security': ['admin.security'],
|
'admin-security': ['admin.security'],
|
||||||
'admin-audit': ['admin.audit'],
|
'admin-audit': ['admin.audit'],
|
||||||
|
'admin-logs': ['admin.logs'],
|
||||||
'admin-config': ['admin.settings', 'config.manage'],
|
'admin-config': ['admin.settings', 'config.manage'],
|
||||||
'admin-integrations': ['admin.integrations']
|
'admin-integrations': ['admin.integrations']
|
||||||
};
|
};
|
||||||
|
|||||||
151
src/components/AppLogsSettings.vue
Normal file
151
src/components/AppLogsSettings.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<div class="toolbar-row mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">Application logs</h2>
|
||||||
|
<p class="quiet text-body-2">
|
||||||
|
Operational/event log of the application — HTTP requests and outbound integration activity (SMTP, webhook, Teams,
|
||||||
|
ServiceNow, Workday). Separate from <strong>Activity & Audit Trail</strong>, which records user data changes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="tonal" prepend-icon="mdi-refresh" :loading="loading" @click="load">Refresh</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-grid mb-3">
|
||||||
|
<v-select v-model="filters.category" :items="categories" label="Category" density="compact" hide-details clearable @update:model-value="load" />
|
||||||
|
<v-select v-model="filters.level" :items="levels" label="Level" density="compact" hide-details clearable @update:model-value="load" />
|
||||||
|
<v-text-field
|
||||||
|
v-model="filters.q"
|
||||||
|
label="Search"
|
||||||
|
placeholder="message or detail…"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
clearable
|
||||||
|
prepend-inner-icon="mdi-magnify"
|
||||||
|
@keyup.enter="load"
|
||||||
|
@click:clear="load"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-row mb-3">
|
||||||
|
<v-btn size="small" color="primary" variant="tonal" prepend-icon="mdi-filter" @click="load">Apply</v-btn>
|
||||||
|
<v-spacer />
|
||||||
|
<span class="quiet text-body-2">{{ total }} matching event{{ total === 1 ? '' : 's' }} (showing {{ logs.length }})</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-alert v-if="error" type="error" density="compact" class="mb-3">{{ error }}</v-alert>
|
||||||
|
|
||||||
|
<v-table density="compact" class="log-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Time</th><th>Level</th><th>Category</th><th>Message</th><th>Detail</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, i) in logs" :key="i">
|
||||||
|
<td class="nowrap">{{ shortDate(row.timestamp) }}</td>
|
||||||
|
<td><v-chip size="x-small" :color="levelColor(row.level)" variant="tonal">{{ row.level }}</v-chip></td>
|
||||||
|
<td><v-chip size="x-small" variant="tonal">{{ row.category }}</v-chip></td>
|
||||||
|
<td>{{ row.message }}</td>
|
||||||
|
<td>
|
||||||
|
<v-menu v-if="row.meta && Object.keys(row.meta).length" location="start">
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn v-bind="props" size="x-small" variant="text" icon="mdi-information-outline" />
|
||||||
|
</template>
|
||||||
|
<v-card class="detail-card pa-3">
|
||||||
|
<pre class="detail-json">{{ pretty(row.meta) }}</pre>
|
||||||
|
</v-card>
|
||||||
|
</v-menu>
|
||||||
|
<span v-else class="quiet">—</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!logs.length && !loading">
|
||||||
|
<td colspan="5" class="text-center quiet py-4">No log events match these filters.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</v-table>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { apiRequest } from '../services/api';
|
||||||
|
import { shortDate } from '../utils/format';
|
||||||
|
|
||||||
|
// Application Logs viewer (Admin → Application Logs). Reads the Winston event log file via
|
||||||
|
// /api/app-logs (filtered by category/level/search); the category/level dropdowns come from the facets
|
||||||
|
// the endpoint returns. Distinct from the DB-backed Activity & Audit Trail.
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
token: { type: String, required: true }
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
filters: { category: null, level: null, q: '' },
|
||||||
|
logs: [],
|
||||||
|
categories: [],
|
||||||
|
levels: [],
|
||||||
|
total: 0,
|
||||||
|
loading: false,
|
||||||
|
error: ''
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
shortDate,
|
||||||
|
async api(path, options = {}) {
|
||||||
|
return apiRequest(path, this.token, options);
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(this.filters)) {
|
||||||
|
if (value !== null && value !== '' && value !== undefined) params.set(key, value);
|
||||||
|
}
|
||||||
|
const data = await (await this.api(`/api/app-logs?${params.toString()}`)).json();
|
||||||
|
this.logs = data.logs;
|
||||||
|
this.total = data.total;
|
||||||
|
this.categories = data.categories;
|
||||||
|
this.levels = data.levels;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
levelColor(level) {
|
||||||
|
return { error: 'error', warn: 'warning', info: 'info', debug: 'grey' }[level] || undefined;
|
||||||
|
},
|
||||||
|
pretty(meta) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(meta, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.log-table .nowrap {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.detail-card {
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.detail-json {
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -173,6 +173,7 @@ export const navItems = [
|
|||||||
{ key: 'admin-users', label: 'Users & Teams', icon: 'mdi-account-group-outline' },
|
{ key: 'admin-users', label: 'Users & Teams', icon: 'mdi-account-group-outline' },
|
||||||
{ key: 'admin-security', label: 'Password & Security', icon: 'mdi-shield-key-outline' },
|
{ key: 'admin-security', label: 'Password & Security', icon: 'mdi-shield-key-outline' },
|
||||||
{ key: 'admin-audit', label: 'Activity & Audit Trail', icon: 'mdi-history' },
|
{ key: 'admin-audit', label: 'Activity & Audit Trail', icon: 'mdi-history' },
|
||||||
|
{ key: 'admin-logs', label: 'Application Logs', icon: 'mdi-text-box-search-outline' },
|
||||||
{ key: 'admin-config', label: 'Application Configuration', icon: 'mdi-tune' },
|
{ key: 'admin-config', label: 'Application Configuration', icon: 'mdi-tune' },
|
||||||
{ key: 'admin-integrations', label: 'Integrations', icon: 'mdi-transit-connection-variant' }
|
{ key: 'admin-integrations', label: 'Integrations', icon: 'mdi-transit-connection-variant' }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -170,6 +170,11 @@
|
|||||||
<AuditTrailSettings :token="token" />
|
<AuditTrailSettings :token="token" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- APPLICATION LOGS -->
|
||||||
|
<template v-if="adminSection === 'admin-logs'">
|
||||||
|
<AppLogsSettings :token="token" />
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- APPLICATION CONFIGURATION -->
|
<!-- APPLICATION CONFIGURATION -->
|
||||||
<template v-if="adminSection === 'admin-config'">
|
<template v-if="adminSection === 'admin-config'">
|
||||||
<CompanySettings :token="token" @updated="$emit('companies-updated')" />
|
<CompanySettings :token="token" @updated="$emit('companies-updated')" />
|
||||||
@@ -309,6 +314,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import AssetClassSettings from '../components/AssetClassSettings.vue';
|
import AssetClassSettings from '../components/AssetClassSettings.vue';
|
||||||
import AssetIdTemplateSettings from '../components/AssetIdTemplateSettings.vue';
|
import AssetIdTemplateSettings from '../components/AssetIdTemplateSettings.vue';
|
||||||
|
import AppLogsSettings from '../components/AppLogsSettings.vue';
|
||||||
import AuditTrailSettings from '../components/AuditTrailSettings.vue';
|
import AuditTrailSettings from '../components/AuditTrailSettings.vue';
|
||||||
import CategorySettings from '../components/CategorySettings.vue';
|
import CategorySettings from '../components/CategorySettings.vue';
|
||||||
import CompanySettings from '../components/CompanySettings.vue';
|
import CompanySettings from '../components/CompanySettings.vue';
|
||||||
@@ -326,7 +332,7 @@ import TeamsSettings from '../components/TeamsSettings.vue';
|
|||||||
import WebhookSettings from '../components/WebhookSettings.vue';
|
import WebhookSettings from '../components/WebhookSettings.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { AssetClassSettings, AssetIdTemplateSettings, AuditTrailSettings, CategorySettings, CompanySettings, DataTable, DepartmentSettings, DepreciationZoneSettings, NotificationSettings, PartCategorySettings, PasswordPolicySettings, PmCategorySettings, PmSettings, Section179LimitSettings, ServiceNowSettings, TeamsSettings, WebhookSettings },
|
components: { AppLogsSettings, AssetClassSettings, AssetIdTemplateSettings, AuditTrailSettings, CategorySettings, CompanySettings, DataTable, DepartmentSettings, DepreciationZoneSettings, NotificationSettings, PartCategorySettings, PasswordPolicySettings, PmCategorySettings, PmSettings, Section179LimitSettings, ServiceNowSettings, TeamsSettings, WebhookSettings },
|
||||||
props: {
|
props: {
|
||||||
token: { type: String, default: '' },
|
token: { type: String, default: '' },
|
||||||
section: { type: String, default: 'admin-users' },
|
section: { type: String, default: 'admin-users' },
|
||||||
@@ -397,7 +403,7 @@ export default {
|
|||||||
return groups;
|
return groups;
|
||||||
},
|
},
|
||||||
adminSection() {
|
adminSection() {
|
||||||
return ['admin-users', 'admin-security', 'admin-audit', 'admin-config', 'admin-integrations'].includes(this.section) ? this.section : 'admin-users';
|
return ['admin-users', 'admin-security', 'admin-audit', 'admin-logs', 'admin-config', 'admin-integrations'].includes(this.section) ? this.section : 'admin-users';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
|||||||
Reference in New Issue
Block a user