Query reports/caching feature (#14460)

This PR includes all the frontend and backend work for #7766
This commit is contained in:
Jacob Shandling 2023-10-11 14:51:36 -07:00 committed by GitHub
commit b86b65db9b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
134 changed files with 15436 additions and 1020 deletions

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,2 @@
* Add `GET /api/_version_/fleet/queries/{id}/report` API endpoint to retrieve the stored results of a given query.
* Add `discard_data` field to API query endpoints.

View file

@ -0,0 +1 @@
* Fixed a bug that would cause live queries to stall if a detail query override was set for a team.

View file

@ -804,6 +804,20 @@ func newCleanupsAndAggregationSchedule(
return verifyDiskEncryptionKeys(ctx, logger, ds, config)
},
),
schedule.WithJob("query_results_cleanup", func(ctx context.Context) error {
config, err := ds.AppConfig(ctx)
if err != nil {
return err
}
if config.ServerSettings.QueryReportsDisabled {
if err = ds.CleanupGlobalDiscardQueryResults(ctx); err != nil {
return err
}
}
return nil
}),
)
return s, nil

View file

@ -4,7 +4,6 @@ import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"fmt"
@ -1204,9 +1203,9 @@ spec:
// Apply queries.
var appliedQueries []*fleet.Query
ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string, opts ...fleet.OptionalArg) (*fleet.Query, error) {
return nil, sql.ErrNoRows
return nil, &notFoundError{}
}
ds.ApplyQueriesFunc = func(ctx context.Context, authorID uint, queries []*fleet.Query) error {
ds.ApplyQueriesFunc = func(ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]bool) error {
appliedQueries = queries
return nil
}
@ -1305,9 +1304,9 @@ func TestApplyQueries(t *testing.T) {
var appliedQueries []*fleet.Query
ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string, opts ...fleet.OptionalArg) (*fleet.Query, error) {
return nil, sql.ErrNoRows
return nil, &notFoundError{}
}
ds.ApplyQueriesFunc = func(ctx context.Context, authorID uint, queries []*fleet.Query) error {
ds.ApplyQueriesFunc = func(ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]bool) error {
appliedQueries = queries
return nil
}

View file

@ -15,14 +15,13 @@ import (
"github.com/fatih/color"
"github.com/fleetdm/fleet/v4/pkg/rawjson"
"github.com/fleetdm/fleet/v4/pkg/secure"
kithttp "github.com/go-kit/kit/transport/http"
"gopkg.in/guregu/null.v3"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/ghodss/yaml"
kithttp "github.com/go-kit/kit/transport/http"
"github.com/olekukonko/tablewriter"
"github.com/urfave/cli/v2"
"gopkg.in/guregu/null.v3"
)
const (
@ -462,6 +461,7 @@ func getQueriesCommand() *cli.Command {
MinOsqueryVersion: query.MinOsqueryVersion,
AutomationsEnabled: query.AutomationsEnabled,
Logging: query.Logging,
DiscardData: query.DiscardData,
}); err != nil {
return fmt.Errorf("unable to print query: %w", err)
}

View file

@ -1128,6 +1128,7 @@ kind: query
spec:
automations_enabled: false
description: some desc
discard_data: false
interval: 0
logging: ""
min_osquery_version: ""
@ -1142,6 +1143,7 @@ kind: query
spec:
automations_enabled: false
description: some desc 2
discard_data: false
interval: 0
logging: ""
min_osquery_version: ""
@ -1156,6 +1158,7 @@ kind: query
spec:
automations_enabled: true
description: some desc 4
discard_data: false
interval: 60
logging: differential_ignore_removals
min_osquery_version: 5.3.0
@ -1165,9 +1168,9 @@ spec:
query: select 4;
team: ""
`
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"}}
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":"","discard_data":false}}
{"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":"","discard_data":false}}
{"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","discard_data":false}}
`
expectedTeam := `+--------+-------------+-----------+--------+----------------------------+
@ -1191,6 +1194,7 @@ kind: query
spec:
automations_enabled: false
description: some desc 3
discard_data: false
interval: 3600
logging: snapshot
min_osquery_version: 5.4.0
@ -1200,7 +1204,7 @@ spec:
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"}}
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","discard_data":false}}
`
assert.Equal(t, expectedGlobal, runAppForTest(t, []string{"get", "queries"}))
@ -1276,6 +1280,7 @@ kind: query
spec:
automations_enabled: false
description: some desc
discard_data: false
interval: 0
logging: ""
min_osquery_version: ""
@ -1285,7 +1290,7 @@ spec:
query: select 1;
team: ""
`
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":""}}
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":"","discard_data":false}}
`
assert.Equal(t, expectedYaml, runAppForTest(t, []string{"get", "query", "globalQuery1"}))
@ -1298,6 +1303,7 @@ kind: query
spec:
automations_enabled: true
description: some team desc
discard_data: false
interval: 3600
logging: differential
min_osquery_version: 5.2.0
@ -1307,7 +1313,7 @@ spec:
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"}}
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","discard_data":false}}
`
assert.Equal(t, expectedYaml, runAppForTest(t, []string{"get", "query", "--team", "1", "teamQuery1"}))
@ -1432,6 +1438,7 @@ kind: query
spec:
automations_enabled: false
description: some desc 2
discard_data: false
interval: 0
logging: ""
min_osquery_version: ""
@ -1441,7 +1448,7 @@ spec:
query: select 2;
team: ""
`
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":""}}
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":"","discard_data":false}}
`
assert.Equal(t, expected, runAppForTest(t, []string{"get", "queries"}))
@ -1509,6 +1516,7 @@ kind: query
spec:
automations_enabled: false
description: some desc
discard_data: false
interval: 0
logging: ""
min_osquery_version: ""
@ -1523,6 +1531,7 @@ kind: query
spec:
automations_enabled: false
description: some desc 2
discard_data: false
interval: 0
logging: ""
min_osquery_version: ""
@ -1537,6 +1546,7 @@ kind: query
spec:
automations_enabled: false
description: some desc 3
discard_data: false
interval: 0
logging: ""
min_osquery_version: ""
@ -1546,9 +1556,9 @@ spec:
query: select 3;
team: ""
`
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":""}}
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":"","discard_data":false}}
{"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":"","discard_data":false}}
{"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":"","discard_data":false}}
`
assert.Equal(t, expected, runAppForTest(t, []string{"get", "queries"}))

View file

@ -4,6 +4,7 @@ kind: query
spec:
automations_enabled: false
description: Retrieves the list of application scheme/protocol-based IPC handlers.
discard_data: false
interval: 86400
logging: ""
min_osquery_version: 1.4.7
@ -18,6 +19,7 @@ kind: query
spec:
automations_enabled: false
description: Retrieves the current disk encryption status for the target system.
discard_data: false
interval: 86400
logging: ""
min_osquery_version: 1.4.5
@ -32,6 +34,7 @@ kind: query
spec:
automations_enabled: false
description: Retrieves the current disk encryption status for the target system.
discard_data: false
interval: 300
logging: ""
min_osquery_version: 1.4.5
@ -46,6 +49,7 @@ kind: query
spec:
automations_enabled: false
description: Retrieve basic information about the physical disks of a system.
discard_data: false
interval: 86400
logging: ""
min_osquery_version: 1.4.7
@ -60,6 +64,7 @@ kind: query
spec:
automations_enabled: false
description: Retrieves the current filters and chains per filter in the target system.
discard_data: false
interval: 3600
logging: ""
min_osquery_version: 1.4.5
@ -76,6 +81,7 @@ spec:
description:
Retrieves all the daemons that will run in the start of the target
OSX system.
discard_data: false
interval: 3600
logging: ""
min_osquery_version: 1.4.5
@ -90,6 +96,7 @@ kind: query
spec:
automations_enabled: false
description: Retrieves the list of listening ports.
discard_data: false
interval: 3600
logging: ""
min_osquery_version: 1.4.7
@ -104,6 +111,7 @@ kind: query
spec:
automations_enabled: false
description: Retrieves the list of listening ports.
discard_data: false
interval: 3600
logging: ""
min_osquery_version: 1.4.7
@ -118,6 +126,7 @@ kind: query
spec:
automations_enabled: false
description: Lists the application bundle that owns a sandbox label.
discard_data: false
interval: 86400
logging: ""
min_osquery_version: 1.4.7
@ -132,6 +141,7 @@ kind: query
spec:
automations_enabled: false
description: System resource usage limits.
discard_data: false
interval: 300
logging: ""
min_osquery_version: 1.4.7
@ -146,6 +156,7 @@ kind: query
spec:
automations_enabled: false
description: System uptime.
discard_data: false
interval: 600
logging: ""
min_osquery_version: 1.4.7
@ -160,6 +171,7 @@ kind: query
spec:
automations_enabled: false
description: System uptime.
discard_data: false
interval: 600
logging: ""
min_osquery_version: 1.4.7
@ -174,6 +186,7 @@ kind: query
spec:
automations_enabled: false
description: System uptime.
discard_data: false
interval: 600
logging: ""
min_osquery_version: 1.4.7
@ -188,6 +201,7 @@ kind: query
spec:
automations_enabled: false
description: System uptime.
discard_data: false
interval: 600
logging: ""
min_osquery_version: 1.4.7
@ -202,6 +216,7 @@ kind: query
spec:
automations_enabled: false
description: Lists the application bundle that owns a sandbox label.
discard_data: false
interval: 86400
logging: ""
min_osquery_version: 1.4.7
@ -216,6 +231,7 @@ kind: query
spec:
automations_enabled: false
description: List of all user groups.
discard_data: false
interval: 3600
logging: ""
min_osquery_version: 1.4.7
@ -230,6 +246,7 @@ kind: query
spec:
automations_enabled: false
description: List of all user groups.
discard_data: false
interval: 3600
logging: ""
min_osquery_version: 1.4.7
@ -244,6 +261,7 @@ kind: query
spec:
automations_enabled: false
description: List of all user groups.
discard_data: false
interval: 3600
logging: ""
min_osquery_version: 1.4.7
@ -258,6 +276,7 @@ kind: query
spec:
automations_enabled: false
description: List of all user groups.
discard_data: false
interval: 3600
logging: ""
min_osquery_version: ""
@ -272,6 +291,7 @@ kind: query
spec:
automations_enabled: false
description: List of all user groups.
discard_data: false
interval: 3600
logging: ""
min_osquery_version: 1.4.7
@ -286,6 +306,7 @@ kind: query
spec:
automations_enabled: false
description: List of all user groups.
discard_data: false
interval: 3600
logging: ""
min_osquery_version: 1.4.7
@ -300,6 +321,7 @@ kind: query
spec:
automations_enabled: false
description: List of all user groups.
discard_data: false
interval: 3600
logging: ""
min_osquery_version: ""
@ -314,6 +336,7 @@ kind: query
spec:
automations_enabled: false
description: List of all user groups.
discard_data: false
interval: 3600
logging: ""
min_osquery_version: 1.4.7
@ -328,6 +351,7 @@ kind: query
spec:
automations_enabled: false
description: Extracted information from Windows crash logs (Minidumps).
discard_data: false
interval: 3600
logging: ""
min_osquery_version: 1.4.7
@ -344,6 +368,7 @@ spec:
description:
Triggers one-off YARA query for files at the specified path. Requires
one of sig_group, sigfile, or sigrule.
discard_data: false
interval: 0
logging: ""
min_osquery_version: 1.4.7

View file

@ -11,6 +11,7 @@
"server_settings": {
"server_url": "",
"live_query_disabled": false,
"query_reports_disabled": false,
"enable_analytics": false,
"deferred_save_host": false
},
@ -113,4 +114,4 @@
},
"scripts": null
}
}
}

View file

@ -49,6 +49,7 @@ spec:
deferred_save_host: false
enable_analytics: false
live_query_disabled: false
query_reports_disabled: false
server_url: ""
smtp_settings:
authentication_method: ""

View file

@ -11,6 +11,7 @@
"server_settings": {
"server_url": "",
"live_query_disabled": false,
"query_reports_disabled": false,
"enable_analytics": false,
"deferred_save_host": false
},
@ -175,4 +176,4 @@
}
}
}
}
}

View file

@ -88,6 +88,7 @@ spec:
deferred_save_host: false
enable_analytics: false
live_query_disabled: false
query_reports_disabled: false
server_url: ""
smtp_settings:
authentication_method: ""

View file

@ -49,6 +49,7 @@ spec:
deferred_save_host: false
enable_analytics: false
live_query_disabled: false
query_reports_disabled: false
server_url: https://example.org
smtp_settings:
authentication_method: ""

View file

@ -49,6 +49,7 @@ spec:
deferred_save_host: false
enable_analytics: false
live_query_disabled: false
query_reports_disabled: false
server_url: https://example.org
smtp_settings:
authentication_method: ""

View file

@ -255,7 +255,7 @@ func TestFleetctlUpgradePacks_EmptyPacks(t *testing.T) {
outputFile := filepath.Join(tempDir, "output.yml")
// write some dummy data in the file, it should be overwritten
err := os.WriteFile(outputFile, []byte("dummy"), 0644)
err := os.WriteFile(outputFile, []byte("dummy"), 0o644)
require.NoError(t, err)
got := runAppForTest(t, []string{"upgrade-packs", "-o", outputFile})
@ -328,6 +328,7 @@ kind: query
spec:
automations_enabled: false
description: (converted from pack "p1", query "q1")
discard_data: false
interval: 0
logging: snapshot
min_osquery_version: ""
@ -342,6 +343,7 @@ kind: query
spec:
automations_enabled: true
description: (converted from pack "p2", query "q2")
discard_data: false
interval: 90
logging: differential
min_osquery_version: ""
@ -356,6 +358,7 @@ kind: query
spec:
automations_enabled: true
description: (converted from pack "p2", query "q2")
discard_data: false
interval: 90
logging: differential
min_osquery_version: ""
@ -371,7 +374,7 @@ spec:
outputFile := filepath.Join(tempDir, "output.yml")
// write some dummy data in the file, it should be overwritten
err := os.WriteFile(outputFile, []byte("dummy"), 0644)
err := os.WriteFile(outputFile, []byte("dummy"), 0o644)
require.NoError(t, err)
testUpgradePacksTimestamp = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
@ -394,7 +397,7 @@ func TestFleetctlUpgradePacks_NotAdmin(t *testing.T) {
outputFile := filepath.Join(tempDir, "output.yml")
// write some dummy data in the file, it should NOT be overwritten
err := os.WriteFile(outputFile, []byte("dummy"), 0644)
err := os.WriteFile(outputFile, []byte("dummy"), 0o644)
require.NoError(t, err)
// first try without the required output file flag
@ -422,7 +425,7 @@ func TestFleetctlUpgradePacks_NoPack(t *testing.T) {
outputFile := filepath.Join(tempDir, "output.yml")
// write some dummy data in the file, it should NOT be overwritten
err := os.WriteFile(outputFile, []byte("dummy"), 0644)
err := os.WriteFile(outputFile, []byte("dummy"), 0o644)
require.NoError(t, err)
got := runAppForTest(t, []string{"upgrade-packs", "-o", outputFile})

View file

@ -20,8 +20,7 @@ services:
# Required for storage of Apple MDM installers.
"--max_allowed_packet=536870912"
]
environment:
&mysql-default-environment
environment: &mysql-default-environment
MYSQL_ROOT_PASSWORD: toor
MYSQL_DATABASE: fleet
MYSQL_USER: fleet
@ -41,7 +40,7 @@ services:
"--log_output=TABLE",
"--log-queries-not-using-indexes",
"--innodb-file-per-table=OFF",
"--table-definition-cache=2048",
"--table-definition-cache=8192",
# These 3 keys run MySQL with GTID consistency enforced to avoid issues with production deployments that use it.
"--enforce-gtid-consistency=ON",
"--log-bin=bin.log",
@ -142,3 +141,4 @@ volumes:
mysql-persistent-volume:
data-minio:

View file

@ -12,6 +12,7 @@ const DEFAULT_CONFIG_MOCK: IConfig = {
live_query_disabled: false,
enable_analytics: true,
deferred_save_host: false,
query_reports_disabled: false,
},
smtp_settings: {
enable_smtp: false,

View file

@ -12,6 +12,7 @@ const DEFAULT_QUERY_MOCK: ISchedulableQuery = {
author_name: "Test User",
author_email: "test@example.com",
observer_can_run: false,
discard_data: false,
interval: 300,
packs: [],
team_id: null,

View file

@ -0,0 +1,331 @@
import { IQueryReport } from "interfaces/query_report";
const DEFAULT_QUERY_REPORT_MOCK: IQueryReport = {
query_id: 31,
results: [
{
host_id: 1,
host_name: "foo",
last_fetched: "2021-01-19T17:08:31Z",
columns: {
model: "Razer Viper",
vendor: "Razer",
model_id: "0078",
},
},
{
host_id: 1,
host_name: "foo",
last_fetched: "2021-01-19T17:08:31Z",
columns: {
model: "USB Keyboard",
vendor: "VIA Labs, Inc.",
},
},
{
host_id: 2,
host_name: "bar",
last_fetched: "2021-01-19T17:20:00Z",
columns: {
model: "USB Reciever",
vendor: "Logitech",
},
},
{
host_id: 2,
host_name: "bar",
last_fetched: "2021-01-19T17:20:00Z",
columns: {
model: "USB Keyboard",
vendor: "Logitech",
},
},
{
host_id: 2,
host_name: "bar",
last_fetched: "2021-01-19T17:20:00Z",
columns: {
model: "YubiKey OTP+FIDO+CCID",
vendor: "Yubico",
},
},
{
host_id: 2,
host_name: "bar",
last_fetched: "2021-01-19T17:20:00Z",
columns: {
model: "Lenovo USB Optical Mouse",
vendor: "PixArt",
},
},
{
host_id: 2,
host_name: "bar",
last_fetched: "2021-01-19T17:20:00Z",
columns: {
model: "Lenovo Traditional USB Keyboard",
vendor: "Lenovo",
},
},
{
host_id: 2,
host_name: "bar",
last_fetched: "2021-01-19T17:20:00Z",
columns: {
model: "Display Audio",
vendor: "Bose",
},
},
{
host_id: 2,
host_name: "bar",
last_fetched: "2021-01-19T17:20:00Z",
columns: {
model: "USB-C Digital AV Multiport Adapter",
vendor: "Apple, Inc.",
model_id: "1460",
},
},
{
host_id: 2,
host_name: "bar",
last_fetched: "2021-01-19T17:20:00Z",
columns: {
model: "USB Reciever",
vendor: "Logitech",
},
},
{
host_id: 2,
host_name: "bar",
last_fetched: "2021-01-19T17:20:00Z",
columns: {
model: "USB-C Digital AV Multiport Adapter",
vendor: "Apple Inc.",
model_id: "1460",
},
},
{
host_id: 2,
host_name: "bar",
last_fetched: "2021-01-19T17:20:00Z",
columns: {
model: "USB Reciever",
vendor: "Logitech",
},
},
{
host_id: 3,
host_name: "zoo",
last_fetched: "2022-04-09T17:20:00Z",
columns: {
model: "Logitech Webcam C925e",
model_id: "085b",
},
},
{
host_id: 3,
host_name: "zoo",
last_fetched: "2022-04-09T17:20:00Z",
columns: {
model: "Display Audio",
vendor: "Apple Inc.",
},
},
{
host_id: 3,
host_name: "zoo",
last_fetched: "2022-04-09T17:20:00Z",
columns: {
model: "Ambient Light Sensor",
vendor: "Apple Inc.",
},
},
{
host_id: 3,
host_name: "zoo",
last_fetched: "2022-04-09T17:20:00Z",
columns: {
model: "DELL Laser Mouse",
model_id: "4d51",
},
},
{
host_id: 7,
host_name: "Rachel's Magnificent Testing Computer of All Computers",
last_fetched: "2023-09-21T19:03:30Z",
columns: {
model: "AppleUSBVHCIBCE Root Hub Simulation",
vendor: "Apple Inc.",
},
},
{
host_id: 7,
host_name: "Rachel's Magnificent Testing Computer of All Computers",
last_fetched: "2023-09-21T19:03:30Z",
columns: {
model: "QuickFire Rapid keyboard",
vendor: "CM Storm",
model_id: "0004",
},
},
{
host_id: 7,
host_name: "Rachel's Magnificent Testing Computer of All Computers",
last_fetched: "2023-09-21T19:03:30Z",
columns: {
model: "Lenovo USB Optical Mouse",
vendor: "Lenovo",
},
},
{
host_id: 7,
host_name: "Rachel's Magnificent Testing Computer of All Computers",
last_fetched: "2023-09-21T19:03:30Z",
columns: {
model: "YubiKey FIDO+CCID",
vendor: "Yubico",
},
},
{
host_id: 4,
host_name: "car",
last_fetched: "2023-01-14T12:40:30Z",
columns: {
model: "USB2.0 Hub",
vendor: "Apple Inc.",
},
},
{
host_id: 8,
host_name: "apple man",
last_fetched: "2021-01-19T17:20:00Z",
columns: {
model: "FaceTime HD Camera (Display)",
vendor: "Apple Inc.",
model_id: "1112",
},
},
{
host_id: 8,
host_name: "apple man",
last_fetched: "2021-01-19T17:20:00Z",
columns: {
model: "Apple Internal Keyboard / Trackpad",
model_id: "027e",
vendor: "Apple Inc.",
},
},
{
host_id: 8,
host_name: "apple man",
last_fetched: "2021-01-19T17:20:00Z",
columns: {
model: "Apple Thunderbolt Display",
vendor: "Apple Inc.",
model_id: "9227",
},
},
{
host_id: 8,
host_name: "apple man",
last_fetched: "2021-01-19T17:20:00Z",
columns: {
model: "AppleUSBXHCI Root Hub Simulation",
vendor: "Apple Inc.",
model_id: "8007",
},
},
{
host_id: 8,
host_name: "apple man",
last_fetched: "2021-01-19T17:20:00Z",
columns: {
model: "Apple T2 Controller",
vendor: "Apple Inc.",
model_id: "8233",
},
},
{
host_id: 5,
host_name: "choo",
last_fetched: "2023-09-03T03:40:30Z",
columns: {
model: "4-Port USB 2.0 Hub",
vendor: "Generic",
},
},
{
host_id: 5,
host_name: "choo",
last_fetched: "2023-09-03T03:40:30Z",
columns: {
model: "USB 10_100_1000 LAN",
vendor: "Realtek",
},
},
{
host_id: 5,
host_name: "choo",
last_fetched: "2023-09-03T03:40:30Z",
columns: {
model: "Display Audio",
vendor: "Apple Inc.",
},
},
{
host_id: 5,
host_name: "choo",
last_fetched: "2023-09-03T03:40:30Z",
columns: {
model: "USB Mouse",
vendor: "Razor",
},
},
{
host_id: 5,
host_name: "choo",
last_fetched: "2023-09-03T03:40:30Z",
columns: {
model: "USB Audio",
vendor: "Apple, Inc.",
},
},
{
host_id: 6,
host_name: "moo",
last_fetched: "2023-09-20T07:02:34Z",
columns: {
model: "Display Audio",
vendor: "Apple Inc.",
},
},
{
host_id: 6,
host_name: "moo",
last_fetched: "2023-09-20T07:02:34Z",
columns: {
model: "USB Reciever",
vendor: "Logitech",
},
},
{
host_id: 6,
host_name: "moo",
last_fetched: "2023-09-20T07:02:34Z",
columns: {
model: "LG Monitor Controls",
vendor: "LG Electronics Inc.",
model_id: "9a39",
},
},
],
};
const createMockQueryReport = (
overrides?: Partial<IQueryReport>
): IQueryReport => {
return { ...DEFAULT_QUERY_REPORT_MOCK, ...overrides };
};
export default createMockQueryReport;

View file

@ -20,6 +20,7 @@ const DEFAULT_SCHEDULABLE_QUERY_MOCK: ISchedulableQuery = {
author_name: "Test User",
author_email: "test@example.com",
observer_can_run: false,
discard_data: false,
packs: [],
stats: {
system_time_p50: 28.1053,

View file

@ -57,7 +57,6 @@
&__container {
align-self: center;
justify-content: center;
margin: 0;
margin-bottom: 20px;
min-height: 155px;
max-width: none;

View file

@ -11,7 +11,7 @@ export interface IInfoBannerProps {
children?: React.ReactNode;
className?: string;
/** default light purple */
color?: "yellow" | "grey";
color?: "purple" | "purple-bold-border" | "yellow" | "grey";
/** default 4px */
borderRadius?: "large" | "xlarge";
pageLevel?: boolean;
@ -26,7 +26,7 @@ export interface IInfoBannerProps {
const InfoBanner = ({
children,
className,
color,
color = "purple",
borderRadius,
pageLevel,
cta,
@ -36,6 +36,7 @@ const InfoBanner = ({
}: IInfoBannerProps): JSX.Element => {
const wrapperClasses = classNames(
baseClass,
`${baseClass}__${color}`,
{
[`${baseClass}__${color}`]: !!color,
[`${baseClass}__border-radius-${borderRadius}`]: !!borderRadius,

View file

@ -5,11 +5,19 @@
padding: $pad-medium;
border-radius: $border-radius;
border: 1px solid $ui-vibrant-blue-50;
background-color: $ui-vibrant-blue-10;
font-size: $x-small;
font-weight: $regular;
color: $core-fleet-black;
&__purple {
background-color: $ui-vibrant-blue-10;
}
&__purple-bold-border {
background-color: $ui-vibrant-blue-10;
border-color: $core-vibrant-blue;
}
&__yellow {
background-color: $ui-yellow-banner;
border-color: $ui-yellow-banner-outline;
@ -35,6 +43,9 @@
}
&__info {
display: flex;
flex-direction: column;
gap: $pad-small;
p {
margin: $pad-small 0 0 0;
}
@ -46,6 +57,8 @@
color: $core-fleet-black;
text-align: right;
gap: $pad-small;
min-width: max-content;
margin-left: $pad-small;
button {
margin-left: $pad-small;
@ -65,4 +78,8 @@
}
}
}
p {
margin: 0;
}
}

View file

@ -12,7 +12,7 @@ import {
ISelectLabel,
ISelectTeam,
ISelectTargetsEntity,
ISelectedTargets,
ISelectedTargetsForApi,
} from "interfaces/target";
import { ITeam } from "interfaces/team";
@ -48,7 +48,9 @@ interface ISelectTargetsProps {
targetedTeams: ITeam[];
goToQueryEditor: () => void;
goToRunQuery: () => void;
setSelectedTargets: React.Dispatch<React.SetStateAction<ITarget[]>>;
setSelectedTargets: // TODO: Refactor policy targets to streamline selectedTargets/selectedTargetsByType
| React.Dispatch<React.SetStateAction<ITarget[]>> // Used for policies page level useState hook
| ((value: ITarget[]) => void); // Used for queries app level QueryContext
setTargetedHosts: React.Dispatch<React.SetStateAction<IHost[]>>;
setTargetedLabels: React.Dispatch<React.SetStateAction<ILabel[]>>;
setTargetedTeams: React.Dispatch<React.SetStateAction<ITeam[]>>;
@ -65,7 +67,7 @@ interface ITargetsQueryKey {
scope: string;
query_id?: number | null;
query?: string | null;
selected?: ISelectedTargets | null;
selected?: ISelectedTargetsForApi | null;
}
const DEBOUNCE_DELAY = 500;
@ -379,12 +381,22 @@ const SelectTargets = ({
}
const { targets_count: total, targets_online: online } = counts;
const onlinePercentage = total > 0 ? Math.round((online / total) * 100) : 0;
const onlinePercentage = () => {
if (total === 0) {
return 0;
}
// If at least 1 host is online, displays <1% instead of 0%
const roundPercentage =
Math.round((online / total) * 100) === 0
? "<1"
: Math.round((online / total) * 100) === 0;
return roundPercentage;
};
return (
<>
<span>{total}</span>&nbsp;host{total > 1 ? `s` : ``} targeted&nbsp; (
{onlinePercentage}
{onlinePercentage()}
%&nbsp;
<TooltipWrapper
tipContent={`Hosts are online if they<br /> have recently checked <br />into Fleet.`}

View file

@ -79,4 +79,7 @@
overflow: auto;
}
}
.input-icon-field__icon {
top: 34px; // Override styling to include label header
}
}

View file

@ -8,6 +8,11 @@ const DefaultColumnFilter = ({
}: FilterProps<TableInstance>): JSX.Element => {
const { setFilter } = column;
// Remove last_fetched filter per design as it is confusing to filter by a non-displayed date-string
if (column.id === "last_fetched") {
return <></>;
}
return (
<div className={"filter-cell"}>
<SearchField

View file

@ -6,6 +6,7 @@ import * as DOMPurify from "dompurify";
interface ITooltipWrapperProps {
children: string | JSX.Element;
tipContent: string;
/** Default: bottom */
position?: "top" | "bottom";
isDelayed?: boolean;
className?: string;

View file

@ -341,9 +341,7 @@ $base-class: "button";
}
&--disabled {
opacity: 0.5;
filter: grayscale(0.5);
cursor: default;
@include disabled;
&:hover,
&:focus {

View file

@ -1,10 +1,12 @@
import React from "react";
import { COLORS, Colors } from "styles/var/colors";
import { IconSizes, ICON_SIZES } from "styles/var/icon_sizes";
interface IChevronProps {
color?: Colors;
/** Default direction "down" */
direction?: "up" | "down" | "left" | "right";
size: IconSizes;
}
const SVG_PATH = {
@ -17,11 +19,12 @@ const SVG_PATH = {
const Chevron = ({
color = "core-fleet-black",
direction = "down",
size = "medium",
}: IChevronProps) => {
return (
<svg
width="16"
height="16"
width={ICON_SIZES[size]}
height={ICON_SIZES[size]}
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"

View file

@ -0,0 +1,254 @@
import React from "react";
const CollectingResults = () => {
return (
<svg
width="240"
height="126"
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 240 126"
>
<g clipPath="url(#a)">
<path
d="M109.214 120.128c26.769-13.44 35.513-50.12 19.53-81.927C112.761 6.394 78.104-8.496 51.335 4.945c-26.768 13.44-35.512 50.12-19.53 81.927 15.984 31.807 50.64 46.697 77.409 33.256Z"
fill="#F1F1FD"
/>
<path
d="M123.697 16.088a14.505 14.505 0 0 1 10.124 4.55 14.49 14.49 0 0 1 3.94 10.374 1.478 1.478 0 0 0 2.475 1.09c.283-.26.454-.62.475-1.004a17.439 17.439 0 0 0-7.27-14.696 17.454 17.454 0 0 0-9.657-3.26 1.472 1.472 0 0 0-1.388.87 1.466 1.466 0 0 0-.034 1.127 1.475 1.475 0 0 0 1.335.95Zm22.334 16.65a1.48 1.48 0 0 0 1.055-.4 1.466 1.466 0 0 0 .463-1.03 24.47 24.47 0 0 0-.309-4.65 24.218 24.218 0 0 0-3.296-8.828 24.288 24.288 0 0 0-19.959-11.517 1.477 1.477 0 0 0-1.094 2.474c.26.283.621.454 1.005.475a21.35 21.35 0 0 1 14.901 6.693 21.332 21.332 0 0 1 5.8 15.265 1.469 1.469 0 0 0 .87 1.39c.177.08.368.123.562.128h.002Z"
fill="#D7DAF5"
/>
<path
d="M185.292 85.93v6.313h-19.379v-6.314a.937.937 0 0 1 .936-.935h17.507a.935.935 0 0 1 .936.935Z"
fill="#F1F1FD"
/>
<path
d="M196.958 114.897V89.31a1.87 1.87 0 0 0-1.871-1.87h-65.532a1.87 1.87 0 0 0-1.871 1.87v25.587a1.87 1.87 0 0 0 1.871 1.871h65.532a1.87 1.87 0 0 0 1.871-1.871Z"
fill="#D7DAF5"
/>
<path
d="M163.092 113.824v-8.418a.468.468 0 0 0-.468-.468h-3.743a.468.468 0 0 0-.468.468v8.418c0 .258.21.468.468.468h3.743c.258 0 .468-.21.468-.468Z"
fill="#F1F1FD"
/>
<path
d="M155.318 112.587V91.621c0-1.147-.93-2.077-2.077-2.077h-21.375c-1.147 0-2.077.93-2.077 2.077v20.966c0 1.147.93 2.076 2.077 2.076h21.375a2.076 2.076 0 0 0 2.077-2.076Z"
fill="#E6E6F7"
/>
<path
d="M181.722 112.966c6.002 0 10.867-4.863 10.867-10.862s-4.865-10.862-10.867-10.862c-6.001 0-10.866 4.863-10.866 10.862s4.865 10.862 10.866 10.862Z"
fill="#fff"
/>
<path
d="M186.333 106.556v-8.907a.935.935 0 0 0-1.871 0v8.907a.936.936 0 1 0 1.871 0ZM182.59 106.556v-8.907a.935.935 0 0 0-1.871 0v8.907a.936.936 0 1 0 1.871 0ZM178.847 106.556v-8.907a.935.935 0 0 0-1.871 0v8.907a.936.936 0 1 0 1.871 0Z"
fill="#B0B2E7"
/>
<path
d="M238.012 116.625a1.578 1.578 0 0 1-.349-.035c-14.607-2.944-26.523-4.641-44.199-4.641a1.754 1.754 0 1 1 0-3.508c17.975 0 30.074 1.728 44.901 4.712a1.755 1.755 0 0 1 1.397 1.893 1.76 1.76 0 0 1-1.743 1.579h-.007Z"
fill="#D7DAF5"
/>
<path
d="m78.157 120.042-50.855 4.677V74.677l50.855-2.105v47.47Z"
fill="#E6E6F7"
/>
<path
d="m45.776 109.065 13.945-.905-1.448-34.76 6.882-.36 2.897 47.792-40.836 3.873 18.56-15.64Z"
fill="#D7DAF5"
/>
<path
d="m38.328 123-1.048-.936c.025-.011 2.793-1.218 5.418-4.842 4.405-6.08 5.708-14.145 5.455-19.877-.253-5.731-3.626-12.201-9.357-17.5-3.38-3.145-11.098-5.08-11.131-5.086-.033-.007 4.74-.286 6.248-.363 1.362.622 4.595 2.285 6.429 3.975 6.082 5.613 8.95 12.862 9.215 18.888.264 6.026-.41 14.498-5.11 20.917-1.92 2.626-3.897 4.061-5.02 4.735l-1.1.089Z"
fill="#D7DAF5"
/>
<path
d="M27.216 124.705c11.505 0 20.832-11.192 20.832-24.998 0-13.806-9.327-24.997-20.832-24.997S6.384 85.9 6.384 99.707s9.327 24.998 20.832 24.998Z"
fill="#fff"
/>
<path
d="M27.216 122.81c10.633 0 19.253-10.343 19.253-23.103 0-12.76-8.62-23.103-19.253-23.103-10.633 0-19.253 10.343-19.253 23.103 0 12.76 8.62 23.103 19.253 23.103Z"
fill="#F1F1FD"
/>
<path
d="M27.216 120.841c9.726 0 17.61-9.462 17.61-21.134s-7.884-21.134-17.61-21.134c-9.726 0-17.61 9.462-17.61 21.134s7.884 21.134 17.61 21.134Z"
fill="#fff"
/>
<path
d="M27.216 119.078c8.916 0 16.144-8.672 16.144-19.37 0-10.7-7.228-19.372-16.144-19.372-8.916 0-16.144 8.673-16.144 19.371 0 10.699 7.228 19.371 16.144 19.371Z"
fill="#F1F1FD"
/>
<path
d="M27.216 116.78c7.858 0 14.228-7.644 14.228-17.073s-6.37-17.073-14.228-17.073-14.228 7.644-14.228 17.073 6.37 17.073 14.228 17.073Z"
fill="#fff"
/>
<path
d="M27.216 101.505c.828 0 1.5-.805 1.5-1.798s-.672-1.798-1.5-1.798-1.5.805-1.5 1.798.672 1.798 1.5 1.798Z"
fill="#B0B2E7"
/>
<path
d="m45.814 123-.37-.884c.026-.012 2.793-1.218 5.416-4.843 4.407-6.08 5.032-14.208 4.779-19.947-.253-5.738-1.755-13.724-7.486-19.022-3.387-3.136-7.797-4.158-7.83-4.165-.032-.007 2.293-.091 3.802-.166a18.424 18.424 0 0 1 4.982 3.302c6.083 5.612 7.666 13.974 7.93 19.99.265 6.017-.409 14.499-5.108 20.918-1.92 2.626-3.898 4.061-5.02 4.735l-1.095.082Z"
fill="#D7DAF5"
/>
<path
d="m51.615 122.462-.234-.578c.021-.011 2.2-1.218 4.267-4.842 3.472-6.08 3.963-14.209 3.764-19.947-.199-5.738-1.382-13.724-5.892-19.023a14.248 14.248 0 0 0-6.17-4.165s1.807-.09 2.995-.166a14.737 14.737 0 0 1 3.928 3.302c4.786 5.612 6.038 13.974 6.246 19.991.208 6.017-.323 14.498-4.024 20.917-1.513 2.626-2.9 3.742-3.785 4.42-.416.04-1.095.091-1.095.091Z"
fill="#D7DAF5"
/>
<path
d="m23.005 99.707.9-.327c-5.953 0-11.68 5.144-11.68 11.102v10.289a3.491 3.491 0 0 1-3.495 3.492H0"
stroke="#B0B2E7"
strokeWidth="1.415"
strokeMiterlimit="10"
/>
<path
d="M25.11 99.707h.789c-5.954 0-11.568 4.834-11.568 10.785v10.289a3.486 3.486 0 0 1-2.158 3.226 3.505 3.505 0 0 1-1.337.265h-8.73"
stroke="#B0B2E7"
strokeWidth="1.415"
strokeMiterlimit="10"
/>
<path
d="M27.216 99.707a10.781 10.781 0 0 0-10.78 10.775v10.289a3.493 3.493 0 0 1-3.495 3.492h-8.73"
stroke="#B0B2E7"
strokeWidth="1.415"
strokeMiterlimit="10"
/>
<path
d="m70.225 65.3-5.547.19-.089-20.897 5.547-.119.089 20.826Z"
fill="#D7DAF5"
/>
<path
d="m83.854 125.404-4.396.402a14.325 14.325 0 0 1-14.427-8.467 14.309 14.309 0 0 1-1.22-5.783V65.894a.702.702 0 0 1 .685-.702l19.358-.451v60.663Z"
fill="#D7DAF5"
/>
<path
d="m131.515 64.13-61.924 1.125-.09-21.363 61.923.816.091 19.423Z"
fill="#D7DAF5"
/>
<path
d="m82.5 56.283-5.536.072a1.997 1.997 0 0 1-2.02-1.99v-.92a1.993 1.993 0 0 1 2.006-2l5.533.033a1.996 1.996 0 0 1 1.984 1.987v.816a1.992 1.992 0 0 1-1.968 2.002Z"
fill="#B0B2E7"
/>
<path
d="m121.59 121.333-39.245 4.396a11.72 11.72 0 0 1-9.11-2.908 11.71 11.71 0 0 1-3.904-8.726V65.134l61.951-1.155a.705.705 0 0 1 .659.43.701.701 0 0 1 .054.271v45.033a11.698 11.698 0 0 1-10.405 11.62Z"
fill="#fff"
/>
<path
d="m81.921 44.02-17.22.63a.701.701 0 0 1-.727-.702l-.037-10.338c-.029-7.633 6.126-13.616 13.762-13.668l4.134-.026-1.736 3.508-5.977 5.081 2.987 12.038 4.814 3.478Z"
fill="#D7DAF5"
/>
<path
d="M74.09 22.229v3.04l39.38 1.183a6.519 6.519 0 0 1 6.274 6.618l-.047 2.787a5.477 5.477 0 0 1-5.528 5.378l-40.08-.194v1.085h52.883v-16.79L74.089 22.23Z"
fill="#D7DAF5"
/>
<path
d="m121.976 21.855-40.195-1.948a11.89 11.89 0 0 0-11.5 7.186 11.876 11.876 0 0 0-.962 4.679v12.627l61.958.468a.702.702 0 0 0 .702-.702V32.36a10.523 10.523 0 0 0-10.003-10.504Zm4.559 12.26a7.268 7.268 0 0 1-7.306 7.275l-34.856-.162a8.636 8.636 0 0 1-8.595-8.628v-1.523a6.928 6.928 0 0 1 4.455-6.472 6.938 6.938 0 0 1 2.73-.454l37.045 1.373a6.78 6.78 0 0 1 6.529 6.765l-.002 1.826Z"
fill="#fff"
/>
<path
d="m126.441 111.109-46.035 5.685a6.245 6.245 0 0 1-4.893-1.518 6.23 6.23 0 0 1-2.102-4.67V76.117h53.03v34.992Z"
fill="#D7DAF5"
/>
<path
d="m126.182 111.074-45.325 4.859a5.788 5.788 0 0 1-5.855-3.277 5.79 5.79 0 0 1-.555-2.302l-1.03-33.327a5.844 5.844 0 0 1 5.653-6.035l46.059-1.705a4.958 4.958 0 0 1 5.147 4.786l1.052 31.115a5.712 5.712 0 0 1-5.146 5.886Z"
fill="#fff"
stroke="#B0B2E7"
strokeWidth=".472"
strokeMiterlimit="10"
/>
<path
d="m125.992 97.92-46.454 3.821a3.59 3.59 0 0 1-2.682-.897 3.587 3.587 0 0 1-1.197-2.561l-.688-21.565a4.054 4.054 0 0 1 3.9-4.18l46.532-1.806a3.664 3.664 0 0 1 3.482 2.158c.198.442.307.919.319 1.403l.508 19.477a4.05 4.05 0 0 1-3.72 4.15Z"
fill="#F1F1FD"
/>
<path
d="m126.189 96.45-46.506 3.809a2.503 2.503 0 0 1-1.881-.63 2.51 2.51 0 0 1-.838-1.797L76.42 79.11a2.68 2.68 0 0 1 2.574-2.761l46.929-1.913a2.005 2.005 0 0 1 2.091 1.964l.384 17.604a2.404 2.404 0 0 1-2.208 2.446Z"
fill="#F1F1FD"
/>
<path
d="M99.03 53.399v1.328h-3.676V53.4h3.677Zm-3.1-2.845v7.166h-1.728v-7.166h1.728Zm4.262 0v7.166H98.47v-7.166h1.722Zm6.634 5.837v1.329h-3.819v-1.329h3.819Zm-3.204-5.837v7.166h-1.727v-7.166h1.727Zm2.712 2.845v1.284h-3.327V53.4h3.327Zm.507-2.845v1.334h-3.834v-1.334h3.834Zm6.034 5.837v1.329h-3.623v-1.329h3.623Zm-3.008-5.837v7.166h-1.727v-7.166h1.727Zm9.076 5.837v1.329h-3.623v-1.329h3.623Zm-3.007-5.837v7.166h-1.728v-7.166h1.728Zm10.089 3.44v.29c0 .549-.077 1.04-.232 1.477-.15.433-.365.804-.644 1.112-.279.306-.609.54-.99.704-.38.161-.8.241-1.26.241-.462 0-.886-.08-1.269-.24a2.915 2.915 0 0 1-.995-.705 3.266 3.266 0 0 1-.649-1.112 4.499 4.499 0 0 1-.227-1.477v-.29c0-.548.076-1.04.227-1.476a3.26 3.26 0 0 1 .644-1.113c.279-.308.609-.543.99-.704a3.198 3.198 0 0 1 1.27-.246c.459 0 .879.082 1.26.246.383.161.715.396.994.704.279.305.495.676.649 1.113.155.436.232.928.232 1.476Zm-1.752.29v-.3a4.19 4.19 0 0 0-.094-.935 2.071 2.071 0 0 0-.266-.684 1.18 1.18 0 0 0-1.023-.556 1.143 1.143 0 0 0-1.029.556 2.138 2.138 0 0 0-.261.684c-.056.27-.084.58-.084.935v.3c0 .352.028.663.084.936.059.269.146.497.261.684.118.187.262.328.433.423.174.095.376.143.605.143.22 0 .415-.048.586-.143.171-.095.313-.236.428-.423.118-.187.207-.415.266-.684.062-.273.094-.584.094-.935ZM95.558 86.67a2.264 2.264 0 1 0 0-4.527 2.264 2.264 0 0 0 0 4.528ZM111.699 85.735a2.263 2.263 0 1 0 0-4.527 2.264 2.264 0 1 0 0 4.527Z"
fill="#B0B2E7"
/>
<path
d="m101.375 89.205 5.797-.234"
stroke="#B0B2E7"
strokeWidth="1.887"
strokeMiterlimit="10"
strokeLinecap="round"
/>
<path
d="M165.913 87.386v-1.457a.937.937 0 0 1 .936-.935h17.507a.935.935 0 0 1 .936.935v1.548"
stroke="#B0B2E7"
strokeWidth=".472"
strokeMiterlimit="10"
/>
<path
d="M131.894 87.44h63.193a1.871 1.871 0 0 1 1.871 1.87v25.585a1.871 1.871 0 0 1-1.871 1.871h-65.338"
stroke="#B0B2E7"
strokeWidth=".472"
strokeMiterlimit="10"
/>
<path
d="M131.864 89.544h21.274a2.182 2.182 0 0 1 2.18 2.18v20.753a2.178 2.178 0 0 1-2.18 2.179h-22.196M181.722 112.966c6.002 0 10.867-4.863 10.867-10.862s-4.865-10.862-10.867-10.862c-6.001 0-10.866 4.863-10.866 10.862s4.865 10.862 10.866 10.862ZM196.956 108.464c16.092.213 27.558 1.904 41.406 4.689a1.755 1.755 0 0 1 .83 3.018 1.75 1.75 0 0 1-1.524.419c-13.629-2.743-24.917-4.408-40.728-4.618M29.296 124.59l38.033-3.515M69.01 43.747a.774.774 0 0 0 .765.783l61.172.398a.98.98 0 0 0 .992-.966c0-1.87 0-6.262-.045-12.188-.03-5.144-3.534-7.716-6.012-8.902-1.201-.56-2.5-.878-3.825-.936-6.187-.299-30.998-1.508-40.795-2.06a8.657 8.657 0 0 0-1.907.103c-10.125 1.654-10.11 12.244-10.11 12.244l-.235 11.524Z"
stroke="#B0B2E7"
strokeWidth=".472"
strokeMiterlimit="10"
/>
<path
d="M79.914 19.904s-11.837-.603-15.194 9.31a14.378 14.378 0 0 0-.723 4.517c-.03 4.488-.014 8.091 0 9.822a1.103 1.103 0 0 0 1.13 1.11l4.494-.095M69.736 44.654v20.33M64.844 44.654v20.419M131.26 45.16v18.737M131.894 64.816v45.531a10.753 10.753 0 0 1-9.546 10.696l-40.558 4.599a11.371 11.371 0 0 1-8.856-2.815 11.36 11.36 0 0 1-3.795-8.479v-48.55a.828.828 0 0 1 .814-.828l61.073-1.005a.849.849 0 0 1 .868.85Z"
stroke="#B0B2E7"
strokeWidth=".472"
strokeMiterlimit="10"
/>
<path
d="M81.152 125.691c-10.595.875-17.393-5.96-17.393-14.03V65.999a.783.783 0 0 1 .744-.781l5.542-.264M26.035 74.721l37.55-1.328M27.216 124.704c11.505 0 20.832-11.191 20.832-24.997S38.72 74.71 27.216 74.71 6.384 85.9 6.384 99.707s9.327 24.997 20.832 24.997ZM119.606 41.357H84.191a8.169 8.169 0 0 1-8.162-8.166v-2.754a6.473 6.473 0 0 1 6.725-6.47l37.385 1.447a6.782 6.782 0 0 1 6.527 6.795v2.105a7.04 7.04 0 0 1-4.359 6.51 7.055 7.055 0 0 1-2.701.533Z"
stroke="#B0B2E7"
strokeWidth=".472"
strokeMiterlimit="10"
/>
<path
d="m78.649 25.278 34.912 1.11a6.426 6.426 0 0 1 6.227 6.428v3.195a5.352 5.352 0 0 1-5.303 5.353M163.092 113.824v-8.418a.468.468 0 0 0-.468-.468h-3.743a.468.468 0 0 0-.468.468v8.418c0 .258.21.468.468.468h3.743c.258 0 .468-.21.468-.468Z"
stroke="#B0B2E7"
strokeWidth=".472"
strokeMiterlimit="10"
/>
<path
d="M147.107 117.091c.261 2.609-1.423 4.289-6.054 4.289M76.927 53.472v8.797a4.977 4.977 0 0 1-4.98 4.976h-7.17c-3.4 0-8.497 2.757-8.497 6.157"
stroke="#B0B2E7"
strokeWidth="1.415"
strokeMiterlimit="10"
/>
<path
d="M79.734 53.472v8.797a4.977 4.977 0 0 1-4.98 4.976h-7.17c-3.4 0-8.497 2.757-8.497 6.157"
stroke="#B0B2E7"
strokeWidth="1.415"
strokeMiterlimit="10"
/>
<path
d="M82.307 53.472v8.797a4.977 4.977 0 0 1-4.98 4.976h-7.17c-3.4 0-8.497 2.757-8.497 6.157"
stroke="#B0B2E7"
strokeWidth="1.415"
strokeMiterlimit="10"
/>
<path
d="m140.211 121.8-4.319-.002a9.98 9.98 0 0 1-9.984-9.98v-3.566a3.564 3.564 0 0 0-3.56-3.562h-.234"
stroke="#B0B2E7"
strokeWidth="1.415"
strokeMiterlimit="10"
strokeLinecap="round"
/>
<path
d="M157.707 86.156h-.708.708Zm-7.198-7.195v.707-.707Zm-28.391 29.291-.707-.001v.001h.707Zm-3.56-4.269a.707.707 0 1 0 0 1.415v-1.415Zm39.856-16.604v-1.223h-1.415v1.223h1.415Zm0-1.223a7.903 7.903 0 0 0-2.315-5.588l-1.001 1a6.487 6.487 0 0 1 1.901 4.588h1.415Zm-2.315-5.588a7.908 7.908 0 0 0-5.59-2.315v1.415c1.721 0 3.372.684 4.589 1.9l1.001-1Zm-5.59-2.315a7.905 7.905 0 0 0-5.59 2.315l1 1a6.492 6.492 0 0 1 4.59-1.9v-1.415Zm-5.59 2.315a7.9 7.9 0 0 0-2.316 5.588h1.415c0-1.72.684-3.37 1.901-4.587l-1-1.001Zm-2.316 5.588v30.6h1.415v-30.6h-1.415Zm0 30.6a5.033 5.033 0 0 1-1.476 3.561l1.001 1.001a6.452 6.452 0 0 0 1.89-4.562h-1.415Zm-1.476 3.561a5.039 5.039 0 0 1-3.562 1.475v1.415c1.711 0 3.353-.68 4.563-1.889l-1.001-1.001Zm-3.562 1.475h-5.462v1.415h5.462v-1.415Zm-5.462 0a9.279 9.279 0 0 1-6.56-2.716l-1 1.001a10.694 10.694 0 0 0 7.56 3.13v-1.415Zm-6.56-2.716a9.27 9.27 0 0 1-2.717-6.557h-1.415c0 2.835 1.126 5.553 3.132 7.558l1-1.001Zm-2.717-6.557v-4.267h-1.415v4.267h1.415Zm0-4.267c0-.561-.11-1.116-.324-1.634l-1.308.541c.144.347.217.717.217 1.092l1.415.001Zm-.324-1.634a4.288 4.288 0 0 0-.925-1.385l-1.001 1.001c.265.265.475.579.618.925l1.308-.541Zm-.925-1.385a4.272 4.272 0 0 0-1.385-.925l-.542 1.307c.346.143.661.354.926.619l1.001-1.001Zm-1.385-.925a4.26 4.26 0 0 0-1.634-.325v1.415c.375 0 .746.074 1.092.217l.542-1.307Z"
fill="#B0B2E7"
/>
<path
d="M153.543 87.379v-1.223a7.194 7.194 0 0 0-7.199-7.195h-.329c-4.492.203-7.57 4.642-6.38 8.977l7.134 25.939c.22.798.331 1.623.331 2.45v.846"
stroke="#B0B2E7"
strokeWidth="1.415"
strokeMiterlimit="10"
/>
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M0 0h240v126H0z" />
</clipPath>
</defs>
</svg>
);
};
export default CollectingResults;

View file

@ -4,6 +4,7 @@ import ArrowInternalLink from "./ArrowInternalLink";
import CalendarCheck from "./CalendarCheck";
import Check from "./Check";
import Chevron from "./Chevron";
import CollectingResults from "./CollectingResults";
import Columns from "./Columns";
import CriticalPolicy from "./CriticalPolicy";
import Disable from "./Disable";
@ -84,6 +85,7 @@ export const ICON_MAP = {
"calendar-check": CalendarCheck,
chevron: Chevron,
check: Check,
"collecting-results": CollectingResults,
columns: Columns,
"critical-policy": CriticalPolicy,
disable: Disable,

View file

@ -1,19 +1,18 @@
import React from "react";
import PhoneHome from "../../../../../assets/images/phone-home.svg";
import EmptyTable from "components/EmptyTable/EmptyTable";
const baseClass = "awaiting-results";
const AwaitingResults = () => {
return (
<div className={baseClass}>
<img src={PhoneHome} alt="awaiting results" />
<span className={`${baseClass}__title`}>Phoning home...</span>
<p className={`${baseClass}__description`}>
There are currently no results to your query. Please wait while we talk
to more hosts.
</p>
</div>
<EmptyTable
iconName="collecting-results"
header="Phoning home..."
info=" There are currently no results to your query. Please wait while we talk
to more hosts."
className={baseClass}
/>
);
};

View file

@ -5,19 +5,4 @@
flex-direction: column;
align-items: center;
text-align: center;
img {
margin-bottom: $pad-medium;
}
&__title {
font-size: $small;
font-weight: $bold;
margin-bottom: $pad-small;
}
&__description {
font-size: $x-small;
margin: 0;
}
}

View file

@ -6,6 +6,12 @@ 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";
import {
DEFAULT_TARGETS,
DEFAULT_TARGETS_BY_TYPE,
ISelectedTargetsByType,
ITarget,
} from "interfaces/target";
type Props = {
children: ReactNode;
@ -22,6 +28,9 @@ type InitialStateType = {
lastEditedQueryPlatforms: SelectedPlatformString;
lastEditedQueryMinOsqueryVersion: string;
lastEditedQueryLoggingType: QueryLoggingOption;
lastEditedQueryDiscardData: boolean;
selectedQueryTargets: ITarget[]; // Mimicks old selectedQueryTargets still used for policies for SelectTargets.tsx and running a live query
selectedQueryTargetsByType: ISelectedTargetsByType; // New format by type for cleaner app wide state
setLastEditedQueryId: (value: number | null) => void;
setLastEditedQueryName: (value: string) => void;
setLastEditedQueryDescription: (value: string) => void;
@ -31,7 +40,10 @@ type InitialStateType = {
setLastEditedQueryPlatforms: (value: SelectedPlatformString) => void;
setLastEditedQueryMinOsqueryVersion: (value: string) => void;
setLastEditedQueryLoggingType: (value: string) => void;
setLastEditedQueryDiscardData: (value: boolean) => void;
setSelectedOsqueryTable: (tableName: string) => void;
setSelectedQueryTargets: (value: ITarget[]) => void;
setSelectedQueryTargetsByType: (value: ISelectedTargetsByType) => void;
};
export type IQueryContext = InitialStateType;
@ -48,6 +60,9 @@ const initialState = {
lastEditedQueryPlatforms: DEFAULT_QUERY.platform,
lastEditedQueryMinOsqueryVersion: DEFAULT_QUERY.min_osquery_version,
lastEditedQueryLoggingType: DEFAULT_QUERY.logging,
lastEditedQueryDiscardData: DEFAULT_QUERY.discard_data,
selectedQueryTargets: DEFAULT_TARGETS,
selectedQueryTargetsByType: DEFAULT_TARGETS_BY_TYPE,
setLastEditedQueryId: () => null,
setLastEditedQueryName: () => null,
setLastEditedQueryDescription: () => null,
@ -57,12 +72,17 @@ const initialState = {
setLastEditedQueryPlatforms: () => null,
setLastEditedQueryMinOsqueryVersion: () => null,
setLastEditedQueryLoggingType: () => null,
setLastEditedQueryDiscardData: () => null,
setSelectedOsqueryTable: () => null,
setSelectedQueryTargets: () => null,
setSelectedQueryTargetsByType: () => null,
};
const actions = {
SET_SELECTED_OSQUERY_TABLE: "SET_SELECTED_OSQUERY_TABLE",
SET_LAST_EDITED_QUERY_INFO: "SET_LAST_EDITED_QUERY_INFO",
SET_SELECTED_QUERY_TARGETS: "SET_SELECTED_QUERY_TARGETS",
SET_SELECTED_QUERY_TARGETS_BY_TYPE: "SET_SELECTED_QUERY_TARGETS_BY_TYPE",
} as const;
const reducer = (state: InitialStateType, action: any) => {
@ -113,6 +133,26 @@ const reducer = (state: InitialStateType, action: any) => {
typeof action.lastEditedQueryLoggingType === "undefined"
? state.lastEditedQueryLoggingType
: action.lastEditedQueryLoggingType,
lastEditedQueryDiscardData:
typeof action.lastEditedQueryDiscardData === "undefined"
? state.lastEditedQueryDiscardData
: action.lastEditedQueryDiscardData,
};
case actions.SET_SELECTED_QUERY_TARGETS:
return {
...state,
selectedQueryTargets:
typeof action.selectedQueryTargets === "undefined"
? state.selectedQueryTargets
: action.selectedQueryTargets,
};
case actions.SET_SELECTED_QUERY_TARGETS_BY_TYPE:
return {
...state,
selectedQueryTargetsByType:
typeof action.selectedQueryTargetsByType === "undefined"
? state.selectedQueryTargetsByType
: action.selectedQueryTargetsByType,
};
default:
return state;
@ -135,6 +175,9 @@ const QueryProvider = ({ children }: Props) => {
lastEditedQueryPlatforms: state.lastEditedQueryPlatforms,
lastEditedQueryMinOsqueryVersion: state.lastEditedQueryMinOsqueryVersion,
lastEditedQueryLoggingType: state.lastEditedQueryLoggingType,
lastEditedQueryDiscardData: state.lastEditedQueryDiscardData,
selectedQueryTargets: state.selectedQueryTargets,
selectedQueryTargetsByType: state.selectedQueryTargetsByType,
setLastEditedQueryId: (lastEditedQueryId: number | null) => {
dispatch({
type: actions.SET_LAST_EDITED_QUERY_INFO,
@ -193,6 +236,26 @@ const QueryProvider = ({ children }: Props) => {
lastEditedQueryLoggingType,
});
},
setLastEditedQueryDiscardData: (lastEditedQueryDiscardData: boolean) => {
dispatch({
type: actions.SET_LAST_EDITED_QUERY_INFO,
lastEditedQueryDiscardData,
});
},
setSelectedQueryTargets: (selectedQueryTargets: ITarget[]) => {
dispatch({
type: actions.SET_SELECTED_QUERY_TARGETS,
selectedQueryTargets,
});
},
setSelectedQueryTargetsByType: (
selectedQueryTargetsByType: ISelectedTargetsByType
) => {
dispatch({
type: actions.SET_SELECTED_QUERY_TARGETS_BY_TYPE,
selectedQueryTargetsByType,
});
},
setSelectedOsqueryTable: (tableName: string) => {
dispatch({ type: actions.SET_SELECTED_OSQUERY_TABLE, tableName });
},

View file

@ -4,7 +4,7 @@ import { filter, uniqueId } from "lodash";
import { IHost } from "interfaces/host";
import { ILabel } from "interfaces/label";
import { ITeam } from "interfaces/team";
import { ISelectedTargets } from "interfaces/target";
import { ISelectedTargetsForApi } from "interfaces/target";
import targetsAPI from "services/entities/targets";
export interface ITargetsLabels {
@ -25,7 +25,7 @@ export interface ITargetsQueryKey {
scope: string;
query: string;
queryId: number | null;
selected: ISelectedTargets;
selected: ISelectedTargetsForApi;
includeLabels: boolean;
}

View file

@ -113,6 +113,7 @@ export interface IConfig {
live_query_disabled: boolean;
enable_analytics: boolean;
deferred_save_host: boolean;
query_reports_disabled: boolean;
};
smtp_settings: {
enable_smtp: boolean;

View file

@ -3,7 +3,7 @@ import { IPack } from "./pack";
import { ISchedulableQuery } from "./schedulable_query";
import { IScheduledQueryStats } from "./scheduled_query_stats";
export interface IQueryFormData {
export interface IEditQueryFormData {
description?: string | number | boolean | undefined;
name?: string | number | boolean | undefined;
query?: string | number | boolean | undefined;
@ -35,7 +35,7 @@ export interface IQuery {
stats?: IScheduledQueryStats;
}
export interface IQueryFormFields {
export interface IEditQueryFormFields {
description: IFormField;
name: IFormField;
query: IFormField;

View file

@ -0,0 +1,12 @@
export interface IQueryReportResultRow {
host_id: number;
host_name: string;
last_fetched: string;
columns: any;
}
// Query report
export interface IQueryReport {
query_id: number;
results: IQueryReportResultRow[];
}

View file

@ -21,6 +21,7 @@ export interface ISchedulableQuery {
author_name: string;
author_email: string;
observer_can_run: boolean;
discard_data: boolean;
packs: IPack[];
stats: ISchedulableQueryStats;
}
@ -62,6 +63,7 @@ export interface ICreateQueryRequestBody {
query: string;
description?: string;
observer_can_run?: boolean;
discard_data?: boolean;
team_id?: number; // global query if ommitted
interval?: number; // default 0 means never run
platform?: SelectedPlatformString; // Might more accurately be called `platforms_to_query` comma-sepparated string of platforms to query, default all platforms if ommitted
@ -81,6 +83,7 @@ export interface IModifyQueryRequestBody
query?: string;
description?: string;
observer_can_run?: boolean;
discard_data?: boolean;
frequency?: number;
platform?: SelectedPlatformString;
min_osquery_version?: string;
@ -108,11 +111,12 @@ export interface IDeleteQueriesResponse {
deleted: number; // number of queries deleted
}
export interface IQueryFormFields {
export interface IEditQueryFormFields {
name: IFormField<string>;
description: IFormField<string>;
query: IFormField<string>;
observer_can_run: IFormField<boolean>;
discard_data: IFormField<boolean>;
frequency: IFormField<number>;
platforms: IFormField<SelectedPlatformString>;
min_osquery_version: IFormField<string>;

View file

@ -38,14 +38,29 @@ export interface ISelectTeam extends ITeam {
export type ISelectTargetsEntity = ISelectHost | ISelectLabel | ISelectTeam;
export interface ISelectedTargets {
export interface ISelectedTargetsForApi {
hosts: number[];
labels: number[];
teams: number[];
}
export interface ISelectedTargetsByType {
hosts: IHost[];
labels: ILabel[];
teams: ITeam[];
}
export interface IPackTargets {
host_ids: (number | string)[];
label_ids: (number | string)[];
team_ids: (number | string)[];
}
// TODO: Also use for testing
export const DEFAULT_TARGETS: ITarget[] = [];
export const DEFAULT_TARGETS_BY_TYPE: ISelectedTargetsByType = {
hosts: [],
labels: [],
teams: [],
};

View file

@ -18,7 +18,7 @@ const Advanced = ({
handleSubmit,
isUpdatingSettings,
}: IAppConfigFormProps): JSX.Element => {
const [formData, setFormData] = useState<any>({
const [formData, setFormData] = useState({
domain: appConfig.smtp_settings.domain || "",
verifySSLCerts: appConfig.smtp_settings.verify_ssl_certs || false,
enableStartTLS: appConfig.smtp_settings.enable_start_tls,
@ -26,6 +26,8 @@ const Advanced = ({
appConfig.host_expiry_settings.host_expiry_enabled || false,
hostExpiryWindow: appConfig.host_expiry_settings.host_expiry_window || 0,
disableLiveQuery: appConfig.server_settings.live_query_disabled || false,
disableQueryReports:
appConfig.server_settings.query_reports_disabled || false,
});
const {
@ -35,6 +37,7 @@ const Advanced = ({
enableHostExpiry,
hostExpiryWindow,
disableLiveQuery,
disableQueryReports,
} = formData;
const [formErrors, setFormErrors] = useState<IAppConfigFormErrors>({});
@ -69,6 +72,7 @@ const Advanced = ({
server_url: appConfig.server_settings.server_url || "",
live_query_disabled: disableLiveQuery,
enable_analytics: appConfig.server_settings.enable_analytics,
query_reports_disabled: disableQueryReports,
},
smtp_settings: {
enable_smtp: appConfig.smtp_settings.enable_smtp || false,
@ -172,6 +176,24 @@ const Advanced = ({
>
Disable live queries
</Checkbox>
<Checkbox
onChange={handleInputChange}
name="disableQueryReports"
value={disableQueryReports}
parseTarget
// TODO - update to JSX once tooltip wrapper refactor is merged
// TODO - once refactor is merged, have this and bove tooltips disappear more
// quickly to get out of users' way
tooltip={
'<p>Disabling query reports will decrease database usage, <br />\
but will prevent you from accessing query results in<br /> \
Fleet and will delete existing reports. This can also be<br />\
disabled on a per-query basis by enabling "Discard <br />\
data". <em>(Default: <b>Off</b>)</em></p>'
}
>
Disable query reports
</Checkbox>
</div>
</div>
</div>

View file

@ -13,6 +13,7 @@ import queryAPI from "services/entities/queries";
import teamAPI, { ILoadTeamsResponse } from "services/entities/teams";
import { AppContext } from "context/app";
import { PolicyContext } from "context/policy";
import { QueryContext } from "context/query";
import { NotificationContext } from "context/notification";
import {
IHost,
@ -26,6 +27,7 @@ import { ILabel } from "interfaces/label";
import { IHostPolicy } from "interfaces/policy";
import { IQueryStats } from "interfaces/query_stats";
import { ISoftware } from "interfaces/software";
import { DEFAULT_TARGETS_BY_TYPE } from "interfaces/target";
import { ITeam } from "interfaces/team";
import {
IListQueriesResponse,
@ -39,8 +41,13 @@ import MainContent from "components/MainContent";
import InfoBanner from "components/InfoBanner";
import BackLink from "components/BackLink";
import { normalizeEmptyValues, wrapFleetHelper } from "utilities/helpers";
import {
normalizeEmptyValues,
wrapFleetHelper,
TAGGED_TEMPLATES,
} from "utilities/helpers";
import permissions from "utilities/permissions";
import { DEFAULT_QUERY } from "utilities/constants";
import HostSummaryCard from "../cards/HostSummary";
import AboutCard from "../cards/About";
@ -101,12 +108,6 @@ interface IHostDetailsSubNavItem {
pathname: string;
}
const TAGGED_TEMPLATES = {
queryByHostRoute: (hostId: number | undefined | null) => {
return `${hostId ? `?host_ids=${hostId}` : ""}`;
},
};
const HostDetailsPage = ({
route,
router,
@ -137,6 +138,7 @@ const HostDetailsPage = ({
setLastEditedQueryCritical,
setPolicyTeamId,
} = useContext(PolicyContext);
const { setSelectedQueryTargetsByType } = useContext(QueryContext);
const { renderFlash } = useContext(NotificationContext);
const handlePageError = useErrorHandler();
@ -523,12 +525,15 @@ const HostDetailsPage = ({
};
const onQueryHostCustom = () => {
setLastEditedQueryBody(DEFAULT_QUERY.query);
setSelectedQueryTargetsByType(DEFAULT_TARGETS_BY_TYPE);
router.push(
PATHS.NEW_QUERY() + TAGGED_TEMPLATES.queryByHostRoute(host?.id)
);
};
const onQueryHostSaved = (selectedQuery: ISchedulableQuery) => {
setSelectedQueryTargetsByType(DEFAULT_TARGETS_BY_TYPE);
router.push(
PATHS.EDIT_QUERY(selectedQuery.id) +
TAGGED_TEMPLATES.queryByHostRoute(host?.id)

View file

@ -19,7 +19,7 @@ import globalPoliciesAPI from "services/entities/global_policies";
import teamPoliciesAPI from "services/entities/team_policies";
import hostAPI from "services/entities/hosts";
import statusAPI from "services/entities/status";
import { QUERIES_PAGE_STEPS } from "utilities/constants";
import { LIVE_POLICY_STEPS } from "utilities/constants";
import QuerySidePanel from "components/side_panels/QuerySidePanel";
import QueryEditor from "pages/policies/PolicyPage/screens/QueryEditor";
@ -127,7 +127,7 @@ const PolicyPage = ({
};
}, []);
const [step, setStep] = useState(QUERIES_PAGE_STEPS[1]);
const [step, setStep] = useState(LIVE_POLICY_STEPS[1]);
const [selectedTargets, setSelectedTargets] = useState<ITarget[]>([]);
const [targetedHosts, setTargetedHosts] = useState<IHost[]>([]);
const [targetedLabels, setTargetedLabels] = useState<ILabel[]>([]);
@ -260,7 +260,7 @@ const PolicyPage = ({
storedPolicyError,
createPolicy,
onOsqueryTableSelect,
goToSelectTargets: () => setStep(QUERIES_PAGE_STEPS[2]),
goToSelectTargets: () => setStep(LIVE_POLICY_STEPS[2]),
onOpenSchemaSidebar,
renderLiveQueryWarning,
};
@ -272,8 +272,8 @@ const PolicyPage = ({
targetedLabels,
targetedTeams,
targetsTotalCount,
goToQueryEditor: () => setStep(QUERIES_PAGE_STEPS[1]),
goToRunQuery: () => setStep(QUERIES_PAGE_STEPS[3]),
goToQueryEditor: () => setStep(LIVE_POLICY_STEPS[1]),
goToRunQuery: () => setStep(LIVE_POLICY_STEPS[3]),
setSelectedTargets,
setTargetedHosts,
setTargetedLabels,
@ -285,21 +285,21 @@ const PolicyPage = ({
selectedTargets,
storedPolicy,
setSelectedTargets,
goToQueryEditor: () => setStep(QUERIES_PAGE_STEPS[1]),
goToQueryEditor: () => setStep(LIVE_POLICY_STEPS[1]),
targetsTotalCount,
};
switch (step) {
case QUERIES_PAGE_STEPS[2]:
case LIVE_POLICY_STEPS[2]:
return <SelectTargets {...step2Opts} />;
case QUERIES_PAGE_STEPS[3]:
case LIVE_POLICY_STEPS[3]:
return <RunQuery {...step3Opts} />;
default:
return <QueryEditor {...step1Opts} />;
}
};
const isFirstStep = step === QUERIES_PAGE_STEPS[1];
const isFirstStep = step === LIVE_POLICY_STEPS[1];
const showSidebar =
isFirstStep &&
isSidebarOpen &&

View file

@ -34,33 +34,6 @@
}
}
&__observer-query-details {
padding: 0 2rem;
h1 {
margin: $pad-large 0;
font-size: $large;
}
p {
margin-bottom: $pad-small;
}
.sql-button {
color: $core-vibrant-blue;
font-weight: $bold;
font-size: $x-small;
}
}
&__query-preview {
margin-top: 15px;
.fleet-ace__label {
display: none;
}
}
.ace_content {
min-height: 500px !important;
}
@ -177,9 +150,4 @@
margin-bottom: 0;
}
}
.targets-input {
.input-icon-field__icon {
top: 34px; // Override styling to include label header
}
}
}

View file

@ -173,7 +173,7 @@ const QueryEditor = ({
return null;
}
// Function instead of constant eliminates race condition with filteredSoftwarePath
// Function instead of constant eliminates race condition with filteredPoliciesPath
const backToPoliciesPath = () => {
return filteredPoliciesPath || PATHS.MANAGE_POLICIES;
};

View file

@ -10,6 +10,7 @@ import { useQuery } from "react-query";
import { pick } from "lodash";
import { AppContext } from "context/app";
import { QueryContext } from "context/query";
import { TableContext } from "context/table";
import { NotificationContext } from "context/notification";
import { performanceIndicator } from "utilities/helpers";
@ -20,8 +21,10 @@ import {
IQueryKeyQueriesLoadAll,
ISchedulableQuery,
} from "interfaces/schedulable_query";
import { DEFAULT_TARGETS_BY_TYPE } from "interfaces/target";
import queriesAPI from "services/entities/queries";
import PATHS from "router/paths";
import { DEFAULT_QUERY } from "utilities/constants";
import { checkPlatformCompatibility } from "utilities/sql_tools";
import Button from "components/buttons/Button";
import Spinner from "components/Spinner";
@ -87,6 +90,9 @@ const ManageQueriesPage = ({
isSandboxMode,
config,
} = useContext(AppContext);
const { setLastEditedQueryBody, setSelectedQueryTargetsByType } = useContext(
QueryContext
);
const { setResetSelectedRows } = useContext(TableContext);
const { renderFlash } = useContext(NotificationContext);
@ -178,7 +184,15 @@ const ManageQueriesPage = ({
}
}, [location, filteredQueriesPath, setFilteredQueriesPath]);
const onCreateQueryClick = () => router.push(PATHS.NEW_QUERY(currentTeamId));
// Reset selected targets when returned to this page
useEffect(() => {
setSelectedQueryTargetsByType(DEFAULT_TARGETS_BY_TYPE);
}, []);
const onCreateQueryClick = () => {
setLastEditedQueryBody(DEFAULT_QUERY.query);
router.push(PATHS.NEW_QUERY(currentTeamId));
};
const toggleDeleteQueryModal = useCallback(() => {
setShowDeleteQueryModal(!showDeleteQueryModal);

View file

@ -152,7 +152,7 @@ const generateTableHeaders = ({
)}
</>
}
path={PATHS.EDIT_QUERY(
path={PATHS.QUERY(
cellProps.row.original.id,
cellProps.row.original.team_id ?? undefined
)}
@ -183,10 +183,7 @@ const generateTableHeaders = ({
<TextCell
value={val}
emptyCellTooltipText={
<>
Assign a frequency and turn <strong>automations</strong> on to
collect data at an interval.
</>
<>Assign a frequency to collect data at an interval.</>
}
/>
);

View file

@ -1,293 +0,0 @@
import React, { useState, useEffect, useContext, useCallback } from "react";
import { useQuery, useMutation } from "react-query";
import { useErrorHandler } from "react-error-boundary";
import { InjectedRouter, Params } from "react-router/lib/Router";
import { AppContext } from "context/app";
import { QueryContext } from "context/query";
import { QUERIES_PAGE_STEPS, DEFAULT_QUERY } from "utilities/constants";
import queryAPI from "services/entities/queries";
import hostAPI from "services/entities/hosts";
import statusAPI from "services/entities/status";
import { IHost, IHostResponse } from "interfaces/host";
import { ILabel } from "interfaces/label";
import { ITeam } from "interfaces/team";
import {
IGetQueryResponse,
ISchedulableQuery,
} from "interfaces/schedulable_query";
import { ITarget } from "interfaces/target";
import QuerySidePanel from "components/side_panels/QuerySidePanel";
import MainContent from "components/MainContent";
import SidePanelContent from "components/SidePanelContent";
import SelectTargets from "components/LiveQuery/SelectTargets";
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: {
pathname: string;
query: { host_ids: string; team_id?: string };
search: string;
};
}
const baseClass = "query-page";
const QueryPage = ({
router,
params: { id: paramsQueryId },
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 {
isGlobalAdmin,
isGlobalMaintainer,
isAnyTeamMaintainerOrTeamAdmin,
isObserverPlus,
isAnyTeamObserverPlus,
} = useContext(AppContext);
const {
selectedOsqueryTable,
setSelectedOsqueryTable,
setLastEditedQueryId,
setLastEditedQueryName,
setLastEditedQueryDescription,
setLastEditedQueryBody,
setLastEditedQueryObserverCanRun,
setLastEditedQueryFrequency,
setLastEditedQueryLoggingType,
setLastEditedQueryMinOsqueryVersion,
setLastEditedQueryPlatforms,
} = useContext(QueryContext);
const [queryParamHostsAdded, setQueryParamHostsAdded] = useState(false);
const [step, setStep] = useState(QUERIES_PAGE_STEPS[1]);
const [selectedTargets, setSelectedTargets] = useState<ITarget[]>([]);
const [targetedHosts, setTargetedHosts] = useState<IHost[]>([]);
const [targetedLabels, setTargetedLabels] = useState<ILabel[]>([]);
const [targetedTeams, setTargetedTeams] = useState<ITeam[]>([]);
const [targetsTotalCount, setTargetsTotalCount] = useState(0);
const [isLiveQueryRunnable, setIsLiveQueryRunnable] = useState(true);
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [showOpenSchemaActionText, setShowOpenSchemaActionText] = useState(
false
);
// disabled on page load so we can control the number of renders
// else it will re-populate the context on occasion
const {
isLoading: isStoredQueryLoading,
data: storedQuery,
error: storedQueryError,
} = useQuery<IGetQueryResponse, Error, ISchedulableQuery>(
["query", queryId],
() => queryAPI.load(queryId as number),
{
enabled: !!queryId,
refetchOnWindowFocus: false,
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),
}
);
useQuery<IHostResponse, Error, IHost>(
"hostFromURL",
() =>
hostAPI.loadHostDetails(parseInt(location.query.host_ids as string, 10)),
{
enabled: !!location.query.host_ids && !queryParamHostsAdded,
select: (data: IHostResponse) => data.host,
onSuccess: (host) => {
setTargetedHosts((prevHosts) =>
prevHosts.filter((h) => h.id !== host.id).concat(host)
);
const targets = selectedTargets;
host.target_type = "hosts";
targets.push(host);
setSelectedTargets([...targets]);
if (!queryParamHostsAdded) {
setQueryParamHostsAdded(true);
}
router.replace(location.pathname);
},
}
);
const detectIsFleetQueryRunnable = () => {
statusAPI.live_query().catch(() => {
setIsLiveQueryRunnable(false);
});
};
useEffect(() => {
detectIsFleetQueryRunnable();
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]);
// Updates title that shows up on browser tabs
useEffect(() => {
// e.g., Query details | Discover TLS certificates | Fleet for osquery
document.title = `Query details | ${storedQuery?.name} | Fleet for osquery`;
}, [location.pathname, storedQuery?.name]);
useEffect(() => {
setShowOpenSchemaActionText(!isSidebarOpen);
}, [isSidebarOpen]);
const onOsqueryTableSelect = (tableName: string) => {
setSelectedOsqueryTable(tableName);
};
const onCloseSchemaSidebar = () => {
setIsSidebarOpen(false);
};
const onOpenSchemaSidebar = () => {
setIsSidebarOpen(true);
};
const renderLiveQueryWarning = (): JSX.Element | null => {
if (isLiveQueryRunnable) {
return null;
}
return (
<div className={`${baseClass}__warning`}>
<div className={`${baseClass}__message`}>
<p>
Fleet is unable to run a live query. Refresh the page or log in
again. If this keeps happening please{" "}
<CustomLink
url="https://github.com/fleetdm/fleet/issues/new/choose"
text="file an issue"
newTab
/>
</p>
</div>
</div>
);
};
const goToQueryEditor = useCallback(() => setStep(QUERIES_PAGE_STEPS[1]), []);
const renderScreen = () => {
const step1Props = {
router,
baseClass,
queryIdForEdit: queryId,
teamNameForQuery,
apiTeamIdForQuery,
showOpenSchemaActionText,
storedQuery,
isStoredQueryLoading,
storedQueryError,
onOsqueryTableSelect,
goToSelectTargets: () => setStep(QUERIES_PAGE_STEPS[2]),
onOpenSchemaSidebar,
renderLiveQueryWarning,
};
const step2Props = {
baseClass,
queryId,
selectedTargets,
targetedHosts,
targetedLabels,
targetedTeams,
targetsTotalCount,
goToQueryEditor: () => setStep(QUERIES_PAGE_STEPS[1]),
goToRunQuery: () => setStep(QUERIES_PAGE_STEPS[3]),
setSelectedTargets,
setTargetedHosts,
setTargetedLabels,
setTargetedTeams,
setTargetsTotalCount,
};
const step3Props = {
queryId,
selectedTargets,
storedQuery,
setSelectedTargets,
goToQueryEditor,
targetsTotalCount,
};
switch (step) {
case QUERIES_PAGE_STEPS[2]:
return <SelectTargets {...step2Props} />;
case QUERIES_PAGE_STEPS[3]:
return <RunQuery {...step3Props} />;
default:
return <QueryEditor {...step1Props} />;
}
};
const isFirstStep = step === QUERIES_PAGE_STEPS[1];
const showSidebar =
isFirstStep &&
isSidebarOpen &&
(isGlobalAdmin ||
isGlobalMaintainer ||
isAnyTeamMaintainerOrTeamAdmin ||
isObserverPlus ||
isAnyTeamObserverPlus);
return (
<>
<MainContent className={baseClass}>
<div className={`${baseClass}_wrapper`}>{renderScreen()}</div>
</MainContent>
{showSidebar && (
<SidePanelContent>
<QuerySidePanel
onOsqueryTableSelect={onOsqueryTableSelect}
selectedOsqueryTable={selectedOsqueryTable}
onClose={onCloseSchemaSidebar}
/>
</SidePanelContent>
)}
</>
);
};
export default QueryPage;

View file

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

View file

@ -1,32 +0,0 @@
.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

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

View file

@ -1,186 +0,0 @@
import React, { useContext, useEffect, useState } from "react";
import { InjectedRouter } from "react-router/lib/Router";
import { UseMutateAsyncFunction } from "react-query";
import queryAPI from "services/entities/queries";
import { AppContext } from "context/app";
import { QueryContext } from "context/query";
import { NotificationContext } from "context/notification";
import {
ICreateQueryRequestBody,
ISchedulableQuery,
} from "interfaces/schedulable_query";
import PATHS from "router/paths";
import debounce from "utilities/debounce";
import deepDifference from "utilities/deep_difference";
import BackLink from "components/BackLink";
import QueryForm from "pages/queries/QueryPage/components/QueryForm";
interface IQueryEditorProps {
router: InjectedRouter;
baseClass: string;
queryIdForEdit: number | null;
teamNameForQuery?: string;
apiTeamIdForQuery?: number;
storedQuery: ISchedulableQuery | undefined;
storedQueryError: Error | null;
showOpenSchemaActionText: boolean;
isStoredQueryLoading: boolean;
onOsqueryTableSelect: (tableName: string) => void;
goToSelectTargets: () => void;
onOpenSchemaSidebar: () => void;
renderLiveQueryWarning: () => JSX.Element | null;
}
const QueryEditor = ({
router,
baseClass,
queryIdForEdit,
teamNameForQuery,
apiTeamIdForQuery,
storedQuery,
storedQueryError,
showOpenSchemaActionText,
isStoredQueryLoading,
onOsqueryTableSelect,
goToSelectTargets,
onOpenSchemaSidebar,
renderLiveQueryWarning,
}: IQueryEditorProps): JSX.Element | null => {
const { currentUser, filteredQueriesPath } = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
// Note: The QueryContext values should always be used for any mutable query data such as query name
// The storedQuery prop should only be used to access immutable metadata such as author id
const {
lastEditedQueryName,
lastEditedQueryDescription,
lastEditedQueryBody,
lastEditedQueryObserverCanRun,
lastEditedQueryFrequency,
lastEditedQueryLoggingType,
lastEditedQueryPlatforms,
lastEditedQueryMinOsqueryVersion,
} = useContext(QueryContext);
const [isQuerySaving, setIsQuerySaving] = useState(false);
const [isQueryUpdating, setIsQueryUpdating] = useState(false);
useEffect(() => {
if (storedQueryError) {
renderFlash(
"error",
"Something went wrong retrieving your query. Please try again."
);
}
}, []);
const [backendValidators, setBackendValidators] = useState<{
[key: string]: string;
}>({});
const saveQuery = debounce(async (formData: ICreateQueryRequestBody) => {
setIsQuerySaving(true);
try {
const { query } = await queryAPI.create(formData);
router.push(PATHS.EDIT_QUERY(query.id));
renderFlash("success", "Query created!");
setBackendValidators({});
} catch (createError: any) {
if (createError.data.errors[0].reason.includes("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: ICreateQueryRequestBody) => {
if (!queryIdForEdit) {
return false;
}
setIsQueryUpdating(true);
const updatedQuery = deepDifference(formData, {
lastEditedQueryName,
lastEditedQueryDescription,
lastEditedQueryBody,
lastEditedQueryObserverCanRun,
lastEditedQueryFrequency,
lastEditedQueryPlatforms,
lastEditedQueryLoggingType,
lastEditedQueryMinOsqueryVersion,
});
try {
await queryAPI.update(queryIdForEdit, updatedQuery);
renderFlash("success", "Query updated!");
} catch (updateError: any) {
console.error(updateError);
if (updateError.data.errors[0].reason.includes("Duplicate")) {
renderFlash("error", "A query with this name already exists.");
} else {
renderFlash(
"error",
"Something went wrong updating your query. Please try again."
);
}
}
setIsQueryUpdating(false);
return false;
};
if (!currentUser) {
return null;
}
// Function instead of constant eliminates race condition with filteredSoftwarePath
const backToQueriesPath = () => {
return filteredQueriesPath || PATHS.MANAGE_QUERIES;
};
return (
<div className={`${baseClass}__form`}>
<div className={`${baseClass}__header-links`}>
<BackLink text="Back to queries" path={backToQueriesPath()} />
</div>
<QueryForm
router={router}
saveQuery={saveQuery}
goToSelectTargets={goToSelectTargets}
onOsqueryTableSelect={onOsqueryTableSelect}
onUpdate={onUpdateQuery}
storedQuery={storedQuery}
queryIdForEdit={queryIdForEdit}
apiTeamIdForQuery={apiTeamIdForQuery}
teamNameForQuery={teamNameForQuery}
isStoredQueryLoading={isStoredQueryLoading}
showOpenSchemaActionText={showOpenSchemaActionText}
onOpenSchemaSidebar={onOpenSchemaSidebar}
renderLiveQueryWarning={renderLiveQueryWarning}
backendValidators={backendValidators}
isQuerySaving={isQuerySaving}
isQueryUpdating={isQueryUpdating}
/>
</div>
);
};
export default QueryEditor;

View file

@ -0,0 +1,298 @@
import React, { useContext } from "react";
import { useQuery } from "react-query";
import { InjectedRouter, Params } from "react-router/lib/Router";
import { useErrorHandler } from "react-error-boundary";
import PATHS from "router/paths";
import { AppContext } from "context/app";
import { QueryContext } from "context/query";
import {
IGetQueryResponse,
ISchedulableQuery,
} from "interfaces/schedulable_query";
import { IQueryReport } from "interfaces/query_report";
import queryAPI from "services/entities/queries";
import queryReportAPI, { ISortOption } from "services/entities/query_report";
import Spinner from "components/Spinner/Spinner";
import Button from "components/buttons/Button";
import BackLink from "components/BackLink";
import MainContent from "components/MainContent";
import TooltipWrapper from "components/TooltipWrapper/TooltipWrapper";
import QueryAutomationsStatusIndicator from "pages/queries/ManageQueriesPage/components/QueryAutomationsStatusIndicator/QueryAutomationsStatusIndicator";
import DataError from "components/DataError/DataError";
import LogDestinationIndicator from "components/LogDestinationIndicator/LogDestinationIndicator";
import CustomLink from "components/CustomLink";
import InfoBanner from "components/InfoBanner";
import QueryReport from "../components/QueryReport/QueryReport";
import NoResults from "../components/NoResults/NoResults";
import {
DEFAULT_SORT_HEADER,
DEFAULT_SORT_DIRECTION,
QUERY_REPORT_RESULTS_LIMIT,
} from "./QueryDetailsPageConfig";
interface IQueryDetailsPageProps {
router: InjectedRouter; // v3
params: Params;
location: {
pathname: string;
query: { team_id?: string; order_key?: string; order_direction?: string };
search: string;
};
}
const baseClass = "query-details-page";
const QueryDetailsPage = ({
router,
params: { id: paramsQueryId },
location,
}: IQueryDetailsPageProps): JSX.Element => {
const queryId = parseInt(paramsQueryId, 10);
const queryParams = location.query;
// Functions to avoid race conditions
const serverSortBy: ISortOption[] = (() => {
return [
{
key: queryParams?.order_key ?? DEFAULT_SORT_HEADER,
direction: queryParams?.order_direction ?? DEFAULT_SORT_DIRECTION,
},
];
})();
const handlePageError = useErrorHandler();
const {
isGlobalAdmin,
isGlobalMaintainer,
isAnyTeamMaintainerOrTeamAdmin,
isObserverPlus,
isAnyTeamObserverPlus,
config,
filteredQueriesPath,
} = useContext(AppContext);
const {
lastEditedQueryName,
lastEditedQueryDescription,
lastEditedQueryObserverCanRun,
setLastEditedQueryId,
setLastEditedQueryName,
setLastEditedQueryDescription,
setLastEditedQueryBody,
setLastEditedQueryObserverCanRun,
setLastEditedQueryFrequency,
setLastEditedQueryLoggingType,
setLastEditedQueryMinOsqueryVersion,
setLastEditedQueryPlatforms,
setLastEditedQueryDiscardData,
} = useContext(QueryContext);
// Title that shows up on browser tabs (e.g., Query details | Discover TLS certificates | Fleet for osquery)
document.title = `Query details | ${lastEditedQueryName} | Fleet for osquery`;
// disabled on page load so we can control the number of renders
// else it will re-populate the context on occasion
const {
isLoading: isStoredQueryLoading,
data: storedQuery,
error: storedQueryError,
} = useQuery<IGetQueryResponse, Error, ISchedulableQuery>(
["query", queryId],
() => queryAPI.load(queryId),
{
enabled: !!queryId,
refetchOnWindowFocus: false,
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);
setLastEditedQueryDiscardData(returnedQuery.discard_data);
},
onError: (error) => handlePageError(error),
}
);
const {
isLoading: isQueryReportLoading,
data: queryReport,
error: queryReportError,
} = useQuery<IQueryReport, Error, IQueryReport>(
[],
() =>
queryReportAPI.load({
sortBy: serverSortBy,
id: queryId,
}),
{
enabled: !!queryId,
refetchOnWindowFocus: false,
onError: (error) => handlePageError(error),
}
);
const isLoading = isStoredQueryLoading || isQueryReportLoading;
const isApiError = storedQueryError || queryReportError;
const isClipped =
(queryReport?.results?.length ?? 0) >= QUERY_REPORT_RESULTS_LIMIT;
const renderHeader = () => {
const canEditQuery =
isGlobalAdmin || isGlobalMaintainer || isAnyTeamMaintainerOrTeamAdmin;
// Function instead of constant eliminates race condition with filteredQueriesPath
const backToQueriesPath = () => {
return filteredQueriesPath || PATHS.MANAGE_QUERIES;
};
return (
<>
<div className={`${baseClass}__header-links`}>
<BackLink text="Back to queries" path={backToQueriesPath()} />
</div>
<div className={`${baseClass}__header-details`}>
{!isLoading && !isApiError && (
<div className={`${baseClass}__title-bar`}>
<div className="name-description">
<h1 className={`${baseClass}__query-name`}>
{lastEditedQueryName}
</h1>
<p className={`${baseClass}__query-description`}>
{lastEditedQueryDescription}
</p>
</div>
<div className={`${baseClass}__action-button-container`}>
{canEditQuery && (
<Button
onClick={() => {
queryId && router.push(PATHS.EDIT_QUERY(queryId));
}}
className={`${baseClass}__manage-automations button`}
variant="brand"
>
Edit query
</Button>
)}
{(lastEditedQueryObserverCanRun ||
isObserverPlus ||
isAnyTeamObserverPlus ||
canEditQuery) && (
<div
className={`${baseClass}__button-wrap ${baseClass}__button-wrap--new-query`}
>
<Button
className={`${baseClass}__run`}
variant="blue-green"
onClick={() => {
queryId && router.push(PATHS.LIVE_QUERY(queryId));
}}
>
Live query
</Button>
</div>
)}
</div>
</div>
)}
{!isLoading && !isApiError && (
<div className={`${baseClass}__settings`}>
<div className={`${baseClass}__automations`}>
<TooltipWrapper
tipContent={`Query automations let you send data to your log destination on a schedule. When automations are <b>on</b>, data is sent according to a querys frequency.`}
>
Automations:
</TooltipWrapper>
<QueryAutomationsStatusIndicator
automationsEnabled={storedQuery?.automations_enabled || false}
interval={storedQuery?.interval || 0}
/>
</div>
<div className={`${baseClass}__log-destination`}>
<strong>Log destination:</strong>{" "}
<LogDestinationIndicator
logDestination={config?.logging.result.plugin || ""}
/>
</div>
</div>
)}
</div>
</>
);
};
const renderClippedBanner = () => (
<InfoBanner
color="yellow"
cta={
<CustomLink
url="https://www.fleetdm.com/support"
text="Get help"
newTab
/>
}
>
<div>
<b>Report clipped.</b> A sample of this query&apos;s results is included
below. You can still use query automations to complete this report in
your log destination.
</div>
</InfoBanner>
);
const renderReport = () => {
const disabledCachingGlobally =
config?.server_settings.query_reports_disabled || true;
const discardDataEnabled = storedQuery?.discard_data || true;
const loggingSnapshot = storedQuery?.logging === "snapshot";
const disabledCaching =
disabledCachingGlobally || discardDataEnabled || !loggingSnapshot;
const emptyCache = (queryReport?.results?.length ?? 0) === 0;
// Loading state
if (isLoading) {
return <Spinner />;
}
// Error state
if (isApiError) {
return <DataError />;
}
// Empty state with varying messages explaining why there's no results
if (emptyCache) {
return (
<NoResults
queryInterval={storedQuery?.interval}
queryUpdatedAt={storedQuery?.updated_at}
disabledCaching={disabledCaching}
disabledCachingGlobally={disabledCachingGlobally}
discardDataEnabled={discardDataEnabled}
loggingSnapshot={loggingSnapshot}
/>
);
}
return <QueryReport queryReport={queryReport} isClipped={isClipped} />;
};
return (
<MainContent className={baseClass}>
<div className={`${baseClass}__wrapper`}>
{renderHeader()}
{isClipped && renderClippedBanner()}
{renderReport()}
</div>
</MainContent>
);
};
export default QueryDetailsPage;

View file

@ -0,0 +1,15 @@
// TODO
export const QUERY_DETAILS_PAGE_FILTER_KEYS = ["model", "vendor"] as const;
// TODO: refactor to use this type as the location.query prop of the page
export type QueryDetailsPageQueryParams = Record<
| "order_key"
| "order_direction"
| typeof QUERY_DETAILS_PAGE_FILTER_KEYS[number],
string
>;
export const DEFAULT_SORT_HEADER = "host_name";
export const DEFAULT_SORT_DIRECTION = "asc";
export const QUERY_REPORT_RESULTS_LIMIT = 1000;

View file

@ -0,0 +1,79 @@
.query-details-page {
&__wrapper {
display: flex;
flex-direction: column;
gap: $pad-large;
}
&__title-bar {
display: flex;
justify-content: space-between;
margin-top: $pad-small;
.name-description {
display: flex;
flex-direction: column;
gap: $pad-small;
}
}
&__action-button-container {
display: flex;
justify-content: flex-end;
min-width: 266px;
gap: $pad-medium;
}
&__query-name {
margin-top: 0;
font-size: $large;
}
&__query-description {
margin-top: 0;
margin-bottom: $pad-small;
font-size: $x-small;
}
&__settings {
display: flex;
gap: $pad-large;
font-size: $x-small;
// TODO - remove once refactored tooltip wrapper is merged
.component__tooltip-wrapper__element__underline::after {
bottom: 0px;
}
}
&__automations,
&__log-destination {
display: flex;
gap: $pad-small;
align-items: center;
.component__tooltip-wrapper__element {
font-weight: $bold;
}
}
.empty-table__inner {
.component__tooltip-wrapper__tip-text {
text-align: left;
width: 320px;
}
ul {
color: $core-white;
li {
&::before {
content: "";
color: $core-white;
}
}
}
}
.data-error {
padding-top: $pad-xxxlarge;
}
}

View file

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

View file

@ -0,0 +1,115 @@
import React from "react";
import differenceInSeconds from "date-fns/differenceInSeconds";
import formatDistance from "date-fns/formatDistance";
import add from "date-fns/add";
import TooltipWrapper from "components/TooltipWrapper/TooltipWrapper";
import EmptyTable from "components/EmptyTable/EmptyTable";
interface INoResultsProps {
queryInterval?: number;
queryUpdatedAt?: string;
disabledCaching: boolean;
disabledCachingGlobally: boolean;
discardDataEnabled: boolean;
loggingSnapshot: boolean;
}
const baseClass = "no-results";
const NoResults = ({
queryInterval,
queryUpdatedAt,
disabledCaching,
disabledCachingGlobally,
discardDataEnabled,
loggingSnapshot,
}: INoResultsProps): JSX.Element => {
// Returns how many seconds it takes to expect a cached update
const secondsCheckbackTime = () => {
const secondsSinceUpdate = queryUpdatedAt
? differenceInSeconds(new Date(), new Date(queryUpdatedAt))
: 0;
const secondsUpdateWaittime = (queryInterval || 0) + 60;
return secondsUpdateWaittime - secondsSinceUpdate;
};
// Update status of collecting cached results
const collectingResults = secondsCheckbackTime() > 0;
// Converts seconds takes to update to human readable format
const readableCheckbackTime = formatDistance(
add(new Date(), { seconds: secondsCheckbackTime() }),
new Date()
);
// Collecting results state
if (collectingResults) {
const collectingResultsInfo = () =>
`Fleet is collecting query results. Check back in about ${readableCheckbackTime}.`;
return (
<EmptyTable
iconName="collecting-results"
header={"Collecting results..."}
info={collectingResultsInfo()}
/>
);
}
const noResultsInfo = () => {
if (!queryInterval) {
return (
<>
This query does not collect data on a schedule. Add a{" "}
<strong>frequency</strong> or run this as a live query to see results.
</>
);
}
if (disabledCaching) {
const tipContent = () => {
if (disabledCachingGlobally) {
return "The following setting prevents saving this query's results in Fleet:<ul><li>Query reports are globally disabled in organization settings.</li></ul>";
}
if (discardDataEnabled) {
return "The following setting prevents saving this query's results in Fleet:<ul><li>This query has Discard data enabled.</li></ul>";
}
if (!loggingSnapshot) {
return "The following setting prevents saving this query's results in Fleet:<ul><li>The logging setting for this query is not Snapshot.</li></ul>";
}
return "Unknown";
};
return (
<>
Results from this query are{" "}
<TooltipWrapper tipContent={tipContent()}>
not reported in Fleet
</TooltipWrapper>
.
</>
);
}
// No errors will be reported in V1
// if (errorsOnly) {
// return (
// <>
// This query had trouble collecting data on some hosts. Check out the{" "}
// <strong>Errors</strong> tab to see why.
// </>
// );
// }
return "This query has returned no data so far.";
};
return (
<EmptyTable
className={baseClass}
iconName="empty-software"
header={"Nothing to report yet"}
info={noResultsInfo()}
/>
);
};
export default NoResults;

View file

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

View file

@ -0,0 +1,172 @@
import React, { useState, useContext, useEffect, useCallback } from "react";
import { Row, Column } from "react-table";
import FileSaver from "file-saver";
import { QueryContext } from "context/query";
import {
generateCSVFilename,
generateCSVQueryResults,
} from "utilities/generate_csv";
import { IQueryReport, IQueryReportResultRow } from "interfaces/query_report";
import Button from "components/buttons/Button";
import Icon from "components/Icon/Icon";
import TableContainer from "components/TableContainer";
import ShowQueryModal from "components/modals/ShowQueryModal";
import TooltipWrapper from "components/TooltipWrapper";
import generateResultsTableHeaders from "./QueryReportTableConfig";
interface IQueryReportProps {
queryReport?: IQueryReport;
isClipped?: boolean;
}
const baseClass = "query-report";
const CSV_TITLE = "Query";
const tableResults = (results: IQueryReportResultRow[]) => {
return results.map((result: IQueryReportResultRow) => {
const hostInfoColumns = {
host_display_name: result.host_name,
last_fetched: result.last_fetched,
};
// hostInfoColumns displays the host metadata that is returned with every query
// result.columns are the variable columns returned by the API that differ per query
return { ...hostInfoColumns, ...result.columns };
});
};
const QueryReport = ({
queryReport,
isClipped,
}: IQueryReportProps): JSX.Element => {
const { lastEditedQueryName, lastEditedQueryBody } = useContext(QueryContext);
const [showQueryModal, setShowQueryModal] = useState(false);
const [filteredResults, setFilteredResults] = useState<Row[]>(
tableResults(queryReport?.results || [])
);
const [tableHeaders, setTableHeaders] = useState<Column[]>([]);
useEffect(() => {
if (queryReport && queryReport.results && queryReport.results.length > 0) {
const generatedTableHeaders = generateResultsTableHeaders(
tableResults(queryReport.results)
);
// Update tableHeaders if new headers are found
if (generatedTableHeaders !== tableHeaders) {
setTableHeaders(generatedTableHeaders);
}
}
}, [queryReport]); // Cannot use tableHeaders as it will cause infinite loop with setTableHeaders
const onExportQueryResults = (evt: React.MouseEvent<HTMLButtonElement>) => {
evt.preventDefault();
FileSaver.saveAs(
generateCSVQueryResults(
filteredResults,
generateCSVFilename(
`${lastEditedQueryName || CSV_TITLE} - Query Report`
),
tableHeaders
)
);
};
const onShowQueryModal = () => {
setShowQueryModal(!showQueryModal);
};
const renderNoResults = () => {
return <p className="no-results-message">TODO</p>;
};
const renderTableButtons = () => {
return (
<div className={`${baseClass}__results-cta`}>
<Button
className={`${baseClass}__show-query-btn`}
onClick={onShowQueryModal}
variant="text-icon"
>
<>
Show query <Icon name="eye" />
</>
</Button>
<Button
className={`${baseClass}__export-btn`}
onClick={onExportQueryResults}
variant="text-icon"
>
<>
Export results
<Icon name="download" color="core-fleet-blue" />
</>
</Button>
</div>
);
};
const renderResultsCount = useCallback(() => {
const count = filteredResults.length;
if (isClipped) {
return (
<div className={`${baseClass}__count `}>
<TooltipWrapper
tipContent={`Fleet has retained a sample of early results for
reference. Reporting is paused until existing data is deleted. <br/><br/>
You can reset this report by updating the query's SQL, or by
temporarily enabling the <b>discard data</b> setting and disabling it again.`}
>
{`${count} result${count === 1 ? "" : "s"}`}
</TooltipWrapper>
</div>
);
}
return (
<div className={`${baseClass}__count `}>
<span>{`${count} result${count === 1 ? "" : "s"}`}</span>
</div>
);
}, [filteredResults.length, isClipped]);
const renderTable = () => {
return (
<div className={`${baseClass}__results-table-container`}>
<TableContainer
columns={tableHeaders}
data={tableResults(queryReport?.results || [])}
emptyComponent={renderNoResults}
isLoading={false}
isClientSidePagination
isClientSideFilter
isMultiColumnFilter
showMarkAllPages={false}
isAllPagesSelected={false}
resultsTitle="results"
customControl={() => renderTableButtons()}
setExportRows={setFilteredResults}
renderCount={renderResultsCount}
/>
</div>
);
};
return (
<div className={`${baseClass}__wrapper`}>
{renderTable()}
{showQueryModal && (
<ShowQueryModal
query={lastEditedQueryBody}
onCancel={onShowQueryModal}
/>
)}
</div>
);
};
export default QueryReport;

View file

@ -0,0 +1,93 @@
/* eslint-disable react/prop-types */
// disable this rule as it was throwing an error in Header and Cell component
// definitions for the selection row for some reason when we dont really need it.
import React from "react";
import {
CellProps,
Column,
ColumnInstance,
ColumnInterface,
HeaderProps,
TableInstance,
} from "react-table";
import DefaultColumnFilter from "components/TableContainer/DataTable/DefaultColumnFilter";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell";
import { humanHostLastSeen } from "utilities/helpers";
type IHeaderProps = HeaderProps<TableInstance> & {
column: ColumnInstance & IDataColumn;
};
type ICellProps = CellProps<TableInstance>;
interface IDataColumn extends ColumnInterface {
title?: string;
accessor: string;
}
const _unshiftHostname = (headers: IDataColumn[]) => {
const newHeaders = [...headers];
const displayNameIndex = headers.findIndex(
(h) => h.id === "host_display_name"
);
if (displayNameIndex >= 0) {
// remove hostname header from headers
const [displayNameHeader] = newHeaders.splice(displayNameIndex, 1);
// reformat title and insert at start of headers array
newHeaders.unshift({ ...displayNameHeader, title: "Host" });
}
// TODO: Remove after v5 when host_hostname is removed rom API response.
const hostNameIndex = headers.findIndex((h) => h.id === "host_hostname");
if (hostNameIndex >= 0) {
newHeaders.splice(hostNameIndex, 1);
}
// end remove
return newHeaders;
};
const generateResultsTableHeaders = (results: any[]): Column[] => {
/* Results include an array of objects, each representing a table row
Each key value pair in an object represents a column name and value
To create headers, use JS set to create an array of all unique column names */
const uniqueColumnNames = Array.from(
results.reduce(
(s, o) => Object.keys(o).reduce((t, k) => t.add(k), s),
new Set() // Set prevents listing duplicate headers
)
);
const headers = uniqueColumnNames.map((key) => {
return {
id: key as string,
title: key as string,
Header: (headerProps: IHeaderProps) => (
<HeaderCell
value={
// Sentence case last fetched
headerProps.column.title === "last_fetched"
? "Last fetched"
: headerProps.column.title || headerProps.column.id
}
isSortedDesc={headerProps.column.isSortedDesc}
/>
),
accessor: key as string,
Cell: (cellProps: ICellProps) => {
// Sorts chronologically by date, but UI displays readable last fetched
if (cellProps.column.id === "last_fetched") {
return humanHostLastSeen(cellProps?.cell?.value);
}
return cellProps?.cell?.value || null;
},
Filter: DefaultColumnFilter, // Component hides filter for last_fetched
filterType: "text",
disableSortBy: false,
};
});
return _unshiftHostname(headers);
};
export default generateResultsTableHeaders;

View file

@ -0,0 +1,22 @@
.query-report {
&__wrapper {
.host_id__header {
width: 95px; // Min width for 6 digits host IDs
}
.last_fetched__header {
.column-header {
margin-bottom: 44px; // Fills space where filter is removed
}
}
.data-table__wrapper {
overflow-x: scroll;
}
}
&__results-cta {
display: flex;
gap: $pad-medium;
}
}

View file

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

View file

@ -0,0 +1,351 @@
import React, { useState, useEffect, useContext } from "react";
import { useQuery } from "react-query";
import { useErrorHandler } from "react-error-boundary";
import { InjectedRouter, Params } from "react-router/lib/Router";
import { AppContext } from "context/app";
import { QueryContext } from "context/query";
import { DEFAULT_QUERY } from "utilities/constants";
import configAPI from "services/entities/config";
import queryAPI from "services/entities/queries";
import statusAPI from "services/entities/status";
import {
IGetQueryResponse,
ICreateQueryRequestBody,
ISchedulableQuery,
} from "interfaces/schedulable_query";
import QuerySidePanel from "components/side_panels/QuerySidePanel";
import MainContent from "components/MainContent";
import SidePanelContent from "components/SidePanelContent";
import CustomLink from "components/CustomLink";
import useTeamIdParam from "hooks/useTeamIdParam";
import { NotificationContext } from "context/notification";
import PATHS from "router/paths";
import debounce from "utilities/debounce";
import deepDifference from "utilities/deep_difference";
import BackLink from "components/BackLink";
import EditQueryForm from "pages/queries/edit/components/EditQueryForm";
import { IConfig } from "interfaces/config";
interface IEditQueryPageProps {
router: InjectedRouter;
params: Params;
location: {
pathname: string;
query: { host_ids: string; team_id?: string };
search: string;
};
}
const baseClass = "edit-query-page";
const EditQueryPage = ({
router,
params: { id: paramsQueryId },
location,
}: IEditQueryPageProps): 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 {
isGlobalAdmin,
isGlobalMaintainer,
isAnyTeamMaintainerOrTeamAdmin,
isObserverPlus,
isAnyTeamObserverPlus,
} = useContext(AppContext);
const {
selectedOsqueryTable,
setSelectedOsqueryTable,
lastEditedQueryName,
lastEditedQueryDescription,
lastEditedQueryBody,
lastEditedQueryObserverCanRun,
lastEditedQueryFrequency,
lastEditedQueryPlatforms,
lastEditedQueryLoggingType,
lastEditedQueryMinOsqueryVersion,
lastEditedQueryDiscardData,
setLastEditedQueryId,
setLastEditedQueryName,
setLastEditedQueryDescription,
setLastEditedQueryBody,
setLastEditedQueryObserverCanRun,
setLastEditedQueryFrequency,
setLastEditedQueryLoggingType,
setLastEditedQueryMinOsqueryVersion,
setLastEditedQueryPlatforms,
setLastEditedQueryDiscardData,
} = useContext(QueryContext);
const { setConfig } = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
const [isLiveQueryRunnable, setIsLiveQueryRunnable] = useState(true);
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [showOpenSchemaActionText, setShowOpenSchemaActionText] = useState(
false
);
const [
showConfirmSaveChangesModal,
setShowConfirmSaveChangesModal,
] = useState(false);
const { data: appConfig } = useQuery<IConfig, Error, IConfig>(
["config"],
() => configAPI.loadAll(),
{
select: (data: IConfig) => data,
onSuccess: (data) => {
setConfig(data);
},
}
);
// disabled on page load so we can control the number of renders
// else it will re-populate the context on occasion
const {
isLoading: isStoredQueryLoading,
data: storedQuery,
refetch: refetchStoredQuery,
} = useQuery<IGetQueryResponse, Error, ISchedulableQuery>(
["query", queryId],
() => queryAPI.load(queryId as number),
{
enabled: !!queryId,
refetchOnWindowFocus: false,
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);
setLastEditedQueryDiscardData(returnedQuery.discard_data);
},
onError: (error) => handlePageError(error),
}
);
const detectIsFleetQueryRunnable = () => {
statusAPI.live_query().catch(() => {
setIsLiveQueryRunnable(false);
});
};
useEffect(() => {
detectIsFleetQueryRunnable();
if (!queryId) {
setLastEditedQueryId(DEFAULT_QUERY.id);
setLastEditedQueryName(DEFAULT_QUERY.name);
setLastEditedQueryDescription(DEFAULT_QUERY.description);
// Persist lastEditedQueryBody through live query flow instead of resetting to 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);
setLastEditedQueryDiscardData(DEFAULT_QUERY.discard_data);
}
}, [queryId]);
const [isQuerySaving, setIsQuerySaving] = useState(false);
const [isQueryUpdating, setIsQueryUpdating] = useState(false);
const [backendValidators, setBackendValidators] = useState<{
[key: string]: string;
}>({});
// Updates title that shows up on browser tabs
useEffect(() => {
// e.g., Query details | Discover TLS certificates | Fleet for osquery
document.title = `Edit query | ${storedQuery?.name} | Fleet for osquery`;
}, [location.pathname, storedQuery?.name]);
useEffect(() => {
setShowOpenSchemaActionText(!isSidebarOpen);
}, [isSidebarOpen]);
const saveQuery = debounce(async (formData: ICreateQueryRequestBody) => {
setIsQuerySaving(true);
try {
const { query } = await queryAPI.create(formData);
router.push(PATHS.EDIT_QUERY(query.id));
renderFlash("success", "Query created!");
setBackendValidators({});
} catch (createError: any) {
if (createError.data.errors[0].reason.includes("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: ICreateQueryRequestBody) => {
if (!queryId) {
return false;
}
setIsQueryUpdating(true);
const updatedQuery = deepDifference(formData, {
lastEditedQueryName,
lastEditedQueryDescription,
lastEditedQueryBody,
lastEditedQueryObserverCanRun,
lastEditedQueryFrequency,
lastEditedQueryPlatforms,
lastEditedQueryLoggingType,
lastEditedQueryMinOsqueryVersion,
lastEditedQueryDiscardData,
});
try {
await queryAPI.update(queryId, updatedQuery);
renderFlash("success", "Query updated!");
refetchStoredQuery(); // Required to compare recently saved query to a subsequent save to the query
} catch (updateError: any) {
console.error(updateError);
if (updateError.data.errors[0].reason.includes("Duplicate")) {
renderFlash("error", "A query with this name already exists.");
} else {
renderFlash(
"error",
"Something went wrong updating your query. Please try again."
);
}
}
setIsQueryUpdating(false);
setShowConfirmSaveChangesModal(false); // Closes conditionally opened modal when discarding previous results
return false;
};
const onOsqueryTableSelect = (tableName: string) => {
setSelectedOsqueryTable(tableName);
};
const onCloseSchemaSidebar = () => {
setIsSidebarOpen(false);
};
const onOpenSchemaSidebar = () => {
setIsSidebarOpen(true);
};
const renderLiveQueryWarning = (): JSX.Element | null => {
if (isLiveQueryRunnable) {
return null;
}
return (
<div className={`${baseClass}__warning`}>
<div className={`${baseClass}__message`}>
<p>
Fleet is unable to run a live query. Refresh the page or log in
again. If this keeps happening please{" "}
<CustomLink
url="https://github.com/fleetdm/fleet/issues/new/choose"
text="file an issue"
newTab
/>
</p>
</div>
</div>
);
};
// Function instead of constant eliminates race condition
const backToQueriesPath = () => {
return queryId ? PATHS.QUERY(queryId) : PATHS.MANAGE_QUERIES;
};
const showSidebar =
isSidebarOpen &&
(isGlobalAdmin ||
isGlobalMaintainer ||
isAnyTeamMaintainerOrTeamAdmin ||
isObserverPlus ||
isAnyTeamObserverPlus);
return (
<>
<MainContent className={baseClass}>
<div className={`${baseClass}_wrapper`}>
<div className={`${baseClass}__form`}>
<div className={`${baseClass}__header-links`}>
<BackLink
text={queryId ? "Back to report" : "Back to queries"}
path={backToQueriesPath()}
/>
</div>
<EditQueryForm
router={router}
saveQuery={saveQuery}
onOsqueryTableSelect={onOsqueryTableSelect}
onUpdate={onUpdateQuery}
storedQuery={storedQuery}
queryIdForEdit={queryId}
apiTeamIdForQuery={apiTeamIdForQuery}
teamNameForQuery={teamNameForQuery}
isStoredQueryLoading={isStoredQueryLoading}
showOpenSchemaActionText={showOpenSchemaActionText}
onOpenSchemaSidebar={onOpenSchemaSidebar}
renderLiveQueryWarning={renderLiveQueryWarning}
backendValidators={backendValidators}
isQuerySaving={isQuerySaving}
isQueryUpdating={isQueryUpdating}
hostId={parseInt(location.query.host_ids as string, 10)}
queryReportsDisabled={
appConfig?.server_settings.query_reports_disabled
}
showConfirmSaveChangesModal={showConfirmSaveChangesModal}
setShowConfirmSaveChangesModal={setShowConfirmSaveChangesModal}
/>
</div>
</div>
</MainContent>
{showSidebar && (
<SidePanelContent>
<QuerySidePanel
onOsqueryTableSelect={onOsqueryTableSelect}
selectedOsqueryTable={selectedOsqueryTable}
onClose={onCloseSchemaSidebar}
/>
</SidePanelContent>
)}
</>
);
};
export default EditQueryPage;

View file

@ -0,0 +1,86 @@
.edit-query-page {
.help-text {
margin-top: $pad-small;
margin-bottom: $pad-large;
font-weight: $regular;
font-size: $xx-small;
color: $ui-fleet-black-75;
}
.fleet-checkbox {
display: flex;
align-items: center;
}
.form-field {
&--frequency {
margin-bottom: 0;
}
&--platform {
margin-bottom: 0;
margin-top: $pad-large;
}
}
.advanced-options-toggle {
font-weight: $xbold;
}
.observer-can-run-wrapper {
margin-bottom: 0;
font-weight: bold;
}
.body-wrap {
min-width: 0;
}
&__warning {
padding: $pad-medium;
font-size: $x-small;
color: $core-fleet-black;
background-color: #fff0b9;
border: 1px solid #f2c94c;
border-radius: $border-radius;
margin: 0;
margin-top: $pad-large;
p {
margin: 0;
line-height: 20px;
}
}
.ace_content {
min-height: 500px !important;
}
&__count-spinner {
margin-right: $pad-small;
}
&__page-loading {
.loading-spinner {
margin: $pad-large 0 0;
}
}
&__page-error {
h4 {
margin: 0;
margin-top: 28px;
margin-left: -7px;
font-size: $small;
img {
transform: scale(0.5);
vertical-align: middle;
position: relative;
top: -2px;
}
}
p {
margin: 0;
margin-top: $pad-medium;
font-size: $x-small;
}
}
}

View file

@ -0,0 +1,49 @@
import React from "react";
import Button from "components/buttons/Button";
import Modal from "components/Modal";
const baseClass = "save-changes-modal";
export interface IConfirmSaveChangesModalProps {
isUpdating: boolean;
onSaveChanges: (evt: React.MouseEvent<HTMLButtonElement>) => void;
onClose: () => void;
showChangedSQLCopy?: boolean;
}
const ConfirmSaveChangesModal = ({
isUpdating,
onSaveChanges,
onClose,
showChangedSQLCopy = false,
}: IConfirmSaveChangesModalProps) => {
const warningText = showChangedSQLCopy
? "Changing this query's SQL will delete its previous results, since the existing report does not reflect the updated query."
: "The changes you are making to this query will delete its previous results.";
return (
<Modal title={"Save changes?"} onExit={onClose}>
<form className={`${baseClass}__form`}>
<p>{warningText}</p>
<p>You cannot undo this action.</p>
<div className="modal-cta-wrap">
<Button
type="button"
onClick={onSaveChanges}
variant="brand"
className="save-loading"
isLoading={isUpdating}
>
Save
</Button>
<Button onClick={onClose} variant="inverse">
Cancel
</Button>
</div>
</form>
</Modal>
);
};
export default ConfirmSaveChangesModal;

View file

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

View file

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

View file

@ -0,0 +1,86 @@
import React from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import DiscardDataOption from "./DiscardDataOption";
describe("DiscardDataOption component", () => {
const selectedLoggingType = "snapshot";
const [discardData, setDiscardData] = [false, jest.fn()];
it("Renders normal help text when the global option is not disabled", () => {
render(
<DiscardDataOption
queryReportsDisabled={false}
{...{ selectedLoggingType, discardData, setDiscardData }}
/>
);
expect(screen.getByText(/Discard data/)).toBeInTheDocument();
expect(screen.getByText(/Data will still be sent/)).toBeInTheDocument();
});
it('Renders the "disabled" help text with tooltip when the global option is disabled', async () => {
render(
<DiscardDataOption
queryReportsDisabled
{...{ selectedLoggingType, discardData, setDiscardData }}
/>
);
expect(screen.getByText(/Discard data/)).toBeInTheDocument();
expect(screen.getByText(/This setting is ignored/)).toBeInTheDocument();
await fireEvent.mouseOver(screen.getByText(/globally disabled/));
expect(screen.getByText(/A Fleet administrator/)).toBeInTheDocument();
});
it('Restores normal help text when disabled and then "Edit anyway" is clicked', async () => {
render(
<DiscardDataOption
queryReportsDisabled
{...{ selectedLoggingType, discardData, setDiscardData }}
/>
);
// disabled
expect(screen.getByText(/Discard data/)).toBeInTheDocument();
expect(screen.getByText(/This setting is ignored/)).toBeInTheDocument();
// enable
await fireEvent.click(screen.getByText(/Edit anyway/));
// normal text
expect(screen.getByText(/Data will still be sent/)).toBeInTheDocument();
});
it('Renders the info banner when "Differential" logging option is selected', () => {
render(
<DiscardDataOption
selectedLoggingType="differential"
queryReportsDisabled={false}
{...{ discardData, setDiscardData }}
/>
);
expect(
screen.getByText(
/setting is ignored when differential logging is enabled. This/
)
).toBeInTheDocument();
});
it('Renders the info banner when "Differential (ignore removals)" logging option is selected', () => {
render(
<DiscardDataOption
selectedLoggingType="differential_ignore_removals"
queryReportsDisabled={false}
{...{ discardData, setDiscardData }}
/>
);
expect(
screen.getByText(
/setting is ignored when differential logging is enabled. This/
)
).toBeInTheDocument();
});
});

View file

@ -0,0 +1,103 @@
import Checkbox from "components/forms/fields/Checkbox";
import Icon from "components/Icon";
import InfoBanner from "components/InfoBanner";
import TooltipWrapper from "components/TooltipWrapper";
import { QueryLoggingOption } from "interfaces/schedulable_query";
import React, { useState } from "react";
import { Link } from "react-router";
const baseClass = "discard-data-option";
interface IDiscardDataOptionProps {
queryReportsDisabled: boolean;
selectedLoggingType: QueryLoggingOption;
discardData: boolean;
setDiscardData: (value: boolean) => void;
breakHelpText?: boolean;
}
const DiscardDataOption = ({
queryReportsDisabled,
selectedLoggingType,
discardData,
setDiscardData,
breakHelpText = false,
}: IDiscardDataOptionProps) => {
const [forceEditDiscardData, setForceEditDiscardData] = useState(false);
const disable = queryReportsDisabled && !forceEditDiscardData;
const renderHelpText = () => (
<div className="help-text">
{disable ? (
<>
This setting is ignored because query reports in Fleet have been{" "}
<TooltipWrapper
// TODO - use JSX once new tooltipwrapper is merged
tipContent={
"A Fleet administrator can enable query reports under <br />\
<b>Organization settings > Advanced options > Disable query reports</b>."
}
position="bottom"
>
{"globally disabled."}
</TooltipWrapper>{" "}
<Link
to={""}
onClick={(e: React.MouseEvent) => {
e.preventDefault();
setForceEditDiscardData(true);
}}
className={`${baseClass}__edit-anyway`}
>
<>
Edit anyway
<Icon
name="chevron"
direction="right"
color="core-fleet-blue"
size="small"
/>
</>
</Link>
</>
) : (
<>
The most recent results for each host will not be available in Fleet.
{breakHelpText ? <br /> : " "}
Data will still be sent to your log destination if <b>
automations
</b>{" "}
are <b>on</b>.
</>
)}
</div>
);
return (
<div className={baseClass}>
{["differential", "differential_ignore_removals"].includes(
selectedLoggingType
) && (
<InfoBanner color="purple-bold-border">
<>
The <b>Discard data</b> setting is ignored when differential logging
is enabled. This <br />
query&apos;s results will not be saved in Fleet.
</>
</InfoBanner>
)}
<Checkbox
name="discardData"
onChange={setDiscardData}
value={discardData}
wrapperClassName={
disable ? `${baseClass}__disabled-discard-data-checkbox` : ""
}
>
<b>Discard data</b>
</Checkbox>
{renderHelpText()}
</div>
);
};
export default DiscardDataOption;

View file

@ -0,0 +1,20 @@
.discard-data-option {
.info-banner {
margin-bottom: 1.5rem;
&__info {
line-height: 21px;
}
}
&__disabled-discard-data-checkbox {
@include disabled;
}
&__edit-anyway {
display: inline-flex;
align-items: center;
cursor: pointer;
font-weight: inherit;
font-size: inherit;
}
}

View file

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

View file

@ -5,7 +5,7 @@ import { createCustomRenderer } from "test/test-utils";
import createMockQuery from "__mocks__/queryMock";
import createMockUser from "__mocks__/userMock";
import QueryForm from "./QueryForm";
import EditQueryForm from "./EditQueryForm";
const mockQuery = createMockQuery();
const mockRouter = {
@ -20,7 +20,7 @@ const mockRouter = {
createPath: jest.fn(),
};
describe("QueryForm - component", () => {
describe("EditQueryForm - component", () => {
it("disables save button for missing query name", async () => {
const render = createCustomRenderer({
context: {
@ -56,7 +56,7 @@ describe("QueryForm - component", () => {
});
render(
<QueryForm
<EditQueryForm
router={mockRouter}
queryIdForEdit={1}
apiTeamIdForQuery={1}
@ -68,11 +68,12 @@ describe("QueryForm - component", () => {
isQueryUpdating={false}
saveQuery={jest.fn()}
onOsqueryTableSelect={jest.fn()}
goToSelectTargets={jest.fn()}
onUpdate={jest.fn()}
onOpenSchemaSidebar={jest.fn()}
renderLiveQueryWarning={jest.fn()}
backendValidators={{}}
showConfirmSaveChangesModal={false}
setShowConfirmSaveChangesModal={jest.fn()}
/>
);

View file

@ -15,7 +15,11 @@ import PATHS from "router/paths";
import { AppContext } from "context/app";
import { QueryContext } from "context/query";
import { NotificationContext } from "context/notification";
import { addGravatarUrlToResource, secondsToDhms } from "utilities/helpers";
import {
addGravatarUrlToResource,
secondsToDhms,
TAGGED_TEMPLATES,
} from "utilities/helpers";
import {
FREQUENCY_DROPDOWN_OPTIONS,
SCHEDULE_PLATFORM_DROPDOWN_OPTIONS,
@ -48,10 +52,12 @@ import Spinner from "components/Spinner";
import Icon from "components/Icon/Icon";
import AutoSizeInputField from "components/forms/fields/AutoSizeInputField";
import SaveQueryModal from "../SaveQueryModal";
import ConfirmSaveChangesModal from "../ConfirmSaveChangesModal";
import DiscardDataOption from "../DiscardDataOption";
const baseClass = "query-form";
const baseClass = "edit-query-form";
interface IQueryFormProps {
interface IEditQueryFormProps {
router: InjectedRouter;
queryIdForEdit: number | null;
apiTeamIdForQuery?: number;
@ -63,11 +69,14 @@ interface IQueryFormProps {
isQueryUpdating: boolean;
saveQuery: (formData: ICreateQueryRequestBody) => void;
onOsqueryTableSelect: (tableName: string) => void;
goToSelectTargets: () => void;
onUpdate: (formData: ICreateQueryRequestBody) => void;
onOpenSchemaSidebar: () => void;
renderLiveQueryWarning: () => JSX.Element | null;
backendValidators: { [key: string]: string };
hostId?: number;
queryReportsDisabled?: boolean;
showConfirmSaveChangesModal: boolean;
setShowConfirmSaveChangesModal: (bool: boolean) => void;
}
const validateQuerySQL = (query: string) => {
@ -98,7 +107,7 @@ const customFrequencyOptions = (frequency: number) => {
return FREQUENCY_DROPDOWN_OPTIONS;
};
const QueryForm = ({
const EditQueryForm = ({
router,
queryIdForEdit,
apiTeamIdForQuery,
@ -110,12 +119,15 @@ const QueryForm = ({
isQueryUpdating,
saveQuery,
onOsqueryTableSelect,
goToSelectTargets,
onUpdate,
onOpenSchemaSidebar,
renderLiveQueryWarning,
backendValidators,
}: IQueryFormProps): JSX.Element => {
hostId,
queryReportsDisabled,
showConfirmSaveChangesModal,
setShowConfirmSaveChangesModal,
}: IEditQueryFormProps): JSX.Element => {
// Note: The QueryContext values should always be used for any mutable query data such as query name
// The storedQuery prop should only be used to access immutable metadata such as author id
const {
@ -128,6 +140,7 @@ const QueryForm = ({
lastEditedQueryPlatforms,
lastEditedQueryMinOsqueryVersion,
lastEditedQueryLoggingType,
lastEditedQueryDiscardData,
setLastEditedQueryName,
setLastEditedQueryDescription,
setLastEditedQueryBody,
@ -136,6 +149,7 @@ const QueryForm = ({
setLastEditedQueryPlatforms,
setLastEditedQueryMinOsqueryVersion,
setLastEditedQueryLoggingType,
setLastEditedQueryDiscardData,
} = useContext(QueryContext);
const {
@ -169,9 +183,7 @@ const QueryForm = ({
const { setCompatiblePlatforms } = platformCompatibility;
const debounceSQL = useDebouncedCallback((sql: string) => {
let valid = true;
const { valid: isValidated, errors: newErrors } = validateQuerySQL(sql);
valid = isValidated;
const { errors: newErrors } = validateQuerySQL(sql);
setErrors({
...newErrors,
@ -194,17 +206,14 @@ const QueryForm = ({
}
}, [lastEditedQueryFrequency, isInitialFrequency]);
const hasTeamMaintainerPermissions = savedQueryMode
? isAnyTeamMaintainerOrTeamAdmin &&
storedQuery &&
currentUser &&
storedQuery.author_id === currentUser.id
: isAnyTeamMaintainerOrTeamAdmin;
const toggleSaveQueryModal = () => {
setShowSaveQueryModal(!showSaveQueryModal);
};
const toggleConfirmSaveChangesModal = () => {
setShowConfirmSaveChangesModal(!showConfirmSaveChangesModal);
};
const onLoad = (editor: IAceEditor) => {
editor.setOptions({
enableLinking: true,
@ -399,6 +408,7 @@ const QueryForm = ({
platform: lastEditedQueryPlatforms,
min_osquery_version: lastEditedQueryMinOsqueryVersion,
logging: lastEditedQueryLoggingType,
discard_data: lastEditedQueryDiscardData,
});
}
}
@ -576,7 +586,12 @@ const QueryForm = ({
<Button
className={`${baseClass}__run`}
variant="blue-green"
onClick={goToSelectTargets}
onClick={() => {
router.push(
PATHS.LIVE_QUERY(queryIdForEdit) +
TAGGED_TEMPLATES.queryByHostRoute(hostId)
);
}}
>
Live query
</Button>
@ -587,6 +602,28 @@ const QueryForm = ({
const hasSavePermissions = isGlobalAdmin || isGlobalMaintainer;
const currentlySavingQueryResults =
storedQuery &&
!storedQuery.discard_data &&
!["differential", "differential_ignore_removals"].includes(
storedQuery.logging
);
const changedSQL = storedQuery && lastEditedQueryBody !== storedQuery.query;
const changedLoggingToDifferential = [
"differential",
"differential_ignore_removals",
].includes(lastEditedQueryLoggingType);
const enabledDiscardData =
storedQuery && lastEditedQueryDiscardData && !storedQuery.discard_data;
const confirmChanges =
currentlySavingQueryResults &&
(changedSQL || changedLoggingToDifferential || enabledDiscardData);
const showChangedSQLCopy =
changedSQL && !changedLoggingToDifferential && !enabledDiscardData;
// Global admin, any maintainer, any observer+ on new query
const renderEditableQueryForm = () => {
// Save disabled for team maintainer/admins viewing global queries
@ -618,7 +655,9 @@ const QueryForm = ({
onLoad={onLoad}
wrapperClassName={`${baseClass}__text-editor-wrapper`}
onChange={onChangeQuery}
handleSubmit={promptSaveQuery}
handleSubmit={
confirmChanges ? toggleConfirmSaveChangesModal : promptSaveQuery
}
wrapEnabled
focus={!savedQueryMode}
/>
@ -635,10 +674,11 @@ const QueryForm = ({
placeholder={"Every day"}
value={lastEditedQueryFrequency}
label={"Frequency"}
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--frequency`}
wrapperClassName={`${baseClass}__form-field form-field--frequency`}
/>
If automations are on, this is how often your query collects
data.
<div className="help-text">
This is how often your query collects data.
</div>
</div>
<div className={`${baseClass}__observers-can-run`}>
<Checkbox
@ -646,18 +686,18 @@ const QueryForm = ({
onChange={(value: boolean) =>
setLastEditedQueryObserverCanRun(value)
}
wrapperClassName={`${baseClass}__query-observer-can-run-wrapper`}
wrapperClassName={"observer-can-run-wrapper"}
>
Observers can run
</Checkbox>
<p>
<div className="help-text">
Users with the observer role will be able to run this query on
hosts where they have access.
</p>
</div>
</div>
<RevealButton
isShowing={showAdvancedOptions}
className={baseClass}
className={"advanced-options-toggle"}
hideText={"Hide advanced options"}
showText={"Show advanced options"}
caretPosition={"after"}
@ -672,8 +712,12 @@ const QueryForm = ({
onChange={onChangeSelectPlatformOptions}
value={lastEditedQueryPlatforms}
multi
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--platform`}
wrapperClassName={`${baseClass}__form-field form-field--platform`}
/>
<div className="help-text">
By default, your query collects data on all compatible
platforms.
</div>
<Dropdown
options={MIN_OSQUERY_VERSION_OPTIONS}
onChange={onChangeMinOsqueryVersionOptions}
@ -690,6 +734,14 @@ const QueryForm = ({
label="Logging"
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--logging`}
/>
{queryReportsDisabled !== undefined && (
<DiscardDataOption
selectedLoggingType={lastEditedQueryLoggingType}
discardData={lastEditedQueryDiscardData}
setDiscardData={setLastEditedQueryDiscardData}
queryReportsDisabled={queryReportsDisabled}
/>
)}
</div>
)}
</div>
@ -711,7 +763,7 @@ const QueryForm = ({
Save as new
</Button>
)}
<div className="query-form__button-wrap--save-query-button">
<div className={`${baseClass}__button-wrap--save-query-button`}>
<div
data-tip
data-for="save-query-button"
@ -721,7 +773,11 @@ const QueryForm = ({
<Button
className="save-loading"
variant="brand"
onClick={promptSaveQuery()}
onClick={
confirmChanges
? toggleConfirmSaveChangesModal
: promptSaveQuery()
}
// Button disabled for team maintainer/admins viewing global queries
disabled={
disableSavePermissionDenied || disableSaveFormErrors
@ -750,7 +806,12 @@ const QueryForm = ({
<Button
className={`${baseClass}__run`}
variant="blue-green"
onClick={goToSelectTargets}
onClick={() => {
router.push(
PATHS.LIVE_QUERY(queryIdForEdit) +
TAGGED_TEMPLATES.queryByHostRoute(hostId)
);
}}
>
Live query
</Button>
@ -764,6 +825,15 @@ const QueryForm = ({
toggleSaveQueryModal={toggleSaveQueryModal}
backendValidators={backendValidators}
isLoading={isQuerySaving}
queryReportsDisabled={queryReportsDisabled}
/>
)}
{showConfirmSaveChangesModal && (
<ConfirmSaveChangesModal
onSaveChanges={promptSaveQuery()}
isUpdating={isQueryUpdating}
onClose={toggleConfirmSaveChangesModal}
showChangedSQLCopy={showChangedSQLCopy}
/>
)}
</>
@ -791,4 +861,4 @@ const QueryForm = ({
return renderEditableQueryForm();
};
export default QueryForm;
export default EditQueryForm;

View file

@ -1,13 +1,8 @@
.query-form {
.edit-query-form {
&__wrapper {
position: relative;
font-size: $x-small;
.query-page__warning {
margin: 0;
margin-top: $pad-large;
}
.form-field--input {
margin: 0;
}
@ -51,7 +46,7 @@
.query-name-wrapper {
display: flex;
&:not(.query-form--editing) {
&:not(.edit-query-form--editing) {
textarea:hover {
cursor: pointer;
color: $core-vibrant-blue;
@ -62,7 +57,7 @@
top: 13px;
margin-left: 0;
}
.query-form__query-name,
.edit-query-form__query-name,
.input-sizer::after {
font-size: $large;
}
@ -75,7 +70,7 @@
.query-description-wrapper {
display: flex;
padding-top: $pad-small;
&:not(.query-form--editing) {
&:not(.edit-query-form--editing) {
textarea:hover {
cursor: pointer;
color: $core-vibrant-blue;
@ -166,26 +161,6 @@
}
}
&__advanced-options {
margin-top: $pad-medium;
}
&__query-observer-can-run-wrapper {
margin: 0;
margin-top: $pad-large;
font-weight: $bold !important; // override checkbox default
& + p {
margin: 0;
margin-top: $pad-small;
}
.fleet-checkbox {
display: flex;
align-items: center;
}
}
&__button-wrap {
margin: 0;
margin-top: $pad-large;

View file

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

View file

@ -23,6 +23,7 @@ import {
ISchedulableQuery,
QueryLoggingOption,
} from "interfaces/schedulable_query";
import DiscardDataOption from "../DiscardDataOption";
const baseClass = "save-query-modal";
export interface ISaveQueryModalProps {
@ -33,6 +34,7 @@ export interface ISaveQueryModalProps {
toggleSaveQueryModal: () => void;
backendValidators: { [key: string]: string };
existingQuery?: ISchedulableQuery;
queryReportsDisabled?: boolean;
}
const validateQueryName = (name: string) => {
@ -54,6 +56,7 @@ const SaveQueryModal = ({
toggleSaveQueryModal,
backendValidators,
existingQuery,
queryReportsDisabled,
}: ISaveQueryModalProps): JSX.Element => {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
@ -73,6 +76,7 @@ const SaveQueryModal = ({
setSelectedLoggingType,
] = useState<QueryLoggingOption>(existingQuery?.logging ?? "snapshot");
const [observerCanRun, setObserverCanRun] = useState(false);
const [discardData, setDiscardData] = useState(false);
const [errors, setErrors] = useState<{ [key: string]: string }>(
backendValidators
);
@ -108,6 +112,7 @@ const SaveQueryModal = ({
description,
interval: selectedFrequency,
observer_can_run: observerCanRun,
discard_data: discardData,
platform: selectedPlatformOptions,
min_osquery_version: selectedMinOsqueryVersionOptions,
logging: selectedLoggingType,
@ -141,116 +146,122 @@ const SaveQueryModal = ({
return (
<Modal title={"Save query"} onExit={toggleSaveQueryModal}>
<>
<form
onSubmit={onClickSaveQuery}
className={baseClass}
autoComplete="off"
<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
ignore1password
/>
<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 form-field--frequency`}
/>
<div className="help-text">
This is how often your query collects data.
</div>
<Checkbox
name="observerCanRun"
onChange={setObserverCanRun}
value={observerCanRun}
wrapperClassName={"observer-can-run-wrapper"}
>
<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
ignore1password
/>
<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>
<div className="help-text">
Users with the Observer role will be able to run this query as a live
query.
</div>
<RevealButton
isShowing={showAdvancedOptions}
className={"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 form-field--platform`}
/>
<div className="help-text">
By default, your query collects data on all compatible platforms.
</div>
<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`}
/>
{queryReportsDisabled !== undefined && (
<DiscardDataOption
{...{
queryReportsDisabled,
selectedLoggingType,
discardData,
setDiscardData,
breakHelpText: true,
}}
/>
)}
</>
)}
<div className="modal-cta-wrap">
<Button
type="submit"
variant="brand"
className="save-query-loading"
isLoading={isLoading}
>
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>
</>
Save
</Button>
<Button onClick={toggleSaveQueryModal} variant="inverse">
Cancel
</Button>
</div>
</form>
</Modal>
);
};

View file

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

View file

@ -0,0 +1,219 @@
import React, { useState, useEffect, useContext, useCallback } from "react";
import { useQuery } from "react-query";
import { useErrorHandler } from "react-error-boundary";
import { InjectedRouter, Params } from "react-router/lib/Router";
import PATHS from "router/paths";
import { AppContext } from "context/app";
import { QueryContext } from "context/query";
import { LIVE_QUERY_STEPS, DEFAULT_QUERY } from "utilities/constants";
import queryAPI from "services/entities/queries";
import hostAPI from "services/entities/hosts";
import statusAPI from "services/entities/status";
import { IHost, IHostResponse } from "interfaces/host";
import { ILabel } from "interfaces/label";
import { ITeam } from "interfaces/team";
import {
IGetQueryResponse,
ISchedulableQuery,
} from "interfaces/schedulable_query";
import MainContent from "components/MainContent";
import SelectTargets from "components/LiveQuery/SelectTargets";
import RunQuery from "pages/queries/live/screens/RunQuery";
import useTeamIdParam from "hooks/useTeamIdParam";
interface IRunQueryPageProps {
router: InjectedRouter;
params: Params;
location: {
pathname: string;
query: { host_ids: string; team_id?: string };
search: string;
};
}
const baseClass = "run-query-page";
const RunQueryPage = ({
router,
params: { id: paramsQueryId },
location,
}: IRunQueryPageProps): 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 {
isGlobalAdmin,
isGlobalMaintainer,
isAnyTeamMaintainerOrTeamAdmin,
isObserverPlus,
isAnyTeamObserverPlus,
} = useContext(AppContext);
const {
selectedQueryTargets,
setSelectedQueryTargets,
selectedQueryTargetsByType,
setSelectedQueryTargetsByType,
setLastEditedQueryId,
setLastEditedQueryName,
setLastEditedQueryDescription,
setLastEditedQueryBody,
setLastEditedQueryObserverCanRun,
setLastEditedQueryFrequency,
setLastEditedQueryLoggingType,
setLastEditedQueryMinOsqueryVersion,
setLastEditedQueryPlatforms,
} = useContext(QueryContext);
const [queryParamHostsAdded, setQueryParamHostsAdded] = useState(false);
const [step, setStep] = useState(LIVE_QUERY_STEPS[1]);
const [targetedHosts, setTargetedHosts] = useState<IHost[]>(
selectedQueryTargetsByType.hosts
);
const [targetedLabels, setTargetedLabels] = useState<ILabel[]>(
selectedQueryTargetsByType.labels
);
const [targetedTeams, setTargetedTeams] = useState<ITeam[]>(
selectedQueryTargetsByType.teams
);
const [targetsTotalCount, setTargetsTotalCount] = useState(0);
const [isLiveQueryRunnable, setIsLiveQueryRunnable] = useState(true);
// disabled on page load so we can control the number of renders
// else it will re-populate the context on occasion
const { data: storedQuery } = useQuery<
IGetQueryResponse,
Error,
ISchedulableQuery
>(["query", queryId], () => queryAPI.load(queryId as number), {
enabled: !!queryId,
refetchOnWindowFocus: false,
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),
});
useQuery<IHostResponse, Error, IHost>(
"hostFromURL",
() =>
hostAPI.loadHostDetails(parseInt(location.query.host_ids as string, 10)),
{
enabled: !!location.query.host_ids && !queryParamHostsAdded,
select: (data: IHostResponse) => data.host,
onSuccess: (host) => {
setTargetedHosts((prevHosts) =>
prevHosts.filter((h) => h.id !== host.id).concat(host)
);
const targets = selectedQueryTargets;
host.target_type = "hosts";
targets.push(host);
setSelectedQueryTargets([...targets]);
if (!queryParamHostsAdded) {
setQueryParamHostsAdded(true);
}
router.replace(location.pathname);
},
}
);
const detectIsFleetQueryRunnable = () => {
statusAPI.live_query().catch(() => {
setIsLiveQueryRunnable(false);
});
};
useEffect(() => {
detectIsFleetQueryRunnable();
}, [queryId]);
useEffect(() => {
setSelectedQueryTargetsByType({
hosts: targetedHosts,
labels: targetedLabels,
teams: targetedTeams,
});
}, [targetedLabels, targetedHosts, targetedTeams]);
console.log(
"LiveQueryPage.tsx: selectedQueryTargetsByType",
selectedQueryTargetsByType
);
// Updates title that shows up on browser tabs
useEffect(() => {
// e.g., Run live query | Discover TLS certificates | Fleet for osquery
document.title = `Run live query | ${storedQuery?.name} | Fleet for osquery`;
}, [location.pathname, storedQuery?.name]);
const goToQueryEditor = useCallback(
() =>
queryId
? router.push(PATHS.EDIT_QUERY(queryId))
: router.push(PATHS.NEW_QUERY()),
[]
);
const renderScreen = () => {
const step1Props = {
baseClass,
queryId,
selectedTargets: selectedQueryTargets,
targetedHosts,
targetedLabels,
targetedTeams,
targetsTotalCount,
goToQueryEditor,
goToRunQuery: () => setStep(LIVE_QUERY_STEPS[2]),
setSelectedTargets: setSelectedQueryTargets,
setTargetedHosts,
setTargetedLabels,
setTargetedTeams,
setTargetsTotalCount,
};
const step2Props = {
queryId,
selectedTargets: selectedQueryTargets,
storedQuery,
setSelectedTargets: setSelectedQueryTargets,
goToQueryEditor,
targetsTotalCount,
};
switch (step) {
case LIVE_QUERY_STEPS[2]:
return <RunQuery {...step2Props} />;
default:
return <SelectTargets {...step1Props} />;
}
};
return (
<MainContent className={baseClass}>
<div className={`${baseClass}_wrapper`}>{renderScreen()}</div>
</MainContent>
);
};
export default RunQueryPage;

View file

@ -1,4 +1,4 @@
.query-page {
.run-query-page {
.body-wrap {
min-width: 0;
}
@ -9,61 +9,6 @@
min-height: 400px;
}
&__warning {
padding: $pad-medium;
font-size: $x-small;
color: $core-fleet-black;
background-color: #fff0b9;
border: 1px solid #f2c94c;
border-radius: $border-radius;
p {
margin: 0;
line-height: 20px;
}
}
&__observer-query-view {
width: 90%;
max-width: 1060px;
margin: 0 auto;
color: $core-fleet-black;
h1 {
font-size: $medium;
}
p {
font-size: $x-small;
}
}
&__observer-query-details {
padding: 0 2rem;
h1 {
margin: $pad-large 0;
font-size: $large;
}
p {
margin-bottom: $pad-small;
}
.sql-button {
color: $core-vibrant-blue;
font-weight: $bold;
font-size: $x-small;
}
}
&__query-preview {
margin-top: 15px;
.fleet-ace__label {
display: none;
}
}
.ace_content {
min-height: 500px !important;
}
@ -172,10 +117,4 @@
font-size: $x-small;
}
}
.targets-input {
.input-icon-field__icon {
top: 34px; // Override styling to include label header
}
}
}

View file

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

View file

@ -15,7 +15,7 @@ import { ICampaign, ICampaignState } from "interfaces/campaign";
import { IQuery } from "interfaces/query";
import { ITarget } from "interfaces/target";
import QueryResults from "../components/QueryResults";
import QueryResults from "../../edit/components/QueryResults";
interface IRunQueryProps {
storedQuery: IQuery | undefined;

View file

@ -36,7 +36,9 @@ import ManagePoliciesPage from "pages/policies/ManagePoliciesPage";
import NoAccessPage from "pages/NoAccessPage";
import PackComposerPage from "pages/packs/PackComposerPage";
import PolicyPage from "pages/policies/PolicyPage";
import QueryPage from "pages/queries/QueryPage";
import QueryDetailsPage from "pages/queries/details/QueryDetailsPage";
import LiveQueryPage from "pages/queries/live/LiveQueryPage";
import EditQueryPage from "pages/queries/edit/EditQueryPage";
import RegistrationPage from "pages/RegistrationPage";
import ResetPasswordPage from "pages/ResetPasswordPage";
import MDMAppleSSOPage from "pages/MDMAppleSSOPage";
@ -223,9 +225,16 @@ const routes = (
<IndexRedirect to="manage" />
<Route path="manage" component={ManageQueriesPage} />
<Route component={AuthAnyMaintainerAdminObserverPlusRoutes}>
<Route path="new" component={QueryPage} />
<Route path="new">
<IndexRoute component={EditQueryPage} />
<Route path="live" component={LiveQueryPage} />
</Route>
</Route>
<Route path=":id">
<IndexRoute component={QueryDetailsPage} />
<Route path="edit" component={EditQueryPage} />
<Route path="live" component={LiveQueryPage} />
</Route>
<Route path=":id" component={QueryPage} />
</Route>
<Route path="policies">
<IndexRedirect to="manage" />

View file

@ -53,6 +53,16 @@ export default {
return `${URL_PREFIX}/labels/${labelId}`;
},
EDIT_QUERY: (queryId: number, teamId?: number): string => {
return `${URL_PREFIX}/queries/${queryId}/edit${
teamId ? `?team_id=${teamId}` : ""
}`;
},
LIVE_QUERY: (queryId: number | null, teamId?: number): string => {
return `${URL_PREFIX}/queries/${queryId || "new"}/live${
teamId ? `?team_id=${teamId}` : ""
}`;
},
QUERY: (queryId: number, teamId?: number): string => {
return `${URL_PREFIX}/queries/${queryId}${
teamId ? `?team_id=${teamId}` : ""
}`;

View file

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import sendRequest, { getError } from "services";
import endpoints from "utilities/endpoints";
import { ISelectedTargets } from "interfaces/target";
import { ISelectedTargetsForApi } from "interfaces/target";
import { AxiosResponse } from "axios";
import {
ICreateQueryRequestBody,
@ -52,12 +52,12 @@ export default {
}: {
query: string;
queryId: number | null;
selected: ISelectedTargets;
selected: ISelectedTargetsForApi;
}) => {
const { RUN_QUERY } = endpoints;
const { LIVE_QUERY } = endpoints;
try {
const { campaign } = await sendRequest("POST", RUN_QUERY, {
const { campaign } = await sendRequest("POST", LIVE_QUERY, {
query,
query_id: queryId,
selected,

View file

@ -0,0 +1,46 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import sendRequest from "services";
import endpoints from "utilities/endpoints";
import { buildQueryStringFromParams } from "utilities/url";
export interface ISortOption {
key: string;
direction: string;
}
export interface ILoadQueryReportOptions {
id: number;
sortBy: ISortOption[];
}
const getSortParams = (sortOptions?: ISortOption[]) => {
if (sortOptions === undefined || sortOptions.length === 0) {
return {};
}
const sortItem = sortOptions[0];
return {
order_key: sortItem.key,
order_direction: sortItem.direction,
};
};
export default {
load: ({ id, sortBy }: ILoadQueryReportOptions) => {
const sortParams = getSortParams(sortBy);
const { QUERIES } = endpoints;
const queryParams = {
order_key: sortParams.order_key,
order_direction: sortParams.order_direction,
};
const queryString = buildQueryStringFromParams(queryParams);
const endpoint = `${QUERIES}/${id}/report`;
const path = `${endpoint}?${queryString}`;
return sendRequest("GET", path);
},
};

View file

@ -1,14 +1,14 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import sendRequest from "services";
import { IHost } from "interfaces/host";
import { ISelectedTargets, ITargetsAPIResponse } from "interfaces/target";
import { ISelectedTargetsForApi, ITargetsAPIResponse } from "interfaces/target";
import endpoints from "utilities/endpoints";
import appendTargetTypeToTargets from "utilities/append_target_type_to_targets";
interface ITargetsProps {
query?: string;
queryId?: number | null;
selected: ISelectedTargets;
selected: ISelectedTargetsForApi;
}
const defaultSelected = {
@ -29,7 +29,7 @@ export interface ITargetsSearchResponse {
export interface ITargetsCountParams {
query_id?: number | null;
selected: ISelectedTargets | null;
selected: ISelectedTargetsForApi | null;
}
export interface ITargetsCountResponse {

View file

@ -33,6 +33,8 @@ const REQUEST_RESPONSE_MAPPINGS: IResponses = {
"queries/7": RESPONSES.globalQuery6,
"queries/8": RESPONSES.teamQuery2,
"queries?team_id=13": RESPONSES.teamQueries,
"queries/113/report?order_key=host_name&order_direction=asc":
RESPONSES.queryReport,
},
POST: {
// request body is ISelectedTargets

File diff suppressed because it is too large Load diff

View file

@ -9,11 +9,11 @@ $max-width: 2560px;
@content;
}
} @else if ($size == ltdesktop) {
@media (max-width: $desktop-width - 1) {
@media (max-width: ($desktop-width - 1)) {
@content;
}
} @else if ($size == desktop) {
@media (min-width: $medium-width + 1) {
@media (min-width: ($medium-width + 1)) {
@content;
}
} @else if ($size == smalldesk) {
@ -108,3 +108,10 @@ $max-width: 2560px;
list-style-position: inside;
}
}
@mixin disabled {
opacity: 0.5;
filter: grayscale(0.5);
pointer-events: none;
cursor: default;
}

View file

@ -96,12 +96,17 @@ export const MIN_OSQUERY_VERSION_OPTIONS = [
{ label: "1.8.1 +", value: "1.8.1" },
];
export const QUERIES_PAGE_STEPS = {
export const LIVE_POLICY_STEPS = {
1: "EDITOR",
2: "TARGETS",
3: "RUN",
};
export const LIVE_QUERY_STEPS = {
1: "TARGETS",
2: "RUN",
};
export const DEFAULT_QUERY: ISchedulableQuery = {
description: "",
name: "",
@ -109,6 +114,7 @@ export const DEFAULT_QUERY: ISchedulableQuery = {
id: 0,
interval: 0,
observer_can_run: false,
discard_data: false,
platform: "",
min_osquery_version: "",
automations_enabled: false,

View file

@ -82,7 +82,7 @@ export default {
PERFORM_REQUIRED_PASSWORD_RESET: `/${API_VERSION}/fleet/perform_required_password_reset`,
QUERIES: `/${API_VERSION}/fleet/queries`,
RESET_PASSWORD: `/${API_VERSION}/fleet/reset_password`,
RUN_QUERY: `/${API_VERSION}/fleet/queries/run`,
LIVE_QUERY: `/${API_VERSION}/fleet/queries/run`,
SCHEDULE_QUERY: `/${API_VERSION}/fleet/packs/schedule`,
SCHEDULED_QUERIES: (packId: number): string => {
return `/${API_VERSION}/fleet/packs/${packId}/scheduled`;

View file

@ -14,7 +14,7 @@ export const generateCSVFilename = (descriptor: string) => {
return `${descriptor} (${format(new Date(), "MM-dd-yy hh-mm-ss")}).csv`;
};
// Query results and query errors
// Live query results, live query errors, and query report
export const generateCSVQueryResults = (
rows: Row[],
filename: string,
@ -35,7 +35,7 @@ export const generateCSVQueryResults = (
);
};
// Policy results only
// Live policy results only
export const generateCSVPolicyResults = (
rows: { host: string; status: string }[],
filename: string
@ -45,7 +45,7 @@ export const generateCSVPolicyResults = (
});
};
// Policy errors only
// Live policy errors only
export const generateCSVPolicyErrors = (
rows: ICampaignError[],
filename: string

View file

@ -31,7 +31,7 @@ import {
} from "interfaces/scheduled_query";
import {
ISelectTargetsEntity,
ISelectedTargets,
ISelectedTargetsForApi,
IPackTargets,
} from "interfaces/target";
import { ITeam, ITeamSummary } from "interfaces/team";
@ -258,7 +258,7 @@ const formatLabelResponse = (response: any): ILabel[] => {
export const formatSelectedTargetsForApi = (
selectedTargets: ISelectTargetsEntity[]
): ISelectedTargets => {
): ISelectedTargetsForApi => {
const targets = selectedTargets || [];
// TODO: can flatMap be removed?
const hostIds = flatMap(targets, filterTarget("hosts"));
@ -910,6 +910,12 @@ export const getSoftwareBundleTooltipMarkup = (bundle: string) => {
`;
};
export const TAGGED_TEMPLATES = {
queryByHostRoute: (hostId: number | undefined | null) => {
return `${hostId ? `?host_ids=${hostId}` : ""}`;
},
};
export default {
addGravatarUrlToResource,
formatConfigDataForServer,
@ -945,4 +951,5 @@ export default {
syntaxHighlight,
normalizeEmptyValues,
wrapFleetHelper,
TAGGED_TEMPLATES,
};

View file

@ -464,6 +464,7 @@ var hostRefs = []string{
"host_disk_encryption_keys",
"host_software_installed_paths",
"host_script_results",
"query_results",
}
// NOTE: The following tables are explicity excluded from hostRefs list and accordingly are not

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