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" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <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
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// 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])
|
@@index([createdAt])
|
||||||
@@map("user_activities")
|
@@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