feat(index): working history page

This commit is contained in:
Tine 2024-02-22 17:29:17 +01:00
parent bff8f2d027
commit 92e1958588
Signed by: mentos1386
SSH key fingerprint: SHA256:MNtTsLbihYaWF8j1fkOHfkKNlnN1JQfxEU/rBU8nCGw
13 changed files with 340 additions and 169 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,38 +1,96 @@
{{define "daily"}}
<div class="justify-self-end text-sm">{{ .HistoryDaily.Uptime }}% uptime</div>
<div class="grid gap-px col-span-2 grid-flow-col h-8 rounded overflow-hidden">
{{ range .HistoryDaily.History }}
{{ if eq . "SUCCESS" }}
<div class="bg-green-400 hover:bg-green-500 flex-auto"></div>
{{ else if eq . "FAILURE" }}
<div class="bg-red-400 hover:bg-red-500 flex-auto"></div>
{{ else }}
<div class="bg-gray-400 hover:bg-gray-500 flex-auto"></div>
{{ end }}
{{ end }}
</div>
<div class="text-slate-500 justify-self-start text-sm">90 days ago</div>
<div class="text-slate-500 justify-self-end text-sm">Today</div>
{{end}}
{{define "hourly"}}
<div class="justify-self-end text-sm">{{ .HistoryHourly.Uptime }}% uptime</div>
<div class="grid gap-px col-span-2 grid-flow-col h-8 rounded overflow-hidden">
{{ range .HistoryHourly.History }}
{{ if eq . "SUCCESS" }}
<div class="bg-green-400 hover:bg-green-500 flex-auto"></div>
{{ else if eq . "FAILURE" }}
<div class="bg-red-400 hover:bg-red-500 flex-auto"></div>
{{ else }}
<div class="bg-gray-400 hover:bg-gray-500 flex-auto"></div>
{{ end }}
{{ end }}
</div>
<div class="text-slate-500 justify-self-start text-sm">48 hours ago</div>
<div class="text-slate-500 justify-self-end text-sm">Now</div>
{{end}}
{{define "main"}}
{{ if eq .HealthchecksLength 0 }}
<section>
<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 healthchecks yet.
</h1>
<p class="mb-8 text-l font-normal text-gray-500 lg:text-l sm:px-8 lg:px-40">
Create a healthcheck to monitor your services and get notified when they are down.
</p>
<div class="flex flex-col space-y-4 sm:flex-row sm:justify-center sm:space-y-0">
<a href="/settings/healthchecks/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 Healthcheck
<svg class="feather ml-1 h-5 w-5 overflow-visible"><use href="/static/icons/feather-sprite.svg#plus" /></svg>
</a>
</div>
</div>
</section>
{{ else }}
{{ if eq .Status "SUCCESS" }}
<div class="flex flex-col items-center">
<svg class="feather h-20 w-20 rounded-full bg-green-300 p-4 overflow-visible"><use href="/static/icons/feather-sprite.svg#check" /></svg>
<h1 class="text-slate-500">All services are online</h1>
<h1 class="text-slate-500 mt-4">All services are online</h1>
<p class="text-slate-500 text-sm">Last updated on Feb 10 at 10:55am UTC</p>
</div>
<div class="flex flex-col items-center mt-20">
{{ else }}
<div class="flex flex-col items-center">
<svg class="feather h-20 w-20 rounded-full bg-red-300 p-4 overflow-visible"><use href="/static/icons/feather-sprite.svg#alert-triangle" /></svg>
<h3 class="text-slate-500">Degraded performance</h3>
<h3 class="text-slate-500 mt-4">Degraded performance</h3>
<p class="text-slate-500 text-sm">Last updated on Feb 10 at 10:55am UTC</p>
</div>
{{ end }}
<div class="healthchecks">
<div class="grid grid-cols-2 gap-2">
<p class="text-l font-normal text-gray-500">Healthchecks</p>
<div class="inline-flex rounded-md shadow-sm justify-self-end time-range" role="group">
<a href="/?time-range=90days" class="{{ if eq .TimeRange "90days" }}active{{end}}" type="button">90 Days</a>
<a href="/?time-range=48hours" class="{{ if eq .TimeRange "48hours" }}active{{end}}" type="button">48 Hours</a>
</div>
</div>
{{ range .HealthChecks }}
<div class="grid grid-cols-2 gap-2">
<div class="flex items-center">
{{ if .Healthy }}
{{ if eq .Status "SUCCESS" }}
<span class="flex w-3 h-3 me-2 bg-green-400 rounded-full"></span>
{{ else }}
{{ else if eq .Status "FAILURE" }}
<span class="flex w-3 h-3 me-2 bg-red-400 rounded-full"></span>
{{ end }}
<p>{{ .Domain }}</p>
</div>
<div class="justify-self-end text-sm">{{ .Uptime }}% uptime</div>
<div class="grid gap-px col-span-2 grid-flow-col h-8 rounded overflow-hidden">
{{ range .History }}
{{ if . }}
<div class="bg-green-400 hover:bg-green-500 flex-auto"></div>
{{ else }}
<div class="bg-red-400 hover:bg-red-500 flex-auto"></div>
<span class="flex w-3 h-3 me-2 bg-gray-400 rounded-full"></span>
{{ end }}
{{ end }}
</div>
<div class="text-slate-500 justify-self-start text-sm">90 days ago</div>
<div class="text-slate-500 justify-self-end text-sm">Today</div>
<p>{{ .Name }}</p>
</div>
{{ if eq $.TimeRange "90days" }}
{{ template "daily" . }}
{{ else }}
{{ template "hourly" . }}
{{ end }}
</div>
{{ end }}
</div>
{{ end }}
{{end}}

View file

@ -55,6 +55,8 @@ export default function () {
value: script,
language: 'javascript',
minimap: { enabled: false },
codeLens: false,
contextmenu: false,
});
const divElem = document.getElementById('editor');