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:
parent
a5630e4f41
commit
93cdb60a45
5 changed files with 891 additions and 1 deletions
299
.github/workflows/endpoint-health-check.yml
vendored
Normal file
299
.github/workflows/endpoint-health-check.yml
vendored
Normal 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
|
||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
|
|
@ -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.
|
||||
|
|
|
|||
170
apps/web/src/app/api/health/report/route.ts
Normal file
170
apps/web/src/app/api/health/report/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
392
apps/web/src/app/health/page.tsx
Normal file
392
apps/web/src/app/health/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue