Compare commits
7 Commits
e9d132cd02
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e146126f86 | ||
|
|
3adf06e4e1 | ||
|
|
957be073ab | ||
|
|
4174b8d59c | ||
|
|
0711a7f7ad | ||
|
|
edfa12b8e9 | ||
|
|
b83d7aec01 |
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
dist
|
||||
**/dist
|
||||
.git
|
||||
.env
|
||||
.env.example
|
||||
*.log
|
||||
data
|
||||
@@ -5,3 +5,7 @@ NPM_EMAIL=api@example.com
|
||||
NPM_SECRET=
|
||||
# Comma-separated regex patterns for which domains to include
|
||||
NPM_INCLUDE_PATTERNS=\.example\.com$,\.dev\.example\.com$
|
||||
|
||||
# Gates the /admin UI (host visibility overrides, manual targets). Leave
|
||||
# unset to disable /admin entirely.
|
||||
ADMIN_PASSWORD=change-me
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
data/
|
||||
|
||||
14
Dockerfile
14
Dockerfile
@@ -1,5 +1,8 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
# better-sqlite3 has a native addon with no prebuilt binary for this
|
||||
# platform/Node combo yet — build it from source.
|
||||
RUN apk add --no-cache python3 make g++
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
@@ -7,8 +10,17 @@ RUN npm run build
|
||||
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
# Same native-addon rebuild needed for the production-only install below;
|
||||
# the toolchain is purged again afterward to keep the runtime image lean.
|
||||
RUN apk add --no-cache python3 make g++
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
RUN npm install --omit=dev && apk del python3 make g++
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/public ./public
|
||||
|
||||
# DATA_DIR (SQLite DB) is a mutable runtime volume — not baked into the
|
||||
# image, so redeploys don't clobber host overrides / manual targets.
|
||||
ENV DATA_DIR=/app/data
|
||||
|
||||
EXPOSE 3035
|
||||
CMD ["node", "dist/index.js"]
|
||||
|
||||
60
README.md
60
README.md
@@ -7,12 +7,27 @@ Designed to run as a standalone public status page on its own subdomain — if y
|
||||
## 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`
|
||||
2. Filters to domains matching `NPM_INCLUDE_PATTERNS` — unless overridden per-host in the admin UI (see below)
|
||||
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.
|
||||
|
||||
## Admin UI
|
||||
|
||||
NPM stays the source of truth for *which domains exist*. The optional admin UI at `/admin` lets you override how they're presented, without touching NPM itself:
|
||||
|
||||
- **Force-show or force-hide** any NPM-discovered host, regardless of what `NPM_INCLUDE_PATTERNS` would otherwise decide
|
||||
- **Rename** a host's display name (e.g. "Wisp" instead of `wisp.dev.xplwd.com`)
|
||||
- **Custom check path** per host, for anything whose root path isn't a meaningful health check
|
||||
- **Manually add non-NPM targets** — anything not proxied through NPM (a raw-port service, or a third-party dependency you want on the same page), checked exactly like a discovered host
|
||||
|
||||
Every override is enrichment, not replacement: leave a field blank and it falls back to the NPM-derived default (domain name, root path, pattern-match result). A host removed from NPM entirely just stops appearing — its override row, if any, becomes inert rather than erroring.
|
||||
|
||||
Overrides are stored in SQLite (`DATA_DIR/statuspage.db`) and re-read on every 60-second check tick, so changes in the admin UI take effect within one cycle — no restart needed. The 10-minute NPM host-discovery refresh is unaffected; only the local override/manual-target reads happen every tick.
|
||||
|
||||
Set `ADMIN_PASSWORD` to enable `/admin` and its API (`/api/admin/*`, gated by an `X-Admin-Password` header, same stateless pattern as `wisp`'s upload gate). Leave it unset and `/admin` is disabled outright — the main status page and `/api/status` work exactly as before, unaffected.
|
||||
|
||||
## Running locally
|
||||
|
||||
```bash
|
||||
@@ -22,6 +37,8 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
`.env` is loaded via Node's native `--env-file` flag (both `dev` and `start` pass it) — no `dotenv` dependency. Docker doesn't use this at all; the container gets its config from `docker-compose.yml`'s `environment:` block instead, same as before.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -30,7 +47,9 @@ npm run dev
|
||||
| `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 |
|
||||
| `NPM_INCLUDE_PATTERNS` | `\.example\.com$` | Comma-separated regex patterns; only domains matching at least one are checked by default |
|
||||
| `ADMIN_PASSWORD` | — | Gates `/admin`. Unset disables the admin UI entirely |
|
||||
| `DATA_DIR` | `./data` | Where the SQLite DB (host overrides, manual targets) lives |
|
||||
|
||||
## Docker
|
||||
|
||||
@@ -72,3 +91,40 @@ GET /api/status
|
||||
```
|
||||
|
||||
`status` is `"up"` for any response (including 4xx — the server is reachable), `"down"` for connection errors and 5xx, `"unknown"` before the first check completes.
|
||||
|
||||
```
|
||||
GET /api/latency-targets
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"refreshedAt": "2026-07-26T18:00:00Z",
|
||||
"targets": [
|
||||
{
|
||||
"key": "wisp.dev.example.com",
|
||||
"name": "Wisp",
|
||||
"url": "https://wisp.dev.example.com/",
|
||||
"source": "npm"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`/api/latency-targets` exposes the current computed check list after include-pattern filtering plus admin overrides/manual targets, making it suitable as a source of truth for downstream probe consumers.
|
||||
|
||||
### Admin API
|
||||
|
||||
All routes below `/api/admin` (except `/auth/check`) require an `X-Admin-Password` header matching `ADMIN_PASSWORD`. Returns `503` for every route if `ADMIN_PASSWORD` is unset.
|
||||
|
||||
```
|
||||
POST /api/admin/auth/check -- { ok: boolean }
|
||||
GET /api/admin/hosts -- every enabled NPM host, annotated with matchesPattern + any override
|
||||
PUT /api/admin/hosts/:domain -- { visibility?, displayName?, checkPath?, sortOrder? }
|
||||
DELETE /api/admin/hosts/:domain -- reset to default (delete the override row)
|
||||
GET /api/admin/manual-targets
|
||||
POST /api/admin/manual-targets -- { name, url, checkPath? }
|
||||
PUT /api/admin/manual-targets/:id -- { name?, url?, checkPath?, enabled?, sortOrder? }
|
||||
DELETE /api/admin/manual-targets/:id
|
||||
```
|
||||
|
||||
`visibility` is one of `"default"` (follow `NPM_INCLUDE_PATTERNS`), `"hidden"`, or `"shown"`.
|
||||
|
||||
@@ -2,6 +2,9 @@ services:
|
||||
app:
|
||||
image: ${IMAGE}
|
||||
restart: unless-stopped
|
||||
pull_policy: always
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "${PORT:-3035}:3035"
|
||||
environment:
|
||||
@@ -9,3 +12,12 @@ services:
|
||||
- NPM_EMAIL=${NPM_EMAIL}
|
||||
- NPM_SECRET=${NPM_SECRET}
|
||||
- NPM_INCLUDE_PATTERNS=${NPM_INCLUDE_PATTERNS}
|
||||
# Admin UI (/admin) is disabled entirely if this is unset.
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
volumes:
|
||||
# Named volume, not a bind mount into the build context — the SQLite
|
||||
# DB holding host overrides / manual targets must survive redeploys.
|
||||
- statuspage-data:/app/data
|
||||
|
||||
volumes:
|
||||
statuspage-data:
|
||||
|
||||
436
package-lock.json
generated
436
package-lock.json
generated
@@ -7,10 +7,13 @@
|
||||
"": {
|
||||
"name": "status",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.3.0",
|
||||
"express": "^4.19.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.11",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.15.0",
|
||||
@@ -459,6 +462,16 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/better-sqlite3": {
|
||||
"version": "7.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
@@ -596,6 +609,57 @@
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "11.10.0",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
|
||||
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.5",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||
@@ -620,6 +684,30 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -658,6 +746,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
@@ -703,6 +797,30 @@
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -722,6 +840,15 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -751,6 +878,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
@@ -838,6 +974,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||
@@ -884,6 +1029,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
@@ -920,6 +1071,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -981,6 +1138,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -1049,12 +1212,38 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -1133,12 +1322,45 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
@@ -1148,6 +1370,18 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.94.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz",
|
||||
"integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
@@ -1172,6 +1406,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -1187,6 +1430,33 @@
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
"github-from-package": "0.0.0",
|
||||
"minimist": "^1.2.3",
|
||||
"mkdirp-classic": "^0.5.3",
|
||||
"napi-build-utils": "^2.0.0",
|
||||
"node-abi": "^3.3.0",
|
||||
"pump": "^3.0.0",
|
||||
"rc": "^1.2.7",
|
||||
"simple-get": "^4.0.0",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tunnel-agent": "^0.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"prebuild-install": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -1200,6 +1470,16 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
@@ -1240,6 +1520,35 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
"minimist": "^1.2.0",
|
||||
"strip-json-comments": "~2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
@@ -1266,6 +1575,18 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
@@ -1389,6 +1710,51 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -1398,6 +1764,52 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
|
||||
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
"fs-constants": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
@@ -1426,6 +1838,18 @@
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
@@ -1469,6 +1893,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
@@ -1486,6 +1916,12 @@
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,18 @@
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"dev": "tsx watch --env-file=.env src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
"start": "node --env-file=.env dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.2"
|
||||
"express": "^4.19.2",
|
||||
"better-sqlite3": "^11.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/better-sqlite3": "^7.6.11",
|
||||
"tsx": "^4.15.0",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
|
||||
4
public/favicon.svg
Normal file
4
public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="7" fill="#080808"/>
|
||||
<path d="M6 18 L11 18 L13.5 11 L18 23 L20.5 18 L26 18" fill="none" stroke="#e8c400" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 275 B |
106
src/admin.ts
Normal file
106
src/admin.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Router } from 'express';
|
||||
import { config } from './config.js';
|
||||
import { fetchAllProxyHosts, matchesIncludePatterns } from './npm.js';
|
||||
import {
|
||||
getAllOverrides,
|
||||
upsertOverride,
|
||||
deleteOverride,
|
||||
listManualTargets,
|
||||
createManualTarget,
|
||||
updateManualTarget,
|
||||
deleteManualTarget,
|
||||
type Visibility,
|
||||
} from './db.js';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
function checkAdminPassword(req: import('express').Request): boolean {
|
||||
return !!config.adminPassword && req.get('X-Admin-Password') === config.adminPassword;
|
||||
}
|
||||
|
||||
// Every route below requires the header — stateless, same shape as wisp's
|
||||
// upload gate, no session/cookie to manage.
|
||||
router.use((req, res, next) => {
|
||||
if (!config.adminPassword) {
|
||||
res.status(503).json({ error: 'admin disabled — ADMIN_PASSWORD not set' });
|
||||
return;
|
||||
}
|
||||
if (req.path === '/auth/check') { next(); return; }
|
||||
if (!checkAdminPassword(req)) {
|
||||
res.status(401).json({ error: 'bad password' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
router.post('/auth/check', (req, res) => {
|
||||
res.json({ ok: config.adminPassword != null && checkAdminPassword(req) });
|
||||
});
|
||||
|
||||
// Full NPM-discovered host list, each annotated with whether the current
|
||||
// include-pattern would show it and any existing override — the admin UI
|
||||
// needs the *unfiltered* view to let you force-show a pattern-excluded host.
|
||||
router.get('/hosts', async (_req, res) => {
|
||||
try {
|
||||
const hosts = await fetchAllProxyHosts();
|
||||
const overrides = getAllOverrides();
|
||||
const data = hosts.map(h => {
|
||||
const domain = h.domain_names[h.domain_names.length - 1];
|
||||
const override = overrides.get(domain);
|
||||
return {
|
||||
domain,
|
||||
matchesPattern: matchesIncludePatterns(domain),
|
||||
visibility: override?.visibility ?? 'default',
|
||||
displayName: override?.displayName ?? null,
|
||||
checkPath: override?.checkPath ?? null,
|
||||
sortOrder: override?.sortOrder ?? null,
|
||||
};
|
||||
});
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: e instanceof Error ? e.message : 'upstream error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/hosts/:domain', (req, res) => {
|
||||
const { domain } = req.params;
|
||||
const body = req.body as { visibility?: Visibility; displayName?: string | null; checkPath?: string | null; sortOrder?: number | null };
|
||||
|
||||
if (body.visibility && !['default', 'hidden', 'shown'].includes(body.visibility)) {
|
||||
res.status(400).json({ error: 'invalid visibility' });
|
||||
return;
|
||||
}
|
||||
|
||||
upsertOverride(domain, body);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/hosts/:domain', (req, res) => {
|
||||
deleteOverride(req.params.domain);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/manual-targets', (_req, res) => {
|
||||
res.json(listManualTargets());
|
||||
});
|
||||
|
||||
router.post('/manual-targets', (req, res) => {
|
||||
const body = req.body as { name?: string; url?: string; checkPath?: string | null; sortOrder?: number | null };
|
||||
if (!body.name || !body.url) {
|
||||
res.status(400).json({ error: 'name and url are required' });
|
||||
return;
|
||||
}
|
||||
res.json(createManualTarget({ name: body.name, url: body.url, checkPath: body.checkPath, sortOrder: body.sortOrder }));
|
||||
});
|
||||
|
||||
router.put('/manual-targets/:id', (req, res) => {
|
||||
const body = req.body as { name?: string; url?: string; checkPath?: string | null; enabled?: boolean; sortOrder?: number | null };
|
||||
const ok = updateManualTarget(req.params.id, body);
|
||||
if (!ok) { res.status(404).json({ error: 'not found' }); return; }
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/manual-targets/:id', (req, res) => {
|
||||
deleteManualTarget(req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
275
src/adminPage.ts
Normal file
275
src/adminPage.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
export function renderAdminPage(): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>admin / status</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<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; --accent: #00e87a; --danger: #ff3300;
|
||||
}
|
||||
*, *::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: 13px; min-height: 100vh;
|
||||
padding: 32px 16px 64px;
|
||||
}
|
||||
#app { width: 100%; max-width: 860px; margin: 0 auto; }
|
||||
h1 { font-size: 18px; letter-spacing: 2px; color: #e8c400; margin-bottom: 24px; }
|
||||
h1 span { color: var(--dim); font-weight: 400; }
|
||||
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--dim); margin: 28px 0 12px; }
|
||||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 20px; }
|
||||
input[type=text], input[type=password], input[type=number] {
|
||||
background: #050505; border: 1px solid var(--border); color: var(--text);
|
||||
font-family: inherit; font-size: 13px; padding: 6px 8px; border-radius: 4px;
|
||||
}
|
||||
select {
|
||||
background: #050505; border: 1px solid var(--border); color: var(--text);
|
||||
font-family: inherit; font-size: 13px; padding: 5px 6px; border-radius: 4px;
|
||||
}
|
||||
button {
|
||||
background: #111; border: 1px solid var(--border); color: var(--text);
|
||||
font-family: inherit; font-size: 12px; padding: 6px 12px; border-radius: 4px; cursor: pointer;
|
||||
}
|
||||
button:hover { border-color: var(--accent); color: var(--accent); }
|
||||
button.primary { border-color: var(--accent); color: var(--accent); }
|
||||
button.danger:hover { border-color: var(--danger); color: var(--danger); }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
th { text-align: left; color: var(--dim); text-transform: uppercase; font-size: 10px; letter-spacing: 0.06em;
|
||||
padding: 0 8px 8px 0; border-bottom: 1px solid var(--border); }
|
||||
td { padding: 8px 8px 8px 0; border-bottom: 1px solid #111; vertical-align: middle; }
|
||||
.dim { color: var(--dim); }
|
||||
.badge { font-size: 10px; padding: 2px 6px; border-radius: 3px; border: 1px solid var(--border); }
|
||||
.badge.yes { color: var(--accent); border-color: var(--accent); }
|
||||
.badge.no { color: var(--dim); }
|
||||
.row-actions { display: flex; gap: 6px; }
|
||||
.error-banner { color: var(--danger); font-size: 12px; margin-top: 10px; }
|
||||
.hint { color: var(--dim); font-size: 12px; }
|
||||
.add-form { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
|
||||
.add-form input { flex: 1; min-width: 120px; }
|
||||
#lock-screen { max-width: 320px; margin: 80px auto 0; text-align: center; }
|
||||
#lock-screen input { width: 100%; margin-bottom: 10px; }
|
||||
#lock-screen button { width: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<h1>admin<span> / status</span></h1>
|
||||
|
||||
<div id="lock-screen" class="card">
|
||||
<p class="hint" style="margin-bottom: 14px;">admin password</p>
|
||||
<input id="password" type="password" placeholder="password" autocomplete="off" />
|
||||
<button id="unlock-btn" class="primary">unlock</button>
|
||||
<div id="lock-error" class="error-banner"></div>
|
||||
</div>
|
||||
|
||||
<div id="panel" style="display:none;">
|
||||
<div class="card">
|
||||
<p class="hint">NPM stays the source of truth for which domains exist. Overrides here are enrichment only — leave a field blank and it falls back to the NPM name / pattern-match result.</p>
|
||||
</div>
|
||||
|
||||
<h2>npm hosts</h2>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>domain</th><th>pattern</th><th>visibility</th><th>display name</th><th>check path</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody id="hosts-body"><tr><td colspan="6" class="dim">loading…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>manual targets</h2>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>name</th><th>url</th><th>check path</th><th>enabled</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody id="manual-body"><tr><td colspan="5" class="dim">loading…</td></tr></tbody>
|
||||
</table>
|
||||
<div class="add-form">
|
||||
<input id="new-name" type="text" placeholder="name" />
|
||||
<input id="new-url" type="text" placeholder="https://example.com" />
|
||||
<input id="new-path" type="text" placeholder="check path (optional)" />
|
||||
<button id="add-btn" class="primary">add</button>
|
||||
</div>
|
||||
<div id="manual-error" class="error-banner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
let password = sessionStorage.getItem('admin_password') || '';
|
||||
|
||||
function headers(extra) {
|
||||
return Object.assign({ 'X-Admin-Password': password }, extra || {});
|
||||
}
|
||||
|
||||
async function api(path, opts) {
|
||||
const res = await fetch('/api/admin' + path, Object.assign({}, opts, { headers: headers((opts && opts.headers) || {}) }));
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(function () { return {}; });
|
||||
throw new Error(body.error || ('request failed: ' + res.status));
|
||||
}
|
||||
return res.status === 204 ? null : res.json();
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
async function tryUnlock() {
|
||||
const input = document.getElementById('password');
|
||||
const err = document.getElementById('lock-error');
|
||||
password = input.value;
|
||||
err.textContent = '';
|
||||
try {
|
||||
const body = await api('/auth/check', { method: 'POST' });
|
||||
if (!body.ok) { err.textContent = 'wrong password'; return; }
|
||||
sessionStorage.setItem('admin_password', password);
|
||||
document.getElementById('lock-screen').style.display = 'none';
|
||||
document.getElementById('panel').style.display = 'block';
|
||||
loadAll();
|
||||
} catch (e) {
|
||||
err.textContent = e.message || 'unlock failed';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('unlock-btn').addEventListener('click', tryUnlock);
|
||||
document.getElementById('password').addEventListener('keydown', function (e) { if (e.key === 'Enter') tryUnlock(); });
|
||||
|
||||
function visibilitySelect(current) {
|
||||
const opts = ['default', 'hidden', 'shown'];
|
||||
return '<select class="vis-select">' + opts.map(function (o) {
|
||||
return '<option value="' + o + '"' + (o === current ? ' selected' : '') + '>' + o + '</option>';
|
||||
}).join('') + '</select>';
|
||||
}
|
||||
|
||||
async function loadHosts() {
|
||||
const body = document.getElementById('hosts-body');
|
||||
try {
|
||||
const hosts = await api('/hosts');
|
||||
if (!hosts.length) { body.innerHTML = '<tr><td colspan="6" class="dim">no NPM hosts found</td></tr>'; return; }
|
||||
body.innerHTML = hosts.map(function (h) {
|
||||
const willShow = h.visibility === 'hidden' ? false : h.visibility === 'shown' ? true : h.matchesPattern;
|
||||
return '<tr data-domain="' + esc(h.domain) + '">' +
|
||||
'<td>' + esc(h.domain) + (willShow ? ' <span class="badge yes">shown</span>' : ' <span class="badge no">hidden</span>') + '</td>' +
|
||||
'<td><span class="badge ' + (h.matchesPattern ? 'yes' : 'no') + '">' + (h.matchesPattern ? 'match' : 'no match') + '</span></td>' +
|
||||
'<td>' + visibilitySelect(h.visibility) + '</td>' +
|
||||
'<td><input type="text" class="name-input" placeholder="' + esc(h.domain) + '" value="' + esc(h.displayName || '') + '" /></td>' +
|
||||
'<td><input type="text" class="path-input" placeholder="/" value="' + esc(h.checkPath || '') + '" /></td>' +
|
||||
'<td class="row-actions"><button class="save-btn primary">save</button></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
|
||||
body.querySelectorAll('.save-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', async function () {
|
||||
const tr = btn.closest('tr');
|
||||
const domain = tr.getAttribute('data-domain');
|
||||
const visibility = tr.querySelector('.vis-select').value;
|
||||
const displayName = tr.querySelector('.name-input').value.trim() || null;
|
||||
const checkPath = tr.querySelector('.path-input').value.trim() || null;
|
||||
btn.textContent = 'saving…';
|
||||
try {
|
||||
await api('/hosts/' + encodeURIComponent(domain), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ visibility: visibility, displayName: displayName, checkPath: checkPath }),
|
||||
});
|
||||
await loadHosts();
|
||||
} catch (e) {
|
||||
btn.textContent = 'failed';
|
||||
setTimeout(function () { btn.textContent = 'save'; }, 1500);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
body.innerHTML = '<tr><td colspan="6" class="error-banner">' + esc(e.message) + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadManual() {
|
||||
const body = document.getElementById('manual-body');
|
||||
try {
|
||||
const targets = await api('/manual-targets');
|
||||
if (!targets.length) { body.innerHTML = '<tr><td colspan="5" class="dim">no manual targets yet</td></tr>'; return; }
|
||||
body.innerHTML = targets.map(function (t) {
|
||||
return '<tr data-id="' + esc(t.id) + '">' +
|
||||
'<td>' + esc(t.name) + '</td>' +
|
||||
'<td class="dim">' + esc(t.url) + '</td>' +
|
||||
'<td class="dim">' + esc(t.checkPath || '—') + '</td>' +
|
||||
'<td><input type="checkbox" class="enabled-check" ' + (t.enabled ? 'checked' : '') + ' /></td>' +
|
||||
'<td class="row-actions"><button class="delete-btn danger">delete</button></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
|
||||
body.querySelectorAll('.enabled-check').forEach(function (cb) {
|
||||
cb.addEventListener('change', async function () {
|
||||
const id = cb.closest('tr').getAttribute('data-id');
|
||||
await api('/manual-targets/' + encodeURIComponent(id), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: cb.checked }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
body.querySelectorAll('.delete-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', async function () {
|
||||
const id = btn.closest('tr').getAttribute('data-id');
|
||||
await api('/manual-targets/' + encodeURIComponent(id), { method: 'DELETE' });
|
||||
await loadManual();
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
body.innerHTML = '<tr><td colspan="5" class="error-banner">' + esc(e.message) + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('add-btn').addEventListener('click', async function () {
|
||||
const name = document.getElementById('new-name').value.trim();
|
||||
const url = document.getElementById('new-url').value.trim();
|
||||
const checkPath = document.getElementById('new-path').value.trim() || null;
|
||||
const err = document.getElementById('manual-error');
|
||||
err.textContent = '';
|
||||
if (!name || !url) { err.textContent = 'name and url are required'; return; }
|
||||
try {
|
||||
await api('/manual-targets', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name, url: url, checkPath: checkPath }),
|
||||
});
|
||||
document.getElementById('new-name').value = '';
|
||||
document.getElementById('new-url').value = '';
|
||||
document.getElementById('new-path').value = '';
|
||||
await loadManual();
|
||||
} catch (e) {
|
||||
err.textContent = e.message;
|
||||
}
|
||||
});
|
||||
|
||||
function loadAll() {
|
||||
loadHosts();
|
||||
loadManual();
|
||||
}
|
||||
|
||||
// If a password is already remembered for this tab session, skip the lock screen.
|
||||
if (password) {
|
||||
api('/auth/check', { method: 'POST' }).then(function (body) {
|
||||
if (body.ok) {
|
||||
document.getElementById('lock-screen').style.display = 'none';
|
||||
document.getElementById('panel').style.display = 'block';
|
||||
loadAll();
|
||||
}
|
||||
}).catch(function () {});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { fetchProxyHosts } from './npm.js';
|
||||
import { fetchAllProxyHosts, matchesIncludePatterns } from './npm.js';
|
||||
import { getAllOverrides, listManualTargets } from './db.js';
|
||||
import { computeTargets, type CheckTarget, type RawNpmHost } from './targets.js';
|
||||
|
||||
export type CheckStatus = 'up' | 'down' | 'unknown';
|
||||
|
||||
@@ -22,7 +24,7 @@ const HOST_REFRESH_MS = 10 * 60_000;
|
||||
|
||||
let store: StatusStore = { hosts: [], lastRefreshed: new Date().toISOString() };
|
||||
let lastHostRefresh = 0;
|
||||
let targets: { name: string; url: string }[] = [];
|
||||
let rawHosts: RawNpmHost[] = [];
|
||||
|
||||
async function checkUrl(url: string): Promise<{ status: CheckStatus; code: number | null; latencyMs: number | null }> {
|
||||
const controller = new AbortController();
|
||||
@@ -39,22 +41,33 @@ async function checkUrl(url: string): Promise<{ status: CheckStatus; code: numbe
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshHosts(): Promise<void> {
|
||||
// Refreshing the NPM host list hits their API and re-authenticates if
|
||||
// needed, so it stays on the original 10-minute cadence. Overrides and
|
||||
// manual targets are local SQLite reads — cheap enough to re-read on every
|
||||
// 60s check tick, so admin changes take effect within one cycle instead of
|
||||
// waiting on the NPM refresh.
|
||||
async function refreshNpmHosts(): 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]}`,
|
||||
}));
|
||||
const hosts = await fetchAllProxyHosts();
|
||||
rawHosts = hosts.map(h => {
|
||||
const domain = h.domain_names[h.domain_names.length - 1];
|
||||
return { domain, matchesPattern: matchesIncludePatterns(domain) };
|
||||
});
|
||||
lastHostRefresh = Date.now();
|
||||
console.log(`[checker] loaded ${targets.length} targets from NPM`);
|
||||
console.log(`[checker] loaded ${rawHosts.length} candidate hosts from NPM`);
|
||||
} catch (e) {
|
||||
console.error('[checker] failed to fetch hosts from NPM:', e);
|
||||
}
|
||||
}
|
||||
|
||||
export function currentTargets(): CheckTarget[] {
|
||||
return computeTargets(rawHosts, getAllOverrides(), listManualTargets());
|
||||
}
|
||||
|
||||
async function runChecks(): Promise<void> {
|
||||
if (Date.now() - lastHostRefresh > HOST_REFRESH_MS) await refreshHosts();
|
||||
if (Date.now() - lastHostRefresh > HOST_REFRESH_MS) await refreshNpmHosts();
|
||||
|
||||
const targets = currentTargets();
|
||||
if (targets.length === 0) return;
|
||||
|
||||
const results = await Promise.all(
|
||||
@@ -70,7 +83,7 @@ async function runChecks(): Promise<void> {
|
||||
export function getStore(): StatusStore { return store; }
|
||||
|
||||
export async function startChecker(): Promise<void> {
|
||||
await refreshHosts();
|
||||
await refreshNpmHosts();
|
||||
await runChecks();
|
||||
setInterval(runChecks, CHECK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
8
src/config.ts
Normal file
8
src/config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import path from 'node:path';
|
||||
|
||||
export const config = {
|
||||
port: parseInt(process.env.PORT ?? '3035', 10),
|
||||
dataDir: process.env.DATA_DIR ?? path.resolve('data'),
|
||||
// Admin UI is disabled entirely if unset — no open-by-default gate.
|
||||
adminPassword: process.env.ADMIN_PASSWORD || null,
|
||||
};
|
||||
169
src/db.ts
Normal file
169
src/db.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { config } from './config.js';
|
||||
|
||||
// 'default' = follow NPM_INCLUDE_PATTERNS as before; 'hidden' / 'shown'
|
||||
// force the opposite of whatever the pattern would have decided.
|
||||
export type Visibility = 'default' | 'hidden' | 'shown';
|
||||
|
||||
export interface HostOverride {
|
||||
domain: string;
|
||||
visibility: Visibility;
|
||||
displayName: string | null;
|
||||
checkPath: string | null;
|
||||
sortOrder: number | null;
|
||||
}
|
||||
|
||||
export interface ManualTarget {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
checkPath: string | null;
|
||||
enabled: boolean;
|
||||
sortOrder: number | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
fs.mkdirSync(config.dataDir, { recursive: true });
|
||||
|
||||
export const db = new Database(path.join(config.dataDir, 'statuspage.db'));
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
db.exec(`
|
||||
-- NPM stays the source of truth for which domains exist. This table is
|
||||
-- pure enrichment: a row only ever overrides one field at a time in
|
||||
-- practice, and every field falls back to the NPM-derived default when
|
||||
-- null / 'default'.
|
||||
CREATE TABLE IF NOT EXISTS host_overrides (
|
||||
domain TEXT PRIMARY KEY,
|
||||
visibility TEXT NOT NULL DEFAULT 'default', -- 'default' | 'hidden' | 'shown'
|
||||
display_name TEXT,
|
||||
check_path TEXT,
|
||||
sort_order INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS manual_targets (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
check_path TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
function newId(): string {
|
||||
return randomBytes(9).toString('base64url');
|
||||
}
|
||||
|
||||
export function getAllOverrides(): Map<string, HostOverride> {
|
||||
const rows = db.prepare(`SELECT * FROM host_overrides`).all() as {
|
||||
domain: string;
|
||||
visibility: Visibility;
|
||||
display_name: string | null;
|
||||
check_path: string | null;
|
||||
sort_order: number | null;
|
||||
}[];
|
||||
const map = new Map<string, HostOverride>();
|
||||
for (const r of rows) {
|
||||
map.set(r.domain, {
|
||||
domain: r.domain,
|
||||
visibility: r.visibility,
|
||||
displayName: r.display_name,
|
||||
checkPath: r.check_path,
|
||||
sortOrder: r.sort_order,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function upsertOverride(
|
||||
domain: string,
|
||||
fields: { visibility?: Visibility; displayName?: string | null; checkPath?: string | null; sortOrder?: number | null },
|
||||
): void {
|
||||
const existing = db.prepare(`SELECT * FROM host_overrides WHERE domain = ?`).get(domain) as
|
||||
| { domain: string; visibility: Visibility; display_name: string | null; check_path: string | null; sort_order: number | null }
|
||||
| undefined;
|
||||
|
||||
const next = {
|
||||
visibility: fields.visibility ?? existing?.visibility ?? 'default',
|
||||
display_name: fields.displayName !== undefined ? fields.displayName : (existing?.display_name ?? null),
|
||||
check_path: fields.checkPath !== undefined ? fields.checkPath : (existing?.check_path ?? null),
|
||||
sort_order: fields.sortOrder !== undefined ? fields.sortOrder : (existing?.sort_order ?? null),
|
||||
};
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO host_overrides (domain, visibility, display_name, check_path, sort_order)
|
||||
VALUES (@domain, @visibility, @display_name, @check_path, @sort_order)
|
||||
ON CONFLICT(domain) DO UPDATE SET
|
||||
visibility = excluded.visibility,
|
||||
display_name = excluded.display_name,
|
||||
check_path = excluded.check_path,
|
||||
sort_order = excluded.sort_order`,
|
||||
).run({ domain, ...next });
|
||||
}
|
||||
|
||||
export function deleteOverride(domain: string): void {
|
||||
db.prepare(`DELETE FROM host_overrides WHERE domain = ?`).run(domain);
|
||||
}
|
||||
|
||||
export function listManualTargets(): ManualTarget[] {
|
||||
const rows = db.prepare(`SELECT * FROM manual_targets ORDER BY created_at ASC`).all() as {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
check_path: string | null;
|
||||
enabled: number;
|
||||
sort_order: number | null;
|
||||
created_at: number;
|
||||
}[];
|
||||
return rows.map(r => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
checkPath: r.check_path,
|
||||
enabled: r.enabled === 1,
|
||||
sortOrder: r.sort_order,
|
||||
createdAt: r.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
export function createManualTarget(fields: { name: string; url: string; checkPath?: string | null; sortOrder?: number | null }): ManualTarget {
|
||||
const id = newId();
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
db.prepare(
|
||||
`INSERT INTO manual_targets (id, name, url, check_path, enabled, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, 1, ?, ?)`,
|
||||
).run(id, fields.name, fields.url, fields.checkPath ?? null, fields.sortOrder ?? null, createdAt);
|
||||
return { id, name: fields.name, url: fields.url, checkPath: fields.checkPath ?? null, enabled: true, sortOrder: fields.sortOrder ?? null, createdAt };
|
||||
}
|
||||
|
||||
export function updateManualTarget(
|
||||
id: string,
|
||||
fields: { name?: string; url?: string; checkPath?: string | null; enabled?: boolean; sortOrder?: number | null },
|
||||
): boolean {
|
||||
const existing = db.prepare(`SELECT * FROM manual_targets WHERE id = ?`).get(id) as
|
||||
| { id: string; name: string; url: string; check_path: string | null; enabled: number; sort_order: number | null }
|
||||
| undefined;
|
||||
if (!existing) return false;
|
||||
|
||||
const next = {
|
||||
name: fields.name ?? existing.name,
|
||||
url: fields.url ?? existing.url,
|
||||
check_path: fields.checkPath !== undefined ? fields.checkPath : existing.check_path,
|
||||
enabled: fields.enabled !== undefined ? (fields.enabled ? 1 : 0) : existing.enabled,
|
||||
sort_order: fields.sortOrder !== undefined ? fields.sortOrder : existing.sort_order,
|
||||
};
|
||||
|
||||
db.prepare(
|
||||
`UPDATE manual_targets SET name = @name, url = @url, check_path = @check_path, enabled = @enabled, sort_order = @sort_order WHERE id = @id`,
|
||||
).run({ id, ...next });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function deleteManualTarget(id: string): void {
|
||||
db.prepare(`DELETE FROM manual_targets WHERE id = ?`).run(id);
|
||||
}
|
||||
29
src/index.ts
29
src/index.ts
@@ -1,21 +1,42 @@
|
||||
import express from 'express';
|
||||
import { startChecker, getStore } from './checker.js';
|
||||
import { config } from './config.js';
|
||||
import { startChecker, getStore, currentTargets } from './checker.js';
|
||||
import { renderPage } from './page.js';
|
||||
import { renderAdminPage } from './adminPage.js';
|
||||
import { router as adminRouter } from './admin.js';
|
||||
|
||||
const PORT = parseInt(process.env.PORT ?? '3035', 10);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(express.static('public'));
|
||||
|
||||
app.get('/api/status', (_req, res) => {
|
||||
res.json(getStore());
|
||||
});
|
||||
|
||||
app.get('/api/latency-targets', (_req, res) => {
|
||||
res.json({
|
||||
refreshedAt: new Date().toISOString(),
|
||||
targets: currentTargets(),
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/api/admin', adminRouter);
|
||||
|
||||
app.get('/admin', (_req, res) => {
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send(renderAdminPage());
|
||||
});
|
||||
|
||||
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}`);
|
||||
app.listen(config.port, () => {
|
||||
console.log(`[status] listening on :${config.port}`);
|
||||
if (!config.adminPassword) {
|
||||
console.log('[status] ADMIN_PASSWORD not set — /admin is disabled');
|
||||
}
|
||||
});
|
||||
|
||||
startChecker().catch(e => {
|
||||
|
||||
16
src/npm.ts
16
src/npm.ts
@@ -11,7 +11,11 @@ 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;
|
||||
export const INCLUDE_PATTERNS = rawPatterns;
|
||||
|
||||
export function matchesIncludePatterns(domain: string): boolean {
|
||||
return INCLUDE_PATTERNS.some(p => p.test(domain));
|
||||
}
|
||||
|
||||
let token: string | null = null;
|
||||
let tokenExpiry = 0;
|
||||
@@ -29,14 +33,14 @@ async function getToken(): Promise<string> {
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function fetchProxyHosts(): Promise<ProxyHost[]> {
|
||||
// Every enabled proxy host, regardless of NPM_INCLUDE_PATTERNS — the admin
|
||||
// UI needs the full set to let you force-show something the pattern would
|
||||
// otherwise exclude. checker.ts applies the pattern (or an override) itself.
|
||||
export async function fetchAllProxyHosts(): 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)))
|
||||
);
|
||||
return all.filter(h => h.enabled);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export function renderPage(store: StatusStore): string {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="refresh" content="60">
|
||||
<title>status / goonk</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap');
|
||||
:root {
|
||||
|
||||
79
src/targets.ts
Normal file
79
src/targets.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { HostOverride, ManualTarget } from './db.js';
|
||||
|
||||
export interface CheckTarget {
|
||||
key: string; // domain for NPM-derived, "manual:<id>" for manual — used for override lookups and stable identity
|
||||
name: string;
|
||||
url: string;
|
||||
source: 'npm' | 'manual';
|
||||
}
|
||||
|
||||
export interface RawNpmHost {
|
||||
domain: string;
|
||||
matchesPattern: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges NPM-discovered hosts with local overrides and manual targets into
|
||||
* the final check list. Pure function — no I/O — so it's cheap to unit test
|
||||
* and cheap to re-run every check tick (every 60s) even though the NPM host
|
||||
* list itself is only refreshed every 10 minutes.
|
||||
*
|
||||
* NPM is always the source of truth for *which domains exist*; overrides
|
||||
* are strictly additive enrichment. A host with no override row behaves
|
||||
* exactly as it did before this feature existed: included iff it matches
|
||||
* NPM_INCLUDE_PATTERNS, named after its own domain.
|
||||
*/
|
||||
export function computeTargets(
|
||||
rawHosts: RawNpmHost[],
|
||||
overrides: Map<string, HostOverride>,
|
||||
manualTargets: ManualTarget[],
|
||||
): CheckTarget[] {
|
||||
const npmTargets: (CheckTarget & { sortOrder: number | null })[] = [];
|
||||
|
||||
for (const host of rawHosts) {
|
||||
const override = overrides.get(host.domain);
|
||||
const visibility = override?.visibility ?? 'default';
|
||||
|
||||
const visible =
|
||||
visibility === 'hidden' ? false :
|
||||
visibility === 'shown' ? true :
|
||||
host.matchesPattern;
|
||||
|
||||
if (!visible) continue;
|
||||
|
||||
const name = override?.displayName || host.domain;
|
||||
const checkPath = override?.checkPath || '/';
|
||||
npmTargets.push({
|
||||
key: host.domain,
|
||||
name,
|
||||
url: `https://${host.domain}${checkPath}`,
|
||||
source: 'npm',
|
||||
sortOrder: override?.sortOrder ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const manualTargetsMapped: (CheckTarget & { sortOrder: number | null })[] = manualTargets
|
||||
.filter(t => t.enabled)
|
||||
.map(t => ({
|
||||
key: `manual:${t.id}`,
|
||||
name: t.name,
|
||||
url: t.checkPath ? `${t.url.replace(/\/$/, '')}${t.checkPath}` : t.url,
|
||||
source: 'manual',
|
||||
sortOrder: t.sortOrder,
|
||||
}));
|
||||
|
||||
// Stable sort: explicit sortOrder wins (ascending), unordered entries
|
||||
// keep their relative discovery order and sink after any ordered ones.
|
||||
const combined = [...npmTargets, ...manualTargetsMapped];
|
||||
const withIndex = combined.map((t, i) => ({ t, i }));
|
||||
withIndex.sort((a, b) => {
|
||||
const aHas = a.t.sortOrder != null;
|
||||
const bHas = b.t.sortOrder != null;
|
||||
if (aHas && bHas) return (a.t.sortOrder as number) - (b.t.sortOrder as number);
|
||||
if (aHas) return -1;
|
||||
if (bHas) return 1;
|
||||
return a.i - b.i;
|
||||
});
|
||||
|
||||
return withIndex.map(({ t }) => ({ key: t.key, name: t.name, url: t.url, source: t.source }));
|
||||
}
|
||||
Reference in New Issue
Block a user