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

* feat(ui): New status page UI

* docs: Rename labels to extra-labels

* Fix domain expiration test

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

* Change ui.header default value to Gatus

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

* Fix flaky metrics test

* Add subtle green color to "Gatus"

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

* Fix collapsed group user preferences

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -32,7 +32,7 @@ For more details, see [Usage](#usage)
> ❤ Like this project? Please consider [sponsoring me](https://github.com/sponsors/TwiN).
![Gatus dashboard](.github/assets/dashboard-dark.png)
![Gatus dashboard](.github/assets/dashboard-dark.jpg)
Have any feedback or questions? [Create a discussion](https://github.com/TwiN/gatus/discussions/new).
@@ -162,7 +162,7 @@ The main features of Gatus are:
- **[Badges](#badges)**: ![Uptime 7d](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/7d/badge.svg) ![Response time 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/24h/badge.svg)
- **Dark mode**
![Gatus dashboard conditions](.github/assets/dashboard-conditions.png)
![Gatus dashboard conditions](.github/assets/dashboard-conditions.jpg)
## Usage
@@ -201,7 +201,7 @@ endpoints:
This example would look similar to this:
![Simple example](.github/assets/example.png)
![Simple example](.github/assets/example.jpg)
By default, the configuration file is expected to be at `config/config.yaml`.
@@ -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:
![Gatus Endpoint Groups](.github/assets/endpoint-groups.png)
![Gatus Endpoint Groups](.github/assets/endpoint-groups.jpg)
### Exposing Gatus on a custom path

View File

@@ -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))]
}

View File

@@ -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")

View File

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

View File

@@ -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
}

View File

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

View File

@@ -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)
}
})
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,35 @@
<template>
<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>

View File

@@ -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">&lt;</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">&gt;</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>

View File

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

View File

@@ -1,104 +1,190 @@
<template>
<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>

View File

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

View File

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

View File

@@ -1,130 +1,158 @@
<template>
<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 ? "&#10003;" : "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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
},
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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,38 +2,78 @@
@tailwind components;
@tailwind 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
View File

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

View File

@@ -1,231 +1,399 @@
<template>
<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">
&larr;
</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>

View File

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

View File

@@ -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: {},

View File

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

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long