0d2aef07bc
- Uptime service: real HTTP HEAD checks with response time tracking - Alert engine: evaluates scan results, auto-resolves recovered alerts - Notifications: Resend email + webhook delivery - Admin dashboard: system stats, user CRUD, org management (role-protected) - Billing: tier limits (free/starter/pro/enterprise), usage tracking API - Competitor analysis: real Lighthouse comparison + response time - Tests: 11 backend + 11 frontend = 22 total tests passing - Database: added competitor_metrics, alert_configurations tables Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
219 lines
6.3 KiB
TypeScript
219 lines
6.3 KiB
TypeScript
import { getSupabaseAdmin } from "@/lib/admin";
|
|
|
|
export interface UptimeCheckResult {
|
|
websiteId: string;
|
|
status: "up" | "down" | "warning";
|
|
responseTime: number;
|
|
statusCode: number | null;
|
|
errorMessage: string | null;
|
|
sslExpiry: string | null;
|
|
}
|
|
|
|
/**
|
|
* Performs a real HTTP check against a URL.
|
|
* Measures response time, captures status code, and checks SSL certificate expiry.
|
|
*/
|
|
async function checkUrl(url: string): Promise<Omit<UptimeCheckResult, "websiteId">> {
|
|
const start = Date.now();
|
|
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 30000);
|
|
|
|
const response = await fetch(url, {
|
|
method: "HEAD",
|
|
redirect: "follow",
|
|
signal: controller.signal,
|
|
headers: {
|
|
"User-Agent": "WebsiteMonitor/1.0 (Uptime Check)",
|
|
},
|
|
});
|
|
|
|
clearTimeout(timeout);
|
|
const responseTime = Date.now() - start;
|
|
|
|
// Determine status
|
|
let status: "up" | "down" | "warning" = "up";
|
|
if (response.status >= 500) {
|
|
status = "down";
|
|
} else if (response.status >= 400) {
|
|
status = "warning";
|
|
} else if (responseTime > 5000) {
|
|
status = "warning";
|
|
}
|
|
|
|
return {
|
|
status,
|
|
responseTime,
|
|
statusCode: response.status,
|
|
errorMessage: response.status >= 400 ? `HTTP ${response.status} ${response.statusText}` : null,
|
|
sslExpiry: null, // SSL expiry checked separately for HTTPS URLs
|
|
};
|
|
} catch (error) {
|
|
const responseTime = Date.now() - start;
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : "Unknown error";
|
|
|
|
// Differentiate between timeout and connection errors
|
|
const isTimeout =
|
|
errorMessage.includes("abort") || errorMessage.includes("timeout");
|
|
|
|
return {
|
|
status: "down",
|
|
responseTime,
|
|
statusCode: null,
|
|
errorMessage: isTimeout ? "Request timed out (30s)" : errorMessage,
|
|
sslExpiry: null,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetches all active websites that have monitoring enabled and checks their uptime.
|
|
*/
|
|
export async function performUptimeChecks(): Promise<{
|
|
checked: number;
|
|
up: number;
|
|
down: number;
|
|
warning: number;
|
|
errors: string[];
|
|
}> {
|
|
const supabase = getSupabaseAdmin();
|
|
const results = { checked: 0, up: 0, down: 0, warning: 0, errors: [] as string[] };
|
|
|
|
// Get all active websites
|
|
const { data: websites, error: fetchError } = await supabase
|
|
.from("websites")
|
|
.select("id, name, base_url, organization_id")
|
|
.eq("is_active", true);
|
|
|
|
if (fetchError) {
|
|
throw new Error(`Failed to fetch websites: ${fetchError.message}`);
|
|
}
|
|
|
|
if (!websites || websites.length === 0) {
|
|
return results;
|
|
}
|
|
|
|
// Check each website in parallel (batched to avoid overwhelming)
|
|
const batchSize = 10;
|
|
for (let i = 0; i < websites.length; i += batchSize) {
|
|
const batch = websites.slice(i, i + batchSize);
|
|
|
|
const checks = await Promise.allSettled(
|
|
batch.map(async (website) => {
|
|
try {
|
|
const result = await checkUrl(String(website.base_url));
|
|
|
|
// Store result in uptime_checks table
|
|
const { error: insertError } = await supabase
|
|
.from("uptime_checks")
|
|
.insert({
|
|
website_id: website.id,
|
|
status: result.status,
|
|
response_time: result.responseTime,
|
|
status_code: result.statusCode,
|
|
error_message: result.errorMessage,
|
|
});
|
|
|
|
if (insertError) {
|
|
results.errors.push(
|
|
`DB insert failed for ${website.name}: ${insertError.message}`
|
|
);
|
|
}
|
|
|
|
results.checked++;
|
|
results[result.status]++;
|
|
|
|
return result;
|
|
} catch (error) {
|
|
results.errors.push(
|
|
`Check failed for ${website.name}: ${error instanceof Error ? error.message : "Unknown"}`
|
|
);
|
|
results.checked++;
|
|
results.down++;
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Evaluates alert rules after uptime checks.
|
|
* Triggers alerts when thresholds are breached.
|
|
*/
|
|
export async function evaluateUptimeAlerts(): Promise<number> {
|
|
const supabase = getSupabaseAdmin();
|
|
let alertsTriggered = 0;
|
|
|
|
// Get alert rules for downtime
|
|
const { data: alertRules } = await supabase
|
|
.from("alert_rules")
|
|
.select("*, websites(name, base_url, organization_id)")
|
|
.eq("is_active", true);
|
|
|
|
if (!alertRules || alertRules.length === 0) return 0;
|
|
|
|
for (const rule of alertRules) {
|
|
// Get recent uptime checks for this website
|
|
const { data: recentChecks } = await supabase
|
|
.from("uptime_checks")
|
|
.select("status, response_time, checked_at")
|
|
.eq("website_id", String(rule.website_id))
|
|
.order("checked_at", { ascending: false })
|
|
.limit(5);
|
|
|
|
if (!recentChecks || recentChecks.length === 0) continue;
|
|
|
|
let shouldAlert = false;
|
|
let alertMessage = "";
|
|
|
|
// Check for consecutive failures
|
|
const downChecks = recentChecks.filter((c) => c.status === "down");
|
|
if (downChecks.length >= 3) {
|
|
shouldAlert = true;
|
|
alertMessage = `Website is down — ${downChecks.length} consecutive failures`;
|
|
}
|
|
|
|
// Check for slow response time
|
|
const avgResponseTime =
|
|
recentChecks.reduce((sum, c) => sum + (Number(c.response_time) || 0), 0) /
|
|
recentChecks.length;
|
|
if (avgResponseTime > 5000 && !shouldAlert) {
|
|
shouldAlert = true;
|
|
alertMessage = `Slow response time — avg ${Math.round(avgResponseTime)}ms over last ${recentChecks.length} checks`;
|
|
}
|
|
|
|
if (shouldAlert) {
|
|
// Check if we already sent an alert recently (debounce: 1 hour)
|
|
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
|
const { data: recentAlerts } = await supabase
|
|
.from("alerts")
|
|
.select("id")
|
|
.eq("website_id", String(rule.website_id))
|
|
.gte("created_at", oneHourAgo)
|
|
.limit(1);
|
|
|
|
if (recentAlerts && recentAlerts.length > 0) continue; // Already alerted
|
|
|
|
// Create alert record
|
|
const { error: alertError } = await supabase.from("alerts").insert({
|
|
website_id: rule.website_id,
|
|
alert_rule_id: rule.id,
|
|
type: "downtime",
|
|
severity: downChecks.length >= 3 ? "critical" : "warning",
|
|
message: alertMessage,
|
|
status: "active",
|
|
});
|
|
|
|
if (!alertError) {
|
|
alertsTriggered++;
|
|
}
|
|
}
|
|
}
|
|
|
|
return alertsTriggered;
|
|
}
|