From cf3bd93952bbf0a7b1b8f3c2cb3f7d6d6b7dc5f8 Mon Sep 17 00:00:00 2001 From: Jeremy Foster Date: Mon, 18 Mar 2024 16:11:35 -0700 Subject: [PATCH] HOSTSD-237 Export to Excel --- .../Controllers/ServerItemController.cs | 67 ++++++++++- src/api/Helpers/IXlsExporter.cs | 8 ++ src/api/Helpers/XlsExporter.cs | 109 ++++++++++++++++++ .../server-items/history/export/route.ts | 6 + src/dashboard/src/app/client/servers/page.tsx | 16 ++- src/dashboard/src/app/hsb/servers/page.tsx | 16 ++- .../allocationTable/AllocationTable.tsx | 23 +++- .../bar/allocationByOS/AllocationByOS.tsx | 11 +- .../AllocationByStorageVolume.tsx | 19 ++- .../segmentedBarChart/SegmentedBarChart.tsx | 19 ++- .../charts/bar/smallBar/SmallBarChart.tsx | 8 +- .../allOrganizations/AllOrganizations.tsx | 19 ++- .../src/components/charts/line/LineChart.tsx | 13 ++- .../storageTrends/StorageTrendsChart.tsx | 47 ++++---- .../src/components/dashboard/Dashboard.tsx | 77 ++++++++++++- .../hooks/api/interfaces/IServerItemFilter.ts | 2 + .../src/hooks/api/useApiServerItems.ts | 27 ++++- src/dashboard/src/hooks/index.ts | 1 + src/dashboard/src/hooks/useDownload.ts | 34 ++++++ src/dashboard/src/styles/_globals.scss | 1 - .../dal/Services/IServerHistoryItemService.cs | 8 +- src/libs/dal/Services/IServerItemService.cs | 4 +- .../dal/Services/ServerHistoryItemService.cs | 76 ++++++++++-- src/libs/dal/Services/ServerItemService.cs | 24 +++- src/libs/models/Filters/ServerItemFilter.cs | 8 ++ 25 files changed, 567 insertions(+), 76 deletions(-) create mode 100644 src/dashboard/src/app/api/dashboard/server-items/history/export/route.ts create mode 100644 src/dashboard/src/hooks/useDownload.ts diff --git a/src/api/Areas/Dashboard/Controllers/ServerItemController.cs b/src/api/Areas/Dashboard/Controllers/ServerItemController.cs index fd5bfb41..498d7a1d 100644 --- a/src/api/Areas/Dashboard/Controllers/ServerItemController.cs +++ b/src/api/Areas/Dashboard/Controllers/ServerItemController.cs @@ -214,8 +214,6 @@ public IActionResult FindHistory() } } - // TODO: Complete functionality - // TODO: Limit based on role and tenant. /// /// Export the server items to Excel. /// @@ -228,11 +226,72 @@ public IActionResult FindHistory() [ProducesResponseType((int)HttpStatusCode.OK)] [ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)] [SwaggerOperation(Tags = new[] { "Server Item" })] - public IActionResult Export(string format, string name = "service-now") + public IActionResult Export(string format = "excel", string name = "service-now") { + var uri = new Uri(this.Request.GetDisplayUrl()); + var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); + var filter = new HSB.Models.Filters.ServerItemFilter(query); + if (format == "excel") { - var items = _serverItemService.Find(a => true); + IEnumerable items; + var isHSB = this.User.HasClientRole(ClientRole.HSB); + if (isHSB) + { + items = _serverItemService.Find(filter, true); + } + else + { + var user = _authorization.GetUser(); + if (user == null) return Forbid(); + items = _serverItemService.FindForUser(user.Id, filter, true); + } + + var workbook = _exporter.GenerateExcel(name, items); + + using var stream = new MemoryStream(); + workbook.Write(stream); + var bytes = stream.ToArray(); + + return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + } + + throw new NotImplementedException("Format 'csv' not implemented yet"); + } + + /// + /// Export the server history items to Excel. + /// + /// + /// + /// + /// + [HttpGet("history/export")] + [Produces("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")] + [ProducesResponseType((int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)] + [SwaggerOperation(Tags = new[] { "Server Item" })] + public IActionResult ExportHistory(string format = "excel", string name = "service-now") + { + var uri = new Uri(this.Request.GetDisplayUrl()); + var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); + var filter = new HSB.Models.Filters.ServerHistoryItemFilter(query); + + if (format == "excel") + { + IEnumerable items; + var isHSB = this.User.HasClientRole(ClientRole.HSB); + if (isHSB) + { + items = _serverHistoryItemService.FindHistoryByMonth(filter.StartDate ?? DateTime.UtcNow, filter.EndDate, filter.TenantId, filter.OrganizationId, filter.OperatingSystemItemId, filter.ServiceNowKey, true); + } + else + { + var user = _authorization.GetUser(); + if (user == null) return Forbid(); + items = _serverHistoryItemService.FindHistoryByMonthForUser(user.Id, filter.StartDate ?? DateTime.UtcNow, filter.EndDate, filter.TenantId, filter.OrganizationId, filter.OperatingSystemItemId, filter.ServiceNowKey, true); + } + var workbook = _exporter.GenerateExcel(name, items); using var stream = new MemoryStream(); diff --git a/src/api/Helpers/IXlsExporter.cs b/src/api/Helpers/IXlsExporter.cs index 38c00608..b26b1566 100644 --- a/src/api/Helpers/IXlsExporter.cs +++ b/src/api/Helpers/IXlsExporter.cs @@ -31,5 +31,13 @@ public interface IXlsExporter /// /// XSSFWorkbook GenerateExcel(string sheetName, IEnumerable items); + + /// + /// + /// + /// + /// + /// + XSSFWorkbook GenerateExcel(string sheetName, IEnumerable items); #endregion } diff --git a/src/api/Helpers/XlsExporter.cs b/src/api/Helpers/XlsExporter.cs index 2c501d97..3b9a5a6b 100644 --- a/src/api/Helpers/XlsExporter.cs +++ b/src/api/Helpers/XlsExporter.cs @@ -80,9 +80,118 @@ public XSSFWorkbook GenerateExcel(string sheetName, IEnumerable + /// + /// + /// + /// + /// + /// + public XSSFWorkbook GenerateExcel(string sheetName, IEnumerable items) + { + if (items == null) throw new ArgumentNullException(nameof(items)); + + var workbook = new XSSFWorkbook(); + var sheet = (XSSFSheet)workbook.CreateSheet(sheetName); + var rowIndex = 0; + + // Header + AddHeaders(sheet.CreateRow(rowIndex++), new[] { "Date", "Tenant", "Org Code", "Organization", "ServiceNowKey", "Name", "OS", "Capacity (bytes)", "Available Space (bytes)" }); + + // Content + foreach (var item in items) + { + AddContent(workbook, sheet.CreateRow(rowIndex++), item); + } + + return workbook; + } + + private static void AddHeaders(IRow row, string[] labels) + { + for (var i = 0; i < labels.Length; i++) + { + var cell = row.CreateCell(i); + cell.SetCellValue(labels[i]); + } + } + + private static void AddContent(IWorkbook workbook, IRow row, Entities.ServerItem item) + { + var numberStyle = workbook.CreateCellStyle(); + var numberFormat = workbook.CreateDataFormat().GetFormat("#,#0"); + numberStyle.DataFormat = numberFormat; + + var cell0 = row.CreateCell(0); + cell0.SetCellValue(item.Tenant?.Code); + var cell1 = row.CreateCell(1); + cell1.SetCellValue(item.Organization?.Code); + var cell2 = row.CreateCell(2); + cell2.SetCellValue(item.Organization?.Name); + var cell3 = row.CreateCell(3); + cell3.SetCellValue(item.ServiceNowKey); + var cell4 = row.CreateCell(4); + cell4.SetCellValue(item.Name); + var cell5 = row.CreateCell(5); + cell5.SetCellValue(item.OperatingSystemItem?.Name); + var cell6 = row.CreateCell(6); + cell6.SetCellType(CellType.Numeric); + cell6.CellStyle = numberStyle; + cell6.SetCellValue(item.Capacity ?? 0); + var cell7 = row.CreateCell(7); + cell7.SetCellType(CellType.Numeric); + cell7.CellStyle = numberStyle; + cell7.SetCellValue(item.AvailableSpace ?? 0); + } + + private static void AddContent(IWorkbook workbook, IRow row, Entities.ServerHistoryItem item) + { + var numberStyle = workbook.CreateCellStyle(); + var numberFormat = workbook.CreateDataFormat().GetFormat("#,#0"); + numberStyle.DataFormat = numberFormat; + + var dateStyle = workbook.CreateCellStyle(); + var dateFormat = workbook.CreateDataFormat().GetFormat("yyyy/MM/dd"); + dateStyle.DataFormat = dateFormat; + + var cell0 = row.CreateCell(0); + cell0.SetCellValue(item.CreatedOn.Date); + cell0.CellStyle = dateStyle; + var cell1 = row.CreateCell(1); + cell1.SetCellValue(item.Tenant?.Code); + var cell2 = row.CreateCell(2); + cell2.SetCellValue(item.Organization?.Code); + var cell3 = row.CreateCell(3); + cell3.SetCellValue(item.Organization?.Name); + var cell4 = row.CreateCell(4); + cell4.SetCellValue(item.ServiceNowKey); + var cell5 = row.CreateCell(5); + cell5.SetCellValue(item.Name); + var cell6 = row.CreateCell(6); + cell6.SetCellValue(item.OperatingSystemItem?.Name); + var cell7 = row.CreateCell(7); + cell7.SetCellType(CellType.Numeric); + cell7.CellStyle = numberStyle; + cell7.SetCellValue(item.Capacity ?? 0); + var cell8 = row.CreateCell(8); + cell8.SetCellType(CellType.Numeric); + cell8.CellStyle = numberStyle; + cell8.SetCellValue(item.AvailableSpace ?? 0); + } #endregion #region Helpers diff --git a/src/dashboard/src/app/api/dashboard/server-items/history/export/route.ts b/src/dashboard/src/app/api/dashboard/server-items/history/export/route.ts new file mode 100644 index 00000000..6876ca4f --- /dev/null +++ b/src/dashboard/src/app/api/dashboard/server-items/history/export/route.ts @@ -0,0 +1,6 @@ +import { dispatch } from '@/app/api/utils'; + +export async function GET(req: Request, context: { params: any }) { + const url = new URL(req.url); + return await dispatch(`/v1/dashboard/server-items/history/export${url.search}`); +} diff --git a/src/dashboard/src/app/client/servers/page.tsx b/src/dashboard/src/app/client/servers/page.tsx index fabda785..d80c18b8 100644 --- a/src/dashboard/src/app/client/servers/page.tsx +++ b/src/dashboard/src/app/client/servers/page.tsx @@ -2,7 +2,7 @@ import { AllocationTable, useDashboardFilter } from '@/components'; import { LoadingAnimation } from '@/components/loadingAnimation'; -import { useAuth } from '@/hooks'; +import { useApiServerItems, useAuth } from '@/hooks'; import { useOperatingSystemItems, useOrganizations, @@ -11,6 +11,7 @@ import { } from '@/hooks/lists'; import { useFilteredStore } from '@/store'; import { redirect, useRouter } from 'next/navigation'; +import { toast } from 'react-toastify'; export default function Page() { const router = useRouter(); @@ -27,6 +28,7 @@ export default function Page() { const setFilteredValues = useFilteredStore((state) => state.setValues); const setFilteredOrganizations = useFilteredStore((state) => state.setOrganizations); const setFilteredServerItems = useFilteredStore((state) => state.setServerItems); + const { download } = useApiServerItems(); const updateDashboard = useDashboardFilter(); @@ -74,6 +76,18 @@ export default function Page() { router.push(`/client/dashboard?serverItem=${serverItem?.serviceNowKey}`); } }} + showExport + onExport={async (search) => { + try { + await download({ + search: search ? search : undefined, + }); + } catch (ex) { + const error = ex as Error; + toast.error('Failed to download data. ' + error.message); + console.error(error); + } + }} /> ); } diff --git a/src/dashboard/src/app/hsb/servers/page.tsx b/src/dashboard/src/app/hsb/servers/page.tsx index 0268e46a..84e80e50 100644 --- a/src/dashboard/src/app/hsb/servers/page.tsx +++ b/src/dashboard/src/app/hsb/servers/page.tsx @@ -2,7 +2,7 @@ import { AllocationTable, useDashboardFilter } from '@/components'; import { LoadingAnimation } from '@/components/loadingAnimation'; -import { useAuth } from '@/hooks'; +import { useApiServerItems, useAuth } from '@/hooks'; import { useOperatingSystemItems, useOrganizations, @@ -11,6 +11,7 @@ import { } from '@/hooks/lists'; import { useFilteredStore } from '@/store'; import { redirect, useRouter } from 'next/navigation'; +import { toast } from 'react-toastify'; export default function Page() { const router = useRouter(); @@ -27,6 +28,7 @@ export default function Page() { const setFilteredValues = useFilteredStore((state) => state.setValues); const setFilteredOrganizations = useFilteredStore((state) => state.setOrganizations); const setFilteredServerItems = useFilteredStore((state) => state.setServerItems); + const { download } = useApiServerItems(); const updateDashboard = useDashboardFilter(); @@ -74,6 +76,18 @@ export default function Page() { router.push(`/hsb/dashboard?serverItem=${serverItem?.serviceNowKey}`); } }} + showExport + onExport={async (search) => { + try { + await download({ + search: search ? search : undefined, + }); + } catch (ex) { + const error = ex as Error; + toast.error('Failed to download data. ' + error.message); + console.error(error); + } + }} /> ); } diff --git a/src/dashboard/src/components/charts/allocationTable/AllocationTable.tsx b/src/dashboard/src/components/charts/allocationTable/AllocationTable.tsx index 0bbec08b..08a35edf 100644 --- a/src/dashboard/src/components/charts/allocationTable/AllocationTable.tsx +++ b/src/dashboard/src/components/charts/allocationTable/AllocationTable.tsx @@ -20,8 +20,11 @@ export interface IAllocationTableProps { operatingSystemId?: number; serverItems: IServerItemListModel[]; loading?: boolean; - onClick?: (serverItem?: IServerItemListModel) => void; margin?: number; + showExport?: boolean; + exportDisabled?: boolean; + onExport?: (search: string) => void; + onClick?: (serverItem?: IServerItemListModel) => void; } export const AllocationTable = ({ @@ -29,8 +32,11 @@ export const AllocationTable = ({ operatingSystemId, serverItems, loading, - onClick, margin, + showExport, + exportDisabled, + onExport, + onClick, }: IAllocationTableProps) => { const getServerItems = useAllocationByOS(osClassName, operatingSystemId); @@ -191,9 +197,16 @@ export const AllocationTable = ({ ))} - + {showExport && ( + + )} ); }; diff --git a/src/dashboard/src/components/charts/bar/allocationByOS/AllocationByOS.tsx b/src/dashboard/src/components/charts/bar/allocationByOS/AllocationByOS.tsx index c487f0bb..f1e34554 100644 --- a/src/dashboard/src/components/charts/bar/allocationByOS/AllocationByOS.tsx +++ b/src/dashboard/src/components/charts/bar/allocationByOS/AllocationByOS.tsx @@ -10,6 +10,9 @@ export interface IAllocationByOSProps { serverItems: IServerItemListModel[]; operatingSystemItems: IOperatingSystemItemListModel[]; loading?: boolean; + showExport?: boolean; + exportDisabled?: boolean; + onExport?: () => void; onClick?: (operatingSystemItem?: IOperatingSystemItemListModel) => void; } @@ -17,14 +20,18 @@ export const AllocationByOS = ({ serverItems, operatingSystemItems, loading, + showExport, + exportDisabled, + onExport, onClick, }: IAllocationByOSProps) => { return ( {}} + showExport={showExport} + exportDisabled={exportDisabled} + onExport={onExport} loading={loading} > {(data) => { diff --git a/src/dashboard/src/components/charts/bar/allocationByStorageVolume/AllocationByStorageVolume.tsx b/src/dashboard/src/components/charts/bar/allocationByStorageVolume/AllocationByStorageVolume.tsx index a8a0fdb3..e29e2d0b 100644 --- a/src/dashboard/src/components/charts/bar/allocationByStorageVolume/AllocationByStorageVolume.tsx +++ b/src/dashboard/src/components/charts/bar/allocationByStorageVolume/AllocationByStorageVolume.tsx @@ -18,6 +18,9 @@ export interface IAllocationByStorageVolumeProps { serverItems: IServerItemListModel[]; loading?: boolean; onClick?: (organization: IOrganizationListModel) => void; + showExport?: boolean; + exportDisabled?: boolean; + onExport?: (search: string) => void; } export const AllocationByStorageVolume = ({ @@ -25,6 +28,9 @@ export const AllocationByStorageVolume = ({ serverItems, loading, onClick, + showExport, + exportDisabled, + onExport, }: IAllocationByStorageVolumeProps) => { const [sortOption, setSortOption] = React.useState(0); const [search, setSearch] = React.useState(''); @@ -103,9 +109,16 @@ export const AllocationByStorageVolume = ({

Used

Unused

- + {showExport && ( + + )} ); }; diff --git a/src/dashboard/src/components/charts/bar/segmentedBarChart/SegmentedBarChart.tsx b/src/dashboard/src/components/charts/bar/segmentedBarChart/SegmentedBarChart.tsx index 697fe34a..17eefbe5 100644 --- a/src/dashboard/src/components/charts/bar/segmentedBarChart/SegmentedBarChart.tsx +++ b/src/dashboard/src/components/charts/bar/segmentedBarChart/SegmentedBarChart.tsx @@ -24,6 +24,9 @@ export interface ISegmentedBarChart { loading?: boolean; dateRange?: string[]; minColumns?: number; + showExport?: boolean; + exportDisabled?: boolean; + onExport?: () => void; } export const SegmentedBarChart = ({ @@ -32,6 +35,9 @@ export const SegmentedBarChart = ({ loading, dateRange: initDateRange, minColumns = 12, + showExport, + exportDisabled, + onExport, }: ISegmentedBarChart) => { const getStorageTrends = useStorageTrendsData(); const dateRange = useStorageTrendsStore((state) => state.dateRangeFileSystemHistoryItems); @@ -131,9 +137,16 @@ export const SegmentedBarChart = ({
- + {showExport && ( + + )} ); }; diff --git a/src/dashboard/src/components/charts/bar/smallBar/SmallBarChart.tsx b/src/dashboard/src/components/charts/bar/smallBar/SmallBarChart.tsx index d387b8b5..39e5304d 100644 --- a/src/dashboard/src/components/charts/bar/smallBar/SmallBarChart.tsx +++ b/src/dashboard/src/components/charts/bar/smallBar/SmallBarChart.tsx @@ -10,6 +10,7 @@ export interface ISmallBarChartProps> { title?: string; /** Data to be displayed in the bar chart */ data: IBarChartData; + showExport?: boolean; /** Whether the export to Excel is disabled */ exportDisabled?: boolean; /** Event fires when the export to Excel is clicked */ @@ -28,9 +29,10 @@ export const SmallBarChart = >({ title, data, children, + loading, + showExport, exportDisabled, onExport, - loading, }: ISmallBarChartProps) => { return (
@@ -59,12 +61,12 @@ export const SmallBarChart = >({ })}
- {onExport && ( + {showExport && ( diff --git a/src/dashboard/src/components/charts/doughnut/allOrganizations/AllOrganizations.tsx b/src/dashboard/src/components/charts/doughnut/allOrganizations/AllOrganizations.tsx index 18e906f8..e14ff74f 100644 --- a/src/dashboard/src/components/charts/doughnut/allOrganizations/AllOrganizations.tsx +++ b/src/dashboard/src/components/charts/doughnut/allOrganizations/AllOrganizations.tsx @@ -16,12 +16,18 @@ export interface IAllOrganizationsProps { organizations: IOrganizationListModel[]; serverItems: IServerItemListModel[]; loading?: boolean; + showExport?: boolean; + exportDisabled?: boolean; + onExport?: () => void; } export const AllOrganizations = ({ organizations, serverItems, loading, + showExport, + exportDisabled, + onExport, }: IAllOrganizationsProps) => { const [data, setData] = React.useState(generateAllOrganizationsDoughnutChart(serverItems)); @@ -79,9 +85,16 @@ export const AllOrganizations = ({

- + {showExport && ( + + )} ); }; diff --git a/src/dashboard/src/components/charts/line/LineChart.tsx b/src/dashboard/src/components/charts/line/LineChart.tsx index 61c83207..3f1a2581 100644 --- a/src/dashboard/src/components/charts/line/LineChart.tsx +++ b/src/dashboard/src/components/charts/line/LineChart.tsx @@ -29,9 +29,10 @@ interface LineChartProps, TLabel = unknown> { filter?: React.ReactNode; disclaimer?: React.ReactNode; data: ChartData<'line', TData, TLabel>; + loading?: boolean; showExport?: boolean; exportDisabled?: boolean; - loading?: boolean; + onExport?: () => void; } export const LineChart = < @@ -45,9 +46,10 @@ export const LineChart = < options = defaultChartOptions, filter, disclaimer, + loading, showExport, exportDisabled, - loading, + onExport, }: LineChartProps) => { return (
@@ -59,7 +61,12 @@ export const LineChart = <
{showExport && ( - )} diff --git a/src/dashboard/src/components/charts/storageTrends/StorageTrendsChart.tsx b/src/dashboard/src/components/charts/storageTrends/StorageTrendsChart.tsx index 10c1804d..f58aeefa 100644 --- a/src/dashboard/src/components/charts/storageTrends/StorageTrendsChart.tsx +++ b/src/dashboard/src/components/charts/storageTrends/StorageTrendsChart.tsx @@ -31,6 +31,9 @@ interface LineChartProps { loading?: boolean; /** Date range selected for the filter. */ dateRange?: string[]; + showExport?: boolean; + exportDisabled?: boolean; + onExport?: (startDate?: string, endDate?: string) => void; } /** @@ -43,6 +46,9 @@ export const StorageTrendsChart: React.FC = ({ loading, minColumns, dateRange: initDateRange, + showExport, + exportDisabled, + onExport, }) => { const getStorageTrendsData = useStorageTrendsData(); const dateRange = useStorageTrendsStore((state) => state.dateRangeServerHistoryItems); @@ -50,36 +56,36 @@ export const StorageTrendsChart: React.FC = ({ const { isReady: serverHistoryItemsIsReady, findServerHistoryItems } = useServerHistoryItems(); const { tenantId, organizationId, operatingSystemItemId, serverItemKey } = useDashboard(); - const values = [ - initDateRange?.length && initDateRange[0] - ? initDateRange[0] - : moment().add(-1, 'year').format('YYYY-MM-DD'), - initDateRange?.length && initDateRange[1] ? initDateRange[1] : '', - ]; - React.useEffect(() => { + const values = [ + initDateRange?.length && initDateRange[0] + ? initDateRange[0] + : moment().add(-1, 'year').format('YYYY-MM-DD'), + initDateRange?.length && initDateRange[1] ? initDateRange[1] : '', + ]; setDateRange(values); // Infinite loop if we use the array instead of individual values. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [values[0], values[1], setDateRange]); + }, [initDateRange?.[0], initDateRange?.[1], setDateRange]); React.useEffect(() => { - if (values[0]) { + if (dateRange[0]) { // A single server was selected, fetch the history for this server. findServerHistoryItems({ tenantId: tenantId, organizationId: organizationId, operatingSystemItemId: operatingSystemItemId, serviceNowKey: serverItemKey, - startDate: values[0], - endDate: values[1] ? values[1] : undefined, + startDate: dateRange[0], + endDate: dateRange[1] ? dateRange[1] : undefined, }).catch((ex) => { const error = ex as Error; toast.error(error.message); console.error(error); }); } - // Values array will cause infinite loop, we're only interested in the values. + // Values array will cause infinite loop, we're only interested in when values change. + // findServerHistoryItems will cause infinite loop. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ findServerHistoryItems, @@ -88,9 +94,9 @@ export const StorageTrendsChart: React.FC = ({ operatingSystemItemId, serverItemKey, // eslint-disable-next-line react-hooks/exhaustive-deps - values[0], + dateRange[0], // eslint-disable-next-line react-hooks/exhaustive-deps - values[1], + dateRange[1], ]); return ( @@ -99,25 +105,18 @@ export const StorageTrendsChart: React.FC = ({ label="Storage Trends" loading={loading || !serverHistoryItemsIsReady} large={large} - showExport - exportDisabled disclaimer={

*Data shows totals on last available day of each month.

} + showExport={showExport} + exportDisabled={exportDisabled} + onExport={() => onExport?.(dateRange[0], dateRange[1])} filter={
{ setDateRange(values); - await findServerHistoryItems({ - tenantId: tenantId, - organizationId: organizationId, - operatingSystemItemId: operatingSystemItemId, - serviceNowKey: serverItemKey, - startDate: values[0] ? values[0] : undefined, - endDate: values[1] ? values[1] : undefined, - }); }} showButton /> diff --git a/src/dashboard/src/components/dashboard/Dashboard.tsx b/src/dashboard/src/components/dashboard/Dashboard.tsx index 69fede5d..0dd481d5 100644 --- a/src/dashboard/src/components/dashboard/Dashboard.tsx +++ b/src/dashboard/src/components/dashboard/Dashboard.tsx @@ -11,7 +11,7 @@ import { StorageTrendsChart, TotalStorage, } from '@/components/charts'; -import { IOperatingSystemItemListModel, IServerItemListModel } from '@/hooks'; +import { IOperatingSystemItemListModel, IServerItemListModel, useApiServerItems } from '@/hooks'; import { useDashboardOperatingSystemItems, useDashboardOrganizations, @@ -28,8 +28,9 @@ import { useServerItems, useTenants, } from '@/hooks/lists'; -import { useFilteredStore } from '@/store'; +import { useDashboardStore, useFilteredStore } from '@/store'; import React from 'react'; +import { toast } from 'react-toastify'; import { useDashboardFilter } from '.'; /** @@ -38,6 +39,7 @@ import { useDashboardFilter } from '.'; * @returns Component */ export const Dashboard = () => { + const { download, downloadHistory } = useApiServerItems(); const { isReady: isReadyTenants, tenants } = useTenants({ init: true }); const { isReady: isReadyOrganizations, organizations } = useOrganizations({ init: true, @@ -60,6 +62,7 @@ export const Dashboard = () => { useSimple: true, }); + const dashboardTenant = useDashboardStore((state) => state.tenant); const { organization: dashboardOrganization, organizations: dashboardOrganizations } = useDashboardOrganizations(); const { @@ -200,6 +203,19 @@ export const Dashboard = () => { await updateDashboard({}); } }} + showExport + onExport={async () => { + try { + await download({ + tenantId: dashboardTenant?.id, + organizationId: dashboardOrganization?.id, + }); + } catch (ex) { + const error = ex as Error; + toast.error('Failed to download data. ' + error.message); + console.error(error); + } + }} /> )} {/* One Server Selected */} @@ -212,16 +228,46 @@ export const Dashboard = () => { organizations={dashboardOrganizations} serverItems={dashboardServerItems} loading={!isReadyOrganizations || !isReadyServerItems} + showExport + onExport={async () => { + try { + await download({ + tenantId: dashboardTenant?.id, + }); + } catch (ex) { + const error = ex as Error; + toast.error('Failed to download data. ' + error.message); + console.error(error); + } + }} /> )} { + try { + await downloadHistory({ + tenantId: dashboardTenant?.id, + organizationId: dashboardOrganization?.id, + operatingSystemItemId: dashboardOperatingSystemItem?.id, + serviceNowKey: dashboardServerItem?.serviceNowKey, + startDate: startDate, + endDate: endDate, + }); + } catch (ex) { + const error = ex as Error; + toast.error('Failed to download data. ' + error.message); + console.error(error); + } + }} /> {showAllocationByStorageVolume && ( { if (organization) { let filteredServerItems: IServerItemListModel[]; @@ -297,6 +343,18 @@ export const Dashboard = () => { await updateDashboard({}); } }} + onExport={async (search) => { + try { + await download({ + tenantId: dashboardTenant?.id, + organizationName: search ? search : undefined, + }); + } catch (ex) { + const error = ex as Error; + toast.error('Failed to download data. ' + error.message); + console.error(error); + } + }} /> )} {showAllocationTable && ( @@ -315,6 +373,21 @@ export const Dashboard = () => { setValues((state) => ({ serverItem, tenant, organization, operatingSystemItem })); await updateDashboard({ tenant, organization, operatingSystemItem, serverItem }); }} + showExport + onExport={async (search) => { + try { + await download({ + tenantId: dashboardTenant?.id, + organizationId: dashboardOrganization?.id, + operatingSystemItemId: dashboardOperatingSystemItem?.id, + search: search ? search : undefined, + }); + } catch (ex) { + const error = ex as Error; + toast.error('Failed to download data. ' + error.message); + console.error(error); + } + }} /> )} {showSegmentedBarChart && ( diff --git a/src/dashboard/src/hooks/api/interfaces/IServerItemFilter.ts b/src/dashboard/src/hooks/api/interfaces/IServerItemFilter.ts index 11db0eb5..22e6ab0a 100644 --- a/src/dashboard/src/hooks/api/interfaces/IServerItemFilter.ts +++ b/src/dashboard/src/hooks/api/interfaces/IServerItemFilter.ts @@ -1,8 +1,10 @@ export interface IServerItemFilter { + search?: string; name?: string; serviceNowKey?: string; operatingSystemItemId?: number; organizationId?: number; + organizationName?: string; tenantId?: number; startDate?: string; endDate?: string; diff --git a/src/dashboard/src/hooks/api/useApiServerItems.ts b/src/dashboard/src/hooks/api/useApiServerItems.ts index 587da5e0..de2c59fd 100644 --- a/src/dashboard/src/hooks/api/useApiServerItems.ts +++ b/src/dashboard/src/hooks/api/useApiServerItems.ts @@ -1,5 +1,6 @@ import { dispatch, toQueryString } from '@/utils'; import React from 'react'; +import { useDownload } from '..'; import { IServerHistoryItemFilter, IServerItemFilter } from './interfaces'; /** @@ -8,6 +9,8 @@ import { IServerHistoryItemFilter, IServerItemFilter } from './interfaces'; * @returns API endpoint functions. */ export const useApiServerItems = () => { + const download = useDownload(); + return React.useMemo( () => ({ find: async (filter: IServerItemFilter | undefined = {}): Promise => { @@ -31,7 +34,29 @@ export const useApiServerItems = () => { cache: 'force-cache', }); }, + download: async ( + filter: IServerItemFilter | undefined = {}, + format?: string, + ): Promise => { + return await download( + `/api/dashboard/server-items/export?${format ? `format=${format}&` : ''}${toQueryString( + filter, + )}`, + { fileName: 'server-items.xlsx', method: 'get' }, + ); + }, + downloadHistory: async ( + filter: IServerHistoryItemFilter | undefined = {}, + format?: string, + ): Promise => { + return await download( + `/api/dashboard/server-items/history/export?${ + format ? `format=${format}&` : '' + }${toQueryString(filter)}`, + { fileName: 'server-items.xlsx', method: 'get' }, + ); + }, }), - [], + [download], ); }; diff --git a/src/dashboard/src/hooks/index.ts b/src/dashboard/src/hooks/index.ts index 002fba72..c5ef8a7e 100644 --- a/src/dashboard/src/hooks/index.ts +++ b/src/dashboard/src/hooks/index.ts @@ -1,3 +1,4 @@ export * from './api'; export * from './constants'; export * from './useAuth'; +export * from './useDownload'; diff --git a/src/dashboard/src/hooks/useDownload.ts b/src/dashboard/src/hooks/useDownload.ts new file mode 100644 index 00000000..2f4f04fa --- /dev/null +++ b/src/dashboard/src/hooks/useDownload.ts @@ -0,0 +1,34 @@ +import { dispatch } from '@/utils'; +import React from 'react'; + +/** + * Download configuration options interface. + */ +export interface IDownloadConfig extends RequestInit { + fileName?: string; +} + +/** + * Make an AJAX request to download content from the specified endpoint. + */ +export const useDownload = () => { + return React.useCallback( + async (input: string | Request | URL, init?: IDownloadConfig | undefined) => { + const response = await dispatch(input, { + method: 'get', + ...init, + }); + + const blob = await response.blob(); + const uri = window.URL.createObjectURL(new Blob([blob])); + const link = document.createElement('a'); + link.href = uri; + link.setAttribute('download', init?.fileName ?? `download-${new Date().toDateString()}`); + document.body.appendChild(link); + link.click(); + + return response; + }, + [], + ); +}; diff --git a/src/dashboard/src/styles/_globals.scss b/src/dashboard/src/styles/_globals.scss index 0088cd8f..ab5043e1 100644 --- a/src/dashboard/src/styles/_globals.scss +++ b/src/dashboard/src/styles/_globals.scss @@ -48,7 +48,6 @@ padding: 5px; padding-left: 28px; font-size: $font-size-small; - display: none; img { left: 9px; diff --git a/src/libs/dal/Services/IServerHistoryItemService.cs b/src/libs/dal/Services/IServerHistoryItemService.cs index 100d0d90..b338b7ef 100644 --- a/src/libs/dal/Services/IServerHistoryItemService.cs +++ b/src/libs/dal/Services/IServerHistoryItemService.cs @@ -5,11 +5,11 @@ namespace HSB.DAL.Services; public interface IServerHistoryItemService : IBaseService { - IEnumerable Find(ServerHistoryItemFilter filter); + IEnumerable Find(ServerHistoryItemFilter filter, bool includeRelated = false); - IEnumerable FindForUser(int userId, ServerHistoryItemFilter filter); + IEnumerable FindForUser(int userId, ServerHistoryItemFilter filter, bool includeRelated = false); - IEnumerable FindHistoryByMonth(DateTime start, DateTime? end, int? tenantId, int? organizationId, int? operatingSystemId, string? serviceKeyNow); + IEnumerable FindHistoryByMonth(DateTime start, DateTime? end, int? tenantId, int? organizationId, int? operatingSystemId, string? serviceKeyNow, bool includeRelated = false); - IEnumerable FindHistoryByMonthForUser(int userId, DateTime start, DateTime? end, int? tenantId, int? organizationId, int? operatingSystemId, string? serviceKeyNow); + IEnumerable FindHistoryByMonthForUser(int userId, DateTime start, DateTime? end, int? tenantId, int? organizationId, int? operatingSystemId, string? serviceKeyNow, bool includeRelated = false); } diff --git a/src/libs/dal/Services/IServerItemService.cs b/src/libs/dal/Services/IServerItemService.cs index f03b2a45..00d9bf85 100644 --- a/src/libs/dal/Services/IServerItemService.cs +++ b/src/libs/dal/Services/IServerItemService.cs @@ -7,9 +7,9 @@ namespace HSB.DAL.Services; public interface IServerItemService : IBaseService { - IEnumerable Find(ServerItemFilter filter); + IEnumerable Find(ServerItemFilter filter, bool includeRelated = false); - IEnumerable FindForUser(long userId, ServerItemFilter filter); + IEnumerable FindForUser(long userId, ServerItemFilter filter, bool includeRelated = false); ServerItem? FindForId(string key, bool includeFileSystemItems = false); diff --git a/src/libs/dal/Services/ServerHistoryItemService.cs b/src/libs/dal/Services/ServerHistoryItemService.cs index 28a52ac1..1fb9c687 100644 --- a/src/libs/dal/Services/ServerHistoryItemService.cs +++ b/src/libs/dal/Services/ServerHistoryItemService.cs @@ -18,9 +18,17 @@ public ServerHistoryItemService(HSBContext dbContext, ClaimsPrincipal principal, #region Methods - public IEnumerable Find(ServerHistoryItemFilter filter) + public IEnumerable Find(ServerHistoryItemFilter filter, bool includeRelated = false) { - var query = this.Context.ServerHistoryItems + var query = this.Context.ServerHistoryItems.AsQueryable(); + + if (includeRelated) + query = query + .Include(shi => shi.Tenant) + .Include(shi => shi.Organization) + .Include(shi => shi.OperatingSystemItem); + + query = query .Where(filter.GeneratePredicate()) .Distinct(); @@ -38,7 +46,7 @@ public IEnumerable Find(ServerHistoryItemFilter filter) .ToArray(); } - public IEnumerable FindForUser(int userId, ServerHistoryItemFilter filter) + public IEnumerable FindForUser(int userId, ServerHistoryItemFilter filter, bool includeRelated = false) { var userOrganizationQuery = from uo in this.Context.UserOrganizations join o in this.Context.Organizations on uo.OrganizationId equals o.Id @@ -51,9 +59,17 @@ join t in this.Context.Tenants on ut.TenantId equals t.Id && t.IsEnabled select ut.TenantId; - var query = (from si in this.Context.ServerHistoryItems - where userTenants.Contains(si.TenantId!.Value) || userOrganizationQuery.Contains(si.OrganizationId) - select si) + var query = from si in this.Context.ServerHistoryItems + where userTenants.Contains(si.TenantId!.Value) || userOrganizationQuery.Contains(si.OrganizationId) + select si; + + if (includeRelated) + query = query + .Include(shi => shi.Tenant) + .Include(shi => shi.Organization) + .Include(shi => shi.OperatingSystemItem); + + query = query .Where(filter.GeneratePredicate()) .Distinct(); @@ -71,18 +87,58 @@ where userTenants.Contains(si.TenantId!.Value) || userOrganizationQuery.Contains .ToArray(); } - public IEnumerable FindHistoryByMonth(DateTime start, DateTime? end, int? tenantId, int? organizationId, int? operatingSystemId, string? serviceKeyNow) + public IEnumerable FindHistoryByMonth(DateTime start, DateTime? end, int? tenantId, int? organizationId, int? operatingSystemId, string? serviceKeyNow, bool includeRelated = false) { - return this.Context.FindServerHistoryItemsByMonth(start.ToUniversalTime(), end?.ToUniversalTime(), tenantId, organizationId, operatingSystemId, serviceKeyNow) + var items = this.Context.FindServerHistoryItemsByMonth(start.ToUniversalTime(), end?.ToUniversalTime(), tenantId, organizationId, operatingSystemId, serviceKeyNow) .AsNoTracking() .ToArray(); + + if (includeRelated) + { + var tenantIds = items.Select(i => i.TenantId).Distinct(); + var organizationIds = items.Select(i => i.OrganizationId).Distinct(); + var operatingSystemIds = items.Select(i => i.OperatingSystemItemId).Distinct(); + + var tenants = this.Context.Tenants.Where(t => tenantIds.Contains(t.Id)).ToDictionary(t => t.Id); + var organizations = this.Context.Organizations.Where(o => organizationIds.Contains(o.Id)).ToDictionary(o => o.Id); + var operatingSystemItems = this.Context.OperatingSystemItems.Where(os => operatingSystemIds.Contains(os.Id)).ToDictionary(os => os.Id); + + foreach (var item in items) + { + item.Tenant = item.TenantId.HasValue ? tenants[item.TenantId.Value] : null; + item.Organization = organizations[item.OrganizationId]; + item.OperatingSystemItem = item.OperatingSystemItemId.HasValue ? operatingSystemItems[item.OperatingSystemItemId.Value] : null; + } + } + + return items; } - public IEnumerable FindHistoryByMonthForUser(int userId, DateTime start, DateTime? end, int? tenantId, int? organizationId, int? operatingSystemId, string? serviceKeyNow) + public IEnumerable FindHistoryByMonthForUser(int userId, DateTime start, DateTime? end, int? tenantId, int? organizationId, int? operatingSystemId, string? serviceKeyNow, bool includeRelated = false) { - return this.Context.FindServerHistoryItemsByMonthForUser(userId, start.ToUniversalTime(), end?.ToUniversalTime(), tenantId, organizationId, operatingSystemId, serviceKeyNow) + var items = this.Context.FindServerHistoryItemsByMonthForUser(userId, start.ToUniversalTime(), end?.ToUniversalTime(), tenantId, organizationId, operatingSystemId, serviceKeyNow) .AsNoTracking() .ToArray(); + + if (includeRelated) + { + var tenantIds = items.Select(i => i.TenantId).Distinct(); + var organizationIds = items.Select(i => i.OrganizationId).Distinct(); + var operatingSystemIds = items.Select(i => i.OperatingSystemItemId).Distinct(); + + var tenants = this.Context.Tenants.Where(t => tenantIds.Contains(t.Id)).ToDictionary(t => t.Id); + var organizations = this.Context.Organizations.Where(o => organizationIds.Contains(o.Id)).ToDictionary(o => o.Id); + var operatingSystemItems = this.Context.OperatingSystemItems.Where(os => operatingSystemIds.Contains(os.Id)).ToDictionary(os => os.Id); + + foreach (var item in items) + { + item.Tenant = item.TenantId.HasValue ? tenants[item.TenantId.Value] : null; + item.Organization = organizations[item.OrganizationId]; + item.OperatingSystemItem = item.OperatingSystemItemId.HasValue ? operatingSystemItems[item.OperatingSystemItemId.Value] : null; + } + } + + return items; } #endregion } diff --git a/src/libs/dal/Services/ServerItemService.cs b/src/libs/dal/Services/ServerItemService.cs index 634a8d7e..02b9093b 100644 --- a/src/libs/dal/Services/ServerItemService.cs +++ b/src/libs/dal/Services/ServerItemService.cs @@ -19,10 +19,18 @@ public ServerItemService(HSBContext dbContext, ClaimsPrincipal principal, IServi #endregion #region Methods - - public IEnumerable Find(ServerItemFilter filter) + public IEnumerable Find(ServerItemFilter filter, bool includeRelated = false) { var query = this.Context.ServerItems + .AsQueryable(); + + if (includeRelated) + query = query + .Include(si => si.Tenant) + .Include(si => si.Organization) + .Include(si => si.OperatingSystemItem); + + query = query .Where(filter.GeneratePredicate()) .Distinct(); @@ -40,7 +48,7 @@ public IEnumerable Find(ServerItemFilter filter) .ToArray(); } - public IEnumerable FindForUser(long userId, ServerItemFilter filter) + public IEnumerable FindForUser(long userId, ServerItemFilter filter, bool includeRelated = false) { var userOrganizationQuery = from uo in this.Context.UserOrganizations join o in this.Context.Organizations on uo.OrganizationId equals o.Id @@ -56,7 +64,15 @@ join t in this.Context.Tenants on ut.TenantId equals t.Id var query = (from si in this.Context.ServerItems where si.TenantId != null && (userTenants.Contains(si.TenantId!.Value) || userOrganizationQuery.Contains(si.OrganizationId)) - select si) + select si); + + if (includeRelated) + query = query + .Include(si => si.Tenant) + .Include(si => si.Organization) + .Include(si => si.OperatingSystemItem); + + query = query .Where(filter.GeneratePredicate()) .Distinct(); diff --git a/src/libs/models/Filters/ServerItemFilter.cs b/src/libs/models/Filters/ServerItemFilter.cs index e577bd81..24f1699b 100644 --- a/src/libs/models/Filters/ServerItemFilter.cs +++ b/src/libs/models/Filters/ServerItemFilter.cs @@ -7,10 +7,12 @@ namespace HSB.Models.Filters; public class ServerItemFilter : PageFilter { #region Properties + public string? Search { get; set; } public string? Name { get; set; } public string? ServiceNowKey { get; set; } public int? TenantId { get; set; } public int? OrganizationId { get; set; } + public string? OrganizationName { get; set; } public int? OperatingSystemItemId { get; set; } public int? InstallStatus { get; set; } public int? NotInstallStatus { get; set; } @@ -33,10 +35,12 @@ public ServerItemFilter(Dictionary(queryParams, StringComparer.OrdinalIgnoreCase); + this.Search = filter.GetStringValue(nameof(this.Search)); this.Name = filter.GetStringValue(nameof(this.Name)); this.ServiceNowKey = filter.GetStringValue(nameof(this.ServiceNowKey)); this.TenantId = filter.GetIntNullValue(nameof(this.TenantId)); this.OrganizationId = filter.GetIntNullValue(nameof(this.OrganizationId)); + this.OrganizationName = filter.GetStringValue(nameof(this.OrganizationName)); this.OperatingSystemItemId = filter.GetIntNullValue(nameof(this.OperatingSystemItemId)); this.StartDate = filter.GetDateTimeNullValue(nameof(this.StartDate)); this.EndDate = filter.GetDateTimeNullValue(nameof(this.EndDate)); @@ -52,6 +56,8 @@ public ServerItemFilter(Dictionary GeneratePredicate() { var predicate = PredicateBuilder.New(); + if (this.Search != null) + predicate = predicate.And((u) => EF.Functions.Like(u.Name, $"%{this.Search}%") || EF.Functions.Like(u.OperatingSystemItem!.Name, $"%{this.Search}%")); if (this.Name != null) predicate = predicate.And((u) => EF.Functions.Like(u.Name, $"%{this.Name}%")); if (this.ServiceNowKey != null) @@ -60,6 +66,8 @@ public ServerItemFilter(Dictionary u.TenantId == this.TenantId); if (this.OrganizationId != null) predicate = predicate.And((u) => u.OrganizationId == this.OrganizationId); + if (this.OrganizationName != null) + predicate = predicate.And((u) => EF.Functions.Like(u.Organization!.Name.ToLower(), $"%{this.OrganizationName.ToLower()}%")); if (this.OperatingSystemItemId != null) predicate = predicate.And((u) => u.OperatingSystemItemId == this.OperatingSystemItemId); if (this.InstallStatus != null)