mirror of
https://github.com/TwiN/gatus.git
synced 2024-11-21 15:33:17 +01:00
Start working on migrating frontend to Vue 3
This commit is contained in:
parent
f1aa5191bf
commit
dc6cb8fc1d
23
web/app/.gitignore
vendored
Normal file
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
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
5
web/app/babel.config.js
Normal file
@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
}
|
27924
web/app/package-lock.json
generated
Normal file
27924
web/app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
web/app/package.json
Normal file
46
web/app/package.json
Normal file
@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/postcss7-compat": "^2.0.2",
|
||||
"autoprefixer": "^9.8.6",
|
||||
"core-js": "^3.6.5",
|
||||
"postcss": "^7.0.35",
|
||||
"tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.0.2",
|
||||
"vue": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "~4.5.0",
|
||||
"@vue/cli-plugin-eslint": "~4.5.0",
|
||||
"@vue/cli-service": "~4.5.0",
|
||||
"@vue/compiler-sfc": "^3.0.0",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"eslint": "^6.7.2",
|
||||
"eslint-plugin-vue": "^7.0.0-0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:vue/vue3-essential",
|
||||
"eslint:recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"parser": "babel-eslint"
|
||||
},
|
||||
"rules": {}
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions",
|
||||
"not dead"
|
||||
]
|
||||
}
|
5
web/app/postcss.config.js
Normal file
5
web/app/postcss.config.js
Normal file
@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: [
|
||||
require('tailwindcss')
|
||||
],
|
||||
};
|
BIN
web/app/public/favicon.ico
Normal file
BIN
web/app/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
17
web/app/public/index.html
Normal file
17
web/app/public/index.html
Normal file
@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||
<title>Health Dashboard | Gatus</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
52
web/app/src/App.vue
Normal file
52
web/app/src/App.vue
Normal file
@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<Services :serviceStatuses="serviceStatuses" :maximumNumberOfResults="20" :showStatusOnHover="true" />
|
||||
<Social />
|
||||
<Settings @refreshStatuses="fetchStatuses" />
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
import Social from './components/Social.vue'
|
||||
import Settings from './components/Settings.vue'
|
||||
import Services from './components/Services.vue';
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: {
|
||||
Services,
|
||||
Social,
|
||||
Settings
|
||||
},
|
||||
methods: {
|
||||
fetchStatuses() {
|
||||
console.log("[App][fetchStatuses] Fetching statuses");
|
||||
fetch("http://localhost:8080/api/v1/statuses")
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (JSON.stringify(this.serviceStatuses) !== JSON.stringify(data)) {
|
||||
console.log(data);
|
||||
this.serviceStatuses = data;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
serviceStatuses: {}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.fetchStatuses();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style>
|
||||
html, body {
|
||||
background-color: #f7f9fb;
|
||||
}
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
BIN
web/app/src/assets/github.png
Normal file
BIN
web/app/src/assets/github.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
BIN
web/app/src/assets/logo.png
Normal file
BIN
web/app/src/assets/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 51 KiB |
128
web/app/src/components/Service.vue
Normal file
128
web/app/src/components/Service.vue
Normal file
@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div class='container px-3 py-3 border-l border-r border-t rounded-none'>
|
||||
<div class='flex flex-wrap mb-2'>
|
||||
<div class='w-3/4'>
|
||||
<span class='font-bold'>{{ data.name }}</span> <span 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 status-min-max-ms'>
|
||||
{{ (minResponseTime === maxResponseTime ? minResponseTime : (minResponseTime + "-" + maxResponseTime)) }}ms
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class='status-over-time flex flex-row'>
|
||||
<slot v-for="filler in 20 - data.results.length" :key="filler">
|
||||
<span class="status rounded border border-dashed"> </span>
|
||||
</slot>
|
||||
<slot v-for="result in data.results" :key="result">
|
||||
<span v-if="result.success" class="status rounded bg-success">✓</span>
|
||||
<span v-else class="status rounded bg-red-600">X</span>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class='flex flex-wrap status-time-ago'>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Service',
|
||||
props: {
|
||||
data: Object
|
||||
},
|
||||
methods: {
|
||||
updateMinAndMaxResponseTimes() {
|
||||
let minResponseTime = null;
|
||||
let maxResponseTime = null;
|
||||
for (let i in this.data.results) {
|
||||
const responseTime = parseInt(this.data.results[i].duration/1000000);
|
||||
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;
|
||||
}
|
||||
},
|
||||
generatePrettyTimeAgo(t) {
|
||||
let differenceInMs = new Date().getTime() - new Date(t).getTime();
|
||||
if (differenceInMs > 3600000) {
|
||||
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";
|
||||
}
|
||||
return (differenceInMs/1000).toFixed(0) + " seconds ago";
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
data: function () {
|
||||
this.updateMinAndMaxResponseTimes();
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.updateMinAndMaxResponseTimes()
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
minResponseTime: 0,
|
||||
maxResponseTime: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style>
|
||||
.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-over-time {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.status-over-time > span:not(:first-child) {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.status-time-ago {
|
||||
color: #6a737d;
|
||||
opacity: 0.5;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.status-min-max-ms {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
</style>
|
80
web/app/src/components/ServiceGroup.vue
Normal file
80
web/app/src/components/ServiceGroup.vue
Normal file
@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div :class="services.length === 0 ? 'mt-3' : 'mt-4'">
|
||||
<slot v-if="name !== 'undefined'">
|
||||
<div class="service-group container pt-2 border">
|
||||
<h5 class='text-monospace text-gray-400 text-xl font-medium pb-2 px-3'>
|
||||
<span v-if="healthy" class='text-green-600'>✓</span>
|
||||
<span v-else class='text-yellow-400'>~</span>
|
||||
{{ name }}
|
||||
</h5>
|
||||
</div>
|
||||
</slot>
|
||||
<div :class="name === 'undefined' ? '' : 'service-group-content'">
|
||||
<slot v-for="service in services" :key="service">
|
||||
<Service :data="service"/>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
import Service from './Service.vue';
|
||||
|
||||
export default {
|
||||
name: 'ServiceGroup',
|
||||
components: {
|
||||
Service
|
||||
},
|
||||
props: {
|
||||
name: String,
|
||||
services: Array
|
||||
},
|
||||
methods: {
|
||||
healthCheck() {
|
||||
if (this.services) {
|
||||
for (let i in this.services) {
|
||||
for (let j in this.services[i].results) {
|
||||
if (!this.services[i].results[j].success) {
|
||||
// Set the service group to unhealthy (only if it's currently healthy)
|
||||
if (this.healthy) {
|
||||
this.healthy = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Set the service group to healthy (only if it's currently unhealthy)
|
||||
if (!this.healthy) {
|
||||
this.healthy = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
services: function () {
|
||||
this.healthCheck();
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.healthCheck();
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
healthy: true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style>
|
||||
.service-group {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.service-group h5:hover {
|
||||
color: #1b1e21 !important;
|
||||
}
|
||||
</style>
|
100
web/app/src/components/Services.vue
Normal file
100
web/app/src/components/Services.vue
Normal file
@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="container mx-auto rounded shadow-xl border my-3 p-5 text-left" id="global">
|
||||
<div class="mb-2">
|
||||
<div class="flex flex-wrap">
|
||||
<div class="w-2/3 text-left my-auto">
|
||||
<div class="title font-light">Health Status</div>
|
||||
</div>
|
||||
<div class="w-1/3 flex justify-end">
|
||||
<img src="../assets/logo.png" alt="Gatus" style="min-width: 50px; max-width: 200px; width: 20%;"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="results">
|
||||
<slot v-for="serviceGroup in serviceGroups" :key="serviceGroup">
|
||||
<ServiceGroup :services="serviceGroup.services" :name="serviceGroup.name" />
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
import ServiceGroup from './ServiceGroup.vue';
|
||||
|
||||
export default {
|
||||
name: 'Services',
|
||||
components: {
|
||||
ServiceGroup
|
||||
},
|
||||
props: {
|
||||
maximumNumberOfResults: Number,
|
||||
showStatusOnHover: Boolean,
|
||||
serviceStatuses: Object
|
||||
},
|
||||
methods: {
|
||||
process() {
|
||||
let outputByGroup = {};
|
||||
for (let serviceStatusIndex in this.serviceStatuses) {
|
||||
let serviceStatus = this.serviceStatuses[serviceStatusIndex];
|
||||
// create an empty entry if this group is new
|
||||
if (!outputByGroup[serviceStatus.group] || outputByGroup[serviceStatus.group].length === 0) {
|
||||
outputByGroup[serviceStatus.group] = [];
|
||||
}
|
||||
outputByGroup[serviceStatus.group].push(serviceStatus);
|
||||
}
|
||||
let serviceGroups = [];
|
||||
for (let name in outputByGroup) {
|
||||
if (name !== 'undefined') {
|
||||
serviceGroups.push({ name: name, services: outputByGroup[name]})
|
||||
}
|
||||
}
|
||||
// Add all services that don't have a group at the end
|
||||
if (outputByGroup['undefined']) {
|
||||
serviceGroups.push({name: 'undefined', services: outputByGroup['undefined']})
|
||||
}
|
||||
this.serviceGroups = serviceGroups;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
serviceStatuses: function () {
|
||||
this.process();
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
userClickedStatus: false,
|
||||
serviceGroups: []
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style>
|
||||
#global {
|
||||
max-width: 1140px;
|
||||
}
|
||||
|
||||
#results div.container:first-child {
|
||||
border-top-left-radius: 3px;
|
||||
border-top-right-radius: 3px;
|
||||
}
|
||||
|
||||
#results div.container:last-child {
|
||||
border-bottom-left-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
border-bottom-width: 1px;
|
||||
border-color: #dee2e6;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
#results .service-group-content > div:nth-child(1) {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
</style>
|
66
web/app/src/components/Settings.vue
Normal file
66
web/app/src/components/Settings.vue
Normal file
@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div id="settings">
|
||||
<div class="flex bg-gray-200 rounded border border-gray-300 shadow">
|
||||
<div class="text-sm text-gray-600 rounded-xl py-1 px-2">
|
||||
↻
|
||||
</div>
|
||||
<select class="text-center text-gray-500 text-sm" id="refresh-rate" ref="refreshInterval" @change="handleChangeRefreshInterval">
|
||||
<option value="10">10s</option>
|
||||
<option value="30" selected>30s</option>
|
||||
<option value="60">1m</option>
|
||||
<option value="120">2m</option>
|
||||
<option value="300">5m</option>
|
||||
<option value="600">10m</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Settings',
|
||||
props: {},
|
||||
methods: {
|
||||
setRefreshInterval(seconds) {
|
||||
let that = this;
|
||||
this.refreshIntervalHandler = setInterval(function() {
|
||||
that.refreshStatuses();
|
||||
}, seconds * 1000);
|
||||
},
|
||||
refreshStatuses() {
|
||||
this.$emit('refreshStatuses')
|
||||
},
|
||||
handleChangeRefreshInterval() {
|
||||
this.refreshStatuses();
|
||||
clearInterval(this.refreshIntervalHandler);
|
||||
this.setRefreshInterval(this.$refs.refreshInterval.value);
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.setRefreshInterval(this.refreshInterval);
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
refreshInterval: 30,
|
||||
refreshIntervalHandler: 0,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// props.refreshInterval = 30
|
||||
//$("#refresh-rate").val(30);
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
#settings {
|
||||
position: fixed;
|
||||
left: 5px;
|
||||
bottom: 5px;
|
||||
padding: 5px;
|
||||
}
|
||||
#settings select:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
32
web/app/src/components/Social.vue
Normal file
32
web/app/src/components/Social.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div id="social">
|
||||
<a href="https://github.com/TwinProduction/gatus" target="_blank" title="Gatus on GitHub">
|
||||
<img src="../assets/github.png" alt="GitHub" width="32" height="auto" />
|
||||
</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>
|
11
web/app/src/index.css
Normal file
11
web/app/src/index.css
Normal file
@ -0,0 +1,11 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
.bg-success {
|
||||
background-color: #28a745;
|
||||
}
|
||||
|
||||
.text-monospace {
|
||||
font-family: Consolas, monospace;
|
||||
}
|
5
web/app/src/main.js
Normal file
5
web/app/src/main.js
Normal file
@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './index.css'
|
||||
|
||||
createApp(App).mount('#app')
|
11
web/app/tailwind.config.js
Normal file
11
web/app/tailwind.config.js
Normal file
@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
purge: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
||||
darkMode: false, // or 'media' or 'class'
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
variants: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
Loading…
Reference in New Issue
Block a user