diff --git a/changes/43769-added-charts-to-dashboard b/changes/43769-added-charts-to-dashboard new file mode 100644 index 00000000000..313c9c97b30 --- /dev/null +++ b/changes/43769-added-charts-to-dashboard @@ -0,0 +1 @@ +- Added "Hosts active" and "Hosts enrolled" charts to dashboard. \ No newline at end of file diff --git a/frontend/pages/DashboardPage/DashboardPage.tsx b/frontend/pages/DashboardPage/DashboardPage.tsx index 6724e742b76..1807ba125a7 100644 --- a/frontend/pages/DashboardPage/DashboardPage.tsx +++ b/frontend/pages/DashboardPage/DashboardPage.tsx @@ -77,6 +77,11 @@ import WelcomeHost from "./cards/WelcomeHost"; import Mdm from "./cards/MDM"; import Munki from "./cards/Munki"; import OperatingSystems from "./cards/OperatingSystems"; +import ChartCard from "./cards/ChartCard"; +import { + HostsEnrolledCard, + IHostPlatformCounts, +} from "./cards/HostsEnrolledCard"; import AddHostsModal from "../../components/AddHostsModal"; import MdmSolutionModal from "./components/MdmSolutionModal"; import ActivityFeedAutomationsModal from "./components/ActivityFeedAutomationsModal"; @@ -262,6 +267,53 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => { } ); + // Separate query to get the total host count per platform regardless of platform filter. + // Used by the "hosts enrolled" chart. + const { data: hostSummaryTotals } = useQuery< + IHostSummary, + Error, + IHostSummary + >( + ["host summary totals", teamIdForApi, isPremiumTier], + () => + hostSummaryAPI.getSummary({ + teamId: teamIdForApi, + lowDiskSpace: isPremiumTier ? LOW_DISK_SPACE_GB : undefined, + }), + { + enabled: isRouteOk, + } + ); + + const totalCounts = useMemo(() => { + const base: IHostPlatformCounts = { + darwin: 0, + windows: 0, + linux: 0, + chrome: 0, + ios: 0, + ipados: 0, + android: 0, + }; + if (!hostSummaryTotals?.platforms) { + return base; + } + const counts = hostSummaryTotals.platforms.reduce( + (acc, item) => { + if (item.platform !== "linux" && item.platform in acc) { + acc[item.platform as keyof IHostPlatformCounts] = + item.hosts_count || 0; + } + return acc; + }, + { ...base } + ); + return { + ...counts, + linux: hostSummaryTotals.all_linux_count || 0, + }; + }, [hostSummaryTotals]); + const { isLoading: isGlobalSecretsLoading, data: globalSecrets } = useQuery< IEnrollSecretsResponse, Error, @@ -889,6 +941,14 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => { +
+ + + + + + +
Platform:  { }} />
- -
- <> - {isHostSummaryFetching ? ( - - - - ) : ( - HostCountCards - )} - -
{renderCards()} {showAddHostsModal && renderAddHostsModal()} {showMdmSolutionModal && renderMdmSolutionModal()} diff --git a/frontend/pages/DashboardPage/_styles.scss b/frontend/pages/DashboardPage/_styles.scss index ffba052e479..96fb889ba13 100644 --- a/frontend/pages/DashboardPage/_styles.scss +++ b/frontend/pages/DashboardPage/_styles.scss @@ -72,6 +72,16 @@ flex-direction: column; } + &__charts-row { + display: grid; + row-gap: $gap-page-component; + + @media screen and (min-width: $break-md) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: $gap-page-component; + } + } + // >= 320px 12 pt gap @media (min-width: $break-mobile-xs) { .dashboard-page__host-sections { diff --git a/frontend/pages/DashboardPage/cards/ChartCard/ChartCard.tests.tsx b/frontend/pages/DashboardPage/cards/ChartCard/ChartCard.tests.tsx new file mode 100644 index 00000000000..0eeaf079546 --- /dev/null +++ b/frontend/pages/DashboardPage/cards/ChartCard/ChartCard.tests.tsx @@ -0,0 +1,144 @@ +/* eslint-disable @typescript-eslint/no-empty-function, class-methods-use-this */ +import React from "react"; +import { screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; + +import { createCustomRenderer, baseUrl } from "test/test-utils"; +import mockServer from "test/mock-server"; + +import ChartCard from "./ChartCard"; + +// Mock ResizeObserver for CheckerboardViz +const MOCK_WIDTH = 600; + +class MockResizeObserver { + callback: ResizeObserverCallback; + + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + } + + observe(target: Element) { + this.callback( + [ + { + target, + contentRect: { width: MOCK_WIDTH, height: 400 } as DOMRectReadOnly, + borderBoxSize: [], + contentBoxSize: [], + devicePixelContentBoxSize: [], + }, + ], + this + ); + } + + // eslint-disable-next-line class-methods-use-this + unobserve() {} + + // eslint-disable-next-line class-methods-use-this + disconnect() {} +} + +const generateMockChartResponse = (metric: string, days: number) => { + const data = []; + for (let d = 0; d < days; d += 1) { + const dateStr = `2026-03-${String(d + 1).padStart(2, "0")}`; + for (let h = 0; h < 24; h += 2) { + data.push({ + timestamp: `${dateStr}T${String(h).padStart(2, "0")}:00:00`, + value: Math.floor(Math.random() * 100), + }); + } + } + return { + metric, + visualization: metric === "uptime" ? "checkerboard" : "line", + total_hosts: 100, + resolution: "2h", + days, + filters: {}, + data, + }; +}; + +const chartHandler = http.get(baseUrl("/charts/:metric"), ({ params }) => { + const metric = params.metric as string; + return HttpResponse.json(generateMockChartResponse(metric, 30)); +}); + +const emptyChartHandler = http.get(baseUrl("/charts/:metric"), () => { + return HttpResponse.json({ + metric: "uptime", + visualization: "checkerboard", + total_hosts: 0, + resolution: "2h", + days: 30, + filters: {}, + data: [], + }); +}); + +describe("ChartCard", () => { + const origGetBCR = Element.prototype.getBoundingClientRect; + const origResizeObserver = global.ResizeObserver; + + beforeAll(() => { + global.ResizeObserver = (MockResizeObserver as unknown) as typeof ResizeObserver; + Element.prototype.getBoundingClientRect = function mockBCR() { + return { + width: MOCK_WIDTH, + height: 400, + top: 0, + left: 0, + bottom: 400, + right: MOCK_WIDTH, + x: 0, + y: 0, + toJSON: () => {}, + }; + }; + }); + + afterAll(() => { + Element.prototype.getBoundingClientRect = origGetBCR; + global.ResizeObserver = origResizeObserver; + }); + + it("renders the checkerboard visualization for uptime (default)", async () => { + mockServer.use(chartHandler); + const render = createCustomRenderer({ withBackendMock: true }); + const { container } = render(); + + // Wait for data to load — checkerboard cells should appear + await waitFor(() => { + const rects = container.querySelectorAll("rect"); + expect(rects.length).toBeGreaterThan(0); + }); + + // Legend should be visible + expect(screen.getByText("No data")).toBeInTheDocument(); + expect(screen.getByText("Less")).toBeInTheDocument(); + expect(screen.getByText("More")).toBeInTheDocument(); + }); + + it("shows the no-data message when API returns empty data", async () => { + mockServer.use(emptyChartHandler); + const render = createCustomRenderer({ withBackendMock: true }); + render(); + + await screen.findByText("No chart data available yet."); + }); + + it("renders the current dataset heading", async () => { + mockServer.use(chartHandler); + const render = createCustomRenderer({ withBackendMock: true }); + render(); + + // Only one dataset is wired up today, so it renders as a heading rather + // than a dropdown. Days selection is fixed at 30 and has no UI yet. + await waitFor(() => { + expect(screen.getByText("Hosts active")).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/pages/DashboardPage/cards/ChartCard/ChartCard.tsx b/frontend/pages/DashboardPage/cards/ChartCard/ChartCard.tsx new file mode 100644 index 00000000000..c400b25154e --- /dev/null +++ b/frontend/pages/DashboardPage/cards/ChartCard/ChartCard.tsx @@ -0,0 +1,234 @@ +import React, { useEffect, useState, useMemo } from "react"; +import { useQuery } from "react-query"; +import { format, parseISO } from "date-fns"; +import { SingleValue } from "react-select-5"; + +import chartsAPI, { + IChartResponse, + IChartRequestParams, + IChartQueryKey, +} from "services/entities/charts"; +import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; + +import Button from "components/buttons/Button"; +import Spinner from "components/Spinner"; +import DataError from "components/DataError"; +import DropdownWrapper from "components/forms/fields/DropdownWrapper"; +import { CustomOptionType } from "components/forms/fields/DropdownWrapper/DropdownWrapper"; +import Icon from "components/Icon"; +import TooltipWrapper from "components/TooltipWrapper"; + +import ChartFilterModal, { IChartFilterState } from "./ChartFilterModal"; +import LineChartViz from "./LineChartViz"; +import CheckerboardViz from "./CheckerboardViz"; +import { IDataSet, IFormattedDataPoint } from "./types"; + +const baseClass = "chart-card"; + +// All charts are currently fixed at a 30-day window. When the server supports +// configurable ranges we'll add UI and request-param plumbing for this. +const CHART_DAYS = 30; + +const DATASETS: IDataSet[] = [ + { + name: "uptime", + label: "Hosts active", + defaultChartType: "checkerboard", + description: ( + <> + Shows the number of hosts detected online +
+ during a given hour. + + ), + }, +]; + +const DATASET_OPTIONS: CustomOptionType[] = DATASETS.map((ds) => ({ + label: ds.label, + value: ds.name, +})); + +const getDataset = (name: string): IDataSet => + DATASETS.find((ds) => ds.name === name) || DATASETS[0]; + +const hasActiveFilters = (filters: IChartFilterState): boolean => { + const hasHostFilter = + filters.hostFilterMode !== "none" && filters.selectedHosts.length > 0; + return ( + filters.labelIDs.length > 0 || filters.platforms.length > 0 || hasHostFilter + ); +}; + +interface IChartCardProps { + currentTeamId?: number; +} + +const ChartCard = ({ currentTeamId }: IChartCardProps): JSX.Element => { + const [selectedMetric, setSelectedMetric] = useState("uptime"); + const [showFilterModal, setShowFilterModal] = useState(false); + const [chartFilters, setChartFilters] = useState({ + labelIDs: [], + platforms: [], + hostFilterMode: "none", + selectedHosts: [], + }); + + // Labels and selected hosts are team-scoped, so clear filters when the + // active fleet changes to avoid submitting stale IDs under the new scope. + useEffect(() => { + setChartFilters({ + labelIDs: [], + platforms: [], + hostFilterMode: "none", + selectedHosts: [], + }); + }, [currentTeamId]); + + const currentDataset = getDataset(selectedMetric); + + const queryParams: IChartRequestParams = useMemo(() => { + return { + days: CHART_DAYS, + tz_offset: new Date().getTimezoneOffset(), + fleet_id: currentTeamId, + label_ids: chartFilters.labelIDs.length + ? chartFilters.labelIDs.join(",") + : undefined, + platforms: chartFilters.platforms.length + ? chartFilters.platforms.join(",") + : undefined, + include_host_ids: + chartFilters.hostFilterMode === "include" && + chartFilters.selectedHosts.length + ? chartFilters.selectedHosts.map((h) => h.id).join(",") + : undefined, + exclude_host_ids: + chartFilters.hostFilterMode === "exclude" && + chartFilters.selectedHosts.length + ? chartFilters.selectedHosts.map((h) => h.id).join(",") + : undefined, + }; + }, [chartFilters, currentTeamId]); + + const { data: chartData, isLoading, error } = useQuery< + IChartResponse, + Error, + IChartResponse, + IChartQueryKey[] + >( + [{ scope: "chart", metric: selectedMetric, params: queryParams }], + () => chartsAPI.getChartData(selectedMetric, queryParams), + { + ...DEFAULT_USE_QUERY_OPTIONS, + staleTime: 300000, // 5 minutes + } + ); + + const formattedData: IFormattedDataPoint[] = useMemo(() => { + if (!chartData?.data) return []; + const totalHosts = chartData.total_hosts || 1; + return chartData.data.map((point) => { + const date = parseISO(point.timestamp); + return { + timestamp: point.timestamp, + label: format(date, "MMM d, h:mm a"), + value: point.value, + percentage: Math.round((point.value / totalHosts) * 100), + }; + }); + }, [chartData]); + + const renderChart = () => { + if (isLoading) { + return ; + } + if (error) { + return ; + } + if (!formattedData.length) { + return ( +
+ No chart data available yet. +
+ ); + } + + const vizProps = { + data: formattedData, + selectedDays: CHART_DAYS, + }; + + switch (currentDataset.defaultChartType) { + case "checkerboard": + return ; + case "line": + default: + return ; + } + }; + + return ( +
+
+
+ {DATASET_OPTIONS.length > 1 ? ( + ) => { + if (option) { + setSelectedMetric(option.value); + } + }} + className={`${baseClass}__dataset-dropdown`} + /> + ) : ( +

{currentDataset.label}

+ )} + {currentDataset.description && ( + + + + )} + {hasActiveFilters(chartFilters) && ( + Filtered + )} +
+
+ +
+
+
{renderChart()}
+ {showFilterModal && ( + { + setChartFilters(newFilters); + setShowFilterModal(false); + }} + onCancel={() => setShowFilterModal(false)} + /> + )} +
+ ); +}; + +export default ChartCard; diff --git a/frontend/pages/DashboardPage/cards/ChartCard/ChartFilterModal/ChartFilterModal.tsx b/frontend/pages/DashboardPage/cards/ChartCard/ChartFilterModal/ChartFilterModal.tsx new file mode 100644 index 00000000000..24f28e537fb --- /dev/null +++ b/frontend/pages/DashboardPage/cards/ChartCard/ChartFilterModal/ChartFilterModal.tsx @@ -0,0 +1,330 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useQuery } from "react-query"; +import { Tab, TabList, TabPanel, Tabs } from "react-tabs"; +import { useDebouncedCallback } from "use-debounce"; + +import { IHost } from "interfaces/host"; +import { ILabelSummary } from "interfaces/label"; +import hostsAPI, { ILoadHostsResponse } from "services/entities/hosts"; +import labelsAPI from "services/entities/labels"; +import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; + +import Modal from "components/Modal"; +import Button from "components/buttons/Button"; +import TabNav from "components/TabNav"; +import TabText from "components/TabText"; +import Checkbox from "components/forms/fields/Checkbox"; +import Icon from "components/Icon"; +import SearchField from "components/forms/fields/SearchField"; +// @ts-ignore +import Dropdown from "components/forms/fields/Dropdown"; + +const baseClass = "chart-filter-modal"; + +const PLATFORM_OPTIONS = [ + { label: "macOS", value: "darwin" }, + { label: "Windows", value: "windows" }, + { label: "Linux", value: "linux" }, + { label: "ChromeOS", value: "chrome" }, + { label: "iOS", value: "ios" }, + { label: "iPadOS", value: "ipados" }, + { label: "Android", value: "android" }, +]; + +type HostFilterMode = "none" | "include" | "exclude"; + +export interface IChartFilterState { + labelIDs: number[]; + platforms: string[]; + hostFilterMode: HostFilterMode; + selectedHosts: IHost[]; +} + +interface IChartFilterModalProps { + filters: IChartFilterState; + currentTeamId?: number; + onApply: (filters: IChartFilterState) => void; + onCancel: () => void; +} + +const PAGE_SIZE = 20; +const SEARCH_DEBOUNCE_MS = 300; + +const ChartFilterModal = ({ + filters, + currentTeamId, + onApply, + onCancel, +}: IChartFilterModalProps): JSX.Element => { + const [selectedLabelIDs, setSelectedLabelIDs] = useState( + filters.labelIDs + ); + const [selectedPlatforms, setSelectedPlatforms] = useState( + filters.platforms + ); + // Host filter mode is either "include" or "exclude", used when selecting + // individual hosts to filter on. + const [hostFilterMode, setHostFilterMode] = useState( + filters.hostFilterMode === "none" ? "exclude" : filters.hostFilterMode + ); + // Individual hosts selected for filtering. + const [selectedHosts, setSelectedHosts] = useState( + filters.selectedHosts + ); + const [searchInput, setSearchInput] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); + const [pageCount, setPageCount] = useState(1); + const [searchFieldKey, setSearchFieldKey] = useState(0); + + const listRef = useRef(null); + const selectedHostIds = new Set(selectedHosts.map((h) => h.id)); + + const debouncedSetSearchQuery = useDebouncedCallback((value: string) => { + setSearchQuery(value); + setPageCount(1); + if (listRef.current) { + listRef.current.scrollTop = 0; + } + }, SEARCH_DEBOUNCE_MS); + + // Flush pending debounced call on unmount so it doesn't fire after teardown. + useEffect(() => { + return () => debouncedSetSearchQuery.cancel(); + }, [debouncedSetSearchQuery]); + + // Fetch hosts with pagination — load all pages up to pageCount. + // Note that we use infinite scrolling in the UI, rather than + // traditional pagination controls, so we keep previously loaded + // pages in the cache and just increase the page count as the user scrolls. + const { + data: hostsData, + isLoading: isLoadingHosts, + error: hostsError, + } = useQuery( + ["chartFilterHosts", currentTeamId, searchQuery, pageCount], + () => + hostsAPI.loadHosts({ + page: 0, + perPage: pageCount * PAGE_SIZE, + teamId: currentTeamId, + globalFilter: searchQuery || undefined, + sortBy: [{ key: "display_name", direction: "asc" }], + }), + { + keepPreviousData: true, + staleTime: 30000, + } + ); + + const hosts = hostsData?.hosts ?? []; + const hasMore = hosts.length === pageCount * PAGE_SIZE; + + // This implements "infinite" scrolling by increasing the page count when the user scrolls + // near the bottom of the list. + const handleScroll = useCallback(() => { + const el = listRef.current; + if (!el || !hasMore || isLoadingHosts) return; + if (el.scrollTop + el.clientHeight >= el.scrollHeight - 40) { + setPageCount((prev) => prev + 1); + } + }, [hasMore, isLoadingHosts]); + + const handleSearchChange = useCallback( + (value: string) => { + setSearchInput(value); + debouncedSetSearchQuery(value); + }, + [debouncedSetSearchQuery] + ); + + const { data: labels } = useQuery( + ["labelsSummary", currentTeamId], + () => labelsAPI.summary(currentTeamId ?? null).then((res) => res.labels), + { + ...DEFAULT_USE_QUERY_OPTIONS, + staleTime: 60000, + } + ); + + const labelOptions = (labels || []) + .filter((l) => l.label_type !== "builtin") + .map((l) => ({ + label: l.name, + value: l.id, + })); + + const handleApply = () => { + onApply({ + labelIDs: selectedLabelIDs, + platforms: selectedPlatforms, + hostFilterMode, + selectedHosts, + }); + }; + + const handleClear = () => { + setSelectedLabelIDs([]); + setSelectedPlatforms([]); + setHostFilterMode("none"); + setSelectedHosts([]); + setSearchInput(""); + setSearchQuery(""); + setPageCount(1); + setSearchFieldKey((k) => k + 1); + debouncedSetSearchQuery.cancel(); + }; + + const handleTabChange = (index: number) => { + const mode = index === 0 ? "exclude" : "include"; + setHostFilterMode(mode); + }; + + const toggleHost = (host: IHost) => { + if (selectedHostIds.has(host.id)) { + setSelectedHosts((prev) => prev.filter((h) => h.id !== host.id)); + } else { + setSelectedHosts((prev) => [...prev, host]); + } + }; + + const removeHost = (hostId: number) => { + setSelectedHosts((prev) => prev.filter((h) => h.id !== hostId)); + }; + + const hasFilters = + selectedLabelIDs.length > 0 || + selectedPlatforms.length > 0 || + selectedHosts.length > 0; + + const tabIndex = hostFilterMode === "include" ? 1 : 0; + + const renderHostSearch = () => ( +
+ + {selectedHosts.length > 0 && ( +
+ {selectedHosts.map((host) => ( + + ))} +
+ )} +
+ {hosts.map((host) => ( +
+ toggleHost(host)} + > + {host.display_name} + +
+ ))} + {hostsError && ( +
+ Couldn't load hosts. Please try again. +
+ )} + {!hostsError && isLoadingHosts && ( +
Loading...
+ )} + {!hostsError && !isLoadingHosts && hosts.length === 0 && ( +
+ No matching hosts. +
+ )} +
+
+ ); + + return ( + +
+ { + if (!value) { + setSelectedLabelIDs([]); + } else { + setSelectedLabelIDs(value.split(",").map(Number)); + } + }} + multi + placeholder="All labels" + searchable + clearable + /> + { + if (!value) { + setSelectedPlatforms([]); + } else { + setSelectedPlatforms(value.split(",")); + } + }} + multi + placeholder="All platforms" + searchable={false} + clearable + /> + + + + + Exclude hosts + + + Specific hosts + + + {/* Only render the active tab to avoid two parallel host lists + fighting over the shared listRef and duplicating API requests. */} + {tabIndex === 0 && renderHostSearch()} + {tabIndex === 1 && renderHostSearch()} + + +
+
+ {hasFilters && ( + + )} +
+ + +
+
+
+ ); +}; + +export default ChartFilterModal; diff --git a/frontend/pages/DashboardPage/cards/ChartCard/ChartFilterModal/_styles.scss b/frontend/pages/DashboardPage/cards/ChartCard/ChartFilterModal/_styles.scss new file mode 100644 index 00000000000..45e6999bf94 --- /dev/null +++ b/frontend/pages/DashboardPage/cards/ChartCard/ChartFilterModal/_styles.scss @@ -0,0 +1,81 @@ +.chart-filter-modal { + &__form { + display: flex; + flex-direction: column; + gap: $pad-large; + margin-bottom: $pad-xlarge; + } + + &__btn-wrap { + display: flex; + justify-content: space-between; + align-items: center; + } + + &__btn-actions { + display: flex; + gap: $pad-small; + margin-left: auto; + } + + &__host-search { + display: flex; + flex-direction: column; + gap: $pad-medium; + } + + &__pills { + display: flex; + flex-wrap: wrap; + gap: $pad-xsmall; + } + + &__pill { + display: inline-flex; + align-items: center; + gap: $pad-xsmall; + padding: 4px $pad-small; + border: 1px solid $ui-fleet-black-25; + border-radius: $border-radius; + background: $core-fleet-white; + font-size: $xx-small; + color: $core-fleet-black; + cursor: pointer; + + &:hover { + border-color: $ui-fleet-black-50; + } + + .fleeticon { + font-size: 10px; + color: $ui-fleet-black-50; + } + } + + &__results-list { + max-height: 280px; + overflow-y: auto; + border: 1px solid $ui-fleet-black-10; + border-radius: $border-radius; + } + + &__results-row { + padding: $pad-small $pad-medium; + border-bottom: 1px solid $ui-fleet-black-10; + + &:last-child { + border-bottom: none; + } + + .fleet-checkbox { + margin: 0; + } + } + + &__results-status { + padding: $pad-medium; + text-align: center; + font-size: $x-small; + color: $ui-fleet-black-50; + } +} diff --git a/frontend/pages/DashboardPage/cards/ChartCard/ChartFilterModal/index.ts b/frontend/pages/DashboardPage/cards/ChartCard/ChartFilterModal/index.ts new file mode 100644 index 00000000000..24aa8b6a821 --- /dev/null +++ b/frontend/pages/DashboardPage/cards/ChartCard/ChartFilterModal/index.ts @@ -0,0 +1,2 @@ +export { default } from "./ChartFilterModal"; +export type { IChartFilterState } from "./ChartFilterModal"; diff --git a/frontend/pages/DashboardPage/cards/ChartCard/CheckerboardViz.tests.tsx b/frontend/pages/DashboardPage/cards/ChartCard/CheckerboardViz.tests.tsx new file mode 100644 index 00000000000..789dcf67466 --- /dev/null +++ b/frontend/pages/DashboardPage/cards/ChartCard/CheckerboardViz.tests.tsx @@ -0,0 +1,207 @@ +/* eslint-disable @typescript-eslint/no-empty-function, class-methods-use-this */ +import React from "react"; +import { screen, waitFor } from "@testing-library/react"; +import { renderWithSetup } from "test/test-utils"; + +import CheckerboardViz from "./CheckerboardViz"; +import { IFormattedDataPoint } from "./types"; + +// Generate data points for a given number of days with 12 two-hour slots each +const generateData = ( + numDays: number, + percentage = 50 +): IFormattedDataPoint[] => { + const points: IFormattedDataPoint[] = []; + for (let d = 0; d < numDays; d += 1) { + const dateStr = `2026-03-${String(d + 1).padStart(2, "0")}`; + for (let h = 0; h < 24; h += 2) { + const ts = `${dateStr}T${String(h).padStart(2, "0")}:00:00`; + points.push({ + timestamp: ts, + label: `Mar ${d + 1}, ${h}:00`, + value: percentage, + percentage, + }); + } + } + return points; +}; + +// Mock ResizeObserver and getBoundingClientRect so the component +// gets a non-zero container width in jsdom +const MOCK_WIDTH = 600; + +class MockResizeObserver { + callback: ResizeObserverCallback; + + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + } + + observe(target: Element) { + this.callback( + [ + { + target, + contentRect: { width: MOCK_WIDTH, height: 400 } as DOMRectReadOnly, + borderBoxSize: [], + contentBoxSize: [], + devicePixelContentBoxSize: [], + }, + ], + this + ); + } + + unobserve() {} + + disconnect() {} +} + +describe("CheckerboardViz", () => { + const origGetBCR = Element.prototype.getBoundingClientRect; + const origResizeObserver = global.ResizeObserver; + + beforeAll(() => { + global.ResizeObserver = (MockResizeObserver as unknown) as typeof ResizeObserver; + // Mock getBoundingClientRect so the initial measurement returns non-zero + Element.prototype.getBoundingClientRect = function mockBCR() { + return { + width: MOCK_WIDTH, + height: 400, + top: 0, + left: 0, + bottom: 400, + right: MOCK_WIDTH, + x: 0, + y: 0, + toJSON: () => {}, + }; + }; + }); + + afterAll(() => { + Element.prototype.getBoundingClientRect = origGetBCR; + global.ResizeObserver = origResizeObserver; + }); + + it("renders the correct number of cells for 3 days of data", async () => { + const data = generateData(3); + // selectedDays={14} → hoursPerSlot=2 → 12 hour rows per day. + const { container } = renderWithSetup( + + ); + + // 3 days × 12 hour rows = 36 cells + await waitFor(() => { + const rects = container.querySelectorAll("rect"); + expect(rects).toHaveLength(36); + }); + }); + + it("applies level-0 class for 0% data points", async () => { + const data = generateData(1, 0); + const { container } = renderWithSetup( + + ); + + await waitFor(() => { + const rects = container.querySelectorAll("rect"); + expect(rects.length).toBeGreaterThan(0); + }); + + const rects = container.querySelectorAll("rect"); + rects.forEach((rect) => { + expect(rect.getAttribute("class")).toContain("--level-0"); + }); + }); + + it("applies correct color level classes based on percentage", async () => { + const points: IFormattedDataPoint[] = [ + { + timestamp: "2026-03-01T00:00:00", + label: "Mar 1, 12am", + value: 10, + percentage: 10, + }, + { + timestamp: "2026-03-01T02:00:00", + label: "Mar 1, 2am", + value: 30, + percentage: 30, + }, + { + timestamp: "2026-03-01T04:00:00", + label: "Mar 1, 4am", + value: 50, + percentage: 50, + }, + { + timestamp: "2026-03-01T06:00:00", + label: "Mar 1, 6am", + value: 70, + percentage: 70, + }, + { + timestamp: "2026-03-01T08:00:00", + label: "Mar 1, 8am", + value: 90, + percentage: 90, + }, + ]; + + // selectedDays={1} renders each point as its own cell without + // slot-bucketing, so every percentage maps to a distinct color level. + const { container } = renderWithSetup( + + ); + + await waitFor(() => { + expect(container.querySelectorAll("rect").length).toBeGreaterThan(0); + }); + + const rects = container.querySelectorAll("rect"); + const classNames = Array.from(rects).map( + (r) => r.getAttribute("class") || "" + ); + for (let level = 1; level <= 5; level += 1) { + expect(classNames.some((c) => c.includes(`--level-${level}`))).toBe(true); + } + }); + + it("renders the legend with all color levels", () => { + const data = generateData(1); + renderWithSetup(); + + expect(screen.getByText("No data")).toBeInTheDocument(); + expect(screen.getByText("Less")).toBeInTheDocument(); + expect(screen.getByText("More")).toBeInTheDocument(); + }); + + it("fills empty hour slots with level-0 cells", async () => { + const points: IFormattedDataPoint[] = [ + { + timestamp: "2026-03-01T00:00:00", + label: "Mar 1, 12am", + value: 80, + percentage: 80, + }, + ]; + + // selectedDays={14} → hoursPerSlot=2 → 12 hour rows per day. + const { container } = renderWithSetup( + + ); + + await waitFor(() => { + expect(container.querySelectorAll("rect").length).toBeGreaterThan(0); + }); + + const rects = container.querySelectorAll("rect"); + const level0Count = Array.from(rects).filter((r) => + (r.getAttribute("class") || "").includes("--level-0") + ).length; + // 11 of 12 rows should be level-0 (only slot 0 has data) + expect(level0Count).toBe(11); + }); +}); diff --git a/frontend/pages/DashboardPage/cards/ChartCard/CheckerboardViz.tsx b/frontend/pages/DashboardPage/cards/ChartCard/CheckerboardViz.tsx new file mode 100644 index 00000000000..d4e3fe58638 --- /dev/null +++ b/frontend/pages/DashboardPage/cards/ChartCard/CheckerboardViz.tsx @@ -0,0 +1,313 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { format, parseISO } from "date-fns"; + +import { IFormattedDataPoint } from "./types"; + +const baseClass = "checkerboard-viz"; + +// Returns a CSS class suffix for the color level (0-5). Buckets match the +// six legend swatches declared in _styles.scss (level-0 is the no-data swatch). +const getColorLevel = (percentage: number): number => { + if (percentage === 0) return 0; + if (percentage <= 20) return 1; + if (percentage <= 40) return 2; + if (percentage <= 60) return 3; + if (percentage <= 80) return 4; + return 5; +}; + +const formatHourLabel = (hourVal: number): string => { + if (hourVal === 0) return "12am"; + if (hourVal < 12) return `${hourVal}am`; + if (hourVal === 12) return "12pm"; + return `${hourVal - 12}pm`; +}; + +interface ICellData { + dayIndex: number; + hourRow: number; + percentage: number; + dayLabel: string; + hourLabel: string; +} + +interface ICheckerboardVizProps { + data: IFormattedDataPoint[]; + selectedDays: number; +} + +// These are calculated at a chart width of 580px and columns. +const CELL_W = 16.75; +const CELL_H = 19; +const CELL_GAP = 2; +const Y_AXIS_WIDTH = 40; // space for y-axis labels on the left +// When cards stack to full-width (below $break-md), the container gets wider +// than this threshold and we scale cells up by WIDE_MULTIPLIER. +const WIDE_THRESHOLD = 700; +const WIDE_MULTIPLIER = 1.5; + +const CheckerboardViz = ({ + data, + selectedDays, +}: ICheckerboardVizProps): JSX.Element => { + const containerRef = useRef(null); + const [isWide, setIsWide] = useState(false); + const [hoveredCell, setHoveredCell] = useState(null); + const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 }); + + useEffect(() => { + const node = containerRef.current; + if (!node) return undefined; + setIsWide(node.getBoundingClientRect().width >= WIDE_THRESHOLD); + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) { + setIsWide(entry.contentRect.width >= WIDE_THRESHOLD); + } + }); + observer.observe(node); + return () => observer.disconnect(); + }, []); + + // Hours per slot: 3 for 30-day, 2 for 7/14-day, 1 for 24-hour + const is24h = selectedDays === 1; + let hoursPerSlot = 1; + if (selectedDays === 30) { + hoursPerSlot = 3; + } else if (selectedDays >= 7) { + hoursPerSlot = 2; + } + const hourRows = 24 / hoursPerSlot; + + const { grid, dayLabels } = useMemo(() => { + // 24h view: each incoming data point becomes a single column in a + // one-row strip. No day grouping, no slot aggregation — the backend has + // already produced one point per hour and we render them in order. + if (is24h) { + const cells: ICellData[] = data.map((point, i) => { + const date = parseISO(point.timestamp); + return { + dayIndex: 0, + hourRow: i, + percentage: point.percentage, + dayLabel: format(date, "MMM d"), + hourLabel: formatHourLabel(date.getHours()), + }; + }); + return { grid: cells, dayLabels: ["today"] }; + } + + // Multi-day view: build a day × time-slot grid. + // + // `dayMap` is keyed by calendar day (yyyy-MM-dd) and each day holds a + // map of slot-index → data point. `dayOrder` preserves first-seen order + // so columns render chronologically regardless of Map iteration quirks. + const dayMap = new Map>(); + const dayOrder: string[] = []; + + data.forEach((point) => { + const date = parseISO(point.timestamp); + const dayKey = format(date, "yyyy-MM-dd"); + const hour = date.getHours(); + // `hoursPerSlot` > 1 for wider windows (2 for 7/14-day, 3 for 30-day), + // so multiple hourly points collapse into the same slot row. + const slot = Math.floor(hour / hoursPerSlot); + + if (!dayMap.has(dayKey)) { + dayMap.set(dayKey, new Map()); + dayOrder.push(dayKey); + } + const hourMap = dayMap.get(dayKey); + if (hourMap) { + // When several hourly points land in the same slot, keep the max + // percentage. This biases visualizations toward "hosts had data + // at some point during this window". + const existing = hourMap.get(slot); + if (!existing || point.percentage > existing.percentage) { + hourMap.set(slot, point); + } + } + }); + + // Emit one cell per (day, slot) pair in a fixed grid. Days with no data + // for a given slot still get a cell with percentage 0 so the grid stays + // rectangular and the color ramp renders the "no data" swatch. + const labels: string[] = []; + const cells: ICellData[] = []; + + dayOrder.forEach((dayKey, dayIndex) => { + const date = parseISO(dayKey); + labels.push(format(date, "MMM d")); + const hourMap = dayMap.get(dayKey); + + for (let row = 0; row < hourRows; row += 1) { + const point = hourMap?.get(row); + const hourVal = row * hoursPerSlot; + cells.push({ + dayIndex, + hourRow: row, + percentage: point?.percentage ?? 0, + dayLabel: format(date, "MMM d"), + hourLabel: formatHourLabel(hourVal), + }); + } + }); + + return { grid: cells, dayLabels: labels }; + }, [data, hoursPerSlot, hourRows, is24h]); + + const numDays = dayLabels.length || 1; + + // For 24h: hours are columns, single row. Otherwise: days are columns, hours are rows. + const numCols = is24h ? hourRows : numDays; + const numRows = is24h ? 1 : hourRows; + + const scale = isWide ? WIDE_MULTIPLIER : 1; + const cellW = CELL_W * scale; + const cellH = CELL_H * scale; + const gridWidth = cellW * numCols + CELL_GAP * (numCols - 1); + const gridHeight = cellH * numRows + CELL_GAP * (numRows - 1); + + // Compute x-axis date labels: start, middle, end + const xAxisDates = useMemo(() => { + if (dayLabels.length < 2) return { start: "", middle: "", end: "" }; + const midIndex = Math.floor(dayLabels.length / 2); + return { + start: dayLabels[0], + middle: dayLabels[midIndex], + end: dayLabels[dayLabels.length - 1], + }; + }, [dayLabels]); + + const handleMouseEnter = (cell: ICellData, e: React.MouseEvent) => { + setHoveredCell(cell); + const rect = (e.target as SVGElement).getBoundingClientRect(); + const containerRect = containerRef.current?.getBoundingClientRect(); + if (containerRect) { + setTooltipPos({ + x: rect.left - containerRect.left + cellW / 2, + y: rect.top - containerRect.top - 8, + }); + } + }; + + const handleMouseLeave = () => { + setHoveredCell(null); + }; + + const showYAxis = !is24h; + const leftMargin = showYAxis ? Y_AXIS_WIDTH : 0; + + return ( +
+
+ {/* Y-axis 6am/6pm labels. Kept outside the scroll-wrapper so they stay + pinned during horizontal scroll and label text can overflow + leftward without being clipped. */} + {showYAxis && ( +
+ {[ + { hour: 6, label: "6am" }, + { hour: 18, label: "6pm" }, + ].map(({ hour, label }) => { + const row = hour / hoursPerSlot; + // Position label centered vertically on the row that represents + // this hour. + const topPx = row * (cellH + CELL_GAP) + cellH / 2; + return ( +
+ {label} +
+ ); + })} +
+ )} + +
+ {/* Grid cells */} + + {grid.map((cell) => { + const col = is24h ? cell.hourRow : cell.dayIndex; + const row = is24h ? 0 : cell.hourRow; + return ( + handleMouseEnter(cell, e)} + onMouseLeave={handleMouseLeave} + /> + ); + })} + + + {/* X-axis date labels */} + {!is24h && dayLabels.length >= 2 && ( +
+ + {xAxisDates.start} + + + {xAxisDates.middle} + + + {xAxisDates.end} + +
+ )} +
+
+ + {hoveredCell && ( +
+
+ {hoveredCell.dayLabel}, {hoveredCell.hourLabel} +
+
+ {hoveredCell.percentage}% of hosts +
+
+ )} +
+ No data + + Less + {[1, 2, 3, 4, 5].map((level) => ( + + ))} + More +
+
+ ); +}; + +export default CheckerboardViz; diff --git a/frontend/pages/DashboardPage/cards/ChartCard/LineChartViz.tsx b/frontend/pages/DashboardPage/cards/ChartCard/LineChartViz.tsx new file mode 100644 index 00000000000..d9a71791ae8 --- /dev/null +++ b/frontend/pages/DashboardPage/cards/ChartCard/LineChartViz.tsx @@ -0,0 +1,90 @@ +import React, { useCallback } from "react"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import { format, parseISO } from "date-fns"; + +import { IFormattedDataPoint } from "./types"; + +const baseClass = "chart-card"; + +interface ILineChartVizProps { + data: IFormattedDataPoint[]; + selectedDays: number; +} + +// Use the design-system accent token via CSS custom property so recharts +// picks up the themed value for the SVG stroke. +const LINE_STROKE = "var(--core-vibrant-blue)"; + +const LineChartViz = ({ + data, + selectedDays, +}: ILineChartVizProps): JSX.Element => { + const formatXAxis = useCallback( + (timestamp: string) => { + try { + const date = parseISO(timestamp); + return selectedDays === 1 ? format(date, "ha") : format(date, "MMM d"); + } catch { + return ""; + } + }, + [selectedDays] + ); + + const formatYAxisTick = (val: number): string => `${val}%`; + + const renderTooltip = useCallback((props: any) => { + const { active, payload } = props; + if (!active || !payload?.length) return null; + const point = payload[0].payload as IFormattedDataPoint; + return ( +
+
{point.label}
+
+ {point.percentage}% ({point.value.toLocaleString()} hosts) +
+
+ ); + }, []); + + const tickInterval = Math.max(1, Math.floor(data.length / 8)); + + return ( + + + + + + + + + + ); +}; + +export default LineChartViz; diff --git a/frontend/pages/DashboardPage/cards/ChartCard/_styles.scss b/frontend/pages/DashboardPage/cards/ChartCard/_styles.scss new file mode 100644 index 00000000000..f4a24197141 --- /dev/null +++ b/frontend/pages/DashboardPage/cards/ChartCard/_styles.scss @@ -0,0 +1,249 @@ +.chart-card { + &__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: $pad-medium; + } + + &__header-left { + display: flex; + align-items: center; + gap: $pad-small; + } + + &__description-tooltip { + display: inline-flex; + align-items: center; + } + + &__header-right { + display: flex; + align-items: center; + gap: $pad-small; + } + + &__title { + font-size: $small; + font-weight: $bold; + margin: 0 !important; + line-height: 1; + } + + &__dataset-dropdown { + // Hide the form field label + .form-field__label { + display: none; + } + + // Override inline styles from DropdownWrapper to make it look like a heading. + // !important is needed because react-select applies inline styles via the + // styles prop which always beat class-based selectors. + .react-select__control { + background-color: transparent !important; + border-color: transparent !important; + box-shadow: none !important; + padding: 0; + min-height: auto; + } + + // When menu is open, show the border + .react-select__control--menu-is-open { + border-color: $ui-fleet-black-10 !important; + background-color: $core-fleet-white !important; + } + + .react-select__single-value { + font-size: 18px; + font-weight: $bold; + color: $core-fleet-black; + } + + .react-select__indicator-separator { + display: none; + } + + .react-select__value-container { + padding: 4px 0 4px 8px; + } + } + + &__filtered-badge { + display: inline-flex; + align-items: center; + padding: 2px 10px; + font-size: $xx-small; + font-weight: $bold; + color: $ui-fleet-black-75; + border: 1px solid $ui-fleet-black-10; + border-radius: 12px; + background: $core-fleet-white; + white-space: nowrap; + } + + &__settings-btn { + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + cursor: pointer; + color: $ui-fleet-black-75; + } + + &__chart-container { + min-height: 280px; + display: flex; + align-items: center; + justify-content: center; + } + + &__no-data { + color: $ui-fleet-black-50; + font-size: $x-small; + text-align: center; + } + + &__tooltip { + background: $core-fleet-black; + color: $core-fleet-white; + padding: $pad-small $pad-medium; + border-radius: $border-radius; + font-size: $xx-small; + box-shadow: 0px 2px 6px rgba(0, 0, 0, 0.1); + } + + &__tooltip-label { + margin-bottom: $pad-xsmall; + opacity: 0.8; + } + + &__tooltip-value { + font-weight: $bold; + } +} + +.checkerboard-viz { + width: 100%; + position: relative; + + &__chart-row { + display: flex; + align-items: flex-start; + } + + &__scroll-wrapper { + flex: 1; + min-width: 0; + overflow-x: auto; + + svg { + display: block; + } + } + + &__y-axis { + position: relative; + flex-shrink: 0; + } + + &__y-axis-label { + position: absolute; + left: 0; + right: 0; + transform: translateY(-50%); + padding-right: $pad-medium; + text-align: right; + font-size: $xx-small; + color: $ui-fleet-black-50; + white-space: nowrap; + } + + &__floating-tooltip { + position: absolute; + transform: translate(-50%, -100%); + pointer-events: none; + } + + &__x-axis { + display: flex; + justify-content: space-between; + padding-top: $pad-xsmall; + font-size: $xx-small; + color: $ui-fleet-black-50; + } + + &__x-axis-label { + &--end { + text-align: right; + } + } + + &__legend { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + margin-top: $pad-small; + font-size: $xx-small; + color: $ui-fleet-black-50; + padding-top: $pad-medium; + } + + &__legend-swatch { + display: inline-block; + width: 14px; + height: 14px; + border-radius: 2px; + } + + &__legend-label { + margin: 0 4px; + + &:first-child { + margin-left: 0; + } + + &:last-child { + margin-right: 0; + } + } + + // Color levels for cells (SVG fill) and legend swatches (background-color). + // Scale colors don't exist in the shared palette, so they stay as literal + // hex values; level-4 matches $core-fleet-green and is aliased accordingly. + &__cell { + stroke: $core-fleet-white; + stroke-width: 1px; + + &--level-0 { + fill: #ebedf0; + background-color: #ebedf0; + } + + &--level-1 { + fill: #e4f1ec; + background-color: #e4f1ec; + } + + &--level-2 { + fill: #badecf; + background-color: #badecf; + } + + &--level-3 { + fill: #82d2b9; + background-color: #82d2b9; + } + + &--level-4 { + fill: $core-fleet-green; + background-color: $core-fleet-green; + } + + &--level-5 { + fill: #005b4a; + background-color: #005b4a; + } + } +} diff --git a/frontend/pages/DashboardPage/cards/ChartCard/index.ts b/frontend/pages/DashboardPage/cards/ChartCard/index.ts new file mode 100644 index 00000000000..a43a4426849 --- /dev/null +++ b/frontend/pages/DashboardPage/cards/ChartCard/index.ts @@ -0,0 +1 @@ +export { default } from "./ChartCard"; diff --git a/frontend/pages/DashboardPage/cards/ChartCard/types.ts b/frontend/pages/DashboardPage/cards/ChartCard/types.ts new file mode 100644 index 00000000000..daf9656c13f --- /dev/null +++ b/frontend/pages/DashboardPage/cards/ChartCard/types.ts @@ -0,0 +1,17 @@ +import { ReactNode } from "react"; + +export type ChartType = "line" | "checkerboard"; + +export interface IDataSet { + name: string; + label: string; + defaultChartType: ChartType; + description?: ReactNode; +} + +export interface IFormattedDataPoint { + timestamp: string; + label: string; + value: number; + percentage: number; +} diff --git a/frontend/pages/DashboardPage/cards/HostsEnrolledCard/HostsEnrolledCard.tsx b/frontend/pages/DashboardPage/cards/HostsEnrolledCard/HostsEnrolledCard.tsx new file mode 100644 index 00000000000..91aafcbd7fe --- /dev/null +++ b/frontend/pages/DashboardPage/cards/HostsEnrolledCard/HostsEnrolledCard.tsx @@ -0,0 +1,96 @@ +import React from "react"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + ResponsiveContainer, + Cell, +} from "recharts"; + +const baseClass = "hosts-enrolled-card"; + +// Use the design-system color token via CSS custom property so recharts +// picks up the themed value for the SVG fill. +const BAR_COLOR = "var(--core-fleet-green)"; + +export interface IHostPlatformCounts { + darwin: number; + windows: number; + linux: number; + chrome: number; + ios: number; + ipados: number; + android: number; +} + +interface IHostsEnrolledCardProps { + counts: IHostPlatformCounts; +} + +interface IPlatformDatum { + label: string; + count: number; +} + +const formatTick = (value: number): string => { + if (value >= 1000) { + const k = value / 1000; + return Number.isInteger(k) ? `${k}k` : `${k.toFixed(1)}k`; + } + return `${value}`; +}; + +const HostsEnrolledCard = ({ + counts, +}: IHostsEnrolledCardProps): JSX.Element => { + const data: IPlatformDatum[] = [ + { label: "macOS", count: counts.darwin }, + { label: "Windows", count: counts.windows }, + { label: "Linux", count: counts.linux }, + { label: "ChromeOS", count: counts.chrome }, + { label: "iOS", count: counts.ios }, + { label: "iPadOS", count: counts.ipados }, + { label: "Android", count: counts.android }, + ]; + + return ( +
+

Hosts enrolled

+ + + + + + + + {data.map((entry) => ( + + ))} + + + +
+ ); +}; + +export default HostsEnrolledCard; diff --git a/frontend/pages/DashboardPage/cards/HostsEnrolledCard/_styles.scss b/frontend/pages/DashboardPage/cards/HostsEnrolledCard/_styles.scss new file mode 100644 index 00000000000..78158e93fbc --- /dev/null +++ b/frontend/pages/DashboardPage/cards/HostsEnrolledCard/_styles.scss @@ -0,0 +1,7 @@ +.hosts-enrolled-card { + &__title { + font-size: $small; + font-weight: $bold; + margin: 0 0 $pad-medium !important; + } +} diff --git a/frontend/pages/DashboardPage/cards/HostsEnrolledCard/index.ts b/frontend/pages/DashboardPage/cards/HostsEnrolledCard/index.ts new file mode 100644 index 00000000000..2cfd8659da2 --- /dev/null +++ b/frontend/pages/DashboardPage/cards/HostsEnrolledCard/index.ts @@ -0,0 +1,2 @@ +export { default as HostsEnrolledCard } from "./HostsEnrolledCard"; +export type { IHostPlatformCounts } from "./HostsEnrolledCard"; diff --git a/frontend/services/entities/charts.ts b/frontend/services/entities/charts.ts new file mode 100644 index 00000000000..61718b5f07e --- /dev/null +++ b/frontend/services/entities/charts.ts @@ -0,0 +1,51 @@ +import sendRequest from "services"; +import endpoints from "utilities/endpoints"; +import { buildQueryStringFromParams } from "utilities/url"; + +export interface IChartDataPoint { + timestamp: string; + value: number; +} + +export interface IChartFilters { + label_ids?: number[]; + platforms?: string[]; + include_host_ids?: number[]; + exclude_host_ids?: number[]; +} + +export interface IChartResponse { + metric: string; + visualization: string; + total_hosts: number; + resolution: string; + days: number; + filters: IChartFilters; + data: IChartDataPoint[]; +} + +export interface IChartRequestParams { + days?: number; + resolution?: number; + tz_offset?: number; + fleet_id?: number; + label_ids?: string; + platforms?: string; + include_host_ids?: string; + exclude_host_ids?: string; +} + +export interface IChartQueryKey { + scope: "chart"; + metric: string; + params: IChartRequestParams; +} + +export default { + getChartData: (metric: string, params: IChartRequestParams = {}) => { + const queryString = buildQueryStringFromParams(params); + const endpoint = endpoints.CHART_DATA(metric); + const path = queryString ? `${endpoint}?${queryString}` : endpoint; + return sendRequest("GET", path); + }, +}; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index 66d332e9822..ab479188166 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -72,6 +72,9 @@ export default { DEVICE_BYPASS_CONDITIONAL_ACCESS: (token: string) => `/${API_VERSION}/fleet/device/${token}/bypass_conditional_access`, + // Chart endpoints + CHART_DATA: (metric: string) => `/${API_VERSION}/fleet/charts/${metric}`, + // Host endpoints HOST_SUMMARY: `/${API_VERSION}/fleet/host_summary`, HOST_QUERY_REPORT: (hostId: number, queryId: number) => diff --git a/package.json b/package.json index 11f6f69e2b2..4ba7684d486 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "react-tabs": "3.2.3", "react-tooltip": "4.2.21", "react-tooltip-5": "npm:react-tooltip@5.29.1", + "recharts": "3.8.1", "remark-gfm": "4.0.1", "sass": "1.83.4", "select": "1.1.2", diff --git a/yarn.lock b/yarn.lock index 0584edd4312..34ec211356c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2229,6 +2229,18 @@ "@parcel/watcher-win32-ia32" "2.5.6" "@parcel/watcher-win32-x64" "2.5.6" +"@reduxjs/toolkit@^1.9.0 || 2.x.x": + version "2.11.2" + resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.11.2.tgz#582225acea567329ca6848583e7dd72580d38e82" + integrity sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ== + dependencies: + "@standard-schema/spec" "^1.0.0" + "@standard-schema/utils" "^0.3.0" + immer "^11.0.0" + redux "^5.0.1" + redux-thunk "^3.1.0" + reselect "^5.1.0" + "@sgress454/node-sql-parser@5.4.0-fork.1": version "5.4.0-fork.1" resolved "https://registry.npmjs.org/@sgress454/node-sql-parser/-/node-sql-parser-5.4.0-fork.1.tgz" @@ -2285,6 +2297,16 @@ dependencies: "@sinonjs/commons" "^3.0.1" +"@standard-schema/spec@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" + integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== + +"@standard-schema/utils@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b" + integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g== + "@storybook/addon-a11y@8.4.7": version "8.4.7" resolved "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-8.4.7.tgz" @@ -2820,6 +2842,57 @@ resolved "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz" integrity sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA== +"@types/d3-array@^3.0.3": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.2.2.tgz#e02151464d02d4a1b44646d0fcdb93faf88fde8c" + integrity sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw== + +"@types/d3-color@*": + version "3.1.3" + resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2" + integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A== + +"@types/d3-ease@^3.0.0": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.2.tgz#e28db1bfbfa617076f7770dd1d9a48eaa3b6c51b" + integrity sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA== + +"@types/d3-interpolate@^3.0.1": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c" + integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA== + dependencies: + "@types/d3-color" "*" + +"@types/d3-path@*": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.1.1.tgz#f632b380c3aca1dba8e34aa049bcd6a4af23df8a" + integrity sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg== + +"@types/d3-scale@^4.0.2": + version "4.0.9" + resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.9.tgz#57a2f707242e6fe1de81ad7bfcccaaf606179afb" + integrity sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw== + dependencies: + "@types/d3-time" "*" + +"@types/d3-shape@^3.1.0": + version "3.1.8" + resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.8.tgz#d1516cc508753be06852cd06758e3bb54a22b0e3" + integrity sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w== + dependencies: + "@types/d3-path" "*" + +"@types/d3-time@*", "@types/d3-time@^3.0.0": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.4.tgz#8472feecd639691450dd8000eb33edd444e1323f" + integrity sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g== + +"@types/d3-timer@^3.0.0": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70" + integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw== + "@types/debug@^4.0.0": version "4.1.12" resolved "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz" @@ -3146,6 +3219,11 @@ resolved "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz" integrity sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA== +"@types/use-sync-external-store@^0.0.6": + version "0.0.6" + resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc" + integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg== + "@types/uuid@8.3.4": version "8.3.4" resolved "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz" @@ -4451,6 +4529,11 @@ clsx@^1.1.0: resolved "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz" integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== +clsx@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" + integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== + co@^4.6.0: version "4.6.0" resolved "https://registry.npmjs.org/co/-/co-4.6.0.tgz" @@ -4806,6 +4889,77 @@ cwd@^0.10.0: find-pkg "^0.1.2" fs-exists-sync "^0.1.0" +"d3-array@2 - 3", "d3-array@2.10.0 - 3", d3-array@^3.1.6: + version "3.2.4" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5" + integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg== + dependencies: + internmap "1 - 2" + +"d3-color@1 - 3": + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" + integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA== + +d3-ease@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4" + integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w== + +"d3-format@1 - 3": + version "3.1.2" + resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.2.tgz#01fdb46b58beb1f55b10b42ad70b6e344d5eb2ae" + integrity sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg== + +"d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d" + integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g== + dependencies: + d3-color "1 - 3" + +d3-path@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.1.0.tgz#22df939032fb5a71ae8b1800d61ddb7851c42526" + integrity sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ== + +d3-scale@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396" + integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ== + dependencies: + d3-array "2.10.0 - 3" + d3-format "1 - 3" + d3-interpolate "1.2.0 - 3" + d3-time "2.1.1 - 3" + d3-time-format "2 - 4" + +d3-shape@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5" + integrity sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA== + dependencies: + d3-path "^3.1.0" + +"d3-time-format@2 - 4": + version "4.1.0" + resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a" + integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg== + dependencies: + d3-time "1 - 3" + +"d3-time@1 - 3", "d3-time@2.1.1 - 3", d3-time@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.1.0.tgz#9310db56e992e3c0175e1ef385e545e48a9bb5c7" + integrity sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q== + dependencies: + d3-array "2 - 3" + +d3-timer@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" + integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== + damerau-levenshtein@^1.0.0: version "1.0.8" resolved "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz" @@ -4882,6 +5036,11 @@ decamelize@^1.2.0: resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz" integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== +decimal.js-light@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934" + integrity sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg== + decimal.js@^10.5.0: version "10.6.0" resolved "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz" @@ -5411,6 +5570,11 @@ es-to-primitive@^1.2.1, es-to-primitive@^1.3.0: is-date-object "^1.0.5" is-symbol "^1.0.4" +es-toolkit@^1.39.3: + version "1.45.1" + resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.45.1.tgz#21b28b2bd43178fd4c9c937c445d5bcaccce907b" + integrity sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw== + es6-error@^4.0.1: version "4.1.1" resolved "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz" @@ -5799,6 +5963,11 @@ esutils@^2.0.2: resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== +eventemitter3@^5.0.1: + version "5.0.4" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.4.tgz#a86d66170433712dde814707ac52b5271ceb1feb" + integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== + events@^3.0.0, events@^3.2.0: version "3.3.0" resolved "https://registry.npmjs.org/events/-/events-3.3.0.tgz" @@ -6758,6 +6927,16 @@ ignore@^5.2.0: resolved "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz" integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== +immer@^10.1.1: + version "10.2.0" + resolved "https://registry.yarnpkg.com/immer/-/immer-10.2.0.tgz#88a4ce06a1af64172d254b70f7cb04df51c871b1" + integrity sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw== + +immer@^11.0.0: + version "11.1.4" + resolved "https://registry.yarnpkg.com/immer/-/immer-11.1.4.tgz#37aee86890b134a8f1a2fadd44361fb86c6ae67e" + integrity sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw== + immutable@^5.0.2: version "5.1.5" resolved "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz" @@ -6826,6 +7005,11 @@ internal-slot@^1.1.0: hasown "^2.0.2" side-channel "^1.1.0" +"internmap@1 - 2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009" + integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== + interpret@^1.0.0: version "1.4.0" resolved "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz" @@ -9866,6 +10050,14 @@ react-query@3.39.3: broadcast-channel "^3.4.1" match-sorter "^6.0.2" +"react-redux@8.x.x || 9.x.x": + version "9.2.0" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.2.0.tgz#96c3ab23fb9a3af2cb4654be4b51c989e32366f5" + integrity sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g== + dependencies: + "@types/use-sync-external-store" "^0.0.6" + use-sync-external-store "^1.4.0" + react-router-dom@^4.1.1: version "4.3.1" resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-4.3.1.tgz" @@ -10035,6 +10227,23 @@ recast@^0.23.5: tiny-invariant "^1.3.3" tslib "^2.0.1" +recharts@3.8.1: + version "3.8.1" + resolved "https://registry.yarnpkg.com/recharts/-/recharts-3.8.1.tgz#1784b14784dab9a27eb426c475e6a9187f14cf01" + integrity sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg== + dependencies: + "@reduxjs/toolkit" "^1.9.0 || 2.x.x" + clsx "^2.1.1" + decimal.js-light "^2.5.1" + es-toolkit "^1.39.3" + eventemitter3 "^5.0.1" + immer "^10.1.1" + react-redux "8.x.x || 9.x.x" + reselect "5.1.1" + tiny-invariant "^1.3.3" + use-sync-external-store "^1.2.2" + victory-vendor "^37.0.2" + rechoir@^0.8.0: version "0.8.0" resolved "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz" @@ -10050,6 +10259,16 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" +redux-thunk@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3" + integrity sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw== + +redux@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b" + integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w== + reflect.getprototypeof@^1.0.10, reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9: version "1.0.10" resolved "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz" @@ -10222,6 +10441,11 @@ requires-port@^1.0.0: resolved "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz" integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== +reselect@5.1.1, reselect@^5.1.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.1.1.tgz#c766b1eb5d558291e5e550298adb0becc24bb72e" + integrity sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w== + resolve-cwd@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz" @@ -11457,6 +11681,11 @@ use-debounce@9.0.4: resolved "https://registry.npmjs.org/use-debounce/-/use-debounce-9.0.4.tgz" integrity sha512-6X8H/mikbrt0XE8e+JXRtZ8yYVvKkdYRfmIhWZYsP8rcNs9hk3APV8Ua2mFkKRLcJKVdnX2/Vwrmg2GWKUQEaQ== +use-sync-external-store@^1.2.2, use-sync-external-store@^1.4.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d" + integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== + util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" @@ -11547,6 +11776,26 @@ vfile@^6.0.0: "@types/unist" "^3.0.0" vfile-message "^4.0.0" +victory-vendor@^37.0.2: + version "37.3.6" + resolved "https://registry.yarnpkg.com/victory-vendor/-/victory-vendor-37.3.6.tgz#401ac4b029a0b3d33e0cba8e8a1d765c487254da" + integrity sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ== + dependencies: + "@types/d3-array" "^3.0.3" + "@types/d3-ease" "^3.0.0" + "@types/d3-interpolate" "^3.0.1" + "@types/d3-scale" "^4.0.2" + "@types/d3-shape" "^3.1.0" + "@types/d3-time" "^3.0.0" + "@types/d3-timer" "^3.0.0" + d3-array "^3.1.6" + d3-ease "^3.0.1" + d3-interpolate "^3.0.1" + d3-scale "^4.0.2" + d3-shape "^3.1.0" + d3-time "^3.0.0" + d3-timer "^3.0.1" + vm-browserify@^1.0.1: version "1.1.2" resolved "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz"