wa-dev-tools/public/status.html
treamz 64bf816e34 Status page: public monitoring at /status
- Real-time checks: web server, MariaDB, FFmpeg, disk usage
- Auto-refresh every 30 seconds
- Overall status banner (operational/degraded/outage)
- Server info: Node version, uptime, memory, PID
- Public page (no auth required)
- Responsive, theme-aware design
2026-03-22 00:35:15 +03:00

655 lines
24 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Статус системы — WA Dev Tools</title>
<meta name="description" content="Текущий статус сервисов WA Dev Tools." />
<script src="/vendor/tailwind.js"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: { extend: {
fontFamily: {
mono: ['JetBrains Mono', 'monospace'],
sans: ['Manrope', 'sans-serif']
},
colors: {
surface: { 900: '#060c18', 800: '#091020', 700: '#0d1828', 600: '#162040' },
accent: { DEFAULT: '#0054e6', dim: '#0043b8', bright: '#6b9fff' }
}
}}
};
</script>
<link href="/vendor/fonts.css" rel="stylesheet">
<link href="/shared.css" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; }
/* ── Nav (reused from landing) ── */
.landing-nav {
position: fixed;
top: 0; left: 0; right: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 32px;
background: rgba(6, 12, 24, 0.7);
backdrop-filter: blur(12px);
border-bottom: 1px solid transparent;
transition: background 0.3s, border-color 0.3s;
}
.landing-nav.scrolled {
background: rgba(6, 12, 24, 0.88);
border-color: var(--surface-600);
}
.nav-logo {
font-family: 'Manrope', sans-serif;
font-size: 18px;
font-weight: 800;
color: var(--text-primary);
text-decoration: none;
letter-spacing: -0.02em;
}
.nav-logo span { color: var(--accent); }
.nav-actions {
display: flex;
align-items: center;
gap: 12px;
}
.nav-link {
font-family: 'Manrope', sans-serif;
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
text-decoration: none;
transition: color 0.2s;
padding: 8px 4px;
}
.nav-link:hover { color: var(--text-primary); }
.nav-btn {
font-family: 'Manrope', sans-serif;
font-size: 14px;
font-weight: 700;
color: #fff;
background: var(--accent);
text-decoration: none;
padding: 9px 20px;
border-radius: 8px;
transition: background 0.2s;
}
.nav-btn:hover { background: var(--accent-dim); }
/* ── Page layout ── */
.status-page {
min-height: 100vh;
padding-top: 72px; /* nav height */
}
/* ── Overall banner ── */
.overall-banner {
padding: 20px 0;
text-align: center;
transition: background 0.4s, border-color 0.4s;
border-bottom: 1px solid transparent;
}
.overall-banner.operational {
background: rgba(34, 197, 94, 0.10);
border-color: rgba(34, 197, 94, 0.25);
}
.overall-banner.degraded {
background: rgba(234, 179, 8, 0.10);
border-color: rgba(234, 179, 8, 0.25);
}
.overall-banner.outage {
background: rgba(239, 68, 68, 0.10);
border-color: rgba(239, 68, 68, 0.25);
}
.overall-icon {
font-size: 32px;
line-height: 1;
margin-bottom: 6px;
}
.overall-text {
font-family: 'Manrope', sans-serif;
font-size: 22px;
font-weight: 800;
letter-spacing: -0.02em;
transition: color 0.4s;
}
.overall-banner.operational .overall-text { color: #16a34a; }
.overall-banner.degraded .overall-text { color: #ca8a04; }
.overall-banner.outage .overall-text { color: #dc2626; }
.dark .overall-banner.operational .overall-text { color: #22c55e; }
.dark .overall-banner.degraded .overall-text { color: #eab308; }
.dark .overall-banner.outage .overall-text { color: #ef4444; }
/* ── Content container ── */
.status-container {
max-width: 760px;
margin: 0 auto;
padding: 40px 20px 60px;
}
/* ── Section title ── */
.section-title {
font-family: 'Manrope', sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 12px;
}
/* ── Service card ── */
.services-card {
background: var(--surface-700);
border: 1px solid var(--surface-600);
border-radius: 12px;
overflow: hidden;
margin-bottom: 32px;
transition: background 0.3s, border-color 0.3s;
}
.service-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--surface-600);
gap: 12px;
}
.service-row:last-child { border-bottom: none; }
.service-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.service-name {
font-family: 'Manrope', sans-serif;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.service-detail {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.service-right {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.service-latency {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--text-muted);
}
/* ── Status badge ── */
.status-badge {
display: flex;
align-items: center;
gap: 6px;
font-family: 'Manrope', sans-serif;
font-size: 12px;
font-weight: 600;
padding: 4px 10px;
border-radius: 999px;
white-space: nowrap;
}
.status-badge.operational {
background: rgba(34, 197, 94, 0.12);
color: #16a34a;
}
.status-badge.degraded {
background: rgba(234, 179, 8, 0.12);
color: #ca8a04;
}
.status-badge.outage {
background: rgba(239, 68, 68, 0.12);
color: #dc2626;
}
.dark .status-badge.operational { color: #22c55e; }
.dark .status-badge.degraded { color: #eab308; }
.dark .status-badge.outage { color: #ef4444; }
/* Status dot */
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.operational { background: #22c55e; }
.status-dot.degraded { background: #eab308; }
.status-dot.outage { background: #ef4444; }
/* Pulse animation on green dots */
@keyframes status-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); }
50% { opacity: 0.85; box-shadow: 0 0 0 5px rgba(34, 197, 94, 0); }
}
.status-dot.operational {
animation: status-pulse 2.5s ease-in-out infinite;
}
/* ── Server info card ── */
.server-card {
background: var(--surface-700);
border: 1px solid var(--surface-600);
border-radius: 12px;
padding: 20px;
margin-bottom: 32px;
transition: background 0.3s, border-color 0.3s;
}
.server-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
@media (min-width: 480px) {
.server-grid { grid-template-columns: repeat(4, 1fr); }
}
.server-stat {
display: flex;
flex-direction: column;
gap: 3px;
}
.server-stat-label {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
}
.server-stat-value {
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
/* ── Last updated / refresh ── */
.status-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.last-updated {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--text-muted);
}
.refresh-indicator {
display: flex;
align-items: center;
gap: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--text-muted);
}
.refresh-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
opacity: 0.5;
}
.refresh-dot.active {
opacity: 1;
animation: status-pulse 1s ease-in-out 1;
}
/* ── Skeleton loader ── */
.skeleton {
background: var(--surface-600);
border-radius: 4px;
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 0.3; }
}
/* ── Footer ── */
.page-footer {
padding: 24px 32px;
border-top: 1px solid var(--surface-600);
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.footer-copy {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--text-muted);
}
.footer-link {
font-family: 'Manrope', sans-serif;
font-size: 13px;
color: var(--text-muted);
text-decoration: none;
transition: color 0.2s;
}
.footer-link:hover { color: var(--text-secondary); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
</style>
</head>
<body>
<!-- ════════════════════════════════════════════
NAV
════════════════════════════════════════════ -->
<nav class="landing-nav" id="mainNav">
<a href="/" class="nav-logo">WA Dev <span>Tools</span></a>
<div class="nav-actions">
<a href="/" class="nav-link">Главная</a>
<a href="/auth/login" class="nav-link">Войти</a>
<a href="/auth/register" class="nav-btn">Регистрация</a>
</div>
</nav>
<!-- ════════════════════════════════════════════
OVERALL STATUS BANNER
════════════════════════════════════════════ -->
<div class="status-page">
<div class="overall-banner" id="overallBanner">
<div class="overall-icon" id="overallIcon">
<!-- filled by JS -->
<span class="skeleton" style="display:inline-block;width:32px;height:32px;border-radius:50%;"></span>
</div>
<div class="overall-text" id="overallText">Проверка состояния...</div>
</div>
<!-- ════════════════════════════════════════════
MAIN CONTENT
════════════════════════════════════════════ -->
<div class="status-container">
<!-- Services -->
<div class="section-title">Сервисы</div>
<div class="services-card" id="servicesCard">
<!-- Skeleton rows while loading -->
<div class="service-row" id="skeleton-1">
<div class="service-info">
<div class="skeleton" style="width:120px;height:14px;"></div>
</div>
<div class="skeleton" style="width:90px;height:24px;border-radius:999px;"></div>
</div>
<div class="service-row" id="skeleton-2">
<div class="service-info">
<div class="skeleton" style="width:100px;height:14px;"></div>
</div>
<div class="skeleton" style="width:90px;height:24px;border-radius:999px;"></div>
</div>
<div class="service-row" id="skeleton-3">
<div class="service-info">
<div class="skeleton" style="width:80px;height:14px;"></div>
</div>
<div class="skeleton" style="width:90px;height:24px;border-radius:999px;"></div>
</div>
</div>
<!-- Server info -->
<div class="section-title">Сервер</div>
<div class="server-card">
<div class="server-grid" id="serverGrid">
<div class="server-stat">
<div class="server-stat-label">Node.js</div>
<div class="server-stat-value skeleton" style="width:70px;height:13px;" id="sNode"></div>
</div>
<div class="server-stat">
<div class="server-stat-label">Аптайм</div>
<div class="server-stat-value skeleton" style="width:80px;height:13px;" id="sUptime"></div>
</div>
<div class="server-stat">
<div class="server-stat-label">Память</div>
<div class="server-stat-value skeleton" style="width:90px;height:13px;" id="sMemory"></div>
</div>
<div class="server-stat">
<div class="server-stat-label">PID</div>
<div class="server-stat-value skeleton" style="width:50px;height:13px;" id="sPid"></div>
</div>
</div>
</div>
<!-- Footer row -->
<div class="status-footer">
<div class="last-updated" id="lastUpdated">Загрузка...</div>
<div class="refresh-indicator">
<div class="refresh-dot" id="refreshDot"></div>
<span>Обновление каждые 30 с</span>
</div>
</div>
</div><!-- /status-container -->
<!-- Page footer -->
<footer class="page-footer">
<div class="footer-copy">WA Dev Tools &copy; 2025</div>
<div style="display:flex;gap:20px;">
<a href="/" class="footer-link">Главная</a>
<a href="/auth/login" class="footer-link">Войти</a>
</div>
</footer>
</div><!-- /status-page -->
<script>
(function () {
'use strict';
// ── Theme: apply saved preference ──────────────────────────────────────
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
// ── Nav scroll effect ───────────────────────────────────────────────────
const nav = document.getElementById('mainNav');
window.addEventListener('scroll', () => {
nav.classList.toggle('scrolled', window.scrollY > 10);
}, { passive: true });
// ── Status helpers ──────────────────────────────────────────────────────
const STATUS_LABELS = {
operational: 'Работает',
degraded: 'Деградация',
outage: 'Сбой',
};
const OVERALL_LABELS = {
operational: 'Все системы работают',
degraded: 'Частичные проблемы',
outage: 'Сбой в системе',
};
const OVERALL_ICONS = {
operational: '✅',
degraded: '⚠️',
outage: '🔴',
};
/**
* Format uptime in seconds to human-readable "Xд Xч Xм".
* @param {number} seconds
* @returns {string}
*/
function formatUptime(seconds) {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
const parts = [];
if (d > 0) parts.push(d + 'д');
if (h > 0) parts.push(h + 'ч');
parts.push(m + 'м');
return parts.join(' ');
}
/**
* Format ISO timestamp to "HH:MM:SS".
* @param {string} iso
* @returns {string}
*/
function formatTime(iso) {
const d = new Date(iso);
return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
// ── DOM refs ────────────────────────────────────────────────────────────
const overallBanner = document.getElementById('overallBanner');
const overallIcon = document.getElementById('overallIcon');
const overallText = document.getElementById('overallText');
const servicesCard = document.getElementById('servicesCard');
const sNode = document.getElementById('sNode');
const sUptime = document.getElementById('sUptime');
const sMemory = document.getElementById('sMemory');
const sPid = document.getElementById('sPid');
const lastUpdated = document.getElementById('lastUpdated');
const refreshDot = document.getElementById('refreshDot');
// ── Render ──────────────────────────────────────────────────────────────
/**
* Build a single service row element.
* @param {{ name: string, status: string, latency?: number, detail?: string }} check
* @returns {HTMLElement}
*/
function buildServiceRow(check) {
const row = document.createElement('div');
row.className = 'service-row';
const info = document.createElement('div');
info.className = 'service-info';
const name = document.createElement('div');
name.className = 'service-name';
name.textContent = check.name;
info.appendChild(name);
if (check.detail) {
const detail = document.createElement('div');
detail.className = 'service-detail';
detail.textContent = check.detail;
info.appendChild(detail);
}
const right = document.createElement('div');
right.className = 'service-right';
if (typeof check.latency === 'number' && check.latency > 0) {
const latency = document.createElement('div');
latency.className = 'service-latency';
latency.textContent = check.latency + ' мс';
right.appendChild(latency);
}
const badge = document.createElement('div');
const st = check.status in STATUS_LABELS ? check.status : 'outage';
badge.className = 'status-badge ' + st;
const dot = document.createElement('div');
dot.className = 'status-dot ' + st;
badge.appendChild(dot);
const label = document.createElement('span');
label.textContent = STATUS_LABELS[st];
badge.appendChild(label);
right.appendChild(badge);
row.appendChild(info);
row.appendChild(right);
return row;
}
/**
* Apply fetched data to the DOM.
* @param {{ overall: string, uptime: number, checks: any[], server: any, timestamp: string }} data
*/
function render(data) {
const overall = data.overall in OVERALL_LABELS ? data.overall : 'outage';
// Banner
overallBanner.className = 'overall-banner ' + overall;
overallIcon.innerHTML = '<span style="font-size:32px;line-height:1;">' + OVERALL_ICONS[overall] + '</span>';
overallText.textContent = OVERALL_LABELS[overall];
// Service rows
servicesCard.innerHTML = '';
data.checks.forEach(check => {
servicesCard.appendChild(buildServiceRow(check));
});
// Server stats — remove skeleton class and set text
function applyStat(el, value) {
el.classList.remove('skeleton');
el.style.removeProperty('width');
el.style.removeProperty('height');
el.textContent = value;
}
applyStat(sNode, data.server.node);
applyStat(sUptime, formatUptime(data.uptime));
applyStat(sMemory, data.server.memory.heap + ' heap');
applyStat(sPid, '#' + data.server.pid);
// Timestamp
lastUpdated.textContent = 'Последнее обновление: ' + formatTime(data.timestamp);
// Blink the refresh dot
refreshDot.classList.remove('active');
// Force reflow so the animation restarts
void refreshDot.offsetWidth;
refreshDot.classList.add('active');
}
// ── Fetch ───────────────────────────────────────────────────────────────
let fetchInProgress = false;
async function fetchStatus() {
if (fetchInProgress) return;
fetchInProgress = true;
try {
const res = await fetch('/status/api', { cache: 'no-store' });
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
render(data);
} catch (err) {
// Show error state without crashing the page
overallBanner.className = 'overall-banner outage';
overallIcon.innerHTML = '<span style="font-size:32px;line-height:1;">🔴</span>';
overallText.textContent = 'Ошибка получения статуса';
lastUpdated.textContent = 'Не удалось обновить данные';
} finally {
fetchInProgress = false;
}
}
// Initial load + poll
fetchStatus();
setInterval(fetchStatus, 30_000);
})();
</script>
</body>
</html>