Add count to host upcoming activities API response (#16511)

This commit is contained in:
Sarah Gillespie 2024-02-06 10:02:38 -06:00 committed by GitHub
parent 379ab87805
commit 424ffef185
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 63 additions and 18 deletions

View File

@ -0,0 +1,2 @@
- Updated `GET /api/v1/fleet/hosts/:id/activities/upcoming` response to include the count of all
upcoming activities for the host.

View File

@ -186,6 +186,16 @@ func (ds *Datastore) MarkActivitiesAsStreamed(ctx context.Context, activityIDs [
}
func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error) {
const countStmt = `SELECT COUNT(*) FROM host_script_results WHERE host_id = ? AND exit_code IS NULL`
var count uint
if err := sqlx.GetContext(ctx, ds.reader(ctx), &count, countStmt, hostID); err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "count upcoming activities")
}
if count == 0 {
return []*fleet.Activity{}, &fleet.PaginationMetadata{}, nil
}
// NOTE: Be sure to update both the count and list statements if the list query is modified
const listStmt = `
SELECT
hsr.execution_id as uuid,
@ -229,12 +239,10 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint
}
var metaData *fleet.PaginationMetadata
if opt.IncludeMetadata {
metaData = &fleet.PaginationMetadata{HasPreviousResults: opt.Page > 0}
if len(activities) > int(opt.PerPage) {
metaData.HasNextResults = true
activities = activities[:len(activities)-1]
}
metaData = &fleet.PaginationMetadata{HasPreviousResults: opt.Page > 0, TotalResults: count}
if len(activities) > int(opt.PerPage) {
metaData.HasNextResults = true
activities = activities[:len(activities)-1]
}
return activities, metaData, nil

View File

@ -375,49 +375,49 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) {
opts: fleet.ListOptions{PerPage: 2},
hostID: h1.ID,
wantExecs: []string{h1A, h1B},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 5},
},
{
opts: fleet.ListOptions{Page: 1, PerPage: 2},
hostID: h1.ID,
wantExecs: []string{h1C, h1D},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true, TotalResults: 5},
},
{
opts: fleet.ListOptions{Page: 2, PerPage: 2},
hostID: h1.ID,
wantExecs: []string{h1E},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 5},
},
{
opts: fleet.ListOptions{PerPage: 3},
hostID: h1.ID,
wantExecs: []string{h1A, h1B, h1C},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 5},
},
{
opts: fleet.ListOptions{Page: 1, PerPage: 3},
hostID: h1.ID,
wantExecs: []string{h1D, h1E},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 5},
},
{
opts: fleet.ListOptions{Page: 2, PerPage: 3},
hostID: h1.ID,
wantExecs: []string{},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 5},
},
{
opts: fleet.ListOptions{PerPage: 3},
hostID: h2.ID,
wantExecs: []string{h2A},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false, TotalResults: 1},
},
{
opts: fleet.ListOptions{},
hostID: h3.ID,
wantExecs: []string{},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false, TotalResults: 0},
},
}
for _, c := range cases {

View File

@ -8,4 +8,8 @@ type ObjectMetadata struct {
type PaginationMetadata struct {
HasNextResults bool `json:"has_next_results"`
HasPreviousResults bool `json:"has_previous_results"`
// TotalResults is the total number of results found for the query (as opposed to the number
// of results returned in the current paginated response). This field is not always set so callers
// must take care to confirm whether a non-zero value should be expected in their specific use cases.
TotalResults uint `json:"-"`
}

View File

@ -538,7 +538,8 @@ type Service interface {
// ListHostUpcomingActivities lists the upcoming activities for the specified
// host. Those are activities that are queued or scheduled to run on the host
// but haven't run yet.
// but haven't run yet. It also returns the total (unpaginated) count of upcoming
// activities.
ListHostUpcomingActivities(ctx context.Context, hostID uint, opt ListOptions) ([]*Activity, *PaginationMetadata, error)
// ListHostPastActivities lists the activities that have already happened for the specified host.

View File

@ -56,14 +56,23 @@ type listHostUpcomingActivitiesRequest struct {
ListOptions fleet.ListOptions `url:"list_options"`
}
type listHostUpcomingActivitiesResponse struct {
Meta *fleet.PaginationMetadata `json:"meta"`
Activities []*fleet.Activity `json:"activities"`
Count uint `json:"count"`
Err error `json:"error,omitempty"`
}
func (r listHostUpcomingActivitiesResponse) error() error { return r.Err }
func listHostUpcomingActivitiesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*listHostUpcomingActivitiesRequest)
acts, meta, err := svc.ListHostUpcomingActivities(ctx, req.HostID, req.ListOptions)
if err != nil {
return listActivitiesResponse{Err: err}, nil
return listHostUpcomingActivitiesResponse{Err: err}, nil
}
return listActivitiesResponse{Meta: meta, Activities: acts}, nil
return listHostUpcomingActivitiesResponse{Meta: meta, Activities: acts, Count: meta.TotalResults}, nil
}
// ListHostUpcomingActivities returns a slice of upcoming activities for the

View File

@ -9849,10 +9849,11 @@ func (s *integrationTestSuite) TestListHostUpcomingActivities() {
}
for _, c := range cases {
t.Run(fmt.Sprintf("%#v", c.queries), func(t *testing.T) {
var listResp listActivitiesResponse
var listResp listHostUpcomingActivitiesResponse
queryArgs := c.queries
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host1.ID), nil, http.StatusOK, &listResp, queryArgs...)
require.Equal(t, uint(5), listResp.Count)
require.Equal(t, len(c.wantExecs), len(listResp.Activities))
require.Equal(t, c.wantMeta, listResp.Meta)
@ -9873,4 +9874,24 @@ func (s *integrationTestSuite) TestListHostUpcomingActivities() {
require.Equal(t, c.wantExecs, gotExecs)
})
}
// Test with a host that has no upcoming activities
host2, err := s.ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name() + "2"),
NodeKey: ptr.String(t.Name() + "2"),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo2.local", t.Name()),
Platform: "darwin",
})
require.NoError(t, err)
var listResp listHostUpcomingActivitiesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host2.ID), nil, http.StatusOK, &listResp)
require.Equal(t, uint(0), listResp.Count)
require.Empty(t, listResp.Activities)
require.Equal(t, &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, listResp.Meta)
}