mirror of
https://github.com/BluemediaDev/fancy-gatus.git
synced 2025-07-01 17:32:42 +02:00
Use SvelteKit 5 and daisyUI 5
This commit is contained in:
parent
be983a0b4e
commit
3b15ec5ea3
45 changed files with 5215 additions and 9859 deletions
226
src/App.vue
226
src/App.vue
|
@ -1,226 +0,0 @@
|
|||
<template>
|
||||
<div class="flex flex-column ai-center main">
|
||||
<div v-if="$data.loading" class="flex ai-center jc-center loader-wrapper">
|
||||
<Loader />
|
||||
</div>
|
||||
<div v-if="!$data.loading" class="flex flex-column jc-center content-wrapper">
|
||||
<Header :title="this.config.title" />
|
||||
<Notice class="mtop-2" v-if="this.config.notice" :notice="this.config.notice" />
|
||||
<OverallStatus class="mtop-2" :failedEndpoints="failedEndpoints" />
|
||||
<EndpointGroup class="mtop-2" v-for="(value, key) in groups" :key="key" :name="key" :endpoints="value" />
|
||||
</div>
|
||||
<div v-if="!$data.loading">
|
||||
<RefreshSettings class="refresh-settings" :defaultRefreshInterval="$data.config.defaultRefreshInterval" v-on:refresh="this.getApiData()" />
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Loader from '@/components/Loader.vue';
|
||||
import Header from '@/components/Header.vue';
|
||||
import Notice from '@/components/Notice.vue';
|
||||
import OverallStatus from '@/components/OverallStatus.vue';
|
||||
import EndpointGroup from '@/components/EndpointGroup.vue';
|
||||
import RefreshSettings from '@/components/RefreshSettings.vue';
|
||||
import Footer from '@/components/Footer.vue';
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: {
|
||||
Loader,
|
||||
Header,
|
||||
Notice,
|
||||
OverallStatus,
|
||||
EndpointGroup,
|
||||
RefreshSettings,
|
||||
Footer
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
config: null,
|
||||
apiData: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// Group endpoints by their group name
|
||||
// and sort the groups according to the config if specified
|
||||
groups() {
|
||||
// Group
|
||||
let groups = this.apiData.reduce(function(rv, x) {
|
||||
if (!x.group) {
|
||||
x.group = "Ungrouped";
|
||||
}
|
||||
(rv[x["group"]] = rv[x["group"]] || []).push(x);
|
||||
return rv;
|
||||
}, {});
|
||||
// Sort
|
||||
if (this.config.groupOrder) {
|
||||
let order = [...this.config.groupOrder];
|
||||
let tmp = {};
|
||||
Object.assign(tmp, groups);
|
||||
groups = {};
|
||||
order.forEach(key => {
|
||||
if (key in tmp) {
|
||||
groups[key] = tmp[key];
|
||||
delete tmp[key];
|
||||
}
|
||||
});
|
||||
Object.assign(groups, tmp);
|
||||
}
|
||||
return groups;
|
||||
},
|
||||
// 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 => {
|
||||
if (error.response.status === 404) {
|
||||
console.warn('Could not find config.json. Using default values.');
|
||||
this.config = {}
|
||||
this.getApiData();
|
||||
} else {
|
||||
console.log("Error getting config: " + 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 => {
|
||||
let data = response.data;
|
||||
// Remove hidden groups if defined in config
|
||||
if (this.config.hiddenGroups) {
|
||||
data = data.filter(endpoint => {
|
||||
return !this.config.hiddenGroups.includes(endpoint.group);
|
||||
});
|
||||
}
|
||||
// Remove hidden endpoints if defined in config
|
||||
if (this.config.hiddenEndpoints) {
|
||||
data = data.filter(endpoint => {
|
||||
return !this.config.hiddenEndpoints.includes(endpoint.name);
|
||||
});
|
||||
}
|
||||
this.apiData = data;
|
||||
this.loading = false;
|
||||
})
|
||||
.catch(error => {
|
||||
console.log(error);
|
||||
});
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.getConfig();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mtop-2 {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.main {
|
||||
padding-left: 30px;
|
||||
padding-right: 30px;
|
||||
}
|
||||
.loader-wrapper {
|
||||
height: 100vh;
|
||||
}
|
||||
.content-wrapper {
|
||||
width: 65%;
|
||||
max-width: 1320px;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
@media screen and (max-width: 1200px) {
|
||||
.content-wrapper {
|
||||
width: 80%;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 820px) {
|
||||
.content-wrapper {
|
||||
width: 90%;
|
||||
}
|
||||
.refresh-settings {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 767px) {
|
||||
.main {
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
}
|
||||
.content-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
|
||||
body,
|
||||
html {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
--green: #00d560;
|
||||
--orange: #ff9100;
|
||||
--grey: #7d8187;
|
||||
}
|
||||
#app {
|
||||
font-family: Avenir, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: #2F3545;
|
||||
padding: 0;
|
||||
}
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
.jc-center {
|
||||
justify-content: center;
|
||||
}
|
||||
.jc-space-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.ai-center {
|
||||
align-items: center;
|
||||
}
|
||||
.flex-column {
|
||||
flex-direction: column;
|
||||
}
|
||||
.flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
.shadow-box {
|
||||
box-shadow: 2px 3px 10px 2px rgb(0 0 0 / 10%);
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.green {
|
||||
color: var(--green);
|
||||
}
|
||||
.orange {
|
||||
color: var(--orange);
|
||||
}
|
||||
</style>
|
50
src/app.css
Normal file
50
src/app.css
Normal file
|
@ -0,0 +1,50 @@
|
|||
@import 'tailwindcss';
|
||||
@import 'bootstrap-icons';
|
||||
|
||||
@plugin "daisyui" {
|
||||
themes: emerald --default;
|
||||
}
|
||||
|
||||
@plugin "daisyui/theme" {
|
||||
name: 'emerald';
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.25rem;
|
||||
--radius-box: 0.5rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
}
|
||||
|
||||
@plugin "daisyui/theme" {
|
||||
name: 'darkgray';
|
||||
default: false;
|
||||
prefersdark: true;
|
||||
color-scheme: 'dark';
|
||||
--color-base-100: oklch(37% 0.034 259.733);
|
||||
--color-base-200: oklch(27% 0.033 256.848);
|
||||
--color-base-300: oklch(21% 0.006 285.885);
|
||||
--color-base-content: oklch(96% 0.001 286.375);
|
||||
--color-primary: oklch(70% 0.14 182.503);
|
||||
--color-primary-content: oklch(98% 0.014 180.72);
|
||||
--color-secondary: oklch(65% 0.241 354.308);
|
||||
--color-secondary-content: oklch(94% 0.028 342.258);
|
||||
--color-accent: oklch(58% 0.233 277.117);
|
||||
--color-accent-content: oklch(96% 0.018 272.314);
|
||||
--color-neutral: oklch(20% 0 0);
|
||||
--color-neutral-content: oklch(96% 0.001 286.375);
|
||||
--color-info: oklch(74% 0.16 232.661);
|
||||
--color-info-content: oklch(95% 0.026 236.824);
|
||||
--color-success: oklch(76% 0.177 163.223);
|
||||
--color-success-content: oklch(26% 0.051 172.552);
|
||||
--color-warning: oklch(82% 0.189 84.429);
|
||||
--color-warning-content: oklch(27% 0.077 45.635);
|
||||
--color-error: oklch(64% 0.246 16.439);
|
||||
--color-error-content: oklch(96% 0.015 12.422);
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.25rem;
|
||||
--radius-box: 0.5rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 0;
|
||||
--noise: 0;
|
||||
}
|
13
src/app.d.ts
vendored
Normal file
13
src/app.d.ts
vendored
Normal file
|
@ -0,0 +1,13 @@
|
|||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
13
src/app.html
Normal file
13
src/app.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Infrastructure Status</title>
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
|
@ -1,89 +0,0 @@
|
|||
<template>
|
||||
<div class="flex ai-center endpoint">
|
||||
<div class="flex ai-center info">
|
||||
<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>
|
||||
</div>
|
||||
<span class="spacer"></span>
|
||||
<div class="history">
|
||||
<EndpointHistory :results="endpoint.results" />
|
||||
</div>
|
||||
</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 {
|
||||
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;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.endpoint {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 5px;
|
||||
}
|
||||
.info {
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.history {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
.icon {
|
||||
font-size: 15px;
|
||||
}
|
||||
span {
|
||||
font-size: 15px;
|
||||
}
|
||||
.hostname {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,36 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<h3>{{ name }}</h3>
|
||||
<div class="flex flex-column shadow-box">
|
||||
<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>
|
||||
.endpoint {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
|
@ -1,61 +0,0 @@
|
|||
<template>
|
||||
<div class="flex flex-row jc-space-between 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({});
|
||||
}
|
||||
for (let t of tmp) {
|
||||
t.timestamp = new Date(t.timestamp).toLocaleString();
|
||||
}
|
||||
return tmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.history {
|
||||
width: 300px;
|
||||
}
|
||||
.blob {
|
||||
width: 7px;
|
||||
height: 20px;
|
||||
margin: 0;
|
||||
border-radius: 50rem;
|
||||
--hover-scale: 1.3;
|
||||
}
|
||||
.blob:hover {
|
||||
transform: scale(var(--hover-scale));
|
||||
transition-duration: 100ms;
|
||||
}
|
||||
.green {
|
||||
background-color: var(--green);
|
||||
}
|
||||
.orange {
|
||||
background-color: var(--orange);
|
||||
}
|
||||
.grey {
|
||||
background-color: var(--grey);
|
||||
}
|
||||
@media screen and (max-width: 768px) {
|
||||
.history {
|
||||
width: 100%;
|
||||
margin-top: 0.4rem
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,25 +0,0 @@
|
|||
<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>
|
|
@ -1,37 +0,0 @@
|
|||
<template>
|
||||
<div class="flex ai-center">
|
||||
<img src="/img/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>
|
||||
.logo {
|
||||
width: 60px;
|
||||
height: auto;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
margin-left: 20px;
|
||||
}
|
||||
@media screen and (max-width: 768px) {
|
||||
.logo {
|
||||
width: 40px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,256 +0,0 @@
|
|||
<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>
|
|
@ -1,57 +0,0 @@
|
|||
<template>
|
||||
<div :class="{
|
||||
notice: true, 'shadow-box': true,
|
||||
primary: this.notice.type === 'primary',
|
||||
warning: this.notice.type === 'warning',
|
||||
danger: this.notice.type === 'danger',
|
||||
info: this.notice.type === 'info'
|
||||
}">
|
||||
<h4>{{ this.notice.title }}</h4>
|
||||
<p>{{ this.notice.content }}</p>
|
||||
<div class="flex flex-column time">
|
||||
Created at: {{ this.notice.createdAt }}
|
||||
<br>
|
||||
<span v-if="this.notice.updatedAt">Last updated: {{ this.notice.updatedAt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Notice',
|
||||
props: {
|
||||
notice: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notice {
|
||||
padding: 1.35rem;
|
||||
}
|
||||
.notice > h4 {
|
||||
margin: 0;
|
||||
font-size: calc(1rem + 0.3vw);
|
||||
}
|
||||
.time {
|
||||
font-size: calc(0.7rem + 0.1vw);
|
||||
}
|
||||
.time > span {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
.primary {
|
||||
background-color: #5cdd8b;
|
||||
}
|
||||
.warning {
|
||||
background-color: #ffc107;
|
||||
}
|
||||
.danger {
|
||||
background-color: #ff5252;
|
||||
}
|
||||
.info {
|
||||
background-color: #5ae4ff;
|
||||
}
|
||||
</style>
|
|
@ -1,66 +0,0 @@
|
|||
<template>
|
||||
<div class="shadow-box">
|
||||
<div class="flex ai-center status-wrapper" v-if="this.failedEndpoints.length === 0">
|
||||
<BIconCheckCircleFill class="icon green" />
|
||||
<span>All services healthy.</span>
|
||||
</div>
|
||||
<div class="flex ai-center 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 {
|
||||
font-size: 1.3rem;
|
||||
margin-left: 10px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.icon {
|
||||
font-size: 30px;
|
||||
}
|
||||
span {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
@media screen and (max-width: 768px) {
|
||||
.status-wrapper {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,76 +0,0 @@
|
|||
<template>
|
||||
<div id="settings" class="flex">
|
||||
<div class="flex jc-center ai-center reload-icon">
|
||||
↻
|
||||
</div>
|
||||
<select 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'RefreshSettings',
|
||||
props: {
|
||||
defaultRefreshInterval: {
|
||||
type: Number,
|
||||
default: 60
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setRefreshInterval(seconds) {
|
||||
if (this.refreshHandler)
|
||||
clearInterval(this.refreshHandler);
|
||||
|
||||
this.refreshInterval = seconds;
|
||||
let reference = this;
|
||||
this.refreshHandler = setInterval(function () {
|
||||
reference.refreshData();
|
||||
}, seconds * 1000);
|
||||
},
|
||||
refreshData() {
|
||||
this.$emit('refresh');
|
||||
},
|
||||
handleChangeRefreshInterval() {
|
||||
this.refreshData();
|
||||
this.setRefreshInterval(this.$refs.refreshInterval.value);
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.setRefreshInterval(this.defaultRefreshInterval);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#settings {
|
||||
position: fixed;
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
background-color: #b9b9b9;
|
||||
border-radius: 0.2rem;
|
||||
border: 1px solid #b9b9b9;
|
||||
box-shadow: 0 1px 3px 0 rgba(0,0,0,.1);
|
||||
}
|
||||
.reload-icon {
|
||||
padding: 0;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
text-align: center;
|
||||
}
|
||||
#settings > select {
|
||||
font-size: 0.8rem;
|
||||
color: #424242;
|
||||
text-transform: none;
|
||||
border: 0px;
|
||||
}
|
||||
#settings select:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
12
src/lib/components/Footer.svelte
Normal file
12
src/lib/components/Footer.svelte
Normal file
|
@ -0,0 +1,12 @@
|
|||
<footer class="footer sm:footer-horizontal footer-center text-base-content p-4 text-base">
|
||||
<aside>
|
||||
<p>
|
||||
Powered by
|
||||
<a class="underline" target="_blank" href="https://github.com/TwiN/gatus">Gatus</a>
|
||||
and
|
||||
<a class="underline" target="_blank" href="https://github.com/BluemediaDev/fancy-gatus"
|
||||
>Fancy Gatus</a
|
||||
>
|
||||
</p>
|
||||
</aside>
|
||||
</footer>
|
21
src/lib/components/Header.svelte
Normal file
21
src/lib/components/Header.svelte
Normal file
|
@ -0,0 +1,21 @@
|
|||
<script lang="ts">
|
||||
import { persistentSettings } from '$lib/persistent_store'
|
||||
|
||||
let { title = 'Infrastructure Status' } = $props()
|
||||
</script>
|
||||
|
||||
<div class="mb-10 flex items-center justify-center md:justify-start">
|
||||
<img src="/img/logo.png" alt="Logo" class="w-14 md:w-16 h-auto" />
|
||||
<h2 class="ml-4 text-xl md:text-2xl font-semibold">{title}</h2>
|
||||
<span class="hidden md:block grow"></span>
|
||||
<label class="invisible md:visible md:text-3xl swap swap-rotate">
|
||||
<!-- this hidden checkbox controls the state -->
|
||||
<input type="checkbox" bind:checked={$persistentSettings.darkmode} />
|
||||
|
||||
<!-- sun icon -->
|
||||
<i class="bi bi-sun swap-off"></i>
|
||||
|
||||
<!-- moon icon -->
|
||||
<i class="bi bi-moon swap-on"></i>
|
||||
</label>
|
||||
</div>
|
228
src/lib/components/Loader.svelte
Normal file
228
src/lib/components/Loader.svelte
Normal file
|
@ -0,0 +1,228 @@
|
|||
<div class="loader-wrapper w-full flex flex-column justify-center items-center">
|
||||
<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>
|
||||
|
||||
<style>
|
||||
.loader-wrapper {
|
||||
height: calc(100vh - (var(--spacing) * 5 * 2));
|
||||
}
|
||||
.loader {
|
||||
--duration: 3s;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
position: relative;
|
||||
}
|
||||
.loader:before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
display: block;
|
||||
background: var(--color-primary);
|
||||
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(--color-base-content);
|
||||
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;
|
||||
}
|
||||
|
||||
.loader {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
*:before,
|
||||
*:after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
32
src/lib/components/Notice.svelte
Normal file
32
src/lib/components/Notice.svelte
Normal file
|
@ -0,0 +1,32 @@
|
|||
<script lang="ts">
|
||||
import type { Notice } from '$lib/types/config'
|
||||
|
||||
let props: {
|
||||
notice: Notice
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={[
|
||||
'alert',
|
||||
'w-full',
|
||||
'mb-10',
|
||||
'p-0',
|
||||
'shadow-sm',
|
||||
props.notice.type === undefined ? 'bg-base-100' : '',
|
||||
props.notice.type === 'success' ? 'alert-success' : '',
|
||||
props.notice.type === 'warning' ? 'alert-warning' : '',
|
||||
props.notice.type === 'error' ? 'alert-error' : '',
|
||||
]}
|
||||
>
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{props.notice.title}</h2>
|
||||
<p>{props.notice.content}</p>
|
||||
<div class="mt-2">
|
||||
<p><span class="font-bold">Created at:</span> {props.notice.createdAt}</p>
|
||||
{#if props.notice.updatedAt}
|
||||
<p><span class="font-bold">Last updated:</span> {props.notice.updatedAt}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
44
src/lib/components/OverallStatus.svelte
Normal file
44
src/lib/components/OverallStatus.svelte
Normal file
|
@ -0,0 +1,44 @@
|
|||
<script lang="ts">
|
||||
import type { Status } from '$lib/types/api'
|
||||
|
||||
let props: {
|
||||
failedStatuses: Status[]
|
||||
} = $props()
|
||||
|
||||
let overallStatusText = $derived.by(() => {
|
||||
if (props.failedStatuses.length === 0) {
|
||||
return 'All services healthy.'
|
||||
} else if (props.failedStatuses.length === 2) {
|
||||
return 'Two services are experiencing issues.'
|
||||
} else if (props.failedStatuses.length === 3) {
|
||||
return 'Three services are experiencing issues.'
|
||||
} else if (props.failedStatuses.length > 3) {
|
||||
return 'Multiple services are experiencing issues.'
|
||||
} else {
|
||||
return 'One service is experiencing issues.'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="card w-full mt-5 mb-10 bg-base-100 shadow-sm">
|
||||
<div class="card-body flex flex-row items-center">
|
||||
<div class="inline-grid *:[grid-area:1/1]">
|
||||
<div
|
||||
class={[
|
||||
'status',
|
||||
'status-lg',
|
||||
'animate-ping',
|
||||
props.failedStatuses.length === 0 ? 'status-success' : 'status-warning',
|
||||
]}
|
||||
></div>
|
||||
<div
|
||||
class={[
|
||||
'status',
|
||||
'status-lg',
|
||||
props.failedStatuses.length === 0 ? 'status-success' : 'status-warning',
|
||||
]}
|
||||
></div>
|
||||
</div>
|
||||
<p class="ml-2 text-lg">{overallStatusText}</p>
|
||||
</div>
|
||||
</div>
|
47
src/lib/components/RefreshSettings.svelte
Normal file
47
src/lib/components/RefreshSettings.svelte
Normal file
|
@ -0,0 +1,47 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
|
||||
let {
|
||||
defaultRefreshInterval = 60,
|
||||
onRefresh,
|
||||
}: { defaultRefreshInterval?: number; onRefresh: () => void } = $props()
|
||||
|
||||
let selectedInterval: number = $state(defaultRefreshInterval)
|
||||
let refreshHandler: ReturnType<typeof setInterval> | undefined = $state(undefined)
|
||||
|
||||
function setRefreshInterval() {
|
||||
if (refreshHandler) clearInterval(refreshHandler)
|
||||
refreshHandler = setInterval(() => onRefresh(), selectedInterval * 1000)
|
||||
}
|
||||
|
||||
function handleRefreshIntervalChange() {
|
||||
onRefresh()
|
||||
setRefreshInterval()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
setRefreshInterval()
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
if (refreshHandler) clearInterval(refreshHandler)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="hidden xl:block fixed left-4 bottom-5 card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-2 flex flex-row items-center">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
<select
|
||||
class="select select-xs"
|
||||
bind:value={selectedInterval}
|
||||
onchange={handleRefreshIntervalChange}
|
||||
>
|
||||
<option value={10}>10s</option>
|
||||
<option value={30}>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>
|
56
src/lib/components/Status.svelte
Normal file
56
src/lib/components/Status.svelte
Normal file
|
@ -0,0 +1,56 @@
|
|||
<script lang="ts">
|
||||
import type { Status } from '$lib/types/api'
|
||||
|
||||
let props: {
|
||||
status: Status
|
||||
} = $props()
|
||||
|
||||
let isFailed = $derived(!props.status.results[props.status.results.length - 1].success)
|
||||
</script>
|
||||
|
||||
<li
|
||||
class="list-row w-full flex flex-col lg:flex-row lg:items-center justify-center lg:justify-start"
|
||||
>
|
||||
<p>
|
||||
<span class="font-semibold">{props.status.name}</span>
|
||||
{#if props.status.results[props.status.results.length - 1].hostname}
|
||||
| {props.status.results[props.status.results.length - 1].hostname}
|
||||
{/if}
|
||||
</p>
|
||||
<span class="hidden lg:flex grow"></span>
|
||||
<div class="h-full w-fit mr-8 flex flex-row justify-between items-center gap-1">
|
||||
{#each props.status.results as result (result.timestamp)}
|
||||
<div class="h-fit w-fit lg:tooltip">
|
||||
<div class="tooltip-content hidden lg:flex flex-col items-center">
|
||||
<p>{new Date(result.timestamp).toLocaleString()}</p>
|
||||
{#if result.success}
|
||||
<p class="text-success">Operational</p>
|
||||
{:else}
|
||||
<p class="text-warning">Service Problem</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class={['w-2', 'h-5', 'rounded-sm', result.success ? 'bg-success' : 'bg-warning']}
|
||||
></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="w-35 flex flex-row items-center">
|
||||
<div class="inline-grid *:[grid-area:1/1]">
|
||||
<div
|
||||
class={[
|
||||
'status',
|
||||
'status-md',
|
||||
'animate-ping',
|
||||
isFailed ? 'status-warning' : 'status-success',
|
||||
]}
|
||||
></div>
|
||||
<div class={['status', 'status-md', isFailed ? 'status-warning' : 'status-success']}></div>
|
||||
</div>
|
||||
{#if isFailed}
|
||||
<p class="ml-2 text-md text-warning">Service Problem</p>
|
||||
{:else}
|
||||
<p class="ml-2 text-md text-success">Operational</p>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
35
src/lib/components/StatusGroup.svelte
Normal file
35
src/lib/components/StatusGroup.svelte
Normal file
|
@ -0,0 +1,35 @@
|
|||
<script lang="ts">
|
||||
import type { Status as StatusType } from '$lib/types/api'
|
||||
import Status from '$lib/components/Status.svelte'
|
||||
|
||||
let props: {
|
||||
title: string
|
||||
statuses: StatusType[]
|
||||
expandByDefault?: boolean
|
||||
} = $props()
|
||||
|
||||
let hasFailedStatuses = $derived.by(() => {
|
||||
return (
|
||||
props.statuses.filter((status) => {
|
||||
return !status.results[status.results.length - 1].success
|
||||
}).length > 0
|
||||
)
|
||||
})
|
||||
|
||||
let expanded = $derived(hasFailedStatuses || props.expandByDefault)
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div tabindex="0" class="collapse collapse-plus mb-5 bg-base-100 border-base-300 border">
|
||||
<input type="checkbox" checked={expanded} />
|
||||
<div class="collapse-title font-semibold">
|
||||
{props.title}
|
||||
</div>
|
||||
<div class="collapse-content flex flex-col">
|
||||
<ul class="list w-full">
|
||||
{#each props.statuses as status (status.key)}
|
||||
<Status {status} />
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
15
src/lib/persistent_store.ts
Normal file
15
src/lib/persistent_store.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { writable } from 'svelte/store'
|
||||
|
||||
interface PersistedSettings {
|
||||
darkmode: boolean
|
||||
}
|
||||
|
||||
const settingsDefault: PersistedSettings = {
|
||||
darkmode: window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches,
|
||||
}
|
||||
|
||||
export const persistentSettings = writable<PersistedSettings>(
|
||||
JSON.parse(localStorage.getItem('persistentSettings') || JSON.stringify(settingsDefault))
|
||||
)
|
||||
|
||||
persistentSettings.subscribe((value) => (localStorage.persistentSettings = JSON.stringify(value)))
|
18
src/lib/types/api.ts
Normal file
18
src/lib/types/api.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
export type Result = {
|
||||
status?: number
|
||||
hostname?: string
|
||||
duration: number
|
||||
conditionResults: {
|
||||
condition: string
|
||||
success: boolean
|
||||
}[]
|
||||
success: boolean
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export type Status = {
|
||||
name: string
|
||||
group?: string
|
||||
key: string
|
||||
results: Result[]
|
||||
}
|
18
src/lib/types/config.ts
Normal file
18
src/lib/types/config.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
export type Notice = {
|
||||
type?: 'success' | 'warning' | 'error'
|
||||
title: string
|
||||
content: string
|
||||
createdAt: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export type Config = {
|
||||
title?: string
|
||||
gatusBaseUrl?: string
|
||||
hiddenGroups?: string[]
|
||||
hiddenStatuses?: string[]
|
||||
groupOrder?: string[]
|
||||
defaultExpandGroups?: boolean
|
||||
defaultRefreshInterval?: 10 | 30 | 60 | 120 | 300 | 600
|
||||
notice?: Notice
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
14
src/routes/+layout.svelte
Normal file
14
src/routes/+layout.svelte
Normal file
|
@ -0,0 +1,14 @@
|
|||
<script>
|
||||
import { persistentSettings } from '$lib/persistent_store'
|
||||
let { children } = $props()
|
||||
import '../app.css'
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="w-screen h-fit min-h-screen flex flex-col items-center bg-base-200"
|
||||
data-theme={$persistentSettings.darkmode ? 'darkgray' : 'emerald'}
|
||||
>
|
||||
<div class="w-full md:w-5/6 xl:w-3/5 max-w-5xl h-full pl-4 md:pl-0 pr-4 md:pr-0 pt-5 pb-5">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
1
src/routes/+layout.ts
Normal file
1
src/routes/+layout.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const ssr = false
|
123
src/routes/+page.svelte
Normal file
123
src/routes/+page.svelte
Normal file
|
@ -0,0 +1,123 @@
|
|||
<script lang="ts">
|
||||
import axios from 'axios'
|
||||
import { onMount } from 'svelte'
|
||||
import type { Config } from '$lib/types/config'
|
||||
import type { Status } from '$lib/types/api'
|
||||
import Loader from '$lib/components/Loader.svelte'
|
||||
import Header from '$lib/components/Header.svelte'
|
||||
import Notice from '$lib/components/Notice.svelte'
|
||||
import OverallStatus from '$lib/components/OverallStatus.svelte'
|
||||
import EndpointGroup from '$lib/components/StatusGroup.svelte'
|
||||
import RefreshSettings from '$lib/components/RefreshSettings.svelte'
|
||||
import Footer from '$lib/components/Footer.svelte'
|
||||
|
||||
let loading = $state(true)
|
||||
let config: Config = $state({})
|
||||
let apiData: Status[] = $state([])
|
||||
|
||||
function getConfig() {
|
||||
axios
|
||||
.get('config.json', { baseURL: '/' })
|
||||
.then((response) => {
|
||||
config = response.data
|
||||
// Set title if defined in config
|
||||
if (config.title) {
|
||||
document.title = config.title
|
||||
}
|
||||
getApiData()
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response.status === 404) {
|
||||
console.warn('No config.json file found. Using default values.')
|
||||
getApiData()
|
||||
} else {
|
||||
console.log('Error getting config: ' + error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getApiData() {
|
||||
// Set base URL for API calls if defined in config
|
||||
if (config.gatusBaseUrl && axios.defaults.baseURL !== config.gatusBaseUrl) {
|
||||
axios.defaults.baseURL = config.gatusBaseUrl
|
||||
}
|
||||
axios
|
||||
.get('/api/v1/endpoints/statuses')
|
||||
.then((response) => {
|
||||
apiData = response.data
|
||||
loading = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error)
|
||||
})
|
||||
}
|
||||
|
||||
// Group statuses by their group name
|
||||
// and sort them according to the config if specified
|
||||
let groups: { title: string; statuses: Status[] }[] = $derived.by(() => {
|
||||
// Group statuses by group name
|
||||
let groups: Map<string, Status[]> = new Map<string, Status[]>()
|
||||
apiData.map((status) => {
|
||||
// Filter statuses that should be hidden
|
||||
if (config.hiddenStatuses?.includes(status.name)) return
|
||||
if (!status.group) {
|
||||
status.group = 'Ungrouped'
|
||||
}
|
||||
// Filter groups that should be hidden
|
||||
if (config.hiddenGroups?.includes(status.group)) return
|
||||
let groupStatuses = groups.get(status.group) || []
|
||||
groupStatuses.push(status)
|
||||
groups.set(status.group, groupStatuses)
|
||||
})
|
||||
|
||||
// Sort by config
|
||||
let tmp = groups
|
||||
let sortedGroups: { title: string; statuses: Status[] }[] = []
|
||||
if (config.groupOrder) {
|
||||
config.groupOrder.forEach((key) => {
|
||||
if (tmp.has(key)) {
|
||||
sortedGroups.push({ title: key, statuses: tmp.get(key)! })
|
||||
tmp.delete(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Sort remaining group alphabetically
|
||||
let sortedKeys = [...tmp.keys()].sort((a, b) => a.localeCompare(b))
|
||||
sortedKeys.map((key) => {
|
||||
let statuses = tmp.get(key)!
|
||||
sortedGroups.push({ title: key, statuses })
|
||||
})
|
||||
return sortedGroups
|
||||
})
|
||||
|
||||
// Array of statuses where the last result has success = false
|
||||
let failedStatuses = $derived.by(() => {
|
||||
return apiData.filter((status) => {
|
||||
return !status.results[status.results.length - 1].success
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
getConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<Loader />
|
||||
{:else}
|
||||
<Header title={config.title} />
|
||||
{#if config.notice}
|
||||
<Notice notice={config.notice} />
|
||||
{/if}
|
||||
<OverallStatus {failedStatuses} />
|
||||
{#each groups as group (group.title)}
|
||||
<EndpointGroup
|
||||
title={group.title}
|
||||
statuses={group.statuses}
|
||||
expandByDefault={config.defaultExpandGroups}
|
||||
/>
|
||||
{/each}
|
||||
<RefreshSettings defaultRefreshInterval={config.defaultRefreshInterval} onRefresh={getConfig} />
|
||||
<Footer />
|
||||
{/if}
|
Loading…
Add table
Add a link
Reference in a new issue