Compare commits

..

7 commits

30 changed files with 1116 additions and 85 deletions

View file

@ -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.
![Screenshot](docs/screenshot-index.png)
Demo is available at https://zdravko.mnts.dev. More screenshots in the [docs folder](docs/).
# Development
@ -36,11 +39,10 @@ Demo is available at https://zdravko.mnts.dev.
```sh
# Configure
# You will need to configure an SSO provider
# This can be github for example.
# This can be github for example:.
cp example.env .env
# Generate JWT key
# Copy the values to your .env
just generate-jwt-key
# Start development environment

1
build/.dockerignore Normal file
View file

@ -0,0 +1 @@
deploy/

View file

@ -30,6 +30,10 @@ EXPOSE 8223
EXPOSE 7233
# Volume to persist sqlite databases
# as well as the keyvalue database.
ENV SQLITE_DATABASE_PATH=/data/zdravko.db
ENV TEMPORAL_DATABASE_PATH=/data/temporal.db
ENV KEYVALUE_DATABASE_PATH=/data/keyvalue.db
VOLUME /data
ENV DATABASE_PATH=/data/zdravko.db

27
database/models/checks.go Normal file
View file

@ -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
}

20
database/models/hooks.go Normal file
View file

@ -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"`
}

View file

@ -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"`
}

48
database/models/target.go Normal file
View file

@ -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"`
}

View file

@ -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;

View file

@ -1,5 +1,3 @@
version: '3.8'
volumes:
server_data:
temporal_data:

Binary file not shown.

After

Width:  |  Height:  |  Size: 988 KiB

BIN
docs/screenshot-checks.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

BIN
docs/screenshot-index.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 KiB

BIN
docs/screenshot-targets.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

View file

@ -80,6 +80,9 @@ check: |
http.get(metadata.spec.url);
}
hook: |
// TODO: Implement hook example
filter: |
target.metadata.kind == "Http" && target.metadata.spec.url != ""

View file

@ -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,

View file

@ -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")
}

View file

@ -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
}

View file

@ -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
}

View file

@ -0,0 +1,13 @@
package temporal
type WorkflowHookParam struct {
Script string
Filter string
HookId string
}
type WorkflowHookResult struct {
Note string
}
const WorkflowHookName = "HOOK_WORKFLOW"

View file

@ -3,7 +3,7 @@ set shell := ["devbox", "run"]
# Load dotenv
set dotenv-load
import 'deploy.just'
import 'deploy/Justfile'
# Load public and private keys
export JWT_PRIVATE_KEY := `cat jwt.private.pem || echo ""`

View file

@ -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)

View file

@ -1065,6 +1065,12 @@ video {
cursor: pointer;
}
.select-none {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
.grid-flow-col {
grid-auto-flow: column;
}

View file

@ -39,7 +39,6 @@
{{ template "main" . }}
<div class="container mx-auto">
<footer class="text-center text-gray-600 text-xs mt-8 mb-4">
Powered by
<a class="hover:underline" href="https://zdravko.mnts.dev">Zdravko</a>
-
<a

View file

@ -114,7 +114,7 @@
{{ $group }}
</h2>
<svg
class="feather h-6 w-6 overflow-visible self-center transition-all duration-300"
class="select-none feather h-6 w-6 overflow-visible self-center transition-all duration-300"
>
<use href="/static/icons/feather-sprite.svg#chevron-right" />
</svg>

View file

@ -0,0 +1,103 @@
{{ define "settings" }}
{{ $description := "Hooks can be used to monitor <strong>CronJobs</strong> by making sure they execute at specified intervals, for <strong>alert integrations</strong> and <strong>heartbeat monitoring</strong> to notify when requests stop coming for certain time periods." }}
{{ $length := len .Hooks }}
{{ if eq $length 0 }}
<div class="py-8 px-4 mx-auto max-w-screen-xl text-center lg:py-16">
<h1
class="mb-4 text-2xl font-extrabold tracking-tight leading-none text-gray-900 md:text-3xl lg:text-4xl"
>
There are no hooks yet.
</h1>
<p
class="mb-8 text-l font-normal text-gray-700 lg:text-l sm:px-8 lg:px-40"
>
{{ $description }}
</p>
<div class="flex flex-col gap-4 sm:flex-row sm:justify-center">
<a
href="/settings/hooks/create"
class="inline-flex justify-center items-center py-3 px-5 text-base font-medium text-center text-white rounded-lg bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300"
>
Create First Hook
<svg class="feather ml-1 h-5 w-5 overflow-visible">
<use href="/static/icons/feather-sprite.svg#plus" />
</svg>
</a>
</div>
</div>
{{ else }}
<section>
<table>
<caption>
List of Hooks
<div class="mt-1 gap-4 grid grid-cols-1 md:grid-cols-[1fr,20%]">
<p>
{{ $description }}
</p>
<a
href="/settings/hooks/create"
class="h-min inline-flex justify-center items-center py-2 px-4 text-sm font-medium text-center text-white rounded-lg bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300"
>
Create New
<svg class="feather h-5 w-5 overflow-visible">
<use href="/static/icons/feather-sprite.svg#plus" />
</svg>
</a>
</div>
</caption>
<thead class="text-xs text-gray-700 uppercase bg-gray-50">
<tr>
<th scope="col">Name</th>
<th scope="col">Targets</th>
<th scope="col">State</th>
<th scope="col">Schedule</th>
<th scope="col">Action</th>
</tr>
</thead>
<tbody>
{{ range $hooks := .Hooks }}
<tr>
<th scope="row">
{{ .Name }}
</th>
<td>
<span
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800"
>3</span
>
</td>
<td>
{{ if eq .State "ACTIVE" }}
<span
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800"
>
ACTIVE
</span>
{{ else if eq .State "PAUSED" }}
<span
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800"
>
PAUSED
</span>
{{ else }}
<span
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800"
>
UNKNOWN
</span>
{{ end }}
</td>
<td>
{{ .Schedule }}
</td>
<td>
<a href="/settings/hooks/{{ .Id }}" class="link">Details</a>
</td>
</tr>
{{ end }}
</tbody>
</table>
</section>
{{ end }}
{{ end }}

View file

@ -0,0 +1,95 @@
{{ define "settings" }}
<section class="p-5">
<form action="/settings/hooks/create" method="post">
<label for="name">Name</label>
<input type="text" name="name" id="name" placeholder="HTTP GET Request" />
<p>Name of the hook can be anything.</p>
<label for="workergroups">Worker Groups</label>
<input
type="text"
name="workergroups"
id="workergroups"
placeholder="europe asia"
required
/>
<p>
Worker groups are used to distribute the hook to specific workers. Space
is a separator between groups.
</p>
<label for="schedule">Schedule</label>
<input
type="text"
name="schedule"
id="schedule"
placeholder="@every 1m"
value="@every 1m"
required
/>
<p>
Schedule is a cron expression that defines when the hook should be
executed.
<br />
You can also use <code>@every [interval]</code> where interval is a
duration like 5m, 1h, 60s. Or use <code>@hourly</code>,
<code>@daily</code>, <code>@weekly</code>, <code>@monthly</code>,
<code>@yearly</code>.
</p>
<label for="script">Script</label>
<textarea required id="script" name="script" class="sm:col-span-2 h-96">
{{ ScriptUnescapeString .ExampleScript }}</textarea
>
<div
id="editor-script"
class="hidden sm:col-span-2 block w-full h-96 rounded-lg border border-gray-300 overflow-hidden"
></div>
<p class="sm:col-span-2">
Script is what determines the status of a service. You can read more
about it on
<a target="_blank" href="https://k6.io/docs/using-k6/http-requests/"
>k6 documentation</a
>.
</p>
<button type="submit" onclick="save()">Create</button>
</form>
</section>
<script src="/static/monaco/vs/loader.js"></script>
<script>
const items = [{ name: "script", language: "javascript" }];
function save() {
for (const { name } of items) {
const elem = window.editors[name].getValue();
document.getElementById(name).value = elem;
}
}
window.editors = {};
for (const { name, language, options = {} } of items) {
const textarea = document.getElementById(name);
const editor = document.getElementById("editor-" + name);
editor.classList.remove("hidden");
textarea.hidden = true;
require.config({ paths: { vs: "/static/monaco/vs" } });
require(["vs/editor/editor.main"], function () {
window.editors[name] = monaco.editor.create(editor, {
value: textarea.value,
language: language,
minimap: { enabled: false },
codeLens: false,
contextmenu: false,
scrollBeyondLastLine: false,
wordWrap: "on",
...options,
});
const resizeObserver = new ResizeObserver((entries) => {
window.editors[name].layout();
});
resizeObserver.observe(editor);
});
}
</script>
{{ end }}

View file

@ -0,0 +1,204 @@
{{ define "settings" }}
<section class="p-5">
<form action="/settings/hooks/{{ .Hook.Id }}" method="post">
<h2>Configuration</h2>
<label for="workergroups">Worker Groups</label>
<input
type="text"
name="workergroups"
id="workergroups"
value="{{ range .Hook.WorkerGroups }}{{ . }}{{ end }}"
required
/>
<p>Worker groups are used to distribute the hook to specific workers.</p>
<label for="schedule">Schedule</label>
<input
type="text"
name="schedule"
id="schedule"
value="{{ .Hook.Schedule }}"
required
/>
<p>
Schedule is a cron expression that defines when the hook should be
executed.
<br />
You can also use <code>@every [interval]</code> where interval is a
duration like 5m, 1h, 60s. Or use <code>@hourly</code>,
<code>@daily</code>, <code>@weekly</code>, <code>@monthly</code>,
<code>@yearly</code>.
</p>
<label for="script">Script</label>
<textarea required id="script" name="script" class="sm:col-span-2 h-96">
{{ ScriptUnescapeString .Hook.Script }}</textarea
>
<div
id="editor-script"
class="hidden sm:col-span-2 block w-full h-96 rounded-lg border border-gray-300 overflow-hidden"
></div>
<p class="sm:col-span-2">
Script is what determines the status of a service. You can read more
about it on
<a target="_blank" href="https://k6.io/docs/using-k6/http-requests/"
>k6 documentation</a
>.
</p>
<button type="submit" onclick="save()">Save</button>
</form>
</section>
<div class="flex md:flex-row flex-col gap-4 h-min">
<section class="p-5 flex-1">
<h2 class="mb-2 flex flex-row gap-2">
State
{{ if eq .Hook.State "ACTIVE" }}
<span
class="self-center h-fit w-fit px-2 text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800"
>
ACTIVE
</span>
{{ else if eq .Hook.State "PAUSED" }}
<span
class="self-center h-fit w-fit px-2 text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800"
>
PAUSED
</span>
{{ end }}
</h2>
<p class="text-sm mb-2">
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.
</p>
{{ if eq .Hook.State "ACTIVE" }}
<a
class="block text-center py-2.5 px-5 me-2 mb-2 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100"
href="/settings/hooks/{{ .Hook.Id }}/disable"
>Pause</a
>
{{ else if eq .Hook.State "PAUSED" }}
<a
class="block text-center py-2.5 px-5 me-2 mb-2 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100"
href="/settings/hooks/{{ .Hook.Id }}/enable"
>Resume</a
>
{{ end }}
</section>
<section class="p-2 flex-1 border-4 border-red-300">
<h2 class="mb-2">Danger Zone</h2>
<p class="text-sm mb-2">Permanently delete this hook.</p>
<a
class="block text-center focus:outline-none text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2"
href="/settings/hooks/{{ .Hook.Id }}/delete"
>Delete</a
>
</section>
</div>
<section>
<table>
<caption>
History
<p>Last 10 executions of hook script.</p>
</caption>
<thead>
<tr>
<th>Hook ID</th>
<th>Status</th>
<th>Started At</th>
<th>Ended At</th>
<th>Duration</th>
<th>Note</th>
</tr>
</thead>
<tbody>
{{ range .History }}
{{ if eq .Status "Running" }}
<tr>
<td>{{ .HookId }}</td>
<td>
<span
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800"
>
{{ .Status }}...
</span>
</td>
<td>{{ .StartTime.Format "2006-01-02 15:04:05" }}</td>
<td></td>
<td></td>
<td class="whitespace-normal"></td>
</tr>
{{ else }}
<tr>
<td>{{ .HookId }}</td>
<td>
<span
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full
{{ if eq .Status "Completed" }}
bg-purple-100 text-purple-800
{{ else }}
bg-red-100 text-red-800
{{ end }}
"
>
{{ .Status }}
</span>
</td>
<td>
<span
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800"
>
{{ .WorkerGroupName }}
</span>
</td>
<td>{{ .StartTime.Format "2006-01-02 15:04:05" }}</td>
<td>{{ .EndTime.Format "2006-01-02 15:04:05" }}</td>
<td>{{ DurationRoundMillisecond .Duration }}</td>
<td class="whitespace-normal">{{ .Note }}</td>
</tr>
{{ end }}
{{ end }}
</tbody>
</table>
</section>
<script src="/static/monaco/vs/loader.js"></script>
<script>
const items = [{ name: "script", language: "javascript" }];
function save() {
for (const { name } of items) {
const elem = window.editors[name].getValue();
document.getElementById(name).value = elem;
}
}
window.editors = {};
for (const { name, language, options = {} } of items) {
const textarea = document.getElementById(name);
const editor = document.getElementById("editor-" + name);
editor.classList.remove("hidden");
textarea.hidden = true;
require.config({ paths: { vs: "/static/monaco/vs" } });
require(["vs/editor/editor.main"], function () {
window.editors[name] = monaco.editor.create(editor, {
value: textarea.value,
language: language,
minimap: { enabled: false },
codeLens: false,
contextmenu: false,
scrollBeyondLastLine: false,
wordWrap: "on",
...options,
});
const resizeObserver = new ResizeObserver((entries) => {
window.editors[name].layout();
});
resizeObserver.observe(editor);
});
}
</script>
{{ end }}