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
BIN
.github/assets/dashboard-conditions.jpg
vendored
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
.github/assets/dashboard-conditions.png
vendored
|
Before Width: | Height: | Size: 43 KiB |
BIN
.github/assets/dashboard-dark.jpg
vendored
Normal file
|
After Width: | Height: | Size: 536 KiB |
BIN
.github/assets/dashboard-dark.png
vendored
|
Before Width: | Height: | Size: 90 KiB |
BIN
.github/assets/endpoint-groups.jpg
vendored
Normal file
|
After Width: | Height: | Size: 375 KiB |
BIN
.github/assets/endpoint-groups.png
vendored
|
Before Width: | Height: | Size: 39 KiB |
BIN
.github/assets/example.jpg
vendored
Normal file
|
After Width: | Height: | Size: 207 KiB |
BIN
.github/assets/example.png
vendored
|
Before Width: | Height: | Size: 43 KiB |
71
README.md
@@ -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).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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)**:  
|
- **[Badges](#badges)**:  
|
||||||
- **Dark mode**
|
- **Dark mode**
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@@ -201,7 +201,7 @@ endpoints:
|
|||||||
|
|
||||||
This example would look similar to this:
|
This example would look similar to this:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
### Exposing Gatus on a custom path
|
### Exposing Gatus on a custom path
|
||||||
|
|||||||
@@ -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))]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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"> </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"> </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'>
|
|
||||||
|
|
||||||
</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>
|
|
||||||
159
web/app/src/components/EndpointCard.vue
Normal 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>
|
||||||
@@ -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 ? '▼' : '▲' }}
|
|
||||||
</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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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({
|
||||||
|
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>
|
</script>
|
||||||
@@ -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"><</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">></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>
|
||||||
100
web/app/src/components/SearchBar.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
58
web/app/src/components/StatusBadge.vue
Normal 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>
|
||||||
@@ -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 ? "✓" : "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, '&')
|
})
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
// State
|
||||||
.replace(/"/g, '"')
|
const hidden = ref(true)
|
||||||
.replace(/'/g, ''');
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 })
|
||||||
|
|
||||||
|
watch(() => props.result, () => {
|
||||||
|
if (!hidden.value) {
|
||||||
|
nextTick(() => reposition())
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<style>
|
|
||||||
#tooltip {
|
|
||||||
position: fixed;
|
|
||||||
background-color: white;
|
|
||||||
border: 1px solid lightgray;
|
|
||||||
border-radius: 4px;
|
|
||||||
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>
|
|
||||||
|
|||||||
37
web/app/src/components/ui/badge/Badge.vue
Normal 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>
|
||||||
1
web/app/src/components/ui/badge/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Badge } from './Badge.vue'
|
||||||
55
web/app/src/components/ui/button/Button.vue
Normal 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>
|
||||||
1
web/app/src/components/ui/button/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Button } from './Button.vue'
|
||||||
9
web/app/src/components/ui/card/Card.vue
Normal 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>
|
||||||
9
web/app/src/components/ui/card/CardContent.vue
Normal 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>
|
||||||
9
web/app/src/components/ui/card/CardHeader.vue
Normal 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>
|
||||||
9
web/app/src/components/ui/card/CardTitle.vue
Normal 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>
|
||||||
4
web/app/src/components/ui/card/index.js
Normal 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'
|
||||||
24
web/app/src/components/ui/input/Input.vue
Normal 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>
|
||||||
1
web/app/src/components/ui/input/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Input } from './Input.vue'
|
||||||
127
web/app/src/components/ui/select/Select.vue
Normal 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>
|
||||||
1
web/app/src/components/ui/select/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Select } from './Select.vue'
|
||||||
@@ -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
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function combineClasses(...inputs) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
←
|
<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>
|
||||||
@@ -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>
|
||||||
@@ -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: {},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||