1
0
mirror of https://github.com/TwiN/gatus.git synced 2026-02-04 10:06:44 +00:00

feat(ui): New status page UI (#1198)

* feat(ui): New status page UI

* docs: Rename labels to extra-labels

* Fix domain expiration test

* feat(ui): Add ui.default-sort-by and ui.default-filter-by

* Change ui.header default value to Gatus

* Re-use EndpointCard in Details.vue as well to avoid duplicate code

* Fix flaky metrics test

* Add subtle green color to "Gatus"

* Remove duplicate title (tooltip is sufficient, no need for title on top of that)

* Fix collapsed group user preferences

* Update status page screenshots
This commit is contained in:
TwiN
2025-08-14 09:15:34 -04:00
committed by GitHub
parent 8d63462fcd
commit 440b732c71
54 changed files with 4251 additions and 2226 deletions

BIN
.github/assets/dashboard-conditions.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

BIN
.github/assets/dashboard-dark.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

BIN
.github/assets/endpoint-groups.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

BIN
.github/assets/example.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -32,7 +32,7 @@ For more details, see [Usage](#usage)
> ❤ Like this project? Please consider [sponsoring me](https://github.com/sponsors/TwiN). > ❤ Like this project? Please consider [sponsoring me](https://github.com/sponsors/TwiN).
![Gatus dashboard](.github/assets/dashboard-dark.png) ![Gatus dashboard](.github/assets/dashboard-dark.jpg)
Have any feedback or questions? [Create a discussion](https://github.com/TwiN/gatus/discussions/new). Have any feedback or questions? [Create a discussion](https://github.com/TwiN/gatus/discussions/new).
@@ -162,7 +162,7 @@ The main features of Gatus are:
- **[Badges](#badges)**: ![Uptime 7d](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/7d/badge.svg) ![Response time 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/24h/badge.svg) - **[Badges](#badges)**: ![Uptime 7d](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/7d/badge.svg) ![Response time 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/24h/badge.svg)
- **Dark mode** - **Dark mode**
![Gatus dashboard conditions](.github/assets/dashboard-conditions.png) ![Gatus dashboard conditions](.github/assets/dashboard-conditions.jpg)
## Usage ## Usage
@@ -201,7 +201,7 @@ endpoints:
This example would look similar to this: This example would look similar to this:
![Simple example](.github/assets/example.png) ![Simple example](.github/assets/example.jpg)
By default, the configuration file is expected to be at `config/config.yaml`. By default, the configuration file is expected to be at `config/config.yaml`.
@@ -222,34 +222,36 @@ If you want to test it locally, see [Docker](#docker).
## Configuration ## Configuration
| Parameter | Description | Default | | Parameter | Description | Default |
|:-----------------------------|:-------------------------------------------------------------------------------------------------------------------------------------|:---------------------------| |:-----------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------|:---------------------------|
| `metrics` | Whether to expose metrics at `/metrics`. | `false` | | `metrics` | Whether to expose metrics at `/metrics`. | `false` |
| `storage` | [Storage configuration](#storage). | `{}` | | `storage` | [Storage configuration](#storage). | `{}` |
| `alerting` | [Alerting configuration](#alerting). | `{}` | | `alerting` | [Alerting configuration](#alerting). | `{}` |
| `endpoints` | [Endpoints configuration](#endpoints). | Required `[]` | | `endpoints` | [Endpoints configuration](#endpoints). | Required `[]` |
| `external-endpoints` | [External Endpoints configuration](#external-endpoints). | `[]` | | `external-endpoints` | [External Endpoints configuration](#external-endpoints). | `[]` |
| `security` | [Security configuration](#security). | `{}` | | `security` | [Security configuration](#security). | `{}` |
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). | `false` | | `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). | `false` |
| `skip-invalid-config-update` | Whether to ignore invalid configuration update. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` | | `skip-invalid-config-update` | Whether to ignore invalid configuration update. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
| `web` | Web configuration. | `{}` | | `web` | Web configuration. | `{}` |
| `web.address` | Address to listen on. | `0.0.0.0` | | `web.address` | Address to listen on. | `0.0.0.0` |
| `web.port` | Port to listen on. | `8080` | | `web.port` | Port to listen on. | `8080` |
| `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` | | `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` |
| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `` | | `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `""` |
| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `` | | `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `""` |
| `ui` | UI configuration. | `{}` | | `ui` | UI configuration. | `{}` |
| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` | | `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` |
| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. | | `ui.description` | Meta description for the page. | `Gatus is an advanced...`. |
| `ui.header` | Header at the top of the dashboard. | `Health Status` | | `ui.header` | Header at the top of the dashboard. | `Gatus` |
| `ui.logo` | URL to the logo to display. | `""` | | `ui.logo` | URL to the logo to display. | `""` |
| `ui.link` | Link to open when the logo is clicked. | `""` | | `ui.link` | Link to open when the logo is clicked. | `""` |
| `ui.buttons` | List of buttons to display below the header. | `[]` | | `ui.buttons` | List of buttons to display below the header. | `[]` |
| `ui.buttons[].name` | Text to display on the button. | Required `""` | | `ui.buttons[].name` | Text to display on the button. | Required `""` |
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` | | `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` |
| `ui.custom-css` | Custom CSS | `""` | | `ui.custom-css` | Custom CSS | `""` |
| `ui.dark-mode` | Whether to enable dark mode by default. Note that this is superseded by the user's operating system theme preferences. | `true` | | `ui.dark-mode` | Whether to enable dark mode by default. Note that this is superseded by the user's operating system theme preferences. | `true` |
| `maintenance` | [Maintenance configuration](#maintenance). | `{}` | | `ui.default-sort-by` | Default sorting option for endpoints in the dashboard. Can be `name`, `group`, or `health`. Note that user preferences override this. | `name` |
| `ui.default-filter-by` | Default filter option for endpoints in the dashboard. Can be `nothing`, `failing`, or `unstable`. Note that user preferences override this. | `nothing` |
| `maintenance` | [Maintenance configuration](#maintenance). | `{}` |
If you want more verbose logging, you may set the `GATUS_LOG_LEVEL` environment variable to `DEBUG`. If you want more verbose logging, you may set the `GATUS_LOG_LEVEL` environment variable to `DEBUG`.
Conversely, if you want less verbose logging, you can set the aforementioned environment variable to `WARN`, `ERROR` or `FATAL`. Conversely, if you want less verbose logging, you can set the aforementioned environment variable to `WARN`, `ERROR` or `FATAL`.
@@ -289,6 +291,7 @@ You can then configure alerts to be triggered when an endpoint is unhealthy once
| `endpoints[].ui.hide-url` | Whether to hide the URL from the results. Useful if the URL contains a token. | `false` | | `endpoints[].ui.hide-url` | Whether to hide the URL from the results. Useful if the URL contains a token. | `false` |
| `endpoints[].ui.dont-resolve-failed-conditions` | Whether to resolve failed conditions for the UI. | `false` | | `endpoints[].ui.dont-resolve-failed-conditions` | Whether to resolve failed conditions for the UI. | `false` |
| `endpoints[].ui.badge.response-time` | List of response time thresholds. Each time a threshold is reached, the badge has a different color. | `[50, 200, 300, 500, 750]` | | `endpoints[].ui.badge.response-time` | List of response time thresholds. Each time a threshold is reached, the badge has a different color. | `[50, 200, 300, 500, 750]` |
| `endpoints[].extra-labels` | Extra labels to add to the metrics. Useful for grouping endpoints together. | `{}` |
You may use the following placeholders in the body (`endpoints[].body`): You may use the following placeholders in the body (`endpoints[].body`):
- `[ENDPOINT_NAME]` (resolved from `endpoints[].name`) - `[ENDPOINT_NAME]` (resolved from `endpoints[].name`)
@@ -1966,7 +1969,7 @@ endpoints:
- "[STATUS] == 200" - "[STATUS] == 200"
- "[BODY].status == UP" - "[BODY].status == UP"
- "[RESPONSE_TIME] < 150" - "[RESPONSE_TIME] < 150"
labels: extra-labels:
environment: staging environment: staging
``` ```
@@ -2431,9 +2434,9 @@ endpoints:
- "[STATUS] == 200" - "[STATUS] == 200"
``` ```
The configuration above will result in a dashboard that looks like this: The configuration above will result in a dashboard that looks like this when sorting by group:
![Gatus Endpoint Groups](.github/assets/endpoint-groups.png) ![Gatus Endpoint Groups](.github/assets/endpoint-groups.jpg)
### Exposing Gatus on a custom path ### Exposing Gatus on a custom path

View File

@@ -9,7 +9,6 @@ import (
"math/rand" "math/rand"
"net/http" "net/http"
"net/url" "net/url"
"time"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
@@ -203,7 +202,6 @@ func randStringBytes(n int) string {
// All the compatible characters to use in a transaction ID // All the compatible characters to use in a transaction ID
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n) b := make([]byte, n)
rand.Seed(time.Now().UnixNano())
for i := range b { for i := range b {
b[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))] b[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))]
} }

View File

@@ -41,26 +41,26 @@ func TestGetHTTPClient(t *testing.T) {
func TestGetDomainExpiration(t *testing.T) { func TestGetDomainExpiration(t *testing.T) {
t.Parallel() t.Parallel()
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil { if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
t.Fatalf("expected error to be nil, but got: `%s`", err) t.Fatalf("expected error to be nil, but got: `%s`", err)
} else if domainExpiration <= 0 { } else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0") t.Error("expected domain expiration to be higher than 0")
} }
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil { if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
t.Errorf("expected error to be nil, but got: `%s`", err) t.Errorf("expected error to be nil, but got: `%s`", err)
} else if domainExpiration <= 0 { } else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0") t.Error("expected domain expiration to be higher than 0")
} }
// Hack to pretend like the domain is expiring in 1 hour, which should trigger a refresh // Hack to pretend like the domain is expiring in 1 hour, which should trigger a refresh
whoisExpirationDateCache.SetWithTTL("example.com", time.Now().Add(time.Hour), 25*time.Hour) whoisExpirationDateCache.SetWithTTL("gatus.io", time.Now().Add(time.Hour), 25*time.Hour)
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil { if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
t.Errorf("expected error to be nil, but got: `%s`", err) t.Errorf("expected error to be nil, but got: `%s`", err)
} else if domainExpiration <= 0 { } else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0") t.Error("expected domain expiration to be higher than 0")
} }
// Make sure the refresh works when the ttl is <24 hours // Make sure the refresh works when the ttl is <24 hours
whoisExpirationDateCache.SetWithTTL("example.com", time.Now().Add(35*time.Hour), 23*time.Hour) whoisExpirationDateCache.SetWithTTL("gatus.io", time.Now().Add(35*time.Hour), 23*time.Hour)
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil { if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
t.Errorf("expected error to be nil, but got: `%s`", err) t.Errorf("expected error to be nil, but got: `%s`", err)
} else if domainExpiration <= 0 { } else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0") t.Error("expected domain expiration to be higher than 0")

View File

@@ -37,7 +37,7 @@ endpoints:
query-name: "example.com" query-name: "example.com"
query-type: "A" query-type: "A"
conditions: conditions:
- "[BODY] == 93.184.215.14" - "[BODY] == pat(*.*.*.*)" # Matches any IPv4 address
- "[DNS_RCODE] == NOERROR" - "[DNS_RCODE] == NOERROR"
- name: icmp-ping - name: icmp-ping

View File

@@ -6,6 +6,7 @@ import (
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"time" "time"
@@ -118,6 +119,9 @@ func (config *Config) GetUniqueExtraMetricLabels() []string {
labels = append(labels, label) labels = append(labels, label)
} }
} }
if len(labels) > 1 {
sort.Strings(labels)
}
return labels return labels
} }

View File

@@ -12,28 +12,34 @@ import (
const ( const (
defaultTitle = "Health Dashboard | Gatus" defaultTitle = "Health Dashboard | Gatus"
defaultDescription = "Gatus is an advanced automated status page that lets you monitor your applications and configure alerts to notify you if there's an issue" defaultDescription = "Gatus is an advanced automated status page that lets you monitor your applications and configure alerts to notify you if there's an issue"
defaultHeader = "Health Status" defaultHeader = "Gatus"
defaultLogo = "" defaultLogo = ""
defaultLink = "" defaultLink = ""
defaultCustomCSS = "" defaultCustomCSS = ""
defaultSortBy = "name"
defaultFilterBy = "nothing"
) )
var ( var (
defaultDarkMode = true defaultDarkMode = true
ErrButtonValidationFailed = errors.New("invalid button configuration: missing required name or link") ErrButtonValidationFailed = errors.New("invalid button configuration: missing required name or link")
ErrInvalidDefaultSortBy = errors.New("invalid default-sort-by value: must be 'name', 'group', or 'health'")
ErrInvalidDefaultFilterBy = errors.New("invalid default-filter-by value: must be 'nothing', 'failing', or 'unstable'")
) )
// Config is the configuration for the UI of Gatus // Config is the configuration for the UI of Gatus
type Config struct { type Config struct {
Title string `yaml:"title,omitempty"` // Title of the page Title string `yaml:"title,omitempty"` // Title of the page
Description string `yaml:"description,omitempty"` // Meta description of the page Description string `yaml:"description,omitempty"` // Meta description of the page
Header string `yaml:"header,omitempty"` // Header is the text at the top of the page Header string `yaml:"header,omitempty"` // Header is the text at the top of the page
Logo string `yaml:"logo,omitempty"` // Logo to display on the page Logo string `yaml:"logo,omitempty"` // Logo to display on the page
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
CustomCSS string `yaml:"custom-css,omitempty"` // Custom CSS to include in the page CustomCSS string `yaml:"custom-css,omitempty"` // Custom CSS to include in the page
DarkMode *bool `yaml:"dark-mode,omitempty"` // DarkMode is a flag to enable dark mode by default DarkMode *bool `yaml:"dark-mode,omitempty"` // DarkMode is a flag to enable dark mode by default
DefaultSortBy string `yaml:"default-sort-by,omitempty"` // DefaultSortBy is the default sort option ('name', 'group', 'health')
DefaultFilterBy string `yaml:"default-filter-by,omitempty"` // DefaultFilterBy is the default filter option ('nothing', 'failing', 'unstable')
////////////////////////////////////////////// //////////////////////////////////////////////
// Non-configurable - used for UI rendering // // Non-configurable - used for UI rendering //
@@ -72,6 +78,8 @@ func GetDefaultConfig() *Config {
Link: defaultLink, Link: defaultLink,
CustomCSS: defaultCustomCSS, CustomCSS: defaultCustomCSS,
DarkMode: &defaultDarkMode, DarkMode: &defaultDarkMode,
DefaultSortBy: defaultSortBy,
DefaultFilterBy: defaultFilterBy,
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults, MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
} }
} }
@@ -99,6 +107,16 @@ func (cfg *Config) ValidateAndSetDefaults() error {
if cfg.DarkMode == nil { if cfg.DarkMode == nil {
cfg.DarkMode = &defaultDarkMode cfg.DarkMode = &defaultDarkMode
} }
if len(cfg.DefaultSortBy) == 0 {
cfg.DefaultSortBy = defaultSortBy
} else if cfg.DefaultSortBy != "name" && cfg.DefaultSortBy != "group" && cfg.DefaultSortBy != "health" {
return ErrInvalidDefaultSortBy
}
if len(cfg.DefaultFilterBy) == 0 {
cfg.DefaultFilterBy = defaultFilterBy
} else if cfg.DefaultFilterBy != "nothing" && cfg.DefaultFilterBy != "failing" && cfg.DefaultFilterBy != "unstable" {
return ErrInvalidDefaultFilterBy
}
for _, btn := range cfg.Buttons { for _, btn := range cfg.Buttons {
if err := btn.Validate(); err != nil { if err := btn.Validate(); err != nil {
return err return err

View File

@@ -1,6 +1,7 @@
package ui package ui
import ( import (
"errors"
"strconv" "strconv"
"testing" "testing"
) )
@@ -25,6 +26,12 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
if cfg.Header != defaultHeader { if cfg.Header != defaultHeader {
t.Errorf("expected header to be %s, got %s", defaultHeader, cfg.Header) t.Errorf("expected header to be %s, got %s", defaultHeader, cfg.Header)
} }
if cfg.DefaultSortBy != defaultSortBy {
t.Errorf("expected defaultSortBy to be %s, got %s", defaultSortBy, cfg.DefaultSortBy)
}
if cfg.DefaultFilterBy != defaultFilterBy {
t.Errorf("expected defaultFilterBy to be %s, got %s", defaultFilterBy, cfg.DefaultFilterBy)
}
} }
func TestButton_Validate(t *testing.T) { func TestButton_Validate(t *testing.T) {
@@ -74,4 +81,114 @@ func TestGetDefaultConfig(t *testing.T) {
if defaultConfig.Logo != defaultLogo { if defaultConfig.Logo != defaultLogo {
t.Error("expected GetDefaultConfig() to return defaultLogo, got", defaultConfig.Logo) t.Error("expected GetDefaultConfig() to return defaultLogo, got", defaultConfig.Logo)
} }
if defaultConfig.DefaultSortBy != defaultSortBy {
t.Error("expected GetDefaultConfig() to return defaultSortBy, got", defaultConfig.DefaultSortBy)
}
if defaultConfig.DefaultFilterBy != defaultFilterBy {
t.Error("expected GetDefaultConfig() to return defaultFilterBy, got", defaultConfig.DefaultFilterBy)
}
}
func TestConfig_ValidateAndSetDefaults_DefaultSortBy(t *testing.T) {
scenarios := []struct {
Name string
DefaultSortBy string
ExpectedError error
ExpectedValue string
}{
{
Name: "EmptyDefaultSortBy",
DefaultSortBy: "",
ExpectedError: nil,
ExpectedValue: defaultSortBy,
},
{
Name: "ValidDefaultSortBy_name",
DefaultSortBy: "name",
ExpectedError: nil,
ExpectedValue: "name",
},
{
Name: "ValidDefaultSortBy_group",
DefaultSortBy: "group",
ExpectedError: nil,
ExpectedValue: "group",
},
{
Name: "ValidDefaultSortBy_health",
DefaultSortBy: "health",
ExpectedError: nil,
ExpectedValue: "health",
},
{
Name: "InvalidDefaultSortBy",
DefaultSortBy: "invalid",
ExpectedError: ErrInvalidDefaultSortBy,
ExpectedValue: "invalid",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg := &Config{DefaultSortBy: scenario.DefaultSortBy}
err := cfg.ValidateAndSetDefaults()
if !errors.Is(err, scenario.ExpectedError) {
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
}
if cfg.DefaultSortBy != scenario.ExpectedValue {
t.Errorf("expected DefaultSortBy to be %s, got %s", scenario.ExpectedValue, cfg.DefaultSortBy)
}
})
}
}
func TestConfig_ValidateAndSetDefaults_DefaultFilterBy(t *testing.T) {
scenarios := []struct {
Name string
DefaultFilterBy string
ExpectedError error
ExpectedValue string
}{
{
Name: "EmptyDefaultFilterBy",
DefaultFilterBy: "",
ExpectedError: nil,
ExpectedValue: defaultFilterBy,
},
{
Name: "ValidDefaultFilterBy_nothing",
DefaultFilterBy: "nothing",
ExpectedError: nil,
ExpectedValue: "nothing",
},
{
Name: "ValidDefaultFilterBy_failing",
DefaultFilterBy: "failing",
ExpectedError: nil,
ExpectedValue: "failing",
},
{
Name: "ValidDefaultFilterBy_unstable",
DefaultFilterBy: "unstable",
ExpectedError: nil,
ExpectedValue: "unstable",
},
{
Name: "InvalidDefaultFilterBy",
DefaultFilterBy: "invalid",
ExpectedError: ErrInvalidDefaultFilterBy,
ExpectedValue: "invalid",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg := &Config{DefaultFilterBy: scenario.DefaultFilterBy}
err := cfg.ValidateAndSetDefaults()
if !errors.Is(err, scenario.ExpectedError) {
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
}
if cfg.DefaultFilterBy != scenario.ExpectedValue {
t.Errorf("expected DefaultFilterBy to be %s, got %s", scenario.ExpectedValue, cfg.DefaultFilterBy)
}
})
}
} }

View File

@@ -63,7 +63,7 @@ func TestInitializePrometheusMetrics(t *testing.T) {
func TestPublishMetricsForEndpoint_withExtraLabels(t *testing.T) { func TestPublishMetricsForEndpoint_withExtraLabels(t *testing.T) {
// Only test one label set per process due to Prometheus registry limits. // Only test one label set per process due to Prometheus registry limits.
reg := prometheus.NewRegistry() reg := prometheus.NewRegistry()
InitializePrometheusMetrics(&config.Config{ cfg := &config.Config{
Endpoints: []*endpoint.Endpoint{ Endpoints: []*endpoint.Endpoint{
{ {
Name: "ep-extra", Name: "ep-extra",
@@ -74,7 +74,8 @@ func TestPublishMetricsForEndpoint_withExtraLabels(t *testing.T) {
}, },
}, },
}, },
}, reg) }
InitializePrometheusMetrics(cfg, reg)
ep := &endpoint.Endpoint{ ep := &endpoint.Endpoint{
Name: "ep-extra", Name: "ep-extra",
@@ -91,8 +92,9 @@ func TestPublishMetricsForEndpoint_withExtraLabels(t *testing.T) {
Duration: 2340 * time.Millisecond, Duration: 2340 * time.Millisecond,
Success: true, Success: true,
} }
// Order of extraLabels as per GetUniqueExtraMetricLabels is ["foo", "bar"] // Get labels in sorted order as per GetUniqueExtraMetricLabels
PublishMetricsForEndpoint(ep, result, []string{"foo", "bar"}) extraLabels := cfg.GetUniqueExtraMetricLabels()
PublishMetricsForEndpoint(ep, result, extraLabels)
expected := ` expected := `
# HELP gatus_results_total Number of results per endpoint # HELP gatus_results_total Number of results per endpoint

2911
web/app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,23 +8,25 @@
"lint": "vue-cli-service lint" "lint": "vue-cli-service lint"
}, },
"dependencies": { "dependencies": {
"@headlessui/vue": "^1.7.3", "class-variance-authority": "^0.7.1",
"@heroicons/vue": "^2.0.12", "clsx": "^2.1.1",
"core-js": "3.22.8", "core-js": "^3.45.0",
"vue": "3.2.37", "lucide-vue-next": "^0.539.0",
"vue-router": "4.0.16" "tailwind-merge": "^3.3.1",
"vue": "^3.5.18",
"vue-router": "^4.5.1"
}, },
"devDependencies": { "devDependencies": {
"@vue/cli-plugin-babel": "5.0.4", "@vue/cli-plugin-babel": "^5.0.8",
"@vue/cli-plugin-eslint": "5.0.4", "@vue/cli-plugin-eslint": "^5.0.8",
"@vue/cli-plugin-router": "5.0.4", "@vue/cli-plugin-router": "^5.0.8",
"@vue/cli-service": "5.0.4", "@vue/cli-service": "^5.0.8",
"@vue/compiler-sfc": "3.2.37", "@vue/compiler-sfc": "^3.5.18",
"autoprefixer": "10.4.7", "autoprefixer": "^10.4.21",
"babel-eslint": "10.1.0", "@babel/eslint-parser": "^7.25.1",
"eslint": "7.32.0", "eslint": "^8.57.1",
"eslint-plugin-vue": "7.20.0", "eslint-plugin-vue": "^9.28.0",
"postcss": "8.4.14", "postcss": "^8.5.6",
"tailwindcss": "^3.1.8" "tailwindcss": "^3.1.8"
}, },
"eslintConfig": { "eslintConfig": {
@@ -37,9 +39,20 @@
"eslint:recommended" "eslint:recommended"
], ],
"parserOptions": { "parserOptions": {
"parser": "babel-eslint" "parser": "@babel/eslint-parser",
"requireConfigFile": false
}, },
"rules": {} "rules": {
"vue/multi-word-component-names": ["error", {
"ignores": ["Home", "Details", "Loading", "Settings", "Social", "Tooltip", "Pagination", "Button", "Badge", "Card", "Input", "Select"]
}]
},
"globals": {
"defineProps": "readonly",
"defineEmits": "readonly",
"defineExpose": "readonly",
"withDefaults": "readonly"
}
}, },
"browserslist": [ "browserslist": [
"defaults", "defaults",

View File

@@ -3,7 +3,17 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<script type="text/javascript"> <script type="text/javascript">
window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", link: "{{ .UI.Link }}", buttons: [], maximumNumberOfResults: "{{ .UI.MaximumNumberOfResults }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}} window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", link: "{{ .UI.Link }}", buttons: [], maximumNumberOfResults: "{{ .UI.MaximumNumberOfResults }}", defaultSortBy: "{{ .UI.DefaultSortBy }}", defaultFilterBy: "{{ .UI.DefaultFilterBy }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}
// Initialize theme immediately to prevent flash
(function() {
const themeFromCookie = document.cookie.match(/theme=(dark|light);?/)?.[1];
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (themeFromCookie === 'dark' || (!themeFromCookie && prefersDark)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
})();
</script> </script>
<title>{{ .UI.Title }}</title> <title>{{ .UI.Title }}</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
@@ -20,7 +30,7 @@
<meta name="application-name" content="{{ .UI.Title }}" /> <meta name="application-name" content="{{ .UI.Title }}" />
<meta name="theme-color" content="#f7f9fb" /> <meta name="theme-color" content="#f7f9fb" />
</head> </head>
<body class="dark:bg-gray-900"> <body>
<noscript><strong>Enable JavaScript to view this page.</strong></noscript> <noscript><strong>Enable JavaScript to view this page.</strong></noscript>
<div id="app"></div> <div id="app"></div>
</body> </body>

View File

@@ -1,106 +1,217 @@
<template> <template>
<Loading v-if="!retrievedConfig" class="h-64 w-64 px-4" /> <div id="global" class="bg-background text-foreground">
<div v-else :class="[config && config.oidc && !config.authenticated ? 'hidden' : '', 'container container-xs relative mx-auto xl:rounded xl:border xl:shadow-xl xl:my-5 p-5 pb-12 xl:pb-5 text-left dark:bg-gray-800 dark:text-gray-200 dark:border-gray-500']" id="global"> <!-- Loading State -->
<div class="mb-2"> <div v-if="!retrievedConfig" class="flex items-center justify-center min-h-screen">
<div class="flex flex-wrap"> <Loading size="lg" />
<div class="w-3/4 text-left my-auto">
<div class="text-3xl xl:text-5xl lg:text-4xl font-light">{{ header }}</div>
</div>
<div class="w-1/4 flex justify-end">
<component :is="link ? 'a' : 'div'" :href="link" target="_blank" class="flex items-center justify-center" style="width:100px;min-height:100px;">
<img v-if="logo" :src="logo" alt="Gatus" class="object-scale-down" style="max-width:100px;min-width:50px;min-height:50px;" />
<img v-else src="./assets/logo.svg" alt="Gatus" class="object-scale-down" style="max-width:100px;min-width:50px;min-height:50px;" />
</component>
</div>
</div>
<div v-if="buttons" class="flex flex-wrap">
<a v-for="button in buttons" :key="button.name" :href="button.link" target="_blank" class="px-2 py-0.5 font-medium select-none text-gray-600 hover:text-gray-500 dark:text-gray-300 dark:hover:text-gray-400 hover:underline">
{{ button.name }}
</a>
</div>
</div> </div>
<router-view @showTooltip="showTooltip" />
</div>
<div v-if="config && config.oidc && !config.authenticated" class="mx-auto max-w-md pt-12"> <!-- Main App Container -->
<img src="./assets/logo.svg" alt="Gatus" class="mx-auto" style="max-width:160px; min-width:50px; min-height:50px;"/> <div v-else-if="!config || !config.oidc || config.authenticated" class="relative">
<h2 class="mt-4 text-center text-4xl font-extrabold text-gray-800 dark:text-gray-200"> <!-- Header -->
Gatus <header class="border-b bg-card/50 backdrop-blur supports-[backdrop-filter]:bg-card/60">
</h2> <div class="container mx-auto px-4 py-4 max-w-7xl">
<div class="py-7 px-4 rounded-sm sm:px-10"> <div class="flex items-center justify-between">
<div v-if="$route && $route.query.error" class="text-red-500 text-center mb-5"> <!-- Logo and Title -->
<div class="text-sm"> <div class="flex items-center gap-4">
<span class="text-red-500" v-if="$route.query.error === 'access_denied'">You do not have access to this status page</span> <component
<span class="text-red-500" v-else>{{ $route.query.error }}</span> :is="link ? 'a' : 'div'"
:href="link"
target="_blank"
class="flex items-center gap-3 hover:opacity-80 transition-opacity"
>
<div class="w-12 h-12 flex items-center justify-center">
<img
v-if="logo"
:src="logo"
alt="Gatus"
class="w-full h-full object-contain"
/>
<img
v-else
src="./assets/logo.svg"
alt="Gatus"
class="w-full h-full object-contain"
/>
</div>
<div>
<h1 class="text-2xl font-bold tracking-tight">{{ header }}</h1>
<p v-if="buttons && buttons.length" class="text-sm text-muted-foreground">
System Monitoring Dashboard
</p>
</div>
</component>
</div>
<!-- Right Side Actions -->
<div class="flex items-center gap-2">
<!-- Navigation Links (Desktop) -->
<nav v-if="buttons && buttons.length" class="hidden md:flex items-center gap-1">
<a
v-for="button in buttons"
:key="button.name"
:href="button.link"
target="_blank"
class="px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors"
>
{{ button.name }}
</a>
</nav>
<!-- Mobile Menu Button -->
<Button
v-if="buttons && buttons.length"
variant="ghost"
size="icon"
class="md:hidden"
@click="mobileMenuOpen = !mobileMenuOpen"
>
<Menu v-if="!mobileMenuOpen" class="h-5 w-5" />
<X v-else class="h-5 w-5" />
</Button>
</div>
</div>
<!-- Mobile Navigation -->
<nav
v-if="buttons && buttons.length && mobileMenuOpen"
class="md:hidden mt-4 pt-4 border-t space-y-1"
>
<a
v-for="button in buttons"
:key="button.name"
:href="button.link"
target="_blank"
class="block px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors"
@click="mobileMenuOpen = false"
>
{{ button.name }}
</a>
</nav>
</div> </div>
</div> </header>
<div>
<a :href="`${SERVER_URL}/oidc/login`" class="max-w-lg mx-auto w-full flex justify-center py-3 px-4 border border-green-800 rounded-md shadow-lg text-sm text-white bg-green-700 bg-gradient-to-r from-green-600 to-green-700 hover:from-green-700 hover:to-green-800">
Login with OIDC
</a>
</div>
</div>
</div>
<Tooltip :result="tooltip.result" :event="tooltip.event"/> <!-- Main Content -->
<Social/> <main class="relative">
<router-view @showTooltip="showTooltip" />
</main>
<!-- Footer -->
<footer class="border-t mt-auto">
<div class="container mx-auto px-4 py-6 max-w-7xl">
<div class="flex flex-col items-center gap-4">
<div class="text-sm text-muted-foreground text-center">
Powered by <a href="https://gatus.io" target="_blank" class="font-medium text-emerald-800 hover:text-emerald-600">Gatus</a>
</div>
<Social />
</div>
</div>
</footer>
</div>
<!-- OIDC Login Screen -->
<div v-else id="login-container" class="flex items-center justify-center min-h-screen p-4">
<Card class="w-full max-w-md">
<CardHeader class="text-center">
<img
src="./assets/logo.svg"
alt="Gatus"
class="w-20 h-20 mx-auto mb-4"
/>
<CardTitle class="text-3xl">Gatus</CardTitle>
<p class="text-muted-foreground mt-2">System Monitoring Dashboard</p>
</CardHeader>
<CardContent>
<div v-if="route && route.query.error" class="mb-6">
<div class="p-3 rounded-md bg-destructive/10 border border-destructive/20">
<p class="text-sm text-destructive text-center">
<span v-if="route.query.error === 'access_denied'">
You do not have access to this status page
</span>
<span v-else>{{ route.query.error }}</span>
</p>
</div>
</div>
<a
:href="`${SERVER_URL}/oidc/login`"
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-11 px-8 w-full"
@click="isOidcLoading = true"
>
<Loading v-if="isOidcLoading" size="xs" />
<template v-else>
<LogIn class="mr-2 h-4 w-4" />
Login with OIDC
</template>
</a>
</CardContent>
</Card>
</div>
<!-- Tooltip -->
<Tooltip :result="tooltip.result" :event="tooltip.event" />
</div>
</template> </template>
<script setup>
<script> /* eslint-disable no-undef */
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { Menu, X, LogIn } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import Social from './components/Social.vue' import Social from './components/Social.vue'
import Tooltip from './components/Tooltip.vue'; import Tooltip from './components/Tooltip.vue'
import {SERVER_URL} from "@/main"; import Loading from './components/Loading.vue'
import Loading from "@/components/Loading"; import { SERVER_URL } from '@/main'
export default { const route = useRoute()
name: 'App',
components: { // State
Loading, const retrievedConfig = ref(false)
Social, const config = ref({ oidc: false, authenticated: true })
Tooltip const tooltip = ref({})
}, const mobileMenuOpen = ref(false)
methods: { const isOidcLoading = ref(false)
fetchConfig() {
fetch(`${SERVER_URL}/api/v1/config`, {credentials: 'include'}) // Computed properties
.then(response => { const logo = computed(() => {
this.retrievedConfig = true; return window.config && window.config.logo && window.config.logo !== '{{ .UI.Logo }}' ? window.config.logo : ""
if (response.status === 200) { })
response.json().then(data => {
this.config = data; const header = computed(() => {
}) return window.config && window.config.header && window.config.header !== '{{ .UI.Header }}' ? window.config.header : "Gatus"
} })
});
}, const link = computed(() => {
showTooltip(result, event) { return window.config && window.config.link && window.config.link !== '{{ .UI.Link }}' ? window.config.link : null
this.tooltip = {result: result, event: event}; })
const buttons = computed(() => {
return window.config && window.config.buttons ? window.config.buttons : []
})
// Methods
const fetchConfig = async () => {
try {
const response = await fetch(`${SERVER_URL}/api/v1/config`, { credentials: 'include' })
retrievedConfig.value = true
if (response.status === 200) {
const data = await response.json()
config.value = data
} }
}, } catch (error) {
computed: { console.error('Failed to fetch config:', error)
logo() { retrievedConfig.value = true
return window.config && window.config.logo && window.config.logo !== '{{ .UI.Logo }}' ? window.config.logo : "";
},
header() {
return window.config && window.config.header && window.config.header !== '{{ .UI.Header }}' ? window.config.header : "Health Status";
},
link() {
return window.config && window.config.link && window.config.link !== '{{ .UI.Link }}' ? window.config.link : null;
},
buttons() {
return window.config && window.config.buttons ? window.config.buttons : [];
}
},
data() {
return {
error: '',
retrievedConfig: false,
config: { oidc: false, authenticated: true },
tooltip: {},
SERVER_URL
}
},
created() {
this.fetchConfig();
} }
} }
const showTooltip = (result, event) => {
tooltip.value = { result, event }
}
// Fetch config on mount
onMounted(() => {
fetchConfig()
})
</script> </script>

View File

@@ -1,186 +0,0 @@
<template>
<div class='endpoint px-3 py-3 border-l border-r border-t rounded-none hover:bg-gray-100 dark:hover:bg-gray-700 dark:border-gray-500' v-if="data">
<div class='flex flex-wrap mb-2'>
<div class='w-3/4'>
<router-link :to="generatePath()" class="font-bold hover:text-blue-800 hover:underline dark:hover:text-blue-400" title="View detailed endpoint health">
{{ data.name }}
</router-link>
<span v-if="data.results && data.results.length && data.results[data.results.length - 1].hostname" class='text-gray-500 font-light'> | {{ data.results[data.results.length - 1].hostname }}</span>
</div>
<div class='w-1/4 text-right'>
<span class='font-light overflow-x-hidden cursor-pointer select-none hover:text-gray-500' v-if="data.results && data.results.length" @click="toggleShowAverageResponseTime" :title="showAverageResponseTime ? 'Average response time' : 'Minimum and maximum response time'">
<slot v-if="showAverageResponseTime">
~{{ averageResponseTime }}ms
</slot>
<slot v-else>
{{ (minResponseTime === maxResponseTime ? minResponseTime : (minResponseTime + '-' + maxResponseTime)) }}ms
</slot>
</span>
<!-- <span class="text-sm font-bold cursor-pointer">-->
<!-- -->
<!-- </span>-->
</div>
</div>
<div>
<div class='status-over-time flex flex-row'>
<slot v-if="data.results && data.results.length">
<slot v-if="data.results.length < maximumNumberOfResults">
<span v-for="filler in maximumNumberOfResults - data.results.length" :key="filler" class="status rounded border border-dashed border-gray-400">&nbsp;</span>
</slot>
<slot v-for="result in data.results" :key="result">
<span v-if="result.success" class="status status-success rounded bg-success" @mouseenter="showTooltip(result, $event)" @mouseleave="showTooltip(null, $event)"></span>
<span v-else class="status status-failure rounded bg-red-600" @mouseenter="showTooltip(result, $event)" @mouseleave="showTooltip(null, $event)"></span>
</slot>
</slot>
<slot v-else>
<span v-for="filler in maximumNumberOfResults" :key="filler" class="status rounded border border-dashed border-gray-400">&nbsp;</span>
</slot>
</div>
</div>
<div class='flex flex-wrap status-time-ago'>
<slot v-if="data.results && data.results.length">
<div class='w-1/2'>
{{ generatePrettyTimeAgo(data.results[0].timestamp) }}
</div>
<div class='w-1/2 text-right'>
{{ generatePrettyTimeAgo(data.results[data.results.length - 1].timestamp) }}
</div>
</slot>
<slot v-else>
<div class='w-1/2'>
&nbsp;
</div>
</slot>
</div>
</div>
</template>
<script>
import {helper} from "@/mixins/helper";
export default {
name: 'Endpoint',
props: {
maximumNumberOfResults: Number,
data: Object,
showAverageResponseTime: Boolean
},
emits: ['showTooltip', 'toggleShowAverageResponseTime'],
mixins: [helper],
methods: {
updateMinAndMaxResponseTimes() {
let minResponseTime = null;
let maxResponseTime = null;
let totalResponseTime = 0;
for (let i in this.data.results) {
const responseTime = parseInt((this.data.results[i].duration/1000000).toFixed(0));
totalResponseTime += responseTime;
if (minResponseTime == null || minResponseTime > responseTime) {
minResponseTime = responseTime;
}
if (maxResponseTime == null || maxResponseTime < responseTime) {
maxResponseTime = responseTime;
}
}
if (this.minResponseTime !== minResponseTime) {
this.minResponseTime = minResponseTime;
}
if (this.maxResponseTime !== maxResponseTime) {
this.maxResponseTime = maxResponseTime;
}
if (this.data.results && this.data.results.length) {
this.averageResponseTime = (totalResponseTime/this.data.results.length).toFixed(0);
}
},
generatePath() {
if (!this.data) {
return '/';
}
return `/endpoints/${this.data.key}`;
},
showTooltip(result, event) {
this.$emit('showTooltip', result, event);
},
toggleShowAverageResponseTime() {
this.$emit('toggleShowAverageResponseTime');
}
},
watch: {
data: function () {
this.updateMinAndMaxResponseTimes();
}
},
created() {
this.updateMinAndMaxResponseTimes()
},
data() {
return {
minResponseTime: 0,
maxResponseTime: 0,
averageResponseTime: 0
}
}
}
</script>
<style>
.endpoint:first-child {
border-top-left-radius: 3px;
border-top-right-radius: 3px;
}
.endpoint:last-child {
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
border-bottom-width: 3px;
}
.status-over-time {
overflow: auto;
}
.status-over-time > span:not(:first-child) {
margin-left: 2px;
}
.status {
cursor: pointer;
transition: all 500ms ease-in-out;
overflow-x: hidden;
color: white;
width: 5%;
font-size: 75%;
font-weight: 700;
text-align: center;
}
.status:hover {
opacity: 0.7;
transition: opacity 100ms ease-in-out;
color: black;
}
.status-time-ago {
color: #6a737d;
opacity: 0.5;
margin-top: 5px;
}
.status.status-success::after {
content: "✓";
}
.status.status-failure::after {
content: "X";
}
@media screen and (max-width: 600px) {
.status.status-success::after,
.status.status-failure::after {
content: " ";
white-space: pre;
}
}
</style>

View File

@@ -0,0 +1,159 @@
<template>
<Card class="endpoint hover:shadow-lg transition-shadow cursor-pointer h-full flex flex-col">
<CardHeader class="endpoint-header px-3 sm:px-6 pt-3 sm:pt-6 pb-2 space-y-0">
<div class="flex items-start justify-between gap-2 sm:gap-3">
<div class="flex-1 min-w-0 overflow-hidden">
<CardTitle class="text-base sm:text-lg truncate">
<span
class="hover:text-primary cursor-pointer hover:underline text-sm sm:text-base block truncate"
@click="navigateToDetails"
@keydown.enter="navigateToDetails"
:title="endpoint.name"
role="link"
tabindex="0"
:aria-label="`View details for ${endpoint.name}`">
{{ endpoint.name }}
</span>
</CardTitle>
<div class="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground">
<span v-if="endpoint.group" class="truncate" :title="endpoint.group">{{ endpoint.group }}</span>
<span v-if="endpoint.group && hostname"></span>
<span v-if="hostname" class="truncate" :title="hostname">{{ hostname }}</span>
</div>
</div>
<div class="flex-shrink-0 ml-2">
<StatusBadge :status="currentStatus" />
</div>
</div>
</CardHeader>
<CardContent class="endpoint-content flex-1 pb-3 sm:pb-4 px-3 sm:px-6 pt-2">
<div class="space-y-2">
<div>
<div class="flex items-center justify-between mb-1">
<div class="flex-1"></div>
<p class="text-xs text-muted-foreground" :title="showAverageResponseTime ? 'Average response time' : 'Minimum and maximum response time'">{{ formattedResponseTime }}</p>
</div>
<div class="flex gap-0.5">
<div
v-for="(result, index) in displayResults"
:key="index"
:class="[
'flex-1 h-6 sm:h-8 rounded-sm transition-all',
result ? (result.success ? 'bg-green-500 hover:bg-green-700' : 'bg-red-500 hover:bg-red-700') : 'bg-gray-200 dark:bg-gray-700'
]"
@mouseenter="result && emit('showTooltip', result, $event)"
@mouseleave="result && emit('showTooltip', null, $event)"
/>
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground mt-1">
<span>{{ oldestResultTime }}</span>
<span>{{ newestResultTime }}</span>
</div>
</div>
</div>
</CardContent>
</Card>
</template>
<script setup>
/* eslint-disable no-undef */
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import StatusBadge from '@/components/StatusBadge.vue'
import { helper } from '@/mixins/helper'
const router = useRouter()
const props = defineProps({
endpoint: {
type: Object,
required: true
},
maxResults: {
type: Number,
default: 50
},
showAverageResponseTime: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['showTooltip'])
const latestResult = computed(() => {
if (!props.endpoint.results || props.endpoint.results.length === 0) {
return null
}
return props.endpoint.results[props.endpoint.results.length - 1]
})
const currentStatus = computed(() => {
if (!latestResult.value) return 'unknown'
return latestResult.value.success ? 'healthy' : 'unhealthy'
})
const hostname = computed(() => {
return latestResult.value?.hostname || null
})
const displayResults = computed(() => {
const results = [...(props.endpoint.results || [])]
while (results.length < props.maxResults) {
results.unshift(null)
}
return results.slice(-props.maxResults)
})
const formattedResponseTime = computed(() => {
if (!props.endpoint.results || props.endpoint.results.length === 0) {
return 'N/A'
}
let total = 0
let count = 0
let min = Infinity
let max = 0
for (const result of props.endpoint.results) {
if (result.duration) {
const durationMs = result.duration / 1000000
total += durationMs
count++
min = Math.min(min, durationMs)
max = Math.max(max, durationMs)
}
}
if (count === 0) return 'N/A'
if (props.showAverageResponseTime) {
const avgMs = Math.round(total / count)
return `~${avgMs}ms`
} else {
// Show min-max range
const minMs = Math.round(min)
const maxMs = Math.round(max)
// If min and max are the same, show single value
if (minMs === maxMs) {
return `${minMs}ms`
}
return `${minMs}-${maxMs}ms`
}
})
const oldestResultTime = computed(() => {
if (!props.endpoint.results || props.endpoint.results.length === 0) return ''
return helper.methods.generatePrettyTimeAgo(props.endpoint.results[0].timestamp)
})
const newestResultTime = computed(() => {
if (!props.endpoint.results || props.endpoint.results.length === 0) return ''
return helper.methods.generatePrettyTimeAgo(props.endpoint.results[props.endpoint.results.length - 1].timestamp)
})
const navigateToDetails = () => {
router.push(`/endpoints/${props.endpoint.key}`)
}
</script>

View File

@@ -1,99 +0,0 @@
<template>
<div :class="endpoints.length === 0 ? 'mt-3' : 'mt-4'">
<slot v-if="name !== 'undefined'">
<div class="endpoint-group pt-2 border dark:bg-gray-800 dark:border-gray-500" @click="toggleGroup">
<h5 class="font-mono text-gray-400 text-xl font-medium pb-2 px-3 dark:text-gray-200 dark:hover:text-gray-500 dark:border-gray-500">
<span class="endpoint-group-arrow mr-2">
{{ collapsed ? '&#9660;' : '&#9650;' }}
</span>
{{ name }}
<span v-if="unhealthyCount" class="rounded-xl bg-red-600 text-white px-2 font-bold leading-6 float-right h-6 text-center hover:scale-110 text-sm" title="Partial Outage">{{unhealthyCount}}</span>
<span v-else class="float-right text-green-600 w-7 hover:scale-110" title="Operational">
<CheckCircleIcon />
</span>
</h5>
</div>
</slot>
<div v-if="!collapsed" :class="name === 'undefined' ? '' : 'endpoint-group-content'">
<slot v-for="(endpoint, idx) in endpoints" :key="idx">
<Endpoint
:data="endpoint"
:maximumNumberOfResults="20"
@showTooltip="showTooltip"
@toggleShowAverageResponseTime="toggleShowAverageResponseTime" :showAverageResponseTime="showAverageResponseTime"
/>
</slot>
</div>
</div>
</template>
<script>
import Endpoint from './Endpoint.vue';
import { CheckCircleIcon } from '@heroicons/vue/20/solid'
export default {
name: 'EndpointGroup',
components: {
Endpoint,
CheckCircleIcon
},
props: {
name: String,
endpoints: Array,
showAverageResponseTime: Boolean
},
emits: ['showTooltip', 'toggleShowAverageResponseTime'],
methods: {
healthCheck() {
let unhealthyCount = 0
if (this.endpoints) {
for (let i in this.endpoints) {
if (this.endpoints[i].results && this.endpoints[i].results.length > 0) {
if (!this.endpoints[i].results[this.endpoints[i].results.length-1].success) {
unhealthyCount++
}
}
}
}
this.unhealthyCount = unhealthyCount;
},
toggleGroup() {
this.collapsed = !this.collapsed;
localStorage.setItem(`gatus:endpoint-group:${this.name}:collapsed`, this.collapsed);
},
showTooltip(result, event) {
this.$emit('showTooltip', result, event);
},
toggleShowAverageResponseTime() {
this.$emit('toggleShowAverageResponseTime');
}
},
watch: {
endpoints: function () {
this.healthCheck();
}
},
created() {
this.healthCheck();
},
data() {
return {
unhealthyCount: 0,
collapsed: localStorage.getItem(`gatus:endpoint-group:${this.name}:collapsed`) === "true"
}
}
}
</script>
<style>
.endpoint-group {
cursor: pointer;
user-select: none;
}
.endpoint-group h5:hover {
color: #1b1e21;
}
</style>

View File

@@ -1,74 +0,0 @@
<template>
<div id="results">
<slot v-for="endpointGroup in endpointGroups" :key="endpointGroup">
<EndpointGroup :endpoints="endpointGroup.endpoints" :name="endpointGroup.name" @showTooltip="showTooltip" @toggleShowAverageResponseTime="toggleShowAverageResponseTime" :showAverageResponseTime="showAverageResponseTime" />
</slot>
</div>
</template>
<script>
import EndpointGroup from './EndpointGroup.vue';
export default {
name: 'Endpoints',
components: {
EndpointGroup
},
props: {
showStatusOnHover: Boolean,
endpointStatuses: Object,
showAverageResponseTime: Boolean
},
emits: ['showTooltip', 'toggleShowAverageResponseTime'],
methods: {
process() {
let outputByGroup = {};
for (let endpointStatusIndex in this.endpointStatuses) {
let endpointStatus = this.endpointStatuses[endpointStatusIndex];
// create an empty entry if this group is new
if (!outputByGroup[endpointStatus.group] || outputByGroup[endpointStatus.group].length === 0) {
outputByGroup[endpointStatus.group] = [];
}
outputByGroup[endpointStatus.group].push(endpointStatus);
}
let endpointGroups = [];
for (let name in outputByGroup) {
if (name !== 'undefined') {
endpointGroups.push({name: name, endpoints: outputByGroup[name]})
}
}
// Add all endpoints that don't have a group at the end
if (outputByGroup['undefined']) {
endpointGroups.push({name: 'undefined', endpoints: outputByGroup['undefined']})
}
this.endpointGroups = endpointGroups;
},
showTooltip(result, event) {
this.$emit('showTooltip', result, event);
},
toggleShowAverageResponseTime() {
this.$emit('toggleShowAverageResponseTime');
}
},
watch: {
endpointStatuses: function () {
this.process();
}
},
data() {
return {
userClickedStatus: false,
endpointGroups: []
}
}
}
</script>
<style>
.endpoint-group-content > div:nth-child(1) {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
</style>

View File

@@ -1,11 +1,35 @@
<template> <template>
<div class="flex justify-center items-center mx-auto"> <div class="flex justify-center items-center">
<img :class="`animate-spin opacity-60 rounded-full`" src="../assets/logo.svg" alt="Gatus logo" /> <img
:class="[
'animate-spin rounded-full opacity-60 grayscale',
sizeClass,
]"
src="../assets/logo.svg"
alt="Gatus logo"
/>
</div> </div>
</template> </template>
<script> <script setup>
export default { import { computed } from 'vue'
} const props = defineProps({
</script> size: {
type: String,
default: 'md',
validator: (value) => ['xs', 'sm', 'md', 'lg', 'xl'].includes(value)
},
})
const sizeClass = computed(() => {
const sizes = {
xs: 'w-4 h-4',
sm: 'w-6 h-6',
md: 'w-8 h-8',
lg: 'w-12 h-12',
xl: 'w-16 h-16'
}
return sizes[props.size] || sizes.md
})
</script>

View File

@@ -1,42 +1,73 @@
<template> <template>
<div class="mt-3 flex"> <div class="flex items-center justify-between">
<div class="flex-1"> <Button
<button v-if="currentPage < maxPages" @click="nextPage" class="bg-gray-100 hover:bg-gray-200 text-gray-500 border border-gray-200 px-2 rounded font-mono dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500 dark:hover:bg-gray-600">&lt;</button> variant="outline"
</div> size="sm"
<div class="flex-1 text-right"> :disabled="currentPage >= maxPages"
<button v-if="currentPage > 1" @click="previousPage" class="bg-gray-100 hover:bg-gray-200 text-gray-500 border border-gray-200 px-2 rounded font-mono dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500 dark:hover:bg-gray-600">&gt;</button> @click="previousPage"
</div> class="flex items-center gap-1"
>
<ChevronLeft class="h-4 w-4" />
Previous
</Button>
<span class="text-sm text-muted-foreground">
Page {{ currentPage }} of {{ maxPages }}
</span>
<Button
variant="outline"
size="sm"
:disabled="currentPage <= 1"
@click="nextPage"
class="flex items-center gap-1"
>
Next
<ChevronRight class="h-4 w-4" />
</Button>
</div> </div>
</template> </template>
<script setup>
/* eslint-disable no-undef */
import { ref, computed } from 'vue'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
<script> const props = defineProps({
export default { numberOfResultsPerPage: Number,
name: 'Pagination', currentPageProp: {
props: { type: Number,
numberOfResultsPerPage: Number, default: 1
}, }
components: {}, })
emits: ['page'],
methods: { const emit = defineEmits(['page'])
nextPage() {
this.currentPage++; const currentPage = ref(props.currentPageProp)
this.$emit('page', this.currentPage);
}, const maxPages = computed(() => {
previousPage() { // Use maximumNumberOfResults from config if available, otherwise default to 100
this.currentPage--; let maxResults = 100 // Default value
this.$emit('page', this.currentPage); // Check if window.config exists and has maximumNumberOfResults
} if (typeof window !== 'undefined' && window.config && window.config.maximumNumberOfResults) {
}, const parsed = parseInt(window.config.maximumNumberOfResults)
computed: { if (!isNaN(parsed)) {
maxPages() { maxResults = parsed
return Math.ceil(parseInt(window.config.maximumNumberOfResults) / this.numberOfResultsPerPage)
}
},
data() {
return {
currentPage: 1,
} }
} }
return Math.ceil(maxResults / props.numberOfResultsPerPage)
})
const nextPage = () => {
// "Next" should show newer data (lower page numbers)
currentPage.value--
emit('page', currentPage.value)
}
const previousPage = () => {
// "Previous" should show older data (higher page numbers)
currentPage.value++
emit('page', currentPage.value)
} }
</script> </script>

View File

@@ -0,0 +1,100 @@
<template>
<div class="flex flex-col lg:flex-row gap-3 lg:gap-4 p-3 sm:p-4 bg-card rounded-lg border">
<div class="flex-1">
<div class="relative">
<Search class="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<label for="search-input" class="sr-only">Search endpoints</label>
<Input
id="search-input"
v-model="searchQuery"
type="text"
placeholder="Search endpoints..."
class="pl-10 text-sm sm:text-base"
@input="$emit('search', searchQuery)"
/>
</div>
</div>
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4">
<div class="flex items-center gap-2 flex-1 sm:flex-initial">
<label class="text-xs sm:text-sm font-medium text-muted-foreground whitespace-nowrap">Filter by:</label>
<Select
v-model="filterBy"
:options="filterOptions"
placeholder="Nothing"
class="flex-1 sm:w-[140px] md:w-[160px]"
@update:model-value="handleFilterChange"
/>
</div>
<div class="flex items-center gap-2 flex-1 sm:flex-initial">
<label class="text-xs sm:text-sm font-medium text-muted-foreground whitespace-nowrap">Sort by:</label>
<Select
v-model="sortBy"
:options="sortOptions"
placeholder="Name"
class="flex-1 sm:w-[90px] md:w-[100px]"
@update:model-value="handleSortChange"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Search } from 'lucide-vue-next'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
const searchQuery = ref('')
const filterBy = ref(localStorage.getItem('gatus:filter-by') || (typeof window !== 'undefined' && window.config?.defaultFilterBy) || 'nothing')
const sortBy = ref(localStorage.getItem('gatus:sort-by') || (typeof window !== 'undefined' && window.config?.defaultSortBy) || 'name')
const filterOptions = [
{ label: 'Nothing', value: 'nothing' },
{ label: 'Failing', value: 'failing' },
{ label: 'Unstable', value: 'unstable' }
]
const sortOptions = [
{ label: 'Name', value: 'name' },
{ label: 'Group', value: 'group' },
{ label: 'Health', value: 'health' }
]
const emit = defineEmits(['search', 'update:showOnlyFailing', 'update:showRecentFailures', 'update:groupByGroup', 'update:sortBy', 'initializeCollapsedGroups'])
const handleFilterChange = (value) => {
filterBy.value = value
localStorage.setItem('gatus:filter-by', value)
// Reset all filter states first
emit('update:showOnlyFailing', false)
emit('update:showRecentFailures', false)
// Apply the selected filter
if (value === 'failing') {
emit('update:showOnlyFailing', true)
} else if (value === 'unstable') {
emit('update:showRecentFailures', true)
}
}
const handleSortChange = (value) => {
sortBy.value = value
localStorage.setItem('gatus:sort-by', value)
emit('update:sortBy', value)
emit('update:groupByGroup', value === 'group')
// When switching to group view, initialize collapsed groups
if (value === 'group') {
emit('initializeCollapsedGroups')
}
}
onMounted(() => {
// Apply saved filter/sort state on load
handleFilterChange(filterBy.value)
handleSortChange(sortBy.value)
})
</script>

View File

@@ -1,104 +1,190 @@
<template> <template>
<div id="settings" class="flex bg-gray-200 border-gray-300 rounded border shadow dark:text-gray-200 dark:bg-gray-800 dark:border-gray-500"> <div id="settings" class="fixed bottom-4 left-4 z-50">
<div class="text-xs text-gray-600 rounded-xl py-1.5 px-1.5 dark:text-gray-200"> <div class="flex items-center gap-1 bg-background/95 backdrop-blur-sm border rounded-full shadow-md p-1">
<ArrowPathIcon class="w-3"/> <!-- Refresh Rate -->
<button
@click="showRefreshMenu = !showRefreshMenu"
:aria-label="`Refresh interval: ${formatRefreshInterval(refreshIntervalValue)}`"
:aria-expanded="showRefreshMenu"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-full hover:bg-accent transition-colors relative"
>
<RefreshCw class="w-3.5 h-3.5 text-muted-foreground" />
<span class="text-xs font-medium">{{ formatRefreshInterval(refreshIntervalValue) }}</span>
<!-- Refresh Rate Dropdown -->
<div
v-if="showRefreshMenu"
@click.stop
class="absolute bottom-full left-0 mb-2 bg-popover border rounded-lg shadow-lg overflow-hidden"
>
<button
v-for="interval in REFRESH_INTERVALS"
:key="interval.value"
@click="selectRefreshInterval(interval.value)"
:class="[
'block w-full px-4 py-2 text-xs text-left hover:bg-accent transition-colors',
refreshIntervalValue === interval.value && 'bg-accent'
]"
>
{{ interval.label }}
</button>
</div>
</button>
<!-- Divider -->
<div class="h-5 w-px bg-border/50" />
<!-- Theme Toggle -->
<button
@click="toggleDarkMode"
:aria-label="darkMode ? 'Switch to light mode' : 'Switch to dark mode'"
class="p-1.5 rounded-full hover:bg-accent transition-colors group relative"
>
<Sun v-if="darkMode" class="h-3.5 w-3.5 transition-all" />
<Moon v-else class="h-3.5 w-3.5 transition-all" />
<!-- Tooltip -->
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-popover text-popover-foreground text-xs rounded-md shadow-md opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap">
{{ darkMode ? 'Light mode' : 'Dark mode' }}
</div>
</button>
</div> </div>
<select class="text-center text-gray-500 text-xs dark:text-gray-200 dark:bg-gray-800 border-r border-l border-gray-300 dark:border-gray-500 pl-1" id="refresh-rate" ref="refreshInterval" @change="handleChangeRefreshInterval">
<option value="10" :selected="refreshInterval === 10">10s</option>
<option value="30" :selected="refreshInterval === 30">30s</option>
<option value="60" :selected="refreshInterval === 60">1m</option>
<option value="120" :selected="refreshInterval === 120">2m</option>
<option value="300" :selected="refreshInterval === 300">5m</option>
<option value="600" :selected="refreshInterval === 600">10m</option>
</select>
<button @click="toggleDarkMode" class="text-xs p-1">
<slot v-if="darkMode"><SunIcon class="w-4"/></slot>
<slot v-else><MoonIcon class="w-4 text-gray-500"/></slot>
</button>
</div> </div>
</template> </template>
<script> <script setup>
import { MoonIcon, SunIcon } from '@heroicons/vue/20/solid' /* eslint-disable no-undef */
import { ArrowPathIcon } from '@heroicons/vue/24/solid' import { ref, onMounted, onUnmounted } from 'vue'
import { Sun, Moon, RefreshCw } from 'lucide-vue-next'
const emit = defineEmits(['refreshData'])
// Constants
const REFRESH_INTERVALS = [
{ value: '10', label: '10s' },
{ value: '30', label: '30s' },
{ value: '60', label: '1m' },
{ value: '120', label: '2m' },
{ value: '300', label: '5m' },
{ value: '600', label: '10m' }
]
const DEFAULT_REFRESH_INTERVAL = '300'
const THEME_COOKIE_NAME = 'theme'
const THEME_COOKIE_MAX_AGE = 31536000 // 1 year
const STORAGE_KEYS = {
REFRESH_INTERVAL: 'gatus:refresh-interval'
}
// Helper functions
function wantsDarkMode() { function wantsDarkMode() {
const themeFromCookie = document.cookie.match(/theme=(dark|light);?/)?.[1]; const themeFromCookie = document.cookie.match(new RegExp(`${THEME_COOKIE_NAME}=(dark|light);?`))?.[1]
return themeFromCookie === 'dark' || !themeFromCookie && (window.matchMedia('(prefers-color-scheme: dark)').matches || document.documentElement.classList.contains("dark")); return themeFromCookie === 'dark' || (!themeFromCookie && (window.matchMedia('(prefers-color-scheme: dark)').matches || document.documentElement.classList.contains("dark")))
} }
export default { function getStoredRefreshInterval() {
name: 'Settings', const stored = localStorage.getItem(STORAGE_KEYS.REFRESH_INTERVAL)
components: { const parsedValue = stored && parseInt(stored)
ArrowPathIcon, const isValid = parsedValue && parsedValue >= 10 && REFRESH_INTERVALS.some(i => i.value === stored)
MoonIcon, return isValid ? stored : DEFAULT_REFRESH_INTERVAL
SunIcon
},
props: {},
methods: {
setRefreshInterval(seconds) {
localStorage.setItem('gatus:refresh-interval', seconds);
let that = this;
this.refreshIntervalHandler = setInterval(function () {
that.refreshData();
}, seconds * 1000);
},
refreshData() {
this.$emit('refreshData');
},
handleChangeRefreshInterval() {
this.refreshData();
clearInterval(this.refreshIntervalHandler);
this.setRefreshInterval(this.$refs.refreshInterval.value);
},
toggleDarkMode() {
if (wantsDarkMode()) {
document.cookie = `theme=light; path=/; max-age=31536000; samesite=strict`;
} else {
document.cookie = `theme=dark; path=/; max-age=31536000; samesite=strict`;
}
this.applyTheme();
},
applyTheme() {
if (wantsDarkMode()) {
this.darkMode = true;
document.documentElement.classList.add('dark');
} else {
this.darkMode = false;
document.documentElement.classList.remove('dark');
}
}
},
created() {
if (this.refreshInterval !== 10 && this.refreshInterval !== 30 && this.refreshInterval !== 60 && this.refreshInterval !== 120 && this.refreshInterval !== 300 && this.refreshInterval !== 600) {
this.refreshInterval = 300;
}
this.setRefreshInterval(this.refreshInterval);
this.applyTheme();
},
unmounted() {
clearInterval(this.refreshIntervalHandler);
},
data() {
return {
refreshInterval: localStorage.getItem('gatus:refresh-interval') < 10 ? 300 : parseInt(localStorage.getItem('gatus:refresh-interval')),
refreshIntervalHandler: 0,
darkMode: wantsDarkMode()
}
},
} }
// State
const refreshIntervalValue = ref(getStoredRefreshInterval())
const darkMode = ref(wantsDarkMode())
const showRefreshMenu = ref(false)
let refreshIntervalHandler = null
// Methods
const formatRefreshInterval = (value) => {
const interval = REFRESH_INTERVALS.find(i => i.value === value)
return interval ? interval.label : `${value}s`
}
const setRefreshInterval = (seconds) => {
localStorage.setItem(STORAGE_KEYS.REFRESH_INTERVAL, seconds)
if (refreshIntervalHandler) {
clearInterval(refreshIntervalHandler)
}
refreshIntervalHandler = setInterval(() => {
refreshData()
}, seconds * 1000)
}
const refreshData = () => {
emit('refreshData')
}
const selectRefreshInterval = (value) => {
refreshIntervalValue.value = value
showRefreshMenu.value = false
refreshData()
setRefreshInterval(value)
}
// Close menu when clicking outside
const handleClickOutside = (event) => {
const settings = document.getElementById('settings')
if (settings && !settings.contains(event.target)) {
showRefreshMenu.value = false
}
}
const setThemeCookie = (theme) => {
document.cookie = `${THEME_COOKIE_NAME}=${theme}; path=/; max-age=${THEME_COOKIE_MAX_AGE}; samesite=strict`
}
const toggleDarkMode = () => {
const newTheme = wantsDarkMode() ? 'light' : 'dark'
setThemeCookie(newTheme)
applyTheme()
}
const applyTheme = () => {
const isDark = wantsDarkMode()
darkMode.value = isDark
document.documentElement.classList.toggle('dark', isDark)
}
// Lifecycle
onMounted(() => {
setRefreshInterval(refreshIntervalValue.value)
applyTheme()
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
if (refreshIntervalHandler) {
clearInterval(refreshIntervalHandler)
}
document.removeEventListener('click', handleClickOutside)
})
</script> </script>
<style> <style scoped>
#settings { /* Animations for smooth transitions */
position: fixed; @keyframes slideIn {
left: 10px; from {
bottom: 10px; transform: translateX(-20px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
} }
#settings select:focus { #settings {
box-shadow: none; animation: slideIn 0.3s ease-out;
}
#settings > div {
transition: all 0.2s ease;
}
#settings > div:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
} }
</style> </style>

View File

@@ -8,14 +8,9 @@
</div> </div>
</template> </template>
<script setup>
<script>
export default {
name: 'Social'
}
</script> </script>
<style scoped> <style scoped>
#social { #social {
position: fixed; position: fixed;
@@ -33,4 +28,4 @@ export default {
#social img:hover { #social img:hover {
opacity: 1; opacity: 1;
} }
</style> </style>

View File

@@ -0,0 +1,58 @@
<template>
<Badge :variant="variant" class="flex items-center gap-1">
<span :class="['w-2 h-2 rounded-full', dotClass]"></span>
{{ label }}
</Badge>
</template>
<script setup>
import { computed } from 'vue'
import { Badge } from '@/components/ui/badge'
const props = defineProps({
status: {
type: String,
required: true,
validator: (value) => ['healthy', 'unhealthy', 'degraded', 'unknown'].includes(value)
}
})
const variant = computed(() => {
switch (props.status) {
case 'healthy':
return 'success'
case 'unhealthy':
return 'destructive'
case 'degraded':
return 'warning'
default:
return 'secondary'
}
})
const label = computed(() => {
switch (props.status) {
case 'healthy':
return 'Healthy'
case 'unhealthy':
return 'Unhealthy'
case 'degraded':
return 'Degraded'
default:
return 'Unknown'
}
})
const dotClass = computed(() => {
switch (props.status) {
case 'healthy':
return 'bg-green-400'
case 'unhealthy':
return 'bg-red-400'
case 'degraded':
return 'bg-yellow-400'
default:
return 'bg-gray-400'
}
})
</script>

View File

@@ -1,130 +1,158 @@
<template> <template>
<div id="tooltip" ref="tooltip" :class="hidden ? 'invisible' : ''" :style="'top:' + top + 'px; left:' + left + 'px'"> <div
<slot v-if="result"> id="tooltip"
<div class="tooltip-title">Timestamp:</div> ref="tooltip"
<code id="tooltip-timestamp">{{ prettifyTimestamp(result.timestamp) }}</code> :class="[
<div class="tooltip-title">Response time:</div> 'fixed z-50 px-3 py-2 text-sm rounded-md shadow-lg border transition-all duration-200',
<code id="tooltip-response-time">{{ (result.duration / 1000000).toFixed(0) }}ms</code> 'bg-popover text-popover-foreground border-border',
<slot v-if="result.conditionResults && result.conditionResults.length"> hidden ? 'invisible opacity-0' : 'visible opacity-100'
<div class="tooltip-title">Conditions:</div> ]"
<code id="tooltip-conditions"> :style="`top: ${top}px; left: ${left}px;`"
<slot v-for="conditionResult in result.conditionResults" :key="conditionResult"> >
{{ conditionResult.success ? "&#10003;" : "X" }} ~ {{ conditionResult.condition }}<br/> <div v-if="result" class="space-y-2">
</slot> <!-- Timestamp -->
</code> <div>
</slot> <div class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Timestamp</div>
<div id="tooltip-errors-container" v-if="result.errors && result.errors.length"> <div class="font-mono text-xs">{{ prettifyTimestamp(result.timestamp) }}</div>
<div class="tooltip-title">Errors:</div>
<code id="tooltip-errors">
<slot v-for="error in result.errors" :key="error">
- {{ error }}<br/>
</slot>
</code>
</div> </div>
</slot>
<!-- Response Time -->
<div>
<div class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Response Time</div>
<div class="font-mono text-xs">{{ (result.duration / 1000000).toFixed(0) }}ms</div>
</div>
<!-- Conditions -->
<div v-if="result.conditionResults && result.conditionResults.length">
<div class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Conditions</div>
<div class="font-mono text-xs space-y-0.5">
<div
v-for="(conditionResult, index) in result.conditionResults"
:key="index"
class="flex items-start gap-1"
>
<span :class="conditionResult.success ? 'text-green-500' : 'text-red-500'">
{{ conditionResult.success ? '✓' : '✗' }}
</span>
<span class="break-all">{{ conditionResult.condition }}</span>
</div>
</div>
</div>
<!-- Errors -->
<div v-if="result.errors && result.errors.length">
<div class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Errors</div>
<div class="font-mono text-xs space-y-0.5">
<div v-for="(error, index) in result.errors" :key="index" class="text-red-500">
{{ error }}
</div>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script setup>
/* eslint-disable no-undef */
import { ref, watch, nextTick } from 'vue'
import { helper } from '@/mixins/helper'
<script> const props = defineProps({
import {helper} from "@/mixins/helper"; event: {
type: [Event, Object],
export default { default: null
name: 'Endpoints',
props: {
event: Event,
result: Object
}, },
mixins: [helper], result: {
methods: { type: Object,
htmlEntities(s) { default: null
return String(s) }
.replace(/&/g, '&amp;') })
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;') // State
.replace(/"/g, '&quot;') const hidden = ref(true)
.replace(/'/g, '&apos;'); const top = ref(0)
}, const left = ref(0)
reposition() { const tooltip = ref(null)
if (this.event && this.event.type) {
if (this.event.type === 'mouseenter') { // Methods from helper mixin
let targetTopPosition = this.event.target.getBoundingClientRect().y + 30; const { prettifyTimestamp } = helper.methods
let targetLeftPosition = this.event.target.getBoundingClientRect().x;
let tooltipBoundingClientRect = this.$refs.tooltip.getBoundingClientRect(); const reposition = async () => {
if (targetLeftPosition + window.scrollX + tooltipBoundingClientRect.width + 50 > document.body.getBoundingClientRect().width) { if (!props.event || !props.event.type) return
targetLeftPosition = this.event.target.getBoundingClientRect().x - tooltipBoundingClientRect.width + this.event.target.getBoundingClientRect().width;
if (targetLeftPosition < 0) { await nextTick()
targetLeftPosition += -targetLeftPosition;
} if (props.event.type === 'mouseenter' && tooltip.value) {
} const target = props.event.target
if (targetTopPosition + window.scrollY + tooltipBoundingClientRect.height + 50 > document.body.getBoundingClientRect().height && targetTopPosition >= 0) { const targetRect = target.getBoundingClientRect()
targetTopPosition = this.event.target.getBoundingClientRect().y - (tooltipBoundingClientRect.height + 10);
if (targetTopPosition < 0) { // First, position tooltip to get its dimensions
targetTopPosition = this.event.target.getBoundingClientRect().y + 30; hidden.value = false
} await nextTick()
}
this.top = targetTopPosition; const tooltipRect = tooltip.value.getBoundingClientRect()
this.left = targetLeftPosition;
} else if (this.event.type === 'mouseleave') { // Since tooltip uses position: fixed, we work with viewport coordinates
this.hidden = true; // getBoundingClientRect() already gives us viewport-relative positions
// Default position: below the target
let newTop = targetRect.bottom + 8
let newLeft = targetRect.left
// Check if tooltip would overflow the viewport bottom
const spaceBelow = window.innerHeight - targetRect.bottom
const spaceAbove = targetRect.top
if (spaceBelow < tooltipRect.height + 20) {
// Not enough space below, try above
if (spaceAbove > tooltipRect.height + 20) {
// Position above
newTop = targetRect.top - tooltipRect.height - 8
} else {
// Not enough space above either, position at the best spot
if (spaceAbove > spaceBelow) {
// More space above
newTop = 10
} else {
// More space below or equal, keep below but adjust
newTop = window.innerHeight - tooltipRect.height - 10
} }
} }
} }
},
watch: { // Adjust horizontal position if tooltip would overflow right edge
event: function (value) { const spaceRight = window.innerWidth - targetRect.left
if (value && value.type) { if (spaceRight < tooltipRect.width + 20) {
if (value.type === 'mouseenter') { // Align right edge of tooltip with right edge of target
this.hidden = false; newLeft = targetRect.right - tooltipRect.width
} else if (value.type === 'mouseleave') { // Make sure it doesn't go off the left edge
this.hidden = true; if (newLeft < 10) {
} newLeft = 10
} }
} }
},
updated() { top.value = Math.round(newTop)
this.reposition(); left.value = Math.round(newLeft)
}, } else if (props.event.type === 'mouseleave') {
created() { hidden.value = true
this.reposition();
},
data() {
return {
hidden: true,
top: 0,
left: 0
}
} }
} }
</script>
// Watchers
watch(() => props.event, (newEvent) => {
if (newEvent && newEvent.type) {
if (newEvent.type === 'mouseenter') {
hidden.value = false
nextTick(() => reposition())
} else if (newEvent.type === 'mouseleave') {
hidden.value = true
}
}
}, { immediate: true })
<style> watch(() => props.result, () => {
#tooltip { if (!hidden.value) {
position: fixed; nextTick(() => reposition())
background-color: white; }
border: 1px solid lightgray; })
border-radius: 4px; </script>
padding: 6px;
font-size: 13px;
}
#tooltip code {
color: #212529;
line-height: 1;
}
#tooltip .tooltip-title {
font-weight: bold;
margin-bottom: 0;
display: block;
}
#tooltip .tooltip-title {
margin-top: 8px;
}
#tooltip > .tooltip-title:first-child {
margin-top: 0;
}
</style>

View File

@@ -0,0 +1,37 @@
<template>
<div :class="combineClasses(badgeVariants({ variant }), $attrs.class ?? '')">
<slot />
</div>
</template>
<script setup>
/* eslint-disable no-undef */
import { cva } from 'class-variance-authority'
import { combineClasses } from '@/lib/utils'
defineProps({
variant: {
type: String,
default: 'default',
},
})
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
success: 'border-transparent bg-green-500 text-white',
warning: 'border-transparent bg-yellow-500 text-white',
},
},
defaultVariants: {
variant: 'default',
},
}
)
</script>

View File

@@ -0,0 +1 @@
export { default as Badge } from './Badge.vue'

View File

@@ -0,0 +1,55 @@
<template>
<button
:class="combineClasses(buttonVariants({ variant, size }), $attrs.class ?? '')"
:disabled="disabled"
>
<slot />
</button>
</template>
<script setup>
/* eslint-disable no-undef */
import { cva } from 'class-variance-authority'
import { combineClasses } from '@/lib/utils'
defineProps({
variant: {
type: String,
default: 'default',
},
size: {
type: String,
default: 'default',
},
disabled: {
type: Boolean,
default: false,
},
})
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
</script>

View File

@@ -0,0 +1 @@
export { default as Button } from './Button.vue'

View File

@@ -0,0 +1,9 @@
<template>
<div :class="combineClasses('rounded-lg border bg-card text-card-foreground shadow-sm', $attrs.class ?? '')">
<slot />
</div>
</template>
<script setup>
import { combineClasses } from '@/lib/utils'
</script>

View File

@@ -0,0 +1,9 @@
<template>
<div :class="combineClasses('p-6 pt-0', $attrs.class ?? '')">
<slot />
</div>
</template>
<script setup>
import { combineClasses } from '@/lib/utils'
</script>

View File

@@ -0,0 +1,9 @@
<template>
<div :class="combineClasses('flex flex-col space-y-1.5 p-6', $attrs.class ?? '')">
<slot />
</div>
</template>
<script setup>
import { combineClasses } from '@/lib/utils'
</script>

View File

@@ -0,0 +1,9 @@
<template>
<h3 :class="combineClasses('text-2xl font-semibold leading-none tracking-tight', $attrs.class ?? '')">
<slot />
</h3>
</template>
<script setup>
import { combineClasses } from '@/lib/utils'
</script>

View File

@@ -0,0 +1,4 @@
export { default as Card } from './Card.vue'
export { default as CardHeader } from './CardHeader.vue'
export { default as CardTitle } from './CardTitle.vue'
export { default as CardContent } from './CardContent.vue'

View File

@@ -0,0 +1,24 @@
<template>
<input
:class="combineClasses(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
$attrs.class ?? ''
)"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
<script setup>
/* eslint-disable no-undef */
import { combineClasses } from '@/lib/utils'
defineProps({
modelValue: {
type: [String, Number],
default: '',
},
})
defineEmits(['update:modelValue'])
</script>

View File

@@ -0,0 +1 @@
export { default as Input } from './Input.vue'

View File

@@ -0,0 +1,127 @@
<template>
<div ref="selectRef" class="relative" :class="props.class">
<button
@click="toggleDropdown"
@keydown="handleKeyDown"
:aria-expanded="isOpen"
:aria-haspopup="true"
:aria-label="selectedOption.label || props.placeholder"
class="flex h-9 sm:h-10 w-full items-center justify-between rounded-md border border-input bg-background px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<span class="truncate">{{ selectedOption.label }}</span>
<ChevronDown class="h-3 w-3 sm:h-4 sm:w-4 opacity-50 flex-shrink-0 ml-1" />
</button>
<div
v-if="isOpen"
role="listbox"
class="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95"
>
<div class="p-1">
<div
v-for="(option, index) in options"
:key="option.value"
@click="selectOption(option)"
:class="[
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-6 sm:pl-8 pr-2 text-xs sm:text-sm outline-none hover:bg-accent hover:text-accent-foreground',
index === focusedIndex && 'bg-accent text-accent-foreground'
]"
role="option"
:aria-selected="modelValue === option.value"
>
<span class="absolute left-1.5 sm:left-2 flex h-3.5 w-3.5 items-center justify-center">
<Check v-if="modelValue === option.value" class="h-3 w-3 sm:h-4 sm:w-4" />
</span>
{{ option.label }}
</div>
</div>
</div>
</div>
</template>
<script setup>
/* eslint-disable no-undef */
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ChevronDown, Check } from 'lucide-vue-next'
const props = defineProps({
modelValue: { type: String, default: '' },
options: { type: Array, required: true },
placeholder: { type: String, default: 'Select...' },
class: { type: String, default: '' }
})
const emit = defineEmits(['update:modelValue'])
const isOpen = ref(false)
const selectRef = ref(null)
const focusedIndex = ref(-1)
const selectedOption = computed(() => {
return props.options.find(option => option.value === props.modelValue) || { label: props.placeholder, value: '' }
})
const selectOption = (option) => {
emit('update:modelValue', option.value)
isOpen.value = false
}
const toggleDropdown = () => {
isOpen.value = !isOpen.value
if (isOpen.value) {
// Set initial focus to selected option or first option
const selectedIdx = props.options.findIndex(opt => opt.value === props.modelValue)
focusedIndex.value = selectedIdx >= 0 ? selectedIdx : 0
} else {
focusedIndex.value = -1
}
}
const handleClickOutside = (event) => {
if (selectRef.value && !selectRef.value.contains(event.target)) {
isOpen.value = false
focusedIndex.value = -1
}
}
const handleKeyDown = (event) => {
if (!isOpen.value) {
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
toggleDropdown()
}
return
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
focusedIndex.value = Math.min(focusedIndex.value + 1, props.options.length - 1)
break
case 'ArrowUp':
event.preventDefault()
focusedIndex.value = Math.max(focusedIndex.value - 1, 0)
break
case 'Enter':
case ' ':
event.preventDefault()
if (focusedIndex.value >= 0 && focusedIndex.value < props.options.length) {
selectOption(props.options[focusedIndex.value])
}
break
case 'Escape':
event.preventDefault()
isOpen.value = false
focusedIndex.value = -1
break
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>

View File

@@ -0,0 +1 @@
export { default as Select } from './Select.vue'

View File

@@ -2,38 +2,78 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
:root.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
.bg-success { .bg-success {
background-color: #28a745; background-color: #28a745;
} }
html:not(.dark) body {
background-color: #f7f9fb;
}
html { html {
height: 100%; height: 100%;
} }
body { body {
padding-top: 20px;
padding-bottom: 20px;
min-height: 100vh; min-height: 100vh;
} }
#global {
margin-top: 0 !important;
}
#global, #results {
max-width: 1280px;
}
@media screen and (max-width: 1279px) { @media screen and (max-width: 1279px) {
body { body {
padding-top: 0; padding-top: 0;
padding-bottom: 0; padding-bottom: 0;
} }
#global {
min-height: 100vh;
}
} }

6
web/app/src/lib/utils.js Normal file
View File

@@ -0,0 +1,6 @@
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function combineClasses(...inputs) {
return twMerge(clsx(inputs))
}

View File

@@ -1,231 +1,399 @@
<template> <template>
<router-link to="../" <div class="dashboard-container bg-background">
class="absolute top-2 left-5 inline-block px-2 pb-0.5 text-sm text-black bg-gray-100 rounded hover:bg-gray-200 focus:outline-none border border-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500 dark:hover:bg-gray-600"> <div class="container mx-auto px-4 py-8 max-w-7xl">
&larr; <div class="mb-8">
</router-link> <Button variant="ghost" class="mb-4" @click="goBack">
<div> <ArrowLeft class="h-4 w-4 mr-2" />
<slot v-if="endpointStatus"> Back to Dashboard
<h1 class="text-xl xl:text-3xl font-mono text-gray-400">RECENT CHECKS</h1> </Button>
<hr class="mb-4"/>
<Endpoint <div v-if="endpointStatus && endpointStatus.name" class="space-y-6">
:data="endpointStatus" <div class="flex items-start justify-between">
:maximumNumberOfResults="20" <div>
@showTooltip="showTooltip" <h1 class="text-4xl font-bold tracking-tight">{{ endpointStatus.name }}</h1>
@toggleShowAverageResponseTime="toggleShowAverageResponseTime" <div class="flex items-center gap-3 text-muted-foreground mt-2">
:showAverageResponseTime="showAverageResponseTime" <span v-if="endpointStatus.group">Group: {{ endpointStatus.group }}</span>
/> <span v-if="endpointStatus.group && hostname"></span>
<Pagination @page="changePage" :numberOfResultsPerPage="20" /> <span v-if="hostname">{{ hostname }}</span>
</slot> </div>
<div v-if="endpointStatus && endpointStatus.key" class="mt-12">
<h1 class="text-xl xl:text-3xl font-mono text-gray-400">UPTIME</h1>
<hr/>
<div class="flex space-x-4 text-center text-2xl mt-6 relative bottom-2 mb-10">
<div class="flex-1">
<h2 class="text-sm text-gray-400 mb-1">Last 30 days</h2>
<img :src="generateUptimeBadgeImageURL('30d')" alt="30d uptime badge" class="mx-auto"/>
</div>
<div class="flex-1">
<h2 class="text-sm text-gray-400 mb-1">Last 7 days</h2>
<img :src="generateUptimeBadgeImageURL('7d')" alt="7d uptime badge" class="mx-auto"/>
</div>
<div class="flex-1">
<h2 class="text-sm text-gray-400 mb-1">Last 24 hours</h2>
<img :src="generateUptimeBadgeImageURL('24h')" alt="24h uptime badge" class="mx-auto"/>
</div>
<div class="flex-1">
<h2 class="text-sm text-gray-400 mb-1">Last hour</h2>
<img :src="generateUptimeBadgeImageURL('1h')" alt="1h uptime badge" class="mx-auto"/>
</div>
</div>
</div>
<div v-if="endpointStatus && endpointStatus.key && showResponseTimeChartAndBadges" class="mt-12">
<div class="flex items-center justify-between">
<h1 class="text-xl xl:text-3xl font-mono text-gray-400">RESPONSE TIME</h1>
<select v-model="selectedChartDuration" class="text-sm bg-gray-400 text-white border border-gray-600 rounded-md px-3 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="24h">24 hours</option>
<option value="7d">7 days</option>
<option value="30d">30 days</option>
</select>
</div>
<img :src="generateResponseTimeChartImageURL(selectedChartDuration)" alt="response time chart" class="mt-6"/>
<div class="flex space-x-4 text-center text-2xl mt-6 relative bottom-2 mb-10">
<div class="flex-1">
<h2 class="text-sm text-gray-400 mb-1">Last 30 days</h2>
<img :src="generateResponseTimeBadgeImageURL('30d')" alt="7d response time badge" class="mx-auto mt-2"/>
</div>
<div class="flex-1">
<h2 class="text-sm text-gray-400 mb-1">Last 7 days</h2>
<img :src="generateResponseTimeBadgeImageURL('7d')" alt="7d response time badge" class="mx-auto mt-2"/>
</div>
<div class="flex-1">
<h2 class="text-sm text-gray-400 mb-1">Last 24 hours</h2>
<img :src="generateResponseTimeBadgeImageURL('24h')" alt="24h response time badge" class="mx-auto mt-2"/>
</div>
<div class="flex-1">
<h2 class="text-sm text-gray-400 mb-1">Last hour</h2>
<img :src="generateResponseTimeBadgeImageURL('1h')" alt="1h response time badge" class="mx-auto mt-2"/>
</div>
</div>
</div>
<div v-if="endpointStatus && endpointStatus.key">
<h1 class="text-xl xl:text-3xl font-mono text-gray-400 mt-4">CURRENT HEALTH</h1>
<hr />
<div class="flex space-x-4 text-center text-2xl mt-6 relative bottom-2 mb-10">
<div class="flex-1">
<img :src="generateHealthBadgeImageURL()" alt="health badge" class="mx-auto"/>
</div>
</div>
</div>
<div v-if="endpointStatus && endpointStatus.key">
<h1 class="text-xl xl:text-3xl font-mono text-gray-400 mt-4">EVENTS</h1>
<hr />
<ul role="list" class="px-0 xl:px-24 divide-y divide-gray-200 dark:divide-gray-600">
<li v-for="event in events" :key="event" class="p-3 my-4">
<h2 class="text-sm sm:text-lg">
<ArrowUpCircleIcon v-if="event.type === 'HEALTHY'" class="w-8 inline mr-2 text-green-600" />
<ArrowDownCircleIcon v-else-if="event.type === 'UNHEALTHY'" class="w-8 inline mr-2 text-red-500" />
<PlayCircleIcon v-else-if="event.type === 'START'" class="w-8 inline mr-2 text-gray-400 dark:text-gray-100" />
{{ event.fancyText }}
</h2>
<div class="flex mt-1 text-xs sm:text-sm text-gray-400">
<div class="flex-2 text-left pl-12">
{{ prettifyTimestamp(event.timestamp) }}
</div> </div>
<div class="flex-1 text-right"> <StatusBadge :status="currentHealthStatus" />
{{ event.fancyTimeAgo }} </div>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">Current Status</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ currentHealthStatus === 'healthy' ? 'Operational' : 'Issues Detected' }}</div>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">Avg Response Time</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ pageAverageResponseTime }}ms</div>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">Response Time Range</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ pageResponseTimeRange }}</div>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">Last Check</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ lastCheckTime }}</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle>Recent Checks</CardTitle>
<div class="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
@click="showAverageResponseTime = !showAverageResponseTime"
:title="showAverageResponseTime ? 'Show min-max response time' : 'Show average response time'"
>
<Activity v-if="showAverageResponseTime" class="h-5 w-5" />
<Timer v-else class="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"
@click="fetchData"
title="Refresh data"
:disabled="isRefreshing"
>
<RefreshCw :class="['h-4 w-4', isRefreshing && 'animate-spin']" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div class="space-y-4">
<EndpointCard
v-if="endpointStatus"
:endpoint="endpointStatus"
:maxResults="50"
:showAverageResponseTime="showAverageResponseTime"
@showTooltip="showTooltip"
class="border-0 shadow-none bg-transparent p-0"
/>
<div v-if="endpointStatus && endpointStatus.key" class="pt-4 border-t">
<Pagination @page="changePage" :numberOfResultsPerPage="50" :currentPageProp="currentPage" />
</div>
</div>
</CardContent>
</Card>
<div v-if="showResponseTimeChartAndBadges" class="space-y-6">
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle>Response Time Trend</CardTitle>
<select
v-model="selectedChartDuration"
class="text-sm bg-background border rounded-md px-3 py-1 focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="24h">24 hours</option>
<option value="7d">7 days</option>
<option value="30d">30 days</option>
</select>
</div>
</CardHeader>
<CardContent>
<img :src="generateResponseTimeChartImageURL(selectedChartDuration)" alt="Response time chart" class="w-full" />
</CardContent>
</Card>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card v-for="period in ['30d', '7d', '24h', '1h']" :key="period">
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground text-center">
{{ period === '30d' ? 'Last 30 days' : period === '7d' ? 'Last 7 days' : period === '24h' ? 'Last 24 hours' : 'Last hour' }}
</CardTitle>
</CardHeader>
<CardContent>
<img :src="generateResponseTimeBadgeImageURL(period)" :alt="`${period} response time`" class="mx-auto mt-2" />
</CardContent>
</Card>
</div> </div>
</div> </div>
</li>
</ul> <Card>
<CardHeader>
<CardTitle>Uptime Statistics</CardTitle>
</CardHeader>
<CardContent>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div v-for="period in ['30d', '7d', '24h', '1h']" :key="period" class="text-center">
<p class="text-sm text-muted-foreground mb-2">
{{ period === '30d' ? 'Last 30 days' : period === '7d' ? 'Last 7 days' : period === '24h' ? 'Last 24 hours' : 'Last hour' }}
</p>
<img :src="generateUptimeBadgeImageURL(period)" :alt="`${period} uptime`" class="mx-auto" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Current Health</CardTitle>
</CardHeader>
<CardContent>
<div class="text-center">
<img :src="generateHealthBadgeImageURL()" alt="health badge" class="mx-auto" />
</div>
</CardContent>
</Card>
<Card v-if="events && events.length > 0">
<CardHeader>
<CardTitle>Events</CardTitle>
</CardHeader>
<CardContent>
<div class="space-y-4">
<div v-for="event in events" :key="event.timestamp" class="flex items-start gap-4 pb-4 border-b last:border-0">
<div class="mt-1">
<ArrowUpCircle v-if="event.type === 'HEALTHY'" class="h-5 w-5 text-green-500" />
<ArrowDownCircle v-else-if="event.type === 'UNHEALTHY'" class="h-5 w-5 text-red-500" />
<PlayCircle v-else class="h-5 w-5 text-muted-foreground" />
</div>
<div class="flex-1">
<p class="font-medium">{{ event.fancyText }}</p>
<p class="text-sm text-muted-foreground">{{ prettifyTimestamp(event.timestamp) }} {{ event.fancyTimeAgo }}</p>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<div v-else class="flex items-center justify-center py-20">
<Loading size="lg" />
</div>
</div>
</div> </div>
<Settings @refreshData="fetchData" />
</div> </div>
<Settings @refreshData="fetchData"/>
</template> </template>
<script setup>
<script> /* eslint-disable no-undef */
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ArrowLeft, RefreshCw, ArrowUpCircle, ArrowDownCircle, PlayCircle, Activity, Timer } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import StatusBadge from '@/components/StatusBadge.vue'
import EndpointCard from '@/components/EndpointCard.vue'
import Settings from '@/components/Settings.vue' import Settings from '@/components/Settings.vue'
import Endpoint from '@/components/Endpoint.vue'; import Pagination from '@/components/Pagination.vue'
import {SERVER_URL} from "@/main.js"; import Loading from '@/components/Loading.vue'
import {helper} from "@/mixins/helper.js"; import { SERVER_URL } from '@/main.js'
import Pagination from "@/components/Pagination"; import { helper } from '@/mixins/helper'
import { ArrowDownCircleIcon, ArrowUpCircleIcon, PlayCircleIcon } from '@heroicons/vue/20/solid'
export default { const router = useRouter()
name: 'Details', const route = useRoute()
components: { const emit = defineEmits(['showTooltip'])
Pagination,
Endpoint, const endpointStatus = ref(null) // For paginated historical data
Settings, const currentStatus = ref(null) // For current/latest status (always page 1)
ArrowDownCircleIcon, const events = ref([])
ArrowUpCircleIcon, const currentPage = ref(1)
PlayCircleIcon const showResponseTimeChartAndBadges = ref(false)
}, const showAverageResponseTime = ref(false)
emits: ['showTooltip'], const selectedChartDuration = ref('24h')
mixins: [helper], const serverUrl = SERVER_URL === '.' ? '..' : SERVER_URL
methods: { const isRefreshing = ref(false)
fetchData() {
//console.log("[Details][fetchData] Fetching data"); const latestResult = computed(() => {
fetch(`${this.serverUrl}/api/v1/endpoints/${this.$route.params.key}/statuses?page=${this.currentPage}`, {credentials: 'include'}) // Use currentStatus for the actual latest result
.then(response => { if (!currentStatus.value || !currentStatus.value.results || currentStatus.value.results.length === 0) {
if (response.status === 200) { return null
response.json().then(data => { }
if (JSON.stringify(this.endpointStatus) !== JSON.stringify(data)) { return currentStatus.value.results[currentStatus.value.results.length - 1]
this.endpointStatus = data; })
let events = [];
for (let i = data.events.length - 1; i >= 0; i--) { const currentHealthStatus = computed(() => {
let event = data.events[i]; if (!latestResult.value) return 'unknown'
if (i === data.events.length - 1) { return latestResult.value.success ? 'healthy' : 'unhealthy'
if (event.type === 'UNHEALTHY') { })
event.fancyText = 'Endpoint is unhealthy';
} else if (event.type === 'HEALTHY') { const hostname = computed(() => {
event.fancyText = 'Endpoint is healthy'; return latestResult.value?.hostname || null
} else if (event.type === 'START') { })
event.fancyText = 'Monitoring started';
} const pageAverageResponseTime = computed(() => {
} else { // Use endpointStatus for current page's average response time
let nextEvent = data.events[i + 1]; if (!endpointStatus.value || !endpointStatus.value.results || endpointStatus.value.results.length === 0) {
if (event.type === 'HEALTHY') { return 'N/A'
event.fancyText = 'Endpoint became healthy'; }
} else if (event.type === 'UNHEALTHY') { let total = 0
if (nextEvent) { let count = 0
event.fancyText = 'Endpoint was unhealthy for ' + this.generatePrettyTimeDifference(nextEvent.timestamp, event.timestamp); for (const result of endpointStatus.value.results) {
} else { if (result.duration) {
event.fancyText = 'Endpoint became unhealthy'; total += result.duration
} count++
} else if (event.type === 'START') {
event.fancyText = 'Monitoring started';
}
}
event.fancyTimeAgo = this.generatePrettyTimeAgo(event.timestamp);
events.push(event);
}
this.events = events;
// Check if there's any non-0 response time data
// If there isn't, it's likely an external endpoint, which means we should
// hide the response time chart and badges
for (let i = 0; i < data.results.length; i++) {
if (data.results[i].duration > 0) {
this.showResponseTimeChartAndBadges = true;
break;
}
}
}
});
} else {
response.text().then(text => {
console.log(`[Details][fetchData] Error: ${text}`);
});
}
});
},
generateHealthBadgeImageURL() {
return `${this.serverUrl}/api/v1/endpoints/${this.endpointStatus.key}/health/badge.svg`;
},
generateUptimeBadgeImageURL(duration) {
return `${this.serverUrl}/api/v1/endpoints/${this.endpointStatus.key}/uptimes/${duration}/badge.svg`;
},
generateResponseTimeBadgeImageURL(duration) {
return `${this.serverUrl}/api/v1/endpoints/${this.endpointStatus.key}/response-times/${duration}/badge.svg`;
},
generateResponseTimeChartImageURL(duration) {
return `${this.serverUrl}/api/v1/endpoints/${this.endpointStatus.key}/response-times/${duration}/chart.svg`;
},
changePage(page) {
this.currentPage = page;
this.fetchData();
},
showTooltip(result, event) {
this.$emit('showTooltip', result, event);
},
toggleShowAverageResponseTime() {
this.showAverageResponseTime = !this.showAverageResponseTime;
},
},
data() {
return {
endpointStatus: {},
events: [],
hourlyAverageResponseTime: {},
selectedChartDuration: '24h',
// Since this page isn't at the root, we need to modify the server URL a bit
serverUrl: SERVER_URL === '.' ? '..' : SERVER_URL,
currentPage: 1,
showAverageResponseTime: true,
showResponseTimeChartAndBadges: false,
chartLabels: [],
chartValues: [],
} }
}, }
created() { if (count === 0) return 'N/A'
this.fetchData(); return Math.round(total / count / 1000000)
})
const pageResponseTimeRange = computed(() => {
// Use endpointStatus for current page's response time range
if (!endpointStatus.value || !endpointStatus.value.results || endpointStatus.value.results.length === 0) {
return 'N/A'
}
let min = Infinity
let max = 0
let hasData = false
for (const result of endpointStatus.value.results) {
if (result.duration) {
const durationMs = result.duration / 1000000
min = Math.min(min, durationMs)
max = Math.max(max, durationMs)
hasData = true
}
}
if (!hasData) return 'N/A'
const minMs = Math.round(min)
const maxMs = Math.round(max)
// If min and max are the same, show single value
if (minMs === maxMs) {
return `${minMs}ms`
}
return `${minMs}-${maxMs}ms`
})
const lastCheckTime = computed(() => {
// Use currentStatus for real-time last check time
if (!currentStatus.value || !currentStatus.value.results || currentStatus.value.results.length === 0) {
return 'Never'
}
return helper.methods.generatePrettyTimeAgo(currentStatus.value.results[currentStatus.value.results.length - 1].timestamp)
})
const fetchData = async () => {
isRefreshing.value = true
try {
const response = await fetch(`${serverUrl}/api/v1/endpoints/${route.params.key}/statuses?page=${currentPage.value}&pageSize=50`, {
credentials: 'include'
})
if (response.status === 200) {
const data = await response.json()
endpointStatus.value = data
// Always update currentStatus when on page 1 (including when returning to it)
if (currentPage.value === 1) {
currentStatus.value = data
}
let processedEvents = []
if (data.events && data.events.length > 0) {
for (let i = data.events.length - 1; i >= 0; i--) {
let event = data.events[i]
if (i === data.events.length - 1) {
if (event.type === 'UNHEALTHY') {
event.fancyText = 'Endpoint is unhealthy'
} else if (event.type === 'HEALTHY') {
event.fancyText = 'Endpoint is healthy'
} else if (event.type === 'START') {
event.fancyText = 'Monitoring started'
}
} else {
let nextEvent = data.events[i + 1]
if (event.type === 'HEALTHY') {
event.fancyText = 'Endpoint became healthy'
} else if (event.type === 'UNHEALTHY') {
if (nextEvent) {
event.fancyText = 'Endpoint was unhealthy for ' + helper.methods.generatePrettyTimeDifference(nextEvent.timestamp, event.timestamp)
} else {
event.fancyText = 'Endpoint became unhealthy'
}
} else if (event.type === 'START') {
event.fancyText = 'Monitoring started'
}
}
event.fancyTimeAgo = helper.methods.generatePrettyTimeAgo(event.timestamp)
processedEvents.push(event)
}
}
events.value = processedEvents
if (data.results && data.results.length > 0) {
for (let i = 0; i < data.results.length; i++) {
if (data.results[i].duration > 0) {
showResponseTimeChartAndBadges.value = true
break
}
}
}
} else {
console.error('[Details][fetchData] Error:', await response.text())
}
} catch (error) {
console.error('[Details][fetchData] Error:', error)
} finally {
isRefreshing.value = false
} }
} }
</script>
<style scoped> const goBack = () => {
.endpoint { router.push('/')
border-radius: 3px;
border-bottom-width: 3px;
} }
</style>
const changePage = (page) => {
currentPage.value = page
fetchData()
}
const showTooltip = (result, event) => {
emit('showTooltip', result, event)
}
const prettifyTimestamp = (timestamp) => {
return new Date(timestamp).toLocaleString()
}
const generateHealthBadgeImageURL = () => {
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/health/badge.svg`
}
const generateUptimeBadgeImageURL = (duration) => {
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/uptimes/${duration}/badge.svg`
}
const generateResponseTimeBadgeImageURL = (duration) => {
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/response-times/${duration}/badge.svg`
}
const generateResponseTimeChartImageURL = (duration) => {
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/response-times/${duration}/chart.svg`
}
onMounted(() => {
fetchData()
})
</script>

View File

@@ -1,76 +1,346 @@
<template> <template>
<Loading v-if="!retrievedData" class="h-64 w-64 px-4 my-24"/> <div class="dashboard-container bg-background">
<slot> <div class="container mx-auto px-4 py-8 max-w-7xl">
<Endpoints <div class="mb-8">
v-show="retrievedData" <div class="flex items-center justify-between mb-6">
:endpointStatuses="endpointStatuses" <div>
:showStatusOnHover="true" <h1 class="text-4xl font-bold tracking-tight">Health Dashboard</h1>
@showTooltip="showTooltip" <p class="text-muted-foreground mt-2">Monitor the health of your endpoints in real-time</p>
@toggleShowAverageResponseTime="toggleShowAverageResponseTime" </div>
:showAverageResponseTime="showAverageResponseTime" <div class="flex items-center gap-4">
/> <Button
<Pagination v-show="retrievedData" @page="changePage" :numberOfResultsPerPage="20" /> variant="ghost"
</slot> size="icon"
<Settings @refreshData="fetchData"/> @click="toggleShowAverageResponseTime"
:title="showAverageResponseTime ? 'Show min-max response time' : 'Show average response time'"
>
<Activity v-if="showAverageResponseTime" class="h-5 w-5" />
<Timer v-else class="h-5 w-5" />
</Button>
<Button variant="ghost" size="icon" @click="refreshData" title="Refresh data">
<RefreshCw class="h-5 w-5" />
</Button>
</div>
</div>
<SearchBar
@search="handleSearch"
@update:showOnlyFailing="showOnlyFailing = $event"
@update:showRecentFailures="showRecentFailures = $event"
@update:groupByGroup="groupByGroup = $event"
@update:sortBy="sortBy = $event"
@initializeCollapsedGroups="initializeCollapsedGroups"
/>
</div>
<div v-if="loading" class="flex items-center justify-center py-20">
<Loading size="lg" />
</div>
<div v-else-if="filteredEndpoints.length === 0" class="text-center py-20">
<AlertCircle class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 class="text-lg font-semibold mb-2">No endpoints found</h3>
<p class="text-muted-foreground">
{{ searchQuery || showOnlyFailing || showRecentFailures
? 'Try adjusting your filters'
: 'No endpoints are configured' }}
</p>
</div>
<div v-else>
<!-- Grouped view -->
<div v-if="groupByGroup" class="space-y-6">
<div v-for="(endpoints, group) in paginatedEndpoints" :key="group" class="endpoint-group border rounded-lg overflow-hidden">
<!-- Group Header -->
<div
@click="toggleGroupCollapse(group)"
class="endpoint-group-header flex items-center justify-between p-4 bg-card border-b cursor-pointer hover:bg-accent/50 transition-colors"
>
<div class="flex items-center gap-3">
<ChevronDown v-if="!collapsedGroups.has(group)" class="h-5 w-5 text-muted-foreground" />
<ChevronUp v-else class="h-5 w-5 text-muted-foreground" />
<h2 class="text-xl font-semibold text-foreground">{{ group }}</h2>
</div>
<div class="flex items-center gap-2">
<span v-if="calculateUnhealthyCount(endpoints) > 0"
class="bg-red-600 text-white px-2 py-1 rounded-full text-sm font-medium">
{{ calculateUnhealthyCount(endpoints) }}
</span>
<CheckCircle v-else class="h-6 w-6 text-green-600" />
</div>
</div>
<!-- Group Content -->
<div v-if="!collapsedGroups.has(group)" class="endpoint-group-content p-4">
<div class="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<EndpointCard
v-for="endpoint in endpoints"
:key="endpoint.key"
:endpoint="endpoint"
:maxResults="50"
:showAverageResponseTime="showAverageResponseTime"
@showTooltip="showTooltip"
/>
</div>
</div>
</div>
</div>
<!-- Regular view -->
<div v-else class="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<EndpointCard
v-for="endpoint in paginatedEndpoints"
:key="endpoint.key"
:endpoint="endpoint"
:maxResults="50"
:showAverageResponseTime="showAverageResponseTime"
@showTooltip="showTooltip"
/>
</div>
<div v-if="!groupByGroup && totalPages > 1" class="mt-8 flex items-center justify-center gap-2">
<Button
variant="outline"
size="icon"
:disabled="currentPage === 1"
@click="goToPage(currentPage - 1)"
>
<ChevronLeft class="h-4 w-4" />
</Button>
<div class="flex gap-1">
<Button
v-for="page in visiblePages"
:key="page"
:variant="page === currentPage ? 'default' : 'outline'"
size="sm"
@click="goToPage(page)"
>
{{ page }}
</Button>
</div>
<Button
variant="outline"
size="icon"
:disabled="currentPage === totalPages"
@click="goToPage(currentPage + 1)"
>
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</div>
</div>
<Settings @refreshData="fetchData" />
</div>
</template> </template>
<script> <script setup>
/* eslint-disable no-undef */
import { ref, computed, onMounted } from 'vue'
import { Activity, Timer, RefreshCw, AlertCircle, ChevronLeft, ChevronRight, ChevronDown, ChevronUp, CheckCircle } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import EndpointCard from '@/components/EndpointCard.vue'
import SearchBar from '@/components/SearchBar.vue'
import Settings from '@/components/Settings.vue' import Settings from '@/components/Settings.vue'
import Endpoints from '@/components/Endpoints.vue'; import Loading from '@/components/Loading.vue'
import Pagination from "@/components/Pagination"; import { SERVER_URL } from '@/main.js'
import Loading from "@/components/Loading";
import {SERVER_URL} from "@/main.js";
export default { const emit = defineEmits(['showTooltip'])
name: 'Home',
components: { const endpointStatuses = ref([])
Loading, const loading = ref(false)
Pagination, const currentPage = ref(1)
Endpoints, const itemsPerPage = 96
Settings, const searchQuery = ref('')
}, const showOnlyFailing = ref(false)
emits: ['showTooltip', 'toggleShowAverageResponseTime'], const showRecentFailures = ref(false)
methods: { const showAverageResponseTime = ref(true)
fetchData() { const groupByGroup = ref(false)
fetch(`${SERVER_URL}/api/v1/endpoints/statuses?page=${this.currentPage}`, {credentials: 'include'}) const sortBy = ref(localStorage.getItem('gatus:sort-by') || 'name')
.then(response => { const collapsedGroups = ref(new Set())
this.retrievedData = true;
if (response.status === 200) { const filteredEndpoints = computed(() => {
response.json().then(data => { let filtered = [...endpointStatuses.value]
if (JSON.stringify(this.endpointStatuses) !== JSON.stringify(data)) {
this.endpointStatuses = data; if (searchQuery.value) {
} const query = searchQuery.value.toLowerCase()
}); filtered = filtered.filter(endpoint =>
} else { endpoint.name.toLowerCase().includes(query) ||
response.text().then(text => { (endpoint.group && endpoint.group.toLowerCase().includes(query))
console.log(`[Home][fetchData] Error: ${text}`); )
}); }
}
}); if (showOnlyFailing.value) {
}, filtered = filtered.filter(endpoint => {
changePage(page) { if (!endpoint.results || endpoint.results.length === 0) return false
this.retrievedData = false; // Show loading only on page change or on initial load const latestResult = endpoint.results[endpoint.results.length - 1]
this.currentPage = page; return !latestResult.success
this.fetchData(); })
}, }
showTooltip(result, event) {
this.$emit('showTooltip', result, event); if (showRecentFailures.value) {
}, filtered = filtered.filter(endpoint => {
toggleShowAverageResponseTime() { if (!endpoint.results || endpoint.results.length === 0) return false
this.showAverageResponseTime = !this.showAverageResponseTime; return endpoint.results.some(result => !result.success)
}, })
}, }
data() {
return { // Sort by health if selected
endpointStatuses: [], if (sortBy.value === 'health') {
currentPage: 1, filtered.sort((a, b) => {
showAverageResponseTime: true, const aHealthy = a.results && a.results.length > 0 && a.results[a.results.length - 1].success
retrievedData: false, const bHealthy = b.results && b.results.length > 0 && b.results[b.results.length - 1].success
// Unhealthy first
if (!aHealthy && bHealthy) return -1
if (aHealthy && !bHealthy) return 1
// Then sort by name
return a.name.localeCompare(b.name)
})
}
return filtered
})
const totalPages = computed(() => {
return Math.ceil(filteredEndpoints.value.length / itemsPerPage)
})
const groupedEndpoints = computed(() => {
if (!groupByGroup.value) {
return null
}
const grouped = {}
filteredEndpoints.value.forEach(endpoint => {
const group = endpoint.group || 'No Group'
if (!grouped[group]) {
grouped[group] = []
} }
}, grouped[group].push(endpoint)
created() { })
this.retrievedData = false; // Show loading only on page change or on initial load
this.fetchData(); // Sort groups alphabetically, with 'No Group' at the end
const sortedGroups = Object.keys(grouped).sort((a, b) => {
if (a === 'No Group') return 1
if (b === 'No Group') return -1
return a.localeCompare(b)
})
const result = {}
sortedGroups.forEach(group => {
result[group] = grouped[group]
})
return result
})
const paginatedEndpoints = computed(() => {
if (groupByGroup.value) {
// When grouping, we don't paginate
return groupedEndpoints.value
}
const start = (currentPage.value - 1) * itemsPerPage
const end = start + itemsPerPage
return filteredEndpoints.value.slice(start, end)
})
const visiblePages = computed(() => {
const pages = []
const maxVisible = 5
let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2))
let end = Math.min(totalPages.value, start + maxVisible - 1)
if (end - start < maxVisible - 1) {
start = Math.max(1, end - maxVisible + 1)
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
})
const fetchData = async () => {
loading.value = true
try {
const response = await fetch(`${SERVER_URL}/api/v1/endpoints/statuses?page=1&pageSize=100`, {
credentials: 'include'
})
if (response.status === 200) {
const data = await response.json()
endpointStatuses.value = data
} else {
console.error('[Home][fetchData] Error:', await response.text())
}
} catch (error) {
console.error('[Home][fetchData] Error:', error)
} finally {
loading.value = false
} }
} }
const refreshData = () => {
fetchData()
}
const handleSearch = (query) => {
searchQuery.value = query
currentPage.value = 1
}
const goToPage = (page) => {
currentPage.value = page
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const toggleShowAverageResponseTime = () => {
showAverageResponseTime.value = !showAverageResponseTime.value
}
const showTooltip = (result, event) => {
emit('showTooltip', result, event)
}
const calculateUnhealthyCount = (endpoints) => {
return endpoints.filter(endpoint => {
if (!endpoint.results || endpoint.results.length === 0) return false
const latestResult = endpoint.results[endpoint.results.length - 1]
return !latestResult.success
}).length
}
const toggleGroupCollapse = (groupName) => {
if (collapsedGroups.value.has(groupName)) {
collapsedGroups.value.delete(groupName)
} else {
collapsedGroups.value.add(groupName)
}
// Save to localStorage
const collapsed = Array.from(collapsedGroups.value)
localStorage.setItem('gatus:collapsed-groups', JSON.stringify(collapsed))
}
const initializeCollapsedGroups = () => {
// Get saved collapsed groups from localStorage
try {
const saved = localStorage.getItem('gatus:collapsed-groups')
if (saved) {
collapsedGroups.value = new Set(JSON.parse(saved))
}
} catch (e) {
console.warn('Failed to parse saved collapsed groups:', e)
localStorage.removeItem('gatus:collapsed-groups')
}
}
onMounted(() => {
fetchData()
})
</script> </script>

View File

@@ -6,9 +6,65 @@ module.exports = {
darkMode: 'class', // or 'media' or 'class' darkMode: 'class', // or 'media' or 'class'
theme: { theme: {
fontFamily: { fontFamily: {
'mono': ['Consolas', 'Monaco', '"Courier New"', 'monospace'] 'mono': ['Consolas', 'Monaco', '"Courier New"', 'monospace'],
'sans': ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif']
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
"accordion-down": {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
"accordion-up": {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
}, },
extend: {},
}, },
variants: { variants: {
extend: {}, extend: {},

View File

@@ -1,3 +1,7 @@
// Note: The fs.Stats deprecation warning is from Vue CLI's webpack dependencies
// which are not yet compatible with Node.js v23. This is suppressed in the build
// script. All user dependencies have been updated to their latest versions.
// Consider migrating to Vite for better Node.js v23+ compatibility.
module.exports = { module.exports = {
filenameHashing: false, filenameHashing: false,
productionSourceMap: false, productionSourceMap: false,

File diff suppressed because one or more lines are too long

View File

@@ -1 +1,11 @@
<!doctype html><html lang="en" class="{{ .Theme }}"><head><meta charset="utf-8"/><script>window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", link: "{{ .UI.Link }}", buttons: [], maximumNumberOfResults: "{{ .UI.MaximumNumberOfResults }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}</script><title>{{ .UI.Title }}</title><meta http-equiv="X-UA-Compatible" content="IE=edge"/><meta name="viewport" content="width=device-width,initial-scale=1"/><link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"/><link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png"/><link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png"/><link rel="manifest" href="/manifest.json" crossorigin="use-credentials"/><link rel="shortcut icon" href="/favicon.ico"/><link rel="stylesheet" href="/css/custom.css"/><meta name="description" content="{{ .UI.Description }}"/><meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/><meta name="apple-mobile-web-app-title" content="{{ .UI.Title }}"/><meta name="application-name" content="{{ .UI.Title }}"/><meta name="theme-color" content="#f7f9fb"/><script defer="defer" src="/js/chunk-vendors.js"></script><script defer="defer" src="/js/app.js"></script><link href="/css/app.css" rel="stylesheet"></head><body class="dark:bg-gray-900"><noscript><strong>Enable JavaScript to view this page.</strong></noscript><div id="app"></div></body></html> <!doctype html><html lang="en" class="{{ .Theme }}"><head><meta charset="utf-8"/><script>window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", link: "{{ .UI.Link }}", buttons: [], maximumNumberOfResults: "{{ .UI.MaximumNumberOfResults }}", defaultSortBy: "{{ .UI.DefaultSortBy }}", defaultFilterBy: "{{ .UI.DefaultFilterBy }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}
// Initialize theme immediately to prevent flash
(function() {
const themeFromCookie = document.cookie.match(/theme=(dark|light);?/)?.[1];
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (themeFromCookie === 'dark' || (!themeFromCookie && prefersDark)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
})();</script><title>{{ .UI.Title }}</title><meta http-equiv="X-UA-Compatible" content="IE=edge"/><meta name="viewport" content="width=device-width,initial-scale=1"/><link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"/><link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png"/><link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png"/><link rel="manifest" href="/manifest.json" crossorigin="use-credentials"/><link rel="shortcut icon" href="/favicon.ico"/><link rel="stylesheet" href="/css/custom.css"/><meta name="description" content="{{ .UI.Description }}"/><meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/><meta name="apple-mobile-web-app-title" content="{{ .UI.Title }}"/><meta name="application-name" content="{{ .UI.Title }}"/><meta name="theme-color" content="#f7f9fb"/><script defer="defer" src="/js/chunk-vendors.js"></script><script defer="defer" src="/js/app.js"></script><link href="/css/app.css" rel="stylesheet"></head><body><noscript><strong>Enable JavaScript to view this page.</strong></noscript><div id="app"></div></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long