This commit is contained in:
seb
2026-07-07 18:09:52 +02:00
commit e0cacf6004
16 changed files with 4569 additions and 0 deletions

16
dashboard/index.html Normal file
View 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
View 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
View 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');

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

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

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

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