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
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import * as api from '../api';
|
||||
import type { AuditEntry } from '../types';
|
||||
|
||||
const ACTION_LABELS: Record<string, { label: string; color: string }> = {
|
||||
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<string, { from: unknown; to: unknown }> | null = null;
|
||||
try { parsed = JSON.parse(raw); } catch { /* not JSON */ }
|
||||
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return <span className="audit-trail-changes-raw">{raw}</span>;
|
||||
}
|
||||
|
||||
const entries = Object.entries(parsed);
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ul className="audit-trail-changes-list">
|
||||
{entries.map(([field, diff]) => (
|
||||
<li key={field}>
|
||||
<span className="audit-trail-field">{field}</span>{' '}
|
||||
<span className="audit-trail-from">"{String(diff.from ?? '')}"</span>
|
||||
{' → '}
|
||||
<span className="audit-trail-to">"{String(diff.to ?? '')}"</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entityId: string;
|
||||
}
|
||||
|
||||
export default function AuditTrail({ entityId }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
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 (
|
||||
<section className="content-section audit-trail-section">
|
||||
<button
|
||||
className="audit-trail-toggle"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="audit-trail-toggle-icon">{open ? '▾' : '▸'}</span>
|
||||
<span>Change History</span>
|
||||
{total > 0 && <span className="audit-trail-count">{total}</span>}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="audit-trail-body">
|
||||
{loading && <div className="audit-trail-loading">Loading…</div>}
|
||||
|
||||
{!loading && entries.length === 0 && (
|
||||
<div className="audit-trail-empty">No history recorded yet.</div>
|
||||
)}
|
||||
|
||||
{!loading && entries.length > 0 && (
|
||||
<>
|
||||
<ul className="audit-trail-list">
|
||||
{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 (
|
||||
<li key={e.id} className={`audit-trail-item${isFirst ? ' audit-trail-item-first' : ''}`}>
|
||||
<span className="audit-trail-dot" style={{ background: meta.color }} />
|
||||
<div className="audit-trail-content">
|
||||
<div className="audit-trail-header">
|
||||
<span className="audit-trail-action" style={{ color: meta.color }}>
|
||||
{meta.label}
|
||||
</span>
|
||||
<span className="audit-trail-user">by {e.username ?? 'unknown'}</span>
|
||||
<span className="audit-trail-time">{formatTs(e.created_at)}</span>
|
||||
</div>
|
||||
{e.changes && (
|
||||
<div className="audit-trail-changes">
|
||||
<ChangeSummary raw={e.changes} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{(total > LIMIT) && (
|
||||
<div className="audit-trail-pager">
|
||||
<button
|
||||
className="btn-ghost btn-sm"
|
||||
disabled={offset === 0}
|
||||
onClick={() => setOffset(o => Math.max(0, o - LIMIT))}
|
||||
>
|
||||
← Newer
|
||||
</button>
|
||||
<span className="audit-trail-pager-info">
|
||||
{offset + 1}–{Math.min(offset + LIMIT, total)} of {total}
|
||||
</span>
|
||||
<button
|
||||
className="btn-ghost btn-sm"
|
||||
disabled={offset + LIMIT >= total}
|
||||
onClick={() => setOffset(o => o + LIMIT)}
|
||||
>
|
||||
Older →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<EditorMode>('split');
|
||||
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved');
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -51,6 +53,14 @@ export default function MarkdownEditor({ value, onChange, onSave, autoSaveDelay
|
||||
{saveStatus === 'saving' ? '⟳ Saving…' : saveStatus === 'unsaved' ? '● Unsaved' : '✓ Saved'}
|
||||
</span>
|
||||
)}
|
||||
{lastEditedBy && (
|
||||
<span className="md-last-edited">
|
||||
✎ {lastEditedBy}
|
||||
{lastEditedAt && (
|
||||
<> · {new Date(lastEditedAt).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span className="md-help-hint">Markdown supported · GFM tables, checkboxes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -229,6 +229,13 @@ export default function Sidebar() {
|
||||
<Link to="/settings" className={`sidebar-footer-link ${isActive('/settings') ? 'active' : ''}`}>
|
||||
⚙ Settings
|
||||
</Link>
|
||||
<button
|
||||
className="sidebar-footer-link sidebar-logout-btn"
|
||||
onClick={() => { window.location.href = (import.meta.env.VITE_PORTAL_LOGOUT_URL as string | undefined) ?? '/logout'; }}
|
||||
title="Log out of NetworkView and the Enterprise Portal"
|
||||
>
|
||||
⏻ Logout
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
{port.last_edited_by && (
|
||||
<div className="port-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' })}</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="port-edit-btn" onClick={() => openPortModal(port)} title="Edit port">✎</button>
|
||||
<button className="port-delete" onClick={() => handleDeletePort(port.id)} title="Delete port">✕</button>
|
||||
@@ -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'}`}
|
||||
</span>
|
||||
{portModal.editPort?.last_edited_by && (
|
||||
<span className="modal-last-edited">
|
||||
✎ {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' })}</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<button className="modal-close" onClick={closePortModal}>✕</button>
|
||||
</div>
|
||||
<form onSubmit={handleSavePort} style={{ padding: '20px', display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
@@ -464,8 +486,16 @@ export default function ComponentPage() {
|
||||
{/* Notes */}
|
||||
<section className="content-section">
|
||||
<h2 className="section-title">Documentation</h2>
|
||||
<MarkdownEditor value={notes} onChange={setNotes} onSave={saveNotes} />
|
||||
<MarkdownEditor
|
||||
value={notes}
|
||||
onChange={setNotes}
|
||||
onSave={saveNotes}
|
||||
lastEditedBy={component.notes_last_edited_by}
|
||||
lastEditedAt={component.notes_updated_at}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<AuditTrail entityId={component.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<GNode, 'x' | 'y' | 'vx' | 'vy' | 'tx' | 'ty' | 'pinned'>[]; edges: GEdge[] }) => {
|
||||
const positions = computeTreeLayout(data.nodes, data.edges);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
<section className="content-section">
|
||||
<h2 className="section-title">Rack Notes</h2>
|
||||
<MarkdownEditor value={notes} onChange={setNotes} onSave={saveNotes} />
|
||||
<MarkdownEditor
|
||||
value={notes}
|
||||
onChange={setNotes}
|
||||
onSave={saveNotes}
|
||||
lastEditedBy={rack.notes_last_edited_by}
|
||||
lastEditedAt={rack.notes_updated_at}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<AuditTrail entityId={rack.id} />
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Graphical front panel */}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
<section className="content-section">
|
||||
<h2 className="section-title">Notes</h2>
|
||||
<MarkdownEditor value={notes} onChange={setNotes} onSave={saveNotes} />
|
||||
<MarkdownEditor
|
||||
value={notes}
|
||||
onChange={setNotes}
|
||||
onSave={saveNotes}
|
||||
lastEditedBy={room.notes_last_edited_by}
|
||||
lastEditedAt={room.notes_updated_at}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<AuditTrail entityId={room.id} />
|
||||
|
||||
{showAddRack && (
|
||||
<SimpleCreateModal
|
||||
title="Add Rack"
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as api from '../api';
|
||||
import type { Site, Room } from '../types';
|
||||
import MarkdownEditor from '../components/MarkdownEditor';
|
||||
import { SimpleCreateModal } from '../components/AddItemModal';
|
||||
import AuditTrail from '../components/AuditTrail';
|
||||
|
||||
export default function SitePage() {
|
||||
const { siteId } = useParams<{ siteId: string }>();
|
||||
@@ -124,9 +125,17 @@ export default function SitePage() {
|
||||
{/* Notes */}
|
||||
<section className="content-section">
|
||||
<h2 className="section-title">Notes</h2>
|
||||
<MarkdownEditor value={notes} onChange={setNotes} onSave={saveNotes} />
|
||||
<MarkdownEditor
|
||||
value={notes}
|
||||
onChange={setNotes}
|
||||
onSave={saveNotes}
|
||||
lastEditedBy={site.notes_last_edited_by}
|
||||
lastEditedAt={site.notes_updated_at}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<AuditTrail entityId={site.id} />
|
||||
|
||||
{showAddRoom && (
|
||||
<SimpleCreateModal
|
||||
title="Add Room"
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface Site {
|
||||
name: string;
|
||||
location?: string;
|
||||
notes: string;
|
||||
notes_last_edited_by?: string | null;
|
||||
notes_updated_at?: string | null;
|
||||
room_count?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -27,6 +29,8 @@ export interface Room {
|
||||
site_id: string;
|
||||
name: string;
|
||||
notes: string;
|
||||
notes_last_edited_by?: string | null;
|
||||
notes_updated_at?: string | null;
|
||||
rack_count?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -42,6 +46,8 @@ export interface Rack {
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
notes: string;
|
||||
notes_last_edited_by?: string | null;
|
||||
notes_updated_at?: string | null;
|
||||
component_count?: number;
|
||||
components?: Component[];
|
||||
room?: { id: string; name: string; site_id: string };
|
||||
@@ -65,6 +71,8 @@ export interface Component {
|
||||
mac_address?: string;
|
||||
status: ComponentStatus;
|
||||
notes: string;
|
||||
notes_last_edited_by?: string | null;
|
||||
notes_updated_at?: string | null;
|
||||
port_count?: number | null;
|
||||
sfp_count?: number | null;
|
||||
ports?: Port[];
|
||||
@@ -83,6 +91,8 @@ export interface Port {
|
||||
port_type: string;
|
||||
connected_to_port_id?: string | null;
|
||||
notes?: string;
|
||||
last_edited_by?: string | null;
|
||||
updated_at?: string;
|
||||
linked_port?: {
|
||||
id: string;
|
||||
port_number: number;
|
||||
|
||||
Reference in New Issue
Block a user