feat: add comprehensive endpoint health monitoring

- Add GitHub Action workflow that runs every 5 minutes testing:
  - Basic health endpoint
  - Database connectivity (tools API)
  - Collections and Agents public APIs
  - MCP HTTP transport (initialize + tools/list)
  - MCP SSE transport
  - MCP server info endpoint
  - Tool health stats

- Add /api/health/report endpoint for storing health check results
- Add EndpointHealthReport Prisma model for persistence
- Add /health status page with:
  - Real-time status banner
  - Uptime percentage
  - Per-service health stats
  - Recent health check history
  - Auto-refresh every 30 seconds

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ajax Davis 2026-01-10 03:08:11 +10:00
parent a5630e4f41
commit 93cdb60a45
5 changed files with 891 additions and 1 deletions

View file

@ -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

View file

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.

View file

@ -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<string, 'pass' | 'fail' | undefined>;
}
/**
* 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<string, { pass: number; fail: number; total: number }> = {};
for (const report of reports) {
const checks = report.checks as Record<string, string>;
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 }
);
}
}

View file

@ -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<string, string>;
}
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<string, { bg: string; text: string; border: string }> = {
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<string, string> = {
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 (
<span
className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${colors.bg} ${colors.text} border ${colors.border}`}
>
<span
className={`w-2 h-2 rounded-full mr-2 ${status === 'healthy' ? 'bg-green-500' : status === 'degraded' ? 'bg-yellow-500' : status === 'down' ? 'bg-red-500' : 'bg-zinc-500'}`}
/>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
}
function CheckStatusIcon({ status }: { status: string }) {
if (status === 'pass') {
return (
<svg className="w-5 h-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
);
}
return (
<svg className="w-5 h-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
);
}
function StatCard({
title,
value,
subtitle,
trend,
}: {
title: string;
value: string | number;
subtitle?: string;
trend?: 'up' | 'down' | 'neutral';
}) {
return (
<div className="bg-surface-elevated rounded-lg border border-border p-6">
<p className="text-sm text-foreground-tertiary mb-1">{title}</p>
<p className="text-3xl font-bold text-foreground">{value}</p>
{subtitle && (
<p
className={`text-sm mt-1 ${trend === 'up' ? 'text-green-500' : trend === 'down' ? 'text-red-500' : 'text-foreground-secondary'}`}
>
{subtitle}
</p>
)}
</div>
);
}
export default function HealthPage() {
const [healthData, setHealthData] = useState<HealthData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date>(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 (
<div className="min-h-screen bg-background">
<AppHeader />
<main className="max-w-6xl mx-auto px-4 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-3xl font-bold text-foreground">System Status</h1>
<p className="text-foreground-secondary mt-1">
Real-time health monitoring for TPMJS services
</p>
</div>
<div className="text-right">
<p className="text-sm text-foreground-tertiary">Last updated</p>
<p className="text-sm text-foreground-secondary">
{lastRefresh.toLocaleTimeString()}
</p>
</div>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-20">
<Spinner size="lg" />
</div>
) : error ? (
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-6 text-center">
<p className="text-red-500 font-medium">{error}</p>
<button
type="button"
onClick={fetchHealthData}
className="mt-4 px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 transition-colors"
>
Retry
</button>
</div>
) : healthData ? (
<>
{/* Current Status Banner */}
<div
className={`rounded-lg border p-6 mb-8 ${statusColors[healthData.currentStatus]?.bg ?? 'bg-zinc-500/10'} ${statusColors[healthData.currentStatus]?.border ?? 'border-zinc-500/30'}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<StatusBadge status={healthData.currentStatus} />
<div>
<p className="text-lg font-semibold text-foreground">
{healthData.currentStatus === 'healthy'
? 'All Systems Operational'
: healthData.currentStatus === 'degraded'
? 'Partial System Outage'
: healthData.currentStatus === 'down'
? 'Major Outage'
: 'Status Unknown'}
</p>
{healthData.lastChecked && (
<p className="text-sm text-foreground-secondary">
Last checked {getTimeAgo(healthData.lastChecked)}
</p>
)}
</div>
</div>
<div className="text-right">
<p className="text-2xl font-bold text-foreground">
{healthData.summary.uptimePercent}
</p>
<p className="text-sm text-foreground-secondary">
Uptime ({healthData.summary.timeRange})
</p>
</div>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<StatCard
title="Total Checks"
value={healthData.summary.totalReports}
subtitle={healthData.summary.timeRange}
/>
<StatCard title="Healthy" value={healthData.summary.healthy} trend="up" />
<StatCard
title="Degraded"
value={healthData.summary.degraded}
trend={healthData.summary.degraded > 0 ? 'down' : 'neutral'}
/>
<StatCard
title="Down"
value={healthData.summary.down}
trend={healthData.summary.down > 0 ? 'down' : 'neutral'}
/>
</div>
{/* Service Health */}
<div className="bg-surface-elevated rounded-lg border border-border p-6 mb-8">
<h2 className="text-xl font-semibold text-foreground mb-4">Service Health</h2>
<div className="space-y-3">
{healthData.checkStats.map((check) => (
<div
key={check.name}
className="flex items-center justify-between py-3 border-b border-border last:border-0"
>
<div className="flex items-center gap-3">
<CheckStatusIcon status={check.fail === 0 ? 'pass' : 'fail'} />
<span className="text-foreground font-medium">
{checkLabels[check.name] || check.name}
</span>
</div>
<div className="flex items-center gap-4">
<Badge variant={check.fail === 0 ? 'success' : 'error'}>
{check.successRate}
</Badge>
<span className="text-sm text-foreground-secondary">
{check.pass}/{check.total} passed
</span>
</div>
</div>
))}
</div>
</div>
{/* Recent Reports */}
<div className="bg-surface-elevated rounded-lg border border-border p-6">
<h2 className="text-xl font-semibold text-foreground mb-4">Recent Health Checks</h2>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border">
<th className="text-left py-3 px-2 text-sm font-medium text-foreground-secondary">
Time
</th>
<th className="text-left py-3 px-2 text-sm font-medium text-foreground-secondary">
Status
</th>
<th className="text-left py-3 px-2 text-sm font-medium text-foreground-secondary">
Checks
</th>
<th className="text-left py-3 px-2 text-sm font-medium text-foreground-secondary">
Source
</th>
</tr>
</thead>
<tbody>
{healthData.recentReports.map((report) => (
<tr key={report.id} className="border-b border-border last:border-0">
<td className="py-3 px-2">
<span className="text-sm text-foreground">
{formatTimestamp(report.timestamp)}
</span>
</td>
<td className="py-3 px-2">
<StatusBadge status={report.overallStatus} />
</td>
<td className="py-3 px-2">
<span className="text-sm text-foreground">
<span className="text-green-500">{report.passCount}</span>
<span className="text-foreground-tertiary">/</span>
<span className="text-foreground-secondary">{report.totalChecks}</span>
</span>
</td>
<td className="py-3 px-2">
<span className="text-sm text-foreground-secondary">{report.source}</span>
{report.runId && (
<Link
href={`https://github.com/tpmjs/tpmjs/actions/runs/${report.runId}`}
target="_blank"
className="ml-2 text-primary hover:underline text-xs"
>
View Run
</Link>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Footer Links */}
<div className="mt-8 text-center text-sm text-foreground-tertiary">
<p>
Health checks run every 5 minutes via{' '}
<Link
href="https://github.com/tpmjs/tpmjs/actions/workflows/endpoint-health-check.yml"
target="_blank"
className="text-primary hover:underline"
>
GitHub Actions
</Link>
</p>
<p className="mt-2">
View{' '}
<Link href="/api/health" className="text-primary hover:underline">
/api/health
</Link>{' '}
|{' '}
<Link href="/api/health/report" className="text-primary hover:underline">
/api/health/report
</Link>{' '}
|{' '}
<Link href="/api/stats/health" className="text-primary hover:underline">
/api/stats/health
</Link>
</p>
</div>
</>
) : (
<div className="text-center py-20">
<p className="text-foreground-secondary">No health data available yet.</p>
<p className="text-sm text-foreground-tertiary mt-2">
Health checks run every 5 minutes. Check back soon.
</p>
</div>
)}
</main>
</div>
);
}

View file

@ -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")
}