mirror of
https://github.com/trafficlunar/statsys.git
synced 2026-03-28 11:13:17 +00:00
659 lines
15 KiB
HTML
659 lines
15 KiB
HTML
<!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>
|
||
* {
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
main {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
width: 100%;
|
||
height: max-content;
|
||
min-height: 100%;
|
||
max-width: 40rem;
|
||
padding: 4rem 2rem 1.5rem;
|
||
background-color: white;
|
||
z-index: 1;
|
||
outline: 1px solid rgba(0, 0, 0, 0.04);
|
||
}
|
||
|
||
h1 {
|
||
font-weight: 800;
|
||
margin-top: 1rem;
|
||
margin-bottom: 1rem;
|
||
font-size: 1.5rem;
|
||
}
|
||
|
||
hr {
|
||
border: 0;
|
||
border-bottom: 2px dotted rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
p {
|
||
margin-top: 4px;
|
||
line-height: 1.5rem;
|
||
}
|
||
|
||
#emoticon {
|
||
display: flex;
|
||
align-items: end;
|
||
height: 4.375rem;
|
||
}
|
||
|
||
#emoticon span {
|
||
font-size: 3rem;
|
||
font-weight: 700;
|
||
}
|
||
|
||
#overall-status {
|
||
background-color: black;
|
||
color: white;
|
||
padding: 0.5rem;
|
||
font-size: 0.875rem;
|
||
width: fit-content;
|
||
}
|
||
|
||
#pattern {
|
||
flex-grow: 1;
|
||
background-image: repeating-linear-gradient(-45deg, #000, #000 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: rgba(0, 0, 0, 0.6);
|
||
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: black;
|
||
font-weight: 600;
|
||
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.2rem;
|
||
}
|
||
|
||
.service-name svg {
|
||
height: 1rem;
|
||
color: rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
.service-name:hover svg {
|
||
color: rgba(0, 0, 0, 0.8);
|
||
}
|
||
|
||
.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: calc(100% / 30);
|
||
height: 2.5rem;
|
||
position: relative;
|
||
}
|
||
|
||
.status-bar {
|
||
background-color: transparent;
|
||
border: 1px solid black;
|
||
width: calc(100% - 0.188rem);
|
||
height: 100%;
|
||
transition: transform 100ms ease-in-out;
|
||
position: relative;
|
||
z-index: 0;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.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: 50%;
|
||
|
||
margin-bottom: 0.5rem;
|
||
font-family: "JetBrains Mono", monospace;
|
||
font-weight: 600;
|
||
|
||
background-color: black;
|
||
color: white;
|
||
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: black;
|
||
}
|
||
|
||
.status-bar.Unknown {
|
||
border: 0;
|
||
}
|
||
|
||
.status-bar.Unknown::before {
|
||
content: "";
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 100%;
|
||
background: rgb(235, 235, 235);
|
||
position: absolute;
|
||
inset: 0;
|
||
margin: auto;
|
||
}
|
||
|
||
.status-bar.Degraded {
|
||
background-color: white;
|
||
background-image: linear-gradient(45deg, black 25%, transparent 25%, transparent 50%, black 50%, black 75%, transparent 75%, transparent);
|
||
background-size: 8px 8px;
|
||
}
|
||
|
||
.status-bar.Offline::before {
|
||
content: "✕";
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 2rem;
|
||
font-weight: 700;
|
||
color: black;
|
||
pointer-events: none;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.bars-footer {
|
||
position: relative;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 0.75rem;
|
||
font-family: "JetBrains Mono", monospace;
|
||
margin-top: 0.5rem;
|
||
color: rgba(0, 0, 0, 0.4);
|
||
width: 100%;
|
||
}
|
||
|
||
.bars-footer span {
|
||
padding: 0 0.5rem;
|
||
}
|
||
|
||
.bars-footer hr {
|
||
flex-grow: 1;
|
||
border-color: rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.uptime-percentage {
|
||
position: absolute;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background-color: white;
|
||
color: rgba(0, 0, 0, 0.5);
|
||
}
|
||
|
||
footer {
|
||
margin-top: auto;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
footer a {
|
||
color: rgba(0, 0, 0, 0.3);
|
||
font-size: 0.875rem;
|
||
text-decoration: none;
|
||
}
|
||
|
||
footer a:hover {
|
||
color: black;
|
||
}
|
||
|
||
.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: white;
|
||
border: 1px solid rgba(0, 0, 0, 0.5);
|
||
border-radius: 4px;
|
||
outline: none;
|
||
padding: 0;
|
||
padding-left: 0.5rem;
|
||
|
||
cursor: pointer;
|
||
transition: background-color 100ms ease;
|
||
}
|
||
|
||
.combobox-trigger:hover {
|
||
background-color: rgba(0, 0, 0, 0.03);
|
||
}
|
||
|
||
.combobox-trigger div {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
border-left: 1px solid rgba(0, 0, 0, 0.5);
|
||
}
|
||
|
||
.combobox-trigger.open div svg {
|
||
transform: rotate(180deg);
|
||
}
|
||
|
||
.combobox-dropdown {
|
||
position: absolute;
|
||
width: 100%;
|
||
background-color: white;
|
||
border: 1px solid black;
|
||
display: none;
|
||
flex-direction: column;
|
||
z-index: 100;
|
||
border-radius: 4px;
|
||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.combobox-dropdown.open {
|
||
display: flex;
|
||
}
|
||
|
||
.combobox-option {
|
||
padding: 0.35rem 0.5rem;
|
||
cursor: pointer;
|
||
color: black;
|
||
font-size: 0.825rem;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.combobox-option.selected {
|
||
background-color: rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
.combobox-option:hover {
|
||
background-color: black;
|
||
color: white;
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
main {
|
||
padding: 3rem 1rem 2rem;
|
||
}
|
||
|
||
#emoticon {
|
||
font-size: 2.5rem;
|
||
}
|
||
|
||
h1 {
|
||
font-size: 1.25rem;
|
||
}
|
||
|
||
h2 {
|
||
font-size: 1.125rem;
|
||
}
|
||
|
||
#overall-status {
|
||
font-size: 0.8125rem;
|
||
}
|
||
|
||
.status {
|
||
font-size: 0.6875rem;
|
||
}
|
||
|
||
.bars {
|
||
gap: 0.15rem;
|
||
}
|
||
|
||
.status-bar {
|
||
height: 2rem;
|
||
}
|
||
|
||
.legend-item {
|
||
font-size: 0.6875rem;
|
||
}
|
||
|
||
.legend-item .status-bar {
|
||
width: 0.875rem;
|
||
height: 0.875rem;
|
||
}
|
||
|
||
.bars-footer {
|
||
font-size: 0.6875rem;
|
||
}
|
||
|
||
#legend {
|
||
gap: 0.75rem;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 480px) {
|
||
main {
|
||
padding: 2rem 0.875rem 1.5rem;
|
||
}
|
||
|
||
#emoticon {
|
||
font-size: 2rem;
|
||
}
|
||
|
||
h1 {
|
||
font-size: 1.125rem;
|
||
}
|
||
|
||
h2 {
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.bars {
|
||
gap: 0.125rem;
|
||
}
|
||
|
||
.status-bar {
|
||
height: 1.75rem;
|
||
}
|
||
|
||
.status-bar:hover::after {
|
||
font-size: 0.6rem;
|
||
padding: 0.35rem 0.4rem;
|
||
}
|
||
|
||
#services {
|
||
gap: 2rem;
|
||
}
|
||
}
|
||
</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">
|
||
{{ 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>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">
|
||
{{ 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>24 hours ago</span>
|
||
<hr />
|
||
<span class="uptime-percentage">{{ $serviceStatus.HourUptime }}% uptime</span>
|
||
<span>now</span>
|
||
</div>
|
||
|
||
{{ else }}
|
||
<div class="bars">
|
||
{{ 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>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>
|
||
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>
|