mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-27 05:21:57 -06:00
Added Scaffold of Global Search Function
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user