mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-27 11:21:57 -06:00
Added Scaffold of Global Search Function
This commit is contained in:
@@ -19,6 +19,9 @@ import {
|
|||||||
Logout as LogoutIcon,
|
Logout as LogoutIcon,
|
||||||
NavigateNext as NavigateNextIcon
|
NavigateNext as NavigateNextIcon
|
||||||
} from "@mui/icons-material";
|
} 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
|
// Workflow Editor Imports
|
||||||
import FlowTabs from "./Flow_Editor/Flow_Tabs";
|
import FlowTabs from "./Flow_Editor/Flow_Tabs";
|
||||||
@@ -104,6 +107,25 @@ export default function App() {
|
|||||||
const [jobsRefreshToken, setJobsRefreshToken] = useState(0);
|
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
|
// Build breadcrumb items for current view
|
||||||
const breadcrumbs = React.useMemo(() => {
|
const breadcrumbs = React.useMemo(() => {
|
||||||
const items = [];
|
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 }) => {
|
const handleLoginSuccess = ({ username, role }) => {
|
||||||
setUser(username);
|
setUser(username);
|
||||||
setUserRole(role || null);
|
setUserRole(role || null);
|
||||||
@@ -613,8 +683,7 @@ export default function App() {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: 'calc(260px - 2px)', // fine-tuned to align with black content edge
|
left: 'calc(260px + 550px)', // fine-tuned to align with black content edge
|
||||||
right: 160, // keep clear of About
|
|
||||||
bottom: 6,
|
bottom: 6,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'flex-end',
|
alignItems: 'flex-end',
|
||||||
@@ -658,6 +727,109 @@ export default function App() {
|
|||||||
})}
|
})}
|
||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
</Box>
|
</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 */}
|
{/* Spacer to keep user menu aligned right */}
|
||||||
<Box sx={{ flexGrow: 1 }} />
|
<Box sx={{ flexGrow: 1 }} />
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -263,6 +263,20 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
// Apply initial site filter from Sites page
|
// Apply initial site filter from Sites page
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
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');
|
const site = localStorage.getItem('device_list_initial_site_filter');
|
||||||
if (site && site.trim()) {
|
if (site && site.trim()) {
|
||||||
setColumns((prev) => {
|
setColumns((prev) => {
|
||||||
|
|||||||
@@ -64,6 +64,18 @@ export default function SiteList({ onOpenDevicesForSite }) {
|
|||||||
|
|
||||||
useEffect(() => { fetchSites(); }, [fetchSites]);
|
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) => {
|
const handleSort = (col) => {
|
||||||
if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc");
|
if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc");
|
||||||
else { setOrderBy(col); setOrder("asc"); }
|
else { setOrderBy(col); setOrder("asc"); }
|
||||||
@@ -320,4 +332,3 @@ export default function SiteList({ onOpenDevicesForSite }) {
|
|||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1387,6 +1387,150 @@ def assign_devices_to_site():
|
|||||||
return jsonify({"error": str(e)}), 500
|
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
|
# Device List Views API
|
||||||
# ---------------------------------------------
|
# ---------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user