Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
dfa2c01
chore: Add state configuration prototype
PythonGermany Dec 14, 2025
2d5861c
refactor: Remove currently unused field
PythonGermany Dec 14, 2025
57a6ab3
chore: Add hardcoded maintenance color
PythonGermany Dec 14, 2025
9c68185
chore: Add maintenance UI showcase
PythonGermany Dec 14, 2025
6fa56d5
refactor(ui): Cleanup template option handling
PythonGermany Dec 10, 2025
85842b3
chore: Add backend state color configuration
PythonGermany Dec 14, 2025
01b359b
chore: Use backend color config in UI
PythonGermany Dec 14, 2025
c40861e
chore: Test darker color for better visibility
PythonGermany Dec 15, 2025
754d523
cleanup: Remove unused prototype code
PythonGermany Dec 15, 2025
7663771
test: Update config for testing
PythonGermany Dec 15, 2025
972d10a
chore: Allow linking condition to states
PythonGermany Dec 16, 2025
082d70b
chore: Store state for persistent storage types
PythonGermany Dec 16, 2025
1fd22c2
chore: Use lighter unhealthy color
PythonGermany Dec 16, 2025
57a7668
feat: Evaluate state based on priority
PythonGermany Dec 17, 2025
be000e5
chore: Validate correct hex code format
PythonGermany Dec 17, 2025
be43788
fix: Restore original maintenance log order
PythonGermany Dec 17, 2025
462ffb3
fix: Broken colors in development mode
PythonGermany Dec 17, 2025
d711fb7
feat: Provide state for each event
PythonGermany Dec 17, 2025
635dff2
feat(ui): Use event state in endpoint details view
PythonGermany Dec 17, 2025
09bc64e
chore(ui): Use utils color function
PythonGermany Dec 17, 2025
e0ffc33
refactor: Move badge color logic to StatusBadge
PythonGermany Dec 17, 2025
3fec961
fix(ui): Toolip result highlighting
PythonGermany Dec 17, 2025
80aea7f
chore: Use lighter unhealthy color
PythonGermany Dec 17, 2025
a77d9f7
refactor: Only validate user input colors
PythonGermany Dec 17, 2025
3c8b9d1
refactor: Create type for color
PythonGermany Dec 17, 2025
d25ad43
cleanup(ui): Remove unused component
PythonGermany Dec 17, 2025
be1da27
refactor: Update event type structure and init
PythonGermany Dec 17, 2025
3b5067f
fix: Unknown state label handling
PythonGermany Dec 17, 2025
22a4cea
fix: Add missing event query column
PythonGermany Dec 17, 2025
8ae9d07
fix: Incorrect default state name
PythonGermany Dec 17, 2025
01a7238
fix: Improve event messages
PythonGermany Dec 17, 2025
5cc0fd8
Merge remote-tracking branch 'origin/master' into add-custom-state-su…
PythonGermany Dec 17, 2025
88f6b72
fix: Add missing local variable
PythonGermany Dec 17, 2025
168fe9d
test: Add for linked condition states
PythonGermany Dec 17, 2025
3c1b7b8
refactor: Change error message
PythonGermany Dec 17, 2025
5596f61
test: Add for state config
PythonGermany Dec 17, 2025
8b73eab
cleanup: Remove resolved todos
PythonGermany Dec 17, 2025
3d7e392
chore: Update custom states test config
PythonGermany Dec 17, 2025
27f5172
chore(ui): Regenerate static assets
PythonGermany Dec 17, 2025
f51f5bc
refactor: Get color directly
PythonGermany Dec 17, 2025
ebeb451
fix: Create events when state has changed
PythonGermany Dec 18, 2025
38e4067
feat(ui): Apply custom states to response time char
PythonGermany Dec 18, 2025
cc751d9
chore(ui): Regenerate static assets
PythonGermany Dec 18, 2025
ea45ec5
test: Update suite config for testing
PythonGermany Dec 18, 2025
0f7b481
fix(storage): Add new column migration
PythonGermany Dec 19, 2025
d012b8d
cleanup: Omit empty states from json
PythonGermany Dec 19, 2025
c2fc1c8
chore: Check valid name characters
PythonGermany Dec 19, 2025
3db0372
fix(ui): Use nodata color for badge if with no result
PythonGermany Dec 19, 2025
c235e75
refactor: Rename to match new state system
PythonGermany Dec 19, 2025
20a2a53
cleanup: Remove resolved storage TODOs
PythonGermany Dec 19, 2025
9c2edc0
refactor(ui): Make local state color names consistent
PythonGermany Dec 20, 2025
93d04a2
fix: No default priority linked for confitions
PythonGermany Dec 20, 2025
0e66d5c
Merge remote-tracking branch 'TwiN/master' into add-custom-state-support
PythonGermany Dec 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ install:

.PHONY: run
run:
ENVIRONMENT=dev GATUS_CONFIG_PATH=./config.yaml go run main.go
ENVIRONMENT=dev GATUS_LOG_LEVEL=debug GATUS_CONFIG_PATH=./config.yaml go run main.go

.PHONY: run-binary
run-binary:
Expand Down
114 changes: 79 additions & 35 deletions config.yaml
Original file line number Diff line number Diff line change
@@ -1,53 +1,97 @@
maintenance:
enabled: true
start: "01:25"
duration: "5m"
timezone: "Europe/Berlin"

storage:
type: sqlite
path: ./gatus.db

states:
- name: degraded
priority: 1

ui:
state-colors:
degraded: "#FFA500" # Orange color for degraded state

endpoints:
- name: front-end
- name: healthy-test
group: core
url: "https://twin.sh/health"
interval: 5m
url: "icmp://9.9.9.9"
interval: 10s
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 150"
- "[CONNECTED] == true"

- name: back-end
- name: maintenance-test
group: core
url: "https://example.org/"
interval: 5m
url: "http://localhost:8088/"
interval: 10s
conditions:
- "[STATUS] == 200"
- "[CERTIFICATE_EXPIRATION] > 48h"
- "[RESPONSE_TIME] < 150"
maintenance-windows:
- start: "12:00"
duration: "23h59m"

- name: monitoring
group: internal
url: "https://example.org/"
interval: 5m
- name: degraded-test
group: core
url: "icmp://9.9.9.9"
interval: 10s
conditions:
- "[STATUS] == 200"
- "[CONNECTED] == true"
- "degraded::[RESPONSE_TIME] < 22"

- name: nas
group: internal
url: "https://example.org/"
interval: 5m
- name: unhealthy-test (error)
group: core
url: "http://doesnotexist.local/"
interval: 10s
conditions:
- "[STATUS] == 200"

- name: example-dns-query
url: "8.8.8.8" # Address of the DNS server to use
interval: 5m
dns:
query-name: "example.com"
query-type: "A"
- name: unhealthy-test (failing condition)
group: core
url: "icmp://9.9.9.9"
interval: 10s
conditions:
- "[BODY] == pat(*.*.*.*)" # Matches any IPv4 address
- "[DNS_RCODE] == NOERROR"
- "[CONNECTED] == true"
- "[RESPONSE_TIME] < 1"

- name: icmp-ping
url: "icmp://example.org"
interval: 1m
- name: unhealthy-test (invalid linked condition state)
group: core
url: "icmp://9.9.9.9"
interval: 10s
conditions:
- "[CONNECTED] == true"
- "invalid::[RESPONSE_TIME] < 1"

- name: check-domain-expiration
url: "https://example.org/"
interval: 1h
conditions:
- "[DOMAIN_EXPIRATION] > 720h"
suites:
- name: custom-state-test
group: api-tests
interval: 10s
endpoints:
- name: create-item
url: icmp://9.9.9.9
conditions:
- "[CONNECTED] == true"
- "[RESPONSE_TIME] < 23"

- name: update-item
url: icmp://9.9.9.9
conditions:
- "[CONNECTED] == true"
- "degraded::[RESPONSE_TIME] < 24"

- name: get-item
url: icmp://9.9.9.9
conditions:
- "[CONNECTED] == true"
- "[RESPONSE_TIME] < 23"

- name: delete-item
url: icmp://9.9.9.9
always-run: true
conditions:
- "[CONNECTED] == true"
- "degraded::[RESPONSE_TIME] < 23"
79 changes: 78 additions & 1 deletion config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/TwiN/gatus/v5/config/key"
"github.com/TwiN/gatus/v5/config/maintenance"
"github.com/TwiN/gatus/v5/config/remote"
"github.com/TwiN/gatus/v5/config/state"
"github.com/TwiN/gatus/v5/config/suite"
"github.com/TwiN/gatus/v5/config/tunneling"
"github.com/TwiN/gatus/v5/config/ui"
Expand Down Expand Up @@ -88,6 +89,9 @@ type Config struct {
// Alerting is the configuration for alerting providers
Alerting *alerting.Config `yaml:"alerting,omitempty"`

// States is the list of configured states
States []*state.State `yaml:"states,omitempty"` // TODO#227 Make this a map since state names should always be unique?

// Endpoints is the list of endpoints to monitor
Endpoints []*endpoint.Endpoint `yaml:"endpoints,omitempty"`

Expand Down Expand Up @@ -304,6 +308,9 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
if err := ValidateSecurityConfig(config); err != nil {
return nil, err
}
if er := ValidateStatesConfig(config); er != nil {
return nil, er
}
if err := ValidateEndpointsConfig(config); err != nil {
return nil, err
}
Expand Down Expand Up @@ -450,6 +457,7 @@ func ValidateMaintenanceConfig(config *Config) error {
return nil
}

// Must be called after ValidateStatesConfig to ensure all states are available for validation
func ValidateUIConfig(config *Config) error {
if config.UI == nil {
config.UI = ui.GetDefaultConfig()
Expand All @@ -458,6 +466,21 @@ func ValidateUIConfig(config *Config) error {
return err
}
}

// Validate all states configured have a corresponding UI color configured
// TODO#227 Add tests
stateColorMap := config.UI.StateColors
colorsMissing := []string{}
for _, state := range config.States {
if _, exists := stateColorMap[state.Name]; !exists {
colorsMissing = append(colorsMissing, state.Name)
}
}
if len(colorsMissing) > 0 {
return fmt.Errorf("no colors configured for states: %s", strings.Join(colorsMissing, ", "))
} else {
logr.Debugf("[config.ValidateUIConfig] Configured colors for all %d state(s)", len(config.States))
}
return nil
}

Expand All @@ -470,9 +493,61 @@ func ValidateWebConfig(config *Config) error {
return nil
}

func ValidateStatesConfig(config *Config) error { // TODO#227 Add tests
if config.States == nil {
logr.Info("[config.ValidateStatesConfig] No custom states configured, using defaults")
config.States = state.GetDefaultConfig()
logr.Debugf("[config.ValidateStatesConfig] Inserted %d default state(s)", len(config.States))
return nil
}

// Insert default states if they are missing
defaultStates := state.GetDefaultConfig()
for _, defaultState := range defaultStates {
found := false
for _, customState := range config.States {
if customState.Name == defaultState.Name {
found = true
break
}
}
if !found {
logr.Debugf("[config.ValidateStatesConfig] Inserting default state into config: %s", defaultState.Name)
config.States = append(config.States, defaultState)
}
}

// Validate custom states
stateNames := make(map[string]bool)
statePriorities := make(map[int]string)
for _, state := range config.States {
// Check for duplicate state names
if stateNames[state.Name] {
return fmt.Errorf("duplicate state name: %s", state.Name)
}
stateNames[state.Name] = true

// Check for duplicate state priorities
if existingState, exists := statePriorities[state.Priority]; exists {
return fmt.Errorf("priority of state '%s' (%d) conflicts with state '%s'", state.Name, state.Priority, existingState)
}
statePriorities[state.Priority] = state.Name

// Validate the state configuration
if err := state.ValidateAndSetDefaults(); err != nil {
return fmt.Errorf("invalid state '%s': %w", state.Name, err)
}
}
logr.Infof("[config.ValidateStatesConfig] Validated %d state(s) (%d custom)", len(config.States), len(config.States)-len(defaultStates))
return nil
}

func ValidateEndpointsConfig(config *Config) error {
duplicateValidationMap := make(map[string]bool)
// Set state configuration for all endpoints
endpoint.SetStateConfig(config.States)

// Validate endpoints
duplicateValidationMap := make(map[string]bool)
for _, ep := range config.Endpoints {
logr.Debugf("[config.ValidateEndpointsConfig] Validating endpoint with key %s", ep.Key())
if endpointKey := ep.Key(); duplicateValidationMap[endpointKey] {
Expand All @@ -485,6 +560,7 @@ func ValidateEndpointsConfig(config *Config) error {
}
}
logr.Infof("[config.ValidateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))

// Validate external endpoints
for _, ee := range config.ExternalEndpoints {
logr.Debugf("[config.ValidateEndpointsConfig] Validating external endpoint '%s'", ee.Key())
Expand All @@ -497,6 +573,7 @@ func ValidateEndpointsConfig(config *Config) error {
return fmt.Errorf("invalid external endpoint %s: %w", ee.Key(), err)
}
}

logr.Infof("[config.ValidateEndpointsConfig] Validated %d external endpoints", len(config.ExternalEndpoints))
return nil
}
Expand Down
18 changes: 16 additions & 2 deletions config/endpoint/condition.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"time"

"github.com/TwiN/gatus/v5/config/gontext"
"github.com/TwiN/gatus/v5/config/state"
"github.com/TwiN/gatus/v5/pattern"
)

Expand All @@ -24,7 +25,7 @@ const (
type Condition string

// Validate checks if the Condition is valid
func (c Condition) Validate() error {
func (c Condition) Validate() error { // TODO#227 Validate conditions with linked states have valid states or just default to error state then?
r := &Result{}
c.evaluate(r, false, nil)
if len(r.Errors) != 0 {
Expand All @@ -37,6 +38,18 @@ func (c Condition) Validate() error {
func (c Condition) evaluate(result *Result, dontResolveFailedConditions bool, context *gontext.Gontext) bool {
condition := string(c)
success := false

var linkedStateName = state.DefaultUnhealthyStateName
if strings.Contains(condition, "::") {
conditionParts := strings.Split(condition, "::")
if len(conditionParts) != 2 { // TODO#227 Not sure if this makes sense. Checking that it is 2 or more should be enough. Then there is no character restriction in the remaining condition.
result.AddError(fmt.Sprintf("invalid condition: %s", condition))
return false
}
linkedStateName = conditionParts[0]
condition = conditionParts[1]
}

conditionToDisplay := condition
if strings.Contains(condition, " == ") {
parameters, resolvedParameters := sanitizeAndResolveWithContext(strings.Split(condition, " == "), result, context)
Expand Down Expand Up @@ -81,7 +94,8 @@ func (c Condition) evaluate(result *Result, dontResolveFailedConditions bool, co
if !success {
//logr.Debugf("[Condition.evaluate] Condition '%s' did not succeed because '%s' is false", condition, condition)
}
result.ConditionResults = append(result.ConditionResults, &ConditionResult{Condition: conditionToDisplay, Success: success})

result.ConditionResults = append(result.ConditionResults, &ConditionResult{Condition: conditionToDisplay, Success: success, LinkedStateName: linkedStateName})
return success
}

Expand Down
3 changes: 3 additions & 0 deletions config/endpoint/condition_result.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,7 @@ type ConditionResult struct {

// Success whether the condition was met (successful) or not (failed)
Success bool `json:"success"`

// Name of the state the condition is linked to
LinkedStateName string `json:"-"`
}
8 changes: 8 additions & 0 deletions config/endpoint/condition_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,20 @@ func TestCondition_Validate(t *testing.T) {
{condition: "[BODY].name == pat(john*)", expectedErr: nil},
{condition: "[CERTIFICATE_EXPIRATION] > 48h", expectedErr: nil},
{condition: "[DOMAIN_EXPIRATION] > 720h", expectedErr: nil},
{condition: "state::[STATUS] == 200", expectedErr: nil},
{condition: "state::[RESPONSE_TIME] < 500", expectedErr: nil},
{condition: "raw == raw", expectedErr: nil},
{condition: "[STATUS] ? 201", expectedErr: errors.New("invalid condition: [STATUS] ? 201")},
{condition: "[STATUS]==201", expectedErr: errors.New("invalid condition: [STATUS]==201")},
{condition: "[STATUS] = = 201", expectedErr: errors.New("invalid condition: [STATUS] = = 201")},
{condition: "[STATUS] ==", expectedErr: errors.New("invalid condition: [STATUS] ==")},
{condition: "[STATUS]", expectedErr: errors.New("invalid condition: [STATUS]")},
{condition: "state::", expectedErr: errors.New("invalid condition: state::")},
{condition: "state:: ", expectedErr: errors.New("invalid condition: state:: ")},
{condition: "::[RESPONSE_TIME] < 500", expectedErr: errors.New("invalid condition: ::[RESPONSE_TIME] < 500")},
{condition: " ::[RESPONSE_TIME] < 500", expectedErr: errors.New("invalid condition: ::[RESPONSE_TIME] < 500")},
{condition: "state::another::[STATUS] == 200", expectedErr: errors.New("invalid condition: state::another::[STATUS] == 200")},
{condition: "[STATUS] != 200::[RESPONSE_TIME] < 500", expectedErr: errors.New("invalid condition: [STATUS] != 200::[RESPONSE_TIME] < 500")},
// FIXME: Should return an error, but doesn't because jsonpath isn't evaluated due to body being empty in Condition.Validate()
//{condition: "len([BODY].users == 100", expectedErr: nil},
}
Expand Down
Loading