mirror of
https://github.com/fleetdm/fleet
synced 2026-05-23 08:58:41 +00:00
Feature: software installers (#19050)
for https://github.com/fleetdm/fleet/issues/14921 # Checklist for submitter If some of the following don't apply, delete the relevant line. <!-- Note that API documentation changes are now addressed by the product design team. --> - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [x] Added/updated tests - [x] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [x] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [x] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [x] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [x] Manual QA for all new/changed functionality - For Orbit and Fleet Desktop changes: - [x] Manual QA must be performed in the three main OSs, macOS, Windows and Linux. - [x] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)).
This commit is contained in:
commit
c4923ffecd
264 changed files with 15280 additions and 2870 deletions
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -38,5 +38,6 @@
|
|||
"prettier.requireConfig": true,
|
||||
"yaml.schemas": {
|
||||
"https://json.schemastore.org/codecov.json": ".github/workflows/codecov.yml"
|
||||
}
|
||||
},
|
||||
"favorites.sortOrder": "ASC"
|
||||
}
|
||||
|
|
|
|||
4
changes/14921-software-installers-sg
Normal file
4
changes/14921-software-installers-sg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
- Added functionality to filter hosts by software installer status.
|
||||
- Added endpoints to upload, delete, and download software installers.
|
||||
- Added endpoints to get host software install results.
|
||||
- Updated activity feeds to include software installer activities.
|
||||
2
changes/17865-get-install-results
Normal file
2
changes/17865-get-install-results
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
- Adds the `/software/install/results/:install_uuid` endpoint, which can be used to get the results
|
||||
for a software install attempt.
|
||||
1
changes/18318-extract-metadata-from-installers
Normal file
1
changes/18318-extract-metadata-from-installers
Normal file
|
|
@ -0,0 +1 @@
|
|||
* Added support to extract package name and version from software installers.
|
||||
1
changes/18319-api-to-list-host-software
Normal file
1
changes/18319-api-to-list-host-software
Normal file
|
|
@ -0,0 +1 @@
|
|||
* Added the `GET /api/v1/fleet/hosts/{id}/software` (and corresponding token-authenticated endpoint for the "My device" page) to list the installed (and available for install) software for the host.
|
||||
1
changes/18325-add-software-installers-to-fleetctl
Normal file
1
changes/18325-add-software-installers-to-fleetctl
Normal file
|
|
@ -0,0 +1 @@
|
|||
* Added `software` team setting to add software installers in YAML files for `fleetctl apply` and `fleetctl gitops`.
|
||||
1
changes/18329-storage-for-software-installers
Normal file
1
changes/18329-storage-for-software-installers
Normal file
|
|
@ -0,0 +1 @@
|
|||
* Implemented an S3-based and local filesystem-based storage abstraction for software installers.
|
||||
1
changes/18330-global-activites
Normal file
1
changes/18330-global-activites
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Adds support to the global activity feed for "Added software" and "Deleted software" actions.
|
||||
1
changes/18673-cleanup-unused-software-installers
Normal file
1
changes/18673-cleanup-unused-software-installers
Normal file
|
|
@ -0,0 +1 @@
|
|||
* Added a `cron` job to periodically remove unused software installers from the store.
|
||||
|
|
@ -0,0 +1 @@
|
|||
* Added the `POST /api/fleet/orbit/software_install/result` endpoint for fleetd to send results for a software installation attempt.
|
||||
1
changes/18772-add-software-installs-to-host-activities
Normal file
1
changes/18772-add-software-installs-to-host-activities
Normal file
|
|
@ -0,0 +1 @@
|
|||
* Added software installation to the host's upcoming and past activities.
|
||||
|
|
@ -0,0 +1 @@
|
|||
* Added the uninstalled but available software installers to the response payload of the "List software titles" endpoint (`GET /software/titles`).
|
||||
1
changes/issue-18326-ui-add-software
Normal file
1
changes/issue-18326-ui-add-software
Normal file
|
|
@ -0,0 +1 @@
|
|||
- add ability to upload software from the UI
|
||||
|
|
@ -0,0 +1 @@
|
|||
- udpates software page to support new add software feature.
|
||||
1
changes/jve-fix-script-typo
Normal file
1
changes/jve-fix-script-typo
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Fixes some typos that were in the Powershell scripts for installing Windows software.
|
||||
1
changes/jve-fix-software-package
Normal file
1
changes/jve-fix-software-package
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Adds a missing field `software_package` to the response from the List Software Titles endpoint.
|
||||
|
|
@ -708,6 +708,7 @@ func newCleanupsAndAggregationSchedule(
|
|||
enrollHostLimiter fleet.EnrollHostLimiter,
|
||||
config *config.FleetConfig,
|
||||
commander *apple_mdm.MDMAppleCommander,
|
||||
softwareInstallStore fleet.SoftwareInstallerStore,
|
||||
) (*schedule.Schedule, error) {
|
||||
const (
|
||||
name = string(fleet.CronCleanupsThenAggregation)
|
||||
|
|
@ -848,6 +849,9 @@ func newCleanupsAndAggregationSchedule(
|
|||
const maxCount = 5000
|
||||
return ds.CleanupActivitiesAndAssociatedData(ctx, maxCount, appConfig.ActivityExpirySettings.ActivityExpiryWindow)
|
||||
}),
|
||||
schedule.WithJob("cleanup_unused_software_installers", func(ctx context.Context) error {
|
||||
return ds.CleanupUnusedSoftwareInstallers(ctx, softwareInstallStore)
|
||||
}),
|
||||
)
|
||||
|
||||
return s, nil
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import (
|
|||
licensectx "github.com/fleetdm/fleet/v4/server/contexts/license"
|
||||
"github.com/fleetdm/fleet/v4/server/cron"
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/cached_mysql"
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/filesystem"
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/mysqlredis"
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/redis"
|
||||
|
|
@ -626,11 +627,33 @@ the way that the Fleet server works.
|
|||
initFatal(err, "initializing service")
|
||||
}
|
||||
|
||||
var softwareInstallStore fleet.SoftwareInstallerStore
|
||||
if license.IsPremium() {
|
||||
var profileMatcher fleet.ProfileMatcher
|
||||
if appCfg.MDM.EnabledAndConfigured {
|
||||
profileMatcher = apple_mdm.NewProfileMatcher(redisPool)
|
||||
}
|
||||
if config.S3.Bucket != "" {
|
||||
store, err := s3.NewSoftwareInstallerStore(config.S3)
|
||||
if err != nil {
|
||||
initFatal(err, "initializing S3 software installer store")
|
||||
}
|
||||
softwareInstallStore = store
|
||||
level.Info(logger).Log("msg", "using S3 software installer store", "bucket", config.S3.Bucket)
|
||||
} else {
|
||||
installerDir := os.TempDir()
|
||||
if dir := os.Getenv("FLEET_SOFTWARE_INSTALLER_STORE_DIR"); dir != "" {
|
||||
installerDir = dir
|
||||
}
|
||||
store, err := filesystem.NewSoftwareInstallerStore(installerDir)
|
||||
if err != nil {
|
||||
level.Error(logger).Log("err", err, "msg", "failed to configure local filesystem software installer store")
|
||||
softwareInstallStore = fleet.FailingSoftwareInstallerStore{}
|
||||
} else {
|
||||
softwareInstallStore = store
|
||||
level.Info(logger).Log("msg", "using local filesystem software installer store, this is not suitable for production use", "directory", installerDir)
|
||||
}
|
||||
}
|
||||
|
||||
svc, err = eeservice.NewService(
|
||||
svc,
|
||||
|
|
@ -644,6 +667,7 @@ the way that the Fleet server works.
|
|||
mdmPushCertTopic,
|
||||
ssoSessionStore,
|
||||
profileMatcher,
|
||||
softwareInstallStore,
|
||||
)
|
||||
if err != nil {
|
||||
initFatal(err, "initial Fleet Premium service")
|
||||
|
|
@ -700,7 +724,7 @@ the way that the Fleet server works.
|
|||
commander = apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService, config.MDM)
|
||||
}
|
||||
return newCleanupsAndAggregationSchedule(
|
||||
ctx, instanceID, ds, logger, redisWrapperDS, &config, commander,
|
||||
ctx, instanceID, ds, logger, redisWrapperDS, &config, commander, softwareInstallStore,
|
||||
)
|
||||
},
|
||||
); err != nil {
|
||||
|
|
|
|||
|
|
@ -623,9 +623,9 @@ func TestGetSoftwareTitles(t *testing.T) {
|
|||
|
||||
var gotTeamID *uint
|
||||
|
||||
ds.ListSoftwareTitlesFunc = func(ctx context.Context, opt fleet.SoftwareTitleListOptions, tmFilter fleet.TeamFilter) ([]fleet.SoftwareTitle, int, *fleet.PaginationMetadata, error) {
|
||||
ds.ListSoftwareTitlesFunc = func(ctx context.Context, opt fleet.SoftwareTitleListOptions, tmFilter fleet.TeamFilter) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) {
|
||||
gotTeamID = opt.TeamID
|
||||
return []fleet.SoftwareTitle{
|
||||
return []fleet.SoftwareTitleListResult{
|
||||
{
|
||||
Name: "foo",
|
||||
Source: "chrome_extensions",
|
||||
|
|
@ -677,6 +677,7 @@ spec:
|
|||
- hosts_count: 2
|
||||
id: 0
|
||||
name: foo
|
||||
software_package: null
|
||||
source: chrome_extensions
|
||||
versions:
|
||||
- id: 0
|
||||
|
|
@ -696,6 +697,7 @@ spec:
|
|||
- hosts_count: 0
|
||||
id: 0
|
||||
name: bar
|
||||
software_package: null
|
||||
source: deb_packages
|
||||
versions:
|
||||
- id: 0
|
||||
|
|
@ -738,7 +740,8 @@ spec:
|
|||
"cve-123-456-003"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"software_package": null
|
||||
},
|
||||
{
|
||||
"id": 0,
|
||||
|
|
@ -752,7 +755,8 @@ spec:
|
|||
"version": "0.0.3",
|
||||
"vulnerabilities": null
|
||||
}
|
||||
]
|
||||
],
|
||||
"software_package": null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -2231,6 +2235,9 @@ func TestGetTeamsYAMLAndApply(t *testing.T) {
|
|||
declaration.DeclarationUUID = uuid.NewString()
|
||||
return declaration, nil
|
||||
}
|
||||
ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
actualYaml := runAppForTest(t, []string{"get", "teams", "--yaml"})
|
||||
yamlFilePath := writeTmpYml(t, actualYaml)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ package main
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
|
@ -20,7 +23,11 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const teamName = "Team Test"
|
||||
const (
|
||||
teamName = "Team Test"
|
||||
fleetServerURL = "https://fleet.example.com"
|
||||
orgName = "GitOps Test"
|
||||
)
|
||||
|
||||
func TestBasicGlobalGitOps(t *testing.T) {
|
||||
// Cannot run t.Parallel() because it sets environment variables
|
||||
|
|
@ -196,6 +203,9 @@ func TestBasicTeamGitOps(t *testing.T) {
|
|||
ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error {
|
||||
return nil
|
||||
}
|
||||
ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var enrolledSecrets []*fleet.EnrollSecret
|
||||
ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
|
||||
|
|
@ -549,6 +559,9 @@ func TestFullTeamGitOps(t *testing.T) {
|
|||
appliedQueries = queries
|
||||
return nil
|
||||
}
|
||||
ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var enrolledSecrets []*fleet.EnrollSecret
|
||||
ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
|
||||
|
|
@ -736,6 +749,9 @@ func TestBasicGlobalAndTeamGitOps(t *testing.T) {
|
|||
declaration.DeclarationUUID = uuid.NewString()
|
||||
return declaration, nil
|
||||
}
|
||||
ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
|
||||
require.NoError(t, err)
|
||||
|
|
@ -850,6 +866,123 @@ team_settings:
|
|||
func TestFullGlobalAndTeamGitOps(t *testing.T) {
|
||||
// Cannot run t.Parallel() because it sets environment variables
|
||||
// mdm test configuration must be set so that activating windows MDM works.
|
||||
ds, savedAppConfigPtr, savedTeamPtr := setupFullGitOpsPremiumServer(t)
|
||||
|
||||
var enrolledSecrets []*fleet.EnrollSecret
|
||||
var enrolledTeamSecrets []*fleet.EnrollSecret
|
||||
var appliedPolicySpecs []*fleet.PolicySpec
|
||||
var appliedQueries []*fleet.Query
|
||||
|
||||
ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
|
||||
if teamID == nil {
|
||||
enrolledSecrets = secrets
|
||||
} else {
|
||||
enrolledTeamSecrets = secrets
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ds.ApplyPolicySpecsFunc = func(ctx context.Context, authorID uint, specs []*fleet.PolicySpec) error {
|
||||
appliedPolicySpecs = specs
|
||||
return nil
|
||||
}
|
||||
ds.ApplyQueriesFunc = func(
|
||||
ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]struct{},
|
||||
) error {
|
||||
appliedQueries = queries
|
||||
return nil
|
||||
}
|
||||
ds.NewTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) {
|
||||
team.ID = 1
|
||||
*savedTeamPtr = team
|
||||
enrolledTeamSecrets = team.Secrets
|
||||
return *savedTeamPtr, nil
|
||||
}
|
||||
|
||||
globalFile := "./testdata/gitops/global_config_no_paths.yml"
|
||||
teamFile := "./testdata/gitops/team_config_no_paths.yml"
|
||||
|
||||
// Dry run on global file should fail because Apple BM Default Team does not exist (and has not been provided)
|
||||
_, err := runAppNoChecks([]string{"gitops", "-f", globalFile, "--dry-run"})
|
||||
require.Error(t, err)
|
||||
assert.True(t, strings.Contains(err.Error(), "team name not found"))
|
||||
|
||||
// Dry run
|
||||
_ = runAppForTest(t, []string{"gitops", "-f", globalFile, "-f", teamFile, "--dry-run", "--delete-other-teams"})
|
||||
assert.False(t, ds.SaveAppConfigFuncInvoked)
|
||||
assert.Len(t, enrolledSecrets, 0)
|
||||
assert.Len(t, enrolledTeamSecrets, 0)
|
||||
assert.Len(t, appliedPolicySpecs, 0)
|
||||
assert.Len(t, appliedQueries, 0)
|
||||
|
||||
// Real run
|
||||
_ = runAppForTest(t, []string{"gitops", "-f", globalFile, "-f", teamFile, "--delete-other-teams"})
|
||||
assert.Equal(t, orgName, (*savedAppConfigPtr).OrgInfo.OrgName)
|
||||
assert.Equal(t, fleetServerURL, (*savedAppConfigPtr).ServerSettings.ServerURL)
|
||||
assert.Len(t, enrolledSecrets, 2)
|
||||
require.NotNil(t, *savedTeamPtr)
|
||||
assert.Equal(t, teamName, (*savedTeamPtr).Name)
|
||||
require.Len(t, enrolledTeamSecrets, 2)
|
||||
}
|
||||
|
||||
func TestTeamSofwareInstallersGitOps(t *testing.T) {
|
||||
// start the web server that will serve the installer
|
||||
b, err := os.ReadFile(filepath.Join("..", "..", "server", "service", "testdata", "software-installers", "ruby.deb"))
|
||||
require.NoError(t, err)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "notfound"):
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
case strings.HasSuffix(r.URL.Path, ".txt"):
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte(`a simple text file`))
|
||||
return
|
||||
case strings.Contains(r.URL.Path, "toolarge"):
|
||||
w.Header().Set("Content-Type", "application/vnd.debian.binary-package")
|
||||
var sz int
|
||||
for sz < 500*1024*1024 {
|
||||
n, _ := w.Write(b)
|
||||
sz += n
|
||||
}
|
||||
default:
|
||||
w.Header().Set("Content-Type", "application/vnd.debian.binary-package")
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
t.Setenv("SOFTWARE_INSTALLER_URL", srv.URL)
|
||||
|
||||
cases := []struct {
|
||||
file string
|
||||
wantErr string
|
||||
}{
|
||||
{"testdata/gitops/team_software_installer_not_found.yml", "Please make sure that URLs are publicy accessible to the internet."},
|
||||
{"testdata/gitops/team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe or .deb."},
|
||||
{"testdata/gitops/team_software_installer_too_large.yml", "The maximum file size is 500 MB"},
|
||||
{"testdata/gitops/team_software_installer_valid.yml", ""},
|
||||
{"testdata/gitops/team_software_installer_pre_condition_multiple_queries.yml", "should have only one query."},
|
||||
{"testdata/gitops/team_software_installer_pre_condition_not_found.yml", "no such file or directory"},
|
||||
{"testdata/gitops/team_software_installer_install_not_found.yml", "no such file or directory"},
|
||||
{"testdata/gitops/team_software_installer_post_install_not_found.yml", "no such file or directory"},
|
||||
{"testdata/gitops/team_software_installer_no_url.yml", "software URL is required"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(filepath.Base(c.file), func(t *testing.T) {
|
||||
setupFullGitOpsPremiumServer(t)
|
||||
|
||||
_, err := runAppNoChecks([]string{"gitops", "-f", c.file})
|
||||
if c.wantErr == "" {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.ErrorContains(t, err, c.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, **fleet.Team) {
|
||||
testCert, testKey, err := apple_mdm.NewSCEPCACertKey()
|
||||
require.NoError(t, err)
|
||||
testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
|
||||
|
|
@ -884,32 +1017,17 @@ func TestFullGlobalAndTeamGitOps(t *testing.T) {
|
|||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
fleetServerURL = "https://fleet.example.com"
|
||||
orgName = "GitOps Test"
|
||||
)
|
||||
var enrolledSecrets []*fleet.EnrollSecret
|
||||
var enrolledTeamSecrets []*fleet.EnrollSecret
|
||||
var appliedPolicySpecs []*fleet.PolicySpec
|
||||
var appliedQueries []*fleet.Query
|
||||
var savedTeam *fleet.Team
|
||||
|
||||
ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
|
||||
if teamID == nil {
|
||||
enrolledSecrets = secrets
|
||||
} else {
|
||||
enrolledTeamSecrets = secrets
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ds.ApplyPolicySpecsFunc = func(ctx context.Context, authorID uint, specs []*fleet.PolicySpec) error {
|
||||
appliedPolicySpecs = specs
|
||||
return nil
|
||||
}
|
||||
ds.ApplyQueriesFunc = func(
|
||||
ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]struct{},
|
||||
) error {
|
||||
appliedQueries = queries
|
||||
return nil
|
||||
}
|
||||
ds.BatchSetMDMProfilesFunc = func(
|
||||
|
|
@ -957,7 +1075,6 @@ func TestFullGlobalAndTeamGitOps(t *testing.T) {
|
|||
ds.NewTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) {
|
||||
team.ID = 1
|
||||
savedTeam = team
|
||||
enrolledTeamSecrets = team.Secrets
|
||||
return savedTeam, nil
|
||||
}
|
||||
ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string) (*fleet.Query, error) {
|
||||
|
|
@ -985,35 +1102,14 @@ func TestFullGlobalAndTeamGitOps(t *testing.T) {
|
|||
declaration.DeclarationUUID = uuid.NewString()
|
||||
return declaration, nil
|
||||
}
|
||||
ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
t.Setenv("FLEET_SERVER_URL", fleetServerURL)
|
||||
t.Setenv("ORG_NAME", orgName)
|
||||
t.Setenv("TEST_TEAM_NAME", teamName)
|
||||
t.Setenv("APPLE_BM_DEFAULT_TEAM", teamName)
|
||||
|
||||
globalFile := "./testdata/gitops/global_config_no_paths.yml"
|
||||
teamFile := "./testdata/gitops/team_config_no_paths.yml"
|
||||
|
||||
// Dry run on global file should fail because Apple BM Default Team does not exist (and has not been provided)
|
||||
_, err = runAppNoChecks([]string{"gitops", "-f", globalFile, "--dry-run"})
|
||||
require.Error(t, err)
|
||||
assert.True(t, strings.Contains(err.Error(), "team name not found"))
|
||||
|
||||
// Dry run
|
||||
_ = runAppForTest(t, []string{"gitops", "-f", globalFile, "-f", teamFile, "--dry-run", "--delete-other-teams"})
|
||||
assert.False(t, ds.SaveAppConfigFuncInvoked)
|
||||
assert.Len(t, enrolledSecrets, 0)
|
||||
assert.Len(t, enrolledTeamSecrets, 0)
|
||||
assert.Len(t, appliedPolicySpecs, 0)
|
||||
assert.Len(t, appliedQueries, 0)
|
||||
|
||||
// Real run
|
||||
_ = runAppForTest(t, []string{"gitops", "-f", globalFile, "-f", teamFile, "--delete-other-teams"})
|
||||
assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName)
|
||||
assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL)
|
||||
assert.Len(t, enrolledSecrets, 2)
|
||||
require.NotNil(t, savedTeam)
|
||||
assert.Equal(t, teamName, savedTeam.Name)
|
||||
require.Len(t, enrolledTeamSecrets, 2)
|
||||
|
||||
return ds, &savedAppConfig, &savedTeam
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,132 +1,132 @@
|
|||
{
|
||||
"kind": "config",
|
||||
"apiVersion": "v1",
|
||||
"spec": {
|
||||
"org_info": {
|
||||
"org_name": "",
|
||||
"org_logo_url": "",
|
||||
"org_logo_url_light_background": "",
|
||||
"contact_url": "https://fleetdm.com/company/contact"
|
||||
},
|
||||
"server_settings": {
|
||||
"server_url": "",
|
||||
"live_query_disabled": false,
|
||||
"query_reports_disabled": false,
|
||||
"enable_analytics": false,
|
||||
"deferred_save_host": false,
|
||||
"scripts_disabled": false,
|
||||
"ai_features_disabled": false
|
||||
},
|
||||
"smtp_settings": {
|
||||
"enable_smtp": false,
|
||||
"configured": false,
|
||||
"sender_address": "",
|
||||
"server": "",
|
||||
"port": 0,
|
||||
"authentication_type": "",
|
||||
"user_name": "",
|
||||
"password": "",
|
||||
"enable_ssl_tls": false,
|
||||
"authentication_method": "",
|
||||
"domain": "",
|
||||
"verify_ssl_certs": false,
|
||||
"enable_start_tls": false
|
||||
},
|
||||
"host_expiry_settings": {
|
||||
"host_expiry_enabled": false,
|
||||
"host_expiry_window": 0
|
||||
},
|
||||
"activity_expiry_settings": {
|
||||
"activity_expiry_enabled": false,
|
||||
"activity_expiry_window": 0
|
||||
},
|
||||
"features": {
|
||||
"enable_host_users": true,
|
||||
"enable_software_inventory": false
|
||||
},
|
||||
"sso_settings": {
|
||||
"entity_id": "",
|
||||
"issuer_uri": "",
|
||||
"idp_image_url": "",
|
||||
"metadata": "",
|
||||
"metadata_url": "",
|
||||
"idp_name": "",
|
||||
"enable_jit_provisioning": false,
|
||||
"enable_jit_role_sync": false,
|
||||
"enable_sso": false,
|
||||
"enable_sso_idp_login": false
|
||||
},
|
||||
"fleet_desktop": {
|
||||
"transparency_url": "https://fleetdm.com/transparency"
|
||||
},
|
||||
"vulnerability_settings": {
|
||||
"databases_path": "/some/path"
|
||||
},
|
||||
"webhook_settings": {
|
||||
"host_status_webhook": {
|
||||
"enable_host_status_webhook": false,
|
||||
"destination_url": "",
|
||||
"host_percentage": 0,
|
||||
"days_count": 0
|
||||
},
|
||||
"failing_policies_webhook": {
|
||||
"enable_failing_policies_webhook": false,
|
||||
"destination_url": "",
|
||||
"policy_ids": null,
|
||||
"host_batch_size": 0
|
||||
},
|
||||
"vulnerabilities_webhook": {
|
||||
"enable_vulnerabilities_webhook": false,
|
||||
"destination_url": "",
|
||||
"host_batch_size": 0
|
||||
},
|
||||
"interval": "0s"
|
||||
},
|
||||
"integrations": {
|
||||
"jira": null,
|
||||
"zendesk": null,
|
||||
"google_calendar": null
|
||||
},
|
||||
"mdm": {
|
||||
"apple_bm_terms_expired": false,
|
||||
"apple_bm_enabled_and_configured": false,
|
||||
"enabled_and_configured": false,
|
||||
"apple_bm_default_team": "",
|
||||
"windows_enabled_and_configured": false,
|
||||
"enable_disk_encryption": false,
|
||||
"macos_updates": {
|
||||
"minimum_version": null,
|
||||
"deadline": null
|
||||
},
|
||||
"windows_updates": {
|
||||
"deadline_days": 7,
|
||||
"grace_period_days": 3
|
||||
},
|
||||
"macos_migration": {
|
||||
"enable": false,
|
||||
"mode": "",
|
||||
"webhook_url": ""
|
||||
},
|
||||
"macos_settings": {
|
||||
"custom_settings": null
|
||||
},
|
||||
"macos_setup": {
|
||||
"bootstrap_package": null,
|
||||
"enable_end_user_authentication": false,
|
||||
"macos_setup_assistant": null,
|
||||
"enable_release_device_manually": false
|
||||
},
|
||||
"windows_settings": {
|
||||
"custom_settings": null
|
||||
},
|
||||
"end_user_authentication": {
|
||||
"entity_id": "",
|
||||
"issuer_uri": "",
|
||||
"metadata": "",
|
||||
"metadata_url": "",
|
||||
"idp_name": ""
|
||||
}
|
||||
},
|
||||
"scripts": null
|
||||
}
|
||||
"kind": "config",
|
||||
"apiVersion": "v1",
|
||||
"spec": {
|
||||
"org_info": {
|
||||
"org_name": "",
|
||||
"org_logo_url": "",
|
||||
"org_logo_url_light_background": "",
|
||||
"contact_url": "https://fleetdm.com/company/contact"
|
||||
},
|
||||
"server_settings": {
|
||||
"server_url": "",
|
||||
"live_query_disabled": false,
|
||||
"query_reports_disabled": false,
|
||||
"enable_analytics": false,
|
||||
"deferred_save_host": false,
|
||||
"scripts_disabled": false,
|
||||
"ai_features_disabled": false
|
||||
},
|
||||
"smtp_settings": {
|
||||
"enable_smtp": false,
|
||||
"configured": false,
|
||||
"sender_address": "",
|
||||
"server": "",
|
||||
"port": 0,
|
||||
"authentication_type": "",
|
||||
"user_name": "",
|
||||
"password": "",
|
||||
"enable_ssl_tls": false,
|
||||
"authentication_method": "",
|
||||
"domain": "",
|
||||
"verify_ssl_certs": false,
|
||||
"enable_start_tls": false
|
||||
},
|
||||
"host_expiry_settings": {
|
||||
"host_expiry_enabled": false,
|
||||
"host_expiry_window": 0
|
||||
},
|
||||
"activity_expiry_settings": {
|
||||
"activity_expiry_enabled": false,
|
||||
"activity_expiry_window": 0
|
||||
},
|
||||
"features": {
|
||||
"enable_host_users": true,
|
||||
"enable_software_inventory": false
|
||||
},
|
||||
"sso_settings": {
|
||||
"entity_id": "",
|
||||
"issuer_uri": "",
|
||||
"idp_image_url": "",
|
||||
"metadata": "",
|
||||
"metadata_url": "",
|
||||
"idp_name": "",
|
||||
"enable_jit_provisioning": false,
|
||||
"enable_jit_role_sync": false,
|
||||
"enable_sso": false,
|
||||
"enable_sso_idp_login": false
|
||||
},
|
||||
"fleet_desktop": {
|
||||
"transparency_url": "https://fleetdm.com/transparency"
|
||||
},
|
||||
"vulnerability_settings": {
|
||||
"databases_path": "/some/path"
|
||||
},
|
||||
"webhook_settings": {
|
||||
"host_status_webhook": {
|
||||
"enable_host_status_webhook": false,
|
||||
"destination_url": "",
|
||||
"host_percentage": 0,
|
||||
"days_count": 0
|
||||
},
|
||||
"failing_policies_webhook": {
|
||||
"enable_failing_policies_webhook": false,
|
||||
"destination_url": "",
|
||||
"policy_ids": null,
|
||||
"host_batch_size": 0
|
||||
},
|
||||
"vulnerabilities_webhook": {
|
||||
"enable_vulnerabilities_webhook": false,
|
||||
"destination_url": "",
|
||||
"host_batch_size": 0
|
||||
},
|
||||
"interval": "0s"
|
||||
},
|
||||
"integrations": {
|
||||
"jira": null,
|
||||
"zendesk": null,
|
||||
"google_calendar": null
|
||||
},
|
||||
"mdm": {
|
||||
"apple_bm_terms_expired": false,
|
||||
"apple_bm_enabled_and_configured": false,
|
||||
"enabled_and_configured": false,
|
||||
"apple_bm_default_team": "",
|
||||
"windows_enabled_and_configured": false,
|
||||
"enable_disk_encryption": false,
|
||||
"macos_updates": {
|
||||
"minimum_version": null,
|
||||
"deadline": null
|
||||
},
|
||||
"windows_updates": {
|
||||
"deadline_days": 7,
|
||||
"grace_period_days": 3
|
||||
},
|
||||
"macos_migration": {
|
||||
"enable": false,
|
||||
"mode": "",
|
||||
"webhook_url": ""
|
||||
},
|
||||
"macos_settings": {
|
||||
"custom_settings": null
|
||||
},
|
||||
"macos_setup": {
|
||||
"bootstrap_package": null,
|
||||
"enable_end_user_authentication": false,
|
||||
"macos_setup_assistant": null,
|
||||
"enable_release_device_manually": false
|
||||
},
|
||||
"windows_settings": {
|
||||
"custom_settings": null
|
||||
},
|
||||
"end_user_authentication": {
|
||||
"entity_id": "",
|
||||
"issuer_uri": "",
|
||||
"metadata": "",
|
||||
"metadata_url": "",
|
||||
"idp_name": ""
|
||||
}
|
||||
},
|
||||
"scripts": null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,194 +1,194 @@
|
|||
{
|
||||
"kind": "config",
|
||||
"apiVersion": "v1",
|
||||
"spec": {
|
||||
"org_info": {
|
||||
"org_name": "",
|
||||
"org_logo_url": "",
|
||||
"org_logo_url_light_background": "",
|
||||
"contact_url": "https://fleetdm.com/company/contact"
|
||||
},
|
||||
"server_settings": {
|
||||
"server_url": "",
|
||||
"live_query_disabled": false,
|
||||
"query_reports_disabled": false,
|
||||
"enable_analytics": false,
|
||||
"deferred_save_host": false,
|
||||
"scripts_disabled": false,
|
||||
"ai_features_disabled": false
|
||||
},
|
||||
"smtp_settings": {
|
||||
"enable_smtp": false,
|
||||
"configured": false,
|
||||
"sender_address": "",
|
||||
"server": "",
|
||||
"port": 0,
|
||||
"authentication_type": "",
|
||||
"user_name": "",
|
||||
"password": "",
|
||||
"enable_ssl_tls": false,
|
||||
"authentication_method": "",
|
||||
"domain": "",
|
||||
"verify_ssl_certs": false,
|
||||
"enable_start_tls": false
|
||||
},
|
||||
"host_expiry_settings": {
|
||||
"host_expiry_enabled": false,
|
||||
"host_expiry_window": 0
|
||||
},
|
||||
"activity_expiry_settings": {
|
||||
"activity_expiry_enabled": false,
|
||||
"activity_expiry_window": 0
|
||||
},
|
||||
"features": {
|
||||
"enable_host_users": true,
|
||||
"enable_software_inventory": false
|
||||
},
|
||||
"mdm": {
|
||||
"apple_bm_default_team": "",
|
||||
"apple_bm_terms_expired": false,
|
||||
"apple_bm_enabled_and_configured": false,
|
||||
"enabled_and_configured": false,
|
||||
"windows_enabled_and_configured": false,
|
||||
"enable_disk_encryption": false,
|
||||
"macos_updates": {
|
||||
"minimum_version": null,
|
||||
"deadline": null
|
||||
},
|
||||
"windows_updates": {
|
||||
"deadline_days": 7,
|
||||
"grace_period_days": 3
|
||||
},
|
||||
"macos_migration": {
|
||||
"enable": false,
|
||||
"mode": "",
|
||||
"webhook_url": ""
|
||||
},
|
||||
"macos_settings": {
|
||||
"custom_settings": null
|
||||
},
|
||||
"macos_setup": {
|
||||
"bootstrap_package": null,
|
||||
"enable_end_user_authentication": false,
|
||||
"macos_setup_assistant": null,
|
||||
"enable_release_device_manually": false
|
||||
},
|
||||
"windows_settings": {
|
||||
"custom_settings": null
|
||||
},
|
||||
"end_user_authentication": {
|
||||
"entity_id": "",
|
||||
"issuer_uri": "",
|
||||
"metadata": "",
|
||||
"metadata_url": "",
|
||||
"idp_name": ""
|
||||
}
|
||||
},
|
||||
"scripts": null,
|
||||
"sso_settings": {
|
||||
"enable_jit_provisioning": false,
|
||||
"enable_jit_role_sync": false,
|
||||
"entity_id": "",
|
||||
"issuer_uri": "",
|
||||
"idp_image_url": "",
|
||||
"metadata": "",
|
||||
"metadata_url": "",
|
||||
"idp_name": "",
|
||||
"enable_sso": false,
|
||||
"enable_sso_idp_login": false
|
||||
},
|
||||
"fleet_desktop": {
|
||||
"transparency_url": "https://fleetdm.com/transparency"
|
||||
},
|
||||
"vulnerability_settings": {
|
||||
"databases_path": "/some/path"
|
||||
},
|
||||
"webhook_settings": {
|
||||
"host_status_webhook": {
|
||||
"enable_host_status_webhook": false,
|
||||
"destination_url": "",
|
||||
"host_percentage": 0,
|
||||
"days_count": 0
|
||||
},
|
||||
"failing_policies_webhook": {
|
||||
"enable_failing_policies_webhook": false,
|
||||
"destination_url": "",
|
||||
"policy_ids": null,
|
||||
"host_batch_size": 0
|
||||
},
|
||||
"vulnerabilities_webhook": {
|
||||
"enable_vulnerabilities_webhook": false,
|
||||
"destination_url": "",
|
||||
"host_batch_size": 0
|
||||
},
|
||||
"interval": "0s"
|
||||
},
|
||||
"integrations": {
|
||||
"jira": null,
|
||||
"zendesk": null,
|
||||
"google_calendar": null
|
||||
},
|
||||
"update_interval": {
|
||||
"osquery_detail": "1h0m0s",
|
||||
"osquery_policy": "1h0m0s"
|
||||
},
|
||||
"vulnerabilities": {
|
||||
"databases_path": "",
|
||||
"periodicity": "0s",
|
||||
"cpe_database_url": "",
|
||||
"cpe_translations_url": "",
|
||||
"cve_feed_prefix_url": "",
|
||||
"current_instance_checks": "",
|
||||
"disable_data_sync": false,
|
||||
"recent_vulnerability_max_age": "0s",
|
||||
"disable_win_os_vulnerabilities": false
|
||||
},
|
||||
"license": {
|
||||
"tier": "free",
|
||||
"expiration": "0001-01-01T00:00:00Z"
|
||||
},
|
||||
"logging": {
|
||||
"debug": true,
|
||||
"json": false,
|
||||
"result": {
|
||||
"plugin": "filesystem",
|
||||
"config": {
|
||||
"enable_log_compression": false,
|
||||
"enable_log_rotation": false,
|
||||
"result_log_file": "/dev/null",
|
||||
"status_log_file": "/dev/null",
|
||||
"audit_log_file": "/dev/null",
|
||||
"max_size": 500,
|
||||
"max_age": 0,
|
||||
"max_backups": 0
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"plugin": "filesystem",
|
||||
"config": {
|
||||
"enable_log_compression": false,
|
||||
"enable_log_rotation": false,
|
||||
"result_log_file": "/dev/null",
|
||||
"status_log_file": "/dev/null",
|
||||
"audit_log_file": "/dev/null",
|
||||
"max_size": 500,
|
||||
"max_age": 0,
|
||||
"max_backups": 0
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
"plugin": "filesystem",
|
||||
"config": {
|
||||
"enable_log_compression": false,
|
||||
"enable_log_rotation": false,
|
||||
"result_log_file": "/dev/null",
|
||||
"status_log_file": "/dev/null",
|
||||
"audit_log_file": "/dev/null",
|
||||
"max_size": 500,
|
||||
"max_age": 0,
|
||||
"max_backups": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"kind": "config",
|
||||
"apiVersion": "v1",
|
||||
"spec": {
|
||||
"org_info": {
|
||||
"org_name": "",
|
||||
"org_logo_url": "",
|
||||
"org_logo_url_light_background": "",
|
||||
"contact_url": "https://fleetdm.com/company/contact"
|
||||
},
|
||||
"server_settings": {
|
||||
"server_url": "",
|
||||
"live_query_disabled": false,
|
||||
"query_reports_disabled": false,
|
||||
"enable_analytics": false,
|
||||
"deferred_save_host": false,
|
||||
"scripts_disabled": false,
|
||||
"ai_features_disabled": false
|
||||
},
|
||||
"smtp_settings": {
|
||||
"enable_smtp": false,
|
||||
"configured": false,
|
||||
"sender_address": "",
|
||||
"server": "",
|
||||
"port": 0,
|
||||
"authentication_type": "",
|
||||
"user_name": "",
|
||||
"password": "",
|
||||
"enable_ssl_tls": false,
|
||||
"authentication_method": "",
|
||||
"domain": "",
|
||||
"verify_ssl_certs": false,
|
||||
"enable_start_tls": false
|
||||
},
|
||||
"host_expiry_settings": {
|
||||
"host_expiry_enabled": false,
|
||||
"host_expiry_window": 0
|
||||
},
|
||||
"activity_expiry_settings": {
|
||||
"activity_expiry_enabled": false,
|
||||
"activity_expiry_window": 0
|
||||
},
|
||||
"features": {
|
||||
"enable_host_users": true,
|
||||
"enable_software_inventory": false
|
||||
},
|
||||
"mdm": {
|
||||
"apple_bm_default_team": "",
|
||||
"apple_bm_terms_expired": false,
|
||||
"apple_bm_enabled_and_configured": false,
|
||||
"enabled_and_configured": false,
|
||||
"windows_enabled_and_configured": false,
|
||||
"enable_disk_encryption": false,
|
||||
"macos_updates": {
|
||||
"minimum_version": null,
|
||||
"deadline": null
|
||||
},
|
||||
"windows_updates": {
|
||||
"deadline_days": 7,
|
||||
"grace_period_days": 3
|
||||
},
|
||||
"macos_migration": {
|
||||
"enable": false,
|
||||
"mode": "",
|
||||
"webhook_url": ""
|
||||
},
|
||||
"macos_settings": {
|
||||
"custom_settings": null
|
||||
},
|
||||
"macos_setup": {
|
||||
"bootstrap_package": null,
|
||||
"enable_end_user_authentication": false,
|
||||
"macos_setup_assistant": null,
|
||||
"enable_release_device_manually": false
|
||||
},
|
||||
"windows_settings": {
|
||||
"custom_settings": null
|
||||
},
|
||||
"end_user_authentication": {
|
||||
"entity_id": "",
|
||||
"issuer_uri": "",
|
||||
"metadata": "",
|
||||
"metadata_url": "",
|
||||
"idp_name": ""
|
||||
}
|
||||
},
|
||||
"scripts": null,
|
||||
"sso_settings": {
|
||||
"enable_jit_provisioning": false,
|
||||
"enable_jit_role_sync": false,
|
||||
"entity_id": "",
|
||||
"issuer_uri": "",
|
||||
"idp_image_url": "",
|
||||
"metadata": "",
|
||||
"metadata_url": "",
|
||||
"idp_name": "",
|
||||
"enable_sso": false,
|
||||
"enable_sso_idp_login": false
|
||||
},
|
||||
"fleet_desktop": {
|
||||
"transparency_url": "https://fleetdm.com/transparency"
|
||||
},
|
||||
"vulnerability_settings": {
|
||||
"databases_path": "/some/path"
|
||||
},
|
||||
"webhook_settings": {
|
||||
"host_status_webhook": {
|
||||
"enable_host_status_webhook": false,
|
||||
"destination_url": "",
|
||||
"host_percentage": 0,
|
||||
"days_count": 0
|
||||
},
|
||||
"failing_policies_webhook": {
|
||||
"enable_failing_policies_webhook": false,
|
||||
"destination_url": "",
|
||||
"policy_ids": null,
|
||||
"host_batch_size": 0
|
||||
},
|
||||
"vulnerabilities_webhook": {
|
||||
"enable_vulnerabilities_webhook": false,
|
||||
"destination_url": "",
|
||||
"host_batch_size": 0
|
||||
},
|
||||
"interval": "0s"
|
||||
},
|
||||
"integrations": {
|
||||
"jira": null,
|
||||
"zendesk": null,
|
||||
"google_calendar": null
|
||||
},
|
||||
"update_interval": {
|
||||
"osquery_detail": "1h0m0s",
|
||||
"osquery_policy": "1h0m0s"
|
||||
},
|
||||
"vulnerabilities": {
|
||||
"databases_path": "",
|
||||
"periodicity": "0s",
|
||||
"cpe_database_url": "",
|
||||
"cpe_translations_url": "",
|
||||
"cve_feed_prefix_url": "",
|
||||
"current_instance_checks": "",
|
||||
"disable_data_sync": false,
|
||||
"recent_vulnerability_max_age": "0s",
|
||||
"disable_win_os_vulnerabilities": false
|
||||
},
|
||||
"license": {
|
||||
"tier": "free",
|
||||
"expiration": "0001-01-01T00:00:00Z"
|
||||
},
|
||||
"logging": {
|
||||
"debug": true,
|
||||
"json": false,
|
||||
"result": {
|
||||
"plugin": "filesystem",
|
||||
"config": {
|
||||
"enable_log_compression": false,
|
||||
"enable_log_rotation": false,
|
||||
"result_log_file": "/dev/null",
|
||||
"status_log_file": "/dev/null",
|
||||
"audit_log_file": "/dev/null",
|
||||
"max_size": 500,
|
||||
"max_age": 0,
|
||||
"max_backups": 0
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"plugin": "filesystem",
|
||||
"config": {
|
||||
"enable_log_compression": false,
|
||||
"enable_log_rotation": false,
|
||||
"result_log_file": "/dev/null",
|
||||
"status_log_file": "/dev/null",
|
||||
"audit_log_file": "/dev/null",
|
||||
"max_size": 500,
|
||||
"max_age": 0,
|
||||
"max_backups": 0
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
"plugin": "filesystem",
|
||||
"config": {
|
||||
"enable_log_compression": false,
|
||||
"enable_log_rotation": false,
|
||||
"result_log_file": "/dev/null",
|
||||
"status_log_file": "/dev/null",
|
||||
"audit_log_file": "/dev/null",
|
||||
"max_size": 500,
|
||||
"max_age": 0,
|
||||
"max_backups": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@
|
|||
}
|
||||
},
|
||||
"scripts": null,
|
||||
"software": null,
|
||||
"user_count": 99,
|
||||
"host_count": 42
|
||||
}
|
||||
|
|
@ -128,6 +129,7 @@
|
|||
}
|
||||
},
|
||||
"scripts": null,
|
||||
"software": null,
|
||||
"user_count": 87,
|
||||
"host_count": 43
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ spec:
|
|||
enable_release_device_manually: false
|
||||
macos_setup_assistant:
|
||||
scripts: null
|
||||
software: null
|
||||
webhook_settings:
|
||||
host_status_webhook: null
|
||||
name: team1
|
||||
|
|
@ -72,6 +73,7 @@ spec:
|
|||
enable_release_device_manually: false
|
||||
macos_setup_assistant:
|
||||
scripts: null
|
||||
software: null
|
||||
webhook_settings:
|
||||
host_status_webhook: null
|
||||
name: team2
|
||||
|
|
|
|||
1
cmd/fleetctl/testdata/gitops/lib/install_ruby.sh
vendored
Normal file
1
cmd/fleetctl/testdata/gitops/lib/install_ruby.sh
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
echo 'ruby'
|
||||
1
cmd/fleetctl/testdata/gitops/lib/post_install_ruby.sh
vendored
Normal file
1
cmd/fleetctl/testdata/gitops/lib/post_install_ruby.sh
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
echo 'post ruby'
|
||||
11
cmd/fleetctl/testdata/gitops/lib/query_multiple.yml
vendored
Normal file
11
cmd/fleetctl/testdata/gitops/lib/query_multiple.yml
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
apiVersion: v1
|
||||
kind: query
|
||||
spec:
|
||||
name: query_ruby
|
||||
query: select 1
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: query
|
||||
spec:
|
||||
name: query_ruby2
|
||||
query: select 2
|
||||
5
cmd/fleetctl/testdata/gitops/lib/query_ruby.yml
vendored
Normal file
5
cmd/fleetctl/testdata/gitops/lib/query_ruby.yml
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
apiVersion: v1
|
||||
kind: query
|
||||
spec:
|
||||
name: query_ruby
|
||||
query: select 1
|
||||
18
cmd/fleetctl/testdata/gitops/team_software_installer_install_not_found.yml
vendored
Normal file
18
cmd/fleetctl/testdata/gitops/team_software_installer_install_not_found.yml
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
name: "${TEST_TEAM_NAME}"
|
||||
team_settings:
|
||||
secrets:
|
||||
- secret: "ABC"
|
||||
features:
|
||||
enable_host_users: true
|
||||
enable_software_inventory: true
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: true
|
||||
host_expiry_window: 30
|
||||
agent_options:
|
||||
controls:
|
||||
policies:
|
||||
queries:
|
||||
software:
|
||||
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
|
||||
install_script:
|
||||
path: lib/notfound.sh
|
||||
21
cmd/fleetctl/testdata/gitops/team_software_installer_no_url.yml
vendored
Normal file
21
cmd/fleetctl/testdata/gitops/team_software_installer_no_url.yml
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
name: "${TEST_TEAM_NAME}"
|
||||
team_settings:
|
||||
secrets:
|
||||
- secret: "ABC"
|
||||
features:
|
||||
enable_host_users: true
|
||||
enable_software_inventory: true
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: true
|
||||
host_expiry_window: 30
|
||||
agent_options:
|
||||
controls:
|
||||
policies:
|
||||
queries:
|
||||
software:
|
||||
- install_script:
|
||||
path: lib/install_ruby.sh
|
||||
pre_install_query:
|
||||
path: lib/query_ruby.yml
|
||||
post_install_script:
|
||||
path: lib/post_install_ruby.sh
|
||||
16
cmd/fleetctl/testdata/gitops/team_software_installer_not_found.yml
vendored
Normal file
16
cmd/fleetctl/testdata/gitops/team_software_installer_not_found.yml
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
name: "${TEST_TEAM_NAME}"
|
||||
team_settings:
|
||||
secrets:
|
||||
- secret: "ABC"
|
||||
features:
|
||||
enable_host_users: true
|
||||
enable_software_inventory: true
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: true
|
||||
host_expiry_window: 30
|
||||
agent_options:
|
||||
controls:
|
||||
policies:
|
||||
queries:
|
||||
software:
|
||||
- url: ${SOFTWARE_INSTALLER_URL}/notfound.deb
|
||||
20
cmd/fleetctl/testdata/gitops/team_software_installer_post_install_not_found.yml
vendored
Normal file
20
cmd/fleetctl/testdata/gitops/team_software_installer_post_install_not_found.yml
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
name: "${TEST_TEAM_NAME}"
|
||||
team_settings:
|
||||
secrets:
|
||||
- secret: "ABC"
|
||||
features:
|
||||
enable_host_users: true
|
||||
enable_software_inventory: true
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: true
|
||||
host_expiry_window: 30
|
||||
agent_options:
|
||||
controls:
|
||||
policies:
|
||||
queries:
|
||||
software:
|
||||
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
|
||||
install_script:
|
||||
path: lib/install_ruby.sh
|
||||
post_install_script:
|
||||
path: lib/notfound.sh
|
||||
22
cmd/fleetctl/testdata/gitops/team_software_installer_pre_condition_multiple_queries.yml
vendored
Normal file
22
cmd/fleetctl/testdata/gitops/team_software_installer_pre_condition_multiple_queries.yml
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
name: "${TEST_TEAM_NAME}"
|
||||
team_settings:
|
||||
secrets:
|
||||
- secret: "ABC"
|
||||
features:
|
||||
enable_host_users: true
|
||||
enable_software_inventory: true
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: true
|
||||
host_expiry_window: 30
|
||||
agent_options:
|
||||
controls:
|
||||
policies:
|
||||
queries:
|
||||
software:
|
||||
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
|
||||
install_script:
|
||||
path: lib/install_ruby.sh
|
||||
pre_install_query:
|
||||
path: lib/query_multiple.yml
|
||||
post_install_script:
|
||||
path: lib/post_install_ruby.sh
|
||||
20
cmd/fleetctl/testdata/gitops/team_software_installer_pre_condition_not_found.yml
vendored
Normal file
20
cmd/fleetctl/testdata/gitops/team_software_installer_pre_condition_not_found.yml
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
name: "${TEST_TEAM_NAME}"
|
||||
team_settings:
|
||||
secrets:
|
||||
- secret: "ABC"
|
||||
features:
|
||||
enable_host_users: true
|
||||
enable_software_inventory: true
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: true
|
||||
host_expiry_window: 30
|
||||
agent_options:
|
||||
controls:
|
||||
policies:
|
||||
queries:
|
||||
software:
|
||||
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
|
||||
install_script:
|
||||
path: lib/install_ruby.sh
|
||||
pre_install_query:
|
||||
path: lib/notfound.yml
|
||||
16
cmd/fleetctl/testdata/gitops/team_software_installer_too_large.yml
vendored
Normal file
16
cmd/fleetctl/testdata/gitops/team_software_installer_too_large.yml
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
name: "${TEST_TEAM_NAME}"
|
||||
team_settings:
|
||||
secrets:
|
||||
- secret: "ABC"
|
||||
features:
|
||||
enable_host_users: true
|
||||
enable_software_inventory: true
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: true
|
||||
host_expiry_window: 30
|
||||
agent_options:
|
||||
controls:
|
||||
policies:
|
||||
queries:
|
||||
software:
|
||||
- url: ${SOFTWARE_INSTALLER_URL}/toolarge.deb
|
||||
16
cmd/fleetctl/testdata/gitops/team_software_installer_unsupported.yml
vendored
Normal file
16
cmd/fleetctl/testdata/gitops/team_software_installer_unsupported.yml
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
name: "${TEST_TEAM_NAME}"
|
||||
team_settings:
|
||||
secrets:
|
||||
- secret: "ABC"
|
||||
features:
|
||||
enable_host_users: true
|
||||
enable_software_inventory: true
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: true
|
||||
host_expiry_window: 30
|
||||
agent_options:
|
||||
controls:
|
||||
policies:
|
||||
queries:
|
||||
software:
|
||||
- url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt
|
||||
22
cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml
vendored
Normal file
22
cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
name: "${TEST_TEAM_NAME}"
|
||||
team_settings:
|
||||
secrets:
|
||||
- secret: "ABC"
|
||||
features:
|
||||
enable_host_users: true
|
||||
enable_software_inventory: true
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: true
|
||||
host_expiry_window: 30
|
||||
agent_options:
|
||||
controls:
|
||||
policies:
|
||||
queries:
|
||||
software:
|
||||
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
|
||||
install_script:
|
||||
path: lib/install_ruby.sh
|
||||
pre_install_query:
|
||||
path: lib/query_ruby.yml
|
||||
post_install_script:
|
||||
path: lib/post_install_ruby.sh
|
||||
|
|
@ -29,6 +29,7 @@ spec:
|
|||
deadline_days: null
|
||||
grace_period_days: null
|
||||
scripts: null
|
||||
software: null
|
||||
webhook_settings:
|
||||
host_status_webhook: null
|
||||
name: tm1
|
||||
|
|
@ -62,6 +63,7 @@ spec:
|
|||
deadline_days: null
|
||||
grace_period_days: null
|
||||
scripts: null
|
||||
software: null
|
||||
webhook_settings:
|
||||
host_status_webhook: null
|
||||
name: tm2
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ spec:
|
|||
deadline_days: null
|
||||
grace_period_days: null
|
||||
scripts: null
|
||||
software: null
|
||||
webhook_settings:
|
||||
host_status_webhook: null
|
||||
name: tm1
|
||||
|
|
@ -62,6 +63,7 @@ spec:
|
|||
deadline_days: null
|
||||
grace_period_days: null
|
||||
scripts: null
|
||||
software: null
|
||||
webhook_settings:
|
||||
host_status_webhook: null
|
||||
name: tm2
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ spec:
|
|||
windows_settings:
|
||||
custom_settings: null
|
||||
scripts: null
|
||||
software: null
|
||||
webhook_settings:
|
||||
host_status_webhook: null
|
||||
name: tm1
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ spec:
|
|||
deadline_days: null
|
||||
grace_period_days: null
|
||||
scripts: null
|
||||
software: null
|
||||
webhook_settings:
|
||||
host_status_webhook: null
|
||||
name: tm1
|
||||
|
|
|
|||
|
|
@ -280,7 +280,7 @@ This activity contains the following fields:
|
|||
```json
|
||||
{
|
||||
"team_id": 123,
|
||||
"team_name": "foo"
|
||||
"team_name": "Workstations"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -297,7 +297,7 @@ This activity contains the following fields:
|
|||
```json
|
||||
{
|
||||
"team_id": 123,
|
||||
"team_name": "foo"
|
||||
"team_name": "Workstations"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -357,7 +357,7 @@ This activity contains the following fields:
|
|||
```json
|
||||
{
|
||||
"team_id": 123,
|
||||
"team_name": "foo",
|
||||
"team_name": "Workstations",
|
||||
"global": false
|
||||
}
|
||||
```
|
||||
|
|
@ -1127,6 +1127,73 @@ This activity contains the following fields:
|
|||
}
|
||||
```
|
||||
|
||||
## installed_software
|
||||
|
||||
Generated when a software is installed on a host.
|
||||
|
||||
This activity contains the following fields:
|
||||
- "host_id": ID of the host.
|
||||
- "host_display_name": Display name of the host.
|
||||
- "install_uuid": ID of the software installation.
|
||||
- "software_title": Name of the software.
|
||||
- "status": Status of the software installation.
|
||||
|
||||
#### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"host_id": 1,
|
||||
"host_display_name": "Anna's MacBook Pro",
|
||||
"software_title": "Falcon.app",
|
||||
"install_uuid": "d6cffa75-b5b5-41ef-9230-15073c8a88cf",
|
||||
"status": "pending"
|
||||
}
|
||||
```
|
||||
|
||||
## added_software
|
||||
|
||||
Generated when a software installer is uploaded to Fleet.
|
||||
|
||||
This activity contains the following fields:
|
||||
- "software_title": Name of the software.
|
||||
- "software_package": Filename of the installer.
|
||||
- "team_name": Name of the team to which this software was added. `null` if it was added to no team." +
|
||||
- "team_id": The ID of the team to which this software was added. `null` if it was added to no team.
|
||||
|
||||
#### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"software_title": "Falcon.app",
|
||||
"software_package": "FalconSensor-6.44.pkg",
|
||||
"team_name": "Workstations",
|
||||
"team_id": 123
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## deleted_software
|
||||
|
||||
Generated when a software installer is deleted from Fleet.
|
||||
|
||||
This activity contains the following fields:
|
||||
- "software_title": Name of the software.
|
||||
- "software_package": Filename of the installer.
|
||||
- "team_name": Name of the team to which this software was added. `null if it was added to no team.
|
||||
- "team_id": The ID of the team to which this software was added. `null` if it was added to no team.
|
||||
|
||||
#### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"software_title": "Falcon.app",
|
||||
"software_package": "FalconSensor-6.44.pkg",
|
||||
"team_name": "Workstations",
|
||||
"team_id": 123
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
<meta name="title" value="Audit logs">
|
||||
<meta name="pageOrderInSection" value="1400">
|
||||
|
|
|
|||
|
|
@ -45,7 +45,14 @@ SELECT de.encrypted, m.path FROM disk_encryption de JOIN mounts m ON m.device_al
|
|||
|
||||
- Query:
|
||||
```sql
|
||||
SELECT 1 FROM bitlocker_info WHERE drive_letter = 'C:' AND protection_status = 1;
|
||||
WITH encrypted(enabled) AS (
|
||||
SELECT CASE WHEN
|
||||
NOT EXISTS(SELECT 1 FROM windows_optional_features WHERE name = 'BitLocker')
|
||||
OR
|
||||
(SELECT 1 FROM windows_optional_features WHERE name = 'BitLocker' AND state = 1)
|
||||
THEN (SELECT 1 FROM bitlocker_info WHERE drive_letter = 'C:' AND protection_status = 1)
|
||||
END)
|
||||
SELECT 1 FROM encrypted WHERE enabled IS NOT NULL
|
||||
```
|
||||
|
||||
## disk_space_unix
|
||||
|
|
@ -304,7 +311,7 @@ LIMIT 1;
|
|||
|
||||
## orbit_info
|
||||
|
||||
- Platforms: all
|
||||
- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, darwin, windows
|
||||
|
||||
- Discovery query:
|
||||
```sql
|
||||
|
|
@ -313,7 +320,7 @@ SELECT 1 FROM osquery_registry WHERE active = true AND registry = 'table' AND na
|
|||
|
||||
- Query:
|
||||
```sql
|
||||
SELECT version FROM orbit_info
|
||||
SELECT * FROM orbit_info
|
||||
```
|
||||
|
||||
## os_chrome
|
||||
|
|
@ -779,7 +786,7 @@ select * from uptime limit 1
|
|||
|
||||
## users
|
||||
|
||||
- Platforms: linux, darwin, windows
|
||||
- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, darwin, windows
|
||||
|
||||
- Query:
|
||||
```sql
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ func setupMockDatastorePremiumService() (*mock.Store, *eeservice.Service, contex
|
|||
"",
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
|
|
|||
|
|
@ -17,17 +17,18 @@ import (
|
|||
type Service struct {
|
||||
fleet.Service
|
||||
|
||||
ds fleet.Datastore
|
||||
logger kitlog.Logger
|
||||
config config.FleetConfig
|
||||
clock clock.Clock
|
||||
authz *authz.Authorizer
|
||||
depStorage storage.AllDEPStorage
|
||||
mdmAppleCommander fleet.MDMAppleCommandIssuer
|
||||
mdmPushCertTopic string
|
||||
ssoSessionStore sso.SessionStore
|
||||
depService *apple_mdm.DEPService
|
||||
profileMatcher fleet.ProfileMatcher
|
||||
ds fleet.Datastore
|
||||
logger kitlog.Logger
|
||||
config config.FleetConfig
|
||||
clock clock.Clock
|
||||
authz *authz.Authorizer
|
||||
depStorage storage.AllDEPStorage
|
||||
mdmAppleCommander fleet.MDMAppleCommandIssuer
|
||||
mdmPushCertTopic string
|
||||
ssoSessionStore sso.SessionStore
|
||||
depService *apple_mdm.DEPService
|
||||
profileMatcher fleet.ProfileMatcher
|
||||
softwareInstallStore fleet.SoftwareInstallerStore
|
||||
}
|
||||
|
||||
func NewService(
|
||||
|
|
@ -42,6 +43,7 @@ func NewService(
|
|||
mdmPushCertTopic string,
|
||||
sso sso.SessionStore,
|
||||
profileMatcher fleet.ProfileMatcher,
|
||||
softwareInstallStore fleet.SoftwareInstallerStore,
|
||||
) (*Service, error) {
|
||||
authorizer, err := authz.NewAuthorizer()
|
||||
if err != nil {
|
||||
|
|
@ -49,18 +51,19 @@ func NewService(
|
|||
}
|
||||
|
||||
eeservice := &Service{
|
||||
Service: svc,
|
||||
ds: ds,
|
||||
logger: logger,
|
||||
config: config,
|
||||
clock: c,
|
||||
authz: authorizer,
|
||||
depStorage: depStorage,
|
||||
mdmAppleCommander: mdmAppleCommander,
|
||||
mdmPushCertTopic: mdmPushCertTopic,
|
||||
ssoSessionStore: sso,
|
||||
depService: apple_mdm.NewDEPService(ds, depStorage, logger),
|
||||
profileMatcher: profileMatcher,
|
||||
Service: svc,
|
||||
ds: ds,
|
||||
logger: logger,
|
||||
config: config,
|
||||
clock: c,
|
||||
authz: authorizer,
|
||||
depStorage: depStorage,
|
||||
mdmAppleCommander: mdmAppleCommander,
|
||||
mdmPushCertTopic: mdmPushCertTopic,
|
||||
ssoSessionStore: sso,
|
||||
depService: apple_mdm.NewDEPService(ds, depStorage, logger),
|
||||
profileMatcher: profileMatcher,
|
||||
softwareInstallStore: softwareInstallStore,
|
||||
}
|
||||
|
||||
// Override methods that can't be easily overriden via
|
||||
|
|
|
|||
518
ee/server/service/software_installers.go
Normal file
518
ee/server/service/software_installers.go
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/pkg/file"
|
||||
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/go-kit/log/level"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) error {
|
||||
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: payload.TeamID}, fleet.ActionWrite); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
vc, ok := viewer.FromContext(ctx)
|
||||
if !ok {
|
||||
return fleet.ErrNoContext
|
||||
}
|
||||
|
||||
// make sure all scripts use unix-style newlines to prevent errors when
|
||||
// running them, browsers use windows-style newlines, which breaks the
|
||||
// shebang when the file is directly executed.
|
||||
payload.InstallScript = file.Dos2UnixNewlines(payload.InstallScript)
|
||||
payload.PostInstallScript = file.Dos2UnixNewlines(payload.PostInstallScript)
|
||||
|
||||
if _, err := svc.addMetadataToSoftwarePayload(ctx, payload); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "adding metadata to payload")
|
||||
}
|
||||
|
||||
if err := svc.storeSoftware(ctx, payload); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "storing software installer")
|
||||
}
|
||||
|
||||
// TODO: basic validation of install and post-install script (e.g., supported interpreters)?
|
||||
// TODO: any validation of pre-install query?
|
||||
|
||||
installerID, err := svc.ds.MatchOrCreateSoftwareInstaller(ctx, payload)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "matching or creating software installer")
|
||||
}
|
||||
level.Debug(svc.logger).Log("msg", "software installer uploaded", "installer_id", installerID)
|
||||
|
||||
// TODO: QA what breaks when you have a software title with no versions?
|
||||
|
||||
var teamName *string
|
||||
if payload.TeamID != nil {
|
||||
t, err := svc.ds.Team(ctx, *payload.TeamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
teamName = &t.Name
|
||||
}
|
||||
|
||||
// Create activity
|
||||
if err := svc.ds.NewActivity(ctx, vc.User, fleet.ActivityTypeAddedSoftware{
|
||||
SoftwareTitle: payload.Title,
|
||||
SoftwarePackage: payload.Filename,
|
||||
TeamName: teamName,
|
||||
TeamID: payload.TeamID,
|
||||
}); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "creating activity for added software")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error {
|
||||
if teamID == nil || *teamID == 0 {
|
||||
return fleet.NewInvalidArgumentError("team_id", "is required and can't be zero")
|
||||
}
|
||||
|
||||
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionWrite); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
meta, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, titleID, false)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "getting software installer metadata")
|
||||
}
|
||||
|
||||
vc, ok := viewer.FromContext(ctx)
|
||||
if !ok {
|
||||
return fleet.ErrNoContext
|
||||
}
|
||||
|
||||
if err := svc.ds.DeleteSoftwareInstaller(ctx, meta.InstallerID); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "deleting software installer")
|
||||
}
|
||||
|
||||
var teamName *string
|
||||
if meta.TeamID != nil {
|
||||
t, err := svc.ds.Team(ctx, *meta.TeamID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "getting team name for deleted software")
|
||||
}
|
||||
teamName = &t.Name
|
||||
}
|
||||
|
||||
if err := svc.ds.NewActivity(ctx, vc.User, fleet.ActivityTypeDeletedSoftware{
|
||||
SoftwareTitle: meta.SoftwareTitle,
|
||||
SoftwarePackage: meta.Name,
|
||||
TeamName: teamName,
|
||||
TeamID: meta.TeamID,
|
||||
}); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "creating activity for deleted software")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *Service) GetSoftwareInstallerMetadata(ctx context.Context, titleID uint, teamID *uint) (*fleet.SoftwareInstaller, error) {
|
||||
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionRead); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
meta, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, titleID, true)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "getting software installer metadata")
|
||||
}
|
||||
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func (svc *Service) DownloadSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) (*fleet.DownloadSoftwareInstallerPayload, error) {
|
||||
if teamID == nil || *teamID == 0 {
|
||||
return nil, fleet.NewInvalidArgumentError("team_id", "is required and can't be zero")
|
||||
}
|
||||
|
||||
meta, err := svc.GetSoftwareInstallerMetadata(ctx, titleID, teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return svc.getSoftwareInstallerBinary(ctx, meta.StorageID, meta.Name)
|
||||
}
|
||||
|
||||
func (svc *Service) OrbitDownloadSoftwareInstaller(ctx context.Context, installerID uint) (*fleet.DownloadSoftwareInstallerPayload, error) {
|
||||
// this is not a user-authenticated endpoint
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
|
||||
_, ok := hostctx.FromContext(ctx)
|
||||
if !ok {
|
||||
return nil, fleet.OrbitError{Message: "internal error: missing host from request context"}
|
||||
}
|
||||
|
||||
// get the installer's metadata
|
||||
meta, err := svc.ds.GetSoftwareInstallerMetadataByID(ctx, installerID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "getting software installer metadata")
|
||||
}
|
||||
|
||||
// Note that we do allow downloading an installer that is on a different team
|
||||
// than the host's team, because the install request might have come while
|
||||
// the host was on that team, and then the host got moved to a different team
|
||||
// but the request is still pending execution.
|
||||
|
||||
return svc.getSoftwareInstallerBinary(ctx, meta.StorageID, meta.Name)
|
||||
}
|
||||
|
||||
func (svc *Service) getSoftwareInstallerBinary(ctx context.Context, storageID string, filename string) (*fleet.DownloadSoftwareInstallerPayload, error) {
|
||||
// check if the installer exists in the store
|
||||
exists, err := svc.softwareInstallStore.Exists(ctx, storageID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "checking if installer exists")
|
||||
}
|
||||
if !exists {
|
||||
return nil, ctxerr.Wrap(ctx, err, "does not exist in software installer store")
|
||||
}
|
||||
|
||||
// get the installer from the store
|
||||
installer, size, err := svc.softwareInstallStore.Get(ctx, storageID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "getting installer from store")
|
||||
}
|
||||
|
||||
return &fleet.DownloadSoftwareInstallerPayload{
|
||||
Filename: filename,
|
||||
Installer: installer,
|
||||
Size: size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softwareTitleID uint) error {
|
||||
// we need to use ds.Host because ds.HostLite doesn't return the orbit
|
||||
// node key
|
||||
host, err := svc.ds.Host(ctx, hostID)
|
||||
if err != nil {
|
||||
// if error is because the host does not exist, check first if the user
|
||||
// had access to install software (to prevent leaking valid host ids).
|
||||
if fleet.IsNotFound(err) {
|
||||
if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{}, fleet.ActionWrite); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
return ctxerr.Wrap(ctx, err, "get host")
|
||||
}
|
||||
|
||||
if host.OrbitNodeKey == nil || *host.OrbitNodeKey == "" {
|
||||
// fleetd is required to install software so if the host is
|
||||
// enrolled via plain osquery we return an error
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
// TODO(roberto): for cleanup task, confirm with product error message.
|
||||
return fleet.NewUserMessageError(errors.New("Host doesn't have fleetd installed"), http.StatusUnprocessableEntity)
|
||||
}
|
||||
|
||||
// authorize with the host's team
|
||||
if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{HostTeamID: host.TeamID}, fleet.ActionWrite); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
installer, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, host.TeamID, softwareTitleID, false)
|
||||
if err != nil {
|
||||
if fleet.IsNotFound(err) {
|
||||
return &fleet.BadRequestError{
|
||||
Message: "Software title has no package added. Please add software package to install.",
|
||||
InternalErr: ctxerr.WrapWithData(
|
||||
ctx, err, "couldn't find an installer for software title",
|
||||
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return ctxerr.Wrap(ctx, err, "finding software installer for title")
|
||||
}
|
||||
|
||||
ext := filepath.Ext(installer.Name)
|
||||
var requiredPlatform string
|
||||
switch ext {
|
||||
case ".msi", ".exe":
|
||||
requiredPlatform = "windows"
|
||||
case ".pkg":
|
||||
requiredPlatform = "darwin"
|
||||
case ".deb":
|
||||
requiredPlatform = "linux"
|
||||
default:
|
||||
// this should never happen
|
||||
return ctxerr.Errorf(ctx, "software installer has unsupported type %s", ext)
|
||||
}
|
||||
|
||||
if host.FleetPlatform() != requiredPlatform {
|
||||
return &fleet.BadRequestError{
|
||||
Message: fmt.Sprintf("Package (%s) can be installed only on %s hosts.", ext, requiredPlatform),
|
||||
InternalErr: ctxerr.WrapWithData(
|
||||
ctx, err, "invalid host platform for requested installer",
|
||||
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
_, err = svc.ds.InsertSoftwareInstallRequest(ctx, hostID, installer.InstallerID)
|
||||
return ctxerr.Wrap(ctx, err, "inserting software install request")
|
||||
}
|
||||
|
||||
func (svc *Service) GetSoftwareInstallResults(ctx context.Context, resultUUID string) (*fleet.HostSoftwareInstallerResult, error) {
|
||||
// Basic auth check
|
||||
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := svc.ds.GetSoftwareInstallResults(ctx, resultUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Team specific auth check
|
||||
if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{HostTeamID: res.HostTeamID}, fleet.ActionRead); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res.EnhanceOutputDetails()
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (svc *Service) storeSoftware(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) error {
|
||||
// check if exists in the installer store
|
||||
exists, err := svc.softwareInstallStore.Exists(ctx, payload.StorageID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "checking if installer exists")
|
||||
}
|
||||
if !exists {
|
||||
if err := svc.softwareInstallStore.Put(ctx, payload.StorageID, payload.InstallerFile); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "storing installer")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *Service) addMetadataToSoftwarePayload(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (extension string, err error) {
|
||||
if payload == nil {
|
||||
return "", ctxerr.New(ctx, "payload is required")
|
||||
}
|
||||
|
||||
if payload.InstallerFile == nil {
|
||||
return "", ctxerr.New(ctx, "installer file is required")
|
||||
}
|
||||
|
||||
title, vers, ext, hash, err := file.ExtractInstallerMetadata(payload.InstallerFile)
|
||||
if err != nil {
|
||||
if errors.Is(err, file.ErrUnsupportedType) {
|
||||
return "", &fleet.BadRequestError{
|
||||
Message: "Couldn't edit software. File type not supported. The file should be .pkg, .msi, .exe or .deb.",
|
||||
InternalErr: ctxerr.Wrap(ctx, err, "extracting metadata from installer"),
|
||||
}
|
||||
}
|
||||
return "", ctxerr.Wrap(ctx, err, "extracting metadata from installer")
|
||||
}
|
||||
if title == "" {
|
||||
// use the filename if no title from metadata
|
||||
title = payload.Filename
|
||||
}
|
||||
payload.Title = title
|
||||
payload.Version = vers
|
||||
payload.StorageID = hex.EncodeToString(hash)
|
||||
|
||||
// reset the reader (it was consumed to extract metadata)
|
||||
if _, err := payload.InstallerFile.Seek(0, 0); err != nil {
|
||||
return "", ctxerr.Wrap(ctx, err, "resetting installer file reader")
|
||||
}
|
||||
|
||||
if payload.InstallScript == "" {
|
||||
payload.InstallScript = file.GetInstallScript(ext)
|
||||
}
|
||||
|
||||
source, err := fleet.SofwareInstallerSourceFromExtension(ext)
|
||||
if err != nil {
|
||||
return "", ctxerr.Wrap(ctx, err, "determining source from extension")
|
||||
}
|
||||
payload.Source = source
|
||||
|
||||
platform, err := fleet.SofwareInstallerPlatformFromExtension(ext)
|
||||
if err != nil {
|
||||
return "", ctxerr.Wrap(ctx, err, "determining platform from extension")
|
||||
}
|
||||
payload.Platform = platform
|
||||
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
const maxInstallerSizeBytes int64 = 1024 * 1024 * 500
|
||||
|
||||
func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName string, payloads []fleet.SoftwareInstallerPayload, dryRun bool) error {
|
||||
if tmName == "" {
|
||||
svc.authz.SkipAuthorization(ctx) // so that the error message is not replaced by "forbidden"
|
||||
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("team_name", "must not be empty"))
|
||||
}
|
||||
|
||||
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tm, err := svc.ds.TeamByName(ctx, tmName)
|
||||
if err != nil {
|
||||
// If this is a dry run, the team may not have been created yet
|
||||
if dryRun && fleet.IsNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: &tm.ID}, fleet.ActionWrite); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "validating authorization")
|
||||
}
|
||||
|
||||
g, workerCtx := errgroup.WithContext(ctx)
|
||||
g.SetLimit(3)
|
||||
// critical to avoid data race, the slice is pre-allocated and each
|
||||
// goroutine only writes to its index.
|
||||
installers := make([]*fleet.UploadSoftwareInstallerPayload, len(payloads))
|
||||
|
||||
client := fleethttp.NewClient()
|
||||
client.Transport = fleethttp.NewSizeLimitTransport(maxInstallerSizeBytes)
|
||||
for i, p := range payloads {
|
||||
i, p := i, p
|
||||
|
||||
g.Go(func() error {
|
||||
// validate the URL before doing the request
|
||||
_, err := url.ParseRequestURI(p.URL)
|
||||
if err != nil {
|
||||
return fleet.NewInvalidArgumentError(
|
||||
"software.url",
|
||||
fmt.Sprintf("Couldn't edit software. URL (%q) is invalid", p.URL),
|
||||
)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(workerCtx, http.MethodGet, p.URL, nil)
|
||||
if err != nil {
|
||||
return ctxerr.Wrapf(ctx, err, "creating request for URL %s", p.URL)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.Is(err, fleethttp.ErrMaxSizeExceeded) || errors.As(err, &maxBytesErr) {
|
||||
return fleet.NewInvalidArgumentError(
|
||||
"software.url",
|
||||
fmt.Sprintf("Couldn't edit software. URL (%q). The maximum file size is %d MB", p.URL, maxInstallerSizeBytes/(1024*1024)),
|
||||
)
|
||||
}
|
||||
|
||||
return ctxerr.Wrapf(ctx, err, "performing request for URL %s", p.URL)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return fleet.NewInvalidArgumentError(
|
||||
"software.url",
|
||||
fmt.Sprintf("Couldn't edit software. URL (%q) doesn't exist. Please make sure that URLs are publicy accessible to the internet.", p.URL),
|
||||
)
|
||||
}
|
||||
|
||||
// Allow all 2xx and 3xx status codes in this pass.
|
||||
if resp.StatusCode > 400 {
|
||||
return fleet.NewInvalidArgumentError(
|
||||
"software.url",
|
||||
fmt.Sprintf("Couldn't edit software. URL (%q) received response status code %d.", p.URL, resp.StatusCode),
|
||||
)
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
// the max size error can be received either at client.Do or here when
|
||||
// reading the body if it's caught via a limited body reader.
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.Is(err, fleethttp.ErrMaxSizeExceeded) || errors.As(err, &maxBytesErr) {
|
||||
return fleet.NewInvalidArgumentError(
|
||||
"software.url",
|
||||
fmt.Sprintf("Couldn't edit software. URL (%q). The maximum file size is %d MB", p.URL, maxInstallerSizeBytes/(1024*1024)),
|
||||
)
|
||||
}
|
||||
return ctxerr.Wrapf(ctx, err, "reading installer %q contents", p.URL)
|
||||
}
|
||||
|
||||
installer := &fleet.UploadSoftwareInstallerPayload{
|
||||
TeamID: &tm.ID,
|
||||
InstallScript: p.InstallScript,
|
||||
PreInstallQuery: p.PreInstallQuery,
|
||||
PostInstallScript: p.PostInstallScript,
|
||||
InstallerFile: bytes.NewReader(bodyBytes),
|
||||
}
|
||||
|
||||
// set the filename before adding metadata, as it is used as fallback
|
||||
var filename string
|
||||
cdh, ok := resp.Header["Content-Disposition"]
|
||||
if ok && len(cdh) > 0 {
|
||||
_, params, err := mime.ParseMediaType(cdh[0])
|
||||
if err == nil {
|
||||
filename = params["filename"]
|
||||
}
|
||||
}
|
||||
installer.Filename = filename
|
||||
|
||||
ext, err := svc.addMetadataToSoftwarePayload(ctx, installer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// if filename was empty, try to extract it from the URL with the
|
||||
// now-known extension
|
||||
if filename == "" {
|
||||
filename = file.ExtractFilenameFromURLPath(p.URL, ext)
|
||||
}
|
||||
// if empty, resort to a default name
|
||||
if filename == "" {
|
||||
filename = fmt.Sprintf("package.%s", ext)
|
||||
}
|
||||
installer.Filename = filename
|
||||
if installer.Title == "" {
|
||||
installer.Title = filename
|
||||
}
|
||||
|
||||
installers[i] = installer
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
// NOTE: intentionally not wrapping to avoid polluting user
|
||||
// errors.
|
||||
return err
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, payload := range installers {
|
||||
if err := svc.storeSoftware(ctx, payload); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "storing software installer")
|
||||
}
|
||||
}
|
||||
|
||||
if err := svc.ds.BatchSetSoftwareInstallers(ctx, &tm.ID, installers); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "batch set software installers")
|
||||
}
|
||||
|
||||
// Note: per @noahtalerman we don't want activity items for CLI actions
|
||||
// anymore, so that's intentionally skipped.
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1085,6 +1085,10 @@ func (svc *Service) editTeamFromSpec(
|
|||
team.Config.Scripts = spec.Scripts
|
||||
}
|
||||
|
||||
if spec.Software.Set {
|
||||
team.Config.Software = spec.Software
|
||||
}
|
||||
|
||||
if len(secrets) > 0 {
|
||||
team.Secrets = secrets
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { pick } from "lodash";
|
|||
|
||||
import { normalizeEmptyValues } from "utilities/helpers";
|
||||
import { HOST_SUMMARY_DATA } from "utilities/constants";
|
||||
import { IGetHostSoftwareResponse } from "services/entities/hosts";
|
||||
import { IHostSoftware } from "interfaces/software";
|
||||
|
||||
const DEFAULT_HOST_PROFILE_MOCK: IHostMdmProfile = {
|
||||
profile_uuid: "123-abc",
|
||||
|
|
@ -116,4 +118,52 @@ export const createMockHostSummary = (overrides?: Partial<IHost>) => {
|
|||
);
|
||||
};
|
||||
|
||||
const DEFAULT_HOST_SOFTWARE_MOCK: IHostSoftware = {
|
||||
id: 1,
|
||||
name: "mock software.app",
|
||||
package_available_for_install: "mockSoftware.app",
|
||||
source: "apps",
|
||||
bundle_identifier: "com.test.mock",
|
||||
status: "installed",
|
||||
last_install: {
|
||||
install_uuid: "123-abc",
|
||||
installed_at: "2022-01-01T12:00:00Z",
|
||||
},
|
||||
installed_versions: [
|
||||
{
|
||||
version: "1.0.0",
|
||||
last_opened_at: "2022-01-01T12:00:00Z",
|
||||
vulnerabilities: ["CVE-2020-0001"],
|
||||
installed_paths: ["/Applications/mock.app"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const createMockHostSoftware = (
|
||||
overrides?: Partial<IHostSoftware>
|
||||
): IHostSoftware => {
|
||||
return {
|
||||
...DEFAULT_HOST_SOFTWARE_MOCK,
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_GET_HOST_SOFTWARE_RESPONSE_MOCK: IGetHostSoftwareResponse = {
|
||||
count: 1,
|
||||
software: [createMockHostSoftware()],
|
||||
meta: {
|
||||
has_next_results: false,
|
||||
has_previous_results: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const createMockGetHostSoftwareResponse = (
|
||||
overrides?: Partial<IGetHostSoftwareResponse>
|
||||
): IGetHostSoftwareResponse => {
|
||||
return {
|
||||
...DEFAULT_GET_HOST_SOFTWARE_RESPONSE_MOCK,
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
export default createMockHost;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
ISoftwareTitle,
|
||||
ISoftwareVulnerability,
|
||||
ISoftwareTitleVersion,
|
||||
ISoftwarePackage,
|
||||
} from "interfaces/software";
|
||||
import {
|
||||
ISoftwareTitlesResponse,
|
||||
|
|
@ -45,6 +46,7 @@ export const createMockSoftwareTitleVersion = (
|
|||
const DEFAULT_SOFTWARE_TITLE_MOCK: ISoftwareTitle = {
|
||||
id: 1,
|
||||
name: "mock software 1.app",
|
||||
software_package: null,
|
||||
versions_count: 1,
|
||||
source: "apps",
|
||||
hosts_count: 1,
|
||||
|
|
@ -147,3 +149,24 @@ export const createMockSoftwareVersionResponse = (
|
|||
): ISoftwareVersionResponse => {
|
||||
return { ...DEFAULT_SOFTWARE_VERSION_RESPONSE, ...overrides };
|
||||
};
|
||||
|
||||
const DEFAULT_SOFTWAREPACKAGE_MOCK: ISoftwarePackage = {
|
||||
name: "TestPackage-1.2.3.pkg",
|
||||
version: "1.2.3",
|
||||
uploaded_at: "2020-01-01T00:00:00.000Z",
|
||||
install_script: "sudo installer -pkg /temp/FalconSensor-6.44.pkg -target /",
|
||||
pre_install_query: "SELECT 1 FROM macos_profiles WHERE uuid='abc123';",
|
||||
post_install_script:
|
||||
"sudo /Applications/Falcon.app/Contents/Resources/falconctl license abc123",
|
||||
status: {
|
||||
installed: 1,
|
||||
pending: 2,
|
||||
failed: 3,
|
||||
},
|
||||
};
|
||||
|
||||
export const createMockSoftwarePackage = (
|
||||
overrides?: Partial<ISoftwarePackage>
|
||||
) => {
|
||||
return { ...DEFAULT_SOFTWAREPACKAGE_MOCK, ...overrides };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
import React from "react";
|
||||
import classnames from "classnames";
|
||||
|
||||
const baseClass = "data-set";
|
||||
|
||||
interface IDataSetProps {
|
||||
title: React.ReactNode;
|
||||
value: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DataSet = ({ title, value }: IDataSetProps) => {
|
||||
const DataSet = ({ title, value, className }: IDataSetProps) => {
|
||||
const classNames = classnames(baseClass, className);
|
||||
|
||||
return (
|
||||
<div className={`${baseClass}`}>
|
||||
<div className={classNames}>
|
||||
<dt>{title}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
|
|
|
|||
120
frontend/components/Editor/Editor.tsx
Normal file
120
frontend/components/Editor/Editor.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import classnames from "classnames";
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
import React, { ReactNode } from "react";
|
||||
import AceEditor from "react-ace";
|
||||
|
||||
const baseClass = "editor";
|
||||
|
||||
interface IEditorProps {
|
||||
focus?: boolean;
|
||||
label?: string;
|
||||
labelTooltip?: string | JSX.Element;
|
||||
error?: string | null;
|
||||
readOnly?: boolean;
|
||||
/**
|
||||
* Help text to display below the editor.
|
||||
*/
|
||||
helpText?: ReactNode;
|
||||
/** Sets the value of the input. Use this if you'd like the editor
|
||||
* to be a controlled component */
|
||||
value?: string;
|
||||
/** Sets the default value of the input. Use this if you'd like the editor
|
||||
* to be an uncontrolled component */
|
||||
defaultValue?: string;
|
||||
/** Enabled wrapping lines.
|
||||
* @default false
|
||||
*/
|
||||
wrapEnabled?: boolean;
|
||||
/** A unique name for the editor.
|
||||
* @default "editor"
|
||||
*/
|
||||
name?: string;
|
||||
maxLines?: number;
|
||||
className?: string;
|
||||
onChange?: (value: string, event?: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* This component is a generic editor that uses the AceEditor component.
|
||||
* TODO: We should move FleetAce and YamlAce into here and deprecate importing
|
||||
* them directly. This component should be used for all editor components and
|
||||
* be configurable from the props. We should look into dynmaic imports for
|
||||
* this.
|
||||
*/
|
||||
const Editor = ({
|
||||
helpText,
|
||||
label,
|
||||
labelTooltip,
|
||||
error,
|
||||
focus,
|
||||
value,
|
||||
defaultValue,
|
||||
readOnly = false,
|
||||
wrapEnabled = false,
|
||||
name = "editor",
|
||||
maxLines = 20,
|
||||
className,
|
||||
onChange,
|
||||
}: IEditorProps) => {
|
||||
const classNames = classnames(baseClass, className, {
|
||||
[`${baseClass}__error`]: !!error,
|
||||
});
|
||||
|
||||
const renderLabel = () => {
|
||||
const labelText = error || label;
|
||||
const labelClassName = classnames(`${baseClass}__label`, {
|
||||
[`${baseClass}__label--error`]: !!error,
|
||||
});
|
||||
|
||||
if (!labelText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (labelTooltip) {
|
||||
return (
|
||||
<TooltipWrapper
|
||||
className={labelClassName}
|
||||
tipContent={labelTooltip}
|
||||
position="top"
|
||||
>
|
||||
{labelText}
|
||||
</TooltipWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={labelClassName}>{labelText}</div>;
|
||||
};
|
||||
|
||||
const renderHelpText = () => {
|
||||
if (helpText) {
|
||||
return <div className={`${baseClass}__help-text`}>{helpText}</div>;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames}>
|
||||
{renderLabel()}
|
||||
<AceEditor
|
||||
wrapEnabled={wrapEnabled}
|
||||
name={name}
|
||||
className={baseClass}
|
||||
fontSize={14}
|
||||
theme="fleet"
|
||||
width="100%"
|
||||
readOnly={readOnly}
|
||||
minLines={2}
|
||||
maxLines={maxLines}
|
||||
editorProps={{ $blockScrolling: Infinity }}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
tabSize={2}
|
||||
focus={focus}
|
||||
onChange={onChange}
|
||||
/>
|
||||
{renderHelpText()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Editor;
|
||||
22
frontend/components/Editor/_styles.scss
Normal file
22
frontend/components/Editor/_styles.scss
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
.editor {
|
||||
|
||||
&__label {
|
||||
font-size: $x-small;
|
||||
font-weight: $bold;
|
||||
margin-bottom: $pad-small;
|
||||
|
||||
&--error {
|
||||
color: $core-vibrant-red;
|
||||
}
|
||||
}
|
||||
|
||||
&__help-text {
|
||||
@include help-text;
|
||||
}
|
||||
|
||||
&__error {
|
||||
.ace-fleet {
|
||||
border: 1px solid $core-vibrant-red;
|
||||
}
|
||||
}
|
||||
}
|
||||
1
frontend/components/Editor/index.ts
Normal file
1
frontend/components/Editor/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./Editor";
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import React from "react";
|
||||
import React, { ReactNode, useState } from "react";
|
||||
import classnames from "classnames";
|
||||
|
||||
import Button from "components/buttons/Button";
|
||||
import Card from "components/Card";
|
||||
import { GraphicNames } from "components/graphics";
|
||||
import Graphic from "components/Graphic";
|
||||
import Icon from "components/Icon";
|
||||
|
||||
const baseClass = "file-uploader";
|
||||
|
||||
|
|
@ -32,9 +33,20 @@ interface IFileUploaderProps {
|
|||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept
|
||||
*/
|
||||
accept?: string;
|
||||
/** The text to display on the upload button */
|
||||
/** The text to display on the upload button
|
||||
* @default "Upload"
|
||||
*/
|
||||
buttonMessage?: string;
|
||||
className?: string;
|
||||
/** renders the button to open the file uploader to appear as a button or
|
||||
* a link.
|
||||
* @default "button"
|
||||
*/
|
||||
buttonType?: "button" | "link";
|
||||
/** If provided FileUploader will display this component when the file is
|
||||
* selected. This is used for previewing the file before uploading.
|
||||
*/
|
||||
filePreview?: ReactNode;
|
||||
onFileUpload: (files: FileList | null) => void;
|
||||
}
|
||||
|
||||
|
|
@ -47,11 +59,26 @@ const FileUploader = ({
|
|||
additionalInfo,
|
||||
isLoading = false,
|
||||
accept,
|
||||
buttonMessage = "Upload",
|
||||
filePreview,
|
||||
className,
|
||||
buttonMessage = "Upload",
|
||||
buttonType = "button",
|
||||
onFileUpload,
|
||||
}: IFileUploaderProps) => {
|
||||
const classes = classnames(baseClass, className);
|
||||
const [isFileSelected, setIsFileSelected] = useState(false);
|
||||
|
||||
const classes = classnames(baseClass, className, {
|
||||
[`${baseClass}__file-preview`]: filePreview !== undefined && isFileSelected,
|
||||
});
|
||||
const buttonVariant = buttonType === "button" ? "brand" : "text-icon";
|
||||
|
||||
const onFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
onFileUpload(files);
|
||||
setIsFileSelected(true);
|
||||
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const renderGraphics = () => {
|
||||
const graphicNamesArr =
|
||||
|
|
@ -64,29 +91,36 @@ const FileUploader = ({
|
|||
/>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card color="gray" className={classes}>
|
||||
<div className={`${baseClass}__graphics`}>{renderGraphics()}</div>
|
||||
<p className={`${baseClass}__message`}>{message}</p>
|
||||
{additionalInfo && (
|
||||
<p className={`${baseClass}__additional-info`}>{additionalInfo}</p>
|
||||
{isFileSelected && filePreview ? (
|
||||
filePreview
|
||||
) : (
|
||||
<>
|
||||
<div className={`${baseClass}__graphics`}>{renderGraphics()}</div>
|
||||
<p className={`${baseClass}__message`}>{message}</p>
|
||||
{additionalInfo && (
|
||||
<p className={`${baseClass}__additional-info`}>{additionalInfo}</p>
|
||||
)}
|
||||
<Button
|
||||
className={`${baseClass}__upload-button`}
|
||||
variant={buttonVariant}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<label htmlFor="upload-file">
|
||||
{buttonType === "link" && <Icon name="upload" />}
|
||||
<span>{buttonMessage}</span>
|
||||
</label>
|
||||
</Button>
|
||||
<input
|
||||
accept={accept}
|
||||
id="upload-file"
|
||||
type="file"
|
||||
onChange={onFileSelect}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
className={`${baseClass}__upload-button`}
|
||||
variant="brand"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<label htmlFor="upload-file">{buttonMessage}</label>
|
||||
</Button>
|
||||
<input
|
||||
accept={accept}
|
||||
id="upload-file"
|
||||
type="file"
|
||||
onChange={(e) => {
|
||||
onFileUpload(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@
|
|||
text-align: center;
|
||||
gap: $pad-small;
|
||||
|
||||
// when the file preview is showing, we want the padding to be
|
||||
// slightly smaller on the top and bottom.
|
||||
&__file-preview {
|
||||
padding: $pad-medium $pad-large;
|
||||
}
|
||||
|
||||
&__graphics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -39,6 +45,7 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $pad-small;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback, useRef } from "react";
|
||||
import React, { ReactNode, useCallback, useRef } from "react";
|
||||
import AceEditor from "react-ace";
|
||||
import ReactAce from "react-ace/lib/ace";
|
||||
import { IAceEditor } from "react-ace/lib/types";
|
||||
|
|
@ -30,10 +30,13 @@ export interface IFleetAceProps {
|
|||
name?: string;
|
||||
value?: string;
|
||||
readOnly?: boolean;
|
||||
maxLines?: number;
|
||||
showGutter?: boolean;
|
||||
wrapEnabled?: boolean;
|
||||
/** @deprecated use the prop `className` instead */
|
||||
wrapperClassName?: string;
|
||||
helpText?: string;
|
||||
className?: string;
|
||||
helpText?: ReactNode;
|
||||
labelActionComponent?: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
onBlur?: (editor?: IAceEditor) => void;
|
||||
|
|
@ -53,9 +56,11 @@ const FleetAce = ({
|
|||
name = "query-editor",
|
||||
value,
|
||||
readOnly,
|
||||
maxLines = 20,
|
||||
showGutter = true,
|
||||
wrapEnabled = false,
|
||||
wrapperClassName,
|
||||
className,
|
||||
helpText,
|
||||
style,
|
||||
onBlur,
|
||||
|
|
@ -64,7 +69,7 @@ const FleetAce = ({
|
|||
handleSubmit = noop,
|
||||
}: IFleetAceProps): JSX.Element => {
|
||||
const editorRef = useRef<ReactAce>(null);
|
||||
const wrapperClass = classnames(wrapperClassName, baseClass, {
|
||||
const wrapperClass = classnames(className, wrapperClassName, baseClass, {
|
||||
[`${baseClass}__wrapper--error`]: !!error,
|
||||
});
|
||||
|
||||
|
|
@ -250,7 +255,7 @@ const FleetAce = ({
|
|||
fontSize={fontSize}
|
||||
mode="fleet"
|
||||
minLines={2}
|
||||
maxLines={20}
|
||||
maxLines={maxLines}
|
||||
name={name}
|
||||
onChange={onChange}
|
||||
onBlur={onBlurHandler}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import React from "react";
|
||||
import { InjectedRouter } from "react-router";
|
||||
import ReactTooltip from "react-tooltip";
|
||||
|
||||
import { uniqueId } from "lodash";
|
||||
|
||||
import Icon from "components/Icon";
|
||||
|
||||
import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
|
||||
|
||||
import LinkCell from "../LinkCell";
|
||||
|
||||
const baseClass = "software-name-cell";
|
||||
|
||||
const InstallIconWithTooltip = () => {
|
||||
const tooltipId = uniqueId();
|
||||
return (
|
||||
<div className={`${baseClass}__install-icon-with-tooltip`}>
|
||||
<div
|
||||
className={`${baseClass}__install-icon-tooltip`}
|
||||
data-tip
|
||||
data-for={tooltipId}
|
||||
>
|
||||
<Icon name="install" className={`${baseClass}__install-icon`} />
|
||||
</div>
|
||||
<ReactTooltip
|
||||
className={`${baseClass}__install-tooltip`}
|
||||
place="top"
|
||||
effect="solid"
|
||||
backgroundColor="#3e4771"
|
||||
id={tooltipId}
|
||||
data-html
|
||||
>
|
||||
<span className={`${baseClass}__install-tooltip-text`}>
|
||||
Software can be installed on Host details page.
|
||||
</span>
|
||||
</ReactTooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ISoftwareNameCellProps {
|
||||
name: string;
|
||||
source: string;
|
||||
path?: string;
|
||||
router?: InjectedRouter;
|
||||
hasPackage?: boolean;
|
||||
}
|
||||
|
||||
const SoftwareNameCell = ({
|
||||
name,
|
||||
source,
|
||||
path,
|
||||
router,
|
||||
hasPackage = false,
|
||||
}: ISoftwareNameCellProps) => {
|
||||
// NO path or router means it's not clickable. return
|
||||
// a non-clickable cell early
|
||||
if (!router || !path) {
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<SoftwareIcon name={name} source={source} />
|
||||
<span className="software-name">{name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const onClickSoftware = (e: React.MouseEvent) => {
|
||||
// Allows for button to be clickable in a clickable row
|
||||
e.stopPropagation();
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<LinkCell
|
||||
className={baseClass}
|
||||
path={path}
|
||||
customOnClick={onClickSoftware}
|
||||
value={
|
||||
<>
|
||||
<SoftwareIcon name={name} source={source} />
|
||||
<span className="software-name">{name}</span>
|
||||
{hasPackage && <InstallIconWithTooltip />}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SoftwareNameCell;
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
.software-name-cell {
|
||||
// TODO: we do not want to use !important but have to for now. We need to pull
|
||||
// the .link-cell styles into the LinkCell component in order to
|
||||
// decrease the specificity of the styles. This will allow us to remove the
|
||||
// !important from here.
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: $pad-small;
|
||||
|
||||
.software-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid $ui-fleet-black-10;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
&__install-icon {
|
||||
// TODO: we do not want to use !important but have to for now. This is
|
||||
// the same issue as the .software-name-cell class display value.
|
||||
display: inline-flex !important;
|
||||
}
|
||||
|
||||
&__install-tooltip-text {
|
||||
font-weight: $regular;
|
||||
font-size: $xx-small;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./SoftwareNameCell";
|
||||
|
|
@ -51,7 +51,7 @@ const TextCell = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<span className={`text-cell ${classes} ${greyed && "grey-cell"}`}>
|
||||
<span className={`text-cell ${classes} ${greyed ? "grey-cell" : ""}`}>
|
||||
{formatter(val) || renderEmptyCell()}
|
||||
</span>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@ interface ITableContainerProps<T = any> {
|
|||
primarySelectAction?: IActionButtonProps;
|
||||
/** Secondary button/s after selecting a row */
|
||||
secondarySelectActions?: IActionButtonProps[]; // TODO: Combine with primarySelectAction as these are all rendered in the same spot
|
||||
/**
|
||||
* @deprecated please use renderCount instead
|
||||
* */
|
||||
filteredCount?: number;
|
||||
searchToolTipText?: string;
|
||||
searchQueryColumn?: string;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ interface IDownload {
|
|||
color?: Colors;
|
||||
size?: IconSizes;
|
||||
}
|
||||
|
||||
const Download = ({
|
||||
color = "ui-fleet-black-75",
|
||||
size = "medium",
|
||||
|
|
|
|||
40
frontend/components/icons/Install.tsx
Normal file
40
frontend/components/icons/Install.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import React from "react";
|
||||
|
||||
import { COLORS, Colors } from "styles/var/colors";
|
||||
import { ICON_SIZES, IconSizes } from "styles/var/icon_sizes";
|
||||
|
||||
interface IInstallProps {
|
||||
color?: Colors;
|
||||
size?: IconSizes;
|
||||
}
|
||||
|
||||
const Install = ({
|
||||
color = "ui-fleet-black-50",
|
||||
size = "medium",
|
||||
}: IInstallProps) => {
|
||||
return (
|
||||
<svg
|
||||
width={ICON_SIZES[size]}
|
||||
height={ICON_SIZES[size]}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
clipPath="url(#a)"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
fill={COLORS[color]}
|
||||
>
|
||||
<path d="M8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12Zm0 2A8 8 0 1 0 8 0a8 8 0 0 0 0 16Z" />
|
||||
<path d="M8 3.5a1 1 0 0 1 1 1v4.865l1.36-1.133a1 1 0 1 1 1.28 1.536l-3 2.5a1 1 0 0 1-1.28 0l-3-2.5a1 1 0 1 1 1.28-1.536L7 9.365V4.5a1 1 0 0 1 1-1Z" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<path fill="#fff" d="M0 0h16v16H0z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Install;
|
||||
32
frontend/components/icons/Settings.tsx
Normal file
32
frontend/components/icons/Settings.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import React from "react";
|
||||
|
||||
import { COLORS, Colors } from "styles/var/colors";
|
||||
import { ICON_SIZES, IconSizes } from "styles/var/icon_sizes";
|
||||
|
||||
interface ISettingsProps {
|
||||
color?: Colors;
|
||||
size?: IconSizes;
|
||||
}
|
||||
|
||||
const Settings = ({
|
||||
size = "medium",
|
||||
color = "ui-fleet-black-75",
|
||||
}: ISettingsProps) => {
|
||||
return (
|
||||
<svg
|
||||
width={ICON_SIZES[size]}
|
||||
height={ICON_SIZES[size]}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.39.876A1 1 0 0 1 7.384 0h1.234a1 1 0 0 1 .992.876l.175 1.394c.35.109.687.249 1.006.417l1.11-.863a1 1 0 0 1 1.32.083l.873.873a1 1 0 0 1 .082 1.32l-.862 1.11c.168.32.308.656.417 1.006l1.394.175a1 1 0 0 1 .876.992v1.234a1 1 0 0 1-.876.992l-1.394.175c-.109.35-.249.687-.417 1.006l.862 1.11a1 1 0 0 1-.082 1.32l-.873.873a1 1 0 0 1-1.32.082l-1.11-.862c-.32.168-.656.308-1.006.417l-.175 1.394a1 1 0 0 1-.992.876H7.383a1 1 0 0 1-.992-.876l-.175-1.394a5.959 5.959 0 0 1-1.006-.417l-1.11.862a1 1 0 0 1-1.32-.082l-.873-.873a1 1 0 0 1-.083-1.32l.863-1.11a5.962 5.962 0 0 1-.417-1.006L.876 9.609A1 1 0 0 1 0 8.617V7.383a1 1 0 0 1 .876-.992l1.394-.175c.109-.35.249-.687.417-1.006L1.824 4.1a1 1 0 0 1 .083-1.32l.873-.873a1 1 0 0 1 1.32-.083l1.11.863c.32-.168.656-.308 1.006-.417L6.391.876ZM4 8a4 4 0 1 0 6.831-2.826l-.005-.005A4 4 0 0 0 4 8Z"
|
||||
fill={COLORS[color]}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
|
|
@ -56,6 +56,8 @@ import Profile from "./Profile";
|
|||
import Download from "./Download";
|
||||
import Upload from "./Upload";
|
||||
import Refresh from "./Refresh";
|
||||
import Install from "./Install";
|
||||
import Settings from "./Settings";
|
||||
|
||||
// a mapping of the usable names of icons to the icon source.
|
||||
export const ICON_MAP = {
|
||||
|
|
@ -116,6 +118,8 @@ export const ICON_MAP = {
|
|||
download: Download,
|
||||
upload: Upload,
|
||||
refresh: Refresh,
|
||||
install: Install,
|
||||
settings: Settings,
|
||||
};
|
||||
|
||||
export type IconNames = keyof typeof ICON_MAP;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useContext, useEffect, useMemo } from "react";
|
||||
import { InjectedRouter } from "react-router";
|
||||
import { findLastIndex, trimStart } from "lodash";
|
||||
import { findLastIndex, over, trimStart } from "lodash";
|
||||
|
||||
import { AppContext } from "context/app";
|
||||
import { TableContext } from "context/table";
|
||||
|
|
@ -16,6 +16,30 @@ import {
|
|||
import { IUser, IUserRole } from "interfaces/user";
|
||||
import permissions from "utilities/permissions";
|
||||
import sort from "utilities/sort";
|
||||
import { HOSTS_QUERY_PARAMS } from "services/entities/hosts";
|
||||
|
||||
type OnTeamChangeFuncShouldStripParam = (
|
||||
teamIdForApi: number | undefined
|
||||
) => boolean;
|
||||
|
||||
type OnTeamChangeFuncShouldReplaceParam = (
|
||||
teamIdForApi: number | undefined
|
||||
) => [boolean, string];
|
||||
|
||||
/**
|
||||
* This type is used to define functions that determine whether a query parameter should be stripped or replaced
|
||||
* when the team id changes.
|
||||
*
|
||||
* The key is the name of the query parameter and the value is a function that receives the new team
|
||||
* id with a return type of either:
|
||||
* - boolean indicating whether the query parameter should be stripped
|
||||
* - tuple of a boolean and a string, where the boolean indicates whether the query parameter should be replaced
|
||||
* and the string is the new value for the query parameter
|
||||
*/
|
||||
export type IConfigOverrideParamsOnTeamChange = Record<
|
||||
string,
|
||||
OnTeamChangeFuncShouldReplaceParam | OnTeamChangeFuncShouldStripParam
|
||||
>;
|
||||
|
||||
const splitQueryStringParts = (queryString: string) =>
|
||||
trimStart(queryString, "?")
|
||||
|
|
@ -27,7 +51,8 @@ const joinQueryStringParts = (parts: string[]) =>
|
|||
|
||||
const rebuildQueryStringWithTeamId = (
|
||||
queryString: string,
|
||||
newTeamId: number
|
||||
newTeamId: number,
|
||||
configAdditionalParams?: IConfigOverrideParamsOnTeamChange
|
||||
) => {
|
||||
const parts = splitQueryStringParts(queryString);
|
||||
|
||||
|
|
@ -67,6 +92,41 @@ const rebuildQueryStringWithTeamId = (
|
|||
parts.splice(teamIndex, 1); // just remove the old team part
|
||||
}
|
||||
|
||||
if (configAdditionalParams) {
|
||||
Object.entries(configAdditionalParams).forEach(([paramName, fn]) => {
|
||||
let shouldStrip = false;
|
||||
let shouldReplace = false;
|
||||
let replaceString = "";
|
||||
|
||||
const val = fn(newTeamId);
|
||||
if (Array.isArray(val)) {
|
||||
[shouldReplace, replaceString] = val;
|
||||
} else if (typeof val === "boolean") {
|
||||
shouldStrip = val;
|
||||
}
|
||||
|
||||
if (shouldStrip || shouldReplace) {
|
||||
const paramIndex = parts.findIndex((p) =>
|
||||
p.startsWith(`${paramName}=`)
|
||||
);
|
||||
|
||||
if (shouldStrip && paramIndex !== -1) {
|
||||
parts.splice(paramIndex, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldReplace) {
|
||||
const newPart = `${paramName}=${replaceString}`;
|
||||
if (paramIndex === -1) {
|
||||
parts.splice(paramIndex, 1, newPart);
|
||||
} else {
|
||||
parts.push(newPart);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return joinQueryStringParts(parts);
|
||||
};
|
||||
|
||||
|
|
@ -223,6 +283,7 @@ export const useTeamIdParam = ({
|
|||
includeNoTeam,
|
||||
permittedAccessByTeamRole,
|
||||
resetSelectedRowsOnTeamChange = true,
|
||||
overrideParamsOnTeamChange,
|
||||
}: {
|
||||
location?: {
|
||||
pathname: string;
|
||||
|
|
@ -235,6 +296,7 @@ export const useTeamIdParam = ({
|
|||
includeNoTeam: boolean;
|
||||
permittedAccessByTeamRole?: Record<IUserRole, boolean>;
|
||||
resetSelectedRowsOnTeamChange?: boolean;
|
||||
overrideParamsOnTeamChange?: IConfigOverrideParamsOnTeamChange;
|
||||
}) => {
|
||||
const { hash, pathname, query, search } = location;
|
||||
const {
|
||||
|
|
@ -282,11 +344,18 @@ export const useTeamIdParam = ({
|
|||
|
||||
router.replace(
|
||||
pathname
|
||||
.concat(rebuildQueryStringWithTeamId(search, teamId))
|
||||
.concat(
|
||||
rebuildQueryStringWithTeamId(
|
||||
search,
|
||||
teamId,
|
||||
overrideParamsOnTeamChange
|
||||
)
|
||||
)
|
||||
.concat(hash || "")
|
||||
);
|
||||
},
|
||||
[
|
||||
overrideParamsOnTeamChange,
|
||||
resetSelectedRowsOnTeamChange,
|
||||
router,
|
||||
pathname,
|
||||
|
|
|
|||
|
|
@ -71,13 +71,17 @@ export enum ActivityType {
|
|||
DeletedDeclarationProfile = "deleted_declaration_profile",
|
||||
EditedDeclarationProfile = "edited_declaration_profile",
|
||||
ResentConfigurationProfile = "resent_configuration_profile",
|
||||
AddedSoftware = "added_software",
|
||||
DeletedSoftware = "deleted_software",
|
||||
InstalledSoftware = "installed_software",
|
||||
}
|
||||
|
||||
// This is a subset of ActivityType that are shown only for the host past activities
|
||||
export type IHostPastActivityType =
|
||||
export type IHostActivityType =
|
||||
| ActivityType.RanScript
|
||||
| ActivityType.LockedHost
|
||||
| ActivityType.UnlockedHost;
|
||||
| ActivityType.UnlockedHost
|
||||
| ActivityType.InstalledSoftware;
|
||||
|
||||
export interface IActivity {
|
||||
created_at: string;
|
||||
|
|
@ -90,8 +94,9 @@ export interface IActivity {
|
|||
details?: IActivityDetails;
|
||||
}
|
||||
|
||||
export type IPastActivity = Omit<IActivity, "type"> & {
|
||||
type: IHostPastActivityType;
|
||||
export type IHostActivity = Omit<IActivity, "type" | "details"> & {
|
||||
type: IHostActivityType;
|
||||
details: IActivityDetails;
|
||||
};
|
||||
|
||||
export interface IActivityDetails {
|
||||
|
|
@ -118,6 +123,7 @@ export interface IActivityDetails {
|
|||
host_display_name?: string;
|
||||
host_display_names?: string[];
|
||||
host_ids?: number[];
|
||||
host_id?: number;
|
||||
host_platform?: string;
|
||||
installed_from_dep?: boolean;
|
||||
mdm_platform?: "microsoft" | "apple";
|
||||
|
|
@ -132,5 +138,8 @@ export interface IActivityDetails {
|
|||
deadline_days?: number;
|
||||
grace_period_days?: number;
|
||||
stats?: ISchedulableQueryStats;
|
||||
host_id?: number;
|
||||
software_title?: string;
|
||||
software_package?: string;
|
||||
status?: string;
|
||||
install_uuid?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,9 +50,24 @@ export interface ISoftwareTitleVersion {
|
|||
hosts_count?: number;
|
||||
}
|
||||
|
||||
export interface ISoftwarePackage {
|
||||
name: string;
|
||||
version: string;
|
||||
uploaded_at: string;
|
||||
install_script: string;
|
||||
pre_install_query?: string;
|
||||
post_install_script?: string;
|
||||
status: {
|
||||
installed: number;
|
||||
pending: number;
|
||||
failed: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ISoftwareTitle {
|
||||
id: number;
|
||||
name: string;
|
||||
software_package: ISoftwarePackage | null;
|
||||
versions_count: number;
|
||||
source: string;
|
||||
hosts_count: number;
|
||||
|
|
@ -123,7 +138,7 @@ export const formatSoftwareType = ({
|
|||
browser,
|
||||
}: {
|
||||
source: string;
|
||||
browser: string;
|
||||
browser?: string;
|
||||
}) => {
|
||||
let type = SOURCE_TYPE_CONVERSION[source] || "Unknown";
|
||||
if (browser) {
|
||||
|
|
@ -133,3 +148,71 @@ export const formatSoftwareType = ({
|
|||
}
|
||||
return type;
|
||||
};
|
||||
|
||||
/**
|
||||
* This list comprises all possible states of software install operations.
|
||||
*/
|
||||
export const SOFTWARE_INSTALL_STATUSES = [
|
||||
"failed",
|
||||
"installed",
|
||||
"pending",
|
||||
] as const;
|
||||
|
||||
/*
|
||||
* SoftwareInstallStatus represents the possible states of software install operations.
|
||||
*/
|
||||
export type SoftwareInstallStatus = typeof SOFTWARE_INSTALL_STATUSES[number];
|
||||
|
||||
export const isValidSoftwareInstallStatus = (
|
||||
s: string | undefined
|
||||
): s is SoftwareInstallStatus =>
|
||||
!!s && SOFTWARE_INSTALL_STATUSES.includes(s as SoftwareInstallStatus);
|
||||
|
||||
/**
|
||||
* ISoftwareInstallResult is the shape of a software install result object
|
||||
* returned by the Fleet API.
|
||||
*/
|
||||
export interface ISoftwareInstallResult {
|
||||
install_uuid: string;
|
||||
software_title: string;
|
||||
software_title_id: number;
|
||||
software_package: string;
|
||||
host_id: number;
|
||||
host_display_name: string;
|
||||
status: SoftwareInstallStatus;
|
||||
detail: string;
|
||||
output: string;
|
||||
pre_install_query_output: string;
|
||||
post_install_script_output: string;
|
||||
}
|
||||
|
||||
export interface ISoftwareInstallResults {
|
||||
results: ISoftwareInstallResult;
|
||||
}
|
||||
|
||||
// ISoftwareInstallerType defines the supported installer types for
|
||||
// software uploaded by the IT admin.
|
||||
export type ISoftwareInstallerType = "pkg" | "msi" | "deb" | "exe";
|
||||
|
||||
export interface ISoftwareLastInstall {
|
||||
install_uuid: string;
|
||||
installed_at: string;
|
||||
}
|
||||
|
||||
export interface ISoftwareInstallVersion {
|
||||
version: string;
|
||||
last_opened_at: string | null;
|
||||
vulnerabilities: string[] | null;
|
||||
installed_paths: string[];
|
||||
}
|
||||
|
||||
export interface IHostSoftware {
|
||||
id: number;
|
||||
name: string;
|
||||
package_available_for_install?: string | null;
|
||||
source: string;
|
||||
bundle_identifier?: string;
|
||||
status: SoftwareInstallStatus | null;
|
||||
last_install: ISoftwareLastInstall | null;
|
||||
installed_versions: ISoftwareInstallVersion[] | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ import Button from "components/buttons/Button";
|
|||
import Spinner from "components/Spinner";
|
||||
// @ts-ignore
|
||||
import FleetIcon from "components/icons/FleetIcon";
|
||||
|
||||
import { SoftwareInstallDetailsModal } from "pages/SoftwarePage/components/SoftwareInstallDetails";
|
||||
|
||||
import ActivityItem from "./ActivityItem";
|
||||
import ScriptDetailsModal from "./components/ScriptDetailsModal/ScriptDetailsModal";
|
||||
|
||||
|
|
@ -35,6 +38,7 @@ const ActivityFeed = ({
|
|||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [showShowQueryModal, setShowShowQueryModal] = useState(false);
|
||||
const [showScriptDetailsModal, setShowScriptDetailsModal] = useState(false);
|
||||
const [installedSoftwareUuid, setInstalledSoftwareUuid] = useState("");
|
||||
const queryShown = useRef("");
|
||||
const queryImpact = useRef<string | undefined>(undefined);
|
||||
const scriptExecutionId = useRef("");
|
||||
|
|
@ -81,6 +85,7 @@ const ActivityFeed = ({
|
|||
activityType: ActivityType,
|
||||
details: IActivityDetails
|
||||
) => {
|
||||
console.log("activityType", activityType);
|
||||
switch (activityType) {
|
||||
case ActivityType.LiveQuery:
|
||||
queryShown.current = details.query_sql ?? "";
|
||||
|
|
@ -93,6 +98,11 @@ const ActivityFeed = ({
|
|||
scriptExecutionId.current = details.script_execution_id ?? "";
|
||||
setShowScriptDetailsModal(true);
|
||||
break;
|
||||
case ActivityType.InstalledSoftware:
|
||||
// installUuid.current = details.install_uuid ?? "";
|
||||
// console.log("installUuid.current", installUuid.current);
|
||||
setInstalledSoftwareUuid(details.install_uuid ?? "");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
@ -184,6 +194,12 @@ const ActivityFeed = ({
|
|||
onCancel={() => setShowScriptDetailsModal(false)}
|
||||
/>
|
||||
)}
|
||||
{installedSoftwareUuid && (
|
||||
<SoftwareInstallDetailsModal
|
||||
installUuid={installedSoftwareUuid}
|
||||
onCancel={() => setInstalledSoftwareUuid("")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import Icon from "components/Icon";
|
|||
import ReactTooltip from "react-tooltip";
|
||||
import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTooltip";
|
||||
import { COLORS } from "styles/var/colors";
|
||||
import { getSoftwareInstallStatusPredicate } from "pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem";
|
||||
|
||||
const baseClass = "activity-item";
|
||||
|
||||
|
|
@ -820,6 +821,76 @@ const TAGGED_TEMPLATES = {
|
|||
</>
|
||||
);
|
||||
},
|
||||
addedSoftware: (activity: IActivity) => {
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
added <b>{activity.details?.software_title}</b> (
|
||||
{activity.details?.software_package}) software to{" "}
|
||||
{activity.details?.team_name ? (
|
||||
<>
|
||||
{" "}
|
||||
the <b>{activity.details?.team_name}</b> team.
|
||||
</>
|
||||
) : (
|
||||
"no team."
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
deletedSoftware: (activity: IActivity) => {
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
deleted <b>{activity.details?.software_title}</b> (
|
||||
{activity.details?.software_package}) software from{" "}
|
||||
{activity.details?.team_name ? (
|
||||
<>
|
||||
{" "}
|
||||
the <b>{activity.details?.team_name}</b> team.
|
||||
</>
|
||||
) : (
|
||||
"no team."
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
installedSoftware: (
|
||||
activity: IActivity,
|
||||
onDetailsClick?: (type: ActivityType, details: IActivityDetails) => void
|
||||
) => {
|
||||
const { details } = activity;
|
||||
if (!details) {
|
||||
return TAGGED_TEMPLATES.defaultActivityTemplate(activity);
|
||||
}
|
||||
|
||||
console.log("onDetailsClick", onDetailsClick);
|
||||
|
||||
const {
|
||||
host_display_name: hostName,
|
||||
software_title: title,
|
||||
status,
|
||||
install_uuid,
|
||||
} = details;
|
||||
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
{getSoftwareInstallStatusPredicate(status)} <b>{title}</b> software on{" "}
|
||||
<b>{hostName}</b>.{" "}
|
||||
<Button
|
||||
className={`${baseClass}__show-query-link`}
|
||||
variant="text-link"
|
||||
onClick={() =>
|
||||
onDetailsClick?.(ActivityType.InstalledSoftware, { install_uuid })
|
||||
}
|
||||
>
|
||||
Show details{" "}
|
||||
<Icon className={`${baseClass}__show-query-icon`} name="eye" />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const getDetail = (
|
||||
|
|
@ -993,6 +1064,15 @@ const getDetail = (
|
|||
case ActivityType.ResentConfigurationProfile: {
|
||||
return TAGGED_TEMPLATES.resentConfigProfile(activity);
|
||||
}
|
||||
case ActivityType.AddedSoftware: {
|
||||
return TAGGED_TEMPLATES.addedSoftware(activity);
|
||||
}
|
||||
case ActivityType.DeletedSoftware: {
|
||||
return TAGGED_TEMPLATES.deletedSoftware(activity);
|
||||
}
|
||||
case ActivityType.InstalledSoftware: {
|
||||
return TAGGED_TEMPLATES.installedSoftware(activity, onDetailsClick);
|
||||
}
|
||||
|
||||
default: {
|
||||
return TAGGED_TEMPLATES.defaultActivityTemplate(activity);
|
||||
|
|
|
|||
|
|
@ -98,7 +98,6 @@ const Software = ({
|
|||
emptyComponent={() => (
|
||||
<EmptySoftwareTable
|
||||
isCollectingSoftware={isCollectingInventory}
|
||||
noSandboxHosts={noSandboxHosts}
|
||||
/>
|
||||
)}
|
||||
showMarkAllPages={false}
|
||||
|
|
@ -125,8 +124,7 @@ const Software = ({
|
|||
emptyComponent={() => (
|
||||
<EmptySoftwareTable
|
||||
isCollectingSoftware={isCollectingInventory}
|
||||
noSandboxHosts={noSandboxHosts}
|
||||
isFilterVulnerable
|
||||
softwareFilter="vulnerableSoftware"
|
||||
/>
|
||||
)}
|
||||
showMarkAllPages={false}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,9 @@ const FileChooser = ({
|
|||
</div>
|
||||
);
|
||||
|
||||
// TODO: if we reuse this one more time, we should consider moving this
|
||||
// into FileUploader as a default preview. Currently we have this in
|
||||
// AddSoftwareForm.tsx and here.
|
||||
const FileDetails = ({
|
||||
baseClass,
|
||||
details: { name, platform },
|
||||
|
|
|
|||
|
|
@ -180,11 +180,7 @@ const SoftwareOSTable = ({
|
|||
isLoading={isLoading}
|
||||
resultsTitle="items"
|
||||
emptyComponent={() => (
|
||||
<EmptySoftwareTable
|
||||
isSoftwareDisabled={!isSoftwareEnabled}
|
||||
isSandboxMode={isSandboxMode}
|
||||
noSandboxHosts={noSandboxHosts}
|
||||
/>
|
||||
<EmptySoftwareTable isSoftwareDisabled={!isSoftwareEnabled} />
|
||||
)}
|
||||
defaultSortHeader={orderKey}
|
||||
defaultSortDirection={orderDirection}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import TeamsHeader from "components/TeamsHeader";
|
|||
import TabsWrapper from "components/TabsWrapper";
|
||||
|
||||
import ManageAutomationsModal from "./components/ManageSoftwareAutomationsModal";
|
||||
import AddSoftwareModal from "./components/AddSoftwareModal";
|
||||
import { ISoftwareDropdownFilterVal } from "./SoftwareTitles/SoftwareTable/helpers";
|
||||
|
||||
interface ISoftwareSubNavItem {
|
||||
name: string;
|
||||
|
|
@ -61,6 +63,16 @@ const getTabIndex = (path: string): number => {
|
|||
});
|
||||
};
|
||||
|
||||
const getSoftwareFilter = (
|
||||
vulnerable?: string,
|
||||
installable?: string
|
||||
): ISoftwareDropdownFilterVal => {
|
||||
if (installable === "true") return "installableSoftware";
|
||||
return vulnerable && vulnerable === "true"
|
||||
? "vulnerableSoftware"
|
||||
: "allSoftware";
|
||||
};
|
||||
|
||||
// default values for query params used on this page if not provided
|
||||
const DEFAULT_SORT_DIRECTION = "desc";
|
||||
const DEFAULT_SORT_HEADER = "hosts_count";
|
||||
|
|
@ -92,6 +104,7 @@ interface ISoftwarePageProps {
|
|||
query: {
|
||||
team_id?: string;
|
||||
vulnerable?: string;
|
||||
available_for_install?: string;
|
||||
exploit?: string;
|
||||
page?: string;
|
||||
query?: string;
|
||||
|
|
@ -110,6 +123,8 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
|
|||
isGlobalAdmin,
|
||||
isGlobalMaintainer,
|
||||
isOnGlobalTeam,
|
||||
isTeamAdmin,
|
||||
isTeamMaintainer,
|
||||
isPremiumTier,
|
||||
isSandboxMode,
|
||||
} = useContext(AppContext);
|
||||
|
|
@ -132,16 +147,19 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
|
|||
: DEFAULT_PAGE;
|
||||
// TODO: move these down into the Software Titles component.
|
||||
const query = queryParams && queryParams.query ? queryParams.query : "";
|
||||
const showVulnerableSoftware =
|
||||
queryParams !== undefined && queryParams.vulnerable === "true";
|
||||
const showExploitedVulnerabilitiesOnly =
|
||||
queryParams !== undefined && queryParams.exploit === "true";
|
||||
const softwareFilter = getSoftwareFilter(
|
||||
queryParams.vulnerable,
|
||||
queryParams.available_for_install
|
||||
);
|
||||
|
||||
const [showManageAutomationsModal, setShowManageAutomationsModal] = useState(
|
||||
false
|
||||
);
|
||||
const [showPreviewPayloadModal, setShowPreviewPayloadModal] = useState(false);
|
||||
const [showPreviewTicketModal, setShowPreviewTicketModal] = useState(false);
|
||||
const [showAddSoftwareModal, setShowAddSoftwareModal] = useState(false);
|
||||
|
||||
const {
|
||||
currentTeamId,
|
||||
|
|
@ -218,13 +236,14 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
|
|||
const isSoftwareConfigLoaded =
|
||||
!isFetchingSoftwareConfig && !softwareConfigError && !!softwareConfig;
|
||||
|
||||
const canManageAutomations =
|
||||
isGlobalAdmin && (!isPremiumTier || !isAnyTeamSelected);
|
||||
|
||||
const toggleManageAutomationsModal = useCallback(() => {
|
||||
setShowManageAutomationsModal(!showManageAutomationsModal);
|
||||
}, [setShowManageAutomationsModal, showManageAutomationsModal]);
|
||||
|
||||
const toggleAddSoftwareModal = useCallback(() => {
|
||||
setShowAddSoftwareModal(!showAddSoftwareModal);
|
||||
}, [showAddSoftwareModal]);
|
||||
|
||||
const togglePreviewPayloadModal = useCallback(() => {
|
||||
setShowPreviewPayloadModal(!showPreviewPayloadModal);
|
||||
}, [setShowPreviewPayloadModal, showPreviewPayloadModal]);
|
||||
|
|
@ -295,18 +314,40 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
|
|||
);
|
||||
};
|
||||
|
||||
const renderPageActions = () => {
|
||||
const canManageAutomations =
|
||||
isGlobalAdmin && (!isPremiumTier || !isAnyTeamSelected);
|
||||
|
||||
const canAddSoftware =
|
||||
isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer;
|
||||
|
||||
if (!isSoftwareConfigLoaded) return null;
|
||||
|
||||
return (
|
||||
<div className={`${baseClass}__action-buttons`}>
|
||||
{canManageAutomations && (
|
||||
<Button
|
||||
onClick={toggleManageAutomationsModal}
|
||||
className={`${baseClass}__manage-automations`}
|
||||
variant="text-link"
|
||||
>
|
||||
<span>Manage automations</span>
|
||||
</Button>
|
||||
)}
|
||||
{canAddSoftware && (
|
||||
<Button onClick={toggleAddSoftwareModal} variant="brand">
|
||||
<span>Add software</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderHeaderDescription = () => {
|
||||
return (
|
||||
<p>
|
||||
Search for installed software{" "}
|
||||
{(isGlobalAdmin || isGlobalMaintainer) &&
|
||||
(!isPremiumTier || !isAnyTeamSelected) &&
|
||||
"and manage automations for detected vulnerabilities (CVEs)"}{" "}
|
||||
on{" "}
|
||||
{isPremiumTier && isAnyTeamSelected
|
||||
? "all hosts assigned to this team"
|
||||
: "all of your hosts"}
|
||||
.
|
||||
Manage software and search for installed software, OS and
|
||||
vulnerabilities {isAnyTeamSelected ? "on this team" : "for all hosts"}.
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
|
@ -342,8 +383,8 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
|
|||
teamId: teamIdForApi,
|
||||
// TODO: move down into the Software Titles component
|
||||
query,
|
||||
showVulnerableSoftware,
|
||||
showExploitedVulnerabilitiesOnly,
|
||||
softwareFilter,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -358,15 +399,7 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
|
|||
<div className={`${baseClass}__title`}>{renderTitle()}</div>
|
||||
</div>
|
||||
</div>
|
||||
{canManageAutomations && isSoftwareConfigLoaded && (
|
||||
<Button
|
||||
onClick={toggleManageAutomationsModal}
|
||||
className={`${baseClass}__manage-automations button`}
|
||||
variant="brand"
|
||||
>
|
||||
<span>Manage automations</span>
|
||||
</Button>
|
||||
)}
|
||||
{renderPageActions()}
|
||||
</div>
|
||||
<div className={`${baseClass}__description`}>
|
||||
{renderHeaderDescription()}
|
||||
|
|
@ -386,6 +419,13 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
|
|||
recentVulnerabilityMaxAge={recentVulnerabilityMaxAge}
|
||||
/>
|
||||
)}
|
||||
{showAddSoftwareModal && (
|
||||
<AddSoftwareModal
|
||||
teamId={currentTeamId ?? 0}
|
||||
router={router}
|
||||
onExit={toggleAddSoftwareModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</MainContent>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
import React from "react";
|
||||
|
||||
// @ts-ignore
|
||||
import InputField from "components/forms/fields/InputField";
|
||||
import Modal from "components/Modal";
|
||||
import Button from "components/buttons/Button";
|
||||
import FleetAce from "components/FleetAce";
|
||||
import CustomLink from "components/CustomLink";
|
||||
import Editor from "components/Editor";
|
||||
|
||||
const baseClass = "advanced-options-modal";
|
||||
|
||||
interface IAdvancedOptionsModalProps {
|
||||
installScript: string;
|
||||
preInstallQuery?: string;
|
||||
postInstallScript?: string;
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
const AdvancedOptionsModal = ({
|
||||
installScript,
|
||||
preInstallQuery,
|
||||
postInstallScript,
|
||||
onExit,
|
||||
}: IAdvancedOptionsModalProps) => {
|
||||
return (
|
||||
<Modal className={baseClass} title="Advanced Options" onExit={onExit}>
|
||||
<>
|
||||
<p>
|
||||
Advanced options are read-only. To change options, delete software and
|
||||
add again.
|
||||
</p>
|
||||
<div className={`${baseClass}__form-inputs`}>
|
||||
<Editor
|
||||
readOnly
|
||||
wrapEnabled
|
||||
maxLines={10}
|
||||
name="install-script"
|
||||
value={installScript}
|
||||
helpText="Fleet will run this command on hosts to install software."
|
||||
label="Install script"
|
||||
labelTooltip="For security agents, add the script provided by the vendor."
|
||||
/>
|
||||
{preInstallQuery && (
|
||||
<div className={`${baseClass}__input-field`}>
|
||||
<span>Pre-install condition:</span>
|
||||
<FleetAce
|
||||
readOnly
|
||||
value={preInstallQuery}
|
||||
label="Query"
|
||||
name="preInstallQuery"
|
||||
maxLines={10}
|
||||
helpText={
|
||||
<>
|
||||
Software will be installed only if the{" "}
|
||||
<CustomLink
|
||||
className={`${baseClass}__table-link`}
|
||||
text="query returns results"
|
||||
url="https://fleetdm.com/tables"
|
||||
newTab
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{postInstallScript && (
|
||||
<div className={`${baseClass}__input-field`}>
|
||||
<span>Post-install script:</span>
|
||||
<Editor
|
||||
readOnly
|
||||
wrapEnabled
|
||||
name="post-install-script-editor"
|
||||
maxLines={10}
|
||||
value={postInstallScript}
|
||||
helpText="Shell (macOS and Linux) or PowerShell (Windows)."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-cta-wrap">
|
||||
<Button variant="brand" onClick={onExit}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvancedOptionsModal;
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
.advanced-options-modal {
|
||||
&__form-inputs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-large;
|
||||
}
|
||||
|
||||
&__input-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-medium
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./AdvancedOptionsModal";
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import React, { useCallback, useContext } from "react";
|
||||
|
||||
import softwareAPI from "services/entities/software";
|
||||
import { NotificationContext } from "context/notification";
|
||||
|
||||
import Modal from "components/Modal";
|
||||
import Button from "components/buttons/Button";
|
||||
|
||||
const baseClass = "delete-software-modal";
|
||||
|
||||
interface IDeleteSoftwareModalProps {
|
||||
softwareId: number;
|
||||
teamId: number;
|
||||
onExit: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const DeleteSoftwareModal = ({
|
||||
softwareId,
|
||||
teamId,
|
||||
onExit,
|
||||
onSuccess,
|
||||
}: IDeleteSoftwareModalProps) => {
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
|
||||
const onDeleteSoftware = useCallback(async () => {
|
||||
try {
|
||||
await softwareAPI.deleteSoftwarePackage(softwareId, teamId);
|
||||
renderFlash("success", "Software deleted successfully!");
|
||||
onSuccess();
|
||||
} catch {
|
||||
renderFlash("error", "Couldn't delete. Please try again.");
|
||||
}
|
||||
onExit();
|
||||
}, [softwareId, teamId, renderFlash, onSuccess, onExit]);
|
||||
|
||||
return (
|
||||
<Modal className={baseClass} title="Delete software" onExit={onExit}>
|
||||
<>
|
||||
<p>Software won't be uninstalled from existing hosts.</p>
|
||||
<div className="modal-cta-wrap">
|
||||
<Button variant="alert" onClick={onDeleteSoftware}>
|
||||
Delete
|
||||
</Button>
|
||||
<Button variant="inverse-alert" onClick={onExit}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteSoftwareModal;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./DeleteSoftwareModal";
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
import React, { useCallback, useContext, useState } from "react";
|
||||
|
||||
import FileSaver from "file-saver";
|
||||
|
||||
import PATHS from "router/paths";
|
||||
|
||||
import { AppContext } from "context/app";
|
||||
import { NotificationContext } from "context/notification";
|
||||
|
||||
import { SoftwareInstallStatus, ISoftwarePackage } from "interfaces/software";
|
||||
|
||||
import softwareAPI from "services/entities/software";
|
||||
|
||||
import { buildQueryStringFromParams } from "utilities/url";
|
||||
import { internationalTimeFormat } from "utilities/helpers";
|
||||
import { uploadedFromNow } from "utilities/date_format";
|
||||
|
||||
import Card from "components/Card";
|
||||
import Graphic from "components/Graphic";
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
import DataSet from "components/DataSet";
|
||||
import Icon from "components/Icon";
|
||||
import Button from "components/buttons/Button";
|
||||
|
||||
import DeleteSoftwareModal from "../DeleteSoftwareModal";
|
||||
import AdvancedOptionsModal from "../AdvancedOptionsModal";
|
||||
|
||||
const baseClass = "software-package-card";
|
||||
|
||||
interface IStatusDisplayOption {
|
||||
displayName: string;
|
||||
iconName: "success" | "pending-outline" | "error";
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
const STATUS_DISPLAY_OPTIONS: Record<
|
||||
SoftwareInstallStatus,
|
||||
IStatusDisplayOption
|
||||
> = {
|
||||
installed: {
|
||||
displayName: "Installed",
|
||||
iconName: "success",
|
||||
tooltip: "Fleet installed software on these hosts.",
|
||||
},
|
||||
pending: {
|
||||
displayName: "Pending",
|
||||
iconName: "pending-outline",
|
||||
tooltip: "Fleet will install software when these hosts come online.",
|
||||
},
|
||||
failed: {
|
||||
displayName: "Failed",
|
||||
iconName: "error",
|
||||
tooltip: "Fleet failed to install software on these hosts.",
|
||||
},
|
||||
};
|
||||
|
||||
interface IPackageStatusCountProps {
|
||||
softwareId: number;
|
||||
status: SoftwareInstallStatus;
|
||||
count: number;
|
||||
teamId?: number;
|
||||
}
|
||||
|
||||
const PackageStatusCount = ({
|
||||
softwareId,
|
||||
status,
|
||||
count,
|
||||
teamId,
|
||||
}: IPackageStatusCountProps) => {
|
||||
const displayData = STATUS_DISPLAY_OPTIONS[status];
|
||||
const linkUrl = `${PATHS.MANAGE_HOSTS}?${buildQueryStringFromParams({
|
||||
software_title_id: softwareId,
|
||||
software_status: status,
|
||||
team_id: teamId,
|
||||
})}`;
|
||||
return (
|
||||
<DataSet
|
||||
title={
|
||||
<TooltipWrapper
|
||||
position="top"
|
||||
tipContent={displayData.tooltip}
|
||||
underline={false}
|
||||
>
|
||||
<div className={`${baseClass}__status-title`}>
|
||||
<Icon name={displayData.iconName} />
|
||||
<span>{displayData.displayName}</span>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
}
|
||||
value={
|
||||
<a className={`${baseClass}__status-count`} href={linkUrl}>
|
||||
{count} hosts
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface ISoftwarePackageCardProps {
|
||||
softwarePackage: ISoftwarePackage;
|
||||
softwareId: number;
|
||||
teamId: number;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const SoftwarePackageCard = ({
|
||||
softwarePackage,
|
||||
softwareId,
|
||||
teamId,
|
||||
onDelete,
|
||||
}: ISoftwarePackageCardProps) => {
|
||||
const {
|
||||
isGlobalAdmin,
|
||||
isGlobalMaintainer,
|
||||
isTeamAdmin,
|
||||
isTeamMaintainer,
|
||||
} = useContext(AppContext);
|
||||
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
|
||||
const [showAdvancedOptionsModal, setShowAdvancedOptionsModal] = useState(
|
||||
false
|
||||
);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
|
||||
const onAdvancedOptionsClick = () => {
|
||||
setShowAdvancedOptionsModal(true);
|
||||
};
|
||||
|
||||
const onDeleteClick = () => {
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const onDeleteSuccess = useCallback(() => {
|
||||
setShowDeleteModal(false);
|
||||
onDelete();
|
||||
}, [onDelete]);
|
||||
|
||||
const onDownloadClick = useCallback(async () => {
|
||||
try {
|
||||
const resp = await softwareAPI.downloadSoftwarePackage(
|
||||
softwareId,
|
||||
teamId
|
||||
);
|
||||
const contentLength = parseInt(resp.headers["content-length"], 10);
|
||||
if (contentLength !== resp.data.size) {
|
||||
throw new Error(
|
||||
`Byte size (${resp.data.size}) does not match content-length header (${contentLength})`
|
||||
);
|
||||
}
|
||||
const filename = softwarePackage.name;
|
||||
const file = new File([resp.data], filename, {
|
||||
type: "application/octet-stream",
|
||||
});
|
||||
if (file.size === 0) {
|
||||
throw new Error("Downloaded file is empty");
|
||||
}
|
||||
if (file.size !== resp.data.size) {
|
||||
throw new Error(
|
||||
`File size (${file.size}) does not match expected size (${resp.data.size})`
|
||||
);
|
||||
}
|
||||
FileSaver.saveAs(file);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
renderFlash("error", "Couldn’t download. Please try again.");
|
||||
}
|
||||
}, [renderFlash, softwareId, softwarePackage.name, teamId]);
|
||||
|
||||
const showActions =
|
||||
isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer;
|
||||
|
||||
return (
|
||||
<Card borderRadiusSize="large" includeShadow className={baseClass}>
|
||||
<div className={`${baseClass}__main-content`}>
|
||||
{/* TODO: main-info could be a seperate component as its reused on a couple
|
||||
pages already. Come back and pull this into a component */}
|
||||
<div className={`${baseClass}__main-info`}>
|
||||
<Graphic name="file-pkg" />
|
||||
<div className={`${baseClass}__info`}>
|
||||
<span className={`${baseClass}__title`}>
|
||||
{softwarePackage.name}
|
||||
</span>
|
||||
<span className={`${baseClass}__details`}>
|
||||
<span>Version {softwarePackage.version} • </span>
|
||||
<TooltipWrapper
|
||||
tipContent={internationalTimeFormat(
|
||||
new Date(softwarePackage.uploaded_at)
|
||||
)}
|
||||
underline={false}
|
||||
>
|
||||
{uploadedFromNow(softwarePackage.uploaded_at)}
|
||||
</TooltipWrapper>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${baseClass}__package-statuses`}>
|
||||
<PackageStatusCount
|
||||
softwareId={softwareId}
|
||||
status="installed"
|
||||
count={softwarePackage.status.installed}
|
||||
teamId={teamId}
|
||||
/>
|
||||
<PackageStatusCount
|
||||
softwareId={softwareId}
|
||||
status="pending"
|
||||
count={softwarePackage.status.pending}
|
||||
teamId={teamId}
|
||||
/>
|
||||
<PackageStatusCount
|
||||
softwareId={softwareId}
|
||||
status="failed"
|
||||
count={softwarePackage.status.failed}
|
||||
teamId={teamId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{showActions && (
|
||||
<div className={`${baseClass}__actions`}>
|
||||
<Button variant="icon" onClick={onAdvancedOptionsClick}>
|
||||
<Icon name="settings" color={"ui-fleet-black-75"} />
|
||||
</Button>
|
||||
{/* TODO: make a component for download icons */}
|
||||
<Button variant="icon" onClick={onDownloadClick}>
|
||||
<Icon name="download" color={"ui-fleet-black-75"} />
|
||||
</Button>
|
||||
<Button variant="icon" onClick={onDeleteClick}>
|
||||
<Icon name="trash" color={"ui-fleet-black-75"} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{showAdvancedOptionsModal && (
|
||||
<AdvancedOptionsModal
|
||||
installScript={softwarePackage.install_script}
|
||||
preInstallQuery={softwarePackage.pre_install_query}
|
||||
postInstallScript={softwarePackage.post_install_script}
|
||||
onExit={() => setShowAdvancedOptionsModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showDeleteModal && (
|
||||
<DeleteSoftwareModal
|
||||
softwareId={softwareId}
|
||||
teamId={teamId}
|
||||
onExit={() => setShowDeleteModal(false)}
|
||||
onSuccess={onDeleteSuccess}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SoftwarePackageCard;
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
.software-package-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
&__main-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $pad-xxlarge;
|
||||
}
|
||||
|
||||
&__main-info {
|
||||
display: flex;
|
||||
gap: $pad-medium;
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-xsmall;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: $x-small;
|
||||
font-weight: $bold;
|
||||
}
|
||||
|
||||
&__details {
|
||||
font-size: $xx-small;
|
||||
}
|
||||
|
||||
&__package-statuses {
|
||||
display: flex;
|
||||
gap: $pad-xxlarge;
|
||||
}
|
||||
|
||||
&__status-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $pad-xsmall;
|
||||
}
|
||||
|
||||
&__status-count {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: $pad-medium;
|
||||
}
|
||||
|
||||
&__download-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
}
|
||||
|
||||
@media (max-width: $break-md) {
|
||||
align-items: flex-start;
|
||||
|
||||
&__main-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $pad-large;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./SoftwarePackageCard";
|
||||
|
|
@ -6,6 +6,8 @@ import { useErrorHandler } from "react-error-boundary";
|
|||
import { RouteComponentProps } from "react-router";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import paths from "router/paths";
|
||||
|
||||
import useTeamIdParam from "hooks/useTeamIdParam";
|
||||
|
||||
import { AppContext } from "context/app";
|
||||
|
|
@ -16,7 +18,7 @@ import softwareAPI, {
|
|||
ISoftwareTitleResponse,
|
||||
IGetSoftwareTitleQueryKey,
|
||||
} from "services/entities/software";
|
||||
|
||||
import { APP_CONTEXT_ALL_TEAMS_ID } from "interfaces/team";
|
||||
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
|
||||
|
||||
import Spinner from "components/Spinner";
|
||||
|
|
@ -27,6 +29,7 @@ import Card from "components/Card";
|
|||
import SoftwareDetailsSummary from "../components/SoftwareDetailsSummary";
|
||||
import SoftwareTitleDetailsTable from "./SoftwareTitleDetailsTable";
|
||||
import DetailsNoHosts from "../components/DetailsNoHosts";
|
||||
import SoftwarePackageCard from "./SoftwarePackageCard";
|
||||
|
||||
const baseClass = "software-title-details-page";
|
||||
|
||||
|
|
@ -45,7 +48,13 @@ const SoftwareTitleDetailsPage = ({
|
|||
routeParams,
|
||||
location,
|
||||
}: ISoftwareTitleDetailsPageProps) => {
|
||||
const { isPremiumTier, isOnGlobalTeam } = useContext(AppContext);
|
||||
const {
|
||||
isPremiumTier,
|
||||
isOnGlobalTeam,
|
||||
isTeamAdmin,
|
||||
isTeamMaintainer,
|
||||
isTeamObserver,
|
||||
} = useContext(AppContext);
|
||||
const handlePageError = useErrorHandler();
|
||||
|
||||
// TODO: handle non integer values
|
||||
|
|
@ -67,6 +76,7 @@ const SoftwareTitleDetailsPage = ({
|
|||
data: softwareTitle,
|
||||
isLoading: isSoftwareTitleLoading,
|
||||
isError: isSoftwareTitleError,
|
||||
refetch: refetchSoftwareTitle,
|
||||
} = useQuery<
|
||||
ISoftwareTitleResponse,
|
||||
AxiosError,
|
||||
|
|
@ -87,6 +97,19 @@ const SoftwareTitleDetailsPage = ({
|
|||
}
|
||||
);
|
||||
|
||||
const onDeleteInstaller = useCallback(() => {
|
||||
if (softwareTitle?.versions?.length) {
|
||||
refetchSoftwareTitle();
|
||||
return;
|
||||
}
|
||||
// redirect to software titles page if no versions are available
|
||||
if (teamIdForApi && teamIdForApi > 0) {
|
||||
router.push(paths.SOFTWARE_TITLES.concat(`?team_id=${teamIdForApi}`));
|
||||
} else {
|
||||
router.push(paths.SOFTWARE_TITLES);
|
||||
}
|
||||
}, [refetchSoftwareTitle, router, softwareTitle, teamIdForApi]);
|
||||
|
||||
const onTeamChange = useCallback(
|
||||
(teamId: number) => {
|
||||
handleTeamChange(teamId);
|
||||
|
|
@ -94,6 +117,15 @@ const SoftwareTitleDetailsPage = ({
|
|||
[handleTeamChange]
|
||||
);
|
||||
|
||||
const hasPermission = Boolean(
|
||||
isOnGlobalTeam || isTeamAdmin || isTeamMaintainer || isTeamObserver
|
||||
);
|
||||
const hasSoftwarePackage = softwareTitle && softwareTitle.software_package;
|
||||
const showPackageCard =
|
||||
currentTeamId !== APP_CONTEXT_ALL_TEAMS_ID &&
|
||||
hasPermission &&
|
||||
hasSoftwarePackage;
|
||||
|
||||
const renderContent = () => {
|
||||
if (isSoftwareTitleLoading) {
|
||||
return <Spinner />;
|
||||
|
|
@ -133,6 +165,16 @@ const SoftwareTitleDetailsPage = ({
|
|||
name={softwareTitle.name}
|
||||
source={softwareTitle.source}
|
||||
/>
|
||||
{showPackageCard &&
|
||||
softwareTitle.software_package &&
|
||||
currentTeamId && (
|
||||
<SoftwarePackageCard
|
||||
softwarePackage={softwareTitle.software_package}
|
||||
softwareId={softwareId}
|
||||
teamId={currentTeamId}
|
||||
onDelete={onDeleteInstaller}
|
||||
/>
|
||||
)}
|
||||
<Card
|
||||
borderRadiusSize="large"
|
||||
includeShadow
|
||||
|
|
|
|||
|
|
@ -3,17 +3,13 @@ software/titles Software tab > Table
|
|||
software/versions Software tab > Table (version toggle on)
|
||||
*/
|
||||
|
||||
import React, { useCallback, useContext, useMemo } from "react";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { InjectedRouter } from "react-router";
|
||||
import { Row } from "react-table";
|
||||
|
||||
import PATHS from "router/paths";
|
||||
import { AppContext } from "context/app";
|
||||
import { getNextLocationPath } from "utilities/helpers";
|
||||
import {
|
||||
GITHUB_NEW_ISSUE_LINK,
|
||||
VULNERABLE_DROPDOWN_OPTIONS,
|
||||
} from "utilities/constants";
|
||||
import { GITHUB_NEW_ISSUE_LINK } from "utilities/constants";
|
||||
import { buildQueryStringFromParams } from "utilities/url";
|
||||
import {
|
||||
ISoftwareTitlesResponse,
|
||||
|
|
@ -33,6 +29,11 @@ import EmptySoftwareTable from "pages/SoftwarePage/components/EmptySoftwareTable
|
|||
|
||||
import generateTitlesTableConfig from "./SoftwareTitlesTableConfig";
|
||||
import generateVersionsTableConfig from "./SoftwareVersionsTableConfig";
|
||||
import {
|
||||
ISoftwareDropdownFilterVal,
|
||||
SOFTWARE_TITLES_DROPDOWN_OPTIONS,
|
||||
SOFTWARE_VERSIONS_DROPDOWN_OPTIONS,
|
||||
} from "./helpers";
|
||||
|
||||
interface IRowProps extends Row {
|
||||
original: {
|
||||
|
|
@ -58,7 +59,7 @@ interface ISoftwareTableProps {
|
|||
perPage: number;
|
||||
orderDirection: "asc" | "desc";
|
||||
orderKey: string;
|
||||
showVulnerableSoftware: boolean;
|
||||
softwareFilter: ISoftwareDropdownFilterVal;
|
||||
currentPage: number;
|
||||
teamId?: number;
|
||||
isLoading: boolean;
|
||||
|
|
@ -75,13 +76,11 @@ const SoftwareTable = ({
|
|||
perPage,
|
||||
orderDirection,
|
||||
orderKey,
|
||||
showVulnerableSoftware,
|
||||
softwareFilter,
|
||||
currentPage,
|
||||
teamId,
|
||||
isLoading,
|
||||
}: ISoftwareTableProps) => {
|
||||
const { isSandboxMode, noSandboxHosts } = useContext(AppContext);
|
||||
|
||||
const currentPath = showVersions
|
||||
? PATHS.SOFTWARE_VERSIONS
|
||||
: PATHS.SOFTWARE_TITLES;
|
||||
|
|
@ -96,8 +95,6 @@ const SoftwareTable = ({
|
|||
return val !== orderDirection;
|
||||
case "sortHeader":
|
||||
return val !== orderKey;
|
||||
case "vulnerable":
|
||||
return val !== showVulnerableSoftware.toString();
|
||||
case "pageIndex":
|
||||
return val !== currentPage;
|
||||
default:
|
||||
|
|
@ -106,21 +103,29 @@ const SoftwareTable = ({
|
|||
});
|
||||
return changedEntry?.[0] ?? "";
|
||||
},
|
||||
[currentPage, orderDirection, orderKey, query, showVulnerableSoftware]
|
||||
[currentPage, orderDirection, orderKey, query]
|
||||
);
|
||||
|
||||
const generateNewQueryParams = useCallback(
|
||||
(newTableQuery: ITableQueryData, changedParam: string) => {
|
||||
return {
|
||||
const newQueryParam: Record<string, string | number | undefined> = {
|
||||
query: newTableQuery.searchQuery,
|
||||
team_id: teamId,
|
||||
order_direction: newTableQuery.sortDirection,
|
||||
order_key: newTableQuery.sortHeader,
|
||||
vulnerable: showVulnerableSoftware.toString(),
|
||||
page: changedParam === "pageIndex" ? newTableQuery.pageIndex : 0,
|
||||
};
|
||||
if (softwareFilter === "installableSoftware") {
|
||||
newQueryParam.available_for_install = true.toString();
|
||||
} else {
|
||||
newQueryParam.vulnerable = (
|
||||
softwareFilter === "vulnerableSoftware"
|
||||
).toString();
|
||||
}
|
||||
|
||||
return newQueryParam;
|
||||
},
|
||||
[showVulnerableSoftware, teamId]
|
||||
[softwareFilter, teamId]
|
||||
);
|
||||
|
||||
// NOTE: this is called once on initial render and every time the query changes
|
||||
|
|
@ -131,7 +136,9 @@ const SoftwareTable = ({
|
|||
const changedParam = determineQueryParamChange(newTableQuery);
|
||||
|
||||
// if nothing has changed, don't update the route. this can happen when
|
||||
// this handler is called on the inital render.
|
||||
// this handler is called on the inital render. Can also happen when
|
||||
// the filter dropdown is changed. That is handled on the onChange handler
|
||||
// for the dropdown.
|
||||
if (changedParam === "") return;
|
||||
|
||||
const newRoute = getNextLocationPath({
|
||||
|
|
@ -167,7 +174,7 @@ const SoftwareTable = ({
|
|||
// determines if a user be able to search in the table
|
||||
const searchable =
|
||||
isSoftwareEnabled &&
|
||||
(!!tableData || query !== "" || showVulnerableSoftware);
|
||||
(!!tableData || query !== "" || softwareFilter === "vulnerableSoftware");
|
||||
|
||||
const getItemsCountText = () => {
|
||||
const count = data?.count;
|
||||
|
|
@ -187,37 +194,57 @@ const SoftwareTable = ({
|
|||
};
|
||||
|
||||
const handleShowVersionsToggle = () => {
|
||||
const queryParams: Record<string, string | number | undefined> = {
|
||||
query,
|
||||
team_id: teamId,
|
||||
order_direction: orderDirection,
|
||||
order_key: orderKey,
|
||||
page: 0, // resets page index
|
||||
};
|
||||
|
||||
// if we are currently showing installable titles, we want to switch to
|
||||
// all software versions. If not, we want to keep the current filter.
|
||||
if (softwareFilter === "installableSoftware") {
|
||||
queryParams.vulnerable = "false";
|
||||
} else {
|
||||
queryParams.vulnerable = (
|
||||
softwareFilter === "vulnerableSoftware"
|
||||
).toString();
|
||||
}
|
||||
|
||||
router.replace(
|
||||
getNextLocationPath({
|
||||
pathPrefix: showVersions
|
||||
? PATHS.SOFTWARE_TITLES
|
||||
: PATHS.SOFTWARE_VERSIONS,
|
||||
routeTemplate: "",
|
||||
queryParams: {
|
||||
query,
|
||||
team_id: teamId,
|
||||
order_direction: orderDirection,
|
||||
order_key: orderKey,
|
||||
vulnerable: showVulnerableSoftware.toString(),
|
||||
page: 0, // resets page index
|
||||
},
|
||||
queryParams,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleVulnFilterDropdownChange = (isFilterVulnerable: string) => {
|
||||
const handleVulnFilterDropdownChange = (
|
||||
value: ISoftwareDropdownFilterVal
|
||||
) => {
|
||||
const queryParams: Record<string, any> = {
|
||||
query,
|
||||
team_id: teamId,
|
||||
order_direction: orderDirection,
|
||||
order_key: orderKey,
|
||||
page: 0, // resets page index
|
||||
};
|
||||
|
||||
if (value === "installableSoftware") {
|
||||
queryParams.available_for_install = true;
|
||||
} else {
|
||||
queryParams.vulnerable = value === "vulnerableSoftware";
|
||||
}
|
||||
|
||||
router.replace(
|
||||
getNextLocationPath({
|
||||
pathPrefix: currentPath,
|
||||
routeTemplate: "",
|
||||
queryParams: {
|
||||
query,
|
||||
team_id: teamId,
|
||||
order_direction: orderDirection,
|
||||
order_key: orderKey,
|
||||
vulnerable: isFilterVulnerable,
|
||||
page: 0, // resets page index
|
||||
},
|
||||
queryParams,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
@ -255,6 +282,10 @@ const SoftwareTable = ({
|
|||
};
|
||||
|
||||
const renderCustomFilters = () => {
|
||||
const options = showVersions
|
||||
? SOFTWARE_VERSIONS_DROPDOWN_OPTIONS
|
||||
: SOFTWARE_TITLES_DROPDOWN_OPTIONS;
|
||||
|
||||
return (
|
||||
<div className={`${baseClass}__filter-controls`}>
|
||||
<div className={`${baseClass}__version-slider`}>
|
||||
|
|
@ -267,9 +298,9 @@ const SoftwareTable = ({
|
|||
/>
|
||||
</div>
|
||||
<Dropdown
|
||||
value={showVulnerableSoftware}
|
||||
value={softwareFilter}
|
||||
className={`${baseClass}__vuln_dropdown`}
|
||||
options={VULNERABLE_DROPDOWN_OPTIONS}
|
||||
options={options}
|
||||
searchable={false}
|
||||
onChange={handleVulnFilterDropdownChange}
|
||||
tableFilterDropdown
|
||||
|
|
@ -300,12 +331,10 @@ const SoftwareTable = ({
|
|||
resultsTitle="items"
|
||||
emptyComponent={() => (
|
||||
<EmptySoftwareTable
|
||||
softwareFilter={softwareFilter}
|
||||
isSoftwareDisabled={!isSoftwareEnabled}
|
||||
isFilterVulnerable={showVulnerableSoftware}
|
||||
isSandboxMode={isSandboxMode}
|
||||
isCollectingSoftware={false} // TODO: update with new API
|
||||
isSearching={query !== ""}
|
||||
noSandboxHosts={noSandboxHosts}
|
||||
/>
|
||||
)}
|
||||
defaultSortHeader={orderKey}
|
||||
|
|
@ -323,7 +352,7 @@ const SoftwareTable = ({
|
|||
// additionalQueries serves as a trigger for the useDeepEffect hook
|
||||
// to fire onQueryChange for events happeing outside of
|
||||
// the TableContainer.
|
||||
additionalQueries={showVulnerableSoftware ? "vulnerable" : ""}
|
||||
// additionalQueries={softwareFilter}
|
||||
customControl={searchable ? renderCustomFilters : undefined}
|
||||
stackControls
|
||||
renderCount={renderSoftwareCount}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,7 @@ import React from "react";
|
|||
import { CellProps, Column } from "react-table";
|
||||
import { InjectedRouter } from "react-router";
|
||||
|
||||
import {
|
||||
ISoftwareTitleVersion,
|
||||
ISoftwareTitle,
|
||||
formatSoftwareType,
|
||||
} from "interfaces/software";
|
||||
import { ISoftwareTitle, formatSoftwareType } from "interfaces/software";
|
||||
import PATHS from "router/paths";
|
||||
|
||||
import { buildQueryStringFromParams } from "utilities/url";
|
||||
|
|
@ -14,12 +10,11 @@ import { IHeaderProps, IStringCellProps } from "interfaces/datatable_config";
|
|||
|
||||
import HeaderCell from "components/TableContainer/DataTable/HeaderCell";
|
||||
import TextCell from "components/TableContainer/DataTable/TextCell";
|
||||
import LinkCell from "components/TableContainer/DataTable/LinkCell/LinkCell";
|
||||
import ViewAllHostsLink from "components/ViewAllHostsLink";
|
||||
import SoftwareNameCell from "components/TableContainer/DataTable/SoftwareNameCell";
|
||||
|
||||
import VersionCell from "../../components/VersionCell";
|
||||
import VulnerabilitiesCell from "../../components/VulnerabilitiesCell";
|
||||
import SoftwareIcon from "../../components/icons/SoftwareIcon";
|
||||
|
||||
// NOTE: cellProps come from react-table
|
||||
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
|
||||
|
|
@ -36,7 +31,11 @@ type IViewAllHostsLinkProps = CellProps<ISoftwareTitle>;
|
|||
|
||||
type ITableHeaderProps = IHeaderProps<ISoftwareTitle>;
|
||||
|
||||
const getVulnerabilities = (versions: ISoftwareTitleVersion[]) => {
|
||||
export const getVulnerabilities = <
|
||||
T extends { vulnerabilities: string[] | null }
|
||||
>(
|
||||
versions: T[]
|
||||
) => {
|
||||
if (!versions) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -64,43 +63,25 @@ const generateTableHeaders = (
|
|||
disableSortBy: false,
|
||||
accessor: "name",
|
||||
Cell: (cellProps: ITableStringCellProps) => {
|
||||
const { id, name, source } = cellProps.row.original;
|
||||
const { id, name, source, software_package } = cellProps.row.original;
|
||||
|
||||
const teamQueryParam = buildQueryStringFromParams({ team_id: teamId });
|
||||
const softwareTitleDetailsPath = `${PATHS.SOFTWARE_TITLE_DETAILS(
|
||||
id.toString()
|
||||
)}?${teamQueryParam}`;
|
||||
|
||||
const onClickSoftware = (e: React.MouseEvent) => {
|
||||
// Allows for button to be clickable in a clickable row
|
||||
e.stopPropagation();
|
||||
|
||||
router?.push(softwareTitleDetailsPath);
|
||||
};
|
||||
|
||||
return (
|
||||
<LinkCell
|
||||
<SoftwareNameCell
|
||||
name={name}
|
||||
source={source}
|
||||
path={softwareTitleDetailsPath}
|
||||
customOnClick={onClickSoftware}
|
||||
value={
|
||||
<>
|
||||
<SoftwareIcon name={name} source={source} />
|
||||
<span className="software-name">{name}</span>
|
||||
</>
|
||||
}
|
||||
router={router}
|
||||
hasPackage={Boolean(software_package)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
sortType: "caseInsensitive",
|
||||
},
|
||||
{
|
||||
Header: "Version",
|
||||
disableSortBy: true,
|
||||
accessor: "versions",
|
||||
Cell: (cellProps: IVersionsCellProps) => (
|
||||
<VersionCell versions={cellProps.cell.value} />
|
||||
),
|
||||
},
|
||||
{
|
||||
Header: "Type",
|
||||
disableSortBy: true,
|
||||
|
|
@ -109,6 +90,14 @@ const generateTableHeaders = (
|
|||
<TextCell value={formatSoftwareType(cellProps.row.original)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
Header: "Version",
|
||||
disableSortBy: true,
|
||||
accessor: "versions",
|
||||
Cell: (cellProps: IVersionsCellProps) => (
|
||||
<VersionCell versions={cellProps.cell.value} />
|
||||
),
|
||||
},
|
||||
// the "vulnerabilities" accessor is used but the data is actually coming
|
||||
// from the version attribute. We do this as we already have a "versions"
|
||||
// attribute used for the "Version" column and we cannot reuse. This is a
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ import PATHS from "router/paths";
|
|||
|
||||
import HeaderCell from "components/TableContainer/DataTable/HeaderCell";
|
||||
import TextCell from "components/TableContainer/DataTable/TextCell";
|
||||
import LinkCell from "components/TableContainer/DataTable/LinkCell/LinkCell";
|
||||
import ViewAllHostsLink from "components/ViewAllHostsLink";
|
||||
import SoftwareNameCell from "components/TableContainer/DataTable/SoftwareNameCell";
|
||||
|
||||
import VulnerabilitiesCell from "../../components/VulnerabilitiesCell";
|
||||
import SoftwareIcon from "../../components/icons/SoftwareIcon";
|
||||
|
||||
// NOTE: cellProps come from react-table
|
||||
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
|
||||
|
|
@ -52,36 +52,17 @@ const generateTableHeaders = (
|
|||
id.toString()
|
||||
)}?${teamQueryParam}`;
|
||||
|
||||
const onClickSoftware = (e: React.MouseEvent) => {
|
||||
// Allows for button to be clickable in a clickable row
|
||||
e.stopPropagation();
|
||||
|
||||
router?.push(softwareVersionDetailsPath);
|
||||
};
|
||||
|
||||
return (
|
||||
<LinkCell
|
||||
<SoftwareNameCell
|
||||
name={name}
|
||||
source={source}
|
||||
path={softwareVersionDetailsPath}
|
||||
customOnClick={onClickSoftware}
|
||||
value={
|
||||
<>
|
||||
<SoftwareIcon name={name} source={source} />
|
||||
<span className="software-name">{name}</span>
|
||||
</>
|
||||
}
|
||||
router={router}
|
||||
/>
|
||||
);
|
||||
},
|
||||
sortType: "caseInsensitive",
|
||||
},
|
||||
{
|
||||
Header: "Version",
|
||||
disableSortBy: true,
|
||||
accessor: "version",
|
||||
Cell: (cellProps: ITableStringCellProps) => (
|
||||
<TextCell value={cellProps.cell.value} />
|
||||
),
|
||||
},
|
||||
{
|
||||
Header: "Type",
|
||||
disableSortBy: true,
|
||||
|
|
@ -90,6 +71,14 @@ const generateTableHeaders = (
|
|||
<TextCell value={formatSoftwareType(cellProps.row.original)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
Header: "Version",
|
||||
disableSortBy: true,
|
||||
accessor: "version",
|
||||
Cell: (cellProps: ITableStringCellProps) => (
|
||||
<TextCell value={cellProps.cell.value} />
|
||||
),
|
||||
},
|
||||
{
|
||||
Header: "Vulnerabilities",
|
||||
disableSortBy: true,
|
||||
|
|
|
|||
|
|
@ -124,12 +124,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.link-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $pad-small;
|
||||
}
|
||||
|
||||
.hosts_count__cell {
|
||||
.hosts-cell__wrapper {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
export type ISoftwareDropdownFilterVal =
|
||||
| "allSoftware"
|
||||
| "vulnerableSoftware"
|
||||
| "installableSoftware";
|
||||
|
||||
export const SOFTWARE_VERSIONS_DROPDOWN_OPTIONS = [
|
||||
{
|
||||
disabled: false,
|
||||
label: "All software",
|
||||
value: "allSoftware",
|
||||
helpText: "All software installed on your hosts.",
|
||||
},
|
||||
{
|
||||
disabled: false,
|
||||
label: "Vulnerable software",
|
||||
value: "vulnerableSoftware",
|
||||
helpText:
|
||||
"All software installed on your hosts with detected vulnerabilities.",
|
||||
},
|
||||
];
|
||||
|
||||
export const SOFTWARE_TITLES_DROPDOWN_OPTIONS = [
|
||||
...SOFTWARE_VERSIONS_DROPDOWN_OPTIONS,
|
||||
{
|
||||
disabled: false,
|
||||
label: "Available for install",
|
||||
value: "installableSoftware",
|
||||
helpText: "Software that can be installed on your hosts.",
|
||||
},
|
||||
];
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
/**
|
||||
/**
|
||||
software/titles Software tab
|
||||
software/versions Software tab (version toggle on)
|
||||
software/versions Software tab (version toggle on)
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { InjectedRouter } from "react-router";
|
||||
import { useQuery } from "react-query";
|
||||
|
|
@ -16,7 +15,9 @@ import softwareAPI, {
|
|||
|
||||
import Spinner from "components/Spinner";
|
||||
import TableDataError from "components/DataError";
|
||||
|
||||
import SoftwareTable from "./SoftwareTable";
|
||||
import { ISoftwareDropdownFilterVal } from "./SoftwareTable/helpers";
|
||||
|
||||
const baseClass = "software-titles";
|
||||
|
||||
|
|
@ -41,7 +42,7 @@ interface ISoftwareTitlesProps {
|
|||
perPage: number;
|
||||
orderDirection: "asc" | "desc";
|
||||
orderKey: string;
|
||||
showVulnerableSoftware: boolean;
|
||||
softwareFilter: ISoftwareDropdownFilterVal;
|
||||
currentPage: number;
|
||||
teamId?: number;
|
||||
}
|
||||
|
|
@ -53,12 +54,31 @@ const SoftwareTitles = ({
|
|||
perPage,
|
||||
orderDirection,
|
||||
orderKey,
|
||||
showVulnerableSoftware,
|
||||
softwareFilter,
|
||||
currentPage,
|
||||
teamId,
|
||||
}: ISoftwareTitlesProps) => {
|
||||
const showVersions = location.pathname === PATHS.SOFTWARE_VERSIONS;
|
||||
|
||||
const generateSoftwareTitlesQueryKey = (): ISoftwareTitlesQueryKey => {
|
||||
const queryKey: ISoftwareTitlesQueryKey = {
|
||||
scope: "software-titles",
|
||||
page: currentPage,
|
||||
perPage,
|
||||
query,
|
||||
orderDirection,
|
||||
orderKey,
|
||||
teamId,
|
||||
};
|
||||
if (softwareFilter === "installableSoftware") {
|
||||
queryKey.availableForInstall = true;
|
||||
} else {
|
||||
queryKey.vulnerable = softwareFilter === "vulnerableSoftware";
|
||||
}
|
||||
|
||||
return queryKey;
|
||||
};
|
||||
|
||||
// request to get software data
|
||||
const {
|
||||
data: titlesData,
|
||||
|
|
@ -71,18 +91,7 @@ const SoftwareTitles = ({
|
|||
ISoftwareTitlesResponse,
|
||||
ISoftwareTitlesQueryKey[]
|
||||
>(
|
||||
[
|
||||
{
|
||||
scope: "software-titles",
|
||||
page: currentPage,
|
||||
perPage,
|
||||
query,
|
||||
orderDirection,
|
||||
orderKey,
|
||||
teamId,
|
||||
vulnerable: showVulnerableSoftware,
|
||||
},
|
||||
],
|
||||
[generateSoftwareTitlesQueryKey()],
|
||||
({ queryKey }) => softwareAPI.getSoftwareTitles(queryKey[0]),
|
||||
{
|
||||
...QUERY_OPTIONS,
|
||||
|
|
@ -111,7 +120,7 @@ const SoftwareTitles = ({
|
|||
orderDirection,
|
||||
orderKey,
|
||||
teamId,
|
||||
vulnerable: showVulnerableSoftware,
|
||||
vulnerable: softwareFilter === "vulnerableSoftware",
|
||||
},
|
||||
],
|
||||
({ queryKey }) => softwareAPI.getSoftwareVersions(queryKey[0]),
|
||||
|
|
@ -140,7 +149,7 @@ const SoftwareTitles = ({
|
|||
perPage={perPage}
|
||||
orderDirection={orderDirection}
|
||||
orderKey={orderKey}
|
||||
showVulnerableSoftware={showVulnerableSoftware}
|
||||
softwareFilter={softwareFilter}
|
||||
currentPage={currentPage}
|
||||
teamId={teamId}
|
||||
isLoading={isTitlesFetching || isVersionsFetching}
|
||||
|
|
|
|||
|
|
@ -267,11 +267,7 @@ const SoftwareVulnerabilitiesTable = ({
|
|||
isLoading={isLoading}
|
||||
resultsTitle={"items"}
|
||||
emptyComponent={() => (
|
||||
<EmptySoftwareTable
|
||||
isSoftwareDisabled={!isSoftwareEnabled}
|
||||
isSandboxMode={isSandboxMode}
|
||||
noSandboxHosts={noSandboxHosts}
|
||||
/>
|
||||
<EmptySoftwareTable isSoftwareDisabled={!isSoftwareEnabled} />
|
||||
)}
|
||||
defaultSortHeader={orderKey}
|
||||
defaultSortDirection={orderDirection}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
&__action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $pad-medium;
|
||||
}
|
||||
|
||||
&__text {
|
||||
margin-right: $pad-large;
|
||||
}
|
||||
|
|
@ -61,14 +67,5 @@
|
|||
.component__tabs-wrapper {
|
||||
margin-bottom: $pad-xxlarge;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
.software-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid $ui-fleet-black-10;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
import React, { useState } from "react";
|
||||
|
||||
import Editor from "components/Editor";
|
||||
import CustomLink from "components/CustomLink";
|
||||
import FleetAce from "components/FleetAce";
|
||||
import RevealButton from "components/buttons/RevealButton";
|
||||
import Checkbox from "components/forms/fields/Checkbox";
|
||||
|
||||
const baseClass = "add-software-advanced-options";
|
||||
|
||||
interface IAddSoftwareAdvancedOptionsProps {
|
||||
errors: { preInstallCondition?: string; postInstallScript?: string };
|
||||
showPreInstallCondition: boolean;
|
||||
showPostInstallScript: boolean;
|
||||
preInstallCondition?: string;
|
||||
postInstallScript?: string;
|
||||
onTogglePreInstallCondition: (value: boolean) => void;
|
||||
onTogglePostInstallScript: (value: boolean) => void;
|
||||
onChangePreInstallCondition: (value?: string) => void;
|
||||
onChangePostInstallScript: (value?: string) => void;
|
||||
}
|
||||
|
||||
const AddSoftwareAdvancedOptions = ({
|
||||
errors,
|
||||
showPreInstallCondition,
|
||||
showPostInstallScript,
|
||||
preInstallCondition,
|
||||
postInstallScript,
|
||||
onTogglePreInstallCondition,
|
||||
onTogglePostInstallScript,
|
||||
onChangePreInstallCondition,
|
||||
onChangePostInstallScript,
|
||||
}: IAddSoftwareAdvancedOptionsProps) => {
|
||||
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
|
||||
|
||||
const onChangePreInstallCheckbox = () => {
|
||||
onTogglePreInstallCondition(!showPreInstallCondition);
|
||||
};
|
||||
|
||||
const onChangePostInstallCheckbox = () => {
|
||||
onTogglePostInstallScript(!showPostInstallScript);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<RevealButton
|
||||
className={`${baseClass}__accordion-title`}
|
||||
isShowing={showAdvancedOptions}
|
||||
showText="Advanced options"
|
||||
hideText="Advanced options"
|
||||
caretPosition="after"
|
||||
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
|
||||
/>
|
||||
{showAdvancedOptions && (
|
||||
<div className={`${baseClass}__input-fields`}>
|
||||
<Checkbox
|
||||
value={showPreInstallCondition}
|
||||
onChange={onChangePreInstallCheckbox}
|
||||
>
|
||||
Pre-install condition
|
||||
</Checkbox>
|
||||
{showPreInstallCondition && (
|
||||
<FleetAce
|
||||
focus
|
||||
error={errors.preInstallCondition}
|
||||
value={preInstallCondition}
|
||||
label="Query"
|
||||
name="preInstallQuery"
|
||||
maxLines={10}
|
||||
onChange={onChangePreInstallCondition}
|
||||
helpText={
|
||||
<>
|
||||
Software will be installed only if the{" "}
|
||||
<CustomLink
|
||||
className={`${baseClass}__table-link`}
|
||||
text="query returns results"
|
||||
url="https://fleetdm.com/tables"
|
||||
newTab
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Checkbox
|
||||
value={showPostInstallScript}
|
||||
onChange={onChangePostInstallCheckbox}
|
||||
>
|
||||
Post-install script
|
||||
</Checkbox>
|
||||
{showPostInstallScript && (
|
||||
<>
|
||||
<Editor
|
||||
focus
|
||||
error={errors.postInstallScript}
|
||||
wrapEnabled
|
||||
name="post-install-script-editor"
|
||||
maxLines={10}
|
||||
onChange={onChangePostInstallScript}
|
||||
value={postInstallScript}
|
||||
helpText="Shell (macOS and Linux) or PowerShell (Windows)."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddSoftwareAdvancedOptions;
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
.add-software-advanced-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: $pad-large;
|
||||
|
||||
&__input-fields {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-medium;
|
||||
}
|
||||
|
||||
&__table-link {
|
||||
font-size: $xx-small;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./AddSoftwareAdvancedOptions";
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
import React, { useState } from "react";
|
||||
|
||||
import getInstallScript from "utilities/software_install_scripts";
|
||||
|
||||
import Spinner from "components/Spinner";
|
||||
import Button from "components/buttons/Button";
|
||||
import FileUploader from "components/FileUploader";
|
||||
import Graphic from "components/Graphic";
|
||||
import Editor from "components/Editor";
|
||||
|
||||
import AddSoftwareAdvancedOptions from "../AddSoftwareAdvancedOptions";
|
||||
|
||||
import { generateFormValidation, getFileDetails } from "./helpers";
|
||||
|
||||
const baseClass = "add-software-form";
|
||||
|
||||
const UploadingSoftware = () => {
|
||||
return (
|
||||
<div className={`${baseClass}__uploading-message`}>
|
||||
<Spinner centered={false} />
|
||||
<p>Uploading. It may take few minutes to finish.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: if we reuse this one more time, we should consider moving this
|
||||
// into FileUploader as a default preview. Currently we have this in
|
||||
// AddProfileModal.tsx and here.
|
||||
const FileDetails = ({
|
||||
details: { name, platform },
|
||||
}: {
|
||||
details: {
|
||||
name: string;
|
||||
platform: string;
|
||||
};
|
||||
}) => (
|
||||
<div className={`${baseClass}__selected-file`}>
|
||||
<Graphic name="file-pkg" />
|
||||
<div className={`${baseClass}__selected-file--details`}>
|
||||
<div className={`${baseClass}__selected-file--details--name`}>{name}</div>
|
||||
<div className={`${baseClass}__selected-file--details--platform`}>
|
||||
{platform}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export interface IAddSoftwareFormData {
|
||||
software: File | null;
|
||||
installScript: string;
|
||||
preInstallCondition?: string;
|
||||
postInstallScript?: string;
|
||||
}
|
||||
|
||||
export interface IFormValidation {
|
||||
isValid: boolean;
|
||||
software: { isValid: boolean };
|
||||
preInstallCondition?: { isValid: boolean; message?: string };
|
||||
postInstallScript?: { isValid: boolean; message?: string };
|
||||
}
|
||||
|
||||
interface IAddSoftwareFormProps {
|
||||
isUploading: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (formData: IAddSoftwareFormData) => void;
|
||||
}
|
||||
|
||||
const AddSoftwareForm = ({
|
||||
isUploading,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: IAddSoftwareFormProps) => {
|
||||
const [showPreInstallCondition, setShowPreInstallCondition] = useState(false);
|
||||
const [showPostInstallScript, setShowPostInstallScript] = useState(false);
|
||||
const [formData, setFormData] = useState<IAddSoftwareFormData>({
|
||||
software: null,
|
||||
installScript: "",
|
||||
preInstallCondition: undefined,
|
||||
postInstallScript: undefined,
|
||||
});
|
||||
const [formValidation, setFormValidation] = useState<IFormValidation>({
|
||||
isValid: false,
|
||||
software: { isValid: false },
|
||||
});
|
||||
|
||||
const onFileUpload = (files: FileList | null) => {
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
const newData = {
|
||||
...formData,
|
||||
software: file,
|
||||
installScript: getInstallScript(file.name),
|
||||
};
|
||||
setFormData(newData);
|
||||
setFormValidation(
|
||||
generateFormValidation(
|
||||
newData,
|
||||
showPreInstallCondition,
|
||||
showPostInstallScript
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onFormSubmit = (evt: React.FormEvent<HTMLFormElement>) => {
|
||||
evt.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
const onTogglePreInstallConditionCheckbox = (value: boolean) => {
|
||||
const newData = { ...formData, preInstallCondition: undefined };
|
||||
setShowPreInstallCondition(value);
|
||||
setFormData(newData);
|
||||
setFormValidation(
|
||||
generateFormValidation(newData, value, showPostInstallScript)
|
||||
);
|
||||
};
|
||||
|
||||
const onTogglePostInstallScriptCheckbox = (value: boolean) => {
|
||||
const newData = { ...formData, postInstallScript: undefined };
|
||||
setShowPostInstallScript(value);
|
||||
setFormData(newData);
|
||||
setFormValidation(
|
||||
generateFormValidation(newData, showPreInstallCondition, value)
|
||||
);
|
||||
};
|
||||
|
||||
const onChangeInstallScript = (value: string) => {
|
||||
setFormData({ ...formData, installScript: value });
|
||||
};
|
||||
|
||||
const onChangePreInstallCondition = (value?: string) => {
|
||||
const newData = { ...formData, preInstallCondition: value };
|
||||
setFormData(newData);
|
||||
setFormValidation(
|
||||
generateFormValidation(
|
||||
newData,
|
||||
showPreInstallCondition,
|
||||
showPostInstallScript
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const onChangePostInstallScript = (value?: string) => {
|
||||
const newData = { ...formData, postInstallScript: value };
|
||||
setFormData(newData);
|
||||
setFormValidation(
|
||||
generateFormValidation(
|
||||
newData,
|
||||
showPreInstallCondition,
|
||||
showPostInstallScript
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const isSubmitDisabled = !formValidation.isValid;
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
{isUploading ? (
|
||||
<UploadingSoftware />
|
||||
) : (
|
||||
<form className={`${baseClass}__form`} onSubmit={onFormSubmit}>
|
||||
<FileUploader
|
||||
graphicName={"file-pkg"}
|
||||
accept=".pkg,.msi,.exe,.deb"
|
||||
message=".pkg, .msi, .exe, or .deb"
|
||||
onFileUpload={onFileUpload}
|
||||
buttonMessage="Choose file"
|
||||
buttonType="link"
|
||||
className={`${baseClass}__file-uploader`}
|
||||
filePreview={
|
||||
formData.software && (
|
||||
<FileDetails details={getFileDetails(formData.software)} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
{formData.software && (
|
||||
<Editor
|
||||
wrapEnabled
|
||||
maxLines={10}
|
||||
name="install-script"
|
||||
onChange={onChangeInstallScript}
|
||||
value={formData.installScript}
|
||||
helpText="Fleet will run this command on hosts to install software."
|
||||
label="Install script"
|
||||
labelTooltip={
|
||||
<>
|
||||
For security agents, add the script provided by the vendor.
|
||||
<br />
|
||||
In custom scripts, you can use the $INSTALLER_PATH variable to
|
||||
point to the installer.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<AddSoftwareAdvancedOptions
|
||||
errors={{
|
||||
preInstallCondition: formValidation.preInstallCondition?.message,
|
||||
postInstallScript: formValidation.postInstallScript?.message,
|
||||
}}
|
||||
showPreInstallCondition={showPreInstallCondition}
|
||||
showPostInstallScript={showPostInstallScript}
|
||||
preInstallCondition={formData.preInstallCondition}
|
||||
postInstallScript={formData.postInstallScript}
|
||||
onTogglePreInstallCondition={onTogglePreInstallConditionCheckbox}
|
||||
onTogglePostInstallScript={onTogglePostInstallScriptCheckbox}
|
||||
onChangePreInstallCondition={onChangePreInstallCondition}
|
||||
onChangePostInstallScript={onChangePostInstallScript}
|
||||
/>
|
||||
<div className="modal-cta-wrap">
|
||||
<Button type="submit" variant="brand" disabled={isSubmitDisabled}>
|
||||
Add software
|
||||
</Button>
|
||||
<Button onClick={onCancel} variant="inverse">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddSoftwareForm;
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
.add-software-form {
|
||||
|
||||
&__uploading-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: $pad-large;
|
||||
|
||||
p {
|
||||
margin: 0
|
||||
}
|
||||
}
|
||||
|
||||
&__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-large;
|
||||
}
|
||||
|
||||
&__file-uploader {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&__selected-file {
|
||||
display: flex;
|
||||
gap: $pad-medium;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
||||
&--details {
|
||||
&--name {
|
||||
font-size: $x-small;
|
||||
font-weight: $bold;
|
||||
}
|
||||
|
||||
&--platform {
|
||||
font-size: $xx-small;
|
||||
color: $ui-fleet-black-75;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
import validator from "validator";
|
||||
|
||||
// @ts-ignore
|
||||
import validateQuery from "components/forms/validators/validate_query";
|
||||
import { getPlatformDisplayName } from "utilities/file/fileUtils";
|
||||
|
||||
import { IAddSoftwareFormData, IFormValidation } from "./AddSoftwareForm";
|
||||
|
||||
type IAddSoftwareFormValidatorKey = Exclude<
|
||||
keyof IAddSoftwareFormData,
|
||||
"installScript"
|
||||
>;
|
||||
|
||||
type IMessageFunc = (formData: IAddSoftwareFormData) => string;
|
||||
type IValidationMessage = string | IMessageFunc;
|
||||
|
||||
interface IValidation {
|
||||
name: string;
|
||||
isValid: (
|
||||
formData: IAddSoftwareFormData,
|
||||
enabledPreInstallCondition?: boolean,
|
||||
enabledPostInstallScript?: boolean
|
||||
) => boolean;
|
||||
message?: IValidationMessage;
|
||||
}
|
||||
|
||||
/** configuration defines validations for each filed in the form. It defines rules
|
||||
* to determine if a field is valid, and rules for generating an error message.
|
||||
*/
|
||||
const FORM_VALIDATION_CONFIG: Record<
|
||||
IAddSoftwareFormValidatorKey,
|
||||
{ validations: IValidation[] }
|
||||
> = {
|
||||
software: {
|
||||
validations: [
|
||||
{
|
||||
name: "required",
|
||||
isValid: (formData) => formData.software !== null,
|
||||
},
|
||||
],
|
||||
},
|
||||
preInstallCondition: {
|
||||
validations: [
|
||||
{
|
||||
name: "required",
|
||||
isValid: (
|
||||
formData: IAddSoftwareFormData,
|
||||
enabledPreInstallCondition
|
||||
) => {
|
||||
if (!enabledPreInstallCondition) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
formData.preInstallCondition !== undefined &&
|
||||
!validator.isEmpty(formData.preInstallCondition)
|
||||
);
|
||||
},
|
||||
message: (formData) => {
|
||||
// we dont want an error message until the user has interacted with
|
||||
// the field. This is why we check for undefined here.
|
||||
if (formData.preInstallCondition === undefined) {
|
||||
return "";
|
||||
}
|
||||
return "Pre-install condition is required when enabled.";
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalidQuery",
|
||||
isValid: (formData, enabledPreInstallCondition) => {
|
||||
if (!enabledPreInstallCondition) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
formData.preInstallCondition !== undefined &&
|
||||
validateQuery(formData.preInstallCondition).valid
|
||||
);
|
||||
},
|
||||
message: (formData) =>
|
||||
validateQuery(formData.preInstallCondition).error,
|
||||
},
|
||||
],
|
||||
},
|
||||
postInstallScript: {
|
||||
validations: [
|
||||
{
|
||||
name: "required",
|
||||
message: (formData) => {
|
||||
// we dont want an error message until the user has interacted with
|
||||
// the field. This is why we check for undefined here.
|
||||
if (formData.postInstallScript === undefined) {
|
||||
return "";
|
||||
}
|
||||
return "Post-install script is required when enabled.";
|
||||
},
|
||||
isValid: (formData, _, enabledPostInstallScript) => {
|
||||
if (!enabledPostInstallScript) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
formData.postInstallScript !== undefined &&
|
||||
!validator.isEmpty(formData.postInstallScript)
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const getErrorMessage = (
|
||||
formData: IAddSoftwareFormData,
|
||||
message?: IValidationMessage
|
||||
) => {
|
||||
if (message === undefined || typeof message === "string") {
|
||||
return message;
|
||||
}
|
||||
return message(formData);
|
||||
};
|
||||
|
||||
export const generateFormValidation = (
|
||||
formData: IAddSoftwareFormData,
|
||||
showingPreInstallCondition: boolean,
|
||||
showingPostInstallScript: boolean
|
||||
) => {
|
||||
const formValidation: IFormValidation = {
|
||||
isValid: true,
|
||||
software: {
|
||||
isValid: false,
|
||||
},
|
||||
};
|
||||
|
||||
Object.keys(FORM_VALIDATION_CONFIG).forEach((key) => {
|
||||
const objKey = key as keyof typeof FORM_VALIDATION_CONFIG;
|
||||
const failedValidation = FORM_VALIDATION_CONFIG[objKey].validations.find(
|
||||
(validation) =>
|
||||
!validation.isValid(
|
||||
formData,
|
||||
showingPreInstallCondition,
|
||||
showingPostInstallScript
|
||||
)
|
||||
);
|
||||
|
||||
if (!failedValidation) {
|
||||
formValidation[objKey] = {
|
||||
isValid: true,
|
||||
};
|
||||
} else {
|
||||
formValidation.isValid = false;
|
||||
formValidation[objKey] = {
|
||||
isValid: false,
|
||||
message: getErrorMessage(formData, failedValidation.message),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return formValidation;
|
||||
};
|
||||
|
||||
export const getFileDetails = (file: File) => {
|
||||
return {
|
||||
name: file.name,
|
||||
platform: getPlatformDisplayName(file),
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./AddSoftwareForm";
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { InjectedRouter } from "react-router";
|
||||
|
||||
import PATHS from "router/paths";
|
||||
import { APP_CONTEXT_ALL_TEAMS_ID } from "interfaces/team";
|
||||
import { getErrorReason } from "interfaces/errors";
|
||||
import softwareAPI from "services/entities/software";
|
||||
import { NotificationContext } from "context/notification";
|
||||
import { buildQueryStringFromParams } from "utilities/url";
|
||||
|
||||
import Modal from "components/Modal";
|
||||
import Button from "components/buttons/Button";
|
||||
|
||||
import AddSoftwareForm from "../AddSoftwareForm";
|
||||
import { IAddSoftwareFormData } from "../AddSoftwareForm/AddSoftwareForm";
|
||||
|
||||
// 2 minutes
|
||||
const UPLOAD_TIMEOUT = 120000;
|
||||
const MAX_FILE_SIZE_MB = 500;
|
||||
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
|
||||
|
||||
const baseClass = "add-software-modal";
|
||||
|
||||
interface IAllTeamsMessageProps {
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
const AllTeamsMessage = ({ onExit }: IAllTeamsMessageProps) => {
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
Please select a team first. Software can't be added when{" "}
|
||||
<b>All teams</b> is selected.
|
||||
</p>
|
||||
<div className="modal-cta-wrap">
|
||||
<Button variant="brand" onClick={onExit}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface IAddSoftwareModalProps {
|
||||
teamId: number;
|
||||
router: InjectedRouter;
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
const AddSoftwareModal = ({
|
||||
teamId,
|
||||
router,
|
||||
onExit,
|
||||
}: IAddSoftwareModalProps) => {
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: NodeJS.Timeout;
|
||||
|
||||
const beforeUnloadHandler = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
// Next line with e.returnValue is included for legacy support
|
||||
// e.g.Chrome / Edge < 119
|
||||
e.returnValue = true;
|
||||
};
|
||||
|
||||
// set up event listener to prevent user from leaving page while uploading
|
||||
if (isUploading) {
|
||||
addEventListener("beforeunload", beforeUnloadHandler);
|
||||
timeout = setTimeout(() => {
|
||||
removeEventListener("beforeunload", beforeUnloadHandler);
|
||||
}, UPLOAD_TIMEOUT);
|
||||
} else {
|
||||
removeEventListener("beforeunload", beforeUnloadHandler);
|
||||
}
|
||||
|
||||
// clean up event listener and timeout on component unmount
|
||||
return () => {
|
||||
removeEventListener("beforeunload", beforeUnloadHandler);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [isUploading]);
|
||||
|
||||
const onAddSoftware = async (formData: IAddSoftwareFormData) => {
|
||||
setIsUploading(true);
|
||||
|
||||
if (formData.software && formData.software.size > MAX_FILE_SIZE_BYTES) {
|
||||
renderFlash(
|
||||
"error",
|
||||
`Couldn’t add. The maximum file size is ${MAX_FILE_SIZE_MB} MB.`
|
||||
);
|
||||
onExit();
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await softwareAPI.addSoftwarePackage(formData, teamId);
|
||||
renderFlash(
|
||||
"success",
|
||||
<>
|
||||
<b>{formData.software?.name}</b> successfully added. Go to Host
|
||||
details page to install software.
|
||||
</>
|
||||
);
|
||||
onExit();
|
||||
router.push(
|
||||
`${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams({
|
||||
available_for_install: true,
|
||||
team_id: teamId,
|
||||
})}`
|
||||
);
|
||||
} catch (e) {
|
||||
renderFlash("error", getErrorReason(e));
|
||||
onExit();
|
||||
}
|
||||
|
||||
setIsUploading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Add software"
|
||||
onExit={onExit}
|
||||
width="large"
|
||||
className={baseClass}
|
||||
>
|
||||
<>
|
||||
{teamId === APP_CONTEXT_ALL_TEAMS_ID ? (
|
||||
<AllTeamsMessage onExit={onExit} />
|
||||
) : (
|
||||
<AddSoftwareForm
|
||||
isUploading={isUploading}
|
||||
onCancel={onExit}
|
||||
onSubmit={onAddSoftware}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddSoftwareModal;
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue