From da54f9d3052c3296dc60a0d805e2532a36b9a9b1 Mon Sep 17 00:00:00 2001 From: Jeremy Foster Date: Thu, 4 Jan 2024 13:01:32 -0800 Subject: [PATCH] HOSTSD-199 Add storage trends chart (#40) --- src/dashboard/src/app/hsb/dashboard/page.tsx | 4 +- .../src/components/buttons/Button.module.scss | 78 ++++++++----- .../src/components/buttons/Button.tsx | 22 ++-- .../charts/allOrgDonut/AllOrgDonutChart.tsx | 7 +- .../charts/allOrgDonut/updateData.ts | 6 +- .../charts/allOrgDonut/useDonutChart.ts | 11 +- .../storageTrends/StorageTrendsChart.tsx | 9 +- .../charts/storageTrends/generateData.ts | 28 ----- .../charts/storageTrends/useStorageTrends.ts | 106 ++++++++++++++++++ .../src/components/filter/Filter.tsx | 50 +++++++-- .../forms/select/Select.module.scss | 16 +++ .../src/components/forms/select/Select.tsx | 9 +- .../components/spinner/Spinner.module.scss | 10 +- .../src/components/spinner/Spinner.tsx | 8 +- .../interfaces/IFileSystemHistoryItemModel.ts | 12 +- .../api/interfaces/IServerHistoryItemModel.ts | 7 +- .../hooks/api/interfaces/IServerItemModel.ts | 2 + src/dashboard/src/hooks/dashboard/index.ts | 2 + .../useDashboardFileSystemHistoryItems.ts | 43 +++++++ .../useDashboardServerHistoryItems.ts | 33 ++++++ .../hooks/filter/useFilteredServerItems.ts | 3 +- src/dashboard/src/store/index.ts | 1 + src/dashboard/src/store/useDashboard.ts | 65 +++++++++++ src/dashboard/src/store/useFiltered.ts | 15 +++ src/dashboard/src/utils/calcMonthsBetween.ts | 7 ++ .../src/utils/convertToStorageSize.ts | 28 +++-- src/dashboard/src/utils/index.ts | 1 + src/data-service/DataService.cs | 12 +- src/data-service/Helpers/HsbApiService.cs | 32 ------ src/data-service/Helpers/IHsbApiService.cs | 14 --- .../FileSystemHistoryItemConfiguration.cs | 14 +-- .../ServerHistoryItemConfiguration.cs | 8 +- .../Configuration/ServerItemConfiguration.cs | 1 + .../00-FindFileSystemHistoryItemsByMonth.sql | 1 + .../00-FindServerHistoryItemsByMonth.sql | 1 + .../04-FindServerHistoryItemsByMonth.sql | 4 + ...er.cs => 20240104163357_0.0.0.Designer.cs} | 75 ++++++------- ...01941_0.0.0.cs => 20240104163357_0.0.0.cs} | 26 ++--- .../dal/Migrations/HSBContextModelSnapshot.cs | 73 +++++------- .../dal/Services/FileSystemItemService.cs | 31 ++++- src/libs/dal/Services/ServerItemService.cs | 37 ++++++ src/libs/entities/FileSystemHistoryItem.cs | 49 ++++++-- src/libs/entities/FileSystemItem.cs | 1 + src/libs/entities/ServerHistoryItem.cs | 39 +++++++ src/libs/entities/ServerItem.cs | 6 + src/libs/models/FileSystemHistoryItemModel.cs | 53 ++++----- src/libs/models/ServerHistoryItemModel.cs | 14 +++ 47 files changed, 746 insertions(+), 328 deletions(-) delete mode 100644 src/dashboard/src/components/charts/storageTrends/generateData.ts create mode 100644 src/dashboard/src/components/charts/storageTrends/useStorageTrends.ts create mode 100644 src/dashboard/src/hooks/dashboard/index.ts create mode 100644 src/dashboard/src/hooks/dashboard/useDashboardFileSystemHistoryItems.ts create mode 100644 src/dashboard/src/hooks/dashboard/useDashboardServerHistoryItems.ts create mode 100644 src/dashboard/src/store/useDashboard.ts create mode 100644 src/dashboard/src/utils/calcMonthsBetween.ts create mode 100644 src/libs/dal/Migrations/0.0.0/Down/PreDown/00-FindFileSystemHistoryItemsByMonth.sql create mode 100644 src/libs/dal/Migrations/0.0.0/Down/PreDown/00-FindServerHistoryItemsByMonth.sql rename src/libs/dal/Migrations/{20231229201941_0.0.0.Designer.cs => 20240104163357_0.0.0.Designer.cs} (96%) rename src/libs/dal/Migrations/{20231229201941_0.0.0.cs => 20240104163357_0.0.0.cs} (97%) diff --git a/src/dashboard/src/app/hsb/dashboard/page.tsx b/src/dashboard/src/app/hsb/dashboard/page.tsx index 57389254..57b2b96c 100644 --- a/src/dashboard/src/app/hsb/dashboard/page.tsx +++ b/src/dashboard/src/app/hsb/dashboard/page.tsx @@ -1,21 +1,19 @@ import { AllOrgDonutChart, AllocationByStorageVolume, - StorageTrendsChart, DonutChart, SmallBarChart, + StorageTrendsChart, } from '@/components/charts'; export default function Page() { return ( <> - {/* HSB Dashboard */} - {/* */} ); } diff --git a/src/dashboard/src/components/buttons/Button.module.scss b/src/dashboard/src/components/buttons/Button.module.scss index b7c7f545..027b60e2 100644 --- a/src/dashboard/src/components/buttons/Button.module.scss +++ b/src/dashboard/src/components/buttons/Button.module.scss @@ -1,54 +1,70 @@ @import '@/styles/utils.scss'; .btn { - padding: 10px 16px; - border-radius: 3px; - font-size: 1em; - font-weight: bold; - border: 0; - cursor: pointer; - position: relative; + padding: 10px 16px; + border-radius: 3px; + font-size: 1em; + font-weight: bold; + border: 0; + cursor: pointer; + position: relative; } .primary { - background-color: $bc-blue; - color: $white; + background-color: $bc-blue; + color: $white; - &:hover { - background-color: $chart-blue; - } + &:hover { + background-color: $chart-blue; + } } .secondary { - background-color: $white; - color: $bc-black; - border: 1px solid $dark-gray; + background-color: $white; + color: $bc-black; + border: 1px solid $dark-gray; - &:hover { - background-color: $light-gray; - } + &:hover { + background-color: $light-gray; + } } .disabled { - background-color: $light-gray; - color: $dark-gray; - border: 1px solid $dark-gray; - pointer-events: none; + background-color: $light-gray; + color: $dark-gray; + border: 1px solid $dark-gray; + pointer-events: none; - &:hover { - background-color: $light-gray; - } + &:hover { + background-color: $light-gray; + } - .buttonIcon { - opacity: 0.5; - } + .buttonIcon { + opacity: 0.5; + } } .buttonIcon { - position: absolute; - left: 16px; + position: absolute; + left: 16px; } .btnWithIcon { - padding-left: 42px; + padding-left: 42px; +} + +.spinner { + top: 0; + right: 0; + width: 22px; + height: 22px; + margin-top: calc(height / 2); + margin-right: 17px; + + >div { + width: 20px; + height: 20px; + border: 2px solid $bc-blue; + border-color: $bc-blue transparent transparent transparent; + } } \ No newline at end of file diff --git a/src/dashboard/src/components/buttons/Button.tsx b/src/dashboard/src/components/buttons/Button.tsx index ef19f628..d5106663 100644 --- a/src/dashboard/src/components/buttons/Button.tsx +++ b/src/dashboard/src/components/buttons/Button.tsx @@ -1,18 +1,27 @@ -import React from 'react'; import Image from 'next/image'; +import React from 'react'; +import { Spinner } from '../spinner'; import styles from './Button.module.scss'; interface IButtonProps extends React.ButtonHTMLAttributes { variant?: 'primary' | 'secondary'; children?: React.ReactNode; iconPath?: string; + loading?: boolean; } -export const Button: React.FC = ({ variant = 'primary', children, iconPath, disabled, ...rest }) => { +export const Button: React.FC = ({ + variant = 'primary', + children, + iconPath, + disabled, + loading, + ...rest +}) => { // Determine the button's className based on the 'variant' prop and whether iconPath is provided. const getButtonClassName = () => { let buttonClasses = `${styles.btn} ${styles[variant] || ''}`; - + if (iconPath) { // If iconPath is truthy, append the class for an icon buttonClasses += ` ${styles.btnWithIcon}`; @@ -27,14 +36,11 @@ export const Button: React.FC = ({ variant = 'primary', children, }; return ( - ); diff --git a/src/dashboard/src/components/charts/allOrgDonut/AllOrgDonutChart.tsx b/src/dashboard/src/components/charts/allOrgDonut/AllOrgDonutChart.tsx index 2992bbab..43fe9d73 100644 --- a/src/dashboard/src/components/charts/allOrgDonut/AllOrgDonutChart.tsx +++ b/src/dashboard/src/components/charts/allOrgDonut/AllOrgDonutChart.tsx @@ -1,7 +1,7 @@ 'use client'; import { Button } from '@/components/buttons'; -import { useFiltered } from '@/store'; +import { useDashboard } from '@/store'; import { ArcElement, Chart as ChartJS, Tooltip } from 'chart.js'; import React from 'react'; import { Doughnut } from 'react-chartjs-2'; @@ -12,8 +12,7 @@ import { defaultData } from './defaultData'; ChartJS.register(ArcElement, Tooltip); export const AllOrgDonutChart: React.FC = () => { - const organization = useFiltered((state) => state.organization); - const organizations = useFiltered((state) => state.organizations); + const organizations = useDashboard((state) => state.organizations); const data = useDonutChart(); return ( @@ -21,7 +20,7 @@ export const AllOrgDonutChart: React.FC = () => {

All Organizations

-

Totals for {organization ? 1 : organizations.length} organizations

+

Totals for {organizations.length} organizations

Allocated {data.space} diff --git a/src/dashboard/src/components/charts/allOrgDonut/updateData.ts b/src/dashboard/src/components/charts/allOrgDonut/updateData.ts index 1a9da2e7..e2b0e39b 100644 --- a/src/dashboard/src/components/charts/allOrgDonut/updateData.ts +++ b/src/dashboard/src/components/charts/allOrgDonut/updateData.ts @@ -13,13 +13,13 @@ export const updateData = ( const availableCir = availablePercent ? (360 * availablePercent) / 100 : 0; return { - space: convertToStorageSize(space, 'MB', 'TB', navigator.language, { + space: convertToStorageSize(space, 'MB', 'TB', { formula: Math.trunc, }), - used: convertToStorageSize(used, 'MB', 'TB', navigator.language, { + used: convertToStorageSize(used, 'MB', 'TB', { formula: Math.trunc, }), - available: convertToStorageSize(available, 'MB', 'TB', navigator.language, { + available: convertToStorageSize(available, 'MB', 'TB', { formula: Math.trunc, }), chart: { diff --git a/src/dashboard/src/components/charts/allOrgDonut/useDonutChart.ts b/src/dashboard/src/components/charts/allOrgDonut/useDonutChart.ts index fa61c0c8..dfaa600f 100644 --- a/src/dashboard/src/components/charts/allOrgDonut/useDonutChart.ts +++ b/src/dashboard/src/components/charts/allOrgDonut/useDonutChart.ts @@ -1,19 +1,16 @@ -import { useFiltered } from '@/store'; +import { useDashboard } from '@/store'; import React from 'react'; import { IStats } from './IStats'; import { defaultData } from './defaultData'; import { updateData } from './updateData'; export const useDonutChart = () => { - const serverItem = useFiltered((state) => state.serverItem); - const serverItems = useFiltered((state) => state.serverItems); + const serverItems = useDashboard((state) => state.serverItems); const [data, setData] = React.useState(defaultData); React.useEffect(() => { - if (serverItem) { - setData((data) => updateData(serverItem.capacity, serverItem.availableSpace, data)); - } else if (serverItems.length) { + if (serverItems.length) { const space = serverItems.map((si) => si.capacity!).reduce((a, b) => (a ?? 0) + (b ?? 0)); const available = serverItems .map((si) => si.availableSpace!) @@ -22,7 +19,7 @@ export const useDonutChart = () => { } else { setData(defaultData); } - }, [serverItems, serverItem]); + }, [serverItems]); return data; }; diff --git a/src/dashboard/src/components/charts/storageTrends/StorageTrendsChart.tsx b/src/dashboard/src/components/charts/storageTrends/StorageTrendsChart.tsx index df1d6521..c0c96a5e 100644 --- a/src/dashboard/src/components/charts/storageTrends/StorageTrendsChart.tsx +++ b/src/dashboard/src/components/charts/storageTrends/StorageTrendsChart.tsx @@ -1,6 +1,5 @@ 'use client'; -import { useFiltered } from '@/store'; import { CategoryScale, Chart as ChartJS, @@ -13,7 +12,7 @@ import { } from 'chart.js'; import React from 'react'; import { LineChart } from '../lineChart'; -import { defaultData } from './defaultData'; +import { useStorageTrends } from './useStorageTrends'; ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend); @@ -22,9 +21,7 @@ interface LineChartProps { } export const StorageTrendsChart: React.FC = ({ large }) => { - const fileSystemItems = useFiltered((state) => state.fileSystemItems); + const data = useStorageTrends(12); - return ( - - ); + return ; }; diff --git a/src/dashboard/src/components/charts/storageTrends/generateData.ts b/src/dashboard/src/components/charts/storageTrends/generateData.ts deleted file mode 100644 index 2d261f59..00000000 --- a/src/dashboard/src/components/charts/storageTrends/generateData.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { IServerItemModel } from '@/hooks'; -import { ChartData } from 'chart.js'; - -export const generateData = ( - items: IServerItemModel[], - minColumns: number = 12, -): ChartData<'line', number[], string> => { - // const earliestRecord = items.find((i) => i.) - return { - labels: Array.from(new Array(12), (val, index) => `Month ${index + 1}`), - datasets: [ - { - label: 'Total Used in TB', - data: Array.from(new Array(12), (_, i) => Math.random() * 10 + 50), // Randomly generated data - borderColor: '#313132', - backgroundColor: '#313132', - fill: false, - }, - { - label: 'Total Allocated in TB', - data: Array.from(new Array(12), (_, i) => Math.random() * 10 + 70), // Randomly generated data - borderColor: '#476E94', - backgroundColor: '#476E94', - fill: false, - }, - ], - }; -}; diff --git a/src/dashboard/src/components/charts/storageTrends/useStorageTrends.ts b/src/dashboard/src/components/charts/storageTrends/useStorageTrends.ts new file mode 100644 index 00000000..b4ae7953 --- /dev/null +++ b/src/dashboard/src/components/charts/storageTrends/useStorageTrends.ts @@ -0,0 +1,106 @@ +import { IServerHistoryItemModel } from '@/hooks'; +import { useDashboard, useFiltered } from '@/store'; +import { calcMonthsBetween, convertToStorageSize } from '@/utils'; +import { ChartData } from 'chart.js'; +import moment from 'moment'; + +/** + * Generates line chart data based on the current filtered server history items. + * @param minColumns Minimum number of columns in the line chard (default = 12). + * @returns Line chart data. + */ +export const useStorageTrends = (minColumns: number = 12): ChartData<'line', number[], string> => { + const dateRange = useFiltered((state) => state.dateRange); + const serverHistoryItems = useDashboard((state) => state.serverHistoryItems); + + const now = moment(); + const start = dateRange[0] + ? moment(dateRange[0]) + : moment(new Date(now.year(), now.month(), 1)).add(-1 * minColumns, 'months'); + const end = dateRange[1] ? moment(dateRange[1]) : moment(Date.now()); + + const numberOfMonths = calcMonthsBetween(start.toDate(), end.toDate()); + const minPoints = numberOfMonths > minColumns ? numberOfMonths : minColumns; + + const endSafeDate = moment(new Date(end.year(), end.month(), 1)); + const groups = Array.from(new Array(minPoints), (_, index) => { + const date = endSafeDate.clone().add(-1 * (minPoints - 1 - index), 'months'); + const month = '0' + (date.month() + 1); + const result: { + key: string; + label: string; + items: IServerHistoryItemModel[]; + capacity: number; + availableSpace: number; + usedSpace: number; + } = { + key: `${date.year()}-${month.substring(month.length - 2)}`, + label: `${date.format('MMM')} ${date.format('YYYY')}`, + items: [], + capacity: 0, + availableSpace: 0, + usedSpace: 0, + }; + return result; + }); + + // server history is returned for each server, however some servers may lack history. + // This process needs to group each month. + const items = serverHistoryItems + .map((item) => { + const createdOn = moment(item.createdOn); + const month = '0' + (createdOn.month() + 1); + return { + ...item, + key: `${createdOn.year()}-${month.substring(month.length - 2)}`, + year: createdOn.year(), + month: createdOn.month() + 1, + }; + }) + .reduce((result, item) => { + const { key } = item; + (result as any)[key] = (result as any)[key] ?? []; + (result as any)[key].push(item); + return result; + }, {}); + + groups.forEach((group) => { + const values: IServerHistoryItemModel[] = (items as any)[group.key] ?? []; + group.items = values; + group.capacity = convertToStorageSize( + values.map((i) => i.capacity).reduce((result, value) => (result ?? 0) + (value ?? 0), 0) ?? 0, + 'MB', + 'TB', + { type: 'number' }, + ); + group.availableSpace = convertToStorageSize( + values + .map((i) => i.availableSpace) + .reduce((result, value) => (result ?? 0) + (value ?? 0), 0) ?? 0, + 'MB', + 'TB', + { type: 'number' }, + ); + group.usedSpace = group.capacity - group.availableSpace; + }); + + return { + labels: groups.map((i) => i.label), + datasets: [ + { + label: 'Total Used in TB', + data: groups.map((i) => i.usedSpace), + borderColor: '#313132', + backgroundColor: '#313132', + fill: false, + }, + { + label: 'Total Allocated in TB', + data: groups.map((i) => i.capacity), + borderColor: '#476E94', + backgroundColor: '#476E94', + fill: false, + }, + ], + }; +}; diff --git a/src/dashboard/src/components/filter/Filter.tsx b/src/dashboard/src/components/filter/Filter.tsx index 24d07e5f..e7387734 100644 --- a/src/dashboard/src/components/filter/Filter.tsx +++ b/src/dashboard/src/components/filter/Filter.tsx @@ -2,6 +2,7 @@ import { Button, DateRangePicker, Select } from '@/components'; import { IOperatingSystemItemModel, IOrganizationModel, ITenantModel } from '@/hooks'; +import { useDashboardServerHistoryItems } from '@/hooks/dashboard'; import { useOperatingSystemItems, useOrganizations, @@ -9,22 +10,21 @@ import { useTenants, } from '@/hooks/data'; import { - useFilteredFileSystemItems, useFilteredOperatingSystemItems, useFilteredOrganizations, useFilteredServerItems, useFilteredTenants, } from '@/hooks/filter'; -import { useFiltered } from '@/store'; +import { useDashboard, useFiltered } from '@/store'; import moment from 'moment'; import React from 'react'; import styles from './Filter.module.scss'; export const Filter: React.FC = () => { - const { tenants } = useTenants(); - const { organizations } = useOrganizations(); - const { operatingSystemItems } = useOperatingSystemItems(); - const { serverItems } = useServerItems(); + const { isReady: tenantsReady, tenants } = useTenants(); + const { isReady: organizationsReady, organizations } = useOrganizations(); + const { isReady: operatingSystemItemsReady, operatingSystemItems } = useOperatingSystemItems(); + const { isReady: serverItemsReady, serverItems } = useServerItems(); const dateRange = useFiltered((state) => state.dateRange); const setDateRange = useFiltered((state) => state.setDateRange); @@ -37,7 +37,11 @@ export const Filter: React.FC = () => { const organization = useFiltered((state) => state.organization); const setOrganization = useFiltered((state) => state.setOrganization); const setOrganizations = useFiltered((state) => state.setOrganizations); - const { options: filteredOrganizationOptions, findOrganizations } = useFilteredOrganizations(); + const { + options: filteredOrganizationOptions, + findOrganizations, + organizations: filteredOrganizations, + } = useFilteredOrganizations(); const operatingSystemItem = useFiltered((state) => state.operatingSystemItem); const setOperatingSystemItem = useFiltered((state) => state.setOperatingSystemItem); @@ -50,7 +54,11 @@ export const Filter: React.FC = () => { const setServerItems = useFiltered((state) => state.setServerItems); const { options: filteredServerItemOptions, findServerItems } = useFilteredServerItems(); - const { findFileSystemItems } = useFilteredFileSystemItems(); + const filteredServerItems = useFiltered((state) => state.serverItems); + const setDashboardOrganizations = useDashboard((state) => state.setOrganizations); + const setDashboardServerItems = useDashboard((state) => state.setServerItems); + const { isReady: serverHistoryItemsReady, findServerHistoryItems } = + useDashboardServerHistoryItems(); React.useEffect(() => { setTenants(tenants); @@ -101,6 +109,8 @@ export const Filter: React.FC = () => { options={filteredTenantOptions} placeholder="Select tenant" value={tenant?.id ?? ''} + disabled={!tenantsReady} + loading={!tenantsReady} onChange={async (value) => { const tenant = tenants.find((t) => t.id == value); setTenant(tenant); @@ -137,6 +147,8 @@ export const Filter: React.FC = () => { options={filteredOrganizationOptions} placeholder="Select organization" value={organization?.id ?? ''} + disabled={!organizationsReady} + loading={!organizationsReady} onChange={async (value) => { const organization = organizations.find((o) => o.id == value); setOrganization(organization); @@ -168,6 +180,8 @@ export const Filter: React.FC = () => { options={filteredOperatingSystemItemOptions} placeholder="Select OS" value={operatingSystemItem?.id ?? ''} + disabled={!operatingSystemItemsReady} + loading={!operatingSystemItemsReady} onChange={async (value) => { const operatingSystemItem = operatingSystemItems.find((o) => o.id == value); setOperatingSystemItem(operatingSystemItem); @@ -191,6 +205,8 @@ export const Filter: React.FC = () => { options={filteredServerItemOptions} placeholder="Select server" value={serverItem?.serviceNowKey ?? ''} + disabled={!serverItemsReady} + loading={!serverItemsReady} onChange={async (value) => { const server = serverItems.find((o) => o.serviceNowKey == value); setServerItem(server); @@ -211,14 +227,28 @@ export const Filter: React.FC = () => {