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

@@ -19,6 +19,9 @@ import {
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";
@@ -104,6 +107,25 @@ export default function App() {
const [jobsRefreshToken, setJobsRefreshToken] = useState(0);
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(() => {
const items = [];
@@ -203,6 +225,54 @@ export default function App() {
})();
}, []);
// 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);
setUserRole(role || null);
@@ -613,8 +683,7 @@ export default function App() {
<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',
@@ -658,6 +727,109 @@ export default function App() {
})}
</Breadcrumbs>
</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

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>
);
}

View File

@@ -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
# ---------------------------------------------