mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
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:
parent
ca89b035ac
commit
2abacc577e
125 changed files with 3695 additions and 1075 deletions
1
changes/31914-patch-policy
Normal file
1
changes/31914-patch-policy
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Added patch policies for Fleet-maintained apps that automatically update when the app is updated.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from "./InheritedBadge";
|
||||
29
frontend/components/PillBadge/PillBadge.tsx
Normal file
29
frontend/components/PillBadge/PillBadge.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
1
frontend/components/PillBadge/index.ts
Normal file
1
frontend/components/PillBadge/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./PillBadge";
|
||||
|
|
@ -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'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;
|
||||
1
frontend/components/SoftwareInstallPolicyBadges/index.ts
Normal file
1
frontend/components/SoftwareInstallPolicyBadges/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./SoftwareInstallPolicyBadges";
|
||||
|
|
@ -322,6 +322,7 @@ export const generateCustomDropdownStyles = (
|
|||
|
||||
return {
|
||||
...provided,
|
||||
fontSize: "13px",
|
||||
...(variant === "button" && buttonVariantPlaceholder),
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
15
frontend/components/graphics/Calendar.tsx
Normal file
15
frontend/components/graphics/Calendar.tsx
Normal 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;
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
23
frontend/components/graphics/Lock.tsx
Normal file
23
frontend/components/graphics/Lock.tsx
Normal 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;
|
||||
15
frontend/components/graphics/Settings.tsx
Normal file
15
frontend/components/graphics/Settings.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 || "")}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,3 @@
|
|||
margin-top: $pad-xxxlarge;
|
||||
}
|
||||
}
|
||||
|
||||
.info-banner {
|
||||
margin-bottom: $pad-xlarge;
|
||||
}
|
||||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -62,12 +62,7 @@
|
|||
gap: $pad-small;
|
||||
}
|
||||
|
||||
.info-banner {
|
||||
margin-top: $pad-small;
|
||||
}
|
||||
|
||||
.form-fields--disabled {
|
||||
@include disabled;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 > Manage automations > 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;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.add-patch-policy-modal {
|
||||
overflow-wrap: anywhere; // Prevent long software name overflow
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./AddPatchPolicyModal";
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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's{" "}
|
||||
<CustomLink
|
||||
text="Google Play URL"
|
||||
url={`${LEARN_MORE_ABOUT_BASE_LINK}/google-play-store`}
|
||||
newTab
|
||||
/>{" "}
|
||||
E.g. "com.android.chrome" from
|
||||
"https://play.google.com/store/apps/details?id=com.android.chrome"
|
||||
</>
|
||||
}
|
||||
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's{" "}
|
||||
<CustomLink
|
||||
text="Google Play URL"
|
||||
url={`${LEARN_MORE_ABOUT_BASE_LINK}/google-play-store`}
|
||||
newTab
|
||||
/>{" "}
|
||||
E.g. "com.android.chrome" from
|
||||
"https://play.google.com/store/apps/details?id=com.android.chrome"
|
||||
</>
|
||||
}
|
||||
onChange={onInputChange}
|
||||
name="applicationID"
|
||||
value={formData.applicationID}
|
||||
parseTarget
|
||||
disabled={gitOpsModeEnabled} // TODO: Confirm GitOps behavior
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<AndroidOptionsDescription />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
.software-deploy-slider {
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./SoftwareDeploySlider";
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'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'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'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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
.user-management {
|
||||
&__api-only-user {
|
||||
@include grey-badge;
|
||||
}
|
||||
|
||||
.data-table-block {
|
||||
.data-table__table {
|
||||
thead {
|
||||
|
|
|
|||
|
|
@ -556,6 +556,7 @@ describe("Device User Page", () => {
|
|||
critical: false,
|
||||
calendar_events_enabled: false,
|
||||
conditional_access_enabled: true,
|
||||
type: "dynamic",
|
||||
response: "fail",
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1122,7 +1122,6 @@ const HostDetailsPage = ({
|
|||
})
|
||||
);
|
||||
};
|
||||
|
||||
const navigateToSoftwareTab = (i: number): void => {
|
||||
const navPath = hostSoftwareSubNav[i].pathname;
|
||||
router.push(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
&__filter-automation-dropdown {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
&__manage-automations-wrapper {
|
||||
@include button-dropdown;
|
||||
.Select-multi-value-wrapper {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(", ");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./PolicyAutomations";
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
encodeScriptBase64,
|
||||
SCRIPTS_ENCODED_HEADER,
|
||||
} from "utilities/scripts_encoding";
|
||||
import {
|
||||
import software, {
|
||||
ISoftwareResponse,
|
||||
ISoftwareCountResponse,
|
||||
ISoftwareVersion,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue