diff --git a/.github/workflows/endpoint-health-check.yml b/.github/workflows/endpoint-health-check.yml new file mode 100644 index 0000000..a72315b --- /dev/null +++ b/.github/workflows/endpoint-health-check.yml @@ -0,0 +1,299 @@ +name: Endpoint Health Check + +on: + schedule: + # Run every 5 minutes + - cron: '*/5 * * * *' + workflow_dispatch: + inputs: + verbose: + description: 'Enable verbose output' + required: false + default: 'false' + type: boolean + +env: + BASE_URL: ${{ secrets.VERCEL_PRODUCTION_URL || 'https://tpmjs.com' }} + # Test data + TEST_USERNAME: ajax + TEST_COLLECTION_SLUG: ajax-collection-tbc + +jobs: + health-check: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Setup + run: | + echo "Starting health checks at $(date -u +"%Y-%m-%dT%H:%M:%SZ")" + echo "Base URL: $BASE_URL" + + - name: Check Basic Health Endpoint + id: basic-health + run: | + echo "Testing: GET /api/health" + RESPONSE=$(curl -s -w "\n%{http_code}" "$BASE_URL/api/health" --connect-timeout 10 --max-time 30) + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | sed '$d') + + echo "HTTP Status: $HTTP_CODE" + if [ "${{ inputs.verbose }}" = "true" ]; then + echo "Response: $BODY" + fi + + if [ "$HTTP_CODE" -eq 200 ]; then + echo "status=pass" >> $GITHUB_OUTPUT + echo "✅ Basic health check passed" + else + echo "status=fail" >> $GITHUB_OUTPUT + echo "❌ Basic health check failed with status $HTTP_CODE" + fi + + - name: Check Database Health + id: db-health + run: | + echo "Testing: GET /api/tools (database connectivity)" + RESPONSE=$(curl -s -w "\n%{http_code}" "$BASE_URL/api/tools?limit=1" --connect-timeout 10 --max-time 30) + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | sed '$d') + + echo "HTTP Status: $HTTP_CODE" + if [ "${{ inputs.verbose }}" = "true" ]; then + echo "Response: $BODY" + fi + + if [ "$HTTP_CODE" -eq 200 ]; then + echo "status=pass" >> $GITHUB_OUTPUT + echo "✅ Database health check passed" + else + echo "status=fail" >> $GITHUB_OUTPUT + echo "❌ Database health check failed with status $HTTP_CODE" + fi + + - name: Check Public Collections API + id: collections-api + run: | + echo "Testing: GET /api/collections/public" + RESPONSE=$(curl -s -w "\n%{http_code}" "$BASE_URL/api/collections/public?limit=1" --connect-timeout 10 --max-time 30) + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | sed '$d') + + echo "HTTP Status: $HTTP_CODE" + if [ "${{ inputs.verbose }}" = "true" ]; then + echo "Response: $BODY" + fi + + if [ "$HTTP_CODE" -eq 200 ]; then + echo "status=pass" >> $GITHUB_OUTPUT + echo "✅ Public collections API check passed" + else + echo "status=fail" >> $GITHUB_OUTPUT + echo "❌ Public collections API check failed with status $HTTP_CODE" + fi + + - name: Check Public Agents API + id: agents-api + run: | + echo "Testing: GET /api/agents/public" + RESPONSE=$(curl -s -w "\n%{http_code}" "$BASE_URL/api/agents/public?limit=1" --connect-timeout 10 --max-time 30) + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | sed '$d') + + echo "HTTP Status: $HTTP_CODE" + if [ "${{ inputs.verbose }}" = "true" ]; then + echo "Response: $BODY" + fi + + if [ "$HTTP_CODE" -eq 200 ]; then + echo "status=pass" >> $GITHUB_OUTPUT + echo "✅ Public agents API check passed" + else + echo "status=fail" >> $GITHUB_OUTPUT + echo "❌ Public agents API check failed with status $HTTP_CODE" + fi + + - name: Check MCP HTTP Transport - Initialize + id: mcp-http-init + run: | + echo "Testing: POST /api/mcp/$TEST_USERNAME/$TEST_COLLECTION_SLUG/http (initialize)" + RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ + "$BASE_URL/api/mcp/$TEST_USERNAME/$TEST_COLLECTION_SLUG/http" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize"}' \ + --connect-timeout 15 --max-time 30) + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | sed '$d') + + echo "HTTP Status: $HTTP_CODE" + if [ "${{ inputs.verbose }}" = "true" ]; then + echo "Response: $BODY" + fi + + # Check for successful JSON-RPC response + if [ "$HTTP_CODE" -eq 200 ] && echo "$BODY" | grep -q '"result"'; then + echo "status=pass" >> $GITHUB_OUTPUT + echo "✅ MCP HTTP initialize check passed" + else + echo "status=fail" >> $GITHUB_OUTPUT + echo "❌ MCP HTTP initialize check failed" + fi + + - name: Check MCP HTTP Transport - Tools List + id: mcp-http-tools + run: | + echo "Testing: POST /api/mcp/$TEST_USERNAME/$TEST_COLLECTION_SLUG/http (tools/list)" + RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ + "$BASE_URL/api/mcp/$TEST_USERNAME/$TEST_COLLECTION_SLUG/http" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \ + --connect-timeout 15 --max-time 30) + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | sed '$d') + + echo "HTTP Status: $HTTP_CODE" + if [ "${{ inputs.verbose }}" = "true" ]; then + echo "Response: $BODY" | head -c 500 + fi + + # Check for successful JSON-RPC response with tools + if [ "$HTTP_CODE" -eq 200 ] && echo "$BODY" | grep -q '"tools"'; then + echo "status=pass" >> $GITHUB_OUTPUT + echo "✅ MCP HTTP tools/list check passed" + else + echo "status=fail" >> $GITHUB_OUTPUT + echo "❌ MCP HTTP tools/list check failed" + fi + + - name: Check MCP SSE Transport + id: mcp-sse + run: | + echo "Testing: POST /api/mcp/$TEST_USERNAME/$TEST_COLLECTION_SLUG/sse (initialize)" + RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ + "$BASE_URL/api/mcp/$TEST_USERNAME/$TEST_COLLECTION_SLUG/sse" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize"}' \ + --connect-timeout 15 --max-time 30) + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | sed '$d') + + echo "HTTP Status: $HTTP_CODE" + if [ "${{ inputs.verbose }}" = "true" ]; then + echo "Response: $BODY" + fi + + # Check for SSE response with data prefix + if [ "$HTTP_CODE" -eq 200 ] && echo "$BODY" | grep -q 'data:'; then + echo "status=pass" >> $GITHUB_OUTPUT + echo "✅ MCP SSE check passed" + else + echo "status=fail" >> $GITHUB_OUTPUT + echo "❌ MCP SSE check failed" + fi + + - name: Check MCP Server Info (GET) + id: mcp-info + run: | + echo "Testing: GET /api/mcp/$TEST_USERNAME/$TEST_COLLECTION_SLUG/http" + RESPONSE=$(curl -s -w "\n%{http_code}" \ + "$BASE_URL/api/mcp/$TEST_USERNAME/$TEST_COLLECTION_SLUG/http" \ + --connect-timeout 10 --max-time 20) + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | sed '$d') + + echo "HTTP Status: $HTTP_CODE" + if [ "${{ inputs.verbose }}" = "true" ]; then + echo "Response: $BODY" + fi + + if [ "$HTTP_CODE" -eq 200 ] && echo "$BODY" | grep -q '"protocol":"mcp"'; then + echo "status=pass" >> $GITHUB_OUTPUT + echo "✅ MCP server info check passed" + else + echo "status=fail" >> $GITHUB_OUTPUT + echo "❌ MCP server info check failed" + fi + + - name: Check Tool Health Stats + id: tool-health-stats + run: | + echo "Testing: GET /api/stats/health" + RESPONSE=$(curl -s -w "\n%{http_code}" "$BASE_URL/api/stats/health" --connect-timeout 10 --max-time 30) + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | sed '$d') + + echo "HTTP Status: $HTTP_CODE" + if [ "${{ inputs.verbose }}" = "true" ]; then + echo "Response: $BODY" | head -c 500 + fi + + if [ "$HTTP_CODE" -eq 200 ] && echo "$BODY" | grep -q '"success":true'; then + echo "status=pass" >> $GITHUB_OUTPUT + echo "✅ Tool health stats check passed" + else + echo "status=fail" >> $GITHUB_OUTPUT + echo "❌ Tool health stats check failed" + fi + + - name: Report Health Status to API + if: always() + run: | + # Collect all results + RESULTS=$(cat << EOF + { + "timestamp": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")", + "source": "github-actions", + "runId": "${{ github.run_id }}", + "checks": { + "basic_health": "${{ steps.basic-health.outputs.status }}", + "database": "${{ steps.db-health.outputs.status }}", + "collections_api": "${{ steps.collections-api.outputs.status }}", + "agents_api": "${{ steps.agents-api.outputs.status }}", + "mcp_http_init": "${{ steps.mcp-http-init.outputs.status }}", + "mcp_http_tools": "${{ steps.mcp-http-tools.outputs.status }}", + "mcp_sse": "${{ steps.mcp-sse.outputs.status }}", + "mcp_info": "${{ steps.mcp-info.outputs.status }}", + "tool_health_stats": "${{ steps.tool-health-stats.outputs.status }}" + } + } + EOF + ) + + echo "Health Check Results:" + echo "$RESULTS" | jq . + + # Report to the health status API if secret is available + if [ -n "${{ secrets.CRON_SECRET }}" ]; then + curl -s -X POST "$BASE_URL/api/health/report" \ + -H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \ + -H "Content-Type: application/json" \ + -d "$RESULTS" || true + fi + + - name: Summary + if: always() + run: | + echo "## Health Check Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Endpoint | Status |" >> $GITHUB_STEP_SUMMARY + echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Basic Health | ${{ steps.basic-health.outputs.status == 'pass' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Database | ${{ steps.db-health.outputs.status == 'pass' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Collections API | ${{ steps.collections-api.outputs.status == 'pass' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Agents API | ${{ steps.agents-api.outputs.status == 'pass' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY + echo "| MCP HTTP Init | ${{ steps.mcp-http-init.outputs.status == 'pass' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY + echo "| MCP HTTP Tools | ${{ steps.mcp-http-tools.outputs.status == 'pass' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY + echo "| MCP SSE | ${{ steps.mcp-sse.outputs.status == 'pass' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY + echo "| MCP Server Info | ${{ steps.mcp-info.outputs.status == 'pass' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Tool Health Stats | ${{ steps.tool-health-stats.outputs.status == 'pass' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY + + - name: Fail if any check failed + if: | + steps.basic-health.outputs.status == 'fail' || + steps.db-health.outputs.status == 'fail' || + steps.mcp-http-init.outputs.status == 'fail' || + steps.mcp-http-tools.outputs.status == 'fail' || + steps.mcp-sse.outputs.status == 'fail' + run: | + echo "One or more critical health checks failed!" + exit 1 diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index c4b7818..9edff1c 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/src/app/api/health/report/route.ts b/apps/web/src/app/api/health/report/route.ts new file mode 100644 index 0000000..d753f62 --- /dev/null +++ b/apps/web/src/app/api/health/report/route.ts @@ -0,0 +1,170 @@ +import { prisma } from '@tpmjs/db'; +import { type NextRequest, NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const maxDuration = 30; + +interface HealthCheckReport { + timestamp: string; + source: string; + runId?: string; + checks: Record; +} + +/** + * POST /api/health/report + * Receives health check reports from GitHub Actions or other monitoring systems + * Requires CRON_SECRET authorization + */ +export async function POST(request: NextRequest) { + // Verify authorization + const authHeader = request.headers.get('authorization'); + const expectedToken = process.env.CRON_SECRET; + + if (!expectedToken || authHeader !== `Bearer ${expectedToken}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const report: HealthCheckReport = await request.json(); + + // Calculate overall status + const checkResults = Object.values(report.checks); + const passCount = checkResults.filter((r) => r === 'pass').length; + const failCount = checkResults.filter((r) => r === 'fail').length; + const totalChecks = checkResults.length; + const overallStatus = + failCount === 0 ? 'healthy' : failCount < totalChecks / 2 ? 'degraded' : 'down'; + + // Store the report in the database + await prisma.endpointHealthReport.create({ + data: { + timestamp: new Date(report.timestamp), + source: report.source, + runId: report.runId, + checks: report.checks, + passCount, + failCount, + totalChecks, + overallStatus, + }, + }); + + // Clean up old reports (keep last 7 days) + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - 7); + await prisma.endpointHealthReport.deleteMany({ + where: { timestamp: { lt: cutoffDate } }, + }); + + return NextResponse.json({ + success: true, + data: { + recorded: true, + overallStatus, + passCount, + failCount, + totalChecks, + }, + }); + } catch (error) { + console.error('[Health Report] Error:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal error' }, + { status: 500 } + ); + } +} + +/** + * GET /api/health/report + * Returns recent health check reports + */ +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const limit = Math.min(parseInt(searchParams.get('limit') || '50', 10), 200); + const hours = Math.min(parseInt(searchParams.get('hours') || '24', 10), 168); // Max 7 days + + try { + const cutoffDate = new Date(); + cutoffDate.setHours(cutoffDate.getHours() - hours); + + const reports = await prisma.endpointHealthReport.findMany({ + where: { timestamp: { gte: cutoffDate } }, + orderBy: { timestamp: 'desc' }, + take: limit, + }); + + // Calculate summary stats + const totalReports = reports.length; + const healthyCount = reports.filter((r) => r.overallStatus === 'healthy').length; + const degradedCount = reports.filter((r) => r.overallStatus === 'degraded').length; + const downCount = reports.filter((r) => r.overallStatus === 'down').length; + + // Calculate uptime percentage + const uptimePercent = + totalReports > 0 ? ((healthyCount / totalReports) * 100).toFixed(2) : '100.00'; + + // Get per-check statistics + const checkStats: Record = {}; + for (const report of reports) { + const checks = report.checks as Record; + for (const [checkName, status] of Object.entries(checks)) { + if (!checkStats[checkName]) { + checkStats[checkName] = { pass: 0, fail: 0, total: 0 }; + } + checkStats[checkName].total++; + if (status === 'pass') { + checkStats[checkName].pass++; + } else if (status === 'fail') { + checkStats[checkName].fail++; + } + } + } + + // Get current status (latest report) + const latestReport = reports[0]; + const currentStatus = latestReport?.overallStatus || 'unknown'; + const lastChecked = latestReport?.timestamp || null; + + return NextResponse.json({ + success: true, + data: { + currentStatus, + lastChecked, + summary: { + totalReports, + healthy: healthyCount, + degraded: degradedCount, + down: downCount, + uptimePercent: `${uptimePercent}%`, + timeRange: `${hours} hours`, + }, + checkStats: Object.entries(checkStats).map(([name, stats]) => ({ + name, + ...stats, + successRate: + stats.total > 0 ? `${((stats.pass / stats.total) * 100).toFixed(1)}%` : 'N/A', + })), + recentReports: reports.slice(0, 20).map((r) => ({ + id: r.id, + timestamp: r.timestamp, + source: r.source, + runId: r.runId, + overallStatus: r.overallStatus, + passCount: r.passCount, + failCount: r.failCount, + totalChecks: r.totalChecks, + checks: r.checks, + })), + }, + }); + } catch (error) { + console.error('[Health Report GET] Error:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal error' }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/health/page.tsx b/apps/web/src/app/health/page.tsx new file mode 100644 index 0000000..a42563d --- /dev/null +++ b/apps/web/src/app/health/page.tsx @@ -0,0 +1,392 @@ +'use client'; + +import { Badge } from '@tpmjs/ui/Badge/Badge'; +import { Spinner } from '@tpmjs/ui/Spinner/Spinner'; +import Link from 'next/link'; +import { useCallback, useEffect, useState } from 'react'; +import { AppHeader } from '~/components/AppHeader'; + +interface CheckStat { + name: string; + pass: number; + fail: number; + total: number; + successRate: string; +} + +interface RecentReport { + id: string; + timestamp: string; + source: string; + runId?: string; + overallStatus: string; + passCount: number; + failCount: number; + totalChecks: number; + checks: Record; +} + +interface HealthData { + currentStatus: string; + lastChecked: string | null; + summary: { + totalReports: number; + healthy: number; + degraded: number; + down: number; + uptimePercent: string; + timeRange: string; + }; + checkStats: CheckStat[]; + recentReports: RecentReport[]; +} + +const statusColors: Record = { + healthy: { bg: 'bg-green-500/10', text: 'text-green-500', border: 'border-green-500/30' }, + degraded: { bg: 'bg-yellow-500/10', text: 'text-yellow-500', border: 'border-yellow-500/30' }, + down: { bg: 'bg-red-500/10', text: 'text-red-500', border: 'border-red-500/30' }, + unknown: { bg: 'bg-zinc-500/10', text: 'text-zinc-500', border: 'border-zinc-500/30' }, +}; + +const checkLabels: Record = { + basic_health: 'Basic Health', + database: 'Database', + collections_api: 'Collections API', + agents_api: 'Agents API', + mcp_http_init: 'MCP HTTP Init', + mcp_http_tools: 'MCP HTTP Tools', + mcp_sse: 'MCP SSE', + mcp_info: 'MCP Server Info', + tool_health_stats: 'Tool Health Stats', +}; + +function StatusBadge({ status }: { status: string }) { + const colors = statusColors[status] ?? + statusColors.unknown ?? { + bg: 'bg-zinc-500/10', + text: 'text-zinc-500', + border: 'border-zinc-500/30', + }; + return ( + + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); +} + +function CheckStatusIcon({ status }: { status: string }) { + if (status === 'pass') { + return ( + + + + ); + } + return ( + + + + ); +} + +function StatCard({ + title, + value, + subtitle, + trend, +}: { + title: string; + value: string | number; + subtitle?: string; + trend?: 'up' | 'down' | 'neutral'; +}) { + return ( +
+

{title}

+

{value}

+ {subtitle && ( +

+ {subtitle} +

+ )} +
+ ); +} + +export default function HealthPage() { + const [healthData, setHealthData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastRefresh, setLastRefresh] = useState(new Date()); + + const fetchHealthData = useCallback(async () => { + try { + const response = await fetch('/api/health/report?hours=24&limit=100'); + if (!response.ok) { + throw new Error('Failed to fetch health data'); + } + const json = await response.json(); + if (json.success) { + setHealthData(json.data); + setError(null); + } else { + throw new Error(json.error || 'Unknown error'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load health data'); + } finally { + setLoading(false); + setLastRefresh(new Date()); + } + }, []); + + useEffect(() => { + fetchHealthData(); + // Refresh every 30 seconds + const interval = setInterval(fetchHealthData, 30000); + return () => clearInterval(interval); + }, [fetchHealthData]); + + const formatTimestamp = (ts: string) => { + const date = new Date(ts); + return date.toLocaleString(); + }; + + const getTimeAgo = (ts: string) => { + const diff = Date.now() - new Date(ts).getTime(); + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; + }; + + return ( +
+ + +
+ {/* Header */} +
+
+
+

System Status

+

+ Real-time health monitoring for TPMJS services +

+
+
+

Last updated

+

+ {lastRefresh.toLocaleTimeString()} +

+
+
+
+ + {loading ? ( +
+ +
+ ) : error ? ( +
+

{error}

+ +
+ ) : healthData ? ( + <> + {/* Current Status Banner */} +
+
+
+ +
+

+ {healthData.currentStatus === 'healthy' + ? 'All Systems Operational' + : healthData.currentStatus === 'degraded' + ? 'Partial System Outage' + : healthData.currentStatus === 'down' + ? 'Major Outage' + : 'Status Unknown'} +

+ {healthData.lastChecked && ( +

+ Last checked {getTimeAgo(healthData.lastChecked)} +

+ )} +
+
+
+

+ {healthData.summary.uptimePercent} +

+

+ Uptime ({healthData.summary.timeRange}) +

+
+
+
+ + {/* Stats Grid */} +
+ + + 0 ? 'down' : 'neutral'} + /> + 0 ? 'down' : 'neutral'} + /> +
+ + {/* Service Health */} +
+

Service Health

+
+ {healthData.checkStats.map((check) => ( +
+
+ + + {checkLabels[check.name] || check.name} + +
+
+ + {check.successRate} + + + {check.pass}/{check.total} passed + +
+
+ ))} +
+
+ + {/* Recent Reports */} +
+

Recent Health Checks

+
+ + + + + + + + + + + {healthData.recentReports.map((report) => ( + + + + + + + ))} + +
+ Time + + Status + + Checks + + Source +
+ + {formatTimestamp(report.timestamp)} + + + + + + {report.passCount} + / + {report.totalChecks} + + + {report.source} + {report.runId && ( + + View Run + + )} +
+
+
+ + {/* Footer Links */} +
+

+ Health checks run every 5 minutes via{' '} + + GitHub Actions + +

+

+ View{' '} + + /api/health + {' '} + |{' '} + + /api/health/report + {' '} + |{' '} + + /api/stats/health + +

+
+ + ) : ( +
+

No health data available yet.

+

+ Health checks run every 5 minutes. Check back soon. +

+
+ )} +
+
+ ); +} diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index d6d09da..b4fd022 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -790,3 +790,32 @@ model UserActivity { @@index([createdAt]) @@map("user_activities") } + +// ============================================================================ +// Endpoint Health Monitoring Models +// ============================================================================ + +/// EndpointHealthReport - stores health check results from GitHub Actions or other monitoring +model EndpointHealthReport { + id String @id @default(cuid()) + + // Report metadata + timestamp DateTime + source String @db.VarChar(50) // 'github-actions' | 'manual' | 'uptime-robot' etc. + runId String? @map("run_id") @db.VarChar(100) // GitHub Actions run ID + + // Check results + checks Json @db.JsonB // { check_name: 'pass' | 'fail' } + passCount Int @map("pass_count") + failCount Int @map("fail_count") + totalChecks Int @map("total_checks") + overallStatus String @map("overall_status") @db.VarChar(20) // 'healthy' | 'degraded' | 'down' + + // Timestamps + createdAt DateTime @default(now()) @map("created_at") + + @@index([timestamp]) + @@index([overallStatus]) + @@index([source]) + @@map("endpoint_health_reports") +}