Genesis
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/node_modules/
|
||||
/tse_events.*
|
||||
/.env
|
||||
16
dashboard/index.html
Normal file
16
dashboard/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TSE Monitor</title>
|
||||
<meta name="description" content="CloudTSE availability and response-time dashboard" />
|
||||
<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&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
148
dashboard/src/App.jsx
Normal file
148
dashboard/src/App.jsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
ThemeProvider, CssBaseline, Box, Container, Grid,
|
||||
Typography, ToggleButton, ToggleButtonGroup,
|
||||
Chip, CircularProgress, Alert, IconButton, Tooltip,
|
||||
} from '@mui/material';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import { theme } from './theme.js';
|
||||
import SummaryCards from './components/SummaryCards.jsx';
|
||||
import AvailabilityChart from './components/AvailabilityChart.jsx';
|
||||
import ResponseTimeChart from './components/ResponseTimeChart.jsx';
|
||||
import OutageTable from './components/OutageTable.jsx';
|
||||
import { fetchSummary, fetchHourly, fetchOutages, fetchLive } from './api.js';
|
||||
|
||||
const RANGE_DAYS = { '24h': 1, '7d': 7, '30d': 30 };
|
||||
const REFRESH_MS = 60_000;
|
||||
|
||||
export default function App() {
|
||||
const [summary, setSummary] = useState(null);
|
||||
const [hourly, setHourly] = useState([]);
|
||||
const [outages, setOutages] = useState([]);
|
||||
const [live, setLive] = useState(null);
|
||||
const [range, setRange] = useState('7d');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [lastRefresh, setLastRefresh] = useState(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const days = RANGE_DAYS[range] ?? 7;
|
||||
try {
|
||||
const [s, h, o, l] = await Promise.all([
|
||||
fetchSummary(),
|
||||
fetchHourly(days),
|
||||
fetchOutages(50),
|
||||
fetchLive(),
|
||||
]);
|
||||
setSummary(s); setHourly(h); setOutages(o); setLive(l);
|
||||
setError(null);
|
||||
setLastRefresh(new Date());
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [range]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
refresh();
|
||||
const id = setInterval(refresh, REFRESH_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [refresh]);
|
||||
|
||||
const isOnline = live && (Date.now() - new Date(live.ts).getTime()) < 180_000;
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 8 }}>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<Box sx={{
|
||||
borderBottom: '1px solid', borderColor: 'divider',
|
||||
position: 'sticky', top: 0, zIndex: 100,
|
||||
backdropFilter: 'blur(12px)',
|
||||
bgcolor: 'rgba(8,13,20,0.85)',
|
||||
}}>
|
||||
<Container maxWidth="xl">
|
||||
<Box sx={{ py: 1.5, display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Typography variant="h5" sx={{ flexGrow: 1, letterSpacing: '-0.5px' }}>
|
||||
⚡ TSE Monitor
|
||||
</Typography>
|
||||
|
||||
{live && (
|
||||
<>
|
||||
<Chip
|
||||
label={isOnline ? '● ONLINE' : '○ STALE'}
|
||||
color={isOnline ? 'success' : 'warning'}
|
||||
size="small"
|
||||
sx={{ fontWeight: 700 }}
|
||||
/>
|
||||
<Chip label={`sig #${live.signature_counter}`} variant="outlined" size="small" />
|
||||
<Chip label={`tx #${live.transaction_counter}`} variant="outlined" size="small" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{lastRefresh && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
updated {lastRefresh.toLocaleTimeString()}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Tooltip title="Refresh now">
|
||||
<IconButton size="small" onClick={refresh} disabled={loading}>
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
<Container maxWidth="xl" sx={{ mt: 4 }}>
|
||||
{error && <Alert severity="error" sx={{ mb: 3 }}>API error: {error}</Alert>}
|
||||
|
||||
{loading && !summary ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 10 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<Grid container spacing={3}>
|
||||
{/* Summary cards */}
|
||||
<Grid size={12}>
|
||||
<SummaryCards summary={summary} />
|
||||
</Grid>
|
||||
|
||||
{/* Time-range toggle */}
|
||||
<Grid size={12} sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<ToggleButtonGroup
|
||||
value={range} exclusive size="small"
|
||||
onChange={(_, v) => v && setRange(v)}
|
||||
>
|
||||
{['24h','7d','30d'].map(v => (
|
||||
<ToggleButton key={v} value={v} sx={{ px: 2 }}>{v}</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
</Grid>
|
||||
|
||||
{/* Availability chart */}
|
||||
<Grid size={12}>
|
||||
<AvailabilityChart data={hourly} />
|
||||
</Grid>
|
||||
|
||||
{/* Response-time chart */}
|
||||
<Grid size={12}>
|
||||
<ResponseTimeChart data={hourly} />
|
||||
</Grid>
|
||||
|
||||
{/* Outage table */}
|
||||
<Grid size={12}>
|
||||
<OutageTable outages={outages} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Container>
|
||||
</Box>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
13
dashboard/src/api.js
Normal file
13
dashboard/src/api.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const BASE = ''; // proxied via vite → localhost:20081
|
||||
|
||||
async function get(path) {
|
||||
const r = await fetch(BASE + path);
|
||||
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export const fetchSummary = () => get('/api/summary');
|
||||
export const fetchHourly = (days = 7) => get(`/api/hourly?days=${days}`);
|
||||
export const fetchDaily = (days = 30)=> get(`/api/daily?days=${days}`);
|
||||
export const fetchOutages = (limit=50) => get(`/api/outages?limit=${limit}`);
|
||||
export const fetchLive = () => get('/api/live');
|
||||
73
dashboard/src/components/AvailabilityChart.jsx
Normal file
73
dashboard/src/components/AvailabilityChart.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Paper, Typography, Box } from '@mui/material';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid,
|
||||
Tooltip, ResponsiveContainer, ReferenceLine,
|
||||
} from 'recharts';
|
||||
|
||||
function fmt(isoStr) {
|
||||
const d = new Date(isoStr);
|
||||
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function CustomTooltip({ active, payload, label }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0]?.payload;
|
||||
return (
|
||||
<Box sx={{
|
||||
bgcolor: 'background.paper', border: '1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius: 2, p: 1.5, fontSize: 13,
|
||||
}}>
|
||||
<div style={{ color: '#94a3b8', marginBottom: 4 }}>{fmt(label)}</div>
|
||||
<div style={{ color: '#22c55e', fontWeight: 700 }}>
|
||||
Uptime: {d?.uptime_pct ?? '—'}%
|
||||
</div>
|
||||
<div style={{ color: '#94a3b8' }}>Polls: {d?.total_polls ?? 0}</div>
|
||||
{d?.outage_s > 0 && (
|
||||
<div style={{ color: '#f59e0b' }}>Outage: {d.outage_s}s</div>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AvailabilityChart({ data }) {
|
||||
return (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>Availability</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
|
||||
Hourly uptime %
|
||||
</Typography>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<AreaChart data={data} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="uptimeGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.35} />
|
||||
<stop offset="95%" stopColor="#22c55e" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
|
||||
<XAxis
|
||||
dataKey="hour"
|
||||
tickFormatter={v => new Date(v).toLocaleDateString(undefined,{month:'short',day:'numeric'})}
|
||||
tick={{ fill: '#64748b', fontSize: 11 }}
|
||||
axisLine={false} tickLine={false}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]} tickFormatter={v => `${v}%`}
|
||||
tick={{ fill: '#64748b', fontSize: 11 }}
|
||||
axisLine={false} tickLine={false} width={42}
|
||||
/>
|
||||
<ReferenceLine y={100} stroke="#22c55e" strokeDasharray="4 4" strokeOpacity={0.3} />
|
||||
<ReferenceLine y={99} stroke="#f59e0b" strokeDasharray="4 4" strokeOpacity={0.4} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Area
|
||||
type="monotone" dataKey="uptime_pct"
|
||||
stroke="#22c55e" strokeWidth={2}
|
||||
fill="url(#uptimeGrad)"
|
||||
dot={false} activeDot={{ r: 4, fill: '#22c55e' }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
63
dashboard/src/components/OutageTable.jsx
Normal file
63
dashboard/src/components/OutageTable.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
Paper, Typography, Table, TableBody, TableCell,
|
||||
TableContainer, TableHead, TableRow, Chip, Box,
|
||||
} from '@mui/material';
|
||||
|
||||
function duration(s) {
|
||||
if (s == null) return '?';
|
||||
if (s < 60) return `${s}s`;
|
||||
if (s < 3600) return `${Math.floor(s/60)}m ${s%60}s`;
|
||||
return `${Math.floor(s/3600)}h ${Math.floor((s%3600)/60)}m`;
|
||||
}
|
||||
|
||||
export default function OutageTable({ outages }) {
|
||||
if (!outages?.length) {
|
||||
return (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>Outages</Typography>
|
||||
<Typography color="text.secondary">No outages recorded.</Typography>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>Outages</Typography>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ color: 'text.secondary', fontWeight: 600 }}>Started</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontWeight: 600 }}>Recovered</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontWeight: 600 }}>Duration</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontWeight: 600 }}>Status</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{outages.map((o) => (
|
||||
<TableRow key={o.id} hover sx={{ '&:last-child td': { border: 0 } }}>
|
||||
<TableCell sx={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||
{new Date(o.started_at).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontFamily: 'monospace', fontSize: 12, color: 'text.secondary' }}>
|
||||
{o.recovered_at ? new Date(o.recovered_at).toLocaleString() : '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box component="span" sx={{ fontFamily: 'monospace', fontWeight: 600 }}>
|
||||
{duration(o.duration_s)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{o.recovered_at
|
||||
? <Chip label="Resolved" color="success" size="small" />
|
||||
: <Chip label="Ongoing" color="error" size="small" />
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
76
dashboard/src/components/ResponseTimeChart.jsx
Normal file
76
dashboard/src/components/ResponseTimeChart.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Paper, Typography, Box } from '@mui/material';
|
||||
import {
|
||||
ComposedChart, Line, Area, XAxis, YAxis, CartesianGrid,
|
||||
Tooltip, ResponsiveContainer, Legend,
|
||||
} from 'recharts';
|
||||
|
||||
function CustomTooltip({ active, payload, label }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0]?.payload;
|
||||
return (
|
||||
<Box sx={{
|
||||
bgcolor: 'background.paper', border: '1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius: 2, p: 1.5, fontSize: 13,
|
||||
}}>
|
||||
<div style={{ color: '#94a3b8', marginBottom: 4 }}>
|
||||
{new Date(label).toLocaleString(undefined, { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' })}
|
||||
</div>
|
||||
{d?.avg_ms != null && <div style={{ color: '#3b82f6', fontWeight: 700 }}>Avg: {d.avg_ms} ms</div>}
|
||||
{d?.min_ms != null && <div style={{ color: '#a78bfa' }}>Min: {d.min_ms} ms</div>}
|
||||
{d?.max_ms != null && <div style={{ color: '#f59e0b' }}>Max: {d.max_ms} ms</div>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResponseTimeChart({ data }) {
|
||||
// build min-max range for area band
|
||||
const enriched = data.map(d => ({
|
||||
...d,
|
||||
range: d.min_ms != null && d.max_ms != null ? [d.min_ms, d.max_ms] : null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>Response Time</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
|
||||
Avg / min / max per hour (ms)
|
||||
</Typography>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<ComposedChart data={enriched} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="rttGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.2} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
|
||||
<XAxis
|
||||
dataKey="hour"
|
||||
tickFormatter={v => new Date(v).toLocaleDateString(undefined,{month:'short',day:'numeric'})}
|
||||
tick={{ fill: '#64748b', fontSize: 11 }}
|
||||
axisLine={false} tickLine={false}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={v => `${v}ms`}
|
||||
tick={{ fill: '#64748b', fontSize: 11 }}
|
||||
axisLine={false} tickLine={false} width={52}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
{/* min-max band */}
|
||||
<Area
|
||||
type="monotone" dataKey="range"
|
||||
fill="url(#rttGrad)" stroke="none"
|
||||
dot={false} connectNulls={false}
|
||||
/>
|
||||
{/* avg line */}
|
||||
<Line
|
||||
type="monotone" dataKey="avg_ms"
|
||||
stroke="#3b82f6" strokeWidth={2}
|
||||
dot={false} activeDot={{ r: 4 }}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
76
dashboard/src/components/SummaryCards.jsx
Normal file
76
dashboard/src/components/SummaryCards.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Grid, Paper, Typography, Box, Skeleton } from '@mui/material';
|
||||
import UptimeIcon from '@mui/icons-material/CheckCircleOutlined';
|
||||
import SpeedIcon from '@mui/icons-material/Speed';
|
||||
import PollIcon from '@mui/icons-material/DataUsage';
|
||||
import OutageIcon from '@mui/icons-material/Warning';
|
||||
|
||||
|
||||
function StatCard({ icon: Icon, label, value, unit, color = 'primary.main', loading }) {
|
||||
return (
|
||||
<Paper sx={{ p: 3, height: '100%', position: 'relative', overflow: 'hidden' }}>
|
||||
{/* glow accent */}
|
||||
<Box sx={{
|
||||
position: 'absolute', top: -20, right: -20,
|
||||
width: 120, height: 120, borderRadius: '50%',
|
||||
bgcolor: color, opacity: 0.06, filter: 'blur(30px)',
|
||||
}} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5 }}>
|
||||
<Box sx={{
|
||||
p: 1, borderRadius: 2,
|
||||
bgcolor: `${color}22`,
|
||||
color,
|
||||
display: 'flex',
|
||||
}}>
|
||||
<Icon fontSize="small" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{loading ? (
|
||||
<Skeleton width={80} height={40} />
|
||||
) : (
|
||||
<Typography variant="h4" fontWeight={700} color={color} sx={{ lineHeight: 1.1, mt: 0.5 }}>
|
||||
{value}
|
||||
{unit && (
|
||||
<Typography component="span" variant="body2" color="text.secondary" ml={0.5}>
|
||||
{unit}
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SummaryCards({ summary }) {
|
||||
const loading = !summary;
|
||||
|
||||
const uptime = summary?.avg_uptime_pct != null ? `${summary.avg_uptime_pct}` : '—';
|
||||
const avgMs = summary?.avg_ms != null ? summary.avg_ms : '—';
|
||||
const polls = summary?.total_polls != null
|
||||
? summary.total_polls >= 1000
|
||||
? `${(summary.total_polls / 1000).toFixed(1)}k`
|
||||
: summary.total_polls
|
||||
: '—';
|
||||
const outages = summary?.outage_count ?? '—';
|
||||
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard icon={UptimeIcon} label="Avg Uptime" value={uptime} unit="%" color="#22c55e" loading={loading} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard icon={SpeedIcon} label="Avg RTT" value={avgMs} unit="ms" color="#3b82f6" loading={loading} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard icon={PollIcon} label="Total Polls" value={polls} color="#a78bfa" loading={loading} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard icon={OutageIcon} label="Outages" value={outages} color="#f59e0b" loading={loading} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
9
dashboard/src/main.jsx
Normal file
9
dashboard/src/main.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
31
dashboard/src/theme.js
Normal file
31
dashboard/src/theme.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
|
||||
export const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
background: { default: '#080d14', paper: '#0f1724' },
|
||||
primary: { main: '#3b82f6' },
|
||||
secondary: { main: '#a78bfa' },
|
||||
success: { main: '#22c55e' },
|
||||
error: { main: '#ef4444' },
|
||||
warning: { main: '#f59e0b' },
|
||||
divider: 'rgba(255,255,255,0.07)',
|
||||
},
|
||||
typography: {
|
||||
fontFamily: "'Inter', sans-serif",
|
||||
h5: { fontWeight: 700 },
|
||||
h6: { fontWeight: 600 },
|
||||
},
|
||||
shape: { borderRadius: 12 },
|
||||
components: {
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundImage: 'none',
|
||||
border: '1px solid rgba(255,255,255,0.07)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiChip: { styleOverrides: { root: { fontFamily: 'monospace' } } },
|
||||
},
|
||||
});
|
||||
10
dashboard/vite.config.js
Normal file
10
dashboard/vite.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 20080,
|
||||
proxy: { '/api': 'http://localhost:20081' },
|
||||
},
|
||||
});
|
||||
58
docs/nginx.conf
Normal file
58
docs/nginx.conf
Normal file
@@ -0,0 +1,58 @@
|
||||
# TSE Monitor — sample nginx reverse-proxy configuration
|
||||
#
|
||||
# Place this file (or include it) in /etc/nginx/sites-available/tsemonitor
|
||||
# then symlink: ln -s /etc/nginx/sites-available/tsemonitor /etc/nginx/sites-enabled/
|
||||
# and reload: nginx -t && systemctl reload nginx
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name monitor.example.com; # ← change to your domain or IP
|
||||
|
||||
# Optional: redirect HTTP → HTTPS (uncomment when TLS is configured)
|
||||
# return 301 https://$host$request_uri;
|
||||
|
||||
# Vite HMR WebSocket (dev mode only — remove in production)
|
||||
location /vite-hmr {
|
||||
proxy_pass http://127.0.0.1:20080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# All traffic → monitor.js (Express + Vite middleware on port 20080)
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:20080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Timeouts — aggregation backfill can take a moment on startup
|
||||
proxy_read_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
}
|
||||
}
|
||||
|
||||
# ── TLS (HTTPS) ──────────────────────────────────────────────────────────────
|
||||
# Uncomment after running: certbot --nginx -d monitor.example.com
|
||||
#
|
||||
# server {
|
||||
# listen 443 ssl http2;
|
||||
# server_name monitor.example.com;
|
||||
#
|
||||
# ssl_certificate /etc/letsencrypt/live/monitor.example.com/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/monitor.example.com/privkey.pem;
|
||||
# include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
#
|
||||
# location / {
|
||||
# proxy_pass http://127.0.0.1:20080;
|
||||
# proxy_http_version 1.1;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# }
|
||||
# }
|
||||
16
index.html
Normal file
16
index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TSE Monitor</title>
|
||||
<meta name="description" content="CloudTSE availability and response-time dashboard" />
|
||||
<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&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/dashboard/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
363
monitor.js
Normal file
363
monitor.js
Normal file
@@ -0,0 +1,363 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import chalk from 'chalk';
|
||||
import express from 'express';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { createServer as createHttpServer } from 'node:http';
|
||||
import { createServer as createViteServer } from 'vite';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────────
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const TSE_URL = 'http://3.78.227.48:20001/';
|
||||
const POLL_MS = 60_000; // poll every 60 s
|
||||
const DB_PATH = './tse_events.db';
|
||||
const PORT = 20080;
|
||||
const POLL_S = 60; // expected interval (gap detection)
|
||||
const GAP_S = POLL_S * 2; // gap > 120 s → outage
|
||||
const AGG_INTERVAL = 2 * 60_000; // re-aggregate every 2 min
|
||||
const AGG_LOOKBACK_H = 3; // hours covered on incremental run
|
||||
|
||||
// ── DB ────────────────────────────────────────────────────────────────────────
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts TEXT NOT NULL,
|
||||
status TEXT,
|
||||
service TEXT,
|
||||
version TEXT,
|
||||
serial_number TEXT,
|
||||
signature_counter INTEGER,
|
||||
transaction_counter INTEGER,
|
||||
registered_clients INTEGER,
|
||||
initialized INTEGER,
|
||||
created_at TEXT,
|
||||
fcc_version TEXT,
|
||||
db_path TEXT,
|
||||
delta INTEGER,
|
||||
response_ms INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS hourly_stats (
|
||||
hour TEXT PRIMARY KEY,
|
||||
total_polls INTEGER NOT NULL DEFAULT 0,
|
||||
avg_ms REAL,
|
||||
min_ms INTEGER,
|
||||
max_ms INTEGER,
|
||||
outage_s INTEGER NOT NULL DEFAULT 0,
|
||||
uptime_pct REAL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS daily_stats (
|
||||
date TEXT PRIMARY KEY,
|
||||
total_polls INTEGER NOT NULL DEFAULT 0,
|
||||
avg_ms REAL,
|
||||
min_ms INTEGER,
|
||||
max_ms INTEGER,
|
||||
outage_s INTEGER NOT NULL DEFAULT 0,
|
||||
uptime_pct REAL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS outages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
started_at TEXT NOT NULL UNIQUE,
|
||||
recovered_at TEXT,
|
||||
duration_s INTEGER
|
||||
);
|
||||
`);
|
||||
|
||||
// migrate old DBs missing the column
|
||||
try { db.exec('ALTER TABLE events ADD COLUMN response_ms INTEGER'); } catch {}
|
||||
try { db.exec('ALTER TABLE events ADD COLUMN delta INTEGER'); } catch {}
|
||||
|
||||
// ── Prepared statements ───────────────────────────────────────────────────────
|
||||
const insertEvent = db.prepare(`
|
||||
INSERT INTO events
|
||||
(ts, status, service, version, serial_number,
|
||||
signature_counter, transaction_counter, registered_clients,
|
||||
initialized, created_at, fcc_version, db_path, delta, response_ms)
|
||||
VALUES
|
||||
(@ts, @status, @service, @version, @serialNumber,
|
||||
@signatureCounter, @transactionCounter, @registeredClients,
|
||||
@initialized, @createdAt, @fccVersion, @dbPath, @delta, @responseMs)
|
||||
`);
|
||||
const lastRow = db.prepare('SELECT signature_counter FROM events ORDER BY id DESC LIMIT 1');
|
||||
|
||||
const stmts = {
|
||||
eventsInRange: db.prepare(
|
||||
'SELECT ts, response_ms FROM events WHERE ts >= ? AND ts < ? ORDER BY ts'
|
||||
),
|
||||
gapsFrom: db.prepare(`
|
||||
WITH ordered AS (
|
||||
SELECT ts, LAG(ts) OVER (ORDER BY ts) AS prev_ts
|
||||
FROM events WHERE ts >= ?
|
||||
)
|
||||
SELECT
|
||||
strftime('%Y-%m-%dT%H:%M:%SZ', prev_ts, '+' || ? || ' seconds') AS started_at,
|
||||
ts AS recovered_at,
|
||||
CAST((julianday(ts) - julianday(prev_ts)) * 86400 AS INTEGER) AS gap_s
|
||||
FROM ordered
|
||||
WHERE prev_ts IS NOT NULL
|
||||
AND CAST((julianday(ts) - julianday(prev_ts)) * 86400 AS INTEGER) > ?
|
||||
`),
|
||||
upsertOutage: db.prepare(`
|
||||
INSERT INTO outages (started_at, recovered_at, duration_s)
|
||||
VALUES (@started_at, @recovered_at, @duration_s)
|
||||
ON CONFLICT(started_at) DO UPDATE SET
|
||||
recovered_at = excluded.recovered_at,
|
||||
duration_s = excluded.duration_s
|
||||
`),
|
||||
outagesOverlap: db.prepare(`
|
||||
SELECT started_at, recovered_at FROM outages
|
||||
WHERE started_at < ? AND (recovered_at IS NULL OR recovered_at > ?)
|
||||
`),
|
||||
upsertHourly: db.prepare(`
|
||||
INSERT INTO hourly_stats (hour, total_polls, avg_ms, min_ms, max_ms, outage_s, uptime_pct)
|
||||
VALUES (@hour, @total_polls, @avg_ms, @min_ms, @max_ms, @outage_s, @uptime_pct)
|
||||
ON CONFLICT(hour) DO UPDATE SET
|
||||
total_polls = excluded.total_polls, avg_ms = excluded.avg_ms,
|
||||
min_ms = excluded.min_ms, max_ms = excluded.max_ms,
|
||||
outage_s = excluded.outage_s, uptime_pct = excluded.uptime_pct
|
||||
`),
|
||||
upsertDaily: db.prepare(`
|
||||
INSERT INTO daily_stats (date, total_polls, avg_ms, min_ms, max_ms, outage_s, uptime_pct)
|
||||
VALUES (@date, @total_polls, @avg_ms, @min_ms, @max_ms, @outage_s, @uptime_pct)
|
||||
ON CONFLICT(date) DO UPDATE SET
|
||||
total_polls = excluded.total_polls, avg_ms = excluded.avg_ms,
|
||||
min_ms = excluded.min_ms, max_ms = excluded.max_ms,
|
||||
outage_s = excluded.outage_s, uptime_pct = excluded.uptime_pct
|
||||
`),
|
||||
};
|
||||
|
||||
// ── Console helpers ───────────────────────────────────────────────────────────
|
||||
const fmt = {
|
||||
ts: s => chalk.dim(s),
|
||||
label: s => chalk.bold.cyan(s.padEnd(22)),
|
||||
value: s => chalk.white(String(s)),
|
||||
ok: s => chalk.bold.green(s),
|
||||
warn: s => chalk.bold.yellow(s),
|
||||
error: s => chalk.bold.red(s),
|
||||
delta: n => n > 0 ? chalk.bold.magenta(`+${n}`) : chalk.dim('±0'),
|
||||
counter: n => chalk.bold.yellow(String(n)),
|
||||
sep: () => chalk.dim('─'.repeat(50)),
|
||||
};
|
||||
|
||||
function logOk(label, data, delta, responseMs) {
|
||||
const rtt = responseMs != null ? chalk.bold.green(`${responseMs} ms`) : chalk.dim('null');
|
||||
console.log(fmt.sep());
|
||||
console.log(fmt.ts(new Date().toISOString()), fmt.ok(`[${label}]`),
|
||||
chalk.bold.white(data.service), chalk.dim(`v${data.version}`), chalk.dim('·'), rtt);
|
||||
console.log(fmt.label('signatureCounter:'), fmt.counter(data.signatureCounter), fmt.delta(delta));
|
||||
console.log(fmt.label('transactionCounter:'), fmt.value(data.transactionCounter));
|
||||
console.log(fmt.label('registeredClients:'), fmt.value(data.registeredClients));
|
||||
console.log(fmt.label('initialized:'), data.initialized ? fmt.ok('true') : fmt.warn('false'));
|
||||
console.log(fmt.label('serialNumber:'), chalk.dim((data.serialNumber?.slice(0, 16) ?? '') + '…'));
|
||||
console.log(fmt.label('dbPath:'), fmt.value(data.dbPath));
|
||||
}
|
||||
|
||||
function logError(err, responseMs) {
|
||||
const rtt = responseMs != null ? chalk.bold.red(`${responseMs} ms`) : chalk.dim('null');
|
||||
console.log(fmt.sep());
|
||||
console.error(fmt.ts(new Date().toISOString()), fmt.error('[ERROR]'), err.message, chalk.dim('·'), rtt);
|
||||
}
|
||||
|
||||
// ── Poller ────────────────────────────────────────────────────────────────────
|
||||
let wasError = false;
|
||||
let isFirstRun = true;
|
||||
|
||||
async function poll() {
|
||||
let data, responseMs = null;
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const res = await fetch(TSE_URL, { signal: AbortSignal.timeout(8_000) });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
||||
data = await res.json();
|
||||
responseMs = Date.now() - t0;
|
||||
} catch (err) {
|
||||
responseMs = err.name === 'TimeoutError' ? null : Date.now() - t0;
|
||||
if (!wasError) { logError(err, responseMs); wasError = true; }
|
||||
return;
|
||||
}
|
||||
|
||||
const prev = lastRow.get();
|
||||
const delta = prev ? (data.signatureCounter - prev.signature_counter) : 0;
|
||||
|
||||
insertEvent.run({
|
||||
ts: new Date().toISOString(),
|
||||
status: data.status ?? null,
|
||||
service: data.service ?? null,
|
||||
version: data.version ?? null,
|
||||
serialNumber: data.serialNumber ?? null,
|
||||
signatureCounter: data.signatureCounter ?? null,
|
||||
transactionCounter: data.transactionCounter ?? null,
|
||||
registeredClients: data.registeredClients ?? null,
|
||||
initialized: data.initialized ? 1 : 0,
|
||||
createdAt: data.createdAt ?? null,
|
||||
fccVersion: data.fccVersion ?? null,
|
||||
dbPath: data.dbPath ?? null,
|
||||
delta,
|
||||
responseMs,
|
||||
});
|
||||
|
||||
if (isFirstRun) { logOk('INIT', data, delta, responseMs); isFirstRun = false; }
|
||||
else if (wasError) { logOk('RECOVERED', data, delta, responseMs); }
|
||||
wasError = false;
|
||||
}
|
||||
|
||||
// ── Aggregator ────────────────────────────────────────────────────────────────
|
||||
function msStats(rows) {
|
||||
const vals = rows.map(r => r.response_ms).filter(v => v != null);
|
||||
if (!vals.length) return { avg_ms: null, min_ms: null, max_ms: null };
|
||||
return {
|
||||
avg_ms: Math.round(vals.reduce((a, b) => a + b, 0) / vals.length),
|
||||
min_ms: Math.min(...vals),
|
||||
max_ms: Math.max(...vals),
|
||||
};
|
||||
}
|
||||
|
||||
function outageSecondsInWindow(winStart, winEnd) {
|
||||
const wsMs = new Date(winStart).getTime(), weMs = new Date(winEnd).getTime();
|
||||
return Math.round(
|
||||
stmts.outagesOverlap.all(winEnd, winStart).reduce((sum, o) => {
|
||||
const s = Math.max(new Date(o.started_at).getTime(), wsMs);
|
||||
const e = o.recovered_at ? Math.min(new Date(o.recovered_at).getTime(), weMs) : weMs;
|
||||
return sum + Math.max(0, (e - s) / 1000);
|
||||
}, 0)
|
||||
);
|
||||
}
|
||||
|
||||
function detectOutages(fromTs) {
|
||||
for (const g of stmts.gapsFrom.all(fromTs, POLL_S, GAP_S)) {
|
||||
stmts.upsertOutage.run({ started_at: g.started_at, recovered_at: g.recovered_at, duration_s: g.gap_s - POLL_S });
|
||||
}
|
||||
}
|
||||
|
||||
function* eachHour(fromTs, toTs) {
|
||||
const cur = new Date(fromTs); cur.setUTCMinutes(0, 0, 0);
|
||||
const end = new Date(toTs);
|
||||
while (cur <= end) { yield cur.toISOString(); cur.setUTCHours(cur.getUTCHours() + 1); }
|
||||
}
|
||||
function* eachDay(fromTs, toTs) {
|
||||
const cur = new Date(fromTs); cur.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(toTs);
|
||||
while (cur <= end) { yield cur.toISOString().slice(0, 10); cur.setUTCDate(cur.getUTCDate() + 1); }
|
||||
}
|
||||
|
||||
function aggregate(fromTs, toTs = new Date().toISOString()) {
|
||||
detectOutages(fromTs);
|
||||
for (const h of eachHour(fromTs, toTs)) {
|
||||
const hourEnd = new Date(new Date(h).getTime() + 3_600_000).toISOString();
|
||||
const rows = stmts.eventsInRange.all(h, hourEnd);
|
||||
if (!rows.length) continue; // no events → no row
|
||||
const { avg_ms, min_ms, max_ms } = msStats(rows);
|
||||
const outage_s = outageSecondsInWindow(h, hourEnd);
|
||||
const uptime_pct = +Math.max(0, Math.min(100, (3600 - outage_s) / 3600 * 100)).toFixed(1);
|
||||
stmts.upsertHourly.run({ hour: h, total_polls: rows.length, avg_ms, min_ms, max_ms, outage_s, uptime_pct });
|
||||
}
|
||||
for (const d of eachDay(fromTs, toTs)) {
|
||||
const dayStart = d + 'T00:00:00Z';
|
||||
const dayEnd = new Date(new Date(dayStart).getTime() + 86_400_000).toISOString();
|
||||
const rows = stmts.eventsInRange.all(dayStart, dayEnd);
|
||||
if (!rows.length) continue; // no events → no row
|
||||
const { avg_ms, min_ms, max_ms } = msStats(rows);
|
||||
const outage_s = outageSecondsInWindow(dayStart, dayEnd);
|
||||
const uptime_pct = +Math.max(0, Math.min(100, (86400 - outage_s) / 86400 * 100)).toFixed(2);
|
||||
stmts.upsertDaily.run({ date: d, total_polls: rows.length, avg_ms, min_ms, max_ms, outage_s, uptime_pct });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Express API ───────────────────────────────────────────────────────────────
|
||||
const app = express();
|
||||
app.use((_, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); next(); });
|
||||
|
||||
app.get('/api/summary', (_, res) => {
|
||||
const stats = db.prepare(`
|
||||
SELECT SUM(total_polls) AS total_polls, ROUND(AVG(avg_ms),0) AS avg_ms,
|
||||
MIN(min_ms) AS min_ms, MAX(max_ms) AS max_ms,
|
||||
SUM(outage_s) AS total_outage_s, ROUND(AVG(uptime_pct),2) AS avg_uptime_pct
|
||||
FROM daily_stats
|
||||
`).get();
|
||||
const outage_count = db.prepare('SELECT COUNT(*) AS n FROM outages').get().n;
|
||||
const live = db.prepare('SELECT * FROM events ORDER BY id DESC LIMIT 1').get() ?? null;
|
||||
res.json({ ...stats, outage_count, live });
|
||||
});
|
||||
|
||||
app.get('/api/hourly', (req, res) => {
|
||||
const days = Math.min(30, Math.max(1, parseInt(req.query.days) || 7));
|
||||
const from = new Date(Date.now() - days * 86_400_000).toISOString();
|
||||
res.json(db.prepare('SELECT * FROM hourly_stats WHERE hour >= ? ORDER BY hour').all(from));
|
||||
});
|
||||
|
||||
app.get('/api/daily', (req, res) => {
|
||||
const days = Math.min(90, Math.max(1, parseInt(req.query.days) || 30));
|
||||
const from = new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10);
|
||||
res.json(db.prepare('SELECT * FROM daily_stats WHERE date >= ? ORDER BY date').all(from));
|
||||
});
|
||||
|
||||
app.get('/api/outages', (req, res) => {
|
||||
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 50));
|
||||
res.json(db.prepare('SELECT * FROM outages ORDER BY started_at DESC LIMIT ?').all(limit));
|
||||
});
|
||||
|
||||
app.get('/api/live', (_, res) => {
|
||||
res.json(db.prepare('SELECT * FROM events ORDER BY id DESC LIMIT 1').get() ?? null);
|
||||
});
|
||||
|
||||
// ── HTTP server (created first so Vite HMR can share it) ─────────────────────
|
||||
const httpServer = createHttpServer(app);
|
||||
|
||||
// ── Vite dev server (programmatic, middleware mode) ───────────────────────────
|
||||
const vite = await createViteServer({
|
||||
root: __dirname,
|
||||
configFile: false,
|
||||
plugins: [react()],
|
||||
server: {
|
||||
middlewareMode: true,
|
||||
hmr: { server: httpServer },
|
||||
watch: {
|
||||
ignored: ['**/tse_events.db*', '**/monitor.js', '**/node_modules/**']
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['react', 'react-dom', '@mui/material', '@mui/icons-material', 'recharts', '@emotion/react', '@emotion/styled']
|
||||
},
|
||||
appType: 'spa',
|
||||
});
|
||||
|
||||
app.use(vite.middlewares); // serves React app + HMR on all non-/api routes
|
||||
|
||||
httpServer.listen(PORT, () => {
|
||||
console.log(chalk.bold.bgCyan.black(' TSE MONITOR ') + ' ' + chalk.dim(TSE_URL));
|
||||
console.log(chalk.dim(`DB → ${DB_PATH} | poll every ${POLL_MS / 1000}s`));
|
||||
console.log(chalk.bold.blue(`Dashboard → http://localhost:${PORT}`));
|
||||
console.log(fmt.sep());
|
||||
});
|
||||
|
||||
// ── Aggregation — backfill then incremental ───────────────────────────────────
|
||||
{
|
||||
const { v: minTs } = db.prepare('SELECT MIN(ts) AS v FROM events').get() ?? {};
|
||||
const { v: maxTs } = db.prepare('SELECT MAX(ts) AS v FROM events').get() ?? {};
|
||||
if (minTs) {
|
||||
console.log(chalk.dim(`[agg] back-filling ${minTs} → ${maxTs} …`));
|
||||
aggregate(minTs, maxTs);
|
||||
console.log(chalk.dim('[agg] done'));
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(
|
||||
() => aggregate(new Date(Date.now() - AGG_LOOKBACK_H * 3_600_000).toISOString()),
|
||||
AGG_INTERVAL
|
||||
);
|
||||
|
||||
// ── Poll ──────────────────────────────────────────────────────────────────────
|
||||
poll();
|
||||
setInterval(poll, POLL_MS);
|
||||
|
||||
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('\n' + chalk.bold.red('Shutting down…'));
|
||||
await vite.close();
|
||||
httpServer.close();
|
||||
db.close();
|
||||
process.exit(0);
|
||||
});
|
||||
3587
package-lock.json
generated
Normal file
3587
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "tsemonitor",
|
||||
"version": "1.0.0",
|
||||
"description": "TSE / CloudTSE signature-counter monitor + dashboard",
|
||||
"type": "module",
|
||||
"main": "monitor.js",
|
||||
"scripts": {
|
||||
"start": "node monitor.js",
|
||||
"dev": "node monitor.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"chalk": "^5.6.2",
|
||||
"express": "^5.2.1",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"@mui/material": "^9.2.0",
|
||||
"@mui/icons-material": "^9.2.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"recharts": "^3.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"vite": "^8.1.3"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user