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
123 changes: 85 additions & 38 deletions frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ import {
IPoliciesCountResponse,
OtherAutomationType,
} from "interfaces/policy";
import { API_ALL_TEAMS_ID, APP_CONTEXT_ALL_TEAMS_ID } from "interfaces/team";
import {
API_ALL_TEAMS_ID,
APP_CONTEXT_ALL_TEAMS_ID,
ITeamConfig,
} from "interfaces/team";

import configAPI from "services/entities/config";
import globalPoliciesAPI, {
Expand Down Expand Up @@ -96,6 +100,23 @@ const AUTOMATION_TYPES: AutomationType[] = [

const GLOBAL_AUTOMATION_TYPES: GlobalPoliciesAutomationType[] = ["other"];

// NOTE: backend uses webhook_settings to store automated policy ids for both
// webhooks and integrations.
const getWebhookOrTicketPolicyIds = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nulmete nulmete May 29, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically yes because we can derive both the state and the IDs in one go. (I simplified this in one of the follow-up PRs so that we return an object with both values.)

See getWebhookOrTicketInfo here: https://github.com/fleetdm/fleet/pull/46326/changes#diff-b779b6619418992f803e16110ef8fcb475a40a9f153c71e7dccefa68b0a465caR16

config: IConfig | ITeamConfig | undefined
): number[] => {
if (!config) return [];
const webhook = config.webhook_settings?.failing_policies_webhook;
const { jira, zendesk } = config.integrations ?? {};
const isIntegrationEnabled =
!!jira?.some((j) => j.enable_failing_policies) ||
!!zendesk?.some((z) => z.enable_failing_policies);
if (isIntegrationEnabled || webhook?.enable_failing_policies_webhook) {
return webhook?.policy_ids || [];
}
return [];
};

const baseClass = "manage-policies-page";

const ManagePolicyPage = ({
Expand Down Expand Up @@ -610,32 +631,41 @@ const ManagePolicyPage = ({
hasPoliciesToAutomate || (isPrimoMode && (teamPolicies?.length ?? 0) > 0); // in Primo mode, allow deleting inherited policies, which will be included in teamPolicies, from this view

// NOTE: backend uses webhook_settings to store automated policy ids for both webhooks and integrations
let currentAutomatedPolicies: number[] = [];
let otherAutomationType: OtherAutomationType | undefined;
if (automationsConfig) {
const getAutomationInfoFromConfig = (
cfg: IConfig | ITeamConfig | undefined
): { policyIds: number[]; type: OtherAutomationType | undefined } => {
if (!cfg) return { policyIds: [], type: undefined };
const {
webhook_settings: { failing_policies_webhook: webhook },
webhook_settings: { failing_policies_webhook: webhook } = {},
integrations,
} = automationsConfig;

let isIntegrationEnabled = false;
if (integrations) {
const { jira, zendesk } = integrations;
isIntegrationEnabled =
!!jira?.find((j) => j.enable_failing_policies) ||
!!zendesk?.find((z) => z.enable_failing_policies);
}

if (isIntegrationEnabled || webhook?.enable_failing_policies_webhook) {
currentAutomatedPolicies = webhook?.policy_ids || [];
}

if (isIntegrationEnabled) {
otherAutomationType = "ticket";
} else if (webhook?.enable_failing_policies_webhook) {
otherAutomationType = "webhook";
}
}
} = cfg;
const isIntegrationEnabled =
!!integrations?.jira?.find((j) => j.enable_failing_policies) ||
!!integrations?.zendesk?.find((z) => z.enable_failing_policies);
const isWebhookEnabled = !!webhook?.enable_failing_policies_webhook;
const policyIds =
isIntegrationEnabled || isWebhookEnabled ? webhook?.policy_ids ?? [] : [];
let type: OtherAutomationType | undefined;
if (isIntegrationEnabled) type = "ticket";
else if (isWebhookEnabled) type = "webhook";
return { policyIds, type };
};
const fleetAutomationInfo = getAutomationInfoFromConfig(automationsConfig);
// Inherited (global) policies are listed in team views, but their webhook
// membership lives on the *global* config — not the team's.
// Union both so an inherited policy with a global-config webhook/ticket
// still shows the correct data.
const inheritedAutomationInfo = !isAllTeamsSelected
? getAutomationInfoFromConfig(globalConfig)
: { policyIds: [], type: undefined as OtherAutomationType | undefined };
const currentAutomatedPolicies: number[] = Array.from(
new Set([
...fleetAutomationInfo.policyIds,
...inheritedAutomationInfo.policyIds,
])
);
const otherAutomationType: OtherAutomationType | undefined =
fleetAutomationInfo.type ?? inheritedAutomationInfo.type;

const renderPoliciesCountAndLastUpdated = (
count?: number,
Expand Down Expand Up @@ -959,19 +989,36 @@ const ManagePolicyPage = ({
onExit={toggleAutomationsModal}
/>
)}
{selectedPolicyForAutomations && (
<ManageAutomationsModal
policy={selectedPolicyForAutomations}
fleetName={currentTeamSummary?.name ?? ""}
isGlobalPolicy={isAllTeamsSelected}
teamIdForApi={teamIdForApi}
automationsConfig={automationsConfig}
globalConfig={globalConfig}
webhookOrTicketPolicyIds={currentAutomatedPolicies}
refetchPolicies={() => refetchPolicies(teamIdForApi)}
onExit={onCloseManageAutomationsModal}
/>
)}
{selectedPolicyForAutomations &&
(() => {
// An inherited policy (team_id === null) is global even when viewed
// from within a fleet's list — its automations live on the global
// config, so route the modal there.
const isInheritedGlobal =
selectedPolicyForAutomations.team_id === null;
const modalAutomationsConfig = isInheritedGlobal
? globalConfig
: automationsConfig;
return (
<ManageAutomationsModal
policy={selectedPolicyForAutomations}
fleetName={
isInheritedGlobal
? "All fleets"
: currentTeamSummary?.name ?? ""
}
isGlobalPolicy={isInheritedGlobal}
teamIdForApi={teamIdForApi}
automationsConfig={modalAutomationsConfig}
globalConfig={globalConfig}
webhookOrTicketPolicyIds={getWebhookOrTicketPolicyIds(
modalAutomationsConfig
)}
refetchPolicies={() => refetchPolicies(teamIdForApi)}
onExit={onCloseManageAutomationsModal}
/>
);
})()}
</>
</MainContent>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// definitions for the selection row for some reason when we dont really need it.
import React from "react";
import { millisecondsToHours, millisecondsToMinutes } from "date-fns";
import classnames from "classnames";
// @ts-ignore
import Checkbox from "components/forms/fields/Checkbox";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell";
Expand Down Expand Up @@ -87,6 +88,43 @@ const AUTOMATION_ICON_RENDERERS: Record<
other: () => <Icon name="settings" />,
};

interface IEditableAutomationsCellProps {
ariaLabel: string;
onEdit: () => void;
className?: string;
children: React.ReactNode;
}

const EditableAutomationsCell = ({
ariaLabel,
onEdit,
className,
children,
}: IEditableAutomationsCellProps): JSX.Element => {
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onEdit();
}
};

return (
<div
role="button"
tabIndex={0}
className={classnames("automations__cell-content", className)}
onClick={onEdit}
onKeyDown={handleKeyDown}
aria-label={ariaLabel}
>
{children}
<span className="automations__edit-button" aria-hidden="true">
<Icon name="pencil" />
</span>
</div>
);
};

interface IAutomationsCellProps {
policy: IPolicyStats;
selectedTeamId?: number | null;
Expand All @@ -102,22 +140,28 @@ const AutomationsCell = ({
}: IAutomationsCellProps): JSX.Element => {
const automations = getAutomationsForPolicy(policy, otherAutomationType);

const handleEdit = () => onOpenManageAutomationsModal?.(policy);

if (automations.length === 0) {
// Read-only users (no edit callback) keep the plain, non-interactive "---".
if (!onOpenManageAutomationsModal) {
return (
<span className="automations__cell-content automations__cell-content--none">
{DEFAULT_EMPTY_CELL_VALUE}
</span>
);
}
return (
<span className="automations__cell-content automations__cell-content--none">
{DEFAULT_EMPTY_CELL_VALUE}
</span>
<EditableAutomationsCell
ariaLabel="Add automation"
onEdit={handleEdit}
className="automations__cell-content--none"
>
<span className="automations__name">{DEFAULT_EMPTY_CELL_VALUE}</span>
</EditableAutomationsCell>
);
}

const handleClick = () => onOpenManageAutomationsModal?.(policy);
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleClick();
}
};

const renderAutomationIcon = ({
type,
name,
Expand All @@ -141,35 +185,21 @@ const AutomationsCell = ({
if (automations.length === 1) {
const automation = automations[0];
return (
<div
role="button"
tabIndex={0}
className="automations__cell-content"
onClick={handleClick}
onKeyDown={handleKeyDown}
aria-label={`Edit automation: ${automation.name}`}
<EditableAutomationsCell
ariaLabel={`Edit automation: ${automation.name}`}
onEdit={handleEdit}
>
<TooltipTruncatedTextCell
prefix={renderAutomationIcon(automation)}
value={automation.name}
className="automations__name"
/>
<span className="automations__edit-button" aria-hidden="true">
<Icon name="pencil" />
</span>
</div>
</EditableAutomationsCell>
);
}

return (
<div
role="button"
tabIndex={0}
className="automations__cell-content"
onClick={handleClick}
onKeyDown={handleKeyDown}
aria-label="Edit automations"
>
<EditableAutomationsCell ariaLabel="Edit automations" onEdit={handleEdit}>
<TooltipWrapper
className="automations__count"
position="top"
Expand All @@ -180,10 +210,7 @@ const AutomationsCell = ({
>
{automations.length} automations
</TooltipWrapper>
<span className="automations__edit-button" aria-hidden="true">
<Icon name="pencil" />
</span>
</div>
</EditableAutomationsCell>
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,15 @@

&--none {
color: $ui-fleet-black-50;
cursor: default;
}
}

// The read-only empty cell is a plain <span> (no role="button"); keep it
// non-interactive. The editable empty cell is a <div role="button">.
span.automations__cell-content--none {
cursor: default;
}

.automations__name {
flex: 1;
min-width: 0;
Expand Down
Loading