From dc3fc5e6f57feb25d2adea888095ba0acd784275 Mon Sep 17 00:00:00 2001 From: Tim Lee Date: Mon, 4 Dec 2023 08:31:35 -0700 Subject: [PATCH 1/9] 15378 record empty data results (#15403) --- server/datastore/mysql/query_results.go | 10 +- server/datastore/mysql/query_results_test.go | 149 +++++++++++++------ server/service/osquery.go | 17 ++- server/service/osquery_test.go | 124 ++++++++++++--- 4 files changed, 224 insertions(+), 76 deletions(-) diff --git a/server/datastore/mysql/query_results.go b/server/datastore/mysql/query_results.go index 87fa102776..5d32458d66 100644 --- a/server/datastore/mysql/query_results.go +++ b/server/datastore/mysql/query_results.go @@ -24,9 +24,7 @@ func (ds *Datastore) OverwriteQueryResultRows(ctx context.Context, rows []*fleet // Count how many rows are already in the database for the given queryID var countExisting int - countStmt := ` - SELECT COUNT(*) FROM query_results WHERE query_id = ? - ` + countStmt := `SELECT COUNT(*) FROM query_results WHERE query_id = ? AND data IS NOT NULL` err = sqlx.GetContext(ctx, tx, &countExisting, countStmt, queryID) if err != nil { return ctxerr.Wrap(ctx, err, "counting existing query results") @@ -95,7 +93,7 @@ func (ds *Datastore) QueryResultRows(ctx context.Context, queryID uint) ([]*flee h.hostname, h.computer_name, h.hardware_model, h.hardware_serial FROM query_results qr LEFT JOIN hosts h ON (qr.host_id=h.id) - WHERE query_id = ? + WHERE query_id = ? AND data IS NOT NULL ` results := []*fleet.ScheduledQueryResultRow{} err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, selectStmt, queryID) @@ -108,7 +106,7 @@ func (ds *Datastore) QueryResultRows(ctx context.Context, queryID uint) ([]*flee func (ds *Datastore) ResultCountForQuery(ctx context.Context, queryID uint) (int, error) { var count int - err := sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(*) FROM query_results WHERE query_id = ?`, queryID) + err := sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(*) FROM query_results WHERE query_id = ? AND data IS NOT NULL`, queryID) if err != nil { return 0, ctxerr.Wrap(ctx, err, "counting query results for query") } @@ -118,7 +116,7 @@ func (ds *Datastore) ResultCountForQuery(ctx context.Context, queryID uint) (int func (ds *Datastore) ResultCountForQueryAndHost(ctx context.Context, queryID, hostID uint) (int, error) { var count int - err := sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(*) FROM query_results WHERE query_id = ? AND host_id = ?`, queryID, hostID) + err := sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(*) FROM query_results WHERE query_id = ? AND host_id = ? AND data IS NOT NULL`, queryID, hostID) if err != nil { return 0, ctxerr.Wrap(ctx, err, "counting query results for query and host") } diff --git a/server/datastore/mysql/query_results_test.go b/server/datastore/mysql/query_results_test.go index 4decdf41c6..5da3a60c52 100644 --- a/server/datastore/mysql/query_results_test.go +++ b/server/datastore/mysql/query_results_test.go @@ -22,8 +22,7 @@ func TestQueryResults(t *testing.T) { name string fn func(t *testing.T, ds *Datastore) }{ - {"Save", saveQueryResultRows}, - {"Get", getQueryResultRows}, + {"Get", testGetQueryResultRows}, {"CountForQuery", testCountResultsForQuery}, {"CountForQueryAndHost", testCountResultsForQueryAndHost}, {"Overwrite", testOverwriteQueryResultRows}, @@ -38,13 +37,14 @@ func TestQueryResults(t *testing.T) { } } -func saveQueryResultRows(t *testing.T, ds *Datastore) { +func testGetQueryResultRows(t *testing.T, ds *Datastore) { user := test.NewUser(t, ds, "Test User", "test@example.com", true) query := test.NewQuery(t, ds, nil, "New Query", "SELECT 1", user.ID, true) host := test.NewHost(t, ds, "hostname123", "192.168.1.100", "1234", "UI8XB1223", time.Now()) mockTime := time.Now().UTC().Truncate(time.Second) + // Insert 2 Result Rows for Query1 and 1 empty data row resultRows := []*fleet.ScheduledQueryResultRow{ { QueryID: query.ID, @@ -62,36 +62,11 @@ func saveQueryResultRows(t *testing.T, ds *Datastore) { `{"model": "USB Mouse", "vendor": "Logitech"}`, ), }, - } - - err := ds.SaveQueryResultRows(context.Background(), resultRows) - require.NoError(t, err) -} - -func getQueryResultRows(t *testing.T, ds *Datastore) { - user := test.NewUser(t, ds, "Test User", "test@example.com", true) - query := test.NewQuery(t, ds, nil, "New Query", "SELECT 1", user.ID, true) - host := test.NewHost(t, ds, "hostname123", "192.168.1.100", "1234", "UI8XB1223", time.Now()) - - mockTime := time.Now().UTC().Truncate(time.Second) - - // Insert 2 Result Rows for Query1 - resultRows := []*fleet.ScheduledQueryResultRow{ { QueryID: query.ID, HostID: host.ID, LastFetched: mockTime, - Data: json.RawMessage( - `{"model": "USB Keyboard", "vendor": "Apple Inc."}`, - ), - }, - { - QueryID: query.ID, - HostID: host.ID, - LastFetched: mockTime, - Data: json.RawMessage( - `{"model": "USB Mouse", "vendor": "Logitech"}`, - ), + Data: nil, }, } @@ -162,10 +137,22 @@ func testCountResultsForQuery(t *testing.T, ds *Datastore) { }`), }, } - err := ds.SaveQueryResultRows(context.Background(), resultRow) require.NoError(t, err) + // Insert 1 Result Row with nil Data for Query1 + // This should not be counted + resultRowNilData := []*fleet.ScheduledQueryResultRow{ + { + QueryID: query1.ID, + HostID: host.ID, + LastFetched: mockTime, + Data: nil, + }, + } + err = ds.SaveQueryResultRows(context.Background(), resultRowNilData) + require.NoError(t, err) + // Insert 5 Result Rows for Query2 resultRow2 := []*fleet.ScheduledQueryResultRow{ { @@ -193,7 +180,7 @@ func testCountResultsForQuery(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Equal(t, 5, count) - // Returns empty result when no results are found + // Returns 0 when no results are found count, err = ds.ResultCountForQuery(context.Background(), 999) require.NoError(t, err) require.Equal(t, 0, count) @@ -244,6 +231,12 @@ func testCountResultsForQueryAndHost(t *testing.T, ds *Datastore) { "foo": "bar" }`), }, + { + QueryID: query2.ID, // This row should not be counted + HostID: host.ID, + LastFetched: mockTime, + Data: nil, + }, } err := ds.SaveQueryResultRows(context.Background(), resultRows) @@ -325,41 +318,109 @@ func testOverwriteQueryResultRows(t *testing.T, ds *Datastore) { } err = ds.OverwriteQueryResultRows(context.Background(), overwriteRows) require.NoError(t, err) + + // Assert that the data has not changed + results, err = ds.QueryResultRowsForHost(context.Background(), overwriteRows[0].QueryID, overwriteRows[0].HostID) + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, overwriteRows[0].QueryID, results[0].QueryID) + require.Equal(t, overwriteRows[0].HostID, results[0].HostID) + require.Equal(t, overwriteRows[0].LastFetched.Unix(), results[0].LastFetched.Unix()) + require.JSONEq(t, string(overwriteRows[0].Data), string(results[0].Data)) } func testQueryResultRowsDoNotExceedMaxRows(t *testing.T, ds *Datastore) { user := test.NewUser(t, ds, "Test User", "test@example.com", true) query := test.NewQuery(t, ds, nil, "Overwrite Test Query", "SELECT 1", user.ID, true) - host := test.NewHost(t, ds, "hostname1", "192.168.1.101", "12345", "UI8XB1224", time.Now()) + query2 := test.NewQuery(t, ds, nil, "Overwrite Test Query 2", "SELECT 1", user.ID, true) + host1 := test.NewHost(t, ds, "hostname1", "192.168.1.101", "11111", "UI8XB1221", time.Now()) + host2 := test.NewHost(t, ds, "hostname2", "192.168.1.101", "22222", "UI8XB1222", time.Now()) + host3 := test.NewHost(t, ds, "hostname3", "192.168.1.101", "33333", "UI8XB1223", time.Now()) + host4 := test.NewHost(t, ds, "hostname4", "192.168.1.101", "44444", "UI8XB1224", time.Now()) mockTime := time.Now().UTC().Truncate(time.Second) - // Generate more than max rows - rows := fleet.MaxQueryReportRows + 50 - largeBatchRows := make([]*fleet.ScheduledQueryResultRow, rows) - for i := 0; i < rows; i++ { - largeBatchRows[i] = &fleet.ScheduledQueryResultRow{ + // Generate max rows -1 + maxRows := fleet.MaxQueryReportRows - 1 + maxMinusOneRows := make([]*fleet.ScheduledQueryResultRow, maxRows) + for i := 0; i < maxRows; i++ { + maxMinusOneRows[i] = &fleet.ScheduledQueryResultRow{ QueryID: query.ID, - HostID: host.ID, + HostID: host1.ID, LastFetched: mockTime, Data: json.RawMessage(`{"model": "Bulk Mouse", "vendor": "BulkTech"}`), } } + err := ds.OverwriteQueryResultRows(context.Background(), maxMinusOneRows) + require.NoError(t, err) - err := ds.OverwriteQueryResultRows(context.Background(), largeBatchRows) + // Add an empty data rows which do not count towards the max + err = ds.OverwriteQueryResultRows(context.Background(), []*fleet.ScheduledQueryResultRow{ + { + QueryID: query.ID, + HostID: host2.ID, + LastFetched: mockTime, + Data: nil, + }, + }) + require.NoError(t, err) + + // Confirm that we can still add a row + err = ds.OverwriteQueryResultRows(context.Background(), []*fleet.ScheduledQueryResultRow{ + { + QueryID: query.ID, + HostID: host3.ID, + LastFetched: mockTime, + Data: json.RawMessage(`{"model": "USB Mouse", "vendor": "Logitech"}`), + }, + }) + require.NoError(t, err) + + // Assert that we now have max rows + count, err := ds.ResultCountForQuery(context.Background(), query.ID) + require.NoError(t, err) + require.Equal(t, fleet.MaxQueryReportRows, count) + + // Attempt to add another row + err = ds.OverwriteQueryResultRows(context.Background(), []*fleet.ScheduledQueryResultRow{ + { + QueryID: query.ID, + HostID: host4.ID, + LastFetched: mockTime, + Data: json.RawMessage(`{"model": "USB Mouse", "vendor": "Logitech"}`), + }, + }) + require.NoError(t, err) + + // Assert that the last row was not added + host4result, err := ds.QueryResultRowsForHost(context.Background(), query.ID, host4.ID) + require.NoError(t, err) + require.Len(t, host4result, 0) + + // Generate more than max rows in Query 2 + rows := fleet.MaxQueryReportRows + 50 + largeBatchRows := make([]*fleet.ScheduledQueryResultRow, rows) + for i := 0; i < rows; i++ { + largeBatchRows[i] = &fleet.ScheduledQueryResultRow{ + QueryID: query2.ID, + HostID: host1.ID, + LastFetched: mockTime, + Data: json.RawMessage(`{"model": "Bulk Mouse", "vendor": "BulkTech"}`), + } + } + err = ds.OverwriteQueryResultRows(context.Background(), largeBatchRows) require.NoError(t, err) // Confirm only max rows are stored for the queryID - allResults, err := ds.QueryResultRowsForHost(context.Background(), query.ID, host.ID) + allResults, err := ds.QueryResultRowsForHost(context.Background(), query2.ID, host1.ID) require.NoError(t, err) require.Len(t, allResults, fleet.MaxQueryReportRows) // Confirm that new rows are not added when the max is reached - host2 := test.NewHost(t, ds, "hostname2", "192.168.1.102", "678910", "UI8XB1225", time.Now()) newMockTime := mockTime.Add(2 * time.Minute) overwriteRows := []*fleet.ScheduledQueryResultRow{ { - QueryID: query.ID, + QueryID: query2.ID, HostID: host2.ID, LastFetched: newMockTime, Data: json.RawMessage( @@ -371,7 +432,7 @@ func testQueryResultRowsDoNotExceedMaxRows(t *testing.T, ds *Datastore) { err = ds.OverwriteQueryResultRows(context.Background(), overwriteRows) require.NoError(t, err) - host2Results, err := ds.QueryResultRowsForHost(context.Background(), query.ID, host2.ID) + host2Results, err := ds.QueryResultRowsForHost(context.Background(), query2.ID, host2.ID) require.NoError(t, err) require.Len(t, host2Results, 0) } @@ -430,7 +491,7 @@ func (ds *Datastore) SaveQueryResultRows(ctx context.Context, rows []*fleet.Sche func (ds *Datastore) QueryResultRowsForHost(ctx context.Context, queryID, hostID uint) ([]*fleet.ScheduledQueryResultRow, error) { selectStmt := ` SELECT query_id, host_id, last_fetched, data FROM query_results - WHERE query_id = ? AND host_id = ? + WHERE query_id = ? AND host_id = ? AND data IS NOT NULL ` results := []*fleet.ScheduledQueryResultRow{} err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, selectStmt, queryID, hostID) diff --git a/server/service/osquery.go b/server/service/osquery.go index 08f8ab970d..fd374794da 100644 --- a/server/service/osquery.go +++ b/server/service/osquery.go @@ -1543,11 +1543,6 @@ func (svc *Service) saveResultLogsToQueryReports(ctx context.Context, unmarshale filtered := getMostRecentResults(unmarshaledResults) for _, result := range filtered { - // Discard result if there is no snapshot - if len(result.Snapshot) == 0 { - continue - } - dbQuery, ok := queriesDBData[result.QueryName] if !ok { // Means the query does not exist with such name anymore. Thus we ignore its result. @@ -1586,6 +1581,18 @@ func (svc *Service) overwriteResultRows(ctx context.Context, result *fleet.Sched fetchTime := time.Now() rows := make([]*fleet.ScheduledQueryResultRow, 0, len(result.Snapshot)) + + // If the snapshot is empty, we still want to save a row with a null value + // to capture LastFetched. + if len(result.Snapshot) == 0 { + rows = append(rows, &fleet.ScheduledQueryResultRow{ + QueryID: queryID, + HostID: hostID, + Data: nil, + LastFetched: fetchTime, + }) + } + for _, snapshotItem := range result.Snapshot { row := &fleet.ScheduledQueryResultRow{ QueryID: queryID, diff --git a/server/service/osquery_test.go b/server/service/osquery_test.go index 78107ce698..d1e059af55 100644 --- a/server/service/osquery_test.go +++ b/server/service/osquery_test.go @@ -531,7 +531,7 @@ func TestSubmitStatusLogs(t *testing.T) { assert.Equal(t, status, testLogger.logs) } -func TestSubmitResultLogs(t *testing.T) { +func TestSubmitResultLogsToLogDestination(t *testing.T) { ds := new(mock.Store) svc, ctx := newTestService(t, ds, nil, nil) @@ -691,26 +691,6 @@ func TestSaveResultLogsToQueryReports(t *testing.T) { }, } - queriesDBData := map[string]*fleet.Query{ - "pack/Global/Uptime": { - ID: 1, - DiscardData: false, - Logging: fleet.LoggingSnapshot, - }, - } - - // Result not saved if result is not a snapshot - notSnapshotResult := []*fleet.ScheduledQueryResult{ - { - QueryName: "pack/Global/Uptime", - OsqueryHostID: "1379f59d98f4", - Snapshot: []json.RawMessage{}, - UnixTime: 1484078931, - }, - } - serv.saveResultLogsToQueryReports(ctx, notSnapshotResult, queriesDBData) - assert.False(t, ds.OverwriteQueryResultRowsFuncInvoked) - // Results not saved if DiscardData is true in Query discardDataFalse := map[string]*fleet.Query{ "pack/Global/Uptime": { @@ -740,6 +720,108 @@ func TestSaveResultLogsToQueryReports(t *testing.T) { require.True(t, ds.OverwriteQueryResultRowsFuncInvoked) } +func TestSubmitResultLogsToQueryResultsWithEmptySnapShot(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil) + + host := fleet.Host{ + ID: 999, + } + ctx = hostctx.NewContext(ctx, &host) + + logs := []string{ + `{"snapshot":[],"action":"snapshot","name":"pack/Global/query_no_rows","hostIdentifier":"1379f59d98f4","calendarTime":"Tue Jan 10 20:08:51 2017 UTC","unixTime":1484078931,"decorations":{"host_uuid":"EB714C9D-C1F8-A436-B6DA-3F853C5502EA"}}`, + } + + logJSON := fmt.Sprintf("[%s]", strings.Join(logs, ",")) + var results []json.RawMessage + err := json.Unmarshal([]byte(logJSON), &results) + require.NoError(t, err) + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + ServerSettings: fleet.ServerSettings{ + QueryReportsDisabled: false, + }, + }, nil + } + + ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string) (*fleet.Query, error) { + return &fleet.Query{ + ID: 1, + DiscardData: false, + Logging: fleet.LoggingSnapshot, + }, nil + } + + ds.ResultCountForQueryFunc = func(ctx context.Context, queryID uint) (int, error) { + return 0, nil + } + + ds.OverwriteQueryResultRowsFunc = func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow) error { + require.Len(t, rows, 1) + require.Equal(t, uint(999), rows[0].HostID) + require.NotZero(t, rows[0].LastFetched) + require.Nil(t, rows[0].Data) + return nil + } + + err = svc.SubmitResultLogs(ctx, results) + require.NoError(t, err) + assert.True(t, ds.OverwriteQueryResultRowsFuncInvoked) +} + +func TestSubmitResultLogsToQueryResultsDoesNotCountNullDataRows(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil) + + host := fleet.Host{ + ID: 999, + } + ctx = hostctx.NewContext(ctx, &host) + + logs := []string{ + `{"snapshot":[],"action":"snapshot","name":"pack/Global/query_no_rows","hostIdentifier":"1379f59d98f4","calendarTime":"Tue Jan 10 20:08:51 2017 UTC","unixTime":1484078931,"decorations":{"host_uuid":"EB714C9D-C1F8-A436-B6DA-3F853C5502EA"}}`, + } + + logJSON := fmt.Sprintf("[%s]", strings.Join(logs, ",")) + var results []json.RawMessage + err := json.Unmarshal([]byte(logJSON), &results) + require.NoError(t, err) + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + ServerSettings: fleet.ServerSettings{ + QueryReportsDisabled: false, + }, + }, nil + } + + ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string) (*fleet.Query, error) { + return &fleet.Query{ + ID: 1, + DiscardData: false, + Logging: fleet.LoggingSnapshot, + }, nil + } + + ds.ResultCountForQueryFunc = func(ctx context.Context, queryID uint) (int, error) { + return 0, nil + } + + ds.OverwriteQueryResultRowsFunc = func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow) error { + require.Len(t, rows, 1) + require.Equal(t, uint(999), rows[0].HostID) + require.NotZero(t, rows[0].LastFetched) + require.Nil(t, rows[0].Data) + return nil + } + + err = svc.SubmitResultLogs(ctx, results) + require.NoError(t, err) + assert.True(t, ds.OverwriteQueryResultRowsFuncInvoked) +} + func TestGetQueryNameAndTeamIDFromResult(t *testing.T) { tests := []struct { input string From bb56e288e59f5cb36cc52dd74cf3372aa02d124a Mon Sep 17 00:00:00 2001 From: Jacob Shandling <61553566+jacobshandling@users.noreply.github.com> Date: Tue, 5 Dec 2023 13:58:02 -0800 Subject: [PATCH 2/9] =?UTF-8?q?UI=20=E2=80=93=2014415=20frontend=20-=20hos?= =?UTF-8?q?t=20details=20(#15437)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Addresses the first major part of #15011 (item 2) – Host Details > Queries tab Screenshot 2023-12-04 at 1 09 31 PM Screenshot 2023-12-04 at 1 09 57 PM - [x] Added/updated tests - [x] Manual QA for all new/changed functionality --------- Co-authored-by: Jacob Shandling --- .../DataTable/TextCell/TextCell.tsx | 3 +- .../components/ViewAllHostsLink/_styles.scss | 6 +- frontend/interfaces/query_stats.ts | 3 + .../HostDetailsPage/HostDetailsPage.tsx | 115 +++++++++++- frontend/pages/hosts/details/_styles.scss | 7 + .../details/cards/HostSummary/HostSummary.tsx | 4 +- .../details/cards/Queries/HostQueries.tsx | 118 +++++++++++++ .../cards/Queries/HostQueriesTableConfig.tsx | 166 ++++++++++++++++++ .../ReportUpdatedCell.tests.tsx | 72 ++++++++ .../ReportUpdatedCell/ReportUpdatedCell.tsx | 111 ++++++++++++ .../Queries/ReportUpdatedCell/_styles.scss | 17 ++ .../cards/Queries/ReportUpdatedCell/index.ts | 1 + .../hosts/details/cards/Queries/_styles.scss | 60 +++++++ .../hosts/details/cards/Queries/index.ts | 1 + .../hosts/details/cards/Schedule/Schedule.tsx | 80 --------- .../cards/Schedule/ScheduleTableConfig.tsx | 130 -------------- .../hosts/details/cards/Schedule/_styles.scss | 29 --- .../hosts/details/cards/Schedule/index.ts | 1 - .../ManageSoftwarePage/ManageSoftwarePage.tsx | 27 ++- frontend/router/index.tsx | 4 +- frontend/router/paths.ts | 6 +- frontend/styles/global/_global.scss | 12 +- frontend/styles/var/mixins.scss | 29 +++ 23 files changed, 716 insertions(+), 286 deletions(-) create mode 100644 frontend/pages/hosts/details/cards/Queries/HostQueries.tsx create mode 100644 frontend/pages/hosts/details/cards/Queries/HostQueriesTableConfig.tsx create mode 100644 frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/ReportUpdatedCell.tests.tsx create mode 100644 frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/ReportUpdatedCell.tsx create mode 100644 frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/_styles.scss create mode 100644 frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/index.ts create mode 100644 frontend/pages/hosts/details/cards/Queries/_styles.scss create mode 100644 frontend/pages/hosts/details/cards/Queries/index.ts delete mode 100644 frontend/pages/hosts/details/cards/Schedule/Schedule.tsx delete mode 100644 frontend/pages/hosts/details/cards/Schedule/ScheduleTableConfig.tsx delete mode 100644 frontend/pages/hosts/details/cards/Schedule/_styles.scss delete mode 100644 frontend/pages/hosts/details/cards/Schedule/index.ts diff --git a/frontend/components/TableContainer/DataTable/TextCell/TextCell.tsx b/frontend/components/TableContainer/DataTable/TextCell/TextCell.tsx index eabf2da046..d086ef5e39 100644 --- a/frontend/components/TableContainer/DataTable/TextCell/TextCell.tsx +++ b/frontend/components/TableContainer/DataTable/TextCell/TextCell.tsx @@ -1,6 +1,7 @@ import { uniqueId } from "lodash"; import React from "react"; import ReactTooltip from "react-tooltip"; +import { COLORS } from "styles/var/colors"; import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants"; interface ITextCellProps { @@ -38,7 +39,7 @@ const TextCell = ({ {emptyCellTooltipText} diff --git a/frontend/components/ViewAllHostsLink/_styles.scss b/frontend/components/ViewAllHostsLink/_styles.scss index 19e6685d31..3e4d049865 100644 --- a/frontend/components/ViewAllHostsLink/_styles.scss +++ b/frontend/components/ViewAllHostsLink/_styles.scss @@ -1,7 +1,3 @@ .view-all-hosts-link { - display: inline-flex; - align-items: center; - padding: $pad-small $pad-xxsmall; // larger clickable area - gap: $pad-xsmall; - white-space: nowrap; + @include table-link; } diff --git a/frontend/interfaces/query_stats.ts b/frontend/interfaces/query_stats.ts index b6906c10de..edc319fa61 100644 --- a/frontend/interfaces/query_stats.ts +++ b/frontend/interfaces/query_stats.ts @@ -27,6 +27,9 @@ export interface IQueryStats { scheduled_query_name: string; scheduled_query_id: number; query_name: string; + discard_data: boolean; + last_fetched: string | null; // timestamp + automations_enabled: boolean; description: string; pack_name: string; pack_id: number; diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index e2c7af8ac4..b5aa3f6561 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -62,7 +62,7 @@ import ScriptsCard from "../cards/Scripts"; import SoftwareCard from "../cards/Software"; import UsersCard from "../cards/Users"; import PoliciesCard from "../cards/Policies"; -import ScheduleCard from "../cards/Schedule"; +import QueriesCard from "../cards/Queries"; import PacksCard from "../cards/Packs"; import PolicyDetailsModal from "../cards/Policies/HostPoliciesTable/PolicyDetailsModal"; import UnenrollMdmModal from "./modals/UnenrollMdmModal"; @@ -238,6 +238,96 @@ const HostDetailsPage = ({ mdm?.enrollment_status !== null && refetchMdm(); }; + // TODO - remove dummy schedule + const dummySchedule: IQueryStats[] = [ + { + scheduled_query_name: "cached query 1 - Never reported", + query_name: "query 1", + description: "should render 'Never' ", + discard_data: false, + last_fetched: null, + automations_enabled: false, + interval: 1000, + scheduled_query_id: 1, + + pack_id: 1, + pack_name: "Team: 💻 Workstations", + average_memory: 435814, + denylisted: false, + executions: 5, + last_executed: "2023-11-29T15:20:02Z", + output_size: 1204, + system_time: 9, + user_time: 3, + wall_time: 0, + }, + { + scheduled_query_name: "cached query 2 - stored results", + description: "should render with row clickable to its report", + discard_data: false, + query_name: "query 2", + last_fetched: "2023-11-29T15:20:02Z", + automations_enabled: false, + interval: 1000, + scheduled_query_id: 2, + + pack_id: 1, + pack_name: "Team: 💻 Workstations", + average_memory: 435814, + denylisted: false, + executions: 5, + last_executed: "2023-11-29T15:20:02Z", + output_size: 1204, + system_time: 9, + user_time: 3, + wall_time: 0, + }, + { + scheduled_query_name: + "cached query 3 - sending results to a log destination, not storing in Fleet", + description: "should render '---', not link to report", + interval: 1000, + discard_data: true, + automations_enabled: true, + query_name: "query 3", + last_fetched: null, + scheduled_query_id: 3, + + pack_id: 1, + pack_name: "Team: 💻 Workstations", + average_memory: 435814, + denylisted: false, + executions: 5, + last_executed: "2023-11-29T15:20:02Z", + output_size: 1204, + system_time: 9, + user_time: 3, + wall_time: 0, + }, + { + scheduled_query_name: + "cached query 4 - stored results, but no current interval", + description: "should render with row clickable to its report", + discard_data: false, + query_name: "query 4", + last_fetched: "2023-11-29T15:20:02Z", + automations_enabled: false, + interval: 0, + scheduled_query_id: 4, + + pack_id: 1, + pack_name: "Team: 💻 Workstations", + average_memory: 435814, + denylisted: false, + executions: 5, + last_executed: "2023-11-29T15:20:02Z", + output_size: 1204, + system_time: 9, + user_time: 3, + wall_time: 0, + }, + ]; + const { isLoading: isLoadingHost, data: host, @@ -300,6 +390,8 @@ const HostDetailsPage = ({ } setHostSoftware(returnedHost.software || []); setUsersState(returnedHost.users || []); + // TODO – remove dummy data + setSchedule(dummySchedule); if (returnedHost.pack_stats) { const packStatsByType = returnedHost.pack_stats.reduce( ( @@ -318,7 +410,8 @@ const HostDetailsPage = ({ }, { packs: [], schedule: [] } ); - setSchedule(packStatsByType.schedule); + // TODO - restore real data + // setSchedule(packStatsByType.schedule); setPacksState(packStatsByType.packs); } }, @@ -583,7 +676,7 @@ const HostDetailsPage = ({ ); }; - if (isLoadingHost) { + if (!host || isLoadingHost) { return ; } const failingPoliciesCount = host?.issues.failing_policies_count || 0; @@ -605,9 +698,9 @@ const HostDetailsPage = ({ pathname: PATHS.HOST_SOFTWARE(hostIdFromURL), }, { - name: "Schedule", - title: "schedule", - pathname: PATHS.HOST_SCHEDULE(hostIdFromURL), + name: "Queries", + title: "queries", + pathname: PATHS.HOST_QUERIES(hostIdFromURL), }, { name: ( @@ -768,10 +861,14 @@ const HostDetailsPage = ({ )} - {canViewPacks && ( diff --git a/frontend/pages/hosts/details/_styles.scss b/frontend/pages/hosts/details/_styles.scss index 919d3a8c51..87b0fc45be 100644 --- a/frontend/pages/hosts/details/_styles.scss +++ b/frontend/pages/hosts/details/_styles.scss @@ -292,4 +292,11 @@ align-items: center; } } + + .empty-table { + &__container { + margin: 0 0 $pad-xxlarge 0; + min-height: initial; + } + } } diff --git a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx index 6c3cb957ca..b60930eccd 100644 --- a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx +++ b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx @@ -340,10 +340,10 @@ const HostSummary = ({ : titleData.display_name || DEFAULT_EMPTY_CELL_VALUE} -

+

{"Last fetched"} {lastFetched}   -

+
{renderRefetch()} diff --git a/frontend/pages/hosts/details/cards/Queries/HostQueries.tsx b/frontend/pages/hosts/details/cards/Queries/HostQueries.tsx new file mode 100644 index 0000000000..18362d3937 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Queries/HostQueries.tsx @@ -0,0 +1,118 @@ +import React, { useCallback, useMemo } from "react"; + +import { IQueryStats } from "interfaces/query_stats"; +import TableContainer from "components/TableContainer"; +import EmptyTable from "components/EmptyTable"; +import CustomLink from "components/CustomLink"; +import PATHS from "router/paths"; +import { InjectedRouter } from "react-router"; +import { Row } from "react-table"; + +import { + generateColumnConfigs, + generateDataSet, +} from "./HostQueriesTableConfig"; + +const baseClass = "host-queries"; + +interface IHostQueriesProps { + hostId: number; + schedule?: IQueryStats[]; + isChromeOSHost: boolean; + queryReportsDisabled?: boolean; + router: InjectedRouter; +} + +interface IHostQueriesRowProps extends Row { + original: { + id?: number; + should_link_to_hqr?: boolean; + }; +} +const HostQueries = ({ + hostId, + schedule, + isChromeOSHost, + queryReportsDisabled, + router, +}: IHostQueriesProps): JSX.Element => { + const renderEmptyQueriesTab = () => { + if (isChromeOSHost) { + return ( + + Interested in collecting data from your Chromebooks? + + + } + /> + ); + } + return ( + + Expecting to see queries? Try selecting Refetch to ask this + host to report fresh vitals. + + } + /> + ); + }; + + const onSelectSingleRow = useCallback( + (row: IHostQueriesRowProps) => { + const { id: queryId, should_link_to_hqr } = row.original; + + if (!hostId || !queryId || !should_link_to_hqr || queryReportsDisabled) { + return; + } + router.push(`${PATHS.HOST_QUERY_REPORT(hostId, queryId)}`); + }, + [hostId, queryReportsDisabled, router] + ); + + const tableData = useMemo(() => generateDataSet(schedule ?? []), [schedule]); + + const columnConfigs = useMemo( + () => generateColumnConfigs(queryReportsDisabled), + [queryReportsDisabled] + ); + + return ( +
+

Queries

+ {!schedule || !schedule.length || isChromeOSHost ? ( + renderEmptyQueriesTab() + ) : ( +
+ null} + resultsTitle="queries" + defaultSortHeader="scheduled_query_name" + defaultSortDirection="asc" + showMarkAllPages={false} + isAllPagesSelected={false} + emptyComponent={() => <>} + disablePagination + disableCount + disableMultiRowSelect + isLoading={false} // loading state handled at parent level + {...{ onSelectSingleRow }} + /> +
+ )} +
+ ); +}; + +export default HostQueries; diff --git a/frontend/pages/hosts/details/cards/Queries/HostQueriesTableConfig.tsx b/frontend/pages/hosts/details/cards/Queries/HostQueriesTableConfig.tsx new file mode 100644 index 0000000000..6630ed0897 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Queries/HostQueriesTableConfig.tsx @@ -0,0 +1,166 @@ +import React from "react"; + +import { IQueryStats } from "interfaces/query_stats"; +import { performanceIndicator } from "utilities/helpers"; + +import TextCell from "components/TableContainer/DataTable/TextCell"; +import PillCell from "components/TableContainer/DataTable/PillCell"; +import TooltipWrapper from "components/TooltipWrapper"; +import ReportUpdatedCell from "pages/hosts/details/cards/Queries/ReportUpdatedCell"; +import Icon from "components/Icon"; + +interface IHostQueriesTableData extends Partial { + performance: { indicator: string; id: number }; + should_link_to_hqr: boolean; +} +interface IHeaderProps { + column: { + title: string; + isSortedDesc: boolean; + }; +} + +interface IRowProps { + row: { + original: IHostQueriesTableData; + }; +} + +interface ICellProps extends IRowProps { + cell: { + value: string | number | boolean; + }; +} + +interface IPillCellProps extends IRowProps { + cell: { + value: { + indicator: string; + id: number; + }; + }; +} + +interface IDataColumn { + title?: string; + Header: ((props: IHeaderProps) => JSX.Element) | string; + accessor: string; + Cell: + | ((props: ICellProps) => JSX.Element) + | ((props: IPillCellProps) => JSX.Element); + disableHidden?: boolean; + disableSortBy?: boolean; +} + +// NOTE: cellProps come from react-table +// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties +const generateColumnConfigs = ( + queryReportsDisabled?: boolean +): IDataColumn[] => { + const cols: IDataColumn[] = [ + { + title: "Query", + Header: "Query", + disableSortBy: true, + accessor: "query_name", + Cell: (cellProps: ICellProps) => ( + + ), + }, + { + Header: () => { + return ( + + This is the performance
+ impact on this host. + + } + > + Performance impact +
+ ); + }, + disableSortBy: true, + accessor: "performance", + Cell: (cellProps: IPillCellProps) => { + const baseClass = "performance-cell"; + return ( + + + {!queryReportsDisabled && + cellProps.row.original.should_link_to_hqr && ( + + )} + + ); + }, + }, + ]; + + // include the Report updated column if query reports are globally enabled + if (!queryReportsDisabled) { + cols.push({ + Header: "Report updated", + disableSortBy: true, + accessor: "last_fetched", // tbd - may change + Cell: (cellProps: ICellProps) => ( + + ), + }); + } + return cols; +}; + +const enhanceScheduleData = ( + query_stats: IQueryStats[] +): IHostQueriesTableData[] => { + return Object.values(query_stats).map((query) => { + const { + user_time, + system_time, + executions, + query_name, + scheduled_query_id, + last_fetched, + interval, + discard_data, + automations_enabled, + } = query; + const scheduledQueryPerformance = { + user_time_p50: user_time, + system_time_p50: system_time, + total_executions: executions, + }; + return { + query_name, + id: scheduled_query_id, + performance: { + indicator: performanceIndicator(scheduledQueryPerformance), + id: scheduled_query_id, + }, + last_fetched, + interval, + discard_data, + automations_enabled, + should_link_to_hqr: !!last_fetched || (!!interval && !discard_data), + }; + }); +}; + +const generateDataSet = ( + query_stats: IQueryStats[] +): IHostQueriesTableData[] => { + return query_stats ? enhanceScheduleData(query_stats) : []; +}; + +export { generateColumnConfigs, generateDataSet }; diff --git a/frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/ReportUpdatedCell.tests.tsx b/frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/ReportUpdatedCell.tests.tsx new file mode 100644 index 0000000000..e9865c7328 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/ReportUpdatedCell.tests.tsx @@ -0,0 +1,72 @@ +import React from "react"; + +import { render, screen } from "@testing-library/react"; + +import ReportUpdatedCell from "./ReportUpdatedCell"; + +describe("ReportUpdatedCell component", () => { + it("Renders '---' with tooltip and no link when run on an interval with discard data and automations enabled", () => { + render( + + ); + + expect(screen.getByText(/---/)).toBeInTheDocument(); + expect(screen.getByText(/Results from this query/)).toBeInTheDocument(); + expect(screen.queryByText(/View report/)).toBeNull(); + }); + + it("Renders 'Never with tooltip and link to report when run on an interval with discard data off and no last_fetched time", () => { + render( + + ); + + expect(screen.getByText(/Never/)).toBeInTheDocument(); + expect(screen.getByText(/This query has not run/)).toBeInTheDocument(); + expect(screen.getByText(/View report/)).toBeInTheDocument(); + }); + + it("Renders a last-updated timestamp with tooltip and link to report when a last_fetched date is present", () => { + render( + + ); + + expect( + screen.getByText(/\d\d\/\d\d\/\d\d\d\d, \d{1,2}:\d{1,2}:\d{1,2}( AM|PM)?/) + ).toBeInTheDocument(); + expect(screen.getByText(/\d+ days ago/)).toBeInTheDocument(); + expect(screen.getByText(/View report/)).toBeInTheDocument(); + }); + it("Renders a last-updated timestamp with tooltip and link to report when a last_fetched date is present but not currently running an interval", () => { + render( + + ); + + expect( + screen.getByText(/\d\d\/\d\d\/\d\d\d\d, \d{1,2}:\d{1,2}:\d{1,2}( AM|PM)?/) + ).toBeInTheDocument(); + expect(screen.getByText(/\d+ days ago/)).toBeInTheDocument(); + expect(screen.getByText(/View report/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/ReportUpdatedCell.tsx b/frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/ReportUpdatedCell.tsx new file mode 100644 index 0000000000..8bdec1eab0 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/ReportUpdatedCell.tsx @@ -0,0 +1,111 @@ +import React from "react"; + +import { HumanTimeDiffWithFleetLaunchCutoff } from "components/HumanTimeDiffWithDateTip"; +import { uniqueId } from "lodash"; +import ReactTooltip from "react-tooltip"; +import { COLORS } from "styles/var/colors"; +import Icon from "components/Icon"; +import TextCell from "components/TableContainer/DataTable/TextCell"; + +const baseClass = "report-updated-cell"; + +interface IReportUpdatedCell { + last_fetched?: string | null; + interval?: number; + discard_data?: boolean; + automations_enabled?: boolean; + should_link_to_hqr?: boolean; +} + +const ReportUpdatedCell = ({ + last_fetched, + interval, + discard_data, + automations_enabled, + should_link_to_hqr, +}: IReportUpdatedCell) => { + const renderCellValue = () => { + // if this query doesn't have an interval, it either has a stored report from previous runs + // and will link to that report, or won't be included in this data in the first place. + if (interval) { + if (discard_data && automations_enabled) { + // this is also the only case where the row is NOT clickable with a link to the host's HQR + // query runs, sends results to a logging dest, doesn't cache + return ( + + Results from this query are not reported in Fleet. +
+ Data is being sent to your log destination. + + } + /> + ); + } + + // Query is scheduled to run on host, but hasn't yet + if (!last_fetched) { + const tipId = uniqueId(); + return ( + ( + <> + + {val} + + + This query has not run on this host. + + + )} + greyed + classes={`${baseClass}__value`} + /> + ); + } + } + + // render with link to cached results (link handled by clickable parent row) + return ( + <> + + + ); + }; + + return ( + + {renderCellValue()} + {should_link_to_hqr && ( + // actual link functionality handled by clickable parent row + + View report + + + )} + + ); +}; + +export default ReportUpdatedCell; diff --git a/frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/_styles.scss b/frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/_styles.scss new file mode 100644 index 0000000000..ef4296bb5c --- /dev/null +++ b/frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/_styles.scss @@ -0,0 +1,17 @@ +.report-updated-cell { + @include cell-with-link; + + &__value { + min-width: initial; + } + + &__link { + @include table-link; + } + + &__link-text { + // hover state of parent tr sets opacity to 1 + opacity: 0; + transition: opacity 250ms; + } +} diff --git a/frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/index.ts b/frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/index.ts new file mode 100644 index 0000000000..1fd58ec49c --- /dev/null +++ b/frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/index.ts @@ -0,0 +1 @@ +export { default } from "./ReportUpdatedCell"; diff --git a/frontend/pages/hosts/details/cards/Queries/_styles.scss b/frontend/pages/hosts/details/cards/Queries/_styles.scss new file mode 100644 index 0000000000..bffcdac122 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Queries/_styles.scss @@ -0,0 +1,60 @@ +.section--host-queries { + margin-top: $pad-medium; + .section__header { + margin-bottom: $pad-medium; + } + .table-container__header { + display: none; + } + .data-table-block { + .data-table__table { + thead { + .query_name__header { + width: $col-lg; + } + .last_fetched__header { + display: table-cell; + } + @media (max-width: $break-md) { + .last_fetched__header { + display: none; + width: 0; + } + } + } + tbody { + tr { + .query_name__cell { + width: $col-lg; + } + .last_fetched__cell { + display: table-cell; + } + .performance-cell { + @include cell-with-link; + &__link-icon { + display: none; + width: 0; + } + } + &:hover { + .report-updated-cell__link-text { + opacity: 1; + } + } + @media (max-width: $break-md) { + .last_fetched__cell { + display: none; + width: 0; + } + .performance-cell__link-icon { + display: inline-flex; + align-self: center; + width: initial; + } + } + } + } + } + } +} diff --git a/frontend/pages/hosts/details/cards/Queries/index.ts b/frontend/pages/hosts/details/cards/Queries/index.ts new file mode 100644 index 0000000000..b6536c4c04 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Queries/index.ts @@ -0,0 +1 @@ +export { default } from "./HostQueries"; diff --git a/frontend/pages/hosts/details/cards/Schedule/Schedule.tsx b/frontend/pages/hosts/details/cards/Schedule/Schedule.tsx deleted file mode 100644 index 1c37493510..0000000000 --- a/frontend/pages/hosts/details/cards/Schedule/Schedule.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from "react"; - -import { IQueryStats } from "interfaces/query_stats"; -import TableContainer from "components/TableContainer"; -import EmptyTable from "components/EmptyTable"; -import CustomLink from "components/CustomLink"; - -import { generateTableHeaders, generateDataSet } from "./ScheduleTableConfig"; - -const baseClass = "schedule"; - -interface IScheduleProps { - schedule?: IQueryStats[]; - isChromeOSHost: boolean; - isLoading: boolean; -} - -const Schedule = ({ - schedule, - isChromeOSHost, - isLoading, -}: IScheduleProps): JSX.Element => { - const wrapperClassName = `${baseClass}__pack-table`; - const tableHeaders = generateTableHeaders(); - - const renderEmptyScheduleTab = () => { - if (isChromeOSHost) { - return ( - - Interested in collecting data from your Chromebooks? - - - } - /> - ); - } - return ( - - ); - }; - - return ( -
-

Schedule

- {!schedule || !schedule.length || isChromeOSHost ? ( - renderEmptyScheduleTab() - ) : ( -
- null} - resultsTitle={"queries"} - defaultSortHeader={"scheduled_query_name"} - defaultSortDirection={"asc"} - showMarkAllPages={false} - isAllPagesSelected={false} - emptyComponent={() => <>} - disablePagination - disableCount - /> -
- )} -
- ); -}; - -export default Schedule; diff --git a/frontend/pages/hosts/details/cards/Schedule/ScheduleTableConfig.tsx b/frontend/pages/hosts/details/cards/Schedule/ScheduleTableConfig.tsx deleted file mode 100644 index 1bada23c35..0000000000 --- a/frontend/pages/hosts/details/cards/Schedule/ScheduleTableConfig.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import React from "react"; - -import { IQueryStats } from "interfaces/query_stats"; -import { performanceIndicator, secondsToDhms } from "utilities/helpers"; - -import TextCell from "components/TableContainer/DataTable/TextCell"; -import PillCell from "components/TableContainer/DataTable/PillCell"; -import TooltipWrapper from "components/TooltipWrapper"; - -interface IHeaderProps { - column: { - title: string; - isSortedDesc: boolean; - }; -} - -interface IRowProps { - row: { - original: IQueryStats; - }; -} - -interface ICellProps extends IRowProps { - cell: { - value: string | number | boolean; - }; -} - -interface IPillCellProps extends IRowProps { - cell: { - value: { - indicator: string; - id: number; - }; - }; -} - -interface IDataColumn { - title?: string; - Header: ((props: IHeaderProps) => JSX.Element) | string; - accessor: string; - Cell: - | ((props: ICellProps) => JSX.Element) - | ((props: IPillCellProps) => JSX.Element); - disableHidden?: boolean; - disableSortBy?: boolean; -} - -interface IScheduleTable extends Partial { - frequency: string; - performance: { indicator: string; id: number }; -} - -// NOTE: cellProps come from react-table -// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties -const generateTableHeaders = (): IDataColumn[] => { - return [ - { - title: "Query", - Header: "Query", - disableSortBy: true, - accessor: "query_name", - Cell: (cellProps: ICellProps) => ( - - ), - }, - { - title: "Frequency", - Header: "Frequency", - disableSortBy: true, - accessor: "frequency", - Cell: (cellProps: ICellProps) => ( - - ), - }, - { - Header: () => { - return ( - - This is the performance
- impact on this host. - - } - > - Performance impact -
- ); - }, - disableSortBy: true, - accessor: "performance", - Cell: (cellProps: IPillCellProps) => ( - - ), - }, - ]; -}; - -const enhanceScheduleData = (query_stats: IQueryStats[]): IScheduleTable[] => { - return Object.values(query_stats).map((query) => { - const scheduledQueryPerformance = { - user_time_p50: query.user_time, - system_time_p50: query.system_time, - total_executions: query.executions, - }; - return { - query_name: query.query_name, - frequency: secondsToDhms(query.interval), - performance: { - indicator: performanceIndicator(scheduledQueryPerformance), - id: query.scheduled_query_id, - }, - }; - }); -}; - -const generateDataSet = (query_stats: IQueryStats[]): IScheduleTable[] => { - if (!query_stats) { - return query_stats; - } - - return [...enhanceScheduleData(query_stats)]; -}; - -export { generateTableHeaders, generateDataSet }; diff --git a/frontend/pages/hosts/details/cards/Schedule/_styles.scss b/frontend/pages/hosts/details/cards/Schedule/_styles.scss deleted file mode 100644 index 4841674726..0000000000 --- a/frontend/pages/hosts/details/cards/Schedule/_styles.scss +++ /dev/null @@ -1,29 +0,0 @@ -.section--schedule { - margin-top: $pad-medium; - .section__header { - margin-bottom: $pad-medium; - } - .table-container__header { - display: none; - } - .data-table-block { - .data-table__table { - thead { - .query_name__header { - width: $col-lg; - } - .frequency__header { - width: $col-md; - } - } - tbody { - .query_name__cell { - width: $col-lg; - } - .frequency__cell { - width: $col-md; - } - } - } - } -} diff --git a/frontend/pages/hosts/details/cards/Schedule/index.ts b/frontend/pages/hosts/details/cards/Schedule/index.ts deleted file mode 100644 index 39250f5640..0000000000 --- a/frontend/pages/hosts/details/cards/Schedule/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./Schedule"; diff --git a/frontend/pages/software/ManageSoftwarePage/ManageSoftwarePage.tsx b/frontend/pages/software/ManageSoftwarePage/ManageSoftwarePage.tsx index ee01fbf49f..714547380b 100644 --- a/frontend/pages/software/ManageSoftwarePage/ManageSoftwarePage.tsx +++ b/frontend/pages/software/ManageSoftwarePage/ManageSoftwarePage.tsx @@ -11,7 +11,6 @@ import { useQuery } from "react-query"; import { InjectedRouter } from "react-router/lib/Router"; import { RouteProps } from "react-router/lib/Route"; import { isEmpty, isEqual } from "lodash"; -// import { useDebouncedCallback } from "use-debounce"; import { AppContext } from "context/app"; import { NotificationContext } from "context/notification"; @@ -48,7 +47,6 @@ import TableDataError from "components/DataError"; import Dropdown from "components/forms/fields/Dropdown"; import LastUpdatedText from "components/LastUpdatedText"; import MainContent from "components/MainContent"; -import Spinner from "components/Spinner"; import TableContainer from "components/TableContainer"; import { ITableQueryData } from "components/TableContainer/TableContainer"; import TeamsDropdown from "components/TeamsDropdown"; @@ -91,7 +89,7 @@ interface ISoftwareAutomations { }; } -interface IRowProps extends Row { +interface ISoftwareRowProps extends Row { original: { id?: number; }; @@ -593,7 +591,7 @@ const ManageSoftwarePage = ({ ), [isPremiumTier, isSandboxMode, router, currentTeamId] ); - const handleRowSelect = (row: IRowProps) => { + const onSelectSingleRow = (row: ISoftwareRowProps) => { const hostsBySoftwareParams = { software_id: row.original.id, team_id: currentTeamId, @@ -631,7 +629,7 @@ const ManageSoftwarePage = ({ !globalConfig || (!softwareConfig && !softwareConfigError) } - resultsTitle={"software items"} + resultsTitle="software items" emptyComponent={() => ( ); }; @@ -709,15 +704,17 @@ const ManageSoftwarePage = ({ {showManageAutomationsModal && ( )} diff --git a/frontend/router/index.tsx b/frontend/router/index.tsx index 645c3616d2..ae73ef01c2 100644 --- a/frontend/router/index.tsx +++ b/frontend/router/index.tsx @@ -182,7 +182,9 @@ const routes = ( - + + {/* legacy route */} + diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts index 6babce6bba..2d9ed40a49 100644 --- a/frontend/router/paths.ts +++ b/frontend/router/paths.ts @@ -92,12 +92,14 @@ export default { HOST_SOFTWARE: (id: number): string => { return `${URL_PREFIX}/hosts/${id}/software`; }, - HOST_SCHEDULE: (id: number): string => { - return `${URL_PREFIX}/hosts/${id}/schedule`; + HOST_QUERIES: (id: number): string => { + return `${URL_PREFIX}/hosts/${id}/queries`; }, HOST_POLICIES: (id: number): string => { return `${URL_PREFIX}/hosts/${id}/policies`; }, + HOST_QUERY_REPORT: (hostId: number, queryId: number): string => + `${URL_PREFIX}/hosts/${hostId}/queries/${queryId}`, DEVICE_USER_DETAILS: (deviceAuthToken: any): string => { return `${URL_PREFIX}/device/${deviceAuthToken}`; }, diff --git a/frontend/styles/global/_global.scss b/frontend/styles/global/_global.scss index 17714c51a3..414b11949b 100644 --- a/frontend/styles/global/_global.scss +++ b/frontend/styles/global/_global.scss @@ -53,17 +53,7 @@ h1 { } a { - color: $core-vibrant-blue; - font-weight: $bold; - font-size: $x-small; - text-decoration: none; - - &:focus-visible { - outline-color: #d9d9fe; - outline-offset: 3px; - outline-style: solid; - outline-width: 2px; - } + @include link; } .__react_component_tooltip { diff --git a/frontend/styles/var/mixins.scss b/frontend/styles/var/mixins.scss index f8fd2d6fe9..355c10af82 100644 --- a/frontend/styles/var/mixins.scss +++ b/frontend/styles/var/mixins.scss @@ -121,3 +121,32 @@ $max-width: 2560px; font-size: $xx-small; color: $ui-fleet-black-75; } + +@mixin link { + color: $core-vibrant-blue; + font-weight: $bold; + font-size: $x-small; + text-decoration: none; + &:focus-visible { + outline-color: #d9d9fe; + outline-offset: 3px; + outline-style: solid; + outline-width: 2px; + } +} + +@mixin table-link { + display: inline-flex; + align-items: center; + padding: $pad-small $pad-xxsmall; // larger clickable area + gap: $pad-small; + white-space: nowrap; + @include link; +} + +@mixin cell-with-link { + display: inline-flex; + align-items: center; + justify-content: space-between; + width: 100%; +} From 333674b0515f56c7854ffbe343a6dbd0a7af0b56 Mon Sep 17 00:00:00 2001 From: Jacob Shandling <61553566+jacobshandling@users.noreply.github.com> Date: Fri, 8 Dec 2023 16:54:24 -0800 Subject: [PATCH 3/9] =?UTF-8?q?UI=20=E2=80=93=20Host=20query=20report=20pa?= =?UTF-8?q?ge=20(#15511)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Addresses second major part of #15011 (item 3) – Host query report _Note for reviewers: The most important files here are:_ - HostQueryReport.tsx - HQRTable.tsx - HQRTableConfig.tsx _The rest are associated API services, interfaces, helpers, routes, styles, and miscellanious code improvements I made along the way._ ____________ ### See linked issue for enumeration of feature-related tasks Screenshot 2023-12-08 at 4 23 50 PM collecting Screenshot 2023-12-08 at 4 24 39 PM Screenshot 2023-12-08 at 4 25 01 PM Re-routes to host details > queries if: - query reports are globally disabled: https://github.com/fleetdm/fleet/assets/61553566/ac67da8c-57bc-4d9b-96be-daf3b198e704 - query has `Discard data` enabled: https://github.com/fleetdm/fleet/assets/61553566/b797dd24-9893-4360-bf40-b80298848864 - [x] Manual QA for all new/changed functionality --------- Co-authored-by: Jacob Shandling --- frontend/components/App/App.tsx | 3 +- frontend/components/BackLink/BackLink.tsx | 14 +- frontend/components/BackLink/_styles.scss | 17 +- frontend/components/EmptyTable/EmptyTable.tsx | 8 +- frontend/components/EmptyTable/_styles.scss | 19 +- .../LiveQuery/TargetsInput/TargetsInput.tsx | 4 +- .../DataTable/IssueCell/IssueCell.tsx | 3 +- .../LiveQueryIssueCell/LiveQueryIssueCell.tsx | 3 +- .../DataTable/PillCell/PillCell.tsx | 3 +- .../TooltipTruncatedTextCell.tsx | 3 +- .../TableContainer/TableContainer.tsx | 9 +- .../PackQueriesTable/PackQueriesTable.tsx | 2 +- .../ActivityItem/ActivityItem.tsx | 3 +- .../pages/DashboardPage/cards/MDM/MDM.tsx | 4 +- .../pages/DashboardPage/cards/Munki/Munki.tsx | 4 +- .../OperatingSystems/OperatingSystems.tsx | 2 +- .../DashboardPage/cards/Software/Software.tsx | 4 +- .../DiskEncryptionTable.tsx | 2 +- .../OSVersionTable/OSVersionTable.tsx | 2 +- .../BootstrapPackageTable.tsx | 4 +- .../BootstrapPackageTableConfig.tsx | 4 +- .../cards/Integrations/Integrations.tsx | 2 +- .../IntegrationForm/IntegrationForm.tsx | 3 +- .../MembersPage/MembersPage.tsx | 6 +- .../MembersPage/MembersPageTableConfig.tsx | 7 +- .../TeamManagementPage/TeamManagementPage.tsx | 2 +- .../components/UsersTable/UsersTable.tsx | 2 +- .../UsersTable/UsersTableConfig.tsx | 3 +- .../hosts/ManageHostsPage/HostTableConfig.tsx | 7 +- .../hosts/ManageHostsPage/ManageHostsPage.tsx | 2 +- .../components/FilterPill/FilterPill.tsx | 3 +- .../details/DeviceUserPage/DeviceUserPage.tsx | 5 +- .../hosts/details/DeviceUserPage/_styles.scss | 4 - .../HostDetailsPage/HostDetailsPage.tsx | 7 +- .../details/HostDetailsPage/_styles.scss | 7 - .../HostDetailsBanners/HostDetailsBanners.tsx | 39 +- .../HostQueryReport/HQRTable/HQRTable.tsx | 174 ++++++++ .../HQRTable/HQRTableConfig.tsx | 65 +++ .../HostQueryReport/HQRTable/_styles.scss | 55 +++ .../details/HostQueryReport/HQRTable/index.ts | 1 + .../HostQueryReport/HostQueryReport.tsx | 414 ++++++++++++++++++ .../details/HostQueryReport/_styles.scss | 24 + .../hosts/details/HostQueryReport/index.ts | 1 + .../OSSettingStatusCell.tsx | 3 +- .../OSSettingsTable/OSSettingsTable.tsx | 2 +- .../ProfileStatusIndicator.tsx | 5 +- frontend/pages/hosts/details/_styles.scss | 14 +- .../pages/hosts/details/cards/About/About.tsx | 5 +- .../details/cards/HostSummary/HostSummary.tsx | 5 +- .../OSSettingsIndicator.tsx | 3 +- .../details/cards/MunkiIssues/MunkiIssues.tsx | 2 +- .../pages/hosts/details/cards/Packs/Packs.tsx | 2 +- .../hosts/details/cards/Policies/Policies.tsx | 2 +- .../details/cards/Queries/HostQueries.tsx | 6 +- .../hosts/details/cards/Scripts/Scripts.tsx | 2 +- .../hosts/details/cards/Software/Software.tsx | 2 +- .../cards/Software/SoftwareTableConfig.tsx | 4 +- .../pages/hosts/details/cards/Users/Users.tsx | 2 +- .../components/PacksTable/PacksTable.tsx | 2 +- .../PoliciesTable/PoliciesTable.tsx | 3 +- .../PoliciesTable/PoliciesTableConfig.tsx | 4 +- .../pages/policies/PolicyPage/PolicyPage.tsx | 4 +- .../PolicyQueriesErrorsTable.tsx | 2 +- .../PolicyQueriesTable/PolicyQueriesTable.tsx | 2 +- .../SaveNewPolicyModal/SaveNewPolicyModal.tsx | 3 +- .../components/QueriesTable/QueriesTable.tsx | 2 +- .../QueriesTable/QueriesTableConfig.tsx | 2 +- .../QueryDetailsPage/QueryDetailsPage.tsx | 7 +- .../components/QueryReport/QueryReport.tsx | 2 +- .../QueryReport/QueryReportTableConfig.tsx | 13 +- frontend/pages/queries/edit/EditQueryPage.tsx | 10 +- .../EditQueryForm/EditQueryForm.tsx | 5 +- .../components/QueryResults/QueryResults.tsx | 2 +- .../QueryResults/QueryResultsTableConfig.tsx | 17 +- .../live/LiveQueryPage/LiveQueryPage.tsx | 6 +- .../ManageSoftwarePage/ManageSoftwarePage.tsx | 2 +- .../SoftwareTableConfig.tsx | 3 +- .../ManageAutomationsModal.tsx | 3 +- .../SoftwareDetailsPage.tsx | 7 +- .../Vulnerabilities/Vulnerabilities.tsx | 2 +- frontend/router/index.tsx | 26 +- frontend/router/page_titles.ts | 43 +- frontend/router/paths.ts | 2 +- .../services/entities/host_query_report.ts | 20 + frontend/services/entities/query_report.ts | 5 +- frontend/styles/var/mixins.scss | 38 +- frontend/utilities/constants.tsx | 2 + frontend/utilities/endpoints.ts | 3 + frontend/utilities/helpers.tsx | 20 +- 89 files changed, 1048 insertions(+), 223 deletions(-) create mode 100644 frontend/pages/hosts/details/HostQueryReport/HQRTable/HQRTable.tsx create mode 100644 frontend/pages/hosts/details/HostQueryReport/HQRTable/HQRTableConfig.tsx create mode 100644 frontend/pages/hosts/details/HostQueryReport/HQRTable/_styles.scss create mode 100644 frontend/pages/hosts/details/HostQueryReport/HQRTable/index.ts create mode 100644 frontend/pages/hosts/details/HostQueryReport/HostQueryReport.tsx create mode 100644 frontend/pages/hosts/details/HostQueryReport/_styles.scss create mode 100644 frontend/pages/hosts/details/HostQueryReport/index.ts create mode 100644 frontend/services/entities/host_query_report.ts diff --git a/frontend/components/App/App.tsx b/frontend/components/App/App.tsx index b65d7828ea..1f844da6de 100644 --- a/frontend/components/App/App.tsx +++ b/frontend/components/App/App.tsx @@ -24,6 +24,7 @@ import Fleet404 from "pages/errors/Fleet404"; import Fleet500 from "pages/errors/Fleet500"; import Spinner from "components/Spinner"; import { QueryParams } from "utilities/url"; +import { DOCUMENT_TITLE_SUFFIX } from "utilities/constants"; interface IAppProps { children: JSX.Element; @@ -122,7 +123,7 @@ const App = ({ children, location }: IAppProps): JSX.Element => { !config?.mdm.enabled_and_configured && curTitle?.path === "/controls/os-updates" ) { - curTitle.title = "Manage OS hosts | Fleet for osquery"; + curTitle.title = `Manage OS hosts | ${DOCUMENT_TITLE_SUFFIX}`; } if (curTitle && curTitle.title) { diff --git a/frontend/components/BackLink/BackLink.tsx b/frontend/components/BackLink/BackLink.tsx index b93ce67bd0..b31aab82f1 100644 --- a/frontend/components/BackLink/BackLink.tsx +++ b/frontend/components/BackLink/BackLink.tsx @@ -13,8 +13,6 @@ interface IBackLinkProps { const baseClass = "back-link"; const BackLink = ({ text, path, className }: IBackLinkProps): JSX.Element => { - const backLinkClass = classnames(baseClass, className); - const onClick = (): void => { if (path) { browserHistory.push(path); @@ -22,13 +20,13 @@ const BackLink = ({ text, path, className }: IBackLinkProps): JSX.Element => { }; return ( - + <> - + {text} diff --git a/frontend/components/BackLink/_styles.scss b/frontend/components/BackLink/_styles.scss index cb4d9f2a6e..aa6854afd3 100644 --- a/frontend/components/BackLink/_styles.scss +++ b/frontend/components/BackLink/_styles.scss @@ -1,18 +1,3 @@ .back-link { - display: inline-flex; - align-items: center; - padding: $pad-small $pad-xxsmall; // larger clickable area - border-radius: 3px; // Visible while tabbing; - gap: $pad-xsmall; - - &:hover { - color: $core-vibrant-blue-over; - text-decoration: underline; - - svg { - path { - stroke: $core-vibrant-blue-over; - } - } - } + @include direction-link; } diff --git a/frontend/components/EmptyTable/EmptyTable.tsx b/frontend/components/EmptyTable/EmptyTable.tsx index 8d0c39249c..75b683c120 100644 --- a/frontend/components/EmptyTable/EmptyTable.tsx +++ b/frontend/components/EmptyTable/EmptyTable.tsx @@ -35,8 +35,12 @@ const EmptyTable = ({ )}
{header &&

{header}

} - {info &&

{info}

} - {additionalInfo &&

{additionalInfo}

} + {info &&
{info}
} + {additionalInfo && ( +
+ {additionalInfo} +
+ )}
{primaryButton && (
diff --git a/frontend/components/EmptyTable/_styles.scss b/frontend/components/EmptyTable/_styles.scss index 15517d8d8f..14b8e3ab93 100644 --- a/frontend/components/EmptyTable/_styles.scss +++ b/frontend/components/EmptyTable/_styles.scss @@ -21,13 +21,6 @@ margin: 0; } - p { - text-align: center; - color: $core-fleet-blue; - font-size: $x-small; - margin: 0; - } - ul { margin: 0; padding: 0; @@ -43,6 +36,18 @@ } } } + &__info { + max-width: 350px; + } + + &__info, + &__additional-info { + line-height: 1.5; + text-align: center; + color: $core-fleet-blue; + font-size: $x-small; + margin: 0; + } &__cta-buttons { display: flex; diff --git a/frontend/components/LiveQuery/TargetsInput/TargetsInput.tsx b/frontend/components/LiveQuery/TargetsInput/TargetsInput.tsx index ff188cb419..84182dcd7a 100644 --- a/frontend/components/LiveQuery/TargetsInput/TargetsInput.tsx +++ b/frontend/components/LiveQuery/TargetsInput/TargetsInput.tsx @@ -63,7 +63,7 @@ const TargetsInput = ({ {isActiveSearch && (
): JSX.Element => { diff --git a/frontend/components/TableContainer/DataTable/LiveQueryIssueCell/LiveQueryIssueCell.tsx b/frontend/components/TableContainer/DataTable/LiveQueryIssueCell/LiveQueryIssueCell.tsx index b18d63efe7..cb789aa7dd 100644 --- a/frontend/components/TableContainer/DataTable/LiveQueryIssueCell/LiveQueryIssueCell.tsx +++ b/frontend/components/TableContainer/DataTable/LiveQueryIssueCell/LiveQueryIssueCell.tsx @@ -2,6 +2,7 @@ import React from "react"; import ReactTooltip from "react-tooltip"; import Icon from "components/Icon"; +import { COLORS } from "styles/var/colors"; interface ILiveQueryIssueCellProps { displayName: string; @@ -38,7 +39,7 @@ const LiveQueryIssueCell = ({ diff --git a/frontend/components/TableContainer/DataTable/PillCell/PillCell.tsx b/frontend/components/TableContainer/DataTable/PillCell/PillCell.tsx index 8fc83621ca..1b482c192b 100644 --- a/frontend/components/TableContainer/DataTable/PillCell/PillCell.tsx +++ b/frontend/components/TableContainer/DataTable/PillCell/PillCell.tsx @@ -3,6 +3,7 @@ import classnames from "classnames"; import { uniqueId } from "lodash"; import ReactTooltip from "react-tooltip"; +import { COLORS } from "styles/var/colors"; interface IPillCellProps { value: { indicator: string; id: number }; @@ -97,7 +98,7 @@ const PillCell = ({ diff --git a/frontend/components/TableContainer/DataTable/TooltipTruncatedTextCell/TooltipTruncatedTextCell.tsx b/frontend/components/TableContainer/DataTable/TooltipTruncatedTextCell/TooltipTruncatedTextCell.tsx index dd6cd61023..96f28d8f6c 100644 --- a/frontend/components/TableContainer/DataTable/TooltipTruncatedTextCell/TooltipTruncatedTextCell.tsx +++ b/frontend/components/TableContainer/DataTable/TooltipTruncatedTextCell/TooltipTruncatedTextCell.tsx @@ -4,6 +4,7 @@ import classnames from "classnames"; import ReactTooltip from "react-tooltip"; import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants"; +import { COLORS } from "styles/var/colors"; interface ITooltipTruncatedTextCellProps { value: string | number | boolean; @@ -46,7 +47,7 @@ const TooltipTruncatedTextCell = ({ @@ -415,7 +416,7 @@ const TableContainer = ({ > {scheduledQueries?.length ? ( {internationalTimeFormat(activityCreatedAt)} diff --git a/frontend/pages/DashboardPage/cards/MDM/MDM.tsx b/frontend/pages/DashboardPage/cards/MDM/MDM.tsx index 45ce2788fc..f5670ac9d4 100644 --- a/frontend/pages/DashboardPage/cards/MDM/MDM.tsx +++ b/frontend/pages/DashboardPage/cards/MDM/MDM.tsx @@ -112,7 +112,7 @@ const Mdm = ({ ) : ( ) : ( ) : ( ) : ( ) : ( ) : ( ) : ( { return (
JSX.Element) | string; accessor: string; @@ -53,7 +53,7 @@ type IDataColumn = { | ((props: IStatusCellProps) => JSX.Element); }; -export const TABLE_HEADERS: IDataColumn[] = [ +export const COLUMN_CONFIGS: IColumnConfig[] = [ { title: "Status", Header: "Status", diff --git a/frontend/pages/admin/IntegrationsPage/cards/Integrations/Integrations.tsx b/frontend/pages/admin/IntegrationsPage/cards/Integrations/Integrations.tsx index ef61ed6f2a..82f497ccc5 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/Integrations/Integrations.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/Integrations/Integrations.tsx @@ -407,7 +407,7 @@ const Integrations = (): JSX.Element => { ) : ( diff --git a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPage/MembersPage.tsx b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPage/MembersPage.tsx index ab4e7f9931..9b638adbae 100644 --- a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPage/MembersPage.tsx +++ b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPage/MembersPage.tsx @@ -31,7 +31,7 @@ import AddMemberModal from "./components/AddMemberModal"; import RemoveMemberModal from "./components/RemoveMemberModal"; import { - generateTableHeaders, + generateColumnConfigs, generateDataSet, IMembersTableData, } from "./MembersPageTableConfig"; @@ -412,7 +412,7 @@ const MembersPage = ({ location, router }: IMembersPageProps): JSX.Element => { return ; } - const tableHeaders = generateTableHeaders(onActionSelection); + const columnConfigs = generateColumnConfigs(onActionSelection); return (
@@ -431,7 +431,7 @@ const MembersPage = ({ location, router }: IMembersPageProps): JSX.Element => { ) : ( void ): IDataColumn[] => { return [ @@ -92,7 +93,7 @@ const generateTableHeaders = ( type="dark" effect="solid" id={`api-only-tooltip-${cellProps.row.original.id}`} - backgroundColor="#3e4771" + backgroundColor={COLORS["tooltip-bg"]} clickable delayHide={200} // need delay set to hover using clickable > @@ -233,4 +234,4 @@ const generateDataSet = ( return [...enhanceMembersData(teamId, users)]; }; -export { generateTableHeaders, generateDataSet }; +export { generateColumnConfigs, generateDataSet }; diff --git a/frontend/pages/admin/TeamManagementPage/TeamManagementPage.tsx b/frontend/pages/admin/TeamManagementPage/TeamManagementPage.tsx index 712fb6808a..7b946149e9 100644 --- a/frontend/pages/admin/TeamManagementPage/TeamManagementPage.tsx +++ b/frontend/pages/admin/TeamManagementPage/TeamManagementPage.tsx @@ -270,7 +270,7 @@ const TeamManagementPage = (): JSX.Element => { ) : ( { return ( diff --git a/frontend/pages/hosts/ManageHostsPage/HostTableConfig.tsx b/frontend/pages/hosts/ManageHostsPage/HostTableConfig.tsx index dfaecd155a..f6236cceda 100644 --- a/frontend/pages/hosts/ManageHostsPage/HostTableConfig.tsx +++ b/frontend/pages/hosts/ManageHostsPage/HostTableConfig.tsx @@ -24,6 +24,7 @@ import { humanHostLastSeen, hostTeamName, } from "utilities/helpers"; +import { COLORS } from "styles/var/colors"; import { IDataColumn } from "interfaces/datatable_config"; import PATHS from "router/paths"; import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants"; @@ -171,7 +172,7 @@ const allHostTableHeaders: IDataColumn[] = [ @@ -355,7 +356,7 @@ const allHostTableHeaders: IDataColumn[] = [ diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx index d8badaf4c0..e638378ba5 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx @@ -23,8 +23,9 @@ import Button from "components/buttons/Button"; import TabsWrapper from "components/TabsWrapper"; import InfoBanner from "components/InfoBanner"; import Icon from "components/Icon/Icon"; -import { normalizeEmptyValues, wrapFleetHelper } from "utilities/helpers"; +import { normalizeEmptyValues } from "utilities/helpers"; import PATHS from "router/paths"; +import { DOCUMENT_TITLE_SUFFIX } from "utilities/constants"; import HostSummaryCard from "../cards/HostSummary"; import AboutCard from "../cards/About"; @@ -309,7 +310,7 @@ const DeviceUserPage = ({ // e.g., Rachel's Macbook Pro schedule details | Fleet for osquery document.title = `My device ${hostTab()} details | ${ host?.display_name || "Unknown host" - } | Fleet for osquery`; + } | ${DOCUMENT_TITLE_SUFFIX}`; }, [location.pathname, host]); const renderActionButtons = () => { diff --git a/frontend/pages/hosts/details/DeviceUserPage/_styles.scss b/frontend/pages/hosts/details/DeviceUserPage/_styles.scss index 2cba28bdfd..ac88dea247 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/_styles.scss +++ b/frontend/pages/hosts/details/DeviceUserPage/_styles.scss @@ -5,13 +5,9 @@ } .device-user { - display: flex; - flex-direction: column; justify-content: flex-start; padding-bottom: 50px; min-width: 0; - background-color: $ui-off-white; - gap: $pad-medium; .info-banner { &__cta { diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index b5aa3f6561..c490e02a3a 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -52,6 +52,7 @@ import { } from "utilities/helpers"; import permissions from "utilities/permissions"; import ScriptDetailsModal from "pages/DashboardPage/cards/ActivityFeed/components/ScriptDetailsModal"; +import { DOCUMENT_TITLE_SUFFIX } from "utilities/constants"; import HostSummaryCard from "../cards/HostSummary"; import AboutCard from "../cards/About"; @@ -453,7 +454,7 @@ const HostDetailsPage = ({ // e.g., Rachel's Macbook Pro schedule details | Fleet for osquery document.title = `Host ${hostTab()} details ${ host?.display_name ? `| ${host?.display_name} |` : "|" - } Fleet for osquery`; + } ${DOCUMENT_TITLE_SUFFIX}`; }, [location.pathname, host]); // Used for back to software pathname @@ -766,7 +767,7 @@ const HostDetailsPage = ({ return ( -
+ <> )} -
+
); }; diff --git a/frontend/pages/hosts/details/HostDetailsPage/_styles.scss b/frontend/pages/hosts/details/HostDetailsPage/_styles.scss index 209857d40e..5a5331f4e1 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/_styles.scss +++ b/frontend/pages/hosts/details/HostDetailsPage/_styles.scss @@ -1,11 +1,4 @@ .host-details { - background-color: $ui-off-white; - - &__wrapper { - display: grid; - gap: 1rem; - } - .component__tabs-wrapper { .react-tabs__tab { display: inline-flex; diff --git a/frontend/pages/hosts/details/HostDetailsPage/components/HostDetailsBanners/HostDetailsBanners.tsx b/frontend/pages/hosts/details/HostDetailsPage/components/HostDetailsBanners/HostDetailsBanners.tsx index e40759fd5b..e39878312f 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/components/HostDetailsBanners/HostDetailsBanners.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/components/HostDetailsBanners/HostDetailsBanners.tsx @@ -45,24 +45,27 @@ const HostDetailsBanners = ({ mdmName === "Fleet" && diskEncryptionStatus === "action_required"; - return ( -
- {showTurnOnMdmInfoBanner && ( - - To change settings and install software, ask the end user to follow - the Turn on MDM instructions on their{" "} - My device page. - - )} - {showDiskEncryptionUserActionRequired && ( - - Disk encryption: Requires action from the end user. Ask the end user - to follow Disk encryption instructions on their{" "} - My device page. - - )} -
- ); + if (showTurnOnMdmInfoBanner || showDiskEncryptionUserActionRequired) { + return ( +
+ {showTurnOnMdmInfoBanner && ( + + To change settings and install software, ask the end user to follow + the Turn on MDM instructions on their{" "} + My device page. + + )} + {showDiskEncryptionUserActionRequired && ( + + Disk encryption: Requires action from the end user. Ask the end user + to follow Disk encryption instructions on their{" "} + My device page. + + )} +
+ ); + } + return null; }; export default HostDetailsBanners; diff --git a/frontend/pages/hosts/details/HostQueryReport/HQRTable/HQRTable.tsx b/frontend/pages/hosts/details/HostQueryReport/HQRTable/HQRTable.tsx new file mode 100644 index 0000000000..9b882041a3 --- /dev/null +++ b/frontend/pages/hosts/details/HostQueryReport/HQRTable/HQRTable.tsx @@ -0,0 +1,174 @@ +import Button from "components/buttons/Button"; +import EmptyTable from "components/EmptyTable"; +import Icon from "components/Icon"; +import TableContainer from "components/TableContainer"; +import React, { useCallback, useState } from "react"; +import { Row } from "react-table"; +import { + generateCSVFilename, + generateCSVQueryResults, +} from "utilities/generate_csv"; +import FileSaver from "file-saver"; +import Spinner from "components/Spinner"; +import { HumanTimeDiffWithFleetLaunchCutoff } from "components/HumanTimeDiffWithDateTip"; +import generateColumnConfigs from "./HQRTableConfig"; + +const baseClass = "hqr-table"; + +interface IHQRTable { + queryName?: string; + queryDescription?: string; + hostName?: string; + rows: Record[]; + reportClipped?: boolean; + lastFetched?: string | null; // timestamp + onShowQuery: () => void; + isLoading: boolean; +} + +const DEFAULT_CSV_TITLE = "Host-Specific Query Report"; + +const HQRTable = ({ + queryName, + queryDescription, + hostName, + rows, + reportClipped, + lastFetched, + onShowQuery, + isLoading, +}: IHQRTable) => { + const [filteredResults, setFilteredResults] = useState([]); + + const columnConfigs = generateColumnConfigs(rows); + + const renderTableButtons = useCallback(() => { + const onExportQueryResults = (evt: React.MouseEvent) => { + evt.preventDefault(); + FileSaver.saveAs( + generateCSVQueryResults( + filteredResults, + generateCSVFilename( + queryName && hostName + ? `'${queryName}' query report results for host '${hostName}'` + : DEFAULT_CSV_TITLE + ), + columnConfigs + ) + ); + }; + return ( +
+ + +
+ ); + }, [onShowQuery, filteredResults, queryName, hostName, columnConfigs]); + + const renderEmptyState = useCallback(() => { + // rows.length === 0 + + if (!lastFetched) { + // collecting results + return ( + + ); + } + if (reportClipped) { + return ( + + ); + } + return ( + // nothing to report + + ); + }, [lastFetched, hostName, reportClipped]); + + const renderCount = useCallback(() => { + const count = filteredResults.length; + return ( +
+ {`${count} result${count === 1 ? "" : "s"}`} + + Last fetched{" "} + + +
+ ); + }, [filteredResults.length, lastFetched]); + + const renderTableInfo = useCallback( + () => ( +
+

{queryName}

+

{queryDescription}

+
+ ), + [queryDescription, queryName] + ); + + if (isLoading) { + return ; + } + return ( +
+ {renderTableInfo()} + {rows.length === 0 ? ( + renderEmptyState() + ) : ( + null} + defaultSortHeader={columnConfigs[0].title} + defaultSortDirection="asc" + /> + )} +
+ ); +}; + +export default HQRTable; diff --git a/frontend/pages/hosts/details/HostQueryReport/HQRTable/HQRTableConfig.tsx b/frontend/pages/hosts/details/HostQueryReport/HQRTable/HQRTableConfig.tsx new file mode 100644 index 0000000000..1245f15ba1 --- /dev/null +++ b/frontend/pages/hosts/details/HostQueryReport/HQRTable/HQRTableConfig.tsx @@ -0,0 +1,65 @@ +import DefaultColumnFilter from "components/TableContainer/DataTable/DefaultColumnFilter"; +import HeaderCell from "components/TableContainer/DataTable/HeaderCell"; +import React from "react"; + +import { + CellProps, + ColumnInstance, + ColumnInterface, + HeaderProps, + TableInstance, +} from "react-table"; +import { + getUniqueColumnNamesFromRows, + humanHostLastSeen, + internallyTruncateText, +} from "utilities/helpers"; + +type IHeaderProps = HeaderProps & { + column: ColumnInstance & IDataColumn; +}; + +type ICellProps = CellProps; + +interface IDataColumn extends ColumnInterface { + title?: string; + accessor: string; +} + +const generateColumnConfigs = (rows: Record[]) => + // casting necessary because of loose typing of below method + // see note there for more details + (getUniqueColumnNamesFromRows(rows) as string[]).map((colName) => { + return { + id: colName, + title: colName, + Header: (headerProps: IHeaderProps) => ( + + ), + accessor: colName, + Cell: (cellProps: ICellProps) => { + // Sorts chronologically by date, but UI displays readable last fetched + if (cellProps.column.id === "last_fetched") { + return humanHostLastSeen(cellProps?.cell?.value); + } + // truncate columns longer than 300 characters + const val = cellProps?.cell?.value; + return !!val?.length && val.length > 300 + ? internallyTruncateText(val) + : val ?? null; + }, + Filter: DefaultColumnFilter, // Component hides filter for last_fetched + filterType: "text", + disableSortBy: false, + }; + }); + +export default generateColumnConfigs; diff --git a/frontend/pages/hosts/details/HostQueryReport/HQRTable/_styles.scss b/frontend/pages/hosts/details/HostQueryReport/HQRTable/_styles.scss new file mode 100644 index 0000000000..9fb768a1ee --- /dev/null +++ b/frontend/pages/hosts/details/HostQueryReport/HQRTable/_styles.scss @@ -0,0 +1,55 @@ +.hqr-table { + gap: $pad-medium; + &__results-count-and-last-fetched { + display: flex; + align-items: baseline; + gap: $pad-small; + + .last-fetched { + font-weight: initial; + @include grey-text; + } + } + &__results-cta { + display: flex; + gap: $pad-medium; + .button { + height: auto; + } + } + + &__export-btn { + .children-wrapper { + align-self: flex-end; + } + .icon { + display: initial; + } + } + + &__query-info { + margin-top: $pad-xsmall; + display: flex; + flex-direction: column; + gap: 0.8rem; + + h2 { + font-size: $medium; + font-weight: $bold; + margin: 0; + } + h3 { + font-size: $x-small; + font-weight: $regular; + margin: 0; + } + } + + .data-table { + overflow-x: scroll; + } + + .empty-table__container { + margin: $pad-large auto 48px; + } +} diff --git a/frontend/pages/hosts/details/HostQueryReport/HQRTable/index.ts b/frontend/pages/hosts/details/HostQueryReport/HQRTable/index.ts new file mode 100644 index 0000000000..62d454bdc2 --- /dev/null +++ b/frontend/pages/hosts/details/HostQueryReport/HQRTable/index.ts @@ -0,0 +1 @@ +export { default } from "./HQRTable"; diff --git a/frontend/pages/hosts/details/HostQueryReport/HostQueryReport.tsx b/frontend/pages/hosts/details/HostQueryReport/HostQueryReport.tsx new file mode 100644 index 0000000000..fe72eb84a3 --- /dev/null +++ b/frontend/pages/hosts/details/HostQueryReport/HostQueryReport.tsx @@ -0,0 +1,414 @@ +import BackLink from "components/BackLink"; +import Icon from "components/Icon"; +import MainContent from "components/MainContent"; +import ShowQueryModal from "components/modals/ShowQueryModal"; +import Spinner from "components/Spinner"; +import { AppContext } from "context/app"; +import { ISchedulableQuery } from "interfaces/schedulable_query"; +import React, { useCallback, useContext, useState } from "react"; +import { useQuery } from "react-query"; +import { browserHistory, InjectedRouter, Link } from "react-router"; +import { Params } from "react-router/lib/Router"; +import PATHS from "router/paths"; +import hqrAPI, { IGetHQRResponse } from "services/entities/host_query_report"; +import queryAPI from "services/entities/queries"; +import { DOCUMENT_TITLE_SUFFIX } from "utilities/constants"; +import HQRTable from "./HQRTable"; + +const baseClass = "host-query-report"; + +interface IHostQueryReportProps { + router: InjectedRouter; + params: Params; +} + +const HostQueryReport = ({ + router, + params: { host_id, query_id }, +}: IHostQueryReportProps) => { + const { config } = useContext(AppContext); + const globalReportsDisabled = config?.server_settings.query_reports_disabled; + const hostId = Number(host_id); + const queryId = Number(query_id); + + if (globalReportsDisabled) { + router.push(PATHS.HOST_QUERIES(hostId)); + } + + const [showQuery, setShowQuery] = useState(false); + + // TODO - remove dummy data, restore API call + const [[hqrResponse, queryResponse], hqrLoading, hqrError] = [ + // // render report + // [ + // { + // host_name: "Haley's Macbook Air", + // report_clipped: false, + // last_fetched: "2021-01-01T00:00:00.000Z", + // results: [ + // { + // columns: { + // username: "user1", + // email: "e@mail", + // ausername: "user1", + // aemail: "e@mail", + // aausername: "user1", + // aaemail: "e@mail", + // aaausername: "user1", + // aaaemail: "e@mail", + // aaaausername: "user1", + // aaaaemail: "e@mail", + // aaaaausername: "user1", + // aaaaaemail: "e@mail", + // aaaaaausername: "user1", + // aaaaaaemail: "e@mail", + // aaaaaaausername: "user1", + // aaaaaaaemail: "e@mail", + // aaaaaaaausername: "user1", + // aaaaaaaaemail: "e@mail", + // aaaaaaaaausername: "user1", + // aaaaaaaaaemail: "e@mail", + // aaaaaaaaaausername: "user1", + // aaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaausername: "user1", + // aaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaaaaaaaaaemail: "e@mail", + // }, + // }, + // { + // columns: { + // username: "zser1", + // email: "e@mail", + // ausername: "user1", + // aemail: "e@mail", + // aausername: "user1", + // aaemail: "e@mail", + // aaausername: "user1", + // aaaemail: "e@mail", + // aaaausername: "user1", + // aaaaemail: "e@mail", + // aaaaausername: "user1", + // aaaaaemail: "e@mail", + // aaaaaausername: "user1", + // aaaaaaemail: "e@mail", + // aaaaaaausername: "user1", + // aaaaaaaemail: "e@mail", + // aaaaaaaausername: "user1", + // aaaaaaaaemail: "e@mail", + // aaaaaaaaausername: "user1", + // aaaaaaaaaemail: "e@mail", + // aaaaaaaaaausername: "user1", + // aaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaausername: "user1", + // aaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaaaaaaaaaemail: "e@mail", + // }, + // }, + // { + // columns: { + // username: "aser1", + // email: "e@mail", + // ausername: "user1", + // aemail: "e@mail", + // aausername: "user1", + // aaemail: "e@mail", + // aaausername: "user1", + // aaaemail: "e@mail", + // aaaausername: "user1", + // aaaaemail: "e@mail", + // aaaaausername: "user1", + // aaaaaemail: "e@mail", + // aaaaaausername: "user1", + // aaaaaaemail: "e@mail", + // aaaaaaausername: "user1", + // aaaaaaaemail: "e@mail", + // aaaaaaaausername: "user1", + // aaaaaaaaemail: "e@mail", + // aaaaaaaaausername: "user1", + // aaaaaaaaaemail: "e@mail", + // aaaaaaaaaausername: "user1", + // aaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaausername: "user1", + // aaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaaaaaaaaemail: "e@mail", + // aaaaaaaaaaaaaaaaaaaaaaausername: "user1", + // aaaaaaaaaaaaaaaaaaaaaaaemail: "e@mail", + // }, + // }, + // ], + // }, + // { + // name: "Test Query", + // description: "A great query", + // query: "SELECT * FROM users", + // discard_data: false, + // interval: 20, + // }, + // ], + + // collecting results (A) + [ + { + host_name: "Haley's Macbook Air", + report_clipped: false, + last_fetched: null, + results: [], + }, + { + name: "Test Query", + description: "a great query", + query: "SELECT * FROM users", + discard_data: false, + inverval: 20, + }, + ], + + // // nothing to report (B) + // [ + // { + // host_name: "Haley's Macbook Air", + // report_clipped: false, + // last_fetched: "2021-01-01T00:00:00.000Z", + // results: [], + // }, + // { + // name: "Test Query", + // description: "a great query", + // query: "SELECT * FROM users", + // discard_data: false, + // inverval: 20, + // }, + // ], + + // // report clipped (C) + // [ + // { + // host_name: "Haley's Macbook Air", + // report_clipped: true, + // last_fetched: "2021-01-01T00:00:00.000Z", + // results: [], + // }, + // { + // name: "Test Query", + // description: "a great query", + // query: "SELECT * FROM users", + // interval: 20, + // discard_data: false, + // }, + // ], + + // // reroute (local setting) + // [ + // { + // host_name: "Haley's Macbook Air", + // report_clipped: false, + // last_fetched: "2021-01-01T00:00:00.000Z", + // results: [ + // { + // columns: { + // username: "user1", + // email: "e@mail", + // }, + // }, + // ], + // }, + // { + // name: 'Test Query', + // description: "a great query", + // query: "SELECT * FROM users", + // discard_data: true, + // inverval: 20, + // }, + // ], + + false, + null, + ]; + + // const { + // data: hqrResponse, + // isLoading: hqrLoading, + // error: hqrError, + // } = useQuery( + // [hostId, queryId], + // () => hqrAPI.load(hostId, queryId), + // { + // refetchOnMount: false, + // refetchOnReconnect: false, + // refetchOnWindowFocus: false, + // } + // ); + + // const { + // isLoading: queryLoading, + // data: queryResponse, + // error: queryError, + // } = useQuery( + // ["query", queryId], + // () => queryAPI.load(queryId), + // { + // enabled: !!queryId, + // refetchOnMount: false, + // refetchOnReconnect: false, + // refetchOnWindowFocus: false, + // } + // ); + + // TODO - remove mock loading state + const queryLoading = false; + + const isLoading = queryLoading || hqrLoading; + + const { + host_name: hostName, + report_clipped: reportClipped, + last_fetched: lastFetched, + results, + // TODO - remove below casting, just for testing + } = (hqrResponse || {}) as Partial; + + // API response is nested this way to mirror that of the full Query Reports response (IQueryReport) + const rows = results?.map((row) => row.columns) ?? []; + + const { + name: queryName, + description: queryDescription, + query: querySQL, + discard_data: queryDiscardData, + } = (queryResponse || {}) as Partial; + + // TODO - finalize local setting reroute conditions + // previous reroute can be done before API call, not this one, hence 2 + if (queryDiscardData) { + router.push(PATHS.HOST_QUERIES(hostId)); + } + + document.title = `Host query report | ${queryName} | ${hostName} | ${DOCUMENT_TITLE_SUFFIX}`; + + const HQRHeader = useCallback(() => { + const fullReportPath = PATHS.QUERY_DETAILS(queryId); + return ( +
+
+ +
+
+ {!hqrError &&

{hostName}

} + { + browserHistory.push(fullReportPath); + }} + className={`${baseClass}__direction-link`} + > + <> + View full query report + + + +
+
+ ); + }, [queryId, hostId, hqrError, hostName]); + + return ( + + {isLoading ? ( + + ) : ( + <> + + setShowQuery(true)} + isLoading={false} + /> + {showQuery && ( + setShowQuery(false)} + /> + )} + + )} + + ); +}; + +export default HostQueryReport; diff --git a/frontend/pages/hosts/details/HostQueryReport/_styles.scss b/frontend/pages/hosts/details/HostQueryReport/_styles.scss new file mode 100644 index 0000000000..5eb09d06a9 --- /dev/null +++ b/frontend/pages/hosts/details/HostQueryReport/_styles.scss @@ -0,0 +1,24 @@ +.host-query-report { + display: flex; + flex-direction: column; + gap: $pad-large; + @include color-contrasted-sections; + + h1 { + font-weight: $xbold; + } + &__header { + display: flex; + flex-direction: column; + gap: $pad-xlarge; + + &__row2 { + display: flex; + align-items: center; + justify-content: space-between; + } + } + &__direction-link { + @include direction-link; + } +} diff --git a/frontend/pages/hosts/details/HostQueryReport/index.ts b/frontend/pages/hosts/details/HostQueryReport/index.ts new file mode 100644 index 0000000000..0c099a2726 --- /dev/null +++ b/frontend/pages/hosts/details/HostQueryReport/index.ts @@ -0,0 +1 @@ +export { default } from "./HostQueryReport"; diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tsx index 34f91caeac..46b0063fe1 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tsx +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tsx @@ -8,6 +8,7 @@ import { FLEET_FILEVAULT_PROFILE_DISPLAY_NAME, ProfileOperationType, } from "interfaces/mdm"; +import { COLORS } from "styles/var/colors"; import { isMdmProfileStatus, @@ -71,7 +72,7 @@ const OSSettingStatusCell = ({ diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTable.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTable.tsx index b97715459d..176d0c12e9 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTable.tsx +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTable.tsx @@ -15,7 +15,7 @@ const OSSettingsTable = ({ tableData }: IOSSettingsTableProps) => { @@ -81,7 +82,7 @@ const ProfileStatusIndicator = ({ diff --git a/frontend/pages/hosts/details/_styles.scss b/frontend/pages/hosts/details/_styles.scss index 87b0fc45be..0f95df91c7 100644 --- a/frontend/pages/hosts/details/_styles.scss +++ b/frontend/pages/hosts/details/_styles.scss @@ -1,20 +1,16 @@ .host-details, .device-user { + display: flex; + flex-direction: column; + gap: $pad-medium; + + @include color-contrasted-sections; .header { flex: 100%; display: flex; flex-direction: column; } .section { - flex: 100%; - display: flex; - flex-direction: column; - background-color: $core-white; - border-radius: 16px; - border: 1px solid $ui-fleet-black-10; - padding: $pad-xxlarge; - box-shadow: 0px 3px 0px rgba(226, 228, 234, 0.4); - &__header { font-size: $medium; font-weight: $bold; diff --git a/frontend/pages/hosts/details/cards/About/About.tsx b/frontend/pages/hosts/details/cards/About/About.tsx index 3612f95739..59653735a3 100644 --- a/frontend/pages/hosts/details/cards/About/About.tsx +++ b/frontend/pages/hosts/details/cards/About/About.tsx @@ -10,6 +10,7 @@ import { DEFAULT_EMPTY_CELL_VALUE, MDM_STATUS_TOOLTIP, } from "utilities/constants"; +import { COLORS } from "styles/var/colors"; interface IAboutProps { aboutData: { [key: string]: any }; @@ -40,7 +41,7 @@ const About = ({ diff --git a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx index b60930eccd..9bf99cf2c9 100644 --- a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx +++ b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx @@ -18,6 +18,7 @@ import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTool import { humanHostMemory, wrapFleetHelper } from "utilities/helpers"; import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants"; import StatusIndicator from "components/StatusIndicator"; +import { COLORS } from "styles/var/colors"; import OSSettingsIndicator from "./OSSettingsIndicator"; import HostSummaryIndicator from "./HostSummaryIndicator"; @@ -141,7 +142,7 @@ const HostSummary = ({ place="top" effect="solid" id="refetch-tooltip" - backgroundColor="#3e4771" + backgroundColor={COLORS["tooltip-bg"]} > You can’t fetch data from
an offline host. @@ -168,7 +169,7 @@ const HostSummary = ({ diff --git a/frontend/pages/hosts/details/cards/HostSummary/OSSettingsIndicator/OSSettingsIndicator.tsx b/frontend/pages/hosts/details/cards/HostSummary/OSSettingsIndicator/OSSettingsIndicator.tsx index 9c23243957..6f90c372ab 100644 --- a/frontend/pages/hosts/details/cards/HostSummary/OSSettingsIndicator/OSSettingsIndicator.tsx +++ b/frontend/pages/hosts/details/cards/HostSummary/OSSettingsIndicator/OSSettingsIndicator.tsx @@ -6,6 +6,7 @@ import { IHostMdmProfile, MdmProfileStatus } from "interfaces/mdm"; import Icon from "components/Icon"; import Button from "components/buttons/Button"; import { IconNames } from "components/icons"; +import { COLORS } from "styles/var/colors"; const baseClass = "os-settings-indicator"; @@ -145,7 +146,7 @@ const OSSettingsIndicator = ({ diff --git a/frontend/pages/hosts/details/cards/MunkiIssues/MunkiIssues.tsx b/frontend/pages/hosts/details/cards/MunkiIssues/MunkiIssues.tsx index e62cda16c9..103fb4c9eb 100644 --- a/frontend/pages/hosts/details/cards/MunkiIssues/MunkiIssues.tsx +++ b/frontend/pages/hosts/details/cards/MunkiIssues/MunkiIssues.tsx @@ -30,7 +30,7 @@ const MunkiIssuesTable = ({ {munkiIssues?.length ? (
{ {!!pack.query_stats.length && (
null} diff --git a/frontend/pages/hosts/details/cards/Policies/Policies.tsx b/frontend/pages/hosts/details/cards/Policies/Policies.tsx index f2ec6662b0..993e5e0e22 100644 --- a/frontend/pages/hosts/details/cards/Policies/Policies.tsx +++ b/frontend/pages/hosts/details/cards/Policies/Policies.tsx @@ -86,7 +86,7 @@ const Policies = ({ )} null} resultsTitle="queries" diff --git a/frontend/pages/hosts/details/cards/Scripts/Scripts.tsx b/frontend/pages/hosts/details/cards/Scripts/Scripts.tsx index 10e5169cd2..38505040c8 100644 --- a/frontend/pages/hosts/details/cards/Scripts/Scripts.tsx +++ b/frontend/pages/hosts/details/cards/Scripts/Scripts.tsx @@ -113,7 +113,7 @@ const Scripts = ({ emptyComponent={() => <>} showMarkAllPages={false} isAllPagesSelected={false} - columns={scriptColumnConfigs} + columnConfigs={scriptColumnConfigs} data={data} isLoading={isLoadingScriptData} onQueryChange={onQueryChange} diff --git a/frontend/pages/hosts/details/cards/Software/Software.tsx b/frontend/pages/hosts/details/cards/Software/Software.tsx index bb695fb725..021296a23a 100644 --- a/frontend/pages/hosts/details/cards/Software/Software.tsx +++ b/frontend/pages/hosts/details/cards/Software/Software.tsx @@ -221,7 +221,7 @@ const SoftwareTable = ({
@@ -333,7 +333,7 @@ export const generateSoftwareTableHeaders = ({ Users

{users?.length ? ( @@ -238,7 +238,7 @@ const generateTableHeaders = ( diff --git a/frontend/pages/policies/PolicyPage/PolicyPage.tsx b/frontend/pages/policies/PolicyPage/PolicyPage.tsx index 6c44c1463b..8b6e9a4747 100644 --- a/frontend/pages/policies/PolicyPage/PolicyPage.tsx +++ b/frontend/pages/policies/PolicyPage/PolicyPage.tsx @@ -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 { LIVE_POLICY_STEPS } from "utilities/constants"; +import { DOCUMENT_TITLE_SUFFIX, LIVE_POLICY_STEPS } from "utilities/constants"; import QuerySidePanel from "components/side_panels/QuerySidePanel"; import QueryEditor from "pages/policies/PolicyPage/screens/QueryEditor"; @@ -207,7 +207,7 @@ const PolicyPage = ({ // Updates title that shows up on browser tabs useEffect(() => { // e.g., Policy details | Antivirus healthy (Linux) | Fleet for osquery - document.title = `Policy details | ${storedPolicy?.name} | Fleet for osquery`; + document.title = `Policy details | ${storedPolicy?.name} | ${DOCUMENT_TITLE_SUFFIX}`; }, [location.pathname, storedPolicy?.name]); useEffect(() => { diff --git a/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsTable/PolicyQueriesErrorsTable.tsx b/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsTable/PolicyQueriesErrorsTable.tsx index 6ee2beab49..e2532a2d8a 100644 --- a/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsTable/PolicyQueriesErrorsTable.tsx +++ b/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsTable/PolicyQueriesErrorsTable.tsx @@ -40,7 +40,7 @@ const PoliciesTable = ({ > Select the platform(s) this
diff --git a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTable.tsx b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTable.tsx index 033aa3c373..410109fd3c 100644 --- a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTable.tsx +++ b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTable.tsx @@ -293,7 +293,7 @@ const QueriesTable = ({ } - path={PATHS.QUERY( + path={PATHS.QUERY_DETAILS( cellProps.row.original.id, cellProps.row.original.team_id ?? currentTeamId )} diff --git a/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx index a3d037c6e0..bf8a5d6d20 100644 --- a/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx +++ b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx @@ -17,6 +17,7 @@ import { IQueryReport } from "interfaces/query_report"; import queryAPI from "services/entities/queries"; import queryReportAPI, { ISortOption } from "services/entities/query_report"; +import { DOCUMENT_TITLE_SUFFIX } from "utilities/constants"; import Spinner from "components/Spinner/Spinner"; import Button from "components/buttons/Button"; @@ -55,6 +56,9 @@ const QueryDetailsPage = ({ location, }: IQueryDetailsPageProps): JSX.Element => { const queryId = parseInt(paramsQueryId, 10); + if (isNaN(queryId)) { + router.push(PATHS.MANAGE_QUERIES); + } const queryParams = location.query; const teamId = location.query.team_id ? parseInt(location.query.team_id, 10) @@ -81,7 +85,6 @@ const QueryDetailsPage = ({ filteredQueriesPath, availableTeams, setCurrentTeam, - currentTeam, } = useContext(AppContext); const { lastEditedQueryName, @@ -102,7 +105,7 @@ const QueryDetailsPage = ({ } = 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`; + document.title = `Query details | ${lastEditedQueryName} | ${DOCUMENT_TITLE_SUFFIX}`; const [disabledCachingGlobally, setDisabledCachingGlobally] = useState(true); diff --git a/frontend/pages/queries/details/components/QueryReport/QueryReport.tsx b/frontend/pages/queries/details/components/QueryReport/QueryReport.tsx index 527528327a..a1fb6dd63d 100644 --- a/frontend/pages/queries/details/components/QueryReport/QueryReport.tsx +++ b/frontend/pages/queries/details/components/QueryReport/QueryReport.tsx @@ -141,7 +141,7 @@ const QueryReport = ({ return (
{ diff --git a/frontend/pages/queries/details/components/QueryReport/QueryReportTableConfig.tsx b/frontend/pages/queries/details/components/QueryReport/QueryReportTableConfig.tsx index ecaef7505f..4c9af9723b 100644 --- a/frontend/pages/queries/details/components/QueryReport/QueryReportTableConfig.tsx +++ b/frontend/pages/queries/details/components/QueryReport/QueryReportTableConfig.tsx @@ -15,7 +15,11 @@ import { import DefaultColumnFilter from "components/TableContainer/DataTable/DefaultColumnFilter"; import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell"; -import { humanHostLastSeen, internallyTruncateText } from "utilities/helpers"; +import { + getUniqueColumnNamesFromRows, + humanHostLastSeen, + internallyTruncateText, +} from "utilities/helpers"; type IHeaderProps = HeaderProps & { column: ColumnInstance & IDataColumn; @@ -52,12 +56,7 @@ const generateReportColumnConfigsFromResults = (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 uniqueColumnNames = getUniqueColumnNamesFromRows(results); const columnConfigs = uniqueColumnNames.map((key) => { return { diff --git a/frontend/pages/queries/edit/EditQueryPage.tsx b/frontend/pages/queries/edit/EditQueryPage.tsx index a9432c3180..2ad7de13ce 100644 --- a/frontend/pages/queries/edit/EditQueryPage.tsx +++ b/frontend/pages/queries/edit/EditQueryPage.tsx @@ -5,7 +5,7 @@ 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 { DEFAULT_QUERY, DOCUMENT_TITLE_SUFFIX } from "utilities/constants"; import configAPI from "services/entities/config"; import queryAPI from "services/entities/queries"; import statusAPI from "services/entities/status"; @@ -174,7 +174,7 @@ const EditQueryPage = ({ queryId > 0 && !canEditExistingQuery ) { - router.push(PATHS.QUERY(queryId)); + router.push(PATHS.QUERY_DETAILS(queryId)); } }, [queryId, isTeamMaintainerOrTeamAdmin, isStoredQueryLoading]); @@ -203,7 +203,7 @@ const EditQueryPage = ({ // 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`; + document.title = `Edit query | ${storedQuery?.name} | ${DOCUMENT_TITLE_SUFFIX}`; }, [location.pathname, storedQuery?.name]); useEffect(() => { @@ -215,7 +215,7 @@ const EditQueryPage = ({ setIsQuerySaving(true); try { const { query } = await queryAPI.create(formData); - router.push(PATHS.QUERY(query.id, query.team_id)); + router.push(PATHS.QUERY_DETAILS(query.id, query.team_id)); renderFlash("success", "Query created!"); setBackendValidators({}); } catch (createError: any) { @@ -317,7 +317,7 @@ const EditQueryPage = ({ // Function instead of constant eliminates race condition const backToQueriesPath = () => { - return queryId ? PATHS.QUERY(queryId) : PATHS.MANAGE_QUERIES; + return queryId ? PATHS.QUERY_DETAILS(queryId) : PATHS.MANAGE_QUERIES; }; const showSidebar = diff --git a/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx b/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx index e7d52c7f07..b3f468492e 100644 --- a/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx +++ b/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx @@ -331,7 +331,10 @@ const EditQueryForm = ({ .then((response: { query: ISchedulableQuery }) => { setIsSaveAsNewLoading(false); router.push( - PATHS.QUERY(response.query.id, response.query.team_id ?? undefined) + PATHS.QUERY_DETAILS( + response.query.id, + response.query.team_id ?? undefined + ) ); renderFlash("success", `Successfully added query.`); }) diff --git a/frontend/pages/queries/edit/components/QueryResults/QueryResults.tsx b/frontend/pages/queries/edit/components/QueryResults/QueryResults.tsx index 2865aea6c2..1c06cdab66 100644 --- a/frontend/pages/queries/edit/components/QueryResults/QueryResults.tsx +++ b/frontend/pages/queries/edit/components/QueryResults/QueryResults.tsx @@ -194,7 +194,7 @@ const QueryResults = ({ return (
& { column: ColumnInstance & IDataColumn; @@ -52,17 +55,7 @@ const generateColumnConfigsFromRows = ( // typed as any[] to accomodate loose typing of websocket API results: any[] // {col:val, ...} for each row of query results ): Column[] => { - const uniqueColumnNames = Array.from( - results.reduce( - (accOuter, row) => - Object.keys(row).reduce( - (accInner, colNameInRow) => accInner.add(colNameInRow), - accOuter - ), - new Set() // Set prevents listing duplicate headers - ) - ); - + const uniqueColumnNames = getUniqueColumnNamesFromRows(results); const columnsConfigs = uniqueColumnNames.map((colName) => { return { id: colName as string, diff --git a/frontend/pages/queries/live/LiveQueryPage/LiveQueryPage.tsx b/frontend/pages/queries/live/LiveQueryPage/LiveQueryPage.tsx index 958f7f7496..99dfd1cf7d 100644 --- a/frontend/pages/queries/live/LiveQueryPage/LiveQueryPage.tsx +++ b/frontend/pages/queries/live/LiveQueryPage/LiveQueryPage.tsx @@ -6,7 +6,7 @@ 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 { LIVE_QUERY_STEPS, DOCUMENT_TITLE_SUFFIX } from "utilities/constants"; import queryAPI from "services/entities/queries"; import hostAPI from "services/entities/hosts"; import statusAPI from "services/entities/status"; @@ -96,7 +96,7 @@ const RunQueryPage = ({ // Reroute users out of live flow when live queries are globally disabled if (disabledLiveQuery) { queryId - ? router.push(PATHS.QUERY(queryId)) + ? router.push(PATHS.QUERY_DETAILS(queryId)) : router.push(PATHS.NEW_QUERY()); } @@ -168,7 +168,7 @@ const RunQueryPage = ({ // 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`; + document.title = `Run live query | ${storedQuery?.name} | ${DOCUMENT_TITLE_SUFFIX}`; }, [location.pathname, storedQuery?.name]); const goToQueryEditor = useCallback( diff --git a/frontend/pages/software/ManageSoftwarePage/ManageSoftwarePage.tsx b/frontend/pages/software/ManageSoftwarePage/ManageSoftwarePage.tsx index 714547380b..120321aaf3 100644 --- a/frontend/pages/software/ManageSoftwarePage/ManageSoftwarePage.tsx +++ b/frontend/pages/software/ManageSoftwarePage/ManageSoftwarePage.tsx @@ -621,7 +621,7 @@ const ManageSoftwarePage = ({ } return ( { diff --git a/frontend/pages/software/ManageSoftwarePage/components/ManageAutomationsModal/ManageAutomationsModal.tsx b/frontend/pages/software/ManageSoftwarePage/components/ManageAutomationsModal/ManageAutomationsModal.tsx index b834c7b431..66c80c3a5a 100644 --- a/frontend/pages/software/ManageSoftwarePage/components/ManageAutomationsModal/ManageAutomationsModal.tsx +++ b/frontend/pages/software/ManageSoftwarePage/components/ManageAutomationsModal/ManageAutomationsModal.tsx @@ -31,6 +31,7 @@ import validUrl from "components/forms/validators/valid_url"; import { IWebhookSoftwareVulnerabilities } from "interfaces/webhook"; import useDeepEffect from "hooks/useDeepEffect"; import { isEmpty, omit } from "lodash"; +import { COLORS } from "styles/var/colors"; import PreviewPayloadModal from "../PreviewPayloadModal"; import PreviewTicketModal from "../PreviewTicketModal"; @@ -519,7 +520,7 @@ const ManageAutomationsModal = ({ className={`save-automation-button-tooltip`} place="bottom" effect="solid" - backgroundColor="#3e4771" + backgroundColor={COLORS["tooltip-bg"]} id="save-automation-button" data-html > diff --git a/frontend/pages/software/SoftwareDetailsPage/SoftwareDetailsPage.tsx b/frontend/pages/software/SoftwareDetailsPage/SoftwareDetailsPage.tsx index 7f174f258e..fb52cc715f 100644 --- a/frontend/pages/software/SoftwareDetailsPage/SoftwareDetailsPage.tsx +++ b/frontend/pages/software/SoftwareDetailsPage/SoftwareDetailsPage.tsx @@ -13,7 +13,10 @@ import { APP_CONTEXT_NO_TEAM_ID } from "interfaces/team"; import softwareAPI from "services/entities/software"; import hostCountAPI from "services/entities/host_count"; -import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants"; +import { + DEFAULT_EMPTY_CELL_VALUE, + DOCUMENT_TITLE_SUFFIX, +} from "utilities/constants"; import Spinner from "components/Spinner"; import BackLink from "components/BackLink"; import MainContent from "components/MainContent"; @@ -76,7 +79,7 @@ const SoftwareDetailsPage = ({ // e.g., Software horizon, 5.2.0 details | Fleet for osquery document.title = `Software details | ${ software && renderName(software) - } | Fleet for osquery`; + } | ${DOCUMENT_TITLE_SUFFIX}`; }, [location.pathname, software]); if (!software || isPremiumTier === undefined) { diff --git a/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/Vulnerabilities.tsx b/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/Vulnerabilities.tsx index 0446c6f20d..7707d92f03 100644 --- a/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/Vulnerabilities.tsx +++ b/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/Vulnerabilities.tsx @@ -55,7 +55,7 @@ const Vulnerabilities = ({ {software && (
- - - - - - - - {/* legacy route */} - - - + + + + + + + {/* legacy route */} + + + diff --git a/frontend/router/page_titles.ts b/frontend/router/page_titles.ts index 49b7cd6f16..e6233a76c8 100644 --- a/frontend/router/page_titles.ts +++ b/frontend/router/page_titles.ts @@ -1,51 +1,60 @@ // Note: Dynamic page titles are constructed for host, software, query, and policy details on their respective *DetailsPage.tsx file + +import { DOCUMENT_TITLE_SUFFIX } from "utilities/constants"; + // Note: Order matters for use of array.find() (specific subpaths must be listed before their parent path) export default [ - { path: "/dashboard", title: "Dashboard | Fleet for osquery" }, - { path: "/hosts/manage", title: "Manage hosts | Fleet for osquery" }, + { path: "/dashboard", title: `Dashboard | ${DOCUMENT_TITLE_SUFFIX}` }, + { path: "/hosts/manage", title: `Manage hosts | ${DOCUMENT_TITLE_SUFFIX}` }, { path: "/controls/os-updates", - title: "Manage OS updates | Fleet for osquery", + title: `Manage OS updates | ${DOCUMENT_TITLE_SUFFIX}`, }, { path: "/controls/os-settings", - title: "Manage OS settings | Fleet for osquery", + title: `Manage OS settings | ${DOCUMENT_TITLE_SUFFIX}`, }, { path: "/controls/setup-experience", - title: "Manage setup experience | Fleet for osquery", + title: `Manage setup experience | ${DOCUMENT_TITLE_SUFFIX}`, }, - { path: "/software/manage", title: "Manage software | Fleet for osquery" }, - { path: "/queries/manage", title: "Manage queries | Fleet for osquery" }, - { path: "/queries/new", title: "New query | Fleet for osquery" }, - { path: "/policies/manage", title: "Manage policies | Fleet for osquery" }, - { path: "/policies/new", title: "New policy | Fleet for osquery" }, + { + path: `/software/manage", title: "Manage software | ${DOCUMENT_TITLE_SUFFIX}`, + }, + { + path: `/queries/manage", title: "Manage queries | ${DOCUMENT_TITLE_SUFFIX}`, + }, + { path: `/queries/new", title: "New query | ${DOCUMENT_TITLE_SUFFIX}` }, + { + path: `/policies/manage", title: "Manage policies | ${DOCUMENT_TITLE_SUFFIX}`, + }, + { path: `/policies/new", title: "New policy | ${DOCUMENT_TITLE_SUFFIX}` }, { path: "/settings/organization", - title: "Manage organization settings | Fleet for osquery", + title: `Manage organization settings | ${DOCUMENT_TITLE_SUFFIX}`, }, { path: "/settings/integrations", - title: "Manage integration settings | Fleet for osquery", + title: `Manage integration settings | ${DOCUMENT_TITLE_SUFFIX}`, }, { path: "/settings/users", - title: "Manage user settings | Fleet for osquery", + title: `Manage user settings | ${DOCUMENT_TITLE_SUFFIX}`, }, { path: "/settings/teams/members", - title: "Manage team members | Fleet for osquery", + title: `Manage team members | ${DOCUMENT_TITLE_SUFFIX}`, }, { path: "/settings/teams/options", - title: "Manage team options | Fleet for osquery", + title: `Manage team options | ${DOCUMENT_TITLE_SUFFIX}`, }, { path: "/settings/teams", - title: "Manage team settings | Fleet for osquery", + title: `Manage team settings | ${DOCUMENT_TITLE_SUFFIX}`, }, { path: "/profile", - title: "Manage my account | Fleet for osquery", + title: `Manage my account | ${DOCUMENT_TITLE_SUFFIX}`, }, ]; diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts index 2d9ed40a49..b9e6d586bb 100644 --- a/frontend/router/paths.ts +++ b/frontend/router/paths.ts @@ -62,7 +62,7 @@ export default { teamId ? `?team_id=${teamId}` : "" }`; }, - QUERY: (queryId: number, teamId?: number): string => { + QUERY_DETAILS: (queryId: number, teamId?: number): string => { return `${URL_PREFIX}/queries/${queryId}${ teamId ? `?team_id=${teamId}` : "" }`; diff --git a/frontend/services/entities/host_query_report.ts b/frontend/services/entities/host_query_report.ts new file mode 100644 index 0000000000..3b6cde4947 --- /dev/null +++ b/frontend/services/entities/host_query_report.ts @@ -0,0 +1,20 @@ +import sendRequest from "services"; +import endpoints from "utilities/endpoints"; + +export interface IHQRResult { + columns: Record; +} +export interface IGetHQRResponse { + query_id: number; + host_id: number; + host_name: string; + last_fetched: string | null; // timestamp + report_clipped: boolean; + results: IHQRResult[]; +} + +export default { + load: (hostId: number, queryId: number): Promise => { + return sendRequest("GET", endpoints.HOST_QUERY_REPORT(hostId, queryId)); + }, +}; diff --git a/frontend/services/entities/query_report.ts b/frontend/services/entities/query_report.ts index 02855a2644..405dfa4ad4 100644 --- a/frontend/services/entities/query_report.ts +++ b/frontend/services/entities/query_report.ts @@ -30,8 +30,6 @@ 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, @@ -39,8 +37,7 @@ export default { const queryString = buildQueryStringFromParams(queryParams); - const endpoint = `${QUERIES}/${id}/report`; - const path = `${endpoint}?${queryString}`; + const path = `${endpoints.QUERY_REPORT(id)}?${queryString}`; return sendRequest("GET", path); }, }; diff --git a/frontend/styles/var/mixins.scss b/frontend/styles/var/mixins.scss index 355c10af82..6096911bef 100644 --- a/frontend/styles/var/mixins.scss +++ b/frontend/styles/var/mixins.scss @@ -117,9 +117,13 @@ $max-width: 2560px; cursor: default; } +@mixin grey-text { + color: $ui-fleet-black-75; +} + @mixin help-text { font-size: $xx-small; - color: $ui-fleet-black-75; + @include grey-text; } @mixin link { @@ -150,3 +154,35 @@ $max-width: 2560px; justify-content: space-between; width: 100%; } + +@mixin direction-link { + display: inline-flex; + align-items: center; + padding: $pad-small $pad-xxsmall; // larger clickable area + border-radius: 3px; // Visible while tabbing; + gap: $pad-xsmall; + + &:hover { + color: $core-vibrant-blue-over; + text-decoration: underline; + + svg { + path { + stroke: $core-vibrant-blue-over; + } + } + } +} + +@mixin color-contrasted-sections { + background-color: $ui-off-white; + .section { + display: flex; + flex-direction: column; + background-color: $core-white; + border-radius: 16px; + border: 1px solid $ui-fleet-black-10; + padding: $pad-xxlarge; + box-shadow: 0px 3px 0px rgba(226, 228, 234, 0.4); + } +} diff --git a/frontend/utilities/constants.tsx b/frontend/utilities/constants.tsx index c4e765220f..33720bfbd4 100644 --- a/frontend/utilities/constants.tsx +++ b/frontend/utilities/constants.tsx @@ -302,3 +302,5 @@ export const EMPTY_AGENT_OPTIONS = { }; export const DEFAULT_EMPTY_CELL_VALUE = "---"; + +export const DOCUMENT_TITLE_SUFFIX = "Fleet for osquery"; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index f93487b2a2..d0196ffba2 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -23,6 +23,8 @@ export default { GLOBAL_POLICIES: `/${API_VERSION}/fleet/policies`, GLOBAL_SCHEDULE: `/${API_VERSION}/fleet/schedule`, HOST_SUMMARY: `/${API_VERSION}/fleet/host_summary`, + HOST_QUERY_REPORT: (hostId: number, queryId: number) => + `/${API_VERSION}/fleet/hosts/${hostId}/queries/${queryId}`, HOSTS: `/${API_VERSION}/fleet/hosts`, HOSTS_COUNT: `/${API_VERSION}/fleet/hosts/count`, HOSTS_DELETE: `/${API_VERSION}/fleet/hosts/delete`, @@ -84,6 +86,7 @@ export default { PACKS: `/${API_VERSION}/fleet/packs`, PERFORM_REQUIRED_PASSWORD_RESET: `/${API_VERSION}/fleet/perform_required_password_reset`, QUERIES: `/${API_VERSION}/fleet/queries`, + QUERY_REPORT: (id: number) => `/${API_VERSION}/fleet/queries/${id}/report`, RESET_PASSWORD: `/${API_VERSION}/fleet/reset_password`, LIVE_QUERY: `/${API_VERSION}/fleet/queries/run`, SCHEDULE_QUERY: `/${API_VERSION}/fleet/packs/schedule`, diff --git a/frontend/utilities/helpers.tsx b/frontend/utilities/helpers.tsx index c255d4eed1..46c54b1942 100644 --- a/frontend/utilities/helpers.tsx +++ b/frontend/utilities/helpers.tsx @@ -11,8 +11,6 @@ import { trimEnd, union, } from "lodash"; -import { buildQueryStringFromParams } from "utilities/url"; - import md5 from "js-md5"; import { formatDistanceToNow, @@ -23,6 +21,7 @@ import { } from "date-fns"; import yaml from "js-yaml"; +import { buildQueryStringFromParams } from "utilities/url"; import { IHost } from "interfaces/host"; import { ILabel } from "interfaces/label"; import { IPack } from "interfaces/pack"; @@ -828,6 +827,22 @@ export const internallyTruncateText = ( ); +export const getUniqueColumnNamesFromRows = (rows: any[]) => + // rows of type {col:val, col:val, ...}[] + // cannot type more narrowly due to loose typing of websocket API and use of this function + // by QueryResultsTableConfig, where results come from that API + // TODO – narrow this entire chain down to the websocket API level + Array.from( + rows.reduce( + (accOuter, row) => + Object.keys(row).reduce( + (accInner, colNameInRow) => accInner.add(colNameInRow), + accOuter + ), + new Set() + ) + ); + export default { addGravatarUrlToResource, formatConfigDataForServer, @@ -843,6 +858,7 @@ export default { formatPackTargetsForApi, generateRole, generateTeam, + getUniqueColumnNamesFromRows, greyCell, humanHostLastSeen, humanHostEnrolled, From 33999cddae66d3c9a022d898fd0fd16f14605d18 Mon Sep 17 00:00:00 2001 From: Tim Lee Date: Mon, 11 Dec 2023 15:33:31 -0700 Subject: [PATCH 4/9] 15381 host query report api (#15441) --- server/datastore/mysql/query_results.go | 21 ++ server/datastore/mysql/query_results_test.go | 346 ++++++++++--------- server/fleet/datastore.go | 1 + server/fleet/queries.go | 15 +- server/fleet/queries_test.go | 21 +- server/fleet/service.go | 6 + server/mock/datastore_mock.go | 12 + server/service/handler.go | 1 + server/service/hosts.go | 107 ++++++ server/service/hosts_test.go | 6 + server/service/integration_core_test.go | 137 +++++++- server/service/osquery_test.go | 10 +- server/service/queries.go | 19 +- server/service/queries_test.go | 36 ++ 14 files changed, 551 insertions(+), 187 deletions(-) diff --git a/server/datastore/mysql/query_results.go b/server/datastore/mysql/query_results.go index 7668455b77..f81b94c5eb 100644 --- a/server/datastore/mysql/query_results.go +++ b/server/datastore/mysql/query_results.go @@ -88,6 +88,7 @@ func (ds *Datastore) OverwriteQueryResultRows(ctx context.Context, rows []*fleet // TODO(lucas): Any chance we can store hostname in the query_results table? // (to avoid having to left join hosts). +// QueryResultRows returns the query result rows for a given query func (ds *Datastore) QueryResultRows(ctx context.Context, queryID uint, filter fleet.TeamFilter) ([]*fleet.ScheduledQueryResultRow, error) { selectStmt := fmt.Sprintf(` SELECT qr.query_id, qr.host_id, qr.last_fetched, qr.data, @@ -106,6 +107,8 @@ func (ds *Datastore) QueryResultRows(ctx context.Context, queryID uint, filter f return results, nil } +// ResultCountForQuery counts the query report rows for a given query +// excluding rows with null data func (ds *Datastore) ResultCountForQuery(ctx context.Context, queryID uint) (int, error) { var count int err := sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(*) FROM query_results WHERE query_id = ? AND data IS NOT NULL`, queryID) @@ -116,6 +119,8 @@ func (ds *Datastore) ResultCountForQuery(ctx context.Context, queryID uint) (int return count, nil } +// ResultCountForQueryAndHost counts the query report rows for a given query and host +// excluding rows with null data func (ds *Datastore) ResultCountForQueryAndHost(ctx context.Context, queryID, hostID uint) (int, error) { var count int err := sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(*) FROM query_results WHERE query_id = ? AND host_id = ? AND data IS NOT NULL`, queryID, hostID) @@ -125,3 +130,19 @@ func (ds *Datastore) ResultCountForQueryAndHost(ctx context.Context, queryID, ho return count, nil } + +// QueryResultRowsForHost returns the query result rows for a given query and host +// including rows with null data +func (ds *Datastore) QueryResultRowsForHost(ctx context.Context, queryID, hostID uint) ([]*fleet.ScheduledQueryResultRow, error) { + selectStmt := ` + SELECT query_id, host_id, last_fetched, data FROM query_results + WHERE query_id = ? AND host_id = ? + ` + results := []*fleet.ScheduledQueryResultRow{} + err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, selectStmt, queryID, hostID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "selecting query result rows for host") + } + + return results, nil +} diff --git a/server/datastore/mysql/query_results_test.go b/server/datastore/mysql/query_results_test.go index 7596133602..ad11177979 100644 --- a/server/datastore/mysql/query_results_test.go +++ b/server/datastore/mysql/query_results_test.go @@ -3,15 +3,12 @@ package mysql import ( "context" "encoding/json" - "fmt" - "strings" "testing" "time" - "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" - "github.com/jmoiron/sqlx" "github.com/stretchr/testify/require" ) @@ -23,6 +20,7 @@ func TestQueryResults(t *testing.T) { fn func(t *testing.T, ds *Datastore) }{ {"Get", testGetQueryResultRows}, + {"GetForHost", testGetQueryResultRowsForHost}, {"CountForQuery", testCountResultsForQuery}, {"CountForQueryAndHost", testCountResultsForQueryAndHost}, {"Overwrite", testOverwriteQueryResultRows}, @@ -45,72 +43,57 @@ func testGetQueryResultRows(t *testing.T, ds *Datastore) { mockTime := time.Now().UTC().Truncate(time.Second) - // Insert 2 Result Rows for Query1 and 1 empty data row - resultRows := []*fleet.ScheduledQueryResultRow{ - { - QueryID: query.ID, - HostID: host.ID, - LastFetched: mockTime, - Data: json.RawMessage( - `{"model": "USB Keyboard", "vendor": "Apple Inc."}`, - ), - }, - { - QueryID: query.ID, - HostID: host.ID, - LastFetched: mockTime, - Data: json.RawMessage( - `{"model": "USB Mouse", "vendor": "Logitech"}`, - ), - }, + // Insert Result Rows for Query1 + query1Rows := []*fleet.ScheduledQueryResultRow{ { QueryID: query.ID, HostID: host.ID, LastFetched: mockTime, Data: nil, }, + { + QueryID: query.ID, + HostID: host.ID, + LastFetched: mockTime, + Data: ptr.RawMessage([]byte(`{ + "model": "USB Keyboard", + "vendor": "Apple Inc." + }`)), + }, } - - err := ds.SaveQueryResultRows(context.Background(), resultRows) + err := ds.OverwriteQueryResultRows(context.Background(), query1Rows) require.NoError(t, err) // Insert Result Row for different Scheduled Query query2 := test.NewQuery(t, ds, nil, "New Query 2", "SELECT 1", user.ID, true) - resultRow3 := []*fleet.ScheduledQueryResultRow{ + query2Rows := []*fleet.ScheduledQueryResultRow{ { QueryID: query2.ID, HostID: host.ID, LastFetched: mockTime, - Data: json.RawMessage( - `{"model": "USB Hub","vendor": "Logitech"}`, - ), + Data: ptr.RawMessage([]byte(`{"model": "USB Hub","vendor": "Logitech"}`)), }, } - err = ds.SaveQueryResultRows(context.Background(), resultRow3) + err = ds.OverwriteQueryResultRows(context.Background(), query2Rows) require.NoError(t, err) - // Assert that Query1 returns 2 results - results, err := ds.QueryResultRowsForHost(context.Background(), resultRows[0].QueryID, resultRows[0].HostID) + results, err := ds.QueryResultRows(context.Background(), query.ID, fleet.TeamFilter{User: test.UserAdmin}) require.NoError(t, err) - require.Len(t, results, 2) - require.Equal(t, resultRows[0].QueryID, results[0].QueryID) - require.Equal(t, resultRows[0].HostID, results[0].HostID) - require.Equal(t, resultRows[0].LastFetched.Unix(), results[0].LastFetched.Unix()) - require.JSONEq(t, string(resultRows[0].Data), string(results[0].Data)) - require.Equal(t, resultRows[1].QueryID, results[1].QueryID) - require.Equal(t, resultRows[1].HostID, results[1].HostID) - require.Equal(t, resultRows[1].LastFetched.Unix(), results[1].LastFetched.Unix()) - require.JSONEq(t, string(resultRows[1].Data), string(results[1].Data)) + require.Len(t, results, 1) // Should not return rows with nil data + require.Equal(t, query1Rows[1].QueryID, results[0].QueryID) + require.Equal(t, query1Rows[1].HostID, results[0].HostID) + require.Equal(t, query1Rows[1].LastFetched.Unix(), results[0].LastFetched.Unix()) + require.JSONEq(t, string(*query1Rows[1].Data), string(*results[0].Data)) // Assert that Query2 returns 1 result - results, err = ds.QueryResultRowsForHost(context.Background(), resultRow3[0].QueryID, resultRow3[0].HostID) + results, err = ds.QueryResultRows(context.Background(), query2.ID, fleet.TeamFilter{User: test.UserAdmin}) require.NoError(t, err) require.Len(t, results, 1) - require.Equal(t, resultRow3[0].QueryID, results[0].QueryID) - require.Equal(t, resultRow3[0].HostID, results[0].HostID) - require.Equal(t, resultRow3[0].LastFetched.Unix(), results[0].LastFetched.Unix()) - require.JSONEq(t, string(resultRow3[0].Data), string(results[0].Data)) + require.Equal(t, query2Rows[0].QueryID, results[0].QueryID) + require.Equal(t, query2Rows[0].HostID, results[0].HostID) + require.Equal(t, query2Rows[0].LastFetched.Unix(), results[0].LastFetched.Unix()) + require.JSONEq(t, string(*query2Rows[0].Data), string(*results[0].Data)) // Assert that QueryResultRowsForHost returns empty slice when no results are found results, err = ds.QueryResultRowsForHost(context.Background(), 999, 999) @@ -118,6 +101,67 @@ func testGetQueryResultRows(t *testing.T, ds *Datastore) { require.Len(t, results, 0) } +func testGetQueryResultRowsForHost(t *testing.T, ds *Datastore) { + user := test.NewUser(t, ds, "Test User", "test@example.com", true) + query := test.NewQuery(t, ds, nil, "New Query", "SELECT 1", user.ID, true) + host1 := test.NewHost(t, ds, "hostname1", "192.168.1.100", "1111", "UI8XB1223", time.Now()) + host2 := test.NewHost(t, ds, "hostname2", "192.168.1.100", "2222", "UI8XB1223", time.Now()) + + mockTime := time.Now().UTC().Truncate(time.Second) + + // Insert 2 Result Rows for Query1 Host1 + host1ResultRows := []*fleet.ScheduledQueryResultRow{ + { + QueryID: query.ID, + HostID: host1.ID, + LastFetched: mockTime, + Data: nil, + }, + { + QueryID: query.ID, + HostID: host1.ID, + LastFetched: mockTime, + Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), + }, + } + err := ds.OverwriteQueryResultRows(context.Background(), host1ResultRows) + require.NoError(t, err) + + // Insert 1 Result Row for Query1 Host2 + host2ResultRows := []*fleet.ScheduledQueryResultRow{ + { + QueryID: query.ID, + HostID: host2.ID, + LastFetched: mockTime, + Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), + }, + } + err = ds.OverwriteQueryResultRows(context.Background(), host2ResultRows) + require.NoError(t, err) + + // Assert that Query1 returns 2 results for Host1 + results, err := ds.QueryResultRowsForHost(context.Background(), query.ID, host1.ID) + require.NoError(t, err) + require.Len(t, results, 2) // should return rows with nil data + require.Equal(t, host1ResultRows[0].QueryID, results[0].QueryID) + require.Equal(t, host1ResultRows[0].HostID, results[0].HostID) + require.Equal(t, host1ResultRows[0].LastFetched.Unix(), results[0].LastFetched.Unix()) + require.Nil(t, results[0].Data) + require.Equal(t, host1ResultRows[1].QueryID, results[1].QueryID) + require.Equal(t, host1ResultRows[1].HostID, results[1].HostID) + require.Equal(t, host1ResultRows[1].LastFetched.Unix(), results[1].LastFetched.Unix()) + require.JSONEq(t, string(*host1ResultRows[1].Data), string(*results[1].Data)) + + // Assert that Query1 returns 1 result for Host2 + results, err = ds.QueryResultRowsForHost(context.Background(), query.ID, host2.ID) + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, host2ResultRows[0].QueryID, results[0].QueryID) + require.Equal(t, host2ResultRows[0].HostID, results[0].HostID) + require.Equal(t, host2ResultRows[0].LastFetched.Unix(), results[0].LastFetched.Unix()) + require.JSONEq(t, string(*host2ResultRows[0].Data), string(*results[0].Data)) +} + func testQueryResultRowsTeamFilter(t *testing.T, ds *Datastore) { team, err := ds.NewTeam(context.Background(), &fleet.Team{ Name: "teamFoo", @@ -163,10 +207,10 @@ func testQueryResultRowsTeamFilter(t *testing.T, ds *Datastore) { QueryID: query.ID, HostID: globalHost.ID, LastFetched: mockTime, - Data: json.RawMessage(`{ + Data: ptr.RawMessage(json.RawMessage(`{ "model": "Global USB Keyboard", "vendor": "Global Inc." - }`), + }`)), }, } @@ -178,10 +222,10 @@ func testQueryResultRowsTeamFilter(t *testing.T, ds *Datastore) { QueryID: query.ID, HostID: teamHost.ID, LastFetched: mockTime, - Data: json.RawMessage(`{ + Data: ptr.RawMessage(json.RawMessage(`{ "model": "Team USB Keyboard", "vendor": "Team Inc." - }`), + }`)), }, } err = ds.OverwriteQueryResultRows(context.Background(), teamRow) @@ -192,10 +236,10 @@ func testQueryResultRowsTeamFilter(t *testing.T, ds *Datastore) { QueryID: query.ID, HostID: observerTeamHost.ID, LastFetched: mockTime, - Data: json.RawMessage(`{ + Data: ptr.RawMessage(json.RawMessage(`{ "model": "Team USB Keyboard", "vendor": "Team Inc." - }`), + }`)), }, } err = ds.OverwriteQueryResultRows(context.Background(), observerTeamRow) @@ -213,66 +257,68 @@ func testQueryResultRowsTeamFilter(t *testing.T, ds *Datastore) { require.Equal(t, teamRow[0].HostID, results[0].HostID) require.Equal(t, teamRow[0].QueryID, results[0].QueryID) require.Equal(t, teamRow[0].LastFetched, results[0].LastFetched) - require.JSONEq(t, string(teamRow[0].Data), string(results[0].Data)) + require.JSONEq(t, string(*teamRow[0].Data), string(*results[0].Data)) require.Equal(t, observerTeamRow[0].HostID, results[1].HostID) require.Equal(t, observerTeamRow[0].QueryID, results[1].QueryID) require.Equal(t, observerTeamRow[0].LastFetched, results[1].LastFetched) - require.JSONEq(t, string(observerTeamRow[0].Data), string(results[1].Data)) + require.JSONEq(t, string(*observerTeamRow[0].Data), string(*results[1].Data)) } func testCountResultsForQuery(t *testing.T, ds *Datastore) { user := test.NewUser(t, ds, "Test User", "test@example.com", true) query1 := test.NewQuery(t, ds, nil, "New Query", "SELECT 1", user.ID, true) query2 := test.NewQuery(t, ds, nil, "New Query 2", "SELECT 1", user.ID, true) - host := test.NewHost(t, ds, "hostname123", "192.168.1.100", "1234", "UI8XB1223", time.Now()) + host := test.NewHost(t, ds, "hostname1", "192.168.1.101", "1111", "UI8XB1223", time.Now()) + host2 := test.NewHost(t, ds, "hostname1", "192.168.1.102", "2222", "UI8XB1224", time.Now()) mockTime := time.Now().UTC().Truncate(time.Second) // Insert 1 Result Row for Query1 - resultRow := []*fleet.ScheduledQueryResultRow{ + host1ResultRow := []*fleet.ScheduledQueryResultRow{ { QueryID: query1.ID, HostID: host.ID, LastFetched: mockTime, - Data: json.RawMessage(`{ + Data: ptr.RawMessage([]byte(`{ "model": "USB Keyboard", "vendor": "Apple Inc." - }`), + }`)), }, } - err := ds.SaveQueryResultRows(context.Background(), resultRow) + err := ds.OverwriteQueryResultRows(context.Background(), host1ResultRow) require.NoError(t, err) - // Insert 1 Result Row with nil Data for Query1 - // This should not be counted - resultRowNilData := []*fleet.ScheduledQueryResultRow{ + // Insert Nil Result Row for Query1, nil data rows are not counted + host2ResultRow := []*fleet.ScheduledQueryResultRow{ { QueryID: query1.ID, - HostID: host.ID, + HostID: host2.ID, LastFetched: mockTime, Data: nil, }, } - err = ds.SaveQueryResultRows(context.Background(), resultRowNilData) + err = ds.OverwriteQueryResultRows(context.Background(), host2ResultRow) require.NoError(t, err) // Insert 5 Result Rows for Query2 - resultRow2 := []*fleet.ScheduledQueryResultRow{ - { - QueryID: query2.ID, - HostID: host.ID, - LastFetched: mockTime, - Data: json.RawMessage(`{ + resultRow2 := &fleet.ScheduledQueryResultRow{ + QueryID: query2.ID, + HostID: host.ID, + LastFetched: mockTime, + Data: ptr.RawMessage([]byte(`{ "model": "USB Mouse", "vendor": "Apple Inc." - }`), - }, + }`)), } + + var resultRows []*fleet.ScheduledQueryResultRow for i := 0; i < 5; i++ { - err = ds.SaveQueryResultRows(context.Background(), resultRow2) - require.NoError(t, err) + resultRows = append(resultRows, resultRow2) } + err = ds.OverwriteQueryResultRows(context.Background(), resultRows) + require.NoError(t, err) + // Assert that ResultCountForQuery returns 1 count, err := ds.ResultCountForQuery(context.Background(), query1.ID) require.NoError(t, err) @@ -293,72 +339,98 @@ func testCountResultsForQueryAndHost(t *testing.T, ds *Datastore) { user := test.NewUser(t, ds, "Test User", "test@example.com", true) query1 := test.NewQuery(t, ds, nil, "New Query", "SELECT 1", user.ID, true) query2 := test.NewQuery(t, ds, nil, "New Query 2", "SELECT 1", user.ID, true) - host := test.NewHost(t, ds, "host1", "192.168.1.100", "1234", "UI8XB1223", time.Now()) + host1 := test.NewHost(t, ds, "host1", "192.168.1.100", "1234", "UI8XB1223", time.Now()) host2 := test.NewHost(t, ds, "host2", "192.168.1.101", "4567", "UI8XB1224", time.Now()) + host3 := test.NewHost(t, ds, "host3", "192.168.1.102", "8910", "UI8XB1225", time.Now()) mockTime := time.Now().UTC().Truncate(time.Second) - resultRows := []*fleet.ScheduledQueryResultRow{ + host1ResultRows := []*fleet.ScheduledQueryResultRow{ { QueryID: query1.ID, - HostID: host.ID, + HostID: host1.ID, LastFetched: mockTime, - Data: json.RawMessage(`{ + Data: ptr.RawMessage([]byte(`{ "model": "USB Keyboard", "vendor": "Apple Inc." - }`), + }`)), }, { QueryID: query1.ID, - HostID: host.ID, + HostID: host1.ID, LastFetched: mockTime, - Data: json.RawMessage(`{ + Data: ptr.RawMessage([]byte(`{ "model": "USB Mouse", "vendor": "Logitech" - }`), + }`)), }, + } + err := ds.OverwriteQueryResultRows(context.Background(), host1ResultRows) + require.NoError(t, err) + + host1Query2 := []*fleet.ScheduledQueryResultRow{ + { + QueryID: query2.ID, + HostID: host1.ID, + LastFetched: mockTime, + Data: ptr.RawMessage([]byte(`{ + "model": "USB Mouse", + "vendor": "Logitech" + }`)), + }, + } + err = ds.OverwriteQueryResultRows(context.Background(), host1Query2) + require.NoError(t, err) + + host2ResultRow := []*fleet.ScheduledQueryResultRow{ { QueryID: query1.ID, HostID: host2.ID, LastFetched: mockTime, - Data: json.RawMessage(`{ + Data: ptr.RawMessage([]byte(`{ "model": "USB Mouse", "vendor": "Logitech" - }`), + }`)), }, + } + err = ds.OverwriteQueryResultRows(context.Background(), host2ResultRow) + require.NoError(t, err) + + host3ResultRow := []*fleet.ScheduledQueryResultRow{ { QueryID: query2.ID, - HostID: host.ID, - LastFetched: mockTime, - Data: json.RawMessage(`{ - "foo": "bar" - }`), - }, - { - QueryID: query2.ID, // This row should not be counted - HostID: host.ID, + HostID: host3.ID, LastFetched: mockTime, Data: nil, }, } - - err := ds.SaveQueryResultRows(context.Background(), resultRows) + err = ds.OverwriteQueryResultRows(context.Background(), host3ResultRow) require.NoError(t, err) // Assert that Query1 returns 2 - count, err := ds.ResultCountForQueryAndHost(context.Background(), query1.ID, host.ID) + count, err := ds.ResultCountForQueryAndHost(context.Background(), query1.ID, host1.ID) require.NoError(t, err) require.Equal(t, 2, count) // Assert that ResultCountForQuery returns 1 - count, err = ds.ResultCountForQueryAndHost(context.Background(), query2.ID, host.ID) + count, err = ds.ResultCountForQueryAndHost(context.Background(), query2.ID, host1.ID) require.NoError(t, err) require.Equal(t, 1, count) - // Returns empty result when no results are found - count, err = ds.ResultCountForQueryAndHost(context.Background(), 999, host.ID) + // Assert that host2 returns 1 row + count, err = ds.ResultCountForQueryAndHost(context.Background(), query1.ID, host2.ID) require.NoError(t, err) - require.Equal(t, 0, count) + require.Equal(t, 1, count) + + // Assert Nil Data rows are not counted + count, err = ds.ResultCountForQueryAndHost(context.Background(), query2.ID, host3.ID) + require.NoError(t, err) + require.Zero(t, count) + + // Returns empty result when no results are found + count, err = ds.ResultCountForQueryAndHost(context.Background(), 999, host1.ID) + require.NoError(t, err) + require.Zero(t, count) } func testOverwriteQueryResultRows(t *testing.T, ds *Datastore) { @@ -369,18 +441,16 @@ func testOverwriteQueryResultRows(t *testing.T, ds *Datastore) { mockTime := time.Now().UTC().Truncate(time.Second) // Insert initial Result Rows - initialRows := []*fleet.ScheduledQueryResultRow{ + initialRow := []*fleet.ScheduledQueryResultRow{ { QueryID: query.ID, HostID: host.ID, LastFetched: mockTime, - Data: json.RawMessage( - `{"model": "USB Keyboard", "vendor": "Apple Inc."}`, - ), + Data: ptr.RawMessage([]byte(`{"model": "USB Keyboard", "vendor": "Apple Inc."}`)), }, } - err := ds.SaveQueryResultRows(context.Background(), initialRows) + err := ds.OverwriteQueryResultRows(context.Background(), initialRow) require.NoError(t, err) // Overwrite Result Rows with new data @@ -390,9 +460,7 @@ func testOverwriteQueryResultRows(t *testing.T, ds *Datastore) { QueryID: query.ID, HostID: host.ID, LastFetched: newMockTime, - Data: json.RawMessage( - `{"model": "USB Mouse", "vendor": "Logitech"}`, - ), + Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), }, } @@ -406,7 +474,7 @@ func testOverwriteQueryResultRows(t *testing.T, ds *Datastore) { require.Equal(t, overwriteRows[0].QueryID, results[0].QueryID) require.Equal(t, overwriteRows[0].HostID, results[0].HostID) require.Equal(t, overwriteRows[0].LastFetched.Unix(), results[0].LastFetched.Unix()) - require.JSONEq(t, string(overwriteRows[0].Data), string(results[0].Data)) + require.JSONEq(t, string(*overwriteRows[0].Data), string(*results[0].Data)) // Test calling OverwriteQueryResultRows with a query that doesn't exist (e.g. a deleted query). overwriteRows = []*fleet.ScheduledQueryResultRow{ @@ -414,9 +482,7 @@ func testOverwriteQueryResultRows(t *testing.T, ds *Datastore) { QueryID: 9999, HostID: host.ID, LastFetched: newMockTime, - Data: json.RawMessage( - `{"model": "USB Mouse", "vendor": "Logitech"}`, - ), + Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), }, } err = ds.OverwriteQueryResultRows(context.Background(), overwriteRows) @@ -429,7 +495,7 @@ func testOverwriteQueryResultRows(t *testing.T, ds *Datastore) { require.Equal(t, overwriteRows[0].QueryID, results[0].QueryID) require.Equal(t, overwriteRows[0].HostID, results[0].HostID) require.Equal(t, overwriteRows[0].LastFetched.Unix(), results[0].LastFetched.Unix()) - require.JSONEq(t, string(overwriteRows[0].Data), string(results[0].Data)) + require.JSONEq(t, string(*overwriteRows[0].Data), string(*results[0].Data)) } func testQueryResultRowsDoNotExceedMaxRows(t *testing.T, ds *Datastore) { @@ -451,7 +517,7 @@ func testQueryResultRowsDoNotExceedMaxRows(t *testing.T, ds *Datastore) { QueryID: query.ID, HostID: host1.ID, LastFetched: mockTime, - Data: json.RawMessage(`{"model": "Bulk Mouse", "vendor": "BulkTech"}`), + Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), } } err := ds.OverwriteQueryResultRows(context.Background(), maxMinusOneRows) @@ -474,7 +540,7 @@ func testQueryResultRowsDoNotExceedMaxRows(t *testing.T, ds *Datastore) { QueryID: query.ID, HostID: host3.ID, LastFetched: mockTime, - Data: json.RawMessage(`{"model": "USB Mouse", "vendor": "Logitech"}`), + Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), }, }) require.NoError(t, err) @@ -490,7 +556,7 @@ func testQueryResultRowsDoNotExceedMaxRows(t *testing.T, ds *Datastore) { QueryID: query.ID, HostID: host4.ID, LastFetched: mockTime, - Data: json.RawMessage(`{"model": "USB Mouse", "vendor": "Logitech"}`), + Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), }, }) require.NoError(t, err) @@ -508,7 +574,7 @@ func testQueryResultRowsDoNotExceedMaxRows(t *testing.T, ds *Datastore) { QueryID: query2.ID, HostID: host1.ID, LastFetched: mockTime, - Data: json.RawMessage(`{"model": "Bulk Mouse", "vendor": "BulkTech"}`), + Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), } } err = ds.OverwriteQueryResultRows(context.Background(), largeBatchRows) @@ -526,9 +592,7 @@ func testQueryResultRowsDoNotExceedMaxRows(t *testing.T, ds *Datastore) { QueryID: query2.ID, HostID: host2.ID, LastFetched: newMockTime, - Data: json.RawMessage( - `{"model": "USB Mouse", "vendor": "Logitech"}`, - ), + Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), }, } @@ -551,9 +615,7 @@ func testQueryResultRows(t *testing.T, ds *Datastore) { QueryID: query.ID, HostID: 9999, LastFetched: mockTime, - Data: json.RawMessage( - `{"model": "USB Mouse", "vendor": "Logitech"}`, - ), + Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), }, } err := ds.OverwriteQueryResultRows(context.Background(), overwriteRows) @@ -566,43 +628,3 @@ func testQueryResultRows(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Len(t, results, 1) } - -func (ds *Datastore) SaveQueryResultRows(ctx context.Context, rows []*fleet.ScheduledQueryResultRow) error { - if len(rows) == 0 { - return nil // Nothing to insert - } - - valueStrings := make([]string, 0, len(rows)) - valueArgs := make([]interface{}, 0, len(rows)*4) - - for _, row := range rows { - valueStrings = append(valueStrings, "(?, ?, ?, ?)") - valueArgs = append(valueArgs, row.QueryID, row.HostID, row.LastFetched, row.Data) - } - - insertStmt := fmt.Sprintf(` - INSERT INTO query_results (query_id, host_id, last_fetched, data) - VALUES %s - `, strings.Join(valueStrings, ",")) - - _, err := ds.writer(ctx).ExecContext(ctx, insertStmt, valueArgs...) - if err != nil { - return err - } - - return nil -} - -func (ds *Datastore) QueryResultRowsForHost(ctx context.Context, queryID, hostID uint) ([]*fleet.ScheduledQueryResultRow, error) { - selectStmt := ` - SELECT query_id, host_id, last_fetched, data FROM query_results - WHERE query_id = ? AND host_id = ? AND data IS NOT NULL - ` - results := []*fleet.ScheduledQueryResultRow{} - err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, selectStmt, queryID, hostID) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "selecting query result rows for host") - } - - return results, nil -} diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 27241d83bf..78e7b8cff3 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -401,6 +401,7 @@ type Datastore interface { // QueryResultRows returns stored results of a query QueryResultRows(ctx context.Context, queryID uint, filter TeamFilter) ([]*ScheduledQueryResultRow, error) + QueryResultRowsForHost(ctx context.Context, queryID, hostID uint) ([]*ScheduledQueryResultRow, error) ResultCountForQuery(ctx context.Context, queryID uint) (int, error) ResultCountForQueryAndHost(ctx context.Context, queryID, hostID uint) (int, error) OverwriteQueryResultRows(ctx context.Context, rows []*ScheduledQueryResultRow) error diff --git a/server/fleet/queries.go b/server/fleet/queries.go index cfc655bbc4..59472e17f9 100644 --- a/server/fleet/queries.go +++ b/server/fleet/queries.go @@ -428,7 +428,10 @@ func MapQueryReportResultsToRows(rows []*ScheduledQueryResultRow) ([]HostQueryRe var results []HostQueryResultRow for _, row := range rows { var columns map[string]string - if err := json.Unmarshal(row.Data, &columns); err != nil { + if row.Data == nil { + continue + } + if err := json.Unmarshal(*row.Data, &columns); err != nil { return nil, err } results = append(results, HostQueryResultRow{ @@ -455,6 +458,12 @@ type HostQueryResultRow struct { Columns map[string]string `json:"columns"` } +type HostQueryReportResult struct { + // Columns contains the key-value pairs of a result row. + // The map key is the name of the column, and the map value is the value. + Columns map[string]string `json:"columns"` +} + // ScheduledQueryResult holds results of a scheduled query received from a osquery agent. type ScheduledQueryResult struct { // QueryName is the name of the query. @@ -463,7 +472,7 @@ type ScheduledQueryResult struct { OsqueryHostID string `json:"hostIdentifier"` // Snapshot holds the result rows. It's an array of maps, where the map keys // are column names and map values are the values. - Snapshot []json.RawMessage `json:"snapshot"` + Snapshot []*json.RawMessage `json:"snapshot"` // LastFetched is the time this result was received. UnixTime uint `json:"unixTime"` } @@ -484,7 +493,7 @@ type ScheduledQueryResultRow struct { HardwareSerial sql.NullString `db:"hardware_serial"` // Data holds a single result row. It holds a map where the map keys // are column names and map values are the values. - Data json.RawMessage `db:"data"` + Data *json.RawMessage `db:"data"` // LastFetched is the time this result was received. LastFetched time.Time `db:"last_fetched"` } diff --git a/server/fleet/queries_test.go b/server/fleet/queries_test.go index ec7b2558e0..092e510faa 100644 --- a/server/fleet/queries_test.go +++ b/server/fleet/queries_test.go @@ -2,7 +2,6 @@ package fleet import ( "database/sql" - "encoding/json" "testing" "time" @@ -231,7 +230,7 @@ func TestMapQueryReportResultRows(t *testing.T) { HostID: 1, Hostname: sql.NullString{String: "macOS host", Valid: true}, LastFetched: macOSUSBDevicesLastFetched, - Data: json.RawMessage(`{ + Data: ptr.RawMessage([]byte(`{ "class": "9", "model": "AppleUSBVHCIBCE Root Hub Simulation", "model_id": "8000", @@ -244,13 +243,13 @@ func TestMapQueryReportResultRows(t *testing.T) { "vendor": "Apple Inc.", "vendor_id": "05bc", "version": "0.0" - }`), + }`)), }, { HostID: 1, Hostname: sql.NullString{String: "macOS host", Valid: true}, LastFetched: macOSUSBDevicesLastFetched, - Data: json.RawMessage(`{ + Data: ptr.RawMessage([]byte(`{ "class": "9", "model": "AppleUSBXHCI Root Hub Simulation", "model_id": "8007", @@ -263,13 +262,13 @@ func TestMapQueryReportResultRows(t *testing.T) { "vendor": "Apple Inc.", "vendor_id": "05ac", "version": "0.0" - }`), + }`)), }, { HostID: 2, Hostname: sql.NullString{String: "ubuntu host", Valid: true}, LastFetched: ubuntuUSBDevicesLastFetched, - Data: json.RawMessage(`{ + Data: ptr.RawMessage([]byte(`{ "class": "9", "model": "1.1 root hub", "model_id": "0001", @@ -282,7 +281,7 @@ func TestMapQueryReportResultRows(t *testing.T) { "vendor": "Linux Foundation", "vendor_id": "1d6b", "version": "0602" - }`), + }`)), }, }, expected: []HostQueryResultRow{ @@ -353,7 +352,7 @@ func TestMapQueryReportResultRows(t *testing.T) { HostID: 1, Hostname: sql.NullString{String: "macOS host", Valid: true}, LastFetched: macOSOsqueryInfoLastFetched, - Data: json.RawMessage(`{ + Data: ptr.RawMessage([]byte(`{ "build_distro": "10.14", "build_platform": "darwin", "config_hash": "eed0d8296e5f90b790a23814a9db7a127b13498d", @@ -366,7 +365,7 @@ func TestMapQueryReportResultRows(t *testing.T) { "uuid": "589966AE-074A-503B-B17B-54B05684A120", "version": "5.9.1", "watcher": "96729" - }`), + }`)), }, }, expected: []HostQueryResultRow{ @@ -399,7 +398,7 @@ func TestMapQueryReportResultRows(t *testing.T) { HostID: 3, Hostname: sql.NullString{String: "bar", Valid: true}, LastFetched: time.Now(), - Data: json.RawMessage(`invalid JSON`), + Data: ptr.RawMessage([]byte(`invalid JSON`)), }, }, shouldFail: true, @@ -411,7 +410,7 @@ func TestMapQueryReportResultRows(t *testing.T) { HostID: 3, Hostname: sql.NullString{String: "bar", Valid: true}, LastFetched: time.Now(), - Data: json.RawMessage(`{"foobar": 1}`), + Data: ptr.RawMessage([]byte(`{"foobar": 1}`)), }, }, shouldFail: true, diff --git a/server/fleet/service.go b/server/fleet/service.go index 816a96d2e1..ea7eda07e4 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -272,6 +272,10 @@ type Service interface { GetQuery(ctx context.Context, id uint) (*Query, error) // GetQueryReportResults returns all the stored results of a query for hosts the requestor has access to GetQueryReportResults(ctx context.Context, id uint) ([]HostQueryResultRow, error) + // GetHostQueryReportResults returns all stored results of a query for a specific host + GetHostQueryReportResults(ctx context.Context, hid uint, queryID uint) (rows []HostQueryReportResult, lastFetched *time.Time, err error) + // QueryReportIsClipped returns true if the number of query report rows exceeds the maximum + QueryReportIsClipped(ctx context.Context, queryID uint) (bool, error) NewQuery(ctx context.Context, p QueryPayload) (*Query, error) ModifyQuery(ctx context.Context, id uint, p QueryPayload) (*Query, error) DeleteQuery(ctx context.Context, teamID *uint, name string) error @@ -325,6 +329,8 @@ type Service interface { // The return value can also include policy information and CVE scores based // on the values provided to `opts` GetHost(ctx context.Context, id uint, opts HostDetailOptions) (host *HostDetail, err error) + // GetHostLite returns basic host information not requiring table joins + GetHostLite(ctx context.Context, id uint) (host *Host, err error) GetHostHealth(ctx context.Context, id uint) (hostHealth *HostHealth, err error) GetHostSummary(ctx context.Context, teamID *uint, platform *string, lowDiskSpace *int) (summary *HostSummary, err error) DeleteHost(ctx context.Context, id uint) (err error) diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index adc53e31d0..5d7226708c 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -300,6 +300,8 @@ type ScheduledQueryIDsByNameFunc func(ctx context.Context, batchSize int, packAn type QueryResultRowsFunc func(ctx context.Context, queryID uint, filter fleet.TeamFilter) ([]*fleet.ScheduledQueryResultRow, error) +type QueryResultRowsForHostFunc func(ctx context.Context, queryID uint, hostID uint) ([]*fleet.ScheduledQueryResultRow, error) + type ResultCountForQueryFunc func(ctx context.Context, queryID uint) (int, error) type ResultCountForQueryAndHostFunc func(ctx context.Context, queryID uint, hostID uint) (int, error) @@ -1196,6 +1198,9 @@ type DataStore struct { QueryResultRowsFunc QueryResultRowsFunc QueryResultRowsFuncInvoked bool + QueryResultRowsForHostFunc QueryResultRowsForHostFunc + QueryResultRowsForHostFuncInvoked bool + ResultCountForQueryFunc ResultCountForQueryFunc ResultCountForQueryFuncInvoked bool @@ -2894,6 +2899,13 @@ func (s *DataStore) QueryResultRows(ctx context.Context, queryID uint, filter fl return s.QueryResultRowsFunc(ctx, queryID, filter) } +func (s *DataStore) QueryResultRowsForHost(ctx context.Context, queryID uint, hostID uint) ([]*fleet.ScheduledQueryResultRow, error) { + s.mu.Lock() + s.QueryResultRowsForHostFuncInvoked = true + s.mu.Unlock() + return s.QueryResultRowsForHostFunc(ctx, queryID, hostID) +} + func (s *DataStore) ResultCountForQuery(ctx context.Context, queryID uint) (int, error) { s.mu.Lock() s.ResultCountForQueryFuncInvoked = true diff --git a/server/service/handler.go b/server/service/handler.go index 3150bf1903..308bf8893f 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -385,6 +385,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/device_mapping", listHostDeviceMappingEndpoint, listHostDeviceMappingRequest{}) ue.GET("/api/_version_/fleet/hosts/report", hostsReportEndpoint, hostsReportRequest{}) ue.GET("/api/_version_/fleet/os_versions", osVersionsEndpoint, osVersionsRequest{}) + ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/queries/{query_id:[0-9]+}", getHostQueryReportEndpoint, getHostQueryReportRequest{}) ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/health", getHostHealthEndpoint, getHostHealthRequest{}) ue.GET("/api/_version_/fleet/hosts/summary/mdm", getHostMDMSummary, getHostMDMSummaryRequest{}) diff --git a/server/service/hosts.go b/server/service/hosts.go index 0b828fb4b3..52e2e03dde 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -509,6 +509,26 @@ func (svc *Service) checkWriteForHostIDs(ctx context.Context, ids []uint) error return nil } +// ////////////////////////////////////////////////////////////////////////////// +// Get Host Lite +// ////////////////////////////////////////////////////////////////////////////// +func (svc *Service) GetHostLite(ctx context.Context, id uint) (*fleet.Host, error) { + if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil { + return nil, err + } + + host, err := svc.ds.HostLite(ctx, id) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get host lite") + } + + if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil { + return nil, err + } + + return host, nil +} + //////////////////////////////////////////////////////////////////////////////// // Get Host Summary //////////////////////////////////////////////////////////////////////////////// @@ -1067,6 +1087,93 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f }, nil } +//////////////////////////////////////////////////////////////////////////////// +// Get Host Query Report +//////////////////////////////////////////////////////////////////////////////// + +type getHostQueryReportRequest struct { + ID uint `url:"id"` + QueryID uint `url:"query_id"` +} + +type getHostQueryReportResponse struct { + QueryID uint `json:"query_id"` + HostID uint `json:"host_id"` + HostName string `json:"host_name"` + LastFetched *time.Time `json:"last_fetched"` + ReportClipped bool `json:"report_clipped"` + Results []fleet.HostQueryReportResult `json:"results"` + Err error `json:"error,omitempty"` +} + +func (r getHostQueryReportResponse) error() error { return r.Err } + +func getHostQueryReportEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*getHostQueryReportRequest) + + // Need to return hostname in response even if there are no report results + host, err := svc.GetHostLite(ctx, req.ID) + if err != nil { + return getHostQueryReportResponse{Err: err}, nil + } + + reportResults, lastFetched, err := svc.GetHostQueryReportResults(ctx, req.ID, req.QueryID) + if err != nil { + return getHostQueryReportResponse{Err: err}, nil + } + + isClipped, err := svc.QueryReportIsClipped(ctx, req.QueryID) + if err != nil { + return getHostQueryReportResponse{Err: err}, nil + } + + return getHostQueryReportResponse{ + QueryID: req.QueryID, + HostID: host.ID, + HostName: host.DisplayName(), + LastFetched: lastFetched, + ReportClipped: isClipped, + Results: reportResults, + }, nil +} + +func (svc *Service) GetHostQueryReportResults(ctx context.Context, hostID uint, queryID uint) ([]fleet.HostQueryReportResult, *time.Time, error) { + query, err := svc.ds.Query(ctx, queryID) + if err != nil { + setAuthCheckedOnPreAuthErr(ctx) + return nil, nil, ctxerr.Wrap(ctx, err, "get query from datastore") + } + if err := svc.authz.Authorize(ctx, query, fleet.ActionRead); err != nil { + return nil, nil, err + } + + rows, err := svc.ds.QueryResultRowsForHost(ctx, queryID, hostID) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "get query result rows for host") + } + + if len(rows) == 0 { + return []fleet.HostQueryReportResult{}, nil, nil + } + + var lastFetched *time.Time + result := make([]fleet.HostQueryReportResult, 0, len(rows)) + for _, row := range rows { + fetched := row.LastFetched // copy to avoid loop reuse issue + lastFetched = &fetched // need to return value even if data is nil + + if row.Data != nil { + columns := map[string]string{} + if err := json.Unmarshal(*row.Data, &columns); err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "unmarshal query result row data") + } + result = append(result, fleet.HostQueryReportResult{Columns: columns}) + } + } + + return result, lastFetched, nil +} + func (svc *Service) hostIDsAndNamesFromFilters(ctx context.Context, opt fleet.HostListOptions, lid *uint) ([]uint, []string, error) { filter, err := processHostFilters(ctx, opt, lid) if err != nil { diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index add9157dc8..69caf12377 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -659,12 +659,18 @@ func TestHostAuth(t *testing.T) { _, err := svc.GetHost(ctx, 1, opts) checkAuthErr(t, tt.shouldFailTeamRead, err) + _, err = svc.GetHostLite(ctx, 1) + checkAuthErr(t, tt.shouldFailTeamRead, err) + _, err = svc.HostByIdentifier(ctx, "1", opts) checkAuthErr(t, tt.shouldFailTeamRead, err) _, err = svc.GetHost(ctx, 2, opts) checkAuthErr(t, tt.shouldFailGlobalRead, err) + _, err = svc.GetHostLite(ctx, 2) + checkAuthErr(t, tt.shouldFailGlobalRead, err) + _, err = svc.HostByIdentifier(ctx, "2", opts) checkAuthErr(t, tt.shouldFailGlobalRead, err) diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 6f4f57f353..58c3b3a68e 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -8559,18 +8559,33 @@ func (s *integrationTestSuite) TestQueryReports() { }) require.NoError(t, err) - host2Team1, err := s.ds.NewHost(ctx, &fleet.Host{ + host2Global, err := s.ds.NewHost(ctx, &fleet.Host{ DetailUpdatedAt: time.Now(), LabelUpdatedAt: time.Now(), PolicyUpdatedAt: time.Now(), SeenTime: time.Now(), NodeKey: ptr.String("2"), UUID: "2", - ComputerName: "Foo Local2", Hostname: "foo.local2", OsqueryHostID: ptr.String("2"), - PrimaryIP: "192.168.1.2", + PrimaryIP: "192.168.1.1", PrimaryMac: "30-65-EC-6F-C4-59", + Platform: "ubuntu", + }) + require.NoError(t, err) + + host2Team1, err := s.ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String("3"), + UUID: "3", + ComputerName: "Foo Local3", + Hostname: "foo.local3", + OsqueryHostID: ptr.String("3"), + PrimaryIP: "192.168.1.3", + PrimaryMac: "30-65-EC-6F-C4-60", Platform: "darwin", }) require.NoError(t, err) @@ -8612,6 +8627,16 @@ func (s *integrationTestSuite) TestQueryReports() { require.NotNil(t, gqrr.Results) require.Len(t, gqrr.Results, 0) + var ghqrr getHostQueryReportResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/queries/%d", host1Global.ID, usbDevicesQuery.ID), getHostQueryReportRequest{}, http.StatusOK, &ghqrr) + require.NoError(t, ghqrr.Err) + require.Equal(t, usbDevicesQuery.ID, ghqrr.QueryID) + require.Equal(t, host1Global.ID, ghqrr.HostID) + require.Nil(t, ghqrr.LastFetched) + require.False(t, ghqrr.ReportClipped) + require.NotNil(t, ghqrr.Results) + require.Len(t, ghqrr.Results, 0) + slreq := submitLogsRequest{ NodeKey: *host2Team1.NodeKey, LogType: "result", @@ -8733,6 +8758,29 @@ func (s *integrationTestSuite) TestQueryReports() { s.DoJSON("POST", "/api/osquery/log", slreq, http.StatusOK, &slres) require.NoError(t, slres.Err) + emptyslreq := submitLogsRequest{ + NodeKey: *host2Global.NodeKey, + LogType: "result", + Data: json.RawMessage(`[{ + "snapshot": [], + "action": "snapshot", + "name": "pack/Global/` + osqueryInfoQuery.Name + `", + "hostIdentifier": "` + *host1Global.OsqueryHostID + `", + "calendarTime": "Fri Oct 6 18:13:04 2023 UTC", + "unixTime": 1696615984, + "epoch": 0, + "counter": 0, + "numerics": false, + "decorations": { + "host_uuid": "187c4d56-8e45-1a9d-8513-ac17efd2f0fd", + "hostname": "` + host1Global.Hostname + `" + } + }]`), + } + emptyslres := submitLogsResponse{} + s.DoJSON("POST", "/api/osquery/log", emptyslreq, http.StatusOK, &emptyslres) + require.NoError(t, emptyslres.Err) + gqrr = getQueryReportResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", usbDevicesQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) require.NoError(t, gqrr.Err) @@ -8777,6 +8825,47 @@ func (s *integrationTestSuite) TestQueryReports() { "version": "9.33", }, gqrr.Results[1].Columns) + ghqrr = getHostQueryReportResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/queries/%d", host2Team1.ID, usbDevicesQuery.ID), getHostQueryReportRequest{}, http.StatusOK, &ghqrr) + require.NoError(t, ghqrr.Err) + require.Equal(t, usbDevicesQuery.ID, ghqrr.QueryID) + require.Equal(t, host2Team1.ID, ghqrr.HostID) + require.NotNil(t, ghqrr.LastFetched) + require.False(t, ghqrr.ReportClipped) + require.Len(t, ghqrr.Results, 2) + sort.Slice(gqrr.Results, func(i, j int) bool { + // Let's just pick a known column of the query to sort. + return gqrr.Results[i].Columns["usb_port"] < gqrr.Results[j].Columns["usb_port"] + }) + require.Equal(t, map[string]string{ + "class": "239", + "model": "HD Pro Webcam C920", + "model_id": "0892", + "protocol": "", + "removable": "1", + "serial": "zoobar", + "subclass": "2", + "usb_address": "3", + "usb_port": "1", + "vendor": "", + "vendor_id": "046d", + "version": "0.19", + }, ghqrr.Results[0].Columns) + require.Equal(t, map[string]string{ + "class": "0", + "model": "Apple Internal Keyboard / Trackpad", + "model_id": "027e", + "protocol": "", + "removable": "0", + "serial": "foobar", + "subclass": "0", + "usb_address": "8", + "usb_port": "5", + "vendor": "Apple Inc.", + "vendor_id": "05ac", + "version": "9.33", + }, ghqrr.Results[1].Columns) + gqrr = getQueryReportResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) require.NoError(t, gqrr.Err) @@ -8821,6 +8910,38 @@ func (s *integrationTestSuite) TestQueryReports() { "watcher": "95636", }, gqrr.Results[1].Columns) + ghqrr = getHostQueryReportResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/queries/%d", host1Global.ID, osqueryInfoQuery.ID), getHostQueryReportRequest{}, http.StatusOK, &ghqrr) + require.NoError(t, ghqrr.Err) + require.Equal(t, osqueryInfoQuery.ID, ghqrr.QueryID) + require.Equal(t, host1Global.ID, ghqrr.HostID) + require.NotNil(t, ghqrr.LastFetched) + require.False(t, ghqrr.ReportClipped) + require.Len(t, ghqrr.Results, 1) + require.Equal(t, map[string]string{ + "build_distro": "centos7", + "build_platform": "linux", + "config_hash": "eed0d8296e5f90b790a23814a9db7a127b13498d", + "config_valid": "1", + "extensions": "active", + "instance_id": "e5799132-85ab-4cfa-89f3-03e0dd3c509a", + "pid": "3574", + "platform_mask": "9", + "start_time": "1696502961", + "uuid": host1Global.UUID, + "version": "5.9.2", + "watcher": "3570", + }, ghqrr.Results[0].Columns) + + ghqrr = getHostQueryReportResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/queries/%d", host2Global.ID, osqueryInfoQuery.ID), getHostQueryReportRequest{}, http.StatusOK, &ghqrr) + require.NoError(t, ghqrr.Err) + require.Equal(t, osqueryInfoQuery.ID, ghqrr.QueryID) + require.Equal(t, host2Global.ID, ghqrr.HostID) + require.NotNil(t, ghqrr.LastFetched) + require.False(t, ghqrr.ReportClipped) + require.Len(t, ghqrr.Results, 0) + // verify that certain modifications to queries don't cause result deletion modifyQueryResp := modifyQueryResponse{} updatedDesc := "Updated description" @@ -8893,7 +9014,13 @@ func (s *integrationTestSuite) TestQueryReports() { s.DoJSON("POST", "/api/osquery/log", slreq, http.StatusOK, &slres) require.NoError(t, slres.Err) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) - require.Len(t, gqrr.Results, 1000) + require.Len(t, gqrr.Results, fleet.MaxQueryReportRows) + + ghqrr = getHostQueryReportResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/queries/%d", host1Global.ID, osqueryInfoQuery.ID), getHostQueryReportRequest{}, http.StatusOK, &ghqrr) + require.NoError(t, ghqrr.Err) + require.True(t, ghqrr.ReportClipped) + require.Len(t, ghqrr.Results, fleet.MaxQueryReportRows) slreq.Data = json.RawMessage(`[{ "snapshot": [` + results(1, host1Global.UUID) + ` @@ -8915,7 +9042,7 @@ func (s *integrationTestSuite) TestQueryReports() { s.DoJSON("POST", "/api/osquery/log", slreq, http.StatusOK, &slres) require.NoError(t, slres.Err) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) - require.Len(t, gqrr.Results, 1000) + require.Len(t, gqrr.Results, fleet.MaxQueryReportRows) // TODO: Set global discard flag and verify that all data is gone. } diff --git a/server/service/osquery_test.go b/server/service/osquery_test.go index 305ab6d6e9..38f7f7985b 100644 --- a/server/service/osquery_test.go +++ b/server/service/osquery_test.go @@ -594,16 +594,16 @@ func TestSubmitResultLogsToLogDestination(t *testing.T) { require.Len(t, rows, 1) require.Equal(t, uint(999), rows[0].HostID) require.NotZero(t, rows[0].LastFetched) - require.JSONEq(t, `{"hour":"20","minutes":"8"}`, string(rows[0].Data)) + require.JSONEq(t, `{"hour":"20","minutes":"8"}`, string(*rows[0].Data)) case rows[0].QueryID == 444: require.Len(t, rows, 2) require.Equal(t, uint(999), rows[0].HostID) require.NotZero(t, rows[0].LastFetched) - require.JSONEq(t, `{"hour":"20","minutes":"8"}`, string(rows[0].Data)) + require.JSONEq(t, `{"hour":"20","minutes":"8"}`, string(*rows[0].Data)) require.Equal(t, uint(999), rows[1].HostID) require.Equal(t, uint(444), rows[1].QueryID) require.NotZero(t, rows[1].LastFetched) - require.JSONEq(t, `{"hour":"21","minutes":"9"}`, string(rows[1].Data)) + require.JSONEq(t, `{"hour":"21","minutes":"9"}`, string(*rows[1].Data)) } return nil } @@ -694,8 +694,8 @@ func TestSaveResultLogsToQueryReports(t *testing.T) { { QueryName: "pack/Global/Uptime", OsqueryHostID: "1379f59d98f4", - Snapshot: []json.RawMessage{ - json.RawMessage(`{"hour":"20","minutes":"8"}`), + Snapshot: []*json.RawMessage{ + ptr.RawMessage(json.RawMessage(`{"hour":"20","minutes":"8"}`)), }, UnixTime: 1484078931, }, diff --git a/server/service/queries.go b/server/service/queries.go index 47429b7923..5e32d2fc14 100644 --- a/server/service/queries.go +++ b/server/service/queries.go @@ -123,7 +123,7 @@ func onlyShowObserverCanRunQueries(user *fleet.User, teamID *uint) bool { } //////////////////////////////////////////////////////////////////////////////// -// Get query report +// Query Reports //////////////////////////////////////////////////////////////////////////////// type getQueryReportRequest struct { @@ -183,6 +183,23 @@ func (svc *Service) GetQueryReportResults(ctx context.Context, id uint) ([]fleet return queryReportResults, nil } +func (svc *Service) QueryReportIsClipped(ctx context.Context, queryID uint) (bool, error) { + query, err := svc.ds.Query(ctx, queryID) + if err != nil { + setAuthCheckedOnPreAuthErr(ctx) + return false, ctxerr.Wrap(ctx, err, "get query from datastore") + } + if err := svc.authz.Authorize(ctx, query, fleet.ActionRead); err != nil { + return false, err + } + + count, err := svc.ds.ResultCountForQuery(ctx, queryID) + if err != nil { + return false, err + } + return count >= fleet.MaxQueryReportRows, nil +} + //////////////////////////////////////////////////////////////////////////////// // Create Query //////////////////////////////////////////////////////////////////////////////// diff --git a/server/service/queries_test.go b/server/service/queries_test.go index cd929ad153..b0786e7a1e 100644 --- a/server/service/queries_test.go +++ b/server/service/queries_test.go @@ -447,6 +447,11 @@ func TestQueryAuth(t *testing.T) { } return nil, newNotFoundError() } + + ds.ResultCountForQueryFunc = func(ctx context.Context, queryID uint) (int, error) { + return 0, nil + } + ds.SaveQueryFunc = func(ctx context.Context, query *fleet.Query, shouldDiscardResults bool) error { return nil } @@ -660,6 +665,9 @@ func TestQueryAuth(t *testing.T) { _, err = svc.GetQuery(ctx, tt.qid) checkAuthErr(t, tt.shouldFailRead, err) + _, err = svc.QueryReportIsClipped(ctx, tt.qid) + checkAuthErr(t, tt.shouldFailRead, err) + _, err = svc.ListQueries(ctx, fleet.ListOptions{}, query.TeamID, nil) checkAuthErr(t, tt.shouldFailRead, err) @@ -682,3 +690,31 @@ func TestQueryAuth(t *testing.T) { }) } } + +func TestQueryReportIsClipped(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil) + viewerCtx := viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{ + ID: 1, + GlobalRole: ptr.String(fleet.RoleAdmin), + }}) + + ds.QueryFunc = func(ctx context.Context, queryID uint) (*fleet.Query, error) { + return &fleet.Query{}, nil + } + ds.ResultCountForQueryFunc = func(ctx context.Context, queryID uint) (int, error) { + return 0, nil + } + + isClipped, err := svc.QueryReportIsClipped(viewerCtx, 1) + require.NoError(t, err) + require.False(t, isClipped) + + ds.ResultCountForQueryFunc = func(ctx context.Context, queryID uint) (int, error) { + return fleet.MaxQueryReportRows, nil + } + + isClipped, err = svc.QueryReportIsClipped(viewerCtx, 1) + require.NoError(t, err) + require.True(t, isClipped) +} From f1acd30bcf677b81832f0705e717565ff267f0b0 Mon Sep 17 00:00:00 2001 From: Tim Lee Date: Tue, 12 Dec 2023 08:40:57 -0700 Subject: [PATCH 5/9] 15380 extend hosts api (#15421) --- server/datastore/mysql/hosts.go | 91 ++++---- server/datastore/mysql/hosts_test.go | 216 ++++++++++++++++-- server/fleet/queries.go | 15 +- server/fleet/scheduled_queries.go | 15 +- server/service/integration_enterprise_test.go | 4 +- 5 files changed, 263 insertions(+), 78 deletions(-) diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index a724d42dfd..2b92311ab5 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -372,50 +372,54 @@ func loadHostScheduledQueryStatsDB(ctx context.Context, db sqlx.QueryerContext, if teamID != nil { teamID_ = *teamID } - ds := dialect.From(goqu.I("queries").As("q")).Select( - goqu.I("q.id"), - goqu.I("q.name"), - goqu.I("q.description"), - goqu.I("q.team_id"), - goqu.I("q.schedule_interval").As("schedule_interval"), - goqu.COALESCE(goqu.I("sqs.average_memory"), 0).As("average_memory"), - goqu.COALESCE(goqu.I("sqs.denylisted"), false).As("denylisted"), - goqu.COALESCE(goqu.I("sqs.executions"), 0).As("executions"), - goqu.COALESCE(goqu.I("sqs.last_executed"), goqu.L("timestamp(?)", pastDate)).As("last_executed"), - goqu.COALESCE(goqu.I("sqs.output_size"), 0).As("output_size"), - goqu.COALESCE(goqu.I("sqs.system_time"), 0).As("system_time"), - goqu.COALESCE(goqu.I("sqs.user_time"), 0).As("user_time"), - goqu.COALESCE(goqu.I("sqs.wall_time"), 0).As("wall_time"), - ).LeftJoin( - dialect.From("scheduled_query_stats").As("sqs").Where( - goqu.I("host_id").Eq(hid), - ), - goqu.On(goqu.I("sqs.scheduled_query_id").Eq(goqu.I("q.id"))), - ).Where( - goqu.And( - goqu.Or( - // sq.platform empty or NULL means the scheduled query is set to - // run on all hosts. - goqu.I("q.platform").Eq(""), - goqu.I("q.platform").IsNull(), - // scheduled_queries.platform can be a comma-separated list of - // platforms, e.g. "darwin,windows". - goqu.L("FIND_IN_SET(?, q.platform)", fleet.PlatformFromHost(hostPlatform)).Neq(0), - ), - goqu.I("q.schedule_interval").Gt(0), - goqu.I("q.automations_enabled").IsTrue(), - goqu.Or( - goqu.I("q.team_id").IsNull(), - goqu.I("q.team_id").Eq(teamID_), - ), - ), - ) - sql, args, err := ds.ToSQL() - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "sql build") + + sqlQuery := ` + SELECT + q.id, + q.name, + q.description, + q.team_id, + q.schedule_interval AS schedule_interval, + q.discard_data, + q.automations_enabled, + MAX(qr.last_fetched) as last_fetched, + COALESCE(MAX(sqs.average_memory), 0) AS average_memory, + COALESCE(MAX(sqs.denylisted), false) AS denylisted, + COALESCE(MAX(sqs.executions), 0) AS executions, + COALESCE(MAX(sqs.last_executed), TIMESTAMP(?)) AS last_executed, + COALESCE(MAX(sqs.output_size), 0) AS output_size, + COALESCE(MAX(sqs.system_time), 0) AS system_time, + COALESCE(MAX(sqs.user_time), 0) AS user_time, + COALESCE(MAX(sqs.wall_time), 0) AS wall_time + FROM + queries q + LEFT JOIN scheduled_query_stats sqs ON (q.id = sqs.scheduled_query_id AND sqs.host_id = ?) + LEFT JOIN query_results qr ON (q.id = qr.query_id AND qr.host_id = ?) + WHERE + (q.platform = '' OR q.platform IS NULL OR FIND_IN_SET(?, q.platform) != 0) + AND q.schedule_interval > 0 + AND (q.automations_enabled IS TRUE OR (q.discard_data IS FALSE AND q.logging_type = ?)) + AND (q.team_id IS NULL OR q.team_id = ?) + OR EXISTS ( + SELECT 1 FROM query_results + WHERE query_results.query_id = q.id + AND query_results.host_id = ? + ) + GROUP BY q.id + ` + + args := []interface{}{ + pastDate, + hid, + hid, + fleet.PlatformFromHost(hostPlatform), + fleet.LoggingSnapshot, + teamID_, + hid, } + var stats []fleet.QueryStats - if err := sqlx.SelectContext(ctx, db, &stats, sql, args...); err != nil { + if err := sqlx.SelectContext(ctx, db, &stats, sqlQuery, args...); err != nil { return nil, ctxerr.Wrap(ctx, err, "load query stats") } return stats, nil @@ -690,6 +694,9 @@ func queryStatsToScheduledQueryStats(queriesStats []fleet.QueryStats, packName s Denylisted: queryStats.Denylisted, Executions: queryStats.Executions, Interval: queryStats.Interval, + DiscardData: queryStats.DiscardData, + AutomationsEnabled: queryStats.AutomationsEnabled, + LastFetched: queryStats.LastFetched, LastExecuted: queryStats.LastExecuted, OutputSize: queryStats.OutputSize, SystemTime: queryStats.SystemTime, diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index 260b8efa36..f41b62f3be 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -116,6 +116,7 @@ func TestHosts(t *testing.T) { {"HostsListByDiskEncryptionStatus", testHostsListMacOSSettingsDiskEncryptionStatus}, {"HostsListFailingPolicies", printReadsInTest(testHostsListFailingPolicies)}, {"HostsExpiration", testHostsExpiration}, + {"HostsIncludesScheduledQueriesInPackStats", testHostsIncludesScheduledQueriesInPackStats}, {"HostsAllPackStats", testHostsAllPackStats}, {"HostsPackStatsMultipleHosts", testHostsPackStatsMultipleHosts}, {"HostsPackStatsForPlatform", testHostsPackStatsForPlatform}, @@ -618,17 +619,20 @@ func testHostsWithTeamPackStats(t *testing.T, ds *Datastore) { PackName: pack1.Name, ScheduledQueryName: squery1.Name, - QueryName: query1.Name, - PackID: pack1.ID, - AverageMemory: 8000, - Denylisted: false, - Executions: 164, - Interval: 30, - LastExecuted: time.Unix(1620325191, 0).UTC(), - OutputSize: 1337, - SystemTime: 150, - UserTime: 180, - WallTime: 0, + QueryName: query1.Name, + PackID: pack1.ID, + DiscardData: false, + AutomationsEnabled: false, + LastFetched: nil, + AverageMemory: 8000, + Denylisted: false, + Executions: 164, + Interval: 30, + LastExecuted: time.Unix(1620325191, 0).UTC(), + OutputSize: 1337, + SystemTime: 150, + UserTime: 180, + WallTime: 0, }, } stats2 := []fleet.ScheduledQueryStats{ @@ -636,17 +640,20 @@ func testHostsWithTeamPackStats(t *testing.T, ds *Datastore) { PackName: fmt.Sprintf("team-%d", team.ID), ScheduledQueryName: tpQuery.Name, - QueryName: tpQuery.Name, - PackID: 0, // pack_id will be 0 for stats of queries not in packs. - AverageMemory: 8000, - Denylisted: false, - Executions: 164, - Interval: 30, - LastExecuted: time.Unix(1620325191, 0).UTC(), - OutputSize: 1337, - SystemTime: 150, - UserTime: 180, - WallTime: 0, + QueryName: tpQuery.Name, + PackID: 0, // pack_id will be 0 for stats of queries not in packs. + LastFetched: nil, + DiscardData: tpQuery.DiscardData, + AutomationsEnabled: tpQuery.AutomationsEnabled, + AverageMemory: 8000, + Denylisted: false, + Executions: 164, + Interval: 30, + LastExecuted: time.Unix(1620325191, 0).UTC(), + OutputSize: 1337, + SystemTime: 150, + UserTime: 180, + WallTime: 0, }, } @@ -3785,6 +3792,171 @@ func testHostsExpiration(t *testing.T, ds *Datastore) { require.Len(t, hosts, 5) } +func testHostsIncludesScheduledQueriesInPackStats(t *testing.T, ds *Datastore) { + host, err := ds.NewHost(context.Background(), &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String("1"), + UUID: "1", + Hostname: "foo.local", + PrimaryIP: "192.168.1.1", + PrimaryMac: "30-65-EC-6F-C4-58", + Platform: "darwin", + }) + require.NoError(t, err) + require.NotNil(t, host) + + team, err := ds.NewTeam(context.Background(), &fleet.Team{Name: "team1"}) + require.NoError(t, err) + err = ds.AddHostsToTeam(context.Background(), &team.ID, []uint{host.ID}) + require.NoError(t, err) + + query1 := &fleet.Query{ + Name: "Only Logged in Query Report", + Query: "select * from time", + AuthorID: nil, + Platform: "darwin", + Saved: true, + TeamID: nil, + Interval: 60, + Logging: fleet.LoggingSnapshot, + DiscardData: false, + AutomationsEnabled: false, + } + + _, err = ds.NewQuery(context.Background(), query1) + require.NoError(t, err) + + query2 := &fleet.Query{ + Name: "Logged In Report and Log Destination", + Query: "select * from time", + AuthorID: nil, + Platform: "darwin", + Saved: true, + TeamID: nil, + Interval: 60, + Logging: fleet.LoggingSnapshot, + DiscardData: false, + AutomationsEnabled: true, + } + _, err = ds.NewQuery(context.Background(), query2) + require.NoError(t, err) + + // This query should not be included in the pack stats + query3 := &fleet.Query{ + Name: "Not LoggingSnapshot", + Query: "select * from time", + AuthorID: nil, + Platform: "darwin", + Saved: true, + TeamID: nil, + Interval: 60, + Logging: fleet.LoggingDifferential, + DiscardData: false, + AutomationsEnabled: false, // automations not on + } + _, err = ds.NewQuery(context.Background(), query3) + require.NoError(t, err) + + // This query should not be included in the pack stats + query4 := &fleet.Query{ + Name: "Query Report No Interval", + Query: "select * from time", + AuthorID: nil, + Platform: "darwin", + Saved: true, + TeamID: nil, + Interval: 0, + Logging: fleet.LoggingSnapshot, + DiscardData: false, + AutomationsEnabled: false, + } + _, err = ds.NewQuery(context.Background(), query4) + require.NoError(t, err) + + // this query should not be included in the pack stats + query5 := &fleet.Query{ + Name: "Automations No Interval", + Query: "select * from time", + AuthorID: nil, + Platform: "darwin", + Saved: true, + TeamID: nil, + Interval: 0, + Logging: fleet.LoggingSnapshot, + DiscardData: true, + AutomationsEnabled: true, + } + _, err = ds.NewQuery(context.Background(), query5) + require.NoError(t, err) + + query6 := &fleet.Query{ + Name: "Team Query", + Query: "select * from time", + AuthorID: nil, + Platform: "darwin", + Saved: true, + TeamID: &team.ID, + Interval: 60, + Logging: fleet.LoggingSnapshot, + DiscardData: false, + AutomationsEnabled: true, + } + _, err = ds.NewQuery(context.Background(), query6) + require.NoError(t, err) + + hostResult, err := ds.Host(context.Background(), host.ID) + require.NoError(t, err) + + globalQueryStats := hostResult.PackStats[0].QueryStats + require.NotNil(t, hostResult) + require.Equal(t, 2, len(globalQueryStats)) + require.Equal(t, query1.Name, globalQueryStats[0].ScheduledQueryName) + require.Equal(t, query2.Name, globalQueryStats[1].ScheduledQueryName) + + teamQueryStats := hostResult.PackStats[1].QueryStats + require.Equal(t, query6.Name, teamQueryStats[0].ScheduledQueryName) + + // Queries with Query Results should be included in the pack stats + // regardless of the query interval + queryResultRow := []*fleet.ScheduledQueryResultRow{ + { + QueryID: query4.ID, // no interval + HostID: host.ID, + Data: ptr.RawMessage(json.RawMessage(`{"foo": "bar"}`)), + }, + { + QueryID: query4.ID, // no interval + HostID: host.ID, + Data: ptr.RawMessage(json.RawMessage(`{"foo": "baz"}`)), + }, + } + err = ds.OverwriteQueryResultRows(context.Background(), queryResultRow) + require.NoError(t, err) + + hostResult, err = ds.Host(context.Background(), host.ID) + require.NoError(t, err) + require.NotNil(t, hostResult) + + assertContains := func(stats []fleet.ScheduledQueryStats, name string) { + t.Helper() + for _, stat := range stats { + if stat.ScheduledQueryName == name { + return + } + } + t.Errorf("expected to find %s in stats", name) + } + + globalQueryStats = hostResult.PackStats[0].QueryStats + require.Equal(t, 3, len(globalQueryStats)) + assertContains(globalQueryStats, query1.Name) + assertContains(globalQueryStats, query2.Name) + assertContains(globalQueryStats, query4.Name) // no interval, but has a query result +} + func testHostsAllPackStats(t *testing.T, ds *Datastore) { host, err := ds.NewHost(context.Background(), &fleet.Host{ DetailUpdatedAt: time.Now(), diff --git a/server/fleet/queries.go b/server/fleet/queries.go index 59472e17f9..0e1aa84f80 100644 --- a/server/fleet/queries.go +++ b/server/fleet/queries.go @@ -414,12 +414,15 @@ type QueryStats struct { Denylisted bool `json:"denylisted" db:"denylisted"` Executions uint64 `json:"executions" db:"executions"` // Note schedule_interval is used for DB since "interval" is a reserved word in MySQL - Interval int `json:"interval" db:"schedule_interval"` - LastExecuted time.Time `json:"last_executed" db:"last_executed"` - OutputSize uint64 `json:"output_size" db:"output_size"` - SystemTime uint64 `json:"system_time" db:"system_time"` - UserTime uint64 `json:"user_time" db:"user_time"` - WallTime uint64 `json:"wall_time" db:"wall_time"` + Interval int `json:"interval" db:"schedule_interval"` + DiscardData bool `json:"discard_data" db:"discard_data"` + LastFetched *time.Time `json:"last_fetched" db:"last_fetched"` + AutomationsEnabled bool `json:"automations_enabled" db:"automations_enabled"` + LastExecuted time.Time `json:"last_executed" db:"last_executed"` + OutputSize uint64 `json:"output_size" db:"output_size"` + SystemTime uint64 `json:"system_time" db:"system_time"` + UserTime uint64 `json:"user_time" db:"user_time"` + WallTime uint64 `json:"wall_time" db:"wall_time"` } // MapQueryReportsResultsToRows converts the scheduled query results as stored in Fleet's database diff --git a/server/fleet/scheduled_queries.go b/server/fleet/scheduled_queries.go index 0eaac54cb5..0c3af78c7f 100644 --- a/server/fleet/scheduled_queries.go +++ b/server/fleet/scheduled_queries.go @@ -155,12 +155,15 @@ type ScheduledQueryStats struct { Denylisted bool `json:"denylisted" db:"denylisted"` Executions uint64 `json:"executions" db:"executions"` // Note schedule_interval is used for DB since "interval" is a reserved word in MySQL - Interval int `json:"interval" db:"schedule_interval"` - LastExecuted time.Time `json:"last_executed" db:"last_executed"` - OutputSize uint64 `json:"output_size" db:"output_size"` - SystemTime uint64 `json:"system_time" db:"system_time"` - UserTime uint64 `json:"user_time" db:"user_time"` - WallTime uint64 `json:"wall_time" db:"wall_time"` + Interval int `json:"interval" db:"schedule_interval"` + DiscardData bool `json:"discard_data" db:"discard_data"` + LastFetched *time.Time `json:"last_fetched" db:"last_fetched"` + AutomationsEnabled bool `json:"automations_enabled" db:"automations_enabled"` + LastExecuted time.Time `json:"last_executed" db:"last_executed"` + OutputSize uint64 `json:"output_size" db:"output_size"` + SystemTime uint64 `json:"system_time" db:"system_time"` + UserTime uint64 `json:"user_time" db:"user_time"` + WallTime uint64 `json:"wall_time" db:"wall_time"` } // TeamID returns the team id if the stat is for a team query stat result diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 5d15d97125..9ee2883fb5 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -4356,7 +4356,7 @@ func (s *integrationEnterpriseTestSuite) TestRunHostScript() { // create a valid sync script execution request, fails because the // request will time-out waiting for a result. runSyncResp = runScriptSyncResponse{} - s.DoJSON("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusGatewayTimeout, &runSyncResp) + s.DoJSON("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusRequestTimeout, &runSyncResp) require.Equal(t, host.ID, runSyncResp.HostID) require.NotEmpty(t, runSyncResp.ExecutionID) require.True(t, runSyncResp.HostTimeout) @@ -4574,7 +4574,7 @@ func (s *integrationEnterpriseTestSuite) TestRunHostSavedScript() { // create a valid sync script execution request, fails because the // request will time-out waiting for a result. var runSyncResp runScriptSyncResponse - s.DoJSON("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: &savedNoTmScript.ID}, http.StatusGatewayTimeout, &runSyncResp) + s.DoJSON("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: &savedNoTmScript.ID}, http.StatusRequestTimeout, &runSyncResp) require.Equal(t, host.ID, runSyncResp.HostID) require.NotEmpty(t, runSyncResp.ExecutionID) require.NotNil(t, runSyncResp.ScriptID) From 5bea0c5cb9d7f7282ddcaac1401e276abef02e4b Mon Sep 17 00:00:00 2001 From: Jacob Shandling <61553566+jacobshandling@users.noreply.github.com> Date: Tue, 12 Dec 2023 10:03:34 -0800 Subject: [PATCH 6/9] Call real API (#15589) Co-authored-by: Jacob Shandling --- .../HostDetailsPage/HostDetailsPage.tsx | 97 +----- .../HostQueryReport/HostQueryReport.tsx | 317 ++---------------- .../details/cards/Queries/HostQueries.tsx | 4 +- 3 files changed, 32 insertions(+), 386 deletions(-) diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index c490e02a3a..41338e53f0 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -239,96 +239,6 @@ const HostDetailsPage = ({ mdm?.enrollment_status !== null && refetchMdm(); }; - // TODO - remove dummy schedule - const dummySchedule: IQueryStats[] = [ - { - scheduled_query_name: "cached query 1 - Never reported", - query_name: "query 1", - description: "should render 'Never' ", - discard_data: false, - last_fetched: null, - automations_enabled: false, - interval: 1000, - scheduled_query_id: 1, - - pack_id: 1, - pack_name: "Team: 💻 Workstations", - average_memory: 435814, - denylisted: false, - executions: 5, - last_executed: "2023-11-29T15:20:02Z", - output_size: 1204, - system_time: 9, - user_time: 3, - wall_time: 0, - }, - { - scheduled_query_name: "cached query 2 - stored results", - description: "should render with row clickable to its report", - discard_data: false, - query_name: "query 2", - last_fetched: "2023-11-29T15:20:02Z", - automations_enabled: false, - interval: 1000, - scheduled_query_id: 2, - - pack_id: 1, - pack_name: "Team: 💻 Workstations", - average_memory: 435814, - denylisted: false, - executions: 5, - last_executed: "2023-11-29T15:20:02Z", - output_size: 1204, - system_time: 9, - user_time: 3, - wall_time: 0, - }, - { - scheduled_query_name: - "cached query 3 - sending results to a log destination, not storing in Fleet", - description: "should render '---', not link to report", - interval: 1000, - discard_data: true, - automations_enabled: true, - query_name: "query 3", - last_fetched: null, - scheduled_query_id: 3, - - pack_id: 1, - pack_name: "Team: 💻 Workstations", - average_memory: 435814, - denylisted: false, - executions: 5, - last_executed: "2023-11-29T15:20:02Z", - output_size: 1204, - system_time: 9, - user_time: 3, - wall_time: 0, - }, - { - scheduled_query_name: - "cached query 4 - stored results, but no current interval", - description: "should render with row clickable to its report", - discard_data: false, - query_name: "query 4", - last_fetched: "2023-11-29T15:20:02Z", - automations_enabled: false, - interval: 0, - scheduled_query_id: 4, - - pack_id: 1, - pack_name: "Team: 💻 Workstations", - average_memory: 435814, - denylisted: false, - executions: 5, - last_executed: "2023-11-29T15:20:02Z", - output_size: 1204, - system_time: 9, - user_time: 3, - wall_time: 0, - }, - ]; - const { isLoading: isLoadingHost, data: host, @@ -391,8 +301,7 @@ const HostDetailsPage = ({ } setHostSoftware(returnedHost.software || []); setUsersState(returnedHost.users || []); - // TODO – remove dummy data - setSchedule(dummySchedule); + setSchedule(schedule); if (returnedHost.pack_stats) { const packStatsByType = returnedHost.pack_stats.reduce( ( @@ -411,9 +320,7 @@ const HostDetailsPage = ({ }, { packs: [], schedule: [] } ); - // TODO - restore real data - // setSchedule(packStatsByType.schedule); - setPacksState(packStatsByType.packs); + setSchedule(packStatsByType.schedule); } }, onError: (error) => handlePageError(error), diff --git a/frontend/pages/hosts/details/HostQueryReport/HostQueryReport.tsx b/frontend/pages/hosts/details/HostQueryReport/HostQueryReport.tsx index fe72eb84a3..0ce732685d 100644 --- a/frontend/pages/hosts/details/HostQueryReport/HostQueryReport.tsx +++ b/frontend/pages/hosts/details/HostQueryReport/HostQueryReport.tsx @@ -37,292 +37,34 @@ const HostQueryReport = ({ const [showQuery, setShowQuery] = useState(false); - // TODO - remove dummy data, restore API call - const [[hqrResponse, queryResponse], hqrLoading, hqrError] = [ - // // render report - // [ - // { - // host_name: "Haley's Macbook Air", - // report_clipped: false, - // last_fetched: "2021-01-01T00:00:00.000Z", - // results: [ - // { - // columns: { - // username: "user1", - // email: "e@mail", - // ausername: "user1", - // aemail: "e@mail", - // aausername: "user1", - // aaemail: "e@mail", - // aaausername: "user1", - // aaaemail: "e@mail", - // aaaausername: "user1", - // aaaaemail: "e@mail", - // aaaaausername: "user1", - // aaaaaemail: "e@mail", - // aaaaaausername: "user1", - // aaaaaaemail: "e@mail", - // aaaaaaausername: "user1", - // aaaaaaaemail: "e@mail", - // aaaaaaaausername: "user1", - // aaaaaaaaemail: "e@mail", - // aaaaaaaaausername: "user1", - // aaaaaaaaaemail: "e@mail", - // aaaaaaaaaausername: "user1", - // aaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaausername: "user1", - // aaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaaaaaaaaaemail: "e@mail", - // }, - // }, - // { - // columns: { - // username: "zser1", - // email: "e@mail", - // ausername: "user1", - // aemail: "e@mail", - // aausername: "user1", - // aaemail: "e@mail", - // aaausername: "user1", - // aaaemail: "e@mail", - // aaaausername: "user1", - // aaaaemail: "e@mail", - // aaaaausername: "user1", - // aaaaaemail: "e@mail", - // aaaaaausername: "user1", - // aaaaaaemail: "e@mail", - // aaaaaaausername: "user1", - // aaaaaaaemail: "e@mail", - // aaaaaaaausername: "user1", - // aaaaaaaaemail: "e@mail", - // aaaaaaaaausername: "user1", - // aaaaaaaaaemail: "e@mail", - // aaaaaaaaaausername: "user1", - // aaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaausername: "user1", - // aaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaaaaaaaaaemail: "e@mail", - // }, - // }, - // { - // columns: { - // username: "aser1", - // email: "e@mail", - // ausername: "user1", - // aemail: "e@mail", - // aausername: "user1", - // aaemail: "e@mail", - // aaausername: "user1", - // aaaemail: "e@mail", - // aaaausername: "user1", - // aaaaemail: "e@mail", - // aaaaausername: "user1", - // aaaaaemail: "e@mail", - // aaaaaausername: "user1", - // aaaaaaemail: "e@mail", - // aaaaaaausername: "user1", - // aaaaaaaemail: "e@mail", - // aaaaaaaausername: "user1", - // aaaaaaaaemail: "e@mail", - // aaaaaaaaausername: "user1", - // aaaaaaaaaemail: "e@mail", - // aaaaaaaaaausername: "user1", - // aaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaausername: "user1", - // aaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaaaaaaaaemail: "e@mail", - // aaaaaaaaaaaaaaaaaaaaaaausername: "user1", - // aaaaaaaaaaaaaaaaaaaaaaaemail: "e@mail", - // }, - // }, - // ], - // }, - // { - // name: "Test Query", - // description: "A great query", - // query: "SELECT * FROM users", - // discard_data: false, - // interval: 20, - // }, - // ], + const { + data: hqrResponse, + isLoading: hqrLoading, + error: hqrError, + } = useQuery( + [hostId, queryId], + () => hqrAPI.load(hostId, queryId), + { + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + } + ); - // collecting results (A) - [ - { - host_name: "Haley's Macbook Air", - report_clipped: false, - last_fetched: null, - results: [], - }, - { - name: "Test Query", - description: "a great query", - query: "SELECT * FROM users", - discard_data: false, - inverval: 20, - }, - ], - - // // nothing to report (B) - // [ - // { - // host_name: "Haley's Macbook Air", - // report_clipped: false, - // last_fetched: "2021-01-01T00:00:00.000Z", - // results: [], - // }, - // { - // name: "Test Query", - // description: "a great query", - // query: "SELECT * FROM users", - // discard_data: false, - // inverval: 20, - // }, - // ], - - // // report clipped (C) - // [ - // { - // host_name: "Haley's Macbook Air", - // report_clipped: true, - // last_fetched: "2021-01-01T00:00:00.000Z", - // results: [], - // }, - // { - // name: "Test Query", - // description: "a great query", - // query: "SELECT * FROM users", - // interval: 20, - // discard_data: false, - // }, - // ], - - // // reroute (local setting) - // [ - // { - // host_name: "Haley's Macbook Air", - // report_clipped: false, - // last_fetched: "2021-01-01T00:00:00.000Z", - // results: [ - // { - // columns: { - // username: "user1", - // email: "e@mail", - // }, - // }, - // ], - // }, - // { - // name: 'Test Query', - // description: "a great query", - // query: "SELECT * FROM users", - // discard_data: true, - // inverval: 20, - // }, - // ], - - false, - null, - ]; - - // const { - // data: hqrResponse, - // isLoading: hqrLoading, - // error: hqrError, - // } = useQuery( - // [hostId, queryId], - // () => hqrAPI.load(hostId, queryId), - // { - // refetchOnMount: false, - // refetchOnReconnect: false, - // refetchOnWindowFocus: false, - // } - // ); - - // const { - // isLoading: queryLoading, - // data: queryResponse, - // error: queryError, - // } = useQuery( - // ["query", queryId], - // () => queryAPI.load(queryId), - // { - // enabled: !!queryId, - // refetchOnMount: false, - // refetchOnReconnect: false, - // refetchOnWindowFocus: false, - // } - // ); - - // TODO - remove mock loading state - const queryLoading = false; + const { + isLoading: queryLoading, + data: queryResponse, + error: queryError, + } = useQuery( + ["query", queryId], + () => queryAPI.load(queryId), + { + enabled: !!queryId, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + } + ); const isLoading = queryLoading || hqrLoading; @@ -331,8 +73,7 @@ const HostQueryReport = ({ report_clipped: reportClipped, last_fetched: lastFetched, results, - // TODO - remove below casting, just for testing - } = (hqrResponse || {}) as Partial; + } = hqrResponse || {}; // API response is nested this way to mirror that of the full Query Reports response (IQueryReport) const rows = results?.map((row) => row.columns) ?? []; @@ -342,7 +83,7 @@ const HostQueryReport = ({ description: queryDescription, query: querySQL, discard_data: queryDiscardData, - } = (queryResponse || {}) as Partial; + } = queryResponse || {}; // TODO - finalize local setting reroute conditions // previous reroute can be done before API call, not this one, hence 2 diff --git a/frontend/pages/hosts/details/cards/Queries/HostQueries.tsx b/frontend/pages/hosts/details/cards/Queries/HostQueries.tsx index b5178a2204..d0dfe0a162 100644 --- a/frontend/pages/hosts/details/cards/Queries/HostQueries.tsx +++ b/frontend/pages/hosts/details/cards/Queries/HostQueries.tsx @@ -74,9 +74,7 @@ const HostQueries = ({ if (!hostId || !queryId || !should_link_to_hqr || queryReportsDisabled) { return; } - // TODO - restore real hostId - router.push(`${PATHS.HOST_QUERY_REPORT(82, queryId)}`); - // router.push(`${PATHS.HOST_QUERY_REPORT(hostId, queryId)}`); + router.push(`${PATHS.HOST_QUERY_REPORT(hostId, queryId)}`); }, [hostId, queryReportsDisabled, router] ); From fd1b1c50b5fabb2246b680dc249eba0928083da0 Mon Sep 17 00:00:00 2001 From: Jacob Shandling <61553566+jacobshandling@users.noreply.github.com> Date: Tue, 12 Dec 2023 11:32:16 -0800 Subject: [PATCH 7/9] 14415 - Loose ends 2 (#15595) ## Addresses #15011 - Sort host details > queries list by query name - Update shape of expected response from HQR call to schedulable query API - Shorten the 'clipped' banner's message for Observers and Observers+ - Update tooltips in RevealButton - [x] Manual QA for all new/changed functionality --------- Co-authored-by: Jacob Shandling --- frontend/components/TeamsDropdown/_styles.scss | 4 ---- .../buttons/RevealButton/RevealButton.tests.tsx | 9 ++++----- .../buttons/RevealButton/RevealButton.tsx | 8 ++++---- .../details/HostQueryReport/HostQueryReport.tsx | 9 +++++++-- .../hosts/details/cards/Queries/HostQueries.tsx | 2 +- .../ManagePoliciesPage/ManagePoliciesPage.tsx | 10 +++++++--- .../ManageQueriesPage/ManageQueriesPage.tsx | 8 ++++++-- .../pages/queries/ManageQueriesPage/_styles.scss | 7 ++++++- .../QueryDetailsPage/QueryDetailsPage.tsx | 16 ++++++++++++++-- 9 files changed, 49 insertions(+), 24 deletions(-) diff --git a/frontend/components/TeamsDropdown/_styles.scss b/frontend/components/TeamsDropdown/_styles.scss index 95ae22ac9b..5463d1d480 100644 --- a/frontend/components/TeamsDropdown/_styles.scss +++ b/frontend/components/TeamsDropdown/_styles.scss @@ -73,10 +73,6 @@ .Select-arrow-zone { padding-left: 15px; - svg { - position: relative; - top: 3px; - } } .Select-multi-value-wrapper { diff --git a/frontend/components/buttons/RevealButton/RevealButton.tests.tsx b/frontend/components/buttons/RevealButton/RevealButton.tests.tsx index c18dd5c49a..b99c858aff 100644 --- a/frontend/components/buttons/RevealButton/RevealButton.tests.tsx +++ b/frontend/components/buttons/RevealButton/RevealButton.tests.tsx @@ -1,12 +1,11 @@ import React from "react"; import { render, screen, fireEvent } from "@testing-library/react"; -import { renderWithSetup } from "test/test-utils"; import RevealButton from "./RevealButton"; const SHOW_TEXT = "Show advanced options"; const HIDE_TEXT = "Hide advanced options"; -const TOOLTIP_HTML = "Customize logging type and platforms"; +const TOOLTIP_CONTENT = "Customize logging type and platforms"; describe("Reveal button", () => { it("renders show text", async () => { @@ -75,18 +74,18 @@ describe("Reveal button", () => { }); it("renders tooltip on hover if provided", async () => { - const { user } = renderWithSetup( + render( ); await fireEvent.mouseEnter(screen.getByText(SHOW_TEXT)); - expect(screen.getByText(TOOLTIP_HTML)).toBeInTheDocument(); + expect(screen.getByText(TOOLTIP_CONTENT)).toBeInTheDocument(); }); }); diff --git a/frontend/components/buttons/RevealButton/RevealButton.tsx b/frontend/components/buttons/RevealButton/RevealButton.tsx index 08e29460db..c1db68f9d9 100644 --- a/frontend/components/buttons/RevealButton/RevealButton.tsx +++ b/frontend/components/buttons/RevealButton/RevealButton.tsx @@ -12,7 +12,7 @@ export interface IRevealButtonProps { caretPosition?: "before" | "after"; autofocus?: boolean; disabled?: boolean; - tooltipHtml?: string; + tooltipContent?: React.ReactNode; onClick?: | ((value?: any) => void) | ((evt: React.MouseEvent) => void); @@ -28,7 +28,7 @@ const RevealButton = ({ caretPosition, autofocus, disabled, - tooltipHtml, + tooltipContent, onClick, }: IRevealButtonProps): JSX.Element => { const classNames = classnames(baseClass, className); @@ -36,8 +36,8 @@ const RevealButton = ({ const buttonContent = () => { const text = isShowing ? hideText : showText; - const buttonText = tooltipHtml ? ( - {text} + const buttonText = tooltipContent ? ( + {text} ) : ( text ); diff --git a/frontend/pages/hosts/details/HostQueryReport/HostQueryReport.tsx b/frontend/pages/hosts/details/HostQueryReport/HostQueryReport.tsx index 0ce732685d..25edd76717 100644 --- a/frontend/pages/hosts/details/HostQueryReport/HostQueryReport.tsx +++ b/frontend/pages/hosts/details/HostQueryReport/HostQueryReport.tsx @@ -4,7 +4,10 @@ import MainContent from "components/MainContent"; import ShowQueryModal from "components/modals/ShowQueryModal"; import Spinner from "components/Spinner"; import { AppContext } from "context/app"; -import { ISchedulableQuery } from "interfaces/schedulable_query"; +import { + IGetQueryResponse, + ISchedulableQuery, +} from "interfaces/schedulable_query"; import React, { useCallback, useContext, useState } from "react"; import { useQuery } from "react-query"; import { browserHistory, InjectedRouter, Link } from "react-router"; @@ -55,10 +58,12 @@ const HostQueryReport = ({ isLoading: queryLoading, data: queryResponse, error: queryError, - } = useQuery( + } = useQuery( ["query", queryId], () => queryAPI.load(queryId), + { + select: (data) => data.query, enabled: !!queryId, refetchOnMount: false, refetchOnReconnect: false, diff --git a/frontend/pages/hosts/details/cards/Queries/HostQueries.tsx b/frontend/pages/hosts/details/cards/Queries/HostQueries.tsx index d0dfe0a162..a8b7315a75 100644 --- a/frontend/pages/hosts/details/cards/Queries/HostQueries.tsx +++ b/frontend/pages/hosts/details/cards/Queries/HostQueries.tsx @@ -98,7 +98,7 @@ const HostQueries = ({ data={tableData} onQueryChange={() => null} resultsTitle="queries" - defaultSortHeader="scheduled_query_name" + defaultSortHeader="query_name" defaultSortDirection="asc" showMarkAllPages={false} isAllPagesSelected={false} diff --git a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx index bde8ac5a48..201ea9c22d 100644 --- a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx @@ -767,9 +767,13 @@ const ManagePolicyPage = ({ globalPoliciesCount )} caretPosition={"before"} - tooltipHtml={`"All teams" policies are checked ${( -
- )} for this team's hosts.`} + tooltipContent={ + <> + "All teams" policies are checked +
+ for this team's hosts. + + } onClick={toggleShowInheritedPolicies} /> )} diff --git a/frontend/pages/queries/ManageQueriesPage/ManageQueriesPage.tsx b/frontend/pages/queries/ManageQueriesPage/ManageQueriesPage.tsx index 80f6caabb5..76c8b17303 100644 --- a/frontend/pages/queries/ManageQueriesPage/ManageQueriesPage.tsx +++ b/frontend/pages/queries/ManageQueriesPage/ManageQueriesPage.tsx @@ -319,8 +319,12 @@ const ManageQueriesPage = ({ inheritedQueryCount === 1 ? "y" : "ies" }`} caretPosition={"before"} - tooltipHtml={ - 'Queries from the "All teams"
schedule run on this team’s hosts.' + tooltipContent={ + <> + Queries from the "All teams" +
+ schedule run on this team's hosts. + } onClick={() => { setShowInheritedQueries(!showInheritedQueries); diff --git a/frontend/pages/queries/ManageQueriesPage/_styles.scss b/frontend/pages/queries/ManageQueriesPage/_styles.scss index ce18192409..c6a8a0086a 100644 --- a/frontend/pages/queries/ManageQueriesPage/_styles.scss +++ b/frontend/pages/queries/ManageQueriesPage/_styles.scss @@ -98,7 +98,7 @@ .data-table-block { .data-table { &__wrapper { - overflow-x: scroll; + overflow-x: auto; overflow-y: hidden; } &__table { @@ -192,4 +192,9 @@ } } } + .reveal-button { + .component__tooltip-wrapper__underline { + position: initial; + } + } } diff --git a/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx index bf8a5d6d20..19b5d0ebeb 100644 --- a/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx +++ b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx @@ -18,6 +18,10 @@ import { IQueryReport } from "interfaces/query_report"; import queryAPI from "services/entities/queries"; import queryReportAPI, { ISortOption } from "services/entities/query_report"; import { DOCUMENT_TITLE_SUFFIX } from "utilities/constants"; +import { + isGlobalObserver, + isTeamObserver, +} from "utilities/permissions/permissions"; import Spinner from "components/Spinner/Spinner"; import Button from "components/buttons/Button"; @@ -76,6 +80,7 @@ const QueryDetailsPage = ({ const handlePageError = useErrorHandler(); const { + currentUser, isGlobalAdmin, isGlobalMaintainer, isTeamMaintainerOrTeamAdmin, @@ -301,8 +306,15 @@ const QueryDetailsPage = ({ >
Report clipped. A sample of this query's results is included - below. You can still use query automations to complete this report in - your log destination. + below. + { + // Exclude below message for global and team observers/observer+s + !( + (currentUser && isGlobalObserver(currentUser)) || + isTeamObserver(currentUser, teamId ?? null) + ) && + " You can still use query automations to complete this report in your log destination." + }
); From 1af37c8c85db4a60484652ce470352f29fa67495 Mon Sep 17 00:00:00 2001 From: mostlikelee Date: Tue, 12 Dec 2023 14:22:02 -0700 Subject: [PATCH 8/9] changelog --- changes/14415-host-query-reports | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changes/14415-host-query-reports diff --git a/changes/14415-host-query-reports b/changes/14415-host-query-reports new file mode 100644 index 0000000000..fd80b1b7e5 --- /dev/null +++ b/changes/14415-host-query-reports @@ -0,0 +1,3 @@ +- Implement host query reports: view query results on a per host basis +- Host Detail Query tab now displays all running queries and queries with result data +- Fleet now includes Kung Fu Fighting because it's fast as lightning From 076f286c8644db07ed7657981e56ec9c848d5093 Mon Sep 17 00:00:00 2001 From: Jacob Shandling Date: Tue, 12 Dec 2023 13:27:41 -0800 Subject: [PATCH 9/9] update prop name to sync new code from main --- .../SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx | 2 +- frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx | 2 +- .../SoftwareVersionDetailsPage/SoftwareVersionDetailsPage.tsx | 2 +- .../pages/SoftwarePage/SoftwareVersions/SoftwareVersions.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx index e42d6decfc..5f8933fa8a 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx @@ -53,7 +53,7 @@ const SoftwareTitleDetailsTable = ({