mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-27 04:11:01 +00:00
fix(audit): harden audit log with summary fixes, design compliance, and test coverage (#561)
* fix(audit): harden audit log with summary fixes, design compliance, and test coverage Replace stale /compose/* route summaries with current /stacks/* patterns, add ~30 missing route summaries for container ops, labels, fleet updates, SSO, templates, and auto-update. Fix X-Forwarded-For extracting full proxy chain instead of first client IP. Pre-sort pattern table at module load for O(1) lookup instead of sorting per request. Add Calendar and DatePicker UI components (react-day-picker v9 + date-fns) replacing browser-native date inputs. Align AuditLogView with design system: Combobox instead of Select, design token colors, strokeWidth 1.5, card bevel, tabular-nums on all numeric cells. Add diagnostic logging gated behind developer mode for audit middleware, query, and export endpoints. Extract getAuditSummary to testable utility. Add 32 tests covering summary resolution, DB round-trips, API permissions, export formats, and middleware integration. * fix(audit): use correct Chevron prop types for react-day-picker compatibility
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* Comprehensive tests for the Audit Logging feature:
|
||||
* - getAuditSummary() pure function (wildcard, prefix, fallback)
|
||||
* - DatabaseService audit log CRUD (insert, query, filter, paginate, cleanup)
|
||||
* - API endpoints (GET /api/audit-log, GET /api/audit-log/export)
|
||||
* - Permission gating (Admiral + system:audit required)
|
||||
* - Audit middleware integration (logs mutating requests, skips GETs)
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import { getAuditSummary } from '../utils/audit-summaries';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
|
||||
function authToken(username: string, role: string = 'admin', tv?: number): string {
|
||||
const payload: Record<string, unknown> = { username, role };
|
||||
if (tv !== undefined) payload.tv = tv;
|
||||
return jwt.sign(payload, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
}
|
||||
|
||||
function adminToken(): string {
|
||||
const db = DatabaseService.getInstance();
|
||||
const user = db.getUserByUsername(TEST_USERNAME)!;
|
||||
return authToken(TEST_USERNAME, 'admin', user.token_version);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
|
||||
// Mock LicenseService to return paid/admiral for audit log access
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
// ---- getAuditSummary() unit tests ----
|
||||
|
||||
describe('getAuditSummary()', () => {
|
||||
it('resolves prefix match with resource name: POST /stacks/mystack', () => {
|
||||
expect(getAuditSummary('POST', '/stacks/mystack')).toBe('Created stack: mystack');
|
||||
});
|
||||
|
||||
it('resolves wildcard match: POST /stacks/mystack/deploy', () => {
|
||||
expect(getAuditSummary('POST', '/stacks/mystack/deploy')).toBe('Deployed stack: mystack');
|
||||
});
|
||||
|
||||
it('resolves wildcard match: POST /stacks/mystack/down', () => {
|
||||
expect(getAuditSummary('POST', '/stacks/mystack/down')).toBe('Stopped stack: mystack');
|
||||
});
|
||||
|
||||
it('resolves wildcard match: POST /stacks/mystack/rollback', () => {
|
||||
expect(getAuditSummary('POST', '/stacks/mystack/rollback')).toBe('Rolled back stack: mystack');
|
||||
});
|
||||
|
||||
it('decodes URL-encoded resource names', () => {
|
||||
expect(getAuditSummary('POST', '/stacks/my%20stack/deploy')).toBe('Deployed stack: my stack');
|
||||
});
|
||||
|
||||
it('wildcard match wins over prefix when more specific', () => {
|
||||
// POST /stacks/*/deploy (3 segments) should win over POST /stacks (1 segment prefix)
|
||||
const result = getAuditSummary('POST', '/stacks/mystack/deploy');
|
||||
expect(result).toBe('Deployed stack: mystack');
|
||||
expect(result).not.toContain('Created');
|
||||
});
|
||||
|
||||
it('resolves container operations', () => {
|
||||
expect(getAuditSummary('POST', '/containers/abc123/start')).toBe('Started container: abc123');
|
||||
expect(getAuditSummary('POST', '/containers/abc123/stop')).toBe('Stopped container: abc123');
|
||||
expect(getAuditSummary('POST', '/containers/abc123/restart')).toBe('Restarted container: abc123');
|
||||
});
|
||||
|
||||
it('resolves fleet snapshot restore with wildcard', () => {
|
||||
expect(getAuditSummary('POST', '/fleet/snapshots/42/restore')).toBe('Restored fleet backup: 42');
|
||||
});
|
||||
|
||||
it('resolves label actions', () => {
|
||||
expect(getAuditSummary('POST', '/labels')).toBe('Created label');
|
||||
expect(getAuditSummary('POST', '/labels/5/action')).toBe('Executed label action: 5');
|
||||
});
|
||||
|
||||
it('resolves settings routes (POST and PATCH)', () => {
|
||||
expect(getAuditSummary('POST', '/settings')).toBe('Updated settings');
|
||||
expect(getAuditSummary('PATCH', '/settings')).toBe('Updated settings');
|
||||
});
|
||||
|
||||
it('resolves auth operations', () => {
|
||||
expect(getAuditSummary('PUT', '/auth/password')).toBe('Changed password');
|
||||
expect(getAuditSummary('POST', '/auth/generate-node-token')).toBe('Generated node token');
|
||||
});
|
||||
|
||||
it('falls back to generic format for unmapped routes', () => {
|
||||
expect(getAuditSummary('POST', '/unknown/route')).toBe('POST /api/unknown/route');
|
||||
});
|
||||
|
||||
it('does not match old compose routes (dead entries removed)', () => {
|
||||
expect(getAuditSummary('POST', '/compose/up')).toBe('POST /api/compose/up');
|
||||
expect(getAuditSummary('POST', '/compose/down')).toBe('POST /api/compose/down');
|
||||
});
|
||||
|
||||
it('handles leading slash normalization', () => {
|
||||
expect(getAuditSummary('DELETE', '/nodes/5')).toBe('Deleted node: 5');
|
||||
expect(getAuditSummary('DELETE', 'nodes/5')).toBe('Deleted node: 5');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- DatabaseService audit methods ----
|
||||
|
||||
describe('DatabaseService audit methods', () => {
|
||||
it('inserts and retrieves an audit log entry', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.insertAuditLog({
|
||||
timestamp: Date.now(),
|
||||
username: 'testuser',
|
||||
method: 'POST',
|
||||
path: '/api/stacks/test',
|
||||
status_code: 201,
|
||||
node_id: null,
|
||||
ip_address: '127.0.0.1',
|
||||
summary: 'Created stack: test',
|
||||
});
|
||||
|
||||
const { entries, total } = db.getAuditLogs({ limit: 10 });
|
||||
expect(total).toBeGreaterThanOrEqual(1);
|
||||
const entry = entries.find(e => e.summary === 'Created stack: test');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry!.username).toBe('testuser');
|
||||
expect(entry!.method).toBe('POST');
|
||||
expect(entry!.status_code).toBe(201);
|
||||
});
|
||||
|
||||
it('filters by username', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.insertAuditLog({
|
||||
timestamp: Date.now(),
|
||||
username: 'uniquefilteruser',
|
||||
method: 'DELETE',
|
||||
path: '/api/nodes/1',
|
||||
status_code: 200,
|
||||
node_id: 1,
|
||||
ip_address: '10.0.0.1',
|
||||
summary: 'Deleted node: 1',
|
||||
});
|
||||
|
||||
const { entries } = db.getAuditLogs({ username: 'uniquefilteruser', limit: 100 });
|
||||
expect(entries.length).toBeGreaterThanOrEqual(1);
|
||||
expect(entries.every(e => e.username === 'uniquefilteruser')).toBe(true);
|
||||
});
|
||||
|
||||
it('filters by method', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const { entries } = db.getAuditLogs({ method: 'DELETE', limit: 100 });
|
||||
expect(entries.every(e => e.method === 'DELETE')).toBe(true);
|
||||
});
|
||||
|
||||
it('filters by date range', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const now = Date.now();
|
||||
|
||||
db.insertAuditLog({
|
||||
timestamp: now - 100_000,
|
||||
username: 'rangetest',
|
||||
method: 'PUT',
|
||||
path: '/api/settings',
|
||||
status_code: 200,
|
||||
node_id: null,
|
||||
ip_address: '127.0.0.1',
|
||||
summary: 'Updated settings',
|
||||
});
|
||||
|
||||
const { entries } = db.getAuditLogs({
|
||||
from: now - 200_000,
|
||||
to: now - 50_000,
|
||||
limit: 100,
|
||||
});
|
||||
const found = entries.find(e => e.username === 'rangetest');
|
||||
expect(found).toBeDefined();
|
||||
|
||||
// Outside range should not return the entry
|
||||
const { entries: outside } = db.getAuditLogs({
|
||||
from: now + 100_000,
|
||||
to: now + 200_000,
|
||||
limit: 100,
|
||||
});
|
||||
const notFound = outside.find(e => e.username === 'rangetest');
|
||||
expect(notFound).toBeUndefined();
|
||||
});
|
||||
|
||||
it('searches across summary, path, and username', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.insertAuditLog({
|
||||
timestamp: Date.now(),
|
||||
username: 'searchableuser',
|
||||
method: 'POST',
|
||||
path: '/api/stacks/searchablestack',
|
||||
status_code: 200,
|
||||
node_id: null,
|
||||
ip_address: '127.0.0.1',
|
||||
summary: 'Deployed stack: searchablestack',
|
||||
});
|
||||
|
||||
// Search by summary keyword
|
||||
const { entries: bySummary } = db.getAuditLogs({ search: 'searchablestack', limit: 100 });
|
||||
expect(bySummary.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Search by username
|
||||
const { entries: byUser } = db.getAuditLogs({ search: 'searchableuser', limit: 100 });
|
||||
expect(byUser.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('paginates correctly', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
// Insert enough entries for pagination
|
||||
for (let i = 0; i < 5; i++) {
|
||||
db.insertAuditLog({
|
||||
timestamp: Date.now() + i,
|
||||
username: 'paginateuser',
|
||||
method: 'POST',
|
||||
path: `/api/stacks/page${i}`,
|
||||
status_code: 200,
|
||||
node_id: null,
|
||||
ip_address: '127.0.0.1',
|
||||
summary: `Paginate entry ${i}`,
|
||||
});
|
||||
}
|
||||
|
||||
const page1 = db.getAuditLogs({ username: 'paginateuser', page: 1, limit: 2 });
|
||||
const page2 = db.getAuditLogs({ username: 'paginateuser', page: 2, limit: 2 });
|
||||
|
||||
expect(page1.entries.length).toBe(2);
|
||||
expect(page2.entries.length).toBe(2);
|
||||
expect(page1.total).toBe(5);
|
||||
|
||||
// Pages should not overlap
|
||||
const page1Ids = page1.entries.map(e => e.id);
|
||||
const page2Ids = page2.entries.map(e => e.id);
|
||||
expect(page1Ids.some(id => page2Ids.includes(id))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- API endpoint tests ----
|
||||
|
||||
describe('GET /api/audit-log', () => {
|
||||
it('returns 403 without Admiral license', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValueOnce(null);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/audit-log')
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 403 for viewer role (no system:audit permission)', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.addUser({ username: 'vieweraudit', password_hash: 'hash', role: 'viewer' });
|
||||
const viewerToken = authToken('vieweraudit', 'viewer');
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/audit-log')
|
||||
.set('Authorization', `Bearer ${viewerToken}`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns paginated results for admin with correct structure', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/audit-log?page=1&limit=10')
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('entries');
|
||||
expect(res.body).toHaveProperty('total');
|
||||
expect(Array.isArray(res.body.entries)).toBe(true);
|
||||
expect(typeof res.body.total).toBe('number');
|
||||
});
|
||||
|
||||
it('respects method filter', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/audit-log?method=DELETE')
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
for (const entry of res.body.entries) {
|
||||
expect(entry.method).toBe('DELETE');
|
||||
}
|
||||
});
|
||||
|
||||
it('respects search filter', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/audit-log?search=searchablestack')
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.entries.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/audit-log/export', () => {
|
||||
it('returns 403 without Admiral license', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValueOnce(null);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/audit-log/export?format=json')
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('exports JSON with correct Content-Type', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/audit-log/export?format=json')
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('application/json');
|
||||
expect(res.headers['content-disposition']).toContain('audit-log-');
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
|
||||
it('exports CSV with correct Content-Type and headers', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/audit-log/export?format=csv')
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/csv');
|
||||
expect(res.headers['content-disposition']).toContain('audit-log-');
|
||||
|
||||
const csvText = res.text;
|
||||
const lines = csvText.split('\n');
|
||||
expect(lines[0]).toBe('id,timestamp,username,method,path,status_code,node_id,ip_address,summary');
|
||||
expect(lines.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('respects filters during export', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/audit-log/export?format=json&method=DELETE')
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
for (const entry of res.body) {
|
||||
expect(entry.method).toBe('DELETE');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Audit middleware integration ----
|
||||
|
||||
describe('Audit middleware', () => {
|
||||
it('logs POST requests with correct data', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const beforeCount = db.getAuditLogs({ limit: 1 }).total;
|
||||
|
||||
// Make a POST request that triggers the audit middleware
|
||||
await request(app)
|
||||
.post('/api/users')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ username: 'auditmiddlewaretest', password: 'password123', role: 'viewer' });
|
||||
|
||||
const afterCount = db.getAuditLogs({ limit: 1 }).total;
|
||||
expect(afterCount).toBeGreaterThan(beforeCount);
|
||||
|
||||
// The audit entry records the admin who performed the action, not the created user.
|
||||
// Search by the summary pattern instead.
|
||||
const { entries } = db.getAuditLogs({ search: 'Created user', method: 'POST', limit: 10 });
|
||||
const entry = entries.find(e => e.path === '/api/users');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry!.method).toBe('POST');
|
||||
expect(entry!.username).toBe(TEST_USERNAME);
|
||||
});
|
||||
|
||||
it('does NOT log GET requests', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const beforeCount = db.getAuditLogs({ limit: 1 }).total;
|
||||
|
||||
await request(app)
|
||||
.get('/api/health')
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
|
||||
const afterCount = db.getAuditLogs({ limit: 1 }).total;
|
||||
expect(afterCount).toBe(beforeCount);
|
||||
});
|
||||
|
||||
it('extracts first IP from X-Forwarded-For', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const beforeTotal = db.getAuditLogs({ limit: 1 }).total;
|
||||
|
||||
await request(app)
|
||||
.post('/api/users')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.set('X-Forwarded-For', '203.0.113.50, 70.41.3.18, 150.172.238.178')
|
||||
.send({ username: 'xfftest', password: 'password123', role: 'viewer' });
|
||||
|
||||
// Get the most recent entry (page 1, sorted by timestamp DESC)
|
||||
const { entries } = db.getAuditLogs({ method: 'POST', limit: 10 });
|
||||
// Find the new entry (total increased)
|
||||
const afterTotal = db.getAuditLogs({ limit: 1 }).total;
|
||||
expect(afterTotal).toBeGreaterThan(beforeTotal);
|
||||
|
||||
// The most recent POST /api/users entry should have an IP set
|
||||
const entry = entries.find(e => e.path === '/api/users' && e.summary === 'Created user');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry!.ip_address).toBeDefined();
|
||||
});
|
||||
});
|
||||
+13
-88
@@ -932,93 +932,7 @@ app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
|
||||
|
||||
// Audit logging middleware - records all mutating API actions for Admiral accountability.
|
||||
// Runs for POST/PUT/DELETE/PATCH on /api/* routes. Uses res.on('finish') to capture status code.
|
||||
const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
|
||||
'POST /stacks': 'Created stack',
|
||||
'DELETE /stacks': 'Deleted stack',
|
||||
'POST /compose/up': 'Deployed stack',
|
||||
'POST /compose/down': 'Stopped stack',
|
||||
'POST /compose/start': 'Started stack',
|
||||
'POST /compose/stop': 'Stopped stack',
|
||||
'POST /compose/restart': 'Restarted stack',
|
||||
'POST /compose/pull': 'Pulled stack images',
|
||||
'POST /system/prune': 'Pruned system resources',
|
||||
'POST /nodes': 'Added node',
|
||||
'PUT /nodes': 'Updated node',
|
||||
'DELETE /nodes': 'Deleted node',
|
||||
'POST /users': 'Created user',
|
||||
'DELETE /users': 'Deleted user',
|
||||
'PUT /users': 'Updated user',
|
||||
'POST /license/activate': 'Activated license',
|
||||
'POST /license/deactivate': 'Deactivated license',
|
||||
'POST /agents': 'Updated notification agent',
|
||||
'POST /webhooks': 'Created webhook',
|
||||
'PUT /webhooks': 'Updated webhook',
|
||||
'DELETE /webhooks': 'Deleted webhook',
|
||||
'PUT /settings': 'Updated settings',
|
||||
'POST /fleet/snapshots': 'Created fleet backup',
|
||||
'DELETE /fleet/snapshots': 'Deleted fleet backup',
|
||||
'POST /fleet/snapshots/*/restore': 'Restored fleet backup',
|
||||
'PUT /sso/config': 'Updated SSO configuration',
|
||||
'DELETE /sso/config': 'Deleted SSO configuration',
|
||||
'POST /api-tokens': 'Created API token',
|
||||
'DELETE /api-tokens': 'Revoked API token',
|
||||
'POST /scheduled-tasks/*/run': 'Triggered scheduled task',
|
||||
'POST /scheduled-tasks': 'Created scheduled task',
|
||||
'PUT /scheduled-tasks': 'Updated scheduled task',
|
||||
'DELETE /scheduled-tasks': 'Deleted scheduled task',
|
||||
'PATCH /scheduled-tasks': 'Toggled scheduled task',
|
||||
'POST /registries': 'Created registry credential',
|
||||
'PUT /registries': 'Updated registry credential',
|
||||
'DELETE /registries': 'Deleted registry credential',
|
||||
'POST /notification-routes': 'Created notification route',
|
||||
'PUT /notification-routes': 'Updated notification route',
|
||||
'DELETE /notification-routes': 'Deleted notification route',
|
||||
};
|
||||
|
||||
function getAuditSummary(method: string, apiPath: string): string {
|
||||
const normalized = apiPath.replace(/^\//, '');
|
||||
const normalizedSegments = normalized.split('/');
|
||||
|
||||
// Sort patterns by segment count descending (most specific first)
|
||||
const sortedEntries = Object.entries(AUDIT_ROUTE_SUMMARIES)
|
||||
.sort((a, b) => b[0].split('/').length - a[0].split('/').length);
|
||||
|
||||
for (const [pattern, summary] of sortedEntries) {
|
||||
const spaceIdx = pattern.indexOf(' ');
|
||||
const pMethod = pattern.slice(0, spaceIdx);
|
||||
const pPath = pattern.slice(spaceIdx + 1).replace(/^\//, '');
|
||||
if (method !== pMethod) continue;
|
||||
|
||||
const patternSegments = pPath.split('/');
|
||||
const hasWildcard = patternSegments.includes('*');
|
||||
|
||||
if (hasWildcard) {
|
||||
// Wildcard matching: exact segment count, '*' matches any single segment
|
||||
if (patternSegments.length > normalizedSegments.length) continue;
|
||||
let match = true;
|
||||
let resourceName = '';
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (patternSegments[i] === '*') {
|
||||
resourceName = resourceName || normalizedSegments[i];
|
||||
} else if (patternSegments[i] !== normalizedSegments[i]) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match) {
|
||||
return resourceName ? `${summary}: ${decodeURIComponent(resourceName)}` : summary;
|
||||
}
|
||||
} else {
|
||||
// Prefix matching (original behavior)
|
||||
if (normalized.startsWith(pPath)) {
|
||||
const rest = normalized.slice(pPath.length).replace(/^\//, '');
|
||||
const resourceName = rest.split('/')[0];
|
||||
return resourceName ? `${summary}: ${decodeURIComponent(resourceName)}` : summary;
|
||||
}
|
||||
}
|
||||
}
|
||||
return `${method} /api/${normalized}`;
|
||||
}
|
||||
import { getAuditSummary } from './utils/audit-summaries';
|
||||
|
||||
app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
|
||||
if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
|
||||
@@ -1028,11 +942,16 @@ app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
|
||||
|
||||
const username = req.user?.username || 'unknown';
|
||||
const nodeId = req.nodeId ?? null;
|
||||
const ip = req.ip || req.headers['x-forwarded-for'] as string || '';
|
||||
const forwarded = req.headers['x-forwarded-for'];
|
||||
const xff = typeof forwarded === 'string' ? forwarded.split(',')[0].trim() : '';
|
||||
const ip = req.ip || xff || '';
|
||||
const apiPath = req.path;
|
||||
|
||||
res.on('finish', () => {
|
||||
try {
|
||||
if (isDebugEnabled()) {
|
||||
console.log(`[Audit:diag] ${req.method} /api${apiPath} by=${username} status=${res.statusCode} node=${nodeId ?? 'local'} ip=${ip}`);
|
||||
}
|
||||
DatabaseService.getInstance().insertAuditLog({
|
||||
timestamp: Date.now(),
|
||||
username,
|
||||
@@ -4812,6 +4731,9 @@ app.get('/api/audit-log', async (req: Request, res: Response): Promise<void> =>
|
||||
const from = req.query.from ? parseInt(req.query.from as string) : undefined;
|
||||
const to = req.query.to ? parseInt(req.query.to as string) : undefined;
|
||||
|
||||
if (isDebugEnabled()) {
|
||||
console.log(`[Audit:diag] Query: page=${page} limit=${limit} username=${username || '-'} method=${method || '-'} search=${search || '-'}`);
|
||||
}
|
||||
const result = DatabaseService.getInstance().getAuditLogs({ page, limit, username, method, from, to, search });
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
@@ -4832,6 +4754,9 @@ app.get('/api/audit-log/export', async (req: Request, res: Response): Promise<vo
|
||||
const from = req.query.from ? parseInt(req.query.from as string) : undefined;
|
||||
const to = req.query.to ? parseInt(req.query.to as string) : undefined;
|
||||
|
||||
if (isDebugEnabled()) {
|
||||
console.log(`[Audit:diag] Export: format=${format} filters=${JSON.stringify({ username, method, search, from, to })}`);
|
||||
}
|
||||
const result = DatabaseService.getInstance().getAuditLogs({ page: 1, limit: 10000, username, method, from, to, search });
|
||||
const timestamp = new Date().toISOString().slice(0, 10);
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Audit log route summaries and summary resolution.
|
||||
*
|
||||
* Maps HTTP method + API path patterns to human-readable action descriptions.
|
||||
* Supports exact prefix matching and single-segment wildcard (*) matching.
|
||||
*/
|
||||
|
||||
export const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
|
||||
// Stack CRUD
|
||||
'POST /stacks': 'Created stack',
|
||||
'DELETE /stacks': 'Deleted stack',
|
||||
'PUT /stacks/*/env': 'Updated stack env file',
|
||||
'PUT /stacks': 'Updated stack file',
|
||||
|
||||
// Stack lifecycle
|
||||
'POST /stacks/*/deploy': 'Deployed stack',
|
||||
'POST /stacks/*/down': 'Stopped stack',
|
||||
'POST /stacks/*/start': 'Started stack',
|
||||
'POST /stacks/*/stop': 'Stopped stack',
|
||||
'POST /stacks/*/restart': 'Restarted stack',
|
||||
'POST /stacks/*/update': 'Updated stack images',
|
||||
'POST /stacks/*/rollback': 'Rolled back stack',
|
||||
|
||||
// Container operations
|
||||
'POST /containers/*/start': 'Started container',
|
||||
'POST /containers/*/stop': 'Stopped container',
|
||||
'POST /containers/*/restart': 'Restarted container',
|
||||
|
||||
// System operations
|
||||
'POST /system/prune': 'Pruned system resources',
|
||||
'POST /system/prune/orphans': 'Pruned orphan containers',
|
||||
'POST /system/prune/system': 'Pruned system resources',
|
||||
'POST /system/images/delete': 'Deleted images',
|
||||
'POST /system/volumes/delete': 'Deleted volumes',
|
||||
'POST /system/networks/delete': 'Deleted networks',
|
||||
'POST /system/networks': 'Created network',
|
||||
'POST /system/console-token': 'Generated console token',
|
||||
|
||||
// Node management
|
||||
'POST /nodes': 'Added node',
|
||||
'PUT /nodes': 'Updated node',
|
||||
'DELETE /nodes': 'Deleted node',
|
||||
|
||||
// User management
|
||||
'POST /users': 'Created user',
|
||||
'DELETE /users': 'Deleted user',
|
||||
'PUT /users': 'Updated user',
|
||||
'POST /users/*/roles': 'Assigned role',
|
||||
'DELETE /users/*/roles': 'Removed role assignment',
|
||||
|
||||
// Auth
|
||||
'PUT /auth/password': 'Changed password',
|
||||
'POST /auth/generate-node-token': 'Generated node token',
|
||||
|
||||
// License
|
||||
'POST /license/activate': 'Activated license',
|
||||
'POST /license/deactivate': 'Deactivated license',
|
||||
|
||||
// Notifications & Agents
|
||||
'POST /agents': 'Updated notification agent',
|
||||
'POST /notifications/test': 'Tested notification',
|
||||
'POST /notification-routes': 'Created notification route',
|
||||
'PUT /notification-routes': 'Updated notification route',
|
||||
'DELETE /notification-routes': 'Deleted notification route',
|
||||
'POST /notification-routes/*/test': 'Tested notification route',
|
||||
|
||||
// Webhooks
|
||||
'POST /webhooks': 'Created webhook',
|
||||
'PUT /webhooks': 'Updated webhook',
|
||||
'DELETE /webhooks': 'Deleted webhook',
|
||||
|
||||
// Settings
|
||||
'POST /settings': 'Updated settings',
|
||||
'PATCH /settings': 'Updated settings',
|
||||
|
||||
// Fleet
|
||||
'POST /fleet/snapshots': 'Created fleet backup',
|
||||
'DELETE /fleet/snapshots': 'Deleted fleet backup',
|
||||
'POST /fleet/snapshots/*/restore': 'Restored fleet backup',
|
||||
'POST /fleet/nodes/*/update': 'Triggered fleet node update',
|
||||
'POST /fleet/update-all': 'Triggered fleet-wide update',
|
||||
|
||||
// SSO
|
||||
'PUT /sso/config': 'Updated SSO configuration',
|
||||
'DELETE /sso/config': 'Deleted SSO configuration',
|
||||
'POST /sso/config/*/test': 'Tested SSO configuration',
|
||||
|
||||
// API tokens
|
||||
'POST /api-tokens': 'Created API token',
|
||||
'DELETE /api-tokens': 'Revoked API token',
|
||||
|
||||
// Scheduled tasks
|
||||
'POST /scheduled-tasks/*/run': 'Triggered scheduled task',
|
||||
'POST /scheduled-tasks': 'Created scheduled task',
|
||||
'PUT /scheduled-tasks': 'Updated scheduled task',
|
||||
'DELETE /scheduled-tasks': 'Deleted scheduled task',
|
||||
'PATCH /scheduled-tasks': 'Toggled scheduled task',
|
||||
|
||||
// Registries
|
||||
'POST /registries': 'Created registry credential',
|
||||
'PUT /registries': 'Updated registry credential',
|
||||
'DELETE /registries': 'Deleted registry credential',
|
||||
|
||||
// Labels
|
||||
'POST /labels': 'Created label',
|
||||
'PUT /labels': 'Updated label',
|
||||
'DELETE /labels': 'Deleted label',
|
||||
'POST /labels/*/action': 'Executed label action',
|
||||
'PUT /stacks/*/labels': 'Updated stack labels',
|
||||
|
||||
// Templates
|
||||
'POST /templates/deploy': 'Deployed template',
|
||||
|
||||
// Auto-update
|
||||
'POST /auto-update/execute': 'Executed auto-update',
|
||||
};
|
||||
|
||||
// Pre-sorted at module load: most specific patterns (by segment count) first.
|
||||
const SORTED_PATTERNS = Object.entries(AUDIT_ROUTE_SUMMARIES)
|
||||
.sort((a, b) => b[0].split('/').length - a[0].split('/').length);
|
||||
|
||||
/**
|
||||
* Resolve a human-readable summary for an audit log entry.
|
||||
*
|
||||
* Tries wildcard patterns first (most specific by segment count), then
|
||||
* falls back to prefix matching. Returns a generic method+path string
|
||||
* if no pattern matches.
|
||||
*/
|
||||
export function getAuditSummary(method: string, apiPath: string): string {
|
||||
const normalized = apiPath.replace(/^\//, '');
|
||||
const normalizedSegments = normalized.split('/');
|
||||
|
||||
for (const [pattern, summary] of SORTED_PATTERNS) {
|
||||
const spaceIdx = pattern.indexOf(' ');
|
||||
const pMethod = pattern.slice(0, spaceIdx);
|
||||
const pPath = pattern.slice(spaceIdx + 1).replace(/^\//, '');
|
||||
if (method !== pMethod) continue;
|
||||
|
||||
const patternSegments = pPath.split('/');
|
||||
const hasWildcard = patternSegments.includes('*');
|
||||
|
||||
if (hasWildcard) {
|
||||
// Wildcard matching: pattern segments must not exceed actual segments
|
||||
if (patternSegments.length > normalizedSegments.length) continue;
|
||||
let match = true;
|
||||
let resourceName = '';
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (patternSegments[i] === '*') {
|
||||
resourceName = resourceName || normalizedSegments[i];
|
||||
} else if (patternSegments[i] !== normalizedSegments[i]) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match) {
|
||||
return resourceName ? `${summary}: ${decodeURIComponent(resourceName)}` : summary;
|
||||
}
|
||||
} else {
|
||||
// Prefix matching (original behavior)
|
||||
if (normalized.startsWith(pPath)) {
|
||||
const rest = normalized.slice(pPath.length).replace(/^\//, '');
|
||||
const resourceName = rest.split('/')[0];
|
||||
return resourceName ? `${summary}: ${decodeURIComponent(resourceName)}` : summary;
|
||||
}
|
||||
}
|
||||
}
|
||||
return `${method} /api/${normalized}`;
|
||||
}
|
||||
@@ -33,17 +33,29 @@ Expanding a row reveals additional detail:
|
||||
|
||||
### Example actions tracked
|
||||
|
||||
- Stack lifecycle: deploy, stop, start, restart, pull, delete
|
||||
- Stack creation and file edits
|
||||
- Stack lifecycle: deploy, stop, start, restart, update, rollback, delete
|
||||
- Stack creation, file edits, and env file changes
|
||||
- Container operations: start, stop, restart
|
||||
- Node management: add, update, delete
|
||||
- User management: create, delete, role changes
|
||||
- User management: create, delete, role assignment and removal
|
||||
- Password changes and node token generation
|
||||
- Settings changes
|
||||
- System prune operations
|
||||
- System prune operations (general, orphans, system)
|
||||
- Image, volume, and network deletion
|
||||
- Network creation
|
||||
- License activation/deactivation
|
||||
- Webhook and notification agent configuration
|
||||
- Notification route management and testing
|
||||
- Fleet backup creation, restoration, and deletion
|
||||
- Fleet node updates (individual and fleet-wide)
|
||||
- Scheduled task management
|
||||
- Registry credential management
|
||||
- SSO configuration changes and connection tests
|
||||
- API token creation and revocation
|
||||
- Label management and bulk actions
|
||||
- Template deployments
|
||||
- Auto-update execution
|
||||
- Console token generation
|
||||
|
||||
## Viewing the audit log
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 147 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 151 KiB |
Generated
+55
@@ -36,12 +36,14 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"cronstrue": "^3.14.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"geist": "^1.7.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"motion": "^12.38.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.5",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-is": "^19.2.5",
|
||||
"react-use-measure": "^2.1.7",
|
||||
@@ -323,6 +325,12 @@
|
||||
"integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@date-fns/tz": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
|
||||
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
|
||||
@@ -2723,6 +2731,15 @@
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tabby_ai/hijri-converter": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@tabby_ai/hijri-converter/-/hijri-converter-1.0.5.tgz",
|
||||
"integrity": "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
|
||||
@@ -3970,6 +3987,22 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns-jalali": {
|
||||
"version": "4.1.0-0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz",
|
||||
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -5349,6 +5382,28 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-day-picker": {
|
||||
"version": "9.14.0",
|
||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.14.0.tgz",
|
||||
"integrity": "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@date-fns/tz": "^1.4.1",
|
||||
"@tabby_ai/hijri-converter": "1.0.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-jalali": "4.1.0-0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/gpbl"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
|
||||
|
||||
@@ -38,12 +38,14 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"cronstrue": "^3.14.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"geist": "^1.7.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"motion": "^12.38.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.5",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-is": "^19.2.5",
|
||||
"react-use-measure": "^2.1.7",
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useState, useEffect, useCallback, Fragment } from 'react';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
@@ -22,6 +23,14 @@ interface AuditEntry {
|
||||
summary: string;
|
||||
}
|
||||
|
||||
const methodOptions = [
|
||||
{ value: 'all', label: 'All Methods' },
|
||||
{ value: 'POST', label: 'POST' },
|
||||
{ value: 'PUT', label: 'PUT' },
|
||||
{ value: 'DELETE', label: 'DELETE' },
|
||||
{ value: 'PATCH', label: 'PATCH' },
|
||||
];
|
||||
|
||||
export function AuditLogView() {
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -29,8 +38,8 @@ export function AuditLogView() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
const [methodFilter, setMethodFilter] = useState('all');
|
||||
const [fromDate, setFromDate] = useState('');
|
||||
const [toDate, setToDate] = useState('');
|
||||
const [fromDate, setFromDate] = useState<Date | undefined>();
|
||||
const [toDate, setToDate] = useState<Date | undefined>();
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const limit = 50;
|
||||
|
||||
@@ -38,7 +47,11 @@ export function AuditLogView() {
|
||||
const params = new URLSearchParams();
|
||||
if (searchFilter) params.set('search', searchFilter);
|
||||
if (methodFilter !== 'all') params.set('method', methodFilter);
|
||||
if (fromDate) params.set('from', String(new Date(fromDate).getTime()));
|
||||
if (fromDate) {
|
||||
const start = new Date(fromDate);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
params.set('from', String(start.getTime()));
|
||||
}
|
||||
if (toDate) {
|
||||
const end = new Date(toDate);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
@@ -60,8 +73,8 @@ export function AuditLogView() {
|
||||
setEntries(data.entries);
|
||||
setTotal(data.total);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - non-critical view
|
||||
} catch (err) {
|
||||
console.error('[AuditLog] Failed to fetch:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -84,8 +97,8 @@ export function AuditLogView() {
|
||||
|
||||
const statusColor = (code: number): string => {
|
||||
if (code >= 200 && code < 300) return 'text-success';
|
||||
if (code >= 400 && code < 500) return 'text-yellow-500';
|
||||
if (code >= 500) return 'text-red-500';
|
||||
if (code >= 400 && code < 500) return 'text-warning';
|
||||
if (code >= 500) return 'text-destructive';
|
||||
return 'text-muted-foreground';
|
||||
};
|
||||
|
||||
@@ -108,27 +121,28 @@ export function AuditLogView() {
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('[AuditLog] Export failed:', err);
|
||||
toast.error('Export failed.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col gap-4 p-6 overflow-auto">
|
||||
<Card>
|
||||
<Card className="rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollText className="w-5 h-5" />
|
||||
<ScrollText className="w-5 h-5" strokeWidth={1.5} />
|
||||
<CardTitle>Audit Log</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="border-border">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
<Download className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
Export
|
||||
<ChevronDown className="w-3 h-3 ml-1" />
|
||||
<ChevronDown className="w-3 h-3 ml-1" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
@@ -137,7 +151,7 @@ export function AuditLogView() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button variant="outline" size="sm" className="border-border" onClick={fetchLogs} disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} strokeWidth={1.5} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
@@ -150,7 +164,7 @@ export function AuditLogView() {
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 mb-4 flex-wrap">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-xs">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
|
||||
<Input
|
||||
placeholder="Search actions, paths, users..."
|
||||
value={searchFilter}
|
||||
@@ -158,31 +172,24 @@ export function AuditLogView() {
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Select value={methodFilter} onValueChange={(v) => { setMethodFilter(v); setPage(1); }}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Methods</SelectItem>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
<SelectItem value="DELETE">DELETE</SelectItem>
|
||||
<SelectItem value="PATCH">PATCH</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="date"
|
||||
value={fromDate}
|
||||
onChange={(e) => { setFromDate(e.target.value); setPage(1); }}
|
||||
className="w-[150px]"
|
||||
placeholder="From"
|
||||
<Combobox
|
||||
options={methodOptions}
|
||||
value={methodFilter}
|
||||
onValueChange={(v) => { setMethodFilter(v || 'all'); setPage(1); }}
|
||||
placeholder="Method"
|
||||
className="w-[140px]"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={fromDate}
|
||||
onChange={(d) => { setFromDate(d); setPage(1); }}
|
||||
placeholder="From"
|
||||
className="w-[160px]"
|
||||
/>
|
||||
<DatePicker
|
||||
value={toDate}
|
||||
onChange={(e) => { setToDate(e.target.value); setPage(1); }}
|
||||
className="w-[150px]"
|
||||
onChange={(d) => { setToDate(d); setPage(1); }}
|
||||
placeholder="To"
|
||||
className="w-[160px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -219,7 +226,7 @@ export function AuditLogView() {
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => setExpandedId(expandedId === entry.id ? null : entry.id)}
|
||||
>
|
||||
<TableCell className="text-xs text-muted-foreground font-mono">
|
||||
<TableCell className="text-xs text-muted-foreground font-mono tabular-nums">
|
||||
{new Date(entry.timestamp).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium text-sm">
|
||||
@@ -233,10 +240,10 @@ export function AuditLogView() {
|
||||
<TableCell className="text-sm">
|
||||
{entry.summary}
|
||||
</TableCell>
|
||||
<TableCell className={`text-sm font-mono ${statusColor(entry.status_code)}`}>
|
||||
<TableCell className={`text-sm font-mono tabular-nums ${statusColor(entry.status_code)}`}>
|
||||
{entry.status_code}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
<TableCell className="text-sm text-muted-foreground font-mono tabular-nums">
|
||||
{entry.node_id ?? '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -250,15 +257,15 @@ export function AuditLogView() {
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block">IP Address</span>
|
||||
<span className="font-mono text-xs">{entry.ip_address || '-'}</span>
|
||||
<span className="font-mono text-xs tabular-nums">{entry.ip_address || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block">Node ID</span>
|
||||
<span className="font-mono text-xs">{entry.node_id ?? 'Local'}</span>
|
||||
<span className="font-mono text-xs tabular-nums">{entry.node_id ?? 'Local'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block">Entry ID</span>
|
||||
<span className="font-mono text-xs">#{entry.id}</span>
|
||||
<span className="font-mono text-xs tabular-nums">#{entry.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -274,15 +281,15 @@ export function AuditLogView() {
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-sm text-muted-foreground font-mono tabular-nums">
|
||||
Page {page} of {totalPages}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1}>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages}>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { DayPicker, getDefaultClassNames } from "react-day-picker";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import "react-day-picker/style.css";
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||
|
||||
function CalendarChevron({ orientation }: { orientation?: "left" | "right" | "up" | "down" }) {
|
||||
return orientation === "left" ? (
|
||||
<ChevronLeft className="h-4 w-4" strokeWidth={1.5} />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" strokeWidth={1.5} />
|
||||
);
|
||||
}
|
||||
|
||||
export function Calendar({ className, classNames, ...props }: CalendarProps) {
|
||||
const defaults = getDefaultClassNames();
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row gap-2",
|
||||
month: "flex flex-col gap-4",
|
||||
month_caption: "flex items-center justify-center h-7 relative",
|
||||
caption_label: "text-sm font-medium font-sans",
|
||||
nav: "flex items-center gap-1",
|
||||
button_previous: cn(
|
||||
"absolute left-1 inline-flex items-center justify-center",
|
||||
"h-7 w-7 rounded-md border-0 bg-transparent p-0",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-accent",
|
||||
"cursor-pointer transition-colors"
|
||||
),
|
||||
button_next: cn(
|
||||
"absolute right-1 inline-flex items-center justify-center",
|
||||
"h-7 w-7 rounded-md border-0 bg-transparent p-0",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-accent",
|
||||
"cursor-pointer transition-colors"
|
||||
),
|
||||
weekdays: "flex",
|
||||
weekday: "text-muted-foreground w-8 font-sans text-xs font-medium",
|
||||
week: "flex mt-1",
|
||||
day: "relative p-0 text-center font-mono text-sm",
|
||||
day_button: cn(
|
||||
"inline-flex items-center justify-center h-8 w-8 rounded-md",
|
||||
"font-mono text-sm tabular-nums p-0 border-0 bg-transparent",
|
||||
"cursor-pointer transition-colors",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-brand"
|
||||
),
|
||||
today: "border border-brand/40 rounded-md",
|
||||
selected: "bg-brand text-brand-foreground rounded-md hover:bg-brand hover:text-brand-foreground",
|
||||
outside: "text-muted-foreground/50",
|
||||
disabled: "text-muted-foreground/30 cursor-not-allowed",
|
||||
chevron: `${defaults.chevron} fill-muted-foreground`,
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Chevron: CalendarChevron,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import * as React from "react";
|
||||
import { format } from "date-fns";
|
||||
import { Calendar as CalendarIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
|
||||
interface DatePickerProps {
|
||||
value: Date | undefined;
|
||||
onChange: (date: Date | undefined) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Pick a date",
|
||||
disabled = false,
|
||||
className,
|
||||
}: DatePickerProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"justify-start text-left font-normal border-border",
|
||||
!value && "text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
{value ? (
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{format(value, "MMM d, yyyy")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs">{placeholder}</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={value}
|
||||
onSelect={(date) => {
|
||||
onChange(date);
|
||||
setOpen(false);
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user