Feat/31914 patch policy (#41518)

Implements patch policies #31914 

- https://github.com/fleetdm/fleet/pull/40816
- https://github.com/fleetdm/fleet/pull/41248
- https://github.com/fleetdm/fleet/pull/41276
- https://github.com/fleetdm/fleet/pull/40948
- https://github.com/fleetdm/fleet/pull/40837
- https://github.com/fleetdm/fleet/pull/40956
- https://github.com/fleetdm/fleet/pull/41168
- https://github.com/fleetdm/fleet/pull/41171
- https://github.com/fleetdm/fleet/pull/40691
- https://github.com/fleetdm/fleet/pull/41524
- https://github.com/fleetdm/fleet/pull/41674

---------

Co-authored-by: Jonathan Katz <44128041+jkatz01@users.noreply.github.com>
Co-authored-by: jkatz01 <yehonatankatz@gmail.com>
Co-authored-by: RachelElysia <71795832+RachelElysia@users.noreply.github.com>
Co-authored-by: Jahziel Villasana-Espinoza <jahziel@fleetdm.com>
This commit is contained in:
Carlo 2026-03-13 16:47:09 -04:00 committed by GitHub
parent ca89b035ac
commit 2abacc577e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
125 changed files with 3695 additions and 1075 deletions

View file

@ -0,0 +1 @@
- Added patch policies for Fleet-maintained apps that automatically update when the app is updated.

View file

@ -73,6 +73,7 @@ func TestRunApiCommand(t *testing.T) {
"platform": "darwin,windows,linux,chrome",
"calendar_events_enabled": false,
"conditional_access_enabled": false,
"type": "dynamic",
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z",
"passing_host_count": 0,

View file

@ -53,9 +53,10 @@ type FileToWrite struct {
}
type Software struct {
Hash string
AppStoreId string
Comment string
Hash string
AppStoreId string
Comment string
MaintainedAppID uint
}
type teamToProcess struct {
@ -87,6 +88,7 @@ type generateGitopsClient interface {
GetAppleMDMEnrollmentProfile(teamID uint) (*fleet.MDMAppleSetupAssistant, error)
GetCertificateAuthoritiesSpec(includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error)
GetCertificateTemplates(teamID string) ([]*fleet.CertificateTemplateResponseSummary, error)
GetFleetMaintainedApp(id uint) (*fleet.MaintainedApp, error)
}
// Given a struct type and a field name, return the JSON field name.
@ -1444,12 +1446,29 @@ func (cmd *GenerateGitopsCommand) generatePolicies(teamId *uint, filePath string
jsonFieldName(t, "Name"): policy.Name,
jsonFieldName(t, "Description"): policy.Description,
jsonFieldName(t, "Resolution"): policy.Resolution,
jsonFieldName(t, "Query"): policy.Query,
jsonFieldName(t, "Platform"): policy.Platform,
jsonFieldName(t, "Critical"): policy.Critical,
jsonFieldName(t, "CalendarEventsEnabled"): policy.CalendarEventsEnabled,
jsonFieldName(t, "ConditionalAccessEnabled"): policy.ConditionalAccessEnabled,
}
if policy.Type == fleet.PolicyTypeDynamic {
policySpec[jsonFieldName(t, "Query")] = policy.Query
}
if policy.PatchSoftware != nil {
cachedSWTitle := cmd.SoftwareList[policy.PatchSoftware.SoftwareTitleID]
fma, err := cmd.Client.GetFleetMaintainedApp(cachedSWTitle.MaintainedAppID)
if err != nil {
return nil, err
}
policySpec["fleet_maintained_app_slug"] = fma.Slug
}
if policy.Type != "" {
policySpec["type"] = policy.Type
}
// This is derived from the failing_policies_webhook.policy_ids field, which is being deprecated.
policySpec["webhooks_and_tickets_enabled"] = failingPolicyIDs[policy.ID]
// Handle software automation.
@ -1638,10 +1657,15 @@ func (cmd *GenerateGitopsCommand) generateSoftware(filePath string, teamID uint,
softwareSpec["hash_sha256"] = cmd.AddComment(filePath, "TODO: Add your hash_sha256 here")
} else {
softwareSpec["hash_sha256"] = *sw.HashSHA256 + " " + comment
cmd.SoftwareList[sw.ID] = Software{
swEntry := Software{
Hash: *sw.HashSHA256,
Comment: comment,
}
if sw.SoftwarePackage != nil && sw.SoftwarePackage.FleetMaintainedAppID != nil {
swEntry.MaintainedAppID = *sw.SoftwarePackage.FleetMaintainedAppID
}
cmd.SoftwareList[sw.ID] = swEntry
}
case sw.AppStoreApp != nil:
softwareSpec["app_store_id"] = sw.AppStoreApp.AppStoreID

View file

@ -319,6 +319,10 @@ func (MockClient) ListSoftwareTitles(query string) ([]fleet.SoftwareTitleListRes
}
}
func (MockClient) GetFleetMaintainedApp(id uint) (*fleet.MaintainedApp, error) {
return &fleet.MaintainedApp{Slug: "foo/darwin"}, nil
}
func (MockClient) GetPolicies(teamID *uint) ([]*fleet.Policy, error) {
if teamID == nil {
return []*fleet.Policy{
@ -336,6 +340,7 @@ func (MockClient) GetPolicies(teamID *uint) ([]*fleet.Policy, error) {
LabelName: "Label B",
}},
ConditionalAccessEnabled: true,
Type: fleet.PolicyTypeDynamic,
},
InstallSoftware: &fleet.PolicySoftwareTitle{
SoftwareTitleID: 1,
@ -353,11 +358,28 @@ func (MockClient) GetPolicies(teamID *uint) ([]*fleet.Policy, error) {
Description: "This is a team policy",
Platform: "linux,windows",
ConditionalAccessEnabled: true,
Type: fleet.PolicyTypeDynamic,
},
RunScript: &fleet.PolicyScript{
ID: 1,
},
},
{
PolicyData: fleet.PolicyData{
ID: 2,
Name: "Team patch policy",
Query: "SELECT * FROM team_policy WHERE id = 1",
Resolution: ptr.String("Do a team thing"),
Description: "This is a team patch policy",
Platform: "linux,windows",
ConditionalAccessEnabled: true,
Type: fleet.PolicyTypePatch,
},
PatchSoftware: &fleet.PolicySoftwareTitle{
SoftwareTitleID: 8,
},
},
}, nil
}
@ -1710,7 +1732,7 @@ func TestGeneratePolicies(t *testing.T) {
Client: fleetClient,
CLI: cli.NewContext(cli.NewApp(), nil, nil),
Messages: Messages{},
FilesToWrite: make(map[string]interface{}),
FilesToWrite: make(map[string]any),
AppConfig: appConfig,
SoftwareList: map[uint]Software{
1: {
@ -1726,43 +1748,46 @@ func TestGeneratePolicies(t *testing.T) {
policiesRaw, err := cmd.generatePolicies(nil, "default.yml", nil)
require.NoError(t, err)
require.NotNil(t, policiesRaw)
var policies []map[string]interface{}
var generatedPolicies []map[string]any
b, err := yaml.Marshal(policiesRaw)
require.NoError(t, err)
fmt.Println("policies raw:\n", string(b)) // Debugging line
err = yaml.Unmarshal(b, &policies)
fmt.Println("generated global policies raw:\n", string(b)) // Debugging line
err = yaml.Unmarshal(b, &generatedPolicies)
require.NoError(t, err)
// Get the expected org settings YAML.
b, err = os.ReadFile("./testdata/generateGitops/expectedGlobalPolicies.yaml")
require.NoError(t, err)
var expectedPolicies []map[string]interface{}
var expectedPolicies []map[string]any
err = yaml.Unmarshal(b, &expectedPolicies)
require.NoError(t, err)
// Compare.
require.Equal(t, expectedPolicies, policies)
require.Equal(t, expectedPolicies, generatedPolicies)
// Generate policies for a team.
// Note that nested keys here may be strings,
// so we'll JSON marshal and unmarshal to a map for comparison.
var expectedTeamPolicies []map[string]any
var generatedTeamPolicies []map[string]any
policiesRaw, err = cmd.generatePolicies(ptr.Uint(1), "some_team", nil)
require.NoError(t, err)
require.NotNil(t, policiesRaw)
b, err = yaml.Marshal(policiesRaw)
require.NoError(t, err)
fmt.Println("policies raw:\n", string(b)) // Debugging line
err = yaml.Unmarshal(b, &policies)
fmt.Println("generated team policies raw:\n", string(b)) // Debugging line
err = yaml.Unmarshal(b, &generatedTeamPolicies)
require.NoError(t, err)
// Get the expected org settings YAML.
b, err = os.ReadFile("./testdata/generateGitops/expectedTeamPolicies.yaml")
require.NoError(t, err)
err = yaml.Unmarshal(b, &expectedPolicies)
err = yaml.Unmarshal(b, &expectedTeamPolicies)
require.NoError(t, err)
// Compare.
require.Equal(t, expectedPolicies, policies)
require.Equal(t, expectedPolicies, generatedPolicies)
}
func TestGenerateQueries(t *testing.T) {

View file

@ -437,6 +437,7 @@ func TestGetHosts(t *testing.T) {
Resolution: ptr.String("Some resolution"),
TeamID: ptr.Uint(1),
CalendarEventsEnabled: true,
Type: "dynamic",
},
Response: "passes",
},
@ -452,6 +453,7 @@ func TestGetHosts(t *testing.T) {
Resolution: nil,
TeamID: nil,
CalendarEventsEnabled: false,
Type: "dynamic",
},
Response: "fails",
},

View file

@ -108,7 +108,7 @@ func TestGitOpsBasicGlobalFree(t *testing.T) {
return nil, 0, 0, nil, nil
}
ds.ListTeamPoliciesFunc = func(
ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions,
ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, automationFilter string,
) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) {
return nil, nil, nil
}
@ -429,7 +429,7 @@ func TestGitOpsBasicGlobalPremium(t *testing.T) {
return nil
}
ds.ListTeamPoliciesFunc = func(
ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions,
ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, automationFilter string,
) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) {
return nil, nil, nil
}
@ -711,7 +711,7 @@ func TestGitOpsBasicTeam(t *testing.T) {
return nil
}
ds.ListTeamPoliciesFunc = func(
ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions,
ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, automationFilter string,
) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) {
return nil, nil, nil
}
@ -1005,7 +1005,7 @@ func TestGitOpsFullGlobal(t *testing.T) {
policy.Name = "Policy to delete"
policyDeleted := false
ds.ListTeamPoliciesFunc = func(
ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions,
ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, automationFilter string,
) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) {
return nil, nil, nil
}
@ -1460,7 +1460,7 @@ func TestGitOpsFullTeam(t *testing.T) {
policy.TeamID = ptr.Uint(teamID)
policyDeleted := false
ds.ListTeamPoliciesFunc = func(
ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions,
ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, automationFilter string,
) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) {
if teamID != 0 {
return []*fleet.Policy{&policy}, nil, nil
@ -1772,7 +1772,7 @@ func TestGitOpsBasicGlobalAndTeam(t *testing.T) {
}
ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { return nil, nil }
ds.ListTeamPoliciesFunc = func(
ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions,
ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, automationFilter string,
) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) {
return nil, nil, nil
}
@ -2186,7 +2186,7 @@ func TestGitOpsBasicGlobalAndNoTeam(t *testing.T) {
}
ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { return nil, nil }
ds.ListTeamPoliciesFunc = func(
ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions,
ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, automationFilter string,
) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) {
return nil, nil, nil
}
@ -3584,7 +3584,7 @@ func TestGitOpsFleetWebhooksAndTicketsEnabled(t *testing.T) {
// Override ListTeamPolicies to return the applied policies with IDs.
ds.ListTeamPoliciesFunc = func(
ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions,
ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, automationFilter string,
) (fleetPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) {
return appliedPolicies, nil, nil
}
@ -3677,7 +3677,7 @@ agent_options:
// Track how many times ListTeamPolicies is called.
listTeamPoliciesCalls := 0
ds.ListTeamPoliciesFunc = func(
ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions,
ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, automationFilter string,
) ([]*fleet.Policy, []*fleet.Policy, error) {
listTeamPoliciesCalls++
return appliedPolicies, nil, nil
@ -5026,7 +5026,7 @@ func TestGitOpsWindowsUpdates(t *testing.T) {
ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) {
return nil, nil
}
ds.ListTeamPoliciesFunc = func(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions) ([]*fleet.Policy, []*fleet.Policy, error) {
ds.ListTeamPoliciesFunc = func(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, automationFilter string) ([]*fleet.Policy, []*fleet.Policy, error) {
return nil, nil, nil
}
ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, androidProfiles []*fleet.MDMAndroidConfigProfile, vars []fleet.MDMProfileIdentifierFleetVariables) (fleet.MDMProfilesUpdates, error) {
@ -5429,7 +5429,7 @@ func TestGitOpsAppStoreAppAutoUpdate(t *testing.T) {
ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) {
return nil, nil
}
ds.ListTeamPoliciesFunc = func(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions) ([]*fleet.Policy, []*fleet.Policy, error) {
ds.ListTeamPoliciesFunc = func(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, automationFilter string) ([]*fleet.Policy, []*fleet.Policy, error) {
return nil, nil, nil
}
ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, androidProfiles []*fleet.MDMAndroidConfigProfile, vars []fleet.MDMProfileIdentifierFleetVariables) (fleet.MDMProfilesUpdates, error) {
@ -5759,7 +5759,7 @@ func TestGitOpsAppleOSUpdates(t *testing.T) {
ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) {
return nil, nil
}
ds.ListTeamPoliciesFunc = func(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions) ([]*fleet.Policy, []*fleet.Policy, error) {
ds.ListTeamPoliciesFunc = func(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, automationFilter string) ([]*fleet.Policy, []*fleet.Policy, error) {
return nil, nil, nil
}
ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, androidProfiles []*fleet.MDMAndroidConfigProfile, vars []fleet.MDMProfileIdentifierFleetVariables) (fleet.MDMProfilesUpdates, error) {
@ -6116,7 +6116,7 @@ func TestGitOpsWindowsOSUpdates(t *testing.T) {
ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) {
return nil, nil
}
ds.ListTeamPoliciesFunc = func(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions) ([]*fleet.Policy, []*fleet.Policy, error) {
ds.ListTeamPoliciesFunc = func(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, automationFilter string) ([]*fleet.Policy, []*fleet.Policy, error) {
return nil, nil, nil
}
ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, androidProfiles []*fleet.MDMAndroidConfigProfile, vars []fleet.MDMProfileIdentifierFleetVariables) (fleet.MDMProfilesUpdates, error) {

View file

@ -78,7 +78,8 @@
"resolution": "Some resolution",
"response": "passes",
"team_id": 1,
"updated_at": "0001-01-01T00:00:00Z"
"updated_at": "0001-01-01T00:00:00Z",
"type": "dynamic"
},
{
"author_email": "alice@example.com",
@ -96,7 +97,8 @@
"query": "select 1 from osquery_info where start_time \u003e 1;",
"response": "fails",
"team_id": null,
"updated_at": "0001-01-01T00:00:00Z"
"updated_at": "0001-01-01T00:00:00Z",
"type": "dynamic"
}
],
"policy_updated_at": "0001-01-01T00:00:00Z",

View file

@ -76,6 +76,7 @@ spec:
response: passes
team_id: 1
updated_at: "0001-01-01T00:00:00Z"
type: "dynamic"
- author_email: alice@example.com
author_id: 1
author_name: Alice
@ -92,6 +93,7 @@ spec:
response: fails
team_id: null
updated_at: "0001-01-01T00:00:00Z"
type: "dynamic"
policy_updated_at: "0001-01-01T00:00:00Z"
primary_ip: ""
primary_mac: ""

View file

@ -11,4 +11,5 @@
platform: darwin
query: SELECT * FROM global_policy WHERE id = 1
resolution: Do a global thing
webhooks_and_tickets_enabled: false
type: dynamic
webhooks_and_tickets_enabled: false

View file

@ -7,4 +7,19 @@
resolution: Do a team thing
run_script:
path: /path/to/script1.sh
webhooks_and_tickets_enabled: false
type: dynamic
conditional_access_enabled: true
conditional_access_bypass_enabled: true
webhooks_and_tickets_enabled: false
- calendar_events_enabled: false
critical: false
description: This is a team patch policy
name: Team patch policy
platform: linux,windows
query: SELECT * FROM team_policy WHERE id = 1
resolution: Do a team thing
fleet_maintained_app_slug: "foo/darwin"
type: patch
conditional_access_enabled: true
conditional_access_bypass_enabled: false
webhooks_and_tickets_enabled: false

View file

@ -189,6 +189,7 @@ policies:
platform: darwin
query: SELECT * FROM global_policy WHERE id = 1
resolution: Do a global thing
type: dynamic
webhooks_and_tickets_enabled: false
reports:
- automations_enabled: true

View file

@ -185,6 +185,7 @@ policies:
platform: darwin
query: SELECT * FROM global_policy WHERE id = 1
resolution: Do a global thing
type: dynamic
webhooks_and_tickets_enabled: false
reports:
- automations_enabled: true

View file

@ -58,6 +58,17 @@ policies:
platform: linux,windows
query: SELECT * FROM team_policy WHERE id = 1
resolution: Do a team thing
type: dynamic
webhooks_and_tickets_enabled: true
- calendar_events_enabled: false
conditional_access_enabled: true
critical: false
description: This is a team patch policy
fleet_maintained_app_slug: foo/darwin
name: Team patch policy
platform: linux,windows
resolution: Do a team thing
type: patch
webhooks_and_tickets_enabled: true
reports:
- automations_enabled: true

View file

@ -41,6 +41,17 @@ policies:
platform: linux,windows
query: SELECT * FROM team_policy WHERE id = 1
resolution: Do a team thing
type: dynamic
webhooks_and_tickets_enabled: true
- calendar_events_enabled: false
conditional_access_enabled: true
critical: false
description: This is a team patch policy
fleet_maintained_app_slug: foo/darwin
name: Team patch policy
platform: linux,windows
resolution: Do a team thing
type: patch
webhooks_and_tickets_enabled: true
settings:
webhook_settings:

View file

@ -87,6 +87,7 @@ func RunServerWithMockedDS(t *testing.T, opts ...*service.TestServerOpts) (*http
Description: args.Description,
Resolution: &args.Resolution,
AuthorID: authorID,
Type: "dynamic",
},
}, nil
}
@ -350,7 +351,7 @@ func SetupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig,
}
ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { return nil, nil }
ds.ListTeamPoliciesFunc = func(
ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions,
ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, automationFilter string,
) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) {
return nil, nil, nil
}

View file

@ -3726,7 +3726,7 @@ settings:
installer, err := s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, nil, titles[0].ID, false)
require.NoError(t, err)
tmPols, err := s.DS.ListMergedTeamPolicies(ctx, 0, fleet.ListOptions{})
tmPols, err := s.DS.ListMergedTeamPolicies(ctx, 0, fleet.ListOptions{}, "")
require.NoError(t, err)
require.Len(t, tmPols, 1)
require.Equal(t, "Install ruby", tmPols[0].Name)
@ -3746,7 +3746,7 @@ settings:
installer, err = s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, &tm.ID, titles[0].ID, false)
require.NoError(t, err)
tmPols, err = s.DS.ListMergedTeamPolicies(ctx, tm.ID, fleet.ListOptions{})
tmPols, err = s.DS.ListMergedTeamPolicies(ctx, tm.ID, fleet.ListOptions{}, "")
require.NoError(t, err)
require.Len(t, tmPols, 1)
require.Equal(t, "Install team ruby", tmPols[0].Name)
@ -3926,7 +3926,7 @@ software:
fl, err := s.DS.TeamByName(ctx, fleetName)
require.NoError(t, err)
flPols, err := s.DS.ListMergedTeamPolicies(ctx, fl.ID, fleet.ListOptions{})
flPols, err := s.DS.ListMergedTeamPolicies(ctx, fl.ID, fleet.ListOptions{}, "")
require.NoError(t, err)
require.Len(t, flPols, 1)
require.Equal(t, "Test Fleet Policy", flPols[0].Name)
@ -3967,7 +3967,7 @@ name: %s
}))
// Verify policies were cleared.
flPols, err = s.DS.ListMergedTeamPolicies(ctx, fl.ID, fleet.ListOptions{})
flPols, err = s.DS.ListMergedTeamPolicies(ctx, fl.ID, fleet.ListOptions{}, "")
require.NoError(t, err)
require.Len(t, flPols, 0)

View file

@ -472,6 +472,13 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.
payloadForNewInstallerFile = nil
payload.InstallerFile = nil
}
if existingInstaller.FleetMaintainedAppID != nil {
return nil, &fleet.BadRequestError{
Message: "Couldn't update. The package can't be changed for Fleet-maintained apps.",
InternalErr: ctxerr.Wrap(ctx, err, "installer file changed for fleet maintained app installer"),
}
}
}
if payload.InstallerFile == nil { // fill in existing existingInstaller data to payload

View file

@ -22,6 +22,7 @@ const DEFAULT_POLICY_MOCK: IPolicyStats = {
webhook: "Off",
has_run: true,
next_update_ms: 3600000,
type: "dynamic",
calendar_events_enabled: true,
conditional_access_enabled: false,
install_software: {
@ -59,6 +60,7 @@ export const createMockPoliciesResponse = (
webhook: "Off",
has_run: true,
next_update_ms: 3600000,
type: "dynamic",
calendar_events_enabled: false,
conditional_access_enabled: false,
},
@ -82,6 +84,7 @@ export const createMockPoliciesResponse = (
webhook: "Off",
has_run: true,
next_update_ms: 3600000,
type: "dynamic",
calendar_events_enabled: false,
conditional_access_enabled: false,
},
@ -107,6 +110,7 @@ export const createMockPoliciesResponse = (
webhook: "Off",
has_run: true,
next_update_ms: 3600000,
type: "dynamic",
calendar_events_enabled: false,
conditional_access_enabled: false,
},

View file

@ -5,6 +5,7 @@ import { GraphicNames, GRAPHIC_MAP } from "components/graphics";
interface IGraphicProps {
name: GraphicNames;
/** scale-40-24 Workaround to scale 40px to 24px */
className?: string;
}

View file

@ -3,4 +3,13 @@
// aligns properly in text, buttons, dropdowns, summary tile custom component
display: inline-flex;
align-self: center;
// Workaround to rescale 40px to 24px
// For file-sh and file-ps1 graphics used in 24px PolicyAutomations.tsx
&.scale-40-24 {
svg {
height: 24px;
width: 24px;
}
}
}

View file

@ -1,32 +0,0 @@
import React from "react";
import { PlacesType } from "react-tooltip-5";
import TooltipWrapper from "components/TooltipWrapper";
const baseClass = "inherited-badge";
interface IInheritedBadgeProps {
tooltipPosition?: PlacesType;
tooltipContent: React.ReactNode;
}
const InheritedBadge = ({
tooltipPosition = "top",
tooltipContent,
}: IInheritedBadgeProps) => {
return (
<div className={baseClass}>
<TooltipWrapper
tipContent={tooltipContent}
showArrow
position={tooltipPosition}
tipOffset={8}
underline={false}
delayInMs={300} // TODO: Apply pattern of delay tooltip for repeated table tooltips
>
<span className={`${baseClass}__element-text`}>Inherited</span>
</TooltipWrapper>
</div>
);
};
export default InheritedBadge;

View file

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

View file

@ -0,0 +1,29 @@
import React from "react";
import TooltipWrapper from "components/TooltipWrapper";
const baseClass = "pill-badge";
interface IPillBadge {
text: string;
tipContent?: JSX.Element | string;
}
const PillBadge = ({ text, tipContent }: IPillBadge) => {
return (
<div className={`${baseClass}__`}>
<TooltipWrapper
tipContent={tipContent}
showArrow
underline={false}
position="top"
tipOffset={12}
delayInMs={300}
>
<span className={`${baseClass}__element-text`}>{text}</span>
</TooltipWrapper>
</div>
);
};
export default PillBadge;

View file

@ -1,8 +1,14 @@
.inherited-badge {
.pill-badge {
&__element-text {
display: flex;
height: 16px;
padding: 0 4px;
justify-content: center;
align-items: center;
gap: $pad-small;
font-weight: $bold;
font-size: $xxx-small;
color: $core-fleet-black;
color: $ui-fleet-black-75;
line-height: 15px;
border-radius: $border-radius;
background: $ui-fleet-black-10;

View file

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

View file

@ -0,0 +1,52 @@
import React from "react";
import TooltipWrapper from "components/TooltipWrapper";
import Icon from "components/Icon";
import { SoftwareInstallPolicyTypeSet } from "interfaces/software";
import PillBadge from "components/PillBadge";
const baseClass = "software-install-policy-badges";
export const PATCH_TOOLTIP_CONTENT = (
<>
Hosts will fail this policy if they&apos;re <br />
running an older version.
</>
);
interface IPatchBadgesProps {
policyType?: SoftwareInstallPolicyTypeSet;
}
const SoftwareInstallPolicyBadges = ({ policyType }: IPatchBadgesProps) => {
const renderPatchBadge = () => (
<PillBadge text="Patch" tipContent={PATCH_TOOLTIP_CONTENT} />
);
const renderAutomaticInstallBadge = () => (
<TooltipWrapper
className={`${baseClass}__dynamic-policy-tooltip`}
tipContent={
<>
Software will be automatically installed <br />
when hosts fail this policy.
</>
}
tipOffset={14}
position="top"
showArrow
underline={false}
>
<Icon name="refresh" color="ui-fleet-black-75" />
</TooltipWrapper>
);
return (
<>
{policyType?.has("patch") && renderPatchBadge()}
{policyType?.has("dynamic") && renderAutomaticInstallBadge()}
</>
);
};
export default SoftwareInstallPolicyBadges;

View file

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

View file

@ -322,6 +322,7 @@ export const generateCustomDropdownStyles = (
return {
...provided,
fontSize: "13px",
...(variant === "button" && buttonVariantPlaceholder),
};
},

View file

@ -4,12 +4,15 @@ import { pick } from "lodash";
import FormField from "components/forms/FormField";
import { IFormFieldProps } from "components/forms/FormField/FormField";
import TooltipWrapper from "components/TooltipWrapper";
interface ISliderProps {
onChange: () => void;
value: boolean;
inactiveText: JSX.Element | string;
activeText: JSX.Element | string;
/** Use to display slider label tooltip, compatible with disabled state */
labelTooltip?: JSX.Element | string;
className?: string;
helpText?: JSX.Element | string;
autoFocus?: boolean;
@ -24,6 +27,7 @@ const Slider = (props: ISliderProps): JSX.Element => {
value,
inactiveText,
activeText,
labelTooltip,
autoFocus,
disabled,
} = props;
@ -63,6 +67,8 @@ const Slider = (props: ISliderProps): JSX.Element => {
const wrapperClassNames = classnames(`${baseClass}__wrapper`, {
[`${baseClass}__wrapper--disabled`]: disabled,
});
const text = value ? activeText : inactiveText;
return (
<FormField {...formFieldProps} type="slider">
<div className={wrapperClassNames}>
@ -76,13 +82,26 @@ const Slider = (props: ISliderProps): JSX.Element => {
>
<div className={sliderDotClass} />
</button>
<span
className={`${baseClass}__label ${baseClass}__label--${
value ? "active" : "inactive"
}`}
>
{value ? activeText : inactiveText}
</span>
{labelTooltip ? (
<TooltipWrapper tipContent={labelTooltip}>
{" "}
<span
className={`${baseClass}__label ${baseClass}__label--${
value ? "active" : "inactive"
}`}
>
{text}
</span>
</TooltipWrapper>
) : (
<span
className={`${baseClass}__label ${baseClass}__label--${
value ? "active" : "inactive"
}`}
>
{text}
</span>
)}
</div>
</FormField>
);

View file

@ -23,10 +23,19 @@
&__wrapper {
display: flex;
gap: $pad-small;
align-items: center;
height: 24px; // Noticeable with help text below slider
&--disabled {
@include disabled;
.fleet-slider {
@include disabled;
}
.fleet-slider__label {
@include disabled;
pointer-events: initial; // Allow label interaction when slider is disabled to view tooltip
}
}
}
@ -49,7 +58,6 @@
font-size: $x-small;
font-weight: $regular;
text-align: left;
margin-left: $pad-small;
}
&__help-text {

View file

@ -0,0 +1,15 @@
import React from "react";
const Calendar = () => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none">
<rect width="24" height="24" fill="#515774" rx="4" />
<path
fill="#fff"
d="M15 6.038c.31 0 .563.251.563.562v.9h.937A1.5 1.5 0 0 1 18 9v7.5a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 6 16.5V9a1.5 1.5 0 0 1 1.5-1.5h.938v-.9a.563.563 0 0 1 1.124 0v.9h4.876v-.9c0-.31.251-.562.562-.562m-7.5 5.025V16.5h9v-5.437zm0-1.125h9V9h-9z"
/>
</svg>
);
};
export default Calendar;

View file

@ -6,7 +6,13 @@ const FileSh = () => {
const clipPathId = uniqueId("clip-path-");
return (
<svg xmlns="http://www.w3.org/2000/svg" width="34" height="40" fill="none">
<svg
xmlns="http://www.w3.org/2000/svg"
width="34"
height="40"
viewBox="0 0 34 40"
fill="none"
>
<g clipPath={`url(#${clipPathId})`}>
<path
fill="#fff"

View file

@ -0,0 +1,23 @@
import React from "react";
const Lock = () => {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="24" height="24" rx="4" fill="#515774" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.25 11.25V9.75C8.25 8.75544 8.64509 7.80161 9.34835 7.09835C10.0516 6.39509 11.0054 6 12 6C12.9946 6 13.9484 6.39509 14.6517 7.09835C15.3549 7.80161 15.75 8.75544 15.75 9.75V11.25C16.1478 11.25 16.5294 11.408 16.8107 11.6893C17.092 11.9706 17.25 12.3522 17.25 12.75V16.5C17.25 16.8978 17.092 17.2794 16.8107 17.5607C16.5294 17.842 16.1478 18 15.75 18H8.25C7.85218 18 7.47064 17.842 7.18934 17.5607C6.90804 17.2794 6.75 16.8978 6.75 16.5V12.75C6.75 12.3522 6.90804 11.9706 7.18934 11.6893C7.47064 11.408 7.85218 11.25 8.25 11.25ZM14.25 9.75V11.25H9.75V9.75C9.75 9.15326 9.98705 8.58097 10.409 8.15901C10.831 7.73705 11.4033 7.5 12 7.5C12.5967 7.5 13.169 7.73705 13.591 8.15901C14.0129 8.58097 14.25 9.15326 14.25 9.75Z"
fill="white"
/>
</svg>
);
};
export default Lock;

View file

@ -0,0 +1,15 @@
import React from "react";
const Settings = () => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none">
<rect width="24" height="24" fill="#515774" rx="4" />
<path
fill="#f9fafc"
d="M12.463 6a.75.75 0 0 1 .744.657l.13 1.044q.395.124.755.315l.833-.648a.75.75 0 0 1 .99.062l.655.655a.75.75 0 0 1 .062.99l-.649.832q.191.36.315.755l1.045.131a.75.75 0 0 1 .657.744v.926a.75.75 0 0 1-.657.744l-1.045.13a4.5 4.5 0 0 1-.315.755l.649.833a.75.75 0 0 1-.062.99l-.655.655a.75.75 0 0 1-.99.062l-.833-.649a4.5 4.5 0 0 1-.755.315l-.13 1.045a.75.75 0 0 1-.744.657h-.926a.75.75 0 0 1-.744-.657l-.13-1.045a4.5 4.5 0 0 1-.756-.315l-.832.649a.75.75 0 0 1-.991-.062l-.654-.655a.75.75 0 0 1-.062-.99l.648-.834a4.5 4.5 0 0 1-.315-.754l-1.044-.13A.75.75 0 0 1 6 12.463v-.926a.75.75 0 0 1 .657-.744l1.044-.13q.125-.396.315-.755l-.648-.833a.75.75 0 0 1 .062-.99l.654-.655a.75.75 0 0 1 .991-.062l.832.648q.36-.191.755-.315l.131-1.044A.75.75 0 0 1 11.537 6zM12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6"
/>
</svg>
);
};
export default Settings;

View file

@ -25,6 +25,9 @@ import EmptySearchCheck from "./EmptySearchCheck";
import EmptySearchQuestion from "./EmptySearchQuestion";
import CollectingResults from "./CollectingResults";
import DataError from "./DataError";
import Calendar from "./Calendar";
import Lock from "./Lock";
import Settings from "./Settings";
export const GRAPHIC_MAP = {
// Empty state graphics
@ -57,6 +60,9 @@ export const GRAPHIC_MAP = {
// Other graphics
"collecting-results": CollectingResults,
"data-error": DataError,
calendar: Calendar,
lock: Lock,
settings: Settings,
};
export type GraphicNames = keyof typeof GRAPHIC_MAP;

View file

@ -28,6 +28,7 @@ export interface SoftwareInstallerMeta {
isIosOrIpadosApp: boolean;
sha256?: string;
androidPlayStoreId?: string;
patchPolicy?: ISoftwarePackage["patch_policy"]; // Only available on FMA packages
automaticInstallPolicies:
| ISoftwarePackage["automatic_install_policies"]
| IAppStoreApp["automatic_install_policies"];
@ -111,6 +112,11 @@ export const useSoftwareInstaller = (
automatic_install_policies: automaticInstallPolicies,
} = softwareInstaller;
const patchPolicy =
"patch_policy" in softwareInstaller
? softwareInstaller.patch_policy
: undefined;
const {
isGlobalAdmin,
isGlobalMaintainer,
@ -146,6 +152,7 @@ export const useSoftwareInstaller = (
isIosOrIpadosApp,
sha256,
androidPlayStoreId,
patchPolicy,
automaticInstallPolicies,
gitOpsModeEnabled,
repoURL,

View file

@ -46,8 +46,10 @@ export interface IPolicy {
critical: boolean;
calendar_events_enabled: boolean;
conditional_access_enabled: boolean;
type: string;
install_software?: IPolicySoftwareToInstall;
run_script?: Pick<IScript, "id" | "name">;
patch_software?: IPolicySoftwareToInstall;
labels_include_any?: ILabelPolicy[];
labels_exclude_any?: ILabelPolicy[];
}
@ -119,6 +121,10 @@ export interface IPolicyFormData {
script_id?: number | null;
labels_include_any?: string[];
labels_exclude_any?: string[];
/** Required for creating patch policy */
type?: "dynamic" | "patch";
/** Required for creating patch policy */
patch_software_title_id?: number;
}
export interface IPolicyNew {

View file

@ -66,9 +66,30 @@ export interface ISoftwareTitleVersion {
hosts_count?: number;
}
export interface ISoftwarePatchPolicy {
id: number;
name: string;
}
export type SoftwareInstallPolicyType = "dynamic" | "patch";
export type SoftwareInstallPolicyTypeSet = Set<SoftwareInstallPolicyType>;
// A policy type returned from the API is set to:
// 1. dynamic if only auto install, and
// 2.patch if it's both auto install and patch policy
// This doesn't include patch alone, as policies set to patch only are under ISoftwarePackage.patch_policy
export interface ISoftwareInstallPolicy {
id: number;
name: string;
type: SoftwareInstallPolicyType;
}
// A policy type in the UI uses a Set because a policy in
// Software Details > Policy can be both dynamic AND/OR patch
export interface ISoftwareInstallPolicyUI {
id: number;
name: string;
type: SoftwareInstallPolicyTypeSet;
}
// Match allowedCategories in cmd/maintained-apps/main.go
@ -117,6 +138,7 @@ export interface ISoftwarePackage {
self_service: boolean;
icon_url: string | null;
status: ISoftwarePackageStatus;
patch_policy?: ISoftwarePatchPolicy | null;
automatic_install_policies?: ISoftwareInstallPolicy[] | null;
install_during_setup?: boolean;
labels_include_any: ILabelSoftwareTitle[] | null;

View file

@ -16,6 +16,7 @@ import TabNav from "components/TabNav";
import TabText from "components/TabText";
import SidePanelContent from "components/SidePanelContent";
import QuerySidePanel from "components/side_panels/QuerySidePanel";
import PageDescription from "components/PageDescription";
import {
FmaPlatformValue,
@ -121,6 +122,7 @@ const SoftwareAddPage = ({
/>
</div>
<h1>Add software</h1>
<PageDescription content="Add software to your library. You can add it to self-service later." />
<TabNav>
<Tabs
selectedIndex={getTabIndex(location?.pathname || "")}

View file

@ -3,7 +3,3 @@
margin-top: $pad-xxxlarge;
}
}
.info-banner {
margin-bottom: $pad-xlarge;
}

View file

@ -1,27 +1,16 @@
import React, { useContext, useState } from "react";
import { AppContext } from "context/app";
/** FleetAppDetailsForm is a separate component remnant of when we had advanced options on add <4.83 */
import { ILabelSummary } from "interfaces/label";
import React, { useState } from "react";
import { SoftwareCategory } from "interfaces/software";
import {
CUSTOM_TARGET_OPTIONS,
generateHelpText,
} from "pages/SoftwarePage/helpers";
import { getPathWithQueryParams } from "utilities/url";
import paths from "router/paths";
import RevealButton from "components/buttons/RevealButton";
import Button from "components/buttons/Button";
import Card from "components/Card";
import SoftwareOptionsSelector from "pages/SoftwarePage/components/forms/SoftwareOptionsSelector";
import TargetLabelSelector from "components/TargetLabelSelector";
import TooltipWrapper from "components/TooltipWrapper";
import CustomLink from "components/CustomLink";
import AdvancedOptionsFields from "pages/SoftwarePage/components/forms/AdvancedOptionsFields";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import { generateFormValidation } from "./helpers";
import SoftwareDeploySlider from "pages/SoftwarePage/components/forms/SoftwareDeploySelector";
const baseClass = "fleet-app-details-form";
@ -69,41 +58,26 @@ export interface IFormValidation {
}
interface IFleetAppDetailsFormProps {
labels: ILabelSummary[] | null;
categories?: SoftwareCategory[] | null;
name: string;
defaultInstallScript: string;
defaultPostInstallScript: string;
defaultUninstallScript: string;
teamId?: string;
showSchemaButton: boolean;
onClickShowSchema: () => void;
onClickPreviewEndUserExperience: () => void;
onCancel: () => void;
onSubmit: (formData: IFleetMaintainedAppFormData) => void;
softwareTitleId?: number;
}
const FleetAppDetailsForm = ({
labels,
categories,
name: appName,
defaultInstallScript,
defaultPostInstallScript,
defaultUninstallScript,
teamId,
showSchemaButton,
onClickShowSchema,
onClickPreviewEndUserExperience,
onCancel,
onSubmit,
softwareTitleId,
}: IFleetAppDetailsFormProps) => {
const gitOpsModeEnabled = useContext(AppContext).config?.gitops
.gitops_mode_enabled;
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const [formData, setFormData] = useState<IFleetMaintainedAppFormData>({
selfService: false,
automaticInstall: false,
@ -116,95 +90,12 @@ const FleetAppDetailsForm = ({
labelTargets: {},
categories: categories || [],
});
const [formValidation, setFormValidation] = useState<IFormValidation>({
isValid: true,
preInstallQuery: { isValid: false },
});
const onChangePreInstallQuery = (value?: string) => {
const newData = { ...formData, preInstallQuery: value };
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
const onChangeInstallScript = (value: string) => {
const newData = { ...formData, installScript: value };
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
const onChangePostInstallScript = (value?: string) => {
const newData = { ...formData, postInstallScript: value };
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
const onChangeUninstallScript = (value?: string) => {
const newData = { ...formData, uninstallScript: value };
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
const onToggleSelfService = () => {
const newData = { ...formData, selfService: !formData.selfService };
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
const onToggleAutomaticInstall = () => {
const newData = {
...formData,
automaticInstall: !formData.automaticInstall,
};
setFormData(newData);
};
const onSelectTargetType = (value: string) => {
const newData = { ...formData, targetType: value };
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
const onSelectCustomTargetOption = (value: string) => {
const newData = { ...formData, customTarget: value };
setFormData(newData);
};
const onSelectLabel = ({ name, value }: { name: string; value: boolean }) => {
const newData = {
...formData,
labelTargets: { ...formData.labelTargets, [name]: value },
};
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
const onSelectCategory = ({
name,
value,
}: {
name: string;
value: boolean;
}) => {
let newCategories: string[];
if (value) {
// Add the name if not already present
newCategories = formData.categories.includes(name)
? formData.categories
: [...formData.categories, name];
} else {
// Remove the name if present
newCategories = formData.categories.filter((cat) => cat !== name);
}
const newData = {
...formData,
categories: newCategories,
};
setFormData(newData);
setFormValidation(generateFormValidation(newData));
const onToggleDeploySoftware = () => {
setFormData((prevData: IFleetMaintainedAppFormData) => ({
...prevData,
automaticInstall: !prevData.automaticInstall,
}));
};
const onSubmitForm = (evt: React.FormEvent<HTMLFormElement>) => {
@ -212,81 +103,15 @@ const FleetAppDetailsForm = ({
onSubmit(formData);
};
const gitOpsModeDisabledClass = gitOpsModeEnabled
? "form-fields--disabled"
: "";
const isSoftwareAlreadyAdded = !!softwareTitleId;
const isSubmitDisabled = isSoftwareAlreadyAdded; // Allows saving invalid SQL
// Define errors separately so AdvancedOptionsFields can memoize effectively
const errors = {
preInstallQuery: formValidation.preInstallQuery?.message,
};
const isSubmitDisabled = isSoftwareAlreadyAdded;
return (
<form className={`${baseClass}`} onSubmit={onSubmitForm}>
<div className={`${baseClass}__form-frame ${gitOpsModeDisabledClass}`}>
<Card paddingSize="medium" borderRadiusSize="large">
<SoftwareOptionsSelector
formData={formData}
onToggleAutomaticInstall={onToggleAutomaticInstall}
onToggleSelfService={onToggleSelfService}
onSelectCategory={onSelectCategory}
disableOptions={isSoftwareAlreadyAdded}
onClickPreviewEndUserExperience={onClickPreviewEndUserExperience}
/>
</Card>
<Card paddingSize="medium" borderRadiusSize="large">
<TargetLabelSelector
selectedTargetType={formData.targetType}
selectedCustomTarget={formData.customTarget}
selectedLabels={formData.labelTargets}
customTargetOptions={CUSTOM_TARGET_OPTIONS}
className={`${baseClass}__target`}
dropdownHelpText={
formData.targetType === "Custom" &&
generateHelpText(formData.automaticInstall, formData.customTarget)
}
onSelectTargetType={onSelectTargetType}
onSelectCustomTarget={onSelectCustomTargetOption}
onSelectLabel={onSelectLabel}
labels={labels || []}
disableOptions={isSoftwareAlreadyAdded}
/>
</Card>
</div>
<div
className={`${baseClass}__advanced-options-section ${gitOpsModeDisabledClass}`}
>
<RevealButton
className={`${baseClass}__accordion-title`}
isShowing={showAdvancedOptions}
showText="Advanced options"
hideText="Advanced options"
caretPosition="after"
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
disabled={isSoftwareAlreadyAdded}
/>
{showAdvancedOptions && (
<AdvancedOptionsFields
className={`${baseClass}__advanced-options-fields`}
showSchemaButton={showSchemaButton}
installScriptHelpText="Use the $INSTALLER_PATH variable to point to the installer. Currently, shell scripts are supported."
postInstallScriptHelpText="Currently, shell scripts are supported."
uninstallScriptHelpText="Currently, shell scripts are supported."
errors={errors}
preInstallQuery={formData.preInstallQuery}
installScript={formData.installScript}
postInstallScript={formData.postInstallScript}
uninstallScript={formData.uninstallScript}
onClickShowSchema={onClickShowSchema}
onChangePreInstallQuery={onChangePreInstallQuery}
onChangeInstallScript={onChangeInstallScript}
onChangePostInstallScript={onChangePostInstallScript}
onChangeUninstallScript={onChangeUninstallScript}
/>
)}
</div>
<form className={baseClass} onSubmit={onSubmitForm}>
<SoftwareDeploySlider
deploySoftware={formData.automaticInstall}
onToggleDeploySoftware={onToggleDeploySoftware}
/>
<div className={`${baseClass}__action-buttons`}>
<GitOpsModeTooltipWrapper
renderChildren={(disableChildren) => (

View file

@ -62,12 +62,7 @@
gap: $pad-small;
}
.info-banner {
margin-top: $pad-small;
}
.form-fields--disabled {
@include disabled;
}
}

View file

@ -9,27 +9,21 @@ import PATHS from "router/paths";
import { getPathWithQueryParams } from "utilities/url";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import softwareAPI from "services/entities/software";
import labelsAPI, { getCustomLabels } from "services/entities/labels";
import { QueryContext } from "context/query";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import { Platform, PLATFORM_DISPLAY_NAMES } from "interfaces/platform";
import { ILabelSummary } from "interfaces/label";
import useToggleSidePanel from "hooks/useToggleSidePanel";
import SidePanelPage from "components/SidePanelPage";
import BackButton from "components/BackButton";
import MainContent from "components/MainContent";
import Spinner from "components/Spinner";
import DataError from "components/DataError";
import SidePanelContent from "components/SidePanelContent";
import QuerySidePanel from "components/side_panels/QuerySidePanel";
import PremiumFeatureMessage from "components/PremiumFeatureMessage";
import Card from "components/Card";
import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
import Button from "components/buttons/Button";
import Icon from "components/Icon";
import CategoriesEndUserExperienceModal from "pages/SoftwarePage/components/modals/CategoriesEndUserExperienceModal";
import PageDescription from "components/PageDescription";
import FleetAppDetailsForm from "./FleetAppDetailsForm";
import { IFleetMaintainedAppFormData } from "./FleetAppDetailsForm/FleetAppDetailsForm";
@ -143,19 +137,11 @@ const FleetMaintainedAppDetailsPage = ({
const handlePageError = useErrorHandler();
const { isPremiumTier } = useContext(AppContext);
const { selectedOsqueryTable, setSelectedOsqueryTable } = useContext(
QueryContext
);
const { isSidePanelOpen, setSidePanelOpen } = useToggleSidePanel(false);
const [
showAddFleetAppSoftwareModal,
setShowAddFleetAppSoftwareModal,
] = useState(false);
const [showAppDetailsModal, setShowAppDetailsModal] = useState(false);
const [
showPreviewEndUserExperience,
setShowPreviewEndUserExperience,
] = useState(false);
const {
data: fleetApp,
@ -173,36 +159,10 @@ const FleetMaintainedAppDetailsPage = ({
}
);
const {
data: labels,
isLoading: isLoadingLabels,
isError: isErrorLabels,
} = useQuery<ILabelSummary[], Error>(
["custom_labels"],
() =>
labelsAPI
.summary(parseInt(teamId || "0", 10))
.then((res) => getCustomLabels(res.labels)),
{
...DEFAULT_USE_QUERY_OPTIONS,
enabled: isPremiumTier,
staleTime: 10000,
}
);
const onOsqueryTableSelect = (tableName: string) => {
setSelectedOsqueryTable(tableName);
};
const onClickShowAppDetails = () => {
setShowAppDetailsModal(true);
};
const onClickPreviewEndUserExperience = () => {
setShowPreviewEndUserExperience(!showPreviewEndUserExperience);
};
const backToAddSoftwareUrl = getPathWithQueryParams(
PATHS.SOFTWARE_ADD_FLEET_MAINTAINED,
{ fleet_id: teamId }
@ -262,11 +222,11 @@ const FleetMaintainedAppDetailsPage = ({
return <PremiumFeatureMessage />;
}
if (isLoadingFleetApp || isLoadingLabels) {
if (isLoadingFleetApp) {
return <Spinner />;
}
if (isErrorFleetApp || isErrorLabels) {
if (isErrorFleetApp) {
return <DataError verticalPaddingSize="pad-xxxlarge" />;
}
@ -279,6 +239,7 @@ const FleetMaintainedAppDetailsPage = ({
className={`${baseClass}__back-to-add-software`}
/>
<h1>{fleetApp.name}</h1>
<PageDescription content="Add software to your library. You can add it to self-service later." />
<div className={`${baseClass}__page-content`}>
<FleetAppSummary
name={fleetApp.name}
@ -287,26 +248,16 @@ const FleetMaintainedAppDetailsPage = ({
onClickShowAppDetails={onClickShowAppDetails}
/>
<FleetAppDetailsForm
labels={labels || []}
categories={fleetApp.categories}
name={fleetApp.name}
showSchemaButton={!isSidePanelOpen}
defaultInstallScript={fleetApp.install_script}
defaultPostInstallScript={fleetApp.post_install_script}
defaultUninstallScript={fleetApp.uninstall_script}
teamId={teamId}
onClickShowSchema={() => setSidePanelOpen(true)}
onCancel={onCancel}
onSubmit={onSubmit}
softwareTitleId={fleetApp.software_title_id}
onClickPreviewEndUserExperience={onClickPreviewEndUserExperience}
/>
</div>
{showPreviewEndUserExperience && (
<CategoriesEndUserExperienceModal
onCancel={onClickPreviewEndUserExperience}
/>
)}
</>
);
}
@ -320,16 +271,6 @@ const FleetMaintainedAppDetailsPage = ({
<MainContent className={baseClass}>
<>{renderContent()}</>
</MainContent>
{isPremiumTier && fleetApp && isSidePanelOpen && (
<SidePanelContent className={`${baseClass}__side-panel`}>
<QuerySidePanel
key="query-side-panel"
onOsqueryTableSelect={onOsqueryTableSelect}
selectedOsqueryTable={selectedOsqueryTable}
onClose={() => setSidePanelOpen(false)}
/>
</SidePanelContent>
)}
{showAddFleetAppSoftwareModal && <AddFleetAppSoftwareModal />}
{showAppDetailsModal && fleetApp && (
<FleetAppDetailsModal

View file

@ -1,10 +1,8 @@
.fleet-maintained-app-details-page {
&__back-to-add-software {
margin-bottom: $pad-medium;
}
@include vertical-page-layout;
h1 {
margin-bottom: $pad-large;
&__back-to-add-software {
align-self: start;
}
&__page-content {

View file

@ -0,0 +1,45 @@
import React from "react";
import { screen, waitFor } from "@testing-library/react";
import { noop } from "lodash";
import { createCustomRenderer } from "test/test-utils";
import AddPatchPolicyModal from "./AddPatchPolicyModal";
const renderModal = (props: { gitOpsModeEnabled?: boolean } = {}) => {
const customRender = createCustomRenderer({
context: {
app: {
config: {
gitops: {
gitops_mode_enabled: props.gitOpsModeEnabled ?? false,
},
},
},
},
});
return customRender(
<AddPatchPolicyModal
softwareId={1}
teamId={1}
onExit={noop}
onSuccess={noop}
{...props}
/>
);
};
describe("AddPatchPolicyModal", () => {
beforeEach(() => {
jest.resetAllMocks();
});
it("renders add button as disabled when gitOpsModeEnabled is true", async () => {
const { user } = renderModal({ gitOpsModeEnabled: true });
const addButton = screen.getByRole("button", { name: "Add" });
expect(addButton).toBeDisabled();
await user.hover(addButton);
});
});

View file

@ -0,0 +1,89 @@
import React, { useCallback, useContext, useState } from "react";
import teamPoliciesAPI from "services/entities/team_policies";
import { NotificationContext } from "context/notification";
import { getErrorReason } from "interfaces/errors";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
const baseClass = "add-patch-policy-modal";
const EXISTING_PATCH_POLICY_ERROR_MSG = `Couldn't add patch policy. Specified "patch_software_title_id" already has a policy with "type" set to "patch".`;
interface IAddPatchPolicyModal {
softwareId: number;
teamId: number;
onExit: () => void;
onSuccess: () => void;
}
const AddPatchPolicyModal = ({
softwareId,
teamId,
onExit,
onSuccess,
}: IAddPatchPolicyModal) => {
const { renderFlash } = useContext(NotificationContext);
const [isAddingPatchPolicy, setIsAddingPatchPolicy] = useState(false);
const onAddPatchPolicy = useCallback(async () => {
setIsAddingPatchPolicy(true);
try {
await teamPoliciesAPI.create({
type: "patch",
patch_software_title_id: softwareId,
team_id: teamId,
});
renderFlash("success", "Successfully added patch policy.");
onSuccess();
} catch (error) {
const reason = getErrorReason(error);
if (reason.includes("already has a policy")) {
renderFlash("error", EXISTING_PATCH_POLICY_ERROR_MSG);
}
renderFlash("error", "Couldn't add patch policy. Please try again.");
}
setIsAddingPatchPolicy(false);
onExit();
}, [softwareId, teamId, renderFlash, onSuccess, onExit]);
return (
<Modal
className={baseClass}
title="Add patch policy"
onExit={onExit}
isContentDisabled={isAddingPatchPolicy}
>
<>
<p>
Later you can add software automation for this software on{" "}
<strong>
Policies &gt; Manage automations &gt; Install software
</strong>
.
</p>
<div className="modal-cta-wrap">
<GitOpsModeTooltipWrapper
renderChildren={(disableChildren) => (
<Button
onClick={onAddPatchPolicy}
isLoading={isAddingPatchPolicy}
disabled={disableChildren}
>
Add
</Button>
)}
/>
<Button variant="inverse" onClick={onExit}>
Cancel
</Button>
</div>
</>
</Modal>
);
};
export default AddPatchPolicyModal;

View file

@ -0,0 +1,3 @@
.add-patch-policy-modal {
overflow-wrap: anywhere; // Prevent long software name overflow
}

View file

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

View file

@ -11,6 +11,8 @@ import InfoBanner from "components/InfoBanner";
const baseClass = "delete-software-modal";
const DELETE_SW_USED_BY_PATCH_POLICY_ERROR_MSG =
"Couldn't delete. This software has a patch policy. Please remove the patch policy and try again.";
const DELETE_SW_USED_BY_POLICY_ERROR_MSG =
"Couldn't delete. Policy automation uses this software. Please disable policy automation for this software and try again.";
const DELETE_SW_INSTALLED_DURING_SETUP_ERROR_MSG = (
@ -90,7 +92,9 @@ const DeleteSoftwareModal = ({
onSuccess();
} catch (error) {
const reason = getErrorReason(error);
if (reason.includes("Policy automation uses this software")) {
if (reason.includes("This software has a patch policy")) {
renderFlash("error", DELETE_SW_USED_BY_PATCH_POLICY_ERROR_MSG);
} else if (reason.includes("Policy automation uses this software")) {
renderFlash("error", DELETE_SW_USED_BY_POLICY_ERROR_MSG);
} else if (reason.includes("This software is installed during")) {
renderFlash("error", DELETE_SW_INSTALLED_DURING_SETUP_ERROR_MSG);

View file

@ -1,25 +1,57 @@
import React from "react";
import { screen, render } from "@testing-library/react";
import { screen, render, waitFor } from "@testing-library/react";
import { renderWithSetup } from "test/test-utils";
import { ISoftwareInstallPolicyUI } from "interfaces/software";
import InstallerPoliciesTable from "./InstallerPoliciesTable";
describe("InstallerPoliciesTable", () => {
const policies: ISoftwareInstallPolicyUI[] = [
{ id: 1, name: "No Gatekeeper", type: new Set(["dynamic"]) },
{ id: 2, name: "Outdated Gatekeeper", type: new Set(["patch"]) },
];
it("renders policy names as links and footer info", () => {
const policies = [{ id: 1, name: "No Gatekeeper" }];
render(<InstallerPoliciesTable teamId={42} policies={policies} />);
// There should be two cells, each with a link
const cells = screen.getAllByRole("cell");
expect(cells).toHaveLength(1);
expect(cells).toHaveLength(2);
// Each cell should contain a link with the policy name
expect(cells[0].querySelector("a.link-cell")).toHaveTextContent(
/No Gatekeeper/i
);
const POLICY_COUNT = /1 policy/i;
expect(screen.getByText(POLICY_COUNT)).toBeInTheDocument();
expect(cells[1].querySelector("a.link-cell")).toHaveTextContent(
/Outdated Gatekeeper/i
);
expect(screen.getByText(/2 policies/i)).toBeInTheDocument();
});
it("renders the badges for patch and dynamic policies", async () => {
const { user } = renderWithSetup(
<InstallerPoliciesTable teamId={42} policies={policies} />
);
const FOOTER_TEXT = /Software will be installed when hosts fail/i;
expect(screen.getByText(FOOTER_TEXT)).toBeInTheDocument();
await waitFor(() => {
waitFor(() => {
user.hover(screen.getByText(/patch/i));
});
expect(
screen.getByText(
"Hosts will fail this policy if they're running an older version."
)
).toBeInTheDocument();
});
await waitFor(() => {
waitFor(() => {
user.hover(screen.getByTestId("refresh-icon"));
});
expect(
screen.getByText(
"Software will be automatically installed when hosts fail this policy."
)
).toBeInTheDocument();
});
});
});

View file

@ -1,20 +1,19 @@
import React, { useCallback } from "react";
import classnames from "classnames";
import { ISoftwareInstallPolicy } from "interfaces/software";
import { ISoftwareInstallPolicyUI } from "interfaces/software";
import TableContainer from "components/TableContainer";
import TableCount from "components/TableContainer/TableCount";
import CustomLink from "components/CustomLink";
import generateInstallerPoliciesTableConfig from "./InstallerPoliciesTableConfig";
const baseClass = "installer-policies-table";
export const baseClass = "installer-policies-table";
interface IInstallerPoliciesTable {
className?: string;
teamId?: number;
isLoading?: boolean;
policies?: ISoftwareInstallPolicy[] | null;
policies?: ISoftwareInstallPolicyUI[] | null;
}
const InstallerPoliciesTable = ({
className,
@ -32,21 +31,9 @@ const InstallerPoliciesTable = ({
return <TableCount name="policies" count={policies?.length} />;
}, [policies?.length]);
const renderTableHelpText = () => (
<div>
Software will be installed when hosts fail{" "}
{policies?.length === 1 ? "this policy" : "any of these policies"}.{" "}
<CustomLink
url="https://fleetdm.com/learn-more-about/policy-automation-install-software"
text="Learn more"
newTab
/>
</div>
);
return (
<TableContainer
className={baseClass}
className={classNames}
isLoading={isLoading}
columnConfigs={softwareStatusHeaders}
data={policies || []}
@ -56,7 +43,7 @@ const InstallerPoliciesTable = ({
emptyComponent={() => <></>}
showMarkAllPages={false}
isAllPagesSelected={false}
renderTableHelpText={renderTableHelpText}
hideFooter
/>
);
};

View file

@ -1,11 +1,12 @@
import React from "react";
import { ISoftwareInstallPolicy } from "interfaces/software";
import { ISoftwareInstallPolicyUI } from "interfaces/software";
import PATHS from "router/paths";
import { getPathWithQueryParams } from "utilities/url";
import LinkCell from "components/TableContainer/DataTable/LinkCell";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell";
import SoftwareInstallPolicyBadges from "components/SoftwareInstallPolicyBadges";
interface IInstallerPoliciesTableConfig {
teamId?: number;
@ -15,7 +16,7 @@ interface ICellProps {
value: string;
};
row: {
original: ISoftwareInstallPolicy;
original: ISoftwareInstallPolicyUI;
};
column: {
isSortedDesc: boolean;
@ -39,12 +40,19 @@ const generateInstallerPoliciesTableConfig = ({
Cell: (cellProps: ICellProps) => (
<LinkCell
value={cellProps.cell.value}
tooltipTruncate
path={getPathWithQueryParams(
PATHS.EDIT_POLICY(cellProps.row.original.id),
{
fleet_id: teamId,
}
)}
className="w400"
suffix={
<SoftwareInstallPolicyBadges
policyType={cellProps.row.original.type}
/>
}
/>
),
},

View file

@ -17,6 +17,7 @@ import { useSoftwareInstaller } from "hooks/useSoftwareInstallerMeta";
import {
getSelfServiceTooltip,
getAutoUpdatesTooltip,
mergePolicies,
} from "pages/SoftwarePage/helpers";
import Card from "components/Card";
@ -213,6 +214,7 @@ const SoftwareInstallerCard = ({
isIosOrIpadosApp,
sha256,
androidPlayStoreId,
patchPolicy,
automaticInstallPolicies,
gitOpsModeEnabled,
repoURL,
@ -269,6 +271,11 @@ const SoftwareInstallerCard = ({
isGlobalTechnician ||
isTeamTechnician;
const mergedPolicies = mergePolicies({
automaticInstallPolicies,
patchPolicy,
});
return (
<Card borderRadiusSize="xxlarge" className={baseClass}>
<div className={`${baseClass}__installer-header`}>
@ -286,21 +293,6 @@ const SoftwareInstallerCard = ({
androidPlayStoreId={androidPlayStoreId}
/>
<div className={`${baseClass}__tags-wrapper`}>
{Array.isArray(automaticInstallPolicies) &&
automaticInstallPolicies.length > 0 && (
<TooltipWrapper
showArrow
position="top"
tipContent={
automaticInstallPolicies.length === 1
? "A policy triggers install."
: `${automaticInstallPolicies.length} policies trigger install.`
}
underline={false}
>
<Tag icon="refresh" text="Automatic install" />
</TooltipWrapper>
)}
{isSelfService && (
<TooltipWrapper
showArrow
@ -361,12 +353,12 @@ const SoftwareInstallerCard = ({
isLoading={isLoading}
/>
</div>
{automaticInstallPolicies && (
{mergedPolicies.length > 0 && (
<div className={`${baseClass}__installer-policies-table`}>
<InstallerPoliciesTable
teamId={teamId}
isLoading={isLoading}
policies={automaticInstallPolicies}
policies={mergedPolicies}
/>
</div>
)}

View file

@ -21,6 +21,7 @@ import EditIconModal from "../EditIconModal";
import EditSoftwareModal from "../EditSoftwareModal";
import EditConfigurationModal from "../EditConfigurationModal";
import EditAutoUpdateConfigModal from "../EditAutoUpdateConfigModal";
import AddPatchPolicyModal from "../AddPatchPolicyModal";
interface ISoftwareSummaryCard {
softwareTitle: ISoftwareTitleDetails;
@ -50,6 +51,7 @@ const SoftwareSummaryCard = ({
const [iconUploadedAt, setIconUploadedAt] = useState("");
const [showEditIconModal, setShowEditIconModal] = useState(false);
const [showEditSoftwareModal, setShowEditSoftwareModal] = useState(false);
const [showAddPatchPolicyModal, setShowAddPatchPolicyModal] = useState(false);
const [showEditConfigurationModal, setShowEditConfigurationModal] = useState(
false
);
@ -121,6 +123,7 @@ const SoftwareSummaryCard = ({
/** Permission to manage software + Google Playstore app that's not a web app */
const canEditConfiguration =
canManageSoftware && isAndroidPlayStoreApp && !isAndroidPlayStoreWebApp;
const canPatchSoftware = canManageSoftware && isFleetMaintainedApp;
/** Installer modals require a specific team; hidden from "All Teams" */
const hasValidTeamId = typeof teamId === "number" && teamId >= 0;
const softwareInstallerOnTeam = hasValidTeamId && softwareInstaller;
@ -130,6 +133,7 @@ const SoftwareSummaryCard = ({
const onClickEditAppearance = () => setShowEditIconModal(true);
const onClickEditSoftware = () => setShowEditSoftwareModal(true);
const onClickAddPatchPolicy = () => setShowAddPatchPolicyModal(true);
const onClickEditConfiguration = () => setShowEditConfigurationModal(true);
const onClickEditAutoUpdateConfig = () =>
setShowEditAutoUpdateConfigModal(true);
@ -158,12 +162,16 @@ const SoftwareSummaryCard = ({
onClickEditSoftware={
canEditSoftware ? onClickEditSoftware : undefined
}
onClickAddPatchPolicy={
canPatchSoftware ? onClickAddPatchPolicy : undefined
}
onClickEditConfiguration={
canEditConfiguration ? onClickEditConfiguration : undefined
}
onClickEditAutoUpdateConfig={
canEditAutoUpdateConfig ? onClickEditAutoUpdateConfig : undefined
}
patchPolicyId={softwareTitle.software_package?.patch_policy?.id}
/>
{showVersionsTable && (
<TitleVersionsTable
@ -217,6 +225,14 @@ const SoftwareSummaryCard = ({
iconUrl={softwareTitle.icon_url}
/>
)}
{showAddPatchPolicyModal && softwareInstallerOnTeam && (
<AddPatchPolicyModal
softwareId={softwareTitle.id}
teamId={teamId}
onSuccess={refetchSoftwareTitle}
onExit={() => setShowAddPatchPolicyModal(false)}
/>
)}
{showEditConfigurationModal && softwareInstallerOnTeam && (
<EditConfigurationModal
softwareInstaller={softwareInstaller as IAppStoreApp}

View file

@ -0,0 +1,189 @@
import {
buildActionOptions,
ACTION_EDIT_APPEARANCE,
ACTION_EDIT_SOFTWARE,
ACTION_EDIT_CONFIGURATION,
ACTION_PATCH,
ACTION_EDIT_AUTO_UPDATE_CONFIGURATION,
} from "./SoftwareDetailsSummary";
describe("buildActionOptions", () => {
it("returns only Edit appearance when user cannot edit software or configuration and cannot patch or configure auto updates", () => {
const result = buildActionOptions({
gitOpsModeEnabled: false,
repoURL: undefined,
source: undefined,
canEditSoftware: false,
canEditConfiguration: false,
canAddPatchPolicy: false,
canConfigureAutoUpdate: false,
hasExistingPatchPolicy: false,
});
expect(result).toEqual([
{
label: "Edit appearance",
value: ACTION_EDIT_APPEARANCE,
isDisabled: false,
tooltipContent: undefined,
},
]);
});
it("adds Edit software when canEditSoftware", () => {
const result = buildActionOptions({
gitOpsModeEnabled: false,
repoURL: undefined,
source: undefined,
canEditSoftware: true,
canEditConfiguration: false,
canAddPatchPolicy: false,
canConfigureAutoUpdate: false,
hasExistingPatchPolicy: false,
});
const values = result.map((o) => o.value);
expect(values).toContain(ACTION_EDIT_SOFTWARE);
const editSoftware = result.find(
(opt) => opt.value === ACTION_EDIT_SOFTWARE
);
expect(editSoftware).toEqual({
label: "Edit software",
value: ACTION_EDIT_SOFTWARE,
isDisabled: false,
tooltipContent: undefined,
});
});
it("adds Edit configuration when canEditConfiguration", () => {
const result = buildActionOptions({
gitOpsModeEnabled: false,
repoURL: undefined,
source: undefined,
canEditSoftware: false,
canEditConfiguration: true,
canAddPatchPolicy: false,
canConfigureAutoUpdate: false,
hasExistingPatchPolicy: false,
});
const values = result.map((o) => o.value);
expect(values).toContain(ACTION_EDIT_CONFIGURATION);
const editConfig = result.find(
(opt) => opt.value === ACTION_EDIT_CONFIGURATION
);
expect(editConfig).toEqual({
label: "Edit configuration",
value: ACTION_EDIT_CONFIGURATION,
isDisabled: false,
tooltipContent: undefined,
});
});
it("applies gitops tooltip to Edit appearance and Edit configuration, and to Edit software for vpp_apps", () => {
const result = buildActionOptions({
gitOpsModeEnabled: true,
repoURL: "https://repo.git",
source: "vpp_apps",
canEditSoftware: true,
canEditConfiguration: true,
canAddPatchPolicy: false,
canConfigureAutoUpdate: false,
hasExistingPatchPolicy: false,
});
const editAppearance = result.find(
(opt) => opt.value === ACTION_EDIT_APPEARANCE
);
const editConfig = result.find(
(opt) => opt.value === ACTION_EDIT_CONFIGURATION
);
const editSoftware = result.find(
(opt) => opt.value === ACTION_EDIT_SOFTWARE
);
expect(editAppearance).toMatchObject({
isDisabled: true,
tooltipContent: expect.anything(),
});
expect(editConfig).toMatchObject({
isDisabled: true,
tooltipContent: expect.anything(),
});
// For vpp_apps, Edit software also gets the gitops tooltip if present.
expect(editSoftware).toMatchObject({
isDisabled: true,
tooltipContent: expect.anything(),
});
});
it("adds Patch option enabled when canAddPatchPolicy and no existing patch policy", () => {
const result = buildActionOptions({
gitOpsModeEnabled: false,
repoURL: undefined,
source: undefined,
canEditSoftware: false,
canEditConfiguration: false,
canAddPatchPolicy: true,
canConfigureAutoUpdate: false,
hasExistingPatchPolicy: false,
});
const patch = result.find((opt) => opt.value === ACTION_PATCH);
expect(patch).toEqual({
label: "Patch",
value: ACTION_PATCH,
isDisabled: false,
tooltipContent: undefined,
});
});
it("adds Patch option disabled with tooltip when hasExistingPatchPolicy", () => {
const result = buildActionOptions({
gitOpsModeEnabled: false,
repoURL: undefined,
source: undefined,
canEditSoftware: false,
canEditConfiguration: false,
canAddPatchPolicy: true,
canConfigureAutoUpdate: false,
hasExistingPatchPolicy: true,
});
const patch = result.find((opt) => opt.value === ACTION_PATCH);
expect(patch).toEqual({
label: "Patch",
value: ACTION_PATCH,
isDisabled: true,
tooltipContent: "Patch policy is already added.",
});
});
it("adds Schedule auto updates option when canConfigureAutoUpdate", () => {
const result = buildActionOptions({
gitOpsModeEnabled: false,
repoURL: undefined,
source: undefined,
canEditSoftware: false,
canEditConfiguration: false,
canAddPatchPolicy: false,
canConfigureAutoUpdate: true,
hasExistingPatchPolicy: false,
});
const autoUpdate = result.find(
(opt) => opt.value === ACTION_EDIT_AUTO_UPDATE_CONFIGURATION
);
expect(autoUpdate).toEqual({
label: "Schedule auto updates",
value: ACTION_EDIT_AUTO_UPDATE_CONFIGURATION,
});
});
});

View file

@ -33,21 +33,37 @@ import TooltipWrapperArchLinuxRolling from "components/TooltipWrapperArchLinuxRo
import SoftwareIcon from "../../icons/SoftwareIcon";
import OSIcon from "../../icons/OSIcon";
const ACTION_EDIT_APPEARANCE = "edit_appearance";
const ACTION_EDIT_SOFTWARE = "edit_software";
const ACTION_EDIT_CONFIGURATION = "edit_configuration";
const ACTION_EDIT_AUTO_UPDATE_CONFIGURATION = "edit_auto_update_configuration";
export const ACTION_EDIT_APPEARANCE = "edit_appearance";
export const ACTION_EDIT_SOFTWARE = "edit_software";
export const ACTION_EDIT_CONFIGURATION = "edit_configuration";
export const ACTION_PATCH = "patch";
export const ACTION_EDIT_AUTO_UPDATE_CONFIGURATION =
"edit_auto_update_configuration";
const buildActionOptions = (
gitOpsModeEnabled: boolean | undefined,
repoURL: string | undefined,
source: string | undefined,
canEditSoftware: boolean,
canEditConfiguration: boolean,
canConfigureAutoUpdate: boolean
): CustomOptionType[] => {
export interface BuildActionOptionsArgs {
gitOpsModeEnabled?: boolean;
repoURL?: string;
source?: string;
canEditSoftware: boolean;
canEditConfiguration: boolean;
canAddPatchPolicy: boolean;
canConfigureAutoUpdate: boolean;
hasExistingPatchPolicy?: boolean;
}
export const buildActionOptions = ({
gitOpsModeEnabled,
repoURL,
source,
canEditSoftware,
canEditConfiguration,
canAddPatchPolicy,
canConfigureAutoUpdate,
hasExistingPatchPolicy = false,
}: BuildActionOptionsArgs): CustomOptionType[] => {
let disableEditAppearanceTooltipContent: TooltipContent | undefined;
let disableEditSoftwareTooltipContent: TooltipContent | undefined;
let disabledPatchPolicyTooltipContent: TooltipContent | undefined;
let disabledEditConfigurationTooltipContent: TooltipContent | undefined;
if (gitOpsModeEnabled) {
@ -62,6 +78,10 @@ const buildActionOptions = (
}
}
if (hasExistingPatchPolicy) {
disabledPatchPolicyTooltipContent = "Patch policy is already added.";
}
const options: CustomOptionType[] = [
{
label: "Edit appearance",
@ -91,6 +111,16 @@ const buildActionOptions = (
});
}
// Show patch option only for fleet maintained apps
if (canAddPatchPolicy) {
options.push({
label: "Patch",
value: ACTION_PATCH,
isDisabled: !!disabledPatchPolicyTooltipContent,
tooltipContent: disabledPatchPolicyTooltipContent,
});
}
if (canConfigureAutoUpdate) {
options.push({
label: "Schedule auto updates",
@ -128,6 +158,8 @@ interface ISoftwareDetailsSummaryProps {
/** Displays an edit CTA to edit the software installer
* Should only be defined for team view of an installable software */
onClickEditSoftware?: () => void;
/** Displays Patch CTA to add a patch policy */
onClickAddPatchPolicy?: () => void;
/** undefined unless previewing icon, in which case is string or null */
/** Displays an edit CTA to edit the software's icon
* Should only be defined for team view of an installable software */
@ -136,6 +168,7 @@ interface ISoftwareDetailsSummaryProps {
iconPreviewUrl?: string | null;
/** timestamp of when icon was last uploaded, used to force refresh of cached icon */
iconUploadedAt?: string;
patchPolicyId?: number;
}
const SoftwareDetailsSummary = ({
@ -152,10 +185,12 @@ const SoftwareDetailsSummary = ({
canManageSoftware = false,
onClickEditAppearance,
onClickEditSoftware,
onClickAddPatchPolicy,
onClickEditConfiguration,
onClickEditAutoUpdateConfig,
iconPreviewUrl,
iconUploadedAt,
patchPolicyId,
}: ISoftwareDetailsSummaryProps) => {
const hostCountPath = getPathWithQueryParams(paths.MANAGE_HOSTS, queryParams);
@ -173,6 +208,9 @@ const SoftwareDetailsSummary = ({
case ACTION_EDIT_SOFTWARE:
onClickEditSoftware && onClickEditSoftware();
break;
case ACTION_PATCH:
onClickAddPatchPolicy && onClickAddPatchPolicy();
break;
case ACTION_EDIT_CONFIGURATION:
onClickEditConfiguration && onClickEditConfiguration();
break;
@ -213,14 +251,16 @@ const SoftwareDetailsSummary = ({
);
};
const actionOptions = buildActionOptions(
const actionOptions = buildActionOptions({
gitOpsModeEnabled,
repoURL,
source,
!!onClickEditSoftware,
!!onClickEditConfiguration,
!!onClickEditAutoUpdateConfig
);
canEditSoftware: !!onClickEditSoftware,
canEditConfiguration: !!onClickEditConfiguration,
canAddPatchPolicy: !!onClickAddPatchPolicy,
canConfigureAutoUpdate: !!onClickEditAutoUpdateConfig,
hasExistingPatchPolicy: !!patchPolicyId,
});
return (
<>

View file

@ -4,6 +4,7 @@ import classnames from "classnames";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
import {
getExtensionFromFileName,
getFileDetails,
@ -15,7 +16,6 @@ import { ILabelSummary } from "interfaces/label";
import { ISoftwareVersion, SoftwareCategory } from "interfaces/software";
import { CustomOptionType } from "components/forms/fields/DropdownWrapper/DropdownWrapper";
import Button from "components/buttons/Button";
import TooltipWrapper from "components/TooltipWrapper";
import FileUploader from "components/FileUploader";
@ -27,8 +27,9 @@ import {
getTargetType,
} from "pages/SoftwarePage/helpers";
import TargetLabelSelector from "components/TargetLabelSelector";
import Card from "components/Card";
import SoftwareOptionsSelector from "pages/SoftwarePage/components/forms/SoftwareOptionsSelector";
import InfoBanner from "components/InfoBanner";
import CustomLink from "components/CustomLink";
import PackageAdvancedOptions from "../PackageAdvancedOptions";
import {
@ -37,6 +38,7 @@ import {
sortByVersionLatestFirst,
} from "./helpers";
import PackageVersionSelector from "../PackageVersionSelector";
import SoftwareDeploySlider from "../SoftwareDeploySelector";
export const baseClass = "package-form";
@ -73,6 +75,24 @@ const getGraphicName = (ext: string) => {
return "file-pkg";
};
const renderSoftwareDeployWarningBanner = () => (
<InfoBanner
color="yellow"
className={`${baseClass}__deploy-warning`}
cta={
<CustomLink
url={`${LEARN_MORE_ABOUT_BASE_LINK}/query-templates-for-automatic-install-software`}
text="Learn more"
newTab
/>
}
>
Installing software over existing installations might cause issues.
Fleet&apos;s policy may not detect these existing installations. Please
create a test fleet in Fleet to verify a smooth installation.
</InfoBanner>
);
const renderFileTypeMessage = () => {
return (
<>
@ -366,21 +386,38 @@ const PackageForm = ({
const showAdvancedOptions =
formData.software && !isScriptPackage && !isIpaPackage;
const showDeploySoftwareSlider =
!!formData.software && // show after selection
!gitOpsModeEnabled && // hide in gitOps mode
!isEditingSoftware && // show only on add, not edit
// automatic install is not supported for ipa packages, exe, tarball, or script packages
!isIpaPackage &&
!isExePackage &&
!isTarballPackage &&
!isScriptPackage;
// 4.83+ Show deploy slider on add if the package type supports it.
// Hide from gitOps mode
const renderSoftwareDeploySlider = () => (
<>
<SoftwareDeploySlider
deploySoftware={formData.automaticInstall}
onToggleDeploySoftware={onToggleAutomaticInstall}
/>
{formData.automaticInstall && renderSoftwareDeployWarningBanner()}
</>
);
// GitOps mode hides SoftwareOptionsSelector and TargetLabelSelector
const showOptionsTargetsSelectors = !gitOpsModeEnabled;
// 4.83 Removed option/targets from Add page
const showOptionsTargetsSelectors = !gitOpsModeEnabled && isEditingSoftware;
const renderSoftwareOptionsSelector = () => (
<SoftwareOptionsSelector
formData={formData}
onToggleAutomaticInstall={onToggleAutomaticInstall}
onToggleSelfService={onToggleSelfService}
onSelectCategory={onSelectCategory}
isCustomPackage
isEditingSoftware={isEditingSoftware}
isExePackage={isExePackage}
isTarballPackage={isTarballPackage}
isScriptPackage={isScriptPackage}
isIpaPackage={isIpaPackage}
onClickPreviewEndUserExperience={() =>
onClickPreviewEndUserExperience(isIpaPackage)
}
@ -461,28 +498,11 @@ const PackageForm = ({
: "form"
}
>
{showDeploySoftwareSlider && renderSoftwareDeploySlider()}
{showOptionsTargetsSelectors && (
<div className={`${baseClass}__form-frame`}>
{isEditingSoftware ? (
renderSoftwareOptionsSelector()
) : (
<Card
paddingSize="medium"
borderRadiusSize={isEditingSoftware ? "medium" : "large"}
>
{renderSoftwareOptionsSelector()}
</Card>
)}
{isEditingSoftware ? (
renderTargetLabelSelector()
) : (
<Card
paddingSize="medium"
borderRadiusSize={isEditingSoftware ? "medium" : "large"}
>
{renderTargetLabelSelector()}
</Card>
)}
{renderSoftwareOptionsSelector()}
{renderTargetLabelSelector()}
</div>
)}
</div>

View file

@ -1,6 +1,12 @@
.package-form {
container-type: inline-size;
// TODO: Refactor InfoBanner to not have default margin so we don't have to override it
// Flex gaps should be handling spacing around all InfoBanner
.info-banner {
margin: 0;
}
&__form-frame {
display: flex;
align-items: flex-start;
@ -59,10 +65,6 @@
box-sizing: border-box;
}
.info-banner {
margin-top: $pad-small;
}
&__form-fields {
&--gitops-disabled {
@include disabled;

View file

@ -9,10 +9,8 @@ import { IInputFieldParseTarget } from "interfaces/form_field";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
import Card from "components/Card";
import CustomLink from "components/CustomLink";
import Button from "components/buttons/Button";
import SoftwareOptionsSelector from "pages/SoftwarePage/components/forms/SoftwareOptionsSelector";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import {
@ -22,6 +20,7 @@ import {
} from "pages/SoftwarePage/helpers";
import generateFormValidation from "./helpers";
import { AndroidOptionsDescription } from "../SoftwareOptionsSelector/SoftwareOptionsSelector";
const baseClass = "software-android-form";
@ -97,47 +96,6 @@ const SoftwareAndroidForm = ({
setFormValidation(generateFormValidation(newFormData));
};
const onToggleSelfService = () => {
const newData = { ...formData, selfService: !formData.selfService };
setFormData(newData);
};
const onSelectCategory = ({
name,
value,
}: {
name: string;
value: boolean;
}) => {
let newCategories: string[];
if (value) {
// Add the name if not already present
newCategories = formData.categories.includes(name)
? formData.categories
: [...formData.categories, name];
} else {
// Remove the name if present
newCategories = formData.categories.filter((cat) => cat !== name);
}
const newData = {
...formData,
categories: newCategories,
};
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
const onToggleAutomaticInstall = () => {
const newData = {
...formData,
automaticInstall: !formData.automaticInstall,
};
setFormData(newData);
};
const isSubmitDisabled = !formValidation.isValid;
const renderContent = () => {
@ -148,43 +106,35 @@ const SoftwareAndroidForm = ({
// Add Android form
return (
<div className={`${baseClass}__form-fields`}>
<InputField
autoFocus
label="Application ID"
placeholder="com.android.chrome"
helpText={
<>
The ID at the end of the app&apos;s{" "}
<CustomLink
text="Google Play URL"
url={`${LEARN_MORE_ABOUT_BASE_LINK}/google-play-store`}
newTab
/>{" "}
E.g. &quot;com.android.chrome&quot; from
&quot;https://play.google.com/store/apps/details?id=com.android.chrome&quot;
</>
}
onChange={onInputChange}
name="applicationID"
value={formData.applicationID}
parseTarget
disabled={gitOpsModeEnabled} // TODO: Confirm GitOps behavior
/>
<div className={`${baseClass}__form-frame`}>
<Card paddingSize="medium" borderRadiusSize="large">
<SoftwareOptionsSelector
platform="android"
formData={formData}
onToggleAutomaticInstall={onToggleAutomaticInstall}
onToggleSelfService={onToggleSelfService}
onSelectCategory={onSelectCategory}
onClickPreviewEndUserExperience={onClickPreviewEndUserExperience}
disableOptions
/>
</Card>
<>
<div className={`${baseClass}__form-fields`}>
<InputField
autoFocus
label="Application ID"
placeholder="com.android.chrome"
helpText={
<>
The ID at the end of the app&apos;s{" "}
<CustomLink
text="Google Play URL"
url={`${LEARN_MORE_ABOUT_BASE_LINK}/google-play-store`}
newTab
/>{" "}
E.g. &quot;com.android.chrome&quot; from
&quot;https://play.google.com/store/apps/details?id=com.android.chrome&quot;
</>
}
onChange={onInputChange}
name="applicationID"
value={formData.applicationID}
parseTarget
disabled={gitOpsModeEnabled} // TODO: Confirm GitOps behavior
/>
</div>
</div>
<div>
<AndroidOptionsDescription />
</div>
</>
);
};

View file

@ -0,0 +1,35 @@
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import SoftwareDeploySlider from "./SoftwareDeploySlider";
const defaultProps = {
deploySoftware: false,
onToggleDeploySoftware: jest.fn(),
};
const getSwitchByLabelText = (text: string) => {
const label = screen.getByText(text);
const wrapper = label.closest(".fleet-slider__wrapper");
if (!wrapper) throw new Error(`Wrapper not found for "${text}"`);
const btn = wrapper.querySelector('button[role="switch"]');
if (!btn) throw new Error(`Switch button not found for "${text}"`);
return btn as HTMLButtonElement;
};
describe("SoftwareOptionsSelector", () => {
it("calls onToggleDeploySoftware when the deploy software slider is toggled", () => {
const onToggleDeploySoftware = jest.fn();
render(
<SoftwareDeploySlider
{...defaultProps}
onToggleDeploySoftware={onToggleDeploySoftware}
/>
);
const deploySoftwareSwitch = getSwitchByLabelText("Deploy");
fireEvent.click(deploySoftwareSwitch);
expect(onToggleDeploySoftware).toHaveBeenCalledTimes(1);
expect(onToggleDeploySoftware).toHaveBeenCalledWith();
});
});

View file

@ -0,0 +1,37 @@
import React from "react";
import classnames from "classnames";
import Slider from "components/forms/fields/Slider";
const baseClass = "software-deploy-slider";
const LABEL_TOOLTIP =
"Automatically install only on hosts missing this software.";
interface ISoftwareDeploySliderProps {
deploySoftware: boolean;
onToggleDeploySoftware: () => void;
className?: string;
}
const SoftwareDeploySlider = ({
deploySoftware,
onToggleDeploySoftware,
className,
}: ISoftwareDeploySliderProps) => {
const sliderClassNames = classnames(`${baseClass}__container`, className);
return (
<div className={sliderClassNames}>
<Slider
value={deploySoftware}
onChange={onToggleDeploySoftware}
activeText="Deploy"
inactiveText="Deploy"
className={`${baseClass}__deploy-slider`}
labelTooltip={LABEL_TOOLTIP}
/>
</div>
);
};
export default SoftwareDeploySlider;

View file

@ -0,0 +1,2 @@
.software-deploy-slider {
}

View file

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

View file

@ -48,111 +48,25 @@ describe("SoftwareOptionsSelector", () => {
expect(onToggleSelfService).toHaveBeenCalledWith();
});
it("calls onToggleAutomaticInstall when the automatic install slider is toggled", () => {
const onToggleAutomaticInstall = jest.fn();
renderComponent({ onToggleAutomaticInstall });
const automaticInstallSwitch = getSwitchByLabelText("Automatic install");
fireEvent.click(automaticInstallSwitch);
expect(onToggleAutomaticInstall).toHaveBeenCalledTimes(1);
expect(onToggleAutomaticInstall).toHaveBeenCalledWith();
});
it("enables self-service and disables automatic install sliders for iOS", () => {
it("enables self-service sliders for iOS", () => {
renderComponent({ platform: "ios" });
const selfServiceSwitch = getSwitchByLabelText("Self-service");
const automaticInstallSwitch = getSwitchByLabelText("Automatic install");
expect(selfServiceSwitch.disabled).toBe(false);
expect(automaticInstallSwitch.disabled).toBe(true);
});
it("enables self-service and disables automatic install sliders for iPadOS", () => {
it("enables self-service for iPadOS", () => {
renderComponent({ platform: "ipados" });
const selfServiceSwitch = getSwitchByLabelText("Self-service");
const automaticInstallSwitch = getSwitchByLabelText("Automatic install");
expect(selfServiceSwitch.disabled).toBe(false);
expect(automaticInstallSwitch.disabled).toBe(true);
});
it("disables sliders when disableOptions is true", () => {
it("disables self-service when disableOptions is true", () => {
renderComponent({ disableOptions: true });
const selfServiceSwitch = getSwitchByLabelText("Self-service");
const automaticInstallSwitch = getSwitchByLabelText("Automatic install");
expect(selfServiceSwitch.disabled).toBe(true);
expect(automaticInstallSwitch.disabled).toBe(true);
});
it("renders the InfoBanner when automaticInstall is true and isCustomPackage is true", () => {
renderComponent({
formData: { ...defaultProps.formData, automaticInstall: true },
isCustomPackage: true,
});
expect(
screen.getByText(
/Installing software over existing installations might cause issues/i
)
).toBeInTheDocument();
});
it("does not render the InfoBanner when automaticInstall is false", () => {
renderComponent({
formData: { ...defaultProps.formData, automaticInstall: false },
isCustomPackage: true,
});
expect(
screen.queryByText(
/Installing software over existing installations might cause issues/i
)
).not.toBeInTheDocument();
});
it("does not render the InfoBanner when isCustomPackage is false", () => {
renderComponent({
formData: { ...defaultProps.formData, automaticInstall: true },
isCustomPackage: false,
});
expect(
screen.queryByText(
/Installing software over existing installations might cause issues/i
)
).not.toBeInTheDocument();
});
it("does not render automatic install slider when isEditingSoftware is true", () => {
renderComponent({ isEditingSoftware: true });
expect(screen.queryByText("Automatic install")).not.toBeInTheDocument();
});
it("displays platform-specific message for iOS", () => {
renderComponent({ platform: "ios" });
expect(
screen.getByText(/Automatic install for iOS and iPadOS is coming soon./i)
).toBeInTheDocument();
});
it("displays platform-specific message for iPadOS", () => {
renderComponent({ platform: "ipados" });
expect(
screen.getByText(/Automatic install for iOS and iPadOS is coming soon./i)
).toBeInTheDocument();
});
it("does not render automatic install slider in edit mode", () => {
renderComponent({ isEditingSoftware: true });
expect(screen.queryByText("Automatic install")).not.toBeInTheDocument();
});
});

View file

@ -3,9 +3,7 @@ import classnames from "classnames";
import Checkbox from "components/forms/fields/Checkbox";
import Slider from "components/forms/fields/Slider";
import InfoBanner from "components/InfoBanner";
import CustomLink from "components/CustomLink";
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
import paths from "router/paths";
import { getSelfServiceTooltip } from "pages/SoftwarePage/helpers";
@ -19,7 +17,6 @@ import {
} from "pages/hosts/details/cards/Software/SelfService/helpers";
import Button from "components/buttons/Button";
import { isAndroid, isIPadOrIPhone } from "interfaces/platform";
import TooltipWrapper from "components/TooltipWrapper";
const baseClass = "software-options-selector";
@ -29,6 +26,19 @@ interface ICategoriesSelector {
onClickPreviewEndUserExperience: () => void;
}
export const AndroidOptionsDescription = () => (
<p>
Currently, Android apps can only be added as self-service and the end user
can install them from the <strong>Play Store</strong> in their work profile.
Additionally, you can install it when hosts enroll on the{" "}
<CustomLink
url={paths.CONTROLS_INSTALL_SOFTWARE("android")}
text="Setup experience"
/>{" "}
page.
</p>
);
const CategoriesSelector = ({
onSelectCategory,
selectedCategories,
@ -71,39 +81,24 @@ interface ISoftwareOptionsSelector {
| ISoftwareVppFormData
| IPackageFormData
| ISoftwareAndroidFormData;
/** Only used in create mode not edit mode for FMA, VPP, and custom packages */
onToggleAutomaticInstall: () => void;
onToggleSelfService: () => void;
onClickPreviewEndUserExperience: () => void;
onSelectCategory: ({ name, value }: { name: string; value: boolean }) => void;
platform?: string;
className?: string;
isCustomPackage?: boolean;
/** Exe packages do not have ability to select automatic install */
isExePackage?: boolean;
/** Tarball packages do not have ability to select automatic install */
isTarballPackage?: boolean;
/** Script only packages do not have ability to select automatic install */
isScriptPackage?: boolean;
/** IPA packages do not have ability to select automatic install or self-service */
/** IPA packages do not have ability to select self-service */
isIpaPackage?: boolean;
/** Edit mode does not have ability to change automatic install */
isEditingSoftware?: boolean;
disableOptions?: boolean;
}
const SoftwareOptionsSelector = ({
formData,
onToggleAutomaticInstall,
onToggleSelfService,
onClickPreviewEndUserExperience,
onSelectCategory,
platform,
className,
isCustomPackage,
isExePackage,
isTarballPackage,
isScriptPackage,
isIpaPackage,
isEditingSoftware,
disableOptions = false,
@ -114,99 +109,16 @@ const SoftwareOptionsSelector = ({
isIPadOrIPhone(platform || "") || isIpaPackage || false;
const isPlatformAndroid = isAndroid(platform || "");
const isSelfServiceDisabled = disableOptions;
const isAutomaticInstallDisabled =
disableOptions ||
isPlatformIosOrIpados ||
isExePackage ||
isTarballPackage ||
isScriptPackage;
/** Tooltip only shows when enabled or for exe/tar.gz/sh/ps1 packages */
const showAutomaticInstallTooltip =
!isAutomaticInstallDisabled ||
isExePackage ||
isTarballPackage ||
isScriptPackage;
const getAutomaticInstallTooltip = (): JSX.Element => {
if (isExePackage || isTarballPackage) {
return (
<>
Fleet can&apos;t create a policy to detect existing installations for{" "}
{isExePackage ? ".exe packages" : ".tar.gz archives"}. To
automatically install{" "}
{isExePackage ? ".exe packages" : ".tar.gz archives"}, add a custom
policy and enable the install software automation on the{" "}
<b>Policies</b> page.
</>
);
}
if (isScriptPackage) {
return (
<>
Fleet can&apos;t create a policy to detect existing installations of
script-only packages. To automatically install these packages, add a
custom policy and enable the install software automation on the{" "}
<b>Policies</b> page.
</>
);
}
return <>Automatically install only on hosts missing this software.</>;
};
// Ability to set categories when adding software is in a future ticket #28061
const canSelectSoftwareCategories = formData.selfService && isEditingSoftware;
const renderOptionsDescription = () => {
if (isPlatformAndroid) {
return (
<p>
Currently, Android apps can only be added as self-service and the end
user can install them from the <strong>Play Store</strong> in their
work profile. Additionally, you can install it when hosts enroll on
the{" "}
<CustomLink
url={paths.CONTROLS_INSTALL_SOFTWARE("android")}
text="Setup experience"
/>{" "}
page.
</p>
);
}
// Render unavailable description for iOS or iPadOS add software form only
return isPlatformIosOrIpados && !isEditingSoftware ? (
<p>
Automatic install for iOS and iPadOS is coming soon. Today, you can
manually install it from the <strong>Host details</strong> page for each
host.
</p>
) : null;
};
const renderOptionsDescription = () =>
isPlatformAndroid ? <AndroidOptionsDescription /> : null;
const selfServiceLabel = () => {
return !isSelfServiceDisabled ? (
<TooltipWrapper
tipContent={getSelfServiceTooltip(
isPlatformIosOrIpados,
isPlatformAndroid
)}
>
<span>Self-service</span>
</TooltipWrapper>
) : (
"Self-service"
);
};
const automaticInstallLabel = () => {
return showAutomaticInstallTooltip ? (
<TooltipWrapper tipContent={getAutomaticInstallTooltip()}>
<span>Automatic install</span>
</TooltipWrapper>
) : (
"Automatic install"
);
};
const selfServiceLabelTooltip = !isSelfServiceDisabled
? getSelfServiceTooltip(isPlatformIosOrIpados, isPlatformAndroid)
: undefined;
return (
<div className={`form-field ${classNames}`}>
@ -216,8 +128,9 @@ const SoftwareOptionsSelector = ({
<Slider
value={formData.selfService}
onChange={onToggleSelfService}
inactiveText={selfServiceLabel()}
activeText={selfServiceLabel()}
inactiveText="Self-service"
activeText="Self-service"
labelTooltip={selfServiceLabelTooltip}
className={`${baseClass}__self-service-slider`}
disabled={isSelfServiceDisabled}
/>
@ -230,29 +143,6 @@ const SoftwareOptionsSelector = ({
/>
)}
</div>
{!isEditingSoftware && (
<Slider
value={formData.automaticInstall}
onChange={onToggleAutomaticInstall}
activeText={automaticInstallLabel()}
inactiveText={automaticInstallLabel()}
className={`${baseClass}__automatic-install-slider`}
disabled={isAutomaticInstallDisabled}
/>
)}
{formData.automaticInstall && isCustomPackage && (
<InfoBanner color="yellow">
Installing software over existing installations might cause issues.
Fleet&apos;s policy may not detect these existing installations.
Please create a test fleet in Fleet to verify a smooth installation.{" "}
<CustomLink
url={`${LEARN_MORE_ABOUT_BASE_LINK}/query-templates-for-automatic-software-install`}
text="Learn more"
newTab
variant="banner-link"
/>
</InfoBanner>
)}
</div>
);
};

View file

@ -8,7 +8,6 @@ import { PLATFORM_DISPLAY_NAMES } from "interfaces/platform";
import { IAppStoreApp, isIpadOrIphoneSoftware } from "interfaces/software";
import { IVppApp } from "services/entities/mdm_apple";
import Card from "components/Card";
import CustomLink from "components/CustomLink";
import Radio from "components/forms/fields/Radio";
import Button from "components/buttons/Button";
@ -27,6 +26,7 @@ import {
} from "pages/SoftwarePage/helpers";
import { generateFormValidation, getUniqueAppId } from "./helpers";
import SoftwareDeploySlider from "../SoftwareDeploySelector";
const baseClass = "software-vpp-form";
@ -265,7 +265,6 @@ const SoftwareVppForm = ({
<SoftwareOptionsSelector
platform={softwareVppForEdit.platform}
formData={formData}
onToggleAutomaticInstall={onToggleAutomaticInstall}
onToggleSelfService={onToggleSelfService}
onSelectCategory={onSelectCategory}
isEditingSoftware
@ -297,7 +296,14 @@ const SoftwareVppForm = ({
);
}
// Hides deploy slider until app is selected
// Hides deploy slider for iOS/iPadOS apps
const showDeploySoftwareSlider =
!!formData.selectedApp &&
!isIpadOrIphoneSoftware(formData.selectedApp.platform);
// Add VPP form
// 4.83+ has no additional options to select beyond the app
if (vppApps) {
return (
<div className={`${baseClass}__form-fields`}>
@ -311,43 +317,12 @@ const SoftwareVppForm = ({
apps, head to{" "}
<CustomLink url="https://business.apple.com" text="ABM" newTab />
</div>
<div className={`${baseClass}__form-frame`}>
<Card paddingSize="medium" borderRadiusSize="large">
<SoftwareOptionsSelector
platform={
("selectedApp" in formData &&
formData.selectedApp &&
formData.selectedApp.platform) ||
""
}
formData={formData}
onToggleAutomaticInstall={onToggleAutomaticInstall}
onToggleSelfService={onToggleSelfService}
onSelectCategory={onSelectCategory}
onClickPreviewEndUserExperience={() =>
onClickPreviewEndUserExperience(
isIpadOrIphoneSoftware(formData.selectedApp?.platform || "")
)
}
/>
</Card>
<Card paddingSize="medium" borderRadiusSize="large">
<TargetLabelSelector
selectedTargetType={formData.targetType}
selectedCustomTarget={formData.customTarget}
selectedLabels={formData.labelTargets}
customTargetOptions={CUSTOM_TARGET_OPTIONS}
className={`${baseClass}__target`}
onSelectTargetType={onSelectTargetType}
onSelectCustomTarget={onSelectCustomTargetOption}
onSelectLabel={onSelectLabel}
labels={labels || []}
dropdownHelpText={
generateHelpText(false, formData.customTarget) // maps to !automaticInstall help text
}
/>
</Card>
</div>
{showDeploySoftwareSlider && (
<SoftwareDeploySlider
deploySoftware={formData.automaticInstall}
onToggleDeploySoftware={onToggleAutomaticInstall}
/>
)}
</div>
);
}

View file

@ -73,6 +73,7 @@ const makePolicies = (count: number): ISoftwareInstallPolicy[] =>
Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: `Policy ${i + 1}`,
type: i % 2 === 0 ? "patch" : "dynamic" /* alternate types for variety */,
}));
describe("getAutomaticInstallPoliciesCount", () => {

View file

@ -15,6 +15,9 @@ import {
ISoftwarePackage,
IAppStoreApp,
ISoftwareTitle,
ISoftwareInstallPolicyUI,
ISoftwareInstallPolicy,
SoftwareInstallPolicyTypeSet,
} from "interfaces/software";
import { IDropdownOption } from "interfaces/dropdownOption";
@ -293,3 +296,50 @@ export const getDisplayedSoftwareName = (
export const isAndroidWebApp = (androidPlayStoreId?: string) =>
!!androidPlayStoreId &&
androidPlayStoreId.startsWith("com.google.enterprise.webapp");
export interface MergePoliciesParams {
automaticInstallPolicies:
| ISoftwarePackage["automatic_install_policies"]
| null
| undefined;
patchPolicy: ISoftwarePackage["patch_policy"] | null | undefined;
}
// const mergePolicies(params: MergePoliciesParams): ISoftwareInstallerPolicyUI[] = function (...) { ... }
export const mergePolicies = ({
automaticInstallPolicies,
patchPolicy,
}: MergePoliciesParams): ISoftwareInstallPolicyUI[] => {
// Map keyed by policy id so we can merge dynamic and patch info for the same id.
const byId = new Map<number, ISoftwareInstallPolicyUI>();
// 1. Seed the map with automatic install ("dynamic") policies.
(automaticInstallPolicies ?? []).forEach((installPolicy) => {
// Type Set with "dynamic" for automatic install policies
const type: SoftwareInstallPolicyTypeSet = new Set(["dynamic"]);
byId.set(installPolicy.id, {
...installPolicy,
type,
});
});
// 2. Merge in the patch policy by its id, updating type if there's a match.
if (patchPolicy) {
const existing = byId.get(patchPolicy.id);
if (existing) {
// If there is already a dynamic policy with this id, just add "patch"
// to the existing Set so type becomes Set(["dynamic", "patch"]).
existing.type.add("patch");
} else {
// If there is no dynamic policy with this id, create a new entry that
// has only "patch" in the Set.
const type: SoftwareInstallPolicyTypeSet = new Set(["patch"]);
byId.set(patchPolicy.id, {
...((patchPolicy as unknown) as ISoftwareInstallPolicy),
type,
});
}
}
return Array.from(byId.values());
};

View file

@ -11,6 +11,7 @@ import ActionsDropdown from "components/ActionsDropdown";
import CustomLink from "components/CustomLink";
import TooltipWrapper from "components/TooltipWrapper";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import PillBadge from "components/PillBadge";
interface IHeaderProps {
column: {
@ -60,8 +61,8 @@ export interface ITeamUsersTableData {
export const renderApiUserIndicator = () => {
return (
<TooltipWrapper
className="api-only-tooltip"
<PillBadge
text="API"
tipContent={
<>
This user was created using fleetctl and
@ -74,13 +75,7 @@ export const renderApiUserIndicator = () => {
/>
</>
}
tipOffset={14}
position="top"
showArrow
underline={false}
>
<span className="team-users__api-only-user">API</span>
</TooltipWrapper>
/>
);
};

View file

@ -1,10 +1,6 @@
.team-users {
@include vertical-page-tab-panel-layout;
&__api-only-user {
@include grey-badge;
}
.data-table-block {
.data-table__table {
thead {

View file

@ -1,8 +1,4 @@
.user-management {
&__api-only-user {
@include grey-badge;
}
.data-table-block {
.data-table__table {
thead {

View file

@ -556,6 +556,7 @@ describe("Device User Page", () => {
critical: false,
calendar_events_enabled: false,
conditional_access_enabled: true,
type: "dynamic",
response: "fail",
});

View file

@ -1122,7 +1122,6 @@ const HostDetailsPage = ({
})
);
};
const navigateToSoftwareTab = (i: number): void => {
const navPath = hostSoftwareSubNav[i].pathname;
router.push(

View file

@ -205,6 +205,16 @@ const ManagePolicyPage = ({
const [sortDirection, setSortDirection] = useState<
"asc" | "desc" | undefined
>(initialSortDirection);
const [automationFilter, setAutomationFilter] = useState<string | null>(null);
// Maps frontend dropdown values to backend automation_type query param values
const AUTOMATION_FILTER_TO_API: Record<string, string> = {
install_software: "software",
run_script: "scripts",
calendar_events: "calendar",
conditional_access: "conditional_access",
other_workflows: "other",
};
useEffect(() => {
setLastEditedQueryPlatform(null);
@ -317,6 +327,9 @@ const ManagePolicyPage = ({
teamId: teamIdForApi || 0,
// no teams does inherit
mergeInherited: true,
automationType: automationFilter
? AUTOMATION_FILTER_TO_API[automationFilter]
: undefined,
},
],
({ queryKey }) => {
@ -996,6 +1009,63 @@ const ManagePolicyPage = ({
);
};
// Client-side filtering is still needed for global policies since the
// global policies endpoint does not support automation_type. Team policies
// use the server-side automation_type query param instead.
const filterGlobalPoliciesByAutomation = (
policies: IPolicyStats[]
): IPolicyStats[] => {
if (!automationFilter) return policies;
return policies.filter((p) => {
switch (automationFilter) {
case "install_software":
return !!p.install_software;
case "run_script":
return !!p.run_script;
case "calendar_events":
return p.calendar_events_enabled;
case "conditional_access":
return p.conditional_access_enabled;
case "other_workflows":
return currentAutomatedPolicies.includes(p.id);
default:
return true;
}
});
};
const automationFilterOptions: CustomOptionType[] = [
{ label: "All automations", value: "all" },
{ label: "Software", value: "install_software" },
{ label: "Scripts", value: "run_script" },
{ label: "Calendar", value: "calendar_events" },
{ label: "Conditional access", value: "conditional_access" },
{ label: "Other", value: "other_workflows" },
];
const renderAutomationFilter = isPremiumTier
? () => (
<DropdownWrapper
className={`${baseClass}__filter-automation-dropdown`}
name="filter-by-automation"
onChange={(val: SingleValue<CustomOptionType>) => {
const newFilter =
val?.value && val.value !== "all" ? val.value : null;
setAutomationFilter(newFilter);
// Reset to first page when filter changes
const locationPath = getNextLocationPath({
pathPrefix: PATHS.MANAGE_POLICIES,
queryParams: { ...queryParams, page: "0" },
});
router?.push(locationPath);
}}
placeholder="Filter by automation"
options={automationFilterOptions}
variant="table-filter"
/>
)
: undefined;
const renderMainTable = () => {
if (!isRouteOk || (isPremiumTier && !userTeams)) {
return <Spinner />;
@ -1006,9 +1076,15 @@ const ManagePolicyPage = ({
if (globalPoliciesError) {
return <TableDataError verticalPaddingSize="pad-xxxlarge" />;
}
const filteredGlobalPolicies = filterGlobalPoliciesByAutomation(
globalPolicies || []
);
const filteredGlobalCount = automationFilter
? filteredGlobalPolicies.length
: globalPoliciesCount || 0;
return (
<PoliciesTable
policiesList={globalPolicies || []}
policiesList={filteredGlobalPolicies}
isLoading={isFetchingGlobalPolicies || isFetchingGlobalConfig}
onDeletePoliciesClick={onDeletePoliciesClick}
canAddOrDeletePolicies={canAddOrDeletePolicies}
@ -1018,16 +1094,18 @@ const ManagePolicyPage = ({
isPremiumTier={isPremiumTier}
renderPoliciesCount={() =>
renderPoliciesCountAndLastUpdated(
globalPoliciesCount,
globalPolicies
filteredGlobalCount,
filteredGlobalPolicies
)
}
count={globalPoliciesCount || 0}
count={filteredGlobalCount}
searchQuery={searchQuery}
sortHeader={sortHeader}
sortDirection={sortDirection}
page={page}
onQueryChange={onQueryChange}
customControl={renderAutomationFilter}
isFiltered={!!automationFilter}
/>
);
}
@ -1036,10 +1114,16 @@ const ManagePolicyPage = ({
if (teamPoliciesError) {
return <TableDataError verticalPaddingSize="pad-xxxlarge" />;
}
const displayedTeamPolicies = teamPolicies || [];
// When a filter is active, use the returned array length as the count
// since the count endpoint doesn't support automation_type yet.
const displayedTeamCount = automationFilter
? displayedTeamPolicies.length
: teamPoliciesCountMergeInherited || 0;
return (
<div>
<PoliciesTable
policiesList={teamPolicies || []}
policiesList={displayedTeamPolicies}
isLoading={
isFetchingTeamPolicies ||
isFetchingTeamConfig ||
@ -1052,17 +1136,19 @@ const ManagePolicyPage = ({
currentAutomatedPolicies={currentAutomatedPolicies}
renderPoliciesCount={() =>
renderPoliciesCountAndLastUpdated(
teamPoliciesCountMergeInherited,
teamPolicies
displayedTeamCount,
displayedTeamPolicies
)
}
isPremiumTier={isPremiumTier}
count={teamPoliciesCountMergeInherited || 0}
count={displayedTeamCount}
searchQuery={searchQuery}
sortHeader={sortHeader}
sortDirection={sortDirection}
page={page}
onQueryChange={onQueryChange}
customControl={renderAutomationFilter}
isFiltered={!!automationFilter}
/>
</div>
);
@ -1145,13 +1231,6 @@ const ManagePolicyPage = ({
}
const options: CustomOptionType[] = [
{
label: "Calendar",
value: "calendar_events",
isDisabled: !!disabledCalendarTooltipContent,
helpText: "Automatically reserve time to resolve failing policies.",
tooltipContent: disabledCalendarTooltipContent,
},
{
label: "Software",
value: "install_software",
@ -1166,6 +1245,13 @@ const ManagePolicyPage = ({
helpText: "Run script to resolve failing policies.",
tooltipContent: disabledRunScriptTooltipContent,
},
{
label: "Calendar",
value: "calendar_events",
isDisabled: !!disabledCalendarTooltipContent,
helpText: "Automatically reserve time to resolve failing policies.",
tooltipContent: disabledCalendarTooltipContent,
},
{
label: "Conditional access",
value: "conditional_access",

View file

@ -19,6 +19,10 @@
}
}
&__filter-automation-dropdown {
min-width: 200px;
}
&__manage-automations-wrapper {
@include button-dropdown;
.Select-multi-value-wrapper {

View file

@ -290,4 +290,112 @@ describe("Policies table", () => {
expect(checkbox).toBeChecked();
});
it("Renders a Patch badge for a patch policy", () => {
const render = createCustomRenderer({
context: {
app: {
isGlobalAdmin: true,
currentUser: createMockUser(),
},
},
});
const testPatchPolicy = createMockPolicy({
type: "patch",
name: "macOS - Zoom up to date",
});
render(
<PoliciesTable
policiesList={[testPatchPolicy]}
isLoading={false}
onDeletePoliciesClick={noop}
currentTeam={{ id: -1, name: "All fleets" }}
isPremiumTier
searchQuery=""
page={0}
onQueryChange={noop}
renderPoliciesCount={() => null}
count={1}
/>
);
expect(screen.getByText("Patch")).toBeInTheDocument();
});
it("Does not render a Patch badge for a dynamic policy", () => {
const render = createCustomRenderer({
context: {
app: {
isGlobalAdmin: true,
currentUser: createMockUser(),
},
},
});
const testDynamicPolicy = createMockPolicy({ type: "dynamic" });
render(
<PoliciesTable
policiesList={[testDynamicPolicy]}
isLoading={false}
onDeletePoliciesClick={noop}
currentTeam={{ id: -1, name: "All fleets" }}
isPremiumTier
searchQuery=""
page={0}
onQueryChange={noop}
renderPoliciesCount={() => null}
count={1}
/>
);
expect(screen.queryByText("Patch")).not.toBeInTheDocument();
});
it("Renders the Automations column with correct values", () => {
const render = createCustomRenderer({
context: {
app: {
isGlobalAdmin: true,
currentUser: createMockUser(),
},
},
});
const policyWithAutomations = createMockPolicy({
id: 10,
name: "Policy with automations",
install_software: { name: "Zoom", software_title_id: 1 },
calendar_events_enabled: true,
});
const policyWithoutAutomations = createMockPolicy({
id: 11,
name: "Policy without automations",
install_software: undefined,
calendar_events_enabled: false,
conditional_access_enabled: false,
});
render(
<PoliciesTable
policiesList={[policyWithAutomations, policyWithoutAutomations]}
isLoading={false}
onDeletePoliciesClick={noop}
currentTeam={{ id: -1, name: "All fleets" }}
isPremiumTier
searchQuery=""
page={0}
onQueryChange={noop}
renderPoliciesCount={() => null}
count={2}
/>
);
expect(screen.getByText("Automations")).toBeInTheDocument();
expect(screen.getByText("Software, calendar")).toBeInTheDocument();
expect(screen.getByText("---")).toBeInTheDocument();
});
});

View file

@ -37,6 +37,8 @@ interface IPoliciesTableProps {
sortDirection?: "asc" | "desc";
page: number;
count: number;
customControl?: () => JSX.Element | null;
isFiltered?: boolean;
}
const PoliciesTable = ({
@ -55,6 +57,8 @@ const PoliciesTable = ({
sortDirection,
page,
count,
customControl,
isFiltered,
}: IPoliciesTableProps): JSX.Element => {
const { config } = useContext(AppContext);
@ -80,14 +84,18 @@ const PoliciesTable = ({
emptyState.info = "";
}
if (searchQuery) {
if (searchQuery || isFiltered) {
delete emptyState.graphicName;
delete emptyState.primaryButton;
emptyState.header = "No matching policies";
emptyState.info = "No policies match the current filters.";
}
const searchable = !(policiesList?.length === 0 && searchQuery === "");
const searchable = !(
policiesList?.length === 0 &&
searchQuery === "" &&
!isFiltered
);
const isPrimoMode = config?.partnerships?.enable_primo || false;
const viewingTeamPolicies =
@ -150,6 +158,7 @@ const PoliciesTable = ({
onQueryChange={onQueryChange}
inputPlaceHolder="Search by name"
searchable={searchable}
customControl={customControl}
/>
</div>
);

View file

@ -13,13 +13,15 @@ import PATHS from "router/paths";
import { getPathWithQueryParams } from "utilities/url";
import sortUtils from "utilities/sort";
import { PolicyResponse } from "utilities/constants";
import { DEFAULT_EMPTY_CELL_VALUE, PolicyResponse } from "utilities/constants";
import CriticalPolicyBadge from "components/CriticalPolicyBadge";
import InheritedBadge from "components/InheritedBadge";
import PillBadge from "components/PillBadge";
import { PATCH_TOOLTIP_CONTENT } from "components/SoftwareInstallPolicyBadges/SoftwareInstallPolicyBadges";
import { getConditionalSelectHeaderCheckboxProps } from "components/TableContainer/utilities/config_utils";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import { getAutomationTypesString } from "../../helpers";
import PassingColumnHeader from "../PassingColumnHeader";
interface IGetToggleAllRowsSelectedProps {
@ -108,7 +110,7 @@ const generateTableHeaders = (
),
accessor: "name",
Cell: (cellProps: ICellProps): JSX.Element => {
const { critical, id, team_id } = cellProps.row.original;
const { critical, id, team_id, type } = cellProps.row.original;
return (
<LinkCell
className="w250"
@ -117,8 +119,14 @@ const generateTableHeaders = (
suffix={
<>
{isPremiumTier && critical && <CriticalPolicyBadge />}
{type === "patch" && (
<PillBadge text="Patch" tipContent={PATCH_TOOLTIP_CONTENT} />
)}
{viewingTeamPolicies && team_id === null && (
<InheritedBadge tooltipContent="This policy runs on all hosts." />
<PillBadge
text="Inherited"
tipContent="This policy runs on all hosts."
/>
)}
</>
}
@ -130,6 +138,27 @@ const generateTableHeaders = (
},
sortType: "caseInsensitive",
},
{
title: "Automations",
Header: "Automations",
accessor: "automations",
disableSortBy: true,
Cell: (cellProps: ICellProps): JSX.Element => {
const policy = cellProps.row.original;
const automationsText = getAutomationTypesString(policy);
const isNone = automationsText === DEFAULT_EMPTY_CELL_VALUE;
return (
<span
className={`automations-cell${
isNone ? " automations-cell--none" : ""
}`}
title={isNone ? undefined : automationsText}
>
{automationsText}
</span>
);
},
},
{
title: "Pass",
Header: (cellProps) => (

View file

@ -26,6 +26,18 @@
}
}
.automations-cell {
display: block;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&--none {
color: $ui-fleet-black-50;
}
}
.no-team-policy {
border: 1px solid #e2e4ea;
box-sizing: border-box;

View file

@ -1,7 +1,9 @@
import React from "react";
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
import {
getInstallSoftwareErrorMessage,
getRunScriptErrorMessage,
getAutomationTypesString,
} from "./helpers";
import { IInstallSoftwareFormData } from "./components/InstallSoftwareModal/InstallSoftwareModal";
import { IPolicyRunScriptFormData } from "./components/PolicyRunScriptModal/PolicyRunScriptModal";
@ -28,6 +30,7 @@ describe("getInstallSoftwareErrorMessage", () => {
critical: false,
calendar_events_enabled: false,
conditional_access_enabled: false,
type: "dynamic",
},
{
swIdToInstall: 456,
@ -49,6 +52,7 @@ describe("getInstallSoftwareErrorMessage", () => {
critical: false,
calendar_events_enabled: false,
conditional_access_enabled: false,
type: "dynamic",
},
];
@ -160,6 +164,7 @@ describe("getRunScriptErrorMessage", () => {
critical: false,
calendar_events_enabled: false,
conditional_access_enabled: false,
type: "dynamic",
},
{
scriptIdToRun: 456,
@ -181,6 +186,7 @@ describe("getRunScriptErrorMessage", () => {
critical: false,
calendar_events_enabled: false,
conditional_access_enabled: false,
type: "dynamic",
},
];
@ -268,3 +274,64 @@ describe("getRunScriptErrorMessage", () => {
);
});
});
describe("getAutomationTypesString", () => {
const basePolicy = {
calendar_events_enabled: false,
conditional_access_enabled: false,
};
it("returns DEFAULT_EMPTY_CELL_VALUE when no automations are enabled", () => {
expect(getAutomationTypesString(basePolicy)).toBe(DEFAULT_EMPTY_CELL_VALUE);
});
it("returns 'Software' when only install_software is present", () => {
expect(
getAutomationTypesString({
...basePolicy,
install_software: { software_title_id: 1 },
})
).toBe("Software");
});
it("returns 'Script' when only run_script is present", () => {
expect(
getAutomationTypesString({
...basePolicy,
run_script: { id: 1 },
})
).toBe("Script");
});
it("returns types in correct order with sentence case: Software, script, calendar, conditional access, other", () => {
expect(
getAutomationTypesString({
install_software: { software_title_id: 1 },
run_script: { id: 1 },
calendar_events_enabled: true,
conditional_access_enabled: true,
webhook: "On",
})
).toBe("Software, script, calendar, conditional access, other");
});
it("returns 'Software, calendar' for software + calendar", () => {
expect(
getAutomationTypesString({
...basePolicy,
install_software: { software_title_id: 1 },
calendar_events_enabled: true,
})
).toBe("Software, calendar");
});
it("does not include Other when webhook is Off", () => {
expect(
getAutomationTypesString({
...basePolicy,
install_software: { software_title_id: 1 },
webhook: "Off",
})
).toBe("Software");
});
});

View file

@ -1,5 +1,7 @@
import React from "react";
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
import { IInstallSoftwareFormData } from "./components/InstallSoftwareModal/InstallSoftwareModal";
import { IPolicyRunScriptFormData } from "./components/PolicyRunScriptModal/PolicyRunScriptModal";
@ -66,3 +68,35 @@ export const getRunScriptErrorMessage = (
return <>Could not update policy. {jsxElement}</>;
};
/** Derives a comma-separated string of automation types enabled for a policy.
* Returns "---" if no automations are enabled. */
export const getAutomationTypesString = (policy: {
install_software?: { software_title_id: number };
run_script?: { id: number };
calendar_events_enabled: boolean;
conditional_access_enabled: boolean;
webhook?: string;
}): string => {
const types: string[] = [];
if (policy.install_software) {
types.push("Software");
}
if (policy.run_script) {
types.push("Script");
}
if (policy.calendar_events_enabled) {
types.push("Calendar");
}
if (policy.conditional_access_enabled) {
types.push("Conditional access");
}
if (policy.webhook === "On") {
types.push("Other");
}
if (types.length === 0) return DEFAULT_EMPTY_CELL_VALUE;
// Lowercase all types after the first to match sentence-case display
return types.map((t, i) => (i === 0 ? t : t.toLowerCase())).join(", ");
};

View file

@ -21,6 +21,7 @@ import {
} from "interfaces/team";
import globalPoliciesAPI from "services/entities/global_policies";
import teamPoliciesAPI from "services/entities/team_policies";
import teamsAPI, { ILoadTeamResponse } from "services/entities/teams";
import hostAPI from "services/entities/hosts";
import statusAPI from "services/entities/status";
import { DOCUMENT_TITLE_SUFFIX, LIVE_POLICY_STEPS } from "utilities/constants";
@ -155,15 +156,20 @@ const PolicyPage = ({
false
);
// TODO: Remove team endpoint workaround once global policy endpoint populates patch_software.
// The global endpoint does not return patch_software for patch policies, but the team endpoint does.
const {
isLoading: isStoredPolicyLoading,
data: storedPolicy,
error: storedPolicyError,
} = useQuery<IStoredPolicyResponse, Error, IPolicy>(
["policy", policyId],
() => globalPoliciesAPI.load(policyId as number), // Note: Team users have access to policies through global API
["policy", policyId, teamIdForApi],
() =>
teamIdForApi && teamIdForApi > 0
? teamPoliciesAPI.load(teamIdForApi, policyId as number)
: globalPoliciesAPI.load(policyId as number),
{
enabled: isRouteOk && !!policyId, // Note: this justifies the number type assertions above
enabled: isRouteOk && !!policyId,
refetchOnWindowFocus: false,
retry: false,
select: (data: IStoredPolicyResponse) => data.policy,
@ -233,6 +239,35 @@ const PolicyPage = ({
);
}
// Fetch team config to determine "Other" automations (webhooks/integrations)
const { data: teamData } = useQuery<ILoadTeamResponse, Error>(
["teams", teamIdForApi],
() => teamsAPI.load(teamIdForApi),
{
enabled:
isRouteOk &&
teamIdForApi !== undefined &&
teamIdForApi > 0 &&
storedPolicy?.type === "patch",
staleTime: 5000,
}
);
let currentAutomatedPolicies: number[] = [];
if (teamData?.team) {
const {
webhook_settings: { failing_policies_webhook: webhook },
integrations,
} = teamData.team;
const isIntegrationEnabled =
(integrations?.jira?.some((j: any) => j.enable_failing_policies) ||
integrations?.zendesk?.some((z: any) => z.enable_failing_policies)) ??
false;
if (isIntegrationEnabled || webhook?.enable_failing_policies_webhook) {
currentAutomatedPolicies = webhook?.policy_ids || [];
}
}
// this function is passed way down, wrapped and ultimately called by SaveNewPolicyModal
const { mutateAsync: createPolicy } = useMutation(
(formData: IPolicyFormData) => {
@ -319,6 +354,7 @@ const PolicyPage = ({
onOpenSchemaSidebar,
renderLiveQueryWarning,
teamIdForApi,
currentAutomatedPolicies,
};
const step2Opts = {

View file

@ -0,0 +1,183 @@
import React from "react";
import { Link } from "react-router";
import { IPolicy } from "interfaces/policy";
import PATHS from "router/paths";
import { getPathWithQueryParams } from "utilities/url";
import Button from "components/buttons/Button";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import Graphic from "components/Graphic";
import { GraphicNames } from "components/graphics";
import Icon from "components/Icon";
import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
const baseClass = "policy-automations";
interface IPolicyAutomationsProps {
storedPolicy: IPolicy;
currentAutomatedPolicies: number[];
onAddAutomation: () => void;
isAddingAutomation: boolean;
gitOpsModeEnabled: boolean;
}
interface IAutomationRow {
name: string;
type: string;
graphicName?: GraphicNames;
isSoftware?: boolean;
link?: string;
sortOrder: number;
sortName: string;
}
const PolicyAutomations = ({
storedPolicy,
currentAutomatedPolicies,
onAddAutomation,
isAddingAutomation,
gitOpsModeEnabled,
}: IPolicyAutomationsProps): JSX.Element => {
const isPatchPolicy = storedPolicy.type === "patch";
const hasPatchSoftware = !!storedPolicy.patch_software;
const hasSoftwareAutomation = !!storedPolicy.install_software;
const showCtaCard =
isPatchPolicy && hasPatchSoftware && !hasSoftwareAutomation;
const automationRows: IAutomationRow[] = [];
if (storedPolicy.install_software) {
automationRows.push({
name: storedPolicy.install_software.name,
type: "Software",
isSoftware: true,
link: getPathWithQueryParams(
PATHS.SOFTWARE_TITLE_DETAILS(
storedPolicy.install_software.software_title_id.toString()
),
{ fleet_id: storedPolicy.team_id }
),
sortOrder: 0,
sortName: storedPolicy.install_software.name.toLowerCase(),
});
}
if (storedPolicy.run_script) {
automationRows.push({
name: storedPolicy.run_script.name,
type: "Script",
graphicName: storedPolicy.run_script.name.endsWith(".sh")
? "file-sh"
: "file-ps1",
sortOrder: 1,
sortName: storedPolicy.run_script.name.toLowerCase(),
});
}
if (storedPolicy.calendar_events_enabled) {
automationRows.push({
name: "Maintenance window",
type: "Calendar",
graphicName: "calendar",
sortOrder: 2,
sortName: "",
});
}
if (storedPolicy.conditional_access_enabled) {
automationRows.push({
name: "Block single sign-on",
type: "Conditional access",
graphicName: "lock",
sortOrder: 3,
sortName: "",
});
}
if (currentAutomatedPolicies.includes(storedPolicy.id)) {
automationRows.push({
name: "Create ticket or send webhook",
type: "Other",
graphicName: "settings",
sortOrder: 4,
sortName: "",
});
}
automationRows.sort((a, b) => {
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder;
return a.sortName.localeCompare(b.sortName);
});
const patchSoftwareName =
storedPolicy.patch_software?.display_name ||
storedPolicy.patch_software?.name ||
"";
return (
<div className={`${baseClass} form-field`}>
{showCtaCard && (
<div className={`${baseClass}__cta-card`}>
<span className={`${baseClass}__cta-label`}>
Automatically patch {patchSoftwareName}
</span>
<GitOpsModeTooltipWrapper
position="top"
renderChildren={(disableChildren) => (
<Button
onClick={onAddAutomation}
variant="text-icon"
disabled={disableChildren || isAddingAutomation}
>
{isAddingAutomation ? (
"Adding..."
) : (
<>
<Icon name="plus" /> Add automation
</>
)}
</Button>
)}
/>
</div>
)}
{automationRows.length > 0 && (
<>
<div className={`${baseClass}__list-label`}>Automations</div>
<div className={`${baseClass}__list`}>
{automationRows.map((row) => (
<div
key={`${row.type}-${row.name}`}
className={`${baseClass}__row`}
>
<div className={`${baseClass}__row-name`}>
{row.isSoftware ? (
<SoftwareIcon name={row.name} size="small" />
) : (
row.graphicName && (
<Graphic
name={row.graphicName}
key={`${row.graphicName}-graphic`}
className={`${baseClass}__row-graphic ${
row.graphicName === "file-sh" ||
row.graphicName === "file-ps1"
? "scale-40-24"
: ""
}`}
/>
)
)}
{row.link ? <Link to={row.link}>{row.name}</Link> : row.name}
</div>
<span className={`${baseClass}__row-type`}>{row.type}</span>
</div>
))}
</div>
</>
)}
</div>
);
};
export default PolicyAutomations;

View file

@ -0,0 +1,60 @@
.policy-automations {
max-width: 600px;
&__cta-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: $pad-medium;
background-color: $ui-fleet-black-5;
border: 1px solid $ui-fleet-black-10;
border-radius: 8px;
margin-bottom: $pad-medium;
}
&__cta-label {
font-size: $x-small;
color: $ui-fleet-black-75;
}
&__list {
border: 1px solid $ui-fleet-black-10;
border-radius: 8px;
}
&__list-label {
color: $core-fleet-black;
font-weight: $bold;
}
&__row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 $pad-large;
height: 40px;
border-top: 1px solid $ui-fleet-black-10;
}
&__row-graphic {
vertical-align: middle;
color: $ui-fleet-black-50;
}
&__row-name {
display: flex;
align-items: center;
gap: $pad-small;
font-size: $x-small;
color: $ui-fleet-black-75;
a {
@include link;
@include animated-bottom-border;
}
}
&__row-type {
color: $ui-fleet-black-75;
}
}

View file

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

View file

@ -60,6 +60,7 @@ describe("PolicyForm - component", () => {
isFetchingAutofillDescription: false,
isFetchingAutofillResolution: false,
resetAiAutofillData: jest.fn(),
currentAutomatedPolicies: [],
};
it("should not show the target selector in the free tier", async () => {
@ -144,6 +145,7 @@ describe("PolicyForm - component", () => {
isFetchingAutofillDescription={false}
isFetchingAutofillResolution={false}
resetAiAutofillData={jest.fn()}
currentAutomatedPolicies={[]}
/>
);
@ -206,6 +208,7 @@ describe("PolicyForm - component", () => {
isFetchingAutofillDescription={false}
isFetchingAutofillResolution={false}
resetAiAutofillData={jest.fn()}
currentAutomatedPolicies={[]}
/>
);
@ -284,6 +287,7 @@ describe("PolicyForm - component", () => {
isFetchingAutofillDescription={false}
isFetchingAutofillResolution={false}
resetAiAutofillData={jest.fn()}
currentAutomatedPolicies={[]}
/>
);
@ -444,6 +448,121 @@ describe("PolicyForm - component", () => {
expect(onUpdate.mock.calls[0][0].labels_exclude_any).toEqual([]);
});
});
describe("patch policy behavior", () => {
const patchPolicy = createMockPolicy({
type: "patch",
platform: "darwin",
patch_software: { name: "Firefox", software_title_id: 42 },
install_software: undefined,
});
const patchPolicyProps = {
...defaultProps,
storedPolicy: patchPolicy,
};
const renderPatchPolicy = createCustomRenderer({
withBackendMock: true,
context: {
app: {
currentUser: createMockUser(),
isGlobalObserver: false,
isGlobalAdmin: true,
isGlobalMaintainer: false,
isOnGlobalTeam: true,
isPremiumTier: true,
isSandboxMode: false,
config: createMockConfig(),
},
policy: {
policyTeamId: undefined,
lastEditedQueryId: patchPolicy.id,
lastEditedQueryName: patchPolicy.name,
lastEditedQueryDescription: patchPolicy.description,
lastEditedQueryBody: patchPolicy.query,
lastEditedQueryResolution: patchPolicy.resolution,
lastEditedQueryCritical: patchPolicy.critical,
lastEditedQueryPlatform: patchPolicy.platform,
lastEditedQueryLabelsIncludeAny: [],
lastEditedQueryLabelsExcludeAny: [],
defaultPolicy: false,
setLastEditedQueryName: jest.fn(),
setLastEditedQueryDescription: jest.fn(),
setLastEditedQueryBody: jest.fn(),
setLastEditedQueryResolution: jest.fn(),
setLastEditedQueryCritical: jest.fn(),
setLastEditedQueryPlatform: jest.fn(),
},
},
});
it("hides platform selector", () => {
renderPatchPolicy(<PolicyForm {...patchPolicyProps} />);
expect(screen.queryByLabelText("macOS")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Windows")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Linux")).not.toBeInTheDocument();
});
it("hides target label selector", () => {
renderPatchPolicy(<PolicyForm {...patchPolicyProps} />);
expect(screen.queryByLabelText("All hosts")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Custom")).not.toBeInTheDocument();
});
it("submits only editable fields on save", async () => {
const onUpdate = jest.fn();
renderPatchPolicy(
<PolicyForm {...patchPolicyProps} onUpdate={onUpdate} />
);
const saveButton = await screen.findByRole("button", { name: "Save" });
await userEvent.click(saveButton);
expect(onUpdate).toHaveBeenCalledTimes(1);
const payload = onUpdate.mock.calls[0][0];
expect(payload).toHaveProperty("name");
expect(payload).toHaveProperty("description");
expect(payload).toHaveProperty("resolution");
expect(payload).toHaveProperty("critical");
expect(payload).not.toHaveProperty("query");
expect(payload).not.toHaveProperty("platform");
expect(payload).not.toHaveProperty("labels_include_any");
});
it("shows 'Add automation' CTA when patch policy has no install_software", async () => {
renderPatchPolicy(<PolicyForm {...patchPolicyProps} />);
await waitFor(() => {
expect(
screen.getByText(/Automatically patch Firefox/)
).toBeInTheDocument();
expect(screen.getByText(/Add automation/)).toBeInTheDocument();
});
});
it("hides 'Add automation' CTA when automation already exists", async () => {
const automatedPatchPolicy = createMockPolicy({
type: "patch",
platform: "darwin",
patch_software: { name: "Firefox", software_title_id: 42 },
install_software: { name: "Firefox", software_title_id: 42 },
});
renderPatchPolicy(
<PolicyForm
{...patchPolicyProps}
storedPolicy={automatedPatchPolicy}
/>
);
// Wait for the component to fully render, then assert CTA is absent
await waitFor(() => {
expect(
screen.getByRole("button", { name: "Save" })
).toBeInTheDocument();
});
expect(screen.queryByText(/Add automation/)).not.toBeInTheDocument();
});
});
});
// TODO: Consider testing save button is disabled for a sql error
// Trickiness is in modifying react-ace using react-testing library

View file

@ -1,7 +1,7 @@
/* eslint-disable jsx-a11y/no-noninteractive-element-to-interactive-role */
/* eslint-disable jsx-a11y/interactive-supports-focus */
import React, { useState, useContext, useEffect, KeyboardEvent } from "react";
import { useQuery } from "react-query";
import { useQuery, useQueryClient } from "react-query";
import { Ace } from "ace-builds";
import ReactTooltip from "react-tooltip";
@ -12,6 +12,7 @@ import { COLORS } from "styles/var/colors";
import { addGravatarUrlToResource } from "utilities/helpers";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import { PolicyContext } from "context/policy";
import usePlatformCompatibility from "hooks/usePlatformCompatibility";
import usePlatformSelector from "hooks/usePlatformSelector";
@ -48,7 +49,10 @@ import labelsAPI, {
ILabelsSummaryResponse,
} from "services/entities/labels";
import teamPoliciesAPI from "services/entities/team_policies";
import SaveNewPolicyModal from "../SaveNewPolicyModal";
import PolicyAutomations from "../PolicyAutomations";
const baseClass = "policy-form";
@ -71,6 +75,7 @@ interface IPolicyFormProps {
onClickAutofillDescription: () => Promise<void>;
onClickAutofillResolution: () => Promise<void>;
resetAiAutofillData: () => void;
currentAutomatedPolicies: number[];
}
const validateQuerySQL = (query: string) => {
@ -104,6 +109,7 @@ const PolicyForm = ({
onClickAutofillDescription,
onClickAutofillResolution,
resetAiAutofillData,
currentAutomatedPolicies,
}: IPolicyFormProps): JSX.Element => {
const [errors, setErrors] = useState<{ [key: string]: any }>({}); // string | null | undefined or boolean | undefined
const [isSaveNewPolicyModalOpen, setIsSaveNewPolicyModalOpen] = useState(
@ -120,6 +126,9 @@ const PolicyForm = ({
);
const [selectedLabels, setSelectedLabels] = useState({});
const isPatchPolicy = storedPolicy?.type === "patch";
const [isAddingAutomation, setIsAddingAutomation] = useState(false);
// Note: The PolicyContext values should always be used for any mutable policy data such as query name
// The storedPolicy prop should only be used to access immutable metadata such as author id
const {
@ -154,6 +163,9 @@ const PolicyForm = ({
});
};
const { renderFlash } = useContext(NotificationContext);
const queryClient = useQueryClient();
const {
currentUser,
currentTeam,
@ -312,6 +324,28 @@ const PolicyForm = ({
}
};
const onAddPatchAutomation = async () => {
if (
!storedPolicy?.patch_software?.software_title_id ||
!storedPolicy?.team_id
) {
return;
}
setIsAddingAutomation(true);
try {
await teamPoliciesAPI.update(policyIdForEdit as number, {
team_id: storedPolicy.team_id,
software_title_id: storedPolicy.patch_software.software_title_id,
});
queryClient.invalidateQueries(["policy", policyIdForEdit]);
renderFlash("success", "Automation added.");
} catch {
renderFlash("error", "Couldn't set automation. Please try again.");
} finally {
setIsAddingAutomation(false);
}
};
const promptSavePolicy = () => (evt: React.MouseEvent<HTMLButtonElement>) => {
evt.preventDefault();
@ -322,13 +356,30 @@ const PolicyForm = ({
});
}
if (isExistingPolicy && !isAnyPlatformSelected) {
if (isExistingPolicy && !isPatchPolicy && !isAnyPlatformSelected) {
return setErrors({
...errors,
name: "At least one platform must be selected",
});
}
if (isPatchPolicy && isExistingPolicy) {
// Patch policies: only send editable fields, not query/platform
const payload: IPolicyFormData = {
name: lastEditedQueryName,
description: lastEditedQueryDescription,
resolution: lastEditedQueryResolution,
};
if (isPremiumTier) {
payload.critical = lastEditedQueryCritical;
}
onUpdate(payload);
setIsEditingName(false);
setIsEditingDescription(false);
setIsEditingResolution(false);
return;
}
let selectedPlatforms = getSelectedPlatforms();
if (selectedPlatforms.length === 0 && !isExistingPolicy && !defaultPolicy) {
// If no platforms are selected, default to all compatible platforms
@ -719,7 +770,8 @@ const PolicyForm = ({
const renderEditablePolicyForm = () => {
// Save disabled for no platforms selected, query name blank on existing query, or sql errors
const disableSaveFormErrors =
(isExistingPolicy && !isAnyPlatformSelected) ||
isAddingAutomation ||
(isExistingPolicy && !isPatchPolicy && !isAnyPlatformSelected) ||
(lastEditedQueryName === "" && !!lastEditedQueryId) ||
(selectedTargetType === "Custom" &&
!Object.entries(selectedLabels).some(([, value]) => {
@ -740,7 +792,21 @@ const PolicyForm = ({
value={lastEditedQueryBody}
error={errors.query}
label="Query"
labelActionComponent={renderLabelComponent()}
labelActionComponent={
isPatchPolicy ? (
<TooltipWrapper
tipContent="Query is read-only for patch policies."
position="top"
underline={false}
showArrow
tipOffset={12}
>
<Icon name="info" size="small" />
</TooltipWrapper>
) : (
renderLabelComponent()
)
}
name="query editor"
onLoad={onLoad}
wrapperClassName={`${baseClass}__text-editor-wrapper form-field`}
@ -748,10 +814,11 @@ const PolicyForm = ({
handleSubmit={promptSavePolicy}
wrapEnabled
focus={!isExistingPolicy}
readOnly={isPatchPolicy}
/>
{renderPlatformCompatibility()}
{isExistingPolicy && platformSelector.render()}
{isExistingPolicy && isPremiumTier && (
{isExistingPolicy && !isPatchPolicy && platformSelector.render()}
{isExistingPolicy && isPremiumTier && !isPatchPolicy && (
<TargetLabelSelector
selectedTargetType={selectedTargetType}
selectedCustomTarget={selectedCustomTarget}
@ -771,6 +838,15 @@ const PolicyForm = ({
suppressTitle
/>
)}
{isExistingPolicy && storedPolicy && (
<PolicyAutomations
storedPolicy={storedPolicy}
currentAutomatedPolicies={currentAutomatedPolicies}
onAddAutomation={onAddPatchAutomation}
isAddingAutomation={isAddingAutomation}
gitOpsModeEnabled={!!gitOpsModeEnabled}
/>
)}
{isExistingPolicy && isPremiumTier && renderCriticalPolicy()}
{renderLiveQueryWarning()}
<div className="button-wrap">
@ -826,6 +902,7 @@ const PolicyForm = ({
<Button
onClick={goToSelectTargets}
disabled={
isAddingAutomation ||
(isExistingPolicy && !isAnyPlatformSelected) ||
disabledLiveQuery
}

View file

@ -32,6 +32,7 @@ interface IQueryEditorProps {
onOpenSchemaSidebar: () => void;
renderLiveQueryWarning: () => JSX.Element | null;
teamIdForApi?: number;
currentAutomatedPolicies?: number[];
}
const QueryEditor = ({
@ -49,6 +50,7 @@ const QueryEditor = ({
onOpenSchemaSidebar,
renderLiveQueryWarning,
teamIdForApi,
currentAutomatedPolicies,
}: IQueryEditorProps): JSX.Element | null => {
const { currentUser, isPremiumTier, filteredPoliciesPath } = useContext(
AppContext
@ -205,6 +207,12 @@ const QueryEditor = ({
lastEditedQueryPlatform,
});
// Patch policies: never send query or platform (BE rejects them)
if (storedPolicy?.type === "patch") {
delete updatedPolicy.query;
delete updatedPolicy.platform;
}
const updateAPIRequest = () => {
// storedPolicy.team_id is used for existing policies because selectedTeamId is subject to change
const team_id = storedPolicy?.team_id ?? undefined;
@ -275,6 +283,7 @@ const QueryEditor = ({
onClickAutofillDescription={onClickAutofillDescription}
onClickAutofillResolution={onClickAutofillResolution}
resetAiAutofillData={() => setPolicyAutofillData(null)}
currentAutomatedPolicies={currentAutomatedPolicies || []}
/>
</div>
);

View file

@ -36,7 +36,7 @@ import PlatformCell from "components/TableContainer/DataTable/PlatformCell";
import TextCell from "components/TableContainer/DataTable/TextCell";
import PerformanceImpactCell from "components/TableContainer/DataTable/PerformanceImpactCell";
import TooltipWrapper from "components/TooltipWrapper";
import InheritedBadge from "components/InheritedBadge";
import PillBadge from "components/PillBadge";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import { HumanTimeDiffWithDateTip } from "components/HumanTimeDiffWithDateTip";
@ -171,7 +171,10 @@ const generateColumnConfigs = ({
{viewingTeamScope &&
// inherited
team_id !== currentTeamId && (
<InheritedBadge tooltipContent="This report runs on all hosts." />
<PillBadge
text="Inherited"
tipContent="This report runs on all hosts."
/>
)}
</>
}

View file

@ -9,7 +9,7 @@ import {
encodeScriptBase64,
SCRIPTS_ENCODED_HEADER,
} from "utilities/scripts_encoding";
import {
import software, {
ISoftwareResponse,
ISoftwareCountResponse,
ISoftwareVersion,

View file

@ -18,6 +18,7 @@ interface IPoliciesApiQueryParams {
orderKey?: string;
orderDirection?: "asc" | "desc";
query?: string;
automationType?: string;
}
export interface IPoliciesApiParams extends IPoliciesApiQueryParams {
@ -67,6 +68,8 @@ export default {
software_title_id,
labels_include_any,
labels_exclude_any,
type,
patch_software_title_id,
// note absence of automations-related fields, which are only set by the UI via update
} = data;
const { TEAMS } = endpoints;
@ -82,6 +85,8 @@ export default {
software_title_id,
labels_include_any,
labels_exclude_any,
type,
patch_software_title_id,
});
},
// TODO - response type Promise<IPolicy>
@ -151,6 +156,7 @@ export default {
orderDirection: orderDir = ORDER_DIRECTION,
query,
mergeInherited,
automationType,
}: IPoliciesApiParams): Promise<ILoadTeamPoliciesResponse> => {
const { TEAMS } = endpoints;
@ -161,6 +167,7 @@ export default {
orderDirection: orderDir,
query,
mergeInherited,
automationType,
};
const snakeCaseParams = convertParamsToSnakeCase(queryParams);

View file

@ -45,17 +45,6 @@ $max-width: 2560px;
padding: $pad-xxlarge 0 54px 0;
}
@mixin grey-badge {
background-color: $ui-fleet-black-25;
color: $core-fleet-white;
font-size: $xx-small;
font-weight: $bold;
padding: 0 $pad-xsmall;
border-radius: $border-radius;
position: relative;
margin-left: $pad-xsmall;
}
// Used to create a list item with the item data (name, created at, etc...) on
// the left and the item actions (download, delete, etc...) on the right.
@mixin list-item {

View file

@ -29,3 +29,26 @@ func (s *StringOr[T]) UnmarshalJSON(data []byte) error {
s.IsOther = true
return json.Unmarshal(data, &s.Other)
}
// BoolOr is a JSON value that can be a boolean or a different type of object
type BoolOr[T any] struct {
Bool bool
Other T
IsOther bool
}
func (s BoolOr[T]) MarshalJSON() ([]byte, error) {
if s.IsOther {
return json.Marshal(s.Other)
}
return json.Marshal(s.Bool)
}
func (s *BoolOr[T]) UnmarshalJSON(data []byte) error {
if bytes.Equal(data, []byte(`true`)) || bytes.Equal(data, []byte(`false`)) {
s.IsOther = false
return json.Unmarshal(data, &s.Bool)
}
s.IsOther = true
return json.Unmarshal(data, &s.Other)
}

View file

@ -135,3 +135,120 @@ func TestStringOr(t *testing.T) {
})
}
}
func TestBoolOr(t *testing.T) {
type child struct {
Name string `json:"name"`
}
type target struct {
Field BoolOr[*child] `json:"field"`
Array []BoolOr[*child] `json:"array"`
}
type nested struct {
Inception BoolOr[*target] `json:"inception"`
}
cases := []struct {
name string
getVar func() any
src string // json source to unmarshal into the value returned by getVar
marshalAs string // how the value should marshal back to json
unmarshalErr string // if non-empty, unmarshal should fail with this error
}{
{
name: "simple bool",
getVar: func() any { var s BoolOr[int]; return &s },
src: `true`,
marshalAs: `true`,
},
{
name: "simple integer",
getVar: func() any { var s BoolOr[int]; return &s },
src: `123`,
marshalAs: `123`,
},
{
name: "field bool",
getVar: func() any { var s target; return &s },
src: `{"field":true}`,
marshalAs: `{"field":true, "array": null}`,
},
{
name: "field object",
getVar: func() any { var s target; return &s },
src: `{"field":{"name": "field object"}}`,
marshalAs: `{"field":{"name": "field object"}, "array": null}`,
},
{
name: "field empty object",
getVar: func() any { var s target; return &s },
src: `{"field":{}}`,
marshalAs: `{"field":{"name": ""}, "array": null}`,
},
{
name: "field invalid array",
getVar: func() any { var s target; return &s },
src: `{"field":[]}`,
unmarshalErr: "cannot unmarshal array into Go struct field target.field of type optjson.child",
},
{
name: "array field null",
getVar: func() any { var s target; return &s },
src: `{"array":null}`,
marshalAs: `{"array":null, "field": false}`,
},
{
name: "array field empty",
getVar: func() any { var s target; return &s },
src: `{"array":[]}`,
marshalAs: `{"array":[], "field": false}`,
},
{
name: "array field single",
getVar: func() any { var s target; return &s },
src: `{"array":[{"name": "array child"}]}`,
marshalAs: `{"array":[{"name": "array child"}], "field": false}`,
},
{
name: "array field empty child",
getVar: func() any { var s target; return &s },
src: `{"array":[{}]}`,
marshalAs: `{"array":[{"name":""}], "field": false}`,
},
{
name: "inception bool",
getVar: func() any { var s nested; return &s },
src: `{"inception":true}`,
marshalAs: `{"inception":true}`,
},
{
name: "inception target field",
getVar: func() any { var s nested; return &s },
src: `{"inception":{"field":{"name":""}}}`,
marshalAs: `{"inception":{"field":{"name": ""}, "array": null}}`,
},
{
name: "inception target field and array",
getVar: func() any { var s nested; return &s },
src: `{"inception":{"field":{"name":"inception field"}, "array": [{"name": "x"}]}}`,
marshalAs: `{"inception":{"field":{"name":"inception field"}, "array": [{"name": "x"}]}}`,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
target := c.getVar()
err := json.Unmarshal([]byte(c.src), target)
if c.unmarshalErr != "" {
require.ErrorContains(t, err, c.unmarshalErr)
return
}
require.NoError(t, err)
data, err := json.Marshal(target)
require.NoError(t, err)
require.JSONEq(t, c.marshalAs, string(data))
})
}
}

View file

@ -14,6 +14,7 @@ import (
"unicode"
"github.com/bmatcuk/doublestar/v4"
"github.com/fleetdm/fleet/v4/pkg/optjson"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/ghodss/yaml"
@ -207,8 +208,8 @@ type Policy struct {
type GitOpsPolicySpec struct {
fleet.PolicySpec
RunScript *PolicyRunScript `json:"run_script"`
InstallSoftware *PolicyInstallSoftware `json:"install_software"`
RunScript *PolicyRunScript `json:"run_script"`
InstallSoftware optjson.BoolOr[*PolicyInstallSoftware] `json:"install_software"`
// InstallSoftwareURL is populated after parsing the software installer yaml
// referenced by InstallSoftware.PackagePath.
InstallSoftwareURL string `json:"-"`
@ -1363,6 +1364,12 @@ func parsePolicies(top map[string]json.RawMessage, result *GitOps, baseDir strin
if err := json.Unmarshal(policiesRaw, &policies); err != nil {
return multierror.Append(multiError, MaybeParseTypeError(filePath, []string{"policies"}, err))
}
// make an index of all FMAs by slug
fmasBySlug := make(map[string]struct{}, len(result.Software.FleetMaintainedApps))
for _, s := range result.Software.FleetMaintainedApps {
fmasBySlug[s.Slug] = struct{}{}
}
var errs []error
if policies, errs = expandBaseItems(policies, baseDir, "policy", GlobExpandOptions{
LogFn: logFn,
@ -1432,9 +1439,24 @@ func parsePolicies(top map[string]json.RawMessage, result *GitOps, baseDir strin
} else {
item.Name = norm.NFC.String(item.Name)
}
if item.Query == "" {
if item.Type == "" {
item.Type = fleet.PolicyTypeDynamic
}
if item.Query == "" && item.Type != fleet.PolicyTypePatch {
multiError = multierror.Append(multiError, errors.New("policy query is required for each policy"))
}
if item.Type == fleet.PolicyTypePatch {
if _, ok := fmasBySlug[item.FleetMaintainedAppSlug]; !ok {
multiError = multierror.Append(
multiError,
fmt.Errorf(
`Couldn't apply "%s": "%s" is specified in the patch policy, but it isn't specified under "software.fleet_maintained_apps."`,
filepath.Base(parentFilePath),
item.FleetMaintainedAppSlug,
),
)
}
}
if result.TeamName != nil {
item.Team = *result.TeamName
} else {
@ -1495,7 +1517,8 @@ func parsePolicyRunScript(baseDir string, parentFilePath string, teamName *strin
}
func parsePolicyInstallSoftware(baseDir string, teamName *string, policy *Policy, packages []*fleet.SoftwarePackageSpec, appStoreApps []*fleet.TeamSpecAppStoreApp) []error {
if policy.InstallSoftware == nil {
installSoftwareObj := policy.InstallSoftware.Other
if installSoftwareObj == nil {
policy.SoftwareTitleID = ptr.Uint(0) // unset the installer
return nil
}
@ -1506,43 +1529,43 @@ func parsePolicyInstallSoftware(baseDir string, teamName *string, policy *Policy
wrapErrs := func(err error) []error {
return []error{wrapErr(err)}
}
if policy.InstallSoftware != nil && (policy.InstallSoftware.PackagePath != "" || policy.InstallSoftware.AppStoreID != "") && teamName == nil {
if (installSoftwareObj.PackagePath != "" || installSoftwareObj.AppStoreID != "") && teamName == nil {
return wrapErrs(errors.New("install_software can only be set on team policies"))
}
if policy.InstallSoftware.PackagePath == "" && policy.InstallSoftware.AppStoreID == "" && policy.InstallSoftware.HashSHA256 == "" {
if installSoftwareObj.PackagePath == "" && installSoftwareObj.AppStoreID == "" && installSoftwareObj.HashSHA256 == "" {
return wrapErrs(errors.New("install_software must include either a package_path, an app_store_id or a hash_sha256"))
}
if policy.InstallSoftware.PackagePath != "" && policy.InstallSoftware.AppStoreID != "" {
if installSoftwareObj.PackagePath != "" && installSoftwareObj.AppStoreID != "" {
return wrapErrs(errors.New("install_software must have only one of package_path or app_store_id"))
}
var errs []error
if policy.InstallSoftware.PackagePath != "" {
fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, policy.InstallSoftware.PackagePath))
if installSoftwareObj.PackagePath != "" {
fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, installSoftwareObj.PackagePath))
if err != nil {
return wrapErrs(fmt.Errorf("failed to read install_software.package_path file %q: %v", policy.InstallSoftware.PackagePath, err))
return wrapErrs(fmt.Errorf("failed to read install_software.package_path file %q: %v", installSoftwareObj.PackagePath, err))
}
// Replace $var and ${var} with env values.
fileBytes, err = ExpandEnvBytes(fileBytes)
if err != nil {
return wrapErrs(fmt.Errorf("failed to expand environment in file %q: %v", policy.InstallSoftware.PackagePath, err))
return wrapErrs(fmt.Errorf("failed to expand environment in file %q: %v", installSoftwareObj.PackagePath, err))
}
var policyInstallSoftwareSpec fleet.SoftwarePackageSpec
if err := YamlUnmarshal(fileBytes, &policyInstallSoftwareSpec); err != nil {
// see if the issue is that a package path was passed in that references multiple packages
var multiplePackages []fleet.SoftwarePackageSpec
if err := YamlUnmarshal(fileBytes, &multiplePackages); err != nil || len(multiplePackages) == 0 {
return wrapErrs(fmt.Errorf("file %q does not contain a valid software package definition", policy.InstallSoftware.PackagePath))
return wrapErrs(fmt.Errorf("file %q does not contain a valid software package definition", installSoftwareObj.PackagePath))
}
if len(multiplePackages) > 1 {
return wrapErrs(fmt.Errorf("file %q contains multiple packages, so cannot be used as a target for policy automation", policy.InstallSoftware.PackagePath))
return wrapErrs(fmt.Errorf("file %q contains multiple packages, so cannot be used as a target for policy automation", installSoftwareObj.PackagePath))
}
errs = append(errs, validateYAMLKeys(fileBytes, reflect.TypeFor[[]fleet.SoftwarePackageSpec](), policy.InstallSoftware.PackagePath, []string{"software", "packages"})...)
errs = append(errs, validateYAMLKeys(fileBytes, reflect.TypeFor[[]fleet.SoftwarePackageSpec](), installSoftwareObj.PackagePath, []string{"software", "packages"})...)
policyInstallSoftwareSpec = multiplePackages[0]
} else {
errs = append(errs, validateYAMLKeys(fileBytes, reflect.TypeFor[fleet.SoftwarePackageSpec](), policy.InstallSoftware.PackagePath, []string{"software", "packages"})...)
errs = append(errs, validateYAMLKeys(fileBytes, reflect.TypeFor[fleet.SoftwarePackageSpec](), installSoftwareObj.PackagePath, []string{"software", "packages"})...)
}
installerOnTeamFound := false
for _, pkg := range packages {
@ -1553,27 +1576,27 @@ func parsePolicyInstallSoftware(baseDir string, teamName *string, policy *Policy
}
if !installerOnTeamFound {
if policyInstallSoftwareSpec.URL != "" {
errs = append(errs, wrapErr(fmt.Errorf("install_software.package_path URL %s not found on team: %s", policyInstallSoftwareSpec.URL, policy.InstallSoftware.PackagePath)))
errs = append(errs, wrapErr(fmt.Errorf("install_software.package_path URL %s not found on team: %s", policyInstallSoftwareSpec.URL, installSoftwareObj.PackagePath)))
} else {
errs = append(errs, wrapErr(fmt.Errorf("install_software.package_path SHA256 %s not found on team: %s", policyInstallSoftwareSpec.SHA256, policy.InstallSoftware.PackagePath)))
errs = append(errs, wrapErr(fmt.Errorf("install_software.package_path SHA256 %s not found on team: %s", policyInstallSoftwareSpec.SHA256, installSoftwareObj.PackagePath)))
}
return errs
}
policy.InstallSoftwareURL = policyInstallSoftwareSpec.URL
policy.InstallSoftware.HashSHA256 = policyInstallSoftwareSpec.SHA256
policy.InstallSoftware.Other.HashSHA256 = policyInstallSoftwareSpec.SHA256
}
if policy.InstallSoftware.AppStoreID != "" {
if policy.InstallSoftware.Other.AppStoreID != "" {
appOnTeamFound := false
for _, app := range appStoreApps {
if app.AppStoreID == policy.InstallSoftware.AppStoreID {
if app.AppStoreID == policy.InstallSoftware.Other.AppStoreID {
appOnTeamFound = true
break
}
}
if !appOnTeamFound {
errs = append(errs, wrapErr(fmt.Errorf("install_software.app_store_id %s not found on team %s", policy.InstallSoftware.AppStoreID, *teamName)))
errs = append(errs, wrapErr(fmt.Errorf("install_software.app_store_id %s not found on team %s", policy.InstallSoftware.Other.AppStoreID, *teamName)))
}
}

View file

@ -10,6 +10,7 @@ import (
"testing"
"github.com/fleetdm/fleet/v4/pkg/file"
"github.com/fleetdm/fleet/v4/pkg/optjson"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/ghodss/yaml"
@ -378,15 +379,15 @@ func TestValidGitOpsYaml(t *testing.T) {
assert.NotNil(t, gitops.Policies[5].InstallSoftware)
if name == "team_config_with_paths_and_only_sha256" {
assert.Equal(t, "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", gitops.Policies[5].InstallSoftware.HashSHA256)
assert.Equal(t, "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", gitops.Policies[5].InstallSoftware.Other.HashSHA256)
} else {
assert.Equal(t, "./microsoft-teams.pkg.software.yml", gitops.Policies[5].InstallSoftware.PackagePath)
assert.Equal(t, "./microsoft-teams.pkg.software.yml", gitops.Policies[5].InstallSoftware.Other.PackagePath)
assert.Equal(t, "https://statics.teams.cdn.office.net/production-osx/enterprise/webview2/lkg/MicrosoftTeams.pkg", gitops.Policies[5].InstallSoftwareURL)
}
assert.Equal(t, "Slack on macOS is installed", gitops.Policies[6].Name)
assert.NotNil(t, gitops.Policies[6].InstallSoftware)
assert.Equal(t, "123456", gitops.Policies[6].InstallSoftware.AppStoreID)
assert.Equal(t, "123456", gitops.Policies[6].InstallSoftware.Other.AppStoreID)
assert.Equal(t, "Script run policy", gitops.Policies[7].Name)
assert.NotNil(t, gitops.Policies[7].RunScript)
@ -3200,12 +3201,14 @@ func TestParsePolicyInstallSoftware(t *testing.T) {
t.Run("wrapErrs prefixes errors", func(t *testing.T) {
t.Parallel()
var installSoftware optjson.BoolOr[*PolicyInstallSoftware]
installSoftware.Other = &PolicyInstallSoftware{}
policy := &Policy{
GitOpsPolicySpec: GitOpsPolicySpec{
PolicySpec: fleet.PolicySpec{Name: "my policy"},
InstallSoftware: &PolicyInstallSoftware{
// no package_path, app_store_id, or hash_sha256
},
InstallSoftware: installSoftware, // no package_path, app_store_id, or hash_sha256
},
}
errs := parsePolicyInstallSoftware(".", &teamName, policy, nil, nil)
@ -3221,12 +3224,13 @@ func TestParsePolicyInstallSoftware(t *testing.T) {
path := filepath.Join(dir, "pkg.yml")
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
var installSoftware optjson.BoolOr[*PolicyInstallSoftware]
installSoftware.Other = &PolicyInstallSoftware{PackagePath: path}
policy := &Policy{
GitOpsPolicySpec: GitOpsPolicySpec{
PolicySpec: fleet.PolicySpec{Name: "typo policy"},
InstallSoftware: &PolicyInstallSoftware{
PackagePath: path,
},
PolicySpec: fleet.PolicySpec{Name: "typo policy"},
InstallSoftware: installSoftware,
},
}
packages := []*fleet.SoftwarePackageSpec{{SHA256: sha}}

View file

@ -80,6 +80,10 @@ func collectFields(t reflect.Type, keys map[string]fieldInfo) {
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if strings.Contains(t.Name(), "BoolOr") && field.Name == "Other" {
collectFields(field.Type, keys)
}
// Handle embedded structs: inline their fields.
if field.Anonymous {
ft := field.Type

View file

@ -388,6 +388,12 @@ func TestValidateRawKeys(t *testing.T) {
require.Len(t, errs, 1)
assert.Contains(t, errs[0].Error(), "invalid")
})
t.Run("bool or", func(t *testing.T) {
raw := []byte(`{"install_software": {"package_path": "./lib/ruby.yml"}}`)
errs := validateRawKeys(raw, reflect.TypeFor[GitOpsPolicySpec](), "test.yml", []string{"policies"})
assert.Empty(t, errs)
})
}
func TestFilterWarnings(t *testing.T) {

View file

@ -3615,7 +3615,7 @@ func (ds *Datastore) ListPoliciesForHost(ctx context.Context, host *fleet.Host)
// We log to help troubleshooting in case this happens.
ds.logger.ErrorContext(ctx, "unrecognized platform", "hostID", host.ID, "platform", host.Platform)
}
query := `SELECT p.id, p.team_id, p.resolution, p.name, p.query, p.description, p.author_id, p.platforms, p.critical, p.created_at, p.updated_at, p.conditional_access_enabled,
query := `SELECT p.id, p.team_id, p.resolution, p.name, p.query, p.description, p.author_id, p.platforms, p.critical, p.created_at, p.updated_at, p.conditional_access_enabled, p.type,
COALESCE(u.name, '<deleted>') AS author_name,
COALESCE(u.email, '') AS author_email,
CASE

View file

@ -0,0 +1,35 @@
package tables
import (
"database/sql"
)
func init() {
MigrationClient.AddMigration(Up_20260313173516, Down_20260313173516)
}
func Up_20260313173516(tx *sql.Tx) error {
return withSteps([]migrationStep{
basicMigrationStep(
`ALTER TABLE policies ADD COLUMN type ENUM('dynamic', 'patch') NOT NULL DEFAULT 'dynamic'`,
"adding type column to policies table",
),
basicMigrationStep(
`ALTER TABLE policies ADD COLUMN patch_software_title_id INT UNSIGNED DEFAULT NULL`,
"adding patch_software_title_id column to policies table",
),
basicMigrationStep(
`ALTER TABLE policies ADD CONSTRAINT fk_patch_software_title_id
FOREIGN KEY (patch_software_title_id) REFERENCES software_titles(id) ON DELETE CASCADE`,
"adding patch_software_title_id foreign key to policies table",
),
basicMigrationStep(
`ALTER TABLE policies ADD UNIQUE INDEX idx_team_id_patch_software_title_id (team_id, patch_software_title_id)`,
"adding (team_id, patch_software_title_id) unique index to policies table",
),
}, tx)
}
func Down_20260313173516(tx *sql.Tx) error {
return nil
}

Some files were not shown because too many files have changed in this diff Show more