Fix 24h bar timezone mismatch and add time-interval uptime calculation

Bars were all grey for 24h view because SQL bucket keys (UTC) never
matched frontend keys (local timezone). Added toUTCKey() helper to
generate UTC keys for all ranges. Replaced check-counting uptime with
time-interval method for 24h/7d: time between consecutive up checks
counts as uptime, intervals involving down checks count as downtime,
uncovered time is unknown.
This commit is contained in:
Shivam Patel
2026-02-09 04:15:34 -05:00
parent d1fd790533
commit e47a719d79
2 changed files with 100 additions and 60 deletions

View File

@@ -12,73 +12,104 @@ const RANGE_CONFIG: Record<TimeRange, { sqlOffset: string; strftime: string; bar
'365d': { sqlOffset: '-365 days', strftime: '%Y-%m', bars: 12 },
};
interface RawLog {
status: string;
timestamp: string;
}
/**
* Time-interval uptime: time between consecutive "up" checks = uptime.
* Intervals involving any "down" check = downtime.
* Remaining uncovered time = unknown.
* Returns uptime / totalPeriod as a percentage.
*/
function calculateTimeUptime(
logs: RawLog[],
periodStartMs: number,
periodEndMs: number,
): number {
const totalMs = periodEndMs - periodStartMs;
if (totalMs <= 0 || logs.length < 2) return 0;
let uptimeMs = 0;
for (let i = 0; i < logs.length - 1; i++) {
const t1 = new Date(logs[i].timestamp).getTime();
const t2 = new Date(logs[i + 1].timestamp).getTime();
const start = Math.max(t1, periodStartMs);
const end = Math.min(t2, periodEndMs);
if (start >= end) continue;
if (logs[i].status === 'up' && logs[i + 1].status === 'up') {
uptimeMs += end - start;
}
}
return totalMs > 0 ? Math.round((uptimeMs / totalMs) * 100) : 0;
}
export async function GET(request: Request) {
try {
const db = await getDb();
const { searchParams } = new URL(request.url);
const range = (searchParams.get('range') || '24h') as TimeRange;
const config = RANGE_CONFIG[range] || RANGE_CONFIG['24h'];
const nowMs = Date.now();
const MS_24H = 24 * 3600_000;
const MS_7D = 7 * 24 * 3600_000;
// Get live status (most recent log per service)
// Live status (most recent log per service)
const live = await db.all(`
SELECT service_name as name, url, status, latency
FROM uptime_logs
WHERE id IN (SELECT MAX(id) FROM uptime_logs GROUP BY service_name)
`);
SELECT service_name as name, url, status, latency
FROM uptime_logs
WHERE id IN (SELECT MAX(id) FROM uptime_logs GROUP BY service_name)
`);
// Get 24h stats
const stats24h = await db.all(`
SELECT service_name,
count(*) as total,
sum(case when status = 'up' then 1 else 0 end) as up_count
FROM uptime_logs
WHERE timestamp > datetime('now', '-24 hours')
GROUP BY service_name
`);
// Raw logs for time-interval uptime (8-day window covers 24h + 7d with buffer)
const rawLogs = await db.all(`
SELECT service_name, status, timestamp
FROM uptime_logs
WHERE timestamp > datetime('now', '-8 days')
ORDER BY service_name, timestamp ASC
`);
// Get 7d stats
const stats7d = await db.all(`
SELECT service_name,
count(*) as total,
sum(case when status = 'up' then 1 else 0 end) as up_count
FROM uptime_logs
WHERE timestamp > datetime('now', '-7 days')
GROUP BY service_name
`);
const rawByService: Record<string, RawLog[]> = {};
for (const row of rawLogs) {
if (!rawByService[row.service_name]) rawByService[row.service_name] = [];
rawByService[row.service_name].push({ status: row.status, timestamp: row.timestamp });
}
// Get lifetime stats (all available data)
// Lifetime stats (check-counting — efficient for large datasets)
const statsLifetime = await db.all(`
SELECT service_name,
count(*) as total,
sum(case when status = 'up' then 1 else 0 end) as up_count
FROM uptime_logs
GROUP BY service_name
`);
SELECT service_name,
count(*) as total,
sum(case when status = 'up' then 1 else 0 end) as up_count
FROM uptime_logs
GROUP BY service_name
`);
// Get average latency over last 24h (only for 'up' checks)
// Average latency (24h, up checks only)
const avgLatencyRows = await db.all(`
SELECT service_name,
ROUND(AVG(latency)) as avg_latency
FROM uptime_logs
WHERE timestamp > datetime('now', '-24 hours')
AND status = 'up'
AND latency > 0
GROUP BY service_name
`);
SELECT service_name,
ROUND(AVG(latency)) as avg_latency
FROM uptime_logs
WHERE timestamp > datetime('now', '-24 hours')
AND status = 'up' AND latency > 0
GROUP BY service_name
`);
// Get bucketed history for the requested range
// Bucketed history for bar display
const history = await db.all(`
SELECT service_name,
strftime('${config.strftime}', timestamp) as bucket,
count(*) as total,
sum(case when status = 'up' then 1 else 0 end) as up_count
FROM uptime_logs
WHERE timestamp > datetime('now', '${config.sqlOffset}')
GROUP BY service_name, bucket
ORDER BY bucket ASC
`);
SELECT service_name,
strftime('${config.strftime}', timestamp) as bucket,
count(*) as total,
sum(case when status = 'up' then 1 else 0 end) as up_count
FROM uptime_logs
WHERE timestamp > datetime('now', '${config.sqlOffset}')
GROUP BY service_name, bucket
ORDER BY bucket ASC
`);
// Group history by service — return up_count and total per bucket
const historyMap: Record<string, Array<{ bucket: string; up_count: number; total: number }>> = {};
for (const row of history) {
if (!historyMap[row.service_name]) historyMap[row.service_name] = [];
@@ -89,18 +120,17 @@ export async function GET(request: Request) {
});
}
// Merge data
// Merge results
const results = live.map(l => {
const s24 = stats24h.find(s => s.service_name === l.name);
const s7d = stats7d.find(s => s.service_name === l.name);
const sLife = statsLifetime.find(s => s.service_name === l.name);
const logs = rawByService[l.name] || [];
const avgLat = avgLatencyRows.find(s => s.service_name === l.name);
const sLife = statsLifetime.find(s => s.service_name === l.name);
return {
...l,
uptime24h: s24 ? Math.round((s24.up_count / s24.total) * 100) : 100,
uptime7d: s7d ? Math.round((s7d.up_count / s7d.total) * 100) : 100,
uptimeLifetime: sLife ? Math.round((sLife.up_count / sLife.total) * 100) : 100,
uptime24h: calculateTimeUptime(logs, nowMs - MS_24H, nowMs),
uptime7d: calculateTimeUptime(logs, nowMs - MS_7D, nowMs),
uptimeLifetime: sLife ? Math.round((sLife.up_count / sLife.total) * 100) : 0,
avgLatency24h: avgLat ? avgLat.avg_latency : 0,
history: historyMap[l.name] || [],
};

View File

@@ -59,6 +59,16 @@ const RANGE_CONFIG: Record<TimeRange, { bars: number; bucketMs: number }> = {
// --- Helpers ---
function toUTCKey(d: Date, mode: 'hour' | 'day' | 'month'): string {
const Y = d.getUTCFullYear();
const M = String(d.getUTCMonth() + 1).padStart(2, '0');
if (mode === 'month') return `${Y}-${M}`;
const D = String(d.getUTCDate()).padStart(2, '0');
if (mode === 'day') return `${Y}-${M}-${D}`;
const H = String(d.getUTCHours()).padStart(2, '0');
return `${Y}-${M}-${D} ${H}:00`;
}
function bucketStatus(upCount: number, total: number): BarStatus {
if (total === 0) return 'unknown';
if (upCount === total) return 'up';
@@ -81,7 +91,7 @@ function buildBars(range: TimeRange, history: HistoryBucket[] | undefined): BarD
if (range === '24h') {
for (let i = config.bars - 1; i >= 0; i--) {
const d = subHours(now, i);
const key = format(d, 'yyyy-MM-dd HH') + ':00';
const key = toUTCKey(d, 'hour');
const h = bucketMap.get(key);
const hourStart = new Date(d);
hourStart.setMinutes(0, 0, 0);
@@ -95,7 +105,7 @@ function buildBars(range: TimeRange, history: HistoryBucket[] | undefined): BarD
} else if (range === '7d' || range === '30d') {
for (let i = config.bars - 1; i >= 0; i--) {
const d = subDays(now, i);
const key = format(d, 'yyyy-MM-dd');
const key = toUTCKey(d, 'day');
const h = bucketMap.get(key);
bars.push({
label: format(d, 'MMM d, yyyy'),
@@ -108,7 +118,7 @@ function buildBars(range: TimeRange, history: HistoryBucket[] | undefined): BarD
// 365d — monthly buckets
for (let i = 11; i >= 0; i--) {
const d = subMonths(now, i);
const key = format(d, 'yyyy-MM');
const key = toUTCKey(d, 'month');
const h = bucketMap.get(key);
bars.push({
label: format(d, 'MMM yyyy'),