mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-16 02:05:48 -07:00
Fixed OS Overwrites in Agent Hearbeats (Legacy Inventory Code removed as well)
This commit is contained in:
@@ -1977,9 +1977,6 @@ def detect_agent_os():
|
|||||||
plat = platform.system().lower()
|
plat = platform.system().lower()
|
||||||
|
|
||||||
if plat.startswith('win'):
|
if plat.startswith('win'):
|
||||||
# Aim for: "Microsoft Windows 11 Pro 24H2 Build 26100.5074"
|
|
||||||
# Pull details from the registry when available and correct
|
|
||||||
# historical quirks like CurrentVersion reporting 6.3.
|
|
||||||
try:
|
try:
|
||||||
import winreg # Only available on Windows
|
import winreg # Only available on Windows
|
||||||
|
|
||||||
@@ -2002,6 +1999,7 @@ def detect_agent_os():
|
|||||||
return default
|
return default
|
||||||
|
|
||||||
product_name = _get("ProductName", "") # e.g., "Windows 11 Pro"
|
product_name = _get("ProductName", "") # e.g., "Windows 11 Pro"
|
||||||
|
installation_type = _get("InstallationType", "") # e.g., "Server"
|
||||||
edition_id = _get("EditionID", "") # e.g., "Professional"
|
edition_id = _get("EditionID", "") # e.g., "Professional"
|
||||||
display_version = _get("DisplayVersion", "") # e.g., "24H2" / "22H2"
|
display_version = _get("DisplayVersion", "") # e.g., "24H2" / "22H2"
|
||||||
release_id = _get("ReleaseId", "") # e.g., "2004" on older Windows 10
|
release_id = _get("ReleaseId", "") # e.g., "2004" on older Windows 10
|
||||||
@@ -2014,19 +2012,113 @@ def detect_agent_os():
|
|||||||
build_int = int(str(build_number).split(".")[0]) if build_number else 0
|
build_int = int(str(build_number).split(".")[0]) if build_number else 0
|
||||||
except Exception:
|
except Exception:
|
||||||
build_int = 0
|
build_int = 0
|
||||||
if build_int >= 22000:
|
|
||||||
major_label = "11"
|
wmi_info = {}
|
||||||
elif build_int >= 10240:
|
try:
|
||||||
major_label = "10"
|
cmd = "Get-CimInstance Win32_OperatingSystem | Select-Object Caption,ProductType,BuildNumber | ConvertTo-Json -Compress"
|
||||||
|
out = subprocess.run(
|
||||||
|
["powershell", "-NoProfile", "-Command", cmd],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
raw = (out.stdout or "").strip()
|
||||||
|
if raw:
|
||||||
|
data = json.loads(raw)
|
||||||
|
if isinstance(data, list):
|
||||||
|
data = data[0] if data else {}
|
||||||
|
if isinstance(data, dict):
|
||||||
|
wmi_info = data
|
||||||
|
except Exception:
|
||||||
|
wmi_info = {}
|
||||||
|
|
||||||
|
wmi_caption = ""
|
||||||
|
caption_val = wmi_info.get("Caption")
|
||||||
|
if isinstance(caption_val, str):
|
||||||
|
wmi_caption = caption_val.strip()
|
||||||
|
if wmi_caption.lower().startswith("microsoft "):
|
||||||
|
wmi_caption = wmi_caption[10:].strip()
|
||||||
|
|
||||||
|
def _parse_int(value) -> int:
|
||||||
|
try:
|
||||||
|
return int(str(value).split(".")[0])
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not build_int:
|
||||||
|
for candidate in (wmi_info.get("BuildNumber"), None):
|
||||||
|
if candidate:
|
||||||
|
parsed = _parse_int(candidate)
|
||||||
|
if parsed:
|
||||||
|
build_int = parsed
|
||||||
|
break
|
||||||
|
if not build_int:
|
||||||
|
try:
|
||||||
|
build_int = _parse_int(sys.getwindowsversion().build) # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
build_int = 0
|
||||||
|
|
||||||
|
product_type_val = wmi_info.get("ProductType")
|
||||||
|
if isinstance(product_type_val, str):
|
||||||
|
try:
|
||||||
|
product_type_val = int(product_type_val.strip())
|
||||||
|
except Exception:
|
||||||
|
product_type_val = None
|
||||||
|
if not isinstance(product_type_val, int):
|
||||||
|
try:
|
||||||
|
product_type_val = getattr(sys.getwindowsversion(), 'product_type', None) # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
product_type_val = None
|
||||||
|
if not isinstance(product_type_val, int):
|
||||||
|
product_type_val = 0
|
||||||
|
|
||||||
|
def _contains_server(text) -> bool:
|
||||||
|
try:
|
||||||
|
return isinstance(text, str) and 'server' in text.lower()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
server_hints = []
|
||||||
|
if isinstance(product_type_val, int) and product_type_val not in (0, 1):
|
||||||
|
server_hints.append("product_type")
|
||||||
|
if isinstance(product_type_val, int) and product_type_val == 1 and _contains_server(product_name):
|
||||||
|
server_hints.append("product_type_mismatch")
|
||||||
|
for hint in (product_name, wmi_caption, edition_id, installation_type):
|
||||||
|
if _contains_server(hint):
|
||||||
|
server_hints.append("string_hint")
|
||||||
|
break
|
||||||
|
if installation_type and str(installation_type).strip().lower() == 'server':
|
||||||
|
server_hints.append("installation_type")
|
||||||
|
if isinstance(edition_id, str) and edition_id.lower().startswith('server'):
|
||||||
|
server_hints.append("edition_id")
|
||||||
|
if build_int in (20348, 26100, 17763) and _contains_server(product_name or wmi_caption or edition_id or installation_type):
|
||||||
|
server_hints.append("build_hint")
|
||||||
|
|
||||||
|
is_server = bool(server_hints)
|
||||||
|
|
||||||
|
if is_server:
|
||||||
|
if build_int >= 26100:
|
||||||
|
family = "Windows Server 2025"
|
||||||
|
elif build_int >= 20348:
|
||||||
|
family = "Windows Server 2022"
|
||||||
|
elif build_int >= 17763:
|
||||||
|
family = "Windows Server 2019"
|
||||||
else:
|
else:
|
||||||
major_label = platform.release()
|
family = "Windows Server"
|
||||||
|
else:
|
||||||
|
if build_int >= 22000:
|
||||||
|
family = "Windows 11"
|
||||||
|
elif build_int >= 10240:
|
||||||
|
family = "Windows 10"
|
||||||
|
else:
|
||||||
|
family = platform.release() or "Windows"
|
||||||
|
|
||||||
# Derive friendly edition name, prefer parsing from ProductName
|
# Derive friendly edition name, prefer parsing from ProductName
|
||||||
edition = ""
|
edition = ""
|
||||||
pn = product_name or ""
|
pn = product_name or ""
|
||||||
if pn.lower().startswith("windows "):
|
if pn.lower().startswith("windows "):
|
||||||
tokens = pn.split()
|
tokens = pn.split()
|
||||||
# tokens like ["Windows", "11", "Pro", ...]
|
# tokens like ["Windows", "Server", "2022", "Standard", ...] or ["Windows", "11", "Pro", ...]
|
||||||
if len(tokens) >= 3:
|
if len(tokens) >= 3:
|
||||||
edition = " ".join(tokens[2:])
|
edition = " ".join(tokens[2:])
|
||||||
if not edition and edition_id:
|
if not edition and edition_id:
|
||||||
@@ -2047,12 +2139,8 @@ def detect_agent_os():
|
|||||||
}
|
}
|
||||||
edition = eid_map.get(edition_id, edition_id)
|
edition = eid_map.get(edition_id, edition_id)
|
||||||
|
|
||||||
os_name = f"Windows {major_label}"
|
|
||||||
|
|
||||||
# Choose version label: DisplayVersion (preferred) then ReleaseId
|
|
||||||
version_label = display_version or release_id or ""
|
version_label = display_version or release_id or ""
|
||||||
|
|
||||||
# Build string with UBR if present
|
|
||||||
if isinstance(ubr, int):
|
if isinstance(ubr, int):
|
||||||
build_str = f"{build_number}.{ubr}" if build_number else str(ubr)
|
build_str = f"{build_number}.{ubr}" if build_number else str(ubr)
|
||||||
else:
|
else:
|
||||||
@@ -2061,7 +2149,7 @@ def detect_agent_os():
|
|||||||
except Exception:
|
except Exception:
|
||||||
build_str = build_number
|
build_str = build_number
|
||||||
|
|
||||||
parts = ["Microsoft", os_name]
|
parts = ["Microsoft", family]
|
||||||
if edition:
|
if edition:
|
||||||
parts.append(edition)
|
parts.append(edition)
|
||||||
if version_label:
|
if version_label:
|
||||||
@@ -2069,8 +2157,6 @@ def detect_agent_os():
|
|||||||
if build_str:
|
if build_str:
|
||||||
parts.append(f"Build {build_str}")
|
parts.append(f"Build {build_str}")
|
||||||
|
|
||||||
# Correct possible mislabeling in ProductName (e.g., says Windows 10 on Win 11)
|
|
||||||
# by trusting build-based major_label.
|
|
||||||
return " ".join(p for p in parts if p).strip()
|
return " ".join(p for p in parts if p).strip()
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -2123,7 +2209,6 @@ def _system_uptime_seconds() -> Optional[int]:
|
|||||||
|
|
||||||
def _collect_heartbeat_metrics() -> Dict[str, Any]:
|
def _collect_heartbeat_metrics() -> Dict[str, Any]:
|
||||||
metrics: Dict[str, Any] = {
|
metrics: Dict[str, Any] = {
|
||||||
"operating_system": detect_agent_os(),
|
|
||||||
"service_mode": SERVICE_MODE,
|
"service_mode": SERVICE_MODE,
|
||||||
}
|
}
|
||||||
uptime = _system_uptime_seconds()
|
uptime = _system_uptime_seconds()
|
||||||
@@ -2450,7 +2535,6 @@ async def send_heartbeat():
|
|||||||
payload = {
|
payload = {
|
||||||
"guid": client.guid or _read_agent_guid_from_disk(),
|
"guid": client.guid or _read_agent_guid_from_disk(),
|
||||||
"hostname": socket.gethostname(),
|
"hostname": socket.gethostname(),
|
||||||
"inventory": {},
|
|
||||||
"metrics": _collect_heartbeat_metrics(),
|
"metrics": _collect_heartbeat_metrics(),
|
||||||
}
|
}
|
||||||
await client.async_post_json("/api/agent/heartbeat", payload, require_auth=True)
|
await client.async_post_json("/api/agent/heartbeat", payload, require_auth=True)
|
||||||
|
|||||||
@@ -1634,8 +1634,8 @@ export default function DeviceList({
|
|||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ flex: "1 1 320px", minWidth: 0, position: "relative", zIndex: 1 }}>
|
<Box sx={{ flex: "1 1 320px", minWidth: 0, position: "relative", zIndex: 1 }}>
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))", gap: 1.2 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))", gap: 1.2 }}>
|
||||||
{statTiles.map((tile) => (
|
{statTiles.map(({ key, ...tileProps }) => (
|
||||||
<StatTile key={tile.key} {...tile} />
|
<StatTile key={key} {...tileProps} />
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
ToggleButton,
|
ToggleButton,
|
||||||
ToggleButtonGroup,
|
ToggleButtonGroup,
|
||||||
Switch,
|
|
||||||
Chip,
|
Chip,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
@@ -150,9 +149,37 @@ const TabPanel = ({ value, active, children }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AUTO_SIZE_COLUMNS = ["status", "site", "hostname", "description", "type"];
|
const GRID_STYLE_BASE = {
|
||||||
|
"& .ag-root-wrapper": { borderRadius: 1.5 },
|
||||||
|
"& .ag-center-cols-container .ag-cell, & .ag-pinned-left-cols-container .ag-cell, & .ag-pinned-right-cols-container .ag-cell": {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "8px 12px 8px 18px",
|
||||||
|
},
|
||||||
|
"& .ag-center-cols-container .ag-cell .ag-cell-wrapper, & .ag-pinned-left-cols-container .ag-cell .ag-cell-wrapper, & .ag-pinned-right-cols-container .ag-cell .ag-cell-wrapper": {
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
"& .ag-center-cols-container .ag-cell .ag-cell-value, & .ag-pinned-left-cols-container .ag-cell .ag-cell-value, & .ag-pinned-right-cols-container .ag-cell .ag-cell-value": {
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
textAlign: "left",
|
||||||
|
},
|
||||||
|
"& .ag-center-cols-container .ag-cell.auto-col-tight, & .ag-pinned-left-cols-container .ag-cell.auto-col-tight, & .ag-pinned-right-cols-container .ag-cell.auto-col-tight": {
|
||||||
|
paddingLeft: "12px",
|
||||||
|
paddingRight: "9px",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const resolveApplyAll = (filter) => Boolean(filter?.applyToAllSites ?? filter?.apply_to_all_sites);
|
const PREVIEW_AUTO_SIZE_COLUMNS = ["status", "site", "hostname", "description", "type"];
|
||||||
|
const SITE_AUTO_SIZE_COLUMNS = ["site"];
|
||||||
|
|
||||||
const resolveLastEdited = (filter) =>
|
const resolveLastEdited = (filter) =>
|
||||||
filter?.lastEdited || filter?.last_edited || filter?.updated_at || filter?.updated || null;
|
filter?.lastEdited || filter?.last_edited || filter?.updated_at || filter?.updated || null;
|
||||||
@@ -196,10 +223,55 @@ const formatLastEditedLabel = (ts, user) => {
|
|||||||
return `Last edited by ${editor} @ ${datePart} @ ${timePart}`;
|
return `Last edited by ${editor} @ ${datePart} @ ${timePart}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeSiteValue = (site) => {
|
||||||
|
if (site == null) return "";
|
||||||
|
if (typeof site === "object") {
|
||||||
|
const candidate =
|
||||||
|
site.id ??
|
||||||
|
site.site_id ??
|
||||||
|
site.siteId ??
|
||||||
|
site.value ??
|
||||||
|
site.site_value ??
|
||||||
|
site.name ??
|
||||||
|
site.site_name;
|
||||||
|
if (candidate != null && candidate !== "") return String(candidate);
|
||||||
|
}
|
||||||
|
if (typeof site === "string" || typeof site === "number") {
|
||||||
|
const value = String(site).trim();
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveSiteSelection = (filter) => {
|
||||||
|
if (!filter) return [];
|
||||||
|
const candidates =
|
||||||
|
filter?.sites || filter?.site_list || filter?.site_scope_values || filter?.site_scope_value || filter?.siteScopeValues;
|
||||||
|
if (Array.isArray(candidates)) {
|
||||||
|
return candidates
|
||||||
|
.map((c) => normalizeSiteValue(c))
|
||||||
|
.filter((v, idx, arr) => v && arr.indexOf(v) === idx);
|
||||||
|
}
|
||||||
|
const single =
|
||||||
|
filter?.site ||
|
||||||
|
filter?.site_id ||
|
||||||
|
filter?.siteId ||
|
||||||
|
filter?.site_scope_value ||
|
||||||
|
filter?.siteScopeValue ||
|
||||||
|
filter?.siteName ||
|
||||||
|
filter?.site_name ||
|
||||||
|
filter?.target_site ||
|
||||||
|
(typeof candidates === "string" || typeof candidates === "number" ? candidates : null);
|
||||||
|
const normalized = normalizeSiteValue(single);
|
||||||
|
return normalized ? [normalized] : [];
|
||||||
|
};
|
||||||
|
|
||||||
const resolveSiteScope = (filter) => {
|
const resolveSiteScope = (filter) => {
|
||||||
const raw = filter?.site_scope || filter?.siteScope || filter?.scope || filter?.type;
|
const raw = filter?.site_scope || filter?.siteScope || filter?.scope || filter?.type;
|
||||||
const normalized = String(raw || "").toLowerCase();
|
const normalized = String(raw || "").toLowerCase();
|
||||||
return normalized === "scoped" ? "scoped" : "global";
|
if (normalized === "scoped" || normalized === "site") return "scoped";
|
||||||
|
const sites = resolveSiteSelection(filter);
|
||||||
|
return sites.length ? "scoped" : "global";
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeGroupsForUI = (rawGroups) => {
|
const normalizeGroupsForUI = (rawGroups) => {
|
||||||
@@ -229,9 +301,9 @@ const normalizeGroupsForUI = (rawGroups) => {
|
|||||||
export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, onPageMetaChange }) {
|
export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, onPageMetaChange }) {
|
||||||
const [name, setName] = useState(initialFilter?.name || "");
|
const [name, setName] = useState(initialFilter?.name || "");
|
||||||
const initialScope = resolveSiteScope(initialFilter);
|
const initialScope = resolveSiteScope(initialFilter);
|
||||||
|
const initialSelectedSites = resolveSiteSelection(initialFilter);
|
||||||
const [scope, setScope] = useState(initialScope === "scoped" ? "site" : "global");
|
const [scope, setScope] = useState(initialScope === "scoped" ? "site" : "global");
|
||||||
const [applyToAllSites, setApplyToAllSites] = useState(initialScope !== "scoped");
|
const [selectedSites, setSelectedSites] = useState(initialSelectedSites);
|
||||||
const [targetSite, setTargetSite] = useState(initialFilter?.site || initialFilter?.siteName || "");
|
|
||||||
const [groups, setGroups] = useState(normalizeGroupsForUI(initialFilter?.groups || initialFilter?.raw?.groups));
|
const [groups, setGroups] = useState(normalizeGroupsForUI(initialFilter?.groups || initialFilter?.raw?.groups));
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState(null);
|
const [saveError, setSaveError] = useState(null);
|
||||||
@@ -247,7 +319,8 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o
|
|||||||
const [previewAppliedAt, setPreviewAppliedAt] = useState(null);
|
const [previewAppliedAt, setPreviewAppliedAt] = useState(null);
|
||||||
const [tab, setTab] = useState(TABS[0].value);
|
const [tab, setTab] = useState(TABS[0].value);
|
||||||
const isEditing = Boolean(initialFilter);
|
const isEditing = Boolean(initialFilter);
|
||||||
const gridRef = useRef(null);
|
const previewGridRef = useRef(null);
|
||||||
|
const siteGridRef = useRef(null);
|
||||||
const sendNotification = useCallback(async (message) => {
|
const sendNotification = useCallback(async (message) => {
|
||||||
if (!message) return;
|
if (!message) return;
|
||||||
try {
|
try {
|
||||||
@@ -288,9 +361,9 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o
|
|||||||
if (!filter) return;
|
if (!filter) return;
|
||||||
setName(filter?.name || "");
|
setName(filter?.name || "");
|
||||||
const resolvedScope = resolveSiteScope(filter);
|
const resolvedScope = resolveSiteScope(filter);
|
||||||
|
const resolvedSites = resolveSiteSelection(filter);
|
||||||
setScope(resolvedScope === "scoped" ? "site" : "global");
|
setScope(resolvedScope === "scoped" ? "site" : "global");
|
||||||
setApplyToAllSites(resolvedScope !== "scoped");
|
setSelectedSites(resolvedSites);
|
||||||
setTargetSite(filter?.site || filter?.site_scope || filter?.siteName || filter?.site_name || "");
|
|
||||||
setGroups(normalizeGroupsForUI(filter?.groups || filter?.raw?.groups));
|
setGroups(normalizeGroupsForUI(filter?.groups || filter?.raw?.groups));
|
||||||
setLastEditedTs(resolveLastEdited(filter));
|
setLastEditedTs(resolveLastEdited(filter));
|
||||||
setLastEditedBy(resolveLastEditedBy(filter));
|
setLastEditedBy(resolveLastEditedBy(filter));
|
||||||
@@ -309,27 +382,27 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o
|
|||||||
return () => onPageMetaChange?.(null);
|
return () => onPageMetaChange?.(null);
|
||||||
}, [onPageMetaChange, pageSubtitle, pageTitle]);
|
}, [onPageMetaChange, pageSubtitle, pageTitle]);
|
||||||
|
|
||||||
const handleGridReady = useCallback((params) => {
|
const handlePreviewGridReady = useCallback((params) => {
|
||||||
gridRef.current = params.api;
|
previewGridRef.current = params.api;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
try {
|
try {
|
||||||
params.api.autoSizeColumns(AUTO_SIZE_COLUMNS, true);
|
params.api.autoSizeColumns(PREVIEW_AUTO_SIZE_COLUMNS, true);
|
||||||
} catch {}
|
} catch {}
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const autoSizeGrid = useCallback(() => {
|
const autoSizePreviewGrid = useCallback(() => {
|
||||||
if (!gridRef.current || !previewRows.length) return;
|
if (!previewGridRef.current || !previewRows.length) return;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
try {
|
try {
|
||||||
gridRef.current.autoSizeColumns(AUTO_SIZE_COLUMNS, true);
|
previewGridRef.current.autoSizeColumns(PREVIEW_AUTO_SIZE_COLUMNS, true);
|
||||||
} catch {}
|
} catch {}
|
||||||
});
|
});
|
||||||
}, [previewRows.length]);
|
}, [previewRows.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
autoSizeGrid();
|
autoSizePreviewGrid();
|
||||||
}, [previewRows, autoSizeGrid]);
|
}, [previewRows, autoSizePreviewGrid]);
|
||||||
|
|
||||||
const getDeviceField = (device, field) => {
|
const getDeviceField = (device, field) => {
|
||||||
const summary = device && typeof device.summary === "object" ? device.summary : {};
|
const summary = device && typeof device.summary === "object" ? device.summary : {};
|
||||||
@@ -413,9 +486,118 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o
|
|||||||
[groups]
|
[groups]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const siteRows = useMemo(
|
||||||
|
() =>
|
||||||
|
sites.map((s, idx) => {
|
||||||
|
const value = String(s.value ?? s.id ?? idx);
|
||||||
|
const label = s.label || `Site ${idx + 1}`;
|
||||||
|
return {
|
||||||
|
id: s.id ? String(s.id) : value,
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
labelLower: String(label).toLowerCase(),
|
||||||
|
valueLower: value.toLowerCase(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
[sites]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedSiteMatchers = useMemo(() => {
|
||||||
|
const tokens = [];
|
||||||
|
selectedSites.forEach((id) => {
|
||||||
|
const value = String(id || "").trim();
|
||||||
|
if (value) tokens.push(value.toLowerCase());
|
||||||
|
const match = sites.find((s) => String(s.value) === String(id) || String(s.id) === String(id));
|
||||||
|
if (match?.label) tokens.push(String(match.label).toLowerCase());
|
||||||
|
if (match?.value) tokens.push(String(match.value).toLowerCase());
|
||||||
|
});
|
||||||
|
return Array.from(new Set(tokens.filter(Boolean)));
|
||||||
|
}, [selectedSites, sites]);
|
||||||
|
|
||||||
|
const selectedSiteLabels = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedSites
|
||||||
|
.map((id) => {
|
||||||
|
const match = sites.find((s) => String(s.value) === String(id) || String(s.id) === String(id));
|
||||||
|
const label = match?.label || match?.name;
|
||||||
|
const fallback = String(id || "").trim();
|
||||||
|
return label || fallback;
|
||||||
|
})
|
||||||
|
.filter((v, idx, arr) => v && arr.indexOf(v) === idx),
|
||||||
|
[selectedSites, sites]
|
||||||
|
);
|
||||||
|
|
||||||
|
const syncSiteGridSelection = useCallback(
|
||||||
|
(apiOverride = null) => {
|
||||||
|
const api = apiOverride || siteGridRef.current;
|
||||||
|
if (!api) return;
|
||||||
|
const selectedSet = new Set(selectedSites.map((s) => String(s)));
|
||||||
|
api.forEachNode((node) => {
|
||||||
|
const nodeId = String(node?.data?.id ?? node?.data?.value ?? "");
|
||||||
|
const shouldSelect = selectedSet.has(nodeId);
|
||||||
|
if (node.isSelected() !== shouldSelect) {
|
||||||
|
node.setSelected(shouldSelect);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[selectedSites]
|
||||||
|
);
|
||||||
|
|
||||||
|
const autoSizeSiteGrid = useCallback(() => {
|
||||||
|
if (!siteGridRef.current || !siteRows.length) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
try {
|
||||||
|
siteGridRef.current.autoSizeColumns(SITE_AUTO_SIZE_COLUMNS, true);
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
}, [siteRows.length]);
|
||||||
|
|
||||||
|
const handleSiteGridReady = useCallback(
|
||||||
|
(params) => {
|
||||||
|
siteGridRef.current = params.api;
|
||||||
|
autoSizeSiteGrid();
|
||||||
|
syncSiteGridSelection(params.api);
|
||||||
|
},
|
||||||
|
[autoSizeSiteGrid, syncSiteGridSelection]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
autoSizeSiteGrid();
|
||||||
|
syncSiteGridSelection();
|
||||||
|
}, [autoSizeSiteGrid, siteRows, syncSiteGridSelection]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const api = siteGridRef.current;
|
||||||
|
if (!api) return;
|
||||||
|
if (loadingSites) {
|
||||||
|
api.showLoadingOverlay();
|
||||||
|
} else if (!siteRows.length) {
|
||||||
|
api.showNoRowsOverlay();
|
||||||
|
} else {
|
||||||
|
api.hideOverlay();
|
||||||
|
}
|
||||||
|
}, [loadingSites, siteRows]);
|
||||||
|
|
||||||
|
const handleSiteSelectionChanged = useCallback(() => {
|
||||||
|
const api = siteGridRef.current;
|
||||||
|
if (!api) return;
|
||||||
|
const selected = api
|
||||||
|
.getSelectedNodes()
|
||||||
|
.map((node) => String(node?.data?.id ?? node?.data?.value ?? ""))
|
||||||
|
.filter(Boolean);
|
||||||
|
setSelectedSites(selected);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const applyCriteria = useCallback(async () => {
|
const applyCriteria = useCallback(async () => {
|
||||||
setPreviewLoading(true);
|
setPreviewLoading(true);
|
||||||
setPreviewError(null);
|
setPreviewError(null);
|
||||||
|
if (scope === "site" && !selectedSiteMatchers.length) {
|
||||||
|
setPreviewRows([]);
|
||||||
|
setPreviewAppliedAt(null);
|
||||||
|
setPreviewLoading(false);
|
||||||
|
setPreviewError("Select at least one site to preview scoped results.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/api/devices");
|
const resp = await fetch("/api/devices");
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
@@ -423,7 +605,16 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o
|
|||||||
}
|
}
|
||||||
const payload = await resp.json();
|
const payload = await resp.json();
|
||||||
const list = Array.isArray(payload?.devices) ? payload.devices : [];
|
const list = Array.isArray(payload?.devices) ? payload.devices : [];
|
||||||
const filtered = list.filter((d) => evaluateCriteria(d));
|
const siteMatchSet = new Set(selectedSiteMatchers);
|
||||||
|
const scopedMode = scope === "site";
|
||||||
|
const filtered = list.filter((d) => {
|
||||||
|
if (!evaluateCriteria(d)) return false;
|
||||||
|
if (!scopedMode) return true;
|
||||||
|
if (!siteMatchSet.size) return false;
|
||||||
|
const deviceSite = getDeviceField(d, "site");
|
||||||
|
const normalizedSite = String(deviceSite || "").toLowerCase();
|
||||||
|
return siteMatchSet.has(normalizedSite);
|
||||||
|
});
|
||||||
const rows = filtered.map((d, idx) => ({
|
const rows = filtered.map((d, idx) => ({
|
||||||
id: d.agent_guid || d.agent_id || d.hostname || `device-${idx}`,
|
id: d.agent_guid || d.agent_id || d.hostname || `device-${idx}`,
|
||||||
status: getDeviceField(d, "status"),
|
status: getDeviceField(d, "status"),
|
||||||
@@ -441,9 +632,9 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o
|
|||||||
setPreviewRows([]);
|
setPreviewRows([]);
|
||||||
} finally {
|
} finally {
|
||||||
setPreviewLoading(false);
|
setPreviewLoading(false);
|
||||||
autoSizeGrid();
|
autoSizePreviewGrid();
|
||||||
}
|
}
|
||||||
}, [autoSizeGrid, evaluateCriteria]);
|
}, [autoSizePreviewGrid, evaluateCriteria, scope, selectedSiteMatchers]);
|
||||||
|
|
||||||
const applyCriteriaRef = useRef(applyCriteria);
|
const applyCriteriaRef = useRef(applyCriteria);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -556,6 +747,45 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const siteColumnDefs = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
headerName: "",
|
||||||
|
field: "select",
|
||||||
|
width: 52,
|
||||||
|
maxWidth: 52,
|
||||||
|
minWidth: 52,
|
||||||
|
checkboxSelection: true,
|
||||||
|
headerCheckboxSelection: true,
|
||||||
|
pinned: "left",
|
||||||
|
suppressMenu: true,
|
||||||
|
sortable: false,
|
||||||
|
resizable: false,
|
||||||
|
lockPosition: true,
|
||||||
|
cellClass: "auto-col-tight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headerName: "Site",
|
||||||
|
field: "label",
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 220,
|
||||||
|
cellClass: "auto-col-tight",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const siteDefaultColDef = useMemo(
|
||||||
|
() => ({
|
||||||
|
sortable: true,
|
||||||
|
filter: "agTextColumnFilter",
|
||||||
|
resizable: true,
|
||||||
|
cellClass: "auto-col-tight",
|
||||||
|
suppressMenu: true,
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialFilter?.id) return;
|
if (!initialFilter?.id) return;
|
||||||
const missingGroups = !initialFilter.groups || initialFilter.groups.length === 0;
|
const missingGroups = !initialFilter.groups || initialFilter.groups.length === 0;
|
||||||
@@ -591,10 +821,18 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o
|
|||||||
const resp = await fetch("/api/sites");
|
const resp = await fetch("/api/sites");
|
||||||
const json = await resp.json().catch(() => []);
|
const json = await resp.json().catch(() => []);
|
||||||
const siteList = Array.isArray(json?.sites) ? json.sites : Array.isArray(json) ? json : [];
|
const siteList = Array.isArray(json?.sites) ? json.sites : Array.isArray(json) ? json : [];
|
||||||
const normalized = siteList.map((s, idx) => ({
|
const normalized = siteList.map((s, idx) => {
|
||||||
label: s.name || s.site_name || `Site ${idx + 1}`,
|
const normalizedValue = normalizeSiteValue(s);
|
||||||
value: s.id || s.site_id || s.name || s.site_name || idx,
|
const value = normalizedValue || String(idx);
|
||||||
}));
|
const label = s.name || s.site_name || s.label || `Site ${idx + 1}`;
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
id: value,
|
||||||
|
labelLower: String(label).toLowerCase(),
|
||||||
|
valueLower: String(value).toLowerCase(),
|
||||||
|
};
|
||||||
|
});
|
||||||
setSites(normalized);
|
setSites(normalized);
|
||||||
} catch {
|
} catch {
|
||||||
setSites([]);
|
setSites([]);
|
||||||
@@ -661,12 +899,26 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o
|
|||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
const siteScope = scope === "site" && !applyToAllSites ? "scoped" : "global";
|
const siteScope = scope === "site" ? "scoped" : "global";
|
||||||
|
const scopedSites =
|
||||||
|
siteScope === "scoped"
|
||||||
|
? Array.from(new Set(selectedSites.map((s) => String(s || "").trim()).filter(Boolean)))
|
||||||
|
: [];
|
||||||
|
const primarySite = siteScope === "scoped" ? scopedSites[0] || null : null;
|
||||||
|
if (siteScope === "scoped" && !scopedSites.length) {
|
||||||
|
setSaveError("Select at least one site when scoping a filter to sites.");
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
id: initialFilter?.id || initialFilter?.filter_id,
|
id: initialFilter?.id || initialFilter?.filter_id,
|
||||||
name: name.trim() || "Unnamed Filter",
|
name: name.trim() || "Unnamed Filter",
|
||||||
site_scope: siteScope,
|
site_scope: siteScope,
|
||||||
site: siteScope === "scoped" ? targetSite : null,
|
site_scope_value: primarySite,
|
||||||
|
site_scope_values: scopedSites,
|
||||||
|
sites: scopedSites,
|
||||||
|
site_names: siteScope === "scoped" ? selectedSiteLabels : [],
|
||||||
|
site: siteScope === "scoped" ? primarySite : null,
|
||||||
groups: groups.map((g, gIdx) => ({
|
groups: groups.map((g, gIdx) => ({
|
||||||
join_with: gIdx === 0 ? null : g.joinWith || "OR",
|
join_with: gIdx === 0 ? null : g.joinWith || "OR",
|
||||||
conditions: (g.conditions || []).map((c, cIdx) => ({
|
conditions: (g.conditions || []).map((c, cIdx) => ({
|
||||||
@@ -701,7 +953,7 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [applyToAllSites, groups, initialFilter, name, onSaved, scope, sendNotification, targetSite]);
|
}, [groups, initialFilter, name, onSaved, scope, selectedSiteLabels, selectedSites, sendNotification]);
|
||||||
|
|
||||||
const renderConditionRow = (groupId, condition, isFirst) => {
|
const renderConditionRow = (groupId, condition, isFirst) => {
|
||||||
const label = DEVICE_FIELDS.find((f) => f.value === condition.field)?.label || condition.field;
|
const label = DEVICE_FIELDS.find((f) => f.value === condition.field)?.label || condition.field;
|
||||||
@@ -1016,45 +1268,59 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o
|
|||||||
|
|
||||||
{scope === "site" && (
|
{scope === "site" && (
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||||
<Stack direction="row" alignItems="center" spacing={1}>
|
<Typography sx={{ fontWeight: 600 }}>Select sites to target</Typography>
|
||||||
<Switch
|
<Typography sx={{ color: AURORA_SHELL.subtext, fontSize: "0.95rem" }}>
|
||||||
checked={applyToAllSites}
|
Use the grid to pick one or more sites. Filters scoped to sites will only apply to the selected rows.
|
||||||
onChange={(e) => setApplyToAllSites(e.target.checked)}
|
|
||||||
color="info"
|
|
||||||
/>
|
|
||||||
<Box>
|
|
||||||
<Typography sx={{ fontWeight: 600 }}>Add filter to all Sites</Typography>
|
|
||||||
<Typography sx={{ color: AURORA_SHELL.subtext, fontSize: "0.9rem" }}>
|
|
||||||
Future sites will also inherit this filter when enabled.
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
<Box
|
||||||
</Stack>
|
className={gridTheme.themeName}
|
||||||
|
|
||||||
{!applyToAllSites && (
|
|
||||||
<Autocomplete
|
|
||||||
disablePortal
|
|
||||||
loading={loadingSites}
|
|
||||||
options={sites}
|
|
||||||
value={sites.find((s) => s.value === targetSite) || null}
|
|
||||||
getOptionLabel={(option) => option?.label || ""}
|
|
||||||
isOptionEqualToValue={(option, value) => option?.value === value?.value}
|
|
||||||
onChange={(_, val) => setTargetSite(val?.value || "")}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
label="Target Site"
|
|
||||||
size="small"
|
|
||||||
placeholder="Search sites"
|
|
||||||
sx={{
|
sx={{
|
||||||
width: { xs: "100%", md: "50%" },
|
...GRID_STYLE_BASE,
|
||||||
maxWidth: 420,
|
flex: 1,
|
||||||
"& .MuiInputBase-root": { backgroundColor: "rgba(4,7,17,0.65)" },
|
minHeight: 0,
|
||||||
"& .MuiOutlinedInput-notchedOutline": { borderColor: AURORA_SHELL.border },
|
height: { xs: 320, md: 360 },
|
||||||
|
maxWidth: 760,
|
||||||
}}
|
}}
|
||||||
|
style={{
|
||||||
|
"--ag-icon-font-family": iconFontFamily,
|
||||||
|
"--ag-background-color": "#070b1a",
|
||||||
|
"--ag-foreground-color": "#f4f7ff",
|
||||||
|
"--ag-header-background-color": "#0f172a",
|
||||||
|
"--ag-header-foreground-color": "#cfe0ff",
|
||||||
|
"--ag-odd-row-background-color": "rgba(255,255,255,0.02)",
|
||||||
|
"--ag-row-hover-color": "rgba(125,183,255,0.08)",
|
||||||
|
"--ag-selected-row-background-color": "rgba(64,164,255,0.18)",
|
||||||
|
"--ag-border-color": "rgba(125,183,255,0.18)",
|
||||||
|
"--ag-row-border-color": "rgba(125,183,255,0.14)",
|
||||||
|
"--ag-border-radius": "8px",
|
||||||
|
"--ag-checkbox-border-radius": "3px",
|
||||||
|
"--ag-checkbox-background-color": "rgba(255,255,255,0.06)",
|
||||||
|
"--ag-checkbox-border-color": "rgba(180,200,220,0.6)",
|
||||||
|
"--ag-checkbox-checked-color": "#7dd3fc",
|
||||||
|
"--ag-cell-horizontal-padding": "18px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AgGridReact
|
||||||
|
rowData={siteRows}
|
||||||
|
columnDefs={siteColumnDefs}
|
||||||
|
defaultColDef={siteDefaultColDef}
|
||||||
|
rowSelection="multiple"
|
||||||
|
rowMultiSelectWithClick
|
||||||
|
suppressCellFocus
|
||||||
|
animateRows
|
||||||
|
pagination
|
||||||
|
paginationPageSize={20}
|
||||||
|
paginationPageSizeSelector={[20, 50, 100]}
|
||||||
|
rowHeight={46}
|
||||||
|
headerHeight={44}
|
||||||
|
getRowId={(params) => params?.data?.id}
|
||||||
|
onGridReady={handleSiteGridReady}
|
||||||
|
onSelectionChanged={handleSiteSelectionChanged}
|
||||||
|
overlayNoRowsTemplate="<span class='ag-overlay-no-rows-center'>No sites available.</span>"
|
||||||
|
theme={gridTheme}
|
||||||
|
style={{ width: "100%", height: "100%", minHeight: "100%", fontFamily: gridFontFamily }}
|
||||||
/>
|
/>
|
||||||
)}
|
</Box>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -1206,12 +1472,11 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o
|
|||||||
<Box
|
<Box
|
||||||
className={gridTheme.themeName}
|
className={gridTheme.themeName}
|
||||||
sx={{
|
sx={{
|
||||||
|
...GRID_STYLE_BASE,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
height: "100%",
|
height: "100%",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
"& .ag-root-wrapper": { borderRadius: 1.5 },
|
|
||||||
"& .ag-cell.auto-col-tight": { paddingLeft: 2, paddingRight: 2 },
|
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
"--ag-icon-font-family": iconFontFamily,
|
"--ag-icon-font-family": iconFontFamily,
|
||||||
@@ -1229,6 +1494,7 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o
|
|||||||
"--ag-checkbox-background-color": "rgba(255,255,255,0.06)",
|
"--ag-checkbox-background-color": "rgba(255,255,255,0.06)",
|
||||||
"--ag-checkbox-border-color": "rgba(180,200,220,0.6)",
|
"--ag-checkbox-border-color": "rgba(180,200,220,0.6)",
|
||||||
"--ag-checkbox-checked-color": "#7dd3fc",
|
"--ag-checkbox-checked-color": "#7dd3fc",
|
||||||
|
"--ag-cell-horizontal-padding": "18px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AgGridReact
|
<AgGridReact
|
||||||
@@ -1240,10 +1506,11 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o
|
|||||||
headerHeight={44}
|
headerHeight={44}
|
||||||
suppressCellFocus
|
suppressCellFocus
|
||||||
overlayNoRowsTemplate="<span class='ag-overlay-no-rows-center'>Apply criteria to preview devices.</span>"
|
overlayNoRowsTemplate="<span class='ag-overlay-no-rows-center'>Apply criteria to preview devices.</span>"
|
||||||
onGridReady={handleGridReady}
|
onGridReady={handlePreviewGridReady}
|
||||||
theme={gridTheme}
|
theme={gridTheme}
|
||||||
pagination
|
pagination
|
||||||
paginationPageSize={20}
|
paginationPageSize={20}
|
||||||
|
paginationPageSizeSelector={[20, 50, 100]}
|
||||||
style={{ width: "100%", height: "100%", minHeight: "100%", fontFamily: gridFontFamily }}
|
style={{ width: "100%", height: "100%", minHeight: "100%", fontFamily: gridFontFamily }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
Reference in New Issue
Block a user