From d746912a96ee0f3b4c84e90cf6d99619fcabfd4f Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Sun, 30 Nov 2025 20:28:38 -0700 Subject: [PATCH] Fixed OS Overwrites in Agent Hearbeats (Legacy Inventory Code removed as well) --- Data/Agent/agent.py | 120 ++++- .../web-interface/src/Devices/Device_List.jsx | 4 +- .../src/Devices/Filters/Filter_Editor.jsx | 437 ++++++++++++++---- 3 files changed, 456 insertions(+), 105 deletions(-) diff --git a/Data/Agent/agent.py b/Data/Agent/agent.py index c684c711..5c463e4e 100644 --- a/Data/Agent/agent.py +++ b/Data/Agent/agent.py @@ -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) diff --git a/Data/Engine/web-interface/src/Devices/Device_List.jsx b/Data/Engine/web-interface/src/Devices/Device_List.jsx index 44178c90..bf37f27c 100644 --- a/Data/Engine/web-interface/src/Devices/Device_List.jsx +++ b/Data/Engine/web-interface/src/Devices/Device_List.jsx @@ -1634,8 +1634,8 @@ export default function DeviceList({ - {statTiles.map((tile) => ( - + {statTiles.map(({ key, ...tileProps }) => ( + ))} diff --git a/Data/Engine/web-interface/src/Devices/Filters/Filter_Editor.jsx b/Data/Engine/web-interface/src/Devices/Filters/Filter_Editor.jsx index 256f4aac..a8619c0d 100644 --- a/Data/Engine/web-interface/src/Devices/Filters/Filter_Editor.jsx +++ b/Data/Engine/web-interface/src/Devices/Filters/Filter_Editor.jsx @@ -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" && ( - - setApplyToAllSites(e.target.checked)} - color="info" + Select sites to target + + Use the grid to pick one or more sites. Filters scoped to sites will only apply to the selected rows. + + + params?.data?.id} + onGridReady={handleSiteGridReady} + onSelectionChanged={handleSiteSelectionChanged} + overlayNoRowsTemplate="No sites available." + theme={gridTheme} + style={{ width: "100%", height: "100%", minHeight: "100%", fontFamily: gridFontFamily }} /> - - Add filter to all Sites - - Future sites will also inherit this filter when enabled. - - - - - {!applyToAllSites && ( - s.value === targetSite) || null} - getOptionLabel={(option) => option?.label || ""} - isOptionEqualToValue={(option, value) => option?.value === value?.value} - onChange={(_, val) => setTargetSite(val?.value || "")} - renderInput={(params) => ( - - )} - /> - )} + )} @@ -1204,19 +1470,18 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o }} > + "--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", + }} + >