Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/43769-added-charts-to-dashboard
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added "Hosts active" and "Hosts enrolled" charts to dashboard.
72 changes: 60 additions & 12 deletions frontend/pages/DashboardPage/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<IHostPlatformCounts>(() => {
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<IHostPlatformCounts>(
(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]);
Comment thread
sgress454 marked this conversation as resolved.

const { isLoading: isGlobalSecretsLoading, data: globalSecrets } = useQuery<
IEnrollSecretsResponse,
Error,
Expand Down Expand Up @@ -889,6 +941,14 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => {
</div>
</div>
</div>
<div className={`${baseClass}__charts-row`}>
<Card paddingSize="xlarge" borderRadiusSize="large">
<HostsEnrolledCard counts={totalCounts} />
</Card>
<Card paddingSize="xlarge" borderRadiusSize="large">
<ChartCard currentTeamId={teamIdForApi} />
</Card>
</div>
<div className={`${baseClass}__platforms`}>
<span>Platform:&nbsp;</span>
<DropdownWrapper
Expand All @@ -908,18 +968,6 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => {
}}
/>
</div>

<div className={`${baseClass}__host-sections`}>
<>
{isHostSummaryFetching ? (
<Card paddingSize="medium">
<Spinner includeContainer={false} verticalPadding="small" />
</Card>
) : (
HostCountCards
)}
</>
</div>
{renderCards()}
{showAddHostsModal && renderAddHostsModal()}
{showMdmSolutionModal && renderMdmSolutionModal()}
Expand Down
10 changes: 10 additions & 0 deletions frontend/pages/DashboardPage/_styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
144 changes: 144 additions & 0 deletions frontend/pages/DashboardPage/cards/ChartCard/ChartCard.tests.tsx
Original file line number Diff line number Diff line change
@@ -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,
Comment thread
sgress454 marked this conversation as resolved.
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(<ChartCard />);

// 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(<ChartCard />);

await screen.findByText("No chart data available yet.");
});

it("renders the current dataset heading", async () => {
mockServer.use(chartHandler);
const render = createCustomRenderer({ withBackendMock: true });
render(<ChartCard />);

// 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();
});
});
});
Loading
Loading