Add Activity Type Filtering to ListActivities API
- Introduced a new `Type` field in the `ActivityQuery` struct to allow filtering activities by type (e.g., execution.started, execution.completed, execution.failed, execution.cancelled). - Updated the `ListActivities` method in `ExecutionStore` to handle the new type filter, mapping it to corresponding execution statuses. - Enhanced unit tests in `execution_test.go` to validate filtering by activity type, including tests for valid and invalid type scenarios. - Modified OpenAPI definitions and related types to support the new type filter in the activities endpoint, improving API usability and flexibility.
This commit is contained in:
parent
2f040196ec
commit
3ef536c1e7
7 changed files with 184 additions and 15 deletions
|
|
@ -16,6 +16,7 @@ type ActivityQuery struct {
|
|||
TeamID string `json:"team_id,omitempty"` // Filter by team ID
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Since *time.Time `json:"since,omitempty"` // Only activities after this time
|
||||
Type string `json:"type,omitempty"` // Filter by activity type: execution.started, execution.completed, execution.failed, execution.cancelled
|
||||
}
|
||||
|
||||
// Activity - activity item for feed
|
||||
|
|
@ -53,6 +54,11 @@ func ListActivities(ctx *types.Context, query *ActivityQuery) (*ActivityListResp
|
|||
opts.TeamID = query.TeamID
|
||||
}
|
||||
|
||||
// Pass type filter if provided
|
||||
if query.Type != "" {
|
||||
opts.Type = store.ActivityType(query.Type)
|
||||
}
|
||||
|
||||
// Query from store
|
||||
storeActivities, err := getExecutionStore().ListActivities(context.Background(), opts)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -897,9 +897,10 @@ type Activity struct {
|
|||
|
||||
// ActivityListOptions - options for listing activities
|
||||
type ActivityListOptions struct {
|
||||
TeamID string `json:"team_id,omitempty"` // Filter by team ID
|
||||
Since *time.Time `json:"since,omitempty"` // Only activities after this time
|
||||
Limit int `json:"limit,omitempty"`
|
||||
TeamID string `json:"team_id,omitempty"` // Filter by team ID
|
||||
Since *time.Time `json:"since,omitempty"` // Only activities after this time
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Type ActivityType `json:"type,omitempty"` // Filter by activity type
|
||||
}
|
||||
|
||||
// ListActivities derives activities from recent execution status changes
|
||||
|
|
@ -912,13 +913,31 @@ func (s *ExecutionStore) ListActivities(ctx context.Context, opts *ActivityListO
|
|||
// Build where conditions
|
||||
var wheres []model.QueryWhere
|
||||
|
||||
// Only completed, failed, or cancelled executions generate activities
|
||||
// For started activities, we'd need running status
|
||||
wheres = append(wheres, model.QueryWhere{
|
||||
Column: "status",
|
||||
OP: "in",
|
||||
Value: []string{"completed", "failed", "cancelled", "running"},
|
||||
})
|
||||
// Filter by activity type if specified
|
||||
// Map activity types to execution statuses
|
||||
if opts != nil && opts.Type != "" {
|
||||
switch opts.Type {
|
||||
case ActivityExecutionStarted:
|
||||
wheres = append(wheres, model.QueryWhere{Column: "status", Value: "running"})
|
||||
case ActivityExecutionCompleted:
|
||||
wheres = append(wheres, model.QueryWhere{Column: "status", Value: "completed"})
|
||||
case ActivityExecutionFailed:
|
||||
wheres = append(wheres, model.QueryWhere{Column: "status", Value: "failed"})
|
||||
case ActivityExecutionCancelled:
|
||||
wheres = append(wheres, model.QueryWhere{Column: "status", Value: "cancelled"})
|
||||
default:
|
||||
// Unknown type, return empty
|
||||
return []*Activity{}, nil
|
||||
}
|
||||
} else {
|
||||
// Only completed, failed, or cancelled executions generate activities
|
||||
// For started activities, we'd need running status
|
||||
wheres = append(wheres, model.QueryWhere{
|
||||
Column: "status",
|
||||
OP: "in",
|
||||
Value: []string{"completed", "failed", "cancelled", "running"},
|
||||
})
|
||||
}
|
||||
|
||||
if opts != nil {
|
||||
if opts.TeamID != "" {
|
||||
|
|
|
|||
|
|
@ -1221,6 +1221,45 @@ func TestExecutionStoreListActivities(t *testing.T) {
|
|||
assert.Greater(t, typeCount[store.ActivityExecutionFailed], 0, "should have failed activities")
|
||||
})
|
||||
|
||||
t.Run("filters_by_type_completed", func(t *testing.T) {
|
||||
activities, err := s.ListActivities(ctx, &store.ActivityListOptions{
|
||||
TeamID: "team_activity_001",
|
||||
Type: store.ActivityExecutionCompleted,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// All returned activities should be of type completed
|
||||
for _, a := range activities {
|
||||
assert.Equal(t, store.ActivityExecutionCompleted, a.Type, "all activities should be completed type")
|
||||
}
|
||||
assert.Greater(t, len(activities), 0, "should have at least one completed activity")
|
||||
})
|
||||
|
||||
t.Run("filters_by_type_failed", func(t *testing.T) {
|
||||
activities, err := s.ListActivities(ctx, &store.ActivityListOptions{
|
||||
TeamID: "team_activity_001",
|
||||
Type: store.ActivityExecutionFailed,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// All returned activities should be of type failed
|
||||
for _, a := range activities {
|
||||
assert.Equal(t, store.ActivityExecutionFailed, a.Type, "all activities should be failed type")
|
||||
}
|
||||
assert.Greater(t, len(activities), 0, "should have at least one failed activity")
|
||||
})
|
||||
|
||||
t.Run("filters_by_type_invalid_returns_empty", func(t *testing.T) {
|
||||
activities, err := s.ListActivities(ctx, &store.ActivityListOptions{
|
||||
TeamID: "team_activity_001",
|
||||
Type: store.ActivityType("invalid.type"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Invalid type should return empty result
|
||||
assert.Equal(t, 0, len(activities), "invalid type should return empty result")
|
||||
})
|
||||
|
||||
t.Run("includes_execution_name_in_message", func(t *testing.T) {
|
||||
activities, err := s.ListActivities(ctx, &store.ActivityListOptions{
|
||||
TeamID: "team_activity_001",
|
||||
|
|
|
|||
|
|
@ -785,9 +785,14 @@ var uiMessages = map[string]map[string]string{
|
|||
**OpenAPI Integration Tests:** `openapi/tests/agent/robot_results_activities_test.go` ✅
|
||||
- [x] `TestListResults` - test with filters, pagination, keyword search
|
||||
- [x] `TestGetResult` - test single result detail
|
||||
- [x] `TestListActivities` - test activity feed with `since` parameter
|
||||
- [x] `TestListActivities` - test activity feed with `since` and `type` parameters
|
||||
- [x] `TestResultsPermissions` - test permission checks
|
||||
|
||||
**Store Layer Unit Tests:** `agent/robot/store/execution_test.go` ✅
|
||||
- [x] `filters_by_type_completed` - test filtering by completed type
|
||||
- [x] `filters_by_type_failed` - test filtering by failed type
|
||||
- [x] `filters_by_type_invalid_returns_empty` - test invalid type returns empty
|
||||
|
||||
**Permissions:** ✅
|
||||
- [x] Added to `yaobots/openapi/scopes/agent/robots.yml`
|
||||
- [x] Added to `yaobots/openapi/scopes/alias.yml`
|
||||
|
|
@ -834,9 +839,17 @@ var uiMessages = map[string]map[string]string{
|
|||
- [x] Updated to use `ResultDetail` type from API
|
||||
- [x] Displays delivery content (summary, body, attachments)
|
||||
|
||||
**Activity Feed:**
|
||||
- [ ] Replace mock data with `listActivities()` API (TODO - not yet integrated in UI)
|
||||
- [ ] Implement auto-refresh (polling or SSE later)
|
||||
**Activity Feed:** ✅
|
||||
- [x] Replace mock data with `listActivities()` API
|
||||
- [x] Added `loadActivities()` function to fetch from API
|
||||
- [x] Periodic refresh (30s polling, same as robots)
|
||||
- [x] Updated Activity Banner to use API data format
|
||||
- [x] Updated Activity Modal to use API data format
|
||||
- [x] Added loading and empty states
|
||||
- [x] Added `type` filter parameter to API (full stack: store → API → OpenAPI → SDK → UI)
|
||||
- [x] Filter to show only `execution.completed` via API `type` param (not client-side)
|
||||
- [x] Reset carousel index on data refresh (show latest activity first)
|
||||
- [x] Click activity item to open result detail modal (overlays activity list)
|
||||
|
||||
**Error Handling UI:** ✅
|
||||
- [x] Error state displays centered in content area (not in toolbar)
|
||||
|
|
@ -848,7 +861,15 @@ var uiMessages = map[string]map[string]string{
|
|||
- [x] Results display correctly with delivery content
|
||||
- [x] Attachments show properly
|
||||
- [x] Error state displays properly with retry option
|
||||
- [ ] Activity feed updates in real-time (pending - Activity UI not yet connected)
|
||||
- [x] Activity feed displays from API (30s polling refresh)
|
||||
- [x] Activity item click opens result detail
|
||||
|
||||
---
|
||||
|
||||
### Future Enhancements (Not in current scope)
|
||||
|
||||
- [ ] Activity feed real-time updates via SSE/WebSocket
|
||||
- [ ] Push notifications for new results
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ func ListActivities(c *gin.Context) {
|
|||
query := &robotapi.ActivityQuery{
|
||||
TeamID: teamID,
|
||||
Limit: filter.Limit,
|
||||
Type: filter.Type, // Pass type filter
|
||||
}
|
||||
|
||||
// Parse 'since' if provided
|
||||
|
|
|
|||
|
|
@ -548,6 +548,7 @@ func NewResultDetailResponse(detail *robotapi.ResultDetail) *ResultDetailRespons
|
|||
type ActivityFilter struct {
|
||||
Limit int `form:"limit"` // max number of activities
|
||||
Since string `form:"since"` // ISO timestamp, only activities after this time
|
||||
Type string `form:"type"` // activity type filter: execution.started, execution.completed, execution.failed, execution.cancelled
|
||||
}
|
||||
|
||||
// ActivityResponse - activity item
|
||||
|
|
|
|||
|
|
@ -301,6 +301,88 @@ func TestListActivities(t *testing.T) {
|
|||
assert.Contains(t, response, "data")
|
||||
})
|
||||
|
||||
t.Run("ListActivitiesWithTypeFilter", func(t *testing.T) {
|
||||
// Test filtering by type: execution.completed
|
||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/activities?type=execution.completed", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, response, "data")
|
||||
|
||||
// Verify all returned activities are of the specified type
|
||||
data, ok := response["data"].([]interface{})
|
||||
if ok && len(data) > 0 {
|
||||
for _, item := range data {
|
||||
activity := item.(map[string]interface{})
|
||||
assert.Equal(t, "execution.completed", activity["type"])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListActivitiesWithTypeStarted", func(t *testing.T) {
|
||||
// Test filtering by type: execution.started
|
||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/activities?type=execution.started", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, response, "data")
|
||||
|
||||
// Verify all returned activities are of the specified type
|
||||
data, ok := response["data"].([]interface{})
|
||||
if ok && len(data) > 0 {
|
||||
for _, item := range data {
|
||||
activity := item.(map[string]interface{})
|
||||
assert.Equal(t, "execution.started", activity["type"])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListActivitiesWithInvalidType", func(t *testing.T) {
|
||||
// Test with an invalid/unknown type - should return empty data (not error)
|
||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/activities?type=invalid.type", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, response, "data")
|
||||
// Invalid type should return empty array
|
||||
data, ok := response["data"].([]interface{})
|
||||
assert.True(t, ok)
|
||||
assert.Empty(t, data)
|
||||
})
|
||||
|
||||
t.Run("ListActivitiesWithSince", func(t *testing.T) {
|
||||
// Use a timestamp in the past - URL encode properly
|
||||
since := time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue