[Host details > Reports] Frontend changes (#42017)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #41533

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [ ] Added/updated automated tests

- [x] QA'd all new/changed functionality manually



https://github.com/user-attachments/assets/64a5f726-1e9f-4508-8726-6227813dcc77

Below I show the `Report clipped` and the `X additional results not
shown` states. For that, I manually inserted records in my DB:

```sql
-- make "clipped"
  INSERT INTO query_results (query_id, host_id, last_fetched, data)
  SELECT 1, t.n + 1000, NOW(), '{"fake_key": "fake_value"}'
  FROM (
      SELECT a.N + b.N * 10 + c.N * 100 AS n
      FROM (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION
  SELECT 9) a,
           (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION
  SELECT 9) b,
           (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION
  SELECT 9) c
  ) t
  WHERE t.n BETWEEN 1 AND 999;

-- populate extra query results
INSERT INTO query_results (query_id, host_id, last_fetched, data)
  VALUES
    (1, 2, NOW(), '{"pid": "9999", "version": "5.21.0"}'),
    (1, 2, NOW(), '{"pid": "8888", "version": "5.20.0"}');
```


https://github.com/user-attachments/assets/8056ea4c-b042-47cf-a05f-ee9d8621252a

Pagination (manually changed to 3 items per page for testing purposes)



https://github.com/user-attachments/assets/87a97259-0821-4659-a612-c952e98a158c
This commit is contained in:
Nico 2026-03-24 10:45:34 -03:00 committed by GitHub
parent 994843f330
commit a265768d20
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 857 additions and 188 deletions

View file

@ -0,0 +1 @@
* Added Reports tab to Host details page.

View file

@ -1,17 +1,21 @@
import React from "react";
import classnames from "classnames";
import TooltipWrapper from "components/TooltipWrapper";
const baseClass = "pill-badge";
interface IPillBadge {
text: string;
interface IPillBadgeProps {
children: React.ReactNode;
tipContent?: JSX.Element | string;
className?: string;
}
const PillBadge = ({ text, tipContent }: IPillBadge) => {
const PillBadge = ({ children, tipContent, className }: IPillBadgeProps) => {
const classNames = classnames(baseClass, className);
return (
<div className={`${baseClass}__`}>
<div className={classNames}>
<TooltipWrapper
tipContent={tipContent}
showArrow
@ -20,7 +24,7 @@ const PillBadge = ({ text, tipContent }: IPillBadge) => {
tipOffset={12}
delayInMs={300}
>
<span className={`${baseClass}__element-text`}>{text}</span>
<span className={`${baseClass}__element`}>{children}</span>
</TooltipWrapper>
</div>
);

View file

@ -1,5 +1,5 @@
.pill-badge {
&__element-text {
&__element {
display: flex;
height: 16px;
padding: 0 4px;

View file

@ -20,7 +20,7 @@ interface IPatchBadgesProps {
const SoftwareInstallPolicyBadges = ({ policyType }: IPatchBadgesProps) => {
const renderPatchBadge = () => (
<PillBadge text="Patch" tipContent={PATCH_TOOLTIP_CONTENT} />
<PillBadge tipContent={PATCH_TOOLTIP_CONTENT}>Patch</PillBadge>
);
const renderAutomaticInstallBadge = () => (

View file

@ -62,7 +62,6 @@ export interface ITeamUsersTableData {
export const renderApiUserIndicator = () => {
return (
<PillBadge
text="API"
tipContent={
<>
This user was created using fleetctl and
@ -75,7 +74,9 @@ export const renderApiUserIndicator = () => {
/>
</>
}
/>
>
API
</PillBadge>
);
};

View file

@ -26,12 +26,10 @@ import {
IMacadminsResponse,
IHostResponse,
IHostMdmData,
IPackStats,
} from "interfaces/host";
import { ILabel } from "interfaces/label";
import { IListSort } from "interfaces/list_options";
import { IHostPolicy } from "interfaces/policy";
import { IQueryStats } from "interfaces/query_stats";
import {
IHostSoftware,
resolveUninstallStatus,
@ -68,7 +66,6 @@ import {
isIPadOrIPhone,
isLinuxLike,
isWindows,
isChrome,
} from "interfaces/platform";
import Spinner from "components/Spinner";
@ -112,8 +109,8 @@ import SoftwareInventoryCard from "../cards/Software";
import SoftwareLibraryCard from "../cards/HostSoftwareLibrary";
import LocalUserAccountsCard from "../cards/LocalUserAccounts";
import PoliciesCard from "../cards/Policies";
import QueriesCard from "../cards/Queries";
import PolicyDetailsModal from "../cards/Policies/HostPoliciesTable/PolicyDetailsModal";
import HostReportsTab from "../HostReportsTab";
import CertificatesCard from "../cards/Certificates";
import TransferHostModal from "../../components/TransferHostModal";
@ -148,7 +145,7 @@ const baseClass = "host-details";
const defaultCardClass = `${baseClass}__card`;
const fullWidthCardClass = `${baseClass}__card--full-width`;
const doubleHeightCardClass = `${baseClass}__card--double-height`;
const tripleHeightCardClass = `${baseClass}__card--triple-height`;
export const REFETCH_HOST_DETAILS_POLLING_INTERVAL = 2000; // 2 seconds
const BYOD_SW_INSTALL_LEARN_MORE_LINK =
@ -211,7 +208,6 @@ const HostDetailsPage = ({
isOnlyObserver,
filteredHostsPath,
currentTeam,
isAnyMaintainerAdminObserverPlus,
isMacMdmEnabledAndConfigured,
} = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
@ -276,7 +272,6 @@ const HostDetailsPage = ({
const [refetchStartTime, setRefetchStartTime] = useState<number | null>(null);
const [showRefetchSpinner, setShowRefetchSpinner] = useState(false);
const [schedule, setSchedule] = useState<IQueryStats[]>();
const [usersState, setUsersState] = useState<{ username: string }[]>([]);
const [usersSearchString, setUsersSearchString] = useState("");
const [
@ -468,27 +463,6 @@ const HostDetailsPage = ({
)
);
setUsersState(returnedHost.users || []);
setSchedule(schedule);
if (returnedHost.pack_stats) {
const packStatsByType = returnedHost.pack_stats.reduce(
(
dictionary: {
packs: IPackStats[];
schedule: IQueryStats[];
},
pack: IPackStats
) => {
if (pack.type === "pack") {
dictionary.packs.push(pack);
} else {
dictionary.schedule.push(...pack.query_stats);
}
return dictionary;
},
{ packs: [], schedule: [] }
);
setSchedule(packStatsByType.schedule);
}
},
onError: (error) => handlePageError(error),
}
@ -977,15 +951,6 @@ const HostDetailsPage = ({
setSelectedCertificate(certificate);
};
const onClickAddQuery = () => {
router.push(
getPathWithQueryParams(PATHS.NEW_REPORT, {
fleet_id: currentTeam?.id || location.query.fleet_id,
host_id: hostIdFromURL,
})
);
};
const renderActionsDropdown = () => {
if (!host) {
return null;
@ -1078,6 +1043,11 @@ const HostDetailsPage = ({
title: "software",
pathname: PATHS.HOST_SOFTWARE(hostIdFromURL),
},
{
name: "Reports",
title: "reports",
pathname: PATHS.HOST_REPORTS(hostIdFromURL),
},
{
name: "Policies",
title: "policies",
@ -1152,12 +1122,8 @@ const HostDetailsPage = ({
const isIosOrIpadosHost = isIPadOrIPhone(host.platform);
const isAndroidHost = isAndroid(host.platform);
const isWindowsHost = isWindows(host.platform);
const isChromeHost = isChrome(host.platform);
const isAppleDeviceHost = isAppleDevice(host.platform);
const isSupportedHostQueriesPlatform =
!isIosOrIpadosHost && !isAndroidHost && !isChromeHost;
const canResendProfiles =
(isAppleDeviceHost || isWindowsHost) &&
(isGlobalAdmin ||
@ -1371,42 +1337,11 @@ const HostDetailsPage = ({
)}
toggleLocationModal={toggleLocationModal}
/>
<QueriesCard
hostId={host.id}
router={router}
hostPlatform={host.platform}
schedule={schedule}
queryReportsDisabled={
config?.server_settings?.query_reports_disabled
}
canAddQuery={
isAnyMaintainerAdminObserverPlus &&
isSupportedHostQueriesPlatform
}
onClickAddQuery={onClickAddQuery}
/>
<UserCard
className={defaultCardClass}
endUsers={host.end_users ?? []}
canWriteEndUser={
isTeamMaintainerOrTeamAdmin ||
isGlobalAdmin ||
isGlobalMaintainer
}
onClickUpdateUser={(
e:
| React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement>
) => {
e.preventDefault();
setShowUpdateEndUserModal(true);
}}
/>
{showActivityCard && (
<ActivityCard
className={
showAgentOptionsCard
? doubleHeightCardClass
? tripleHeightCardClass
: defaultCardClass
}
activeTab={activeActivityTab}
@ -1460,14 +1395,23 @@ const HostDetailsPage = ({
onCancel={onCancelActivity}
/>
)}
{showAgentOptionsCard && (
<AgentOptionsCard
className={defaultCardClass}
osqueryData={osqueryData}
wrapFleetHelper={wrapFleetHelper}
isChromeOS={host?.platform === "chrome"}
/>
)}
<UserCard
className={defaultCardClass}
endUsers={host.end_users ?? []}
canWriteEndUser={
isTeamMaintainerOrTeamAdmin ||
isGlobalAdmin ||
isGlobalMaintainer
}
onClickUpdateUser={(
e:
| React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement>
) => {
e.preventDefault();
setShowUpdateEndUserModal(true);
}}
/>
<LabelsCard
className={
!showActivityCard && !showAgentOptionsCard
@ -1477,6 +1421,14 @@ const HostDetailsPage = ({
labels={host?.labels || []}
onLabelClick={onLabelClick}
/>
{showAgentOptionsCard && (
<AgentOptionsCard
className={defaultCardClass}
osqueryData={osqueryData}
wrapFleetHelper={wrapFleetHelper}
isChromeOS={host?.platform === "chrome"}
/>
)}
{showLocalUserAccountsCard && (
<LocalUserAccountsCard
className={fullWidthCardClass}
@ -1516,6 +1468,17 @@ const HostDetailsPage = ({
</Tabs>
</TabNav>
</TabPanel>
<TabPanel>
<HostReportsTab
hostId={host.id}
hostName={host.display_name}
router={router}
location={location}
saveReportsDisabledInConfig={
config?.server_settings?.query_reports_disabled
}
/>
</TabPanel>
<TabPanel>
<PoliciesCard
policies={host?.policies || []}

View file

@ -38,6 +38,10 @@
&--double-height {
grid-row: span 2; // card will be 1 column x 2 rows
}
&--triple-height {
grid-row: span 3; // card will be 1 column x 3 rows
}
}
}

View file

@ -0,0 +1,27 @@
import React from "react";
import Icon from "components/Icon";
const baseClass = "empty-reports";
interface IEmptyReportsProps {
isSearching: boolean;
}
const EmptyReports = ({ isSearching }: IEmptyReportsProps): JSX.Element => {
return (
<div className={baseClass}>
<Icon name="search" color="ui-fleet-black-25" size="extra-large" />
<h2 className={`${baseClass}__heading`}>
{isSearching
? "No reports match the current search criteria"
: "No reports for this host"}
</h2>
<p className={`${baseClass}__subheading`}>
Expecting to see reports? Check back later.
</p>
</div>
);
};
export default EmptyReports;

View file

@ -0,0 +1,206 @@
import React, { ReactNode, useCallback, useMemo } from "react";
import { IHostReport } from "services/entities/host_reports";
import { humanLastSeen } from "utilities/helpers";
import { pluralize } from "utilities/strings/stringUtils";
import Button from "components/buttons/Button";
import Card from "components/Card";
import DataSet from "components/DataSet";
import Icon from "components/Icon";
import { IconNames } from "components/icons";
import InfoBanner from "components/InfoBanner";
import ActionsDropdown from "components/ActionsDropdown";
import { IDropdownOption } from "interfaces/dropdownOption";
import PillBadge from "components/PillBadge";
import TooltipTruncatedText from "components/TooltipTruncatedText";
import { Colors } from "styles/var/colors";
const baseClass = "host-report-card";
const ICON_COLOR: Colors = "ui-fleet-black-75";
const ACTION_SHOW_DETAILS = "show_details";
const ACTION_VIEW_ALL_HOSTS = "view_all_hosts";
const ReportBanner = ({
iconName,
message,
children,
}: {
iconName: IconNames;
message: ReactNode;
children?: ReactNode;
}) => (
<InfoBanner color="grey" borderRadius="xlarge">
<div className={`${baseClass}__banner-content`}>
<div className={`${baseClass}__banner-text`}>
<Icon name={iconName} color={ICON_COLOR} />
{message}
</div>
{children}
</div>
</InfoBanner>
);
interface IHostReportCardProps {
report: IHostReport;
hostName: string;
onShowDetails: (report: IHostReport) => void;
onViewAllHosts: (report: IHostReport) => void;
}
const HostReportCard = ({
report,
hostName,
onShowDetails,
onViewAllHosts,
}: IHostReportCardProps): JSX.Element => {
const hasResults = report.last_fetched !== null;
const hasData = report.first_result !== null;
const isAwaitingResults = !hasResults;
const doesNotStoreResults = !report.store_results;
const onActionChange = useCallback(
(value: string) => {
if (value === ACTION_SHOW_DETAILS) {
onShowDetails(report);
} else if (value === ACTION_VIEW_ALL_HOSTS) {
onViewAllHosts(report);
}
},
[report, onShowDetails, onViewAllHosts]
);
const actionOptions: IDropdownOption[] = useMemo(() => {
const options: IDropdownOption[] = [];
if (hasResults) {
options.push({ value: ACTION_SHOW_DETAILS, label: "Show details" });
}
options.push({
value: ACTION_VIEW_ALL_HOSTS,
label: "View report for all hosts",
});
return options;
}, [hasResults]);
const renderLastUpdated = () => {
if (isAwaitingResults) return null;
const prefix = doesNotStoreResults ? "Last ran" : "Last updated";
return (
<span className={`${baseClass}__last-updated`}>
{prefix} {humanLastSeen(report.last_fetched || "")}
</span>
);
};
const renderDataCells = () => {
if (!hasData || !report.first_result) return null;
const entries = Object.entries(report.first_result);
return (
<div className={`${baseClass}__data-grid`}>
{entries.map(([key, value]) => (
<DataSet
key={key}
title={key}
value={<TooltipTruncatedText value={value} />}
/>
))}
</div>
);
};
const renderBanner = () => {
if (doesNotStoreResults) {
return (
<ReportBanner
iconName="info-outline"
message="Results from this report are not stored in Fleet."
/>
);
}
if (isAwaitingResults) {
return (
<ReportBanner
iconName="pending-outline"
message={`Fleet is awaiting results from ${hostName}.`}
/>
);
}
if (!hasData) {
return (
<ReportBanner
iconName="check"
message={`This report has run on ${hostName}, but returned no data for this host.`}
/>
);
}
if (report.n_host_results > 1) {
const additionalCount = report.n_host_results - 1;
return (
<ReportBanner
iconName="info-outline"
message={`${additionalCount} additional ${pluralize(
additionalCount,
"result"
)} not shown`}
>
<Button
className={`${baseClass}__view-full-report`}
variant="inverse"
size="small"
onClick={() => onShowDetails(report)}
>
View full report
<Icon name="chevron-right" color={ICON_COLOR} />
</Button>
</ReportBanner>
);
}
return null;
};
return (
<Card className={baseClass} borderRadiusSize="xlarge" paddingSize="xlarge">
<div className={`${baseClass}__header`}>
<div className={`${baseClass}__header-left`}>
<div className={`${baseClass}__title-row`}>
<h3 className={`${baseClass}__name`}>{report.name}</h3>
{renderLastUpdated()}
</div>
{report.description && (
<p className={`${baseClass}__description`}>{report.description}</p>
)}
</div>
<div className={`${baseClass}__header-right`}>
{report.report_clipped && (
<PillBadge
className={`${baseClass}__clipped-badge`}
tipContent="This report has paused saving results. If automations are enabled, results are still sent to your log destination."
>
<Icon size="small" name="warning" color={ICON_COLOR} />
Report clipped
</PillBadge>
)}
<ActionsDropdown
options={actionOptions}
placeholder="Actions"
onChange={onActionChange}
variant="button"
menuAlign="right"
/>
</div>
</div>
{renderDataCells()}
{renderBanner()}
</Card>
);
};
export default HostReportCard;

View file

@ -0,0 +1,273 @@
import React, { useState, useCallback, useMemo } from "react";
import { useQuery } from "react-query";
import { InjectedRouter } from "react-router";
import PATHS from "router/paths";
import hostReportsAPI, {
IHostReport,
IListHostReportsResponse,
} from "services/entities/host_reports";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import { pluralize } from "utilities/strings/stringUtils";
import { getNextLocationPath } from "utilities/helpers";
import Spinner from "components/Spinner";
import DataError from "components/DataError";
import SearchField from "components/forms/fields/SearchField";
import Slider from "components/forms/fields/Slider";
import ActionsDropdown from "components/ActionsDropdown";
import { IDropdownOption } from "interfaces/dropdownOption";
import Pagination from "components/Pagination";
import HostReportCard from "./HostReportCard";
import EmptyReports from "./EmptyReports";
const baseClass = "host-reports-tab";
const PAGE_SIZE = 50;
type SortOption =
| "newest_results"
| "oldest_results"
| "name_asc"
| "name_desc";
const DEFAULT_SORT_OPTION: SortOption = "newest_results";
const SORT_OPTIONS: IDropdownOption[] = [
{ value: "newest_results", label: "Newest results" },
{ value: "oldest_results", label: "Oldest results" },
{ value: "name_asc", label: "Name A-Z" },
{ value: "name_desc", label: "Name Z-A" },
];
const getSortParams = (sort: SortOption) => {
switch (sort) {
case "newest_results":
return { order_key: "last_fetched", order_direction: "desc" };
case "oldest_results":
return { order_key: "last_fetched", order_direction: "asc" };
case "name_asc":
return { order_key: "name", order_direction: "asc" };
case "name_desc":
return { order_key: "name", order_direction: "desc" };
default:
return { order_key: "last_fetched", order_direction: "desc" };
}
};
interface IHostReportsTabProps {
hostId: number;
hostName: string;
router: InjectedRouter;
location: {
pathname: string;
query: {
query?: string;
sort?: string;
show_dont_store?: string;
};
};
saveReportsDisabledInConfig?: boolean;
}
const HostReportsTab = ({
hostId,
hostName,
router,
location,
saveReportsDisabledInConfig,
}: IHostReportsTabProps): JSX.Element => {
const searchQuery = location.query.query ?? "";
const sortOption: SortOption =
location.query.sort &&
SORT_OPTIONS.some((o) => o.value === location.query.sort)
? (location.query.sort as SortOption)
: DEFAULT_SORT_OPTION;
const showDontStoreResults = location.query.show_dont_store === "true";
const [page, setPage] = useState(0);
const sortParams = getSortParams(sortOption);
// If save reports is disabled in the org settings, always include reports
// that don't store results and hide the toggle
const includeReportsDontStoreResults =
saveReportsDisabledInConfig || showDontStoreResults;
const { data: reportsData, isLoading, isError, isFetching } = useQuery<
IListHostReportsResponse,
Error
>(
[
"host_reports",
hostId,
page,
sortParams.order_key,
sortParams.order_direction,
searchQuery,
includeReportsDontStoreResults,
],
() =>
hostReportsAPI.list(hostId, {
page,
per_page: PAGE_SIZE,
order_key: sortParams.order_key,
order_direction: sortParams.order_direction,
query: searchQuery || undefined,
include_reports_dont_store_results: includeReportsDontStoreResults,
}),
{
...DEFAULT_USE_QUERY_OPTIONS,
keepPreviousData: true,
}
);
const reports = reportsData?.reports ?? [];
const totalCount = reportsData?.count ?? 0;
const meta = reportsData?.meta;
const onSearchChange = useCallback(
(value: string) => {
router.replace(
getNextLocationPath({
pathPrefix: location.pathname,
queryParams: { ...location.query, query: value || undefined },
})
);
setPage(0);
},
[router, location.pathname, location.query]
);
const onSortChange = useCallback(
(value: string) => {
router.replace(
getNextLocationPath({
pathPrefix: location.pathname,
queryParams: {
...location.query,
sort: value === DEFAULT_SORT_OPTION ? undefined : value,
},
})
);
setPage(0);
},
[router, location.pathname, location.query]
);
const onToggleDontStoreResults = useCallback(() => {
router.replace(
getNextLocationPath({
pathPrefix: location.pathname,
queryParams: {
...location.query,
show_dont_store: showDontStoreResults ? undefined : "true",
},
})
);
setPage(0);
}, [router, location.pathname, location.query, showDontStoreResults]);
const onShowDetails = useCallback(
(report: IHostReport) => {
router.push(PATHS.HOST_REPORT_RESULTS(hostId, report.report_id));
},
[hostId, router]
);
const onViewAllHosts = useCallback(
(report: IHostReport) => {
router.push(PATHS.REPORT_DETAILS(report.report_id));
},
[router]
);
const sortDropdownOptions = useMemo(() => {
return SORT_OPTIONS.map((opt) => ({
...opt,
label: opt.value === sortOption ? `Sort: ${opt.label}` : opt.label,
}));
}, [sortOption]);
if (isLoading) {
return <Spinner />;
}
if (isError) {
return <DataError />;
}
if (totalCount === 0 && !searchQuery) {
return <EmptyReports isSearching={false} />;
}
return (
<div className={baseClass}>
<div className={`${baseClass}__controls`}>
<div className={`${baseClass}__controls-left`}>
<span className={`${baseClass}__count`}>
{totalCount} {pluralize(totalCount, "report")}
</span>
{!saveReportsDisabledInConfig && (
<Slider
value={showDontStoreResults}
onChange={onToggleDontStoreResults}
activeText="Show reports that don't store results"
inactiveText="Show reports that don't store results"
className={`${baseClass}__toggle`}
/>
)}
</div>
<div className={`${baseClass}__controls-right`}>
<ActionsDropdown
options={sortDropdownOptions}
placeholder={`Sort: ${
SORT_OPTIONS.find((o) => o.value === sortOption)?.label ?? ""
}`}
onChange={onSortChange}
className={`${baseClass}__sort-dropdown`}
variant="button"
menuAlign="right"
/>
<SearchField
placeholder="Search by name"
defaultValue={searchQuery}
onChange={onSearchChange}
/>
</div>
</div>
{reports.length === 0 && searchQuery ? (
<EmptyReports isSearching />
) : (
<>
<div className={`${baseClass}__reports-list`}>
{reports.map((report) => (
<HostReportCard
key={report.report_id}
report={report}
hostName={hostName}
onShowDetails={onShowDetails}
onViewAllHosts={onViewAllHosts}
/>
))}
</div>
<Pagination
onNextPage={() => setPage((p) => p + 1)}
onPrevPage={() => setPage((p) => Math.max(0, p - 1))}
disableNext={!meta?.has_next_results}
disablePrev={!meta?.has_previous_results}
hidePagination={
!!meta && !meta.has_next_results && !meta.has_previous_results
}
/>
</>
)}
{isFetching && !isLoading && (
<div className={`${baseClass}__fetching-overlay`} />
)}
</div>
);
};
export default HostReportsTab;

View file

@ -0,0 +1,174 @@
.host-reports-tab {
position: relative;
&__controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $pad-medium;
flex-wrap: wrap;
gap: $pad-small;
}
&__controls-left {
display: flex;
align-items: center;
gap: $pad-medium;
}
&__controls-right {
display: flex;
align-items: center;
gap: $pad-small;
}
&__count {
font-size: $x-small;
font-weight: $bold;
color: $core-fleet-black;
}
&__toggle {
margin: 0;
width: auto;
}
&__sort-dropdown {
min-width: 160px;
}
&__reports-list {
display: flex;
flex-direction: column;
gap: $pad-medium;
}
&__fetching-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.5);
pointer-events: none;
}
}
.host-report-card {
&__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: $pad-medium;
margin-bottom: px-to-rem(24);
}
&__header-left {
flex: 1;
min-width: 0;
}
&__header-right {
display: flex;
align-items: center;
gap: $pad-small;
flex-shrink: 0;
}
&__title-row {
display: flex;
align-items: baseline;
gap: $pad-medium;
flex-wrap: wrap;
}
&__name {
font-size: $small;
font-weight: $bold;
margin: 0;
}
&__last-updated {
font-size: $xx-small;
color: $ui-fleet-black-75;
white-space: nowrap;
}
&__description {
font-size: $xx-small;
color: $ui-fleet-black-75;
margin: $pad-xsmall 0 0;
}
&__clipped-badge {
.pill-badge__element {
background: transparent;
font-size: $xx-small;
color: $ui-fleet-black-75;
border: 1px solid $ui-fleet-black-25;
padding: $pad-small;
}
}
&__data-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: $gap-data-sets $pad-xxlarge;
margin-bottom: px-to-rem(24);
@media (min-width: $break-sm) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@media (min-width: $break-md) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (min-width: $break-lg) {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
&__banner-content {
display: flex;
align-items: center;
gap: px-to-rem(24);
}
&__banner-text {
display: flex;
align-items: center;
gap: $pad-small;
.icon {
flex-shrink: 0;
}
}
&__view-full-report {
margin-left: auto;
white-space: nowrap;
}
}
.empty-reports {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $pad-xxxlarge 0;
text-align: center;
&__heading {
font-size: $small;
font-weight: $bold;
margin-top: $pad-medium;
margin-bottom: $pad-xsmall;
}
&__subheading {
font-size: $xx-small;
color: $ui-fleet-black-75;
margin: 0;
}
}

View file

@ -0,0 +1 @@
export { default } from "./HostReportsTab";

View file

@ -1,5 +1,16 @@
.host-labels-card {
@include vertical-card-layout;
.list {
display: flex;
flex-wrap: wrap;
gap: $pad-small;
.list__item {
margin-bottom: 0;
}
}
.button,
.children-wrapper {
max-width: 100%;

View file

@ -120,13 +120,14 @@ const generateTableHeaders = (
<>
{isPremiumTier && critical && <CriticalPolicyBadge />}
{type === "patch" && (
<PillBadge text="Patch" tipContent={PATCH_TOOLTIP_CONTENT} />
<PillBadge tipContent={PATCH_TOOLTIP_CONTENT}>
Patch
</PillBadge>
)}
{viewingTeamPolicies && team_id === null && (
<PillBadge
text="Inherited"
tipContent="This policy runs on all hosts."
/>
<PillBadge tipContent="This policy runs on all hosts.">
Inherited
</PillBadge>
)}
</>
}

View file

@ -171,10 +171,9 @@ const generateColumnConfigs = ({
{viewingTeamScope &&
// inherited
team_id !== currentTeamId && (
<PillBadge
text="Inherited"
tipContent="This report runs on all hosts."
/>
<PillBadge tipContent="This report runs on all hosts.">
Inherited
</PillBadge>
)}
</>
}

View file

@ -283,6 +283,7 @@ const routes = (
<Route path="inventory" component={HostDetailsPage} />
<Route path="library" component={HostDetailsPage} />
</Route>
<Route path="reports" component={HostDetailsPage} />
<Route path="policies" component={HostDetailsPage} />
</Route>

View file

@ -0,0 +1,56 @@
import sendRequest from "services";
import endpoints from "utilities/endpoints";
import { buildQueryStringFromParams } from "utilities/url";
export interface IHostReport {
report_id: number;
name: string;
description: string;
last_fetched: string | null;
first_result: Record<string, string> | null;
n_host_results: number;
report_clipped: boolean;
store_results: boolean;
}
export interface IListHostReportsResponse {
reports: IHostReport[];
count: number;
meta: {
has_previous_results: boolean;
has_next_results: boolean;
};
}
export interface IListHostReportsParams {
page?: number;
per_page?: number;
order_key?: string;
order_direction?: string;
query?: string;
include_reports_dont_store_results?: boolean;
}
export default {
list: (
hostId: number,
params?: IListHostReportsParams
): Promise<IListHostReportsResponse> => {
const { query, ...rest } = params || {};
const queryParams: Record<string, string | number | boolean> = {};
if (rest.page !== undefined) queryParams.page = rest.page;
if (rest.per_page !== undefined) queryParams.per_page = rest.per_page;
if (rest.order_key) queryParams.order_key = rest.order_key;
if (rest.order_direction)
queryParams.order_direction = rest.order_direction;
if (query) queryParams.query = query;
if (rest.include_reports_dont_store_results) {
queryParams.include_reports_dont_store_results = true;
}
const queryString = buildQueryStringFromParams(queryParams);
const path = `${endpoints.HOST_REPORTS(hostId)}?${queryString}`;
return sendRequest("GET", path);
},
};

View file

@ -76,6 +76,8 @@ export default {
HOST_SUMMARY: `/${API_VERSION}/fleet/host_summary`,
HOST_QUERY_REPORT: (hostId: number, queryId: number) =>
`/${API_VERSION}/fleet/hosts/${hostId}/reports/${queryId}`,
HOST_REPORTS: (hostId: number) =>
`/${API_VERSION}/fleet/hosts/${hostId}/reports`,
HOSTS: `/${API_VERSION}/fleet/hosts`,
HOSTS_COUNT: `/${API_VERSION}/fleet/hosts/count`,
HOSTS_DELETE: `/${API_VERSION}/fleet/hosts/delete`,

View file

@ -466,7 +466,7 @@ func (ds *Datastore) ListHostReports(
reports := make([]*fleet.HostReport, 0, len(queryRows))
for _, qr := range queryRows {
r := &fleet.HostReport{
QueryID: qr.QueryID,
ReportID: qr.QueryID,
Name: qr.Name,
Description: qr.Description,
StoreResults: !qr.DiscardData && qr.LoggingType == fleet.LoggingSnapshot,

View file

@ -492,8 +492,8 @@ type HostQueryReportResult struct {
// HostReport represents a query/report entry as returned by the list-reports-for-host endpoint.
type HostReport struct {
// QueryID is the unique identifier of the query.
QueryID uint `json:"query_id" renameto:"report_id"`
// ReportID is the unique identifier of the query backing this report.
ReportID uint `json:"report_id"`
// Name is the name of the query.
Name string `json:"name"`
// Description is the description of the query.

View file

@ -347,9 +347,8 @@ type Service interface {
// QueryReportIsClipped returns true if the number of query report rows exceeds the maximum
QueryReportIsClipped(ctx context.Context, queryID uint, maxQueryReportRows int) (bool, error)
// ListHostReports returns the reports/queries associated with the given host, filtered,
// sorted, and paginated according to opts. The bool return value indicates whether
// query reports are globally disabled in the org settings.
ListHostReports(ctx context.Context, hostID uint, opts ListHostReportsOptions) (rows []*HostReport, total int, metadata *PaginationMetadata, savedReportsDisabled bool, err error)
// sorted, and paginated according to opts.
ListHostReports(ctx context.Context, hostID uint, opts ListHostReportsOptions) (rows []*HostReport, total int, metadata *PaginationMetadata, err error)
NewQuery(ctx context.Context, p QueryPayload) (*Query, error)
ModifyQuery(ctx context.Context, id uint, p QueryPayload) (*Query, error)
DeleteQuery(ctx context.Context, teamID *uint, name string) error

View file

@ -191,7 +191,7 @@ type GetHostQueryReportResultsFunc func(ctx context.Context, hid uint, queryID u
type QueryReportIsClippedFunc func(ctx context.Context, queryID uint, maxQueryReportRows int) (bool, error)
type ListHostReportsFunc func(ctx context.Context, hostID uint, opts fleet.ListHostReportsOptions) (rows []*fleet.HostReport, total int, metadata *fleet.PaginationMetadata, savedReportsDisabled bool, err error)
type ListHostReportsFunc func(ctx context.Context, hostID uint, opts fleet.ListHostReportsOptions) (rows []*fleet.HostReport, total int, metadata *fleet.PaginationMetadata, err error)
type NewQueryFunc func(ctx context.Context, p fleet.QueryPayload) (*fleet.Query, error)
@ -2820,7 +2820,7 @@ func (s *Service) QueryReportIsClipped(ctx context.Context, queryID uint, maxQue
return s.QueryReportIsClippedFunc(ctx, queryID, maxQueryReportRows)
}
func (s *Service) ListHostReports(ctx context.Context, hostID uint, opts fleet.ListHostReportsOptions) (rows []*fleet.HostReport, total int, metadata *fleet.PaginationMetadata, savedReportsDisabled bool, err error) {
func (s *Service) ListHostReports(ctx context.Context, hostID uint, opts fleet.ListHostReportsOptions) (rows []*fleet.HostReport, total int, metadata *fleet.PaginationMetadata, err error) {
s.mu.Lock()
s.ListHostReportsFuncInvoked = true
s.mu.Unlock()

View file

@ -1954,16 +1954,11 @@ type listHostReportsRequest struct {
IncludeReportsDontStoreResults *bool `query:"include_reports_dont_store_results,optional"`
}
type listHostReportsFeatures struct {
SavedReportsDisabled bool `json:"save_reports_disabled"`
}
type listHostReportsResponse struct {
Reports []*fleet.HostReport `json:"reports"`
Count int `json:"count"`
Meta *fleet.PaginationMetadata `json:"meta,omitempty"`
Features listHostReportsFeatures `json:"features"`
Err error `json:"error,omitempty"`
Reports []*fleet.HostReport `json:"reports"`
Count int `json:"count"`
Meta *fleet.PaginationMetadata `json:"meta,omitempty"`
Err error `json:"error,omitempty"`
}
func (r listHostReportsResponse) Error() error { return r.Err }
@ -1981,7 +1976,7 @@ func listHostReportsEndpoint(ctx context.Context, request any, svc fleet.Service
IncludeReportsDontStoreResults: includeReportsDontStoreResults,
}
reports, count, meta, savedReportsDisabled, err := svc.ListHostReports(ctx, req.ID, opts)
reports, count, meta, err := svc.ListHostReports(ctx, req.ID, opts)
if err != nil {
return listHostReportsResponse{Err: err}, nil
}
@ -1990,9 +1985,6 @@ func listHostReportsEndpoint(ctx context.Context, request any, svc fleet.Service
Reports: reports,
Count: count,
Meta: meta,
Features: listHostReportsFeatures{
SavedReportsDisabled: savedReportsDisabled,
},
}, nil
}
@ -2004,34 +1996,32 @@ func (svc *Service) ListHostReports(
[]*fleet.HostReport,
int,
*fleet.PaginationMetadata,
bool,
error,
) {
// Load host to get team ID and authorize.
host, err := svc.ds.HostLite(ctx, hostID)
if err != nil {
setAuthCheckedOnPreAuthErr(ctx)
return nil, 0, nil, false, ctxerr.Wrap(ctx, err, "get host")
return nil, 0, nil, ctxerr.Wrap(ctx, err, "get host")
}
// Verify the caller can read this specific host.
if err := svc.authz.Authorize(ctx, &fleet.Host{ID: host.ID, TeamID: host.TeamID}, fleet.ActionRead); err != nil {
return nil, 0, nil, false, err
return nil, 0, nil, err
}
// Authorize against the host's team. Global queries (team_id IS NULL) are
// intentionally visible to all users who can read queries in this context —
// team-scoped users see global queries in addition to their own team's queries.
if err := svc.authz.Authorize(ctx, &fleet.Query{TeamID: host.TeamID}, fleet.ActionRead); err != nil {
return nil, 0, nil, false, err
return nil, 0, nil, err
}
appConfig, err := svc.AppConfigObfuscated(ctx)
if err != nil {
return nil, 0, nil, false, ctxerr.Wrap(ctx, err, "get app config")
return nil, 0, nil, ctxerr.Wrap(ctx, err, "get app config")
}
maxQueryReportRows := appConfig.ServerSettings.GetQueryReportCap()
savedReportsDisabled := appConfig.ServerSettings.QueryReportsDisabled
// This end-point is always paginated; metadata is required for HasNextResults.
opts.ListOptions.IncludeMetadata = true
@ -2045,7 +2035,7 @@ func (svc *Service) ListHostReports(
case "", "name", "last_fetched":
// valid
default:
return nil, 0, nil, false, fleet.NewInvalidArgumentError("order_key", "must be one of: name, last_fetched")
return nil, 0, nil, fleet.NewInvalidArgumentError("order_key", "must be one of: name, last_fetched")
}
// Default: sort by newest results first. Applies only when the caller has
@ -2058,10 +2048,10 @@ func (svc *Service) ListHostReports(
reports, total, meta, err := svc.ds.ListHostReports(ctx, hostID, host.TeamID, opts, maxQueryReportRows)
if err != nil {
return nil, 0, nil, false, ctxerr.Wrap(ctx, err, "list host reports from datastore")
return nil, 0, nil, ctxerr.Wrap(ctx, err, "list host reports from datastore")
}
return reports, total, meta, savedReportsDisabled, nil
return reports, total, meta, nil
}
func (svc *Service) hostIDsAndNamesFromFilters(ctx context.Context, opt fleet.HostListOptions, lid *uint) ([]uint, []string, []*fleet.Host, error) {

View file

@ -34,7 +34,7 @@ func TestListHostReports(t *testing.T) {
sampleReports := []*fleet.HostReport{
{
QueryID: 1,
ReportID: 1,
Name: "Query Alpha",
Description: "desc alpha",
LastFetched: &now,
@ -42,7 +42,7 @@ func TestListHostReports(t *testing.T) {
NHostResults: 3,
},
{
QueryID: 2,
ReportID: 2,
Name: "Query Beta",
Description: "desc beta",
LastFetched: nil,
@ -76,7 +76,7 @@ func TestListHostReports(t *testing.T) {
t.Run("admin can list reports for host with no team", func(t *testing.T) {
viewerCtx := viewer.NewContext(ctx, viewer.Viewer{User: admin})
opts := fleet.ListHostReportsOptions{}
reports, count, _, _, err := svc.ListHostReports(viewerCtx, hostNoTeam.ID, opts)
reports, count, _, err := svc.ListHostReports(viewerCtx, hostNoTeam.ID, opts)
require.NoError(t, err)
assert.Equal(t, 2, count)
assert.Len(t, reports, 2)
@ -95,7 +95,7 @@ func TestListHostReports(t *testing.T) {
t.Run("admin can list reports for host with team", func(t *testing.T) {
viewerCtx := viewer.NewContext(ctx, viewer.Viewer{User: admin})
opts := fleet.ListHostReportsOptions{}
reports, count, _, _, err := svc.ListHostReports(viewerCtx, hostWithTeam.ID, opts)
reports, count, _, err := svc.ListHostReports(viewerCtx, hostWithTeam.ID, opts)
require.NoError(t, err)
assert.Equal(t, 2, count)
assert.Len(t, reports, 2)
@ -110,29 +110,15 @@ func TestListHostReports(t *testing.T) {
}
viewerCtx := viewer.NewContext(ctx, viewer.Viewer{User: observer})
opts := fleet.ListHostReportsOptions{}
reports, count, _, _, err := svc.ListHostReports(viewerCtx, hostNoTeam.ID, opts)
reports, count, _, err := svc.ListHostReports(viewerCtx, hostNoTeam.ID, opts)
require.NoError(t, err)
assert.Equal(t, 2, count)
assert.Len(t, reports, 2)
})
t.Run("save_reports_disabled is forwarded from app config", func(t *testing.T) {
original := ds.AppConfigFunc
t.Cleanup(func() { ds.AppConfigFunc = original })
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{
ServerSettings: fleet.ServerSettings{QueryReportsDisabled: true},
}, nil
}
viewerCtx := viewer.NewContext(ctx, viewer.Viewer{User: admin})
_, _, _, disabled, err := svc.ListHostReports(viewerCtx, hostNoTeam.ID, fleet.ListHostReportsOptions{})
require.NoError(t, err)
assert.True(t, disabled)
})
t.Run("invalid order_key returns bad request", func(t *testing.T) {
viewerCtx := viewer.NewContext(ctx, viewer.Viewer{User: admin})
_, _, _, _, err := svc.ListHostReports(viewerCtx, hostNoTeam.ID, fleet.ListHostReportsOptions{
_, _, _, err := svc.ListHostReports(viewerCtx, hostNoTeam.ID, fleet.ListHostReportsOptions{
ListOptions: fleet.ListOptions{OrderKey: "invalid_key"},
})
require.Error(t, err)
@ -141,7 +127,7 @@ func TestListHostReports(t *testing.T) {
})
t.Run("unauthenticated gets error", func(t *testing.T) {
_, _, _, _, err := svc.ListHostReports(ctx, hostNoTeam.ID, fleet.ListHostReportsOptions{})
_, _, _, err := svc.ListHostReports(ctx, hostNoTeam.ID, fleet.ListHostReportsOptions{})
require.Error(t, err)
require.Contains(t, err.Error(), "forbidden")
})
@ -156,7 +142,7 @@ func TestListHostReports(t *testing.T) {
}
// hostWithTeam belongs to teamID=42; teamObserver only has access to teamID=99.
viewerCtx := viewer.NewContext(ctx, viewer.Viewer{User: teamObserver})
_, _, _, _, err := svc.ListHostReports(viewerCtx, hostWithTeam.ID, fleet.ListHostReportsOptions{})
_, _, _, err := svc.ListHostReports(viewerCtx, hostWithTeam.ID, fleet.ListHostReportsOptions{})
require.Error(t, err)
require.Contains(t, err.Error(), "forbidden")
})
@ -208,7 +194,7 @@ func TestListHostReportsDatastorePassthrough(t *testing.T) {
},
}
_, _, _, _, err := svc.ListHostReports(viewerCtx, host.ID, opts)
_, _, _, err := svc.ListHostReports(viewerCtx, host.ID, opts)
require.NoError(t, err)
assert.Equal(t, host.ID, capturedHostID)
@ -228,7 +214,7 @@ func TestHostReportJSONRoundTrip(t *testing.T) {
// This test exercises the HostReport struct's FirstResult field to ensure
// the data mapping from query_results.data JSON is correct.
report := &fleet.HostReport{
QueryID: 1,
ReportID: 1,
Name: "USB Devices",
Description: "List USB devices",
LastFetched: &now,
@ -244,7 +230,7 @@ func TestHostReportJSONRoundTrip(t *testing.T) {
err = json.Unmarshal(b, &decoded)
require.NoError(t, err)
assert.Equal(t, report.QueryID, decoded.QueryID)
assert.Equal(t, report.ReportID, decoded.ReportID)
assert.Equal(t, report.Name, decoded.Name)
assert.Equal(t, report.Description, decoded.Description)
assert.Equal(t, report.FirstResult, decoded.FirstResult)

View file

@ -16174,7 +16174,7 @@ func (s *integrationTestSuite) TestListHostReports() {
require.Len(t, resp.Reports, 2)
alpha := resp.Reports[0]
assert.Equal(t, qAlpha.ID, alpha.QueryID)
assert.Equal(t, qAlpha.ID, alpha.ReportID)
assert.Equal(t, qAlpha.Name, alpha.Name)
assert.Equal(t, "alpha description", alpha.Description)
// first_result is the most recent row.
@ -16191,7 +16191,7 @@ func (s *integrationTestSuite) TestListHostReports() {
// qBeta has no results yet.
beta := resp.Reports[1]
assert.Equal(t, qBeta.ID, beta.QueryID)
assert.Equal(t, qBeta.ID, beta.ReportID)
assert.Nil(t, beta.FirstResult)
assert.Nil(t, beta.LastFetched)
assert.Equal(t, 0, beta.NHostResults)
@ -16210,29 +16210,6 @@ func (s *integrationTestSuite) TestListHostReports() {
assert.False(t, discard.StoreResults)
})
t.Run("features.save_reports_disabled reflects app config", func(t *testing.T) {
// Default: query reports are enabled.
var resp listHostReportsResponse
s.DoJSON("GET", url, nil, http.StatusOK, &resp)
assert.False(t, resp.Features.SavedReportsDisabled)
// Save the current value before mutating.
var originalConfig fleet.AppConfig
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &originalConfig)
origDisabled := originalConfig.ServerSettings.QueryReportsDisabled
// Disable query reports.
s.DoRaw("PATCH", "/api/latest/fleet/config", []byte(`{"server_settings":{"query_reports_disabled":true}}`), http.StatusOK)
t.Cleanup(func() {
s.DoRaw("PATCH", "/api/latest/fleet/config",
fmt.Appendf(nil, `{"server_settings":{"query_reports_disabled":%v}}`, origDisabled),
http.StatusOK)
})
s.DoJSON("GET", url, nil, http.StatusOK, &resp)
assert.True(t, resp.Features.SavedReportsDisabled)
})
t.Run("report_clipped when total results reach the cap", func(t *testing.T) {
// Save the current cap before mutating.
var originalConfig fleet.AppConfig
@ -16352,10 +16329,7 @@ func (s *integrationTestSuite) TestListHostReports() {
assert.Equal(t, qBeta.Name, resp.Reports[1].Name)
})
t.Run("report_id alias is present in JSON response", func(t *testing.T) {
// The HostReport struct uses `renameto:"report_id"` on the QueryID field,
// which causes the endpointer to duplicate the key in the response as
// both "query_id" (deprecated) and "report_id" (new name).
t.Run("report_id is present in JSON response", func(t *testing.T) {
rawBody := s.DoRaw("GET", url, nil, http.StatusOK)
var raw map[string]any
require.NoError(t, json.NewDecoder(rawBody.Body).Decode(&raw))
@ -16367,11 +16341,7 @@ func (s *integrationTestSuite) TestListHostReports() {
firstReport, ok := reports[0].(map[string]any)
require.True(t, ok)
// Both the deprecated key and the new alias must be present.
_, hasQueryID := firstReport["query_id"]
_, hasReportID := firstReport["report_id"]
assert.True(t, hasQueryID, "expected deprecated key 'query_id' in response")
assert.True(t, hasReportID, "expected alias key 'report_id' in response")
assert.Equal(t, firstReport["query_id"], firstReport["report_id"])
assert.True(t, hasReportID, "expected key 'report_id' in response")
})
}