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,27 +1,25 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
{
"name": "Node.js",
"name": "Node.js",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/fancy-gatus",
"shutdownAction": "stopCompose",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/fancy-gatus",
"shutdownAction": "stopCompose",
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [
5173
],
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [5173],
// Configure tool-specific properties.
"customizations": {
"vscode": {
"extensions": [
"svelte.svelte-vscode",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"eamodio.gitlens"
]
}
}
}
// Configure tool-specific properties.
"customizations": {
"vscode": {
"extensions": [
"svelte.svelte-vscode",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"eamodio.gitlens"
]
}
}
}

38
.gitignore vendored
View file

@ -1,23 +1,23 @@
.DS_Store
node_modules
/dist
/public/config.json
# local env files
.env.local
.env.*.local
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# OS
.DS_Store
Thumbs.db
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

6
.prettierignore Normal file
View file

@ -0,0 +1,6 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb

20
.prettierrc Normal file
View file

@ -0,0 +1,20 @@
{
"trailingComma": "es5",
"semi": false,
"singleQuote": true,
"useTabs": false,
"tabWidth": 2,
"quoteProps": "consistent",
"bracketSpacing": true,
"arrowParens": "always",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

View file

@ -8,18 +8,19 @@ You can see it in action here: https://status.bluemedia.dev
## Configuration
The frontend tries to retrieve a configuration file named `config.json` from the webroot during page load. If the configuration is loaded successfully, it will be used to make advanced adjustments to the frontend. The possible options are listed below.
The frontend tries to retrieve a configuration file named `config.json` from the webroot during page load. If the configuration is loaded successfully, it will be used to adjust the frontend. The possible options are listed below.
| Parameter | Description | Default |
|:-------------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:------------------------|
| :----------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :---------------------- |
| `title` | Title of the page. Both in the tab and next to the logo. | `Infrastructure Status` |
| `gatusBaseUrl` | Alternative base URL (without trailing slash) of the Gatus instance, if the API is not available relative to the frontend. | `none` |
| `gatusBaseUrl` | Alternative base URL of the Gatus instance, if the API is not available relative to the frontend. | `none` |
| `hiddenGroups` | Array containing names of groups that should be hidden. These groups are still visible in the API response! | `[]` |
| `hiddenEndpoints` | Array containing names of endpoints that should be hidden. These endpoints are still visible in the API response! | `[]` |
| `hiddenStatuses` | Array containing names of statuses that should be hidden. These are still visible in the API response! | `[]` |
| `groupOrder` | Array containing names of groups. The groups are sorted in the frontend according to the order in the array (different from alphabetical sorting by default). If groups are not included in the array, they will be added alphabetically sorted below the sorted groups. | `[]` |
| `defaultRefreshInterval` | Interval in seconds after which the API data is refreshed in the background by default. Can be changed by the user by selecting a value in the lower left corner of the page. Possible values: `10`, `30`, `60`, `120`, `300`, `600` | `60` |
| `notice` | Optional configuration for a notice on the page. It can be used to provide information about a current problem, for example. | `{}` |
| `notice.type` | Type of the notice. This determines the background color of the card. Possible values: `info`, `primary`, `warning`, `danger`, `""` (empty / white) | `""` |
| `defaultExpandGroups` | Boolean specifying if groups should be expanded by default. | `false` |
| `defaultRefreshInterval` | Interval in seconds after which the API data and config is refreshed in the background by default. Can be changed by the user by selecting a value in the lower left corner of the page. Possible values: `10`, `30`, `60`, `120`, `300`, `600` | `60` |
| `notice` | Optional configuration for a notice on the page. It can be used to provide further information about a current outage, for example. | `{}` |
| `notice.type` | Type of the notice. This determines the background color of the card. Possible values: `success`, `warning`, `error`, (not defined - uses the base color) | not defined |
| `notice.title` | Title of the notice card. | `""` |
| `notice.content` | Content text of the notice card. | `""` |
| `notice.createdAt` | Date and time the notice was created at. For example, use the format `yyyy-MM-dd hh:mm` | `""` |
@ -34,37 +35,47 @@ Make sure that the Gatus API endpoint `/api/v1/endpoints/statuses` is available
### Customizing the logo
If you want to use your own logo, you can simply replace the file in the `img` folder. The image should be square, have a minimum size of 250x250 pixels and preferably a transparent background.
If you want to use your own logo, you can simply replace the file in the `static/img/` folder. The image should be square, have a minimum size of 250x250 pixels and preferably a transparent background.
You can also easily replace the favicon. You can find various converters for the `ico` format online.
## Building Fancy Gatus
You can also build the project yourself instead of using one of the pre built releases. To do this, you need the following requirements:
- `NodeJS >= v14.16.0`
- `Yarn >= v1.22.17`
- `NodeJS >= v22`
- `NPM >= v10`
If you have the necessary requirements, you can use `yarn install` to install the dependencies and `yarn build` to run the build process. The finished data for your web server can be found in the `dist` folder.
If you have the necessary requirements, you can use `npm install` to install the dependencies and `npm run build` to run the build process. The finished data for your web server can be found in the `build` folder.
## Development
Fancy Gatus uses Vue.js 3 as its frontend framework. You can therefore use all common Vue.js commands:
Fancy Gatus uses SvelteKit 5 and daisyUI 5 for it's frontend. You can therefore use all common `npm` commands:
Compile with hot-reloading for development
Compiles and hot-reloads for development
```
yarn serve
npn run dev
```
Compiles and minifies for production
Compile and minify for production
```
yarn build
npm run build
```
Lints and fixes files
Format files using Prettier
```
yarn lint
npm run format
```
Lint and fix files
```
npm run lint
```
### Contribution Guidelines
- Use 2 spaces indent and camelCase

View file

@ -1,5 +0,0 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

36
eslint.config.js Normal file
View file

@ -0,0 +1,36 @@
import prettier from 'eslint-config-prettier'
import js from '@eslint/js'
import { includeIgnoreFile } from '@eslint/compat'
import svelte from 'eslint-plugin-svelte'
import globals from 'globals'
import { fileURLToPath } from 'node:url'
import ts from 'typescript-eslint'
import svelteConfig from './svelte.config.js'
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url))
export default ts.config(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node },
},
rules: { 'no-undef': 'off' },
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig,
},
},
}
)

4265
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,44 +1,41 @@
{
"name": "fancy-gatus",
"version": "1.0.1",
"private": true,
"version": "2.0.0",
"type": "module",
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"axios": "^0.24.0",
"bootstrap-icons-vue": "^0.8.0",
"core-js": "^3.6.5",
"vue": "^3.0.0"
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"@vue/compiler-sfc": "^3.0.0",
"babel-eslint": "^10.1.0",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^7.0.0"
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^6.2.6"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/vue3-essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "babel-eslint"
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
"dependencies": {
"@tailwindcss/vite": "^4.1.8",
"axios": "^1.9.0",
"bootstrap-icons": "^1.13.1",
"daisyui": "^5.0.43",
"tailwindcss": "^4.1.8"
}
}

View file

@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>Infrastructure Status</title>
</head>
<body>
<noscript>
<strong>We're sorry but this page doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

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}

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Before After
Before After

17
svelte.config.js Normal file
View file

@ -0,0 +1,17 @@
import adapter from '@sveltejs/adapter-static'
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
fallback: 'index.html',
}),
},
}
export default config

19
tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

10
vite.config.ts Normal file
View file

@ -0,0 +1,10 @@
import { sveltekit } from '@sveltejs/kit/vite'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
host: '127.0.0.1',
},
})

8809
yarn.lock

File diff suppressed because it is too large Load diff