Kick off (#1)

* Kick off
* Update LICENSE copyright
This commit is contained in:
2026-04-11 13:22:36 +02:00
committed by GitHub
parent 1f1e74c9f8
commit 3882a1941a
76 changed files with 17154 additions and 1 deletions

View 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>

View 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>

View 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>

View 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
View 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;
};
}

View 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>

View File

@@ -0,0 +1 @@
export { getStatusApiData } from '../../../src/api/status.js';

View 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();
});
});

View 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;
}

View 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>

View 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();
}

View 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);
}

View 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);
}