0aefadbfd8
- 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
243 lines
9.0 KiB
TypeScript
243 lines
9.0 KiB
TypeScript
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>
|
||
);
|
||
}
|