From be0d3a88ffb501efd8afc51ddf4294b524e792d0 Mon Sep 17 00:00:00 2001 From: Brijesh Bhalala Date: Thu, 23 Jul 2026 11:01:11 +0530 Subject: [PATCH 1/2] ATLAS-5350: Atlas UI: Enhance Purge Audit Results UI --- dashboard/src/hooks/useVirtualization.ts | 62 ++ dashboard/src/utils/Enum.ts | 14 + .../Administrator/Audits/AdminAuditTable.tsx | 2 +- .../Administrator/Audits/AuditResults.tsx | 847 ++++++++++++++---- .../AuditsFilter/AuditFiltersFields.tsx | 4 + .../Audits/__tests__/AuditResults.test.tsx | 717 +++++++++++++-- dashboardv2/public/css/scss/drawer.scss | 322 +++++++ dashboardv2/public/css/scss/style.scss | 2 +- .../js/templates/audit/DrawerView_tmpl.html | 58 ++ dashboardv2/public/js/utils/Utils.js | 33 + .../views/audit/AdminAuditTableLayoutView.js | 338 +++++-- .../public/js/views/audit/DrawerView.js | 319 +++++++ .../js/views/search/QueryBuilderView.js | 3 + 13 files changed, 2426 insertions(+), 295 deletions(-) create mode 100644 dashboard/src/hooks/useVirtualization.ts create mode 100644 dashboardv2/public/css/scss/drawer.scss create mode 100644 dashboardv2/public/js/templates/audit/DrawerView_tmpl.html create mode 100644 dashboardv2/public/js/views/audit/DrawerView.js diff --git a/dashboard/src/hooks/useVirtualization.ts b/dashboard/src/hooks/useVirtualization.ts new file mode 100644 index 00000000000..20aa20df420 --- /dev/null +++ b/dashboard/src/hooks/useVirtualization.ts @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useMemo } from 'react'; + +interface UseVirtualizationProps { + items: T[]; + scrollTop: number; + itemHeight?: number; + overscan?: number; + visibleCount?: number; +} + +export const useVirtualization = ({ + items, + scrollTop, + itemHeight = 37, + overscan = 10, + visibleCount = 40, +}: UseVirtualizationProps) => { + return useMemo(() => { + const totalItems = items.length; + + if (totalItems === 0) { + return { + visibleItems: [], + paddingTop: 0, + paddingBottom: 0, + startIndex: 0, + }; + } + + const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan); + const endIndex = Math.min(totalItems - 1, Math.floor(scrollTop / itemHeight) + visibleCount + overscan); + + const visibleItems = items.slice(startIndex, endIndex + 1); + + const paddingTop = startIndex * itemHeight; + const paddingBottom = Math.max(0, (totalItems - 1 - endIndex) * itemHeight); + + return { + visibleItems, + paddingTop, + paddingBottom, + startIndex, + }; + }, [items, scrollTop, itemHeight, overscan, visibleCount]); +}; diff --git a/dashboard/src/utils/Enum.ts b/dashboard/src/utils/Enum.ts index 50644400b87..a67463a1f4f 100644 --- a/dashboard/src/utils/Enum.ts +++ b/dashboard/src/utils/Enum.ts @@ -111,6 +111,20 @@ export const auditAction: { [key: string]: string } = { AUTO_PURGE : "Auto Purged Entities" }; + +export enum AuditOperation { + PURGE = "PURGE", + AUTO_PURGE = "AUTO_PURGE", + IMPORT = "IMPORT", + EXPORT = "EXPORT" +} + +export enum PurgeActiveView { + NONE = "none", + REQUESTED = "requested", + PURGED = "purged" +} + export const stats: any = { generalData: { collectionTime: "day" diff --git a/dashboard/src/views/Administrator/Audits/AdminAuditTable.tsx b/dashboard/src/views/Administrator/Audits/AdminAuditTable.tsx index 86732832284..9c0ad92420a 100644 --- a/dashboard/src/views/Administrator/Audits/AdminAuditTable.tsx +++ b/dashboard/src/views/Administrator/Audits/AdminAuditTable.tsx @@ -65,7 +65,7 @@ const AdminAuditTable = () => { try { setLoader(true); let searchResp = await getAuditData(params); - setAuditData(searchResp.data); + setAuditData(searchResp.data || []); setLoader(false); } catch (error: any) { console.error("Error fetching data:", error.response.data.errorMessage); diff --git a/dashboard/src/views/Administrator/Audits/AuditResults.tsx b/dashboard/src/views/Administrator/Audits/AuditResults.tsx index acde90ff397..4dcd1ec3ae5 100644 --- a/dashboard/src/views/Administrator/Audits/AuditResults.tsx +++ b/dashboard/src/views/Administrator/Audits/AuditResults.tsx @@ -15,130 +15,228 @@ * limitations under the License. */ -import { Grid, Link, List, ListItem, ListItemText, Typography } from "@mui/material"; -import { auditAction, category } from "@utils/Enum"; +import { Grid, Link, List, ListItem, ListItemText, Typography, Divider, Alert, AlertTitle, Box, Drawer, IconButton, Stack, Tooltip, TextField, InputAdornment, CircularProgress } from "@mui/material"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import SearchIcon from "@mui/icons-material/Search"; +import { auditAction, category, AuditOperation, PurgeActiveView } from "@utils/Enum"; import { isEmpty, jsonParse } from "@utils/Utils"; +import { useVirtualization } from '@hooks/useVirtualization'; import CustomModal from "@components/Modal"; import TypeDefAuditDetailModal from "@components/TypeDefAuditDetailModal"; -import { useState } from "react"; -import { Item } from "@utils/Muiutils"; +import { useRef, useState } from "react"; import AuditsTab from "@views/DetailPage/EntityDetailTabs/AuditsTab"; import ImportExportAudits from "./ImportExportAudits"; +import { LightTooltip } from '@components/muiComponents'; -const AuditResults = ({ componentProps, row }: any) => { +interface AuditEntry { + guid: string; + operation: string; + params?: string; + result?: string; + runId?: string; + [key: string]: unknown; +} + +interface AuditResultsProps { + componentProps?: { + auditData?: AuditEntry[]; + }; + row: { + original: { + guid: string; + runId?: string; + [key: string]: unknown; + }; + }; +} + +const AuditResults = ({ componentProps, row }: AuditResultsProps) => { const { auditData } = componentProps || {}; const [openModal, setOpenModal] = useState(false); const [openPurgeModal, setOpenPurgeModal] = useState(false); - const [currentResultObj, setCurrentObj] = useState({}); - const [currentPurgeResultObj, setCurrentPurgeResultObj] = useState(""); + const [currentResultObj, setCurrentObj] = useState | undefined>(); + // Stores the guid of the clicked purged entity + const [currentPurgeResultObj, setCurrentPurgeResultObj] = useState(); + const [activePurgeView, setActivePurgeView] = useState(PurgeActiveView.NONE); + const [drawerSearchText, setDrawerSearchText] = useState(''); + const [drawerPage, setDrawerPage] = useState(1); + const [drawerPageSize, setDrawerPageSize] = useState(10); + const [drawerPageSizeInput, setDrawerPageSizeInput] = useState('10'); + const [scrollTop, setScrollTop] = useState(0); + const [copiedRunId, setCopiedRunId] = useState(false); + const [purgedApiGuids, setPurgedApiGuids] = useState([]); + const [loadingPurgedApi, setLoadingPurgedApi] = useState(false); + const [purgedTotalCount, setPurgedTotalCount] = useState(0); + const drawerScrollTimerRef = useRef | null>(null); + const handleCloseModal = () => { setOpenModal(false); }; const handleClosePurgeModal = () => { setOpenPurgeModal(false); }; - const auditObj = !isEmpty(auditData) - ? auditData.find((obj: { guid: string }) => obj.guid == row.original.guid) - : {}; - const { operation, params, result } = auditObj; + const auditObj: AuditEntry | undefined = !isEmpty(auditData) + ? (auditData as AuditEntry[]).find((obj) => obj.guid === row.original.guid) + : undefined; + + const operation = auditObj?.operation ?? ''; + const params = auditObj?.params; + const result = auditObj?.result; + + let isPurgeOperation = operation === AuditOperation.PURGE || operation === AuditOperation.AUTO_PURGE; + let summary: Record = {}; + let requestedEntitiesList: string[] = []; + let legacyPurgedList: string[] = []; + + if (isPurgeOperation) { + try { + const parsed = typeof result === "string" ? JSON.parse(result) : result; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + summary = (parsed as Record).summary + ? (parsed as Record).summary as Record + : parsed as Record; + } else if (Array.isArray(parsed)) { + legacyPurgedList = (parsed as unknown[]).map((item) => + typeof item === "string" ? item : (item as { guid?: string }).guid || String(item) + ); + } + } catch (e) { + if (typeof result === "string" && !result.startsWith("{")) { + legacyPurgedList = result.replace(/^\[|\]$/g, "").split(",").map(s => s.trim()).filter(Boolean); + } + } + + if (params) { + try { + const parsedParams = JSON.parse(params); + if (Array.isArray(parsedParams)) { + requestedEntitiesList = parsedParams as string[]; + } else if (typeof params === "string") { + requestedEntitiesList = params.replace(/^\[|\]$/g, "").split(",").map(s => s.trim()).filter(Boolean); + } + } catch (e) { + requestedEntitiesList = typeof params === "string" + ? params.replace(/^\[|\]$/g, "").split(",").map(s => s.trim()).filter(Boolean) + : []; + } + } + } else { + try { + summary = jsonParse(result) as Record; + } catch (e) { + summary = {}; + } + } + + const summaryGuid = auditObj?.guid ?? row.original.guid; + const runId = (row.original.runId as string | undefined) + ?? (summary?.runId as string | undefined) + ?? (auditObj?.runId as string | undefined) + ?? 'N/A'; + + const isSummaryRow = (runId !== 'N/A') && isPurgeOperation; + + const requestedCount = (summary?.requestedCount as number | undefined) ?? requestedEntitiesList.length; + const purgedCount = (summary?.purgedCount as number | undefined) ?? legacyPurgedList.length; + const purgedDependenciesCount = (summary?.purgedDependenciesCount as number | undefined) ?? 0; + const totalPurgedCount = (purgedCount as number) + (purgedDependenciesCount as number); + const failedCount = (summary?.failedCount as number | undefined) ?? 0; + const skippedCount = (summary?.skippedCount as number | undefined) ?? 0; + const executionFailed = (summary?.executionFailed as boolean | undefined) || (failedCount as number) > 0; + + // Fetch a page of purged entities server-side using limit & offset, can append to existing + const fetchPurged = (append: boolean = false, limitOverride?: number) => { + if (!summaryGuid) return; + const pageSize = limitOverride ?? drawerPageSize; + const offset = append ? purgedApiGuids.length : 0; - const resultObj = - (operation == "PURGE" || operation == "AUTO_PURGE") - ? result.replace("[", "").replace("]", "").split(",") - : jsonParse(result); + setLoadingPurgedApi(true); + if (!append) { + setPurgedApiGuids([]); + } + + fetch(`/api/atlas/admin/audit/${summaryGuid}/purgedEntities?limit=${pageSize}&offset=${offset}`, { + headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' } + }) + .then(res => res.ok ? res.json() : []) + .then((data: string[]) => { + if (Array.isArray(data)) { + setPurgedApiGuids(prev => append ? [...prev, ...data] : data); + } + }) + .catch(() => { }) + .finally(() => { + setLoadingPurgedApi(false); + }); + }; + + // Handle clicking Total Purged card: opens drawer and fetches first page + const handleOpenPurgedDrawer = () => { + if (totalPurgedCount === 0) return; + setPurgedTotalCount(totalPurgedCount); + setActivePurgeView(PurgeActiveView.PURGED); + setDrawerPage(1); + setScrollTop(0); + if (isSummaryRow) { + fetchPurged(false, drawerPageSize); + } else { + setPurgedApiGuids(legacyPurgedList); + setLoadingPurgedApi(false); + } + }; return ( <> - {operation != "PURGE" && - operation != "AUTO_PURGE" && - operation != "IMPORT" && - operation != "EXPORT" && - !isEmpty(resultObj) ? ( - - {params.split(",").length > 1 ? ( - <> - {params.split(",")?.map((param: { param: string }) => { - return ( - - - {`${category[param as any]} ${auditAction[operation] - }`} - - - {resultObj[param as any].map( - (obj: { name: string }) => { - const { name } = obj; - return ( - <> - - { - setOpenModal(true); - setCurrentObj(obj); - }} - title={name} - sx={{ - display: "inline-block", - maxWidth: "100%", - textOverflow: "ellipsis", - overflow: "hidden", - whiteSpace: "nowrap", - textAlign: "left", - verticalAlign: "bottom" - }} - > - {name} - - - - ); - } - )} - - - - ); - })} - - ) : ( - <> - - - {`${category[params as any]} ${auditAction[operation] - }`} - - {resultObj[params].map((obj: { name: string }) => { - const { name } = obj; - return ( - <> - + + + + + + + {operation === "TYPE_DEF_CREATE" || + operation === "TYPE_DEF_UPDATE" || + operation === "TYPE_DEF_DELETE" ? ( + + {summary && + Object.keys(summary).map((key: string) => { + const rawItems = summary[key]; + const items: Array | string> = Array.isArray(rawItems) + ? (rawItems as Array | string>) + : []; + return ( +
+ + {`${category[key as keyof typeof category] || key} ${auditAction[operation as keyof typeof auditAction] || operation}`} + + {items.map((obj: Record | string, idx: number) => { + const name = typeof obj === 'object' && obj !== null + ? (obj.name as string) || String(obj) + : String(obj); + return ( + + { setOpenModal(true); - setCurrentObj(obj); + setCurrentObj(typeof obj === "object" ? obj : { name: obj }); }} title={name} sx={{ @@ -153,89 +251,494 @@ const AuditResults = ({ componentProps, row }: any) => { > {name} - - - ); - })} - - + } + /> + + ); + })} +
+ ); + })} +
+ ) : operation === "IMPORT" || operation === "EXPORT" ? ( + + ) : !isPurgeOperation ? ( + No Results Found + ) : null} + + {/* Purge Audit View */} + {isPurgeOperation ? ( + + + + {/* Run Id Header with Copy Action */} + {runId !== 'N/A' && ( + + + Run Id: {runId} + + + { + if (navigator.clipboard) { + navigator.clipboard.writeText(runId); + } else { + const textField = document.createElement('textarea'); + textField.innerText = runId; + document.body.appendChild(textField); + textField.select(); + document.execCommand('copy'); + textField.remove(); + } + setCopiedRunId(true); + setTimeout(() => setCopiedRunId(false), 2000); + }} + sx={{ p: 0.5 }} + > + + + + + )} + + {/* 4 Cards Grid: Requested, Total Purged, Failed (Display Only), Skipped (Display Only) */} + + {/* 1. Clickable Requested Card */} + {isSummaryRow && ( + + { + setActivePurgeView(PurgeActiveView.REQUESTED); + setDrawerPage(1); + setScrollTop(0); + }} + sx={{ + p: 1.5, + borderRadius: 2, + backgroundColor: '#eff6ff', + border: '1px solid #bfdbfe', + transition: 'all 0.2s ease-in-out', + cursor: 'pointer', + boxShadow: '0 1px 3px rgba(0,0,0,0.04)', + '&:hover': { + transform: 'translateY(-2px)', + boxShadow: '0 4px 12px rgba(59,130,246,0.15)', + borderColor: '#60a5fa' + } + }} + > + + Requested + + + {requestedCount} + + + + )} + + {/* 2. Clickable Total Purged Card */} + + 0 ? 'pointer' : 'default', + boxShadow: '0 1px 3px rgba(0,0,0,0.04)', + '&:hover': totalPurgedCount > 0 ? { + transform: 'translateY(-2px)', + boxShadow: '0 4px 12px rgba(34,197,94,0.15)', + borderColor: '#4ade80' + } : {} + }} + > + + {isSummaryRow ? 'Total Purged' : 'Purged Entities'} + + + {totalPurgedCount} + + - - )} - - ) : ( - operation != "PURGE" && - operation != "AUTO_PURGE" && - operation != "IMPORT" && - operation != "EXPORT" && No Results Found - )} - - {(operation == "PURGE" || operation == "AUTO_PURGE") && !isEmpty(resultObj) ? ( - <> - {`${category[operation]}`} - - {resultObj.map((obj: string) => { - return ( - - { - setOpenPurgeModal(true); - setCurrentPurgeResultObj(obj); + + {/* 3 & 4. Display-Only Failed and Skipped Cards */} + {isSummaryRow && ( + <> + + 0 || executionFailed + ? "Some entities failed to purge. Please check /logs/purgefailure.log for details." + : "No failed entities during this purge operation." + } + arrow + placement="top" + > + 0 ? '#fef2f2' : '#fafafa', + border: failedCount > 0 ? '1px solid #fecaca' : '1px solid rgba(0,0,0,0.08)', + boxShadow: '0 1px 3px rgba(0,0,0,0.04)', + cursor: 'default' }} - title={obj} + > + 0 ? "error.main" : "textSecondary"} display="block" sx={{ textTransform: 'uppercase', fontWeight: 'bold', letterSpacing: '0.5px', fontSize: '11px' }}> + Failed + + 0 ? "error.main" : "textPrimary"} sx={{ fontWeight: 'bold', mt: 0.5 }}> + {failedCount} + + + + + + {/* 4. Display-Only Skipped Card */} + + 0 || executionFailed + ? "Some entities were skipped during purge. Please check /logs/purgefailure.log for details." + : "No skipped entities during this purge operation." + } + arrow + placement="top" + > + 0 ? '#fffbeb' : '#fafafa', + border: skippedCount > 0 ? '1px solid #fef08a' : '1px solid rgba(0,0,0,0.08)', + boxShadow: '0 1px 3px rgba(0,0,0,0.04)', + cursor: 'default' }} > - {obj} - - } - /> - - ); - })} - - - ) : ( - (operation == "PURGE" || operation == "AUTO_PURGE") && No Results Found - )} + 0 ? "warning.main" : "textSecondary"} display="block" sx={{ textTransform: 'uppercase', fontWeight: 'bold', letterSpacing: '0.5px', fontSize: '11px' }}> + Skipped + + 0 ? "warning.main" : "textPrimary"} sx={{ fontWeight: 'bold', mt: 0.5 }}> + {skippedCount} + + + +
+ + )} +
- {(operation == "IMPORT" || operation == "EXPORT") && ( - - )} + {/* Status Alert if execution failure or failedCount > 0 */} + {executionFailed && ( + + + Partial success + Some entities failed to purge. Check purgefailure.log for details. + + + )} + - + {/* Right Side Drawer — server-side pagination for Purged, client-side for Requested */} + {(() => { + // For REQUESTED view: client-side filter + paginate + // For PURGED view: server-side paginate, client-side search on current page + const isPurgedView = activePurgeView === PurgeActiveView.PURGED; + const isServerSidePagination = isPurgedView && isSummaryRow; + + // Determine the raw list for the current view + const rawListForView: string[] = activePurgeView === PurgeActiveView.REQUESTED + ? requestedEntitiesList + : purgedApiGuids; // full list for non-summary, page list for summary + + // Client-side search filter (on current page for purged, on full list for requested) + const filteredList = rawListForView.filter((guidStr: string) => { + if (!drawerSearchText) return true; + return guidStr.toLowerCase().includes(drawerSearchText.trim().toLowerCase()); + }); + + // For client-side pagination (requested OR non-summary purged) + const clientSideItems = !isServerSidePagination + ? filteredList.slice(0, drawerPage * drawerPageSize) + : []; + + const displayItems = isServerSidePagination ? filteredList : clientSideItems; + + const { visibleItems, paddingTop, paddingBottom, startIndex } = useVirtualization({ + items: displayItems, + scrollTop, + itemHeight: 37 + }); - {(operation == "PURGE" || operation == "AUTO_PURGE") && ( - - - - )} + // ---- Purged: server-side pages ---- + // Server side infinite scroll handles data loading, we just display what we have + const displayTotal = isServerSidePagination ? purgedTotalCount : filteredList.length; + + const handleDrawerScroll = (e: React.UIEvent) => { + setScrollTop(e.currentTarget.scrollTop); + if (drawerScrollTimerRef.current) return; + + // If it's a wheel event and they are scrolling up, ignore it + const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; + const isNearBottom = scrollHeight - scrollTop - clientHeight < 20; + if (isNearBottom) { + drawerScrollTimerRef.current = setTimeout(() => { + drawerScrollTimerRef.current = null; + if (isServerSidePagination && !loadingPurgedApi && purgedApiGuids.length < displayTotal) { + fetchPurged(true); + } else if (!isServerSidePagination && visibleItems.length < displayTotal) { + setDrawerPage(prev => prev + 1); + } + }, 250); + } + }; + + return ( + { + setActivePurgeView(PurgeActiveView.NONE); + setDrawerSearchText(''); + setDrawerPage(1); + setScrollTop(0); + }} + PaperProps={{ sx: { width: '460px', p: 2.5, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' } }} + > + {/* 1. Drawer Header */} + + + {activePurgeView === PurgeActiveView.REQUESTED ? 'Requested Entities' : 'Purged Entities'} + + { + setActivePurgeView(PurgeActiveView.NONE); + setDrawerSearchText(''); + setDrawerPage(1); + setScrollTop(0); + }} + size="small" + > + ✕ + + + + {/* 2. Run Id Header inside Drawer */} + {runId !== 'N/A' && ( + + + Run Id: {runId} + + + { + if (navigator.clipboard) { + navigator.clipboard.writeText(runId); + } else { + const textField = document.createElement('textarea'); + textField.innerText = runId; + document.body.appendChild(textField); + textField.select(); + document.execCommand('copy'); + textField.remove(); + } + setCopiedRunId(true); + setTimeout(() => setCopiedRunId(false), 2000); + }} + sx={{ p: 0.5, ml: 'auto' }} + > + + + + + )} + + {/* 3. Search Bar */} + {(activePurgeView === 'requested' ? requestedEntitiesList.length > 0 : purgedApiGuids.length > 0 || loadingPurgedApi) && ( + + { + setDrawerSearchText(e.target.value); + setDrawerPage(1); + setScrollTop(0); + // For purged view: re-fetch page 1 with new search is not supported server-side; + // search is applied client-side on the current page's data + }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: drawerSearchText ? ( + + { + setDrawerSearchText(''); + setDrawerPage(1); + setScrollTop(0); + }}> + ✕ + + + ) : null + }} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: 2, + bgcolor: '#fff' + } + }} + /> + + )} + + + + {/* 4. GUID List */} + {isPurgedView && loadingPurgedApi && purgedApiGuids.length === 0 ? ( + + + + ) : ( + + {(() => { + if (displayItems.length === 0) { + return ( + + No matching GUIDs found + + ); + } + + return ( + <> + {paddingTop > 0 &&
} + {visibleItems.map((guidStr: string, localIndex: number) => { + const index = startIndex + localIndex; + const globalIndex = index + 1; + return ( + + {globalIndex}. + { + setOpenPurgeModal(true); + setCurrentPurgeResultObj(guidStr); + }} + title={guidStr} + sx={{ + display: "inline-block", + maxWidth: "100%", + textOverflow: "ellipsis", + overflow: "hidden", + whiteSpace: "nowrap", + textAlign: "left" + }} + > + {guidStr} + + + ); + })} + {paddingBottom > 0 &&
} + + ); + })()} + + {displayItems.length > 0 && displayItems.length < displayTotal && ( + + {isPurgedView && loadingPurgedApi && } + + {isPurgedView && loadingPurgedApi ? 'Loading more...' : 'Scroll to load more data'} + + + )} + + )} + + {/* 5. Relationship-card-style Footer: Showing X of Y | Limit [input] */} + {displayTotal > 0 && ( + + + Showing {displayItems.length} of {displayTotal} + + + Limit + ) => { + setDrawerPageSizeInput(e.target.value); + }} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + const parsed = parseInt((e.target as HTMLInputElement).value, 10); + if (Number.isFinite(parsed) && parsed > 0) { + const clamped = Math.min(parsed, displayTotal); + setDrawerPageSize(clamped); + setDrawerPageSizeInput(String(clamped)); + setDrawerPage(1); + if (isServerSidePagination) { + fetchPurged(false, clamped); + } + } + } + }} + sx={{ + width: '56px', + fontSize: '12px', + border: '1px solid #cbd5e1', + borderRadius: '4px', + px: 0.75, + py: 0.25, + textAlign: 'center', + outline: 'none', + '&:focus': { borderColor: '#90caf9' } + }} + /> + + + )} + + ); + })()} + + ) : null} ); }; + export default AuditResults; +// trigger hmr diff --git a/dashboard/src/views/Administrator/Audits/AuditsFilter/AuditFiltersFields.tsx b/dashboard/src/views/Administrator/Audits/AuditsFilter/AuditFiltersFields.tsx index b479a4be390..08a59766925 100644 --- a/dashboard/src/views/Administrator/Audits/AuditsFilter/AuditFiltersFields.tsx +++ b/dashboard/src/views/Administrator/Audits/AuditsFilter/AuditFiltersFields.tsx @@ -308,6 +308,10 @@ export const fields = (allDataObj) => { } if (!isEmpty(auditEntryAttributeDefs)) { for (const attributes in auditEntryAttributeDefs) { + if (auditEntryAttributeDefs[attributes]?.name === "auditRowKind") { + continue; // Backend-only field, do not display in UI filter + } + let returnObj: any = getObjDef( allDataObj, auditEntryAttributeDefs[attributes], diff --git a/dashboard/src/views/Administrator/Audits/__tests__/AuditResults.test.tsx b/dashboard/src/views/Administrator/Audits/__tests__/AuditResults.test.tsx index 1896838f7d6..cdd557434f6 100644 --- a/dashboard/src/views/Administrator/Audits/__tests__/AuditResults.test.tsx +++ b/dashboard/src/views/Administrator/Audits/__tests__/AuditResults.test.tsx @@ -39,11 +39,15 @@ const mockIsEmpty = jest.fn((val: any) => { const mockIsArray = jest.fn((val: any) => Array.isArray(val)); const mockJsonParse = jest.fn((val: any) => { - try { - return JSON.parse(val); - } catch { - return {}; - } + if (!val) return []; + // Real jsonParse throws if the outer JSON is invalid + return JSON.parse(val, (_key, value) => { + try { + return typeof value === 'string' ? JSON.parse(value) : value; + } catch { + return value; + } + }); }); jest.mock('@utils/Utils', () => ({ @@ -52,8 +56,17 @@ jest.mock('@utils/Utils', () => ({ jsonParse: (...args: any[]) => mockJsonParse(...args) })); +// Mock global fetch so tests that trigger API calls don't crash +global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve([]) + }) +) as jest.Mock; + // Mock Enum jest.mock('@utils/Enum', () => ({ + ...jest.requireActual('@utils/Enum'), auditAction: { CREATE: 'Created', UPDATE: 'Updated', @@ -139,6 +152,7 @@ jest.mock('@mui/material', () => { {children} ), + Drawer: ({ children, open, ...props }: any) => open ?
{children}
: null, List: ({ children, ...props }: any) =>
    {children}
, ListItem: ({ children, ...props }: any) =>
  • {children}
  • , ListItemText: ({ primary, ...props }: any) =>
    {primary}
    , @@ -150,7 +164,7 @@ describe('AuditResults Component', () => { const mockAuditData = [ { guid: 'audit-1', - operation: 'CREATE', + operation: 'TYPE_DEF_CREATE', params: 'entityDefs', result: JSON.stringify({ entityDefs: [ @@ -161,7 +175,7 @@ describe('AuditResults Component', () => { }, { guid: 'audit-2', - operation: 'UPDATE', + operation: 'TYPE_DEF_UPDATE', params: 'classificationDefs,enumDefs', result: JSON.stringify({ classificationDefs: [{ name: 'Classification1', category: 'classificationDefs' }], @@ -202,6 +216,10 @@ describe('AuditResults Component', () => { beforeEach(() => { jest.clearAllMocks(); + // Reset fetch mock before each test + (global.fetch as jest.Mock).mockImplementation(() => + Promise.resolve({ ok: true, json: () => Promise.resolve([]) }) + ); mockIsEmpty.mockImplementation((val: any) => { if (val === null || val === undefined || val === '') return true; if (Array.isArray(val) && val.length === 0) return true; @@ -210,11 +228,14 @@ describe('AuditResults Component', () => { }); mockIsArray.mockImplementation((val: any) => Array.isArray(val)); mockJsonParse.mockImplementation((val: any) => { - try { - return JSON.parse(val); - } catch { - return {}; - } + if (!val) return []; + return JSON.parse(val, (_key, value) => { + try { + return typeof value === 'string' ? JSON.parse(value) : value; + } catch { + return value; + } + }); }); }); @@ -223,8 +244,8 @@ describe('AuditResults Component', () => { const componentProps = { auditData: mockAuditData }; render(); - const grids = screen.getAllByTestId('grid'); - expect(grids.length).toBeGreaterThan(0); + const lists = screen.getAllByTestId('list'); + expect(lists.length).toBeGreaterThan(0); }); it('should find audit object by guid', () => { @@ -232,7 +253,7 @@ describe('AuditResults Component', () => { render(); // Should render results for audit-1 - expect(screen.getAllByTestId('item').length).toBeGreaterThan(0); + expect(screen.getAllByTestId('list-item').length).toBeGreaterThan(0); }); it('should handle empty auditData', () => { @@ -270,8 +291,8 @@ describe('AuditResults Component', () => { }); }); - describe('CREATE/UPDATE/DELETE Operations', () => { - it('should render results for CREATE operation with single param', () => { + describe('TYPE_DEF_CREATE/UPDATE/DELETE Operations', () => { + it('should render results for TYPE_DEF_CREATE operation with single param', () => { const componentProps = { auditData: mockAuditData }; const row = { original: { guid: 'audit-1' } }; @@ -283,7 +304,7 @@ describe('AuditResults Component', () => { expect(screen.getByText('Entity2')).toBeInTheDocument(); }); - it('should render results for UPDATE operation with multiple params', () => { + it('should render results for TYPE_DEF_UPDATE operation with multiple params', () => { const componentProps = { auditData: mockAuditData }; const row = { original: { guid: 'audit-2' } }; @@ -363,24 +384,15 @@ describe('AuditResults Component', () => { }); }); - it('should show "No Record Found" when current object is empty', async () => { - const componentProps = { - auditData: [ - { - guid: 'audit-empty', - operation: 'CREATE', - params: 'entityDefs', - result: JSON.stringify({ entityDefs: [{}] }) - } - ] - }; - const row = { original: { guid: 'audit-empty' } }; + it('should show "No Record Found" when current object is empty', () => { + mockIsEmpty.mockReturnValue(true); // Force isEmpty to return true + const componentProps = { auditData: mockAuditData }; + const row = { original: { guid: 'audit-1' } }; render(); // The component should still render but with empty object - const grids = screen.getAllByTestId('grid'); - expect(grids.length).toBeGreaterThan(0); + expect(screen.getByText('No Results Found')).toBeInTheDocument(); }); }); @@ -391,6 +403,8 @@ describe('AuditResults Component', () => { render(); + fireEvent.click(screen.getAllByText('Purged Entities')[0]); + const typographies = screen.getAllByTestId('typography'); expect(typographies.length).toBeGreaterThan(0); expect(screen.getByText('guid-1')).toBeInTheDocument(); @@ -404,6 +418,8 @@ describe('AuditResults Component', () => { render(); + fireEvent.click(screen.getAllByText('Purged Entities')[0]); + const typographies = screen.getAllByTestId('typography'); expect(typographies.length).toBeGreaterThan(0); expect(screen.getByText('guid-4')).toBeInTheDocument(); @@ -416,6 +432,8 @@ describe('AuditResults Component', () => { render(); + fireEvent.click(screen.getAllByText('Purged Entities')[0]); + const purgeLink = screen.getByText('guid-1'); fireEvent.click(purgeLink); @@ -425,7 +443,6 @@ describe('AuditResults Component', () => { expect(screen.getByTestId('modal-title')).toHaveTextContent('Purged Entity Details: guid-1'); expect(screen.getByTestId('audits-tab')).toBeInTheDocument(); - expect(screen.getByTestId('audits-tab')).toHaveAttribute('data-loading', 'false'); }); it('should open auto purge modal with correct title', async () => { @@ -434,6 +451,9 @@ describe('AuditResults Component', () => { render(); + // Click to open drawer first + fireEvent.click(screen.getAllByText('Purged Entities')[0]); + const purgeLink = screen.getByText('guid-4'); fireEvent.click(purgeLink); @@ -441,7 +461,7 @@ describe('AuditResults Component', () => { expect(screen.getByTestId('custom-modal')).toBeInTheDocument(); }); - expect(screen.getByTestId('modal-title')).toHaveTextContent('Auto Purged Entity Details: guid-4'); + expect(screen.getByTestId('modal-title')).toHaveTextContent('Purged Entity Details: guid-4'); }); it('should close purge modal when close button is clicked', async () => { @@ -450,6 +470,9 @@ describe('AuditResults Component', () => { render(); + // Click to open drawer first + fireEvent.click(screen.getAllByText('Purged Entities')[0]); + // Open modal const purgeLink = screen.getByText('guid-1'); fireEvent.click(purgeLink); @@ -533,19 +556,11 @@ describe('AuditResults Component', () => { describe('Edge Cases', () => { it('should handle empty result object for non-PURGE operations', () => { - mockJsonParse.mockReturnValue({}); - mockIsEmpty.mockImplementation((val) => { - if (val === null || val === undefined || val === '') return true; - if (Array.isArray(val) && val.length === 0) return true; - if (typeof val === 'object' && Object.keys(val).length === 0) return true; - return false; - }); - const componentProps = { auditData: [ { guid: 'audit-empty-result', - operation: 'CREATE', + operation: 'TYPE_DEF_CREATE', params: 'entityDefs', result: '{}' } @@ -555,24 +570,18 @@ describe('AuditResults Component', () => { render(); - const typographies = screen.getAllByTestId('typography'); - expect(typographies.length).toBeGreaterThan(0); + const list = screen.getByTestId('list'); + expect(list).toBeInTheDocument(); + const listItems = screen.queryAllByTestId('list-item'); + expect(listItems.length).toBe(0); }); it('should handle malformed JSON in result', () => { - mockJsonParse.mockReturnValue({}); - mockIsEmpty.mockImplementation((val) => { - if (val === null || val === undefined || val === '') return true; - if (Array.isArray(val) && val.length === 0) return true; - if (typeof val === 'object' && Object.keys(val).length === 0) return true; - return false; - }); - const componentProps = { auditData: [ { guid: 'audit-malformed', - operation: 'CREATE', + operation: 'TYPE_DEF_CREATE', params: 'entityDefs', result: 'malformed json' } @@ -582,31 +591,35 @@ describe('AuditResults Component', () => { render(); - const typographies = screen.getAllByTestId('typography'); - expect(typographies.length).toBeGreaterThan(0); + const list = screen.getByTestId('list'); + expect(list).toBeInTheDocument(); + const listItems = screen.queryAllByTestId('list-item'); + expect(listItems.length).toBe(0); }); it('should handle audit object not found', () => { const componentProps = { auditData: mockAuditData }; const row = { original: { guid: 'non-existent-guid' } }; - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); - - // This will cause an error because auditObj will be undefined and result will be undefined - expect(() => render()).toThrow(); - - consoleSpy.mockRestore(); + // With proper TypeScript types, auditObj is undefined when guid is not found. + // The component now handles this gracefully by rendering "No Results Found" + // instead of crashing (improved behavior from the TS fix). + render(); + + // Component renders without throwing and shows a default "No Results Found" state + const typographies = screen.getAllByTestId('typography'); + expect(typographies.length).toBeGreaterThan(0); }); it('should handle params with comma-separated values', () => { const componentProps = { auditData: mockAuditData }; - const row = { original: { guid: 'audit-2' } }; + const row = { original: { guid: 'audit-2' } }; // This has params 'classificationDefs,enumDefs' render(); - // Should render multiple grids for each param - const grids = screen.getAllByTestId('grid'); - expect(grids.length).toBeGreaterThan(1); + // Should render multiple list items for each param + const listItems = screen.getAllByTestId('list-item'); + expect(listItems.length).toBeGreaterThan(1); }); it('should handle single param without comma', () => { @@ -615,9 +628,9 @@ describe('AuditResults Component', () => { render(); - // Should render grids - const grids = screen.getAllByTestId('grid'); - expect(grids.length).toBeGreaterThan(0); + // Should render list items + const listItems = screen.getAllByTestId('list-item'); + expect(listItems.length).toBeGreaterThan(0); }); it('should display array length in modal when value is array', async () => { @@ -625,7 +638,7 @@ describe('AuditResults Component', () => { auditData: [ { guid: 'audit-array', - operation: 'CREATE', + operation: 'TYPE_DEF_CREATE', params: 'entityDefs', result: JSON.stringify({ entityDefs: [ @@ -677,6 +690,9 @@ describe('AuditResults Component', () => { render(); + // Click the "Purged Entities" summary card to open the drawer + fireEvent.click(screen.getAllByText('Purged Entities')[0]); + // Should split "[guid-1,guid-2,guid-3]" into array expect(screen.getByText('guid-1')).toBeInTheDocument(); expect(screen.getByText('guid-2')).toBeInTheDocument(); @@ -689,6 +705,8 @@ describe('AuditResults Component', () => { render(); + fireEvent.click(screen.getAllByText('Purged Entities')[0]); + // Should split "[guid-4,guid-5]" into array expect(screen.getByText('guid-4')).toBeInTheDocument(); expect(screen.getByText('guid-5')).toBeInTheDocument(); @@ -795,4 +813,563 @@ describe('AuditResults Component', () => { expect(typographies.length).toBeGreaterThan(0); }); }); + + // ───────────────────────────────────────────────────────────────────────────── + // TYPE_DEF_DELETE Operation + // ───────────────────────────────────────────────────────────────────────────── + describe('TYPE_DEF_DELETE Operation', () => { + it('should render results for TYPE_DEF_DELETE operation', () => { + const auditData = [ + { + guid: 'audit-del', + operation: 'TYPE_DEF_DELETE', + params: 'entityDefs', + result: JSON.stringify({ + entityDefs: [{ name: 'DeletedEntity', category: 'entityDefs' }] + }) + } + ]; + render(); + + expect(screen.getByText('DeletedEntity')).toBeInTheDocument(); + expect(screen.getByText(/TYPE_DEF_DELETE/)).toBeInTheDocument(); + }); + + it('should open modal when TYPE_DEF_DELETE entity is clicked', async () => { + const auditData = [ + { + guid: 'audit-del', + operation: 'TYPE_DEF_DELETE', + params: 'entityDefs', + result: JSON.stringify({ + entityDefs: [{ name: 'DeletedEntity', category: 'entityDefs' }] + }) + } + ]; + render(); + + fireEvent.click(screen.getByText('DeletedEntity')); + await waitFor(() => { + expect(screen.getByTestId('custom-modal')).toBeInTheDocument(); + }); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // PURGE — JSON Summary Format (new structured format) + // ───────────────────────────────────────────────────────────────────────────── + describe('PURGE Operations — JSON Summary format', () => { + const summaryResult = JSON.stringify({ + requestedCount: 5, + purgedCount: 3, + purgedDependenciesCount: 1, + failedCount: 1, + skippedCount: 0, + executionFailed: false, + runId: 'run-abc-123' + }); + + const auditDataWithSummary = [ + { + guid: 'audit-summary', + operation: 'PURGE', + params: JSON.stringify(['g1', 'g2', 'g3', 'g4', 'g5']), + result: summaryResult + } + ]; + + it('should display purgedCount from JSON summary', () => { + render(); + // Total Purged = purgedCount(3) + purgedDependenciesCount(1) = 4 + expect(screen.getByText('4')).toBeInTheDocument(); + }); + + it('should display failedCount from JSON summary', () => { + render(); + expect(screen.getByText('Failed')).toBeInTheDocument(); + expect(screen.getByText('1')).toBeInTheDocument(); + }); + + it('should display skippedCount from JSON summary', () => { + render(); + expect(screen.getByText('Skipped')).toBeInTheDocument(); + expect(screen.getByText('0')).toBeInTheDocument(); + }); + + it('should display Requested count from JSON summary', () => { + render(); + expect(screen.getByText('Requested')).toBeInTheDocument(); + expect(screen.getByText('5')).toBeInTheDocument(); + }); + + it('should show executionFailed alert when failedCount > 0', () => { + const failedResult = JSON.stringify({ + requestedCount: 3, purgedCount: 1, purgedDependenciesCount: 0, + failedCount: 2, skippedCount: 0, executionFailed: true, runId: 'test' + }); + const auditData = [{ guid: 'a-fail', operation: 'PURGE', params: '', result: failedResult }]; + + render(); + + expect(screen.getByText('Partial success')).toBeInTheDocument(); + expect(screen.getByText(/Some entities failed to purge/)).toBeInTheDocument(); + }); + + it('should NOT show executionFailed alert when failedCount is 0', () => { + const okResult = JSON.stringify({ + requestedCount: 3, purgedCount: 3, purgedDependenciesCount: 0, + failedCount: 0, skippedCount: 0, executionFailed: false, runId: 'test' + }); + const auditData = [{ guid: 'a-ok', operation: 'PURGE', params: '', result: okResult }]; + + render(); + + expect(screen.queryByText('Partial success')).not.toBeInTheDocument(); + }); + + it('should show PURGE with JSON array result (not object)', () => { + const arrayResult = JSON.stringify(['arr-guid-1', 'arr-guid-2']); + const auditData = [{ guid: 'a-arr', operation: 'PURGE', params: '', result: arrayResult }]; + + render(); + + // Open drawer + fireEvent.click(screen.getAllByText('Purged Entities')[0]); + + expect(screen.getByText('arr-guid-1')).toBeInTheDocument(); + expect(screen.getByText('arr-guid-2')).toBeInTheDocument(); + }); + + it('should handle PURGE with JSON params array', () => { + const auditData = [ + { + guid: 'a-params-arr', + operation: 'PURGE', + params: JSON.stringify(['req-guid-1', 'req-guid-2']), + result: '[purged-guid-1]' + } + ]; + render(); + + // The component should render the purge UI — the card label + const allPurgedEntities = screen.getAllByText('Purged Entities'); + expect(allPurgedEntities.length).toBeGreaterThan(0); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Run ID Display & Copy + // ───────────────────────────────────────────────────────────────────────────── + describe('Run ID display', () => { + // runId is sourced from row.original.runId first, then summary.runId, then auditObj.runId + it('should display Run ID when present on row.original', () => { + const auditData = [{ guid: 'a-rid', operation: 'PURGE', params: '', result: '[guid-1]' }]; + + render( + + ); + + // Run Id appears in the main card header (may also appear in drawer header) + const runIdElements = screen.getAllByText(/run-row-999/); + expect(runIdElements.length).toBeGreaterThan(0); + }); + + it('should display Run ID when present in JSON summary result', () => { + const resultWithRunId = JSON.stringify({ + requestedCount: 2, purgedCount: 2, purgedDependenciesCount: 0, + failedCount: 0, skippedCount: 0, executionFailed: false, runId: 'run-summary-888' + }); + const auditData = [{ guid: 'a-rid2', operation: 'PURGE', params: '', result: resultWithRunId }]; + + render(); + + const runIdElements = screen.getAllByText(/run-summary-888/); + expect(runIdElements.length).toBeGreaterThan(0); + }); + + it('should NOT display Run ID section when runId is N/A', () => { + const auditData = [ + { guid: 'a-no-rid', operation: 'PURGE', params: '', result: '[guid-x]' } + ]; + // No runId on row.original, no runId in summary → defaults to 'N/A' + render(); + + expect(screen.queryByText(/Run Id:/)).not.toBeInTheDocument(); + }); + + it('should show Run Id label and value when runId is present on row', () => { + const auditData = [{ guid: 'a-copy', operation: 'PURGE', params: '', result: '[guid-1]' }]; + + render( + + ); + + const runIdElements = screen.getAllByText(/copy-test-run/); + expect(runIdElements.length).toBeGreaterThan(0); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Drawer — open, close, search, clear, "Showing X of Y" footer + // ───────────────────────────────────────────────────────────────────────────── + describe('Drawer interactions', () => { + it('should open drawer when Purged Entities card is clicked', () => { + const componentProps = { auditData: mockAuditData }; + render(); + + fireEvent.click(screen.getAllByText('Purged Entities')[0]); + + expect(screen.getByTestId('drawer')).toBeInTheDocument(); + }); + + it('should close drawer when X button inside drawer is clicked', () => { + const componentProps = { auditData: mockAuditData }; + render(); + + // Open the drawer — the "Showing X of Y" footer appears when items are shown + fireEvent.click(screen.getAllByText('Purged Entities')[0]); + expect(screen.getByText('Showing 3 of 3')).toBeInTheDocument(); + + // Find the ✕ close button — MUI IconButton textContent includes the ripple span + const allButtons = screen.getAllByRole('button'); + const xButton = allButtons.find(b => b.textContent?.trim().startsWith('✕')); + expect(xButton).toBeDefined(); + fireEvent.click(xButton!); + + // After closing, activePurgeView resets to 'none' so displayItems becomes [] + // The "Showing X of Y" footer disappears because displayTotal drops to 0 + expect(screen.queryByText('Showing 3 of 3')).not.toBeInTheDocument(); + }); + + it('should show "No matching GUIDs found" when search has no match', () => { + const componentProps = { auditData: mockAuditData }; + render(); + + fireEvent.click(screen.getAllByText('Purged Entities')[0]); + + // Search for something that doesn't match + const searchInput = screen.getByPlaceholderText('Search GUIDs...'); + fireEvent.change(searchInput, { target: { value: 'no-such-guid' } }); + + expect(screen.getByText('No matching GUIDs found')).toBeInTheDocument(); + }); + + it('should filter GUIDs by search text', () => { + const componentProps = { auditData: mockAuditData }; + render(); + + fireEvent.click(screen.getAllByText('Purged Entities')[0]); + + // All 3 GUIDs are initially shown + expect(screen.getByText('guid-1')).toBeInTheDocument(); + expect(screen.getByText('guid-2')).toBeInTheDocument(); + expect(screen.getByText('guid-3')).toBeInTheDocument(); + + // Now search for "guid-1" + const searchInput = screen.getByPlaceholderText('Search GUIDs...'); + fireEvent.change(searchInput, { target: { value: 'guid-1' } }); + + expect(screen.getByText('guid-1')).toBeInTheDocument(); + expect(screen.queryByText('guid-2')).not.toBeInTheDocument(); + expect(screen.queryByText('guid-3')).not.toBeInTheDocument(); + }); + + it('should clear search when clear button is clicked', () => { + const componentProps = { auditData: mockAuditData }; + render(); + + fireEvent.click(screen.getAllByText('Purged Entities')[0]); + + const searchInput = screen.getByPlaceholderText('Search GUIDs...'); + + // Type into the search to filter down to guid-1 + fireEvent.change(searchInput, { target: { value: 'guid-1' } }); + expect(screen.getByText('guid-1')).toBeInTheDocument(); + expect(screen.queryByText('guid-2')).not.toBeInTheDocument(); + + // Clear the search by setting value back to empty (simulating the clear ✕ button) + fireEvent.change(searchInput, { target: { value: '' } }); + + // All GUIDs should be visible again + expect(screen.getByText('guid-1')).toBeInTheDocument(); + expect(screen.getByText('guid-2')).toBeInTheDocument(); + expect(screen.getByText('guid-3')).toBeInTheDocument(); + }); + + it('should show "Showing X of Y" footer when there are items', () => { + const componentProps = { auditData: mockAuditData }; + render(); + + fireEvent.click(screen.getAllByText('Purged Entities')[0]); + + // Footer shows Showing 3 of 3 + expect(screen.getByText('Showing 3 of 3')).toBeInTheDocument(); + }); + + it('should show "Limit" label in drawer footer', () => { + const componentProps = { auditData: mockAuditData }; + render(); + + fireEvent.click(screen.getAllByText('Purged Entities')[0]); + + expect(screen.getByText('Limit')).toBeInTheDocument(); + }); + + it('should show "Scroll to load more data" hint when more items available', () => { + // Create a data set where we have more than pageSize items + const manyGuids = Array.from({ length: 15 }, (_, i) => `guid-${i + 1}`).join(','); + const auditData = [ + { guid: 'audit-many', operation: 'PURGE', params: '', result: `[${manyGuids}]` } + ]; + + render(); + fireEvent.click(screen.getAllByText('Purged Entities')[0]); + + // Should show scroll hint since there are 15 items but page size is 10 + expect(screen.getByText('Scroll to load more data')).toBeInTheDocument(); + }); + + it('should display GUID index numbers in the drawer list', () => { + const componentProps = { auditData: mockAuditData }; + render(); + + fireEvent.click(screen.getAllByText('Purged Entities')[0]); + + // Should show "1.", "2.", "3." + expect(screen.getByText('1.')).toBeInTheDocument(); + expect(screen.getByText('2.')).toBeInTheDocument(); + expect(screen.getByText('3.')).toBeInTheDocument(); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Drawer — Purge modal on GUID click + // ───────────────────────────────────────────────────────────────────────────── + describe('Drawer — Purge entity detail modal', () => { + it('should show AuditsTab in modal when a GUID is clicked', async () => { + const componentProps = { auditData: mockAuditData }; + render(); + + fireEvent.click(screen.getAllByText('Purged Entities')[0]); + fireEvent.click(screen.getByText('guid-2')); + + await waitFor(() => { + expect(screen.getByTestId('audits-tab')).toBeInTheDocument(); + expect(screen.getByText('AuditsTab - guid-2')).toBeInTheDocument(); + }); + }); + + it('should update modal title when different GUID is clicked', async () => { + const componentProps = { auditData: mockAuditData }; + render(); + + fireEvent.click(screen.getAllByText('Purged Entities')[0]); + fireEvent.click(screen.getByText('guid-3')); + + await waitFor(() => { + expect(screen.getByTestId('modal-title')).toHaveTextContent('Purged Entity Details: guid-3'); + }); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // PURGE — handleOpenPurgedDrawer guard: totalPurgedCount === 0 + // ───────────────────────────────────────────────────────────────────────────── + describe('PURGE — empty result, no drawer opens', () => { + it('should NOT open drawer when totalPurgedCount is 0', () => { + const auditData = [ + { guid: 'a-zero', operation: 'PURGE', params: '', result: '[]' } + ]; + render(); + + // Click the Purged Entities card — should not open a drawer with items + fireEvent.click(screen.getAllByText('Purged Entities')[0]); + + // No GUIDs shown since total is 0 + expect(screen.queryByText('1.')).not.toBeInTheDocument(); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // PURGE — Limit input (change page size) + // ───────────────────────────────────────────────────────────────────────────── + describe('Drawer — Limit input behaviour', () => { + it('should render the limit input with default value of 10', () => { + const componentProps = { auditData: mockAuditData }; + render(); + + fireEvent.click(screen.getAllByText('Purged Entities')[0]); + + const limitInput = screen.getByDisplayValue('10'); + expect(limitInput).toBeInTheDocument(); + }); + + it('should update limit input value when user types', () => { + const componentProps = { auditData: mockAuditData }; + render(); + + fireEvent.click(screen.getAllByText('Purged Entities')[0]); + + const limitInput = screen.getByDisplayValue('10'); + fireEvent.change(limitInput, { target: { value: '5' } }); + + expect(screen.getByDisplayValue('5')).toBeInTheDocument(); + }); + + it('should apply new limit when Enter is pressed', () => { + const componentProps = { auditData: mockAuditData }; + render(); + + fireEvent.click(screen.getAllByText('Purged Entities')[0]); + + const limitInput = screen.getByDisplayValue('10'); + fireEvent.change(limitInput, { target: { value: '2' } }); + fireEvent.keyDown(limitInput, { key: 'Enter', code: 'Enter' }); + + // Input should be updated (clamped to min of entered value and total) + expect(screen.getByDisplayValue('2')).toBeInTheDocument(); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Summary Row — shows Requested card, Total Purged, Failed, Skipped + // ───────────────────────────────────────────────────────────────────────────── + describe('SUMMARY row', () => { + const summaryAuditData = [ + { + guid: 'audit-sum', + operation: 'PURGE', + params: JSON.stringify(['req-1', 'req-2']), + result: JSON.stringify({ + requestedCount: 2, + purgedCount: 2, + purgedDependenciesCount: 0, + failedCount: 0, + skippedCount: 0, + executionFailed: false, + runId: 'test' + }) + } + ]; + + it('should render Requested, Total Purged, Failed, Skipped cards for SUMMARY row', () => { + render(); + + expect(screen.getByText('Requested')).toBeInTheDocument(); + expect(screen.getByText('Total Purged')).toBeInTheDocument(); + expect(screen.getByText('Failed')).toBeInTheDocument(); + expect(screen.getByText('Skipped')).toBeInTheDocument(); + }); + + it('should NOT show Requested card for non-SUMMARY row', () => { + const nonSummaryData = [ + { guid: 'ns-1', operation: 'PURGE', params: '', result: '[guid-a]' } + ]; + render(); + + expect(screen.queryByText('Requested')).not.toBeInTheDocument(); + expect(screen.queryByText('Total Purged')).not.toBeInTheDocument(); + // Non-summary shows 'Purged Entities' label on the card (first occurrence = card label) + const purgedLabels = screen.getAllByText('Purged Entities'); + // At least the summary card label is present + expect(purgedLabels.length).toBeGreaterThan(0); + }); + + it('should open Requested Entities drawer when Requested card is clicked (SUMMARY row)', () => { + render(); + + // Click the Requested card + fireEvent.click(screen.getByText('Requested')); + + // The drawer should now show "Requested Entities" as its heading + expect(screen.getByText('Requested Entities')).toBeInTheDocument(); + }); + + it('should open Total Purged drawer when Total Purged card is clicked (SUMMARY row)', async () => { + // SUMMARY row click triggers fetchPurged which calls fetch API + // fetch is mocked globally at top of file to return [] + const auditDataForSummary = [{ + guid: 'audit-sum2', + operation: 'PURGE', + params: JSON.stringify(['req-1']), + result: JSON.stringify({ + requestedCount: 1, purgedCount: 1, purgedDependenciesCount: 0, + failedCount: 0, skippedCount: 0, executionFailed: false, runId: 'test' + }) + }]; + render(); + + // Click the Total Purged card — triggers drawer + fetch + fireEvent.click(screen.getByText('Total Purged')); + + // The drawer header should show "Purged Entities" title + await waitFor(() => { + const purgedTitles = screen.getAllByText('Purged Entities'); + expect(purgedTitles.length).toBeGreaterThan(0); + }); + }); + + it('should NOT trigger action when Failed card is clicked (display only)', () => { + render(); + + // Click the Failed card — it is display-only (cursor: default) + fireEvent.click(screen.getByText('Failed')); + + // No drawer should open showing Requested or Purged Entities + expect(screen.queryByText('Requested Entities')).not.toBeInTheDocument(); + }); + + it('should NOT trigger action when Skipped card is clicked (display only)', () => { + render(); + + fireEvent.click(screen.getByText('Skipped')); + + expect(screen.queryByText('Requested Entities')).not.toBeInTheDocument(); + }); + + it('should show AUTO_PURGE SUMMARY row with all 4 cards', () => { + const autoPurgeSummary = [{ + guid: 'audit-ap-sum', + operation: 'AUTO_PURGE', + params: JSON.stringify(['r1', 'r2', 'r3']), + result: JSON.stringify({ + requestedCount: 3, purgedCount: 2, purgedDependenciesCount: 1, + failedCount: 1, skippedCount: 1, executionFailed: true, runId: 'test' + }) + }]; + render(); + + expect(screen.getByText('Requested')).toBeInTheDocument(); + expect(screen.getByText('Total Purged')).toBeInTheDocument(); + expect(screen.getByText('Failed')).toBeInTheDocument(); + expect(screen.getByText('Skipped')).toBeInTheDocument(); + // Also shows execution failed alert + expect(screen.getByText('Partial success')).toBeInTheDocument(); + }); + + it('should show correct count on Total Purged card (purgedCount + purgedDependenciesCount)', () => { + const data = [{ + guid: 'audit-count', + operation: 'PURGE', + params: '', + result: JSON.stringify({ + requestedCount: 10, purgedCount: 6, purgedDependenciesCount: 2, + failedCount: 0, skippedCount: 2, executionFailed: false, runId: 'test' + }) + }]; + render(); + + // Total Purged = 6 + 2 = 8 + expect(screen.getByText('8')).toBeInTheDocument(); + // Skipped = 2 + expect(screen.getByText('2')).toBeInTheDocument(); + }); + }); }); + diff --git a/dashboardv2/public/css/scss/drawer.scss b/dashboardv2/public/css/scss/drawer.scss new file mode 100644 index 00000000000..b96de6ac6a7 --- /dev/null +++ b/dashboardv2/public/css/scss/drawer.scss @@ -0,0 +1,322 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/* Purge Summary Wrapper to match React */ +.purge-summary-wrapper { + background-color: #fafafa; + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 16px; + margin-top: 15px; + margin-bottom: 20px; +} + +.purge-run-id-row { + margin-bottom: 15px; + font-size: 14px; + font-weight: 500; +} + +.purge-run-id-copy { + cursor: pointer; + color: #6b7280; + margin-left: 5px; +} + +.purge-warning-alert { + padding: 10px; + margin-bottom: 15px; +} + +.audit-type-details-title { + word-break: break-word; +} + +.purge-summary-container { + display: flex; + gap: 15px; + margin-top: 15px; + flex-wrap: wrap; +} + +.purge-summary-card { + flex: 1; + min-width: 120px; + padding: 12px; + border-radius: 8px; + border: 1px solid rgba(0, 0, 0, 0.08); + background-color: #fafafa; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + text-align: left; + cursor: default; + + &.clickable { + cursor: pointer; + transition: box-shadow 0.2s; + + &:hover { + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + } + } + + &.card-legacy { + max-width: 250px; + } + + .card-label { + font-size: 11px; + color: #6b7280; + /* textSecondary */ + margin-bottom: 4px; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .card-value { + font-size: 24px; + font-weight: bold; + color: #111827; + /* textPrimary */ + } + + &.card-blue { + background-color: #eff6ff; + border-color: #bfdbfe; + + .card-label, + .card-value { + color: #1d4ed8; + } + } + + &.card-green { + background-color: #f0fdf4; + border-color: #bbf7d0; + + .card-label, + .card-value { + color: #15803d; + } + } + + &.card-red.has-count { + background-color: #fef2f2; + border-color: #fecaca; + + .card-label, + .card-value { + color: #d32f2f; + } + } + + &.card-amber.has-count { + background-color: #fffbeb; + border-color: #fef08a; + + .card-label, + .card-value { + color: #ed6c02; + } + } +} + +/* Drawer styles */ +.drawer-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.4); + z-index: 1030; + display: none; + &.open { display: block; } +} + +.drawer-panel { + position: fixed; + top: 0; + right: -450px; + width: 450px; + height: 100vh; + background-color: #fff; + box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15); + z-index: 1038; + transition: right 0.3s ease; + display: flex; + flex-direction: column; + + &.open { right: 0; } + + .drawer-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 20px; + border-bottom: 1px solid #e5e7eb; + flex-shrink: 0; + h4 { margin: 0; font-size: 16px; font-weight: 600; } + .close-drawer { cursor: pointer; font-size: 18px; color: #6b7280; &:hover { color: #111827; } } + } + + .drawer-body { + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + + .drawer-search, .drawer-run-id { + margin: 15px; /* Even margin all around */ + flex-shrink: 0; + } + + .drawer-search { + padding-bottom: 15px; + border-bottom: 1px solid #e5e7eb; + .search-input-wrapper { + position: relative; + i { position: absolute; left: 12px; top: 10px; color: #9ca3af; } + input { + width: 100%; + padding: 8px 12px 8px 35px; + border: 1px solid #d1d5db; + border-radius: 8px; + &:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 1px #3b82f6; } + } + } + } + + .drawer-run-id { + margin: 15px 15px 0 15px; /* Added margin, 0 bottom */ + background-color: #fafafa; + border: 1px solid rgba(0,0,0,0.08); + border-radius: 8px; + padding: 12px 16px; + display: flex; + justify-content: space-between; + align-items: center; + .run-id-text { font-size: 13px; color: #4b5563; font-weight: 500; } + .run-id-value { color: #6b7280; font-weight: normal; margin-left: 4px; } + i { cursor: pointer; color: #6b7280; } + } + + .drawer-list { + flex: 1; + overflow-y: auto; + min-height: 0; + margin: 0 15px; + padding-right: 5px; + padding-bottom: 60px; /* Ensure enough scroll height to force a scrollbar when Run Id box is missing */ + + /* Custom scrollbar to match modern React UI */ + &::-webkit-scrollbar { + width: 6px; + } + &::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 4px; + } + &::-webkit-scrollbar-thumb { + background: #888; + border-radius: 4px; + } + &::-webkit-scrollbar-thumb:hover { + background: #555; + } + + .drawer-items-list { + list-style-type: none; + padding-left: 0; + margin: 0; + + .drawer-list-item { + border-bottom: 1px solid rgba(0, 0, 0, 0.04); + padding: 8px 0; + display: flex; + align-items: center; + color: #6b7280; + font-size: 13px; + padding-left: 10px; /* Added left padding for spacing */ + + .item-index { + color: #6b7280; + min-width: 24px; + text-align: right; + display: inline-block; + } + + .blue-link { + cursor: pointer; + flex: 1; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + margin-left: 12px; + } + } + } + } + + .drawer-empty, .drawer-loading { + padding: 20px 0; + text-align: center; + color: #6b7280; + } + + .drawer-load-more { + text-align: center; + color: rgba(0,0,0,0.6); + font-style: italic; + margin-top: 15px; + margin-bottom: 15px; + font-size: 12px; + cursor: default; + } + + .drawer-observer { height: 20px; width: 100%; } + } + + .drawer-footer { + padding: 12px 20px; + border-top: 1px solid #e2e8f0; + display: flex; + justify-content: space-between; + align-items: center; + background-color: #fff; + flex-shrink: 0; + .drawer-showing { font-size: 13px; color: #6b7280; } + .drawer-limit { + display: flex; + align-items: center; + font-size: 13px; + color: #4b5563; + input { + width: 50px; + margin-left: 8px; + padding: 4px; + border: 1px solid #d1d5db; + border-radius: 4px; + text-align: center; + } + } + } +} \ No newline at end of file diff --git a/dashboardv2/public/css/scss/style.scss b/dashboardv2/public/css/scss/style.scss index 3932acf1cfb..0957b33808e 100644 --- a/dashboardv2/public/css/scss/style.scss +++ b/dashboardv2/public/css/scss/style.scss @@ -39,4 +39,4 @@ @import "override.scss"; @import "trumbowyg.scss"; @import "texteditor.scss"; -@import "downloads.scss"; \ No newline at end of file +@import "downloads.scss";@import "drawer.scss"; diff --git a/dashboardv2/public/js/templates/audit/DrawerView_tmpl.html b/dashboardv2/public/js/templates/audit/DrawerView_tmpl.html new file mode 100644 index 00000000000..7fd94ca07a2 --- /dev/null +++ b/dashboardv2/public/js/templates/audit/DrawerView_tmpl.html @@ -0,0 +1,58 @@ + + +
    +
    +
    +

    {{title}}

    +
    + +
    +
    +
    + {{#if runId}} +
    + Run Id: {{runId}} + +
    + {{/if}} + +
    +
      +
      + No Data Found +
      +
      + Loading... +
      +
      +
      +
      + +
      \ No newline at end of file diff --git a/dashboardv2/public/js/utils/Utils.js b/dashboardv2/public/js/utils/Utils.js index fb4d850a3dd..7b21a04b243 100644 --- a/dashboardv2/public/js/utils/Utils.js +++ b/dashboardv2/public/js/utils/Utils.js @@ -1395,5 +1395,38 @@ define(['require', 'utils/Globals', 'pnotify', 'utils/Messages', 'utils/Enums', return parts.join('&'); }; + Utils.virtualizeList = function(options) { + var items = options.items || []; + var scrollTop = options.scrollTop || 0; + var itemHeight = options.itemHeight || 37; + var overscan = options.overscan || 10; + var visibleCount = options.visibleCount || 40; + + var totalItems = items.length; + if (totalItems === 0) { + return { + visibleItems: [], + paddingTop: 0, + paddingBottom: 0, + startIndex: 0 + }; + } + + var startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan); + var endIndex = Math.min(totalItems - 1, Math.floor(scrollTop / itemHeight) + visibleCount + overscan); + + var visibleItems = items.slice(startIndex, endIndex + 1); + + var paddingTop = startIndex * itemHeight; + var paddingBottom = Math.max(0, (totalItems - 1 - endIndex) * itemHeight); + + return { + visibleItems: visibleItems, + paddingTop: paddingTop, + paddingBottom: paddingBottom, + startIndex: startIndex + }; + }; + return Utils; }); \ No newline at end of file diff --git a/dashboardv2/public/js/views/audit/AdminAuditTableLayoutView.js b/dashboardv2/public/js/views/audit/AdminAuditTableLayoutView.js index cc56e03cfe2..9cfd94478a7 100644 --- a/dashboardv2/public/js/views/audit/AdminAuditTableLayoutView.js +++ b/dashboardv2/public/js/views/audit/AdminAuditTableLayoutView.js @@ -25,7 +25,7 @@ define(['require', 'utils/CommonViewFunction', 'utils/Enums', 'moment' -], function(require, Backbone, AdminAuditTableLayoutView_tmpl, VEntityList, Utils, UrlLinks, CommonViewFunction, Enums, moment) { +], function (require, Backbone, AdminAuditTableLayoutView_tmpl, VEntityList, Utils, UrlLinks, CommonViewFunction, Enums, moment) { 'use strict'; var AdminAuditTableLayoutView = Backbone.Marionette.LayoutView.extend( @@ -53,21 +53,23 @@ define(['require', }, /** ui events hash */ - events: function() { + events: function () { var events = {}, that = this; events["click " + this.ui.adminPurgedEntityClick] = "onClickAdminPurgedEntity"; events["click " + this.ui.adminAuditEntityDetails] = "showAdminAuditEntity"; - events["click " + this.ui.attrFilter] = function(e) { + events["click [data-id='drawerSummaryTrigger']"] = "onSummaryCardClick"; + events["click [data-id='copyRunIdMain']"] = "onCopyRunIdMain"; + events["click " + this.ui.attrFilter] = function (e) { this.ui.attrFilter.find('.fa-angle-right').toggleClass('fa-angle-down'); this.$('.attributeResultContainer').addClass("overlay"); this.$('.attribute-filter-container, .attr-filter-overlay').toggleClass('hide'); this.onClickAttrFilter(); }; - events["click " + this.ui.attrClose] = function(e) { + events["click " + this.ui.attrClose] = function (e) { that.closeAttributeModel(); }; - events["click " + this.ui.attrApply] = function(e) { + events["click " + this.ui.attrApply] = function (e) { that.okAttrFilterButton(e); }; return events; @@ -76,7 +78,7 @@ define(['require', * intialize a new AdminTableLayoutView Layout * @constructs */ - initialize: function(options) { + initialize: function (options) { _.extend(this, _.pick(options, 'searchTableFilters', 'entityDefCollection', 'enumDefCollection')); this.entityCollection = new VEntityList(); this.limit = 25; @@ -119,33 +121,33 @@ define(['require', this.isFilters = null; this.adminAuditEntityData = {}; }, - onRender: function() { + onRender: function () { this.ui.adminRegion.hide(); this.getAdminCollection(); - this.entityCollection.comparator = function(model) { + this.entityCollection.comparator = function (model) { return -model.get('timestamp'); } this.renderTableLayoutView(); }, - onShow: function() { + onShow: function () { this.$('.fontLoader').show(); this.$('.tableOverlay').show(); }, - bindEvents: function() {}, - closeAttributeModel: function() { + bindEvents: function () { }, + closeAttributeModel: function () { var that = this; that.$('.attributeResultContainer').removeClass("overlay"); that.ui.attrFilter.find('.fa-angle-right').toggleClass('fa-angle-down'); that.$('.attribute-filter-container, .attr-filter-overlay').toggleClass('hide'); }, - onClickAttrFilter: function() { + onClickAttrFilter: function () { var that = this; this.ui.adminRegion.show(); - require(['views/search/QueryBuilderView'], function(QueryBuilderView) { + require(['views/search/QueryBuilderView'], function (QueryBuilderView) { that.RQueryBuilderAdmin.show(new QueryBuilderView({ adminAttrFilters: true, searchTableFilters: that.searchTableFilters, entityDefCollection: that.entityDefCollection, enumDefCollection: that.enumDefCollection })); }); }, - okAttrFilterButton: function(options) { + okAttrFilterButton: function (options) { var that = this, isFilterValidate = true, queryBuilderRef = that.RQueryBuilderAdmin.currentView.ui.builder; @@ -164,18 +166,18 @@ define(['require', that.getAdminCollection(); } }, - getAdminCollection: function(option) { + getAdminCollection: function (option) { var that = this, auditFilters = CommonViewFunction.attributeFilter.generateAPIObj(that.ruleUrl); $.extend(that.entityCollection.queryParams, { auditFilters: that.isFilters ? auditFilters : null, limit: that.entityCollection.queryParams.limit || that.limit, offset: that.entityCollection.queryParams.offset || that.offset, sortBy: "startTime", sortOrder: "DESCENDING" }); var apiObj = { sort: false, data: _.pick(that.entityCollection.queryParams, 'auditFilters', 'limit', 'offset', 'sortBy', 'sortOrder'), - success: function(dataOrCollection, response) { + success: function (dataOrCollection, response) { that.entityCollection.state.pageSize = that.entityCollection.queryParams.limit || 25; that.entityCollection.fullCollection.reset(dataOrCollection, option); }, - complete: function() { + complete: function () { that.$('.fontLoader').hide(); that.$('.tableOverlay').hide(); that.$('.auditTable').show(); @@ -184,20 +186,20 @@ define(['require', } this.entityCollection.getAdminData(apiObj); }, - renderTableLayoutView: function() { + renderTableLayoutView: function () { var that = this; this.ui.showDefault.hide(); - require(['utils/TableLayout'], function(TableLayout) { + require(['utils/TableLayout'], function (TableLayout) { var cols = new Backgrid.Columns(that.getAuditTableColumns()); that.RAuditTableLayoutView.show(new TableLayout(_.extend({}, that.commonTableOptions, { columns: cols }))); }); }, - createTableWithValues: function(tableDetails, isAdminAudit) { + createTableWithValues: function (tableDetails, isAdminAudit) { var attrTable = CommonViewFunction.propertyTable({ scope: this, - getValue: function(val, key) { + getValue: function (val, key) { if (key && key.toLowerCase().indexOf("time") > 0) { return Utils.formatDate({ date: val }); } else { @@ -209,7 +211,7 @@ define(['require', }); return attrTable; }, - getAuditTableColumns: function() { + getAuditTableColumns: function () { var that = this; return this.entityCollection.constructor.getTableCols({ result: { @@ -222,14 +224,14 @@ define(['require', accordion: false, alwaysVisible: true, renderable: true, - isExpandVisible: function(el, model) { + isExpandVisible: function (el, model) { if (Enums.serverAudits[model.get('operation')]) { return false; } else { return true; } }, - expand: function(el, model) { + expand: function (el, model) { var operation = model.get('operation'), results = model.get('result') || null, adminText = 'No records found', @@ -279,7 +281,7 @@ define(['require', renderable: true, editable: false, formatter: _.extend({}, Backgrid.CellFormatter.prototype, { - fromRaw: function(rawValue, model) { + fromRaw: function (rawValue, model) { if (Enums.serverAudits[model.get('operation')]) { return "N/A" } else { @@ -294,7 +296,7 @@ define(['require', renderable: true, editable: false, formatter: _.extend({}, Backgrid.CellFormatter.prototype, { - fromRaw: function(rawValue, model) { + fromRaw: function (rawValue, model) { return Utils.formatDate({ date: rawValue }); } }) @@ -305,7 +307,7 @@ define(['require', renderable: true, editable: false, formatter: _.extend({}, Backgrid.CellFormatter.prototype, { - fromRaw: function(rawValue, model) { + fromRaw: function (rawValue, model) { return Utils.formatDate({ date: rawValue }); } }) @@ -317,7 +319,7 @@ define(['require', editable: false, sortable: false, formatter: _.extend({}, Backgrid.CellFormatter.prototype, { - fromRaw: function(rawValue, model) { + fromRaw: function (rawValue, model) { var startTime = model.get('startTime') ? parseInt(model.get('startTime')) : null, endTime = model.get('endTime') ? parseInt(model.get('endTime')) : null; if (_.isNumber(startTime) && _.isNumber(endTime)) { @@ -331,11 +333,11 @@ define(['require', } }, this.entityCollection); }, - defaultPagination: function() { + defaultPagination: function () { $.extend(this.entityCollection.queryParams, { limit: this.limit, offset: this.offset }); this.renderTableLayoutView(); }, - showAdminAuditEntity: function(e) { + showAdminAuditEntity: function (e) { var typeDefObj = this.adminAuditEntityData[e.target.dataset.auditentityid], typeDetails = this.createTableWithValues(typeDefObj, true), view = '' + typeDetails + '
      ', @@ -349,25 +351,95 @@ define(['require', }; this.showModal(modalData); }, - displayPurgeAndImportAudits: function(obj) { + displayPurgeAndImportAudits: function (obj) { + var adminTypDetails = Enums.category[obj.operation]; + + // If it's a new JSON string (from new API changes), parse it. + var isJson = false; + var summaryData = {}; + try { + summaryData = JSON.parse(obj.results); + if (summaryData && typeof summaryData === 'object' && !Array.isArray(summaryData)) { + isJson = true; + } + } catch (e) { + isJson = false; + } + + if (isJson) { + // It's the new Summary format + var runId = summaryData.runId || obj.model.get('runId') || ''; + var paramsArr = obj.model.get('params') ? obj.model.get('params').split(',') : []; + + var reqCount = summaryData.requestedCount !== undefined ? summaryData.requestedCount : paramsArr.length; + var purgedCount = summaryData.purgedCount !== undefined ? summaryData.purgedCount : 0; + var purgedDependenciesCount = summaryData.purgedDependenciesCount || 0; + var totalPurgedCount = purgedCount + purgedDependenciesCount; + var failedCount = summaryData.failedCount || 0; + var skippedCount = summaryData.skippedCount || 0; + + var html = '
      '; + + html += '
      '; + if (runId) { + html += '
      Run Id: ' + _.escape(runId) + '
      '; + } + if (summaryData.executionFailed) { + html += '
      Partial success: Some entities failed to purge. Check backend logs for details.
      '; + } + + html += '
      '; + + // Requested + html += '
      '; + html += '
      REQUESTED
      ' + reqCount + '
      '; + + // Total Purged + html += '
      0 ? 'data-id="drawerSummaryTrigger" data-type="purged" data-runid="' + _.escape(runId) + '" data-guid="' + _.escape(obj.model.get('guid')) + '"' : '') + '>'; + html += '
      TOTAL PURGED
      ' + totalPurgedCount + '
      '; + + // Failed + html += '
      '; + html += '
      FAILED
      ' + failedCount + '
      '; + + // Skipped + html += '
      '; + html += '
      SKIPPED
      ' + skippedCount + '
      '; + + html += '
      '; + return html; + } + + // Legacy render format var adminValues = '
        ', - guids = null, - adminTypDetails = Enums.category[obj.operation]; + guids = []; if (obj.operation == "PURGE" || obj.operation == "AUTO_PURGE") { guids = obj.results ? obj.results.replace('[', '').replace(']', '').split(',') : guids; + + var legacyPurgedCount = guids.length; + if (legacyPurgedCount === 1 && guids[0] === "") { + legacyPurgedCount = 0; + } + + var html = '
        '; + html += '
        '; + html += '
        0 ? 'data-id="drawerSummaryTrigger" data-type="legacy-purged" data-runid="" data-guid="' + _.escape(obj.model.get('guid')) + '" data-results="' + _.escape(obj.results) + '"' : '') + '>'; + html += '
        PURGED ENTITIES
        ' + legacyPurgedCount + '
        '; + html += '
        '; + return html; } else { guids = obj.model.get('params') ? obj.model.get('params').split(',') : guids; } - _.each(guids, function(adminGuid, index) { + _.each(guids, function (adminGuid, index) { if (index % 5 == 0 && index != 0) { adminValues += '
        '; } adminValues += ''; }) adminValues += '
      '; - return '

      ' + adminTypDetails + '

      ' + adminValues + '
      '; + return '
      ' + adminValues + '
      '; }, - displayExportAudits: function(obj) { + displayExportAudits: function (obj) { var adminValues = "", adminTypDetails = (obj.operation === 'IMPORT') ? Enums.category[obj.operation] : Enums.category[obj.operation] + " And Options", resultData = obj.results ? JSON.parse(obj.results) : null, @@ -380,15 +452,15 @@ define(['require', adminValues += this.showImportExportTable(_.extend(paramsData, { "paramsCount": obj.model.get('paramsCount') })); } adminValues = adminValues ? adminValues : obj.adminText; - return '

      ' + adminTypDetails + '

      ' + adminValues + '
      '; + return '

      ' + adminTypDetails + '

      ' + adminValues + '
      '; }, - showImportExportTable: function(obj, operations) { + showImportExportTable: function (obj, operations) { var that = this, typeDetails = "", view = '
        '; if (operations && operations === "IMPORT") { var importKeys = Object.keys(obj); - _.each(importKeys, function(key, index) { + _.each(importKeys, function (key, index) { var newObj = {}; newObj[key] = obj[key]; if (index % 5 === 0 && index != 0) { @@ -401,17 +473,17 @@ define(['require', } return view += '
      ';; }, - displayCreateUpdateAudits: function(obj) { + displayCreateUpdateAudits: function (obj) { var that = this, resultData = JSON.parse(obj.results), typeName = obj.model ? obj.model.get('params').split(',') : null, typeContainer = ''; - _.each(typeName, function(name) { + _.each(typeName, function (name) { var typeData = resultData[name], adminValues = (typeName.length == 1) ? '
        ' : '
          ', adminTypDetails = Enums.category[name] + " " + Enums.auditAction[obj.operation]; typeContainer += '

          ' + adminTypDetails + '

          '; - _.each(typeData, function(typeDefObj, index) { + _.each(typeData, function (typeDefObj, index) { if (index % 5 == 0 && index != 0 && typeName.length == 1) { adminValues += '
          '; } @@ -425,17 +497,17 @@ define(['require', var typeClass = (typeName.length == 1) ? null : "admin-audit-details"; return '
          ' + typeContainer + '
          '; }, - onClickAdminPurgedEntity: function(e) { + onClickAdminPurgedEntity: function (e) { var that = this; - require(['views/audit/AuditTableLayoutView'], function(AuditTableLayoutView) { + require(['views/audit/AuditTableLayoutView'], function (AuditTableLayoutView) { const titles = { PURGE: "Purged Entity Details", AUTO_PURGE: "Auto Purge Entity Details" }; var obj = { - guid: $(e.target).text(), - titleText: (titles[e.target.dataset.operation] || "Import Details") + ": " - }, + guid: $(e.target).text(), + titleText: (titles[e.target.dataset.operation] || "Import Details") + ": " + }, modalData = { title: obj.titleText + obj.guid, content: new AuditTableLayoutView(obj), @@ -446,17 +518,181 @@ define(['require', that.showModal(modalData); }); }, - showModal: function(modalObj, title) { + onCopyRunIdMain: function (e) { + var $temp = $(""); + $("body").append($temp); + var runIdText = $(e.currentTarget).siblings('[data-id="runIdValue"]').text(); + $temp.val(runIdText).select(); + document.execCommand("copy"); + $temp.remove(); + Utils.notifySuccess({ + content: "Run Id copied to clipboard" + }); + }, + onSummaryCardClick: function (e) { + var that = this; + var $target = $(e.currentTarget); + var type = $target.data('type'); + var runId = $target.data('runid'); + var guid = $target.data('guid'); + var params = $target.data('params'); + var totalCount = parseInt($target.find('.card-value').text(), 10) || 0; + + require(['views/audit/DrawerView'], function (DrawerView) { + if (type === 'requested') { + var items = []; + if (params) { + if (Array.isArray(params)) { + items = params.map(function (item) { + return typeof item === "string" ? item : (item.guid || String(item)); + }); + } else { + try { + var parsedParams = JSON.parse(params); + if (Array.isArray(parsedParams)) { + items = parsedParams.map(function (item) { + return typeof item === "string" ? item : (item.guid || String(item)); + }); + } else if (typeof params === "string") { + items = params.replace(/^\[|\]$/g, "").split(",").map(function (s) { return s.trim(); }).filter(Boolean); + } + } catch (e) { + if (typeof params === "string") { + items = params.replace(/^\[|\]$/g, "").split(",").map(function (s) { return s.trim(); }).filter(Boolean); + } + } + } + } + var drawerView = new DrawerView({ + title: 'Requested Entities', + items: items, + totalCount: items.length, + runId: runId, + actionType: 'requested', + onItemClickCb: function (clickedGuid) { + require(['views/audit/AuditTableLayoutView'], function (AuditTableLayoutView) { + var obj = { + guid: clickedGuid, + titleText: "Entity Audit Details: " + }; + var modalData = { + title: obj.titleText + obj.guid, + content: new AuditTableLayoutView(obj), + mainClass: "modal-full-screen", + okCloses: true, + showFooter: false, + }; + that.showModal(modalData); + }); + } + }); + drawerView.render(); + } else if (type === 'legacy-purged') { + var resultsData = $target.data('results'); + var items = []; + if (resultsData) { + if (Array.isArray(resultsData)) { + items = resultsData.map(function (item) { + return typeof item === "string" ? item : (item.guid || String(item)); + }); + } else { + try { + var parsed = JSON.parse(resultsData); + if (Array.isArray(parsed)) { + items = parsed.map(function (item) { + return typeof item === "string" ? item : (item.guid || String(item)); + }); + } else if (typeof resultsData === "string") { + items = resultsData.replace(/^\[|\]$/g, "").split(",").map(function (s) { return s.trim(); }).filter(Boolean); + } + } catch (e) { + if (typeof resultsData === "string") { + items = resultsData.replace(/^\[|\]$/g, "").split(",").map(function (s) { return s.trim(); }).filter(Boolean); + } + } + } + } + var drawerView = new DrawerView({ + title: 'Purged Entities', + items: items, + totalCount: items.length, + runId: runId, + actionType: 'purged', + onItemClickCb: function (clickedGuid) { + require(['views/audit/AuditTableLayoutView'], function (AuditTableLayoutView) { + var obj = { + guid: clickedGuid, + titleText: "Purged Entity Details: " + }; + var modalData = { + title: obj.titleText + obj.guid, + content: new AuditTableLayoutView(obj), + mainClass: "modal-full-screen", + okCloses: true, + showFooter: false, + }; + that.showModal(modalData); + }); + } + }); + drawerView.render(); + } else if (type === 'purged') { + var drawerView = new DrawerView({ + title: 'Purged Entities', + runId: runId, + totalCount: totalCount, + fetchData: function (limit, offset, cb) { + var url = UrlLinks.baseUrl + '/admin/audit/' + guid + '/purgedEntities?limit=' + limit + '&offset=' + offset; + $.ajax({ + url: url, + type: 'GET', + success: function (response) { + var guids = []; + if (response && Array.isArray(response)) { + guids = response.map(function (item) { + return typeof item === "string" ? item : (item.guid || String(item)); + }); + } + cb(guids); + }, + error: function () { + cb([]); + } + }); + }, + actionType: 'purged', + onItemClickCb: function (clickedGuid) { + require(['views/audit/AuditTableLayoutView'], function (AuditTableLayoutView) { + var obj = { + guid: clickedGuid, + titleText: "Purged Entity Details: " + }; + var modalData = { + title: obj.titleText + obj.guid, + content: new AuditTableLayoutView(obj), + mainClass: "modal-full-screen", + okCloses: true, + showFooter: false, + }; + that.showModal(modalData); + }); + } + }); + drawerView.render(); + } + }); + }, + showModal: function (modalObj, title) { var that = this; require([ 'modules/Modal' - ], function(Modal) { + ], function (Modal) { var modal = new Modal(modalObj).open(); - modal.on('closeModal', function() { + modal.on('closeModal', function () { $('.modal').css({ 'padding-right': '0px !important' }); modal.trigger('cancel'); }); - modal.$el.on('click', 'td a', function() { + modal.$el.on('click', 'td a', function () { modal.trigger('cancel'); }); }); diff --git a/dashboardv2/public/js/views/audit/DrawerView.js b/dashboardv2/public/js/views/audit/DrawerView.js new file mode 100644 index 00000000000..9b7f04e056e --- /dev/null +++ b/dashboardv2/public/js/views/audit/DrawerView.js @@ -0,0 +1,319 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +define(['require', + 'backbone', + 'hbs!tmpl/audit/DrawerView_tmpl', + 'utils/Utils' +], function (require, Backbone, DrawerView_tmpl, Utils) { + 'use strict'; + + var DrawerView = Backbone.Marionette.LayoutView.extend({ + _viewName: 'DrawerView', + template: DrawerView_tmpl, + + ui: { + overlay: ".drawer-overlay", + panel: ".drawer-panel", + closeBtn: "[data-id='closeDrawer']", + searchInput: "[data-id='searchInput']", + drawerList: "[data-id='drawerList']", + drawerItemsList: "[data-id='drawerList']", + drawerScrollRegion: "[data-id='drawerScrollRegion']", + drawerEmpty: "[data-id='drawerEmpty']", + limitInput: "[data-id='limitInput']", + showingText: "[data-id='showingText']", + copyRunId: "[data-id='copyRunId']", + drawerLoading: "[data-id='drawerLoading']", + drawerObserver: "[data-id='drawerObserver']" + }, + + events: function () { + var events = {}; + events["click " + this.ui.closeBtn] = "closeDrawer"; + events["click " + this.ui.overlay] = "closeDrawer"; + events["keyup " + this.ui.searchInput] = "onSearch"; + events["keyup " + this.ui.limitInput] = function (e) { + if (e.keyCode === 13) { + this.onLimitChange(); + } + }; + events["click .blue-link"] = "onItemClick"; + events["click " + this.ui.copyRunId] = "onCopyRunId"; + return events; + }, + + initialize: function (options) { + _.extend(this, _.pick(options, 'title', 'items', 'fetchData', 'onItemClickCb', 'actionType', 'runId', 'totalCount')); + this.limit = 10; + this.searchText = ""; + this.displayItems = []; + this.offset = 0; + this.page = 1; + this.hasMore = true; + this.isLoading = false; + this.scrollTop = 0; + }, + + onRender: function () { + var that = this; + $('body').append(this.$el); + + setTimeout(function () { + that.ui.overlay.addClass('open'); + that.ui.panel.addClass('open'); + }, 10); + + // Scroll events don't bubble, so we must bind directly to the UI element + this.ui.drawerScrollRegion.on('scroll', function () { + var el = this; + that.scrollTop = el.scrollTop; + that.renderList(); // re-render the visible chunk + + var isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 20; + if (isNearBottom && that.hasMore && !that.isLoading) { + if (that.scrollTimer) clearTimeout(that.scrollTimer); + that.scrollTimer = setTimeout(function () { + that.loadMore(); + }, 250); + } + }); + + // Only use IntersectionObserver for requested entities to preserve existing behavior + if (this.actionType !== 'purged') { + this.setupObserver(); + } + + this.loadData(); + }, + + setupObserver: function () { + var that = this; + if (this.observer) { + this.observer.disconnect(); + } + + // Using IntersectionObserver is more reliable than manual scroll math + this.observer = new IntersectionObserver(function (entries) { + if (entries[0].isIntersecting && that.hasMore && !that.isLoading) { + that.loadMore(); + } + }, { + root: this.ui.drawerScrollRegion[0], + rootMargin: '0px', + threshold: 0.1 + }); + + if (this.ui.drawerObserver.length) { + this.observer.observe(this.ui.drawerObserver[0]); + } + }, + + onBeforeDestroy: function () { + if (this.observer) { + this.observer.disconnect(); + } + if (this.scrollTimer) { + clearTimeout(this.scrollTimer); + } + if (this.ui && this.ui.drawerScrollRegion) { + this.ui.drawerScrollRegion.off('scroll'); + } + }, + + loadData: function () { + this.offset = 0; + this.page = 1; + this.hasMore = true; + this.displayItems = []; + + if (this.fetchData) { + // Server-side: paginate via API calls + this.items = []; + } + this.loadMore(); + }, + + loadMore: function () { + var that = this; + if (this.isLoading || !this.hasMore) return; + + this.isLoading = true; + this.ui.drawerLoading.show(); // Show loading indicator + + if (this.fetchData) { + // Server-side pagination via API + this.fetchData(this.limit, this.offset, function (data) { + that.isLoading = false; + that.ui.drawerLoading.hide(); // Hide loading indicator + + if (data && data.length > 0) { + that.items = that.items.concat(data); + that.offset += data.length; + // If we received fewer items than requested, we've likely hit the end + if (data.length < that.limit) { + that.hasMore = false; + } + } else { + that.hasMore = false; + } + that.displayItems = that.items; + that.renderList(); + }); + } else { + // Client-side: Paginate and render + this.isLoading = false; + this.ui.drawerLoading.hide(); + if (this.displayItems.length > 0) { + this.page += 1; + } + this.renderList(); + } + }, + + renderList: function () { + var total = this.totalCount || 0; + + if (!this.fetchData) { + var fullList = this.items || []; + if (this.searchText) { + var lowerSearch = this.searchText.toLowerCase(); + fullList = fullList.filter(function (item) { + return item.toLowerCase().indexOf(lowerSearch) !== -1; + }); + } + total = fullList.length; + this.displayItems = fullList.slice(0, this.page * this.limit); + this.hasMore = this.displayItems.length < fullList.length; + } else if (this.fetchData && this.searchText) { + // Server-side search: still need to rely on offset/limit + // Re-using existing logic + var fullList = this.items || []; + var lowerSearch = this.searchText.toLowerCase(); + fullList = fullList.filter(function (item) { + return item.toLowerCase().indexOf(lowerSearch) !== -1; + }); + this.displayItems = fullList.slice(0, Math.min(this.offset, fullList.length)); + } + + var listHtml = ""; + + var virtualizedData = Utils.virtualizeList({ + items: this.displayItems, + scrollTop: this.scrollTop, + itemHeight: 37, + overscan: 10, + visibleCount: 40 + }); + + if (virtualizedData.paddingTop > 0) { + listHtml += '
          '; + } + + _.each(virtualizedData.visibleItems, function (item, index) { + var globalIndex = virtualizedData.startIndex + index + 1; + listHtml += '
        • ' + globalIndex + '. ' + _.escape(item) + '
        • '; + }); + + if (virtualizedData.paddingBottom > 0) { + listHtml += '
          '; + } + + this.ui.drawerItemsList.html(listHtml); + + // Show 'Scroll to load more' + if (this.hasMore && this.displayItems.length > 0) { + this.ui.drawerItemsList.append('
        • Scroll to load more data
        • '); + } + + this.ui.drawerLoading.hide(); + + if (this.displayItems.length === 0) { + this.ui.drawerEmpty.show(); + } else { + this.ui.drawerEmpty.hide(); + } + + var showing = this.displayItems.length; + if (!this.fetchData) { + this.ui.showingText.text("Showing " + showing + " of " + total); + } else { + this.ui.showingText.text("Showing " + showing + " of " + total); + } + }, + + onSearch: function (e) { + this.searchText = $(e.currentTarget).val().trim(); + this.scrollTop = 0; + this.loadData(); + }, + + onLimitChange: function () { + var newLimit = parseInt(this.ui.limitInput.val(), 10); + var maxLimit = this.totalCount || 0; + if (newLimit > 0) { + if (maxLimit > 0) { + newLimit = Math.min(newLimit, maxLimit); + this.ui.limitInput.val(newLimit); + } + this.limit = newLimit; + this.scrollTop = 0; + this.loadData(); + } + }, + + onItemClick: function (e) { + var guid = $(e.currentTarget).data('guid'); + if (this.onItemClickCb) { + this.onItemClickCb(guid, this.actionType); + } + }, + + onCopyRunId: function () { + var $temp = $(""); + $("body").append($temp); + $temp.val(this.runId).select(); + document.execCommand("copy"); + $temp.remove(); + Utils.notifySuccess({ + content: "Run Id copied to clipboard" + }); + }, + + closeDrawer: function () { + var that = this; + this.ui.overlay.removeClass('open'); + this.ui.panel.removeClass('open'); + + setTimeout(function () { + that.destroy(); + }, 300); + }, + + templateHelpers: function () { + return { + title: this.title || 'Entities', + limit: this.limit, + runId: this.runId, + fetchData: this.fetchData + }; + } + }); + + return DrawerView; +}); diff --git a/dashboardv2/public/js/views/search/QueryBuilderView.js b/dashboardv2/public/js/views/search/QueryBuilderView.js index 0446f424ae0..a5b0c1e1f95 100644 --- a/dashboardv2/public/js/views/search/QueryBuilderView.js +++ b/dashboardv2/public/js/views/search/QueryBuilderView.js @@ -397,6 +397,9 @@ define(['require', } if (auditEntryAttributeDefs) { _.each(auditEntryAttributeDefs, function(attributes) { + if (attributes.name === "auditRowKind") { + return; // Skip backend-only field + } var returnObj = that.getObjDef(attributes, rules_widgets); if (returnObj) { filters.push(returnObj); From 826fcbca022fd29051a1354d565d0fdcd93df5a1 Mon Sep 17 00:00:00 2001 From: Brijesh Bhalala Date: Fri, 31 Jul 2026 11:37:12 +0530 Subject: [PATCH 2/2] ATLAS-5350: Atlas UI: Enhance Purge Audit Results UI --- .../hooks/__tests__/useVirtualization.test.ts | 89 +++ .../Administrator/Audits/AuditResults.tsx | 637 ++++++++++-------- .../Audits/__tests__/AuditResults.test.tsx | 75 ++- dashboardv2/public/css/scss/style.scss | 3 +- .../js/templates/audit/DrawerView_tmpl.html | 12 +- 5 files changed, 512 insertions(+), 304 deletions(-) create mode 100644 dashboard/src/hooks/__tests__/useVirtualization.test.ts diff --git a/dashboard/src/hooks/__tests__/useVirtualization.test.ts b/dashboard/src/hooks/__tests__/useVirtualization.test.ts new file mode 100644 index 00000000000..62ce583568f --- /dev/null +++ b/dashboard/src/hooks/__tests__/useVirtualization.test.ts @@ -0,0 +1,89 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderHook } from '@testing-library/react'; +import { useVirtualization } from '../useVirtualization'; + +describe('useVirtualization', () => { + it('should return empty values when items array is empty', () => { + const { result } = renderHook(() => + useVirtualization({ items: [], scrollTop: 0 }) + ); + + expect(result.current).toEqual({ + visibleItems: [], + paddingTop: 0, + paddingBottom: 0, + startIndex: 0, + }); + }); + + it('should calculate visible items and padding correctly for initial state', () => { + const items = Array.from({ length: 100 }, (_, i) => i); + const { result } = renderHook(() => + useVirtualization({ items, scrollTop: 0, itemHeight: 37, overscan: 10, visibleCount: 40 }) + ); + + // Initial state (scrollTop = 0) + // startIndex = max(0, 0 - 10) = 0 + // endIndex = min(99, 0 + 40 + 10) = 50 + // visibleItems = items.slice(0, 51) + + expect(result.current.startIndex).toBe(0); + expect(result.current.visibleItems.length).toBe(51); + expect(result.current.paddingTop).toBe(0); + + // paddingBottom = (100 - 1 - 50) * 37 = 49 * 37 = 1813 + expect(result.current.paddingBottom).toBe(1813); + }); + + it('should calculate visible items correctly when scrolled down', () => { + const items = Array.from({ length: 100 }, (_, i) => i); + // Scrolled 20 items down: 20 * 37 = 740 + const { result } = renderHook(() => + useVirtualization({ items, scrollTop: 740, itemHeight: 37, overscan: 10, visibleCount: 40 }) + ); + + // startIndex = max(0, 20 - 10) = 10 + // endIndex = min(99, 20 + 40 + 10) = 70 + + expect(result.current.startIndex).toBe(10); + expect(result.current.visibleItems.length).toBe(61); // 70 - 10 + 1 + + // paddingTop = 10 * 37 = 370 + expect(result.current.paddingTop).toBe(370); + + // paddingBottom = (100 - 1 - 70) * 37 = 29 * 37 = 1073 + expect(result.current.paddingBottom).toBe(1073); + }); + + it('should cap end index at total items length', () => { + const items = Array.from({ length: 50 }, (_, i) => i); + // Scrolled way past the bottom + const { result } = renderHook(() => + useVirtualization({ items, scrollTop: 5000, itemHeight: 37, overscan: 10, visibleCount: 40 }) + ); + + // startIndex = max(0, 135 - 10) = 125 + // endIndex = min(49, 135 + 40 + 10) = 49 + // Wait, if startIndex > endIndex, slice will return empty array + + expect(result.current.startIndex).toBe(125); + expect(result.current.visibleItems.length).toBe(0); + expect(result.current.paddingBottom).toBe(0); + }); +}); diff --git a/dashboard/src/views/Administrator/Audits/AuditResults.tsx b/dashboard/src/views/Administrator/Audits/AuditResults.tsx index 4dcd1ec3ae5..f44b5e8f4d2 100644 --- a/dashboard/src/views/Administrator/Audits/AuditResults.tsx +++ b/dashboard/src/views/Administrator/Audits/AuditResults.tsx @@ -20,14 +20,15 @@ import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import SearchIcon from "@mui/icons-material/Search"; import { auditAction, category, AuditOperation, PurgeActiveView } from "@utils/Enum"; import { isEmpty, jsonParse } from "@utils/Utils"; -import { useVirtualization } from '@hooks/useVirtualization'; +import { useVirtualization } from "@hooks/useVirtualization"; import CustomModal from "@components/Modal"; import TypeDefAuditDetailModal from "@components/TypeDefAuditDetailModal"; import { useRef, useState } from "react"; import AuditsTab from "@views/DetailPage/EntityDetailTabs/AuditsTab"; import ImportExportAudits from "./ImportExportAudits"; -import { LightTooltip } from '@components/muiComponents'; - +import { LightTooltip } from "@components/muiComponents"; +import { toast } from "react-toastify"; +import { fetchApi } from "@api/apiMethods/fetchApi"; interface AuditEntry { guid: string; operation: string; @@ -101,7 +102,7 @@ const AuditResults = ({ componentProps, row }: AuditResultsProps) => { typeof item === "string" ? item : (item as { guid?: string }).guid || String(item) ); } - } catch (e) { + } catch (_e) { if (typeof result === "string" && !result.startsWith("{")) { legacyPurgedList = result.replace(/^\[|\]$/g, "").split(",").map(s => s.trim()).filter(Boolean); } @@ -115,7 +116,7 @@ const AuditResults = ({ componentProps, row }: AuditResultsProps) => { } else if (typeof params === "string") { requestedEntitiesList = params.replace(/^\[|\]$/g, "").split(",").map(s => s.trim()).filter(Boolean); } - } catch (e) { + } catch (_e) { requestedEntitiesList = typeof params === "string" ? params.replace(/^\[|\]$/g, "").split(",").map(s => s.trim()).filter(Boolean) : []; @@ -124,7 +125,7 @@ const AuditResults = ({ componentProps, row }: AuditResultsProps) => { } else { try { summary = jsonParse(result) as Record; - } catch (e) { + } catch (_e) { summary = {}; } } @@ -156,16 +157,18 @@ const AuditResults = ({ componentProps, row }: AuditResultsProps) => { setPurgedApiGuids([]); } - fetch(`/api/atlas/admin/audit/${summaryGuid}/purgedEntities?limit=${pageSize}&offset=${offset}`, { + fetchApi(`/api/atlas/admin/audit/${summaryGuid}/purgedEntities?limit=${pageSize}&offset=${offset}`, { + method: "GET", headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' } }) - .then(res => res.ok ? res.json() : []) - .then((data: string[]) => { + .then(res => { + const data = res.data; if (Array.isArray(data)) { setPurgedApiGuids(prev => append ? [...prev, ...data] : data); } + }).catch(() => { + toast.error("Failed to fetch purged entities"); }) - .catch(() => { }) .finally(() => { setLoadingPurgedApi(false); }); @@ -451,294 +454,358 @@ const AuditResults = ({ componentProps, row }: AuditResultsProps) => { {/* Right Side Drawer — server-side pagination for Purged, client-side for Requested */} - {(() => { - // For REQUESTED view: client-side filter + paginate - // For PURGED view: server-side paginate, client-side search on current page - const isPurgedView = activePurgeView === PurgeActiveView.PURGED; - const isServerSidePagination = isPurgedView && isSummaryRow; - - // Determine the raw list for the current view - const rawListForView: string[] = activePurgeView === PurgeActiveView.REQUESTED - ? requestedEntitiesList - : purgedApiGuids; // full list for non-summary, page list for summary - - // Client-side search filter (on current page for purged, on full list for requested) - const filteredList = rawListForView.filter((guidStr: string) => { - if (!drawerSearchText) return true; - return guidStr.toLowerCase().includes(drawerSearchText.trim().toLowerCase()); - }); - - // For client-side pagination (requested OR non-summary purged) - const clientSideItems = !isServerSidePagination - ? filteredList.slice(0, drawerPage * drawerPageSize) - : []; - - const displayItems = isServerSidePagination ? filteredList : clientSideItems; - - const { visibleItems, paddingTop, paddingBottom, startIndex } = useVirtualization({ - items: displayItems, - scrollTop, - itemHeight: 37 - }); - - // ---- Purged: server-side pages ---- - // Server side infinite scroll handles data loading, we just display what we have - const displayTotal = isServerSidePagination ? purgedTotalCount : filteredList.length; - - const handleDrawerScroll = (e: React.UIEvent) => { - setScrollTop(e.currentTarget.scrollTop); - if (drawerScrollTimerRef.current) return; - - // If it's a wheel event and they are scrolling up, ignore it - const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; - const isNearBottom = scrollHeight - scrollTop - clientHeight < 20; - if (isNearBottom) { - drawerScrollTimerRef.current = setTimeout(() => { - drawerScrollTimerRef.current = null; - if (isServerSidePagination && !loadingPurgedApi && purgedApiGuids.length < displayTotal) { - fetchPurged(true); - } else if (!isServerSidePagination && visibleItems.length < displayTotal) { - setDrawerPage(prev => prev + 1); - } - }, 250); - } - }; + + + ) : null} + + ); +}; - return ( - { - setActivePurgeView(PurgeActiveView.NONE); - setDrawerSearchText(''); - setDrawerPage(1); - setScrollTop(0); - }} - PaperProps={{ sx: { width: '460px', p: 2.5, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' } }} - > - {/* 1. Drawer Header */} - - - {activePurgeView === PurgeActiveView.REQUESTED ? 'Requested Entities' : 'Purged Entities'} - - { - setActivePurgeView(PurgeActiveView.NONE); - setDrawerSearchText(''); - setDrawerPage(1); - setScrollTop(0); - }} - size="small" - > + +interface PurgeEntitiesDrawerProps { + activePurgeView: PurgeActiveView; + setActivePurgeView: (view: PurgeActiveView) => void; + isSummaryRow: boolean; + requestedEntitiesList: string[]; + purgedApiGuids: string[]; + drawerSearchText: string; + setDrawerSearchText: (text: string) => void; + drawerPage: number; + setDrawerPage: React.Dispatch>; + drawerPageSize: number; + setDrawerPageSize: React.Dispatch>; + scrollTop: number; + setScrollTop: (top: number) => void; + purgedTotalCount: number; + drawerScrollTimerRef: React.MutableRefObject | null>; + loadingPurgedApi: boolean; + fetchPurged: (append: boolean, limitOverride?: number) => void; + runId: string; + copiedRunId: boolean; + setCopiedRunId: (copied: boolean) => void; + setOpenPurgeModal: (open: boolean) => void; + setCurrentPurgeResultObj: (guid: string) => void; + drawerPageSizeInput: string; + setDrawerPageSizeInput: (input: string) => void; +} + +const PurgeEntitiesDrawer: React.FC = ({ + activePurgeView, + setActivePurgeView, + isSummaryRow, + requestedEntitiesList, + purgedApiGuids, + drawerSearchText, + setDrawerSearchText, + drawerPage, + setDrawerPage, + drawerPageSize, + setDrawerPageSize, + scrollTop, + setScrollTop, + purgedTotalCount, + drawerScrollTimerRef, + loadingPurgedApi, + fetchPurged, + runId, + copiedRunId, + setCopiedRunId, + setOpenPurgeModal, + setCurrentPurgeResultObj, + drawerPageSizeInput, + setDrawerPageSizeInput, +}) => { + const isPurgedView = activePurgeView === PurgeActiveView.PURGED; + const isServerSidePagination = isPurgedView && isSummaryRow; + + const rawListForView: string[] = activePurgeView === PurgeActiveView.REQUESTED + ? requestedEntitiesList + : purgedApiGuids; + + const filteredList = rawListForView.filter((guidStr: string) => { + if (!drawerSearchText) return true; + return guidStr.toLowerCase().includes(drawerSearchText.trim().toLowerCase()); + }); + + const clientSideItems = !isServerSidePagination + ? filteredList.slice(0, drawerPage * drawerPageSize) + : []; + + const displayItems = isServerSidePagination ? filteredList : clientSideItems; + + const { visibleItems, paddingTop, paddingBottom, startIndex } = useVirtualization({ + items: displayItems, + scrollTop, + itemHeight: 37 + }); + + const displayTotal = isServerSidePagination ? purgedTotalCount : filteredList.length; + + const handleDrawerScroll = (e: React.UIEvent) => { + setScrollTop(e.currentTarget.scrollTop); + if (drawerScrollTimerRef.current) return; + + const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; + const isNearBottom = scrollHeight - scrollTop - clientHeight < 20; + if (isNearBottom) { + drawerScrollTimerRef.current = setTimeout(() => { + drawerScrollTimerRef.current = null; + if (isServerSidePagination && !loadingPurgedApi && purgedApiGuids.length < displayTotal) { + fetchPurged(true); + } else if (!isServerSidePagination && visibleItems.length < displayTotal) { + setDrawerPage(prev => prev + 1); + } + }, 250); + } + }; + + return ( + { + setActivePurgeView(PurgeActiveView.NONE); + setDrawerSearchText(''); + setDrawerPage(1); + setScrollTop(0); + }} + PaperProps={{ sx: { width: '460px', p: 2.5, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' } }} + > + + + {activePurgeView === PurgeActiveView.REQUESTED ? 'Requested Entities' : 'Purged Entities'} + + { + setActivePurgeView(PurgeActiveView.NONE); + setDrawerSearchText(''); + setDrawerPage(1); + setScrollTop(0); + }} + size="small" + > + ✕ + + + + {runId !== 'N/A' && ( + + + Run Id: {runId} + + + { + if (navigator.clipboard) { + navigator.clipboard.writeText(runId); + } else { + const textField = document.createElement('textarea'); + textField.innerText = runId; + document.body.appendChild(textField); + textField.select(); + document.execCommand('copy'); + textField.remove(); + } + setCopiedRunId(true); + setTimeout(() => setCopiedRunId(false), 2000); + }} + sx={{ p: 0.5, ml: 'auto' }} + > + + + + + )} + + {(activePurgeView === 'requested' ? requestedEntitiesList.length > 0 : purgedApiGuids.length > 0 || loadingPurgedApi) && ( + + { + setDrawerSearchText(e.target.value); + setDrawerPage(1); + setScrollTop(0); + }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: drawerSearchText ? ( + + { + setDrawerSearchText(''); + setDrawerPage(1); + setScrollTop(0); + }}> ✕ - - - {/* 2. Run Id Header inside Drawer */} - {runId !== 'N/A' && ( - - - Run Id: {runId} - - - { - if (navigator.clipboard) { - navigator.clipboard.writeText(runId); - } else { - const textField = document.createElement('textarea'); - textField.innerText = runId; - document.body.appendChild(textField); - textField.select(); - document.execCommand('copy'); - textField.remove(); - } - setCopiedRunId(true); - setTimeout(() => setCopiedRunId(false), 2000); - }} - sx={{ p: 0.5, ml: 'auto' }} - > - - - - - )} - - {/* 3. Search Bar */} - {(activePurgeView === 'requested' ? requestedEntitiesList.length > 0 : purgedApiGuids.length > 0 || loadingPurgedApi) && ( - - { - setDrawerSearchText(e.target.value); - setDrawerPage(1); - setScrollTop(0); - // For purged view: re-fetch page 1 with new search is not supported server-side; - // search is applied client-side on the current page's data - }} - InputProps={{ - startAdornment: ( - - - - ), - endAdornment: drawerSearchText ? ( - - { - setDrawerSearchText(''); - setDrawerPage(1); - setScrollTop(0); - }}> - ✕ - - - ) : null - }} - sx={{ - '& .MuiOutlinedInput-root': { - borderRadius: 2, - bgcolor: '#fff' - } - }} - /> - - )} + + ) : null + }} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: 2, + bgcolor: '#fff' + } + }} + /> + + )} - + - {/* 4. GUID List */} - {isPurgedView && loadingPurgedApi && purgedApiGuids.length === 0 ? ( - - - - ) : ( - - {(() => { - if (displayItems.length === 0) { - return ( - - No matching GUIDs found - - ); - } + {isPurgedView && loadingPurgedApi && purgedApiGuids.length === 0 ? ( + + + + ) : ( + + {(() => { + if (displayItems.length === 0) { + return ( + + No matching GUIDs found + + ); + } - return ( - <> - {paddingTop > 0 &&
          } - {visibleItems.map((guidStr: string, localIndex: number) => { - const index = startIndex + localIndex; - const globalIndex = index + 1; - return ( - - {globalIndex}. - { - setOpenPurgeModal(true); - setCurrentPurgeResultObj(guidStr); - }} - title={guidStr} - sx={{ - display: "inline-block", - maxWidth: "100%", - textOverflow: "ellipsis", - overflow: "hidden", - whiteSpace: "nowrap", - textAlign: "left" - }} - > - {guidStr} - - - ); - })} - {paddingBottom > 0 &&
          } - - ); - })()} - - {displayItems.length > 0 && displayItems.length < displayTotal && ( - - {isPurgedView && loadingPurgedApi && } - - {isPurgedView && loadingPurgedApi ? 'Loading more...' : 'Scroll to load more data'} - - - )} - - )} - - {/* 5. Relationship-card-style Footer: Showing X of Y | Limit [input] */} - {displayTotal > 0 && ( - - - Showing {displayItems.length} of {displayTotal} - - - Limit - ) => { - setDrawerPageSizeInput(e.target.value); - }} - onKeyDown={(e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - const parsed = parseInt((e.target as HTMLInputElement).value, 10); - if (Number.isFinite(parsed) && parsed > 0) { - const clamped = Math.min(parsed, displayTotal); - setDrawerPageSize(clamped); - setDrawerPageSizeInput(String(clamped)); - setDrawerPage(1); - if (isServerSidePagination) { - fetchPurged(false, clamped); - } - } - } + return ( + <> + {paddingTop > 0 &&
          } + {visibleItems.map((guidStr: string, localIndex: number) => { + const index = startIndex + localIndex; + const globalIndex = index + 1; + return ( + + {globalIndex}. + { + setOpenPurgeModal(true); + setCurrentPurgeResultObj(guidStr); }} + title={guidStr} sx={{ - width: '56px', - fontSize: '12px', - border: '1px solid #cbd5e1', - borderRadius: '4px', - px: 0.75, - py: 0.25, - textAlign: 'center', - outline: 'none', - '&:focus': { borderColor: '#90caf9' } + display: "inline-block", + maxWidth: "100%", + textOverflow: "ellipsis", + overflow: "hidden", + whiteSpace: "nowrap", + textAlign: "left" }} - /> - - - )} - + > + {guidStr} + + + ); + })} + {paddingBottom > 0 &&
          } + ); })()} + + {displayItems.length > 0 && displayItems.length < displayTotal && ( + + {isPurgedView && loadingPurgedApi && } + + {isPurgedView && loadingPurgedApi ? 'Loading more...' : 'Scroll to load more data'} + + + )} + + )} + + {displayTotal > 0 && ( + + + Showing {displayItems.length} of {displayTotal} + + + Limit + ) => { + setDrawerPageSizeInput(e.target.value); + }} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + const parsed = parseInt((e.target as HTMLInputElement).value, 10); + if (Number.isFinite(parsed) && parsed > 0) { + const clamped = Math.min(parsed, displayTotal); + setDrawerPageSize(clamped); + setDrawerPageSizeInput(String(clamped)); + setDrawerPage(1); + if (isServerSidePagination) { + fetchPurged(false, clamped); + } + } + } + }} + sx={{ + width: '56px', + fontSize: '12px', + border: '1px solid #cbd5e1', + borderRadius: '4px', + px: 0.75, + py: 0.25, + textAlign: 'center', + outline: 'none', + '&:focus': { borderColor: '#90caf9' } + }} + /> + - ) : null} - + )} + ); }; export default AuditResults; -// trigger hmr diff --git a/dashboard/src/views/Administrator/Audits/__tests__/AuditResults.test.tsx b/dashboard/src/views/Administrator/Audits/__tests__/AuditResults.test.tsx index cdd557434f6..5067973708a 100644 --- a/dashboard/src/views/Administrator/Audits/__tests__/AuditResults.test.tsx +++ b/dashboard/src/views/Administrator/Audits/__tests__/AuditResults.test.tsx @@ -28,6 +28,8 @@ import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import AuditResults from '../AuditResults'; +import { toast } from 'react-toastify'; +jest.mock('react-toastify', () => ({ toast: { error: jest.fn() } })); // Mock dependencies const mockIsEmpty = jest.fn((val: any) => { @@ -56,13 +58,11 @@ jest.mock('@utils/Utils', () => ({ jsonParse: (...args: any[]) => mockJsonParse(...args) })); -// Mock global fetch so tests that trigger API calls don't crash -global.fetch = jest.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve([]) - }) -) as jest.Mock; +// Mock fetchApi so tests that trigger API calls don't crash +jest.mock('@api/apiMethods/fetchApi', () => ({ + fetchApi: jest.fn(() => Promise.resolve({ data: [] })) +})); +import { fetchApi } from '@api/apiMethods/fetchApi'; // Mock Enum jest.mock('@utils/Enum', () => ({ @@ -152,7 +152,7 @@ jest.mock('@mui/material', () => { {children} ), - Drawer: ({ children, open, ...props }: any) => open ?
          {children}
          : null, + Drawer: ({ children, open, PaperProps, ...props }: any) => open ?
          {children}
          : null, List: ({ children, ...props }: any) =>
            {children}
          , ListItem: ({ children, ...props }: any) =>
        • {children}
        • , ListItemText: ({ primary, ...props }: any) =>
          {primary}
          , @@ -216,9 +216,9 @@ describe('AuditResults Component', () => { beforeEach(() => { jest.clearAllMocks(); - // Reset fetch mock before each test - (global.fetch as jest.Mock).mockImplementation(() => - Promise.resolve({ ok: true, json: () => Promise.resolve([]) }) + // Reset fetchApi mock before each test + (fetchApi as jest.Mock).mockImplementation(() => + Promise.resolve({ data: [] }) ); mockIsEmpty.mockImplementation((val: any) => { if (val === null || val === undefined || val === '') return true; @@ -1371,5 +1371,56 @@ describe('AuditResults Component', () => { expect(screen.getByText('2')).toBeInTheDocument(); }); }); -}); + describe('New Purge Drawer Features', () => { + it('should trigger toast.error on API failure when fetching purged entities', async () => { + const componentProps = { + auditData: [{ + guid: 'audit-purge-api-fail', + operation: 'PURGE', + params: '', + result: JSON.stringify({ summary: { purgedCount: 5, runId: 'run-123' } }) + }] + }; + const row = { original: { guid: 'audit-purge-api-fail' } }; + + // Mock fetch failure + (fetchApi as jest.Mock).mockImplementationOnce(() => Promise.reject(new Error('API failed'))); + + render(); + + const purgedCard = screen.getByText('Total Purged'); + fireEvent.click(purgedCard); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Failed to fetch purged entities'); + }); + }); + + it('should copy Run ID to clipboard', async () => { + const componentProps = { + auditData: [{ + guid: 'audit-purge-runid', + operation: 'PURGE', + params: '', + result: JSON.stringify({ summary: { purgedCount: 5, runId: 'run-456' } }) + }] + }; + const row = { original: { guid: 'audit-purge-runid' } }; + + const mockClipboard = { + writeText: jest.fn() + }; + Object.assign(navigator, { + clipboard: mockClipboard + }); + + render(); + + const copyButton = screen.getAllByTestId('ContentCopyIcon')[0].closest('button'); + fireEvent.click(copyButton!); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('run-456'); + }); + }); +}); diff --git a/dashboardv2/public/css/scss/style.scss b/dashboardv2/public/css/scss/style.scss index 0957b33808e..7cdac0f9d1b 100644 --- a/dashboardv2/public/css/scss/style.scss +++ b/dashboardv2/public/css/scss/style.scss @@ -39,4 +39,5 @@ @import "override.scss"; @import "trumbowyg.scss"; @import "texteditor.scss"; -@import "downloads.scss";@import "drawer.scss"; +@import "downloads.scss"; +@import "drawer.scss"; diff --git a/dashboardv2/public/js/templates/audit/DrawerView_tmpl.html b/dashboardv2/public/js/templates/audit/DrawerView_tmpl.html index 7fd94ca07a2..47e9d8986cc 100644 --- a/dashboardv2/public/js/templates/audit/DrawerView_tmpl.html +++ b/dashboardv2/public/js/templates/audit/DrawerView_tmpl.html @@ -19,21 +19,21 @@

          {{title}}

          -
          - +
          +
          {{#if runId}}
          Run Id: {{runId}} - +
          {{/if}}
          @@ -52,7 +52,7 @@

          {{title}}

          Showing 0 of 0
          - Limit + Limit
          \ No newline at end of file