statsys/www/index.html
trafficlunar def80333da feat: page style changes
- use CSS variables for colors
- attempt to fix mobile support (tooltips are still kinda broken)
- use 1 decimal place for uptime percentages
2025-12-16 22:34:47 +00:00

742 lines
18 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" />
<title>status - {{ .Title }}</title>
<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;
}
.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 some inconsistencies */
transform: translateX(0px);
}
.bars > div:hover .status-bar {
transform: translateY(-3px);
filter: brightness(0.9);
}
.bars > div:hover .status-bar::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: var(--tooltip-left, 50%);
margin-bottom: 0.5rem;
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;
font-size: 0.75rem;
transform: translateX(-50%);
white-space: nowrap;
z-index: 10;
}
.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: 0;
bottom: 0;
width: 2.5rem !important;
height: 3rem !important;
background: linear-gradient(to left, transparent, var(--bg-main));
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);
}
@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-container {
overflow-x: hidden;
padding-top: 3rem;
margin-top: -3rem;
}
.bars-gradient {
display: block !important;
}
}
@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;
}
.bars > div:hover .status-bar::after {
position: fixed;
left: 50vw;
transform: translateX(-50%);
bottom: 4rem;
}
.uptime-percentage {
transform: translateX(0%);
}
}
</style>
<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>
<div
class="status-bar {{ $timelineStatus.Status }}"
data-tooltip="{{ $timelineStatus.FormattedTime }} • {{ $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>
<div
class="status-bar {{ $timelineStatus.Status }}"
data-tooltip="{{ $timelineStatus.FormattedTime }} • {{ $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>
<div
class="status-bar {{ $timelineStatus.Status }}"
data-tooltip="{{ $timelineStatus.FormattedTime }} • {{ $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>
<script>
// responsiveness
const timelineLengthTexts = document.querySelectorAll(".timeline-length");
function resizeUpdate() {
timelineLengthTexts.forEach((t) => {
const amountOfBars = Math.ceil(t.parentElement.clientWidth / 19); // 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>