diff --git a/README.md b/README.md index 735d27f..a32fba7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A self-hosted, full-stack network management platform for MikroTik devices. Monitor, configure, and manage your entire MikroTik infrastructure — routers, switches, and wireless access points — from a single web interface. -![Version](https://img.shields.io/badge/version-0.11.2_Beta-blue) +![Version](https://img.shields.io/badge/version-0.11.3_Beta-blue) ![License](https://img.shields.io/badge/license-AGPLv3-blue) ![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white) diff --git a/backend/package.json b/backend/package.json index d23bc24..2c392cd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.11.2-beta", + "version": "0.11.3-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 35c6a68..ec5a1eb 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -291,6 +291,52 @@ CREATE TABLE IF NOT EXISTS credential_presets ( ); ALTER TABLE credential_presets ADD COLUMN IF NOT EXISTS allow_operator_use BOOLEAN NOT NULL DEFAULT TRUE; +-- Maintenance windows — suppress alerts for planned downtime +CREATE TABLE IF NOT EXISTS maintenance_windows ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + device_ids INTEGER[] NOT NULL DEFAULT '{}', + start_at TIMESTAMPTZ NOT NULL, + end_at TIMESTAMPTZ NOT NULL, + recurring_cron VARCHAR(100), + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_maintenance_windows_active ON maintenance_windows(active, start_at, end_at); + +-- Device tags +CREATE TABLE IF NOT EXISTS tags ( + id SERIAL PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE, + color VARCHAR(20) NOT NULL DEFAULT '#6366f1', + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS device_tags ( + device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (device_id, tag_id) +); +CREATE INDEX IF NOT EXISTS idx_device_tags_tag ON device_tags(tag_id); + +-- Audit log — records all write operations performed by authenticated users +CREATE TABLE IF NOT EXISTS audit_log ( + id SERIAL PRIMARY KEY, + user_id INTEGER, + username VARCHAR(50), + method VARCHAR(10) NOT NULL, + path TEXT NOT NULL, + entity_type VARCHAR(50), + entity_id INTEGER, + summary TEXT, + ip_address VARCHAR(45), + status_code INTEGER, + created_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_log_entity ON audit_log(entity_type, entity_id, created_at DESC); + -- Device availability (offline/online outage tracking) CREATE TABLE IF NOT EXISTS device_availability ( id SERIAL PRIMARY KEY, diff --git a/backend/src/index.ts b/backend/src/index.ts index 56db5aa..04df643 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -28,6 +28,9 @@ import { decrypt } from './utils/crypto'; import { corsMiddlewareOptions, socketIoCorsOptions } from './utils/corsOrigins'; import authRoutes from './routes/auth'; +import auditLogRoutes from './routes/auditLog'; +import tagsRoutes from './routes/tags'; +import maintenanceWindowsRoutes from './routes/maintenanceWindows'; import devicesRoutes, { setPollerService as setDevicesPoller } from './routes/devices'; import clientsRoutes, { setPollerService as setClientsPoller } from './routes/clients'; import eventsRoutes from './routes/events'; @@ -43,6 +46,7 @@ import alertsRoutes from './routes/alerts'; import wirelessRoutes from './routes/wireless'; import networkServicesRoutes from './routes/networkServices'; import credentialPresetsRoutes from './routes/credentialPresets'; +import { auditMiddleware } from './middleware/auditMiddleware'; const app = express(); // nginx sits exactly one hop in front; trust its X-Forwarded-For so req.ip is the real client IP @@ -184,6 +188,7 @@ app.use(express.urlencoded({ extended: true })); if (process.env.NODE_ENV !== 'test') { app.use(morgan('combined')); } +app.use(auditMiddleware); // ─── Health Check ───────────────────────────────────────────────────────────── app.get('/api/health', (_req, res) => { @@ -207,6 +212,9 @@ app.use('/api/alerts', alertsRoutes); app.use('/api/wireless', wirelessRoutes); app.use('/api/network-services', networkServicesRoutes); app.use('/api/credential-presets', credentialPresetsRoutes); +app.use('/api/audit-log', auditLogRoutes); +app.use('/api/tags', tagsRoutes); +app.use('/api/maintenance-windows', maintenanceWindowsRoutes); // ─── Error Handler ──────────────────────────────────────────────────────────── app.use(errorHandler); diff --git a/backend/src/middleware/auditMiddleware.ts b/backend/src/middleware/auditMiddleware.ts new file mode 100644 index 0000000..ac7fb27 --- /dev/null +++ b/backend/src/middleware/auditMiddleware.ts @@ -0,0 +1,60 @@ +import { Request, Response, NextFunction } from 'express'; +import { query } from '../config/database'; +import { verifyToken } from './auth'; + +function extractEntity(path: string): { entityType: string | null; entityId: number | null } { + // e.g. /api/devices/42/interfaces → 'device', 42 + const segments = path.replace(/^\/api\//, '').split('/'); + const resourceMap: Record = { + devices: 'device', + backups: 'backup', + clients: 'client', + events: 'event', + settings: 'settings', + alerts: 'alert', + topology: 'topology', + wireless: 'wireless', + 'network-services': 'network_services', + 'credential-presets': 'credential_preset', + 'audit-log': 'audit_log', + users: 'user', + }; + const entityType = resourceMap[segments[0]] ?? segments[0] ?? null; + const idRaw = segments[1]; + const entityId = idRaw && /^\d+$/.test(idRaw) ? parseInt(idRaw, 10) : null; + return { entityType, entityId }; +} + +function extractUser(req: Request): { userId: number | null; username: string | null } { + if (req.user) return { userId: req.user.userId, username: req.user.username }; + const authHeader = req.headers.authorization; + if (authHeader?.startsWith('Bearer ')) { + try { + const payload = verifyToken(authHeader.slice(7)); + return { userId: payload.userId, username: payload.username }; + } catch { /* expired/invalid — record as anonymous */ } + } + return { userId: null, username: null }; +} + +export function auditMiddleware(req: Request, res: Response, next: NextFunction): void { + if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') { + next(); + return; + } + + res.on('finish', () => { + const { entityType, entityId } = extractEntity(req.path); + const { userId, username } = extractUser(req); + const summary = `${req.method} ${req.path}`; + const ip = (req.ip ?? '').replace(/^::ffff:/, ''); + + query( + `INSERT INTO audit_log (user_id, username, method, path, entity_type, entity_id, summary, ip_address, status_code) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + [userId, username, req.method, req.path, entityType, entityId, summary, ip, res.statusCode] + ).catch(() => {}); + }); + + next(); +} diff --git a/backend/src/routes/auditLog.ts b/backend/src/routes/auditLog.ts new file mode 100644 index 0000000..8665ddc --- /dev/null +++ b/backend/src/routes/auditLog.ts @@ -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; diff --git a/backend/src/routes/devices.ts b/backend/src/routes/devices.ts index e34aa37..7d2d108 100644 --- a/backend/src/routes/devices.ts +++ b/backend/src/routes/devices.ts @@ -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 = {}; + 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) ───────────────── diff --git a/backend/src/routes/maintenanceWindows.ts b/backend/src/routes/maintenanceWindows.ts new file mode 100644 index 0000000..0174dea --- /dev/null +++ b/backend/src/routes/maintenanceWindows.ts @@ -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 { + 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( + `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( + `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; + const rows = await query( + `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( + `SELECT * FROM maintenance_windows WHERE active = true AND NOW() BETWEEN start_at AND end_at` + ); + res.json(rows); +}); + +export default router; diff --git a/backend/src/routes/tags.ts b/backend/src/routes/tags.ts new file mode 100644 index 0000000..b5bd04a --- /dev/null +++ b/backend/src/routes/tags.ts @@ -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; diff --git a/backend/src/services/AlertService.ts b/backend/src/services/AlertService.ts index 0430a45..969ae54 100644 --- a/backend/src/services/AlertService.ts +++ b/backend/src/services/AlertService.ts @@ -73,6 +73,19 @@ export class AlertService { const rule = await this.getRule(eventType); if (!rule?.enabled) return; + // Maintenance window suppression — skip if device is in a currently active window + if (ctx.deviceId != null) { + const inMaintenance = 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`, + [ctx.deviceId] + ).catch(() => [{ count: '0' }]); + if (parseInt(inMaintenance[0]?.count || '0', 10) > 0) { + console.log(`[AlertService] Suppressed ${eventType} for device ${ctx.deviceId} (maintenance window)`); + return; + } + } + // Cooldown check const cooldownKey = ctx.cooldownKey ?? `${eventType}:${ctx.deviceId ?? 'global'}`; const cooldownMs = (rule.cooldown_min ?? 15) * 60 * 1000; diff --git a/frontend/package.json b/frontend/package.json index 896cdd8..1106cd5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.11.2-beta", + "version": "0.11.3-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/pages/DevicesPage.tsx b/frontend/src/pages/DevicesPage.tsx index b3488d8..c88aab6 100644 --- a/frontend/src/pages/DevicesPage.tsx +++ b/frontend/src/pages/DevicesPage.tsx @@ -5,7 +5,7 @@ import { Plus, RefreshCw, Router, Wifi, Trash2, ChevronRight, Search, Radar, ArrowUpCircle, Cpu, Pencil, ArrowUpDown, ArrowUp, ArrowDown, } from 'lucide-react'; -import { devicesApi, topologyApi, metricsApi } from '../services/api'; +import { devicesApi, topologyApi, metricsApi, tagsApi } from '../services/api'; import type { Device } from '../types'; import type { DiscoveredDevice } from '../services/api'; import { useCanWrite } from '../hooks/useCanWrite'; @@ -126,6 +126,7 @@ export default function DevicesPage() { }); const [statusFilter, setStatusFilter] = useState<'all' | 'online' | 'offline' | 'updates'>('all'); const [typeFilter, setTypeFilter] = useState(null); + const [tagFilter, setTagFilter] = useState(null); const { data: devices = [], isLoading } = useQuery({ queryKey: ['devices'], @@ -139,6 +140,11 @@ export default function DevicesPage() { refetchInterval: 60_000, }); + const { data: allTags = [] } = useQuery({ + queryKey: ['tags'], + queryFn: () => tagsApi.list().then((r) => r.data), + }); + const syncMutation = useMutation({ mutationFn: (id: number) => devicesApi.sync(id), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['devices'] }), @@ -184,6 +190,7 @@ export default function DevicesPage() { if (typeFilter === 'AP' && d.device_type !== 'wireless_ap') return false; if (typeFilter === 'SW' && d.device_type !== 'switch') return false; if (typeFilter === 'RTR' && d.device_type !== 'router') return false; + if (tagFilter != null && !d.tags?.some(t => t.id === tagFilter)) return false; return true; }); const sorted = [...base].sort((a, b) => { @@ -206,7 +213,7 @@ export default function DevicesPage() { return deviceSort.dir === 'asc' ? cmp : -cmp; }); return sorted; - }, [devices, search, statusFilter, typeFilter, deviceSort]); + }, [devices, search, statusFilter, typeFilter, tagFilter, deviceSort]); const discoveredList = discovered as DiscoveredDevice[]; const duplicateCount = useMemo( @@ -338,6 +345,22 @@ export default function DevicesPage() { ); })} + {allTags.length > 0 && ( + <> +
+ + + )}
{/* Device table */} @@ -411,6 +434,19 @@ export default function DevicesPage() {
{device.name}
+ {device.tags && device.tags.length > 0 && ( +
+ {device.tags.map(tag => ( + + {tag.name} + + ))} +
+ )} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index daf73b9..854bd31 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -2,9 +2,10 @@ import { useState, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Settings, Users, Key, Plus, Trash2, CheckCircle, AlertCircle, Pencil, X, - ShieldCheck, ShieldAlert, RefreshCw, Upload, Lock, Bell, Send, KeyRound, + ShieldCheck, ShieldAlert, RefreshCw, Upload, Lock, Bell, Send, KeyRound, ClipboardList, } from 'lucide-react'; -import { settingsApi, authApi, certApi, alertsApi } from '../services/api'; +import { settingsApi, authApi, certApi, alertsApi, auditLogApi, tagsApi, maintenanceApi } from '../services/api'; +import type { MaintenanceWindow } from '../services/api'; import type { CertInfo, AlertRule, AlertChannel } from '../services/api'; import { useAuthStore } from '../store/authStore'; import { useThemeStore } from '../store/themeStore'; @@ -42,7 +43,12 @@ export default function SettingsPage() { const { theme, setTheme } = useThemeStore(); const isAdmin = user?.role === 'admin'; const canWrite = user?.role !== 'viewer'; - const [activeTab, setActiveTab] = useState<'general' | 'users' | 'credentials' | 'security' | 'certificate' | 'alerting'>('general'); + const [activeTab, setActiveTab] = useState<'general' | 'users' | 'credentials' | 'security' | 'certificate' | 'alerting' | 'audit' | 'tags' | 'maintenance'>('general'); + const [auditSearch, setAuditSearch] = useState(''); + const [auditPage, setAuditPage] = useState(1); + const [newTagName, setNewTagName] = useState(''); + const [newTagColor, setNewTagColor] = useState('#6366f1'); + const [mwForm, setMwForm] = useState({ name: '', start_at: '', end_at: '' }); // ─── App settings ───────────────────────────────────────────────────────── const { data: settings = {} } = useQuery({ @@ -186,6 +192,65 @@ export default function SettingsPage() { reader.readAsText(file); }); + // ─── Tags ───────────────────────────────────────────────────────────────── + const { data: tags = [] } = useQuery({ + queryKey: ['tags'], + queryFn: () => tagsApi.list().then((r) => r.data), + enabled: activeTab === 'tags', + }); + + const createTagMutation = useMutation({ + mutationFn: () => tagsApi.create(newTagName.trim(), newTagColor), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tags'] }); + setNewTagName(''); + }, + }); + + const deleteTagMutation = useMutation({ + mutationFn: (id: number) => tagsApi.delete(id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tags'] }), + }); + + // ─── Maintenance Windows ─────────────────────────────────────────────────── + const { data: maintenanceWindows = [] } = useQuery({ + queryKey: ['maintenance-windows'], + queryFn: () => maintenanceApi.list().then((r) => r.data), + enabled: activeTab === 'maintenance', + }); + + const createMwMutation = useMutation({ + mutationFn: () => maintenanceApi.create({ + name: mwForm.name.trim(), + device_ids: [], + start_at: mwForm.start_at, + end_at: mwForm.end_at, + recurring_cron: null, + active: true, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['maintenance-windows'] }); + setMwForm({ name: '', start_at: '', end_at: '' }); + }, + }); + + const deleteMwMutation = useMutation({ + mutationFn: (id: number) => maintenanceApi.delete(id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['maintenance-windows'] }), + }); + + const toggleMwMutation = useMutation({ + mutationFn: ({ id, active }: { id: number; active: boolean }) => maintenanceApi.update(id, { active }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['maintenance-windows'] }), + }); + + // ─── Audit Log ──────────────────────────────────────────────────────────── + const { data: auditData } = useQuery({ + queryKey: ['audit-log', auditPage, auditSearch], + queryFn: () => auditLogApi.list({ page: auditPage, limit: 50, search: auditSearch || undefined }).then((r) => r.data), + enabled: activeTab === 'audit', + }); + // ─── Alerting ────────────────────────────────────────────────────────────── const { data: alertRules = [] } = useQuery({ queryKey: ['alert-rules'], @@ -284,6 +349,11 @@ export default function SettingsPage() { { key: 'security' as const, label: 'My Password', icon: Key }, { key: 'certificate' as const, label: 'Certificate', icon: Lock }, { key: 'alerting' as const, label: 'Alerting', icon: Bell }, + ...(isAdmin ? [ + { key: 'tags' as const, label: 'Tags', icon: ShieldCheck }, + { key: 'maintenance' as const, label: 'Maintenance', icon: ShieldAlert }, + { key: 'audit' as const, label: 'Audit Log', icon: ClipboardList }, + ] : []), ]; return ( @@ -1414,6 +1484,258 @@ export default function SettingsPage() { )} + {/* ── Tags ── */} + {activeTab === 'tags' && ( +
+
+

Device Tags

+ {isAdmin && ( +
+ setNewTagColor(e.target.value)} + className="w-8 h-8 rounded cursor-pointer border border-gray-300 dark:border-slate-600 p-0.5" + /> + setNewTagName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && newTagName.trim() && createTagMutation.mutate()} + className="input-field flex-1 text-sm" + /> + +
+ )} +
+ {tags.map((tag) => ( +
+
+
+ {tag.name} + {tag.device_count} device{tag.device_count !== 1 ? 's' : ''} +
+ {isAdmin && ( + + )} +
+ ))} + {tags.length === 0 && ( +

No tags created yet

+ )} +
+
+
+ )} + + {/* ── Maintenance Windows ── */} + {activeTab === 'maintenance' && ( +
+
+

Maintenance Windows

+

+ Alerts are suppressed for devices during active maintenance windows. +

+ + {isAdmin && ( +
+ setMwForm(f => ({ ...f, name: e.target.value }))} + className="input-field text-sm" + /> +
+ + setMwForm(f => ({ ...f, start_at: e.target.value }))} + className="input-field text-sm" + /> +
+
+ + setMwForm(f => ({ ...f, end_at: e.target.value }))} + className="input-field text-sm" + /> +
+ +
+ )} + +
+ {(maintenanceWindows as MaintenanceWindow[]).map((mw) => { + const now = new Date(); + const start = new Date(mw.start_at); + const end = new Date(mw.end_at); + const isActive = mw.active && now >= start && now <= end; + const isPast = now > end; + return ( +
+
+
+ {mw.name} + {isActive && Active} + {isPast && Expired} + {!mw.active && Disabled} +
+
+ {new Date(mw.start_at).toLocaleString()} – {new Date(mw.end_at).toLocaleString()} + {mw.device_ids.length > 0 && ` · ${mw.device_ids.length} device(s)`} +
+
+ {isAdmin && ( +
+ + +
+ )} +
+ ); + })} + {(maintenanceWindows as MaintenanceWindow[]).length === 0 && ( +

No maintenance windows created

+ )} +
+
+
+ )} + + {/* ── Audit Log ── */} + {activeTab === 'audit' && ( +
+
+
+

+ + Audit Log +

+ { setAuditSearch(e.target.value); setAuditPage(1); }} + className="input-field w-64 text-sm" + /> +
+
+ + + + + + + + + + + + + {(auditData?.rows ?? []).map((row) => ( + + + + + + + + + ))} + {(auditData?.rows ?? []).length === 0 && ( + + + + )} + +
TimeUserActionEntityStatusIP
+ {new Date(row.created_at).toLocaleString()} + {row.username ?? '—'} + + {row.method} + + {row.path} + + {row.entity_type ? (row.entity_id ? `${row.entity_type}#${row.entity_id}` : row.entity_type) : '—'} + + + {row.status_code ?? '—'} + + {row.ip_address ?? '—'}
+ No audit log entries yet +
+
+ {(auditData?.total ?? 0) > 50 && ( +
+ {auditData?.total} total entries +
+ + Page {auditPage} + +
+
+ )} +
+
+ )}
); } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 4955825..c16ff0b 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -728,4 +728,52 @@ export const networkServicesApi = { api.delete(`/network-services/wireguard/peer/${encodeURIComponent(id)}`, { params: { deviceId } }), }; +// ─── Maintenance Windows ────────────────────────────────────────────────────── +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; +} +export const maintenanceApi = { + list: () => api.get('/maintenance-windows'), + create: (data: Omit) => + api.post('/maintenance-windows', data), + update: (id: number, data: Partial) => + api.put(`/maintenance-windows/${id}`, data), + delete: (id: number) => api.delete(`/maintenance-windows/${id}`), +}; + +// ─── Tags ──────────────────────────────────────────────────────────────────── +export const tagsApi = { + list: () => api.get('/tags'), + create: (name: string, color: string) => api.post('/tags', { name, color }), + update: (id: number, data: { name?: string; color?: string }) => api.put(`/tags/${id}`, data), + delete: (id: number) => api.delete(`/tags/${id}`), + assignDevices: (tagId: number, deviceIds: number[], action: 'add' | 'remove') => + api.post(`/tags/${tagId}/devices`, { deviceIds, action }), + deviceTags: (deviceId: number) => api.get(`/tags/device/${deviceId}`), +}; + +// ─── Audit Log ─────────────────────────────────────────────────────────────── +export const auditLogApi = { + list: (params: { + page?: number; limit?: number; userId?: number; entityType?: string; + search?: string; from?: string; to?: string; + } = {}) => + api.get<{ + rows: { + 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; + }[]; + total: number; page: number; limit: number; + }>('/audit-log', { params }), +}; + export default api; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index c3b559f..c76266d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -69,6 +69,14 @@ export interface Device { rack_slot?: string; created_at: string; updated_at?: string; + tags?: { id: number; name: string; color: string }[]; +} + +export interface Tag { + id: number; + name: string; + color: string; + device_count?: number; } export interface Interface { diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 9df9534..1452005 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.11.2 Beta'; +export const APP_VERSION = 'v0.11.3 Beta';