mirror of
https://github.com/BluemediaDev/fancy-gatus.git
synced 2025-06-30 17:02:41 +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
|
@ -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
38
.gitignore
vendored
|
@ -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
1
.npmrc
Normal file
|
@ -0,0 +1 @@
|
|||
engine-strict=true
|
6
.prettierignore
Normal file
6
.prettierignore
Normal file
|
@ -0,0 +1,6 @@
|
|||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lock
|
||||
bun.lockb
|
20
.prettierrc
Normal file
20
.prettierrc
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
49
README.md
49
README.md
|
@ -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
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
}
|
36
eslint.config.js
Normal file
36
eslint.config.js
Normal 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
4265
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
69
package.json
69
package.json
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
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}
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
17
svelte.config.js
Normal file
17
svelte.config.js
Normal 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
19
tsconfig.json
Normal 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
10
vite.config.ts
Normal 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',
|
||||
},
|
||||
})
|
Loading…
Add table
Add a link
Reference in a new issue