feat: audit log, device tags, maintenance windows (v0.11.3 Beta)

- Audit log: middleware captures all write operations, paginated admin UI in Settings
- Device tags: full CRUD, device assignment, filter bar and chips on Devices page
- Maintenance windows: alert suppression during planned downtime, Settings management UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-05-16 15:33:15 -07:00
co-authored by Claude Sonnet 4.6
parent 6a4ab1fc5f
commit 49ab1b002e
16 changed files with 801 additions and 10 deletions
+61
View File
@@ -0,0 +1,61 @@
import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { requireAuth, requireAdmin } from '../middleware/auth';
const router = Router();
router.use(requireAuth);
router.use(requireAdmin);
// GET /api/audit-log?page=1&limit=50&userId=&entityType=&search=&from=&to=
router.get('/', async (req: Request, res: Response) => {
const page = Math.max(1, parseInt(String(req.query.page || '1'), 10));
const limit = Math.min(100, Math.max(1, parseInt(String(req.query.limit || '50'), 10)));
const offset = (page - 1) * limit;
const conditions: string[] = [];
const params: unknown[] = [];
if (req.query.userId) {
params.push(parseInt(String(req.query.userId), 10));
conditions.push(`user_id = $${params.length}`);
}
if (req.query.entityType) {
params.push(String(req.query.entityType));
conditions.push(`entity_type = $${params.length}`);
}
if (req.query.search) {
params.push(`%${String(req.query.search)}%`);
conditions.push(`(summary ILIKE $${params.length} OR username ILIKE $${params.length} OR ip_address ILIKE $${params.length})`);
}
if (req.query.from) {
params.push(String(req.query.from));
conditions.push(`created_at >= $${params.length}`);
}
if (req.query.to) {
params.push(String(req.query.to));
conditions.push(`created_at <= $${params.length}`);
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const [rows, countRows] = await Promise.all([
query<{
id: number; user_id: number | null; username: string | null;
method: string; path: string; entity_type: string | null;
entity_id: number | null; summary: string; ip_address: string | null;
status_code: number | null; created_at: string;
}>(`SELECT * FROM audit_log ${where} ORDER BY created_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
[...params, limit, offset]
),
query<{ count: string }>(`SELECT COUNT(*) as count FROM audit_log ${where}`, params),
]);
res.json({
rows,
total: parseInt(countRows[0]?.count || '0', 10),
page,
limit,
});
});
export default router;
+17 -1
View File
@@ -67,7 +67,23 @@ router.get('/', async (_req: Request, res: Response) => {
rack_name, rack_slot, created_at
FROM devices ORDER BY name ASC`
);
res.json(devices);
// Attach tags to each device
const tagRows = await query<{ device_id: number; id: number; name: string; color: string }>(
`SELECT dt.device_id, t.id, t.name, t.color
FROM device_tags dt JOIN tags t ON t.id = dt.tag_id`
);
const tagsByDevice: Record<number, { id: number; name: string; color: string }[]> = {};
for (const t of tagRows) {
if (!tagsByDevice[t.device_id]) tagsByDevice[t.device_id] = [];
tagsByDevice[t.device_id].push({ id: t.id, name: t.name, color: t.color });
}
const result = (devices as { id: number }[]).map((d) => ({
...d,
tags: tagsByDevice[d.id] ?? [],
}));
res.json(result);
});
// ─── Routers overview (router-type devices with route counts) ─────────────────
+88
View File
@@ -0,0 +1,88 @@
import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { requireAuth, requireWrite, requireAdmin } from '../middleware/auth';
const router = Router();
router.use(requireAuth);
export interface MaintenanceWindow {
id: number;
name: string;
device_ids: number[];
start_at: string;
end_at: string;
recurring_cron: string | null;
active: boolean;
created_at: string;
}
// Returns true if deviceId is currently in an active maintenance window
export async function isDeviceInMaintenance(deviceId: number): Promise<boolean> {
const rows = await query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM maintenance_windows
WHERE active = true
AND $1 = ANY(device_ids)
AND NOW() BETWEEN start_at AND end_at`,
[deviceId]
).catch(() => [{ count: '0' }]);
return parseInt(rows[0]?.count || '0', 10) > 0;
}
// GET /api/maintenance-windows
router.get('/', async (_req: Request, res: Response) => {
const rows = await query<MaintenanceWindow>(
`SELECT * FROM maintenance_windows ORDER BY start_at DESC`
);
res.json(rows);
});
// POST /api/maintenance-windows (admin)
router.post('/', requireAdmin, async (req: Request, res: Response) => {
const { name, device_ids, start_at, end_at, recurring_cron } = req.body as {
name: string; device_ids: number[]; start_at: string; end_at: string; recurring_cron?: string;
};
if (!name?.trim() || !start_at || !end_at) {
res.status(400).json({ error: 'name, start_at, and end_at are required' });
return;
}
const rows = await query<MaintenanceWindow>(
`INSERT INTO maintenance_windows (name, device_ids, start_at, end_at, recurring_cron)
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[name.trim(), device_ids ?? [], start_at, end_at, recurring_cron || null]
);
res.status(201).json(rows[0]);
});
// PUT /api/maintenance-windows/:id (admin)
router.put('/:id', requireAdmin, async (req: Request, res: Response) => {
const { name, device_ids, start_at, end_at, recurring_cron, active } = req.body as Partial<MaintenanceWindow>;
const rows = await query<MaintenanceWindow>(
`UPDATE maintenance_windows SET
name = COALESCE($1, name),
device_ids = COALESCE($2, device_ids),
start_at = COALESCE($3, start_at),
end_at = COALESCE($4, end_at),
recurring_cron = COALESCE($5, recurring_cron),
active = COALESCE($6, active)
WHERE id = $7 RETURNING *`,
[name, device_ids, start_at, end_at, recurring_cron, active, req.params.id]
);
if (!rows[0]) { res.status(404).json({ error: 'Not found' }); return; }
res.json(rows[0]);
});
// DELETE /api/maintenance-windows/:id (admin)
router.delete('/:id', requireAdmin, async (req: Request, res: Response) => {
await query(`DELETE FROM maintenance_windows WHERE id = $1`, [req.params.id]);
res.json({ ok: true });
});
// GET /api/maintenance-windows/active — currently active windows (used by AlertService)
router.get('/active', requireWrite, async (_req: Request, res: Response) => {
const rows = await query<MaintenanceWindow>(
`SELECT * FROM maintenance_windows WHERE active = true AND NOW() BETWEEN start_at AND end_at`
);
res.json(rows);
});
export default router;
+85
View File
@@ -0,0 +1,85 @@
import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { requireAuth, requireWrite, requireAdmin } from '../middleware/auth';
const router = Router();
router.use(requireAuth);
// GET /api/tags — list all tags with device count
router.get('/', async (_req: Request, res: Response) => {
const tags = await query<{ id: number; name: string; color: string; device_count: string }>(
`SELECT t.id, t.name, t.color, COUNT(dt.device_id)::text AS device_count
FROM tags t
LEFT JOIN device_tags dt ON dt.tag_id = t.id
GROUP BY t.id ORDER BY t.name`
);
res.json(tags.map(t => ({ ...t, device_count: parseInt(t.device_count, 10) })));
});
// POST /api/tags — create tag (admin only)
router.post('/', requireAdmin, async (req: Request, res: Response) => {
const { name, color } = req.body as { name: string; color?: string };
if (!name?.trim()) { res.status(400).json({ error: 'Tag name is required' }); return; }
const rows = await query<{ id: number; name: string; color: string }>(
`INSERT INTO tags (name, color) VALUES ($1, $2) RETURNING id, name, color`,
[name.trim(), color || '#6366f1']
);
res.status(201).json(rows[0]);
});
// PUT /api/tags/:id — rename or recolor (admin only)
router.put('/:id', requireAdmin, async (req: Request, res: Response) => {
const { name, color } = req.body as { name?: string; color?: string };
const rows = await query<{ id: number; name: string; color: string }>(
`UPDATE tags SET
name = COALESCE($1, name),
color = COALESCE($2, color)
WHERE id = $3 RETURNING id, name, color`,
[name?.trim() || null, color || null, req.params.id]
);
if (!rows[0]) { res.status(404).json({ error: 'Tag not found' }); return; }
res.json(rows[0]);
});
// DELETE /api/tags/:id (admin only)
router.delete('/:id', requireAdmin, async (req: Request, res: Response) => {
await query(`DELETE FROM tags WHERE id = $1`, [req.params.id]);
res.json({ ok: true });
});
// POST /api/tags/:id/devices — assign tag to devices (operator+)
router.post('/:id/devices', requireWrite, async (req: Request, res: Response) => {
const tagId = parseInt(req.params.id, 10);
const { deviceIds, action } = req.body as { deviceIds: number[]; action: 'add' | 'remove' };
if (!Array.isArray(deviceIds) || deviceIds.length === 0) {
res.status(400).json({ error: 'deviceIds array required' });
return;
}
if (action === 'remove') {
await query(
`DELETE FROM device_tags WHERE tag_id = $1 AND device_id = ANY($2::int[])`,
[tagId, deviceIds]
);
} else {
for (const deviceId of deviceIds) {
await query(
`INSERT INTO device_tags (device_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[deviceId, tagId]
);
}
}
res.json({ ok: true });
});
// GET /api/tags/device/:deviceId — tags for a specific device
router.get('/device/:deviceId', async (req: Request, res: Response) => {
const tags = await query<{ id: number; name: string; color: string }>(
`SELECT t.id, t.name, t.color FROM tags t
JOIN device_tags dt ON dt.tag_id = t.id
WHERE dt.device_id = $1 ORDER BY t.name`,
[req.params.deviceId]
);
res.json(tags);
});
export default router;