Added Scaffold of Global Search Function

This commit is contained in:
2025-09-24 15:35:44 -06:00
parent 7d7f9c384c
commit 811ad92a6c
4 changed files with 368 additions and 27 deletions

View File

@@ -11,14 +11,17 @@ import NavigationSidebar from "./Navigation_Sidebar";
// Styling Imports
import {
AppBar, Toolbar, Typography, Box, Menu, MenuItem, Button,
CssBaseline, ThemeProvider, createTheme, Breadcrumbs
} from "@mui/material";
import {
KeyboardArrowDown as KeyboardArrowDownIcon,
Logout as LogoutIcon,
NavigateNext as NavigateNextIcon
} from "@mui/icons-material";
AppBar, Toolbar, Typography, Box, Menu, MenuItem, Button,
CssBaseline, ThemeProvider, createTheme, Breadcrumbs
} from "@mui/material";
import {
KeyboardArrowDown as KeyboardArrowDownIcon,
Logout as LogoutIcon,
NavigateNext as NavigateNextIcon
} from "@mui/icons-material";
import SearchIcon from "@mui/icons-material/Search";
import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
import ArrowDropUpIcon from "@mui/icons-material/ArrowDropUp";
// Workflow Editor Imports
import FlowTabs from "./Flow_Editor/Flow_Tabs";
@@ -83,7 +86,7 @@ const darkTheme = createTheme({
const LOCAL_STORAGE_KEY = "borealis_persistent_state";
export default function App() {
export default function App() {
const [tabs, setTabs] = useState([{ id: "flow_1", tab_name: "Flow 1", nodes: [], edges: [] }]);
const [activeTabId, setActiveTabId] = useState("flow_1");
const [currentPage, setCurrentPage] = useState("devices");
@@ -102,7 +105,26 @@ export default function App() {
const [userDisplayName, setUserDisplayName] = useState(null);
const [editingJob, setEditingJob] = useState(null);
const [jobsRefreshToken, setJobsRefreshToken] = useState(0);
const [notAuthorizedOpen, setNotAuthorizedOpen] = useState(false);
const [notAuthorizedOpen, setNotAuthorizedOpen] = useState(false);
// Top-bar search state
const SEARCH_CATEGORIES = [
{ key: "hostname", label: "Hostname", scope: "device", placeholder: "Search Hostname" },
{ key: "internal_ip", label: "Internal IP", scope: "device", placeholder: "Search Internal IP" },
{ key: "external_ip", label: "External IP", scope: "device", placeholder: "Search External IP" },
{ key: "description", label: "Description", scope: "device", placeholder: "Search Description" },
{ key: "last_user", label: "Last User", scope: "device", placeholder: "Search Last User" },
{ key: "serial_number", label: "Serial Number (Soon)", scope: "device", placeholder: "Search Serial Number" },
{ key: "site_name", label: "Site Name", scope: "site", placeholder: "Search Site Name" },
{ key: "site_description", label: "Site Description", scope: "site", placeholder: "Search Site Description" },
];
const [searchCategory, setSearchCategory] = useState("hostname");
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searchMenuEl, setSearchMenuEl] = useState(null);
const [suggestions, setSuggestions] = useState({ devices: [], sites: [], q: "", field: "" });
const searchAnchorRef = useRef(null);
const searchDebounceRef = useRef(null);
// Build breadcrumb items for current view
const breadcrumbs = React.useMemo(() => {
@@ -170,8 +192,8 @@ export default function App() {
return items;
}, [currentPage, selectedDevice, editingJob]);
useEffect(() => {
const session = localStorage.getItem("borealis_session");
useEffect(() => {
const session = localStorage.getItem("borealis_session");
if (session) {
try {
const data = JSON.parse(session);
@@ -201,7 +223,55 @@ export default function App() {
}
} catch {}
})();
}, []);
}, []);
// Suggest fetcher with debounce
const fetchSuggestions = useCallback((field, q) => {
const params = new URLSearchParams({ field, q, limit: "5" });
fetch(`/api/search/suggest?${params.toString()}`)
.then((r) => (r.ok ? r.json() : { devices: [], sites: [] }))
.then((data) => setSuggestions(data))
.catch(() => setSuggestions({ devices: [], sites: [], q, field }));
}, []);
useEffect(() => {
if (!searchOpen) return;
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
searchDebounceRef.current = setTimeout(() => {
fetchSuggestions(searchCategory, searchQuery);
}, 220);
return () => { if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); };
}, [searchOpen, searchCategory, searchQuery, fetchSuggestions]);
const execSearch = useCallback((field, q, navigateImmediate = true) => {
const cat = SEARCH_CATEGORIES.find((c) => c.key === field) || SEARCH_CATEGORIES[0];
if (cat.scope === "site") {
try {
localStorage.setItem('site_list_initial_filters', JSON.stringify(
field === 'site_name' ? { name: q } : { description: q }
));
} catch {}
if (navigateImmediate) setCurrentPage("sites");
} else {
// device field
// Map API field -> Device_List filter key
const fieldMap = {
hostname: 'hostname',
description: 'description',
last_user: 'lastUser',
internal_ip: 'internalIp',
external_ip: 'externalIp',
serial_number: 'serialNumber', // placeholder (ignored by Device_List for now)
};
const k = fieldMap[field] || 'hostname';
try {
const payload = (k === 'serialNumber') ? {} : { [k]: q };
localStorage.setItem('device_list_initial_filters', JSON.stringify(payload));
} catch {}
if (navigateImmediate) setCurrentPage("devices");
}
setSearchOpen(false);
}, [SEARCH_CATEGORIES, setCurrentPage]);
const handleLoginSuccess = ({ username, role }) => {
setUser(username);
@@ -606,15 +676,14 @@ export default function App() {
<ThemeProvider theme={darkTheme}>
<CssBaseline />
<Box sx={{ width: "100vw", height: "100vh", display: "flex", flexDirection: "column", overflow: "hidden" }}>
<AppBar position="static" sx={{ bgcolor: "#16191d" }}>
<Toolbar sx={{ minHeight: "36px", position: 'relative' }}>
<Box component="img" src="/Borealis_Logo_Full.png" alt="Borealis Logo" sx={{ height: "52px", marginRight: "8px" }} />
<AppBar position="static" sx={{ bgcolor: "#16191d" }}>
<Toolbar sx={{ minHeight: "36px", position: 'relative' }}>
<Box component="img" src="/Borealis_Logo_Full.png" alt="Borealis Logo" sx={{ height: "52px", marginRight: "8px" }} />
{/* Breadcrumbs inline in top bar (transparent), aligned to content area */}
<Box
sx={{
position: 'absolute',
left: 'calc(260px - 2px)', // fine-tuned to align with black content edge
right: 160, // keep clear of About
left: 'calc(260px + 550px)', // fine-tuned to align with black content edge
bottom: 6,
display: 'flex',
alignItems: 'flex-end',
@@ -657,14 +726,117 @@ export default function App() {
);
})}
</Breadcrumbs>
</Box>
{/* Spacer to keep user menu aligned right */}
<Box sx={{ flexGrow: 1 }} />
<Button
color="inherit"
onClick={handleUserMenuOpen}
endIcon={<KeyboardArrowDownIcon />}
sx={{ height: "36px" }}
</Box>
{/* Top search: category + input */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, ml: 2 }}>
<Button
variant="outlined"
size="small"
onClick={(e) => setSearchMenuEl(e.currentTarget)}
endIcon={searchMenuEl ? <ArrowDropUpIcon /> : <ArrowDropDownIcon />}
sx={{
height: 32,
color: '#ddd',
left: -11,
bottom: -6,
borderColor: '#3a3f44',
textTransform: 'none',
bgcolor: '#1e2328',
'&:hover': { borderColor: '#4b5158', bgcolor: '#22272e' },
minWidth: 160,
justifyContent: 'space-between'
}}
>
{(SEARCH_CATEGORIES.find(c => c.key === searchCategory) || {}).label || 'Hostname'}
</Button>
<Menu
anchorEl={searchMenuEl}
open={Boolean(searchMenuEl)}
onClose={() => setSearchMenuEl(null)}
PaperProps={{ sx: { bgcolor: '#1e1e1e', color: '#fff', minWidth: 240 } }}
>
{SEARCH_CATEGORIES.map((c) => (
<MenuItem key={c.key} onClick={() => { setSearchCategory(c.key); setSearchMenuEl(null); setSearchQuery(''); setSuggestions({ devices: [], sites: [], q: '', field: '' }); }}>
{c.label}
</MenuItem>
))}
</Menu>
<Box
ref={searchAnchorRef}
sx={{ position: 'relative', left: -2, bottom: -6, display: 'flex', alignItems: 'center', border: '1px solid #3a3f44', borderRadius: 1, height: 32, minWidth: 320, bgcolor: '#1e2328' }}
>
<input
value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setSearchOpen(true); }}
onFocus={() => setSearchOpen(true)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
execSearch(searchCategory, searchQuery);
} else if (e.key === 'Escape') {
setSearchOpen(false);
}
}}
placeholder={(SEARCH_CATEGORIES.find(c => c.key === searchCategory) || {}).placeholder || 'Search'}
style={{
outline: 'none', border: 'none', background: 'transparent', color: '#e8eaed', paddingLeft: 10, paddingRight: 28, width: 360, height: '100%'
}}
/>
<SearchIcon sx={{ position: 'absolute', right: 6, color: '#8aa0b4', fontSize: 18 }} />
{searchOpen && (
<Box
sx={{ position: 'absolute', top: '100%', left: 0, right: 0, bgcolor: '#121417', border: '1px solid #2b2f34', borderTop: 'none', zIndex: 1400, borderRadius: '0 0 6px 6px', maxHeight: 320, overflowY: 'auto' }}
>
{/* Devices group */}
{((suggestions.devices || []).length > 0 || (SEARCH_CATEGORIES.find(c=>c.key===searchCategory)?.scope==='device')) && (
<Box sx={{ borderBottom: '1px solid #2b2f34' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', px: 1.2, py: 0.8, color: '#9aa0a6', fontSize: 12 }}>
<span>Devices</span>
<Button size="small" onClick={() => execSearch(searchCategory, searchQuery)} sx={{ textTransform: 'none', color: '#80bfff' }}>View Results</Button>
</Box>
{suggestions.devices && suggestions.devices.length > 0 ? (
suggestions.devices.map((d, idx) => (
<Box key={idx} onClick={() => execSearch(searchCategory, d.value)} sx={{ px: 1.2, py: 0.6, '&:hover': { bgcolor: '#1c2127' }, cursor: 'pointer' }}>
<Typography variant="body2" sx={{ color: '#e8eaed' }}>{d.hostname || d.value}</Typography>
<Typography variant="caption" sx={{ color: '#9aa0a6' }}>{[d.site_name, d.internal_ip || d.external_ip || d.description || d.last_user].filter(Boolean).join(' • ')}</Typography>
</Box>
))
) : (
<Box sx={{ px: 1.2, py: 1, color: '#6b737c', fontSize: 12 }}>
{searchCategory === 'serial_number' ? 'Serial numbers are not tracked yet.' : 'No matches'}
</Box>
)}
</Box>
)}
{/* Sites group */}
{((suggestions.sites || []).length > 0 || (SEARCH_CATEGORIES.find(c=>c.key===searchCategory)?.scope==='site')) && (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', px: 1.2, py: 0.8, color: '#9aa0a6', fontSize: 12 }}>
<span>Sites</span>
<Button size="small" onClick={() => execSearch(searchCategory, searchQuery)} sx={{ textTransform: 'none', color: '#80bfff' }}>View Results</Button>
</Box>
{suggestions.sites && suggestions.sites.length > 0 ? (
suggestions.sites.map((s, idx) => (
<Box key={idx} onClick={() => execSearch(searchCategory, s.value)} sx={{ px: 1.2, py: 0.6, '&:hover': { bgcolor: '#1c2127' }, cursor: 'pointer' }}>
<Typography variant="body2" sx={{ color: '#e8eaed' }}>{s.site_name}</Typography>
<Typography variant="caption" sx={{ color: '#9aa0a6' }}>{s.site_description || ''}</Typography>
</Box>
))
) : (
<Box sx={{ px: 1.2, py: 1, color: '#6b737c', fontSize: 12 }}>No matches</Box>
)}
</Box>
)}
</Box>
)}
</Box>
</Box>
{/* Spacer to keep user menu aligned right */}
<Box sx={{ flexGrow: 1 }} />
<Button
color="inherit"
onClick={handleUserMenuOpen}
endIcon={<KeyboardArrowDownIcon />}
sx={{ height: "36px" }}
>
{userDisplayName || user || 'User'}
</Button>

View File

@@ -263,6 +263,20 @@ export default function DeviceList({ onSelectDevice }) {
// Apply initial site filter from Sites page
useEffect(() => {
try {
// General initial filters (set by global search)
const json = localStorage.getItem('device_list_initial_filters');
if (json) {
const obj = JSON.parse(json);
if (obj && typeof obj === 'object') {
setFilters((prev) => ({ ...prev, ...obj }));
// Optionally ensure Site column exists when site filter is present
if (obj.site) {
setColumns((prev) => (prev.some((c) => c.id === 'site') ? prev : [{ id: 'status', label: COL_LABELS.status }, { id: 'site', label: COL_LABELS.site }, ...prev.filter((c) => c.id !== 'status') ]));
}
}
localStorage.removeItem('device_list_initial_filters');
}
const site = localStorage.getItem('device_list_initial_site_filter');
if (site && site.trim()) {
setColumns((prev) => {

View File

@@ -64,6 +64,18 @@ export default function SiteList({ onOpenDevicesForSite }) {
useEffect(() => { fetchSites(); }, [fetchSites]);
// Apply initial filters from global search
useEffect(() => {
try {
const json = localStorage.getItem('site_list_initial_filters');
if (json) {
const obj = JSON.parse(json);
if (obj && typeof obj === 'object') setFilters((prev) => ({ ...prev, ...obj }));
localStorage.removeItem('site_list_initial_filters');
}
} catch {}
}, []);
const handleSort = (col) => {
if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc");
else { setOrderBy(col); setOrder("asc"); }
@@ -320,4 +332,3 @@ export default function SiteList({ onOpenDevicesForSite }) {
</Paper>
);
}