Use SvelteKit 5 and daisyUI 5

This commit is contained in:
Oliver Traber 2025-06-14 16:34:37 +00:00
parent be983a0b4e
commit 3b15ec5ea3
Signed by: Bluemedia
GPG key ID: C0674B105057136C
45 changed files with 5215 additions and 9859 deletions

View file

@ -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
View 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
View 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
View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -1,76 +0,0 @@
<template>
<div id="settings" class="flex">
<div class="flex jc-center ai-center reload-icon">
&#x21bb;
</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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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
}

View file

@ -1,4 +0,0 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

14
src/routes/+layout.svelte Normal file
View 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
View file

@ -0,0 +1 @@
export const ssr = false

123
src/routes/+page.svelte Normal file
View 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}