mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-14 21:15:47 -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()
|
||||
|
||||
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:
|
||||
import winreg # Only available on Windows
|
||||
|
||||
@@ -2002,6 +1999,7 @@ def detect_agent_os():
|
||||
return default
|
||||
|
||||
product_name = _get("ProductName", "") # e.g., "Windows 11 Pro"
|
||||
installation_type = _get("InstallationType", "") # e.g., "Server"
|
||||
edition_id = _get("EditionID", "") # e.g., "Professional"
|
||||
display_version = _get("DisplayVersion", "") # e.g., "24H2" / "22H2"
|
||||
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
|
||||
except Exception:
|
||||
build_int = 0
|
||||
if build_int >= 22000:
|
||||
major_label = "11"
|
||||
elif build_int >= 10240:
|
||||
major_label = "10"
|
||||
|
||||
wmi_info = {}
|
||||
try:
|
||||
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:
|
||||
family = "Windows Server"
|
||||
else:
|
||||
major_label = platform.release()
|
||||
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
|
||||
edition = ""
|
||||
pn = product_name or ""
|
||||
if pn.lower().startswith("windows "):
|
||||
tokens = pn.split()
|
||||
# tokens like ["Windows", "11", "Pro", ...]
|
||||
# tokens like ["Windows", "Server", "2022", "Standard", ...] or ["Windows", "11", "Pro", ...]
|
||||
if len(tokens) >= 3:
|
||||
edition = " ".join(tokens[2:])
|
||||
if not edition and edition_id:
|
||||
@@ -2047,12 +2139,8 @@ def detect_agent_os():
|
||||
}
|
||||
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 ""
|
||||
|
||||
# Build string with UBR if present
|
||||
if isinstance(ubr, int):
|
||||
build_str = f"{build_number}.{ubr}" if build_number else str(ubr)
|
||||
else:
|
||||
@@ -2061,7 +2149,7 @@ def detect_agent_os():
|
||||
except Exception:
|
||||
build_str = build_number
|
||||
|
||||
parts = ["Microsoft", os_name]
|
||||
parts = ["Microsoft", family]
|
||||
if edition:
|
||||
parts.append(edition)
|
||||
if version_label:
|
||||
@@ -2069,8 +2157,6 @@ def detect_agent_os():
|
||||
if 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()
|
||||
|
||||
except Exception:
|
||||
@@ -2123,7 +2209,6 @@ def _system_uptime_seconds() -> Optional[int]:
|
||||
|
||||
def _collect_heartbeat_metrics() -> Dict[str, Any]:
|
||||
metrics: Dict[str, Any] = {
|
||||
"operating_system": detect_agent_os(),
|
||||
"service_mode": SERVICE_MODE,
|
||||
}
|
||||
uptime = _system_uptime_seconds()
|
||||
@@ -2450,7 +2535,6 @@ async def send_heartbeat():
|
||||
payload = {
|
||||
"guid": client.guid or _read_agent_guid_from_disk(),
|
||||
"hostname": socket.gethostname(),
|
||||
"inventory": {},
|
||||
"metrics": _collect_heartbeat_metrics(),
|
||||
}
|
||||
await client.async_post_json("/api/agent/heartbeat", payload, require_auth=True)
|
||||
|
||||
@@ -1634,8 +1634,8 @@ export default function DeviceList({
|
||||
</Box>
|
||||
<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 }}>
|
||||
{statTiles.map((tile) => (
|
||||
<StatTile key={tile.key} {...tile} />
|
||||
{statTiles.map(({ key, ...tileProps }) => (
|
||||
<StatTile key={key} {...tileProps} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
TextField,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Switch,
|
||||
Chip,
|
||||
Tooltip,
|
||||
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) =>
|
||||
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}`;
|
||||
};
|
||||
|
||||
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 raw = filter?.site_scope || filter?.siteScope || filter?.scope || filter?.type;
|
||||
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) => {
|
||||
@@ -229,9 +301,9 @@ const normalizeGroupsForUI = (rawGroups) => {
|
||||
export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, onPageMetaChange }) {
|
||||
const [name, setName] = useState(initialFilter?.name || "");
|
||||
const initialScope = resolveSiteScope(initialFilter);
|
||||
const initialSelectedSites = resolveSiteSelection(initialFilter);
|
||||
const [scope, setScope] = useState(initialScope === "scoped" ? "site" : "global");
|
||||
const [applyToAllSites, setApplyToAllSites] = useState(initialScope !== "scoped");
|
||||
const [targetSite, setTargetSite] = useState(initialFilter?.site || initialFilter?.siteName || "");
|
||||
const [selectedSites, setSelectedSites] = useState(initialSelectedSites);
|
||||
const [groups, setGroups] = useState(normalizeGroupsForUI(initialFilter?.groups || initialFilter?.raw?.groups));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState(null);
|
||||
@@ -247,7 +319,8 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o
|
||||
const [previewAppliedAt, setPreviewAppliedAt] = useState(null);
|
||||
const [tab, setTab] = useState(TABS[0].value);
|
||||
const isEditing = Boolean(initialFilter);
|
||||
const gridRef = useRef(null);
|
||||
const previewGridRef = useRef(null);
|
||||
const siteGridRef = useRef(null);
|
||||
const sendNotification = useCallback(async (message) => {
|
||||
if (!message) return;
|
||||
try {
|
||||
@@ -288,9 +361,9 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o
|
||||
if (!filter) return;
|
||||
setName(filter?.name || "");
|
||||
const resolvedScope = resolveSiteScope(filter);
|
||||
const resolvedSites = resolveSiteSelection(filter);
|
||||
setScope(resolvedScope === "scoped" ? "site" : "global");
|
||||
setApplyToAllSites(resolvedScope !== "scoped");
|
||||
setTargetSite(filter?.site || filter?.site_scope || filter?.siteName || filter?.site_name || "");
|
||||
setSelectedSites(resolvedSites);
|
||||
setGroups(normalizeGroupsForUI(filter?.groups || filter?.raw?.groups));
|
||||
setLastEditedTs(resolveLastEdited(filter));
|
||||
setLastEditedBy(resolveLastEditedBy(filter));
|
||||
@@ -309,27 +382,27 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o
|
||||
return () => onPageMetaChange?.(null);
|
||||
}, [onPageMetaChange, pageSubtitle, pageTitle]);
|
||||
|
||||
const handleGridReady = useCallback((params) => {
|
||||
gridRef.current = params.api;
|
||||
const handlePreviewGridReady = useCallback((params) => {
|
||||
previewGridRef.current = params.api;
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
params.api.autoSizeColumns(AUTO_SIZE_COLUMNS, true);
|
||||
params.api.autoSizeColumns(PREVIEW_AUTO_SIZE_COLUMNS, true);
|
||||
} catch {}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const autoSizeGrid = useCallback(() => {
|
||||
if (!gridRef.current || !previewRows.length) return;
|
||||
const autoSizePreviewGrid = useCallback(() => {
|
||||
if (!previewGridRef.current || !previewRows.length) return;
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
gridRef.current.autoSizeColumns(AUTO_SIZE_COLUMNS, true);
|
||||
previewGridRef.current.autoSizeColumns(PREVIEW_AUTO_SIZE_COLUMNS, true);
|
||||
} catch {}
|
||||
});
|
||||
}, [previewRows.length]);
|
||||
|
||||
useEffect(() => {
|
||||
autoSizeGrid();
|
||||
}, [previewRows, autoSizeGrid]);
|
||||
autoSizePreviewGrid();
|
||||
}, [previewRows, autoSizePreviewGrid]);
|
||||
|
||||
const getDeviceField = (device, field) => {
|
||||
const summary = device && typeof device.summary === "object" ? device.summary : {};
|
||||
@@ -413,9 +486,118 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o
|
||||
[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 () => {
|
||||
setPreviewLoading(true);
|
||||
setPreviewError(null);
|
||||
if (scope === "site" && !selectedSiteMatchers.length) {
|
||||
setPreviewRows([]);
|
||||
setPreviewAppliedAt(null);
|
||||
setPreviewLoading(false);
|
||||
setPreviewError("Select at least one site to preview scoped results.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch("/api/devices");
|
||||
if (!resp.ok) {
|
||||
@@ -423,7 +605,16 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o
|
||||
}
|
||||
const payload = await resp.json();
|
||||
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) => ({
|
||||
id: d.agent_guid || d.agent_id || d.hostname || `device-${idx}`,
|
||||
status: getDeviceField(d, "status"),
|
||||
@@ -441,9 +632,9 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o
|
||||
setPreviewRows([]);
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
autoSizeGrid();
|
||||
autoSizePreviewGrid();
|
||||
}
|
||||
}, [autoSizeGrid, evaluateCriteria]);
|
||||
}, [autoSizePreviewGrid, evaluateCriteria, scope, selectedSiteMatchers]);
|
||||
|
||||
const applyCriteriaRef = useRef(applyCriteria);
|
||||
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(() => {
|
||||
if (!initialFilter?.id) return;
|
||||
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 json = await resp.json().catch(() => []);
|
||||
const siteList = Array.isArray(json?.sites) ? json.sites : Array.isArray(json) ? json : [];
|
||||
const normalized = siteList.map((s, idx) => ({
|
||||
label: s.name || s.site_name || `Site ${idx + 1}`,
|
||||
value: s.id || s.site_id || s.name || s.site_name || idx,
|
||||
}));
|
||||
const normalized = siteList.map((s, idx) => {
|
||||
const normalizedValue = normalizeSiteValue(s);
|
||||
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);
|
||||
} catch {
|
||||
setSites([]);
|
||||
@@ -661,12 +899,26 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
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 = {
|
||||
id: initialFilter?.id || initialFilter?.filter_id,
|
||||
name: name.trim() || "Unnamed Filter",
|
||||
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) => ({
|
||||
join_with: gIdx === 0 ? null : g.joinWith || "OR",
|
||||
conditions: (g.conditions || []).map((c, cIdx) => ({
|
||||
@@ -701,7 +953,7 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [applyToAllSites, groups, initialFilter, name, onSaved, scope, sendNotification, targetSite]);
|
||||
}, [groups, initialFilter, name, onSaved, scope, selectedSiteLabels, selectedSites, sendNotification]);
|
||||
|
||||
const renderConditionRow = (groupId, condition, isFirst) => {
|
||||
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" && (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Switch
|
||||
checked={applyToAllSites}
|
||||
onChange={(e) => setApplyToAllSites(e.target.checked)}
|
||||
color="info"
|
||||
<Typography sx={{ fontWeight: 600 }}>Select sites to target</Typography>
|
||||
<Typography sx={{ color: AURORA_SHELL.subtext, fontSize: "0.95rem" }}>
|
||||
Use the grid to pick one or more sites. Filters scoped to sites will only apply to the selected rows.
|
||||
</Typography>
|
||||
<Box
|
||||
className={gridTheme.themeName}
|
||||
sx={{
|
||||
...GRID_STYLE_BASE,
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
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>
|
||||
<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>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{!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={{
|
||||
width: { xs: "100%", md: "50%" },
|
||||
maxWidth: 420,
|
||||
"& .MuiInputBase-root": { backgroundColor: "rgba(4,7,17,0.65)" },
|
||||
"& .MuiOutlinedInput-notchedOutline": { borderColor: AURORA_SHELL.border },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
@@ -1204,19 +1470,18 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className={gridTheme.themeName}
|
||||
sx={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
"& .ag-root-wrapper": { borderRadius: 1.5 },
|
||||
"& .ag-cell.auto-col-tight": { paddingLeft: 2, paddingRight: 2 },
|
||||
}}
|
||||
style={{
|
||||
"--ag-icon-font-family": iconFontFamily,
|
||||
"--ag-background-color": "#070b1a",
|
||||
"--ag-foreground-color": "#f4f7ff",
|
||||
className={gridTheme.themeName}
|
||||
sx={{
|
||||
...GRID_STYLE_BASE,
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
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)",
|
||||
@@ -1226,11 +1491,12 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o
|
||||
"--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-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={previewRows}
|
||||
columnDefs={previewColumns}
|
||||
@@ -1238,12 +1504,13 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o
|
||||
animateRows
|
||||
rowHeight={46}
|
||||
headerHeight={44}
|
||||
suppressCellFocus
|
||||
suppressCellFocus
|
||||
overlayNoRowsTemplate="<span class='ag-overlay-no-rows-center'>Apply criteria to preview devices.</span>"
|
||||
onGridReady={handleGridReady}
|
||||
onGridReady={handlePreviewGridReady}
|
||||
theme={gridTheme}
|
||||
pagination
|
||||
paginationPageSize={20}
|
||||
paginationPageSizeSelector={[20, 50, 100]}
|
||||
style={{ width: "100%", height: "100%", minHeight: "100%", fontFamily: gridFontFamily }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user