diff --git a/backend/internal/bootstrap/jobs_bootstrap.go b/backend/internal/bootstrap/jobs_bootstrap.go index f5c3533840..c5685f6b99 100644 --- a/backend/internal/bootstrap/jobs_bootstrap.go +++ b/backend/internal/bootstrap/jobs_bootstrap.go @@ -31,7 +31,7 @@ func registerJobs(appCtx context.Context, newScheduler *pkg_scheduler.JobSchedul // Send initial heartbeat on startup without blocking bootstrap. go analyticsJob.Run(appCtx) - eventCleanupJob := pkg_scheduler.NewEventCleanupJob(appServices.Event, appServices.Settings) + eventCleanupJob := pkg_scheduler.NewEventCleanupJob(appServices.Event, appServices.Activity, appServices.Settings) newScheduler.RegisterJob(eventCleanupJob) scheduledPruneJob := pkg_scheduler.NewScheduledPruneJob(appServices.System, appServices.Settings, appServices.Notification) diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index d6d22c55da..65c1e3acae 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -156,6 +156,7 @@ func setupRouter(ctx context.Context, cfg *config.Config, appServices *Services) Font: appServices.Font, Project: appServices.Project, Event: appServices.Event, + Activity: appServices.Activity, Version: appServices.Version, Environment: appServices.Environment, Settings: appServices.Settings, diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index 614a3c4fde..869aa5db83 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -39,6 +39,7 @@ type Services struct { SystemUpgrade *services.SystemUpgradeService Updater *services.UpdaterService Event *services.EventService + Activity *services.ActivityService Version *services.VersionService Notification *services.NotificationService Apprise *services.AppriseService //nolint:staticcheck // Apprise still functional, deprecated in favor of Shoutrrr @@ -55,6 +56,7 @@ func initializeServices(ctx context.Context, db *database.DB, cfg *config.Config svcs = &Services{} svcs.Event = services.NewEventService(db, cfg, httpClient) + svcs.Activity = services.NewActivityService(db) svcs.Settings, err = services.NewSettingsService(ctx, db) if err != nil { return nil, nil, fmt.Errorf("failed to settings service: %w", err) @@ -76,8 +78,8 @@ func initializeServices(ctx context.Context, db *database.DB, cfg *config.Config svcs.Version = services.NewVersionService(httpClient, cfg.UpdateCheckDisabled, config.Version, config.Revision, svcs.ContainerRegistry, svcs.Docker) svcs.Notification = services.NewNotificationService(db, cfg, svcs.Environment) svcs.Apprise = services.NewAppriseService(db, cfg) - svcs.Vulnerability = services.NewVulnerabilityService(db, svcs.Docker, svcs.Event, svcs.Settings, svcs.Notification) - svcs.ImageUpdate = services.NewImageUpdateService(db, svcs.Settings, svcs.ContainerRegistry, svcs.Docker, svcs.Event, svcs.Notification) + svcs.Vulnerability = services.NewVulnerabilityService(db, svcs.Docker, svcs.Event, svcs.Settings, svcs.Notification, svcs.Activity) + svcs.ImageUpdate = services.NewImageUpdateService(db, svcs.Settings, svcs.ContainerRegistry, svcs.Docker, svcs.Event, svcs.Notification, svcs.Activity) svcs.Image = services.NewImageService(db, svcs.Docker, svcs.ContainerRegistry, svcs.ImageUpdate, svcs.Vulnerability, svcs.Event) svcs.GitRepository = services.NewGitRepositoryService(db, cfg.GitWorkDir, svcs.Event, svcs.Settings) svcs.Build = services.NewBuildService(db, svcs.Settings, svcs.Docker, svcs.ContainerRegistry, svcs.GitRepository, svcs.Event) @@ -102,9 +104,9 @@ func initializeServices(ctx context.Context, db *database.DB, cfg *config.Config svcs.Template = services.NewTemplateService(ctx, db, httpClient, svcs.Settings) svcs.Auth = services.NewAuthService(svcs.User, svcs.Settings, svcs.Event, cfg.JWTSecret, cfg) svcs.Oidc = services.NewOidcService(svcs.Auth, cfg, httpClient) - svcs.System = services.NewSystemService(db, svcs.Docker, svcs.Container, svcs.Image, svcs.Volume, svcs.Network, svcs.Settings) + svcs.System = services.NewSystemService(db, svcs.Docker, svcs.Container, svcs.Image, svcs.Volume, svcs.Network, svcs.Settings, svcs.Activity) svcs.SystemUpgrade = services.NewSystemUpgradeService(svcs.Docker, svcs.Version, svcs.Event, svcs.Settings) - svcs.Updater = services.NewUpdaterService(db, svcs.Settings, svcs.Docker, svcs.Project, svcs.ImageUpdate, svcs.ContainerRegistry, svcs.Event, svcs.Image, svcs.Notification, svcs.SystemUpgrade) + svcs.Updater = services.NewUpdaterService(db, svcs.Settings, svcs.Docker, svcs.Project, svcs.ImageUpdate, svcs.ContainerRegistry, svcs.Event, svcs.Image, svcs.Notification, svcs.SystemUpgrade, svcs.Activity) svcs.GitOpsSync = services.NewGitOpsSyncService(db, svcs.GitRepository, svcs.Project, svcs.Swarm, svcs.Event, svcs.Settings) svcs.Webhook = services.NewWebhookService(db, svcs.Container, svcs.Updater, svcs.Project, svcs.GitOpsSync, svcs.Event) diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go index f27ce6f38c..cf2e4d6d0d 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -880,14 +880,6 @@ func (e *DockerInfoError) Error() string { return fmt.Sprintf("Failed to get Docker info: %v", e.Err) } -type SystemPruneError struct { - Err error -} - -func (e *SystemPruneError) Error() string { - return fmt.Sprintf("Failed to prune resources: %v", e.Err) -} - type ContainerStartAllError struct { Err error } diff --git a/backend/internal/configschema/schema_test.go b/backend/internal/configschema/schema_test.go index e4021bf8fb..8708097258 100644 --- a/backend/internal/configschema/schema_test.go +++ b/backend/internal/configschema/schema_test.go @@ -261,6 +261,8 @@ var expectedEnvConfigVars = []string{ var expectedSettingOverrideKeys = []string{ "accentColor", + "activityHistoryMaxEntries", + "activityHistoryRetentionDays", "applicationTheme", "authLocalEnabled", "authOidcConfig", diff --git a/backend/internal/huma/handlers/activities.go b/backend/internal/huma/handlers/activities.go new file mode 100644 index 0000000000..66537bdafa --- /dev/null +++ b/backend/internal/huma/handlers/activities.go @@ -0,0 +1,526 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/pagination" + "github.com/getarcaneapp/arcane/types/activity" + "github.com/getarcaneapp/arcane/types/base" + "gorm.io/gorm" +) + +type ActivityHandler struct { + activityService *services.ActivityService + environmentService *services.EnvironmentService +} + +type ListActivitiesInput struct { + EnvironmentID string `path:"id" doc:"Environment ID"` + Search string `query:"search" doc:"Search query"` + Sort string `query:"sort" doc:"Column to sort by"` + Order string `query:"order" default:"desc" doc:"Sort direction"` + Start int `query:"start" default:"0" doc:"Start index"` + Limit int `query:"limit" default:"50" doc:"Limit"` + Status string `query:"status" doc:"Filter by activity status"` + Type string `query:"type" doc:"Filter by activity type"` + ResourceType string `query:"resourceType" doc:"Filter by resource type"` +} + +type ListActivitiesOutput struct { + Body base.Paginated[activity.Activity] +} + +type GetActivityInput struct { + EnvironmentID string `path:"id" doc:"Environment ID"` + ActivityID string `path:"activityId" doc:"Activity ID"` + Limit int `query:"limit" default:"500" doc:"Maximum messages to return"` +} + +type GetActivityOutput struct { + Body base.ApiResponse[activity.Detail] +} + +type ClearActivityHistoryInput struct { + EnvironmentID string `path:"id" doc:"Environment ID"` +} + +type ClearActivityHistoryOutput struct { + Body base.ApiResponse[activity.ClearHistoryResult] +} + +type StreamActivitiesInput struct { + EnvironmentID string `path:"id" doc:"Environment ID"` + Limit int `query:"limit" default:"50" doc:"Initial snapshot limit"` +} + +func RegisterActivities(api huma.API, activityService *services.ActivityService, environmentService *services.EnvironmentService) { + h := &ActivityHandler{ + activityService: activityService, + environmentService: environmentService, + } + + huma.Register(api, huma.Operation{ + OperationID: "list-activities", + Method: http.MethodGet, + Path: "/environments/{id}/activities", + Summary: "List background activities", + Description: "Get current and recent background activities for an environment", + Tags: []string{"Activities"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + }, h.ListActivities) + + huma.Register(api, huma.Operation{ + OperationID: "get-activity", + Method: http.MethodGet, + Path: "/environments/{id}/activities/{activityId}", + Summary: "Get background activity", + Description: "Get a background activity with its recent output messages", + Tags: []string{"Activities"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + }, h.GetActivity) + + huma.Register(api, huma.Operation{ + OperationID: "stream-activities", + Method: http.MethodGet, + Path: "/environments/{id}/activities/stream", + Summary: "Stream background activities", + Description: "Stream background activity updates as JSON lines", + Tags: []string{"Activities"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + }, h.StreamActivities) + + huma.Register(api, huma.Operation{ + OperationID: "clear-activity-history", + Method: http.MethodDelete, + Path: "/environments/{id}/activities/history", + Summary: "Clear background activity history", + Description: "Delete completed background activity history for an environment", + Tags: []string{"Activities"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + }, h.ClearHistory) +} + +func (h *ActivityHandler) ListActivities(ctx context.Context, input *ListActivitiesInput) (*ListActivitiesOutput, error) { + if input.EnvironmentID != "0" { + return h.proxyListActivitiesInternal(ctx, input) + } + if h.activityService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + + params := buildActivityPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) + if input.Status != "" { + params.Filters["status"] = input.Status + } + if input.Type != "" { + params.Filters["type"] = input.Type + } + if input.ResourceType != "" { + params.Filters["resourceType"] = input.ResourceType + } + + activities, paginationResp, err := h.activityService.ListActivitiesPaginated(ctx, input.EnvironmentID, params) + if err != nil { + return nil, huma.Error500InternalServerError(err.Error()) + } + h.applyActivitySourceLabelsInternal(ctx, input.EnvironmentID, activities) + + return &ListActivitiesOutput{ + Body: base.Paginated[activity.Activity]{ + Success: true, + Data: activities, + Pagination: base.PaginationResponse{ + TotalPages: paginationResp.TotalPages, + TotalItems: paginationResp.TotalItems, + CurrentPage: paginationResp.CurrentPage, + ItemsPerPage: paginationResp.ItemsPerPage, + GrandTotalItems: paginationResp.GrandTotalItems, + }, + }, + }, nil +} + +func (h *ActivityHandler) GetActivity(ctx context.Context, input *GetActivityInput) (*GetActivityOutput, error) { + if input.EnvironmentID != "0" { + return h.proxyGetActivityInternal(ctx, input) + } + if h.activityService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + if input.ActivityID == "" { + return nil, huma.Error400BadRequest("activity id is required") + } + + detail, err := h.activityService.GetActivityDetail(ctx, input.EnvironmentID, input.ActivityID, input.Limit) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, huma.Error404NotFound("activity not found") + } + return nil, huma.Error500InternalServerError(err.Error()) + } + h.applyActivitySourceLabelInternal(ctx, input.EnvironmentID, &detail.Activity) + + return &GetActivityOutput{ + Body: base.ApiResponse[activity.Detail]{ + Success: true, + Data: *detail, + }, + }, nil +} + +func (h *ActivityHandler) StreamActivities(ctx context.Context, input *StreamActivitiesInput) (*huma.StreamResponse, error) { + if h.activityService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + + return &huma.StreamResponse{ + Body: func(humaCtx huma.Context) { + humaCtx.SetHeader("Content-Type", "application/x-json-stream") + humaCtx.SetHeader("Cache-Control", "no-cache") + humaCtx.SetHeader("Connection", "keep-alive") + humaCtx.SetHeader("X-Accel-Buffering", "no") + + writer := humaCtx.BodyWriter() + encoder := json.NewEncoder(writer) + flush := func() { + if f, ok := writer.(http.Flusher); ok { + f.Flush() + } + } + + if input.EnvironmentID != "0" { + h.streamRemoteActivitySnapshotsInternal(ctx, input, encoder, flush) + return + } + + h.streamLocalActivitiesInternal(ctx, input, encoder, flush) + }, + }, nil +} + +func (h *ActivityHandler) streamLocalActivitiesInternal( + ctx context.Context, + input *StreamActivitiesInput, + encoder *json.Encoder, + flush func(), +) { + sendSnapshot := func() bool { + activities, _, err := h.activityService.ListActivitiesPaginated(ctx, input.EnvironmentID, pagination.QueryParams{ + PaginationParams: pagination.PaginationParams{Limit: resolveActivityStreamLimitInternal(input.Limit)}, + }) + if err != nil { + return false + } + h.applyActivitySourceLabelsInternal(ctx, input.EnvironmentID, activities) + if err := encoder.Encode(activity.StreamEvent{ + Type: "snapshot", + Activities: activities, + Timestamp: time.Now(), + }); err != nil { + return false + } + flush() + return true + } + if !sendSnapshot() { + return + } + + events, missedEvents, unsubscribe := h.activityService.Subscribe(input.EnvironmentID) + defer unsubscribe() + + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case event, ok := <-events: + if !ok { + return + } + h.applyActivityStreamEventSourceLabelInternal(ctx, input.EnvironmentID, &event) + if err := encoder.Encode(event); err != nil { + return + } + flush() + case <-ticker.C: + if missedEvents() && !sendSnapshot() { + return + } + if err := encoder.Encode(activity.StreamEvent{ + Type: "heartbeat", + Timestamp: time.Now(), + }); err != nil { + return + } + flush() + } + } +} + +func (h *ActivityHandler) ClearHistory(ctx context.Context, input *ClearActivityHistoryInput) (*ClearActivityHistoryOutput, error) { + if err := checkAdmin(ctx); err != nil { + return nil, err + } + + if input.EnvironmentID != "0" { + return h.proxyClearHistoryInternal(ctx, input) + } + if h.activityService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + + deleted, err := h.activityService.DeleteHistory(ctx, input.EnvironmentID) + if err != nil { + return nil, huma.Error500InternalServerError(err.Error()) + } + + return &ClearActivityHistoryOutput{ + Body: base.ApiResponse[activity.ClearHistoryResult]{ + Success: true, + Data: activity.ClearHistoryResult{Deleted: deleted}, + }, + }, nil +} + +func (h *ActivityHandler) streamRemoteActivitySnapshotsInternal( + ctx context.Context, + input *StreamActivitiesInput, + encoder *json.Encoder, + flush func(), +) { + pollTicker := time.NewTicker(5 * time.Second) + defer pollTicker.Stop() + heartbeatTicker := time.NewTicker(15 * time.Second) + defer heartbeatTicker.Stop() + + sendSnapshot := func(ctx context.Context) bool { + output, err := h.proxyListActivitiesInternal(ctx, &ListActivitiesInput{ + EnvironmentID: input.EnvironmentID, + Start: 0, + Limit: resolveActivityStreamLimitInternal(input.Limit), + Order: "desc", + }) + if err != nil { + return true + } + h.applyActivitySourceLabelsInternal(ctx, input.EnvironmentID, output.Body.Data) + if err := encoder.Encode(activity.StreamEvent{ + Type: "snapshot", + Activities: output.Body.Data, + Timestamp: time.Now(), + }); err != nil { + return false + } + flush() + return true + } + + if !sendSnapshot(ctx) { + return + } + + for { + select { + case <-ctx.Done(): + return + case <-pollTicker.C: + if !sendSnapshot(ctx) { + return + } + case <-heartbeatTicker.C: + if err := encoder.Encode(activity.StreamEvent{ + Type: "heartbeat", + Timestamp: time.Now(), + }); err != nil { + return + } + flush() + } + } +} + +func (h *ActivityHandler) proxyListActivitiesInternal(ctx context.Context, input *ListActivitiesInput) (*ListActivitiesOutput, error) { + if h.environmentService == nil { + return nil, huma.Error500InternalServerError("environment service not available") + } + + path := "/api/environments/0/activities?" + activityListQueryInternal(input).Encode() + respBody, statusCode, err := h.environmentService.ProxyRequest(ctx, input.EnvironmentID, http.MethodGet, path, nil) + if err != nil { + return nil, huma.Error502BadGateway("failed to proxy request to environment: " + err.Error()) + } + if statusCode != http.StatusOK { + return nil, huma.NewError(statusCode, "environment returned error: "+string(respBody), nil) + } + + var out base.Paginated[activity.Activity] + if err := json.Unmarshal(respBody, &out); err != nil { + return nil, huma.Error500InternalServerError("failed to decode environment response: " + err.Error()) + } + h.applyActivitySourceLabelsInternal(ctx, input.EnvironmentID, out.Data) + return &ListActivitiesOutput{Body: out}, nil +} + +func (h *ActivityHandler) proxyGetActivityInternal(ctx context.Context, input *GetActivityInput) (*GetActivityOutput, error) { + if h.environmentService == nil { + return nil, huma.Error500InternalServerError("environment service not available") + } + + path := fmt.Sprintf("/api/environments/0/activities/%s?limit=%d", url.PathEscape(input.ActivityID), input.Limit) + respBody, statusCode, err := h.environmentService.ProxyRequest(ctx, input.EnvironmentID, http.MethodGet, path, nil) + if err != nil { + return nil, huma.Error502BadGateway("failed to proxy request to environment: " + err.Error()) + } + if statusCode != http.StatusOK { + return nil, huma.NewError(statusCode, "environment returned error: "+string(respBody), nil) + } + + var out base.ApiResponse[activity.Detail] + if err := json.Unmarshal(respBody, &out); err != nil { + return nil, huma.Error500InternalServerError("failed to decode environment response: " + err.Error()) + } + h.applyActivitySourceLabelInternal(ctx, input.EnvironmentID, &out.Data.Activity) + return &GetActivityOutput{Body: out}, nil +} + +func (h *ActivityHandler) proxyClearHistoryInternal(ctx context.Context, input *ClearActivityHistoryInput) (*ClearActivityHistoryOutput, error) { + if h.environmentService == nil { + return nil, huma.Error500InternalServerError("environment service not available") + } + + respBody, statusCode, err := h.environmentService.ProxyRequest(ctx, input.EnvironmentID, http.MethodDelete, "/api/environments/0/activities/history", nil) + if err != nil { + return nil, huma.Error502BadGateway("failed to proxy request to environment: " + err.Error()) + } + if statusCode != http.StatusOK { + return nil, huma.NewError(statusCode, "environment returned error: "+string(respBody), nil) + } + + var out base.ApiResponse[activity.ClearHistoryResult] + if err := json.Unmarshal(respBody, &out); err != nil { + return nil, huma.Error500InternalServerError("failed to decode environment response: " + err.Error()) + } + return &ClearActivityHistoryOutput{Body: out}, nil +} + +func (h *ActivityHandler) applyActivitySourceLabelsInternal(ctx context.Context, environmentID string, activities []activity.Activity) { + sourceID, sourceName := h.resolveActivitySourceInternal(ctx, environmentID) + for i := range activities { + applyActivitySourceInternal(&activities[i], sourceID, sourceName) + } +} + +func (h *ActivityHandler) applyActivitySourceLabelInternal(ctx context.Context, environmentID string, item *activity.Activity) { + sourceID, sourceName := h.resolveActivitySourceInternal(ctx, environmentID) + applyActivitySourceInternal(item, sourceID, sourceName) +} + +func (h *ActivityHandler) applyActivityStreamEventSourceLabelInternal(ctx context.Context, environmentID string, event *activity.StreamEvent) { + if event == nil { + return + } + sourceID, sourceName := h.resolveActivitySourceInternal(ctx, environmentID) + if event.Activity != nil { + applyActivitySourceInternal(event.Activity, sourceID, sourceName) + } + for i := range event.Activities { + applyActivitySourceInternal(&event.Activities[i], sourceID, sourceName) + } +} + +func (h *ActivityHandler) resolveActivitySourceInternal(ctx context.Context, environmentID string) (string, string) { + if environmentID == "" { + environmentID = "0" + } + if h.environmentService != nil { + if env, err := h.environmentService.GetEnvironmentByID(ctx, environmentID); err == nil && env != nil { + return env.ID, env.Name + } + } + if environmentID == "0" { + return "0", "Local" + } + return environmentID, environmentID +} + +func applyActivitySourceInternal(item *activity.Activity, sourceID, sourceName string) { + if item == nil { + return + } + item.SourceEnvironmentID = sourceID + item.SourceEnvironmentName = sourceName +} + +func buildActivityPaginationParamsInternal(start, limit int, sort, order, search string) pagination.QueryParams { + return pagination.QueryParams{ + SearchQuery: pagination.SearchQuery{Search: search}, + SortParams: pagination.SortParams{ + Sort: sort, + Order: pagination.SortOrder(order), + }, + PaginationParams: pagination.PaginationParams{ + Start: start, + Limit: limit, + }, + Filters: map[string]string{}, + } +} + +func activityListQueryInternal(input *ListActivitiesInput) url.Values { + values := url.Values{} + values.Set("start", strconv.Itoa(input.Start)) + values.Set("limit", strconv.Itoa(input.Limit)) + if input.Search != "" { + values.Set("search", input.Search) + } + if input.Sort != "" { + values.Set("sort", input.Sort) + } + if input.Order != "" { + values.Set("order", input.Order) + } + if input.Status != "" { + values.Set("status", input.Status) + } + if input.Type != "" { + values.Set("type", input.Type) + } + if input.ResourceType != "" { + values.Set("resourceType", input.ResourceType) + } + return values +} + +func resolveActivityStreamLimitInternal(limit int) int { + if limit <= 0 { + return 50 + } + if limit > 100 { + return 100 + } + return limit +} diff --git a/backend/internal/huma/handlers/activities_test.go b/backend/internal/huma/handlers/activities_test.go new file mode 100644 index 0000000000..92e7d26243 --- /dev/null +++ b/backend/internal/huma/handlers/activities_test.go @@ -0,0 +1,112 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + glsqlite "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "github.com/getarcaneapp/arcane/backend/internal/database" + humamw "github.com/getarcaneapp/arcane/backend/internal/huma/middleware" + "github.com/getarcaneapp/arcane/backend/internal/models" + "github.com/getarcaneapp/arcane/backend/internal/services" +) + +func setupActivityHandlerTestDBInternal(t *testing.T) *database.DB { + t.Helper() + + db, err := gorm.Open(glsqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &models.Activity{}, + &models.ActivityMessage{}, + &models.Environment{}, + &models.SettingVariable{}, + )) + return &database.DB{DB: db} +} + +func activityHandlerAdminContextInternal() context.Context { + return context.WithValue(context.Background(), humamw.ContextKeyUserIsAdmin, true) +} + +func TestActivityHandlerClearHistoryRequiresAdminInternal(t *testing.T) { + handler := &ActivityHandler{} + + _, err := handler.ClearHistory(context.Background(), &ClearActivityHistoryInput{EnvironmentID: "0"}) + require.Error(t, err) + require.Contains(t, err.Error(), "admin access required") +} + +func TestActivityHandlerClearHistoryDeletesSelectedEnvironmentOnlyInternal(t *testing.T) { + ctx := activityHandlerAdminContextInternal() + db := setupActivityHandlerTestDBInternal(t) + activityService := services.NewActivityService(db) + handler := &ActivityHandler{activityService: activityService} + + completed, err := activityService.StartActivity(ctx, services.StartActivityRequest{EnvironmentID: "0", Type: models.ActivityTypeResourceAction}) + require.NoError(t, err) + _, err = activityService.CompleteActivity(ctx, completed.ID, models.ActivityStatusSuccess, "done", nil) + require.NoError(t, err) + + running, err := activityService.StartActivity(ctx, services.StartActivityRequest{EnvironmentID: "0", Type: models.ActivityTypeResourceAction}) + require.NoError(t, err) + remoteCompleted, err := activityService.StartActivity(ctx, services.StartActivityRequest{EnvironmentID: "remote-1", Type: models.ActivityTypeResourceAction}) + require.NoError(t, err) + _, err = activityService.CompleteActivity(ctx, remoteCompleted.ID, models.ActivityStatusSuccess, "done", nil) + require.NoError(t, err) + + out, err := handler.ClearHistory(ctx, &ClearActivityHistoryInput{EnvironmentID: "0"}) + require.NoError(t, err) + require.EqualValues(t, 1, out.Body.Data.Deleted) + + var remaining []models.Activity + require.NoError(t, db.Find(&remaining).Error) + require.Len(t, remaining, 2) + require.ElementsMatch(t, []string{running.ID, remoteCompleted.ID}, []string{remaining[0].ID, remaining[1].ID}) +} + +func TestActivityHandlerClearHistoryProxiesRemoteEnvironmentInternal(t *testing.T) { + ctx := activityHandlerAdminContextInternal() + db := setupActivityHandlerTestDBInternal(t) + settingsService, err := services.NewSettingsService(ctx, db) + require.NoError(t, err) + + token := "remote-token" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodDelete, r.Method) + require.Equal(t, "/api/environments/0/activities/history", r.URL.Path) + require.Equal(t, token, r.Header.Get("X-API-Key")) + require.Equal(t, token, r.Header.Get("X-Arcane-Agent-Token")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"data":{"deleted":7}}`)) + })) + defer server.Close() + + now := time.Now() + require.NoError(t, db.Create(&models.Environment{ + BaseModel: models.BaseModel{ + ID: "remote-1", + CreatedAt: now, + UpdatedAt: &now, + }, + Name: "Remote", + ApiUrl: server.URL, + Status: string(models.EnvironmentStatusOnline), + Enabled: true, + AccessToken: &token, + }).Error) + + handler := &ActivityHandler{ + environmentService: services.NewEnvironmentService(db, server.Client(), nil, nil, settingsService, nil), + } + + out, err := handler.ClearHistory(ctx, &ClearActivityHistoryInput{EnvironmentID: "remote-1"}) + require.NoError(t, err) + require.EqualValues(t, 7, out.Body.Data.Deleted) +} diff --git a/backend/internal/huma/handlers/containers.go b/backend/internal/huma/handlers/containers.go index bf4e789b40..b6a8ee6670 100644 --- a/backend/internal/huma/handlers/containers.go +++ b/backend/internal/huma/handlers/containers.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "io" "maps" "net/http" "net/netip" @@ -10,9 +11,12 @@ import ( "github.com/danielgtaylor/huma/v2" "github.com/getarcaneapp/arcane/backend/internal/common" humamw "github.com/getarcaneapp/arcane/backend/internal/huma/middleware" + "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" "github.com/getarcaneapp/arcane/backend/pkg/libarcane" + activitylib "github.com/getarcaneapp/arcane/backend/pkg/libarcane/activity" "github.com/getarcaneapp/arcane/backend/pkg/pagination" + "github.com/getarcaneapp/arcane/backend/pkg/projects" "github.com/getarcaneapp/arcane/types/base" containertypes "github.com/getarcaneapp/arcane/types/container" dockercontainer "github.com/moby/moby/api/types/container" @@ -23,6 +27,7 @@ type ContainerHandler struct { containerService *services.ContainerService dockerService *services.DockerClientService settingsService *services.SettingsService + activityService *services.ActivityService } // Paginated response @@ -135,11 +140,12 @@ type SetAutoUpdateOutput struct { Body ContainerActionResponse } -func RegisterContainers(api huma.API, containerSvc *services.ContainerService, dockerSvc *services.DockerClientService, settingsSvc *services.SettingsService) { +func RegisterContainers(api huma.API, containerSvc *services.ContainerService, dockerSvc *services.DockerClientService, settingsSvc *services.SettingsService, activitySvc *services.ActivityService) { h := &ContainerHandler{ containerService: containerSvc, dockerService: dockerSvc, settingsService: settingsSvc, + activityService: activitySvc, } huma.Register(api, huma.Operation{ @@ -604,14 +610,17 @@ func (h *ContainerHandler) StartContainer(ctx context.Context, input *ContainerA return nil, huma.Error401Unauthorized("not authenticated") } + activityID := activitylib.StartHandlerActivityForUser(ctx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerStart, "container", input.ContainerID, input.ContainerID, user, "Starting container", "Container start requested", models.JSON{"containerID": input.ContainerID}) if err := h.containerService.StartContainer(ctx, input.ContainerID, *user); err != nil { + activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Container started", err) return nil, huma.Error500InternalServerError((&common.ContainerStartError{Err: err}).Error()) } + activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Container started", nil) return &ContainerActionOutput{ Body: ContainerActionResponse{ Success: true, - Data: base.MessageResponse{Message: "Container started successfully"}, + Data: base.MessageResponse{Message: "Container started successfully", ActivityID: activitylib.StringPtr(activityID)}, }, }, nil } @@ -626,14 +635,17 @@ func (h *ContainerHandler) StopContainer(ctx context.Context, input *ContainerAc return nil, huma.Error401Unauthorized("not authenticated") } + activityID := activitylib.StartHandlerActivityForUser(ctx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerStop, "container", input.ContainerID, input.ContainerID, user, "Stopping container", "Container stop requested", models.JSON{"containerID": input.ContainerID}) if err := h.containerService.StopContainer(ctx, input.ContainerID, *user); err != nil { + activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Container stopped", err) return nil, huma.Error500InternalServerError((&common.ContainerStopError{Err: err}).Error()) } + activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Container stopped", nil) return &ContainerActionOutput{ Body: ContainerActionResponse{ Success: true, - Data: base.MessageResponse{Message: "Container stopped successfully"}, + Data: base.MessageResponse{Message: "Container stopped successfully", ActivityID: activitylib.StringPtr(activityID)}, }, }, nil } @@ -648,14 +660,17 @@ func (h *ContainerHandler) RestartContainer(ctx context.Context, input *Containe return nil, huma.Error401Unauthorized("not authenticated") } + activityID := activitylib.StartHandlerActivityForUser(ctx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerRestart, "container", input.ContainerID, input.ContainerID, user, "Restarting container", "Container restart requested", models.JSON{"containerID": input.ContainerID}) if err := h.containerService.RestartContainer(ctx, input.ContainerID, *user); err != nil { + activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Container restarted", err) return nil, huma.Error500InternalServerError((&common.ContainerRestartError{Err: err}).Error()) } + activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Container restarted", nil) return &ContainerActionOutput{ Body: ContainerActionResponse{ Success: true, - Data: base.MessageResponse{Message: "Container restarted successfully"}, + Data: base.MessageResponse{Message: "Container restarted successfully", ActivityID: activitylib.StringPtr(activityID)}, }, }, nil } @@ -670,14 +685,21 @@ func (h *ContainerHandler) RedeployContainer(ctx context.Context, input *Contain return nil, huma.Error401Unauthorized("not authenticated") } - newContainerID, err := h.containerService.RedeployContainer(ctx, input.ContainerID, *user) + activityID := activitylib.StartHandlerActivityForUser(ctx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerRedeploy, "container", input.ContainerID, input.ContainerID, user, "Starting redeploy", "Container redeploy requested", models.JSON{"containerID": input.ContainerID}) + activityWriter := activitylib.NewWriter(ctx, h.activityService, activityID, io.Discard, "Redeploying container") + redeployCtx := context.WithValue(ctx, projects.ProgressWriterKey{}, activityWriter) + newContainerID, err := h.containerService.RedeployContainer(redeployCtx, input.ContainerID, *user) if err != nil { + activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Container redeploy failed", err) return nil, huma.Error500InternalServerError((&common.ContainerRedeployError{Err: err}).Error()) } + activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Container redeployed", nil) // Fetch full container details to return (consistent with other endpoints) details, inspectErr := h.containerService.GetContainerDetails(ctx, newContainerID) if inspectErr == nil { + details.ActivityID = activitylib.StringPtr(activityID) + return &GetContainerOutput{ Body: ContainerDetailsResponse{ Success: true, @@ -692,7 +714,8 @@ func (h *ContainerHandler) RedeployContainer(ctx context.Context, input *Contain Body: ContainerDetailsResponse{ Success: true, Data: containertypes.Details{ - ID: newContainerID, + ID: newContainerID, + ActivityID: activitylib.StringPtr(activityID), }, }, }, nil @@ -708,14 +731,17 @@ func (h *ContainerHandler) DeleteContainer(ctx context.Context, input *DeleteCon return nil, huma.Error401Unauthorized("not authenticated") } + activityID := activitylib.StartHandlerActivityForUser(ctx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerDelete, "container", input.ContainerID, input.ContainerID, user, "Deleting container", "Container delete requested", models.JSON{"containerID": input.ContainerID, "force": input.Force, "removeVolumes": input.RemoveVolumes}) if err := h.containerService.DeleteContainer(ctx, input.ContainerID, input.Force, input.RemoveVolumes, *user); err != nil { + activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Container deleted", err) return nil, huma.Error500InternalServerError((&common.ContainerDeleteError{Err: err}).Error()) } + activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Container deleted", nil) return &DeleteContainerOutput{ Body: ContainerActionResponse{ Success: true, - Data: base.MessageResponse{Message: "Container deleted successfully"}, + Data: base.MessageResponse{Message: "Container deleted successfully", ActivityID: activitylib.StringPtr(activityID)}, }, }, nil } diff --git a/backend/internal/huma/handlers/images.go b/backend/internal/huma/handlers/images.go index e7b1d094a5..661ae8c5c6 100644 --- a/backend/internal/huma/handlers/images.go +++ b/backend/internal/huma/handlers/images.go @@ -11,7 +11,9 @@ import ( "github.com/danielgtaylor/huma/v2" "github.com/getarcaneapp/arcane/backend/internal/common" humamw "github.com/getarcaneapp/arcane/backend/internal/huma/middleware" + "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + activitylib "github.com/getarcaneapp/arcane/backend/pkg/libarcane/activity" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/image" @@ -26,6 +28,7 @@ type ImageHandler struct { imageUpdateService *services.ImageUpdateService settingsService *services.SettingsService buildService *services.BuildService + activityService *services.ActivityService } // --- Huma Input/Output Wrappers --- @@ -149,13 +152,14 @@ type UploadImageOutput struct { } // RegisterImages registers image management routes using Huma. -func RegisterImages(api huma.API, dockerService *services.DockerClientService, imageService *services.ImageService, imageUpdateService *services.ImageUpdateService, settingsService *services.SettingsService, buildService *services.BuildService) { +func RegisterImages(api huma.API, dockerService *services.DockerClientService, imageService *services.ImageService, imageUpdateService *services.ImageUpdateService, settingsService *services.SettingsService, buildService *services.BuildService, activityService *services.ActivityService) { h := &ImageHandler{ dockerService: dockerService, imageService: imageService, imageUpdateService: imageUpdateService, settingsService: settingsService, buildService: buildService, + activityService: activityService, } huma.Register(api, huma.Operation{ @@ -433,12 +437,32 @@ func (h *ImageHandler) PullImage(ctx context.Context, input *PullImageInput) (*h humaCtx.SetHeader("Connection", "keep-alive") humaCtx.SetHeader("X-Accel-Buffering", "no") - writer := humaCtx.BodyWriter() + rawWriter := humaCtx.BodyWriter() + activityID := activitylib.StartHandlerActivityForUser( + humaCtx.Context(), + h.activityService, + input.EnvironmentID, + models.ActivityTypeImagePull, + "image", + "", + fullImageName, + user, + "Pulling image", + "Image pull started", + models.JSON{"imageName": fullImageName}, + ) + activitylib.WriteStartedLine(rawWriter, activityID) + if f, ok := rawWriter.(http.Flusher); ok { + f.Flush() + } + writer := activitylib.NewWriter(humaCtx.Context(), h.activityService, activityID, rawWriter, "Pulling image") if err := h.imageService.PullImage(humaCtx.Context(), fullImageName, writer, *user, credentials); err != nil { + activitylib.CompleteHandlerActivity(humaCtx.Context(), h.activityService, activityID, "Image pull failed", err) _, _ = fmt.Fprintf(writer, `{"error":%q}`+"\n", err.Error()) return } + activitylib.CompleteHandlerActivity(humaCtx.Context(), h.activityService, activityID, "Image pull completed", nil) }, }, nil } @@ -465,11 +489,36 @@ func (h *ImageHandler) BuildImage(ctx context.Context, input *BuildImageInput) ( humaCtx.SetHeader("Connection", "keep-alive") humaCtx.SetHeader("X-Accel-Buffering", "no") - writer := humaCtx.BodyWriter() + rawWriter := humaCtx.BodyWriter() + resourceName := strings.Join(input.Body.Tags, ", ") + if strings.TrimSpace(resourceName) == "" { + resourceName = input.Body.ContextDir + } + activityID := activitylib.StartHandlerActivityForUser( + humaCtx.Context(), + h.activityService, + input.EnvironmentID, + models.ActivityTypeImageBuild, + "image", + "", + resourceName, + user, + "Building image", + "Image build started", + models.JSON{"contextDir": input.Body.ContextDir, "tags": input.Body.Tags}, + ) + activitylib.WriteStartedLine(rawWriter, activityID) + if f, ok := rawWriter.(http.Flusher); ok { + f.Flush() + } + + writer := activitylib.NewWriter(humaCtx.Context(), h.activityService, activityID, rawWriter, "Building image") if _, err := h.buildService.BuildImage(humaCtx.Context(), input.EnvironmentID, input.Body, writer, "", user); err != nil { + activitylib.CompleteHandlerActivity(humaCtx.Context(), h.activityService, activityID, "Image build failed", err) _, _ = fmt.Fprintf(writer, `{"error":%q}`+"\n", err.Error()) return } + activitylib.CompleteHandlerActivity(humaCtx.Context(), h.activityService, activityID, "Image build completed", nil) }, }, nil } diff --git a/backend/internal/huma/handlers/networks.go b/backend/internal/huma/handlers/networks.go index 609b279fd6..d5c99df685 100644 --- a/backend/internal/huma/handlers/networks.go +++ b/backend/internal/huma/handlers/networks.go @@ -11,7 +11,9 @@ import ( "github.com/danielgtaylor/huma/v2" "github.com/getarcaneapp/arcane/backend/internal/common" humamw "github.com/getarcaneapp/arcane/backend/internal/huma/middleware" + "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + activitylib "github.com/getarcaneapp/arcane/backend/pkg/libarcane/activity" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" "github.com/getarcaneapp/arcane/types/base" @@ -20,8 +22,9 @@ import ( ) type NetworkHandler struct { - networkService *services.NetworkService - dockerService *services.DockerClientService + networkService *services.NetworkService + dockerService *services.DockerClientService + activityService *services.ActivityService } type NetworkPaginatedResponse struct { @@ -132,10 +135,11 @@ type PruneNetworksOutput struct { } // RegisterNetworks registers network endpoints. -func RegisterNetworks(api huma.API, networkSvc *services.NetworkService, dockerSvc *services.DockerClientService) { +func RegisterNetworks(api huma.API, networkSvc *services.NetworkService, dockerSvc *services.DockerClientService, activitySvc *services.ActivityService) { h := &NetworkHandler{ - networkService: networkSvc, - dockerService: dockerSvc, + networkService: networkSvc, + dockerService: dockerSvc, + activityService: activitySvc, } huma.Register(api, huma.Operation{ @@ -271,7 +275,26 @@ func (h *NetworkHandler) CreateNetwork(ctx context.Context, input *CreateNetwork // Convert to Docker SDK options dockerOptions := input.Body.Options.ToDockerCreateOptions() - response, err := h.networkService.CreateNetwork(ctx, input.Body.Name, dockerOptions, *user) + var response *dockernetwork.CreateResponse + activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "network", + ResourceID: input.Body.Name, + ResourceName: input.Body.Name, + User: user, + Step: "Creating network", + Message: "Creating network", + SuccessMessage: "Network created successfully", + Metadata: models.JSON{ + "action": "create_network", + "driver": input.Body.Options.Driver, + }, + }, func() error { + var createErr error + response, createErr = h.networkService.CreateNetwork(ctx, input.Body.Name, dockerOptions, *user) + return createErr + }) if err != nil { return nil, huma.Error500InternalServerError((&common.NetworkCreationError{Err: err}).Error()) } @@ -280,6 +303,7 @@ func (h *NetworkHandler) CreateNetwork(ctx context.Context, input *CreateNetwork if err != nil { return nil, huma.Error500InternalServerError((&common.NetworkMappingError{Err: err}).Error()) } + out.ActivityID = activitylib.StringPtr(activityID) return &CreateNetworkOutput{ Body: NetworkCreatedApiResponse{ @@ -397,20 +421,49 @@ func (h *NetworkHandler) DeleteNetwork(ctx context.Context, input *DeleteNetwork return nil, huma.Error401Unauthorized("not authenticated") } - if err := h.networkService.RemoveNetwork(ctx, input.NetworkID, *user); err != nil { + activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "network", + ResourceID: input.NetworkID, + ResourceName: input.NetworkID, + User: user, + Step: "Removing network", + Message: "Removing network", + SuccessMessage: "Network removed successfully", + Metadata: models.JSON{ + "action": "remove_network", + }, + }, func() error { + return h.networkService.RemoveNetwork(ctx, input.NetworkID, *user) + }) + if err != nil { return nil, huma.Error500InternalServerError((&common.NetworkRemovalError{Err: err}).Error()) } return &DeleteNetworkOutput{ Body: NetworkMessageApiResponse{ Success: true, - Data: base.MessageResponse{Message: "Network removed successfully"}, + Data: base.MessageResponse{Message: "Network removed successfully", ActivityID: activitylib.StringPtr(activityID)}, }, }, nil } func (h *NetworkHandler) PruneNetworks(ctx context.Context, input *PruneNetworksInput) (*PruneNetworksOutput, error) { - report, err := h.networkService.PruneNetworks(ctx) + var report *dockernetwork.PruneReport + activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "network", + Step: "Pruning unused networks", + Message: "Pruning unused networks", + SuccessMessage: "Networks pruned successfully", + Metadata: models.JSON{"action": "prune_networks"}, + }, func() error { + var pruneErr error + report, pruneErr = h.networkService.PruneNetworks(ctx) + return pruneErr + }) if err != nil { return nil, huma.Error500InternalServerError((&common.NetworkPruneError{Err: err}).Error()) } @@ -419,6 +472,7 @@ func (h *NetworkHandler) PruneNetworks(ctx context.Context, input *PruneNetworks if err != nil { return nil, huma.Error500InternalServerError((&common.NetworkMappingError{Err: err}).Error()) } + out.ActivityID = activitylib.StringPtr(activityID) return &PruneNetworksOutput{ Body: NetworkPruneResponse{ diff --git a/backend/internal/huma/handlers/projects.go b/backend/internal/huma/handlers/projects.go index 610344a2dd..61a4f50341 100644 --- a/backend/internal/huma/handlers/projects.go +++ b/backend/internal/huma/handlers/projects.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "log/slog" "net/http" "time" @@ -11,7 +12,9 @@ import ( "github.com/danielgtaylor/huma/v2" "github.com/getarcaneapp/arcane/backend/internal/common" humamw "github.com/getarcaneapp/arcane/backend/internal/huma/middleware" + "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + activitylib "github.com/getarcaneapp/arcane/backend/pkg/libarcane/activity" "github.com/getarcaneapp/arcane/backend/pkg/pagination" projects "github.com/getarcaneapp/arcane/backend/pkg/projects" "github.com/getarcaneapp/arcane/backend/pkg/utils" @@ -22,7 +25,8 @@ import ( // ProjectHandler provides Huma-based project management endpoints. type ProjectHandler struct { - projectService *services.ProjectService + projectService *services.ProjectService + activityService *services.ActivityService } // --- Huma Input/Output Wrappers --- @@ -201,9 +205,10 @@ type PullProgressEvent struct { // RegisterProjects registers project management routes using Huma. // Note: WebSocket and streaming endpoints remain as Gin handlers. -func RegisterProjects(api huma.API, projectService *services.ProjectService) { +func RegisterProjects(api huma.API, projectService *services.ProjectService, activityService *services.ActivityService) { h := &ProjectHandler{ - projectService: projectService, + projectService: projectService, + activityService: activityService, } huma.Register(api, huma.Operation{ @@ -520,8 +525,23 @@ func (h *ProjectHandler) DeployProject(ctx context.Context, input *DeployProject humaCtx.SetHeader("Connection", "keep-alive") humaCtx.SetHeader("X-Accel-Buffering", "no") - writer := humaCtx.BodyWriter() - + rawWriter := humaCtx.BodyWriter() + activityID := activitylib.StartHandlerActivityForUser( + humaCtx.Context(), + h.activityService, + input.EnvironmentID, + models.ActivityTypeProjectDeploy, + "project", + input.ProjectID, + input.ProjectID, + user, + "Starting deployment", + "Project deployment started", + models.JSON{"projectID": input.ProjectID}, + ) + activitylib.WriteStartedLine(rawWriter, activityID) + + writer := activitylib.NewWriter(humaCtx.Context(), h.activityService, activityID, rawWriter, "Deploying project") _, _ = writer.Write([]byte(`{"type":"deploy","phase":"begin"}` + "\n")) if f, ok := writer.(http.Flusher); ok { f.Flush() @@ -529,6 +549,7 @@ func (h *ProjectHandler) DeployProject(ctx context.Context, input *DeployProject deployCtx := context.WithValue(humaCtx.Context(), projects.ProgressWriterKey{}, writer) if err := h.projectService.DeployProject(deployCtx, input.ProjectID, *user, input.Body); err != nil { + activitylib.CompleteHandlerActivity(humaCtx.Context(), h.activityService, activityID, "Project deployment failed", err) _, _ = fmt.Fprintf(writer, `{"error":%q}`+"\n", err.Error()) if f, ok := writer.(http.Flusher); ok { f.Flush() @@ -540,6 +561,7 @@ func (h *ProjectHandler) DeployProject(ctx context.Context, input *DeployProject if f, ok := writer.(http.Flusher); ok { f.Flush() } + activitylib.CompleteHandlerActivity(humaCtx.Context(), h.activityService, activityID, "Project deployment completed", nil) }, }, nil } @@ -555,19 +577,25 @@ func (h *ProjectHandler) DownProject(ctx context.Context, input *DownProjectInpu return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - if err := h.projectService.DownProject(ctx, input.ProjectID, *user); err != nil { + activityID := activitylib.StartHandlerActivityForUser(ctx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectDown, "project", input.ProjectID, input.ProjectID, user, "Stopping project", "Project stop requested", models.JSON{"projectID": input.ProjectID}) + activityWriter := activitylib.NewWriter(ctx, h.activityService, activityID, io.Discard, "Stopping project") + downCtx := context.WithValue(ctx, projects.ProgressWriterKey{}, activityWriter) + if err := h.projectService.DownProject(downCtx, input.ProjectID, *user); err != nil { + activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Project stopped", err) var archivedErr *common.ProjectArchivedError if errors.As(err, &archivedErr) { return nil, huma.Error400BadRequest((&common.ProjectDownError{Err: err}).Error()) } return nil, huma.Error500InternalServerError((&common.ProjectDownError{Err: err}).Error()) } + activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Project stopped", nil) return &DownProjectOutput{ Body: base.ApiResponse[base.MessageResponse]{ Success: true, Data: base.MessageResponse{ - Message: "Project brought down successfully", + Message: "Project brought down successfully", + ActivityID: activitylib.StringPtr(activityID), }, }, }, nil @@ -584,7 +612,23 @@ func (h *ProjectHandler) CreateProject(ctx context.Context, input *CreateProject return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - proj, err := h.projectService.CreateProject(ctx, input.Body.Name, input.Body.ComposeContent, input.Body.EnvContent, *user) + var proj *models.Project + activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "project", + ResourceID: input.Body.Name, + ResourceName: input.Body.Name, + User: user, + Step: "Creating project", + Message: "Creating project", + SuccessMessage: "Project created successfully", + Metadata: models.JSON{"action": "create_project"}, + }, func() error { + var createErr error + proj, createErr = h.projectService.CreateProject(ctx, input.Body.Name, input.Body.ComposeContent, input.Body.EnvContent, *user) + return createErr + }) if err != nil { return nil, huma.Error500InternalServerError((&common.ProjectCreationError{Err: err}).Error()) } @@ -602,6 +646,7 @@ func (h *ProjectHandler) CreateProject(ctx context.Context, input *CreateProject response.GitOpsManagedBy = proj.GitOpsManagedBy response.IsArchived = proj.IsArchived response.ArchivedAt = proj.ArchivedAt + response.ActivityID = activitylib.StringPtr(activityID) return &CreateProjectOutput{ Body: base.ApiResponse[project.CreateReponse]{ @@ -686,19 +731,37 @@ func (h *ProjectHandler) RedeployProject(ctx context.Context, input *RedeployPro return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - if err := h.projectService.RedeployProject(ctx, input.ProjectID, *user); err != nil { + activityID := activitylib.StartHandlerActivityForUser( + ctx, + h.activityService, + input.EnvironmentID, + models.ActivityTypeProjectRedeploy, + "project", + input.ProjectID, + input.ProjectID, + user, + "Starting redeploy", + "Project redeploy started", + models.JSON{"projectID": input.ProjectID}, + ) + activityWriter := activitylib.NewWriter(ctx, h.activityService, activityID, io.Discard, "Redeploying project") + redeployCtx := context.WithValue(ctx, projects.ProgressWriterKey{}, activityWriter) + if err := h.projectService.RedeployProject(redeployCtx, input.ProjectID, *user); err != nil { + activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Project redeploy failed", err) var archivedErr *common.ProjectArchivedError if errors.As(err, &archivedErr) { return nil, huma.Error400BadRequest(err.Error()) } return nil, huma.Error400BadRequest((&common.ProjectRedeploymentError{Err: err}).Error()) } + activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Project redeploy completed", nil) return &RedeployProjectOutput{ Body: base.ApiResponse[base.MessageResponse]{ Success: true, Data: base.MessageResponse{ - Message: "Project redeployed successfully", + Message: "Project redeployed successfully", + ActivityID: activitylib.StringPtr(activityID), }, }, }, nil @@ -729,15 +792,21 @@ func (h *ProjectHandler) DestroyProject(ctx context.Context, input *DestroyProje "projectID", input.ProjectID) } - if err := h.projectService.DestroyProject(ctx, input.ProjectID, removeFiles, removeVolumes, *user); err != nil { + activityID := activitylib.StartHandlerActivityForUser(ctx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectDestroy, "project", input.ProjectID, input.ProjectID, user, "Destroying project", "Project destroy requested", models.JSON{"projectID": input.ProjectID, "removeFiles": removeFiles, "removeVolumes": removeVolumes}) + activityWriter := activitylib.NewWriter(ctx, h.activityService, activityID, io.Discard, "Destroying project") + destroyCtx := context.WithValue(ctx, projects.ProgressWriterKey{}, activityWriter) + if err := h.projectService.DestroyProject(destroyCtx, input.ProjectID, removeFiles, removeVolumes, *user); err != nil { + activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Project destroyed", err) return nil, huma.Error500InternalServerError((&common.ProjectDestroyError{Err: err}).Error()) } + activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Project destroyed", nil) return &DestroyProjectOutput{ Body: base.ApiResponse[base.MessageResponse]{ Success: true, Data: base.MessageResponse{ - Message: "Project destroyed successfully", + Message: "Project destroyed successfully", + ActivityID: activitylib.StringPtr(activityID), }, }, }, nil @@ -758,7 +827,22 @@ func (h *ProjectHandler) UpdateProject(ctx context.Context, input *UpdateProject return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - if _, err := h.projectService.UpdateProject(ctx, input.ProjectID, input.Body.Name, input.Body.ComposeContent, input.Body.EnvContent, *user); err != nil { + activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "project", + ResourceID: input.ProjectID, + ResourceName: utils.DerefString(input.Body.Name), + User: user, + Step: "Updating project", + Message: "Updating project", + SuccessMessage: "Project updated successfully", + Metadata: models.JSON{"action": "update_project", "projectID": input.ProjectID}, + }, func() error { + _, updateErr := h.projectService.UpdateProject(ctx, input.ProjectID, input.Body.Name, input.Body.ComposeContent, input.Body.EnvContent, *user) + return updateErr + }) + if err != nil { return nil, huma.Error400BadRequest((&common.ProjectUpdateError{Err: err}).Error()) } @@ -766,6 +850,7 @@ func (h *ProjectHandler) UpdateProject(ctx context.Context, input *UpdateProject if err != nil { return nil, huma.Error500InternalServerError((&common.ProjectDetailsError{Err: err}).Error()) } + details.ActivityID = activitylib.StringPtr(activityID) return &UpdateProjectOutput{ Body: base.ApiResponse[project.Details]{ @@ -790,7 +875,25 @@ func (h *ProjectHandler) UpdateProjectInclude(ctx context.Context, input *Update return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - if err := h.projectService.UpdateProjectIncludeFile(ctx, input.ProjectID, input.Body.RelativePath, input.Body.Content, *user); err != nil { + activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "project", + ResourceID: input.ProjectID, + ResourceName: input.ProjectID, + User: user, + Step: "Updating project file", + Message: "Updating project include file", + SuccessMessage: "Project file updated successfully", + Metadata: models.JSON{ + "action": "update_project_include", + "projectID": input.ProjectID, + "relativePath": input.Body.RelativePath, + }, + }, func() error { + return h.projectService.UpdateProjectIncludeFile(ctx, input.ProjectID, input.Body.RelativePath, input.Body.Content, *user) + }) + if err != nil { return nil, huma.Error400BadRequest((&common.ProjectUpdateError{Err: err}).Error()) } @@ -798,6 +901,7 @@ func (h *ProjectHandler) UpdateProjectInclude(ctx context.Context, input *Update if err != nil { return nil, huma.Error500InternalServerError((&common.ProjectDetailsError{Err: err}).Error()) } + details.ActivityID = activitylib.StringPtr(activityID) return &UpdateProjectIncludeOutput{ Body: base.ApiResponse[project.Details]{ @@ -822,19 +926,25 @@ func (h *ProjectHandler) RestartProject(ctx context.Context, input *RestartProje return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - if err := h.projectService.RestartProject(ctx, input.ProjectID, *user); err != nil { + activityID := activitylib.StartHandlerActivityForUser(ctx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectRestart, "project", input.ProjectID, input.ProjectID, user, "Restarting project", "Project restart requested", models.JSON{"projectID": input.ProjectID}) + activityWriter := activitylib.NewWriter(ctx, h.activityService, activityID, io.Discard, "Restarting project") + restartCtx := context.WithValue(ctx, projects.ProgressWriterKey{}, activityWriter) + if err := h.projectService.RestartProject(restartCtx, input.ProjectID, *user); err != nil { + activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Project restarted", err) var archivedErr *common.ProjectArchivedError if errors.As(err, &archivedErr) { return nil, huma.Error400BadRequest(err.Error()) } return nil, huma.Error400BadRequest((&common.ProjectRestartError{Err: err}).Error()) } + activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Project restarted", nil) return &RestartProjectOutput{ Body: base.ApiResponse[base.MessageResponse]{ Success: true, Data: base.MessageResponse{ - Message: "Project restarted successfully", + Message: "Project restarted successfully", + ActivityID: activitylib.StringPtr(activityID), }, }, }, nil @@ -918,14 +1028,30 @@ func (h *ProjectHandler) PullProjectImages(ctx context.Context, input *PullProje humaCtx.SetHeader("Connection", "keep-alive") humaCtx.SetHeader("X-Accel-Buffering", "no") - writer := humaCtx.BodyWriter() - + rawWriter := humaCtx.BodyWriter() + activityID := activitylib.StartHandlerActivityForUser( + humaCtx.Context(), + h.activityService, + input.EnvironmentID, + models.ActivityTypeProjectPull, + "project", + input.ProjectID, + input.ProjectID, + user, + "Pulling project images", + "Project image pull started", + models.JSON{"projectID": input.ProjectID}, + ) + activitylib.WriteStartedLine(rawWriter, activityID) + + writer := activitylib.NewWriter(humaCtx.Context(), h.activityService, activityID, rawWriter, "Pulling project images") _, _ = writer.Write([]byte(`{"status":"starting project image pull"}` + "\n")) if f, ok := writer.(http.Flusher); ok { f.Flush() } if err := h.projectService.PullProjectImages(humaCtx.Context(), input.ProjectID, writer, *user, nil); err != nil { + activitylib.CompleteHandlerActivity(humaCtx.Context(), h.activityService, activityID, "Project image pull failed", err) _, _ = fmt.Fprintf(writer, `{"error":%q}`+"\n", err.Error()) if f, ok := writer.(http.Flusher); ok { f.Flush() @@ -937,6 +1063,7 @@ func (h *ProjectHandler) PullProjectImages(ctx context.Context, input *PullProje if f, ok := writer.(http.Flusher); ok { f.Flush() } + activitylib.CompleteHandlerActivity(humaCtx.Context(), h.activityService, activityID, "Project image pull completed", nil) }, }, nil } @@ -971,13 +1098,30 @@ func (h *ProjectHandler) BuildProjectImages(ctx context.Context, input *BuildPro humaCtx.SetHeader("Connection", "keep-alive") humaCtx.SetHeader("X-Accel-Buffering", "no") - writer := humaCtx.BodyWriter() + rawWriter := humaCtx.BodyWriter() + activityID := activitylib.StartHandlerActivityForUser( + humaCtx.Context(), + h.activityService, + input.EnvironmentID, + models.ActivityTypeProjectBuild, + "project", + input.ProjectID, + input.ProjectID, + user, + "Building project images", + "Project image build started", + models.JSON{"projectID": input.ProjectID, "services": options.Services}, + ) + activitylib.WriteStartedLine(rawWriter, activityID) + + writer := activitylib.NewWriter(humaCtx.Context(), h.activityService, activityID, rawWriter, "Building project images") _, _ = writer.Write([]byte(`{"type":"build","phase":"begin"}` + "\n")) if f, ok := writer.(http.Flusher); ok { f.Flush() } if err := h.projectService.BuildProjectServices(humaCtx.Context(), input.ProjectID, options, writer, user); err != nil { + activitylib.CompleteHandlerActivity(humaCtx.Context(), h.activityService, activityID, "Project image build failed", err) _, _ = fmt.Fprintf(writer, `{"error":%q}`+"\n", err.Error()) if f, ok := writer.(http.Flusher); ok { f.Flush() @@ -989,6 +1133,7 @@ func (h *ProjectHandler) BuildProjectImages(ctx context.Context, input *BuildPro if f, ok := writer.(http.Flusher); ok { f.Flush() } + activitylib.CompleteHandlerActivity(humaCtx.Context(), h.activityService, activityID, "Project image build completed", nil) }, }, nil } diff --git a/backend/internal/huma/handlers/system.go b/backend/internal/huma/handlers/system.go index 90a288f326..4db88cf235 100644 --- a/backend/internal/huma/handlers/system.go +++ b/backend/internal/huma/handlers/system.go @@ -23,10 +23,11 @@ import ( // SystemHandler handles system management endpoints. type SystemHandler struct { - dockerService *services.DockerClientService - systemService *services.SystemService - upgradeService *services.SystemUpgradeService - cfg *config.Config + dockerService *services.DockerClientService + systemService *services.SystemService + upgradeService *services.SystemUpgradeService + activityService *services.ActivityService + cfg *config.Config } // --- Input/Output Types --- @@ -114,12 +115,13 @@ type TriggerUpgradeOutput struct { // RegisterSystem registers system management endpoints using Huma. // Note: WebSocket endpoints (stats) remain in the Gin handler. -func RegisterSystem(api huma.API, dockerService *services.DockerClientService, systemService *services.SystemService, upgradeService *services.SystemUpgradeService, cfg *config.Config) { +func RegisterSystem(api huma.API, dockerService *services.DockerClientService, systemService *services.SystemService, upgradeService *services.SystemUpgradeService, cfg *config.Config, activityService *services.ActivityService) { h := &SystemHandler{ - dockerService: dockerService, - systemService: systemService, - upgradeService: upgradeService, - cfg: cfg, + dockerService: dockerService, + systemService: systemService, + upgradeService: upgradeService, + activityService: activityService, + cfg: cfg, } huma.Register(api, huma.Operation{ @@ -370,18 +372,9 @@ func (h *SystemHandler) PruneAll(ctx context.Context, input *PruneAllInput) (*Pr "networks", input.Body.Networks, "build_cache", input.Body.BuildCache) - result, err := h.systemService.PruneAll(ctx, input.Body) - if err != nil { - slog.ErrorContext(ctx, "System prune operation failed", "error", err) - return nil, huma.Error500InternalServerError((&common.SystemPruneError{Err: err}).Error()) - } + result := h.systemService.StartPruneAll(ctx, input.EnvironmentID, input.Body) - slog.InfoContext(ctx, "System prune operation completed successfully", - "containers_pruned", len(result.ContainersPruned), - "images_deleted", len(result.ImagesDeleted), - "volumes_deleted", len(result.VolumesDeleted), - "networks_deleted", len(result.NetworksDeleted), - "space_reclaimed", result.SpaceReclaimed) + slog.InfoContext(ctx, "System prune background activity started", "activityId", result.ActivityID) return &PruneAllOutput{ Body: base.ApiResponse[system.PruneAllResult]{ @@ -401,7 +394,7 @@ func (h *SystemHandler) StartAllContainers(ctx context.Context, input *StartAllC return nil, err } - result, err := h.systemService.StartAllContainers(ctx) + result, err := h.systemService.StartAllContainers(ctx, input.EnvironmentID) if err != nil { return nil, huma.Error500InternalServerError((&common.ContainerStartAllError{Err: err}).Error()) } @@ -424,7 +417,7 @@ func (h *SystemHandler) StartAllStoppedContainers(ctx context.Context, input *St return nil, err } - result, err := h.systemService.StartAllStoppedContainers(ctx) + result, err := h.systemService.StartAllStoppedContainers(ctx, input.EnvironmentID) if err != nil { return nil, huma.Error500InternalServerError((&common.ContainerStartStoppedError{Err: err}).Error()) } @@ -447,7 +440,7 @@ func (h *SystemHandler) StopAllContainers(ctx context.Context, input *StopAllCon return nil, err } - result, err := h.systemService.StopAllContainers(ctx) + result, err := h.systemService.StopAllContainers(ctx, input.EnvironmentID) if err != nil { return nil, huma.Error500InternalServerError((&common.ContainerStopAllError{Err: err}).Error()) } diff --git a/backend/internal/huma/handlers/volumes.go b/backend/internal/huma/handlers/volumes.go index 07d8bade9f..6fc53ba51f 100644 --- a/backend/internal/huma/handlers/volumes.go +++ b/backend/internal/huma/handlers/volumes.go @@ -13,6 +13,7 @@ import ( humamw "github.com/getarcaneapp/arcane/backend/internal/huma/middleware" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + activitylib "github.com/getarcaneapp/arcane/backend/pkg/libarcane/activity" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/types/base" volumetypes "github.com/getarcaneapp/arcane/types/volume" @@ -21,8 +22,9 @@ import ( // VolumeHandler provides Huma-based volume management endpoints. type VolumeHandler struct { - volumeService *services.VolumeService - dockerService *services.DockerClientService + volumeService *services.VolumeService + dockerService *services.DockerClientService + activityService *services.ActivityService } // --- Huma Input/Output Wrappers --- @@ -95,6 +97,7 @@ type PruneVolumesInput struct { type VolumePruneReportData struct { VolumesDeleted []string `json:"volumesDeleted,omitempty"` SpaceReclaimed uint64 `json:"spaceReclaimed"` + ActivityID *string `json:"activityId,omitempty"` } type PruneVolumesOutput struct { @@ -294,10 +297,11 @@ type UploadAndRestoreOutput struct { } // RegisterVolumes registers volume management routes using Huma. -func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, volumeService *services.VolumeService) { +func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, volumeService *services.VolumeService, activityService *services.ActivityService) { h := &VolumeHandler{ - volumeService: volumeService, - dockerService: dockerService, + volumeService: volumeService, + dockerService: dockerService, + activityService: activityService, } huma.Register(api, huma.Operation{ @@ -719,10 +723,30 @@ func (h *VolumeHandler) CreateVolume(ctx context.Context, input *CreateVolumeInp DriverOpts: input.Body.DriverOpts, } - response, err := h.volumeService.CreateVolume(ctx, options, *user) + var response *volumetypes.Volume + activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "volume", + ResourceID: input.Body.Name, + ResourceName: input.Body.Name, + User: user, + Step: "Creating volume", + Message: "Creating volume", + SuccessMessage: "Volume created successfully", + Metadata: models.JSON{ + "action": "create_volume", + "driver": input.Body.Driver, + }, + }, func() error { + var createErr error + response, createErr = h.volumeService.CreateVolume(ctx, options, *user) + return createErr + }) if err != nil { return nil, huma.Error500InternalServerError((&common.VolumeCreationError{Err: err}).Error()) } + response.ActivityID = activitylib.StringPtr(activityID) return &CreateVolumeOutput{ Body: base.ApiResponse[*volumetypes.Volume]{ @@ -743,7 +767,24 @@ func (h *VolumeHandler) RemoveVolume(ctx context.Context, input *RemoveVolumeInp return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - if err := h.volumeService.DeleteVolume(ctx, input.VolumeName, input.Force, *user); err != nil { + activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "volume", + ResourceID: input.VolumeName, + ResourceName: input.VolumeName, + User: user, + Step: "Removing volume", + Message: "Removing volume", + SuccessMessage: "Volume removed successfully", + Metadata: models.JSON{ + "action": "remove_volume", + "force": input.Force, + }, + }, func() error { + return h.volumeService.DeleteVolume(ctx, input.VolumeName, input.Force, *user) + }) + if err != nil { return nil, huma.Error500InternalServerError((&common.VolumeDeletionError{Err: err}).Error()) } @@ -751,7 +792,8 @@ func (h *VolumeHandler) RemoveVolume(ctx context.Context, input *RemoveVolumeInp Body: base.ApiResponse[base.MessageResponse]{ Success: true, Data: base.MessageResponse{ - Message: "Volume removed successfully", + Message: "Volume removed successfully", + ActivityID: activitylib.StringPtr(activityID), }, }, }, nil @@ -763,7 +805,20 @@ func (h *VolumeHandler) PruneVolumes(ctx context.Context, input *PruneVolumesInp return nil, huma.Error500InternalServerError("service not available") } - report, err := h.volumeService.PruneVolumes(ctx) + var report *volumetypes.PruneReport + activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "volume", + Step: "Pruning unused volumes", + Message: "Pruning unused volumes", + SuccessMessage: "Volumes pruned successfully", + Metadata: models.JSON{"action": "prune_volumes"}, + }, func() error { + var pruneErr error + report, pruneErr = h.volumeService.PruneVolumes(ctx) + return pruneErr + }) if err != nil { return nil, huma.Error500InternalServerError((&common.VolumePruneError{Err: err}).Error()) } @@ -774,6 +829,7 @@ func (h *VolumeHandler) PruneVolumes(ctx context.Context, input *PruneVolumesInp Data: VolumePruneReportData{ VolumesDeleted: report.VolumesDeleted, SpaceReclaimed: report.SpaceReclaimed, + ActivityID: activitylib.StringPtr(activityID), }, }, }, nil @@ -930,13 +986,30 @@ func (h *VolumeHandler) UploadFile(ctx context.Context, input *UploadFileInput) defer func() { _ = file.Close() }() user, _ := humamw.GetCurrentUserFromContext(ctx) - err = h.volumeService.UploadFile(ctx, input.VolumeName, input.Path, file, fileHeader.Filename, user) + activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "volume", + ResourceID: input.VolumeName, + ResourceName: input.VolumeName, + User: user, + Step: "Uploading file", + Message: "Uploading file to volume", + SuccessMessage: "File uploaded successfully", + Metadata: models.JSON{ + "action": "upload_volume_file", + "path": input.Path, + "filename": fileHeader.Filename, + }, + }, func() error { + return h.volumeService.UploadFile(ctx, input.VolumeName, input.Path, file, fileHeader.Filename, user) + }) if err != nil { return nil, huma.Error500InternalServerError(err.Error()) } return &base.ApiResponse[base.MessageResponse]{ Success: true, - Data: base.MessageResponse{Message: "File uploaded successfully"}, + Data: base.MessageResponse{Message: "File uploaded successfully", ActivityID: activitylib.StringPtr(activityID)}, }, nil } @@ -945,13 +1018,29 @@ func (h *VolumeHandler) CreateDirectory(ctx context.Context, input *CreateDirect return nil, huma.Error500InternalServerError("service not available") } user, _ := humamw.GetCurrentUserFromContext(ctx) - err := h.volumeService.CreateDirectory(ctx, input.VolumeName, input.Path, user) + activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "volume", + ResourceID: input.VolumeName, + ResourceName: input.VolumeName, + User: user, + Step: "Creating directory", + Message: "Creating directory in volume", + SuccessMessage: "Directory created successfully", + Metadata: models.JSON{ + "action": "create_volume_directory", + "path": input.Path, + }, + }, func() error { + return h.volumeService.CreateDirectory(ctx, input.VolumeName, input.Path, user) + }) if err != nil { return nil, huma.Error500InternalServerError(err.Error()) } return &base.ApiResponse[base.MessageResponse]{ Success: true, - Data: base.MessageResponse{Message: "Directory created successfully"}, + Data: base.MessageResponse{Message: "Directory created successfully", ActivityID: activitylib.StringPtr(activityID)}, }, nil } @@ -960,13 +1049,29 @@ func (h *VolumeHandler) DeleteFile(ctx context.Context, input *DeleteFileInput) return nil, huma.Error500InternalServerError("service not available") } user, _ := humamw.GetCurrentUserFromContext(ctx) - err := h.volumeService.DeleteFile(ctx, input.VolumeName, input.Path, user) + activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "volume", + ResourceID: input.VolumeName, + ResourceName: input.VolumeName, + User: user, + Step: "Deleting file", + Message: "Deleting file or directory from volume", + SuccessMessage: "Deleted successfully", + Metadata: models.JSON{ + "action": "delete_volume_file", + "path": input.Path, + }, + }, func() error { + return h.volumeService.DeleteFile(ctx, input.VolumeName, input.Path, user) + }) if err != nil { return nil, huma.Error500InternalServerError(err.Error()) } return &base.ApiResponse[base.MessageResponse]{ Success: true, - Data: base.MessageResponse{Message: "Deleted successfully"}, + Data: base.MessageResponse{Message: "Deleted successfully", ActivityID: activitylib.StringPtr(activityID)}, }, nil } @@ -1032,10 +1137,27 @@ func (h *VolumeHandler) CreateBackup(ctx context.Context, input *CreateBackupInp return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - backup, err := h.volumeService.CreateBackup(ctx, input.VolumeName, *user) + var backup *models.VolumeBackup + activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "volume", + ResourceID: input.VolumeName, + ResourceName: input.VolumeName, + User: user, + Step: "Creating backup", + Message: "Creating volume backup", + SuccessMessage: "Volume backup created successfully", + Metadata: models.JSON{"action": "create_volume_backup"}, + }, func() error { + var backupErr error + backup, backupErr = h.volumeService.CreateBackup(ctx, input.VolumeName, *user) + return backupErr + }) if err != nil { return nil, huma.Error500InternalServerError(err.Error()) } + backup.ActivityID = activitylib.StringPtr(activityID) return &CreateBackupOutput{ Body: base.ApiResponse[*models.VolumeBackup]{ Success: true, @@ -1053,14 +1175,30 @@ func (h *VolumeHandler) RestoreBackup(ctx context.Context, input *RestoreBackupI return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - err := h.volumeService.RestoreBackup(ctx, input.VolumeName, input.BackupID, *user) + activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "volume", + ResourceID: input.VolumeName, + ResourceName: input.VolumeName, + User: user, + Step: "Restoring backup", + Message: "Restoring volume backup", + SuccessMessage: "Restore initiated successfully", + Metadata: models.JSON{ + "action": "restore_volume_backup", + "backupId": input.BackupID, + }, + }, func() error { + return h.volumeService.RestoreBackup(ctx, input.VolumeName, input.BackupID, *user) + }) if err != nil { return nil, huma.Error500InternalServerError(err.Error()) } return &RestoreBackupOutput{ Body: base.ApiResponse[base.MessageResponse]{ Success: true, - Data: base.MessageResponse{Message: "Restore initiated successfully"}, + Data: base.MessageResponse{Message: "Restore initiated successfully", ActivityID: activitylib.StringPtr(activityID)}, }, }, nil } @@ -1079,14 +1217,32 @@ func (h *VolumeHandler) RestoreBackupFiles(ctx context.Context, input *RestoreBa return nil, huma.Error400BadRequest("paths are required") } - if err := h.volumeService.RestoreBackupFiles(ctx, input.VolumeName, input.BackupID, input.Body.Paths, *user); err != nil { + activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "volume", + ResourceID: input.VolumeName, + ResourceName: input.VolumeName, + User: user, + Step: "Restoring backup files", + Message: "Restoring files from volume backup", + SuccessMessage: "Restore initiated successfully", + Metadata: models.JSON{ + "action": "restore_volume_backup_files", + "backupId": input.BackupID, + "paths": input.Body.Paths, + }, + }, func() error { + return h.volumeService.RestoreBackupFiles(ctx, input.VolumeName, input.BackupID, input.Body.Paths, *user) + }) + if err != nil { return nil, huma.Error500InternalServerError(err.Error()) } return &RestoreBackupFilesOutput{ Body: base.ApiResponse[base.MessageResponse]{ Success: true, - Data: base.MessageResponse{Message: "Restore initiated successfully"}, + Data: base.MessageResponse{Message: "Restore initiated successfully", ActivityID: activitylib.StringPtr(activityID)}, }, }, nil } @@ -1136,14 +1292,30 @@ func (h *VolumeHandler) DeleteBackup(ctx context.Context, input *DeleteBackupInp return nil, huma.Error500InternalServerError("service not available") } user, _ := humamw.GetCurrentUserFromContext(ctx) - err := h.volumeService.DeleteBackup(ctx, input.BackupID, user) + activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "volume_backup", + ResourceID: input.BackupID, + ResourceName: input.BackupID, + User: user, + Step: "Deleting backup", + Message: "Deleting volume backup", + SuccessMessage: "Backup deleted successfully", + Metadata: models.JSON{ + "action": "delete_volume_backup", + "backupId": input.BackupID, + }, + }, func() error { + return h.volumeService.DeleteBackup(ctx, input.BackupID, user) + }) if err != nil { return nil, huma.Error500InternalServerError(err.Error()) } return &DeleteBackupOutput{ Body: base.ApiResponse[base.MessageResponse]{ Success: true, - Data: base.MessageResponse{Message: "Backup deleted successfully"}, + Data: base.MessageResponse{Message: "Backup deleted successfully", ActivityID: activitylib.StringPtr(activityID)}, }, }, nil } @@ -1193,14 +1365,30 @@ func (h *VolumeHandler) UploadAndRestore(ctx context.Context, input *UploadAndRe } defer func() { _ = file.Close() }() - err = h.volumeService.UploadAndRestore(ctx, input.VolumeName, file, fileHeader.Filename, *user) + activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "volume", + ResourceID: input.VolumeName, + ResourceName: input.VolumeName, + User: user, + Step: "Uploading backup", + Message: "Uploading and restoring volume backup", + SuccessMessage: "Backup uploaded and restored successfully", + Metadata: models.JSON{ + "action": "upload_restore_volume_backup", + "filename": fileHeader.Filename, + }, + }, func() error { + return h.volumeService.UploadAndRestore(ctx, input.VolumeName, file, fileHeader.Filename, *user) + }) if err != nil { return nil, huma.Error500InternalServerError(err.Error()) } return &UploadAndRestoreOutput{ Body: base.ApiResponse[base.MessageResponse]{ Success: true, - Data: base.MessageResponse{Message: "Backup uploaded and restored successfully"}, + Data: base.MessageResponse{Message: "Backup uploaded and restored successfully", ActivityID: activitylib.StringPtr(activityID)}, }, }, nil } diff --git a/backend/internal/huma/huma.go b/backend/internal/huma/huma.go index f414bf3df2..737094e22b 100644 --- a/backend/internal/huma/huma.go +++ b/backend/internal/huma/huma.go @@ -145,6 +145,7 @@ type Services struct { Font *services.FontService Project *services.ProjectService Event *services.EventService + Activity *services.ActivityService Version *services.VersionService Environment *services.EnvironmentService Settings *services.SettingsService @@ -318,6 +319,7 @@ func registerHandlers(api huma.API, svc *Services) { var fontSvc *services.FontService var projectSvc *services.ProjectService var eventSvc *services.EventService + var activitySvc *services.ActivityService var versionSvc *services.VersionService var environmentSvc *services.EnvironmentService var settingsSvc *services.SettingsService @@ -357,6 +359,7 @@ func registerHandlers(api huma.API, svc *Services) { fontSvc = svc.Font projectSvc = svc.Project eventSvc = svc.Event + activitySvc = svc.Activity versionSvc = svc.Version environmentSvc = svc.Environment settingsSvc = svc.Settings @@ -392,28 +395,29 @@ func registerHandlers(api huma.API, svc *Services) { handlers.RegisterApiKeys(api, apiKeySvc) handlers.RegisterAppImages(api, appImagesSvc) handlers.RegisterFonts(api, fontSvc) - handlers.RegisterProjects(api, projectSvc) + handlers.RegisterProjects(api, projectSvc, activitySvc) handlers.RegisterUsers(api, userSvc) handlers.RegisterVersion(api, versionSvc) handlers.RegisterEvents(api, eventSvc, apiKeySvc) + handlers.RegisterActivities(api, activitySvc, environmentSvc) handlers.RegisterOidc(api, authSvc, oidcSvc, cfg) handlers.RegisterEnvironments(api, environmentSvc, settingsSvc, apiKeySvc, eventSvc, cfg) handlers.RegisterContainerRegistries(api, containerRegistrySvc, environmentSvc) handlers.RegisterTemplates(api, templateSvc, environmentSvc) - handlers.RegisterImages(api, dockerSvc, imageSvc, imageUpdateSvc, settingsSvc, buildSvc) + handlers.RegisterImages(api, dockerSvc, imageSvc, imageUpdateSvc, settingsSvc, buildSvc, activitySvc) handlers.RegisterBuildWorkspaces(api, buildWorkspaceSvc) handlers.RegisterImageUpdates(api, imageUpdateSvc, imageSvc) handlers.RegisterSettings(api, settingsSvc, settingsSearchSvc, environmentSvc, cfg) handlers.RegisterJobSchedules(api, jobScheduleSvc, environmentSvc) - handlers.RegisterVolumes(api, dockerSvc, volumeSvc) - handlers.RegisterContainers(api, containerSvc, dockerSvc, settingsSvc) + handlers.RegisterVolumes(api, dockerSvc, volumeSvc, activitySvc) + handlers.RegisterContainers(api, containerSvc, dockerSvc, settingsSvc, activitySvc) handlers.RegisterPorts(api, portSvc) - handlers.RegisterNetworks(api, networkSvc, dockerSvc) + handlers.RegisterNetworks(api, networkSvc, dockerSvc, activitySvc) handlers.RegisterSwarm(api, swarmSvc, environmentSvc, eventSvc, cfg) handlers.RegisterNotifications(api, notificationSvc, appriseSvc, cfg) handlers.RegisterUpdater(api, updaterSvc) handlers.RegisterCustomize(api, customizeSearchSvc) - handlers.RegisterSystem(api, dockerSvc, systemSvc, systemUpgradeSvc, cfg) + handlers.RegisterSystem(api, dockerSvc, systemSvc, systemUpgradeSvc, cfg, activitySvc) handlers.RegisterGitRepositories(api, gitRepositorySvc) handlers.RegisterGitOpsSyncs(api, gitOpsSyncSvc) handlers.RegisterWebhooks(api, webhookSvc) diff --git a/backend/internal/models/activity.go b/backend/internal/models/activity.go new file mode 100644 index 0000000000..31c8d4e5cb --- /dev/null +++ b/backend/internal/models/activity.go @@ -0,0 +1,84 @@ +package models + +import "time" + +type ( + ActivityType string + ActivityStatus string + ActivityMessageLevel string +) + +const ( + ActivityStatusQueued ActivityStatus = "queued" + ActivityStatusRunning ActivityStatus = "running" + ActivityStatusSuccess ActivityStatus = "success" + ActivityStatusFailed ActivityStatus = "failed" + ActivityStatusCancelled ActivityStatus = "cancelled" +) + +const ( + ActivityTypeImagePull ActivityType = "image_pull" + ActivityTypeImageBuild ActivityType = "image_build" + ActivityTypeImageUpdateCheck ActivityType = "image_update_check" + ActivityTypeProjectPull ActivityType = "project_pull" + ActivityTypeProjectBuild ActivityType = "project_build" + ActivityTypeProjectDeploy ActivityType = "project_deploy" + ActivityTypeProjectRedeploy ActivityType = "project_redeploy" + ActivityTypeProjectDown ActivityType = "project_down" + ActivityTypeProjectRestart ActivityType = "project_restart" + ActivityTypeProjectDestroy ActivityType = "project_destroy" + ActivityTypeContainerStart ActivityType = "container_start" + ActivityTypeContainerStop ActivityType = "container_stop" + ActivityTypeContainerRestart ActivityType = "container_restart" + ActivityTypeContainerRedeploy ActivityType = "container_redeploy" + ActivityTypeContainerDelete ActivityType = "container_delete" + ActivityTypeVulnerabilityScan ActivityType = "vulnerability_scan" + ActivityTypeAutoUpdate ActivityType = "auto_update" + ActivityTypeSystemPrune ActivityType = "system_prune" + ActivityTypeResourceAction ActivityType = "resource_action" +) + +const ( + ActivityMessageLevelInfo ActivityMessageLevel = "info" + ActivityMessageLevelWarning ActivityMessageLevel = "warning" + ActivityMessageLevelError ActivityMessageLevel = "error" + ActivityMessageLevelSuccess ActivityMessageLevel = "success" +) + +type Activity struct { + EnvironmentID string `json:"environmentId" gorm:"column:environment_id;not null;index" sortable:"true"` + Type ActivityType `json:"type" gorm:"column:type;not null;index" sortable:"true"` + Status ActivityStatus `json:"status" gorm:"column:status;not null;index" sortable:"true"` + ResourceType *string `json:"resourceType,omitempty" gorm:"column:resource_type;index" sortable:"true"` + ResourceID *string `json:"resourceId,omitempty" gorm:"column:resource_id;index"` + ResourceName *string `json:"resourceName,omitempty" gorm:"column:resource_name" sortable:"true"` + Progress *int `json:"progress,omitempty" gorm:"column:progress"` + Step string `json:"step,omitempty" gorm:"column:step"` + LatestMessage string `json:"latestMessage,omitempty" gorm:"column:latest_message"` + StartedByUserID *string `json:"startedByUserId,omitempty" gorm:"column:started_by_user_id;index"` + StartedByUsername *string `json:"startedByUsername,omitempty" gorm:"column:started_by_username"` + StartedByDisplayName *string `json:"startedByDisplayName,omitempty" gorm:"column:started_by_display_name"` + StartedAt time.Time `json:"startedAt" gorm:"column:started_at;not null" sortable:"true"` + EndedAt *time.Time `json:"endedAt,omitempty" gorm:"column:ended_at" sortable:"true"` + DurationMs *int64 `json:"durationMs,omitempty" gorm:"column:duration_ms" sortable:"true"` + Error *string `json:"error,omitempty" gorm:"column:error"` + Metadata JSON `json:"metadata,omitempty" gorm:"type:text"` + BaseModel +} + +func (Activity) TableName() string { + return "activities" +} + +type ActivityMessage struct { + ActivityID string `json:"activityId" gorm:"column:activity_id;not null;index"` + Level ActivityMessageLevel `json:"level" gorm:"column:level;not null"` + Message string `json:"message" gorm:"column:message;not null"` + Payload JSON `json:"payload,omitempty" gorm:"type:text"` + Activity *Activity `json:"-" gorm:"foreignKey:ActivityID;constraint:OnDelete:CASCADE"` + BaseModel +} + +func (ActivityMessage) TableName() string { + return "activity_messages" +} diff --git a/backend/internal/models/settings.go b/backend/internal/models/settings.go index 2d8face74f..24eee8dec6 100644 --- a/backend/internal/models/settings.go +++ b/backend/internal/models/settings.go @@ -89,6 +89,8 @@ type Settings struct { PollingInterval SettingVariable `key:"pollingInterval" meta:"label=Polling Interval;type=cron;keywords=interval,frequency,schedule,time,minutes,period,delay;category=internal;description=How often to check for image updates (cron expression)"` DockerClientRefreshInterval SettingVariable `key:"dockerClientRefreshInterval" meta:"label=Docker Client Refresh Interval;type=cron;keywords=docker,client,refresh,daemon,api,version,reconnect,renegotiate,schedule;category=internal;description=How often to refresh the cached Docker client API version (cron expression)"` EventCleanupInterval SettingVariable `key:"eventCleanupInterval" meta:"label=Event Cleanup Interval;type=cron;keywords=events,cleanup,retention,interval,frequency,schedule,history,logs,jobs;description=How often to delete old events (cron expression)"` + ActivityHistoryRetentionDays SettingVariable `key:"activityHistoryRetentionDays" meta:"label=Activity History Retention;type=number;keywords=activity,history,retention,days,cleanup,background,tasks;category=activity;description=Delete completed Activity Center entries older than this many days. Set 0 to disable age-based cleanup." catmeta:"id=activity;title=Activity;icon=activity;url=/settings/activity;description=Configure Activity Center history and cleanup"` + ActivityHistoryMaxEntries SettingVariable `key:"activityHistoryMaxEntries" meta:"label=Activity History Limit;type=number;keywords=activity,history,limit,entries,count,cleanup,background,tasks;category=activity;description=Maximum completed Activity Center entries to keep per environment. Set 0 to disable count-based cleanup."` AutoInjectEnv SettingVariable `key:"autoInjectEnv" meta:"label=Auto Inject Env Variables;type=boolean;keywords=auto,inject,env,environment,variables,interpolation;category=internal;description=Automatically inject project .env variables into all containers (default: false)"` // Deprecated: Use the granular prune mode settings instead. PruneMode SettingVariable `key:"dockerPruneMode,internal,deprecated" meta:"label=Legacy Docker Prune Action;type=select;keywords=prune,cleanup,clean,remove,delete,unused,dangling,space,disk,legacy;category=internal;description=Legacy prune mode retained for compatibility and migration"` diff --git a/backend/internal/models/volume_backup.go b/backend/internal/models/volume_backup.go index a4df4768a3..4643ef465c 100644 --- a/backend/internal/models/volume_backup.go +++ b/backend/internal/models/volume_backup.go @@ -11,6 +11,7 @@ type VolumeBackup struct { VolumeName string `json:"volumeName" gorm:"column:volume_name;index"` Size int64 `json:"size" gorm:"column:size"` CreatedAt time.Time `json:"createdAt" gorm:"column:created_at"` + ActivityID *string `json:"activityId,omitempty" gorm:"-"` } func (*VolumeBackup) TableName() string { diff --git a/backend/internal/services/activity_service.go b/backend/internal/services/activity_service.go new file mode 100644 index 0000000000..a4b0a26a78 --- /dev/null +++ b/backend/internal/services/activity_service.go @@ -0,0 +1,714 @@ +package services + +import ( + "context" + "fmt" + "log/slog" + "strings" + "sync" + "time" + + "github.com/getarcaneapp/arcane/backend/internal/database" + "github.com/getarcaneapp/arcane/backend/internal/models" + activitylib "github.com/getarcaneapp/arcane/backend/pkg/libarcane/activity" + "github.com/getarcaneapp/arcane/backend/pkg/pagination" + activitytypes "github.com/getarcaneapp/arcane/types/activity" + "gorm.io/gorm" +) + +const ( + defaultActivityRetentionDays = 30 + defaultActivityHistoryLimit = 1000 + defaultActivityMessages = 500 +) + +type ActivityService struct { + db *database.DB + + subscribersMu sync.RWMutex + subscribers map[int]*activitySubscriber + nextSubID int +} + +type activitySubscriber struct { + environmentID string + ch chan activitytypes.StreamEvent + missedEvents bool +} + +type StartActivityRequest = activitylib.StartRequest +type UpdateActivityRequest = activitylib.UpdateRequest +type AppendActivityMessageRequest = activitylib.AppendMessageRequest + +func NewActivityService(db *database.DB) *ActivityService { + return &ActivityService{ + db: db, + subscribers: map[int]*activitySubscriber{}, + } +} + +func (s *ActivityService) StartActivity(ctx context.Context, req StartActivityRequest) (*activitytypes.Activity, error) { + if s == nil || s.db == nil { + return nil, fmt.Errorf("activity service not initialized") + } + + now := time.Now() + environmentID := strings.TrimSpace(req.EnvironmentID) + if environmentID == "" { + environmentID = "0" + } + + model := &models.Activity{ + EnvironmentID: environmentID, + Type: req.Type, + Status: models.ActivityStatusRunning, + ResourceType: copyStringPtrInternal(req.ResourceType), + ResourceID: copyStringPtrInternal(req.ResourceID), + ResourceName: copyStringPtrInternal(req.ResourceName), + StartedByUserID: activityUserIDInternal(req.StartedBy), + StartedByUsername: activityUsernameInternal(req.StartedBy), + StartedByDisplayName: activityDisplayNameInternal(req.StartedBy), + Progress: clampProgressPtrInternal(req.Progress), + Step: strings.TrimSpace(req.Step), + LatestMessage: strings.TrimSpace(req.LatestMessage), + StartedAt: now, + Metadata: cloneJSONInternal(req.Metadata), + BaseModel: models.BaseModel{ + CreatedAt: now, + }, + } + if model.Type == "" { + model.Type = models.ActivityTypeAutoUpdate + } + + if err := s.db.WithContext(ctx).Create(model).Error; err != nil { + return nil, fmt.Errorf("failed to create activity: %w", err) + } + + dto := activityToDTOInternal(model) + s.publishActivityInternal(dto) + return &dto, nil +} + +func (s *ActivityService) UpdateActivity(ctx context.Context, activityID string, req UpdateActivityRequest) (*activitytypes.Activity, error) { + if s == nil || s.db == nil { + return nil, fmt.Errorf("activity service not initialized") + } + activityID = strings.TrimSpace(activityID) + if activityID == "" { + return nil, fmt.Errorf("activity id is required") + } + + updates := map[string]any{ + "updated_at": time.Now(), + } + if req.Status != "" { + updates["status"] = req.Status + } + if req.Progress != nil { + updates["progress"] = *clampProgressPtrInternal(req.Progress) + } + if req.Step != nil { + updates["step"] = strings.TrimSpace(*req.Step) + } + if req.LatestMessage != nil { + updates["latest_message"] = strings.TrimSpace(*req.LatestMessage) + } + if req.Error != nil { + updates["error"] = strings.TrimSpace(*req.Error) + } + if req.Metadata != nil { + updates["metadata"] = cloneJSONInternal(req.Metadata) + } + + var model models.Activity + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + result := tx.Model(&models.Activity{}).Where("id = ?", activityID).Updates(updates) + if result.Error != nil { + return fmt.Errorf("failed to update activity: %w", result.Error) + } + if result.RowsAffected == 0 { + return fmt.Errorf("activity not found") + } + if err := tx.First(&model, "id = ?", activityID).Error; err != nil { + return fmt.Errorf("failed to load updated activity: %w", err) + } + return nil + }); err != nil { + return nil, err + } + + dto := activityToDTOInternal(&model) + s.publishActivityInternal(dto) + return &dto, nil +} + +func (s *ActivityService) AppendMessage(ctx context.Context, activityID string, req AppendActivityMessageRequest) (*activitytypes.Message, error) { + if s == nil || s.db == nil { + return nil, fmt.Errorf("activity service not initialized") + } + activityID = strings.TrimSpace(activityID) + if activityID == "" { + return nil, fmt.Errorf("activity id is required") + } + + messageText := strings.TrimSpace(req.Message) + if messageText == "" { + return nil, nil + } + if len(messageText) > 8192 { + messageText = messageText[:8192] + } + + level := req.Level + if level == "" { + level = models.ActivityMessageLevelInfo + } + + now := time.Now() + message := &models.ActivityMessage{ + ActivityID: activityID, + Level: level, + Message: messageText, + Payload: cloneJSONInternal(req.Payload), + BaseModel: models.BaseModel{ + CreatedAt: now, + }, + } + + var updated models.Activity + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(message).Error; err != nil { + return fmt.Errorf("failed to append activity message: %w", err) + } + + updates := map[string]any{ + "latest_message": messageText, + "updated_at": now, + } + if req.Progress != nil { + updates["progress"] = *clampProgressPtrInternal(req.Progress) + } + if strings.TrimSpace(req.Step) != "" { + updates["step"] = strings.TrimSpace(req.Step) + } + + result := tx.Model(&models.Activity{}).Where("id = ?", activityID).Updates(updates) + if result.Error != nil { + return fmt.Errorf("failed to update activity latest message: %w", result.Error) + } + if result.RowsAffected == 0 { + return fmt.Errorf("activity not found") + } + if err := tx.First(&updated, "id = ?", activityID).Error; err != nil { + return fmt.Errorf("failed to load updated activity: %w", err) + } + return nil + }); err != nil { + return nil, err + } + + dto := activityMessageToDTOInternal(message) + s.publishMessageInternal(updated.EnvironmentID, dto) + s.publishActivityInternal(activityToDTOInternal(&updated)) + return &dto, nil +} + +func (s *ActivityService) CompleteActivity(ctx context.Context, activityID string, status models.ActivityStatus, finalMessage string, errMessage *string) (*activitytypes.Activity, error) { + if status == "" { + status = models.ActivityStatusSuccess + } + if status != models.ActivityStatusSuccess && status != models.ActivityStatusFailed && status != models.ActivityStatusCancelled { + status = models.ActivityStatusSuccess + } + + activityID = strings.TrimSpace(activityID) + if activityID == "" { + return nil, fmt.Errorf("activity id is required") + } + + now := time.Now() + var model models.Activity + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.First(&model, "id = ?", activityID).Error; err != nil { + return fmt.Errorf("failed to load activity: %w", err) + } + + duration := now.Sub(model.StartedAt).Milliseconds() + updates := map[string]any{ + "status": status, + "ended_at": now, + "duration_ms": duration, + "updated_at": now, + } + if trimmed := strings.TrimSpace(finalMessage); trimmed != "" { + updates["latest_message"] = trimmed + } + if errMessage != nil && strings.TrimSpace(*errMessage) != "" { + updates["error"] = strings.TrimSpace(*errMessage) + } + if status == models.ActivityStatusSuccess { + progress := 100 + updates["progress"] = progress + } + + if err := tx.Model(&models.Activity{}).Where("id = ?", activityID).Updates(updates).Error; err != nil { + return fmt.Errorf("failed to complete activity: %w", err) + } + if err := tx.First(&model, "id = ?", activityID).Error; err != nil { + return fmt.Errorf("failed to load completed activity: %w", err) + } + return nil + }); err != nil { + return nil, err + } + + if strings.TrimSpace(finalMessage) != "" { + level := models.ActivityMessageLevelSuccess + switch status { + case models.ActivityStatusFailed: + level = models.ActivityMessageLevelError + case models.ActivityStatusCancelled: + level = models.ActivityMessageLevelWarning + case models.ActivityStatusQueued, models.ActivityStatusRunning, models.ActivityStatusSuccess: + } + if _, err := s.AppendMessage(context.WithoutCancel(ctx), activityID, AppendActivityMessageRequest{ + Level: level, + Message: finalMessage, + }); err != nil { + slog.DebugContext(ctx, "failed to append final activity message", "activityId", activityID, "error", err) + } + if err := s.db.WithContext(context.WithoutCancel(ctx)).First(&model, "id = ?", activityID).Error; err != nil { + slog.DebugContext(ctx, "failed to reload activity after appending message", "activityId", activityID, "error", err) + } + } + + dto := activityToDTOInternal(&model) + s.publishActivityInternal(dto) + return &dto, nil +} + +func (s *ActivityService) ListActivitiesPaginated(ctx context.Context, environmentID string, params pagination.QueryParams) ([]activitytypes.Activity, pagination.Response, error) { + if s == nil || s.db == nil { + return nil, pagination.Response{}, fmt.Errorf("activity service not initialized") + } + + environmentID = strings.TrimSpace(environmentID) + if environmentID == "" { + environmentID = "0" + } + + var activities []models.Activity + q := s.db.WithContext(ctx).Model(&models.Activity{}).Where("environment_id = ?", environmentID) + + if term := strings.TrimSpace(params.Search); term != "" { + searchPattern := "%" + term + "%" + q = q.Where( + "type LIKE ? OR COALESCE(resource_name, '') LIKE ? OR COALESCE(latest_message, '') LIKE ? OR COALESCE(step, '') LIKE ? OR COALESCE(error, '') LIKE ?", + searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, + ) + } + + q = pagination.ApplyFilter(q, "status", params.Filters["status"]) + q = pagination.ApplyFilter(q, "type", params.Filters["type"]) + q = pagination.ApplyFilter(q, "resource_type", params.Filters["resourceType"]) + + if params.Sort == "" { + q = q.Order("CASE WHEN status IN ('queued', 'running') THEN 0 ELSE 1 END ASC"). + Order("COALESCE(updated_at, created_at) DESC"). + Order("started_at DESC") + } + + paginationResp, err := pagination.PaginateAndSortDB(params, q, &activities) + if err != nil { + return nil, pagination.Response{}, fmt.Errorf("failed to paginate activities: %w", err) + } + + out := make([]activitytypes.Activity, 0, len(activities)) + for i := range activities { + out = append(out, activityToDTOInternal(&activities[i])) + } + return out, paginationResp, nil +} + +func (s *ActivityService) GetActivityDetail(ctx context.Context, environmentID, activityID string, limit int) (*activitytypes.Detail, error) { + if s == nil || s.db == nil { + return nil, fmt.Errorf("activity service not initialized") + } + if limit <= 0 || limit > defaultActivityMessages { + limit = defaultActivityMessages + } + + var model models.Activity + if err := s.db.WithContext(ctx). + Where("id = ? AND environment_id = ?", activityID, environmentID). + First(&model).Error; err != nil { + return nil, fmt.Errorf("failed to load activity: %w", err) + } + + var messages []models.ActivityMessage + if err := s.db.WithContext(ctx). + Where("activity_id = ?", activityID). + Order("created_at DESC"). + Limit(limit). + Find(&messages).Error; err != nil { + return nil, fmt.Errorf("failed to load activity messages: %w", err) + } + + outMessages := make([]activitytypes.Message, 0, len(messages)) + for i := len(messages) - 1; i >= 0; i-- { + outMessages = append(outMessages, activityMessageToDTOInternal(&messages[i])) + } + + return &activitytypes.Detail{ + Activity: activityToDTOInternal(&model), + Messages: outMessages, + }, nil +} + +func (s *ActivityService) PruneHistory(ctx context.Context, retentionDays, maxEntries int) (int64, error) { + if s == nil || s.db == nil { + return 0, nil + } + if retentionDays < 0 { + retentionDays = defaultActivityRetentionDays + } + if maxEntries < 0 { + maxEntries = defaultActivityHistoryLimit + } + + var deleted int64 + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if retentionDays > 0 { + cutoff := time.Now().Add(-time.Duration(retentionDays) * 24 * time.Hour) + ids, err := findTerminalActivityIDsInternal(tx. + Where("COALESCE(ended_at, updated_at, created_at) < ?", cutoff)) + if err != nil { + return fmt.Errorf("failed to find activities older than retention window: %w", err) + } + count, err := deleteActivitiesByIDInternal(tx, ids) + if err != nil { + return err + } + deleted += count + } + + if maxEntries > 0 { + ids, err := findActivityIDsBeyondHistoryLimitInternal(tx, maxEntries) + if err != nil { + return err + } + count, err := deleteActivitiesByIDInternal(tx, ids) + if err != nil { + return err + } + deleted += count + } + + return nil + }); err != nil { + return 0, err + } + + return deleted, nil +} + +func (s *ActivityService) DeleteHistory(ctx context.Context, environmentID string) (int64, error) { + if s == nil || s.db == nil { + return 0, nil + } + + environmentID = strings.TrimSpace(environmentID) + if environmentID == "" { + environmentID = "0" + } + + var deleted int64 + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + ids, err := findTerminalActivityIDsInternal(tx.Where("environment_id = ?", environmentID)) + if err != nil { + return fmt.Errorf("failed to find activity history: %w", err) + } + count, err := deleteActivitiesByIDInternal(tx, ids) + if err != nil { + return err + } + deleted = count + return nil + }); err != nil { + return 0, err + } + + return deleted, nil +} + +func (s *ActivityService) Subscribe(environmentID string) (<-chan activitytypes.StreamEvent, func() bool, func()) { + ch := make(chan activitytypes.StreamEvent, 64) + if s == nil { + close(ch) + return ch, func() bool { return false }, func() {} + } + + environmentID = strings.TrimSpace(environmentID) + if environmentID == "" { + environmentID = "0" + } + + s.subscribersMu.Lock() + s.nextSubID++ + id := s.nextSubID + s.subscribers[id] = &activitySubscriber{environmentID: environmentID, ch: ch} + s.subscribersMu.Unlock() + + missedEvents := func() bool { + s.subscribersMu.Lock() + defer s.subscribersMu.Unlock() + + sub, ok := s.subscribers[id] + if !ok || !sub.missedEvents { + return false + } + sub.missedEvents = false + return true + } + + unsubscribe := func() { + s.subscribersMu.Lock() + if sub, ok := s.subscribers[id]; ok { + delete(s.subscribers, id) + close(sub.ch) + } + s.subscribersMu.Unlock() + } + + return ch, missedEvents, unsubscribe +} + +func (s *ActivityService) publishActivityInternal(activity activitytypes.Activity) { + s.publishInternal(activity.EnvironmentID, activitytypes.StreamEvent{ + Type: "activity", + ActivityID: activity.ID, + Activity: &activity, + Timestamp: time.Now(), + }) +} + +func (s *ActivityService) publishMessageInternal(environmentID string, message activitytypes.Message) { + s.publishInternal(environmentID, activitytypes.StreamEvent{ + Type: "message", + ActivityID: message.ActivityID, + Message: &message, + Timestamp: time.Now(), + }) +} + +func (s *ActivityService) publishInternal(environmentID string, event activitytypes.StreamEvent) { + if s == nil { + return + } + s.subscribersMu.Lock() + defer s.subscribersMu.Unlock() + + for _, sub := range s.subscribers { + if sub.environmentID != environmentID { + continue + } + select { + case sub.ch <- event: + default: + sub.missedEvents = true + slog.Warn("activity subscriber event buffer full; snapshot will be sent on next heartbeat", "environmentId", environmentID, "eventType", event.Type) + } + } +} + +func activityToDTOInternal(model *models.Activity) activitytypes.Activity { + if model == nil { + return activitytypes.Activity{} + } + return activitytypes.Activity{ + ID: model.ID, + EnvironmentID: model.EnvironmentID, + SourceEnvironmentID: model.EnvironmentID, + Type: activitytypes.Type(model.Type), + Status: activitytypes.Status(model.Status), + ResourceType: copyStringPtrInternal(model.ResourceType), + ResourceID: copyStringPtrInternal(model.ResourceID), + ResourceName: copyStringPtrInternal(model.ResourceName), + Progress: clampProgressPtrInternal(model.Progress), + Step: model.Step, + LatestMessage: model.LatestMessage, + StartedBy: activityStartedByDTOInternal(model), + StartedAt: model.StartedAt, + EndedAt: copyTimePtrInternal(model.EndedAt), + DurationMs: copyInt64PtrInternal(model.DurationMs), + Error: copyStringPtrInternal(model.Error), + Metadata: jsonToMapInternal(model.Metadata), + CreatedAt: model.CreatedAt, + UpdatedAt: copyTimePtrInternal(model.UpdatedAt), + } +} + +func activityMessageToDTOInternal(model *models.ActivityMessage) activitytypes.Message { + if model == nil { + return activitytypes.Message{} + } + return activitytypes.Message{ + ID: model.ID, + ActivityID: model.ActivityID, + Level: activitytypes.MessageLevel(model.Level), + Message: model.Message, + Payload: jsonToMapInternal(model.Payload), + CreatedAt: model.CreatedAt, + } +} + +func copyStringPtrInternal(value *string) *string { + if value == nil { + return nil + } + out := *value + return &out +} + +func copyTimePtrInternal(value *time.Time) *time.Time { + if value == nil { + return nil + } + out := *value + return &out +} + +func copyInt64PtrInternal(value *int64) *int64 { + if value == nil { + return nil + } + out := *value + return &out +} + +func clampProgressPtrInternal(value *int) *int { + if value == nil { + return nil + } + clamped := *value + if clamped < 0 { + clamped = 0 + } + if clamped > 100 { + clamped = 100 + } + return &clamped +} + +func cloneJSONInternal(input models.JSON) models.JSON { + if len(input) == 0 { + return nil + } + out := make(models.JSON, len(input)) + for key, value := range input { + out[key] = value + } + return out +} + +func jsonToMapInternal(input models.JSON) map[string]any { + if len(input) == 0 { + return nil + } + out := make(map[string]any, len(input)) + for key, value := range input { + out[key] = value + } + return out +} + +func terminalActivityStatusesInternal() []models.ActivityStatus { + return []models.ActivityStatus{ + models.ActivityStatusSuccess, + models.ActivityStatusFailed, + models.ActivityStatusCancelled, + } +} + +func findTerminalActivityIDsInternal(q *gorm.DB) ([]string, error) { + var activityIDs []string + if err := q.Model(&models.Activity{}). + Where("status IN ?", terminalActivityStatusesInternal()). + Pluck("id", &activityIDs).Error; err != nil { + return nil, err + } + return activityIDs, nil +} + +func findActivityIDsBeyondHistoryLimitInternal(tx *gorm.DB, maxEntries int) ([]string, error) { + var activityIDs []string + if err := tx.Raw(` + SELECT a.id + FROM activities a + WHERE a.status IN ? + AND a.id NOT IN ( + SELECT b.id + FROM activities b + WHERE b.environment_id = a.environment_id + AND b.status IN ? + ORDER BY COALESCE(b.ended_at, b.updated_at, b.created_at) DESC, b.started_at DESC + LIMIT ? + ) + `, terminalActivityStatusesInternal(), terminalActivityStatusesInternal(), maxEntries).Scan(&activityIDs).Error; err != nil { + return nil, fmt.Errorf("failed to find excess activities: %w", err) + } + return activityIDs, nil +} + +func deleteActivitiesByIDInternal(tx *gorm.DB, activityIDs []string) (int64, error) { + if len(activityIDs) == 0 { + return 0, nil + } + if err := tx.Where("activity_id IN ?", activityIDs).Delete(&models.ActivityMessage{}).Error; err != nil { + return 0, fmt.Errorf("failed to delete activity messages: %w", err) + } + result := tx.Where("id IN ?", activityIDs).Delete(&models.Activity{}) + if result.Error != nil { + return 0, fmt.Errorf("failed to delete activities: %w", result.Error) + } + return result.RowsAffected, nil +} + +func activityUserIDInternal(user *models.User) *string { + if user == nil { + return nil + } + return activitylib.StringPtr(user.ID) +} + +func activityUsernameInternal(user *models.User) *string { + if user == nil { + return nil + } + return activitylib.StringPtr(user.Username) +} + +func activityDisplayNameInternal(user *models.User) *string { + if user == nil || user.DisplayName == nil { + return nil + } + return activitylib.StringPtr(*user.DisplayName) +} + +func activityStartedByDTOInternal(model *models.Activity) *activitytypes.StartedBy { + if model.StartedByUsername == nil || strings.TrimSpace(*model.StartedByUsername) == "" { + return &activitytypes.StartedBy{Username: "System"} + } + + startedBy := &activitytypes.StartedBy{ + Username: strings.TrimSpace(*model.StartedByUsername), + } + if model.StartedByUserID != nil { + startedBy.UserID = strings.TrimSpace(*model.StartedByUserID) + } + if model.StartedByDisplayName != nil { + startedBy.DisplayName = strings.TrimSpace(*model.StartedByDisplayName) + } + return startedBy +} diff --git a/backend/internal/services/activity_service_test.go b/backend/internal/services/activity_service_test.go new file mode 100644 index 0000000000..e85335174d --- /dev/null +++ b/backend/internal/services/activity_service_test.go @@ -0,0 +1,296 @@ +package services + +import ( + "context" + "testing" + "time" + + glsqlite "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "github.com/getarcaneapp/arcane/backend/internal/database" + "github.com/getarcaneapp/arcane/backend/internal/models" + "github.com/getarcaneapp/arcane/backend/pkg/pagination" + activitytypes "github.com/getarcaneapp/arcane/types/activity" +) + +func setupActivityServiceTestDBInternal(t *testing.T) *database.DB { + t.Helper() + + db, err := gorm.Open(glsqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.Activity{}, &models.ActivityMessage{})) + return &database.DB{DB: db} +} + +func TestActivityServiceLifecycleInternal(t *testing.T) { + ctx := context.Background() + db := setupActivityServiceTestDBInternal(t) + service := NewActivityService(db) + + resourceType := "image" + resourceID := "img-123" + resourceName := "nginx:latest" + progress := 5 + displayName := "Arcane Admin" + startedBy := &models.User{ + BaseModel: models.BaseModel{ID: "user-1"}, + Username: "arcane", + DisplayName: &displayName, + } + created, err := service.StartActivity(ctx, StartActivityRequest{ + EnvironmentID: "0", + Type: models.ActivityTypeImagePull, + ResourceType: &resourceType, + ResourceID: &resourceID, + ResourceName: &resourceName, + StartedBy: startedBy, + Progress: &progress, + Step: "queued", + LatestMessage: "Pull queued", + }) + require.NoError(t, err) + require.NotEmpty(t, created.ID) + require.Equal(t, "0", created.EnvironmentID) + require.Equal(t, "running", string(created.Status)) + require.Equal(t, 5, *created.Progress) + require.NotNil(t, created.StartedBy) + require.Equal(t, "user-1", created.StartedBy.UserID) + require.Equal(t, "arcane", created.StartedBy.Username) + require.Equal(t, "Arcane Admin", created.StartedBy.DisplayName) + + progress = 42 + message, err := service.AppendMessage(ctx, created.ID, AppendActivityMessageRequest{ + Level: models.ActivityMessageLevelInfo, + Message: "Downloading layers", + Progress: &progress, + Step: "download", + }) + require.NoError(t, err) + require.NotNil(t, message) + require.Equal(t, created.ID, message.ActivityID) + + completed, err := service.CompleteActivity(ctx, created.ID, models.ActivityStatusSuccess, "Pull complete", nil) + require.NoError(t, err) + require.Equal(t, "success", string(completed.Status)) + require.NotNil(t, completed.EndedAt) + require.NotNil(t, completed.DurationMs) + require.Equal(t, 100, *completed.Progress) + + list, paginationResp, err := service.ListActivitiesPaginated(ctx, "0", pagination.QueryParams{ + PaginationParams: pagination.PaginationParams{Limit: 10}, + }) + require.NoError(t, err) + require.Len(t, list, 1) + require.Equal(t, int64(1), paginationResp.TotalItems) + require.Equal(t, created.ID, list[0].ID) + + detail, err := service.GetActivityDetail(ctx, "0", created.ID, 10) + require.NoError(t, err) + require.Equal(t, created.ID, detail.Activity.ID) + require.Len(t, detail.Messages, 2) + require.Equal(t, "Downloading layers", detail.Messages[0].Message) + require.Equal(t, "Pull complete", detail.Messages[1].Message) +} + +func TestActivityServiceStreamFanoutInternal(t *testing.T) { + ctx := context.Background() + db := setupActivityServiceTestDBInternal(t) + service := NewActivityService(db) + + events, _, unsubscribe := service.Subscribe("0") + defer unsubscribe() + + created, err := service.StartActivity(ctx, StartActivityRequest{ + EnvironmentID: "0", + Type: models.ActivityTypeProjectDeploy, + LatestMessage: "Deploy queued", + }) + require.NoError(t, err) + + first := receiveActivityEventInternal(t, events) + require.Equal(t, "activity", first.Type) + require.Equal(t, created.ID, first.ActivityID) + require.NotNil(t, first.Activity) + + _, err = service.AppendMessage(ctx, created.ID, AppendActivityMessageRequest{ + Level: models.ActivityMessageLevelInfo, + Message: "Deploying services", + Step: "deploy", + }) + require.NoError(t, err) + + messageEvent := receiveActivityEventInternal(t, events) + require.Equal(t, "message", messageEvent.Type) + require.Equal(t, created.ID, messageEvent.ActivityID) + require.NotNil(t, messageEvent.Message) + require.Equal(t, "Deploying services", messageEvent.Message.Message) +} + +func TestActivityServiceRetentionCleanupInternal(t *testing.T) { + ctx := context.Background() + db := setupActivityServiceTestDBInternal(t) + service := NewActivityService(db) + + created, err := service.StartActivity(ctx, StartActivityRequest{ + EnvironmentID: "0", + Type: models.ActivityTypeSystemPrune, + LatestMessage: "Prune started", + }) + require.NoError(t, err) + _, err = service.AppendMessage(ctx, created.ID, AppendActivityMessageRequest{ + Message: "Removing unused resources", + }) + require.NoError(t, err) + _, err = service.CompleteActivity(ctx, created.ID, models.ActivityStatusSuccess, "Prune complete", nil) + require.NoError(t, err) + + oldEndedAt := time.Now().Add(-((time.Duration(defaultActivityRetentionDays) * 24 * time.Hour) + time.Hour)) + require.NoError(t, db.Model(&models.Activity{}).Where("id = ?", created.ID).Update("ended_at", oldEndedAt).Error) + + deleted, err := service.PruneHistory(ctx, defaultActivityRetentionDays, 0) + require.NoError(t, err) + require.EqualValues(t, 1, deleted) + + var activityCount int64 + require.NoError(t, db.Model(&models.Activity{}).Count(&activityCount).Error) + require.Zero(t, activityCount) + + var messageCount int64 + require.NoError(t, db.Model(&models.ActivityMessage{}).Count(&messageCount).Error) + require.Zero(t, messageCount) +} + +func TestActivityServicePruneHistoryZeroRetentionDisablesAgeCleanupInternal(t *testing.T) { + ctx := context.Background() + db := setupActivityServiceTestDBInternal(t) + service := NewActivityService(db) + + created, err := service.StartActivity(ctx, StartActivityRequest{ + EnvironmentID: "0", + Type: models.ActivityTypeSystemPrune, + LatestMessage: "Prune started", + }) + require.NoError(t, err) + _, err = service.CompleteActivity(ctx, created.ID, models.ActivityStatusSuccess, "Prune complete", nil) + require.NoError(t, err) + + oldEndedAt := time.Now().Add(-((time.Duration(defaultActivityRetentionDays) * 24 * time.Hour) + time.Hour)) + require.NoError(t, db.Model(&models.Activity{}).Where("id = ?", created.ID).Update("ended_at", oldEndedAt).Error) + + deleted, err := service.PruneHistory(ctx, 0, 0) + require.NoError(t, err) + require.Zero(t, deleted) + + var activityCount int64 + require.NoError(t, db.Model(&models.Activity{}).Where("id = ?", created.ID).Count(&activityCount).Error) + require.EqualValues(t, 1, activityCount) +} + +func TestActivityServiceSubscribeMarksMissedEventsWhenBufferFullInternal(t *testing.T) { + service := NewActivityService(nil) + + events, missedEvents, unsubscribe := service.Subscribe("0") + defer unsubscribe() + + for i := 0; i < cap(events); i++ { + service.publishInternal("0", activitytypes.StreamEvent{Type: "activity"}) + } + require.False(t, missedEvents()) + + service.publishInternal("0", activitytypes.StreamEvent{Type: "activity"}) + require.True(t, missedEvents()) + require.False(t, missedEvents()) +} + +func TestActivityServiceDeleteHistoryPreservesActiveActivitiesInternal(t *testing.T) { + ctx := context.Background() + db := setupActivityServiceTestDBInternal(t) + service := NewActivityService(db) + + completed, err := service.StartActivity(ctx, StartActivityRequest{EnvironmentID: "0", Type: models.ActivityTypeResourceAction}) + require.NoError(t, err) + _, err = service.AppendMessage(ctx, completed.ID, AppendActivityMessageRequest{Message: "done"}) + require.NoError(t, err) + _, err = service.CompleteActivity(ctx, completed.ID, models.ActivityStatusSuccess, "complete", nil) + require.NoError(t, err) + + running, err := service.StartActivity(ctx, StartActivityRequest{EnvironmentID: "0", Type: models.ActivityTypeResourceAction}) + require.NoError(t, err) + + remoteCompleted, err := service.StartActivity(ctx, StartActivityRequest{EnvironmentID: "remote-1", Type: models.ActivityTypeResourceAction}) + require.NoError(t, err) + _, err = service.CompleteActivity(ctx, remoteCompleted.ID, models.ActivityStatusFailed, "failed", nil) + require.NoError(t, err) + + deleted, err := service.DeleteHistory(ctx, "0") + require.NoError(t, err) + require.EqualValues(t, 1, deleted) + + var remaining []models.Activity + require.NoError(t, db.Order("id").Find(&remaining).Error) + require.Len(t, remaining, 2) + require.ElementsMatch(t, []string{running.ID, remoteCompleted.ID}, []string{remaining[0].ID, remaining[1].ID}) +} + +func TestActivityServicePruneHistoryByAgeAndCountInternal(t *testing.T) { + ctx := context.Background() + db := setupActivityServiceTestDBInternal(t) + service := NewActivityService(db) + + oldActivity, err := service.StartActivity(ctx, StartActivityRequest{EnvironmentID: "0", Type: models.ActivityTypeResourceAction}) + require.NoError(t, err) + _, err = service.CompleteActivity(ctx, oldActivity.ID, models.ActivityStatusSuccess, "old", nil) + require.NoError(t, err) + oldTime := time.Now().Add(-48 * time.Hour) + require.NoError(t, db.Model(&models.Activity{}).Where("id = ?", oldActivity.ID).Updates(map[string]any{ + "ended_at": oldTime, + "updated_at": oldTime, + }).Error) + + for i := 0; i < 3; i++ { + item, startErr := service.StartActivity(ctx, StartActivityRequest{EnvironmentID: "remote-1", Type: models.ActivityTypeResourceAction}) + require.NoError(t, startErr) + _, completeErr := service.CompleteActivity(ctx, item.ID, models.ActivityStatusSuccess, "done", nil) + require.NoError(t, completeErr) + stamp := time.Now().Add(time.Duration(i) * time.Minute) + require.NoError(t, db.Model(&models.Activity{}).Where("id = ?", item.ID).Updates(map[string]any{ + "ended_at": stamp, + "updated_at": stamp, + }).Error) + } + + running, err := service.StartActivity(ctx, StartActivityRequest{EnvironmentID: "remote-1", Type: models.ActivityTypeResourceAction}) + require.NoError(t, err) + + deleted, err := service.PruneHistory(ctx, 1, 2) + require.NoError(t, err) + require.EqualValues(t, 2, deleted) + + var terminalRemoteCount int64 + require.NoError(t, db.Model(&models.Activity{}). + Where("environment_id = ? AND status IN ?", "remote-1", terminalActivityStatusesInternal()). + Count(&terminalRemoteCount).Error) + require.EqualValues(t, 2, terminalRemoteCount) + + var runningCount int64 + require.NoError(t, db.Model(&models.Activity{}).Where("id = ?", running.ID).Count(&runningCount).Error) + require.EqualValues(t, 1, runningCount) + + var oldCount int64 + require.NoError(t, db.Model(&models.Activity{}).Where("id = ?", oldActivity.ID).Count(&oldCount).Error) + require.Zero(t, oldCount) +} + +func receiveActivityEventInternal(t *testing.T, events <-chan activitytypes.StreamEvent) activitytypes.StreamEvent { + t.Helper() + + select { + case event := <-events: + return event + case <-time.After(time.Second): + t.Fatal("timed out waiting for activity event") + return activitytypes.StreamEvent{} + } +} diff --git a/backend/internal/services/container_service.go b/backend/internal/services/container_service.go index d0e5cd0cee..5469802375 100644 --- a/backend/internal/services/container_service.go +++ b/backend/internal/services/container_service.go @@ -118,6 +118,17 @@ func shouldStartRedeployedContainerInternal(containerInfo container.InspectRespo return shouldStart } +func writeContainerProgressInternal(ctx context.Context, message string, progress int, phase string) { + progressWriter, _ := ctx.Value(projects.ProgressWriterKey{}).(io.Writer) + if progressWriter == nil { + return + } + payload := fmt.Sprintf(`{"type":"container","phase":%q,"status":%q,"progressDetail":{"current":%d,"total":100}}`+"\n", phase, message, progress) + if _, err := progressWriter.Write([]byte(payload)); err != nil { + slog.DebugContext(ctx, "failed to write container progress", "phase", phase, "error", err) + } +} + func (s *ContainerService) pullRedeployImageInternal(ctx context.Context, dockerClient *client.Client, imageName, containerID, containerName string, user models.User) error { settings := s.settingsService.GetSettingsConfig() pullCtx, pullCancel := timeouts.WithTimeout(ctx, settings.DockerImagePullTimeout.AsInt(), timeouts.DefaultDockerImagePull) @@ -491,12 +502,14 @@ func (s *ContainerService) RedeployContainer(ctx context.Context, containerID st } if imageName != "" { + writeContainerProgressInternal(ctx, "Pulling latest container image", 20, "pull") if err := s.pullRedeployImageInternal(ctx, dockerClient, imageName, containerID, containerName, user); err != nil { return "", err } } backupName := buildRedeployBackupNameInternal(containerName, containerID) + writeContainerProgressInternal(ctx, "Preparing existing container", 45, "prepare") if err := s.prepareContainerForRedeployInternal(ctx, dockerClient, containerID, containerName, backupName, wasRunning, user); err != nil { return "", err } @@ -508,6 +521,7 @@ func (s *ContainerService) RedeployContainer(ctx context.Context, containerID st newConfig.Hostname = "" } + writeContainerProgressInternal(ctx, "Creating replacement container", 65, "create") createResp, err := libarcane.ContainerCreateWithCompatibilityForAPIVersion(ctx, dockerClient, client.ContainerCreateOptions{ Config: &newConfig, HostConfig: containerInfo.HostConfig, @@ -525,6 +539,7 @@ func (s *ContainerService) RedeployContainer(ctx context.Context, containerID st } if shouldStartRedeployedContainerInternal(containerInfo, wasRunning) { + writeContainerProgressInternal(ctx, "Starting replacement container", 80, "start") _, err = dockerClient.ContainerStart(ctx, createResp.ID, client.ContainerStartOptions{}) if err != nil { if _, removeErr := dockerClient.ContainerRemove(ctx, createResp.ID, client.ContainerRemoveOptions{Force: true}); removeErr != nil { @@ -566,6 +581,7 @@ func (s *ContainerService) RedeployContainer(ctx context.Context, containerID st slog.WarnContext(ctx, "failed to log deploy event", "err", logErr) } + writeContainerProgressInternal(ctx, "Container redeployed", 100, "complete") return createResp.ID, nil } diff --git a/backend/internal/services/image_update_service.go b/backend/internal/services/image_update_service.go index 984d6c7c1f..00745e2d64 100644 --- a/backend/internal/services/image_update_service.go +++ b/backend/internal/services/image_update_service.go @@ -30,6 +30,7 @@ type ImageUpdateService struct { dockerService *DockerClientService eventService *EventService notificationService *NotificationService + activityService *ActivityService } type ImageParts struct { @@ -46,7 +47,7 @@ type localImageSnapshot struct { AllDigests []string } -func NewImageUpdateService(db *database.DB, settingsService *SettingsService, registryService *ContainerRegistryService, dockerService *DockerClientService, eventService *EventService, notificationService *NotificationService) *ImageUpdateService { +func NewImageUpdateService(db *database.DB, settingsService *SettingsService, registryService *ContainerRegistryService, dockerService *DockerClientService, eventService *EventService, notificationService *NotificationService, activityService *ActivityService) *ImageUpdateService { return &ImageUpdateService{ db: db, settingsService: settingsService, @@ -54,19 +55,93 @@ func NewImageUpdateService(db *database.DB, settingsService *SettingsService, re dockerService: dockerService, eventService: eventService, notificationService: notificationService, + activityService: activityService, + } +} + +func (s *ImageUpdateService) startImageUpdateActivityInternal(ctx context.Context, resourceName string, count int) string { + if s.activityService == nil { + return "" + } + resourceType := "image" + if count > 1 { + resourceType = "images" + } + activity, err := s.activityService.StartActivity(ctx, StartActivityRequest{ + EnvironmentID: "0", + Type: models.ActivityTypeImageUpdateCheck, + ResourceType: &resourceType, + ResourceName: activityStringPtrForServiceInternal(resourceName), + Step: "Checking image updates", + LatestMessage: "Image update check started", + Metadata: models.JSON{ + "imageCount": count, + }, + }) + if err != nil { + slog.DebugContext(ctx, "failed to start image update activity", "error", err) + return "" + } + return activity.ID +} + +func (s *ImageUpdateService) appendImageUpdateActivityMessageInternal(ctx context.Context, activityID string, level models.ActivityMessageLevel, message string, progress int, step string) { + if s.activityService == nil || activityID == "" || strings.TrimSpace(message) == "" { + return + } + if level == "" { + level = models.ActivityMessageLevelInfo + } + if _, err := s.activityService.AppendMessage(ctx, activityID, AppendActivityMessageRequest{ + Level: level, + Message: message, + Progress: &progress, + Step: step, + }); err != nil { + slog.DebugContext(ctx, "failed to append image update activity message", "activityId", activityID, "error", err) + } +} + +func (s *ImageUpdateService) completeImageUpdateActivityInternal(ctx context.Context, activityID string, success bool, message string, progress int) { + if s.activityService == nil || activityID == "" { + return + } + status := models.ActivityStatusSuccess + var errMessage *string + if !success { + status = models.ActivityStatusFailed + errMessage = activityStringPtrForServiceInternal(message) + } + if message == "" { + message = "Image update check completed" + } + step := "Image update check complete" + if _, err := s.activityService.UpdateActivity(context.WithoutCancel(ctx), activityID, UpdateActivityRequest{ + Progress: &progress, + Step: &step, + }); err != nil { + slog.DebugContext(ctx, "failed to update image update activity progress", "activityId", activityID, "error", err) + } + if _, err := s.activityService.CompleteActivity(context.WithoutCancel(ctx), activityID, status, message, errMessage); err != nil { + slog.DebugContext(ctx, "failed to complete image update activity", "activityId", activityID, "error", err) } } func (s *ImageUpdateService) CheckImageUpdate(ctx context.Context, imageRef string) (*imageupdate.Response, error) { startTime := time.Now() + activityID := s.startImageUpdateActivityInternal(ctx, imageRef, 1) + s.appendImageUpdateActivityMessageInternal(ctx, activityID, models.ActivityMessageLevelInfo, fmt.Sprintf("Checking %s", imageRef), 20, "Checking remote digest") parts := s.parseImageReference(imageRef) if parts == nil { - return &imageupdate.Response{ + result := &imageupdate.Response{ Error: "Invalid image reference format", CheckTime: time.Now(), ResponseTimeMs: int(time.Since(startTime).Milliseconds()), - }, nil + ActivityID: activityStringPtrForServiceInternal(activityID), + } + s.completeImageUpdateActivityInternal(ctx, activityID, false, result.Error, 100) + return result, nil } digestResult, snapshot, err := s.checkDigestUpdateWithSnapshotInternal(ctx, parts) @@ -75,6 +150,7 @@ func (s *ImageUpdateService) CheckImageUpdate(ctx context.Context, imageRef stri Error: err.Error(), CheckTime: time.Now(), ResponseTimeMs: int(time.Since(startTime).Milliseconds()), + ActivityID: activityStringPtrForServiceInternal(activityID), } metadata := models.JSON{ "action": "check_update", @@ -88,10 +164,12 @@ func (s *ImageUpdateService) CheckImageUpdate(ctx context.Context, imageRef stri if saveErr := s.saveUpdateResultWithSnapshotInternal(ctx, imageRef, result, snapshot); saveErr != nil { slog.WarnContext(ctx, "Failed to save update result", "imageRef", imageRef, "error", saveErr.Error()) } + s.completeImageUpdateActivityInternal(ctx, activityID, false, result.Error, 100) return result, err } digestResult.ResponseTimeMs = int(time.Since(startTime).Milliseconds()) + digestResult.ActivityID = activityStringPtrForServiceInternal(activityID) metadata := models.JSON{ "action": "check_update", "imageRef": imageRef, @@ -115,6 +193,11 @@ func (s *ImageUpdateService) CheckImageUpdate(ctx context.Context, imageRef stri } } + finalMessage := "Image update check completed" + if digestResult.HasUpdate { + finalMessage = "Image update available" + } + s.completeImageUpdateActivityInternal(ctx, activityID, true, finalMessage, 100) return digestResult, nil } @@ -502,6 +585,22 @@ func countBatchResultOutcomesInternal(imageRefs []string, results map[string]*im return successCount, errorCount } +// imageCheckResultMessageInternal derives an activity message level and text from +// a per-image update check result: errors become ERROR, available updates become +// SUCCESS, and up-to-date images stay INFO. +func imageCheckResultMessageInternal(imageRef string, res *imageupdate.Response) (models.ActivityMessageLevel, string) { + if res == nil { + return models.ActivityMessageLevelError, fmt.Sprintf("%s: check failed", imageRef) + } + if err := strings.TrimSpace(res.Error); err != "" { + return models.ActivityMessageLevelError, fmt.Sprintf("%s: %s", imageRef, err) + } + if res.HasUpdate { + return models.ActivityMessageLevelSuccess, fmt.Sprintf("%s — update available", imageRef) + } + return models.ActivityMessageLevelInfo, fmt.Sprintf("%s — up to date", imageRef) +} + func extractRepoAndTagFromImage(dockerImage image.InspectResponse) (repo, tag string) { if len(dockerImage.RepoTags) > 0 && dockerImage.RepoTags[0] != ":" { if named, err := ref.ParseNormalizedNamed(dockerImage.RepoTags[0]); err == nil { @@ -889,29 +988,45 @@ func (s *ImageUpdateService) CheckMultipleImages(ctx context.Context, imageRefs return results, nil } + activityID := s.startImageUpdateActivityInternal(ctx, fmt.Sprintf("%d images", len(imageRefs)), len(imageRefs)) + s.appendImageUpdateActivityMessageInternal(ctx, activityID, models.ActivityMessageLevelInfo, fmt.Sprintf("Checking %d image references", len(imageRefs)), 5, "Preparing image update check") slog.DebugContext(ctx, "Starting batch image update check", "imageCount", len(imageRefs), "externalCredCount", len(externalCreds)) regRepos, initialResults, images := s.parseAndGroupImagesInternal(imageRefs) maps.Copy(results, initialResults) + for _, result := range initialResults { + if result != nil { + result.ActivityID = activityStringPtrForServiceInternal(activityID) + } + } resolvedCreds := s.resolveBatchCredentialsInternal(ctx, externalCreds) slog.DebugContext(ctx, "Resolved batch registry credentials", "credentialCount", len(resolvedCreds), "registryCount", len(regRepos)) var mu sync.Mutex + completed := 0 g, groupCtx := errgroup.WithContext(ctx) g.SetLimit(10) // Limit concurrency for _, img := range images { g.Go(func() error { res, snapshot := s.checkSingleImageInBatchInternal(groupCtx, resolvedCreds, img.parts) + if res != nil { + res.ActivityID = activityStringPtrForServiceInternal(activityID) + } mu.Lock() + completed++ + progress := 10 + int(float64(completed)/float64(len(images))*80) for _, ref := range img.refs { results[ref] = res } mu.Unlock() + level, message := imageCheckResultMessageInternal(img.canonicalRef, res) + s.appendImageUpdateActivityMessageInternal(groupCtx, activityID, level, message, progress, "Checking image") + if err := s.saveUpdateResultWithSnapshotInternal(groupCtx, img.canonicalRef, res, snapshot); err != nil { slog.WarnContext(groupCtx, "Failed to save update result", "imageRef", img.canonicalRef, "error", err.Error()) } @@ -924,6 +1039,9 @@ func (s *ImageUpdateService) CheckMultipleImages(ctx context.Context, imageRefs } successCount, errorCount := countBatchResultOutcomesInternal(imageRefs, results) + finalSuccess := errorCount == 0 + finalMessage := fmt.Sprintf("Image update check completed: %d checked, %d errors", successCount, errorCount) + s.completeImageUpdateActivityInternal(ctx, activityID, finalSuccess, finalMessage, 100) slog.InfoContext(ctx, "Batch image update check completed", "totalImages", len(imageRefs), "successCount", successCount, diff --git a/backend/internal/services/image_update_service_test.go b/backend/internal/services/image_update_service_test.go index 9bd57e4b26..945336fabf 100644 --- a/backend/internal/services/image_update_service_test.go +++ b/backend/internal/services/image_update_service_test.go @@ -521,7 +521,7 @@ func TestImageUpdateService_CheckImageUpdate_UsesRegistryFallback(t *testing.T) dockerService := &DockerClientService{client: newTestDockerClient(t, server)} eventService := NewEventService(db, nil, nil) - svc := NewImageUpdateService(db, nil, registryService, dockerService, eventService, nil) + svc := NewImageUpdateService(db, nil, registryService, dockerService, eventService, nil, nil) result, err := svc.CheckImageUpdate(context.Background(), imageRef) require.NoError(t, err) @@ -561,7 +561,7 @@ func TestImageUpdateService_CheckMultipleImages_UsesRegistryFallback(t *testing. dockerService := &DockerClientService{client: newTestDockerClient(t, server)} eventService := NewEventService(db, nil, nil) - svc := NewImageUpdateService(db, nil, registryService, dockerService, eventService, nil) + svc := NewImageUpdateService(db, nil, registryService, dockerService, eventService, nil, nil) results, err := svc.CheckMultipleImages(context.Background(), []string{imageRef}, nil) require.NoError(t, err) @@ -602,7 +602,7 @@ func TestImageUpdateService_CheckMultipleImages_PersistsRefScopedErrorsWhenLocal dockerService := &DockerClientService{client: newTestDockerClient(t, server)} eventService := NewEventService(db, nil, nil) - svc := NewImageUpdateService(db, nil, registryService, dockerService, eventService, nil) + svc := NewImageUpdateService(db, nil, registryService, dockerService, eventService, nil, nil) results, err := svc.CheckMultipleImages(context.Background(), []string{imageRef}, nil) require.NoError(t, err) @@ -630,7 +630,7 @@ func TestImageUpdateService_SaveUpdateResultWithSnapshotInternal_PersistsRegistr require.NoError(t, err) imageRef := serverURL.Host + "/library/nginx:alpine" - svc := NewImageUpdateService(db, nil, nil, &DockerClientService{client: newTestDockerClient(t, server)}, nil, nil) + svc := NewImageUpdateService(db, nil, nil, &DockerClientService{client: newTestDockerClient(t, server)}, nil, nil, nil) result := &imageupdate.Response{ HasUpdate: true, UpdateType: "digest", @@ -707,7 +707,7 @@ func TestImageUpdateService_MarkImageRefUpToDateAfterPull_ClearsMatchingRecordsA CheckTime: now, }).Error) - svc := NewImageUpdateService(db, nil, nil, &DockerClientService{client: newTestDockerClient(t, server)}, nil, nil) + svc := NewImageUpdateService(db, nil, nil, &DockerClientService{client: newTestDockerClient(t, server)}, nil, nil, nil) require.NoError(t, svc.MarkImageRefUpToDateAfterPull(context.Background(), imageRef)) diff --git a/backend/internal/services/project_service.go b/backend/internal/services/project_service.go index 6b332ee6d4..d9b1b9953a 100644 --- a/backend/internal/services/project_service.go +++ b/backend/internal/services/project_service.go @@ -550,6 +550,17 @@ func (s *ProjectService) pullAndReconcileImageInternal( return nil } +func writeProjectProgressInternal(ctx context.Context, message string, progress int, phase string) { + progressWriter, _ := ctx.Value(projects.ProgressWriterKey{}).(io.Writer) + if progressWriter == nil { + return + } + payload := fmt.Sprintf(`{"type":"project","phase":%q,"status":%q,"progressDetail":{"current":%d,"total":100}}`+"\n", phase, message, progress) + if _, err := progressWriter.Write([]byte(payload)); err != nil { + slog.DebugContext(ctx, "failed to write project progress", "phase", phase, "error", err) + } +} + func (s *ProjectService) UpdateProjectServices(ctx context.Context, projectID string, servicesToUpdate []string, user models.User) error { projectFromDb, err := s.GetProjectFromDatabaseByID(ctx, projectID) if err != nil { @@ -568,16 +579,19 @@ func (s *ProjectService) UpdateProjectServices(ctx context.Context, projectID st } // 3. Pull images for specific services + writeProjectProgressInternal(ctx, "Pulling updated service images", 20, "pull") if err := s.composePullSelectedServicesInternal(ctx, compProj, servicesToUpdate); err != nil { slog.WarnContext(ctx, "compose pull failed, continuing", "error", err) } // 4. Stop specific services + writeProjectProgressInternal(ctx, "Stopping selected services", 45, "stop") if err := projects.ComposeStop(ctx, compProj, servicesToUpdate); err != nil { slog.WarnContext(ctx, "compose stop failed, continuing", "error", err) } // 5. Up specific services + writeProjectProgressInternal(ctx, "Starting selected services", 70, "up") if err := projects.ComposeUp(ctx, compProj, servicesToUpdate, false, false); err != nil { if statusErr := s.updateProjectStatusandCountsInternal(ctx, projectID, models.ProjectStatusStopped); statusErr != nil { slog.ErrorContext(ctx, "UpdateProjectServices: failed to set stopped status after compose up failure", "projectID", projectID, "error", statusErr) @@ -586,9 +600,11 @@ func (s *ProjectService) UpdateProjectServices(ctx context.Context, projectID st } // 6. Finalize status + writeProjectProgressInternal(ctx, "Refreshing project status", 90, "status") if err := s.updateProjectStatusandCountsInternal(ctx, projectID, models.ProjectStatusRunning); err != nil { return err } + writeProjectProgressInternal(ctx, "Service update completed", 100, "complete") metadata := models.JSON{ "action": "update_services", @@ -1681,6 +1697,7 @@ func (s *ProjectService) DownProject(ctx context.Context, projectID string, user return fmt.Errorf("failed to update project status to stopping: %w", err) } + writeProjectProgressInternal(ctx, "Stopping project services", 45, "down") if err := projects.ComposeDown(ctx, proj, false); err != nil { _ = s.updateProjectStatusInternal(ctx, projectID, models.ProjectStatusRunning) return fmt.Errorf("failed to bring down project: %w", err) @@ -1695,7 +1712,12 @@ func (s *ProjectService) DownProject(ctx context.Context, projectID string, user slog.ErrorContext(ctx, "could not log project down action", "error", logErr) } - return s.updateProjectStatusandCountsInternal(ctx, projectID, models.ProjectStatusStopped) + writeProjectProgressInternal(ctx, "Refreshing project status", 90, "status") + if err := s.updateProjectStatusandCountsInternal(ctx, projectID, models.ProjectStatusStopped); err != nil { + return err + } + writeProjectProgressInternal(ctx, "Project stopped", 100, "complete") + return nil } func (s *ProjectService) CreateProject(ctx context.Context, name, composeContent string, envContent *string, user models.User) (*models.Project, error) { @@ -1756,11 +1778,13 @@ func (s *ProjectService) DestroyProject(ctx context.Context, projectID string, r "projectName", proj.Name, "projectPath", proj.Path) + writeProjectProgressInternal(ctx, "Stopping project before destroy", 25, "down") if err := s.DownProject(ctx, projectID, systemUser); err != nil { slog.WarnContext(ctx, "failed to bring down project", "error", err) } if removeVolumes { + writeProjectProgressInternal(ctx, "Removing project volumes", 55, "volumes") if compProj, _, lerr := s.loadComposeProjectForProjectInternal(ctx, proj); lerr == nil { if derr := projects.ComposeDown(ctx, compProj, true); derr != nil { slog.WarnContext(ctx, "failed to remove volumes", "error", derr) @@ -1771,6 +1795,7 @@ func (s *ProjectService) DestroyProject(ctx context.Context, projectID string, r } if removeFiles { + writeProjectProgressInternal(ctx, "Removing project files", 75, "files") slog.DebugContext(ctx, "Removing project files", "path", proj.Path) if err := os.RemoveAll(proj.Path); err != nil { slog.ErrorContext(ctx, "Failed to remove project files", "path", proj.Path, "error", err) @@ -1784,6 +1809,7 @@ func (s *ProjectService) DestroyProject(ctx context.Context, projectID string, r if err := s.db.WithContext(ctx).Delete(proj).Error; err != nil { return fmt.Errorf("failed to delete project from database: %w", err) } + writeProjectProgressInternal(ctx, "Project destroyed", 100, "complete") metadata := models.JSON{"action": "destroy", "projectID": projectID, "projectName": proj.Name, "removeFiles": removeFiles, "removeVolumes": removeVolumes} if logErr := s.eventService.LogProjectEvent(ctx, models.EventTypeProjectDelete, projectID, proj.Name, user.ID, user.Username, "0", metadata); logErr != nil { @@ -1810,7 +1836,15 @@ func (s *ProjectService) RedeployProject(ctx context.Context, projectID string, return &common.ArcaneSelfRedeployError{} } - if err := s.PullProjectImages(ctx, projectID, io.Discard, user, nil); err != nil { + progressWriter, _ := ctx.Value(projects.ProgressWriterKey{}).(io.Writer) + if progressWriter == nil { + progressWriter = io.Discard + } + if _, writeErr := progressWriter.Write([]byte(`{"type":"deploy","phase":"pull","status":"pulling project images"}` + "\n")); writeErr != nil { + slog.DebugContext(ctx, "failed to write redeploy pull progress", "error", writeErr) + } + + if err := s.PullProjectImages(ctx, projectID, progressWriter, user, nil); err != nil { slog.WarnContext(ctx, "failed to pull project images", "error", err) } @@ -1819,6 +1853,10 @@ func (s *ProjectService) RedeployProject(ctx context.Context, projectID string, slog.ErrorContext(ctx, "could not log project redeploy action", "error", logErr) } + if _, writeErr := progressWriter.Write([]byte(`{"type":"deploy","phase":"up","status":"starting project deployment"}` + "\n")); writeErr != nil { + slog.DebugContext(ctx, "failed to write redeploy deploy progress", "error", writeErr) + } + return s.DeployProject(ctx, projectID, user, nil) } @@ -2526,6 +2564,7 @@ func (s *ProjectService) RestartProject(ctx context.Context, projectID string, u return fmt.Errorf("failed to load compose project: %w", lerr) } + writeProjectProgressInternal(ctx, "Restarting project services", 55, "restart") if err := projects.ComposeRestart(ctx, compProj, nil); err != nil { _ = s.updateProjectStatusInternal(ctx, projectID, models.ProjectStatusRunning) return fmt.Errorf("failed to restart project: %w", err) @@ -2540,7 +2579,12 @@ func (s *ProjectService) RestartProject(ctx context.Context, projectID string, u slog.ErrorContext(ctx, "could not log project restart action", "error", logErr) } - return s.updateProjectStatusandCountsInternal(ctx, projectID, models.ProjectStatusRunning) + writeProjectProgressInternal(ctx, "Refreshing project status", 90, "status") + if err := s.updateProjectStatusandCountsInternal(ctx, projectID, models.ProjectStatusRunning); err != nil { + return err + } + writeProjectProgressInternal(ctx, "Project restarted", 100, "complete") + return nil } func (s *ProjectService) UpdateProject(ctx context.Context, projectID string, name *string, composeContent, envContent *string, user models.User) (*models.Project, error) { diff --git a/backend/internal/services/project_service_test.go b/backend/internal/services/project_service_test.go index ee71f9ce37..1a68de075a 100644 --- a/backend/internal/services/project_service_test.go +++ b/backend/internal/services/project_service_test.go @@ -561,7 +561,7 @@ func TestProjectService_PullProjectImages_UpdatesCurrentImageRecordAfterPull(t * dockerService := &DockerClientService{client: newTestDockerClient(t, server)} eventService := NewEventService(db, nil, nil) - imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil) + imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil, nil) imageService := NewImageService(db, dockerService, nil, imageUpdateService, nil, eventService) svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, config.Load()) @@ -656,7 +656,7 @@ func TestProjectService_EnsureImagesPresent_UpdatesCurrentImageRecordAfterPull(t dockerService := &DockerClientService{client: newTestDockerClient(t, server)} eventService := NewEventService(db, nil, nil) - imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil) + imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil, nil) imageService := NewImageService(db, dockerService, nil, imageUpdateService, nil, eventService) svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, config.Load()) @@ -708,7 +708,7 @@ func TestProjectService_PullImageForService_UpdatesCurrentImageRecordAfterPull(t dockerService := &DockerClientService{client: newTestDockerClient(t, server)} eventService := NewEventService(db, nil, nil) - imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil) + imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil, nil) imageService := NewImageService(db, dockerService, nil, imageUpdateService, nil, eventService) svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, config.Load()) @@ -757,7 +757,7 @@ func TestProjectService_ComposePullSelectedServicesInternal_ReconcilesOnlyOnSucc dockerService := &DockerClientService{client: newTestDockerClient(t, server)} eventService := NewEventService(db, nil, nil) - imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil) + imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil, nil) imageService := NewImageService(db, dockerService, nil, imageUpdateService, nil, eventService) svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, config.Load()) @@ -835,7 +835,7 @@ func TestProjectService_ComposePullSelectedServicesInternal_LeavesRecordsWhenPul repository := "registry.example.com/team/app" dockerService := &DockerClientService{} - imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil) + imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil, nil) imageService := NewImageService(db, dockerService, nil, imageUpdateService, nil, NewEventService(db, nil, nil)) svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, config.Load()) diff --git a/backend/internal/services/service_helpers.go b/backend/internal/services/service_helpers.go new file mode 100644 index 0000000000..684e6a2eca --- /dev/null +++ b/backend/internal/services/service_helpers.go @@ -0,0 +1,9 @@ +package services + +func activityStringPtrForServiceInternal(value string) *string { + if value == "" { + return nil + } + out := value + return &out +} diff --git a/backend/internal/services/settings_service.go b/backend/internal/services/settings_service.go index 48dad819d2..117d1b6d93 100644 --- a/backend/internal/services/settings_service.go +++ b/backend/internal/services/settings_service.go @@ -106,6 +106,8 @@ func DefaultSettingsConfig() *models.Settings { PollingInterval: models.SettingVariable{Value: "0 0 * * * *"}, DockerClientRefreshInterval: models.SettingVariable{Value: "*/30 * * * * *"}, EventCleanupInterval: models.SettingVariable{Value: "0 0 */6 * * *"}, + ActivityHistoryRetentionDays: models.SettingVariable{Value: "30"}, + ActivityHistoryMaxEntries: models.SettingVariable{Value: "1000"}, AutoInjectEnv: models.SettingVariable{Value: "false"}, PruneMode: models.SettingVariable{Value: "dangling"}, //nolint:staticcheck // Legacy prune setting is still seeded for migration compatibility. DefaultDeployPullPolicy: models.SettingVariable{Value: "missing"}, diff --git a/backend/internal/services/system_service.go b/backend/internal/services/system_service.go index f99bd5ab8d..0aeeda68c4 100644 --- a/backend/internal/services/system_service.go +++ b/backend/internal/services/system_service.go @@ -28,6 +28,7 @@ type SystemService struct { volumeService *VolumeService networkService *NetworkService settingsService *SettingsService + activityService *ActivityService } func NewSystemService( @@ -38,6 +39,7 @@ func NewSystemService( volumeService *VolumeService, networkService *NetworkService, settingsService *SettingsService, + activityService *ActivityService, ) *SystemService { return &SystemService{ db: db, @@ -47,6 +49,7 @@ func NewSystemService( volumeService: volumeService, networkService: networkService, settingsService: settingsService, + activityService: activityService, } } @@ -54,7 +57,7 @@ var systemUser = models.User{ Username: "System", } -func (s *SystemService) PruneAll(ctx context.Context, req system.PruneAllRequest) (*system.PruneAllResult, error) { +func (s *SystemService) PruneAll(ctx context.Context, environmentID string, req system.PruneAllRequest) (*system.PruneAllResult, error) { slog.InfoContext(ctx, "Starting selective prune operation", "containers", req.Containers, "images", req.Images, @@ -63,11 +66,31 @@ func (s *SystemService) PruneAll(ctx context.Context, req system.PruneAllRequest "build_cache", req.BuildCache, ) - result := &system.PruneAllResult{Success: true} + activityID := s.startSystemPruneActivityInternal(ctx, environmentID, req) + result := &system.PruneAllResult{Success: true, ActivityID: activityStringPtrForServiceInternal(activityID)} + s.runSystemPruneInternal(ctx, req, activityID, result) + + return result, nil +} + +func (s *SystemService) StartPruneAll(ctx context.Context, environmentID string, req system.PruneAllRequest) *system.PruneAllResult { + activityID := s.startSystemPruneActivityInternal(ctx, environmentID, req) + backgroundCtx := context.WithoutCancel(ctx) + + go func() { + result := &system.PruneAllResult{Success: true, ActivityID: activityStringPtrForServiceInternal(activityID)} + s.runSystemPruneInternal(backgroundCtx, req, activityID, result) + }() + + return &system.PruneAllResult{Success: true, ActivityID: activityStringPtrForServiceInternal(activityID)} +} + +func (s *SystemService) runSystemPruneInternal(ctx context.Context, req system.PruneAllRequest, activityID string, result *system.PruneAllResult) { var mu sync.Mutex // 1. Prune Containers first (sequential) as it may free up other resources if req.Containers != nil && req.Containers.Mode != system.PruneContainerModeNone { + s.appendSystemPruneActivityMessageInternal(ctx, activityID, "Pruning containers", 15) slog.InfoContext(ctx, "Pruning containers...", "mode", req.Containers.Mode, "until", req.Containers.Until) if err := s.pruneContainersInternal(ctx, *req.Containers, result); err != nil { result.Errors = append(result.Errors, fmt.Sprintf("Container pruning failed: %v", err)) @@ -80,6 +103,7 @@ func (s *SystemService) PruneAll(ctx context.Context, req system.PruneAllRequest if req.Images != nil && req.Images.Mode != system.PruneImageModeNone { g.Go(func() error { + s.appendSystemPruneActivityMessageInternal(groupCtx, activityID, "Pruning images", 40) slog.InfoContext(groupCtx, "Pruning images...", "mode", req.Images.Mode, "until", req.Images.Until) localResult := &system.PruneAllResult{} if err := s.pruneImagesInternal(groupCtx, *req.Images, localResult); err != nil { @@ -100,6 +124,7 @@ func (s *SystemService) PruneAll(ctx context.Context, req system.PruneAllRequest if req.BuildCache != nil && req.BuildCache.Mode != system.PruneBuildCacheModeNone { g.Go(func() error { + s.appendSystemPruneActivityMessageInternal(groupCtx, activityID, "Pruning build cache", 45) slog.InfoContext(groupCtx, "Pruning build cache...", "mode", req.BuildCache.Mode, "until", req.BuildCache.Until) localResult := &system.PruneAllResult{} if err := s.pruneBuildCacheInternal(groupCtx, *req.BuildCache, localResult); err != nil { @@ -117,6 +142,7 @@ func (s *SystemService) PruneAll(ctx context.Context, req system.PruneAllRequest if req.Volumes != nil && req.Volumes.Mode != system.PruneVolumeModeNone { g.Go(func() error { + s.appendSystemPruneActivityMessageInternal(groupCtx, activityID, "Pruning volumes", 55) slog.InfoContext(groupCtx, "Pruning volumes...", "mode", req.Volumes.Mode) localResult := &system.PruneAllResult{} if err := s.pruneVolumesInternal(groupCtx, *req.Volumes, localResult); err != nil { @@ -137,6 +163,7 @@ func (s *SystemService) PruneAll(ctx context.Context, req system.PruneAllRequest if req.Networks != nil && req.Networks.Mode != system.PruneNetworkModeNone { g.Go(func() error { + s.appendSystemPruneActivityMessageInternal(groupCtx, activityID, "Pruning networks", 65) slog.InfoContext(groupCtx, "Pruning networks...", "mode", req.Networks.Mode, "until", req.Networks.Until) localResult := &system.PruneAllResult{} if err := s.pruneNetworksInternal(groupCtx, *req.Networks, localResult); err != nil { @@ -158,8 +185,68 @@ func (s *SystemService) PruneAll(ctx context.Context, req system.PruneAllRequest } slog.InfoContext(ctx, "Selective prune operation completed", "success", result.Success, "containers_pruned", len(result.ContainersPruned), "images_deleted", len(result.ImagesDeleted), "volumes_deleted", len(result.VolumesDeleted), "networks_deleted", len(result.NetworksDeleted), "space_reclaimed", result.SpaceReclaimed, "error_count", len(result.Errors)) + s.completeSystemPruneActivityInternal(ctx, activityID, result) +} - return result, nil +func (s *SystemService) startSystemPruneActivityInternal(ctx context.Context, environmentID string, req system.PruneAllRequest) string { + if s.activityService == nil { + return "" + } + resourceType := "system" + resourceName := "Docker resources" + activity, err := s.activityService.StartActivity(ctx, StartActivityRequest{ + EnvironmentID: environmentID, + Type: models.ActivityTypeSystemPrune, + ResourceType: &resourceType, + ResourceName: &resourceName, + Step: "Preparing prune", + LatestMessage: "System prune started", + Metadata: models.JSON{ + "containers": req.Containers, + "images": req.Images, + "volumes": req.Volumes, + "networks": req.Networks, + "buildCache": req.BuildCache, + }, + }) + if err != nil { + slog.DebugContext(ctx, "failed to start system prune activity", "error", err) + return "" + } + return activity.ID +} + +func (s *SystemService) appendSystemPruneActivityMessageInternal(ctx context.Context, activityID, message string, progress int) { + if s.activityService == nil || activityID == "" { + return + } + if _, err := s.activityService.AppendMessage(ctx, activityID, AppendActivityMessageRequest{ + Level: models.ActivityMessageLevelInfo, + Message: message, + Progress: &progress, + Step: message, + }); err != nil { + slog.DebugContext(ctx, "failed to append system prune activity message", "activityId", activityID, "error", err) + } +} + +func (s *SystemService) completeSystemPruneActivityInternal(ctx context.Context, activityID string, result *system.PruneAllResult) { + if s.activityService == nil || activityID == "" || result == nil { + return + } + + status := models.ActivityStatusSuccess + message := "System prune completed" + var errMessage *string + if !result.Success || len(result.Errors) > 0 { + status = models.ActivityStatusFailed + message = "System prune completed with errors" + joined := strings.Join(result.Errors, "; ") + errMessage = &joined + } + if _, err := s.activityService.CompleteActivity(context.WithoutCancel(ctx), activityID, status, message, errMessage); err != nil { + slog.DebugContext(ctx, "failed to complete system prune activity", "activityId", activityID, "error", err) + } } func (s *SystemService) performBatchContainerAction(ctx context.Context, containers []container.Summary, actionName string, shouldProcess func(container.Summary) bool, action func(context.Context, string) error) *containertypes.ActionResult { @@ -201,55 +288,116 @@ func (s *SystemService) performBatchContainerAction(ctx context.Context, contain return result } -func (s *SystemService) StartAllContainers(ctx context.Context) (*containertypes.ActionResult, error) { +func (s *SystemService) StartAllContainers(ctx context.Context, environmentID string) (*containertypes.ActionResult, error) { + activityID := s.startSystemContainerActivityInternal(ctx, environmentID, models.ActivityTypeContainerStart, "All containers", "Starting all containers") containers, _, _, _, err := s.dockerService.GetAllContainers(ctx) if err != nil { - return &containertypes.ActionResult{ - Success: false, - Errors: []string{fmt.Sprintf("Failed to list containers: %v", err)}, - }, err + result := &containertypes.ActionResult{ + Success: false, + Errors: []string{fmt.Sprintf("Failed to list containers: %v", err)}, + ActivityID: activityStringPtrForServiceInternal(activityID), + } + s.completeSystemContainerActivityInternal(ctx, activityID, "Starting all containers failed", result) + return result, err } - return s.performBatchContainerAction(ctx, containers, "start", + result := s.performBatchContainerAction(ctx, containers, "start", func(c container.Summary) bool { return c.State != "running" }, func(ctx context.Context, id string) error { return s.containerService.StartContainer(ctx, id, systemUser) - }), nil + }) + result.ActivityID = activityStringPtrForServiceInternal(activityID) + s.completeSystemContainerActivityInternal(ctx, activityID, "Started all containers", result) + return result, nil } -func (s *SystemService) StartAllStoppedContainers(ctx context.Context) (*containertypes.ActionResult, error) { +func (s *SystemService) StartAllStoppedContainers(ctx context.Context, environmentID string) (*containertypes.ActionResult, error) { + activityID := s.startSystemContainerActivityInternal(ctx, environmentID, models.ActivityTypeContainerStart, "Stopped containers", "Starting stopped containers") containers, _, _, _, err := s.dockerService.GetAllContainers(ctx) if err != nil { - return &containertypes.ActionResult{ - Success: false, - Errors: []string{fmt.Sprintf("Failed to list containers: %v", err)}, - }, err + result := &containertypes.ActionResult{ + Success: false, + Errors: []string{fmt.Sprintf("Failed to list containers: %v", err)}, + ActivityID: activityStringPtrForServiceInternal(activityID), + } + s.completeSystemContainerActivityInternal(ctx, activityID, "Starting stopped containers failed", result) + return result, err } - return s.performBatchContainerAction(ctx, containers, "start", + result := s.performBatchContainerAction(ctx, containers, "start", func(c container.Summary) bool { return c.State == "exited" }, func(ctx context.Context, id string) error { return s.containerService.StartContainer(ctx, id, systemUser) - }), nil + }) + result.ActivityID = activityStringPtrForServiceInternal(activityID) + s.completeSystemContainerActivityInternal(ctx, activityID, "Started stopped containers", result) + return result, nil } -func (s *SystemService) StopAllContainers(ctx context.Context) (*containertypes.ActionResult, error) { +func (s *SystemService) StopAllContainers(ctx context.Context, environmentID string) (*containertypes.ActionResult, error) { + activityID := s.startSystemContainerActivityInternal(ctx, environmentID, models.ActivityTypeContainerStop, "All containers", "Stopping all containers") containers, _, _, _, err := s.dockerService.GetAllContainers(ctx) if err != nil { - return &containertypes.ActionResult{ - Success: false, - Errors: []string{fmt.Sprintf("Failed to list containers: %v", err)}, - }, err + result := &containertypes.ActionResult{ + Success: false, + Errors: []string{fmt.Sprintf("Failed to list containers: %v", err)}, + ActivityID: activityStringPtrForServiceInternal(activityID), + } + s.completeSystemContainerActivityInternal(ctx, activityID, "Stopping all containers failed", result) + return result, err } - return s.performBatchContainerAction(ctx, containers, "stop", + result := s.performBatchContainerAction(ctx, containers, "stop", func(c container.Summary) bool { // Skip Arcane container return !libupdater.IsArcaneContainer(c.Labels) }, func(ctx context.Context, id string) error { return s.containerService.StopContainer(ctx, id, systemUser) - }), nil + }) + result.ActivityID = activityStringPtrForServiceInternal(activityID) + s.completeSystemContainerActivityInternal(ctx, activityID, "Stopped all containers", result) + return result, nil +} + +func (s *SystemService) startSystemContainerActivityInternal(ctx context.Context, environmentID string, activityType models.ActivityType, resourceName, message string) string { + if s.activityService == nil { + return "" + } + resourceType := "system" + activity, err := s.activityService.StartActivity(ctx, StartActivityRequest{ + EnvironmentID: environmentID, + Type: activityType, + ResourceType: &resourceType, + ResourceName: &resourceName, + Step: message, + LatestMessage: message, + Metadata: models.JSON{"scope": resourceName}, + }) + if err != nil { + slog.DebugContext(ctx, "failed to start system container activity", "type", activityType, "error", err) + return "" + } + return activity.ID +} + +func (s *SystemService) completeSystemContainerActivityInternal(ctx context.Context, activityID, successMessage string, result *containertypes.ActionResult) { + if s.activityService == nil || activityID == "" || result == nil { + return + } + + status := models.ActivityStatusSuccess + message := successMessage + var errMessage *string + if !result.Success || len(result.Errors) > 0 { + status = models.ActivityStatusFailed + message = strings.Join(result.Errors, "; ") + errMessage = &message + } + + if _, err := s.activityService.CompleteActivity(context.WithoutCancel(ctx), activityID, status, message, errMessage); err != nil { + slog.DebugContext(ctx, "failed to complete system container activity", "activityId", activityID, "error", err) + } } func (s *SystemService) pruneContainersInternal(ctx context.Context, options system.PruneContainersOptions, result *system.PruneAllResult) error { diff --git a/backend/internal/services/updater_service.go b/backend/internal/services/updater_service.go index 72b3d8cc60..45842a0538 100644 --- a/backend/internal/services/updater_service.go +++ b/backend/internal/services/updater_service.go @@ -22,6 +22,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/pkg/libarcane" + activitylib "github.com/getarcaneapp/arcane/backend/pkg/libarcane/activity" libupdater "github.com/getarcaneapp/arcane/backend/pkg/libarcane/updater" projectspkg "github.com/getarcaneapp/arcane/backend/pkg/projects" arcRegistry "github.com/getarcaneapp/arcane/backend/pkg/utils/registry" @@ -41,6 +42,7 @@ type UpdaterService struct { imageService *ImageService notificationService *NotificationService upgradeService selfUpgradeService + activityService *ActivityService statusMu sync.RWMutex updatingContainers map[string]bool @@ -64,6 +66,7 @@ func NewUpdaterService( imageSvc *ImageService, notifications *NotificationService, upgrade selfUpgradeService, + activityService *ActivityService, ) *UpdaterService { return &UpdaterService{ db: db, @@ -76,6 +79,7 @@ func NewUpdaterService( imageService: imageSvc, notificationService: notifications, upgradeService: upgrade, + activityService: activityService, updatingContainers: map[string]bool{}, updatingProjects: map[string]bool{}, } @@ -86,9 +90,17 @@ func NewUpdaterService( // actions without mutating containers or projects. // //nolint:gocognit -func (s *UpdaterService) ApplyPending(ctx context.Context, dryRun bool) (*updater.Result, error) { +func (s *UpdaterService) ApplyPending(ctx context.Context, dryRun bool) (out *updater.Result, err error) { start := time.Now() - out := &updater.Result{Items: []updater.ResourceResult{}} + out = &updater.Result{Items: []updater.ResourceResult{}} + activityID := s.startAutoUpdateActivityInternal(ctx, dryRun) + out.ActivityID = activityStringPtrForServiceInternal(activityID) + defer func() { + if out != nil { + out.ActivityID = activityStringPtrForServiceInternal(activityID) + } + s.completeAutoUpdateActivityInternal(ctx, activityID, out, err) + }() var records []models.ImageUpdateRecord if err := s.db.WithContext(ctx).Where("has_update = ?", true).Find(&records).Error; err != nil { @@ -96,6 +108,7 @@ func (s *UpdaterService) ApplyPending(ctx context.Context, dryRun bool) (*update } // debug: how many pending records and dryRun flag slog.DebugContext(ctx, "ApplyPending: found pending image update records", "records", len(records), "dryRun", dryRun) + s.appendAutoUpdateActivityMessageInternal(ctx, activityID, fmt.Sprintf("Found %d pending image update records", len(records)), 10) if len(records) == 0 { out.Duration = time.Since(start).String() @@ -145,6 +158,7 @@ func (s *UpdaterService) ApplyPending(ctx context.Context, dryRun bool) (*update } if len(plans) == 0 { + s.appendAutoUpdateActivityMessageInternal(ctx, activityID, "No active resources need updates", 100) out.Duration = time.Since(start).String() return out, nil } @@ -171,6 +185,7 @@ func (s *UpdaterService) ApplyPending(ctx context.Context, dryRun bool) (*update for i := range plans { p := plans[i] + s.appendAutoUpdateActivityMessageInternal(ctx, activityID, fmt.Sprintf("Checking update for %s", p.oldRef), 20) item := updater.ResourceResult{ ResourceID: p.oldRef, ResourceType: "image", @@ -224,7 +239,8 @@ func (s *UpdaterService) ApplyPending(ctx context.Context, dryRun bool) (*update } if !skipPull { - if err := s.imageService.PullImage(ctx, p.newRef, io.Discard, systemUser, nil); err != nil { + progressWriter := activitylib.NewWriter(ctx, s.activityService, activityID, io.Discard, "Pulling updated images") + if err := s.imageService.PullImage(ctx, p.newRef, progressWriter, systemUser, nil); err != nil { item.Status = "failed" item.Error = err.Error() out.Failed++ @@ -268,6 +284,7 @@ func (s *UpdaterService) ApplyPending(ctx context.Context, dryRun bool) (*update } if !dryRun && (len(oldIDToNewRef) > 0 || len(oldRefToNewRef) > 0) { + s.appendAutoUpdateActivityMessageInternal(ctx, activityID, "Restarting containers that use updated images", 75) results, err := s.restartContainersUsingOldIDs(ctx, oldIDToNewRef, oldRefToNewRef) if err != nil { slog.Warn("container restarts had errors", "err", err) @@ -318,6 +335,7 @@ func (s *UpdaterService) ApplyPending(ctx context.Context, dryRun bool) (*update } if !dryRun && len(oldIDSet) > 0 { + s.appendAutoUpdateActivityMessageInternal(ctx, activityID, "Pruning old image IDs", 90) ids := make([]string, 0, len(oldIDSet)) for id := range oldIDSet { ids = append(ids, id) @@ -384,13 +402,105 @@ func (s *UpdaterService) ApplyPending(ctx context.Context, dryRun bool) (*update return out, nil } +func (s *UpdaterService) startAutoUpdateActivityInternal(ctx context.Context, dryRun bool) string { + if s.activityService == nil { + return "" + } + resourceType := "system" + resourceName := "Auto update" + activity, err := s.activityService.StartActivity(ctx, StartActivityRequest{ + EnvironmentID: "0", + Type: models.ActivityTypeAutoUpdate, + ResourceType: &resourceType, + ResourceName: &resourceName, + Step: "Planning updates", + LatestMessage: "Auto-update run started", + Metadata: models.JSON{"dryRun": dryRun}, + }) + if err != nil { + slog.DebugContext(ctx, "failed to start auto-update activity", "error", err) + return "" + } + return activity.ID +} + +func (s *UpdaterService) startSingleContainerUpdateActivityInternal(ctx context.Context, containerID string) string { + if s.activityService == nil { + return "" + } + resourceType := "container" + resourceName := containerID + activity, err := s.activityService.StartActivity(ctx, StartActivityRequest{ + EnvironmentID: "0", + Type: models.ActivityTypeAutoUpdate, + ResourceType: &resourceType, + ResourceID: &containerID, + ResourceName: &resourceName, + Step: "Updating container", + LatestMessage: "Container update started", + Metadata: models.JSON{"containerID": containerID}, + }) + if err != nil { + slog.DebugContext(ctx, "failed to start container update activity", "containerID", containerID, "error", err) + return "" + } + return activity.ID +} + +func (s *UpdaterService) appendAutoUpdateActivityMessageInternal(ctx context.Context, activityID, message string, progress int) { + if s.activityService == nil || activityID == "" { + return + } + if _, err := s.activityService.AppendMessage(ctx, activityID, AppendActivityMessageRequest{ + Level: models.ActivityMessageLevelInfo, + Message: message, + Progress: &progress, + Step: message, + }); err != nil { + slog.DebugContext(ctx, "failed to append auto-update activity message", "activityId", activityID, "error", err) + } +} + +func (s *UpdaterService) completeAutoUpdateActivityInternal(ctx context.Context, activityID string, result *updater.Result, applyErr error) { + if s.activityService == nil || activityID == "" { + return + } + + status := models.ActivityStatusSuccess + message := "Auto-update run completed" + var errMessage *string + if applyErr != nil { + status = models.ActivityStatusFailed + errText := applyErr.Error() + errMessage = &errText + message = errText + } else if result != nil && result.Failed > 0 { + status = models.ActivityStatusFailed + errText := fmt.Sprintf("%d update action(s) failed", result.Failed) + errMessage = &errText + message = errText + } + + if _, err := s.activityService.CompleteActivity(context.WithoutCancel(ctx), activityID, status, message, errMessage); err != nil { + slog.DebugContext(ctx, "failed to complete auto-update activity", "activityId", activityID, "error", err) + } +} + // UpdateSingleContainer updates a single container by ID to the latest available image. // It pulls the new image, stops the container, removes it, and recreates it with the new image. // //nolint:gocognit // single-container update flow is intentionally linear with explicit early exits for failure reporting -func (s *UpdaterService) UpdateSingleContainer(ctx context.Context, containerID string) (*updater.Result, error) { +func (s *UpdaterService) UpdateSingleContainer(ctx context.Context, containerID string) (out *updater.Result, err error) { start := time.Now() - out := &updater.Result{Items: []updater.ResourceResult{}} + out = &updater.Result{Items: []updater.ResourceResult{}} + activityID := s.startSingleContainerUpdateActivityInternal(ctx, containerID) + out.ActivityID = activityStringPtrForServiceInternal(activityID) + defer func() { + if out != nil { + out.ActivityID = activityStringPtrForServiceInternal(activityID) + } + s.completeAutoUpdateActivityInternal(ctx, activityID, out, err) + }() slog.InfoContext(ctx, "UpdateSingleContainer: starting", "containerID", containerID) dcli, err := s.dockerService.GetClient(ctx) if err != nil { diff --git a/backend/internal/services/vulnerability_service.go b/backend/internal/services/vulnerability_service.go index 81ec59f95a..a3dfca35b7 100644 --- a/backend/internal/services/vulnerability_service.go +++ b/backend/internal/services/vulnerability_service.go @@ -76,6 +76,7 @@ type VulnerabilityService struct { eventService *EventService settingsService *SettingsService notificationService *NotificationService + activityService *ActivityService // scanLocks provides per-image locking to allow concurrent scans of different images // while preventing duplicate scans of the same image scanLocks sync.Map // map[string]*sync.Mutex @@ -227,13 +228,14 @@ func (s *VulnerabilityService) getTrivyScanSlotChannelInternal(limit int) chan i } // NewVulnerabilityService creates a new VulnerabilityService instance -func NewVulnerabilityService(db *database.DB, dockerService *DockerClientService, eventService *EventService, settingsService *SettingsService, notificationService *NotificationService) *VulnerabilityService { +func NewVulnerabilityService(db *database.DB, dockerService *DockerClientService, eventService *EventService, settingsService *SettingsService, notificationService *NotificationService, activityService *ActivityService) *VulnerabilityService { return &VulnerabilityService{ db: db, dockerService: dockerService, eventService: eventService, settingsService: settingsService, notificationService: notificationService, + activityService: activityService, trivyScanSlots: nil, } } @@ -268,14 +270,16 @@ func (s *VulnerabilityService) ScanImage(ctx context.Context, envID string, imag "imageId", imageID, "imageName", imageName, ) + activityID := s.startVulnerabilityScanActivityInternal(scanCtx, envID, imageID, imageName, &user) s.setScanPhaseInternal(imageID, vulnerability.ScanPhaseCreatingContainer) // Create pending scan record pendingResult := &vulnerability.ScanResult{ - ImageID: imageID, - ImageName: imageName, - ScanTime: time.Now(), - Status: vulnerability.ScanStatusScanning, + ImageID: imageID, + ImageName: imageName, + ScanTime: time.Now(), + Status: vulnerability.ScanStatusScanning, + ActivityID: activityStringPtrForServiceInternal(activityID), } s.applyScanPhaseToResultInternal(pendingResult) if saveErr := s.saveScanResultWithRetryInternal(scanCtx, pendingResult, defaultScanSaveRetryAttempts, defaultScanSaveRetryDelay); saveErr != nil { @@ -294,9 +298,9 @@ func (s *VulnerabilityService) ScanImage(ctx context.Context, envID string, imag ) } - go func(bgCtx context.Context, scanEnvID, imgID, imgName string, scanUser models.User) { - s.scanImageInBackgroundInternal(bgCtx, scanEnvID, imgID, imgName, scanUser) - }(scanCtx, envID, imageID, imageName, user) + go func(bgCtx context.Context, scanEnvID, imgID, imgName string, scanUser models.User, scanActivityID string) { + s.scanImageInBackgroundInternal(bgCtx, scanEnvID, imgID, imgName, scanUser, scanActivityID) + }(scanCtx, envID, imageID, imageName, user, activityID) return pendingResult, nil } @@ -337,9 +341,10 @@ func (s *VulnerabilityService) markStaleScanIfNeeded(ctx context.Context, record return true } -func (s *VulnerabilityService) scanImageInBackgroundInternal(ctx context.Context, envID string, imageID, imageName string, user models.User) { +func (s *VulnerabilityService) scanImageInBackgroundInternal(ctx context.Context, envID string, imageID, imageName string, user models.User, activityID string) { defer s.clearScanPhaseInternal(imageID) s.setScanPhaseInternal(imageID, vulnerability.ScanPhaseCreatingContainer) + s.updateVulnerabilityScanActivityInternal(ctx, activityID, vulnerability.ScanPhaseCreatingContainer, 10, "Preparing vulnerability scanner") slog.DebugContext(ctx, "starting background vulnerability scan", @@ -351,12 +356,14 @@ func (s *VulnerabilityService) scanImageInBackgroundInternal(ctx context.Context trivyImage, err := s.ensureTrivyImageInternal(ctx) if err != nil { s.setScanPhaseInternal(imageID, vulnerability.ScanPhaseStoringResults) + s.updateVulnerabilityScanActivityInternal(ctx, activityID, vulnerability.ScanPhaseStoringResults, 90, "Unable to prepare vulnerability scanner") result := &vulnerability.ScanResult{ - ImageID: imageID, - ImageName: imageName, - ScanTime: time.Now(), - Status: vulnerability.ScanStatusFailed, - Error: fmt.Sprintf("Trivy scanner is not available: %s", err.Error()), + ImageID: imageID, + ImageName: imageName, + ScanTime: time.Now(), + Status: vulnerability.ScanStatusFailed, + ActivityID: activityStringPtrForServiceInternal(activityID), + Error: fmt.Sprintf("Trivy scanner is not available: %s", err.Error()), } if saveErr := s.saveScanResultWithRetryInternal(ctx, result, defaultScanSaveRetryAttempts, defaultScanSaveRetryDelay); saveErr != nil { slog.WarnContext(ctx, "failed to save scan result", "error", saveErr) @@ -368,22 +375,27 @@ func (s *VulnerabilityService) scanImageInBackgroundInternal(ctx context.Context "imageName", imageName, "error", result.Error, ) + s.completeVulnerabilityScanActivityInternal(ctx, activityID, false, result.Error) return } + s.setScanPhaseInternal(imageID, vulnerability.ScanPhaseScanningImage) + s.updateVulnerabilityScanActivityInternal(ctx, activityID, vulnerability.ScanPhaseScanningImage, 45, "Scanning image for vulnerabilities") startTime := time.Now() result, err := s.runTrivyScan(ctx, trivyImage, imageName, imageID) duration := time.Since(startTime).Milliseconds() if err != nil { s.setScanPhaseInternal(imageID, vulnerability.ScanPhaseStoringResults) + s.updateVulnerabilityScanActivityInternal(ctx, activityID, vulnerability.ScanPhaseStoringResults, 90, "Storing failed scan result") failedResult := &vulnerability.ScanResult{ - ImageID: imageID, - ImageName: imageName, - ScanTime: time.Now(), - Status: vulnerability.ScanStatusFailed, - Error: err.Error(), - Duration: duration, + ImageID: imageID, + ImageName: imageName, + ScanTime: time.Now(), + Status: vulnerability.ScanStatusFailed, + ActivityID: activityStringPtrForServiceInternal(activityID), + Error: err.Error(), + Duration: duration, } if saveErr := s.saveScanResultWithRetryInternal(ctx, failedResult, defaultScanSaveRetryAttempts, defaultScanSaveRetryDelay); saveErr != nil { slog.WarnContext(ctx, "failed to save failed scan result", "error", saveErr) @@ -397,12 +409,15 @@ func (s *VulnerabilityService) scanImageInBackgroundInternal(ctx context.Context "error", err, ) s.logScanEvent(ctx, envID, imageID, imageName, user, false, err.Error()) + s.completeVulnerabilityScanActivityInternal(ctx, activityID, false, err.Error()) return } result.Duration = duration + result.ActivityID = activityStringPtrForServiceInternal(activityID) s.ensureSummary(result) s.setScanPhaseInternal(imageID, vulnerability.ScanPhaseStoringResults) + s.updateVulnerabilityScanActivityInternal(ctx, activityID, vulnerability.ScanPhaseStoringResults, 90, "Storing vulnerability results") if saveErr := s.saveScanResultWithRetryInternal(ctx, result, defaultScanSaveRetryAttempts, defaultScanSaveRetryDelay); saveErr != nil { slog.WarnContext(ctx, "failed to save scan result", "error", saveErr) } @@ -417,6 +432,69 @@ func (s *VulnerabilityService) scanImageInBackgroundInternal(ctx context.Context ) s.logScanEvent(ctx, envID, imageID, imageName, user, true, "") + s.completeVulnerabilityScanActivityInternal(ctx, activityID, true, "") +} + +func (s *VulnerabilityService) startVulnerabilityScanActivityInternal(ctx context.Context, envID, imageID, imageName string, user *models.User) string { + if s.activityService == nil { + return "" + } + + resourceType := "image" + progress := 0 + activity, err := s.activityService.StartActivity(ctx, StartActivityRequest{ + EnvironmentID: envID, + Type: models.ActivityTypeVulnerabilityScan, + ResourceType: &resourceType, + ResourceID: &imageID, + ResourceName: &imageName, + StartedBy: user, + Progress: &progress, + Step: string(vulnerability.ScanPhaseCreatingContainer), + LatestMessage: "Vulnerability scan queued", + }) + if err != nil { + slog.WarnContext(ctx, "failed to create vulnerability scan activity", "error", err, "imageId", imageID) + return "" + } + return activity.ID +} + +func (s *VulnerabilityService) updateVulnerabilityScanActivityInternal(ctx context.Context, activityID string, phase vulnerability.ScanPhase, progress int, message string) { + if s.activityService == nil || activityID == "" { + return + } + + if _, err := s.activityService.AppendMessage(ctx, activityID, AppendActivityMessageRequest{ + Level: models.ActivityMessageLevelInfo, + Message: message, + Progress: &progress, + Step: string(phase), + }); err != nil { + slog.DebugContext(ctx, "failed to append vulnerability scan activity message", "activityId", activityID, "error", err) + } +} + +func (s *VulnerabilityService) completeVulnerabilityScanActivityInternal(ctx context.Context, activityID string, success bool, errMessage string) { + if s.activityService == nil || activityID == "" { + return + } + + status := models.ActivityStatusSuccess + message := "Vulnerability scan completed" + var errorPtr *string + if !success { + status = models.ActivityStatusFailed + message = "Vulnerability scan failed" + if strings.TrimSpace(errMessage) != "" { + errorPtr = &errMessage + message = errMessage + } + } + + if _, err := s.activityService.CompleteActivity(context.WithoutCancel(ctx), activityID, status, message, errorPtr); err != nil { + slog.DebugContext(ctx, "failed to complete vulnerability scan activity", "activityId", activityID, "error", err) + } } // GetScanResult retrieves the most recent scan result for an image diff --git a/backend/pkg/fswatch/watcher.go b/backend/pkg/fswatch/watcher.go index a661f18d33..ef1c4c7c2d 100644 --- a/backend/pkg/fswatch/watcher.go +++ b/backend/pkg/fswatch/watcher.go @@ -257,11 +257,7 @@ func (fw *Watcher) addExistingDirectoriesRecursiveInternal(path string, ancestor return nil } - if err := fw.watcher.Add(path); err != nil { - slog.Warn("Failed to add directory to watcher", - "path", path, - "error", err) - } + fw.addWatchPathInternal(path) if fw.maxDepth > 0 && depth == fw.maxDepth { return nil @@ -286,6 +282,25 @@ func (fw *Watcher) addExistingDirectoriesRecursiveInternal(path string, ancestor return nil } +func (fw *Watcher) addWatchPathInternal(path string) { + watchPaths := []string{path} + if fw.followSymlinks { + if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 { + if resolvedPath, resolveErr := filepath.EvalSymlinks(path); resolveErr == nil && resolvedPath != path { + watchPaths = append(watchPaths, resolvedPath) + } + } + } + + for _, watchPath := range watchPaths { + if err := fw.watcher.Add(watchPath); err != nil { + slog.Warn("Failed to add directory to watcher", + "path", watchPath, + "error", err) + } + } +} + func (fw *Watcher) dirDepth(path string) int { cleanRoot := fw.watchedPath cleanPath := filepath.Clean(path) diff --git a/backend/pkg/libarcane/activity/handler.go b/backend/pkg/libarcane/activity/handler.go new file mode 100644 index 0000000000..bed7e3ea31 --- /dev/null +++ b/backend/pkg/libarcane/activity/handler.go @@ -0,0 +1,115 @@ +package activity + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "strings" + + "github.com/getarcaneapp/arcane/backend/internal/models" +) + +type HandlerOptions struct { + EnvironmentID string + Type models.ActivityType + ResourceType string + ResourceID string + ResourceName string + User *models.User + Step string + Message string + SuccessMessage string + Metadata models.JSON +} + +func StartHandlerActivityForUser( + ctx context.Context, + activityService Service, + environmentID string, + activityType models.ActivityType, + resourceType string, + resourceID string, + resourceName string, + user *models.User, + step string, + message string, + metadata models.JSON, +) string { + if activityService == nil { + return "" + } + + activity, err := activityService.StartActivity(ctx, StartRequest{ + EnvironmentID: environmentID, + Type: activityType, + ResourceType: StringPtr(resourceType), + ResourceID: StringPtr(resourceID), + ResourceName: StringPtr(resourceName), + StartedBy: user, + Step: step, + LatestMessage: message, + Metadata: metadata, + }) + if err != nil { + slog.DebugContext(ctx, "failed to start background activity", "type", activityType, "error", err) + return "" + } + return activity.ID +} + +func CompleteHandlerActivity(ctx context.Context, activityService Service, activityID string, successMessage string, err error) { + if activityService == nil || strings.TrimSpace(activityID) == "" { + return + } + + status := models.ActivityStatusSuccess + var errMessage *string + finalMessage := successMessage + if err != nil { + status = models.ActivityStatusFailed + errText := err.Error() + errMessage = &errText + finalMessage = errText + } + + activityCtx := context.WithoutCancel(ctx) + if _, completeErr := activityService.CompleteActivity(activityCtx, activityID, status, finalMessage, errMessage); completeErr != nil { + slog.DebugContext(activityCtx, "failed to complete background activity", "activityId", activityID, "error", completeErr) + } +} + +func RunHandlerActivity(ctx context.Context, activityService Service, opts HandlerOptions, action func() error) (string, error) { + activityID := StartHandlerActivityForUser( + ctx, + activityService, + opts.EnvironmentID, + opts.Type, + opts.ResourceType, + opts.ResourceID, + opts.ResourceName, + opts.User, + opts.Step, + opts.Message, + opts.Metadata, + ) + + err := action() + CompleteHandlerActivity(ctx, activityService, activityID, opts.SuccessMessage, err) + return activityID, err +} + +func WriteStartedLine(writer io.Writer, activityID string) { + if writer == nil || strings.TrimSpace(activityID) == "" { + return + } + + payload := map[string]string{ + "type": "activity", + "activityId": activityID, + } + if err := json.NewEncoder(writer).Encode(payload); err != nil { + _, _ = fmt.Fprintf(writer, `{"activityId":%q}`+"\n", activityID) + } +} diff --git a/backend/pkg/libarcane/activity/requests.go b/backend/pkg/libarcane/activity/requests.go new file mode 100644 index 0000000000..d2b9776d17 --- /dev/null +++ b/backend/pkg/libarcane/activity/requests.go @@ -0,0 +1,56 @@ +package activity + +import ( + "context" + "strings" + + "github.com/getarcaneapp/arcane/backend/internal/models" + activitytypes "github.com/getarcaneapp/arcane/types/activity" +) + +type Service interface { + StartActivity(ctx context.Context, req StartRequest) (*activitytypes.Activity, error) + CompleteActivity(ctx context.Context, activityID string, status models.ActivityStatus, finalMessage string, errMessage *string) (*activitytypes.Activity, error) +} + +type MessageAppender interface { + AppendMessage(ctx context.Context, activityID string, req AppendMessageRequest) (*activitytypes.Message, error) +} + +type StartRequest struct { + EnvironmentID string + Type models.ActivityType + ResourceType *string + ResourceID *string + ResourceName *string + StartedBy *models.User + Step string + LatestMessage string + Progress *int + Metadata models.JSON +} + +type UpdateRequest struct { + Status models.ActivityStatus + Progress *int + Step *string + LatestMessage *string + Error *string + Metadata models.JSON +} + +type AppendMessageRequest struct { + Level models.ActivityMessageLevel + Message string + Payload models.JSON + Progress *int + Step string +} + +func StringPtr(value string) *string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + return &trimmed +} diff --git a/backend/pkg/libarcane/activity/writer.go b/backend/pkg/libarcane/activity/writer.go new file mode 100644 index 0000000000..0062b12d7c --- /dev/null +++ b/backend/pkg/libarcane/activity/writer.go @@ -0,0 +1,329 @@ +package activity + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + + "github.com/getarcaneapp/arcane/backend/internal/models" +) + +type Writer struct { + ctx context.Context + activityService MessageAppender + activityID string + writer io.Writer + defaultStep string + + mu sync.Mutex + buffer []byte + layers map[string]layerProgress +} + +type layerProgress struct { + current int64 + total int64 + status string +} + +func NewWriter(ctx context.Context, activityService MessageAppender, activityID string, writer io.Writer, defaultStep string) io.Writer { + if activityService == nil || strings.TrimSpace(activityID) == "" { + if writer == nil { + return io.Discard + } + return writer + } + if existing, ok := writer.(*Writer); ok { + return existing + } + return &Writer{ + ctx: ctx, + activityService: activityService, + activityID: strings.TrimSpace(activityID), + writer: writer, + defaultStep: strings.TrimSpace(defaultStep), + layers: map[string]layerProgress{}, + } +} + +func (w *Writer) Write(p []byte) (int, error) { + if w.writer != nil { + if _, err := w.writer.Write(p); err != nil { + return 0, err + } + } + + w.mu.Lock() + defer w.mu.Unlock() + + w.buffer = append(w.buffer, p...) + for { + idx := bytes.IndexByte(w.buffer, '\n') + if idx < 0 { + break + } + line := strings.TrimSpace(string(w.buffer[:idx])) + w.buffer = w.buffer[idx+1:] + w.processLineInternal(line) + } + + return len(p), nil +} + +func (w *Writer) Flush() { + if flusher, ok := w.writer.(http.Flusher); ok { + flusher.Flush() + } +} + +func (w *Writer) processLineInternal(line string) { + if line == "" || w.activityService == nil || w.activityID == "" { + return + } + + var payload map[string]any + if err := json.Unmarshal([]byte(line), &payload); err != nil { + w.appendMessageInternal(models.ActivityMessageLevelInfo, line, nil, nil, w.defaultStep) + return + } + + message, level, step, progress := w.describePayloadInternal(payload) + if message == "" { + return + } + w.appendMessageInternal(level, message, models.JSON(payload), progress, step) +} + +func (w *Writer) describePayloadInternal(payload map[string]any) (string, models.ActivityMessageLevel, string, *int) { + level := models.ActivityMessageLevelInfo + step := w.defaultStep + + if errorValue, ok := payload["error"]; ok && errorValue != nil { + level = models.ActivityMessageLevelError + return valueToStringInternal(errorValue), level, step, nil + } + + if typ := strings.TrimSpace(valueToStringInternal(payload["type"])); typ != "" { + if typ == "container" { + return containerEventMessageInternal(payload), level, step, nil + } + if phase := strings.TrimSpace(valueToStringInternal(payload["phase"])); phase != "" { + step = phaseStepInternal(typ, phase, step) + progress := phaseProgressInternal(phase) + return phaseMessageInternal(typ, phase, payload), level, step, progress + } + } + + if stream := strings.TrimSpace(valueToStringInternal(payload["stream"])); stream != "" { + return stream, level, fallbackStepInternal(step, "Building image"), nil + } + + status := strings.TrimSpace(valueToStringInternal(payload["status"])) + id := strings.TrimSpace(valueToStringInternal(payload["id"])) + progressText := strings.TrimSpace(valueToStringInternal(payload["progress"])) + progress := w.updateLayerProgressInternal(id, status, payload["progressDetail"]) + + if status != "" { + parts := []string{status} + if id != "" { + parts = append(parts, id) + } + if progressText != "" { + parts = append(parts, progressText) + } + return strings.Join(parts, " · "), level, statusStepInternal(status, step), progress + } + + return "", level, step, nil +} + +func (w *Writer) updateLayerProgressInternal(id, status string, rawDetail any) *int { + if id == "" { + return nil + } + + layer := w.layers[id] + layer.status = status + if detail, ok := rawDetail.(map[string]any); ok { + layer.current = numberToInt64Internal(detail["current"]) + layer.total = numberToInt64Internal(detail["total"]) + } + w.layers[id] = layer + + if len(w.layers) == 0 { + return nil + } + + var weighted float64 + for _, item := range w.layers { + statusLower := strings.ToLower(item.status) + switch { + case layerCompleteInternal(statusLower): + weighted += 1 + case strings.Contains(statusLower, "extracting"): + weighted += 0.95 + case strings.Contains(statusLower, "verifying"): + weighted += 0.92 + case strings.Contains(statusLower, "download complete"): + weighted += 0.85 + case item.total > 0: + weighted += min((float64(item.current)/float64(item.total))*0.85, 0.85) + case strings.Contains(statusLower, "downloading") || strings.Contains(statusLower, "pulling"): + weighted += 0.05 + } + } + + progress := int((weighted / float64(len(w.layers))) * 100) + if progress > 100 { + progress = 100 + } + if progress < 0 { + progress = 0 + } + return &progress +} + +func (w *Writer) appendMessageInternal(level models.ActivityMessageLevel, message string, payload models.JSON, progress *int, step string) { + if w.ctx == nil { + w.ctx = context.Background() + } + if _, err := w.activityService.AppendMessage(w.ctx, w.activityID, AppendMessageRequest{ + Level: level, + Message: message, + Payload: payload, + Progress: progress, + Step: step, + }); err != nil { + return + } +} + +func valueToStringInternal(value any) string { + switch typed := value.(type) { + case string: + return typed + case fmt.Stringer: + return typed.String() + case nil: + return "" + default: + return fmt.Sprint(typed) + } +} + +func numberToInt64Internal(value any) int64 { + switch typed := value.(type) { + case float64: + return int64(typed) + case float32: + return int64(typed) + case int64: + return typed + case int: + return int64(typed) + case json.Number: + out, _ := typed.Int64() + return out + default: + return 0 + } +} + +func layerCompleteInternal(status string) bool { + return strings.Contains(status, "pull complete") || + strings.Contains(status, "already exists") || + strings.Contains(status, "downloaded newer image") || + strings.Contains(status, "image is up to date") +} + +func statusStepInternal(status, fallback string) string { + lower := strings.ToLower(status) + switch { + case strings.Contains(lower, "downloading") || strings.Contains(lower, "pulling"): + return "Downloading layers" + case strings.Contains(lower, "extracting"): + return "Extracting layers" + case strings.Contains(lower, "verifying") || strings.Contains(lower, "digest"): + return "Verifying image" + case strings.Contains(lower, "building"): + return "Building image" + } + return fallback +} + +func phaseStepInternal(typ, phase, fallback string) string { + switch typ { + case "deploy": + switch phase { + case "begin": + return "Starting deployment" + case "complete": + return "Deployment complete" + default: + return "Deploying services" + } + case "build": + switch phase { + case "begin": + return "Starting build" + case "complete": + return "Build complete" + default: + return "Building image" + } + } + return fallback +} + +func phaseMessageInternal(typ, phase string, payload map[string]any) string { + if status := strings.TrimSpace(valueToStringInternal(payload["status"])); status != "" { + return status + } + if service := strings.TrimSpace(valueToStringInternal(payload["service"])); service != "" { + return fmt.Sprintf("%s %s: %s", typ, phase, service) + } + return fmt.Sprintf("%s %s", typ, phase) +} + +func phaseProgressInternal(phase string) *int { + switch phase { + case "begin": + out := 5 + return &out + case "complete": + out := 100 + return &out + default: + return nil + } +} + +func containerEventMessageInternal(payload map[string]any) string { + service := strings.TrimSpace(valueToStringInternal(payload["service"])) + state := strings.TrimSpace(valueToStringInternal(payload["state"])) + status := strings.TrimSpace(valueToStringInternal(payload["status"])) + + if service == "" { + service = "unknown" + } + + if status != "" { + return fmt.Sprintf("Container %s: %s", service, status) + } + if state != "" { + return fmt.Sprintf("Container %s: %s", service, state) + } + return fmt.Sprintf("Container %s", service) +} + +func fallbackStepInternal(value, fallback string) string { + if strings.TrimSpace(value) != "" { + return value + } + return fallback +} diff --git a/backend/pkg/projects/cmds.go b/backend/pkg/projects/cmds.go index ad78309a5d..ae70629305 100644 --- a/backend/pkg/projects/cmds.go +++ b/backend/pkg/projects/cmds.go @@ -58,7 +58,11 @@ func ComposeRestart(ctx context.Context, proj *types.Project, services []string) return err } defer func() { _ = c.Close() }() - return c.svc.Restart(restartCtx, proj.Name, api.RestartOptions{Services: services}) + + progressWriter, _ := ctx.Value(ProgressWriterKey{}).(io.Writer) + return runWithContainerPolling(restartCtx, c.svc, proj.Name, progressWriter, func() error { + return c.svc.Restart(restartCtx, proj.Name, api.RestartOptions{Services: services}) + }) } func ComposePull(ctx context.Context, proj *types.Project, services []string) error { @@ -92,7 +96,6 @@ func ComposeStop(ctx context.Context, proj *types.Project, services []string) er if len(services) == 0 { return nil } - // Detach from the HTTP request context. See #1209. stopCtx, cancel := detachFromHTTPContextInternal(ctx) defer cancel() @@ -101,7 +104,11 @@ func ComposeStop(ctx context.Context, proj *types.Project, services []string) er return err } defer func() { _ = c.Close() }() - return c.svc.Stop(stopCtx, proj.Name, api.StopOptions{Services: services}) + + progressWriter, _ := ctx.Value(ProgressWriterKey{}).(io.Writer) + return runWithContainerPolling(stopCtx, c.svc, proj.Name, progressWriter, func() error { + return c.svc.Stop(stopCtx, proj.Name, api.StopOptions{Services: services}) + }) } func ComposeUp(ctx context.Context, proj *types.Project, services []string, removeOrphans bool, forceRecreate bool) error { @@ -245,6 +252,69 @@ func deployPhaseFromSummary(cs api.ContainerSummary) string { } } +func runWithContainerPolling(ctx context.Context, svc api.Compose, projectName string, progressWriter io.Writer, operation func() error) error { + if progressWriter == nil { + return operation() + } + + pollCtx, cancel := context.WithCancel(ctx) + defer cancel() + + pollDone := make(chan struct{}) + go func() { + defer close(pollDone) + pollContainerStatus(pollCtx, svc, projectName, progressWriter) + }() + + err := operation() + cancel() + <-pollDone + return err +} + +func pollContainerStatus(ctx context.Context, svc api.Compose, projectName string, progressWriter io.Writer) { + ticker := time.NewTicker(300 * time.Millisecond) + defer ticker.Stop() + + lastState := map[string]string{} + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + containers, err := svc.Ps(ctx, projectName, api.PsOptions{All: true}) + if err != nil { + continue + } + for _, cs := range containers { + name := strings.TrimSpace(cs.Service) + if name == "" { + name = strings.TrimSpace(cs.Name) + } + if name == "" { + continue + } + + state := string(cs.State) + sig := state + "|" + string(cs.Health) + "|" + strings.TrimSpace(cs.Status) + if lastState[name] == sig { + continue + } + lastState[name] = sig + + writeJSONLine(progressWriter, map[string]any{ + "type": "container", + "service": name, + "state": state, + "health": string(cs.Health), + "status": strings.TrimSpace(cs.Status), + }) + } + } + } +} + func ComposePs(ctx context.Context, proj *types.Project, services []string, all bool) ([]api.ContainerSummary, error) { c, err := NewClient(ctx) if err != nil { @@ -265,7 +335,10 @@ func ComposeDown(ctx context.Context, proj *types.Project, removeVolumes bool) e } defer func() { _ = c.Close() }() - return c.svc.Down(downCtx, proj.Name, api.DownOptions{RemoveOrphans: true, Volumes: removeVolumes}) + progressWriter, _ := ctx.Value(ProgressWriterKey{}).(io.Writer) + return runWithContainerPolling(downCtx, c.svc, proj.Name, progressWriter, func() error { + return c.svc.Down(downCtx, proj.Name, api.DownOptions{RemoveOrphans: true, Volumes: removeVolumes}) + }) } func ComposeLogs(ctx context.Context, projectName string, out io.Writer, follow bool, tail string) error { diff --git a/backend/pkg/scheduler/event_cleanup_job.go b/backend/pkg/scheduler/event_cleanup_job.go index 96429970ff..dee5c1d49e 100644 --- a/backend/pkg/scheduler/event_cleanup_job.go +++ b/backend/pkg/scheduler/event_cleanup_job.go @@ -14,12 +14,14 @@ const EventCleanupJobName = "event-cleanup" type EventCleanupJob struct { eventService *services.EventService + activityService *services.ActivityService settingsService *services.SettingsService } -func NewEventCleanupJob(eventService *services.EventService, settingsService *services.SettingsService) *EventCleanupJob { +func NewEventCleanupJob(eventService *services.EventService, activityService *services.ActivityService, settingsService *services.SettingsService) *EventCleanupJob { return &EventCleanupJob{ eventService: eventService, + activityService: activityService, settingsService: settingsService, } } @@ -61,6 +63,22 @@ func (j *EventCleanupJob) Run(ctx context.Context) { slog.InfoContext(ctx, "Event cleanup job completed successfully", "jobName", EventCleanupJobName, "olderThan", olderThan.String()) + + if j.activityService != nil { + retentionDays := j.settingsService.GetIntSetting(ctx, "activityHistoryRetentionDays", 30) + maxEntries := j.settingsService.GetIntSetting(ctx, "activityHistoryMaxEntries", 1000) + deleted, err := j.activityService.PruneHistory(ctx, retentionDays, maxEntries) + if err != nil { + slog.ErrorContext(ctx, "Failed to prune activity history", "jobName", EventCleanupJobName, "error", err) + return + } + + slog.InfoContext(ctx, "Activity history cleanup completed successfully", + "jobName", EventCleanupJobName, + "retentionDays", retentionDays, + "maxEntries", maxEntries, + "deleted", deleted) + } } func (j *EventCleanupJob) Reschedule(ctx context.Context) error { diff --git a/backend/pkg/scheduler/scheduled_prune_job.go b/backend/pkg/scheduler/scheduled_prune_job.go index 6b4810ccd2..b4c35c8944 100644 --- a/backend/pkg/scheduler/scheduled_prune_job.go +++ b/backend/pkg/scheduler/scheduled_prune_job.go @@ -87,7 +87,7 @@ func (j *ScheduledPruneJob) Run(ctx context.Context) { "build_cache", req.BuildCache, ) - result, err := j.systemService.PruneAll(ctx, req) + result, err := j.systemService.PruneAll(ctx, "0", req) if err != nil { slog.ErrorContext(ctx, "scheduled prune run failed", "error", err) return diff --git a/backend/resources/migrations/postgres/051_add_activities.down.sql b/backend/resources/migrations/postgres/051_add_activities.down.sql new file mode 100644 index 0000000000..58d060bf56 --- /dev/null +++ b/backend/resources/migrations/postgres/051_add_activities.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS activity_messages; +DROP TABLE IF EXISTS activities; diff --git a/backend/resources/migrations/postgres/051_add_activities.up.sql b/backend/resources/migrations/postgres/051_add_activities.up.sql new file mode 100644 index 0000000000..728f41a72f --- /dev/null +++ b/backend/resources/migrations/postgres/051_add_activities.up.sql @@ -0,0 +1,40 @@ +CREATE TABLE activities ( + id TEXT PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ, + environment_id TEXT NOT NULL, + type TEXT NOT NULL, + status TEXT NOT NULL, + resource_type TEXT, + resource_id TEXT, + resource_name TEXT, + progress INTEGER, + step TEXT, + latest_message TEXT, + started_by_user_id TEXT, + started_by_username TEXT, + started_by_display_name TEXT, + started_at TIMESTAMPTZ NOT NULL, + ended_at TIMESTAMPTZ, + duration_ms BIGINT, + error TEXT, + metadata TEXT +); + +CREATE INDEX idx_activities_environment_status_updated ON activities(environment_id, status, updated_at); +CREATE INDEX idx_activities_environment_started ON activities(environment_id, started_at); +CREATE INDEX idx_activities_type ON activities(type); +CREATE INDEX idx_activities_resource ON activities(resource_type, resource_id); +CREATE INDEX idx_activities_started_by_user_id ON activities(started_by_user_id); + +CREATE TABLE activity_messages ( + id TEXT PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ, + activity_id TEXT NOT NULL REFERENCES activities(id) ON DELETE CASCADE, + level TEXT NOT NULL, + message TEXT NOT NULL, + payload TEXT +); + +CREATE INDEX idx_activity_messages_activity_created ON activity_messages(activity_id, created_at); diff --git a/backend/resources/migrations/sqlite/051_add_activities.down.sql b/backend/resources/migrations/sqlite/051_add_activities.down.sql new file mode 100644 index 0000000000..58d060bf56 --- /dev/null +++ b/backend/resources/migrations/sqlite/051_add_activities.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS activity_messages; +DROP TABLE IF EXISTS activities; diff --git a/backend/resources/migrations/sqlite/051_add_activities.up.sql b/backend/resources/migrations/sqlite/051_add_activities.up.sql new file mode 100644 index 0000000000..03a95b7193 --- /dev/null +++ b/backend/resources/migrations/sqlite/051_add_activities.up.sql @@ -0,0 +1,41 @@ +CREATE TABLE activities ( + id TEXT PRIMARY KEY, + created_at DATETIME NOT NULL, + updated_at DATETIME, + environment_id TEXT NOT NULL, + type TEXT NOT NULL, + status TEXT NOT NULL, + resource_type TEXT, + resource_id TEXT, + resource_name TEXT, + progress INTEGER, + step TEXT, + latest_message TEXT, + started_by_user_id TEXT, + started_by_username TEXT, + started_by_display_name TEXT, + started_at DATETIME NOT NULL, + ended_at DATETIME, + duration_ms INTEGER, + error TEXT, + metadata TEXT +); + +CREATE INDEX idx_activities_environment_status_updated ON activities(environment_id, status, updated_at); +CREATE INDEX idx_activities_environment_started ON activities(environment_id, started_at); +CREATE INDEX idx_activities_type ON activities(type); +CREATE INDEX idx_activities_resource ON activities(resource_type, resource_id); +CREATE INDEX idx_activities_started_by_user_id ON activities(started_by_user_id); + +CREATE TABLE activity_messages ( + id TEXT PRIMARY KEY, + created_at DATETIME NOT NULL, + updated_at DATETIME, + activity_id TEXT NOT NULL, + level TEXT NOT NULL, + message TEXT NOT NULL, + payload TEXT, + FOREIGN KEY(activity_id) REFERENCES activities(id) ON DELETE CASCADE +); + +CREATE INDEX idx_activity_messages_activity_created ON activity_messages(activity_id, created_at); diff --git a/cli/pkg/containers/cmd.go b/cli/pkg/containers/cmd.go index 40b1ffa849..312e30f8b5 100644 --- a/cli/pkg/containers/cmd.go +++ b/cli/pkg/containers/cmd.go @@ -281,7 +281,7 @@ var containersStartCmd = &cobra.Command{ } defer func() { _ = resp.Body.Close() }() - var result base.ApiResponse[container.ActionResult] + var result base.ApiResponse[base.MessageResponse] if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return fmt.Errorf("failed to parse response: %w", err) } @@ -323,7 +323,7 @@ var containersStopCmd = &cobra.Command{ } defer func() { _ = resp.Body.Close() }() - var result base.ApiResponse[container.ActionResult] + var result base.ApiResponse[base.MessageResponse] if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return fmt.Errorf("failed to parse response: %w", err) } @@ -365,7 +365,7 @@ var containersRestartCmd = &cobra.Command{ } defer func() { _ = resp.Body.Close() }() - var result base.ApiResponse[container.ActionResult] + var result base.ApiResponse[base.MessageResponse] if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return fmt.Errorf("failed to parse response: %w", err) } diff --git a/frontend/messages/da.json b/frontend/messages/da.json index 4000223546..d06d5f0685 100644 --- a/frontend/messages/da.json +++ b/frontend/messages/da.json @@ -2762,5 +2762,81 @@ "webhook_disable_failed": "Failed to disable webhook \"{name}\"", "webhook_status_enabled": "Enabled", "webhook_status_disabled": "Disabled", - "webhook_col_status": "Status" + "webhook_col_status": "Status", + "_comment_activity_center": "=== ACTIVITY CENTER ===", + "activity_center_title": "Activity Center", + "activity_center_open": "Open activity center", + "activity_active_count": "{count} active", + "activity_count_many": "9+", + "activity_filter_running": "Running", + "activity_filter_failed": "Failed", + "activity_filter_completed": "Completed", + "activity_status_queued": "Queued", + "activity_status_running": "Running", + "activity_status_success": "Success", + "activity_status_failed": "Failed", + "activity_status_cancelled": "Cancelled", + "activity_type_image_pull": "Image Pull", + "activity_type_image_build": "Image Build", + "activity_type_image_update_check": "Image Update Check", + "activity_type_project_pull": "Project Pull", + "activity_type_project_build": "Project Build", + "activity_type_project_deploy": "Project Deploy", + "activity_type_project_redeploy": "Project Redeploy", + "activity_type_project_down": "Project Down", + "activity_type_project_restart": "Project Restart", + "activity_type_project_destroy": "Project Destroy", + "activity_type_container_start": "Container Start", + "activity_type_container_stop": "Container Stop", + "activity_type_container_restart": "Container Restart", + "activity_type_container_redeploy": "Container Redeploy", + "activity_type_container_delete": "Container Delete", + "activity_type_vulnerability_scan": "Vulnerability Scan", + "activity_type_auto_update": "Auto Update", + "activity_type_system_prune": "System Prune", + "activity_type_resource_action": "Resource Action", + "activity_unknown_target": "Unknown target", + "activity_no_message": "No message yet", + "activity_progress_percent": "{progress}%", + "activity_loading": "Loading activity…", + "activity_empty_title": "No activity here", + "activity_empty_description": "Background work will appear here as it starts.", + "activity_step_unknown": "Waiting for the next step", + "activity_duration": "Duration", + "activity_duration_ms": "{ms}ms", + "activity_duration_seconds": "{seconds}s", + "activity_duration_minutes": "{minutes}m {seconds}s", + "activity_output_title": "Output", + "activity_copy_output": "Copy Output", + "activity_output_loading": "Loading output…", + "activity_output_load_failed": "Failed to load activity output", + "activity_output_empty": "No output has been recorded for this activity yet.", + "activity_stream_error": "Lost connection to activity stream", + "activity_select_prompt_title": "Select an activity", + "activity_select_prompt": "Choose a background task to inspect its progress and recent output.", + "activity_view_activity": "View Activity", + "activity_source_environment": "Source environment", + "activity_started_by": "Started by {user}", + "activity_started_by_label": "Started by", + "activity_clear_history": "Clear history", + "activity_clear_history_title": "Clear activity history?", + "activity_clear_history_message": "Completed, failed, and cancelled entries for the selected environment will be deleted. Running activity will stay visible.", + "activity_clear_history_confirm": "Clear History", + "activity_clear_history_success": "Activity history cleared", + "activity_clear_history_failed": "Failed to clear activity history", + "activity_settings_title": "Activity", + "activity_settings_description": "Configure Activity Center history retention for each environment.", + "activity_settings_saved": "Activity settings saved", + "activity_history_section_title": "History Retention", + "activity_history_retention_days": "Retention Days", + "activity_history_retention_days_description": "Delete completed Activity Center entries older than this many days.", + "activity_history_retention_days_placeholder": "30", + "activity_history_retention_days_help": "Set to 0 to disable age-based cleanup.", + "activity_history_max_entries": "Maximum Entries", + "activity_history_max_entries_description": "Keep this many completed Activity Center entries per environment.", + "activity_history_max_entries_placeholder": "1000", + "activity_history_max_entries_help": "Set to 0 to disable count-based cleanup.", + "activity_scan_phase_creating_container": "Creating container", + "activity_scan_phase_scanning_image": "Scanning image", + "activity_scan_phase_storing_results": "Storing results" } diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 8e09c8cdc9..c2ff76fcd4 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -2762,5 +2762,81 @@ "webhook_disable_failed": "Webhook kann nicht deaktiviert werden \"{name}\"", "webhook_status_enabled": "Aktiviert", "webhook_status_disabled": "Behinderte", - "webhook_col_status": "Status" + "webhook_col_status": "Status", + "_comment_activity_center": "=== ACTIVITY CENTER ===", + "activity_center_title": "Activity Center", + "activity_center_open": "Open activity center", + "activity_active_count": "{count} active", + "activity_count_many": "9+", + "activity_filter_running": "Running", + "activity_filter_failed": "Failed", + "activity_filter_completed": "Completed", + "activity_status_queued": "Queued", + "activity_status_running": "Running", + "activity_status_success": "Success", + "activity_status_failed": "Failed", + "activity_status_cancelled": "Cancelled", + "activity_type_image_pull": "Image Pull", + "activity_type_image_build": "Image Build", + "activity_type_image_update_check": "Image Update Check", + "activity_type_project_pull": "Project Pull", + "activity_type_project_build": "Project Build", + "activity_type_project_deploy": "Project Deploy", + "activity_type_project_redeploy": "Project Redeploy", + "activity_type_project_down": "Project Down", + "activity_type_project_restart": "Project Restart", + "activity_type_project_destroy": "Project Destroy", + "activity_type_container_start": "Container Start", + "activity_type_container_stop": "Container Stop", + "activity_type_container_restart": "Container Restart", + "activity_type_container_redeploy": "Container Redeploy", + "activity_type_container_delete": "Container Delete", + "activity_type_vulnerability_scan": "Vulnerability Scan", + "activity_type_auto_update": "Auto Update", + "activity_type_system_prune": "System Prune", + "activity_type_resource_action": "Resource Action", + "activity_unknown_target": "Unknown target", + "activity_no_message": "No message yet", + "activity_progress_percent": "{progress}%", + "activity_loading": "Loading activity…", + "activity_empty_title": "No activity here", + "activity_empty_description": "Background work will appear here as it starts.", + "activity_step_unknown": "Waiting for the next step", + "activity_duration": "Duration", + "activity_duration_ms": "{ms}ms", + "activity_duration_seconds": "{seconds}s", + "activity_duration_minutes": "{minutes}m {seconds}s", + "activity_output_title": "Output", + "activity_copy_output": "Copy Output", + "activity_output_loading": "Loading output…", + "activity_output_load_failed": "Failed to load activity output", + "activity_output_empty": "No output has been recorded for this activity yet.", + "activity_stream_error": "Lost connection to activity stream", + "activity_select_prompt_title": "Select an activity", + "activity_select_prompt": "Choose a background task to inspect its progress and recent output.", + "activity_view_activity": "View Activity", + "activity_source_environment": "Source environment", + "activity_started_by": "Started by {user}", + "activity_started_by_label": "Started by", + "activity_clear_history": "Clear history", + "activity_clear_history_title": "Clear activity history?", + "activity_clear_history_message": "Completed, failed, and cancelled entries for the selected environment will be deleted. Running activity will stay visible.", + "activity_clear_history_confirm": "Clear History", + "activity_clear_history_success": "Activity history cleared", + "activity_clear_history_failed": "Failed to clear activity history", + "activity_settings_title": "Activity", + "activity_settings_description": "Configure Activity Center history retention for each environment.", + "activity_settings_saved": "Activity settings saved", + "activity_history_section_title": "History Retention", + "activity_history_retention_days": "Retention Days", + "activity_history_retention_days_description": "Delete completed Activity Center entries older than this many days.", + "activity_history_retention_days_placeholder": "30", + "activity_history_retention_days_help": "Set to 0 to disable age-based cleanup.", + "activity_history_max_entries": "Maximum Entries", + "activity_history_max_entries_description": "Keep this many completed Activity Center entries per environment.", + "activity_history_max_entries_placeholder": "1000", + "activity_history_max_entries_help": "Set to 0 to disable count-based cleanup.", + "activity_scan_phase_creating_container": "Creating container", + "activity_scan_phase_scanning_image": "Scanning image", + "activity_scan_phase_storing_results": "Storing results" } diff --git a/frontend/messages/el.json b/frontend/messages/el.json index 93e88a8a77..c9fdaf10b0 100644 --- a/frontend/messages/el.json +++ b/frontend/messages/el.json @@ -2762,5 +2762,81 @@ "webhook_disable_failed": "Απέτυχε η απενεργοποίηση του webhook \"{name}\"", "webhook_status_enabled": "Ενεργοποιημένο", "webhook_status_disabled": "Άτομα με ειδικές ανάγκες", - "webhook_col_status": "Κατάσταση" + "webhook_col_status": "Κατάσταση", + "_comment_activity_center": "=== ACTIVITY CENTER ===", + "activity_center_title": "Activity Center", + "activity_center_open": "Open activity center", + "activity_active_count": "{count} active", + "activity_count_many": "9+", + "activity_filter_running": "Running", + "activity_filter_failed": "Failed", + "activity_filter_completed": "Completed", + "activity_status_queued": "Queued", + "activity_status_running": "Running", + "activity_status_success": "Success", + "activity_status_failed": "Failed", + "activity_status_cancelled": "Cancelled", + "activity_type_image_pull": "Image Pull", + "activity_type_image_build": "Image Build", + "activity_type_image_update_check": "Image Update Check", + "activity_type_project_pull": "Project Pull", + "activity_type_project_build": "Project Build", + "activity_type_project_deploy": "Project Deploy", + "activity_type_project_redeploy": "Project Redeploy", + "activity_type_project_down": "Project Down", + "activity_type_project_restart": "Project Restart", + "activity_type_project_destroy": "Project Destroy", + "activity_type_container_start": "Container Start", + "activity_type_container_stop": "Container Stop", + "activity_type_container_restart": "Container Restart", + "activity_type_container_redeploy": "Container Redeploy", + "activity_type_container_delete": "Container Delete", + "activity_type_vulnerability_scan": "Vulnerability Scan", + "activity_type_auto_update": "Auto Update", + "activity_type_system_prune": "System Prune", + "activity_type_resource_action": "Resource Action", + "activity_unknown_target": "Unknown target", + "activity_no_message": "No message yet", + "activity_progress_percent": "{progress}%", + "activity_loading": "Loading activity…", + "activity_empty_title": "No activity here", + "activity_empty_description": "Background work will appear here as it starts.", + "activity_step_unknown": "Waiting for the next step", + "activity_duration": "Duration", + "activity_duration_ms": "{ms}ms", + "activity_duration_seconds": "{seconds}s", + "activity_duration_minutes": "{minutes}m {seconds}s", + "activity_output_title": "Output", + "activity_copy_output": "Copy Output", + "activity_output_loading": "Loading output…", + "activity_output_load_failed": "Failed to load activity output", + "activity_output_empty": "No output has been recorded for this activity yet.", + "activity_stream_error": "Lost connection to activity stream", + "activity_select_prompt_title": "Select an activity", + "activity_select_prompt": "Choose a background task to inspect its progress and recent output.", + "activity_view_activity": "View Activity", + "activity_source_environment": "Source environment", + "activity_started_by": "Started by {user}", + "activity_started_by_label": "Started by", + "activity_clear_history": "Clear history", + "activity_clear_history_title": "Clear activity history?", + "activity_clear_history_message": "Completed, failed, and cancelled entries for the selected environment will be deleted. Running activity will stay visible.", + "activity_clear_history_confirm": "Clear History", + "activity_clear_history_success": "Activity history cleared", + "activity_clear_history_failed": "Failed to clear activity history", + "activity_settings_title": "Activity", + "activity_settings_description": "Configure Activity Center history retention for each environment.", + "activity_settings_saved": "Activity settings saved", + "activity_history_section_title": "History Retention", + "activity_history_retention_days": "Retention Days", + "activity_history_retention_days_description": "Delete completed Activity Center entries older than this many days.", + "activity_history_retention_days_placeholder": "30", + "activity_history_retention_days_help": "Set to 0 to disable age-based cleanup.", + "activity_history_max_entries": "Maximum Entries", + "activity_history_max_entries_description": "Keep this many completed Activity Center entries per environment.", + "activity_history_max_entries_placeholder": "1000", + "activity_history_max_entries_help": "Set to 0 to disable count-based cleanup.", + "activity_scan_phase_creating_container": "Creating container", + "activity_scan_phase_scanning_image": "Scanning image", + "activity_scan_phase_storing_results": "Storing results" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index c8b2432432..46359477c3 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1955,6 +1955,82 @@ "sidebar_version": "{version}", "sidebar_update_available": "Update available", "sidebar_update_available_tooltip": "Update available: {version}", + "_comment_activity_center": "=== ACTIVITY CENTER ===", + "activity_center_title": "Activity Center", + "activity_center_open": "Open activity center", + "activity_active_count": "{count} active", + "activity_count_many": "9+", + "activity_filter_running": "Running", + "activity_filter_failed": "Failed", + "activity_filter_completed": "Completed", + "activity_status_queued": "Queued", + "activity_status_running": "Running", + "activity_status_success": "Success", + "activity_status_failed": "Failed", + "activity_status_cancelled": "Cancelled", + "activity_type_image_pull": "Image Pull", + "activity_type_image_build": "Image Build", + "activity_type_image_update_check": "Image Update Check", + "activity_type_project_pull": "Project Pull", + "activity_type_project_build": "Project Build", + "activity_type_project_deploy": "Project Deploy", + "activity_type_project_redeploy": "Project Redeploy", + "activity_type_project_down": "Project Down", + "activity_type_project_restart": "Project Restart", + "activity_type_project_destroy": "Project Destroy", + "activity_type_container_start": "Container Start", + "activity_type_container_stop": "Container Stop", + "activity_type_container_restart": "Container Restart", + "activity_type_container_redeploy": "Container Redeploy", + "activity_type_container_delete": "Container Delete", + "activity_type_vulnerability_scan": "Vulnerability Scan", + "activity_type_auto_update": "Auto Update", + "activity_type_system_prune": "System Prune", + "activity_type_resource_action": "Resource Action", + "activity_unknown_target": "Unknown target", + "activity_no_message": "No message yet", + "activity_progress_percent": "{progress}%", + "activity_loading": "Loading activity…", + "activity_empty_title": "No activity here", + "activity_empty_description": "Background work will appear here as it starts.", + "activity_step_unknown": "Waiting for the next step", + "activity_duration": "Duration", + "activity_duration_ms": "{ms}ms", + "activity_duration_seconds": "{seconds}s", + "activity_duration_minutes": "{minutes}m {seconds}s", + "activity_output_title": "Output", + "activity_copy_output": "Copy Output", + "activity_output_loading": "Loading output…", + "activity_output_load_failed": "Failed to load activity output", + "activity_output_empty": "No output has been recorded for this activity yet.", + "activity_stream_error": "Lost connection to activity stream", + "activity_select_prompt_title": "Select an activity", + "activity_select_prompt": "Choose a background task to inspect its progress and recent output.", + "activity_view_activity": "View Activity", + "activity_source_environment": "Source environment", + "activity_started_by": "Started by {user}", + "activity_started_by_label": "Started by", + "activity_clear_history": "Clear history", + "activity_clear_history_title": "Clear activity history?", + "activity_clear_history_message": "Completed, failed, and cancelled entries for the selected environment will be deleted. Running activity will stay visible.", + "activity_clear_history_confirm": "Clear History", + "activity_clear_history_success": "Activity history cleared", + "activity_clear_history_failed": "Failed to clear activity history", + "activity_settings_title": "Activity", + "activity_settings_description": "Configure Activity Center history retention for each environment.", + "activity_settings_saved": "Activity settings saved", + "activity_history_section_title": "History Retention", + "activity_history_retention_days": "Retention Days", + "activity_history_retention_days_description": "Delete completed Activity Center entries older than this many days.", + "activity_history_retention_days_placeholder": "30", + "activity_history_retention_days_help": "Set to 0 to disable age-based cleanup.", + "activity_history_max_entries": "Maximum Entries", + "activity_history_max_entries_description": "Keep this many completed Activity Center entries per environment.", + "activity_history_max_entries_placeholder": "1000", + "activity_history_max_entries_help": "Set to 0 to disable count-based cleanup.", + "activity_scan_phase_creating_container": "Creating container", + "activity_scan_phase_scanning_image": "Scanning image", + "activity_scan_phase_storing_results": "Storing results", "_comment_version_info": "=== VERSION INFO DIALOG ===", "version_info_title": "About Arcane", "version_info_version": "Version", diff --git a/frontend/messages/eo.json b/frontend/messages/eo.json index e721a89777..8e7a994031 100644 --- a/frontend/messages/eo.json +++ b/frontend/messages/eo.json @@ -2762,5 +2762,81 @@ "webhook_disable_failed": "Failed to disable webhook \"{name}\"", "webhook_status_enabled": "Enabled", "webhook_status_disabled": "Disabled", - "webhook_col_status": "Status" + "webhook_col_status": "Status", + "_comment_activity_center": "=== ACTIVITY CENTER ===", + "activity_center_title": "Activity Center", + "activity_center_open": "Open activity center", + "activity_active_count": "{count} active", + "activity_count_many": "9+", + "activity_filter_running": "Running", + "activity_filter_failed": "Failed", + "activity_filter_completed": "Completed", + "activity_status_queued": "Queued", + "activity_status_running": "Running", + "activity_status_success": "Success", + "activity_status_failed": "Failed", + "activity_status_cancelled": "Cancelled", + "activity_type_image_pull": "Image Pull", + "activity_type_image_build": "Image Build", + "activity_type_image_update_check": "Image Update Check", + "activity_type_project_pull": "Project Pull", + "activity_type_project_build": "Project Build", + "activity_type_project_deploy": "Project Deploy", + "activity_type_project_redeploy": "Project Redeploy", + "activity_type_project_down": "Project Down", + "activity_type_project_restart": "Project Restart", + "activity_type_project_destroy": "Project Destroy", + "activity_type_container_start": "Container Start", + "activity_type_container_stop": "Container Stop", + "activity_type_container_restart": "Container Restart", + "activity_type_container_redeploy": "Container Redeploy", + "activity_type_container_delete": "Container Delete", + "activity_type_vulnerability_scan": "Vulnerability Scan", + "activity_type_auto_update": "Auto Update", + "activity_type_system_prune": "System Prune", + "activity_type_resource_action": "Resource Action", + "activity_unknown_target": "Unknown target", + "activity_no_message": "No message yet", + "activity_progress_percent": "{progress}%", + "activity_loading": "Loading activity…", + "activity_empty_title": "No activity here", + "activity_empty_description": "Background work will appear here as it starts.", + "activity_step_unknown": "Waiting for the next step", + "activity_duration": "Duration", + "activity_duration_ms": "{ms}ms", + "activity_duration_seconds": "{seconds}s", + "activity_duration_minutes": "{minutes}m {seconds}s", + "activity_output_title": "Output", + "activity_copy_output": "Copy Output", + "activity_output_loading": "Loading output…", + "activity_output_load_failed": "Failed to load activity output", + "activity_output_empty": "No output has been recorded for this activity yet.", + "activity_stream_error": "Lost connection to activity stream", + "activity_select_prompt_title": "Select an activity", + "activity_select_prompt": "Choose a background task to inspect its progress and recent output.", + "activity_view_activity": "View Activity", + "activity_source_environment": "Source environment", + "activity_started_by": "Started by {user}", + "activity_started_by_label": "Started by", + "activity_clear_history": "Clear history", + "activity_clear_history_title": "Clear activity history?", + "activity_clear_history_message": "Completed, failed, and cancelled entries for the selected environment will be deleted. Running activity will stay visible.", + "activity_clear_history_confirm": "Clear History", + "activity_clear_history_success": "Activity history cleared", + "activity_clear_history_failed": "Failed to clear activity history", + "activity_settings_title": "Activity", + "activity_settings_description": "Configure Activity Center history retention for each environment.", + "activity_settings_saved": "Activity settings saved", + "activity_history_section_title": "History Retention", + "activity_history_retention_days": "Retention Days", + "activity_history_retention_days_description": "Delete completed Activity Center entries older than this many days.", + "activity_history_retention_days_placeholder": "30", + "activity_history_retention_days_help": "Set to 0 to disable age-based cleanup.", + "activity_history_max_entries": "Maximum Entries", + "activity_history_max_entries_description": "Keep this many completed Activity Center entries per environment.", + "activity_history_max_entries_placeholder": "1000", + "activity_history_max_entries_help": "Set to 0 to disable count-based cleanup.", + "activity_scan_phase_creating_container": "Creating container", + "activity_scan_phase_scanning_image": "Scanning image", + "activity_scan_phase_storing_results": "Storing results" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 6e9b824664..577b50a4e4 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -2762,5 +2762,81 @@ "webhook_disable_failed": "Error al desactivar el webhook \"{name}\"", "webhook_status_enabled": "Activado", "webhook_status_disabled": "Discapacitados", - "webhook_col_status": "Estado" + "webhook_col_status": "Estado", + "_comment_activity_center": "=== ACTIVITY CENTER ===", + "activity_center_title": "Activity Center", + "activity_center_open": "Open activity center", + "activity_active_count": "{count} active", + "activity_count_many": "9+", + "activity_filter_running": "Running", + "activity_filter_failed": "Failed", + "activity_filter_completed": "Completed", + "activity_status_queued": "Queued", + "activity_status_running": "Running", + "activity_status_success": "Success", + "activity_status_failed": "Failed", + "activity_status_cancelled": "Cancelled", + "activity_type_image_pull": "Image Pull", + "activity_type_image_build": "Image Build", + "activity_type_image_update_check": "Image Update Check", + "activity_type_project_pull": "Project Pull", + "activity_type_project_build": "Project Build", + "activity_type_project_deploy": "Project Deploy", + "activity_type_project_redeploy": "Project Redeploy", + "activity_type_project_down": "Project Down", + "activity_type_project_restart": "Project Restart", + "activity_type_project_destroy": "Project Destroy", + "activity_type_container_start": "Container Start", + "activity_type_container_stop": "Container Stop", + "activity_type_container_restart": "Container Restart", + "activity_type_container_redeploy": "Container Redeploy", + "activity_type_container_delete": "Container Delete", + "activity_type_vulnerability_scan": "Vulnerability Scan", + "activity_type_auto_update": "Auto Update", + "activity_type_system_prune": "System Prune", + "activity_type_resource_action": "Resource Action", + "activity_unknown_target": "Unknown target", + "activity_no_message": "No message yet", + "activity_progress_percent": "{progress}%", + "activity_loading": "Loading activity…", + "activity_empty_title": "No activity here", + "activity_empty_description": "Background work will appear here as it starts.", + "activity_step_unknown": "Waiting for the next step", + "activity_duration": "Duration", + "activity_duration_ms": "{ms}ms", + "activity_duration_seconds": "{seconds}s", + "activity_duration_minutes": "{minutes}m {seconds}s", + "activity_output_title": "Output", + "activity_copy_output": "Copy Output", + "activity_output_loading": "Loading output…", + "activity_output_load_failed": "Failed to load activity output", + "activity_output_empty": "No output has been recorded for this activity yet.", + "activity_stream_error": "Lost connection to activity stream", + "activity_select_prompt_title": "Select an activity", + "activity_select_prompt": "Choose a background task to inspect its progress and recent output.", + "activity_view_activity": "View Activity", + "activity_source_environment": "Source environment", + "activity_started_by": "Started by {user}", + "activity_started_by_label": "Started by", + "activity_clear_history": "Clear history", + "activity_clear_history_title": "Clear activity history?", + "activity_clear_history_message": "Completed, failed, and cancelled entries for the selected environment will be deleted. Running activity will stay visible.", + "activity_clear_history_confirm": "Clear History", + "activity_clear_history_success": "Activity history cleared", + "activity_clear_history_failed": "Failed to clear activity history", + "activity_settings_title": "Activity", + "activity_settings_description": "Configure Activity Center history retention for each environment.", + "activity_settings_saved": "Activity settings saved", + "activity_history_section_title": "History Retention", + "activity_history_retention_days": "Retention Days", + "activity_history_retention_days_description": "Delete completed Activity Center entries older than this many days.", + "activity_history_retention_days_placeholder": "30", + "activity_history_retention_days_help": "Set to 0 to disable age-based cleanup.", + "activity_history_max_entries": "Maximum Entries", + "activity_history_max_entries_description": "Keep this many completed Activity Center entries per environment.", + "activity_history_max_entries_placeholder": "1000", + "activity_history_max_entries_help": "Set to 0 to disable count-based cleanup.", + "activity_scan_phase_creating_container": "Creating container", + "activity_scan_phase_scanning_image": "Scanning image", + "activity_scan_phase_storing_results": "Storing results" } diff --git a/frontend/messages/fr.json b/frontend/messages/fr.json index ceb4226fcc..420ed89a17 100644 --- a/frontend/messages/fr.json +++ b/frontend/messages/fr.json @@ -2762,5 +2762,81 @@ "webhook_disable_failed": "Échec de la désactivation du webhook \"{name}\"", "webhook_status_enabled": "Activé", "webhook_status_disabled": "Handicapés", - "webhook_col_status": "Statut" + "webhook_col_status": "Statut", + "_comment_activity_center": "=== ACTIVITY CENTER ===", + "activity_center_title": "Activity Center", + "activity_center_open": "Open activity center", + "activity_active_count": "{count} active", + "activity_count_many": "9+", + "activity_filter_running": "Running", + "activity_filter_failed": "Failed", + "activity_filter_completed": "Completed", + "activity_status_queued": "Queued", + "activity_status_running": "Running", + "activity_status_success": "Success", + "activity_status_failed": "Failed", + "activity_status_cancelled": "Cancelled", + "activity_type_image_pull": "Image Pull", + "activity_type_image_build": "Image Build", + "activity_type_image_update_check": "Image Update Check", + "activity_type_project_pull": "Project Pull", + "activity_type_project_build": "Project Build", + "activity_type_project_deploy": "Project Deploy", + "activity_type_project_redeploy": "Project Redeploy", + "activity_type_project_down": "Project Down", + "activity_type_project_restart": "Project Restart", + "activity_type_project_destroy": "Project Destroy", + "activity_type_container_start": "Container Start", + "activity_type_container_stop": "Container Stop", + "activity_type_container_restart": "Container Restart", + "activity_type_container_redeploy": "Container Redeploy", + "activity_type_container_delete": "Container Delete", + "activity_type_vulnerability_scan": "Vulnerability Scan", + "activity_type_auto_update": "Auto Update", + "activity_type_system_prune": "System Prune", + "activity_type_resource_action": "Resource Action", + "activity_unknown_target": "Unknown target", + "activity_no_message": "No message yet", + "activity_progress_percent": "{progress}%", + "activity_loading": "Loading activity…", + "activity_empty_title": "No activity here", + "activity_empty_description": "Background work will appear here as it starts.", + "activity_step_unknown": "Waiting for the next step", + "activity_duration": "Duration", + "activity_duration_ms": "{ms}ms", + "activity_duration_seconds": "{seconds}s", + "activity_duration_minutes": "{minutes}m {seconds}s", + "activity_output_title": "Output", + "activity_copy_output": "Copy Output", + "activity_output_loading": "Loading output…", + "activity_output_load_failed": "Failed to load activity output", + "activity_output_empty": "No output has been recorded for this activity yet.", + "activity_stream_error": "Lost connection to activity stream", + "activity_select_prompt_title": "Select an activity", + "activity_select_prompt": "Choose a background task to inspect its progress and recent output.", + "activity_view_activity": "View Activity", + "activity_source_environment": "Source environment", + "activity_started_by": "Started by {user}", + "activity_started_by_label": "Started by", + "activity_clear_history": "Clear history", + "activity_clear_history_title": "Clear activity history?", + "activity_clear_history_message": "Completed, failed, and cancelled entries for the selected environment will be deleted. Running activity will stay visible.", + "activity_clear_history_confirm": "Clear History", + "activity_clear_history_success": "Activity history cleared", + "activity_clear_history_failed": "Failed to clear activity history", + "activity_settings_title": "Activity", + "activity_settings_description": "Configure Activity Center history retention for each environment.", + "activity_settings_saved": "Activity settings saved", + "activity_history_section_title": "History Retention", + "activity_history_retention_days": "Retention Days", + "activity_history_retention_days_description": "Delete completed Activity Center entries older than this many days.", + "activity_history_retention_days_placeholder": "30", + "activity_history_retention_days_help": "Set to 0 to disable age-based cleanup.", + "activity_history_max_entries": "Maximum Entries", + "activity_history_max_entries_description": "Keep this many completed Activity Center entries per environment.", + "activity_history_max_entries_placeholder": "1000", + "activity_history_max_entries_help": "Set to 0 to disable count-based cleanup.", + "activity_scan_phase_creating_container": "Creating container", + "activity_scan_phase_scanning_image": "Scanning image", + "activity_scan_phase_storing_results": "Storing results" } diff --git a/frontend/messages/hu.json b/frontend/messages/hu.json index ede4e4d072..ec5206205e 100644 --- a/frontend/messages/hu.json +++ b/frontend/messages/hu.json @@ -2762,5 +2762,81 @@ "webhook_disable_failed": "Failed to disable webhook \"{name}\"", "webhook_status_enabled": "Enabled", "webhook_status_disabled": "Disabled", - "webhook_col_status": "Status" + "webhook_col_status": "Status", + "_comment_activity_center": "=== ACTIVITY CENTER ===", + "activity_center_title": "Activity Center", + "activity_center_open": "Open activity center", + "activity_active_count": "{count} active", + "activity_count_many": "9+", + "activity_filter_running": "Running", + "activity_filter_failed": "Failed", + "activity_filter_completed": "Completed", + "activity_status_queued": "Queued", + "activity_status_running": "Running", + "activity_status_success": "Success", + "activity_status_failed": "Failed", + "activity_status_cancelled": "Cancelled", + "activity_type_image_pull": "Image Pull", + "activity_type_image_build": "Image Build", + "activity_type_image_update_check": "Image Update Check", + "activity_type_project_pull": "Project Pull", + "activity_type_project_build": "Project Build", + "activity_type_project_deploy": "Project Deploy", + "activity_type_project_redeploy": "Project Redeploy", + "activity_type_project_down": "Project Down", + "activity_type_project_restart": "Project Restart", + "activity_type_project_destroy": "Project Destroy", + "activity_type_container_start": "Container Start", + "activity_type_container_stop": "Container Stop", + "activity_type_container_restart": "Container Restart", + "activity_type_container_redeploy": "Container Redeploy", + "activity_type_container_delete": "Container Delete", + "activity_type_vulnerability_scan": "Vulnerability Scan", + "activity_type_auto_update": "Auto Update", + "activity_type_system_prune": "System Prune", + "activity_type_resource_action": "Resource Action", + "activity_unknown_target": "Unknown target", + "activity_no_message": "No message yet", + "activity_progress_percent": "{progress}%", + "activity_loading": "Loading activity…", + "activity_empty_title": "No activity here", + "activity_empty_description": "Background work will appear here as it starts.", + "activity_step_unknown": "Waiting for the next step", + "activity_duration": "Duration", + "activity_duration_ms": "{ms}ms", + "activity_duration_seconds": "{seconds}s", + "activity_duration_minutes": "{minutes}m {seconds}s", + "activity_output_title": "Output", + "activity_copy_output": "Copy Output", + "activity_output_loading": "Loading output…", + "activity_output_load_failed": "Failed to load activity output", + "activity_output_empty": "No output has been recorded for this activity yet.", + "activity_stream_error": "Lost connection to activity stream", + "activity_select_prompt_title": "Select an activity", + "activity_select_prompt": "Choose a background task to inspect its progress and recent output.", + "activity_view_activity": "View Activity", + "activity_source_environment": "Source environment", + "activity_started_by": "Started by {user}", + "activity_started_by_label": "Started by", + "activity_clear_history": "Clear history", + "activity_clear_history_title": "Clear activity history?", + "activity_clear_history_message": "Completed, failed, and cancelled entries for the selected environment will be deleted. Running activity will stay visible.", + "activity_clear_history_confirm": "Clear History", + "activity_clear_history_success": "Activity history cleared", + "activity_clear_history_failed": "Failed to clear activity history", + "activity_settings_title": "Activity", + "activity_settings_description": "Configure Activity Center history retention for each environment.", + "activity_settings_saved": "Activity settings saved", + "activity_history_section_title": "History Retention", + "activity_history_retention_days": "Retention Days", + "activity_history_retention_days_description": "Delete completed Activity Center entries older than this many days.", + "activity_history_retention_days_placeholder": "30", + "activity_history_retention_days_help": "Set to 0 to disable age-based cleanup.", + "activity_history_max_entries": "Maximum Entries", + "activity_history_max_entries_description": "Keep this many completed Activity Center entries per environment.", + "activity_history_max_entries_placeholder": "1000", + "activity_history_max_entries_help": "Set to 0 to disable count-based cleanup.", + "activity_scan_phase_creating_container": "Creating container", + "activity_scan_phase_scanning_image": "Scanning image", + "activity_scan_phase_storing_results": "Storing results" } diff --git a/frontend/messages/it.json b/frontend/messages/it.json index c2043a09f1..64f2ff12a9 100644 --- a/frontend/messages/it.json +++ b/frontend/messages/it.json @@ -2762,5 +2762,81 @@ "webhook_disable_failed": "Impossibile disattivare il webhook \"{name}\".", "webhook_status_enabled": "Abilitato", "webhook_status_disabled": "Disabili", - "webhook_col_status": "Stato" + "webhook_col_status": "Stato", + "_comment_activity_center": "=== ACTIVITY CENTER ===", + "activity_center_title": "Activity Center", + "activity_center_open": "Open activity center", + "activity_active_count": "{count} active", + "activity_count_many": "9+", + "activity_filter_running": "Running", + "activity_filter_failed": "Failed", + "activity_filter_completed": "Completed", + "activity_status_queued": "Queued", + "activity_status_running": "Running", + "activity_status_success": "Success", + "activity_status_failed": "Failed", + "activity_status_cancelled": "Cancelled", + "activity_type_image_pull": "Image Pull", + "activity_type_image_build": "Image Build", + "activity_type_image_update_check": "Image Update Check", + "activity_type_project_pull": "Project Pull", + "activity_type_project_build": "Project Build", + "activity_type_project_deploy": "Project Deploy", + "activity_type_project_redeploy": "Project Redeploy", + "activity_type_project_down": "Project Down", + "activity_type_project_restart": "Project Restart", + "activity_type_project_destroy": "Project Destroy", + "activity_type_container_start": "Container Start", + "activity_type_container_stop": "Container Stop", + "activity_type_container_restart": "Container Restart", + "activity_type_container_redeploy": "Container Redeploy", + "activity_type_container_delete": "Container Delete", + "activity_type_vulnerability_scan": "Vulnerability Scan", + "activity_type_auto_update": "Auto Update", + "activity_type_system_prune": "System Prune", + "activity_type_resource_action": "Resource Action", + "activity_unknown_target": "Unknown target", + "activity_no_message": "No message yet", + "activity_progress_percent": "{progress}%", + "activity_loading": "Loading activity…", + "activity_empty_title": "No activity here", + "activity_empty_description": "Background work will appear here as it starts.", + "activity_step_unknown": "Waiting for the next step", + "activity_duration": "Duration", + "activity_duration_ms": "{ms}ms", + "activity_duration_seconds": "{seconds}s", + "activity_duration_minutes": "{minutes}m {seconds}s", + "activity_output_title": "Output", + "activity_copy_output": "Copy Output", + "activity_output_loading": "Loading output…", + "activity_output_load_failed": "Failed to load activity output", + "activity_output_empty": "No output has been recorded for this activity yet.", + "activity_stream_error": "Lost connection to activity stream", + "activity_select_prompt_title": "Select an activity", + "activity_select_prompt": "Choose a background task to inspect its progress and recent output.", + "activity_view_activity": "View Activity", + "activity_source_environment": "Source environment", + "activity_started_by": "Started by {user}", + "activity_started_by_label": "Started by", + "activity_clear_history": "Clear history", + "activity_clear_history_title": "Clear activity history?", + "activity_clear_history_message": "Completed, failed, and cancelled entries for the selected environment will be deleted. Running activity will stay visible.", + "activity_clear_history_confirm": "Clear History", + "activity_clear_history_success": "Activity history cleared", + "activity_clear_history_failed": "Failed to clear activity history", + "activity_settings_title": "Activity", + "activity_settings_description": "Configure Activity Center history retention for each environment.", + "activity_settings_saved": "Activity settings saved", + "activity_history_section_title": "History Retention", + "activity_history_retention_days": "Retention Days", + "activity_history_retention_days_description": "Delete completed Activity Center entries older than this many days.", + "activity_history_retention_days_placeholder": "30", + "activity_history_retention_days_help": "Set to 0 to disable age-based cleanup.", + "activity_history_max_entries": "Maximum Entries", + "activity_history_max_entries_description": "Keep this many completed Activity Center entries per environment.", + "activity_history_max_entries_placeholder": "1000", + "activity_history_max_entries_help": "Set to 0 to disable count-based cleanup.", + "activity_scan_phase_creating_container": "Creating container", + "activity_scan_phase_scanning_image": "Scanning image", + "activity_scan_phase_storing_results": "Storing results" } diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index 11855f908f..aa83986472 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -2762,5 +2762,81 @@ "webhook_disable_failed": "ウェブフックの無効化に失敗しました \"{name}\"", "webhook_status_enabled": "有効", "webhook_status_disabled": "無効", - "webhook_col_status": "ステータス" + "webhook_col_status": "ステータス", + "_comment_activity_center": "=== ACTIVITY CENTER ===", + "activity_center_title": "Activity Center", + "activity_center_open": "Open activity center", + "activity_active_count": "{count} active", + "activity_count_many": "9+", + "activity_filter_running": "Running", + "activity_filter_failed": "Failed", + "activity_filter_completed": "Completed", + "activity_status_queued": "Queued", + "activity_status_running": "Running", + "activity_status_success": "Success", + "activity_status_failed": "Failed", + "activity_status_cancelled": "Cancelled", + "activity_type_image_pull": "Image Pull", + "activity_type_image_build": "Image Build", + "activity_type_image_update_check": "Image Update Check", + "activity_type_project_pull": "Project Pull", + "activity_type_project_build": "Project Build", + "activity_type_project_deploy": "Project Deploy", + "activity_type_project_redeploy": "Project Redeploy", + "activity_type_project_down": "Project Down", + "activity_type_project_restart": "Project Restart", + "activity_type_project_destroy": "Project Destroy", + "activity_type_container_start": "Container Start", + "activity_type_container_stop": "Container Stop", + "activity_type_container_restart": "Container Restart", + "activity_type_container_redeploy": "Container Redeploy", + "activity_type_container_delete": "Container Delete", + "activity_type_vulnerability_scan": "Vulnerability Scan", + "activity_type_auto_update": "Auto Update", + "activity_type_system_prune": "System Prune", + "activity_type_resource_action": "Resource Action", + "activity_unknown_target": "Unknown target", + "activity_no_message": "No message yet", + "activity_progress_percent": "{progress}%", + "activity_loading": "Loading activity…", + "activity_empty_title": "No activity here", + "activity_empty_description": "Background work will appear here as it starts.", + "activity_step_unknown": "Waiting for the next step", + "activity_duration": "Duration", + "activity_duration_ms": "{ms}ms", + "activity_duration_seconds": "{seconds}s", + "activity_duration_minutes": "{minutes}m {seconds}s", + "activity_output_title": "Output", + "activity_copy_output": "Copy Output", + "activity_output_loading": "Loading output…", + "activity_output_load_failed": "Failed to load activity output", + "activity_output_empty": "No output has been recorded for this activity yet.", + "activity_stream_error": "Lost connection to activity stream", + "activity_select_prompt_title": "Select an activity", + "activity_select_prompt": "Choose a background task to inspect its progress and recent output.", + "activity_view_activity": "View Activity", + "activity_source_environment": "Source environment", + "activity_started_by": "Started by {user}", + "activity_started_by_label": "Started by", + "activity_clear_history": "Clear history", + "activity_clear_history_title": "Clear activity history?", + "activity_clear_history_message": "Completed, failed, and cancelled entries for the selected environment will be deleted. Running activity will stay visible.", + "activity_clear_history_confirm": "Clear History", + "activity_clear_history_success": "Activity history cleared", + "activity_clear_history_failed": "Failed to clear activity history", + "activity_settings_title": "Activity", + "activity_settings_description": "Configure Activity Center history retention for each environment.", + "activity_settings_saved": "Activity settings saved", + "activity_history_section_title": "History Retention", + "activity_history_retention_days": "Retention Days", + "activity_history_retention_days_description": "Delete completed Activity Center entries older than this many days.", + "activity_history_retention_days_placeholder": "30", + "activity_history_retention_days_help": "Set to 0 to disable age-based cleanup.", + "activity_history_max_entries": "Maximum Entries", + "activity_history_max_entries_description": "Keep this many completed Activity Center entries per environment.", + "activity_history_max_entries_placeholder": "1000", + "activity_history_max_entries_help": "Set to 0 to disable count-based cleanup.", + "activity_scan_phase_creating_container": "Creating container", + "activity_scan_phase_scanning_image": "Scanning image", + "activity_scan_phase_storing_results": "Storing results" } diff --git a/frontend/messages/ko.json b/frontend/messages/ko.json index 430df67a67..bc0206f6df 100644 --- a/frontend/messages/ko.json +++ b/frontend/messages/ko.json @@ -2762,5 +2762,81 @@ "webhook_disable_failed": "웹훅 \"{name}\" 비활성화에 실패했습니다.", "webhook_status_enabled": "활성화됨", "webhook_status_disabled": "비활성화됨", - "webhook_col_status": "상태" + "webhook_col_status": "상태", + "_comment_activity_center": "=== ACTIVITY CENTER ===", + "activity_center_title": "Activity Center", + "activity_center_open": "Open activity center", + "activity_active_count": "{count} active", + "activity_count_many": "9+", + "activity_filter_running": "Running", + "activity_filter_failed": "Failed", + "activity_filter_completed": "Completed", + "activity_status_queued": "Queued", + "activity_status_running": "Running", + "activity_status_success": "Success", + "activity_status_failed": "Failed", + "activity_status_cancelled": "Cancelled", + "activity_type_image_pull": "Image Pull", + "activity_type_image_build": "Image Build", + "activity_type_image_update_check": "Image Update Check", + "activity_type_project_pull": "Project Pull", + "activity_type_project_build": "Project Build", + "activity_type_project_deploy": "Project Deploy", + "activity_type_project_redeploy": "Project Redeploy", + "activity_type_project_down": "Project Down", + "activity_type_project_restart": "Project Restart", + "activity_type_project_destroy": "Project Destroy", + "activity_type_container_start": "Container Start", + "activity_type_container_stop": "Container Stop", + "activity_type_container_restart": "Container Restart", + "activity_type_container_redeploy": "Container Redeploy", + "activity_type_container_delete": "Container Delete", + "activity_type_vulnerability_scan": "Vulnerability Scan", + "activity_type_auto_update": "Auto Update", + "activity_type_system_prune": "System Prune", + "activity_type_resource_action": "Resource Action", + "activity_unknown_target": "Unknown target", + "activity_no_message": "No message yet", + "activity_progress_percent": "{progress}%", + "activity_loading": "Loading activity…", + "activity_empty_title": "No activity here", + "activity_empty_description": "Background work will appear here as it starts.", + "activity_step_unknown": "Waiting for the next step", + "activity_duration": "Duration", + "activity_duration_ms": "{ms}ms", + "activity_duration_seconds": "{seconds}s", + "activity_duration_minutes": "{minutes}m {seconds}s", + "activity_output_title": "Output", + "activity_copy_output": "Copy Output", + "activity_output_loading": "Loading output…", + "activity_output_load_failed": "Failed to load activity output", + "activity_output_empty": "No output has been recorded for this activity yet.", + "activity_stream_error": "Lost connection to activity stream", + "activity_select_prompt_title": "Select an activity", + "activity_select_prompt": "Choose a background task to inspect its progress and recent output.", + "activity_view_activity": "View Activity", + "activity_source_environment": "Source environment", + "activity_started_by": "Started by {user}", + "activity_started_by_label": "Started by", + "activity_clear_history": "Clear history", + "activity_clear_history_title": "Clear activity history?", + "activity_clear_history_message": "Completed, failed, and cancelled entries for the selected environment will be deleted. Running activity will stay visible.", + "activity_clear_history_confirm": "Clear History", + "activity_clear_history_success": "Activity history cleared", + "activity_clear_history_failed": "Failed to clear activity history", + "activity_settings_title": "Activity", + "activity_settings_description": "Configure Activity Center history retention for each environment.", + "activity_settings_saved": "Activity settings saved", + "activity_history_section_title": "History Retention", + "activity_history_retention_days": "Retention Days", + "activity_history_retention_days_description": "Delete completed Activity Center entries older than this many days.", + "activity_history_retention_days_placeholder": "30", + "activity_history_retention_days_help": "Set to 0 to disable age-based cleanup.", + "activity_history_max_entries": "Maximum Entries", + "activity_history_max_entries_description": "Keep this many completed Activity Center entries per environment.", + "activity_history_max_entries_placeholder": "1000", + "activity_history_max_entries_help": "Set to 0 to disable count-based cleanup.", + "activity_scan_phase_creating_container": "Creating container", + "activity_scan_phase_scanning_image": "Scanning image", + "activity_scan_phase_storing_results": "Storing results" } diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index d6aab12a70..5e6d7d6723 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -2762,5 +2762,81 @@ "webhook_disable_failed": "Mislukt bij het uitschakelen van webhook \"{name}\"", "webhook_status_enabled": "Ingeschakeld", "webhook_status_disabled": "Uitgeschakeld", - "webhook_col_status": "Status" + "webhook_col_status": "Status", + "_comment_activity_center": "=== ACTIVITY CENTER ===", + "activity_center_title": "Activity Center", + "activity_center_open": "Open activity center", + "activity_active_count": "{count} active", + "activity_count_many": "9+", + "activity_filter_running": "Running", + "activity_filter_failed": "Failed", + "activity_filter_completed": "Completed", + "activity_status_queued": "Queued", + "activity_status_running": "Running", + "activity_status_success": "Success", + "activity_status_failed": "Failed", + "activity_status_cancelled": "Cancelled", + "activity_type_image_pull": "Image Pull", + "activity_type_image_build": "Image Build", + "activity_type_image_update_check": "Image Update Check", + "activity_type_project_pull": "Project Pull", + "activity_type_project_build": "Project Build", + "activity_type_project_deploy": "Project Deploy", + "activity_type_project_redeploy": "Project Redeploy", + "activity_type_project_down": "Project Down", + "activity_type_project_restart": "Project Restart", + "activity_type_project_destroy": "Project Destroy", + "activity_type_container_start": "Container Start", + "activity_type_container_stop": "Container Stop", + "activity_type_container_restart": "Container Restart", + "activity_type_container_redeploy": "Container Redeploy", + "activity_type_container_delete": "Container Delete", + "activity_type_vulnerability_scan": "Vulnerability Scan", + "activity_type_auto_update": "Auto Update", + "activity_type_system_prune": "System Prune", + "activity_type_resource_action": "Resource Action", + "activity_unknown_target": "Unknown target", + "activity_no_message": "No message yet", + "activity_progress_percent": "{progress}%", + "activity_loading": "Loading activity…", + "activity_empty_title": "No activity here", + "activity_empty_description": "Background work will appear here as it starts.", + "activity_step_unknown": "Waiting for the next step", + "activity_duration": "Duration", + "activity_duration_ms": "{ms}ms", + "activity_duration_seconds": "{seconds}s", + "activity_duration_minutes": "{minutes}m {seconds}s", + "activity_output_title": "Output", + "activity_copy_output": "Copy Output", + "activity_output_loading": "Loading output…", + "activity_output_load_failed": "Failed to load activity output", + "activity_output_empty": "No output has been recorded for this activity yet.", + "activity_stream_error": "Lost connection to activity stream", + "activity_select_prompt_title": "Select an activity", + "activity_select_prompt": "Choose a background task to inspect its progress and recent output.", + "activity_view_activity": "View Activity", + "activity_source_environment": "Source environment", + "activity_started_by": "Started by {user}", + "activity_started_by_label": "Started by", + "activity_clear_history": "Clear history", + "activity_clear_history_title": "Clear activity history?", + "activity_clear_history_message": "Completed, failed, and cancelled entries for the selected environment will be deleted. Running activity will stay visible.", + "activity_clear_history_confirm": "Clear History", + "activity_clear_history_success": "Activity history cleared", + "activity_clear_history_failed": "Failed to clear activity history", + "activity_settings_title": "Activity", + "activity_settings_description": "Configure Activity Center history retention for each environment.", + "activity_settings_saved": "Activity settings saved", + "activity_history_section_title": "History Retention", + "activity_history_retention_days": "Retention Days", + "activity_history_retention_days_description": "Delete completed Activity Center entries older than this many days.", + "activity_history_retention_days_placeholder": "30", + "activity_history_retention_days_help": "Set to 0 to disable age-based cleanup.", + "activity_history_max_entries": "Maximum Entries", + "activity_history_max_entries_description": "Keep this many completed Activity Center entries per environment.", + "activity_history_max_entries_placeholder": "1000", + "activity_history_max_entries_help": "Set to 0 to disable count-based cleanup.", + "activity_scan_phase_creating_container": "Creating container", + "activity_scan_phase_scanning_image": "Scanning image", + "activity_scan_phase_storing_results": "Storing results" } diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json index 2b7b012423..8089a43a04 100644 --- a/frontend/messages/pt-BR.json +++ b/frontend/messages/pt-BR.json @@ -2762,5 +2762,81 @@ "webhook_disable_failed": "Falha ao desativar o webhook \"{name}\"", "webhook_status_enabled": "Ativado", "webhook_status_disabled": "Desativado", - "webhook_col_status": "Status" + "webhook_col_status": "Status", + "_comment_activity_center": "=== ACTIVITY CENTER ===", + "activity_center_title": "Activity Center", + "activity_center_open": "Open activity center", + "activity_active_count": "{count} active", + "activity_count_many": "9+", + "activity_filter_running": "Running", + "activity_filter_failed": "Failed", + "activity_filter_completed": "Completed", + "activity_status_queued": "Queued", + "activity_status_running": "Running", + "activity_status_success": "Success", + "activity_status_failed": "Failed", + "activity_status_cancelled": "Cancelled", + "activity_type_image_pull": "Image Pull", + "activity_type_image_build": "Image Build", + "activity_type_image_update_check": "Image Update Check", + "activity_type_project_pull": "Project Pull", + "activity_type_project_build": "Project Build", + "activity_type_project_deploy": "Project Deploy", + "activity_type_project_redeploy": "Project Redeploy", + "activity_type_project_down": "Project Down", + "activity_type_project_restart": "Project Restart", + "activity_type_project_destroy": "Project Destroy", + "activity_type_container_start": "Container Start", + "activity_type_container_stop": "Container Stop", + "activity_type_container_restart": "Container Restart", + "activity_type_container_redeploy": "Container Redeploy", + "activity_type_container_delete": "Container Delete", + "activity_type_vulnerability_scan": "Vulnerability Scan", + "activity_type_auto_update": "Auto Update", + "activity_type_system_prune": "System Prune", + "activity_type_resource_action": "Resource Action", + "activity_unknown_target": "Unknown target", + "activity_no_message": "No message yet", + "activity_progress_percent": "{progress}%", + "activity_loading": "Loading activity…", + "activity_empty_title": "No activity here", + "activity_empty_description": "Background work will appear here as it starts.", + "activity_step_unknown": "Waiting for the next step", + "activity_duration": "Duration", + "activity_duration_ms": "{ms}ms", + "activity_duration_seconds": "{seconds}s", + "activity_duration_minutes": "{minutes}m {seconds}s", + "activity_output_title": "Output", + "activity_copy_output": "Copy Output", + "activity_output_loading": "Loading output…", + "activity_output_load_failed": "Failed to load activity output", + "activity_output_empty": "No output has been recorded for this activity yet.", + "activity_stream_error": "Lost connection to activity stream", + "activity_select_prompt_title": "Select an activity", + "activity_select_prompt": "Choose a background task to inspect its progress and recent output.", + "activity_view_activity": "View Activity", + "activity_source_environment": "Source environment", + "activity_started_by": "Started by {user}", + "activity_started_by_label": "Started by", + "activity_clear_history": "Clear history", + "activity_clear_history_title": "Clear activity history?", + "activity_clear_history_message": "Completed, failed, and cancelled entries for the selected environment will be deleted. Running activity will stay visible.", + "activity_clear_history_confirm": "Clear History", + "activity_clear_history_success": "Activity history cleared", + "activity_clear_history_failed": "Failed to clear activity history", + "activity_settings_title": "Activity", + "activity_settings_description": "Configure Activity Center history retention for each environment.", + "activity_settings_saved": "Activity settings saved", + "activity_history_section_title": "History Retention", + "activity_history_retention_days": "Retention Days", + "activity_history_retention_days_description": "Delete completed Activity Center entries older than this many days.", + "activity_history_retention_days_placeholder": "30", + "activity_history_retention_days_help": "Set to 0 to disable age-based cleanup.", + "activity_history_max_entries": "Maximum Entries", + "activity_history_max_entries_description": "Keep this many completed Activity Center entries per environment.", + "activity_history_max_entries_placeholder": "1000", + "activity_history_max_entries_help": "Set to 0 to disable count-based cleanup.", + "activity_scan_phase_creating_container": "Creating container", + "activity_scan_phase_scanning_image": "Scanning image", + "activity_scan_phase_storing_results": "Storing results" } diff --git a/frontend/messages/pt.json b/frontend/messages/pt.json index 314982c659..c41b381113 100644 --- a/frontend/messages/pt.json +++ b/frontend/messages/pt.json @@ -1528,5 +1528,81 @@ "environments_generate_config": "Generate Agent Configuration", "environments_connect_existing_agent": "Connect to Existing Agent", "environments_connect_existing_agent_description": "Connect to an already-running agent using a bootstrap token.", - "project_files": "Arquivos de Projeto" + "project_files": "Arquivos de Projeto", + "_comment_activity_center": "=== ACTIVITY CENTER ===", + "activity_center_title": "Activity Center", + "activity_center_open": "Open activity center", + "activity_active_count": "{count} active", + "activity_count_many": "9+", + "activity_filter_running": "Running", + "activity_filter_failed": "Failed", + "activity_filter_completed": "Completed", + "activity_status_queued": "Queued", + "activity_status_running": "Running", + "activity_status_success": "Success", + "activity_status_failed": "Failed", + "activity_status_cancelled": "Cancelled", + "activity_type_image_pull": "Image Pull", + "activity_type_image_build": "Image Build", + "activity_type_image_update_check": "Image Update Check", + "activity_type_project_pull": "Project Pull", + "activity_type_project_build": "Project Build", + "activity_type_project_deploy": "Project Deploy", + "activity_type_project_redeploy": "Project Redeploy", + "activity_type_project_down": "Project Down", + "activity_type_project_restart": "Project Restart", + "activity_type_project_destroy": "Project Destroy", + "activity_type_container_start": "Container Start", + "activity_type_container_stop": "Container Stop", + "activity_type_container_restart": "Container Restart", + "activity_type_container_redeploy": "Container Redeploy", + "activity_type_container_delete": "Container Delete", + "activity_type_vulnerability_scan": "Vulnerability Scan", + "activity_type_auto_update": "Auto Update", + "activity_type_system_prune": "System Prune", + "activity_type_resource_action": "Resource Action", + "activity_unknown_target": "Unknown target", + "activity_no_message": "No message yet", + "activity_progress_percent": "{progress}%", + "activity_loading": "Loading activity…", + "activity_empty_title": "No activity here", + "activity_empty_description": "Background work will appear here as it starts.", + "activity_step_unknown": "Waiting for the next step", + "activity_duration": "Duration", + "activity_duration_ms": "{ms}ms", + "activity_duration_seconds": "{seconds}s", + "activity_duration_minutes": "{minutes}m {seconds}s", + "activity_output_title": "Output", + "activity_copy_output": "Copy Output", + "activity_output_loading": "Loading output…", + "activity_output_load_failed": "Failed to load activity output", + "activity_output_empty": "No output has been recorded for this activity yet.", + "activity_stream_error": "Lost connection to activity stream", + "activity_select_prompt_title": "Select an activity", + "activity_select_prompt": "Choose a background task to inspect its progress and recent output.", + "activity_view_activity": "View Activity", + "activity_source_environment": "Source environment", + "activity_started_by": "Started by {user}", + "activity_started_by_label": "Started by", + "activity_clear_history": "Clear history", + "activity_clear_history_title": "Clear activity history?", + "activity_clear_history_message": "Completed, failed, and cancelled entries for the selected environment will be deleted. Running activity will stay visible.", + "activity_clear_history_confirm": "Clear History", + "activity_clear_history_success": "Activity history cleared", + "activity_clear_history_failed": "Failed to clear activity history", + "activity_settings_title": "Activity", + "activity_settings_description": "Configure Activity Center history retention for each environment.", + "activity_settings_saved": "Activity settings saved", + "activity_history_section_title": "History Retention", + "activity_history_retention_days": "Retention Days", + "activity_history_retention_days_description": "Delete completed Activity Center entries older than this many days.", + "activity_history_retention_days_placeholder": "30", + "activity_history_retention_days_help": "Set to 0 to disable age-based cleanup.", + "activity_history_max_entries": "Maximum Entries", + "activity_history_max_entries_description": "Keep this many completed Activity Center entries per environment.", + "activity_history_max_entries_placeholder": "1000", + "activity_history_max_entries_help": "Set to 0 to disable count-based cleanup.", + "activity_scan_phase_creating_container": "Creating container", + "activity_scan_phase_scanning_image": "Scanning image", + "activity_scan_phase_storing_results": "Storing results" } diff --git a/frontend/messages/ru.json b/frontend/messages/ru.json index 3c3ea3d944..ed8ca47245 100644 --- a/frontend/messages/ru.json +++ b/frontend/messages/ru.json @@ -2762,5 +2762,81 @@ "webhook_disable_failed": "Не удалось отключить Webhook \"{name}\"", "webhook_status_enabled": "Включено", "webhook_status_disabled": "Отключено", - "webhook_col_status": "Статус" + "webhook_col_status": "Статус", + "_comment_activity_center": "=== ACTIVITY CENTER ===", + "activity_center_title": "Activity Center", + "activity_center_open": "Open activity center", + "activity_active_count": "{count} active", + "activity_count_many": "9+", + "activity_filter_running": "Running", + "activity_filter_failed": "Failed", + "activity_filter_completed": "Completed", + "activity_status_queued": "Queued", + "activity_status_running": "Running", + "activity_status_success": "Success", + "activity_status_failed": "Failed", + "activity_status_cancelled": "Cancelled", + "activity_type_image_pull": "Image Pull", + "activity_type_image_build": "Image Build", + "activity_type_image_update_check": "Image Update Check", + "activity_type_project_pull": "Project Pull", + "activity_type_project_build": "Project Build", + "activity_type_project_deploy": "Project Deploy", + "activity_type_project_redeploy": "Project Redeploy", + "activity_type_project_down": "Project Down", + "activity_type_project_restart": "Project Restart", + "activity_type_project_destroy": "Project Destroy", + "activity_type_container_start": "Container Start", + "activity_type_container_stop": "Container Stop", + "activity_type_container_restart": "Container Restart", + "activity_type_container_redeploy": "Container Redeploy", + "activity_type_container_delete": "Container Delete", + "activity_type_vulnerability_scan": "Vulnerability Scan", + "activity_type_auto_update": "Auto Update", + "activity_type_system_prune": "System Prune", + "activity_type_resource_action": "Resource Action", + "activity_unknown_target": "Unknown target", + "activity_no_message": "No message yet", + "activity_progress_percent": "{progress}%", + "activity_loading": "Loading activity…", + "activity_empty_title": "No activity here", + "activity_empty_description": "Background work will appear here as it starts.", + "activity_step_unknown": "Waiting for the next step", + "activity_duration": "Duration", + "activity_duration_ms": "{ms}ms", + "activity_duration_seconds": "{seconds}s", + "activity_duration_minutes": "{minutes}m {seconds}s", + "activity_output_title": "Output", + "activity_copy_output": "Copy Output", + "activity_output_loading": "Loading output…", + "activity_output_load_failed": "Failed to load activity output", + "activity_output_empty": "No output has been recorded for this activity yet.", + "activity_stream_error": "Lost connection to activity stream", + "activity_select_prompt_title": "Select an activity", + "activity_select_prompt": "Choose a background task to inspect its progress and recent output.", + "activity_view_activity": "View Activity", + "activity_source_environment": "Source environment", + "activity_started_by": "Started by {user}", + "activity_started_by_label": "Started by", + "activity_clear_history": "Clear history", + "activity_clear_history_title": "Clear activity history?", + "activity_clear_history_message": "Completed, failed, and cancelled entries for the selected environment will be deleted. Running activity will stay visible.", + "activity_clear_history_confirm": "Clear History", + "activity_clear_history_success": "Activity history cleared", + "activity_clear_history_failed": "Failed to clear activity history", + "activity_settings_title": "Activity", + "activity_settings_description": "Configure Activity Center history retention for each environment.", + "activity_settings_saved": "Activity settings saved", + "activity_history_section_title": "History Retention", + "activity_history_retention_days": "Retention Days", + "activity_history_retention_days_description": "Delete completed Activity Center entries older than this many days.", + "activity_history_retention_days_placeholder": "30", + "activity_history_retention_days_help": "Set to 0 to disable age-based cleanup.", + "activity_history_max_entries": "Maximum Entries", + "activity_history_max_entries_description": "Keep this many completed Activity Center entries per environment.", + "activity_history_max_entries_placeholder": "1000", + "activity_history_max_entries_help": "Set to 0 to disable count-based cleanup.", + "activity_scan_phase_creating_container": "Creating container", + "activity_scan_phase_scanning_image": "Scanning image", + "activity_scan_phase_storing_results": "Storing results" } diff --git a/frontend/messages/sv.json b/frontend/messages/sv.json index 6979813bd9..64fadc152b 100644 --- a/frontend/messages/sv.json +++ b/frontend/messages/sv.json @@ -2762,5 +2762,81 @@ "webhook_disable_failed": "Misslyckades med att inaktivera webhook \"{name}\"", "webhook_status_enabled": "Aktiverad", "webhook_status_disabled": "Inaktiverad", - "webhook_col_status": "Status" + "webhook_col_status": "Status", + "_comment_activity_center": "=== ACTIVITY CENTER ===", + "activity_center_title": "Activity Center", + "activity_center_open": "Open activity center", + "activity_active_count": "{count} active", + "activity_count_many": "9+", + "activity_filter_running": "Running", + "activity_filter_failed": "Failed", + "activity_filter_completed": "Completed", + "activity_status_queued": "Queued", + "activity_status_running": "Running", + "activity_status_success": "Success", + "activity_status_failed": "Failed", + "activity_status_cancelled": "Cancelled", + "activity_type_image_pull": "Image Pull", + "activity_type_image_build": "Image Build", + "activity_type_image_update_check": "Image Update Check", + "activity_type_project_pull": "Project Pull", + "activity_type_project_build": "Project Build", + "activity_type_project_deploy": "Project Deploy", + "activity_type_project_redeploy": "Project Redeploy", + "activity_type_project_down": "Project Down", + "activity_type_project_restart": "Project Restart", + "activity_type_project_destroy": "Project Destroy", + "activity_type_container_start": "Container Start", + "activity_type_container_stop": "Container Stop", + "activity_type_container_restart": "Container Restart", + "activity_type_container_redeploy": "Container Redeploy", + "activity_type_container_delete": "Container Delete", + "activity_type_vulnerability_scan": "Vulnerability Scan", + "activity_type_auto_update": "Auto Update", + "activity_type_system_prune": "System Prune", + "activity_type_resource_action": "Resource Action", + "activity_unknown_target": "Unknown target", + "activity_no_message": "No message yet", + "activity_progress_percent": "{progress}%", + "activity_loading": "Loading activity…", + "activity_empty_title": "No activity here", + "activity_empty_description": "Background work will appear here as it starts.", + "activity_step_unknown": "Waiting for the next step", + "activity_duration": "Duration", + "activity_duration_ms": "{ms}ms", + "activity_duration_seconds": "{seconds}s", + "activity_duration_minutes": "{minutes}m {seconds}s", + "activity_output_title": "Output", + "activity_copy_output": "Copy Output", + "activity_output_loading": "Loading output…", + "activity_output_load_failed": "Failed to load activity output", + "activity_output_empty": "No output has been recorded for this activity yet.", + "activity_stream_error": "Lost connection to activity stream", + "activity_select_prompt_title": "Select an activity", + "activity_select_prompt": "Choose a background task to inspect its progress and recent output.", + "activity_view_activity": "View Activity", + "activity_source_environment": "Source environment", + "activity_started_by": "Started by {user}", + "activity_started_by_label": "Started by", + "activity_clear_history": "Clear history", + "activity_clear_history_title": "Clear activity history?", + "activity_clear_history_message": "Completed, failed, and cancelled entries for the selected environment will be deleted. Running activity will stay visible.", + "activity_clear_history_confirm": "Clear History", + "activity_clear_history_success": "Activity history cleared", + "activity_clear_history_failed": "Failed to clear activity history", + "activity_settings_title": "Activity", + "activity_settings_description": "Configure Activity Center history retention for each environment.", + "activity_settings_saved": "Activity settings saved", + "activity_history_section_title": "History Retention", + "activity_history_retention_days": "Retention Days", + "activity_history_retention_days_description": "Delete completed Activity Center entries older than this many days.", + "activity_history_retention_days_placeholder": "30", + "activity_history_retention_days_help": "Set to 0 to disable age-based cleanup.", + "activity_history_max_entries": "Maximum Entries", + "activity_history_max_entries_description": "Keep this many completed Activity Center entries per environment.", + "activity_history_max_entries_placeholder": "1000", + "activity_history_max_entries_help": "Set to 0 to disable count-based cleanup.", + "activity_scan_phase_creating_container": "Creating container", + "activity_scan_phase_scanning_image": "Scanning image", + "activity_scan_phase_storing_results": "Storing results" } diff --git a/frontend/messages/uk.json b/frontend/messages/uk.json index d587d31db9..ef758f9165 100644 --- a/frontend/messages/uk.json +++ b/frontend/messages/uk.json @@ -2762,5 +2762,81 @@ "webhook_disable_failed": "Не вдалося відключити веб-хук \"{name}\"", "webhook_status_enabled": "Увімкнено", "webhook_status_disabled": "Інваліди", - "webhook_col_status": "Статус" + "webhook_col_status": "Статус", + "_comment_activity_center": "=== ACTIVITY CENTER ===", + "activity_center_title": "Activity Center", + "activity_center_open": "Open activity center", + "activity_active_count": "{count} active", + "activity_count_many": "9+", + "activity_filter_running": "Running", + "activity_filter_failed": "Failed", + "activity_filter_completed": "Completed", + "activity_status_queued": "Queued", + "activity_status_running": "Running", + "activity_status_success": "Success", + "activity_status_failed": "Failed", + "activity_status_cancelled": "Cancelled", + "activity_type_image_pull": "Image Pull", + "activity_type_image_build": "Image Build", + "activity_type_image_update_check": "Image Update Check", + "activity_type_project_pull": "Project Pull", + "activity_type_project_build": "Project Build", + "activity_type_project_deploy": "Project Deploy", + "activity_type_project_redeploy": "Project Redeploy", + "activity_type_project_down": "Project Down", + "activity_type_project_restart": "Project Restart", + "activity_type_project_destroy": "Project Destroy", + "activity_type_container_start": "Container Start", + "activity_type_container_stop": "Container Stop", + "activity_type_container_restart": "Container Restart", + "activity_type_container_redeploy": "Container Redeploy", + "activity_type_container_delete": "Container Delete", + "activity_type_vulnerability_scan": "Vulnerability Scan", + "activity_type_auto_update": "Auto Update", + "activity_type_system_prune": "System Prune", + "activity_type_resource_action": "Resource Action", + "activity_unknown_target": "Unknown target", + "activity_no_message": "No message yet", + "activity_progress_percent": "{progress}%", + "activity_loading": "Loading activity…", + "activity_empty_title": "No activity here", + "activity_empty_description": "Background work will appear here as it starts.", + "activity_step_unknown": "Waiting for the next step", + "activity_duration": "Duration", + "activity_duration_ms": "{ms}ms", + "activity_duration_seconds": "{seconds}s", + "activity_duration_minutes": "{minutes}m {seconds}s", + "activity_output_title": "Output", + "activity_copy_output": "Copy Output", + "activity_output_loading": "Loading output…", + "activity_output_load_failed": "Failed to load activity output", + "activity_output_empty": "No output has been recorded for this activity yet.", + "activity_stream_error": "Lost connection to activity stream", + "activity_select_prompt_title": "Select an activity", + "activity_select_prompt": "Choose a background task to inspect its progress and recent output.", + "activity_view_activity": "View Activity", + "activity_source_environment": "Source environment", + "activity_started_by": "Started by {user}", + "activity_started_by_label": "Started by", + "activity_clear_history": "Clear history", + "activity_clear_history_title": "Clear activity history?", + "activity_clear_history_message": "Completed, failed, and cancelled entries for the selected environment will be deleted. Running activity will stay visible.", + "activity_clear_history_confirm": "Clear History", + "activity_clear_history_success": "Activity history cleared", + "activity_clear_history_failed": "Failed to clear activity history", + "activity_settings_title": "Activity", + "activity_settings_description": "Configure Activity Center history retention for each environment.", + "activity_settings_saved": "Activity settings saved", + "activity_history_section_title": "History Retention", + "activity_history_retention_days": "Retention Days", + "activity_history_retention_days_description": "Delete completed Activity Center entries older than this many days.", + "activity_history_retention_days_placeholder": "30", + "activity_history_retention_days_help": "Set to 0 to disable age-based cleanup.", + "activity_history_max_entries": "Maximum Entries", + "activity_history_max_entries_description": "Keep this many completed Activity Center entries per environment.", + "activity_history_max_entries_placeholder": "1000", + "activity_history_max_entries_help": "Set to 0 to disable count-based cleanup.", + "activity_scan_phase_creating_container": "Creating container", + "activity_scan_phase_scanning_image": "Scanning image", + "activity_scan_phase_storing_results": "Storing results" } diff --git a/frontend/messages/vi.json b/frontend/messages/vi.json index 3e27d27fd9..2e0323a3e4 100644 --- a/frontend/messages/vi.json +++ b/frontend/messages/vi.json @@ -2762,5 +2762,81 @@ "webhook_disable_failed": "Không thể tắt webhook \"{name}\"", "webhook_status_enabled": "Đã bật", "webhook_status_disabled": "Người khuyết tật", - "webhook_col_status": "Trạng thái" + "webhook_col_status": "Trạng thái", + "_comment_activity_center": "=== ACTIVITY CENTER ===", + "activity_center_title": "Activity Center", + "activity_center_open": "Open activity center", + "activity_active_count": "{count} active", + "activity_count_many": "9+", + "activity_filter_running": "Running", + "activity_filter_failed": "Failed", + "activity_filter_completed": "Completed", + "activity_status_queued": "Queued", + "activity_status_running": "Running", + "activity_status_success": "Success", + "activity_status_failed": "Failed", + "activity_status_cancelled": "Cancelled", + "activity_type_image_pull": "Image Pull", + "activity_type_image_build": "Image Build", + "activity_type_image_update_check": "Image Update Check", + "activity_type_project_pull": "Project Pull", + "activity_type_project_build": "Project Build", + "activity_type_project_deploy": "Project Deploy", + "activity_type_project_redeploy": "Project Redeploy", + "activity_type_project_down": "Project Down", + "activity_type_project_restart": "Project Restart", + "activity_type_project_destroy": "Project Destroy", + "activity_type_container_start": "Container Start", + "activity_type_container_stop": "Container Stop", + "activity_type_container_restart": "Container Restart", + "activity_type_container_redeploy": "Container Redeploy", + "activity_type_container_delete": "Container Delete", + "activity_type_vulnerability_scan": "Vulnerability Scan", + "activity_type_auto_update": "Auto Update", + "activity_type_system_prune": "System Prune", + "activity_type_resource_action": "Resource Action", + "activity_unknown_target": "Unknown target", + "activity_no_message": "No message yet", + "activity_progress_percent": "{progress}%", + "activity_loading": "Loading activity…", + "activity_empty_title": "No activity here", + "activity_empty_description": "Background work will appear here as it starts.", + "activity_step_unknown": "Waiting for the next step", + "activity_duration": "Duration", + "activity_duration_ms": "{ms}ms", + "activity_duration_seconds": "{seconds}s", + "activity_duration_minutes": "{minutes}m {seconds}s", + "activity_output_title": "Output", + "activity_copy_output": "Copy Output", + "activity_output_loading": "Loading output…", + "activity_output_load_failed": "Failed to load activity output", + "activity_output_empty": "No output has been recorded for this activity yet.", + "activity_stream_error": "Lost connection to activity stream", + "activity_select_prompt_title": "Select an activity", + "activity_select_prompt": "Choose a background task to inspect its progress and recent output.", + "activity_view_activity": "View Activity", + "activity_source_environment": "Source environment", + "activity_started_by": "Started by {user}", + "activity_started_by_label": "Started by", + "activity_clear_history": "Clear history", + "activity_clear_history_title": "Clear activity history?", + "activity_clear_history_message": "Completed, failed, and cancelled entries for the selected environment will be deleted. Running activity will stay visible.", + "activity_clear_history_confirm": "Clear History", + "activity_clear_history_success": "Activity history cleared", + "activity_clear_history_failed": "Failed to clear activity history", + "activity_settings_title": "Activity", + "activity_settings_description": "Configure Activity Center history retention for each environment.", + "activity_settings_saved": "Activity settings saved", + "activity_history_section_title": "History Retention", + "activity_history_retention_days": "Retention Days", + "activity_history_retention_days_description": "Delete completed Activity Center entries older than this many days.", + "activity_history_retention_days_placeholder": "30", + "activity_history_retention_days_help": "Set to 0 to disable age-based cleanup.", + "activity_history_max_entries": "Maximum Entries", + "activity_history_max_entries_description": "Keep this many completed Activity Center entries per environment.", + "activity_history_max_entries_placeholder": "1000", + "activity_history_max_entries_help": "Set to 0 to disable count-based cleanup.", + "activity_scan_phase_creating_container": "Creating container", + "activity_scan_phase_scanning_image": "Scanning image", + "activity_scan_phase_storing_results": "Storing results" } diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json index cb1d56d3df..bbff6187b3 100644 --- a/frontend/messages/zh-CN.json +++ b/frontend/messages/zh-CN.json @@ -2762,5 +2762,81 @@ "webhook_disable_failed": "禁用 Webhook \"{name}\" 失败 ", "webhook_status_enabled": "启用", "webhook_status_disabled": "禁用", - "webhook_col_status": "状态" + "webhook_col_status": "状态", + "_comment_activity_center": "=== ACTIVITY CENTER ===", + "activity_center_title": "Activity Center", + "activity_center_open": "Open activity center", + "activity_active_count": "{count} active", + "activity_count_many": "9+", + "activity_filter_running": "Running", + "activity_filter_failed": "Failed", + "activity_filter_completed": "Completed", + "activity_status_queued": "Queued", + "activity_status_running": "Running", + "activity_status_success": "Success", + "activity_status_failed": "Failed", + "activity_status_cancelled": "Cancelled", + "activity_type_image_pull": "Image Pull", + "activity_type_image_build": "Image Build", + "activity_type_image_update_check": "Image Update Check", + "activity_type_project_pull": "Project Pull", + "activity_type_project_build": "Project Build", + "activity_type_project_deploy": "Project Deploy", + "activity_type_project_redeploy": "Project Redeploy", + "activity_type_project_down": "Project Down", + "activity_type_project_restart": "Project Restart", + "activity_type_project_destroy": "Project Destroy", + "activity_type_container_start": "Container Start", + "activity_type_container_stop": "Container Stop", + "activity_type_container_restart": "Container Restart", + "activity_type_container_redeploy": "Container Redeploy", + "activity_type_container_delete": "Container Delete", + "activity_type_vulnerability_scan": "Vulnerability Scan", + "activity_type_auto_update": "Auto Update", + "activity_type_system_prune": "System Prune", + "activity_type_resource_action": "Resource Action", + "activity_unknown_target": "Unknown target", + "activity_no_message": "No message yet", + "activity_progress_percent": "{progress}%", + "activity_loading": "Loading activity…", + "activity_empty_title": "No activity here", + "activity_empty_description": "Background work will appear here as it starts.", + "activity_step_unknown": "Waiting for the next step", + "activity_duration": "Duration", + "activity_duration_ms": "{ms}ms", + "activity_duration_seconds": "{seconds}s", + "activity_duration_minutes": "{minutes}m {seconds}s", + "activity_output_title": "Output", + "activity_copy_output": "Copy Output", + "activity_output_loading": "Loading output…", + "activity_output_load_failed": "Failed to load activity output", + "activity_output_empty": "No output has been recorded for this activity yet.", + "activity_stream_error": "Lost connection to activity stream", + "activity_select_prompt_title": "Select an activity", + "activity_select_prompt": "Choose a background task to inspect its progress and recent output.", + "activity_view_activity": "View Activity", + "activity_source_environment": "Source environment", + "activity_started_by": "Started by {user}", + "activity_started_by_label": "Started by", + "activity_clear_history": "Clear history", + "activity_clear_history_title": "Clear activity history?", + "activity_clear_history_message": "Completed, failed, and cancelled entries for the selected environment will be deleted. Running activity will stay visible.", + "activity_clear_history_confirm": "Clear History", + "activity_clear_history_success": "Activity history cleared", + "activity_clear_history_failed": "Failed to clear activity history", + "activity_settings_title": "Activity", + "activity_settings_description": "Configure Activity Center history retention for each environment.", + "activity_settings_saved": "Activity settings saved", + "activity_history_section_title": "History Retention", + "activity_history_retention_days": "Retention Days", + "activity_history_retention_days_description": "Delete completed Activity Center entries older than this many days.", + "activity_history_retention_days_placeholder": "30", + "activity_history_retention_days_help": "Set to 0 to disable age-based cleanup.", + "activity_history_max_entries": "Maximum Entries", + "activity_history_max_entries_description": "Keep this many completed Activity Center entries per environment.", + "activity_history_max_entries_placeholder": "1000", + "activity_history_max_entries_help": "Set to 0 to disable count-based cleanup.", + "activity_scan_phase_creating_container": "Creating container", + "activity_scan_phase_scanning_image": "Scanning image", + "activity_scan_phase_storing_results": "Storing results" } diff --git a/frontend/messages/zh-TW.json b/frontend/messages/zh-TW.json index a128a19781..9c479f34df 100644 --- a/frontend/messages/zh-TW.json +++ b/frontend/messages/zh-TW.json @@ -2762,5 +2762,81 @@ "webhook_disable_failed": "停用 Webhook「{name}」時失敗", "webhook_status_enabled": "已啟用", "webhook_status_disabled": "已停用", - "webhook_col_status": "狀態" + "webhook_col_status": "狀態", + "_comment_activity_center": "=== ACTIVITY CENTER ===", + "activity_center_title": "Activity Center", + "activity_center_open": "Open activity center", + "activity_active_count": "{count} active", + "activity_count_many": "9+", + "activity_filter_running": "Running", + "activity_filter_failed": "Failed", + "activity_filter_completed": "Completed", + "activity_status_queued": "Queued", + "activity_status_running": "Running", + "activity_status_success": "Success", + "activity_status_failed": "Failed", + "activity_status_cancelled": "Cancelled", + "activity_type_image_pull": "Image Pull", + "activity_type_image_build": "Image Build", + "activity_type_image_update_check": "Image Update Check", + "activity_type_project_pull": "Project Pull", + "activity_type_project_build": "Project Build", + "activity_type_project_deploy": "Project Deploy", + "activity_type_project_redeploy": "Project Redeploy", + "activity_type_project_down": "Project Down", + "activity_type_project_restart": "Project Restart", + "activity_type_project_destroy": "Project Destroy", + "activity_type_container_start": "Container Start", + "activity_type_container_stop": "Container Stop", + "activity_type_container_restart": "Container Restart", + "activity_type_container_redeploy": "Container Redeploy", + "activity_type_container_delete": "Container Delete", + "activity_type_vulnerability_scan": "Vulnerability Scan", + "activity_type_auto_update": "Auto Update", + "activity_type_system_prune": "System Prune", + "activity_type_resource_action": "Resource Action", + "activity_unknown_target": "Unknown target", + "activity_no_message": "No message yet", + "activity_progress_percent": "{progress}%", + "activity_loading": "Loading activity…", + "activity_empty_title": "No activity here", + "activity_empty_description": "Background work will appear here as it starts.", + "activity_step_unknown": "Waiting for the next step", + "activity_duration": "Duration", + "activity_duration_ms": "{ms}ms", + "activity_duration_seconds": "{seconds}s", + "activity_duration_minutes": "{minutes}m {seconds}s", + "activity_output_title": "Output", + "activity_copy_output": "Copy Output", + "activity_output_loading": "Loading output…", + "activity_output_load_failed": "Failed to load activity output", + "activity_output_empty": "No output has been recorded for this activity yet.", + "activity_stream_error": "Lost connection to activity stream", + "activity_select_prompt_title": "Select an activity", + "activity_select_prompt": "Choose a background task to inspect its progress and recent output.", + "activity_view_activity": "View Activity", + "activity_source_environment": "Source environment", + "activity_started_by": "Started by {user}", + "activity_started_by_label": "Started by", + "activity_clear_history": "Clear history", + "activity_clear_history_title": "Clear activity history?", + "activity_clear_history_message": "Completed, failed, and cancelled entries for the selected environment will be deleted. Running activity will stay visible.", + "activity_clear_history_confirm": "Clear History", + "activity_clear_history_success": "Activity history cleared", + "activity_clear_history_failed": "Failed to clear activity history", + "activity_settings_title": "Activity", + "activity_settings_description": "Configure Activity Center history retention for each environment.", + "activity_settings_saved": "Activity settings saved", + "activity_history_section_title": "History Retention", + "activity_history_retention_days": "Retention Days", + "activity_history_retention_days_description": "Delete completed Activity Center entries older than this many days.", + "activity_history_retention_days_placeholder": "30", + "activity_history_retention_days_help": "Set to 0 to disable age-based cleanup.", + "activity_history_max_entries": "Maximum Entries", + "activity_history_max_entries_description": "Keep this many completed Activity Center entries per environment.", + "activity_history_max_entries_placeholder": "1000", + "activity_history_max_entries_help": "Set to 0 to disable count-based cleanup.", + "activity_scan_phase_creating_container": "Creating container", + "activity_scan_phase_scanning_image": "Scanning image", + "activity_scan_phase_storing_results": "Storing results" } diff --git a/frontend/src/lib/components/action-buttons.svelte b/frontend/src/lib/components/action-buttons.svelte index 225b113078..80f766cb48 100644 --- a/frontend/src/lib/components/action-buttons.svelte +++ b/frontend/src/lib/components/action-buttons.svelte @@ -7,7 +7,6 @@ import { handleApiResultWithCallbacks } from '$lib/utils/api.util'; import { ArcaneButton } from '$lib/components/arcane-button/index.js'; import DeploySplitButton from '$lib/components/deploy-split-button/deploy-split-button.svelte'; - import ProgressPopover from '$lib/components/progress-popover.svelte'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js'; import { m } from '$lib/paraglide/messages'; import settingsStore from '$lib/stores/config-store'; @@ -15,9 +14,7 @@ import { containerService, type ContainerDetailsResponse } from '$lib/services/container-service'; import { projectService, type DeployProjectOptions } from '$lib/services/project-service'; import type { Project } from '$lib/types/project.type'; - import { isDownloadingLine, calculateOverallProgress, areAllLayersComplete } from '$lib/utils/pull-progress'; - import { sanitizeLogText } from '$lib/utils/log-text'; - import { EllipsisIcon, DownloadIcon, HammerIcon } from '$lib/icons'; + import { EllipsisIcon } from '$lib/icons'; import { createMutation } from '@tanstack/svelte-query'; type TargetType = 'container' | 'project'; @@ -36,7 +33,6 @@ let { id, - name, type = 'container', itemState = 'stopped', desktopVariant = 'labels', @@ -105,8 +101,8 @@ stop: !!(isLoading.stop || loading?.stop || stopLoading), restart: !!(isLoading.restart || loading?.restart || restartLoading), remove: !!(isLoading.remove || loading?.remove || removeLoading), - pulling: !!(isLoading.pull || loading?.pull), - building: !!(isLoading.build || loading?.build), + pull: !!(isLoading.pull || loading?.pull), + build: !!(isLoading.build || loading?.build), redeploy: !!(isLoading.redeploy || loading?.redeploy || redeployLoading), validating: !!(isLoading.validating || loading?.validating), refresh: !!(isLoading.refresh || loading?.refresh || refreshLoading) @@ -121,11 +117,7 @@ : projectService.deployProject(id, deployOptionsStore.getRequestOptions()) ), onMutate: () => setLoading('start', true), - onSettled: () => { - if (!deployPulling) { - setLoading('start', false); - } - } + onSettled: () => setLoading('start', false) })); const stopMutation = createMutation(() => ({ @@ -173,21 +165,6 @@ onSettled: () => setLoading('refresh', false) })); - let pullPopoverOpen = $state(false); - let buildPopoverOpen = $state(false); - let deployPullPopoverOpen = $state(false); - let projectPulling = $state(false); // only for Project Pull button/popover - let projectBuilding = $state(false); // only for Project Build button/popover - let deployPulling = $state(false); // only for Deploy popover - let pullProgress = $state(0); - let pullStatusText = $state(''); - let pullError = $state(''); - let layerProgress = $state>({}); - let buildOutputLines = $state([]); - let deployProgressPhase = $state<'pull' | 'build' | 'deploy'>('deploy'); - let deployServiceProgress = $state>({}); - let deployLastNonWaitingStatus = $state(''); - const isRunning = $derived(itemState === 'running' || (type === 'project' && itemState === 'partially running')); const projectHasBuildDirective = $derived(type === 'project' && hasBuildDirective); const deployButtonLabel = $derived(projectHasBuildDirective ? m.compose_build_and_deploy() : m.common_up()); @@ -203,20 +180,6 @@ } return configuredProvider; }); - const deployPopoverTitle = $derived.by(() => { - switch (deployProgressPhase) { - case 'build': - return m.progress_building_images(); - case 'pull': - return m.progress_pulling_images(); - default: - return m.progress_deploying_project(); - } - }); - const deployPopoverIcon = $derived(deployProgressPhase === 'build' ? HammerIcon : DownloadIcon); - const deployPopoverLayers = $derived.by(() => - deployProgressPhase === 'pull' || deployProgressPhase === 'build' ? layerProgress : {} - ); // Tailwind xl breakpoint is 1280px. We use this to avoid mounting two desktop variants at once // (which would duplicate portaled popovers when the same `open` state is bound twice). @@ -254,119 +217,6 @@ }; }); - function resetPullState() { - pullProgress = 0; - pullStatusText = ''; - pullError = ''; - layerProgress = {}; - buildOutputLines = []; - deployProgressPhase = 'deploy'; - deployServiceProgress = {}; - deployLastNonWaitingStatus = ''; - } - - function appendBuildOutputLine(rawStatus: unknown, rawService?: unknown) { - const status = sanitizeLogText(String(rawStatus ?? '')); - if (!status) return; - - const service = sanitizeLogText(String(rawService ?? '')); - const line = service ? `[${service}] ${status}` : status; - - if (buildOutputLines.length > 0 && buildOutputLines[buildOutputLines.length - 1] === line) { - return; - } - - buildOutputLines = [...buildOutputLines.slice(-149), line]; - } - - function deriveDeployStatusText(): string { - const entries = Object.entries(deployServiceProgress); - if (entries.length === 0) return m.progress_deploy_starting(); - - const waiting = entries.filter(([_, v]) => v.phase === 'service_waiting_healthy').sort(([a], [b]) => a.localeCompare(b)); - const firstWaiting = waiting[0]; - if (firstWaiting) { - const [service, v] = firstWaiting; - const health = String(v.health ?? '').trim(); - return health - ? m.progress_deploy_waiting_for_service_with_health({ service, health }) - : m.progress_deploy_waiting_for_service({ service }); - } - - const stateIssues = entries - .filter(([_, v]) => v.phase === 'service_state' && (v.state ?? '').toLowerCase() !== 'running') - .sort(([a], [b]) => a.localeCompare(b)); - const firstStateIssue = stateIssues[0]; - if (firstStateIssue) { - const [service, v] = firstStateIssue; - return m.progress_deploy_service_state({ service, state: String(v.state ?? '') }); - } - - return deployLastNonWaitingStatus || m.progress_deploy_starting(); - } - - function updatePullProgress() { - pullProgress = calculateOverallProgress(layerProgress); - } - - function updateLayerProgressFromStreamData(data: any) { - const layerId = String(data?.id ?? '').trim(); - if (!layerId) return; - - const currentLayer = layerProgress[layerId] || { current: 0, total: 0, status: '' }; - if (data?.status) { - currentLayer.status = String(data.status); - } - - if (data?.progressDetail) { - const { current, total } = data.progressDetail; - if (typeof current === 'number') currentLayer.current = current; - if (typeof total === 'number') currentLayer.total = total; - } - - layerProgress[layerId] = currentLayer; - updatePullProgress(); - } - - function handleBuildStreamLine( - data: any, - errorFallback: string, - errorFormatter: (message: string) => string, - onError?: (message: string) => void - ): boolean { - if (!data) return false; - - if (data.phase === 'begin') { - pullStatusText = m.progress_building_images_starting(); - appendBuildOutputLine(m.build_phase_started(), data.service); - } - - if (data.status) { - pullStatusText = String(data.status); - appendBuildOutputLine(data.status, data.service); - } - - if (data.id) { - updateLayerProgressFromStreamData(data); - } - - if (data.phase === 'complete') { - pullStatusText = m.build_completed(); - pullProgress = 100; - appendBuildOutputLine(m.build_phase_completed(), data.service); - } - - if (data.error) { - const errMsg = typeof data.error === 'string' ? data.error : data.error.message || errorFallback; - pullError = errMsg; - pullStatusText = errorFormatter(errMsg); - onError?.(errMsg); - return true; - } - - return false; - } - async function handleRefresh() { if (!onRefresh) return; await refreshMutation.mutateAsync(); @@ -395,11 +245,6 @@ type: type }), onSuccess: async () => { - toast.success( - type === 'project' - ? m.common_destroyed_success({ type: m.project() }) - : m.common_removed_success({ type: m.container() }) - ); await invalidateAll(); goto(type === 'project' ? '/projects' : '/containers'); } @@ -427,9 +272,6 @@ result, message: m.common_action_failed_with_type({ action: m.common_redeploy(), type }), onSuccess: async (data) => { - toast.success( - type === 'container' ? m.container_redeploy_success() : m.common_redeploy_success({ type: name || type }) - ); const containerData = data as ContainerDetailsResponse; if (type === 'container' && containerData?.data?.id) { goto(`/containers/${containerData.data.id}`); @@ -453,233 +295,26 @@ message: m.common_action_failed_with_type({ action: m.common_start(), type }), onSuccess: async () => { itemState = 'running'; - toast.success(m.common_started_success({ type: name || type })); onActionComplete('running'); } }); } async function handleDeploy(options?: DeployProjectOptions) { - resetPullState(); setLoading('start', true); - let openedPopover = false; - let hadError = false; - let deployPhaseStarted = false; - let buildPhaseStarted = false; - - // Always open the popover for deploy so we can show health-wait status even - // when there is nothing to pull. - deployPullPopoverOpen = true; - deployPulling = true; - deployProgressPhase = 'deploy'; - pullStatusText = m.progress_deploy_starting(); - openedPopover = true; try { - const handleDeployLine = (deployData: any) => { - if (!deployData) return; - - if (deployData.type === 'build') { - deployProgressPhase = 'build'; - if (!buildPhaseStarted) { - buildPhaseStarted = true; - pullProgress = 0; - layerProgress = {}; - pullError = ''; - deployServiceProgress = {}; - deployLastNonWaitingStatus = ''; - } - - if ( - handleBuildStreamLine( - deployData, - m.progress_deploy_failed(), - (errMsg) => m.progress_deploy_failed_with_error({ error: errMsg }), - () => { - hadError = true; - deployPulling = false; - } - ) - ) { - return; - } - return; - } - - // Pull progress lines can be streamed by backend /up before deploy when - // image policy requires pulling (missing/always/refresh). - if (deployData.type !== 'deploy') { - if (isDownloadingLine(deployData)) { - deployProgressPhase = 'pull'; - pullStatusText = m.images_pull_initiating(); - } - - if (deployData.error) { - const errMsg = - typeof deployData.error === 'string' ? deployData.error : deployData.error.message || m.images_pull_stream_error(); - pullError = errMsg; - pullStatusText = m.images_pull_failed_with_error({ error: errMsg }); - hadError = true; - deployPulling = false; - return; - } - - if (deployData.status) pullStatusText = deployData.status; - - if (deployData.id) { - deployProgressPhase = 'pull'; - const currentLayer = layerProgress[deployData.id] || { current: 0, total: 0, status: '' }; - currentLayer.status = deployData.status || currentLayer.status; - if (deployData.progressDetail) { - const { current, total } = deployData.progressDetail; - if (typeof current === 'number') currentLayer.current = current; - if (typeof total === 'number') currentLayer.total = total; - } - layerProgress[deployData.id] = currentLayer; - } - - if (deployProgressPhase === 'pull') { - updatePullProgress(); - } - return; - } - - // First deploy status line: switch UI from pull -> deploy. - if (!deployPhaseStarted) { - deployPhaseStarted = true; - deployProgressPhase = 'deploy'; - pullProgress = 0; - layerProgress = {}; - pullError = ''; - deployServiceProgress = {}; - deployLastNonWaitingStatus = ''; - } - - // Keep the popover in "loading" state during deployment. - deployPulling = true; - if (deployData.type === 'deploy') { - switch (deployData.phase) { - case 'begin': - pullStatusText = m.progress_deploy_starting(); - break; - case 'service_waiting_healthy': { - const service = String(deployData.service ?? '').trim(); - if (service) { - deployServiceProgress[service] = { - phase: 'service_waiting_healthy', - health: String(deployData.health ?? '') - }; - pullStatusText = deriveDeployStatusText(); - } - break; - } - case 'service_healthy': - { - const service = String(deployData.service ?? '').trim(); - if (service) { - deployServiceProgress[service] = { - phase: 'service_healthy', - health: String(deployData.health ?? ''), - state: String(deployData.state ?? ''), - status: String(deployData.status ?? '') - }; - deployLastNonWaitingStatus = m.progress_deploy_service_healthy({ service }); - pullStatusText = deriveDeployStatusText(); - } - } - break; - case 'service_state': - { - const service = String(deployData.service ?? '').trim(); - if (service) { - deployServiceProgress[service] = { - phase: 'service_state', - state: String(deployData.state ?? ''), - health: String(deployData.health ?? ''), - status: String(deployData.status ?? '') - }; - deployLastNonWaitingStatus = m.progress_deploy_service_state({ - service, - state: String(deployData.state ?? '') - }); - pullStatusText = deriveDeployStatusText(); - } - } - break; - case 'service_status': - { - const service = String(deployData.service ?? '').trim(); - if (service) { - deployServiceProgress[service] = { - phase: 'service_status', - status: String(deployData.status ?? ''), - state: String(deployData.state ?? ''), - health: String(deployData.health ?? '') - }; - deployLastNonWaitingStatus = m.progress_deploy_service_status({ - service, - status: String(deployData.status ?? '') - }); - pullStatusText = deriveDeployStatusText(); - } - } - break; - case 'complete': - pullStatusText = m.progress_deploy_completed(); - break; - default: - break; - } - } else if (deployData.status) { - // fallback for unexpected payloads - pullStatusText = String(deployData.status); - } - - if (deployData.error) { - const errMsg = - typeof deployData.error === 'string' ? deployData.error : deployData.error.message || m.progress_deploy_failed(); - pullError = errMsg; - pullStatusText = m.progress_deploy_failed_with_error({ error: errMsg }); - hadError = true; - deployPulling = false; - return; - } - - // If we got "complete", stop the loading state - if (deployData.type === 'deploy' && deployData.phase === 'complete') { - deployPulling = false; - pullProgress = 100; - } - }; - - await projectService.deployProject(id, handleDeployLine, options ?? deployOptionsStore.getRequestOptions()); - - if (hadError) throw new Error(pullError || m.progress_deploy_failed()); - - // Deployment finished successfully. - pullProgress = 100; - deployPulling = false; - pullStatusText = m.progress_deploy_completed(); - await invalidateAll(); - - setTimeout(() => { - deployPullPopoverOpen = false; - deployPulling = false; - resetPullState(); - }, 1500); - - // Deploy already completed successfully - itemState = 'running'; - toast.success(m.common_started_success({ type: name || type })); - onActionComplete('running'); - } catch (e: any) { - const message = e?.message || m.common_action_failed_with_type({ action: m.common_start(), type }); - if (openedPopover) { - pullError = message; - pullStatusText = m.images_pull_failed_with_error({ error: message }); - deployPulling = false; - } - toast.error(message); + projectService + .deployProject(id, () => {}, options ?? deployOptionsStore.getRequestOptions()) + .then(async () => { + await invalidateAll(); + itemState = 'running'; + onActionComplete('running'); + }) + .catch((e: any) => { + const message = e?.message || m.common_action_failed_with_type({ action: m.common_start(), type }); + toast.error(message); + }); } finally { setLoading('start', false); } @@ -692,7 +327,6 @@ message: m.common_action_failed_with_type({ action: m.common_stop(), type }), onSuccess: async () => { itemState = 'stopped'; - toast.success(m.common_stopped_success({ type: name || type })); onActionComplete('stopped'); } }); @@ -705,126 +339,54 @@ message: m.common_action_failed_with_type({ action: m.common_restart(), type }), onSuccess: async () => { itemState = 'running'; - toast.success(m.common_restarted_success({ type: name || type })); onActionComplete('running'); } }); } async function handleProjectPull() { - resetPullState(); - projectPulling = true; - pullPopoverOpen = true; - pullStatusText = m.images_pull_initiating(); - - let wasSuccessful = false; + setLoading('pull', true); try { - await projectService.pullProjectImages(id, (data) => { - if (!data) return; - - if (data.error) { - const errMsg = typeof data.error === 'string' ? data.error : data.error.message || m.images_pull_stream_error(); - pullError = errMsg; - pullStatusText = m.images_pull_failed_with_error({ error: errMsg }); - return; - } - - if (data.status) pullStatusText = data.status; - - if (data.id) { - const currentLayer = layerProgress[data.id] || { current: 0, total: 0, status: '' }; - currentLayer.status = data.status || currentLayer.status; - - if (data.progressDetail) { - const { current, total } = data.progressDetail; - if (typeof current === 'number') currentLayer.current = current; - if (typeof total === 'number') currentLayer.total = total; - } - layerProgress[data.id] = currentLayer; - } - - updatePullProgress(); - }); - - // Stream finished - updatePullProgress(); - if (!pullError && pullProgress < 100 && areAllLayersComplete(layerProgress)) { - pullProgress = 100; - } - - if (pullError) throw new Error(pullError); - - wasSuccessful = true; - pullProgress = 100; - pullStatusText = m.images_pulled_success(); - toast.success(m.images_pulled_success()); - await invalidateAll(); - onActionComplete(itemState); - - setTimeout(() => { - pullPopoverOpen = false; - projectPulling = false; - resetPullState(); - }, 2000); - } catch (error: any) { - const message = error?.message || m.images_pull_failed(); - pullError = message; - pullStatusText = m.images_pull_failed_with_error({ error: message }); - toast.error(message); + projectService + .pullProjectImages(id, () => {}) + .then(async () => { + await invalidateAll(); + onActionComplete(itemState); + }) + .catch((error: any) => { + const message = error?.message || m.images_pull_failed(); + toast.error(message); + }); } finally { - if (!wasSuccessful) { - projectPulling = false; - } + setLoading('pull', false); } } async function handleProjectBuild() { - resetPullState(); - projectBuilding = true; - buildPopoverOpen = true; - pullStatusText = m.progress_building_images_starting(); - - let wasSuccessful = false; + setLoading('build', true); try { const buildProvider = projectBuildProvider; - await projectService.buildProjectImages( - id, - { - provider: buildProvider, - push: buildProvider === 'depot', - load: buildProvider !== 'depot' - }, - (data) => { - if (!data) return; - - handleBuildStreamLine(data, m.build_failed(), (errMsg) => m.build_failed_with_error({ error: errMsg })); - } - ); - - if (pullError) throw new Error(pullError); - - wasSuccessful = true; - pullProgress = 100; - pullStatusText = m.build_completed(); - toast.success(m.build_completed()); - await invalidateAll(); - - setTimeout(() => { - buildPopoverOpen = false; - projectBuilding = false; - resetPullState(); - }, 2000); - } catch (error: any) { - const message = error?.message || m.build_failed(); - pullError = message; - pullStatusText = m.build_failed_with_error({ error: message }); - toast.error(message); + projectService + .buildProjectImages( + id, + { + provider: buildProvider, + push: buildProvider === 'depot', + load: buildProvider !== 'depot' + }, + () => {} + ) + .then(async () => { + await invalidateAll(); + }) + .catch((error: any) => { + const message = error?.message || m.build_failed(); + toast.error(message); + }); } finally { - if (!wasSuccessful) { - projectBuilding = false; - } + setLoading('build', false); } } @@ -866,28 +428,13 @@ loading={uiLoading.start} /> {:else} - - handleDeploy()} - loading={uiLoading.start} - /> - + handleDeploy()} + loading={uiLoading.start} + /> {/if} {/if} @@ -923,48 +470,22 @@ {#if type === 'project'} {#if projectHasBuildDirective} - - handleProjectBuild()} - loading={projectBuilding} - /> - - {/if} - - handleProjectPull()} - loading={projectPulling} + onclick={() => handleProjectBuild()} + loading={uiLoading.build} /> - + {/if} + + handleProjectPull()} + loading={uiLoading.pull} + /> {/if} {#if onRefresh} @@ -1049,11 +570,11 @@ {#if type === 'project'} {#if projectHasBuildDirective} - + {m.build()} {/if} - + {m.images_pull()} {/if} @@ -1071,58 +592,6 @@ - - {#if type === 'project'} - - - - - - - - - - - - {/if} {/if} @@ -1133,22 +602,7 @@ {#if type === 'container'} handleStart()} loading={uiLoading.start} /> {:else} - - handleDeploy()} loading={uiLoading.start} /> - + handleDeploy()} loading={uiLoading.start} /> {/if} {/if} @@ -1170,36 +624,10 @@ {#if type === 'project'} {#if projectHasBuildDirective} - - handleProjectBuild()} loading={projectBuilding} /> - + handleProjectBuild()} loading={uiLoading.build} /> {/if} - - handleProjectPull()} loading={projectPulling} /> - + handleProjectPull()} loading={uiLoading.pull} /> {/if} {#if onRefresh} @@ -1276,11 +704,11 @@ {#if type === 'project'} {#if projectHasBuildDirective} - + {m.build()} {/if} - + {m.images_pull()} {/if} diff --git a/frontend/src/lib/components/activity/activity-center-trigger.svelte b/frontend/src/lib/components/activity/activity-center-trigger.svelte new file mode 100644 index 0000000000..36ab8c8be8 --- /dev/null +++ b/frontend/src/lib/components/activity/activity-center-trigger.svelte @@ -0,0 +1,108 @@ + + +{#if compact} + +{:else} + +{/if} diff --git a/frontend/src/lib/components/activity/activity-center.svelte b/frontend/src/lib/components/activity/activity-center.svelte new file mode 100644 index 0000000000..e8f4791e6f --- /dev/null +++ b/frontend/src/lib/components/activity/activity-center.svelte @@ -0,0 +1,144 @@ + + + +
+
+ {#if activityStore.activeCount > 0} + + {m.activity_active_count({ count: activityStore.activeCount })} + + {/if} +
+ +
+
+ {#each filters as filter (filter)} + + {/each} +
+ + {#if isAdmin} + + {/if} +
+
+ +
+ {#if activityStore.loading && activityStore.activities.length === 0} +
+
+
+
+ {:else if activityStore.filteredActivities.length === 0} +
+
+
+
+ {:else} +
+ {#each activityStore.filteredActivities as activity (activity.id)} + {@const expanded = activityStore.isExpanded(activity.id)} + activityStore.setActivityExpanded(activity.id, open)}> + + + + + + + + {/each} +
+ {/if} +
+
diff --git a/frontend/src/lib/components/activity/activity-detail-panel.svelte b/frontend/src/lib/components/activity/activity-detail-panel.svelte new file mode 100644 index 0000000000..eec062fa8f --- /dev/null +++ b/frontend/src/lib/components/activity/activity-detail-panel.svelte @@ -0,0 +1,228 @@ + + +
+
+
+
+
+
+
+
+
+

{activityTypeLabel(liveActivity.type)}

+ +
+

{activityTarget}

+
+
+
+ +
+
+ {liveActivity.step || m.activity_step_unknown()} + + {#if hasProgress} + {m.activity_progress_percent({ progress: progressValue })} + {:else} + {m.common_live()} + {/if} + +
+ +
+ +
+
+ {m.common_started()} + {formatDateTimeInternal(liveActivity.startedAt)} +
+ +
+ {m.common_finished()} + {formatDateTimeInternal(liveActivity.endedAt)} +
+ +
+ {m.activity_duration()} + {formatDurationInternal(liveActivity)} +
+ {#if sourceEnvironmentName} + +
+ {m.activity_source_environment()} + {sourceEnvironmentName} +
+ {/if} + {#if startedByName} + +
+ {m.activity_started_by_label()} + {startedByName} +
+ {/if} +
+ + {#if liveActivity.error} +
+ {liveActivity.error} +
+ {/if} +
+ +
+
+
+
+ + {m.activity_copy_output()} + +
+ +
+ {#if isDetailError && messages.length === 0} +
+ {m.activity_output_load_failed()} + +
+ {:else if isLoading && messages.length === 0} +
+
+ {:else if messages.length === 0} +
+ {m.activity_output_empty()} +
+ {:else} + {#each messages as message (message.id)} +
+ {formatDateTimeInternal(message.createdAt)} + + {message.level.toUpperCase()} + + {message.message} +
+ {/each} + {/if} +
+
+
+
diff --git a/frontend/src/lib/components/activity/activity-labels.ts b/frontend/src/lib/components/activity/activity-labels.ts new file mode 100644 index 0000000000..b6f7b5ef5c --- /dev/null +++ b/frontend/src/lib/components/activity/activity-labels.ts @@ -0,0 +1,140 @@ +import { m } from '$lib/paraglide/messages'; +import type { ActivityFilter, ActivityStatus, ActivityType } from '$lib/types/activity.type'; +import type { IconType } from '$lib/icons'; +import { + ActivityIcon, + DownloadIcon, + HammerIcon, + RedeployIcon, + RefreshIcon, + RestartIcon, + ScanIcon, + StartIcon, + StopIcon, + TrashIcon +} from '$lib/icons'; + +export type ActivityBadgeVariant = 'red' | 'green' | 'blue' | 'gray' | 'amber' | 'purple'; + +export function activityStatusLabel(status: ActivityStatus): string { + switch (status) { + case 'queued': + return m.activity_status_queued(); + case 'running': + return m.activity_status_running(); + case 'success': + return m.activity_status_success(); + case 'failed': + return m.activity_status_failed(); + case 'cancelled': + return m.activity_status_cancelled(); + } +} + +export function activityStatusVariant(status: ActivityStatus): ActivityBadgeVariant { + switch (status) { + case 'queued': + return 'amber'; + case 'running': + return 'blue'; + case 'success': + return 'green'; + case 'failed': + return 'red'; + case 'cancelled': + return 'gray'; + } +} + +export function activityTypeLabel(type: ActivityType): string { + switch (type) { + case 'image_pull': + return m.activity_type_image_pull(); + case 'image_build': + return m.activity_type_image_build(); + case 'image_update_check': + return m.activity_type_image_update_check(); + case 'project_pull': + return m.activity_type_project_pull(); + case 'project_build': + return m.activity_type_project_build(); + case 'project_deploy': + return m.activity_type_project_deploy(); + case 'project_redeploy': + return m.activity_type_project_redeploy(); + case 'project_down': + return m.activity_type_project_down(); + case 'project_restart': + return m.activity_type_project_restart(); + case 'project_destroy': + return m.activity_type_project_destroy(); + case 'container_start': + return m.activity_type_container_start(); + case 'container_stop': + return m.activity_type_container_stop(); + case 'container_restart': + return m.activity_type_container_restart(); + case 'container_redeploy': + return m.activity_type_container_redeploy(); + case 'container_delete': + return m.activity_type_container_delete(); + case 'vulnerability_scan': + return m.activity_type_vulnerability_scan(); + case 'auto_update': + return m.activity_type_auto_update(); + case 'system_prune': + return m.activity_type_system_prune(); + case 'resource_action': + return m.activity_type_resource_action(); + } +} + +export function activityTypeIcon(type: ActivityType): IconType { + switch (type) { + case 'image_pull': + case 'project_pull': + return DownloadIcon; + case 'image_build': + case 'project_build': + return HammerIcon; + case 'image_update_check': + return RefreshIcon; + case 'project_deploy': + return ActivityIcon; + case 'container_start': + return StartIcon; + case 'project_redeploy': + case 'container_redeploy': + return RedeployIcon; + case 'project_down': + case 'container_stop': + return StopIcon; + case 'project_restart': + case 'container_restart': + return RestartIcon; + case 'project_destroy': + case 'container_delete': + return TrashIcon; + case 'vulnerability_scan': + return ScanIcon; + case 'auto_update': + return RefreshIcon; + case 'system_prune': + return TrashIcon; + case 'resource_action': + return ActivityIcon; + default: + return ActivityIcon; + } +} + +export function activityFilterLabel(filter: ActivityFilter): string { + switch (filter) { + case 'running': + return m.activity_filter_running(); + case 'failed': + return m.activity_filter_failed(); + case 'completed': + return m.activity_filter_completed(); + } +} diff --git a/frontend/src/lib/components/activity/activity-list-item.svelte b/frontend/src/lib/components/activity/activity-list-item.svelte new file mode 100644 index 0000000000..cb01ec760a --- /dev/null +++ b/frontend/src/lib/components/activity/activity-list-item.svelte @@ -0,0 +1,121 @@ + + +
+ + +
+
+
+
+
+
+ {activityTypeLabel(activity.type)} + {#if relativeTime} + · {relativeTime} + {/if} +
+
{targetName}
+
+ {#if sourceEnvironmentName} + {sourceEnvironmentName} + {/if} + {#if startedByName} + · + {m.activity_started_by({ user: startedByName })} + {/if} +
+
+ +
+ +
+
{subtitle}
+ {#if isActive && !expanded} +
+ + + {#if hasProgress} + {m.activity_progress_percent({ progress: progressValue })} + {:else} + {m.common_live()} + {/if} + +
+ {/if} +
+
+ +
+
+
diff --git a/frontend/src/lib/components/file-browser/CreateFolderDialog.svelte b/frontend/src/lib/components/file-browser/CreateFolderDialog.svelte index f9776e9260..d516c57072 100644 --- a/frontend/src/lib/components/file-browser/CreateFolderDialog.svelte +++ b/frontend/src/lib/components/file-browser/CreateFolderDialog.svelte @@ -5,6 +5,7 @@ import { Label } from '$lib/components/ui/label'; import { toast } from 'svelte-sonner'; import * as m from '$lib/paraglide/messages.js'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { open = $bindable(false), @@ -13,7 +14,7 @@ }: { open: boolean; currentPath: string; - onCreate: (folderName: string) => Promise; + onCreate: (folderName: string) => Promise; } = $props(); let folderName = $state(''); @@ -25,8 +26,8 @@ loading = true; try { - await onCreate(folderName); - toast.success(m.common_create_success({ resource: folderName })); + const result = await onCreate(folderName); + toast.success(m.common_create_success({ resource: folderName }), activityToastOptions(extractActivityId(result))); open = false; folderName = ''; } catch (e: any) { diff --git a/frontend/src/lib/components/file-browser/FileList.svelte b/frontend/src/lib/components/file-browser/FileList.svelte index dc0983e426..d521c418e7 100644 --- a/frontend/src/lib/components/file-browser/FileList.svelte +++ b/frontend/src/lib/components/file-browser/FileList.svelte @@ -22,6 +22,7 @@ import { ArcaneButton } from '$lib/components/arcane-button'; import bytes from '$lib/utils/bytes'; import { format } from 'date-fns'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { files, @@ -40,7 +41,7 @@ persistKey?: string; onNavigate: (path: string) => void; onRefresh: () => void; - onDelete: (file: FileEntry) => Promise; + onDelete: (file: FileEntry) => Promise; onDownload: (file: FileEntry) => Promise; onPreview: (file: FileEntry) => void; onRestoreFromBackup?: (file: FileEntry) => void; @@ -110,8 +111,8 @@ destructive: true, action: async () => { try { - await onDelete(file); - toast.success(m.common_delete_success({ resource: file.name })); + const result = await onDelete(file); + toast.success(m.common_delete_success({ resource: file.name }), activityToastOptions(extractActivityId(result))); onRefresh(); } catch (e: any) { toast.error(e.message || m.common_delete_failed({ resource: file.name })); diff --git a/frontend/src/lib/components/file-browser/FileUploadDialog.svelte b/frontend/src/lib/components/file-browser/FileUploadDialog.svelte index ff2c22414b..e52aacfb53 100644 --- a/frontend/src/lib/components/file-browser/FileUploadDialog.svelte +++ b/frontend/src/lib/components/file-browser/FileUploadDialog.svelte @@ -5,6 +5,7 @@ import { toast } from 'svelte-sonner'; import * as m from '$lib/paraglide/messages.js'; import { CloseIcon } from '$lib/icons'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { open = $bindable(false), @@ -13,7 +14,7 @@ }: { open: boolean; currentPath: string; - onUpload: (file: File) => Promise; + onUpload: (file: File) => Promise; } = $props(); let files = $state([]); @@ -35,10 +36,11 @@ if (files.length === 0) return; uploading = true; try { + let lastResult: unknown; for (const file of files) { - await onUploadAction(file); + lastResult = await onUploadAction(file); } - toast.success(m.common_success()); + toast.success(m.common_success(), activityToastOptions(extractActivityId(lastResult))); open = false; files = []; } catch (e: any) { diff --git a/frontend/src/lib/components/file-browser/GenericFileBrowser.svelte b/frontend/src/lib/components/file-browser/GenericFileBrowser.svelte index 5011782373..c78bd051a6 100644 --- a/frontend/src/lib/components/file-browser/GenericFileBrowser.svelte +++ b/frontend/src/lib/components/file-browser/GenericFileBrowser.svelte @@ -3,13 +3,13 @@ export interface FileProvider { list: (path: string) => Promise; - mkdir: (path: string) => Promise; - upload: (path: string, file: File) => Promise; - delete: (path: string) => Promise; + mkdir: (path: string) => Promise; + upload: (path: string, file: File) => Promise; + delete: (path: string) => Promise; download: (path: string) => Promise; getContent: (path: string) => Promise<{ content: string }>; listBackups?: () => Promise; - restoreFromBackup?: (backupId: string, path: string) => Promise; + restoreFromBackup?: (backupId: string, path: string) => Promise; backupHasPath?: (backupId: string, path: string) => Promise; } @@ -32,6 +32,7 @@ import { toast } from 'svelte-sonner'; import bytes from '$lib/utils/bytes'; import { format } from 'date-fns'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { provider, rootLabel, persistKey }: { provider: FileProvider; rootLabel?: string; persistKey?: string } = $props(); @@ -113,8 +114,8 @@ if (!restoreTarget || !provider.restoreFromBackup || !selectedBackupId) return; restoringFile = true; try { - await provider.restoreFromBackup(selectedBackupId, restoreTarget.path); - toast.success(m.volumes_backup_file_restore_success()); + const result = await provider.restoreFromBackup(selectedBackupId, restoreTarget.path); + toast.success(m.volumes_backup_file_restore_success(), activityToastOptions(extractActivityId(result))); showRestoreFile = false; // Refresh the file list to show the restored file await loadFiles(currentPath); diff --git a/frontend/src/lib/components/image-update-item.svelte b/frontend/src/lib/components/image-update-item.svelte index 9b032a8bac..d9d48f48e5 100644 --- a/frontend/src/lib/components/image-update-item.svelte +++ b/frontend/src/lib/components/image-update-item.svelte @@ -10,6 +10,7 @@ import { ArrowRightIcon, RefreshIcon, AlertIcon, VerifiedCheckIcon, ApiKeyIcon, CircleArrowUpIcon, BoxIcon } from '$lib/icons'; import { createQuery } from '@tanstack/svelte-query'; import UpdateStatusPopover from '$lib/components/update-status-popover.svelte'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; interface Props { updateInfo?: ImageUpdateData; @@ -140,11 +141,12 @@ const result = await imageUpdateQuery.refetch(); if (result.data) { onUpdated?.(result.data); + const toastOptions = activityToastOptions(extractActivityId(result.data)); if (result.data.error) { - toast.error(result.data.error || m.images_update_check_failed()); + toast.error(result.data.error || m.images_update_check_failed(), toastOptions); } else { - toast.success(m.images_update_check_completed()); + toast.success(m.images_update_check_completed(), toastOptions); } return; } diff --git a/frontend/src/lib/components/mobile-nav/mobile-nav-sheet.svelte b/frontend/src/lib/components/mobile-nav/mobile-nav-sheet.svelte index 7d7c2a4594..aa118b6adc 100644 --- a/frontend/src/lib/components/mobile-nav/mobile-nav-sheet.svelte +++ b/frontend/src/lib/components/mobile-nav/mobile-nav-sheet.svelte @@ -7,6 +7,7 @@ import { m } from '$lib/paraglide/messages'; import { environmentStore } from '$lib/stores/environment.store.svelte'; import MobileUserCard from './mobile-user-card.svelte'; + import ActivityCenterTrigger from '$lib/components/activity/activity-center-trigger.svelte'; import * as Drawer from '$lib/components/ui/drawer/index.js'; import { ArcaneButton } from '$lib/components/arcane-button/index.js'; import { queryKeys } from '$lib/query/query-keys'; @@ -142,6 +143,7 @@ {#if memoizedUser} {/if} +
@@ -381,10 +383,11 @@ {#if versionInformation}

- Arcane {versionInformation.displayVersion ?? versionInformation.currentVersion} + {m.layout_title()} + {versionInformation.displayVersion ?? versionInformation.currentVersion}

{#if shouldShowBanner} -

Update available

+

{m.sidebar_update_available()}

{/if}
{#if shouldShowUpgrade} diff --git a/frontend/src/lib/components/progress-popover.svelte b/frontend/src/lib/components/progress-popover.svelte deleted file mode 100644 index 4ef6dc5694..0000000000 --- a/frontend/src/lib/components/progress-popover.svelte +++ /dev/null @@ -1,316 +0,0 @@ - - -{#snippet content()} - - - {#if error} - - {:else if isComplete && !loading} - - {:else} - - {/if} - - - {displayTitle} - - {#if error} - {error} - {:else if mode !== 'pull' && layerStats.total > 0} - {hasReachedComplete ? 100 : percent}% · {genericStatus} - - · {m.progress_layers_status({ completed: layerStats.completed, total: layerStats.total })} - {:else if mode !== 'pull'} - {#if hasStructuredProgress || loading || hasReachedComplete} - {hasReachedComplete ? 100 : percent}% · {genericStatus} - {:else} - {genericStatus} - {/if} - {:else if layerStats.total > 0} - {aggregateStatus || subtitle} - - · {m.progress_layers_status({ completed: layerStats.completed, total: layerStats.total })} - {:else} - {hasReachedComplete ? 100 : percent}% · {aggregateStatus || subtitle} - {/if} - - - {#if loading && onCancel} - - - - {/if} - {#if !error} - - - - {/if} - - - {#if Object.keys(layers).length > 0 && !error} - - - {m.progress_show_layers()} - - - -
- {#each Object.entries(layers) as [id, layer] (id)} - {@const layerStatus = sanitizeLogText(layer.status || '')} - {@const phase = hasReachedComplete ? 'complete' : getLayerPhase(layerStatus)} - {@const layerPercent = - phase === 'complete' ? 100 : layer.total > 0 ? Math.round((layer.current / layer.total) * 100) : 0} -
-
- {id.slice(0, 12)} - - {#if phase === 'complete'} - ✓ - {:else if layer.total > 0} - {layerPercent}% - {:else} - {layerStatus} - {/if} - -
- -
- {/each} -
-
-
- {/if} - - {#if (showOutputPanel || outputLines.length > 0) && !error} -
-
- {m.build_output()} - {outputLines.length} -
-
{#if outputLines.length > 0}{#each outputLines as line, i (i)}{line}{/each}{:else}{outputPlaceholder}{/if}
-
- {/if} -{/snippet} -{#if isMobile.current} - - - {#snippet child({ props })} - - {@render children()} - - {/snippet} - - - {@render content()} - - -{:else} - - - {#snippet child({ props })} - - {@render children()} - - {/snippet} - - - - {@render content()} - - - -{/if} diff --git a/frontend/src/lib/components/sheets/image-pull-sheet.svelte b/frontend/src/lib/components/sheets/image-pull-sheet.svelte index 93d0223256..b11ed523e1 100644 --- a/frontend/src/lib/components/sheets/image-pull-sheet.svelte +++ b/frontend/src/lib/components/sheets/image-pull-sheet.svelte @@ -2,28 +2,11 @@ import * as ResponsiveDialog from '$lib/components/ui/responsive-dialog/index.js'; import { ArcaneButton } from '$lib/components/arcane-button/index.js'; import FormInput from '$lib/components/form/form-input.svelte'; - import { Progress } from '$lib/components/ui/progress/index.js'; - import * as Collapsible from '$lib/components/ui/collapsible/index.js'; import { z } from 'zod/v4'; import { createForm, preventDefault } from '$lib/utils/form.utils'; import { toast } from 'svelte-sonner'; import { environmentStore } from '$lib/stores/environment.store.svelte'; import { m } from '$lib/paraglide/messages'; - import { cn } from '$lib/utils.js'; - import { - type LayerProgress, - type PullPhase, - calculateOverallProgress, - areAllLayersComplete, - updateLayerFromStreamData, - extractErrorMessage, - getLayerStats, - getPullPhase, - showImageLayersState, - isIndeterminatePhase, - getAggregateStatus - } from '$lib/utils/pull-progress'; - import { ArrowDownIcon } from '$lib/icons'; type ImagePullFormProps = { open: boolean; @@ -45,53 +28,12 @@ let { inputs, ...form } = $derived(createForm(formSchema, formData)); let isPulling = $state(false); - let pullProgress = $state(0); - let pullStatusText = $state(''); - let pullError = $state(''); - let layerProgress = $state>({}); - let hasReachedComplete = $state(false); - let currentImageName = $state(''); - const layerStats = $derived(getLayerStats(layerProgress, hasReachedComplete)); - const aggregateStatus = $derived(getAggregateStatus(layerProgress, pullStatusText, hasReachedComplete)); - const showPullUI = $derived(isPulling || hasReachedComplete || !!pullError); - const isIndeterminate = $derived(isIndeterminatePhase(layerProgress, pullProgress)); - let prevOpen = $state(false); - - $effect(() => { - if (prevOpen && !open && !isPulling) { - // Sheet just closed, reset state and form - resetState(); - $inputs.imageRef.value = ''; - $inputs.tag.value = 'latest'; - } - prevOpen = open; - }); - - function getLayerPhase(status: string): PullPhase { - return getPullPhase(status, false, false); - } - - function resetState() { - isPulling = false; - pullProgress = 0; - pullStatusText = ''; - pullError = ''; - layerProgress = {}; - hasReachedComplete = false; - currentImageName = ''; - } - - function updateProgress() { - pullProgress = calculateOverallProgress(layerProgress); - } async function handleSubmit() { const data = form.validate(); if (!data) return; - resetState(); isPulling = true; - pullStatusText = m.images_pull_initiating(); let imageName = data.imageRef.trim(); let imageTag = data.tag?.trim() || 'latest'; @@ -99,7 +41,6 @@ if (imageName.includes(':')) { const lastColonIndex = imageName.lastIndexOf(':'); const possibleTag = imageName.substring(lastColonIndex + 1).trim(); - // Only split if the part after the last colon looks like a tag (not a port number in a registry URL) if (possibleTag && !possibleTag.includes('/')) { imageName = imageName.substring(0, lastColonIndex); imageTag = possibleTag; @@ -107,16 +48,12 @@ } const fullImageName = `${imageName}:${imageTag}`; - currentImageName = fullImageName; const envId = await environmentStore.getCurrentEnvironmentId(); - pullStatusText = m.images_pull_initiating(); try { const response = await fetch(`/api/environments/${envId}/images/pull`, { method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ imageName: fullImageName }) }); @@ -124,101 +61,73 @@ const errorData = await response.json().catch(() => ({ data: { message: m.images_pull_server_error() } })); - const errorMessage = errorData.data?.message || errorData.error || errorData.message || `${m.images_pull_server_error()}: HTTP ${response.status}`; - throw new Error(errorMessage); } - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) { - pullStatusText = m.images_pull_processing_final_layers(); - break; - } - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (line.trim() === '') continue; - try { - const data = JSON.parse(line); - - const errorMsg = extractErrorMessage(data, m.images_pull_stream_error()); - if (errorMsg) { - console.error('Error in stream:', errorMsg); - pullError = errorMsg; - pullStatusText = m.images_pull_failed_with_error({ error: pullError }); - continue; - } - - if (data.status) pullStatusText = data.status; - layerProgress = updateLayerFromStreamData(layerProgress, data); - updateProgress(); - } catch (e: any) { - console.warn('Failed to parse stream line or process data:', line, e); - } - } - } - - updateProgress(); - if (!pullError && pullProgress < 100 && areAllLayersComplete(layerProgress)) { - pullProgress = 100; - } - - if (pullError) { - throw new Error(pullError); - } - - hasReachedComplete = true; - pullProgress = 100; - pullStatusText = m.images_pull_success({ repoTag: fullImageName }); - toast.success(m.images_pull_success({ repoTag: fullImageName })); - onPullFinished(true, fullImageName); + open = false; + isPulling = false; - // Close sheet after a brief delay to show success state - // State will be reset by handleOpenChange when sheet closes - setTimeout(() => { - open = false; - }, 1500); + drainPullStream(response.body, fullImageName); } catch (error: any) { - console.error('Pull image error:', error); const message = error.message || m.images_pull_unexpected_error(); - pullError = message; - pullStatusText = m.images_pull_failed_with_error({ error: message }); toast.error(message); onPullFinished(false, fullImageName, message); - } finally { isPulling = false; } } + function drainPullStream(body: ReadableStream, fullImageName: string) { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + (async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line); + if (parsed?.error) { + const errMsg = + typeof parsed.error === 'string' ? parsed.error : parsed.error.message || m.images_pull_stream_error(); + toast.error(errMsg); + onPullFinished(false, fullImageName, errMsg); + return; + } + } catch { + // ignore non-JSON lines + } + } + } + onPullFinished(true, fullImageName); + } catch (error: any) { + const message = error.message || m.images_pull_unexpected_error(); + toast.error(message); + onPullFinished(false, fullImageName, message); + } + })(); + } + function handleOpenChange(newOpenState: boolean) { if (!newOpenState && isPulling) { - toast.info(m.images_pull_in_progress_toast()); - open = true; // Keep it open return; } open = newOpenState; - if (!newOpenState) { - // Reset when closing (if we get here, isPulling is false) - resetState(); - $inputs.imageRef.value = ''; - $inputs.tag.value = 'latest'; - } else { - // Also reset when opening to ensure clean state - resetState(); + if (!newOpenState || newOpenState) { $inputs.imageRef.value = ''; $inputs.tag.value = 'latest'; } @@ -226,138 +135,36 @@ {#snippet children()} - {#if showPullUI} - -
- {#if pullError} -
-

{m.image_update_error_label()}

-

{pullError}

-
- {:else} - -
-
-

- {#if hasReachedComplete} - {m.progress_pull_completed()} - {:else if layerStats.total > 0} - - {aggregateStatus} - - - {m.progress_layers_status({ completed: layerStats.completed, total: layerStats.total })} - - - {:else} - {aggregateStatus || m.common_action_pulling()} - {/if} -

- {#if !isIndeterminate || hasReachedComplete} -

{Math.round(hasReachedComplete ? 100 : pullProgress)}%

- {/if} -
- -
- - {#if Object.keys(layerProgress).length > 0} - - - {m.progress_show_layers()} - - - -
- {#each Object.entries(layerProgress) as [id, layer] (id)} - {@const phase = hasReachedComplete ? 'complete' : getLayerPhase(layer.status)} - {@const layerPercent = - phase === 'complete' ? 100 : layer.total > 0 ? Math.round((layer.current / layer.total) * 100) : 0} -
-
- {id.slice(0, 12)} - - {#if phase === 'complete'} - ✓ - {:else if layer.total > 0} - {layerPercent}% - {:else} - {layer.status} - {/if} - -
- -
- {/each} -
-
-
- {/if} - - {#if isPulling} -

{m.images_pull_wait_message()}

- {/if} - {/if} -
- {:else} -
- - - - {/if} +
+ + + {/snippet} {#snippet footer()} - {#if pullError} -
- resetState()} - customLabel={m.common_retry()} - /> - (open = false)} customLabel={m.common_close()} /> -
- {:else if !showPullUI} -
- (open = false)} /> - -
- {/if} +
+ (open = false)} /> + +
{/snippet}
diff --git a/frontend/src/lib/components/sidebar/sidebar.svelte b/frontend/src/lib/components/sidebar/sidebar.svelte index d148b7f946..7a5e5deae5 100644 --- a/frontend/src/lib/components/sidebar/sidebar.svelte +++ b/frontend/src/lib/components/sidebar/sidebar.svelte @@ -15,6 +15,7 @@ import SidebarLogo from './sidebar-logo.svelte'; import SidebarUpdatebanner from './sidebar-updatebanner.svelte'; import SidebarPinButton from './sidebar-pin-button.svelte'; + import ActivityCenterTrigger from '$lib/components/activity/activity-center-trigger.svelte'; import userStore from '$lib/stores/user-store'; import settingsStore from '$lib/stores/config-store'; import { m } from '$lib/paraglide/messages'; @@ -90,6 +91,17 @@ {:else} (envSwitcherOpen = true)} /> {/if} + {#if isCollapsed} +
+ +
+ {:else} + + + + + + {/if} diff --git a/frontend/src/lib/components/vulnerability/vulnerability-scan-item.svelte b/frontend/src/lib/components/vulnerability/vulnerability-scan-item.svelte index 262dde1930..b386d165db 100644 --- a/frontend/src/lib/components/vulnerability/vulnerability-scan-item.svelte +++ b/frontend/src/lib/components/vulnerability/vulnerability-scan-item.svelte @@ -9,6 +9,7 @@ import { formatTime } from '$lib/utils/locale.util'; import { queryKeys } from '$lib/query/query-keys'; import { vulnerabilityService } from '$lib/services/vulnerability-service'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; import { startVulnerabilityScanPolling, stabilizeFailedVulnerabilitySummary, @@ -45,9 +46,9 @@ const isScanInProgress = $derived(isScanning || isVulnerabilityScanInProgress(scanSummary?.status)); const scanPhaseSteps = [ - { key: 'creating_container', label: 'Creating container' }, - { key: 'scanning_image', label: 'Scanning image' }, - { key: 'storing_results', label: 'Storing results' } + { key: 'creating_container', label: m.activity_scan_phase_creating_container() }, + { key: 'scanning_image', label: m.activity_scan_phase_scanning_image() }, + { key: 'storing_results', label: m.activity_scan_phase_storing_results() } ] as const; const scanErrorMessage = $derived.by(() => { const detail = scanSummary?.error?.trim(); @@ -134,6 +135,7 @@ imageId: result.imageId, scanTime: result.scanTime, status: result.status, + activityId: result.activityId, scanPhase: result.scanPhase, summary: result.summary, error: result.error @@ -141,10 +143,11 @@ scanSummary = summary; onScanned?.(summary); if (result.status === 'completed') { - toast.success(m.vuln_scan_completed()); + toast.success(m.vuln_scan_completed(), activityToastOptions(result.activityId)); } else if (result.status === 'failed') { - toast.error(result.error || m.vuln_scan_failed()); + toast.error(result.error || m.vuln_scan_failed(), activityToastOptions(result.activityId)); } else if (pollingEnabled) { + toast.info(m.vuln_scan_started(), activityToastOptions(result.activityId)); beginPolling(true); } } @@ -207,10 +210,11 @@ stopScanPolling(); if (showToast) { + const toastOptions = activityToastOptions(extractActivityId(resolvedSummary)); if (resolvedSummary.status === 'completed') { - toast.success(m.vuln_scan_completed()); + toast.success(m.vuln_scan_completed(), toastOptions); } else { - toast.error(resolvedSummary.error || m.vuln_scan_failed()); + toast.error(resolvedSummary.error || m.vuln_scan_failed(), toastOptions); } } }, diff --git a/frontend/src/lib/config/navigation-config.ts b/frontend/src/lib/config/navigation-config.ts index 9c56c27916..cfc047d756 100644 --- a/frontend/src/lib/config/navigation-config.ts +++ b/frontend/src/lib/config/navigation-config.ts @@ -25,7 +25,8 @@ import { TemplateIcon, GlobeIcon, UpdateIcon, - VariableIcon + VariableIcon, + ActivityIcon } from '$lib/icons'; import { m } from '$lib/paraglide/messages'; import type { ShortcutKey } from '$lib/utils/keyboard-shortcut.utils'; @@ -125,6 +126,7 @@ export const navigationItems: NavigationSections = { icon: NotificationsIcon, shortcut: ['mod', 'shift', '4'] }, + { title: m.activity_settings_title(), url: '/settings/activity', icon: ActivityIcon }, { title: m.builds(), url: '/settings/builds', icon: HammerIcon, shortcut: ['mod', 'shift', '6'] }, { title: m.timeouts_settings(), url: '/settings/timeouts', icon: JobsIcon, shortcut: ['mod', 'shift', '7'] }, { title: m.users_title(), url: '/settings/users', icon: UsersIcon, shortcut: ['mod', 'shift', '8'] } diff --git a/frontend/src/lib/icons/index.ts b/frontend/src/lib/icons/index.ts index 2f1963bd1b..d651c81d98 100644 --- a/frontend/src/lib/icons/index.ts +++ b/frontend/src/lib/icons/index.ts @@ -12,6 +12,7 @@ export { default as ImagesIcon } from 'virtual:icons/solar/gallery-linear'; export { default as NetworksIcon } from 'virtual:icons/fluent/virtual-network-16-filled'; export { default as VolumesIcon } from 'virtual:icons/fluent/hard-drive-20-filled'; export { default as EventsIcon } from 'virtual:icons/material-symbols/event-list'; +export { default as ActivityIcon } from 'virtual:icons/lucide/activity'; export { default as SettingsIcon } from 'virtual:icons/solar/settings-outline'; export { default as AppearanceIcon } from 'virtual:icons/f7/paintbrush'; export { default as DockerBrandIcon } from 'virtual:icons/cib/docker'; diff --git a/frontend/src/lib/services/activity-service.ts b/frontend/src/lib/services/activity-service.ts new file mode 100644 index 0000000000..2b65ce7a94 --- /dev/null +++ b/frontend/src/lib/services/activity-service.ts @@ -0,0 +1,48 @@ +import BaseAPIService from './api-service'; +import { environmentStore } from '$lib/stores/environment.store.svelte'; +import type { Activity, ActivityClearHistoryResult, ActivityDetail } from '$lib/types/activity.type'; +import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; +import { transformPaginationParams } from '$lib/utils/params.util'; + +export class ActivityService extends BaseAPIService { + private async resolveEnvironmentId(environmentId?: string): Promise { + return environmentId ?? (await environmentStore.getCurrentEnvironmentId()); + } + + async getActivities(options?: SearchPaginationSortRequest, environmentId?: string): Promise> { + const envId = await this.resolveEnvironmentId(environmentId); + const params = transformPaginationParams(options); + const res = await this.api.get(`/environments/${envId}/activities`, { params }); + return res.data; + } + + async getActivity(activityId: string, environmentId?: string, limit = 500): Promise { + const envId = await this.resolveEnvironmentId(environmentId); + return this.handleResponse(this.api.get(`/environments/${envId}/activities/${activityId}`, { params: { limit } })); + } + + async clearHistory(environmentId?: string): Promise { + const envId = await this.resolveEnvironmentId(environmentId); + return this.handleResponse(this.api.delete(`/environments/${envId}/activities/history`)); + } + + getActivityStreamUrl(environmentId: string, limit = 50): string { + const baseUrl = this.api.defaults.baseURL.replace(/\/+$/, ''); + const params = new URLSearchParams({ limit: String(limit) }); + return `${baseUrl}/environments/${encodeURIComponent(environmentId)}/activities/stream?${params.toString()}`; + } + + async openActivityStream(environmentId: string, signal: AbortSignal, limit = 50): Promise { + const response = await fetch(this.getActivityStreamUrl(environmentId, limit), { + credentials: 'include', + headers: { Accept: 'application/x-json-stream' }, + signal + }); + if (!response.ok) { + throw new Error(`Activity stream failed with status ${response.status}`); + } + return response; + } +} + +export const activityService = new ActivityService(); diff --git a/frontend/src/lib/services/image-service.ts b/frontend/src/lib/services/image-service.ts index e149d82d7d..399a99c7a7 100644 --- a/frontend/src/lib/services/image-service.ts +++ b/frontend/src/lib/services/image-service.ts @@ -49,9 +49,9 @@ export class ImageService extends BaseAPIService { return this.handleResponse(this.api.post(`/environments/${envId}/images/pull`, { imageName, tag, auth })); } - async deleteImage(imageId: string, options?: { force?: boolean; noprune?: boolean }): Promise { + async deleteImage(imageId: string, options?: { force?: boolean; noprune?: boolean }): Promise { const envId = await environmentStore.getCurrentEnvironmentId(); - await this.handleResponse(this.api.delete(`/environments/${envId}/images/${imageId}`, { params: options })); + return this.handleResponse(this.api.delete(`/environments/${envId}/images/${imageId}`, { params: options })); } async pruneImages(options: PruneImagesOptions): Promise { diff --git a/frontend/src/lib/services/volume-backup-service.ts b/frontend/src/lib/services/volume-backup-service.ts index 6fddad623d..b9d6462657 100644 --- a/frontend/src/lib/services/volume-backup-service.ts +++ b/frontend/src/lib/services/volume-backup-service.ts @@ -20,12 +20,12 @@ export class VolumeBackupService extends BaseAPIService { return res.data; } - async restoreBackup(volumeName: string, backupId: string): Promise { + async restoreBackup(volumeName: string, backupId: string): Promise { const envId = await environmentStore.getCurrentEnvironmentId(); return this.handleResponse(this.api.post(`/environments/${envId}/volumes/${volumeName}/backups/${backupId}/restore`)); } - async restoreBackupFiles(volumeName: string, backupId: string, paths: string[]): Promise { + async restoreBackupFiles(volumeName: string, backupId: string, paths: string[]): Promise { const envId = await environmentStore.getCurrentEnvironmentId(); return this.handleResponse( this.api.post(`/environments/${envId}/volumes/${volumeName}/backups/${backupId}/restore-files`, { @@ -48,7 +48,7 @@ export class VolumeBackupService extends BaseAPIService { return res.data.data ?? []; } - async deleteBackup(backupId: string): Promise { + async deleteBackup(backupId: string): Promise { const envId = await environmentStore.getCurrentEnvironmentId(); return this.handleResponse(this.api.delete(`/environments/${envId}/volumes/backups/${backupId}`)); } @@ -68,7 +68,7 @@ export class VolumeBackupService extends BaseAPIService { link.remove(); } - async uploadAndRestore(volumeName: string, file: File): Promise { + async uploadAndRestore(volumeName: string, file: File): Promise { const envId = await environmentStore.getCurrentEnvironmentId(); const formData = new FormData(); formData.append('file', file); diff --git a/frontend/src/lib/services/volume-browser-service.ts b/frontend/src/lib/services/volume-browser-service.ts index b932757462..2a865dd0c4 100644 --- a/frontend/src/lib/services/volume-browser-service.ts +++ b/frontend/src/lib/services/volume-browser-service.ts @@ -37,7 +37,7 @@ export class VolumeBrowserService extends BaseAPIService { link.remove(); } - async uploadFile(volumeName: string, path: string, file: File): Promise { + async uploadFile(volumeName: string, path: string, file: File): Promise { const envId = await environmentStore.getCurrentEnvironmentId(); const formData = new FormData(); formData.append('file', file); @@ -48,7 +48,7 @@ export class VolumeBrowserService extends BaseAPIService { ); } - async createDirectory(volumeName: string, path: string): Promise { + async createDirectory(volumeName: string, path: string): Promise { const envId = await environmentStore.getCurrentEnvironmentId(); return this.handleResponse( this.api.post(`/environments/${envId}/volumes/${volumeName}/browse/mkdir`, null, { @@ -57,7 +57,7 @@ export class VolumeBrowserService extends BaseAPIService { ); } - async deleteFile(volumeName: string, path: string): Promise { + async deleteFile(volumeName: string, path: string): Promise { const envId = await environmentStore.getCurrentEnvironmentId(); return this.handleResponse( this.api.delete(`/environments/${envId}/volumes/${volumeName}/browse`, { diff --git a/frontend/src/lib/stores/activity.store.svelte.ts b/frontend/src/lib/stores/activity.store.svelte.ts new file mode 100644 index 0000000000..ebb5ac0e6d --- /dev/null +++ b/frontend/src/lib/stores/activity.store.svelte.ts @@ -0,0 +1,434 @@ +import { browser } from '$app/environment'; +import { activityService } from '$lib/services/activity-service'; +import { environmentStore, LOCAL_DOCKER_ENVIRONMENT_ID } from '$lib/stores/environment.store.svelte'; +import type { + Activity, + ActivityDetail, + ActivityFilter, + ActivityMessage, + ActivityStatus, + ActivityStreamEvent +} from '$lib/types/activity.type'; + +const ACTIVITY_LIST_LIMIT = 50; +const ACTIVITY_DETAIL_LIMIT = 500; +const MAX_RECONNECT_DELAY = 15_000; +const MAX_RECONNECT_ATTEMPTS = 20; + +function sortActivitiesInternal(items: Activity[]): Activity[] { + return [...items].sort((a, b) => { + const aActive = isActiveStatusInternal(a.status); + const bActive = isActiveStatusInternal(b.status); + if (aActive !== bActive) return aActive ? -1 : 1; + return getActivitySortTimeInternal(b) - getActivitySortTimeInternal(a); + }); +} + +function getActivitySortTimeInternal(activity: Activity): number { + const value = activity.updatedAt || activity.endedAt || activity.startedAt || activity.createdAt; + return value ? new Date(value).getTime() : 0; +} + +function isActiveStatusInternal(status: ActivityStatus): boolean { + return status === 'queued' || status === 'running'; +} + +function filterActivityInternal(activity: Activity, filter: ActivityFilter): boolean { + switch (filter) { + case 'running': + return isActiveStatusInternal(activity.status); + case 'failed': + return activity.status === 'failed'; + case 'completed': + return activity.status === 'success' || activity.status === 'cancelled'; + } +} + +function createActivityStore() { + let _activities = $state([]); + let _details = $state>({}); + let _expandedActivityIds = $state>({}); + let _detailLoadingIds = $state>({}); + let _detailErrorIds = $state>({}); + let _filter = $state('running'); + let _open = $state(false); + let _loading = $state(false); + let _connected = $state(false); + let _streamError = $state(false); + let _currentEnvironmentId = $state(LOCAL_DOCKER_ENVIRONMENT_ID); + + let started = false; + let streamGeneration = 0; + let streamAbortController: AbortController | null = null; + let reconnectTimer: ReturnType | null = null; + let unsubscribeEnvironment: (() => void) | null = null; + let reconnectAttempt = 0; + + function clearReconnectTimerInternal() { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + } + + function abortStreamInternal() { + clearReconnectTimerInternal(); + streamAbortController?.abort(); + streamAbortController = null; + _connected = false; + } + + function resetEnvironmentStateInternal(environmentId: string) { + _currentEnvironmentId = environmentId || LOCAL_DOCKER_ENVIRONMENT_ID; + _activities = []; + _details = {}; + _expandedActivityIds = {}; + _detailLoadingIds = {}; + _detailErrorIds = {}; + _connected = false; + _streamError = false; + _loading = true; + reconnectAttempt = 0; + } + + async function refreshInternal(environmentId = _currentEnvironmentId, generation = streamGeneration) { + _loading = true; + try { + const result = await activityService.getActivities({ pagination: { page: 1, limit: ACTIVITY_LIST_LIMIT } }, environmentId); + if (generation !== streamGeneration || environmentId !== _currentEnvironmentId) { + return; + } + replaceSnapshotInternal(result.data ?? []); + } catch (error) { + console.warn('Failed to refresh activities:', error); + } finally { + if (generation === streamGeneration && environmentId === _currentEnvironmentId) { + _loading = false; + } + } + } + + function replaceSnapshotInternal(activities: Activity[]) { + _activities = sortActivitiesInternal(activities); + // Drop expansion state for activities that no longer exist in the snapshot. + const present = new Set(_activities.map((activity) => activity.id)); + const nextExpanded: Record = {}; + for (const id of Object.keys(_expandedActivityIds)) { + if (_expandedActivityIds[id] && present.has(id)) { + nextExpanded[id] = true; + } + } + _expandedActivityIds = nextExpanded; + } + + function mergeActivityInternal(activity: Activity) { + const index = _activities.findIndex((item) => item.id === activity.id); + if (index >= 0) { + _activities = sortActivitiesInternal([..._activities.slice(0, index), activity, ..._activities.slice(index + 1)]); + } else { + _activities = sortActivitiesInternal([activity, ..._activities]).slice(0, ACTIVITY_LIST_LIMIT); + } + + const existingDetail = _details[activity.id]; + if (existingDetail) { + _details = { + ..._details, + [activity.id]: { + ...existingDetail, + activity + } + }; + } + } + + function mergeMessageInternal(message: ActivityMessage) { + const detail = _details[message.activityId]; + if (!detail) { + return; + } + + const exists = detail.messages.some((item) => item.id === message.id); + const messages = exists ? detail.messages : [...detail.messages, message].slice(-ACTIVITY_DETAIL_LIMIT); + _details = { + ..._details, + [message.activityId]: { + ...detail, + messages + } + }; + } + + function applyStreamEventInternal(event: ActivityStreamEvent) { + switch (event.type) { + case 'snapshot': + replaceSnapshotInternal(event.activities ?? []); + _loading = false; + break; + case 'activity': + if (event.activity) { + mergeActivityInternal(event.activity); + } + break; + case 'message': + if (event.message) { + mergeMessageInternal(event.message); + } + break; + case 'heartbeat': + _connected = true; + break; + } + } + + async function connectStreamInternal(environmentId: string, generation: number) { + if (!browser || generation !== streamGeneration) { + return; + } + + streamAbortController = new AbortController(); + try { + const response = await activityService.openActivityStream(environmentId, streamAbortController.signal, ACTIVITY_LIST_LIMIT); + if (generation !== streamGeneration || !response.body) { + streamAbortController = null; + return; + } + + _connected = true; + _streamError = false; + reconnectAttempt = 0; + await readJSONLinesInternal(response.body, generation); + } catch (error) { + if (!streamAbortController?.signal.aborted && generation === streamGeneration) { + console.warn('Activity stream disconnected:', error); + } + } finally { + streamAbortController = null; + if (generation === streamGeneration) { + _connected = false; + scheduleReconnectInternal(environmentId, generation); + } + } + } + + async function readJSONLinesInternal(stream: ReadableStream, generation: number) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (generation === streamGeneration) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + for (const line of lines) { + handleStreamLineInternal(line); + } + } + + buffer += decoder.decode(); + if (buffer.trim()) { + handleStreamLineInternal(buffer); + } + } finally { + reader.releaseLock(); + } + } + + function handleStreamLineInternal(line: string) { + const trimmed = line.trim(); + if (!trimmed) { + return; + } + + try { + applyStreamEventInternal(JSON.parse(trimmed) as ActivityStreamEvent); + } catch (error) { + console.warn('Failed to parse activity stream line:', error); + } + } + + function scheduleReconnectInternal(environmentId: string, generation: number) { + if (!browser || !started || generation !== streamGeneration) { + return; + } + + if (reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) { + _streamError = true; + return; + } + + clearReconnectTimerInternal(); + const delay = Math.min(1000 * 2 ** reconnectAttempt, MAX_RECONNECT_DELAY); + reconnectAttempt += 1; + reconnectTimer = setTimeout(() => { + void connectStreamInternal(environmentId, generation); + }, delay); + } + + function restartForEnvironmentInternal(environmentId: string) { + streamGeneration += 1; + abortStreamInternal(); + resetEnvironmentStateInternal(environmentId); + const generation = streamGeneration; + void refreshInternal(environmentId, generation); + void connectStreamInternal(environmentId, generation); + } + + async function loadDetailInternal(activityId: string) { + if (_details[activityId] || _detailLoadingIds[activityId]) { + return; + } + + _detailLoadingIds = { ..._detailLoadingIds, [activityId]: true }; + try { + const detail = await activityService.getActivity(activityId, _currentEnvironmentId, ACTIVITY_DETAIL_LIMIT); + _details = { ..._details, [activityId]: detail }; + const nextErrors = { ..._detailErrorIds }; + delete nextErrors[activityId]; + _detailErrorIds = nextErrors; + } catch (error) { + console.warn('Failed to load activity detail:', error); + _detailErrorIds = { ..._detailErrorIds, [activityId]: true }; + } finally { + const next = { ..._detailLoadingIds }; + delete next[activityId]; + _detailLoadingIds = next; + } + } + + function setActivityExpanded(activityId: string, expanded: boolean) { + if (!activityId) { + return; + } + + if (expanded) { + if (_expandedActivityIds[activityId]) { + return; + } + _expandedActivityIds = { ..._expandedActivityIds, [activityId]: true }; + void loadDetailInternal(activityId); + } else { + if (!_expandedActivityIds[activityId]) { + return; + } + const next = { ..._expandedActivityIds }; + delete next[activityId]; + _expandedActivityIds = next; + } + } + + function toggleActivity(activityId: string) { + setActivityExpanded(activityId, !_expandedActivityIds[activityId]); + } + + return { + get activities(): Activity[] { + return _activities; + }, + get filteredActivities(): Activity[] { + return _activities.filter((activity) => filterActivityInternal(activity, _filter)); + }, + get activeCount(): number { + return _activities.filter((activity) => isActiveStatusInternal(activity.status)).length; + }, + get filter(): ActivityFilter { + return _filter; + }, + get open(): boolean { + return _open; + }, + get loading(): boolean { + return _loading; + }, + get connected(): boolean { + return _connected; + }, + get streamError(): boolean { + return _streamError; + }, + get currentEnvironmentId(): string { + return _currentEnvironmentId; + }, + isExpanded(activityId: string): boolean { + return !!_expandedActivityIds[activityId]; + }, + isDetailLoading(activityId: string): boolean { + return !!_detailLoadingIds[activityId]; + }, + isDetailError(activityId: string): boolean { + return !!_detailErrorIds[activityId]; + }, + getDetail(activityId: string): ActivityDetail | null { + const activity = _details[activityId]?.activity ?? _activities.find((item) => item.id === activityId); + if (!activity) { + return null; + } + return _details[activityId] ?? { activity, messages: [] }; + }, + getActivity(activityId: string): Activity | null { + return _details[activityId]?.activity ?? _activities.find((item) => item.id === activityId) ?? null; + }, + start: async () => { + if (!browser || started) { + return; + } + + started = true; + await environmentStore.ready; + restartForEnvironmentInternal(environmentStore.selected?.id ?? LOCAL_DOCKER_ENVIRONMENT_ID); + unsubscribeEnvironment = environmentStore.subscribeSelected((environment) => { + restartForEnvironmentInternal(environment?.id ?? LOCAL_DOCKER_ENVIRONMENT_ID); + }); + }, + stop: () => { + started = false; + unsubscribeEnvironment?.(); + unsubscribeEnvironment = null; + streamGeneration += 1; + abortStreamInternal(); + }, + refresh: () => refreshInternal(), + clearHistory: async () => { + await activityService.clearHistory(_currentEnvironmentId); + _details = {}; + _expandedActivityIds = {}; + _detailLoadingIds = {}; + await refreshInternal(); + }, + setFilter: (filter: ActivityFilter) => { + _filter = filter; + }, + setOpen: (open: boolean) => { + _open = open; + }, + openCenter: (activityId?: string) => { + _open = true; + if (activityId) { + setActivityExpanded(activityId, true); + } + }, + retryLoadDetail: (activityId: string) => { + const nextErrors = { ..._detailErrorIds }; + delete nextErrors[activityId]; + _detailErrorIds = nextErrors; + const nextDetails = { ..._details }; + delete nextDetails[activityId]; + _details = nextDetails; + void loadDetailInternal(activityId); + }, + retryStream: () => { + _streamError = false; + reconnectAttempt = 0; + restartForEnvironmentInternal(_currentEnvironmentId); + }, + setActivityExpanded, + toggleActivity + }; +} + +export const activityStore = createActivityStore(); diff --git a/frontend/src/lib/types/activity.type.ts b/frontend/src/lib/types/activity.type.ts new file mode 100644 index 0000000000..8c2a8a571d --- /dev/null +++ b/frontend/src/lib/types/activity.type.ts @@ -0,0 +1,82 @@ +export type ActivityStatus = 'queued' | 'running' | 'success' | 'failed' | 'cancelled'; + +export type ActivityType = + | 'image_pull' + | 'image_build' + | 'image_update_check' + | 'project_pull' + | 'project_build' + | 'project_deploy' + | 'project_redeploy' + | 'project_down' + | 'project_restart' + | 'project_destroy' + | 'container_start' + | 'container_stop' + | 'container_restart' + | 'container_redeploy' + | 'container_delete' + | 'vulnerability_scan' + | 'auto_update' + | 'system_prune' + | 'resource_action'; + +export type ActivityMessageLevel = 'info' | 'warning' | 'error' | 'success'; + +export type ActivityFilter = 'running' | 'failed' | 'completed'; + +export interface Activity { + id: string; + environmentId: string; + sourceEnvironmentId?: string; + sourceEnvironmentName?: string; + type: ActivityType; + status: ActivityStatus; + resourceType?: string; + resourceId?: string; + resourceName?: string; + progress?: number | null; + step?: string; + latestMessage?: string; + startedBy?: ActivityStartedBy; + startedAt: string; + endedAt?: string; + durationMs?: number; + error?: string; + metadata?: Record; + createdAt: string; + updatedAt?: string; +} + +export interface ActivityStartedBy { + userId?: string; + username: string; + displayName?: string; +} + +export interface ActivityMessage { + id: string; + activityId: string; + level: ActivityMessageLevel; + message: string; + payload?: Record; + createdAt: string; +} + +export interface ActivityDetail { + activity: Activity; + messages: ActivityMessage[]; +} + +export interface ActivityClearHistoryResult { + deleted: number; +} + +export interface ActivityStreamEvent { + type: 'snapshot' | 'activity' | 'message' | 'heartbeat'; + activityId?: string; + activity?: Activity; + activities?: Activity[]; + message?: ActivityMessage; + timestamp: string; +} diff --git a/frontend/src/lib/types/auto-update.type.ts b/frontend/src/lib/types/auto-update.type.ts index 01524d659d..f465ceedc5 100644 --- a/frontend/src/lib/types/auto-update.type.ts +++ b/frontend/src/lib/types/auto-update.type.ts @@ -15,6 +15,7 @@ export interface AutoUpdateResult { failed: number; items: AutoUpdateResourceResult[]; duration: string; + activityId?: string; } export interface AutoUpdateResourceResult { diff --git a/frontend/src/lib/types/image.type.ts b/frontend/src/lib/types/image.type.ts index 38b5b5556a..e378292bb4 100644 --- a/frontend/src/lib/types/image.type.ts +++ b/frontend/src/lib/types/image.type.ts @@ -14,6 +14,7 @@ export interface ImageUpdateInfoDto { authUsername?: string; authRegistry?: string; usedCredential?: boolean; + activityId?: string; } export interface ImageUsageCounts { diff --git a/frontend/src/lib/types/network.type.ts b/frontend/src/lib/types/network.type.ts index 469a2896b3..97c37248ef 100644 --- a/frontend/src/lib/types/network.type.ts +++ b/frontend/src/lib/types/network.type.ts @@ -29,6 +29,12 @@ export interface NetworkCreateRequest { options: NetworkCreateOptions; } +export interface NetworkCreateResponse { + id: string; + warning?: string; + activityId?: string; +} + export interface NetworkUsageCounts { inuse: number; unused: number; diff --git a/frontend/src/lib/types/project.type.ts b/frontend/src/lib/types/project.type.ts index b8908ee596..70448d10ae 100644 --- a/frontend/src/lib/types/project.type.ts +++ b/frontend/src/lib/types/project.type.ts @@ -96,6 +96,7 @@ export interface Project { envContent?: string; includeFiles?: IncludeFile[]; directoryFiles?: IncludeFile[]; + activityId?: string; } export interface ProjectStatusCounts { diff --git a/frontend/src/lib/types/settings.type.ts b/frontend/src/lib/types/settings.type.ts index 2c111ff89d..bc3f6e8166 100644 --- a/frontend/src/lib/types/settings.type.ts +++ b/frontend/src/lib/types/settings.type.ts @@ -14,6 +14,8 @@ export type Settings = { pollingInterval: number; dockerClientRefreshInterval?: string; environmentHealthInterval: number; + activityHistoryRetentionDays: number; + activityHistoryMaxEntries: number; defaultDeployPullPolicy: 'missing' | 'always' | 'never'; scheduledPruneEnabled?: boolean; scheduledPruneInterval?: number; diff --git a/frontend/src/lib/types/volume.type.ts b/frontend/src/lib/types/volume.type.ts index 350b68b964..672540b09d 100644 --- a/frontend/src/lib/types/volume.type.ts +++ b/frontend/src/lib/types/volume.type.ts @@ -22,6 +22,7 @@ export interface VolumeSummaryDto { inUse: boolean; usageData?: VolumeUsageData; size: number; + activityId?: string; } export interface VolumeDetailDto extends VolumeSummaryDto { diff --git a/frontend/src/lib/types/vulnerability.type.ts b/frontend/src/lib/types/vulnerability.type.ts index 2c806dbde9..b6531da4a7 100644 --- a/frontend/src/lib/types/vulnerability.type.ts +++ b/frontend/src/lib/types/vulnerability.type.ts @@ -48,6 +48,7 @@ export interface VulnerabilityScanResult { imageName: string; scanTime: string; status: VulnerabilityScanStatus; + activityId?: string; scanPhase?: 'creating_container' | 'scanning_image' | 'storing_results'; summary?: SeveritySummary; vulnerabilities?: Vulnerability[]; @@ -60,6 +61,7 @@ export interface VulnerabilityScanSummary { imageId: string; scanTime: string; status: VulnerabilityScanStatus; + activityId?: string; scanPhase?: 'creating_container' | 'scanning_image' | 'storing_results'; summary?: SeveritySummary; error?: string; diff --git a/frontend/src/lib/utils/activity-toast.ts b/frontend/src/lib/utils/activity-toast.ts new file mode 100644 index 0000000000..fdf978fe94 --- /dev/null +++ b/frontend/src/lib/utils/activity-toast.ts @@ -0,0 +1,41 @@ +import { activityStore } from '$lib/stores/activity.store.svelte'; +import { m } from '$lib/paraglide/messages'; + +export function activityToastOptions(activityId?: string) { + if (!activityId) { + return undefined; + } + + return { + action: { + label: m.activity_view_activity(), + onClick: () => activityStore.openCenter(activityId) + } + }; +} + +export function extractActivityId(value: unknown): string | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + + const activityId = (value as { activityId?: unknown }).activityId; + if (typeof activityId === 'string' && activityId.trim()) { + return activityId; + } + + if (Array.isArray(value)) { + for (const item of value) { + const nested = extractActivityId(item); + if (nested) return nested; + } + return undefined; + } + + for (const item of Object.values(value)) { + const nested = extractActivityId(item); + if (nested) return nested; + } + + return undefined; +} diff --git a/frontend/src/routes/(app)/+layout.svelte b/frontend/src/routes/(app)/+layout.svelte index fdfd3dd9de..56b4346695 100644 --- a/frontend/src/routes/(app)/+layout.svelte +++ b/frontend/src/routes/(app)/+layout.svelte @@ -5,6 +5,7 @@ import * as Sidebar from '$lib/components/ui/sidebar/index.js'; import AppSidebar from '$lib/components/sidebar/sidebar.svelte'; import MobileNav from '$lib/components/mobile-nav/mobile-nav.svelte'; + import ActivityCenter from '$lib/components/activity/activity-center.svelte'; import { IsMobile } from '$lib/hooks/is-mobile.svelte.js'; import { IsTablet } from '$lib/hooks/is-tablet.svelte.js'; import { getEffectiveNavigationSettings, navigationSettingsOverridesStore } from '$lib/utils/navigation.utils'; @@ -114,3 +115,5 @@ {/if} + + diff --git a/frontend/src/routes/(app)/containers/+page.svelte b/frontend/src/routes/(app)/containers/+page.svelte index fd35aa9e3d..10a060237e 100644 --- a/frontend/src/routes/(app)/containers/+page.svelte +++ b/frontend/src/routes/(app)/containers/+page.svelte @@ -15,6 +15,7 @@ import type { SearchPaginationSortRequest } from '$lib/types/pagination.type'; import type { ContainerListRequestOptions } from '$lib/services/container-service'; import ContainerEnvironmentSync from './components/container-environment-sync.svelte'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { data } = $props(); @@ -54,8 +55,8 @@ const checkUpdatesMutation = createMutation(() => ({ mutationKey: queryKeys.containers.checkUpdates(envId), mutationFn: () => imageService.runAutoUpdate(), - onSuccess: async () => { - toast.success(m.containers_check_updates_success()); + onSuccess: async (data) => { + toast.success(m.containers_check_updates_success(), activityToastOptions(extractActivityId(data))); await refreshContainers(); }, onError: () => { diff --git a/frontend/src/routes/(app)/containers/container-table.actions.ts b/frontend/src/routes/(app)/containers/container-table.actions.ts index 72d024ead0..6d8171d9a4 100644 --- a/frontend/src/routes/(app)/containers/container-table.actions.ts +++ b/frontend/src/routes/(app)/containers/container-table.actions.ts @@ -3,6 +3,7 @@ import { m } from '$lib/paraglide/messages'; import { containerService, type ContainersPaginatedResponse } from '$lib/services/container-service'; import type { ContainerSummaryDto } from '$lib/types/container.type'; import { handleApiResultWithCallbacks } from '$lib/utils/api.util'; +import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; import { tryCatch } from '$lib/utils/try-catch'; import { toast } from 'svelte-sonner'; import { getContainerDisplayName, type ActionStatus } from './container-table.helpers'; @@ -94,8 +95,8 @@ export function createContainerActions({ setLoadingState: (value) => { actionStatus[id] = value ? config.status : ''; }, - async onSuccess() { - toast.success(config.success()); + async onSuccess(data) { + toast.success(config.success(), activityToastOptions(extractActivityId(data))); await reloadContainers(); } }); @@ -135,8 +136,8 @@ export function createContainerActions({ setLoadingState: (value) => { actionStatus[id] = value ? 'removing' : ''; }, - async onSuccess() { - toast.success(m.containers_remove_success()); + async onSuccess(data) { + toast.success(m.containers_remove_success(), activityToastOptions(extractActivityId(data))); await reloadContainers(); } }); @@ -157,19 +158,19 @@ export function createContainerActions({ action: async () => { actionStatus[container.id] = 'updating'; try { - toast.info(m.containers_update_pulling_image()); - const result = await containerService.updateContainer(container.id); + const toastOptions = activityToastOptions(extractActivityId(result)); if (result.failed > 0) { const failedItem = result.items?.find((item: { status?: string; error?: string }) => item.status === 'failed'); toast.error( - m.containers_update_failed({ name: containerName }) + (failedItem?.error ? `: ${failedItem.error}` : '') + m.containers_update_failed({ name: containerName }) + (failedItem?.error ? `: ${failedItem.error}` : ''), + toastOptions ); } else if (result.updated > 0) { - toast.success(m.containers_update_success({ name: containerName })); + toast.success(m.containers_update_success({ name: containerName }), toastOptions); } else { - toast.info(m.image_update_up_to_date_title()); + toast.info(m.image_update_up_to_date_title(), toastOptions); } await reloadContainers(); @@ -199,8 +200,8 @@ export function createContainerActions({ setLoadingState: (value) => { actionStatus[container.id] = value ? 'redeploying' : ''; }, - async onSuccess() { - toast.success(m.container_redeploy_success()); + async onSuccess(data) { + toast.success(m.container_redeploy_success(), activityToastOptions(extractActivityId(data))); await refreshContainers(); } }); diff --git a/frontend/src/routes/(app)/dashboard/+page.svelte b/frontend/src/routes/(app)/dashboard/+page.svelte index 0baa126faf..606fd30734 100644 --- a/frontend/src/routes/(app)/dashboard/+page.svelte +++ b/frontend/src/routes/(app)/dashboard/+page.svelte @@ -8,6 +8,7 @@ import * as Card from '$lib/components/ui/card/index.js'; import { ArcaneButton } from '$lib/components/arcane-button/index.js'; import { handleApiResultWithCallbacks } from '$lib/utils/api.util'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; import { tryCatch } from '$lib/utils/try-catch'; import { openConfirmDialog } from '$lib/components/confirm-dialog'; import { environmentStore } from '$lib/stores/environment.store.svelte'; @@ -366,8 +367,8 @@ result: await tryCatch(systemService.startAllStoppedContainers()), message: m.dashboard_start_all_failed(), setLoadingState: (value) => (isLoading.starting = value), - onSuccess: async () => { - toast.success(m.dashboard_start_all_success()); + onSuccess: async (data) => { + toast.success(m.dashboard_start_all_success(), activityToastOptions(extractActivityId(data))); await refreshData(); } }); @@ -386,8 +387,8 @@ result: await tryCatch(systemService.stopAllContainers()), message: m.dashboard_stop_all_failed(), setLoadingState: (value) => (isLoading.stopping = value), - onSuccess: async () => { - toast.success(m.dashboard_stop_all_success()); + onSuccess: async (data) => { + toast.success(m.dashboard_stop_all_success(), activityToastOptions(extractActivityId(data))); await refreshData(); } }); @@ -414,12 +415,12 @@ result: await tryCatch(systemService.pruneAll(pruneRequest)), message: m.dashboard_prune_failed({ types: typesString }), setLoadingState: (value) => (isLoading.pruning = value), - onSuccess: async () => { + onSuccess: async (data) => { isPruneDialogOpen = false; if (selectedTypes.length === 1) { - toast.success(m.dashboard_prune_success_one({ types: typesString })); + toast.success(m.dashboard_prune_success_one({ types: typesString }), activityToastOptions(extractActivityId(data))); } else { - toast.success(m.dashboard_prune_success_many({ types: typesString })); + toast.success(m.dashboard_prune_success_many({ types: typesString }), activityToastOptions(extractActivityId(data))); } await refreshData(); } diff --git a/frontend/src/routes/(app)/dashboard/dashboard-all-environments-view.svelte b/frontend/src/routes/(app)/dashboard/dashboard-all-environments-view.svelte index 73e9ce6e53..fcf50e9895 100644 --- a/frontend/src/routes/(app)/dashboard/dashboard-all-environments-view.svelte +++ b/frontend/src/routes/(app)/dashboard/dashboard-all-environments-view.svelte @@ -25,6 +25,7 @@ import type { Environment } from '$lib/types/environment.type'; import type { PruneType, SystemPruneRequest } from '$lib/types/prune.type'; import { extractApiErrorMessage, handleApiResultWithCallbacks } from '$lib/utils/api.util'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; import { capitalizeFirstLetter } from '$lib/utils/string.utils'; import { tryCatch } from '$lib/utils/try-catch'; import { getEnvironmentStatusVariant, isEnvironmentOnline, resolveEnvironmentStatus } from '$lib/utils/environment-status'; @@ -622,17 +623,17 @@ setLoadingState: (value) => { pruningEnvironmentId = value ? environmentId : null; }, - onSuccess: async () => { + onSuccess: async (data) => { isPruneDialogOpen = false; pruneEnvironment = null; + const toastOptions = { + ...(activityToastOptions(extractActivityId(data)) ?? {}), + description: targetEnvironment.environment.name + }; if (selectedTypes.length === 1) { - toast.success(m.dashboard_prune_success_one({ types: typesString }), { - description: targetEnvironment.environment.name - }); + toast.success(m.dashboard_prune_success_one({ types: typesString }), toastOptions); } else { - toast.success(m.dashboard_prune_success_many({ types: typesString }), { - description: targetEnvironment.environment.name - }); + toast.success(m.dashboard_prune_success_many({ types: typesString }), toastOptions); } await refreshOverview(); } diff --git a/frontend/src/routes/(app)/images/+page.svelte b/frontend/src/routes/(app)/images/+page.svelte index 6ad9651a7c..a68daeb7e0 100644 --- a/frontend/src/routes/(app)/images/+page.svelte +++ b/frontend/src/routes/(app)/images/+page.svelte @@ -17,6 +17,7 @@ import { CloseIcon, VolumesIcon, LocalFolderComputerIcon } from '$lib/icons'; import { createMutation, createQuery } from '@tanstack/svelte-query'; import PruneModeCard from '$lib/components/prune/prune-mode-card.svelte'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { data } = $props(); @@ -58,8 +59,8 @@ mode: imagePruneMode, ...(imagePruneMode === 'olderThan' ? { until: imagePruneUntil } : {}) }), - onSuccess: async () => { - toast.success(m.images_pruned_success()); + onSuccess: async (data) => { + toast.success(m.images_pruned_success(), activityToastOptions(extractActivityId(data))); await Promise.all([imagesQuery.refetch(), imageUsageCountsQuery.refetch()]); isConfirmPruneDialogOpen = false; }, @@ -71,8 +72,8 @@ const checkUpdatesMutation = createMutation(() => ({ mutationKey: ['images', 'check-updates', envId], mutationFn: () => imageService.checkAllImages(), - onSuccess: async () => { - toast.success(m.images_update_check_completed()); + onSuccess: async (data) => { + toast.success(m.images_update_check_completed(), activityToastOptions(extractActivityId(data))); await imagesQuery.refetch(); }, onError: () => { diff --git a/frontend/src/routes/(app)/images/[imageId]/+page.svelte b/frontend/src/routes/(app)/images/[imageId]/+page.svelte index c4888bc75d..0d6ecdc832 100644 --- a/frontend/src/routes/(app)/images/[imageId]/+page.svelte +++ b/frontend/src/routes/(app)/images/[imageId]/+page.svelte @@ -13,6 +13,7 @@ import { m } from '$lib/paraglide/messages'; import { imageService } from '$lib/services/image-service.js'; import { vulnerabilityService } from '$lib/services/vulnerability-service.js'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; import { startVulnerabilityScanPolling, stabilizeFailedVulnerabilitySummary, @@ -66,10 +67,11 @@ vulnerabilityScan = result; lastScanRequestedAt = result.scanTime || new Date().toISOString(); if (result.status === 'completed') { - toast.success(m.vuln_scan_completed()); + toast.success(m.vuln_scan_completed(), activityToastOptions(result.activityId)); } else if (result.status === 'failed') { - toast.error(result.error || m.vuln_scan_failed()); + toast.error(result.error || m.vuln_scan_failed(), activityToastOptions(result.activityId)); } else { + toast.info(m.vuln_scan_started(), activityToastOptions(result.activityId)); beginScanPolling(true); } } catch (error) { @@ -145,10 +147,11 @@ } as VulnerabilityScanResult; } if (showToast) { + const toastOptions = activityToastOptions(extractActivityId(resolvedSummary)); if (resolvedSummary.status === 'completed') { - toast.success(m.vuln_scan_completed()); + toast.success(m.vuln_scan_completed(), toastOptions); } else { - toast.error(resolvedSummary.error || m.vuln_scan_failed()); + toast.error(resolvedSummary.error || m.vuln_scan_failed(), toastOptions); } } }, @@ -215,8 +218,8 @@ result: await tryCatch(imageService.deleteImage(id, { force })), message: m.images_remove_failed(), setLoadingState: (value) => (isLoading.removing = value), - onSuccess: async () => { - toast.success(m.images_remove_success()); + onSuccess: async (data) => { + toast.success(m.images_remove_success(), activityToastOptions(extractActivityId(data))); goto('/images'); } }); diff --git a/frontend/src/routes/(app)/images/image-table.svelte b/frontend/src/routes/(app)/images/image-table.svelte index 6ac316bc9c..2be59e923c 100644 --- a/frontend/src/routes/(app)/images/image-table.svelte +++ b/frontend/src/routes/(app)/images/image-table.svelte @@ -24,6 +24,7 @@ import { m } from '$lib/paraglide/messages'; import { imageService } from '$lib/services/image-service'; import { vulnerabilityService } from '$lib/services/vulnerability-service'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; import { isLikelyStaleFailedSummary, isVulnerabilityScanInProgress } from '$lib/utils/vulnerability-scan.util'; import { @@ -150,8 +151,8 @@ result, message: m.images_remove_failed(), setLoadingState: () => {}, - onSuccess: async () => { - toast.success(m.images_remove_success()); + onSuccess: async (data) => { + toast.success(m.images_remove_success(), activityToastOptions(extractActivityId(data))); await refreshImages(); } }); @@ -174,8 +175,8 @@ result, message: m.images_pull_failed(), setLoadingState: () => {}, - onSuccess: async () => { - toast.success(m.images_pull_success({ repoTag })); + onSuccess: async (data) => { + toast.success(m.images_pull_success({ repoTag }), activityToastOptions(extractActivityId(data))); await refreshImages(); } }); @@ -197,6 +198,7 @@ imageId: data.imageId, scanTime: data.scanTime, status: data.status, + activityId: data.activityId, scanPhase: data.scanPhase, summary: data.summary, error: data.error @@ -204,14 +206,15 @@ await handleVulnerabilityScanChanged(imageId, summary); if (data.status === 'completed') { - toast.success(m.vuln_scan_completed()); + toast.success(m.vuln_scan_completed(), activityToastOptions(data.activityId)); return; } if (data.status === 'failed') { - toast.error(data.error || m.vuln_scan_failed()); + toast.error(data.error || m.vuln_scan_failed(), activityToastOptions(data.activityId)); return; } + toast.info(m.vuln_scan_started(), activityToastOptions(data.activityId)); startBatchScanPolling(); } }); @@ -430,7 +433,7 @@ {#snippet TagCell({ item }: { item: ImageSummaryDto })} {#if item.repoTags && item.repoTags.length > 0 && item.repoTags[0] !== ':'}
- {#each item.repoTags.slice(0, 2) as repoTag} + {#each item.repoTags.slice(0, 2) as repoTag, tagIndex (`${repoTag}-${tagIndex}`)} {@const tag = repoTag.split(':').pop() || repoTag} {tag} {/each} @@ -468,7 +471,7 @@ {@const visibleUsage = hasOverflow ? item.usedBy.slice(0, maxVisible) : item.usedBy} {@const overflowUsage = hasOverflow ? item.usedBy.slice(maxVisible) : []}
- {#each visibleUsage as usage} + {#each visibleUsage as usage, usageIndex (`${usage.type}-${usage.id ?? usage.name}-${usageIndex}`)} {#if usage.type === 'project'} {#if usage.id} @@ -522,7 +525,7 @@
- {#each overflowUsage as usage} + {#each overflowUsage as usage, usageIndex (`${usage.type}-${usage.id ?? usage.name}-${usageIndex}`)} {#if usage.type === 'project'} {#if usage.id} diff --git a/frontend/src/routes/(app)/networks/+page.svelte b/frontend/src/routes/(app)/networks/+page.svelte index 9df864405f..0c11308eda 100644 --- a/frontend/src/routes/(app)/networks/+page.svelte +++ b/frontend/src/routes/(app)/networks/+page.svelte @@ -13,6 +13,7 @@ import { untrack } from 'svelte'; import { ResourcePageLayout, type ActionButton, type StatCardConfig } from '$lib/layouts/index.js'; import { createMutation, createQuery } from '@tanstack/svelte-query'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { data } = $props(); @@ -33,8 +34,11 @@ mutationKey: ['networks', 'create', envId], mutationFn: ({ name, options }: { name: string; options: NetworkCreateOptions }) => networkService.createNetwork(name, options), - onSuccess: async (_data, variables) => { - toast.success(m.common_create_success({ resource: `${m.resource_network()} "${variables.name}"` })); + onSuccess: async (data, variables) => { + toast.success( + m.common_create_success({ resource: `${m.resource_network()} "${variables.name}"` }), + activityToastOptions(extractActivityId(data)) + ); await networksQuery.refetch(); isCreateDialogOpen = false; }, diff --git a/frontend/src/routes/(app)/networks/[networkId]/+page.svelte b/frontend/src/routes/(app)/networks/[networkId]/+page.svelte index 9ec7a65765..7f44fcb9e6 100644 --- a/frontend/src/routes/(app)/networks/[networkId]/+page.svelte +++ b/frontend/src/routes/(app)/networks/[networkId]/+page.svelte @@ -28,6 +28,7 @@ import { m } from '$lib/paraglide/messages'; import { networkService } from '$lib/services/network-service'; import { ResourceDetailLayout, type DetailAction } from '$lib/layouts'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { data }: { data: PageData } = $props(); let errorMessage = $state(''); @@ -88,8 +89,11 @@ result: await tryCatch(networkService.deleteNetwork(network.id)), message: m.networks_remove_failed({ name: network?.name ?? shortId }), setLoadingState: (value) => (isRemoving = value), - onSuccess: async () => { - toast.success(m.networks_remove_success({ name: network?.name ?? shortId })); + onSuccess: async (data) => { + toast.success( + m.networks_remove_success({ name: network?.name ?? shortId }), + activityToastOptions(extractActivityId(data)) + ); goto('/networks'); }, onError: (error) => { @@ -318,7 +322,7 @@
- {#each network.peers as peer} + {#each network.peers as peer (`${peer.Name ?? ''}:${peer.IP ?? ''}`)}
{peer.Name}
@@ -346,7 +350,7 @@
- {#each Object.entries(network.services) as [name, service]} + {#each Object.entries(network.services) as [name, service] (name)}
@@ -378,7 +382,7 @@ >{m.networks_service_ports_label()}:
- {#each service.Ports as port} + {#each service.Ports as port (port)} {port} diff --git a/frontend/src/routes/(app)/networks/network-table.svelte b/frontend/src/routes/(app)/networks/network-table.svelte index b0ba90fba2..0a610298a3 100644 --- a/frontend/src/routes/(app)/networks/network-table.svelte +++ b/frontend/src/routes/(app)/networks/network-table.svelte @@ -17,6 +17,7 @@ import { m } from '$lib/paraglide/messages'; import { networkService } from '$lib/services/network-service'; import { NetworksIcon, GlobeIcon, InspectIcon, TrashIcon, EllipsisIcon } from '$lib/icons'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; type FieldVisibility = Record; @@ -67,8 +68,11 @@ result: await tryCatch(networkService.deleteNetwork(id)), message: m.common_delete_failed({ resource: `${m.resource_network()} "${safeName}"` }), setLoadingState: (value) => (isLoading.remove = value), - onSuccess: async () => { - toast.success(m.common_delete_success({ resource: `${m.resource_network()} "${safeName}"` })); + onSuccess: async (data) => { + toast.success( + m.common_delete_success({ resource: `${m.resource_network()} "${safeName}"` }), + activityToastOptions(extractActivityId(data)) + ); await refreshNetworks(); } }); @@ -113,7 +117,8 @@ toast.success( m.common_delete_success({ resource: `${m.resource_network()} "${network.name ?? m.common_unknown()}"` - }) + }), + activityToastOptions(extractActivityId(result.data)) ); } } diff --git a/frontend/src/routes/(app)/projects/+page.svelte b/frontend/src/routes/(app)/projects/+page.svelte index 0bea9b369e..8cdbcee34a 100644 --- a/frontend/src/routes/(app)/projects/+page.svelte +++ b/frontend/src/routes/(app)/projects/+page.svelte @@ -14,6 +14,7 @@ import { untrack } from 'svelte'; import { createMutation, createQuery } from '@tanstack/svelte-query'; import { ResourcePageLayout, type ActionButton, type StatCardConfig } from '$lib/layouts/index.js'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { data } = $props(); @@ -65,7 +66,7 @@ // map to narrow the redeploy to projects that actually have updates. // This avoids hitting every project (and its registry) when nothing has // changed, which is especially expensive on instances with many projects. - await imageService.checkAllImages(); + const imageCheckResults = await imageService.checkAllImages(); const images = await imageService.getImagesForEnvironment(envId, { pagination: { page: 1, limit: 10000 } }); const projectIdsWithUpdates = new Set(); @@ -79,7 +80,7 @@ } if (projectIdsWithUpdates.size === 0) { - return { updated: 0 }; + return { updated: 0, activityId: extractActivityId(imageCheckResults) }; } const allProjects = await projectService.getProjectsForEnvironment(envId, { pagination: { page: 1, limit: 1000 } }); @@ -99,13 +100,14 @@ throw new Error(`${failed.length} project(s) failed to update (${succeeded} succeeded)`); } - return { updated: results.length }; + return { updated: results.length, activityId: extractActivityId(imageCheckResults) }; }, onSuccess: async (result) => { + const toastOptions = activityToastOptions(result.activityId); if (result && result.updated === 0) { - toast.success(m.image_update_up_to_date_title()); + toast.success(m.image_update_up_to_date_title(), toastOptions); } else { - toast.success(m.compose_update_success()); + toast.success(m.compose_update_success(), toastOptions); } await Promise.all([projectsQuery.refetch(), projectStatusCountsQuery.refetch()]); }, diff --git a/frontend/src/routes/(app)/projects/[projectId]/+page.svelte b/frontend/src/routes/(app)/projects/[projectId]/+page.svelte index fe95ffc785..33f04125f0 100644 --- a/frontend/src/routes/(app)/projects/[projectId]/+page.svelte +++ b/frontend/src/routes/(app)/projects/[projectId]/+page.svelte @@ -40,6 +40,7 @@ import IconImage from '$lib/components/icon-image.svelte'; import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'; import ProjectUpdateItem from '$lib/components/project-update-item.svelte'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { data } = $props(); let projectId = $derived(data.projectId); @@ -328,10 +329,11 @@ .find((result) => !!result?.error?.trim()) ?.error?.trim(); const hasErrors = !!firstError; + const toastOptions = activityToastOptions(extractActivityId(results)); if (hasErrors) { - toast.error(firstError || m.containers_check_updates_failed()); + toast.error(firstError || m.containers_check_updates_failed(), toastOptions); } else { - toast.success(m.images_update_check_completed()); + toast.success(m.images_update_check_completed(), toastOptions); } await Promise.all([ refreshProjectDetails(), @@ -421,7 +423,7 @@ }; rebaseEditorDraft(savedProject); await syncProjectQueries(savedProject); - toast.success(m.common_update_success({ resource: m.project() })); + toast.success(m.common_update_success({ resource: m.project() }), activityToastOptions(extractActivityId(savedProject))); } }); } diff --git a/frontend/src/routes/(app)/projects/components/ProjectContainersTable.svelte b/frontend/src/routes/(app)/projects/components/ProjectContainersTable.svelte index 1a2723d063..6c6226a3fe 100644 --- a/frontend/src/routes/(app)/projects/components/ProjectContainersTable.svelte +++ b/frontend/src/routes/(app)/projects/components/ProjectContainersTable.svelte @@ -21,6 +21,7 @@ import * as ArcaneTooltip from '$lib/components/arcane-tooltip'; import IconImage from '$lib/components/icon-image.svelte'; import { getArcaneIconUrlFromLabels } from '$lib/utils/arcane-labels'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; import { StartIcon, StopIcon, @@ -123,8 +124,8 @@ setLoadingState: (value) => { actionStatus[id] = value ? statusMap[action] : ''; }, - async onSuccess() { - toast.success(messageMap[action].success); + async onSuccess(data) { + toast.success(messageMap[action].success, activityToastOptions(extractActivityId(data))); await onRefresh?.(); } }); @@ -157,8 +158,8 @@ setLoadingState: (value) => { actionStatus[id] = value ? 'removing' : ''; }, - async onSuccess() { - toast.success(m.containers_remove_success()); + async onSuccess(data) { + toast.success(m.containers_remove_success(), activityToastOptions(extractActivityId(data))); await onRefresh?.(); } }); diff --git a/frontend/src/routes/(app)/projects/new/+page.svelte b/frontend/src/routes/(app)/projects/new/+page.svelte index 00a3bf4db8..1c99793b4e 100644 --- a/frontend/src/routes/(app)/projects/new/+page.svelte +++ b/frontend/src/routes/(app)/projects/new/+page.svelte @@ -26,6 +26,7 @@ import EditableName from '../components/EditableName.svelte'; import { environmentStore } from '$lib/stores/environment.store.svelte'; import { ComposeEditorSplit } from '$lib/components/compose'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { data } = $props(); @@ -91,7 +92,10 @@ message: m.common_create_failed({ resource: `${m.resource_project()} "${name}"` }), setLoadingState: (value) => (saving = value), onSuccess: async (project) => { - toast.success(m.common_create_success({ resource: `${m.resource_project()} "${name}"` })); + toast.success( + m.common_create_success({ resource: `${m.resource_project()} "${name}"` }), + activityToastOptions(extractActivityId(project)) + ); goto(`/projects/${project.id}`, { invalidateAll: true }); } }); diff --git a/frontend/src/routes/(app)/projects/projects-table.actions.ts b/frontend/src/routes/(app)/projects/projects-table.actions.ts index c5d9649b89..01023ea28d 100644 --- a/frontend/src/routes/(app)/projects/projects-table.actions.ts +++ b/frontend/src/routes/(app)/projects/projects-table.actions.ts @@ -5,6 +5,7 @@ import { gitOpsSyncService } from '$lib/services/gitops-sync-service'; import { projectService } from '$lib/services/project-service'; import type { SearchPaginationSortRequest } from '$lib/types/pagination.type'; import { handleApiResultWithCallbacks } from '$lib/utils/api.util'; +import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; import { tryCatch } from '$lib/utils/try-catch'; import { toast } from 'svelte-sonner'; import type { ActionStatus } from './projects-table.helpers'; @@ -123,8 +124,8 @@ export function createProjectActions({ setLoadingState: (value) => { actionStatus[id] = value ? config.status : ''; }, - onSuccess: async () => { - toast.success(config.success()); + onSuccess: async (data) => { + toast.success(config.success(), activityToastOptions(extractActivityId(data))); await refreshProjects(); } }); @@ -164,8 +165,8 @@ export function createProjectActions({ setLoadingState: (value) => { actionStatus[id] = value ? 'destroying' : ''; }, - onSuccess: async () => { - toast.success(m.compose_destroy_success()); + onSuccess: async (data) => { + toast.success(m.compose_destroy_success(), activityToastOptions(extractActivityId(data))); await refreshProjects(); } }); diff --git a/frontend/src/routes/(app)/settings/+page.svelte b/frontend/src/routes/(app)/settings/+page.svelte index 6ec8015dd7..a0eac0a6b9 100644 --- a/frontend/src/routes/(app)/settings/+page.svelte +++ b/frontend/src/routes/(app)/settings/+page.svelte @@ -15,7 +15,8 @@ CloseIcon, JobsIcon, CodeIcon, - GlobeIcon + GlobeIcon, + ActivityIcon } from '$lib/icons'; import { ArcaneButton } from '$lib/components/arcane-button/index.js'; import { Card } from '$lib/components/ui/card'; @@ -47,7 +48,8 @@ apikey: ApiKeyIcon, jobs: JobsIcon, code: CodeIcon, - globe: GlobeIcon + globe: GlobeIcon, + activity: ActivityIcon }; onMount(async () => { diff --git a/frontend/src/routes/(app)/settings/activity/+page.svelte b/frontend/src/routes/(app)/settings/activity/+page.svelte new file mode 100644 index 0000000000..0ade7f0726 --- /dev/null +++ b/frontend/src/routes/(app)/settings/activity/+page.svelte @@ -0,0 +1,92 @@ + + + + {#snippet mainContent()} +
+
+

{m.activity_history_section_title()}

+
+
+
+
+ +

{m.activity_history_retention_days_description()}

+
+
+ +
+
+ +
+
+
+ +

{m.activity_history_max_entries_description()}

+
+
+ +
+
+
+
+
+
+
+ {/snippet} +
diff --git a/frontend/src/routes/(app)/settings/activity/+page.ts b/frontend/src/routes/(app)/settings/activity/+page.ts new file mode 100644 index 0000000000..41fcb0fd60 --- /dev/null +++ b/frontend/src/routes/(app)/settings/activity/+page.ts @@ -0,0 +1,20 @@ +import type { PageLoad } from './$types'; +import { settingsService } from '$lib/services/settings-service'; +import { environmentStore } from '$lib/stores/environment.store.svelte'; +import { queryKeys } from '$lib/query/query-keys'; + +export const load: PageLoad = async ({ parent }) => { + const { queryClient } = await parent(); + const envId = await environmentStore.getCurrentEnvironmentId(); + + try { + const settings = await queryClient.fetchQuery({ + queryKey: queryKeys.settings.byEnvironment(envId), + queryFn: () => settingsService.getSettingsForEnvironmentMerged(envId) + }); + return { settings }; + } catch (error) { + console.error('Failed to load activity settings:', error); + throw error; + } +}; diff --git a/frontend/src/routes/(app)/swarm/nodes/swarm-node-label-dialog.svelte b/frontend/src/routes/(app)/swarm/nodes/swarm-node-label-dialog.svelte index 1888471e50..8100724952 100644 --- a/frontend/src/routes/(app)/swarm/nodes/swarm-node-label-dialog.svelte +++ b/frontend/src/routes/(app)/swarm/nodes/swarm-node-label-dialog.svelte @@ -66,7 +66,7 @@
- + {#snippet footer()} diff --git a/frontend/src/routes/(app)/volumes/+page.svelte b/frontend/src/routes/(app)/volumes/+page.svelte index 9c88c0e135..83735e7663 100644 --- a/frontend/src/routes/(app)/volumes/+page.svelte +++ b/frontend/src/routes/(app)/volumes/+page.svelte @@ -10,9 +10,11 @@ import { queryKeys } from '$lib/query/query-keys'; import { untrack } from 'svelte'; import { ResourcePageLayout, type ActionButton, type StatCardConfig } from '$lib/layouts/index.js'; - import { createMutation, createQuery } from '@tanstack/svelte-query'; + import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { data } = $props(); + const queryClient = useQueryClient(); let volumes = $state(untrack(() => data.volumes)); let requestOptions = $state(untrack(() => data.volumeRequestOptions)); @@ -30,10 +32,13 @@ const createVolumeMutation = createMutation(() => ({ mutationKey: ['volumes', 'create', envId], mutationFn: (options: VolumeCreateRequest) => volumeService.createVolume(options), - onSuccess: async (_data, options) => { + onSuccess: async (data, options) => { const name = options.name?.trim() || m.common_unknown(); - toast.success(m.common_create_success({ resource: `${m.resource_volume()} "${name}"` })); - await volumesQuery.refetch(); + toast.success( + m.common_create_success({ resource: `${m.resource_volume()} "${name}"` }), + activityToastOptions(extractActivityId(data)) + ); + await loadVolumes(); isCreateDialogOpen = false; }, onError: (_error, options) => { @@ -52,8 +57,16 @@ await createVolumeMutation.mutateAsync(options); } + async function loadVolumes(options = requestOptions) { + requestOptions = options; + volumes = await queryClient.fetchQuery({ + queryKey: queryKeys.volumes.table(envId, options), + queryFn: () => volumeService.getVolumesForEnvironment(envId, options) + }); + } + async function refresh() { - await volumesQuery.refetch(); + await loadVolumes(); } const isRefreshing = $derived(volumesQuery.isFetching && !volumesQuery.isPending); @@ -96,15 +109,7 @@ {#snippet mainContent()} - { - requestOptions = options; - await volumesQuery.refetch(); - }} - /> + {/snippet} {#snippet additionalContent()} diff --git a/frontend/src/routes/(app)/volumes/[volumeName]/+page.svelte b/frontend/src/routes/(app)/volumes/[volumeName]/+page.svelte index f796626049..5072acc670 100644 --- a/frontend/src/routes/(app)/volumes/[volumeName]/+page.svelte +++ b/frontend/src/routes/(app)/volumes/[volumeName]/+page.svelte @@ -18,6 +18,7 @@ import { VolumeBrowser } from '$lib/components/file-browser'; import BackupList from '../components/volume-backup-table.svelte'; import settingsStore from '$lib/stores/config-store'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { data } = $props(); let volume = $state(untrack(() => data.volume)); @@ -55,8 +56,8 @@ result: await tryCatch(volumeService.deleteVolume(safeName)), message: m.volumes_remove_failed({ name: safeName }), setLoadingState: (value) => (isLoading.remove = value), - onSuccess: async () => { - toast.success(m.volumes_remove_success({ name: safeName })); + onSuccess: async (data) => { + toast.success(m.volumes_remove_success({ name: safeName }), activityToastOptions(extractActivityId(data))); goto('/volumes'); } }); diff --git a/frontend/src/routes/(app)/volumes/components/volume-backup-table.svelte b/frontend/src/routes/(app)/volumes/components/volume-backup-table.svelte index 0a1b36651e..7b203018cb 100644 --- a/frontend/src/routes/(app)/volumes/components/volume-backup-table.svelte +++ b/frontend/src/routes/(app)/volumes/components/volume-backup-table.svelte @@ -31,6 +31,7 @@ import { ScrollArea } from '$lib/components/ui/scroll-area'; import * as Checkbox from '$lib/components/ui/checkbox'; import * as Alert from '$lib/components/ui/alert'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { volumeName }: { volumeName: string } = $props(); @@ -83,8 +84,8 @@ async function handleCreate() { creating = true; try { - await volumeBackupService.createBackup(volumeName); - toast.success(m.common_success()); + const result = await volumeBackupService.createBackup(volumeName); + toast.success(m.common_success(), activityToastOptions(extractActivityId(result))); await loadData(requestOptions); } catch (e: any) { toast.error(e.message || m.common_failed()); @@ -102,8 +103,8 @@ destructive: true, action: async () => { try { - await volumeBackupService.deleteBackup(backup.id); - toast.success(m.common_delete_success({ resource: 'Backup' })); + const result = await volumeBackupService.deleteBackup(backup.id); + toast.success(m.common_delete_success({ resource: 'Backup' }), activityToastOptions(extractActivityId(result))); await loadData(requestOptions); } catch (e: any) { toast.error(e.message || m.common_delete_failed({ resource: 'Backup' })); @@ -171,8 +172,8 @@ destructive: !!usageWarning, action: async () => { try { - await volumeBackupService.restoreBackup(volumeName, backup.id); - toast.success(m.volumes_backup_restore_success()); + const result = await volumeBackupService.restoreBackup(volumeName, backup.id); + toast.success(m.volumes_backup_restore_success(), activityToastOptions(extractActivityId(result))); await loadData(requestOptions); } catch (e: any) { toast.error(e.message || m.common_failed()); @@ -188,8 +189,11 @@ restoringFiles = true; try { - await volumeBackupService.restoreBackupFiles(volumeName, restoreTarget.id, selectedPaths); - toast.success(m.volumes_backup_restore_files_success({ count: selectedPaths.length })); + const result = await volumeBackupService.restoreBackupFiles(volumeName, restoreTarget.id, selectedPaths); + toast.success( + m.volumes_backup_restore_files_success({ count: selectedPaths.length }), + activityToastOptions(extractActivityId(result)) + ); showRestoreFiles = false; } catch (e: any) { toast.error(e.message || m.common_failed()); @@ -370,7 +374,7 @@
No files found in this backup.
{:else}
- {#each filteredBackupFiles as filePath} + {#each filteredBackupFiles as filePath (filePath)}
(isLoading.removing = value), - onSuccess: async () => { - toast.success(m.common_remove_success({ resource: `${m.resource_volume()} "${safeName}"` })); + onSuccess: async (data) => { + toast.success( + m.common_remove_success({ resource: `${m.resource_volume()} "${safeName}"` }), + activityToastOptions(extractActivityId(data)) + ); await refreshVolumes(); } }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34ceeee763..cd185d24f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,8 @@ overrides: uuid: '>=11.1.1' fast-uri: '>=3.1.2' +packageExtensionsChecksum: sha256-Yj0m2AD9TZO/cWup5bCg2l8l7VHtLu5doy9Oybhgv+w= + importers: .: @@ -5549,6 +5551,7 @@ snapshots: svelte-codemirror-editor@2.1.0(codemirror@6.0.2)(svelte@5.55.5): dependencies: + '@codemirror/search': 6.7.0 codemirror: 6.0.2 svelte: 5.55.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index be99f0b0da..fbfa32dfd4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,12 @@ packages: - frontend - tests - email-templates + +packageExtensions: + svelte-codemirror-editor@2.1.0: + dependencies: + "@codemirror/search": ^6.7.0 + overrides: devalue: ">=5.6.4" valibot: ^1.2.0 diff --git a/tests/setup/gitops.setup.ts b/tests/setup/gitops.setup.ts index 575489199f..ad881eb637 100644 --- a/tests/setup/gitops.setup.ts +++ b/tests/setup/gitops.setup.ts @@ -18,7 +18,7 @@ setup('create gitops sync in arcane', async ({ page }) => { // Step 1: Create a Git Repository in Arcane pointing to GitHub await page.goto('/customize/git-repositories'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // Check if test repo already exists const existingRepo = page.getByRole('cell', { name: GITOPS_REPO_NAME }); @@ -68,7 +68,7 @@ setup('create gitops sync in arcane', async ({ page }) => { // Step 2: Create GitOps Sync await page.goto('/environments/0/gitops'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // Check if sync already exists const existingSync = page.getByRole('cell', { name: GITOPS_SYNC_NAME }); @@ -151,7 +151,7 @@ setup('create gitops sync in arcane', async ({ page }) => { // Step 3: Trigger initial sync to create the managed project console.log('Triggering initial sync...'); await page.reload(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // Find the sync row and trigger sync const syncRow = page.locator('tr').filter({ hasText: GITOPS_SYNC_NAME }); @@ -178,7 +178,7 @@ setup('create gitops sync in arcane', async ({ page }) => { // Verify project was created await page.goto('/projects'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.waitForTimeout(2000); console.log('GitOps test setup complete!'); diff --git a/tests/setup/global-setup.ts b/tests/setup/global-setup.ts index 6cad033e1b..bb0d436888 100644 --- a/tests/setup/global-setup.ts +++ b/tests/setup/global-setup.ts @@ -43,6 +43,14 @@ async function globalSetup() { } if (attempts === maxAttempts) { + try { + console.error('Server did not become ready. Docker compose status:'); + execSync(`docker compose -f ${composeFile} ps`, { stdio: 'inherit' }); + console.error('Recent Docker compose logs:'); + execSync(`docker compose -f ${composeFile} logs --tail=200`, { stdio: 'inherit' }); + } catch (error) { + console.error('Failed to collect Docker compose diagnostics:', error); + } throw new Error(`Server at ${baseURL} did not become ready in time.`); } diff --git a/tests/setup/project.data.ts b/tests/setup/project.data.ts index 7a91e4860d..ee1cfa6538 100644 --- a/tests/setup/project.data.ts +++ b/tests/setup/project.data.ts @@ -7,18 +7,16 @@ export const TEST_COMPOSE_YAML = `configs: services: redis: - image: redis:latest + image: ghcr.io/getarcaneapp/tools:latest + pull_policy: always container_name: \${CONTAINER_NAME} + entrypoint: ["sleep"] + command: ["infinity"] configs: - source: some_content target: /etc/some_content.txt - command: /bin/sh -c 'cat /etc/some_content.txt && redis-server' - ports: - - "8081:81" - - "6379:6379" - - "6378:6378" volumes: - - redis_data:/data + - redis_data:/config volumes: redis_data: diff --git a/tests/spec/activity-center.spec.ts b/tests/spec/activity-center.spec.ts new file mode 100644 index 0000000000..5f9c632bbc --- /dev/null +++ b/tests/spec/activity-center.spec.ts @@ -0,0 +1,167 @@ +import { test, expect, type Page } from '@playwright/test'; + +function extractActivityId(value: unknown): string | undefined { + if (!value || typeof value !== 'object') return undefined; + + const activityId = (value as { activityId?: unknown }).activityId; + if (typeof activityId === 'string' && activityId.trim()) return activityId; + + if (Array.isArray(value)) { + for (const item of value) { + const nested = extractActivityId(item); + if (nested) return nested; + } + return undefined; + } + + for (const item of Object.values(value)) { + const nested = extractActivityId(item); + if (nested) return nested; + } + + return undefined; +} + +function extractCreatedNetworkId(value: unknown): string | undefined { + if (!value || typeof value !== 'object') return undefined; + const data = (value as { data?: { id?: unknown } }).data; + return typeof data?.id === 'string' ? data.id : undefined; +} + +async function createNetworkViaUI(page: Page, networkName: string) { + await page.goto('/networks'); + await page.waitForLoadState('load'); + await expect(page.getByRole('heading', { level: 1, name: 'Networks' })).toBeVisible(); + + await page.getByRole('button', { name: 'Create Network' }).first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await dialog.locator('#network-name').fill(networkName); + + const createRequest = page.waitForResponse( + (response) => { + const request = response.request(); + return ( + request.method() === 'POST' && + /\/api\/environments\/[^/]+\/networks$/.test(new URL(response.url()).pathname) + ); + }, + { timeout: 15000 } + ); + + await dialog.getByRole('button', { name: 'Create Network' }).click(); + const createResponse = await createRequest; + const body = await createResponse.json(); + if (!createResponse.ok()) { + throw new Error(`Failed to create network ${networkName}: ${createResponse.status()}`); + } + + return { + activityId: extractActivityId(body), + networkId: extractCreatedNetworkId(body) + }; +} + +async function removeNetworkViaApi(page: Page, networkId: string | undefined) { + if (!networkId) return; + await page.evaluate(async (id) => { + await fetch(`/api/environments/0/networks/${encodeURIComponent(id)}`, { method: 'DELETE' }); + }, networkId); +} + +async function openActivityCenter(page: Page) { + await page.getByRole('button', { name: 'Open activity center' }).first().click(); + const activityCenter = page.getByRole('dialog', { name: 'Activity Center' }); + await expect(activityCenter).toBeVisible(); + return activityCenter; +} + +test.describe('Activity Center', () => { + test('shows completed activity details for UI-triggered work', async ({ page }) => { + const networkName = `e2e-activity-network-${Date.now()}`; + let networkId: string | undefined; + + try { + const created = await createNetworkViaUI(page, networkName); + networkId = created.networkId; + expect(created.activityId).toBeTruthy(); + + const activityCenter = await openActivityCenter(page); + await expect(activityCenter.getByRole('button', { name: 'Running' })).toBeVisible(); + await expect(activityCenter.getByRole('button', { name: 'Failed' })).toBeVisible(); + await activityCenter.getByRole('button', { name: 'Completed' }).click(); + + const activityItem = activityCenter + .locator('button[aria-label="Activity Center"]') + .filter({ hasText: networkName }) + .first(); + await expect(activityItem).toBeVisible(); + await expect(activityItem).toContainText('Resource Action'); + await expect(activityItem).toContainText('Success'); + await expect(activityItem).toContainText('Local'); + await expect(activityItem).toContainText(/Started by/i); + await expect(activityCenter.getByRole('button', { name: 'Clear history' })).toBeVisible(); + + await activityItem.click(); + await expect(activityCenter.getByText('Output', { exact: true })).toBeVisible(); + await expect(activityCenter.getByText('Creating network').first()).toBeVisible(); + await expect(activityCenter.getByText('Network created successfully').first()).toBeVisible(); + await expect(activityCenter.getByText('Source environment')).toBeVisible(); + await expect(activityCenter.getByText('Started by', { exact: true })).toBeVisible(); + } finally { + await removeNetworkViaApi(page, networkId); + } + }); + + test('admin can clear completed activity history', async ({ page }) => { + const networkName = `e2e-activity-wipe-${Date.now()}`; + let networkId: string | undefined; + + try { + const created = await createNetworkViaUI(page, networkName); + networkId = created.networkId; + expect(created.activityId).toBeTruthy(); + + const activityCenter = await openActivityCenter(page); + await activityCenter.getByRole('button', { name: 'Completed' }).click(); + await expect( + activityCenter + .locator('button[aria-label="Activity Center"]') + .filter({ hasText: networkName }) + .first() + ).toBeVisible(); + + await activityCenter.getByRole('button', { name: 'Clear history' }).click(); + const confirmDialog = page.getByRole('dialog', { name: 'Clear activity history?' }); + await expect(confirmDialog).toBeVisible(); + await confirmDialog.getByRole('button', { name: 'Clear History' }).click(); + + await expect( + activityCenter + .locator('button[aria-label="Activity Center"]') + .filter({ hasText: networkName }) + .first() + ).toHaveCount(0); + } finally { + await removeNetworkViaApi(page, networkId); + } + }); + + test('non-admin users do not see clear history action', async ({ page }) => { + await page.route(/\/api\/auth\/me$/, async (route) => { + const response = await route.fetch(); + const body = await response.json(); + const user = body.user ?? body.data; + if (user) { + user.roles = ['user']; + } + await route.fulfill({ response, json: body }); + }); + + await page.goto('/dashboard'); + await page.waitForLoadState('load'); + + const activityCenter = await openActivityCenter(page); + await expect(activityCenter.getByRole('button', { name: 'Clear history' })).toHaveCount(0); + }); +}); diff --git a/tests/spec/api-keys.spec.ts b/tests/spec/api-keys.spec.ts index a1123c79fd..ab6e28baa1 100644 --- a/tests/spec/api-keys.spec.ts +++ b/tests/spec/api-keys.spec.ts @@ -5,7 +5,7 @@ const API_KEYS_ROUTE = '/settings/api-keys'; async function navigateToApiKeys(page: Page) { await page.goto(API_KEYS_ROUTE); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); } test.describe('API Keys Page', () => { diff --git a/tests/spec/builds.spec.ts b/tests/spec/builds.spec.ts index ade00d7411..5a3c6290ae 100644 --- a/tests/spec/builds.spec.ts +++ b/tests/spec/builds.spec.ts @@ -20,7 +20,7 @@ const FIELD_LABELS = { async function navigateToBuildWorkspace(page: Page) { await page.goto(ROUTES.page); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.getByRole('heading', { level: 1, name: /Build Workspace/i })).toBeVisible(); } diff --git a/tests/spec/container-grouped-pagination.spec.ts b/tests/spec/container-grouped-pagination.spec.ts index 429b4b110a..f7763b6011 100644 --- a/tests/spec/container-grouped-pagination.spec.ts +++ b/tests/spec/container-grouped-pagination.spec.ts @@ -153,7 +153,7 @@ test('grouped containers do not split the same project across pages', async ({ p }); await page.goto('/containers'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.setViewportSize({ width: 1440, height: 900 }); diff --git a/tests/spec/containers.spec.ts b/tests/spec/containers.spec.ts index 62981049c1..5bbf80b325 100644 --- a/tests/spec/containers.spec.ts +++ b/tests/spec/containers.spec.ts @@ -6,7 +6,7 @@ const CONTAINERS_ROUTE = '/containers'; async function navigateToContainers(page: Page) { await page.goto(CONTAINERS_ROUTE); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); } let containersData: Paginated = { data: [], pagination: { totalItems: 0 } }; @@ -66,7 +66,7 @@ test.describe('Containers Page', () => { test.skip(!running, 'No running container available'); await page.goto(`/containers/${running!.id}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.getByRole('tab', { name: 'Logs' }).click(); @@ -87,7 +87,7 @@ test.describe('Containers Page', () => { test.skip(!stopped, 'No stopped container available'); await page.goto(`/containers/${stopped!.id}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.getByRole('tab', { name: 'Logs' }).click(); diff --git a/tests/spec/converter.spec.ts b/tests/spec/converter.spec.ts index 0fd52b49d3..5177f7990b 100644 --- a/tests/spec/converter.spec.ts +++ b/tests/spec/converter.spec.ts @@ -24,18 +24,19 @@ const SELECTORS = { async function openConvertFromDockerRun(page: Page) { // Wait for the page to be fully loaded - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); - // Split-button chevron trigger on /projects/new. (Trigger is rendered via child snippet, - // so data-slot="dropdown-menu-trigger" may not be present on the final button element.) - let dropdownTrigger = page.locator('button.rounded-l-none.px-2').first(); + const buttonGroup = page + .getByRole('group') + .filter({ has: page.getByRole('button', { name: 'Create Project' }) }) + .first(); + let dropdownTrigger = buttonGroup.getByRole('button').last(); if ((await dropdownTrigger.count()) === 0) { - // Fallback for markup changes: use the old icon-only heuristic - dropdownTrigger = page.locator('button:has(svg)').filter({ hasText: '' }).last(); + dropdownTrigger = page.locator('button.rounded-l-none.px-2').first(); } await expect(dropdownTrigger).toBeVisible(); - await dropdownTrigger.click(); + await dropdownTrigger.click({ force: true }); // Prefer text match, but keep positional fallback for non-English locales. const menuItems = page.locator('[data-slot="dropdown-menu-item"], [role="menuitem"]'); @@ -66,7 +67,7 @@ async function setupMockConvert(page: Page, payload: ConvertResponse) { test.describe('Docker Run to Compose Converter', () => { test.beforeEach(async ({ page }) => { await page.goto(ROUTES.page); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); }); test('should convert simple docker run command', async ({ page }) => { diff --git a/tests/spec/dashboard-system-stats.spec.ts b/tests/spec/dashboard-system-stats.spec.ts index c06bb70e6e..78a20e97bc 100644 --- a/tests/spec/dashboard-system-stats.spec.ts +++ b/tests/spec/dashboard-system-stats.spec.ts @@ -124,7 +124,7 @@ test.describe('Dashboard system stats websocket', () => { await mockDashboardStatsWebSocket(page); await page.goto(defaultDashboardPath); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.getByRole('tab', { name: 'All' })).toHaveAttribute('data-state', 'active'); await expect(page.getByRole('heading', { name: 'Overview' })).toBeVisible(); @@ -144,9 +144,13 @@ test.describe('Dashboard system stats websocket', () => { }) => { await mockDashboardStatsWebSocket(page); const requestPaths = collectDashboardRequestPaths(page); + const overviewRequest = page.waitForResponse((response) => { + return new URL(response.url()).pathname === '/api/dashboard/environments'; + }); await page.goto(defaultDashboardPath); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); + await overviewRequest; expect(requestPaths).toContain('/api/dashboard/environments'); @@ -173,10 +177,10 @@ test.describe('Dashboard system stats websocket', () => { const requestPaths = collectDashboardRequestPaths(page); await page.goto(defaultDashboardPath); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.getByRole('tab', { name: 'Current' }).click(); await expect(page).toHaveURL(currentDashboardPath); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); expect( countMatchingRequests(requestPaths, /\/api\/environments\/[^/]+\/system\/docker\/info$/) diff --git a/tests/spec/edge-agent.spec.ts b/tests/spec/edge-agent.spec.ts index 06f2974f98..a3de3f1e39 100644 --- a/tests/spec/edge-agent.spec.ts +++ b/tests/spec/edge-agent.spec.ts @@ -6,7 +6,7 @@ const ROUTES = { async function openNewEnvironmentSheet(page: Page) { await page.goto(ROUTES.environments); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const addButton = page.getByRole('button', { name: 'Add Environment', exact: true }); await expect(addButton).toBeVisible(); diff --git a/tests/spec/environment-settings.spec.ts b/tests/spec/environment-settings.spec.ts index 94deaafe1c..19e2708c2a 100644 --- a/tests/spec/environment-settings.spec.ts +++ b/tests/spec/environment-settings.spec.ts @@ -4,14 +4,14 @@ const LOCAL_ENV_ID = '0'; async function openEnvironment(page: Page, environmentId: string) { await page.goto(`/environments/${environmentId}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.locator('#env-name')).toBeVisible(); await expect(page.getByRole('button', { name: 'Save', exact: true }).first()).toBeVisible(); } async function createDirectEnvironmentViaUI(page: Page, environmentName: string) { await page.goto('/environments'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.getByRole('button', { name: 'Add Environment', exact: true }).click(); await expect(page.getByText('Create New Agent Environment')).toBeVisible(); @@ -27,7 +27,7 @@ async function createDirectEnvironmentViaUI(page: Page, environmentName: string) async function deleteEnvironmentViaUI(page: Page, environmentName: string) { await page.goto('/environments'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const envRow = page.locator('tr').filter({ has: page.getByRole('button', { name: environmentName, exact: true }) diff --git a/tests/spec/image-updates.spec.ts b/tests/spec/image-updates.spec.ts index 16d486311c..0a90d30b68 100644 --- a/tests/spec/image-updates.spec.ts +++ b/tests/spec/image-updates.spec.ts @@ -41,8 +41,20 @@ interface UpdateSummary { } async function navigateToImages(page: Page) { - await page.goto(ROUTES.page); - await page.waitForLoadState('networkidle'); + for (let attempt = 1; attempt <= 3; attempt++) { + await page.goto(ROUTES.page); + await page.waitForLoadState('load'); + + try { + await page.getByRole('heading', { name: 'Images', level: 1 }).waitFor({ + state: 'visible', + timeout: 5000 + }); + return; + } catch (error) { + if (attempt === 3) throw error; + } + } } async function fetchImagesTotal(page: Page, updatesFilter?: string): Promise { @@ -125,7 +137,7 @@ test.describe('Image Update UI - Individual Image Update Check via Hover Card', await navigateToImages(page); // Wait for the table to load - await expect(page.locator('table')).toBeVisible(); + await expect(page.locator('table')).toBeVisible({ timeout: 10000 }); // Check that image rows exist const rows = page.locator('tbody tr'); @@ -138,7 +150,7 @@ test.describe('Image Update UI - Individual Image Update Check via Hover Card', await navigateToImages(page); // Wait for images table - await expect(page.locator('table')).toBeVisible(); + await expect(page.locator('table')).toBeVisible({ timeout: 10000 }); // Find the first row's update status area (the Updates column) const firstRow = page.locator('tbody tr').first(); @@ -178,7 +190,7 @@ test.describe('Image Update UI - Individual Image Update Check via Hover Card', test.skip(!testImage, 'No suitable image found for update check'); await navigateToImages(page); - await expect(page.locator('table')).toBeVisible(); + await expect(page.locator('table')).toBeVisible({ timeout: 10000 }); // Find the row for our test image or the first row with a valid image const rows = page.locator('tbody tr'); @@ -289,7 +301,7 @@ test.describe('Image Update UI Integration', () => { await navigateToImages(page); // Wait for the table to load - await expect(page.locator('table')).toBeVisible(); + await expect(page.locator('table')).toBeVisible({ timeout: 10000 }); // Check that image rows exist const rows = page.locator('tbody tr'); @@ -306,7 +318,7 @@ test.describe('Image Update UI Integration', () => { // Navigate to image detail await page.goto(`/images/${encodeURIComponent(testImage.id)}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // The detail page should load await expect(page.locator('h1, h2, [data-testid="image-detail"]').first()).toBeVisible({ diff --git a/tests/spec/images.spec.ts b/tests/spec/images.spec.ts index 79494648cd..70c99bd253 100644 --- a/tests/spec/images.spec.ts +++ b/tests/spec/images.spec.ts @@ -8,8 +8,20 @@ const ROUTES = { }; async function navigateToImages(page: Page) { - await page.goto(ROUTES.page); - await page.waitForLoadState('networkidle'); + for (let attempt = 1; attempt <= 3; attempt++) { + await page.goto(ROUTES.page); + await page.waitForLoadState('load'); + + try { + await page.getByRole('heading', { name: 'Images', level: 1 }).waitFor({ + state: 'visible', + timeout: 5000 + }); + return; + } catch (error) { + if (attempt === 3) throw error; + } + } } async function fetchAllImagesForUsage(page: Page): Promise { @@ -63,7 +75,7 @@ test.describe('Images Page', () => { await navigateToImages(page); await expect(page.getByRole('heading', { name: 'Images', level: 1 })).toBeVisible(); - await expect(page.getByText('View and Manage your Container images').first()).toBeVisible(); + await expect(page.getByText('View and Manage your Container Images').first()).toBeVisible(); }); test('should display stats cards with correct counts and size', async ({ page }) => { @@ -88,7 +100,7 @@ test.describe('Images Page', () => { test('should display the image table when images exist', async ({ page }) => { await navigateToImages(page); - await expect(page.locator('table')).toBeVisible(); + await expect(page.locator('table')).toBeVisible({ timeout: 10000 }); await expect(page.getByRole('button', { name: 'Repository' })).toBeVisible(); }); @@ -135,7 +147,7 @@ test.describe('Images Page', () => { await firstRow.getByRole('button', { name: 'Open menu' }).click(); await page.getByRole('menuitem', { name: 'Pull' }).click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect( page.locator(`li[data-sonner-toast][data-type="success"] div[data-title]`) diff --git a/tests/spec/network.spec.ts b/tests/spec/network.spec.ts index 43610f0cad..4d529c5750 100644 --- a/tests/spec/network.spec.ts +++ b/tests/spec/network.spec.ts @@ -3,7 +3,7 @@ import { fetchNetworksCountsWithRetry } from '../utils/fetch.util'; async function navigateToNetworks(page: Page) { await page.goto('/networks'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); } test.beforeEach(async ({ page }) => { @@ -34,23 +34,14 @@ async function createNetworkViaUI(page: Page, networkName: string) { `Failed to create network ${networkName}: ${createResponse.status()} ${responseText}` ); } - - await navigateToNetworks(page); - await expect(await findNetworkRow(page, networkName, 15)).toBeVisible(); } -async function findNetworkRow(page: Page, networkName: string, maxRetries = 10) { - for (let i = 0; i < maxRetries; i++) { - const searchInput = page.getByPlaceholder(/Search/i).first(); - if (await searchInput.isVisible().catch(() => false)) { - await searchInput.fill(networkName); - } - - const row = page.locator('tbody tr', { has: page.getByText(networkName) }).first(); - if (await row.isVisible().catch(() => false)) return row; - await page.waitForTimeout(500); - await navigateToNetworks(page); +async function findNetworkRow(page: Page, networkName: string, _maxRetries = 10) { + const searchInput = page.getByPlaceholder(/Search/i).first(); + if (await searchInput.isVisible().catch(() => false)) { + await searchInput.fill(networkName); } + return page.locator('tbody tr', { has: page.getByText(networkName) }).first(); } @@ -69,6 +60,15 @@ async function removeNetworkViaUI(page: Page, networkName: string) { ).toBeVisible(); } +async function removeNetworkViaApi(page: Page, networkName: string) { + if (!networkName || page.isClosed()) return; + await page + .evaluate(async (name) => { + await fetch(`/api/environments/0/networks/${encodeURIComponent(name)}`, { method: 'DELETE' }); + }, networkName) + .catch(() => {}); +} + test.describe('Networks Page', () => { test.describe.configure({ mode: 'serial' }); @@ -98,7 +98,7 @@ test.describe('Networks Page', () => { await expect(page.getByRole('button', { name: 'Name' })).toBeVisible(); await expect(await findNetworkRow(page, networkName)).toBeVisible(); } finally { - await removeNetworkViaUI(page, networkName); + await removeNetworkViaApi(page, networkName); } }); @@ -109,7 +109,7 @@ test.describe('Networks Page', () => { await navigateToNetworks(page); await expect(await findNetworkRow(page, networkName)).toBeVisible(); } finally { - await removeNetworkViaUI(page, networkName); + await removeNetworkViaApi(page, networkName); } }); @@ -125,28 +125,32 @@ test.describe('Networks Page', () => { await expect(page).toHaveURL(/\/networks\/.+/); await expect(page.getByRole('heading', { level: 1, name: networkName })).toBeVisible(); } finally { - await removeNetworkViaUI(page, networkName); + await removeNetworkViaApi(page, networkName); } }); test('Remove Network from table', async ({ page }) => { const networkName = `test-remove-network-${Date.now()}`; - await createNetworkViaUI(page, networkName); - await navigateToNetworks(page); - const row = await findNetworkRow(page, networkName); - await expect(row).toBeVisible(); + try { + await createNetworkViaUI(page, networkName); + await navigateToNetworks(page); + const row = await findNetworkRow(page, networkName); + await expect(row).toBeVisible(); - await row.locator('a[href*="/networks/"]').first().click(); - await expect(page).toHaveURL(/\/networks\/.+/); - await page.getByRole('button', { name: 'Remove', exact: true }).click(); - await page.getByRole('button', { name: 'Remove', exact: true }).last().click(); - await expect( - page.locator('li[data-sonner-toast][data-type="success"] div[data-title]') - ).toBeVisible(); + await row.locator('a[href*="/networks/"]').first().click(); + await expect(page).toHaveURL(/\/networks\/.+/); + await page.getByRole('button', { name: 'Remove', exact: true }).click(); + await page.getByRole('button', { name: 'Remove', exact: true }).last().click(); + await expect( + page.locator('li[data-sonner-toast][data-type="success"] div[data-title]') + ).toBeVisible(); - await navigateToNetworks(page); - const removedRow = await findNetworkRow(page, networkName, 2); - await expect(removedRow).not.toBeVisible(); + await navigateToNetworks(page); + const removedRow = await findNetworkRow(page, networkName, 2); + await expect(removedRow).not.toBeVisible(); + } finally { + await removeNetworkViaApi(page, networkName); + } }); test('Default networks cannot be removed on details page', async ({ page }) => { @@ -156,7 +160,7 @@ test.describe('Networks Page', () => { .first(); await expect(bridgeRow).toBeVisible(); await bridgeRow.locator('a[href*="/networks/"]').first().click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const removeBtn = page.getByRole('button', { name: 'Remove' }); await expect(removeBtn).toBeDisabled(); @@ -170,11 +174,11 @@ test.describe('Networks Page', () => { const row = await findNetworkRow(page, networkName); await expect(row).toBeVisible(); await row.locator('a[href*="/networks/"]').first().click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.getByText('Unused').first()).toBeVisible(); } finally { - await removeNetworkViaUI(page, networkName); + await removeNetworkViaApi(page, networkName); } }); }); diff --git a/tests/spec/project.spec.ts b/tests/spec/project.spec.ts index 5ec567c639..180f62bbae 100644 --- a/tests/spec/project.spec.ts +++ b/tests/spec/project.spec.ts @@ -17,7 +17,7 @@ const DEPLOY_STREAM_SUCCESS = async function navigateToProjects(page: Page) { await page.goto(ROUTES.page); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); } async function setCodeMirrorValue(page: Page, editor: Locator, text: string) { @@ -25,7 +25,7 @@ async function setCodeMirrorValue(page: Page, editor: Locator, text: string) { await expect(content).toBeVisible(); await content.click({ position: { x: 10, y: 10 } }); await content.press('ControlOrMeta+A'); - await page.keyboard.type(text, { delay: 0 }); + await page.keyboard.insertText(text); } async function getCodeMirrorValue(editor: Locator) { @@ -53,13 +53,21 @@ async function openDropdownMenu(page: Page, trigger: Locator) { async function clickProjectsPageUpdateAction(page: Page) { const updateProjectsButton = page.getByRole('button', { name: 'Update Projects', exact: true }); + const moreActionsButton = page.getByRole('button', { name: 'More actions', exact: true }); + + await expect + .poll(async () => { + if (await updateProjectsButton.isVisible().catch(() => false)) return 'direct'; + if (await moreActionsButton.isVisible().catch(() => false)) return 'overflow'; + return ''; + }) + .toMatch(/direct|overflow/); + if (await updateProjectsButton.isVisible().catch(() => false)) { await updateProjectsButton.click(); return; } - const moreActionsButton = page.getByRole('button', { name: 'More actions', exact: true }); - await expect(moreActionsButton).toBeVisible(); await moreActionsButton.click(); await page.getByRole('menuitem', { name: 'Update Projects', exact: true }).click(); } @@ -99,6 +107,18 @@ async function fetchProjectDetail(page: Page, projectId: string): Promise { + const project = await fetchProjectDetail(page, projectId); + return project?.status?.toLowerCase() ?? ''; + }, + { timeout: 90_000 } + ) + .toBe(status); +} + async function findProjectWithDetailUpdateAction(page: Page): Promise { for (const project of realProjects) { if (Number(project.serviceCount ?? 0) <= 0) { @@ -122,12 +142,42 @@ function getProjectIdFromPageUrl(url: string): string { return url.split('/projects/')[1]?.split(/[?#]/)[0] ?? ''; } +async function cleanupProjectViaApi(page: Page, projectName: string, projectId?: string | null) { + if (page.isClosed()) { + return; + } + + let targetProjectId = projectId || ''; + if (!targetProjectId) { + const projects = await fetchProjectsWithRetry(page, 1).catch(() => []); + targetProjectId = projects.find((project) => project.name === projectName)?.id ?? ''; + } + + if (!targetProjectId) { + return; + } + + const response = await page.request.delete( + `/api/environments/0/projects/${targetProjectId}/destroy`, + { + data: { + removeFiles: true, + removeVolumes: true + } + } + ); + + if (!response.ok() && response.status() !== 404) { + throw new Error(`Failed to clean up project ${projectName}: HTTP ${response.status()}`); + } +} + async function createProjectViaUI(page: Page, projectName: string) { const containerName = `test-redis-container-${Date.now()}`; const envFile = TEST_ENV_FILE.replace(/CONTAINER_NAME=.*/m, `CONTAINER_NAME=${containerName}`); await page.goto(ROUTES.newProject); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.getByRole('button', { name: 'My New Project' }).click(); await page.getByRole('textbox', { name: 'My New Project' }).fill(projectName); @@ -141,6 +191,9 @@ async function createProjectViaUI(page: Page, projectName: string) { await setCodeMirrorValue(page, composeEditor, TEST_COMPOSE_YAML); await setCodeMirrorValue(page, envEditor, envFile); + await expect + .poll(async () => (await getCodeMirrorValue(composeEditor)).replace(/\r/g, '').trimEnd()) + .toBe(TEST_COMPOSE_YAML.trimEnd()); const createButton = page .getByRole('button', { name: 'Create Project' }) @@ -180,7 +233,7 @@ async function destroyProjectByNameViaUI(page: Page, projectName: string) { } await page.goto(ROUTES.page); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const searchInput = page.getByPlaceholder('Search…'); if (await searchInput.isVisible().catch(() => false)) { @@ -279,7 +332,7 @@ test.describe('Projects Page', () => { test('should show project actions menu', async ({ page }) => { test.skip(!realProjects.length, 'No projects available for actions menu test'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const firstRow = page.locator('tbody tr').first(); const menu = await openDropdownMenu(page, firstRow.getByRole('button', { name: 'Open menu' })); @@ -314,7 +367,7 @@ test.describe('Projects Page', () => { }); await page.goto(ROUTES.page); - await page.waitForLoadState('networkidle'); + await expect(page.getByLabel('Show archived')).toBeVisible(); await expect(page.locator('tbody tr').filter({ hasText: projectName })).toHaveCount(0); await page.getByLabel('Show archived').check(); @@ -344,7 +397,7 @@ test.describe('Projects Page', () => { test('should navigate to project details when project name is clicked', async ({ page }) => { test.skip(!realProjects.length, 'No projects available for navigation test'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // Get the first project link that points to /projects/ (not the "Git" indicator link) const firstProjectLink = page .locator('tbody tr') @@ -404,7 +457,7 @@ test.describe('Projects Page', () => { test('should display project status badges', async ({ page }) => { test.skip(!realProjects.length, 'No projects available for status badge test'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const runningProjects = realProjects.filter((p) => p.status === 'running'); const stoppedProjects = realProjects.filter((p) => p.status === 'stopped'); @@ -422,7 +475,7 @@ test.describe('Projects Page', () => { test.describe('New Compose Project Page', () => { test.beforeEach(async ({ page }) => { await page.goto(ROUTES.newProject); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); }); test('should display the create project form', async ({ page }) => { @@ -543,97 +596,115 @@ test.describe('New Compose Project Page', () => { }); test('should create a new project successfully', async ({ page }) => { + test.setTimeout(120_000); + const projectName = `test-project-${Date.now()}`; const containerName = `test-redis-container-${Date.now()}`; const envFile = TEST_ENV_FILE.replace(/CONTAINER_NAME=.*/m, `CONTAINER_NAME=${containerName}`); let createdProjectId: string | null = null; let projectPullRequestCount = 0; - await page.getByRole('button', { name: 'My New Project' }).click(); - await page.getByRole('textbox', { name: 'My New Project' }).fill(projectName); - await page.getByRole('textbox', { name: 'My New Project' }).press('Enter'); - - const composeEditor = page.locator('.cm-editor:visible').first(); - await expect(composeEditor).toBeVisible(); - await setCodeMirrorValue(page, composeEditor, TEST_COMPOSE_YAML); - await expect(composeEditor).toContainText(/redis/i); + try { + await page.getByRole('button', { name: 'My New Project' }).click(); + await page.getByRole('textbox', { name: 'My New Project' }).fill(projectName); + await page.getByRole('textbox', { name: 'My New Project' }).press('Enter'); - const envEditor = page.locator('.cm-editor:visible').nth(1); - await expect(envEditor).toBeVisible(); - await setCodeMirrorValue(page, envEditor, envFile); - await expect(envEditor).toContainText(/redis/i); - - await page.route('/api/environments/*/projects', async (route) => { - if (route.request().method() === 'POST') { - const response = await route.fetch(); - const responseBody = await response.text(); - - try { - const parsed = JSON.parse(responseBody); - createdProjectId = parsed.id; - } catch { - // Keep existing createdProjectId value if parsing fails + const composeEditor = page.locator('.cm-editor:visible').first(); + await expect(composeEditor).toBeVisible(); + await setCodeMirrorValue(page, composeEditor, TEST_COMPOSE_YAML); + await expect(composeEditor).toContainText(/redis/i); + + const envEditor = page.locator('.cm-editor:visible').nth(1); + await expect(envEditor).toBeVisible(); + await setCodeMirrorValue(page, envEditor, envFile); + await expect(envEditor).toContainText(/redis/i); + + await page.route('/api/environments/*/projects', async (route) => { + const request = route.request(); + if ( + request.method() === 'POST' && + /\/api\/environments\/[^/]+\/projects$/.test(getPathname(request.url())) + ) { + const response = await route.fetch(); + const responseBody = await response.text(); + + try { + const parsed = JSON.parse(responseBody); + createdProjectId = + parsed.id ?? parsed.project?.id ?? parsed.data?.id ?? createdProjectId; + } catch { + // Keep existing createdProjectId value if parsing fails + } + + await route.fulfill({ + status: response.status(), + headers: response.headers(), + body: responseBody + }); + } else { + await route.continue(); } + }); - await route.fulfill({ - status: response.status(), - headers: response.headers(), - body: responseBody - }); - } else { - await route.continue(); - } - }); + const createButton = page + .getByRole('button', { name: 'Create Project' }) + .locator('[data-slot="arcane-button"]'); + await createButton.click(); - const createButton = page - .getByRole('button', { name: 'Create Project' }) - .locator('[data-slot="arcane-button"]'); - await createButton.click(); + await page.waitForURL(/\/projects\/.+/, { timeout: 10000 }); - await page.waitForURL(/\/projects\/.+/, { timeout: 10000 }); + if (!createdProjectId) { + createdProjectId = getProjectIdFromPageUrl(page.url()); + } - if (createdProjectId) { - await expect(page).toHaveURL(new RegExp(`/projects/${createdProjectId}`)); - } else { - await expect(page).toHaveURL(new RegExp(`/projects/[a-f0-9\\-]{36}`)); - } + if (createdProjectId) { + await expect(page).toHaveURL(new RegExp(`/projects/${createdProjectId}`)); + } else { + await expect(page).toHaveURL(new RegExp(`/projects/[a-f0-9\\-]{36}`)); + } - await expect(page.getByRole('button', { name: projectName })).toBeVisible(); + await expect(page.getByRole('button', { name: projectName })).toBeVisible(); - await page.getByRole('tab', { name: 'Services' }).click(); - await page.waitForLoadState('networkidle'); + await page.getByRole('tab', { name: 'Services' }).click(); + await page.waitForLoadState('load'); - const serviceTable = page.getByRole('table'); - const serviceNameWhenStopped = serviceTable.getByText('redis', { - exact: true - }); - const emptyServicesState = page.getByText(/No services found for this project/i); + const serviceTable = page.getByRole('table'); + const serviceNameWhenStopped = serviceTable.getByText('redis', { + exact: true + }); + const emptyServicesState = page.getByText(/No services found for this project/i); - if ((await serviceNameWhenStopped.count()) > 0) { - await expect(serviceNameWhenStopped.first()).toBeVisible(); - } else { - await expect(emptyServicesState).toBeVisible(); - } + if ((await serviceNameWhenStopped.count()) > 0) { + await expect(serviceNameWhenStopped.first()).toBeVisible(); + } else { + await expect(emptyServicesState).toBeVisible(); + } - await page.route('**/api/environments/*/projects/*/pull', async (route) => { - projectPullRequestCount += 1; - await route.continue(); - }); + await page.route('**/api/environments/*/projects/*/pull', async (route) => { + projectPullRequestCount += 1; + await route.continue(); + }); - const deployButton = page - .getByRole('button', { name: 'Up', exact: true }) - .filter({ hasText: 'Up' }) - .last(); - await deployButton.click(); + const deployButton = page + .getByRole('button', { name: 'Up', exact: true }) + .filter({ hasText: 'Up' }) + .last(); + await deployButton.click(); - await page.waitForTimeout(5000); - await page.waitForLoadState('networkidle'); + expect(projectPullRequestCount).toBe(0); + expect(createdProjectId).toBeTruthy(); - expect(projectPullRequestCount).toBe(0); - await expect(page.getByText('Running', { exact: true })).toBeVisible({ - timeout: 20000 - }); - await expect(page.getByRole('button', { name: 'Down', exact: true })).toBeVisible(); + await waitForProjectStatus(page, createdProjectId!, 'running'); + await expect(page.getByRole('button', { name: 'Down', exact: true })).toBeVisible(); + await page.reload(); + await page.waitForLoadState('load'); + await expect(page.locator('main').getByText('Running', { exact: true }).first()).toBeVisible({ + timeout: 20000 + }); + await expect(page.getByRole('button', { name: 'Down', exact: true })).toBeVisible(); + } finally { + await cleanupProjectViaApi(page, projectName, createdProjectId); + } }); test('should send selected deploy split-button options in the up request', async ({ page }) => { @@ -642,9 +713,10 @@ test.describe('New Compose Project Page', () => { await page.setViewportSize({ width: 1440, height: 900 }); const projectName = `test-deploy-options-${Date.now()}`; + let projectId: string | null = null; try { - await createProjectViaUI(page, projectName); + projectId = await createProjectViaUI(page, projectName); // Reset scroll so the floating header doesn't appear from stale scroll state await page.mouse.wheel(0, -100000); @@ -707,11 +779,11 @@ test.describe('New Compose Project Page', () => { forceRecreate: true }); } finally { - if (!page.isClosed() && /\/projects\/.+/.test(getPathname(page.url()))) { - await destroyCurrentProjectViaUI(page); - } else { - await destroyProjectByNameViaUI(page, projectName); - } + await cleanupProjectViaApi( + page, + projectName, + projectId || getProjectIdFromPageUrl(page.url()) + ); } }); @@ -726,7 +798,7 @@ test.describe('New Compose Project Page', () => { try { await createProjectViaUI(page, projectName); await page.goto(ROUTES.page); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.route('**/api/environments/*/projects/*/redeploy', async (route) => { if (route.request().method() !== 'POST') { @@ -834,7 +906,7 @@ test.describe('New Compose Project Page', () => { test.describe('GitOps Managed Project', () => { test('should navigate back to gitops when opened from the git syncs page', async ({ page }) => { await page.goto('/environments/0/gitops'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const projectLink = page.locator('tbody tr').locator('a[href^="/projects/"]').first(); test.skip((await projectLink.count()) === 0, 'No GitOps project links found'); @@ -855,12 +927,12 @@ test.describe('GitOps Managed Project', () => { test.skip(!gitOpsProject, 'No GitOps-managed projects found'); await page.goto(`/projects/${gitOpsProject!.id}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // Navigate to Configuration tab const configTab = page.getByRole('tab', { name: /Configuration|Config/i }); await configTab.click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // Verify the GitOps read-only alert is visible (title contains "Git" and "Read-only") await expect(page.getByText('Git Read-only')).toBeVisible(); @@ -872,11 +944,11 @@ test.describe('GitOps Managed Project', () => { test.skip(!gitOpsProject, 'No GitOps-managed projects found'); await page.goto(`/projects/${gitOpsProject!.id}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const configTab = page.getByRole('tab', { name: /Configuration|Config/i }); await configTab.click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // Verify the Sync from Git button is present await expect(page.getByRole('button', { name: 'Sync from Git' })).toBeVisible(); @@ -887,7 +959,7 @@ test.describe('GitOps Managed Project', () => { test.skip(!gitOpsProject, 'No GitOps-managed projects with sync commit found'); await page.goto(`/projects/${gitOpsProject!.id}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // The commit hash should be visible somewhere on the page const commitHash = gitOpsProject!.lastSyncCommit!.substring(0, 7); @@ -899,7 +971,7 @@ test.describe('GitOps Managed Project', () => { test.skip(!gitOpsProject, 'No GitOps-managed projects found'); await page.goto(`/projects/${gitOpsProject!.id}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // The name button should be disabled for GitOps-managed projects const nameButton = page.getByRole('button', { name: gitOpsProject!.name }); @@ -911,11 +983,11 @@ test.describe('GitOps Managed Project', () => { test.skip(!gitOpsProject, 'No GitOps-managed projects found'); await page.goto(`/projects/${gitOpsProject!.id}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const configTab = page.getByRole('tab', { name: /Configuration|Config/i }); await configTab.click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.waitForTimeout(800); const composeContent = page.locator('.cm-editor:visible').first().locator('.cm-content'); @@ -929,11 +1001,11 @@ test.describe('GitOps Managed Project', () => { test.skip(!gitOpsProject, 'No GitOps-managed projects found'); await page.goto(`/projects/${gitOpsProject!.id}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const configTab = page.getByRole('tab', { name: /Configuration|Config/i }); await configTab.click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.waitForTimeout(800); const envEditor = page.locator('.cm-editor:visible').nth(1); @@ -952,7 +1024,7 @@ test.describe('GitOps Managed Project', () => { }); if (await layoutSwitch.count()) { await layoutSwitch.click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const envFileButton = page.getByRole('button', { name: '.env' }).first(); await expect(envFileButton).toBeVisible(); @@ -970,7 +1042,7 @@ test.describe('GitOps Managed Project', () => { test.skip(!regularProject, 'No regular (non-GitOps) stopped projects found'); await page.goto(`/projects/${regularProject!.id}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // The name button should be enabled for regular projects that are stopped const nameButton = page.getByRole('button', { name: regularProject!.name }); @@ -979,7 +1051,7 @@ test.describe('GitOps Managed Project', () => { // Navigate to Configuration tab const configTab = page.getByRole('tab', { name: /Configuration|Config/i }); await configTab.click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // GitOps alert should NOT be visible await expect(page.getByText('Git Read-only')).not.toBeVisible(); @@ -995,11 +1067,11 @@ test.describe('GitOps Managed Project', () => { test.skip(!regularProject, 'No regular (non-GitOps) projects found'); await page.goto(`/projects/${regularProject!.id}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const configTab = page.getByRole('tab', { name: /Configuration|Config/i }); await configTab.click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // Verify no GitOps-related UI elements await expect(page.getByText(/managed by Git\./i)).not.toBeVisible(); @@ -1013,7 +1085,7 @@ test.describe('Project Detail Page', () => { const firstProject = realProjects[0]; await page.goto(`/projects/${firstProject.id || firstProject.name}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const backLink = page.getByRole('link', { name: /^Back$/i }).first(); await expect(backLink).toBeVisible(); @@ -1028,7 +1100,7 @@ test.describe('Project Detail Page', () => { const firstProject = realProjects[0]; await page.goto(`/projects/${firstProject.id || firstProject.name}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.getByRole('button', { name: firstProject.name, exact: false })).toBeVisible(); @@ -1041,7 +1113,7 @@ test.describe('Project Detail Page', () => { test.skip(!realProjects.length, 'No projects available for navigation test'); const firstProject = realProjects[0]; await page.goto(`/projects/${firstProject.id || firstProject.name}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.getByRole('tab', { name: /Services/i })).toBeVisible(); await expect(page.getByRole('tab', { name: /Configuration|Config/i })).toBeVisible(); @@ -1083,7 +1155,7 @@ test.describe('Project Detail Page', () => { }); await page.goto(`/projects/${projectWithServices!.id || projectWithServices!.name}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const clicked = await clickProjectDetailUpdateAction(page); test.skip(!clicked, 'Current project detail view has no clickable update action'); @@ -1098,7 +1170,7 @@ test.describe('Project Detail Page', () => { const projectWithServices = realProjects.find((p) => (p.serviceCount ?? 0) > 0) ?? realProjects[0]!; await page.goto(`/projects/${projectWithServices.id || projectWithServices.name}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.getByRole('tab', { name: /Services/i }).click(); @@ -1122,7 +1194,7 @@ test.describe('Project Detail Page', () => { const firstProject = realProjects[0]; await page.goto(`/projects/${firstProject.id || firstProject.name}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const configTab = page.getByRole('tab', { name: /Configuration|Config/i }); await configTab.click(); @@ -1186,11 +1258,11 @@ test.describe('Project Detail Page', () => { test.skip(!regularProject, 'No regular (non-GitOps) projects found'); await page.goto(`/projects/${regularProject!.id || regularProject!.name}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const configTab = page.getByRole('tab', { name: /Configuration|Config/i }); await configTab.click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); let layoutSwitch = page.getByRole('switch', { name: /Classic|Tree View/i @@ -1210,12 +1282,12 @@ test.describe('Project Detail Page', () => { } await page.reload(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const restoredConfigTab = page.getByRole('tab', { name: /Configuration|Config/i }); if ((await restoredConfigTab.getAttribute('aria-selected')) !== 'true') { await restoredConfigTab.click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); } await expect(projectFilesHeading).toBeVisible(); @@ -1225,9 +1297,9 @@ test.describe('Project Detail Page', () => { await expect(treeComposeEditor).toBeVisible(); const marker = `ARCANE_TREE_SAVE_${Date.now()}`; - const originalCompose = await getCodeMirrorValue(treeComposeEditor); - const updatedCompose = `${originalCompose.trimEnd()}\n# ${marker}\n`; + const updatedCompose = `${TEST_COMPOSE_YAML.trimEnd()}\n# ${marker}\n`; await setCodeMirrorValue(page, treeComposeEditor, updatedCompose); + await expect.poll(async () => getCodeMirrorValue(treeComposeEditor)).toContain(marker); const saveButton = page.getByRole('button', { name: 'Save', exact: true }).first(); await expect(saveButton).toBeVisible(); @@ -1247,12 +1319,12 @@ test.describe('Project Detail Page', () => { test('should keep saved compose edits visible without a page refresh', async ({ page }) => { const projectName = `save-persist-${Date.now()}`; - await createProjectViaUI(page, projectName); + const projectId = await createProjectViaUI(page, projectName); try { const configTab = page.getByRole('tab', { name: /Configuration|Config/i }); await configTab.click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); let composeEditor = page.locator('.cm-editor:visible').first(); await expect(composeEditor).toBeVisible(); @@ -1293,12 +1365,12 @@ test.describe('Project Detail Page', () => { } await page.reload(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const restoredConfigTab = page.getByRole('tab', { name: /Configuration|Config/i }); if ((await restoredConfigTab.getAttribute('aria-selected')) !== 'true') { await restoredConfigTab.click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); } composeEditor = page.locator('.cm-editor:visible').first(); @@ -1309,7 +1381,7 @@ test.describe('Project Detail Page', () => { }) .toContain(marker); } finally { - await destroyCurrentProjectViaUI(page); + await cleanupProjectViaApi(page, projectName, projectId); } }); @@ -1321,7 +1393,7 @@ test.describe('Project Detail Page', () => { const targetProject = runningProject!; await page.goto(`/projects/${targetProject.id || targetProject.name}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const logsTab = page.getByRole('tab', { name: /Logs/i }); await expect(logsTab).toBeEnabled(); diff --git a/tests/spec/registries.spec.ts b/tests/spec/registries.spec.ts index 57c0efa94b..5346ccd5b1 100644 --- a/tests/spec/registries.spec.ts +++ b/tests/spec/registries.spec.ts @@ -78,7 +78,7 @@ async function mockRegistryPullUsage( test.describe('Container Registries', () => { test.beforeEach(async ({ page }) => { await page.goto(route); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); }); test('should display title and subtitle, and refresh', async ({ page }) => { @@ -118,7 +118,7 @@ test.describe('Container Registries', () => { await mockRegistryList(page); await mockRegistryPullUsage(page, { remaining: 76, limit: 100, observedPulls: 4 }); await page.goto(route); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const table = page.getByRole('table'); await expect(table.getByText('Pull Usage')).toBeVisible(); @@ -131,7 +131,7 @@ test.describe('Container Registries', () => { await mockRegistryList(page); await mockRegistryPullUsage(page, { observedPulls: 4 }); await page.goto(route); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const table = page.getByRole('table'); await expect(table.getByText('Pull Usage')).toBeVisible(); diff --git a/tests/spec/settings-notifications.spec.ts b/tests/spec/settings-notifications.spec.ts index 86c19baad6..c1cd46b2ad 100644 --- a/tests/spec/settings-notifications.spec.ts +++ b/tests/spec/settings-notifications.spec.ts @@ -89,7 +89,7 @@ test.describe('Notification settings', () => { }); await page.goto('/settings/notifications'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.getByRole('tab', { name: 'Built-in Notifications' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'Email' })).toBeVisible(); diff --git a/tests/spec/swarm-ui.spec.ts b/tests/spec/swarm-ui.spec.ts index 94fbe058d3..53f5d82fd0 100644 --- a/tests/spec/swarm-ui.spec.ts +++ b/tests/spec/swarm-ui.spec.ts @@ -31,7 +31,7 @@ test.describe('Swarm UI', () => { page }) => { await page.goto('/swarm/cluster'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.getByRole('heading', { name: 'Cluster', level: 1 })).toBeVisible(); @@ -57,7 +57,7 @@ test.describe('Swarm UI', () => { test('configs page renders name/data fields and empty state', async ({ page }) => { await mockConfigs(page); await page.goto('/swarm/configs'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.getByRole('heading', { name: 'Configs', level: 1 })).toBeVisible(); await expect( @@ -72,7 +72,7 @@ test.describe('Swarm UI', () => { test('secrets page renders name/data fields and empty state', async ({ page }) => { await mockSecrets(page); await page.goto('/swarm/secrets'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.getByRole('heading', { name: 'Secrets', level: 1 })).toBeVisible(); await expect( diff --git a/tests/spec/token-refresh.spec.ts b/tests/spec/token-refresh.spec.ts index 18ab2b9075..6a1df82c68 100644 --- a/tests/spec/token-refresh.spec.ts +++ b/tests/spec/token-refresh.spec.ts @@ -88,9 +88,9 @@ test.describe('Token refresh behaviour', () => { await injectVersionMismatch401Once(page, /\/api\/auth\/me$/); await page.goto('/dashboard'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); - expect(wasRefreshCalled()).toBe(true); + await expect.poll(wasRefreshCalled).toBe(true); await expect(page).toHaveURL('/dashboard'); await expect(page.getByRole('button', { name: 'Sign in to Arcane' })).not.toBeVisible(); }); @@ -103,9 +103,9 @@ test.describe('Token refresh behaviour', () => { await injectVersionMismatch401Once(page, /\/api\/environments\/0\/containers/); await page.goto('/containers'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); - expect(wasRefreshCalled()).toBe(true); + await expect.poll(wasRefreshCalled).toBe(true); await expect(page).toHaveURL('/containers'); await expect(page.getByRole('heading', { name: 'Containers', level: 1 })).toBeVisible(); await expect(page.getByRole('button', { name: 'Sign in to Arcane' })).not.toBeVisible(); @@ -140,7 +140,7 @@ test.describe('Token refresh behaviour', () => { await page.context().clearCookies(); await page.goto('/dashboard'); await page.waitForURL(/\/login/, { timeout: 10_000 }); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page).toHaveURL(/\/login/); await expect( page.getByRole('button', { name: 'Sign in to Arcane', exact: true }) diff --git a/tests/spec/volumes.spec.ts b/tests/spec/volumes.spec.ts index 2ff8ce39f5..5afb83fa67 100644 --- a/tests/spec/volumes.spec.ts +++ b/tests/spec/volumes.spec.ts @@ -11,7 +11,7 @@ test.beforeEach(async ({ page }) => { async function openCreateVolumeSheet(page: Page) { await page.goto('/volumes'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.getByRole('heading', { name: 'Volumes', level: 1 })).toBeVisible(); const createButton = page.getByRole('button', { name: 'Create Volume' }).first(); @@ -36,20 +36,57 @@ async function createVolumeViaUI(page: Page, volumeName: string) { ).toBeVisible(); } -async function findVolumeRow(page: Page, volumeName: string, maxRetries = 10) { - for (let i = 0; i < maxRetries; i++) { - const searchInput = page.getByPlaceholder(/Search/i).first(); - if (await searchInput.isVisible().catch(() => false)) { - await searchInput.fill(volumeName); - } - - const row = page.locator('tbody tr').filter({ hasText: volumeName }).first(); - if (await row.isVisible().catch(() => false)) return row; - await page.waitForTimeout(500); - await page.goto('/volumes'); - await page.waitForLoadState('networkidle'); +async function createVolumeViaApi(page: Page, volumeName: string) { + await page.goto('/volumes'); + await page.waitForLoadState('load'); + const response = await page.evaluate(async (name) => { + const res = await fetch('/api/environments/0/volumes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, driver: 'local' }) + }); + return { ok: res.ok, status: res.status, text: await res.text() }; + }, volumeName); + if (!response.ok) { + throw new Error(`Failed to create volume ${volumeName}: ${response.status} ${response.text}`); } - return page.locator('tbody tr').filter({ hasText: volumeName }).first(); +} + +async function waitForVolumeAvailableViaApi(page: Page, volumeName: string) { + await expect + .poll( + async () => { + const response = await page.request.get('/api/environments/0/volumes', { + params: { + search: volumeName, + start: '0', + limit: '1' + } + }); + if (!response.ok()) return false; + + const body = await response.json().catch(() => null); + return ( + Array.isArray(body?.data) && + body.data.some((volume: { name?: string }) => volume.name === volumeName) + ); + }, + { timeout: 15_000 } + ) + .toBe(true); +} + +async function findVolumeRow(page: Page, volumeName: string) { + const searchInput = page.getByPlaceholder(/Search/i).first(); + await expect(searchInput).toBeVisible({ timeout: 15_000 }); + await searchInput.fill(volumeName); + await searchInput.press('Enter'); + + await waitForVolumeAvailableViaApi(page, volumeName); + + const row = page.locator('tbody tr').filter({ hasText: volumeName }).first(); + await expect(row).toBeVisible({ timeout: 15_000 }); + return row; } async function removeVolumeViaUI(page: Page, volumeName: string) { @@ -58,10 +95,10 @@ async function removeVolumeViaUI(page: Page, volumeName: string) { } await page.goto('/volumes'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); - const row = await findVolumeRow(page, volumeName, 4); - if (!(await row.isVisible().catch(() => false))) return; + const row = await findVolumeRow(page, volumeName).catch(() => null); + if (!row || !(await row.isVisible().catch(() => false))) return; await row.locator('a[href*="/volumes/"]').first().click(); await expect(page).toHaveURL(/\/volumes\/.+/); @@ -72,6 +109,17 @@ async function removeVolumeViaUI(page: Page, volumeName: string) { ).toBeVisible(); } +async function removeVolumeViaApi(page: Page, volumeName: string) { + if (!volumeName || page.isClosed()) return; + await page + .evaluate(async (name) => { + await fetch(`/api/environments/0/volumes/${encodeURIComponent(name)}?force=true`, { + method: 'DELETE' + }); + }, volumeName) + .catch(() => {}); +} + function facetIds(title: string) { const key = title.toLowerCase(); return { @@ -102,7 +150,7 @@ test.describe('Volumes Page', () => { test('Correct Volume Stat Card Counts', async ({ page }) => { await page.goto('/volumes'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.getByText(`${volumeCount.total} Total Volumes`)).toBeVisible(); }); @@ -114,7 +162,7 @@ test.describe('Volumes Page', () => { test('Display Volume Filters', async ({ page }) => { await page.goto('/volumes'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const { content } = await ensureFacetOpen(page, 'Usage'); await expect(content.getByRole('option', { name: /In Use\b/i })).toBeVisible(); @@ -125,9 +173,9 @@ test.describe('Volumes Page', () => { const volumeName = `e2e-inspect-volume-${Date.now()}`; try { - await createVolumeViaUI(page, volumeName); + await createVolumeViaApi(page, volumeName); await page.goto('/volumes'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const row = await findVolumeRow(page, volumeName); await expect(row).toBeVisible(); @@ -136,26 +184,30 @@ test.describe('Volumes Page', () => { await expect(page).toHaveURL(new RegExp(`/volumes/.+`)); await expect(page.getByRole('heading', { level: 1, name: volumeName })).toBeVisible(); } finally { - await removeVolumeViaUI(page, volumeName); + await removeVolumeViaApi(page, volumeName); } }); test('Remove Volume', async ({ page }) => { const volumeName = `test-remove-volume-${Date.now()}`; - await createVolumeViaUI(page, volumeName); - await page.goto('/volumes'); - await page.waitForLoadState('networkidle'); - - const row = await findVolumeRow(page, volumeName); - await expect(row).toBeVisible(); - await row.locator('a[href*="/volumes/"]').first().click(); - await expect(page).toHaveURL(new RegExp(`/volumes/.+`)); - await page.locator('button[data-slot="arcane-button"][data-action="remove"]').click(); - await page.getByRole('button', { name: 'Remove', exact: true }).last().click(); - - await expect( - page.locator('li[data-sonner-toast][data-type="success"] div[data-title]') - ).toBeVisible(); + try { + await createVolumeViaApi(page, volumeName); + await page.goto('/volumes'); + await page.waitForLoadState('load'); + + const row = await findVolumeRow(page, volumeName); + await expect(row).toBeVisible(); + await row.locator('a[href*="/volumes/"]').first().click(); + await expect(page).toHaveURL(new RegExp(`/volumes/.+`)); + await page.locator('button[data-slot="arcane-button"][data-action="remove"]').click(); + await page.getByRole('button', { name: 'Remove', exact: true }).last().click(); + + await expect( + page.locator('li[data-sonner-toast][data-type="success"] div[data-title]') + ).toBeVisible(); + } finally { + await removeVolumeViaApi(page, volumeName); + } }); test('Create Volume', async ({ page }) => { @@ -165,22 +217,22 @@ test.describe('Volumes Page', () => { await page.goto('/volumes'); await expect(await findVolumeRow(page, volumeName)).toBeVisible(); } finally { - await removeVolumeViaUI(page, volumeName); + await removeVolumeViaApi(page, volumeName); } }); test('Display correct volume usage badge', async ({ page }) => { const volumeName = `e2e-badge-volume-${Date.now()}`; try { - await createVolumeViaUI(page, volumeName); + await createVolumeViaApi(page, volumeName); await page.goto('/volumes'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const row = await findVolumeRow(page, volumeName); await expect(row).toBeVisible(); await expect(row.getByText('Unused')).toBeVisible(); } finally { - await removeVolumeViaUI(page, volumeName); + await removeVolumeViaApi(page, volumeName); } }); }); diff --git a/types/activity/activity.go b/types/activity/activity.go new file mode 100644 index 0000000000..ff7f32b86a --- /dev/null +++ b/types/activity/activity.go @@ -0,0 +1,102 @@ +package activity + +import "time" + +type Status string + +const ( + StatusQueued Status = "queued" + StatusRunning Status = "running" + StatusSuccess Status = "success" + StatusFailed Status = "failed" + StatusCancelled Status = "cancelled" +) + +type Type string + +const ( + TypeImagePull Type = "image_pull" + TypeImageBuild Type = "image_build" + TypeImageUpdateCheck Type = "image_update_check" + TypeProjectPull Type = "project_pull" + TypeProjectBuild Type = "project_build" + TypeProjectDeploy Type = "project_deploy" + TypeProjectRedeploy Type = "project_redeploy" + TypeProjectDown Type = "project_down" + TypeProjectRestart Type = "project_restart" + TypeProjectDestroy Type = "project_destroy" + TypeContainerStart Type = "container_start" + TypeContainerStop Type = "container_stop" + TypeContainerRestart Type = "container_restart" + TypeContainerRedeploy Type = "container_redeploy" + TypeContainerDelete Type = "container_delete" + TypeVulnerabilityScan Type = "vulnerability_scan" + TypeAutoUpdate Type = "auto_update" + TypeSystemPrune Type = "system_prune" + TypeResourceAction Type = "resource_action" +) + +type MessageLevel string + +const ( + MessageLevelInfo MessageLevel = "info" + MessageLevelWarning MessageLevel = "warning" + MessageLevelError MessageLevel = "error" + MessageLevelSuccess MessageLevel = "success" +) + +type Activity struct { + ID string `json:"id"` + EnvironmentID string `json:"environmentId"` + SourceEnvironmentID string `json:"sourceEnvironmentId,omitempty"` + SourceEnvironmentName string `json:"sourceEnvironmentName,omitempty"` + Type Type `json:"type"` + Status Status `json:"status"` + ResourceType *string `json:"resourceType,omitempty"` + ResourceID *string `json:"resourceId,omitempty"` + ResourceName *string `json:"resourceName,omitempty"` + Progress *int `json:"progress,omitempty"` + Step string `json:"step,omitempty"` + LatestMessage string `json:"latestMessage,omitempty"` + StartedBy *StartedBy `json:"startedBy,omitempty"` + StartedAt time.Time `json:"startedAt"` + EndedAt *time.Time `json:"endedAt,omitempty"` + DurationMs *int64 `json:"durationMs,omitempty"` + Error *string `json:"error,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} + +type StartedBy struct { + UserID string `json:"userId,omitempty"` + Username string `json:"username"` + DisplayName string `json:"displayName,omitempty"` +} + +type Message struct { + ID string `json:"id"` + ActivityID string `json:"activityId"` + Level MessageLevel `json:"level"` + Message string `json:"message"` + Payload map[string]any `json:"payload,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +type Detail struct { + Activity Activity `json:"activity"` + Messages []Message `json:"messages"` +} + +type StreamEvent struct { + Type string `json:"type"` + ActivityID string `json:"activityId,omitempty"` + Activity *Activity `json:"activity,omitempty"` + Activities []Activity `json:"activities,omitempty"` + Message *Message `json:"message,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +type ClearHistoryResult struct { + Deleted int64 `json:"deleted"` +} diff --git a/types/base/response.go b/types/base/response.go index 4998018619..bfdeee8b06 100644 --- a/types/base/response.go +++ b/types/base/response.go @@ -7,7 +7,8 @@ type ErrorResponse struct { // MessageResponse represents a simple message response. type MessageResponse struct { - Message string `json:"message" doc:"Response message"` + Message string `json:"message" doc:"Response message"` + ActivityID *string `json:"activityId,omitempty" doc:"Background activity ID tracking this action"` } // PaginationResponse contains pagination metadata. diff --git a/types/container/container.go b/types/container/container.go index 55452116e2..a86bab3eb2 100644 --- a/types/container/container.go +++ b/types/container/container.go @@ -319,6 +319,11 @@ type ActionResult struct { // // Required: false Errors []string `json:"errors,omitempty"` + + // ActivityID is the background activity that tracked this action. + // + // Required: false + ActivityID *string `json:"activityId,omitempty"` } // Port represents a port binding for a container. @@ -836,6 +841,11 @@ type Details struct { // // Required: false RedeployDisabled bool `json:"redeployDisabled,omitempty"` + + // ActivityID is the background activity that tracked the action returning these details. + // + // Required: false + ActivityID *string `json:"activityId,omitempty"` } // Created represents a newly created container. diff --git a/types/imageupdate/image_update.go b/types/imageupdate/image_update.go index 94ca0808b7..1639f1f669 100644 --- a/types/imageupdate/image_update.go +++ b/types/imageupdate/image_update.go @@ -71,6 +71,11 @@ type Response struct { // // Required: false UsedCredential bool `json:"usedCredential,omitempty"` + + // ActivityID is the background activity that tracked this check. + // + // Required: false + ActivityID *string `json:"activityId,omitempty"` } type Summary struct { diff --git a/types/network/network.go b/types/network/network.go index 17c1a129c1..eb2c69f0b6 100644 --- a/types/network/network.go +++ b/types/network/network.go @@ -200,6 +200,11 @@ type CreateResponse struct { // // Required: false Warning string `json:"warning,omitempty"` + + // ActivityID is the activity created by the network creation action. + // + // Required: false + ActivityID *string `json:"activityId,omitempty"` } // CreateRequest contains the parameters for creating a network. @@ -369,6 +374,11 @@ type PruneReport struct { // // Required: true SpaceReclaimed uint64 `json:"spaceReclaimed"` + + // ActivityID is the activity created by the prune action. + // + // Required: false + ActivityID *string `json:"activityId,omitempty"` } // NewSummary creates a Summary from a docker network.Summary, calculating InUse and IsDefault fields. diff --git a/types/project/project.go b/types/project/project.go index d3c5ea68e7..5a4fefc8e1 100644 --- a/types/project/project.go +++ b/types/project/project.go @@ -273,6 +273,11 @@ type CreateReponse struct { // // Required: true UpdatedAt string `json:"updatedAt"` + + // ActivityID is the activity created by the project action. + // + // Required: false + ActivityID *string `json:"activityId,omitempty"` } // Details contains detailed information about a project. @@ -417,6 +422,11 @@ type Details struct { // // Required: false GitRepositoryURL string `json:"gitRepositoryURL,omitempty"` + + // ActivityID is the activity created by the project action. + // + // Required: false + ActivityID *string `json:"activityId,omitempty"` } // Destroy is used to destroy a project. diff --git a/types/settings/settings.go b/types/settings/settings.go index 64b6fd8991..a4a94ddb61 100644 --- a/types/settings/settings.go +++ b/types/settings/settings.go @@ -88,6 +88,16 @@ type Update struct { // Required: false EnvironmentHealthInterval *string `json:"environmentHealthInterval,omitempty"` + // ActivityHistoryRetentionDays is the number of days of completed Activity Center history to retain. + // + // Required: false + ActivityHistoryRetentionDays *string `json:"activityHistoryRetentionDays,omitempty"` + + // ActivityHistoryMaxEntries is the maximum completed Activity Center entries to retain per environment. + // + // Required: false + ActivityHistoryMaxEntries *string `json:"activityHistoryMaxEntries,omitempty"` + // PruneMode is the Docker prune mode ("all" or "dangling"). // // Deprecated: Use the granular prune mode settings instead. diff --git a/types/system/prune.go b/types/system/prune.go index 630d8b938b..b86858f394 100644 --- a/types/system/prune.go +++ b/types/system/prune.go @@ -295,4 +295,9 @@ type PruneAllResult struct { // // Required: false Errors []string `json:"errors,omitempty"` + + // ActivityID is the background activity that tracked this prune operation. + // + // Required: false + ActivityID *string `json:"activityId,omitempty"` } diff --git a/types/updater/updater.go b/types/updater/updater.go index 4064dc6cac..e8ee234430 100644 --- a/types/updater/updater.go +++ b/types/updater/updater.go @@ -113,6 +113,11 @@ type Result struct { // // Required: true Items []ResourceResult `json:"items"` + + // ActivityID is the background activity that tracked this update operation. + // + // Required: false + ActivityID *string `json:"activityId,omitempty"` } // Status represents the current status of the updater. diff --git a/types/volume/volume.go b/types/volume/volume.go index a2eec24008..5c038364b2 100644 --- a/types/volume/volume.go +++ b/types/volume/volume.go @@ -63,6 +63,11 @@ type Volume struct { // // Required: true Containers []string `json:"containers"` + + // ActivityID is the activity created by a mutating volume action. + // + // Required: false + ActivityID *string `json:"activityId,omitempty"` } // UsageCounts contains counts of volumes by usage status. @@ -94,6 +99,11 @@ type PruneReport struct { // // Required: true SpaceReclaimed uint64 `json:"spaceReclaimed"` + + // ActivityID is the activity created by the prune action. + // + // Required: false + ActivityID *string `json:"activityId,omitempty"` } // Create is used to create a new volume. diff --git a/types/vulnerability/vulnerability.go b/types/vulnerability/vulnerability.go index b916f445ed..1c3d68de8f 100644 --- a/types/vulnerability/vulnerability.go +++ b/types/vulnerability/vulnerability.go @@ -184,6 +184,11 @@ type ScanResult struct { // Required: true Status ScanStatus `json:"status"` + // ActivityID is the background activity tracking this scan, if available + // + // Required: false + ActivityID *string `json:"activityId,omitempty"` + // ScanPhase is the current phase of a running scan // (e.g. creating_container, scanning_image, storing_results). //