mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
[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:
parent
994843f330
commit
a265768d20
25 changed files with 857 additions and 188 deletions
1
changes/42017-host-details-reports-tab
Normal file
1
changes/42017-host-details-reports-tab
Normal file
|
|
@ -0,0 +1 @@
|
|||
* Added Reports tab to Host details page.
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
.pill-badge {
|
||||
&__element-text {
|
||||
&__element {
|
||||
display: flex;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
|
|
|
|||
|
|
@ -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 = () => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 || []}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
27
frontend/pages/hosts/details/HostReportsTab/EmptyReports.tsx
Normal file
27
frontend/pages/hosts/details/HostReportsTab/EmptyReports.tsx
Normal 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;
|
||||
206
frontend/pages/hosts/details/HostReportsTab/HostReportCard.tsx
Normal file
206
frontend/pages/hosts/details/HostReportsTab/HostReportCard.tsx
Normal 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;
|
||||
273
frontend/pages/hosts/details/HostReportsTab/HostReportsTab.tsx
Normal file
273
frontend/pages/hosts/details/HostReportsTab/HostReportsTab.tsx
Normal 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;
|
||||
174
frontend/pages/hosts/details/HostReportsTab/_styles.scss
Normal file
174
frontend/pages/hosts/details/HostReportsTab/_styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
1
frontend/pages/hosts/details/HostReportsTab/index.ts
Normal file
1
frontend/pages/hosts/details/HostReportsTab/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./HostReportsTab";
|
||||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
56
frontend/services/entities/host_reports.ts
Normal file
56
frontend/services/entities/host_reports.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue