Initial commit: enterprise digital platform with portal SSO, DigiServer, IT Assets, NetworkView, Server Monitor
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
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>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user