diff --git a/README.md b/README.md index e2e3d87..35b2537 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Mostly just a project to test [temporal.io](https://temporal.io/). - [ ] History and working home page. - [ ] Edit/Delete operations for healthchecks and workers. - [ ] CronJob Healthchecks (via webhooks). + - [ ] Notifications (webhooks, slack, etc). ![Screenshot](docs/screenshot.png) Demo is available at https://zdravko.mnts.dev. diff --git a/deploy/fly.toml b/deploy/fly.toml index 83f73ae..3cea3de 100644 --- a/deploy/fly.toml +++ b/deploy/fly.toml @@ -16,8 +16,8 @@ primary_region = 'waw' ROOT_URL = 'https://zdravko.mnts.dev' TEMPORAL_SERVER_HOST = 'server.process.zdravko.internal:7233' - TEMPORAL_DATABASE_PATH = '/data/temporal-1.db' - DATABASE_PATH = '/data/zdravko-1.db' + TEMPORAL_DATABASE_PATH = '/data/temporal-2.db' + DATABASE_PATH = '/data/zdravko-2.db' [processes] server = '--temporal --server' diff --git a/internal/handlers/index.go b/internal/handlers/index.go index 81e452a..5a97d56 100644 --- a/internal/handlers/index.go +++ b/internal/handlers/index.go @@ -1,54 +1,172 @@ package handlers import ( - "math/rand" + "context" "net/http" + "time" + "code.tjo.space/mentos1386/zdravko/internal/models" + "code.tjo.space/mentos1386/zdravko/internal/services" "code.tjo.space/mentos1386/zdravko/web/templates/components" "github.com/labstack/echo/v4" ) type IndexData struct { *components.Base - HealthChecks []*HealthCheck + HealthChecks []*HealthCheck + HealthchecksLength int + TimeRange string + Status string } type HealthCheck struct { - Domain string - Healthy bool - Uptime string - History []bool + Name string + Status string + HistoryDaily *History + HistoryHourly *History } -func newMockHealthCheck(domain string) *HealthCheck { - randBool := func() bool { - return rand.Intn(2) == 1 +type History struct { + History []string + Uptime int +} + +func getDay(date time.Time) string { + return date.Format("2006-01-02") +} + +func getHour(date time.Time) string { + return date.Format("2006-01-02T15:04") +} + +func getDailyHistory(history []models.HealthcheckHistory) *History { + numDays := 90 + historyDailyMap := map[string]string{} + numOfSuccess := 0 + numTotal := 1 + + for i := 0; i < numDays; i++ { + day := getDay(time.Now().AddDate(0, 0, -i).Truncate(time.Hour * 24)) + historyDailyMap[day] = models.HealthcheckUnknown } - var history []bool - for i := 0; i < 90; i++ { - history = append(history, randBool()) + for _, _history := range history { + day := getDay(_history.CreatedAt.Truncate(time.Hour * 24)) + + // skip if day is not in the last 90 days + if _, ok := historyDailyMap[day]; !ok { + continue + } + + numTotal++ + if _history.Status == models.HealthcheckSuccess { + numOfSuccess++ + } + + // skip if day is already set to failure + if historyDailyMap[day] == models.HealthcheckFailure { + continue + } + + historyDailyMap[day] = _history.Status } - return &HealthCheck{ - Domain: domain, - Healthy: randBool(), - Uptime: "100", - History: history, + historyDaily := make([]string, numDays) + for i := 0; i < numDays; i++ { + day := getDay(time.Now().AddDate(0, 0, -numDays+i+1).Truncate(time.Hour * 24)) + historyDaily[i] = historyDailyMap[day] + } + + return &History{ + History: historyDaily, + Uptime: 100 * numOfSuccess / numTotal, + } +} + +func getHourlyHistory(history []models.HealthcheckHistory) *History { + numHours := 48 + historyHourlyMap := map[string]string{} + numOfSuccess := 0 + numTotal := 1 + + for i := 0; i < numHours; i++ { + hour := getHour(time.Now().Add(time.Hour * time.Duration(-i)).Truncate(time.Hour)) + historyHourlyMap[hour] = models.HealthcheckUnknown + } + + for _, _history := range history { + hour := getHour(_history.CreatedAt.Truncate(time.Hour)) + + // skip if day is not in the last 90 days + if _, ok := historyHourlyMap[hour]; !ok { + continue + } + + numTotal++ + if _history.Status == models.HealthcheckSuccess { + numOfSuccess++ + } + + // skip if day is already set to failure + if historyHourlyMap[hour] == models.HealthcheckFailure { + continue + } + + historyHourlyMap[hour] = _history.Status + } + + historyHourly := make([]string, numHours) + for i := 0; i < numHours; i++ { + hour := getHour(time.Now().Add(time.Hour * time.Duration(-numHours+i+1)).Truncate(time.Hour)) + historyHourly[i] = historyHourlyMap[hour] + } + + return &History{ + History: historyHourly, + Uptime: 100 * numOfSuccess / numTotal, } } func (h *BaseHandler) Index(c echo.Context) error { + ctx := context.Background() + healthchecks, err := services.GetHealthchecksWithHistory(ctx, h.query) + if err != nil { + return err + } + + timeRange := c.QueryParam("time-range") + if timeRange != "48hours" && timeRange != "90days" { + timeRange = "90days" + } + + overallStatus := "SUCCESS" + + healthchecksWithHistory := make([]*HealthCheck, len(healthchecks)) + for i, healthcheck := range healthchecks { + historyDaily := getDailyHistory(healthcheck.History) + historyHourly := getHourlyHistory(healthcheck.History) + + status := historyDaily.History[89] + if status != models.HealthcheckSuccess { + overallStatus = status + } + + healthchecksWithHistory[i] = &HealthCheck{ + Name: healthcheck.Name, + Status: status, + HistoryDaily: historyDaily, + HistoryHourly: historyHourly, + } + } + return c.Render(http.StatusOK, "index.tmpl", &IndexData{ Base: &components.Base{ NavbarActive: GetPageByTitle(Pages, "Status"), Navbar: Pages, }, - HealthChecks: []*HealthCheck{ - newMockHealthCheck("example.com"), - newMockHealthCheck("example.org"), - newMockHealthCheck("example.net"), - newMockHealthCheck("foo.example.net"), - }, + HealthChecks: healthchecksWithHistory, + HealthchecksLength: len(healthchecks), + TimeRange: timeRange, + Status: overallStatus, }) } diff --git a/internal/models/models.go b/internal/models/models.go index 6c1ae5a..83290ef 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -20,6 +20,13 @@ type Worker struct { Status string } +const ( + HealthcheckSuccess string = "SUCCESS" + HealthcheckFailure string = "FAILURE" + HealthcheckError string = "ERROR" + HealthcheckUnknown string = "UNKNOWN" +) + type Healthcheck struct { gorm.Model Slug string `gorm:"unique"` @@ -30,7 +37,7 @@ type Healthcheck struct { Script string `validate:"required"` - History []HealthcheckHistory `gorm:"foreignKey:ID"` + History []HealthcheckHistory `gorm:"foreignKey:Healthcheck"` } type Cronjob struct { @@ -43,8 +50,8 @@ type Cronjob struct { type HealthcheckHistory struct { gorm.Model - Healthcheck Healthcheck `gorm:"foreignkey:ID"` - Status string // SUCCESS, FAIL, ERROR + Healthcheck uint + Status string Note string } diff --git a/internal/models/query/healthcheck_histories.gen.go b/internal/models/query/healthcheck_histories.gen.go index cfdeb19..860b805 100644 --- a/internal/models/query/healthcheck_histories.gen.go +++ b/internal/models/query/healthcheck_histories.gen.go @@ -31,25 +31,9 @@ func newHealthcheckHistory(db *gorm.DB, opts ...gen.DOOption) healthcheckHistory _healthcheckHistory.CreatedAt = field.NewTime(tableName, "created_at") _healthcheckHistory.UpdatedAt = field.NewTime(tableName, "updated_at") _healthcheckHistory.DeletedAt = field.NewField(tableName, "deleted_at") + _healthcheckHistory.Healthcheck = field.NewUint(tableName, "healthcheck") _healthcheckHistory.Status = field.NewString(tableName, "status") - _healthcheckHistory.Healthcheck = healthcheckHistoryHasOneHealthcheck{ - db: db.Session(&gorm.Session{}), - - RelationField: field.NewRelation("Healthcheck", "models.Healthcheck"), - History: struct { - field.RelationField - Healthcheck struct { - field.RelationField - } - }{ - RelationField: field.NewRelation("Healthcheck.History", "models.HealthcheckHistory"), - Healthcheck: struct { - field.RelationField - }{ - RelationField: field.NewRelation("Healthcheck.History.Healthcheck", "models.Healthcheck"), - }, - }, - } + _healthcheckHistory.Note = field.NewString(tableName, "note") _healthcheckHistory.fillFieldMap() @@ -64,8 +48,9 @@ type healthcheckHistory struct { CreatedAt field.Time UpdatedAt field.Time DeletedAt field.Field + Healthcheck field.Uint Status field.String - Healthcheck healthcheckHistoryHasOneHealthcheck + Note field.String fieldMap map[string]field.Expr } @@ -86,7 +71,9 @@ func (h *healthcheckHistory) updateTableName(table string) *healthcheckHistory { h.CreatedAt = field.NewTime(table, "created_at") h.UpdatedAt = field.NewTime(table, "updated_at") h.DeletedAt = field.NewField(table, "deleted_at") + h.Healthcheck = field.NewUint(table, "healthcheck") h.Status = field.NewString(table, "status") + h.Note = field.NewString(table, "note") h.fillFieldMap() @@ -115,13 +102,14 @@ func (h *healthcheckHistory) GetFieldByName(fieldName string) (field.OrderExpr, } func (h *healthcheckHistory) fillFieldMap() { - h.fieldMap = make(map[string]field.Expr, 6) + h.fieldMap = make(map[string]field.Expr, 7) h.fieldMap["id"] = h.ID h.fieldMap["created_at"] = h.CreatedAt h.fieldMap["updated_at"] = h.UpdatedAt h.fieldMap["deleted_at"] = h.DeletedAt + h.fieldMap["healthcheck"] = h.Healthcheck h.fieldMap["status"] = h.Status - + h.fieldMap["note"] = h.Note } func (h healthcheckHistory) clone(db *gorm.DB) healthcheckHistory { @@ -134,84 +122,6 @@ func (h healthcheckHistory) replaceDB(db *gorm.DB) healthcheckHistory { return h } -type healthcheckHistoryHasOneHealthcheck struct { - db *gorm.DB - - field.RelationField - - History struct { - field.RelationField - Healthcheck struct { - field.RelationField - } - } -} - -func (a healthcheckHistoryHasOneHealthcheck) Where(conds ...field.Expr) *healthcheckHistoryHasOneHealthcheck { - if len(conds) == 0 { - return &a - } - - exprs := make([]clause.Expression, 0, len(conds)) - for _, cond := range conds { - exprs = append(exprs, cond.BeCond().(clause.Expression)) - } - a.db = a.db.Clauses(clause.Where{Exprs: exprs}) - return &a -} - -func (a healthcheckHistoryHasOneHealthcheck) WithContext(ctx context.Context) *healthcheckHistoryHasOneHealthcheck { - a.db = a.db.WithContext(ctx) - return &a -} - -func (a healthcheckHistoryHasOneHealthcheck) Session(session *gorm.Session) *healthcheckHistoryHasOneHealthcheck { - a.db = a.db.Session(session) - return &a -} - -func (a healthcheckHistoryHasOneHealthcheck) Model(m *models.HealthcheckHistory) *healthcheckHistoryHasOneHealthcheckTx { - return &healthcheckHistoryHasOneHealthcheckTx{a.db.Model(m).Association(a.Name())} -} - -type healthcheckHistoryHasOneHealthcheckTx struct{ tx *gorm.Association } - -func (a healthcheckHistoryHasOneHealthcheckTx) Find() (result *models.Healthcheck, err error) { - return result, a.tx.Find(&result) -} - -func (a healthcheckHistoryHasOneHealthcheckTx) Append(values ...*models.Healthcheck) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Append(targetValues...) -} - -func (a healthcheckHistoryHasOneHealthcheckTx) Replace(values ...*models.Healthcheck) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Replace(targetValues...) -} - -func (a healthcheckHistoryHasOneHealthcheckTx) Delete(values ...*models.Healthcheck) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Delete(targetValues...) -} - -func (a healthcheckHistoryHasOneHealthcheckTx) Clear() error { - return a.tx.Clear() -} - -func (a healthcheckHistoryHasOneHealthcheckTx) Count() int64 { - return a.tx.Count() -} - type healthcheckHistoryDo struct{ gen.DO } type IHealthcheckHistoryDo interface { diff --git a/internal/models/query/healthchecks.gen.go b/internal/models/query/healthchecks.gen.go index 54002d0..878978f 100644 --- a/internal/models/query/healthchecks.gen.go +++ b/internal/models/query/healthchecks.gen.go @@ -40,19 +40,6 @@ func newHealthcheck(db *gorm.DB, opts ...gen.DOOption) healthcheck { db: db.Session(&gorm.Session{}), RelationField: field.NewRelation("History", "models.HealthcheckHistory"), - Healthcheck: struct { - field.RelationField - History struct { - field.RelationField - } - }{ - RelationField: field.NewRelation("History.Healthcheck", "models.Healthcheck"), - History: struct { - field.RelationField - }{ - RelationField: field.NewRelation("History.Healthcheck.History", "models.HealthcheckHistory"), - }, - }, } _healthcheck.fillFieldMap() @@ -152,13 +139,6 @@ type healthcheckHasManyHistory struct { db *gorm.DB field.RelationField - - Healthcheck struct { - field.RelationField - History struct { - field.RelationField - } - } } func (a healthcheckHasManyHistory) Where(conds ...field.Expr) *healthcheckHasManyHistory { diff --git a/internal/services/healthcheck.go b/internal/services/healthcheck.go index 2c35ba2..30d8ef2 100644 --- a/internal/services/healthcheck.go +++ b/internal/services/healthcheck.go @@ -3,6 +3,7 @@ package services import ( "context" "log" + "time" "code.tjo.space/mentos1386/zdravko/internal/models" "code.tjo.space/mentos1386/zdravko/internal/models/query" @@ -17,12 +18,20 @@ func CreateHealthcheck(ctx context.Context, db *gorm.DB, healthcheck *models.Hea } func GetHealthcheck(ctx context.Context, q *query.Query, slug string) (*models.Healthcheck, error) { - log.Println("GetHealthcheck") return q.Healthcheck.WithContext(ctx).Where( q.Healthcheck.Slug.Eq(slug), + q.Healthcheck.DeletedAt.IsNull(), ).First() } +func GetHealthchecksWithHistory(ctx context.Context, q *query.Query) ([]*models.Healthcheck, error) { + return q.Healthcheck.WithContext(ctx).Preload( + q.Healthcheck.History, + ).Where( + q.Healthcheck.DeletedAt.IsNull(), + ).Find() +} + func StartHealthcheck(ctx context.Context, t client.Client, healthcheck *models.Healthcheck) error { log.Println("Starting Healthcheck Workflow") @@ -38,6 +47,7 @@ func StartHealthcheck(ctx context.Context, t client.Client, healthcheck *models. ID: id + "-" + group, Spec: client.ScheduleSpec{ CronExpressions: []string{healthcheck.Schedule}, + Jitter: time.Second * 10, }, Action: &client.ScheduleWorkflowAction{ ID: id + "-" + group, diff --git a/internal/temporal/temporal.go b/internal/temporal/temporal.go index b3a36c2..5ab0e11 100644 --- a/internal/temporal/temporal.go +++ b/internal/temporal/temporal.go @@ -31,7 +31,7 @@ func ConnectServerToTemporal(cfg *config.ServerConfig) (client.Client, error) { provider := &AuthHeadersProvider{token} // Try to connect to the Temporal Server - return retry.Retry(10, 3*time.Second, func() (client.Client, error) { + return retry.Retry(10, 2*time.Second, func() (client.Client, error) { return client.Dial(client.Options{ HostPort: cfg.Temporal.ServerHost, HeadersProvider: provider, diff --git a/internal/workflows/healthcheck.go b/internal/workflows/healthcheck.go index 24ff485..43da0ed 100644 --- a/internal/workflows/healthcheck.go +++ b/internal/workflows/healthcheck.go @@ -4,6 +4,7 @@ import ( "time" "code.tjo.space/mentos1386/zdravko/internal/activities" + "code.tjo.space/mentos1386/zdravko/internal/models" "go.temporal.io/sdk/workflow" ) @@ -28,9 +29,9 @@ func (w *Workflows) HealthcheckWorkflowDefinition(ctx workflow.Context, param He return err } - status := "failure" + status := models.HealthcheckFailure if healthcheckResult.Success { - status = "success" + status = models.HealthcheckSuccess } historyParam := activities.HealtcheckAddToHistoryParam{ diff --git a/web/static/css/main.css b/web/static/css/main.css index 1e9a030..663b447 100644 --- a/web/static/css/main.css +++ b/web/static/css/main.css @@ -57,3 +57,16 @@ .healthchecks > div:not(:last-child) { @apply mb-3; } +.healthchecks .time-range > a { + @apply font-medium text-sm px-2.5 py-1; + @apply text-black bg-gray-100 hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-300; +} +.healthchecks .time-range > a.active { + @apply text-white bg-blue-700 hover:bg-blue-800; +} +.healthchecks .time-range > a:first-child { + @apply rounded-s-lg; +} +.healthchecks .time-range > a:last-child { + @apply rounded-e-lg; +} diff --git a/web/static/css/tailwind.css b/web/static/css/tailwind.css index 183154b..b9ae389 100644 --- a/web/static/css/tailwind.css +++ b/web/static/css/tailwind.css @@ -818,6 +818,10 @@ video { border-radius: 0.5rem; } +.rounded-md { + border-radius: 0.375rem; +} + .border { border-width: 1px; } @@ -837,6 +841,11 @@ video { background-color: rgb(243 244 246 / var(--tw-bg-opacity)); } +.bg-gray-400 { + --tw-bg-opacity: 1; + background-color: rgb(156 163 175 / var(--tw-bg-opacity)); +} + .bg-gray-50 { --tw-bg-opacity: 1; background-color: rgb(249 250 251 / var(--tw-bg-opacity)); @@ -1053,6 +1062,12 @@ video { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); } +.shadow-sm { + --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + .navbar { margin-top: 2.5rem; display: flex; @@ -1197,6 +1212,57 @@ video { margin-bottom: 0.75rem; } +.healthchecks .time-range > a { + padding-left: 0.625rem; + padding-right: 0.625rem; + padding-top: 0.25rem; + padding-bottom: 0.25rem; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 500; + --tw-bg-opacity: 1; + background-color: rgb(243 244 246 / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: rgb(0 0 0 / var(--tw-text-opacity)); +} + +.healthchecks .time-range > a:hover { + --tw-bg-opacity: 1; + background-color: rgb(209 213 219 / var(--tw-bg-opacity)); +} + +.healthchecks .time-range > a:focus { + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); + --tw-ring-opacity: 1; + --tw-ring-color: rgb(147 197 253 / var(--tw-ring-opacity)); +} + +.healthchecks .time-range > a.active { + --tw-bg-opacity: 1; + background-color: rgb(29 78 216 / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +.healthchecks .time-range > a.active:hover { + --tw-bg-opacity: 1; + background-color: rgb(30 64 175 / var(--tw-bg-opacity)); +} + +.healthchecks .time-range > a:first-child { + border-start-start-radius: 0.5rem; + border-end-start-radius: 0.5rem; +} + +.healthchecks .time-range > a:last-child { + border-start-end-radius: 0.5rem; + border-end-end-radius: 0.5rem; +} + .odd\:bg-white:nth-child(odd) { --tw-bg-opacity: 1; background-color: rgb(255 255 255 / var(--tw-bg-opacity)); @@ -1212,6 +1278,11 @@ video { background-color: rgb(30 64 175 / var(--tw-bg-opacity)); } +.hover\:bg-gray-500:hover { + --tw-bg-opacity: 1; + background-color: rgb(107 114 128 / var(--tw-bg-opacity)); +} + .hover\:bg-green-500:hover { --tw-bg-opacity: 1; background-color: rgb(34 197 94 / var(--tw-bg-opacity)); diff --git a/web/templates/pages/index.tmpl b/web/templates/pages/index.tmpl index b071dd2..b4994c3 100644 --- a/web/templates/pages/index.tmpl +++ b/web/templates/pages/index.tmpl @@ -1,38 +1,96 @@ +{{define "daily"}} +
+ Create a healthcheck to monitor your services and get notified when they are down. +
+ +Last updated on Feb 10 at 10:55am UTC
Last updated on Feb 10 at 10:55am UTC
{{ .Domain }}
-{{ .Name }}
+