Files
enterprise_digital-platform/NetworkView/frontend/src/components/Sidebar.tsx
T
ske087 0aefadbfd8 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
2026-05-10 23:10:02 +03:00

243 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import * as api from '../api';
import type { Site, Room, Rack } from '../types';
interface TreeRoom extends Room {
racks: Rack[];
expanded: boolean;
}
interface TreeSite extends Site {
rooms: TreeRoom[];
expanded: boolean;
}
export default function Sidebar() {
const [tree, setTree] = useState<TreeSite[]>([]);
const [search, setSearch] = useState('');
const [searchResults, setSearchResults] = useState<Awaited<ReturnType<typeof api.search>> | null>(null);
const [loading, setLoading] = useState(true);
const [addSiteName, setAddSiteName] = useState('');
const [showAddSite, setShowAddSite] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const loadTree = useCallback(async () => {
const sites = await api.getSites();
setTree(sites.map(s => ({ ...s, rooms: [], expanded: false })));
setLoading(false);
}, []);
useEffect(() => { loadTree(); }, [loadTree]);
const expandSite = async (siteId: string) => {
setTree(prev => prev.map(s => {
if (s.id !== siteId) return s;
if (s.expanded) return { ...s, expanded: false };
return { ...s, expanded: true };
}));
// Load rooms if not loaded
setTree(prev => {
const site = prev.find(s => s.id === siteId);
if (site && site.rooms.length === 0) {
api.getSite(siteId).then(full => {
setTree(p => p.map(s => s.id === siteId
? { ...s, rooms: (full.rooms ?? []).map(r => ({ ...r, racks: [], expanded: false })) }
: s
));
});
}
return prev;
});
};
const expandRoom = async (siteId: string, roomId: string) => {
setTree(prev => prev.map(s => s.id !== siteId ? s : {
...s,
rooms: s.rooms.map(r => {
if (r.id !== roomId) return r;
if (r.expanded) return { ...r, expanded: false };
// Load racks
if (r.racks.length === 0) {
api.getRacks(roomId).then(racks => {
setTree(p => p.map(ss => ss.id !== siteId ? ss : {
...ss,
rooms: ss.rooms.map(rr => rr.id === roomId ? { ...rr, racks } : rr),
}));
});
}
return { ...r, expanded: true };
}),
}));
};
const handleAddSite = async (e: React.FormEvent) => {
e.preventDefault();
if (!addSiteName.trim()) return;
const site = await api.createSite({ name: addSiteName.trim() });
setAddSiteName('');
setShowAddSite(false);
await loadTree();
navigate(`/sites/${site.id}`);
};
useEffect(() => {
if (!search.trim()) { setSearchResults(null); return; }
const t = setTimeout(async () => {
const r = await api.search(search);
setSearchResults(r);
}, 300);
return () => clearTimeout(t);
}, [search]);
const isActive = (path: string) => location.pathname === path;
return (
<aside className="sidebar">
<div className="sidebar-header">
<Link to="/" className="sidebar-logo">
<span className="logo-icon"></span>
<span className="logo-text">NetworkView</span>
</Link>
</div>
<div className="sidebar-search">
<input
type="text"
placeholder="Search..."
value={search}
onChange={e => setSearch(e.target.value)}
className="search-input"
/>
{searchResults && (
<div className="search-results">
{searchResults.sites.map(s => (
<Link key={s.id} to={`/sites/${s.id}`} className="search-result-item" onClick={() => setSearch('')}>
<span className="search-result-icon">🏢</span> {s.name}
</Link>
))}
{searchResults.rooms.map(r => (
<Link key={r.id} to={`/rooms/${r.id}`} className="search-result-item" onClick={() => setSearch('')}>
<span className="search-result-icon">🚪</span> {r.name}
</Link>
))}
{searchResults.racks.map(r => (
<Link key={r.id} to={`/racks/${r.id}`} className="search-result-item" onClick={() => setSearch('')}>
<span className="search-result-icon">🗄</span> {r.name}
</Link>
))}
{searchResults.components.map(c => (
<Link key={c.id} to={`/components/${c.id}`} className="search-result-item" onClick={() => setSearch('')}>
<span className="search-result-icon">🔧</span> {c.name}
{c.ip_address && <span className="search-result-sub"> {c.ip_address}</span>}
</Link>
))}
{!searchResults.sites.length && !searchResults.rooms.length &&
!searchResults.racks.length && !searchResults.components.length && (
<div className="search-no-results">No results found</div>
)}
</div>
)}
</div>
<nav className="sidebar-nav">
<div className="sidebar-section-header">
<span>Sites</span>
<button className="icon-btn" title="Add site" onClick={() => setShowAddSite(v => !v)}>+</button>
</div>
{showAddSite && (
<form onSubmit={handleAddSite} className="inline-add-form">
<input
autoFocus
type="text"
placeholder="Site name..."
value={addSiteName}
onChange={e => setAddSiteName(e.target.value)}
className="inline-input"
/>
<button type="submit" className="inline-btn">Add</button>
<button type="button" className="inline-btn secondary" onClick={() => setShowAddSite(false)}></button>
</form>
)}
{loading && <div className="sidebar-loading">Loading...</div>}
{tree.map(site => (
<div key={site.id} className="tree-node">
<div className={`tree-item tree-site ${isActive(`/sites/${site.id}`) ? 'active' : ''}`}>
<button className="tree-expand" onClick={() => expandSite(site.id)}>
{site.expanded ? '▾' : '▸'}
</button>
<Link to={`/sites/${site.id}`} className="tree-label">
<span className="tree-icon">🏢</span>
<span className="tree-name">{site.name}</span>
{site.room_count != null && (
<span className="tree-count">{site.room_count}</span>
)}
</Link>
</div>
{site.expanded && (
<div className="tree-children">
{site.rooms.map(room => (
<div key={room.id} className="tree-node">
<div className={`tree-item tree-room ${isActive(`/rooms/${room.id}`) ? 'active' : ''}`}>
<button className="tree-expand" onClick={() => expandRoom(site.id, room.id)}>
{room.expanded ? '▾' : '▸'}
</button>
<Link to={`/rooms/${room.id}`} className="tree-label">
<span className="tree-icon">🚪</span>
<span className="tree-name">{room.name}</span>
</Link>
</div>
{room.expanded && (
<div className="tree-children">
{room.racks.map(rack => (
<div key={rack.id} className={`tree-item tree-rack ${isActive(`/racks/${rack.id}`) ? 'active' : ''}`}>
<Link to={`/racks/${rack.id}`} className="tree-label">
<span className="tree-icon">🗄</span>
<span className="tree-name">{rack.name}</span>
<span className="tree-count">{rack.total_units}U</span>
</Link>
</div>
))}
</div>
)}
</div>
))}
{site.rooms.length === 0 && (
<div className="tree-empty">No rooms yet</div>
)}
</div>
)}
</div>
))}
{!loading && tree.length === 0 && (
<div className="sidebar-empty">
<p>No sites yet.</p>
<button className="btn-primary btn-sm" onClick={() => setShowAddSite(true)}>
+ Add your first site
</button>
</div>
)}
</nav>
<div className="sidebar-footer">
<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>
);
}