Initial commit

This commit is contained in:
Sofía Aritz 2023-10-28 18:28:50 +02:00
commit a55ccd4335
Signed by: sofia
GPG key ID: 90B5116E3542B28F
14 changed files with 686 additions and 0 deletions

2
.dockerignore Normal file
View file

@ -0,0 +1,2 @@
./database/
node_modules/

3
.editorconfig Normal file
View file

@ -0,0 +1,3 @@
[*.{ts,js,tsx,jsx}]
indent_style = tab
tab_width = 2

181
.gitignore vendored Normal file
View 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
View 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
View file

@ -0,0 +1,3 @@
# Status Page
This is a simple way to create customizable status pages.

87
database/example.json5 Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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}} &dashv;
{{/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> &dash;
{{/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}}
&dash; <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
View 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
]
}
}