7765 combined schedules and queries (#13037)

Issue #7765 – Combine the query and schedule features to provide a single interface
for creating, scheduling, and tweaking queries at the global and team
level.

Co-authored-by: Juan Fernandez <juan@fleetdm.com>
Co-authored-by: Lucas Manuel Rodriguez <lucarodriguez@gmail.com>
Co-authored-by: Rachel Perkins <rachel.elysia.perkins@gmail.com>
Co-authored-by: Jacob Shandling <jacobshandling@gmail.com>
This commit is contained in:
Jacob Shandling 2023-07-28 14:24:59 -07:00 committed by GitHub
commit dba896f901
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
176 changed files with 7173 additions and 4335 deletions

View file

@ -0,0 +1 @@
- Merged all functionality of the Schedule page into the Queries page

View file

@ -0,0 +1,2 @@
- The `osquery/config` endpoint should include scheduled queries for the host's team stored in the
`queries` table.

View file

@ -0,0 +1 @@
- Users able to manage schedulable queries (new feature) with automations modal

View file

@ -0,0 +1 @@
- Query editor includes frequency and other advanced options

View file

@ -0,0 +1 @@
- Update the save query modal to include scheduling-related fields.

View file

@ -0,0 +1 @@
* Update the "Platforms" column to the more explicit "Compatible with"

View file

@ -0,0 +1 @@
- Combine the query and schedule features to provide a single interface for creating, scheduling, and tweaking queries at the global and team level.

View file

@ -0,0 +1 @@
- Updated 'queries' table schema to allow storing scheduling information and configuration in the 'queries' table.

View file

@ -779,12 +779,6 @@ func newCleanupsAndAggregationSchedule(
return ds.UpdateQueryAggregatedStats(ctx)
},
),
schedule.WithJob(
"scheduled_query_aggregated_stats",
func(ctx context.Context) error {
return ds.UpdateScheduledQueryAggregatedStats(ctx)
},
),
schedule.WithJob(
"aggregated_munki_and_mdm",
func(ctx context.Context) error {

View file

@ -62,8 +62,8 @@ import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/spf13/cobra"
_ "go.elastic.co/apm/module/apmsql"
_ "go.elastic.co/apm/module/apmsql/mysql"
_ "go.elastic.co/apm/module/apmsql/v2"
_ "go.elastic.co/apm/module/apmsql/v2/mysql"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"

View file

@ -1122,7 +1122,7 @@ spec:
// Apply queries.
var appliedQueries []*fleet.Query
ds.QueryByNameFunc = func(ctx context.Context, name string, opts ...fleet.OptionalArg) (*fleet.Query, error) {
ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string, opts ...fleet.OptionalArg) (*fleet.Query, error) {
return nil, sql.ErrNoRows
}
ds.ApplyQueriesFunc = func(ctx context.Context, authorID uint, queries []*fleet.Query) error {
@ -1223,7 +1223,7 @@ func TestApplyQueries(t *testing.T) {
_, ds := runServerWithMockedDS(t)
var appliedQueries []*fleet.Query
ds.QueryByNameFunc = func(ctx context.Context, name string, opts ...fleet.OptionalArg) (*fleet.Query, error) {
ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string, opts ...fleet.OptionalArg) (*fleet.Query, error) {
return nil, sql.ErrNoRows
}
ds.ApplyQueriesFunc = func(ctx context.Context, authorID uint, queries []*fleet.Query) error {

View file

@ -64,5 +64,5 @@ func TestConvertFileStdout(t *testing.T) {
os.Stdout = oldStdout
w.Close()
out, _ := ioutil.ReadAll(r)
require.Equal(t, expected, out)
require.Equal(t, string(expected), string(out))
}

View file

@ -78,11 +78,11 @@ func TestDeleteQuery(t *testing.T) {
_, ds := runServerWithMockedDS(t)
var deletedQuery string
ds.DeleteQueryFunc = func(ctx context.Context, name string) error {
ds.DeleteQueryFunc = func(ctx context.Context, teamID *uint, name string) error {
deletedQuery = name
return nil
}
ds.QueryByNameFunc = func(ctx context.Context, name string, opts ...fleet.OptionalArg) (*fleet.Query, error) {
ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string, opts ...fleet.OptionalArg) (*fleet.Query, error) {
if name != "query1" {
return nil, nil
}

View file

@ -102,7 +102,7 @@ func printLabel(c *cli.Context, label *fleet.LabelSpec) error {
return printSpec(c, spec)
}
func printQuery(c *cli.Context, query *fleet.QuerySpec) error {
func printQuerySpec(c *cli.Context, query *fleet.QuerySpec) error {
spec := specGeneric{
Kind: fleet.QueryKind,
Version: fleet.ApiVersion,
@ -298,12 +298,74 @@ func getCommand() *cli.Command {
}
}
func queryToTableRow(query fleet.Query, teamName string) []string {
platform := "all"
if query.Platform != "" {
platform = query.Platform
}
minOsqueryVersion := "all"
if query.MinOsqueryVersion != "" {
minOsqueryVersion = query.MinOsqueryVersion
}
scheduleInfo := fmt.Sprintf("interval: %d\nplatform: %s\nmin_osquery_version: %s\nautomations_enabled: %t\nlogging: %s",
query.Interval,
platform,
minOsqueryVersion,
query.AutomationsEnabled,
query.Logging,
)
teamNameOut := teamName
if teamName == "" {
teamNameOut = "All teams"
}
return []string{
query.Name,
query.Description,
query.Query,
teamNameOut,
scheduleInfo,
}
}
func printInheritedQueriesMsg(client *service.Client, teamID *uint) error {
if teamID != nil {
globalQueries, err := client.GetQueries(nil)
if err != nil {
return fmt.Errorf("could not list global queries: %w", err)
}
if len(globalQueries) > 0 {
fmt.Printf("Not showing %d inherited queries. To see global queries, run this command without the `--team` flag.\n", len(globalQueries))
}
return nil
}
return nil
}
func printNoQueriesFoundMsg(teamID *uint) {
if teamID != nil {
fmt.Println("No team queries found.")
return
}
fmt.Println("No global queries found.")
fmt.Println("To see team queries, run this command with the --team flag.")
}
func getQueriesCommand() *cli.Command {
return &cli.Command{
Name: "queries",
Aliases: []string{"query", "q"},
Usage: "List information about one or more queries",
Flags: []cli.Flag{
&cli.UintFlag{
Name: teamFlagName,
Usage: "filter queries by team_id (0 means global)",
},
jsonFlag(),
yamlFlag(),
configFlag(),
@ -318,9 +380,27 @@ func getQueriesCommand() *cli.Command {
name := c.Args().First()
// if name wasn't provided, list all queries
var teamID *uint
var teamName string
if tid := c.Uint(teamFlagName); tid != 0 {
teamID = &tid
team, err := client.GetTeam(*teamID)
if err != nil {
var notFoundErr service.NotFoundErr
if errors.As(err, &notFoundErr) {
// Do not error out, just inform the user and 'gracefully' exit.
fmt.Println("Team not found.")
return nil
}
return fmt.Errorf("get team: %w", err)
}
teamName = team.Name
}
// if name wasn't provided, list either all global queries or all team queries...
if name == "" {
queries, err := client.GetQueries()
queries, err := client.GetQueries(teamID)
if err != nil {
return fmt.Errorf("could not list queries: %w", err)
}
@ -350,44 +430,54 @@ func getQueriesCommand() *cli.Command {
}
if len(queries) == 0 {
fmt.Println("No queries found")
printNoQueriesFoundMsg(teamID)
if err := printInheritedQueriesMsg(client, teamID); err != nil {
return err
}
return nil
}
if c.Bool(yamlFlagName) || c.Bool(jsonFlagName) {
for _, query := range queries {
if err := printQuery(c, &fleet.QuerySpec{
if err := printQuerySpec(c, &fleet.QuerySpec{
Name: query.Name,
Description: query.Description,
Query: query.Query,
TeamName: teamName,
Interval: query.Interval,
ObserverCanRun: query.ObserverCanRun,
Platform: query.Platform,
MinOsqueryVersion: query.MinOsqueryVersion,
AutomationsEnabled: query.AutomationsEnabled,
Logging: query.Logging,
}); err != nil {
return fmt.Errorf("unable to print query: %w", err)
}
}
} else {
// Default to printing as a table
data := [][]string{}
rows := [][]string{}
columns := []string{"name", "description", "query", "team", "schedule"}
for _, query := range queries {
data = append(data, []string{
query.Name,
query.Description,
query.Query,
})
rows = append(rows, queryToTableRow(query, teamName))
}
columns := []string{"name", "description", "query"}
printTable(c, columns, data)
printQueryTable(c, columns, rows)
if err := printInheritedQueriesMsg(client, teamID); err != nil {
return err
}
}
return nil
}
query, err := client.GetQuery(name)
query, err := client.GetQuerySpec(teamID, name)
if err != nil {
return err
}
if err := printQuery(c, query); err != nil {
if err := printQuerySpec(c, query); err != nil {
return fmt.Errorf("unable to print query: %w", err)
}
@ -426,7 +516,7 @@ func getPacksCommand() *cli.Command {
Flags: []cli.Flag{
&cli.BoolFlag{
Name: withQueriesFlagName,
Usage: "Output queries included in pack(s) too",
Usage: "Output queries included in pack(s) too, when used alongside --yaml or --json",
},
jsonFlag(),
yamlFlag(),
@ -457,7 +547,8 @@ func getPacksCommand() *cli.Command {
return nil
}
queries, err := client.GetQueries()
// Get global queries (teamID==nil), because 2017 packs reference global queries.
queries, err := client.GetQueries(nil)
if err != nil {
return fmt.Errorf("could not list queries: %w", err)
}
@ -469,7 +560,7 @@ func getPacksCommand() *cli.Command {
continue
}
if err := printQuery(c, &fleet.QuerySpec{
if err := printQuerySpec(c, &fleet.QuerySpec{
Name: query.Name,
Description: query.Description,
Query: query.Query,
@ -493,10 +584,8 @@ func getPacksCommand() *cli.Command {
if err := printPack(c, pack); err != nil {
return fmt.Errorf("unable to print pack: %w", err)
}
addQueries(pack)
}
return printQueries()
}
@ -994,6 +1083,14 @@ func getUserRolesCommand() *cli.Command {
}
}
func printQueryTable(c *cli.Context, columns []string, data [][]string) {
table := defaultTable(c.App.Writer)
table.SetHeader(columns)
table.SetReflowDuringAutoWrap(false)
table.AppendBulk(data)
table.Render()
}
func printTable(c *cli.Context, columns []string, data [][]string) {
table := defaultTable(c.App.Writer)
table.SetHeader(columns)

View file

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
@ -972,92 +973,313 @@ spec:
}
func TestGetQueries(t *testing.T) {
_, ds := runServerWithMockedDS(t)
_, ds := runServerWithMockedDS(t, &service.TestServerOpts{
License: &fleet.LicenseInfo{
Tier: fleet.TierPremium,
Expiration: time.Now().Add(24 * time.Hour),
},
})
ds.ListQueriesFunc = func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, error) {
return []*fleet.Query{
ds.TeamsSummaryFunc = func(ctx context.Context) ([]*fleet.TeamSummary, error) {
return []*fleet.TeamSummary{
{
ID: 33,
Name: "query1",
Description: "some desc",
Query: "select 1;",
Saved: false,
ObserverCanRun: false,
},
{
ID: 12,
Name: "query2",
Description: "some desc 2",
Query: "select 2;",
Saved: true,
ObserverCanRun: false,
ID: 1,
Name: "Foobar",
},
}, nil
}
ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) {
if tid == 1 {
return &fleet.Team{
ID: tid,
Name: "Foobar",
}, nil
}
return nil, &notFoundError{}
}
ds.ListQueriesFunc = func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, error) {
if opt.TeamID == nil {
return []*fleet.Query{
{
ID: 33,
Name: "query1",
Description: "some desc",
Query: "select 1;",
Saved: true, // ListQueries always returns the saved ones.
ObserverCanRun: false,
},
{
ID: 12,
Name: "query2",
Description: "some desc 2",
Query: "select 2;",
Saved: true, // ListQueries always returns the saved ones.
ObserverCanRun: false,
},
{
ID: 14,
Name: "query4",
Description: "some desc 4",
Query: "select 4;",
Interval: 60,
AutomationsEnabled: true,
MinOsqueryVersion: "5.3.0",
Platform: "darwin,windows",
Logging: "differential_ignore_removals",
Saved: true, // ListQueries always returns the saved ones.
ObserverCanRun: true,
},
}, nil
} else if *opt.TeamID == 1 {
return []*fleet.Query{
{
ID: 13,
Name: "query3",
Description: "some desc 3",
Query: "select 3;",
Interval: 3600,
AutomationsEnabled: false,
MinOsqueryVersion: "5.4.0",
Platform: "darwin",
Logging: "snapshot",
Saved: true, // ListQueries always returns the saved ones.
TeamID: ptr.Uint(1),
ObserverCanRun: true,
},
}, nil
} else if *opt.TeamID == 2 {
return []*fleet.Query{}, nil
}
return nil, errors.New("invalid team ID")
}
expected := `+--------+-------------+-----------+
| NAME | DESCRIPTION | QUERY |
+--------+-------------+-----------+
| query1 | some desc | select 1; |
+--------+-------------+-----------+
| query2 | some desc 2 | select 2; |
+--------+-------------+-----------+
expectedGlobal := `+--------+-------------+-----------+-----------+--------------------------------+
| NAME | DESCRIPTION | QUERY | TEAM | SCHEDULE |
+--------+-------------+-----------+-----------+--------------------------------+
| query1 | some desc | select 1; | All teams | interval: 0 |
| | | | | |
| | | | | platform: all |
| | | | | |
| | | | | min_osquery_version: all |
| | | | | |
| | | | | automations_enabled: false |
| | | | | |
| | | | | logging: |
+--------+-------------+-----------+-----------+--------------------------------+
| query2 | some desc 2 | select 2; | All teams | interval: 0 |
| | | | | |
| | | | | platform: all |
| | | | | |
| | | | | min_osquery_version: all |
| | | | | |
| | | | | automations_enabled: false |
| | | | | |
| | | | | logging: |
+--------+-------------+-----------+-----------+--------------------------------+
| query4 | some desc 4 | select 4; | All teams | interval: 60 |
| | | | | |
| | | | | platform: darwin,windows |
| | | | | |
| | | | | min_osquery_version: 5.3.0 |
| | | | | |
| | | | | automations_enabled: true |
| | | | | |
| | | | | logging: |
| | | | | differential_ignore_removals |
+--------+-------------+-----------+-----------+--------------------------------+
`
expectedYaml := `---
expectedYAMLGlobal := `---
apiVersion: v1
kind: query
spec:
automations_enabled: false
description: some desc
interval: 0
logging: ""
min_osquery_version: ""
name: query1
observer_can_run: false
platform: ""
query: select 1;
team: ""
---
apiVersion: v1
kind: query
spec:
automations_enabled: false
description: some desc 2
interval: 0
logging: ""
min_osquery_version: ""
name: query2
observer_can_run: false
platform: ""
query: select 2;
team: ""
---
apiVersion: v1
kind: query
spec:
automations_enabled: true
description: some desc 4
interval: 60
logging: differential_ignore_removals
min_osquery_version: 5.3.0
name: query4
observer_can_run: true
platform: darwin,windows
query: select 4;
team: ""
`
expectedJson := `{"kind":"query","apiVersion":"v1","spec":{"name":"query1","description":"some desc","query":"select 1;"}}
{"kind":"query","apiVersion":"v1","spec":{"name":"query2","description":"some desc 2","query":"select 2;"}}
expectedJSONGlobal := `{"kind":"query","apiVersion":"v1","spec":{"name":"query1","description":"some desc","query":"select 1;","team":"","interval":0,"observer_can_run":false,"platform":"","min_osquery_version":"","automations_enabled":false,"logging":""}}
{"kind":"query","apiVersion":"v1","spec":{"name":"query2","description":"some desc 2","query":"select 2;","team":"","interval":0,"observer_can_run":false,"platform":"","min_osquery_version":"","automations_enabled":false,"logging":""}}
{"kind":"query","apiVersion":"v1","spec":{"name":"query4","description":"some desc 4","query":"select 4;","team":"","interval":60,"observer_can_run":true,"platform":"darwin,windows","min_osquery_version":"5.3.0","automations_enabled":true,"logging":"differential_ignore_removals"}}
`
assert.Equal(t, expected, runAppForTest(t, []string{"get", "queries"}))
assert.Equal(t, expectedYaml, runAppForTest(t, []string{"get", "queries", "--yaml"}))
assert.Equal(t, expectedJson, runAppForTest(t, []string{"get", "queries", "--json"}))
expectedTeam := `+--------+-------------+-----------+--------+----------------------------+
| NAME | DESCRIPTION | QUERY | TEAM | SCHEDULE |
+--------+-------------+-----------+--------+----------------------------+
| query3 | some desc 3 | select 3; | Foobar | interval: 3600 |
| | | | | |
| | | | | platform: darwin |
| | | | | |
| | | | | min_osquery_version: 5.4.0 |
| | | | | |
| | | | | automations_enabled: false |
| | | | | |
| | | | | logging: snapshot |
+--------+-------------+-----------+--------+----------------------------+
`
expectedYAMLTeam := `---
apiVersion: v1
kind: query
spec:
automations_enabled: false
description: some desc 3
interval: 3600
logging: snapshot
min_osquery_version: 5.4.0
name: query3
observer_can_run: true
platform: darwin
query: select 3;
team: Foobar
`
expectedJSONTeam := `{"kind":"query","apiVersion":"v1","spec":{"name":"query3","description":"some desc 3","query":"select 3;","team":"Foobar","interval":3600,"observer_can_run":true,"platform":"darwin","min_osquery_version":"5.4.0","automations_enabled":false,"logging":"snapshot"}}
`
assert.Equal(t, expectedGlobal, runAppForTest(t, []string{"get", "queries"}))
assert.Equal(t, expectedYAMLGlobal, runAppForTest(t, []string{"get", "queries", "--yaml"}))
assert.Equal(t, expectedJSONGlobal, runAppForTest(t, []string{"get", "queries", "--json"}))
assert.Equal(t, expectedTeam, runAppForTest(t, []string{"get", "queries", "--team", "1"}))
assert.Equal(t, expectedYAMLTeam, runAppForTest(t, []string{"get", "queries", "--yaml", "--team", "1"}))
assert.Equal(t, expectedJSONTeam, runAppForTest(t, []string{"get", "queries", "--json", "--team", "1"}))
assert.Equal(t, "", runAppForTest(t, []string{"get", "queries", "--team", "2"}))
assert.Equal(t, "", runAppForTest(t, []string{"get", "queries", "--yaml", "--team", "2"}))
assert.Equal(t, "", runAppForTest(t, []string{"get", "queries", "--json", "--team", "2"}))
}
func TestGetQuery(t *testing.T) {
_, ds := runServerWithMockedDS(t)
_, ds := runServerWithMockedDS(t, &service.TestServerOpts{
License: &fleet.LicenseInfo{
Tier: fleet.TierPremium,
Expiration: time.Now().Add(24 * time.Hour),
},
})
ds.QueryByNameFunc = func(ctx context.Context, name string, opts ...fleet.OptionalArg) (*fleet.Query, error) {
if name != "query1" {
return nil, nil
ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) {
if tid == 1 {
return &fleet.Team{
ID: tid,
Name: "Foobar",
}, nil
}
return nil, &notFoundError{}
}
ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string, opts ...fleet.OptionalArg) (*fleet.Query, error) {
if teamID == nil {
if name != "globalQuery1" {
return nil, &notFoundError{}
}
return &fleet.Query{
ID: 33,
Name: "globalQuery1",
Description: "some desc",
Query: "select 1;",
Saved: true,
ObserverCanRun: false,
}, nil
} else if *teamID == 1 {
if name != "teamQuery1" {
return nil, &notFoundError{}
}
return &fleet.Query{
ID: 34,
Name: "teamQuery1",
Description: "some team desc",
Query: "select 2;",
Saved: true,
ObserverCanRun: true,
TeamID: teamID,
Interval: 3600,
AutomationsEnabled: true,
MinOsqueryVersion: "5.2.0",
Platform: "linux",
Logging: "differential",
}, nil
} else {
return nil, &notFoundError{}
}
return &fleet.Query{
ID: 33,
Name: "query1",
Description: "some desc",
Query: "select 1;",
Saved: false,
ObserverCanRun: false,
}, nil
}
expectedYaml := `---
apiVersion: v1
kind: query
spec:
automations_enabled: false
description: some desc
name: query1
interval: 0
logging: ""
min_osquery_version: ""
name: globalQuery1
observer_can_run: false
platform: ""
query: select 1;
team: ""
`
expectedJson := `{"kind":"query","apiVersion":"v1","spec":{"name":"query1","description":"some desc","query":"select 1;"}}
expectedJson := `{"kind":"query","apiVersion":"v1","spec":{"name":"globalQuery1","description":"some desc","query":"select 1;","team":"","interval":0,"observer_can_run":false,"platform":"","min_osquery_version":"","automations_enabled":false,"logging":""}}
`
assert.Equal(t, expectedYaml, runAppForTest(t, []string{"get", "query", "query1"}))
assert.Equal(t, expectedYaml, runAppForTest(t, []string{"get", "query", "--yaml", "query1"}))
assert.Equal(t, expectedJson, runAppForTest(t, []string{"get", "query", "--json", "query1"}))
assert.Equal(t, expectedYaml, runAppForTest(t, []string{"get", "query", "globalQuery1"}))
assert.Equal(t, expectedYaml, runAppForTest(t, []string{"get", "query", "--yaml", "globalQuery1"}))
assert.Equal(t, expectedJson, runAppForTest(t, []string{"get", "query", "--json", "globalQuery1"}))
expectedYaml = `---
apiVersion: v1
kind: query
spec:
automations_enabled: true
description: some team desc
interval: 3600
logging: differential
min_osquery_version: 5.2.0
name: teamQuery1
observer_can_run: true
platform: linux
query: select 2;
team: Foobar
`
expectedJson = `{"kind":"query","apiVersion":"v1","spec":{"name":"teamQuery1","description":"some team desc","query":"select 2;","team":"Foobar","interval":3600,"observer_can_run":true,"platform":"linux","min_osquery_version":"5.2.0","automations_enabled":true,"logging":"differential"}}
`
assert.Equal(t, expectedYaml, runAppForTest(t, []string{"get", "query", "--team", "1", "teamQuery1"}))
assert.Equal(t, expectedYaml, runAppForTest(t, []string{"get", "query", "--yaml", "--team", "1", "teamQuery1"}))
assert.Equal(t, expectedJson, runAppForTest(t, []string{"get", "query", "--json", "--team", "1", "teamQuery1"}))
}
// TestGetQueriesAsObservers tests that when observers run `fleectl get queries` they
@ -1157,21 +1379,36 @@ func TestGetQueriesAsObserver(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
setCurrentUserSession(tc.user)
expected := `+--------+-------------+-----------+
| NAME | DESCRIPTION | QUERY |
+--------+-------------+-----------+
| query2 | some desc 2 | select 2; |
+--------+-------------+-----------+
expected := `+--------+-------------+-----------+-----------+----------------------------+
| NAME | DESCRIPTION | QUERY | TEAM | SCHEDULE |
+--------+-------------+-----------+-----------+----------------------------+
| query2 | some desc 2 | select 2; | All teams | interval: 0 |
| | | | | |
| | | | | platform: all |
| | | | | |
| | | | | min_osquery_version: all |
| | | | | |
| | | | | automations_enabled: false |
| | | | | |
| | | | | logging: |
+--------+-------------+-----------+-----------+----------------------------+
`
expectedYaml := `---
apiVersion: v1
kind: query
spec:
automations_enabled: false
description: some desc 2
interval: 0
logging: ""
min_osquery_version: ""
name: query2
observer_can_run: true
platform: ""
query: select 2;
team: ""
`
expectedJson := `{"kind":"query","apiVersion":"v1","spec":{"name":"query2","description":"some desc 2","query":"select 2;"}}
expectedJson := `{"kind":"query","apiVersion":"v1","spec":{"name":"query2","description":"some desc 2","query":"select 2;","team":"","interval":0,"observer_can_run":true,"platform":"","min_osquery_version":"","automations_enabled":false,"logging":""}}
`
assert.Equal(t, expected, runAppForTest(t, []string{"get", "queries"}))
@ -1199,41 +1436,86 @@ spec:
},
})
expected := `+--------+-------------+-----------+
| NAME | DESCRIPTION | QUERY |
+--------+-------------+-----------+
| query1 | some desc | select 1; |
+--------+-------------+-----------+
| query2 | some desc 2 | select 2; |
+--------+-------------+-----------+
| query3 | some desc 3 | select 3; |
+--------+-------------+-----------+
expected := `+--------+-------------+-----------+-----------+----------------------------+
| NAME | DESCRIPTION | QUERY | TEAM | SCHEDULE |
+--------+-------------+-----------+-----------+----------------------------+
| query1 | some desc | select 1; | All teams | interval: 0 |
| | | | | |
| | | | | platform: all |
| | | | | |
| | | | | min_osquery_version: all |
| | | | | |
| | | | | automations_enabled: false |
| | | | | |
| | | | | logging: |
+--------+-------------+-----------+-----------+----------------------------+
| query2 | some desc 2 | select 2; | All teams | interval: 0 |
| | | | | |
| | | | | platform: all |
| | | | | |
| | | | | min_osquery_version: all |
| | | | | |
| | | | | automations_enabled: false |
| | | | | |
| | | | | logging: |
+--------+-------------+-----------+-----------+----------------------------+
| query3 | some desc 3 | select 3; | All teams | interval: 0 |
| | | | | |
| | | | | platform: all |
| | | | | |
| | | | | min_osquery_version: all |
| | | | | |
| | | | | automations_enabled: false |
| | | | | |
| | | | | logging: |
+--------+-------------+-----------+-----------+----------------------------+
`
expectedYaml := `---
apiVersion: v1
kind: query
spec:
automations_enabled: false
description: some desc
interval: 0
logging: ""
min_osquery_version: ""
name: query1
observer_can_run: false
platform: ""
query: select 1;
team: ""
---
apiVersion: v1
kind: query
spec:
automations_enabled: false
description: some desc 2
interval: 0
logging: ""
min_osquery_version: ""
name: query2
observer_can_run: true
platform: ""
query: select 2;
team: ""
---
apiVersion: v1
kind: query
spec:
automations_enabled: false
description: some desc 3
interval: 0
logging: ""
min_osquery_version: ""
name: query3
observer_can_run: false
platform: ""
query: select 3;
team: ""
`
expectedJson := `{"kind":"query","apiVersion":"v1","spec":{"name":"query1","description":"some desc","query":"select 1;"}}
{"kind":"query","apiVersion":"v1","spec":{"name":"query2","description":"some desc 2","query":"select 2;"}}
{"kind":"query","apiVersion":"v1","spec":{"name":"query3","description":"some desc 3","query":"select 3;"}}
expectedJson := `{"kind":"query","apiVersion":"v1","spec":{"name":"query1","description":"some desc","query":"select 1;","team":"","interval":0,"observer_can_run":false,"platform":"","min_osquery_version":"","automations_enabled":false,"logging":""}}
{"kind":"query","apiVersion":"v1","spec":{"name":"query2","description":"some desc 2","query":"select 2;","team":"","interval":0,"observer_can_run":true,"platform":"","min_osquery_version":"","automations_enabled":false,"logging":""}}
{"kind":"query","apiVersion":"v1","spec":{"name":"query3","description":"some desc 3","query":"select 3;","team":"","interval":0,"observer_can_run":false,"platform":"","min_osquery_version":"","automations_enabled":false,"logging":""}}
`
assert.Equal(t, expected, runAppForTest(t, []string{"get", "queries"}))
@ -1288,13 +1570,29 @@ spec:
},
}, nil
}
expected = `+--------+-------------+-----------+
| NAME | DESCRIPTION | QUERY |
+--------+-------------+-----------+
| query1 | some desc | select 1; |
+--------+-------------+-----------+
| query2 | some desc 2 | select 2; |
+--------+-------------+-----------+
expected = `+--------+-------------+-----------+-----------+----------------------------+
| NAME | DESCRIPTION | QUERY | TEAM | SCHEDULE |
+--------+-------------+-----------+-----------+----------------------------+
| query1 | some desc | select 1; | All teams | interval: 0 |
| | | | | |
| | | | | platform: all |
| | | | | |
| | | | | min_osquery_version: all |
| | | | | |
| | | | | automations_enabled: false |
| | | | | |
| | | | | logging: |
+--------+-------------+-----------+-----------+----------------------------+
| query2 | some desc 2 | select 2; | All teams | interval: 0 |
| | | | | |
| | | | | platform: all |
| | | | | |
| | | | | min_osquery_version: all |
| | | | | |
| | | | | automations_enabled: false |
| | | | | |
| | | | | logging: |
+--------+-------------+-----------+-----------+----------------------------+
`
assert.Equal(t, expected, runAppForTest(t, []string{"get", "queries"}))
}

View file

@ -75,6 +75,10 @@ func queryCommand() *cli.Command {
Destination: &flTimeout,
Usage: "How long to run query before exiting (10s, 1h, etc.)",
},
&cli.UintFlag{
Name: teamFlagName,
Usage: "ID of the team where the named query belongs to (0 means global)",
},
configFlag(),
contextFlag(),
debugFlag(),
@ -94,7 +98,11 @@ func queryCommand() *cli.Command {
}
if flQueryName != "" {
q, err := fleet.GetQuery(flQueryName)
var teamID *uint
if tid := c.Uint(teamFlagName); tid != 0 {
teamID = &tid
}
q, err := fleet.GetQuerySpec(teamID, flQueryName)
if err != nil {
return fmt.Errorf("Query '%s' not found", flQueryName)
}

View file

@ -44,35 +44,70 @@ spec:
apiVersion: v1
kind: query
spec:
automations_enabled: false
description: Retrieves the list of application scheme/protocol-based IPC handlers.
interval: 0
logging: ""
min_osquery_version: ""
name: app_schemes
observer_can_run: false
platform: ""
query: select * from app_schemes;
team: ""
---
apiVersion: v1
kind: query
spec:
automations_enabled: false
description: Retrieves the current disk encryption status for the target system.
interval: 0
logging: ""
min_osquery_version: ""
name: disk_encryption
observer_can_run: false
platform: ""
query: select * from disk_encryption;
team: ""
---
apiVersion: v1
kind: query
spec:
automations_enabled: false
description: Retrieves the current filters and chains per filter in the target system.
interval: 0
logging: ""
min_osquery_version: ""
name: iptables
observer_can_run: false
platform: ""
query: select * from iptables;
team: ""
---
apiVersion: v1
kind: query
spec:
automations_enabled: false
description: Retrieves all the daemons that will run in the start of the target
OSX system.
interval: 0
logging: ""
min_osquery_version: ""
name: launchd
observer_can_run: false
platform: ""
query: select * from launchd;
team: ""
---
apiVersion: v1
kind: query
spec:
automations_enabled: false
description: Lists the application bundle that owns a sandbox label.
interval: 0
logging: ""
min_osquery_version: ""
name: sandboxes
observer_can_run: false
platform: ""
query: select * from sandboxes;
team: ""

View file

@ -0,0 +1,39 @@
// "SchedulableQuery" to be used in developing frontend for #7765
import { ISchedulableQuery } from "interfaces/schedulable_query";
const DEFAULT_SCHEDULABLE_QUERY_MOCK: ISchedulableQuery = {
created_at: "2022-11-03T17:22:14Z",
updated_at: "2022-11-03T17:22:14Z",
id: 1,
name: "Test Query",
description: "A test query",
query: "SELECT * FROM users",
team_id: null,
interval: 43200, // Every 12 hours
platform: "darwin,windows,linux",
min_osquery_version: "",
automations_enabled: true,
logging: "snapshot",
saved: true,
author_id: 1,
author_name: "Test User",
author_email: "test@example.com",
observer_can_run: false,
packs: [],
stats: {
system_time_p50: 28.1053,
system_time_p95: 397.6667,
user_time_p50: 29.9412,
user_time_p95: 251.4615,
total_executions: 5746,
},
};
const createMockSchedulableQuery = (
overrides?: Partial<ISchedulableQuery>
): ISchedulableQuery => {
return { ...DEFAULT_SCHEDULABLE_QUERY_MOCK, ...overrides };
};
export default createMockSchedulableQuery;

View file

@ -0,0 +1,17 @@
import { Meta, StoryObj } from "@storybook/react";
import LogDestinationIndicator from "./LogDestinationIndicator";
const meta: Meta<typeof LogDestinationIndicator> = {
title: "Components/LogDestinationIndicator",
component: LogDestinationIndicator,
args: {
logDestination: "filesystem",
},
};
export default meta;
type Story = StoryObj<typeof LogDestinationIndicator>;
export const Basic: Story = {};

View file

@ -0,0 +1,86 @@
import React from "react";
import classnames from "classnames";
import TooltipWrapper from "components/TooltipWrapper/TooltipWrapper";
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
interface ILogDestinationIndicatorProps {
logDestination: string;
}
const generateClassTag = (rawValue: string): string => {
if (rawValue === DEFAULT_EMPTY_CELL_VALUE) {
return "indeterminate";
}
return rawValue.replace(" ", "-").toLowerCase();
};
const LogDestinationIndicator = ({
logDestination,
}: ILogDestinationIndicatorProps): JSX.Element => {
const classTag = generateClassTag(logDestination);
const statusClassName = classnames(
"log-destination-indicator",
`log-destination-indicator--${classTag}`,
`log-destination--${classTag}`
);
const readableLogDestination = () => {
switch (logDestination) {
case "filesystem":
return "Filesystem";
case "firehose":
return "Amazon Kinesis Data Firehose";
case "kinesis":
return "Amazon Kinesis Data Streams";
case "lambda":
return "AWS Lambda";
case "pubsub":
return "Google Cloud Pub/Sub";
case "kafta":
return "Apache Kafka";
case "stdout":
return "Standard output (stdout)";
case "":
return "Not configured";
default:
return logDestination;
}
};
const tooltipText = () => {
switch (logDestination) {
case "filesystem":
return `Each time a query runs, the data is sent to <br />
/var/log/osquery/osqueryd.snapshots.log <br />
in each host&apos;s filesystem.`;
case "firehose":
return `Each time a query runs, the data is sent to <br />
Amazon Kinesis Data Firehose.`;
case "kinesis":
return `Each time a query runs, the data is sent to <br />
Amazon Kinesis Data Streams.`;
case "lambda":
return `
Each time a query runs, the data <br />is sent to AWS Lambda.
`;
case "pubsub":
return `Each time a query runs, the data is <br />sent to Google Cloud Pub/Sub.`;
case "kafta":
return `Each time a query runs, the data <br />is sent to Apache Kafka.`;
case "stdout":
return `Each time a query runs, the data is sent to <br />
standard output (stdout) on the Fleet server.`;
case "":
return "Please configure a log destination.";
default:
return "No additional information is available about this log destination.";
}
};
return (
<TooltipWrapper tipContent={tooltipText()} className={statusClassName}>
{readableLogDestination()}
</TooltipWrapper>
);
};
export default LogDestinationIndicator;

View file

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

View file

@ -11,6 +11,7 @@ export interface IModalProps {
children: JSX.Element;
onExit: () => void;
onEnter?: () => void;
/** default 650px, large 800px, xlarge 850px, auto auto-width */
width?: ModalWidth;
className?: string;
}

View file

@ -1,13 +1,13 @@
import React from "react";
import { IOsqueryPlatform } from "interfaces/platform";
import { OsqueryPlatform } from "interfaces/platform";
import { PLATFORM_DISPLAY_NAMES } from "utilities/constants";
import TooltipWrapper from "components/TooltipWrapper";
import Icon from "components/Icon";
interface IPlatformCompatibilityProps {
compatiblePlatforms: IOsqueryPlatform[] | null;
compatiblePlatforms: OsqueryPlatform[] | null;
error: Error | null;
}
@ -18,13 +18,13 @@ const DISPLAY_ORDER = [
"Windows",
"Linux",
"ChromeOS",
] as IOsqueryPlatform[];
] as OsqueryPlatform[];
const ERROR_NO_COMPATIBLE_TABLES = Error("no tables in query");
const formatPlatformsForDisplay = (
compatiblePlatforms: IOsqueryPlatform[]
): IOsqueryPlatform[] => {
compatiblePlatforms: OsqueryPlatform[]
): OsqueryPlatform[] => {
return compatiblePlatforms.map((str) => PLATFORM_DISPLAY_NAMES[str] || str);
};

View file

@ -3,6 +3,7 @@
font-size: $x-small;
align-items: center;
padding-top: $pad-medium;
padding-bottom: $pad-large;
b,
svg,

View file

@ -0,0 +1,18 @@
import { Meta, StoryObj } from "@storybook/react";
import QueryFrequencyIndicator from "./QueryFrequencyIndicator";
const meta: Meta<typeof QueryFrequencyIndicator> = {
title: "Components/QueryFrequencyIndicator",
component: QueryFrequencyIndicator,
args: {
frequency: 300,
checked: true,
},
};
export default meta;
type Story = StoryObj<typeof QueryFrequencyIndicator>;
export const Basic: Story = {};

View file

@ -0,0 +1,73 @@
import React from "react";
import classnames from "classnames";
import Icon from "components/Icon/Icon";
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
interface IStatusIndicatorProps {
frequency: number;
checked: boolean;
}
const generateClassTag = (rawValue: string): string => {
if (rawValue === DEFAULT_EMPTY_CELL_VALUE) {
return "indeterminate";
}
return rawValue.replace(" ", "-").toLowerCase();
};
const QueryFrequencyIndicator = ({
frequency,
checked,
}: IStatusIndicatorProps): JSX.Element => {
const classTag = generateClassTag(frequency.toString());
const frequencyClassName = classnames(
"query-frequency-indicator",
`query-frequency-indicator--${classTag}`,
`frequency--${classTag}`
);
const readableQueryFrequency = () => {
switch (frequency) {
case 0:
return "Never";
case 300:
case 600:
case 900:
case 1800: // 5, 10, 15, 30 minutes
return `${(frequency / 60).toString()} minutes`;
case 3600:
return "Hourly";
case 21600:
case 43200: // 6, 12 hours
return `${(frequency / 3600).toString()} hours`;
case 86400:
return "Daily";
case 604800:
return "Weekly";
default:
return "Unknown";
}
};
const frequencyIcon = () => {
if (frequency === 0) {
return checked ? (
<Icon size="small" name="warning" />
) : (
<Icon size="small" name="clock" color="ui-fleet-black-33" />
);
}
return <Icon size="small" name="clock" />;
};
return (
<div
className={`${frequencyClassName}
${frequency === 0 && !checked && "grey"}`}
>
{frequencyIcon()}
{readableQueryFrequency()}
</div>
);
};
export default QueryFrequencyIndicator;

View file

@ -0,0 +1,14 @@
.query-frequency-indicator {
width: 100px;
display: flex;
align-items: center;
padding: 8px 12px;
.icon {
padding-right: $pad-small;
}
}
.grey {
color: $ui-fleet-black-33;
}

View file

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

View file

@ -8,7 +8,6 @@ const meta: Meta<typeof StatusIndicator> = {
args: {
value: "100",
tooltip: {
id: 1,
tooltipText: "Tooltip text",
},
},

View file

@ -2,58 +2,72 @@ import React from "react";
import classnames from "classnames";
import ReactTooltip from "react-tooltip";
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
import { uniqueId } from "lodash";
import { COLORS } from "styles/var/colors";
interface IStatusIndicatorProps {
value: string;
tooltip?: {
id: number;
tooltipText: string;
tooltipText: string | JSX.Element;
position?: "top" | "bottom";
};
customIndicatorType?: string;
}
const generateClassTag = (rawValue: string): string => {
const generateIndicatorStateClassTag = (
rawValue: string,
customIndicatorType?: string
): string => {
if (rawValue === DEFAULT_EMPTY_CELL_VALUE) {
return "indeterminate";
}
return rawValue.replace(" ", "-").toLowerCase();
const prefix = customIndicatorType ? `${customIndicatorType}-` : "";
return `${prefix}${rawValue.replace(" ", "-").toLowerCase()}`;
};
const StatusIndicator = ({
value,
tooltip,
customIndicatorType,
}: IStatusIndicatorProps): JSX.Element => {
const classTag = generateClassTag(value);
const statusClassName = classnames(
const indicatorStateClassTag = generateIndicatorStateClassTag(
value,
customIndicatorType
);
const indicatorClassNames = classnames(
"status-indicator",
`status-indicator--${classTag}`,
`status--${classTag}`
`status-indicator--${indicatorStateClassTag}`,
`status--${indicatorStateClassTag}`
);
const indicatorContent = tooltip ? (
<>
<span
className="host-status tooltip tooltip__tooltip-icon"
data-tip
data-for={`status-${tooltip.id}`}
data-tip-disable={false}
>
{value}
</span>
<ReactTooltip
className="status-tooltip"
place={tooltip?.position ? tooltip.position : "top"}
type="dark"
effect="solid"
id={`status-${tooltip.id}`}
backgroundColor="#3e4771"
>
{tooltip.tooltipText}
</ReactTooltip>
</>
) : (
<>{value}</>
);
return <span className={statusClassName}>{indicatorContent}</span>;
let indicatorContent;
if (tooltip) {
const tooltipId = uniqueId();
indicatorContent = (
<>
<span
className="host-status tooltip tooltip__tooltip-icon"
data-tip
data-for={`status-${tooltipId}`}
data-tip-disable={false}
>
{value}
</span>
<ReactTooltip
className="status-tooltip"
place={tooltip?.position ? tooltip.position : "top"}
type="dark"
effect="solid"
id={`status-${tooltipId}`}
backgroundColor={COLORS["tooltip-bg"]}
>
{tooltip.tooltipText}
</ReactTooltip>
</>
);
} else {
indicatorContent = <>{value}</>;
}
return <span className={indicatorClassNames}>{indicatorContent}</span>;
};
export default StatusIndicator;

View file

@ -8,9 +8,7 @@ const PERFORMANCE_IMPACT = { indicator: "Minimal", id: 3 };
describe("Pill cell", () => {
it("renders pill text and tooltip on hover", async () => {
const { user } = renderWithSetup(
<PillCell value={PERFORMANCE_IMPACT} hostDetails />
);
const { user } = renderWithSetup(<PillCell value={PERFORMANCE_IMPACT} />);
await user.hover(screen.getByText("Minimal"));

View file

@ -75,9 +75,8 @@ const PillCell = ({
case "Undetermined":
return (
<>
To see performance <br /> impact, this query must <br /> run as a
scheduled query <br /> on {hostDetails ? "this" : "at least one"}{" "}
host.
To see performance impact, this query must have run with{" "}
<b>automations</b> on {hostDetails ? "this" : "at least one"} host.
</>
);
default:

View file

@ -6,7 +6,7 @@ const meta: Meta<typeof PlatformCell> = {
title: "Components/Table/PlatformCell",
component: PlatformCell,
args: {
value: ["darwin", "windows", "linux"],
platforms: ["darwin", "windows", "linux"],
},
};

View file

@ -1,14 +1,15 @@
import React from "react";
import { getByTestId, render, screen, within } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
import { SupportedPlatform } from "interfaces/platform";
import PlatformCell from "./PlatformCell";
const PLATFORMS = ["windows", "darwin", "linux", "chrome"];
const PLATFORMS: SupportedPlatform[] = ["windows", "darwin", "linux", "chrome"];
describe("Platform cell", () => {
it("renders platform icons in correct order", () => {
render(<PlatformCell value={PLATFORMS} />);
render(<PlatformCell platforms={PLATFORMS} />);
const icons = screen.queryAllByTestId("icon");
const appleIcon = screen.queryByTestId("apple-icon");
@ -23,7 +24,7 @@ describe("Platform cell", () => {
expect(icons[3].firstChild).toBe(chromeIcon);
});
it("renders empty state", () => {
render(<PlatformCell value={[]} />);
render(<PlatformCell platforms={[]} />);
const icons = screen.queryAllByTestId("icon");
const emptyText = screen.queryByText(DEFAULT_EMPTY_CELL_VALUE);

View file

@ -1,8 +1,10 @@
import React from "react";
import Icon from "components/Icon";
import { SupportedPlatform } from "interfaces/platform";
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
interface IPlatformCellProps {
value: string[];
platforms: SupportedPlatform[];
}
const baseClass = "platform-cell";
@ -14,7 +16,7 @@ const ICONS: Record<string, "darwin" | "windows" | "linux" | "chrome"> = {
chrome: "chrome",
};
const DISPLAY_ORDER = [
const DISPLAY_ORDER: SupportedPlatform[] = [
"darwin",
"windows",
"linux",
@ -23,9 +25,7 @@ const DISPLAY_ORDER = [
// "Invalid query",
];
const PlatformCell = ({
value: platforms,
}: IPlatformCellProps): JSX.Element => {
const PlatformCell = ({ platforms }: IPlatformCellProps): JSX.Element => {
const orderedList = DISPLAY_ORDER.filter((platform) =>
platforms.includes(platform)
);
@ -43,7 +43,9 @@ const PlatformCell = ({
) : null;
})
) : (
<span className={`${baseClass}__muted`}>---</span>
<span className={`${baseClass}__muted`}>
{DEFAULT_EMPTY_CELL_VALUE}
</span>
)}
</span>
);

View file

@ -1,4 +1,6 @@
import { uniqueId } from "lodash";
import React from "react";
import ReactTooltip from "react-tooltip";
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
interface ITextCellProps {
@ -6,6 +8,7 @@ interface ITextCellProps {
formatter?: (val: any) => JSX.Element | string; // string, number, or null
greyed?: boolean;
classes?: string;
emptyCellTooltipText?: JSX.Element | string;
}
const TextCell = ({
@ -13,6 +16,7 @@ const TextCell = ({
formatter = (val) => val, // identity function if no formatter is provided
greyed,
classes = "w250",
emptyCellTooltipText,
}: ITextCellProps): JSX.Element => {
let val = value;
@ -22,9 +26,32 @@ const TextCell = ({
if (!val) {
greyed = true;
}
const renderEmptyCell = () => {
if (emptyCellTooltipText) {
const tooltipId = uniqueId();
return (
<>
<span data-tip data-for={tooltipId}>
{DEFAULT_EMPTY_CELL_VALUE}
</span>
<ReactTooltip
place="top"
effect="solid"
backgroundColor="#3e4771"
id={tooltipId}
>
{emptyCellTooltipText}
</ReactTooltip>
</>
);
}
return DEFAULT_EMPTY_CELL_VALUE;
};
return (
<span className={`text-cell ${classes} ${greyed && "grey-cell"}`}>
{formatter(val) || DEFAULT_EMPTY_CELL_VALUE}
{formatter(val) || renderEmptyCell()}
</span>
);
};

View file

@ -216,10 +216,11 @@ $shadow-transition-width: 10px;
.link-cell,
.text-cell {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
.__react_component_tooltip {
white-space: normal;
}
}
.w400 {
max-width: calc(400px - 48px);
@ -234,6 +235,9 @@ $shadow-transition-width: 10px;
.grey-cell {
color: $ui-fleet-black-50;
font-style: italic;
.__react_component_tooltip {
font-style: normal;
}
}
}

View file

@ -47,7 +47,7 @@ const RevealButton = ({
{caretPosition === "before" && (
<Icon
name="chevron"
direction={isShowing ? "right" : "down"}
direction={isShowing ? "down" : "right"}
color="core-fleet-blue"
/>
)}

View file

@ -103,7 +103,6 @@ class InputFieldWithIcon extends InputField {
{ [`${baseClass}__icon--active`]: value }
);
console.log("iconSvg", iconSvg);
return (
<div className={wrapperClasses}>
{this.props.label && this.renderHeading()}

View file

@ -0,0 +1,39 @@
import React from "react";
import { COLORS, Colors } from "styles/var/colors";
import { ICON_SIZES, IconSizes } from "styles/var/icon_sizes";
interface IClockProps {
color?: Colors;
size?: IconSizes;
}
const Clock = ({
color = "ui-fleet-black-75",
size = "small",
}: IClockProps) => {
return (
<svg
width={ICON_SIZES[size]}
height={ICON_SIZES[size]}
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 12 13"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6 11a4.5 4.5 0 1 0 0-9 4.5 4.5 0 0 0 0 9Zm0 1.5a6 6 0 1 0 0-12 6 6 0 0 0 0 12Z"
fill={COLORS[color]}
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6 3.125a.75.75 0 0 1 .75.75V5.75h1.125a.75.75 0 0 1 0 1.5H6a.75.75 0 0 1-.75-.75V3.875a.75.75 0 0 1 .75-.75Z"
fill={COLORS[color]}
/>
</svg>
);
};
export default Clock;

View file

@ -0,0 +1,33 @@
import React from "react";
import { COLORS, Colors } from "styles/var/colors";
import { ICON_SIZES, IconSizes } from "styles/var/icon_sizes";
interface IWarningProps {
color?: Colors;
size?: IconSizes;
}
const Warning = ({
color = "status-warning",
size = "small",
}: IWarningProps) => {
return (
<svg
width={ICON_SIZES[size]}
height={ICON_SIZES[size]}
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 12 13"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="m11.887 11.214.008-.008L6.645.92l-.007.009C6.51.67 6.277.5 6 .5c-.277 0-.503.171-.638.429L5.356.92.105 11.206l.008.008a.898.898 0 0 0-.113.429c0 .471.338.857.75.857h10.5c.412 0 .75-.386.75-.857 0-.163-.045-.3-.113-.429ZM6 4.25a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0V5A.75.75 0 0 1 6 4.25ZM6 11a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"
fill={COLORS[color]}
/>
</svg>
);
};
export default Warning;

View file

@ -51,6 +51,8 @@ import Pending from "./Pending";
import PendingPartial from "./PendingPartial";
import ErrorOutline from "./ErrorOutline";
import Error from "./Error";
import Warning from "./Warning";
import Clock from "./Clock";
import Copy from "./Copy";
import Eye from "./Eye";
@ -110,6 +112,8 @@ export const ICON_MAP = {
"pending-partial": PendingPartial,
error: Error,
"error-outline": ErrorOutline,
warning: Warning,
clock: Clock,
darwin: Apple,
macOS: Apple,
windows: Windows,

View file

@ -1,11 +1,11 @@
import React from "react";
import { IOsqueryPlatform } from "interfaces/platform";
import { OsqueryPlatform } from "interfaces/platform";
import { PLATFORM_DISPLAY_NAMES } from "utilities/constants";
import Icon from "components/Icon";
interface IPLatformListItemProps {
platform: IOsqueryPlatform;
platform: OsqueryPlatform;
}
const baseClassListItem = "platform-list-item";
@ -20,7 +20,7 @@ const PlatformListItem = ({ platform }: IPLatformListItemProps) => {
};
// TODO: remove when freebsd is removed
type IPlatformsWithFreebsd = IOsqueryPlatform | "freebsd";
type IPlatformsWithFreebsd = OsqueryPlatform | "freebsd";
interface IQueryTablePlatformsProps {
platforms: IPlatformsWithFreebsd[];
@ -38,7 +38,7 @@ const QueryTablePlatforms = ({ platforms }: IQueryTablePlatformsProps) => {
return (
<PlatformListItem
key={platform}
platform={platform as IOsqueryPlatform} // TODO: remove when freebsd is removed
platform={platform as OsqueryPlatform} // TODO: remove when freebsd is removed
/>
);
});

View file

@ -43,7 +43,6 @@ describe("SiteTopNav - component", () => {
expect(screen.getByText(/controls/i)).toBeInTheDocument();
expect(screen.getByText(/software/i)).toBeInTheDocument();
expect(screen.getByText(/queries/i)).toBeInTheDocument();
expect(screen.getByText(/schedule/i)).toBeInTheDocument();
expect(screen.getByText(/policies/i)).toBeInTheDocument();
expect(screen.getByText(/settings/i)).toBeInTheDocument();
expect(screen.getByText(/manage users/i)).toBeInTheDocument();
@ -80,7 +79,6 @@ describe("SiteTopNav - component", () => {
expect(screen.getByText(/controls/i)).toBeInTheDocument();
expect(screen.getByText(/software/i)).toBeInTheDocument();
expect(screen.getByText(/queries/i)).toBeInTheDocument();
expect(screen.getByText(/schedule/i)).toBeInTheDocument();
expect(screen.getByText(/policies/i)).toBeInTheDocument();
expect(screen.getByText(/my account/i)).toBeInTheDocument();
expect(screen.getByText(/documentation/i)).toBeInTheDocument();
@ -122,7 +120,6 @@ describe("SiteTopNav - component", () => {
expect(screen.getByText(/sign out/i)).toBeInTheDocument();
expect(screen.queryByText(/controls/i)).not.toBeInTheDocument();
expect(screen.queryByText(/schedule/i)).not.toBeInTheDocument();
expect(screen.queryByText(/settings/i)).not.toBeInTheDocument();
expect(screen.queryByText(/manage users/i)).not.toBeInTheDocument();
});
@ -152,7 +149,6 @@ describe("SiteTopNav - component", () => {
expect(screen.getByText(/controls/i)).toBeInTheDocument();
expect(screen.getByText(/software/i)).toBeInTheDocument();
expect(screen.getByText(/queries/i)).toBeInTheDocument();
expect(screen.getByText(/schedule/i)).toBeInTheDocument();
expect(screen.getByText(/policies/i)).toBeInTheDocument();
expect(screen.getByText(/settings/i)).toBeInTheDocument();
expect(screen.getByText(/manage users/i)).toBeInTheDocument();
@ -189,7 +185,6 @@ describe("SiteTopNav - component", () => {
expect(screen.getByText(/controls/i)).toBeInTheDocument();
expect(screen.getByText(/software/i)).toBeInTheDocument();
expect(screen.getByText(/queries/i)).toBeInTheDocument();
expect(screen.getByText(/schedule/i)).toBeInTheDocument();
expect(screen.getByText(/policies/i)).toBeInTheDocument();
expect(screen.getByText(/my account/i)).toBeInTheDocument();
expect(screen.getByText(/documentation/i)).toBeInTheDocument();
@ -231,7 +226,6 @@ describe("SiteTopNav - component", () => {
expect(screen.getByText(/sign out/i)).toBeInTheDocument();
expect(screen.queryByText(/controls/i)).not.toBeInTheDocument();
expect(screen.queryByText(/schedule/i)).not.toBeInTheDocument();
expect(screen.queryByText(/settings/i)).not.toBeInTheDocument();
expect(screen.queryByText(/manage users/i)).not.toBeInTheDocument();
});
@ -264,7 +258,6 @@ describe("SiteTopNav - component", () => {
expect(screen.getByText(/controls/i)).toBeInTheDocument();
expect(screen.getByText(/software/i)).toBeInTheDocument();
expect(screen.getByText(/queries/i)).toBeInTheDocument();
expect(screen.getByText(/schedule/i)).toBeInTheDocument();
expect(screen.getByText(/policies/i)).toBeInTheDocument();
expect(screen.getByText(/settings/i)).toBeInTheDocument();
expect(screen.getByText(/my account/i)).toBeInTheDocument();
@ -302,7 +295,6 @@ describe("SiteTopNav - component", () => {
expect(screen.getByText(/controls/i)).toBeInTheDocument();
expect(screen.getByText(/software/i)).toBeInTheDocument();
expect(screen.getByText(/queries/i)).toBeInTheDocument();
expect(screen.getByText(/schedule/i)).toBeInTheDocument();
expect(screen.getByText(/policies/i)).toBeInTheDocument();
expect(screen.getByText(/my account/i)).toBeInTheDocument();
expect(screen.getByText(/documentation/i)).toBeInTheDocument();
@ -344,7 +336,6 @@ describe("SiteTopNav - component", () => {
expect(screen.getByText(/sign out/i)).toBeInTheDocument();
expect(screen.queryByText(/controls/i)).not.toBeInTheDocument();
expect(screen.queryByText(/schedule/i)).not.toBeInTheDocument();
expect(screen.queryByText(/settings/i)).not.toBeInTheDocument();
expect(screen.queryByText(/manage users/i)).not.toBeInTheDocument();
});

View file

@ -39,13 +39,10 @@ const REGEX_DETAIL_PAGES = {
PACK_NEW: /\/packs\/new/i,
POLICY_EDIT: /\/policies\/\d+/i,
POLICY_NEW: /\/policies\/new/i,
QUERY_EDIT: /\/queries\/\d+/i,
QUERY_NEW: /\/queries\/new/i,
SOFTWARE_DETAILS: /\/software\/\d+/i,
};
const REGEX_GLOBAL_PAGES = {
MANAGE_QUERIES: /\/queries\/manage/i,
MANAGE_PACKS: /\/packs\/manage/i,
ORGANIZATION: /\/settings\/organization/i,
USERS: /\/settings\/users/i,

View file

@ -77,14 +77,6 @@ export default (
regex: new RegExp(`^${URL_PREFIX}/queries/`),
pathname: PATHS.MANAGE_QUERIES,
},
},
{
name: "Schedule",
location: {
regex: new RegExp(`^${URL_PREFIX}/(schedule|packs)/`),
pathname: PATHS.MANAGE_SCHEDULE,
},
exclude: !isMaintainerOrAdmin,
withParams: { type: "query", names: ["team_id"] },
},
{

View file

@ -9,7 +9,7 @@ import { find } from "lodash";
import { osqueryTables } from "utilities/osquery_tables";
import { IOsQueryTable, DEFAULT_OSQUERY_TABLE } from "interfaces/osquery_table";
import { IPlatformString } from "interfaces/platform";
import { SelectedPlatformString } from "interfaces/platform";
enum ACTIONS {
SET_LAST_EDITED_QUERY_INFO = "SET_LAST_EDITED_QUERY_INFO",
@ -25,7 +25,7 @@ interface ISetLastEditedQueryInfo {
lastEditedQueryBody?: string;
lastEditedQueryResolution?: string;
lastEditedQueryCritical?: boolean;
lastEditedQueryPlatform?: IPlatformString | null;
lastEditedQueryPlatform?: SelectedPlatformString | null;
defaultPolicy?: boolean;
}
@ -55,7 +55,7 @@ type InitialStateType = {
lastEditedQueryBody: string;
lastEditedQueryResolution: string;
lastEditedQueryCritical: boolean;
lastEditedQueryPlatform: IPlatformString | null;
lastEditedQueryPlatform: SelectedPlatformString | null;
defaultPolicy: boolean;
setLastEditedQueryId: (value: number) => void;
setLastEditedQueryName: (value: string) => void;
@ -63,7 +63,7 @@ type InitialStateType = {
setLastEditedQueryBody: (value: string) => void;
setLastEditedQueryResolution: (value: string) => void;
setLastEditedQueryCritical: (value: boolean) => void;
setLastEditedQueryPlatform: (value: IPlatformString | null) => void;
setLastEditedQueryPlatform: (value: SelectedPlatformString | null) => void;
setDefaultPolicy: (value: boolean) => void;
policyTeamId: number;
setPolicyTeamId: (id: number) => void;
@ -210,7 +210,7 @@ const PolicyProvider = ({ children }: Props): JSX.Element => {
[]
);
const setLastEditedQueryPlatform = useCallback(
(lastEditedQueryPlatform: IPlatformString | null | undefined) => {
(lastEditedQueryPlatform: SelectedPlatformString | null | undefined) => {
dispatch({
type: ACTIONS.SET_LAST_EDITED_QUERY_INFO,
lastEditedQueryPlatform,

View file

@ -4,6 +4,8 @@ import { find } from "lodash";
import { osqueryTables } from "utilities/osquery_tables";
import { DEFAULT_QUERY } from "utilities/constants";
import { DEFAULT_OSQUERY_TABLE, IOsQueryTable } from "interfaces/osquery_table";
import { SelectedPlatformString } from "interfaces/platform";
import { QueryLoggingOption } from "interfaces/schedulable_query";
type Props = {
children: ReactNode;
@ -16,11 +18,19 @@ type InitialStateType = {
lastEditedQueryDescription: string;
lastEditedQueryBody: string;
lastEditedQueryObserverCanRun: boolean;
lastEditedQueryFrequency: number;
lastEditedQueryPlatforms: SelectedPlatformString;
lastEditedQueryMinOsqueryVersion: string;
lastEditedQueryLoggingType: QueryLoggingOption;
setLastEditedQueryId: (value: number) => void;
setLastEditedQueryName: (value: string) => void;
setLastEditedQueryDescription: (value: string) => void;
setLastEditedQueryBody: (value: string) => void;
setLastEditedQueryObserverCanRun: (value: boolean) => void;
setLastEditedQueryFrequency: (value: number) => void;
setLastEditedQueryPlatforms: (value: SelectedPlatformString) => void;
setLastEditedQueryMinOsqueryVersion: (value: string) => void;
setLastEditedQueryLoggingType: (value: string) => void;
setSelectedOsqueryTable: (tableName: string) => void;
};
@ -32,11 +42,19 @@ const initialState = {
lastEditedQueryDescription: DEFAULT_QUERY.description,
lastEditedQueryBody: DEFAULT_QUERY.query,
lastEditedQueryObserverCanRun: DEFAULT_QUERY.observer_can_run,
lastEditedQueryFrequency: DEFAULT_QUERY.interval,
lastEditedQueryPlatforms: DEFAULT_QUERY.platform,
lastEditedQueryMinOsqueryVersion: DEFAULT_QUERY.min_osquery_version,
lastEditedQueryLoggingType: DEFAULT_QUERY.logging,
setLastEditedQueryId: () => null,
setLastEditedQueryName: () => null,
setLastEditedQueryDescription: () => null,
setLastEditedQueryBody: () => null,
setLastEditedQueryObserverCanRun: () => null,
setLastEditedQueryFrequency: () => null,
setLastEditedQueryPlatforms: () => null,
setLastEditedQueryMinOsqueryVersion: () => null,
setLastEditedQueryLoggingType: () => null,
setSelectedOsqueryTable: () => null,
};
@ -77,6 +95,22 @@ const reducer = (state: InitialStateType, action: any) => {
typeof action.lastEditedQueryObserverCanRun === "undefined"
? state.lastEditedQueryObserverCanRun
: action.lastEditedQueryObserverCanRun,
lastEditedQueryFrequency:
typeof action.lastEditedQueryFrequency === "undefined"
? state.lastEditedQueryFrequency
: action.lastEditedQueryFrequency,
lastEditedQueryPlatforms:
typeof action.lastEditedQueryPlatforms === "undefined"
? state.lastEditedQueryPlatforms
: action.lastEditedQueryPlatforms,
lastEditedQueryMinOsqueryVersion:
typeof action.lastEditedQueryMinOsqueryVersion === "undefined"
? state.lastEditedQueryMinOsqueryVersion
: action.lastEditedQueryMinOsqueryVersion,
lastEditedQueryLoggingType:
typeof action.lastEditedQueryLoggingType === "undefined"
? state.lastEditedQueryLoggingType
: action.lastEditedQueryLoggingType,
};
default:
return state;
@ -95,6 +129,10 @@ const QueryProvider = ({ children }: Props) => {
lastEditedQueryDescription: state.lastEditedQueryDescription,
lastEditedQueryBody: state.lastEditedQueryBody,
lastEditedQueryObserverCanRun: state.lastEditedQueryObserverCanRun,
lastEditedQueryFrequency: state.lastEditedQueryFrequency,
lastEditedQueryPlatforms: state.lastEditedQueryPlatforms,
lastEditedQueryMinOsqueryVersion: state.lastEditedQueryMinOsqueryVersion,
lastEditedQueryLoggingType: state.lastEditedQueryLoggingType,
setLastEditedQueryId: (lastEditedQueryId: number) => {
dispatch({
type: actions.SET_LAST_EDITED_QUERY_INFO,
@ -127,6 +165,32 @@ const QueryProvider = ({ children }: Props) => {
lastEditedQueryObserverCanRun,
});
},
setLastEditedQueryFrequency: (lastEditedQueryFrequency: number) => {
dispatch({
type: actions.SET_LAST_EDITED_QUERY_INFO,
lastEditedQueryFrequency,
});
},
setLastEditedQueryPlatforms: (lastEditedQueryPlatforms: string) => {
dispatch({
type: actions.SET_LAST_EDITED_QUERY_INFO,
lastEditedQueryPlatforms,
});
},
setLastEditedQueryMinOsqueryVersion: (
lastEditedQueryMinOsqueryVersion: string
) => {
dispatch({
type: actions.SET_LAST_EDITED_QUERY_INFO,
lastEditedQueryMinOsqueryVersion,
});
},
setLastEditedQueryLoggingType: (lastEditedQueryLoggingType: string) => {
dispatch({
type: actions.SET_LAST_EDITED_QUERY_INFO,
lastEditedQueryLoggingType,
});
},
setSelectedOsqueryTable: (tableName: string) => {
dispatch({ type: actions.SET_SELECTED_OSQUERY_TABLE, tableName });
},

View file

@ -1,7 +1,7 @@
import React, { useCallback, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
import { IOsqueryPlatform, SUPPORTED_PLATFORMS } from "interfaces/platform";
import { OsqueryPlatform, SUPPORTED_PLATFORMS } from "interfaces/platform";
import checkPlatformCompatibility from "utilities/sql_tools";
import PlatformCompatibility from "components/PlatformCompatibility";
@ -16,7 +16,7 @@ const DEBOUNCE_DELAY = 300;
const usePlatformCompatibility = (): IPlatformCompatibility => {
const [compatiblePlatforms, setCompatiblePlatforms] = useState<
IOsqueryPlatform[] | null
OsqueryPlatform[] | null
>(null);
const [error, setError] = useState<Error | null>(null);

View file

@ -1,7 +1,10 @@
import React, { useCallback, useEffect, useState } from "react";
import { forEach } from "lodash";
import { IPlatformString, SUPPORTED_PLATFORMS } from "interfaces/platform";
import {
SelectedPlatformString,
SUPPORTED_PLATFORMS,
} from "interfaces/platform";
import PlatformSelector from "components/PlatformSelector";
@ -13,7 +16,7 @@ export interface IPlatformSelector {
}
const usePlatformSelector = (
platformContext: IPlatformString | null | undefined,
platformContext: SelectedPlatformString | null | undefined,
baseClass = ""
): IPlatformSelector => {
const [checkDarwin, setCheckDarwin] = useState(false);

View file

@ -1,5 +1,5 @@
import PropTypes from "prop-types";
import { IOsqueryPlatform } from "./platform";
import { OsqueryPlatform } from "./platform";
export default PropTypes.shape({
columns: PropTypes.arrayOf(
@ -28,7 +28,7 @@ export interface IQueryTableColumn {
hidden: boolean;
required: boolean;
index: boolean;
platforms?: IOsqueryPlatform[];
platforms?: OsqueryPlatform[];
requires_user_context?: boolean;
}
@ -36,7 +36,7 @@ export interface IOsQueryTable {
name: string;
description: string;
url: string;
platforms: IOsqueryPlatform[];
platforms: OsqueryPlatform[];
evented: boolean;
cacheable: boolean;
columns: IQueryTableColumn[];

View file

@ -1,4 +1,4 @@
export type IOsqueryPlatform =
export type OsqueryPlatform =
| "darwin"
| "macOS"
| "windows"
@ -8,14 +8,17 @@ export type IOsqueryPlatform =
| "chrome"
| "ChromeOS";
export type ISelectedPlatform =
| "all"
| "darwin"
| "windows"
| "linux"
| "chrome";
export type SupportedPlatform = "darwin" | "windows" | "linux" | "chrome";
export type IPlatformString =
export const SUPPORTED_PLATFORMS: SupportedPlatform[] = [
"darwin",
"windows",
"linux",
"chrome",
];
export type SelectedPlatform = SupportedPlatform | "all";
export type SelectedPlatformString =
| ""
| "darwin"
| "windows"
@ -33,15 +36,8 @@ export type IPlatformString =
| "windows,chrome"
| "linux,chrome";
export const SUPPORTED_PLATFORMS = [
"darwin",
"windows",
"linux",
"chrome",
] as const;
// TODO: revisit this approach pending resolution of https://github.com/fleetdm/fleet/issues/3555.
export const MACADMINS_EXTENSION_TABLES: Record<string, IOsqueryPlatform[]> = {
export const MACADMINS_EXTENSION_TABLES: Record<string, OsqueryPlatform[]> = {
file_lines: ["darwin", "linux", "windows"],
filevault_users: ["darwin"],
google_chrome_profiles: ["darwin", "linux", "windows"],

View file

@ -1,5 +1,5 @@
import PropTypes from "prop-types";
import { IPlatformString } from "interfaces/platform";
import { SelectedPlatformString } from "interfaces/platform";
// Legacy PropTypes used on host interface
export default PropTypes.shape({
@ -31,7 +31,7 @@ export interface IPolicy {
author_name: string;
author_email: string;
resolution: string;
platform: IPlatformString;
platform: SelectedPlatformString;
team_id?: number;
created_at: string;
updated_at: string;
@ -80,7 +80,7 @@ export interface IPolicyFormData {
description?: string | number | boolean | undefined;
resolution?: string | number | boolean | undefined;
critical?: boolean;
platform?: IPlatformString;
platform?: SelectedPlatformString;
name?: string | number | boolean | undefined;
query?: string | number | boolean | undefined;
team_id?: number;
@ -95,6 +95,6 @@ export interface IPolicyNew {
query: string;
resolution: string;
critical: boolean;
platform: IPlatformString;
platform: SelectedPlatformString;
mdm_required?: boolean;
}

View file

@ -1,37 +1,22 @@
import PropTypes from "prop-types";
import { IFormField } from "./form_field";
import packInterface, { IPack } from "./pack";
import scheduledQueryStatsInterface, {
IScheduledQueryStats,
} from "./scheduled_query_stats";
import { IPack } from "./pack";
import { ISchedulableQuery } from "./schedulable_query";
import { IScheduledQueryStats } from "./scheduled_query_stats";
export default PropTypes.shape({
created_at: PropTypes.string,
updated_at: PropTypes.string,
id: PropTypes.number,
name: PropTypes.string,
description: PropTypes.string,
query: PropTypes.string,
saved: PropTypes.bool,
author_id: PropTypes.number,
author_name: PropTypes.string,
observer_can_run: PropTypes.bool,
packs: PropTypes.arrayOf(packInterface),
stats: scheduledQueryStatsInterface,
});
export interface IQueryFormData {
description?: string | number | boolean | undefined;
name?: string | number | boolean | undefined;
query?: string | number | boolean | undefined;
observer_can_run?: string | number | boolean | undefined;
automations_enabled?: boolean;
}
export interface IStoredQueryResponse {
query: IQuery;
query: ISchedulableQuery;
}
export interface IFleetQueriesResponse {
queries: IQuery[];
queries: ISchedulableQuery[];
}
export interface IQuery {

View file

@ -1,6 +1,6 @@
import { IFormField } from "./form_field";
import { IPack } from "./pack";
import { IPlatformString } from "./platform";
import { SelectedPlatformString, SupportedPlatform } from "./platform";
// Query itself
export interface ISchedulableQuery {
@ -12,7 +12,7 @@ export interface ISchedulableQuery {
query: string;
team_id: number | null;
interval: number;
platform: IPlatformString; // Might more accurately be called `platforms_to_query` comma-sepparated string of platforms to query, default all platforms if ommitted
platform: SelectedPlatformString; // Might more accurately be called `platforms_to_query` comma-sepparated string of platforms to query, default all platforms if ommitted
min_osquery_version: string;
automations_enabled: boolean;
logging: QueryLoggingOption;
@ -24,6 +24,11 @@ export interface ISchedulableQuery {
packs: IPack[];
stats: ISchedulableQueryStats;
}
export interface IEnhancedQuery extends ISchedulableQuery {
performance: string;
platforms: SupportedPlatform[];
}
export interface ISchedulableQueryStats {
user_time_p50?: number;
user_time_p95?: number;
@ -46,6 +51,10 @@ export interface IListQueriesResponse {
queries: ISchedulableQuery[];
}
export interface IQueryKeyQueriesLoadAll {
scope: "queries";
teamId: number | undefined;
}
// Create a new query
/** POST /api/v1/fleet/queries */
export interface ICreateQueryRequestBody {
@ -55,7 +64,7 @@ export interface ICreateQueryRequestBody {
observer_can_run?: boolean;
team_id?: number; // global query if ommitted
interval?: number; // default 0 means never run
platform?: IPlatformString; // Might more accurately be called `platforms_to_query` comma-sepparated string of platforms to query, default all platforms if ommitted
platform?: SelectedPlatformString; // Might more accurately be called `platforms_to_query` comma-sepparated string of platforms to query, default all platforms if ommitted
min_osquery_version?: string; // default all versions if ommitted
automations_enabled?: boolean; // whether to send data to the configured log destination according to the query's `interval`. Default false if ommitted.
logging?: QueryLoggingOption;
@ -67,9 +76,14 @@ export interface ICreateQueryRequestBody {
/** PATCH /api/v1/fleet/queries/{id} */
export interface IModifyQueryRequestBody
extends Omit<ICreateQueryRequestBody, "name" | "query"> {
id: number;
id?: number;
name?: string;
query?: string;
description?: string;
observer_can_run?: boolean;
frequency?: number;
platform?: SelectedPlatformString;
min_osquery_version?: string;
}
// response is ISchedulableQuery // better way to indicate this?
@ -77,7 +91,7 @@ export interface IModifyQueryRequestBody
// Delete a query by name
/** DELETE /api/v1/fleet/queries/{name} */
export interface IDeleteQueryRequestBody {
team_id?: number; // searches for a global query if ommitted
team_id?: number; // searches for a global query if omitted
}
// Delete a query by id
@ -100,7 +114,7 @@ export interface IQueryFormFields {
query: IFormField<string>;
observer_can_run: IFormField<boolean>;
frequency: IFormField<number>;
platforms: IFormField<IPlatformString>;
platforms: IFormField<SelectedPlatformString>;
min_osquery_version: IFormField<string>;
logging: IFormField<QueryLoggingOption>;
}

View file

@ -21,7 +21,7 @@ import {
IMdmSolution,
IMdmSummaryResponse,
} from "interfaces/mdm";
import { ISelectedPlatform } from "interfaces/platform";
import { SelectedPlatform } from "interfaces/platform";
import { ISoftwareResponse, ISoftwareCountResponse } from "interfaces/software";
import { ITeam } from "interfaces/team";
import { useTeamIdParam } from "hooks/useTeamIdParam";
@ -107,7 +107,7 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => {
includeNoTeam: false,
});
const [selectedPlatform, setSelectedPlatform] = useState<ISelectedPlatform>(
const [selectedPlatform, setSelectedPlatform] = useState<SelectedPlatform>(
"all"
);
const [
@ -757,7 +757,7 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => {
className={`${baseClass}__platform_dropdown`}
options={PLATFORM_DROPDOWN_OPTIONS}
searchable={false}
onChange={(value: ISelectedPlatform) => {
onChange={(value: SelectedPlatform) => {
const selectedPlatformOption = PLATFORM_DROPDOWN_OPTIONS.find(
(platform) => platform.value === value
);

View file

@ -3,7 +3,7 @@ import PATHS from "router/paths";
import labelsAPI from "services/entities/labels";
import DataError from "components/DataError";
import { ISelectedPlatform } from "interfaces/platform";
import { SelectedPlatform } from "interfaces/platform";
import { useQuery } from "react-query";
import { ILabelSpecResponse } from "interfaces/label";
@ -20,7 +20,7 @@ interface IHostSummaryProps {
isLoadingHostsSummary: boolean;
showHostsUI: boolean;
errorHosts: boolean;
selectedPlatform?: ISelectedPlatform;
selectedPlatform?: SelectedPlatform;
}
const HostsSummary = ({

View file

@ -5,7 +5,7 @@ import {
OS_END_OF_LIFE_LINK_BY_PLATFORM,
OS_VENDOR_BY_PLATFORM,
} from "interfaces/operating_system";
import { ISelectedPlatform } from "interfaces/platform";
import { SelectedPlatform } from "interfaces/platform";
import {
getOSVersions,
IGetOSVersionsQueryKey,
@ -26,7 +26,7 @@ import generateTableHeaders from "./OperatingSystemsTableConfig";
interface IOperatingSystemsCardProps {
currentTeamId: number | undefined;
selectedPlatform: ISelectedPlatform;
selectedPlatform: SelectedPlatform;
showTitle: boolean;
/** controls the displaying of description text under the title. Defaults to `true` */
showDescription?: boolean;
@ -42,7 +42,7 @@ const DEFAULT_SORT_HEADER = "hosts_count";
const PAGE_SIZE = 8;
const baseClass = "operating-systems";
const EmptyOperatingSystems = (platform: ISelectedPlatform): JSX.Element => (
const EmptyOperatingSystems = (platform: SelectedPlatform): JSX.Element => (
<EmptyTable
className={`${baseClass}__os-empty-table`}
header={`No${

View file

@ -268,7 +268,6 @@ const allHostTableHeaders: IDataColumn[] = [
Cell: (cellProps: ICellProps) => {
const value = cellProps.cell.value;
const tooltip = {
id: cellProps.row.original.id,
tooltipText: getHostStatusTooltipText(value),
};
return <StatusIndicator value={value} tooltip={tooltip} />;

View file

@ -54,22 +54,6 @@ describe("Host Actions Dropdown", () => {
});
});
it("renders the Query action as disabled if the host is offline", async () => {
const render = createCustomRenderer();
const { user } = render(
<HostActionsDropdown
onSelect={noop}
hostStatus="offline"
hostMdmEnrollemntStatus="On (automatic)"
/>
);
await user.click(screen.getByText("Actions"));
expect(screen.getByText("Query").parentNode).toHaveClass("is-disabled");
});
it("renders the Show Disk Encryption Key action when on premium tier and we store the disk encryption key", async () => {
const render = createCustomRenderer({
context: {

View file

@ -18,16 +18,20 @@ import {
IHost,
IDeviceMappingResponse,
IMacadminsResponse,
IPackStats,
IHostResponse,
IHostMdmData,
IPackStats,
} from "interfaces/host";
import { ILabel } from "interfaces/label";
import { IHostPolicy } from "interfaces/policy";
import { IQuery, IFleetQueriesResponse } from "interfaces/query";
import { IQueryStats } from "interfaces/query_stats";
import { ISoftware } from "interfaces/software";
import { ITeam } from "interfaces/team";
import {
IListQueriesResponse,
IQueryKeyQueriesLoadAll,
ISchedulableQuery,
} from "interfaces/schedulable_query";
import Spinner from "components/Spinner";
import TabsWrapper from "components/TabsWrapper";
@ -48,7 +52,6 @@ import UsersCard from "../cards/Users";
import PoliciesCard from "../cards/Policies";
import ScheduleCard from "../cards/Schedule";
import PacksCard from "../cards/Packs";
import SelectQueryModal from "./modals/SelectQueryModal";
import PolicyDetailsModal from "../cards/Policies/HostPoliciesTable/PolicyDetailsModal";
import OSPolicyModal from "./modals/OSPolicyModal";
import UnenrollMdmModal from "./modals/UnenrollMdmModal";
@ -61,6 +64,7 @@ import DiskEncryptionKeyModal from "./modals/DiskEncryptionKeyModal";
import HostActionDropdown from "./HostActionsDropdown/HostActionsDropdown";
import MacSettingsModal from "../MacSettingsModal";
import BootstrapPackageModal from "./modals/BootstrapPackageModal";
import SelectQueryModal from "./modals/SelectQueryModal";
const baseClass = "host-details";
@ -151,24 +155,25 @@ const HostDetailsPage = ({
const [refetchStartTime, setRefetchStartTime] = useState<number | null>(null);
const [showRefetchSpinner, setShowRefetchSpinner] = useState(false);
const [packsState, setPacksState] = useState<IPackStats[]>();
const [schedule, setSchedule] = useState<IQueryStats[]>();
const [packsState, setPacksState] = useState<IPackStats[]>();
const [hostSoftware, setHostSoftware] = useState<ISoftware[]>([]);
const [usersState, setUsersState] = useState<{ username: string }[]>([]);
const [usersSearchString, setUsersSearchString] = useState("");
const [pathname, setPathname] = useState("");
const { data: fleetQueries, error: fleetQueriesError } = useQuery<
IFleetQueriesResponse,
IListQueriesResponse,
Error,
IQuery[]
>("fleet queries", () => queryAPI.loadAll(), {
ISchedulableQuery[],
IQueryKeyQueriesLoadAll[]
>([{ scope: "queries", teamId: undefined }], () => queryAPI.loadAll(), {
enabled: !!hostIdFromURL,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
retry: false,
select: (data: IFleetQueriesResponse) => data.queries,
select: (data: IListQueriesResponse) => data.queries,
});
const { data: teams } = useQuery<ILoadTeamsResponse, Error, ITeam[]>(
@ -312,8 +317,8 @@ const HostDetailsPage = ({
},
{ packs: [], schedule: [] }
);
setPacksState(packStatsByType.packs);
setSchedule(packStatsByType.schedule);
setPacksState(packStatsByType.packs);
}
},
onError: (error) => handlePageError(error),
@ -484,9 +489,9 @@ const HostDetailsPage = ({
router.push(PATHS.NEW_QUERY + TAGGED_TEMPLATES.queryByHostRoute(host?.id));
};
const onQueryHostSaved = (selectedQuery: IQuery) => {
const onQueryHostSaved = (selectedQuery: ISchedulableQuery) => {
router.push(
PATHS.EDIT_QUERY(selectedQuery) +
PATHS.EDIT_QUERY(selectedQuery.id) +
TAGGED_TEMPLATES.queryByHostRoute(host?.id)
);
};

View file

@ -1,7 +1,6 @@
import React, { useState, useCallback, useContext } from "react";
import { filter, includes } from "lodash";
import { IQuery } from "interfaces/query";
import { AppContext } from "context/app";
import Button from "components/buttons/Button";
@ -11,12 +10,13 @@ import InputFieldWithIcon from "components/forms/fields/InputFieldWithIcon";
import DataError from "components/DataError";
import permissions from "utilities/permissions";
import { ISchedulableQuery } from "interfaces/schedulable_query";
export interface ISelectQueryModalProps {
onCancel: () => void;
onQueryHostCustom: () => void;
onQueryHostSaved: (selectedQuery: IQuery) => void;
queries: IQuery[] | [];
onQueryHostSaved: (selectedQuery: ISchedulableQuery) => void;
queries: ISchedulableQuery[] | [];
queryErrors: Error | null;
isOnlyObserver?: boolean;
hostsTeamId: number | null;

View file

@ -187,7 +187,6 @@ const HostSummary = ({
<StatusIndicator
value={status || ""} // temporary work around of integration test bug
tooltip={{
id,
tooltipText: getHostStatusTooltipText(status),
position: "bottom",
}}

View file

@ -104,7 +104,6 @@ const generatePackTableHeaders = (): IDataColumn[] => {
<PillCell
value={cellProps.cell.value}
customIdPrefix="query-perf-pill"
hostDetails
/>
),
},

View file

@ -22,7 +22,7 @@ const TAGGED_TEMPLATES = {
};
const DEFAULT_SORT_DIRECTION = "asc";
const DEFAULT_SORT_HEADER = "updated_at";
const DEFAULT_SORT_HEADER = "name";
interface IPoliciesTableProps {
policiesList: IPolicyStats[];

View file

@ -15,6 +15,7 @@ import PATHS from "router/paths";
import sortUtils from "utilities/sort";
import { PolicyResponse } from "utilities/constants";
import { buildQueryStringFromParams } from "utilities/url";
import { COLORS } from "styles/var/colors";
import PassingColumnHeader from "../PassingColumnHeader";
interface IGetToggleAllRowsSelectedProps {
@ -138,7 +139,7 @@ const generateTableHeaders = (
type="dark"
effect="solid"
id={`critical-tooltip-${cellProps.row.original.id}`}
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
>
This policy has been marked as critical.
{isSandboxMode && (

View file

@ -14,7 +14,7 @@ import usePlatformCompatibility from "hooks/usePlatformCompatibility";
import usePlatformSelector from "hooks/usePlatformSelector";
import { IPolicy, IPolicyFormData } from "interfaces/policy";
import { IOsqueryPlatform, IPlatformString } from "interfaces/platform";
import { OsqueryPlatform, SelectedPlatformString } from "interfaces/platform";
import { DEFAULT_POLICIES } from "pages/policies/constants";
import Avatar from "components/Avatar";
@ -226,7 +226,7 @@ const PolicyForm = ({
});
}
let selectedPlatforms: IOsqueryPlatform[] = [];
let selectedPlatforms: OsqueryPlatform[] = [];
if (isEditMode || defaultPolicy) {
selectedPlatforms = getSelectedPlatforms();
} else {
@ -234,7 +234,9 @@ const PolicyForm = ({
setSelectedPlatforms(selectedPlatforms);
}
const newPlatformString = selectedPlatforms.join(",") as IPlatformString;
const newPlatformString = selectedPlatforms.join(
","
) as SelectedPlatformString;
if (!defaultPolicy) {
setLastEditedQueryPlatform(newPlatformString);

View file

@ -5,7 +5,7 @@ import { AppContext } from "context/app";
import { PolicyContext } from "context/policy";
import { IPlatformSelector } from "hooks/usePlatformSelector";
import { IPolicyFormData } from "interfaces/policy";
import { IPlatformString } from "interfaces/platform";
import { SelectedPlatformString } from "interfaces/platform";
import useDeepEffect from "hooks/useDeepEffect";
// @ts-ignore
@ -81,7 +81,7 @@ const SaveNewPolicyModal = ({
const newPlatformString = platformSelector
.getSelectedPlatforms()
.join(",") as IPlatformString;
.join(",") as SelectedPlatformString;
setLastEditedQueryPlatform(newPlatformString);
const { valid: validName, errors: newErrors } = validatePolicyName(name);

View file

@ -1,7 +1,7 @@
import { IPolicyNew } from "interfaces/policy";
import { IPlatformString } from "interfaces/platform";
import { SelectedPlatformString } from "interfaces/platform";
const DEFAULT_POLICY_PLATFORM: IPlatformString = "";
const DEFAULT_POLICY_PLATFORM: SelectedPlatformString = "";
export const DEFAULT_POLICY = {
id: 1,

View file

@ -2,10 +2,10 @@ import React, {
useContext,
useCallback,
useEffect,
useMemo,
useState,
useMemo,
} from "react";
import { RouteProps, InjectedRouter } from "react-router";
import { InjectedRouter } from "react-router";
import { useQuery } from "react-query";
import { pick } from "lodash";
@ -13,48 +13,52 @@ import { AppContext } from "context/app";
import { TableContext } from "context/table";
import { NotificationContext } from "context/notification";
import { performanceIndicator } from "utilities/helpers";
import { IOsqueryPlatform } from "interfaces/platform";
import { IQuery, IFleetQueriesResponse } from "interfaces/query";
import fleetQueriesAPI from "services/entities/queries";
import { SupportedPlatform } from "interfaces/platform";
import { API_ALL_TEAMS_ID } from "interfaces/team";
import {
IEnhancedQuery,
IQueryKeyQueriesLoadAll,
ISchedulableQuery,
} from "interfaces/schedulable_query";
import queriesAPI from "services/entities/queries";
import PATHS from "router/paths";
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
import checkPlatformCompatibility from "utilities/sql_tools";
import Button from "components/buttons/Button";
import Spinner from "components/Spinner";
import TableDataError from "components/DataError";
import MainContent from "components/MainContent";
import TeamsDropdown from "components/TeamsDropdown";
import useTeamIdParam from "hooks/useTeamIdParam";
import RevealButton from "components/buttons/RevealButton";
import QueriesTable from "./components/QueriesTable";
import DeleteQueryModal from "./components/DeleteQueryModal";
import ManageAutomationsModal from "./components/ManageAutomationsModal/ManageAutomationsModal";
import PreviewDataModal from "./components/PreviewDataModal/PreviewDataModal";
const baseClass = "manage-queries-page";
interface IManageQueriesPageProps {
route: RouteProps;
router: InjectedRouter; // v3
location: {
pathname?: string;
pathname: string;
query: {
platform?: string;
page?: string;
query?: string;
order_key?: string;
order_direction?: "asc" | "desc";
team_id?: string;
};
search: string;
};
}
interface IQueryTableData extends IQuery {
performance: string;
platforms: string[];
}
const getPlatforms = (queryString: string): Array<IOsqueryPlatform | "---"> => {
const getPlatforms = (queryString: string): SupportedPlatform[] => {
const { platforms } = checkPlatformCompatibility(queryString);
return platforms || [DEFAULT_EMPTY_CELL_VALUE];
return platforms ?? [];
};
const enhanceQuery = (q: IQuery) => {
const enhanceQuery = (q: ISchedulableQuery): IEnhancedQuery => {
return {
...q,
performance: performanceIndicator(
@ -71,51 +75,101 @@ const ManageQueriesPage = ({
const queryParams = location.query;
const {
isGlobalAdmin,
isTeamAdmin,
isOnlyObserver,
isObserverPlus,
isAnyTeamObserverPlus,
isOnGlobalTeam,
setFilteredQueriesPath,
filteredQueriesPath,
isPremiumTier,
isSandboxMode,
config,
} = useContext(AppContext);
const { setResetSelectedRows } = useContext(TableContext);
const { renderFlash } = useContext(NotificationContext);
const [queriesList, setQueriesList] = useState<IQueryTableData[] | null>(
null
);
const {
userTeams,
currentTeamId,
handleTeamChange,
teamIdForApi,
isRouteOk,
} = useTeamIdParam({
location,
router,
includeAllTeams: true,
includeNoTeam: false,
});
const isAnyTeamSelected = currentTeamId !== -1;
const [selectedQueryIds, setSelectedQueryIds] = useState<number[]>([]);
const [showDeleteQueryModal, setShowDeleteQueryModal] = useState(false);
const [showManageAutomationsModal, setShowManageAutomationsModal] = useState(
false
);
const [showPreviewDataModal, setShowPreviewDataModal] = useState(false);
const [isUpdatingQueries, setIsUpdatingQueries] = useState(false);
const [showInheritedQueries, setShowInheritedQueries] = useState(false);
const [isUpdatingAutomations, setIsUpdatingAutomations] = useState(false);
const {
data: fleetQueries,
error: fleetQueriesError,
isFetching: isFetchingFleetQueries,
refetch: refetchFleetQueries,
} = useQuery<IFleetQueriesResponse, Error, IQuery[]>(
"fleet queries by platform",
() => fleetQueriesAPI.loadAll(),
data: curTeamEnhancedQueries,
error: curTeamQueriesError,
isFetching: isFetchingCurTeamQueries,
refetch: refetchCurTeamQueries,
} = useQuery<
IEnhancedQuery[],
Error,
IEnhancedQuery[],
IQueryKeyQueriesLoadAll[]
>(
[{ scope: "queries", teamId: teamIdForApi }],
({ queryKey: [{ teamId }] }) =>
queriesAPI.loadAll(teamId).then(({ queries }) => {
return queries.map(enhanceQuery);
}),
{
refetchOnWindowFocus: false,
select: (data: IFleetQueriesResponse) => data.queries,
enabled: isRouteOk,
staleTime: 5000,
}
);
const enhancedQueriesList = useMemo(() => {
const enhancedQueries = fleetQueries?.map((q: IQuery) => {
const query = enhanceQuery(q);
return query;
});
return enhancedQueries || [];
}, [fleetQueries]);
useEffect(() => {
if (!isFetchingFleetQueries && enhancedQueriesList) {
setQueriesList(enhancedQueriesList);
// If a team is selected, inherit global queries
const {
data: globalEnhancedQueries,
error: globalQueriesError,
isFetching: isFetchingGlobalQueries,
refetch: refetchGlobalQueries,
} = useQuery<
IEnhancedQuery[],
Error,
IEnhancedQuery[],
IQueryKeyQueriesLoadAll[]
>(
[{ scope: "queries", teamId: API_ALL_TEAMS_ID }],
({ queryKey: [{ teamId }] }) =>
queriesAPI.loadAll(teamId).then(({ queries }) => {
return queries.map(enhanceQuery);
}),
{
refetchOnWindowFocus: false,
enabled: isRouteOk && isAnyTeamSelected,
staleTime: 5000,
}
}, [enhancedQueriesList, isFetchingFleetQueries]);
);
const automatedQueryIds = useMemo(() => {
return curTeamEnhancedQueries
? curTeamEnhancedQueries
.filter((query) => query.automations_enabled)
.map((query) => query.id)
: [];
}, [curTeamEnhancedQueries]);
useEffect(() => {
const path = location.pathname + location.search;
@ -124,7 +178,7 @@ const ManageQueriesPage = ({
}
}, [location, filteredQueriesPath, setFilteredQueriesPath]);
const onCreateQueryClick = () => router.push(PATHS.NEW_QUERY);
const onCreateQueryClick = () => router.push(PATHS.NEW_QUERY(currentTeamId));
const toggleDeleteQueryModal = useCallback(() => {
setShowDeleteQueryModal(!showDeleteQueryModal);
@ -135,80 +189,205 @@ const ManageQueriesPage = ({
setSelectedQueryIds(selectedTableQueryIds);
};
const onDeleteQuerySubmit = useCallback(async () => {
const queryOrQueries = selectedQueryIds.length === 1 ? "query" : "queries";
const refetchAllQueries = useCallback(() => {
refetchCurTeamQueries();
refetchGlobalQueries();
}, [refetchCurTeamQueries, refetchGlobalQueries]);
const toggleManageAutomationsModal = useCallback(() => {
setShowManageAutomationsModal(!showManageAutomationsModal);
}, [showManageAutomationsModal, setShowManageAutomationsModal]);
const onManageAutomationsClick = () => {
toggleManageAutomationsModal();
};
const togglePreviewDataModal = useCallback(() => {
// Manage automation modal must close/open every time preview data modal opens/closes
setShowManageAutomationsModal(!showManageAutomationsModal);
setShowPreviewDataModal(!showPreviewDataModal);
}, [
showPreviewDataModal,
setShowPreviewDataModal,
showManageAutomationsModal,
setShowManageAutomationsModal,
]);
const onDeleteQuerySubmit = useCallback(async () => {
const bulk = selectedQueryIds.length > 1;
setIsUpdatingQueries(true);
const deleteQueries = selectedQueryIds.map((id) =>
fleetQueriesAPI.destroy(id)
);
try {
await Promise.all(deleteQueries).then(() => {
renderFlash("success", `Successfully deleted ${queryOrQueries}.`);
setResetSelectedRows(true);
refetchFleetQueries();
});
renderFlash("success", `Successfully deleted ${queryOrQueries}.`);
if (bulk) {
await queriesAPI.bulkDestroy(selectedQueryIds);
} else {
await queriesAPI.destroy(selectedQueryIds[0]);
}
renderFlash(
"success",
`Successfully deleted ${bulk ? "queries" : "query"}.`
);
setResetSelectedRows(true);
refetchAllQueries();
} catch (errorResponse) {
renderFlash(
"error",
`There was an error deleting your ${queryOrQueries}. Please try again later.`
`There was an error deleting your ${
bulk ? "queries" : "query"
}. Please try again later.`
);
} finally {
toggleDeleteQueryModal();
setIsUpdatingQueries(false);
}
}, [refetchFleetQueries, selectedQueryIds, toggleDeleteQueryModal]);
}, [refetchAllQueries, selectedQueryIds, toggleDeleteQueryModal]);
const isTableDataLoading = isFetchingFleetQueries || queriesList === null;
return (
<MainContent className={baseClass}>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__header-wrap`}>
<div className={`${baseClass}__header`}>
<div className={`${baseClass}__text`}>
<h1 className={`${baseClass}__title`}>
<span>Queries</span>
</h1>
</div>
</div>
{(!isOnlyObserver || isObserverPlus || isAnyTeamObserverPlus) &&
!!fleetQueries?.length && (
<div className={`${baseClass}__action-button-container`}>
<Button
variant="brand"
className={`${baseClass}__create-button`}
onClick={onCreateQueryClick}
>
Create new query
</Button>
</div>
)}
</div>
<div className={`${baseClass}__description`}>
<p>Manage queries to ask specific questions about your devices.</p>
</div>
<div>
{isTableDataLoading && !fleetQueriesError && <Spinner />}
{!isTableDataLoading && fleetQueriesError ? (
<TableDataError />
) : (
<QueriesTable
queriesList={queriesList}
isLoading={isTableDataLoading}
onCreateQueryClick={onCreateQueryClick}
onDeleteQueryClick={onDeleteQueryClick}
isOnlyObserver={isOnlyObserver}
isObserverPlus={isObserverPlus}
isAnyTeamObserverPlus={isAnyTeamObserverPlus || false}
router={router}
queryParams={queryParams}
const renderHeader = () => {
if (isPremiumTier) {
if (userTeams) {
if (userTeams.length > 1 || isOnGlobalTeam) {
return (
<TeamsDropdown
currentUserTeams={userTeams}
selectedTeamId={currentTeamId}
onChange={handleTeamChange}
isSandboxMode={isSandboxMode}
/>
)}
</div>
);
} else if (!isOnGlobalTeam && userTeams.length === 1) {
return <h1>{userTeams[0].name}</h1>;
}
}
}
return <h1>Queries</h1>;
};
const renderCurrentScopeQueriesTable = () => {
if (isFetchingCurTeamQueries) {
return <Spinner />;
}
if (curTeamQueriesError) {
return <TableDataError />;
}
return (
<QueriesTable
queriesList={curTeamEnhancedQueries || []}
isLoading={isFetchingCurTeamQueries}
onCreateQueryClick={onCreateQueryClick}
onDeleteQueryClick={onDeleteQueryClick}
isOnlyObserver={isOnlyObserver}
isObserverPlus={isObserverPlus}
isAnyTeamObserverPlus={isAnyTeamObserverPlus || false}
router={router}
queryParams={queryParams}
/>
);
};
const renderShowInheritedQueriesTableButton = () => {
const inheritedQueryCount = globalEnhancedQueries?.length;
return (
<RevealButton
isShowing={showInheritedQueries}
className={baseClass}
hideText={`Hide ${inheritedQueryCount} inherited quer${
inheritedQueryCount === 1 ? "y" : "ies"
}`}
showText={`Show ${inheritedQueryCount} inherited quer${
inheritedQueryCount === 1 ? "y" : "ies"
}`}
caretPosition={"before"}
tooltipHtml={
'Queries from the "All teams"<br/>schedule run on this teams hosts.'
}
onClick={() => {
setShowInheritedQueries(!showInheritedQueries);
}}
/>
);
};
const renderInheritedQueriesTable = () => {
if (isFetchingGlobalQueries) {
return <Spinner />;
}
if (globalQueriesError) {
return <TableDataError />;
}
return (
<QueriesTable
queriesList={globalEnhancedQueries || []}
isLoading={isFetchingGlobalQueries}
onCreateQueryClick={onCreateQueryClick}
onDeleteQueryClick={onDeleteQueryClick}
isOnlyObserver={isOnlyObserver}
isObserverPlus={isObserverPlus}
isAnyTeamObserverPlus={isAnyTeamObserverPlus || false}
router={router}
queryParams={queryParams}
isInherited
/>
);
};
const renderInheritedQueriesSection = () => {
return (
<>
{renderShowInheritedQueriesTableButton()}
{showInheritedQueries && renderInheritedQueriesTable()}
</>
);
};
const onSaveQueryAutomations = useCallback(
async (newAutomatedQueryIds) => {
setIsUpdatingAutomations(true);
// Query ids added to turn on automations
const turnOnAutomations = newAutomatedQueryIds.filter(
(query: number) => !automatedQueryIds.includes(query)
);
// Query ids removed to turn off automations
const turnOffAutomations = automatedQueryIds.filter(
(query: number) => !newAutomatedQueryIds.includes(query)
);
// Update query automations using queries/{id} manage_automations parameter
const updateAutomatedQueries: Promise<any>[] = [];
turnOnAutomations.map((id: number) =>
updateAutomatedQueries.push(
queriesAPI.update(id, { automations_enabled: true })
)
);
turnOffAutomations.map((id: number) =>
updateAutomatedQueries.push(
queriesAPI.update(id, { automations_enabled: false })
)
);
try {
await Promise.all(updateAutomatedQueries).then(() => {
renderFlash("success", `Successfully updated query automations.`);
refetchAllQueries();
});
} catch (errorResponse) {
renderFlash(
"error",
`There was an error updating your query automations. Please try again later.`
);
} finally {
toggleManageAutomationsModal();
setIsUpdatingAutomations(false);
}
},
[refetchAllQueries, automatedQueryIds, toggleManageAutomationsModal]
);
// const isTableDataLoading = isFetchingFleetQueries || queriesList === null;
const renderModals = () => {
return (
<>
{showDeleteQueryModal && (
<DeleteQueryModal
isUpdatingQueries={isUpdatingQueries}
@ -216,6 +395,69 @@ const ManageQueriesPage = ({
onSubmit={onDeleteQuerySubmit}
/>
)}
{showManageAutomationsModal && (
<ManageAutomationsModal
isUpdatingAutomations={isUpdatingAutomations}
handleSubmit={onSaveQueryAutomations}
onCancel={toggleManageAutomationsModal}
togglePreviewDataModal={togglePreviewDataModal}
availableQueries={curTeamEnhancedQueries}
automatedQueryIds={automatedQueryIds}
logDestination={config?.logging.result.plugin || ""}
/>
)}
{showPreviewDataModal && (
<PreviewDataModal onCancel={togglePreviewDataModal} />
)}
</>
);
};
return (
<MainContent className={baseClass}>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__header-wrap`}>
<div className={`${baseClass}__header`}>
<div className={`${baseClass}__text`}>
<div className={`${baseClass}__title`}>{renderHeader()}</div>
</div>
</div>
<div className={`${baseClass}__action-button-container`}>
{(isGlobalAdmin || isTeamAdmin) && (
<Button
onClick={onManageAutomationsClick}
className={`${baseClass}__manage-automations button`}
variant="inverse"
>
Manage automations
</Button>
)}
{(!isOnlyObserver || isObserverPlus || isAnyTeamObserverPlus) &&
!!curTeamEnhancedQueries?.length && (
<>
<Button
variant="brand"
className={`${baseClass}__create-button`}
onClick={onCreateQueryClick}
>
Add query
</Button>
</>
)}
</div>
</div>
<div className={`${baseClass}__description`}>
<p>
Manage and schedule queries to ask questions and collect telemetry
for all hosts{isAnyTeamSelected && " assigned to this team"}.
</p>
</div>
{renderCurrentScopeQueriesTable()}
{isAnyTeamSelected &&
globalEnhancedQueries &&
globalEnhancedQueries?.length > 0 &&
renderInheritedQueriesSection()}
{renderModals()}
</div>
</MainContent>
);

View file

@ -56,14 +56,17 @@
&__action-button-container {
display: flex;
align-items: flex-start;
}
.form-field--dropdown {
margin: 0;
gap: $pad-small;
}
.queries-table {
.controls {
.form-field {
&--dropdown {
margin: 0;
}
}
}
&__platform-dropdown {
width: 159px;
@ -95,99 +98,100 @@
}
.data-table-block {
.data-table__table {
thead {
.name__header {
width: auto;
}
.platforms__header {
width: $col-sm;
}
.author_name__header {
display: none;
width: 0;
}
.updated_at__header {
display: none;
width: 0;
}
@media (min-width: $break-md) {
.author_name__header {
display: table-cell;
.data-table {
&__wrapper {
overflow-x: scroll;
overflow-y: hidden;
}
&__table {
thead {
.name__header {
width: auto;
}
}
@media (min-width: $break-lg) {
.author_name__header {
width: $col-md;
.platforms__header {
width: $col-sm;
}
.updated_at__header {
display: table-cell;
width: auto;
display: none;
width: 0;
}
}
}
tbody {
.name__cell {
max-width: $col-lg;
.children-wrapper {
display: flex;
gap: $pad-xsmall;
.observer-can-run-tooltip {
font-weight: $regular;
.performance__header {
display: none;
width: 0;
@media (min-width: $break-md) {
display: table-cell;
width: auto;
}
}
@media (min-width: $break-lg) {
.author_name__header {
width: $col-md;
}
.updated_at__header {
display: table-cell;
width: auto;
}
}
}
@media (max-width: $break-md) {
tbody {
.name__cell {
.w400 {
max-width: calc(400px - 81px);
max-width: $col-lg;
.children-wrapper {
display: flex;
gap: $pad-xsmall;
.observer-can-run-tooltip {
font-weight: $regular;
}
}
}
}
.platforms__cell {
max-width: $col-md;
}
.author_name__cell {
display: none;
max-width: $col-md;
img,
div,
span {
display: flex;
align-items: center;
@media (max-width: $break-md) {
.name__cell {
.w400 {
max-width: calc(400px - 81px);
}
}
}
div {
padding-right: $pad-small;
.platforms__cell {
max-width: $col-md;
}
.author-name {
display: block;
}
}
.updated_at__cell {
display: none;
max-width: $col-md;
}
@media (min-width: $break-md) {
.author_name__cell {
display: table-cell;
}
}
@media (min-width: $break-lg) {
.updated_at__cell {
display: table-cell;
display: none;
max-width: $col-md;
}
.performance__cell {
display: none;
max-width: $col-md;
}
@media (min-width: $break-md) {
.performance__cell {
display: table-cell;
}
}
@media (min-width: $break-lg) {
.updated_at__cell {
display: table-cell;
}
}
}
}
}
}
.query-name-cell {
.children-wrapper {
.query-name-text {
text-overflow: ellipsis;
overflow: hidden;
}
}
}
.query-icon {
position: relative;
top: 2px;
display: block;
}
}
}

View file

@ -0,0 +1,19 @@
import React from "react";
import Modal from "components/Modal";
const baseClass = "automations-modal";
interface IAutomationsModalProps {
onExit: () => void;
}
const AutomationsModal = ({ onExit }: IAutomationsModalProps): JSX.Element => {
return (
<Modal title={"Manage automations"} onExit={onExit} className={baseClass}>
<div className={baseClass} />
</Modal>
);
};
export default AutomationsModal;

View file

@ -0,0 +1,208 @@
import React, { useState, useEffect } from "react";
import { omit } from "lodash";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import InfoBanner from "components/InfoBanner/InfoBanner";
import CustomLink from "components/CustomLink/CustomLink";
import Checkbox from "components/forms/fields/Checkbox/Checkbox";
import QueryFrequencyIndicator from "components/QueryFrequencyIndicator/QueryFrequencyIndicator";
import LogDestinationIndicator from "components/LogDestinationIndicator/LogDestinationIndicator";
import { ISchedulableQuery } from "interfaces/schedulable_query";
interface IManageAutomationsModalProps {
isUpdatingAutomations: boolean;
handleSubmit: (formData: any) => void; // TODO
onCancel: () => void;
togglePreviewDataModal: () => void;
availableQueries?: ISchedulableQuery[];
automatedQueryIds: number[];
logDestination: string;
}
interface ICheckedQuery {
name?: string;
id: number;
isChecked: boolean;
interval: number;
}
const useCheckboxListStateManagement = (
allQueries: ISchedulableQuery[],
automatedQueryIds: number[] | undefined
) => {
const [queryItems, setQueryItems] = useState<ICheckedQuery[]>(() => {
return allQueries.map(({ name, id, interval }) => ({
name,
id,
isChecked: !!automatedQueryIds?.includes(id),
interval,
}));
});
const updateQueryItems = (queryId: number) => {
setQueryItems((prevItems) =>
prevItems.map((query) =>
query.id !== queryId ? query : { ...query, isChecked: !query.isChecked }
)
);
};
return { queryItems, updateQueryItems };
};
const baseClass = "manage-automations-modal";
const ManageAutomationsModal = ({
isUpdatingAutomations,
automatedQueryIds,
handleSubmit,
onCancel,
togglePreviewDataModal,
availableQueries,
logDestination,
}: IManageAutomationsModalProps): JSX.Element => {
// TODO: Error handling, if any
const [errors, setErrors] = useState<{ [key: string]: string }>({});
// Client side sort queries alphabetically
const sortedAvailableQueries =
availableQueries?.sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
) || [];
const { queryItems, updateQueryItems } = useCheckboxListStateManagement(
sortedAvailableQueries,
automatedQueryIds || []
);
const onSubmit = (evt: React.MouseEvent<HTMLFormElement> | KeyboardEvent) => {
evt.preventDefault();
const newQueryIds: number[] = [];
queryItems?.forEach((p) => p.isChecked && newQueryIds.push(p.id));
handleSubmit(newQueryIds);
};
useEffect(() => {
const listener = (event: KeyboardEvent) => {
if (event.code === "Enter" || event.code === "NumpadEnter") {
event.preventDefault();
onSubmit(event);
}
};
document.addEventListener("keydown", listener);
return () => {
document.removeEventListener("keydown", listener);
};
});
return (
<Modal
title={"Manage automations"}
onExit={onCancel}
className={baseClass}
width="large"
>
<div className={baseClass}>
<div className={`${baseClass}__heading`}>
Query automations let you send data to your log destination on a
schedule. Data is sent according to a querys frequency.
</div>
{availableQueries?.length ? (
<div className={`${baseClass}__select`}>
<p>
<strong>Choose which queries will send data:</strong>
</p>
<div className={`${baseClass}__checkboxes`}>
{queryItems &&
queryItems.map((queryItem) => {
const { isChecked, name, id, interval } = queryItem;
return (
<div key={id} className={`${baseClass}__query-item`}>
<Checkbox
value={isChecked}
name={name}
onChange={() => {
updateQueryItems(id);
!isChecked &&
setErrors((errs) => omit(errs, "queryItems"));
}}
>
{name}
</Checkbox>
<QueryFrequencyIndicator
frequency={interval}
checked={isChecked}
/>
</div>
);
})}
</div>
</div>
) : (
<div className={`${baseClass}__no-queries`}>
<b>You have no queries.</b>
<p>Add a query to turn on automations.</p>
</div>
)}
<div className={`${baseClass}__log-destination`}>
<p>
<strong>Log destination:</strong>
</p>
<div className={`${baseClass}__selection`}>
<LogDestinationIndicator logDestination={logDestination} />
</div>
<div className={`${baseClass}__configure`}>
Users with the admin role can&nbsp;
<CustomLink
url="https://fleetdm.com/docs/using-fleet/log-destinations"
text="configure a different log destination"
newTab
/>
</div>
</div>
<InfoBanner className={`${baseClass}__supported-platforms`}>
<p>Automations currently run on macOS, Windows, and Linux hosts.</p>
<p>
Interested in query automations for your Chromebooks? &nbsp;
<CustomLink
url="https://fleetdm.com/contact"
text="Let us know"
newTab
/>
</p>
</InfoBanner>
<div className={`${baseClass}__btn-wrap`}>
<div className={`${baseClass}__preview-btn-wrap`}>
<Button
type="button"
variant="inverse"
onClick={togglePreviewDataModal}
>
Preview data
</Button>
</div>
<div className="modal-cta-wrap">
<Button
type="submit"
variant="brand"
onClick={onSubmit}
className="save-loading"
isLoading={isUpdatingAutomations}
>
Save
</Button>
<Button onClick={onCancel} variant="inverse">
Cancel
</Button>
</div>
</div>
</div>
</Modal>
);
};
export default ManageAutomationsModal;

View file

@ -0,0 +1,65 @@
.manage-automations-modal {
display: flex;
flex-direction: column;
gap: $pad-xlarge;
&__selection {
margin-bottom: $pad-small;
}
&__checkboxes {
display: flex;
flex-direction: column;
align-items: flex-start;
align-self: stretch;
border-radius: 4px;
border: 1px solid $ui-fleet-black-10;
}
&__query-item {
width: 100%;
display: flex;
justify-content: space-between;
&:not(:last-child) {
border-bottom: 1px solid $ui-fleet-black-10;
}
}
&__configure {
color: $ui-fleet-black-75;
}
.info-banner {
&__info {
display: flex;
flex-direction: column;
gap: 8px;
p {
margin: 0;
}
}
}
.fleet-checkbox {
height: 20px;
display: flex;
align-items: center;
&__label {
width: 490px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.form-field--checkbox {
display: flex;
padding: 8px 12px;
justify-content: space-between;
align-items: center;
align-self: stretch;
margin-bottom: 0;
}
}

View file

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

View file

@ -39,11 +39,13 @@ interface IQueriesTableProps {
query?: string;
order_key?: string;
order_direction?: "asc" | "desc";
team_id?: string;
};
isInherited?: boolean;
}
const DEFAULT_SORT_DIRECTION = "desc";
const DEFAULT_SORT_HEADER = "updated_at";
const DEFAULT_SORT_DIRECTION = "asc";
const DEFAULT_SORT_HEADER = "name";
const DEFAULT_PAGE_SIZE = 20;
const DEFAULT_PLATFORM = "all";
@ -88,16 +90,16 @@ const QueriesTable = ({
isOnlyObserver,
isObserverPlus,
isAnyTeamObserverPlus,
queryParams,
router,
queryParams,
isInherited = false,
}: IQueriesTableProps): JSX.Element | null => {
const { currentUser } = useContext(AppContext);
// Functions to avoid race conditions
const initialSearchQuery = (() => queryParams?.query ?? "")();
const initialSortHeader = (() =>
(queryParams?.order_key as "updated_at" | "name" | "author") ??
"updated_at")();
(queryParams?.order_key as "name" | "updated_at" | "author") ?? "name")();
const initialSortDirection = (() =>
(queryParams?.order_direction as "asc" | "desc") ?? "asc")();
const initialPlatform = (() =>
@ -143,6 +145,7 @@ const QueriesTable = ({
) {
newQueryParams.page = 0;
}
newQueryParams.team_id = queryParams?.team_id;
const locationPath = getNextLocationPath({
pathPrefix: PATHS.MANAGE_QUERIES,
queryParams: newQueryParams,
@ -233,19 +236,21 @@ const QueriesTable = ({
};
const tableHeaders = useMemo(
() => currentUser && generateTableHeaders({ currentUser }),
[currentUser]
() => currentUser && generateTableHeaders({ currentUser, isInherited }),
[currentUser, isInherited]
);
const searchable = !(queriesList?.length === 0 && searchQuery === "");
const searchable =
!(queriesList?.length === 0 && searchQuery === "") && !isInherited;
return tableHeaders && !isLoading ? (
<div className={`${baseClass}`}>
<TableContainer
disableCount={isInherited}
resultsTitle="queries"
columns={tableHeaders}
data={queriesList}
filters={{ global: searchQuery }}
filters={{ global: isInherited ? "" : searchQuery }}
isLoading={isLoading}
defaultSortHeader={sortHeader || DEFAULT_SORT_HEADER}
defaultSortDirection={sortDirection || DEFAULT_SORT_DIRECTION}
@ -267,7 +272,9 @@ const QueriesTable = ({
isAllPagesSelected={false}
searchable={searchable}
searchQueryColumn="name"
customControl={searchable ? renderPlatformDropdown : undefined}
customControl={
searchable && !isInherited ? renderPlatformDropdown : undefined
}
isClientSidePagination
onClientSidePaginationChange={onClientSidePaginationChange}
isClientSideFilter
@ -278,7 +285,7 @@ const QueriesTable = ({
variant: "text-icon",
onActionButtonClick: onDeleteQueryClick,
}}
selectedDropdownFilter={platform}
selectedDropdownFilter={!isInherited ? platform : undefined}
/>
</div>
) : (

View file

@ -7,12 +7,15 @@ import formatDistanceToNow from "date-fns/formatDistanceToNow";
import PATHS from "router/paths";
import permissionsUtils from "utilities/permissions";
import { IQuery } from "interfaces/query";
import { IUser } from "interfaces/user";
import { addGravatarUrlToResource } from "utilities/helpers";
import { secondsToDhms } from "utilities/helpers";
import {
IEnhancedQuery,
ISchedulableQuery,
} from "interfaces/schedulable_query";
import { SupportedPlatform } from "interfaces/platform";
import Icon from "components/Icon";
import Avatar from "components/Avatar";
import Checkbox from "components/forms/fields/Checkbox";
import LinkCell from "components/TableContainer/DataTable/LinkCell/LinkCell";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell";
@ -20,10 +23,12 @@ import PlatformCell from "components/TableContainer/DataTable/PlatformCell";
import TextCell from "components/TableContainer/DataTable/TextCell";
import PillCell from "components/TableContainer/DataTable/PillCell";
import TooltipWrapper from "components/TooltipWrapper";
import { COLORS } from "styles/var/colors";
import QueryAutomationsStatusIndicator from "../QueryAutomationsStatusIndicator";
interface IQueryRow {
id: string;
original: IQuery;
original: ISchedulableQuery;
}
interface IGetToggleAllRowsSelectedProps {
@ -46,7 +51,7 @@ interface IHeaderProps {
}
interface IRowProps {
row: {
original: IQuery;
original: IEnhancedQuery;
getToggleRowSelectedProps: () => IGetToggleAllRowsSelectedProps;
toggleRowSelected: () => void;
};
@ -55,13 +60,26 @@ interface IRowProps {
interface ICellProps extends IRowProps {
cell: {
value: string;
value: string | number | boolean;
};
}
interface INumberCellProps extends IRowProps {
cell: {
value: number;
};
}
interface IStringCellProps extends IRowProps {
cell: { value: string };
}
interface IBoolCellProps extends IRowProps {
cell: { value: boolean };
}
interface IPlatformCellProps extends IRowProps {
cell: {
value: string[];
value: SupportedPlatform[];
};
}
@ -69,7 +87,10 @@ interface IDataColumn {
Header: ((props: IHeaderProps) => JSX.Element) | string;
Cell:
| ((props: ICellProps) => JSX.Element)
| ((props: IPlatformCellProps) => JSX.Element);
| ((props: IPlatformCellProps) => JSX.Element)
| ((props: IStringCellProps) => JSX.Element)
| ((props: INumberCellProps) => JSX.Element)
| ((props: IBoolCellProps) => JSX.Element);
id?: string;
title?: string;
accessor?: string;
@ -80,17 +101,16 @@ interface IDataColumn {
interface IGenerateTableHeaders {
currentUser: IUser;
isInherited?: boolean;
}
// NOTE: cellProps come from react-table
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
const generateTableHeaders = ({
currentUser,
isInherited = false,
}: IGenerateTableHeaders): IDataColumn[] => {
const isOnlyObserver = permissionsUtils.isOnlyObserver(currentUser);
const isAnyTeamMaintainerOrTeamAdmin = permissionsUtils.isAnyTeamMaintainerOrTeamAdmin(
currentUser
);
const tableHeaders: IDataColumn[] = [
{
@ -105,7 +125,7 @@ const generateTableHeaders = ({
Cell: (cellProps: ICellProps): JSX.Element => {
return (
<LinkCell
classes="w400"
classes="w400 query-name-cell"
value={
<>
<div className="query-name-text">{cellProps.cell.value}</div>
@ -124,7 +144,7 @@ const generateTableHeaders = ({
type="dark"
effect="solid"
id={`observer-can-run-tooltip-${cellProps.row.original.id}`}
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
>
Observers can run this query.
</ReactTooltip>
@ -132,7 +152,10 @@ const generateTableHeaders = ({
)}
</>
}
path={PATHS.EDIT_QUERY(cellProps.row.original)}
path={PATHS.EDIT_QUERY(
cellProps.row.original.id,
cellProps.row.original.team_id ?? undefined
)}
/>
);
},
@ -140,38 +163,37 @@ const generateTableHeaders = ({
},
{
title: "Platform",
Header: "Platform",
Header: "Compatible with",
disableSortBy: true,
accessor: "platforms",
Cell: (cellProps: IPlatformCellProps): JSX.Element => {
return <PlatformCell value={cellProps.cell.value} />;
return <PlatformCell platforms={cellProps.row.original.platforms} />;
},
},
{
title: "Author",
Header: (cellProps) => (
<HeaderCell
value={cellProps.column.title}
isSortedDesc={cellProps.column.isSortedDesc}
/>
),
accessor: "author_name",
Cell: (cellProps: ICellProps): JSX.Element => {
const { author_name, author_email } = cellProps.row.original;
const author = author_name === currentUser.name ? "You" : author_name;
title: "Frequency",
Header: "Frequency",
disableSortBy: true,
accessor: "interval",
Cell: (cellProps: INumberCellProps): JSX.Element => {
const val = cellProps.cell.value
? `Every ${secondsToDhms(cellProps.cell.value)}`
: undefined;
return (
<span>
<Avatar
user={addGravatarUrlToResource({ email: author_email })}
size="xsmall"
/>
<span className="text-cell author-name">{author}</span>
</span>
<TextCell
value={val}
emptyCellTooltipText={
<>
Assign a frequency and turn <strong>automations</strong> on to
collect data at an interval.
</>
}
/>
);
},
sortType: "caseInsensitive",
},
{
title: "Performance impact",
Header: () => {
return (
<div>
@ -189,7 +211,7 @@ const generateTableHeaders = ({
},
disableSortBy: true,
accessor: "performance",
Cell: (cellProps: ICellProps) => (
Cell: (cellProps: IStringCellProps) => (
<PillCell
value={{
indicator: cellProps.cell.value,
@ -198,6 +220,20 @@ const generateTableHeaders = ({
/>
),
},
{
title: "Automations",
Header: "Automations",
disableSortBy: true,
accessor: "automations_enabled",
Cell: (cellProps: IBoolCellProps): JSX.Element => {
return (
<QueryAutomationsStatusIndicator
automationsEnabled={cellProps.cell.value}
interval={cellProps.row.original.interval}
/>
);
},
},
{
title: "Last modified",
Header: (cellProps) => (
@ -207,7 +243,7 @@ const generateTableHeaders = ({
/>
),
accessor: "updated_at",
Cell: (cellProps: ICellProps): JSX.Element => (
Cell: (cellProps: INumberCellProps): JSX.Element => (
<TextCell
value={formatDistanceToNow(new Date(cellProps.cell.value), {
includeSeconds: true,
@ -217,62 +253,21 @@ const generateTableHeaders = ({
),
},
];
if (!isOnlyObserver) {
if (!isOnlyObserver && !isInherited) {
tableHeaders.splice(0, 0, {
id: "selection",
Header: (cellProps: IHeaderProps): JSX.Element => {
const {
getToggleAllRowsSelectedProps,
rows,
selectedFlatRows,
toggleAllRowsSelected,
toggleRowSelected,
} = cellProps;
const { checked, indeterminate } = getToggleAllRowsSelectedProps();
const disableToggleAllRowsSelected = () => {
/* Team admin or team maintainer can only delete queries they authored
If team admin or team maintainer authored 0 queries, disable select all queries for deletion */
if (isAnyTeamMaintainerOrTeamAdmin) {
return (
rows.filter(
(r: IQueryRow) => r.original.author_id === currentUser.id
).length === 0
);
}
return false;
};
const checkboxProps = {
value: checked,
indeterminate,
disabled: disableToggleAllRowsSelected(), // Disable select all if all rows are disabled
onChange: () => {
if (!isAnyTeamMaintainerOrTeamAdmin) {
toggleAllRowsSelected();
} else {
// Team maintainers may only delete the queries that they have authored
// so we need to do some filtering and then modify the toggle select all
// behavior for the header checkbox
const userAuthoredQueries = rows.filter(
(r: IQueryRow) => r.original.author_id === currentUser.id
);
if (
selectedFlatRows.length &&
selectedFlatRows.length !== userAuthoredQueries.length
) {
// If some but not all of the user authored queries are already selected,
// we toggle all of the user's unselected queries to true
userAuthoredQueries.forEach((r: IQueryRow) =>
toggleRowSelected(r.id, true)
);
} else {
// Otherwise, we toggle all of the user's queries to the opposite of their current state
userAuthoredQueries.forEach((r: IQueryRow) =>
toggleRowSelected(r.id)
);
}
}
toggleAllRowsSelected();
},
};
return <Checkbox {...checkboxProps} />;
@ -283,44 +278,9 @@ const generateTableHeaders = ({
const checkboxProps = {
value: checked,
onChange: () => row.toggleRowSelected(),
disabled:
isAnyTeamMaintainerOrTeamAdmin &&
row.original.author_id !== currentUser.id,
};
// If the user is a team maintainer, we only enable checkboxes for queries
// that they authored and we include a tooltip to explain disabled checkboxes
return (
<>
<div
data-tip
data-for={`${"select-checkbox"}__${row.original.id}`}
data-tip-disable={
!isAnyTeamMaintainerOrTeamAdmin ||
row.original.author_id === currentUser.id
}
className={`${
!(
!isAnyTeamMaintainerOrTeamAdmin ||
row.original.author_id === currentUser.id
) && "tooltip"
}`}
>
<Checkbox {...checkboxProps} />
</div>{" "}
<ReactTooltip
className="select-checkbox-tooltip"
place="bottom"
effect="solid"
backgroundColor="#3e4771"
id={`${"select-checkbox"}__${row.original.id}`}
data-html
>
<>
You can only delete a<br /> query if you are the author.
</>
</ReactTooltip>
</>
);
// v4.35.0 Any team admin or maintainer now can add, edit, delete their team's queries
return <Checkbox {...checkboxProps} />;
},
disableHidden: true,
});

View file

@ -0,0 +1,44 @@
import StatusIndicator from "components/StatusIndicator";
import React from "react";
interface IQueryAutomationsStatusIndicator {
automationsEnabled: boolean;
interval: number;
}
const QueryAutomationsStatusIndicator = ({
automationsEnabled,
interval,
}: IQueryAutomationsStatusIndicator) => {
let status;
if (automationsEnabled) {
if (interval === 0) {
status = "paused";
} else {
status = "on";
}
} else {
status = "off";
}
const tooltip =
status === "paused"
? {
tooltipText: (
<>
<strong>Automations</strong> will resume for this query when a
frequency is set.
</>
),
}
: undefined;
return (
<StatusIndicator
value={status}
tooltip={tooltip}
customIndicatorType={"query-automations"}
/>
);
};
export default QueryAutomationsStatusIndicator;

View file

@ -0,0 +1,21 @@
.status-indicator {
// Query automations status
&--query-automations-on {
&:before {
background-color: $ui-success;
}
}
&--query-automations-off {
&:before {
background-color: $ui-offline;
}
}
&--query-automations-paused {
.status-tooltip {
text-transform: none;
}
&:before {
background-color: $ui-offline;
}
}
}

View file

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

View file

@ -12,7 +12,10 @@ import statusAPI from "services/entities/status";
import { IHost, IHostResponse } from "interfaces/host";
import { ILabel } from "interfaces/label";
import { ITeam } from "interfaces/team";
import { IQueryFormData, IQuery, IStoredQueryResponse } from "interfaces/query";
import {
IGetQueryResponse,
ISchedulableQuery,
} from "interfaces/schedulable_query";
import { ITarget } from "interfaces/target";
import QuerySidePanel from "components/side_panels/QuerySidePanel";
@ -23,12 +26,15 @@ import CustomLink from "components/CustomLink";
import QueryEditor from "pages/queries/QueryPage/screens/QueryEditor";
import RunQuery from "pages/queries/QueryPage/screens/RunQuery";
import useTeamIdParam from "hooks/useTeamIdParam";
interface IQueryPageProps {
router: InjectedRouter;
params: Params;
location: {
query: { host_ids: string };
pathname: string;
query: { host_ids: string; team_id?: string };
search: string;
};
}
@ -37,9 +43,18 @@ const baseClass = "query-page";
const QueryPage = ({
router,
params: { id: paramsQueryId },
location: { query: URLQuerySearch },
location,
}: IQueryPageProps): JSX.Element => {
const queryId = paramsQueryId ? parseInt(paramsQueryId, 10) : null;
const {
currentTeamName: teamNameForQuery,
teamIdForApi: apiTeamIdForQuery,
} = useTeamIdParam({
location,
router,
includeAllTeams: true,
includeNoTeam: false,
});
const handlePageError = useErrorHandler();
const {
@ -57,6 +72,10 @@ const QueryPage = ({
setLastEditedQueryDescription,
setLastEditedQueryBody,
setLastEditedQueryObserverCanRun,
setLastEditedQueryFrequency,
setLastEditedQueryLoggingType,
setLastEditedQueryMinOsqueryVersion,
setLastEditedQueryPlatforms,
} = useContext(QueryContext);
const [queryParamHostsAdded, setQueryParamHostsAdded] = useState(false);
@ -78,19 +97,23 @@ const QueryPage = ({
isLoading: isStoredQueryLoading,
data: storedQuery,
error: storedQueryError,
} = useQuery<IStoredQueryResponse, Error, IQuery>(
} = useQuery<IGetQueryResponse, Error, ISchedulableQuery>(
["query", queryId],
() => queryAPI.load(queryId as number),
{
enabled: !!queryId,
refetchOnWindowFocus: false,
select: (data: IStoredQueryResponse) => data.query,
select: (data) => data.query,
onSuccess: (returnedQuery) => {
setLastEditedQueryId(returnedQuery.id);
setLastEditedQueryName(returnedQuery.name);
setLastEditedQueryDescription(returnedQuery.description);
setLastEditedQueryBody(returnedQuery.query);
setLastEditedQueryObserverCanRun(returnedQuery.observer_can_run);
setLastEditedQueryFrequency(returnedQuery.interval);
setLastEditedQueryPlatforms(returnedQuery.platform);
setLastEditedQueryLoggingType(returnedQuery.logging);
setLastEditedQueryMinOsqueryVersion(returnedQuery.min_osquery_version);
},
onError: (error) => handlePageError(error),
}
@ -99,9 +122,9 @@ const QueryPage = ({
useQuery<IHostResponse, Error, IHost>(
"hostFromURL",
() =>
hostAPI.loadHostDetails(parseInt(URLQuerySearch.host_ids as string, 10)),
hostAPI.loadHostDetails(parseInt(location.query.host_ids as string, 10)),
{
enabled: !!URLQuerySearch.host_ids && !queryParamHostsAdded,
enabled: !!location.query.host_ids && !queryParamHostsAdded,
select: (data: IHostResponse) => data.host,
onSuccess: (host) => {
setTargetedHosts((prevHosts) =>
@ -119,10 +142,6 @@ const QueryPage = ({
}
);
const { mutateAsync: createQuery } = useMutation((formData: IQueryFormData) =>
queryAPI.create(formData)
);
const detectIsFleetQueryRunnable = () => {
statusAPI.live_query().catch(() => {
setIsLiveQueryRunnable(false);
@ -131,12 +150,18 @@ const QueryPage = ({
useEffect(() => {
detectIsFleetQueryRunnable();
setLastEditedQueryId(DEFAULT_QUERY.id);
setLastEditedQueryName(DEFAULT_QUERY.name);
setLastEditedQueryDescription(DEFAULT_QUERY.description);
setLastEditedQueryBody(DEFAULT_QUERY.query);
setLastEditedQueryObserverCanRun(DEFAULT_QUERY.observer_can_run);
}, []);
if (!queryId) {
setLastEditedQueryId(DEFAULT_QUERY.id);
setLastEditedQueryName(DEFAULT_QUERY.name);
setLastEditedQueryDescription(DEFAULT_QUERY.description);
setLastEditedQueryBody(DEFAULT_QUERY.query);
setLastEditedQueryObserverCanRun(DEFAULT_QUERY.observer_can_run);
setLastEditedQueryFrequency(DEFAULT_QUERY.interval);
setLastEditedQueryLoggingType(DEFAULT_QUERY.logging);
setLastEditedQueryMinOsqueryVersion(DEFAULT_QUERY.min_osquery_version);
setLastEditedQueryPlatforms(DEFAULT_QUERY.platform);
}
}, [queryId]);
useEffect(() => {
setShowOpenSchemaActionText(!isSidebarOpen);
@ -179,22 +204,23 @@ const QueryPage = ({
const goToQueryEditor = useCallback(() => setStep(QUERIES_PAGE_STEPS[1]), []);
const renderScreen = () => {
const step1Opts = {
const step1Props = {
router,
baseClass,
queryIdForEdit: queryId,
teamNameForQuery,
apiTeamIdForQuery,
showOpenSchemaActionText,
storedQuery,
isStoredQueryLoading,
storedQueryError,
createQuery,
onOsqueryTableSelect,
goToSelectTargets: () => setStep(QUERIES_PAGE_STEPS[2]),
onOpenSchemaSidebar,
renderLiveQueryWarning,
};
const step2Opts = {
const step2Props = {
baseClass,
queryId,
selectedTargets,
@ -211,7 +237,7 @@ const QueryPage = ({
setTargetsTotalCount,
};
const step3Opts = {
const step3Props = {
queryId,
selectedTargets,
storedQuery,
@ -222,11 +248,11 @@ const QueryPage = ({
switch (step) {
case QUERIES_PAGE_STEPS[2]:
return <SelectTargets {...step2Opts} />;
return <SelectTargets {...step2Props} />;
case QUERIES_PAGE_STEPS[3]:
return <RunQuery {...step3Opts} />;
return <RunQuery {...step3Props} />;
default:
return <QueryEditor {...step1Opts} />;
return <QueryEditor {...step1Props} />;
}
};

View file

@ -1,135 +0,0 @@
import React, { useState, useEffect } from "react";
import { size } from "lodash";
import { IQueryFormData } from "interfaces/query";
import useDeepEffect from "hooks/useDeepEffect";
import Checkbox from "components/forms/fields/Checkbox";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
import Button from "components/buttons/Button";
import Modal from "components/Modal";
export interface INewQueryModalProps {
baseClass: string;
queryValue: string;
isLoading: boolean;
onCreateQuery: (formData: IQueryFormData) => void;
setIsSaveModalOpen: (isOpen: boolean) => void;
backendValidators: { [key: string]: string };
}
const validateQueryName = (name: string) => {
const errors: { [key: string]: string } = {};
if (!name) {
errors.name = "Query name must be present";
}
const valid = !size(errors);
return { valid, errors };
};
const NewQueryModal = ({
baseClass,
queryValue,
isLoading,
onCreateQuery,
setIsSaveModalOpen,
backendValidators,
}: INewQueryModalProps): JSX.Element => {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [observerCanRun, setObserverCanRun] = useState(false);
const [errors, setErrors] = useState<{ [key: string]: string }>(
backendValidators
);
useDeepEffect(() => {
if (name) {
setErrors({});
}
}, [name]);
useEffect(() => {
setErrors(backendValidators);
}, [backendValidators]);
const handleUpdate = (evt: React.MouseEvent<HTMLFormElement>) => {
evt.preventDefault();
const { valid, errors: newErrors } = validateQueryName(name);
setErrors({
...errors,
...newErrors,
});
if (valid) {
onCreateQuery({
description,
name,
query: queryValue,
observer_can_run: observerCanRun,
});
}
};
return (
<Modal title={"Save query"} onExit={() => setIsSaveModalOpen(false)}>
<>
<form
onSubmit={handleUpdate}
className={`${baseClass}__save-modal-form`}
autoComplete="off"
>
<InputField
name="name"
onChange={(value: string) => setName(value)}
value={name}
error={errors.name}
inputClassName={`${baseClass}__query-save-modal-name`}
label="Name"
placeholder="What is your query called?"
autofocus
/>
<InputField
name="description"
onChange={(value: string) => setDescription(value)}
value={description}
inputClassName={`${baseClass}__query-save-modal-description`}
label="Description"
type="textarea"
placeholder="What information does your query reveal? (optional)"
/>
<Checkbox
name="observerCanRun"
onChange={setObserverCanRun}
value={observerCanRun}
wrapperClassName={`${baseClass}__query-save-modal-observer-can-run-wrapper`}
>
Observers can run
</Checkbox>
<p>
Users with the Observer role will be able to run this query on hosts
where they have access.
</p>
<div className="modal-cta-wrap">
<Button
type="submit"
variant="brand"
className="save-query-loading"
isLoading={isLoading}
>
Save query
</Button>
<Button onClick={() => setIsSaveModalOpen(false)} variant="inverse">
Cancel
</Button>
</div>
</form>
</>
</Modal>
);
};
export default NewQueryModal;

View file

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

View file

@ -1,17 +1,35 @@
import React, { useState, useContext, useEffect, KeyboardEvent } from "react";
import React, {
useState,
useContext,
useEffect,
KeyboardEvent,
useCallback,
} from "react";
import { InjectedRouter } from "react-router";
import { size } from "lodash";
import { pull, size } from "lodash";
import classnames from "classnames";
import { useDebouncedCallback } from "use-debounce";
import { COLORS } from "styles/var/colors";
import PATHS from "router/paths";
import { AppContext } from "context/app";
import { QueryContext } from "context/query";
import { NotificationContext } from "context/notification";
import { addGravatarUrlToResource } from "utilities/helpers";
import {
FREQUENCY_DROPDOWN_OPTIONS,
SCHEDULE_PLATFORM_DROPDOWN_OPTIONS,
MIN_OSQUERY_VERSION_OPTIONS,
LOGGING_TYPE_OPTIONS,
} from "utilities/constants";
import usePlatformCompatibility from "hooks/usePlatformCompatibility";
import { IApiError } from "interfaces/errors";
import { IQuery, IQueryFormData } from "interfaces/query";
import {
ISchedulableQuery,
ICreateQueryRequestBody,
QueryLoggingOption,
} from "interfaces/schedulable_query";
import { SelectedPlatformString } from "interfaces/platform";
import queryAPI from "services/entities/queries";
import { IAceEditor } from "react-ace/lib/types";
@ -23,10 +41,12 @@ import validateQuery from "components/forms/validators/validate_query";
import Button from "components/buttons/Button";
import RevealButton from "components/buttons/RevealButton";
import Checkbox from "components/forms/fields/Checkbox";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
import Spinner from "components/Spinner";
import Icon from "components/Icon/Icon";
import AutoSizeInputField from "components/forms/fields/AutoSizeInputField";
import NewQueryModal from "../NewQueryModal";
import SaveQueryModal from "../SaveQueryModal";
import InfoIcon from "../../../../../../assets/images/icon-info-purple-14x14@2x.png";
const baseClass = "query-form";
@ -34,15 +54,17 @@ const baseClass = "query-form";
interface IQueryFormProps {
router: InjectedRouter;
queryIdForEdit: number | null;
apiTeamIdForQuery?: number;
teamNameForQuery?: string;
showOpenSchemaActionText: boolean;
storedQuery: IQuery | undefined;
storedQuery: ISchedulableQuery | undefined;
isStoredQueryLoading: boolean;
isQuerySaving: boolean;
isQueryUpdating: boolean;
onCreateQuery: (formData: IQueryFormData) => void;
saveQuery: (formData: ICreateQueryRequestBody) => void;
onOsqueryTableSelect: (tableName: string) => void;
goToSelectTargets: () => void;
onUpdate: (formData: IQueryFormData) => void;
onUpdate: (formData: ICreateQueryRequestBody) => void;
onOpenSchemaSidebar: () => void;
renderLiveQueryWarning: () => JSX.Element | null;
backendValidators: { [key: string]: string };
@ -63,12 +85,14 @@ const validateQuerySQL = (query: string) => {
const QueryForm = ({
router,
queryIdForEdit,
apiTeamIdForQuery,
teamNameForQuery,
showOpenSchemaActionText,
storedQuery,
isStoredQueryLoading,
isQuerySaving,
isQueryUpdating,
onCreateQuery,
saveQuery,
onOsqueryTableSelect,
goToSelectTargets,
onUpdate,
@ -84,10 +108,18 @@ const QueryForm = ({
lastEditedQueryDescription,
lastEditedQueryBody,
lastEditedQueryObserverCanRun,
lastEditedQueryFrequency,
lastEditedQueryPlatforms,
lastEditedQueryMinOsqueryVersion,
lastEditedQueryLoggingType,
setLastEditedQueryName,
setLastEditedQueryDescription,
setLastEditedQueryBody,
setLastEditedQueryObserverCanRun,
setLastEditedQueryFrequency,
setLastEditedQueryPlatforms,
setLastEditedQueryMinOsqueryVersion,
setLastEditedQueryLoggingType,
} = useContext(QueryContext);
const {
@ -104,13 +136,14 @@ const QueryForm = ({
const savedQueryMode = !!queryIdForEdit;
const [errors, setErrors] = useState<{ [key: string]: any }>({}); // string | null | undefined or boolean | undefined
const [isSaveModalOpen, setIsSaveModalOpen] = useState(false);
const [showSaveQueryModal, setShowSaveQueryModal] = useState(false);
const [showQueryEditor, setShowQueryEditor] = useState(
isObserverPlus || isAnyTeamObserverPlus || false
);
const [isEditingName, setIsEditingName] = useState(false);
const [isEditingDescription, setIsEditingDescription] = useState(false);
const [isSaveAsNewLoading, setIsSaveAsNewLoading] = useState(false);
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const platformCompatibility = usePlatformCompatibility();
const { setCompatiblePlatforms } = platformCompatibility;
@ -133,7 +166,7 @@ const QueryForm = ({
}
debounceSQL(lastEditedQueryBody);
}, [lastEditedQueryBody, lastEditedQueryId]);
}, [lastEditedQueryBody, lastEditedQueryId, isStoredQueryLoading]);
const hasTeamMaintainerPermissions = savedQueryMode
? isAnyTeamMaintainerOrTeamAdmin &&
@ -142,6 +175,10 @@ const QueryForm = ({
storedQuery.author_id === currentUser.id
: isAnyTeamMaintainerOrTeamAdmin;
const toggleSaveQueryModal = () => {
setShowSaveQueryModal(!showSaveQueryModal);
};
const onLoad = (editor: IAceEditor) => {
editor.setOptions({
enableLinking: true,
@ -173,6 +210,50 @@ const QueryForm = ({
}
};
const onChangeSelectFrequency = useCallback(
(value: number) => {
setLastEditedQueryFrequency(value);
},
[setLastEditedQueryFrequency]
);
const toggleAdvancedOptions = () => {
setShowAdvancedOptions(!showAdvancedOptions);
};
const onChangeSelectPlatformOptions = useCallback(
(values: string) => {
const valArray = values.split(",");
// Remove All if another OS is chosen
// else if Remove OS if All is chosen
if (valArray.indexOf("") === 0 && valArray.length > 1) {
setLastEditedQueryPlatforms(
pull(valArray, "").join(",") as SelectedPlatformString
);
} else if (valArray.length > 1 && valArray.indexOf("") > -1) {
setLastEditedQueryPlatforms("");
} else {
setLastEditedQueryPlatforms(values as SelectedPlatformString);
}
},
[setLastEditedQueryPlatforms]
);
const onChangeMinOsqueryVersionOptions = useCallback(
(value: string) => {
setLastEditedQueryMinOsqueryVersion(value);
},
[setLastEditedQueryMinOsqueryVersion]
);
const onChangeSelectLoggingType = useCallback(
(value: QueryLoggingOption) => {
setLastEditedQueryLoggingType(value);
},
[setLastEditedQueryLoggingType]
);
const promptSaveAsNewQuery = () => (
evt: React.MouseEvent<HTMLButtonElement>
) => {
@ -192,17 +273,21 @@ const QueryForm = ({
if (valid) {
setIsSaveAsNewLoading(true);
queryAPI
.create({
name: lastEditedQueryName,
description: lastEditedQueryDescription,
query: lastEditedQueryBody,
team_id: apiTeamIdForQuery,
observer_can_run: lastEditedQueryObserverCanRun,
interval: lastEditedQueryFrequency,
platform: lastEditedQueryPlatforms,
min_osquery_version: lastEditedQueryMinOsqueryVersion,
logging: lastEditedQueryLoggingType,
})
.then((response: { query: IQuery }) => {
.then((response: { query: ISchedulableQuery }) => {
setIsSaveAsNewLoading(false);
router.push(PATHS.EDIT_QUERY(response.query));
router.push(PATHS.EDIT_QUERY(response.query.id));
renderFlash("success", `Successfully added query.`);
})
.catch((createError: { data: IApiError }) => {
@ -212,11 +297,16 @@ const QueryForm = ({
name: `Copy of ${lastEditedQueryName}`,
description: lastEditedQueryDescription,
query: lastEditedQueryBody,
team_id: apiTeamIdForQuery,
observer_can_run: lastEditedQueryObserverCanRun,
interval: lastEditedQueryFrequency,
platform: lastEditedQueryPlatforms,
min_osquery_version: lastEditedQueryMinOsqueryVersion,
logging: lastEditedQueryLoggingType,
})
.then((response: { query: IQuery }) => {
.then((response: { query: ISchedulableQuery }) => {
setIsSaveAsNewLoading(false);
router.push(PATHS.EDIT_QUERY(response.query));
router.push(PATHS.EDIT_QUERY(response.query.id));
renderFlash(
"success",
`Successfully added query as "Copy of ${lastEditedQueryName}".`
@ -228,9 +318,19 @@ const QueryForm = ({
"already exists"
)
) {
let teamErrorText;
if (apiTeamIdForQuery !== 0) {
if (teamNameForQuery) {
teamErrorText = `the ${teamNameForQuery} team`;
} else {
teamErrorText = "this team";
}
} else {
teamErrorText = "all teams";
}
renderFlash(
"error",
`"Copy of ${lastEditedQueryName}" already exists. Please rename your query and try again.`
`A query called "Copy of ${lastEditedQueryName}" already exists for ${teamErrorText}.`
);
}
setIsSaveAsNewLoading(false);
@ -260,13 +360,17 @@ const QueryForm = ({
if (valid) {
if (!savedQueryMode) {
setIsSaveModalOpen(true);
setShowSaveQueryModal(true);
} else {
onUpdate({
name: lastEditedQueryName,
description: lastEditedQueryDescription,
query: lastEditedQueryBody,
observer_can_run: lastEditedQueryObserverCanRun,
interval: lastEditedQueryFrequency,
platform: lastEditedQueryPlatforms,
min_osquery_version: lastEditedQueryMinOsqueryVersion,
logging: lastEditedQueryLoggingType,
});
}
}
@ -445,7 +549,7 @@ const QueryForm = ({
variant="blue-green"
onClick={goToSelectTargets}
>
Run query
Live query
</Button>
</div>
)}
@ -482,21 +586,72 @@ const QueryForm = ({
{renderPlatformCompatibility()}
</span>
{savedQueryMode && (
<>
<Checkbox
value={lastEditedQueryObserverCanRun}
onChange={(value: boolean) =>
setLastEditedQueryObserverCanRun(value)
}
wrapperClassName={`${baseClass}__query-observer-can-run-wrapper`}
>
Observers can run
</Checkbox>
<p>
Users with the observer role will be able to run this query on
hosts where they have access.
</p>
</>
<div className={`${baseClass}__edit-options`}>
<div className={`${baseClass}__frequency`}>
<Dropdown
searchable={false}
options={FREQUENCY_DROPDOWN_OPTIONS}
onChange={onChangeSelectFrequency}
placeholder={"Every day"}
value={lastEditedQueryFrequency}
label={"Frequency"}
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--frequency`}
/>
If automations are on, this is how often your query collects data.
</div>
<div className={`${baseClass}__observers-can-run`}>
<Checkbox
value={lastEditedQueryObserverCanRun}
onChange={(value: boolean) =>
setLastEditedQueryObserverCanRun(value)
}
wrapperClassName={`${baseClass}__query-observer-can-run-wrapper`}
>
Observers can run
</Checkbox>
<p>
Users with the observer role will be able to run this query on
hosts where they have access.
</p>
</div>
<RevealButton
isShowing={showAdvancedOptions}
className={baseClass}
hideText={"Hide advanced options"}
showText={"Show advanced options"}
caretPosition={"after"}
onClick={toggleAdvancedOptions}
/>
{showAdvancedOptions && (
<div className={`${baseClass}__advanced-options`}>
<Dropdown
options={SCHEDULE_PLATFORM_DROPDOWN_OPTIONS}
placeholder="Select"
label="Platform"
onChange={onChangeSelectPlatformOptions}
value={lastEditedQueryPlatforms}
multi
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--platform`}
/>
<Dropdown
options={MIN_OSQUERY_VERSION_OPTIONS}
onChange={onChangeMinOsqueryVersionOptions}
placeholder="Select"
value={lastEditedQueryMinOsqueryVersion}
label="Minimum osquery version"
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--osquer-vers`}
/>
<Dropdown
options={LOGGING_TYPE_OPTIONS}
onChange={onChangeSelectLoggingType}
placeholder="Select"
value={lastEditedQueryLoggingType}
label="Logging"
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--logging`}
/>
</div>
)}
</div>
)}
{renderLiveQueryWarning()}
<div
@ -519,10 +674,12 @@ const QueryForm = ({
<div
data-tip
data-for="save-query-button"
// Tooltip shows for team maintainer/admins viewing global queries
data-tip-disable={
!(
isAnyTeamMaintainerOrTeamAdmin &&
!hasTeamMaintainerPermissions
!storedQuery?.team_id &&
!!queryIdForEdit
)
}
>
@ -530,9 +687,11 @@ const QueryForm = ({
className="save-loading"
variant="brand"
onClick={promptSaveQuery()}
// Button disabled for team maintainer/admins viewing global queries
disabled={
isAnyTeamMaintainerOrTeamAdmin &&
!hasTeamMaintainerPermissions
!storedQuery?.team_id &&
!!queryIdForEdit
}
isLoading={isQueryUpdating}
>
@ -541,16 +700,15 @@ const QueryForm = ({
</div>{" "}
<ReactTooltip
className={`save-query-button-tooltip`}
place="bottom"
place="top"
effect="solid"
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
id="save-query-button"
data-html
>
<>
You can only save
<br /> changes to a query if you
<br /> are the author.
You can only save changes
<br /> to a team level query.
</>
</ReactTooltip>
</div>
@ -561,16 +719,16 @@ const QueryForm = ({
variant="blue-green"
onClick={goToSelectTargets}
>
Run query
Live query
</Button>
</div>
</form>
{isSaveModalOpen && (
<NewQueryModal
baseClass={baseClass}
{showSaveQueryModal && (
<SaveQueryModal
queryValue={lastEditedQueryBody}
onCreateQuery={onCreateQuery}
setIsSaveModalOpen={setIsSaveModalOpen}
apiTeamIdForQuery={apiTeamIdForQuery}
saveQuery={saveQuery}
toggleSaveQueryModal={toggleSaveQueryModal}
backendValidators={backendValidators}
isLoading={isQuerySaving}
/>

View file

@ -154,6 +154,22 @@
font-size: $x-small;
}
&__edit-options {
> div:not(:last-child) {
margin-bottom: $pad-large;
}
}
&__frequency {
.form-field {
margin-bottom: $pad-small;
}
}
&__advanced-options {
margin-top: $pad-medium;
}
&__query-observer-can-run-wrapper {
margin: 0;
margin-top: $pad-large;

View file

@ -0,0 +1,257 @@
import React, { useState, useEffect, useCallback } from "react";
import { pull, size } from "lodash";
import useDeepEffect from "hooks/useDeepEffect";
import Checkbox from "components/forms/fields/Checkbox";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
import Button from "components/buttons/Button";
import Modal from "components/Modal";
import {
FREQUENCY_DROPDOWN_OPTIONS,
LOGGING_TYPE_OPTIONS,
MIN_OSQUERY_VERSION_OPTIONS,
SCHEDULE_PLATFORM_DROPDOWN_OPTIONS,
} from "utilities/constants";
import RevealButton from "components/buttons/RevealButton";
import { SelectedPlatformString } from "interfaces/platform";
import {
ICreateQueryRequestBody,
ISchedulableQuery,
QueryLoggingOption,
} from "interfaces/schedulable_query";
const baseClass = "save-query-modal";
export interface ISaveQueryModalProps {
queryValue: string;
apiTeamIdForQuery?: number; // query will be global if omitted
isLoading: boolean;
saveQuery: (formData: ICreateQueryRequestBody) => void;
toggleSaveQueryModal: () => void;
backendValidators: { [key: string]: string };
existingQuery?: ISchedulableQuery;
}
const validateQueryName = (name: string) => {
const errors: { [key: string]: string } = {};
if (!name) {
errors.name = "Query name must be present";
}
const valid = !size(errors);
return { valid, errors };
};
const SaveQueryModal = ({
queryValue,
apiTeamIdForQuery,
isLoading,
saveQuery,
toggleSaveQueryModal,
backendValidators,
existingQuery,
}: ISaveQueryModalProps): JSX.Element => {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [selectedFrequency, setSelectedFrequency] = useState(
existingQuery?.interval ?? 3600
);
const [
selectedPlatformOptions,
setSelectedPlatformOptions,
] = useState<SelectedPlatformString>(existingQuery?.platform ?? "");
const [
selectedMinOsqueryVersionOptions,
setSelectedMinOsqueryVersionOptions,
] = useState(existingQuery?.min_osquery_version ?? "");
const [
selectedLoggingType,
setSelectedLoggingType,
] = useState<QueryLoggingOption>(existingQuery?.logging ?? "snapshot");
const [observerCanRun, setObserverCanRun] = useState(false);
const [errors, setErrors] = useState<{ [key: string]: string }>(
backendValidators
);
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const toggleAdvancedOptions = () => {
setShowAdvancedOptions(!showAdvancedOptions);
};
useDeepEffect(() => {
if (name) {
setErrors({});
}
}, [name]);
useEffect(() => {
setErrors(backendValidators);
}, [backendValidators]);
const onClickSaveQuery = (evt: React.MouseEvent<HTMLFormElement>) => {
evt.preventDefault();
const { valid, errors: newErrors } = validateQueryName(name);
setErrors({
...errors,
...newErrors,
});
if (valid) {
saveQuery({
// from modal fields
name,
description,
interval: selectedFrequency,
observer_can_run: observerCanRun,
platform: selectedPlatformOptions,
min_osquery_version: selectedMinOsqueryVersionOptions,
logging: selectedLoggingType,
// from previous New query page
query: queryValue,
// from doubly previous ManageQueriesPage
team_id: apiTeamIdForQuery,
});
}
};
const onChangeSelectPlatformOptions = useCallback(
(values: string) => {
const valArray = values.split(",");
// Remove All if another OS is chosen
// else if Remove OS if All is chosen
if (valArray.indexOf("") === 0 && valArray.length > 1) {
// TODO - inmprove type safety of all 3 options
setSelectedPlatformOptions(
pull(valArray, "").join(",") as SelectedPlatformString
);
} else if (valArray.length > 1 && valArray.indexOf("") > -1) {
setSelectedPlatformOptions("");
} else {
setSelectedPlatformOptions(values as SelectedPlatformString);
}
},
[setSelectedPlatformOptions]
);
return (
<Modal title={"Save query"} onExit={toggleSaveQueryModal}>
<>
<form
onSubmit={onClickSaveQuery}
className={baseClass}
autoComplete="off"
>
<InputField
name="name"
onChange={(value: string) => setName(value)}
value={name}
error={errors.name}
inputClassName={`${baseClass}__name`}
label="Name"
placeholder="What is your query called?"
autofocus
/>
<InputField
name="description"
onChange={(value: string) => setDescription(value)}
value={description}
inputClassName={`${baseClass}__description`}
label="Description"
type="textarea"
placeholder="What information does your query reveal? (optional)"
/>
<Dropdown
searchable={false}
options={FREQUENCY_DROPDOWN_OPTIONS}
onChange={(value: number) => {
setSelectedFrequency(value);
}}
placeholder={"Every hour"}
value={selectedFrequency}
label="Frequency"
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--frequency`}
/>
<p className="help-text">
If automations are on, this is how often your query collects data.
</p>
<Checkbox
name="observerCanRun"
onChange={setObserverCanRun}
value={observerCanRun}
wrapperClassName={`${baseClass}__observer-can-run-wrapper`}
>
Observers can run
</Checkbox>
<p className="help-text">
Users with the Observer role will be able to run this query as a
live query.
</p>
<RevealButton
isShowing={showAdvancedOptions}
className={`${baseClass}__advanced-options-toggle`}
hideText={"Hide advanced options"}
showText={"Show advanced options"}
caretPosition={"after"}
onClick={toggleAdvancedOptions}
/>
{showAdvancedOptions && (
<>
<Dropdown
options={SCHEDULE_PLATFORM_DROPDOWN_OPTIONS}
placeholder="Select"
label="Platforms"
onChange={onChangeSelectPlatformOptions}
value={selectedPlatformOptions}
multi
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--platform`}
/>
<p className="help-text">
If automations are turned on, your query collects data on
compatible platforms.
<br />
If you want more control, override platforms.
</p>
<Dropdown
options={MIN_OSQUERY_VERSION_OPTIONS}
onChange={setSelectedMinOsqueryVersionOptions}
placeholder="Select"
value={selectedMinOsqueryVersionOptions}
label="Minimum osquery version"
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--osquer-vers`}
/>
<Dropdown
options={LOGGING_TYPE_OPTIONS}
onChange={setSelectedLoggingType}
placeholder="Select"
value={selectedLoggingType}
label="Logging"
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--logging`}
/>
</>
)}
<div className="modal-cta-wrap">
<Button
type="submit"
variant="brand"
className="save-query-loading"
isLoading={isLoading}
>
Save
</Button>
<Button onClick={toggleSaveQueryModal} variant="inverse">
Cancel
</Button>
</div>
</form>
</>
</Modal>
);
};
export default SaveQueryModal;

View file

@ -0,0 +1,32 @@
.save-query-modal {
.fleet-checkbox {
display: flex;
align-items: center;
}
.help-text {
margin-top: $pad-small;
margin-bottom: $pad-large;
font-weight: $regular;
font-size: 0.75rem;
color: $ui-fleet-black-75;
}
&__form-field {
&--frequency {
margin-bottom: 0;
}
&--platform {
margin-bottom: 0;
margin-top: $pad-large;
}
}
&__observer-can-run-wrapper {
margin-bottom: 0;
}
&__advanced-options-toggle {
font-weight: $xbold;
}
}

View file

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

View file

@ -7,7 +7,10 @@ import queryAPI from "services/entities/queries";
import { AppContext } from "context/app";
import { QueryContext } from "context/query";
import { NotificationContext } from "context/notification";
import { IQueryFormData, IQuery } from "interfaces/query";
import {
ICreateQueryRequestBody,
ISchedulableQuery,
} from "interfaces/schedulable_query";
import PATHS from "router/paths";
import debounce from "utilities/debounce";
import deepDifference from "utilities/deep_difference";
@ -19,16 +22,12 @@ interface IQueryEditorProps {
router: InjectedRouter;
baseClass: string;
queryIdForEdit: number | null;
storedQuery: IQuery | undefined;
teamNameForQuery?: string;
apiTeamIdForQuery?: number;
storedQuery: ISchedulableQuery | undefined;
storedQueryError: Error | null;
showOpenSchemaActionText: boolean;
isStoredQueryLoading: boolean;
createQuery: UseMutateAsyncFunction<
{ query: IQuery },
unknown,
IQueryFormData,
unknown
>;
onOsqueryTableSelect: (tableName: string) => void;
goToSelectTargets: () => void;
onOpenSchemaSidebar: () => void;
@ -39,11 +38,12 @@ const QueryEditor = ({
router,
baseClass,
queryIdForEdit,
teamNameForQuery,
apiTeamIdForQuery,
storedQuery,
storedQueryError,
showOpenSchemaActionText,
isStoredQueryLoading,
createQuery,
onOsqueryTableSelect,
goToSelectTargets,
onOpenSchemaSidebar,
@ -59,6 +59,10 @@ const QueryEditor = ({
lastEditedQueryDescription,
lastEditedQueryBody,
lastEditedQueryObserverCanRun,
lastEditedQueryFrequency,
lastEditedQueryLoggingType,
lastEditedQueryPlatforms,
lastEditedQueryMinOsqueryVersion,
} = useContext(QueryContext);
const [isQuerySaving, setIsQuerySaving] = useState(false);
@ -77,29 +81,35 @@ const QueryEditor = ({
[key: string]: string;
}>({});
const onSaveQueryFormSubmit = debounce(async (formData: IQueryFormData) => {
const saveQuery = debounce(async (formData: ICreateQueryRequestBody) => {
setIsQuerySaving(true);
try {
const { query }: { query: IQuery } = await createQuery(formData);
router.push(PATHS.EDIT_QUERY(query));
const { query } = await queryAPI.create(formData);
router.push(PATHS.EDIT_QUERY(query.id));
renderFlash("success", "Query created!");
setBackendValidators({});
} catch (createError: any) {
console.error(createError);
if (createError.data.errors[0].reason.includes("already exists")) {
setBackendValidators({ name: "A query with this name already exists" });
const teamErrorText =
teamNameForQuery && apiTeamIdForQuery !== 0
? `the ${teamNameForQuery} team`
: "all teams";
setBackendValidators({
name: `A query with that name already exists for ${teamErrorText}.`,
});
} else {
renderFlash(
"error",
"Something went wrong creating your query. Please try again."
);
setBackendValidators({});
}
} finally {
setIsQuerySaving(false);
}
});
const onUpdateQuery = async (formData: IQueryFormData) => {
const onUpdateQuery = async (formData: ICreateQueryRequestBody) => {
if (!queryIdForEdit) {
return false;
}
@ -111,6 +121,10 @@ const QueryEditor = ({
lastEditedQueryDescription,
lastEditedQueryBody,
lastEditedQueryObserverCanRun,
lastEditedQueryFrequency,
lastEditedQueryPlatforms,
lastEditedQueryLoggingType,
lastEditedQueryMinOsqueryVersion,
});
try {
@ -149,12 +163,14 @@ const QueryEditor = ({
</div>
<QueryForm
router={router}
onCreateQuery={onSaveQueryFormSubmit}
saveQuery={saveQuery}
goToSelectTargets={goToSelectTargets}
onOsqueryTableSelect={onOsqueryTableSelect}
onUpdate={onUpdateQuery}
storedQuery={storedQuery}
queryIdForEdit={queryIdForEdit}
apiTeamIdForQuery={apiTeamIdForQuery}
teamNameForQuery={teamNameForQuery}
isStoredQueryLoading={isStoredQueryLoading}
showOpenSchemaActionText={showOpenSchemaActionText}
onOpenSchemaSidebar={onOpenSchemaSidebar}

View file

@ -1,546 +0,0 @@
/* Conditionally renders global schedule and team schedules */
import React, { useCallback, useContext, useState } from "react";
import { useQuery } from "react-query";
import { InjectedRouter } from "react-router/lib/Router";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import useTeamIdParam from "hooks/useTeamIdParam";
import { ITeam } from "interfaces/team";
import { IQuery, IFleetQueriesResponse } from "interfaces/query";
import {
IScheduledQuery,
IEditScheduledQuery,
ILoadAllGlobalScheduledQueriesResponse,
IStoredScheduledQueriesResponse,
} from "interfaces/scheduled_query";
import paths from "router/paths";
import fleetQueriesAPI from "services/entities/queries";
import globalScheduledQueriesAPI from "services/entities/global_scheduled_queries";
import teamScheduledQueriesAPI from "services/entities/team_scheduled_queries";
import teamsAPI, { ILoadTeamsResponse } from "services/entities/teams";
import deepDifference from "utilities/deep_difference";
import Button from "components/buttons/Button";
import RevealButton from "components/buttons/RevealButton";
import Spinner from "components/Spinner";
import TeamsDropdown from "components/TeamsDropdown";
import TableDataError from "components/DataError";
import MainContent from "components/MainContent";
import ShowQueryModal from "components/modals/ShowQueryModal";
import ScheduleTable from "./components/ScheduleTable";
import ScheduleEditorModal from "./components/ScheduleEditorModal";
import RemoveScheduledQueryModal from "./components/RemoveScheduledQueryModal";
const baseClass = "manage-schedule-page";
const renderTable = (
router: InjectedRouter,
onRemoveScheduledQueryClick: (selectIds: number[]) => void,
onEditScheduledQueryClick: (selectedQuery: IEditScheduledQuery) => void,
onShowQueryClick: (selectedQuery: IEditScheduledQuery) => void,
allScheduledQueriesList: IScheduledQuery[],
allScheduledQueriesError: Error | null,
toggleScheduleEditorModal: () => void,
isOnGlobalTeam: boolean,
selectedTeamData: ITeam | undefined,
isLoadingGlobalScheduledQueries: boolean,
isLoadingTeamScheduledQueries: boolean,
errorQueries: Error | null
): JSX.Element => {
return allScheduledQueriesError || errorQueries ? (
<TableDataError />
) : (
<ScheduleTable
router={router}
onRemoveScheduledQueryClick={onRemoveScheduledQueryClick}
onEditScheduledQueryClick={onEditScheduledQueryClick}
onShowQueryClick={onShowQueryClick}
allScheduledQueriesList={allScheduledQueriesList}
toggleScheduleEditorModal={toggleScheduleEditorModal}
isOnGlobalTeam={isOnGlobalTeam}
selectedTeamData={selectedTeamData}
loadingInheritedQueriesTableData={isLoadingGlobalScheduledQueries}
loadingTeamQueriesTableData={isLoadingTeamScheduledQueries}
/>
);
};
const renderAllTeamsTable = (
router: InjectedRouter,
allTeamsScheduledQueriesList: IScheduledQuery[],
allTeamsScheduledQueriesError: Error | null,
isOnGlobalTeam: boolean,
selectedTeamData: ITeam | undefined,
isLoadingGlobalScheduledQueries: boolean,
isLoadingTeamScheduledQueries: boolean
): JSX.Element => {
return allTeamsScheduledQueriesError ? (
<TableDataError />
) : (
<div className={`${baseClass}__all-teams-table`}>
<ScheduleTable
router={router}
inheritedQueries
allScheduledQueriesList={allTeamsScheduledQueriesList}
isOnGlobalTeam={isOnGlobalTeam}
selectedTeamData={selectedTeamData}
loadingInheritedQueriesTableData={isLoadingGlobalScheduledQueries}
loadingTeamQueriesTableData={isLoadingTeamScheduledQueries}
/>
</div>
);
};
interface IFormData {
interval: number;
name?: string;
shard: number;
query?: string;
query_id?: number;
logging_type: string;
platform: string;
version: string;
team_id?: number;
}
interface ITeamSchedulesPageProps {
params: {
team_id: string;
};
router: InjectedRouter; // v3
route: any;
location: any;
}
const ManageSchedulePage = ({
router,
location,
}: ITeamSchedulesPageProps): JSX.Element => {
const { renderFlash } = useContext(NotificationContext);
const { MANAGE_PACKS } = paths;
const handleAdvanced = () => router.push(MANAGE_PACKS);
const {
isOnGlobalTeam,
isPremiumTier,
isFreeTier,
isSandboxMode,
} = useContext(AppContext);
const {
currentTeamId,
isAnyTeamSelected,
isRouteOk,
teamIdForApi,
userTeams,
handleTeamChange,
} = useTeamIdParam({
location,
router,
includeAllTeams: true,
includeNoTeam: false,
permittedAccessByTeamRole: {
admin: true,
maintainer: true,
observer: false,
observer_plus: false,
},
});
const { data: teams, isLoading: isLoadingTeams } = useQuery<
ILoadTeamsResponse,
Error,
ITeam[]
>(["teams"], () => teamsAPI.loadAll(), {
enabled: isRouteOk && !!isPremiumTier,
refetchOnMount: false,
refetchOnWindowFocus: false,
select: (data) => data.teams,
});
const {
data: fleetQueries,
isLoading: isLoadingFleetQueries,
error: errorQueries,
} = useQuery<IFleetQueriesResponse, Error, IQuery[]>(
["fleetQueries"],
() => fleetQueriesAPI.loadAll(),
{
enabled: isRouteOk,
refetchOnMount: false,
refetchOnWindowFocus: false,
select: (data) => data.queries,
}
);
const {
data: globalScheduledQueries,
error: globalScheduledQueriesError,
isLoading: isLoadingGlobalScheduledQueries,
refetch: refetchGlobalScheduledQueries,
} = useQuery<
ILoadAllGlobalScheduledQueriesResponse,
Error,
IScheduledQuery[]
>(["globalScheduledQueries"], () => globalScheduledQueriesAPI.loadAll(), {
enabled: isRouteOk,
select: (data) => data.global_schedule,
});
const {
data: teamScheduledQueries,
error: teamScheduledQueriesError,
isLoading: isLoadingTeamScheduledQueries,
refetch: refetchTeamScheduledQueries,
} = useQuery<IStoredScheduledQueriesResponse, Error, IScheduledQuery[]>(
["teamScheduledQueries", teamIdForApi],
() => teamScheduledQueriesAPI.loadAll(teamIdForApi),
{
enabled: isRouteOk && isPremiumTier && !!teamIdForApi,
select: (data) => data.scheduled,
}
);
const refetchScheduledQueries = useCallback(() => {
refetchGlobalScheduledQueries();
if (isAnyTeamSelected) {
refetchTeamScheduledQueries();
}
}, [
isAnyTeamSelected,
refetchGlobalScheduledQueries,
refetchTeamScheduledQueries,
]);
const allScheduledQueriesList =
(isAnyTeamSelected ? teamScheduledQueries : globalScheduledQueries) || [];
const allScheduledQueriesError = isAnyTeamSelected
? teamScheduledQueriesError
: globalScheduledQueriesError;
const inheritedScheduledQueriesList = globalScheduledQueries;
const inheritedScheduledQueriesError = globalScheduledQueriesError;
const inheritedQueryOrQueries =
inheritedScheduledQueriesList?.length === 1 ? "query" : "queries";
const selectedTeamData = isAnyTeamSelected
? teams?.find((team: ITeam) => teamIdForApi === team.id)
: undefined;
const [isUpdatingScheduledQuery, setIsUpdatingScheduledQuery] = useState(
false
);
const [showInheritedQueries, setShowInheritedQueries] = useState(false);
const [showScheduleEditorModal, setShowScheduleEditorModal] = useState(false);
const [showShowQueryModal, setShowShowQueryModal] = useState(false);
const [showPreviewDataModal, setShowPreviewDataModal] = useState(false);
const [
showRemoveScheduledQueryModal,
setShowRemoveScheduledQueryModal,
] = useState(false);
const [selectedQueryIds, setSelectedQueryIds] = useState<number[] | never[]>(
[]
);
const [
selectedScheduledQuery,
setSelectedScheduledQuery,
] = useState<IEditScheduledQuery>();
const toggleInheritedQueries = () => {
setShowInheritedQueries(!showInheritedQueries);
};
const togglePreviewDataModal = useCallback(() => {
setShowPreviewDataModal(!showPreviewDataModal);
}, [setShowPreviewDataModal, showPreviewDataModal]);
const toggleScheduleEditorModal = useCallback(() => {
setSelectedScheduledQuery(undefined); // create modal renders
setShowScheduleEditorModal(!showScheduleEditorModal);
}, [showScheduleEditorModal, setShowScheduleEditorModal]);
const toggleShowQueryModal = useCallback(() => {
setSelectedScheduledQuery(undefined);
setShowShowQueryModal(!showShowQueryModal);
}, [showShowQueryModal, setShowShowQueryModal]);
const toggleRemoveScheduledQueryModal = useCallback(() => {
setShowRemoveScheduledQueryModal(!showRemoveScheduledQueryModal);
}, [showRemoveScheduledQueryModal, setShowRemoveScheduledQueryModal]);
const onRemoveScheduledQueryClick = (
selectedTableQueryIds: number[]
): void => {
toggleRemoveScheduledQueryModal();
setSelectedQueryIds(selectedTableQueryIds);
};
const onShowQueryClick = (selectedQuery: IEditScheduledQuery): void => {
toggleShowQueryModal();
setSelectedScheduledQuery(selectedQuery);
};
const onEditScheduledQueryClick = (
selectedQuery: IEditScheduledQuery
): void => {
toggleScheduleEditorModal();
setSelectedScheduledQuery(selectedQuery); // edit modal renders
};
const onRemoveScheduledQuerySubmit = useCallback(() => {
setIsUpdatingScheduledQuery(true);
const promises = selectedQueryIds.map((id: number) => {
return isAnyTeamSelected
? teamScheduledQueriesAPI.destroy(teamIdForApi, id)
: globalScheduledQueriesAPI.destroy({ id });
});
const queryOrQueries = selectedQueryIds.length === 1 ? "query" : "queries";
return Promise.all(promises)
.then(() => {
renderFlash(
"success",
`Successfully removed scheduled ${queryOrQueries}.`
);
toggleRemoveScheduledQueryModal();
refetchScheduledQueries();
})
.catch(() => {
renderFlash(
"error",
`Unable to remove scheduled ${queryOrQueries}. Please try again.`
);
toggleRemoveScheduledQueryModal();
})
.finally(() => {
refetchGlobalScheduledQueries();
setIsUpdatingScheduledQuery(false);
});
}, [
selectedQueryIds,
isAnyTeamSelected,
teamIdForApi,
renderFlash,
toggleRemoveScheduledQueryModal,
refetchScheduledQueries,
refetchGlobalScheduledQueries,
]);
const onAddScheduledQuerySubmit = useCallback(
(formData: IFormData, editQuery: IEditScheduledQuery | undefined) => {
setIsUpdatingScheduledQuery(true);
if (editQuery) {
const updatedAttributes = deepDifference(formData, editQuery);
const editResponse =
editQuery.type === "team_scheduled_query"
? teamScheduledQueriesAPI.update(editQuery, updatedAttributes)
: globalScheduledQueriesAPI.update(editQuery, updatedAttributes);
editResponse
.then(() => {
renderFlash(
"success",
`Successfully updated ${formData.name} in the schedule.`
);
refetchScheduledQueries();
toggleScheduleEditorModal();
})
.catch(() => {
renderFlash(
"error",
"Could not update scheduled query. Please try again."
);
})
.finally(() => {
setIsUpdatingScheduledQuery(false);
refetchGlobalScheduledQueries();
});
} else {
const createResponse = isAnyTeamSelected
? teamScheduledQueriesAPI.create({ ...formData })
: globalScheduledQueriesAPI.create({ ...formData });
createResponse
.then(() => {
renderFlash(
"success",
`Successfully added ${formData.name} to the schedule.`
);
refetchScheduledQueries();
toggleScheduleEditorModal();
})
.catch(() => {
renderFlash("error", "Could not schedule query. Please try again.");
})
.finally(() => {
setIsUpdatingScheduledQuery(false);
refetchGlobalScheduledQueries();
});
}
},
[
isAnyTeamSelected,
refetchGlobalScheduledQueries,
refetchScheduledQueries,
renderFlash,
toggleScheduleEditorModal,
]
);
if (!isRouteOk || (isPremiumTier && !userTeams?.length)) {
return (
<div className={`${baseClass}__loading-spinner`}>
<Spinner />
</div>
);
}
return (
<MainContent className={baseClass}>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__header-wrap`}>
<div className={`${baseClass}__header`}>
<div className={`${baseClass}__text`}>
<div className={`${baseClass}__title`}>
{isFreeTier && <h1>Schedule</h1>}
{isPremiumTier &&
userTeams &&
(userTeams.length > 1 || isOnGlobalTeam) && (
<TeamsDropdown
selectedTeamId={currentTeamId}
currentUserTeams={userTeams || []}
onChange={handleTeamChange}
isSandboxMode={isSandboxMode}
/>
)}
{isPremiumTier &&
!isOnGlobalTeam &&
userTeams &&
userTeams.length === 1 && <h1>{userTeams[0].name}</h1>}
</div>
</div>
</div>
{allScheduledQueriesList?.length !== 0 && !allScheduledQueriesError && (
<div className={`${baseClass}__action-button-container`}>
{/* NOTE: Product decision to remove packs from UI
{isOnGlobalTeam && (
<Button
variant="inverse"
onClick={handleAdvanced}
className={`${baseClass}__advanced-button`}
>
Advanced
</Button>
)} */}
<Button
variant="brand"
className={`${baseClass}__schedule-button`}
onClick={toggleScheduleEditorModal}
>
Schedule a query
</Button>
</div>
)}
</div>
<div className={`${baseClass}__description`}>
{!isLoadingTeams && (
<div>
{isAnyTeamSelected ? (
<p>
Schedule queries for{" "}
<strong>all hosts assigned to this team</strong>
</p>
) : (
<p>
Schedule queries to run at regular intervals across{" "}
<strong>all of your hosts</strong>
</p>
)}
</div>
)}
</div>
<div>
{isLoadingTeams ||
isLoadingFleetQueries ||
isLoadingGlobalScheduledQueries ||
isLoadingTeamScheduledQueries ? (
<Spinner />
) : (
renderTable(
router,
onRemoveScheduledQueryClick,
onEditScheduledQueryClick,
onShowQueryClick,
allScheduledQueriesList,
allScheduledQueriesError,
toggleScheduleEditorModal,
isOnGlobalTeam || false,
selectedTeamData,
isLoadingGlobalScheduledQueries,
isLoadingTeamScheduledQueries,
errorQueries
)
)}
</div>
{/* must use ternary for NaN */}
{isAnyTeamSelected &&
inheritedScheduledQueriesList &&
inheritedScheduledQueriesList.length > 0 ? (
<RevealButton
isShowing={showInheritedQueries}
className={baseClass}
hideText={`Hide ${inheritedScheduledQueriesList.length} inherited ${inheritedQueryOrQueries}`}
showText={`Show ${inheritedScheduledQueriesList.length} inherited ${inheritedQueryOrQueries}`}
caretPosition={"before"}
tooltipHtml={
'Queries from the "All teams"<br/>schedule run on this teams hosts.'
}
onClick={toggleInheritedQueries}
/>
) : null}
{showInheritedQueries &&
inheritedScheduledQueriesList &&
renderAllTeamsTable(
router,
inheritedScheduledQueriesList,
inheritedScheduledQueriesError,
isOnGlobalTeam || false,
selectedTeamData,
isLoadingGlobalScheduledQueries,
isLoadingTeamScheduledQueries
)}
{showScheduleEditorModal && fleetQueries && (
<ScheduleEditorModal
onClose={toggleScheduleEditorModal}
onScheduleSubmit={onAddScheduledQuerySubmit}
allQueries={fleetQueries}
editQuery={selectedScheduledQuery}
teamId={teamIdForApi}
togglePreviewDataModal={togglePreviewDataModal}
showPreviewDataModal={showPreviewDataModal}
isUpdatingScheduledQuery={isUpdatingScheduledQuery}
/>
)}
{showRemoveScheduledQueryModal && (
<RemoveScheduledQueryModal
onCancel={toggleRemoveScheduledQueryModal}
onSubmit={onRemoveScheduledQuerySubmit}
isUpdatingScheduledQuery={isUpdatingScheduledQuery}
/>
)}
{showShowQueryModal && (
<ShowQueryModal
query={selectedScheduledQuery?.query}
onCancel={toggleShowQueryModal}
/>
)}
</div>
</MainContent>
);
};
export default ManageSchedulePage;

View file

@ -1,122 +0,0 @@
.manage-schedule-page {
&__header-wrap {
display: flex;
align-items: center;
justify-content: space-between;
height: 38px;
}
&__header {
display: flex;
align-items: center;
.form-field {
margin-bottom: 0;
}
}
&__text {
margin-right: $pad-large;
}
&__title {
font-size: $large;
.fleeticon {
color: $core-fleet-blue;
margin-right: 15px;
}
.fleeticon-success-check {
color: $ui-success;
}
.fleeticon-offline {
color: $ui-error;
}
}
&__description {
margin: 0;
margin-bottom: $pad-xxlarge;
h2 {
text-transform: uppercase;
color: $core-fleet-black;
font-weight: $regular;
font-size: $small;
}
p {
color: $ui-fleet-black-75;
margin: 0;
font-size: $x-small;
font-style: italic;
}
}
&__action-button-container {
display: flex;
align-items: flex-start;
}
&__advanced-button {
margin-right: $pad-medium;
}
.Select.is-open {
.Select-value-label {
color: $core-vibrant-blue !important;
}
}
.schedule-table {
.data-table-block {
.data-table__table {
thead {
.query_name__header {
width: $col-lg;
}
.interval__header {
width: auto;
}
.actions__header {
width: auto;
}
@media (min-width: $break-lg) {
.interval__header {
width: 0;
}
}
}
tbody {
.query_name__cell {
width: $col-lg;
max-width: 175px; // Truncates at smaller widths
}
.interval__cell {
width: auto;
}
.actions__cell {
width: auto;
}
@media (min-width: $break-lg) {
.interval_cell {
width: 0;
}
}
}
}
}
.empty-table__container {
max-width: 465px; // Fixes wider font causing orphaned word on all teams empty state
}
}
.no-team-schedule {
border: 1px solid #e2e4ea;
box-sizing: border-box;
border-radius: 8px;
}
}

View file

@ -1,14 +0,0 @@
.preview-data-modal {
&__sandbox-info {
margin-top: $pad-medium;
p {
margin: 0;
margin-bottom: $pad-medium;
}
p:last-child {
margin-bottom: 0;
}
}
}

View file

@ -1,47 +0,0 @@
import React from "react";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
const baseClass = "remove-scheduled-query-modal";
interface IRemoveScheduledQueryModalProps {
isUpdatingScheduledQuery: boolean;
onCancel: () => void;
onSubmit: () => void;
}
const RemoveScheduledQueryModal = ({
isUpdatingScheduledQuery,
onCancel,
onSubmit,
}: IRemoveScheduledQueryModalProps): JSX.Element => {
return (
<Modal
title={"Remove scheduled query"}
onExit={onCancel}
onEnter={onSubmit}
className={baseClass}
>
<div className={baseClass}>
Are you sure you want to remove the selected queries from the schedule?
<div className="modal-cta-wrap">
<Button
type="button"
variant="alert"
onClick={onSubmit}
className="remove-loading"
isLoading={isUpdatingScheduledQuery}
>
Remove
</Button>
<Button onClick={onCancel} variant="inverse-alert">
Cancel
</Button>
</div>
</div>
</Modal>
);
};
export default RemoveScheduledQueryModal;

View file

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

View file

@ -1,364 +0,0 @@
/* This component is used for creating and editing both global and team scheduled queries */
import React, { useState, useCallback, useContext } from "react";
import { pull } from "lodash";
import { AppContext } from "context/app";
import { IQuery } from "interfaces/query";
import { IEditScheduledQuery } from "interfaces/scheduled_query";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import RevealButton from "components/buttons/RevealButton";
import InfoBanner from "components/InfoBanner/InfoBanner";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
import CustomLink from "components/CustomLink";
import {
FREQUENCY_DROPDOWN_OPTIONS,
SCHEDULE_PLATFORM_DROPDOWN_OPTIONS,
LOGGING_TYPE_OPTIONS,
MIN_OSQUERY_VERSION_OPTIONS,
} from "utilities/constants";
import PreviewDataModal from "../PreviewDataModal";
const baseClass = "schedule-editor-modal";
interface IFormData {
interval: number;
name?: string;
shard: number;
query?: string;
query_id?: number;
logging_type: string;
platform: string;
version: string;
team_id?: number;
}
interface IScheduleEditorModalProps {
allQueries: IQuery[];
onClose: () => void;
onScheduleSubmit: (
formData: IFormData,
editQuery: IEditScheduledQuery | undefined
) => void;
editQuery?: IEditScheduledQuery;
teamId?: number;
togglePreviewDataModal: () => void;
showPreviewDataModal: boolean;
isUpdatingScheduledQuery: boolean;
}
interface INoQueryOption {
id: number;
name: string;
}
const generateLoggingType = (query: IEditScheduledQuery) => {
if (query.snapshot) {
return "snapshot";
}
if (query.removed) {
return "differential";
}
return "differential_ignore_removals";
};
const generateLoggingDestination = (loggingConfig: string): string => {
switch (loggingConfig) {
case "filesystem":
return "the filesystem";
case "firehose":
return "AWS Kinesis Firehose";
case "kinesis":
return "AWS Kinesis";
case "lambda":
return "AWS Lambda";
case "pubsub":
return "GCP PubSub";
case "stdout":
return "the standard output stream";
default:
return loggingConfig;
}
};
const ScheduleEditorModal = ({
onClose,
onScheduleSubmit,
allQueries,
editQuery,
teamId,
togglePreviewDataModal,
showPreviewDataModal,
isUpdatingScheduledQuery,
}: IScheduleEditorModalProps): JSX.Element => {
const { config } = useContext(AppContext);
const loggingConfig = config?.logging.result.plugin || "unknown";
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const [selectedQuery, setSelectedQuery] = useState<
IEditScheduledQuery | INoQueryOption
>();
const [selectedFrequency, setSelectedFrequency] = useState(
editQuery ? editQuery.interval : 86400
);
const [selectedPlatformOptions, setSelectedPlatformOptions] = useState(
editQuery?.platform || ""
);
const [selectedLoggingType, setSelectedLoggingType] = useState(
editQuery ? generateLoggingType(editQuery) : "snapshot"
);
const [
selectedMinOsqueryVersionOptions,
setSelectedMinOsqueryVersionOptions,
] = useState(editQuery?.version || "");
const [selectedShard, setSelectedShard] = useState(
editQuery?.shard ? editQuery?.shard.toString() : ""
);
const createQueryDropdownOptions = () => {
const queryOptions = allQueries.map((q) => {
return {
value: String(q.id),
label: q.name,
};
});
return queryOptions;
};
const toggleAdvancedOptions = () => {
setShowAdvancedOptions(!showAdvancedOptions);
};
const onChangeSelectQuery = useCallback(
(queryId: string) => {
const queryWithId: IQuery | undefined = allQueries.find(
(query: IQuery) => query.id === parseInt(queryId, 10)
);
setSelectedQuery(queryWithId);
},
[allQueries, setSelectedQuery]
);
const onChangeSelectFrequency = useCallback(
(value: number) => {
setSelectedFrequency(value);
},
[setSelectedFrequency]
);
const onChangeSelectPlatformOptions = useCallback(
(values: string) => {
const valArray = values.split(",");
// Remove All if another OS is chosen
// else if Remove OS if All is chosen
if (valArray.indexOf("") === 0 && valArray.length > 1) {
setSelectedPlatformOptions(pull(valArray, "").join(","));
} else if (valArray.length > 1 && valArray.indexOf("") > -1) {
setSelectedPlatformOptions("");
} else {
setSelectedPlatformOptions(values);
}
},
[setSelectedPlatformOptions]
);
const onChangeSelectLoggingType = useCallback(
(value: string) => {
setSelectedLoggingType(value);
},
[setSelectedLoggingType]
);
const onChangeMinOsqueryVersionOptions = useCallback(
(value: string) => {
setSelectedMinOsqueryVersionOptions(value);
},
[setSelectedMinOsqueryVersionOptions]
);
const onChangeShard = useCallback(
(value: string) => {
setSelectedShard(value);
},
[setSelectedShard]
);
const onFormSubmit = (): void => {
const query_id = () => {
if (editQuery) {
return editQuery.query_id;
}
return selectedQuery?.id;
};
const name = () => {
if (editQuery) {
return editQuery.name;
}
return selectedQuery?.name;
};
onScheduleSubmit(
{
shard: parseInt(selectedShard, 10),
interval: selectedFrequency,
query_id: query_id(),
name: name(),
logging_type: selectedLoggingType,
platform: selectedPlatformOptions,
version: selectedMinOsqueryVersionOptions,
team_id: teamId,
},
editQuery
);
};
if (showPreviewDataModal) {
return <PreviewDataModal onCancel={togglePreviewDataModal} />;
}
return (
<Modal
title={editQuery?.query_name || "Schedule editor"}
onExit={onClose}
onEnter={onFormSubmit}
className={baseClass}
width="large"
>
<form className={`${baseClass}__form`}>
<p className={`${baseClass}__platform-compatibility`}>
Scheduled queries can currently be run on macOS, Windows, and Linux
hosts. Interested in collecting data from your Chromebooks?{" "}
<CustomLink
url="https://www.fleetdm.com/contact"
text="Let us know"
newTab
/>
</p>
{!editQuery && (
<Dropdown
searchable
options={createQueryDropdownOptions()}
onChange={onChangeSelectQuery}
placeholder={"Select query"}
value={selectedQuery?.id}
wrapperClassName={`${baseClass}__select-query-dropdown-wrapper`}
autoFocus
/>
)}
<Dropdown
searchable={false}
options={FREQUENCY_DROPDOWN_OPTIONS}
onChange={onChangeSelectFrequency}
placeholder={"Every day"}
value={selectedFrequency}
label={"Choose a frequency and then run this query on a schedule"}
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--frequency`}
/>
<InfoBanner className={`${baseClass}__sandbox-info`}>
<p>
Your configured log destination is <b>{loggingConfig}</b>.
</p>
<p>
{loggingConfig === "unknown"
? ""
: `This means that when this query is run on your hosts, the data will
be sent to ${generateLoggingDestination(loggingConfig)}.`}
</p>
<p>
Check out the Fleet documentation on&nbsp;
<CustomLink
url="https://fleetdm.com/docs/deploying/configuration#osquery-result-log-plugin"
text="how to configure a different log destination"
newTab
multiline
/>
.
</p>
</InfoBanner>
<div>
<RevealButton
isShowing={showAdvancedOptions}
className={baseClass}
hideText={"Hide advanced options"}
showText={"Show advanced options"}
caretPosition={"after"}
onClick={toggleAdvancedOptions}
/>
{showAdvancedOptions && (
<div>
<Dropdown
options={LOGGING_TYPE_OPTIONS}
onChange={onChangeSelectLoggingType}
placeholder="Select"
value={selectedLoggingType}
label="Logging"
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--logging`}
/>
<Dropdown
options={SCHEDULE_PLATFORM_DROPDOWN_OPTIONS}
placeholder="Select"
label="Platform"
onChange={onChangeSelectPlatformOptions}
value={selectedPlatformOptions}
multi
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--platform`}
/>
<Dropdown
options={MIN_OSQUERY_VERSION_OPTIONS}
onChange={onChangeMinOsqueryVersionOptions}
placeholder="Select"
value={selectedMinOsqueryVersionOptions}
label="Minimum osquery version"
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--osquer-vers`}
/>
<InputField
onChange={onChangeShard}
inputWrapperClass={`${baseClass}__form-field ${baseClass}__form-field--shard`}
value={selectedShard}
placeholder="- - -"
label="Shard"
type="number"
/>
</div>
)}
</div>
<div className={`${baseClass}__btn-wrap`}>
<div className={`${baseClass}__preview-btn-wrap`}>
<Button
type="button"
variant="inverse"
onClick={togglePreviewDataModal}
>
Preview data
</Button>
</div>
<div className="modal-cta-wrap">
<Button
type="button"
variant="brand"
onClick={onFormSubmit}
disabled={!selectedQuery && !editQuery}
className="schedule-loading"
isLoading={isUpdatingScheduledQuery}
>
Schedule
</Button>
<Button onClick={onClose} variant="inverse">
Cancel
</Button>
</div>
</div>
</form>
</Modal>
);
};
export default ScheduleEditorModal;

View file

@ -1,26 +0,0 @@
.schedule-editor-modal {
&__platform-compatibility {
margin-bottom: $pad-large;
}
&__sandbox-info {
margin-top: $pad-medium;
p {
margin: 0;
margin-bottom: $pad-medium;
}
p:last-child {
margin-bottom: 0;
}
}
&__info-header {
font-weight: $bold;
}
.Select-value-label {
font-size: $small;
}
}

View file

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

View file

@ -1,224 +0,0 @@
/**
* Component when there is an error retrieving schedule set up in fleet
*/
import React from "react";
import { InjectedRouter } from "react-router";
import paths from "router/paths";
import {
IScheduledQuery,
IEditScheduledQuery,
} from "interfaces/scheduled_query";
import { ITeam } from "interfaces/team";
import { IEmptyTableProps } from "interfaces/empty_table";
import Button from "components/buttons/Button";
import CustomLink from "components/CustomLink";
import TableContainer from "components/TableContainer";
import EmptyTable from "components/EmptyTable";
import {
generateInheritedQueriesTableHeaders,
generateTableHeaders,
generateDataSet,
} from "./ScheduleTableConfig";
const baseClass = "schedule-table";
const TAGGED_TEMPLATES = {
hostsByTeamRoute: (teamId: number | undefined | null) => {
return `${teamId ? `/?team_id=${teamId}` : ""}`;
},
};
interface IScheduleTableProps {
router: InjectedRouter; // v3
onRemoveScheduledQueryClick?: (selectedIds: number[]) => void;
onEditScheduledQueryClick?: (selectedQuery: IEditScheduledQuery) => void;
onShowQueryClick?: (selectedQuery: IEditScheduledQuery) => void;
allScheduledQueriesList: IScheduledQuery[];
toggleScheduleEditorModal?: () => void;
inheritedQueries?: boolean;
isOnGlobalTeam: boolean;
selectedTeamData: ITeam | undefined;
loadingInheritedQueriesTableData: boolean;
loadingTeamQueriesTableData: boolean;
}
const ScheduleTable = ({
router,
onRemoveScheduledQueryClick,
onEditScheduledQueryClick,
onShowQueryClick,
allScheduledQueriesList,
toggleScheduleEditorModal,
inheritedQueries,
isOnGlobalTeam,
selectedTeamData,
loadingInheritedQueriesTableData,
loadingTeamQueriesTableData,
}: IScheduleTableProps): JSX.Element => {
const { MANAGE_PACKS, MANAGE_HOSTS } = paths;
const handleAdvanced = () => router.push(MANAGE_PACKS);
const emptyState = () => {
const emptySchedule: IEmptyTableProps = {
iconName: "empty-schedule",
header: (
<>
Schedule queries to run at regular intervals on{" "}
<a href={MANAGE_HOSTS}>all your hosts</a>
</>
),
additionalInfo: (
<>
Want to learn more?&nbsp;
<CustomLink
url="https://fleetdm.com/docs/using-fleet/fleet-ui#schedule-a-query"
text="Read about scheduling a query"
newTab
/>
</>
),
primaryButton: (
<Button
variant="brand"
className={`${baseClass}__schedule-button`}
onClick={toggleScheduleEditorModal}
>
Schedule a query
</Button>
),
};
if (selectedTeamData) {
emptySchedule.header = (
<>
Schedule queries for all hosts assigned to{" "}
<a
href={
MANAGE_HOSTS +
TAGGED_TEMPLATES.hostsByTeamRoute(selectedTeamData.id)
}
>
{selectedTeamData.name}
</a>
</>
);
}
/* NOTE: Product decision to remove packs from UI
if (isOnGlobalTeam) {
emptySchedule.info = (
<>Or go to your osquery packs via the Advanced button. </>
);
emptySchedule.secondaryButton = (
<Button
variant="inverse"
onClick={handleAdvanced}
className={`${baseClass}__advanced-button`}
>
Advanced
</Button>
);
}
*/
return emptySchedule;
};
const onActionSelection = (
action: string,
scheduledQuery: IEditScheduledQuery
): void => {
switch (action) {
case "edit":
if (onEditScheduledQueryClick) {
onEditScheduledQueryClick(scheduledQuery);
}
break;
case "showQuery":
if (onShowQueryClick) {
onShowQueryClick(scheduledQuery);
}
break;
default:
if (onRemoveScheduledQueryClick) {
onRemoveScheduledQueryClick([scheduledQuery.id]);
}
break;
}
};
const tableHeaders = generateTableHeaders(onActionSelection);
const loadingTableData = selectedTeamData?.id
? loadingTeamQueriesTableData
: loadingInheritedQueriesTableData;
if (inheritedQueries) {
const inheritedQueriesTableHeaders = generateInheritedQueriesTableHeaders();
return (
<div className={`${baseClass}`}>
<TableContainer
resultsTitle={"queries"}
columns={inheritedQueriesTableHeaders}
data={generateDataSet(allScheduledQueriesList, selectedTeamData?.id)}
isLoading={loadingInheritedQueriesTableData}
defaultSortHeader={"query"}
defaultSortDirection={"desc"}
showMarkAllPages={false}
isAllPagesSelected={false}
searchable={false}
disablePagination
disableCount
emptyComponent={() =>
EmptyTable({
iconName: emptyState().iconName,
header: emptyState().header,
info: emptyState().info,
additionalInfo: emptyState().additionalInfo,
primaryButton: emptyState().primaryButton,
secondaryButton: emptyState().secondaryButton,
})
}
/>
</div>
);
}
return (
<div className={`${baseClass}`}>
<TableContainer
resultsTitle={"queries"}
columns={tableHeaders}
data={generateDataSet(allScheduledQueriesList, selectedTeamData?.id)}
isLoading={loadingTableData}
defaultSortHeader={"query"}
defaultSortDirection={"desc"}
showMarkAllPages={false}
isAllPagesSelected={false}
inputPlaceHolder="Search"
searchable={false}
primarySelectAction={{
name: "remove scheduled query",
buttonText: "Remove",
iconSvg: "ex",
variant: "text-icon",
onActionButtonClick: onRemoveScheduledQueryClick,
}}
emptyComponent={() =>
EmptyTable({
iconName: emptyState().iconName,
header: emptyState().header,
info: emptyState().info,
additionalInfo: emptyState().additionalInfo,
primaryButton: emptyState().primaryButton,
secondaryButton: emptyState().secondaryButton,
})
}
isClientSidePagination
/>
</div>
);
};
export default ScheduleTable;

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