Merge pull request #4 from BlackRoad-OS/codex/implement-health-and-version-api-routes
This commit is contained in:
65
.github/workflows/console-deploy.yaml
vendored
Normal file
65
.github/workflows/console-deploy.yaml
vendored
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
name: Console Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev, staging, main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: '20'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Select Railway environment
|
||||||
|
id: env
|
||||||
|
run: |
|
||||||
|
BRANCH=${GITHUB_REF##*/}
|
||||||
|
if [ "$BRANCH" = "main" ]; then
|
||||||
|
echo "railway_env=prod" >> $GITHUB_OUTPUT
|
||||||
|
echo "health_url=https://console.blackroad.systems/api/health" >> $GITHUB_OUTPUT
|
||||||
|
elif [ "$BRANCH" = "staging" ]; then
|
||||||
|
echo "railway_env=staging" >> $GITHUB_OUTPUT
|
||||||
|
echo "health_url=https://staging.console.blackroad.systems/api/health" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "railway_env=dev" >> $GITHUB_OUTPUT
|
||||||
|
if [ -n "${DEV_CONSOLE_URL:-}" ]; then
|
||||||
|
echo "health_url=${DEV_CONSOLE_URL%/}/api/health" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "health_url=" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Deploy to Railway
|
||||||
|
uses: railwayapp/railway-action@v2
|
||||||
|
with:
|
||||||
|
railwayToken: ${{ secrets.RAILWAY_TOKEN }}
|
||||||
|
service: prism-console-web
|
||||||
|
environment: ${{ steps.env.outputs.railway_env }}
|
||||||
|
|
||||||
|
- name: Health check
|
||||||
|
run: |
|
||||||
|
if [ -z "${{ steps.env.outputs.health_url }}" ]; then
|
||||||
|
echo "No health URL configured for this branch; skipping health check."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Checking ${{ steps.env.outputs.health_url }}"
|
||||||
|
curl --fail --location "${{ steps.env.outputs.health_url }}"
|
||||||
60
.github/workflows/deploy-console.yml
vendored
60
.github/workflows/deploy-console.yml
vendored
@@ -1,60 +0,0 @@
|
|||||||
name: Deploy Prism Console
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [dev, staging, main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
NODE_VERSION: '20'
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Select Railway environment
|
|
||||||
run: |
|
|
||||||
BRANCH=${GITHUB_REF##*/}
|
|
||||||
if [ "$BRANCH" = "main" ]; then
|
|
||||||
echo "RAILWAY_ENV=prod" >> $GITHUB_ENV
|
|
||||||
echo "CONSOLE_URL=https://console.blackroad.systems" >> $GITHUB_ENV
|
|
||||||
elif [ "$BRANCH" = "staging" ]; then
|
|
||||||
echo "RAILWAY_ENV=staging" >> $GITHUB_ENV
|
|
||||||
echo "CONSOLE_URL=https://staging.console.blackroad.systems" >> $GITHUB_ENV
|
|
||||||
else
|
|
||||||
echo "RAILWAY_ENV=dev" >> $GITHUB_ENV
|
|
||||||
echo "CONSOLE_URL=${DEV_CONSOLE_URL:-""}" >> $GITHUB_ENV
|
|
||||||
echo "SKIP_HEALTH_CHECK=$([ -z "${DEV_CONSOLE_URL:-}" ] && echo true || echo false)" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Deploy to Railway
|
|
||||||
uses: railwayapp/railway-action@v2
|
|
||||||
with:
|
|
||||||
railwayToken: ${{ secrets.RAILWAY_TOKEN }}
|
|
||||||
service: prism-console-web
|
|
||||||
environment: ${{ env.RAILWAY_ENV }}
|
|
||||||
|
|
||||||
- name: Health check
|
|
||||||
run: |
|
|
||||||
if [ "${SKIP_HEALTH_CHECK:-false}" = "true" ]; then
|
|
||||||
echo "Skipping health check because CONSOLE_URL is not configured for dev deployments"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
if [ -z "$CONSOLE_URL" ]; then
|
|
||||||
echo "CONSOLE_URL is not set for this environment" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
curl --fail --location "$CONSOLE_URL/health"
|
|
||||||
92
README.md
92
README.md
@@ -1,75 +1,47 @@
|
|||||||
# BlackRoad OS — Prism Console
|
# BlackRoad OS — Prism Console
|
||||||
|
|
||||||
## Short Description
|
Operator console UI for BlackRoad OS built with Next.js (App Router + TypeScript).
|
||||||
|
|
||||||
Admin console for deployments, observability, environments, and system control.
|
## Running locally
|
||||||
|
|
||||||
## Long Description
|
1. Install dependencies: `npm ci`
|
||||||
|
2. Set environment variables (see below). You can use a `.env.local` file for local development.
|
||||||
|
3. Start the dev server: `npm run dev`
|
||||||
|
4. Visit `http://localhost:3000` to load the console.
|
||||||
|
|
||||||
Prism Console is the command center of BlackRoad OS. It exposes environment controls, deployment dashboards, worker health, agent supervision, configuration management, secrets overview (abstracted), and unified observability tools. It serves founders, engineers, and operators.
|
## Key routes
|
||||||
|
|
||||||
## Structured Table
|
- `/` — Overview of configured upstream endpoints.
|
||||||
|
- `/status` — Live health table that polls each configured `/health` endpoint.
|
||||||
|
- `/health` — UI that consumes the `/api/health` JSON endpoint and shows service badges.
|
||||||
|
- `/agents` — Lists agents via `AGENTS_API_URL/agents` and triggers `AGENTS_API_URL/agents/run` through internal API proxies.
|
||||||
|
- `/api/health` and `/api/version` — Machine-friendly health and version metadata.
|
||||||
|
|
||||||
| Field | Value |
|
## Configuration
|
||||||
|
|
||||||
|
Environment variables are centralized in `src/lib/config.ts`. Server-side values stay private; `NEXT_PUBLIC_*` values are exposed to the browser.
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Purpose** | Admin, ops, observability, environments |
|
| `NODE_ENV` | Runtime environment selector (`development` \| `staging` \| `production`). |
|
||||||
| **Depends On** | API Gateway, Operator Engine |
|
| `CORE_API_URL` | Server-side Core API base URL used for backend calls. |
|
||||||
| **Used By** | Founders, devs, ops |
|
| `AGENTS_API_URL` | Server-side Agents API base URL for listing/running agents. |
|
||||||
| **Owner** | Alexa + Cece (Prism Operations Group) |
|
| `PUBLIC_CONSOLE_URL` | Public URL for this console; also used for the operator badge. |
|
||||||
| **Status** | Active — mission critical |
|
| `NEXT_PUBLIC_CORE_API_URL` | Browser-safe Core API base URL (if direct browser calls are allowed). |
|
||||||
|
| `NEXT_PUBLIC_AGENTS_API_URL` | Browser-safe Agents API base URL (if direct browser calls are allowed). |
|
||||||
|
| `SKIP_ENV_VALIDATION` | Optional; set to `true` in dev to bypass strict env checks. |
|
||||||
|
|
||||||
## Roadmap Board (Prism)
|
> Keep sensitive URLs in server-side vars (`CORE_API_URL`, `AGENTS_API_URL`). Only expose endpoints in `NEXT_PUBLIC_*` if they are safe for the browser.
|
||||||
|
|
||||||
Columns:
|
## Wiring to backend services
|
||||||
|
|
||||||
- Backlog
|
- Health polling: `/api/health` calls each configured service and appends `/health` to the base URL. `/status` renders the live results.
|
||||||
- UI Wireframing
|
- Version metadata: `/api/version` (and `/version`) returns the build version, environment, and timestamp.
|
||||||
- Integration Layer
|
- Agents: `/api/agents` proxies `GET {AGENTS_API_URL}/agents`; `/api/agents/run` proxies `POST {AGENTS_API_URL}/agents/run`. The `/agents` page consumes these endpoints.
|
||||||
- Telemetry
|
|
||||||
- Review
|
|
||||||
- Prod Ready
|
|
||||||
|
|
||||||
Sample tasks:
|
|
||||||
|
|
||||||
- Agent lifecycle dashboard
|
|
||||||
- Deployment monitor
|
|
||||||
- Worker queue timeline
|
|
||||||
- Error heatmaps
|
|
||||||
- Rail & Cloudflare integrated view
|
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Prism Console deploys to the Railway project **`blackroad-prism-console`** as the service **`prism-console-web`**. It fronts environments published via Cloudflare:
|
- Railway service: `prism-console-web` defined in `railway.json` (build `npm run build`, start `npm run start`, health check `/api/health`).
|
||||||
|
- GitHub Actions: `.github/workflows/console-deploy.yaml` installs dependencies, runs lint+build, deploys to the correct Railway environment (`dev`/`staging`/`prod`), and performs a post-deploy health check.
|
||||||
|
|
||||||
- **Production:** `https://console.blackroad.systems`
|
Production and staging URLs remain `https://console.blackroad.systems` and `https://staging.console.blackroad.systems`; dev uses the Railway-provided URL (or `DEV_CONSOLE_URL` in CI for health checks).
|
||||||
- **Staging:** `https://staging.console.blackroad.systems`
|
|
||||||
- **Dev:** Railway-provided dev URL (optionally `https://dev.console.blackroad.systems`)
|
|
||||||
|
|
||||||
### Runtime commands
|
|
||||||
|
|
||||||
- Install: `npm ci`
|
|
||||||
- Build: `npm run build`
|
|
||||||
- Start: `npm run start`
|
|
||||||
|
|
||||||
### Environment variables
|
|
||||||
|
|
||||||
| Variable | Purpose | Prod | Staging | Dev |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| `NODE_ENV` | Runtime environment | `production` | `staging` | `development` |
|
|
||||||
| `CORE_API_URL` | Core backend base URL | `https://core.blackroad.systems` | `https://staging.core.blackroad.systems` | Core dev Railway URL |
|
|
||||||
| `AGENTS_API_URL` | Agents API base URL | `https://agents.blackroad.systems` | `https://staging.agents.blackroad.systems` | Agents dev Railway URL |
|
|
||||||
| `PUBLIC_CONSOLE_URL` | Console base URL | `https://console.blackroad.systems` | `https://staging.console.blackroad.systems` | Dev console URL |
|
|
||||||
| `NEXT_PUBLIC_CORE_API_URL` | Browser-safe core URL | `https://core.blackroad.systems` | `https://staging.core.blackroad.systems` | Core dev Railway URL |
|
|
||||||
| `NEXT_PUBLIC_AGENTS_API_URL` | Browser-safe agents URL | `https://agents.blackroad.systems` | `https://staging.agents.blackroad.systems` | Agents dev Railway URL |
|
|
||||||
| `NEXT_PUBLIC_CONSOLE_URL` | Browser-safe console URL | `https://console.blackroad.systems` | `https://staging.console.blackroad.systems` | Dev console URL |
|
|
||||||
| `DATABASE_URL` | (Optional) Metrics DB connection string | as provisioned | as provisioned | as provisioned |
|
|
||||||
| `SKIP_ENV_VALIDATION` | (Optional) Set to `true` to bypass env enforcement during local builds | optional | optional | `true` (in build script) |
|
|
||||||
|
|
||||||
### Health and status
|
|
||||||
|
|
||||||
- `GET /health` — lightweight liveness JSON suitable for Railway checks.
|
|
||||||
- `GET /status` — static status board that surfaces configured backend URLs; ready to wire to real health endpoints later.
|
|
||||||
|
|
||||||
### Railway definition
|
|
||||||
|
|
||||||
`railway.json` declares the project + service so `railway up` deploys with the right commands and port. Set `RAILWAY_TOKEN` in CI/CD and provide the above env vars per environment before deploying.
|
|
||||||
|
|||||||
@@ -8,15 +8,14 @@
|
|||||||
"port": 3000,
|
"port": 3000,
|
||||||
"buildCommand": "npm run build",
|
"buildCommand": "npm run build",
|
||||||
"startCommand": "npm run start",
|
"startCommand": "npm run start",
|
||||||
|
"healthcheckPath": "/api/health",
|
||||||
"envVariables": {
|
"envVariables": {
|
||||||
"NODE_ENV": "production",
|
"NODE_ENV": "production",
|
||||||
"CORE_API_URL": "",
|
"CORE_API_URL": "",
|
||||||
"AGENTS_API_URL": "",
|
"AGENTS_API_URL": "",
|
||||||
"PUBLIC_CONSOLE_URL": "",
|
"PUBLIC_CONSOLE_URL": "",
|
||||||
"NEXT_PUBLIC_CORE_API_URL": "",
|
"NEXT_PUBLIC_CORE_API_URL": "",
|
||||||
"NEXT_PUBLIC_AGENTS_API_URL": "",
|
"NEXT_PUBLIC_AGENTS_API_URL": ""
|
||||||
"NEXT_PUBLIC_CONSOLE_URL": "",
|
|
||||||
"DATABASE_URL": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
109
src/app/agents/page.tsx
Normal file
109
src/app/agents/page.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
type Agent = {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AgentsResponse = {
|
||||||
|
agents: Agent[];
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AgentsPage() {
|
||||||
|
const [agents, setAgents] = useState<Agent[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadAgents = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/agents', { cache: 'no-store' });
|
||||||
|
const payload = (await response.json()) as AgentsResponse;
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error || 'Unable to load agents');
|
||||||
|
}
|
||||||
|
setAgents(payload.agents || []);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setAgents([]);
|
||||||
|
setError(err instanceof Error ? err.message : 'Unable to load agents');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runAgent = async (agent: Agent) => {
|
||||||
|
try {
|
||||||
|
setMessage(null);
|
||||||
|
const response = await fetch('/api/agents/run', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ agentId: agent.id || agent.name })
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload?.error || 'Agent run failed');
|
||||||
|
}
|
||||||
|
setMessage(`Triggered ${agent.name || agent.id || 'agent'}`);
|
||||||
|
} catch (err) {
|
||||||
|
setMessage(null);
|
||||||
|
setError(err instanceof Error ? err.message : 'Unable to trigger agent');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAgents();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid">
|
||||||
|
<div className="card">
|
||||||
|
<h1>Agents</h1>
|
||||||
|
<p className="muted">Lists agents from the configured AGENTS_API_URL and triggers /agents/run.</p>
|
||||||
|
<button onClick={loadAgents} disabled={loading} style={{ marginTop: 8 }}>
|
||||||
|
{loading ? 'Loading…' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
{message && <p className="status-ok" style={{ marginTop: 8 }}>{message}</p>}
|
||||||
|
{error && <p className="status-bad" style={{ marginTop: 8 }}>{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>Agent List</h3>
|
||||||
|
{agents.length === 0 && !loading && <p className="muted">No agents found.</p>}
|
||||||
|
{loading && <p className="muted">Loading…</p>}
|
||||||
|
{agents.length > 0 && (
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{agents.map((agent) => (
|
||||||
|
<tr key={agent.id || agent.name}>
|
||||||
|
<td>{agent.name || agent.id}</td>
|
||||||
|
<td className="muted">{agent.status || 'n/a'}</td>
|
||||||
|
<td className="muted">{agent.description || '—'}</td>
|
||||||
|
<td>
|
||||||
|
<button onClick={() => runAgent(agent)}>Run</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/app/api/agents/route.ts
Normal file
22
src/app/api/agents/route.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { serverConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
if (!serverConfig.agentsApiUrl) {
|
||||||
|
return NextResponse.json({ error: 'AGENTS_API_URL is not configured' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL('/agents', serverConfig.agentsApiUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url.toString(), { cache: 'no-store' });
|
||||||
|
const payload = await response.json();
|
||||||
|
const agents = Array.isArray(payload) ? payload : payload.agents ?? [];
|
||||||
|
return NextResponse.json({ agents });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Unable to load agents' },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/app/api/agents/run/route.ts
Normal file
31
src/app/api/agents/run/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { serverConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!serverConfig.agentsApiUrl) {
|
||||||
|
return NextResponse.json({ error: 'AGENTS_API_URL is not configured' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL('/agents/run', serverConfig.agentsApiUrl);
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body ?? {}),
|
||||||
|
cache: 'no-store'
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
return NextResponse.json(payload, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Unable to trigger agent' },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/app/api/health/route.ts
Normal file
17
src/app/api/health/route.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { pollServiceHealth, serverConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const services = await pollServiceHealth();
|
||||||
|
const healthy = services.every((service) => service.status === 'healthy' || service.status === 'not_configured');
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
status: healthy ? 'ok' : 'degraded',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
environment: serverConfig.environment,
|
||||||
|
version: process.env.npm_package_version ?? 'unknown',
|
||||||
|
services
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(payload, { status: healthy ? 200 : 503 });
|
||||||
|
}
|
||||||
11
src/app/api/version/route.ts
Normal file
11
src/app/api/version/route.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { serverConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const version = process.env.npm_package_version ?? 'unknown';
|
||||||
|
return NextResponse.json({
|
||||||
|
version,
|
||||||
|
environment: serverConfig.environment,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,13 +1,97 @@
|
|||||||
import { getStaticServiceHealth } from '@/lib/config';
|
'use client';
|
||||||
|
|
||||||
export const revalidate = 0;
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { ServiceStatus } from '@/lib/config';
|
||||||
|
|
||||||
|
type HealthResponse = {
|
||||||
|
status: string;
|
||||||
|
timestamp: string;
|
||||||
|
environment: string;
|
||||||
|
version?: string;
|
||||||
|
services: ServiceStatus[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function badgeClass(status: ServiceStatus['status']) {
|
||||||
|
if (status === 'healthy') return 'status-ok';
|
||||||
|
if (status === 'not_configured') return 'status-bad';
|
||||||
|
return 'status-bad';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HealthPage() {
|
||||||
|
const [health, setHealth] = useState<HealthResponse | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/health', { cache: 'no-store' });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Health endpoint returned ${response.status}`);
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as HealthResponse;
|
||||||
|
if (!cancelled) {
|
||||||
|
setHealth(payload);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unable to load health');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
|
const interval = setInterval(load, 15000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
export default async function HealthPage() {
|
|
||||||
const health = await getStaticServiceHealth();
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="grid">
|
||||||
|
<div className="card">
|
||||||
<h1>Health Check</h1>
|
<h1>Health Check</h1>
|
||||||
<pre>{JSON.stringify(health, null, 2)}</pre>
|
<p className="muted">Live status from <code>/api/health</code>.</p>
|
||||||
|
{health && (
|
||||||
|
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||||
|
<span className={health.status === 'ok' ? 'badge' : 'status-bad'}>
|
||||||
|
{health.status === 'ok' ? 'OK' : 'Degraded'}
|
||||||
|
</span>
|
||||||
|
<span className="muted">Env: {health.environment}</span>
|
||||||
|
<span className="muted">Version: {health.version}</span>
|
||||||
|
<span className="muted">Updated: {new Date(health.timestamp).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <p className="status-bad">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>Services</h3>
|
||||||
|
<p className="muted">Core, API Gateway, Agents, and the operator console.</p>
|
||||||
|
<div style={{ display: 'grid', gap: 8 }}>
|
||||||
|
{health?.services.map((service) => (
|
||||||
|
<div
|
||||||
|
key={service.key}
|
||||||
|
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div>{service.name}</div>
|
||||||
|
<div className="muted">{service.url || 'not set'}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span className={badgeClass(service.status)}>
|
||||||
|
{service.status === 'healthy' && 'Healthy'}
|
||||||
|
{service.status === 'not_configured' && 'Not configured'}
|
||||||
|
{service.status === 'unreachable' && 'Unreachable'}
|
||||||
|
</span>
|
||||||
|
<span className="muted">{service.latencyMs ? `${service.latencyMs} ms` : '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)) || <p className="muted">No services configured.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { config } from '@/lib/config';
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const payload = {
|
|
||||||
status: 'ok',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
environment: config.environment,
|
|
||||||
services: {
|
|
||||||
coreApiConfigured: Boolean(config.coreApiUrl),
|
|
||||||
agentsApiConfigured: Boolean(config.agentsApiUrl)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return NextResponse.json(payload);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { config, getStaticServiceHealth, publicConfig } from '@/lib/config';
|
import { getStaticServiceHealth, publicConfig, serverConfig } from '@/lib/config';
|
||||||
|
|
||||||
const cards = [
|
const cards = [
|
||||||
{ title: 'Core API', key: 'coreApiUrl', description: 'Primary backend for Prism data.' },
|
{ title: 'Core API', key: 'coreApiUrl', description: 'Primary backend for Prism data.' },
|
||||||
@@ -9,9 +9,9 @@ const cards = [
|
|||||||
export default function Home() {
|
export default function Home() {
|
||||||
const serviceHealth = getStaticServiceHealth();
|
const serviceHealth = getStaticServiceHealth();
|
||||||
const resolvedValues = {
|
const resolvedValues = {
|
||||||
coreApiUrl: publicConfig.coreApiUrl || config.coreApiUrl,
|
coreApiUrl: publicConfig.coreApiUrl || serverConfig.coreApiUrl,
|
||||||
agentsApiUrl: publicConfig.agentsApiUrl || config.agentsApiUrl,
|
agentsApiUrl: publicConfig.agentsApiUrl || serverConfig.agentsApiUrl,
|
||||||
consoleUrl: publicConfig.consoleUrl || config.consoleUrl
|
consoleUrl: publicConfig.consoleUrl || serverConfig.consoleUrl
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -19,15 +19,13 @@ export default function Home() {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Environment</h3>
|
<h3>Environment</h3>
|
||||||
<p className="muted">Aligns with Railway and Cloudflare mapping.</p>
|
<p className="muted">Aligns with Railway and Cloudflare mapping.</p>
|
||||||
<div className="badge">{config.environment}</div>
|
<div className="badge">{serverConfig.environment}</div>
|
||||||
<table className="table">
|
<table className="table">
|
||||||
<tbody>
|
<tbody>
|
||||||
{cards.map((card) => (
|
{cards.map((card) => (
|
||||||
<tr key={card.key}>
|
<tr key={card.key}>
|
||||||
<td>{card.title}</td>
|
<td>{card.title}</td>
|
||||||
<td className="muted">
|
<td className="muted">{resolvedValues[card.key] || 'not set'}</td>
|
||||||
{resolvedValues[card.key] || 'not set'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -42,7 +40,7 @@ export default function Home() {
|
|||||||
<p className="muted">Configuration-based readiness of upstream services.</p>
|
<p className="muted">Configuration-based readiness of upstream services.</p>
|
||||||
<ul>
|
<ul>
|
||||||
{serviceHealth.map((service) => (
|
{serviceHealth.map((service) => (
|
||||||
<li key={service.name}>
|
<li key={service.key}>
|
||||||
{service.name}:{' '}
|
{service.name}:{' '}
|
||||||
<span className={service.configured ? 'status-ok' : 'status-bad'}>
|
<span className={service.configured ? 'status-ok' : 'status-bad'}>
|
||||||
{service.configured ? 'Configured' : 'Missing'}
|
{service.configured ? 'Configured' : 'Missing'}
|
||||||
@@ -58,7 +56,7 @@ export default function Home() {
|
|||||||
<ul>
|
<ul>
|
||||||
<li>Use /status for a focused service status board.</li>
|
<li>Use /status for a focused service status board.</li>
|
||||||
<li>Centralized fetch helper lives in <code>src/lib/api.ts</code>.</li>
|
<li>Centralized fetch helper lives in <code>src/lib/api.ts</code>.</li>
|
||||||
<li>Health endpoint: <code>/health</code>.</li>
|
<li>Health endpoint: <code>/api/health</code>.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import { getStaticServiceHealth, publicConfig } from '@/lib/config';
|
import { pollServiceHealth, publicConfig } from '@/lib/config';
|
||||||
import { StatusCard } from '@/components/status/StatusCard';
|
import { StatusCard } from '@/components/status/StatusCard';
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'System Status | Prism Console'
|
title: 'System Status | Prism Console'
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function StatusPage() {
|
export const revalidate = 0;
|
||||||
const services = getStaticServiceHealth();
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function StatusPage() {
|
||||||
|
const services = await pollServiceHealth();
|
||||||
const env = publicConfig.environment;
|
const env = publicConfig.environment;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid">
|
<div className="grid">
|
||||||
<StatusCard
|
<StatusCard
|
||||||
title="Prism Console"
|
title="Prism Console"
|
||||||
description="Static overview of configured upstream services. Replace mock health with live pings when endpoints are ready."
|
description="Live health from upstream services. Polls /health for each configured endpoint."
|
||||||
environment={env}
|
environment={env}
|
||||||
services={services}
|
services={services}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,9 +1 @@
|
|||||||
import { NextResponse } from 'next/server';
|
export { GET } from '../api/version/route';
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const version = process.env.npm_package_version ?? 'unknown';
|
|
||||||
return NextResponse.json({
|
|
||||||
version,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import type { Route } from 'next';
|
import type { Route } from 'next';
|
||||||
import { config } from '@/lib/config';
|
import { serverConfig } from '@/lib/config';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -10,7 +10,8 @@ type Props = {
|
|||||||
const navLinks: { href: Route; label: string }[] = [
|
const navLinks: { href: Route; label: string }[] = [
|
||||||
{ href: '/', label: 'Overview' },
|
{ href: '/', label: 'Overview' },
|
||||||
{ href: '/status', label: 'Status' },
|
{ href: '/status', label: 'Status' },
|
||||||
{ href: '/health', label: 'Health API' }
|
{ href: '/health', label: 'Health' },
|
||||||
|
{ href: '/agents', label: 'Agents' }
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AppShell({ children }: Props) {
|
export default function AppShell({ children }: Props) {
|
||||||
@@ -27,7 +28,7 @@ export default function AppShell({ children }: Props) {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
<span className="badge" style={{ marginLeft: 'auto' }}>
|
<span className="badge" style={{ marginLeft: 'auto' }}>
|
||||||
Environment: {config.environment}
|
Environment: {serverConfig.environment}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { ServiceHealth } from '@/lib/config';
|
import { ServiceStatus } from '@/lib/config';
|
||||||
|
|
||||||
export type StatusCardProps = {
|
export type StatusCardProps = {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
environment: string;
|
environment: string;
|
||||||
services: ServiceHealth[];
|
services: ServiceStatus[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function statusLabel(status: ServiceStatus['status']) {
|
||||||
|
if (status === 'healthy') return 'status-ok';
|
||||||
|
if (status === 'not_configured') return 'status-bad';
|
||||||
|
return 'status-bad';
|
||||||
|
}
|
||||||
|
|
||||||
export function StatusCard({ title, description, environment, services }: StatusCardProps) {
|
export function StatusCard({ title, description, environment, services }: StatusCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -21,6 +27,7 @@ export function StatusCard({ title, description, environment, services }: Status
|
|||||||
<th>Service</th>
|
<th>Service</th>
|
||||||
<th>URL</th>
|
<th>URL</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Latency</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -28,9 +35,12 @@ export function StatusCard({ title, description, environment, services }: Status
|
|||||||
<tr key={service.name}>
|
<tr key={service.name}>
|
||||||
<td>{service.name}</td>
|
<td>{service.name}</td>
|
||||||
<td className="muted">{service.url || 'not set'}</td>
|
<td className="muted">{service.url || 'not set'}</td>
|
||||||
<td className={service.configured ? 'status-ok' : 'status-bad'}>
|
<td className={statusLabel(service.status)}>
|
||||||
{service.configured ? 'Configured' : 'Missing'}
|
{service.status === 'healthy' && 'Healthy'}
|
||||||
|
{service.status === 'not_configured' && 'Not configured'}
|
||||||
|
{service.status === 'unreachable' && 'Unreachable'}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="muted">{service.latencyMs ? `${service.latencyMs} ms` : '—'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { config } from './config';
|
import { serverConfig } from './config';
|
||||||
|
|
||||||
type FetchTarget = 'core' | 'agents';
|
type FetchTarget = 'core' | 'agents';
|
||||||
|
|
||||||
@@ -9,8 +9,8 @@ type ApiClientOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const baseMap: Record<FetchTarget, string> = {
|
const baseMap: Record<FetchTarget, string> = {
|
||||||
core: config.coreApiUrl,
|
core: serverConfig.coreApiUrl,
|
||||||
agents: config.agentsApiUrl
|
agents: serverConfig.agentsApiUrl
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ type ReadEnvOptions = {
|
|||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ServiceKey = 'console' | 'core' | 'agents';
|
||||||
|
|
||||||
const nodeEnvRaw = (process.env.NODE_ENV || 'development') as string;
|
const nodeEnvRaw = (process.env.NODE_ENV || 'development') as string;
|
||||||
const environment: RuntimeEnvironment =
|
const environment: RuntimeEnvironment =
|
||||||
nodeEnvRaw === 'production' ? 'production' : nodeEnvRaw === 'staging' ? 'staging' : 'development';
|
nodeEnvRaw === 'production' ? 'production' : nodeEnvRaw === 'staging' ? 'staging' : 'development';
|
||||||
@@ -26,7 +28,7 @@ function readEnv(variable: string, { optional, defaultValue }: ReadEnvOptions =
|
|||||||
return value ?? '';
|
return value ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const serverConfig = {
|
||||||
environment,
|
environment,
|
||||||
nodeEnv: environment,
|
nodeEnv: environment,
|
||||||
coreApiUrl: readEnv('CORE_API_URL', { optional: isDev }),
|
coreApiUrl: readEnv('CORE_API_URL', { optional: isDev }),
|
||||||
@@ -45,28 +47,110 @@ export const config = {
|
|||||||
|
|
||||||
export const publicConfig = {
|
export const publicConfig = {
|
||||||
environment,
|
environment,
|
||||||
consoleUrl: readEnv('NEXT_PUBLIC_CONSOLE_URL', { optional: isDev }),
|
consoleUrl: readEnv('PUBLIC_CONSOLE_URL', { optional: true }),
|
||||||
coreApiUrl: readEnv('NEXT_PUBLIC_CORE_API_URL', { optional: isDev }),
|
coreApiUrl: readEnv('NEXT_PUBLIC_CORE_API_URL', { optional: true }),
|
||||||
agentsApiUrl: readEnv('NEXT_PUBLIC_AGENTS_API_URL', { optional: isDev })
|
agentsApiUrl: readEnv('NEXT_PUBLIC_AGENTS_API_URL', { optional: true })
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ServiceHealth = {
|
export type ServiceDescriptor = {
|
||||||
|
key: ServiceKey;
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getStaticServiceHealth(): ServiceHealth[] {
|
export type ServiceStatus = ServiceDescriptor & {
|
||||||
return [
|
status: 'healthy' | 'unreachable' | 'not_configured';
|
||||||
{
|
latencyMs?: number;
|
||||||
name: 'Core API',
|
error?: string;
|
||||||
url: publicConfig.coreApiUrl || config.coreApiUrl,
|
};
|
||||||
configured: Boolean(publicConfig.coreApiUrl || config.coreApiUrl)
|
|
||||||
},
|
const serviceCatalog: Record<ServiceKey, { name: string }> = {
|
||||||
{
|
console: { name: 'Operator Console' },
|
||||||
name: 'Agents API',
|
core: { name: 'Core API' },
|
||||||
url: publicConfig.agentsApiUrl || config.agentsApiUrl,
|
agents: { name: 'Agents API' }
|
||||||
configured: Boolean(publicConfig.agentsApiUrl || config.agentsApiUrl)
|
};
|
||||||
|
|
||||||
|
function resolveServiceUrl(key: ServiceKey, preferPublic = true): string {
|
||||||
|
if (key === 'console') {
|
||||||
|
return preferPublic ? publicConfig.consoleUrl || serverConfig.consoleUrl : serverConfig.consoleUrl || publicConfig.consoleUrl;
|
||||||
}
|
}
|
||||||
];
|
|
||||||
|
if (key === 'core') {
|
||||||
|
return preferPublic ? publicConfig.coreApiUrl || serverConfig.coreApiUrl : serverConfig.coreApiUrl || publicConfig.coreApiUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preferPublic ? publicConfig.agentsApiUrl || serverConfig.agentsApiUrl : serverConfig.agentsApiUrl || publicConfig.agentsApiUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStaticServiceHealth(preferPublic = true): ServiceDescriptor[] {
|
||||||
|
return (Object.keys(serviceCatalog) as ServiceKey[]).map((key) => {
|
||||||
|
const url = resolveServiceUrl(key, preferPublic);
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
name: serviceCatalog[key].name,
|
||||||
|
url,
|
||||||
|
configured: Boolean(url)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function withHealthPath(url: string): string {
|
||||||
|
if (!url) return '';
|
||||||
|
try {
|
||||||
|
const normalized = url.endsWith('/') ? url.slice(0, -1) : url;
|
||||||
|
const target = new URL(normalized);
|
||||||
|
target.pathname = target.pathname.endsWith('/health') ? target.pathname : `${target.pathname || ''}/health`;
|
||||||
|
return target.toString();
|
||||||
|
} catch (error) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollServiceHealth(timeoutMs = 2500): Promise<ServiceStatus[]> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
const checks = getStaticServiceHealth(false).map(async (service) => {
|
||||||
|
if (!service.configured) {
|
||||||
|
return { ...service, status: 'not_configured' as const } satisfies ServiceStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const healthUrl = withHealthPath(service.url);
|
||||||
|
const started = performance.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(healthUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
const latencyMs = Math.round(performance.now() - started);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
...service,
|
||||||
|
status: 'unreachable',
|
||||||
|
latencyMs,
|
||||||
|
error: `HTTP ${response.status}`
|
||||||
|
} satisfies ServiceStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...service,
|
||||||
|
status: 'healthy',
|
||||||
|
latencyMs
|
||||||
|
} satisfies ServiceStatus;
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...service,
|
||||||
|
status: 'unreachable',
|
||||||
|
error: error instanceof Error ? error.message : 'unknown error'
|
||||||
|
} satisfies ServiceStatus;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(checks);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user