diff --git a/README.md b/README.md index b674086..6814db7 100644 --- a/README.md +++ b/README.md @@ -15,17 +15,20 @@ Golang selfhosted Status/Healthcheck monitoring app. - Kinda working atm. ~But look if all the data could be stored/fetched from temporal.~ - [x] Edit/Delete operations for healthchecks and workers. - [ ] CronJob Healthchecks (via webhooks). + - Allow CronJob monitoring by checking that there was an event at expected time. + - Allow Heartbeat monitoring to alert when events stop coming. + - Allow integration with other services by alerting/transforming when events come. - [ ] Notifications (webhooks, slack, etc). - [ ] Incidents (based on script that is triggered by monitors/crobjobs). - [ ] Prepare i18n. - - [ ] Alpha Version (1H 2024) + - [ ] Alpha Version (3Q 2024) - [ ] ?? - - [ ] Beta Version (2H 2024) + - [ ] Beta Version (4Q 2024) - [ ] ?? - - [ ] Stable Release (2025) + - [ ] Stable Release (1H 2025) ![Screenshot](docs/screenshot.png) -Demo is available at https://zdravko.mnts.dev. +Demo is available at https://zdravko.mnts.dev. More screenshots in the [docs folder](docs/). # Development diff --git a/database/models/checks.go b/database/models/checks.go new file mode 100644 index 0000000..d06ed97 --- /dev/null +++ b/database/models/checks.go @@ -0,0 +1,27 @@ +package models + +type CheckState string + +const ( + CheckStateActive CheckState = "ACTIVE" + CheckStatePaused CheckState = "PAUSED" + CheckStateUnknown CheckState = "UNKNOWN" +) + +type Check struct { + CreatedAt *Time `db:"created_at"` + UpdatedAt *Time `db:"updated_at"` + + Id string `db:"id"` + Name string `db:"name"` + + Schedule string `db:"schedule"` + Script string `db:"script"` + Filter string `db:"filter"` +} + +type CheckWithWorkerGroups struct { + Check + // List of worker group names + WorkerGroups []string +} diff --git a/database/models/hooks.go b/database/models/hooks.go new file mode 100644 index 0000000..641144d --- /dev/null +++ b/database/models/hooks.go @@ -0,0 +1,20 @@ +package models + +type HookState string + +const ( + HookStateActive HookState = "ACTIVE" + HookStatePaused HookState = "PAUSED" + HookStateUnknown HookState = "UNKNOWN" +) + +type Hook struct { + CreatedAt *Time `db:"created_at"` + UpdatedAt *Time `db:"updated_at"` + + Id string `db:"id"` + Name string `db:"name"` + + Schedule string `db:"schedule"` + Script string `db:"script"` +} diff --git a/database/models/models.go b/database/models/models.go index cf0e9eb..c00e4bc 100644 --- a/database/models/models.go +++ b/database/models/models.go @@ -45,32 +45,6 @@ type OAuth2State struct { ExpiresAt *Time `db:"expires_at"` } -type CheckState string - -const ( - CheckStateActive CheckState = "ACTIVE" - CheckStatePaused CheckState = "PAUSED" - CheckStateUnknown CheckState = "UNKNOWN" -) - -type Check struct { - CreatedAt *Time `db:"created_at"` - UpdatedAt *Time `db:"updated_at"` - - Id string `db:"id"` - Name string `db:"name"` - - Schedule string `db:"schedule"` - Script string `db:"script"` - Filter string `db:"filter"` -} - -type CheckWithWorkerGroups struct { - Check - // List of worker group names - WorkerGroups []string -} - type WorkerGroup struct { CreatedAt *Time `db:"created_at"` UpdatedAt *Time `db:"updated_at"` @@ -102,50 +76,3 @@ type Trigger struct { Name string `db:"name"` Script string `db:"script"` } - -type TargetVisibility string - -const ( - TargetVisibilityPublic TargetVisibility = "PUBLIC" - TargetVisibilityPrivate TargetVisibility = "PRIVATE" - TargetVisibilityUnknown TargetVisibility = "UNKNOWN" -) - -type TargetState string - -const ( - TargetStateActive TargetState = "ACTIVE" - TargetStatePaused TargetState = "PAUSED" - TargetStateUnknown TargetState = "UNKNOWN" -) - -type Target struct { - CreatedAt *Time `db:"created_at"` - UpdatedAt *Time `db:"updated_at"` - - Id string `db:"id"` - Name string `db:"name"` - Group string `db:"group"` - Visibility TargetVisibility `db:"visibility"` - State TargetState `db:"state"` - Metadata string `db:"metadata"` -} - -type TargetStatus string - -const ( - TargetStatusSuccess TargetStatus = "SUCCESS" - TargetStatusFailure TargetStatus = "FAILURE" - TargetStatusUnknown TargetStatus = "UNKNOWN" -) - -type TargetHistory struct { - CreatedAt *Time `db:"created_at"` - - TargetId string `db:"target_id"` - WorkerGroupId string `db:"worker_group_id"` - CheckId string `db:"check_id"` - - Status TargetStatus `db:"status"` - Note string `db:"note"` -} diff --git a/database/models/target.go b/database/models/target.go new file mode 100644 index 0000000..82fc146 --- /dev/null +++ b/database/models/target.go @@ -0,0 +1,48 @@ +package models + +type TargetVisibility string + +const ( + TargetVisibilityPublic TargetVisibility = "PUBLIC" + TargetVisibilityPrivate TargetVisibility = "PRIVATE" + TargetVisibilityUnknown TargetVisibility = "UNKNOWN" +) + +type TargetState string + +const ( + TargetStateActive TargetState = "ACTIVE" + TargetStatePaused TargetState = "PAUSED" + TargetStateUnknown TargetState = "UNKNOWN" +) + +type Target struct { + CreatedAt *Time `db:"created_at"` + UpdatedAt *Time `db:"updated_at"` + + Id string `db:"id"` + Name string `db:"name"` + Group string `db:"group"` + Visibility TargetVisibility `db:"visibility"` + State TargetState `db:"state"` + Metadata string `db:"metadata"` +} + +type TargetStatus string + +const ( + TargetStatusSuccess TargetStatus = "SUCCESS" + TargetStatusFailure TargetStatus = "FAILURE" + TargetStatusUnknown TargetStatus = "UNKNOWN" +) + +type TargetHistory struct { + CreatedAt *Time `db:"created_at"` + + TargetId string `db:"target_id"` + WorkerGroupId string `db:"worker_group_id"` + CheckId string `db:"check_id"` + + Status TargetStatus `db:"status"` + Note string `db:"note"` +} diff --git a/database/sqlite/migrations/2024-05-29_hooks.sql b/database/sqlite/migrations/2024-05-29_hooks.sql new file mode 100644 index 0000000..107dd42 --- /dev/null +++ b/database/sqlite/migrations/2024-05-29_hooks.sql @@ -0,0 +1,30 @@ +-- +migrate Up +CREATE TABLE hooks ( + id TEXT NOT NULL, + name TEXT NOT NULL, + schedule TEXT NOT NULL, + script TEXT NOT NULL, + + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ')), + + PRIMARY KEY (id), + CONSTRAINT unique_hooks_name UNIQUE (name) +) STRICT; +-- +migrate StatementBegin +CREATE TRIGGER hooks_updated_timestamp AFTER UPDATE ON hooks BEGIN + UPDATE hooks SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ') WHERE id = NEW.id; +END; +-- +migrate StatementEnd + +CREATE TABLE hook_worker_groups ( + worker_group_id TEXT NOT NULL, + hook_id TEXT NOT NULL, + + PRIMARY KEY (worker_group_id,hook_id), + CONSTRAINT fk_hook_worker_groups_worker_group FOREIGN KEY (worker_group_id) REFERENCES worker_groups(id) ON DELETE CASCADE, + CONSTRAINT fk_hook_worker_groups_hook FOREIGN KEY (hook_id) REFERENCES hooks(id) ON DELETE CASCADE +) STRICT; +-- +migrate Down +DROP TABLE hook_worker_groups; +DROP TABLE hooks; diff --git a/docs/screenshot-checks-describe.png b/docs/screenshot-checks-describe.png new file mode 100644 index 0000000..0b56357 Binary files /dev/null and b/docs/screenshot-checks-describe.png differ diff --git a/docs/screenshot-checks.png b/docs/screenshot-checks.png new file mode 100644 index 0000000..77d1ad1 Binary files /dev/null and b/docs/screenshot-checks.png differ diff --git a/docs/screenshot-index.png b/docs/screenshot-index.png new file mode 100644 index 0000000..b649616 Binary files /dev/null and b/docs/screenshot-index.png differ diff --git a/docs/screenshot-settings.png b/docs/screenshot-settings.png new file mode 100644 index 0000000..8ffc4da Binary files /dev/null and b/docs/screenshot-settings.png differ diff --git a/docs/screenshot-targets.png b/docs/screenshot-targets.png new file mode 100644 index 0000000..e75c67e Binary files /dev/null and b/docs/screenshot-targets.png differ diff --git a/docs/screenshot.png b/docs/screenshot.png deleted file mode 100644 index 559db4c..0000000 Binary files a/docs/screenshot.png and /dev/null differ diff --git a/internal/server/handlers/examples.yaml b/internal/server/handlers/examples.yaml index f0bcc0d..04909e8 100644 --- a/internal/server/handlers/examples.yaml +++ b/internal/server/handlers/examples.yaml @@ -80,6 +80,9 @@ check: | http.get(metadata.spec.url); } +hook: | + // TODO: Implement hook example + filter: | target.metadata.kind == "Http" && target.metadata.spec.url != "" diff --git a/internal/server/handlers/handlers.go b/internal/server/handlers/handlers.go index eb67a5f..d6c90c5 100644 --- a/internal/server/handlers/handlers.go +++ b/internal/server/handlers/handlers.go @@ -22,6 +22,7 @@ type examples struct { Filter string `yaml:"filter"` Trigger string `yaml:"trigger"` Target string `yaml:"target"` + Hook string `yaml:"hook"` } var Pages = []*components.Page{ @@ -69,6 +70,7 @@ func NewBaseHandler(db *sqlx.DB, kvStore database.KeyValueStore, temporal client examples.Filter = script.EscapeString(examples.Filter) examples.Trigger = script.EscapeString(examples.Trigger) examples.Target = script.EscapeString(examples.Target) + examples.Hook = script.EscapeString(examples.Hook) return &BaseHandler{ db: db, diff --git a/internal/server/handlers/settings_hooks.go b/internal/server/handlers/settings_hooks.go new file mode 100644 index 0000000..2166c37 --- /dev/null +++ b/internal/server/handlers/settings_hooks.go @@ -0,0 +1,271 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/go-playground/validator/v10" + "github.com/gosimple/slug" + "github.com/labstack/echo/v4" + "github.com/mentos1386/zdravko/database/models" + "github.com/mentos1386/zdravko/internal/server/services" + "github.com/mentos1386/zdravko/pkg/script" + "github.com/mentos1386/zdravko/web/templates/components" +) + +type CreateHook struct { + Name string `validate:"required"` + Schedule string `validate:"required,cron"` + Script string `validate:"required"` +} + +type UpdateHook struct { + Schedule string `validate:"required,cron"` + Script string `validate:"required"` +} + +type HookWithState struct { + *models.Hook + State models.HookState +} + +type SettingsHooks struct { + *Settings + Hooks []*HookWithState + History []struct { + CreatedAt time.Time + Status string + Note string + } +} + +type SettingsHook struct { + *Settings + Hook *HookWithState + History []*services.HookHistory +} + +type SettingsHookCreate struct { + *Settings + ExampleScript string + ExampleFilter string +} + +func (h *BaseHandler) SettingsHooksGET(c echo.Context) error { + cc := c.(AuthenticatedContext) + + hooks, err := services.GetHooks(context.Background(), h.db) + if err != nil { + return err + } + + hooksWithState := make([]*HookWithState, len(hooks)) + for i, hook := range hooks { + state, err := services.GetHookState(context.Background(), h.temporal, hook.Id) + if err != nil { + h.logger.Error("Failed to get hook state", "error", err) + state = models.HookStateUnknown + } + hooksWithState[i] = &HookWithState{ + Hook: hook, + State: state, + } + } + + return c.Render(http.StatusOK, "settings_hooks.tmpl", &SettingsHooks{ + Settings: NewSettings( + cc.Principal.User, + GetPageByTitle(SettingsPages, "Hooks"), + []*components.Page{GetPageByTitle(SettingsPages, "Hooks")}, + ), + Hooks: hooksWithState, + }) +} + +func (h *BaseHandler) SettingsHooksDescribeGET(c echo.Context) error { + cc := c.(AuthenticatedContext) + + slug := c.Param("id") + + hook, err := services.GetHook(context.Background(), h.db, slug) + if err != nil { + return err + } + + status, err := services.GetHookState(context.Background(), h.temporal, hook.Id) + if err != nil { + return err + } + + hookWithStatus := &HookWithState{ + Hook: hook, + State: status, + } + + history, err := services.GetHookHistoryForHook(context.Background(), h.temporal, slug) + if err != nil { + return err + } + + maxElements := 10 + if len(history) < maxElements { + maxElements = len(history) + } + + return c.Render(http.StatusOK, "settings_hooks_describe.tmpl", &SettingsHook{ + Settings: NewSettings( + cc.Principal.User, + GetPageByTitle(SettingsPages, "Hooks"), + []*components.Page{ + GetPageByTitle(SettingsPages, "Hooks"), + { + Path: fmt.Sprintf("/settings/hooks/%s", slug), + Title: "Describe", + Breadcrumb: hook.Name, + }, + }), + Hook: hookWithStatus, + History: history[:maxElements], + }) +} + +func (h *BaseHandler) SettingsHooksDescribeDELETE(c echo.Context) error { + slug := c.Param("id") + + err := services.DeleteHook(context.Background(), h.db, slug) + if err != nil { + return err + } + + err = services.DeleteHookSchedule(context.Background(), h.temporal, slug) + if err != nil { + return err + } + + return c.Redirect(http.StatusSeeOther, "/settings/hooks") +} + +func (h *BaseHandler) SettingsHooksDisableGET(c echo.Context) error { + slug := c.Param("id") + + hook, err := services.GetHook(context.Background(), h.db, slug) + if err != nil { + return err + } + + err = services.SetHookState(context.Background(), h.temporal, hook.Id, models.HookStatePaused) + if err != nil { + return err + } + + return c.Redirect(http.StatusSeeOther, fmt.Sprintf("/settings/hooks/%s", slug)) +} + +func (h *BaseHandler) SettingsHooksEnableGET(c echo.Context) error { + slug := c.Param("id") + + hook, err := services.GetHook(context.Background(), h.db, slug) + if err != nil { + return err + } + + err = services.SetHookState(context.Background(), h.temporal, hook.Id, models.HookStateActive) + if err != nil { + return err + } + + return c.Redirect(http.StatusSeeOther, fmt.Sprintf("/settings/hooks/%s", slug)) +} + +func (h *BaseHandler) SettingsHooksDescribePOST(c echo.Context) error { + ctx := context.Background() + hookId := c.Param("id") + + update := UpdateHook{ + Schedule: c.FormValue("schedule"), + Script: script.EscapeString(c.FormValue("script")), + } + err := validator.New(validator.WithRequiredStructEnabled()).Struct(update) + if err != nil { + return err + } + + hook, err := services.GetHook(ctx, h.db, hookId) + if err != nil { + return err + } + hook.Schedule = update.Schedule + hook.Script = update.Script + + err = services.UpdateHook( + ctx, + h.db, + hook, + ) + if err != nil { + return err + } + + err = services.CreateOrUpdateHookSchedule(ctx, h.temporal, hook) + if err != nil { + return err + } + + return c.Redirect(http.StatusSeeOther, fmt.Sprintf("/settings/hooks/%s", hookId)) +} + +func (h *BaseHandler) SettingsHooksCreateGET(c echo.Context) error { + cc := c.(AuthenticatedContext) + + return c.Render(http.StatusOK, "settings_hooks_create.tmpl", &SettingsHookCreate{ + Settings: NewSettings( + cc.Principal.User, + GetPageByTitle(SettingsPages, "Hooks"), + []*components.Page{ + GetPageByTitle(SettingsPages, "Hooks"), + GetPageByTitle(SettingsPages, "Hooks Create"), + }, + ), + ExampleScript: h.examples.Hook, + }) +} + +func (h *BaseHandler) SettingsHooksCreatePOST(c echo.Context) error { + ctx := context.Background() + hookId := slug.Make(c.FormValue("name")) + + create := CreateHook{ + Name: c.FormValue("name"), + Schedule: c.FormValue("schedule"), + Script: script.EscapeString(c.FormValue("script")), + } + err := validator.New(validator.WithRequiredStructEnabled()).Struct(create) + if err != nil { + return err + } + + hook := &models.Hook{ + Name: create.Name, + Id: hookId, + Schedule: create.Schedule, + Script: create.Script, + } + + err = services.CreateHook( + ctx, + h.db, + hook, + ) + if err != nil { + return err + } + + err = services.CreateOrUpdateHookSchedule(ctx, h.temporal, hook) + if err != nil { + return err + } + + return c.Redirect(http.StatusSeeOther, "/settings/hooks") +} diff --git a/internal/server/services/hook.go b/internal/server/services/hook.go new file mode 100644 index 0000000..02f3548 --- /dev/null +++ b/internal/server/services/hook.go @@ -0,0 +1,164 @@ +package services + +import ( + "context" + "log" + "time" + + "github.com/jmoiron/sqlx" + "github.com/mentos1386/zdravko/database/models" + internaltemporal "github.com/mentos1386/zdravko/internal/temporal" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/temporal" +) + +func getHookScheduleId(id string) string { + return "hook-" + id +} + +func CountHooks(ctx context.Context, db *sqlx.DB) (int, error) { + var count int + err := db.GetContext(ctx, &count, "SELECT COUNT(*) FROM hooks") + return count, err +} + +func GetHookState(ctx context.Context, temporal client.Client, id string) (models.HookState, error) { + schedule := temporal.ScheduleClient().GetHandle(ctx, getHookScheduleId(id)) + + description, err := schedule.Describe(ctx) + if err != nil { + return models.HookStateUnknown, err + } + + if description.Schedule.State.Paused { + return models.HookStatePaused, nil + } + + return models.HookStateActive, nil +} + +func SetHookState(ctx context.Context, temporal client.Client, id string, state models.HookState) error { + schedule := temporal.ScheduleClient().GetHandle(ctx, getHookScheduleId(id)) + + if state == models.HookStateActive { + return schedule.Unpause(ctx, client.ScheduleUnpauseOptions{Note: "Unpaused by user"}) + } + + if state == models.HookStatePaused { + return schedule.Pause(ctx, client.SchedulePauseOptions{Note: "Paused by user"}) + } + + return nil +} + +func CreateHook(ctx context.Context, db *sqlx.DB, hook *models.Hook) error { + _, err := db.NamedExecContext(ctx, + `INSERT INTO hooks (id, name, script, schedule) + VALUES (:id, :name, :script, :schedule)`, + hook, + ) + return err +} + +func UpdateHook(ctx context.Context, db *sqlx.DB, hook *models.Hook) error { + _, err := db.NamedExecContext(ctx, + `UPDATE hooks SET script=:script, schedule=:schedule WHERE id=:id`, + hook, + ) + return err +} + +func DeleteHook(ctx context.Context, db *sqlx.DB, id string) error { + _, err := db.ExecContext(ctx, + "DELETE FROM hooks WHERE id=$1", + id, + ) + return err +} + +func GetHook(ctx context.Context, db *sqlx.DB, id string) (*models.Hook, error) { + hook := &models.Hook{} + err := db.GetContext(ctx, hook, + "SELECT * FROM hooks WHERE id=$1", + id, + ) + return hook, err +} + +func GetHooks(ctx context.Context, db *sqlx.DB) ([]*models.Hook, error) { + hooks := []*models.Hook{} + err := db.SelectContext(ctx, &hooks, + "SELECT * FROM hooks ORDER BY name", + ) + return hooks, err +} + +func DeleteHookSchedule(ctx context.Context, t client.Client, id string) error { + schedule := t.ScheduleClient().GetHandle(ctx, getHookScheduleId(id)) + return schedule.Delete(ctx) +} + +func CreateOrUpdateHookSchedule( + ctx context.Context, + t client.Client, + hook *models.Hook, +) error { + log.Println("Creating or Updating Hook Schedule") + + args := make([]interface{}, 1) + args[0] = internaltemporal.WorkflowHookParam{ + Script: hook.Script, + HookId: hook.Id, + } + + options := client.ScheduleOptions{ + ID: getHookScheduleId(hook.Id), + Spec: client.ScheduleSpec{ + CronExpressions: []string{hook.Schedule}, + Jitter: time.Second * 10, + }, + Action: &client.ScheduleWorkflowAction{ + ID: getHookScheduleId(hook.Id), + Workflow: internaltemporal.WorkflowHookName, + Args: args, + TaskQueue: internaltemporal.TEMPORAL_SERVER_QUEUE, + RetryPolicy: &temporal.RetryPolicy{ + MaximumAttempts: 3, + }, + }, + } + + schedule := t.ScheduleClient().GetHandle(ctx, getHookScheduleId(hook.Id)) + + // If exists, we update + _, err := schedule.Describe(ctx) + if err == nil { + err = schedule.Update(ctx, client.ScheduleUpdateOptions{ + DoUpdate: func(input client.ScheduleUpdateInput) (*client.ScheduleUpdate, error) { + return &client.ScheduleUpdate{ + Schedule: &client.Schedule{ + Spec: &options.Spec, + Action: options.Action, + Policy: input.Description.Schedule.Policy, + State: input.Description.Schedule.State, + }, + }, nil + }, + }) + if err != nil { + return err + } + } else { + schedule, err = t.ScheduleClient().Create(ctx, options) + if err != nil { + return err + } + } + + err = schedule.Trigger(ctx, client.ScheduleTriggerOptions{}) + if err != nil { + return err + } + + return nil +} diff --git a/internal/server/services/hook_history.go b/internal/server/services/hook_history.go new file mode 100644 index 0000000..1f6b32d --- /dev/null +++ b/internal/server/services/hook_history.go @@ -0,0 +1,105 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/mentos1386/zdravko/internal/temporal" + "go.temporal.io/api/enums/v1" + "go.temporal.io/api/workflowservice/v1" + "go.temporal.io/sdk/client" +) + +type HookHistory struct { + HookId string + Status string + Duration time.Duration + StartTime time.Time + EndTime time.Time + WorkerGroupName string + Note string +} + +func GetLastNHookHistory(ctx context.Context, t client.Client, n int32) ([]*HookHistory, error) { + var hookHistory []*HookHistory + + response, err := t.ListWorkflow(ctx, &workflowservice.ListWorkflowExecutionsRequest{ + PageSize: n, + }) + if err != nil { + return hookHistory, err + } + + executions := response.GetExecutions() + + for _, execution := range executions { + scheduleId := string(execution.GetSearchAttributes().GetIndexedFields()["TemporalScheduledById"].Data) + + // Remove the quotes around the hookId and the prefix. + hookId := scheduleId[len("\"hook-") : len(scheduleId)-1] + + var result temporal.WorkflowHookResult + if execution.Status != enums.WORKFLOW_EXECUTION_STATUS_RUNNING { + workflowRun := t.GetWorkflow(ctx, execution.GetExecution().GetWorkflowId(), execution.GetExecution().GetRunId()) + err := workflowRun.Get(ctx, &result) + if err != nil { + return nil, err + } + } + + hookHistory = append(hookHistory, &HookHistory{ + HookId: hookId, + Duration: execution.CloseTime.AsTime().Sub(execution.StartTime.AsTime()), + StartTime: execution.StartTime.AsTime(), + EndTime: execution.CloseTime.AsTime(), + Status: execution.Status.String(), + WorkerGroupName: execution.GetTaskQueue(), + Note: result.Note, + }) + } + + return hookHistory, nil +} + +func GetHookHistoryForHook(ctx context.Context, t client.Client, hookId string) ([]*HookHistory, error) { + var hookHistory []*HookHistory + + response, err := t.ListWorkflow(ctx, &workflowservice.ListWorkflowExecutionsRequest{ + PageSize: 10, + Query: fmt.Sprintf(`TemporalScheduledById = "%s"`, getScheduleId(hookId)), + }) + if err != nil { + return hookHistory, err + } + + executions := response.GetExecutions() + + for _, execution := range executions { + scheduleId := string(execution.GetSearchAttributes().GetIndexedFields()["TemporalScheduledById"].Data) + + // Remove the quotes around the hookId and the prefix. + hookId := scheduleId[len("\"hook-") : len(scheduleId)-1] + + var result temporal.WorkflowHookResult + if execution.Status != enums.WORKFLOW_EXECUTION_STATUS_RUNNING { + workflowRun := t.GetWorkflow(ctx, execution.GetExecution().GetWorkflowId(), execution.GetExecution().GetRunId()) + err := workflowRun.Get(ctx, &result) + if err != nil { + return nil, err + } + } + + hookHistory = append(hookHistory, &HookHistory{ + HookId: hookId, + Duration: execution.CloseTime.AsTime().Sub(execution.StartTime.AsTime()), + StartTime: execution.StartTime.AsTime(), + EndTime: execution.CloseTime.AsTime(), + Status: execution.Status.String(), + WorkerGroupName: execution.GetTaskQueue(), + Note: result.Note, + }) + } + + return hookHistory, nil +} diff --git a/internal/temporal/workflow_hook.go b/internal/temporal/workflow_hook.go new file mode 100644 index 0000000..938867f --- /dev/null +++ b/internal/temporal/workflow_hook.go @@ -0,0 +1,13 @@ +package temporal + +type WorkflowHookParam struct { + Script string + Filter string + HookId string +} + +type WorkflowHookResult struct { + Note string +} + +const WorkflowHookName = "HOOK_WORKFLOW" diff --git a/pkg/server/routes.go b/pkg/server/routes.go index 1043238..01bb26b 100644 --- a/pkg/server/routes.go +++ b/pkg/server/routes.go @@ -83,6 +83,15 @@ func Routes( settings.GET("/checks/:id/disable", h.SettingsChecksDisableGET) settings.GET("/checks/:id/enable", h.SettingsChecksEnableGET) + settings.GET("/hooks", h.SettingsHooksGET) + settings.GET("/hooks/create", h.SettingsHooksCreateGET) + settings.POST("/hooks/create", h.SettingsHooksCreatePOST) + settings.GET("/hooks/:id", h.SettingsHooksDescribeGET) + settings.POST("/hooks/:id", h.SettingsHooksDescribePOST) + settings.GET("/hooks/:id/delete", h.SettingsHooksDescribeDELETE) + settings.GET("/hooks/:id/disable", h.SettingsHooksDisableGET) + settings.GET("/hooks/:id/enable", h.SettingsHooksEnableGET) + settings.GET("/notifications", h.SettingsNotificationsGET) settings.GET("/worker-groups", h.SettingsWorkerGroupsGET) diff --git a/web/templates/pages/settings_hooks.tmpl b/web/templates/pages/settings_hooks.tmpl new file mode 100644 index 0000000..05460f8 --- /dev/null +++ b/web/templates/pages/settings_hooks.tmpl @@ -0,0 +1,103 @@ +{{ define "settings" }} + {{ $description := "Hooks can be used to monitor CronJobs by making sure they execute at specified intervals, for alert integrations and heartbeat monitoring to notify when requests stop coming for certain time periods." }} + + {{ $length := len .Hooks }} + {{ if eq $length 0 }} +
+ {{ else }} +Name | +Targets | +State | +Schedule | +Action | +
---|---|---|---|---|
+ {{ .Name }} + | ++ 3 + | ++ {{ if eq .State "ACTIVE" }} + + ACTIVE + + {{ else if eq .State "PAUSED" }} + + PAUSED + + {{ else }} + + UNKNOWN + + {{ end }} + | ++ {{ .Schedule }} + | ++ Details + | +
+ Pausing the hook will stop it from executing. This can be useful in + cases of expected downtime. Or when the hook is not needed anymore. +
+ {{ if eq .Hook.State "ACTIVE" }} + Pause + {{ else if eq .Hook.State "PAUSED" }} + Resume + {{ end }} +Permanently delete this hook.
+ Delete +Hook ID | +Status | +Started At | +Ended At | +Duration | +Note | +|
---|---|---|---|---|---|---|
{{ .HookId }} | ++ + {{ .Status }}... + + | +{{ .StartTime.Format "2006-01-02 15:04:05" }} | ++ | + | + | |
{{ .HookId }} | ++ + {{ .Status }} + + | ++ + {{ .WorkerGroupName }} + + | +{{ .StartTime.Format "2006-01-02 15:04:05" }} | +{{ .EndTime.Format "2006-01-02 15:04:05" }} | +{{ DurationRoundMillisecond .Duration }} | +{{ .Note }} | +