Openstatus www.openstatus.dev

chore: assertions package (#727)

* chore: assertions package

* wip: status code assertion

* fix: build

* ๐Ÿš€ Golang checker asssertion

* ๐Ÿš€ Golang checker asssertion

* wip: headers assertions

* chore: response details

* fix: build

* fix: remove unused import

* refactor: status code badge

* ๐Ÿš€ Golang checker asssertion

* refactor: tb version

* ๐Ÿš€ Golang checker asssertion

* ๐Ÿš€ Golang checker asssertion

* fix: server monitor status

* feat: abort timeout toast

* fix: missing default error message

* style: update border to fit secondary button

* ๐Ÿ”ƒ add retry

* ๐Ÿงช fix test

---------

Co-authored-by: Thibault Le Ouay <thibaultleouay@gmail.Com>

authored by

Maximilian Kaske
Thibault Le Ouay
and committed by
GitHub
0a12e3fb 374e5f9f

+3273 -319
+3
.vscode/settings.json
··· 19 19 }, 20 20 "[xml]": { 21 21 "editor.defaultFormatter": "redhat.vscode-xml" 22 + }, 23 + "[go]": { 24 + "editor.defaultFormatter": "golang.go" 22 25 } 23 26 }
+73 -8
apps/checker/cmd/main.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "encoding/json" 5 6 "errors" 6 7 "fmt" 7 8 "net/http" ··· 12 13 13 14 "github.com/gin-gonic/gin" 14 15 "github.com/openstatushq/openstatus/apps/checker" 16 + "github.com/openstatushq/openstatus/apps/checker/pkg/assertions" 15 17 "github.com/openstatushq/openstatus/apps/checker/pkg/logger" 16 18 "github.com/openstatushq/openstatus/apps/checker/pkg/tinybird" 17 19 "github.com/openstatushq/openstatus/apps/checker/request" ··· 85 87 } 86 88 defer requestClient.CloseIdleConnections() 87 89 90 + // Might be a more efficient way to do it 91 + var i interface{} = req.RawAssertions 92 + jsonBytes, _ := json.Marshal(i) 93 + assertionAsString := string(jsonBytes) 94 + if assertionAsString == "null" { 95 + assertionAsString = "" 96 + } 97 + 88 98 var called int 89 99 op := func() error { 90 100 called++ ··· 93 103 return fmt.Errorf("unable to ping: %w", err) 94 104 } 95 105 statusCode := statusCode(res.StatusCode) 106 + 107 + var isSuccessfull bool = true 108 + if len(req.RawAssertions) > 0 { 109 + for _, a := range req.RawAssertions { 110 + var assert request.Assertion 111 + err = json.Unmarshal(a, &assert) 112 + if err != nil { 113 + // handle error 114 + return fmt.Errorf("unable to unmarshal assertion: %w", err) 115 + 116 + } 117 + switch assert.AssertionType { 118 + case request.AssertionHeaders: 119 + var target assertions.HeaderTarget 120 + if err := json.Unmarshal(a, &target); err != nil { 121 + return fmt.Errorf("unable to unmarshal IntTarget: %w", err) 122 + } 123 + isSuccessfull = isSuccessfull && target.HeaderEvaluate(res.Headers) 124 + 125 + fmt.Println("assertion type", assert.AssertionType) 126 + case request.AssertionTextBody: 127 + fmt.Println("assertion type", assert.AssertionType) 128 + case request.AssertionStatus: 129 + var target assertions.StatusTarget 130 + if err := json.Unmarshal(a, &target); err != nil { 131 + return fmt.Errorf("unable to unmarshal IntTarget: %w", err) 132 + } 133 + isSuccessfull = isSuccessfull && target.StatusEvaluate(int64(res.StatusCode)) 134 + case request.AssertionJsonBody: 135 + fmt.Println("assertion type", assert.AssertionType) 136 + default: 137 + fmt.Println("โš ๏ธ Not Handled assertion type", assert.AssertionType) 138 + } 139 + } 140 + } else { 141 + isSuccessfull = statusCode.IsSuccessful() 142 + } 143 + 96 144 // let's retry at least once if the status code is not successful. 97 - if !statusCode.IsSuccessful() && called < 2 { 145 + if !isSuccessfull && called < 2 { 98 146 return fmt.Errorf("unable to ping: %v with status %v", res, res.StatusCode) 99 147 } 100 148 101 - if !statusCode.IsSuccessful() && req.Status == "active" { 149 + // it's in error if not successful 150 + if isSuccessfull { 151 + res.Error = 0 152 + } else { 153 + res.Error = 1 154 + } 155 + 156 + res.Assertions = assertionAsString 157 + if !isSuccessfull && req.Status == "active" { 102 158 // Q: Why here we do not check if the status was previously active? 103 159 checker.UpdateStatus(ctx, checker.UpdateData{ 104 160 MonitorId: req.MonitorID, ··· 110 166 }) 111 167 } 112 168 113 - if req.Status == "error" && statusCode.IsSuccessful() { 169 + if req.Status == "error" && isSuccessfull { 114 170 // Q: Why here we check the data before updating the status in this scenario? 115 171 checker.UpdateStatus(ctx, checker.UpdateData{ 116 172 MonitorId: req.MonitorID, ··· 120 176 CronTimestamp: req.CronTimestamp, 121 177 }) 122 178 } 179 + 123 180 if err := tinybirdClient.SendEvent(ctx, res); err != nil { 124 181 log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") 125 182 } ··· 136 193 Timestamp: req.CronTimestamp, 137 194 MonitorID: req.MonitorID, 138 195 WorkspaceID: req.WorkspaceID, 196 + Error: 1, 197 + Assertions: assertionAsString, 139 198 }); err != nil { 140 199 log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") 141 200 } ··· 157 216 158 217 router.GET("/health", func(c *gin.Context) { 159 218 c.JSON(http.StatusOK, gin.H{"message": "pong", "fly_region": flyRegion}) 160 - return 161 219 }) 162 220 163 221 router.POST("/ping/:region", func(c *gin.Context) { ··· 205 263 c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) 206 264 return 207 265 } 208 - 209 - res, err := checker.SinglePing(c.Request.Context(), requestClient, req) 210 - if err != nil { 266 + var res checker.Response 267 + op := func() error { 268 + r, err := checker.SinglePing(c.Request.Context(), requestClient, req) 269 + if err != nil { 270 + return fmt.Errorf("unable to ping: %w", err) 271 + } 272 + res = r 273 + return nil 274 + } 275 + if err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), 3)); err != nil { 211 276 c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) 212 277 return 213 278 } 214 279 c.JSON(http.StatusOK, res) 215 - return 216 280 }) 281 + 217 282 httpServer := &http.Server{ 218 283 Addr: fmt.Sprintf("0.0.0.0:%s", env("PORT", "8080")), 219 284 Handler: router,
+2
apps/checker/ping.go
··· 30 30 Message string `json:"message,omitempty"` 31 31 Timing string `json:"timing,omitempty"` 32 32 Headers string `json:"headers,omitempty"` 33 + Error uint8 `json:"error"` 34 + Assertions string `json:"assertions"` 33 35 } 34 36 35 37 type Timing struct {
+115
apps/checker/pkg/assertions/assertions.go
··· 1 + package assertions 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "strings" 7 + 8 + "github.com/openstatushq/openstatus/apps/checker/request" 9 + ) 10 + 11 + type BodyString struct { 12 + AssertionType request.AssertionType `json:"type"` 13 + Comparator request.StringComparator `json:"compare"` 14 + Target string `json:"target"` 15 + } 16 + 17 + type StatusTarget struct { 18 + AssertionType request.AssertionType `json:"type"` 19 + Comparator request.NumberComparator `json:"compare"` 20 + Target int64 `json:"target"` 21 + } 22 + 23 + type HeaderTarget struct { 24 + AssertionType request.AssertionType `json:"type"` 25 + Comparator request.StringComparator `json:"compare"` 26 + Target string `json:"target"` 27 + Key string `json:"key"` 28 + } 29 + 30 + type StringTargetType struct { 31 + Comparator request.StringComparator 32 + Target string 33 + } 34 + 35 + func (target StringTargetType) StringEvaluate(s string) bool { 36 + switch target.Comparator { 37 + case request.StringContains: 38 + return strings.Contains(target.Target, s) 39 + case request.StringNotContains: 40 + return !strings.Contains(target.Target, s) 41 + case request.StringEmpty: 42 + return s == "" 43 + case request.StringNotEmpty: 44 + return s != "" 45 + case request.StringEquals: 46 + return s == target.Target 47 + case request.StringNotEquals: 48 + return s != target.Target 49 + case request.StringGreaterThan: 50 + return s > target.Target 51 + case request.StringGreaterThanEqual: 52 + return s >= target.Target 53 + case request.StringLowerThan: 54 + return s < target.Target 55 + case request.StringLowerThanEqual: 56 + return s <= target.Target 57 + } 58 + 59 + return false 60 + } 61 + 62 + func (target HeaderTarget) HeaderEvaluate(s string) bool { 63 + 64 + fmt.Println(s) 65 + 66 + headers := map[string]interface{}{} 67 + 68 + if err := json.Unmarshal([]byte(s), &headers); err != nil { 69 + panic(err) 70 + } 71 + v, found := headers[target.Key] 72 + if !found { 73 + return false 74 + } 75 + 76 + t := StringTargetType{Comparator: target.Comparator, Target: target.Target} 77 + // convert all headers to array 78 + str := fmt.Sprintf("%v", v) 79 + 80 + return t.StringEvaluate(str) 81 + } 82 + 83 + func (target StatusTarget) StatusEvaluate(value int64) bool { 84 + 85 + switch target.Comparator { 86 + case request.NumberEquals: 87 + if target.Target != value { 88 + return false 89 + } 90 + case request.NumberNotEquals: 91 + if target.Target == value { 92 + return false 93 + } 94 + 95 + case request.NumberGreaterThan: 96 + if target.Target >= value { 97 + return false 98 + } 99 + case request.NumberGreaterThanEqual: 100 + if target.Target > value { 101 + return false 102 + } 103 + case request.NumberLowerThan: 104 + if target.Target <= value { 105 + return false 106 + } 107 + case request.NumberLowerThanEqual: 108 + if target.Target < value { 109 + return false 110 + } 111 + default: 112 + fmt.Println("something strange ", target) 113 + } 114 + return true 115 + }
+119
apps/checker/pkg/assertions/assertions_test.go
··· 1 + package assertions 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/openstatushq/openstatus/apps/checker/request" 7 + ) 8 + 9 + func TestIntTarget_IntEvaluate(t *testing.T) { 10 + type fields struct { 11 + AssertionType request.AssertionType 12 + Comparator request.NumberComparator 13 + Target int64 14 + } 15 + type args struct { 16 + value int64 17 + } 18 + tests := []struct { 19 + name string 20 + fields fields 21 + args args 22 + want bool 23 + }{ 24 + { 25 + name: "Equals true", fields: fields{Comparator: request.NumberEquals, Target: 200}, args: args{value: 200}, want: true, 26 + }, 27 + { 28 + name: "Equals false", fields: fields{Comparator: request.NumberEquals, Target: 200}, args: args{value: 201}, want: false, 29 + }, 30 + { 31 + name: "Not Equals true", fields: fields{Comparator: request.NumberNotEquals, Target: 200}, args: args{value: 201}, want: true, 32 + }, 33 + { 34 + name: "Not Equals false", fields: fields{Comparator: request.NumberNotEquals, Target: 200}, args: args{value: 200}, want: false, 35 + }, 36 + { 37 + name: "greater than true", fields: fields{Comparator: request.NumberGreaterThan, Target: 200}, args: args{value: 201}, want: true, 38 + }, 39 + { 40 + name: "greater than false 1", fields: fields{Comparator: request.NumberGreaterThan, Target: 200}, args: args{value: 200}, want: false, 41 + }, 42 + { 43 + name: "greater than false", fields: fields{Comparator: request.NumberGreaterThan, Target: 200}, args: args{value: 199}, want: false, 44 + }, 45 + { 46 + name: "greater than equal true", fields: fields{Comparator: request.NumberGreaterThanEqual, Target: 200}, args: args{value: 201}, want: true, 47 + }, 48 + { 49 + name: "greater than equal true 1", fields: fields{Comparator: request.NumberGreaterThanEqual, Target: 200}, args: args{value: 200}, want: true, 50 + }, 51 + { 52 + name: "greater than equal false", fields: fields{Comparator: request.NumberGreaterThanEqual, Target: 200}, args: args{value: 199}, want: false, 53 + }, 54 + { 55 + name: "lower than true", fields: fields{Comparator: request.NumberLowerThan, Target: 200}, args: args{value: 199}, want: true, 56 + }, 57 + { 58 + name: "lower than false 1", fields: fields{Comparator: request.NumberLowerThan, Target: 200}, args: args{value: 200}, want: false, 59 + }, 60 + { 61 + name: "lower than false", fields: fields{Comparator: request.NumberLowerThan, Target: 200}, args: args{value: 201}, want: false, 62 + }, 63 + { 64 + name: "lower than Equal true", fields: fields{Comparator: request.NumberLowerThanEqual, Target: 200}, args: args{value: 199}, want: true, 65 + }, 66 + { 67 + name: "lower than equal true 1", fields: fields{Comparator: request.NumberLowerThanEqual, Target: 200}, args: args{value: 200}, want: true, 68 + }, 69 + { 70 + name: "lower than equal false", fields: fields{Comparator: request.NumberLowerThan, Target: 200}, args: args{value: 201}, want: false, 71 + }, 72 + } 73 + for _, tt := range tests { 74 + t.Run(tt.name, func(t *testing.T) { 75 + target := StatusTarget{ 76 + AssertionType: tt.fields.AssertionType, 77 + Comparator: tt.fields.Comparator, 78 + Target: tt.fields.Target, 79 + } 80 + if got := target.StatusEvaluate(tt.args.value); got != tt.want { 81 + t.Errorf("IntTarget.IntEvaluate() = %v, want %v", got, tt.want) 82 + } 83 + }) 84 + } 85 + } 86 + 87 + func TestHeaderTarget_HeaderEvaluate(t *testing.T) { 88 + type fields struct { 89 + AssertionType request.AssertionType 90 + Comparator request.StringComparator 91 + Target string 92 + Key string 93 + } 94 + type args struct { 95 + s string 96 + } 97 + tests := []struct { 98 + name string 99 + fields fields 100 + args args 101 + want bool 102 + }{ 103 + {name: "Header 1", fields: fields{Comparator: request.StringEmpty, Target: "", Key: "headers1"}, args: args{s: `{"Content-Type":"text/plain;charset=UTF-8","Strict-Transport-Security":"max-age=3153600000","Vary":"Accept-Encoding"}`}, want: false}, 104 + {name: "Header 1", fields: fields{Comparator: request.StringEmpty, Target: "", Key: "headers1"}, args: args{s: `{"Content-Type":"text/plain;charset=UTF-8","Strict-Transport-Security":"max-age=3153600000","headers1":"Accept-Encoding"}`}, want: true}, 105 + } 106 + for _, tt := range tests { 107 + t.Run(tt.name, func(t *testing.T) { 108 + target := HeaderTarget{ 109 + AssertionType: tt.fields.AssertionType, 110 + Comparator: tt.fields.Comparator, 111 + Target: tt.fields.Target, 112 + Key: tt.fields.Key, 113 + } 114 + if got := target.HeaderEvaluate(tt.args.s); got != tt.want { 115 + t.Errorf("HeaderTarget.HeaderEvaluate() = %v, want %v", got, tt.want) 116 + } 117 + }) 118 + } 119 + }
+1 -1
apps/checker/pkg/tinybird/client.go
··· 37 37 } 38 38 39 39 q := requestURL.Query() 40 - q.Add("name", "ping_response__v7") 40 + q.Add("name", "ping_response__v8") 41 41 requestURL.RawQuery = q.Encode() 42 42 43 43 var payload bytes.Buffer
+1 -1
apps/checker/pkg/tinybird/client_test.go
··· 73 73 74 74 err := client.SendEvent(ctx, "event") 75 75 require.NoError(t, err) 76 - require.Equal(t, "https://api.tinybird.co/v0/events?name=ping_response__v7", url) 76 + require.Equal(t, "https://api.tinybird.co/v0/events?name=ping_response__v8", url) 77 77 }) 78 78 }
+45 -1
apps/checker/request/request.go
··· 1 1 package request 2 2 3 + import "encoding/json" 4 + 5 + type AssertionType string 6 + 7 + const ( 8 + AssertionHeaders AssertionType = "headers" 9 + AssertionTextBody AssertionType = "textBody" 10 + AssertionStatus AssertionType = "status" 11 + AssertionJsonBody AssertionType = "jsonBody" 12 + ) 13 + 14 + type StringComparator string 15 + 16 + const ( 17 + StringContains StringComparator = "contains" 18 + StringNotContains StringComparator = "not_contains" 19 + StringEquals StringComparator = "eq" 20 + StringNotEquals StringComparator = "not_eq" 21 + StringEmpty StringComparator = "empty" 22 + StringNotEmpty StringComparator = "not_empty" 23 + StringGreaterThan StringComparator = "gt" 24 + StringGreaterThanEqual StringComparator = "gte" 25 + StringLowerThan StringComparator = "lt" 26 + StringLowerThanEqual StringComparator = "lte" 27 + ) 28 + 29 + type NumberComparator string 30 + 31 + const ( 32 + NumberEquals NumberComparator = "eq" 33 + NumberNotEquals NumberComparator = "not_eq" 34 + NumberGreaterThan NumberComparator = "gt" 35 + NumberGreaterThanEqual NumberComparator = "gte" 36 + NumberLowerThan NumberComparator = "lt" 37 + NumberLowerThanEqual NumberComparator = "lte" 38 + ) 39 + 40 + type Assertion struct { 41 + AssertionType AssertionType `json:"type"` 42 + Comparator json.RawMessage `json:"compare"` 43 + RawTarget json.RawMessage `json:"target"` 44 + } 45 + 3 46 type CheckerRequest struct { 4 47 WorkspaceID string `json:"workspaceId"` 5 48 URL string `json:"url"` ··· 11 54 Key string `json:"key"` 12 55 Value string `json:"value"` 13 56 } `json:"headers,omitempty"` 14 - Status string `json:"status"` 57 + Status string `json:"status"` 58 + RawAssertions []json.RawMessage `json:"assertions,omitempty"` 15 59 } 16 60 17 61 type PingRequest struct {
+12 -11
apps/server/src/checker/index.ts
··· 4 4 import { and, db, eq, isNull, schema } from "@openstatus/db"; 5 5 import { incidentTable } from "@openstatus/db/src/schema"; 6 6 import { flyRegions } from "@openstatus/db/src/schema/monitors/constants"; 7 - import { selectMonitorSchema } from "@openstatus/db/src/schema/monitors/validation"; 7 + import { 8 + monitorStatusSchema, 9 + selectMonitorSchema, 10 + } from "@openstatus/db/src/schema/monitors/validation"; 8 11 import { Redis } from "@openstatus/upstash"; 9 12 10 13 import { env } from "../env"; ··· 28 31 statusCode: z.number().optional(), 29 32 region: z.enum(flyRegions), 30 33 cronTimestamp: z.number(), 31 - // status: z.enum(["active", "error"]), 34 + status: monitorStatusSchema, 32 35 }); 33 36 34 37 const result = payloadSchema.safeParse(json); 38 + 35 39 if (!result.success) { 36 40 return c.text("Unprocessable Entity", 422); 37 41 } 38 - const { monitorId, message, region, statusCode, cronTimestamp } = result.data; 42 + const { monitorId, message, region, statusCode, cronTimestamp, status } = 43 + result.data; 39 44 40 45 console.log(`๐Ÿ“ update monitor status ${JSON.stringify(result.data)}`); 41 46 ··· 59 64 .get(); 60 65 61 66 // if we are in error 62 - if (!statusCode || statusCode < 200 || statusCode > 300) { 63 - // create incident 67 + if (status === "error") { 64 68 // trigger alerting 65 69 await checkerAudit.publishAuditLog({ 66 70 id: `monitor:${monitorId}`, 67 71 action: "monitor.failed", 68 72 targets: [{ id: monitorId, type: "monitor" }], 69 - metadata: { 70 - region: region, 71 - statusCode: statusCode, 72 - message, 73 - }, 73 + metadata: { region, statusCode, message }, 74 74 }); 75 75 // We upsert the status of the monitor 76 76 await upsertMonitorStatus({ ··· 114 114 ), 115 115 ) 116 116 .get(); 117 + 117 118 if (incident === undefined) { 118 119 await db 119 120 .insert(incidentTable) ··· 135 136 } 136 137 } 137 138 // When the status is ok 138 - if (statusCode && statusCode >= 200 && statusCode < 300) { 139 + if (status === "active") { 139 140 await upsertMonitorStatus({ 140 141 monitorId: monitorId, 141 142 status: "active",
+1
apps/web/package.json
··· 16 16 "@hookform/resolvers": "3.3.1", 17 17 "@openstatus/analytics": "workspace:*", 18 18 "@openstatus/api": "workspace:*", 19 + "@openstatus/assertions": "workspace:*", 19 20 "@openstatus/db": "workspace:*", 20 21 "@openstatus/emails": "workspace:*", 21 22 "@openstatus/notification-discord": "workspace:*",
+1
apps/web/src/app/api/checker/cron/_cron.ts
··· 133 133 body: row.body, 134 134 headers: row.headers, 135 135 status: status, 136 + assertions: row.assertions, 136 137 }; 137 138 138 139 const newTask: google.cloud.tasks.v2beta3.ITask = {
+1
apps/web/src/app/api/checker/schema.ts
··· 11 11 url: z.string(), 12 12 cronTimestamp: z.number(), 13 13 status: z.enum(monitorStatus), 14 + assertions: z.string().nullable(), 14 15 }); 15 16 16 17 export type Payload = z.infer<typeof payloadSchema>;
+13
apps/web/src/app/api/test/timeout/route.ts
··· 1 + import { NextResponse } from "next/server"; 2 + 3 + import { wait } from "@/lib/utils"; 4 + 5 + export async function POST() { 6 + await wait(10_000); 7 + return NextResponse.json({ message: "Hello, World!" }); 8 + } 9 + 10 + export async function GET() { 11 + await wait(10_000); 12 + return NextResponse.json({ message: "Hello, World!" }); 13 + }
+4
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/_components/data-table-wrapper.tsx
··· 6 6 import { Suspense, use } from "react"; 7 7 import type { Row } from "@tanstack/react-table"; 8 8 9 + import * as assertions from "@openstatus/assertions"; 9 10 import type { OSTinybird } from "@openstatus/tinybird"; 10 11 11 12 import { ResponseDetailTabs } from "@/app/play/checker/[id]/_components/response-detail-tabs"; ··· 28 29 timestamp: number; 29 30 workspaceId: string; 30 31 cronTimestamp: number | null; 32 + error: boolean; 33 + assertions?: string | null; 31 34 }; 32 35 33 36 export function DataTableWrapper({ data }: { data: Monitor[] }) { ··· 85 88 timing={first.timing} 86 89 headers={first.headers} 87 90 message={first.message} 91 + assertions={assertions.deserialize(first.assertions || "[]")} 88 92 /> 89 93 </div> 90 94 );
+2 -2
apps/web/src/app/play/checker/[id]/_components/multi-region-table.tsx
··· 8 8 TableRow, 9 9 } from "@openstatus/ui"; 10 10 11 + import { StatusCodeBadge } from "@/components/monitor/status-code-badge"; 11 12 import type { RegionChecker } from "../utils"; 12 13 import { 13 14 getTimingPhases, ··· 15 16 latencyFormatter, 16 17 regionFormatter, 17 18 } from "../utils"; 18 - import { StatusBadge } from "./status-badge"; 19 19 20 20 // TBD: add the popover infos about timing details 21 21 ··· 45 45 {regionFormatter(region)} 46 46 </TableCell> 47 47 <TableCell> 48 - <StatusBadge statusCode={status} /> 48 + <StatusCodeBadge statusCode={status} /> 49 49 </TableCell> 50 50 <TableCell className="text-muted-foreground"> 51 51 <code>{latencyFormatter(dns)}</code>
+16 -2
apps/web/src/app/play/checker/[id]/_components/region-info.tsx
··· 1 + import { StatusCodeBadge } from "@/components/monitor/status-code-badge"; 1 2 import { 2 3 latencyFormatter, 3 4 regionFormatter, 4 5 timestampFormatter, 5 6 } from "../utils"; 6 7 import type { RegionChecker } from "../utils"; 7 - import { StatusBadge } from "./status-badge"; 8 8 9 9 export function RegionInfo({ 10 10 check, 11 + error, 11 12 }: { 12 13 check: Pick<RegionChecker, "region" | "time" | "latency" | "status">; 14 + error?: string; 13 15 }) { 14 16 return ( 15 17 <div className="grid grid-cols-5 gap-2 text-sm sm:grid-cols-9"> ··· 37 39 <p className="text-muted-foreground">Status:</p> 38 40 </div> 39 41 <div className="col-span-3 sm:col-span-6"> 40 - <StatusBadge statusCode={check.status} /> 42 + <StatusCodeBadge statusCode={check.status} /> 41 43 </div> 44 + {error ? ( 45 + <> 46 + <div className="col-span-2"> 47 + <p className="text-muted-foreground">Error:</p> 48 + </div> 49 + <div className="col-span-3 sm:col-span-6"> 50 + <p className="text-destructive font-medium before:content-['ยซ_'] after:content-['_ยป']"> 51 + {error} 52 + </p> 53 + </div> 54 + </> 55 + ) : null} 42 56 </div> 43 57 ); 44 58 }
+60
apps/web/src/app/play/checker/[id]/_components/response-assertion.tsx
··· 1 + import { 2 + numberCompareDictionary, 3 + stringCompareDictionary, 4 + } from "@openstatus/assertions"; 5 + import type { Assertion } from "@openstatus/assertions"; 6 + import { 7 + Table, 8 + TableBody, 9 + TableCaption, 10 + TableCell, 11 + TableHead, 12 + TableHeader, 13 + TableRow, 14 + } from "@openstatus/ui"; 15 + 16 + export function ResponseAssertion({ assertions }: { assertions: Assertion[] }) { 17 + return ( 18 + <Table> 19 + <TableCaption className="mt-2">Response Assertions</TableCaption> 20 + <TableHeader> 21 + <TableRow> 22 + <TableHead>Source</TableHead> 23 + <TableHead>Key</TableHead> 24 + <TableHead>Comparison</TableHead> 25 + <TableHead>Target</TableHead> 26 + </TableRow> 27 + </TableHeader> 28 + <TableBody> 29 + {assertions.map((a, i) => { 30 + if (a.schema.type === "status") { 31 + return ( 32 + <TableRow key={i}> 33 + <TableCell className="text-muted-foreground"> 34 + Status Code 35 + </TableCell> 36 + <TableCell /> 37 + <TableCell> 38 + {numberCompareDictionary[a.schema.compare]} 39 + </TableCell> 40 + <TableCell className="font-mono">{a.schema.target}</TableCell> 41 + </TableRow> 42 + ); 43 + } else if (a.schema.type === "header") { 44 + return ( 45 + <TableRow key={i}> 46 + <TableCell className="text-muted-foreground">Header</TableCell> 47 + <TableCell className="font-mono">{a.schema.key}</TableCell> 48 + <TableCell> 49 + {stringCompareDictionary[a.schema.compare]} 50 + </TableCell> 51 + <TableCell className="font-mono">{a.schema.target}</TableCell> 52 + </TableRow> 53 + ); 54 + } 55 + return null; 56 + })} 57 + </TableBody> 58 + </Table> 59 + ); 60 + }
+14
apps/web/src/app/play/checker/[id]/_components/response-detail-tabs.tsx
··· 1 + import type { Assertion } from "@openstatus/assertions"; 2 + 1 3 import { 2 4 Tabs, 3 5 TabsContent, ··· 5 7 TabsTrigger, 6 8 } from "@/components/dashboard/tabs"; 7 9 import type { Timing } from "../utils"; 10 + import { ResponseAssertion } from "./response-assertion"; 8 11 import { ResponseHeaderTable } from "./response-header-table"; 9 12 import { ResponseTimingTable } from "./response-timing-table"; 10 13 ··· 12 15 timing, 13 16 headers, 14 17 message, 18 + assertions, 15 19 }: { 16 20 timing: Timing | null; 17 21 headers: Record<string, string> | null; 18 22 message?: string | null; 23 + assertions?: Assertion[] | null; 19 24 }) { 20 25 return ( 21 26 <Tabs defaultValue="headers"> ··· 25 30 <TabsTrigger value="message" disabled={!message}> 26 31 Message 27 32 </TabsTrigger> 33 + <TabsTrigger 34 + value="assertions" 35 + disabled={!assertions || assertions.length === 0} 36 + > 37 + Assertions 38 + </TabsTrigger> 28 39 </TabsList> 29 40 <TabsContent value="headers"> 30 41 {headers ? <ResponseHeaderTable headers={headers} /> : null} ··· 41 52 </p> 42 53 </div> 43 54 ) : null} 55 + </TabsContent> 56 + <TabsContent value="assertions"> 57 + {assertions ? <ResponseAssertion {...{ assertions }} /> : null} 44 58 </TabsContent> 45 59 </Tabs> 46 60 );
+2 -2
apps/web/src/app/play/checker/[id]/_components/response-header-table.tsx
··· 29 29 <TableRow key={key}> 30 30 <TableCell className="group"> 31 31 <div className="flex items-center justify-between gap-4"> 32 - <code className="font-medium">{key}</code> 32 + <code className="break-all font-medium">{key}</code> 33 33 <CopyToClipboardButton 34 34 copyValue={key} 35 35 className="invisible group-hover:visible" ··· 38 38 </TableCell> 39 39 <TableCell className="group"> 40 40 <div className="flex items-center justify-between gap-4"> 41 - <code>{value}</code> 41 + <code className="break-all">{value}</code> 42 42 <CopyToClipboardButton 43 43 copyValue={value} 44 44 className="invisible group-hover:visible"
+4 -1
apps/web/src/app/play/checker/[id]/_components/status-badge.tsx apps/web/src/components/monitor/status-code-badge.tsx
··· 2 2 3 3 import { cn } from "@/lib/utils"; 4 4 5 - export function StatusBadge({ statusCode }: { statusCode: number }) { 5 + export function StatusCodeBadge({ statusCode }: { statusCode: number }) { 6 + const yellow = String(statusCode).startsWith("1"); 6 7 const green = String(statusCode).startsWith("2"); 7 8 const blue = String(statusCode).startsWith("3"); 8 9 const rose = ··· 17 18 blue, 18 19 "border-rose-500/20 bg-rose-500/10 text-rose-800 dark:text-rose-300": 19 20 rose, 21 + "border-yellow-500/20 bg-yellow-500/10 text-yellow-800 dark:text-yellow-300": 22 + yellow, 20 23 })} 21 24 > 22 25 {statusCode}
+17
apps/web/src/components/data-table/columns.tsx
··· 2 2 3 3 import type { ColumnDef } from "@tanstack/react-table"; 4 4 import { format } from "date-fns"; 5 + import { Check, X } from "lucide-react"; 5 6 import * as z from "zod"; 6 7 7 8 import type { Ping } from "@openstatus/tinybird"; ··· 17 18 import { DataTableStatusBadge } from "./data-table-status-badge"; 18 19 19 20 export const columns: ColumnDef<Ping>[] = [ 21 + { 22 + id: "state", 23 + cell: ({ row }) => { 24 + if (row.original.error) 25 + return ( 26 + <div className="max-w-max rounded-full bg-rose-500 p-1"> 27 + <X className="text-background h-3 w-3" /> 28 + </div> 29 + ); 30 + return ( 31 + <div className="max-w-max rounded-full bg-green-500 p-1"> 32 + <Check className="text-background h-3 w-3" /> 33 + </div> 34 + ); 35 + }, 36 + }, 20 37 { 21 38 accessorKey: "cronTimestamp", 22 39 header: ({ column }) => (
+12 -21
apps/web/src/components/data-table/data-table-status-badge.tsx
··· 1 1 import type { Ping } from "@openstatus/tinybird"; 2 2 import { Badge } from "@openstatus/ui"; 3 3 4 - import { cn } from "@/lib/utils"; 4 + import { StatusCodeBadge } from "../monitor/status-code-badge"; 5 5 6 6 export function DataTableStatusBadge({ 7 7 statusCode, 8 8 }: { 9 9 statusCode: Ping["statusCode"]; 10 10 }) { 11 - const isOk = String(statusCode).startsWith("2"); 12 - return ( 13 - <Badge 14 - variant="outline" 15 - className={cn( 16 - "px-2 py-0.5 text-xs", 17 - isOk 18 - ? "border-green-500/20 bg-green-500/10" 19 - : "border-red-500/20 bg-red-500/10", 20 - )} 21 - > 22 - {statusCode || "Error"} 23 - <div 24 - className={cn( 25 - "bg-foreground ml-1 h-1.5 w-1.5 rounded-full", 26 - isOk ? "bg-green-500" : "bg-red-500", 27 - )} 28 - /> 29 - </Badge> 30 - ); 11 + if (!statusCode) { 12 + return ( 13 + <Badge 14 + variant="outline" 15 + className="border-rose-500/20 bg-rose-500/10 text-rose-800 dark:text-rose-300" 16 + > 17 + Error 18 + </Badge> 19 + ); 20 + } 21 + return <StatusCodeBadge statusCode={statusCode} />; 31 22 }
+96 -22
apps/web/src/components/forms/monitor/form.tsx
··· 5 5 import { zodResolver } from "@hookform/resolvers/zod"; 6 6 import { useForm } from "react-hook-form"; 7 7 8 + import * as assertions from "@openstatus/assertions"; 8 9 import type { 9 10 InsertMonitor, 10 11 MonitorFlyRegion, ··· 24 25 TabsTrigger, 25 26 } from "@/components/dashboard/tabs"; 26 27 import { FailedPingAlertConfirmation } from "@/components/modals/failed-ping-alert-confirmation"; 27 - import { toastAction } from "@/lib/toast"; 28 + import { toast, toastAction } from "@/lib/toast"; 29 + import { formatDuration } from "@/lib/utils"; 28 30 import { api } from "@/trpc/client"; 29 31 import type { Writeable } from "@/types/utils"; 30 32 import { SaveButton } from "../shared/save-button"; 31 33 import { General } from "./general"; 34 + import { RequestTestButton } from "./request-test-button"; 35 + import { SectionAssertions } from "./section-assertions"; 32 36 import { SectionDanger } from "./section-danger"; 33 37 import { SectionNotifications } from "./section-notifications"; 34 38 import { SectionRequests } from "./section-requests"; ··· 44 48 pages?: Page[]; 45 49 nextUrl?: string; 46 50 } 51 + 52 + const ABORT_TIMEOUT = 7_000; // in ms 47 53 48 54 export function MonitorForm({ 49 55 defaultSection, ··· 54 60 tags, 55 61 nextUrl, 56 62 }: Props) { 63 + const _assertions = defaultValues?.assertions 64 + ? assertions.deserialize(defaultValues?.assertions).map((a) => a.schema) 65 + : []; 57 66 const form = useForm<InsertMonitor>({ 58 67 resolver: zodResolver(insertMonitorSchema), 59 68 defaultValues: { ··· 73 82 notifications: defaultValues?.notifications ?? [], 74 83 pages: defaultValues?.pages ?? [], 75 84 tags: defaultValues?.tags ?? [], 85 + statusAssertions: _assertions.filter((a) => a.type === "status") as any, // TS considers a.type === "header" 86 + headerAssertions: _assertions.filter((a) => a.type === "header") as any, // TS considers a.type === "status" 76 87 }, 77 88 }); 78 89 const router = useRouter(); ··· 102 113 const onSubmit = ({ ...props }: InsertMonitor) => { 103 114 startTransition(async () => { 104 115 try { 105 - // const pingResult = await pingEndpoint(); 106 - // const isOk = pingResult?.status >= 200 && pingResult?.status < 300; 107 - // if (!isOk) { 108 - // setPingFailed(true); 109 - // return; 110 - // } 116 + const { error } = await pingEndpoint(); 117 + if (error) { 118 + setPingFailed(true); 119 + toast.error(error); 120 + return; 121 + } 111 122 await handleDataUpdateOrInsertion(props); 112 123 } catch { 113 124 toastAction("error"); ··· 116 127 }; 117 128 118 129 const pingEndpoint = async (region?: MonitorFlyRegion) => { 119 - const { url, body, method, headers } = form.getValues(); 120 - const res = await fetch(`/api/checker/test`, { 121 - method: "POST", 122 - headers: new Headers({ 123 - "Content-Type": "application/json", 124 - }), 125 - body: JSON.stringify({ url, body, method, headers, region }), 126 - }); 127 - const data = (await res.json()) as RegionChecker; 128 - return data; 130 + const { url, body, method, headers, statusAssertions, headerAssertions } = 131 + form.getValues(); 132 + 133 + try { 134 + const res = await fetch(`/api/checker/test`, { 135 + method: "POST", 136 + headers: new Headers({ 137 + "Content-Type": "application/json", 138 + }), 139 + body: JSON.stringify({ url, body, method, headers, region }), 140 + signal: AbortSignal.timeout(ABORT_TIMEOUT), 141 + }); 142 + 143 + const as = assertions.deserialize( 144 + JSON.stringify([ 145 + ...(statusAssertions || []), 146 + ...(headerAssertions || []), 147 + ]), 148 + ); 149 + 150 + const data = (await res.json()) as RegionChecker; 151 + 152 + const _headers: Record<string, string> = {}; 153 + res.headers.forEach((value, key) => (_headers[key] = value)); 154 + 155 + if (as.length > 0) { 156 + for (const a of as) { 157 + const { success, message } = a.assert({ 158 + body: "", // data.body ?? "", 159 + header: data.headers ?? {}, 160 + status: data.status, 161 + }); 162 + if (!success) { 163 + return { data, error: `Assertion error: ${message}` }; 164 + } 165 + } 166 + } else { 167 + // default assertion if no assertions are provided 168 + if (res.status < 200 || res.status >= 300) { 169 + return { 170 + data, 171 + error: `Assertion error: The response status was not 2XX: ${data.status}.`, 172 + }; 173 + } 174 + } 175 + 176 + return { data, error: undefined }; 177 + } catch (error) { 178 + if (error instanceof Error && error.name === "AbortError") { 179 + return { 180 + error: `Abort error: request takes more then ${formatDuration( 181 + ABORT_TIMEOUT, 182 + )}.`, 183 + }; 184 + } 185 + return { 186 + error: "Something went wrong. Please try again.", 187 + }; 188 + } 129 189 }; 130 190 131 191 function onValueChange(value: string) { ··· 153 213 <TabsList> 154 214 <TabsTrigger value="request">Request</TabsTrigger> 155 215 <TabsTrigger value="scheduling">Scheduling</TabsTrigger> 216 + <TabsTrigger value="assertions"> 217 + Assertions{" "} 218 + {_assertions.length ? ( 219 + <Badge variant="secondary" className="ml-1"> 220 + {_assertions.length} 221 + </Badge> 222 + ) : null} 223 + </TabsTrigger> 156 224 <TabsTrigger value="notifications"> 157 225 Notifications{" "} 158 226 {defaultValues?.notifications?.length ? ( ··· 176 244 <TabsContent value="request"> 177 245 <SectionRequests {...{ form, plan, pingEndpoint }} /> 178 246 </TabsContent> 247 + <TabsContent value="assertions"> 248 + <SectionAssertions {...{ form }} /> 249 + </TabsContent> 179 250 <TabsContent value="scheduling"> 180 251 <SectionScheduling {...{ form, plan }} /> 181 252 </TabsContent> ··· 191 262 </TabsContent> 192 263 ) : null} 193 264 </Tabs> 194 - <SaveButton 195 - isPending={isPending} 196 - isDirty={form.formState.isDirty} 197 - onSubmit={form.handleSubmit(onSubmit)} 198 - /> 265 + <div className="grid gap-4 sm:flex sm:items-start sm:justify-end"> 266 + <RequestTestButton {...{ form, pingEndpoint }} /> 267 + <SaveButton 268 + isPending={isPending} 269 + isDirty={form.formState.isDirty} 270 + onSubmit={form.handleSubmit(onSubmit)} 271 + /> 272 + </div> 199 273 </form> 200 274 </Form> 201 275 <FailedPingAlertConfirmation
-2
apps/web/src/components/forms/monitor/general.tsx
··· 19 19 Switch, 20 20 } from "@openstatus/ui"; 21 21 22 - import { TagBadge } from "@/components/monitor/tag-badge"; 23 22 import { SectionHeader } from "../shared/section-header"; 24 23 import { TagsMultiBox } from "./tags-multi-box"; 25 24 ··· 30 29 } 31 30 32 31 export function General({ form, tags }: Props) { 33 - const watchTags = form.watch("tags"); 34 32 return ( 35 33 <div className="grid gap-4 sm:grid-cols-3 sm:gap-6"> 36 34 <SectionHeader
+35 -13
apps/web/src/components/forms/monitor/request-test-button.tsx
··· 2 2 import { Send } from "lucide-react"; 3 3 import type { UseFormReturn } from "react-hook-form"; 4 4 5 + import { deserialize } from "@openstatus/assertions"; 5 6 import type { 6 7 InsertMonitor, 7 8 MonitorFlyRegion, ··· 10 11 Button, 11 12 Dialog, 12 13 DialogContent, 14 + DialogHeader, 15 + DialogTitle, 13 16 Select, 14 17 SelectContent, 15 18 SelectItem, ··· 26 29 import { ResponseDetailTabs } from "@/app/play/checker/[id]/_components/response-detail-tabs"; 27 30 import type { RegionChecker } from "@/app/play/checker/[id]/utils"; 28 31 import { LoadingAnimation } from "@/components/loading-animation"; 29 - import { toastAction } from "@/lib/toast"; 32 + import { toast, toastAction } from "@/lib/toast"; 30 33 31 34 interface Props { 32 35 form: UseFormReturn<InsertMonitor>; 33 - pingEndpoint(region?: MonitorFlyRegion): Promise<RegionChecker>; 36 + pingEndpoint( 37 + region?: MonitorFlyRegion, 38 + ): Promise<{ data?: RegionChecker; error?: string }>; 34 39 } 35 40 36 41 export function RequestTestButton({ form, pingEndpoint }: Props) { 37 - const [check, setCheck] = React.useState<RegionChecker | undefined>(); 42 + const [check, setCheck] = React.useState< 43 + { data: RegionChecker; error?: string } | undefined 44 + >(); 38 45 const [value, setValue] = React.useState<MonitorFlyRegion>(flyRegions[0]); 39 46 const [isPending, startTransition] = React.useTransition(); 40 47 ··· 50 57 51 58 startTransition(async () => { 52 59 try { 53 - const data = await pingEndpoint(value); 54 - setCheck(data); 55 - const isOk = data.status >= 200 && data.status < 300; 60 + const { data, error } = await pingEndpoint(value); 61 + if (data) setCheck({ data, error }); 62 + const isOk = !error; 56 63 if (isOk) { 57 64 toastAction("test-success"); 58 65 } else { 59 - toastAction("test-error"); 66 + toast.error(error); 60 67 } 61 68 } catch { 62 69 toastAction("error"); ··· 66 73 67 74 const { flag } = flyRegionsDict[value as keyof typeof flyRegionsDict]; 68 75 76 + const { statusAssertions, headerAssertions } = form.getValues(); 77 + 69 78 return ( 70 79 <Dialog open={!!check} onOpenChange={() => setCheck(undefined)}> 71 80 <div className="ring-offset-background focus-within:ring-ring group flex h-10 items-center rounded-md bg-transparent text-sm focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2"> ··· 74 83 onValueChange={(value: MonitorFlyRegion) => setValue(value)} 75 84 > 76 85 <SelectTrigger 77 - className="flex-1 rounded-r-none focus:ring-0" 86 + className="border-accent flex-1 rounded-r-none focus:ring-0" 78 87 aria-label={value} 79 88 > 80 89 <SelectValue>{flag}</SelectValue> ··· 97 106 onClick={onClick} 98 107 disabled={isPending} 99 108 className="h-full flex-1 rounded-l-none focus:ring-0" 109 + variant="secondary" 100 110 > 101 111 {isPending ? ( 102 - <LoadingAnimation /> 112 + <LoadingAnimation variant="inverse" /> 103 113 ) : ( 104 114 <> 105 - Ping <Send className="ml-2 h-4 w-4" /> 115 + Test <Send className="ml-2 h-4 w-4" /> 106 116 </> 107 117 )} 108 118 </Button> 109 119 </TooltipTrigger> 110 120 <TooltipContent> 111 - <p>Send a request to your endpoint</p> 121 + <p>Ping your endpoint</p> 112 122 </TooltipContent> 113 123 </Tooltip> 114 124 </TooltipProvider> 115 125 </div> 116 126 <DialogContent className="max-h-screen w-full overflow-auto sm:max-w-3xl sm:p-8"> 127 + <DialogHeader> 128 + <DialogTitle>Response</DialogTitle> 129 + </DialogHeader> 117 130 {check ? ( 118 131 <div className="grid gap-8"> 119 - <RegionInfo check={check} /> 120 - <ResponseDetailTabs timing={check.timing} headers={check.headers} /> 132 + <RegionInfo check={check.data} error={check.error} /> 133 + <ResponseDetailTabs 134 + timing={check.data.timing} 135 + headers={check.data.headers} 136 + assertions={deserialize( 137 + JSON.stringify([ 138 + ...(statusAssertions || []), 139 + ...(headerAssertions || []), 140 + ]), 141 + )} 142 + /> 121 143 </div> 122 144 ) : null} 123 145 </DialogContent>
+192
apps/web/src/components/forms/monitor/section-assertions.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import { useFieldArray } from "react-hook-form"; 5 + import type { UseFormReturn } from "react-hook-form"; 6 + 7 + import { 8 + numberCompareDictionary, 9 + stringCompareDictionary, 10 + } from "@openstatus/assertions"; 11 + import type { InsertMonitor } from "@openstatus/db/src/schema"; 12 + import { 13 + Button, 14 + Input, 15 + Select, 16 + SelectContent, 17 + SelectItem, 18 + SelectTrigger, 19 + SelectValue, 20 + } from "@openstatus/ui"; 21 + 22 + import { Icons } from "@/components/icons"; 23 + import { SectionHeader } from "../shared/section-header"; 24 + 25 + // IMPROVEMENT: use FormFields incl. error message 26 + 27 + export const setEmptyOrStr = (v: unknown) => { 28 + if (typeof v === "string" && v.trim() === "") return undefined; 29 + return v; 30 + }; 31 + 32 + interface Props { 33 + form: UseFormReturn<InsertMonitor>; 34 + } 35 + 36 + export function SectionAssertions({ form }: Props) { 37 + const statusAssertions = useFieldArray({ 38 + control: form.control, 39 + name: "statusAssertions", 40 + }); 41 + const headerAssertions = useFieldArray({ 42 + control: form.control, 43 + name: "headerAssertions", 44 + }); 45 + return ( 46 + <div className="grid w-full gap-4"> 47 + <SectionHeader 48 + title="Assertions" 49 + description={ 50 + <> 51 + Validate the response to ensure your service is working as expected. 52 + <br /> 53 + <span className="decoration-border underline underline-offset-4"> 54 + By default, we check for a{" "} 55 + <span className="text-foreground font-medium"> 56 + <code>2xx</code> status code 57 + </span> 58 + </span> 59 + . 60 + </> 61 + } 62 + /> 63 + <div className="flex flex-col gap-4"> 64 + {statusAssertions.fields.map((f, i) => ( 65 + <div key={f.id} className="grid grid-cols-12 items-center gap-4"> 66 + <p className="text-muted-foreground col-span-2 text-sm"> 67 + Status Code 68 + </p> 69 + <div className="col-span-3" /> 70 + <Select 71 + {...form.register(`statusAssertions.${i}.compare`, { 72 + required: true, 73 + })} 74 + > 75 + <SelectTrigger className="col-span-3 w-full"> 76 + <SelectValue defaultValue="eq" placeholder="Equal" /> 77 + </SelectTrigger> 78 + <SelectContent> 79 + {Object.entries(numberCompareDictionary).map(([key, value]) => ( 80 + <SelectItem key={key} value={key}> 81 + {value} 82 + </SelectItem> 83 + ))} 84 + </SelectContent> 85 + </Select> 86 + <Input 87 + {...form.register(`statusAssertions.${i}.target`, { 88 + required: true, 89 + valueAsNumber: true, 90 + })} 91 + type="number" 92 + placeholder="200" 93 + className="col-span-3" 94 + /> 95 + <div className="col-span-1"> 96 + <Button 97 + size="icon" 98 + onClick={() => statusAssertions.remove(i)} 99 + variant="ghost" 100 + type="button" 101 + > 102 + <Icons.trash className="h-4 w-4" /> 103 + </Button> 104 + </div> 105 + </div> 106 + ))} 107 + {headerAssertions.fields.map((f, i) => ( 108 + <div key={f.id} className="grid grid-cols-12 items-center gap-4"> 109 + <p className="text-muted-foreground col-span-2 text-sm"> 110 + Response Header 111 + </p> 112 + <Input 113 + {...form.register(`headerAssertions.${i}.key`, { 114 + required: true, 115 + setValueAs: setEmptyOrStr, 116 + })} 117 + className="col-span-3" 118 + placeholder="X-Header" 119 + /> 120 + 121 + <Select 122 + {...form.register(`headerAssertions.${i}.compare`, { 123 + required: true, 124 + })} 125 + > 126 + <SelectTrigger className="col-span-3 w-full"> 127 + <SelectValue defaultValue="eq" placeholder="Equal" /> 128 + </SelectTrigger> 129 + <SelectContent> 130 + {Object.entries(stringCompareDictionary).map(([key, value]) => ( 131 + <SelectItem key={key} value={key}> 132 + {value} 133 + </SelectItem> 134 + ))} 135 + </SelectContent> 136 + </Select> 137 + 138 + <Input 139 + {...form.register(`headerAssertions.${i}.target`, { 140 + required: true, 141 + setValueAs: setEmptyOrStr, 142 + })} 143 + className="col-span-3" 144 + placeholder="x-value" 145 + /> 146 + 147 + <div className="col-span-1"> 148 + <Button 149 + size="icon" 150 + onClick={() => headerAssertions.remove(i)} 151 + variant="ghost" 152 + > 153 + <Icons.trash className="h-4 w-4" /> 154 + </Button> 155 + </div> 156 + </div> 157 + ))} 158 + <div className="flex gap-4"> 159 + <Button 160 + variant="outline" 161 + type="button" 162 + onClick={() => 163 + statusAssertions.append({ 164 + version: "v1", 165 + type: "status", 166 + compare: "eq", 167 + target: 200, 168 + }) 169 + } 170 + > 171 + Add Status Code Assertion 172 + </Button> 173 + <Button 174 + variant="outline" 175 + type="button" 176 + onClick={() => 177 + headerAssertions.append({ 178 + version: "v1", 179 + type: "header", 180 + key: "Content-Type", 181 + compare: "eq", 182 + target: "application/json", 183 + }) 184 + } 185 + > 186 + Add Header Assertion 187 + </Button> 188 + </div> 189 + </div> 190 + </div> 191 + ); 192 + }
+5 -7
apps/web/src/components/forms/monitor/section-danger.tsx
··· 19 19 import { LoadingAnimation } from "@/components/loading-animation"; 20 20 import { toastAction } from "@/lib/toast"; 21 21 import { api } from "@/trpc/client"; 22 + import { SectionHeader } from "../shared/section-header"; 22 23 23 24 interface Props { 24 25 monitorId: number; ··· 44 45 45 46 return ( 46 47 <div className="grid w-full gap-4"> 47 - <div className="grid gap-1"> 48 - <h4 className="text-foreground font-medium">Delete monitor</h4> 49 - <p className="text-muted-foreground"> 50 - This action cannot be undone. This will permanently delete the 51 - monitor. 52 - </p> 53 - </div> 48 + <SectionHeader 49 + title="Danger Zone" 50 + description="This action cannot be undone. This will permanently delete the monitor." 51 + /> 54 52 <div> 55 53 <AlertDialog open={open} onOpenChange={setOpen}> 56 54 <AlertDialogTrigger asChild>
+102
apps/web/src/components/forms/monitor/section-limits.tsx
··· 1 + "use client"; 2 + 3 + import type { UseFormReturn } from "react-hook-form"; 4 + 5 + import type { InsertMonitor } from "@openstatus/db/src/schema"; 6 + import { 7 + FormControl, 8 + FormDescription, 9 + FormField, 10 + FormItem, 11 + FormLabel, 12 + FormMessage, 13 + Select, 14 + SelectContent, 15 + SelectItem, 16 + SelectTrigger, 17 + SelectValue, 18 + } from "@openstatus/ui"; 19 + 20 + import { formatDuration } from "@/lib/utils"; 21 + import { SectionHeader } from "../shared/section-header"; 22 + 23 + interface Props { 24 + form: UseFormReturn<InsertMonitor>; 25 + } 26 + 27 + // FIXME: replace with enum 28 + // TODO: can be also replaced by a custom number input with max value (+ validation) 29 + const limits = [100, 250, 500, 1_000, 2_000, 5_000, 10_000, 20_000, 40_000]; 30 + 31 + export function SectionLimits({ form }: Props) { 32 + return ( 33 + <div className="grid w-full gap-4"> 34 + <SectionHeader 35 + title="Response Time Limits" 36 + description="Check when your endpoint is taking too long to respond. And set the limit to be considered degraded." 37 + /> 38 + <FormField 39 + control={form.control} 40 + name="id" // FIXME: should be something like 'limitDegraded' 41 + render={({ field }) => ( 42 + <FormItem> 43 + <FormLabel>Degraded limit</FormLabel> 44 + <Select 45 + onValueChange={(value) => field.onChange(parseInt(value))} 46 + defaultValue={String(field.value)} 47 + > 48 + <FormControl> 49 + <SelectTrigger className="sm:w-[200px]"> 50 + <SelectValue placeholder="Select" /> 51 + </SelectTrigger> 52 + </FormControl> 53 + <SelectContent> 54 + {limits.map((limit) => ( 55 + <SelectItem key={limit} value={String(limit)}> 56 + {formatDuration(limit)} 57 + </SelectItem> 58 + ))} 59 + </SelectContent> 60 + </Select> 61 + <FormDescription> 62 + When the response time exceeds this limit, the monitor is will be 63 + considered as <span className="text-amber-500">degraded</span>. 64 + </FormDescription> 65 + <FormMessage /> 66 + </FormItem> 67 + )} 68 + /> 69 + <FormField 70 + control={form.control} 71 + name="id" // FIXME: should be something like 'limitFailed' 72 + render={({ field }) => ( 73 + <FormItem> 74 + <FormLabel>Failed limit</FormLabel> 75 + <Select 76 + onValueChange={(value) => field.onChange(parseInt(value))} 77 + defaultValue={String(field.value)} 78 + > 79 + <FormControl> 80 + <SelectTrigger className="sm:w-[200px]"> 81 + <SelectValue placeholder="Select" /> 82 + </SelectTrigger> 83 + </FormControl> 84 + <SelectContent> 85 + {limits.map((limit) => ( 86 + <SelectItem key={limit} value={String(limit)}> 87 + {formatDuration(limit)} 88 + </SelectItem> 89 + ))} 90 + </SelectContent> 91 + </Select> 92 + <FormDescription> 93 + When the response time exceeds this limit, the monitor is will be 94 + considered as <span className="text-rose-500">failed</span>. 95 + </FormDescription> 96 + <FormMessage /> 97 + </FormItem> 98 + )} 99 + /> 100 + </div> 101 + ); 102 + }
+13 -14
apps/web/src/components/forms/monitor/section-requests.tsx
··· 9 9 monitorMethods, 10 10 monitorMethodsSchema, 11 11 } from "@openstatus/db/src/schema"; 12 - import type { 13 - InsertMonitor, 14 - MonitorFlyRegion, 15 - WorkspacePlan, 16 - } from "@openstatus/db/src/schema"; 12 + import type { InsertMonitor, WorkspacePlan } from "@openstatus/db/src/schema"; 17 13 import { 18 14 Button, 19 15 FormControl, ··· 35 31 TooltipTrigger, 36 32 } from "@openstatus/ui"; 37 33 38 - import type { RegionChecker } from "@/app/play/checker/[id]/utils"; 39 34 import { SectionHeader } from "../shared/section-header"; 40 35 import { RequestTestButton } from "./request-test-button"; 41 36 42 37 interface Props { 43 38 form: UseFormReturn<InsertMonitor>; 44 39 plan: WorkspacePlan; 45 - pingEndpoint(region?: MonitorFlyRegion): Promise<RegionChecker>; 46 40 } 47 41 48 42 // TODO: add Dialog with response informations when pingEndpoint! 49 43 50 - export function SectionRequests({ form, pingEndpoint }: Props) { 44 + export function SectionRequests({ form }: Props) { 51 45 const { fields, append, remove } = useFieldArray({ 52 46 name: "headers", 53 47 control: form.control, ··· 83 77 title="HTTP Request Settings" 84 78 description="Create your HTTP. Add custom headers, payload and test your endpoint before submitting." 85 79 /> 86 - <div className="flex flex-col gap-4 sm:flex-row sm:items-end"> 80 + <div className="grid gap-4 sm:grid-cols-7"> 87 81 <FormField 88 82 control={form.control} 89 83 name="method" 90 84 render={({ field }) => ( 91 - <FormItem> 85 + <FormItem className="sm:col-span-1"> 92 86 <FormLabel>Method</FormLabel> 93 87 <Select 94 88 onValueChange={(value) => { ··· 98 92 defaultValue={field.value} 99 93 > 100 94 <FormControl> 101 - <SelectTrigger className="sm:w-[120px]"> 95 + <SelectTrigger> 102 96 <SelectValue placeholder="Select" /> 103 97 </SelectTrigger> 104 98 </FormControl> ··· 118 112 control={form.control} 119 113 name="url" 120 114 render={({ field }) => ( 121 - <FormItem className="w-full"> 115 + <FormItem className="sm:col-span-5"> 122 116 <FormLabel>URL</FormLabel> 123 117 <FormControl> 124 118 {/* <InputWithAddons ··· 136 130 </FormItem> 137 131 )} 138 132 /> 139 - <RequestTestButton {...{ form, pingEndpoint }} /> 140 133 </div> 141 134 <div className="space-y-2 sm:col-span-full"> 142 135 <FormLabel>Request Header</FormLabel> ··· 220 213 {...field} 221 214 /> 222 215 </FormControl> 223 - <FormDescription>Write your json payload.</FormDescription> 216 + <FormDescription> 217 + Write your json payload. We automatically append{" "} 218 + <code> 219 + &quot;Content-Type&quot;: &quot;application/json&quot; 220 + </code>{" "} 221 + to the request header. 222 + </FormDescription> 224 223 <FormMessage /> 225 224 </FormItem> 226 225 )}
+3 -1
apps/web/src/components/forms/shared/section-header.tsx
··· 1 + import * as React from "react"; 2 + 1 3 import { cn } from "@/lib/utils"; 2 4 3 5 export function SectionHeader({ ··· 6 8 className, 7 9 }: { 8 10 title: string; 9 - description: string; 11 + description: React.ReactNode; 10 12 className?: string; 11 13 }) { 12 14 return (
+2 -3
apps/web/src/components/monitor/status-dot-with-tooltip.tsx
··· 1 - import type { Monitor } from "@openstatus/db/src/schema"; 2 1 import { 3 2 Tooltip, 4 3 TooltipContent, ··· 6 5 TooltipTrigger, 7 6 } from "@openstatus/ui"; 8 7 8 + import type { StatusDotProps } from "./status-dot"; 9 9 import { StatusDot } from "./status-dot"; 10 10 11 - export interface StatusDotWithTooltipProps 12 - extends Pick<Monitor, "active" | "status"> {} 11 + export interface StatusDotWithTooltipProps extends StatusDotProps {} 13 12 14 13 export function StatusDotWithTooltip({ 15 14 status,
+15
apps/web/src/lib/utils.ts
··· 19 19 return format(date, "LLL dd, y HH:mm:ss"); 20 20 } 21 21 22 + export function formatDuration(ms: number) { 23 + if (ms < 0) ms = -ms; 24 + const time = { 25 + day: Math.floor(ms / 86400000), 26 + hour: Math.floor(ms / 3600000) % 24, 27 + min: Math.floor(ms / 60000) % 60, 28 + sec: Math.floor(ms / 1000) % 60, 29 + ms: Math.floor(ms) % 1000, 30 + }; 31 + return Object.entries(time) 32 + .filter((val) => val[1] !== 0) 33 + .map(([key, val]) => `${val} ${key}${val !== 1 && key !== "ms" ? "s" : ""}`) 34 + .join(", "); 35 + } 36 + 22 37 export function notEmpty<TValue>( 23 38 value: TValue | null | undefined, 24 39 ): value is TValue {
+1
packages/api/package.json
··· 9 9 "dependencies": { 10 10 "@clerk/nextjs": "4.29.9", 11 11 "@openstatus/analytics": "workspace:*", 12 + "@openstatus/assertions": "workspace:*", 12 13 "@openstatus/db": "workspace:*", 13 14 "@openstatus/emails": "workspace:*", 14 15 "@openstatus/plans": "workspace:*",
+45 -8
packages/api/src/router/monitor.ts
··· 1 1 import { TRPCError } from "@trpc/server"; 2 2 import { z } from "zod"; 3 3 4 + import { 5 + Assertion, 6 + HeaderAssertion, 7 + serialize, 8 + StatusAssertion, 9 + } from "@openstatus/assertions"; 4 10 import { and, eq, inArray, sql } from "@openstatus/db"; 5 11 import { 6 12 insertMonitorSchema, 7 - insertMonitorStatusSchema, 8 13 monitor, 9 - monitorPeriodicitySchema, 10 - monitorStatusTable, 11 14 monitorsToPages, 12 15 monitorTag, 13 16 monitorTagsToMonitors, ··· 15 18 notificationsToMonitors, 16 19 page, 17 20 selectMonitorSchema, 18 - selectMonitorStatusSchema, 19 21 selectMonitorTagSchema, 20 22 selectNotificationSchema, 21 23 } from "@openstatus/db/src/schema"; ··· 59 61 } 60 62 61 63 // FIXME: this is a hotfix 62 - const { regions, headers, notifications, id, pages, tags, ...data } = 63 - opts.input; 64 + const { 65 + regions, 66 + headers, 67 + notifications, 68 + id, 69 + pages, 70 + tags, 71 + statusAssertions, 72 + headerAssertions, 73 + ...data 74 + } = opts.input; 75 + 76 + const assertions: Assertion[] = []; 77 + for (const a of statusAssertions ?? []) { 78 + assertions.push(new StatusAssertion(a)); 79 + } 80 + for (const a of headerAssertions ?? []) { 81 + assertions.push(new HeaderAssertion(a)); 82 + } 64 83 65 84 const newMonitor = await opts.ctx.db 66 85 .insert(monitor) ··· 71 90 workspaceId: opts.ctx.workspace.id, 72 91 regions: regions?.join(","), 73 92 headers: headers ? JSON.stringify(headers) : undefined, 93 + assertions: assertions.length > 0 ? serialize(assertions) : undefined, 74 94 }) 75 95 .returning() 76 96 .get(); ··· 179 199 180 200 console.log(opts.input); 181 201 182 - const { regions, headers, notifications, pages, tags, ...data } = 183 - opts.input; 202 + const { 203 + regions, 204 + headers, 205 + notifications, 206 + pages, 207 + tags, 208 + statusAssertions, 209 + headerAssertions, 210 + ...data 211 + } = opts.input; 212 + 213 + const assertions: Assertion[] = []; 214 + for (const a of statusAssertions ?? []) { 215 + assertions.push(new StatusAssertion(a)); 216 + } 217 + for (const a of headerAssertions ?? []) { 218 + assertions.push(new HeaderAssertion(a)); 219 + } 184 220 185 221 const currentMonitor = await opts.ctx.db 186 222 .update(monitor) ··· 189 225 regions: regions?.join(","), 190 226 updatedAt: new Date(), 191 227 headers: headers ? JSON.stringify(headers) : undefined, 228 + assertions: serialize(assertions), 192 229 }) 193 230 .where( 194 231 and(
+2
packages/assertions/README.md
··· 1 + Biggest props to [@chronark\_](https://twitter.com/chronark_/) for providing us 2 + the snippets.
+17
packages/assertions/package.json
··· 1 + { 2 + "name": "@openstatus/assertions", 3 + "version": "1.0.0", 4 + "description": "", 5 + "main": "src/index.ts", 6 + "scripts": {}, 7 + "dependencies": {}, 8 + "devDependencies": { 9 + "@openstatus/tsconfig": "workspace:*", 10 + "jsonpath-plus": "7.2.0", 11 + "typescript": "5.4.2", 12 + "zod": "3.22.2" 13 + }, 14 + "keywords": [], 15 + "author": "", 16 + "license": "ISC" 17 + }
+31
packages/assertions/src/dictionary.ts
··· 1 + import type { z } from "zod"; 2 + 3 + import type { numberCompare, stringCompare } from "./v1"; 4 + 5 + export const numberCompareDictionary: Record< 6 + z.infer<typeof numberCompare>, 7 + string 8 + > = { 9 + eq: "Equal", 10 + not_eq: "Not Equal", 11 + gt: "Greater than", 12 + gte: "Greater than or equal", 13 + lt: "Less than", 14 + lte: "Less than or equal", 15 + }; 16 + 17 + export const stringCompareDictionary: Record< 18 + z.infer<typeof stringCompare>, 19 + string 20 + > = { 21 + contains: "Contains", 22 + not_contains: "Does not contain", 23 + eq: "Equal", 24 + not_eq: "Not Equal", 25 + empty: "Empty", 26 + not_empty: "Not Empty", 27 + gt: "Greater than", 28 + gte: "Greater than or equal", 29 + lt: "Less than", 30 + lte: "Less than or equal", 31 + };
+4
packages/assertions/src/index.ts
··· 1 + export * from "./dictionary"; 2 + export * from "./types"; 3 + export * from "./serializing"; 4 + export * from "./v1";
+36
packages/assertions/src/serializing.ts
··· 1 + import { z } from "zod"; 2 + 3 + import type { Assertion } from "./types"; 4 + import { 5 + base, 6 + HeaderAssertion, 7 + headerAssertion, 8 + JsonBodyAssertion, 9 + jsonBodyAssertion, 10 + StatusAssertion, 11 + statusAssertion, 12 + TextBodyAssertion, 13 + textBodyAssertion, 14 + } from "./v1"; 15 + 16 + export function serialize(assertions: Assertion[]): string { 17 + return JSON.stringify(assertions.map((a) => a.schema)); 18 + } 19 + export function deserialize(s: string): Assertion[] { 20 + const bases = z.array(base).parse(JSON.parse(s)); 21 + return bases.map((b) => { 22 + switch (b.type) { 23 + case "status": 24 + return new StatusAssertion(statusAssertion.parse(b)); 25 + case "header": 26 + return new HeaderAssertion(headerAssertion.parse(b)); 27 + case "jsonBody": 28 + return new JsonBodyAssertion(jsonBodyAssertion.parse(b)); 29 + case "textBody": 30 + return new TextBodyAssertion(textBodyAssertion.parse(b)); 31 + 32 + default: 33 + throw new Error(`unknown assertion type: ${b.type}`); 34 + } 35 + }); 36 + }
+23
packages/assertions/src/types.ts
··· 1 + import type { z } from "zod"; 2 + 3 + import type { assertion } from "./v1"; 4 + 5 + export type AssertionRequest = { 6 + body: string; 7 + header: Record<string, string>; 8 + status: number; 9 + }; 10 + 11 + export type AssertionResult = 12 + | { 13 + success: true; 14 + message?: never; 15 + } 16 + | { 17 + success: false; 18 + message: string; 19 + }; 20 + export interface Assertion { 21 + schema: z.infer<typeof assertion>; 22 + assert: (req: AssertionRequest) => AssertionResult; 23 + }
+293
packages/assertions/src/v1.ts
··· 1 + import { JSONPath } from "jsonpath-plus"; 2 + import { z } from "zod"; 3 + 4 + import type { Assertion, AssertionRequest, AssertionResult } from "./types"; 5 + 6 + export const stringCompare = z.enum([ 7 + "contains", 8 + "not_contains", 9 + "eq", 10 + "not_eq", 11 + "empty", 12 + "not_empty", 13 + "gt", 14 + "gte", 15 + "lt", 16 + "lte", 17 + ]); 18 + export const numberCompare = z.enum(["eq", "not_eq", "gt", "gte", "lt", "lte"]); 19 + 20 + function evaluateNumber( 21 + value: number, 22 + compare: z.infer<typeof numberCompare>, 23 + target: number, 24 + ): AssertionResult { 25 + switch (compare) { 26 + case "eq": 27 + if (value !== target) { 28 + return { 29 + success: false, 30 + message: `Expected ${value} to be equal to ${target}`, 31 + }; 32 + } 33 + break; 34 + case "not_eq": 35 + if (value === target) { 36 + return { 37 + success: false, 38 + message: `Expected ${value} to not be equal to ${target}`, 39 + }; 40 + } 41 + break; 42 + case "gt": 43 + if (value <= target) { 44 + return { 45 + success: false, 46 + message: `Expected ${value} to be greater than ${target}`, 47 + }; 48 + } 49 + break; 50 + case "gte": 51 + if (value < target) { 52 + return { 53 + success: false, 54 + message: `Expected ${value} to be greater than or equal to ${target}`, 55 + }; 56 + } 57 + break; 58 + case "lt": 59 + if (value >= target) { 60 + return { 61 + success: false, 62 + message: `Expected ${value} to be less than ${target}`, 63 + }; 64 + } 65 + break; 66 + case "lte": 67 + if (value > target) { 68 + return { 69 + success: false, 70 + message: `Expected ${value} to be less than or equal to ${target}`, 71 + }; 72 + } 73 + break; 74 + } 75 + return { success: true }; 76 + } 77 + 78 + function evaluateString( 79 + value: string, 80 + compare: z.infer<typeof stringCompare>, 81 + target: string, 82 + ): AssertionResult { 83 + switch (compare) { 84 + case "contains": 85 + if (!value.includes(target)) { 86 + return { 87 + success: false, 88 + message: `Expected ${value} to contain ${target}`, 89 + }; 90 + } 91 + break; 92 + case "not_contains": 93 + if (value.includes(target)) { 94 + return { 95 + success: false, 96 + message: `Expected ${value} to not contain ${target}`, 97 + }; 98 + } 99 + break; 100 + case "empty": 101 + if (value !== "") { 102 + return { success: false, message: `Expected ${value} to be empty` }; 103 + } 104 + break; 105 + case "not_empty": 106 + if (value === "") { 107 + return { success: false, message: `Expected ${value} to not be empty` }; 108 + } 109 + break; 110 + case "eq": 111 + if (value !== target) { 112 + return { 113 + success: false, 114 + message: `Expected ${value} to be equal to ${target}`, 115 + }; 116 + } 117 + break; 118 + case "not_eq": 119 + if (value === target) { 120 + return { 121 + success: false, 122 + message: `Expected ${value} to not be equal to ${target}`, 123 + }; 124 + } 125 + break; 126 + case "gt": 127 + if (value <= target) { 128 + return { 129 + success: false, 130 + message: `Expected ${value} to be greater than ${target}`, 131 + }; 132 + } 133 + break; 134 + case "gte": 135 + if (value < target) { 136 + return { 137 + success: false, 138 + message: `Expected ${value} to be greater than or equal to ${target}`, 139 + }; 140 + } 141 + break; 142 + case "lt": 143 + if (value >= target) { 144 + return { 145 + success: false, 146 + message: `Expected ${value} to be less than ${target}`, 147 + }; 148 + } 149 + break; 150 + case "lte": 151 + if (value > target) { 152 + return { 153 + success: false, 154 + message: `Expected ${value} to be less than or equal to ${target}`, 155 + }; 156 + } 157 + break; 158 + } 159 + return { success: true }; 160 + } 161 + 162 + export const base = z 163 + .object({ 164 + version: z.enum(["v1"]), 165 + type: z.string(), 166 + }) 167 + .passthrough(); 168 + export const statusAssertion = base.merge( 169 + z.object({ 170 + type: z.literal("status"), 171 + compare: numberCompare, 172 + target: z.number().int().positive(), 173 + }), 174 + ); 175 + 176 + export const headerAssertion = base.merge( 177 + z.object({ 178 + type: z.literal("header"), 179 + compare: stringCompare, 180 + key: z.string(), 181 + target: z.string(), 182 + }), 183 + ); 184 + 185 + export const textBodyAssertion = base.merge( 186 + z.object({ 187 + type: z.literal("textBody"), 188 + compare: stringCompare, 189 + target: z.string(), 190 + }), 191 + ); 192 + 193 + export const jsonBodyAssertion = base.merge( 194 + z.object({ 195 + type: z.literal("jsonBody"), 196 + path: z.string(), // https://www.npmjs.com/package/jsonpath-plus 197 + compare: stringCompare, 198 + target: z.string(), 199 + }), 200 + ); 201 + 202 + export const assertion = z.discriminatedUnion("type", [ 203 + statusAssertion, 204 + headerAssertion, 205 + textBodyAssertion, 206 + jsonBodyAssertion, 207 + ]); 208 + 209 + export class StatusAssertion implements Assertion { 210 + readonly schema: z.infer<typeof statusAssertion>; 211 + 212 + constructor(schema: z.infer<typeof statusAssertion>) { 213 + this.schema = schema; 214 + } 215 + 216 + public assert(req: AssertionRequest): AssertionResult { 217 + const { success, message } = evaluateNumber( 218 + req.status, 219 + this.schema.compare, 220 + this.schema.target, 221 + ); 222 + if (success) { 223 + return { success }; 224 + } 225 + return { success, message: `Status: ${message}` }; 226 + } 227 + } 228 + 229 + export class HeaderAssertion { 230 + readonly schema: z.infer<typeof headerAssertion>; 231 + 232 + constructor(schema: z.infer<typeof headerAssertion>) { 233 + this.schema = schema; 234 + } 235 + 236 + public assert(req: AssertionRequest): AssertionResult { 237 + const { success, message } = evaluateString( 238 + req.header[this.schema.key], 239 + this.schema.compare, 240 + this.schema.target, 241 + ); 242 + if (success) { 243 + return { success }; 244 + } 245 + return { success, message: `Header ${this.schema.key}: ${message}` }; 246 + } 247 + } 248 + 249 + export class TextBodyAssertion { 250 + readonly schema: z.infer<typeof textBodyAssertion>; 251 + 252 + constructor(schema: z.infer<typeof textBodyAssertion>) { 253 + this.schema = schema; 254 + } 255 + 256 + public assert(req: AssertionRequest): AssertionResult { 257 + const { success, message } = evaluateString( 258 + req.body, 259 + this.schema.compare, 260 + this.schema.target, 261 + ); 262 + if (success) { 263 + return { success }; 264 + } 265 + return { success, message: `Body: ${message}` }; 266 + } 267 + } 268 + export class JsonBodyAssertion implements Assertion { 269 + readonly schema: z.infer<typeof jsonBodyAssertion>; 270 + 271 + constructor(schema: z.infer<typeof jsonBodyAssertion>) { 272 + this.schema = schema; 273 + } 274 + 275 + public assert(req: AssertionRequest): AssertionResult { 276 + try { 277 + const json = JSON.parse(req.body); 278 + const value = JSONPath({ path: this.schema.path, json }); 279 + const { success, message } = evaluateString( 280 + value, 281 + this.schema.compare, 282 + this.schema.target, 283 + ); 284 + if (success) { 285 + return { success }; 286 + } 287 + return { success, message: `Body: ${message}` }; 288 + } catch (_e) { 289 + console.error("Unable to parse json"); 290 + return { success: false, message: "Unable to parse json" }; 291 + } 292 + } 293 + }
+7
packages/assertions/tsconfig.json
··· 1 + { 2 + "extends": "@openstatus/tsconfig/base.json", 3 + "compilerOptions": { 4 + "lib": ["es2015", "dom"] 5 + }, 6 + "include": ["src", "*.ts"] 7 + }
+1
packages/db/drizzle/0022_chunky_rockslide.sql
··· 1 + ALTER TABLE monitor ADD `assertions` text;
+1612
packages/db/drizzle/meta/0022_snapshot.json
··· 1 + { 2 + "version": "5", 3 + "dialect": "sqlite", 4 + "id": "a53b01bf-cce5-411d-99cf-a0645f3bd125", 5 + "prevId": "44c2bbb5-69d5-40e8-b301-f2569bc2e499", 6 + "tables": { 7 + "status_report_to_monitors": { 8 + "name": "status_report_to_monitors", 9 + "columns": { 10 + "monitor_id": { 11 + "name": "monitor_id", 12 + "type": "integer", 13 + "primaryKey": false, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "status_report_id": { 18 + "name": "status_report_id", 19 + "type": "integer", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "created_at": { 25 + "name": "created_at", 26 + "type": "integer", 27 + "primaryKey": false, 28 + "notNull": false, 29 + "autoincrement": false, 30 + "default": "(strftime('%s', 'now'))" 31 + } 32 + }, 33 + "indexes": {}, 34 + "foreignKeys": { 35 + "status_report_to_monitors_monitor_id_monitor_id_fk": { 36 + "name": "status_report_to_monitors_monitor_id_monitor_id_fk", 37 + "tableFrom": "status_report_to_monitors", 38 + "tableTo": "monitor", 39 + "columnsFrom": [ 40 + "monitor_id" 41 + ], 42 + "columnsTo": [ 43 + "id" 44 + ], 45 + "onDelete": "cascade", 46 + "onUpdate": "no action" 47 + }, 48 + "status_report_to_monitors_status_report_id_status_report_id_fk": { 49 + "name": "status_report_to_monitors_status_report_id_status_report_id_fk", 50 + "tableFrom": "status_report_to_monitors", 51 + "tableTo": "status_report", 52 + "columnsFrom": [ 53 + "status_report_id" 54 + ], 55 + "columnsTo": [ 56 + "id" 57 + ], 58 + "onDelete": "cascade", 59 + "onUpdate": "no action" 60 + } 61 + }, 62 + "compositePrimaryKeys": { 63 + "status_report_to_monitors_monitor_id_status_report_id_pk": { 64 + "columns": [ 65 + "monitor_id", 66 + "status_report_id" 67 + ], 68 + "name": "status_report_to_monitors_monitor_id_status_report_id_pk" 69 + } 70 + }, 71 + "uniqueConstraints": {} 72 + }, 73 + "status_reports_to_pages": { 74 + "name": "status_reports_to_pages", 75 + "columns": { 76 + "page_id": { 77 + "name": "page_id", 78 + "type": "integer", 79 + "primaryKey": false, 80 + "notNull": true, 81 + "autoincrement": false 82 + }, 83 + "status_report_id": { 84 + "name": "status_report_id", 85 + "type": "integer", 86 + "primaryKey": false, 87 + "notNull": true, 88 + "autoincrement": false 89 + }, 90 + "created_at": { 91 + "name": "created_at", 92 + "type": "integer", 93 + "primaryKey": false, 94 + "notNull": false, 95 + "autoincrement": false, 96 + "default": "(strftime('%s', 'now'))" 97 + } 98 + }, 99 + "indexes": {}, 100 + "foreignKeys": { 101 + "status_reports_to_pages_page_id_page_id_fk": { 102 + "name": "status_reports_to_pages_page_id_page_id_fk", 103 + "tableFrom": "status_reports_to_pages", 104 + "tableTo": "page", 105 + "columnsFrom": [ 106 + "page_id" 107 + ], 108 + "columnsTo": [ 109 + "id" 110 + ], 111 + "onDelete": "cascade", 112 + "onUpdate": "no action" 113 + }, 114 + "status_reports_to_pages_status_report_id_status_report_id_fk": { 115 + "name": "status_reports_to_pages_status_report_id_status_report_id_fk", 116 + "tableFrom": "status_reports_to_pages", 117 + "tableTo": "status_report", 118 + "columnsFrom": [ 119 + "status_report_id" 120 + ], 121 + "columnsTo": [ 122 + "id" 123 + ], 124 + "onDelete": "cascade", 125 + "onUpdate": "no action" 126 + } 127 + }, 128 + "compositePrimaryKeys": { 129 + "status_reports_to_pages_page_id_status_report_id_pk": { 130 + "columns": [ 131 + "page_id", 132 + "status_report_id" 133 + ], 134 + "name": "status_reports_to_pages_page_id_status_report_id_pk" 135 + } 136 + }, 137 + "uniqueConstraints": {} 138 + }, 139 + "status_report": { 140 + "name": "status_report", 141 + "columns": { 142 + "id": { 143 + "name": "id", 144 + "type": "integer", 145 + "primaryKey": true, 146 + "notNull": true, 147 + "autoincrement": false 148 + }, 149 + "status": { 150 + "name": "status", 151 + "type": "text", 152 + "primaryKey": false, 153 + "notNull": true, 154 + "autoincrement": false 155 + }, 156 + "title": { 157 + "name": "title", 158 + "type": "text(256)", 159 + "primaryKey": false, 160 + "notNull": true, 161 + "autoincrement": false 162 + }, 163 + "workspace_id": { 164 + "name": "workspace_id", 165 + "type": "integer", 166 + "primaryKey": false, 167 + "notNull": false, 168 + "autoincrement": false 169 + }, 170 + "created_at": { 171 + "name": "created_at", 172 + "type": "integer", 173 + "primaryKey": false, 174 + "notNull": false, 175 + "autoincrement": false, 176 + "default": "(strftime('%s', 'now'))" 177 + }, 178 + "updated_at": { 179 + "name": "updated_at", 180 + "type": "integer", 181 + "primaryKey": false, 182 + "notNull": false, 183 + "autoincrement": false, 184 + "default": "(strftime('%s', 'now'))" 185 + } 186 + }, 187 + "indexes": {}, 188 + "foreignKeys": { 189 + "status_report_workspace_id_workspace_id_fk": { 190 + "name": "status_report_workspace_id_workspace_id_fk", 191 + "tableFrom": "status_report", 192 + "tableTo": "workspace", 193 + "columnsFrom": [ 194 + "workspace_id" 195 + ], 196 + "columnsTo": [ 197 + "id" 198 + ], 199 + "onDelete": "no action", 200 + "onUpdate": "no action" 201 + } 202 + }, 203 + "compositePrimaryKeys": {}, 204 + "uniqueConstraints": {} 205 + }, 206 + "status_report_update": { 207 + "name": "status_report_update", 208 + "columns": { 209 + "id": { 210 + "name": "id", 211 + "type": "integer", 212 + "primaryKey": true, 213 + "notNull": true, 214 + "autoincrement": false 215 + }, 216 + "status": { 217 + "name": "status", 218 + "type": "text(4)", 219 + "primaryKey": false, 220 + "notNull": true, 221 + "autoincrement": false 222 + }, 223 + "date": { 224 + "name": "date", 225 + "type": "integer", 226 + "primaryKey": false, 227 + "notNull": true, 228 + "autoincrement": false 229 + }, 230 + "message": { 231 + "name": "message", 232 + "type": "text", 233 + "primaryKey": false, 234 + "notNull": true, 235 + "autoincrement": false 236 + }, 237 + "status_report_id": { 238 + "name": "status_report_id", 239 + "type": "integer", 240 + "primaryKey": false, 241 + "notNull": true, 242 + "autoincrement": false 243 + }, 244 + "created_at": { 245 + "name": "created_at", 246 + "type": "integer", 247 + "primaryKey": false, 248 + "notNull": false, 249 + "autoincrement": false, 250 + "default": "(strftime('%s', 'now'))" 251 + }, 252 + "updated_at": { 253 + "name": "updated_at", 254 + "type": "integer", 255 + "primaryKey": false, 256 + "notNull": false, 257 + "autoincrement": false, 258 + "default": "(strftime('%s', 'now'))" 259 + } 260 + }, 261 + "indexes": {}, 262 + "foreignKeys": { 263 + "status_report_update_status_report_id_status_report_id_fk": { 264 + "name": "status_report_update_status_report_id_status_report_id_fk", 265 + "tableFrom": "status_report_update", 266 + "tableTo": "status_report", 267 + "columnsFrom": [ 268 + "status_report_id" 269 + ], 270 + "columnsTo": [ 271 + "id" 272 + ], 273 + "onDelete": "cascade", 274 + "onUpdate": "no action" 275 + } 276 + }, 277 + "compositePrimaryKeys": {}, 278 + "uniqueConstraints": {} 279 + }, 280 + "integration": { 281 + "name": "integration", 282 + "columns": { 283 + "id": { 284 + "name": "id", 285 + "type": "integer", 286 + "primaryKey": true, 287 + "notNull": true, 288 + "autoincrement": false 289 + }, 290 + "name": { 291 + "name": "name", 292 + "type": "text(256)", 293 + "primaryKey": false, 294 + "notNull": true, 295 + "autoincrement": false 296 + }, 297 + "workspace_id": { 298 + "name": "workspace_id", 299 + "type": "integer", 300 + "primaryKey": false, 301 + "notNull": false, 302 + "autoincrement": false 303 + }, 304 + "credential": { 305 + "name": "credential", 306 + "type": "text", 307 + "primaryKey": false, 308 + "notNull": false, 309 + "autoincrement": false 310 + }, 311 + "external_id": { 312 + "name": "external_id", 313 + "type": "text", 314 + "primaryKey": false, 315 + "notNull": true, 316 + "autoincrement": false 317 + }, 318 + "created_at": { 319 + "name": "created_at", 320 + "type": "integer", 321 + "primaryKey": false, 322 + "notNull": false, 323 + "autoincrement": false, 324 + "default": "(strftime('%s', 'now'))" 325 + }, 326 + "updated_at": { 327 + "name": "updated_at", 328 + "type": "integer", 329 + "primaryKey": false, 330 + "notNull": false, 331 + "autoincrement": false, 332 + "default": "(strftime('%s', 'now'))" 333 + }, 334 + "data": { 335 + "name": "data", 336 + "type": "text", 337 + "primaryKey": false, 338 + "notNull": true, 339 + "autoincrement": false 340 + } 341 + }, 342 + "indexes": {}, 343 + "foreignKeys": { 344 + "integration_workspace_id_workspace_id_fk": { 345 + "name": "integration_workspace_id_workspace_id_fk", 346 + "tableFrom": "integration", 347 + "tableTo": "workspace", 348 + "columnsFrom": [ 349 + "workspace_id" 350 + ], 351 + "columnsTo": [ 352 + "id" 353 + ], 354 + "onDelete": "no action", 355 + "onUpdate": "no action" 356 + } 357 + }, 358 + "compositePrimaryKeys": {}, 359 + "uniqueConstraints": {} 360 + }, 361 + "page": { 362 + "name": "page", 363 + "columns": { 364 + "id": { 365 + "name": "id", 366 + "type": "integer", 367 + "primaryKey": true, 368 + "notNull": true, 369 + "autoincrement": false 370 + }, 371 + "workspace_id": { 372 + "name": "workspace_id", 373 + "type": "integer", 374 + "primaryKey": false, 375 + "notNull": true, 376 + "autoincrement": false 377 + }, 378 + "title": { 379 + "name": "title", 380 + "type": "text", 381 + "primaryKey": false, 382 + "notNull": true, 383 + "autoincrement": false 384 + }, 385 + "description": { 386 + "name": "description", 387 + "type": "text", 388 + "primaryKey": false, 389 + "notNull": true, 390 + "autoincrement": false 391 + }, 392 + "icon": { 393 + "name": "icon", 394 + "type": "text(256)", 395 + "primaryKey": false, 396 + "notNull": false, 397 + "autoincrement": false, 398 + "default": "''" 399 + }, 400 + "slug": { 401 + "name": "slug", 402 + "type": "text(256)", 403 + "primaryKey": false, 404 + "notNull": true, 405 + "autoincrement": false 406 + }, 407 + "custom_domain": { 408 + "name": "custom_domain", 409 + "type": "text(256)", 410 + "primaryKey": false, 411 + "notNull": true, 412 + "autoincrement": false 413 + }, 414 + "published": { 415 + "name": "published", 416 + "type": "integer", 417 + "primaryKey": false, 418 + "notNull": false, 419 + "autoincrement": false, 420 + "default": false 421 + }, 422 + "created_at": { 423 + "name": "created_at", 424 + "type": "integer", 425 + "primaryKey": false, 426 + "notNull": false, 427 + "autoincrement": false, 428 + "default": "(strftime('%s', 'now'))" 429 + }, 430 + "updated_at": { 431 + "name": "updated_at", 432 + "type": "integer", 433 + "primaryKey": false, 434 + "notNull": false, 435 + "autoincrement": false, 436 + "default": "(strftime('%s', 'now'))" 437 + } 438 + }, 439 + "indexes": { 440 + "page_slug_unique": { 441 + "name": "page_slug_unique", 442 + "columns": [ 443 + "slug" 444 + ], 445 + "isUnique": true 446 + } 447 + }, 448 + "foreignKeys": { 449 + "page_workspace_id_workspace_id_fk": { 450 + "name": "page_workspace_id_workspace_id_fk", 451 + "tableFrom": "page", 452 + "tableTo": "workspace", 453 + "columnsFrom": [ 454 + "workspace_id" 455 + ], 456 + "columnsTo": [ 457 + "id" 458 + ], 459 + "onDelete": "cascade", 460 + "onUpdate": "no action" 461 + } 462 + }, 463 + "compositePrimaryKeys": {}, 464 + "uniqueConstraints": {} 465 + }, 466 + "monitor": { 467 + "name": "monitor", 468 + "columns": { 469 + "id": { 470 + "name": "id", 471 + "type": "integer", 472 + "primaryKey": true, 473 + "notNull": true, 474 + "autoincrement": false 475 + }, 476 + "job_type": { 477 + "name": "job_type", 478 + "type": "text", 479 + "primaryKey": false, 480 + "notNull": true, 481 + "autoincrement": false, 482 + "default": "'other'" 483 + }, 484 + "periodicity": { 485 + "name": "periodicity", 486 + "type": "text", 487 + "primaryKey": false, 488 + "notNull": true, 489 + "autoincrement": false, 490 + "default": "'other'" 491 + }, 492 + "status": { 493 + "name": "status", 494 + "type": "text", 495 + "primaryKey": false, 496 + "notNull": true, 497 + "autoincrement": false, 498 + "default": "'active'" 499 + }, 500 + "active": { 501 + "name": "active", 502 + "type": "integer", 503 + "primaryKey": false, 504 + "notNull": false, 505 + "autoincrement": false, 506 + "default": false 507 + }, 508 + "regions": { 509 + "name": "regions", 510 + "type": "text", 511 + "primaryKey": false, 512 + "notNull": true, 513 + "autoincrement": false, 514 + "default": "''" 515 + }, 516 + "url": { 517 + "name": "url", 518 + "type": "text(2048)", 519 + "primaryKey": false, 520 + "notNull": true, 521 + "autoincrement": false 522 + }, 523 + "name": { 524 + "name": "name", 525 + "type": "text(256)", 526 + "primaryKey": false, 527 + "notNull": true, 528 + "autoincrement": false, 529 + "default": "''" 530 + }, 531 + "description": { 532 + "name": "description", 533 + "type": "text", 534 + "primaryKey": false, 535 + "notNull": true, 536 + "autoincrement": false, 537 + "default": "''" 538 + }, 539 + "headers": { 540 + "name": "headers", 541 + "type": "text", 542 + "primaryKey": false, 543 + "notNull": false, 544 + "autoincrement": false, 545 + "default": "''" 546 + }, 547 + "body": { 548 + "name": "body", 549 + "type": "text", 550 + "primaryKey": false, 551 + "notNull": false, 552 + "autoincrement": false, 553 + "default": "''" 554 + }, 555 + "method": { 556 + "name": "method", 557 + "type": "text", 558 + "primaryKey": false, 559 + "notNull": false, 560 + "autoincrement": false, 561 + "default": "'GET'" 562 + }, 563 + "workspace_id": { 564 + "name": "workspace_id", 565 + "type": "integer", 566 + "primaryKey": false, 567 + "notNull": false, 568 + "autoincrement": false 569 + }, 570 + "assertions": { 571 + "name": "assertions", 572 + "type": "text", 573 + "primaryKey": false, 574 + "notNull": false, 575 + "autoincrement": false 576 + }, 577 + "created_at": { 578 + "name": "created_at", 579 + "type": "integer", 580 + "primaryKey": false, 581 + "notNull": false, 582 + "autoincrement": false, 583 + "default": "(strftime('%s', 'now'))" 584 + }, 585 + "updated_at": { 586 + "name": "updated_at", 587 + "type": "integer", 588 + "primaryKey": false, 589 + "notNull": false, 590 + "autoincrement": false, 591 + "default": "(strftime('%s', 'now'))" 592 + } 593 + }, 594 + "indexes": {}, 595 + "foreignKeys": { 596 + "monitor_workspace_id_workspace_id_fk": { 597 + "name": "monitor_workspace_id_workspace_id_fk", 598 + "tableFrom": "monitor", 599 + "tableTo": "workspace", 600 + "columnsFrom": [ 601 + "workspace_id" 602 + ], 603 + "columnsTo": [ 604 + "id" 605 + ], 606 + "onDelete": "no action", 607 + "onUpdate": "no action" 608 + } 609 + }, 610 + "compositePrimaryKeys": {}, 611 + "uniqueConstraints": {} 612 + }, 613 + "monitors_to_pages": { 614 + "name": "monitors_to_pages", 615 + "columns": { 616 + "monitor_id": { 617 + "name": "monitor_id", 618 + "type": "integer", 619 + "primaryKey": false, 620 + "notNull": true, 621 + "autoincrement": false 622 + }, 623 + "page_id": { 624 + "name": "page_id", 625 + "type": "integer", 626 + "primaryKey": false, 627 + "notNull": true, 628 + "autoincrement": false 629 + }, 630 + "created_at": { 631 + "name": "created_at", 632 + "type": "integer", 633 + "primaryKey": false, 634 + "notNull": false, 635 + "autoincrement": false, 636 + "default": "(strftime('%s', 'now'))" 637 + } 638 + }, 639 + "indexes": {}, 640 + "foreignKeys": { 641 + "monitors_to_pages_monitor_id_monitor_id_fk": { 642 + "name": "monitors_to_pages_monitor_id_monitor_id_fk", 643 + "tableFrom": "monitors_to_pages", 644 + "tableTo": "monitor", 645 + "columnsFrom": [ 646 + "monitor_id" 647 + ], 648 + "columnsTo": [ 649 + "id" 650 + ], 651 + "onDelete": "cascade", 652 + "onUpdate": "no action" 653 + }, 654 + "monitors_to_pages_page_id_page_id_fk": { 655 + "name": "monitors_to_pages_page_id_page_id_fk", 656 + "tableFrom": "monitors_to_pages", 657 + "tableTo": "page", 658 + "columnsFrom": [ 659 + "page_id" 660 + ], 661 + "columnsTo": [ 662 + "id" 663 + ], 664 + "onDelete": "cascade", 665 + "onUpdate": "no action" 666 + } 667 + }, 668 + "compositePrimaryKeys": { 669 + "monitors_to_pages_monitor_id_page_id_pk": { 670 + "columns": [ 671 + "monitor_id", 672 + "page_id" 673 + ], 674 + "name": "monitors_to_pages_monitor_id_page_id_pk" 675 + } 676 + }, 677 + "uniqueConstraints": {} 678 + }, 679 + "user": { 680 + "name": "user", 681 + "columns": { 682 + "id": { 683 + "name": "id", 684 + "type": "integer", 685 + "primaryKey": true, 686 + "notNull": true, 687 + "autoincrement": false 688 + }, 689 + "tenant_id": { 690 + "name": "tenant_id", 691 + "type": "text(256)", 692 + "primaryKey": false, 693 + "notNull": false, 694 + "autoincrement": false 695 + }, 696 + "first_name": { 697 + "name": "first_name", 698 + "type": "text", 699 + "primaryKey": false, 700 + "notNull": false, 701 + "autoincrement": false, 702 + "default": "''" 703 + }, 704 + "last_name": { 705 + "name": "last_name", 706 + "type": "text", 707 + "primaryKey": false, 708 + "notNull": false, 709 + "autoincrement": false, 710 + "default": "''" 711 + }, 712 + "email": { 713 + "name": "email", 714 + "type": "text", 715 + "primaryKey": false, 716 + "notNull": false, 717 + "autoincrement": false, 718 + "default": "''" 719 + }, 720 + "photo_url": { 721 + "name": "photo_url", 722 + "type": "text", 723 + "primaryKey": false, 724 + "notNull": false, 725 + "autoincrement": false, 726 + "default": "''" 727 + }, 728 + "created_at": { 729 + "name": "created_at", 730 + "type": "integer", 731 + "primaryKey": false, 732 + "notNull": false, 733 + "autoincrement": false, 734 + "default": "(strftime('%s', 'now'))" 735 + }, 736 + "updated_at": { 737 + "name": "updated_at", 738 + "type": "integer", 739 + "primaryKey": false, 740 + "notNull": false, 741 + "autoincrement": false, 742 + "default": "(strftime('%s', 'now'))" 743 + } 744 + }, 745 + "indexes": { 746 + "user_tenant_id_unique": { 747 + "name": "user_tenant_id_unique", 748 + "columns": [ 749 + "tenant_id" 750 + ], 751 + "isUnique": true 752 + } 753 + }, 754 + "foreignKeys": {}, 755 + "compositePrimaryKeys": {}, 756 + "uniqueConstraints": {} 757 + }, 758 + "users_to_workspaces": { 759 + "name": "users_to_workspaces", 760 + "columns": { 761 + "user_id": { 762 + "name": "user_id", 763 + "type": "integer", 764 + "primaryKey": false, 765 + "notNull": true, 766 + "autoincrement": false 767 + }, 768 + "workspace_id": { 769 + "name": "workspace_id", 770 + "type": "integer", 771 + "primaryKey": false, 772 + "notNull": true, 773 + "autoincrement": false 774 + }, 775 + "role": { 776 + "name": "role", 777 + "type": "text", 778 + "primaryKey": false, 779 + "notNull": true, 780 + "autoincrement": false, 781 + "default": "'member'" 782 + }, 783 + "created_at": { 784 + "name": "created_at", 785 + "type": "integer", 786 + "primaryKey": false, 787 + "notNull": false, 788 + "autoincrement": false, 789 + "default": "(strftime('%s', 'now'))" 790 + } 791 + }, 792 + "indexes": {}, 793 + "foreignKeys": { 794 + "users_to_workspaces_user_id_user_id_fk": { 795 + "name": "users_to_workspaces_user_id_user_id_fk", 796 + "tableFrom": "users_to_workspaces", 797 + "tableTo": "user", 798 + "columnsFrom": [ 799 + "user_id" 800 + ], 801 + "columnsTo": [ 802 + "id" 803 + ], 804 + "onDelete": "no action", 805 + "onUpdate": "no action" 806 + }, 807 + "users_to_workspaces_workspace_id_workspace_id_fk": { 808 + "name": "users_to_workspaces_workspace_id_workspace_id_fk", 809 + "tableFrom": "users_to_workspaces", 810 + "tableTo": "workspace", 811 + "columnsFrom": [ 812 + "workspace_id" 813 + ], 814 + "columnsTo": [ 815 + "id" 816 + ], 817 + "onDelete": "no action", 818 + "onUpdate": "no action" 819 + } 820 + }, 821 + "compositePrimaryKeys": { 822 + "users_to_workspaces_user_id_workspace_id_pk": { 823 + "columns": [ 824 + "user_id", 825 + "workspace_id" 826 + ], 827 + "name": "users_to_workspaces_user_id_workspace_id_pk" 828 + } 829 + }, 830 + "uniqueConstraints": {} 831 + }, 832 + "page_subscriber": { 833 + "name": "page_subscriber", 834 + "columns": { 835 + "id": { 836 + "name": "id", 837 + "type": "integer", 838 + "primaryKey": true, 839 + "notNull": true, 840 + "autoincrement": false 841 + }, 842 + "email": { 843 + "name": "email", 844 + "type": "text", 845 + "primaryKey": false, 846 + "notNull": true, 847 + "autoincrement": false 848 + }, 849 + "page_id": { 850 + "name": "page_id", 851 + "type": "integer", 852 + "primaryKey": false, 853 + "notNull": true, 854 + "autoincrement": false 855 + }, 856 + "token": { 857 + "name": "token", 858 + "type": "text", 859 + "primaryKey": false, 860 + "notNull": false, 861 + "autoincrement": false 862 + }, 863 + "accepted_at": { 864 + "name": "accepted_at", 865 + "type": "integer", 866 + "primaryKey": false, 867 + "notNull": false, 868 + "autoincrement": false 869 + }, 870 + "expires_at": { 871 + "name": "expires_at", 872 + "type": "integer", 873 + "primaryKey": false, 874 + "notNull": false, 875 + "autoincrement": false 876 + }, 877 + "created_at": { 878 + "name": "created_at", 879 + "type": "integer", 880 + "primaryKey": false, 881 + "notNull": false, 882 + "autoincrement": false, 883 + "default": "(strftime('%s', 'now'))" 884 + }, 885 + "updated_at": { 886 + "name": "updated_at", 887 + "type": "integer", 888 + "primaryKey": false, 889 + "notNull": false, 890 + "autoincrement": false, 891 + "default": "(strftime('%s', 'now'))" 892 + } 893 + }, 894 + "indexes": {}, 895 + "foreignKeys": { 896 + "page_subscriber_page_id_page_id_fk": { 897 + "name": "page_subscriber_page_id_page_id_fk", 898 + "tableFrom": "page_subscriber", 899 + "tableTo": "page", 900 + "columnsFrom": [ 901 + "page_id" 902 + ], 903 + "columnsTo": [ 904 + "id" 905 + ], 906 + "onDelete": "no action", 907 + "onUpdate": "no action" 908 + } 909 + }, 910 + "compositePrimaryKeys": {}, 911 + "uniqueConstraints": {} 912 + }, 913 + "workspace": { 914 + "name": "workspace", 915 + "columns": { 916 + "id": { 917 + "name": "id", 918 + "type": "integer", 919 + "primaryKey": true, 920 + "notNull": true, 921 + "autoincrement": false 922 + }, 923 + "slug": { 924 + "name": "slug", 925 + "type": "text", 926 + "primaryKey": false, 927 + "notNull": true, 928 + "autoincrement": false 929 + }, 930 + "name": { 931 + "name": "name", 932 + "type": "text", 933 + "primaryKey": false, 934 + "notNull": false, 935 + "autoincrement": false 936 + }, 937 + "stripe_id": { 938 + "name": "stripe_id", 939 + "type": "text(256)", 940 + "primaryKey": false, 941 + "notNull": false, 942 + "autoincrement": false 943 + }, 944 + "subscription_id": { 945 + "name": "subscription_id", 946 + "type": "text", 947 + "primaryKey": false, 948 + "notNull": false, 949 + "autoincrement": false 950 + }, 951 + "plan": { 952 + "name": "plan", 953 + "type": "text", 954 + "primaryKey": false, 955 + "notNull": false, 956 + "autoincrement": false 957 + }, 958 + "ends_at": { 959 + "name": "ends_at", 960 + "type": "integer", 961 + "primaryKey": false, 962 + "notNull": false, 963 + "autoincrement": false 964 + }, 965 + "paid_until": { 966 + "name": "paid_until", 967 + "type": "integer", 968 + "primaryKey": false, 969 + "notNull": false, 970 + "autoincrement": false 971 + }, 972 + "created_at": { 973 + "name": "created_at", 974 + "type": "integer", 975 + "primaryKey": false, 976 + "notNull": false, 977 + "autoincrement": false, 978 + "default": "(strftime('%s', 'now'))" 979 + }, 980 + "updated_at": { 981 + "name": "updated_at", 982 + "type": "integer", 983 + "primaryKey": false, 984 + "notNull": false, 985 + "autoincrement": false, 986 + "default": "(strftime('%s', 'now'))" 987 + } 988 + }, 989 + "indexes": { 990 + "workspace_slug_unique": { 991 + "name": "workspace_slug_unique", 992 + "columns": [ 993 + "slug" 994 + ], 995 + "isUnique": true 996 + }, 997 + "workspace_stripe_id_unique": { 998 + "name": "workspace_stripe_id_unique", 999 + "columns": [ 1000 + "stripe_id" 1001 + ], 1002 + "isUnique": true 1003 + } 1004 + }, 1005 + "foreignKeys": {}, 1006 + "compositePrimaryKeys": {}, 1007 + "uniqueConstraints": {} 1008 + }, 1009 + "notification": { 1010 + "name": "notification", 1011 + "columns": { 1012 + "id": { 1013 + "name": "id", 1014 + "type": "integer", 1015 + "primaryKey": true, 1016 + "notNull": true, 1017 + "autoincrement": false 1018 + }, 1019 + "name": { 1020 + "name": "name", 1021 + "type": "text", 1022 + "primaryKey": false, 1023 + "notNull": true, 1024 + "autoincrement": false 1025 + }, 1026 + "provider": { 1027 + "name": "provider", 1028 + "type": "text", 1029 + "primaryKey": false, 1030 + "notNull": true, 1031 + "autoincrement": false 1032 + }, 1033 + "data": { 1034 + "name": "data", 1035 + "type": "text", 1036 + "primaryKey": false, 1037 + "notNull": false, 1038 + "autoincrement": false, 1039 + "default": "'{}'" 1040 + }, 1041 + "workspace_id": { 1042 + "name": "workspace_id", 1043 + "type": "integer", 1044 + "primaryKey": false, 1045 + "notNull": false, 1046 + "autoincrement": false 1047 + }, 1048 + "created_at": { 1049 + "name": "created_at", 1050 + "type": "integer", 1051 + "primaryKey": false, 1052 + "notNull": false, 1053 + "autoincrement": false, 1054 + "default": "(strftime('%s', 'now'))" 1055 + }, 1056 + "updated_at": { 1057 + "name": "updated_at", 1058 + "type": "integer", 1059 + "primaryKey": false, 1060 + "notNull": false, 1061 + "autoincrement": false, 1062 + "default": "(strftime('%s', 'now'))" 1063 + } 1064 + }, 1065 + "indexes": {}, 1066 + "foreignKeys": { 1067 + "notification_workspace_id_workspace_id_fk": { 1068 + "name": "notification_workspace_id_workspace_id_fk", 1069 + "tableFrom": "notification", 1070 + "tableTo": "workspace", 1071 + "columnsFrom": [ 1072 + "workspace_id" 1073 + ], 1074 + "columnsTo": [ 1075 + "id" 1076 + ], 1077 + "onDelete": "no action", 1078 + "onUpdate": "no action" 1079 + } 1080 + }, 1081 + "compositePrimaryKeys": {}, 1082 + "uniqueConstraints": {} 1083 + }, 1084 + "notifications_to_monitors": { 1085 + "name": "notifications_to_monitors", 1086 + "columns": { 1087 + "monitor_id": { 1088 + "name": "monitor_id", 1089 + "type": "integer", 1090 + "primaryKey": false, 1091 + "notNull": true, 1092 + "autoincrement": false 1093 + }, 1094 + "notification_id": { 1095 + "name": "notification_id", 1096 + "type": "integer", 1097 + "primaryKey": false, 1098 + "notNull": true, 1099 + "autoincrement": false 1100 + }, 1101 + "created_at": { 1102 + "name": "created_at", 1103 + "type": "integer", 1104 + "primaryKey": false, 1105 + "notNull": false, 1106 + "autoincrement": false, 1107 + "default": "(strftime('%s', 'now'))" 1108 + } 1109 + }, 1110 + "indexes": {}, 1111 + "foreignKeys": { 1112 + "notifications_to_monitors_monitor_id_monitor_id_fk": { 1113 + "name": "notifications_to_monitors_monitor_id_monitor_id_fk", 1114 + "tableFrom": "notifications_to_monitors", 1115 + "tableTo": "monitor", 1116 + "columnsFrom": [ 1117 + "monitor_id" 1118 + ], 1119 + "columnsTo": [ 1120 + "id" 1121 + ], 1122 + "onDelete": "cascade", 1123 + "onUpdate": "no action" 1124 + }, 1125 + "notifications_to_monitors_notification_id_notification_id_fk": { 1126 + "name": "notifications_to_monitors_notification_id_notification_id_fk", 1127 + "tableFrom": "notifications_to_monitors", 1128 + "tableTo": "notification", 1129 + "columnsFrom": [ 1130 + "notification_id" 1131 + ], 1132 + "columnsTo": [ 1133 + "id" 1134 + ], 1135 + "onDelete": "cascade", 1136 + "onUpdate": "no action" 1137 + } 1138 + }, 1139 + "compositePrimaryKeys": { 1140 + "notifications_to_monitors_monitor_id_notification_id_pk": { 1141 + "columns": [ 1142 + "monitor_id", 1143 + "notification_id" 1144 + ], 1145 + "name": "notifications_to_monitors_monitor_id_notification_id_pk" 1146 + } 1147 + }, 1148 + "uniqueConstraints": {} 1149 + }, 1150 + "monitor_status": { 1151 + "name": "monitor_status", 1152 + "columns": { 1153 + "monitor_id": { 1154 + "name": "monitor_id", 1155 + "type": "integer", 1156 + "primaryKey": false, 1157 + "notNull": true, 1158 + "autoincrement": false 1159 + }, 1160 + "region": { 1161 + "name": "region", 1162 + "type": "text", 1163 + "primaryKey": false, 1164 + "notNull": true, 1165 + "autoincrement": false, 1166 + "default": "''" 1167 + }, 1168 + "status": { 1169 + "name": "status", 1170 + "type": "text", 1171 + "primaryKey": false, 1172 + "notNull": true, 1173 + "autoincrement": false, 1174 + "default": "'active'" 1175 + }, 1176 + "created_at": { 1177 + "name": "created_at", 1178 + "type": "integer", 1179 + "primaryKey": false, 1180 + "notNull": false, 1181 + "autoincrement": false, 1182 + "default": "(strftime('%s', 'now'))" 1183 + }, 1184 + "updated_at": { 1185 + "name": "updated_at", 1186 + "type": "integer", 1187 + "primaryKey": false, 1188 + "notNull": false, 1189 + "autoincrement": false, 1190 + "default": "(strftime('%s', 'now'))" 1191 + } 1192 + }, 1193 + "indexes": { 1194 + "monitor_status_idx": { 1195 + "name": "monitor_status_idx", 1196 + "columns": [ 1197 + "monitor_id", 1198 + "region" 1199 + ], 1200 + "isUnique": false 1201 + } 1202 + }, 1203 + "foreignKeys": { 1204 + "monitor_status_monitor_id_monitor_id_fk": { 1205 + "name": "monitor_status_monitor_id_monitor_id_fk", 1206 + "tableFrom": "monitor_status", 1207 + "tableTo": "monitor", 1208 + "columnsFrom": [ 1209 + "monitor_id" 1210 + ], 1211 + "columnsTo": [ 1212 + "id" 1213 + ], 1214 + "onDelete": "cascade", 1215 + "onUpdate": "no action" 1216 + } 1217 + }, 1218 + "compositePrimaryKeys": { 1219 + "monitor_status_monitor_id_region_pk": { 1220 + "columns": [ 1221 + "monitor_id", 1222 + "region" 1223 + ], 1224 + "name": "monitor_status_monitor_id_region_pk" 1225 + } 1226 + }, 1227 + "uniqueConstraints": {} 1228 + }, 1229 + "invitation": { 1230 + "name": "invitation", 1231 + "columns": { 1232 + "id": { 1233 + "name": "id", 1234 + "type": "integer", 1235 + "primaryKey": true, 1236 + "notNull": true, 1237 + "autoincrement": false 1238 + }, 1239 + "email": { 1240 + "name": "email", 1241 + "type": "text", 1242 + "primaryKey": false, 1243 + "notNull": true, 1244 + "autoincrement": false 1245 + }, 1246 + "role": { 1247 + "name": "role", 1248 + "type": "text", 1249 + "primaryKey": false, 1250 + "notNull": true, 1251 + "autoincrement": false, 1252 + "default": "'member'" 1253 + }, 1254 + "workspace_id": { 1255 + "name": "workspace_id", 1256 + "type": "integer", 1257 + "primaryKey": false, 1258 + "notNull": true, 1259 + "autoincrement": false 1260 + }, 1261 + "token": { 1262 + "name": "token", 1263 + "type": "text", 1264 + "primaryKey": false, 1265 + "notNull": true, 1266 + "autoincrement": false 1267 + }, 1268 + "expires_at": { 1269 + "name": "expires_at", 1270 + "type": "integer", 1271 + "primaryKey": false, 1272 + "notNull": true, 1273 + "autoincrement": false 1274 + }, 1275 + "created_at": { 1276 + "name": "created_at", 1277 + "type": "integer", 1278 + "primaryKey": false, 1279 + "notNull": false, 1280 + "autoincrement": false, 1281 + "default": "(strftime('%s', 'now'))" 1282 + }, 1283 + "accepted_at": { 1284 + "name": "accepted_at", 1285 + "type": "integer", 1286 + "primaryKey": false, 1287 + "notNull": false, 1288 + "autoincrement": false 1289 + } 1290 + }, 1291 + "indexes": {}, 1292 + "foreignKeys": {}, 1293 + "compositePrimaryKeys": {}, 1294 + "uniqueConstraints": {} 1295 + }, 1296 + "incident": { 1297 + "name": "incident", 1298 + "columns": { 1299 + "id": { 1300 + "name": "id", 1301 + "type": "integer", 1302 + "primaryKey": true, 1303 + "notNull": true, 1304 + "autoincrement": false 1305 + }, 1306 + "title": { 1307 + "name": "title", 1308 + "type": "text", 1309 + "primaryKey": false, 1310 + "notNull": true, 1311 + "autoincrement": false, 1312 + "default": "''" 1313 + }, 1314 + "summary": { 1315 + "name": "summary", 1316 + "type": "text", 1317 + "primaryKey": false, 1318 + "notNull": true, 1319 + "autoincrement": false, 1320 + "default": "''" 1321 + }, 1322 + "status": { 1323 + "name": "status", 1324 + "type": "text", 1325 + "primaryKey": false, 1326 + "notNull": true, 1327 + "autoincrement": false, 1328 + "default": "'triage'" 1329 + }, 1330 + "monitor_id": { 1331 + "name": "monitor_id", 1332 + "type": "integer", 1333 + "primaryKey": false, 1334 + "notNull": false, 1335 + "autoincrement": false 1336 + }, 1337 + "workspace_id": { 1338 + "name": "workspace_id", 1339 + "type": "integer", 1340 + "primaryKey": false, 1341 + "notNull": false, 1342 + "autoincrement": false 1343 + }, 1344 + "started_at": { 1345 + "name": "started_at", 1346 + "type": "integer", 1347 + "primaryKey": false, 1348 + "notNull": true, 1349 + "autoincrement": false, 1350 + "default": "(strftime('%s', 'now'))" 1351 + }, 1352 + "acknowledged_at": { 1353 + "name": "acknowledged_at", 1354 + "type": "integer", 1355 + "primaryKey": false, 1356 + "notNull": false, 1357 + "autoincrement": false 1358 + }, 1359 + "acknowledged_by": { 1360 + "name": "acknowledged_by", 1361 + "type": "integer", 1362 + "primaryKey": false, 1363 + "notNull": false, 1364 + "autoincrement": false 1365 + }, 1366 + "resolved_at": { 1367 + "name": "resolved_at", 1368 + "type": "integer", 1369 + "primaryKey": false, 1370 + "notNull": false, 1371 + "autoincrement": false 1372 + }, 1373 + "resolved_by": { 1374 + "name": "resolved_by", 1375 + "type": "integer", 1376 + "primaryKey": false, 1377 + "notNull": false, 1378 + "autoincrement": false 1379 + }, 1380 + "auto_resolved": { 1381 + "name": "auto_resolved", 1382 + "type": "integer", 1383 + "primaryKey": false, 1384 + "notNull": false, 1385 + "autoincrement": false, 1386 + "default": false 1387 + }, 1388 + "created_at": { 1389 + "name": "created_at", 1390 + "type": "integer", 1391 + "primaryKey": false, 1392 + "notNull": false, 1393 + "autoincrement": false, 1394 + "default": "(strftime('%s', 'now'))" 1395 + }, 1396 + "updated_at": { 1397 + "name": "updated_at", 1398 + "type": "integer", 1399 + "primaryKey": false, 1400 + "notNull": false, 1401 + "autoincrement": false, 1402 + "default": "(strftime('%s', 'now'))" 1403 + } 1404 + }, 1405 + "indexes": { 1406 + "incident_monitor_id_started_at_unique": { 1407 + "name": "incident_monitor_id_started_at_unique", 1408 + "columns": [ 1409 + "monitor_id", 1410 + "started_at" 1411 + ], 1412 + "isUnique": true 1413 + } 1414 + }, 1415 + "foreignKeys": { 1416 + "incident_monitor_id_monitor_id_fk": { 1417 + "name": "incident_monitor_id_monitor_id_fk", 1418 + "tableFrom": "incident", 1419 + "tableTo": "monitor", 1420 + "columnsFrom": [ 1421 + "monitor_id" 1422 + ], 1423 + "columnsTo": [ 1424 + "id" 1425 + ], 1426 + "onDelete": "set default", 1427 + "onUpdate": "no action" 1428 + }, 1429 + "incident_workspace_id_workspace_id_fk": { 1430 + "name": "incident_workspace_id_workspace_id_fk", 1431 + "tableFrom": "incident", 1432 + "tableTo": "workspace", 1433 + "columnsFrom": [ 1434 + "workspace_id" 1435 + ], 1436 + "columnsTo": [ 1437 + "id" 1438 + ], 1439 + "onDelete": "no action", 1440 + "onUpdate": "no action" 1441 + }, 1442 + "incident_acknowledged_by_user_id_fk": { 1443 + "name": "incident_acknowledged_by_user_id_fk", 1444 + "tableFrom": "incident", 1445 + "tableTo": "user", 1446 + "columnsFrom": [ 1447 + "acknowledged_by" 1448 + ], 1449 + "columnsTo": [ 1450 + "id" 1451 + ], 1452 + "onDelete": "no action", 1453 + "onUpdate": "no action" 1454 + }, 1455 + "incident_resolved_by_user_id_fk": { 1456 + "name": "incident_resolved_by_user_id_fk", 1457 + "tableFrom": "incident", 1458 + "tableTo": "user", 1459 + "columnsFrom": [ 1460 + "resolved_by" 1461 + ], 1462 + "columnsTo": [ 1463 + "id" 1464 + ], 1465 + "onDelete": "no action", 1466 + "onUpdate": "no action" 1467 + } 1468 + }, 1469 + "compositePrimaryKeys": {}, 1470 + "uniqueConstraints": {} 1471 + }, 1472 + "monitor_tag": { 1473 + "name": "monitor_tag", 1474 + "columns": { 1475 + "id": { 1476 + "name": "id", 1477 + "type": "integer", 1478 + "primaryKey": true, 1479 + "notNull": true, 1480 + "autoincrement": false 1481 + }, 1482 + "workspace_id": { 1483 + "name": "workspace_id", 1484 + "type": "integer", 1485 + "primaryKey": false, 1486 + "notNull": true, 1487 + "autoincrement": false 1488 + }, 1489 + "name": { 1490 + "name": "name", 1491 + "type": "text", 1492 + "primaryKey": false, 1493 + "notNull": true, 1494 + "autoincrement": false 1495 + }, 1496 + "color": { 1497 + "name": "color", 1498 + "type": "text", 1499 + "primaryKey": false, 1500 + "notNull": true, 1501 + "autoincrement": false 1502 + }, 1503 + "created_at": { 1504 + "name": "created_at", 1505 + "type": "integer", 1506 + "primaryKey": false, 1507 + "notNull": false, 1508 + "autoincrement": false, 1509 + "default": "(strftime('%s', 'now'))" 1510 + }, 1511 + "updated_at": { 1512 + "name": "updated_at", 1513 + "type": "integer", 1514 + "primaryKey": false, 1515 + "notNull": false, 1516 + "autoincrement": false, 1517 + "default": "(strftime('%s', 'now'))" 1518 + } 1519 + }, 1520 + "indexes": {}, 1521 + "foreignKeys": { 1522 + "monitor_tag_workspace_id_workspace_id_fk": { 1523 + "name": "monitor_tag_workspace_id_workspace_id_fk", 1524 + "tableFrom": "monitor_tag", 1525 + "tableTo": "workspace", 1526 + "columnsFrom": [ 1527 + "workspace_id" 1528 + ], 1529 + "columnsTo": [ 1530 + "id" 1531 + ], 1532 + "onDelete": "cascade", 1533 + "onUpdate": "no action" 1534 + } 1535 + }, 1536 + "compositePrimaryKeys": {}, 1537 + "uniqueConstraints": {} 1538 + }, 1539 + "monitor_tag_to_monitor": { 1540 + "name": "monitor_tag_to_monitor", 1541 + "columns": { 1542 + "monitor_id": { 1543 + "name": "monitor_id", 1544 + "type": "integer", 1545 + "primaryKey": false, 1546 + "notNull": true, 1547 + "autoincrement": false 1548 + }, 1549 + "monitor_tag_id": { 1550 + "name": "monitor_tag_id", 1551 + "type": "integer", 1552 + "primaryKey": false, 1553 + "notNull": true, 1554 + "autoincrement": false 1555 + }, 1556 + "created_at": { 1557 + "name": "created_at", 1558 + "type": "integer", 1559 + "primaryKey": false, 1560 + "notNull": false, 1561 + "autoincrement": false, 1562 + "default": "(strftime('%s', 'now'))" 1563 + } 1564 + }, 1565 + "indexes": {}, 1566 + "foreignKeys": { 1567 + "monitor_tag_to_monitor_monitor_id_monitor_id_fk": { 1568 + "name": "monitor_tag_to_monitor_monitor_id_monitor_id_fk", 1569 + "tableFrom": "monitor_tag_to_monitor", 1570 + "tableTo": "monitor", 1571 + "columnsFrom": [ 1572 + "monitor_id" 1573 + ], 1574 + "columnsTo": [ 1575 + "id" 1576 + ], 1577 + "onDelete": "cascade", 1578 + "onUpdate": "no action" 1579 + }, 1580 + "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk": { 1581 + "name": "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk", 1582 + "tableFrom": "monitor_tag_to_monitor", 1583 + "tableTo": "monitor_tag", 1584 + "columnsFrom": [ 1585 + "monitor_tag_id" 1586 + ], 1587 + "columnsTo": [ 1588 + "id" 1589 + ], 1590 + "onDelete": "cascade", 1591 + "onUpdate": "no action" 1592 + } 1593 + }, 1594 + "compositePrimaryKeys": { 1595 + "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk": { 1596 + "columns": [ 1597 + "monitor_id", 1598 + "monitor_tag_id" 1599 + ], 1600 + "name": "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk" 1601 + } 1602 + }, 1603 + "uniqueConstraints": {} 1604 + } 1605 + }, 1606 + "enums": {}, 1607 + "_meta": { 1608 + "schemas": {}, 1609 + "tables": {}, 1610 + "columns": {} 1611 + } 1612 + }
+7
packages/db/drizzle/meta/_journal.json
··· 155 155 "when": 1710677383007, 156 156 "tag": "0021_reflective_nico_minoru", 157 157 "breakpoints": true 158 + }, 159 + { 160 + "idx": 22, 161 + "version": "5", 162 + "when": 1711307113089, 163 + "tag": "0022_chunky_rockslide", 164 + "breakpoints": true 158 165 } 159 166 ] 160 167 }
+1
packages/db/package.json
··· 20 20 "zod": "3.22.2" 21 21 }, 22 22 "devDependencies": { 23 + "@openstatus/assertions": "workspace:*", 23 24 "@openstatus/tsconfig": "workspace:*", 24 25 "@types/node": "20.8.0", 25 26 "better-sqlite3": "9.4.1",
+2
packages/db/src/schema/monitors/monitor.ts
··· 41 41 method: text("method", { enum: monitorMethods }).default("GET"), 42 42 workspaceId: integer("workspace_id").references(() => workspace.id), 43 43 44 + assertions: text("assertions"), 45 + 44 46 createdAt: integer("created_at", { mode: "timestamp" }).default( 45 47 sql`(strftime('%s', 'now'))`, 46 48 ),
+4
packages/db/src/schema/monitors/validation.ts
··· 1 1 import { createInsertSchema, createSelectSchema } from "drizzle-zod"; 2 2 import { z } from "zod"; 3 3 4 + import * as assertions from "@openstatus/assertions"; 5 + 4 6 import { 5 7 flyRegions, 6 8 monitorJobTypes, ··· 73 75 pages: z.array(z.number()).optional().default([]), 74 76 body: z.string().default("").optional(), 75 77 tags: z.array(z.number()).optional().default([]), 78 + statusAssertions: z.array(assertions.statusAssertion).optional(), 79 + headerAssertions: z.array(assertions.headerAssertion).optional(), 76 80 }); 77 81 78 82 export type InsertMonitor = z.infer<typeof insertMonitorSchema>;
+4 -2
packages/tinybird/_migration/ping_response.datasource
··· 1 - VERSION 7 1 + VERSION 8 2 2 3 3 SCHEMA > 4 4 `latency` Int64 `json:$.latency`, 5 5 `monitorId` String `json:$.monitorId`, 6 6 `region` LowCardinality(String) `json:$.region`, 7 7 `statusCode` Nullable(Int16) `json:$.statusCode`, 8 + `error` Int8 `json:$.error`, 8 9 `timestamp` Int64 `json:$.timestamp`, 9 10 `url` String `json:$.url`, 10 11 `workspaceId` String `json:$.workspaceId`, 11 12 `cronTimestamp` Int64 `json:$.cronTimestamp`, 12 13 `message` Nullable(String) `json:$.message`, 13 14 `timing` Nullable(String) `json:$.timing`, 14 - `headers` Nullable(String) `json:$.headers` 15 + `headers` Nullable(String) `json:$.headers`, 16 + `assertions` Nullable(String) `json:$.assertions` 15 17 16 18 ENGINE "MergeTree" 17 19 ENGINE_SORTING_KEY "monitorId, cronTimestamp"
-16
packages/tinybird/_migration/ping_response__v6.datasource
··· 1 - VERSION 6 2 - 3 - SCHEMA > 4 - `latency` Int64 `json:$.latency`, 5 - `monitorId` String `json:$.monitorId`, 6 - `region` LowCardinality(String) `json:$.region`, 7 - `statusCode` Nullable(Int16) `json:$.statusCode`, 8 - `timestamp` Int64 `json:$.timestamp`, 9 - `url` String `json:$.url`, 10 - `workspaceId` String `json:$.workspaceId`, 11 - `cronTimestamp` Int64 `json:$.cronTimestamp`, 12 - `message` Nullable(String) `json:$.message` 13 - 14 - ENGINE "MergeTree" 15 - ENGINE_SORTING_KEY "monitorId, cronTimestamp" 16 - ENGINE_PARTITION_KEY "toYYYYMM(fromUnixTimestamp64Milli(cronTimestamp))"
+21
packages/tinybird/_migration/tb_backfill_populate.pipe
··· 1 + NODE mat_node 2 + SQL > 3 + 4 + SELECT 5 + latency, 6 + monitorId, 7 + toLowCardinality(region) region, 8 + statusCode, 9 + if(statusCode >= 200 AND statusCode < 300, toInt8(0), toInt8(1)) AS error, 10 + timestamp, 11 + url, 12 + workspaceId, 13 + cronTimestamp, 14 + message, 15 + timing, 16 + headers, 17 + FROM ping_response__v7 18 + WHERE fromUnixTimestamp64Milli(cronTimestamp) <= '2024-03-29 13:16:00.000' 19 + 20 + TYPE materialized 21 + DATASOURCE ping_response__v8
-18
packages/tinybird/_migration/tb_backfill_populate__v7.pipe
··· 1 - NODE mat_node 2 - SQL > 3 - 4 - SELECT 5 - latency, 6 - monitorId, 7 - toLowCardinality(region) region, 8 - statusCode, 9 - timestamp, 10 - url, 11 - workspaceId, 12 - cronTimestamp, 13 - message 14 - FROM ping_response__v6 15 - WHERE fromUnixTimestamp64Milli(cronTimestamp) <= '2024-01-16 18:16:00.000' 16 - 17 - TYPE materialized 18 - DATASOURCE ping_response__v7
+2 -2
packages/tinybird/_migration/tb_datasource_union.pipe
··· 1 1 NODE union_all 2 2 SQL > 3 3 4 - SELECT count() FROM ping_response__v7 4 + SELECT count() FROM ping_response__v8 5 5 UNION ALL 6 - SELECT count() FROM ping_response__v6 6 + SELECT count() FROM ping_response__v7
+21
packages/tinybird/_migration/tb_materialize_until_change_ingest.pipe
··· 1 + NODE mat_node 2 + SQL > 3 + 4 + SELECT 5 + latency, 6 + monitorId, 7 + toLowCardinality(region) region, 8 + statusCode, 9 + if(statusCode >= 200 AND statusCode < 300, toInt8(0), toInt8(1)) AS error, 10 + timestamp, 11 + url, 12 + workspaceId, 13 + cronTimestamp, 14 + message, 15 + timing, 16 + headers, 17 + FROM ping_response__v7 18 + WHERE fromUnixTimestamp64Milli(cronTimestamp) > '2023-03-29 13:16:00.000' 19 + 20 + TYPE materialized 21 + DATASOURCE ping_response__v8
-18
packages/tinybird/_migration/tb_materialized_until_change_ingest__v7.pipe
··· 1 - NODE mat_node 2 - SQL > 3 - 4 - SELECT 5 - latency, 6 - monitorId, 7 - toLowCardinality(region) region, 8 - statusCode, 9 - timestamp, 10 - url, 11 - workspaceId, 12 - cronTimestamp, 13 - message 14 - FROM ping_response__v6 15 - WHERE fromUnixTimestamp64Milli(cronTimestamp) > '2023-11-16 18:16:00.000' 16 - 17 - TYPE materialized 18 - DATASOURCE ping_response__v7
+3 -2
packages/tinybird/pipes/__ttl_14d.pipe
··· 1 - VERSION 0 1 + VERSION 1 2 2 3 3 NODE __ttl_14d_0 4 4 SQL > ··· 7 7 toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, 8 8 latency, 9 9 monitorId, 10 + error, 10 11 region, 11 12 statusCode, 12 13 url, ··· 15 16 FROM ping_response__v7 16 17 17 18 TYPE materialized 18 - DATASOURCE __ttl_14d_mv__v0 19 + DATASOURCE __ttl_14d_mv__v1 19 20 20 21
+2 -4
packages/tinybird/pipes/__ttl_14d_chart_get.pipe
··· 1 - VERSION 0 2 - 3 - TOKEN "__ttl_14d_chart_get_endpoint_read_2504" READ 1 + VERSION 1 4 2 5 3 NODE __ttl_14d_chart_get_0 6 4 SQL > ··· 18 16 round(quantile(0.9)(latency)) as p90Latency, 19 17 round(quantile(0.95)(latency)) as p95Latency, 20 18 round(quantile(0.99)(latency)) as p99Latency 21 - FROM __ttl_14d_mv__v0 19 + FROM __ttl_14d_mv__v1 22 20 WHERE 23 21 monitorId = {{ String(monitorId, '1') }} 24 22 {% if defined(url) %} AND url = {{ String(url) }} {% end %}
+5 -7
packages/tinybird/pipes/__ttl_14d_metrics_get.pipe
··· 1 - VERSION 0 2 - 3 - TOKEN "__ttl_14d_metrics_get_endpoint_read_3564" READ 1 + VERSION 1 4 2 5 3 NODE __ttl_14d_metrics_get_0 6 4 SQL > ··· 13 11 round(quantile(0.95)(latency)) as p95Latency, 14 12 round(quantile(0.99)(latency)) as p99Latency, 15 13 count() as count, 16 - count(multiIf((statusCode >= 200) AND (statusCode <= 299), 1, NULL)) AS ok, 14 + count(if(error = 0, 1, NULL)) AS ok, 17 15 max(cronTimestamp) AS lastTimestamp 18 - FROM __ttl_14d_mv__v0 16 + FROM __ttl_14d_mv__v1 19 17 WHERE 20 18 monitorId = {{ String(monitorId, '1') }} 21 19 {% if defined(url) %} AND url = {{ String(url) }} {% end %} ··· 28 26 round(quantile(0.95)(latency)) AS p95Latency, 29 27 round(quantile(0.99)(latency)) AS p99Latency, 30 28 count() as count, 31 - count(multiIf((statusCode >= 200) AND (statusCode <= 299), 1, NULL)) AS ok, 29 + count(if(error = 0, 1, NULL)) AS ok, 32 30 NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant 33 - FROM __ttl_45d_mv__v0 31 + FROM __ttl_45d_mv__v1 34 32 WHERE 35 33 monitorId = {{ String(monitorId, '1') }} 36 34 {% if defined(url) %} AND url = {{ String(url) }} {% end %}
+3 -5
packages/tinybird/pipes/__ttl_14d_metrics_get_by_region.pipe
··· 1 - VERSION 0 2 - 3 - TOKEN "__ttl_14d_metrics_get_by_region_endpoint_read_0048" READ 1 + VERSION 1 4 2 5 3 NODE __ttl_14d_metrics_get_by_region_0 6 4 SQL > ··· 14 12 round(quantile(0.95)(latency)) as p95Latency, 15 13 round(quantile(0.99)(latency)) as p99Latency, 16 14 count() as count, 17 - count(multiIf((statusCode >= 200) AND (statusCode <= 299), 1, NULL)) AS ok, 15 + count(if(error = 0, 1, NULL)) AS ok, 18 16 max(cronTimestamp) AS lastTimestamp 19 - FROM __ttl_14d_mv__v0 17 + FROM __ttl_14d_mv__v1 20 18 WHERE 21 19 monitorId = {{ String(monitorId, '1') }} 22 20 {% if defined(url) %} AND url = {{ String(url) }} {% end %}
+4 -3
packages/tinybird/pipes/__ttl_1d.pipe
··· 1 - VERSION 0 1 + VERSION 1 2 2 3 3 NODE __ttl_1d_0 4 4 SQL > ··· 7 7 toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, 8 8 latency, 9 9 monitorId, 10 + error, 10 11 region, 11 12 statusCode, 12 13 url, 13 14 workspaceId, 14 15 timestamp, 15 16 cronTimestamp 16 - FROM ping_response__v7 17 + FROM ping_response__v8 17 18 18 19 TYPE materialized 19 - DATASOURCE __ttl_1d_mv__v0 20 + DATASOURCE __ttl_1d_mv__v1 20 21 21 22
+2 -2
packages/tinybird/pipes/__ttl_1d_chart_get.pipe
··· 1 - VERSION 0 1 + VERSION 1 2 2 3 3 TOKEN "__ttl_1d_chart_get_endpoint_read_4815" READ 4 4 ··· 18 18 round(quantile(0.9)(latency)) as p90Latency, 19 19 round(quantile(0.95)(latency)) as p95Latency, 20 20 round(quantile(0.99)(latency)) as p99Latency 21 - FROM __ttl_1d_mv__v0 21 + FROM __ttl_1d_mv__v1 22 22 WHERE 23 23 monitorId = {{ String(monitorId, '1') }} 24 24 {% if defined(url) %} AND url = {{ String(url) }} {% end %}
+3 -5
packages/tinybird/pipes/__ttl_1d_list_get.pipe
··· 1 - VERSION 0 2 - 3 - TOKEN "__ttl_1d_list_get__v0_endpoint_read_1695" READ 1 + VERSION 1 4 2 5 3 NODE __ttl_1d_list_get__v0_0 6 4 SQL > 7 5 8 6 % 9 - SELECT latency, monitorId, region, statusCode, timestamp, url, workspaceId, cronTimestamp 10 - FROM __ttl_1d_mv__v0 7 + SELECT latency, monitorId, error, region, statusCode, timestamp, url, workspaceId, cronTimestamp 8 + FROM __ttl_1d_mv__v1 11 9 WHERE 12 10 monitorId = {{ String(monitorId, '1') }} 13 11 {% if defined(url) %} AND url = {{ String(url) }} {% end %}
+5 -7
packages/tinybird/pipes/__ttl_1d_metrics_get.pipe
··· 1 - VERSION 0 2 - 3 - TOKEN "__ttl_1d_metrics_get_endpoint_read_1167" READ 1 + VERSION 1 4 2 5 3 NODE __ttl_1d_metrics_get_0 6 4 SQL > ··· 13 11 round(quantile(0.95)(latency)) as p95Latency, 14 12 round(quantile(0.99)(latency)) as p99Latency, 15 13 count() as count, 16 - count(multiIf((statusCode >= 200) AND (statusCode <= 299), 1, NULL)) AS ok, 14 + count(if(error = 0, 1, NULL)) AS ok, 17 15 max(cronTimestamp) AS lastTimestamp 18 - FROM __ttl_1d_mv__v0 16 + FROM __ttl_1d_mv__v1 19 17 WHERE 20 18 monitorId = {{ String(monitorId, '1') }} 21 19 {% if defined(url) %} AND url = {{ String(url) }} {% end %} ··· 28 26 round(quantile(0.95)(latency)) AS p95Latency, 29 27 round(quantile(0.99)(latency)) AS p99Latency, 30 28 count() as count, 31 - count(multiIf((statusCode >= 200) AND (statusCode <= 299), 1, NULL)) AS ok, 29 + count(if(error = 0, 1, NULL)) AS ok, 32 30 NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant 33 - FROM __ttl_3d_mv__v0 31 + FROM __ttl_3d_mv__v1 34 32 WHERE 35 33 monitorId = {{ String(monitorId, '1') }} 36 34 {% if defined(url) %} AND url = {{ String(url) }} {% end %}
+3 -5
packages/tinybird/pipes/__ttl_1d_metrics_get_by_region.pipe
··· 1 - VERSION 0 2 - 3 - TOKEN "__ttl_1d_metrics_get_by_region_endpoint_read_3463" READ 1 + VERSION 1 4 2 5 3 NODE __ttl_1d_metrics_get_by_region_0 6 4 SQL > ··· 14 12 round(quantile(0.95)(latency)) as p95Latency, 15 13 round(quantile(0.99)(latency)) as p99Latency, 16 14 count() as count, 17 - count(multiIf((statusCode >= 200) AND (statusCode <= 299), 1, NULL)) AS ok 18 - FROM __ttl_1d_mv__v0 15 + count(if(error = 0, 1, NULL)) AS ok 16 + FROM __ttl_1d_mv__v1 19 17 WHERE 20 18 monitorId = {{ String(monitorId, '1') }} 21 19 {% if defined(url) %} AND url = {{ String(url) }} {% end %}
+4 -3
packages/tinybird/pipes/__ttl_1h.pipe
··· 1 - VERSION 0 1 + VERSION 1 2 2 3 3 NODE __ttl_1h_0 4 4 SQL > ··· 7 7 toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, 8 8 latency, 9 9 monitorId, 10 + error, 10 11 region, 11 12 statusCode, 12 13 url, 13 14 workspaceId, 14 15 timestamp, 15 16 cronTimestamp 16 - FROM ping_response__v7 17 + FROM ping_response__v8 17 18 18 19 TYPE materialized 19 - DATASOURCE __ttl_1h_mv__v0 20 + DATASOURCE __ttl_1h_mv__v1 20 21 21 22
+2 -4
packages/tinybird/pipes/__ttl_1h_chart_get.pipe
··· 1 - VERSION 0 2 - 3 - TOKEN "__ttl_1h_chart_get__v0_endpoint_read_3603" READ 1 + VERSION 1 4 2 5 3 NODE __ttl_1h_chart_get_0 6 4 SQL > ··· 18 16 round(quantile(0.9)(latency)) as p90Latency, 19 17 round(quantile(0.95)(latency)) as p95Latency, 20 18 round(quantile(0.99)(latency)) as p99Latency 21 - FROM __ttl_1h_mv__v0 19 + FROM __ttl_1h_mv__v1 22 20 WHERE 23 21 monitorId = {{ String(monitorId, '1') }} 24 22 {% if defined(url) %} AND url = {{ String(url) }} {% end %}
+2 -4
packages/tinybird/pipes/__ttl_1h_last_timestamp_monitor_get.pipe
··· 1 - VERSION 0 2 - 3 - TOKEN "__ttl_1h_last_timestamp_monitor_get__v0_endpoint_read_5766" READ 1 + VERSION 1 4 2 5 3 NODE __ttl_1h_last_timestamp_monitor_get__v0_0 6 4 SQL > 7 5 8 6 % 9 7 SELECT max(cronTimestamp) as cronTimestamp 10 - FROM __ttl_1h_mv__v0 8 + FROM __ttl_1h_mv__v1 11 9 WHERE 12 10 monitorId = {{ String(monitorId, '1') }} 13 11 {% if defined(url) %} AND url = {{ String(url) }} {% end %}
+2 -4
packages/tinybird/pipes/__ttl_1h_last_timestamp_workspace_get.pipe
··· 1 - VERSION 0 2 - 3 - TOKEN "__ttl_1h_last_timestamp_workspace_get__v0_endpoint_read_5322" READ 1 + VERSION 1 4 2 5 3 NODE __ttl_1h_last_timestamp_workspace_get__v0_0 6 4 SQL > 7 5 8 6 % 9 7 SELECT max(cronTimestamp) as cronTimestamp 10 - FROM __ttl_1h_mv__v0 8 + FROM __ttl_1h_mv__v1 11 9 WHERE workspaceId = {{ String(workspaceId, '1') }} 12 10 13 11
+3 -5
packages/tinybird/pipes/__ttl_1h_list_get.pipe
··· 1 - VERSION 0 2 - 3 - TOKEN "__ttl_1h_list_get__v0_endpoint_read_0371" READ 1 + VERSION 1 4 2 5 3 NODE __ttl_1h_list_get__v0_0 6 4 SQL > 7 5 8 6 % 9 7 -- FIXME: `timestamp` is missing on 1h, 3d, 7d, 45d mv! 10 - SELECT latency, monitorId, region, statusCode, url, workspaceId, cronTimestamp, timestamp 11 - FROM __ttl_1h_mv__v0 8 + SELECT latency, monitorId, error, region, statusCode, url, workspaceId, cronTimestamp, timestamp 9 + FROM __ttl_1h_mv__v1 12 10 WHERE 13 11 monitorId = {{ String(monitorId, '1') }} 14 12 {% if defined(url) %} AND url = {{ String(url) }} {% end %}
+5 -7
packages/tinybird/pipes/__ttl_1h_metrics_get.pipe
··· 1 - VERSION 0 2 - 3 - TOKEN "__ttl_1h_metrics_get__v0_endpoint_read_0457" READ 1 + VERSION 1 4 2 5 3 NODE __ttl_1h_metrics_get_0 6 4 SQL > ··· 13 11 round(quantile(0.95)(latency)) as p95Latency, 14 12 round(quantile(0.99)(latency)) as p99Latency, 15 13 count() as count, 16 - count(multiIf((statusCode >= 200) AND (statusCode <= 299), 1, NULL)) AS ok, 14 + count(if(error = 0, 1, NULL)) AS ok, 17 15 max(cronTimestamp) AS lastTimestamp 18 - FROM __ttl_1h_mv__v0 16 + FROM __ttl_1h_mv__v1 19 17 WHERE 20 18 monitorId = {{ String(monitorId, '1') }} 21 19 {% if defined(url) %} AND url = {{ String(url) }} {% end %} ··· 28 26 round(quantile(0.95)(latency)) AS p95Latency, 29 27 round(quantile(0.99)(latency)) AS p99Latency, 30 28 count() as count, 31 - count(multiIf((statusCode >= 200) AND (statusCode <= 299), 1, NULL)) AS ok, 29 + count(if(error = 0, 1, NULL)) AS ok, 32 30 NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant 33 - FROM __ttl_1d_mv__v0 31 + FROM __ttl_1d_mv__v1 34 32 WHERE 35 33 monitorId = {{ String(monitorId, '1') }} 36 34 {% if defined(url) %} AND url = {{ String(url) }} {% end %}
+3 -5
packages/tinybird/pipes/__ttl_1h_metrics_get_by_region.pipe
··· 1 - VERSION 0 2 - 3 - TOKEN "__ttl_1h_metrics_get_by_region__v0_endpoint_read_0839" READ 1 + VERSION 1 4 2 5 3 NODE __ttl_1h_metrics_get_by_region_0 6 4 SQL > ··· 14 12 round(quantile(0.95)(latency)) as p95Latency, 15 13 round(quantile(0.99)(latency)) as p99Latency, 16 14 count() as count, 17 - count(multiIf((statusCode >= 200) AND (statusCode <= 299), 1, NULL)) AS ok 18 - FROM __ttl_1h_mv__v0 15 + count(if(error = 0, 1, NULL)) AS ok, 16 + FROM __ttl_1h_mv__v1 19 17 WHERE 20 18 monitorId = {{ String(monitorId, '1') }} 21 19 {% if defined(url) %} AND url = {{ String(url) }} {% end %}
+4 -3
packages/tinybird/pipes/__ttl_3d.pipe
··· 1 - VERSION 0 1 + VERSION 1 2 2 3 3 NODE __ttl_3d_0 4 4 SQL > ··· 7 7 toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, 8 8 latency, 9 9 monitorId, 10 + error, 10 11 region, 11 12 statusCode, 12 13 url, 13 14 workspaceId, 14 15 cronTimestamp 15 - FROM ping_response__v7 16 + FROM ping_response__v8 16 17 17 18 TYPE materialized 18 - DATASOURCE __ttl_3d_mv__v0 19 + DATASOURCE __ttl_3d_mv__v1 19 20 20 21
+1 -3
packages/tinybird/pipes/__ttl_3d_chart_get.pipe
··· 1 - VERSION 0 2 - 3 - TOKEN "__ttl_3d_chart_get_endpoint_read_5551" READ 1 + VERSION 1 4 2 5 3 NODE __ttl_3d_chart_get_0 6 4 SQL >
+5 -7
packages/tinybird/pipes/__ttl_3d_metrics_get.pipe
··· 1 - VERSION 0 2 - 3 - TOKEN "__ttl_3d_metrics_get_endpoint_read_5801" READ 1 + VERSION 1 4 2 5 3 NODE __ttl_3d_metrics_get_0 6 4 SQL > ··· 13 11 round(quantile(0.95)(latency)) as p95Latency, 14 12 round(quantile(0.99)(latency)) as p99Latency, 15 13 count() as count, 16 - count(multiIf((statusCode >= 200) AND (statusCode <= 299), 1, NULL)) AS ok, 14 + count(if(error = 0, 1, NULL)) AS ok, 17 15 max(cronTimestamp) AS lastTimestamp 18 - FROM __ttl_3d_mv__v0 16 + FROM __ttl_3d_mv__v1 19 17 WHERE 20 18 monitorId = {{ String(monitorId, '1') }} 21 19 {% if defined(url) %} AND url = {{ String(url) }} {% end %} ··· 28 26 round(quantile(0.95)(latency)) AS p95Latency, 29 27 round(quantile(0.99)(latency)) AS p99Latency, 30 28 count() as count, 31 - count(multiIf((statusCode >= 200) AND (statusCode <= 299), 1, NULL)) AS ok, 29 + count(if(error = 0, 1, NULL)) AS ok, 32 30 NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant 33 - FROM __ttl_7d_mv__v0 31 + FROM __ttl_7d_mv__v1 34 32 WHERE 35 33 monitorId = {{ String(monitorId, '1') }} 36 34 {% if defined(url) %} AND url = {{ String(url) }} {% end %}
+2 -4
packages/tinybird/pipes/__ttl_3d_metrics_get_by_region.pipe
··· 1 - VERSION 0 2 - 3 - TOKEN "__ttl_3d_metrics_get_by_region__v0_endpoint_read_3349" READ 1 + VERSION 1 4 2 5 3 NODE __ttl_3d_metrics_get_by_region_0 6 4 SQL > ··· 14 12 round(quantile(0.95)(latency)) as p95Latency, 15 13 round(quantile(0.99)(latency)) as p99Latency, 16 14 count() as count, 17 - count(multiIf((statusCode >= 200) AND (statusCode <= 299), 1, NULL)) AS ok, 15 + count(if(error = 0, 1, NULL)) AS ok, 18 16 max(cronTimestamp) AS lastTimestamp 19 17 FROM __ttl_3d_mv__v0 20 18 WHERE
+3 -3
packages/tinybird/pipes/__ttl_45d.pipe
··· 1 - VERSION 0 1 + VERSION 1 2 2 3 3 NODE __ttl_45d_0 4 4 SQL > ··· 12 12 url, 13 13 workspaceId, 14 14 cronTimestamp 15 - FROM ping_response__v7 15 + FROM ping_response__v8 16 16 17 17 TYPE materialized 18 - DATASOURCE __ttl_45d_mv__v0 18 + DATASOURCE __ttl_45d_mv__v1 19 19 20 20
+3 -3
packages/tinybird/pipes/__ttl_45d_all.pipe
··· 1 - VERSION 0 1 + VERSION 1 2 2 3 3 NODE __ttl_45d_all_0 4 4 SQL > 5 5 6 - SELECT toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, * FROM ping_response__v7 6 + SELECT toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, * FROM ping_response__v8 7 7 8 8 TYPE materialized 9 - DATASOURCE __ttl_45d_all_mv__v0 9 + DATASOURCE __ttl_45d_all_mv__v1 10 10 11 11
+2 -4
packages/tinybird/pipes/__ttl_45d_all_details_get.pipe
··· 1 - VERSION 0 2 - 3 - TOKEN "__ttl_45d_all_details_get__v0_endpoint_read_7742" READ 1 + VERSION 1 4 2 5 3 NODE __ttl_45d_all_details_get_0 6 4 SQL > 7 5 8 6 % 9 7 SELECT * 10 - from __ttl_45d_all_mv__v0 8 + from __ttl_45d_all_mv__v1 11 9 WHERE 12 10 monitorId = {{ String(monitorId, '1') }} 13 11 {% if defined(url) %} AND url = {{ String(url) }} {% end %}
+4 -4
packages/tinybird/pipes/__ttl_45d_count.pipe
··· 1 - VERSION 0 1 + VERSION 1 2 2 3 3 DESCRIPTION > 4 4 TODO: descripe what it is for! ··· 12 12 monitorId, 13 13 url, 14 14 countState() AS count, 15 - countState(multiIf((statusCode >= 200) AND (statusCode <= 299), 1, NULL)) AS ok 16 - FROM __ttl_45d_mv__v0 15 + countState(if(error = 0, 1, NULL)) AS ok 16 + FROM __ttl_45d_mv__v1 17 17 GROUP BY 18 18 time, 19 19 monitorId, 20 20 url 21 21 22 22 TYPE materialized 23 - DATASOURCE __ttl_45d_count_mv__v0 23 + DATASOURCE __ttl_45d_count_mv__v1 24 24 25 25
+2 -4
packages/tinybird/pipes/__ttl_45d_count_get.pipe
··· 1 - VERSION 0 2 - 3 - TOKEN "__ttl_45d_count_get__v0_endpoint_read_2389" READ 1 + VERSION 1 4 2 5 3 NODE __ttl_45d_count_get_0 6 4 SQL > 7 5 8 6 % 9 7 SELECT time as day, countMerge(count) as count, countMerge(ok) as ok 10 - FROM __ttl_45d_count_mv__v0 8 + FROM __ttl_45d_count_mv__v1 11 9 WHERE 12 10 monitorId = {{ String(monitorId, '4') }} 13 11 {% if defined(url) %} AND url = {{ String(url) }} {% end %}
+4 -3
packages/tinybird/pipes/__ttl_7d.pipe
··· 1 - VERSION 0 1 + VERSION 1 2 2 3 3 NODE __ttl_7d_0 4 4 SQL > ··· 7 7 toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, 8 8 latency, 9 9 monitorId, 10 + error 10 11 region, 11 12 statusCode, 12 13 url, 13 14 workspaceId, 14 15 cronTimestamp 15 - FROM ping_response__v7 16 + FROM ping_response__v8 16 17 17 18 TYPE materialized 18 - DATASOURCE __ttl_7d_mv__v0 19 + DATASOURCE __ttl_7d_mv__v1 19 20 20 21
+3 -3
packages/tinybird/pipes/__ttl_7d_all.pipe
··· 1 - VERSION 0 1 + VERSION 1 2 2 3 3 NODE __ttl_7d_all_0 4 4 SQL > 5 5 6 - SELECT toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, * FROM ping_response__v7 6 + SELECT toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, * FROM ping_response__v8 7 7 8 8 TYPE materialized 9 - DATASOURCE __ttl_7d_all_mv__v0 9 + DATASOURCE __ttl_7d_all_mv__v1 10 10 11 11
+2 -4
packages/tinybird/pipes/__ttl_7d_all_details_get.pipe
··· 1 - VERSION 0 2 - 3 - TOKEN "__ttl_7d_all_details_get__v0_endpoint_read_7052" READ 1 + VERSION 1 4 2 5 3 NODE __ttl_7d_all_details_get_0 6 4 SQL > 7 5 8 6 % 9 7 SELECT * 10 - from __ttl_7d_all_mv__v0 8 + from __ttl_7d_all_mv__v1 11 9 WHERE 12 10 monitorId = {{ String(monitorId, '1') }} 13 11 {% if defined(url) %} AND url = {{ String(url) }} {% end %}
+2 -4
packages/tinybird/pipes/__ttl_7d_chart_get.pipe
··· 1 - VERSION 0 2 - 3 - TOKEN "__ttl_7d_chart_get_endpoint_read_8119" READ 1 + VERSION 1 4 2 5 3 NODE __ttl_7d_chart_get_0 6 4 SQL > ··· 18 16 round(quantile(0.9)(latency)) as p90Latency, 19 17 round(quantile(0.95)(latency)) as p95Latency, 20 18 round(quantile(0.99)(latency)) as p99Latency 21 - FROM __ttl_7d_mv__v0 19 + FROM __ttl_7d_mv__v1 22 20 WHERE 23 21 monitorId = {{ String(monitorId, '1') }} 24 22 {% if defined(url) %} AND url = {{ String(url) }} {% end %}
+3 -3
packages/tinybird/pipes/__ttl_7d_count.pipe
··· 1 - VERSION 0 1 + VERSION 1 2 2 3 3 DESCRIPTION > 4 4 TODO: descripe what it is for! ··· 12 12 monitorId, 13 13 url, 14 14 countState() AS count, 15 - countState(multiIf((statusCode >= 200) AND (statusCode <= 299), 1, NULL)) AS ok 15 + countState(if(error = 0, 1, NULL)) AS ok 16 16 FROM __ttl_45d_mv__v0 17 17 GROUP BY 18 18 time, ··· 20 20 url 21 21 22 22 TYPE materialized 23 - DATASOURCE __ttl_7d_count_mv__v0 23 + DATASOURCE __ttl_7d_count_mv__v1 24 24 25 25
+2 -2
packages/tinybird/pipes/__ttl_7d_count_get.pipe
··· 1 - VERSION 0 1 + VERSION 1 2 2 3 3 NODE __ttl_7d_count_get_0 4 4 SQL > 5 5 6 6 % 7 7 SELECT time as day, countMerge(count) as count, countMerge(ok) as ok 8 - FROM __ttl_7d_count_mv__v0 8 + FROM __ttl_7d_count_mv__v1 9 9 WHERE 10 10 monitorId = {{ String(monitorId, '4') }} 11 11 {% if defined(url) %} AND url = {{ String(url) }} {% end %}
+5 -5
packages/tinybird/pipes/__ttl_7d_metrics_get.pipe
··· 1 - VERSION 0 1 + VERSION 1 2 2 3 3 NODE __ttl_7d_metrics_get_0 4 4 SQL > ··· 11 11 round(quantile(0.95)(latency)) as p95Latency, 12 12 round(quantile(0.99)(latency)) as p99Latency, 13 13 count() as count, 14 - count(multiIf((statusCode >= 200) AND (statusCode <= 299), 1, NULL)) AS ok, 14 + count(if(error = 0, 1, NULL)) AS ok, 15 15 max(cronTimestamp) AS lastTimestamp 16 - FROM __ttl_7d_mv__v0 16 + FROM __ttl_7d_mv__v1 17 17 WHERE 18 18 monitorId = {{ String(monitorId, '1') }} 19 19 {% if defined(url) %} AND url = {{ String(url) }} {% end %} ··· 26 26 round(quantile(0.95)(latency)) AS p95Latency, 27 27 round(quantile(0.99)(latency)) AS p99Latency, 28 28 count() as count, 29 - count(multiIf((statusCode >= 200) AND (statusCode <= 299), 1, NULL)) AS ok, 29 + count(if(error = 0, 1, NULL)) AS ok, 30 30 NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant 31 - FROM __ttl_14d_mv__v0 31 + FROM __ttl_14d_mv__v1 32 32 WHERE 33 33 monitorId = {{ String(monitorId, '1') }} 34 34 {% if defined(url) %} AND url = {{ String(url) }} {% end %}
+3 -5
packages/tinybird/pipes/__ttl_7d_metrics_get_by_region.pipe
··· 1 - VERSION 0 2 - 3 - TOKEN "__ttl_7d_metrics_get_by_region__v0_endpoint_read_4172" READ 1 + VERSION 1 4 2 5 3 NODE __ttl_7d_metrics_get_by_region_0 6 4 SQL > ··· 14 12 round(quantile(0.95)(latency)) as p95Latency, 15 13 round(quantile(0.99)(latency)) as p99Latency, 16 14 count() as count, 17 - count(multiIf((statusCode >= 200) AND (statusCode <= 299), 1, NULL)) AS ok, 15 + count(if(error = 0, 1, NULL)) AS ok, 18 16 max(cronTimestamp) AS lastTimestamp 19 - FROM __ttl_7d_mv__v0 17 + FROM __ttl_7d_mv__v1 20 18 WHERE 21 19 monitorId = {{ String(monitorId, '1') }} 22 20 {% if defined(url) %} AND url = {{ String(url) }} {% end %}
+19 -7
packages/tinybird/src/os-client.ts
··· 11 11 const DEFAULT_CACHE = isProd ? 120 : DEV_CACHE; // 2min 12 12 const MAX_CACHE = 86_400; // 1d 13 13 14 + const VERSION = "v1"; 15 + 14 16 export const latencySchema = z.object({ 15 17 p50Latency: z.number().int().nullable(), 16 18 p75Latency: z.number().int().nullable(), ··· 50 52 return async (props: z.infer<typeof parameters>) => { 51 53 try { 52 54 const res = await this.tb.buildPipe({ 53 - pipe: `__ttl_${period}_chart_get__v0`, 55 + pipe: `__ttl_${period}_chart_get__${VERSION}`, 54 56 parameters, 55 57 data: z 56 58 .object({ ··· 78 80 return async (props: z.infer<typeof parameters>) => { 79 81 try { 80 82 const res = await this.tb.buildPipe({ 81 - pipe: `__ttl_${period}_metrics_get__v0`, 83 + pipe: `__ttl_${period}_metrics_get__${VERSION}`, 82 84 parameters, 83 85 data: z 84 86 .object({ ··· 108 110 return async (props: z.infer<typeof parameters>) => { 109 111 try { 110 112 const res = await this.tb.buildPipe({ 111 - pipe: `__ttl_${period}_metrics_get_by_region__v0`, 113 + pipe: `__ttl_${period}_metrics_get_by_region__${VERSION}`, 112 114 parameters, 113 115 data: z 114 116 .object({ ··· 141 143 ) => { 142 144 try { 143 145 const res = await this.tb.buildPipe({ 144 - pipe: `__ttl_${period}_count_get__v0`, 146 + pipe: `__ttl_${period}_count_get__${VERSION}`, 145 147 parameters, 146 148 data: z.object({ 147 149 day: z.string().transform((val) => { ··· 173 175 return async (props: z.infer<typeof parameters>) => { 174 176 try { 175 177 const res = await this.tb.buildPipe({ 176 - pipe: `__ttl_${period}_list_get__v0`, 178 + pipe: `__ttl_${period}_list_get__${VERSION}`, 177 179 parameters, 178 180 data: z.object({ 179 181 latency: z.number().int(), // in ms 180 182 monitorId: z.string(), 181 183 region: z.enum(flyRegions), 184 + error: z 185 + .number() 186 + .default(0) 187 + .transform((val) => val !== 0), 182 188 statusCode: z.number().int().nullable().default(null), 183 189 timestamp: z.number().int(), 184 190 url: z.string().url(), 185 191 workspaceId: z.string(), 186 192 cronTimestamp: z.number().int().nullable().default(Date.now()), 193 + assertions: z.string().nullable().optional(), 187 194 }), 188 195 opts: { 189 196 revalidate: DEFAULT_CACHE, ··· 204 211 return async (props: z.infer<typeof parameters>) => { 205 212 try { 206 213 const res = await this.tb.buildPipe({ 207 - pipe: `__ttl_1h_last_timestamp_${type}_get__v0`, 214 + pipe: `__ttl_1h_last_timestamp_${type}_get__${VERSION}`, 208 215 parameters, 209 216 data: z.object({ cronTimestamp: z.number().int() }), 210 217 opts: { ··· 229 236 return async (props: z.infer<typeof parameters>) => { 230 237 try { 231 238 const res = await this.tb.buildPipe({ 232 - pipe: `__ttl_${period}_all_details_get__v0`, // TODO: make it also a bit dynamic to avoid query through too much data 239 + pipe: `__ttl_${period}_all_details_get__${VERSION}`, 233 240 parameters, 234 241 data: z.object({ 235 242 latency: z.number().int(), // in ms 236 243 statusCode: z.number().int().nullable().default(null), 237 244 monitorId: z.string().default(""), 238 245 url: z.string().url().optional(), 246 + error: z 247 + .number() 248 + .default(0) 249 + .transform((val) => val !== 0), 239 250 region: z.enum(flyRegions), 240 251 cronTimestamp: z.number().int().optional(), 241 252 message: z.string().nullable().optional(), ··· 261 272 if (value.success) return value.data; 262 273 return null; 263 274 }), 275 + assertions: z.string().nullable().optional(), // REMINDER: maybe include Assertions.serialize here 264 276 }), 265 277 opts: { 266 278 revalidate: MAX_CACHE,
+5
packages/tinybird/src/validation.ts
··· 40 40 workspaceId: z.string(), 41 41 monitorId: z.string(), 42 42 timestamp: z.number().int(), 43 + error: z 44 + .number() 45 + .default(0) 46 + .transform((val) => val !== 0), 43 47 statusCode: z.number().int().nullable().default(null), 44 48 latency: z.number().int(), // in ms 45 49 cronTimestamp: z.number().int().nullable().default(Date.now()), 46 50 url: z.string().url(), 47 51 region: z.enum(flyRegions), 48 52 message: z.string().nullable().optional(), 53 + assertions: z.string().nullable().optional(), 49 54 }); 50 55 51 56 /**
+33
pnpm-lock.yaml
··· 134 134 '@openstatus/api': 135 135 specifier: workspace:* 136 136 version: link:../../packages/api 137 + '@openstatus/assertions': 138 + specifier: workspace:* 139 + version: link:../../packages/assertions 137 140 '@openstatus/db': 138 141 specifier: workspace:* 139 142 version: link:../../packages/db ··· 397 400 '@openstatus/analytics': 398 401 specifier: workspace:* 399 402 version: link:../analytics 403 + '@openstatus/assertions': 404 + specifier: workspace:* 405 + version: link:../assertions 400 406 '@openstatus/db': 401 407 specifier: workspace:* 402 408 version: link:../db ··· 444 450 specifier: 5.4.2 445 451 version: 5.4.2 446 452 453 + packages/assertions: 454 + devDependencies: 455 + '@openstatus/tsconfig': 456 + specifier: workspace:* 457 + version: link:../tsconfig 458 + jsonpath-plus: 459 + specifier: 7.2.0 460 + version: 7.2.0 461 + typescript: 462 + specifier: 5.4.2 463 + version: 5.4.2 464 + zod: 465 + specifier: 3.22.2 466 + version: 3.22.2 467 + 447 468 packages/config/eslint: 448 469 dependencies: 449 470 '@types/eslint': ··· 499 520 specifier: 3.22.2 500 521 version: 3.22.2 501 522 devDependencies: 523 + '@openstatus/assertions': 524 + specifier: workspace:* 525 + version: link:../assertions 502 526 '@openstatus/tsconfig': 503 527 specifier: workspace:* 504 528 version: link:../tsconfig ··· 1407 1431 peerDependencies: 1408 1432 '@effect-ts/otel-node': '*' 1409 1433 peerDependenciesMeta: 1434 + '@effect-ts/core': 1435 + optional: true 1436 + '@effect-ts/otel': 1437 + optional: true 1410 1438 '@effect-ts/otel-node': 1411 1439 optional: true 1412 1440 dependencies: ··· 9360 9388 universalify: 2.0.0 9361 9389 optionalDependencies: 9362 9390 graceful-fs: 4.2.11 9391 + 9392 + /jsonpath-plus@7.2.0: 9393 + resolution: {integrity: sha512-zBfiUPM5nD0YZSBT/o/fbCUlCcepMIdP0CJZxM1+KgA4f2T206f6VAg9e7mX35+KlMaIc5qXW34f3BnwJ3w+RA==} 9394 + engines: {node: '>=12.0.0'} 9395 + dev: true 9363 9396 9364 9397 /jsx-ast-utils@3.3.5: 9365 9398 resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}