mirror of
https://github.com/mentos1386/zdravko.git
synced 2024-11-21 15:26:29 +00:00
feat: initial hooks boilerplate and updated screenshots
This commit is contained in:
parent
c51bed6cb5
commit
1ef49c444d
22 changed files with 1101 additions and 77 deletions
11
README.md
11
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
|
||||
|
||||
|
|
27
database/models/checks.go
Normal file
27
database/models/checks.go
Normal 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
20
database/models/hooks.go
Normal 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"`
|
||||
}
|
|
@ -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
48
database/models/target.go
Normal 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"`
|
||||
}
|
30
database/sqlite/migrations/2024-05-29_hooks.sql
Normal file
30
database/sqlite/migrations/2024-05-29_hooks.sql
Normal 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;
|
BIN
docs/screenshot-checks-describe.png
Normal file
BIN
docs/screenshot-checks-describe.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 988 KiB |
BIN
docs/screenshot-checks.png
Normal file
BIN
docs/screenshot-checks.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 302 KiB |
BIN
docs/screenshot-index.png
Normal file
BIN
docs/screenshot-index.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 326 KiB |
BIN
docs/screenshot-settings.png
Normal file
BIN
docs/screenshot-settings.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 581 KiB |
BIN
docs/screenshot-targets.png
Normal file
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 |
|
@ -80,6 +80,9 @@ check: |
|
|||
http.get(metadata.spec.url);
|
||||
}
|
||||
|
||||
hook: |
|
||||
// TODO: Implement hook example
|
||||
|
||||
filter: |
|
||||
target.metadata.kind == "Http" && target.metadata.spec.url != ""
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
271
internal/server/handlers/settings_hooks.go
Normal file
271
internal/server/handlers/settings_hooks.go
Normal 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")
|
||||
}
|
164
internal/server/services/hook.go
Normal file
164
internal/server/services/hook.go
Normal 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
|
||||
}
|
105
internal/server/services/hook_history.go
Normal file
105
internal/server/services/hook_history.go
Normal 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
|
||||
}
|
13
internal/temporal/workflow_hook.go
Normal file
13
internal/temporal/workflow_hook.go
Normal 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"
|
|
@ -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)
|
||||
|
|
103
web/templates/pages/settings_hooks.tmpl
Normal file
103
web/templates/pages/settings_hooks.tmpl
Normal 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 }}
|
95
web/templates/pages/settings_hooks_create.tmpl
Normal file
95
web/templates/pages/settings_hooks_create.tmpl
Normal 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 }}
|
204
web/templates/pages/settings_hooks_describe.tmpl
Normal file
204
web/templates/pages/settings_hooks_describe.tmpl
Normal 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 }}
|
Loading…
Reference in a new issue