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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/__mocks__/softwareMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,8 @@ const DEFAULT_FLEET_MAINTAINED_APP_DETAILS_MOCK: IFleetMaintainedAppDetails = {
post_install_script: 'echo "Installed"',
uninstall_script:
"#!/bin/sh\n\n# Fleet extracts and saves package IDs\npkg_ids=$PACKAGE_ID",
automatic_install_query:
"SELECT 1 FROM apps WHERE bundle_identifier = 'com.example.test-app';",
slug: "applications/test-app",
url: "http://www.testurl1234abcd.com/testapp",
categories: ["Browsers"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getSoftwareInstallHandlerWithHash,
getSoftwareInstallHandlerWithPreInstall,
getSoftwareInstallHandlerOnlyPreInstallOutput,
getSoftwareInstallHandlerAppOpen,
getSoftwareInstallResultHandlerPremiumRequired,
} from "test/handlers/software-handlers";
import mockServer from "test/mock-server";
Expand Down Expand Up @@ -135,6 +136,31 @@ describe("SoftwareInstallDetailsModal", () => {
expect(screen.getByText(/\d+.*ago/)).toBeInTheDocument();
});

it("renders app-open skipped copy instead of generic failed-install copy", () => {
render(
<StatusMessage
softwareName="CoolApp"
installResult={createMockSoftwareInstallResult({
status: "failed_install",
})}
isMyDevicePage={false}
installSkippedWhenAppOpen
/>
);

expect(screen.getByText(/Fleet skipped install of/)).toBeInTheDocument();
expect(screen.getByText(/The app was open/)).toBeInTheDocument();
expect(
screen.getByText(
/It will update once the user closes it and policy runs again, or update via self service\./
)
).toBeInTheDocument();
expect(screen.queryByText(/failed to install/)).not.toBeInTheDocument();
// Grey "!" (error-outline), not the red failure icon.
expect(screen.getByTestId("error-outline-icon")).toBeInTheDocument();
expect(screen.queryByTestId("error-icon")).not.toBeInTheDocument();
});

it("on host details page/install activity, renders installed message with timestamp", () => {
render(
<StatusMessage
Expand Down Expand Up @@ -310,6 +336,33 @@ describe("SoftwareInstallDetailsModal", () => {
).not.toBeInTheDocument();
});

it("renders the app-open pre-install output for a skipped install", async () => {
mockServer.use(getSoftwareInstallHandlerAppOpen);
const renderWithServer = createCustomRenderer({ withBackendMock: true });
const { user } = renderWithServer(
<SoftwareInstallDetailsModal
details={{
...baseDetails,
install_skipped_when_app_open: true,
}}
onCancel={noop}
/>
);

await screen.findByText(/Fleet skipped install of/);
await user.click(screen.getByRole("button", { name: /Details/i }));

expect(screen.getByText("Pre-install query output:")).toBeInTheDocument();
// Figma: the code block shows both the generic no-result line and the
// app-open reason (label stays "Pre-install query output:").
expect(
screen.getByText(
/Query didn't return result or failed\s+The app was open/
)
).toBeInTheDocument();
expect(screen.queryByText("Install stopped")).not.toBeInTheDocument();
});

it("shows install and post-install outputs after clicking Details (no pre-install)", async () => {
mockServer.use(getDefaultSoftwareInstallHandler);
const renderWithServer = createCustomRenderer({ withBackendMock: true });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const baseClass = "software-install-details-modal";
export type IPackageInstallDetails = {
host_display_name?: string;
install_uuid?: string; // not actually optional
install_skipped_when_app_open?: boolean;
};

export const renderContactOption = (url?: string) => (
Expand All @@ -73,6 +74,7 @@ interface IInstallStatusMessage {
- From Activity feed: never override (always show the failure).
Parity with VPPInstallDetailsModal/SoftwareIpaInstallDetailsModal */
canOverrideFailureWithInstalled?: boolean;
installSkippedWhenAppOpen?: boolean;
}

// TODO - match VppInstallDetailsModal status to this, still accounting for MDM-specific cases
Expand All @@ -83,6 +85,7 @@ export const StatusMessage = ({
isMyDevicePage,
contactUrl,
canOverrideFailureWithInstalled = false,
installSkippedWhenAppOpen = false,
}: IInstallStatusMessage) => {
// the case when software is installed by the user and not by Fleet
if (!installResult) {
Expand Down Expand Up @@ -143,6 +146,24 @@ export const StatusMessage = ({
})})`
: "";

if (installSkippedWhenAppOpen && status === "failed_install") {
return (
<IconStatusMessage
className={`${baseClass}__status-message`}
iconName={INSTALL_DETAILS_STATUS_ICONS.skipped_install}
iconColor="ui-fleet-black-50"
message={
<span>
Fleet skipped install of <b>{software_title}</b> ({software_package}
) on {formattedHost}
{displayTimeStamp}. The app was open. It will update once the user
closes it and policy runs again, or update via self service.
</span>
}
/>
);
}

const renderStatusCopy = () => {
const prefix = (
<>
Expand Down Expand Up @@ -296,7 +317,9 @@ export const SoftwareInstallDetailsModal = ({
const outputs = [
{
label: "Pre-install query output:",
value: swInstallResult?.pre_install_query_output,
value: detailsFromProps.install_skipped_when_app_open
? "Query didn't return result or failed\nThe app was open"
: swInstallResult?.pre_install_query_output,
},
{
label: "Install script output:",
Expand All @@ -312,7 +335,8 @@ export const SoftwareInstallDetailsModal = ({
const showDetailsButton =
(!!swInstallResult?.post_install_script_output ||
!!swInstallResult?.output ||
!!swInstallResult?.pre_install_query_output) &&
!!swInstallResult?.pre_install_query_output ||
!!detailsFromProps.install_skipped_when_app_open) &&
swInstallResult?.status !== "pending_install";

return (
Expand Down Expand Up @@ -453,6 +477,9 @@ export const SoftwareInstallDetailsModal = ({
isMyDevicePage={!!deviceAuthToken}
contactUrl={contactUrl}
canOverrideFailureWithInstalled={canOverrideFailureWithInstalled}
installSkippedWhenAppOpen={
detailsFromProps.install_skipped_when_app_open
}
/>

{/* Package SHA-256 hash — backend hydrates `hash_sha256` on the
Expand Down
10 changes: 7 additions & 3 deletions frontend/components/ActivityDetails/InstallDetails/constants.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { IconNames } from "components/icons";
import {
SoftwareInstallUninstallStatus,
SoftwareInstallDetailsStatus,
EnhancedSoftwareInstallUninstallStatus,
SoftwareInstallStatus,
} from "interfaces/software";

// Install/Uninstall helpers

export const INSTALL_DETAILS_STATUS_ICONS: Record<
SoftwareInstallUninstallStatus, // former is superset of latter, latter included in union for type system
SoftwareInstallDetailsStatus,
IconNames
> = {
pending_install: "pending-outline",
Expand All @@ -17,10 +17,13 @@ export const INSTALL_DETAILS_STATUS_ICONS: Record<
failed_install: "error",
pending_uninstall: "pending-outline",
failed_uninstall: "error",
// Same "!" glyph as a failure, but the call site renders it muted grey
// (ui-fleet-black-50): a skip is deferred (app was open), not an error.
skipped_install: "error-outline",
} as const;

const INSTALL_DETAILS_STATUS_PREDICATES: Record<
EnhancedSoftwareInstallUninstallStatus,
EnhancedSoftwareInstallUninstallStatus | "skipped_install",
string
> = {
pending_install: "is installing or will install",
Expand All @@ -32,6 +35,7 @@ const INSTALL_DETAILS_STATUS_PREDICATES: Record<
pending_script: "is running or will run",
failed_script: "failed to run",
ran_script: "ran",
skipped_install: "skipped install of",
} as const;

export const getInstallDetailsStatusPredicate = (
Expand Down
1 change: 1 addition & 0 deletions frontend/interfaces/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ export interface IActivityDetails {
host_platform?: string;
host_serial?: string;
install_uuid?: string;
install_skipped_when_app_open?: boolean;
installed_from_dep?: boolean;
labels_exclude_any?: ILabelSoftwareTitle[];
labels_include_any?: ILabelSoftwareTitle[];
Expand Down
2 changes: 2 additions & 0 deletions frontend/interfaces/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export interface IPolicy {
run_script?: Pick<IScript, "id" | "name">;
patch_software?: IPolicySoftwareToInstall;
continuous_automations_enabled?: boolean;
patch_when_closed?: boolean;
labels_include_any?: ILabelPolicy[];
labels_include_all?: ILabelPolicy[];
labels_exclude_any?: ILabelPolicy[];
Expand Down Expand Up @@ -146,6 +147,7 @@ export interface IPolicyFormData {
calendar_events_enabled?: boolean;
conditional_access_enabled?: boolean;
continuous_automations_enabled?: boolean;
patch_when_closed?: boolean;
software_title_id?: number | null;
/** Pins the policy to a specific package on a multi-package title. `null`
* on PATCH lets the backend fall back to the title's first-added package
Expand Down
9 changes: 9 additions & 0 deletions frontend/interfaces/software.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ export interface ISoftwareTitleVersion {
export interface ISoftwarePatchPolicy {
id: number;
name: string;
patch_when_closed: boolean;
continuous_automations_enabled: boolean;
}

export type SoftwareInstallPolicyType = "dynamic" | "patch";
Expand Down Expand Up @@ -472,6 +474,12 @@ export const SOFTWARE_INSTALL_UNINSTALL_STATUSES = [
*/
export type SoftwareInstallUninstallStatus = typeof SOFTWARE_INSTALL_UNINSTALL_STATUSES[number];

/** Activity-backed install details can display a skipped state while the
* persisted install result remains failed_install. */
export type SoftwareInstallDetailsStatus =
| SoftwareInstallUninstallStatus
| "skipped_install";

/** Include script-only software statuses */
export const ENAHNCED_SOFTWARE_INSTALL_UNINSTALL_STATUSES = [
...SOFTWARE_INSTALL_STATUSES,
Expand Down Expand Up @@ -945,6 +953,7 @@ export interface IFleetMaintainedAppDetails {
install_script: string;
post_install_script: string;
uninstall_script: string;
automatic_install_query: string;
url: string;
slug: string;
software_title_id?: number; // null unless the team already has the software added (as a Fleet-maintained app, App Store (app), or custom package)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1917,6 +1917,47 @@ describe("Activity Feed", () => {
expect(screen.getByText("Script-only Software")).toBeInTheDocument();
});

it("renders skipped copy when the app was open", () => {
const activity = createMockActivity({
type: ActivityType.InstalledSoftware,
actor_full_name: "Fleet",
fleet_initiated: true,
details: {
software_title: "Firefox",
software_package: "Firefox.pkg",
host_display_name: "Work Mac",
source: "apps",
status: "failed_install",
install_skipped_when_app_open: true,
},
});

render(<GlobalActivityItem activity={activity} isPremiumTier />);
expect(screen.getByText(/skipped install of/)).toBeInTheDocument();
expect(screen.getByText("Firefox")).toBeInTheDocument();
expect(screen.getByText("Work Mac")).toBeInTheDocument();
expect(screen.queryByText(/failed to install/)).not.toBeInTheDocument();
});

it("keeps generic failed-install copy when the app-open flag is absent", () => {
const activity = createMockActivity({
type: ActivityType.InstalledSoftware,
actor_full_name: "Fleet",
fleet_initiated: true,
details: {
software_title: "Firefox",
software_package: "Firefox.pkg",
host_display_name: "Work Mac",
source: "apps",
status: "failed_install",
},
});

render(<GlobalActivityItem activity={activity} isPremiumTier />);
expect(screen.getByText(/failed to install/)).toBeInTheDocument();
expect(screen.queryByText(/skipped install/)).not.toBeInTheDocument();
});

it("renders py script package ran status in InstalledSoftware activity", () => {
const activity = createMockActivity({
type: ActivityType.InstalledSoftware,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1478,13 +1478,23 @@ const TAGGED_TEMPLATES = {
source,
self_service,
from_setup_experience,
install_skipped_when_app_open,
} = details;

const showSoftwarePackage =
!!details.software_package &&
activity.type === ActivityType.InstalledSoftware;
const isScriptPackageSource = SCRIPT_PACKAGE_SOURCES.includes(source || "");

if (install_skipped_when_app_open) {
return (
<>
{" "}
skipped install of <b>{title}</b> on <b>{hostName}</b>.
</>
);
}

// Self-service actions: drop the actor and switch to passive voice so the
// sentence reads "<title> was installed on <host> (self-service)." without
// misattributing the action.
Expand Down
Loading
Loading