diff --git a/Data/Server/WebUI/src/App.jsx b/Data/Server/WebUI/src/App.jsx index 7bbb067..aa56b85 100644 --- a/Data/Server/WebUI/src/App.jsx +++ b/Data/Server/WebUI/src/App.jsx @@ -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() { - - - + + + {/* Breadcrumbs inline in top bar (transparent), aligned to content area */} - - {/* Spacer to keep user menu aligned right */} - - + setSearchMenuEl(null)} + PaperProps={{ sx: { bgcolor: '#1e1e1e', color: '#fff', minWidth: 240 } }} + > + {SEARCH_CATEGORIES.map((c) => ( + { setSearchCategory(c.key); setSearchMenuEl(null); setSearchQuery(''); setSuggestions({ devices: [], sites: [], q: '', field: '' }); }}> + {c.label} + + ))} + + + { 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%' + }} + /> + + {searchOpen && ( + + {/* Devices group */} + {((suggestions.devices || []).length > 0 || (SEARCH_CATEGORIES.find(c=>c.key===searchCategory)?.scope==='device')) && ( + + + Devices + + + {suggestions.devices && suggestions.devices.length > 0 ? ( + suggestions.devices.map((d, idx) => ( + execSearch(searchCategory, d.value)} sx={{ px: 1.2, py: 0.6, '&:hover': { bgcolor: '#1c2127' }, cursor: 'pointer' }}> + {d.hostname || d.value} + {[d.site_name, d.internal_ip || d.external_ip || d.description || d.last_user].filter(Boolean).join(' • ')} + + )) + ) : ( + + {searchCategory === 'serial_number' ? 'Serial numbers are not tracked yet.' : 'No matches'} + + )} + + )} + {/* Sites group */} + {((suggestions.sites || []).length > 0 || (SEARCH_CATEGORIES.find(c=>c.key===searchCategory)?.scope==='site')) && ( + + + Sites + + + {suggestions.sites && suggestions.sites.length > 0 ? ( + suggestions.sites.map((s, idx) => ( + execSearch(searchCategory, s.value)} sx={{ px: 1.2, py: 0.6, '&:hover': { bgcolor: '#1c2127' }, cursor: 'pointer' }}> + {s.site_name} + {s.site_description || ''} + + )) + ) : ( + No matches + )} + + )} + + )} + + + {/* Spacer to keep user menu aligned right */} + + diff --git a/Data/Server/WebUI/src/Devices/Device_List.jsx b/Data/Server/WebUI/src/Devices/Device_List.jsx index 0117e26..c43fadd 100644 --- a/Data/Server/WebUI/src/Devices/Device_List.jsx +++ b/Data/Server/WebUI/src/Devices/Device_List.jsx @@ -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) => { diff --git a/Data/Server/WebUI/src/Sites/Site_List.jsx b/Data/Server/WebUI/src/Sites/Site_List.jsx index fdf23cb..21c5990 100644 --- a/Data/Server/WebUI/src/Sites/Site_List.jsx +++ b/Data/Server/WebUI/src/Sites/Site_List.jsx @@ -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 }) { ); } - diff --git a/Data/Server/server.py b/Data/Server/server.py index d132ffc..cd80caf 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -1387,6 +1387,150 @@ def assign_devices_to_site(): return jsonify({"error": str(e)}), 500 +# --------------------------------------------- +# Global Search (suggestions) +# --------------------------------------------- + +def _load_device_records(limit: int = 0): + """ + Load device records from SQLite and flatten commonly-searched fields + from the JSON details column. Returns a list of dicts with keys: + hostname, description, last_user, internal_ip, external_ip, site_id, site_name + """ + try: + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + cur.execute("SELECT hostname, description, details FROM device_details") + rows = cur.fetchall() + + # Build device -> site mapping + cur.execute( + """ + SELECT ds.device_hostname, s.id, s.name + FROM device_sites ds + JOIN sites s ON s.id = ds.site_id + """ + ) + site_map = {r[0]: {"site_id": r[1], "site_name": r[2]} for r in cur.fetchall()} + + conn.close() + except Exception: + rows = [] + site_map = {} + + out = [] + for hostname, description, details_json in rows: + d = {} + try: + d = json.loads(details_json or "{}") + except Exception: + d = {} + summary = d.get("summary") or {} + rec = { + "hostname": hostname or summary.get("hostname") or "", + "description": (description or summary.get("description") or ""), + "last_user": summary.get("last_user") or summary.get("last_user_name") or "", + "internal_ip": summary.get("internal_ip") or "", + "external_ip": summary.get("external_ip") or "", + } + site_info = site_map.get(rec["hostname"]) or {} + rec.update({ + "site_id": site_info.get("site_id"), + "site_name": site_info.get("site_name") or "", + }) + out.append(rec) + if limit and len(out) >= limit: + break + return out + + +@app.route("/api/search/suggest", methods=["GET"]) +def search_suggest(): + """ + Suggest results for the top-bar search with category selector. + Query parameters: + field: one of hostname|description|last_user|internal_ip|external_ip|serial_number|site_name|site_description + q: text fragment (case-insensitive contains) + limit: max results per group (default 5) + Returns: { devices: [...], sites: [...], field: "...", q: "..." } + """ + field = (request.args.get("field") or "hostname").strip().lower() + q = (request.args.get("q") or "").strip() + try: + limit = int(request.args.get("limit") or 5) + except Exception: + limit = 5 + + q_lc = q.lower() + + device_fields = { + "hostname": "hostname", + "description": "description", + "last_user": "last_user", + "internal_ip": "internal_ip", + "external_ip": "external_ip", + "serial_number": "serial_number", # placeholder, currently not stored + } + site_fields = { + "site_name": "name", + "site_description": "description", + } + + devices = [] + sites = [] + + # Device suggestions + if field in device_fields: + key = device_fields[field] + for rec in _load_device_records(): + # serial_number is not currently tracked; produce no suggestions + if key == "serial_number": + break + val = str(rec.get(key) or "") + if not q or q_lc in val.lower(): + devices.append({ + "hostname": rec.get("hostname") or "", + "value": val, + "site_id": rec.get("site_id"), + "site_name": rec.get("site_name") or "", + "description": rec.get("description") or "", + "last_user": rec.get("last_user") or "", + "internal_ip": rec.get("internal_ip") or "", + "external_ip": rec.get("external_ip") or "", + }) + if len(devices) >= limit: + break + + # Site suggestions + if field in site_fields: + column = site_fields[field] + try: + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + cur.execute("SELECT id, name, description FROM sites") + for sid, name, desc in cur.fetchall(): + val = name if column == "name" else (desc or "") + if not q or q_lc in str(val).lower(): + sites.append({ + "site_id": sid, + "site_name": name, + "site_description": desc or "", + "value": val or "", + }) + if len(sites) >= limit: + break + conn.close() + except Exception: + pass + + return jsonify({ + "field": field, + "q": q, + "devices": devices, + "sites": sites, + }) + + # --------------------------------------------- # Device List Views API # ---------------------------------------------