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