mirror of
https://github.com/TwiN/gatus.git
synced 2026-08-24 08:06:03 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6576e9080 | ||
|
|
cd10b31ab5 | ||
|
|
d1ef0b72a4 | ||
|
|
327a39964d | ||
|
|
c87c651ff0 | ||
|
|
1658825525 | ||
|
|
3a95e32210 | ||
|
|
bd793305e9 | ||
|
|
0d2a55cf11 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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].")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -72,5 +72,5 @@ func (r *Result) AddError(error string) {
|
||||
return
|
||||
}
|
||||
}
|
||||
r.Errors = append(r.Errors, error)
|
||||
r.Errors = append(r.Errors, error+"")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ×tamp)
|
||||
err = epRows.Scan(&epResultID, &name, &success, &joinedErrors, &duration, ×tamp)
|
||||
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
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user