Reset
23
web/app/.gitignore
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
24
web/app/README.md
Normal file
@ -0,0 +1,24 @@
|
||||
# app
|
||||
|
||||
## Project setup
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compiles and hot-reloads for development
|
||||
```
|
||||
npm run serve
|
||||
```
|
||||
|
||||
### Compiles and minifies for production
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Lints and fixes files
|
||||
```
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### Customize configuration
|
||||
See [Configuration Reference](https://cli.vuejs.org/config/).
|
5
web/app/babel.config.js
Normal file
@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
}
|
20218
web/app/package-lock.json
generated
Normal file
50
web/app/package.json
Normal file
@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "gatus",
|
||||
"version": "4.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve --mode development",
|
||||
"build": "vue-cli-service build --modern --mode production",
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
"tailwindcss": "^3.1.8"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:vue/vue3-essential",
|
||||
"eslint:recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"parser": "babel-eslint"
|
||||
},
|
||||
"rules": {}
|
||||
},
|
||||
"browserslist": [
|
||||
"defaults",
|
||||
"> 1%",
|
||||
"last 2 versions",
|
||||
"not dead"
|
||||
]
|
||||
}
|
8
web/app/postcss.config.js
Normal file
@ -0,0 +1,8 @@
|
||||
const tailwindcss = require('tailwindcss');
|
||||
|
||||
module.exports = {
|
||||
plugins: [
|
||||
tailwindcss('./tailwind.config.js'),
|
||||
require('autoprefixer'),
|
||||
],
|
||||
};
|
BIN
web/app/public/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 7.5 KiB |
BIN
web/app/public/favicon-16x16.png
Normal file
After Width: | Height: | Size: 1.0 KiB |
BIN
web/app/public/favicon-32x32.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
web/app/public/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
27
web/app/public/index.html
Normal file
@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="{{ .Theme }}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<script type="text/javascript">
|
||||
window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", link: "{{ .UI.Link }}", buttons: []};{{- 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.0" />
|
||||
<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" />
|
||||
</head>
|
||||
<body class="dark:bg-gray-900">
|
||||
<noscript><strong>Enable JavaScript to view this page.</strong></noscript>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
BIN
web/app/public/logo-192x192.png
Normal file
After Width: | Height: | Size: 8.5 KiB |
BIN
web/app/public/logo-512x512.png
Normal file
After Width: | Height: | Size: 26 KiB |
24
web/app/public/manifest.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"id": "gatus",
|
||||
"name": "Gatus",
|
||||
"short_name": "Gatus",
|
||||
"description": "Gatus is an advanced automated status page that lets you monitor your applications and configure alerts to notify you if there's an issue",
|
||||
"lang": "en",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"theme_color": "#f7f9fb",
|
||||
"background_color": "#f7f9fb",
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/logo-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/logo-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
106
web/app/src/App.vue
Normal file
@ -0,0 +1,106 @@
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<a :href="`${SERVER_URL}/oidc/login`" class="max-w-lg mx-auto w-full flex justify-center py-3 px-4 border border-green-800 rounded-md shadow-lg text-sm text-white bg-green-700 bg-gradient-to-r from-green-600 to-green-700 hover:from-green-700 hover:to-green-800">
|
||||
Login with OIDC
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tooltip :result="tooltip.result" :event="tooltip.event"/>
|
||||
<Social/>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
import Social from './components/Social.vue'
|
||||
import Tooltip from './components/Tooltip.vue';
|
||||
import {SERVER_URL} from "@/main";
|
||||
import Loading from "@/components/Loading";
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: {
|
||||
Loading,
|
||||
Social,
|
||||
Tooltip
|
||||
},
|
||||
methods: {
|
||||
fetchConfig() {
|
||||
fetch(`${SERVER_URL}/api/v1/config`, {credentials: 'include'})
|
||||
.then(response => {
|
||||
this.retrievedConfig = true;
|
||||
if (response.status === 200) {
|
||||
response.json().then(data => {
|
||||
this.config = data;
|
||||
})
|
||||
}
|
||||
});
|
||||
},
|
||||
showTooltip(result, event) {
|
||||
this.tooltip = {result: result, event: event};
|
||||
}
|
||||
},
|
||||
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();
|
||||
}
|
||||
}
|
||||
</script>
|
BIN
web/app/src/assets/logo.png
Normal file
After Width: | Height: | Size: 51 KiB |
1
web/app/src/assets/logo.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 89.76 89.75"><defs><style>.cls-1{fill:#3cad4b;}.cls-2{fill:#017400;}.cls-3{fill:#1e9025;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M33.67,65.35a23.35,23.35,0,0,1,.08-41,22.94,22.94,0,0,1,3.8-1.64A23,23,0,0,0,53.6,1C53,0,51,0,44.89,0c-9.08,0-9.21.17-8.81,3.22,1.07,8.12-9.42,12.5-14.45,6-1.94-2.52-2.1-2.52-8.68,4.16-6.22,6.3-6.33,6.28-3.77,8.25a8.09,8.09,0,0,1,2.56,9.53A8.15,8.15,0,0,1,3.08,36C0,35.63,0,35.73,0,45.2.08,53.81,0,54,3.3,53.63A8.06,8.06,0,0,1,9.76,67.52c-3,2.83-2.84,2.61,2.84,8.48,5.43,5.62,6.33,6.73,8.16,5.24L34,68A1.63,1.63,0,0,0,33.67,65.35Z"/><path class="cls-2" d="M85.43,36.13a8.11,8.11,0,0,1-5.27-14.21c2.85-2.5,2.82-2.37-3.55-8.75-4.31-4.31-5.71-5.75-6.87-5.4l-14,14a1.65,1.65,0,0,0,.36,2.61,23.35,23.35,0,0,1-.1,41,24.5,24.5,0,0,1-5.11,2c-8.54,2.28-14.73,9.63-14.73,18.47v1.27c.15,2.54,1.19,2.42,8.06,2.52,9.32.14,9.1.35,9.38-4.66a8.11,8.11,0,0,1,14-5.09c3,3.15,2.39,3.11,8.73-3.14,6.56-6.47,6.86-6.25,3.68-9.14a8.1,8.1,0,0,1,6.06-14.07c3.68.27,3.51.06,3.63-8.09C89.85,36.27,90,36.16,85.43,36.13Z"/><path class="cls-3" d="M41.11,59h8a.76.76,0,0,0,.77-.76V50.43a.76.76,0,0,1,.77-.76h7.84a.78.78,0,0,0,.77-.77V40.84a.77.77,0,0,0-.77-.76H50.7a.76.76,0,0,1-.77-.77V31.47a.76.76,0,0,0-.77-.77h-8a.76.76,0,0,0-.77.77v7.84a.76.76,0,0,1-.77.77H31.73a.77.77,0,0,0-.77.76V48.9a.78.78,0,0,0,.77.77h7.84a.76.76,0,0,1,.77.76v7.85A.76.76,0,0,0,41.11,59Z"/></g></g></svg>
|
After Width: | Height: | Size: 1.5 KiB |
186
web/app/src/components/Endpoint.vue
Normal file
@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div class='endpoint px-3 py-3 border-l border-r border-t rounded-none hover:bg-gray-100 dark:hover:bg-gray-700 dark:border-gray-500' v-if="data">
|
||||
<div class='flex flex-wrap mb-2'>
|
||||
<div class='w-3/4'>
|
||||
<router-link :to="generatePath()" class="font-bold hover:text-blue-800 hover:underline dark:hover:text-blue-400" title="View detailed endpoint health">
|
||||
{{ data.name }}
|
||||
</router-link>
|
||||
<span v-if="data.results && data.results.length && data.results[data.results.length - 1].hostname" class='text-gray-500 font-light'> | {{ data.results[data.results.length - 1].hostname }}</span>
|
||||
</div>
|
||||
<div class='w-1/4 text-right'>
|
||||
<span class='font-light overflow-x-hidden cursor-pointer select-none hover:text-gray-500' v-if="data.results && data.results.length" @click="toggleShowAverageResponseTime" :title="showAverageResponseTime ? 'Average response time' : 'Minimum and maximum response time'">
|
||||
<slot v-if="showAverageResponseTime">
|
||||
~{{ averageResponseTime }}ms
|
||||
</slot>
|
||||
<slot v-else>
|
||||
{{ (minResponseTime === maxResponseTime ? minResponseTime : (minResponseTime + '-' + maxResponseTime)) }}ms
|
||||
</slot>
|
||||
</span>
|
||||
<!-- <span class="text-sm font-bold cursor-pointer">-->
|
||||
<!-- ⋯-->
|
||||
<!-- </span>-->
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class='status-over-time flex flex-row'>
|
||||
<slot v-if="data.results && data.results.length">
|
||||
<slot v-if="data.results.length < maximumNumberOfResults">
|
||||
<span v-for="filler in maximumNumberOfResults - data.results.length" :key="filler" class="status rounded border border-dashed border-gray-400"> </span>
|
||||
</slot>
|
||||
<slot v-for="result in data.results" :key="result">
|
||||
<span v-if="result.success" class="status status-success rounded bg-success" @mouseenter="showTooltip(result, $event)" @mouseleave="showTooltip(null, $event)"></span>
|
||||
<span v-else class="status status-failure rounded bg-red-600" @mouseenter="showTooltip(result, $event)" @mouseleave="showTooltip(null, $event)"></span>
|
||||
</slot>
|
||||
</slot>
|
||||
<slot v-else>
|
||||
<span v-for="filler in maximumNumberOfResults" :key="filler" class="status rounded border border-dashed border-gray-400"> </span>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class='flex flex-wrap status-time-ago'>
|
||||
<slot v-if="data.results && data.results.length">
|
||||
<div class='w-1/2'>
|
||||
{{ generatePrettyTimeAgo(data.results[0].timestamp) }}
|
||||
</div>
|
||||
<div class='w-1/2 text-right'>
|
||||
{{ generatePrettyTimeAgo(data.results[data.results.length - 1].timestamp) }}
|
||||
</div>
|
||||
</slot>
|
||||
<slot v-else>
|
||||
<div class='w-1/2'>
|
||||
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
import {helper} from "@/mixins/helper";
|
||||
|
||||
export default {
|
||||
name: 'Endpoint',
|
||||
props: {
|
||||
maximumNumberOfResults: Number,
|
||||
data: Object,
|
||||
showAverageResponseTime: Boolean
|
||||
},
|
||||
emits: ['showTooltip', 'toggleShowAverageResponseTime'],
|
||||
mixins: [helper],
|
||||
methods: {
|
||||
updateMinAndMaxResponseTimes() {
|
||||
let minResponseTime = null;
|
||||
let maxResponseTime = null;
|
||||
let totalResponseTime = 0;
|
||||
for (let i in this.data.results) {
|
||||
const responseTime = parseInt((this.data.results[i].duration/1000000).toFixed(0));
|
||||
totalResponseTime += responseTime;
|
||||
if (minResponseTime == null || minResponseTime > responseTime) {
|
||||
minResponseTime = responseTime;
|
||||
}
|
||||
if (maxResponseTime == null || maxResponseTime < responseTime) {
|
||||
maxResponseTime = responseTime;
|
||||
}
|
||||
}
|
||||
if (this.minResponseTime !== minResponseTime) {
|
||||
this.minResponseTime = minResponseTime;
|
||||
}
|
||||
if (this.maxResponseTime !== maxResponseTime) {
|
||||
this.maxResponseTime = maxResponseTime;
|
||||
}
|
||||
if (this.data.results && this.data.results.length) {
|
||||
this.averageResponseTime = (totalResponseTime/this.data.results.length).toFixed(0);
|
||||
}
|
||||
},
|
||||
generatePath() {
|
||||
if (!this.data) {
|
||||
return '/';
|
||||
}
|
||||
return `/endpoints/${this.data.key}`;
|
||||
},
|
||||
showTooltip(result, event) {
|
||||
this.$emit('showTooltip', result, event);
|
||||
},
|
||||
toggleShowAverageResponseTime() {
|
||||
this.$emit('toggleShowAverageResponseTime');
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
data: function () {
|
||||
this.updateMinAndMaxResponseTimes();
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.updateMinAndMaxResponseTimes()
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
minResponseTime: 0,
|
||||
maxResponseTime: 0,
|
||||
averageResponseTime: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style>
|
||||
.endpoint:first-child {
|
||||
border-top-left-radius: 3px;
|
||||
border-top-right-radius: 3px;
|
||||
}
|
||||
|
||||
.endpoint:last-child {
|
||||
border-bottom-left-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
border-bottom-width: 3px;
|
||||
}
|
||||
|
||||
.status-over-time {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.status-over-time > span:not(:first-child) {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.status {
|
||||
cursor: pointer;
|
||||
transition: all 500ms ease-in-out;
|
||||
overflow-x: hidden;
|
||||
color: white;
|
||||
width: 5%;
|
||||
font-size: 75%;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status:hover {
|
||||
opacity: 0.7;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.status-time-ago {
|
||||
color: #6a737d;
|
||||
opacity: 0.5;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.status.status-success::after {
|
||||
content: "✓";
|
||||
}
|
||||
|
||||
.status.status-failure::after {
|
||||
content: "X";
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.status.status-success::after,
|
||||
.status.status-failure::after {
|
||||
content: " ";
|
||||
white-space: pre;
|
||||
}
|
||||
}
|
||||
</style>
|
99
web/app/src/components/EndpointGroup.vue
Normal file
@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div :class="endpoints.length === 0 ? 'mt-3' : 'mt-4'">
|
||||
<slot v-if="name !== 'undefined'">
|
||||
<div class="endpoint-group pt-2 border dark:bg-gray-800 dark:border-gray-500" @click="toggleGroup">
|
||||
<h5 class="font-mono text-gray-400 text-xl font-medium pb-2 px-3 dark:text-gray-200 dark:hover:text-gray-500 dark:border-gray-500">
|
||||
<span class="endpoint-group-arrow mr-2">
|
||||
{{ collapsed ? '▼' : '▲' }}
|
||||
</span>
|
||||
{{ name }}
|
||||
<span v-if="unhealthyCount" class="rounded-xl bg-red-600 text-white px-2 font-bold leading-6 float-right h-6 text-center hover:scale-110 text-sm" title="Partial Outage">{{unhealthyCount}}</span>
|
||||
<span v-else class="float-right text-green-600 w-7 hover:scale-110" title="Operational">
|
||||
<CheckCircleIcon />
|
||||
</span>
|
||||
</h5>
|
||||
</div>
|
||||
</slot>
|
||||
<div v-if="!collapsed" :class="name === 'undefined' ? '' : 'endpoint-group-content'">
|
||||
<slot v-for="(endpoint, idx) in endpoints" :key="idx">
|
||||
<Endpoint
|
||||
:data="endpoint"
|
||||
:maximumNumberOfResults="20"
|
||||
@showTooltip="showTooltip"
|
||||
@toggleShowAverageResponseTime="toggleShowAverageResponseTime" :showAverageResponseTime="showAverageResponseTime"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
import Endpoint from './Endpoint.vue';
|
||||
import { CheckCircleIcon } from '@heroicons/vue/20/solid'
|
||||
|
||||
export default {
|
||||
name: 'EndpointGroup',
|
||||
components: {
|
||||
Endpoint,
|
||||
CheckCircleIcon
|
||||
},
|
||||
props: {
|
||||
name: String,
|
||||
endpoints: Array,
|
||||
showAverageResponseTime: Boolean
|
||||
},
|
||||
emits: ['showTooltip', 'toggleShowAverageResponseTime'],
|
||||
methods: {
|
||||
healthCheck() {
|
||||
let unhealthyCount = 0
|
||||
if (this.endpoints) {
|
||||
for (let i in this.endpoints) {
|
||||
if (this.endpoints[i].results && this.endpoints[i].results.length > 0) {
|
||||
if (!this.endpoints[i].results[this.endpoints[i].results.length-1].success) {
|
||||
unhealthyCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.unhealthyCount = unhealthyCount;
|
||||
},
|
||||
toggleGroup() {
|
||||
this.collapsed = !this.collapsed;
|
||||
localStorage.setItem(`gatus:endpoint-group:${this.name}:collapsed`, this.collapsed);
|
||||
},
|
||||
showTooltip(result, event) {
|
||||
this.$emit('showTooltip', result, event);
|
||||
},
|
||||
toggleShowAverageResponseTime() {
|
||||
this.$emit('toggleShowAverageResponseTime');
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
endpoints: function () {
|
||||
this.healthCheck();
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.healthCheck();
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
unhealthyCount: 0,
|
||||
collapsed: localStorage.getItem(`gatus:endpoint-group:${this.name}:collapsed`) === "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style>
|
||||
.endpoint-group {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.endpoint-group h5:hover {
|
||||
color: #1b1e21;
|
||||
}
|
||||
</style>
|
74
web/app/src/components/Endpoints.vue
Normal file
@ -0,0 +1,74 @@
|
||||
<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>
|
11
web/app/src/components/Loading.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
|
||||
}
|
||||
</script>
|
34
web/app/src/components/Pagination.vue
Normal file
@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="mt-3 flex">
|
||||
<div class="flex-1">
|
||||
<button v-if="currentPage < 5" @click="nextPage" class="bg-gray-100 hover:bg-gray-200 text-gray-500 border border-gray-200 px-2 rounded font-mono dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500 dark:hover:bg-gray-600"><</button>
|
||||
</div>
|
||||
<div class="flex-1 text-right">
|
||||
<button v-if="currentPage > 1" @click="previousPage" class="bg-gray-100 hover:bg-gray-200 text-gray-500 border border-gray-200 px-2 rounded font-mono dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500 dark:hover:bg-gray-600">></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Pagination',
|
||||
components: {},
|
||||
emits: ['page'],
|
||||
methods: {
|
||||
nextPage() {
|
||||
this.currentPage++;
|
||||
this.$emit('page', this.currentPage);
|
||||
},
|
||||
previousPage() {
|
||||
this.currentPage--;
|
||||
this.$emit('page', this.currentPage);
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentPage: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
104
web/app/src/components/Settings.vue
Normal file
@ -0,0 +1,104 @@
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
import { MoonIcon, SunIcon } from '@heroicons/vue/20/solid'
|
||||
import { ArrowPathIcon } from '@heroicons/vue/24/solid'
|
||||
|
||||
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"));
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style>
|
||||
#settings {
|
||||
position: fixed;
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
}
|
||||
|
||||
#settings select:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
36
web/app/src/components/Social.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div id="social">
|
||||
<a href="https://github.com/TwiN/gatus" target="_blank" title="Gatus on GitHub">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16" class="hover:scale-110">
|
||||
<path fill="gray" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Social'
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
#social {
|
||||
position: fixed;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
padding: 5px;
|
||||
margin: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
#social img {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
#social img:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
130
web/app/src/components/Tooltip.vue
Normal file
@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div id="tooltip" ref="tooltip" :class="hidden ? 'invisible' : ''" :style="'top:' + top + 'px; left:' + left + 'px'">
|
||||
<slot v-if="result">
|
||||
<div class="tooltip-title">Timestamp:</div>
|
||||
<code id="tooltip-timestamp">{{ prettifyTimestamp(result.timestamp) }}</code>
|
||||
<div class="tooltip-title">Response time:</div>
|
||||
<code id="tooltip-response-time">{{ (result.duration / 1000000).toFixed(0) }}ms</code>
|
||||
<slot v-if="result.conditionResults && result.conditionResults.length">
|
||||
<div class="tooltip-title">Conditions:</div>
|
||||
<code id="tooltip-conditions">
|
||||
<slot v-for="conditionResult in result.conditionResults" :key="conditionResult">
|
||||
{{ conditionResult.success ? "✓" : "X" }} ~ {{ conditionResult.condition }}<br/>
|
||||
</slot>
|
||||
</code>
|
||||
</slot>
|
||||
<div id="tooltip-errors-container" v-if="result.errors && result.errors.length">
|
||||
<div class="tooltip-title">Errors:</div>
|
||||
<code id="tooltip-errors">
|
||||
<slot v-for="error in result.errors" :key="error">
|
||||
- {{ error }}<br/>
|
||||
</slot>
|
||||
</code>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
import {helper} from "@/mixins/helper";
|
||||
|
||||
export default {
|
||||
name: 'Endpoints',
|
||||
props: {
|
||||
event: Event,
|
||||
result: Object
|
||||
},
|
||||
mixins: [helper],
|
||||
methods: {
|
||||
htmlEntities(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
},
|
||||
reposition() {
|
||||
if (this.event && this.event.type) {
|
||||
if (this.event.type === 'mouseenter') {
|
||||
let targetTopPosition = this.event.target.getBoundingClientRect().y + 30;
|
||||
let targetLeftPosition = this.event.target.getBoundingClientRect().x;
|
||||
let tooltipBoundingClientRect = this.$refs.tooltip.getBoundingClientRect();
|
||||
if (targetLeftPosition + window.scrollX + tooltipBoundingClientRect.width + 50 > document.body.getBoundingClientRect().width) {
|
||||
targetLeftPosition = this.event.target.getBoundingClientRect().x - tooltipBoundingClientRect.width + this.event.target.getBoundingClientRect().width;
|
||||
if (targetLeftPosition < 0) {
|
||||
targetLeftPosition += -targetLeftPosition;
|
||||
}
|
||||
}
|
||||
if (targetTopPosition + window.scrollY + tooltipBoundingClientRect.height + 50 > document.body.getBoundingClientRect().height && targetTopPosition >= 0) {
|
||||
targetTopPosition = this.event.target.getBoundingClientRect().y - (tooltipBoundingClientRect.height + 10);
|
||||
if (targetTopPosition < 0) {
|
||||
targetTopPosition = this.event.target.getBoundingClientRect().y + 30;
|
||||
}
|
||||
}
|
||||
this.top = targetTopPosition;
|
||||
this.left = targetLeftPosition;
|
||||
} else if (this.event.type === 'mouseleave') {
|
||||
this.hidden = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
event: function (value) {
|
||||
if (value && value.type) {
|
||||
if (value.type === 'mouseenter') {
|
||||
this.hidden = false;
|
||||
} else if (value.type === 'mouseleave') {
|
||||
this.hidden = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
this.reposition();
|
||||
},
|
||||
created() {
|
||||
this.reposition();
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hidden: true,
|
||||
top: 0,
|
||||
left: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
</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>
|
39
web/app/src/index.css
Normal file
@ -0,0 +1,39 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
8
web/app/src/main.js
Normal file
@ -0,0 +1,8 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './index.css'
|
||||
import router from './router'
|
||||
|
||||
export const SERVER_URL = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:8080'
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
38
web/app/src/mixins/helper.js
Normal file
@ -0,0 +1,38 @@
|
||||
export const helper = {
|
||||
methods: {
|
||||
generatePrettyTimeAgo(t) {
|
||||
let differenceInMs = new Date().getTime() - new Date(t).getTime();
|
||||
if (differenceInMs < 500) {
|
||||
return "now";
|
||||
}
|
||||
if (differenceInMs > 3 * 86400000) { // If it was more than 3 days ago, we'll display the number of days ago
|
||||
let days = (differenceInMs / 86400000).toFixed(0);
|
||||
return days + " day" + (days !== "1" ? "s" : "") + " ago";
|
||||
}
|
||||
if (differenceInMs > 3600000) { // If it was more than 1h ago, display the number of hours ago
|
||||
let hours = (differenceInMs / 3600000).toFixed(0);
|
||||
return hours + " hour" + (hours !== "1" ? "s" : "") + " ago";
|
||||
}
|
||||
if (differenceInMs > 60000) {
|
||||
let minutes = (differenceInMs / 60000).toFixed(0);
|
||||
return minutes + " minute" + (minutes !== "1" ? "s" : "") + " ago";
|
||||
}
|
||||
let seconds = (differenceInMs / 1000).toFixed(0);
|
||||
return seconds + " second" + (seconds !== "1" ? "s" : "") + " ago";
|
||||
},
|
||||
generatePrettyTimeDifference(start, end) {
|
||||
let minutes = Math.ceil((new Date(start) - new Date(end)) / 1000 / 60);
|
||||
return minutes + (minutes === 1 ? ' minute' : ' minutes');
|
||||
},
|
||||
prettifyTimestamp(timestamp) {
|
||||
let date = new Date(timestamp);
|
||||
let YYYY = date.getFullYear();
|
||||
let MM = ((date.getMonth() + 1) < 10 ? "0" : "") + "" + (date.getMonth() + 1);
|
||||
let DD = ((date.getDate()) < 10 ? "0" : "") + "" + (date.getDate());
|
||||
let hh = ((date.getHours()) < 10 ? "0" : "") + "" + (date.getHours());
|
||||
let mm = ((date.getMinutes()) < 10 ? "0" : "") + "" + (date.getMinutes());
|
||||
let ss = ((date.getSeconds()) < 10 ? "0" : "") + "" + (date.getSeconds());
|
||||
return YYYY + "-" + MM + "-" + DD + " " + hh + ":" + mm + ":" + ss;
|
||||
},
|
||||
}
|
||||
}
|
23
web/app/src/router/index.js
Normal file
@ -0,0 +1,23 @@
|
||||
import {createRouter, createWebHistory} from 'vue-router'
|
||||
import Home from '@/views/Home'
|
||||
import Details from "@/views/Details";
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: Home
|
||||
},
|
||||
{
|
||||
path: '/endpoints/:key',
|
||||
name: 'Details',
|
||||
component: Details,
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(process.env.BASE_URL),
|
||||
routes
|
||||
});
|
||||
|
||||
export default router;
|
231
web/app/src/views/Details.vue
Normal file
@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<router-link to="../"
|
||||
class="absolute top-2 left-5 inline-block px-2 pb-0.5 text-sm text-black bg-gray-100 rounded hover:bg-gray-200 focus:outline-none border border-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500 dark:hover:bg-gray-600">
|
||||
←
|
||||
</router-link>
|
||||
<div>
|
||||
<slot v-if="endpointStatus">
|
||||
<h1 class="text-xl xl:text-3xl font-mono text-gray-400">RECENT CHECKS</h1>
|
||||
<hr class="mb-4"/>
|
||||
<Endpoint
|
||||
:data="endpointStatus"
|
||||
:maximumNumberOfResults="20"
|
||||
@showTooltip="showTooltip"
|
||||
@toggleShowAverageResponseTime="toggleShowAverageResponseTime"
|
||||
:showAverageResponseTime="showAverageResponseTime"
|
||||
/>
|
||||
<Pagination @page="changePage"/>
|
||||
</slot>
|
||||
<div v-if="endpointStatus && endpointStatus.key" class="mt-12">
|
||||
<h1 class="text-xl xl:text-3xl font-mono text-gray-400">UPTIME</h1>
|
||||
<hr/>
|
||||
<div class="flex space-x-4 text-center text-2xl mt-6 relative bottom-2 mb-10">
|
||||
<div class="flex-1">
|
||||
<h2 class="text-sm text-gray-400 mb-1">Last 30 days</h2>
|
||||
<img :src="generateUptimeBadgeImageURL('30d')" alt="30d uptime badge" class="mx-auto"/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-sm text-gray-400 mb-1">Last 7 days</h2>
|
||||
<img :src="generateUptimeBadgeImageURL('7d')" alt="7d uptime badge" class="mx-auto"/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-sm text-gray-400 mb-1">Last 24 hours</h2>
|
||||
<img :src="generateUptimeBadgeImageURL('24h')" alt="24h uptime badge" class="mx-auto"/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-sm text-gray-400 mb-1">Last hour</h2>
|
||||
<img :src="generateUptimeBadgeImageURL('1h')" alt="1h uptime badge" class="mx-auto"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="endpointStatus && endpointStatus.key && showResponseTimeChartAndBadges" class="mt-12">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl xl:text-3xl font-mono text-gray-400">RESPONSE TIME</h1>
|
||||
<select v-model="selectedChartDuration" class="text-sm bg-gray-400 text-white border border-gray-600 rounded-md px-3 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="24h">24 hours</option>
|
||||
<option value="7d">7 days</option>
|
||||
<option value="30d">30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
<img :src="generateResponseTimeChartImageURL(selectedChartDuration)" alt="response time chart" class="mt-6"/>
|
||||
<div class="flex space-x-4 text-center text-2xl mt-6 relative bottom-2 mb-10">
|
||||
<div class="flex-1">
|
||||
<h2 class="text-sm text-gray-400 mb-1">Last 30 days</h2>
|
||||
<img :src="generateResponseTimeBadgeImageURL('30d')" alt="7d response time badge" class="mx-auto mt-2"/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-sm text-gray-400 mb-1">Last 7 days</h2>
|
||||
<img :src="generateResponseTimeBadgeImageURL('7d')" alt="7d response time badge" class="mx-auto mt-2"/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-sm text-gray-400 mb-1">Last 24 hours</h2>
|
||||
<img :src="generateResponseTimeBadgeImageURL('24h')" alt="24h response time badge" class="mx-auto mt-2"/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-sm text-gray-400 mb-1">Last hour</h2>
|
||||
<img :src="generateResponseTimeBadgeImageURL('1h')" alt="1h response time badge" class="mx-auto mt-2"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="endpointStatus && endpointStatus.key">
|
||||
<h1 class="text-xl xl:text-3xl font-mono text-gray-400 mt-4">CURRENT HEALTH</h1>
|
||||
<hr />
|
||||
<div class="flex space-x-4 text-center text-2xl mt-6 relative bottom-2 mb-10">
|
||||
<div class="flex-1">
|
||||
<img :src="generateHealthBadgeImageURL()" alt="health badge" class="mx-auto"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="endpointStatus && endpointStatus.key">
|
||||
<h1 class="text-xl xl:text-3xl font-mono text-gray-400 mt-4">EVENTS</h1>
|
||||
<hr />
|
||||
<ul role="list" class="px-0 xl:px-24 divide-y divide-gray-200 dark:divide-gray-600">
|
||||
<li v-for="event in events" :key="event" class="p-3 my-4">
|
||||
<h2 class="text-sm sm:text-lg">
|
||||
<ArrowUpCircleIcon v-if="event.type === 'HEALTHY'" class="w-8 inline mr-2 text-green-600" />
|
||||
<ArrowDownCircleIcon v-else-if="event.type === 'UNHEALTHY'" class="w-8 inline mr-2 text-red-500" />
|
||||
<PlayCircleIcon v-else-if="event.type === 'START'" class="w-8 inline mr-2 text-gray-400 dark:text-gray-100" />
|
||||
{{ event.fancyText }}
|
||||
</h2>
|
||||
<div class="flex mt-1 text-xs sm:text-sm text-gray-400">
|
||||
<div class="flex-2 text-left pl-12">
|
||||
{{ prettifyTimestamp(event.timestamp) }}
|
||||
</div>
|
||||
<div class="flex-1 text-right">
|
||||
{{ event.fancyTimeAgo }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<Settings @refreshData="fetchData"/>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
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'
|
||||
|
||||
export default {
|
||||
name: 'Details',
|
||||
components: {
|
||||
Pagination,
|
||||
Endpoint,
|
||||
Settings,
|
||||
ArrowDownCircleIcon,
|
||||
ArrowUpCircleIcon,
|
||||
PlayCircleIcon
|
||||
},
|
||||
emits: ['showTooltip'],
|
||||
mixins: [helper],
|
||||
methods: {
|
||||
fetchData() {
|
||||
//console.log("[Details][fetchData] Fetching data");
|
||||
fetch(`${this.serverUrl}/api/v1/endpoints/${this.$route.params.key}/statuses?page=${this.currentPage}`, {credentials: 'include'})
|
||||
.then(response => {
|
||||
if (response.status === 200) {
|
||||
response.json().then(data => {
|
||||
if (JSON.stringify(this.endpointStatus) !== JSON.stringify(data)) {
|
||||
this.endpointStatus = data;
|
||||
let events = [];
|
||||
for (let i = data.events.length - 1; i >= 0; i--) {
|
||||
let event = data.events[i];
|
||||
if (i === data.events.length - 1) {
|
||||
if (event.type === 'UNHEALTHY') {
|
||||
event.fancyText = 'Endpoint is unhealthy';
|
||||
} else if (event.type === 'HEALTHY') {
|
||||
event.fancyText = 'Endpoint is healthy';
|
||||
} else if (event.type === 'START') {
|
||||
event.fancyText = 'Monitoring started';
|
||||
}
|
||||
} else {
|
||||
let nextEvent = data.events[i + 1];
|
||||
if (event.type === 'HEALTHY') {
|
||||
event.fancyText = 'Endpoint became healthy';
|
||||
} else if (event.type === 'UNHEALTHY') {
|
||||
if (nextEvent) {
|
||||
event.fancyText = 'Endpoint was unhealthy for ' + this.generatePrettyTimeDifference(nextEvent.timestamp, event.timestamp);
|
||||
} else {
|
||||
event.fancyText = 'Endpoint became unhealthy';
|
||||
}
|
||||
} else if (event.type === 'START') {
|
||||
event.fancyText = 'Monitoring started';
|
||||
}
|
||||
}
|
||||
event.fancyTimeAgo = this.generatePrettyTimeAgo(event.timestamp);
|
||||
events.push(event);
|
||||
}
|
||||
this.events = events;
|
||||
// Check if there's any non-0 response time data
|
||||
// If there isn't, it's likely an external endpoint, which means we should
|
||||
// hide the response time chart and badges
|
||||
for (let i = 0; i < data.results.length; i++) {
|
||||
if (data.results[i].duration > 0) {
|
||||
this.showResponseTimeChartAndBadges = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
response.text().then(text => {
|
||||
console.log(`[Details][fetchData] Error: ${text}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
generateHealthBadgeImageURL() {
|
||||
return `${this.serverUrl}/api/v1/endpoints/${this.endpointStatus.key}/health/badge.svg`;
|
||||
},
|
||||
generateUptimeBadgeImageURL(duration) {
|
||||
return `${this.serverUrl}/api/v1/endpoints/${this.endpointStatus.key}/uptimes/${duration}/badge.svg`;
|
||||
},
|
||||
generateResponseTimeBadgeImageURL(duration) {
|
||||
return `${this.serverUrl}/api/v1/endpoints/${this.endpointStatus.key}/response-times/${duration}/badge.svg`;
|
||||
},
|
||||
generateResponseTimeChartImageURL(duration) {
|
||||
return `${this.serverUrl}/api/v1/endpoints/${this.endpointStatus.key}/response-times/${duration}/chart.svg`;
|
||||
},
|
||||
changePage(page) {
|
||||
this.currentPage = page;
|
||||
this.fetchData();
|
||||
},
|
||||
showTooltip(result, event) {
|
||||
this.$emit('showTooltip', result, event);
|
||||
},
|
||||
toggleShowAverageResponseTime() {
|
||||
this.showAverageResponseTime = !this.showAverageResponseTime;
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
endpointStatus: {},
|
||||
events: [],
|
||||
hourlyAverageResponseTime: {},
|
||||
selectedChartDuration: '24h',
|
||||
// Since this page isn't at the root, we need to modify the server URL a bit
|
||||
serverUrl: SERVER_URL === '.' ? '..' : SERVER_URL,
|
||||
currentPage: 1,
|
||||
showAverageResponseTime: true,
|
||||
showResponseTimeChartAndBadges: false,
|
||||
chartLabels: [],
|
||||
chartValues: [],
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.fetchData();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.endpoint {
|
||||
border-radius: 3px;
|
||||
border-bottom-width: 3px;
|
||||
}
|
||||
</style>
|
76
web/app/src/views/Home.vue
Normal file
@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<Loading v-if="!retrievedData" class="h-64 w-64 px-4 my-24"/>
|
||||
<slot>
|
||||
<Endpoints
|
||||
v-show="retrievedData"
|
||||
:endpointStatuses="endpointStatuses"
|
||||
:showStatusOnHover="true"
|
||||
@showTooltip="showTooltip"
|
||||
@toggleShowAverageResponseTime="toggleShowAverageResponseTime"
|
||||
:showAverageResponseTime="showAverageResponseTime"
|
||||
/>
|
||||
<Pagination v-show="retrievedData" @page="changePage"/>
|
||||
</slot>
|
||||
<Settings @refreshData="fetchData"/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
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";
|
||||
|
||||
export default {
|
||||
name: 'Home',
|
||||
components: {
|
||||
Loading,
|
||||
Pagination,
|
||||
Endpoints,
|
||||
Settings,
|
||||
},
|
||||
emits: ['showTooltip', 'toggleShowAverageResponseTime'],
|
||||
methods: {
|
||||
fetchData() {
|
||||
fetch(`${SERVER_URL}/api/v1/endpoints/statuses?page=${this.currentPage}`, {credentials: 'include'})
|
||||
.then(response => {
|
||||
this.retrievedData = true;
|
||||
if (response.status === 200) {
|
||||
response.json().then(data => {
|
||||
if (JSON.stringify(this.endpointStatuses) !== JSON.stringify(data)) {
|
||||
this.endpointStatuses = data;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
response.text().then(text => {
|
||||
console.log(`[Home][fetchData] Error: ${text}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
changePage(page) {
|
||||
this.retrievedData = false; // Show loading only on page change or on initial load
|
||||
this.currentPage = page;
|
||||
this.fetchData();
|
||||
},
|
||||
showTooltip(result, event) {
|
||||
this.$emit('showTooltip', result, event);
|
||||
},
|
||||
toggleShowAverageResponseTime() {
|
||||
this.showAverageResponseTime = !this.showAverageResponseTime;
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
endpointStatuses: [],
|
||||
currentPage: 1,
|
||||
showAverageResponseTime: true,
|
||||
retrievedData: false,
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.retrievedData = false; // Show loading only on page change or on initial load
|
||||
this.fetchData();
|
||||
}
|
||||
}
|
||||
</script>
|
20
web/app/tailwind.config.js
Normal file
@ -0,0 +1,20 @@
|
||||
module.exports = {
|
||||
content: [
|
||||
'./public/index.html',
|
||||
'./src/**/*.{vue,js,ts,jsx,tsx}'
|
||||
],
|
||||
darkMode: 'class', // or 'media' or 'class'
|
||||
theme: {
|
||||
fontFamily: {
|
||||
'mono': ['Consolas', 'Monaco', '"Courier New"', 'monospace']
|
||||
},
|
||||
extend: {},
|
||||
},
|
||||
variants: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
future: {
|
||||
hoverOnlyWhenSupported: true,
|
||||
},
|
||||
}
|
6
web/app/vue.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
filenameHashing: false,
|
||||
productionSourceMap: false,
|
||||
outputDir: '../static',
|
||||
publicPath: '/'
|
||||
}
|