statsys/www/index.html

791 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self'; font-src 'self'; connect-src 'self'; base-uri 'self'; form-action 'self';"
/>
<title>status — {{ .Title }}</title>
<meta name="title" content="{{ .Title }} Status" />
<meta name="description" content="Real-time status monitoring for {{ .Title }}" />
<meta name="keywords" content="status page, uptime, system status, service status, {{ .Title }}, monitoring, statsys" />
<meta name="author" content="{{ .Title }}" />
<meta name="robots" content="index, follow" />
<meta property="og:type" content="website" />
<meta property="og:title" content="{{ .Title }} Status" />
<meta property="og:description" content="Real-time status monitoring for {{ .Title }}" />
<meta property="og:site_name" content="{{ .Title }} Status" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="{{ .Title }} Status" />
<meta name="twitter:description" content="Real-time status monitoring for {{ .Title }}" />
<meta name="theme-color" content="#000000" />
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<link rel="stylesheet" href="/styles.css" />
<style>
:root {
--bg-body: #fafafa;
--bg-main: white;
--text-primary: black;
--text-secondary: rgba(0, 0, 0, 0.6);
--border-primary: black;
--border-secondary: rgba(0, 0, 0, 0.5);
--border-subtle: rgba(0, 0, 0, 0.1);
--border-dotted: rgba(0, 0, 0, 0.1);
--border-outline: rgba(0, 0, 0, 0.04);
--accent-primary: black;
--accent-secondary: white;
--accent-tertiary: rgba(0, 0, 0, 0.4);
--accent-muted: rgba(0, 0, 0, 0.3);
--accent-faded: rgba(0, 0, 0, 0.2);
--status-online: black;
--status-online-border: unset;
--status-degraded: white;
--status-degraded-pattern: black;
--status-degraded-border: unset;
--status-offline: white;
--status-offline-x: black;
--status-offline-border: unset;
--status-unknown: rgb(235, 235, 235);
--hover-bg: rgba(0, 0, 0, 0.03);
--hover-text: black;
--selected-bg: rgba(0, 0, 0, 0.08);
--tooltip-bg: black;
--tooltip-text: white;
--tooltip-border: transparent;
--shadow-color: rgba(0, 0, 0, 0.1);
}
* {
box-sizing: border-box;
}
body {
background-color: var(--bg-body);
color: var(--text-primary);
font-family: "Inter", sans-serif;
height: 100vh;
margin: 0;
display: flex;
justify-content: center;
}
main {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: max-content;
min-height: 100%;
max-width: 39.625rem;
padding: 4rem 2rem 1.5rem;
background-color: var(--bg-main);
z-index: 1;
outline: 1px solid var(--border-outline);
}
h1 {
font-weight: 800;
margin-top: 1rem;
margin-bottom: 1rem;
font-size: 1.5rem;
color: var(--text-primary);
}
hr {
border: 0;
border-bottom: 2px dotted var(--border-dotted);
}
p {
margin-top: 4px;
line-height: 1.5rem;
}
#emoticon {
display: flex;
align-items: end;
height: 4.375rem;
color: var(--accent-primary);
}
#emoticon span {
font-size: 3rem;
font-weight: 700;
}
#overall-status {
background-color: var(--accent-primary);
color: var(--accent-secondary);
padding: 0.5rem;
font-size: 0.875rem;
width: fit-content;
}
#pattern {
flex-grow: 1;
background-image: repeating-linear-gradient(-45deg, var(--accent-primary), var(--accent-primary) 10px, transparent 0px, transparent 20px);
}
#caption {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.25rem;
margin-bottom: 1.25rem;
}
#last-updated {
color: var(--text-secondary);
font-size: 0.875rem;
margin: 0;
}
#legend {
display: flex;
justify-content: flex-end;
gap: 1rem;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
pointer-events: none;
}
.legend-item .status-bar {
width: 1rem;
height: 1rem;
}
#services {
display: flex;
flex-direction: column;
gap: 2.5rem;
margin-bottom: 5rem;
}
.info {
display: flex;
align-items: end;
justify-content: space-between;
}
.service-name {
margin: 0;
font-size: 1.25rem;
text-decoration: none;
color: var(--text-primary);
font-weight: 600;
display: flex;
align-items: center;
gap: 0.2rem;
}
.service-name svg {
height: 1rem;
color: var(--accent-faded);
}
.service-name:hover svg {
color: var(--accent-primary);
}
.status {
font-family: "JetBrains Mono", monospace;
font-weight: 600;
font-size: 0.75rem;
}
.bars-container {
display: flex;
flex-direction: column;
align-items: end;
}
.bars {
position: relative;
display: flex;
justify-content: end;
width: 100%;
}
.bars > div {
display: flex;
justify-content: center;
width: 1.188rem;
height: 2.5rem;
flex-shrink: 0;
position: relative;
}
.status-bar {
background-color: transparent;
border: 1px solid black;
width: 1rem;
height: 100%;
transition: transform 70ms ease-out;
z-index: 0;
border-radius: 3px;
/* this weird hack fixes a lot of CSS glitches */
transform: translateX(0px);
}
.bars > div:hover .status-bar {
transform: translateY(-3px);
filter: brightness(0.9);
}
/* tooltip */
.bars > div:hover::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
margin-bottom: 0.5rem;
font-size: 0.75rem;
font-family: "JetBrains Mono", monospace;
font-weight: 600;
background-color: var(--tooltip-bg);
color: var(--tooltip-text);
border: 1px solid var(--tooltip-border);
padding: 0.45rem 0.5rem;
border-radius: 0.25rem;
transform: translateX(-50%);
white-space: nowrap;
z-index: 10;
pointer-events: none;
}
.status-bar.Online {
background-color: var(--status-online);
border-color: var(--status-online-border);
}
.status-bar.Unknown {
border: 0;
}
.status-bar.Unknown::before {
content: "";
width: 6px;
height: 6px;
border-radius: 100%;
background: var(--status-unknown);
border-color: var(--status-unknown-border);
position: absolute;
inset: 0;
margin: auto;
}
.status-bar.Degraded {
background-color: var(--status-degraded);
background-image: linear-gradient(
45deg,
var(--status-degraded-pattern) 25%,
transparent 25%,
transparent 50%,
var(--status-degraded-pattern) 50%,
var(--status-degraded-pattern) 75%,
transparent 75%,
transparent
);
background-size: 8px 8px;
border-color: var(--status-degraded-border);
}
.status-bar.Offline {
background-color: var(--status-offline);
border-color: var(--status-offline-border);
}
.status-bar.Offline::before {
content: "✕";
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
font-weight: 700;
color: var(--status-offline-x);
pointer-events: none;
overflow: hidden;
}
/* only show when screen is small */
.bars-gradient {
display: none !important;
position: absolute !important;
left: -2.5rem;
bottom: 0;
width: 5rem !important;
height: 3rem !important;
background: linear-gradient(to left, transparent, var(--bg-main) 50%);
pointer-events: none;
z-index: 10;
}
.bars-footer {
position: relative;
display: flex;
justify-content: space-between;
font-size: 0.75rem;
font-family: "JetBrains Mono", monospace;
margin-top: 0.5rem;
color: var(--accent-muted);
width: 100%;
}
.bars-footer span {
padding: 0 0.5rem;
}
.bars-footer hr {
flex-grow: 1;
border-color: var(--border-subtle);
}
.uptime-percentage {
position: absolute;
left: 50%;
transform: translateX(-50%);
background-color: var(--bg-main);
color: var(--accent-tertiary);
}
footer {
margin-top: auto;
display: flex;
justify-content: space-between;
align-items: center;
}
footer a {
color: var(--accent-muted);
font-size: 0.875rem;
text-decoration: none;
}
footer a:hover {
color: var(--accent-primary);
}
.combobox {
position: relative;
width: 8rem;
}
.combobox-trigger {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
height: 1.75rem;
font-family: "Inter", sans-serif;
font-size: 0.825rem;
background-color: var(--bg-main);
border: 1px solid var(--border-secondary);
border-radius: 4px;
outline: none;
padding: 0;
padding-left: 0.5rem;
cursor: pointer;
transition: background-color 100ms ease;
color: var(--text-primary);
}
.combobox-trigger.open {
border-color: var(--border-primary);
}
.combobox-trigger:hover {
background-color: var(--hover-bg);
}
.combobox-trigger div {
display: flex;
justify-content: center;
align-items: center;
border-left: 1px solid var(--border-secondary);
}
.combobox-trigger.open div svg {
transform: rotate(180deg);
}
.combobox-dropdown {
position: absolute;
width: 100%;
background-color: var(--bg-main);
border: 1px solid var(--border-primary);
display: none;
flex-direction: column;
z-index: 100;
border-radius: 4px;
box-shadow: 0 10px 25px var(--shadow-color);
}
.combobox-dropdown.open {
display: flex;
}
.combobox-option {
padding: 0.35rem 0.5rem;
cursor: pointer;
color: var(--text-primary);
font-size: 0.825rem;
text-decoration: none;
}
.combobox-option.selected {
background-color: var(--selected-bg);
}
.combobox-option:hover {
background-color: var(--accent-primary);
color: var(--accent-secondary);
}
#watermark {
position: absolute;
bottom: 1rem;
right: 1rem;
font-size: 0.875rem;
text-decoration: none;
color: var(--accent-faded);
word-break: break-word;
width: 15%;
text-align: right;
}
#watermark:hover {
color: var(--accent-primary);
opacity: 1;
}
@media (min-width: 39.625rem) {
body::before {
content: "";
position: fixed;
inset: 0;
background-image: linear-gradient(90deg, var(--border-outline) 1px, transparent 1px),
linear-gradient(var(--border-outline) 1px, transparent 1px);
background-size: 48px 48px;
background-position: center;
pointer-events: none;
}
}
@media (max-width: 39.625rem) {
body {
background-color: var(--bg-main);
}
main {
width: 100%;
}
.bars-gradient {
display: block !important;
}
#watermark {
top: 2rem;
left: 2rem;
right: unset;
width: 20%;
text-align: left;
font-size: 0.75rem;
z-index: 10;
}
#watermark span:nth-child(2) {
opacity: 0.5;
}
}
@media (max-width: 30rem) {
main {
padding: 4rem 1rem 1.5rem;
}
#caption {
align-items: start;
}
#legend {
gap: 0.5rem;
flex-direction: column;
margin-top: 0.25rem;
}
/* places tooltip in middle of screen, allows small screens to see tooltip without getting cut off */
.bars > div {
position: static;
}
.uptime-percentage {
transform: translateX(0%);
}
#watermark {
left: 1.5rem;
}
}
</style>
<link rel="preload" as="style" href="/themes/{{ .Theme }}.css" />
<link rel="stylesheet" href="/themes/{{ .Theme }}.css" />
</head>
<body>
<main>
<div id="emoticon">
<span>{{ if .IsOperational }} ( • ⩊ • ) {{ else }} ( 𖦹 𖦹 ) {{ end }}</span>
</div>
<h1>status — {{ .Title }}</h1>
{{ if .EnableThemeSwitcher }}
<div class="combobox" style="position: absolute; right: 2rem; top: 2rem">
<button class="combobox-trigger">
<span
>{{ if eq .Theme "color-dark" }} color dark {{ else if eq .Theme "ctp-latte" }} ctp latte {{ else if eq .Theme "ctp-frappe" }} ctp frappé
{{ else if eq .Theme "ctp-macchiato" }} ctp macchiato {{ else if eq .Theme "ctp-mocha" }} ctp mocha {{ else }} {{ .Theme }} {{ end
}}</span
>
<!-- arrow icon -->
<div>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m7 10l5 5m0 0l5-5" />
</svg>
</div>
</button>
<div class="combobox-dropdown" style="top: calc(100% + 0.25rem)">
<a href="/?theme=monochrome&view={{ .View }}" class='combobox-option {{ if eq .Theme "monochrome" }}selected{{ end }}'>monochrome</a>
<a href="/?theme=inverted&view={{ .View }}" class='combobox-option {{ if eq .Theme "inverted" }}selected{{ end }}'>inverted</a>
<a href="/?theme=color&view={{ .View }}" class='combobox-option {{ if eq .Theme "color" }}selected{{ end }}'>color</a>
<a href="/?theme=color-dark&view={{ .View }}" class='combobox-option {{ if eq .Theme "color-dark" }}selected{{ end }}'>color dark</a>
<a href="/?theme=ctp-latte&view={{ .View }}" class='combobox-option {{ if eq .Theme "ctp-latte" }}selected{{ end }}'>ctp latte</a>
<a href="/?theme=ctp-frappe&view={{ .View }}" class='combobox-option {{ if eq .Theme "ctp-frappe" }}selected{{ end }}'>ctp frappé</a>
<a href="/?theme=ctp-macchiato&view={{ .View }}" class='combobox-option {{ if eq .Theme "ctp-macchiato" }}selected{{ end }}'
>ctp macchiato</a
>
<a href="/?theme=ctp-mocha&view={{ .View }}" class='combobox-option {{ if eq .Theme "ctp-mocha" }}selected{{ end }}'>ctp mocha</a>
</div>
</div>
{{ end }}
<div style="display: flex">
<div id="overall-status">
<span style="font-weight: 500; margin-right: 0.15rem">{{ if .IsOperational }} ✔ {{ else }} ✖ {{ end }}</span>
<span>{{ if .IsOperational }} All services are operational! {{ else }} We're experiencing some problems! {{ end }}</span>
</div>
<div id="pattern"></div>
</div>
<div id="caption">
<p id="last-updated">Last updated: ? ago</p>
<div id="legend">
<div class="legend-item">
<div class="status-bar Offline"></div>
<span>Offline</span>
</div>
<div class="legend-item">
<div class="status-bar Degraded" style="background-position: 3px 3px"></div>
<span>Degraded</span>
</div>
<div class="legend-item">
<div class="status-bar Online"></div>
<span>Online</span>
</div>
</div>
</div>
<div id="services">
{{ range $serviceIndex, $serviceStatus := .Services }}
<div>
<div class="info">
<a href="{{ $serviceStatus.Url }}" class="service-name">
{{ $serviceStatus.Name }}
<!-- link icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 256 256">
<path
fill="currentColor"
d="M228 104a12 12 0 0 1-24 0V69l-59.51 59.51a12 12 0 0 1-17-17L187 52h-35a12 12 0 0 1 0-24h64a12 12 0 0 1 12 12Zm-44 24a12 12 0 0 0-12 12v64H52V84h64a12 12 0 0 0 0-24H48a20 20 0 0 0-20 20v128a20 20 0 0 0 20 20h128a20 20 0 0 0 20-20v-68a12 12 0 0 0-12-12"
/>
</svg>
</a>
<span class="status">{{ $serviceStatus.Status | ToUpper }}</span>
</div>
<hr />
<div class="bars-container">
{{ if eq $.View "minutes" }}
<div class="bars">
<div class="bars-gradient"></div>
{{ range $timelineIndex, $timelineStatus := $serviceStatus.MinuteTimeline }}
<div tabindex="0" data-tooltip="{{ $timelineStatus.FormattedTime }} • {{ $timelineStatus.Status }}">
<div class="status-bar {{ $timelineStatus.Status }}"></div>
</div>
{{ end }}
</div>
<div class="bars-footer">
<span class="timeline-length">30 minutes ago</span>
<hr />
<span class="uptime-percentage">{{ $serviceStatus.MinuteUptime }}% uptime</span>
<span>now</span>
</div>
{{ else if eq $.View "hours" }}
<div class="bars">
<div class="bars-gradient"></div>
{{ range $timelineIndex, $timelineStatus := $serviceStatus.HourTimeline }}
<div tabindex="0" data-tooltip="{{ $timelineStatus.FormattedTime }} • {{ $timelineStatus.Status }}">
<div class="status-bar {{ $timelineStatus.Status }}"></div>
</div>
{{ end }}
</div>
<div class="bars-footer">
<span class="timeline-length">24 hours ago</span>
<hr />
<span class="uptime-percentage">{{ $serviceStatus.HourUptime }}% uptime</span>
<span>now</span>
</div>
{{ else }}
<div class="bars">
<div class="bars-gradient"></div>
{{ range $timelineIndex, $timelineStatus := $serviceStatus.DayTimeline }}
<div tabindex="0" data-tooltip="{{ $timelineStatus.FormattedTime }} • {{ $timelineStatus.Status }}">
<div class="status-bar {{ $timelineStatus.Status }}"></div>
</div>
{{ end }}
</div>
<div class="bars-footer">
<span class="timeline-length">30 days ago</span>
<hr />
<span class="uptime-percentage">{{ $serviceStatus.DayUptime }}% uptime</span>
<span>today</span>
</div>
{{ end }}
</div>
</div>
{{ else }}
<p>No services configured.</p>
{{ end }}
</div>
<footer>
<div class="combobox">
<button class="combobox-trigger">
<span>{{ if eq .View "minutes" }} 30 minutes {{ else if eq .View "hours" }} 24 hours {{ else }} 30 days {{ end }}</span>
<!-- arrow icon -->
<div>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m7 10l5 5m0 0l5-5" />
</svg>
</div>
</button>
<div class="combobox-dropdown" style="bottom: calc(100% + 0.25rem)">
<a href="/?view=minutes&theme={{ .Theme }}" class='combobox-option {{ if eq .View "minutes" }}selected{{ end }}'>30 minutes</a>
<a href="/?view=hours&theme={{ .Theme }}" class='combobox-option {{ if eq .View "hours" }}selected{{ end }}'>24 hours</a>
<a href="/?view=days&theme={{ .Theme }}" class='combobox-option {{ if eq .View "days" }}selected{{ end }}'>30 days</a>
</div>
</div>
<a href="{{ .LinkUrl }}">{{ .LinkText }}</a>
</footer>
</main>
{{ if .EnableWatermark }}
<a id="watermark" href="https://github.com/trafficlunar/statsys">
<span>powered by</span>
<span style="font-family: 'JetBrains Mono', monospace; font-weight: 700; color: var(--accent-primary)">statsys</span>
</a>
{{ end }}
<script>
// responsiveness
const timelineLengthTexts = document.querySelectorAll(".timeline-length");
function resizeUpdate() {
timelineLengthTexts.forEach((t) => {
const amountOfBars = Math.min(Math.floor(t.parentElement.clientWidth / 19), "{{ .View }}" === "hours" ? 24 : 30); // each bar is 19px
t.textContent = `${amountOfBars} {{ .View }} ago`;
});
}
resizeUpdate();
window.addEventListener("resize", resizeUpdate);
// last updated text
const lastUpdatedText = document.querySelector("#last-updated");
function update() {
const seconds = Math.floor((Date.now() - new Date(Number("{{ .LastUpdated }}"))) / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(seconds / 3600);
const days = Math.floor(seconds / 86400);
if (days >= 1) {
lastUpdatedText.textContent = `Last updated: ${days}d ago`;
} else if (hours >= 1) {
lastUpdatedText.textContent = `Last updated: ${hours}h ago`;
} else if (minutes >= 1) {
lastUpdatedText.textContent = `Last updated: ${minutes}m ago`;
} else {
lastUpdatedText.textContent = `Last updated: now`;
}
}
update();
setInterval(update, 60000);
// combobox
const triggers = document.querySelectorAll(".combobox-trigger");
triggers.forEach((trigger) => {
trigger.addEventListener("click", () => {
const parent = trigger.parentElement;
const dropdown = parent.querySelector(".combobox-dropdown");
trigger.classList.toggle("open");
dropdown.classList.toggle("open");
});
});
</script>
</body>
</html>