diff --git a/Dockerfile b/Dockerfile index e34e061..797a45e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,6 +49,6 @@ VOLUME /app/data EXPOSE 4321 HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:4321/ || exit 1 + CMD wget --no-verbose --tries=1 --spider http://localhost:4321/api/health || exit 1 ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/docker-compose.homelab.yml b/docker-compose.homelab.yml index dcef182..cb3fd8d 100644 --- a/docker-compose.homelab.yml +++ b/docker-compose.homelab.yml @@ -18,7 +18,7 @@ services: - DATABASE_URL=sqlite://data/gitea-mirror.db - DELAY=${DELAY:-3600} healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:4321/health"] + test: ["CMD", "curl", "-f", "http://localhost:4321/api/health"] interval: 1m timeout: 10s retries: 3 diff --git a/src/lib/api.ts b/src/lib/api.ts index eb27948..9526095 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -88,3 +88,80 @@ export const giteaApi = { body: JSON.stringify({ url, token }), }), }; + +// Health API +export interface HealthResponse { + status: "ok" | "error"; + timestamp: string; + version: string; + database: { + connected: boolean; + message: string; + }; + system: { + uptime: { + startTime: string; + uptimeMs: number; + formatted: string; + }; + memory: { + rss: string; + heapTotal: string; + heapUsed: string; + external: string; + systemTotal: string; + systemFree: string; + }; + os: { + platform: string; + version: string; + arch: string; + }; + env: string; + }; + error?: string; +} + +export const healthApi = { + check: async (): Promise => { + try { + const response = await fetch(`${API_BASE}/health`); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ + status: "error", + error: "Failed to parse error response", + })); + + return { + ...errorData, + status: "error", + timestamp: new Date().toISOString(), + } as HealthResponse; + } + + return await response.json(); + } catch (error) { + return { + status: "error", + timestamp: new Date().toISOString(), + error: error instanceof Error ? error.message : "Unknown error checking health", + version: "unknown", + database: { connected: false, message: "Failed to connect to API" }, + system: { + uptime: { startTime: "", uptimeMs: 0, formatted: "N/A" }, + memory: { + rss: "N/A", + heapTotal: "N/A", + heapUsed: "N/A", + external: "N/A", + systemTotal: "N/A", + systemFree: "N/A", + }, + os: { platform: "", version: "", arch: "" }, + env: "", + }, + }; + } + }, +}; diff --git a/src/pages/api/health.ts b/src/pages/api/health.ts new file mode 100644 index 0000000..9695293 --- /dev/null +++ b/src/pages/api/health.ts @@ -0,0 +1,126 @@ +import type { APIRoute } from "astro"; +import { jsonResponse } from "@/lib/utils"; +import { db } from "@/lib/db"; +import { ENV } from "@/lib/config"; +import os from "os"; + +// Track when the server started +const serverStartTime = new Date(); + +export const GET: APIRoute = async () => { + try { + // Check database connection by running a simple query + const dbStatus = await checkDatabaseConnection(); + + // Get system information + const systemInfo = { + uptime: getUptime(), + memory: getMemoryUsage(), + os: { + platform: os.platform(), + version: os.version(), + arch: os.arch(), + }, + env: ENV.NODE_ENV, + }; + + // Build response + const healthData = { + status: "ok", + timestamp: new Date().toISOString(), + version: process.env.npm_package_version || "unknown", + database: dbStatus, + system: systemInfo, + }; + + return jsonResponse({ + data: healthData, + status: 200, + }); + } catch (error) { + console.error("Health check failed:", error); + + return jsonResponse({ + data: { + status: "error", + timestamp: new Date().toISOString(), + error: error instanceof Error ? error.message : "Unknown error", + }, + status: 503, // Service Unavailable + }); + } +}; + +/** + * Check database connection by running a simple query + */ +async function checkDatabaseConnection() { + try { + // Run a simple query to check if the database is accessible + const result = await db.select({ test: sql`1` }).from(sql`sqlite_master`).limit(1); + + return { + connected: true, + message: "Database connection successful", + }; + } catch (error) { + console.error("Database connection check failed:", error); + + return { + connected: false, + message: error instanceof Error ? error.message : "Database connection failed", + }; + } +} + +/** + * Get server uptime information + */ +function getUptime() { + const now = new Date(); + const uptimeMs = now.getTime() - serverStartTime.getTime(); + + // Convert to human-readable format + const seconds = Math.floor(uptimeMs / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + return { + startTime: serverStartTime.toISOString(), + uptimeMs, + formatted: `${days}d ${hours % 24}h ${minutes % 60}m ${seconds % 60}s`, + }; +} + +/** + * Get memory usage information + */ +function getMemoryUsage() { + const memoryUsage = process.memoryUsage(); + + return { + rss: formatBytes(memoryUsage.rss), + heapTotal: formatBytes(memoryUsage.heapTotal), + heapUsed: formatBytes(memoryUsage.heapUsed), + external: formatBytes(memoryUsage.external), + systemTotal: formatBytes(os.totalmem()), + systemFree: formatBytes(os.freemem()), + }; +} + +/** + * Format bytes to human-readable format + */ +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +// Import sql tag for raw SQL queries +import { sql } from "drizzle-orm";