feat(ui): New status page UI (#1198)
* feat(ui): New status page UI * docs: Rename labels to extra-labels * Fix domain expiration test * feat(ui): Add ui.default-sort-by and ui.default-filter-by * Change ui.header default value to Gatus * Re-use EndpointCard in Details.vue as well to avoid duplicate code * Fix flaky metrics test * Add subtle green color to "Gatus" * Remove duplicate title (tooltip is sufficient, no need for title on top of that) * Fix collapsed group user preferences * Update status page screenshots
BIN
.github/assets/dashboard-conditions.jpg
vendored
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
.github/assets/dashboard-conditions.png
vendored
|
Before Width: | Height: | Size: 43 KiB |
BIN
.github/assets/dashboard-dark.jpg
vendored
Normal file
|
After Width: | Height: | Size: 536 KiB |
BIN
.github/assets/dashboard-dark.png
vendored
|
Before Width: | Height: | Size: 90 KiB |
BIN
.github/assets/endpoint-groups.jpg
vendored
Normal file
|
After Width: | Height: | Size: 375 KiB |
BIN
.github/assets/endpoint-groups.png
vendored
|
Before Width: | Height: | Size: 39 KiB |
BIN
.github/assets/example.jpg
vendored
Normal file
|
After Width: | Height: | Size: 207 KiB |
BIN
.github/assets/example.png
vendored
|
Before Width: | Height: | Size: 43 KiB |
23
README.md
@@ -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`.
|
||||
|
||||
@@ -223,7 +223,7 @@ 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). | `{}` |
|
||||
@@ -236,12 +236,12 @@ If you want to test it locally, see [Docker](#docker).
|
||||
| `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. | `` |
|
||||
| `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.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. | `[]` |
|
||||
@@ -249,6 +249,8 @@ If you want to test it locally, see [Docker](#docker).
|
||||
| `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`.
|
||||
@@ -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,16 +12,20 @@ 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
|
||||
@@ -34,6 +38,8 @@ type Config struct {
|
||||
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
|
||||
|
||||
2903
web/app/package-lock.json
generated
@@ -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>
|
||||
<router-view @showTooltip="showTooltip" />
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<!-- 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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<Tooltip :result="tooltip.result" :event="tooltip.event"/>
|
||||
<Social/>
|
||||
<!-- 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>
|
||||
</header>
|
||||
|
||||
<!-- 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'
|
||||
|
||||
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
|
||||
|
||||
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;
|
||||
})
|
||||
const data = await response.json()
|
||||
config.value = data
|
||||
}
|
||||
});
|
||||
},
|
||||
showTooltip(result, event) {
|
||||
this.tooltip = {result: result, event: event};
|
||||
}
|
||||
},
|
||||
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>
|
||||
159
web/app/src/components/EndpointCard.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<Card class="endpoint hover:shadow-lg transition-shadow cursor-pointer h-full flex flex-col">
|
||||
<CardHeader class="endpoint-header px-3 sm:px-6 pt-3 sm:pt-6 pb-2 space-y-0">
|
||||
<div class="flex items-start justify-between gap-2 sm:gap-3">
|
||||
<div class="flex-1 min-w-0 overflow-hidden">
|
||||
<CardTitle class="text-base sm:text-lg truncate">
|
||||
<span
|
||||
class="hover:text-primary cursor-pointer hover:underline text-sm sm:text-base block truncate"
|
||||
@click="navigateToDetails"
|
||||
@keydown.enter="navigateToDetails"
|
||||
:title="endpoint.name"
|
||||
role="link"
|
||||
tabindex="0"
|
||||
:aria-label="`View details for ${endpoint.name}`">
|
||||
{{ endpoint.name }}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<div class="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground">
|
||||
<span v-if="endpoint.group" class="truncate" :title="endpoint.group">{{ endpoint.group }}</span>
|
||||
<span v-if="endpoint.group && hostname">•</span>
|
||||
<span v-if="hostname" class="truncate" :title="hostname">{{ hostname }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0 ml-2">
|
||||
<StatusBadge :status="currentStatus" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="endpoint-content flex-1 pb-3 sm:pb-4 px-3 sm:px-6 pt-2">
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex-1"></div>
|
||||
<p class="text-xs text-muted-foreground" :title="showAverageResponseTime ? 'Average response time' : 'Minimum and maximum response time'">{{ formattedResponseTime }}</p>
|
||||
</div>
|
||||
<div class="flex gap-0.5">
|
||||
<div
|
||||
v-for="(result, index) in displayResults"
|
||||
:key="index"
|
||||
:class="[
|
||||
'flex-1 h-6 sm:h-8 rounded-sm transition-all',
|
||||
result ? (result.success ? 'bg-green-500 hover:bg-green-700' : 'bg-red-500 hover:bg-red-700') : 'bg-gray-200 dark:bg-gray-700'
|
||||
]"
|
||||
@mouseenter="result && emit('showTooltip', result, $event)"
|
||||
@mouseleave="result && emit('showTooltip', null, $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground mt-1">
|
||||
<span>{{ oldestResultTime }}</span>
|
||||
<span>{{ newestResultTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { helper } from '@/mixins/helper'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
endpoint: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
maxResults: {
|
||||
type: Number,
|
||||
default: 50
|
||||
},
|
||||
showAverageResponseTime: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['showTooltip'])
|
||||
|
||||
const latestResult = computed(() => {
|
||||
if (!props.endpoint.results || props.endpoint.results.length === 0) {
|
||||
return null
|
||||
}
|
||||
return props.endpoint.results[props.endpoint.results.length - 1]
|
||||
})
|
||||
|
||||
const currentStatus = computed(() => {
|
||||
if (!latestResult.value) return 'unknown'
|
||||
return latestResult.value.success ? 'healthy' : 'unhealthy'
|
||||
})
|
||||
|
||||
const hostname = computed(() => {
|
||||
return latestResult.value?.hostname || null
|
||||
})
|
||||
|
||||
const displayResults = computed(() => {
|
||||
const results = [...(props.endpoint.results || [])]
|
||||
while (results.length < props.maxResults) {
|
||||
results.unshift(null)
|
||||
}
|
||||
return results.slice(-props.maxResults)
|
||||
})
|
||||
|
||||
const formattedResponseTime = computed(() => {
|
||||
if (!props.endpoint.results || props.endpoint.results.length === 0) {
|
||||
return 'N/A'
|
||||
}
|
||||
|
||||
let total = 0
|
||||
let count = 0
|
||||
let min = Infinity
|
||||
let max = 0
|
||||
|
||||
for (const result of props.endpoint.results) {
|
||||
if (result.duration) {
|
||||
const durationMs = result.duration / 1000000
|
||||
total += durationMs
|
||||
count++
|
||||
min = Math.min(min, durationMs)
|
||||
max = Math.max(max, durationMs)
|
||||
}
|
||||
}
|
||||
|
||||
if (count === 0) return 'N/A'
|
||||
|
||||
if (props.showAverageResponseTime) {
|
||||
const avgMs = Math.round(total / count)
|
||||
return `~${avgMs}ms`
|
||||
} else {
|
||||
// Show min-max range
|
||||
const minMs = Math.round(min)
|
||||
const maxMs = Math.round(max)
|
||||
// If min and max are the same, show single value
|
||||
if (minMs === maxMs) {
|
||||
return `${minMs}ms`
|
||||
}
|
||||
return `${minMs}-${maxMs}ms`
|
||||
}
|
||||
})
|
||||
|
||||
const oldestResultTime = computed(() => {
|
||||
if (!props.endpoint.results || props.endpoint.results.length === 0) return ''
|
||||
return helper.methods.generatePrettyTimeAgo(props.endpoint.results[0].timestamp)
|
||||
})
|
||||
|
||||
const newestResultTime = computed(() => {
|
||||
if (!props.endpoint.results || props.endpoint.results.length === 0) return ''
|
||||
return helper.methods.generatePrettyTimeAgo(props.endpoint.results[props.endpoint.results.length - 1].timestamp)
|
||||
})
|
||||
|
||||
const navigateToDetails = () => {
|
||||
router.push(`/endpoints/${props.endpoint.key}`)
|
||||
}
|
||||
</script>
|
||||
@@ -1,99 +0,0 @@
|
||||
<template>
|
||||
<div :class="endpoints.length === 0 ? 'mt-3' : 'mt-4'">
|
||||
<slot v-if="name !== 'undefined'">
|
||||
<div class="endpoint-group pt-2 border dark:bg-gray-800 dark:border-gray-500" @click="toggleGroup">
|
||||
<h5 class="font-mono text-gray-400 text-xl font-medium pb-2 px-3 dark:text-gray-200 dark:hover:text-gray-500 dark:border-gray-500">
|
||||
<span class="endpoint-group-arrow mr-2">
|
||||
{{ collapsed ? '▼' : '▲' }}
|
||||
</span>
|
||||
{{ name }}
|
||||
<span v-if="unhealthyCount" class="rounded-xl bg-red-600 text-white px-2 font-bold leading-6 float-right h-6 text-center hover:scale-110 text-sm" title="Partial Outage">{{unhealthyCount}}</span>
|
||||
<span v-else class="float-right text-green-600 w-7 hover:scale-110" title="Operational">
|
||||
<CheckCircleIcon />
|
||||
</span>
|
||||
</h5>
|
||||
</div>
|
||||
</slot>
|
||||
<div v-if="!collapsed" :class="name === 'undefined' ? '' : 'endpoint-group-content'">
|
||||
<slot v-for="(endpoint, idx) in endpoints" :key="idx">
|
||||
<Endpoint
|
||||
:data="endpoint"
|
||||
:maximumNumberOfResults="20"
|
||||
@showTooltip="showTooltip"
|
||||
@toggleShowAverageResponseTime="toggleShowAverageResponseTime" :showAverageResponseTime="showAverageResponseTime"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
import Endpoint from './Endpoint.vue';
|
||||
import { CheckCircleIcon } from '@heroicons/vue/20/solid'
|
||||
|
||||
export default {
|
||||
name: 'EndpointGroup',
|
||||
components: {
|
||||
Endpoint,
|
||||
CheckCircleIcon
|
||||
},
|
||||
props: {
|
||||
name: String,
|
||||
endpoints: Array,
|
||||
showAverageResponseTime: Boolean
|
||||
},
|
||||
emits: ['showTooltip', 'toggleShowAverageResponseTime'],
|
||||
methods: {
|
||||
healthCheck() {
|
||||
let unhealthyCount = 0
|
||||
if (this.endpoints) {
|
||||
for (let i in this.endpoints) {
|
||||
if (this.endpoints[i].results && this.endpoints[i].results.length > 0) {
|
||||
if (!this.endpoints[i].results[this.endpoints[i].results.length-1].success) {
|
||||
unhealthyCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.unhealthyCount = unhealthyCount;
|
||||
},
|
||||
toggleGroup() {
|
||||
this.collapsed = !this.collapsed;
|
||||
localStorage.setItem(`gatus:endpoint-group:${this.name}:collapsed`, this.collapsed);
|
||||
},
|
||||
showTooltip(result, event) {
|
||||
this.$emit('showTooltip', result, event);
|
||||
},
|
||||
toggleShowAverageResponseTime() {
|
||||
this.$emit('toggleShowAverageResponseTime');
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
endpoints: function () {
|
||||
this.healthCheck();
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.healthCheck();
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
unhealthyCount: 0,
|
||||
collapsed: localStorage.getItem(`gatus:endpoint-group:${this.name}:collapsed`) === "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style>
|
||||
.endpoint-group {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.endpoint-group h5:hover {
|
||||
color: #1b1e21;
|
||||
}
|
||||
</style>
|
||||
@@ -1,74 +0,0 @@
|
||||
<template>
|
||||
<div id="results">
|
||||
<slot v-for="endpointGroup in endpointGroups" :key="endpointGroup">
|
||||
<EndpointGroup :endpoints="endpointGroup.endpoints" :name="endpointGroup.name" @showTooltip="showTooltip" @toggleShowAverageResponseTime="toggleShowAverageResponseTime" :showAverageResponseTime="showAverageResponseTime" />
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
import EndpointGroup from './EndpointGroup.vue';
|
||||
|
||||
export default {
|
||||
name: 'Endpoints',
|
||||
components: {
|
||||
EndpointGroup
|
||||
},
|
||||
props: {
|
||||
showStatusOnHover: Boolean,
|
||||
endpointStatuses: Object,
|
||||
showAverageResponseTime: Boolean
|
||||
},
|
||||
emits: ['showTooltip', 'toggleShowAverageResponseTime'],
|
||||
methods: {
|
||||
process() {
|
||||
let outputByGroup = {};
|
||||
for (let endpointStatusIndex in this.endpointStatuses) {
|
||||
let endpointStatus = this.endpointStatuses[endpointStatusIndex];
|
||||
// create an empty entry if this group is new
|
||||
if (!outputByGroup[endpointStatus.group] || outputByGroup[endpointStatus.group].length === 0) {
|
||||
outputByGroup[endpointStatus.group] = [];
|
||||
}
|
||||
outputByGroup[endpointStatus.group].push(endpointStatus);
|
||||
}
|
||||
let endpointGroups = [];
|
||||
for (let name in outputByGroup) {
|
||||
if (name !== 'undefined') {
|
||||
endpointGroups.push({name: name, endpoints: outputByGroup[name]})
|
||||
}
|
||||
}
|
||||
// Add all endpoints that don't have a group at the end
|
||||
if (outputByGroup['undefined']) {
|
||||
endpointGroups.push({name: 'undefined', endpoints: outputByGroup['undefined']})
|
||||
}
|
||||
this.endpointGroups = endpointGroups;
|
||||
},
|
||||
showTooltip(result, event) {
|
||||
this.$emit('showTooltip', result, event);
|
||||
},
|
||||
toggleShowAverageResponseTime() {
|
||||
this.$emit('toggleShowAverageResponseTime');
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
endpointStatuses: function () {
|
||||
this.process();
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
userClickedStatus: false,
|
||||
endpointGroups: []
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style>
|
||||
.endpoint-group-content > div:nth-child(1) {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,35 @@
|
||||
<template>
|
||||
<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: {
|
||||
const props = defineProps({
|
||||
numberOfResultsPerPage: Number,
|
||||
},
|
||||
components: {},
|
||||
emits: ['page'],
|
||||
methods: {
|
||||
nextPage() {
|
||||
this.currentPage++;
|
||||
this.$emit('page', this.currentPage);
|
||||
},
|
||||
previousPage() {
|
||||
this.currentPage--;
|
||||
this.$emit('page', this.currentPage);
|
||||
currentPageProp: {
|
||||
type: Number,
|
||||
default: 1
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
maxPages() {
|
||||
return Math.ceil(parseInt(window.config.maximumNumberOfResults) / this.numberOfResultsPerPage)
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentPage: 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>
|
||||
100
web/app/src/components/SearchBar.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="flex flex-col lg:flex-row gap-3 lg:gap-4 p-3 sm:p-4 bg-card rounded-lg border">
|
||||
<div class="flex-1">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<label for="search-input" class="sr-only">Search endpoints</label>
|
||||
<Input
|
||||
id="search-input"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search endpoints..."
|
||||
class="pl-10 text-sm sm:text-base"
|
||||
@input="$emit('search', searchQuery)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4">
|
||||
<div class="flex items-center gap-2 flex-1 sm:flex-initial">
|
||||
<label class="text-xs sm:text-sm font-medium text-muted-foreground whitespace-nowrap">Filter by:</label>
|
||||
<Select
|
||||
v-model="filterBy"
|
||||
:options="filterOptions"
|
||||
placeholder="Nothing"
|
||||
class="flex-1 sm:w-[140px] md:w-[160px]"
|
||||
@update:model-value="handleFilterChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-1 sm:flex-initial">
|
||||
<label class="text-xs sm:text-sm font-medium text-muted-foreground whitespace-nowrap">Sort by:</label>
|
||||
<Select
|
||||
v-model="sortBy"
|
||||
:options="sortOptions"
|
||||
placeholder="Name"
|
||||
class="flex-1 sm:w-[90px] md:w-[100px]"
|
||||
@update:model-value="handleSortChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Search } from 'lucide-vue-next'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
|
||||
const searchQuery = ref('')
|
||||
const filterBy = ref(localStorage.getItem('gatus:filter-by') || (typeof window !== 'undefined' && window.config?.defaultFilterBy) || 'nothing')
|
||||
const sortBy = ref(localStorage.getItem('gatus:sort-by') || (typeof window !== 'undefined' && window.config?.defaultSortBy) || 'name')
|
||||
|
||||
const filterOptions = [
|
||||
{ label: 'Nothing', value: 'nothing' },
|
||||
{ label: 'Failing', value: 'failing' },
|
||||
{ label: 'Unstable', value: 'unstable' }
|
||||
]
|
||||
|
||||
const sortOptions = [
|
||||
{ label: 'Name', value: 'name' },
|
||||
{ label: 'Group', value: 'group' },
|
||||
{ label: 'Health', value: 'health' }
|
||||
]
|
||||
|
||||
const emit = defineEmits(['search', 'update:showOnlyFailing', 'update:showRecentFailures', 'update:groupByGroup', 'update:sortBy', 'initializeCollapsedGroups'])
|
||||
|
||||
const handleFilterChange = (value) => {
|
||||
filterBy.value = value
|
||||
localStorage.setItem('gatus:filter-by', value)
|
||||
|
||||
// Reset all filter states first
|
||||
emit('update:showOnlyFailing', false)
|
||||
emit('update:showRecentFailures', false)
|
||||
|
||||
// Apply the selected filter
|
||||
if (value === 'failing') {
|
||||
emit('update:showOnlyFailing', true)
|
||||
} else if (value === 'unstable') {
|
||||
emit('update:showRecentFailures', true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSortChange = (value) => {
|
||||
sortBy.value = value
|
||||
localStorage.setItem('gatus:sort-by', value)
|
||||
emit('update:sortBy', value)
|
||||
emit('update:groupByGroup', value === 'group')
|
||||
|
||||
// When switching to group view, initialize collapsed groups
|
||||
if (value === 'group') {
|
||||
emit('initializeCollapsedGroups')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Apply saved filter/sort state on load
|
||||
handleFilterChange(filterBy.value)
|
||||
handleSortChange(sortBy.value)
|
||||
})
|
||||
</script>
|
||||
@@ -1,104 +1,190 @@
|
||||
<template>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</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;
|
||||
|
||||
58
web/app/src/components/StatusBadge.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<Badge :variant="variant" class="flex items-center gap-1">
|
||||
<span :class="['w-2 h-2 rounded-full', dotClass]"></span>
|
||||
{{ label }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (value) => ['healthy', 'unhealthy', 'degraded', 'unknown'].includes(value)
|
||||
}
|
||||
})
|
||||
|
||||
const variant = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'healthy':
|
||||
return 'success'
|
||||
case 'unhealthy':
|
||||
return 'destructive'
|
||||
case 'degraded':
|
||||
return 'warning'
|
||||
default:
|
||||
return 'secondary'
|
||||
}
|
||||
})
|
||||
|
||||
const label = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'healthy':
|
||||
return 'Healthy'
|
||||
case 'unhealthy':
|
||||
return 'Unhealthy'
|
||||
case 'degraded':
|
||||
return 'Degraded'
|
||||
default:
|
||||
return 'Unknown'
|
||||
}
|
||||
})
|
||||
|
||||
const dotClass = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'healthy':
|
||||
return 'bg-green-400'
|
||||
case 'unhealthy':
|
||||
return 'bg-red-400'
|
||||
case 'degraded':
|
||||
return 'bg-yellow-400'
|
||||
default:
|
||||
return 'bg-gray-400'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,130 +1,158 @@
|
||||
<template>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
</slot>
|
||||
</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";
|
||||
const props = defineProps({
|
||||
event: {
|
||||
type: [Event, Object],
|
||||
default: null
|
||||
},
|
||||
result: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
export default {
|
||||
name: 'Endpoints',
|
||||
props: {
|
||||
event: Event,
|
||||
result: Object
|
||||
},
|
||||
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;
|
||||
// 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>
|
||||
|
||||
37
web/app/src/components/ui/badge/Badge.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div :class="combineClasses(badgeVariants({ variant }), $attrs.class ?? '')">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
import { cva } from 'class-variance-authority'
|
||||
import { combineClasses } from '@/lib/utils'
|
||||
|
||||
defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
})
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
success: 'border-transparent bg-green-500 text-white',
|
||||
warning: 'border-transparent bg-yellow-500 text-white',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
</script>
|
||||
1
web/app/src/components/ui/badge/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Badge } from './Badge.vue'
|
||||
55
web/app/src/components/ui/button/Button.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<button
|
||||
:class="combineClasses(buttonVariants({ variant, size }), $attrs.class ?? '')"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
import { cva } from 'class-variance-authority'
|
||||
import { combineClasses } from '@/lib/utils'
|
||||
|
||||
defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
</script>
|
||||
1
web/app/src/components/ui/button/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Button } from './Button.vue'
|
||||
9
web/app/src/components/ui/card/Card.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div :class="combineClasses('rounded-lg border bg-card text-card-foreground shadow-sm', $attrs.class ?? '')">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { combineClasses } from '@/lib/utils'
|
||||
</script>
|
||||
9
web/app/src/components/ui/card/CardContent.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div :class="combineClasses('p-6 pt-0', $attrs.class ?? '')">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { combineClasses } from '@/lib/utils'
|
||||
</script>
|
||||
9
web/app/src/components/ui/card/CardHeader.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div :class="combineClasses('flex flex-col space-y-1.5 p-6', $attrs.class ?? '')">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { combineClasses } from '@/lib/utils'
|
||||
</script>
|
||||
9
web/app/src/components/ui/card/CardTitle.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<h3 :class="combineClasses('text-2xl font-semibold leading-none tracking-tight', $attrs.class ?? '')">
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { combineClasses } from '@/lib/utils'
|
||||
</script>
|
||||
4
web/app/src/components/ui/card/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as Card } from './Card.vue'
|
||||
export { default as CardHeader } from './CardHeader.vue'
|
||||
export { default as CardTitle } from './CardTitle.vue'
|
||||
export { default as CardContent } from './CardContent.vue'
|
||||
24
web/app/src/components/ui/input/Input.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<input
|
||||
:class="combineClasses(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
$attrs.class ?? ''
|
||||
)"
|
||||
:value="modelValue"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
import { combineClasses } from '@/lib/utils'
|
||||
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
</script>
|
||||
1
web/app/src/components/ui/input/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Input } from './Input.vue'
|
||||
127
web/app/src/components/ui/select/Select.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div ref="selectRef" class="relative" :class="props.class">
|
||||
<button
|
||||
@click="toggleDropdown"
|
||||
@keydown="handleKeyDown"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-haspopup="true"
|
||||
:aria-label="selectedOption.label || props.placeholder"
|
||||
class="flex h-9 sm:h-10 w-full items-center justify-between rounded-md border border-input bg-background px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span class="truncate">{{ selectedOption.label }}</span>
|
||||
<ChevronDown class="h-3 w-3 sm:h-4 sm:w-4 opacity-50 flex-shrink-0 ml-1" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="isOpen"
|
||||
role="listbox"
|
||||
class="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95"
|
||||
>
|
||||
<div class="p-1">
|
||||
<div
|
||||
v-for="(option, index) in options"
|
||||
:key="option.value"
|
||||
@click="selectOption(option)"
|
||||
:class="[
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-6 sm:pl-8 pr-2 text-xs sm:text-sm outline-none hover:bg-accent hover:text-accent-foreground',
|
||||
index === focusedIndex && 'bg-accent text-accent-foreground'
|
||||
]"
|
||||
role="option"
|
||||
:aria-selected="modelValue === option.value"
|
||||
>
|
||||
<span class="absolute left-1.5 sm:left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<Check v-if="modelValue === option.value" class="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</span>
|
||||
{{ option.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ChevronDown, Check } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
options: { type: Array, required: true },
|
||||
placeholder: { type: String, default: 'Select...' },
|
||||
class: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const isOpen = ref(false)
|
||||
const selectRef = ref(null)
|
||||
const focusedIndex = ref(-1)
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
return props.options.find(option => option.value === props.modelValue) || { label: props.placeholder, value: '' }
|
||||
})
|
||||
|
||||
const selectOption = (option) => {
|
||||
emit('update:modelValue', option.value)
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const toggleDropdown = () => {
|
||||
isOpen.value = !isOpen.value
|
||||
if (isOpen.value) {
|
||||
// Set initial focus to selected option or first option
|
||||
const selectedIdx = props.options.findIndex(opt => opt.value === props.modelValue)
|
||||
focusedIndex.value = selectedIdx >= 0 ? selectedIdx : 0
|
||||
} else {
|
||||
focusedIndex.value = -1
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (selectRef.value && !selectRef.value.contains(event.target)) {
|
||||
isOpen.value = false
|
||||
focusedIndex.value = -1
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (!isOpen.value) {
|
||||
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
toggleDropdown()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
focusedIndex.value = Math.min(focusedIndex.value + 1, props.options.length - 1)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
focusedIndex.value = Math.max(focusedIndex.value - 1, 0)
|
||||
break
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
event.preventDefault()
|
||||
if (focusedIndex.value >= 0 && focusedIndex.value < props.options.length) {
|
||||
selectOption(props.options[focusedIndex.value])
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
isOpen.value = false
|
||||
focusedIndex.value = -1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
1
web/app/src/components/ui/select/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Select } from './Select.vue'
|
||||
@@ -2,38 +2,78 @@
|
||||
@tailwind components;
|
||||
@tailwind 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;
|
||||
}
|
||||
}
|
||||
|
||||
6
web/app/src/lib/utils.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function combineClasses(...inputs) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -1,231 +1,399 @@
|
||||
<template>
|
||||
<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 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>
|
||||
<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"/>
|
||||
<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>
|
||||
<StatusBadge :status="currentHealthStatus" />
|
||||
</div>
|
||||
<div v-if="endpointStatus && endpointStatus.key && showResponseTimeChartAndBadges" class="mt-12">
|
||||
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
<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"/>
|
||||
</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>
|
||||
|
||||
<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">
|
||||
<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"/>
|
||||
<p class="font-medium">{{ event.fancyText }}</p>
|
||||
<p class="text-sm text-muted-foreground">{{ prettifyTimestamp(event.timestamp) }} • {{ event.fancyTimeAgo }}</p>
|
||||
</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"/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex items-center justify-center py-20">
|
||||
<Loading size="lg" />
|
||||
</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) }}
|
||||
|
||||
<Settings @refreshData="fetchData" />
|
||||
</div>
|
||||
<div class="flex-1 text-right">
|
||||
{{ event.fancyTimeAgo }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</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'
|
||||
|
||||
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++
|
||||
}
|
||||
}
|
||||
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'
|
||||
})
|
||||
|
||||
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 = [];
|
||||
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];
|
||||
let event = data.events[i]
|
||||
if (i === data.events.length - 1) {
|
||||
if (event.type === 'UNHEALTHY') {
|
||||
event.fancyText = 'Endpoint is unhealthy';
|
||||
event.fancyText = 'Endpoint is unhealthy'
|
||||
} else if (event.type === 'HEALTHY') {
|
||||
event.fancyText = 'Endpoint is healthy';
|
||||
event.fancyText = 'Endpoint is healthy'
|
||||
} else if (event.type === 'START') {
|
||||
event.fancyText = 'Monitoring started';
|
||||
event.fancyText = 'Monitoring started'
|
||||
}
|
||||
} else {
|
||||
let nextEvent = data.events[i + 1];
|
||||
let nextEvent = data.events[i + 1]
|
||||
if (event.type === 'HEALTHY') {
|
||||
event.fancyText = 'Endpoint became 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);
|
||||
event.fancyText = 'Endpoint was unhealthy for ' + helper.methods.generatePrettyTimeDifference(nextEvent.timestamp, event.timestamp)
|
||||
} else {
|
||||
event.fancyText = 'Endpoint became unhealthy';
|
||||
event.fancyText = 'Endpoint became unhealthy'
|
||||
}
|
||||
} else if (event.type === 'START') {
|
||||
event.fancyText = 'Monitoring started';
|
||||
event.fancyText = 'Monitoring started'
|
||||
}
|
||||
}
|
||||
event.fancyTimeAgo = this.generatePrettyTimeAgo(event.timestamp);
|
||||
events.push(event);
|
||||
event.fancyTimeAgo = helper.methods.generatePrettyTimeAgo(event.timestamp)
|
||||
processedEvents.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
|
||||
}
|
||||
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) {
|
||||
this.showResponseTimeChartAndBadges = true;
|
||||
break;
|
||||
showResponseTimeChartAndBadges.value = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
response.text().then(text => {
|
||||
console.log(`[Details][fetchData] Error: ${text}`);
|
||||
});
|
||||
console.error('[Details][fetchData] Error:', await response.text())
|
||||
}
|
||||
});
|
||||
},
|
||||
generateHealthBadgeImageURL() {
|
||||
return `${this.serverUrl}/api/v1/endpoints/${this.endpointStatus.key}/health/badge.svg`;
|
||||
},
|
||||
generateUptimeBadgeImageURL(duration) {
|
||||
return `${this.serverUrl}/api/v1/endpoints/${this.endpointStatus.key}/uptimes/${duration}/badge.svg`;
|
||||
},
|
||||
generateResponseTimeBadgeImageURL(duration) {
|
||||
return `${this.serverUrl}/api/v1/endpoints/${this.endpointStatus.key}/response-times/${duration}/badge.svg`;
|
||||
},
|
||||
generateResponseTimeChartImageURL(duration) {
|
||||
return `${this.serverUrl}/api/v1/endpoints/${this.endpointStatus.key}/response-times/${duration}/chart.svg`;
|
||||
},
|
||||
changePage(page) {
|
||||
this.currentPage = page;
|
||||
this.fetchData();
|
||||
},
|
||||
showTooltip(result, event) {
|
||||
this.$emit('showTooltip', result, event);
|
||||
},
|
||||
toggleShowAverageResponseTime() {
|
||||
this.showAverageResponseTime = !this.showAverageResponseTime;
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
endpointStatus: {},
|
||||
events: [],
|
||||
hourlyAverageResponseTime: {},
|
||||
selectedChartDuration: '24h',
|
||||
// Since this page isn't at the root, we need to modify the server URL a bit
|
||||
serverUrl: SERVER_URL === '.' ? '..' : SERVER_URL,
|
||||
currentPage: 1,
|
||||
showAverageResponseTime: true,
|
||||
showResponseTimeChartAndBadges: false,
|
||||
chartLabels: [],
|
||||
chartValues: [],
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.fetchData();
|
||||
} 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"
|
||||
<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"
|
||||
/>
|
||||
<Pagination v-show="retrievedData" @page="changePage" :numberOfResultsPerPage="20" />
|
||||
</slot>
|
||||
<Settings @refreshData="fetchData"/>
|
||||
</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'
|
||||
|
||||
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] = []
|
||||
}
|
||||
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'
|
||||
})
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
const data = await response.json()
|
||||
endpointStatuses.value = data
|
||||
} else {
|
||||
response.text().then(text => {
|
||||
console.log(`[Home][fetchData] Error: ${text}`);
|
||||
});
|
||||
console.error('[Home][fetchData] Error:', await response.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,
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.retrievedData = false; // Show loading only on page change or on initial load
|
||||
this.fetchData();
|
||||
} 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 +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>
|
||||