Initial build

This commit is contained in:
Oliver Traber 2022-01-15 02:22:37 +01:00
parent ba484d3d9a
commit 20686e39b6
Signed by: Bluemedia
GPG key ID: C7BA47275B086E2C
14 changed files with 714 additions and 6 deletions

View file

@ -1,3 +1,26 @@
# fancy-gatus
Fancy-Gatus is an alternative frontend for [Gatus](https://github.com/TwiN/gatus). The goal is to provide a simplified and modern status page based on data from a Gatus instance, which only displays the most important information in a way that is understandable for end users.
## Project setup
```
yarn install
```
### Compiles and hot-reloads for development
```
yarn serve
```
### Compiles and minifies for production
```
yarn build
```
### Lints and fixes files
```
yarn lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

View file

@ -8,6 +8,8 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"axios": "^0.24.0",
"bootstrap-icons-vue": "^0.8.0",
"core-js": "^3.6.5",
"vue": "^3.0.0"
},

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -5,11 +5,11 @@
<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><%= htmlWebpackPlugin.options.title %></title>
<title>Fancy 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>
<strong>We're sorry but this page doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->

View file

@ -1,21 +1,157 @@
<template>
<div class="main">
<div v-if="$data.loading" class="loader-wrapper">
<Loader />
</div>
<div v-if="!$data.loading" class="content-wrapper">
<Header :title="this.config.title" />
<OverallStatus class="overall-status" :failedEndpoints="failedEndpoints" />
<EndpointGroup class="endpoint-group" v-for="(value, key) in groups" :key="key" :name="key" :endpoints="value" />
</div>
<Footer />
</div>
</template>
<script>
import Loader from '@/components/Loader.vue';
import Header from '@/components/Header.vue';
import OverallStatus from '@/components/OverallStatus.vue';
import EndpointGroup from '@/components/EndpointGroup.vue';
import Footer from '@/components/Footer.vue';
import axios from 'axios';
export default {
name: 'App',
components: {
Loader,
Header,
OverallStatus,
EndpointGroup,
Footer
},
data() {
return {
loading: true,
config: null,
apiData: null
};
},
computed: {
// Group endpoints by their group name
groups() {
return this.apiData.reduce(function(rv, x) {
(rv[x["group"]] = rv[x["group"]] || []).push(x);
return rv;
}, {});
},
// Get an array of all failed endpoints
failedEndpoints() {
return this.apiData.filter(endpoint => {
return !endpoint.results[endpoint.results.length - 1].success;
});
}
},
methods: {
// Get frontend config and trigger initial data fetch
getConfig() {
axios.get('/config.json')
.then(response => {
this.config = response.data;
// Set title if defined in config
if (this.config.title) {
document.title = this.config.title;
}
this.getApiData();
})
.catch(error => {
console.log(error);
});
},
getApiData() {
// Set base URL for API calls if defined in config
if (this.config.gatusBaseUrl && axios.defaults.baseURL !== this.config.gatusBaseUrl) {
axios.defaults.baseURL = this.config.gatusBaseUrl;
}
axios.get('/api/v1/endpoints/statuses')
.then(response => {
// Remove hidden endpoints if defined in config
if (this.config.hiddenEndpoints) {
this.apiData = response.data.filter(endpoint => {
return !this.config.hiddenEndpoints.includes(endpoint.name);
});
} else {
this.apiData = response.data;
}
this.loading = false;
})
.catch(error => {
console.log(error);
});
}
},
mounted() {
this.getConfig();
}
}
</script>
<style scoped>
.main {
padding-left: 30px;
padding-right: 30px;
display: flex;
flex-direction: column;
align-items: center;
}
.loader-wrapper {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.content-wrapper {
min-width: 60%;
max-width: 90%;
margin-top: 1rem;
margin-bottom: 5rem;
display: flex;
flex-direction: column;
justify-content: center;
}
.overall-status {
margin-top: 2rem;
}
.endpoint-group {
margin-top: 2rem;
}
</style>
<style>
body,
html {
padding: 0;
margin: 0;
--green: #00d560;
--orange: #ff9100;
}
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
color: #2F3545;
padding: 0;
}
</style>
.shadow-box {
box-shadow: 0 15px 70px rgb(0 0 0 / 10%);
padding: 10px;
border-radius: 10px;
}
.green {
color: var(--green);
}
.orange {
color: var(--orange);
}
</style>

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -0,0 +1,61 @@
<template>
<div class="endpoint">
<BIconCheckCircleFill class="green icon" v-if="isSuccessfull" />
<BIconExclamationCircleFill class="orange icon" v-if="!isSuccessfull" />
<span>{{ endpoint.name }}</span>
<span class="hostname" v-if="hostname">| {{ hostname }}</span>
<span class="spacer"></span>
<EndpointHistory :results="endpoint.results" />
</div>
</template>
<script>
import { BIconExclamationCircleFill, BIconCheckCircleFill } from 'bootstrap-icons-vue';
import EndpointHistory from '@/components/EndpointHistory.vue';
export default {
name: 'Endpoint',
components: {
BIconExclamationCircleFill,
BIconCheckCircleFill,
EndpointHistory
},
props: {
endpoint: {
type: Object,
required: true
}
},
computed: {
isSuccessfull() {
return this.endpoint.results[this.endpoint.results.length - 1].success;
},
hostname() {
return this.endpoint.results[0].hostname;
}
}
}
</script>
<style scoped>
.endpoint {
display: flex;
align-items: center;
padding: 10px;
}
span {
margin: 0;
margin-left: 10px;
margin-top: 2px;
}
.hostname {
margin-left: 5px;
color: rgb(143, 143, 143);
}
.icon {
font-size: 20px;
}
.spacer {
flex: 1;
}
</style>

View file

@ -0,0 +1,41 @@
<template>
<div class="endpoint-group">
<h3>{{ name }}</h3>
<div class="shadow-box list">
<Endpoint class="endpoint" v-for="endpoint in endpoints" :key="endpoint.name" :endpoint="endpoint" />
</div>
</div>
</template>
<script>
import Endpoint from '@/components/Endpoint.vue';
export default {
name: 'EndpointGroup',
components: {
Endpoint
},
props: {
name: {
type: String,
required: true
},
endpoints: {
type: Array,
required: true
}
},
}
</script>
<style scoped>
.list {
display: flex;
flex-direction: column;
justify-items: space-between;
}
.endpoint {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
</style>

View file

@ -0,0 +1,54 @@
<template>
<div class="history">
<div v-for="(result, index) in preparedResults" :key="index" :title="result.timestamp" :class="{blob: true, green: result.success === true, orange: result.success === false, grey: !result.timestamp}"></div>
</div>
</template>
<script>
export default {
name: 'EndpointHistory',
props: {
results: {
type: Array,
required: true
}
},
computed: {
preparedResults() {
let tmp = [...this.results];
while (tmp.length < 20) {
tmp.unshift({});
}
return tmp;
}
}
}
</script>
<style scoped>
.history {
display: flex;
flex-direction: row;
justify-content: space-between;
width: 300px;
}
.blob {
width: 7px;
height: 20px;
margin: 0;
border-radius: 50rem;
--hover-scale: 1.5;
}
.blob:hover {
transform: scale(var(--hover-scale));
}
.green {
background-color: var(--green);
}
.orange {
background-color: var(--orange);
}
.grey {
background-color: grey;
}
</style>

25
src/components/Footer.vue Normal file
View file

@ -0,0 +1,25 @@
<template>
<footer>Powered by
<a target="_blank" href="https://github.com/TwiN/gatus">Gatus</a>
and
<a target="_blank" href="https://github.com/BluemediaGER/fancy-gatus">Fancy Gatus</a>
</footer>
</template>
<script>
export default {
name: 'Footer'
}
</script>
<style scoped>
footer {
margin-bottom: 1rem;
text-align: center;
font-size: 14px;
}
a {
color: #111;
text-decoration: underline;
}
</style>

33
src/components/Header.vue Normal file
View file

@ -0,0 +1,33 @@
<template>
<div class="header">
<img src="@/assets/logo.png" alt="Logo" class="logo">
<h2>{{ this.title }}</h2>
</div>
</template>
<script>
export default {
name: 'Header',
props: {
title: {
type: String,
default: 'Infrastructure Status'
},
},
}
</script>
<style scoped>
.header {
display: flex;
align-items: center;
}
.logo {
width: 60px;
height: auto;
}
h2 {
margin: 0;
margin-left: 20px;
}
</style>

256
src/components/Loader.vue Normal file
View file

@ -0,0 +1,256 @@
<template>
<div>
<div class="loader">
<svg viewBox="0 0 80 80">
<circle id="test" cx="40" cy="40" r="32"></circle>
</svg>
</div>
<div class="loader triangle">
<svg viewBox="0 0 86 80">
<polygon points="43 8 79 72 7 72"></polygon>
</svg>
</div>
<div class="loader">
<svg viewBox="0 0 80 80">
<rect x="8" y="8" width="64" height="64"></rect>
</svg>
</div>
</div>
</template>
<script>
export default {
name: "Loader"
}
</script>
<style scoped>
.loader {
--path: #2F3545;
--dot: #00d560;
--duration: 3s;
width: 44px;
height: 44px;
position: relative;
}
.loader:before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
position: absolute;
display: block;
background: var(--dot);
top: 37px;
left: 19px;
transform: translate(-18px, -18px);
-webkit-animation: dotRect var(--duration) cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite;
animation: dotRect var(--duration) cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite;
}
.loader svg {
display: block;
width: 100%;
height: 100%;
}
.loader svg rect,
.loader svg polygon,
.loader svg circle {
fill: none;
stroke: var(--path);
stroke-width: 10px;
stroke-linejoin: round;
stroke-linecap: round;
}
.loader svg polygon {
stroke-dasharray: 145 76 145 76;
stroke-dashoffset: 0;
-webkit-animation: pathTriangle var(--duration) cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite;
animation: pathTriangle var(--duration) cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite;
}
.loader svg rect {
stroke-dasharray: 192 64 192 64;
stroke-dashoffset: 0;
-webkit-animation: pathRect 3s cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite;
animation: pathRect 3s cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite;
}
.loader svg circle {
stroke-dasharray: 150 50 150 50;
stroke-dashoffset: 75;
-webkit-animation: pathCircle var(--duration) cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite;
animation: pathCircle var(--duration) cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite;
}
.loader.triangle {
width: 48px;
}
.loader.triangle:before {
left: 21px;
transform: translate(-10px, -18px);
-webkit-animation: dotTriangle var(--duration) cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite;
animation: dotTriangle var(--duration) cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite;
}
@-webkit-keyframes pathTriangle {
33% {
stroke-dashoffset: 74;
}
66% {
stroke-dashoffset: 147;
}
100% {
stroke-dashoffset: 221;
}
}
@keyframes pathTriangle {
33% {
stroke-dashoffset: 74;
}
66% {
stroke-dashoffset: 147;
}
100% {
stroke-dashoffset: 221;
}
}
@-webkit-keyframes dotTriangle {
33% {
transform: translate(0, 0);
}
66% {
transform: translate(10px, -18px);
}
100% {
transform: translate(-10px, -18px);
}
}
@keyframes dotTriangle {
33% {
transform: translate(0, 0);
}
66% {
transform: translate(10px, -18px);
}
100% {
transform: translate(-10px, -18px);
}
}
@-webkit-keyframes pathRect {
25% {
stroke-dashoffset: 64;
}
50% {
stroke-dashoffset: 128;
}
75% {
stroke-dashoffset: 192;
}
100% {
stroke-dashoffset: 256;
}
}
@keyframes pathRect {
25% {
stroke-dashoffset: 64;
}
50% {
stroke-dashoffset: 128;
}
75% {
stroke-dashoffset: 192;
}
100% {
stroke-dashoffset: 256;
}
}
@-webkit-keyframes dotRect {
25% {
transform: translate(0, 0);
}
50% {
transform: translate(18px, -18px);
}
75% {
transform: translate(0, -36px);
}
100% {
transform: translate(-18px, -18px);
}
}
@keyframes dotRect {
25% {
transform: translate(0, 0);
}
50% {
transform: translate(18px, -18px);
}
75% {
transform: translate(0, -36px);
}
100% {
transform: translate(-18px, -18px);
}
}
@-webkit-keyframes pathCircle {
25% {
stroke-dashoffset: 125;
}
50% {
stroke-dashoffset: 175;
}
75% {
stroke-dashoffset: 225;
}
100% {
stroke-dashoffset: 275;
}
}
@keyframes pathCircle {
25% {
stroke-dashoffset: 125;
}
50% {
stroke-dashoffset: 175;
}
75% {
stroke-dashoffset: 225;
}
100% {
stroke-dashoffset: 275;
}
}
.loader {
display: inline-block;
margin: 0 16px;
}
html {
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
}
*:before, *:after {
box-sizing: border-box;
}
body {
min-height: 100vh;
background: #F5F9FF;
display: flex;
justify-content: center;
align-items: center;
}
body .dribbble {
position: fixed;
display: block;
right: 20px;
bottom: 20px;
}
body .dribbble img {
display: block;
height: 28px;
}
</style>

View file

@ -0,0 +1,60 @@
<template>
<div class="shadow-box">
<div class="status-wrapper" v-if="this.failedEndpoints.length === 0">
<BIconCheckCircleFill class="icon green" />
<span>All services healthy.</span>
</div>
<div class="status-wrapper" v-else>
<BIconExclamationCircleFill class="icon orange" />
<span>{{ failedEndpointCountText }}</span>
</div>
</div>
</template>
<script>
import { BIconExclamationCircleFill, BIconCheckCircleFill } from 'bootstrap-icons-vue';
export default {
name: 'OverallStatus',
components: {
BIconExclamationCircleFill,
BIconCheckCircleFill
},
props: {
failedEndpoints: {
type: Array,
required: true
},
},
computed: {
failedEndpointCountText() {
if (this.failedEndpoints.length === 2) {
return 'Two services are experiencing issues.';
} else if (this.failedEndpoints.length === 3) {
return 'Three services are experiencing issues.';
} else if (this.failedEndpoints.length > 3) {
return 'Multiple services are experiencing issues.';
} else {
return 'One service is experiencing issues.';
}
},
}
}
</script>
<style scoped>
.status-wrapper {
display: flex;
align-items: center;
font-size: 1.3rem;
margin-left: 10px;
margin-top: 10px;
margin-bottom: 10px;
}
.icon {
font-size: 30px;
}
span {
margin-left: 1rem;
}
</style>

View file

@ -1941,6 +1941,13 @@ aws4@^1.8.0:
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59"
integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==
axios@^0.24.0:
version "0.24.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-0.24.0.tgz#804e6fa1e4b9c5288501dd9dff56a7a0940d20d6"
integrity sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==
dependencies:
follow-redirects "^1.14.4"
babel-eslint@^10.1.0:
version "10.1.0"
resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.1.0.tgz#6968e568a910b78fb3779cdd8b6ac2f479943232"
@ -2114,6 +2121,11 @@ boolbase@^1.0.0, boolbase@~1.0.0:
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24=
bootstrap-icons-vue@^0.8.0:
version "0.8.0"
resolved "https://registry.yarnpkg.com/bootstrap-icons-vue/-/bootstrap-icons-vue-0.8.0.tgz#922dfc368e62c85fe9cadad4f3568ce4c2b7bdba"
integrity sha512-bFBpTGNN5RtUpSH7zTz3WkZs2UZA7WXSmkwAKsrA7qddjbIQrvoGsu/yNJiAl14eJgZ+rTqmjqSB+0gRlNZEfA==
brace-expansion@^1.1.7:
version "1.1.11"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
@ -4026,6 +4038,11 @@ follow-redirects@^1.0.0:
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.6.tgz#8cfb281bbc035b3c067d6cd975b0f6ade6e855cd"
integrity sha512-fhUl5EwSJbbl8AR+uYL2KQDxLkdSjZGR36xy46AO7cOMTrCMON6Sa28FmAnC2tRTDbd/Uuzz3aJBv7EBN7JH8A==
follow-redirects@^1.14.4:
version "1.14.7"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.7.tgz#2004c02eb9436eee9a21446a6477debf17e81685"
integrity sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ==
for-in@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"