Initial commit — status checker service
All checks were successful
Docker / build-and-push (push) Successful in 35s
All checks were successful
Docker / build-and-push (push) Successful in 35s
Polls Nginx Proxy Manager API for proxy hosts matching configurable domain patterns (NPM_INCLUDE_PATTERNS), HTTP-checks each every 60s, serves results as JSON (/api/status) and a terminal-aesthetic HTML page (/). Port 3035. NPM connection and domain filter patterns are fully configurable via environment variables — see README and .env.example. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal file
@@ -0,0 +1,7 @@
|
||||
IMAGE=registry.example.com/youruser/status:latest
|
||||
PORT=3035
|
||||
NPM_BASE_URL=http://proxy.example.com:81
|
||||
NPM_EMAIL=api@example.com
|
||||
NPM_SECRET=
|
||||
# Comma-separated regex patterns for which domains to include
|
||||
NPM_INCLUDE_PATTERNS=\.example\.com$,\.dev\.example\.com$
|
||||
51
.gitea/workflows/build.yml
Normal file
51
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,51 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Derive image name and tags
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SERVER_URL="${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-}}"
|
||||
REPO="${GITHUB_REPOSITORY:-${GITEA_REPOSITORY:-}}"
|
||||
SHA="${GITHUB_SHA:-${GITEA_SHA:-}}"
|
||||
if [[ -z "${SERVER_URL}" || -z "${REPO}" || -z "${SHA}" ]]; then
|
||||
echo "Missing SERVER_URL/REPO/SHA env vars." >&2; exit 1
|
||||
fi
|
||||
HOST=$(echo "${SERVER_URL}" | sed 's|https://||;s|http://||')
|
||||
IMAGE=$(echo "${HOST}/${REPO}" | tr '[:upper:]' '[:lower:]')
|
||||
SHORT_SHA=$(echo "${SHA}" | cut -c1-7)
|
||||
echo "host=${HOST}" >> "$GITHUB_OUTPUT"
|
||||
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
||||
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to Gitea container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ steps.meta.outputs.host }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.TKNTKN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ steps.meta.outputs.image }}:latest
|
||||
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.short_sha }}
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY --from=build /app/dist ./dist
|
||||
EXPOSE 3035
|
||||
CMD ["node", "dist/index.js"]
|
||||
74
README.md
Normal file
74
README.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# status
|
||||
|
||||
A lightweight HTTP status checker that sources its target list from Nginx Proxy Manager and serves results as JSON and a terminal-aesthetic HTML page.
|
||||
|
||||
Designed to run as a standalone public status page on its own subdomain — if your main site is down, the status page is still reachable independently.
|
||||
|
||||
## How it works
|
||||
|
||||
1. On startup (and every 10 minutes), fetches all enabled proxy hosts from the NPM API
|
||||
2. Filters to domains matching `NPM_INCLUDE_PATTERNS`
|
||||
3. HTTP-checks each host every 60 seconds
|
||||
4. Serves current results at `/` (HTML) and `/api/status` (JSON)
|
||||
|
||||
The HTML page auto-refreshes every 60 seconds.
|
||||
|
||||
## Running locally
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# fill in NPM_EMAIL and NPM_SECRET
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `PORT` | `3035` | Port to listen on |
|
||||
| `NPM_BASE_URL` | `http://localhost:81` | Base URL of your Nginx Proxy Manager instance |
|
||||
| `NPM_EMAIL` | — | NPM account email |
|
||||
| `NPM_SECRET` | — | NPM account password |
|
||||
| `NPM_INCLUDE_PATTERNS` | `\.example\.com$` | Comma-separated regex patterns; only domains matching at least one are checked |
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Uses the image from `IMAGE` in `.env`. For a local build:
|
||||
|
||||
```bash
|
||||
docker build -t status:local .
|
||||
IMAGE=status:local docker compose up -d
|
||||
```
|
||||
|
||||
## CI/CD
|
||||
|
||||
Gitea Actions workflow at `.gitea/workflows/build.yml`. Builds and pushes to the Gitea container registry on every push to `main`. Requires a `TKNTKN` secret (PAT with `packages:write`) in the repo settings.
|
||||
|
||||
## API
|
||||
|
||||
```
|
||||
GET /api/status
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"lastRefreshed": "2026-07-07T19:35:43Z",
|
||||
"hosts": [
|
||||
{
|
||||
"name": "example.com",
|
||||
"url": "https://example.com",
|
||||
"status": "up",
|
||||
"statusCode": 200,
|
||||
"latencyMs": 182,
|
||||
"checkedAt": "2026-07-07T19:35:43Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`status` is `"up"` for any response (including 4xx — the server is reachable), `"down"` for connection errors and 5xx, `"unknown"` before the first check completes.
|
||||
11
docker-compose.yml
Normal file
11
docker-compose.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
app:
|
||||
image: ${IMAGE}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-3035}:3035"
|
||||
environment:
|
||||
- NPM_BASE_URL=${NPM_BASE_URL}
|
||||
- NPM_EMAIL=${NPM_EMAIL}
|
||||
- NPM_SECRET=${NPM_SECRET}
|
||||
- NPM_INCLUDE_PATTERNS=${NPM_INCLUDE_PATTERNS}
|
||||
1491
package-lock.json
generated
Normal file
1491
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "status",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.15.0",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
76
src/checker.ts
Normal file
76
src/checker.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { fetchProxyHosts } from './npm.js';
|
||||
|
||||
export type CheckStatus = 'up' | 'down' | 'unknown';
|
||||
|
||||
export interface HostResult {
|
||||
name: string;
|
||||
url: string;
|
||||
status: CheckStatus;
|
||||
statusCode: number | null;
|
||||
latencyMs: number | null;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export interface StatusStore {
|
||||
hosts: HostResult[];
|
||||
lastRefreshed: string;
|
||||
}
|
||||
|
||||
const TIMEOUT_MS = 8_000;
|
||||
const CHECK_INTERVAL_MS = 60_000;
|
||||
const HOST_REFRESH_MS = 10 * 60_000;
|
||||
|
||||
let store: StatusStore = { hosts: [], lastRefreshed: new Date().toISOString() };
|
||||
let lastHostRefresh = 0;
|
||||
let targets: { name: string; url: string }[] = [];
|
||||
|
||||
async function checkUrl(url: string): Promise<{ status: CheckStatus; code: number | null; latencyMs: number | null }> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
const start = Date.now();
|
||||
try {
|
||||
const res = await fetch(url, { signal: controller.signal, redirect: 'follow' });
|
||||
const latencyMs = Date.now() - start;
|
||||
clearTimeout(timer);
|
||||
return { status: res.ok || res.status < 500 ? 'up' : 'down', code: res.status, latencyMs };
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
return { status: 'down', code: null, latencyMs: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshHosts(): Promise<void> {
|
||||
try {
|
||||
const hosts = await fetchProxyHosts();
|
||||
targets = hosts.map(h => ({
|
||||
name: h.domain_names[h.domain_names.length - 1],
|
||||
url: `https://${h.domain_names[h.domain_names.length - 1]}`,
|
||||
}));
|
||||
lastHostRefresh = Date.now();
|
||||
console.log(`[checker] loaded ${targets.length} targets from NPM`);
|
||||
} catch (e) {
|
||||
console.error('[checker] failed to fetch hosts from NPM:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function runChecks(): Promise<void> {
|
||||
if (Date.now() - lastHostRefresh > HOST_REFRESH_MS) await refreshHosts();
|
||||
if (targets.length === 0) return;
|
||||
|
||||
const results = await Promise.all(
|
||||
targets.map(async t => {
|
||||
const r = await checkUrl(t.url);
|
||||
return { name: t.name, url: t.url, status: r.status, statusCode: r.code, latencyMs: r.latencyMs, checkedAt: new Date().toISOString() } satisfies HostResult;
|
||||
})
|
||||
);
|
||||
|
||||
store = { hosts: results, lastRefreshed: new Date().toISOString() };
|
||||
}
|
||||
|
||||
export function getStore(): StatusStore { return store; }
|
||||
|
||||
export async function startChecker(): Promise<void> {
|
||||
await refreshHosts();
|
||||
await runChecks();
|
||||
setInterval(runChecks, CHECK_INTERVAL_MS);
|
||||
}
|
||||
24
src/index.ts
Normal file
24
src/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import express from 'express';
|
||||
import { startChecker, getStore } from './checker.js';
|
||||
import { renderPage } from './page.js';
|
||||
|
||||
const PORT = parseInt(process.env.PORT ?? '3035', 10);
|
||||
const app = express();
|
||||
|
||||
app.get('/api/status', (_req, res) => {
|
||||
res.json(getStore());
|
||||
});
|
||||
|
||||
app.get('/', (_req, res) => {
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send(renderPage(getStore()));
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[status] listening on :${PORT}`);
|
||||
});
|
||||
|
||||
startChecker().catch(e => {
|
||||
console.error('[status] checker failed to start:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
42
src/npm.ts
Normal file
42
src/npm.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export interface ProxyHost {
|
||||
domain_names: string[];
|
||||
forward_host: string;
|
||||
forward_port: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const NPM_BASE = process.env.NPM_BASE_URL ?? 'http://localhost:81';
|
||||
const NPM_EMAIL = process.env.NPM_EMAIL ?? '';
|
||||
const NPM_SECRET = process.env.NPM_SECRET ?? '';
|
||||
|
||||
const rawPatterns = (process.env.NPM_INCLUDE_PATTERNS ?? '\\.goonk\\.se$,\\.dev\\.xplwd\\.com$')
|
||||
.split(',').map(p => new RegExp(p.trim()));
|
||||
const INCLUDE_PATTERNS = rawPatterns;
|
||||
|
||||
let token: string | null = null;
|
||||
let tokenExpiry = 0;
|
||||
|
||||
async function getToken(): Promise<string> {
|
||||
if (token && Date.now() < tokenExpiry) return token;
|
||||
const res = await fetch(`${NPM_BASE}/api/tokens`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ identity: NPM_EMAIL, secret: NPM_SECRET }),
|
||||
});
|
||||
const data = await res.json() as { token: string; expires: string };
|
||||
token = data.token;
|
||||
tokenExpiry = new Date(data.expires).getTime() - 60_000;
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function fetchProxyHosts(): Promise<ProxyHost[]> {
|
||||
const tok = await getToken();
|
||||
const res = await fetch(`${NPM_BASE}/api/nginx/proxy-hosts`, {
|
||||
headers: { Authorization: `Bearer ${tok}` },
|
||||
});
|
||||
const all = await res.json() as ProxyHost[];
|
||||
return all.filter(h =>
|
||||
h.enabled &&
|
||||
h.domain_names.some(d => INCLUDE_PATTERNS.some(p => p.test(d)))
|
||||
);
|
||||
}
|
||||
98
src/page.ts
Normal file
98
src/page.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { StatusStore } from './checker.js';
|
||||
|
||||
export function renderPage(store: StatusStore): string {
|
||||
const upCount = store.hosts.filter(h => h.status === 'up').length;
|
||||
const downCount = store.hosts.filter(h => h.status === 'down').length;
|
||||
const allUp = downCount === 0 && store.hosts.length > 0;
|
||||
|
||||
const rows = store.hosts.map(h => {
|
||||
const cls = h.status === 'up' ? 'up' : h.status === 'down' ? 'down' : 'unknown';
|
||||
const indicator = h.status === 'up' ? '●' : h.status === 'down' ? '●' : '○';
|
||||
const latency = h.latencyMs != null ? `${h.latencyMs}ms` : '—';
|
||||
const code = h.statusCode != null ? String(h.statusCode) : '—';
|
||||
return `<tr>
|
||||
<td><span class="dot ${cls}">${indicator}</span></td>
|
||||
<td><a href="${h.url}" target="_blank" rel="noopener">${h.name}</a></td>
|
||||
<td class="dim">${code}</td>
|
||||
<td class="dim">${latency}</td>
|
||||
</tr>`;
|
||||
}).join('\n');
|
||||
|
||||
const summaryClass = allUp ? 'up' : downCount > 0 ? 'down' : 'unknown';
|
||||
const summaryText = store.hosts.length === 0
|
||||
? 'checking…'
|
||||
: allUp
|
||||
? 'all systems operational'
|
||||
: `${downCount} host${downCount !== 1 ? 's' : ''} down`;
|
||||
|
||||
const checked = new Date(store.lastRefreshed).toUTCString();
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="refresh" content="60">
|
||||
<title>status / goonk</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap');
|
||||
:root {
|
||||
--bg: #080808; --panel: #0f0f0f; --border: #1e1e1e;
|
||||
--text: #d6d6d6; --dim: #555;
|
||||
--up: #00e87a; --down: #ff3300; --unknown: #888;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
background: var(--bg); color: var(--text);
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 14px; min-height: 100vh;
|
||||
display: flex; justify-content: center; align-items: flex-start;
|
||||
padding: 32px 16px 64px;
|
||||
}
|
||||
#app { width: 100%; max-width: 640px; }
|
||||
header { border-bottom: 1px solid var(--border); padding-bottom: 12px; margin-bottom: 20px;
|
||||
display: flex; justify-content: space-between; align-items: baseline; }
|
||||
.brand { font-size: 18px; font-weight: 700; letter-spacing: 2px; color: #e8c400; }
|
||||
.brand span { color: var(--dim); font-weight: 400; }
|
||||
.summary { font-size: 13px; }
|
||||
.summary.up { color: var(--up); }
|
||||
.summary.down { color: var(--down); }
|
||||
.summary.unknown { color: var(--unknown); }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { text-align: left; font-size: 11px; color: var(--dim); text-transform: uppercase;
|
||||
letter-spacing: 0.08em; padding: 0 8px 8px 0; border-bottom: 1px solid var(--border); }
|
||||
td { padding: 9px 8px 9px 0; border-bottom: 1px solid #111; vertical-align: middle; }
|
||||
td a { color: var(--text); text-decoration: none; }
|
||||
td a:hover { color: var(--up); }
|
||||
.dim { color: var(--dim); font-size: 12px; }
|
||||
.dot { font-size: 10px; }
|
||||
.dot.up { color: var(--up); }
|
||||
.dot.down { color: var(--down); }
|
||||
.dot.unknown { color: var(--unknown); }
|
||||
footer { margin-top: 20px; font-size: 11px; color: var(--dim);
|
||||
display: flex; justify-content: space-between; border-top: 1px solid var(--border); padding-top: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header>
|
||||
<div class="brand">status<span> / goonk</span></div>
|
||||
<div class="summary ${summaryClass}">${summaryText}</div>
|
||||
</header>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th style="width:24px"></th>
|
||||
<th>host</th>
|
||||
<th style="width:60px">code</th>
|
||||
<th style="width:80px">latency</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
<footer>
|
||||
<span>refreshes every 60s</span>
|
||||
<span>checked ${checked}</span>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
12
tsconfig.json
Normal file
12
tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user