Initial commit
This commit is contained in:
commit
a55ccd4335
14 changed files with 686 additions and 0 deletions
2
.dockerignore
Normal file
2
.dockerignore
Normal file
|
@ -0,0 +1,2 @@
|
|||
./database/
|
||||
node_modules/
|
3
.editorconfig
Normal file
3
.editorconfig
Normal file
|
@ -0,0 +1,3 @@
|
|||
[*.{ts,js,tsx,jsx}]
|
||||
indent_style = tab
|
||||
tab_width = 2
|
181
.gitignore
vendored
Normal file
181
.gitignore
vendored
Normal file
|
@ -0,0 +1,181 @@
|
|||
# Application specific
|
||||
database/database.json
|
||||
|
||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
\*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
\*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
\*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
\*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
.cache/
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.\*
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
# Bun
|
||||
bun.lockb
|
10
Dockerfile
Normal file
10
Dockerfile
Normal file
|
@ -0,0 +1,10 @@
|
|||
FROM oven/bun:1
|
||||
|
||||
ENV LISTEN_PORT 7000
|
||||
|
||||
WORKDIR .
|
||||
COPY . .
|
||||
|
||||
RUN bun install
|
||||
CMD ["bun", "start"]
|
||||
EXPOSE ${LISTEN_PORT}
|
3
README.md
Normal file
3
README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# Status Page
|
||||
This is a simple way to create customizable status pages.
|
||||
|
87
database/example.json5
Normal file
87
database/example.json5
Normal file
|
@ -0,0 +1,87 @@
|
|||
// NOTE: JSON5 is not supported! Change the extension to `json` and remove the comments.
|
||||
// NOTE: This example _may be_ obsolete. Check [src/db.ts] for the full schema.
|
||||
{
|
||||
"metadata": {
|
||||
"title": "Example"
|
||||
},
|
||||
"statuses": {
|
||||
"working": {
|
||||
"displayName": "Working",
|
||||
"color": "#26a269",
|
||||
"description": "The service is up and running"
|
||||
},
|
||||
"unstable": {
|
||||
"displayName": "Unstable",
|
||||
"color": "#e5a50a",
|
||||
"description": "The service may have some short-lived problems"
|
||||
},
|
||||
"degraded": {
|
||||
"displayName": "Degraded",
|
||||
"color": "#c64600",
|
||||
"description": "There are known issues being resolved right now"
|
||||
},
|
||||
"down": {
|
||||
"display": "Down",
|
||||
"color": "#a51d2d",
|
||||
"description": "The service is down"
|
||||
},
|
||||
"deprecated": {
|
||||
"display": "Deprecated",
|
||||
"color": "#613583",
|
||||
"description": "Status of this service is not tracked and it may be turned off at any time"
|
||||
}
|
||||
},
|
||||
"notices": [
|
||||
{
|
||||
"title": "Maintenance of the CDN",
|
||||
"expectedStatus": "unstable", // Optional
|
||||
"affectedServices": ["cdn", "invidious"], // Optional
|
||||
"expectedDuration": { // Optional
|
||||
"from": 1698408000000, // Result of Date.UTC(...)
|
||||
"to": 1698494400000
|
||||
},
|
||||
"description": "The CDN is going to be in maintenance for some time"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"cdn": {
|
||||
"displayName": "CDN",
|
||||
"link": "https://cdn.sofiaritz.com", // The link is optional
|
||||
"description": "CDN used to host things like GFonts",
|
||||
"status": "working"
|
||||
},
|
||||
"invidious": {
|
||||
"displayName": "Invidious",
|
||||
"description": "Invite-only Invidious instance",
|
||||
"status": "unstable",
|
||||
"updates": [
|
||||
{
|
||||
"time": 1698348215919,
|
||||
"description": "**Monitoring** ‐ A fix has been implemented and the result are being monitored.",
|
||||
"author": "Sofía Aritz" // The author is optional
|
||||
},
|
||||
{
|
||||
"time": 169834801889, // The result of `Date.now()`
|
||||
"description": "**Investigating** ‐ Log-in is not working properly."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"contact": [
|
||||
{
|
||||
"name": "Sofía Aritz",
|
||||
"links": [
|
||||
{
|
||||
"name": "E-Mail",
|
||||
"href": "mailto:sofi@sofiaritz.com"
|
||||
},
|
||||
{
|
||||
"name": "Matrix",
|
||||
"href": "https://matrix.to/#/@sofiaritz:matrix.org"
|
||||
}
|
||||
],
|
||||
"gravatarEmail": "sofi@sofiaritz.com", // This is optional
|
||||
"additionalInfo": "My PGP key is available at [sofiaritz.com/keys/pub.asc](https://sofiaritz.com/keys/pub.asc)" // This is optional
|
||||
}
|
||||
]
|
||||
}
|
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
|
@ -0,0 +1,9 @@
|
|||
version: "3"
|
||||
|
||||
services:
|
||||
status-page:
|
||||
image: "status-page:latest"
|
||||
volumes:
|
||||
- ./database:/home/bun/app/database:ro
|
||||
ports:
|
||||
- "7000:7000"
|
23
package.json
Normal file
23
package.json
Normal file
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "status-page",
|
||||
"module": "src/index.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "bun run src/index.ts",
|
||||
"watch": "bun --watch src/index.ts",
|
||||
"docker:build": "docker build -t status-page ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysiajs/html": "^0.7.3",
|
||||
"@elysiajs/static": "^0.7.1",
|
||||
"elysia": "^0.7.21",
|
||||
"handlebars": "^4.7.8",
|
||||
"lowdb": "^6.1.1"
|
||||
}
|
||||
}
|
103
public/style.css
Normal file
103
public/style.css
Normal file
|
@ -0,0 +1,103 @@
|
|||
:root {
|
||||
font-family: Inter, "Segoe UI", system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6, p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
summary:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
main > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.notices {
|
||||
border: #fafafa solid 1px;
|
||||
padding: .5rem;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.expected-status, .affected-services, .expected-duration {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.services {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.service, .status {
|
||||
border: #fafafa solid 1px;
|
||||
padding: 1rem;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.contact, .statuses {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.individual-contact {
|
||||
border: #fafafa solid 1px;
|
||||
padding: 1rem;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.contact-metadata, .contact-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.contact-metadata img {
|
||||
border-radius: 99999px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 1000px) {
|
||||
.services {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
2
src/consts.ts
Normal file
2
src/consts.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export const UPDATE_INTERVAL = Number(Bun.env["UPDATE_INTERVAL"]) || 1000
|
||||
export const LISTEN_PORT = Number(Bun.env["LISTEN_PORT"]) || 7000
|
74
src/db.ts
Normal file
74
src/db.ts
Normal file
|
@ -0,0 +1,74 @@
|
|||
import { JSONPreset } from "lowdb/node"
|
||||
import {UPDATE_INTERVAL} from "./consts.ts";
|
||||
|
||||
const defaultData: Database = {
|
||||
metadata: {
|
||||
title: "Default Status Page"
|
||||
},
|
||||
statuses: {
|
||||
defaultData: {
|
||||
displayName: "Default Data",
|
||||
color: "#c64600",
|
||||
description: "This status is part of the default schema"
|
||||
}
|
||||
},
|
||||
services: {
|
||||
example: {
|
||||
displayName: "Example",
|
||||
description: "This service is an example",
|
||||
status: "defaultData"
|
||||
}
|
||||
},
|
||||
contact: [{
|
||||
name: "Example maintainer",
|
||||
links: [{
|
||||
name: "Example contact",
|
||||
href: "mailto:example@example.com"
|
||||
}],
|
||||
additionalInfo: "This is an example contact info"
|
||||
}]
|
||||
}
|
||||
export const database = await JSONPreset<Database>("database/database.json", defaultData)
|
||||
|
||||
setInterval(() => database.read(), UPDATE_INTERVAL)
|
||||
|
||||
export type Database = {
|
||||
metadata: {
|
||||
title: string,
|
||||
},
|
||||
statuses: {[key: string]: {
|
||||
displayName: string,
|
||||
color: string,
|
||||
description: string,
|
||||
}},
|
||||
notices?: {
|
||||
title: string,
|
||||
description: string,
|
||||
expectedStatus?: keyof Database["statuses"],
|
||||
affectedServices?: (keyof Database["services"])[],
|
||||
expectedDuration?: {
|
||||
from: number,
|
||||
to: number,
|
||||
},
|
||||
}[],
|
||||
services: {[key: string]: {
|
||||
displayName: string,
|
||||
description: string,
|
||||
status: keyof Database["statuses"],
|
||||
link?: string,
|
||||
updates?: {
|
||||
time: number,
|
||||
description: string,
|
||||
author?: string,
|
||||
}[],
|
||||
}},
|
||||
contact: {
|
||||
name: string,
|
||||
links: {
|
||||
name: string,
|
||||
href: string,
|
||||
}[],
|
||||
gravatarEmail?: string,
|
||||
additionalInfo?: string,
|
||||
}[],
|
||||
}
|
46
src/index.ts
Normal file
46
src/index.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
import {database} from "./db.ts";
|
||||
import Elysia from "elysia";
|
||||
import Handlebars from "handlebars";
|
||||
import {readFile} from "fs/promises";
|
||||
import {LISTEN_PORT} from "./consts.ts";
|
||||
import {html} from "@elysiajs/html";
|
||||
import staticPlugin from "@elysiajs/static";
|
||||
import {hash} from "bun";
|
||||
|
||||
Handlebars.registerHelper("status", (status, options) => {
|
||||
return options.fn({
|
||||
status,
|
||||
...options.data.root["statuses"][status]
|
||||
})
|
||||
})
|
||||
|
||||
Handlebars.registerHelper("inlineStatus", (statusID, options) => {
|
||||
let status = options.data.root["statuses"][statusID]
|
||||
return new Handlebars.SafeString(`<a href="#status-${statusID}" style="color: ${status.color}" class="inline-status status-${statusID}">${status.displayName}</a>`)
|
||||
})
|
||||
|
||||
Handlebars.registerHelper("inlineService", (serviceID, options) => {
|
||||
let service = options.data.root["services"][serviceID]
|
||||
return new Handlebars.SafeString(`<span class="inline-service service-${serviceID}">${service.displayName}</span>`)
|
||||
})
|
||||
|
||||
Handlebars.registerHelper("epochUTC", (time) => {
|
||||
return new Handlebars.SafeString(`<time datetime="${(new Date(time)).toISOString()}">UTC: ${(new Date(time)).toLocaleString()}</time>`)
|
||||
})
|
||||
|
||||
Handlebars.registerHelper("gravatar", (email) => {
|
||||
let hasher = new Bun.CryptoHasher("sha256")
|
||||
hasher.update(email.trim().toLowerCase())
|
||||
let src = "https://gravatar.com/avatar/" + hasher.digest("hex")
|
||||
|
||||
return new Handlebars.SafeString(`<img height="40px" src=${src}>`)
|
||||
})
|
||||
|
||||
let template = Handlebars.compile((await readFile("templates/index.hbs")).toString())
|
||||
new Elysia()
|
||||
.use(html())
|
||||
.use(staticPlugin())
|
||||
.get("/", () => template(database.data))
|
||||
.listen(LISTEN_PORT)
|
||||
|
||||
console.log(`Status Page is running on port ${LISTEN_PORT}`)
|
121
templates/index.hbs
Normal file
121
templates/index.hbs
Normal file
|
@ -0,0 +1,121 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport"
|
||||
content="width=device-width, user-scalable=yes, initial-scale=1.0">
|
||||
<link href="/public/style.css" rel="stylesheet">
|
||||
<title>{{metadata.title}}</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
{{#if notices}}
|
||||
<div>
|
||||
<h2>Notices</h2>
|
||||
<div class="notices">
|
||||
{{#each notices}}
|
||||
<div class="notice">
|
||||
<h3 class="notice-title">{{title}}</h3>
|
||||
{{#if expectedStatus}}
|
||||
<div class="expected-status">
|
||||
Expected status: {{inlineStatus expectedStatus}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if affectedServices.length}}
|
||||
<div class="affected-services">
|
||||
Affected services:
|
||||
{{#each affectedServices}}
|
||||
{{inlineService this}} ⊣
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if expectedDuration}}
|
||||
<div class="expected-duration">
|
||||
Expected duration
|
||||
<ul>
|
||||
<li><span class="from">From: <b>{{epochUTC expectedDuration.from}}</b></span></li>
|
||||
<li><span class="to">To: <b>{{epochUTC expectedDuration.to}}</b></span></li>
|
||||
</ul>
|
||||
</div>
|
||||
{{/if}}
|
||||
<p class="notice-description">{{{description}}}</p>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div>
|
||||
<h2>Services</h2>
|
||||
<div class="services">
|
||||
{{#each services}}
|
||||
<div class="service">
|
||||
<h3 class="service-title">{{displayName}}</h3>
|
||||
<div class="service-metadata">
|
||||
{{#if link}}
|
||||
<a href={{link}}>{{link}}</a> ‐
|
||||
{{/if}}
|
||||
{{inlineStatus status}}
|
||||
</div>
|
||||
<p class="service-description">{{{description}}}</p>
|
||||
{{#if updates.length}}
|
||||
<hr>
|
||||
<div class="service-updates">
|
||||
{{#each updates}}
|
||||
<details>
|
||||
<summary>Updates</summary>
|
||||
<div class="service-update">
|
||||
<div class="update-metadata">
|
||||
<b>{{epochUTC time}}</b>
|
||||
{{#if author}}
|
||||
‐ <span class="update-author">{{author}}</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
<p class="update-description">{{{description}}}</p>
|
||||
</div>
|
||||
</details>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Contact</h2>
|
||||
<div class="contact">
|
||||
{{#each contact}}
|
||||
<div class="individual-contact">
|
||||
<div class="contact-metadata">
|
||||
{{#if gravatarEmail}}
|
||||
{{gravatar gravatarEmail}}
|
||||
{{/if}}
|
||||
<h3 class="contact-name">{{name}}</h3>
|
||||
</div>
|
||||
<p class="contact-addt-info">{{{additionalInfo}}}</p>
|
||||
<div class="contact-links">
|
||||
{{#each links}}
|
||||
<a class="contact-link" href={{href}}>{{name}}</a>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Statuses</h2>
|
||||
<div class="statuses">
|
||||
{{#each statuses}}
|
||||
<div id="status-{{@key}}" class="status">
|
||||
<h3 style="color: {{color}}">{{displayName}}</h3>
|
||||
<p>{{description}}</p>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
Array.from(document.getElementsByTagName("time")).forEach((el) =>
|
||||
el.innerText = (new Date(el.getAttribute("datetime"))).toLocaleString(navigator.language || "en-GB", { timeStyle: "short", dateStyle: "short" }))
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
22
tsconfig.json
Normal file
22
tsconfig.json
Normal file
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"composite": true,
|
||||
"strict": true,
|
||||
"downlevelIteration": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react-jsx",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"allowJs": true,
|
||||
"types": [
|
||||
"bun-types" // add Bun global
|
||||
]
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue