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:
@@ -12,41 +12,74 @@ const RANGE_CONFIG: Record<TimeRange, { sqlOffset: string; strftime: string; bar
|
|||||||
'365d': { sqlOffset: '-365 days', strftime: '%Y-%m', bars: 12 },
|
'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) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const range = (searchParams.get('range') || '24h') as TimeRange;
|
const range = (searchParams.get('range') || '24h') as TimeRange;
|
||||||
const config = RANGE_CONFIG[range] || RANGE_CONFIG['24h'];
|
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(`
|
const live = await db.all(`
|
||||||
SELECT service_name as name, url, status, latency
|
SELECT service_name as name, url, status, latency
|
||||||
FROM uptime_logs
|
FROM uptime_logs
|
||||||
WHERE id IN (SELECT MAX(id) FROM uptime_logs GROUP BY service_name)
|
WHERE id IN (SELECT MAX(id) FROM uptime_logs GROUP BY service_name)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Get 24h stats
|
// Raw logs for time-interval uptime (8-day window covers 24h + 7d with buffer)
|
||||||
const stats24h = await db.all(`
|
const rawLogs = await db.all(`
|
||||||
SELECT service_name,
|
SELECT service_name, status, timestamp
|
||||||
count(*) as total,
|
|
||||||
sum(case when status = 'up' then 1 else 0 end) as up_count
|
|
||||||
FROM uptime_logs
|
FROM uptime_logs
|
||||||
WHERE timestamp > datetime('now', '-24 hours')
|
WHERE timestamp > datetime('now', '-8 days')
|
||||||
GROUP BY service_name
|
ORDER BY service_name, timestamp ASC
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Get 7d stats
|
const rawByService: Record<string, RawLog[]> = {};
|
||||||
const stats7d = await db.all(`
|
for (const row of rawLogs) {
|
||||||
SELECT service_name,
|
if (!rawByService[row.service_name]) rawByService[row.service_name] = [];
|
||||||
count(*) as total,
|
rawByService[row.service_name].push({ status: row.status, timestamp: row.timestamp });
|
||||||
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
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Get lifetime stats (all available data)
|
// Lifetime stats (check-counting — efficient for large datasets)
|
||||||
const statsLifetime = await db.all(`
|
const statsLifetime = await db.all(`
|
||||||
SELECT service_name,
|
SELECT service_name,
|
||||||
count(*) as total,
|
count(*) as total,
|
||||||
@@ -55,18 +88,17 @@ export async function GET(request: Request) {
|
|||||||
GROUP BY service_name
|
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(`
|
const avgLatencyRows = await db.all(`
|
||||||
SELECT service_name,
|
SELECT service_name,
|
||||||
ROUND(AVG(latency)) as avg_latency
|
ROUND(AVG(latency)) as avg_latency
|
||||||
FROM uptime_logs
|
FROM uptime_logs
|
||||||
WHERE timestamp > datetime('now', '-24 hours')
|
WHERE timestamp > datetime('now', '-24 hours')
|
||||||
AND status = 'up'
|
AND status = 'up' AND latency > 0
|
||||||
AND latency > 0
|
|
||||||
GROUP BY service_name
|
GROUP BY service_name
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Get bucketed history for the requested range
|
// Bucketed history for bar display
|
||||||
const history = await db.all(`
|
const history = await db.all(`
|
||||||
SELECT service_name,
|
SELECT service_name,
|
||||||
strftime('${config.strftime}', timestamp) as bucket,
|
strftime('${config.strftime}', timestamp) as bucket,
|
||||||
@@ -78,7 +110,6 @@ export async function GET(request: Request) {
|
|||||||
ORDER BY bucket ASC
|
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 }>> = {};
|
const historyMap: Record<string, Array<{ bucket: string; up_count: number; total: number }>> = {};
|
||||||
for (const row of history) {
|
for (const row of history) {
|
||||||
if (!historyMap[row.service_name]) historyMap[row.service_name] = [];
|
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 results = live.map(l => {
|
||||||
const s24 = stats24h.find(s => s.service_name === l.name);
|
const logs = rawByService[l.name] || [];
|
||||||
const s7d = stats7d.find(s => s.service_name === l.name);
|
|
||||||
const sLife = statsLifetime.find(s => s.service_name === l.name);
|
|
||||||
const avgLat = avgLatencyRows.find(s => s.service_name === l.name);
|
const avgLat = avgLatencyRows.find(s => s.service_name === l.name);
|
||||||
|
const sLife = statsLifetime.find(s => s.service_name === l.name);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...l,
|
...l,
|
||||||
uptime24h: s24 ? Math.round((s24.up_count / s24.total) * 100) : 100,
|
uptime24h: calculateTimeUptime(logs, nowMs - MS_24H, nowMs),
|
||||||
uptime7d: s7d ? Math.round((s7d.up_count / s7d.total) * 100) : 100,
|
uptime7d: calculateTimeUptime(logs, nowMs - MS_7D, nowMs),
|
||||||
uptimeLifetime: sLife ? Math.round((sLife.up_count / sLife.total) * 100) : 100,
|
uptimeLifetime: sLife ? Math.round((sLife.up_count / sLife.total) * 100) : 0,
|
||||||
avgLatency24h: avgLat ? avgLat.avg_latency : 0,
|
avgLatency24h: avgLat ? avgLat.avg_latency : 0,
|
||||||
history: historyMap[l.name] || [],
|
history: historyMap[l.name] || [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -59,6 +59,16 @@ const RANGE_CONFIG: Record<TimeRange, { bars: number; bucketMs: number }> = {
|
|||||||
|
|
||||||
// --- Helpers ---
|
// --- 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 {
|
function bucketStatus(upCount: number, total: number): BarStatus {
|
||||||
if (total === 0) return 'unknown';
|
if (total === 0) return 'unknown';
|
||||||
if (upCount === total) return 'up';
|
if (upCount === total) return 'up';
|
||||||
@@ -81,7 +91,7 @@ function buildBars(range: TimeRange, history: HistoryBucket[] | undefined): BarD
|
|||||||
if (range === '24h') {
|
if (range === '24h') {
|
||||||
for (let i = config.bars - 1; i >= 0; i--) {
|
for (let i = config.bars - 1; i >= 0; i--) {
|
||||||
const d = subHours(now, 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 h = bucketMap.get(key);
|
||||||
const hourStart = new Date(d);
|
const hourStart = new Date(d);
|
||||||
hourStart.setMinutes(0, 0, 0);
|
hourStart.setMinutes(0, 0, 0);
|
||||||
@@ -95,7 +105,7 @@ function buildBars(range: TimeRange, history: HistoryBucket[] | undefined): BarD
|
|||||||
} else if (range === '7d' || range === '30d') {
|
} else if (range === '7d' || range === '30d') {
|
||||||
for (let i = config.bars - 1; i >= 0; i--) {
|
for (let i = config.bars - 1; i >= 0; i--) {
|
||||||
const d = subDays(now, i);
|
const d = subDays(now, i);
|
||||||
const key = format(d, 'yyyy-MM-dd');
|
const key = toUTCKey(d, 'day');
|
||||||
const h = bucketMap.get(key);
|
const h = bucketMap.get(key);
|
||||||
bars.push({
|
bars.push({
|
||||||
label: format(d, 'MMM d, yyyy'),
|
label: format(d, 'MMM d, yyyy'),
|
||||||
@@ -108,7 +118,7 @@ function buildBars(range: TimeRange, history: HistoryBucket[] | undefined): BarD
|
|||||||
// 365d — monthly buckets
|
// 365d — monthly buckets
|
||||||
for (let i = 11; i >= 0; i--) {
|
for (let i = 11; i >= 0; i--) {
|
||||||
const d = subMonths(now, i);
|
const d = subMonths(now, i);
|
||||||
const key = format(d, 'yyyy-MM');
|
const key = toUTCKey(d, 'month');
|
||||||
const h = bucketMap.get(key);
|
const h = bucketMap.get(key);
|
||||||
bars.push({
|
bars.push({
|
||||||
label: format(d, 'MMM yyyy'),
|
label: format(d, 'MMM yyyy'),
|
||||||
|
|||||||
Reference in New Issue
Block a user