Compare commits

..
9 Commits
Author SHA1 Message Date
mehdiMjandGitHub e6576e9080 fix(alerting): Support custom slack title (#1079) 2025-09-20 20:21:46 -04:00
TwiNandGitHub cd10b31ab5 fix(condition): Properly format conditions with invalid context placeholders (#1281) 2025-09-20 19:28:27 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d1ef0b72a4 chore(deps): bump golang.org/x/sync from 0.16.0 to 0.17.0 (#1269)
Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.16.0 to 0.17.0.
- [Commits](https://github.com/golang/sync/compare/v0.16.0...v0.17.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sync
  dependency-version: 0.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-20 12:22:13 -04:00
TwiNandGitHub 327a39964d fix(security): Make OIDC session TTL configurable (#1280)
* fix(security): Increase session cookie from 1h to 8h

* fix(security): Make OIDC session TTL configurable

* revert accidental change
2025-09-20 07:29:25 -04:00
TwiNandGitHub c87c651ff0 fix(suite): Display condition results when user clicks step in execution flow (#1278) 2025-09-19 12:43:43 -04:00
TwiNandGitHub 1658825525 fix(suite): Add hyphen support for context keys (#1277) 2025-09-19 12:09:18 -04:00
TwiNandGitHub 3a95e32210 fix: Suite endpoint listed as standalone endpoint (#1276) 2025-09-19 11:55:58 -04:00
TwiNandGitHub bd793305e9 fix(storage): Zero allocation issue with fiber (#1273)
* fix(storage): Zero allocation issue with fiber

* ci: Bump Go version
2025-09-19 11:38:46 -04:00
TwiN 0d2a55cf11 docs: Add gatus-cli command to push a external endpoint status 2025-09-18 07:31:32 -04:00
28 changed files with 534 additions and 132 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
steps:
- uses: actions/setup-go@v6
with:
go-version: 1.24.1
go-version: 1.24.4
repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
ref: "${{ github.event.inputs.ref || 'master' }}"
- uses: actions/checkout@v5
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
steps:
- uses: actions/setup-go@v6
with:
go-version: 1.24.1
go-version: 1.24.4
- uses: actions/checkout@v5
- name: Build binary to make sure it works
run: go build
+24 -13
View File
@@ -357,7 +357,12 @@ external-endpoints:
send-on-resolved: true
```
To push the status of an external endpoint, you can use [gatus-cli](https://github.com/TwiN/gatus-cli), or send an HTTP request:
To push the status of an external endpoint, you can use [gatus-cli](https://github.com/TwiN/gatus-cli):
```
gatus-cli external-endpoint push --url https://status.example.org --key "core_ext-ep-test" --token "potato" --success
```
or send an HTTP request:
```
POST /api/v1/endpoints/{key}/external?success={success}&error={error}&duration={duration}
```
@@ -398,10 +403,12 @@ Here are a few cases in which suites could be useful:
#### Using Context in Endpoints
Once values are stored in the context, they can be referenced in subsequent endpoints:
- In the URL: `https://api.example.com/users/[CONTEXT].userId`
- In headers: `Authorization: Bearer [CONTEXT].authToken`
- In the body: `{"user_id": "[CONTEXT].userId"}`
- In conditions: `[BODY].server_ip == [CONTEXT].serverIp`
- In the URL: `https://api.example.com/users/[CONTEXT].user_id`
- In headers: `Authorization: Bearer [CONTEXT].auth_token`
- In the body: `{"user_id": "[CONTEXT].user_id"}`
- In conditions: `[BODY].server_ip == [CONTEXT].server_ip`
Note that context/store keys are limited to A-Z, a-z, 0-9, underscores (`_`), and hyphens (`-`).
#### Example Suite Configuration
```yaml
@@ -1895,14 +1902,15 @@ endpoints:
#### Configuring Slack alerts
| Parameter | Description | Default |
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.slack` | Configuration for alerts of type `slack` | `{}` |
| `alerting.slack.webhook-url` | Slack Webhook URL | Required `""` |
| `alerting.slack.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.slack.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.slack.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.slack.overrides[].*` | See `alerting.slack.*` parameters | `{}` |
| Parameter | Description | Default |
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:------------------------------------|
| `alerting.slack` | Configuration for alerts of type `slack` | `{}` |
| `alerting.slack.webhook-url` | Slack Webhook URL | Required `""` |
| `alerting.slack.title` | Title of the notification | `":helmet_with_white_cross: Gatus"` |
| `alerting.slack.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.slack.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.slack.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.slack.overrides[].*` | See `alerting.slack.*` parameters | `{}` |
```yaml
alerting:
@@ -2572,6 +2580,7 @@ security:
| `security.oidc.client-secret` | Client secret | Required `""` |
| `security.oidc.scopes` | Scopes to request. The only scope you need is `openid`. | Required `[]` |
| `security.oidc.allowed-subjects` | List of subjects to allow. If empty, all subjects are allowed. | `[]` |
| `security.oidc.session-ttl` | Session time-to-live (e.g. `8h`, `1h30m`, `2h`). | `8h` |
```yaml
security:
@@ -2583,6 +2592,8 @@ security:
scopes: ["openid"]
# You may optionally specify a list of allowed subjects. If this is not specified, all subjects will be allowed.
#allowed-subjects: ["johndoe@example.com"]
# You may optionally specify a session time-to-live. If this is not specified, defaults to 8 hours.
#session-ttl: 8h
```
Confused? Read [Securing Gatus with OIDC using Auth0](https://twin.sh/articles/56/securing-gatus-with-oidc-using-auth0).
+11 -4
View File
@@ -20,7 +20,8 @@ var (
)
type Config struct {
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
}
func (cfg *Config) Validate() error {
@@ -34,6 +35,9 @@ func (cfg *Config) Merge(override *Config) {
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
if len(override.Title) > 0 {
cfg.Title = override.Title
}
}
// AlertProvider is the configuration necessary for sending an alert using Slack
@@ -73,7 +77,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil {
return err
@@ -111,7 +115,7 @@ type Field struct {
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, color string
if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
@@ -138,13 +142,16 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
Text: "",
Attachments: []Attachment{
{
Title: ":helmet_with_white_cross: Gatus",
Title: cfg.Title,
Text: message + description,
Short: false,
Color: color,
},
},
}
if len(body.Attachments[0].Title) == 0 {
body.Attachments[0].Title = ":helmet_with_white_cross: Gatus"
}
if len(formattedConditionResults) > 0 {
body.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{
Title: "Condition results",
+18 -5
View File
@@ -150,7 +150,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{
{
Name: "triggered",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
@@ -158,7 +158,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
{
Name: "triggered-with-group",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
@@ -167,7 +167,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{
Name: "triggered-with-no-conditions",
NoConditions: true,
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
@@ -175,7 +175,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
{
Name: "resolved",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
@@ -183,12 +183,20 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
{
Name: "resolved-with-group",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
},
{
Name: "resolved-with-group-and-custom-title",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com", Title: "custom title"}},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\"custom title\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
@@ -199,7 +207,12 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
}
}
cfg, err := scenario.Provider.GetConfig(scenario.Endpoint.Group, &scenario.Alert)
if err != nil {
t.Fatal("couldn't get config:", err.Error())
}
body := scenario.Provider.buildRequestBody(
cfg,
&scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{
+1
View File
@@ -52,6 +52,7 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
},
ReadBufferSize: cfg.Web.ReadBufferSize,
Network: fiber.NetworkTCP,
Immutable: true, // If not enabled, will cause issues due to fiber's zero allocation. See #1268 and https://docs.gofiber.io/#zero-allocation
})
if os.Getenv("ENVIRONMENT") == "dev" {
app.Use(cors.New(cors.Config{
+2 -2
View File
@@ -56,8 +56,8 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
}
result.Duration = parsedDuration
}
if !result.Success && c.Query("error") != "" {
result.Errors = append(result.Errors, c.Query("error"))
if errorFromQuery := c.Query("error"); !result.Success && len(errorFromQuery) > 0 {
result.AddError(errorFromQuery)
}
convertedEndpoint := externalEndpoint.ToEndpoint()
if err := store.Get().InsertEndpointResult(convertedEndpoint, result); err != nil {
+1 -1
View File
@@ -514,7 +514,7 @@ func validateUniqueKeys(config *Config) error {
func validateSecurityConfig(config *Config) error {
if config.Security != nil {
if config.Security.IsValid() {
if config.Security.ValidateAndSetDefaults() {
logr.Debug("[config.validateSecurityConfig] Basic security configuration has been validated")
} else {
// If there was an attempt to configure security, then it must mean that some confidential or private
+1 -1
View File
@@ -1850,7 +1850,7 @@ endpoints:
if config.Security == nil {
t.Fatal("config.Security shouldn't have been nil")
}
if !config.Security.IsValid() {
if !config.Security.ValidateAndSetDefaults() {
t.Error("Security config should've been valid")
}
if config.Security.Basic == nil {
+23 -18
View File
@@ -214,30 +214,35 @@ func prettifyNumericalParameters(parameters []string, resolvedParameters []int64
// prettify returns a string representation of a condition with its parameters resolved between parentheses
func prettify(parameters []string, resolvedParameters []string, operator string) string {
// Since, in the event of an invalid path, the resolvedParameters also contain the condition itself,
// we'll return the resolvedParameters as-is.
if strings.HasSuffix(resolvedParameters[0], InvalidConditionElementSuffix) || strings.HasSuffix(resolvedParameters[1], InvalidConditionElementSuffix) {
return resolvedParameters[0] + " " + operator + " " + resolvedParameters[1]
}
// If using the pattern function, truncate the parameter it's being compared to if said parameter is long enough
// Handle pattern function truncation first
if strings.HasPrefix(parameters[0], PatternFunctionPrefix) && strings.HasSuffix(parameters[0], FunctionSuffix) && len(resolvedParameters[1]) > maximumLengthBeforeTruncatingWhenComparedWithPattern {
resolvedParameters[1] = fmt.Sprintf("%.25s...(truncated)", resolvedParameters[1])
}
if strings.HasPrefix(parameters[1], PatternFunctionPrefix) && strings.HasSuffix(parameters[1], FunctionSuffix) && len(resolvedParameters[0]) > maximumLengthBeforeTruncatingWhenComparedWithPattern {
resolvedParameters[0] = fmt.Sprintf("%.25s...(truncated)", resolvedParameters[0])
}
// First element is a placeholder
if parameters[0] != resolvedParameters[0] && parameters[1] == resolvedParameters[1] {
return parameters[0] + " (" + resolvedParameters[0] + ") " + operator + " " + parameters[1]
// Determine the state of each parameter
leftChanged := parameters[0] != resolvedParameters[0]
rightChanged := parameters[1] != resolvedParameters[1]
leftInvalid := resolvedParameters[0] == parameters[0]+" "+InvalidConditionElementSuffix
rightInvalid := resolvedParameters[1] == parameters[1]+" "+InvalidConditionElementSuffix
// Build the output based on what was resolved
var left, right string
// Format left side
if leftChanged && !leftInvalid {
left = parameters[0] + " (" + resolvedParameters[0] + ")"
} else if leftInvalid {
left = resolvedParameters[0] // Already has (INVALID)
} else {
left = parameters[0] // Unchanged
}
// Second element is a placeholder
if parameters[0] == resolvedParameters[0] && parameters[1] != resolvedParameters[1] {
return parameters[0] + " " + operator + " " + parameters[1] + " (" + resolvedParameters[1] + ")"
// Format right side
if rightChanged && !rightInvalid {
right = parameters[1] + " (" + resolvedParameters[1] + ")"
} else if rightInvalid {
right = resolvedParameters[1] // Already has (INVALID)
} else {
right = parameters[1] // Unchanged
}
// Both elements are placeholders...?
if parameters[0] != resolvedParameters[0] && parameters[1] != resolvedParameters[1] {
return parameters[0] + " (" + resolvedParameters[0] + ") " + operator + " " + parameters[1] + " (" + resolvedParameters[1] + ")"
}
// Neither elements are placeholders
return parameters[0] + " " + operator + " " + parameters[1]
return left + " " + operator + " " + right
}
+76
View File
@@ -6,6 +6,8 @@ import (
"strconv"
"testing"
"time"
"github.com/TwiN/gatus/v5/config/gontext"
)
func TestCondition_Validate(t *testing.T) {
@@ -777,3 +779,77 @@ func TestCondition_evaluateWithInvalidOperator(t *testing.T) {
t.Error("condition was invalid, result should've had an error")
}
}
func TestConditionEvaluateWithInvalidContextPlaceholder(t *testing.T) {
// Test case: Suite endpoint with invalid context placeholder
// This should display the original placeholder names with resolved values
condition := Condition("[STATUS] == [CONTEXT].expected_statusz")
result := &Result{HTTPStatus: 200}
ctx := gontext.New(map[string]interface{}{
// Note: expected_statusz is not in the context (typo - should be expected_status)
"expected_status": 200,
"max_response_time": 5000,
})
// Simulate suite endpoint evaluation with context
success := condition.evaluate(result, false, ctx) // false = don't skip resolution (default)
if success {
t.Error("Condition should have failed because [CONTEXT].expected_statusz doesn't exist")
}
if len(result.ConditionResults) == 0 {
t.Fatal("No condition results found")
}
actualDisplay := result.ConditionResults[0].Condition
// The expected format should preserve the placeholder names
expectedDisplay := "[STATUS] (200) == [CONTEXT].expected_statusz (INVALID)"
if actualDisplay != expectedDisplay {
t.Errorf("Incorrect condition display for failed context placeholder\nExpected: %s\nActual: %s", expectedDisplay, actualDisplay)
}
}
func TestConditionEvaluateWithValidContextPlaceholder(t *testing.T) {
// Test case: Suite endpoint with valid context placeholder
condition := Condition("[STATUS] == [CONTEXT].expected_status")
result := &Result{HTTPStatus: 200}
ctx := gontext.New(map[string]interface{}{
"expected_status": 200,
})
// Simulate suite endpoint evaluation with context
success := condition.evaluate(result, false, ctx)
if !success {
t.Error("Condition should have succeeded")
}
if len(result.ConditionResults) == 0 {
t.Fatal("No condition results found")
}
actualDisplay := result.ConditionResults[0].Condition
// For successful conditions, just the original condition is shown
expectedDisplay := "[STATUS] == [CONTEXT].expected_status"
if actualDisplay != expectedDisplay {
t.Errorf("Incorrect condition display for successful context placeholder\nExpected: %s\nActual: %s", expectedDisplay, actualDisplay)
}
}
func TestConditionEvaluateWithMixedValidAndInvalidContext(t *testing.T) {
// Test case: One valid placeholder, one invalid
// Note: For numerical comparisons, invalid placeholders that can't be parsed as numbers
// default to 0 due to sanitizeAndResolveNumericalWithContext's behavior
condition := Condition("[RESPONSE_TIME] < [CONTEXT].invalid_key")
result := &Result{Duration: 100 * 1000000} // 100ms in nanoseconds
ctx := gontext.New(map[string]interface{}{
"valid_key": 5000,
})
// Simulate suite endpoint evaluation with context
success := condition.evaluate(result, false, ctx)
if success {
t.Error("Condition should have failed because [CONTEXT].invalid_key doesn't exist")
}
if len(result.ConditionResults) == 0 {
t.Fatal("No condition results found")
}
actualDisplay := result.ConditionResults[0].Condition
// For numerical comparisons, invalid context placeholders become 0
expectedDisplay := "[RESPONSE_TIME] (100) < [CONTEXT].invalid_key (0)"
if actualDisplay != expectedDisplay {
t.Errorf("Incorrect condition display\nExpected: %s\nActual: %s", expectedDisplay, actualDisplay)
}
}
+1 -1
View File
@@ -391,7 +391,7 @@ func replaceContextPlaceholders(input string, ctx *gontext.Gontext) (string, err
return input, nil
}
var contextErrors []string
contextRegex := regexp.MustCompile(`\[CONTEXT\]\.[\w\.]+`)
contextRegex := regexp.MustCompile(`\[CONTEXT\]\.[\w\.\-]+`)
result := contextRegex.ReplaceAllStringFunc(input, func(match string) string {
// Extract the path after [CONTEXT].
path := strings.TrimPrefix(match, "[CONTEXT].")
+132
View File
@@ -1227,6 +1227,138 @@ func TestEndpoint_preprocessWithContext(t *testing.T) {
expectedErrorCount: 1,
expectedErrorContains: []string{"path 'response.missing.path' not found"},
},
{
name: "hyphen_support_in_simple_keys",
endpoint: &Endpoint{
URL: "https://api.example.com/users/[CONTEXT].user-id",
Body: `{"api-key": "[CONTEXT].api-key", "user-name": "[CONTEXT].user-name"}`,
},
context: map[string]interface{}{
"user-id": "user-12345",
"api-key": "key-abcdef",
"user-name": "john-doe",
},
expectedURL: "https://api.example.com/users/user-12345",
expectedBody: `{"api-key": "key-abcdef", "user-name": "john-doe"}`,
expectedErrorCount: 0,
},
{
name: "hyphen_support_in_headers",
endpoint: &Endpoint{
URL: "https://api.example.com",
Body: "",
Headers: map[string]string{
"X-API-Key": "[CONTEXT].api-key",
"X-User-ID": "[CONTEXT].user-id",
"Content-Type": "[CONTEXT].content-type",
},
},
context: map[string]interface{}{
"api-key": "secret-key-123",
"user-id": "user-456",
"content-type": "application-json",
},
expectedURL: "https://api.example.com",
expectedBody: "",
expectedHeaders: map[string]string{
"X-API-Key": "secret-key-123",
"X-User-ID": "user-456",
"Content-Type": "application-json",
},
expectedErrorCount: 0,
},
{
name: "mixed_hyphens_underscores_and_dots",
endpoint: &Endpoint{
URL: "https://api.example.com/[CONTEXT].service-name/[CONTEXT].user_data.user-id",
Body: `{"tenant-id": "[CONTEXT].tenant_config.tenant-id"}`,
},
context: map[string]interface{}{
"service-name": "auth-service",
"user_data": map[string]interface{}{
"user-id": "user-789",
},
"tenant_config": map[string]interface{}{
"tenant-id": "tenant-abc-123",
},
},
expectedURL: "https://api.example.com/auth-service/user-789",
expectedBody: `{"tenant-id": "tenant-abc-123"}`,
expectedErrorCount: 0,
},
{
name: "hyphen_in_nested_paths",
endpoint: &Endpoint{
URL: "https://api.example.com/users/[CONTEXT].auth-response.user-data.profile-id",
Body: "",
},
context: map[string]interface{}{
"auth-response": map[string]interface{}{
"user-data": map[string]interface{}{
"profile-id": "profile-xyz-789",
},
},
},
expectedURL: "https://api.example.com/users/profile-xyz-789",
expectedBody: "",
expectedErrorCount: 0,
},
{
name: "missing_hyphenated_context_key",
endpoint: &Endpoint{
URL: "https://api.example.com/users/[CONTEXT].missing-user-id",
Body: `{"api-key": "[CONTEXT].missing-api-key"}`,
},
context: map[string]interface{}{
"user-id": "valid-user", // different key
},
expectedURL: "https://api.example.com/users/[CONTEXT].missing-user-id",
expectedBody: `{"api-key": "[CONTEXT].missing-api-key"}`,
expectedErrorCount: 2,
expectedErrorContains: []string{"path 'missing-user-id' not found", "path 'missing-api-key' not found"},
},
{
name: "multiple_hyphens_in_single_key",
endpoint: &Endpoint{
URL: "https://api.example.com/[CONTEXT].multi-hyphen-key-name",
Body: "",
},
context: map[string]interface{}{
"multi-hyphen-key-name": "value-with-multiple-hyphens",
},
expectedURL: "https://api.example.com/value-with-multiple-hyphens",
expectedBody: "",
expectedErrorCount: 0,
},
{
name: "hyphens_with_numeric_values",
endpoint: &Endpoint{
URL: "https://api.example.com/limit/[CONTEXT].max-items",
Body: `{"timeout-ms": [CONTEXT].timeout-ms, "retry-count": [CONTEXT].retry-count}`,
},
context: map[string]interface{}{
"max-items": 100,
"timeout-ms": 5000,
"retry-count": 3,
},
expectedURL: "https://api.example.com/limit/100",
expectedBody: `{"timeout-ms": 5000, "retry-count": 3}`,
expectedErrorCount: 0,
},
{
name: "hyphens_with_boolean_values",
endpoint: &Endpoint{
URL: "https://api.example.com",
Body: `{"enable-feature": [CONTEXT].enable-feature, "disable-cache": [CONTEXT].disable-cache}`,
},
context: map[string]interface{}{
"enable-feature": true,
"disable-cache": false,
},
expectedURL: "https://api.example.com",
expectedBody: `{"enable-feature": true, "disable-cache": false}`,
expectedErrorCount: 0,
},
}
for _, tt := range tests {
+1 -1
View File
@@ -72,5 +72,5 @@ func (r *Result) AddError(error string) {
return
}
}
r.Errors = append(r.Errors, error)
r.Errors = append(r.Errors, error+"")
}
+5 -3
View File
@@ -1,12 +1,14 @@
module github.com/TwiN/gatus/v5
go 1.24.1
go 1.24.4
toolchain go1.24.7
require (
code.gitea.io/sdk/gitea v0.21.0
github.com/TwiN/deepmerge v0.2.2
github.com/TwiN/g8/v2 v2.0.0
github.com/TwiN/gocache/v2 v2.2.2
github.com/TwiN/gocache/v2 v2.4.0
github.com/TwiN/health v1.6.0
github.com/TwiN/logr v0.3.1
github.com/TwiN/whois v1.1.11
@@ -26,7 +28,7 @@ require (
golang.org/x/crypto v0.40.0
golang.org/x/net v0.42.0
golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.16.0
golang.org/x/sync v0.17.0
google.golang.org/api v0.242.0
gopkg.in/mail.v2 v2.3.1
gopkg.in/yaml.v3 v3.0.1
+4 -4
View File
@@ -12,8 +12,8 @@ github.com/TwiN/deepmerge v0.2.2 h1:FUG9QMIYg/j2aQyPPhA3XTFJwXSNHI/swaR4Lbyxwg4=
github.com/TwiN/deepmerge v0.2.2/go.mod h1:4OHvjV3pPNJCJZBHswYAwk6rxiD8h8YZ+9cPo7nu4oI=
github.com/TwiN/g8/v2 v2.0.0 h1:+hwIbRLMhDd2iwHzkZUPp2FkX7yTx8ddYOnS91HkDqQ=
github.com/TwiN/g8/v2 v2.0.0/go.mod h1:4sVAF27q8T8ISggRa/Fb0drw7wpB22B6eWd+/+SGMqE=
github.com/TwiN/gocache/v2 v2.2.2 h1:4HToPfDV8FSbaYO5kkbhLpEllUYse5rAf+hVU/mSsuI=
github.com/TwiN/gocache/v2 v2.2.2/go.mod h1:WfIuwd7GR82/7EfQqEtmLFC3a2vqaKbs4Pe6neB7Gyc=
github.com/TwiN/gocache/v2 v2.4.0 h1:BZ/TqvhipDQE23MFFTjC0MiI1qZ7GEVtSdOFVVXyr18=
github.com/TwiN/gocache/v2 v2.4.0/go.mod h1:Cl1c0qNlQlXzJhTpAARVqpQDSuGDM5RhtzPYAM1x17g=
github.com/TwiN/health v1.6.0 h1:L2ks575JhRgQqWWOfKjw9B0ec172hx7GdToqkYUycQM=
github.com/TwiN/health v1.6.0/go.mod h1:Z6TszwQPMvtSiVx1QMidVRgvVr4KZGfiwqcD7/Z+3iw=
github.com/TwiN/logr v0.3.1 h1:CfTKA83jUmsAoxqrr3p4JxEkqXOBnEE9/f35L5MODy4=
@@ -195,8 +195,8 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+3 -3
View File
@@ -26,9 +26,9 @@ type Config struct {
gate *g8.Gate
}
// IsValid returns whether the security configuration is valid or not
func (c *Config) IsValid() bool {
return (c.Basic != nil && c.Basic.isValid()) || (c.OIDC != nil && c.OIDC.isValid())
// ValidateAndSetDefaults returns whether the security configuration is valid or not and sets default values.
func (c *Config) ValidateAndSetDefaults() bool {
return (c.Basic != nil && c.Basic.isValid()) || (c.OIDC != nil && c.OIDC.ValidateAndSetDefaults())
}
// RegisterHandlers registers all handlers required based on the security configuration
+3 -2
View File
@@ -9,12 +9,12 @@ import (
"golang.org/x/oauth2"
)
func TestConfig_IsValid(t *testing.T) {
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
c := &Config{
Basic: nil,
OIDC: nil,
}
if c.IsValid() {
if c.ValidateAndSetDefaults() {
t.Error("expected empty config to be valid")
}
}
@@ -65,6 +65,7 @@ func TestConfig_ApplySecurityMiddleware(t *testing.T) {
RedirectURL: "http://localhost:80/authorization-code/callback",
Scopes: []string{"openid"},
AllowedSubjects: []string{"user1@example.com"},
SessionTTL: DefaultOIDCSessionTTL,
oauth2Config: oauth2.Config{},
verifier: nil,
}}
+18 -10
View File
@@ -13,21 +13,29 @@ import (
"golang.org/x/oauth2"
)
const (
DefaultOIDCSessionTTL = 8 * time.Hour
)
// OIDCConfig is the configuration for OIDC authentication
type OIDCConfig struct {
IssuerURL string `yaml:"issuer-url"` // e.g. https://dev-12345678.okta.com
RedirectURL string `yaml:"redirect-url"` // e.g. http://localhost:8080/authorization-code/callback
ClientID string `yaml:"client-id"`
ClientSecret string `yaml:"client-secret"`
Scopes []string `yaml:"scopes"` // e.g. ["openid"]
AllowedSubjects []string `yaml:"allowed-subjects"` // e.g. ["user1@example.com"]. If empty, all subjects are allowed
IssuerURL string `yaml:"issuer-url"` // e.g. https://dev-12345678.okta.com
RedirectURL string `yaml:"redirect-url"` // e.g. http://localhost:8080/authorization-code/callback
ClientID string `yaml:"client-id"`
ClientSecret string `yaml:"client-secret"`
Scopes []string `yaml:"scopes"` // e.g. ["openid"]
AllowedSubjects []string `yaml:"allowed-subjects"` // e.g. ["user1@example.com"]. If empty, all subjects are allowed
SessionTTL time.Duration `yaml:"session-ttl"` // e.g. 8h. Defaults to 8 hours
oauth2Config oauth2.Config
verifier *oidc.IDTokenVerifier
}
// isValid returns whether the basic security configuration is valid or not
func (c *OIDCConfig) isValid() bool {
// ValidateAndSetDefaults returns whether the OIDC configuration is valid and sets default values.
func (c *OIDCConfig) ValidateAndSetDefaults() bool {
if c.SessionTTL <= 0 {
c.SessionTTL = DefaultOIDCSessionTTL
}
return len(c.IssuerURL) > 0 && len(c.RedirectURL) > 0 && strings.HasSuffix(c.RedirectURL, "/authorization-code/callback") && len(c.ClientID) > 0 && len(c.ClientSecret) > 0 && len(c.Scopes) > 0
}
@@ -131,12 +139,12 @@ func (c *OIDCConfig) callbackHandler(w http.ResponseWriter, r *http.Request) { /
func (c *OIDCConfig) setSessionCookie(w http.ResponseWriter, idToken *oidc.IDToken) {
// At this point, the user has been confirmed. All that's left to do is create a session.
sessionID := uuid.NewString()
sessions.SetWithTTL(sessionID, idToken.Subject, time.Hour)
sessions.SetWithTTL(sessionID, idToken.Subject, c.SessionTTL)
http.SetCookie(w, &http.Cookie{
Name: cookieNameSession,
Value: sessionID,
Path: "/",
MaxAge: int(time.Hour.Seconds()),
MaxAge: int(c.SessionTTL.Seconds()),
SameSite: http.SameSiteStrictMode,
})
}
+22 -2
View File
@@ -4,11 +4,12 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/coreos/go-oidc/v3/oidc"
)
func TestOIDCConfig_isValid(t *testing.T) {
func TestOIDCConfig_ValidateAndSetDefaults(t *testing.T) {
c := &OIDCConfig{
IssuerURL: "https://sso.gatus.io/",
RedirectURL: "http://localhost:80/authorization-code/callback",
@@ -16,10 +17,14 @@ func TestOIDCConfig_isValid(t *testing.T) {
ClientSecret: "client-secret",
Scopes: []string{"openid"},
AllowedSubjects: []string{"user1@example.com"},
SessionTTL: 0, // Not set! ValidateAndSetDefaults should set it to DefaultOIDCSessionTTL
}
if !c.isValid() {
if !c.ValidateAndSetDefaults() {
t.Error("OIDCConfig should be valid")
}
if c.SessionTTL != DefaultOIDCSessionTTL {
t.Error("expected SessionTTL to be set to DefaultOIDCSessionTTL")
}
}
func TestOIDCConfig_callbackHandler(t *testing.T) {
@@ -68,3 +73,18 @@ func TestOIDCConfig_setSessionCookie(t *testing.T) {
t.Error("expected cookie to be set")
}
}
func TestOIDCConfig_setSessionCookieWithCustomTTL(t *testing.T) {
customTTL := 30 * time.Minute
c := &OIDCConfig{SessionTTL: customTTL}
responseRecorder := httptest.NewRecorder()
c.setSessionCookie(responseRecorder, &oidc.IDToken{Subject: "test@example.com"})
cookies := responseRecorder.Result().Cookies()
if len(cookies) == 0 {
t.Error("expected cookie to be set")
}
sessionCookie := cookies[0]
if sessionCookie.MaxAge != int(customTTL.Seconds()) {
t.Errorf("expected cookie MaxAge to be %d, but was %d", int(customTTL.Seconds()), sessionCookie.MaxAge)
}
}
+30 -29
View File
@@ -282,7 +282,7 @@ func TestStore_MixedEndpointsAndSuites(t *testing.T) {
if len(status.Results) != 1 {
t.Errorf("expected 1 suite result, got %d", len(status.Results))
}
stored := status.Results[0]
if stored.Name != testSuite.Name {
t.Errorf("expected result name %s, got %s", testSuite.Name, stored.Name)
@@ -303,36 +303,37 @@ func TestStore_MixedEndpointsAndSuites(t *testing.T) {
// Test 3: GetAllEndpointStatuses should only return endpoints, not suites
t.Run("GetAllEndpointStatuses", func(t *testing.T) {
store, endpoint1, endpoint2, suiteEndpoint1, suiteEndpoint2, testSuite := setupStore(t)
store, endpoint1, endpoint2, _, _, testSuite := setupStore(t)
// InsertEndpointResult all test data
// Insert standalone endpoint results only
store.InsertEndpointResult(endpoint1, &endpoint.Result{Success: true, Timestamp: time.Now(), Duration: 100 * time.Millisecond})
store.InsertEndpointResult(endpoint2, &endpoint.Result{Success: false, Timestamp: time.Now(), Duration: 200 * time.Millisecond})
store.InsertEndpointResult(suiteEndpoint1, &endpoint.Result{Success: true, Timestamp: time.Now(), Duration: 50 * time.Millisecond})
store.InsertEndpointResult(suiteEndpoint2, &endpoint.Result{Success: true, Timestamp: time.Now(), Duration: 75 * time.Millisecond})
// Suite endpoints should only exist as part of suite results, not as individual endpoint results
store.InsertSuiteResult(testSuite, &suite.Result{
Name: testSuite.Name, Group: testSuite.Group, Success: true,
Timestamp: time.Now(), Duration: 125 * time.Millisecond,
EndpointResults: []*endpoint.Result{
{Success: true, Duration: 50 * time.Millisecond, Name: "suite-endpoint1"},
{Success: true, Duration: 75 * time.Millisecond, Name: "suite-endpoint2"},
},
})
statuses, err := store.GetAllEndpointStatuses(&paging.EndpointStatusParams{})
if err != nil {
t.Fatalf("failed to get all endpoint statuses: %v", err)
}
// Should have 4 endpoints (2 regular + 2 suite endpoints)
if len(statuses) != 4 {
t.Errorf("expected 4 endpoint statuses, got %d", len(statuses))
// Should have 2 endpoints (only standalone endpoints, not suite endpoints)
if len(statuses) != 2 {
t.Errorf("expected 2 endpoint statuses, got %d", len(statuses))
}
// Verify all are endpoint statuses with correct data, not suite statuses
// Verify all are standalone endpoint statuses with correct data, not suite endpoints
expectedEndpoints := map[string]struct {
success bool
duration time.Duration
}{
"endpoint1": {success: true, duration: 100 * time.Millisecond},
"endpoint2": {success: false, duration: 200 * time.Millisecond},
"suite-endpoint1": {success: true, duration: 50 * time.Millisecond},
"suite-endpoint2": {success: true, duration: 75 * time.Millisecond},
"endpoint1": {success: true, duration: 100 * time.Millisecond},
"endpoint2": {success: false, duration: 200 * time.Millisecond},
}
for _, status := range statuses {
@@ -425,7 +426,7 @@ func TestStore_MixedEndpointsAndSuites(t *testing.T) {
timestamp2 := time.Now().Add(1 * time.Hour)
store.InsertEndpointResult(endpoint1, &endpoint.Result{Success: true, Timestamp: timestamp1, Duration: 100 * time.Millisecond})
store.InsertEndpointResult(suiteEndpoint1, &endpoint.Result{Success: false, Timestamp: timestamp2, Duration: 50 * time.Millisecond, Errors: []string{"suite error"}})
// Test regular endpoints
status1, err := store.GetEndpointStatusByKey(endpoint1.Key(), &paging.EndpointStatusParams{})
if err != nil {
@@ -505,7 +506,7 @@ func TestStore_MixedEndpointsAndSuites(t *testing.T) {
if len(suiteStatus.Results) != 1 {
t.Errorf("expected 1 suite result, got %d", len(suiteStatus.Results))
}
if len(suiteStatus.Results) > 0 {
result := suiteStatus.Results[0]
if result.Success {
@@ -698,7 +699,7 @@ func TestStore_MaximumLimits(t *testing.T) {
t.Run("endpoint-result-limits", func(t *testing.T) {
ep := &endpoint.Endpoint{Name: "test-endpoint", Group: "test", URL: "https://example.com"}
// Insert more results than the maximum
baseTime := time.Now().Add(-10 * time.Hour)
for i := 0; i < maxResults*2; i++ {
@@ -740,7 +741,7 @@ func TestStore_MaximumLimits(t *testing.T) {
t.Run("suite-result-limits", func(t *testing.T) {
testSuite := &suite.Suite{Name: "test-suite", Group: "test"}
// Insert more results than the maximum
baseTime := time.Now().Add(-10 * time.Hour)
for i := 0; i < maxResults*2; i++ {
@@ -791,11 +792,11 @@ func TestSuiteResultOrdering(t *testing.T) {
defer store.Clear()
testSuite := &suite.Suite{Name: "ordering-suite", Group: "test"}
// Insert results with distinct timestamps
baseTime := time.Now().Add(-5 * time.Hour)
timestamps := make([]time.Time, 5)
for i := 0; i < 5; i++ {
timestamp := baseTime.Add(time.Duration(i) * time.Hour)
timestamps[i] = timestamp
@@ -817,17 +818,17 @@ func TestSuiteResultOrdering(t *testing.T) {
if err != nil {
t.Fatalf("failed to get suite status: %v", err)
}
// Verify results are in chronological order (oldest first due to append)
for i := 0; i < len(status.Results)-1; i++ {
current := status.Results[i]
next := status.Results[i+1]
if !next.Timestamp.After(current.Timestamp) {
t.Errorf("result %d timestamp %v should be before result %d timestamp %v",
t.Errorf("result %d timestamp %v should be before result %d timestamp %v",
i, current.Timestamp, i+1, next.Timestamp)
}
}
// Verify specific timestamp order
if !status.Results[0].Timestamp.Equal(timestamps[0]) {
t.Errorf("first result timestamp should be %v, got %v", timestamps[0], status.Results[0].Timestamp)
@@ -852,11 +853,11 @@ func TestSuiteResultOrdering(t *testing.T) {
},
paging.NewSuiteStatusParams().WithPagination(1, 3),
)
if len(page1.Results) != 3 {
t.Errorf("expected 3 results in page 1, got %d", len(page1.Results))
}
// With reverse pagination, page 1 should have the 3 newest results
// That means results[2], results[3], results[4] from original array
if page1.Results[0].Duration != 200*time.Millisecond {
@@ -873,9 +874,9 @@ func TestSuiteResultOrdering(t *testing.T) {
t.Fatal("expected no error, got", err)
}
defer limitedStore.Clear()
smallSuite := &suite.Suite{Name: "small-suite", Group: "test"}
// Insert 6 results, should keep only the newest 3
for i := 0; i < 6; i++ {
result := &suite.Result{
@@ -890,16 +891,16 @@ func TestSuiteResultOrdering(t *testing.T) {
t.Fatalf("failed to insert result %d: %v", i, err)
}
}
status, err := limitedStore.GetSuiteStatusByKey(smallSuite.Key(), nil)
if err != nil {
t.Fatalf("failed to get suite status: %v", err)
}
if len(status.Results) != 3 {
t.Errorf("expected 3 results after trimming, got %d", len(status.Results))
}
// Should have results 3, 4, 5 (the newest ones)
expectedDurations := []time.Duration{150 * time.Millisecond, 200 * time.Millisecond, 250 * time.Millisecond}
for i, expectedDuration := range expectedDurations {
+49 -6
View File
@@ -1499,7 +1499,8 @@ func (s *Store) getSuiteResults(tx *sql.Tx, suiteID int64, page, pageSize int) (
resultID := data.id
// Query endpoint results for this suite result
epRows, err := tx.Query(`
SELECT
SELECT
er.endpoint_result_id,
e.endpoint_name,
er.success,
er.errors,
@@ -1514,31 +1515,73 @@ func (s *Store) getSuiteResults(tx *sql.Tx, suiteID int64, page, pageSize int) (
logr.Errorf("[sql.getSuiteResults] Failed to get endpoint results for suite_result_id=%d: %s", resultID, err.Error())
continue
}
// Map to store endpoint results by their ID for condition lookup
epResultMap := make(map[int64]*endpoint.Result)
epCount := 0
for epRows.Next() {
epCount++
var epResultID int64
var name string
var success bool
var joinedErrors string
var duration int64
var timestamp time.Time
err = epRows.Scan(&name, &success, &joinedErrors, &duration, &timestamp)
err = epRows.Scan(&epResultID, &name, &success, &joinedErrors, &duration, &timestamp)
if err != nil {
logr.Errorf("[sql.getSuiteResults] Failed to scan endpoint result: %s", err.Error())
continue
}
epResult := &endpoint.Result{
Name: name,
Success: success,
Duration: time.Duration(duration),
Timestamp: timestamp,
Name: name,
Success: success,
Duration: time.Duration(duration),
Timestamp: timestamp,
ConditionResults: []*endpoint.ConditionResult{}, // Initialize empty slice
}
if len(joinedErrors) > 0 {
epResult.Errors = strings.Split(joinedErrors, arraySeparator)
}
epResultMap[epResultID] = epResult
result.EndpointResults = append(result.EndpointResults, epResult)
}
epRows.Close()
// Fetch condition results for all endpoint results in this suite result
if len(epResultMap) > 0 {
args := make([]interface{}, 0, len(epResultMap))
condQuery := `SELECT endpoint_result_id, condition, success
FROM endpoint_result_conditions
WHERE endpoint_result_id IN (`
index := 1
for epResultID := range epResultMap {
condQuery += "$" + strconv.Itoa(index) + ","
args = append(args, epResultID)
index++
}
condQuery = condQuery[:len(condQuery)-1] + ")"
condRows, err := tx.Query(condQuery, args...)
if err != nil {
logr.Errorf("[sql.getSuiteResults] Failed to get condition results for suite_result_id=%d: %s", resultID, err.Error())
} else {
condCount := 0
for condRows.Next() {
condCount++
conditionResult := &endpoint.ConditionResult{}
var epResultID int64
if err = condRows.Scan(&epResultID, &conditionResult.Condition, &conditionResult.Success); err != nil {
logr.Errorf("[sql.getSuiteResults] Failed to scan condition result: %s", err.Error())
continue
}
if epResult, exists := epResultMap[epResultID]; exists {
epResult.ConditionResults = append(epResult.ConditionResults, conditionResult)
}
}
condRows.Close()
if condCount > 0 {
logr.Debugf("[sql.getSuiteResults] Found %d condition results for suite_result_id=%d", condCount, resultID)
}
}
}
if epCount > 0 {
logr.Debugf("[sql.getSuiteResults] Found %d endpoint results for suite_result_id=%d", epCount, resultID)
}
+1 -3
View File
@@ -50,12 +50,10 @@ func executeSuite(s *suite.Suite, cfg *config.Config, extraLabels []string) {
if cfg.Metrics {
metrics.PublishMetricsForSuite(s, result, extraLabels)
}
// Store individual endpoint results and handle alerting
// Handle alerting for suite endpoints
for i, ep := range s.Endpoints {
if i < len(result.EndpointResults) {
epResult := result.EndpointResults[i]
// Store the endpoint result
UpdateEndpointStatus(ep, epResult)
// Handle alerting if configured and not under maintenance
if cfg.Alerting != nil && !cfg.Maintenance.IsUnderMaintenance() {
// Check if endpoint is under maintenance
+87 -1
View File
@@ -74,6 +74,92 @@
</div>
</div>
</div>
<!-- Condition Results -->
<div v-if="step.result?.conditionResults?.length" class="space-y-2">
<h3 class="text-sm font-medium flex items-center gap-2">
<CheckCircle class="w-4 h-4" />
Condition Results ({{ step.result.conditionResults.length }})
</h3>
<div class="space-y-2 max-h-48 overflow-y-auto">
<div
v-for="(conditionResult, index) in step.result.conditionResults"
:key="index"
class="flex items-start gap-3 p-1 rounded-lg border"
:class="conditionResult.success
? 'bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-700'
: 'bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-700'"
>
<!-- Status icon -->
<div class="flex-shrink-0 mt-0.5">
<CheckCircle
v-if="conditionResult.success"
class="w-4 h-4 text-green-600 dark:text-green-400"
/>
<XCircle
v-else
class="w-4 h-4 text-red-600 dark:text-red-400"
/>
</div>
<!-- Condition text -->
<div class="flex-1 min-w-0 flex items-center justify-between gap-3">
<p class="text-sm font-mono break-all"
:class="conditionResult.success
? 'text-green-800 dark:text-green-200'
: 'text-red-800 dark:text-red-200'">
{{ conditionResult.condition }}
</p>
<span class="text-xs font-medium whitespace-nowrap"
:class="conditionResult.success
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'">
{{ conditionResult.success ? 'Passed' : 'Failed' }}
</span>
</div>
</div>
</div>
</div>
<!-- Endpoint Configuration -->
<div v-if="step.endpoint" class="space-y-2">
<h3 class="text-sm font-medium flex items-center gap-2">
<Settings class="w-4 h-4" />
Endpoint Configuration
</h3>
<div class="space-y-3 text-xs">
<div v-if="step.endpoint.url">
<span class="text-muted-foreground">URL:</span>
<p class="font-mono mt-1 break-all">{{ step.endpoint.url }}</p>
</div>
<div v-if="step.endpoint.method">
<span class="text-muted-foreground">Method:</span>
<p class="mt-1 font-medium">{{ step.endpoint.method }}</p>
</div>
<div v-if="step.endpoint.interval">
<span class="text-muted-foreground">Interval:</span>
<p class="mt-1">{{ step.endpoint.interval }}</p>
</div>
<div v-if="step.endpoint.timeout">
<span class="text-muted-foreground">Timeout:</span>
<p class="mt-1">{{ step.endpoint.timeout }}</p>
</div>
</div>
</div>
<!-- Result Errors (separate from step errors) -->
<div v-if="step.result?.errors?.length" class="space-y-2">
<h3 class="text-sm font-medium flex items-center gap-2 text-red-600 dark:text-red-400">
<AlertCircle class="w-4 h-4" />
Result Errors ({{ step.result.errors.length }})
</h3>
<div class="space-y-2 max-h-32 overflow-y-auto">
<div v-for="(error, index) in step.result.errors" :key="index"
class="p-3 bg-red-50 dark:bg-red-900/50 border border-red-200 dark:border-red-700 rounded text-sm font-mono text-red-800 dark:text-red-300 break-all">
{{ error }}
</div>
</div>
</div>
</div>
</div>
</div>
@@ -81,7 +167,7 @@
<script setup>
import { computed } from 'vue'
import { X, AlertCircle, RotateCcw, Download, CheckCircle, XCircle, SkipForward, Pause, Clock } from 'lucide-vue-next'
import { X, AlertCircle, RotateCcw, Download, CheckCircle, XCircle, SkipForward, Pause, Clock, Settings } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { formatDuration } from '@/utils/format'
import { prettifyTimestamp } from '@/utils/time'
+2 -10
View File
@@ -68,7 +68,7 @@
<!-- Enhanced Execution Flow -->
<div class="mt-6">
<h3 class="text-lg font-semibold mb-4">Execution Flow</h3>
<SequentialFlowDiagram
<SequentialFlowDiagram
:flow-steps="flowSteps"
:progress-percentage="executionProgress"
:completed-steps="completedStepsCount"
@@ -132,7 +132,7 @@
<Settings @refreshData="fetchData" />
<!-- Step Details Modal -->
<StepDetailsModal
<StepDetailsModal
v-if="selectedStep"
:step="selectedStep"
:index="selectedStepIndex"
@@ -255,13 +255,10 @@ const flowSteps = computed(() => {
if (!latestResult.value || !latestResult.value.endpointResults) {
return []
}
const results = latestResult.value.endpointResults
return results.map((result, index) => {
const endpoint = suite.value?.endpoints?.[index]
const nextResult = results[index + 1]
// Determine if this is an always-run endpoint by checking execution pattern
// If a previous step failed but this one still executed, it must be always-run
let isAlwaysRun = false
@@ -272,7 +269,6 @@ const flowSteps = computed(() => {
break
}
}
return {
name: endpoint?.name || result.name || `Step ${index + 1}`,
endpoint: endpoint,
@@ -296,21 +292,17 @@ const executionProgress = computed(() => {
})
// Helper functions
const determineStepStatus = (result) => {
if (!result) return 'not-started'
// Check if step was skipped
if (result.conditionResults && result.conditionResults.some(c => c.condition.includes('SKIP'))) {
return 'skipped'
}
// Check if step failed but is always-run (still shows as failed but executed)
if (!result.success) {
return 'failed'
}
return 'success'
}
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long