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
No files matched your search
|
After Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 536 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 375 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 43 KiB |
@@ -32,7 +32,7 @@ For more details, see [Usage](#usage)
|
||||
|
||||
> ❤ 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).
|
||||
|
||||
@@ -162,7 +162,7 @@ The main features of Gatus are:
|
||||
- **[Badges](#badges)**:  
|
||||
- **Dark mode**
|
||||
|
||||

|
||||

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

|
||||

|
||||
|
||||
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
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------|:-------------------------------------------------------------------------------------------------------------------------------------|:---------------------------|
|
||||
| `metrics` | Whether to expose metrics at `/metrics`. | `false` |
|
||||
| `storage` | [Storage configuration](#storage). | `{}` |
|
||||
| `alerting` | [Alerting configuration](#alerting). | `{}` |
|
||||
| `endpoints` | [Endpoints configuration](#endpoints). | Required `[]` |
|
||||
| `external-endpoints` | [External Endpoints configuration](#external-endpoints). | `[]` |
|
||||
| `security` | [Security configuration](#security). | `{}` |
|
||||
| `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` |
|
||||
| `web` | Web configuration. | `{}` |
|
||||
| `web.address` | Address to listen on. | `0.0.0.0` |
|
||||
| `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.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. | `` |
|
||||
| `ui` | UI configuration. | `{}` |
|
||||
| `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.header` | Header at the top of the dashboard. | `Health Status` |
|
||||
| `ui.logo` | URL to the logo to display. | `""` |
|
||||
| `ui.link` | Link to open when the logo is clicked. | `""` |
|
||||
| `ui.buttons` | List of buttons to display below the header. | `[]` |
|
||||
| `ui.buttons[].name` | Text to display on the button. | Required `""` |
|
||||
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` |
|
||||
| `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` |
|
||||
| `maintenance` | [Maintenance configuration](#maintenance). | `{}` |
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------|:---------------------------|
|
||||
| `metrics` | Whether to expose metrics at `/metrics`. | `false` |
|
||||
| `storage` | [Storage configuration](#storage). | `{}` |
|
||||
| `alerting` | [Alerting configuration](#alerting). | `{}` |
|
||||
| `endpoints` | [Endpoints configuration](#endpoints). | Required `[]` |
|
||||
| `external-endpoints` | [External Endpoints configuration](#external-endpoints). | `[]` |
|
||||
| `security` | [Security configuration](#security). | `{}` |
|
||||
| `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` |
|
||||
| `web` | Web configuration. | `{}` |
|
||||
| `web.address` | Address to listen on. | `0.0.0.0` |
|
||||
| `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.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. | `""` |
|
||||
| `ui` | UI configuration. | `{}` |
|
||||
| `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.header` | Header at the top of the dashboard. | `Gatus` |
|
||||
| `ui.logo` | URL to the logo to display. | `""` |
|
||||
| `ui.link` | Link to open when the logo is clicked. | `""` |
|
||||
| `ui.buttons` | List of buttons to display below the header. | `[]` |
|
||||
| `ui.buttons[].name` | Text to display on the button. | Required `""` |
|
||||
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` |
|
||||
| `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.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`.
|
||||
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.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[].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`):
|
||||
- `[ENDPOINT_NAME]` (resolved from `endpoints[].name`)
|
||||
@@ -1966,7 +1969,7 @@ endpoints:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 150"
|
||||
labels:
|
||||
extra-labels:
|
||||
environment: staging
|
||||
```
|
||||
|
||||
@@ -2431,9 +2434,9 @@ endpoints:
|
||||
- "[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
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"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
|
||||
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, n)
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
for i := range b {
|
||||
b[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))]
|
||||
}
|
||||
|
||||
@@ -41,26 +41,26 @@ func TestGetHTTPClient(t *testing.T) {
|
||||
|
||||
func TestGetDomainExpiration(t *testing.T) {
|
||||
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)
|
||||
} else if domainExpiration <= 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)
|
||||
} else if domainExpiration <= 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
|
||||
whoisExpirationDateCache.SetWithTTL("example.com", time.Now().Add(time.Hour), 25*time.Hour)
|
||||
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil {
|
||||
whoisExpirationDateCache.SetWithTTL("gatus.io", time.Now().Add(time.Hour), 25*time.Hour)
|
||||
if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
|
||||
t.Errorf("expected error to be nil, but got: `%s`", err)
|
||||
} else if domainExpiration <= 0 {
|
||||
t.Error("expected domain expiration to be higher than 0")
|
||||
}
|
||||
// Make sure the refresh works when the ttl is <24 hours
|
||||
whoisExpirationDateCache.SetWithTTL("example.com", time.Now().Add(35*time.Hour), 23*time.Hour)
|
||||
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil {
|
||||
whoisExpirationDateCache.SetWithTTL("gatus.io", time.Now().Add(35*time.Hour), 23*time.Hour)
|
||||
if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
|
||||
t.Errorf("expected error to be nil, but got: `%s`", err)
|
||||
} else if domainExpiration <= 0 {
|
||||
t.Error("expected domain expiration to be higher than 0")
|
||||
|
||||
@@ -37,7 +37,7 @@ endpoints:
|
||||
query-name: "example.com"
|
||||
query-type: "A"
|
||||
conditions:
|
||||
- "[BODY] == 93.184.215.14"
|
||||
- "[BODY] == pat(*.*.*.*)" # Matches any IPv4 address
|
||||
- "[DNS_RCODE] == NOERROR"
|
||||
|
||||
- name: icmp-ping
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -118,6 +119,9 @@ func (config *Config) GetUniqueExtraMetricLabels() []string {
|
||||
labels = append(labels, label)
|
||||
}
|
||||
}
|
||||
if len(labels) > 1 {
|
||||
sort.Strings(labels)
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
|
||||
@@ -12,28 +12,34 @@ import (
|
||||
const (
|
||||
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"
|
||||
defaultHeader = "Health Status"
|
||||
defaultHeader = "Gatus"
|
||||
defaultLogo = ""
|
||||
defaultLink = ""
|
||||
defaultCustomCSS = ""
|
||||
defaultSortBy = "name"
|
||||
defaultFilterBy = "nothing"
|
||||
)
|
||||
|
||||
var (
|
||||
defaultDarkMode = true
|
||||
|
||||
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
|
||||
type Config struct {
|
||||
Title string `yaml:"title,omitempty"` // Title 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
|
||||
Logo string `yaml:"logo,omitempty"` // Logo to display on the page
|
||||
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
|
||||
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
|
||||
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
|
||||
Title string `yaml:"title,omitempty"` // Title 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
|
||||
Logo string `yaml:"logo,omitempty"` // Logo to display on the page
|
||||
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
|
||||
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
|
||||
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
|
||||
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 //
|
||||
@@ -72,6 +78,8 @@ func GetDefaultConfig() *Config {
|
||||
Link: defaultLink,
|
||||
CustomCSS: defaultCustomCSS,
|
||||
DarkMode: &defaultDarkMode,
|
||||
DefaultSortBy: defaultSortBy,
|
||||
DefaultFilterBy: defaultFilterBy,
|
||||
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
|
||||
}
|
||||
}
|
||||
@@ -99,6 +107,16 @@ func (cfg *Config) ValidateAndSetDefaults() error {
|
||||
if cfg.DarkMode == nil {
|
||||
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 {
|
||||
if err := btn.Validate(); err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
@@ -25,6 +26,12 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||
if cfg.Header != defaultHeader {
|
||||
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) {
|
||||
@@ -74,4 +81,114 @@ func TestGetDefaultConfig(t *testing.T) {
|
||||
if defaultConfig.Logo != defaultLogo {
|
||||
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) {
|
||||
// Only test one label set per process due to Prometheus registry limits.
|
||||
reg := prometheus.NewRegistry()
|
||||
InitializePrometheusMetrics(&config.Config{
|
||||
cfg := &config.Config{
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "ep-extra",
|
||||
@@ -74,7 +74,8 @@ func TestPublishMetricsForEndpoint_withExtraLabels(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
}, reg)
|
||||
}
|
||||
InitializePrometheusMetrics(cfg, reg)
|
||||
|
||||
ep := &endpoint.Endpoint{
|
||||
Name: "ep-extra",
|
||||
@@ -91,8 +92,9 @@ func TestPublishMetricsForEndpoint_withExtraLabels(t *testing.T) {
|
||||
Duration: 2340 * time.Millisecond,
|
||||
Success: true,
|
||||
}
|
||||
// Order of extraLabels as per GetUniqueExtraMetricLabels is ["foo", "bar"]
|
||||
PublishMetricsForEndpoint(ep, result, []string{"foo", "bar"})
|
||||
// Get labels in sorted order as per GetUniqueExtraMetricLabels
|
||||
extraLabels := cfg.GetUniqueExtraMetricLabels()
|
||||
PublishMetricsForEndpoint(ep, result, extraLabels)
|
||||
|
||||
expected := `
|
||||
# HELP gatus_results_total Number of results per endpoint
|
||||
|
||||
@@ -8,23 +8,25 @@
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/vue": "^1.7.3",
|
||||
"@heroicons/vue": "^2.0.12",
|
||||
"core-js": "3.22.8",
|
||||
"vue": "3.2.37",
|
||||
"vue-router": "4.0.16"
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"core-js": "^3.45.0",
|
||||
"lucide-vue-next": "^0.539.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vue": "^3.5.18",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "5.0.4",
|
||||
"@vue/cli-plugin-eslint": "5.0.4",
|
||||
"@vue/cli-plugin-router": "5.0.4",
|
||||
"@vue/cli-service": "5.0.4",
|
||||
"@vue/compiler-sfc": "3.2.37",
|
||||
"autoprefixer": "10.4.7",
|
||||
"babel-eslint": "10.1.0",
|
||||
"eslint": "7.32.0",
|
||||
"eslint-plugin-vue": "7.20.0",
|
||||
"postcss": "8.4.14",
|
||||
"@vue/cli-plugin-babel": "^5.0.8",
|
||||
"@vue/cli-plugin-eslint": "^5.0.8",
|
||||
"@vue/cli-plugin-router": "^5.0.8",
|
||||
"@vue/cli-service": "^5.0.8",
|
||||
"@vue/compiler-sfc": "^3.5.18",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"@babel/eslint-parser": "^7.25.1",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-plugin-vue": "^9.28.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.1.8"
|
||||
},
|
||||
"eslintConfig": {
|
||||
@@ -37,9 +39,20 @@
|
||||
"eslint:recommended"
|
||||
],
|
||||
"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": [
|
||||
"defaults",
|
||||
|
||||
@@ -3,7 +3,17 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<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>
|
||||
<title>{{ .UI.Title }}</title>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
@@ -20,7 +30,7 @@
|
||||
<meta name="application-name" content="{{ .UI.Title }}" />
|
||||
<meta name="theme-color" content="#f7f9fb" />
|
||||
</head>
|
||||
<body class="dark:bg-gray-900">
|
||||
<body>
|
||||
<noscript><strong>Enable JavaScript to view this page.</strong></noscript>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
|
||||
@@ -1,106 +1,217 @@
|
||||
<template>
|
||||
<Loading v-if="!retrievedConfig" class="h-64 w-64 px-4" />
|
||||
<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">
|
||||
<div class="mb-2">
|
||||
<div class="flex flex-wrap">
|
||||
<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 id="global" class="bg-background text-foreground">
|
||||
<!-- Loading State -->
|
||||
<div v-if="!retrievedConfig" class="flex items-center justify-center min-h-screen">
|
||||
<Loading size="lg" />
|
||||
</div>
|
||||
<router-view @showTooltip="showTooltip" />
|
||||
</div>
|
||||
|
||||
<div v-if="config && config.oidc && !config.authenticated" class="mx-auto max-w-md pt-12">
|
||||
<img src="./assets/logo.svg" alt="Gatus" class="mx-auto" style="max-width:160px; min-width:50px; min-height:50px;"/>
|
||||
<h2 class="mt-4 text-center text-4xl font-extrabold text-gray-800 dark:text-gray-200">
|
||||
Gatus
|
||||
</h2>
|
||||
<div class="py-7 px-4 rounded-sm sm:px-10">
|
||||
<div v-if="$route && $route.query.error" class="text-red-500 text-center mb-5">
|
||||
<div class="text-sm">
|
||||
<span class="text-red-500" v-if="$route.query.error === 'access_denied'">You do not have access to this status page</span>
|
||||
<span class="text-red-500" v-else>{{ $route.query.error }}</span>
|
||||
<!-- Main App Container -->
|
||||
<div v-else-if="!config || !config.oidc || config.authenticated" class="relative">
|
||||
<!-- Header -->
|
||||
<header class="border-b bg-card/50 backdrop-blur supports-[backdrop-filter]:bg-card/60">
|
||||
<div class="container mx-auto px-4 py-4 max-w-7xl">
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Logo and Title -->
|
||||
<div class="flex items-center gap-4">
|
||||
<component
|
||||
: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>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<Tooltip :result="tooltip.result" :event="tooltip.event"/>
|
||||
<Social/>
|
||||
<!-- Main Content -->
|
||||
<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>
|
||||
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
/* 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 Tooltip from './components/Tooltip.vue';
|
||||
import {SERVER_URL} from "@/main";
|
||||
import Loading from "@/components/Loading";
|
||||
import Tooltip from './components/Tooltip.vue'
|
||||
import Loading from './components/Loading.vue'
|
||||
import { SERVER_URL } from '@/main'
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: {
|
||||
Loading,
|
||||
Social,
|
||||
Tooltip
|
||||
},
|
||||
methods: {
|
||||
fetchConfig() {
|
||||
fetch(`${SERVER_URL}/api/v1/config`, {credentials: 'include'})
|
||||
.then(response => {
|
||||
this.retrievedConfig = true;
|
||||
if (response.status === 200) {
|
||||
response.json().then(data => {
|
||||
this.config = data;
|
||||
})
|
||||
}
|
||||
});
|
||||
},
|
||||
showTooltip(result, event) {
|
||||
this.tooltip = {result: result, event: event};
|
||||
const route = useRoute()
|
||||
|
||||
// State
|
||||
const retrievedConfig = ref(false)
|
||||
const config = ref({ oidc: false, authenticated: true })
|
||||
const tooltip = ref({})
|
||||
const mobileMenuOpen = ref(false)
|
||||
const isOidcLoading = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const logo = computed(() => {
|
||||
return window.config && window.config.logo && window.config.logo !== '{{ .UI.Logo }}' ? window.config.logo : ""
|
||||
})
|
||||
|
||||
const header = computed(() => {
|
||||
return window.config && window.config.header && window.config.header !== '{{ .UI.Header }}' ? window.config.header : "Gatus"
|
||||
})
|
||||
|
||||
const link = computed(() => {
|
||||
return window.config && window.config.link && window.config.link !== '{{ .UI.Link }}' ? window.config.link : null
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
logo() {
|
||||
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();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch config:', error)
|
||||
retrievedConfig.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const showTooltip = (result, event) => {
|
||||
tooltip.value = { result, event }
|
||||
}
|
||||
|
||||
// Fetch config on mount
|
||||
onMounted(() => {
|
||||
fetchConfig()
|
||||
})
|
||||
</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>
|
||||
@@ -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>
|
||||
<div class="flex justify-center items-center mx-auto">
|
||||
<img :class="`animate-spin opacity-60 rounded-full`" src="../assets/logo.svg" alt="Gatus logo" />
|
||||
<div class="flex justify-center items-center">
|
||||
<img
|
||||
:class="[
|
||||
'animate-spin rounded-full opacity-60 grayscale',
|
||||
sizeClass,
|
||||
]"
|
||||
src="../assets/logo.svg"
|
||||
alt="Gatus logo"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
<script setup>
|
||||
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>
|
||||
@@ -1,42 +1,73 @@
|
||||
<template>
|
||||
<div class="mt-3 flex">
|
||||
<div class="flex-1">
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex-1 text-right">
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="currentPage >= maxPages"
|
||||
@click="previousPage"
|
||||
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>
|
||||
</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>
|
||||
export default {
|
||||
name: 'Pagination',
|
||||
props: {
|
||||
numberOfResultsPerPage: Number,
|
||||
},
|
||||
components: {},
|
||||
emits: ['page'],
|
||||
methods: {
|
||||
nextPage() {
|
||||
this.currentPage++;
|
||||
this.$emit('page', this.currentPage);
|
||||
},
|
||||
previousPage() {
|
||||
this.currentPage--;
|
||||
this.$emit('page', this.currentPage);
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
maxPages() {
|
||||
return Math.ceil(parseInt(window.config.maximumNumberOfResults) / this.numberOfResultsPerPage)
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentPage: 1,
|
||||
const props = defineProps({
|
||||
numberOfResultsPerPage: Number,
|
||||
currentPageProp: {
|
||||
type: Number,
|
||||
default: 1
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['page'])
|
||||
|
||||
const currentPage = ref(props.currentPageProp)
|
||||
|
||||
const maxPages = computed(() => {
|
||||
// Use maximumNumberOfResults from config if available, otherwise default to 100
|
||||
let maxResults = 100 // Default value
|
||||
// Check if window.config exists and has maximumNumberOfResults
|
||||
if (typeof window !== 'undefined' && window.config && window.config.maximumNumberOfResults) {
|
||||
const parsed = parseInt(window.config.maximumNumberOfResults)
|
||||
if (!isNaN(parsed)) {
|
||||
maxResults = parsed
|
||||
}
|
||||
}
|
||||
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>
|
||||
@@ -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>
|
||||
<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 class="text-xs text-gray-600 rounded-xl py-1.5 px-1.5 dark:text-gray-200">
|
||||
<ArrowPathIcon class="w-3"/>
|
||||
<div id="settings" class="fixed bottom-4 left-4 z-50">
|
||||
<div class="flex items-center gap-1 bg-background/95 backdrop-blur-sm border rounded-full shadow-md p-1">
|
||||
<!-- 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>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
import { MoonIcon, SunIcon } from '@heroicons/vue/20/solid'
|
||||
import { ArrowPathIcon } from '@heroicons/vue/24/solid'
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
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() {
|
||||
const themeFromCookie = document.cookie.match(/theme=(dark|light);?/)?.[1];
|
||||
return themeFromCookie === 'dark' || !themeFromCookie && (window.matchMedia('(prefers-color-scheme: dark)').matches || document.documentElement.classList.contains("dark"));
|
||||
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")))
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'Settings',
|
||||
components: {
|
||||
ArrowPathIcon,
|
||||
MoonIcon,
|
||||
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()
|
||||
}
|
||||
},
|
||||
function getStoredRefreshInterval() {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.REFRESH_INTERVAL)
|
||||
const parsedValue = stored && parseInt(stored)
|
||||
const isValid = parsedValue && parsedValue >= 10 && REFRESH_INTERVALS.some(i => i.value === stored)
|
||||
return isValid ? stored : DEFAULT_REFRESH_INTERVAL
|
||||
}
|
||||
|
||||
// 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>
|
||||
|
||||
|
||||
<style>
|
||||
#settings {
|
||||
position: fixed;
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
<style scoped>
|
||||
/* Animations for smooth transitions */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
#settings select:focus {
|
||||
box-shadow: none;
|
||||
#settings {
|
||||
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>
|
||||
@@ -8,14 +8,9 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Social'
|
||||
}
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
#social {
|
||||
position: fixed;
|
||||
|
||||
@@ -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>
|
||||
<div id="tooltip" ref="tooltip" :class="hidden ? 'invisible' : ''" :style="'top:' + top + 'px; left:' + left + 'px'">
|
||||
<slot v-if="result">
|
||||
<div class="tooltip-title">Timestamp:</div>
|
||||
<code id="tooltip-timestamp">{{ prettifyTimestamp(result.timestamp) }}</code>
|
||||
<div class="tooltip-title">Response time:</div>
|
||||
<code id="tooltip-response-time">{{ (result.duration / 1000000).toFixed(0) }}ms</code>
|
||||
<slot v-if="result.conditionResults && result.conditionResults.length">
|
||||
<div class="tooltip-title">Conditions:</div>
|
||||
<code id="tooltip-conditions">
|
||||
<slot v-for="conditionResult in result.conditionResults" :key="conditionResult">
|
||||
{{ conditionResult.success ? "✓" : "X" }} ~ {{ conditionResult.condition }}<br/>
|
||||
</slot>
|
||||
</code>
|
||||
</slot>
|
||||
<div id="tooltip-errors-container" v-if="result.errors && result.errors.length">
|
||||
<div class="tooltip-title">Errors:</div>
|
||||
<code id="tooltip-errors">
|
||||
<slot v-for="error in result.errors" :key="error">
|
||||
- {{ error }}<br/>
|
||||
</slot>
|
||||
</code>
|
||||
<div
|
||||
id="tooltip"
|
||||
ref="tooltip"
|
||||
:class="[
|
||||
'fixed z-50 px-3 py-2 text-sm rounded-md shadow-lg border transition-all duration-200',
|
||||
'bg-popover text-popover-foreground border-border',
|
||||
hidden ? 'invisible opacity-0' : 'visible opacity-100'
|
||||
]"
|
||||
:style="`top: ${top}px; left: ${left}px;`"
|
||||
>
|
||||
<div v-if="result" class="space-y-2">
|
||||
<!-- Timestamp -->
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Timestamp</div>
|
||||
<div class="font-mono text-xs">{{ prettifyTimestamp(result.timestamp) }}</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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { helper } from '@/mixins/helper'
|
||||
|
||||
<script>
|
||||
import {helper} from "@/mixins/helper";
|
||||
|
||||
export default {
|
||||
name: 'Endpoints',
|
||||
props: {
|
||||
event: Event,
|
||||
result: Object
|
||||
const props = defineProps({
|
||||
event: {
|
||||
type: [Event, Object],
|
||||
default: null
|
||||
},
|
||||
mixins: [helper],
|
||||
methods: {
|
||||
htmlEntities(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
},
|
||||
reposition() {
|
||||
if (this.event && this.event.type) {
|
||||
if (this.event.type === 'mouseenter') {
|
||||
let targetTopPosition = this.event.target.getBoundingClientRect().y + 30;
|
||||
let targetLeftPosition = this.event.target.getBoundingClientRect().x;
|
||||
let tooltipBoundingClientRect = this.$refs.tooltip.getBoundingClientRect();
|
||||
if (targetLeftPosition + window.scrollX + tooltipBoundingClientRect.width + 50 > document.body.getBoundingClientRect().width) {
|
||||
targetLeftPosition = this.event.target.getBoundingClientRect().x - tooltipBoundingClientRect.width + this.event.target.getBoundingClientRect().width;
|
||||
if (targetLeftPosition < 0) {
|
||||
targetLeftPosition += -targetLeftPosition;
|
||||
}
|
||||
}
|
||||
if (targetTopPosition + window.scrollY + tooltipBoundingClientRect.height + 50 > document.body.getBoundingClientRect().height && targetTopPosition >= 0) {
|
||||
targetTopPosition = this.event.target.getBoundingClientRect().y - (tooltipBoundingClientRect.height + 10);
|
||||
if (targetTopPosition < 0) {
|
||||
targetTopPosition = this.event.target.getBoundingClientRect().y + 30;
|
||||
}
|
||||
}
|
||||
this.top = targetTopPosition;
|
||||
this.left = targetLeftPosition;
|
||||
} else if (this.event.type === 'mouseleave') {
|
||||
this.hidden = true;
|
||||
result: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
// State
|
||||
const hidden = ref(true)
|
||||
const top = ref(0)
|
||||
const left = ref(0)
|
||||
const tooltip = ref(null)
|
||||
|
||||
// Methods from helper mixin
|
||||
const { prettifyTimestamp } = helper.methods
|
||||
|
||||
const reposition = async () => {
|
||||
if (!props.event || !props.event.type) return
|
||||
|
||||
await nextTick()
|
||||
|
||||
if (props.event.type === 'mouseenter' && tooltip.value) {
|
||||
const target = props.event.target
|
||||
const targetRect = target.getBoundingClientRect()
|
||||
|
||||
// First, position tooltip to get its dimensions
|
||||
hidden.value = false
|
||||
await nextTick()
|
||||
|
||||
const tooltipRect = tooltip.value.getBoundingClientRect()
|
||||
|
||||
// Since tooltip uses position: fixed, we work with viewport coordinates
|
||||
// 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: {
|
||||
event: function (value) {
|
||||
if (value && value.type) {
|
||||
if (value.type === 'mouseenter') {
|
||||
this.hidden = false;
|
||||
} else if (value.type === 'mouseleave') {
|
||||
this.hidden = true;
|
||||
}
|
||||
|
||||
// Adjust horizontal position if tooltip would overflow right edge
|
||||
const spaceRight = window.innerWidth - targetRect.left
|
||||
if (spaceRight < tooltipRect.width + 20) {
|
||||
// Align right edge of tooltip with right edge of target
|
||||
newLeft = targetRect.right - tooltipRect.width
|
||||
// Make sure it doesn't go off the left edge
|
||||
if (newLeft < 10) {
|
||||
newLeft = 10
|
||||
}
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
this.reposition();
|
||||
},
|
||||
created() {
|
||||
this.reposition();
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hidden: true,
|
||||
top: 0,
|
||||
left: 0
|
||||
}
|
||||
|
||||
top.value = Math.round(newTop)
|
||||
left.value = Math.round(newLeft)
|
||||
} else if (props.event.type === 'mouseleave') {
|
||||
hidden.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 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>
|
||||
|
||||
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Badge } from './Badge.vue'
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Button } from './Button.vue'
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Input } from './Input.vue'
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Select } from './Select.vue'
|
||||
@@ -2,38 +2,78 @@
|
||||
@tailwind components;
|
||||
@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 {
|
||||
background-color: #28a745;
|
||||
}
|
||||
|
||||
html:not(.dark) body {
|
||||
background-color: #f7f9fb;
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
padding-top: 20px;
|
||||
padding-bottom: 20px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#global {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
#global, #results {
|
||||
max-width: 1280px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1279px) {
|
||||
body {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
#global {
|
||||
min-height: 100vh;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
<router-link to="../"
|
||||
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">
|
||||
←
|
||||
</router-link>
|
||||
<div>
|
||||
<slot v-if="endpointStatus">
|
||||
<h1 class="text-xl xl:text-3xl font-mono text-gray-400">RECENT CHECKS</h1>
|
||||
<hr class="mb-4"/>
|
||||
<Endpoint
|
||||
:data="endpointStatus"
|
||||
:maximumNumberOfResults="20"
|
||||
@showTooltip="showTooltip"
|
||||
@toggleShowAverageResponseTime="toggleShowAverageResponseTime"
|
||||
:showAverageResponseTime="showAverageResponseTime"
|
||||
/>
|
||||
<Pagination @page="changePage" :numberOfResultsPerPage="20" />
|
||||
</slot>
|
||||
<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 class="dashboard-container bg-background">
|
||||
<div class="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<div class="mb-8">
|
||||
<Button variant="ghost" class="mb-4" @click="goBack">
|
||||
<ArrowLeft class="h-4 w-4 mr-2" />
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
|
||||
<div v-if="endpointStatus && endpointStatus.name" class="space-y-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold tracking-tight">{{ endpointStatus.name }}</h1>
|
||||
<div class="flex items-center gap-3 text-muted-foreground mt-2">
|
||||
<span v-if="endpointStatus.group">Group: {{ endpointStatus.group }}</span>
|
||||
<span v-if="endpointStatus.group && hostname">•</span>
|
||||
<span v-if="hostname">{{ hostname }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 text-right">
|
||||
{{ event.fancyTimeAgo }}
|
||||
<StatusBadge :status="currentHealthStatus" />
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<Settings @refreshData="fetchData" />
|
||||
</div>
|
||||
<Settings @refreshData="fetchData"/>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
/* 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 Endpoint from '@/components/Endpoint.vue';
|
||||
import {SERVER_URL} from "@/main.js";
|
||||
import {helper} from "@/mixins/helper.js";
|
||||
import Pagination from "@/components/Pagination";
|
||||
import { ArrowDownCircleIcon, ArrowUpCircleIcon, PlayCircleIcon } from '@heroicons/vue/20/solid'
|
||||
import Pagination from '@/components/Pagination.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { SERVER_URL } from '@/main.js'
|
||||
import { helper } from '@/mixins/helper'
|
||||
|
||||
export default {
|
||||
name: 'Details',
|
||||
components: {
|
||||
Pagination,
|
||||
Endpoint,
|
||||
Settings,
|
||||
ArrowDownCircleIcon,
|
||||
ArrowUpCircleIcon,
|
||||
PlayCircleIcon
|
||||
},
|
||||
emits: ['showTooltip'],
|
||||
mixins: [helper],
|
||||
methods: {
|
||||
fetchData() {
|
||||
//console.log("[Details][fetchData] Fetching data");
|
||||
fetch(`${this.serverUrl}/api/v1/endpoints/${this.$route.params.key}/statuses?page=${this.currentPage}`, {credentials: 'include'})
|
||||
.then(response => {
|
||||
if (response.status === 200) {
|
||||
response.json().then(data => {
|
||||
if (JSON.stringify(this.endpointStatus) !== JSON.stringify(data)) {
|
||||
this.endpointStatus = data;
|
||||
let events = [];
|
||||
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 ' + this.generatePrettyTimeDifference(nextEvent.timestamp, event.timestamp);
|
||||
} else {
|
||||
event.fancyText = 'Endpoint became unhealthy';
|
||||
}
|
||||
} 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: [],
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const emit = defineEmits(['showTooltip'])
|
||||
|
||||
const endpointStatus = ref(null) // For paginated historical data
|
||||
const currentStatus = ref(null) // For current/latest status (always page 1)
|
||||
const events = ref([])
|
||||
const currentPage = ref(1)
|
||||
const showResponseTimeChartAndBadges = ref(false)
|
||||
const showAverageResponseTime = ref(false)
|
||||
const selectedChartDuration = ref('24h')
|
||||
const serverUrl = SERVER_URL === '.' ? '..' : SERVER_URL
|
||||
const isRefreshing = ref(false)
|
||||
|
||||
const latestResult = computed(() => {
|
||||
// Use currentStatus for the actual latest result
|
||||
if (!currentStatus.value || !currentStatus.value.results || currentStatus.value.results.length === 0) {
|
||||
return null
|
||||
}
|
||||
return currentStatus.value.results[currentStatus.value.results.length - 1]
|
||||
})
|
||||
|
||||
const currentHealthStatus = computed(() => {
|
||||
if (!latestResult.value) return 'unknown'
|
||||
return latestResult.value.success ? 'healthy' : 'unhealthy'
|
||||
})
|
||||
|
||||
const hostname = computed(() => {
|
||||
return latestResult.value?.hostname || null
|
||||
})
|
||||
|
||||
const pageAverageResponseTime = computed(() => {
|
||||
// Use endpointStatus for current page's average response time
|
||||
if (!endpointStatus.value || !endpointStatus.value.results || endpointStatus.value.results.length === 0) {
|
||||
return 'N/A'
|
||||
}
|
||||
let total = 0
|
||||
let count = 0
|
||||
for (const result of endpointStatus.value.results) {
|
||||
if (result.duration) {
|
||||
total += result.duration
|
||||
count++
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.fetchData();
|
||||
}
|
||||
if (count === 0) return 'N/A'
|
||||
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>
|
||||
.endpoint {
|
||||
border-radius: 3px;
|
||||
border-bottom-width: 3px;
|
||||
const goBack = () => {
|
||||
router.push('/')
|
||||
}
|
||||
</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>
|
||||
<Loading v-if="!retrievedData" class="h-64 w-64 px-4 my-24"/>
|
||||
<slot>
|
||||
<Endpoints
|
||||
v-show="retrievedData"
|
||||
:endpointStatuses="endpointStatuses"
|
||||
:showStatusOnHover="true"
|
||||
@showTooltip="showTooltip"
|
||||
@toggleShowAverageResponseTime="toggleShowAverageResponseTime"
|
||||
:showAverageResponseTime="showAverageResponseTime"
|
||||
/>
|
||||
<Pagination v-show="retrievedData" @page="changePage" :numberOfResultsPerPage="20" />
|
||||
</slot>
|
||||
<Settings @refreshData="fetchData"/>
|
||||
<div class="dashboard-container bg-background">
|
||||
<div class="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold tracking-tight">Health Dashboard</h1>
|
||||
<p class="text-muted-foreground mt-2">Monitor the health of your endpoints in real-time</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@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>
|
||||
|
||||
<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 Endpoints from '@/components/Endpoints.vue';
|
||||
import Pagination from "@/components/Pagination";
|
||||
import Loading from "@/components/Loading";
|
||||
import {SERVER_URL} from "@/main.js";
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { SERVER_URL } from '@/main.js'
|
||||
|
||||
export default {
|
||||
name: 'Home',
|
||||
components: {
|
||||
Loading,
|
||||
Pagination,
|
||||
Endpoints,
|
||||
Settings,
|
||||
},
|
||||
emits: ['showTooltip', 'toggleShowAverageResponseTime'],
|
||||
methods: {
|
||||
fetchData() {
|
||||
fetch(`${SERVER_URL}/api/v1/endpoints/statuses?page=${this.currentPage}`, {credentials: 'include'})
|
||||
.then(response => {
|
||||
this.retrievedData = true;
|
||||
if (response.status === 200) {
|
||||
response.json().then(data => {
|
||||
if (JSON.stringify(this.endpointStatuses) !== JSON.stringify(data)) {
|
||||
this.endpointStatuses = data;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
response.text().then(text => {
|
||||
console.log(`[Home][fetchData] Error: ${text}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
changePage(page) {
|
||||
this.retrievedData = false; // Show loading only on page change or on initial load
|
||||
this.currentPage = page;
|
||||
this.fetchData();
|
||||
},
|
||||
showTooltip(result, event) {
|
||||
this.$emit('showTooltip', result, event);
|
||||
},
|
||||
toggleShowAverageResponseTime() {
|
||||
this.showAverageResponseTime = !this.showAverageResponseTime;
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
endpointStatuses: [],
|
||||
currentPage: 1,
|
||||
showAverageResponseTime: true,
|
||||
retrievedData: false,
|
||||
const emit = defineEmits(['showTooltip'])
|
||||
|
||||
const endpointStatuses = ref([])
|
||||
const loading = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = 96
|
||||
const searchQuery = ref('')
|
||||
const showOnlyFailing = ref(false)
|
||||
const showRecentFailures = ref(false)
|
||||
const showAverageResponseTime = ref(true)
|
||||
const groupByGroup = ref(false)
|
||||
const sortBy = ref(localStorage.getItem('gatus:sort-by') || 'name')
|
||||
const collapsedGroups = ref(new Set())
|
||||
|
||||
const filteredEndpoints = computed(() => {
|
||||
let filtered = [...endpointStatuses.value]
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
filtered = filtered.filter(endpoint =>
|
||||
endpoint.name.toLowerCase().includes(query) ||
|
||||
(endpoint.group && endpoint.group.toLowerCase().includes(query))
|
||||
)
|
||||
}
|
||||
|
||||
if (showOnlyFailing.value) {
|
||||
filtered = filtered.filter(endpoint => {
|
||||
if (!endpoint.results || endpoint.results.length === 0) return false
|
||||
const latestResult = endpoint.results[endpoint.results.length - 1]
|
||||
return !latestResult.success
|
||||
})
|
||||
}
|
||||
|
||||
if (showRecentFailures.value) {
|
||||
filtered = filtered.filter(endpoint => {
|
||||
if (!endpoint.results || endpoint.results.length === 0) return false
|
||||
return endpoint.results.some(result => !result.success)
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by health if selected
|
||||
if (sortBy.value === 'health') {
|
||||
filtered.sort((a, b) => {
|
||||
const aHealthy = a.results && a.results.length > 0 && a.results[a.results.length - 1].success
|
||||
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] = []
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.retrievedData = false; // Show loading only on page change or on initial load
|
||||
this.fetchData();
|
||||
grouped[group].push(endpoint)
|
||||
})
|
||||
|
||||
// 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>
|
||||
@@ -6,9 +6,65 @@ module.exports = {
|
||||
darkMode: 'class', // or 'media' or 'class'
|
||||
theme: {
|
||||
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: {
|
||||
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 = {
|
||||
filenameHashing: false,
|
||||
productionSourceMap: false,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#social[data-v-a4b3d200]{position:fixed;right:5px;bottom:5px;padding:5px;margin:0;z-index:100}#social img[data-v-a4b3d200]{opacity:.3}#social img[data-v-a4b3d200]:hover{opacity:1}#tooltip{position:fixed;background-color:#fff;border:1px solid #d3d3d3;border-radius:4px;padding:6px;font-size:13px}#tooltip code{color:#212529;line-height:1}#tooltip .tooltip-title{font-weight:700;margin-bottom:0;display:block;margin-top:8px}#tooltip>.tooltip-title:first-child{margin-top:0}
|
||||
#social[data-v-788af9ce]{position:fixed;right:5px;bottom:5px;padding:5px;margin:0;z-index:100}#social img[data-v-788af9ce]{opacity:.3}#social img[data-v-788af9ce]:hover{opacity:1}
|
||||
|
||||
/*
|
||||
! tailwindcss v3.1.8 | MIT License | https://tailwindcss.com
|
||||
*/*,:after,:before{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:after,:before{--tw-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:Consolas,Monaco,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::-webkit-backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.coLine truncated
|
||||
*/*,:after,:before{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:after,:before{--tw-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:Consolas,Monaco,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}: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%}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground))}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-bluLine truncated
|
||||
@@ -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>
|
||||
@@ -1 +1 @@
|
||||
(function(){"use strict";var e={9351:function(e,t,s){s.d(t,{L:function(){return bs}});s(7727);var n=s(9963),o=s(6252),a=s(3577),r=s.p+"img/logo.svg";const i={class:"mb-2"},l={class:"flex flex-wrap"},d={class:"w-3/4 text-left my-auto"},g={class:"text-3xl xl:text-5xl lg:text-4xl font-light"},u={class:"w-1/4 flex justify-end"},h=["src"],c={key:1,src:r,alt:"Gatus",class:"object-scale-down",style:{"max-width":"100px","min-width":"50px","min-height":"50px"}},p={key:0,class:"flex flex-wrap"},m=["href"],f={key:2,class:"mx-auto max-w-md pt-12"},v=(0,o._)("img",{src:r,alt:"Gatus",class:"mx-auto",style:{"max-width":"160px","min-width":"50px","min-height":"50px"}},null,-1),w=(0,o._)("h2",{class:"mt-4 text-center text-4xl font-extrabold text-gray-800 dark:text-gray-200"}," Gatus ",-1),x={class:"py-7 px-4 rounded-sm sm:px-10"},y={key:0,class:"text-red-500 text-center mb-5"},k={class:"text-sm"},T={key:0,class:"text-red-500"},b={key:1,class:"text-red-500"},R=["href"];function _(e,t,s,n,r,_){const S=(0,o.up)("Loading"),D=(0,o.up)("router-view"),I=(0,o.up)("Tooltip"),A=(0,o.up)("Social");return(0,o.wg)(),(0,o.iD)(o.HY,null,[r.retrievedConfig?((0,o.wg)(),(0,o.iD)("div",{key:1,class:(0,a.C_)([r.config&&r.config.oidc&&!r.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"},[(0,o._)("div",i,[(0,o._)("div",l,[(0,o._)("div",d,[(0,o._)("div",g,(0,a.zw)(_.header),1)]),(0,o._)("div",u,[((0,o.wg)(),(0,o.j4)((0,o.LL)(_.link?"a":"div"),{href:_.link,target:"_blank",class:"flex items-center justify-center",style:{width:"100px","min-height":"100px"}},{default:(0,o.w5)((()=>[_.logo?((0,o.wg)(),(0,o.iD)("img",{key:0,src:_.logo,alt:"Gatus",class:"object-scale-down",style:{"max-width":"100px","min-width":"50px","min-height":"50px"}},null,8,h)):((0,o.wg)(),(0,o.iD)("img",c))])),_:1},8,["href"]))])]),_.buttons?((0,o.wg)(),(0,o.iD)("div",p,[((0,o.wg)(!0),(0,o.iD)(o.HY,null,(0,o.Ko)(_.buttons,(e=>((0,o.wg)(),(0,o.iD)("a",{key:e.name,href:e.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"},(0,a.zw)(e.name),9,m)))),128))])):(0,o.kq)("",!0)]),(0,o.Wm)(D,{onShowTooltip:_.showTooltip},null,8,["onShowTooltip"])],2)):((0,o.wg)(),(0,o.j4)(S,{key:0,class:"h-64 w-64 px-4"})),r.config&&r.config.oidc&&!r.config.authenticated?((0,o.wg)(),(0,o.iD)("div",f,[v,w,(0,o._)("div",x,[e.$route&&e.$route.query.error?((0,o.wg)(),(0,o.iD)("div",y,[(0,o._)("div",k,["access_denied"===e.$route.query.error?((0,o.wg)(),(0,o.iD)("span",T,"You do not have access to this status page")):((0,o.wg)(),(0,o.iD)("span",b,(0,a.zw)(e.$route.query.error),1))])])):(0,o.kq)("",!0),(0,o._)("div",null,[(0,o._)("a",{href:`${r.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 ",8,R)])])])):(0,o.kq)("",!0),(0,o.Wm)(I,{result:r.tooltip.result,event:r.tooltip.event},null,8,["result","event"]),(0,o.Wm)(A)],64)}const S=e=>((0,o.dD)("data-v-a4b3d200"),e=e(),(0,o.Cn)(),e),D={id:"social"},I=S((()=>(0,o._)("a",{href:"https://github.com/TwiN/gatus",target:"_blank",title:"Gatus on GitHub"},[(0,o._)("svg",{xmlns:"http://www.w3.org/2000/svg",width:"32",height:"32",viewBox:"0 0 16 16",class:"hover:scale-110"},[(0,o._)("path",{fill:"gray",d:"M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"})])],-1))),A=[I];function C(e,t,s,n,a,r){return(0,o.wg)(),(0,o.iD)("div",D,A)}var P={name:"Social"},$=s(3744);const E=(0,$.Z)(P,[["render",C],["__scopeId","data-v-a4b3d200"]]);var U=E;const L=(0,o._)("div",{class:"tooltip-title"},"Timestamp:",-1),H={id:"tooltip-timestamp"},O=(0,o._)("div",{class:"tooltip-title"},"Response time:",-1),W={id:"tooltip-response-time"},M=(0,o._)("div",{class:"tooltip-title"},"Conditions:",-1),B={id:"tooltip-conditions"},j=(0,o._)("br",null,null,-1),q={key:1,id:"tooltip-errors-container"},z=(0,o._)("div",{class:"tooltip-title"},"Errors:",-1),N={id:"tooltip-errors"},Y=(0,o._)("br",null,null,-1);function Z(e,t,s,n,r,i){return(0,o.wg)(),(0,o.iD)("div",{id:"tooltip",ref:"tooltip",class:(0,a.C_)(r.hidden?"invisible":""),style:(0,a.j5)("top:"+r.top+"px; left:"+r.left+"px")},[s.result?(0,o.WI)(e.$slots,"defaultLine truncated
|
||||
(function(){"use strict";var e={682:function(e,t,a){a.d(t,{L:function(){return vl}});var l=a(963),s=a(252),n=a(577),o=a(262),r=a.p+"img/logo.svg",i=a(201),u=a(507),d=a(970),c=a(135),m=a(3),g=a(512),v=a(388);function p(...e){return(0,v.m6)((0,g.W)(e))}const f=["disabled"];var w={__name:"Button",props:{variant:{type:String,default:"default"},size:{type:String,default:"default"},disabled:{type:Boolean,default:!1}},setup(e){const t=(0,m.j)("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"}});return(a,l)=>((0,s.wg)(),(0,s.iD)("button",{class:(0,n.C_)((0,o.SU)(p)((0,o.SU)(t)({variant:e.variant,size:e.size}),a.$attrs.class??"")),disabled:e.disabled},[(0,s.WI)(a.$slots,"default")],10,f))}};const h=w;var x=h,b={__name:"Card",setup(e){return(e,t)=>((0,s.wg)(),(0,s.iD)("div",{class:(0,n.C_)((0,o.SU)(p)("rounded-lg border bg-card text-card-foreground shadow-sm",e.$attrs.class??""))},[(0,s.WI)(e.$slots,"default")],2))}};const y=b;var _=y,k={__name:"CardHeader",setup(e){return(e,t)=>((0,s.wg)(),(0,s.iD)("div",{class:(0,n.C_)((0,o.SU)(p)("flex flex-col space-y-1.5 p-6",e.$attrs.class??""))},[(0,s.WI)(e.$slots,"default")],2))}};const S=k;var U=S,D={__name:"CardTitle",setup(e){return(e,t)=>((0,s.wg)(),(0,s.iD)("h3",{class:(0,n.C_)((0,o.SU)(p)("text-2xl font-semibold leading-none tracking-tight",e.$attrs.class??""))},[(0,s.WI)(e.$slots,"default")],2))}};const W=D;var H=W,z={__name:"CardContent",setup(e){return(e,t)=>((0,s.wg)(),(0,s.iD)("div",{class:(0,n.C_)((0,o.SU)(p)("p-6 pt-0",e.$attrs.class??""))},[(0,s.WI)(e.$slots,"default")],2))}};const j=z;var C=j;const T={id:"social"};function R(e,t){return(0,s.wg)(),(0,s.iD)("div",T,t[0]||(t[0]=[(0,s._)("a",{href:"https://github.com/TwiN/gatus",target:"_blank",title:"Gatus on GitHub"},[(0,s._)("svg",{xmlns:"http://www.w3.org/2000/svg",width:"32",height:"32",viewBox:"0 0 16 16",class:"hover:scale-110"},[(0,s._)("path",{fill:"gray",d:"M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"})])],-1)]))}var F=a(744);const $={},A=(0,F.Z)($,[["render",R],["__scopeId","data-v-788af9ce"]]);var E=A;const M={methods:{generatePrettyTimeAgo(e){let t=(new Date).getTime()-new Date(e).getTime();if(t<500)return"now";if(t>2592e5){let e=(t/864e5).toFixed(0);return e+" day"+("1"!==e?"s":"")+" ago"}if(t>36e5){let e=(t/36e5).toFixed(0);return e+" hour"+("1"!==e?"s":"")+" ago"}if(t>6e4){let e=(t/6e4).toFixed(0);return e+" minute"+("1"!==e?"s":"")+" ago"}let a=(t/1e3).toFixed(0);return a+" second"+("1"!==a?"s":"")+" ago"},generatePrettyTimeDifference(e,t){let a=Math.ceil((new Date(e)-new Date(t))/1e3/60);return a+(1===a?" minute":" minutes")},prettifyTimestamp(e){let t=new Date(e),a=t.getFullYear(),l=(t.getMonth()+1<10?"0":"")+(t.getMonth()+1),s=(t.getDate()<10?"0":"")+t.getDate(),n=(t.getHours()<10?"0":"")+t.getHours(),o=(t.getMinutes()<10?"0":"")+t.getMinutes(),r=(t.getSeconds()<10?"0":"")+t.getSeconds();return a+"-"+l+"-"+s+" "+n+":"+o+":"+r}}},I={key:0,class:"space-y-2"},L={class:"font-mono text-xs"},N={class:"font-mono text-xs"},P={key:0},q={class:"font-mono text-xs space-y-0.5"},O={class:"break-all"},Y={key:1},Z={class:"font-mono text-xs space-y-0.5"};var V={__name:"Tooltip",props:{event:{type:[Event,Object],default:null},result:{type:Object,default:null}},setup(e){const t=e,a=(0,o.iH)(!0),l=(0,o.iH)(0),r=(0,o.iH)(0),i=(0,o.iH)(null),{prettifyTimestamp:u}=M.methods,d=async()=>{if(t.event&&t.event.type)if(await(0,s.Y3)(),"mouseenter"===t.event.type&&i.value){const e=t.event.target,n=e.getBoundingClientRect();a.value=!1,await(0,s.Y3)();const o=i.value.getBoundingClientRect();let u=n.bottom+8,d=n.left;const c=window.innerHeight-n.bottom,m=n.top;c<o.height+20&&(u=m>o.height+20?n.top-o.height-8Line truncated
|
||||
@@ -1,7 +1,170 @@
|
||||
(self["webpackChunkgatus"]=self["webpackChunkgatus"]||[]).push([[998],{2262:function(e,t,n){"use strict";n.d(t,{Bj:function(){return i},Fl:function(){return Ve},IU:function(){return Ae},Jd:function(){return k},PG:function(){return Se},SU:function(){return Ne},Um:function(){return xe},WL:function(){return Je},X$:function(){return E},X3:function(){return Ee},XI:function(){return Le},Xl:function(){return Re},dq:function(){return Fe},iH:function(){return Me},j:function(){return C},lk:function(){return S},qj:function(){return _e},qq:function(){return b},yT:function(){return Oe}});var r=n(3577);let o;class i{constructor(e=!1){this.active=!0,this.effects=[],this.cleanups=[],!e&&o&&(this.parent=o,this.index=(o.scopes||(o.scopes=[])).push(this)-1)}run(e){if(this.active){const t=o;try{return o=this,e()}finally{o=t}}else 0}on(){o=this}off(){o=this.parent}stop(e){if(this.active){let t,n;for(t=0,n=this.effects.length;t<n;t++)this.effects[t].stop();for(t=0,n=this.cleanups.length;t<n;t++)this.cleanups[t]();if(this.scopes)for(t=0,n=this.scopes.length;t<n;t++)this.scopes[t].stop(!0);if(this.parent&&!e){const e=this.parent.scopes.pop();e&&e!==this&&(this.parent.scopes[this.index]=e,e.index=this.index)}this.active=!1}}}function s(e,t=o){t&&t.active&&t.effects.push(e)}const c=e=>{const t=new Set(e);return t.w=0,t.n=0,t},u=e=>(e.w&h)>0,l=e=>(e.n&h)>0,a=({deps:e})=>{if(e.length)for(let t=0;t<e.length;t++)e[t].w|=h},f=e=>{const{deps:t}=e;if(t.length){let n=0;for(let r=0;r<t.length;r++){const o=t[r];u(o)&&!l(o)?o.delete(e):t[n++]=o,o.w&=~h,o.n&=~h}t.length=n}},p=new WeakMap;let d=0,h=1;const v=30;let g;const m=Symbol(""),y=Symbol("");class b{constructor(e,t=null,n){this.fn=e,this.scheduler=t,this.active=!0,this.deps=[],this.parent=void 0,s(this,n)}run(){if(!this.active)return this.fn();let e=g,t=x;while(e){if(e===this)return;e=e.parent}try{return this.parent=g,g=this,x=!0,h=1<<++d,d<=v?a(this):_(this),this.fn()}finally{d<=v&&f(this),h=1<<--d,g=this.parent,x=t,this.parent=void 0,this.deferStop&&this.stop()}}stop(){g===this?this.deferStop=!0:this.active&&(_(this),this.onStop&&this.onStop(),this.active=!1)}}function _(e){const{deps:t}=e;if(t.length){for(let n=0;n<t.length;n++)t[n].delete(e);t.length=0}}let x=!0;const w=[];function k(){w.push(x),x=!1}function S(){const e=w.pop();x=void 0===e||e}function C(e,t,n){if(x&&g){let t=p.get(e);t||p.set(e,t=new Map);let r=t.get(n);r||t.set(n,r=c());const o=void 0;O(r,o)}}function O(e,t){let n=!1;d<=v?l(e)||(e.n|=h,n=!u(e)):n=!e.has(g),n&&(e.add(g),g.deps.push(e))}function E(e,t,n,o,i,s){const u=p.get(e);if(!u)return;let l=[];if("clear"===t)l=[...u.values()];else if("length"===n&&(0,r.kJ)(e))u.forEach(((e,t)=>{("length"===t||t>=o)&&l.push(e)}));else switch(void 0!==n&&l.push(u.get(n)),t){case"add":(0,r.kJ)(e)?(0,r.S0)(n)&&l.push(u.get("length")):(l.push(u.get(m)),(0,r._N)(e)&&l.push(u.get(y)));break;case"delete":(0,r.kJ)(e)||(l.push(u.get(m)),(0,r._N)(e)&&l.push(u.get(y)));break;case"set":(0,r._N)(e)&&l.push(u.get(m));break}if(1===l.length)l[0]&&A(l[0]);else{const e=[];for(const t of l)t&&e.push(...t);A(c(e))}}function A(e,t){const n=(0,r.kJ)(e)?e:[...e];for(const r of n)r.computed&&R(r,t);for(const r of n)r.computed||R(r,t)}function R(e,t){(e!==g||e.allowRecurse)&&(e.scheduler?e.scheduler():e.run())}const I=(0,r.fY)("__proto__,__v_isRef,__isVue"),P=new Set(Object.getOwnPropertyNames(Symbol).filter((e=>"arguments"!==e&&"caller"!==e)).map((e=>Symbol[e])).filter(r.yk)),T=$(),j=$(!1,!0),F=$(!0),M=L();function L(){const e={};return["includes","indexOf","lastIndexOf"].forEach((t=>{e[t]=function(...e){const n=Ae(this);for(let t=0,o=this.length;t<o;t++)C(n,"get",t+"");const r=n[t](...e);return-1===r||!1===r?n[t](...e.map(Ae)):r}})),["push","pop","shift","unshift","splice"].forEach((t=>{e[t]=function(...e){k();const n=Ae(this)[t].apply(this,e);return S(),n}})),e}function $(e=!1,t=!1){return function(n,o,i){if("__v_isReactive"===o)return!e;if("__v_isReadonly"===o)return e;if("__v_isShallow"===o)return t;if("__v_raw"===o&&i===(e?t?me:ge:t?ve:he).get(n))return n;const s=(0,r.kJ)(n);if(!e&&s&&(0,r.RI)(M,o))return Reflect.get(M,o,i);const c=Reflect.get(n,o,i);return((0,r.yk)(o)?P.has(o):I(o))?c:(e||C(n,"get",o),t?c:Fe(c)?s&&(0,r.S0)(o)?c:c.value:(0,r.Kn)(c)?e?we(c):_e(c):c)}}const U=D(),N=D(!0);function D(e=!1){return function(t,n,o,i){let s=t[n];if(Ce(s)&&Fe(s)&&!Fe(o))return!1;if(!e&&!Ce(o)&&(Oe(o)||(o=Ae(o),s=Ae(s)),!(0,r.kJ)(t)&&Fe(s)&&!Fe(o)))return s.value=o,!0;const c=(0,r.kJ)(t)&&(0,r.S0)(n)?Number(n)<t.length:(0,r.RI)(t,n),u=Reflect.set(t,n,o,i);return t===Ae(i)&&(c?(0,r.aU)(o,s)&&E(t,"set",n,o,s):E(t,"add",n,o)),u}}function J(e,t){const n=(0,r.RI)(e,t),o=e[t],i=Reflect.deleteProperty(e,t);return i&&n&&E(e,"delete",t,void 0,o),i}function B(e,t){const n=Reflect.has(e,t);return(0,r.yk)(t)&&P.has(t)||C(e,"has",t),n}function V(e){return C(e,"iterate",(0,r.kJ)(e)?"length":m),Reflect.ownKeys(e)}const G={get:T,set:U,deleteProperty:J,has:B,ownKeys:V},q={get:F,set(e,t){return!0},deleteProperty(e,t){return!Line truncated
|
||||
"use strict";(self["webpackChunkgatus"]=self["webpackChunkgatus"]||[]).push([[998],{262:function(e,t,n){n.d(t,{$y:function(){return we},BX:function(){return Oe},Bj:function(){return i},Fl:function(){return Ne},IU:function(){return Se},Jd:function(){return S},PG:function(){return ke},SU:function(){return Te},Um:function(){return ve},WL:function(){return Fe},X$:function(){return D},X3:function(){return _e},XB:function(){return $},XI:function(){return Me},Xl:function(){return Ce},YL:function(){return Ee},YP:function(){return Je},dq:function(){return Re},fw:function(){return Ve},iH:function(){return je},j:function(){return F},lk:function(){return C},qj:function(){return ge},qq:function(){return a},yT:function(){return xe}});var r=n(577);
|
||||
/**
|
||||
* @vue/reactivity v3.5.18
|
||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||
* @license MIT
|
||||
**/let o,s;class i{constructor(e=!1){this.detached=e,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this.parent=o,!e&&o&&(this.index=(o.scopes||(o.scopes=[])).push(this)-1)}get active(){return this._active}pause(){if(this._active){let e,t;if(this._isPaused=!0,this.scopes)for(e=0,t=this.scopes.length;e<t;e++)this.scopes[e].pause();for(e=0,t=this.effects.length;e<t;e++)this.effects[e].pause()}}resume(){if(this._active&&this._isPaused){let e,t;if(this._isPaused=!1,this.scopes)for(e=0,t=this.scopes.length;e<t;e++)this.scopes[e].resume();for(e=0,t=this.effects.length;e<t;e++)this.effects[e].resume()}}run(e){if(this._active){const t=o;try{return o=this,e()}finally{o=t}}else 0}on(){1===++this._on&&(this.prevScope=o,o=this)}off(){this._on>0&&0===--this._on&&(o=this.prevScope,this.prevScope=void 0)}stop(e){if(this._active){let t,n;for(this._active=!1,t=0,n=this.effects.length;t<n;t++)this.effects[t].stop();for(this.effects.length=0,t=0,n=this.cleanups.length;t<n;t++)this.cleanups[t]();if(this.cleanups.length=0,this.scopes){for(t=0,n=this.scopes.length;t<n;t++)this.scopes[t].stop(!0);this.scopes.length=0}if(!this.detached&&this.parent&&!e){const e=this.parent.scopes.pop();e&&e!==this&&(this.parent.scopes[this.index]=e,e.index=this.index)}this.parent=void 0}}}function l(){return o}const c=new WeakSet;class a{constructor(e){this.fn=e,this.deps=void 0,this.depsTail=void 0,this.flags=5,this.next=void 0,this.cleanup=void 0,this.scheduler=void 0,o&&o.active&&o.effects.push(this)}pause(){this.flags|=64}resume(){64&this.flags&&(this.flags&=-65,c.has(this)&&(c.delete(this),this.trigger()))}notify(){2&this.flags&&!(32&this.flags)||8&this.flags||p(this)}run(){if(!(1&this.flags))return this.fn();this.flags|=2,E(this),g(this);const e=s,t=x;s=this,x=!0;try{return this.fn()}finally{0,v(this),s=e,x=t,this.flags&=-3}}stop(){if(1&this.flags){for(let e=this.deps;e;e=e.nextDep)k(e);this.deps=this.depsTail=void 0,E(this),this.onStop&&this.onStop(),this.flags&=-2}}trigger(){64&this.flags?c.add(this):this.scheduler?this.scheduler():this.runIfDirty()}runIfDirty(){b(this)&&this.run()}get dirty(){return b(this)}}let u,f,d=0;function p(e,t=!1){if(e.flags|=8,t)return e.next=f,void(f=e);e.next=u,u=e}function h(){d++}function m(){if(--d>0)return;if(f){let e=f;f=void 0;while(e){const t=e.next;e.next=void 0,e.flags&=-9,e=t}}let e;while(u){let n=u;u=void 0;while(n){const r=n.next;if(n.next=void 0,n.flags&=-9,1&n.flags)try{n.trigger()}catch(t){e||(e=t)}n=r}}if(e)throw e}function g(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function v(e){let t,n=e.depsTail,r=n;while(r){const e=r.prevDep;-1===r.version?(r===n&&(n=e),k(r),w(r)):t=r,r.dep.activeLink=r.prevActiveLink,r.prevActiveLink=void 0,r=e}e.deps=t,e.depsTail=n}function b(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(y(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function y(e){if(4&e.flags&&!(16&e.flags))return;if(e.flags&=-17,e.globalVersion===O)return;if(e.globalVersion=O,!e.isSSR&&128&e.flags&&(!e.deps&&!e._dirty||!b(e)))return;e.flags|=2;const t=e.dep,n=s,o=x;s=e,x=!0;try{g(e);const i=e.fn(e._value);(0===t.version||(0,r.aU)(i,e._value))&&(e.flags|=128,e._value=i,t.version++)}catch(i){throw t.version++,i}finally{s=n,x=o,v(e),e.flags&=-3}}function k(e,t=!1){const{dep:n,prevSub:r,nextSub:o}=e;if(r&&(r.nextSub=o,e.prevSub=void 0),o&&(o.prevSub=r,e.nextSub=void 0),n.subs===e&&(n.subs=r,!r&&n.computed)){n.computed.flags&=-5;for(let e=n.computed.deps;e;e=e.nextDep)k(e,!0)}t||--n.sc||!n.map||n.map.delete(n.key)}function w(e){const{prevDep:t,nextDep:n}=e;t&&(t.nextDep=n,e.prevDep=void 0),n&&(n.prevDep=t,e.nextDep=void 0)}let x=!0;const _=[];function S(){_.push(x),x=!1}function C(){const e=_.pop();x=void 0===e||e}function E(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const e=s;s=void 0;try{t()}finally{s=e}}}let O=0;class R{constructor(e,t){this.sub=e,this.dep=t,this.version=t.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class j{constructor(e){this.computed=e,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(e){if(!s||!x||s===this.computed)return;let t=this.activeLink;if(void 0===t||t.sub!==s)t=this.activeLink=new R(s,this),s.deps?(t.prevDep=s.depsTail,s.depsTail.nextDep=t,s.depsTail=t):s.deps=s.depsTail=t,M(t);else if(-1===t.version&&(t.version=this.version,t.nextDep)){const e=t.nextDep;e.prevDep=t.prevDep,t.prevDep&&(t.prevDep.nextDep=e),t.prevDep=s.depsTail,t.nextDep=void 0,s.depsTail.nextDep=t,s.depsTail=t,s.deps===t&&(s.deps=e)}return t}trigger(e){this.version++,O++,this.notify(e)}notify(e){h();try{0;for(let e=this.subs;e;e=e.prevSub)e.sub.notify()&&e.sub.dep.notify()}finally{m()}}}function M(e){if(e.dep.sc++,4&e.sub.flags){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let e=t.deps;e;e=e.nextDep)M(e)}const n=e.dep.subs;n!==e&&(e.preLine truncated
|
||||
/*! #__NO_SIDE_EFFECTS__ */function J(e,t){return(0,o.mf)(e)?(()=>(0,o.l7)({name:e.name},t,{setup:e}))():e}function V(e){e.ids=[e.ids[0]+e.ids[2]+++"-",0,0]}function Z(e,t,n,i,l=!1){if((0,o.kJ)(e))return void e.forEach(((e,r)=>Z(e,t&&((0,o.kJ)(t)?t[r]:t),n,i,l)));if(H(i)&&!l)return void(512&i.shapeFlag&&i.type.__asyncResolved&&i.component.subTree.component&&Z(e,t,n,i.component.subTree));const c=4&i.shapeFlag?Fn(i.component):i.el,a=l?null:c,{i:u,r:f}=e;const d=t&&t.r,p=u.refs===o.kT?u.refs={}:u.refs,h=u.setupState,m=(0,r.IU)(h),g=h===o.kT?()=>!1:e=>(0,o.RI)(m,e);if(null!=d&&d!==f&&((0,o.HD)(d)?(p[d]=null,g(d)&&(h[d]=null)):(0,r.dq)(d)&&(d.value=null)),(0,o.mf)(f))s(f,u,12,[a,p]);else{const t=(0,o.HD)(f),s=(0,r.dq)(f);if(t||s){const r=()=>{if(e.f){const n=t?g(f)?h[f]:p[f]:f.value;l?(0,o.kJ)(n)&&(0,o.Od)(n,c):(0,o.kJ)(n)?n.includes(c)||n.push(c):t?(p[f]=[c],g(f)&&(h[f]=p[f])):(f.value=[c],e.k&&(p[e.k]=f.value))}else t?(p[f]=a,g(f)&&(h[f]=a)):s&&(f.value=a,e.k&&(p[e.k]=a))};a?(r.id=-1,dt(r,n)):r()}else 0}}(0,o.E9)().requestIdleCallback,(0,o.E9)().cancelIdleCallback;const H=e=>!!e.type.__asyncLoader
|
||||
/*! #__NO_SIDE_EFFECTS__ */;const B=e=>e.type.__isKeepAlive;RegExp,RegExp;function G(e,t){return(0,o.kJ)(e)?e.some((e=>G(e,t))):(0,o.HD)(e)?e.split(",").includes(t):!!(0,o.Kj)(e)&&(e.lastIndex=0,e.test(t))}function W(e,t){K(e,"a",t)}function q(e,t){K(e,"da",t)}function K(e,t,n=yn){const r=e.__wdc||(e.__wdc=()=>{let t=n;while(t){if(t.isDeactivated)return;t=t.parent}return e()});if(ee(t,r,n),n){let e=n.parent;while(e&&e.parent)B(e.parent.vnode)&&Y(r,t,n,e),e=e.parent}}function Y(e,t,n,r){const s=ee(t,e,r,!0);le((()=>{(0,o.Od)(r[t],s)}),n)}function X(e){e.shapeFlag&=-257,e.shapeFlag&=-513}function Q(e){return 128&e.shapeFlag?e.ssContent:e}function ee(e,t,n=yn,o=!1){if(n){const s=n[e]||(n[e]=[]),l=t.__weh||(t.__weh=(...o)=>{(0,r.Jd)();const s=_n(n),l=i(t,n,e,o);return s(),(0,r.lk)(),l});return o?s.unshift(l):s.push(l),l}}const te=e=>(t,n=yn)=>{Rn&&"sp"!==e||ee(e,((...e)=>t(...e)),n)},ne=te("bm"),re=te("m"),oe=te("bu"),se=te("u"),ie=te("bum"),le=te("um"),ce=te("sp"),ae=te("rtg"),ue=te("rtc");function fe(e,t=yn){ee("ec",e,t)}const de="components";function pe(e,t){return ge(de,e,!0,t)||e}const he=Symbol.for("v-ndc");function me(e){return(0,o.HD)(e)?ge(de,e,!1)||e:e||he}function ge(e,t,n=!0,r=!1){const s=C||yn;if(s){const n=s.type;if(e===de){const e=Dn(n,!1);if(e&&(e===t||e===(0,o._A)(t)||e===(0,o.kC)((0,o._A)(t))))return n}const i=ve(s[e]||n[e],t)||ve(s.appContext[e],t);return!i&&r?n:i}}function ve(e,t){return e&&(e[t]||e[(0,o._A)(t)]||e[(0,o.kC)((0,o._A)(t))])}function be(e,t,n,s){let i;const l=n&&n[s],c=(0,o.kJ)(e);if(c||(0,o.HD)(e)){const n=c&&(0,r.PG)(e);let o=!1,s=!1;n&&(o=!(0,r.yT)(e),s=(0,r.$y)(e),e=(0,r.XB)(e)),i=new Array(e.length);for(let c=0,a=e.length;c<a;c++)i[c]=t(o?s?(0,r.BX)((0,r.YL)(e[c])):(0,r.YL)(e[c]):e[c],c,void 0,l&&l[c])}else if("number"===typeof e){0,i=new Array(e);for(let n=0;n<e;n++)i[n]=t(n+1,n,void 0,l&&l[n])}else if((0,o.Kn)(e))if(e[Symbol.iterator])i=Array.from(e,((e,n)=>t(e,n,void 0,l&&l[n])));else{const n=Object.keys(e);i=new Array(n.length);for(let r=0,o=n.length;r<o;r++){const o=n[r];i[r]=t(e[o],o,r,l&&l[r])}}else i=[];return n&&(n[s]=i),i}function ye(e,t,n={},r,s){if(C.ce||C.parent&&H(C.parent)&&C.parent.ce)return"default"!==t&&(n.name=t),Bt(),Xt(Ut,null,[on("slot",n,r&&r())],64);let i=e[t];i&&i._c&&(i._d=!1),Bt();const l=i&&ke(i(n)),c=n.key||l&&l.key,a=Xt(Ut,{key:(c&&!(0,o.yk)(c)?c:`_${t}`)+(!l&&r?"_fb":"")},l||(r?r():[]),l&&1===e._?64:-2);return!s&&a.scopeId&&(a.slotScopeIds=[a.scopeId+"-s"]),i&&i._c&&(i._d=!0),a}function ke(e){return e.some((e=>!Qt(e)||e.type!==Jt&&!(e.type===Ut&&!ke(e.children))))?e:null}const we=e=>e?Cn(e)?Fn(e):we(e.parent):null,xe=(0,o.l7)(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>we(e.parent),$root:e=>we(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>Ae(e),$forceUpdate:e=>e.f||(e.f=()=>{b(e.update)}),$nextTick:e=>e.n||(e.n=g.bind(e.proxy)),$watch:e=>Et.bind(e)}),_e=(e,t)=>e!==o.kT&&!e.__isScriptSetup&&(0,o.RI)(e,t),Se={get({_:e},t){if("__v_skip"===t)return!0;const{ctx:n,setupState:s,data:i,props:l,accessCache:c,type:a,appContext:u}=e;let f;if("$"!==t[0]){const r=c[t];if(void 0!==r)switch(r){case 1:return s[t];case 2:return i[t];case 4:return n[t];case 3:return l[t]}else{if(_e(s,t))return c[t]=1,s[t];if(i!==o.kT&&(0,o.RI)(i,t))return c[t]=2,i[t];if((f=e.propsOptions[0])&&(0,o.RI)(f,t))return c[t]=3,l[t];if(n!==o.kT&&(0,o.RI)(n,t))return c[t]=4,n[t];Ee&&(c[t]=0)}}const d=xe[t];let p,h;return d?("$attrs"===t&&(0,r.j)(e.attrs,"get",""),d(e)):(p=a.__cssModules)&&(p=p[t])?p:n!==o.kT&&(0,o.RI)(n,t)?(c[t]=4,n[t]):(h=u.config.globalProperties,(0,o.RI)(h,t)?h[t]:void 0)},set({_:e},t,n){const{data:r,setupState:s,ctx:i}=e;return _e(s,t)?(s[t]=n,!0):r!==o.kT&&(0,o.RI)(r,t)?(r[t]=n,!0):!(0,o.RI)(e.props,t)&&(("$"!==t[0]||!(t.slice(1)in e))&&(i[t]=n,!0))},has({_:{data:e,setupState:t,accessCache:n,ctx:r,appContext:s,propsOptions:i}},l){let c;return!!n[l]||e!==o.kT&&(0,o.RI)(e,l)||_e(t,l)||(c=i[0])&&(0,o.RI)(c,l)||(0,o.RI)(r,l)||(0,o.RI)(xe,l)||(0,o.RI)(s.config.globalProperties,l)},defineProperty(e,t,n){return null!=n.get?e._.accessCache[t]=0:(0,o.RI)(n,"value")&&this.set(e,t,n.value,null),Reflect.defineProperty(e,t,n)}};function Ce(e){return(0,o.kJ)(e)?e.reduce(((e,t)=>(e[t]=null,e)),{}):e}let Ee=!0;function Oe(e){const t=Ae(e),n=e.proxy,s=e.ctx;Ee=!1,t.beforeCreate&&je(t.beforeCreate,e,"bc");const{data:i,computed:l,methods:c,watch:a,provide:u,inject:f,created:d,beforeMount:p,mounted:h,beforeUpdate:m,updated:g,activated:v,deactivated:b,beforeDestroy:y,beforeUnmount:k,destroyed:w,unmounted:x,render:_,renderTracked:S,renderTriggered:C,errorCaptured:E,serverPrefetch:O,expose:R,inheritAttrs:j,components:M,directives:A,filters:I}=t,T=null;if(f&&Re(f,s,T),c)for(const r in c){const e=c[r];(0,o.mf)(e)&&(s[r]=e.bind(n))}if(i){0;const t=i.call(n,n);0,(0,o.Kn)(t)&&(e.data=(0,r.qj)(t))}if(Ee=!0,l)for(const r in l){const e=l[r],t=(0,o.mf)(e)?e.bind(n,n):(0,o.mf)(e.get)?e.get.bindLine truncated
|
||||
/**
|
||||
* @vue/runtime-dom v3.5.18
|
||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||
* @license MIT
|
||||
**/
|
||||
let s;const i="undefined"!==typeof window&&window.trustedTypes;if(i)try{s=i.createPolicy("vue",{createHTML:e=>e})}catch(se){}const l=s?e=>s.createHTML(e):e=>e,c="http://www.w3.org/2000/svg",a="http://www.w3.org/1998/Math/MathML",u="undefined"!==typeof document?document:null,f=u&&u.createElement("template"),d={insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,r)=>{const o="svg"===t?u.createElementNS(c,e):"mathml"===t?u.createElementNS(a,e):n?u.createElement(e,{is:n}):u.createElement(e);return"select"===e&&r&&null!=r.multiple&&o.setAttribute("multiple",r.multiple),o},createText:e=>u.createTextNode(e),createComment:e=>u.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>u.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,n,r,o,s){const i=n?n.previousSibling:t.lastChild;if(o&&(o===s||o.nextSibling)){while(1)if(t.insertBefore(o.cloneNode(!0),n),o===s||!(o=o.nextSibling))break}else{f.innerHTML=l("svg"===r?`<svg>${e}</svg>`:"mathml"===r?`<math>${e}</math>`:e);const o=f.content;if("svg"===r||"mathml"===r){const e=o.firstChild;while(e.firstChild)o.appendChild(e.firstChild);o.removeChild(e)}t.insertBefore(o,n)}return[i?i.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}},p=Symbol("_vtc"),h={name:String,type:String,css:{type:Boolean,default:!0},duration:[String,Number,Object],enterFromClass:String,enterActiveClass:String,enterToClass:String,appearFromClass:String,appearActiveClass:String,appearToClass:String,leaveFromClass:String,leaveActiveClass:String,leaveToClass:String};r.nJ;function m(e,t,n){const r=e[p];r&&(t=(t?[t,...r]:[...r]).join(" ")),null==t?e.removeAttribute("class"):n?e.setAttribute("class",t):e.className=t}const g=Symbol("_vod"),v=Symbol("_vsh");const b=Symbol("");const y=/(^|;)\s*display\s*:/;function k(e,t,n){const r=e.style,s=(0,o.HD)(n);let i=!1;if(n&&!s){if(t)if((0,o.HD)(t))for(const e of t.split(";")){const t=e.slice(0,e.indexOf(":")).trim();null==n[t]&&x(r,t,"")}else for(const e in t)null==n[e]&&x(r,e,"");for(const e in n)"display"===e&&(i=!0),x(r,e,n[e])}else if(s){if(t!==n){const e=r[b];e&&(n+=";"+e),r.cssText=n,i=y.test(n)}}else t&&e.removeAttribute("style");g in e&&(e[g]=i?r.display:"",e[v]&&(r.display="none"))}const w=/\s*!important$/;function x(e,t,n){if((0,o.kJ)(n))n.forEach((n=>x(e,t,n)));else if(null==n&&(n=""),t.startsWith("--"))e.setProperty(t,n);else{const r=C(e,t);w.test(n)?e.setProperty((0,o.rs)(r),n.replace(w,""),"important"):e[r]=n}}const _=["Webkit","Moz","ms"],S={};function C(e,t){const n=S[t];if(n)return n;let r=(0,o._A)(t);if("filter"!==r&&r in e)return S[t]=r;r=(0,o.kC)(r);for(let o=0;o<_.length;o++){const n=_[o]+r;if(n in e)return S[t]=n}return t}const E="http://www.w3.org/1999/xlink";function O(e,t,n,r,s,i=(0,o.Pq)(t)){r&&t.startsWith("xlink:")?null==n?e.removeAttributeNS(E,t.slice(6,t.length)):e.setAttributeNS(E,t,n):null==n||i&&!(0,o.yA)(n)?e.removeAttribute(t):e.setAttribute(t,i?"":(0,o.yk)(n)?String(n):n)}function R(e,t,n,r,s){if("innerHTML"===t||"textContent"===t)return void(null!=n&&(e[t]="innerHTML"===t?l(n):n));const i=e.tagName;if("value"===t&&"PROGRESS"!==i&&!i.includes("-")){const r="OPTION"===i?e.getAttribute("value")||"":e.value,o=null==n?"checkbox"===e.type?"on":"":String(n);return r===o&&"_value"in e||(e.value=o),null==n&&e.removeAttribute(t),void(e._value=n)}let c=!1;if(""===n||null==n){const r=typeof e[t];"boolean"===r?n=(0,o.yA)(n):null==n&&"string"===r?(n="",c=!0):"number"===r&&(n=0,c=!0)}try{e[t]=n}catch(se){0}c&&e.removeAttribute(s||t)}function j(e,t,n,r){e.addEventListener(t,n,r)}function M(e,t,n,r){e.removeEventListener(t,n,r)}const A=Symbol("_vei");function I(e,t,n,r,o=null){const s=e[A]||(e[A]={}),i=s[t];if(r&&i)i.value=r;else{const[n,l]=P(t);if(r){const i=s[t]=$(r,o);j(e,n,i,l)}else i&&(M(e,n,i,l),s[t]=void 0)}}const T=/(?:Once|Passive|Capture)$/;function P(e){let t;if(T.test(e)){let n;t={};while(n=e.match(T))e=e.slice(0,e.length-n[0].length),t[n[0].toLowerCase()]=!0}const n=":"===e[2]?e.slice(3):(0,o.rs)(e.slice(2));return[n,t]}let F=0;const D=Promise.resolve(),N=()=>F||(D.then((()=>F=0)),F=Date.now());function $(e,t){const n=e=>{if(e._vts){if(e._vts<=n.attached)return}else e._vts=Date.now();(0,r.$d)(L(e,n.value),t,5,[e])};return n.value=e,n.attached=N(),n}function L(e,t){if((0,o.kJ)(t)){const n=e.stopImmediatePropagation;return e.stopImmediatePropagation=()=>{n.call(e),e._stopped=!0},t.map((e=>t=>!t._stopped&&e&&e(t)))}return t}const U=e=>111===e.charCodeAt(0)&&110===e.charCodeAt(1)&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,z=(e,t,n,r,s,i)=>{const l="svg"===s;"class"===t?m(e,r,l):"style"===t?k(e,n,r):(0,o.F7)(t)?(0,o.tR)(t)||I(e,t,n,r,i):("."===t[0]?(t=t.slice(1),1):"^"===t[0]?(t=t.slice(1),0):J(e,t,r,l))?(R(e,t,r),e.tagName.includes("-")||"value"!==t&&"checked"!==t&&"selected"!==t||O(e,t,r,l,i,"value"!==t)):!e._isVueCE||!/[A-Z]/.testLine truncated
|
||||
/*! #__NO_SIDE_EFFECTS__ */
|
||||
"undefined"!==typeof HTMLElement&&HTMLElement;Symbol("_moveCb"),Symbol("_enterCb");const V=e=>{const t=e.props["onUpdate:modelValue"]||!1;return(0,o.kJ)(t)?e=>(0,o.ir)(t,e):t};const Z=Symbol("_assign");const H={deep:!0,created(e,{value:t,modifiers:{number:n}},s){const i=(0,o.DM)(t);j(e,"change",(()=>{const t=Array.prototype.filter.call(e.options,(e=>e.selected)).map((e=>n?(0,o.h5)(G(e)):G(e)));e[Z](e.multiple?i?new Set(t):t:t[0]),e._assigning=!0,(0,r.Y3)((()=>{e._assigning=!1}))})),e[Z]=V(s)},mounted(e,{value:t}){B(e,t)},beforeUpdate(e,t,n){e[Z]=V(n)},updated(e,{value:t}){e._assigning||B(e,t)}};function B(e,t){const n=e.multiple,r=(0,o.kJ)(t);if(!n||r||(0,o.DM)(t)){for(let s=0,i=e.options.length;s<i;s++){const i=e.options[s],l=G(i);if(n)if(r){const e=typeof l;i.selected="string"===e||"number"===e?t.some((e=>String(e)===String(l))):(0,o.hq)(t,l)>-1}else i.selected=t.has(l);else if((0,o.WV)(G(i),t))return void(e.selectedIndex!==s&&(e.selectedIndex=s))}n||-1===e.selectedIndex||(e.selectedIndex=-1)}}function G(e){return"_value"in e?e._value:e.value}const W=["ctrl","shift","alt","meta"],q={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&0!==e.button,middle:e=>"button"in e&&1!==e.button,right:e=>"button"in e&&2!==e.button,exact:(e,t)=>W.some((n=>e[`${n}Key`]&&!t.includes(n)))},K=(e,t)=>{const n=e._withMods||(e._withMods={}),r=t.join(".");return n[r]||(n[r]=(n,...r)=>{for(let e=0;e<t.length;e++){const r=q[t[e]];if(r&&r(n,t))return}return e(n,...r)})},Y={esc:"escape",space:" ",up:"arrow-up",left:"arrow-left",right:"arrow-right",down:"arrow-down",delete:"backspace"},X=(e,t)=>{const n=e._withKeys||(e._withKeys={}),r=t.join(".");return n[r]||(n[r]=n=>{if(!("key"in n))return;const r=(0,o.rs)(n.key);return t.some((e=>e===r||Y[e]===r))?e(n):void 0})},Q=(0,o.l7)({patchProp:z},d);let ee;function te(){return ee||(ee=(0,r.Us)(Q))}const ne=(...e)=>{const t=te().createApp(...e);const{mount:n}=t;return t.mount=e=>{const r=oe(e);if(!r)return;const s=t._component;(0,o.mf)(s)||s.render||s.template||(s.template=r.innerHTML),1===r.nodeType&&(r.textContent="");const i=n(r,!1,re(r));return r instanceof Element&&(r.removeAttribute("v-cloak"),r.setAttribute("data-v-app","")),i},t};function re(e){return e instanceof SVGElement?"svg":"function"===typeof MathMLElement&&e instanceof MathMLElement?"mathml":void 0}function oe(e){if((0,o.HD)(e)){const t=document.querySelector(e);return t}return e}},577:function(e,t,n){
|
||||
/**
|
||||
* @vue/shared v3.5.18
|
||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||
* @license MIT
|
||||
**/
|
||||
/*! #__NO_SIDE_EFFECTS__ */
|
||||
function r(e){const t=Object.create(null);for(const n of e.split(","))t[n]=1;return e=>e in t}n.d(t,{C_:function(){return Y},DM:function(){return g},E9:function(){return V},F7:function(){return c},Gg:function(){return j},HD:function(){return k},He:function(){return z},Kj:function(){return b},Kn:function(){return x},NO:function(){return l},Nj:function(){return L},Od:function(){return f},PO:function(){return O},Pq:function(){return Q},RI:function(){return p},S0:function(){return R},W7:function(){return E},WV:function(){return ne},Z6:function(){return s},_A:function(){return I},_N:function(){return m},aU:function(){return N},dG:function(){return i},fY:function(){return r},h5:function(){return U},hR:function(){return D},hq:function(){return re},ir:function(){return $},j5:function(){return B},kC:function(){return F},kJ:function(){return h},kT:function(){return o},l7:function(){return u},mf:function(){return y},rs:function(){return P},tI:function(){return _},tR:function(){return a},yA:function(){return ee},yk:function(){return w},yl:function(){return H},zw:function(){return se}});const o={},s=[],i=()=>{},l=()=>!1,c=e=>111===e.charCodeAt(0)&&110===e.charCodeAt(1)&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),a=e=>e.startsWith("onUpdate:"),u=Object.assign,f=(e,t)=>{const n=e.indexOf(t);n>-1&&e.splice(n,1)},d=Object.prototype.hasOwnProperty,p=(e,t)=>d.call(e,t),h=Array.isArray,m=e=>"[object Map]"===C(e),g=e=>"[object Set]"===C(e),v=e=>"[object Date]"===C(e),b=e=>"[object RegExp]"===C(e),y=e=>"function"===typeof e,k=e=>"string"===typeof e,w=e=>"symbol"===typeof e,x=e=>null!==e&&"object"===typeof e,_=e=>(x(e)||y(e))&&y(e.then)&&y(e.catch),S=Object.prototype.toString,C=e=>S.call(e),E=e=>C(e).slice(8,-1),O=e=>"[object Object]"===C(e),R=e=>k(e)&&"NaN"!==e&&"-"!==e[0]&&""+parseInt(e,10)===e,j=r(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),M=e=>{const t=Object.create(null);return n=>{const r=t[n];return r||(t[n]=e(n))}},A=/-(\w)/g,I=M((e=>e.replace(A,((e,t)=>t?t.toUpperCase():"")))),T=/\B([A-Z])/g,P=M((e=>e.replace(T,"-$1").toLowerCase())),F=M((e=>e.charAt(0).toUpperCase()+e.slice(1))),D=M((e=>{const t=e?`on${F(e)}`:"";return t})),N=(e,t)=>!Object.is(e,t),$=(e,...t)=>{for(let n=0;n<e.length;n++)e[n](...t)},L=(e,t,n,r=!1)=>{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:r,value:n})},U=e=>{const t=parseFloat(e);return isNaN(t)?e:t},z=e=>{const t=k(e)?Number(e):NaN;return isNaN(t)?e:t};let J;const V=()=>J||(J="undefined"!==typeof globalThis?globalThis:"undefined"!==typeof self?self:"undefined"!==typeof window?window:"undefined"!==typeof n.g?n.g:{});const Z="Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt,console,Error,Symbol",H=r(Z);function B(e){if(h(e)){const t={};for(let n=0;n<e.length;n++){const r=e[n],o=k(r)?K(r):B(r);if(o)for(const e in o)t[e]=o[e]}return t}if(k(e)||x(e))return e}const G=/;(?![^(]*\))/g,W=/:([^]+)/,q=/\/\*[^]*?\*\//g;function K(e){const t={};return e.replace(q,"").split(G).forEach((e=>{if(e){const n=e.split(W);n.length>1&&(t[n[0].trim()]=n[1].trim())}})),t}function Y(e){let t="";if(k(e))t=e;else if(h(e))for(let n=0;n<e.length;n++){const r=Y(e[n]);r&&(t+=r+" ")}else if(x(e))for(const n in e)e[n]&&(t+=n+" ");return t.trim()}const X="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly",Q=r(X);function ee(e){return!!e||""===e}function te(e,t){if(e.length!==t.length)return!1;let n=!0;for(let r=0;n&&r<e.length;r++)n=ne(e[r],t[r]);return n}function ne(e,t){if(e===t)return!0;let n=v(e),r=v(t);if(n||r)return!(!n||!r)&&e.getTime()===t.getTime();if(n=w(e),r=w(t),n||r)return e===t;if(n=h(e),r=h(t),n||r)return!(!n||!r)&&te(e,t);if(n=x(e),r=x(t),n||r){if(!n||!r)return!1;const o=Object.keys(e).length,s=Object.keys(t).length;if(o!==s)return!1;for(const n in e){const r=e.hasOwnProperty(n),o=t.hasOwnProperty(n);if(r&&!o||!r&&o||!ne(e[n],t[n]))return!1}}return String(e)===String(t)}function re(e,t){return e.findIndex((e=>ne(e,t)))}const oe=e=>!(!e||!0!==e["__v_isRef"]),se=e=>k(e)?e:null==e?"":h(e)||x(e)&&(e.toString===S||!y(e.toString))?oe(e)?se(e.value):JSON.stringify(e,ie,2):String(e),ie=(e,t)=>oe(t)?ie(e,t.value):m(t)?{[`Map(${t.size})`]:[...t.entries()].reduce(((e,[t,n],r)=>(e[le(t,r)+" =>"]=n,e)),{})}:g(t)?{[`Set(${t.size})`]:[...t.values()].map((e=>le(e)))}:w(t)?le(t):!x(t)||h(t)||O(t)?t:String(t),le=(e,t="")=>{var n;return w(e)?`Symbol(${null!=(n=e.description)?n:t})`:e}},264:function(e,t,n){n.d(t,{Z:function(){return f}});var r=n(252);
|
||||
/**
|
||||
* @license lucide-vue-next v0.539.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
const o=e=>e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),s=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,((e,t,n)=>n?n.toUpperCase():t.toLowerCase())),i=e=>{const t=s(e);return t.charAt(0).toUpperCase()+t.slice(1)},l=(...e)=>e.filter(((e,t,n)=>Boolean(e)&&""!==e.trim()&&n.indexOf(e)===t)).join(" ").trim(),c=e=>""===e;
|
||||
/**
|
||||
* @license lucide-vue-next v0.539.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
var a={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":2,"stroke-linecap":"round","stroke-linejoin":"round"};
|
||||
/**
|
||||
* @license lucide-vue-next v0.539.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
const u=({name:e,iconNode:t,absoluteStrokeWidth:n,"absolute-stroke-width":s,strokeWidth:u,"stroke-width":f,size:d=a.width,color:p=a.stroke,...h},{slots:m})=>(0,r.h)("svg",{...a,...h,width:d,height:d,stroke:p,"stroke-width":c(n)||c(s)||!0===n||!0===s?24*Number(u||f||a["stroke-width"])/Number(d):u||f||a["stroke-width"],class:l("lucide",h.class,...e?[`lucide-${o(i(e))}-icon`,`lucide-${o(e)}`]:["lucide-icon"])},[...t.map((e=>(0,r.h)(...e))),...m.default?[m.default()]:[]]),f=(e,t)=>(n,{slots:o,attrs:s})=>(0,r.h)(u,{...s,...n,iconNode:t,name:e},o)},793:function(e,t,n){n.d(t,{Z:function(){return o}});var r=n(264);
|
||||
/**
|
||||
* @license lucide-vue-next v0.539.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const o=(0,r.Z)("activity",[["path",{d:"M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2",key:"169zse"}]])},318:function(e,t,n){n.d(t,{Z:function(){return o}});var r=n(264);
|
||||
/**
|
||||
* @license lucide-vue-next v0.539.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const o=(0,r.Z)("arrow-left",[["path",{d:"m12 19-7-7 7-7",key:"1l729n"}],["path",{d:"M19 12H5",key:"x3x0zl"}]])},368:function(e,t,n){n.d(t,{Z:function(){return o}});var r=n(264);
|
||||
/**
|
||||
* @license lucide-vue-next v0.539.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const o=(0,r.Z)("check",[["path",{d:"M20 6 9 17l-5-5",key:"1gmf2c"}]])},485:function(e,t,n){n.d(t,{Z:function(){return o}});var r=n(264);
|
||||
/**
|
||||
* @license lucide-vue-next v0.539.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const o=(0,r.Z)("chevron-down",[["path",{d:"m6 9 6 6 6-6",key:"qrunsl"}]])},372:function(e,t,n){n.d(t,{Z:function(){return o}});var r=n(264);
|
||||
/**
|
||||
* @license lucide-vue-next v0.539.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const o=(0,r.Z)("chevron-left",[["path",{d:"m15 18-6-6 6-6",key:"1wnfg3"}]])},981:function(e,t,n){n.d(t,{Z:function(){return o}});var r=n(264);
|
||||
/**
|
||||
* @license lucide-vue-next v0.539.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const o=(0,r.Z)("chevron-right",[["path",{d:"m9 18 6-6-6-6",key:"mthhwq"}]])},893:function(e,t,n){n.d(t,{Z:function(){return o}});var r=n(264);
|
||||
/**
|
||||
* @license lucide-vue-next v0.539.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const o=(0,r.Z)("chevron-up",[["path",{d:"m18 15-6-6-6 6",key:"153udz"}]])},146:function(e,t,n){n.d(t,{Z:function(){return o}});var r=n(264);
|
||||
/**
|
||||
* @license lucide-vue-next v0.539.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const o=(0,r.Z)("circle-alert",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["line",{x1:"12",x2:"12",y1:"8",y2:"12",key:"1pkeuh"}],["line",{x1:"12",x2:"12.01",y1:"16",y2:"16",key:"4dfq90"}]])},141:function(e,t,n){n.d(t,{Z:function(){return o}});var r=n(264);
|
||||
/**
|
||||
* @license lucide-vue-next v0.539.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const o=(0,r.Z)("circle-arrow-down",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"M12 8v8",key:"napkw2"}],["path",{d:"m8 12 4 4 4-4",key:"k98ssh"}]])},779:function(e,t,n){n.d(t,{Z:function(){return o}});var r=n(264);
|
||||
/**
|
||||
* @license lucide-vue-next v0.539.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const o=(0,r.Z)("circle-arrow-up",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"m16 12-4-4-4 4",key:"177agl"}],["path",{d:"M12 16V8",key:"1sbj14"}]])},89:function(e,t,n){n.d(t,{Z:function(){return o}});var r=n(264);
|
||||
/**
|
||||
* @license lucide-vue-next v0.539.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const o=(0,r.Z)("circle-check-big",[["path",{d:"M21.801 10A10 10 0 1 1 17 3.335",key:"yps3ct"}],["path",{d:"m9 11 3 3L22 4",key:"1pflzl"}]])},478:function(e,t,n){n.d(t,{Z:function(){return o}});var r=n(264);
|
||||
/**
|
||||
* @license lucide-vue-next v0.539.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const o=(0,r.Z)("circle-play",[["path",{d:"M9 9.003a1 1 0 0 1 1.517-.859l4.997 2.997a1 1 0 0 1 0 1.718l-4.997 2.997A1 1 0 0 1 9 14.996z",key:"kmsa83"}],["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}]])},135:function(e,t,n){n.d(t,{Z:function(){return o}});var r=n(264);
|
||||
/**
|
||||
* @license lucide-vue-next v0.539.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const o=(0,r.Z)("log-in",[["path",{d:"m10 17 5-5-5-5",key:"1bsop3"}],["path",{d:"M15 12H3",key:"6jk70r"}],["path",{d:"M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4",key:"u53s6r"}]])},507:function(e,t,n){n.d(t,{Z:function(){return o}});var r=n(264);
|
||||
/**
|
||||
* @license lucide-vue-next v0.539.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const o=(0,r.Z)("menu",[["path",{d:"M4 12h16",key:"1lakjw"}],["path",{d:"M4 18h16",key:"19g7jn"}],["path",{d:"M4 6h16",key:"1o0s65"}]])},679:function(e,t,n){n.d(t,{Z:function(){return o}});var r=n(264);
|
||||
/**
|
||||
* @license lucide-vue-next v0.539.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const o=(0,r.Z)("moon",[["path",{d:"M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401",key:"kfwtm"}]])},254:function(e,t,n){n.d(t,{Z:function(){return o}});var r=n(264);
|
||||
/**
|
||||
* @license lucide-vue-next v0.539.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const o=(0,r.Z)("refresh-cw",[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8",key:"v9h5vc"}],["path",{d:"M21 3v5h-5",key:"1q7to0"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16",key:"3uifl3"}],["path",{d:"M8 16H3v5",key:"1cv678"}]])},275:function(e,t,n){n.d(t,{Z:function(){return o}});var r=n(264);
|
||||
/**
|
||||
* @license lucide-vue-next v0.539.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const o=(0,r.Z)("search",[["path",{d:"m21 21-4.34-4.34",key:"14j7rj"}],["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}]])},789:function(e,t,n){n.d(t,{Z:function(){return o}});var r=n(264);
|
||||
/**
|
||||
* @license lucide-vue-next v0.539.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const o=(0,r.Z)("sun",[["circle",{cx:"12",cy:"12",r:"4",key:"4exip2"}],["path",{d:"M12 2v2",key:"tus03m"}],["path",{d:"M12 20v2",key:"1lh1kg"}],["path",{d:"m4.93 4.93 1.41 1.41",key:"149t6j"}],["path",{d:"m17.66 17.66 1.41 1.41",key:"ptbguv"}],["path",{d:"M2 12h2",key:"1t8f8n"}],["path",{d:"M20 12h2",key:"1q8mjw"}],["path",{d:"m6.34 17.66-1.41 1.41",key:"1m8zz5"}],["path",{d:"m19.07 4.93-1.41 1.41",key:"1shlcs"}]])},138:function(e,t,n){n.d(t,{Z:function(){return o}});var r=n(264);
|
||||
/**
|
||||
* @license lucide-vue-next v0.539.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const o=(0,r.Z)("timer",[["line",{x1:"10",x2:"14",y1:"2",y2:"2",key:"14vaq8"}],["line",{x1:"12",x2:"15",y1:"14",y2:"11",key:"17fdiu"}],["circle",{cx:"12",cy:"14",r:"8",key:"1e1u0o"}]])},970:function(e,t,n){n.d(t,{Z:function(){return o}});var r=n(264);
|
||||
/**
|
||||
* @license lucide-vue-next v0.539.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const o=(0,r.Z)("x",[["path",{d:"M18 6 6 18",key:"1bl5f8"}],["path",{d:"m6 6 12 12",key:"d8bk6v"}]])},744:function(e,t){t.Z=(e,t)=>{const n=e.__vccOpts||e;for(const[r,o]of t)n[r]=o;return n}},3:function(e,t,n){n.d(t,{j:function(){return i}});var r=n(512);const o=e=>"boolean"===typeof e?`${e}`:0===e?"0":e,s=r.W,i=(e,t)=>n=>{var r;if(null==(null===t||void 0===t?void 0:t.variants))return s(e,null===n||void 0===n?void 0:n.class,null===n||void 0===n?void 0:n.className);const{variants:i,defaultVariants:l}=t,c=Object.keys(i).map((e=>{const t=null===n||void 0===n?void 0:n[e],r=null===l||void 0===l?void 0:l[e];if(null===t)return null;const s=o(t)||o(r);return i[e][s]})),a=n&&Object.entries(n).reduce(((e,t)=>{let[n,r]=t;return void 0===r||(e[n]=r),e}),{}),u=null===t||void 0===t||null===(r=t.compoundVariants)||void 0===r?void 0:r.reduce(((e,t)=>{let{class:n,className:r,...o}=t;return Object.entries(o).every((e=>{let[t,n]=e;return Array.isArray(n)?n.includes({...l,...a}[t]):{...l,...a}[t]===n}))?[...e,n,r]:e}),[]);return s(e,c,u,null===n||void 0===n?void 0:n.class,null===n||void 0===n?void 0:n.className)}},512:function(e,t,n){function r(e){var t,n,o="";if("string"==typeof e||"number"==typeof e)o+=e;else if("object"==typeof e)if(Array.isArray(e)){var s=e.length;for(t=0;t<s;t++)e[t]&&(n=r(e[t]))&&(o&&(o+=" "),o+=n)}else for(n in e)e[n]&&(o&&(o+=" "),o+=n);return o}function o(){for(var e,t,n=0,o="",s=arguments.length;n<s;n++)(e=arguments[n])&&(t=r(e))&&(o&&(o+=" "),o+=t);return o}n.d(t,{W:function(){return o}})},388:function(e,t,n){n.d(t,{m6:function(){return me}});const r="-",o=e=>{const t=c(e),{conflictingClassGroups:n,conflictingClassGroupModifiers:o}=e,i=e=>{const n=e.split(r);return""===n[0]&&1!==n.length&&n.shift(),s(n,t)||l(e)},a=(e,t)=>{const r=n[e]||[];return t&&o[e]?[...r,...o[e]]:r};return{getClassGroupId:i,getConflictingClassGroupIds:a}},s=(e,t)=>{if(0===e.length)return t.classGroupId;const n=e[0],o=t.nextPart.get(n),i=o?s(e.slice(1),o):void 0;if(i)return i;if(0===t.validators.length)return;const l=e.join(r);return t.validators.find((({validator:e})=>e(l)))?.classGroupId},i=/^\[(.+)\]$/,l=e=>{if(i.test(e)){const t=i.exec(e)[1],n=t?.substring(0,t.indexOf(":"));if(n)return"arbitrary.."+n}},c=e=>{const{theme:t,classGroups:n}=e,r={nextPart:new Map,validators:[]};for(const o in n)a(n[o],r,o,t);return r},a=(e,t,n,r)=>{e.forEach((e=>{if("string"!==typeof e){if("function"===typeof e)return f(e)?void a(e(r),t,n,r):void t.validators.push({validator:e,classGroupId:n});Object.entries(e).forEach((([e,o])=>{a(o,u(t,e),n,r)}))}else{const r=""===e?t:u(t,e);r.classGroupId=n}}))},u=(e,t)=>{let n=e;return t.split(r).forEach((e=>{n.nextPart.has(e)||n.nextPart.set(e,{nextPart:new Map,validators:[]}),n=n.nextPart.get(e)})),n},f=e=>e.isThemeGetter,d=e=>{if(e<1)return{get:()=>{},set:()=>{}};let t=0,n=new Map,r=new Map;const o=(o,s)=>{n.set(o,s),t++,t>e&&(t=0,r=n,n=new Map)};return{get(e){let t=n.get(e);return void 0!==t?t:void 0!==(t=r.get(e))?(o(e,t),t):void 0},set(e,t){n.has(e)?n.set(e,t):o(e,t)}}},p="!",h=":",m=h.length,g=e=>{const{prefix:t,experimentalParseClassName:n}=e;let r=e=>{const t=[];let n,r=0,o=0,s=0;for(let u=0;u<e.length;u++){let i=e[u];if(0===r&&0===o){if(i===h){t.push(e.slice(s,u)),s=u+m;continue}if("/"===i){n=u;continue}}"["===i?r++:"]"===i?r--:"("===i?o++:")"===i&&o--}const i=0===t.length?e:e.substring(s),l=v(i),c=l!==i,a=n&&n>s?n-s:void 0;return{modifiers:t,hasImportantModifier:c,baseClassName:l,maybePostfixModifierPosition:a}};if(t){const e=t+h,n=r;r=t=>t.startsWith(e)?n(t.substring(e.length)):{isExternal:!0,modifiers:[],hasImportantModifier:!1,baseClassName:t,maybePostfixModifierPosition:void 0}}if(n){const e=r;r=t=>n({className:t,parseClassName:e})}return r},v=e=>e.endsWith(p)?e.substring(0,e.length-1):e.startsWith(p)?e.substring(1):e,b=e=>{const t=Object.fromEntries(e.orderSensitiveModifiers.map((e=>[e,!0]))),n=e=>{if(e.length<=1)return e;const n=[];let r=[];return e.forEach((e=>{const o="["===e[0]||t[e];o?(n.push(...r.sort(),e),r=[]):r.push(e)})),n.push(...r.sort()),n};return n},y=e=>({cache:d(e.cacheSize),parseClassName:g(e),sortModifiers:b(e),...o(e)}),k=/\s+/,w=(e,t)=>{const{parseClassName:n,getClassGroupId:r,getConflictingClassGroupIds:o,sortModifiers:s}=t,i=[],l=e.trim().split(k);let c="";for(let a=l.length-1;a>=0;a-=1){const e=l[a],{isExternal:t,modifiers:u,hasImportantModifier:f,baseClassName:d,maybePostfixModifierPosition:h}=n(e);if(t){c=e+(c.length>0?" "+c:c);continue}let m=!!h,g=r(m?d.substring(0,h):d);if(!g){if(!m){c=e+(c.length>0?" "+c:c);continue}if(g=r(d),!g){c=e+(c.length>0?" "+c:c);continue}m=!1}const v=s(u).join(":"),b=f?v+p:v,y=b+g;if(i.includes(y))continue;i.push(y);const k=o(g,m);for(let n=0;n<k.length;++n){const e=k[n];i.push(b+e)}c=e+(c.length>0?" "+c:c)}return c};function x(){let e,t,n=0,r="";while(n<arguments.length)(e=arguments[n++])&&(t=_(e))&&(r&&(r+=" "),r+=t);return r}const _=e=>{if("string"===typeof e)return e;let t,n="";for(let r=0;r<e.length;r++)e[r]&&(t=_(e[r]))&&(n&&(n+="Line truncated
|
||||
/*!
|
||||
* vue-router v4.0.16
|
||||
* (c) 2022 Eduardo San Martin Morote
|
||||
* vue-router v4.5.1
|
||||
* (c) 2025 Eduardo San Martin Morote
|
||||
* @license MIT
|
||||
*/
|
||||
const i="function"===typeof Symbol&&"symbol"===typeof Symbol.toStringTag,s=e=>i?Symbol(e):"_vr_"+e,c=s("rvlm"),u=s("rvd"),l=s("r"),a=s("rl"),f=s("rvl"),p="undefined"!==typeof window;function d(e){return e.__esModule||i&&"Module"===e[Symbol.toStringTag]}const h=Object.assign;function v(e,t){const n={};for(const r in t){const o=t[r];n[r]=Array.isArray(o)?o.map(e):e(o)}return n}const g=()=>{};const m=/\/$/,y=e=>e.replace(m,"");function b(e,t,n="/"){let r,o={},i="",s="";const c=t.indexOf("?"),u=t.indexOf("#",c>-1?c:0);return c>-1&&(r=t.slice(0,c),i=t.slice(c+1,u>-1?u:t.length),o=e(i)),u>-1&&(r=r||t.slice(0,u),s=t.slice(u,t.length)),r=E(null!=r?r:t,n),{fullPath:r+(i&&"?")+i+s,path:r,query:o,hash:s}}function _(e,t){const n=t.query?e(t.query):"";return t.path+(n&&"?")+n+(t.hash||"")}function x(e,t){return t&&e.toLowerCase().startsWith(t.toLowerCase())?e.slice(t.length)||"/":e}function w(e,t,n){const r=t.matched.length-1,o=n.matched.length-1;return r>-1&&r===o&&k(t.matched[r],n.matched[o])&&S(t.params,n.params)&&e(t.query)===e(n.query)&&t.hash===n.hash}function k(e,t){return(e.aliasOf||e)===(t.aliasOf||t)}function S(e,t){if(Object.keys(e).length!==Object.keys(t).length)return!1;for(const n in e)if(!C(e[n],t[n]))return!1;return!0}function C(e,t){return Array.isArray(e)?O(e,t):Array.isArray(t)?O(t,e):e===t}function O(e,t){return Array.isArray(t)?e.length===t.length&&e.every(((e,n)=>e===t[n])):1===e.length&&e[0]===t}function E(e,t){if(e.startsWith("/"))return e;if(!e)return t;const n=t.split("/"),r=e.split("/");let o,i,s=n.length-1;for(o=0;o<r.length;o++)if(i=r[o],1!==s&&"."!==i){if(".."!==i)break;s--}return n.slice(0,s).join("/")+"/"+r.slice(o-(o===r.length?1:0)).join("/")}var A,R;(function(e){e["pop"]="pop",e["push"]="push"})(A||(A={})),function(e){e["back"]="back",e["forward"]="forward",e["unknown"]=""}(R||(R={}));function I(e){if(!e)if(p){const t=document.querySelector("base");e=t&&t.getAttribute("href")||"/",e=e.replace(/^\w+:\/\/[^\/]+/,"")}else e="/";return"/"!==e[0]&&"#"!==e[0]&&(e="/"+e),y(e)}const P=/^[^#]+#/;function T(e,t){return e.replace(P,"#")+t}function j(e,t){const n=document.documentElement.getBoundingClientRect(),r=e.getBoundingClientRect();return{behavior:t.behavior,left:r.left-n.left-(t.left||0),top:r.top-n.top-(t.top||0)}}const F=()=>({left:window.pageXOffset,top:window.pageYOffset});function M(e){let t;if("el"in e){const n=e.el,r="string"===typeof n&&n.startsWith("#");0;const o="string"===typeof n?r?document.getElementById(n.slice(1)):document.querySelector(n):n;if(!o)return;t=j(o,e)}else t=e;"scrollBehavior"in document.documentElement.style?window.scrollTo(t):window.scrollTo(null!=t.left?t.left:window.pageXOffset,null!=t.top?t.top:window.pageYOffset)}function L(e,t){const n=history.state?history.state.position-t:-1;return n+e}const $=new Map;function U(e,t){$.set(e,t)}function N(e){const t=$.get(e);return $.delete(e),t}let D=()=>location.protocol+"//"+location.host;function J(e,t){const{pathname:n,search:r,hash:o}=t,i=e.indexOf("#");if(i>-1){let t=o.includes(e.slice(i))?e.slice(i).length:1,n=o.slice(t);return"/"!==n[0]&&(n="/"+n),x(n,"")}const s=x(n,e);return s+r+o}function B(e,t,n,r){let o=[],i=[],s=null;const c=({state:i})=>{const c=J(e,location),u=n.value,l=t.value;let a=0;if(i){if(n.value=c,t.value=i,s&&s===u)return void(s=null);a=l?i.position-l.position:0}else r(c);o.forEach((e=>{e(n.value,u,{delta:a,type:A.pop,direction:a?a>0?R.forward:R.back:R.unknown})}))};function u(){s=n.value}function l(e){o.push(e);const t=()=>{const t=o.indexOf(e);t>-1&&o.splice(t,1)};return i.push(t),t}function a(){const{history:e}=window;e.state&&e.replaceState(h({},e.state,{scroll:F()}),"")}function f(){for(const e of i)e();i=[],window.removeEventListener("popstate",c),window.removeEventListener("beforeunload",a)}return window.addEventListener("popstate",c),window.addEventListener("beforeunload",a),{pauseListeners:u,listen:l,destroy:f}}function V(e,t,n,r=!1,o=!1){return{back:e,current:t,forward:n,replaced:r,position:window.history.length,scroll:o?F():null}}function G(e){const{history:t,location:n}=window,r={value:J(e,n)},o={value:t.state};function i(r,i,s){const c=e.indexOf("#"),u=c>-1?(n.host&&document.querySelector("base")?e:e.slice(c))+r:D()+e+r;try{t[s?"replaceState":"pushState"](i,"",u),o.value=i}catch(l){console.error(l),n[s?"replace":"assign"](u)}}function s(e,n){const s=h({},t.state,V(o.value.back,e,o.value.forward,!0),n,{position:o.value.position});i(e,s,!0),r.value=e}function c(e,n){const s=h({},o.value,t.state,{forward:e,scroll:F()});i(s.current,s,!0);const c=h({},V(r.value,e,null),{position:s.position+1},n);i(e,c,!1),r.value=e}return o.value||i(r.value,{back:null,current:r.value,forward:null,position:t.length-1,replaced:!0,scroll:null},!0),{location:r,state:o,push:c,replace:s}}function q(e){e=I(e);const t=G(e),n=B(e,t.state,t.location,t.replace);function r(e,t=!0){t||n.pauseListeners(),history.go(e)}const o=h({location:"",base:e,go:r,createHref:T.bind(null,e)},t,n);return Object.defineProperty(o,"loLine truncated
|
||||
const s="undefined"!==typeof document;function i(e){return"object"===typeof e||"displayName"in e||"props"in e||"__vccOpts"in e}function l(e){return e.__esModule||"Module"===e[Symbol.toStringTag]||e.default&&i(e.default)}const c=Object.assign;function a(e,t){const n={};for(const r in t){const o=t[r];n[r]=f(o)?o.map(e):e(o)}return n}const u=()=>{},f=Array.isArray;const d=/#/g,p=/&/g,h=/\//g,m=/=/g,g=/\?/g,v=/\+/g,b=/%5B/g,y=/%5D/g,k=/%5E/g,w=/%60/g,x=/%7B/g,_=/%7C/g,S=/%7D/g,C=/%20/g;function E(e){return encodeURI(""+e).replace(_,"|").replace(b,"[").replace(y,"]")}function O(e){return E(e).replace(x,"{").replace(S,"}").replace(k,"^")}function R(e){return E(e).replace(v,"%2B").replace(C,"+").replace(d,"%23").replace(p,"%26").replace(w,"`").replace(x,"{").replace(S,"}").replace(k,"^")}function j(e){return R(e).replace(m,"%3D")}function M(e){return E(e).replace(d,"%23").replace(g,"%3F")}function A(e){return null==e?"":M(e).replace(h,"%2F")}function I(e){try{return decodeURIComponent(""+e)}catch(t){}return""+e}const T=/\/$/,P=e=>e.replace(T,"");function F(e,t,n="/"){let r,o={},s="",i="";const l=t.indexOf("#");let c=t.indexOf("?");return l<c&&l>=0&&(c=-1),c>-1&&(r=t.slice(0,c),s=t.slice(c+1,l>-1?l:t.length),o=e(s)),l>-1&&(r=r||t.slice(0,l),i=t.slice(l,t.length)),r=V(null!=r?r:t,n),{fullPath:r+(s&&"?")+s+i,path:r,query:o,hash:I(i)}}function D(e,t){const n=t.query?e(t.query):"";return t.path+(n&&"?")+n+(t.hash||"")}function N(e,t){return t&&e.toLowerCase().startsWith(t.toLowerCase())?e.slice(t.length)||"/":e}function $(e,t,n){const r=t.matched.length-1,o=n.matched.length-1;return r>-1&&r===o&&L(t.matched[r],n.matched[o])&&U(t.params,n.params)&&e(t.query)===e(n.query)&&t.hash===n.hash}function L(e,t){return(e.aliasOf||e)===(t.aliasOf||t)}function U(e,t){if(Object.keys(e).length!==Object.keys(t).length)return!1;for(const n in e)if(!z(e[n],t[n]))return!1;return!0}function z(e,t){return f(e)?J(e,t):f(t)?J(t,e):e===t}function J(e,t){return f(t)?e.length===t.length&&e.every(((e,n)=>e===t[n])):1===e.length&&e[0]===t}function V(e,t){if(e.startsWith("/"))return e;if(!e)return t;const n=t.split("/"),r=e.split("/"),o=r[r.length-1];".."!==o&&"."!==o||r.push("");let s,i,l=n.length-1;for(s=0;s<r.length;s++)if(i=r[s],"."!==i){if(".."!==i)break;l>1&&l--}return n.slice(0,l).join("/")+"/"+r.slice(s).join("/")}const Z={path:"/",name:void 0,params:{},query:{},hash:"",fullPath:"/",matched:[],meta:{},redirectedFrom:void 0};var H,B;(function(e){e["pop"]="pop",e["push"]="push"})(H||(H={})),function(e){e["back"]="back",e["forward"]="forward",e["unknown"]=""}(B||(B={}));function G(e){if(!e)if(s){const t=document.querySelector("base");e=t&&t.getAttribute("href")||"/",e=e.replace(/^\w+:\/\/[^\/]+/,"")}else e="/";return"/"!==e[0]&&"#"!==e[0]&&(e="/"+e),P(e)}const W=/^[^#]+#/;function q(e,t){return e.replace(W,"#")+t}function K(e,t){const n=document.documentElement.getBoundingClientRect(),r=e.getBoundingClientRect();return{behavior:t.behavior,left:r.left-n.left-(t.left||0),top:r.top-n.top-(t.top||0)}}const Y=()=>({left:window.scrollX,top:window.scrollY});function X(e){let t;if("el"in e){const n=e.el,r="string"===typeof n&&n.startsWith("#");0;const o="string"===typeof n?r?document.getElementById(n.slice(1)):document.querySelector(n):n;if(!o)return;t=K(o,e)}else t=e;"scrollBehavior"in document.documentElement.style?window.scrollTo(t):window.scrollTo(null!=t.left?t.left:window.scrollX,null!=t.top?t.top:window.scrollY)}function Q(e,t){const n=history.state?history.state.position-t:-1;return n+e}const ee=new Map;function te(e,t){ee.set(e,t)}function ne(e){const t=ee.get(e);return ee.delete(e),t}let re=()=>location.protocol+"//"+location.host;function oe(e,t){const{pathname:n,search:r,hash:o}=t,s=e.indexOf("#");if(s>-1){let t=o.includes(e.slice(s))?e.slice(s).length:1,n=o.slice(t);return"/"!==n[0]&&(n="/"+n),N(n,"")}const i=N(n,e);return i+r+o}function se(e,t,n,r){let o=[],s=[],i=null;const l=({state:s})=>{const l=oe(e,location),c=n.value,a=t.value;let u=0;if(s){if(n.value=l,t.value=s,i&&i===c)return void(i=null);u=a?s.position-a.position:0}else r(l);o.forEach((e=>{e(n.value,c,{delta:u,type:H.pop,direction:u?u>0?B.forward:B.back:B.unknown})}))};function a(){i=n.value}function u(e){o.push(e);const t=()=>{const t=o.indexOf(e);t>-1&&o.splice(t,1)};return s.push(t),t}function f(){const{history:e}=window;e.state&&e.replaceState(c({},e.state,{scroll:Y()}),"")}function d(){for(const e of s)e();s=[],window.removeEventListener("popstate",l),window.removeEventListener("beforeunload",f)}return window.addEventListener("popstate",l),window.addEventListener("beforeunload",f,{passive:!0}),{pauseListeners:a,listen:u,destroy:d}}function ie(e,t,n,r=!1,o=!1){return{back:e,current:t,forward:n,replaced:r,position:window.history.length,scroll:o?Y():null}}function le(e){const{history:t,location:n}=window,r={value:oe(e,n)},o={value:t.state};function s(r,s,i){const l=e.indexOf("#"),c=l>-1?(n.host&&document.querySelector("base")?e:e.slice(l))+r:re()+e+r;try{t[i?"replaceState"Line truncated
|
||||