mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-15 16:55:48 -07:00
Added Results Page for Filter Editor
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Paper,
|
Paper,
|
||||||
Box,
|
Box,
|
||||||
@@ -21,7 +21,12 @@ import {
|
|||||||
Add as AddIcon,
|
Add as AddIcon,
|
||||||
Remove as RemoveIcon,
|
Remove as RemoveIcon,
|
||||||
Cached as CachedIcon,
|
Cached as CachedIcon,
|
||||||
|
PlayArrow as PlayIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
|
import { AgGridReact } from "ag-grid-react";
|
||||||
|
import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community";
|
||||||
|
|
||||||
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
const AURORA_SHELL = {
|
const AURORA_SHELL = {
|
||||||
background:
|
background:
|
||||||
@@ -132,6 +137,25 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
|
|||||||
const [lastEditedTs, setLastEditedTs] = useState(resolveLastEdited(initialFilter));
|
const [lastEditedTs, setLastEditedTs] = useState(resolveLastEdited(initialFilter));
|
||||||
const [loadingFilter, setLoadingFilter] = useState(false);
|
const [loadingFilter, setLoadingFilter] = useState(false);
|
||||||
const [loadError, setLoadError] = useState(null);
|
const [loadError, setLoadError] = useState(null);
|
||||||
|
const [previewRows, setPreviewRows] = useState([]);
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
|
const [previewError, setPreviewError] = useState(null);
|
||||||
|
const [previewAppliedAt, setPreviewAppliedAt] = useState(null);
|
||||||
|
const gridRef = useRef(null);
|
||||||
|
const gridTheme = useMemo(
|
||||||
|
() =>
|
||||||
|
themeQuartz.withParams({
|
||||||
|
accentColor: "#8b5cf6",
|
||||||
|
backgroundColor: "#070b1a",
|
||||||
|
browserColorScheme: "dark",
|
||||||
|
fontFamily: { googleFont: "IBM Plex Sans" },
|
||||||
|
foregroundColor: "#f4f7ff",
|
||||||
|
headerFontSize: 13,
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const gridFontFamily = "'IBM Plex Sans','Helvetica Neue',Arial,sans-serif";
|
||||||
|
const iconFontFamily = "'Quartz Regular'";
|
||||||
|
|
||||||
const applyFilterData = useCallback((filter) => {
|
const applyFilterData = useCallback((filter) => {
|
||||||
if (!filter) return;
|
if (!filter) return;
|
||||||
@@ -148,6 +172,161 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
|
|||||||
applyFilterData(initialFilter);
|
applyFilterData(initialFilter);
|
||||||
}, [applyFilterData, initialFilter]);
|
}, [applyFilterData, initialFilter]);
|
||||||
|
|
||||||
|
const handleGridReady = useCallback((params) => {
|
||||||
|
gridRef.current = params.api;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
try {
|
||||||
|
params.api.autoSizeColumns(AUTO_SIZE_COLUMNS, true);
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const autoSizeGrid = useCallback(() => {
|
||||||
|
if (!gridRef.current || !previewRows.length) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
try {
|
||||||
|
gridRef.current.autoSizeColumns(AUTO_SIZE_COLUMNS, true);
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
}, [previewRows.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
autoSizeGrid();
|
||||||
|
}, [previewRows, autoSizeGrid]);
|
||||||
|
|
||||||
|
const getDeviceField = (device, field) => {
|
||||||
|
const summary = device && typeof device.summary === "object" ? device.summary : {};
|
||||||
|
switch (field) {
|
||||||
|
case "status":
|
||||||
|
return device.status || summary.status || "";
|
||||||
|
case "site":
|
||||||
|
return device.site || device.site_name || summary.site || "";
|
||||||
|
case "hostname":
|
||||||
|
return device.hostname || summary.hostname || "";
|
||||||
|
case "description":
|
||||||
|
return device.description || summary.description || "";
|
||||||
|
case "type":
|
||||||
|
return device.type || summary.type || summary.device_type || device.device_type || "";
|
||||||
|
default:
|
||||||
|
return device[field] || summary[field] || "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const evaluateCondition = (device, condition) => {
|
||||||
|
const operator = (condition.operator || "contains").toLowerCase();
|
||||||
|
const value = String(condition.value ?? "").trim();
|
||||||
|
const fieldValueRaw = getDeviceField(device, condition.field);
|
||||||
|
const fieldValue = fieldValueRaw == null ? "" : String(fieldValueRaw);
|
||||||
|
const lcField = fieldValue.toLowerCase();
|
||||||
|
const lcValue = value.toLowerCase();
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case "contains":
|
||||||
|
return lcField.includes(lcValue);
|
||||||
|
case "not_contains":
|
||||||
|
return !lcField.includes(lcValue);
|
||||||
|
case "empty":
|
||||||
|
return lcField.length === 0;
|
||||||
|
case "not_empty":
|
||||||
|
return lcField.length > 0;
|
||||||
|
case "begins_with":
|
||||||
|
return lcField.startsWith(lcValue);
|
||||||
|
case "not_begins_with":
|
||||||
|
return !lcField.startsWith(lcValue);
|
||||||
|
case "ends_with":
|
||||||
|
return lcField.endsWith(lcValue);
|
||||||
|
case "not_ends_with":
|
||||||
|
return !lcField.endsWith(lcValue);
|
||||||
|
case "equals":
|
||||||
|
return lcField === lcValue;
|
||||||
|
case "not_equals":
|
||||||
|
return lcField !== lcValue;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const evaluateGroup = (device, group) => {
|
||||||
|
const conditions = group?.conditions || [];
|
||||||
|
if (!conditions.length) return true;
|
||||||
|
let result = evaluateCondition(device, conditions[0]);
|
||||||
|
for (let i = 1; i < conditions.length; i++) {
|
||||||
|
const cond = conditions[i];
|
||||||
|
const joiner = (cond.joinWith || "AND").toUpperCase();
|
||||||
|
const res = evaluateCondition(device, cond);
|
||||||
|
result = joiner === "OR" ? result || res : result && res;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const evaluateCriteria = useCallback(
|
||||||
|
(device) => {
|
||||||
|
if (!groups.length) return true;
|
||||||
|
let result = evaluateGroup(device, groups[0]);
|
||||||
|
for (let i = 1; i < groups.length; i++) {
|
||||||
|
const group = groups[i];
|
||||||
|
const joiner = (group.joinWith || "OR").toUpperCase();
|
||||||
|
const res = evaluateGroup(device, group);
|
||||||
|
result = joiner === "AND" ? result && res : result || res;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
[groups]
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyCriteria = useCallback(async () => {
|
||||||
|
setPreviewLoading(true);
|
||||||
|
setPreviewError(null);
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/api/devices");
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`Failed to load devices (${resp.status})`);
|
||||||
|
}
|
||||||
|
const payload = await resp.json();
|
||||||
|
const list = Array.isArray(payload?.devices) ? payload.devices : [];
|
||||||
|
const filtered = list.filter((d) => evaluateCriteria(d));
|
||||||
|
const rows = filtered.map((d, idx) => ({
|
||||||
|
id: d.agent_guid || d.agent_id || d.hostname || `device-${idx}`,
|
||||||
|
status: getDeviceField(d, "status"),
|
||||||
|
site: getDeviceField(d, "site"),
|
||||||
|
hostname: getDeviceField(d, "hostname"),
|
||||||
|
description: getDeviceField(d, "description"),
|
||||||
|
type: getDeviceField(d, "type"),
|
||||||
|
}));
|
||||||
|
setPreviewRows(rows);
|
||||||
|
setPreviewAppliedAt(new Date());
|
||||||
|
} catch (err) {
|
||||||
|
setPreviewError(err?.message || "Unable to apply criteria");
|
||||||
|
setPreviewRows([]);
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading(false);
|
||||||
|
autoSizeGrid();
|
||||||
|
}
|
||||||
|
}, [autoSizeGrid, evaluateCriteria]);
|
||||||
|
|
||||||
|
const previewColumns = useMemo(
|
||||||
|
() => [
|
||||||
|
{ field: "status", headerName: "Status", minWidth: 110, cellClass: "auto-col-tight" },
|
||||||
|
{ field: "site", headerName: "Site", minWidth: 140, cellClass: "auto-col-tight" },
|
||||||
|
{ field: "hostname", headerName: "Hostname", minWidth: 160, cellClass: "auto-col-tight" },
|
||||||
|
{ field: "description", headerName: "Description", minWidth: 200, cellClass: "auto-col-tight" },
|
||||||
|
{ field: "type", headerName: "Device Type", minWidth: 140, cellClass: "auto-col-tight" },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultPreviewColDef = useMemo(
|
||||||
|
() => ({
|
||||||
|
sortable: true,
|
||||||
|
filter: "agTextColumnFilter",
|
||||||
|
resizable: true,
|
||||||
|
flex: 1,
|
||||||
|
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;
|
||||||
@@ -736,6 +915,88 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
background: AURORA_SHELL.glass,
|
||||||
|
border: `1px solid ${AURORA_SHELL.border}`,
|
||||||
|
borderRadius: 2.5,
|
||||||
|
p: 2,
|
||||||
|
boxShadow: "0 18px 38px rgba(3,7,18,0.65)",
|
||||||
|
backdropFilter: "blur(12px)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ fontWeight: 700 }}>Results</Typography>
|
||||||
|
<Typography sx={{ color: AURORA_SHELL.subtext, fontSize: "0.95rem" }}>
|
||||||
|
Apply criteria to preview matching devices (20 per page).
|
||||||
|
</Typography>
|
||||||
|
{previewAppliedAt && (
|
||||||
|
<Typography sx={{ color: AURORA_SHELL.subtext, fontSize: "0.85rem" }}>
|
||||||
|
Last applied: {previewAppliedAt.toLocaleString()}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{previewError ? (
|
||||||
|
<Typography sx={{ color: "#ffb4b4", fontSize: "0.9rem", mt: 0.5 }}>{previewError}</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={previewLoading ? <CachedIcon /> : <PlayIcon />}
|
||||||
|
onClick={applyCriteria}
|
||||||
|
disabled={previewLoading}
|
||||||
|
sx={gradientButtonSx}
|
||||||
|
>
|
||||||
|
{previewLoading ? "Applying..." : "Apply Criteria"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
className={gridTheme.themeName}
|
||||||
|
sx={{
|
||||||
|
height: 420,
|
||||||
|
"& .ag-root-wrapper": { borderRadius: 1.5 },
|
||||||
|
"& .ag-cell.auto-col-tight": { paddingLeft: 8, paddingRight: 6 },
|
||||||
|
}}
|
||||||
|
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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AgGridReact
|
||||||
|
rowData={previewRows}
|
||||||
|
columnDefs={previewColumns}
|
||||||
|
defaultColDef={defaultPreviewColDef}
|
||||||
|
animateRows
|
||||||
|
rowHeight={46}
|
||||||
|
headerHeight={44}
|
||||||
|
suppressCellFocus
|
||||||
|
overlayNoRowsTemplate="<span class='ag-overlay-no-rows-center'>Apply criteria to preview devices.</span>"
|
||||||
|
onGridReady={handleGridReady}
|
||||||
|
theme={gridTheme}
|
||||||
|
pagination
|
||||||
|
paginationPageSize={20}
|
||||||
|
style={{ width: "100%", height: "100%", fontFamily: gridFontFamily }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{saveError ? (
|
{saveError ? (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
Reference in New Issue
Block a user