mirror of
https://github.com/BluemediaDev/fancy-gatus.git
synced 2025-06-07 16:43:15 +02:00
Initial build
This commit is contained in:
parent
ba484d3d9a
commit
20686e39b6
14 changed files with 714 additions and 6 deletions
144
src/App.vue
144
src/App.vue
|
@ -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
BIN
src/assets/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.5 KiB |
61
src/components/Endpoint.vue
Normal file
61
src/components/Endpoint.vue
Normal 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>
|
41
src/components/EndpointGroup.vue
Normal file
41
src/components/EndpointGroup.vue
Normal 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>
|
54
src/components/EndpointHistory.vue
Normal file
54
src/components/EndpointHistory.vue
Normal 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
25
src/components/Footer.vue
Normal 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
33
src/components/Header.vue
Normal 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
256
src/components/Loader.vue
Normal 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>
|
60
src/components/OverallStatus.vue
Normal file
60
src/components/OverallStatus.vue
Normal 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>
|
Loading…
Add table
Add a link
Reference in a new issue