Openstatus www.openstatus.dev

๐Ÿš€ TCP New checker type (#982)

* ๐Ÿšง wip

* ๐Ÿšง new checker type

* ๐Ÿš€ tcp checker

* ๐Ÿš€ tcp checker

* ๐Ÿšง tcp tests

* ๐Ÿšง tcp test

* ๐Ÿšง tcp test

* ci: apply automated fixes

* ๐Ÿš€ tcp machine

* ci: apply automated fixes

* ๐Ÿš€

* ๐Ÿ˜ฑ major refactor

* ๐Ÿคฃ fix build

* ci: apply automated fixes

* chore: tcp request form input

* ๐Ÿ”ฅ tcp

* ๐Ÿ”ฅ remove useEffect

* ๐Ÿš€ file upload

* ci: apply automated fixes

* ๐Ÿ˜ฑ slowly start migration

* ci: apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Maximilian Kaske <maximilian@kaske.org>

authored by

Thibault Le Ouay
autofix-ci[bot]
Maximilian Kaske
and committed by
GitHub
7c889a52 57c316fb

+4257 -1192
+4
apps/checker/.golangci.yml
··· 1 + linters: 2 + enable-all: true 3 + disable-all: false 4 + fast: true
+15 -287
apps/checker/cmd/main.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "encoding/json" 6 5 "errors" 7 6 "fmt" 8 7 "net/http" ··· 12 11 "time" 13 12 14 13 "github.com/gin-gonic/gin" 15 - "github.com/openstatushq/openstatus/apps/checker" 16 - "github.com/openstatushq/openstatus/apps/checker/pkg/assertions" 14 + "github.com/openstatushq/openstatus/apps/checker/handlers" 15 + 17 16 "github.com/openstatushq/openstatus/apps/checker/pkg/logger" 18 17 "github.com/openstatushq/openstatus/apps/checker/pkg/tinybird" 19 - "github.com/openstatushq/openstatus/apps/checker/request" 20 18 "github.com/rs/zerolog/log" 21 - 22 - backoff "github.com/cenkalti/backoff/v4" 23 19 ) 24 20 25 - type statusCode int 26 - 27 - // We should export it 28 - type PingResponse struct { 29 - Body string `json:"body,omitempty"` 30 - Headers string `json:"headers,omitempty"` 31 - Region string `json:"region"` 32 - RequestId int64 `json:"requestId,omitempty"` 33 - WorkspaceId int64 `json:"workspaceId,omitempty"` 34 - Latency int64 `json:"latency"` 35 - Time int64 `json:"time"` 36 - Timing checker.Timing `json:"timing"` 37 - Status int `json:"status,omitempty"` 38 - } 39 - 40 - func (s statusCode) IsSuccessful() bool { 41 - return s >= 200 && s < 300 42 - } 43 - 44 21 func main() { 45 22 ctx, cancel := context.WithCancel(context.Background()) 46 23 defer cancel() ··· 71 48 72 49 tinybirdClient := tinybird.NewClient(httpClient, tinyBirdToken) 73 50 74 - router := gin.New() 75 - router.POST("/checker", func(c *gin.Context) { 76 - ctx := c.Request.Context() 77 - dataSourceName := "ping_response__v8" 78 - if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", cronSecret) { 79 - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) 80 - return 81 - } 82 - 83 - if cloudProvider == "fly" { 84 - // if the request has been routed to a wrong region, we forward it to the correct one. 85 - region := c.GetHeader("fly-prefer-region") 86 - if region != "" && region != flyRegion { 87 - c.Header("fly-replay", fmt.Sprintf("region=%s", region)) 88 - c.String(http.StatusAccepted, "Forwarding request to %s", region) 89 - return 90 - } 91 - } 92 - 93 - var req request.CheckerRequest 94 - if err := c.ShouldBindJSON(&req); err != nil { 95 - log.Ctx(ctx).Error().Err(err).Msg("failed to decode checker request") 96 - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) 97 - return 98 - } 99 - // We need a new client for each request to avoid connection reuse. 100 - requestClient := &http.Client{ 101 - Timeout: time.Duration(req.Timeout) * time.Millisecond, 102 - } 103 - defer requestClient.CloseIdleConnections() 104 - 105 - // Might be a more efficient way to do it 106 - var i interface{} = req.RawAssertions 107 - jsonBytes, _ := json.Marshal(i) 108 - assertionAsString := string(jsonBytes) 109 - if assertionAsString == "null" { 110 - assertionAsString = "" 111 - } 112 - 113 - var called int 114 - op := func() error { 115 - called++ 116 - res, err := checker.Ping(ctx, requestClient, req) 117 - if err != nil { 118 - return fmt.Errorf("unable to ping: %w", err) 119 - } 120 - statusCode := statusCode(res.StatusCode) 121 - 122 - var isSuccessfull bool = true 123 - if len(req.RawAssertions) > 0 { 124 - for _, a := range req.RawAssertions { 125 - var assert request.Assertion 126 - err = json.Unmarshal(a, &assert) 127 - if err != nil { 128 - // handle error 129 - return fmt.Errorf("unable to unmarshal assertion: %w", err) 130 - } 131 - switch assert.AssertionType { 132 - case request.AssertionHeader: 133 - var target assertions.HeaderTarget 134 - if err := json.Unmarshal(a, &target); err != nil { 135 - return fmt.Errorf("unable to unmarshal IntTarget: %w", err) 136 - } 137 - isSuccessfull = isSuccessfull && target.HeaderEvaluate(res.Headers) 138 - 139 - case request.AssertionTextBody: 140 - var target assertions.StringTargetType 141 - if err := json.Unmarshal(a, &target); err != nil { 142 - return fmt.Errorf("unable to unmarshal IntTarget: %w", err) 143 - } 144 - isSuccessfull = isSuccessfull && target.StringEvaluate(res.Body) 145 - 146 - case request.AssertionStatus: 147 - var target assertions.StatusTarget 148 - if err := json.Unmarshal(a, &target); err != nil { 149 - return fmt.Errorf("unable to unmarshal IntTarget: %w", err) 150 - } 151 - isSuccessfull = isSuccessfull && target.StatusEvaluate(int64(res.StatusCode)) 152 - case request.AssertionJsonBody: 153 - fmt.Println("assertion type", assert.AssertionType) 154 - default: 155 - fmt.Println("โš ๏ธ Not Handled assertion type", assert.AssertionType) 156 - } 157 - } 158 - } else { 159 - isSuccessfull = statusCode.IsSuccessful() 160 - } 161 - 162 - // let's retry at least once if the status code is not successful. 163 - if !isSuccessfull && called < 2 { 164 - return fmt.Errorf("unable to ping: %v with status %v", res, res.StatusCode) 165 - } 166 - 167 - // it's in error if not successful 168 - if isSuccessfull { 169 - res.Error = 0 170 - // Small trick to avoid sending the body at the moment to TB 171 - res.Body = "" 172 - } else { 173 - res.Error = 1 174 - } 175 - 176 - res.Assertions = assertionAsString 177 - // That part could be refactored 178 - if !isSuccessfull && req.Status == "active" { 179 - // Q: Why here we do not check if the status was previously active? 180 - checker.UpdateStatus(ctx, checker.UpdateData{ 181 - MonitorId: req.MonitorID, 182 - Status: "error", 183 - StatusCode: res.StatusCode, 184 - Region: flyRegion, 185 - Message: res.Message, 186 - CronTimestamp: req.CronTimestamp, 187 - }) 188 - } 189 - // Check if the status is degraded 190 - if isSuccessfull && req.Status == "active" { 191 - if req.DegradedAfter > 0 && res.Latency > req.DegradedAfter { 192 - checker.UpdateStatus(ctx, checker.UpdateData{ 193 - MonitorId: req.MonitorID, 194 - Status: "degraded", 195 - Region: flyRegion, 196 - StatusCode: res.StatusCode, 197 - CronTimestamp: req.CronTimestamp, 198 - }) 199 - } 200 - } 201 - // We were in error and now we are successful don't check for degraded 202 - if isSuccessfull && req.Status == "error" { 203 - // Q: Why here we check the data before updating the status in this scenario? 204 - checker.UpdateStatus(ctx, checker.UpdateData{ 205 - MonitorId: req.MonitorID, 206 - Status: "active", 207 - Region: flyRegion, 208 - StatusCode: res.StatusCode, 209 - CronTimestamp: req.CronTimestamp, 210 - }) 211 - } 212 - // if we were in degraded and now we are successful, we should update the status to active 213 - if isSuccessfull && req.Status == "degraded" { 214 - if req.DegradedAfter > 0 && res.Latency <= req.DegradedAfter { 215 - checker.UpdateStatus(ctx, checker.UpdateData{ 216 - MonitorId: req.MonitorID, 217 - Status: "active", 218 - Region: flyRegion, 219 - StatusCode: res.StatusCode, 220 - CronTimestamp: req.CronTimestamp, 221 - }) 222 - } 223 - } 224 - 225 - if err := tinybirdClient.SendEvent(ctx, res, dataSourceName); err != nil { 226 - log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") 227 - } 228 - 229 - return nil 230 - } 231 - 232 - if err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), 3)); err != nil { 233 - if err := tinybirdClient.SendEvent(ctx, checker.PingData{ 234 - URL: req.URL, 235 - Region: flyRegion, 236 - Message: err.Error(), 237 - CronTimestamp: req.CronTimestamp, 238 - Timestamp: req.CronTimestamp, 239 - MonitorID: req.MonitorID, 240 - WorkspaceID: req.WorkspaceID, 241 - Error: 1, 242 - Assertions: assertionAsString, 243 - Body: "", 244 - }, dataSourceName); err != nil { 245 - log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") 246 - } 247 - 248 - if req.Status == "active" { 249 - checker.UpdateStatus(ctx, checker.UpdateData{ 250 - MonitorId: req.MonitorID, 251 - Status: "error", 252 - Message: err.Error(), 253 - Region: flyRegion, 254 - CronTimestamp: req.CronTimestamp, 255 - }) 256 - } 51 + h := &handlers.Handler{ 52 + Secret: cronSecret, 53 + CloudProvider: cloudProvider, 54 + Region: flyRegion, 55 + TbClient: tinybirdClient, 56 + } 257 57 258 - } 259 - 260 - c.JSON(http.StatusOK, gin.H{"message": "ok"}) 261 - }) 58 + router := gin.New() 59 + router.POST("/checker", h.HTTPCheckerHandler) 60 + router.POST("/checker/http", h.HTTPCheckerHandler) 61 + router.POST("/checker/tcp", h.TCPHandler) 62 + router.POST("/ping/:region", h.PingRegionHandler) 63 + router.POST("/tcp/:region", h.TCPHandlerRegion) 262 64 263 65 router.GET("/health", func(c *gin.Context) { 264 66 c.JSON(http.StatusOK, gin.H{"message": "pong", "fly_region": flyRegion}) 265 67 }) 266 68 267 - router.POST("/ping/:region", func(c *gin.Context) { 268 - dataSourceName := "check_response__v1" 269 - region := c.Param("region") 270 - if region == "" { 271 - c.String(http.StatusBadRequest, "region is required") 272 - return 273 - } 274 - fmt.Printf("Start of /ping/%s\n", region) 275 - 276 - if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", cronSecret) { 277 - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) 278 - return 279 - } 280 - 281 - if cloudProvider == "fly" { 282 - 283 - if region != flyRegion { 284 - c.Header("fly-replay", fmt.Sprintf("region=%s", region)) 285 - c.String(http.StatusAccepted, "Forwarding request to %s", region) 286 - return 287 - } 288 - } 289 - // We need a new client for each request to avoid connection reuse. 290 - requestClient := &http.Client{ 291 - Timeout: 45 * time.Second, 292 - } 293 - defer requestClient.CloseIdleConnections() 294 - 295 - var req request.PingRequest 296 - if err := c.ShouldBindJSON(&req); err != nil { 297 - log.Ctx(ctx).Error().Err(err).Msg("failed to decode checker request") 298 - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) 299 - return 300 - } 301 - var res checker.Response 302 - op := func() error { 303 - r, err := checker.SinglePing(c.Request.Context(), requestClient, req) 304 - if err != nil { 305 - return fmt.Errorf("unable to ping: %w", err) 306 - } 307 - 308 - r.Region = flyRegion 309 - 310 - headersAsString, err := json.Marshal(r.Headers) 311 - if err != nil { 312 - return err 313 - } 314 - 315 - tbData := PingResponse{ 316 - RequestId: req.RequestId, 317 - WorkspaceId: req.WorkspaceId, 318 - Status: r.Status, 319 - Latency: r.Latency, 320 - Body: r.Body, 321 - Headers: string(headersAsString), 322 - Time: r.Time, 323 - Timing: r.Timing, 324 - Region: r.Region, 325 - } 326 - 327 - res = r 328 - if tbData.RequestId != 0 { 329 - if err := tinybirdClient.SendEvent(ctx, tbData, dataSourceName); err != nil { 330 - log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") 331 - } 332 - } 333 - return nil 334 - } 335 - if err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), 3)); err != nil { 336 - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) 337 - return 338 - } 339 - c.JSON(http.StatusOK, res) 340 - }) 341 - 342 69 httpServer := &http.Server{ 343 70 Addr: fmt.Sprintf("0.0.0.0:%s", env("PORT", "8080")), 344 71 Handler: router, ··· 354 81 <-ctx.Done() 355 82 if err := httpServer.Shutdown(ctx); err != nil { 356 83 log.Ctx(ctx).Error().Err(err).Msg("failed to shutdown http server") 84 + 357 85 return 358 86 } 359 87 }
+262
apps/checker/handlers/checker.go
··· 1 + package handlers 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "time" 8 + 9 + "github.com/cenkalti/backoff/v4" 10 + "github.com/gin-gonic/gin" 11 + "github.com/openstatushq/openstatus/apps/checker" 12 + "github.com/openstatushq/openstatus/apps/checker/pkg/assertions" 13 + "github.com/openstatushq/openstatus/apps/checker/request" 14 + "github.com/rs/zerolog/log" 15 + ) 16 + 17 + type statusCode int 18 + 19 + func (s statusCode) IsSuccessful() bool { 20 + return s >= 200 && s < 300 21 + } 22 + 23 + type PingData struct { 24 + WorkspaceID string `json:"workspaceId"` 25 + MonitorID string `json:"monitorId"` 26 + URL string `json:"url"` 27 + Region string `json:"region"` 28 + Message string `json:"message,omitempty"` 29 + Timing string `json:"timing,omitempty"` 30 + Headers string `json:"headers,omitempty"` 31 + Assertions string `json:"assertions"` 32 + Body string `json:"body,omitempty"` 33 + Latency int64 `json:"latency"` 34 + CronTimestamp int64 `json:"cronTimestamp"` 35 + Timestamp int64 `json:"timestamp"` 36 + StatusCode int `json:"statusCode,omitempty"` 37 + Error uint8 `json:"error"` 38 + } 39 + 40 + func (h Handler) HTTPCheckerHandler(c *gin.Context) { 41 + ctx := c.Request.Context() 42 + dataSourceName := "ping_response__v8" 43 + 44 + if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", h.Secret) { 45 + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) 46 + 47 + return 48 + } 49 + 50 + if h.CloudProvider == "fly" { 51 + // if the request has been routed to a wrong region, we forward it to the correct one. 52 + region := c.GetHeader("fly-prefer-region") 53 + if region != "" && region != h.Region { 54 + c.Header("fly-replay", fmt.Sprintf("region=%s", region)) 55 + c.String(http.StatusAccepted, "Forwarding request to %s", region) 56 + 57 + return 58 + } 59 + } 60 + 61 + var req request.HttpCheckerRequest 62 + if err := c.ShouldBindJSON(&req); err != nil { 63 + log.Ctx(ctx).Error().Err(err).Msg("failed to decode checker request") 64 + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) 65 + 66 + return 67 + } 68 + // We need a new client for each request to avoid connection reuse. 69 + requestClient := &http.Client{ 70 + Timeout: time.Duration(req.Timeout) * time.Millisecond, 71 + } 72 + defer requestClient.CloseIdleConnections() 73 + 74 + // Might be a more efficient way to do it 75 + var i interface{} = req.RawAssertions 76 + jsonBytes, _ := json.Marshal(i) 77 + assertionAsString := string(jsonBytes) 78 + 79 + if assertionAsString == "null" { 80 + assertionAsString = "" 81 + } 82 + 83 + var called int 84 + op := func() error { 85 + called++ 86 + res, err := checker.Http(ctx, requestClient, req) 87 + 88 + if err != nil { 89 + return fmt.Errorf("unable to ping: %w", err) 90 + } 91 + 92 + // In TB we need to store them as string 93 + timingAsString, err := json.Marshal(res.Timing) 94 + if err != nil { 95 + return fmt.Errorf("error while parsing timing data %s: %w", req.URL, err) 96 + } 97 + 98 + headersAsString, err := json.Marshal(res.Headers) 99 + if err != nil { 100 + return fmt.Errorf("error while parsing headers %s: %w", req.URL, err) 101 + } 102 + 103 + data := PingData{ 104 + Latency: res.Latency, 105 + StatusCode: res.Status, 106 + MonitorID: req.MonitorID, 107 + Region: h.Region, 108 + WorkspaceID: req.WorkspaceID, 109 + Timestamp: res.Timestamp, 110 + CronTimestamp: req.CronTimestamp, 111 + URL: req.URL, 112 + Timing: string(timingAsString), 113 + Headers: string(headersAsString), 114 + Body: string(res.Body), 115 + } 116 + 117 + statusCode := statusCode(res.Status) 118 + 119 + var isSuccessfull bool = true 120 + 121 + if len(req.RawAssertions) > 0 { 122 + for _, a := range req.RawAssertions { 123 + var assert request.Assertion 124 + err = json.Unmarshal(a, &assert) 125 + 126 + if err != nil { 127 + // handle error 128 + return fmt.Errorf("unable to unmarshal assertion: %w", err) 129 + } 130 + 131 + switch assert.AssertionType { 132 + case request.AssertionHeader: 133 + var target assertions.HeaderTarget 134 + if err := json.Unmarshal(a, &target); err != nil { 135 + return fmt.Errorf("unable to unmarshal IntTarget: %w", err) 136 + } 137 + isSuccessfull = isSuccessfull && target.HeaderEvaluate(data.Headers) 138 + 139 + case request.AssertionTextBody: 140 + var target assertions.StringTargetType 141 + if err := json.Unmarshal(a, &target); err != nil { 142 + return fmt.Errorf("unable to unmarshal IntTarget: %w", err) 143 + } 144 + isSuccessfull = isSuccessfull && target.StringEvaluate(data.Body) 145 + 146 + case request.AssertionStatus: 147 + var target assertions.StatusTarget 148 + if err := json.Unmarshal(a, &target); err != nil { 149 + return fmt.Errorf("unable to unmarshal IntTarget: %w", err) 150 + } 151 + isSuccessfull = isSuccessfull && target.StatusEvaluate(int64(res.Status)) 152 + case request.AssertionJsonBody: 153 + fmt.Println("assertion type", assert.AssertionType) 154 + default: 155 + fmt.Println("! Not Handled assertion type", assert.AssertionType) 156 + } 157 + } 158 + } else { 159 + isSuccessfull = statusCode.IsSuccessful() 160 + } 161 + 162 + // let's retry at least once if the status code is not successful. 163 + if !isSuccessfull && called < 2 { 164 + return fmt.Errorf("unable to ping: %v with status %v", res, res.Status) 165 + } 166 + 167 + // it's in error if not successful 168 + if isSuccessfull { 169 + data.Error = 0 170 + // Small trick to avoid sending the body at the moment to TB 171 + data.Body = "" 172 + } else { 173 + data.Error = 1 174 + } 175 + 176 + data.Assertions = assertionAsString 177 + // That part could be refactored 178 + if !isSuccessfull && req.Status == "active" { 179 + // Q: Why here we do not check if the status was previously active? 180 + checker.UpdateStatus(ctx, checker.UpdateData{ 181 + MonitorId: req.MonitorID, 182 + Status: "error", 183 + StatusCode: res.Status, 184 + Region: h.Region, 185 + Message: res.Error, 186 + CronTimestamp: req.CronTimestamp, 187 + }) 188 + } 189 + // Check if the status is degraded 190 + if isSuccessfull && req.Status == "active" { 191 + if req.DegradedAfter > 0 && res.Latency > req.DegradedAfter { 192 + checker.UpdateStatus(ctx, checker.UpdateData{ 193 + MonitorId: req.MonitorID, 194 + Status: "degraded", 195 + Region: h.Region, 196 + StatusCode: res.Status, 197 + CronTimestamp: req.CronTimestamp, 198 + }) 199 + } 200 + } 201 + // We were in error and now we are successful don't check for degraded 202 + if isSuccessfull && req.Status == "error" { 203 + // Q: Why here we check the data before updating the status in this scenario? 204 + checker.UpdateStatus(ctx, checker.UpdateData{ 205 + MonitorId: req.MonitorID, 206 + Status: "active", 207 + Region: h.Region, 208 + StatusCode: res.Status, 209 + CronTimestamp: req.CronTimestamp, 210 + }) 211 + } 212 + // if we were in degraded and now we are successful, we should update the status to active 213 + if isSuccessfull && req.Status == "degraded" { 214 + if req.DegradedAfter > 0 && res.Latency <= req.DegradedAfter { 215 + checker.UpdateStatus(ctx, checker.UpdateData{ 216 + MonitorId: req.MonitorID, 217 + Status: "active", 218 + Region: h.Region, 219 + StatusCode: res.Status, 220 + CronTimestamp: req.CronTimestamp, 221 + }) 222 + } 223 + } 224 + 225 + if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { 226 + log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") 227 + } 228 + 229 + return nil 230 + } 231 + 232 + if err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), 3)); err != nil { 233 + if err := h.TbClient.SendEvent(ctx, PingData{ 234 + URL: req.URL, 235 + Region: h.Region, 236 + Message: err.Error(), 237 + CronTimestamp: req.CronTimestamp, 238 + Timestamp: req.CronTimestamp, 239 + MonitorID: req.MonitorID, 240 + WorkspaceID: req.WorkspaceID, 241 + Error: 1, 242 + Assertions: assertionAsString, 243 + Body: "", 244 + }, dataSourceName); err != nil { 245 + log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") 246 + } 247 + 248 + if req.Status == "active" { 249 + checker.UpdateStatus(ctx, checker.UpdateData{ 250 + MonitorId: req.MonitorID, 251 + Status: "error", 252 + Message: err.Error(), 253 + Region: h.Region, 254 + CronTimestamp: req.CronTimestamp, 255 + }) 256 + } 257 + 258 + } 259 + 260 + c.JSON(http.StatusOK, gin.H{"message": "ok"}) 261 + 262 + }
+14
apps/checker/handlers/handler.go
··· 1 + package handlers 2 + 3 + import ( 4 + "github.com/openstatushq/openstatus/apps/checker/pkg/tinybird" 5 + ) 6 + 7 + type Handler struct { 8 + TbClient tinybird.Client 9 + Secret string 10 + CloudProvider string 11 + Region string 12 + } 13 + 14 + // Authorization could be handle by middleware
+148
apps/checker/handlers/ping.go
··· 1 + package handlers 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "time" 8 + 9 + "github.com/cenkalti/backoff/v4" 10 + "github.com/gin-gonic/gin" 11 + "github.com/openstatushq/openstatus/apps/checker" 12 + "github.com/openstatushq/openstatus/apps/checker/request" 13 + "github.com/rs/zerolog/log" 14 + ) 15 + 16 + type PingResponse struct { 17 + Body string `json:"body,omitempty"` 18 + Headers string `json:"headers,omitempty"` 19 + Region string `json:"region"` 20 + RequestId int64 `json:"requestId,omitempty"` 21 + WorkspaceId int64 `json:"workspaceId,omitempty"` 22 + Latency int64 `json:"latency"` 23 + Time int64 `json:"time"` 24 + Status int `json:"status,omitempty"` 25 + Timing checker.Timing `json:"timing"` 26 + } 27 + 28 + type Response struct { 29 + Headers map[string]string `json:"headers,omitempty"` 30 + Error string `json:"error,omitempty"` 31 + Body string `json:"body,omitempty"` 32 + Region string `json:"region"` 33 + Tags []string `json:"tags,omitempty"` 34 + RequestId int64 `json:"requestId,omitempty"` 35 + WorkspaceId int64 `json:"workspaceId,omitempty"` 36 + Latency int64 `json:"latency"` 37 + Time int64 `json:"time"` 38 + Timing checker.Timing `json:"timing"` 39 + Status int `json:"status,omitempty"` 40 + } 41 + 42 + func (h Handler) PingRegionHandler(c *gin.Context) { 43 + ctx := c.Request.Context() 44 + 45 + dataSourceName := "check_response__v1" 46 + region := c.Param("region") 47 + 48 + if region == "" { 49 + c.String(http.StatusBadRequest, "region is required") 50 + 51 + return 52 + } 53 + 54 + fmt.Printf("Start of /ping/%s\n", region) 55 + 56 + if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", h.Secret) { 57 + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) 58 + 59 + return 60 + } 61 + 62 + if h.CloudProvider == "fly" { 63 + if region != h.Region { 64 + c.Header("fly-replay", fmt.Sprintf("region=%s", region)) 65 + c.String(http.StatusAccepted, "Forwarding request to %s", region) 66 + 67 + return 68 + } 69 + } 70 + 71 + // We need a new client for each request to avoid connection reuse. 72 + requestClient := &http.Client{ 73 + Timeout: 45 * time.Second, 74 + } 75 + defer requestClient.CloseIdleConnections() 76 + 77 + var req request.PingRequest 78 + if err := c.ShouldBindJSON(&req); err != nil { 79 + log.Ctx(ctx).Error().Err(err).Msg("failed to decode checker request") 80 + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) 81 + 82 + return 83 + } 84 + 85 + var res checker.Response 86 + 87 + op := func() error { 88 + 89 + headers := make([]struct { 90 + Key string `json:"key"` 91 + Value string `json:"value"` 92 + }, 0) 93 + 94 + for key, value := range req.Headers { 95 + headers = append(headers, struct { 96 + Key string `json:"key"` 97 + Value string `json:"value"` 98 + }{Key: key, Value: value}) 99 + } 100 + 101 + input := request.HttpCheckerRequest{ 102 + Headers: headers, 103 + URL: req.URL, 104 + Method: req.Method, 105 + Body: req.Body, 106 + } 107 + 108 + r, err := checker.Http(c.Request.Context(), requestClient, input) 109 + 110 + if err != nil { 111 + return fmt.Errorf("unable to ping: %w", err) 112 + } 113 + 114 + headersAsString, err := json.Marshal(r.Headers) 115 + if err != nil { 116 + return nil 117 + } 118 + 119 + tbData := PingResponse{ 120 + RequestId: req.RequestId, 121 + WorkspaceId: req.WorkspaceId, 122 + Status: r.Status, 123 + Latency: r.Latency, 124 + Body: r.Body, 125 + Headers: string(headersAsString), 126 + Time: r.Timestamp, 127 + Timing: r.Timing, 128 + Region: h.Region, 129 + } 130 + 131 + res = r 132 + 133 + if tbData.RequestId != 0 { 134 + if err := h.TbClient.SendEvent(ctx, tbData, dataSourceName); err != nil { 135 + log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") 136 + } 137 + } 138 + 139 + return nil 140 + } 141 + if err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), 3)); err != nil { 142 + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) 143 + 144 + return 145 + } 146 + 147 + c.JSON(http.StatusOK, res) 148 + }
+218
apps/checker/handlers/tcp.go
··· 1 + package handlers 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "strconv" 7 + 8 + "github.com/cenkalti/backoff/v4" 9 + "github.com/gin-gonic/gin" 10 + "github.com/openstatushq/openstatus/apps/checker" 11 + "github.com/openstatushq/openstatus/apps/checker/request" 12 + "github.com/rs/zerolog/log" 13 + ) 14 + 15 + type TCPResponse struct { 16 + Error string `json:"error,omitempty"` 17 + Region string `json:"region"` 18 + RequestId int64 `json:"requestId,omitempty"` 19 + WorkspaceID int64 `json:"workspaceId"` 20 + MonitorID int64 `json:"monitorId"` 21 + Timestamp int64 `json:"timestamp"` 22 + Timing checker.TCPResponseTiming `json:"timing"` 23 + } 24 + 25 + func (h Handler) TCPHandler(c *gin.Context) { 26 + ctx := c.Request.Context() 27 + dataSourceName := "tcp_response__v0" 28 + if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", h.Secret) { 29 + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) 30 + return 31 + } 32 + 33 + if h.CloudProvider == "fly" { 34 + // if the request has been routed to a wrong region, we forward it to the correct one. 35 + region := c.GetHeader("fly-prefer-region") 36 + if region != "" && region != h.Region { 37 + c.Header("fly-replay", fmt.Sprintf("region=%s", region)) 38 + c.String(http.StatusAccepted, "Forwarding request to %s", region) 39 + return 40 + } 41 + } 42 + var req request.TCPCheckerRequest 43 + if err := c.ShouldBindJSON(&req); err != nil { 44 + log.Ctx(ctx).Error().Err(err).Msg("failed to decode checker request") 45 + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) 46 + return 47 + } 48 + workspaceId, err := strconv.ParseInt(req.WorkspaceID, 10, 64) 49 + if err != nil { 50 + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) 51 + return 52 + } 53 + monitorId, err := strconv.ParseInt(req.MonitorID, 10, 64) 54 + if err != nil { 55 + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) 56 + return 57 + } 58 + 59 + var called int 60 + op := func() error { 61 + called++ 62 + res, err := checker.PingTcp(int(req.Timeout), req.URL) 63 + if err != nil { 64 + return fmt.Errorf("unable to check tcp %s", err) 65 + } 66 + 67 + r := TCPResponse{ 68 + WorkspaceID: workspaceId, 69 + Timestamp: req.CronTimestamp, 70 + Timing: checker.TCPResponseTiming{ 71 + TCPStart: res.TCPStart, 72 + TCPDone: res.TCPDone, 73 + }, 74 + Region: h.Region, 75 + MonitorID: monitorId, 76 + } 77 + latency := res.TCPDone - res.TCPStart 78 + if req.Status == "active" && req.DegradedAfter > 0 && latency > req.DegradedAfter { 79 + checker.UpdateStatus(ctx, checker.UpdateData{ 80 + MonitorId: req.MonitorID, 81 + Status: "degraded", 82 + Region: h.Region, 83 + CronTimestamp: req.CronTimestamp, 84 + }) 85 + } 86 + if req.Status == "degraded" && req.DegradedAfter > 0 && latency <= req.DegradedAfter { 87 + checker.UpdateStatus(ctx, checker.UpdateData{ 88 + MonitorId: req.MonitorID, 89 + Status: "active", 90 + Region: h.Region, 91 + CronTimestamp: req.CronTimestamp, 92 + }) 93 + } 94 + 95 + if req.Status == "error" { 96 + checker.UpdateStatus(ctx, checker.UpdateData{ 97 + MonitorId: req.MonitorID, 98 + Status: "active", 99 + Region: h.Region, 100 + CronTimestamp: req.CronTimestamp, 101 + }) 102 + } 103 + 104 + if err := h.TbClient.SendEvent(ctx, r, dataSourceName); err != nil { 105 + log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") 106 + } 107 + 108 + return nil 109 + } 110 + 111 + if err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), 3)); err != nil { 112 + if err := h.TbClient.SendEvent(ctx, TCPResponse{ 113 + WorkspaceID: workspaceId, 114 + Timestamp: req.CronTimestamp, 115 + Error: err.Error(), 116 + Region: h.Region, 117 + MonitorID: monitorId, 118 + }, dataSourceName); err != nil { 119 + log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") 120 + } 121 + 122 + if req.Status == "active" { 123 + checker.UpdateStatus(ctx, checker.UpdateData{ 124 + MonitorId: req.MonitorID, 125 + Status: "error", 126 + Message: err.Error(), 127 + Region: h.Region, 128 + CronTimestamp: req.CronTimestamp, 129 + }) 130 + } 131 + } 132 + c.JSON(http.StatusOK, gin.H{"message": "ok"}) 133 + } 134 + 135 + func (h Handler) TCPHandlerRegion(c *gin.Context) { 136 + ctx := c.Request.Context() 137 + dataSourceName := "check_tcp_response__v1" 138 + 139 + region := c.Param("region") 140 + if region == "" { 141 + c.String(http.StatusBadRequest, "region is required") 142 + return 143 + } 144 + 145 + if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", h.Secret) { 146 + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) 147 + return 148 + } 149 + 150 + if h.CloudProvider == "fly" { 151 + // if the request has been routed to a wrong region, we forward it to the correct one. 152 + region := c.GetHeader("fly-prefer-region") 153 + if region != "" && region != h.Region { 154 + c.Header("fly-replay", fmt.Sprintf("region=%s", region)) 155 + c.String(http.StatusAccepted, "Forwarding request to %s", region) 156 + return 157 + } 158 + } 159 + var req request.TCPCheckerRequest 160 + if err := c.ShouldBindJSON(&req); err != nil { 161 + log.Ctx(ctx).Error().Err(err).Msg("failed to decode checker request") 162 + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) 163 + return 164 + } 165 + 166 + workspaceId, err := strconv.ParseInt(req.WorkspaceID, 10, 64) 167 + if err != nil { 168 + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) 169 + return 170 + } 171 + monitorId, err := strconv.ParseInt(req.MonitorID, 10, 64) 172 + if err != nil { 173 + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) 174 + return 175 + } 176 + 177 + var called int 178 + var response TCPResponse 179 + op := func() error { 180 + called++ 181 + res, err := checker.PingTcp(int(req.Timeout), req.URL) 182 + if err != nil { 183 + return fmt.Errorf("unable to check tcp %s", err) 184 + } 185 + 186 + response = TCPResponse{ 187 + WorkspaceID: workspaceId, 188 + Timestamp: req.CronTimestamp, 189 + Timing: checker.TCPResponseTiming{ 190 + TCPStart: res.TCPStart, 191 + TCPDone: res.TCPDone, 192 + }, 193 + Region: h.Region, 194 + MonitorID: monitorId, 195 + } 196 + 197 + if req.RequestId != 0 { 198 + if err := h.TbClient.SendEvent(ctx, response, dataSourceName); err != nil { 199 + log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") 200 + } 201 + } 202 + 203 + return nil 204 + } 205 + 206 + if err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), 3)); err != nil { 207 + if err := h.TbClient.SendEvent(ctx, TCPResponse{ 208 + WorkspaceID: workspaceId, 209 + Timestamp: req.CronTimestamp, 210 + Error: err.Error(), 211 + Region: h.Region, 212 + MonitorID: monitorId, 213 + }, dataSourceName); err != nil { 214 + log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") 215 + } 216 + } 217 + c.JSON(http.StatusOK, response) 218 + }
+155
apps/checker/http.go
··· 1 + package checker 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "crypto/tls" 7 + "encoding/base64" 8 + "errors" 9 + "fmt" 10 + "io" 11 + "net/http" 12 + "net/http/httptrace" 13 + "net/url" 14 + "strings" 15 + "time" 16 + 17 + "github.com/openstatushq/openstatus/apps/checker/request" 18 + "github.com/rs/zerolog/log" 19 + ) 20 + 21 + type Timing struct { 22 + DnsStart int64 `json:"dnsStart"` 23 + DnsDone int64 `json:"dnsDone"` 24 + ConnectStart int64 `json:"connectStart"` 25 + ConnectDone int64 `json:"connectDone"` 26 + TlsHandshakeStart int64 `json:"tlsHandshakeStart"` 27 + TlsHandshakeDone int64 `json:"tlsHandshakeDone"` 28 + FirstByteStart int64 `json:"firstByteStart"` 29 + FirstByteDone int64 `json:"firstByteDone"` 30 + TransferStart int64 `json:"transferStart"` 31 + TransferDone int64 `json:"transferDone"` 32 + } 33 + 34 + type Response struct { 35 + Headers map[string]string `json:"headers,omitempty"` 36 + Body string `json:"body,omitempty"` 37 + Error string `json:"error,omitempty"` 38 + Latency int64 `json:"latency"` 39 + Timestamp int64 `json:"timestamp"` 40 + Status int `json:"status,omitempty"` 41 + Timing Timing `json:"timing"` 42 + } 43 + 44 + // FIXME: This should only return the TCP Timing Data; 45 + func Http(ctx context.Context, client *http.Client, inputData request.HttpCheckerRequest) (Response, error) { 46 + logger := log.Ctx(ctx).With().Str("monitor", inputData.URL).Logger() 47 + 48 + b := []byte(inputData.Body) 49 + if inputData.Method == http.MethodPost { 50 + for _, header := range inputData.Headers { 51 + if header.Key == "Content-Type" && header.Value == "application/octet-stream" { 52 + // split the body by comma and convert it to bytes it's data url base64 53 + data := strings.Split(inputData.Body, ",") 54 + if len(data) == 2 { 55 + decoded, err := base64.StdEncoding.DecodeString(data[1]) 56 + if err != nil { 57 + return Response{}, fmt.Errorf("error while decoding base64: %w", err) 58 + } 59 + 60 + b = decoded 61 + 62 + } 63 + } 64 + } 65 + } 66 + 67 + req, err := http.NewRequestWithContext(ctx, inputData.Method, inputData.URL, bytes.NewReader(b)) 68 + if err != nil { 69 + logger.Error().Err(err).Msg("error while creating req") 70 + return Response{}, fmt.Errorf("unable to create req: %w", err) 71 + } 72 + req.Header.Set("User-Agent", "OpenStatus/1.0") 73 + for _, header := range inputData.Headers { 74 + if header.Key != "" { 75 + req.Header.Set(header.Key, header.Value) 76 + } 77 + } 78 + 79 + if inputData.Method != http.MethodGet { 80 + head := req.Header 81 + _, ok := head["Content-Type"] 82 + if !ok { 83 + // by default we set the content type to application/json if it's a POST request 84 + req.Header.Set("Content-Type", "application/json") 85 + } 86 + } 87 + 88 + timing := Timing{} 89 + 90 + trace := &httptrace.ClientTrace{ 91 + DNSStart: func(_ httptrace.DNSStartInfo) { timing.DnsStart = time.Now().UTC().UnixMilli() }, 92 + DNSDone: func(_ httptrace.DNSDoneInfo) { timing.DnsDone = time.Now().UTC().UnixMilli() }, 93 + ConnectStart: func(_, _ string) { timing.ConnectStart = time.Now().UTC().UnixMilli() }, 94 + ConnectDone: func(_, _ string, _ error) { timing.ConnectDone = time.Now().UTC().UnixMilli() }, 95 + TLSHandshakeStart: func() { timing.TlsHandshakeStart = time.Now().UTC().UnixMilli() }, 96 + TLSHandshakeDone: func(_ tls.ConnectionState, _ error) { timing.TlsHandshakeDone = time.Now().UTC().UnixMilli() }, 97 + GotConn: func(_ httptrace.GotConnInfo) { 98 + timing.FirstByteStart = time.Now().UTC().UnixMilli() 99 + }, 100 + GotFirstResponseByte: func() { 101 + timing.FirstByteDone = time.Now().UTC().UnixMilli() 102 + timing.TransferStart = time.Now().UTC().UnixMilli() 103 + }, 104 + } 105 + 106 + req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace)) 107 + 108 + start := time.Now() 109 + 110 + response, err := client.Do(req) 111 + timing.TransferDone = time.Now().UTC().UnixMilli() 112 + latency := time.Since(start).Milliseconds() 113 + if err != nil { 114 + 115 + var urlErr *url.Error 116 + if errors.As(err, &urlErr) && urlErr.Timeout() { 117 + return Response{ 118 + Latency: latency, 119 + Timing: timing, 120 + Timestamp: start.UTC().UnixMilli(), 121 + Error: fmt.Sprintf("Timeout after %d ms", latency), 122 + }, nil 123 + } 124 + 125 + logger.Error().Err(err).Msg("error while pinging") 126 + 127 + return Response{}, fmt.Errorf("error with monitorURL %s: %w", inputData.URL, err) 128 + } 129 + defer response.Body.Close() 130 + 131 + body, err := io.ReadAll(response.Body) 132 + if err != nil { 133 + return Response{ 134 + Latency: latency, 135 + Timing: timing, 136 + Timestamp: start.UTC().UnixMilli(), 137 + Error: fmt.Sprintf("Cannot read response body: %s", err.Error()), 138 + }, fmt.Errorf("error with monitorURL %s: %w", inputData.URL, err) 139 + } 140 + 141 + headers := make(map[string]string) 142 + for key := range response.Header { 143 + headers[key] = response.Header.Get(key) 144 + } 145 + 146 + return Response{ 147 + Timestamp: start.UTC().UnixMilli(), 148 + Status: response.StatusCode, 149 + Headers: headers, 150 + Timing: timing, 151 + Latency: latency, 152 + Body: string(body), 153 + }, nil 154 + 155 + }
+49
apps/checker/http_test.go
··· 1 + package checker_test 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + "testing" 7 + 8 + "github.com/openstatushq/openstatus/apps/checker" 9 + "github.com/openstatushq/openstatus/apps/checker/request" 10 + ) 11 + 12 + func Test_ping(t *testing.T) { 13 + type args struct { 14 + client *http.Client 15 + inputData request.HttpCheckerRequest 16 + } 17 + tests := []struct { 18 + name string 19 + args args 20 + want checker.Response 21 + wantErr bool 22 + }{ 23 + {name: "200", args: args{client: &http.Client{}, inputData: request.HttpCheckerRequest{URL: "https://openstat.us", CronTimestamp: 1, Headers: []struct { 24 + Key string `json:"key"` 25 + Value string `json:"value"` 26 + }{{Key: "", Value: ""}}}}, want: checker.Response{Status: 200}, wantErr: false}, 27 + {name: "200", args: args{client: &http.Client{}, inputData: request.HttpCheckerRequest{URL: "https://openstat.us", CronTimestamp: 1, Headers: []struct { 28 + Key string `json:"key"` 29 + Value string `json:"value"` 30 + }{{Key: "Test", Value: ""}}}}, want: checker.Response{Status: 200}, wantErr: false}, 31 + {name: "500", args: args{client: &http.Client{}, inputData: request.HttpCheckerRequest{URL: "https://openstat.us/500", CronTimestamp: 1}}, want: checker.Response{Status: 500}, wantErr: false}, 32 + {name: "500", args: args{client: &http.Client{}, inputData: request.HttpCheckerRequest{URL: "https://somethingthatwillfail.ed", CronTimestamp: 1}}, want: checker.Response{Status: 0}, wantErr: true}, 33 + 34 + // TODO: Add test cases. 35 + } 36 + for _, tt := range tests { 37 + t.Run(tt.name, func(t *testing.T) { 38 + got, err := checker.Http(context.Background(), tt.args.client, tt.args.inputData) 39 + 40 + if (err != nil) != tt.wantErr { 41 + t.Errorf("Ping() error = %v, wantErr %v", err, tt.wantErr) 42 + return 43 + } 44 + if got.Status != tt.want.Status { 45 + t.Errorf("Ping() = %v, want %v", got, tt.want) 46 + } 47 + }) 48 + } 49 + }
-313
apps/checker/ping.go
··· 1 - package checker 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "crypto/tls" 7 - "encoding/base64" 8 - "encoding/json" 9 - "errors" 10 - "fmt" 11 - "io" 12 - "net/http" 13 - "net/http/httptrace" 14 - "net/url" 15 - "os" 16 - "strings" 17 - "time" 18 - 19 - "github.com/openstatushq/openstatus/apps/checker/request" 20 - "github.com/rs/zerolog/log" 21 - ) 22 - 23 - type PingData struct { 24 - WorkspaceID string `json:"workspaceId"` 25 - MonitorID string `json:"monitorId"` 26 - URL string `json:"url"` 27 - Region string `json:"region"` 28 - Message string `json:"message,omitempty"` 29 - Timing string `json:"timing,omitempty"` 30 - Headers string `json:"headers,omitempty"` 31 - Assertions string `json:"assertions"` 32 - Body string `json:"body,omitempty"` 33 - Latency int64 `json:"latency"` 34 - CronTimestamp int64 `json:"cronTimestamp"` 35 - Timestamp int64 `json:"timestamp"` 36 - StatusCode int `json:"statusCode,omitempty"` 37 - Error uint8 `json:"error"` 38 - } 39 - 40 - type Timing struct { 41 - DnsStart int64 `json:"dnsStart"` 42 - DnsDone int64 `json:"dnsDone"` 43 - ConnectStart int64 `json:"connectStart"` 44 - ConnectDone int64 `json:"connectDone"` 45 - TlsHandshakeStart int64 `json:"tlsHandshakeStart"` 46 - TlsHandshakeDone int64 `json:"tlsHandshakeDone"` 47 - FirstByteStart int64 `json:"firstByteStart"` 48 - FirstByteDone int64 `json:"firstByteDone"` 49 - TransferStart int64 `json:"transferStart"` 50 - TransferDone int64 `json:"transferDone"` 51 - } 52 - 53 - type Response struct { 54 - Headers map[string]string `json:"headers,omitempty"` 55 - Error string `json:"error,omitempty"` 56 - Body string `json:"body,omitempty"` 57 - Region string `json:"region"` 58 - Tags []string `json:"tags,omitempty"` 59 - RequestId int64 `json:"requestId,omitempty"` 60 - WorkspaceId int64 `json:"workspaceId,omitempty"` 61 - Latency int64 `json:"latency"` 62 - Time int64 `json:"time"` 63 - Timing Timing `json:"timing"` 64 - Status int `json:"status,omitempty"` 65 - } 66 - 67 - func Ping(ctx context.Context, client *http.Client, inputData request.CheckerRequest) (PingData, error) { 68 - logger := log.Ctx(ctx).With().Str("monitor", inputData.URL).Logger() 69 - region := os.Getenv("FLY_REGION") 70 - 71 - b := []byte(inputData.Body) 72 - if inputData.Method == http.MethodPost { 73 - for _, header := range inputData.Headers { 74 - if header.Key == "Content-Type" && header.Value == "application/octet-stream" { 75 - 76 - // split the body by comma and convert it to bytes 77 - data := strings.Split(inputData.Body, ",") 78 - if len(data) == 2 { 79 - 80 - decoded, err := base64.StdEncoding.DecodeString(data[1]) 81 - if err != nil { 82 - return PingData{}, fmt.Errorf("error while decoding base64: %w", err) 83 - } 84 - 85 - b = decoded 86 - 87 - } 88 - } 89 - } 90 - } 91 - 92 - req, err := http.NewRequestWithContext(ctx, inputData.Method, inputData.URL, bytes.NewReader(b)) 93 - if err != nil { 94 - logger.Error().Err(err).Msg("error while creating req") 95 - return PingData{}, fmt.Errorf("unable to create req: %w", err) 96 - } 97 - req.Header.Set("User-Agent", "OpenStatus/1.0") 98 - for _, header := range inputData.Headers { 99 - if header.Key != "" { 100 - req.Header.Set(header.Key, header.Value) 101 - } 102 - } 103 - if inputData.Method != http.MethodGet { 104 - head := req.Header 105 - _, ok := head["Content-Type"] 106 - if !ok { 107 - // by default we set the content type to application/json if it's a POST request 108 - req.Header.Set("Content-Type", "application/json") 109 - } 110 - } 111 - 112 - timing := Timing{} 113 - 114 - trace := &httptrace.ClientTrace{ 115 - DNSStart: func(_ httptrace.DNSStartInfo) { timing.DnsStart = time.Now().UTC().UnixMilli() }, 116 - DNSDone: func(_ httptrace.DNSDoneInfo) { timing.DnsDone = time.Now().UTC().UnixMilli() }, 117 - ConnectStart: func(_, _ string) { timing.ConnectStart = time.Now().UTC().UnixMilli() }, 118 - ConnectDone: func(_, _ string, _ error) { timing.ConnectDone = time.Now().UTC().UnixMilli() }, 119 - TLSHandshakeStart: func() { timing.TlsHandshakeStart = time.Now().UTC().UnixMilli() }, 120 - TLSHandshakeDone: func(_ tls.ConnectionState, _ error) { timing.TlsHandshakeDone = time.Now().UTC().UnixMilli() }, 121 - GotConn: func(_ httptrace.GotConnInfo) { 122 - timing.FirstByteStart = time.Now().UTC().UnixMilli() 123 - }, 124 - GotFirstResponseByte: func() { 125 - timing.FirstByteDone = time.Now().UTC().UnixMilli() 126 - timing.TransferStart = time.Now().UTC().UnixMilli() 127 - }, 128 - } 129 - 130 - req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace)) 131 - 132 - start := time.Now() 133 - 134 - response, err := client.Do(req) 135 - timing.TransferDone = time.Now().UTC().UnixMilli() 136 - latency := time.Since(start).Milliseconds() 137 - if err != nil { 138 - timingAsString, err2 := json.Marshal(timing) 139 - if err2 != nil { 140 - logger.Error().Err(err2).Msg("error while parsing timing data") 141 - } 142 - var urlErr *url.Error 143 - if errors.As(err, &urlErr) && urlErr.Timeout() { 144 - return PingData{ 145 - Latency: latency, 146 - MonitorID: inputData.MonitorID, 147 - Region: region, 148 - WorkspaceID: inputData.WorkspaceID, 149 - Timestamp: start.UTC().UnixMilli(), 150 - CronTimestamp: inputData.CronTimestamp, 151 - URL: inputData.URL, 152 - Message: fmt.Sprintf("Timeout after %d ms", latency), 153 - Timing: string(timingAsString), 154 - }, nil 155 - } 156 - 157 - logger.Error().Err(err).Msg("error while pinging") 158 - return PingData{}, fmt.Errorf("error with monitorURL %s: %w", inputData.URL, err) 159 - } 160 - defer response.Body.Close() 161 - 162 - body, err := io.ReadAll(response.Body) 163 - if err != nil { 164 - return PingData{ 165 - Latency: latency, 166 - MonitorID: inputData.MonitorID, 167 - Region: region, 168 - WorkspaceID: inputData.WorkspaceID, 169 - Timestamp: start.UTC().UnixMilli(), 170 - CronTimestamp: inputData.CronTimestamp, 171 - URL: inputData.URL, 172 - Message: fmt.Sprintf("Cannot read response body: %s", err.Error()), 173 - }, fmt.Errorf("error with monitorURL %s: %w", inputData.URL, err) 174 - } 175 - 176 - headers := make(map[string]string) 177 - for key := range response.Header { 178 - headers[key] = response.Header.Get(key) 179 - } 180 - 181 - // In TB we need to store them as string 182 - timingAsString, err := json.Marshal(timing) 183 - if err != nil { 184 - return PingData{}, fmt.Errorf("error while parsing timing data %s: %w", inputData.URL, err) 185 - } 186 - 187 - headersAsString, err := json.Marshal(headers) 188 - if err != nil { 189 - return PingData{}, fmt.Errorf("error while parsing headers %s: %w", inputData.URL, err) 190 - } 191 - 192 - return PingData{ 193 - Latency: latency, 194 - StatusCode: response.StatusCode, 195 - MonitorID: inputData.MonitorID, 196 - Region: region, 197 - WorkspaceID: inputData.WorkspaceID, 198 - Timestamp: start.UTC().UnixMilli(), 199 - CronTimestamp: inputData.CronTimestamp, 200 - URL: inputData.URL, 201 - Timing: string(timingAsString), 202 - Headers: string(headersAsString), 203 - Body: string(body), 204 - }, nil 205 - } 206 - 207 - func SinglePing(ctx context.Context, client *http.Client, inputData request.PingRequest) (Response, error) { 208 - logger := log.Ctx(ctx).With().Str("monitor", inputData.URL).Logger() 209 - 210 - b := []byte(inputData.Body) 211 - if inputData.Headers["Content-Type"] == "application/octet-stream" { 212 - 213 - // split the body by comma and convert it to bytes 214 - data := strings.Split(inputData.Body, ",") 215 - if len(data) == 2 { 216 - 217 - decoded, err := base64.StdEncoding.DecodeString(data[1]) 218 - if err != nil { 219 - return Response{}, fmt.Errorf("error while decoding base64: %w", err) 220 - } 221 - 222 - b = decoded 223 - 224 - } 225 - } 226 - 227 - req, err := http.NewRequestWithContext(ctx, inputData.Method, inputData.URL, bytes.NewReader(b)) 228 - if err != nil { 229 - logger.Error().Err(err).Msg("error while creating req") 230 - return Response{}, fmt.Errorf("unable to create req: %w", err) 231 - } 232 - 233 - req.Header.Set("User-Agent", "OpenStatus/1.0") 234 - for key, value := range inputData.Headers { 235 - req.Header.Set(key, value) 236 - } 237 - 238 - if inputData.Method != http.MethodGet { 239 - head := req.Header 240 - _, ok := head["Content-Type"] 241 - if !ok { 242 - // by default we set the content type to application/json if it's a POST request 243 - req.Header.Set("Content-Type", "application/json") 244 - } 245 - // if the content type is octet-stream, we need to set the body as bytes 246 - 247 - } 248 - 249 - timing := Timing{} 250 - 251 - trace := &httptrace.ClientTrace{ 252 - DNSStart: func(_ httptrace.DNSStartInfo) { timing.DnsStart = time.Now().UTC().UnixMilli() }, 253 - DNSDone: func(_ httptrace.DNSDoneInfo) { timing.DnsDone = time.Now().UTC().UnixMilli() }, 254 - ConnectStart: func(_, _ string) { timing.ConnectStart = time.Now().UTC().UnixMilli() }, 255 - ConnectDone: func(_, _ string, _ error) { timing.ConnectDone = time.Now().UTC().UnixMilli() }, 256 - TLSHandshakeStart: func() { timing.TlsHandshakeStart = time.Now().UTC().UnixMilli() }, 257 - TLSHandshakeDone: func(_ tls.ConnectionState, _ error) { timing.TlsHandshakeDone = time.Now().UTC().UnixMilli() }, 258 - GotConn: func(_ httptrace.GotConnInfo) { 259 - timing.FirstByteStart = time.Now().UTC().UnixMilli() 260 - }, 261 - GotFirstResponseByte: func() { 262 - timing.FirstByteDone = time.Now().UTC().UnixMilli() 263 - timing.TransferStart = time.Now().UTC().UnixMilli() 264 - }, 265 - } 266 - 267 - req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace)) 268 - 269 - start := time.Now() 270 - res, err := client.Do(req) 271 - timing.TransferDone = time.Now().UTC().UnixMilli() 272 - latency := time.Since(start).Milliseconds() 273 - 274 - if err != nil { 275 - var urlErr *url.Error 276 - if errors.As(err, &urlErr) && urlErr.Timeout() { 277 - return Response{ 278 - Latency: latency, 279 - Timing: timing, 280 - Time: start.UTC().UnixMilli(), 281 - Error: fmt.Sprintf("Timeout after %d ms", latency), 282 - }, nil 283 - } 284 - 285 - logger.Error().Err(err).Msg("error while pinging") 286 - return Response{}, fmt.Errorf("error with monitorURL %s: %w", inputData.URL, err) 287 - } 288 - defer res.Body.Close() 289 - body, err := io.ReadAll(res.Body) 290 - 291 - if err != nil { 292 - return Response{ 293 - Latency: latency, 294 - Timing: timing, 295 - Time: start.UTC().UnixMilli(), 296 - Error: fmt.Sprintf("Cannot read response body: %s", err.Error()), 297 - }, fmt.Errorf("error with monitorURL %s: %w", inputData.URL, err) 298 - } 299 - 300 - headers := make(map[string]string) 301 - for key := range res.Header { 302 - headers[key] = res.Header.Get(key) 303 - } 304 - 305 - return Response{ 306 - Time: start.UTC().UnixMilli(), 307 - Status: res.StatusCode, 308 - Headers: headers, 309 - Timing: timing, 310 - Latency: latency, 311 - Body: string(body), 312 - }, nil 313 - }
-48
apps/checker/ping_test.go
··· 1 - package checker 2 - 3 - import ( 4 - "context" 5 - "net/http" 6 - "testing" 7 - 8 - "github.com/openstatushq/openstatus/apps/checker/request" 9 - ) 10 - 11 - func Test_ping(t *testing.T) { 12 - type args struct { 13 - client *http.Client 14 - inputData request.CheckerRequest 15 - } 16 - tests := []struct { 17 - name string 18 - args args 19 - want PingData 20 - wantErr bool 21 - }{ 22 - {name: "200", args: args{client: &http.Client{}, inputData: request.CheckerRequest{URL: "https://openstat.us", CronTimestamp: 1, Headers: []struct { 23 - Key string `json:"key"` 24 - Value string `json:"value"` 25 - }{{Key: "", Value: ""}}}}, want: PingData{URL: "https://openstat.us", StatusCode: 200}, wantErr: false}, 26 - {name: "200", args: args{client: &http.Client{}, inputData: request.CheckerRequest{URL: "https://openstat.us", CronTimestamp: 1, Headers: []struct { 27 - Key string `json:"key"` 28 - Value string `json:"value"` 29 - }{{Key: "Test", Value: ""}}}}, want: PingData{URL: "https://openstat.us", StatusCode: 200}, wantErr: false}, 30 - {name: "500", args: args{client: &http.Client{}, inputData: request.CheckerRequest{URL: "https://openstat.us/500", CronTimestamp: 1}}, want: PingData{URL: "https://openstat.us/500", StatusCode: 500}, wantErr: false}, 31 - {name: "500", args: args{client: &http.Client{}, inputData: request.CheckerRequest{URL: "https://somethingthatwillfail.ed", CronTimestamp: 1}}, want: PingData{URL: "https://openstat.us/500", StatusCode: 0}, wantErr: true}, 32 - 33 - // TODO: Add test cases. 34 - } 35 - for _, tt := range tests { 36 - t.Run(tt.name, func(t *testing.T) { 37 - got, err := Ping(context.Background(), tt.args.client, tt.args.inputData) 38 - 39 - if (err != nil) != tt.wantErr { 40 - t.Errorf("Ping() error = %v, wantErr %v", err, tt.wantErr) 41 - return 42 - } 43 - if got.StatusCode != tt.want.StatusCode { 44 - t.Errorf("Ping() = %v, want %v", got, tt.want) 45 - } 46 - }) 47 - } 48 - }
+21 -1
apps/checker/request/request.go
··· 45 45 RawTarget json.RawMessage `json:"target"` 46 46 } 47 47 48 - type CheckerRequest struct { 48 + type HttpCheckerRequest struct { 49 49 Headers []struct { 50 50 Key string `json:"key"` 51 51 Value string `json:"value"` ··· 60 60 CronTimestamp int64 `json:"cronTimestamp"` 61 61 Timeout int64 `json:"timeout"` 62 62 DegradedAfter int64 `json:"degradedAfter,omitempty"` 63 + } 64 + 65 + type TCPCheckerRequest struct { 66 + Status string `json:"status"` 67 + WorkspaceID string `json:"workspaceId"` 68 + URL string `json:"url"` 69 + MonitorID string `json:"monitorId"` 70 + RawAssertions []json.RawMessage `json:"assertions,omitempty"` 71 + RequestId int64 `json:"requestId,omitempty"` 72 + CronTimestamp int64 `json:"cronTimestamp"` 73 + Timeout int64 `json:"timeout"` 74 + DegradedAfter int64 `json:"degradedAfter,omitempty"` 75 + } 76 + 77 + type TCPRequest struct { 78 + WorkspaceID string `json:"workspaceId"` 79 + URL string `json:"url"` 80 + MonitorID string `json:"monitorId"` 81 + CronTimestamp int64 `json:"cronTimestamp"` 82 + Timeout int64 `json:"timeout"` 63 83 } 64 84 65 85 type PingRequest struct {
+38
apps/checker/tcp.go
··· 1 + package checker 2 + 3 + import ( 4 + "fmt" 5 + "net" 6 + "strings" 7 + "time" 8 + ) 9 + 10 + type TCPData struct { 11 + WorkspaceID string `json:"workspaceId"` 12 + MonitorID string `json:"monitorId"` 13 + Timestamp int64 `json:"timestamp"` 14 + } 15 + 16 + type TCPResponseTiming struct { 17 + TCPStart int64 `json:"tcpStart"` 18 + TCPDone int64 `json:"tcpDone"` 19 + } 20 + 21 + func PingTcp(timeout int, url string) (TCPResponseTiming, error) { 22 + start := time.Now().UTC().UnixMilli() 23 + conn, err := net.DialTimeout("tcp", url, time.Duration(timeout)*time.Second) 24 + 25 + if err != nil { 26 + if e := err.(*net.OpError).Timeout(); e { 27 + return TCPResponseTiming{}, fmt.Errorf("timeout after %d ms", timeout*1000) 28 + } 29 + if strings.Contains(err.Error(), "connection refused") { 30 + return TCPResponseTiming{}, fmt.Errorf("connection refused") 31 + } 32 + return TCPResponseTiming{}, fmt.Errorf("dial error: %v", err) 33 + } 34 + stop := time.Now().UTC().UnixMilli() 35 + defer conn.Close() 36 + 37 + return TCPResponseTiming{TCPStart: start, TCPDone: stop}, nil 38 + }
+41
apps/checker/tcp_test.go
··· 1 + package checker_test 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/openstatushq/openstatus/apps/checker" 7 + ) 8 + 9 + func TestPingTcp(t *testing.T) { 10 + type args struct { 11 + url string 12 + timeout int 13 + } 14 + tests := []struct { 15 + name string 16 + args args 17 + want checker.TCPResponseTiming 18 + wantErr bool 19 + }{ 20 + {name: "will failed", args: args{url: "error", timeout: 60}, wantErr: true}, 21 + {name: "will be ok", args: args{url: "openstat.us:443", timeout: 60}, wantErr: false}, 22 + } 23 + for _, tt := range tests { 24 + t.Run(tt.name, func(t *testing.T) { 25 + got, err := checker.PingTcp(tt.args.timeout, tt.args.url) 26 + if (err != nil) != tt.wantErr { 27 + t.Errorf("PingTcp() error = %v, wantErr %v", err, tt.wantErr) 28 + return 29 + } 30 + if got.TCPStart == 0 && tt.wantErr == false { 31 + t.Errorf("PingTcp() = %v", got) 32 + return 33 + } 34 + if got.TCPDone == 0 && tt.wantErr == false { 35 + t.Errorf("PingTcp() = %v", got) 36 + return 37 + } 38 + 39 + }) 40 + } 41 + }
+14 -1
apps/web/src/app/api/checker/cron/_cron.ts
··· 168 168 Authorization: `Basic ${env.CRON_SECRET}`, 169 169 }, 170 170 httpMethod: "POST", 171 - url: `https://openstatus-checker.fly.dev/checker?monitor_id=${row.id}`, 171 + url: generateUrl({ row }), 172 172 body: Buffer.from(JSON.stringify(payload)).toString("base64"), 173 173 }, 174 174 scheduleTime: { ··· 179 179 const request = { parent: parent, task: newTask }; 180 180 return client.createTask(request); 181 181 }; 182 + 183 + function generateUrl({ row }: { row: z.infer<typeof selectMonitorSchema> }) { 184 + switch (row.jobType) { 185 + case "http": 186 + // FIXME: remove this after the migration 187 + case "other": 188 + return `https://openstatus-checker.fly.dev/checker/http?monitor_id=${row.id}`; 189 + case "tcp": 190 + return `https://openstatus-checker.fly.dev/checker/tcp?monitor_id=${row.id}`; 191 + default: 192 + throw new Error("Invalid jobType"); 193 + } 194 + }
+44
apps/web/src/app/api/checker/test/http/route.ts
··· 1 + import { NextResponse } from "next/server"; 2 + import { z } from "zod"; 3 + 4 + import { monitorFlyRegionSchema } from "@openstatus/db/src/schema/constants"; 5 + 6 + import { checkRegion } from "@/components/ping-response-analysis/utils"; 7 + import { payloadSchema } from "../../schema"; 8 + import { isAnInvalidTestUrl } from "../../utils"; 9 + 10 + export const runtime = "edge"; 11 + export const preferredRegion = "auto"; 12 + export const dynamic = "force-dynamic"; 13 + export const revalidate = 0; 14 + 15 + export function GET() { 16 + return NextResponse.json({ success: true }); 17 + } 18 + 19 + export async function POST(request: Request) { 20 + try { 21 + const json = await request.json(); 22 + const _valid = payloadSchema 23 + .pick({ url: true, method: true, headers: true, body: true }) 24 + .merge(z.object({ region: monitorFlyRegionSchema.default("ams") })) 25 + .safeParse(json); 26 + 27 + if (!_valid.success) { 28 + return NextResponse.json({ success: false }, { status: 400 }); 29 + } 30 + 31 + const { url, region, method, headers, body } = _valid.data; 32 + // ๐Ÿง‘โ€๐Ÿ’ป for the smart one who want to create a loop hole 33 + if (isAnInvalidTestUrl(url)) { 34 + return NextResponse.json({ success: true }, { status: 200 }); 35 + } 36 + 37 + const res = await checkRegion(url, region, { method, headers, body }); 38 + 39 + return NextResponse.json(res); 40 + } catch (e) { 41 + console.error(e); 42 + return NextResponse.json({ success: false }, { status: 400 }); 43 + } 44 + }
+69
apps/web/src/app/api/checker/test/tcp/route.ts
··· 1 + import { NextResponse } from "next/server"; 2 + import { z } from "zod"; 3 + 4 + import { 5 + type MonitorFlyRegion, 6 + monitorFlyRegionSchema, 7 + } from "@openstatus/db/src/schema/constants"; 8 + 9 + import { TCPResponse, tcpPayload } from "./schema"; 10 + 11 + export const runtime = "edge"; 12 + export const preferredRegion = "auto"; 13 + export const dynamic = "force-dynamic"; 14 + export const revalidate = 0; 15 + 16 + export function GET() { 17 + return NextResponse.json({ success: true }); 18 + } 19 + 20 + export async function POST(request: Request) { 21 + try { 22 + const json = await request.json(); 23 + const _valid = tcpPayload 24 + .merge(z.object({ region: monitorFlyRegionSchema.default("ams") })) 25 + .safeParse(json); 26 + 27 + if (!_valid.success) { 28 + return NextResponse.json({ success: false }, { status: 400 }); 29 + } 30 + 31 + const { url, region } = _valid.data; 32 + 33 + const res = await checkTCP(url, region); 34 + 35 + return NextResponse.json(res); 36 + } catch (e) { 37 + console.error(e); 38 + return NextResponse.json({ success: false }, { status: 400 }); 39 + } 40 + } 41 + async function checkTCP(url: string, region: MonitorFlyRegion) { 42 + // 43 + const res = await fetch(`https://checker.openstatus.dev/ping/tcp/${region}`, { 44 + headers: { 45 + Authorization: `Basic ${process.env.CRON_SECRET}`, 46 + "Content-Type": "application/json", 47 + "fly-prefer-region": region, 48 + }, 49 + method: "POST", 50 + body: JSON.stringify({ 51 + url, 52 + }), 53 + next: { revalidate: 0 }, 54 + }); 55 + 56 + const json = await res.json(); 57 + 58 + const data = TCPResponse.safeParse(json); 59 + 60 + if (!data.success) { 61 + console.log(json); 62 + console.error( 63 + `something went wrong with result ${json} request to ${url} error ${data.error.message}`, 64 + ); 65 + throw new Error(data.error.message); 66 + } 67 + 68 + return data.data; 69 + }
+27
apps/web/src/app/api/checker/test/tcp/schema.ts
··· 1 + import { z } from "zod"; 2 + 3 + import { monitorFlyRegionSchema } from "@openstatus/db/src/schema/constants"; 4 + 5 + export const tcpPayload = z.object({ 6 + workspaceId: z.string(), 7 + monitorId: z.string(), 8 + url: z.string(), 9 + cronTimestamp: z.number(), 10 + timeout: z.number().default(45000), 11 + degradedAfter: z.number().nullable(), 12 + }); 13 + 14 + export const TCPResponse = z.object({ 15 + requestId: z.string().optional(), 16 + workspaceId: z.string(), 17 + monitorId: z.string(), 18 + timestamp: z.number(), 19 + timing: z.object({ 20 + tcpStart: z.number(), 21 + tcpDone: z.number(), 22 + }), 23 + error: z.string().optional(), 24 + region: monitorFlyRegionSchema, 25 + }); 26 + 27 + export type tcpPayload = z.infer<typeof tcpPayload>;
+11 -1
apps/web/src/components/forms/monitor/form.tsx
··· 97 97 ) as any, // TS considers a.type === "textBody" 98 98 degradedAfter: defaultValues?.degradedAfter, 99 99 timeout: defaultValues?.timeout || 45000, 100 + jobType: defaultValues?.jobType || "http", 100 101 }, 101 102 }); 102 103 const router = useRouter(); 103 104 const pathname = usePathname(); 104 105 const [isPending, setPending] = React.useState(false); 105 106 const [pingFailed, setPingFailed] = React.useState(false); 107 + const type = React.useMemo( 108 + () => (defaultValues ? "update" : "create"), 109 + [defaultValues], 110 + ); 106 111 107 112 const handleDataUpdateOrInsertion = async (props: InsertMonitor) => { 108 113 if (defaultValues) { ··· 176 181 statusAssertions, 177 182 headerAssertions, 178 183 textBodyAssertions, 184 + jobType, 179 185 } = form.getValues(); 186 + 187 + // FIXME: add support for TCP 188 + if (jobType !== "http") 189 + return { error: "Only HTTP tests are supported. Coming soon..." }; 180 190 181 191 if ( 182 192 body && ··· 311 321 ) : null} 312 322 </TabsList> 313 323 <TabsContent value="request"> 314 - <SectionRequests {...{ form, pingEndpoint }} /> 324 + <SectionRequests {...{ form, pingEndpoint, type }} /> 315 325 </TabsContent> 316 326 <TabsContent value="assertions"> 317 327 <SectionAssertions {...{ form }} />
+1 -1
apps/web/src/components/forms/monitor/general.tsx
··· 83 83 /> 84 84 </FormControl> 85 85 <FormDescription> 86 - Easily categorize your monitors. 86 + Categorize your monitors. Create new tags by typing a name. 87 87 </FormDescription> 88 88 <FormMessage /> 89 89 </FormItem>
+217 -205
apps/web/src/components/forms/monitor/section-assertions.tsx
··· 25 25 SelectValue, 26 26 } from "@openstatus/ui"; 27 27 28 + import { EmptyState } from "@/components/dashboard/empty-state"; 28 29 import { Icons } from "@/components/icons"; 29 30 import { SectionHeader } from "../shared/section-header"; 30 31 ··· 38 39 interface Props { 39 40 form: UseFormReturn<InsertMonitor>; 40 41 } 42 + 43 + // REMINDER: once we have different types of assertions based on different jobTypes 44 + // we shoulds start creating a mapping function with allowed assertions for each jobType 41 45 42 46 export function SectionAssertions({ form }: Props) { 43 47 const statusAssertions = useFieldArray({ ··· 132 136 </> 133 137 } 134 138 /> 135 - <div className="flex flex-col gap-4"> 136 - {statusAssertions.fields.map((f, i) => ( 137 - <div key={f.id} className="grid grid-cols-12 items-center gap-4"> 138 - <p className="col-span-2 text-muted-foreground text-sm"> 139 - Status Code 140 - </p> 141 - <div className="col-span-3" /> 142 - <FormField 143 - control={form.control} 144 - name={`statusAssertions.${i}.compare`} 145 - render={({ field }) => ( 146 - <FormItem className="col-span-3 w-full"> 147 - <Select 148 - onValueChange={field.onChange} 149 - defaultValue={field.value} 150 - > 151 - <FormControl> 152 - <SelectTrigger> 153 - <SelectValue defaultValue="eq" placeholder="Equal" /> 154 - </SelectTrigger> 155 - </FormControl> 156 - <SelectContent> 157 - {Object.entries(numberCompareDictionary).map( 158 - ([key, value]) => ( 159 - <SelectItem key={key} value={key}> 160 - {value} 161 - </SelectItem> 162 - ), 163 - )} 164 - </SelectContent> 165 - </Select> 166 - </FormItem> 167 - )} 168 - /> 169 - <Input 170 - {...form.register(`statusAssertions.${i}.target`, { 171 - required: true, 172 - valueAsNumber: true, 173 - validate: (value) => 174 - value <= 599 || "Value must be 599 or lower", 175 - })} 176 - type="number" 177 - placeholder="200" 178 - className="col-span-3" 179 - /> 180 - <div className="col-span-1"> 181 - <Button 182 - size="icon" 183 - onClick={() => statusAssertions.remove(i)} 184 - variant="ghost" 185 - type="button" 186 - > 187 - <Icons.trash className="h-4 w-4" /> 188 - </Button> 139 + {form.getValues("jobType") === "http" ? ( 140 + <div className="flex flex-col gap-4"> 141 + {statusAssertions.fields.map((f, i) => ( 142 + <div key={f.id} className="grid grid-cols-12 items-center gap-4"> 143 + <p className="col-span-2 text-muted-foreground text-sm"> 144 + Status Code 145 + </p> 146 + <div className="col-span-3" /> 147 + <FormField 148 + control={form.control} 149 + name={`statusAssertions.${i}.compare`} 150 + render={({ field }) => ( 151 + <FormItem className="col-span-3 w-full"> 152 + <Select 153 + onValueChange={field.onChange} 154 + defaultValue={field.value} 155 + > 156 + <FormControl> 157 + <SelectTrigger> 158 + <SelectValue defaultValue="eq" placeholder="Equal" /> 159 + </SelectTrigger> 160 + </FormControl> 161 + <SelectContent> 162 + {Object.entries(numberCompareDictionary).map( 163 + ([key, value]) => ( 164 + <SelectItem key={key} value={key}> 165 + {value} 166 + </SelectItem> 167 + ), 168 + )} 169 + </SelectContent> 170 + </Select> 171 + </FormItem> 172 + )} 173 + /> 174 + <Input 175 + {...form.register(`statusAssertions.${i}.target`, { 176 + required: true, 177 + valueAsNumber: true, 178 + validate: (value) => 179 + value <= 599 || "Value must be 599 or lower", 180 + })} 181 + type="number" 182 + placeholder="200" 183 + className="col-span-3" 184 + /> 185 + <div className="col-span-1"> 186 + <Button 187 + size="icon" 188 + onClick={() => statusAssertions.remove(i)} 189 + variant="ghost" 190 + type="button" 191 + > 192 + <Icons.trash className="h-4 w-4" /> 193 + </Button> 194 + </div> 189 195 </div> 190 - </div> 191 - ))} 192 - {headerAssertions.fields.map((f, i) => ( 193 - <div key={f.id} className="grid grid-cols-12 items-center gap-4"> 194 - <p className="col-span-2 text-muted-foreground text-sm"> 195 - Response Header 196 - </p> 197 - <Input 198 - {...form.register(`headerAssertions.${i}.key`, { 199 - required: true, 200 - setValueAs: setEmptyOrStr, 201 - })} 202 - className="col-span-3" 203 - placeholder="X-Header" 204 - /> 205 - <FormField 206 - control={form.control} 207 - name={`headerAssertions.${i}.compare`} 208 - render={({ field }) => ( 209 - <FormItem className="col-span-3 w-full"> 210 - <Select 211 - onValueChange={field.onChange} 212 - defaultValue={field.value} 213 - > 214 - <FormControl> 215 - <SelectTrigger> 216 - <SelectValue defaultValue="eq" placeholder="Equal" /> 217 - </SelectTrigger> 218 - </FormControl> 219 - <SelectContent> 220 - {Object.entries(stringCompareDictionary).map( 221 - ([key, value]) => ( 222 - <SelectItem key={key} value={key}> 223 - {value} 224 - </SelectItem> 225 - ), 226 - )} 227 - </SelectContent> 228 - </Select> 229 - </FormItem> 230 - )} 231 - /> 232 - <Input 233 - {...form.register(`headerAssertions.${i}.target`)} 234 - className="col-span-3" 235 - placeholder="x-value" 236 - /> 237 - <div className="col-span-1"> 238 - <Button 239 - size="icon" 240 - onClick={() => headerAssertions.remove(i)} 241 - variant="ghost" 242 - > 243 - <Icons.trash className="h-4 w-4" /> 244 - </Button> 196 + ))} 197 + {headerAssertions.fields.map((f, i) => ( 198 + <div key={f.id} className="grid grid-cols-12 items-center gap-4"> 199 + <p className="col-span-2 text-muted-foreground text-sm"> 200 + Response Header 201 + </p> 202 + <Input 203 + {...form.register(`headerAssertions.${i}.key`, { 204 + required: true, 205 + setValueAs: setEmptyOrStr, 206 + })} 207 + className="col-span-3" 208 + placeholder="X-Header" 209 + /> 210 + <FormField 211 + control={form.control} 212 + name={`headerAssertions.${i}.compare`} 213 + render={({ field }) => ( 214 + <FormItem className="col-span-3 w-full"> 215 + <Select 216 + onValueChange={field.onChange} 217 + defaultValue={field.value} 218 + > 219 + <FormControl> 220 + <SelectTrigger> 221 + <SelectValue defaultValue="eq" placeholder="Equal" /> 222 + </SelectTrigger> 223 + </FormControl> 224 + <SelectContent> 225 + {Object.entries(stringCompareDictionary).map( 226 + ([key, value]) => ( 227 + <SelectItem key={key} value={key}> 228 + {value} 229 + </SelectItem> 230 + ), 231 + )} 232 + </SelectContent> 233 + </Select> 234 + </FormItem> 235 + )} 236 + /> 237 + <Input 238 + {...form.register(`headerAssertions.${i}.target`)} 239 + className="col-span-3" 240 + placeholder="x-value" 241 + /> 242 + <div className="col-span-1"> 243 + <Button 244 + size="icon" 245 + onClick={() => headerAssertions.remove(i)} 246 + variant="ghost" 247 + > 248 + <Icons.trash className="h-4 w-4" /> 249 + </Button> 250 + </div> 245 251 </div> 246 - </div> 247 - ))} 248 - {textBodyAssertions.fields.map((f, i) => ( 249 - <div key={f.id} className="grid grid-cols-12 items-center gap-4"> 250 - <p className="col-span-2 text-muted-foreground text-sm">Body</p> 251 - <div className="col-span-3" /> 252 - <FormField 253 - control={form.control} 254 - name={`textBodyAssertions.${i}.compare`} 255 - render={({ field }) => ( 256 - <FormItem className="col-span-3 w-full"> 257 - <Select 258 - onValueChange={field.onChange} 259 - defaultValue={field.value} 260 - > 261 - <FormControl> 262 - <SelectTrigger> 263 - <SelectValue defaultValue="eq" placeholder="Equal" /> 264 - </SelectTrigger> 265 - </FormControl> 266 - <SelectContent> 267 - {Object.entries(stringCompareDictionary).map( 268 - ([key, value]) => ( 269 - <SelectItem key={key} value={key}> 270 - {value} 271 - </SelectItem> 272 - ), 273 - )} 274 - </SelectContent> 275 - </Select> 276 - </FormItem> 277 - )} 278 - /> 279 - <Input 280 - {...form.register(`textBodyAssertions.${i}.target`, { 281 - required: true, 282 - })} 283 - placeholder="<html>...</html>" 284 - className="col-span-3" 285 - /> 286 - <div className="col-span-1"> 287 - <Button 288 - size="icon" 289 - onClick={() => textBodyAssertions.remove(i)} 290 - variant="ghost" 291 - type="button" 292 - > 293 - <Icons.trash className="h-4 w-4" /> 294 - </Button> 252 + ))} 253 + {textBodyAssertions.fields.map((f, i) => ( 254 + <div key={f.id} className="grid grid-cols-12 items-center gap-4"> 255 + <p className="col-span-2 text-muted-foreground text-sm">Body</p> 256 + <div className="col-span-3" /> 257 + <FormField 258 + control={form.control} 259 + name={`textBodyAssertions.${i}.compare`} 260 + render={({ field }) => ( 261 + <FormItem className="col-span-3 w-full"> 262 + <Select 263 + onValueChange={field.onChange} 264 + defaultValue={field.value} 265 + > 266 + <FormControl> 267 + <SelectTrigger> 268 + <SelectValue defaultValue="eq" placeholder="Equal" /> 269 + </SelectTrigger> 270 + </FormControl> 271 + <SelectContent> 272 + {Object.entries(stringCompareDictionary).map( 273 + ([key, value]) => ( 274 + <SelectItem key={key} value={key}> 275 + {value} 276 + </SelectItem> 277 + ), 278 + )} 279 + </SelectContent> 280 + </Select> 281 + </FormItem> 282 + )} 283 + /> 284 + <Input 285 + {...form.register(`textBodyAssertions.${i}.target`, { 286 + required: true, 287 + })} 288 + placeholder="<html>...</html>" 289 + className="col-span-3" 290 + /> 291 + <div className="col-span-1"> 292 + <Button 293 + size="icon" 294 + onClick={() => textBodyAssertions.remove(i)} 295 + variant="ghost" 296 + type="button" 297 + > 298 + <Icons.trash className="h-4 w-4" /> 299 + </Button> 300 + </div> 295 301 </div> 302 + ))} 303 + <div className="flex flex-wrap gap-4"> 304 + <Button 305 + variant="outline" 306 + type="button" 307 + onClick={() => 308 + statusAssertions.append({ 309 + version: "v1", 310 + type: "status", 311 + compare: "eq", 312 + target: 200, 313 + }) 314 + } 315 + > 316 + Add Status Code Assertion 317 + </Button> 318 + <Button 319 + variant="outline" 320 + type="button" 321 + onClick={() => 322 + headerAssertions.append({ 323 + version: "v1", 324 + type: "header", 325 + key: "Content-Type", 326 + compare: "eq", 327 + target: "application/json", 328 + }) 329 + } 330 + > 331 + Add Header Assertion 332 + </Button> 333 + 334 + <Button 335 + variant="outline" 336 + type="button" 337 + onClick={() => 338 + textBodyAssertions.append({ 339 + version: "v1", 340 + type: "textBody", 341 + compare: "eq", 342 + target: "", 343 + }) 344 + } 345 + > 346 + Add String Body Assertion 347 + </Button> 296 348 </div> 297 - ))} 298 - <div className="flex flex-wrap gap-4"> 299 - <Button 300 - variant="outline" 301 - type="button" 302 - onClick={() => 303 - statusAssertions.append({ 304 - version: "v1", 305 - type: "status", 306 - compare: "eq", 307 - target: 200, 308 - }) 309 - } 310 - > 311 - Add Status Code Assertion 312 - </Button> 313 - <Button 314 - variant="outline" 315 - type="button" 316 - onClick={() => 317 - headerAssertions.append({ 318 - version: "v1", 319 - type: "header", 320 - key: "Content-Type", 321 - compare: "eq", 322 - target: "application/json", 323 - }) 324 - } 325 - > 326 - Add Header Assertion 327 - </Button> 328 - 329 - <Button 330 - variant="outline" 331 - type="button" 332 - onClick={() => 333 - textBodyAssertions.append({ 334 - version: "v1", 335 - type: "textBody", 336 - compare: "eq", 337 - target: "", 338 - }) 339 - } 340 - > 341 - Add String Body Assertion 342 - </Button> 343 349 </div> 344 - </div> 350 + ) : ( 351 + <EmptyState 352 + icon="alert-triangle" 353 + title="No Assertions" 354 + description="Assertions are only available for HTTP monitors." 355 + /> 356 + )} 345 357 </div> 346 358 ); 347 359 }
+331
apps/web/src/components/forms/monitor/section-request-http.tsx
··· 1 + "use client"; 2 + 3 + import { Wand2, X } from "lucide-react"; 4 + import type * as React from "react"; 5 + import { useFieldArray } from "react-hook-form"; 6 + import type { UseFormReturn } from "react-hook-form"; 7 + 8 + import { 9 + monitorMethods, 10 + monitorMethodsSchema, 11 + } from "@openstatus/db/src/schema"; 12 + import type { InsertMonitor } from "@openstatus/db/src/schema"; 13 + import { 14 + Button, 15 + FormControl, 16 + FormDescription, 17 + FormField, 18 + FormItem, 19 + FormLabel, 20 + FormMessage, 21 + Input, 22 + Select, 23 + SelectContent, 24 + SelectItem, 25 + SelectTrigger, 26 + SelectValue, 27 + Textarea, 28 + Tooltip, 29 + TooltipContent, 30 + TooltipProvider, 31 + TooltipTrigger, 32 + } from "@openstatus/ui"; 33 + 34 + import { toast } from "@/lib/toast"; 35 + import { useRef, useState } from "react"; 36 + 37 + const contentTypes = [ 38 + { value: "application/octet-stream", label: "Binary File" }, 39 + { value: "application/json", label: "JSON" }, 40 + { value: "application/xml", label: "XML" }, 41 + { value: "application/yaml", label: "YAML" }, 42 + { value: "application/edn", label: "EDN" }, 43 + { value: "application/other", label: "Other" }, 44 + { value: "none", label: "None" }, 45 + ]; 46 + 47 + interface Props { 48 + form: UseFormReturn<InsertMonitor>; 49 + } 50 + 51 + // TODO: add Dialog with response informations when pingEndpoint! 52 + 53 + export function SectionRequestHTTP({ form }: Props) { 54 + const { fields, append, prepend, remove, update } = useFieldArray({ 55 + name: "headers", 56 + control: form.control, 57 + }); 58 + const inputRef = useRef<HTMLInputElement>(null); 59 + const watchMethod = form.watch("method"); 60 + const [file, setFile] = useState<string | undefined>(undefined); 61 + const [content, setContent] = useState<string | undefined>( 62 + fields.find((field) => field.key === "Content-Type")?.value, 63 + ); 64 + 65 + const validateJSON = (value?: string) => { 66 + if (!value) return; 67 + try { 68 + const obj = JSON.parse(value) as Record<string, unknown>; 69 + form.clearErrors("body"); 70 + return obj; 71 + } catch (_e) { 72 + form.setError("body", { 73 + message: "Not a valid JSON object", 74 + }); 75 + return false; 76 + } 77 + }; 78 + 79 + const uploadFile = async (event: React.ChangeEvent<HTMLInputElement>) => { 80 + if (event.target.files?.[0]) { 81 + const file = event.target.files[0]; 82 + 83 + // File too big, return error 84 + const fileSize = file.size / 1024 / 1024; // in MiB 85 + if (fileSize > 10) { 86 + // Display error message 87 + toast.error("File size is too big. Max 10MB allowed."); 88 + return; 89 + } 90 + 91 + const reader = new FileReader(); 92 + reader.onload = (event) => { 93 + if (event.target?.result && typeof event.target.result === "string") { 94 + form.setValue("body", event.target?.result); 95 + setFile(file.name); 96 + } 97 + }; 98 + 99 + reader.readAsDataURL(file); 100 + } 101 + }; 102 + 103 + const onPrettifyJSON = () => { 104 + const body = form.getValues("body"); 105 + const obj = validateJSON(body); 106 + if (obj) { 107 + const pretty = JSON.stringify(obj, undefined, 4); 108 + form.setValue("body", pretty); 109 + } 110 + }; 111 + 112 + return ( 113 + <div className="grid w-full gap-4"> 114 + <div className="grid gap-4 sm:grid-cols-7"> 115 + <FormField 116 + control={form.control} 117 + name="method" 118 + render={({ field }) => ( 119 + <FormItem className="sm:col-span-1"> 120 + <FormLabel>Method</FormLabel> 121 + <Select 122 + onValueChange={(value) => { 123 + field.onChange(monitorMethodsSchema.parse(value)); 124 + form.resetField("body", { defaultValue: "" }); 125 + setContent(undefined); 126 + }} 127 + defaultValue={field.value} 128 + > 129 + <FormControl> 130 + <SelectTrigger> 131 + <SelectValue placeholder="Select" /> 132 + </SelectTrigger> 133 + </FormControl> 134 + <SelectContent> 135 + {monitorMethods.map((method) => ( 136 + <SelectItem key={method} value={method}> 137 + {method} 138 + </SelectItem> 139 + ))} 140 + </SelectContent> 141 + </Select> 142 + <FormMessage /> 143 + </FormItem> 144 + )} 145 + /> 146 + <FormField 147 + control={form.control} 148 + name="url" 149 + render={({ field }) => ( 150 + <FormItem className="sm:col-span-6"> 151 + <FormLabel>URL</FormLabel> 152 + <FormControl> 153 + <Input 154 + className="bg-muted" 155 + placeholder="https://documenso.com/api/health" 156 + {...field} 157 + /> 158 + </FormControl> 159 + </FormItem> 160 + )} 161 + /> 162 + </div> 163 + <div className="space-y-2 sm:col-span-full"> 164 + <FormLabel>Request Header</FormLabel> 165 + {fields.map((field, index) => ( 166 + <div key={field.id} className="grid grid-cols-6 gap-4"> 167 + <FormField 168 + control={form.control} 169 + name={`headers.${index}.key`} 170 + render={({ field }) => ( 171 + <FormItem className="col-span-2"> 172 + <FormControl> 173 + <Input placeholder="key" {...field} /> 174 + </FormControl> 175 + </FormItem> 176 + )} 177 + /> 178 + <div className="col-span-4 flex items-center space-x-2"> 179 + <FormField 180 + control={form.control} 181 + name={`headers.${index}.value`} 182 + render={({ field }) => ( 183 + <FormItem className="w-full"> 184 + <FormControl> 185 + <Input placeholder="value" {...field} /> 186 + </FormControl> 187 + </FormItem> 188 + )} 189 + /> 190 + <Button 191 + size="icon" 192 + variant="ghost" 193 + type="button" 194 + onClick={() => remove(index)} 195 + > 196 + <X className="h-4 w-4" /> 197 + </Button> 198 + </div> 199 + </div> 200 + ))} 201 + <div> 202 + <Button 203 + type="button" 204 + variant="outline" 205 + onClick={() => append({ key: "", value: "" })} 206 + > 207 + Add Custom Header 208 + </Button> 209 + </div> 210 + </div> 211 + {watchMethod === "POST" && ( 212 + <div className="sm:col-span-full"> 213 + <FormField 214 + control={form.control} 215 + name="body" 216 + render={({ field }) => ( 217 + <FormItem className="space-y-1.5"> 218 + <div className="flex items-end justify-between"> 219 + <FormLabel className="flex items-center space-x-2"> 220 + Body 221 + <Select 222 + defaultValue={content} 223 + onValueChange={(value) => { 224 + setContent(value); 225 + 226 + if (content === "application/octet-stream") { 227 + form.setValue("body", ""); 228 + setFile(undefined); 229 + } 230 + 231 + const contentIndex = fields.findIndex( 232 + (field) => field.key === "Content-Type", 233 + ); 234 + 235 + if (contentIndex >= 0) { 236 + if (value === "none") { 237 + remove(contentIndex); 238 + } else { 239 + update(contentIndex, { 240 + key: "Content-Type", 241 + value, 242 + }); 243 + } 244 + } else { 245 + prepend({ key: "Content-Type", value }); 246 + } 247 + }} 248 + > 249 + <SelectTrigger 250 + variant={"ghost"} 251 + className="ml-1 h-7 text-muted-foreground text-xs" 252 + > 253 + <SelectValue placeholder="Content-Type" /> 254 + </SelectTrigger> 255 + <SelectContent> 256 + {contentTypes.map((type) => ( 257 + <SelectItem key={type.value} value={type.value}> 258 + {type.label} 259 + </SelectItem> 260 + ))} 261 + </SelectContent> 262 + </Select> 263 + </FormLabel> 264 + {watchMethod === "POST" && 265 + fields.some( 266 + (field) => 267 + field.key === "Content-Type" && 268 + field.value === "application/json", 269 + ) && ( 270 + <TooltipProvider> 271 + <Tooltip> 272 + <TooltipTrigger asChild> 273 + <Button 274 + type="button" 275 + variant="ghost" 276 + size="icon" 277 + className="h-7 w-7" 278 + onClick={onPrettifyJSON} 279 + > 280 + <Wand2 className="h-3 w-3" /> 281 + </Button> 282 + </TooltipTrigger> 283 + <TooltipContent> 284 + <p>Prettify JSON</p> 285 + </TooltipContent> 286 + </Tooltip> 287 + </TooltipProvider> 288 + )} 289 + </div> 290 + <div className="space-y-2"> 291 + <FormControl> 292 + {content === "application/octet-stream" ? ( 293 + <> 294 + <Button 295 + type="button" 296 + variant="outline" 297 + onClick={() => inputRef.current?.click()} 298 + className="max-w-56" 299 + > 300 + <span className="truncate"> 301 + {file || form.getValues("body") || "Upload file"} 302 + </span> 303 + </Button> 304 + <input 305 + type="file" 306 + onChange={uploadFile} 307 + ref={inputRef} 308 + hidden 309 + /> 310 + </> 311 + ) : ( 312 + <> 313 + <Textarea 314 + rows={8} 315 + placeholder='{ "hello": "world" }' 316 + {...field} 317 + /> 318 + <FormDescription>Write your payload.</FormDescription> 319 + </> 320 + )} 321 + </FormControl> 322 + <FormMessage /> 323 + </div> 324 + </FormItem> 325 + )} 326 + /> 327 + </div> 328 + )} 329 + </div> 330 + ); 331 + }
+67
apps/web/src/components/forms/monitor/section-request-tcp.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import type { UseFormReturn } from "react-hook-form"; 5 + 6 + import type { InsertMonitor } from "@openstatus/db/src/schema"; 7 + import { 8 + FormControl, 9 + FormDescription, 10 + FormField, 11 + FormItem, 12 + FormLabel, 13 + FormMessage, 14 + Input, 15 + } from "@openstatus/ui"; 16 + 17 + // TODO: add `port` and `host` field instead! 18 + 19 + interface Props { 20 + form: UseFormReturn<InsertMonitor>; 21 + } 22 + 23 + export function SectionRequestTCP({ form }: Props) { 24 + return ( 25 + <div className="grid w-full gap-4"> 26 + <div className="grid gap-4 sm:grid-cols-7"> 27 + <FormField 28 + control={form.control} 29 + name="url" 30 + render={({ field }) => ( 31 + <FormItem className="sm:col-span-5"> 32 + <FormLabel>Host:Port</FormLabel> 33 + <FormControl> 34 + <Input 35 + className="bg-muted" 36 + placeholder="192.168.1.1:80" 37 + {...field} 38 + /> 39 + </FormControl> 40 + <FormMessage /> 41 + <FormDescription> 42 + The input supports both IPv4 addresses and IPv6 addresses. 43 + </FormDescription> 44 + </FormItem> 45 + )} 46 + /> 47 + </div> 48 + <div className="text-sm"> 49 + <p>Examples:</p> 50 + <ul className="list-inside list-disc text-muted-foreground"> 51 + <li> 52 + Domain: <code className="text-foreground">openstatus.dev:443</code> 53 + </li> 54 + <li> 55 + IPv4: <code className="text-foreground">192.168.1.1:443</code> 56 + </li> 57 + <li> 58 + IPv6:{" "} 59 + <code className="text-foreground"> 60 + [2001:db8:85a3:8d3:1319:8a2e:370:7348]:443 61 + </code> 62 + </li> 63 + </ul> 64 + </div> 65 + </div> 66 + ); 67 + }
+106 -313
apps/web/src/components/forms/monitor/section-requests.tsx
··· 1 1 "use client"; 2 2 3 - import { Wand2, X } from "lucide-react"; 4 - import type * as React from "react"; 5 - import { useFieldArray } from "react-hook-form"; 3 + import * as React from "react"; 6 4 import type { UseFormReturn } from "react-hook-form"; 7 5 8 - import { 9 - monitorMethods, 10 - monitorMethodsSchema, 11 - } from "@openstatus/db/src/schema"; 6 + import { monitorJobTypesSchema } from "@openstatus/db/src/schema"; 12 7 import type { InsertMonitor } from "@openstatus/db/src/schema"; 8 + 13 9 import { 14 - Button, 15 - FormControl, 16 - FormDescription, 17 - FormField, 18 - FormItem, 19 - FormLabel, 20 - FormMessage, 21 - Input, 22 - Select, 23 - SelectContent, 24 - SelectItem, 25 - SelectTrigger, 26 - SelectValue, 27 - Textarea, 28 - Tooltip, 29 - TooltipContent, 30 - TooltipProvider, 31 - TooltipTrigger, 32 - } from "@openstatus/ui"; 10 + Tabs, 11 + TabsContent, 12 + TabsList, 13 + TabsTrigger, 14 + } from "@/components/dashboard/tabs"; 33 15 34 - import { toast } from "@/lib/toast"; 35 - import { useRef, useState } from "react"; 16 + import { Alert, AlertDescription, AlertTitle, Badge } from "@openstatus/ui"; 36 17 import { SectionHeader } from "../shared/section-header"; 37 - 38 - const contentTypes = [ 39 - { value: "application/octet-stream", label: "Binary File" }, 40 - { value: "application/json", label: "JSON" }, 41 - { value: "application/xml", label: "XML" }, 42 - { value: "application/yaml", label: "YAML" }, 43 - { value: "application/edn", label: "EDN" }, 44 - { value: "application/other", label: "Other" }, 45 - { value: "none", label: "None" }, 46 - ]; 18 + import { SectionRequestHTTP } from "./section-request-http"; 19 + import { SectionRequestTCP } from "./section-request-tcp"; 47 20 48 21 interface Props { 49 22 form: UseFormReturn<InsertMonitor>; 23 + type: "create" | "update"; 50 24 } 51 25 52 26 // TODO: add Dialog with response informations when pingEndpoint! 53 27 54 - export function SectionRequests({ form }: Props) { 55 - const { fields, append, prepend, remove, update } = useFieldArray({ 56 - name: "headers", 57 - control: form.control, 58 - }); 59 - const inputRef = useRef<HTMLInputElement>(null); 60 - const watchMethod = form.watch("method"); 61 - const [file, setFile] = useState<string | undefined>(undefined); 62 - const [content, setContent] = useState<string | undefined>( 63 - fields.find((field) => field.key === "Content-Type")?.value, 64 - ); 28 + export function SectionRequests({ form, type }: Props) { 29 + const jobType = form.getValues("jobType"); 30 + const [prevJobType, setPrevJobType] = React.useState(jobType); 65 31 66 - const validateJSON = (value?: string) => { 67 - if (!value) return; 68 - try { 69 - const obj = JSON.parse(value) as Record<string, unknown>; 70 - form.clearErrors("body"); 71 - return obj; 72 - } catch (_e) { 73 - form.setError("body", { 74 - message: "Not a valid JSON object", 75 - }); 76 - return false; 77 - } 78 - }; 79 - 80 - const uploadFile = async (event: React.ChangeEvent<HTMLInputElement>) => { 81 - if (event.target.files?.[0]) { 82 - const file = event.target.files[0]; 83 - 84 - // File too big, return error 85 - const fileSize = file.size / 1024 / 1024; // in MiB 86 - if (fileSize > 10) { 87 - // Display error message 88 - toast.error("File size is too big. Max 10MB allowed."); 89 - return; 90 - } 91 - 92 - const reader = new FileReader(); 93 - reader.onload = (event) => { 94 - if (event.target?.result && typeof event.target.result === "string") { 95 - form.setValue("body", event.target?.result); 96 - setFile(file.name); 97 - } 98 - }; 99 - 100 - reader.readAsDataURL(file); 101 - } 102 - }; 103 - 104 - const onPrettifyJSON = () => { 105 - const body = form.getValues("body"); 106 - const obj = validateJSON(body); 107 - if (obj) { 108 - const pretty = JSON.stringify(obj, undefined, 4); 109 - form.setValue("body", pretty); 32 + if (prevJobType !== jobType) { 33 + setPrevJobType(jobType); 34 + if (type === "create") { 35 + form.resetField("url"); 36 + form.resetField("method"); 37 + form.resetField("body"); 38 + form.resetField("headers"); 39 + form.resetField("headerAssertions"); 40 + form.resetField("statusAssertions"); 41 + form.resetField("textBodyAssertions"); 110 42 } 111 - }; 43 + } 112 44 113 45 return ( 114 46 <div className="grid w-full gap-4"> 115 47 <SectionHeader 116 - title="HTTP Request Settings" 117 - description="Create your HTTP. Add custom headers, payload and test your endpoint before submitting." 48 + title="Request Settings" 49 + description={ 50 + type === "create" ? ( 51 + <> 52 + Create your{" "} 53 + <span className="font-medium font-mono text-foreground"> 54 + HTTP 55 + </span>{" "} 56 + or{" "} 57 + <span className="font-medium font-mono text-foreground">TCP</span>{" "} 58 + request type. Add custom headers, payload and test your endpoint 59 + before submitting.{" "} 60 + <span className="font-medium"> 61 + You will not be able to switch type after saving. 62 + </span> 63 + </> 64 + ) : ( 65 + <> 66 + Update your{" "} 67 + <span className="font-medium font-mono text-foreground"> 68 + {jobType?.toUpperCase()} 69 + </span>{" "} 70 + request. Add custom headers, payload and test your endpoint before 71 + submitting. 72 + </> 73 + ) 74 + } 118 75 /> 119 - <div className="grid gap-4 sm:grid-cols-7"> 120 - <FormField 121 - control={form.control} 122 - name="method" 123 - render={({ field }) => ( 124 - <FormItem className="sm:col-span-1"> 125 - <FormLabel>Method</FormLabel> 126 - <Select 127 - onValueChange={(value) => { 128 - field.onChange(monitorMethodsSchema.parse(value)); 129 - form.resetField("body", { defaultValue: "" }); 130 - setContent(undefined); 131 - }} 132 - defaultValue={field.value} 133 - > 134 - <FormControl> 135 - <SelectTrigger> 136 - <SelectValue placeholder="Select" /> 137 - </SelectTrigger> 138 - </FormControl> 139 - <SelectContent> 140 - {monitorMethods.map((method) => ( 141 - <SelectItem key={method} value={method}> 142 - {method} 143 - </SelectItem> 144 - ))} 145 - </SelectContent> 146 - </Select> 147 - <FormMessage /> 148 - </FormItem> 149 - )} 150 - /> 151 - <FormField 152 - control={form.control} 153 - name="url" 154 - render={({ field }) => ( 155 - <FormItem className="sm:col-span-6"> 156 - <FormLabel>URL</FormLabel> 157 - <FormControl> 158 - <Input 159 - className="bg-muted" 160 - placeholder="https://documenso.com/api/health" 161 - {...field} 162 - /> 163 - </FormControl> 164 - </FormItem> 165 - )} 166 - /> 167 - </div> 168 - <div className="space-y-2 sm:col-span-full"> 169 - <FormLabel>Request Header</FormLabel> 170 - {fields.map((field, index) => ( 171 - <div key={field.id} className="grid grid-cols-6 gap-4"> 172 - <FormField 173 - control={form.control} 174 - name={`headers.${index}.key`} 175 - render={({ field }) => ( 176 - <FormItem className="col-span-2"> 177 - <FormControl> 178 - <Input placeholder="key" {...field} /> 179 - </FormControl> 180 - </FormItem> 181 - )} 182 - /> 183 - <div className="col-span-4 flex items-center space-x-2"> 184 - <FormField 185 - control={form.control} 186 - name={`headers.${index}.value`} 187 - render={({ field }) => ( 188 - <FormItem className="w-full"> 189 - <FormControl> 190 - <Input placeholder="value" {...field} /> 191 - </FormControl> 192 - </FormItem> 193 - )} 194 - /> 195 - <Button 196 - size="icon" 197 - variant="ghost" 198 - type="button" 199 - onClick={() => { 200 - remove(index); 201 - }} 202 - > 203 - <X className="h-4 w-4" /> 204 - </Button> 205 - </div> 206 - </div> 207 - ))} 208 - <div> 209 - <Button 210 - type="button" 211 - variant="outline" 212 - onClick={() => append({ key: "", value: "" })} 213 - > 214 - Add Custom Header 215 - </Button> 216 - </div> 217 - </div> 218 - {watchMethod === "POST" && ( 219 - <div className="sm:col-span-full"> 220 - <FormField 221 - control={form.control} 222 - name="body" 223 - render={({ field }) => ( 224 - <FormItem className="space-y-1.5"> 225 - <div className="flex items-end justify-between"> 226 - <FormLabel className="flex items-center space-x-2"> 227 - Body 228 - <Select 229 - defaultValue={content} 230 - onValueChange={(value) => { 231 - setContent(value); 232 - 233 - if (content === "application/octet-stream") { 234 - form.setValue("body", ""); 235 - setFile(undefined); 236 - } 237 - 238 - const contentIndex = fields.findIndex( 239 - (field) => field.key === "Content-Type", 240 - ); 241 - 242 - if (contentIndex >= 0) { 243 - if (value === "none") { 244 - remove(contentIndex); 245 - } else { 246 - update(contentIndex, { 247 - key: "Content-Type", 248 - value, 249 - }); 250 - } 251 - } else { 252 - prepend({ key: "Content-Type", value }); 253 - } 254 - }} 255 - > 256 - <SelectTrigger 257 - variant={"ghost"} 258 - className="ml-1 h-7 text-muted-foreground text-xs" 259 - > 260 - <SelectValue placeholder="Content-Type" /> 261 - </SelectTrigger> 262 - <SelectContent> 263 - {contentTypes.map((type) => ( 264 - <SelectItem key={type.value} value={type.value}> 265 - {type.label} 266 - </SelectItem> 267 - ))} 268 - </SelectContent> 269 - </Select> 270 - </FormLabel> 271 - {watchMethod === "POST" && 272 - fields.some( 273 - (field) => 274 - field.key === "Content-Type" && 275 - field.value === "application/json", 276 - ) && ( 277 - <TooltipProvider> 278 - <Tooltip> 279 - <TooltipTrigger asChild> 280 - <Button 281 - type="button" 282 - variant="ghost" 283 - size="icon" 284 - className="h-7 w-7" 285 - onClick={onPrettifyJSON} 286 - > 287 - <Wand2 className="h-3 w-3" /> 288 - </Button> 289 - </TooltipTrigger> 290 - <TooltipContent> 291 - <p>Prettify JSON</p> 292 - </TooltipContent> 293 - </Tooltip> 294 - </TooltipProvider> 295 - )} 296 - </div> 297 - <div className="space-y-2"> 298 - <FormControl> 299 - {content === "application/octet-stream" ? ( 300 - <> 301 - <Button 302 - type="button" 303 - variant="outline" 304 - onClick={() => inputRef.current?.click()} 305 - className="max-w-56" 306 - > 307 - <span className="truncate"> 308 - {file || form.getValues("body") || "Upload file"} 309 - </span> 310 - </Button> 311 - <input 312 - type="file" 313 - onChange={uploadFile} 314 - ref={inputRef} 315 - hidden 316 - /> 317 - </> 318 - ) : ( 319 - <> 320 - <Textarea 321 - rows={8} 322 - placeholder='{ "hello": "world" }' 323 - {...field} 324 - /> 325 - <FormDescription>Write your payload.</FormDescription> 326 - </> 327 - )} 328 - </FormControl> 329 - <FormMessage /> 330 - </div> 331 - </FormItem> 332 - )} 333 - /> 334 - </div> 335 - )} 76 + {type === "create" ? ( 77 + <Tabs 78 + value={jobType} 79 + onValueChange={(value) => { 80 + const validate = monitorJobTypesSchema.safeParse(value); 81 + if (!validate.success) return; 82 + form.setValue("jobType", validate.data, { 83 + shouldDirty: true, 84 + shouldTouch: true, 85 + shouldValidate: true, 86 + }); 87 + }} 88 + > 89 + <TabsList> 90 + <TabsTrigger value="http">HTTP</TabsTrigger> 91 + <TabsTrigger value="tcp" disabled={true}> 92 + TCP{" "} 93 + <Badge className="ml-2" variant="default"> 94 + Coming soon 95 + </Badge> 96 + </TabsTrigger> 97 + </TabsList> 98 + <TabsContent value="http"> 99 + <SectionRequestHTTP {...{ form }} /> 100 + </TabsContent> 101 + <TabsContent value="tcp"> 102 + <SectionRequestTCP {...{ form }} /> 103 + </TabsContent> 104 + </Tabs> 105 + ) : null} 106 + {type === "update" 107 + ? (() => { 108 + switch (jobType) { 109 + case "http": 110 + return <SectionRequestHTTP {...{ form }} />; 111 + case "tcp": 112 + return <SectionRequestTCP {...{ form }} />; 113 + default: 114 + return ( 115 + <Alert> 116 + <AlertTitle>Missing Type</AlertTitle> 117 + <AlertDescription> 118 + The job type{" "} 119 + <span className="font-mono text-foreground uppercase"> 120 + {jobType} 121 + </span>{" "} 122 + is missing. Please select a valid job type. 123 + </AlertDescription> 124 + </Alert> 125 + ); 126 + } 127 + })() 128 + : null} 336 129 </div> 337 130 ); 338 131 }
+22 -12
apps/web/src/components/forms/monitor/section-scheduling.tsx
··· 24 24 import type { Limits } from "@openstatus/db/src/schema/plan/schema"; 25 25 import { CheckboxLabel } from "../shared/checkbox-label"; 26 26 import { SectionHeader } from "../shared/section-header"; 27 + import { SelectRegion } from "./select-region"; 27 28 28 29 // TODO: centralize in a shared file! 29 30 const cronJobs = [ ··· 95 96 render={({ field }) => { 96 97 return ( 97 98 <FormItem> 98 - <div className="mb-4"> 99 - <FormLabel className="text-base">Regions</FormLabel> 100 - <FormDescription> 101 - Select the regions you want to monitor your endpoint from.{" "} 102 - <br /> 103 - {plan === "free" 104 - ? "Only a few regions are available in the free plan. Upgrade to access all regions." 105 - : ""} 106 - </FormDescription> 99 + <FormLabel className="text-base">Regions</FormLabel> 100 + <div> 101 + <FormControl> 102 + <SelectRegion 103 + value={field.value} 104 + allowedRegions={regionsLimit} 105 + onChange={field.onChange} 106 + /> 107 + </FormControl> 107 108 </div> 109 + <FormDescription> 110 + Select the regions you want to monitor your endpoint from.{" "} 111 + <br /> 112 + {plan === "free" 113 + ? "Only a few regions are available in the free plan. Upgrade to access all regions." 114 + : ""} 115 + </FormDescription> 108 116 <div> 109 117 {Object.entries(groupByContinent) 110 118 .sort((a, b) => a[0].localeCompare(b[0])) ··· 114 122 .map((current) => { 115 123 return ( 116 124 <div key={current.continent} className="py-2"> 117 - {current.continent} 118 - 119 - <div className="grid grid-cols-3 grid-rows-1 gap-2 pt-1"> 125 + <p className="font-medium text-muted-foreground text-sm"> 126 + {current.continent} 127 + </p> 128 + <div className="grid grid-cols-3 gap-2"> 120 129 {current.regions 121 130 .sort((a, b) => 122 131 a.location.localeCompare(b.location), ··· 160 169 ), 161 170 ); 162 171 }} 172 + className="p-3" 163 173 > 164 174 {location} {flag} 165 175 </CheckboxLabel>
+142
apps/web/src/components/forms/monitor/select-region.tsx
··· 1 + "use client"; 2 + 3 + import { Check, ChevronsUpDown, Globe2 } from "lucide-react"; 4 + import * as React from "react"; 5 + 6 + import type { Region } from "@openstatus/tinybird"; 7 + import { Button, type ButtonProps } from "@openstatus/ui/src/components/button"; 8 + import { 9 + Command, 10 + CommandEmpty, 11 + CommandGroup, 12 + CommandInput, 13 + CommandItem, 14 + CommandList, 15 + CommandSeparator, 16 + } from "@openstatus/ui/src/components/command"; 17 + import { 18 + Popover, 19 + PopoverContent, 20 + PopoverTrigger, 21 + } from "@openstatus/ui/src/components/popover"; 22 + import { 23 + type Continent, 24 + type RegionInfo, 25 + flyRegionsDict, 26 + } from "@openstatus/utils"; 27 + 28 + import { cn } from "@/lib/utils"; 29 + import { flyRegions } from "@openstatus/db/src/schema/constants"; 30 + 31 + interface SelectRegionProps extends Omit<ButtonProps, "onChange"> { 32 + allowedRegions: Region[]; 33 + value?: Region[]; 34 + onChange?: (value: Region[]) => void; 35 + } 36 + 37 + export function SelectRegion({ 38 + value = [], 39 + onChange, 40 + allowedRegions, 41 + className, 42 + ...props 43 + }: SelectRegionProps) { 44 + const regionsByContinent = flyRegions.reduce( 45 + (prev, curr) => { 46 + const region = flyRegionsDict[curr]; 47 + 48 + const item = prev.find((r) => r.continent === region.continent); 49 + 50 + if (item) { 51 + item.data.push(region); 52 + } else { 53 + prev.push({ 54 + continent: region.continent, 55 + data: [region], 56 + }); 57 + } 58 + 59 + return prev; 60 + }, 61 + [] as { continent: Continent; data: RegionInfo[] }[], 62 + ); 63 + 64 + return ( 65 + <Popover> 66 + <PopoverTrigger asChild> 67 + <Button 68 + size="lg" 69 + variant="outline" 70 + className={cn("px-3 shadow-none", className)} 71 + {...props} 72 + > 73 + <Globe2 className="mr-2 h-4 w-4" /> 74 + <span className="whitespace-nowrap"> 75 + <code>{value.length}</code> Regions 76 + </span> 77 + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> 78 + </Button> 79 + </PopoverTrigger> 80 + <PopoverContent className="p-0" align="start"> 81 + <Command> 82 + <CommandInput placeholder="Search regions..." /> 83 + <CommandList className="max-h-64"> 84 + <CommandEmpty>No results found.</CommandEmpty> 85 + <CommandGroup> 86 + <CommandItem 87 + onSelect={() => onChange?.(value.length ? [] : allowedRegions)} 88 + > 89 + {value.length ? "Clear all" : "Select all"} 90 + </CommandItem> 91 + </CommandGroup> 92 + <CommandSeparator /> 93 + {regionsByContinent.map(({ continent, data }) => { 94 + return ( 95 + <CommandGroup key={continent} heading={continent}> 96 + {data.map((region) => { 97 + const { code, flag, location, continent } = region; 98 + const isSelected = value.includes(code); 99 + return ( 100 + <CommandItem 101 + key={code} 102 + value={code} 103 + keywords={[code, location, continent]} 104 + disabled={!allowedRegions.includes(code)} 105 + onSelect={(checked) => { 106 + const newValue = !value.includes(checked as Region) 107 + ? [...value, code] 108 + : value.filter((r) => r !== code); 109 + onChange?.(newValue); 110 + }} 111 + > 112 + <div 113 + className={cn( 114 + "mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary", 115 + isSelected 116 + ? "bg-primary text-primary-foreground" 117 + : "opacity-50 [&_svg]:invisible", 118 + )} 119 + > 120 + <Check className={cn("h-4 w-4")} /> 121 + </div> 122 + <div className="flex w-full justify-between"> 123 + <span> 124 + {code}{" "} 125 + <span className="truncate text-muted-foreground"> 126 + {location} 127 + </span> 128 + </span> 129 + <span>{flag}</span> 130 + </div> 131 + </CommandItem> 132 + ); 133 + })} 134 + </CommandGroup> 135 + ); 136 + })} 137 + </CommandList> 138 + </Command> 139 + </PopoverContent> 140 + </Popover> 141 + ); 142 + }
+5 -3
apps/web/src/components/forms/monitor/tags-multi-box.tsx
··· 366 366 <AlertDialogContent> 367 367 <AlertDialogHeader> 368 368 <AlertDialogTitle>Are you sure sure?</AlertDialogTitle> 369 - <AlertDialogDescription> 370 - You are about to delete the tag{" "} 371 - <TagBadge color={color} name={name} /> . 369 + <AlertDialogDescription asChild> 370 + <div> 371 + You are about to delete the tag{" "} 372 + <TagBadge color={color} name={name} /> . 373 + </div> 372 374 </AlertDialogDescription> 373 375 </AlertDialogHeader> 374 376 <AlertDialogFooter>
+1 -1
apps/web/src/components/forms/shared/checkbox-label.tsx
··· 36 36 <Label 37 37 htmlFor={`${name}-${id}`} 38 38 className={cn( 39 - "flex h-full items-center gap-1 rounded-md border border-border bg-popover p-4 pr-10 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary", 39 + "flex h-full items-center gap-1 rounded-md border border-border bg-popover p-4 pr-10 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary peer-disabled:hover:bg-background peer-disabled:text-muted-foreground", 40 40 className, 41 41 )} 42 42 >
+1 -1
package.json
··· 4 4 "build": "turbo run build", 5 5 "dev": "turbo run dev", 6 6 "lint": "biome lint .", 7 - "format": "pnpm biome format . --write && pnpm biome check . --apply-unsafe ", 7 + "format": "pnpm biome format . --write && pnpm biome check . --write ", 8 8 "lint:turbo": "turbo run lint", 9 9 "dev:web": "turbo run dev --filter='./apps/web' --filter='./packages/db'", 10 10 "dx": "turbo run dx",
+1
packages/db/drizzle/0036_gifted_deathbird.sql
··· 1 + UPDATE `monitor` SET `job_type` = 'http' ;
+2197
packages/db/drizzle/meta/0036_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "7321a546-31be-4313-8e3f-235db16ddee4", 5 + "prevId": "d42f0027-40b6-4d43-9d76-160e0a6e35e7", 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_report": { 74 + "name": "status_report", 75 + "columns": { 76 + "id": { 77 + "name": "id", 78 + "type": "integer", 79 + "primaryKey": true, 80 + "notNull": true, 81 + "autoincrement": false 82 + }, 83 + "status": { 84 + "name": "status", 85 + "type": "text", 86 + "primaryKey": false, 87 + "notNull": true, 88 + "autoincrement": false 89 + }, 90 + "title": { 91 + "name": "title", 92 + "type": "text(256)", 93 + "primaryKey": false, 94 + "notNull": true, 95 + "autoincrement": false 96 + }, 97 + "workspace_id": { 98 + "name": "workspace_id", 99 + "type": "integer", 100 + "primaryKey": false, 101 + "notNull": false, 102 + "autoincrement": false 103 + }, 104 + "page_id": { 105 + "name": "page_id", 106 + "type": "integer", 107 + "primaryKey": false, 108 + "notNull": false, 109 + "autoincrement": false 110 + }, 111 + "created_at": { 112 + "name": "created_at", 113 + "type": "integer", 114 + "primaryKey": false, 115 + "notNull": false, 116 + "autoincrement": false, 117 + "default": "(strftime('%s', 'now'))" 118 + }, 119 + "updated_at": { 120 + "name": "updated_at", 121 + "type": "integer", 122 + "primaryKey": false, 123 + "notNull": false, 124 + "autoincrement": false, 125 + "default": "(strftime('%s', 'now'))" 126 + } 127 + }, 128 + "indexes": {}, 129 + "foreignKeys": { 130 + "status_report_workspace_id_workspace_id_fk": { 131 + "name": "status_report_workspace_id_workspace_id_fk", 132 + "tableFrom": "status_report", 133 + "tableTo": "workspace", 134 + "columnsFrom": [ 135 + "workspace_id" 136 + ], 137 + "columnsTo": [ 138 + "id" 139 + ], 140 + "onDelete": "no action", 141 + "onUpdate": "no action" 142 + }, 143 + "status_report_page_id_page_id_fk": { 144 + "name": "status_report_page_id_page_id_fk", 145 + "tableFrom": "status_report", 146 + "tableTo": "page", 147 + "columnsFrom": [ 148 + "page_id" 149 + ], 150 + "columnsTo": [ 151 + "id" 152 + ], 153 + "onDelete": "no action", 154 + "onUpdate": "no action" 155 + } 156 + }, 157 + "compositePrimaryKeys": {}, 158 + "uniqueConstraints": {} 159 + }, 160 + "status_report_update": { 161 + "name": "status_report_update", 162 + "columns": { 163 + "id": { 164 + "name": "id", 165 + "type": "integer", 166 + "primaryKey": true, 167 + "notNull": true, 168 + "autoincrement": false 169 + }, 170 + "status": { 171 + "name": "status", 172 + "type": "text(4)", 173 + "primaryKey": false, 174 + "notNull": true, 175 + "autoincrement": false 176 + }, 177 + "date": { 178 + "name": "date", 179 + "type": "integer", 180 + "primaryKey": false, 181 + "notNull": true, 182 + "autoincrement": false 183 + }, 184 + "message": { 185 + "name": "message", 186 + "type": "text", 187 + "primaryKey": false, 188 + "notNull": true, 189 + "autoincrement": false 190 + }, 191 + "status_report_id": { 192 + "name": "status_report_id", 193 + "type": "integer", 194 + "primaryKey": false, 195 + "notNull": true, 196 + "autoincrement": false 197 + }, 198 + "created_at": { 199 + "name": "created_at", 200 + "type": "integer", 201 + "primaryKey": false, 202 + "notNull": false, 203 + "autoincrement": false, 204 + "default": "(strftime('%s', 'now'))" 205 + }, 206 + "updated_at": { 207 + "name": "updated_at", 208 + "type": "integer", 209 + "primaryKey": false, 210 + "notNull": false, 211 + "autoincrement": false, 212 + "default": "(strftime('%s', 'now'))" 213 + } 214 + }, 215 + "indexes": {}, 216 + "foreignKeys": { 217 + "status_report_update_status_report_id_status_report_id_fk": { 218 + "name": "status_report_update_status_report_id_status_report_id_fk", 219 + "tableFrom": "status_report_update", 220 + "tableTo": "status_report", 221 + "columnsFrom": [ 222 + "status_report_id" 223 + ], 224 + "columnsTo": [ 225 + "id" 226 + ], 227 + "onDelete": "cascade", 228 + "onUpdate": "no action" 229 + } 230 + }, 231 + "compositePrimaryKeys": {}, 232 + "uniqueConstraints": {} 233 + }, 234 + "integration": { 235 + "name": "integration", 236 + "columns": { 237 + "id": { 238 + "name": "id", 239 + "type": "integer", 240 + "primaryKey": true, 241 + "notNull": true, 242 + "autoincrement": false 243 + }, 244 + "name": { 245 + "name": "name", 246 + "type": "text(256)", 247 + "primaryKey": false, 248 + "notNull": true, 249 + "autoincrement": false 250 + }, 251 + "workspace_id": { 252 + "name": "workspace_id", 253 + "type": "integer", 254 + "primaryKey": false, 255 + "notNull": false, 256 + "autoincrement": false 257 + }, 258 + "credential": { 259 + "name": "credential", 260 + "type": "text", 261 + "primaryKey": false, 262 + "notNull": false, 263 + "autoincrement": false 264 + }, 265 + "external_id": { 266 + "name": "external_id", 267 + "type": "text", 268 + "primaryKey": false, 269 + "notNull": true, 270 + "autoincrement": false 271 + }, 272 + "created_at": { 273 + "name": "created_at", 274 + "type": "integer", 275 + "primaryKey": false, 276 + "notNull": false, 277 + "autoincrement": false, 278 + "default": "(strftime('%s', 'now'))" 279 + }, 280 + "updated_at": { 281 + "name": "updated_at", 282 + "type": "integer", 283 + "primaryKey": false, 284 + "notNull": false, 285 + "autoincrement": false, 286 + "default": "(strftime('%s', 'now'))" 287 + }, 288 + "data": { 289 + "name": "data", 290 + "type": "text", 291 + "primaryKey": false, 292 + "notNull": true, 293 + "autoincrement": false 294 + } 295 + }, 296 + "indexes": {}, 297 + "foreignKeys": { 298 + "integration_workspace_id_workspace_id_fk": { 299 + "name": "integration_workspace_id_workspace_id_fk", 300 + "tableFrom": "integration", 301 + "tableTo": "workspace", 302 + "columnsFrom": [ 303 + "workspace_id" 304 + ], 305 + "columnsTo": [ 306 + "id" 307 + ], 308 + "onDelete": "no action", 309 + "onUpdate": "no action" 310 + } 311 + }, 312 + "compositePrimaryKeys": {}, 313 + "uniqueConstraints": {} 314 + }, 315 + "page": { 316 + "name": "page", 317 + "columns": { 318 + "id": { 319 + "name": "id", 320 + "type": "integer", 321 + "primaryKey": true, 322 + "notNull": true, 323 + "autoincrement": false 324 + }, 325 + "workspace_id": { 326 + "name": "workspace_id", 327 + "type": "integer", 328 + "primaryKey": false, 329 + "notNull": true, 330 + "autoincrement": false 331 + }, 332 + "title": { 333 + "name": "title", 334 + "type": "text", 335 + "primaryKey": false, 336 + "notNull": true, 337 + "autoincrement": false 338 + }, 339 + "description": { 340 + "name": "description", 341 + "type": "text", 342 + "primaryKey": false, 343 + "notNull": true, 344 + "autoincrement": false 345 + }, 346 + "icon": { 347 + "name": "icon", 348 + "type": "text(256)", 349 + "primaryKey": false, 350 + "notNull": false, 351 + "autoincrement": false, 352 + "default": "''" 353 + }, 354 + "slug": { 355 + "name": "slug", 356 + "type": "text(256)", 357 + "primaryKey": false, 358 + "notNull": true, 359 + "autoincrement": false 360 + }, 361 + "custom_domain": { 362 + "name": "custom_domain", 363 + "type": "text(256)", 364 + "primaryKey": false, 365 + "notNull": true, 366 + "autoincrement": false 367 + }, 368 + "published": { 369 + "name": "published", 370 + "type": "integer", 371 + "primaryKey": false, 372 + "notNull": false, 373 + "autoincrement": false, 374 + "default": false 375 + }, 376 + "password": { 377 + "name": "password", 378 + "type": "text(256)", 379 + "primaryKey": false, 380 + "notNull": false, 381 + "autoincrement": false 382 + }, 383 + "password_protected": { 384 + "name": "password_protected", 385 + "type": "integer", 386 + "primaryKey": false, 387 + "notNull": false, 388 + "autoincrement": false, 389 + "default": false 390 + }, 391 + "created_at": { 392 + "name": "created_at", 393 + "type": "integer", 394 + "primaryKey": false, 395 + "notNull": false, 396 + "autoincrement": false, 397 + "default": "(strftime('%s', 'now'))" 398 + }, 399 + "updated_at": { 400 + "name": "updated_at", 401 + "type": "integer", 402 + "primaryKey": false, 403 + "notNull": false, 404 + "autoincrement": false, 405 + "default": "(strftime('%s', 'now'))" 406 + } 407 + }, 408 + "indexes": { 409 + "page_slug_unique": { 410 + "name": "page_slug_unique", 411 + "columns": [ 412 + "slug" 413 + ], 414 + "isUnique": true 415 + } 416 + }, 417 + "foreignKeys": { 418 + "page_workspace_id_workspace_id_fk": { 419 + "name": "page_workspace_id_workspace_id_fk", 420 + "tableFrom": "page", 421 + "tableTo": "workspace", 422 + "columnsFrom": [ 423 + "workspace_id" 424 + ], 425 + "columnsTo": [ 426 + "id" 427 + ], 428 + "onDelete": "cascade", 429 + "onUpdate": "no action" 430 + } 431 + }, 432 + "compositePrimaryKeys": {}, 433 + "uniqueConstraints": {} 434 + }, 435 + "monitor": { 436 + "name": "monitor", 437 + "columns": { 438 + "id": { 439 + "name": "id", 440 + "type": "integer", 441 + "primaryKey": true, 442 + "notNull": true, 443 + "autoincrement": false 444 + }, 445 + "job_type": { 446 + "name": "job_type", 447 + "type": "text", 448 + "primaryKey": false, 449 + "notNull": true, 450 + "autoincrement": false, 451 + "default": "'http'" 452 + }, 453 + "periodicity": { 454 + "name": "periodicity", 455 + "type": "text", 456 + "primaryKey": false, 457 + "notNull": true, 458 + "autoincrement": false, 459 + "default": "'other'" 460 + }, 461 + "status": { 462 + "name": "status", 463 + "type": "text", 464 + "primaryKey": false, 465 + "notNull": true, 466 + "autoincrement": false, 467 + "default": "'active'" 468 + }, 469 + "active": { 470 + "name": "active", 471 + "type": "integer", 472 + "primaryKey": false, 473 + "notNull": false, 474 + "autoincrement": false, 475 + "default": false 476 + }, 477 + "regions": { 478 + "name": "regions", 479 + "type": "text", 480 + "primaryKey": false, 481 + "notNull": true, 482 + "autoincrement": false, 483 + "default": "''" 484 + }, 485 + "url": { 486 + "name": "url", 487 + "type": "text(2048)", 488 + "primaryKey": false, 489 + "notNull": true, 490 + "autoincrement": false 491 + }, 492 + "name": { 493 + "name": "name", 494 + "type": "text(256)", 495 + "primaryKey": false, 496 + "notNull": true, 497 + "autoincrement": false, 498 + "default": "''" 499 + }, 500 + "description": { 501 + "name": "description", 502 + "type": "text", 503 + "primaryKey": false, 504 + "notNull": true, 505 + "autoincrement": false, 506 + "default": "''" 507 + }, 508 + "headers": { 509 + "name": "headers", 510 + "type": "text", 511 + "primaryKey": false, 512 + "notNull": false, 513 + "autoincrement": false, 514 + "default": "''" 515 + }, 516 + "body": { 517 + "name": "body", 518 + "type": "text", 519 + "primaryKey": false, 520 + "notNull": false, 521 + "autoincrement": false, 522 + "default": "''" 523 + }, 524 + "method": { 525 + "name": "method", 526 + "type": "text", 527 + "primaryKey": false, 528 + "notNull": false, 529 + "autoincrement": false, 530 + "default": "'GET'" 531 + }, 532 + "workspace_id": { 533 + "name": "workspace_id", 534 + "type": "integer", 535 + "primaryKey": false, 536 + "notNull": false, 537 + "autoincrement": false 538 + }, 539 + "timeout": { 540 + "name": "timeout", 541 + "type": "integer", 542 + "primaryKey": false, 543 + "notNull": true, 544 + "autoincrement": false, 545 + "default": 45000 546 + }, 547 + "degraded_after": { 548 + "name": "degraded_after", 549 + "type": "integer", 550 + "primaryKey": false, 551 + "notNull": false, 552 + "autoincrement": false 553 + }, 554 + "assertions": { 555 + "name": "assertions", 556 + "type": "text", 557 + "primaryKey": false, 558 + "notNull": false, 559 + "autoincrement": false 560 + }, 561 + "public": { 562 + "name": "public", 563 + "type": "integer", 564 + "primaryKey": false, 565 + "notNull": false, 566 + "autoincrement": false, 567 + "default": false 568 + }, 569 + "created_at": { 570 + "name": "created_at", 571 + "type": "integer", 572 + "primaryKey": false, 573 + "notNull": false, 574 + "autoincrement": false, 575 + "default": "(strftime('%s', 'now'))" 576 + }, 577 + "updated_at": { 578 + "name": "updated_at", 579 + "type": "integer", 580 + "primaryKey": false, 581 + "notNull": false, 582 + "autoincrement": false, 583 + "default": "(strftime('%s', 'now'))" 584 + }, 585 + "deleted_at": { 586 + "name": "deleted_at", 587 + "type": "integer", 588 + "primaryKey": false, 589 + "notNull": false, 590 + "autoincrement": false 591 + } 592 + }, 593 + "indexes": {}, 594 + "foreignKeys": { 595 + "monitor_workspace_id_workspace_id_fk": { 596 + "name": "monitor_workspace_id_workspace_id_fk", 597 + "tableFrom": "monitor", 598 + "tableTo": "workspace", 599 + "columnsFrom": [ 600 + "workspace_id" 601 + ], 602 + "columnsTo": [ 603 + "id" 604 + ], 605 + "onDelete": "no action", 606 + "onUpdate": "no action" 607 + } 608 + }, 609 + "compositePrimaryKeys": {}, 610 + "uniqueConstraints": {} 611 + }, 612 + "monitors_to_pages": { 613 + "name": "monitors_to_pages", 614 + "columns": { 615 + "monitor_id": { 616 + "name": "monitor_id", 617 + "type": "integer", 618 + "primaryKey": false, 619 + "notNull": true, 620 + "autoincrement": false 621 + }, 622 + "page_id": { 623 + "name": "page_id", 624 + "type": "integer", 625 + "primaryKey": false, 626 + "notNull": true, 627 + "autoincrement": false 628 + }, 629 + "created_at": { 630 + "name": "created_at", 631 + "type": "integer", 632 + "primaryKey": false, 633 + "notNull": false, 634 + "autoincrement": false, 635 + "default": "(strftime('%s', 'now'))" 636 + }, 637 + "order": { 638 + "name": "order", 639 + "type": "integer", 640 + "primaryKey": false, 641 + "notNull": false, 642 + "autoincrement": false, 643 + "default": 0 644 + } 645 + }, 646 + "indexes": {}, 647 + "foreignKeys": { 648 + "monitors_to_pages_monitor_id_monitor_id_fk": { 649 + "name": "monitors_to_pages_monitor_id_monitor_id_fk", 650 + "tableFrom": "monitors_to_pages", 651 + "tableTo": "monitor", 652 + "columnsFrom": [ 653 + "monitor_id" 654 + ], 655 + "columnsTo": [ 656 + "id" 657 + ], 658 + "onDelete": "cascade", 659 + "onUpdate": "no action" 660 + }, 661 + "monitors_to_pages_page_id_page_id_fk": { 662 + "name": "monitors_to_pages_page_id_page_id_fk", 663 + "tableFrom": "monitors_to_pages", 664 + "tableTo": "page", 665 + "columnsFrom": [ 666 + "page_id" 667 + ], 668 + "columnsTo": [ 669 + "id" 670 + ], 671 + "onDelete": "cascade", 672 + "onUpdate": "no action" 673 + } 674 + }, 675 + "compositePrimaryKeys": { 676 + "monitors_to_pages_monitor_id_page_id_pk": { 677 + "columns": [ 678 + "monitor_id", 679 + "page_id" 680 + ], 681 + "name": "monitors_to_pages_monitor_id_page_id_pk" 682 + } 683 + }, 684 + "uniqueConstraints": {} 685 + }, 686 + "workspace": { 687 + "name": "workspace", 688 + "columns": { 689 + "id": { 690 + "name": "id", 691 + "type": "integer", 692 + "primaryKey": true, 693 + "notNull": true, 694 + "autoincrement": false 695 + }, 696 + "slug": { 697 + "name": "slug", 698 + "type": "text", 699 + "primaryKey": false, 700 + "notNull": true, 701 + "autoincrement": false 702 + }, 703 + "name": { 704 + "name": "name", 705 + "type": "text", 706 + "primaryKey": false, 707 + "notNull": false, 708 + "autoincrement": false 709 + }, 710 + "stripe_id": { 711 + "name": "stripe_id", 712 + "type": "text(256)", 713 + "primaryKey": false, 714 + "notNull": false, 715 + "autoincrement": false 716 + }, 717 + "subscription_id": { 718 + "name": "subscription_id", 719 + "type": "text", 720 + "primaryKey": false, 721 + "notNull": false, 722 + "autoincrement": false 723 + }, 724 + "plan": { 725 + "name": "plan", 726 + "type": "text", 727 + "primaryKey": false, 728 + "notNull": false, 729 + "autoincrement": false 730 + }, 731 + "ends_at": { 732 + "name": "ends_at", 733 + "type": "integer", 734 + "primaryKey": false, 735 + "notNull": false, 736 + "autoincrement": false 737 + }, 738 + "paid_until": { 739 + "name": "paid_until", 740 + "type": "integer", 741 + "primaryKey": false, 742 + "notNull": false, 743 + "autoincrement": false 744 + }, 745 + "limits": { 746 + "name": "limits", 747 + "type": "text", 748 + "primaryKey": false, 749 + "notNull": true, 750 + "autoincrement": false, 751 + "default": "'{}'" 752 + }, 753 + "created_at": { 754 + "name": "created_at", 755 + "type": "integer", 756 + "primaryKey": false, 757 + "notNull": false, 758 + "autoincrement": false, 759 + "default": "(strftime('%s', 'now'))" 760 + }, 761 + "updated_at": { 762 + "name": "updated_at", 763 + "type": "integer", 764 + "primaryKey": false, 765 + "notNull": false, 766 + "autoincrement": false, 767 + "default": "(strftime('%s', 'now'))" 768 + }, 769 + "dsn": { 770 + "name": "dsn", 771 + "type": "text", 772 + "primaryKey": false, 773 + "notNull": false, 774 + "autoincrement": false 775 + } 776 + }, 777 + "indexes": { 778 + "workspace_slug_unique": { 779 + "name": "workspace_slug_unique", 780 + "columns": [ 781 + "slug" 782 + ], 783 + "isUnique": true 784 + }, 785 + "workspace_stripe_id_unique": { 786 + "name": "workspace_stripe_id_unique", 787 + "columns": [ 788 + "stripe_id" 789 + ], 790 + "isUnique": true 791 + }, 792 + "workspace_id_dsn_unique": { 793 + "name": "workspace_id_dsn_unique", 794 + "columns": [ 795 + "id", 796 + "dsn" 797 + ], 798 + "isUnique": true 799 + } 800 + }, 801 + "foreignKeys": {}, 802 + "compositePrimaryKeys": {}, 803 + "uniqueConstraints": {} 804 + }, 805 + "account": { 806 + "name": "account", 807 + "columns": { 808 + "user_id": { 809 + "name": "user_id", 810 + "type": "integer", 811 + "primaryKey": false, 812 + "notNull": true, 813 + "autoincrement": false 814 + }, 815 + "type": { 816 + "name": "type", 817 + "type": "text", 818 + "primaryKey": false, 819 + "notNull": true, 820 + "autoincrement": false 821 + }, 822 + "provider": { 823 + "name": "provider", 824 + "type": "text", 825 + "primaryKey": false, 826 + "notNull": true, 827 + "autoincrement": false 828 + }, 829 + "provider_account_id": { 830 + "name": "provider_account_id", 831 + "type": "text", 832 + "primaryKey": false, 833 + "notNull": true, 834 + "autoincrement": false 835 + }, 836 + "refresh_token": { 837 + "name": "refresh_token", 838 + "type": "text", 839 + "primaryKey": false, 840 + "notNull": false, 841 + "autoincrement": false 842 + }, 843 + "access_token": { 844 + "name": "access_token", 845 + "type": "text", 846 + "primaryKey": false, 847 + "notNull": false, 848 + "autoincrement": false 849 + }, 850 + "expires_at": { 851 + "name": "expires_at", 852 + "type": "integer", 853 + "primaryKey": false, 854 + "notNull": false, 855 + "autoincrement": false 856 + }, 857 + "token_type": { 858 + "name": "token_type", 859 + "type": "text", 860 + "primaryKey": false, 861 + "notNull": false, 862 + "autoincrement": false 863 + }, 864 + "scope": { 865 + "name": "scope", 866 + "type": "text", 867 + "primaryKey": false, 868 + "notNull": false, 869 + "autoincrement": false 870 + }, 871 + "id_token": { 872 + "name": "id_token", 873 + "type": "text", 874 + "primaryKey": false, 875 + "notNull": false, 876 + "autoincrement": false 877 + }, 878 + "session_state": { 879 + "name": "session_state", 880 + "type": "text", 881 + "primaryKey": false, 882 + "notNull": false, 883 + "autoincrement": false 884 + } 885 + }, 886 + "indexes": {}, 887 + "foreignKeys": { 888 + "account_user_id_user_id_fk": { 889 + "name": "account_user_id_user_id_fk", 890 + "tableFrom": "account", 891 + "tableTo": "user", 892 + "columnsFrom": [ 893 + "user_id" 894 + ], 895 + "columnsTo": [ 896 + "id" 897 + ], 898 + "onDelete": "cascade", 899 + "onUpdate": "no action" 900 + } 901 + }, 902 + "compositePrimaryKeys": { 903 + "account_provider_provider_account_id_pk": { 904 + "columns": [ 905 + "provider", 906 + "provider_account_id" 907 + ], 908 + "name": "account_provider_provider_account_id_pk" 909 + } 910 + }, 911 + "uniqueConstraints": {} 912 + }, 913 + "session": { 914 + "name": "session", 915 + "columns": { 916 + "session_token": { 917 + "name": "session_token", 918 + "type": "text", 919 + "primaryKey": true, 920 + "notNull": true, 921 + "autoincrement": false 922 + }, 923 + "user_id": { 924 + "name": "user_id", 925 + "type": "integer", 926 + "primaryKey": false, 927 + "notNull": true, 928 + "autoincrement": false 929 + }, 930 + "expires": { 931 + "name": "expires", 932 + "type": "integer", 933 + "primaryKey": false, 934 + "notNull": true, 935 + "autoincrement": false 936 + } 937 + }, 938 + "indexes": {}, 939 + "foreignKeys": { 940 + "session_user_id_user_id_fk": { 941 + "name": "session_user_id_user_id_fk", 942 + "tableFrom": "session", 943 + "tableTo": "user", 944 + "columnsFrom": [ 945 + "user_id" 946 + ], 947 + "columnsTo": [ 948 + "id" 949 + ], 950 + "onDelete": "cascade", 951 + "onUpdate": "no action" 952 + } 953 + }, 954 + "compositePrimaryKeys": {}, 955 + "uniqueConstraints": {} 956 + }, 957 + "user": { 958 + "name": "user", 959 + "columns": { 960 + "id": { 961 + "name": "id", 962 + "type": "integer", 963 + "primaryKey": true, 964 + "notNull": true, 965 + "autoincrement": false 966 + }, 967 + "tenant_id": { 968 + "name": "tenant_id", 969 + "type": "text(256)", 970 + "primaryKey": false, 971 + "notNull": false, 972 + "autoincrement": false 973 + }, 974 + "first_name": { 975 + "name": "first_name", 976 + "type": "text", 977 + "primaryKey": false, 978 + "notNull": false, 979 + "autoincrement": false, 980 + "default": "''" 981 + }, 982 + "last_name": { 983 + "name": "last_name", 984 + "type": "text", 985 + "primaryKey": false, 986 + "notNull": false, 987 + "autoincrement": false, 988 + "default": "''" 989 + }, 990 + "photo_url": { 991 + "name": "photo_url", 992 + "type": "text", 993 + "primaryKey": false, 994 + "notNull": false, 995 + "autoincrement": false, 996 + "default": "''" 997 + }, 998 + "name": { 999 + "name": "name", 1000 + "type": "text", 1001 + "primaryKey": false, 1002 + "notNull": false, 1003 + "autoincrement": false 1004 + }, 1005 + "email": { 1006 + "name": "email", 1007 + "type": "text", 1008 + "primaryKey": false, 1009 + "notNull": false, 1010 + "autoincrement": false, 1011 + "default": "''" 1012 + }, 1013 + "emailVerified": { 1014 + "name": "emailVerified", 1015 + "type": "integer", 1016 + "primaryKey": false, 1017 + "notNull": false, 1018 + "autoincrement": false 1019 + }, 1020 + "created_at": { 1021 + "name": "created_at", 1022 + "type": "integer", 1023 + "primaryKey": false, 1024 + "notNull": false, 1025 + "autoincrement": false, 1026 + "default": "(strftime('%s', 'now'))" 1027 + }, 1028 + "updated_at": { 1029 + "name": "updated_at", 1030 + "type": "integer", 1031 + "primaryKey": false, 1032 + "notNull": false, 1033 + "autoincrement": false, 1034 + "default": "(strftime('%s', 'now'))" 1035 + } 1036 + }, 1037 + "indexes": { 1038 + "user_tenant_id_unique": { 1039 + "name": "user_tenant_id_unique", 1040 + "columns": [ 1041 + "tenant_id" 1042 + ], 1043 + "isUnique": true 1044 + } 1045 + }, 1046 + "foreignKeys": {}, 1047 + "compositePrimaryKeys": {}, 1048 + "uniqueConstraints": {} 1049 + }, 1050 + "users_to_workspaces": { 1051 + "name": "users_to_workspaces", 1052 + "columns": { 1053 + "user_id": { 1054 + "name": "user_id", 1055 + "type": "integer", 1056 + "primaryKey": false, 1057 + "notNull": true, 1058 + "autoincrement": false 1059 + }, 1060 + "workspace_id": { 1061 + "name": "workspace_id", 1062 + "type": "integer", 1063 + "primaryKey": false, 1064 + "notNull": true, 1065 + "autoincrement": false 1066 + }, 1067 + "role": { 1068 + "name": "role", 1069 + "type": "text", 1070 + "primaryKey": false, 1071 + "notNull": true, 1072 + "autoincrement": false, 1073 + "default": "'member'" 1074 + }, 1075 + "created_at": { 1076 + "name": "created_at", 1077 + "type": "integer", 1078 + "primaryKey": false, 1079 + "notNull": false, 1080 + "autoincrement": false, 1081 + "default": "(strftime('%s', 'now'))" 1082 + } 1083 + }, 1084 + "indexes": {}, 1085 + "foreignKeys": { 1086 + "users_to_workspaces_user_id_user_id_fk": { 1087 + "name": "users_to_workspaces_user_id_user_id_fk", 1088 + "tableFrom": "users_to_workspaces", 1089 + "tableTo": "user", 1090 + "columnsFrom": [ 1091 + "user_id" 1092 + ], 1093 + "columnsTo": [ 1094 + "id" 1095 + ], 1096 + "onDelete": "no action", 1097 + "onUpdate": "no action" 1098 + }, 1099 + "users_to_workspaces_workspace_id_workspace_id_fk": { 1100 + "name": "users_to_workspaces_workspace_id_workspace_id_fk", 1101 + "tableFrom": "users_to_workspaces", 1102 + "tableTo": "workspace", 1103 + "columnsFrom": [ 1104 + "workspace_id" 1105 + ], 1106 + "columnsTo": [ 1107 + "id" 1108 + ], 1109 + "onDelete": "no action", 1110 + "onUpdate": "no action" 1111 + } 1112 + }, 1113 + "compositePrimaryKeys": { 1114 + "users_to_workspaces_user_id_workspace_id_pk": { 1115 + "columns": [ 1116 + "user_id", 1117 + "workspace_id" 1118 + ], 1119 + "name": "users_to_workspaces_user_id_workspace_id_pk" 1120 + } 1121 + }, 1122 + "uniqueConstraints": {} 1123 + }, 1124 + "verification_token": { 1125 + "name": "verification_token", 1126 + "columns": { 1127 + "identifier": { 1128 + "name": "identifier", 1129 + "type": "text", 1130 + "primaryKey": false, 1131 + "notNull": true, 1132 + "autoincrement": false 1133 + }, 1134 + "token": { 1135 + "name": "token", 1136 + "type": "text", 1137 + "primaryKey": false, 1138 + "notNull": true, 1139 + "autoincrement": false 1140 + }, 1141 + "expires": { 1142 + "name": "expires", 1143 + "type": "integer", 1144 + "primaryKey": false, 1145 + "notNull": true, 1146 + "autoincrement": false 1147 + } 1148 + }, 1149 + "indexes": {}, 1150 + "foreignKeys": {}, 1151 + "compositePrimaryKeys": { 1152 + "verification_token_identifier_token_pk": { 1153 + "columns": [ 1154 + "identifier", 1155 + "token" 1156 + ], 1157 + "name": "verification_token_identifier_token_pk" 1158 + } 1159 + }, 1160 + "uniqueConstraints": {} 1161 + }, 1162 + "page_subscriber": { 1163 + "name": "page_subscriber", 1164 + "columns": { 1165 + "id": { 1166 + "name": "id", 1167 + "type": "integer", 1168 + "primaryKey": true, 1169 + "notNull": true, 1170 + "autoincrement": false 1171 + }, 1172 + "email": { 1173 + "name": "email", 1174 + "type": "text", 1175 + "primaryKey": false, 1176 + "notNull": true, 1177 + "autoincrement": false 1178 + }, 1179 + "page_id": { 1180 + "name": "page_id", 1181 + "type": "integer", 1182 + "primaryKey": false, 1183 + "notNull": true, 1184 + "autoincrement": false 1185 + }, 1186 + "token": { 1187 + "name": "token", 1188 + "type": "text", 1189 + "primaryKey": false, 1190 + "notNull": false, 1191 + "autoincrement": false 1192 + }, 1193 + "accepted_at": { 1194 + "name": "accepted_at", 1195 + "type": "integer", 1196 + "primaryKey": false, 1197 + "notNull": false, 1198 + "autoincrement": false 1199 + }, 1200 + "expires_at": { 1201 + "name": "expires_at", 1202 + "type": "integer", 1203 + "primaryKey": false, 1204 + "notNull": false, 1205 + "autoincrement": false 1206 + }, 1207 + "created_at": { 1208 + "name": "created_at", 1209 + "type": "integer", 1210 + "primaryKey": false, 1211 + "notNull": false, 1212 + "autoincrement": false, 1213 + "default": "(strftime('%s', 'now'))" 1214 + }, 1215 + "updated_at": { 1216 + "name": "updated_at", 1217 + "type": "integer", 1218 + "primaryKey": false, 1219 + "notNull": false, 1220 + "autoincrement": false, 1221 + "default": "(strftime('%s', 'now'))" 1222 + } 1223 + }, 1224 + "indexes": {}, 1225 + "foreignKeys": { 1226 + "page_subscriber_page_id_page_id_fk": { 1227 + "name": "page_subscriber_page_id_page_id_fk", 1228 + "tableFrom": "page_subscriber", 1229 + "tableTo": "page", 1230 + "columnsFrom": [ 1231 + "page_id" 1232 + ], 1233 + "columnsTo": [ 1234 + "id" 1235 + ], 1236 + "onDelete": "no action", 1237 + "onUpdate": "no action" 1238 + } 1239 + }, 1240 + "compositePrimaryKeys": {}, 1241 + "uniqueConstraints": {} 1242 + }, 1243 + "notification": { 1244 + "name": "notification", 1245 + "columns": { 1246 + "id": { 1247 + "name": "id", 1248 + "type": "integer", 1249 + "primaryKey": true, 1250 + "notNull": true, 1251 + "autoincrement": false 1252 + }, 1253 + "name": { 1254 + "name": "name", 1255 + "type": "text", 1256 + "primaryKey": false, 1257 + "notNull": true, 1258 + "autoincrement": false 1259 + }, 1260 + "provider": { 1261 + "name": "provider", 1262 + "type": "text", 1263 + "primaryKey": false, 1264 + "notNull": true, 1265 + "autoincrement": false 1266 + }, 1267 + "data": { 1268 + "name": "data", 1269 + "type": "text", 1270 + "primaryKey": false, 1271 + "notNull": false, 1272 + "autoincrement": false, 1273 + "default": "'{}'" 1274 + }, 1275 + "workspace_id": { 1276 + "name": "workspace_id", 1277 + "type": "integer", 1278 + "primaryKey": false, 1279 + "notNull": false, 1280 + "autoincrement": false 1281 + }, 1282 + "created_at": { 1283 + "name": "created_at", 1284 + "type": "integer", 1285 + "primaryKey": false, 1286 + "notNull": false, 1287 + "autoincrement": false, 1288 + "default": "(strftime('%s', 'now'))" 1289 + }, 1290 + "updated_at": { 1291 + "name": "updated_at", 1292 + "type": "integer", 1293 + "primaryKey": false, 1294 + "notNull": false, 1295 + "autoincrement": false, 1296 + "default": "(strftime('%s', 'now'))" 1297 + } 1298 + }, 1299 + "indexes": {}, 1300 + "foreignKeys": { 1301 + "notification_workspace_id_workspace_id_fk": { 1302 + "name": "notification_workspace_id_workspace_id_fk", 1303 + "tableFrom": "notification", 1304 + "tableTo": "workspace", 1305 + "columnsFrom": [ 1306 + "workspace_id" 1307 + ], 1308 + "columnsTo": [ 1309 + "id" 1310 + ], 1311 + "onDelete": "no action", 1312 + "onUpdate": "no action" 1313 + } 1314 + }, 1315 + "compositePrimaryKeys": {}, 1316 + "uniqueConstraints": {} 1317 + }, 1318 + "notifications_to_monitors": { 1319 + "name": "notifications_to_monitors", 1320 + "columns": { 1321 + "monitor_id": { 1322 + "name": "monitor_id", 1323 + "type": "integer", 1324 + "primaryKey": false, 1325 + "notNull": true, 1326 + "autoincrement": false 1327 + }, 1328 + "notification_id": { 1329 + "name": "notification_id", 1330 + "type": "integer", 1331 + "primaryKey": false, 1332 + "notNull": true, 1333 + "autoincrement": false 1334 + }, 1335 + "created_at": { 1336 + "name": "created_at", 1337 + "type": "integer", 1338 + "primaryKey": false, 1339 + "notNull": false, 1340 + "autoincrement": false, 1341 + "default": "(strftime('%s', 'now'))" 1342 + } 1343 + }, 1344 + "indexes": {}, 1345 + "foreignKeys": { 1346 + "notifications_to_monitors_monitor_id_monitor_id_fk": { 1347 + "name": "notifications_to_monitors_monitor_id_monitor_id_fk", 1348 + "tableFrom": "notifications_to_monitors", 1349 + "tableTo": "monitor", 1350 + "columnsFrom": [ 1351 + "monitor_id" 1352 + ], 1353 + "columnsTo": [ 1354 + "id" 1355 + ], 1356 + "onDelete": "cascade", 1357 + "onUpdate": "no action" 1358 + }, 1359 + "notifications_to_monitors_notification_id_notification_id_fk": { 1360 + "name": "notifications_to_monitors_notification_id_notification_id_fk", 1361 + "tableFrom": "notifications_to_monitors", 1362 + "tableTo": "notification", 1363 + "columnsFrom": [ 1364 + "notification_id" 1365 + ], 1366 + "columnsTo": [ 1367 + "id" 1368 + ], 1369 + "onDelete": "cascade", 1370 + "onUpdate": "no action" 1371 + } 1372 + }, 1373 + "compositePrimaryKeys": { 1374 + "notifications_to_monitors_monitor_id_notification_id_pk": { 1375 + "columns": [ 1376 + "monitor_id", 1377 + "notification_id" 1378 + ], 1379 + "name": "notifications_to_monitors_monitor_id_notification_id_pk" 1380 + } 1381 + }, 1382 + "uniqueConstraints": {} 1383 + }, 1384 + "monitor_status": { 1385 + "name": "monitor_status", 1386 + "columns": { 1387 + "monitor_id": { 1388 + "name": "monitor_id", 1389 + "type": "integer", 1390 + "primaryKey": false, 1391 + "notNull": true, 1392 + "autoincrement": false 1393 + }, 1394 + "region": { 1395 + "name": "region", 1396 + "type": "text", 1397 + "primaryKey": false, 1398 + "notNull": true, 1399 + "autoincrement": false, 1400 + "default": "''" 1401 + }, 1402 + "status": { 1403 + "name": "status", 1404 + "type": "text", 1405 + "primaryKey": false, 1406 + "notNull": true, 1407 + "autoincrement": false, 1408 + "default": "'active'" 1409 + }, 1410 + "created_at": { 1411 + "name": "created_at", 1412 + "type": "integer", 1413 + "primaryKey": false, 1414 + "notNull": false, 1415 + "autoincrement": false, 1416 + "default": "(strftime('%s', 'now'))" 1417 + }, 1418 + "updated_at": { 1419 + "name": "updated_at", 1420 + "type": "integer", 1421 + "primaryKey": false, 1422 + "notNull": false, 1423 + "autoincrement": false, 1424 + "default": "(strftime('%s', 'now'))" 1425 + } 1426 + }, 1427 + "indexes": { 1428 + "monitor_status_idx": { 1429 + "name": "monitor_status_idx", 1430 + "columns": [ 1431 + "monitor_id", 1432 + "region" 1433 + ], 1434 + "isUnique": false 1435 + } 1436 + }, 1437 + "foreignKeys": { 1438 + "monitor_status_monitor_id_monitor_id_fk": { 1439 + "name": "monitor_status_monitor_id_monitor_id_fk", 1440 + "tableFrom": "monitor_status", 1441 + "tableTo": "monitor", 1442 + "columnsFrom": [ 1443 + "monitor_id" 1444 + ], 1445 + "columnsTo": [ 1446 + "id" 1447 + ], 1448 + "onDelete": "cascade", 1449 + "onUpdate": "no action" 1450 + } 1451 + }, 1452 + "compositePrimaryKeys": { 1453 + "monitor_status_monitor_id_region_pk": { 1454 + "columns": [ 1455 + "monitor_id", 1456 + "region" 1457 + ], 1458 + "name": "monitor_status_monitor_id_region_pk" 1459 + } 1460 + }, 1461 + "uniqueConstraints": {} 1462 + }, 1463 + "invitation": { 1464 + "name": "invitation", 1465 + "columns": { 1466 + "id": { 1467 + "name": "id", 1468 + "type": "integer", 1469 + "primaryKey": true, 1470 + "notNull": true, 1471 + "autoincrement": false 1472 + }, 1473 + "email": { 1474 + "name": "email", 1475 + "type": "text", 1476 + "primaryKey": false, 1477 + "notNull": true, 1478 + "autoincrement": false 1479 + }, 1480 + "role": { 1481 + "name": "role", 1482 + "type": "text", 1483 + "primaryKey": false, 1484 + "notNull": true, 1485 + "autoincrement": false, 1486 + "default": "'member'" 1487 + }, 1488 + "workspace_id": { 1489 + "name": "workspace_id", 1490 + "type": "integer", 1491 + "primaryKey": false, 1492 + "notNull": true, 1493 + "autoincrement": false 1494 + }, 1495 + "token": { 1496 + "name": "token", 1497 + "type": "text", 1498 + "primaryKey": false, 1499 + "notNull": true, 1500 + "autoincrement": false 1501 + }, 1502 + "expires_at": { 1503 + "name": "expires_at", 1504 + "type": "integer", 1505 + "primaryKey": false, 1506 + "notNull": true, 1507 + "autoincrement": false 1508 + }, 1509 + "created_at": { 1510 + "name": "created_at", 1511 + "type": "integer", 1512 + "primaryKey": false, 1513 + "notNull": false, 1514 + "autoincrement": false, 1515 + "default": "(strftime('%s', 'now'))" 1516 + }, 1517 + "accepted_at": { 1518 + "name": "accepted_at", 1519 + "type": "integer", 1520 + "primaryKey": false, 1521 + "notNull": false, 1522 + "autoincrement": false 1523 + } 1524 + }, 1525 + "indexes": {}, 1526 + "foreignKeys": {}, 1527 + "compositePrimaryKeys": {}, 1528 + "uniqueConstraints": {} 1529 + }, 1530 + "incident": { 1531 + "name": "incident", 1532 + "columns": { 1533 + "id": { 1534 + "name": "id", 1535 + "type": "integer", 1536 + "primaryKey": true, 1537 + "notNull": true, 1538 + "autoincrement": false 1539 + }, 1540 + "title": { 1541 + "name": "title", 1542 + "type": "text", 1543 + "primaryKey": false, 1544 + "notNull": true, 1545 + "autoincrement": false, 1546 + "default": "''" 1547 + }, 1548 + "summary": { 1549 + "name": "summary", 1550 + "type": "text", 1551 + "primaryKey": false, 1552 + "notNull": true, 1553 + "autoincrement": false, 1554 + "default": "''" 1555 + }, 1556 + "status": { 1557 + "name": "status", 1558 + "type": "text", 1559 + "primaryKey": false, 1560 + "notNull": true, 1561 + "autoincrement": false, 1562 + "default": "'triage'" 1563 + }, 1564 + "monitor_id": { 1565 + "name": "monitor_id", 1566 + "type": "integer", 1567 + "primaryKey": false, 1568 + "notNull": false, 1569 + "autoincrement": false 1570 + }, 1571 + "workspace_id": { 1572 + "name": "workspace_id", 1573 + "type": "integer", 1574 + "primaryKey": false, 1575 + "notNull": false, 1576 + "autoincrement": false 1577 + }, 1578 + "started_at": { 1579 + "name": "started_at", 1580 + "type": "integer", 1581 + "primaryKey": false, 1582 + "notNull": true, 1583 + "autoincrement": false, 1584 + "default": "(strftime('%s', 'now'))" 1585 + }, 1586 + "acknowledged_at": { 1587 + "name": "acknowledged_at", 1588 + "type": "integer", 1589 + "primaryKey": false, 1590 + "notNull": false, 1591 + "autoincrement": false 1592 + }, 1593 + "acknowledged_by": { 1594 + "name": "acknowledged_by", 1595 + "type": "integer", 1596 + "primaryKey": false, 1597 + "notNull": false, 1598 + "autoincrement": false 1599 + }, 1600 + "resolved_at": { 1601 + "name": "resolved_at", 1602 + "type": "integer", 1603 + "primaryKey": false, 1604 + "notNull": false, 1605 + "autoincrement": false 1606 + }, 1607 + "resolved_by": { 1608 + "name": "resolved_by", 1609 + "type": "integer", 1610 + "primaryKey": false, 1611 + "notNull": false, 1612 + "autoincrement": false 1613 + }, 1614 + "incident_screenshot_url": { 1615 + "name": "incident_screenshot_url", 1616 + "type": "text", 1617 + "primaryKey": false, 1618 + "notNull": false, 1619 + "autoincrement": false 1620 + }, 1621 + "recovery_screenshot_url": { 1622 + "name": "recovery_screenshot_url", 1623 + "type": "text", 1624 + "primaryKey": false, 1625 + "notNull": false, 1626 + "autoincrement": false 1627 + }, 1628 + "auto_resolved": { 1629 + "name": "auto_resolved", 1630 + "type": "integer", 1631 + "primaryKey": false, 1632 + "notNull": false, 1633 + "autoincrement": false, 1634 + "default": false 1635 + }, 1636 + "created_at": { 1637 + "name": "created_at", 1638 + "type": "integer", 1639 + "primaryKey": false, 1640 + "notNull": false, 1641 + "autoincrement": false, 1642 + "default": "(strftime('%s', 'now'))" 1643 + }, 1644 + "updated_at": { 1645 + "name": "updated_at", 1646 + "type": "integer", 1647 + "primaryKey": false, 1648 + "notNull": false, 1649 + "autoincrement": false, 1650 + "default": "(strftime('%s', 'now'))" 1651 + } 1652 + }, 1653 + "indexes": { 1654 + "incident_monitor_id_started_at_unique": { 1655 + "name": "incident_monitor_id_started_at_unique", 1656 + "columns": [ 1657 + "monitor_id", 1658 + "started_at" 1659 + ], 1660 + "isUnique": true 1661 + } 1662 + }, 1663 + "foreignKeys": { 1664 + "incident_monitor_id_monitor_id_fk": { 1665 + "name": "incident_monitor_id_monitor_id_fk", 1666 + "tableFrom": "incident", 1667 + "tableTo": "monitor", 1668 + "columnsFrom": [ 1669 + "monitor_id" 1670 + ], 1671 + "columnsTo": [ 1672 + "id" 1673 + ], 1674 + "onDelete": "set default", 1675 + "onUpdate": "no action" 1676 + }, 1677 + "incident_workspace_id_workspace_id_fk": { 1678 + "name": "incident_workspace_id_workspace_id_fk", 1679 + "tableFrom": "incident", 1680 + "tableTo": "workspace", 1681 + "columnsFrom": [ 1682 + "workspace_id" 1683 + ], 1684 + "columnsTo": [ 1685 + "id" 1686 + ], 1687 + "onDelete": "no action", 1688 + "onUpdate": "no action" 1689 + }, 1690 + "incident_acknowledged_by_user_id_fk": { 1691 + "name": "incident_acknowledged_by_user_id_fk", 1692 + "tableFrom": "incident", 1693 + "tableTo": "user", 1694 + "columnsFrom": [ 1695 + "acknowledged_by" 1696 + ], 1697 + "columnsTo": [ 1698 + "id" 1699 + ], 1700 + "onDelete": "no action", 1701 + "onUpdate": "no action" 1702 + }, 1703 + "incident_resolved_by_user_id_fk": { 1704 + "name": "incident_resolved_by_user_id_fk", 1705 + "tableFrom": "incident", 1706 + "tableTo": "user", 1707 + "columnsFrom": [ 1708 + "resolved_by" 1709 + ], 1710 + "columnsTo": [ 1711 + "id" 1712 + ], 1713 + "onDelete": "no action", 1714 + "onUpdate": "no action" 1715 + } 1716 + }, 1717 + "compositePrimaryKeys": {}, 1718 + "uniqueConstraints": {} 1719 + }, 1720 + "monitor_tag": { 1721 + "name": "monitor_tag", 1722 + "columns": { 1723 + "id": { 1724 + "name": "id", 1725 + "type": "integer", 1726 + "primaryKey": true, 1727 + "notNull": true, 1728 + "autoincrement": false 1729 + }, 1730 + "workspace_id": { 1731 + "name": "workspace_id", 1732 + "type": "integer", 1733 + "primaryKey": false, 1734 + "notNull": true, 1735 + "autoincrement": false 1736 + }, 1737 + "name": { 1738 + "name": "name", 1739 + "type": "text", 1740 + "primaryKey": false, 1741 + "notNull": true, 1742 + "autoincrement": false 1743 + }, 1744 + "color": { 1745 + "name": "color", 1746 + "type": "text", 1747 + "primaryKey": false, 1748 + "notNull": true, 1749 + "autoincrement": false 1750 + }, 1751 + "created_at": { 1752 + "name": "created_at", 1753 + "type": "integer", 1754 + "primaryKey": false, 1755 + "notNull": false, 1756 + "autoincrement": false, 1757 + "default": "(strftime('%s', 'now'))" 1758 + }, 1759 + "updated_at": { 1760 + "name": "updated_at", 1761 + "type": "integer", 1762 + "primaryKey": false, 1763 + "notNull": false, 1764 + "autoincrement": false, 1765 + "default": "(strftime('%s', 'now'))" 1766 + } 1767 + }, 1768 + "indexes": {}, 1769 + "foreignKeys": { 1770 + "monitor_tag_workspace_id_workspace_id_fk": { 1771 + "name": "monitor_tag_workspace_id_workspace_id_fk", 1772 + "tableFrom": "monitor_tag", 1773 + "tableTo": "workspace", 1774 + "columnsFrom": [ 1775 + "workspace_id" 1776 + ], 1777 + "columnsTo": [ 1778 + "id" 1779 + ], 1780 + "onDelete": "cascade", 1781 + "onUpdate": "no action" 1782 + } 1783 + }, 1784 + "compositePrimaryKeys": {}, 1785 + "uniqueConstraints": {} 1786 + }, 1787 + "monitor_tag_to_monitor": { 1788 + "name": "monitor_tag_to_monitor", 1789 + "columns": { 1790 + "monitor_id": { 1791 + "name": "monitor_id", 1792 + "type": "integer", 1793 + "primaryKey": false, 1794 + "notNull": true, 1795 + "autoincrement": false 1796 + }, 1797 + "monitor_tag_id": { 1798 + "name": "monitor_tag_id", 1799 + "type": "integer", 1800 + "primaryKey": false, 1801 + "notNull": true, 1802 + "autoincrement": false 1803 + }, 1804 + "created_at": { 1805 + "name": "created_at", 1806 + "type": "integer", 1807 + "primaryKey": false, 1808 + "notNull": false, 1809 + "autoincrement": false, 1810 + "default": "(strftime('%s', 'now'))" 1811 + } 1812 + }, 1813 + "indexes": {}, 1814 + "foreignKeys": { 1815 + "monitor_tag_to_monitor_monitor_id_monitor_id_fk": { 1816 + "name": "monitor_tag_to_monitor_monitor_id_monitor_id_fk", 1817 + "tableFrom": "monitor_tag_to_monitor", 1818 + "tableTo": "monitor", 1819 + "columnsFrom": [ 1820 + "monitor_id" 1821 + ], 1822 + "columnsTo": [ 1823 + "id" 1824 + ], 1825 + "onDelete": "cascade", 1826 + "onUpdate": "no action" 1827 + }, 1828 + "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk": { 1829 + "name": "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk", 1830 + "tableFrom": "monitor_tag_to_monitor", 1831 + "tableTo": "monitor_tag", 1832 + "columnsFrom": [ 1833 + "monitor_tag_id" 1834 + ], 1835 + "columnsTo": [ 1836 + "id" 1837 + ], 1838 + "onDelete": "cascade", 1839 + "onUpdate": "no action" 1840 + } 1841 + }, 1842 + "compositePrimaryKeys": { 1843 + "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk": { 1844 + "columns": [ 1845 + "monitor_id", 1846 + "monitor_tag_id" 1847 + ], 1848 + "name": "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk" 1849 + } 1850 + }, 1851 + "uniqueConstraints": {} 1852 + }, 1853 + "application": { 1854 + "name": "application", 1855 + "columns": { 1856 + "id": { 1857 + "name": "id", 1858 + "type": "integer", 1859 + "primaryKey": true, 1860 + "notNull": true, 1861 + "autoincrement": false 1862 + }, 1863 + "name": { 1864 + "name": "name", 1865 + "type": "text", 1866 + "primaryKey": false, 1867 + "notNull": false, 1868 + "autoincrement": false 1869 + }, 1870 + "dsn": { 1871 + "name": "dsn", 1872 + "type": "text", 1873 + "primaryKey": false, 1874 + "notNull": false, 1875 + "autoincrement": false 1876 + }, 1877 + "workspace_id": { 1878 + "name": "workspace_id", 1879 + "type": "integer", 1880 + "primaryKey": false, 1881 + "notNull": false, 1882 + "autoincrement": false 1883 + }, 1884 + "created_at": { 1885 + "name": "created_at", 1886 + "type": "integer", 1887 + "primaryKey": false, 1888 + "notNull": false, 1889 + "autoincrement": false, 1890 + "default": "(strftime('%s', 'now'))" 1891 + }, 1892 + "updated_at": { 1893 + "name": "updated_at", 1894 + "type": "integer", 1895 + "primaryKey": false, 1896 + "notNull": false, 1897 + "autoincrement": false, 1898 + "default": "(strftime('%s', 'now'))" 1899 + } 1900 + }, 1901 + "indexes": { 1902 + "application_dsn_unique": { 1903 + "name": "application_dsn_unique", 1904 + "columns": [ 1905 + "dsn" 1906 + ], 1907 + "isUnique": true 1908 + } 1909 + }, 1910 + "foreignKeys": { 1911 + "application_workspace_id_workspace_id_fk": { 1912 + "name": "application_workspace_id_workspace_id_fk", 1913 + "tableFrom": "application", 1914 + "tableTo": "workspace", 1915 + "columnsFrom": [ 1916 + "workspace_id" 1917 + ], 1918 + "columnsTo": [ 1919 + "id" 1920 + ], 1921 + "onDelete": "no action", 1922 + "onUpdate": "no action" 1923 + } 1924 + }, 1925 + "compositePrimaryKeys": {}, 1926 + "uniqueConstraints": {} 1927 + }, 1928 + "maintenance": { 1929 + "name": "maintenance", 1930 + "columns": { 1931 + "id": { 1932 + "name": "id", 1933 + "type": "integer", 1934 + "primaryKey": true, 1935 + "notNull": true, 1936 + "autoincrement": false 1937 + }, 1938 + "title": { 1939 + "name": "title", 1940 + "type": "text(256)", 1941 + "primaryKey": false, 1942 + "notNull": true, 1943 + "autoincrement": false 1944 + }, 1945 + "message": { 1946 + "name": "message", 1947 + "type": "text", 1948 + "primaryKey": false, 1949 + "notNull": true, 1950 + "autoincrement": false 1951 + }, 1952 + "from": { 1953 + "name": "from", 1954 + "type": "integer", 1955 + "primaryKey": false, 1956 + "notNull": true, 1957 + "autoincrement": false 1958 + }, 1959 + "to": { 1960 + "name": "to", 1961 + "type": "integer", 1962 + "primaryKey": false, 1963 + "notNull": true, 1964 + "autoincrement": false 1965 + }, 1966 + "workspace_id": { 1967 + "name": "workspace_id", 1968 + "type": "integer", 1969 + "primaryKey": false, 1970 + "notNull": false, 1971 + "autoincrement": false 1972 + }, 1973 + "page_id": { 1974 + "name": "page_id", 1975 + "type": "integer", 1976 + "primaryKey": false, 1977 + "notNull": false, 1978 + "autoincrement": false 1979 + }, 1980 + "created_at": { 1981 + "name": "created_at", 1982 + "type": "integer", 1983 + "primaryKey": false, 1984 + "notNull": false, 1985 + "autoincrement": false, 1986 + "default": "(strftime('%s', 'now'))" 1987 + }, 1988 + "updated_at": { 1989 + "name": "updated_at", 1990 + "type": "integer", 1991 + "primaryKey": false, 1992 + "notNull": false, 1993 + "autoincrement": false, 1994 + "default": "(strftime('%s', 'now'))" 1995 + } 1996 + }, 1997 + "indexes": {}, 1998 + "foreignKeys": { 1999 + "maintenance_workspace_id_workspace_id_fk": { 2000 + "name": "maintenance_workspace_id_workspace_id_fk", 2001 + "tableFrom": "maintenance", 2002 + "tableTo": "workspace", 2003 + "columnsFrom": [ 2004 + "workspace_id" 2005 + ], 2006 + "columnsTo": [ 2007 + "id" 2008 + ], 2009 + "onDelete": "no action", 2010 + "onUpdate": "no action" 2011 + }, 2012 + "maintenance_page_id_page_id_fk": { 2013 + "name": "maintenance_page_id_page_id_fk", 2014 + "tableFrom": "maintenance", 2015 + "tableTo": "page", 2016 + "columnsFrom": [ 2017 + "page_id" 2018 + ], 2019 + "columnsTo": [ 2020 + "id" 2021 + ], 2022 + "onDelete": "no action", 2023 + "onUpdate": "no action" 2024 + } 2025 + }, 2026 + "compositePrimaryKeys": {}, 2027 + "uniqueConstraints": {} 2028 + }, 2029 + "maintenance_to_monitor": { 2030 + "name": "maintenance_to_monitor", 2031 + "columns": { 2032 + "monitor_id": { 2033 + "name": "monitor_id", 2034 + "type": "integer", 2035 + "primaryKey": false, 2036 + "notNull": true, 2037 + "autoincrement": false 2038 + }, 2039 + "maintenance_id": { 2040 + "name": "maintenance_id", 2041 + "type": "integer", 2042 + "primaryKey": false, 2043 + "notNull": true, 2044 + "autoincrement": false 2045 + }, 2046 + "created_at": { 2047 + "name": "created_at", 2048 + "type": "integer", 2049 + "primaryKey": false, 2050 + "notNull": false, 2051 + "autoincrement": false, 2052 + "default": "(strftime('%s', 'now'))" 2053 + } 2054 + }, 2055 + "indexes": {}, 2056 + "foreignKeys": { 2057 + "maintenance_to_monitor_monitor_id_monitor_id_fk": { 2058 + "name": "maintenance_to_monitor_monitor_id_monitor_id_fk", 2059 + "tableFrom": "maintenance_to_monitor", 2060 + "tableTo": "monitor", 2061 + "columnsFrom": [ 2062 + "monitor_id" 2063 + ], 2064 + "columnsTo": [ 2065 + "id" 2066 + ], 2067 + "onDelete": "cascade", 2068 + "onUpdate": "no action" 2069 + }, 2070 + "maintenance_to_monitor_maintenance_id_maintenance_id_fk": { 2071 + "name": "maintenance_to_monitor_maintenance_id_maintenance_id_fk", 2072 + "tableFrom": "maintenance_to_monitor", 2073 + "tableTo": "maintenance", 2074 + "columnsFrom": [ 2075 + "maintenance_id" 2076 + ], 2077 + "columnsTo": [ 2078 + "id" 2079 + ], 2080 + "onDelete": "cascade", 2081 + "onUpdate": "no action" 2082 + } 2083 + }, 2084 + "compositePrimaryKeys": { 2085 + "maintenance_to_monitor_monitor_id_maintenance_id_pk": { 2086 + "columns": [ 2087 + "maintenance_id", 2088 + "monitor_id" 2089 + ], 2090 + "name": "maintenance_to_monitor_monitor_id_maintenance_id_pk" 2091 + } 2092 + }, 2093 + "uniqueConstraints": {} 2094 + }, 2095 + "check": { 2096 + "name": "check", 2097 + "columns": { 2098 + "id": { 2099 + "name": "id", 2100 + "type": "integer", 2101 + "primaryKey": true, 2102 + "notNull": true, 2103 + "autoincrement": true 2104 + }, 2105 + "regions": { 2106 + "name": "regions", 2107 + "type": "text", 2108 + "primaryKey": false, 2109 + "notNull": true, 2110 + "autoincrement": false, 2111 + "default": "''" 2112 + }, 2113 + "url": { 2114 + "name": "url", 2115 + "type": "text(4096)", 2116 + "primaryKey": false, 2117 + "notNull": true, 2118 + "autoincrement": false 2119 + }, 2120 + "headers": { 2121 + "name": "headers", 2122 + "type": "text", 2123 + "primaryKey": false, 2124 + "notNull": false, 2125 + "autoincrement": false, 2126 + "default": "''" 2127 + }, 2128 + "body": { 2129 + "name": "body", 2130 + "type": "text", 2131 + "primaryKey": false, 2132 + "notNull": false, 2133 + "autoincrement": false, 2134 + "default": "''" 2135 + }, 2136 + "method": { 2137 + "name": "method", 2138 + "type": "text", 2139 + "primaryKey": false, 2140 + "notNull": false, 2141 + "autoincrement": false, 2142 + "default": "'GET'" 2143 + }, 2144 + "count_requests": { 2145 + "name": "count_requests", 2146 + "type": "integer", 2147 + "primaryKey": false, 2148 + "notNull": false, 2149 + "autoincrement": false, 2150 + "default": 1 2151 + }, 2152 + "workspace_id": { 2153 + "name": "workspace_id", 2154 + "type": "integer", 2155 + "primaryKey": false, 2156 + "notNull": false, 2157 + "autoincrement": false 2158 + }, 2159 + "created_at": { 2160 + "name": "created_at", 2161 + "type": "integer", 2162 + "primaryKey": false, 2163 + "notNull": false, 2164 + "autoincrement": false, 2165 + "default": "(strftime('%s', 'now'))" 2166 + } 2167 + }, 2168 + "indexes": {}, 2169 + "foreignKeys": { 2170 + "check_workspace_id_workspace_id_fk": { 2171 + "name": "check_workspace_id_workspace_id_fk", 2172 + "tableFrom": "check", 2173 + "tableTo": "workspace", 2174 + "columnsFrom": [ 2175 + "workspace_id" 2176 + ], 2177 + "columnsTo": [ 2178 + "id" 2179 + ], 2180 + "onDelete": "no action", 2181 + "onUpdate": "no action" 2182 + } 2183 + }, 2184 + "compositePrimaryKeys": {}, 2185 + "uniqueConstraints": {} 2186 + } 2187 + }, 2188 + "enums": {}, 2189 + "_meta": { 2190 + "schemas": {}, 2191 + "tables": {}, 2192 + "columns": {} 2193 + }, 2194 + "internal": { 2195 + "indexes": {} 2196 + } 2197 + }
+7
packages/db/drizzle/meta/_journal.json
··· 253 253 "when": 1721159796428, 254 254 "tag": "0035_open_the_professor", 255 255 "breakpoints": true 256 + }, 257 + { 258 + "idx": 36, 259 + "version": "6", 260 + "when": 1723459608109, 261 + "tag": "0036_gifted_deathbird", 262 + "breakpoints": true 256 263 } 257 264 ] 258 265 }
+10 -1
packages/db/src/schema/monitors/constants.ts
··· 1 1 export const monitorMethods = ["GET", "POST", "HEAD"] as const; 2 2 export const monitorStatus = ["active", "error", "degraded"] as const; 3 3 4 - export const monitorJobTypes = ["website", "cron", "other"] as const; 4 + export const monitorJobTypes = [ 5 + "http", 6 + "tcp", 7 + "imcp", 8 + "udp", 9 + "dns", 10 + "ssl", 11 + // FIXME: remove this after the migration 12 + "other", 13 + ] as const;
+2 -2
packages/db/src/schema/monitors/monitor.ts
··· 18 18 export const monitor = sqliteTable("monitor", { 19 19 id: integer("id").primaryKey(), 20 20 jobType: text("job_type", { enum: monitorJobTypes }) 21 - .default("other") 21 + .default("http") 22 22 .notNull(), 23 23 periodicity: text("periodicity", { enum: monitorPeriodicity }) 24 24 .default("other") ··· 28 28 29 29 regions: text("regions").default("").notNull(), 30 30 31 - url: text("url", { length: 2048 }).notNull(), 31 + url: text("url", { length: 2048 }).notNull(), // URI 32 32 33 33 name: text("name", { length: 256 }).default("").notNull(), 34 34 description: text("description").default("").notNull(),
+1 -2
packages/db/src/schema/monitors/validation.ts
··· 43 43 export const selectMonitorSchema = createSelectSchema(monitor, { 44 44 periodicity: monitorPeriodicitySchema.default("10m"), 45 45 status: monitorStatusSchema.default("active"), 46 - jobType: monitorJobTypesSchema.default("other"), 46 + jobType: monitorJobTypesSchema.default("http"), 47 47 timeout: z.number().default(45), 48 48 regions: regionsToArraySchema.default([]), 49 49 }).extend({ ··· 59 59 export const insertMonitorSchema = createInsertSchema(monitor, { 60 60 name: z.string().min(1, "Name must be at least 1 character long"), 61 61 periodicity: monitorPeriodicitySchema.default("10m"), 62 - url: z.string().url(), // find a better way to not always start with "https://" including the `InputWithAddons` 63 62 status: monitorStatusSchema.default("active"), 64 63 regions: z.array(monitorRegionSchema).default([]).optional(), 65 64 headers: headersSchema.default([]),
+16
temp/main.go
··· 1 + package main 2 + 3 + import "fmt" 4 + 5 + type Person struct { 6 + Test int 7 + Other string 8 + } 9 + 10 + func HelloVet() { 11 + fmt.Println("Hello Vet") 12 + 13 + return 14 + 15 + fmt.Println("This line will never be executed") 16 + }