From 0aefadbfd8a5a985ab63b7aa67925fd38e7d1f0b Mon Sep 17 00:00:00 2001 From: ske087 Date: Sun, 10 May 2026 23:10:02 +0300 Subject: [PATCH] NetworkView: API routing fix, logout button, audit trail, port/notes editor tracking - Fix frontend API base path (VITE_API_BASE env var, GraphPage hardcoded /api) - Add logout button to NetworkView sidebar (clears portal SSO) - Add AuditTrail component: inline change history on all entity pages - DB migration: add updated_at, last_edited_by to ports table - DB migration: add notes_last_edited_by, notes_updated_at to all entity tables - Backend: track actor on port create/update; notes editor on entity PUT - Frontend: extend types, MarkdownEditor shows last editor, port modal/list show last editor - Fix port CREATE TABLE definition to include new columns upfront - Add try/catch in handleSavePort to surface API errors in modal --- .dev-pids | 10 +- IT_asset_management/app/routes/auth.py | 2 +- IT_asset_management/config.py | 1 + IT_asset_management/run.py | 2 +- NetworkView/backend/src/db.js | 18 +- NetworkView/backend/src/index.js | 2 +- NetworkView/backend/src/routes/components.js | 14 +- NetworkView/backend/src/routes/racks.js | 5 + NetworkView/backend/src/routes/rooms.js | 10 +- NetworkView/backend/src/routes/sites.js | 10 +- NetworkView/frontend/src/api.ts | 2 +- .../frontend/src/components/AuditTrail.tsx | 146 ++++++++ .../src/components/MarkdownEditor.tsx | 12 +- .../frontend/src/components/Sidebar.tsx | 7 + NetworkView/frontend/src/index.css | 115 ++++++ .../frontend/src/pages/ComponentPage.tsx | 62 +++- NetworkView/frontend/src/pages/GraphPage.tsx | 4 +- NetworkView/frontend/src/pages/RackPage.tsx | 11 +- NetworkView/frontend/src/pages/RoomPage.tsx | 11 +- NetworkView/frontend/src/pages/SitePage.tsx | 11 +- NetworkView/frontend/src/types.ts | 10 + Server_Monitorizare_v2/main.py | 2 +- digiserver-v2/app/blueprints/auth.py | 6 +- digiserver-v2/app/config.py | 1 + .../app/templates/admin/user_management.html | 345 ++---------------- portal/run.py | 2 +- start-dev.sh | 4 +- 27 files changed, 470 insertions(+), 355 deletions(-) create mode 100644 NetworkView/frontend/src/components/AuditTrail.tsx diff --git a/.dev-pids b/.dev-pids index 7ab966d..8f3ed45 100644 --- a/.dev-pids +++ b/.dev-pids @@ -1,5 +1,5 @@ -31614 -31616 -31618 -31620 -31622 +36827 +36829 +36831 +36833 +36835 diff --git a/IT_asset_management/app/routes/auth.py b/IT_asset_management/app/routes/auth.py index b1442eb..9750086 100644 --- a/IT_asset_management/app/routes/auth.py +++ b/IT_asset_management/app/routes/auth.py @@ -15,4 +15,4 @@ def login(): @login_required def logout(): logout_user() - return redirect(current_app.config.get('PORTAL_LOGIN_URL', 'http://localhost:8080/login')) + return redirect(current_app.config.get('PORTAL_LOGOUT_URL', 'http://localhost:8080/logout')) diff --git a/IT_asset_management/config.py b/IT_asset_management/config.py index b0fa790..e3d1dc8 100644 --- a/IT_asset_management/config.py +++ b/IT_asset_management/config.py @@ -52,6 +52,7 @@ class Config: # Portal SSO PORTAL_LOGIN_URL = os.environ.get('PORTAL_LOGIN_URL', 'http://localhost:8080/login') + PORTAL_LOGOUT_URL = os.environ.get('PORTAL_LOGOUT_URL', 'http://localhost:8080/logout') # Pagination ITEMS_PER_PAGE = int(os.environ.get('ITEMS_PER_PAGE', 25)) diff --git a/IT_asset_management/run.py b/IT_asset_management/run.py index 3b30eb5..db8226a 100644 --- a/IT_asset_management/run.py +++ b/IT_asset_management/run.py @@ -5,4 +5,4 @@ app = create_app(os.environ.get('FLASK_ENV', 'development')) if __name__ == '__main__': port = int(os.environ.get('PORT', 5003)) - app.run(host='0.0.0.0', port=port) + app.run(host='127.0.0.1', port=port) diff --git a/NetworkView/backend/src/db.js b/NetworkView/backend/src/db.js index ea5757e..74b4f6d 100644 --- a/NetworkView/backend/src/db.js +++ b/NetworkView/backend/src/db.js @@ -68,7 +68,9 @@ db.exec(` port_type TEXT DEFAULT 'RJ45', connected_to_port_id TEXT REFERENCES ports(id) ON DELETE SET NULL, notes TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_edited_by TEXT ); `); @@ -76,6 +78,20 @@ db.exec(` try { db.exec('ALTER TABLE components ADD COLUMN port_count INTEGER DEFAULT NULL'); } catch (_) {} try { db.exec('ALTER TABLE components ADD COLUMN sfp_count INTEGER DEFAULT NULL'); } catch (_) {} +// Migrate: port editor tracking +try { db.exec('ALTER TABLE ports ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP'); } catch (_) {} +try { db.exec('ALTER TABLE ports ADD COLUMN last_edited_by TEXT'); } catch (_) {} + +// Migrate: notes editor tracking per entity +try { db.exec('ALTER TABLE sites ADD COLUMN notes_last_edited_by TEXT'); } catch (_) {} +try { db.exec('ALTER TABLE sites ADD COLUMN notes_updated_at DATETIME'); } catch (_) {} +try { db.exec('ALTER TABLE rooms ADD COLUMN notes_last_edited_by TEXT'); } catch (_) {} +try { db.exec('ALTER TABLE rooms ADD COLUMN notes_updated_at DATETIME'); } catch (_) {} +try { db.exec('ALTER TABLE racks ADD COLUMN notes_last_edited_by TEXT'); } catch (_) {} +try { db.exec('ALTER TABLE racks ADD COLUMN notes_updated_at DATETIME'); } catch (_) {} +try { db.exec('ALTER TABLE components ADD COLUMN notes_last_edited_by TEXT'); } catch (_) {} +try { db.exec('ALTER TABLE components ADD COLUMN notes_updated_at DATETIME'); } catch (_) {} + // ── Users & Audit tables ───────────────────────────────────────────────────── db.exec(` CREATE TABLE IF NOT EXISTS users ( diff --git a/NetworkView/backend/src/index.js b/NetworkView/backend/src/index.js index b535586..fcbf3c9 100644 --- a/NetworkView/backend/src/index.js +++ b/NetworkView/backend/src/index.js @@ -67,6 +67,6 @@ app.get('*', (_req, res) => { }); }); -app.listen(PORT, () => { +app.listen(PORT, '127.0.0.1', () => { console.log(`NetworkView backend running on http://localhost:${PORT}`); }); diff --git a/NetworkView/backend/src/routes/components.js b/NetworkView/backend/src/routes/components.js index 71c3b0a..ae486fd 100644 --- a/NetworkView/backend/src/routes/components.js +++ b/NetworkView/backend/src/routes/components.js @@ -126,12 +126,16 @@ router.put('/:id', (req, res) => { } } + const notesChanging = notes != null; + const actor = req.actor?.username || null; db.prepare(` UPDATE components SET name = ?, type = ?, position = ?, height_units = ?, manufacturer = ?, model = ?, serial_number = ?, asset_tag = ?, ip_address = ?, mac_address = ?, status = ?, notes = ?, port_count = ?, sfp_count = ?, + notes_last_edited_by = CASE WHEN ? = 1 THEN ? ELSE notes_last_edited_by END, + notes_updated_at = CASE WHEN ? = 1 THEN CURRENT_TIMESTAMP ELSE notes_updated_at END, updated_at = CURRENT_TIMESTAMP WHERE id = ? `).run( @@ -149,6 +153,7 @@ router.put('/:id', (req, res) => { notes ?? component.notes, port_count !== undefined ? port_count : component.port_count, sfp_count !== undefined ? sfp_count : component.sfp_count, + notesChanging ? 1 : 0, actor, notesChanging ? 1 : 0, req.params.id ); @@ -198,9 +203,9 @@ router.post('/:id/ports', (req, res) => { const id = uuidv4(); db.prepare(` - INSERT INTO ports (id, component_id, port_number, label, port_type, connected_to_port_id, notes) - VALUES (?, ?, ?, ?, ?, ?, ?) - `).run(id, req.params.id, port_number, label || null, port_type || 'RJ45', connected_to_port_id || null, notes || null); + INSERT INTO ports (id, component_id, port_number, label, port_type, connected_to_port_id, notes, last_edited_by, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + `).run(id, req.params.id, port_number, label || null, port_type || 'RJ45', connected_to_port_id || null, notes || null, req.actor?.username || null); // Mirror link on the other side if (connected_to_port_id) { @@ -225,12 +230,13 @@ router.put('/:componentId/ports/:portId', (req, res) => { } db.prepare(` - UPDATE ports SET label = ?, port_type = ?, connected_to_port_id = ?, notes = ? WHERE id = ? + UPDATE ports SET label = ?, port_type = ?, connected_to_port_id = ?, notes = ?, last_edited_by = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `).run( label ?? port.label, port_type ?? port.port_type, newLinkedId, notes ?? port.notes, + req.actor?.username || null, req.params.portId ); diff --git a/NetworkView/backend/src/routes/racks.js b/NetworkView/backend/src/routes/racks.js index 320338a..f1d950a 100644 --- a/NetworkView/backend/src/routes/racks.js +++ b/NetworkView/backend/src/routes/racks.js @@ -76,8 +76,12 @@ router.put('/:id', (req, res) => { if (total_units != null && total_units !== rack.total_units) changes.total_units = { from: rack.total_units, to: total_units }; if (manufacturer != null && manufacturer !== rack.manufacturer) changes.manufacturer = { from: rack.manufacturer, to: manufacturer }; if (model != null && model !== rack.model) changes.model = { from: rack.model, to: model }; + const notesChanging = notes != null; + const actor = req.actor?.username || null; db.prepare(` UPDATE racks SET name = ?, total_units = ?, manufacturer = ?, model = ?, notes = ?, + notes_last_edited_by = CASE WHEN ? = 1 THEN ? ELSE notes_last_edited_by END, + notes_updated_at = CASE WHEN ? = 1 THEN CURRENT_TIMESTAMP ELSE notes_updated_at END, updated_at = CURRENT_TIMESTAMP WHERE id = ? `).run( name ?? rack.name, @@ -85,6 +89,7 @@ router.put('/:id', (req, res) => { manufacturer ?? rack.manufacturer, model ?? rack.model, notes ?? rack.notes, + notesChanging ? 1 : 0, actor, notesChanging ? 1 : 0, req.params.id ); diff --git a/NetworkView/backend/src/routes/rooms.js b/NetworkView/backend/src/routes/rooms.js index 945ff50..4fe7f23 100644 --- a/NetworkView/backend/src/routes/rooms.js +++ b/NetworkView/backend/src/routes/rooms.js @@ -63,9 +63,15 @@ router.put('/:id', (req, res) => { const { name, notes } = req.body; const changes = {}; if (name != null && name !== room.name) changes.name = { from: room.name, to: name }; + const notesChanging = notes != null; + const actor = req.actor?.username || null; db.prepare(` - UPDATE rooms SET name = ?, notes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? - `).run(name ?? room.name, notes ?? room.notes, req.params.id); + UPDATE rooms SET name = ?, notes = ?, + notes_last_edited_by = CASE WHEN ? = 1 THEN ? ELSE notes_last_edited_by END, + notes_updated_at = CASE WHEN ? = 1 THEN CURRENT_TIMESTAMP ELSE notes_updated_at END, + updated_at = CURRENT_TIMESTAMP WHERE id = ? + `).run(name ?? room.name, notes ?? room.notes, + notesChanging ? 1 : 0, actor, notesChanging ? 1 : 0, req.params.id); logAudit(req, { action: 'update', entityType: 'room', entityId: room.id, entityName: name ?? room.name, changes }); res.json(db.prepare('SELECT * FROM rooms WHERE id = ?').get(req.params.id)); diff --git a/NetworkView/backend/src/routes/sites.js b/NetworkView/backend/src/routes/sites.js index 7eface3..586d5d9 100644 --- a/NetworkView/backend/src/routes/sites.js +++ b/NetworkView/backend/src/routes/sites.js @@ -56,10 +56,16 @@ router.put('/:id', (req, res) => { const changes = {}; if (name != null && name !== site.name) changes.name = { from: site.name, to: name }; if (location != null && location !== site.location) changes.location = { from: site.location, to: location }; + const notesChanging = notes != null; + const actor = req.actor?.username || null; db.prepare(` - UPDATE sites SET name = ?, location = ?, notes = ?, updated_at = CURRENT_TIMESTAMP + UPDATE sites SET name = ?, location = ?, notes = ?, + notes_last_edited_by = CASE WHEN ? = 1 THEN ? ELSE notes_last_edited_by END, + notes_updated_at = CASE WHEN ? = 1 THEN CURRENT_TIMESTAMP ELSE notes_updated_at END, + updated_at = CURRENT_TIMESTAMP WHERE id = ? - `).run(name ?? site.name, location ?? site.location, notes ?? site.notes, req.params.id); + `).run(name ?? site.name, location ?? site.location, notes ?? site.notes, + notesChanging ? 1 : 0, actor, notesChanging ? 1 : 0, req.params.id); logAudit(req, { action: 'update', entityType: 'site', entityId: site.id, entityName: name ?? site.name, changes }); res.json(db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id)); diff --git a/NetworkView/frontend/src/api.ts b/NetworkView/frontend/src/api.ts index f2d03a6..eb3aed8 100644 --- a/NetworkView/frontend/src/api.ts +++ b/NetworkView/frontend/src/api.ts @@ -87,7 +87,7 @@ export const revokeApiKey = (id: string) => // --- Audit --- export const getAuditLog = (params: { - entity_type?: string; action?: string; user_id?: string; + entity_type?: string; entity_id?: string; action?: string; user_id?: string; limit?: number; offset?: number; }) => { const qs = new URLSearchParams( diff --git a/NetworkView/frontend/src/components/AuditTrail.tsx b/NetworkView/frontend/src/components/AuditTrail.tsx new file mode 100644 index 0000000..c109742 --- /dev/null +++ b/NetworkView/frontend/src/components/AuditTrail.tsx @@ -0,0 +1,146 @@ +import { useEffect, useState, useCallback } from 'react'; +import * as api from '../api'; +import type { AuditEntry } from '../types'; + +const ACTION_LABELS: Record = { + create: { label: 'Created', color: 'var(--success)' }, + update: { label: 'Updated', color: 'var(--accent)' }, + delete: { label: 'Deleted', color: 'var(--danger)' }, + delete_all: { label: 'Deleted all', color: 'var(--danger)' }, + api_key_rotate: { label: 'API key rotated', color: 'var(--warning)' }, + api_key_revoke: { label: 'API key revoked', color: 'var(--warning)' }, +}; + +function formatTs(ts: string) { + const d = new Date(ts); + return d.toLocaleString(undefined, { + year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', + }); +} + +function ChangeSummary({ raw }: { raw: string }) { + let parsed: Record | null = null; + try { parsed = JSON.parse(raw); } catch { /* not JSON */ } + + if (!parsed || typeof parsed !== 'object') { + return {raw}; + } + + const entries = Object.entries(parsed); + if (entries.length === 0) return null; + + return ( +
    + {entries.map(([field, diff]) => ( +
  • + {field}{' '} + "{String(diff.from ?? '')}" + {' → '} + "{String(diff.to ?? '')}" +
  • + ))} +
+ ); +} + +interface Props { + entityId: string; +} + +export default function AuditTrail({ entityId }: Props) { + const [open, setOpen] = useState(false); + const [entries, setEntries] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [offset, setOffset] = useState(0); + const LIMIT = 20; + + const load = useCallback(async () => { + setLoading(true); + const result = await api.getAuditLog({ entity_id: entityId, limit: LIMIT, offset }); + setEntries(result.entries); + setTotal(result.total); + setLoading(false); + }, [entityId, offset]); + + useEffect(() => { + if (open) load(); + }, [open, load]); + + return ( +
+ + + {open && ( +
+ {loading &&
Loading…
} + + {!loading && entries.length === 0 && ( +
No history recorded yet.
+ )} + + {!loading && entries.length > 0 && ( + <> +
    + {entries.map((e, idx) => { + const meta = ACTION_LABELS[e.action] ?? { label: e.action, color: 'var(--text3)' }; + const isFirst = idx === entries.length - 1 && offset === 0; + return ( +
  • + +
    +
    + + {meta.label} + + by {e.username ?? 'unknown'} + {formatTs(e.created_at)} +
    + {e.changes && ( +
    + +
    + )} +
    +
  • + ); + })} +
+ + {(total > LIMIT) && ( +
+ + + {offset + 1}–{Math.min(offset + LIMIT, total)} of {total} + + +
+ )} + + )} +
+ )} +
+ ); +} diff --git a/NetworkView/frontend/src/components/MarkdownEditor.tsx b/NetworkView/frontend/src/components/MarkdownEditor.tsx index e6e7916..c88ff3f 100644 --- a/NetworkView/frontend/src/components/MarkdownEditor.tsx +++ b/NetworkView/frontend/src/components/MarkdownEditor.tsx @@ -7,11 +7,13 @@ interface Props { onChange: (val: string) => void; onSave?: (val: string) => void; autoSaveDelay?: number; + lastEditedBy?: string | null; + lastEditedAt?: string | null; } type EditorMode = 'edit' | 'split' | 'preview'; -export default function MarkdownEditor({ value, onChange, onSave, autoSaveDelay = 1500 }: Props) { +export default function MarkdownEditor({ value, onChange, onSave, autoSaveDelay = 1500, lastEditedBy, lastEditedAt }: Props) { const [mode, setMode] = useState('split'); const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved'); const saveTimer = useRef | null>(null); @@ -51,6 +53,14 @@ export default function MarkdownEditor({ value, onChange, onSave, autoSaveDelay {saveStatus === 'saving' ? '⟳ Saving…' : saveStatus === 'unsaved' ? '● Unsaved' : '✓ Saved'} )} + {lastEditedBy && ( + + ✎ {lastEditedBy} + {lastEditedAt && ( + <> · {new Date(lastEditedAt).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} + )} + + )} Markdown supported · GFM tables, checkboxes diff --git a/NetworkView/frontend/src/components/Sidebar.tsx b/NetworkView/frontend/src/components/Sidebar.tsx index 004d9ca..caf4c89 100644 --- a/NetworkView/frontend/src/components/Sidebar.tsx +++ b/NetworkView/frontend/src/components/Sidebar.tsx @@ -229,6 +229,13 @@ export default function Sidebar() { ⚙ Settings + ); diff --git a/NetworkView/frontend/src/index.css b/NetworkView/frontend/src/index.css index ec9bad1..a9e2cb6 100644 --- a/NetworkView/frontend/src/index.css +++ b/NetworkView/frontend/src/index.css @@ -440,6 +440,7 @@ a:hover { text-decoration: underline; } border-bottom: 1px solid var(--border); } .modal-title { font-size: 17px; font-weight: 600; } +.modal-last-edited { font-size: 11px; color: var(--accent); opacity: 0.8; margin-left: auto; margin-right: 12px; white-space: nowrap; } .modal-close { background: none; border: none; @@ -893,6 +894,7 @@ a:hover { text-decoration: underline; } .port-label { font-size: 13px; font-weight: 500; color: var(--text); } .port-type { font-size: 11px; color: var(--text2); } .port-notes { font-size: 11px; color: var(--text3); font-style: italic; margin-top: 2px; } +.port-edited-by { font-size: 10px; color: var(--accent); opacity: 0.7; margin-top: 3px; } .port-link-chain { font-size: 11px; @@ -1005,6 +1007,7 @@ a:hover { text-decoration: underline; } .save-status-unsaved { color: var(--text2); } .md-help-hint { font-size: 11px; color: var(--text3); } +.md-last-edited { font-size: 11px; color: var(--accent); opacity: 0.8; white-space: nowrap; } .md-editor-body { display: flex; min-height: 280px; } .md-mode-edit .md-editor-body { } @@ -1654,6 +1657,18 @@ a:hover { text-decoration: underline; } background: var(--surface2); color: var(--text1); } +.sidebar-logout-btn { + width: 100%; + background: none; + border: none; + cursor: pointer; + text-align: left; + margin-top: 2px; +} +.sidebar-logout-btn:hover { + background: rgba(248, 81, 73, 0.12) !important; + color: var(--danger) !important; +} /* ─── Settings Page ──────────────────────────────────────────── */ .page-settings { @@ -1867,3 +1882,103 @@ a:hover { text-decoration: underline; } width: 140px; } .danger-input:focus { outline: 2px solid #f87171; } + +/* ─── Audit Trail (inline entity history) ────────────────────── */ +.audit-trail-section { + padding-top: 0; +} +.audit-trail-toggle { + display: flex; + align-items: center; + gap: 8px; + background: none; + border: none; + cursor: pointer; + color: var(--text2); + font-size: 0.85rem; + font-weight: 500; + padding: 8px 0; + transition: color 0.15s; +} +.audit-trail-toggle:hover { color: var(--text); } +.audit-trail-toggle-icon { font-size: 10px; color: var(--text3); } +.audit-trail-count { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 999px; + font-size: 11px; + padding: 1px 7px; + color: var(--text3); +} +.audit-trail-body { + margin-top: 4px; + border-left: 2px solid var(--border); + padding-left: 16px; +} +.audit-trail-loading, +.audit-trail-empty { + font-size: 12px; + color: var(--text3); + padding: 8px 0; +} +.audit-trail-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0; +} +.audit-trail-item { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid var(--border2); + position: relative; +} +.audit-trail-item:last-child { border-bottom: none; } +.audit-trail-item-first .audit-trail-action::after { + content: ' (initial)'; + font-size: 10px; + color: var(--text3); +} +.audit-trail-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + margin-top: 5px; +} +.audit-trail-content { flex: 1; min-width: 0; } +.audit-trail-header { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + font-size: 12px; +} +.audit-trail-action { font-weight: 600; } +.audit-trail-user { color: var(--text2); } +.audit-trail-time { color: var(--text3); margin-left: auto; white-space: nowrap; } +.audit-trail-changes { + margin-top: 4px; + font-size: 11px; +} +.audit-trail-changes-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 2px; + padding-left: 0; +} +.audit-trail-changes-list li { color: var(--text3); } +.audit-trail-field { color: var(--text2); font-weight: 500; } +.audit-trail-from { color: var(--danger); opacity: 0.8; } +.audit-trail-to { color: var(--success); opacity: 0.9; } +.audit-trail-changes-raw { color: var(--text3); font-style: italic; } +.audit-trail-pager { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 0 0; +} +.audit-trail-pager-info { font-size: 11px; color: var(--text3); } diff --git a/NetworkView/frontend/src/pages/ComponentPage.tsx b/NetworkView/frontend/src/pages/ComponentPage.tsx index 6134bac..a629e99 100644 --- a/NetworkView/frontend/src/pages/ComponentPage.tsx +++ b/NetworkView/frontend/src/pages/ComponentPage.tsx @@ -4,6 +4,7 @@ import * as api from '../api'; import type { Component, Port } from '../types'; import { COMPONENT_META, COMPONENT_TYPES } from '../types'; import MarkdownEditor from '../components/MarkdownEditor'; +import AuditTrail from '../components/AuditTrail'; const STATUS_OPTIONS = ['active', 'maintenance', 'decommissioned'] as const; @@ -141,21 +142,26 @@ export default function ComponentPage() { } } - if (portModal?.editPort) { - await api.updatePort(componentId, portModal.editPort.id, { - label: portForm.label || undefined, - port_type: portForm.port_type, - notes: portForm.notes || undefined, - connected_to_port_id: connectedToPortId, - }); - } else { - await api.createPort(componentId, { - port_number: Number(portForm.port_number), - label: portForm.label || undefined, - port_type: portForm.port_type, - notes: portForm.notes || undefined, - connected_to_port_id: connectedToPortId ?? undefined, - }); + try { + if (portModal?.editPort) { + await api.updatePort(componentId, portModal.editPort.id, { + label: portForm.label || undefined, + port_type: portForm.port_type, + notes: portForm.notes || undefined, + connected_to_port_id: connectedToPortId, + }); + } else { + await api.createPort(componentId, { + port_number: Number(portForm.port_number), + label: portForm.label || undefined, + port_type: portForm.port_type, + notes: portForm.notes || undefined, + connected_to_port_id: connectedToPortId ?? undefined, + }); + } + } catch (err: unknown) { + setPortError(err instanceof Error ? err.message : 'Failed to save port'); + return; } setPortModal(null); setSwLink(null); @@ -353,6 +359,14 @@ export default function ComponentPage() { } )} + {port.last_edited_by && ( +
+ ✎ {port.last_edited_by} + {port.updated_at && ( + <> · {new Date(port.updated_at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} + )} +
+ )} @@ -372,6 +386,14 @@ export default function ComponentPage() { ? `Edit ${component.type === 'patch_panel' ? 'PP' : 'SW'} Port ${portModal.editPort.port_number}` : `Add ${component.type === 'patch_panel' ? 'PP Port' : 'SW Port'}`} + {portModal.editPort?.last_edited_by && ( + + ✎ {portModal.editPort.last_edited_by} + {portModal.editPort.updated_at && ( + <> · {new Date(portModal.editPort.updated_at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} + )} + + )}
@@ -464,8 +486,16 @@ export default function ComponentPage() { {/* Notes */}

Documentation

- +
+ + ); } diff --git a/NetworkView/frontend/src/pages/GraphPage.tsx b/NetworkView/frontend/src/pages/GraphPage.tsx index 5e3fc25..0583764 100644 --- a/NetworkView/frontend/src/pages/GraphPage.tsx +++ b/NetworkView/frontend/src/pages/GraphPage.tsx @@ -2,6 +2,8 @@ import { useEffect, useState, useRef, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { COMPONENT_META } from '../types'; +const API_BASE = import.meta.env.VITE_API_BASE ?? '/api'; + // ── Types ──────────────────────────────────────────────────────────────────── interface GNode { @@ -158,7 +160,7 @@ export default function GraphPage() { if (!level || !id) return; setLoading(true); setErrMsg(null); - fetch(`/api/graph/${level}/${id}`) + fetch(`${API_BASE}/graph/${level}/${id}`) .then(r => r.ok ? r.json() : r.json().then((b: { error: string }) => Promise.reject(b.error))) .then((data: { nodes: Omit[]; edges: GEdge[] }) => { const positions = computeTreeLayout(data.nodes, data.edges); diff --git a/NetworkView/frontend/src/pages/RackPage.tsx b/NetworkView/frontend/src/pages/RackPage.tsx index 250864f..128d332 100644 --- a/NetworkView/frontend/src/pages/RackPage.tsx +++ b/NetworkView/frontend/src/pages/RackPage.tsx @@ -7,6 +7,7 @@ import RackGraphicView from '../components/RackGraphicView'; import MarkdownEditor from '../components/MarkdownEditor'; import { AddComponentModal, SimpleCreateModal } from '../components/AddItemModal'; import type { ComponentFormData } from '../components/AddItemModal'; +import AuditTrail from '../components/AuditTrail'; // ── Rack Layout Table ───────────────────────────────────────────────────────── function RackLayoutView({ rack, components, onAddAtSlot }: { @@ -259,8 +260,16 @@ export default function RackPage() {

Rack Notes

- +
+ + {/* RIGHT: Graphical front panel */} diff --git a/NetworkView/frontend/src/pages/RoomPage.tsx b/NetworkView/frontend/src/pages/RoomPage.tsx index ddc80b7..f1db62b 100644 --- a/NetworkView/frontend/src/pages/RoomPage.tsx +++ b/NetworkView/frontend/src/pages/RoomPage.tsx @@ -4,6 +4,7 @@ import * as api from '../api'; import type { Room, Rack } from '../types'; import MarkdownEditor from '../components/MarkdownEditor'; import { SimpleCreateModal } from '../components/AddItemModal'; +import AuditTrail from '../components/AuditTrail'; export default function RoomPage() { const { roomId } = useParams<{ roomId: string }>(); @@ -132,9 +133,17 @@ export default function RoomPage() {

Notes

- +
+ + {showAddRack && ( (); @@ -124,9 +125,17 @@ export default function SitePage() { {/* Notes */}

Notes

- +
+ + {showAddRoom && (

👥 User Management

- + + +
+ 🔒 +
+ User accounts are managed by the Enterprise Digital Platform portal.
+ To create users or change roles, visit Portal Settings → Users. + This page shows only the users who currently have access to DigiServer. +
@@ -19,7 +27,6 @@ Role Created At Last Login - Actions @@ -39,26 +46,11 @@ {{ user.created_at | localtime if user.created_at else 'N/A' }} {{ user.last_login | localtime if user.last_login else 'Never' }} - - {% if user.id != current_user.id %} - - - - {% else %} - Current User - {% endif %} - {% endfor %} {% else %} - No users found + No users found {% endif %} @@ -94,93 +86,7 @@ - - - - - - - - {% endblock %} diff --git a/portal/run.py b/portal/run.py index 319756b..02bd8ea 100644 --- a/portal/run.py +++ b/portal/run.py @@ -5,4 +5,4 @@ app = create_app(os.environ.get('FLASK_ENV', 'production')) if __name__ == '__main__': port = int(os.environ.get('PORT', 5001)) - app.run(host='0.0.0.0', port=port, debug=(os.environ.get('FLASK_ENV') == 'development')) + app.run(host='127.0.0.1', port=port, debug=(os.environ.get('FLASK_ENV') == 'development')) diff --git a/start-dev.sh b/start-dev.sh index c83e184..f2f06e0 100755 --- a/start-dev.sh +++ b/start-dev.sh @@ -359,8 +359,9 @@ if module_enabled "digiserver"; then ADMIN_USERNAME=admin \ ADMIN_PASSWORD=admin123 \ PORTAL_JWT_SECRET=change-this-jwt-secret-in-production \ + PORTAL_LOGOUT_URL="http://localhost:${NGINX_PORT}/logout" \ "$ROOT/digiserver-v2/.venv/bin/gunicorn" \ - --bind "0.0.0.0:$DIGISERVER_PORT" \ + --bind "127.0.0.1:$DIGISERVER_PORT" \ --workers 2 \ --timeout 120 \ --chdir "$ROOT/digiserver-v2" \ @@ -378,6 +379,7 @@ if module_enabled "itassets"; then SQLALCHEMY_DATABASE_URI="sqlite:///$ROOT/IT_asset_management/data/itassets.db" \ PORTAL_JWT_SECRET=change-this-jwt-secret-in-production \ PORTAL_LOGIN_URL="http://localhost:${NGINX_PORT}/login" \ + PORTAL_LOGOUT_URL="http://localhost:${NGINX_PORT}/logout" \ FLASK_APP=run.py \ "$ROOT/IT_asset_management/.venv/bin/python" "$ROOT/IT_asset_management/run.py" else