mirror of
https://github.com/dcarrillo/atalaya.git
synced 2026-04-18 02:24:05 +00:00
44
status-page/astro.config.mjs
Normal file
44
status-page/astro.config.mjs
Normal file
@@ -0,0 +1,44 @@
|
||||
import {defineConfig} from 'astro/config';
|
||||
import cloudflare from '@astrojs/cloudflare';
|
||||
|
||||
// Workaround for Astro 6.1 + @astrojs/cloudflare 13.1 build bug.
|
||||
// Astro's static-build.js creates the SSR environment config with only
|
||||
// rollupOptions.output, dropping the rollupOptions.input that
|
||||
// @cloudflare/vite-plugin sets. This plugin restores the input via
|
||||
// configEnvironment (which runs after all config merging).
|
||||
// TODO: remove once fixed upstream in @astrojs/cloudflare or astro.
|
||||
function fixBuildRollupInput() {
|
||||
return {
|
||||
name: 'fix-build-rollup-input',
|
||||
enforce: 'post',
|
||||
configEnvironment(name, options) {
|
||||
if (name === 'ssr' && !options.build?.rollupOptions?.input) {
|
||||
return {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {index: 'virtual:cloudflare/worker-entry'},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
output: 'server',
|
||||
adapter: cloudflare({prerenderEnvironment: 'node'}),
|
||||
vite: {
|
||||
plugins: [fixBuildRollupInput()],
|
||||
optimizeDeps: {
|
||||
exclude: ['cookie'],
|
||||
},
|
||||
environments: {
|
||||
ssr: {
|
||||
optimizeDeps: {
|
||||
exclude: ['cookie'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
12
status-page/eslint.config.js
Normal file
12
status-page/eslint.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import eslintPluginAstro from 'eslint-plugin-astro';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
|
||||
export default [
|
||||
...eslintPluginAstro.configs.recommended,
|
||||
{
|
||||
files: ['src/**/*.ts'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
},
|
||||
},
|
||||
];
|
||||
30
status-page/package.json
Normal file
30
status-page/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "atalaya-prod-status-page",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"deploy": "echo 'Deploy from root: npm run deploy'",
|
||||
"typecheck": "astro check && tsc --noEmit",
|
||||
"check": "astro check && tsc --noEmit && eslint",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.8",
|
||||
"@astrojs/cloudflare": "^13.1.7",
|
||||
"astro": "^6.1.4",
|
||||
"uplot": "^1.6.31"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/parser": "^8.58.1",
|
||||
"astro-eslint-parser": "^1.4.0",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-plugin-astro": "^1.7.0",
|
||||
"typescript": "^6.0.0"
|
||||
}
|
||||
}
|
||||
12
status-page/public/favicon.svg
Normal file
12
status-page/public/favicon.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<defs>
|
||||
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#60a5fa" />
|
||||
<stop offset="100%" stop-color="#10b981" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="8" fill="#0a0f1a" />
|
||||
<path d="M8 8 L24 8 L24 24 L8 24 Z" fill="none" stroke="url(#gradient)" stroke-width="2" stroke-linecap="round" />
|
||||
<circle cx="16" cy="16" r="4" fill="url(#gradient)" />
|
||||
<path d="M12 12 L20 20 M20 12 L12 20" stroke="url(#gradient)" stroke-width="1.5" stroke-linecap="round" opacity="0.7" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 654 B |
25
status-page/src/components/Footer.astro
Normal file
25
status-page/src/components/Footer.astro
Normal file
@@ -0,0 +1,25 @@
|
||||
<footer class="footer">
|
||||
Powered by <a href="https://github.com/dcarrillo/atalaya" target="_blank" rel="noopener noreferrer">Atalaya</a>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.footer {
|
||||
margin-top: 32px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
color: var(--text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
317
status-page/src/components/Header.astro
Normal file
317
status-page/src/components/Header.astro
Normal file
@@ -0,0 +1,317 @@
|
||||
---
|
||||
interface Props {
|
||||
summary: {
|
||||
total: number;
|
||||
operational: number;
|
||||
down: number;
|
||||
};
|
||||
lastUpdated: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { summary, lastUpdated, title } = Astro.props;
|
||||
|
||||
function formatAbsoluteTime(unixTimestamp: number): string {
|
||||
const date = new Date(unixTimestamp * 1000);
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
}
|
||||
|
||||
const absoluteTime = formatAbsoluteTime(lastUpdated);
|
||||
---
|
||||
|
||||
<header class="header">
|
||||
<div class="header-top">
|
||||
<h1>{title}</h1>
|
||||
<button class="theme-toggle" aria-label="Toggle theme" title="Toggle light/dark theme">
|
||||
<svg class="theme-icon sun" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<line x1="12" y1="1" x2="12" y2="3"/>
|
||||
<line x1="12" y1="21" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="3" y2="12"/>
|
||||
<line x1="21" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
<svg class="theme-icon moon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="header-updated">
|
||||
<span class="pulse-dot"></span>
|
||||
Updated {absoluteTime}
|
||||
</div>
|
||||
<div class="summary-counts">
|
||||
<span class="count-pill count-up">
|
||||
<span class="count-dot count-dot-up"></span>
|
||||
{summary.operational} Operational
|
||||
</span>
|
||||
{summary.down > 0 && (
|
||||
<span class="count-pill count-down">
|
||||
<span class="count-dot count-dot-down"></span>
|
||||
{summary.down} Down
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<script>
|
||||
// Theme toggle functionality
|
||||
(function() {
|
||||
const toggle = document.querySelector('.theme-toggle');
|
||||
if (!toggle) return;
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const current = document.documentElement.getAttribute('data-theme');
|
||||
const newTheme = current === 'light' ? 'dark' : 'light';
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
localStorage.setItem('atalaya-theme', newTheme);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-12);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: calc(var(--space-8) * -1);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 200px;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-md);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-4xl);
|
||||
font-weight: var(--font-bold);
|
||||
margin: 0;
|
||||
font-feature-settings: 'case' 1;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: var(--leading-tight);
|
||||
color: var(--accent); /* Fallback for browsers that don't support background-clip */
|
||||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-gradient) 100%);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* Fallback for browsers that don't support background-clip: text */
|
||||
@supports not (background-clip: text) {
|
||||
h1 {
|
||||
background: none;
|
||||
-webkit-text-fill-color: initial;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
width: 2.25rem; /* 36px - smaller but still accessible */
|
||||
height: 2.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
transition: var(--transition-normal);
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
|
||||
.theme-toggle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
transition: opacity var(--transition-normal), transform var(--transition-normal);
|
||||
}
|
||||
|
||||
.theme-icon.moon {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
.theme-icon.sun {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .theme-icon.moon {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .theme-icon.sun {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
.header-updated {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-5);
|
||||
letter-spacing: 0.02em;
|
||||
font-family: var(--font-family-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pulse-dot {
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
border-radius: 50%;
|
||||
background: var(--up);
|
||||
box-shadow: 0 0 0.375rem var(--up-glow);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.pulse-dot {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.summary-counts {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.count-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-1) var(--space-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
font-family: var(--font-family-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.count-up {
|
||||
background: var(--up-bg);
|
||||
color: var(--up);
|
||||
border: 1px solid rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
.count-down {
|
||||
background: var(--down-bg);
|
||||
color: var(--down);
|
||||
border: 1px solid rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
.count-dot {
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.count-dot-up {
|
||||
background: var(--up);
|
||||
}
|
||||
|
||||
.count-dot-down {
|
||||
background: var(--down);
|
||||
}
|
||||
|
||||
/* Mobile styles */
|
||||
@media (max-width: 640px) {
|
||||
.header-top {
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
right: -0.5rem; /* Adjust position for mobile */
|
||||
}
|
||||
|
||||
.header-updated {
|
||||
font-size: var(--text-xs);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.summary-counts {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.count-pill {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
}
|
||||
|
||||
/* Extra small screens */
|
||||
@media (max-width: 375px) {
|
||||
h1 {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
|
||||
.summary-counts {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.count-pill {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
325
status-page/src/components/MonitorCard.astro
Normal file
325
status-page/src/components/MonitorCard.astro
Normal file
@@ -0,0 +1,325 @@
|
||||
---
|
||||
import type { ApiMonitorStatus } from '@worker/types';
|
||||
import UptimeBars from './UptimeBars.astro';
|
||||
|
||||
interface Props {
|
||||
monitor: ApiMonitorStatus;
|
||||
}
|
||||
|
||||
const { monitor } = Astro.props;
|
||||
|
||||
const uptimeFormatted = monitor.uptimePercent.toFixed(2);
|
||||
|
||||
function formatLastChecked(timestamp: number | undefined): string {
|
||||
if (timestamp == null) return 'Never';
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const diffSeconds = now - timestamp;
|
||||
|
||||
if (diffSeconds < 60) return '';
|
||||
if (diffSeconds < 3600) {
|
||||
const minutes = Math.floor(diffSeconds / 60);
|
||||
return `${minutes}m ago`;
|
||||
}
|
||||
if (diffSeconds < 86400) {
|
||||
const hours = Math.floor(diffSeconds / 3600);
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
const days = Math.floor(diffSeconds / 86400);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
const lastCheckedText = formatLastChecked(monitor.lastChecked);
|
||||
|
||||
const chartData = JSON.stringify({
|
||||
timestamps: monitor.recentChecks.map(c => c.timestamp),
|
||||
responseTimes: monitor.recentChecks.map(c => c.responseTimeMs),
|
||||
statuses: monitor.recentChecks.map(c => c.status),
|
||||
});
|
||||
---
|
||||
|
||||
<article class="monitor-card" aria-labelledby={`monitor-${monitor.name.replace(/\s+/g, '-').toLowerCase()}-title`}>
|
||||
<div class="monitor-head">
|
||||
<div
|
||||
class:list={['status-dot', `status-dot-${monitor.status}`]}
|
||||
role="status"
|
||||
aria-label={`Status: ${monitor.status === 'up' ? 'Operational' : monitor.status === 'down' ? 'Down' : 'Unknown'}`}
|
||||
title={`${monitor.status === 'up' ? 'Operational' : monitor.status === 'down' ? 'Down' : 'Unknown'}`}
|
||||
></div>
|
||||
<h3 class="monitor-name" id={`monitor-${monitor.name.replace(/\s+/g, '-').toLowerCase()}-title`} title={monitor.name}>{monitor.name}</h3>
|
||||
<span class:list={['monitor-uptime', `uptime-${monitor.status}`]}>{uptimeFormatted}%</span>
|
||||
<span class="monitor-meta">{lastCheckedText}</span>
|
||||
</div>
|
||||
|
||||
<UptimeBars dailyHistory={monitor.dailyHistory} />
|
||||
|
||||
<div class="chart-section">
|
||||
<div class="chart-labels">
|
||||
<span class="section-label">Response time</span>
|
||||
<span class="section-label">24h</span>
|
||||
</div>
|
||||
<div class="chart-container" data-monitor={monitor.name}>
|
||||
<script is:inline type="application/json" set:html={chartData} />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.monitor-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
transition: all var(--transition-normal) cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.monitor-card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--shadow-xl);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.monitor-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, var(--accent), transparent);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-normal);
|
||||
}
|
||||
|
||||
.monitor-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.monitor-card.status-down {
|
||||
/* Status indicated by status dot and uptime color */
|
||||
}
|
||||
|
||||
.monitor-card.status-unknown {
|
||||
/* Status indicated by status dot and uptime color */
|
||||
}
|
||||
|
||||
.monitor-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.status-dot::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 50%;
|
||||
opacity: 0.3;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.status-dot-up {
|
||||
background: var(--up);
|
||||
box-shadow: 0 0 16px var(--up-glow), 0 0 32px var(--up-glow);
|
||||
}
|
||||
|
||||
.status-dot-up::after {
|
||||
background: var(--up);
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-dot-down {
|
||||
background: var(--down);
|
||||
box-shadow: 0 0 16px var(--down-glow), 0 0 32px var(--down-glow);
|
||||
}
|
||||
|
||||
.status-dot-down::after {
|
||||
background: var(--down);
|
||||
animation: pulse-glow 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-dot-unknown {
|
||||
background: var(--unknown);
|
||||
box-shadow: 0 0 16px var(--unknown-glow), 0 0 32px var(--unknown-glow);
|
||||
}
|
||||
|
||||
.status-dot-unknown::after {
|
||||
background: var(--unknown);
|
||||
animation: pulse-glow 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
opacity: 0.3;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: translate(-50%, -50%) scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.monitor-name {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text);
|
||||
word-break: break-word;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
|
||||
.monitor-uptime {
|
||||
margin-left: auto;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: var(--font-bold);
|
||||
letter-spacing: -0.03em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: var(--font-family-mono);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
|
||||
.uptime-up {
|
||||
color: var(--up);
|
||||
}
|
||||
|
||||
.uptime-down {
|
||||
color: var(--down);
|
||||
}
|
||||
|
||||
.uptime-unknown {
|
||||
color: var(--unknown);
|
||||
}
|
||||
|
||||
.monitor-meta {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-dim);
|
||||
margin-left: var(--space-md);
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-family-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
/* last element, no bottom margin */
|
||||
}
|
||||
|
||||
.chart-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-family: var(--font-family-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
height: 7.5rem;
|
||||
background: var(--bg-inset);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
touch-action: pan-y; /* Allow vertical scrolling only */
|
||||
position: relative; /* For loading overlay positioning */
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.monitor-card {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.monitor-head {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
order: -1; /* Move status dot to beginning */
|
||||
}
|
||||
|
||||
.status-dot::after {
|
||||
width: 1.875rem;
|
||||
height: 1.875rem;
|
||||
}
|
||||
|
||||
.monitor-name {
|
||||
font-size: var(--text-sm);
|
||||
flex: 1;
|
||||
min-width: 0; /* Allow text truncation */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.monitor-uptime {
|
||||
font-size: var(--text-lg);
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
order: 1; /* Move uptime to after name */
|
||||
text-align: right;
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.monitor-meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 5rem;
|
||||
margin-top: var(--space-3);
|
||||
position: relative; /* For loading overlay positioning */
|
||||
}
|
||||
|
||||
.chart-labels {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
}
|
||||
|
||||
/* Extra small screens (320px - 375px) */
|
||||
@media (max-width: 375px) {
|
||||
.monitor-card {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.monitor-name {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.monitor-uptime {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 4rem;
|
||||
position: relative; /* For loading overlay positioning */
|
||||
}
|
||||
}
|
||||
</style>
|
||||
147
status-page/src/components/UptimeBars.astro
Normal file
147
status-page/src/components/UptimeBars.astro
Normal file
@@ -0,0 +1,147 @@
|
||||
---
|
||||
import type { ApiDayStatus } from '@worker/types';
|
||||
|
||||
interface Props {
|
||||
dailyHistory: ApiDayStatus[];
|
||||
}
|
||||
|
||||
const { dailyHistory } = Astro.props;
|
||||
|
||||
function getBarColor(uptimePercent: number | undefined): string {
|
||||
if (uptimePercent == null) return 'no-data';
|
||||
if (uptimePercent >= 99.8) return 'up';
|
||||
if (uptimePercent >= 95) return 'degraded';
|
||||
return 'down';
|
||||
}
|
||||
---
|
||||
|
||||
<div class="uptime-section">
|
||||
<div class="section-label">90-day uptime</div>
|
||||
<div class="uptime-bars">
|
||||
{
|
||||
dailyHistory.map(day => {
|
||||
const color = getBarColor(day.uptimePercent);
|
||||
const tooltip =
|
||||
day.uptimePercent == null
|
||||
? `${day.date}: No data`
|
||||
: `${day.date}: ${day.uptimePercent.toFixed(1)}%`;
|
||||
return (
|
||||
<div
|
||||
class:list={['bar', `bar-${color}`]}
|
||||
data-tooltip={tooltip}
|
||||
aria-label={tooltip}
|
||||
role="img"
|
||||
tabindex="0"
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
<div class="bar-labels">
|
||||
<span>90d ago</span>
|
||||
<span>Today</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.uptime-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.uptime-bars {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
width: 100%;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
flex: 1;
|
||||
border-radius: 2px;
|
||||
opacity: 0.65;
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
cursor: help;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bar:hover {
|
||||
opacity: 1;
|
||||
transform: scaleY(1.3);
|
||||
}
|
||||
|
||||
.bar-up {
|
||||
background: var(--up);
|
||||
}
|
||||
|
||||
.bar-degraded {
|
||||
background: var(--degraded);
|
||||
}
|
||||
|
||||
.bar-down {
|
||||
background: var(--down);
|
||||
}
|
||||
|
||||
.bar-no-data {
|
||||
background: var(--no-data);
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
.bar::before {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-8px);
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-inner);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s ease;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.bar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-2px);
|
||||
border: 4px solid transparent;
|
||||
border-top-color: var(--text);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s ease;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.bar:hover::before,
|
||||
.bar:focus::before,
|
||||
.bar:hover::after,
|
||||
.bar:focus::after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.bar-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 3px;
|
||||
}
|
||||
</style>
|
||||
11
status-page/src/env.d.ts
vendored
Normal file
11
status-page/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
/// <reference types="astro/client" />
|
||||
|
||||
// Env bindings available via `import { env } from 'cloudflare:workers'`.
|
||||
// Extends the Cloudflare.Env declared in @cloudflare/workers-types.
|
||||
// Must match the D1 binding declared in the root wrangler.toml.
|
||||
declare namespace Cloudflare {
|
||||
type Env = {
|
||||
DB: D1Database;
|
||||
};
|
||||
}
|
||||
85
status-page/src/layouts/Layout.astro
Normal file
85
status-page/src/layouts/Layout.astro
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const { title, description = 'Real-time uptime monitoring dashboard' } = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content={description} />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="mask-icon" href="/favicon.svg" color="#60a5fa" />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body>
|
||||
<slot />
|
||||
<script src="../scripts/charts.ts"></script>
|
||||
<script>
|
||||
// Theme detection and persistence
|
||||
(function() {
|
||||
const STORAGE_KEY = 'atalaya-theme';
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
function getPreferredTheme() {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === 'light' || stored === 'dark') return stored;
|
||||
return prefersDark.matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function setTheme(theme: 'light' | 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem(STORAGE_KEY, theme);
|
||||
}
|
||||
|
||||
// Set initial theme
|
||||
setTheme(getPreferredTheme());
|
||||
|
||||
// Watch for system preference changes (only when no explicit choice)
|
||||
prefersDark.addEventListener('change', (e) => {
|
||||
if (!localStorage.getItem(STORAGE_KEY)) {
|
||||
setTheme(e.matches ? 'dark' : 'light');
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style is:global>
|
||||
@import '../styles/global.css';
|
||||
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -2.5rem;
|
||||
left: 0;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
font-weight: var(--font-semibold);
|
||||
z-index: 1000;
|
||||
transition: top var(--transition-fast);
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: var(--space-4);
|
||||
left: var(--space-4);
|
||||
}
|
||||
</style>
|
||||
1
status-page/src/lib/api.ts
Normal file
1
status-page/src/lib/api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { getStatusApiData } from '../../../src/api/status.js';
|
||||
86
status-page/src/lib/auth.test.ts
Normal file
86
status-page/src/lib/auth.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { checkAuth, type AuthEnv } from './auth.js';
|
||||
|
||||
function makeRequest(authHeader?: string): Request {
|
||||
const headers = new Headers();
|
||||
if (authHeader) {
|
||||
headers.set('Authorization', authHeader);
|
||||
}
|
||||
|
||||
return new Request('https://example.com/', { headers });
|
||||
}
|
||||
|
||||
function encodeBasic(username: string, password: string): string {
|
||||
return 'Basic ' + btoa(`${username}:${password}`);
|
||||
}
|
||||
|
||||
describe('checkAuth', () => {
|
||||
it('allows access when STATUS_PUBLIC is true', async () => {
|
||||
const env: AuthEnv = { STATUS_PUBLIC: 'true' };
|
||||
const result = await checkAuth(makeRequest(), env);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns 403 when no credentials are configured', async () => {
|
||||
const env: AuthEnv = {};
|
||||
const result = await checkAuth(makeRequest(), env);
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result!.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 403 when only username is configured', async () => {
|
||||
const env: AuthEnv = { STATUS_USERNAME: 'admin' };
|
||||
const result = await checkAuth(makeRequest(), env);
|
||||
expect(result!.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 401 when no Authorization header is sent', async () => {
|
||||
const env: AuthEnv = { STATUS_USERNAME: 'admin', STATUS_PASSWORD: 'secret' };
|
||||
const result = await checkAuth(makeRequest(), env);
|
||||
expect(result!.status).toBe(401);
|
||||
expect(result!.headers.get('WWW-Authenticate')).toBe('Basic realm="Status Page"');
|
||||
});
|
||||
|
||||
it('returns 401 for non-Basic auth header', async () => {
|
||||
const env: AuthEnv = { STATUS_USERNAME: 'admin', STATUS_PASSWORD: 'secret' };
|
||||
const result = await checkAuth(makeRequest('Bearer token123'), env);
|
||||
expect(result!.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 401 for invalid base64 encoding', async () => {
|
||||
const env: AuthEnv = { STATUS_USERNAME: 'admin', STATUS_PASSWORD: 'secret' };
|
||||
const result = await checkAuth(makeRequest('Basic !!!invalid!!!'), env);
|
||||
expect(result!.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 401 for credentials without colon separator', async () => {
|
||||
const env: AuthEnv = { STATUS_USERNAME: 'admin', STATUS_PASSWORD: 'secret' };
|
||||
|
||||
const result = await checkAuth(makeRequest('Basic ' + btoa('nocolon')), env);
|
||||
expect(result!.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 401 for wrong username', async () => {
|
||||
const env: AuthEnv = { STATUS_USERNAME: 'admin', STATUS_PASSWORD: 'secret' };
|
||||
const result = await checkAuth(makeRequest(encodeBasic('wrong', 'secret')), env);
|
||||
expect(result!.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 401 for wrong password', async () => {
|
||||
const env: AuthEnv = { STATUS_USERNAME: 'admin', STATUS_PASSWORD: 'secret' };
|
||||
const result = await checkAuth(makeRequest(encodeBasic('admin', 'wrong')), env);
|
||||
expect(result!.status).toBe(401);
|
||||
});
|
||||
|
||||
it('allows access with valid credentials', async () => {
|
||||
const env: AuthEnv = { STATUS_USERNAME: 'admin', STATUS_PASSWORD: 'secret' };
|
||||
const result = await checkAuth(makeRequest(encodeBasic('admin', 'secret')), env);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles passwords containing colons', async () => {
|
||||
const env: AuthEnv = { STATUS_USERNAME: 'admin', STATUS_PASSWORD: 'pass:with:colons' };
|
||||
const result = await checkAuth(makeRequest(encodeBasic('admin', 'pass:with:colons')), env);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
77
status-page/src/lib/auth.ts
Normal file
77
status-page/src/lib/auth.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export type AuthEnv = {
|
||||
STATUS_PUBLIC?: string;
|
||||
STATUS_USERNAME?: string;
|
||||
STATUS_PASSWORD?: string;
|
||||
};
|
||||
|
||||
const unauthorizedResponse = (): Response =>
|
||||
new Response('Unauthorized', {
|
||||
status: 401,
|
||||
headers: { 'WWW-Authenticate': 'Basic realm="Status Page"' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Timing-safe string comparison using SHA-256 hashing.
|
||||
* Hashing both values to a fixed size prevents leaking length information.
|
||||
* Uses constant-time byte comparison to prevent timing side-channel attacks.
|
||||
*/
|
||||
async function timingSafeCompare(a: string, b: string): Promise<boolean> {
|
||||
const encoder = new TextEncoder();
|
||||
const [hashA, hashB] = await Promise.all([
|
||||
crypto.subtle.digest('SHA-256', encoder.encode(a)),
|
||||
crypto.subtle.digest('SHA-256', encoder.encode(b)),
|
||||
]);
|
||||
|
||||
const viewA = new Uint8Array(hashA);
|
||||
const viewB = new Uint8Array(hashB);
|
||||
|
||||
// Constant-time comparison: always check every byte
|
||||
let mismatch = 0;
|
||||
for (let i = 0; i < viewA.length; i++) {
|
||||
mismatch |= viewA[i] ^ viewB[i];
|
||||
}
|
||||
return mismatch === 0;
|
||||
}
|
||||
|
||||
export async function checkAuth(request: Request, env: AuthEnv): Promise<Response | undefined> {
|
||||
if (env.STATUS_PUBLIC === 'true') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!env.STATUS_USERNAME || !env.STATUS_PASSWORD) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
const basicAuthPrefix = 'Basic ';
|
||||
if (!authHeader?.startsWith(basicAuthPrefix)) {
|
||||
return unauthorizedResponse();
|
||||
}
|
||||
|
||||
const base64Credentials = authHeader.slice(basicAuthPrefix.length);
|
||||
let credentials: string;
|
||||
try {
|
||||
credentials = atob(base64Credentials);
|
||||
} catch {
|
||||
return unauthorizedResponse();
|
||||
}
|
||||
|
||||
const colonIndex = credentials.indexOf(':');
|
||||
if (colonIndex === -1) {
|
||||
return unauthorizedResponse();
|
||||
}
|
||||
|
||||
const username = credentials.slice(0, colonIndex);
|
||||
const password = credentials.slice(colonIndex + 1);
|
||||
|
||||
const [usernameMatch, passwordMatch] = await Promise.all([
|
||||
timingSafeCompare(username, env.STATUS_USERNAME),
|
||||
timingSafeCompare(password, env.STATUS_PASSWORD),
|
||||
]);
|
||||
|
||||
if (!usernameMatch || !passwordMatch) {
|
||||
return unauthorizedResponse();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
221
status-page/src/pages/index.astro
Normal file
221
status-page/src/pages/index.astro
Normal file
@@ -0,0 +1,221 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import Header from '../components/Header.astro';
|
||||
import MonitorCard from '../components/MonitorCard.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import { getStatusApiData } from '../lib/api.js';
|
||||
import { parseConfig } from '../../../src/config/index.js';
|
||||
import { interpolateSecrets } from '../../../src/utils/interpolate.js';
|
||||
import { env } from 'cloudflare:workers';
|
||||
|
||||
let data: Awaited<ReturnType<typeof getStatusApiData>> | null = null;
|
||||
let error: Error | null = null;
|
||||
|
||||
try {
|
||||
// TypeScript doesn't know about MONITORS_CONFIG in cloudflare:workers env
|
||||
const envAny = env as any;
|
||||
const monitorsConfig = envAny.MONITORS_CONFIG;
|
||||
if (typeof monitorsConfig !== 'string' || !monitorsConfig) {
|
||||
throw new Error('MONITORS_CONFIG environment variable is not set or is not a string');
|
||||
}
|
||||
const configYaml = interpolateSecrets(monitorsConfig, envAny);
|
||||
const config = parseConfig(configYaml);
|
||||
data = await getStatusApiData(env.DB, config);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch status data:', err);
|
||||
error = err as Error;
|
||||
}
|
||||
|
||||
// Sort: down monitors first, then unknown, then up
|
||||
const sortOrder = { down: 0, unknown: 1, up: 2 } as const;
|
||||
const sortedMonitors = data ? [...data.monitors].sort(
|
||||
(a, b) => (sortOrder[a.status] ?? 1) - (sortOrder[b.status] ?? 1)
|
||||
) : [];
|
||||
---
|
||||
|
||||
<Layout title={data!.title}>
|
||||
<main id="main-content" class="container">
|
||||
{error ? (
|
||||
<div class="error-state">
|
||||
<div class="error-icon">⚠️</div>
|
||||
<h2>Service Temporarily Unavailable</h2>
|
||||
<p>We're having trouble loading status data. Please try again in a moment.</p>
|
||||
<button class="retry-button" onclick="window.location.reload()" aria-label="Retry loading status data">Retry</button>
|
||||
</div>
|
||||
) : !data ? (
|
||||
<div class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading status data...</p>
|
||||
</div>
|
||||
) : data!.monitors.length === 0 ? (
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📊</div>
|
||||
<h2>No Monitors Configured</h2>
|
||||
<p>Add monitors to start tracking your services.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Header summary={data!.summary} lastUpdated={data!.lastUpdated} title={data!.title} />
|
||||
<div class="monitors">
|
||||
{sortedMonitors.map(monitor => <MonitorCard monitor={monitor} />)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Footer />
|
||||
</main>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
max-width: min(1000px, 90vw);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-8) var(--space-6);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.container::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image:
|
||||
linear-gradient(var(--border-subtle) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--border-subtle) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
opacity: 0.15;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .container::before {
|
||||
background-image:
|
||||
linear-gradient(var(--border) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--border) 1px, transparent 1px);
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.monitors {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
padding: var(--space-16) var(--space-12) var(--space-20);
|
||||
}
|
||||
|
||||
.monitors {
|
||||
gap: var(--space-5);
|
||||
}
|
||||
}
|
||||
|
||||
.error-state,
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--space-20) var(--space-5);
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.error-icon,
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 24px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.error-state h2,
|
||||
.empty-state h2 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.error-state p,
|
||||
.empty-state p,
|
||||
.loading-state p {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 24px;
|
||||
max-width: min(400px, 90vw);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: var(--space-3) var(--space-6);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: inherit;
|
||||
font-weight: var(--font-semibold);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.retry-button:hover {
|
||||
background: color-mix(in oklch, var(--accent), white 20%);
|
||||
}
|
||||
|
||||
.retry-button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.retry-button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.retry-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
margin: 0 auto var(--space-6);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.loading-spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.container {
|
||||
padding: var(--space-5) var(--space-4);
|
||||
}
|
||||
|
||||
.error-state,
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
padding: var(--space-12) var(--space-4);
|
||||
}
|
||||
|
||||
.error-icon,
|
||||
.empty-icon {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
|
||||
.error-state h2,
|
||||
.empty-state h2 {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
348
status-page/src/scripts/charts.ts
Normal file
348
status-page/src/scripts/charts.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import 'uplot/dist/uPlot.min.css';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
type ChartData = {
|
||||
timestamps: number[];
|
||||
responseTimes: number[];
|
||||
statuses: string[];
|
||||
};
|
||||
|
||||
// Cache for computed CSS variables to avoid layout thrashing
|
||||
const cssVarCache = new Map<string, string>();
|
||||
|
||||
function getComputedCssVar(name: string): string {
|
||||
if (cssVarCache.has(name)) {
|
||||
return cssVarCache.get(name)!;
|
||||
}
|
||||
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
cssVarCache.set(name, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
// Clear cache on theme change to get updated values
|
||||
function clearCssVarCache(): void {
|
||||
cssVarCache.clear();
|
||||
}
|
||||
|
||||
// Listen for theme changes to clear cache
|
||||
if (typeof window !== 'undefined') {
|
||||
// Check if theme attribute changes
|
||||
const observer = new MutationObserver(mutations => {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'data-theme') {
|
||||
clearCssVarCache();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, { attributes: true });
|
||||
}
|
||||
|
||||
const fmtTime = uPlot.fmtDate('{HH}:{mm}:{ss}');
|
||||
|
||||
// 24h time formatters for X-axis at different granularities
|
||||
const formatAxisHourMinute = uPlot.fmtDate('{HH}:{mm}');
|
||||
const fmtAxisDate = uPlot.fmtDate('{M}/{D}');
|
||||
|
||||
function fmtAxisValues(_u: uPlot, splits: number[], _ax: number, _space: number, incr: number) {
|
||||
const oneHour = 3600;
|
||||
const oneDay = 86_400;
|
||||
|
||||
return splits.map(v => {
|
||||
if (v === undefined || v === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const d = new Date(v * 1000);
|
||||
if (incr >= oneDay) {
|
||||
return fmtAxisDate(d);
|
||||
}
|
||||
|
||||
if (incr >= oneHour) {
|
||||
return formatAxisHourMinute(d);
|
||||
}
|
||||
|
||||
return formatAxisHourMinute(d);
|
||||
});
|
||||
}
|
||||
|
||||
function tooltipPlugin(strokeColor: string): uPlot.Plugin {
|
||||
let tooltipElement: HTMLDivElement;
|
||||
let over: HTMLElement;
|
||||
|
||||
return {
|
||||
hooks: {
|
||||
init: [
|
||||
(u: uPlot) => {
|
||||
over = u.over;
|
||||
tooltipElement = document.createElement('div');
|
||||
tooltipElement.className = 'chart-tooltip';
|
||||
tooltipElement.style.cssText = `
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
border: 1px solid ${strokeColor};
|
||||
color: #e2e8f0;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font: 500 10px 'Geist Mono', monospace;
|
||||
display: none;
|
||||
white-space: nowrap;
|
||||
z-index: 10;
|
||||
`;
|
||||
// Cast needed: @cloudflare/workers-types overrides DOM append() signature
|
||||
(over as ParentNode).append(tooltipElement);
|
||||
|
||||
over.addEventListener('mouseenter', () => {
|
||||
tooltipElement.style.display = 'block';
|
||||
});
|
||||
over.addEventListener('mouseleave', () => {
|
||||
tooltipElement.style.display = 'none';
|
||||
});
|
||||
},
|
||||
],
|
||||
setCursor: [
|
||||
(u: uPlot) => {
|
||||
const { left, top, idx } = u.cursor;
|
||||
|
||||
if (
|
||||
idx === null ||
|
||||
idx === undefined ||
|
||||
left === null ||
|
||||
left === undefined ||
|
||||
left < 0
|
||||
) {
|
||||
tooltipElement.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const xValue = u.data[0][idx];
|
||||
const yValue = u.data[1][idx];
|
||||
|
||||
if (yValue === null || yValue === undefined) {
|
||||
tooltipElement.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
tooltipElement.style.display = 'block';
|
||||
|
||||
const timeString = fmtTime(new Date(xValue * 1000));
|
||||
const msString = Math.round(yValue) + ' ms';
|
||||
tooltipElement.textContent = `${timeString} ${msString}`;
|
||||
|
||||
// Position tooltip, flipping side if near right edge
|
||||
const tipWidth = tooltipElement.offsetWidth;
|
||||
const plotWidth = over.clientWidth;
|
||||
const shiftX = 12;
|
||||
const shiftY = -10;
|
||||
let posLeft = left + shiftX;
|
||||
if (posLeft + tipWidth > plotWidth) {
|
||||
posLeft = left - tipWidth - shiftX;
|
||||
}
|
||||
|
||||
tooltipElement.style.left = posLeft + 'px';
|
||||
tooltipElement.style.top = (top ?? 0) + shiftY + 'px';
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createChart(container: HTMLElement): void {
|
||||
// Remove loading state if present
|
||||
const loadingEl = container.querySelector('.chart-loading');
|
||||
if (loadingEl) {
|
||||
loadingEl.remove();
|
||||
}
|
||||
|
||||
const scriptTag = container.querySelector('script[type="application/json"]');
|
||||
if (!scriptTag?.textContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
let data: ChartData;
|
||||
try {
|
||||
data = JSON.parse(scriptTag.textContent) as ChartData;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.timestamps.length === 0) {
|
||||
container.innerHTML =
|
||||
'<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:11px;">No data available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const upColor = getComputedCssVar('--up') || '#10b981';
|
||||
const downColor = getComputedCssVar('--down') || '#ef4444';
|
||||
const textDim = getComputedCssVar('--text-dim') || '#475569';
|
||||
|
||||
// Determine line color based on current monitor status
|
||||
const monitorCard = container.closest('.monitor-card');
|
||||
const isDown = monitorCard?.classList.contains('status-down');
|
||||
const strokeColor = isDown ? downColor : upColor;
|
||||
const fillColorRgba = isDown ? 'rgba(239, 68, 68, 0.12)' : 'rgba(16, 185, 129, 0.12)';
|
||||
const downtimeBandColor = 'rgba(239, 68, 68, 0.08)';
|
||||
|
||||
// Build downtime bands for the draw hook
|
||||
const downtimeBands: Array<[number, number]> = [];
|
||||
let bandStart: number | undefined;
|
||||
for (let i = 0; i < data.statuses.length; i++) {
|
||||
if (data.statuses[i] === 'down') {
|
||||
bandStart ??= data.timestamps[i];
|
||||
} else if (bandStart !== undefined) {
|
||||
downtimeBands.push([bandStart, data.timestamps[i]]);
|
||||
bandStart = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (bandStart !== undefined) {
|
||||
downtimeBands.push([bandStart, data.timestamps.at(-1)!]);
|
||||
}
|
||||
|
||||
const options: uPlot.Options = {
|
||||
width: container.clientWidth,
|
||||
height: container.clientHeight || 120,
|
||||
cursor: {
|
||||
show: true,
|
||||
points: { show: true, size: 6, fill: strokeColor },
|
||||
},
|
||||
legend: { show: false },
|
||||
plugins: [tooltipPlugin(strokeColor)],
|
||||
scales: {
|
||||
x: { time: true },
|
||||
y: { auto: true, range: (_u, _min, max) => [0, Math.max(max * 1.1, 100)] },
|
||||
},
|
||||
axes: [
|
||||
{
|
||||
show: true,
|
||||
stroke: textDim,
|
||||
font: '10px Geist Mono, monospace',
|
||||
size: 24,
|
||||
space: 60,
|
||||
gap: 2,
|
||||
ticks: { show: false },
|
||||
grid: { show: false },
|
||||
values: fmtAxisValues,
|
||||
},
|
||||
{
|
||||
show: true,
|
||||
stroke: textDim,
|
||||
font: '10px Geist Mono, monospace',
|
||||
size: 42,
|
||||
gap: 4,
|
||||
ticks: { show: false },
|
||||
grid: { show: true, stroke: 'rgba(255, 255, 255, 0.04)', width: 1 },
|
||||
values: (_u: uPlot, splits: number[]) =>
|
||||
splits.map(v => (v === undefined || v === null ? '' : Math.round(v) + ' ms')),
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
{
|
||||
label: 'Response Time',
|
||||
stroke: strokeColor,
|
||||
width: 1.5,
|
||||
fill: fillColorRgba,
|
||||
spanGaps: false,
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
draw: [
|
||||
(u: uPlot) => {
|
||||
const { ctx } = u;
|
||||
ctx.save();
|
||||
ctx.fillStyle = downtimeBandColor;
|
||||
for (const [start, end] of downtimeBands) {
|
||||
const x0 = u.valToPos(start, 'x', true);
|
||||
const x1 = u.valToPos(end, 'x', true);
|
||||
ctx.fillRect(x0, u.bbox.top, x1 - x0, u.bbox.height);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Build uPlot data format: [timestamps, values]
|
||||
// Replace response times for down status with undefined (gaps)
|
||||
const values: Array<number | undefined> = data.responseTimes.map((rt, i) =>
|
||||
data.statuses[i] === 'up' ? rt : undefined
|
||||
);
|
||||
|
||||
const plotData: uPlot.AlignedData = [data.timestamps, values];
|
||||
|
||||
// Clear container and create chart
|
||||
container.textContent = '';
|
||||
|
||||
const plot = new uPlot(options, plotData, container);
|
||||
|
||||
// Double-click to reset zoom
|
||||
plot.over.addEventListener('dblclick', () => {
|
||||
plot.setScale('x', {
|
||||
min: data.timestamps[0],
|
||||
max: data.timestamps.at(-1)!,
|
||||
});
|
||||
});
|
||||
|
||||
// Resize observer
|
||||
const observer = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
const { width } = entry.contentRect;
|
||||
if (width > 0) {
|
||||
plot.setSize({ width, height: entry.contentRect.height || 120 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
}
|
||||
|
||||
// Initialize charts lazily when they enter viewport
|
||||
function initCharts(): void {
|
||||
const containers = document.querySelectorAll<HTMLElement>('.chart-container');
|
||||
|
||||
// Add loading state to all chart containers
|
||||
containers.forEach(container => {
|
||||
if (!container.querySelector('.chart-loading')) {
|
||||
const loadingEl = document.createElement('div');
|
||||
loadingEl.className = 'chart-loading';
|
||||
loadingEl.innerHTML = `
|
||||
<div style="display:flex;align-items:center;justify-content:center;height:100%;">
|
||||
<div class="chart-loading-spinner"></div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(loadingEl);
|
||||
}
|
||||
});
|
||||
|
||||
// Use IntersectionObserver to lazy load charts
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const container = entry.target as HTMLElement;
|
||||
createChart(container);
|
||||
observer.unobserve(container);
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
rootMargin: '100px', // Start loading 100px before entering viewport
|
||||
threshold: 0.1, // Trigger when at least 10% visible
|
||||
}
|
||||
);
|
||||
|
||||
// Observe all chart containers
|
||||
containers.forEach(container => {
|
||||
observer.observe(container);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initCharts);
|
||||
} else {
|
||||
initCharts();
|
||||
}
|
||||
144
status-page/src/styles/global.css
Normal file
144
status-page/src/styles/global.css
Normal file
@@ -0,0 +1,144 @@
|
||||
/* Import design tokens */
|
||||
@import './tokens.css';
|
||||
|
||||
/* Map legacy variable names to new token names for backward compatibility */
|
||||
:root {
|
||||
--radius: var(--radius-md);
|
||||
--radius-sm: var(--radius-sm);
|
||||
--radius-inner: var(--radius-xs);
|
||||
--transition: var(--transition-normal);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-family-sans);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: var(--leading-relaxed);
|
||||
min-height: 100dvh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-feature-settings:
|
||||
'tnum' 1,
|
||||
'ss01' 1,
|
||||
'cv05' 1; /* Alternate 1 for better readability */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image:
|
||||
radial-gradient(circle at 10% 20%, oklch(70% 0.25 240 / 0.08) 0%, transparent 40%),
|
||||
radial-gradient(circle at 90% 80%, oklch(70% 0.25 145 / 0.06) 0%, transparent 40%),
|
||||
radial-gradient(circle at 50% 50%, oklch(70% 0.25 25 / 0.04) 0%, transparent 60%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
[data-theme='dark'] body::before {
|
||||
background-image:
|
||||
radial-gradient(circle at 10% 20%, oklch(75% 0.3 235 / 0.12) 0%, transparent 40%),
|
||||
radial-gradient(circle at 90% 80%, oklch(80% 0.3 145 / 0.1) 0%, transparent 40%),
|
||||
radial-gradient(circle at 50% 50%, oklch(75% 0.35 25 / 0.08) 0%, transparent 60%);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
code,
|
||||
.monitor-uptime {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
code,
|
||||
.monitor-uptime {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-inner);
|
||||
}
|
||||
|
||||
/* Chart loading states */
|
||||
.chart-loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg-inset);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.chart-loading-spinner {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: chart-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes chart-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chart-loading-spinner {
|
||||
animation: none;
|
||||
border-top-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* uPlot drag-select highlight */
|
||||
.u-select {
|
||||
background: rgba(59, 130, 246, 0.15) !important;
|
||||
border-left: 1px solid rgba(59, 130, 246, 0.4);
|
||||
border-right: 1px solid rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
182
status-page/src/styles/tokens.css
Normal file
182
status-page/src/styles/tokens.css
Normal file
@@ -0,0 +1,182 @@
|
||||
/* Design Tokens for Atalaya Status Page */
|
||||
/* Using OKLCH for perceptual uniformity and modern CSS features */
|
||||
|
||||
:root {
|
||||
/* Spacing scale (4pt base) */
|
||||
--space-1: 0.25rem; /* 4px */
|
||||
--space-2: 0.5rem; /* 8px */
|
||||
--space-3: 0.75rem; /* 12px */
|
||||
--space-4: 1rem; /* 16px */
|
||||
--space-5: 1.25rem; /* 20px */
|
||||
--space-6: 1.5rem; /* 24px */
|
||||
--space-8: 2rem; /* 32px */
|
||||
--space-10: 2.5rem; /* 40px */
|
||||
--space-12: 3rem; /* 48px */
|
||||
--space-16: 4rem; /* 64px */
|
||||
--space-20: 5rem; /* 80px */
|
||||
--space-24: 6rem; /* 96px */
|
||||
|
||||
/* Semantic spacing tokens */
|
||||
--space-xs: var(--space-1);
|
||||
--space-sm: var(--space-2);
|
||||
--space-md: var(--space-3);
|
||||
--space-lg: var(--space-4);
|
||||
--space-xl: var(--space-6);
|
||||
--space-2xl: var(--space-8);
|
||||
--space-3xl: var(--space-12);
|
||||
--space-4xl: var(--space-16);
|
||||
|
||||
/* Border radius */
|
||||
--radius-xs: 0.25rem; /* 4px */
|
||||
--radius-sm: 0.5rem; /* 8px */
|
||||
--radius-md: 0.75rem; /* 12px */
|
||||
--radius-lg: 1rem; /* 16px */
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Typography */
|
||||
--font-family-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-family-mono: 'Geist Mono', 'SF Mono', 'JetBrains Mono', 'Fira Code', monospace;
|
||||
|
||||
/* Font sizes (rem-based for accessibility, 1.333 ratio - perfect fourth) */
|
||||
--text-xs: 0.75rem; /* 12px */
|
||||
--text-sm: 0.875rem; /* 14px */
|
||||
--text-base: 1rem; /* 16px */
|
||||
--text-lg: 1.125rem; /* 18px */
|
||||
--text-xl: 1.25rem; /* 20px */
|
||||
--text-2xl: 1.5rem; /* 24px */
|
||||
--text-3xl: 2rem; /* 32px */
|
||||
--text-4xl: 2.667rem; /* 42.67px */
|
||||
--text-5xl: 3.556rem; /* 56.89px */
|
||||
--text-6xl: 4.741rem; /* 75.86px */
|
||||
|
||||
/* Font weights */
|
||||
--font-normal: 400;
|
||||
--font-medium: 500;
|
||||
--font-semibold: 600;
|
||||
--font-bold: 700;
|
||||
|
||||
/* Line heights */
|
||||
--leading-tight: 1.25;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.75;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 350ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* Light theme color tokens */
|
||||
:root,
|
||||
[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
|
||||
/* Surface colors */
|
||||
--bg: oklch(99% 0.005 250); /* Near white, tinted slightly blue */
|
||||
--bg-card: oklch(98% 0.005 250); /* Slightly darker for cards */
|
||||
--bg-inset: oklch(96% 0.005 250); /* Even darker for inset elements */
|
||||
|
||||
/* Border colors */
|
||||
--border: oklch(85% 0.01 250); /* Light borders */
|
||||
--border-subtle: oklch(90% 0.005 250); /* Very subtle borders */
|
||||
|
||||
/* Text colors */
|
||||
--text: oklch(20% 0.02 250); /* Dark text - higher contrast */
|
||||
--text-muted: oklch(40% 0.015 250); /* Muted text - better contrast */
|
||||
--text-dim: oklch(50% 0.01 250); /* Dim text - improved contrast */
|
||||
|
||||
/* Status colors - more vibrant and distinctive */
|
||||
--up: oklch(70% 0.25 145); /* Vibrant green - operational */
|
||||
--up-glow: oklch(70% 0.25 145 / 0.5);
|
||||
--up-bg: oklch(70% 0.25 145 / 0.12);
|
||||
|
||||
--down: oklch(65% 0.3 25); /* Vibrant red - down */
|
||||
--down-glow: oklch(65% 0.3 25 / 0.5);
|
||||
--down-bg: oklch(65% 0.3 25 / 0.12);
|
||||
|
||||
--degraded: oklch(70% 0.25 75); /* Vibrant amber - degraded */
|
||||
--degraded-bg: oklch(70% 0.25 75 / 0.12);
|
||||
|
||||
--unknown: oklch(65% 0.1 250); /* Distinctive gray-blue - unknown */
|
||||
--unknown-glow: oklch(65% 0.1 250 / 0.4);
|
||||
--unknown-bg: oklch(65% 0.1 250 / 0.12);
|
||||
|
||||
--no-data: oklch(75% 0.05 250); /* Tinted gray for no data */
|
||||
|
||||
/* Accent color - more vibrant */
|
||||
--accent: oklch(70% 0.25 240); /* Vibrant blue accent */
|
||||
--accent-gradient: oklch(70% 0.25 270); /* Purple-blue for gradient */
|
||||
|
||||
/* Shadows (lighter in light mode) */
|
||||
--shadow: 0 4px 20px rgb(0 0 0 / 0.08);
|
||||
--shadow-hover: 0 8px 30px rgb(0 0 0 / 0.12);
|
||||
--shadow-inset: inset 0 1px 0 rgb(0 0 0 / 0.05);
|
||||
}
|
||||
|
||||
/* Dark theme color tokens */
|
||||
[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
|
||||
/* Surface colors */
|
||||
--bg: oklch(15% 0.02 250); /* Dark background */
|
||||
--bg-card: oklch(20% 0.02 250); /* Slightly lighter for cards */
|
||||
--bg-inset: oklch(18% 0.02 250); /* Darker for inset elements */
|
||||
|
||||
/* Border colors */
|
||||
--border: oklch(30% 0.02 250); /* Dark borders */
|
||||
--border-subtle: oklch(25% 0.02 250); /* Subtle borders */
|
||||
|
||||
/* Text colors */
|
||||
--text: oklch(98% 0.01 250); /* Light text - higher contrast */
|
||||
--text-muted: oklch(85% 0.01 250); /* Muted text - better contrast */
|
||||
--text-dim: oklch(70% 0.01 250); /* Dim text - improved contrast */
|
||||
|
||||
/* Status colors - more vibrant in dark mode */
|
||||
--up: oklch(80% 0.3 145); /* Very vibrant green */
|
||||
--up-glow: oklch(80% 0.3 145 / 0.6);
|
||||
--up-bg: oklch(80% 0.3 145 / 0.15);
|
||||
|
||||
--down: oklch(75% 0.35 25); /* Very vibrant red */
|
||||
--down-glow: oklch(75% 0.35 25 / 0.6);
|
||||
--down-bg: oklch(75% 0.35 25 / 0.15);
|
||||
|
||||
--degraded: oklch(80% 0.3 75); /* Very vibrant amber */
|
||||
--degraded-bg: oklch(80% 0.3 75 / 0.15);
|
||||
|
||||
--unknown: oklch(75% 0.15 240); /* Distinctive blue-gray */
|
||||
--unknown-glow: oklch(75% 0.15 240 / 0.5);
|
||||
--unknown-bg: oklch(75% 0.15 240 / 0.15);
|
||||
|
||||
--no-data: oklch(50% 0.08 240); /* Tinted gray-blue for no data */
|
||||
|
||||
/* Accent color - more vibrant */
|
||||
--accent: oklch(75% 0.3 235); /* Very vibrant blue accent */
|
||||
--accent-gradient: oklch(75% 0.3 265); /* Purple-blue for gradient */
|
||||
|
||||
/* Shadows (darker in dark mode) */
|
||||
--shadow: 0 4px 32px rgb(0 0 0 / 0.25);
|
||||
--shadow-hover: 0 8px 48px rgb(0 0 0 / 0.35);
|
||||
--shadow-inset: inset 0 1px 0 rgb(255 255 255 / 0.05);
|
||||
}
|
||||
|
||||
/* System preference detection */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme]) {
|
||||
color-scheme: dark;
|
||||
/* Dark theme variables will be applied via [data-theme="dark"] cascade */
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure smooth transitions for theme changes */
|
||||
* {
|
||||
transition:
|
||||
background-color var(--transition-normal),
|
||||
border-color var(--transition-normal),
|
||||
color var(--transition-normal);
|
||||
}
|
||||
9
status-page/tsconfig.json
Normal file
9
status-page/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"paths": {
|
||||
"@worker/types": ["../src/types.ts"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user