Compare commits
445
Commits
72bc11813d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9d38c6fea | ||
|
|
114f35cb9b | ||
|
|
b2cc83538b | ||
|
|
4bb8e394ef | ||
|
|
f1363a4f55 | ||
|
|
f8cb3764d9 | ||
|
|
9c156c91e6 | ||
|
|
824cb15faa | ||
|
|
180f888819 | ||
|
|
eb9fc633fb | ||
|
|
816cf616d1 | ||
|
|
b7ca30cbb9 | ||
|
|
2083326418 | ||
|
|
aa405aedef | ||
|
|
4a2270a72b | ||
|
|
0971d7ce97 | ||
|
|
bac917b718 | ||
|
|
bc3f4dd032 | ||
|
|
27d478c41a | ||
|
|
8c3cc3739a | ||
|
|
a6d0596c9e | ||
|
|
9339c67ba7 | ||
|
|
b1ad8f513b | ||
|
|
22d62d0df5 | ||
|
|
1f63dd5546 | ||
|
|
b80258f17e | ||
|
|
34470197d1 | ||
|
|
161316bd6a | ||
|
|
bfffb4a719 | ||
|
|
6aa5795683 | ||
|
|
4f85f81ee8 | ||
|
|
69fd4467ff | ||
|
|
04153f2287 | ||
|
|
9cb4a1db3d | ||
|
|
e26f50f4bc | ||
|
|
94f93fdf0c | ||
|
|
18bdfafee4 | ||
|
|
a343f56d89 | ||
|
|
94232f5851 | ||
|
|
e376fd64fc | ||
|
|
43c3443e3f | ||
|
|
496ba0f9fb | ||
|
|
671a9a59cd | ||
|
|
992570341a | ||
|
|
f7a9ee624d | ||
|
|
1176d4e1b2 | ||
|
|
51d7050d66 | ||
|
|
dc8bb323ca | ||
|
|
060b79fef4 | ||
|
|
60790fc6fd | ||
|
|
efa90cac6a | ||
|
|
52434d6529 | ||
|
|
704a920564 | ||
|
|
fd2884e5a1 | ||
|
|
c24847ec18 | ||
|
|
60b88dd4d4 | ||
|
|
9409f6ee02 | ||
|
|
ed59e9c8dc | ||
|
|
9b2f1de4e7 | ||
|
|
783427ab34 | ||
|
|
3f9de968f0 | ||
|
|
d18851defd | ||
|
|
c70ceaba67 | ||
|
|
7877b14f85 | ||
|
|
79ba63d86b | ||
|
|
5f20b41e91 | ||
|
|
16dd0ea36e | ||
|
|
21194371b8 | ||
|
|
968887a8f4 | ||
|
|
bf16cabde1 | ||
|
|
8c13383431 | ||
|
|
2df046080f | ||
|
|
fc6cf17023 | ||
|
|
60f885d9ba | ||
|
|
6f94319c6a | ||
|
|
b88ba7bd2f | ||
|
|
240b0a9b18 | ||
|
|
26be492dc7 | ||
|
|
7bc3f920f3 | ||
|
|
3e8c13b712 | ||
|
|
c8715bb9aa | ||
|
|
cbc942d998 | ||
|
|
d05d646be3 | ||
|
|
19600b12a8 | ||
|
|
abdcdfd7ca | ||
|
|
c847bdfdf8 | ||
|
|
4765fd9bc2 | ||
|
|
29a9d6cea6 | ||
|
|
a58030323a | ||
|
|
a129cef985 | ||
|
|
c4d5e43d69 | ||
|
|
f9801345ae | ||
|
|
17fe00d123 | ||
|
|
ba2a682bb8 | ||
|
|
dc062767ac | ||
|
|
3acad6647c | ||
|
|
59a29a1e63 | ||
|
|
d8ecabe2e2 | ||
|
|
120ed4b94f | ||
|
|
34960c1868 | ||
|
|
85df039f51 | ||
|
|
ce36957ba3 | ||
|
|
2db6c3402c | ||
|
|
91ca996e74 | ||
|
|
89f0856a04 | ||
|
|
4bf8d1b20d | ||
|
|
27d8630611 | ||
|
|
db9c63a096 | ||
|
|
264a51384c | ||
|
|
1732a9d055 | ||
|
|
ae5a8f4597 | ||
|
|
0e009dc684 | ||
|
|
3a4fa6c0fe | ||
|
|
22cb11170a | ||
|
|
8feffa98bc | ||
|
|
697513a5cf | ||
|
|
84e609c48f | ||
|
|
8f7ac6bb4b | ||
|
|
5b3994b042 | ||
|
|
601c918e9f | ||
|
|
60037edf21 | ||
|
|
33896daf77 | ||
|
|
a7fef0a2b7 | ||
|
|
102f78b523 | ||
|
|
5bf02b47d5 | ||
|
|
c74ee9856e | ||
|
|
1434330545 | ||
|
|
981ed965a8 | ||
|
|
530e37c581 | ||
|
|
9940453731 | ||
|
|
25a12368a0 | ||
|
|
a415dab320 | ||
|
|
7da67364c4 | ||
|
|
67559e164c | ||
|
|
74aa5c8376 | ||
|
|
9425fb2454 | ||
|
|
587442d034 | ||
|
|
2336f86aa3 | ||
|
|
b8f502fdb3 | ||
|
|
1fab19e03a | ||
|
|
6adeeeb6ea | ||
|
|
1f01a00ad6 | ||
|
|
8e554498f0 | ||
|
|
005f635fab | ||
|
|
de5f7580a6 | ||
|
|
deeb604e22 | ||
|
|
6dcb5c0a52 | ||
|
|
8831c9c7b4 | ||
|
|
3dc0757b4a | ||
|
|
8791cdc509 | ||
|
|
e82a9e92f8 | ||
|
|
d844b495b2 | ||
|
|
b5c4e458d4 | ||
|
|
9e1ba85043 | ||
|
|
e34f1938f7 | ||
|
|
97b1bb6c9c | ||
|
|
7b5411ab64 | ||
|
|
091096aa11 | ||
|
|
98965ca2f3 | ||
|
|
56bd48b0cc | ||
|
|
54c839e82e | ||
|
|
123ae4c251 | ||
|
|
cfd7e8173a | ||
|
|
d636bcb3e7 | ||
|
|
3a3499cd8b | ||
|
|
ad798d0bbf | ||
|
|
343248c79f | ||
|
|
b6bc7e8a52 | ||
|
|
d01d435565 | ||
|
|
430e7f1dbe | ||
|
|
b80a8ee240 | ||
|
|
42812678ea | ||
|
|
d620f700b5 | ||
|
|
f06ced17fc | ||
|
|
878fbcc181 | ||
|
|
8d82aeb005 | ||
|
|
bea1a91eb9 | ||
|
|
1a00d49be2 | ||
|
|
0b1ffa8b3b | ||
|
|
b987024f9d | ||
|
|
7b8f693710 | ||
|
|
371eeffd38 | ||
|
|
e69a5b8db8 | ||
|
|
3b096df682 | ||
|
|
bdbefad18b | ||
|
|
5e10723021 | ||
|
|
79b7f21591 | ||
|
|
bb05dae926 | ||
|
|
ecb876c964 | ||
|
|
59c3c4b724 | ||
|
|
9a9f872fa6 | ||
|
|
01bdc711fe | ||
|
|
fc3159ff9d | ||
|
|
c0e27bc43b | ||
|
|
85b078fce3 | ||
|
|
7f6f79f028 | ||
|
|
313c9973cd | ||
|
|
2561558c50 | ||
|
|
db90c74560 | ||
|
|
72290a9841 | ||
|
|
b06a2f7ad2 | ||
|
|
e334f31bd5 | ||
|
|
4e62b239a7 | ||
|
|
8971e256dc | ||
|
|
fd755b39db | ||
|
|
71fd258b59 | ||
|
|
06b00f2c8c | ||
|
|
d0b364460f | ||
|
|
6a7639e94d | ||
|
|
aad3c50500 | ||
|
|
a3fdec9436 | ||
|
|
fc96e0d613 | ||
|
|
1a0b6bb0fe | ||
|
|
a1d37c6215 | ||
|
|
b014cbf98a | ||
|
|
d5510aae14 | ||
|
|
fd592a5a2e | ||
|
|
d159d1b17a | ||
|
|
2d0f1ce302 | ||
|
|
827757804b | ||
|
|
1049677f19 | ||
|
|
014976027b | ||
|
|
80ca78f901 | ||
|
|
40c4d3341c | ||
|
|
42b6aae54e | ||
|
|
859a55d66b | ||
|
|
8be0632ea8 | ||
|
|
862b9f284f | ||
|
|
6e7887a92c | ||
|
|
c096044e32 | ||
|
|
f86ed2746d | ||
|
|
540ddd0c50 | ||
|
|
ca24fec9e5 | ||
|
|
551108adc0 | ||
|
|
aa2733f93c | ||
|
|
823b382add | ||
|
|
ed79fe4f12 | ||
|
|
d6aa4d878f | ||
|
|
d5c62267ce | ||
|
|
92029e97c0 | ||
|
|
c08d1a10ac | ||
|
|
2ae3fc64f9 | ||
|
|
e9cfe8fc5a | ||
|
|
950d82e5b2 | ||
|
|
2234a1a068 | ||
|
|
c566881cc2 | ||
|
|
f37d3e7f48 | ||
|
|
30468e378d | ||
|
|
63b83e77dc | ||
|
|
d89f5f45ee | ||
|
|
2462f1e8ee | ||
|
|
4cec734d63 | ||
|
|
f9c835f591 | ||
|
|
723708cf8b | ||
|
|
4149a4753e | ||
|
|
6f0499d34b | ||
|
|
eae2b3869d | ||
|
|
dad663b5e3 | ||
|
|
9881d4f681 | ||
|
|
113c1101c4 | ||
|
|
a7f56c69d3 | ||
|
|
9cf70ab8dd | ||
|
|
7ba11614e7 | ||
|
|
ae9c18b771 | ||
|
|
31c349e6f1 | ||
|
|
9937272ba3 | ||
|
|
f4282bac58 | ||
|
|
096bd186ea | ||
|
|
c1392a7dd9 | ||
|
|
437b493567 | ||
|
|
63cd0c7af5 | ||
|
|
e19e723d49 | ||
|
|
d52026f787 | ||
|
|
b1dce432a5 | ||
|
|
d62f776f7b | ||
|
|
164b7f65f8 | ||
|
|
57bf994926 | ||
|
|
b6fa544d5e | ||
|
|
96a58233fd | ||
|
|
d8d1952304 | ||
|
|
03fe13e707 | ||
|
|
61560ae521 | ||
|
|
632f006988 | ||
|
|
df638c94f1 | ||
|
|
e62b60e0fd | ||
|
|
955b26f942 | ||
|
|
1e6739b42a | ||
|
|
8a24b30b2a | ||
|
|
7e55b2ad95 | ||
|
|
2554321183 | ||
|
|
2dbf85652b | ||
|
|
0854914411 | ||
|
|
c3103f06ce | ||
|
|
81f1e1585e | ||
|
|
d02b27bc16 | ||
|
|
b46c3c618e | ||
|
|
63c7d7c1d7 | ||
|
|
36a7d309fa | ||
|
|
1cbbd65adb | ||
|
|
4cdd4b5343 | ||
|
|
8b2dcf0d56 | ||
|
|
2bd6adc090 | ||
|
|
91c24b20f7 | ||
|
|
bc0f7715da | ||
|
|
7d1a350f86 | ||
|
|
c9130243e8 | ||
|
|
bead49b870 | ||
|
|
c89b63bd97 | ||
|
|
11e6c593ad | ||
|
|
a829c4507c | ||
|
|
3e6e758648 | ||
|
|
fad343c85a | ||
|
|
56114a3852 | ||
|
|
2075de164d | ||
|
|
e4895afe8e | ||
|
|
fd821acec2 | ||
|
|
5295fcfe79 | ||
|
|
3b79e8d589 | ||
|
|
c74e2d3b58 | ||
|
|
0d059a0112 | ||
|
|
86d36a87fc | ||
|
|
dae1d5f77a | ||
|
|
f9826fbbe4 | ||
|
|
13beacbc5a | ||
|
|
2d46491dee | ||
|
|
cbb1a26582 | ||
|
|
f2fcf1a0b9 | ||
|
|
766d1094ad | ||
|
|
993e6bb1df | ||
|
|
e9ca90324b | ||
|
|
1706e83a3f | ||
|
|
59861651bd | ||
|
|
df22029755 | ||
|
|
f013890c40 | ||
|
|
5b7a211c21 | ||
|
|
688245b0c2 | ||
|
|
6beb7f8039 | ||
|
|
5be1f85365 | ||
|
|
42024a9fc3 | ||
|
|
425bfbfc39 | ||
|
|
980261632e | ||
|
|
c5eb514990 | ||
|
|
7b98ae78e5 | ||
|
|
b6a66ddb21 | ||
|
|
77534a037c | ||
|
|
f7ab3b96b2 | ||
|
|
e057a65535 | ||
|
|
07648672ad | ||
|
|
e1bb39431d | ||
|
|
8c39fb45c1 | ||
|
|
93db31db3b | ||
|
|
52f7f8102b | ||
|
|
957674fca4 | ||
|
|
d3ee0ef9d5 | ||
|
|
5836c44228 | ||
|
|
6882162d7f | ||
|
|
1b8cbc6e23 | ||
|
|
f17bfbfd4b | ||
|
|
3fa624eff8 | ||
|
|
6b8e1fc9d9 | ||
|
|
6e1d642c77 | ||
|
|
5037222846 | ||
|
|
3ce89a88bf | ||
|
|
749a06b9d7 | ||
|
|
cb8d0f81b1 | ||
|
|
556729b881 | ||
|
|
5e76b2212f | ||
|
|
289ec8e038 | ||
|
|
0edce8c555 | ||
|
|
94200b430f | ||
|
|
79bf7d9295 | ||
|
|
554d95cfb4 | ||
|
|
5730a3cb83 | ||
|
|
c68700da46 | ||
|
|
4035a3b215 | ||
|
|
5b0bce65c6 | ||
|
|
b8b0a9d5e2 | ||
|
|
7905a8003b | ||
|
|
de6e24e973 | ||
|
|
d49cfa70f5 | ||
|
|
68093131fa | ||
|
|
dbbc9c00cc | ||
|
|
b41faff2ad | ||
|
|
81ac8f0c20 | ||
|
|
05c60db605 | ||
|
|
baea9f8e7e | ||
|
|
6d057d5797 | ||
|
|
988c43090b | ||
|
|
a9a1e43146 | ||
|
|
f7d6eff972 | ||
|
|
eeb0318c4d | ||
|
|
c56af82053 | ||
|
|
1a7bc4dfa5 | ||
|
|
5a0cf5033b | ||
|
|
d278ab0458 | ||
|
|
641af86779 | ||
|
|
fa8ff8b361 | ||
|
|
7a9417297f | ||
|
|
9fb90d9ea8 | ||
|
|
841a852491 | ||
|
|
ba437e3c5a | ||
|
|
3a9cd62cdd | ||
|
|
1325512c6d | ||
|
|
7a6a1fdd10 | ||
|
|
143868eb21 | ||
|
|
7db2889960 | ||
|
|
705073b508 | ||
|
|
7e3c651db7 | ||
|
|
ce4411b13b | ||
|
|
3df7f4b702 | ||
|
|
8221b3a33d | ||
|
|
a246b60cbe | ||
|
|
1e3a5ff4d1 | ||
|
|
bd3c33dda4 | ||
|
|
27be1fa8bf | ||
|
|
e7ec457d0e | ||
|
|
f71388a51a | ||
|
|
5c91cbc522 | ||
|
|
ac693e55e0 | ||
|
|
41bfe52f10 | ||
|
|
6342ec907f | ||
|
|
ea5f1840df | ||
|
|
a995fe3c94 | ||
|
|
3638ce3dd2 | ||
|
|
081d3ad6e2 | ||
|
|
6cf11dadde | ||
|
|
486c94f4ff | ||
|
|
6965a114f0 | ||
|
|
ca686403d9 | ||
|
|
3fa6c5f1f6 | ||
|
|
7d67312a1b | ||
|
|
a5d321f198 | ||
|
|
62b67aaa7c | ||
|
|
5dd514aa94 | ||
|
|
24bf97ae7d | ||
|
|
0e6c27eca9 | ||
|
|
e925e9b0ed | ||
|
|
819a979f28 | ||
|
|
08e5039d81 | ||
|
|
98c0cc35b2 | ||
|
|
1495da41fa | ||
|
|
34fd66f784 | ||
|
|
cf8903f95d | ||
|
|
017d489e2e | ||
|
|
2c32bc1115 |
@@ -1,8 +1,23 @@
|
|||||||
# Web app + coordinator
|
# Web app + coordinator
|
||||||
DATABASE_URL=postgres://pingql:pingql@localhost:5432/pingql
|
DATABASE_URL=postgres://pingql:pingql@localhost:5432/pingql
|
||||||
MONITOR_TOKEN=changeme-use-a-random-secret
|
MONITOR_TOKEN=changeme-use-a-random-secret
|
||||||
|
EMAIL_HMAC_KEY=changeme-use-a-random-secret
|
||||||
|
# Set to "false" only for local development without HTTPS
|
||||||
|
# COOKIE_SECURE=false
|
||||||
|
|
||||||
# Rust monitor
|
# Rust monitor
|
||||||
COORDINATOR_URL=http://localhost:3000
|
COORDINATOR_URL=http://localhost:3000
|
||||||
MONITOR_TOKEN=changeme-use-a-random-secret
|
MONITOR_TOKEN=changeme-use-a-random-secret
|
||||||
RUST_LOG=info
|
RUST_LOG=info
|
||||||
|
|
||||||
|
# Feistel cipher for payment IDs (shared between pay + web)
|
||||||
|
FEISTEL_SECRET=changeme-use-a-random-secret
|
||||||
|
|
||||||
|
# Pay app — crypto payments
|
||||||
|
FREEDOM_API=https://api-v1.freedom.st
|
||||||
|
XPUB_BTC=
|
||||||
|
XPUB_LTC=zpub6oKss9XPK8K3GFp8FmvPqnd8gcKZeXG7GZ2jwed9M5nuD3VovacCX5XTTC22idzwHxFggbx13qRFZEWzrwHeAZs32LNWuqGKKvdiZ37Lafy
|
||||||
|
XPUB_DOGE=
|
||||||
|
XPUB_DASH=
|
||||||
|
XPUB_BCH=xpub6DTaQAZ4bY2vyt5wtR2gg8RpS3dGo2Dz5XD1d9AbMzD3XUyUA6BCc6Cx3jEVBRaius9rX5V918Nfq2vXHAMwUYwFvaxwhWJUbqo2vaXJh4N
|
||||||
|
XPUB_XEC=
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
.env.local
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
target/
|
target/
|
||||||
*.lock
|
*.lock
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
.claude
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "@pingql/api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run --hot src/index.ts",
|
||||||
|
"start": "bun run src/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@elysiajs/cors": "^1.4.1",
|
||||||
|
"elysia": "^1.4.27",
|
||||||
|
"postgres": "^3.4.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "^1.3.10",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+62
@@ -0,0 +1,62 @@
|
|||||||
|
// In-memory cache of the enabled monitor list, keyed by region. The /internal/due
|
||||||
|
// endpoint is polled by every runner roughly once a second per region; without
|
||||||
|
// this cache each poll re-runs a 500-row scan against `monitors` with an array
|
||||||
|
// predicate, which dominates the api's Postgres traffic at any real fleet size.
|
||||||
|
//
|
||||||
|
// The list almost never changes between polls - monitor create/edit/delete is at
|
||||||
|
// most a few times per hour. So we memoize per-region with a short TTL and bust
|
||||||
|
// the cache from the monitor mutation handlers so edits are visible instantly.
|
||||||
|
|
||||||
|
import sql from "../db";
|
||||||
|
|
||||||
|
const TTL_MS = 5000;
|
||||||
|
|
||||||
|
type MonitorRow = Record<string, any>;
|
||||||
|
type Entry = { rows: MonitorRow[]; expiresAt: number };
|
||||||
|
|
||||||
|
const cache = new Map<string, Entry>();
|
||||||
|
const inflight = new Map<string, Promise<MonitorRow[]>>();
|
||||||
|
|
||||||
|
async function fetchForRegion(region: string): Promise<MonitorRow[]> {
|
||||||
|
return sql<MonitorRow[]>`
|
||||||
|
SELECT id, url, method, request_headers, request_body, timeout_ms, interval_s, query, regions,
|
||||||
|
max_retries, retry_interval_s, max_redirects, created_at
|
||||||
|
FROM monitors
|
||||||
|
WHERE enabled = true
|
||||||
|
AND (
|
||||||
|
array_length(regions, 1) IS NULL
|
||||||
|
OR regions = '{}'
|
||||||
|
OR ${region} = ANY(regions)
|
||||||
|
)
|
||||||
|
LIMIT 500
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMonitorsForRegion(region: string): Promise<MonitorRow[]> {
|
||||||
|
const now = Date.now();
|
||||||
|
const hit = cache.get(region);
|
||||||
|
if (hit && hit.expiresAt > now) return hit.rows;
|
||||||
|
|
||||||
|
// Coalesce concurrent refreshes for the same region so a thundering herd of
|
||||||
|
// runner polls doesn't fan out into N parallel SELECTs against an expired
|
||||||
|
// entry.
|
||||||
|
let pending = inflight.get(region);
|
||||||
|
if (!pending) {
|
||||||
|
pending = fetchForRegion(region)
|
||||||
|
.then((rows) => {
|
||||||
|
cache.set(region, { rows, expiresAt: Date.now() + TTL_MS });
|
||||||
|
return rows;
|
||||||
|
})
|
||||||
|
.finally(() => { inflight.delete(region); });
|
||||||
|
inflight.set(region, pending);
|
||||||
|
}
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by monitor create/patch/delete/toggle handlers. Wipes the entire
|
||||||
|
// region map - fine because (a) entries are tiny, (b) refresh is cheap, and
|
||||||
|
// (c) we don't know which regions a freshly-edited monitor belongs to without
|
||||||
|
// reading it back. Simpler than per-region invalidation, identical net effect.
|
||||||
|
export function invalidateMonitorList(): void {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import postgres from "postgres";
|
||||||
|
import { migrate as sharedMigrate } from "../../shared/db";
|
||||||
|
|
||||||
|
const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@localhost:5432/pingql", {
|
||||||
|
max: 20,
|
||||||
|
idle_timeout: 30,
|
||||||
|
connect_timeout: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default sql;
|
||||||
|
|
||||||
|
export async function migrate() {
|
||||||
|
await sharedMigrate(sql);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Elysia } from "elysia";
|
||||||
|
import { ingest } from "./routes/pings";
|
||||||
|
import { monitors } from "./routes/monitors";
|
||||||
|
import { account } from "./routes/auth";
|
||||||
|
import { internal, startPruneJob } from "./routes/internal";
|
||||||
|
import { channels } from "./routes/channels";
|
||||||
|
import { statusPages } from "./routes/status_pages";
|
||||||
|
import { incidents } from "./routes/incidents";
|
||||||
|
import { startRollupJob } from "./jobs/rollup";
|
||||||
|
import { migrate } from "./db";
|
||||||
|
import { SECURITY_HEADERS } from "../../shared/auth";
|
||||||
|
|
||||||
|
// Global safety net: never let an unhandled rejection take down the API process.
|
||||||
|
// We'd rather log loudly and stay up than 502 every monitor on a single bug.
|
||||||
|
process.on("unhandledRejection", (reason) => {
|
||||||
|
console.error("[unhandledRejection]", reason);
|
||||||
|
});
|
||||||
|
process.on("uncaughtException", (err) => {
|
||||||
|
console.error("[uncaughtException]", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
await migrate();
|
||||||
|
await startRollupJob();
|
||||||
|
startPruneJob();
|
||||||
|
|
||||||
|
const elysia = new Elysia()
|
||||||
|
.get("/", () => ({
|
||||||
|
name: "PingQL API",
|
||||||
|
version: "1",
|
||||||
|
docs: "https://pingql.com/docs",
|
||||||
|
}))
|
||||||
|
.use(account)
|
||||||
|
.use(monitors)
|
||||||
|
.use(channels)
|
||||||
|
.use(statusPages)
|
||||||
|
.use(incidents)
|
||||||
|
.use(ingest)
|
||||||
|
.use(internal);
|
||||||
|
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 3001,
|
||||||
|
async fetch(req) {
|
||||||
|
const origin = req.headers.get("origin") || "*";
|
||||||
|
const corsHeaders: Record<string, string> = {
|
||||||
|
"access-control-allow-origin": origin,
|
||||||
|
"access-control-allow-credentials": "true",
|
||||||
|
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS",
|
||||||
|
"access-control-allow-headers": "Content-Type, Authorization",
|
||||||
|
...SECURITY_HEADERS,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new Response(null, { status: 204, headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await elysia.handle(req);
|
||||||
|
for (const [k, v] of Object.entries(corsHeaders)) {
|
||||||
|
res.headers.set(k, v);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`PingQL API running at http://localhost:${server.port}`);
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import sql from "../db";
|
||||||
|
|
||||||
|
// Aggregates raw pings into monitor_uptime_rollup so status pages and dashboard
|
||||||
|
// widgets can compute uptime % over arbitrary windows without ever scanning the
|
||||||
|
// pings table at read time. Two resolutions: hourly and daily.
|
||||||
|
//
|
||||||
|
// Watermark model: rollup_watermarks(bucket_type) records the most recent
|
||||||
|
// pings.checked_at that has been folded into this bucket_type's rollup rows.
|
||||||
|
// Each periodic pass scans ONLY pings newer than the watermark, then merges
|
||||||
|
// them into existing rollup rows additively (total = total + new_total, etc.)
|
||||||
|
// via ON CONFLICT … DO UPDATE. This makes per-pass work proportional to the
|
||||||
|
// delta of new pings, not the bucket size - critical once a single account has
|
||||||
|
// thousands of monitors.
|
||||||
|
|
||||||
|
type BucketType = "hourly" | "daily";
|
||||||
|
|
||||||
|
const BUCKET_TRUNC: Record<BucketType, string> = {
|
||||||
|
hourly: "hour",
|
||||||
|
daily: "day",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pull the current watermark for a bucket type. First call after a fresh
|
||||||
|
// deploy returns epoch (1970-01-01) so the first pass folds the entire
|
||||||
|
// retention window in one go.
|
||||||
|
async function getWatermark(bucket: BucketType): Promise<Date> {
|
||||||
|
const [row] = await sql<{ last_aggregated_at: Date }[]>`
|
||||||
|
SELECT last_aggregated_at FROM rollup_watermarks WHERE bucket_type = ${bucket}
|
||||||
|
`;
|
||||||
|
return row?.last_aggregated_at ?? new Date(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setWatermark(bucket: BucketType, ts: Date): Promise<void> {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO rollup_watermarks (bucket_type, last_aggregated_at)
|
||||||
|
VALUES (${bucket}, ${ts})
|
||||||
|
ON CONFLICT (bucket_type) DO UPDATE SET last_aggregated_at = EXCLUDED.last_aggregated_at
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate every ping newer than the watermark into the appropriate bucket
|
||||||
|
// row. Merges into existing rows additively so we never re-scan rows that
|
||||||
|
// were already folded in on a previous pass. The avg_latency merge is a
|
||||||
|
// weighted average over the running totals.
|
||||||
|
async function rollupSinceWatermark(bucket: BucketType): Promise<number> {
|
||||||
|
const trunc = BUCKET_TRUNC[bucket];
|
||||||
|
const watermark = await getWatermark(bucket);
|
||||||
|
|
||||||
|
// Capture the upper bound BEFORE the aggregation runs so we don't miss any
|
||||||
|
// pings that arrive between SELECT and the watermark write. Anything ingested
|
||||||
|
// after `boundary` will be picked up on the next pass.
|
||||||
|
const boundary = new Date();
|
||||||
|
|
||||||
|
// GROUP BY 1,2,4 (ordinals) instead of repeating the date_trunc expression -
|
||||||
|
// when the unit is a $-bound parameter, Postgres won't recognize the two
|
||||||
|
// expressions as identical and will reject the column. Ordinals are safe.
|
||||||
|
//
|
||||||
|
// The ON CONFLICT merge formula:
|
||||||
|
// total = old + new
|
||||||
|
// up_count = old + new
|
||||||
|
// avg_latency = weighted average of old and new, weighted by their totals
|
||||||
|
// (NULLIF guards against the degenerate empty-bucket case)
|
||||||
|
// The weighted-average formula is mathematically identical to a one-shot
|
||||||
|
// SUM(latency)/SUM(total) recompute, so the merged value is byte-equal to
|
||||||
|
// what a full rescan would produce.
|
||||||
|
const result = await sql`
|
||||||
|
INSERT INTO monitor_uptime_rollup (monitor_id, region, bucket_type, bucket_start, total, up_count, avg_latency)
|
||||||
|
SELECT
|
||||||
|
monitor_id,
|
||||||
|
COALESCE(region, 'default') AS region,
|
||||||
|
${bucket} AS bucket_type,
|
||||||
|
date_trunc(${trunc}, checked_at) AS bucket_start,
|
||||||
|
count(*)::int AS total,
|
||||||
|
count(*) FILTER (WHERE up)::int AS up_count,
|
||||||
|
avg(latency_ms)::real AS avg_latency
|
||||||
|
FROM pings
|
||||||
|
WHERE checked_at > ${watermark} AND checked_at <= ${boundary}
|
||||||
|
GROUP BY 1, 2, 3, 4
|
||||||
|
ON CONFLICT (monitor_id, region, bucket_type, bucket_start) DO UPDATE SET
|
||||||
|
total = monitor_uptime_rollup.total + EXCLUDED.total,
|
||||||
|
up_count = monitor_uptime_rollup.up_count + EXCLUDED.up_count,
|
||||||
|
avg_latency = (
|
||||||
|
COALESCE(monitor_uptime_rollup.avg_latency, 0) * monitor_uptime_rollup.total
|
||||||
|
+ COALESCE(EXCLUDED.avg_latency, 0) * EXCLUDED.total
|
||||||
|
) / NULLIF(monitor_uptime_rollup.total + EXCLUDED.total, 0)
|
||||||
|
`;
|
||||||
|
|
||||||
|
await setWatermark(bucket, boundary);
|
||||||
|
return result.count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-shot recompute over an arbitrary window, fully overwriting matched rows.
|
||||||
|
// Used for the startup backfill and the "still empty after backfill" force-run.
|
||||||
|
// Takes an explicit upper boundary so the caller can capture it BEFORE running
|
||||||
|
// the recompute and use the same value for the watermark write afterwards -
|
||||||
|
// any ping with checked_at > boundary is guaranteed to be outside this window
|
||||||
|
// and will be picked up by the first incremental pass instead. This closes
|
||||||
|
// the race where a ping ingested between boundary capture and the SELECT
|
||||||
|
// could otherwise be folded in by both recompute and incremental.
|
||||||
|
async function recomputeWindow(bucket: BucketType, units: number, boundary: Date): Promise<number> {
|
||||||
|
const trunc = BUCKET_TRUNC[bucket];
|
||||||
|
const intervalLiteral = `${units} ${trunc}s`;
|
||||||
|
const result = await sql`
|
||||||
|
INSERT INTO monitor_uptime_rollup (monitor_id, region, bucket_type, bucket_start, total, up_count, avg_latency)
|
||||||
|
SELECT
|
||||||
|
monitor_id,
|
||||||
|
COALESCE(region, 'default') AS region,
|
||||||
|
${bucket} AS bucket_type,
|
||||||
|
date_trunc(${trunc}, checked_at) AS bucket_start,
|
||||||
|
count(*)::int AS total,
|
||||||
|
count(*) FILTER (WHERE up)::int AS up_count,
|
||||||
|
avg(latency_ms)::real AS avg_latency
|
||||||
|
FROM pings
|
||||||
|
WHERE checked_at >= date_trunc(${trunc}, ${boundary}::timestamptz) - ${intervalLiteral}::interval
|
||||||
|
AND checked_at <= ${boundary}
|
||||||
|
GROUP BY 1, 2, 3, 4
|
||||||
|
ON CONFLICT (monitor_id, region, bucket_type, bucket_start) DO UPDATE SET
|
||||||
|
total = EXCLUDED.total,
|
||||||
|
up_count = EXCLUDED.up_count,
|
||||||
|
avg_latency = EXCLUDED.avg_latency
|
||||||
|
`;
|
||||||
|
return result.count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let started = false;
|
||||||
|
|
||||||
|
async function rollupIsEmpty(bucket: BucketType): Promise<boolean> {
|
||||||
|
const [row] = await sql<{ exists: boolean }[]>`
|
||||||
|
SELECT EXISTS (SELECT 1 FROM monitor_uptime_rollup WHERE bucket_type = ${bucket} LIMIT 1) AS exists
|
||||||
|
`;
|
||||||
|
return !row?.exists;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startRollupJob() {
|
||||||
|
if (started) return;
|
||||||
|
started = true;
|
||||||
|
|
||||||
|
// Startup backfill. Capture the boundary FIRST, then run the one-shot
|
||||||
|
// recompute bounded by it. The watermark is then set to the same boundary,
|
||||||
|
// so any ping with checked_at > boundary is guaranteed to be picked up by
|
||||||
|
// the first incremental pass - never folded in twice and never missed.
|
||||||
|
try {
|
||||||
|
const boundary = new Date();
|
||||||
|
const [h, d] = await Promise.all([
|
||||||
|
recomputeWindow("hourly", 48, boundary),
|
||||||
|
recomputeWindow("daily", 90, boundary),
|
||||||
|
]);
|
||||||
|
console.log(`[rollup] backfilled rows: hourly=${h} daily=${d}`);
|
||||||
|
await Promise.all([
|
||||||
|
setWatermark("hourly", boundary),
|
||||||
|
setWatermark("daily", boundary),
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[rollup] backfill FAILED - rollup table will be empty until fixed:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force-run check: if any bucket type is still empty after the backfill,
|
||||||
|
// run an incremental pass immediately so we have at least one row per type.
|
||||||
|
// Covers the "fresh deploy with very recent pings only" case.
|
||||||
|
try {
|
||||||
|
for (const b of ["hourly", "daily"] as BucketType[]) {
|
||||||
|
if (await rollupIsEmpty(b)) {
|
||||||
|
console.log(`[rollup] ${b} still empty - forcing incremental aggregation`);
|
||||||
|
// Reset watermark so the pass picks up everything in retention.
|
||||||
|
await setWatermark(b, new Date(0));
|
||||||
|
await rollupSinceWatermark(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[rollup] force-run check failed:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Periodic incremental refreshes. Each pass scans only pings newer than the
|
||||||
|
// last watermark, so the work is proportional to the delta - not the bucket
|
||||||
|
// size. Hourly runs frequently so the current-hour bar appears quickly for
|
||||||
|
// fresh monitors; daily can run less often.
|
||||||
|
setInterval(() => { rollupSinceWatermark("hourly").catch((e) => console.warn("[rollup] hourly failed:", e)); }, 30 * 1000); // every 30s
|
||||||
|
setInterval(() => { rollupSinceWatermark("daily").catch((e) => console.warn("[rollup] daily failed:", e)); }, 5 * 60 * 1000); // every 5min
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import sql from "../db";
|
||||||
|
import type { NotificationProvider, ChannelRow, NotificationEvent } from "./types";
|
||||||
|
import { webhook } from "./webhook";
|
||||||
|
|
||||||
|
// Modular provider registry. Add new providers by importing them here and
|
||||||
|
// dropping them in the map; nothing else needs to change.
|
||||||
|
const providers: Record<string, NotificationProvider> = {
|
||||||
|
webhook,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function knownProviderKinds(): string[] {
|
||||||
|
return Object.keys(providers);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatch(channel: ChannelRow, event: NotificationEvent): Promise<void> {
|
||||||
|
const p = providers[channel.kind];
|
||||||
|
if (!p) {
|
||||||
|
console.warn(`[notify] unknown provider kind: ${channel.kind}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await p.send(channel, event);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[notify] ${channel.kind} send failed for channel ${channel.id}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchForMonitor(monitorId: string, event: NotificationEvent): Promise<void> {
|
||||||
|
// Belt-and-braces: this is called fire-and-forget from the ingest path, so any
|
||||||
|
// error escaping here would become an unhandled rejection and crash the process.
|
||||||
|
try {
|
||||||
|
const channels = await sql<ChannelRow[]>`
|
||||||
|
SELECT c.id, c.account_id, c.name, c.kind, c.config, c.enabled
|
||||||
|
FROM notification_channels c
|
||||||
|
WHERE c.account_id = (SELECT account_id FROM monitors WHERE id = ${monitorId})
|
||||||
|
AND ${monitorId} = ANY(c.monitor_ids)
|
||||||
|
AND c.enabled = true
|
||||||
|
`;
|
||||||
|
if (channels.length === 0) return;
|
||||||
|
await Promise.all(channels.map((c) => dispatch(c, event)));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[notify] dispatchForMonitor failed for ${monitorId}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { NotificationEvent, ChannelRow, MonitorContext, PingContext } from "./types";
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export interface ChannelRow {
|
||||||
|
id: string;
|
||||||
|
account_id: string;
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
config: any;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitorContext {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
region: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PingContext {
|
||||||
|
status_code: number | null;
|
||||||
|
latency_ms: number | null;
|
||||||
|
error: string | null;
|
||||||
|
checked_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NotificationEvent =
|
||||||
|
| { kind: "down"; monitor: MonitorContext; ping: PingContext }
|
||||||
|
| { kind: "up"; monitor: MonitorContext; ping: PingContext }
|
||||||
|
| { kind: "cert"; monitor: MonitorContext; days: number }
|
||||||
|
| { kind: "test"; monitor: MonitorContext };
|
||||||
|
|
||||||
|
export interface NotificationProvider {
|
||||||
|
kind: string;
|
||||||
|
send(channel: ChannelRow, event: NotificationEvent): Promise<void>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { createHmac } from "crypto";
|
||||||
|
import type { NotificationProvider, ChannelRow, NotificationEvent } from "./types";
|
||||||
|
|
||||||
|
export const webhook: NotificationProvider = {
|
||||||
|
kind: "webhook",
|
||||||
|
async send(channel: ChannelRow, event: NotificationEvent) {
|
||||||
|
const cfg = channel.config || {};
|
||||||
|
const url: string | undefined = cfg.url;
|
||||||
|
if (!url) throw new Error("webhook channel missing config.url");
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
channel: { id: channel.id, name: channel.name },
|
||||||
|
event,
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"user-agent": "PingQL-Notifier/1",
|
||||||
|
};
|
||||||
|
if (cfg.headers && typeof cfg.headers === "object") {
|
||||||
|
for (const [k, v] of Object.entries(cfg.headers)) {
|
||||||
|
if (typeof v === "string") headers[k.toLowerCase()] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof cfg.secret === "string" && cfg.secret.length) {
|
||||||
|
headers["x-pingql-signature"] = createHmac("sha256", cfg.secret).update(payload).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), 5000);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, { method: "POST", headers, body: payload, signal: ctrl.signal });
|
||||||
|
if (!resp.ok) throw new Error(`webhook ${url} returned ${resp.status}`);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
import sql from "../db";
|
||||||
|
import { createRateLimiter } from "../../../shared/rate-limit";
|
||||||
|
import { getPlanLimits } from "../../../shared/plans";
|
||||||
|
import { generateKey, hashEmail, resolveKey as sharedResolveKey, extractAuthKey, COOKIE_OPTS } from "../../../shared/auth";
|
||||||
|
|
||||||
|
const checkAuthRateLimit = createRateLimiter();
|
||||||
|
|
||||||
|
async function resolveKey(key: string) {
|
||||||
|
return sharedResolveKey(sql, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { resolveKey };
|
||||||
|
|
||||||
|
export function requireAuth(app: Elysia) {
|
||||||
|
return app
|
||||||
|
.derive(async ({ headers, cookie, set }) => {
|
||||||
|
const key = extractAuthKey(headers, cookie);
|
||||||
|
if (!key) {
|
||||||
|
set.status = 401;
|
||||||
|
return { accountId: null as string | null, keyId: null as string | null, plan: "free" as string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = await resolveKey(key);
|
||||||
|
if (resolved) return { accountId: resolved.accountId, keyId: resolved.keyId, plan: resolved.plan };
|
||||||
|
|
||||||
|
set.status = 401;
|
||||||
|
return { accountId: null as string | null, keyId: null as string | null, plan: "free" as string };
|
||||||
|
})
|
||||||
|
.onBeforeHandle(({ accountId, set }) => {
|
||||||
|
if (!accountId) {
|
||||||
|
set.status = 401;
|
||||||
|
return { error: "Invalid or missing account key" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const account = new Elysia({ prefix: "/account" })
|
||||||
|
|
||||||
|
.post("/login", async ({ body, cookie, set, request }) => {
|
||||||
|
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
|
||||||
|
if (!checkAuthRateLimit(ip, 10)) { set.status = 429; return { error: "Too many login attempts. Try again later." }; }
|
||||||
|
|
||||||
|
const key = (body.key as string)?.trim();
|
||||||
|
if (!key) { set.status = 400; return { error: "Key required" }; }
|
||||||
|
|
||||||
|
const resolved = await resolveKey(key);
|
||||||
|
if (!resolved) {
|
||||||
|
set.status = 401;
|
||||||
|
if ((body as any)._form) { set.redirect = "/dashboard?error=invalid"; return; }
|
||||||
|
return { error: "Invalid account key" };
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
|
||||||
|
if ((body as any)._form) { set.redirect = "/dashboard/home"; return; }
|
||||||
|
return { ok: true };
|
||||||
|
}, { detail: { hide: true } })
|
||||||
|
|
||||||
|
.get("/logout", ({ cookie, set }) => {
|
||||||
|
cookie.pingql_key.set({ value: "", ...COOKIE_OPTS, maxAge: 0 });
|
||||||
|
set.redirect = "/dashboard";
|
||||||
|
}, { detail: { hide: true } })
|
||||||
|
|
||||||
|
.post("/register", async ({ body, cookie, request, set }) => {
|
||||||
|
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
|
||||||
|
if (!checkAuthRateLimit(ip, 5)) { set.status = 429; return { error: "Too many registrations. Try again later." }; }
|
||||||
|
|
||||||
|
const key = generateKey();
|
||||||
|
const emailHash = body.email ? hashEmail(body.email) : null;
|
||||||
|
await sql`INSERT INTO accounts (key, email_hash) VALUES (${key}, ${emailHash})`;
|
||||||
|
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
...(body.email ? { email_registered: true } : { email_registered: false }),
|
||||||
|
};
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
email: t.Optional(t.String({ format: "email", description: "Optional. Used for account recovery only." })),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
.use(requireAuth)
|
||||||
|
|
||||||
|
.get("/settings", async ({ accountId }) => {
|
||||||
|
const [acc] = await sql`SELECT id, email_hash, plan, plan_expires_at, plan_stack, created_at FROM accounts WHERE id = ${accountId}`;
|
||||||
|
const keys = await sql`SELECT id, key, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`;
|
||||||
|
const [{ count: monitorCount }] = await sql`SELECT COUNT(*)::int as count FROM monitors WHERE account_id = ${accountId}`;
|
||||||
|
const limits = getPlanLimits(acc.plan);
|
||||||
|
return {
|
||||||
|
account_id: acc.id,
|
||||||
|
has_email: !!acc.email_hash,
|
||||||
|
plan: acc.plan,
|
||||||
|
plan_expires_at: acc.plan_expires_at,
|
||||||
|
plan_stack: acc.plan_stack || [],
|
||||||
|
monitor_count: monitorCount,
|
||||||
|
limits,
|
||||||
|
created_at: acc.created_at,
|
||||||
|
api_keys: keys,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
|
||||||
|
.post("/email", async ({ accountId, keyId, body, set }) => {
|
||||||
|
if (keyId) { set.status = 403; return { error: "Sub-keys cannot modify account email" }; }
|
||||||
|
const emailHash = body.email ? hashEmail(body.email) : null;
|
||||||
|
await sql`UPDATE accounts SET email_hash = ${emailHash} WHERE id = ${accountId}`;
|
||||||
|
return { ok: true };
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
email: t.Optional(t.Nullable(t.String({ description: "Email for account recovery only." }))),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
.post("/reset-key", async ({ accountId, keyId, cookie, set }) => {
|
||||||
|
if (keyId) { set.status = 403; return { error: "Sub-keys cannot rotate the account key" }; }
|
||||||
|
const key = generateKey();
|
||||||
|
await sql`UPDATE accounts SET key = ${key} WHERE id = ${accountId}`;
|
||||||
|
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
|
||||||
|
return { key, message: "Primary key rotated. Your old key is now invalid." };
|
||||||
|
})
|
||||||
|
|
||||||
|
.post("/keys", async ({ accountId, keyId, body, set }) => {
|
||||||
|
if (keyId) { set.status = 403; return { error: "Sub-keys cannot create other sub-keys" }; }
|
||||||
|
const key = generateKey();
|
||||||
|
const [created] = await sql`INSERT INTO api_keys (key, account_id, label) VALUES (${key}, ${accountId}, ${body.label}) RETURNING id`;
|
||||||
|
return { key, id: created.id, label: body.label };
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
label: t.String({ description: "A name for this key, e.g. 'ci-pipeline' or 'mobile-app'" }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
.delete("/keys/:id", async ({ accountId, keyId, params, set }) => {
|
||||||
|
if (keyId) { set.status = 403; return { error: "Sub-keys cannot revoke other sub-keys" }; }
|
||||||
|
const [deleted] = await sql`
|
||||||
|
DELETE FROM api_keys WHERE id = ${params.id} AND account_id = ${accountId} RETURNING id
|
||||||
|
`;
|
||||||
|
if (!deleted) { set.status = 404; return { error: "Key not found" }; }
|
||||||
|
return { deleted: true };
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
import { requireAuth } from "./auth";
|
||||||
|
import sql from "../db";
|
||||||
|
import { dispatch, knownProviderKinds, type ChannelRow } from "../notifications";
|
||||||
|
|
||||||
|
const ChannelBody = t.Object({
|
||||||
|
name: t.String({ minLength: 1, maxLength: 200 }),
|
||||||
|
kind: t.String({ description: "Provider kind, e.g. 'webhook'" }),
|
||||||
|
config: t.Any({ description: "Provider-specific config object" }),
|
||||||
|
enabled: t.Optional(t.Boolean()),
|
||||||
|
monitor_ids: t.Optional(t.Array(t.String(), { description: "Monitor IDs this channel dispatches for." })),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function validateMonitorIds(accountId: string, monitorIds: string[]): Promise<string[]> {
|
||||||
|
if (monitorIds.length === 0) return [];
|
||||||
|
const owned = await sql<{ id: string }[]>`
|
||||||
|
SELECT id FROM monitors
|
||||||
|
WHERE account_id = ${accountId}
|
||||||
|
AND id = ANY(${sql.array(monitorIds)}::text[])
|
||||||
|
`;
|
||||||
|
return owned.map((o) => o.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateKind(kind: string): string | null {
|
||||||
|
if (!knownProviderKinds().includes(kind)) {
|
||||||
|
return `Unknown provider kind '${kind}'. Known: ${knownProviderKinds().join(", ")}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateWebhookConfig(config: any): string | null {
|
||||||
|
if (!config || typeof config !== "object") return "config must be an object";
|
||||||
|
if (typeof config.url !== "string" || !/^https?:\/\//.test(config.url)) {
|
||||||
|
return "webhook config.url must be a http(s) URL";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateConfig(kind: string, config: any): string | null {
|
||||||
|
if (kind === "webhook") return validateWebhookConfig(config);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const channels = new Elysia({ prefix: "/notifications/channels" })
|
||||||
|
.use(requireAuth)
|
||||||
|
|
||||||
|
.get("/", async ({ accountId }) => {
|
||||||
|
return sql`
|
||||||
|
SELECT id, name, kind, config, enabled, monitor_ids, created_at
|
||||||
|
FROM notification_channels
|
||||||
|
WHERE account_id = ${accountId}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`;
|
||||||
|
}, { detail: { summary: "List notification channels", tags: ["notifications"] } })
|
||||||
|
|
||||||
|
.post("/", async ({ accountId, body, set }) => {
|
||||||
|
const kindErr = validateKind(body.kind);
|
||||||
|
if (kindErr) { set.status = 400; return { error: kindErr }; }
|
||||||
|
const cfgErr = validateConfig(body.kind, body.config);
|
||||||
|
if (cfgErr) { set.status = 400; return { error: cfgErr }; }
|
||||||
|
|
||||||
|
const monitorIds = body.monitor_ids ? await validateMonitorIds(accountId, body.monitor_ids) : [];
|
||||||
|
const [row] = await sql`
|
||||||
|
INSERT INTO notification_channels (account_id, name, kind, config, enabled, monitor_ids)
|
||||||
|
VALUES (${accountId}, ${body.name}, ${body.kind}, ${sql.json(body.config)}, ${body.enabled ?? true}, ${sql.array(monitorIds)}::text[])
|
||||||
|
RETURNING id, name, kind, config, enabled, monitor_ids, created_at
|
||||||
|
`;
|
||||||
|
return row;
|
||||||
|
}, { body: ChannelBody, detail: { summary: "Create notification channel", tags: ["notifications"] } })
|
||||||
|
|
||||||
|
.patch("/:id", async ({ accountId, params, body, set }) => {
|
||||||
|
if (body.kind != null) {
|
||||||
|
const kindErr = validateKind(body.kind);
|
||||||
|
if (kindErr) { set.status = 400; return { error: kindErr }; }
|
||||||
|
}
|
||||||
|
if (body.config != null) {
|
||||||
|
const kind = body.kind ?? (await sql`SELECT kind FROM notification_channels WHERE id = ${params.id} AND account_id = ${accountId}`)[0]?.kind;
|
||||||
|
if (kind) {
|
||||||
|
const cfgErr = validateConfig(kind, body.config);
|
||||||
|
if (cfgErr) { set.status = 400; return { error: cfgErr }; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validatedMonitorIds = body.monitor_ids ? await validateMonitorIds(accountId, body.monitor_ids) : null;
|
||||||
|
const [row] = await sql`
|
||||||
|
UPDATE notification_channels SET
|
||||||
|
name = COALESCE(${body.name ?? null}, name),
|
||||||
|
kind = COALESCE(${body.kind ?? null}, kind),
|
||||||
|
config = COALESCE(${body.config != null ? sql.json(body.config) : null}, config),
|
||||||
|
enabled = COALESCE(${body.enabled ?? null}, enabled),
|
||||||
|
monitor_ids = COALESCE(${validatedMonitorIds ? sql.array(validatedMonitorIds) : null}::text[], monitor_ids)
|
||||||
|
WHERE id = ${params.id} AND account_id = ${accountId}
|
||||||
|
RETURNING id, name, kind, config, enabled, monitor_ids, created_at
|
||||||
|
`;
|
||||||
|
if (!row) { set.status = 404; return { error: "Not found" }; }
|
||||||
|
return row;
|
||||||
|
}, { body: t.Partial(ChannelBody), detail: { summary: "Update notification channel", tags: ["notifications"] } })
|
||||||
|
|
||||||
|
.delete("/:id", async ({ accountId, params, set }) => {
|
||||||
|
const [row] = await sql`
|
||||||
|
DELETE FROM notification_channels
|
||||||
|
WHERE id = ${params.id} AND account_id = ${accountId}
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
if (!row) { set.status = 404; return { error: "Not found" }; }
|
||||||
|
return { deleted: true };
|
||||||
|
}, { detail: { summary: "Delete notification channel", tags: ["notifications"] } })
|
||||||
|
|
||||||
|
.post("/:id/test", async ({ accountId, params, set }) => {
|
||||||
|
const [row] = await sql<ChannelRow[]>`
|
||||||
|
SELECT id, account_id, name, kind, config, enabled
|
||||||
|
FROM notification_channels
|
||||||
|
WHERE id = ${params.id} AND account_id = ${accountId}
|
||||||
|
`;
|
||||||
|
if (!row) { set.status = 404; return { error: "Not found" }; }
|
||||||
|
try {
|
||||||
|
await dispatch(row, {
|
||||||
|
kind: "test",
|
||||||
|
monitor: { id: "test", name: "Test event", url: "https://example.com", region: "" },
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e: any) {
|
||||||
|
set.status = 502;
|
||||||
|
return { error: e?.message || String(e) };
|
||||||
|
}
|
||||||
|
}, { detail: { summary: "Send a test event to a channel", tags: ["notifications"] } });
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
import { requireAuth } from "./auth";
|
||||||
|
import sql from "../db";
|
||||||
|
|
||||||
|
const Status = t.Union([t.Literal("investigating"), t.Literal("identified"), t.Literal("monitoring"), t.Literal("resolved")]);
|
||||||
|
const Severity = t.Union([t.Literal("minor"), t.Literal("major"), t.Literal("critical")]);
|
||||||
|
|
||||||
|
const IncidentBody = t.Object({
|
||||||
|
title: t.String({ minLength: 1, maxLength: 200 }),
|
||||||
|
status: Status,
|
||||||
|
severity: t.Optional(Severity),
|
||||||
|
pinned: t.Optional(t.Boolean()),
|
||||||
|
monitor_ids: t.Optional(t.Array(t.String())),
|
||||||
|
status_page_ids: t.Optional(t.Array(t.String())),
|
||||||
|
initial_update: t.Optional(t.Object({
|
||||||
|
body: t.String({ minLength: 1, maxLength: 10_000 }),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const IncidentUpdateBody = t.Object({
|
||||||
|
status: Status,
|
||||||
|
body: t.String({ minLength: 1, maxLength: 10_000 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// HTML escape every byte first, then walk a tiny markdown subset and produce
|
||||||
|
// safe HTML. Anything we didn't explicitly enable stays escaped. Output is
|
||||||
|
// stored in incident_updates.body_html and rendered without further processing.
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(src: string): string {
|
||||||
|
let out = escapeHtml(src.trim());
|
||||||
|
// Inline code first so we don't expand markdown inside it.
|
||||||
|
const codeStash: string[] = [];
|
||||||
|
out = out.replace(/`([^`\n]+?)`/g, (_m, code) => {
|
||||||
|
codeStash.push(code);
|
||||||
|
return `\u0000${codeStash.length - 1}\u0000`;
|
||||||
|
});
|
||||||
|
// Links: [text](http(s)://...)
|
||||||
|
out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_m, text, url) =>
|
||||||
|
`<a href="${url}" rel="noopener nofollow" target="_blank">${text}</a>`,
|
||||||
|
);
|
||||||
|
// Bold then italic.
|
||||||
|
out = out.replace(/\*\*([^*\n]+?)\*\*/g, "<strong>$1</strong>");
|
||||||
|
out = out.replace(/\*([^*\n]+?)\*/g, "<em>$1</em>");
|
||||||
|
// Restore inline code as <code>.
|
||||||
|
out = out.replace(/\u0000(\d+)\u0000/g, (_m, i) => `<code>${codeStash[Number(i)]}</code>`);
|
||||||
|
// Paragraphs: split on blank lines, single newlines become <br>.
|
||||||
|
const paras = out.split(/\n{2,}/).map((p) => `<p>${p.replace(/\n/g, "<br>")}</p>`);
|
||||||
|
return paras.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachJoins(incidentId: string, accountId: string, monitorIds: string[] | undefined, pageIds: string[] | undefined) {
|
||||||
|
if (monitorIds !== undefined) {
|
||||||
|
await sql`DELETE FROM incident_monitors WHERE incident_id = ${incidentId}`;
|
||||||
|
if (monitorIds.length > 0) {
|
||||||
|
const owned = await sql<{ id: string }[]>`
|
||||||
|
SELECT id FROM monitors
|
||||||
|
WHERE account_id = ${accountId} AND id = ANY(${sql.array(monitorIds)}::text[])
|
||||||
|
`;
|
||||||
|
if (owned.length > 0) {
|
||||||
|
const rows = owned.map((o) => ({ incident_id: incidentId, monitor_id: o.id }));
|
||||||
|
await sql`INSERT INTO incident_monitors ${sql(rows, "incident_id", "monitor_id")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pageIds !== undefined) {
|
||||||
|
await sql`DELETE FROM incident_status_pages WHERE incident_id = ${incidentId}`;
|
||||||
|
if (pageIds.length > 0) {
|
||||||
|
const owned = await sql<{ id: string }[]>`
|
||||||
|
SELECT id FROM status_pages
|
||||||
|
WHERE account_id = ${accountId} AND id = ANY(${sql.array(pageIds)}::uuid[])
|
||||||
|
`;
|
||||||
|
if (owned.length > 0) {
|
||||||
|
const rows = owned.map((o) => ({ incident_id: incidentId, status_page_id: o.id }));
|
||||||
|
await sql`INSERT INTO incident_status_pages ${sql(rows, "incident_id", "status_page_id")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const incidents = new Elysia({ prefix: "/incidents" })
|
||||||
|
.use(requireAuth)
|
||||||
|
|
||||||
|
.get("/", async ({ accountId }) => {
|
||||||
|
return sql`
|
||||||
|
SELECT id, title, status, severity, pinned, started_at, resolved_at, created_at
|
||||||
|
FROM incidents
|
||||||
|
WHERE account_id = ${accountId}
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT 200
|
||||||
|
`;
|
||||||
|
}, { detail: { summary: "List incidents", tags: ["incidents"] } })
|
||||||
|
|
||||||
|
.post("/", async ({ accountId, body }) => {
|
||||||
|
const [row] = await sql`
|
||||||
|
INSERT INTO incidents (account_id, title, status, severity, pinned, resolved_at)
|
||||||
|
VALUES (
|
||||||
|
${accountId}, ${body.title}, ${body.status},
|
||||||
|
${body.severity ?? 'minor'}, ${body.pinned ?? true},
|
||||||
|
${body.status === 'resolved' ? sql`now()` : null}
|
||||||
|
)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
await attachJoins(row.id, accountId, body.monitor_ids, body.status_page_ids);
|
||||||
|
if (body.initial_update) {
|
||||||
|
const html = renderMarkdown(body.initial_update.body);
|
||||||
|
await sql`
|
||||||
|
INSERT INTO incident_updates (incident_id, status, body, body_html)
|
||||||
|
VALUES (${row.id}, ${body.status}, ${body.initial_update.body}, ${html})
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}, { body: IncidentBody, detail: { summary: "Create incident", tags: ["incidents"] } })
|
||||||
|
|
||||||
|
.get("/:id", async ({ accountId, params, set }) => {
|
||||||
|
const [incident] = await sql`
|
||||||
|
SELECT * FROM incidents WHERE id = ${params.id} AND account_id = ${accountId}
|
||||||
|
`;
|
||||||
|
if (!incident) { set.status = 404; return { error: "Not found" }; }
|
||||||
|
const updates = await sql`
|
||||||
|
SELECT id, status, body, body_html, created_at FROM incident_updates
|
||||||
|
WHERE incident_id = ${params.id} ORDER BY created_at ASC
|
||||||
|
`;
|
||||||
|
const monitorRows = await sql<{ monitor_id: string }[]>`
|
||||||
|
SELECT monitor_id FROM incident_monitors WHERE incident_id = ${params.id}
|
||||||
|
`;
|
||||||
|
const pageRows = await sql<{ status_page_id: string }[]>`
|
||||||
|
SELECT status_page_id FROM incident_status_pages WHERE incident_id = ${params.id}
|
||||||
|
`;
|
||||||
|
return {
|
||||||
|
...incident,
|
||||||
|
updates,
|
||||||
|
monitor_ids: monitorRows.map((r) => r.monitor_id),
|
||||||
|
status_page_ids: pageRows.map((r) => r.status_page_id),
|
||||||
|
};
|
||||||
|
}, { detail: { summary: "Get incident", tags: ["incidents"] } })
|
||||||
|
|
||||||
|
.patch("/:id", async ({ accountId, params, body, set }) => {
|
||||||
|
const [row] = await sql`
|
||||||
|
UPDATE incidents SET
|
||||||
|
title = COALESCE(${body.title ?? null}, title),
|
||||||
|
status = COALESCE(${body.status ?? null}, status),
|
||||||
|
severity = COALESCE(${body.severity ?? null}, severity),
|
||||||
|
pinned = COALESCE(${body.pinned ?? null}, pinned),
|
||||||
|
resolved_at = CASE WHEN ${body.status === 'resolved'} THEN COALESCE(resolved_at, now())
|
||||||
|
WHEN ${body.status != null && body.status !== 'resolved'} THEN NULL
|
||||||
|
ELSE resolved_at END
|
||||||
|
WHERE id = ${params.id} AND account_id = ${accountId}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
if (!row) { set.status = 404; return { error: "Not found" }; }
|
||||||
|
await attachJoins(row.id, accountId, body.monitor_ids, body.status_page_ids);
|
||||||
|
return row;
|
||||||
|
}, { body: t.Partial(IncidentBody), detail: { summary: "Update incident", tags: ["incidents"] } })
|
||||||
|
|
||||||
|
.post("/:id/updates", async ({ accountId, params, body, set }) => {
|
||||||
|
const [incident] = await sql<{ id: string }[]>`
|
||||||
|
SELECT id FROM incidents WHERE id = ${params.id} AND account_id = ${accountId}
|
||||||
|
`;
|
||||||
|
if (!incident) { set.status = 404; return { error: "Not found" }; }
|
||||||
|
const html = renderMarkdown(body.body);
|
||||||
|
const [update] = await sql`
|
||||||
|
INSERT INTO incident_updates (incident_id, status, body, body_html)
|
||||||
|
VALUES (${params.id}, ${body.status}, ${body.body}, ${html})
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
// Bring the parent incident's status into sync with the latest update.
|
||||||
|
await sql`
|
||||||
|
UPDATE incidents SET
|
||||||
|
status = ${body.status},
|
||||||
|
resolved_at = CASE WHEN ${body.status === 'resolved'} THEN COALESCE(resolved_at, now()) ELSE NULL END
|
||||||
|
WHERE id = ${params.id}
|
||||||
|
`;
|
||||||
|
return update;
|
||||||
|
}, { body: IncidentUpdateBody, detail: { summary: "Post incident update", tags: ["incidents"] } })
|
||||||
|
|
||||||
|
.delete("/:id", async ({ accountId, params, set }) => {
|
||||||
|
const [row] = await sql`
|
||||||
|
DELETE FROM incidents WHERE id = ${params.id} AND account_id = ${accountId}
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
if (!row) { set.status = 404; return { error: "Not found" }; }
|
||||||
|
return { deleted: true };
|
||||||
|
}, { detail: { summary: "Delete incident", tags: ["incidents"] } });
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { Elysia } from "elysia";
|
||||||
|
import sql from "../db";
|
||||||
|
import { safeTokenCompare } from "../../../shared/auth";
|
||||||
|
import { getMonitorsForRegion } from "../cache/monitor-list";
|
||||||
|
|
||||||
|
// Chunked retention prune. A single big DELETE on the pings table holds locks
|
||||||
|
// for the duration of the scan, blocks autovacuum from reclaiming dead tuples,
|
||||||
|
// and can stall replication. We loop in 10k-row batches so each commit
|
||||||
|
// releases its locks and lets autovacuum keep up. Total wall-clock is similar
|
||||||
|
// to a one-shot DELETE but the per-statement impact on the rest of the system
|
||||||
|
// is dramatically lower.
|
||||||
|
const PRUNE_BATCH_SIZE = 10_000;
|
||||||
|
export async function pruneOldPings(retentionDays = 90) {
|
||||||
|
const interval = `${retentionDays} days`;
|
||||||
|
let total = 0;
|
||||||
|
while (true) {
|
||||||
|
const result = await sql`
|
||||||
|
WITH victims AS (
|
||||||
|
SELECT id FROM pings
|
||||||
|
WHERE checked_at < now() - ${interval}::interval
|
||||||
|
LIMIT ${PRUNE_BATCH_SIZE}
|
||||||
|
)
|
||||||
|
DELETE FROM pings WHERE id IN (SELECT id FROM victims)
|
||||||
|
`;
|
||||||
|
const batch = result.count ?? 0;
|
||||||
|
total += batch;
|
||||||
|
if (batch < PRUNE_BATCH_SIZE) break;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Periodic prune is gated behind a Postgres session-level advisory lock so
|
||||||
|
// horizontally scaled api replicas don't race each other. Without the lock,
|
||||||
|
// two replicas running the same chunked DELETE would interleave their
|
||||||
|
// LIMIT 10000 batches and compete for row locks. The lock is acquired with
|
||||||
|
// pg_try_advisory_lock so a busy replica skips its tick instead of blocking.
|
||||||
|
//
|
||||||
|
// Session-level locks live on a specific Postgres backend connection, so we
|
||||||
|
// reserve a connection from the pool with sql.reserve() and run the lock,
|
||||||
|
// the chunked prune, and the unlock all on it. Releasing the reservation
|
||||||
|
// returns the connection to the pool. If the api process crashes mid-prune,
|
||||||
|
// the backend dies with it and Postgres releases the lock automatically.
|
||||||
|
//
|
||||||
|
// 134678338 is just an arbitrary lock id unique within this app. If we add
|
||||||
|
// more global jobs later, give each its own constant in this file.
|
||||||
|
const PRUNE_LOCK_ID = 134678338;
|
||||||
|
let pruneJobStarted = false;
|
||||||
|
|
||||||
|
async function runPruneTickWithLock(retentionDays: number): Promise<void> {
|
||||||
|
const reserved = await (sql as any).reserve();
|
||||||
|
try {
|
||||||
|
const [{ locked }] = await reserved`
|
||||||
|
SELECT pg_try_advisory_lock(${PRUNE_LOCK_ID}) AS locked
|
||||||
|
`;
|
||||||
|
if (!locked) return; // another replica is pruning right now
|
||||||
|
try {
|
||||||
|
const interval = `${retentionDays} days`;
|
||||||
|
let total = 0;
|
||||||
|
while (true) {
|
||||||
|
const result = await reserved`
|
||||||
|
WITH victims AS (
|
||||||
|
SELECT id FROM pings
|
||||||
|
WHERE checked_at < now() - ${interval}::interval
|
||||||
|
LIMIT ${PRUNE_BATCH_SIZE}
|
||||||
|
)
|
||||||
|
DELETE FROM pings WHERE id IN (SELECT id FROM victims)
|
||||||
|
`;
|
||||||
|
const batch = result.count ?? 0;
|
||||||
|
total += batch;
|
||||||
|
if (batch < PRUNE_BATCH_SIZE) break;
|
||||||
|
}
|
||||||
|
if (total > 0) console.log(`[prune] retention pruned ${total} pings (>${retentionDays}d)`);
|
||||||
|
} finally {
|
||||||
|
await reserved`SELECT pg_advisory_unlock(${PRUNE_LOCK_ID})`;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reserved.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startPruneJob() {
|
||||||
|
if (pruneJobStarted) return;
|
||||||
|
pruneJobStarted = true;
|
||||||
|
setInterval(() => {
|
||||||
|
const days = Number(process.env.PING_RETENTION_DAYS ?? 90);
|
||||||
|
runPruneTickWithLock(days).catch((err) => console.error("Retention cleanup failed:", err));
|
||||||
|
}, 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const internal = new Elysia({ prefix: "/internal", detail: { hide: true } })
|
||||||
|
.derive(({ headers, set }) => {
|
||||||
|
if (!safeTokenCompare(headers["x-monitor-token"], process.env.MONITOR_TOKEN)) {
|
||||||
|
set.status = 401;
|
||||||
|
return { _unauthorized: true };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
})
|
||||||
|
.onBeforeHandle(({ _unauthorized, set }: any) => {
|
||||||
|
if (_unauthorized) {
|
||||||
|
set.status = 401;
|
||||||
|
return { error: "Unauthorized" };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
.get("/due", async ({ request }) => {
|
||||||
|
const params = new URL(request.url).searchParams;
|
||||||
|
const region = params.get('region') || 'default';
|
||||||
|
const lookaheadMs = Math.min(Number(params.get('lookahead_ms') || 2000), 10000);
|
||||||
|
|
||||||
|
// No JOIN, no state lookup. The check schedule for a monitor is purely
|
||||||
|
// a function of its created_at and interval_s - every interval since
|
||||||
|
// creation is a tick. We pull all enabled monitors that match this
|
||||||
|
// region, compute the next tick in JS, and return the ones whose next
|
||||||
|
// tick falls within the lookahead window.
|
||||||
|
//
|
||||||
|
// The monitor list itself is memoized in apps/api/src/cache/monitor-list.ts
|
||||||
|
// with a 5s TTL - runners poll this endpoint roughly once a second per
|
||||||
|
// region, but the underlying list almost never changes between polls. The
|
||||||
|
// cache is busted from monitor create/patch/delete/toggle so edits show up
|
||||||
|
// immediately.
|
||||||
|
const monitors = await getMonitorsForRegion(region);
|
||||||
|
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const lookaheadEnd = nowMs + lookaheadMs;
|
||||||
|
const due: any[] = [];
|
||||||
|
for (const m of monitors) {
|
||||||
|
const createdMs = new Date((m as any).created_at).getTime();
|
||||||
|
const intervalMs = Number((m as any).interval_s) * 1000;
|
||||||
|
if (intervalMs <= 0) continue;
|
||||||
|
const elapsed = nowMs - createdMs;
|
||||||
|
const ticksPassed = Math.floor(elapsed / intervalMs);
|
||||||
|
const nextTickMs = createdMs + (ticksPassed + 1) * intervalMs;
|
||||||
|
if (nextTickMs <= lookaheadEnd) {
|
||||||
|
due.push({ ...m, scheduled_at_ms: nextTickMs });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return due;
|
||||||
|
})
|
||||||
|
|
||||||
|
.post("/prune", async () => {
|
||||||
|
const days = Number(process.env.PING_RETENTION_DAYS ?? 90);
|
||||||
|
const deleted = await pruneOldPings(days);
|
||||||
|
return { deleted, retention_days: days };
|
||||||
|
});
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
import { requireAuth } from "./auth";
|
||||||
|
import sql from "../db";
|
||||||
|
import { validateMonitorUrl } from "../utils/ssrf";
|
||||||
|
import { getPlanLimits } from "../../../shared/plans";
|
||||||
|
import { invalidateMonitorList } from "../cache/monitor-list";
|
||||||
|
|
||||||
|
const MonitorBody = t.Object({
|
||||||
|
name: t.String({ maxLength: 200, description: "Human-readable name" }),
|
||||||
|
url: t.String({ format: "uri", maxLength: 2048, description: "URL to check" }),
|
||||||
|
method: t.Optional(t.String({ default: "GET", description: "HTTP method: GET, POST, PUT, PATCH, DELETE, HEAD" })),
|
||||||
|
request_headers: t.Optional(t.Any({ description: "Request headers as key-value object" })),
|
||||||
|
request_body: t.Optional(t.Nullable(t.String({ maxLength: 65536, description: "Request body for POST/PUT/PATCH (max 64KB)" }))),
|
||||||
|
timeout_ms: t.Optional(t.Number({ minimum: 1000, maximum: 60000, default: 10000, description: "Request timeout in ms" })),
|
||||||
|
interval_s: t.Optional(t.Number({ minimum: 2, default: 30, description: "Check interval in seconds (minimum 2)" })),
|
||||||
|
max_retries: t.Optional(t.Number({ minimum: 0, maximum: 10, default: 0, description: "Retry a failing check up to N times before declaring DOWN" })),
|
||||||
|
retry_interval_s: t.Optional(t.Number({ minimum: 1, maximum: 600, default: 30, description: "Seconds between retries" })),
|
||||||
|
resend_interval: t.Optional(t.Number({ minimum: 0, maximum: 1000, default: 0, description: "Re-alert every Nth consecutive down beat. 0 = never resend." })),
|
||||||
|
cert_alert_days: t.Optional(t.Number({ minimum: 0, maximum: 365, default: 0, description: "Alert when TLS cert is within N days of expiry. 0 disables (default)." })),
|
||||||
|
max_redirects: t.Optional(t.Number({ minimum: 0, maximum: 4, default: 1, description: "Follow up to N redirects. 0 = don't follow. Default 1." })),
|
||||||
|
query: t.Optional(t.Any({ description: "PingQL query - filter conditions for up/down" })),
|
||||||
|
regions: t.Optional(t.Array(t.String(), { description: "Regions to run checks from. Empty array = all regions." })),
|
||||||
|
tags: t.Optional(t.Array(t.String({ pattern: "^[a-z0-9][a-z0-9-]{0,40}$" }), { description: "Lowercase tag slugs for grouping. Replaces the existing tag set." })),
|
||||||
|
});
|
||||||
|
|
||||||
|
function dedupeTags(tags: string[]): string[] {
|
||||||
|
return Array.from(new Set(tags.map((t) => t.trim()).filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const monitors = new Elysia({ prefix: "/monitors" })
|
||||||
|
.use(requireAuth)
|
||||||
|
|
||||||
|
.get("/", async ({ accountId, query }) => {
|
||||||
|
const tag = (query as any)?.tag;
|
||||||
|
if (tag) {
|
||||||
|
return sql`
|
||||||
|
SELECT * FROM monitors
|
||||||
|
WHERE account_id = ${accountId} AND ${tag} = ANY(tags)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
return sql`SELECT * FROM monitors WHERE account_id = ${accountId} ORDER BY created_at DESC`;
|
||||||
|
}, { detail: { summary: "List monitors (optional ?tag= filter)", tags: ["monitors"] } })
|
||||||
|
|
||||||
|
.post("/", async ({ accountId, plan, body, set }) => {
|
||||||
|
const limits = getPlanLimits(plan);
|
||||||
|
|
||||||
|
const [{ count }] = await sql`SELECT COUNT(*)::int as count FROM monitors WHERE account_id = ${accountId}`;
|
||||||
|
if (count >= limits.maxMonitors) {
|
||||||
|
set.status = 403;
|
||||||
|
return { error: `Plan limit reached: ${limits.maxMonitors} monitors (${plan}). Upgrade to create more.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = body.interval_s ?? limits.minIntervalS;
|
||||||
|
if (interval < limits.minIntervalS) {
|
||||||
|
set.status = 400;
|
||||||
|
return { error: `Minimum interval for ${plan} plan is ${limits.minIntervalS}s` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryGap = body.retry_interval_s ?? limits.minIntervalS;
|
||||||
|
if (retryGap < limits.minIntervalS) {
|
||||||
|
set.status = 400;
|
||||||
|
return { error: `Retry interval for ${plan} plan must be at least ${limits.minIntervalS}s` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const regions = body.regions ?? [];
|
||||||
|
if (regions.length > limits.maxRegions) {
|
||||||
|
set.status = 400;
|
||||||
|
return { error: `Free plan allows ${limits.maxRegions} region per monitor. Upgrade to use multi-region.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ssrfError = await validateMonitorUrl(body.url);
|
||||||
|
if (ssrfError) { set.status = 400; return { error: ssrfError }; }
|
||||||
|
const tags = body.tags ? dedupeTags(body.tags) : [];
|
||||||
|
const [monitor] = await sql`
|
||||||
|
INSERT INTO monitors (account_id, name, url, method, request_headers, request_body, timeout_ms, interval_s, max_retries, retry_interval_s, resend_interval, cert_alert_days, max_redirects, query, regions, tags)
|
||||||
|
VALUES (
|
||||||
|
${accountId}, ${body.name}, ${body.url},
|
||||||
|
${(body.method ?? 'GET').toUpperCase()},
|
||||||
|
${body.request_headers ? sql.json(body.request_headers) : null},
|
||||||
|
${body.request_body ?? null},
|
||||||
|
${body.timeout_ms ?? 10000},
|
||||||
|
${interval},
|
||||||
|
${body.max_retries ?? 0},
|
||||||
|
${retryGap},
|
||||||
|
${body.resend_interval ?? 0},
|
||||||
|
${body.cert_alert_days ?? 0},
|
||||||
|
${body.max_redirects ?? 1},
|
||||||
|
${body.query ? sql.json(body.query) : null},
|
||||||
|
${sql.array(regions)},
|
||||||
|
${sql.array(tags)}
|
||||||
|
)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
invalidateMonitorList();
|
||||||
|
return monitor;
|
||||||
|
}, { body: MonitorBody, detail: { summary: "Create monitor", tags: ["monitors"] } })
|
||||||
|
|
||||||
|
.get("/:id", async ({ accountId, params, set }) => {
|
||||||
|
const [monitor] = await sql`
|
||||||
|
SELECT * FROM monitors WHERE id = ${params.id} AND account_id = ${accountId}
|
||||||
|
`;
|
||||||
|
if (!monitor) { set.status = 404; return { error: "Not found" }; }
|
||||||
|
|
||||||
|
const results = await sql`
|
||||||
|
SELECT * FROM pings WHERE monitor_id = ${params.id}
|
||||||
|
ORDER BY checked_at DESC LIMIT 100
|
||||||
|
`;
|
||||||
|
return { ...monitor, results };
|
||||||
|
}, { detail: { summary: "Get monitor with results", tags: ["monitors"] } })
|
||||||
|
|
||||||
|
.patch("/:id", async ({ accountId, plan, params, body, set }) => {
|
||||||
|
const limits = getPlanLimits(plan);
|
||||||
|
|
||||||
|
if (body.interval_s != null && body.interval_s < limits.minIntervalS) {
|
||||||
|
set.status = 400;
|
||||||
|
return { error: `Minimum interval for ${plan} plan is ${limits.minIntervalS}s` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.retry_interval_s != null && body.retry_interval_s < limits.minIntervalS) {
|
||||||
|
set.status = 400;
|
||||||
|
return { error: `Retry interval for ${plan} plan must be at least ${limits.minIntervalS}s` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.regions && body.regions.length > limits.maxRegions) {
|
||||||
|
set.status = 400;
|
||||||
|
return { error: `Free plan allows ${limits.maxRegions} region per monitor. Upgrade to use multi-region.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.url) {
|
||||||
|
const ssrfError = await validateMonitorUrl(body.url);
|
||||||
|
if (ssrfError) { set.status = 400; return { error: ssrfError }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const [monitor] = await sql`
|
||||||
|
UPDATE monitors SET
|
||||||
|
name = COALESCE(${body.name ?? null}, name),
|
||||||
|
url = COALESCE(${body.url ?? null}, url),
|
||||||
|
method = COALESCE(${body.method ? body.method.toUpperCase() : null}, method),
|
||||||
|
request_headers = COALESCE(${body.request_headers ? sql.json(body.request_headers) : null}, request_headers),
|
||||||
|
request_body = COALESCE(${body.request_body ?? null}, request_body),
|
||||||
|
timeout_ms = COALESCE(${body.timeout_ms ?? null}, timeout_ms),
|
||||||
|
interval_s = COALESCE(${body.interval_s ?? null}, interval_s),
|
||||||
|
max_retries = COALESCE(${body.max_retries ?? null}, max_retries),
|
||||||
|
retry_interval_s = COALESCE(${body.retry_interval_s ?? null}, retry_interval_s),
|
||||||
|
resend_interval = COALESCE(${body.resend_interval ?? null}, resend_interval),
|
||||||
|
cert_alert_days = COALESCE(${body.cert_alert_days ?? null}, cert_alert_days),
|
||||||
|
max_redirects = COALESCE(${body.max_redirects ?? null}, max_redirects),
|
||||||
|
query = COALESCE(${body.query ? sql.json(body.query) : null}, query),
|
||||||
|
regions = COALESCE(${body.regions ? sql.array(body.regions) : null}, regions),
|
||||||
|
tags = COALESCE(${body.tags ? sql.array(dedupeTags(body.tags)) : null}, tags),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = ${params.id} AND account_id = ${accountId}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
if (!monitor) { set.status = 404; return { error: "Not found" }; }
|
||||||
|
invalidateMonitorList();
|
||||||
|
return monitor;
|
||||||
|
}, { body: t.Partial(MonitorBody), detail: { summary: "Update monitor", tags: ["monitors"] } })
|
||||||
|
|
||||||
|
.delete("/:id", async ({ accountId, params, set }) => {
|
||||||
|
const [deleted] = await sql`
|
||||||
|
DELETE FROM monitors WHERE id = ${params.id} AND account_id = ${accountId} RETURNING id
|
||||||
|
`;
|
||||||
|
if (!deleted) { set.status = 404; return { error: "Not found" }; }
|
||||||
|
await sql`
|
||||||
|
UPDATE notification_channels SET monitor_ids = array_remove(monitor_ids, ${params.id})
|
||||||
|
WHERE account_id = ${accountId} AND ${params.id} = ANY(monitor_ids)
|
||||||
|
`;
|
||||||
|
invalidateMonitorList();
|
||||||
|
return { deleted: true };
|
||||||
|
}, { detail: { summary: "Delete monitor", tags: ["monitors"] } })
|
||||||
|
|
||||||
|
.post("/:id/toggle", async ({ accountId, params, set }) => {
|
||||||
|
const [monitor] = await sql`
|
||||||
|
UPDATE monitors SET enabled = NOT enabled
|
||||||
|
WHERE id = ${params.id} AND account_id = ${accountId}
|
||||||
|
RETURNING id, enabled
|
||||||
|
`;
|
||||||
|
if (!monitor) { set.status = 404; return { error: "Not found" }; }
|
||||||
|
invalidateMonitorList();
|
||||||
|
return monitor;
|
||||||
|
}, { detail: { summary: "Toggle monitor on/off", tags: ["monitors"] } })
|
||||||
|
|
||||||
|
.get("/:id/pings", async ({ accountId, params, query, set }) => {
|
||||||
|
const [monitor] = await sql`
|
||||||
|
SELECT id FROM monitors WHERE id = ${params.id} AND account_id = ${accountId}
|
||||||
|
`;
|
||||||
|
if (!monitor) { set.status = 404; return { error: "Not found" }; }
|
||||||
|
|
||||||
|
const limit = Math.min(Number(query.limit) || 100, 1000);
|
||||||
|
const skip = Math.max(0, Math.floor(Number(query.skip) || 0));
|
||||||
|
const filter = query.filter; // "up", "down", "events", or undefined/all
|
||||||
|
const before = query.before ? Number(query.before) * 1000 : null; // unix timestamp (seconds) -> ms
|
||||||
|
|
||||||
|
const cursorClause = before ? sql`AND checked_at < ${new Date(before)}` : sql``;
|
||||||
|
const offsetClause = skip > 0 ? sql`OFFSET ${skip}` : sql``;
|
||||||
|
|
||||||
|
if (filter === "up") {
|
||||||
|
return sql`
|
||||||
|
SELECT * FROM pings WHERE monitor_id = ${params.id} AND up = true ${cursorClause}
|
||||||
|
ORDER BY checked_at DESC LIMIT ${limit} ${offsetClause}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (filter === "down") {
|
||||||
|
return sql`
|
||||||
|
SELECT * FROM pings WHERE monitor_id = ${params.id} AND up = false ${cursorClause}
|
||||||
|
ORDER BY checked_at DESC LIMIT ${limit} ${offsetClause}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (filter === "events") {
|
||||||
|
return sql`
|
||||||
|
SELECT * FROM (
|
||||||
|
SELECT *, LAG(up) OVER (ORDER BY checked_at) AS prev_up
|
||||||
|
FROM pings WHERE monitor_id = ${params.id}
|
||||||
|
) t
|
||||||
|
WHERE prev_up IS NULL OR up != prev_up
|
||||||
|
${before ? sql`AND checked_at < ${new Date(before)}` : sql``}
|
||||||
|
ORDER BY checked_at DESC LIMIT ${limit} ${offsetClause}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sql`
|
||||||
|
SELECT * FROM pings WHERE monitor_id = ${params.id} ${cursorClause}
|
||||||
|
ORDER BY checked_at DESC LIMIT ${limit} ${offsetClause}
|
||||||
|
`;
|
||||||
|
}, { detail: { summary: "Get ping history", tags: ["monitors"] } });
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
import sql from "../db";
|
||||||
|
import { resolveKey } from "./auth";
|
||||||
|
import { extractAuthKey, safeTokenCompare } from "../../../shared/auth";
|
||||||
|
import { dispatchForMonitor, type NotificationEvent } from "../notifications";
|
||||||
|
|
||||||
|
type SSEController = ReadableStreamDefaultController<Uint8Array>;
|
||||||
|
const bus = new Map<string, Set<SSEController>>(); // keyed by accountId
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
|
||||||
|
function publish(accountId: string, data: object) {
|
||||||
|
const subs = bus.get(accountId);
|
||||||
|
if (!subs?.size) return;
|
||||||
|
const msg = enc.encode(`data: ${JSON.stringify(data)}\n\n`);
|
||||||
|
for (const ctrl of subs) {
|
||||||
|
try { ctrl.enqueue(msg); } catch { subs.delete(ctrl); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSSEStream(accountId: string): Response {
|
||||||
|
let ctrl: SSEController;
|
||||||
|
let heartbeat: Timer;
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
start(c) {
|
||||||
|
ctrl = c;
|
||||||
|
if (!bus.has(accountId)) bus.set(accountId, new Set());
|
||||||
|
bus.get(accountId)!.add(ctrl);
|
||||||
|
ctrl.enqueue(enc.encode(": connected\n\n"));
|
||||||
|
heartbeat = setInterval(() => {
|
||||||
|
try { ctrl.enqueue(enc.encode(": heartbeat\n\n")); } catch {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
bus.get(accountId)?.delete(ctrl);
|
||||||
|
if (bus.get(accountId)?.size === 0) bus.delete(accountId);
|
||||||
|
}
|
||||||
|
}, 10_000);
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
bus.get(accountId)?.delete(ctrl);
|
||||||
|
if (bus.get(accountId)?.size === 0) bus.delete(accountId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ingest = new Elysia()
|
||||||
|
|
||||||
|
.post("/internal/ingest", async ({ body, headers, set }) => {
|
||||||
|
const token = headers["x-monitor-token"];
|
||||||
|
if (!safeTokenCompare(token, process.env.MONITOR_TOKEN)) { set.status = 401; return { error: "Unauthorized" }; }
|
||||||
|
|
||||||
|
// Per-region transition state. Region is always populated by current runners;
|
||||||
|
// legacy null values from older pings collapse to "default" so state and
|
||||||
|
// notifications never carry an empty label.
|
||||||
|
const region = body.region && body.region.length > 0 ? body.region : 'default';
|
||||||
|
|
||||||
|
// The monitor lookup and the per-region state lookup are independent -
|
||||||
|
// the state row's primary key doesn't depend on anything from the monitor
|
||||||
|
// row. Fire them in parallel to halve the wall-clock cost on the hottest
|
||||||
|
// path in the system. (Combining them into a JOIN is a wash on a warm
|
||||||
|
// pool: both sides are PK lookups, and a JOIN just adds nested-loop
|
||||||
|
// planner overhead. Promise.all keeps each query's plan trivial.)
|
||||||
|
const [[monitor_check], [stateRow]] = await Promise.all([
|
||||||
|
sql`
|
||||||
|
SELECT id, account_id, name, url, resend_interval, cert_alert_days
|
||||||
|
FROM monitors WHERE id = ${body.monitor_id}
|
||||||
|
`,
|
||||||
|
sql`
|
||||||
|
SELECT last_state, consecutive_down, cert_alert_sent
|
||||||
|
FROM monitor_region_state
|
||||||
|
WHERE monitor_id = ${body.monitor_id} AND region = ${region}
|
||||||
|
`,
|
||||||
|
]);
|
||||||
|
if (!monitor_check) { set.status = 404; return { error: "Monitor not found" }; }
|
||||||
|
|
||||||
|
const meta = body.meta ? { ...body.meta } : {};
|
||||||
|
|
||||||
|
const responseBody: string | null = meta.body_preview ?? null;
|
||||||
|
delete meta.body_preview;
|
||||||
|
|
||||||
|
const checkedAt = body.checked_at ? new Date(body.checked_at) : null;
|
||||||
|
const scheduledAt = body.scheduled_at ? new Date(body.scheduled_at) : null;
|
||||||
|
const jitterMs = body.jitter_ms ?? null;
|
||||||
|
|
||||||
|
const newState = body.up ? 'up' : 'down';
|
||||||
|
const prevState: string | null = stateRow?.last_state ?? null;
|
||||||
|
let consecutiveDown: number = stateRow?.consecutive_down ?? 0;
|
||||||
|
let certAlertSent: boolean = stateRow?.cert_alert_sent ?? false;
|
||||||
|
const resendInterval: number = monitor_check.resend_interval ?? 0;
|
||||||
|
const certAlertDays: number = monitor_check.cert_alert_days ?? 0;
|
||||||
|
let important = false;
|
||||||
|
|
||||||
|
if (prevState !== newState) {
|
||||||
|
important = true;
|
||||||
|
consecutiveDown = body.up ? 0 : 1;
|
||||||
|
} else if (!body.up) {
|
||||||
|
consecutiveDown += 1;
|
||||||
|
if (resendInterval > 0 && consecutiveDown > 1 && (consecutiveDown - 1) % resendInterval === 0) {
|
||||||
|
important = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
consecutiveDown = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cert dedupe flag: flip on threshold cross, clear on renewal. Per-region.
|
||||||
|
let certEvent = false;
|
||||||
|
const days = body.cert_expiry_days;
|
||||||
|
if (days != null && certAlertDays > 0) {
|
||||||
|
if (days <= certAlertDays && !certAlertSent) {
|
||||||
|
certEvent = true;
|
||||||
|
certAlertSent = true;
|
||||||
|
} else if (days > certAlertDays && certAlertSent) {
|
||||||
|
certAlertSent = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
INSERT INTO monitor_region_state (monitor_id, region, last_state, consecutive_down, cert_alert_sent, updated_at)
|
||||||
|
VALUES (${body.monitor_id}, ${region}, ${newState}, ${consecutiveDown}, ${certAlertSent}, now())
|
||||||
|
ON CONFLICT (monitor_id, region) DO UPDATE SET
|
||||||
|
last_state = EXCLUDED.last_state,
|
||||||
|
consecutive_down = EXCLUDED.consecutive_down,
|
||||||
|
cert_alert_sent = EXCLUDED.cert_alert_sent,
|
||||||
|
updated_at = now()
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [ping] = await sql`
|
||||||
|
INSERT INTO pings (monitor_id, checked_at, scheduled_at, jitter_ms, status_code, latency_ms, up, important, error, meta, region, run_id, cert_expiry_days, cert_issuer, response_size, dns_ms, tcp_ms, tls_ms)
|
||||||
|
VALUES (
|
||||||
|
${body.monitor_id},
|
||||||
|
${checkedAt ?? sql`now()`},
|
||||||
|
${scheduledAt},
|
||||||
|
${jitterMs},
|
||||||
|
${body.status_code ?? null},
|
||||||
|
${body.latency_ms ?? null},
|
||||||
|
${body.up},
|
||||||
|
${important},
|
||||||
|
${body.error ?? null},
|
||||||
|
${Object.keys(meta).length > 0 ? sql.json(meta) : null},
|
||||||
|
${region},
|
||||||
|
${body.run_id ?? null},
|
||||||
|
${body.cert_expiry_days ?? null},
|
||||||
|
${body.cert_issuer ?? null},
|
||||||
|
${body.response_size ?? null},
|
||||||
|
${body.dns_ms ?? null},
|
||||||
|
${body.tcp_ms ?? null},
|
||||||
|
${body.tls_ms ?? null}
|
||||||
|
)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (responseBody != null && ping) {
|
||||||
|
await sql`INSERT INTO ping_bodies (ping_id, body) VALUES (${ping.id}, ${responseBody})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(monitor_check.account_id, ping);
|
||||||
|
|
||||||
|
// Fire-and-forget notifications. Failures log only and never block ingest.
|
||||||
|
const monitorCtx = {
|
||||||
|
id: monitor_check.id,
|
||||||
|
name: monitor_check.name,
|
||||||
|
url: monitor_check.url,
|
||||||
|
region,
|
||||||
|
};
|
||||||
|
const pingCtx = {
|
||||||
|
status_code: body.status_code ?? null,
|
||||||
|
latency_ms: body.latency_ms ?? null,
|
||||||
|
error: body.error ?? null,
|
||||||
|
checked_at: ping?.checked_at?.toISOString?.() ?? new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (important) {
|
||||||
|
const event: NotificationEvent = body.up
|
||||||
|
? { kind: "up", monitor: monitorCtx, ping: pingCtx }
|
||||||
|
: { kind: "down", monitor: monitorCtx, ping: pingCtx };
|
||||||
|
// Suppress the synthetic "up" on a brand-new monitor (no prior state).
|
||||||
|
if (!(body.up && prevState === null)) {
|
||||||
|
dispatchForMonitor(monitor_check.id, event).catch((e) => console.warn("[notify] dispatch escaped:", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (certEvent && days != null) {
|
||||||
|
dispatchForMonitor(monitor_check.id, { kind: "cert", monitor: monitorCtx, days }).catch((e) => console.warn("[notify] cert dispatch escaped:", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
monitor_id: t.String(),
|
||||||
|
checked_at: t.Optional(t.Nullable(t.String())),
|
||||||
|
scheduled_at: t.Optional(t.Nullable(t.String())),
|
||||||
|
jitter_ms: t.Optional(t.Nullable(t.Number())),
|
||||||
|
status_code: t.Optional(t.Nullable(t.Number())),
|
||||||
|
latency_ms: t.Optional(t.Nullable(t.Number())),
|
||||||
|
up: t.Boolean(),
|
||||||
|
error: t.Optional(t.Nullable(t.String())),
|
||||||
|
cert_expiry_days: t.Optional(t.Nullable(t.Number())),
|
||||||
|
cert_issuer: t.Optional(t.Nullable(t.String())),
|
||||||
|
response_size: t.Optional(t.Nullable(t.Number())),
|
||||||
|
dns_ms: t.Optional(t.Nullable(t.Number())),
|
||||||
|
tcp_ms: t.Optional(t.Nullable(t.Number())),
|
||||||
|
tls_ms: t.Optional(t.Nullable(t.Number())),
|
||||||
|
meta: t.Optional(t.Any()),
|
||||||
|
region: t.Optional(t.Nullable(t.String())),
|
||||||
|
run_id: t.Optional(t.Nullable(t.String())),
|
||||||
|
}),
|
||||||
|
detail: { hide: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
.get("/pings/:id/body", async ({ params, headers, cookie, set }) => {
|
||||||
|
const key = extractAuthKey(headers, cookie);
|
||||||
|
if (!key) { set.status = 401; return { error: "Unauthorized" }; }
|
||||||
|
|
||||||
|
const resolved = await resolveKey(key);
|
||||||
|
if (!resolved) { set.status = 401; return { error: "Unauthorized" }; }
|
||||||
|
|
||||||
|
const [ping] = await sql`
|
||||||
|
SELECT p.id FROM pings p
|
||||||
|
JOIN monitors m ON m.id = p.monitor_id
|
||||||
|
WHERE p.id = ${params.id} AND m.account_id = ${resolved.accountId}
|
||||||
|
`;
|
||||||
|
if (!ping) { set.status = 404; return { error: "Not found" }; }
|
||||||
|
|
||||||
|
const [row] = await sql`SELECT body FROM ping_bodies WHERE ping_id = ${params.id}`;
|
||||||
|
return { body: row?.body ?? null };
|
||||||
|
}, { detail: { hide: true } })
|
||||||
|
|
||||||
|
.get("/account/stream", async ({ headers, cookie }) => {
|
||||||
|
const key = extractAuthKey(headers, cookie);
|
||||||
|
if (!key) return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
||||||
|
|
||||||
|
const resolved = await resolveKey(key);
|
||||||
|
if (!resolved) return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
||||||
|
|
||||||
|
const limit = Number(process.env.MAX_SSE_PER_ACCOUNT ?? 10);
|
||||||
|
if ((bus.get(resolved.accountId)?.size ?? 0) >= limit) {
|
||||||
|
return new Response(JSON.stringify({ error: "Too many connections" }), { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return makeSSEStream(resolved.accountId);
|
||||||
|
}, { detail: { hide: true } });
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
import { requireAuth } from "./auth";
|
||||||
|
import sql from "../db";
|
||||||
|
|
||||||
|
const Theme = t.Union([t.Literal("auto"), t.Literal("light"), t.Literal("dark")]);
|
||||||
|
const BarFrequency = t.Union([t.Literal("hourly"), t.Literal("daily")]);
|
||||||
|
|
||||||
|
const StatusPageBody = t.Object({
|
||||||
|
slug: t.String({ minLength: 1, maxLength: 80, pattern: "^[a-z0-9][a-z0-9-]*$", description: "URL slug, lowercase + hyphens" }),
|
||||||
|
title: t.String({ minLength: 1, maxLength: 200 }),
|
||||||
|
description: t.Optional(t.Nullable(t.String({ maxLength: 2000 }))),
|
||||||
|
theme: t.Optional(Theme),
|
||||||
|
password: t.Optional(t.Nullable(t.String({ description: "Plain text. Will be hashed at write time. Pass null to clear." }))),
|
||||||
|
index_search: t.Optional(t.Boolean()),
|
||||||
|
show_powered_by: t.Optional(t.Boolean()),
|
||||||
|
show_response_time: t.Optional(t.Boolean()),
|
||||||
|
bar_frequency: t.Optional(BarFrequency),
|
||||||
|
bar_count: t.Optional(t.Number({ minimum: 1, maximum: 180 })),
|
||||||
|
custom_domain: t.Optional(t.Nullable(t.String({ maxLength: 253 }))),
|
||||||
|
custom_css: t.Optional(t.Nullable(t.String({ maxLength: 50_000 }))),
|
||||||
|
footer_text: t.Optional(t.Nullable(t.String({ maxLength: 5000 }))),
|
||||||
|
auto_refresh_s: t.Optional(t.Number({ minimum: 10, maximum: 3600 })),
|
||||||
|
groups: t.Optional(t.Array(t.Object({
|
||||||
|
name: t.String({ minLength: 1, maxLength: 200 }),
|
||||||
|
position: t.Optional(t.Number()),
|
||||||
|
}))),
|
||||||
|
monitors: t.Optional(t.Array(t.Object({
|
||||||
|
monitor_id: t.String(),
|
||||||
|
group_index: t.Optional(t.Nullable(t.Number())),
|
||||||
|
display_name: t.Optional(t.Nullable(t.String({ maxLength: 200 }))),
|
||||||
|
position: t.Optional(t.Number()),
|
||||||
|
}))),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Strip @import and expression() from custom CSS - basic sanity, not a full
|
||||||
|
// parser. The CSS still runs in the visitor's browser; this just blocks the
|
||||||
|
// most common smuggling vectors.
|
||||||
|
function sanitizeCss(css: string | null | undefined): string | null {
|
||||||
|
if (!css) return null;
|
||||||
|
return css
|
||||||
|
.replace(/@import[^;]*;?/gi, "")
|
||||||
|
.replace(/expression\s*\(/gi, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hashPassword(plain: string): Promise<string> {
|
||||||
|
return await Bun.password.hash(plain, { algorithm: "bcrypt", cost: 10 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceGroupsAndMonitors(
|
||||||
|
pageId: string,
|
||||||
|
accountId: string,
|
||||||
|
groups: { name: string; position?: number }[] | undefined,
|
||||||
|
monitorsList: { monitor_id: string; group_index?: number | null; display_name?: string | null; position?: number }[] | undefined,
|
||||||
|
) {
|
||||||
|
if (groups !== undefined) {
|
||||||
|
await sql`DELETE FROM status_page_groups WHERE status_page_id = ${pageId}`;
|
||||||
|
}
|
||||||
|
// Single bulk INSERT instead of one round-trip per group. The RETURNING set
|
||||||
|
// comes back in INSERT order, which equals the array order - that lets us
|
||||||
|
// map index → id without a follow-up SELECT. Mirrors the bulk insert pattern
|
||||||
|
// used by the monitors block right below.
|
||||||
|
const groupIds: string[] = [];
|
||||||
|
if (groups && groups.length > 0) {
|
||||||
|
const rows = groups.map((g, i) => ({
|
||||||
|
status_page_id: pageId,
|
||||||
|
name: g.name,
|
||||||
|
position: g.position ?? i,
|
||||||
|
}));
|
||||||
|
const inserted = await sql<{ id: string }[]>`
|
||||||
|
INSERT INTO status_page_groups ${sql(rows, "status_page_id", "name", "position")}
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
for (const r of inserted) groupIds.push(r.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (monitorsList !== undefined) {
|
||||||
|
await sql`DELETE FROM status_page_monitors WHERE status_page_id = ${pageId}`;
|
||||||
|
}
|
||||||
|
if (monitorsList && monitorsList.length > 0) {
|
||||||
|
// Validate that the monitors all belong to this account.
|
||||||
|
const monitorIds = monitorsList.map((m) => m.monitor_id);
|
||||||
|
const owned = await sql<{ id: string }[]>`
|
||||||
|
SELECT id FROM monitors
|
||||||
|
WHERE account_id = ${accountId} AND id = ANY(${sql.array(monitorIds)}::text[])
|
||||||
|
`;
|
||||||
|
const ownedSet = new Set(owned.map((o) => o.id));
|
||||||
|
const rows: any[] = [];
|
||||||
|
for (let i = 0; i < monitorsList.length; i++) {
|
||||||
|
const m = monitorsList[i]!;
|
||||||
|
if (!ownedSet.has(m.monitor_id)) continue;
|
||||||
|
const groupId = m.group_index != null && groupIds[m.group_index] ? groupIds[m.group_index] : null;
|
||||||
|
rows.push({
|
||||||
|
status_page_id: pageId,
|
||||||
|
monitor_id: m.monitor_id,
|
||||||
|
group_id: groupId,
|
||||||
|
display_name: m.display_name ?? null,
|
||||||
|
position: m.position ?? i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (rows.length > 0) {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO status_page_monitors ${sql(rows, "status_page_id", "monitor_id", "group_id", "display_name", "position")}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const statusPages = new Elysia({ prefix: "/pages" })
|
||||||
|
.use(requireAuth)
|
||||||
|
|
||||||
|
.get("/", async ({ accountId }) => {
|
||||||
|
return sql`
|
||||||
|
SELECT id, slug, title, description, theme, created_at, updated_at
|
||||||
|
FROM status_pages
|
||||||
|
WHERE account_id = ${accountId}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`;
|
||||||
|
}, { detail: { summary: "List status pages", tags: ["status-pages"] } })
|
||||||
|
|
||||||
|
.post("/", async ({ accountId, body, set }) => {
|
||||||
|
const password_hash = body.password ? await hashPassword(body.password) : null;
|
||||||
|
const css = sanitizeCss(body.custom_css);
|
||||||
|
let row;
|
||||||
|
try {
|
||||||
|
[row] = await sql`
|
||||||
|
INSERT INTO status_pages (
|
||||||
|
account_id, slug, title, description, theme, password_hash, index_search,
|
||||||
|
show_powered_by, show_response_time,
|
||||||
|
bar_frequency, bar_count,
|
||||||
|
custom_domain, custom_css, footer_text, auto_refresh_s
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
${accountId}, ${body.slug}, ${body.title}, ${body.description ?? null},
|
||||||
|
${body.theme ?? 'auto'}, ${password_hash}, ${body.index_search ?? true},
|
||||||
|
${body.show_powered_by ?? true}, ${body.show_response_time ?? true},
|
||||||
|
${body.bar_frequency ?? 'daily'}, ${body.bar_count ?? 90},
|
||||||
|
${body.custom_domain || null}, ${css}, ${body.footer_text ?? null},
|
||||||
|
${body.auto_refresh_s ?? 60}
|
||||||
|
)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.code === "23505") { set.status = 409; return { error: "Slug already in use" }; }
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
await replaceGroupsAndMonitors(row.id, accountId, body.groups, body.monitors);
|
||||||
|
return row;
|
||||||
|
}, { body: StatusPageBody, detail: { summary: "Create status page", tags: ["status-pages"] } })
|
||||||
|
|
||||||
|
.get("/:id", async ({ accountId, params, set }) => {
|
||||||
|
const [page] = await sql`
|
||||||
|
SELECT * FROM status_pages WHERE id = ${params.id} AND account_id = ${accountId}
|
||||||
|
`;
|
||||||
|
if (!page) { set.status = 404; return { error: "Not found" }; }
|
||||||
|
const groups = await sql`
|
||||||
|
SELECT id, name, position FROM status_page_groups
|
||||||
|
WHERE status_page_id = ${page.id} ORDER BY position ASC
|
||||||
|
`;
|
||||||
|
const monitors = await sql`
|
||||||
|
SELECT spm.monitor_id, spm.group_id, spm.display_name, spm.position, m.name, m.url
|
||||||
|
FROM status_page_monitors spm
|
||||||
|
JOIN monitors m ON m.id = spm.monitor_id
|
||||||
|
WHERE spm.status_page_id = ${page.id}
|
||||||
|
ORDER BY spm.position ASC
|
||||||
|
`;
|
||||||
|
delete (page as any).password_hash;
|
||||||
|
return { ...page, has_password: !!(page as any).password_hash || false, groups, monitors };
|
||||||
|
}, { detail: { summary: "Get status page", tags: ["status-pages"] } })
|
||||||
|
|
||||||
|
.patch("/:id", async ({ accountId, params, body, set }) => {
|
||||||
|
const password_hash =
|
||||||
|
body.password === undefined ? null
|
||||||
|
: body.password === null ? null
|
||||||
|
: await hashPassword(body.password);
|
||||||
|
const css = body.custom_css === undefined ? null : sanitizeCss(body.custom_css);
|
||||||
|
let row;
|
||||||
|
try {
|
||||||
|
[row] = await sql`
|
||||||
|
UPDATE status_pages SET
|
||||||
|
slug = COALESCE(${body.slug ?? null}, slug),
|
||||||
|
title = COALESCE(${body.title ?? null}, title),
|
||||||
|
description = COALESCE(${body.description ?? null}, description),
|
||||||
|
theme = COALESCE(${body.theme ?? null}, theme),
|
||||||
|
password_hash = CASE WHEN ${body.password === null} THEN NULL
|
||||||
|
WHEN ${body.password !== undefined} THEN ${password_hash}
|
||||||
|
ELSE password_hash END,
|
||||||
|
index_search = COALESCE(${body.index_search ?? null}, index_search),
|
||||||
|
show_powered_by = COALESCE(${body.show_powered_by ?? null}, show_powered_by),
|
||||||
|
show_response_time = COALESCE(${body.show_response_time ?? null}, show_response_time),
|
||||||
|
bar_frequency = COALESCE(${body.bar_frequency ?? null}, bar_frequency),
|
||||||
|
bar_count = COALESCE(${body.bar_count ?? null}, bar_count),
|
||||||
|
custom_domain = CASE WHEN ${body.custom_domain !== undefined} THEN ${body.custom_domain || null} ELSE custom_domain END,
|
||||||
|
custom_css = CASE WHEN ${body.custom_css !== undefined} THEN ${css} ELSE custom_css END,
|
||||||
|
footer_text = COALESCE(${body.footer_text ?? null}, footer_text),
|
||||||
|
auto_refresh_s = COALESCE(${body.auto_refresh_s ?? null}, auto_refresh_s),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = ${params.id} AND account_id = ${accountId}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.code === "23505") { set.status = 409; return { error: "Slug already in use" }; }
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if (!row) { set.status = 404; return { error: "Not found" }; }
|
||||||
|
await replaceGroupsAndMonitors(row.id, accountId, body.groups, body.monitors);
|
||||||
|
return row;
|
||||||
|
}, { body: t.Partial(StatusPageBody), detail: { summary: "Update status page", tags: ["status-pages"] } })
|
||||||
|
|
||||||
|
.delete("/:id", async ({ accountId, params, set }) => {
|
||||||
|
const [row] = await sql`
|
||||||
|
DELETE FROM status_pages WHERE id = ${params.id} AND account_id = ${accountId}
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
if (!row) { set.status = 404; return { error: "Not found" }; }
|
||||||
|
return { deleted: true };
|
||||||
|
}, { detail: { summary: "Delete status page", tags: ["status-pages"] } });
|
||||||
@@ -21,14 +21,21 @@ function isPrivateIP(ip: string): boolean {
|
|||||||
if (second >= 16 && second <= 31) return true;
|
if (second >= 16 && second <= 31) return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// IPv6
|
// IPv6 - normalize: strip zone ID (%eth0) and lowercase
|
||||||
if (ip === "::1" || ip === "::") return true;
|
const ip6 = ip.replace(/%.*$/, "").toLowerCase();
|
||||||
if (ip.toLowerCase().startsWith("fe80")) return true; // fe80::/10
|
if (ip6 === "::1" || ip6 === "::") return true;
|
||||||
if (ip.toLowerCase().startsWith("fd00:ec2::254")) return true; // AWS EC2 metadata
|
if (ip6.startsWith("fe80")) return true; // fe80::/10 link-local
|
||||||
if (ip.toLowerCase() === "::ffff:127.0.0.1") return true;
|
if (ip6.startsWith("fc") || ip6.startsWith("fd")) return true; // fc00::/7 unique-local (ULA)
|
||||||
if (ip.toLowerCase().startsWith("::ffff:")) {
|
if (ip6.startsWith("fd00:ec2::")) return true; // AWS EC2 metadata IPv6
|
||||||
// IPv4-mapped IPv6 — extract the IPv4 part and re-check
|
if (ip6 === "::ffff:127.0.0.1") return true;
|
||||||
const v4 = ip.slice(7);
|
if (ip6.startsWith("::ffff:")) {
|
||||||
|
// IPv4-mapped IPv6 - extract the IPv4 part and re-check
|
||||||
|
const v4 = ip6.slice(7);
|
||||||
|
return isPrivateIP(v4);
|
||||||
|
}
|
||||||
|
// IPv4-compatible IPv6 (deprecated but still reachable)
|
||||||
|
if (ip6.startsWith("::") && ip6.includes(".")) {
|
||||||
|
const v4 = ip6.slice(2);
|
||||||
return isPrivateIP(v4);
|
return isPrivateIP(v4);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,26 +54,22 @@ export async function validateMonitorUrl(url: string): Promise<string | null> {
|
|||||||
return "Invalid URL";
|
return "Invalid URL";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allow http and https
|
|
||||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
return `Blocked scheme: ${parsed.protocol} — only http: and https: are allowed`;
|
return `Blocked scheme: ${parsed.protocol} - only http: and https: are allowed`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hostname = parsed.hostname.toLowerCase();
|
const hostname = parsed.hostname.toLowerCase();
|
||||||
|
|
||||||
// Block localhost by name
|
|
||||||
if (BLOCKED_HOSTNAMES.includes(hostname)) {
|
if (BLOCKED_HOSTNAMES.includes(hostname)) {
|
||||||
return "Blocked hostname: localhost is not allowed";
|
return "Blocked hostname: localhost is not allowed";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block non-public TLDs
|
|
||||||
for (const tld of BLOCKED_TLDS) {
|
for (const tld of BLOCKED_TLDS) {
|
||||||
if (hostname.endsWith(tld)) {
|
if (hostname.endsWith(tld)) {
|
||||||
return `Blocked TLD: ${tld} is not allowed`;
|
return `Blocked TLD: ${tld} is not allowed`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve DNS and check all IPs
|
|
||||||
try {
|
try {
|
||||||
const ips: string[] = [];
|
const ips: string[] = [];
|
||||||
try {
|
try {
|
||||||
@@ -5,16 +5,21 @@ edition = "2021"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
reqwest = { version = "0.12", features = ["json", "native-tls"], default-features = false }
|
||||||
|
hyper = { version = "1", features = ["client", "http1", "http2"] }
|
||||||
|
hyper-util = { version = "0.1", features = ["client-legacy", "tokio", "http1", "http2"] }
|
||||||
|
http-body-util = "0.1"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
scraper = "0.21" # CSS selector / HTML parsing
|
scraper = "0.26"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
rustls = { version = "0.23", features = ["ring"] }
|
rustls = { version = "0.23", features = ["ring"] }
|
||||||
rustls-native-certs = "0.8"
|
rustls-native-certs = "0.8"
|
||||||
webpki-roots = "0.26"
|
webpki-roots = "1"
|
||||||
x509-parser = "0.16"
|
x509-parser = "0.18"
|
||||||
tokio-rustls = "0.26"
|
tokio-rustls = "0.26"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
http = "1"
|
||||||
|
|||||||
@@ -13,10 +13,7 @@ use tracing::{error, info};
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
// Install default rustls crypto provider (required for cert expiry checks)
|
rustls::crypto::ring::default_provider().install_default().ok();
|
||||||
rustls::crypto::ring::default_provider()
|
|
||||||
.install_default()
|
|
||||||
.ok(); // ok() — ignore error if already installed
|
|
||||||
|
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(env::var("RUST_LOG").unwrap_or_else(|_| "info".into()))
|
.with_env_filter(env::var("RUST_LOG").unwrap_or_else(|_| "info".into()))
|
||||||
@@ -26,20 +23,42 @@ async fn main() -> Result<()> {
|
|||||||
.unwrap_or_else(|_| "http://localhost:3000".into());
|
.unwrap_or_else(|_| "http://localhost:3000".into());
|
||||||
let monitor_token = env::var("MONITOR_TOKEN")
|
let monitor_token = env::var("MONITOR_TOKEN")
|
||||||
.expect("MONITOR_TOKEN must be set");
|
.expect("MONITOR_TOKEN must be set");
|
||||||
|
// Region label this runner reports on every ping. "default" means the operator
|
||||||
|
// didn't pin this runner to a named region — it's still a meaningful label so
|
||||||
|
// alerts say where they came from instead of being blank.
|
||||||
|
let region = env::var("REGION").ok().filter(|s| !s.is_empty()).unwrap_or_else(|| "default".to_string());
|
||||||
|
|
||||||
info!("PingQL monitor starting, coordinator: {coordinator_url}");
|
info!("PingQL monitor starting, coordinator: {coordinator_url}, region: {region}");
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.user_agent("PingQL-Monitor/0.1")
|
.user_agent("PingQL-Monitor/0.1")
|
||||||
|
.connect_timeout(std::time::Duration::from_secs(10))
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
let in_flight: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
|
let in_flight: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
|
||||||
|
|
||||||
|
let shutdown = tokio::signal::ctrl_c();
|
||||||
|
tokio::pin!(shutdown);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match runner::fetch_and_run(&client, &coordinator_url, &monitor_token, &in_flight).await {
|
tokio::select! {
|
||||||
Ok(n) => { if n > 0 { info!("Spawned {n} checks"); } },
|
_ = &mut shutdown => {
|
||||||
Err(e) => error!("Check cycle failed: {e}"),
|
info!("Shutdown signal received, waiting for in-flight checks...");
|
||||||
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(35);
|
||||||
|
while !in_flight.lock().await.is_empty() && tokio::time::Instant::now() < deadline {
|
||||||
|
sleep(Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
info!("Shutdown complete");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ = sleep(Duration::from_millis(500)) => {
|
||||||
|
match runner::fetch_and_run(&client, &coordinator_url, &monitor_token, ®ion, &in_flight).await {
|
||||||
|
Ok(n) => { if n > 0 { info!("Spawned {n} checks"); } },
|
||||||
|
Err(e) => error!("Check cycle failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sleep(Duration::from_secs(1)).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-84
@@ -1,38 +1,3 @@
|
|||||||
/// PingQL query evaluation against a check response.
|
|
||||||
///
|
|
||||||
/// Query shape (MongoDB-inspired):
|
|
||||||
///
|
|
||||||
/// Simple equality:
|
|
||||||
/// { "status": 200 }
|
|
||||||
///
|
|
||||||
/// Operators:
|
|
||||||
/// { "status": { "$eq": 200 } }
|
|
||||||
/// { "status": { "$ne": 500 } }
|
|
||||||
/// { "status": { "$gte": 200, "$lt": 300 } }
|
|
||||||
/// { "body": { "$contains": "healthy" } }
|
|
||||||
/// { "body": { "$startsWith": "OK" } }
|
|
||||||
/// { "body": { "$endsWith": "done" } }
|
|
||||||
/// { "body": { "$regex": "ok|healthy" } }
|
|
||||||
/// { "body": { "$exists": true } }
|
|
||||||
/// { "status": { "$in": [200, 201, 204] } }
|
|
||||||
///
|
|
||||||
/// CSS selector (HTML parsing):
|
|
||||||
/// { "$select": "span.status", "$eq": "operational" }
|
|
||||||
///
|
|
||||||
/// JSONPath:
|
|
||||||
/// { "$json": "$.data.status", "$eq": "ok" }
|
|
||||||
///
|
|
||||||
/// Response time:
|
|
||||||
/// { "$responseTime": { "$lt": 500 } }
|
|
||||||
///
|
|
||||||
/// Certificate expiry:
|
|
||||||
/// { "$certExpiry": { "$gt": 30 } }
|
|
||||||
///
|
|
||||||
/// Logical:
|
|
||||||
/// { "$and": [ { "status": 200 }, { "body": { "$contains": "ok" } } ] }
|
|
||||||
/// { "$or": [ { "status": 200 }, { "status": 204 } ] }
|
|
||||||
/// { "$not": { "status": 500 } }
|
|
||||||
|
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use scraper::{Html, Selector};
|
use scraper::{Html, Selector};
|
||||||
@@ -44,24 +9,15 @@ pub struct Response {
|
|||||||
pub headers: std::collections::HashMap<String, String>,
|
pub headers: std::collections::HashMap<String, String>,
|
||||||
pub latency_ms: Option<u64>,
|
pub latency_ms: Option<u64>,
|
||||||
pub cert_expiry_days: Option<i64>,
|
pub cert_expiry_days: Option<i64>,
|
||||||
|
pub cert_issuer: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if `query` matches `response`. No query = always up.
|
|
||||||
pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
|
pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
|
||||||
match query {
|
match query {
|
||||||
|
Value::Array(clauses) => {
|
||||||
|
return Ok(clauses.iter().all(|c| evaluate(c, response).unwrap_or(false)));
|
||||||
|
}
|
||||||
Value::Object(map) => {
|
Value::Object(map) => {
|
||||||
// $consider — "up" (default) or "down": flips result if conditions match
|
|
||||||
if let Some(consider) = map.get("$consider") {
|
|
||||||
let is_down = consider.as_str() == Some("down");
|
|
||||||
let rest: serde_json::Map<String, Value> = map.iter()
|
|
||||||
.filter(|(k, _)| k.as_str() != "$consider")
|
|
||||||
.map(|(k, v)| (k.clone(), v.clone()))
|
|
||||||
.collect();
|
|
||||||
let matches = evaluate(&Value::Object(rest), response).unwrap_or(false);
|
|
||||||
return Ok(if is_down { !matches } else { matches });
|
|
||||||
}
|
|
||||||
|
|
||||||
// $and / $or / $not
|
|
||||||
if let Some(and) = map.get("$and") {
|
if let Some(and) = map.get("$and") {
|
||||||
let Value::Array(clauses) = and else { bail!("$and expects array") };
|
let Value::Array(clauses) = and else { bail!("$and expects array") };
|
||||||
return Ok(clauses.iter().all(|c| evaluate(c, response).unwrap_or(false)));
|
return Ok(clauses.iter().all(|c| evaluate(c, response).unwrap_or(false)));
|
||||||
@@ -74,19 +30,24 @@ pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
|
|||||||
return Ok(!evaluate(not, response)?);
|
return Ok(!evaluate(not, response)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
// $responseTime
|
if let Some(cond) = map.get("size") {
|
||||||
if let Some(cond) = map.get("$responseTime") {
|
let val = Value::Number(serde_json::Number::from(response.body.len()));
|
||||||
|
return eval_condition(cond, &val, response);
|
||||||
|
}
|
||||||
|
if let Some(cond) = map.get("$time") {
|
||||||
let val = Value::Number(serde_json::Number::from(response.latency_ms.unwrap_or(0)));
|
let val = Value::Number(serde_json::Number::from(response.latency_ms.unwrap_or(0)));
|
||||||
return eval_condition(cond, &val, response);
|
return eval_condition(cond, &val, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
// $certExpiry
|
|
||||||
if let Some(cond) = map.get("$certExpiry") {
|
if let Some(cond) = map.get("$certExpiry") {
|
||||||
let val = Value::Number(serde_json::Number::from(response.cert_expiry_days.unwrap_or(0)));
|
let val = Value::Number(serde_json::Number::from(response.cert_expiry_days.unwrap_or(0)));
|
||||||
return eval_condition(cond, &val, response);
|
return eval_condition(cond, &val, response);
|
||||||
}
|
}
|
||||||
|
if let Some(cond) = map.get("$certIssuer") {
|
||||||
// $json — { "$json": { "$.path": { "$op": val } } }
|
let val = response.cert_issuer.as_ref()
|
||||||
|
.map(|s| Value::String(s.clone()))
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
return eval_condition(cond, &val, response);
|
||||||
|
}
|
||||||
if let Some(json_path_map) = map.get("$json") {
|
if let Some(json_path_map) = map.get("$json") {
|
||||||
let path_map = match json_path_map {
|
let path_map = match json_path_map {
|
||||||
Value::Object(m) => m,
|
Value::Object(m) => m,
|
||||||
@@ -99,22 +60,23 @@ pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
|
|||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// $select — { "$select": { "css.selector": { "$op": val } } }
|
if let Some(sel_map) = map.get("$html") {
|
||||||
if let Some(sel_map) = map.get("$select") {
|
|
||||||
let sel_obj = match sel_map {
|
let sel_obj = match sel_map {
|
||||||
Value::Object(m) => m,
|
Value::Object(m) => m,
|
||||||
_ => bail!("$select expects an object {{ selector: condition }}"),
|
_ => bail!("$html expects an object {{ selector: condition }}"),
|
||||||
};
|
};
|
||||||
|
let doc = Html::parse_document(&response.body);
|
||||||
for (selector, condition) in sel_obj {
|
for (selector, condition) in sel_obj {
|
||||||
let selected = css_select(&response.body, selector)
|
let sel = Selector::parse(selector)
|
||||||
.map(Value::String)
|
.map_err(|_| anyhow::anyhow!("Invalid CSS selector: {selector}"))?;
|
||||||
|
let selected = doc.select(&sel).next()
|
||||||
|
.map(|el| Value::String(el.text().collect::<String>().trim().to_string()))
|
||||||
.unwrap_or(Value::Null);
|
.unwrap_or(Value::Null);
|
||||||
if !eval_condition(condition, &selected, response)? { return Ok(false); }
|
if !eval_condition(condition, &selected, response)? { return Ok(false); }
|
||||||
}
|
}
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Field-level checks
|
|
||||||
for (field, condition) in map {
|
for (field, condition) in map {
|
||||||
let field_val = resolve_field(field, response);
|
let field_val = resolve_field(field, response);
|
||||||
if !eval_condition(condition, &field_val, response)? {
|
if !eval_condition(condition, &field_val, response)? {
|
||||||
@@ -150,7 +112,6 @@ fn resolve_json_path(body: &str, path: &str) -> Value {
|
|||||||
if path.is_empty() { return obj; }
|
if path.is_empty() { return obj; }
|
||||||
let mut current = &obj;
|
let mut current = &obj;
|
||||||
for part in path.split('.') {
|
for part in path.split('.') {
|
||||||
// Handle array indexing like "items[0]"
|
|
||||||
if let Some(idx_start) = part.find('[') {
|
if let Some(idx_start) = part.find('[') {
|
||||||
let key = &part[..idx_start];
|
let key = &part[..idx_start];
|
||||||
if !key.is_empty() {
|
if !key.is_empty() {
|
||||||
@@ -180,7 +141,6 @@ fn resolve_json_path(body: &str, path: &str) -> Value {
|
|||||||
|
|
||||||
fn eval_condition(condition: &Value, field_val: &Value, response: &Response) -> Result<bool> {
|
fn eval_condition(condition: &Value, field_val: &Value, response: &Response) -> Result<bool> {
|
||||||
match condition {
|
match condition {
|
||||||
// Shorthand: { "status": 200 }
|
|
||||||
Value::Number(n) => Ok(field_val.as_f64() == n.as_f64()),
|
Value::Number(n) => Ok(field_val.as_f64() == n.as_f64()),
|
||||||
Value::String(s) => Ok(field_val.as_str() == Some(s.as_str())),
|
Value::String(s) => Ok(field_val.as_str() == Some(s.as_str())),
|
||||||
Value::Bool(b) => Ok(field_val.as_bool() == Some(*b)),
|
Value::Bool(b) => Ok(field_val.as_bool() == Some(*b)),
|
||||||
@@ -196,29 +156,33 @@ fn eval_condition(condition: &Value, field_val: &Value, response: &Response) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn eval_op(op: &str, field_val: &Value, val: &Value, response: &Response) -> Result<bool> {
|
fn eval_op(op: &str, field_val: &Value, val: &Value, _response: &Response) -> Result<bool> {
|
||||||
let ok = match op {
|
let ok = match op {
|
||||||
"$eq" => field_val == val,
|
"$eq" => field_val == val,
|
||||||
"$ne" => field_val != val,
|
"$ne" => field_val != val,
|
||||||
"$gt" => cmp_num(field_val, val, |a,b| a > b),
|
"$gt" => cmp_num(field_val, val, |a,b| a > b),
|
||||||
"$gte" => cmp_num(field_val, val, |a,b| a >= b),
|
"$ge" => cmp_num(field_val, val, |a,b| a >= b),
|
||||||
"$lt" => cmp_num(field_val, val, |a,b| a < b),
|
"$lt" => cmp_num(field_val, val, |a,b| a < b),
|
||||||
"$lte" => cmp_num(field_val, val, |a,b| a <= b),
|
"$le" => cmp_num(field_val, val, |a,b| a <= b),
|
||||||
"$contains" => {
|
"$co" => {
|
||||||
let needle = val.as_str().unwrap_or("");
|
let needle = val.as_str().unwrap_or("");
|
||||||
field_val.as_str().map(|s| s.contains(needle)).unwrap_or(false)
|
field_val.as_str().map(|s| s.contains(needle)).unwrap_or(false)
|
||||||
}
|
}
|
||||||
"$startsWith" => {
|
"$sw" => {
|
||||||
let needle = val.as_str().unwrap_or("");
|
let needle = val.as_str().unwrap_or("");
|
||||||
field_val.as_str().map(|s| s.starts_with(needle)).unwrap_or(false)
|
field_val.as_str().map(|s| s.starts_with(needle)).unwrap_or(false)
|
||||||
}
|
}
|
||||||
"$endsWith" => {
|
"$ew" => {
|
||||||
let needle = val.as_str().unwrap_or("");
|
let needle = val.as_str().unwrap_or("");
|
||||||
field_val.as_str().map(|s| s.ends_with(needle)).unwrap_or(false)
|
field_val.as_str().map(|s| s.ends_with(needle)).unwrap_or(false)
|
||||||
}
|
}
|
||||||
"$regex" => {
|
"$re" => {
|
||||||
let pattern = val.as_str().unwrap_or("");
|
let pattern = val.as_str().unwrap_or("");
|
||||||
let re = Regex::new(pattern).unwrap_or_else(|_| Regex::new("$^").unwrap());
|
if pattern.len() > 200 { return Ok(false); }
|
||||||
|
let re = match Regex::new(pattern) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => return Ok(false),
|
||||||
|
};
|
||||||
field_val.as_str().map(|s| re.is_match(s)).unwrap_or(false)
|
field_val.as_str().map(|s| re.is_match(s)).unwrap_or(false)
|
||||||
}
|
}
|
||||||
"$exists" => {
|
"$exists" => {
|
||||||
@@ -226,21 +190,10 @@ fn eval_op(op: &str, field_val: &Value, val: &Value, response: &Response) -> Res
|
|||||||
let exists = !field_val.is_null();
|
let exists = !field_val.is_null();
|
||||||
exists == should_exist
|
exists == should_exist
|
||||||
}
|
}
|
||||||
"$in" => {
|
_ => {
|
||||||
if let Value::Array(arr) = val {
|
tracing::warn!("Unknown query operator: {op}");
|
||||||
arr.contains(field_val)
|
false
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
"$select" => {
|
|
||||||
// Nested: { "body": { "$select": "css", "$eq": "val" } }
|
|
||||||
let sel_str = val.as_str().unwrap_or("");
|
|
||||||
let selected = css_select(&response.body, sel_str);
|
|
||||||
// If no comparison operator follows, just check existence
|
|
||||||
selected.is_some()
|
|
||||||
}
|
|
||||||
_ => true, // unknown op — skip
|
|
||||||
};
|
};
|
||||||
Ok(ok)
|
Ok(ok)
|
||||||
}
|
}
|
||||||
|
|||||||
+428
-140
@@ -6,197 +6,475 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use tokio::io::{AsyncRead, AsyncWrite};
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
use rustls::pki_types::ServerName;
|
||||||
|
use tokio_rustls::TlsConnector;
|
||||||
|
use x509_parser::prelude::*;
|
||||||
|
|
||||||
|
|
||||||
|
static ROOT_STORE: std::sync::LazyLock<Arc<rustls::RootCertStore>> = std::sync::LazyLock::new(|| {
|
||||||
|
let mut store = rustls::RootCertStore::empty();
|
||||||
|
store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||||
|
Arc::new(store)
|
||||||
|
});
|
||||||
|
|
||||||
|
struct CheckResult {
|
||||||
|
status: u16,
|
||||||
|
headers: HashMap<String, String>,
|
||||||
|
body: String,
|
||||||
|
dns_ms: u64,
|
||||||
|
tcp_ms: u64,
|
||||||
|
tls_ms: u64,
|
||||||
|
http_ms: u64,
|
||||||
|
cert_expiry_days: Option<i64>,
|
||||||
|
cert_issuer: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch due monitors from coordinator, run them, post results back.
|
|
||||||
pub async fn fetch_and_run(
|
pub async fn fetch_and_run(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
coordinator_url: &str,
|
coordinator_url: &str,
|
||||||
token: &str,
|
token: &str,
|
||||||
|
region: &str,
|
||||||
in_flight: &Arc<Mutex<HashSet<String>>>,
|
in_flight: &Arc<Mutex<HashSet<String>>>,
|
||||||
) -> Result<usize> {
|
) -> Result<usize> {
|
||||||
// Fetch due monitors
|
let url = format!("{coordinator_url}/internal/due?region={region}&lookahead_ms=2000");
|
||||||
let monitors: Vec<Monitor> = client
|
let resp = client
|
||||||
.get(format!("{coordinator_url}/internal/due"))
|
.get(&url)
|
||||||
.header("x-monitor-token", token)
|
.header("x-monitor-token", token)
|
||||||
.send()
|
.send()
|
||||||
.await?
|
|
||||||
.json()
|
|
||||||
.await?;
|
.await?;
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await?;
|
||||||
|
let monitors: Vec<Monitor> = match serde_json::from_str(&body) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
let preview: String = body.chars().take(500).collect();
|
||||||
|
anyhow::bail!("/internal/due returned status {status}, body could not be parsed: {e}. Body preview: {preview}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let n = monitors.len();
|
let n = monitors.len();
|
||||||
if n == 0 { return Ok(0); }
|
if n == 0 { return Ok(0); }
|
||||||
|
|
||||||
// Spawn all checks — fire and forget, skip if already in-flight
|
let coordinator_url: Arc<str> = Arc::from(coordinator_url);
|
||||||
|
let token: Arc<str> = Arc::from(token);
|
||||||
|
let region: Arc<str> = Arc::from(region);
|
||||||
|
|
||||||
let mut spawned = 0usize;
|
let mut spawned = 0usize;
|
||||||
for monitor in monitors {
|
for monitor in monitors {
|
||||||
{
|
{
|
||||||
let mut set = in_flight.lock().await;
|
let mut set = in_flight.lock().await;
|
||||||
if set.contains(&monitor.id) {
|
if set.contains(&monitor.id) {
|
||||||
debug!("Skipping {} — check already in flight", monitor.id);
|
debug!("Skipping {} - check already in flight", monitor.id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
set.insert(monitor.id.clone());
|
set.insert(monitor.id.clone());
|
||||||
}
|
}
|
||||||
spawned += 1;
|
spawned += 1;
|
||||||
let client = client.clone();
|
let client = client.clone();
|
||||||
let coordinator_url = coordinator_url.to_string();
|
let coordinator_url = Arc::clone(&coordinator_url);
|
||||||
let token = token.to_string();
|
let token = Arc::clone(&token);
|
||||||
|
let region_owned = Arc::clone(®ion);
|
||||||
|
let run_id_owned = {
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let bucket = monitor.scheduled_at_ms.unwrap_or_else(|| chrono::Utc::now().timestamp_millis())
|
||||||
|
/ (monitor.interval_s * 1000);
|
||||||
|
let mut h = DefaultHasher::new();
|
||||||
|
monitor.id.hash(&mut h);
|
||||||
|
bucket.hash(&mut h);
|
||||||
|
format!("{:016x}", h.finish())
|
||||||
|
};
|
||||||
|
let scheduled_at_iso = monitor.scheduled_at_ms.map(|ms| {
|
||||||
|
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms)
|
||||||
|
.map(|dt| dt.to_rfc3339())
|
||||||
|
.unwrap_or_default()
|
||||||
|
});
|
||||||
let in_flight = in_flight.clone();
|
let in_flight = in_flight.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let result = run_check(&client, &monitor).await;
|
if let Some(ms) = monitor.scheduled_at_ms {
|
||||||
// Remove from in-flight before posting so a fast next cycle can pick it up
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
in_flight.lock().await.remove(&monitor.id);
|
if ms > now_ms {
|
||||||
|
let wait = std::time::Duration::from_millis((ms - now_ms) as u64);
|
||||||
|
tokio::time::sleep(wait).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let timeout_ms = monitor.timeout_ms.unwrap_or(30000);
|
||||||
|
let attempts = monitor.max_retries.saturating_add(1) as u64;
|
||||||
|
let retry_gap_s = monitor.retry_interval_s;
|
||||||
|
let deadline = std::time::Duration::from_millis(
|
||||||
|
attempts * (timeout_ms + 5000) + attempts.saturating_sub(1) * retry_gap_s * 1000,
|
||||||
|
);
|
||||||
|
let result = match tokio::time::timeout(deadline, run_check_with_retries(&monitor, scheduled_at_iso.clone(), ®ion_owned, &run_id_owned)).await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => PingResult {
|
||||||
|
monitor_id: monitor.id.clone(),
|
||||||
|
checked_at: Some(chrono::Utc::now().to_rfc3339()),
|
||||||
|
scheduled_at: scheduled_at_iso.clone(),
|
||||||
|
jitter_ms: None,
|
||||||
|
status_code: None,
|
||||||
|
latency_ms: Some(timeout_ms as u64),
|
||||||
|
up: false,
|
||||||
|
error: Some(format!("timed out after {}ms", timeout_ms)),
|
||||||
|
cert_expiry_days: None,
|
||||||
|
cert_issuer: None,
|
||||||
|
response_size: None,
|
||||||
|
dns_ms: None,
|
||||||
|
tcp_ms: None,
|
||||||
|
tls_ms: None,
|
||||||
|
meta: None,
|
||||||
|
region: Some(region_owned.to_string()),
|
||||||
|
run_id: Some(run_id_owned.clone()),
|
||||||
|
},
|
||||||
|
};
|
||||||
if let Err(e) = post_result(&client, &coordinator_url, &token, result).await {
|
if let Err(e) = post_result(&client, &coordinator_url, &token, result).await {
|
||||||
warn!("Failed to post result for {}: {e}", monitor.id);
|
warn!("Failed to post result for {}: {e}", monitor.id);
|
||||||
}
|
}
|
||||||
|
in_flight.lock().await.remove(&monitor.id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(spawned)
|
Ok(spawned)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_check(client: &reqwest::Client, monitor: &Monitor) -> PingResult {
|
async fn run_check_with_retries(monitor: &Monitor, scheduled_at: Option<String>, region: &str, run_id: &str) -> PingResult {
|
||||||
|
let attempts = monitor.max_retries.saturating_add(1);
|
||||||
|
let retry_gap = std::time::Duration::from_secs(monitor.retry_interval_s);
|
||||||
|
let mut last: Option<PingResult> = None;
|
||||||
|
for attempt in 0..attempts {
|
||||||
|
let mut result = run_check(monitor, scheduled_at.clone(), region, run_id).await;
|
||||||
|
if result.up {
|
||||||
|
if attempt > 0 {
|
||||||
|
if let Some(meta) = result.meta.as_mut().and_then(|m| m.as_object_mut()) {
|
||||||
|
meta.insert("retries".into(), json!(attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
last = Some(result);
|
||||||
|
if attempt + 1 < attempts {
|
||||||
|
tokio::time::sleep(retry_gap).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut result = last.expect("at least one attempt");
|
||||||
|
if attempts > 1 {
|
||||||
|
let meta = result.meta.get_or_insert_with(|| json!({}));
|
||||||
|
if let Some(obj) = meta.as_object_mut() {
|
||||||
|
obj.insert("retries".into(), json!(attempts - 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_check(monitor: &Monitor, scheduled_at: Option<String>, region: &str, run_id: &str) -> PingResult {
|
||||||
|
let checked_at = chrono::Utc::now().to_rfc3339();
|
||||||
|
let jitter_ms: Option<i64> = scheduled_at.as_deref().and_then(|s| {
|
||||||
|
let scheduled = chrono::DateTime::parse_from_rfc3339(s).ok()?;
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
Some((now - scheduled.with_timezone(&chrono::Utc)).num_milliseconds())
|
||||||
|
});
|
||||||
|
|
||||||
|
let timeout = std::time::Duration::from_millis(monitor.timeout_ms.unwrap_or(30000));
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
|
|
||||||
// Check cert expiry for HTTPS URLs
|
let result = tokio::time::timeout(timeout, run_check_async(
|
||||||
let cert_expiry_days = if monitor.url.starts_with("https://") {
|
&monitor.url,
|
||||||
check_cert_expiry(&monitor.url).await.ok().flatten()
|
monitor.method.as_deref().unwrap_or("GET"),
|
||||||
} else {
|
monitor.request_headers.as_ref(),
|
||||||
None
|
monitor.request_body.as_deref(),
|
||||||
|
monitor.max_redirects,
|
||||||
|
)).await;
|
||||||
|
|
||||||
|
let cr = match result {
|
||||||
|
Err(_) => {
|
||||||
|
return PingResult {
|
||||||
|
monitor_id: monitor.id.clone(),
|
||||||
|
checked_at: Some(checked_at),
|
||||||
|
scheduled_at,
|
||||||
|
jitter_ms,
|
||||||
|
status_code: None,
|
||||||
|
latency_ms: Some(start.elapsed().as_millis() as u64),
|
||||||
|
up: false,
|
||||||
|
error: Some(format!("timed out after {}ms", timeout.as_millis())),
|
||||||
|
cert_expiry_days: None,
|
||||||
|
cert_issuer: None,
|
||||||
|
response_size: None,
|
||||||
|
dns_ms: None,
|
||||||
|
tcp_ms: None,
|
||||||
|
tls_ms: None,
|
||||||
|
meta: None,
|
||||||
|
region: Some(region.to_string()),
|
||||||
|
run_id: Some(run_id.to_string()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
return PingResult {
|
||||||
|
monitor_id: monitor.id.clone(),
|
||||||
|
checked_at: Some(checked_at),
|
||||||
|
scheduled_at,
|
||||||
|
jitter_ms,
|
||||||
|
status_code: None,
|
||||||
|
latency_ms: Some(start.elapsed().as_millis() as u64),
|
||||||
|
up: false,
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
cert_expiry_days: None,
|
||||||
|
cert_issuer: None,
|
||||||
|
response_size: None,
|
||||||
|
dns_ms: None,
|
||||||
|
tcp_ms: None,
|
||||||
|
tls_ms: None,
|
||||||
|
meta: None,
|
||||||
|
region: Some(region.to_string()),
|
||||||
|
run_id: Some(run_id.to_string()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(Ok(cr)) => cr,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build request with method, headers, body, timeout
|
let latency_ms = cr.http_ms;
|
||||||
let method = monitor.method.as_deref().unwrap_or("GET").to_uppercase();
|
|
||||||
let timeout = std::time::Duration::from_millis(monitor.timeout_ms.unwrap_or(30000));
|
|
||||||
|
|
||||||
let req_method = reqwest::Method::from_bytes(method.as_bytes())
|
let query = &monitor.query;
|
||||||
.unwrap_or(reqwest::Method::GET);
|
let (up, query_error) = if let Some(q) = query {
|
||||||
|
let response = Response {
|
||||||
let mut req = client.request(req_method, &monitor.url).timeout(timeout);
|
status: cr.status,
|
||||||
|
body: cr.body.clone(),
|
||||||
if let Some(headers) = &monitor.request_headers {
|
headers: cr.headers.clone(),
|
||||||
for (k, v) in headers {
|
|
||||||
if let (Ok(name), Ok(value)) = (
|
|
||||||
reqwest::header::HeaderName::from_bytes(k.as_bytes()),
|
|
||||||
reqwest::header::HeaderValue::from_str(v),
|
|
||||||
) {
|
|
||||||
req = req.header(name, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(body) = &monitor.request_body {
|
|
||||||
req = req.body(body.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = req.send().await;
|
|
||||||
let latency_ms = start.elapsed().as_millis() as u64;
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Err(e) => PingResult {
|
|
||||||
monitor_id: monitor.id.clone(),
|
|
||||||
status_code: None,
|
|
||||||
latency_ms: Some(latency_ms),
|
latency_ms: Some(latency_ms),
|
||||||
up: false,
|
cert_expiry_days: cr.cert_expiry_days,
|
||||||
error: Some(e.to_string()),
|
cert_issuer: cr.cert_issuer.clone(),
|
||||||
cert_expiry_days,
|
};
|
||||||
meta: None,
|
match query::evaluate(q, &response) {
|
||||||
},
|
Ok(result) => (result, None),
|
||||||
Ok(resp) => {
|
Err(e) => {
|
||||||
let status = resp.status().as_u16();
|
warn!("Query error for {}: {e}", monitor.id);
|
||||||
let headers: HashMap<String, String> = resp.headers().iter()
|
(cr.status < 400, Some(e.to_string()))
|
||||||
.filter_map(|(k, v)| Some((k.to_string(), v.to_str().ok()?.to_string())))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let body = resp.text().await.unwrap_or_default();
|
|
||||||
|
|
||||||
// Evaluate query if present
|
|
||||||
let (up, query_error) = if let Some(q) = &monitor.query {
|
|
||||||
let response = Response {
|
|
||||||
status,
|
|
||||||
body: body.clone(),
|
|
||||||
headers: headers.clone(),
|
|
||||||
latency_ms: Some(latency_ms),
|
|
||||||
cert_expiry_days,
|
|
||||||
};
|
|
||||||
match query::evaluate(q, &response) {
|
|
||||||
Ok(result) => (result, None),
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Query error for {}: {e}", monitor.id);
|
|
||||||
// Fall back to status-based up/down
|
|
||||||
(status < 400, Some(e.to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Default: up if 2xx/3xx
|
|
||||||
(status < 400, None)
|
|
||||||
};
|
|
||||||
|
|
||||||
let meta = json!({
|
|
||||||
"headers": headers,
|
|
||||||
"body_preview": &body[..body.len().min(500)],
|
|
||||||
});
|
|
||||||
|
|
||||||
debug!("{} → {status} {latency_ms}ms up={up}", monitor.url);
|
|
||||||
|
|
||||||
PingResult {
|
|
||||||
monitor_id: monitor.id.clone(),
|
|
||||||
status_code: Some(status),
|
|
||||||
latency_ms: Some(latency_ms),
|
|
||||||
up,
|
|
||||||
error: query_error,
|
|
||||||
cert_expiry_days,
|
|
||||||
meta: Some(meta),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
((200..300).contains(&(cr.status as u16)), None)
|
||||||
|
};
|
||||||
|
|
||||||
|
let meta = json!({
|
||||||
|
"headers": cr.headers,
|
||||||
|
"body_preview": &cr.body[..cr.body.len().min(25_000)],
|
||||||
|
});
|
||||||
|
|
||||||
|
debug!("{} -> {status} {latency_ms}ms (dns={dns} tcp={tcp} tls={tls} http={http}) up={up}",
|
||||||
|
monitor.url, status=cr.status, dns=cr.dns_ms, tcp=cr.tcp_ms, tls=cr.tls_ms, http=cr.http_ms);
|
||||||
|
|
||||||
|
PingResult {
|
||||||
|
monitor_id: monitor.id.clone(),
|
||||||
|
checked_at: Some(checked_at),
|
||||||
|
scheduled_at,
|
||||||
|
jitter_ms,
|
||||||
|
status_code: Some(cr.status),
|
||||||
|
latency_ms: Some(latency_ms),
|
||||||
|
up,
|
||||||
|
error: query_error,
|
||||||
|
cert_expiry_days: cr.cert_expiry_days,
|
||||||
|
cert_issuer: cr.cert_issuer,
|
||||||
|
response_size: Some(cr.body.len()),
|
||||||
|
dns_ms: Some(cr.dns_ms),
|
||||||
|
tcp_ms: Some(cr.tcp_ms),
|
||||||
|
tls_ms: Some(cr.tls_ms),
|
||||||
|
meta: Some(meta),
|
||||||
|
region: Some(region.to_string()),
|
||||||
|
run_id: Some(run_id.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check SSL certificate expiry for a given HTTPS URL.
|
async fn run_check_async(
|
||||||
/// Returns the number of days until the certificate expires.
|
url: &str,
|
||||||
async fn check_cert_expiry(url: &str) -> Result<Option<i64>> {
|
method: &str,
|
||||||
use rustls::ClientConfig;
|
req_headers: Option<&HashMap<String, String>>,
|
||||||
use rustls::pki_types::ServerName;
|
req_body: Option<&str>,
|
||||||
use tokio::net::TcpStream;
|
max_redirects: u32,
|
||||||
use tokio_rustls::TlsConnector;
|
) -> Result<CheckResult> {
|
||||||
use x509_parser::prelude::*;
|
let mut current_url = url.to_string();
|
||||||
|
let mut redirects_left = max_redirects;
|
||||||
|
let mut final_result: Option<CheckResult> = None;
|
||||||
|
|
||||||
// Parse host and port from URL
|
loop {
|
||||||
let url_parsed = reqwest::Url::parse(url)?;
|
let parsed = reqwest::Url::parse(¤t_url)?;
|
||||||
let host = url_parsed.host_str().unwrap_or("");
|
let is_https = parsed.scheme() == "https";
|
||||||
let port = url_parsed.port().unwrap_or(443);
|
let host = parsed.host_str().ok_or_else(|| anyhow::anyhow!("no host"))?.to_string();
|
||||||
|
let port = parsed.port().unwrap_or(if is_https { 443 } else { 80 });
|
||||||
|
let path = if parsed.query().is_some() {
|
||||||
|
format!("{}?{}", parsed.path(), parsed.query().unwrap())
|
||||||
|
} else {
|
||||||
|
parsed.path().to_string()
|
||||||
|
};
|
||||||
|
|
||||||
// Build a rustls config that captures certificates
|
// 1. DNS resolve
|
||||||
let mut root_store = rustls::RootCertStore::empty();
|
let dns_start = Instant::now();
|
||||||
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
let addr = tokio::net::lookup_host(format!("{host}:{port}"))
|
||||||
|
.await?
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("DNS lookup returned no addresses"))?;
|
||||||
|
let dns_ms = dns_start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
let config = ClientConfig::builder()
|
// 2. TCP connect
|
||||||
.with_root_certificates(root_store)
|
let tcp_start = Instant::now();
|
||||||
.with_no_client_auth();
|
let tcp_stream = TcpStream::connect(addr).await?;
|
||||||
|
let tcp_ms = tcp_start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
let connector = TlsConnector::from(Arc::new(config));
|
// 3. TLS (if HTTPS)
|
||||||
let server_name = ServerName::try_from(host.to_string())?;
|
let mut tls_ms = 0u64;
|
||||||
|
let mut cert_expiry_days: Option<i64> = None;
|
||||||
|
let mut cert_issuer: Option<String> = None;
|
||||||
|
|
||||||
let stream = TcpStream::connect(format!("{host}:{port}")).await?;
|
let (status, resp_headers, body, http_ms) = if is_https {
|
||||||
let tls_stream = connector.connect(server_name, stream).await?;
|
let config = rustls::ClientConfig::builder()
|
||||||
|
.with_root_certificates(ROOT_STORE.clone())
|
||||||
|
.with_no_client_auth();
|
||||||
|
let connector = TlsConnector::from(Arc::new(config));
|
||||||
|
let server_name = ServerName::try_from(host.clone())?;
|
||||||
|
|
||||||
// Get peer certificates
|
let tls_start = Instant::now();
|
||||||
let (_, conn) = tls_stream.get_ref();
|
let tls_stream = connector.connect(server_name, tcp_stream).await?;
|
||||||
let certs = conn.peer_certificates().unwrap_or(&[]);
|
tls_ms = tls_start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
if let Some(cert_der) = certs.first() {
|
// Extract cert info from this connection
|
||||||
let (_, cert) = X509Certificate::from_der(cert_der.as_ref())?;
|
let (_, conn) = tls_stream.get_ref();
|
||||||
let not_after = cert.validity().not_after.timestamp();
|
if let Some(certs) = conn.peer_certificates() {
|
||||||
let now = std::time::SystemTime::now()
|
if let Some(cert_der) = certs.first() {
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
if let Ok((_, cert)) = X509Certificate::from_der(cert_der.as_ref()) {
|
||||||
.unwrap()
|
let not_after = cert.validity().not_after.timestamp();
|
||||||
.as_secs() as i64;
|
let now = std::time::SystemTime::now()
|
||||||
let days = (not_after - now) / 86400;
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
return Ok(Some(days));
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
cert_expiry_days = Some((not_after - now) / 86400);
|
||||||
|
let issuer_str = cert.issuer().to_string();
|
||||||
|
cert_issuer = Some(
|
||||||
|
issuer_str.split(", ")
|
||||||
|
.find(|p| p.starts_with("O="))
|
||||||
|
.map(|p| p[2..].to_string())
|
||||||
|
.unwrap_or(issuer_str)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP over TLS
|
||||||
|
do_http_request(tls_stream, &host, &path, method, req_headers, req_body).await?
|
||||||
|
} else {
|
||||||
|
// HTTP over plain TCP
|
||||||
|
do_http_request(tcp_stream, &host, &path, method, req_headers, req_body).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check for redirect
|
||||||
|
if (301..=308).contains(&status) && redirects_left > 0 {
|
||||||
|
if let Some(location) = resp_headers.get("location") {
|
||||||
|
let next = if location.starts_with("http://") || location.starts_with("https://") {
|
||||||
|
location.clone()
|
||||||
|
} else {
|
||||||
|
// Relative redirect
|
||||||
|
let base = reqwest::Url::parse(¤t_url)?;
|
||||||
|
base.join(location)?.to_string()
|
||||||
|
};
|
||||||
|
current_url = next;
|
||||||
|
redirects_left -= 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final_result = Some(CheckResult {
|
||||||
|
status,
|
||||||
|
headers: resp_headers,
|
||||||
|
body,
|
||||||
|
dns_ms,
|
||||||
|
tcp_ms,
|
||||||
|
tls_ms,
|
||||||
|
http_ms,
|
||||||
|
cert_expiry_days,
|
||||||
|
cert_issuer,
|
||||||
|
});
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(None)
|
final_result.ok_or_else(|| anyhow::anyhow!("no result"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn do_http_request<S>(
|
||||||
|
stream: S,
|
||||||
|
host: &str,
|
||||||
|
path: &str,
|
||||||
|
method: &str,
|
||||||
|
req_headers: Option<&HashMap<String, String>>,
|
||||||
|
req_body: Option<&str>,
|
||||||
|
) -> Result<(u16, HashMap<String, String>, String, u64)>
|
||||||
|
where
|
||||||
|
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
|
||||||
|
{
|
||||||
|
use hyper_util::rt::TokioIo;
|
||||||
|
|
||||||
|
let io = TokioIo::new(stream);
|
||||||
|
let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await?;
|
||||||
|
|
||||||
|
// Spawn the connection driver
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = conn.await {
|
||||||
|
debug!("connection error: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build request
|
||||||
|
let body_bytes = req_body.unwrap_or("").to_string();
|
||||||
|
let has_body = req_body.is_some() && !body_bytes.is_empty();
|
||||||
|
|
||||||
|
let mut builder = http::Request::builder()
|
||||||
|
.method(method)
|
||||||
|
.uri(path)
|
||||||
|
.header("host", host)
|
||||||
|
.header("user-agent", "Mozilla/5.0 (compatible; PingQL/1.0; +https://pingql.com)");
|
||||||
|
|
||||||
|
if let Some(hdrs) = req_headers {
|
||||||
|
let mut has_content_type = false;
|
||||||
|
for (k, v) in hdrs {
|
||||||
|
if k.eq_ignore_ascii_case("content-type") { has_content_type = true; }
|
||||||
|
builder = builder.header(k.as_str(), v.as_str());
|
||||||
|
}
|
||||||
|
if has_body && !has_content_type {
|
||||||
|
builder = builder.header("content-type", "application/json");
|
||||||
|
}
|
||||||
|
} else if has_body {
|
||||||
|
builder = builder.header("content-type", "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
let req = if has_body {
|
||||||
|
builder.body(http_body_util::Full::new(hyper::body::Bytes::from(body_bytes)))?
|
||||||
|
} else {
|
||||||
|
builder.body(http_body_util::Full::new(hyper::body::Bytes::new()))?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Time just the HTTP request/response
|
||||||
|
let http_start = Instant::now();
|
||||||
|
let resp = sender.send_request(req).await?;
|
||||||
|
let status = resp.status().as_u16();
|
||||||
|
|
||||||
|
let mut resp_headers = HashMap::new();
|
||||||
|
for (name, value) in resp.headers() {
|
||||||
|
resp_headers.insert(
|
||||||
|
name.as_str().to_lowercase(),
|
||||||
|
value.to_str().unwrap_or("").to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read body with size limit
|
||||||
|
const MAX_BODY: usize = 2 * 1024 * 1024;
|
||||||
|
let body_bytes = http_body_util::BodyExt::collect(resp.into_body()).await?;
|
||||||
|
let mut body_vec = body_bytes.to_bytes().to_vec();
|
||||||
|
if body_vec.len() > MAX_BODY {
|
||||||
|
body_vec.truncate(MAX_BODY);
|
||||||
|
}
|
||||||
|
let body_str = String::from_utf8_lossy(&body_vec).into_owned();
|
||||||
|
|
||||||
|
let http_ms = http_start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
|
Ok((status, resp_headers, body_str, http_ms))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn post_result(
|
async fn post_result(
|
||||||
@@ -205,11 +483,21 @@ async fn post_result(
|
|||||||
token: &str,
|
token: &str,
|
||||||
result: PingResult,
|
result: PingResult,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
client
|
let resp = tokio::time::timeout(
|
||||||
.post(format!("{coordinator_url}/internal/ingest"))
|
std::time::Duration::from_secs(10),
|
||||||
.header("x-monitor-token", token)
|
client
|
||||||
.json(&result)
|
.post(format!("{coordinator_url}/internal/ingest"))
|
||||||
.send()
|
.header("x-monitor-token", token)
|
||||||
.await?;
|
.json(&result)
|
||||||
|
.send()
|
||||||
|
).await
|
||||||
|
.map_err(|_| anyhow::anyhow!("post_result timed out"))?
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(anyhow::anyhow!("ingest returned {status}: {body}"));
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
|
|
||||||
|
fn default_max_redirects() -> u32 { 1 }
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
// Postgres BIGINT sometimes comes back as a JSON string — handle both
|
||||||
|
fn deserialize_ms<'de, D: Deserializer<'de>>(d: D) -> Result<Option<i64>, D::Error> {
|
||||||
|
use serde::de::Error;
|
||||||
|
let v: Option<serde_json::Value> = Option::deserialize(d)?;
|
||||||
|
match v {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(serde_json::Value::Number(n)) => Ok(n.as_i64()),
|
||||||
|
Some(serde_json::Value::String(s)) => s.parse::<i64>().map(Some).map_err(D::Error::custom),
|
||||||
|
Some(other) => Err(D::Error::custom(format!("unexpected type for scheduled_at_ms: {other}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct Monitor {
|
pub struct Monitor {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -11,16 +25,36 @@ pub struct Monitor {
|
|||||||
pub request_body: Option<String>,
|
pub request_body: Option<String>,
|
||||||
pub timeout_ms: Option<u64>,
|
pub timeout_ms: Option<u64>,
|
||||||
pub interval_s: i64,
|
pub interval_s: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_retries: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub retry_interval_s: u64,
|
||||||
|
#[serde(default = "default_max_redirects")]
|
||||||
|
pub max_redirects: u32,
|
||||||
pub query: Option<Value>,
|
pub query: Option<Value>,
|
||||||
|
pub scheduled_at: Option<String>, // ISO string for backward compat in PingResult
|
||||||
|
#[serde(deserialize_with = "deserialize_ms")]
|
||||||
|
pub scheduled_at_ms: Option<i64>, // unix ms from API for precise scheduling
|
||||||
|
pub regions: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct PingResult {
|
pub struct PingResult {
|
||||||
pub monitor_id: String,
|
pub monitor_id: String,
|
||||||
|
pub checked_at: Option<String>,
|
||||||
|
pub scheduled_at: Option<String>,
|
||||||
|
pub jitter_ms: Option<i64>,
|
||||||
pub status_code: Option<u16>,
|
pub status_code: Option<u16>,
|
||||||
pub latency_ms: Option<u64>,
|
pub latency_ms: Option<u64>,
|
||||||
pub up: bool,
|
pub up: bool,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub cert_expiry_days: Option<i64>,
|
pub cert_expiry_days: Option<i64>,
|
||||||
|
pub cert_issuer: Option<String>,
|
||||||
|
pub response_size: Option<usize>,
|
||||||
|
pub dns_ms: Option<u64>,
|
||||||
|
pub tcp_ms: Option<u64>,
|
||||||
|
pub tls_ms: Option<u64>,
|
||||||
pub meta: Option<Value>,
|
pub meta: Option<Value>,
|
||||||
|
pub region: Option<String>,
|
||||||
|
pub run_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "@pingql/pay",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run --hot src/index.ts",
|
||||||
|
"start": "bun run src/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dashevo/dashcore-lib": "^0.25.0",
|
||||||
|
"@sinclair/typebox": "^0.34.0",
|
||||||
|
"bitcore-lib": "^10.10.7",
|
||||||
|
"bitcore-lib-cash": "^10.10.5",
|
||||||
|
"bitcore-lib-doge": "^10.9.0",
|
||||||
|
"bitcore-lib-ltc": "^10.10.5",
|
||||||
|
"bs58check": "^4.0.0",
|
||||||
|
"elysia": "^1.4.27",
|
||||||
|
"postgres": "^3.4.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "^1.3.10",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// @ts-ignore
|
||||||
|
import bitcore from "bitcore-lib";
|
||||||
|
import bs58check from "bs58check";
|
||||||
|
// @ts-ignore
|
||||||
|
import bitcoreCash from "bitcore-lib-cash";
|
||||||
|
// @ts-ignore
|
||||||
|
import bitcoreLtc from "bitcore-lib-ltc";
|
||||||
|
// @ts-ignore
|
||||||
|
import bitcoreDoge from "bitcore-lib-doge";
|
||||||
|
// @ts-ignore
|
||||||
|
import dashcore from "@dashevo/dashcore-lib";
|
||||||
|
|
||||||
|
function getXpub(coin: string): string {
|
||||||
|
const key = `XPUB_${coin.toUpperCase()}`;
|
||||||
|
const val = process.env[key];
|
||||||
|
if (!val) throw new Error(`Missing env var: ${key}`);
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasXpub(coin: string): boolean {
|
||||||
|
const key = `XPUB_${coin.toUpperCase()}`;
|
||||||
|
return !!process.env[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function derive(coin: string, index: number): string {
|
||||||
|
const xpub = getXpub(coin === "xec" ? "xec" : coin);
|
||||||
|
|
||||||
|
if (coin === "btc") {
|
||||||
|
const hd = new bitcore.HDPublicKey(xpub);
|
||||||
|
return hd.deriveChild(0).deriveChild(index).publicKey.toAddress().toString();
|
||||||
|
}
|
||||||
|
if (coin === "bch") {
|
||||||
|
const hd = new bitcoreCash.HDPublicKey(xpub);
|
||||||
|
return hd.deriveChild(0).deriveChild(index).publicKey.toAddress().toCashAddress();
|
||||||
|
}
|
||||||
|
if (coin === "xec") {
|
||||||
|
const hd = new bitcoreCash.HDPublicKey(xpub);
|
||||||
|
const addr = hd.deriveChild(0).deriveChild(index).publicKey.toAddress().toCashAddress();
|
||||||
|
return addr.replace(/^bitcoincash:/, "ecash:");
|
||||||
|
}
|
||||||
|
if (coin === "ltc") {
|
||||||
|
// Remap zpub/Ltub/Mtub to standard xpub version bytes for bitcore-lib-ltc
|
||||||
|
const decoded = Buffer.from(bs58check.decode(xpub));
|
||||||
|
decoded[0] = 0x04; decoded[1] = 0x88; decoded[2] = 0xb2; decoded[3] = 0x1e;
|
||||||
|
const normalized = bs58check.encode(decoded);
|
||||||
|
const hd = new bitcoreLtc.HDPublicKey(normalized);
|
||||||
|
const pubkey = hd.deriveChild(0).deriveChild(index).publicKey;
|
||||||
|
return new bitcoreLtc.Address(pubkey, bitcoreLtc.Networks.mainnet, bitcoreLtc.Address.PayToWitnessPublicKeyHash).toString();
|
||||||
|
}
|
||||||
|
if (coin === "doge") {
|
||||||
|
const hd = new bitcoreDoge.HDPublicKey(xpub);
|
||||||
|
return hd.deriveChild(0).deriveChild(index).publicKey.toAddress().toString();
|
||||||
|
}
|
||||||
|
if (coin === "dash") {
|
||||||
|
const hd = new dashcore.HDPublicKey(xpub);
|
||||||
|
return hd.deriveChild(0).deriveChild(index).publicKey.toAddress().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unsupported coin: ${coin}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import postgres from "postgres";
|
||||||
|
import { migrate as sharedMigrate } from "../../shared/db";
|
||||||
|
|
||||||
|
const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@localhost:5432/pingql", {
|
||||||
|
max: 10,
|
||||||
|
idle_timeout: 30,
|
||||||
|
connect_timeout: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default sql;
|
||||||
|
|
||||||
|
export async function migrate() {
|
||||||
|
await sharedMigrate(sql);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const API = process.env.FREEDOM_API ?? "https://api.freedom.st";
|
||||||
|
|
||||||
|
export async function getChainInfo(): Promise<Record<string, any>> {
|
||||||
|
const res = await fetch(`${API}/info`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExchangeRates(): Promise<Record<string, number>> {
|
||||||
|
const res = await fetch(`${API}/rates`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAddressInfo(address: string): Promise<any> {
|
||||||
|
const res = await fetch(`${API}/address/${address}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAddressInfoBulk(addresses: string[]): Promise<Record<string, any>> {
|
||||||
|
if (addresses.length === 0) return {};
|
||||||
|
const res = await fetch(`${API}/address`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ addresses }),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchQrBase64(text: string): Promise<string> {
|
||||||
|
const url = `${API}/qr/${encodeURIComponent(text)}`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return url;
|
||||||
|
const buf = await res.arrayBuffer();
|
||||||
|
const type = res.headers.get("content-type") || "image/png";
|
||||||
|
const b64 = Buffer.from(buf).toString("base64");
|
||||||
|
return `data:${type};base64,${b64}`;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAvailableCoins(): Promise<string[]> {
|
||||||
|
const info = await getChainInfo();
|
||||||
|
return Object.entries(info)
|
||||||
|
.filter(([_, v]) => v != null)
|
||||||
|
.map(([k]) => k);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Elysia } from "elysia";
|
||||||
|
import { migrate } from "./db";
|
||||||
|
import { routes } from "./routes";
|
||||||
|
import { checkPayments, expireProPlans } from "./monitor";
|
||||||
|
|
||||||
|
await migrate();
|
||||||
|
|
||||||
|
import { SECURITY_HEADERS } from "../../shared/auth";
|
||||||
|
|
||||||
|
const CORS_ORIGIN = process.env.CORS_ORIGINS?.split(",") ?? ["https://pingql.com"];
|
||||||
|
|
||||||
|
const app = new Elysia()
|
||||||
|
.onAfterHandle(({ set }) => {
|
||||||
|
Object.assign(set.headers, SECURITY_HEADERS);
|
||||||
|
})
|
||||||
|
.onRequest(({ request, set }) => {
|
||||||
|
const origin = request.headers.get("origin") ?? "";
|
||||||
|
if (CORS_ORIGIN.includes(origin)) {
|
||||||
|
set.headers["access-control-allow-origin"] = origin;
|
||||||
|
set.headers["access-control-allow-credentials"] = "true";
|
||||||
|
set.headers["access-control-allow-methods"] = "GET, POST, OPTIONS";
|
||||||
|
set.headers["access-control-allow-headers"] = "Content-Type, Authorization";
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.options("/*", ({ request }) => {
|
||||||
|
const origin = request.headers.get("origin") ?? "";
|
||||||
|
const allowed = CORS_ORIGIN.includes(origin) ? origin : CORS_ORIGIN[0];
|
||||||
|
return new Response(null, {
|
||||||
|
status: 204,
|
||||||
|
headers: {
|
||||||
|
"access-control-allow-origin": allowed,
|
||||||
|
"access-control-allow-credentials": "true",
|
||||||
|
"access-control-allow-methods": "GET, POST, OPTIONS",
|
||||||
|
"access-control-allow-headers": "Content-Type, Authorization",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.get("/", () => ({ name: "PingQL Pay", version: "1" }))
|
||||||
|
.use(routes)
|
||||||
|
.listen(3002);
|
||||||
|
|
||||||
|
console.log(`PingQL Pay running at http://localhost:${app.server?.port}`);
|
||||||
|
|
||||||
|
checkPayments().catch((err) => console.error("Payment check failed:", err));
|
||||||
|
setInterval(() => {
|
||||||
|
checkPayments().catch((err) => console.error("Payment check failed:", err));
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
expireProPlans().catch((err) => console.error("Plan expiry check failed:", err));
|
||||||
|
}, 60 * 60_000);
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { test, expect, describe } from "bun:test";
|
||||||
|
import { computeApplyPlan, computeExpiry, insertIntoStack } from "./monitor";
|
||||||
|
|
||||||
|
const day = 86400000;
|
||||||
|
const now = new Date("2026-03-24T00:00:00Z");
|
||||||
|
function daysFromNow(d: number) { return new Date(now.getTime() + d * day); }
|
||||||
|
|
||||||
|
describe("insertIntoStack", () => {
|
||||||
|
test("inserts into empty stack", () => {
|
||||||
|
expect(insertIntoStack([], { plan: "pro", remaining_days: 20 }))
|
||||||
|
.toEqual([{ plan: "pro", remaining_days: 20 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maintains tier-descending order", () => {
|
||||||
|
const stack = [{ plan: "pro2x", remaining_days: 10 }];
|
||||||
|
expect(insertIntoStack(stack, { plan: "pro4x", remaining_days: 5 }))
|
||||||
|
.toEqual([{ plan: "pro4x", remaining_days: 5 }, { plan: "pro2x", remaining_days: 10 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("inserts lower tier after higher", () => {
|
||||||
|
const stack = [{ plan: "pro2x", remaining_days: 10 }];
|
||||||
|
expect(insertIntoStack(stack, { plan: "pro", remaining_days: 30 }))
|
||||||
|
.toEqual([{ plan: "pro2x", remaining_days: 10 }, { plan: "pro", remaining_days: 30 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("merges same plan by adding days", () => {
|
||||||
|
const stack = [{ plan: "pro", remaining_days: 20 }];
|
||||||
|
expect(insertIntoStack(stack, { plan: "pro", remaining_days: 30 }))
|
||||||
|
.toEqual([{ plan: "pro", remaining_days: 50 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("merges same plan - null wins (lifetime)", () => {
|
||||||
|
const stack = [{ plan: "lifetime", remaining_days: null }];
|
||||||
|
expect(insertIntoStack(stack, { plan: "lifetime", remaining_days: null }))
|
||||||
|
.toEqual([{ plan: "lifetime", remaining_days: null }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("merges timed into permanent → permanent", () => {
|
||||||
|
const stack = [{ plan: "pro", remaining_days: 20 }];
|
||||||
|
// This shouldn't happen in practice but tests null-wins logic
|
||||||
|
expect(insertIntoStack(stack, { plan: "pro", remaining_days: null }))
|
||||||
|
.toEqual([{ plan: "pro", remaining_days: null }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeApplyPlan", () => {
|
||||||
|
test("free user buys Pro 1mo", () => {
|
||||||
|
const acc = { plan: "free", plan_expires_at: null, plan_stack: [] };
|
||||||
|
const result = computeApplyPlan(acc, { plan: "pro", months: 1 }, now);
|
||||||
|
expect(result.plan).toBe("pro");
|
||||||
|
expect(result.plan_expires_at!.getTime()).toBe(now.getTime() + 30 * day);
|
||||||
|
expect(result.plan_stack).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pro user renews same plan", () => {
|
||||||
|
const expires = daysFromNow(20);
|
||||||
|
const acc = { plan: "pro", plan_expires_at: expires, plan_stack: [] };
|
||||||
|
const result = computeApplyPlan(acc, { plan: "pro", months: 1 }, now);
|
||||||
|
expect(result.plan).toBe("pro");
|
||||||
|
expect(result.plan_expires_at!.getTime()).toBe(expires.getTime() + 30 * day);
|
||||||
|
expect(result.plan_stack).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pro (20d left) upgrades to pro2x 1mo", () => {
|
||||||
|
const acc = { plan: "pro", plan_expires_at: daysFromNow(20), plan_stack: [] };
|
||||||
|
const result = computeApplyPlan(acc, { plan: "pro2x", months: 1 }, now);
|
||||||
|
expect(result.plan).toBe("pro2x");
|
||||||
|
expect(result.plan_expires_at!.getTime()).toBe(now.getTime() + 30 * day);
|
||||||
|
expect(result.plan_stack).toEqual([{ plan: "pro", remaining_days: 20 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pro2x (10d left) upgrades to pro4x, existing pro base", () => {
|
||||||
|
const acc = {
|
||||||
|
plan: "pro2x", plan_expires_at: daysFromNow(10),
|
||||||
|
plan_stack: [{ plan: "pro", remaining_days: 20 }]
|
||||||
|
};
|
||||||
|
const result = computeApplyPlan(acc, { plan: "pro4x", months: 1 }, now);
|
||||||
|
expect(result.plan).toBe("pro4x");
|
||||||
|
expect(result.plan_expires_at!.getTime()).toBe(now.getTime() + 30 * day);
|
||||||
|
// pro2x (tier 2) should be before pro (tier 1) in stack
|
||||||
|
expect(result.plan_stack).toEqual([
|
||||||
|
{ plan: "pro2x", remaining_days: 10 },
|
||||||
|
{ plan: "pro", remaining_days: 20 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lifetime user buys pro2x 1mo", () => {
|
||||||
|
const acc = { plan: "lifetime", plan_expires_at: null, plan_stack: [] };
|
||||||
|
const result = computeApplyPlan(acc, { plan: "pro2x", months: 1 }, now);
|
||||||
|
expect(result.plan).toBe("pro2x");
|
||||||
|
expect(result.plan_expires_at!.getTime()).toBe(now.getTime() + 30 * day);
|
||||||
|
expect(result.plan_stack).toEqual([{ plan: "lifetime", remaining_days: null }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pro4x (15d left) buys lifetime - goes to stack", () => {
|
||||||
|
const acc = { plan: "pro4x", plan_expires_at: daysFromNow(15), plan_stack: [] };
|
||||||
|
const result = computeApplyPlan(acc, { plan: "lifetime", months: null }, now);
|
||||||
|
expect(result.plan).toBe("pro4x"); // stays active (higher tier)
|
||||||
|
expect(result.plan_expires_at!.getTime()).toBe(daysFromNow(15).getTime());
|
||||||
|
expect(result.plan_stack).toEqual([{ plan: "lifetime", remaining_days: null }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pro2x (10d left) buys pro 1mo with lifetime in stack", () => {
|
||||||
|
const acc = {
|
||||||
|
plan: "pro2x", plan_expires_at: daysFromNow(10),
|
||||||
|
plan_stack: [{ plan: "lifetime", remaining_days: null }]
|
||||||
|
};
|
||||||
|
const result = computeApplyPlan(acc, { plan: "pro", months: 1 }, now);
|
||||||
|
expect(result.plan).toBe("pro2x"); // stays active
|
||||||
|
expect(result.plan_stack).toEqual([
|
||||||
|
{ plan: "pro", remaining_days: 30 },
|
||||||
|
{ plan: "lifetime", remaining_days: null },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expired pro activates new plan without saving expired", () => {
|
||||||
|
const acc = { plan: "pro", plan_expires_at: daysFromNow(-5), plan_stack: [] };
|
||||||
|
const result = computeApplyPlan(acc, { plan: "pro2x", months: 1 }, now);
|
||||||
|
expect(result.plan).toBe("pro2x");
|
||||||
|
expect(result.plan_expires_at!.getTime()).toBe(now.getTime() + 30 * day);
|
||||||
|
expect(result.plan_stack).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("free with existing stack - preserves stack", () => {
|
||||||
|
const acc = { plan: "free", plan_expires_at: null, plan_stack: [{ plan: "lifetime", remaining_days: null }] };
|
||||||
|
const result = computeApplyPlan(acc, { plan: "pro", months: 1 }, now);
|
||||||
|
expect(result.plan).toBe("pro");
|
||||||
|
expect(result.plan_stack).toEqual([{ plan: "lifetime", remaining_days: null }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buying same lower tier twice merges days", () => {
|
||||||
|
const acc = {
|
||||||
|
plan: "pro2x", plan_expires_at: daysFromNow(20),
|
||||||
|
plan_stack: [{ plan: "pro", remaining_days: 15 }]
|
||||||
|
};
|
||||||
|
const result = computeApplyPlan(acc, { plan: "pro", months: 1 }, now);
|
||||||
|
expect(result.plan).toBe("pro2x"); // stays
|
||||||
|
expect(result.plan_stack).toEqual([{ plan: "pro", remaining_days: 45 }]); // 15 + 30
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeExpiry", () => {
|
||||||
|
test("returns null for non-pro plans", () => {
|
||||||
|
expect(computeExpiry({ plan: "free", plan_expires_at: null, plan_stack: [] }, now)).toBeNull();
|
||||||
|
expect(computeExpiry({ plan: "lifetime", plan_expires_at: null, plan_stack: [] }, now)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null for non-expired pro", () => {
|
||||||
|
const acc = { plan: "pro", plan_expires_at: daysFromNow(10), plan_stack: [] };
|
||||||
|
expect(computeExpiry(acc, now)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expired pro with empty stack → free", () => {
|
||||||
|
const acc = { plan: "pro", plan_expires_at: daysFromNow(-1), plan_stack: [] };
|
||||||
|
const result = computeExpiry(acc, now)!;
|
||||||
|
expect(result.plan).toBe("free");
|
||||||
|
expect(result.plan_expires_at).toBeNull();
|
||||||
|
expect(result.plan_stack).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expired pro with lifetime in stack → promote lifetime", () => {
|
||||||
|
const acc = {
|
||||||
|
plan: "pro2x", plan_expires_at: daysFromNow(-1),
|
||||||
|
plan_stack: [{ plan: "lifetime", remaining_days: null }]
|
||||||
|
};
|
||||||
|
const result = computeExpiry(acc, now)!;
|
||||||
|
expect(result.plan).toBe("lifetime");
|
||||||
|
expect(result.plan_expires_at).toBeNull();
|
||||||
|
expect(result.plan_stack).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expired pro with timed base → promote with computed expiry", () => {
|
||||||
|
const acc = {
|
||||||
|
plan: "pro2x", plan_expires_at: daysFromNow(-1),
|
||||||
|
plan_stack: [{ plan: "pro", remaining_days: 20 }]
|
||||||
|
};
|
||||||
|
const result = computeExpiry(acc, now)!;
|
||||||
|
expect(result.plan).toBe("pro");
|
||||||
|
expect(result.plan_expires_at!.getTime()).toBe(now.getTime() + 20 * day);
|
||||||
|
expect(result.plan_stack).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expired pro with multi-layer stack → promotes top, keeps rest", () => {
|
||||||
|
const acc = {
|
||||||
|
plan: "pro4x", plan_expires_at: daysFromNow(-1),
|
||||||
|
plan_stack: [
|
||||||
|
{ plan: "pro2x", remaining_days: 15 },
|
||||||
|
{ plan: "lifetime", remaining_days: null },
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const result = computeExpiry(acc, now)!;
|
||||||
|
expect(result.plan).toBe("pro2x");
|
||||||
|
expect(result.plan_expires_at!.getTime()).toBe(now.getTime() + 15 * day);
|
||||||
|
expect(result.plan_stack).toEqual([{ plan: "lifetime", remaining_days: null }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expired pro skips zero-day layers", () => {
|
||||||
|
const acc = {
|
||||||
|
plan: "pro4x", plan_expires_at: daysFromNow(-1),
|
||||||
|
plan_stack: [
|
||||||
|
{ plan: "pro2x", remaining_days: 0 },
|
||||||
|
{ plan: "pro", remaining_days: 10 },
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const result = computeExpiry(acc, now)!;
|
||||||
|
expect(result.plan).toBe("pro");
|
||||||
|
expect(result.plan_expires_at!.getTime()).toBe(now.getTime() + 10 * day);
|
||||||
|
expect(result.plan_stack).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expired pro with all zero-day layers → free", () => {
|
||||||
|
const acc = {
|
||||||
|
plan: "pro", plan_expires_at: daysFromNow(-1),
|
||||||
|
plan_stack: [{ plan: "pro2x", remaining_days: 0 }]
|
||||||
|
};
|
||||||
|
const result = computeExpiry(acc, now)!;
|
||||||
|
expect(result.plan).toBe("free");
|
||||||
|
expect(result.plan_stack).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import sql from "./db";
|
||||||
|
import { getAddressInfo, getAddressInfoBulk } from "./freedom";
|
||||||
|
import { COINS, planTier } from "../../shared/plans";
|
||||||
|
import { generateReceipt } from "./receipt";
|
||||||
|
|
||||||
|
const SOCK_API = process.env.FREEDOM_SOCK ?? "https://sock.freedom.st";
|
||||||
|
const THRESHOLD = 0.95;
|
||||||
|
|
||||||
|
let addressMap = new Map<string, any>(); // address → payment
|
||||||
|
let txidToPayment = new Map<string, number>(); // txid → payment.id
|
||||||
|
const seenTxids = new Set<string>();
|
||||||
|
|
||||||
|
async function refreshMaps() {
|
||||||
|
const active = await sql`
|
||||||
|
SELECT * FROM payments
|
||||||
|
WHERE status IN ('pending', 'underpaid', 'confirming')
|
||||||
|
AND expires_at >= now()
|
||||||
|
`;
|
||||||
|
const newAddr = new Map<string, any>();
|
||||||
|
const newTxid = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const p of active) newAddr.set(p.address, p);
|
||||||
|
|
||||||
|
if (active.length > 0) {
|
||||||
|
const ids = active.map((p: any) => p.id);
|
||||||
|
const txs = await sql`SELECT payment_id, txid FROM payment_txs WHERE payment_id = ANY(${ids})`;
|
||||||
|
for (const tx of txs) newTxid.set(tx.txid, tx.payment_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
addressMap = newAddr;
|
||||||
|
txidToPayment = newTxid;
|
||||||
|
for (const t of seenTxids) { if (!newTxid.has(t)) seenTxids.delete(t); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordTx(paymentId: number, address: string, txid: string, amount: number, confirmed: boolean) {
|
||||||
|
// Verify the payment exists and the address matches - prevents stale in-memory state
|
||||||
|
// from attributing transactions to the wrong payment
|
||||||
|
const [payment] = await sql`
|
||||||
|
SELECT id FROM payments WHERE id = ${paymentId} AND address = ${address}
|
||||||
|
AND status IN ('pending', 'underpaid', 'confirming')
|
||||||
|
`;
|
||||||
|
if (!payment) return;
|
||||||
|
|
||||||
|
const [ins] = await sql`
|
||||||
|
INSERT INTO payment_txs (payment_id, txid, amount, confirmed)
|
||||||
|
VALUES (${paymentId}, ${txid}, ${amount.toFixed(8)}, ${confirmed})
|
||||||
|
ON CONFLICT (payment_id, txid) DO UPDATE SET confirmed = EXCLUDED.confirmed OR payment_txs.confirmed
|
||||||
|
RETURNING (xmax = 0) as is_new
|
||||||
|
`;
|
||||||
|
if (ins?.is_new) {
|
||||||
|
const exp = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
await sql`UPDATE payments SET expires_at = ${exp} WHERE id = ${paymentId} AND expires_at < ${exp}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function evaluatePayment(paymentId: number) {
|
||||||
|
const [payment] = await sql`
|
||||||
|
SELECT * FROM payments WHERE id = ${paymentId} AND status IN ('pending', 'underpaid', 'confirming')
|
||||||
|
`;
|
||||||
|
if (!payment) return;
|
||||||
|
|
||||||
|
const coin = COINS[payment.coin];
|
||||||
|
if (!coin) return;
|
||||||
|
|
||||||
|
const [{ total, unconfirmed }] = await sql`
|
||||||
|
SELECT COALESCE(SUM(amount::numeric), 0) as total,
|
||||||
|
COUNT(*) FILTER (WHERE NOT confirmed)::int as unconfirmed
|
||||||
|
FROM payment_txs WHERE payment_id = ${paymentId}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const received = Number(total);
|
||||||
|
const expected = parseFloat(payment.amount_crypto);
|
||||||
|
const threshold = expected * THRESHOLD;
|
||||||
|
|
||||||
|
let newStatus = payment.status;
|
||||||
|
if (received >= threshold && (coin.confirmations === 0 || Number(unconfirmed) === 0)) {
|
||||||
|
newStatus = "paid";
|
||||||
|
} else if (received >= threshold) {
|
||||||
|
newStatus = "confirming";
|
||||||
|
} else if (received > 0) {
|
||||||
|
newStatus = "underpaid";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newStatus === payment.status && received <= parseFloat(payment.amount_received || "0")) return;
|
||||||
|
|
||||||
|
if (newStatus === "paid") {
|
||||||
|
await sql`UPDATE payments SET status = 'paid', amount_received = ${received.toFixed(8)}, paid_at = now() WHERE id = ${paymentId} AND status != 'paid'`;
|
||||||
|
await applyPlan(payment);
|
||||||
|
await generateReceipt(paymentId).catch(e => console.error(`Receipt generation failed for ${paymentId}:`, e));
|
||||||
|
addressMap.delete(payment.address);
|
||||||
|
console.log(`Payment ${paymentId} paid`);
|
||||||
|
} else {
|
||||||
|
await sql`UPDATE payments SET status = ${newStatus}, amount_received = ${received.toFixed(8)} WHERE id = ${paymentId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTxEvent(event: any) {
|
||||||
|
const txHash = event.data?.hash;
|
||||||
|
if (!txHash || seenTxids.has(txHash)) return;
|
||||||
|
seenTxids.add(txHash);
|
||||||
|
|
||||||
|
const outputs = event.data?.to ?? [];
|
||||||
|
for (const out of outputs) {
|
||||||
|
const addr = out?.address;
|
||||||
|
const payment = addr && addressMap.get(addr);
|
||||||
|
if (!payment) continue;
|
||||||
|
|
||||||
|
const txValue = outputs
|
||||||
|
.filter((o: any) => o?.address === addr)
|
||||||
|
.reduce((s: number, o: any) => s + Number(o.amount ?? 0), 0);
|
||||||
|
if (txValue <= 0) continue;
|
||||||
|
|
||||||
|
console.log(`SSE: tx ${txHash} for payment ${payment.id}: +${txValue} ${payment.coin}`);
|
||||||
|
await recordTx(payment.id, payment.address, txHash, txValue, false);
|
||||||
|
txidToPayment.set(txHash, payment.id);
|
||||||
|
await evaluatePayment(payment.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBlockEvent(event: any) {
|
||||||
|
const blockTxs: string[] = (event.data?.transactions ?? []).map((t: any) => typeof t === 'string' ? t : t.hash);
|
||||||
|
const matched = blockTxs.filter(t => txidToPayment.has(t));
|
||||||
|
if (matched.length === 0) return;
|
||||||
|
|
||||||
|
await sql`UPDATE payment_txs SET confirmed = true WHERE txid = ANY(${matched})`;
|
||||||
|
|
||||||
|
const pids = new Set(matched.map(t => txidToPayment.get(t)!));
|
||||||
|
for (const pid of pids) {
|
||||||
|
console.log(`SSE: block confirmed tx for payment ${pid}`);
|
||||||
|
await evaluatePayment(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSSE() {
|
||||||
|
connectSSE(`${SOCK_API}/sse`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectSSE(url: string) {
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok || !res.body) { await sleep(5000); continue; }
|
||||||
|
console.log("SSE: connected");
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buf = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buf.split("\n");
|
||||||
|
buf = lines.pop() ?? "";
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith("data:")) continue;
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(line.slice(line.indexOf("{")));
|
||||||
|
if (event.type === "block") handleBlockEvent(event).catch(() => {});
|
||||||
|
else if (event.type === "tx") handleTxEvent(event).catch(() => {});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("SSE error:", e.message);
|
||||||
|
}
|
||||||
|
console.log("SSE: reconnecting in 3s...");
|
||||||
|
await sleep(3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkPayments() {
|
||||||
|
await sql`
|
||||||
|
UPDATE payments SET status = 'expired'
|
||||||
|
WHERE status IN ('pending', 'underpaid', 'confirming') AND expires_at < now()
|
||||||
|
`;
|
||||||
|
await refreshMaps();
|
||||||
|
|
||||||
|
const payments = await sql`
|
||||||
|
SELECT * FROM payments WHERE status IN ('pending', 'underpaid', 'confirming') AND expires_at >= now()
|
||||||
|
`;
|
||||||
|
if (payments.length === 0) return;
|
||||||
|
|
||||||
|
let bulk: Record<string, any> = {};
|
||||||
|
try { bulk = await getAddressInfoBulk(payments.map((p: any) => p.address)); } catch {}
|
||||||
|
|
||||||
|
for (const payment of payments) {
|
||||||
|
try {
|
||||||
|
let info = bulk[payment.address];
|
||||||
|
if (!info) try { info = await getAddressInfo(payment.address); } catch { continue; }
|
||||||
|
if (!info || info.error) continue;
|
||||||
|
|
||||||
|
const inbound = (info.transactions ?? []).filter((tx: any) => tx.amount > 0);
|
||||||
|
for (const tx of inbound) {
|
||||||
|
if (!tx.hash) continue;
|
||||||
|
await recordTx(payment.id, payment.address, tx.hash, Number(tx.amount ?? 0), tx.block != null);
|
||||||
|
}
|
||||||
|
await evaluatePayment(payment.id);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error checking payment ${payment.id}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function watchPayment(payment: any) {
|
||||||
|
addressMap.set(payment.address, payment);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StackEntry { plan: string; remaining_days: number | null }
|
||||||
|
interface AccountState { plan: string; plan_expires_at: Date | null; plan_stack: StackEntry[] }
|
||||||
|
interface AccountUpdate { plan: string; plan_expires_at: Date | null; plan_stack: StackEntry[] }
|
||||||
|
|
||||||
|
export function insertIntoStack(stack: StackEntry[], entry: StackEntry): StackEntry[] {
|
||||||
|
const result = stack.slice();
|
||||||
|
const existing = result.findIndex(e => e.plan === entry.plan);
|
||||||
|
if (existing !== -1) {
|
||||||
|
const old = result[existing];
|
||||||
|
if (old.remaining_days === null || entry.remaining_days === null) {
|
||||||
|
result[existing] = { plan: entry.plan, remaining_days: null };
|
||||||
|
} else {
|
||||||
|
result[existing] = { plan: entry.plan, remaining_days: old.remaining_days + entry.remaining_days };
|
||||||
|
}
|
||||||
|
result.sort((a, b) => planTier(b.plan) - planTier(a.plan));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const tier = planTier(entry.plan);
|
||||||
|
let i = 0;
|
||||||
|
while (i < result.length && planTier(result[i].plan) >= tier) i++;
|
||||||
|
result.splice(i, 0, entry);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeApplyPlan(
|
||||||
|
acc: AccountState,
|
||||||
|
payment: { plan: string; months: number | null },
|
||||||
|
now: Date
|
||||||
|
): AccountUpdate {
|
||||||
|
const stack = (acc.plan_stack || []).slice();
|
||||||
|
const newPlan = payment.plan;
|
||||||
|
const newDays = payment.plan.startsWith("lifetime") ? null : (payment.months ?? 1) * 30;
|
||||||
|
|
||||||
|
const currentExpiry = acc.plan_expires_at ? new Date(acc.plan_expires_at) : null;
|
||||||
|
const currentIsActive = acc.plan.startsWith("lifetime")
|
||||||
|
|| (acc.plan !== "free" && currentExpiry && currentExpiry > now);
|
||||||
|
|
||||||
|
if (!currentIsActive || acc.plan === "free") {
|
||||||
|
const expiresAt = newDays != null ? new Date(now.getTime() + newDays * 86400000) : null;
|
||||||
|
return { plan: newPlan, plan_expires_at: expiresAt, plan_stack: stack };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPlan === acc.plan && newDays != null && currentExpiry) {
|
||||||
|
const extended = new Date(currentExpiry.getTime() + newDays * 86400000);
|
||||||
|
return { plan: acc.plan, plan_expires_at: extended, plan_stack: stack };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (planTier(newPlan) > planTier(acc.plan)) {
|
||||||
|
const remainingDays = acc.plan.startsWith("lifetime")
|
||||||
|
? null
|
||||||
|
: Math.ceil((currentExpiry!.getTime() - now.getTime()) / 86400000);
|
||||||
|
const newStack = insertIntoStack(stack, { plan: acc.plan, remaining_days: remainingDays });
|
||||||
|
const expiresAt = newDays != null ? new Date(now.getTime() + newDays * 86400000) : null;
|
||||||
|
return { plan: newPlan, plan_expires_at: expiresAt, plan_stack: newStack };
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStack = insertIntoStack(stack, { plan: newPlan, remaining_days: newDays });
|
||||||
|
return { plan: acc.plan, plan_expires_at: currentExpiry, plan_stack: newStack };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeExpiry(acc: AccountState, now: Date): AccountUpdate | null {
|
||||||
|
if (!["pro", "pro2x", "pro4x"].includes(acc.plan)) return null;
|
||||||
|
if (!acc.plan_expires_at || new Date(acc.plan_expires_at) >= now) return null;
|
||||||
|
|
||||||
|
const stack = (acc.plan_stack || []).slice();
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const next = stack.shift()!;
|
||||||
|
if (next.remaining_days === null) {
|
||||||
|
return { plan: next.plan, plan_expires_at: null, plan_stack: stack };
|
||||||
|
}
|
||||||
|
if (next.remaining_days > 0) {
|
||||||
|
const expiresAt = new Date(now.getTime() + next.remaining_days * 86400000);
|
||||||
|
return { plan: next.plan, plan_expires_at: expiresAt, plan_stack: stack };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { plan: "free", plan_expires_at: null, plan_stack: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyPlan(payment: any) {
|
||||||
|
try {
|
||||||
|
await sql.begin(async (tx) => {
|
||||||
|
const [acc] = await tx`
|
||||||
|
SELECT plan, plan_expires_at, plan_stack FROM accounts WHERE id = ${payment.account_id} FOR UPDATE
|
||||||
|
`;
|
||||||
|
const stack = typeof acc.plan_stack === "string" ? JSON.parse(acc.plan_stack) : (acc.plan_stack || []);
|
||||||
|
const update = computeApplyPlan(
|
||||||
|
{ plan: acc.plan, plan_expires_at: acc.plan_expires_at, plan_stack: stack },
|
||||||
|
{ plan: payment.plan, months: payment.months },
|
||||||
|
new Date()
|
||||||
|
);
|
||||||
|
await tx`
|
||||||
|
UPDATE accounts SET plan = ${update.plan},
|
||||||
|
plan_expires_at = ${update.plan_expires_at?.toISOString() ?? null},
|
||||||
|
plan_stack = ${sql.json(update.plan_stack)}
|
||||||
|
WHERE id = ${payment.account_id}
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
console.log(`Payment ${payment.id} activated: ${payment.plan} for account ${payment.account_id}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`applyPlan failed for payment ${payment.id}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expireProPlans() {
|
||||||
|
const expired = await sql`
|
||||||
|
SELECT id, plan, plan_expires_at, plan_stack FROM accounts
|
||||||
|
WHERE plan IN ('pro', 'pro2x', 'pro4x') AND plan_expires_at IS NOT NULL AND plan_expires_at < now()
|
||||||
|
`;
|
||||||
|
if (expired.length === 0) return;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
for (const acc of expired) {
|
||||||
|
const stack = typeof acc.plan_stack === "string" ? JSON.parse(acc.plan_stack) : (acc.plan_stack || []);
|
||||||
|
const update = computeExpiry(
|
||||||
|
{ plan: acc.plan, plan_expires_at: acc.plan_expires_at, plan_stack: stack },
|
||||||
|
now
|
||||||
|
);
|
||||||
|
if (!update) continue;
|
||||||
|
await sql`
|
||||||
|
UPDATE accounts SET plan = ${update.plan},
|
||||||
|
plan_expires_at = ${update.plan_expires_at?.toISOString() ?? null},
|
||||||
|
plan_stack = ${sql.json(update.plan_stack)}
|
||||||
|
WHERE id = ${acc.id}
|
||||||
|
`;
|
||||||
|
console.log(`Account ${acc.id}: ${acc.plan} expired → ${update.plan}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)); }
|
||||||
|
|
||||||
|
startSSE();
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import sql from "./db";
|
||||||
|
import { COINS } from "../../shared/plans";
|
||||||
|
import { encodeId } from "../../shared/feistel";
|
||||||
|
|
||||||
|
export async function generateReceipt(paymentId: number): Promise<string> {
|
||||||
|
const [payment] = await sql`SELECT * FROM payments WHERE id = ${paymentId}`;
|
||||||
|
if (!payment) throw new Error("Payment not found");
|
||||||
|
|
||||||
|
if (payment.receipt_html) return payment.receipt_html;
|
||||||
|
|
||||||
|
const coinInfo = COINS[payment.coin];
|
||||||
|
const txs = await sql`
|
||||||
|
SELECT txid, amount, confirmed, detected_at
|
||||||
|
FROM payment_txs WHERE payment_id = ${paymentId}
|
||||||
|
ORDER BY detected_at ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const paidDate = payment.paid_at
|
||||||
|
? new Date(payment.paid_at).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })
|
||||||
|
: "-";
|
||||||
|
const createdDate = new Date(payment.created_at).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
|
||||||
|
const planNames: Record<string, string> = { pro: "Pro", pro2x: "Pro 2x", pro4x: "Pro 4x", lifetime: "Lifetime", lifetime2x: "Lifetime 2x", lifetime4x: "Lifetime 4x" };
|
||||||
|
const planName = planNames[payment.plan] || payment.plan;
|
||||||
|
const planLabel = payment.plan.startsWith("lifetime")
|
||||||
|
? planName
|
||||||
|
: `${planName} × ${payment.months} month${payment.months > 1 ? "s" : ""}`;
|
||||||
|
|
||||||
|
const txRows = txs.map((tx: any) => {
|
||||||
|
const date = new Date(tx.detected_at).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
|
||||||
|
});
|
||||||
|
return `<tr>
|
||||||
|
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;font-family:monospace;font-size:12px;word-break:break-all">${tx.txid}</td>
|
||||||
|
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;text-align:right">${tx.amount} ${coinInfo?.ticker || payment.coin.toUpperCase()}</td>
|
||||||
|
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;text-align:right">${date}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PingQL Receipt #${encodeId(payment.id)}</title>
|
||||||
|
<link rel="icon" href="https://pingql.com/favicon.svg" type="image/svg+xml">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #1a1a1a; max-width: 640px; margin: 0 auto; padding: 40px 24px; }
|
||||||
|
.print-btn { background: #2563eb; color: white; border: none; padding: 10px 24px; border-radius: 6px; font-size: 14px; cursor: pointer; margin-bottom: 32px; display: inline-block; }
|
||||||
|
.print-btn:hover { background: #3b82f6; }
|
||||||
|
@media print { .no-print { display: none !important; } body { padding: 0; } }
|
||||||
|
.header { border-bottom: 2px solid #1a1a1a; padding-bottom: 16px; margin-bottom: 24px; }
|
||||||
|
.logo { font-size: 24px; font-weight: 700; }
|
||||||
|
.logo span { color: #2563eb; }
|
||||||
|
.meta { color: #6b7280; font-size: 13px; margin-top: 4px; }
|
||||||
|
.section { margin-bottom: 24px; }
|
||||||
|
.section-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; margin-bottom: 8px; }
|
||||||
|
.row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; }
|
||||||
|
.row .label { color: #6b7280; }
|
||||||
|
.row .value { font-weight: 500; }
|
||||||
|
.total { border-top: 2px solid #1a1a1a; padding-top: 12px; margin-top: 12px; font-size: 16px; font-weight: 600; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
thead th { text-align: left; padding: 8px 12px; border-bottom: 2px solid #e5e7eb; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; }
|
||||||
|
.address { font-family: monospace; font-size: 12px; color: #6b7280; word-break: break-all; }
|
||||||
|
.footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid #e5e7eb; font-size: 12px; color: #9ca3af; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="no-print">
|
||||||
|
<button class="print-btn" onclick="window.print()">Print Receipt</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">Ping<span>QL</span></div>
|
||||||
|
<div class="meta">Receipt #${encodeId(payment.id)} · Issued ${paidDate}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Plan</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">Product</span>
|
||||||
|
<span class="value">PingQL ${planLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">Invoice Date</span>
|
||||||
|
<span class="value">${createdDate}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">Payment Date</span>
|
||||||
|
<span class="value">${paidDate}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Payment</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">Currency</span>
|
||||||
|
<span class="value">${coinInfo?.label || payment.coin} (${coinInfo?.ticker || payment.coin.toUpperCase()})</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">Amount Paid</span>
|
||||||
|
<span class="value">${payment.amount_received || payment.amount_crypto} ${coinInfo?.ticker || payment.coin.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">Payment Address</span>
|
||||||
|
<span class="value address">${payment.address}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row total">
|
||||||
|
<span>Total (USD)</span>
|
||||||
|
<span>$${Number(payment.amount_usd).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${txs.length > 0 ? `<div class="section">
|
||||||
|
<div class="section-title">Transactions</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Transaction ID</th><th style="text-align:right">Amount</th><th style="text-align:right">Date</th></tr></thead>
|
||||||
|
<tbody>${txRows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>` : ""}
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
PingQL · pingql.com<br>
|
||||||
|
This receipt was generated on ${paidDate} and cannot be modified.
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
const minified = html.replace(/\n\s*/g, "").replace(/>\s+</g, "><").replace(/\s{2,}/g, " ");
|
||||||
|
await sql`UPDATE payments SET receipt_html = ${minified} WHERE id = ${paymentId}`;
|
||||||
|
return minified;
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
import sql from "./db";
|
||||||
|
import { derive, hasXpub } from "./address";
|
||||||
|
import { getExchangeRates, getAvailableCoins, fetchQrBase64 } from "./freedom";
|
||||||
|
import { PLAN_PRICING as PLANS, COINS, planTier } from "../../shared/plans";
|
||||||
|
import { generateReceipt } from "./receipt";
|
||||||
|
import { watchPayment } from "./monitor";
|
||||||
|
import { resolveKey as sharedResolveKey, extractAuthKey } from "../../shared/auth";
|
||||||
|
import { encodeId, decodeId } from "../../shared/feistel";
|
||||||
|
|
||||||
|
function requireAuth(app: Elysia) {
|
||||||
|
return app
|
||||||
|
.derive(async ({ headers, cookie, set }) => {
|
||||||
|
const key = extractAuthKey(headers, cookie);
|
||||||
|
if (!key) {
|
||||||
|
set.status = 401;
|
||||||
|
return { accountId: null as string | null, keyId: null as string | null, plan: "free" };
|
||||||
|
}
|
||||||
|
const resolved = await sharedResolveKey(sql, key, { trackUsage: false });
|
||||||
|
if (resolved) return resolved;
|
||||||
|
set.status = 401;
|
||||||
|
return { accountId: null as string | null, keyId: null as string | null, plan: "free" };
|
||||||
|
})
|
||||||
|
.onBeforeHandle(({ accountId, set }) => {
|
||||||
|
if (!accountId) {
|
||||||
|
set.status = 401;
|
||||||
|
return { error: "Unauthorized" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const routes = new Elysia()
|
||||||
|
|
||||||
|
.get("/coins", async () => {
|
||||||
|
const [available, rates] = await Promise.all([getAvailableCoins(), getExchangeRates()]);
|
||||||
|
const coins = Object.entries(COINS)
|
||||||
|
.filter(([k]) => available.includes(k) && hasXpub(k))
|
||||||
|
.map(([k, v]) => ({ id: k, ...v, rate: rates[k] }));
|
||||||
|
return { coins, plans: PLANS };
|
||||||
|
})
|
||||||
|
|
||||||
|
.use(requireAuth)
|
||||||
|
|
||||||
|
.post("/checkout", async ({ accountId, keyId, body, set }) => {
|
||||||
|
if (keyId) { set.status = 403; return { error: "Sub-keys cannot create checkouts" }; }
|
||||||
|
|
||||||
|
const { plan, months, coin } = body;
|
||||||
|
|
||||||
|
if (plan.startsWith("lifetime")) {
|
||||||
|
const [acc] = await sql`SELECT plan, plan_stack FROM accounts WHERE id = ${accountId}`;
|
||||||
|
const stack = typeof acc.plan_stack === "string" ? JSON.parse(acc.plan_stack) : (acc.plan_stack || []);
|
||||||
|
const buyingTier = planTier(plan);
|
||||||
|
const ownedLifetimes = [acc.plan, ...stack.map((s: any) => s.plan)].filter((p: string) => p.startsWith("lifetime"));
|
||||||
|
const maxOwnedTier = Math.max(0, ...ownedLifetimes.map((p: string) => planTier(p)));
|
||||||
|
if (maxOwnedTier >= buyingTier) { set.status = 400; return { error: "You already have this lifetime tier or higher" }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!COINS[coin]) { set.status = 400; return { error: `Unknown coin: ${coin}` }; }
|
||||||
|
if (!hasXpub(coin)) { set.status = 400; return { error: `${coin} is not configured` }; }
|
||||||
|
const available = await getAvailableCoins();
|
||||||
|
if (!available.includes(coin)) { set.status = 400; return { error: `${coin} is temporarily unavailable` }; }
|
||||||
|
|
||||||
|
const planDef = PLANS[plan];
|
||||||
|
if (!planDef) { set.status = 400; return { error: `Unknown plan: ${plan}` }; }
|
||||||
|
let amountUsd = planDef.priceUsd ?? (planDef.monthlyUsd! * (months ?? 1));
|
||||||
|
|
||||||
|
if (plan.startsWith("lifetime") && planDef.priceUsd) {
|
||||||
|
const [{ total }] = await sql`SELECT COALESCE(SUM(amount_usd), 0)::numeric as total FROM payments WHERE account_id = ${accountId} AND status = 'paid'`;
|
||||||
|
const credit = Math.min(Number(total), planDef.priceUsd * 0.90);
|
||||||
|
amountUsd = Math.max(amountUsd - credit, 1);
|
||||||
|
}
|
||||||
|
const rates = await getExchangeRates();
|
||||||
|
const rate = rates[coin];
|
||||||
|
if (!rate) { set.status = 500; return { error: "Could not fetch exchange rate" }; }
|
||||||
|
|
||||||
|
const amountCrypto = (amountUsd / rate).toFixed(8);
|
||||||
|
|
||||||
|
const [{ next_index }] = await sql`
|
||||||
|
SELECT COALESCE(MAX(derivation_index), -1) + 1 as next_index FROM payments WHERE coin = ${coin}
|
||||||
|
`;
|
||||||
|
|
||||||
|
let address: string;
|
||||||
|
try {
|
||||||
|
address = derive(coin, next_index);
|
||||||
|
} catch (e: any) {
|
||||||
|
set.status = 500;
|
||||||
|
return { error: `Address derivation failed: ${e.message}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
||||||
|
|
||||||
|
const [payment] = await sql`
|
||||||
|
INSERT INTO payments (account_id, plan, months, amount_usd, coin, amount_crypto, address, derivation_index, expires_at)
|
||||||
|
VALUES (${accountId}, ${plan}, ${plan.startsWith("lifetime") ? null : months ?? 1}, ${amountUsd}, ${coin}, ${amountCrypto}, ${address}, ${next_index}, ${expiresAt.toISOString()})
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
watchPayment(payment);
|
||||||
|
|
||||||
|
const coinInfo = COINS[coin];
|
||||||
|
const uri = `${coinInfo.uri}:${address.replace(/^.*:/, '')}?amount=${amountCrypto}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: encodeId(payment.id),
|
||||||
|
plan: payment.plan,
|
||||||
|
months: payment.months,
|
||||||
|
amount_usd: Number(payment.amount_usd),
|
||||||
|
coin: payment.coin,
|
||||||
|
amount_crypto: payment.amount_crypto,
|
||||||
|
amount_received: "0",
|
||||||
|
amount_remaining: payment.amount_crypto,
|
||||||
|
address: payment.address,
|
||||||
|
status: payment.status,
|
||||||
|
expires_at: payment.expires_at,
|
||||||
|
txs: [],
|
||||||
|
qr_url: await fetchQrBase64(uri),
|
||||||
|
pay_uri: uri,
|
||||||
|
coin_label: coinInfo.label,
|
||||||
|
coin_ticker: coinInfo.ticker,
|
||||||
|
};
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
plan: t.String({ description: "'pro' or 'lifetime'" }),
|
||||||
|
months: t.Optional(t.Number({ minimum: 1, maximum: 12 })),
|
||||||
|
coin: t.String({ description: "Coin ticker: btc, bch, ltc, doge, dash, xec" }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
.get("/checkout/:id", async ({ accountId, params, set }) => {
|
||||||
|
const dbId = decodeId(params.id);
|
||||||
|
const [payment] = await sql`
|
||||||
|
SELECT * FROM payments WHERE id = ${dbId} AND account_id = ${accountId}
|
||||||
|
`;
|
||||||
|
if (!payment) { set.status = 404; return { error: "Payment not found" }; }
|
||||||
|
|
||||||
|
const coinInfo = COINS[payment.coin];
|
||||||
|
const amountCrypto = parseFloat(payment.amount_crypto);
|
||||||
|
const amountReceived = parseFloat(payment.amount_received || "0");
|
||||||
|
const amountRemaining = Math.max(0, amountCrypto - amountReceived);
|
||||||
|
|
||||||
|
const qrAmount = amountRemaining > 0 && amountRemaining < amountCrypto
|
||||||
|
? amountRemaining.toFixed(8) : payment.amount_crypto;
|
||||||
|
const addrPart = payment.address.includes(':') ? payment.address.split(':')[1] : payment.address;
|
||||||
|
const uri = `${coinInfo.uri}:${addrPart}?amount=${qrAmount}`;
|
||||||
|
|
||||||
|
const txs = await sql`
|
||||||
|
SELECT txid, amount, confirmed, detected_at
|
||||||
|
FROM payment_txs WHERE payment_id = ${payment.id}
|
||||||
|
ORDER BY detected_at ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: encodeId(payment.id),
|
||||||
|
plan: payment.plan,
|
||||||
|
months: payment.months,
|
||||||
|
amount_usd: Number(payment.amount_usd),
|
||||||
|
coin: payment.coin,
|
||||||
|
amount_crypto: payment.amount_crypto,
|
||||||
|
amount_received: payment.amount_received || "0",
|
||||||
|
amount_remaining: amountRemaining.toFixed(8),
|
||||||
|
address: payment.address,
|
||||||
|
status: payment.status,
|
||||||
|
created_at: payment.created_at,
|
||||||
|
expires_at: payment.expires_at,
|
||||||
|
paid_at: payment.paid_at,
|
||||||
|
txs: txs.map((t: any) => ({ txid: t.txid, amount: t.amount, confirmed: t.confirmed, detected_at: t.detected_at })),
|
||||||
|
qr_url: await fetchQrBase64(uri),
|
||||||
|
pay_uri: uri,
|
||||||
|
coin_label: coinInfo?.label,
|
||||||
|
coin_ticker: coinInfo?.ticker,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
|
||||||
|
.get("/checkout/:id/receipt", async ({ accountId, params, set }) => {
|
||||||
|
const dbId = decodeId(params.id);
|
||||||
|
const [payment] = await sql`
|
||||||
|
SELECT * FROM payments WHERE id = ${dbId} AND account_id = ${accountId}
|
||||||
|
`;
|
||||||
|
if (!payment) { set.status = 404; return { error: "Payment not found" }; }
|
||||||
|
if (payment.status !== "paid") { set.status = 400; return { error: "Receipt is only available for paid invoices" }; }
|
||||||
|
|
||||||
|
set.headers["content-type"] = "text/html; charset=utf-8";
|
||||||
|
return payment.receipt_html || await generateReceipt(payment.id);
|
||||||
|
})
|
||||||
|
|
||||||
|
;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"types": ["bun-types"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { createHmac, randomBytes, timingSafeEqual } from "crypto";
|
||||||
|
|
||||||
|
const EMAIL_HMAC_KEY = process.env.EMAIL_HMAC_KEY || "pingql-default-hmac-key";
|
||||||
|
|
||||||
|
export function generateKey(): string {
|
||||||
|
return randomBytes(32).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashEmail(email: string): string {
|
||||||
|
return createHmac("sha256", EMAIL_HMAC_KEY).update(email.toLowerCase().trim()).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractAuthKey(headers: Record<string, string | undefined>, cookie: any): string | null {
|
||||||
|
const authHeader = headers["authorization"] ?? "";
|
||||||
|
const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim();
|
||||||
|
return bearer ?? cookie?.pingql_key?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveKey(
|
||||||
|
sql: any, key: string, opts?: { trackUsage?: boolean }
|
||||||
|
): Promise<{ accountId: string; keyId: string | null; plan: string } | null> {
|
||||||
|
const [account] = await sql`SELECT id, plan FROM accounts WHERE key = ${key}`;
|
||||||
|
if (account) return { accountId: account.id, keyId: null, plan: account.plan };
|
||||||
|
|
||||||
|
const [apiKey] = await sql`SELECT k.id, k.account_id, a.plan FROM api_keys k JOIN accounts a ON a.id = k.account_id WHERE k.key = ${key}`;
|
||||||
|
if (apiKey) {
|
||||||
|
if (opts?.trackUsage !== false) {
|
||||||
|
sql`UPDATE api_keys SET last_used_at = now() WHERE id = ${apiKey.id}`.catch(() => {});
|
||||||
|
}
|
||||||
|
return { accountId: apiKey.account_id, keyId: apiKey.id, plan: apiKey.plan };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COOKIE_OPTS = {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.COOKIE_SECURE !== "false",
|
||||||
|
sameSite: "none" as const,
|
||||||
|
path: "/",
|
||||||
|
domain: process.env.COOKIE_DOMAIN ?? ".pingql.com",
|
||||||
|
maxAge: 60 * 60 * 24 * 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function safeTokenCompare(a: string | undefined, b: string | undefined): boolean {
|
||||||
|
if (!a || !b) return false;
|
||||||
|
const bufA = Buffer.from(a);
|
||||||
|
const bufB = Buffer.from(b);
|
||||||
|
if (bufA.length !== bufB.length) return false;
|
||||||
|
return timingSafeEqual(bufA, bufB);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SECURITY_HEADERS = {
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"X-Frame-Options": "DENY",
|
||||||
|
"Strict-Transport-Security": "max-age=63072000; includeSubDomains",
|
||||||
|
"X-XSS-Protection": "0",
|
||||||
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||||
|
};
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
export async function migrate(sql: any) {
|
||||||
|
await sql`CREATE EXTENSION IF NOT EXISTS pgcrypto`;
|
||||||
|
|
||||||
|
// ── accounts ──────────────────────────────────────────────────────────
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS accounts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
key TEXT NOT NULL UNIQUE,
|
||||||
|
email_hash TEXT,
|
||||||
|
plan TEXT NOT NULL DEFAULT 'free',
|
||||||
|
plan_expires_at TIMESTAMPTZ,
|
||||||
|
plan_stack JSONB NOT NULL DEFAULT '[]',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── monitors ──────────────────────────────────────────────────────────
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS monitors (
|
||||||
|
id TEXT PRIMARY KEY DEFAULT encode(gen_random_bytes(8), 'hex'),
|
||||||
|
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
method TEXT NOT NULL DEFAULT 'GET',
|
||||||
|
request_headers JSONB,
|
||||||
|
request_body TEXT,
|
||||||
|
timeout_ms INTEGER NOT NULL DEFAULT 30000,
|
||||||
|
interval_s INTEGER NOT NULL DEFAULT 60,
|
||||||
|
query JSONB,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
regions TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
max_retries INTEGER NOT NULL DEFAULT 0,
|
||||||
|
retry_interval_s INTEGER NOT NULL DEFAULT 30,
|
||||||
|
resend_interval INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cert_alert_days INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_redirects INTEGER NOT NULL DEFAULT 1,
|
||||||
|
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`CREATE INDEX IF NOT EXISTS idx_monitors_account ON monitors(account_id)`;
|
||||||
|
await sql`CREATE INDEX IF NOT EXISTS idx_monitors_tags ON monitors USING GIN(tags)`;
|
||||||
|
|
||||||
|
// ── pings ─────────────────────────────────────────────────────────────
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS pings (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
||||||
|
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
scheduled_at TIMESTAMPTZ,
|
||||||
|
jitter_ms INTEGER,
|
||||||
|
status_code INTEGER,
|
||||||
|
latency_ms INTEGER,
|
||||||
|
up BOOLEAN NOT NULL,
|
||||||
|
important BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
error TEXT,
|
||||||
|
meta JSONB,
|
||||||
|
region TEXT NOT NULL DEFAULT 'default',
|
||||||
|
run_id TEXT,
|
||||||
|
cert_expiry_days INTEGER,
|
||||||
|
cert_issuer TEXT,
|
||||||
|
response_size INTEGER,
|
||||||
|
dns_ms INTEGER,
|
||||||
|
tcp_ms INTEGER,
|
||||||
|
tls_ms INTEGER
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`CREATE INDEX IF NOT EXISTS idx_pings_monitor ON pings(monitor_id, checked_at DESC)`;
|
||||||
|
await sql`CREATE INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`;
|
||||||
|
|
||||||
|
// ── ping_bodies ───────────────────────────────────────────────────────
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS ping_bodies (
|
||||||
|
ping_id BIGINT PRIMARY KEY REFERENCES pings(id) ON DELETE CASCADE,
|
||||||
|
body TEXT
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── api_keys ──────────────────────────────────────────────────────────
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
key TEXT NOT NULL UNIQUE,
|
||||||
|
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
last_used_at TIMESTAMPTZ
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`CREATE INDEX IF NOT EXISTS idx_api_keys_account ON api_keys(account_id)`;
|
||||||
|
|
||||||
|
// ── monitor_region_state ──────────────────────────────────────────────
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS monitor_region_state (
|
||||||
|
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
||||||
|
region TEXT NOT NULL,
|
||||||
|
last_state TEXT,
|
||||||
|
consecutive_down INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cert_alert_sent BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
PRIMARY KEY (monitor_id, region)
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── notification_channels ─────────────────────────────────────────────
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_channels (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
config JSONB NOT NULL,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
monitor_ids TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`CREATE INDEX IF NOT EXISTS idx_notification_channels_account ON notification_channels(account_id)`;
|
||||||
|
await sql`ALTER TABLE notification_channels ADD COLUMN IF NOT EXISTS monitor_ids TEXT[] NOT NULL DEFAULT '{}'`;
|
||||||
|
await sql`CREATE INDEX IF NOT EXISTS idx_notification_channels_monitor_ids ON notification_channels USING GIN(monitor_ids)`;
|
||||||
|
|
||||||
|
// ── one-time flip: monitors.channel_ids → notification_channels.monitor_ids ──
|
||||||
|
const [chanCol] = await sql`
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'monitors' AND column_name = 'channel_ids'
|
||||||
|
`;
|
||||||
|
if (chanCol) {
|
||||||
|
await sql`
|
||||||
|
UPDATE notification_channels c SET monitor_ids = sub.mids
|
||||||
|
FROM (
|
||||||
|
SELECT m.channel_id::uuid AS cid, array_agg(m.id) AS mids
|
||||||
|
FROM (
|
||||||
|
SELECT id, UNNEST(channel_ids) AS channel_id FROM monitors
|
||||||
|
) m
|
||||||
|
GROUP BY m.channel_id
|
||||||
|
) sub
|
||||||
|
WHERE c.id = sub.cid
|
||||||
|
`;
|
||||||
|
await sql`ALTER TABLE monitors DROP COLUMN IF EXISTS channel_ids`;
|
||||||
|
await sql`DROP INDEX IF EXISTS idx_monitors_channel_ids`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── status_pages ──────────────────────────────────────────────────────
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS status_pages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
theme TEXT NOT NULL DEFAULT 'auto',
|
||||||
|
password_hash TEXT,
|
||||||
|
index_search BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
show_powered_by BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
show_response_time BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
bar_frequency TEXT NOT NULL DEFAULT 'daily',
|
||||||
|
bar_count INTEGER NOT NULL DEFAULT 90,
|
||||||
|
custom_domain TEXT UNIQUE,
|
||||||
|
custom_css TEXT,
|
||||||
|
footer_text TEXT,
|
||||||
|
auto_refresh_s INTEGER NOT NULL DEFAULT 60,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`CREATE INDEX IF NOT EXISTS idx_status_pages_account ON status_pages(account_id)`;
|
||||||
|
|
||||||
|
// ── status_page_groups ────────────────────────────────────────────────
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS status_page_groups (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`CREATE INDEX IF NOT EXISTS idx_status_page_groups_page ON status_page_groups(status_page_id)`;
|
||||||
|
|
||||||
|
// ── status_page_monitors ──────────────────────────────────────────────
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS status_page_monitors (
|
||||||
|
status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE,
|
||||||
|
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
||||||
|
group_id UUID REFERENCES status_page_groups(id) ON DELETE SET NULL,
|
||||||
|
display_name TEXT,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (status_page_id, monitor_id)
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`CREATE INDEX IF NOT EXISTS idx_status_page_monitors_monitor ON status_page_monitors(monitor_id)`;
|
||||||
|
|
||||||
|
// ── incidents ─────────────────────────────────────────────────────────
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS incidents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL DEFAULT 'minor',
|
||||||
|
pinned BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
resolved_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`CREATE INDEX IF NOT EXISTS idx_incidents_account ON incidents(account_id, started_at DESC)`;
|
||||||
|
|
||||||
|
// ── incident_updates ──────────────────────────────────────────────────
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS incident_updates (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
body_html TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`CREATE INDEX IF NOT EXISTS idx_incident_updates_incident ON incident_updates(incident_id, created_at)`;
|
||||||
|
|
||||||
|
// ── incident_monitors ─────────────────────────────────────────────────
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS incident_monitors (
|
||||||
|
incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
|
||||||
|
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (incident_id, monitor_id)
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`CREATE INDEX IF NOT EXISTS idx_incident_monitors_monitor ON incident_monitors(monitor_id)`;
|
||||||
|
|
||||||
|
// ── incident_status_pages ─────────────────────────────────────────────
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS incident_status_pages (
|
||||||
|
incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
|
||||||
|
status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (incident_id, status_page_id)
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`CREATE INDEX IF NOT EXISTS idx_incident_status_pages_page ON incident_status_pages(status_page_id)`;
|
||||||
|
|
||||||
|
// ── monitor_uptime_rollup ─────────────────────────────────────────────
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS monitor_uptime_rollup (
|
||||||
|
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
||||||
|
region TEXT NOT NULL,
|
||||||
|
bucket_type TEXT NOT NULL,
|
||||||
|
bucket_start TIMESTAMPTZ NOT NULL,
|
||||||
|
total INTEGER NOT NULL,
|
||||||
|
up_count INTEGER NOT NULL,
|
||||||
|
avg_latency REAL,
|
||||||
|
PRIMARY KEY (monitor_id, region, bucket_type, bucket_start)
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`CREATE INDEX IF NOT EXISTS idx_uptime_rollup_lookup ON monitor_uptime_rollup(monitor_id, bucket_type, bucket_start DESC)`;
|
||||||
|
|
||||||
|
// ── rollup_watermarks ─────────────────────────────────────────────────
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS rollup_watermarks (
|
||||||
|
bucket_type TEXT PRIMARY KEY,
|
||||||
|
last_aggregated_at TIMESTAMPTZ NOT NULL DEFAULT to_timestamp(0)
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── payments ──────────────────────────────────────────────────────────
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS payments (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
plan TEXT NOT NULL,
|
||||||
|
months INTEGER,
|
||||||
|
amount_usd NUMERIC(10,2) NOT NULL,
|
||||||
|
coin TEXT NOT NULL,
|
||||||
|
amount_crypto TEXT NOT NULL,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
derivation_index INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
amount_received TEXT NOT NULL DEFAULT '0',
|
||||||
|
receipt_html TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
paid_at TIMESTAMPTZ,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
txid TEXT
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status)`;
|
||||||
|
await sql`CREATE INDEX IF NOT EXISTS idx_payments_account ON payments(account_id)`;
|
||||||
|
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_payments_coin_derivation ON payments(coin, derivation_index)`;
|
||||||
|
|
||||||
|
// ── payment_txs ───────────────────────────────────────────────────────
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS payment_txs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
payment_id BIGINT NOT NULL REFERENCES payments(id) ON DELETE CASCADE,
|
||||||
|
txid TEXT NOT NULL,
|
||||||
|
amount TEXT NOT NULL,
|
||||||
|
confirmed BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
detected_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
UNIQUE(payment_id, txid)
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`CREATE INDEX IF NOT EXISTS idx_payment_txs_payment ON payment_txs(payment_id)`;
|
||||||
|
await sql`CREATE INDEX IF NOT EXISTS idx_payment_txs_txid ON payment_txs(txid)`;
|
||||||
|
|
||||||
|
console.log("DB ready");
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
const SECRET = process.env.FEISTEL_SECRET || "change-me";
|
||||||
|
const ROUNDS = 8;
|
||||||
|
const HALF = 20;
|
||||||
|
const MASK = (1 << HALF) - 1;
|
||||||
|
const CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||||
|
|
||||||
|
function roundFn(right: number, round: number): number {
|
||||||
|
const h = new Bun.CryptoHasher("sha256").update(SECRET + ":" + round + ":" + right).digest();
|
||||||
|
return (h[0] << 12 | h[1] << 4 | h[2] >> 4) & MASK;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBase62(n: number): string {
|
||||||
|
let s = "";
|
||||||
|
for (let i = 0; i < 7; i++) { s = CHARS[n % 62] + s; n = Math.floor(n / 62); }
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromBase62(s: string): number {
|
||||||
|
let n = 0;
|
||||||
|
for (const c of s) n = n * 62 + CHARS.indexOf(c);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeId(val: number): string {
|
||||||
|
let left = Math.floor(val / (MASK + 1)) & MASK;
|
||||||
|
let right = val & MASK;
|
||||||
|
for (let i = 0; i < ROUNDS; i++) {
|
||||||
|
const tmp = (left ^ roundFn(right, i)) & MASK;
|
||||||
|
left = right;
|
||||||
|
right = tmp;
|
||||||
|
}
|
||||||
|
return toBase62(left * (MASK + 1) + right);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeId(s: string): number {
|
||||||
|
const val = fromBase62(s);
|
||||||
|
let left = Math.floor(val / (MASK + 1)) & MASK;
|
||||||
|
let right = val & MASK;
|
||||||
|
for (let i = ROUNDS - 1; i >= 0; i--) {
|
||||||
|
const tmp = (right ^ roundFn(left, i)) & MASK;
|
||||||
|
right = left;
|
||||||
|
left = tmp;
|
||||||
|
}
|
||||||
|
return left * (MASK + 1) + right;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
export type Plan = "free" | "pro" | "pro2x" | "pro4x" | "lifetime" | "lifetime2x" | "lifetime4x";
|
||||||
|
|
||||||
|
export interface PlanLimits {
|
||||||
|
maxMonitors: number;
|
||||||
|
minIntervalS: number;
|
||||||
|
maxRegions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
||||||
|
free: { maxMonitors: 10, minIntervalS: 30, maxRegions: 1 },
|
||||||
|
pro: { maxMonitors: 200, minIntervalS: 5, maxRegions: 99 },
|
||||||
|
pro2x: { maxMonitors: 400, minIntervalS: 5, maxRegions: 99 },
|
||||||
|
pro4x: { maxMonitors: 800, minIntervalS: 5, maxRegions: 99 },
|
||||||
|
lifetime: { maxMonitors: 200, minIntervalS: 5, maxRegions: 99 },
|
||||||
|
lifetime2x: { maxMonitors: 400, minIntervalS: 5, maxRegions: 99 },
|
||||||
|
lifetime4x: { maxMonitors: 800, minIntervalS: 5, maxRegions: 99 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getPlanLimits(plan: string): PlanLimits {
|
||||||
|
return PLAN_LIMITS[plan as Plan] || PLAN_LIMITS.free;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PLAN_LABELS: Record<string, string> = {
|
||||||
|
free: "Free", pro: "Pro", pro2x: "Pro 2x", pro4x: "Pro 4x",
|
||||||
|
lifetime: "Lifetime", lifetime2x: "Lifetime 2x", lifetime4x: "Lifetime 4x",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PRO_MONTHLY_USD = 12;
|
||||||
|
export const LIFETIME_USD = 140;
|
||||||
|
|
||||||
|
export const PLAN_PRICING: Record<string, { label: string; monthlyUsd?: number; priceUsd?: number }> = {
|
||||||
|
pro: { label: "Pro", monthlyUsd: PRO_MONTHLY_USD },
|
||||||
|
pro2x: { label: "Pro 2x", monthlyUsd: PRO_MONTHLY_USD * 2 },
|
||||||
|
pro4x: { label: "Pro 4x", monthlyUsd: PRO_MONTHLY_USD * 4 },
|
||||||
|
lifetime: { label: "Lifetime", priceUsd: LIFETIME_USD },
|
||||||
|
lifetime2x: { label: "Lifetime 2x", priceUsd: LIFETIME_USD * 2 },
|
||||||
|
lifetime4x: { label: "Lifetime 4x", priceUsd: LIFETIME_USD * 4 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const COINS: Record<string, { label: string; ticker: string; confirmations: number; uri: string }> = {
|
||||||
|
btc: { label: "Bitcoin", ticker: "BTC", confirmations: 1, uri: "bitcoin" },
|
||||||
|
ltc: { label: "Litecoin", ticker: "LTC", confirmations: 1, uri: "litecoin" },
|
||||||
|
doge: { label: "Dogecoin", ticker: "DOGE", confirmations: 1, uri: "dogecoin" },
|
||||||
|
dash: { label: "Dash", ticker: "DASH", confirmations: 1, uri: "dash" },
|
||||||
|
bch: { label: "Bitcoin Cash", ticker: "BCH", confirmations: 0, uri: "bitcoincash" },
|
||||||
|
xec: { label: "eCash", ticker: "XEC", confirmations: 0, uri: "ecash" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLAN_RANK: Record<string, number> = {
|
||||||
|
free: 0, pro: 1, lifetime: 1, pro2x: 2, lifetime2x: 2, pro4x: 3, lifetime4x: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function planTier(plan: string): number {
|
||||||
|
return PLAN_RANK[plan] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REGION_COLORS: Record<string, string> = {
|
||||||
|
"eu-central": "#3b82f6",
|
||||||
|
"us-west": "#f59e0b",
|
||||||
|
"default": "#6b7280",
|
||||||
|
"__none__": "#6b7280",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REGION_LABELS: Record<string, string> = {
|
||||||
|
"eu-central": "EU Central",
|
||||||
|
"us-west": "US West",
|
||||||
|
"default": "Default",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REGIONS: [string, string][] = [
|
||||||
|
["eu-central", "EU Central"],
|
||||||
|
["us-west", "US West"],
|
||||||
|
];
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export function createRateLimiter(windowMs = 60_000, cleanupIntervalMs = 5 * 60_000) {
|
||||||
|
const map = new Map<string, { count: number; resetAt: number }>();
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, entry] of map) {
|
||||||
|
if (now > entry.resetAt) map.delete(key);
|
||||||
|
}
|
||||||
|
}, cleanupIntervalMs);
|
||||||
|
|
||||||
|
return function check(key: string, max: number): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = map.get(key);
|
||||||
|
if (!entry || now > entry.resetAt) {
|
||||||
|
map.set(key, { count: 1, resetAt: now + windowMs });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
entry.count++;
|
||||||
|
return entry.count <= max;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Shared sparkline utilities used by both apps/web (dashboard) and apps/status
|
||||||
|
// (public status pages). Pure HTML/SVG output, no client JS required for the
|
||||||
|
// first paint.
|
||||||
|
|
||||||
|
import { REGION_COLORS } from "../plans";
|
||||||
|
|
||||||
|
export function sparkline(values: number[], width = 120, height = 32, color = '#60a5fa', region = 'default'): string {
|
||||||
|
if (!values.length) return '';
|
||||||
|
const max = Math.max(...values, 1);
|
||||||
|
const min = Math.min(...values, 0);
|
||||||
|
const range = max - min || 1;
|
||||||
|
const step = width / Math.max(values.length - 1, 1);
|
||||||
|
const points = values.map((v, i) => {
|
||||||
|
const x = i * step;
|
||||||
|
const y = height - ((v - min) / range) * (height - 4) - 2;
|
||||||
|
return `${x},${y}`;
|
||||||
|
}).join(' ');
|
||||||
|
return `<svg width="${width}" height="${height}" class="inline-block" data-vals="${values.join(',')}" data-region="${region}"><polyline points="${points}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickBestRegion(pings: Array<{ latency_ms?: number | null; region?: string | null }>): { region: string; values: number[]; latest: number | null } {
|
||||||
|
const withLatency = pings.filter((p) => p.latency_ms != null);
|
||||||
|
if (!withLatency.length) return { region: 'default', values: [], latest: null };
|
||||||
|
|
||||||
|
const byRegion: Record<string, number[]> = {};
|
||||||
|
for (const p of withLatency) {
|
||||||
|
const key = p.region || 'default';
|
||||||
|
if (!byRegion[key]) byRegion[key] = [];
|
||||||
|
byRegion[key].push(p.latency_ms!);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentRegions = new Set(withLatency.slice(-3).map((p) => p.region || 'default'));
|
||||||
|
|
||||||
|
let bestRegion = 'default';
|
||||||
|
let bestAvg = Infinity;
|
||||||
|
for (const [region, vals] of Object.entries(byRegion)) {
|
||||||
|
if (!recentRegions.has(region)) continue;
|
||||||
|
const recent = vals.slice(-3);
|
||||||
|
const avg = recent.reduce((a, b) => a + b, 0) / recent.length;
|
||||||
|
if (avg < bestAvg) { bestAvg = avg; bestRegion = region; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = byRegion[bestRegion] || [];
|
||||||
|
return { region: bestRegion, values, latest: values.length ? values[values.length - 1]! : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sparklineFromPings(pings: Array<{ latency_ms?: number | null; region?: string | null }>, width = 120, height = 32): string {
|
||||||
|
const { region, values } = pickBestRegion(pings);
|
||||||
|
if (!values.length) return '';
|
||||||
|
const color = REGION_COLORS[region] || '#60a5fa';
|
||||||
|
return sparkline(values, width, height, color, region);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// Server-rendered "X ago" timestamp. Returns an HTML span carrying the original
|
||||||
|
// epoch ms in a data attribute so a tiny client script can refresh it without a
|
||||||
|
// re-render. Reused by the dashboard and the public status pages.
|
||||||
|
export function timeAgoSSR(date: string | Date): string {
|
||||||
|
const ts = new Date(date).getTime();
|
||||||
|
const s = Math.ceil((Date.now() - ts) / 1000) || 1;
|
||||||
|
const text =
|
||||||
|
s < 60 ? `${s}s ago`
|
||||||
|
: s < 3600 ? `${Math.floor(s / 60)}m ago`
|
||||||
|
: s < 86400 ? `${Math.floor(s / 3600)}h ago`
|
||||||
|
: `${Math.floor(s / 86400)}d ago`;
|
||||||
|
return `<span class="timestamp" data-ts="${ts}">${text}</span>`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "@pingql/status",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run --hot src/index.ts",
|
||||||
|
"start": "bun run src/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"elysia": "^1.4.27",
|
||||||
|
"eta": "^4.5.1",
|
||||||
|
"postgres": "^3.4.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "^1.3.10",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Password gate for protected status pages. We sign a short-lived cookie with
|
||||||
|
// the page id + a tag derived from the current password hash + an expiry, so
|
||||||
|
// a successful password unlock survives across page loads without us having
|
||||||
|
// to hit Postgres on every request - and so changing the page password
|
||||||
|
// invalidates every cookie issued under the old password.
|
||||||
|
//
|
||||||
|
// Cookie format: <pageId>.<pwTag>.<exp>.<sig>
|
||||||
|
// pwTag = HMAC(SECRET, password_hash).slice(0, 16)
|
||||||
|
// sig = HMAC(SECRET, "<pageId>.<pwTag>.<exp>")
|
||||||
|
//
|
||||||
|
// Why include pwTag: without it, the signed payload would be tied only to
|
||||||
|
// the page id, so a stolen / old cookie would remain valid for the full TTL
|
||||||
|
// even after the operator rotated the password. With pwTag, the verifier
|
||||||
|
// derives the expected tag from the *current* password_hash; old cookies
|
||||||
|
// no longer match and are silently rejected.
|
||||||
|
|
||||||
|
import { createHmac, timingSafeEqual } from "crypto";
|
||||||
|
|
||||||
|
const SECRET = process.env.STATUS_COOKIE_SECRET ?? process.env.MONITOR_TOKEN ?? "dev-secret-change-me";
|
||||||
|
const COOKIE = "pingql_status_auth";
|
||||||
|
const TTL_MS = 12 * 60 * 60 * 1000; // 12 hours
|
||||||
|
|
||||||
|
function sign(payload: string): string {
|
||||||
|
return createHmac("sha256", SECRET).update(payload).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic 16-hex-char tag derived from the current password hash.
|
||||||
|
// Stable for as long as the password_hash row doesn't change. When the
|
||||||
|
// operator changes the password, password_hash changes, pwTag changes, old
|
||||||
|
// cookies' embedded pwTag stops matching → verification fails.
|
||||||
|
function passwordTag(passwordHash: string): string {
|
||||||
|
return createHmac("sha256", SECRET).update(`pw:${passwordHash}`).digest("hex").slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeAuthCookie(pageId: string, passwordHash: string): string {
|
||||||
|
const exp = Date.now() + TTL_MS;
|
||||||
|
const pwTag = passwordTag(passwordHash);
|
||||||
|
const payload = `${pageId}.${pwTag}.${exp}`;
|
||||||
|
const sig = sign(payload);
|
||||||
|
const value = `${payload}.${sig}`;
|
||||||
|
return `${COOKIE}=${value}; Path=/; Max-Age=${Math.floor(TTL_MS / 1000)}; HttpOnly; SameSite=Lax${process.env.NODE_ENV !== "development" ? "; Secure" : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyAuthCookie(cookieHeader: string | null | undefined, pageId: string, passwordHash: string): boolean {
|
||||||
|
if (!cookieHeader) return false;
|
||||||
|
const match = cookieHeader.split(/;\s*/).find((c) => c.startsWith(`${COOKIE}=`));
|
||||||
|
if (!match) return false;
|
||||||
|
const value = match.slice(COOKIE.length + 1);
|
||||||
|
const lastDot = value.lastIndexOf(".");
|
||||||
|
if (lastDot < 0) return false;
|
||||||
|
const payload = value.slice(0, lastDot);
|
||||||
|
const sig = value.slice(lastDot + 1);
|
||||||
|
const parts = payload.split(".");
|
||||||
|
if (parts.length !== 3) return false;
|
||||||
|
const [id, pwTag, expStr] = parts;
|
||||||
|
if (id !== pageId) return false;
|
||||||
|
// Compare the cookie's pwTag against the tag derived from the *current*
|
||||||
|
// password hash. Mismatch = the password was rotated since this cookie was
|
||||||
|
// issued, so the cookie is dead. Constant-time compare to avoid leaking
|
||||||
|
// partial-match timing.
|
||||||
|
const expectedPwTag = passwordTag(passwordHash);
|
||||||
|
if (pwTag.length !== expectedPwTag.length) return false;
|
||||||
|
try {
|
||||||
|
if (!timingSafeEqual(Buffer.from(pwTag), Buffer.from(expectedPwTag))) return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const exp = Number(expStr);
|
||||||
|
if (!Number.isFinite(exp) || Date.now() > exp) return false;
|
||||||
|
const expected = sign(payload);
|
||||||
|
if (expected.length !== sig.length) return false;
|
||||||
|
try {
|
||||||
|
return timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkPassword(plain: string, hash: string): Promise<boolean> {
|
||||||
|
return await Bun.password.verify(plain, hash);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// Tiny in-memory TTL cache keyed by string. Status pages serve the same payload
|
||||||
|
// to many visitors during an outage; we don't want every page hit to fan out to
|
||||||
|
// Postgres. The cache is per-process; behind a load balancer each replica fills
|
||||||
|
// independently, which is fine - short TTLs converge quickly.
|
||||||
|
|
||||||
|
interface Entry<T> { value: T; expires: number }
|
||||||
|
|
||||||
|
const store = new Map<string, Entry<unknown>>();
|
||||||
|
|
||||||
|
export function cacheGet<T>(key: string): T | null {
|
||||||
|
const entry = store.get(key) as Entry<T> | undefined;
|
||||||
|
if (!entry) return null;
|
||||||
|
if (Date.now() > entry.expires) {
|
||||||
|
store.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cacheSet<T>(key: string, value: T, ttlSeconds: number): void {
|
||||||
|
// Soft cap so a runaway path can't blow memory. LRU-ish: oldest entries get
|
||||||
|
// dropped first by insertion order (Map preserves it).
|
||||||
|
if (store.size > 5000) {
|
||||||
|
const firstKey = store.keys().next().value;
|
||||||
|
if (firstKey) store.delete(firstKey);
|
||||||
|
}
|
||||||
|
store.set(key, { value, expires: Date.now() + ttlSeconds * 1000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience wrapper: get-or-fill. The producer runs at most once per key
|
||||||
|
// during the TTL window across this process.
|
||||||
|
export async function cached<T>(key: string, ttlSeconds: number, producer: () => Promise<T>): Promise<T> {
|
||||||
|
const hit = cacheGet<T>(key);
|
||||||
|
if (hit !== null) return hit;
|
||||||
|
const value = await producer();
|
||||||
|
cacheSet(key, value, ttlSeconds);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,586 @@
|
|||||||
|
// Loads the read-only data needed to render a public status page. NEVER reads
|
||||||
|
// the raw `pings` table - uses `monitor_region_state` for current state and
|
||||||
|
// `monitor_uptime_rollup` for historical uptime windows.
|
||||||
|
|
||||||
|
import sql from "./db";
|
||||||
|
|
||||||
|
export type BucketType = "hourly" | "daily";
|
||||||
|
|
||||||
|
export interface StatusPageRow {
|
||||||
|
id: string;
|
||||||
|
account_id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
theme: "auto" | "light" | "dark";
|
||||||
|
password_hash: string | null;
|
||||||
|
index_search: boolean;
|
||||||
|
show_powered_by: boolean;
|
||||||
|
show_response_time:boolean;
|
||||||
|
|
||||||
|
bar_frequency: BucketType;
|
||||||
|
bar_count: number;
|
||||||
|
custom_domain: string | null;
|
||||||
|
custom_css: string | null;
|
||||||
|
footer_text: string | null;
|
||||||
|
auto_refresh_s: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitorRow {
|
||||||
|
id: string;
|
||||||
|
display_name: string;
|
||||||
|
// Note: the underlying monitor.url is intentionally NOT exposed on the
|
||||||
|
// public payload. Status pages display `display_name`; the literal target
|
||||||
|
// URL (which can contain auth tokens, internal hostnames, staging paths,
|
||||||
|
// etc.) must never leak to anonymous visitors via the JSON endpoint.
|
||||||
|
// Group correlator. Emitted as the matching group's `position` index
|
||||||
|
// (0-based string), NOT the underlying UUID, so the JSON doesn't leak
|
||||||
|
// internal IDs. The HTML render works the same either way - it just
|
||||||
|
// looks up groups by this token.
|
||||||
|
group_id: string | null;
|
||||||
|
position: number;
|
||||||
|
// 'paused' means the monitor was disabled in the dashboard - the runner has
|
||||||
|
// stopped checking it, and the public page should treat it as planned
|
||||||
|
// maintenance rather than an outage.
|
||||||
|
current_state: "up" | "down" | "unknown" | "paused";
|
||||||
|
region_states: Array<{ region: string; state: "up" | "down" | "unknown"; updated_at: string | null }>;
|
||||||
|
uptime_pct: number | null;
|
||||||
|
buckets: Array<{ start: string; total: number; up: number; avg_latency: number | null }>; // bar chart input
|
||||||
|
avg_latency: number | null;
|
||||||
|
latency_history: Array<{ region: string; latency_ms: number | null; ts: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-window uptime (24h / 7d / 30d / 90d) is now derived from the same
|
||||||
|
// rollup row set that loadMonitors pulls for the bar chart - see the in-JS
|
||||||
|
// aggregation pass below. This used to be a second SQL round-trip running
|
||||||
|
// four FILTER aggregates that redid arithmetic the raw bucket rows already
|
||||||
|
// contained.
|
||||||
|
|
||||||
|
export interface GroupRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IncidentUpdateRow {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
body_html: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IncidentSummary {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
severity: string;
|
||||||
|
pinned: boolean;
|
||||||
|
started_at: string;
|
||||||
|
resolved_at: string | null;
|
||||||
|
updates: IncidentUpdateRow[]; // full timeline, newest first
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadStatusPage(slug: string): Promise<StatusPageRow | null> {
|
||||||
|
const [row] = await sql<StatusPageRow[]>`SELECT * FROM status_pages WHERE slug = ${slug}`;
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadStatusPageByDomain(domain: string): Promise<StatusPageRow | null> {
|
||||||
|
const [row] = await sql<StatusPageRow[]>`SELECT * FROM status_pages WHERE custom_domain = ${domain}`;
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyDomain(domain: string): Promise<boolean> {
|
||||||
|
const [row] = await sql<{ id: string }[]>`SELECT id FROM status_pages WHERE custom_domain = ${domain}`;
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadGroups(pageId: string): Promise<GroupRow[]> {
|
||||||
|
return sql<GroupRow[]>`
|
||||||
|
SELECT id, name, position FROM status_page_groups
|
||||||
|
WHERE status_page_id = ${pageId}
|
||||||
|
ORDER BY position ASC, name ASC
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadMonitors(
|
||||||
|
pageId: string,
|
||||||
|
barFrequency: BucketType = "daily",
|
||||||
|
barCount: number = 90,
|
||||||
|
): Promise<MonitorRow[]> {
|
||||||
|
// Step 1: page → monitors with display overrides + group + position. Pull
|
||||||
|
// m.enabled too so we can render disabled monitors as "Maintenance" on the
|
||||||
|
// public page (the runner stops checking them when disabled, so their
|
||||||
|
// region_states would otherwise drift to a stale "up" - visitors should
|
||||||
|
// see this as planned downtime, not phantom uptime).
|
||||||
|
// Deliberately do NOT select m.url - see the MonitorRow comment for why the
|
||||||
|
// raw target URL must never reach the public payload.
|
||||||
|
const monitorRows = await sql<any[]>`
|
||||||
|
SELECT
|
||||||
|
spm.monitor_id AS id,
|
||||||
|
COALESCE(spm.display_name, m.name) AS display_name,
|
||||||
|
m.enabled AS enabled,
|
||||||
|
spm.group_id,
|
||||||
|
spm.position
|
||||||
|
FROM status_page_monitors spm
|
||||||
|
JOIN monitors m ON m.id = spm.monitor_id
|
||||||
|
WHERE spm.status_page_id = ${pageId}
|
||||||
|
ORDER BY spm.position ASC, m.name ASC
|
||||||
|
`;
|
||||||
|
if (monitorRows.length === 0) return [];
|
||||||
|
|
||||||
|
const ids = monitorRows.map((r) => r.id);
|
||||||
|
|
||||||
|
// Step 2: per-region current state for these monitors.
|
||||||
|
const stateRows = await sql<{ monitor_id: string; region: string; last_state: string | null; updated_at: string }[]>`
|
||||||
|
SELECT monitor_id, region, last_state, updated_at
|
||||||
|
FROM monitor_region_state
|
||||||
|
WHERE monitor_id = ANY(${sql.array(ids)}::text[])
|
||||||
|
`;
|
||||||
|
const stateByMonitor: Record<string, MonitorRow["region_states"]> = {};
|
||||||
|
for (const s of stateRows) {
|
||||||
|
if (!stateByMonitor[s.monitor_id]) stateByMonitor[s.monitor_id] = [];
|
||||||
|
stateByMonitor[s.monitor_id]!.push({
|
||||||
|
region: s.region,
|
||||||
|
state: (s.last_state as any) ?? "unknown",
|
||||||
|
updated_at: s.updated_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: ONE unified rollup query covering everything we need:
|
||||||
|
// - bar chart: bucket_type = barFrequency, last barCount buckets
|
||||||
|
// - multi-window uptime: hourly back 24h + daily back 90d
|
||||||
|
// - latency sparkline: hourly back 30h
|
||||||
|
//
|
||||||
|
// Union of all of those is "hourly back N hours OR daily back N days" with
|
||||||
|
// N chosen to cover whichever consumer needs the widest window. The rows
|
||||||
|
// are then partitioned by purpose entirely in JS - no second round-trip,
|
||||||
|
// no duplicate FILTER aggregates inside Postgres.
|
||||||
|
const bucket: BucketType = barFrequency;
|
||||||
|
const count = Math.max(1, Math.min(180, barCount));
|
||||||
|
|
||||||
|
// Hourly span has to cover the latency sparkline (30h) AND the bar chart if
|
||||||
|
// it's hourly (up to 180h). +2h slack so the truncated bucket boundary at
|
||||||
|
// the start of the window is included even if we cross an hour during the
|
||||||
|
// request.
|
||||||
|
const hourlyBackHours = Math.max(30, bucket === "hourly" ? count : 0) + 2;
|
||||||
|
// Daily span has to cover multi-window uptime (90d) AND the bar chart if
|
||||||
|
// it's daily (up to 180d). +1d slack for the same reason.
|
||||||
|
const dailyBackDays = Math.max(90, bucket === "daily" ? count : 0) + 1;
|
||||||
|
const hourlyInterval = `${hourlyBackHours} hours`;
|
||||||
|
const dailyInterval = `${dailyBackDays} days`;
|
||||||
|
|
||||||
|
let rollupRows = await sql<any[]>`
|
||||||
|
SELECT monitor_id, region, bucket_type, bucket_start, total, up_count, avg_latency
|
||||||
|
FROM monitor_uptime_rollup
|
||||||
|
WHERE monitor_id = ANY(${sql.array(ids)}::text[])
|
||||||
|
AND (
|
||||||
|
(bucket_type = 'hourly' AND bucket_start > now() - ${hourlyInterval}::interval)
|
||||||
|
OR
|
||||||
|
(bucket_type = 'daily' AND bucket_start > now() - ${dailyInterval}::interval)
|
||||||
|
)
|
||||||
|
ORDER BY monitor_id, bucket_type, region, bucket_start ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Fallback: if the rollup table has nothing for any of these monitors in
|
||||||
|
// either bucket type (cold deploy, broken job), aggregate directly from
|
||||||
|
// pings. Produces both bucket types via UNION ALL so downstream JS doesn't
|
||||||
|
// need to know which path it came from. Bounded by the wider of the two
|
||||||
|
// windows so it stays cheap. Once the rollup catches up this never fires.
|
||||||
|
if (rollupRows.length === 0) {
|
||||||
|
rollupRows = await sql<any[]>`
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
monitor_id,
|
||||||
|
COALESCE(region, 'default') AS region,
|
||||||
|
'hourly'::text AS bucket_type,
|
||||||
|
date_trunc('hour', checked_at) AS bucket_start,
|
||||||
|
count(*)::int AS total,
|
||||||
|
count(*) FILTER (WHERE up)::int AS up_count,
|
||||||
|
avg(latency_ms)::real AS avg_latency
|
||||||
|
FROM pings
|
||||||
|
WHERE monitor_id = ANY(${sql.array(ids)}::text[])
|
||||||
|
AND checked_at > now() - ${hourlyInterval}::interval
|
||||||
|
GROUP BY 1, 2, 4
|
||||||
|
)
|
||||||
|
UNION ALL
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
monitor_id,
|
||||||
|
COALESCE(region, 'default') AS region,
|
||||||
|
'daily'::text AS bucket_type,
|
||||||
|
date_trunc('day', checked_at) AS bucket_start,
|
||||||
|
count(*)::int AS total,
|
||||||
|
count(*) FILTER (WHERE up)::int AS up_count,
|
||||||
|
avg(latency_ms)::real AS avg_latency
|
||||||
|
FROM pings
|
||||||
|
WHERE monitor_id = ANY(${sql.array(ids)}::text[])
|
||||||
|
AND checked_at > now() - ${dailyInterval}::interval
|
||||||
|
GROUP BY 1, 2, 4
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single pass over the unified rows builds every index we need:
|
||||||
|
// barIndexed[mid][isoStart] → cross-region {total, up} for bar coloring (only rows of barFrequency)
|
||||||
|
// barRegionLat[mid][region] → weighted latency over the bar window for picking fastest region
|
||||||
|
// barRegionBucketLat[mid][region][iso] → per-bucket latency in the fastest region (only rows of barFrequency)
|
||||||
|
// latByMonitor[mid][] → 30h hourly latency sparkline rows
|
||||||
|
const barIndexed: Record<string, Record<string, { total: number; up: number }>> = {};
|
||||||
|
const barRegionLat: Record<string, Record<string, { sum: number; n: number }>> = {};
|
||||||
|
const barRegionBucketLat: Record<string, Record<string, Record<string, number>>> = {};
|
||||||
|
|
||||||
|
const latByMonitor: Record<string, MonitorRow["latency_history"]> = {};
|
||||||
|
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const ms30h = 30 * 3600_000;
|
||||||
|
|
||||||
|
for (const r of rollupRows) {
|
||||||
|
const startDate = r.bucket_start instanceof Date ? r.bucket_start : new Date(r.bucket_start);
|
||||||
|
const startIso = startDate.toISOString();
|
||||||
|
const startMs = startDate.getTime();
|
||||||
|
const total = Number(r.total);
|
||||||
|
const up = Number(r.up_count);
|
||||||
|
const avgLat = r.avg_latency == null ? null : Number(r.avg_latency);
|
||||||
|
const mid = r.monitor_id;
|
||||||
|
const bt: BucketType = r.bucket_type;
|
||||||
|
|
||||||
|
// Bar chart accumulators - only rows matching the configured bar frequency.
|
||||||
|
if (bt === bucket) {
|
||||||
|
if (!barIndexed[mid]) barIndexed[mid] = {};
|
||||||
|
const slot = barIndexed[mid]![startIso] ?? { total: 0, up: 0 };
|
||||||
|
slot.total += total;
|
||||||
|
slot.up += up;
|
||||||
|
barIndexed[mid]![startIso] = slot;
|
||||||
|
|
||||||
|
if (avgLat != null && total > 0) {
|
||||||
|
if (!barRegionLat[mid]) barRegionLat[mid] = {};
|
||||||
|
const acc = barRegionLat[mid]![r.region] ?? { sum: 0, n: 0 };
|
||||||
|
acc.sum += avgLat * total;
|
||||||
|
acc.n += total;
|
||||||
|
barRegionLat[mid]![r.region] = acc;
|
||||||
|
|
||||||
|
if (!barRegionBucketLat[mid]) barRegionBucketLat[mid] = {};
|
||||||
|
if (!barRegionBucketLat[mid]![r.region]) barRegionBucketLat[mid]![r.region] = {};
|
||||||
|
barRegionBucketLat[mid]![r.region]![startIso] = Math.round(avgLat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 30h hourly latency sparkline.
|
||||||
|
if (bt === "hourly" && nowMs - startMs < ms30h) {
|
||||||
|
if (!latByMonitor[mid]) latByMonitor[mid] = [];
|
||||||
|
latByMonitor[mid]!.push({
|
||||||
|
region: r.region,
|
||||||
|
latency_ms: avgLat == null ? null : Math.round(avgLat),
|
||||||
|
ts: startIso,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort the latency sparkline rows by ts ASC per monitor (the unified query
|
||||||
|
// sorts by bucket_type then region then bucket_start, so the per-monitor
|
||||||
|
// hourly subset is already ordered within a region but interleaved across
|
||||||
|
// regions - this normalises it the same way the old separate query did).
|
||||||
|
for (const mid of Object.keys(latByMonitor)) {
|
||||||
|
latByMonitor[mid]!.sort((a, b) => a.ts.localeCompare(b.ts));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick the fastest region per monitor over the bar window (lowest weighted
|
||||||
|
// average latency). Per-bucket latency display + the per-monitor avg_latency
|
||||||
|
// both come from the chosen region.
|
||||||
|
const fastestRegionByMonitor: Record<string, string | null> = {};
|
||||||
|
const fastestLatency: Record<string, number | null> = {};
|
||||||
|
for (const id of ids) {
|
||||||
|
let bestRegion: string | null = null;
|
||||||
|
let bestAvg = Infinity;
|
||||||
|
const regions = barRegionLat[id] ?? {};
|
||||||
|
for (const [region, acc] of Object.entries(regions)) {
|
||||||
|
if (acc.n === 0) continue;
|
||||||
|
const avg = acc.sum / acc.n;
|
||||||
|
if (avg < bestAvg) { bestAvg = avg; bestRegion = region; }
|
||||||
|
}
|
||||||
|
fastestRegionByMonitor[id] = bestRegion;
|
||||||
|
fastestLatency[id] = bestRegion != null ? Math.round(bestAvg) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate the full sequence of expected bucket timestamps so empty bars
|
||||||
|
// render as "no data" instead of disappearing entirely. Truncate `now()`
|
||||||
|
// to the unit so the slot boundaries line up with what the rollup writes.
|
||||||
|
const bucketMs = bucket === "hourly" ? 3600_000 : 86_400_000;
|
||||||
|
const truncate = (d: Date): Date => {
|
||||||
|
const t = new Date(d);
|
||||||
|
if (bucket === "hourly") t.setUTCMinutes(0, 0, 0);
|
||||||
|
else t.setUTCHours(0, 0, 0, 0);
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
const nowTrunc = truncate(new Date()).getTime();
|
||||||
|
const slotIsos: string[] = [];
|
||||||
|
for (let i = count - 1; i >= 0; i--) {
|
||||||
|
slotIsos.push(new Date(nowTrunc - i * bucketMs).toISOString());
|
||||||
|
}
|
||||||
|
const bucketsByMonitor: Record<string, MonitorRow["buckets"]> = {};
|
||||||
|
for (const id of ids) {
|
||||||
|
const slotMap = barIndexed[id] ?? {};
|
||||||
|
const bestRegion = fastestRegionByMonitor[id];
|
||||||
|
const fastestBuckets = bestRegion ? barRegionBucketLat[id]?.[bestRegion] ?? {} : {};
|
||||||
|
bucketsByMonitor[id] = slotIsos.map((iso) => {
|
||||||
|
const hit = slotMap[iso];
|
||||||
|
const lat = fastestBuckets[iso] ?? null;
|
||||||
|
return hit
|
||||||
|
? { start: iso, total: hit.total, up: hit.up, avg_latency: lat }
|
||||||
|
: { start: iso, total: 0, up: 0, avg_latency: null };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const latencyByMonitorList = latByMonitor;
|
||||||
|
|
||||||
|
return monitorRows.map((m) => {
|
||||||
|
const region_states = stateByMonitor[m.id] ?? [];
|
||||||
|
let current_state: MonitorRow["current_state"] = "unknown";
|
||||||
|
if (region_states.length > 0) {
|
||||||
|
const anyDown = region_states.some((s) => s.state === "down");
|
||||||
|
const anyUp = region_states.some((s) => s.state === "up");
|
||||||
|
current_state = anyDown ? "down" : anyUp ? "up" : "unknown";
|
||||||
|
}
|
||||||
|
// A disabled monitor is in operator-declared maintenance - runner has
|
||||||
|
// stopped checking it. Override whatever the last region state was so the
|
||||||
|
// public page reads "Maintenance" instead of a stale "Operational".
|
||||||
|
if (m.enabled === false) current_state = "paused";
|
||||||
|
const buckets = bucketsByMonitor[m.id] ?? [];
|
||||||
|
let uptime_pct: number | null = null;
|
||||||
|
if (buckets.length > 0) {
|
||||||
|
const tot = buckets.reduce((a, b) => a + b.total, 0);
|
||||||
|
const upT = buckets.reduce((a, b) => a + b.up, 0);
|
||||||
|
// Full precision - the display layer truncates (not rounds) to 2 decimals
|
||||||
|
// so any downtime, however small, never visually rounds up to 100%.
|
||||||
|
uptime_pct = tot > 0 ? (100 * upT / tot) : null;
|
||||||
|
}
|
||||||
|
const avg_latency = fastestLatency[m.id] ?? null;
|
||||||
|
return {
|
||||||
|
id: m.id,
|
||||||
|
display_name: m.display_name,
|
||||||
|
group_id: m.group_id,
|
||||||
|
position: m.position,
|
||||||
|
current_state,
|
||||||
|
region_states,
|
||||||
|
uptime_pct,
|
||||||
|
buckets,
|
||||||
|
avg_latency,
|
||||||
|
latency_history: latencyByMonitorList[m.id] ?? [],
|
||||||
|
} as MonitorRow;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadIncidents(pageId: string): Promise<{ active: IncidentSummary[]; recent: IncidentSummary[] }> {
|
||||||
|
const incidents = await sql<any[]>`
|
||||||
|
SELECT i.*
|
||||||
|
FROM incidents i
|
||||||
|
JOIN incident_status_pages isp ON isp.incident_id = i.id
|
||||||
|
WHERE isp.status_page_id = ${pageId}
|
||||||
|
ORDER BY i.started_at DESC
|
||||||
|
LIMIT 50
|
||||||
|
`;
|
||||||
|
if (incidents.length === 0) return { active: [], recent: [] };
|
||||||
|
|
||||||
|
const ids = incidents.map((i) => i.id);
|
||||||
|
// Full timeline per incident (newest first), so the public page can show the
|
||||||
|
// entire course of events on both active and resolved incidents.
|
||||||
|
const allUpdates = await sql<any[]>`
|
||||||
|
SELECT id, incident_id, status, body_html, created_at
|
||||||
|
FROM incident_updates
|
||||||
|
WHERE incident_id = ANY(${sql.array(ids)}::uuid[])
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`;
|
||||||
|
const updatesByIncident: Record<string, IncidentUpdateRow[]> = {};
|
||||||
|
for (const u of allUpdates) {
|
||||||
|
if (!updatesByIncident[u.incident_id]) updatesByIncident[u.incident_id] = [];
|
||||||
|
updatesByIncident[u.incident_id]!.push({
|
||||||
|
id: u.id,
|
||||||
|
status: u.status,
|
||||||
|
body_html: u.body_html,
|
||||||
|
created_at: u.created_at instanceof Date ? u.created_at.toISOString() : String(u.created_at),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const enriched: IncidentSummary[] = incidents.map((i) => ({
|
||||||
|
id: i.id,
|
||||||
|
title: i.title,
|
||||||
|
status: i.status,
|
||||||
|
severity: i.severity,
|
||||||
|
pinned: i.pinned,
|
||||||
|
started_at: i.started_at instanceof Date ? i.started_at.toISOString() : String(i.started_at),
|
||||||
|
resolved_at: i.resolved_at ? (i.resolved_at instanceof Date ? i.resolved_at.toISOString() : String(i.resolved_at)) : null,
|
||||||
|
updates: updatesByIncident[i.id] ?? [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const active = enriched.filter((i) => i.pinned && !i.resolved_at);
|
||||||
|
const recent = enriched.filter((i) => !active.includes(i));
|
||||||
|
return { active, recent };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitorDetailPayload {
|
||||||
|
monitor: MonitorRow;
|
||||||
|
incidents: IncidentSummary[]; // recent incidents that touch this monitor
|
||||||
|
generated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadMonitorDetail(slug: string, monitorId: string): Promise<MonitorDetailPayload | null> {
|
||||||
|
const page = await loadStatusPage(slug);
|
||||||
|
if (!page) return null;
|
||||||
|
// Existence check only - confirm the monitor is actually attached to this
|
||||||
|
// page. The bulk loader below produces the full payload; this query exists
|
||||||
|
// purely so we can return null on a wrong slug/monitor combo without firing
|
||||||
|
// the bigger query at all.
|
||||||
|
const [link] = await sql<any[]>`
|
||||||
|
SELECT 1
|
||||||
|
FROM status_page_monitors spm
|
||||||
|
WHERE spm.status_page_id = ${page.id} AND spm.monitor_id = ${monitorId}
|
||||||
|
`;
|
||||||
|
if (!link) return null;
|
||||||
|
|
||||||
|
// Reuse the bulk loader with a single-monitor list - keeps the bucket/state
|
||||||
|
// logic in one place. Cheap because we're querying for one ID. We also need
|
||||||
|
// the page's groups so we can redact the monitor's group_id (UUID → public
|
||||||
|
// position-as-string token), matching what /:slug.json emits.
|
||||||
|
const [allGroups, allMonitors] = await Promise.all([
|
||||||
|
loadGroups(page.id),
|
||||||
|
loadMonitors(page.id, page.bar_frequency, page.bar_count),
|
||||||
|
]);
|
||||||
|
const { monitors } = redactGroupsAndMonitors(allGroups, allMonitors);
|
||||||
|
const m = monitors.find((x) => x.id === monitorId);
|
||||||
|
if (!m) return null;
|
||||||
|
|
||||||
|
// Incidents touching this monitor (any status), most recent 20, full timeline.
|
||||||
|
const incidentRows = await sql<any[]>`
|
||||||
|
SELECT i.*
|
||||||
|
FROM incidents i
|
||||||
|
JOIN incident_monitors im ON im.incident_id = i.id
|
||||||
|
WHERE im.monitor_id = ${monitorId} AND i.account_id = ${page.account_id}
|
||||||
|
ORDER BY i.started_at DESC
|
||||||
|
LIMIT 20
|
||||||
|
`;
|
||||||
|
let incidents: IncidentSummary[] = [];
|
||||||
|
if (incidentRows.length > 0) {
|
||||||
|
const ids = incidentRows.map((i) => i.id);
|
||||||
|
const allUpdates = await sql<any[]>`
|
||||||
|
SELECT id, incident_id, status, body_html, created_at
|
||||||
|
FROM incident_updates
|
||||||
|
WHERE incident_id = ANY(${sql.array(ids)}::uuid[])
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`;
|
||||||
|
const updatesByIncident: Record<string, IncidentUpdateRow[]> = {};
|
||||||
|
for (const u of allUpdates) {
|
||||||
|
if (!updatesByIncident[u.incident_id]) updatesByIncident[u.incident_id] = [];
|
||||||
|
updatesByIncident[u.incident_id]!.push({
|
||||||
|
id: u.id,
|
||||||
|
status: u.status,
|
||||||
|
body_html: u.body_html,
|
||||||
|
created_at: u.created_at instanceof Date ? u.created_at.toISOString() : String(u.created_at),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
incidents = incidentRows.map((i) => ({
|
||||||
|
id: i.id,
|
||||||
|
title: i.title,
|
||||||
|
status: i.status,
|
||||||
|
severity: i.severity,
|
||||||
|
pinned: i.pinned,
|
||||||
|
started_at: i.started_at instanceof Date ? i.started_at.toISOString() : String(i.started_at),
|
||||||
|
resolved_at: i.resolved_at ? (i.resolved_at instanceof Date ? i.resolved_at.toISOString() : String(i.resolved_at)) : null,
|
||||||
|
updates: updatesByIncident[i.id] ?? [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { monitor: m, incidents, generated_at: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shape we actually expose to anonymous visitors. Computed by stripping
|
||||||
|
// internal IDs and any field a public consumer doesn't need from the row
|
||||||
|
// types - see redactPageForPublic / redactGroupsAndMonitors below.
|
||||||
|
export interface PublicPageView {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
theme: "auto" | "light" | "dark";
|
||||||
|
index_search: boolean;
|
||||||
|
show_powered_by: boolean;
|
||||||
|
show_response_time: boolean;
|
||||||
|
bar_frequency: BucketType;
|
||||||
|
bar_count: number;
|
||||||
|
footer_text: string | null;
|
||||||
|
auto_refresh_s: number;
|
||||||
|
has_password: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicGroupView {
|
||||||
|
id: string; // re-keyed to position-as-string, NOT the underlying UUID
|
||||||
|
name: string;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagePayload {
|
||||||
|
page: PublicPageView;
|
||||||
|
groups: PublicGroupView[];
|
||||||
|
monitors: MonitorRow[];
|
||||||
|
incidents: { active: IncidentSummary[]; recent: IncidentSummary[] };
|
||||||
|
generated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip everything that doesn't belong on an unauthenticated payload:
|
||||||
|
// - account_id leaks the customer identifier across the platform
|
||||||
|
// - id internal status_page UUID, no consumer needs it
|
||||||
|
// - password_hash obvious
|
||||||
|
function redactPageForPublic(p: StatusPageRow): PublicPageView {
|
||||||
|
return {
|
||||||
|
slug: p.slug,
|
||||||
|
title: p.title,
|
||||||
|
description: p.description,
|
||||||
|
theme: p.theme,
|
||||||
|
index_search: p.index_search,
|
||||||
|
show_powered_by: p.show_powered_by,
|
||||||
|
show_response_time: p.show_response_time,
|
||||||
|
bar_frequency: p.bar_frequency,
|
||||||
|
bar_count: p.bar_count,
|
||||||
|
footer_text: p.footer_text,
|
||||||
|
auto_refresh_s: p.auto_refresh_s,
|
||||||
|
has_password: !!p.password_hash,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace each group's UUID with its position-as-string. Monitors carry the
|
||||||
|
// same token in their group_id field, so the consumer can still join them
|
||||||
|
// - they just see opaque "0", "1", "2" tokens instead of internal UUIDs.
|
||||||
|
function redactGroupsAndMonitors(
|
||||||
|
groups: GroupRow[],
|
||||||
|
monitors: MonitorRow[],
|
||||||
|
): { groups: PublicGroupView[]; monitors: MonitorRow[] } {
|
||||||
|
const idMap = new Map<string, string>();
|
||||||
|
groups.forEach((g, i) => idMap.set(g.id, String(i)));
|
||||||
|
const publicGroups: PublicGroupView[] = groups.map((g, i) => ({
|
||||||
|
id: String(i),
|
||||||
|
name: g.name,
|
||||||
|
position: g.position,
|
||||||
|
}));
|
||||||
|
const publicMonitors = monitors.map((m) => ({
|
||||||
|
...m,
|
||||||
|
group_id: m.group_id ? (idMap.get(m.group_id) ?? null) : null,
|
||||||
|
}));
|
||||||
|
return { groups: publicGroups, monitors: publicMonitors };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPagePayload(slug: string): Promise<PagePayload | null> {
|
||||||
|
const page = await loadStatusPage(slug);
|
||||||
|
if (!page) return null;
|
||||||
|
const [rawGroups, rawMonitors, incidents] = await Promise.all([
|
||||||
|
loadGroups(page.id),
|
||||||
|
loadMonitors(page.id, page.bar_frequency, page.bar_count),
|
||||||
|
loadIncidents(page.id),
|
||||||
|
]);
|
||||||
|
const { groups, monitors } = redactGroupsAndMonitors(rawGroups, rawMonitors);
|
||||||
|
return {
|
||||||
|
page: redactPageForPublic(page),
|
||||||
|
groups,
|
||||||
|
monitors,
|
||||||
|
incidents,
|
||||||
|
generated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// Read-only Postgres client. The status service does NOT run migrations -
|
||||||
|
// schema is owned by apps/api. This file just opens a connection.
|
||||||
|
import postgres from "postgres";
|
||||||
|
|
||||||
|
const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@localhost:5432/pingql", {
|
||||||
|
max: 10,
|
||||||
|
idle_timeout: 30,
|
||||||
|
connect_timeout: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default sql;
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
import { Elysia } from "elysia";
|
||||||
|
import { Eta } from "eta";
|
||||||
|
import { resolve } from "path";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import sql from "./db";
|
||||||
|
import { loadStatusPage, loadStatusPageByDomain, loadPagePayload, loadMonitorDetail, verifyDomain } from "./data";
|
||||||
|
import { renderRss } from "./render/rss";
|
||||||
|
import { renderBadge, badgeFromState } from "./render/badge";
|
||||||
|
import { cached } from "./cache";
|
||||||
|
import { allow } from "./rate-limit";
|
||||||
|
import { checkPassword, makeAuthCookie, verifyAuthCookie } from "./auth";
|
||||||
|
|
||||||
|
// Crash isolation: log loudly, never exit. Status pages going down silently is
|
||||||
|
// worse than weird logs.
|
||||||
|
process.on("unhandledRejection", (reason) => console.error("[unhandledRejection]", reason));
|
||||||
|
process.on("uncaughtException", (err) => console.error("[uncaughtException]", err));
|
||||||
|
|
||||||
|
const eta = new Eta({ views: resolve(import.meta.dir, "./views"), cache: true, defaultExtension: ".ejs", rmWhitespace: true });
|
||||||
|
|
||||||
|
const PUBLIC_BASE = process.env.STATUS_BASE_URL ?? "https://pages.pingql.com";
|
||||||
|
|
||||||
|
// Hash the static assets so their URLs change whenever their bytes change.
|
||||||
|
// Used as cache-busting query strings on the <link>/<script> tags so visitors
|
||||||
|
// always pull the freshest CSS/JS even with the long-lived `immutable`
|
||||||
|
// Cache-Control we set on the asset routes below.
|
||||||
|
const expandJsPath = resolve(import.meta.dir, "./static/expand.js");
|
||||||
|
const expandJsBytes = await Bun.file(expandJsPath).bytes();
|
||||||
|
const expandJsHash = createHash("md5").update(expandJsBytes).digest("hex").slice(0, 8);
|
||||||
|
|
||||||
|
const appCssPath = resolve(import.meta.dir, "./static/app.css");
|
||||||
|
const appCssBytes = await Bun.file(appCssPath).bytes();
|
||||||
|
const appCssHash = createHash("md5").update(appCssBytes).digest("hex").slice(0, 8);
|
||||||
|
|
||||||
|
function clientIp(req: Request): string {
|
||||||
|
return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|
||||||
|
|| req.headers.get("cf-connecting-ip")
|
||||||
|
|| "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 404s must NOT be cached by browsers or Cloudflare. The same URL frequently
|
||||||
|
// flips between "200 with data" and "404 not-found" - for example when an
|
||||||
|
// operator adds a password to a previously-public page, or when a slug
|
||||||
|
// changes, or when a monitor is removed. If Cloudflare cached a 404 (default
|
||||||
|
// behaviour for unspecified Cache-Control on 404 responses) the operator
|
||||||
|
// would see stale 404s for the entire edge TTL. Likewise, an old 200 cached
|
||||||
|
// at the edge could keep serving image/svg+xml headers over a now-changed
|
||||||
|
// body, which is exactly the symptom that prompted this fix.
|
||||||
|
//
|
||||||
|
// no-store is the bluntest form: never store, always revalidate. Pair with
|
||||||
|
// 404 status so any layer in front knows not to hold on to it.
|
||||||
|
const NO_STORE_HEADERS = {
|
||||||
|
"cache-control": "no-store, must-revalidate",
|
||||||
|
"pragma": "no-cache",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function notFound(): Response {
|
||||||
|
return new Response(eta.render("not-found", {}), {
|
||||||
|
status: 404,
|
||||||
|
headers: {
|
||||||
|
"content-type": "text/html; charset=utf-8",
|
||||||
|
...NO_STORE_HEADERS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonNotFound(): Response {
|
||||||
|
return new Response(JSON.stringify({ error: "not found" }), {
|
||||||
|
status: 404,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
...NO_STORE_HEADERS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function rateLimited(): Response {
|
||||||
|
return new Response("Too many requests", { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuthorised(page: { id: string; password_hash: string | null }, req: Request): boolean {
|
||||||
|
if (!page.password_hash) return true;
|
||||||
|
// Pass the current password_hash so verifyAuthCookie can derive the
|
||||||
|
// expected pwTag and reject any cookie that was issued under a previous
|
||||||
|
// password - i.e. rotating the password evicts every existing session.
|
||||||
|
return verifyAuthCookie(req.headers.get("cookie"), page.id, page.password_hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memoirist (Elysia's router) treats `:slug.json` as a single parameter named
|
||||||
|
// "slug.json" and refuses to coexist with `:slug`. Instead, use one `:slug`
|
||||||
|
// route and dispatch on the suffix in the handler.
|
||||||
|
const STATUS_HOST = process.env.STATUS_HOST || "pages.pingql.com";
|
||||||
|
|
||||||
|
function isCustomDomain(request: Request): string | null {
|
||||||
|
const host = new URL(request.url).hostname;
|
||||||
|
if (host === STATUS_HOST || host === "localhost" || host === "127.0.0.1") return null;
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveSlugFromDomain(domain: string): Promise<string | null> {
|
||||||
|
const page = await loadStatusPageByDomain(domain);
|
||||||
|
return page?.slug ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitSlugAndFormat(raw: string): { slug: string; format: "html" | "json" | "rss" } {
|
||||||
|
if (raw.endsWith(".json")) return { slug: raw.slice(0, -5), format: "json" };
|
||||||
|
if (raw.endsWith(".rss")) return { slug: raw.slice(0, -4), format: "rss" };
|
||||||
|
return { slug: raw, format: "html" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderHtml(slug: string, request: Request): Promise<Response> {
|
||||||
|
// Page row is fetched fresh on every request - never cached. The page row
|
||||||
|
// carries the live password_hash + index_search + display config; an
|
||||||
|
// operator changing any of those in the dashboard must take effect
|
||||||
|
// immediately, not after a TTL window. The single PK lookup is sub-ms,
|
||||||
|
// and the heavy work (rollup payload below) is still cached for 15s.
|
||||||
|
const page = await loadStatusPage(slug);
|
||||||
|
if (!page) return notFound();
|
||||||
|
if (!isAuthorised(page, request)) {
|
||||||
|
// Do NOT pass page.title to the password template - that would let any
|
||||||
|
// OSINT scraper iterating slugs harvest the human-readable name of every
|
||||||
|
// private page without ever authenticating. Slug is fine: it's already
|
||||||
|
// in the URL the visitor typed.
|
||||||
|
return new Response(eta.render("password", { slug: page.slug, error: null }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "content-type": "text/html; charset=utf-8", ...NO_STORE_HEADERS },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const payload = await cached(`payload:${slug}`, 15, () => loadPagePayload(slug));
|
||||||
|
if (!payload) return notFound();
|
||||||
|
const html = eta.render("page", { ...payload, expandJsHash, appCssHash });
|
||||||
|
// Password-protected pages MUST be private - never let an edge cache or
|
||||||
|
// shared proxy hold a copy that some other visitor could pull. Public
|
||||||
|
// pages keep the 15s shared cache for performance under viral hits.
|
||||||
|
const cacheControl = page.password_hash
|
||||||
|
? "private, no-store, must-revalidate"
|
||||||
|
: "public, max-age=15, s-maxage=15";
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"content-type": "text/html; charset=utf-8",
|
||||||
|
"cache-control": cacheControl,
|
||||||
|
"x-frame-options":"SAMEORIGIN",
|
||||||
|
"x-content-type-options": "nosniff",
|
||||||
|
"referrer-policy":"strict-origin-when-cross-origin",
|
||||||
|
};
|
||||||
|
if (!page.index_search) headers["x-robots-tag"] = "noindex, nofollow";
|
||||||
|
return new Response(html, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderJson(slug: string, request: Request): Promise<Response> {
|
||||||
|
// Same as renderHtml: never cache the page row. The auth check has to see
|
||||||
|
// the live password_hash, otherwise rotating a password leaves a 15s
|
||||||
|
// window where old cookies still validate against the stale row.
|
||||||
|
const page = await loadStatusPage(slug);
|
||||||
|
// Both nonexistent and gated-without-cookie collapse to the same 404 so a
|
||||||
|
// scraper iterating slugs against /<slug>.json can't use the response code
|
||||||
|
// as an oracle for which slugs are private. Forces any OSINT enumeration to
|
||||||
|
// fall back to HTML scraping (which gets the password form, also a 401-ish
|
||||||
|
// signal but more expensive to parse and less stable).
|
||||||
|
if (!page || !isAuthorised(page, request)) return jsonNotFound();
|
||||||
|
const payload = await cached(`payload:${slug}`, 15, () => loadPagePayload(slug));
|
||||||
|
if (!payload) return jsonNotFound();
|
||||||
|
// Password-protected JSON must be private - same reasoning as renderHtml.
|
||||||
|
const cacheControl = page.password_hash
|
||||||
|
? "private, no-store, must-revalidate"
|
||||||
|
: "public, max-age=15, s-maxage=15";
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"cache-control": cacheControl,
|
||||||
|
...(page.index_search ? {} : { "x-robots-tag": "noindex, nofollow" }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderRssResp(slug: string, request: Request): Promise<Response> {
|
||||||
|
const page = await loadStatusPage(slug);
|
||||||
|
if (!page) return notFound();
|
||||||
|
// Password-protected pages must NOT leak incident text via RSS. Without
|
||||||
|
// this check anyone with the slug could pull every incident update on a
|
||||||
|
// private page just by hitting /<slug>.rss. Match the page-existence 404
|
||||||
|
// exactly so an unauthenticated scraper can't even tell whether a slug
|
||||||
|
// is gated vs. nonexistent.
|
||||||
|
if (!isAuthorised(page, request)) return notFound();
|
||||||
|
const xml = await cached(`rss:${slug}`, 300, () => renderRss(page, PUBLIC_BASE));
|
||||||
|
// Authenticated visitor on a private page → never edge-cache the response.
|
||||||
|
const cacheControl = page.password_hash
|
||||||
|
? "private, no-store, must-revalidate"
|
||||||
|
: "public, max-age=300, s-maxage=300";
|
||||||
|
return new Response(xml, {
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/rss+xml; charset=utf-8",
|
||||||
|
"cache-control": cacheControl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = new Elysia()
|
||||||
|
// No status page lives at the root - show the same 404 visitors get for any
|
||||||
|
// unknown slug, so a stray hit on the apex doesn't leak service identity.
|
||||||
|
// Caddy on_demand TLS verification - confirms a domain is a valid custom domain
|
||||||
|
.get("/internal/verify-domain", async ({ query }) => {
|
||||||
|
const domain = (query as any)?.domain;
|
||||||
|
if (!domain) return new Response("missing domain", { status: 400 });
|
||||||
|
const valid = await verifyDomain(domain);
|
||||||
|
return new Response(valid ? "ok" : "not found", { status: valid ? 200 : 404 });
|
||||||
|
})
|
||||||
|
|
||||||
|
// Custom domain routes - serve at root when Host is a custom domain
|
||||||
|
.get("/", async ({ request }) => {
|
||||||
|
const domain = isCustomDomain(request);
|
||||||
|
if (!domain) return notFound();
|
||||||
|
const slug = await resolveSlugFromDomain(domain);
|
||||||
|
if (!slug) return notFound();
|
||||||
|
return renderHtml(slug, request);
|
||||||
|
})
|
||||||
|
.get("/index.json", async ({ request }) => {
|
||||||
|
const domain = isCustomDomain(request);
|
||||||
|
if (!domain) return notFound();
|
||||||
|
const slug = await resolveSlugFromDomain(domain);
|
||||||
|
if (!slug) return notFound();
|
||||||
|
return renderJson(slug, request);
|
||||||
|
})
|
||||||
|
.get("/index.rss", async ({ request }) => {
|
||||||
|
const domain = isCustomDomain(request);
|
||||||
|
if (!domain) return notFound();
|
||||||
|
const slug = await resolveSlugFromDomain(domain);
|
||||||
|
if (!slug) return notFound();
|
||||||
|
return renderRssResp(slug, request);
|
||||||
|
})
|
||||||
|
.get("/badge.svg", async ({ request }) => {
|
||||||
|
const domain = isCustomDomain(request);
|
||||||
|
if (!domain) return notFound();
|
||||||
|
const slug = await resolveSlugFromDomain(domain);
|
||||||
|
if (!slug) return notFound();
|
||||||
|
const page = await loadStatusPage(slug);
|
||||||
|
if (!page || !isAuthorised(page, request)) return notFound();
|
||||||
|
const payload = await cached("payload:" + slug, 15, () => loadPagePayload(slug));
|
||||||
|
if (!payload) return notFound();
|
||||||
|
const states = payload.monitors.map((m: any) => m.current_state);
|
||||||
|
return new Response(renderBadge(page.title, badgeFromState(states)), { headers: { "content-type": "image/svg+xml", "cache-control": page.password_hash ? "private, no-store" : "public, max-age=15" } });
|
||||||
|
})
|
||||||
|
|
||||||
|
// Static expand.js - cached aggressively, hash-busted via query string.
|
||||||
|
.get("/_static/expand.js", () => new Response(Bun.file(expandJsPath), {
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/javascript; charset=utf-8",
|
||||||
|
"cache-control": "public, max-age=31536000, immutable",
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Static app.css - same caching contract as expand.js. The query string in
|
||||||
|
// the <link> tag is the file's MD5 hash, so deploys propagate immediately
|
||||||
|
// even though the asset itself is marked immutable for a year.
|
||||||
|
.get("/_static/app.css", () => new Response(Bun.file(appCssPath), {
|
||||||
|
headers: {
|
||||||
|
"content-type": "text/css; charset=utf-8",
|
||||||
|
"cache-control": "public, max-age=31536000, immutable",
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Single public route - dispatches HTML / JSON / RSS by extension on the slug.
|
||||||
|
.get("/:slug", async ({ params, request, query }) => {
|
||||||
|
const { slug, format } = splitSlugAndFormat(params.slug);
|
||||||
|
if (!allow(slug, clientIp(request))) return rateLimited();
|
||||||
|
if (format === "json") return renderJson(slug, request);
|
||||||
|
if (format === "rss") return renderRssResp(slug, request);
|
||||||
|
return renderHtml(slug, request);
|
||||||
|
})
|
||||||
|
|
||||||
|
// Public SVG badge. Password-protected pages 404 here so an unauthenticated
|
||||||
|
// shields-style embed can't reveal a private page's current state - and
|
||||||
|
// crucially can't even confirm whether a private slug exists, since the
|
||||||
|
// 404 is identical to a totally bogus slug.
|
||||||
|
.get("/:slug/badge.svg", async ({ params, request }) => {
|
||||||
|
if (!allow(params.slug, clientIp(request))) return rateLimited();
|
||||||
|
const page = await loadStatusPage(params.slug);
|
||||||
|
if (!page) return notFound();
|
||||||
|
if (!isAuthorised(page, request)) return notFound();
|
||||||
|
const payload = await cached(`payload:${params.slug}`, 15, () => loadPagePayload(params.slug));
|
||||||
|
if (!payload) return notFound();
|
||||||
|
const { message, color } = badgeFromState(payload.monitors);
|
||||||
|
const svg = renderBadge("status", message, color);
|
||||||
|
// Same private/no-store rule on authenticated badge fetches for private pages.
|
||||||
|
const cacheControl = page.password_hash
|
||||||
|
? "private, no-store, must-revalidate"
|
||||||
|
: "public, max-age=15, s-maxage=15";
|
||||||
|
return new Response(svg, {
|
||||||
|
headers: {
|
||||||
|
"content-type": "image/svg+xml",
|
||||||
|
"cache-control": cacheControl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
// Per-monitor detail JSON for the click-to-expand UI in compact mode.
|
||||||
|
// Path is /:slug/monitor/:idWithExt where idWithExt is e.g. "abc123.json".
|
||||||
|
// We strip the .json suffix in the handler - same trick as the slug route to
|
||||||
|
// dodge memoirist's "two params at the same position" rule.
|
||||||
|
.get("/:slug/monitor/:idWithExt", async ({ params, request, query }) => {
|
||||||
|
if (!allow(params.slug, clientIp(request))) return rateLimited();
|
||||||
|
// Same password gate as the page-level JSON. Without this, the click-to-
|
||||||
|
// expand endpoint would happily serve uptime / region state / incidents
|
||||||
|
// for monitors on a password-protected page to anyone who guesses the
|
||||||
|
// monitor id (8 hex bytes). Match the page existence 404 so unauthenticated
|
||||||
|
// requests can't even tell whether a slug is gated.
|
||||||
|
const page = await loadStatusPage(params.slug);
|
||||||
|
if (!page || !isAuthorised(page, request)) return jsonNotFound();
|
||||||
|
const idWithExt = params.idWithExt;
|
||||||
|
const monitorId = idWithExt.endsWith(".json") ? idWithExt.slice(0, -5) : idWithExt;
|
||||||
|
const cacheKey = `monitor:${params.slug}:${monitorId}`;
|
||||||
|
const payload = await cached(cacheKey, 15, () => loadMonitorDetail(params.slug, monitorId));
|
||||||
|
if (!payload) return jsonNotFound();
|
||||||
|
const cacheControl = page.password_hash
|
||||||
|
? "private, no-store, must-revalidate"
|
||||||
|
: "public, max-age=15, s-maxage=15";
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"cache-control": cacheControl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
// PWA manifest. Rate-limited like every other public endpoint, and
|
||||||
|
// password-gated 404s match the page-existence 404 so a scraper can't use
|
||||||
|
// this route as an oracle for which slugs exist behind a password gate.
|
||||||
|
.get("/:slug/manifest.json", async ({ params, request }) => {
|
||||||
|
if (!allow(params.slug, clientIp(request))) return rateLimited();
|
||||||
|
const page = await loadStatusPage(params.slug);
|
||||||
|
if (!page) return notFound();
|
||||||
|
if (!isAuthorised(page, request)) return notFound();
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
name: page.title,
|
||||||
|
short_name: page.title.slice(0, 12),
|
||||||
|
description: page.description ?? "",
|
||||||
|
start_url: `/${page.slug}`,
|
||||||
|
display: "standalone",
|
||||||
|
background_color: page.theme === "light" ? "#ffffff" : "#0a0a0a",
|
||||||
|
theme_color: page.theme === "light" ? "#0ea5e9" : "#0a0a0a",
|
||||||
|
}), {
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/manifest+json",
|
||||||
|
"cache-control": page.password_hash
|
||||||
|
? "private, no-store, must-revalidate"
|
||||||
|
: "public, max-age=86400, s-maxage=86400",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
// Password gate POST
|
||||||
|
.post("/:slug/auth", async ({ params, request }) => {
|
||||||
|
if (!allow(params.slug, clientIp(request))) return rateLimited();
|
||||||
|
const page = await loadStatusPage(params.slug);
|
||||||
|
if (!page) return notFound();
|
||||||
|
if (!page.password_hash) {
|
||||||
|
return Response.redirect(`/${page.slug}`, 303);
|
||||||
|
}
|
||||||
|
const form = await request.formData();
|
||||||
|
const password = String(form.get("password") ?? "");
|
||||||
|
const ok = await checkPassword(password, page.password_hash);
|
||||||
|
if (!ok) {
|
||||||
|
return new Response(eta.render("password", { slug: page.slug, error: "Wrong password" }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "content-type": "text/html; charset=utf-8", ...NO_STORE_HEADERS },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(null, {
|
||||||
|
status: 303,
|
||||||
|
headers: { "location": `/${page.slug}`, "set-cookie": makeAuthCookie(page.id, page.password_hash) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = Number(process.env.STATUS_PORT ?? 3003);
|
||||||
|
const server = Bun.serve({
|
||||||
|
port,
|
||||||
|
// Wrap app.handle in a try/catch so any unexpected throw - Postgres
|
||||||
|
// connection blip, template render error, missing env var, etc. - turns
|
||||||
|
// into a generic 500 with an opaque body. Without this wrapper Bun's
|
||||||
|
// default error path may include framework details, file paths, or stack
|
||||||
|
// traces in the response, which would leak internal layout to anyone who
|
||||||
|
// could trigger an exception. We log the real reason server-side.
|
||||||
|
async fetch(req) {
|
||||||
|
try {
|
||||||
|
return await app.handle(req);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[fetch] unhandled error:", err);
|
||||||
|
return new Response("Internal server error", {
|
||||||
|
status: 500,
|
||||||
|
headers: { "content-type": "text/plain; charset=utf-8", ...NO_STORE_HEADERS },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error(err) {
|
||||||
|
// Belt-and-suspenders: even if Bun catches an error before our fetch
|
||||||
|
// wrapper sees it, return a generic body rather than the default page.
|
||||||
|
console.error("[server] error:", err);
|
||||||
|
return new Response("Internal server error", {
|
||||||
|
status: 500,
|
||||||
|
headers: { "content-type": "text/plain; charset=utf-8", ...NO_STORE_HEADERS },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`PingQL status service running at http://localhost:${server.port}`);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Per-(slug, IP) token bucket. 30 requests in a 10s window. Cheap, in-memory,
|
||||||
|
// resets on process restart. Behind a load balancer each replica enforces its
|
||||||
|
// own bucket - that's fine, the goal is "stop a hostile script from melting one
|
||||||
|
// box", not perfect distributed accounting.
|
||||||
|
|
||||||
|
interface Bucket { tokens: number; refillAt: number }
|
||||||
|
|
||||||
|
const buckets = new Map<string, Bucket>();
|
||||||
|
const CAPACITY = 30;
|
||||||
|
const WINDOW_MS = 10_000;
|
||||||
|
|
||||||
|
export function allow(slug: string, ip: string): boolean {
|
||||||
|
const key = `${slug}\x00${ip}`;
|
||||||
|
const now = Date.now();
|
||||||
|
let b = buckets.get(key);
|
||||||
|
if (!b || now > b.refillAt) {
|
||||||
|
b = { tokens: CAPACITY, refillAt: now + WINDOW_MS };
|
||||||
|
buckets.set(key, b);
|
||||||
|
}
|
||||||
|
if (b.tokens <= 0) return false;
|
||||||
|
b.tokens--;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Periodic sweep so the map doesn't grow forever.
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [k, b] of buckets) {
|
||||||
|
if (now > b.refillAt + WINDOW_MS) buckets.delete(k);
|
||||||
|
}
|
||||||
|
}, 60_000).unref?.();
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Shields-style SVG badge for embedding on README files etc.
|
||||||
|
|
||||||
|
export function renderBadge(label: string, message: string, color: string): string {
|
||||||
|
// Approximate text width: 6.5px per char + 10px padding each side.
|
||||||
|
const labelW = 10 + label.length * 6.5 + 10;
|
||||||
|
const messageW = 10 + message.length * 6.5 + 10;
|
||||||
|
const total = labelW + messageW;
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${total.toFixed(0)}" height="20" role="img" aria-label="${label}: ${message}">
|
||||||
|
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
|
||||||
|
<clipPath id="r"><rect width="${total.toFixed(0)}" height="20" rx="3" fill="#fff"/></clipPath>
|
||||||
|
<g clip-path="url(#r)">
|
||||||
|
<rect width="${labelW.toFixed(0)}" height="20" fill="#555"/>
|
||||||
|
<rect x="${labelW.toFixed(0)}" width="${messageW.toFixed(0)}" height="20" fill="${color}"/>
|
||||||
|
<rect width="${total.toFixed(0)}" height="20" fill="url(#s)"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||||
|
<text x="${(labelW / 2).toFixed(1)}" y="14">${escapeXml(label)}</text>
|
||||||
|
<text x="${(labelW + messageW / 2).toFixed(1)}" y="14">${escapeXml(message)}</text>
|
||||||
|
</g>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function badgeFromState(monitors: Array<{ current_state: "up" | "down" | "unknown" }>): { message: string; color: string } {
|
||||||
|
if (monitors.length === 0) return { message: "no data", color: "#9f9f9f" };
|
||||||
|
const down = monitors.filter((m) => m.current_state === "down").length;
|
||||||
|
if (down === 0) return { message: "operational", color: "#4c1" };
|
||||||
|
if (down < monitors.length) return { message: "degraded", color: "#dfb317" };
|
||||||
|
return { message: "down", color: "#e05d44" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(s: string): string {
|
||||||
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// RSS 2.0 feed of incidents on a status page. Unlike Uptime Kuma we include all
|
||||||
|
// incident lifecycle events (investigating / identified / monitoring / resolved),
|
||||||
|
// not just initial outages, so subscribers see the full timeline.
|
||||||
|
|
||||||
|
import sql from "../db";
|
||||||
|
import type { StatusPageRow } from "../data";
|
||||||
|
|
||||||
|
interface FeedItem {
|
||||||
|
guid: string;
|
||||||
|
title: string;
|
||||||
|
link: string;
|
||||||
|
pubDate: string;
|
||||||
|
body_html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderRss(page: StatusPageRow, baseUrl: string): Promise<string> {
|
||||||
|
const updates = await sql<any[]>`
|
||||||
|
SELECT iu.id, iu.status, iu.body_html, iu.created_at, i.title AS incident_title, i.id AS incident_id
|
||||||
|
FROM incident_updates iu
|
||||||
|
JOIN incidents i ON i.id = iu.incident_id
|
||||||
|
JOIN incident_status_pages isp ON isp.incident_id = i.id
|
||||||
|
WHERE isp.status_page_id = ${page.id}
|
||||||
|
ORDER BY iu.created_at DESC
|
||||||
|
LIMIT 50
|
||||||
|
`;
|
||||||
|
|
||||||
|
const items: FeedItem[] = updates.map((u) => ({
|
||||||
|
guid: `update-${u.id}`,
|
||||||
|
title: `[${u.status}] ${u.incident_title}`,
|
||||||
|
link: `${baseUrl}/${page.slug}#incident-${u.incident_id}`,
|
||||||
|
pubDate: new Date(u.created_at).toUTCString(),
|
||||||
|
body_html: u.body_html,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const channelTitle = escapeXml(`${page.title} - Incidents`);
|
||||||
|
const channelDescription = escapeXml(page.description || `${page.title} status updates`);
|
||||||
|
const channelLink = `${baseUrl}/${page.slug}`;
|
||||||
|
|
||||||
|
const itemsXml = items.map((it) => `
|
||||||
|
<item>
|
||||||
|
<guid isPermaLink="false">${it.guid}</guid>
|
||||||
|
<title>${escapeXml(it.title)}</title>
|
||||||
|
<link>${escapeXml(it.link)}</link>
|
||||||
|
<pubDate>${it.pubDate}</pubDate>
|
||||||
|
<description><![CDATA[${it.body_html}]]></description>
|
||||||
|
</item>`).join("");
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>${channelTitle}</title>
|
||||||
|
<link>${escapeXml(channelLink)}</link>
|
||||||
|
<description>${channelDescription}</description>
|
||||||
|
<ttl>300</ttl>
|
||||||
|
${itemsXml}
|
||||||
|
</channel>
|
||||||
|
</rss>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(s: string): string {
|
||||||
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/* Public status page styles. Served as a static asset with a hash-busted
|
||||||
|
query string (see apps/status/src/index.ts), so changes here propagate to
|
||||||
|
visitors as soon as the new bundle is deployed.
|
||||||
|
|
||||||
|
Per-page custom CSS is still inlined in page.ejs (it's per-request data,
|
||||||
|
not a build artifact) - kept in a separate <style> block AFTER this
|
||||||
|
file is loaded so it always wins on specificity ties. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #ffffff; --fg: #1e293b; --muted: #64748b; --card: #f8fafc;
|
||||||
|
--border: #e2e8f0; --accent: #0284c7;
|
||||||
|
--bar-up: #10b981; --bar-down: #ef4444; --bar-partial: #f59e0b; --bar-empty: #e5e7eb;
|
||||||
|
--bar-paused: #6366f1;
|
||||||
|
--overall-fg: #ffffff;
|
||||||
|
}
|
||||||
|
/* OLED-friendly dark mode: pure black background for power saving, but a
|
||||||
|
muted slate-300 foreground instead of white so it doesn't blow out on
|
||||||
|
self-emissive displays. */
|
||||||
|
html.dark, html:not(.light):not(.dark) { color-scheme: dark; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
html:not(.light) {
|
||||||
|
--bg: #0a0a0a; --fg: #c2c8d0; --muted: #6b7280; --card: #131418;
|
||||||
|
--border: #1f2026; --accent: #3b9bd1;
|
||||||
|
--bar-up: #1f8f6e; --bar-down: #c84a4a; --bar-partial: #c98a2c; --bar-empty: #1f2026; --bar-paused: #5b6cb8;
|
||||||
|
--overall-fg: #f1f5f9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html.dark {
|
||||||
|
--bg: #0a0a0a; --fg: #c2c8d0; --muted: #6b7280; --card: #131418;
|
||||||
|
--border: #1f2026; --accent: #3b9bd1;
|
||||||
|
--bar-up: #1f8f6e; --bar-down: #c84a4a; --bar-partial: #c98a2c; --bar-empty: #1f2026; --bar-paused: #5b6cb8;
|
||||||
|
--overall-fg: #f1f5f9;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; background: var(--bg); color: var(--fg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif; line-height: 1.5; }
|
||||||
|
main { max-width: 880px; margin: 0 auto; padding: 3rem 1.5rem; }
|
||||||
|
h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem; }
|
||||||
|
.title-row { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }
|
||||||
|
.title-row h1 { margin: 0; line-height: 1.2; }
|
||||||
|
.json-link, .rss-link { color: var(--muted); display: inline-flex; align-items: center; transition: color 0.15s; }
|
||||||
|
.json-link:hover { color: #60a5fa; }
|
||||||
|
.rss-link:hover { color: #f59e0b; }
|
||||||
|
.muted { color: var(--muted); font-size: 0.875rem; }
|
||||||
|
.overall { padding: 1rem 1.25rem; border-radius: 10px; font-weight: 600; font-size: 1rem; margin: 1.5rem 0rem 1rem 0rem; display: flex; align-items: center; gap: 0.75rem; border: 1px solid; }
|
||||||
|
.overall .dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; animation: pulse-dot 2s ease-in-out infinite; }
|
||||||
|
@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||||
|
/* Group sections - styled like monitor cards */
|
||||||
|
.group-section { background: var(--card); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
||||||
|
.group-toggle { display: none; }
|
||||||
|
.group-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem 1.25rem; cursor: pointer; }
|
||||||
|
/*.group-header:hover { background: rgba(255,255,255,0.02); }*/
|
||||||
|
.group-toggle:checked ~ .group-header .chev { transform: rotate(90deg); }
|
||||||
|
.group-aggregate { padding: 0 1.25rem 1rem; }
|
||||||
|
.group-toggle:checked ~ .group-aggregate { display: none; }
|
||||||
|
.group-body { display: none; padding: 0 0.75rem 0.75rem; }
|
||||||
|
.group-toggle:checked ~ .group-body { display: block; }
|
||||||
|
.group-body .monitors { gap: 0.35rem; }
|
||||||
|
.page-components { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
.monitors { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
/* All monitors share one structure: a clickable header row + a collapsible
|
||||||
|
detail panel. display_mode just controls whether `expanded-state` is set
|
||||||
|
on initial render. */
|
||||||
|
.monitor { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 0; overflow: hidden; }
|
||||||
|
.monitor-name { display: flex; align-items: center; gap: 0.75rem; min-width: 0; }
|
||||||
|
.monitor-name .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.monitor-name .name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.monitor-meta { display: flex; gap: 1rem; align-items: center; font-size: 0.85rem; color: var(--muted); }
|
||||||
|
|
||||||
|
.group-body .monitor {
|
||||||
|
border: unset;
|
||||||
|
border-radius: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uptime-pct { font-variant-numeric: tabular-nums; font-weight: 600; color: var(--fg); display: inline-block; width: 5.5ch; text-align: right; }
|
||||||
|
.uptime-pct.good { color: var(--bar-up); }
|
||||||
|
.uptime-pct.warn { color: var(--bar-partial); }
|
||||||
|
.uptime-pct.bad { color: var(--bar-down); }
|
||||||
|
.uptime-pct.empty { color: var(--muted); }
|
||||||
|
.maintenance-pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.3rem;
|
||||||
|
padding: 0.15rem 0.55rem; border-radius: 999px;
|
||||||
|
font-size: 0.7rem; font-weight: 600;
|
||||||
|
color: var(--bar-paused); border: 1px solid var(--bar-paused);
|
||||||
|
background: transparent; text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.bars { display: flex; gap: 0.1rem; height: 30px; align-items: stretch; position: relative; }
|
||||||
|
.bar { flex: 1; min-width: 0; border-radius: 2px; transition: opacity 0.15s; }
|
||||||
|
.bar:hover { opacity: 0.8; }
|
||||||
|
.bars-meta { display: flex; justify-content: space-between; font-size: 0.7rem; color: var(--muted); margin-top: 0.4rem; }
|
||||||
|
/* Tooltip shown when hovering a bar. Single floating element per page, positioned by JS. */
|
||||||
|
#bar-tooltip { position: fixed; pointer-events: none; background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem 0.75rem; font-size: 0.75rem; color: var(--fg); box-shadow: 0 8px 24px rgba(0,0,0,0.3); z-index: 100; min-width: 160px; transform: translate(-50%, -100%); margin-top: -8px; display: none; }
|
||||||
|
#bar-tooltip .head { color: var(--muted); margin-bottom: 0.25rem; font-size: 0.7rem; }
|
||||||
|
#bar-tooltip .row { display: flex; justify-content: space-between; gap: 1rem; }
|
||||||
|
#bar-tooltip .row span:last-child { color: var(--fg); font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||||
|
#bar-tooltip .pct.good { color: var(--bar-up); }
|
||||||
|
#bar-tooltip .pct.warn { color: var(--bar-partial); }
|
||||||
|
#bar-tooltip .pct.bad { color: var(--bar-down); }
|
||||||
|
/* Multi-window uptime row: four labelled cells side by side. */
|
||||||
|
.monitor-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem 1.25rem; }
|
||||||
|
.monitor .bars { padding: 0 1.25rem; margin: 0 0 1rem; }
|
||||||
|
.regions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem; font-size: 0.75rem; }
|
||||||
|
.region { padding: 0.15rem 0.5rem; border-radius: 999px; border: 1px solid var(--border); }
|
||||||
|
.region.up { color: var(--green); border-color: rgba(16,185,129,0.3); }
|
||||||
|
.region.down { color: var(--red); border-color: rgba(239,68,68,0.3); }
|
||||||
|
.incidents { margin-bottom: 2rem; }
|
||||||
|
.incident { background: var(--card); border-left: 4px solid var(--bar-partial); border-radius: 8px; padding: 1rem 1.25rem; margin-bottom: 1rem; }
|
||||||
|
.incident.critical { border-left-color: var(--bar-down); }
|
||||||
|
.incident.major { border-left-color: var(--bar-partial); }
|
||||||
|
.incident.minor { border-left-color: var(--muted); }
|
||||||
|
.incident.resolved { border-left-color: var(--bar-up); opacity: 0.85; }
|
||||||
|
.incident-title { font-weight: 600; margin-bottom: 0.25rem; }
|
||||||
|
.incident-meta { color: var(--muted); font-size: 0.8rem; margin-bottom: 0.5rem; }
|
||||||
|
.incident-meta .pill { display: inline-block; padding: 0.05rem 0.4rem; border-radius: 999px; border: 1px solid var(--border); margin-right: 0.4rem; text-transform: capitalize; }
|
||||||
|
.incident-meta .pill.investigating { color: var(--bar-partial); border-color: rgba(245,158,11,0.3); }
|
||||||
|
.incident-meta .pill.identified { color: var(--bar-partial); border-color: rgba(245,158,11,0.3); }
|
||||||
|
.incident-meta .pill.monitoring { color: var(--accent); border-color: rgba(14,165,233,0.3); }
|
||||||
|
.incident-meta .pill.resolved { color: var(--bar-up); border-color: rgba(16,185,129,0.3); }
|
||||||
|
/* Full incident timeline (newest at top). One block per update. */
|
||||||
|
.incident-timeline { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--border); }
|
||||||
|
.incident-update { padding: 0.5rem 0; border-bottom: 1px dashed var(--border); }
|
||||||
|
.incident-update:last-child { border-bottom: none; padding-bottom: 0; }
|
||||||
|
.incident-update .head { font-size: 0.75rem; color: var(--muted); margin-bottom: 0.2rem; }
|
||||||
|
.incident-update .head .status { display: inline-block; padding: 0.05rem 0.4rem; border-radius: 4px; font-weight: 600; text-transform: capitalize; margin-right: 0.4rem; }
|
||||||
|
.incident-update .head .status.investigating { background: rgba(245,158,11,0.15); color: var(--bar-partial); }
|
||||||
|
.incident-update .head .status.identified { background: rgba(245,158,11,0.15); color: var(--bar-partial); }
|
||||||
|
.incident-update .head .status.monitoring { background: rgba(14,165,233,0.15); color: var(--accent); }
|
||||||
|
.incident-update .head .status.resolved { background: rgba(16,185,129,0.15); color: var(--bar-up); }
|
||||||
|
.incident-update .body { font-size: 0.875rem; }
|
||||||
|
.incident-update .body p { margin: 0.25rem 0; }
|
||||||
|
.incident-update .body code { background: var(--bg); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.85em; }
|
||||||
|
.past-incidents { margin-top: 3rem; }
|
||||||
|
.past-incidents h2 { font-size: 1.1rem; margin-bottom: 1rem; }
|
||||||
|
/* Past incidents - Atlassian-style: grouped by date with light typography. */
|
||||||
|
.past-day { margin-bottom: 2rem; }
|
||||||
|
.past-day-header { font-size: 0.95rem; font-weight: 600; color: var(--fg); padding-bottom: 0.5rem; border-bottom: 1px solid var(--border); margin: 0 0 1rem; }
|
||||||
|
.past-day-empty { font-size: 0.85rem; color: var(--muted); margin: 0.25rem 0 0; }
|
||||||
|
.past-item { margin-bottom: 1.25rem; }
|
||||||
|
.past-item:last-child { margin-bottom: 0; }
|
||||||
|
.past-item-title { font-size: 0.95rem; font-weight: 600; color: var(--fg); margin: 0 0 0.5rem; }
|
||||||
|
.past-update { font-size: 0.8rem; line-height: 1.55; color: var(--fg); padding-left: 0.75rem; border-left: 2px solid var(--border); margin-bottom: 0.5rem; }
|
||||||
|
.past-update:last-child { margin-bottom: 0; }
|
||||||
|
.past-update-status { font-weight: 600; text-transform: capitalize; }
|
||||||
|
.past-update-status.investigating { color: var(--bar-partial); }
|
||||||
|
.past-update-status.identified { color: var(--bar-partial); }
|
||||||
|
.past-update-status.monitoring { color: var(--accent); }
|
||||||
|
.past-update-status.resolved { color: var(--bar-up); }
|
||||||
|
.past-update-body { color: var(--fg); }
|
||||||
|
.past-update-body p { display: inline; margin: 0; }
|
||||||
|
.past-update-body code { background: var(--bg); padding: 0.05em 0.3em; border-radius: 3px; font-size: 0.85em; }
|
||||||
|
.past-update-time { color: var(--muted); font-size: 0.7rem; margin-top: 0.15rem; }
|
||||||
|
footer { padding-top: 2rem; color: var(--muted); font-size: 0.8rem; text-align: center; }
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Public status page client JS:
|
||||||
|
// Bar tooltips - hover any heartbeat bar to see the bucket time range and uptime breakdown.
|
||||||
|
// Expand/collapse is pure CSS (hidden checkbox + :checked sibling selector), no JS needed.
|
||||||
|
// Reads its config from window.PINGQL_PAGE.
|
||||||
|
(function () {
|
||||||
|
var cfg = window.PINGQL_PAGE || {};
|
||||||
|
var barFrequency = cfg.bar_frequency || "daily";
|
||||||
|
|
||||||
|
// ── Bar tooltips ──────────────────────────────────────────────────────
|
||||||
|
var tooltip = document.getElementById("bar-tooltip");
|
||||||
|
if (!tooltip) return;
|
||||||
|
|
||||||
|
var bucketSpanMs = (barFrequency === "hourly") ? 3600 * 1000 : 86400 * 1000;
|
||||||
|
function fmtBucketRange(startIso) {
|
||||||
|
var start = new Date(startIso);
|
||||||
|
var end = new Date(start.getTime() + bucketSpanMs);
|
||||||
|
var dateOpts = { month: "short", day: "numeric" };
|
||||||
|
var timeOpts = { hour: "2-digit", minute: "2-digit" };
|
||||||
|
if (barFrequency === "hourly") {
|
||||||
|
return start.toLocaleDateString(undefined, dateOpts) + ", " +
|
||||||
|
start.toLocaleTimeString(undefined, timeOpts) + " - " +
|
||||||
|
end.toLocaleTimeString(undefined, timeOpts);
|
||||||
|
}
|
||||||
|
return start.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
function uptimeBand(p) {
|
||||||
|
if (p == null) return "";
|
||||||
|
if (p >= 99.9) return "good";
|
||||||
|
if (p >= 99.0) return "warn";
|
||||||
|
return "bad";
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTooltipForBar(bar) {
|
||||||
|
var total = parseInt(bar.getAttribute("data-total") || "0", 10);
|
||||||
|
var up = parseInt(bar.getAttribute("data-up") || "0", 10);
|
||||||
|
var start = bar.getAttribute("data-start");
|
||||||
|
var latRaw = bar.getAttribute("data-latency");
|
||||||
|
var lat = latRaw == null ? null : parseInt(latRaw, 10);
|
||||||
|
if (!start) return;
|
||||||
|
// Full precision pct so the formatter can decide. Anything below 100% gets
|
||||||
|
// 2 truncated (not rounded) decimals - same rule as the page-level uptime
|
||||||
|
// numbers, so a bucket with one failed check never displays as "100%".
|
||||||
|
var pct = total > 0 ? (100 * up / total) : null;
|
||||||
|
var pctText;
|
||||||
|
if (pct == null) pctText = "-";
|
||||||
|
else if (pct >= 100) pctText = "100%";
|
||||||
|
else pctText = (Math.floor(pct * 100) / 100).toFixed(2) + "%";
|
||||||
|
var html = '<div class="head">' + fmtBucketRange(start) + "</div>";
|
||||||
|
if (total > 0) {
|
||||||
|
html += '<div class="row"><span>Checks</span><span>' + total + "</span></div>";
|
||||||
|
html += '<div class="row"><span>Successful</span><span>' + up + "</span></div>";
|
||||||
|
html += '<div class="row"><span>Uptime</span><span class="pct ' + uptimeBand(pct) + '">' + pctText + "</span></div>";
|
||||||
|
if (lat != null) {
|
||||||
|
html += '<div class="row"><span>Avg ping</span><span>' + lat + "ms</span></div>";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html += '<div class="row"><span>No data</span><span>-</span></div>';
|
||||||
|
}
|
||||||
|
tooltip.innerHTML = html;
|
||||||
|
tooltip.style.display = "block";
|
||||||
|
var rect = bar.getBoundingClientRect();
|
||||||
|
tooltip.style.left = (rect.left + rect.width / 2) + "px";
|
||||||
|
tooltip.style.top = rect.top + "px";
|
||||||
|
}
|
||||||
|
function hideTooltip() {
|
||||||
|
tooltip.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("mouseover", function (e) {
|
||||||
|
var bar = e.target && e.target.closest && e.target.closest(".bar");
|
||||||
|
if (bar && bar.parentElement && bar.parentElement.classList.contains("bars")) {
|
||||||
|
showTooltipForBar(bar);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener("mouseout", function (e) {
|
||||||
|
var bar = e.target && e.target.closest && e.target.closest(".bar");
|
||||||
|
if (bar) hideTooltip();
|
||||||
|
});
|
||||||
|
window.addEventListener("scroll", hideTooltip, { passive: true });
|
||||||
|
})();
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="6" fill="#0a0a0a"/>
|
||||||
|
<text x="3" y="23" font-family="system-ui, sans-serif" font-weight="700" font-size="18" fill="#e5e7eb">P</text>
|
||||||
|
<text x="14" y="23" font-family="system-ui, sans-serif" font-weight="700" font-size="18" fill="#3b82f6">Q</text>
|
||||||
|
<circle cx="26" cy="6" r="3" fill="#22c55e">
|
||||||
|
<animate attributeName="opacity" values="1;0.4;1" dur="2s" repeatCount="indefinite"/>
|
||||||
|
</circle>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 501 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en"><head><meta charset="UTF-8"><title>Not found</title>
|
||||||
|
<style>body { background:#0a0a0a; color:#94a3b8; font-family:-apple-system, sans-serif; display:flex; align-items:center; justify-content:center; min-height:100vh; margin:0; } .card{text-align:center} h1{color:#f1f5f9; margin:0 0 0.5rem; font-size:1.5rem} a{color:#38bdf8;text-decoration:none}</style>
|
||||||
|
</head><body><div class="card"><h1>Status page not found</h1><p>The page you're looking for doesn't exist.</p><p><a href="https://pingql.com">PingQL</a></p></div></body></html>
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
<%
|
||||||
|
const page = it.page;
|
||||||
|
const monitors = it.monitors;
|
||||||
|
const groups = it.groups;
|
||||||
|
const incidents = it.incidents;
|
||||||
|
const themeClass = page.theme === 'dark' ? 'dark' : page.theme === 'light' ? 'light' : '';
|
||||||
|
|
||||||
|
// Group monitors. group_id null = "ungrouped".
|
||||||
|
const grouped = {};
|
||||||
|
for (const m of monitors) {
|
||||||
|
const key = m.group_id || '';
|
||||||
|
if (!grouped[key]) grouped[key] = [];
|
||||||
|
grouped[key].push(m);
|
||||||
|
}
|
||||||
|
const groupOrder = [...groups.map(g => g.id), ''];
|
||||||
|
|
||||||
|
function fmtPct(p) {
|
||||||
|
if (p == null) return '-';
|
||||||
|
// Only show "100%" when the value is *exactly* 100. Anything below - even
|
||||||
|
// 99.9999% - must show 2 decimals so visitors can see there was downtime.
|
||||||
|
// Truncate (floor) rather than round, otherwise 99.9999 would render as
|
||||||
|
// "100.00" and silently swallow the downtime.
|
||||||
|
if (p >= 100) return '100%';
|
||||||
|
return (Math.floor(p * 100) / 100).toFixed(2) + '%';
|
||||||
|
}
|
||||||
|
function statusLabel(s) {
|
||||||
|
if (s === 'up') return 'Operational';
|
||||||
|
if (s === 'down') return 'Down';
|
||||||
|
if (s === 'paused') return 'Under maintenance';
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
function statusColor(s) {
|
||||||
|
if (s === 'up') return 'var(--bar-up)';
|
||||||
|
if (s === 'down') return 'var(--bar-down)';
|
||||||
|
if (s === 'paused') return 'var(--bar-paused)';
|
||||||
|
return 'var(--muted)';
|
||||||
|
}
|
||||||
|
function bucketColor(b) {
|
||||||
|
if (!b || b.total === 0) return 'var(--bar-empty)';
|
||||||
|
if (b.up === b.total) return 'var(--bar-up)';
|
||||||
|
if (b.up === 0) return 'var(--bar-down)';
|
||||||
|
return 'var(--bar-partial)';
|
||||||
|
}
|
||||||
|
function uptimeBand(p) {
|
||||||
|
if (p == null) return 'empty';
|
||||||
|
if (p >= 97) return 'good';
|
||||||
|
if (p >= 87) return 'warn';
|
||||||
|
return 'bad';
|
||||||
|
}
|
||||||
|
function fmtUptime(p) {
|
||||||
|
if (p == null) return '-';
|
||||||
|
// Only "100%" if the monitor was up for every single check in the window.
|
||||||
|
// Truncate (not round) below that so 99.9999% never displays as "100.00".
|
||||||
|
if (p >= 100) return '100%';
|
||||||
|
return (Math.floor(p * 100) / 100).toFixed(2) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overall status: down if any monitor is down, degraded if any partial, else up.
|
||||||
|
// Paused monitors are operator-declared maintenance - they don't count
|
||||||
|
// toward "down" or "degraded", but we surface a small note in the banner
|
||||||
|
// when at least one is in maintenance so visitors aren't confused.
|
||||||
|
let paused_count = 0;
|
||||||
|
let down_count = 0;
|
||||||
|
let active_count = 0;
|
||||||
|
let has_degraded = false;
|
||||||
|
for (const m of monitors) {
|
||||||
|
if (m.current_state === 'paused') { paused_count++; continue; }
|
||||||
|
active_count++;
|
||||||
|
const partial = m.region_states.some(r => r.state === 'down') && m.region_states.some(r => r.state === 'up');
|
||||||
|
if (m.current_state === 'down') down_count++;
|
||||||
|
else if (partial) has_degraded = true;
|
||||||
|
}
|
||||||
|
const overall = down_count === 0 && !has_degraded ? 'up'
|
||||||
|
: active_count > 0 && down_count === active_count ? 'down'
|
||||||
|
: 'degraded';
|
||||||
|
let overallText = overall === 'up' ? 'All systems operational'
|
||||||
|
: overall === 'down' ? 'Major outage in progress'
|
||||||
|
: 'Partial outage';
|
||||||
|
if (paused_count > 0) {
|
||||||
|
overallText += ' - ' + paused_count + (paused_count === 1 ? ' service' : ' services') + ' under maintenance';
|
||||||
|
}
|
||||||
|
const overallBg = overall === 'up' ? 'rgba(16,185,129,0.1)'
|
||||||
|
: overall === 'degraded' ? 'rgba(245,158,11,0.1)'
|
||||||
|
: 'rgba(239,68,68,0.1)';
|
||||||
|
const overallBorder = overall === 'up' ? 'rgba(16,185,129,0.25)'
|
||||||
|
: overall === 'degraded' ? 'rgba(245,158,11,0.25)'
|
||||||
|
: 'rgba(239,68,68,0.25)';
|
||||||
|
const overallFg = overall === 'up' ? 'var(--bar-up)'
|
||||||
|
: overall === 'degraded' ? 'var(--bar-partial)'
|
||||||
|
: 'var(--bar-down)';
|
||||||
|
const dotColor = overall === 'up' ? '#10b981'
|
||||||
|
: overall === 'degraded' ? '#f59e0b'
|
||||||
|
: '#ef4444';
|
||||||
|
const dotGlow = overall === 'up' ? 'rgba(16,185,129,0.4)'
|
||||||
|
: overall === 'degraded' ? 'rgba(245,158,11,0.4)'
|
||||||
|
: 'rgba(239,68,68,0.4)';
|
||||||
|
|
||||||
|
%><!DOCTYPE html>
|
||||||
|
<html lang="en" class="<%= themeClass %>">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title><%= page.title %></title>
|
||||||
|
<% if (page.description) { %><meta name="description" content="<%= page.description %>"><% } %>
|
||||||
|
<% if (!page.index_search) { %><meta name="robots" content="noindex,nofollow"><% } %>
|
||||||
|
<meta property="og:title" content="<%= page.title %>">
|
||||||
|
<% if (page.description) { %><meta property="og:description" content="<%= page.description %>"><% } %>
|
||||||
|
<link rel="icon" href="/_static/favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="<%= page.title %> incidents" href="/<%= page.slug %>.rss">
|
||||||
|
<link rel="manifest" href="/<%= page.slug %>/manifest.json">
|
||||||
|
<link rel="stylesheet" href="/_static/app.css?v=<%= it.appCssHash %>">
|
||||||
|
<% if (page.custom_css) { %><style><%~ page.custom_css %></style><% } %>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<div class="title-row">
|
||||||
|
<h1><%= page.title %></h1>
|
||||||
|
<a class="json-link" href="/<%= page.slug %>.json" title="JSON API" aria-label="JSON API">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M10 4a3 3 0 0 0-3 3v6a3 3 0 0 1-3 3 3 3 0 0 1 3 3v6a3 3 0 0 0 3 3"/>
|
||||||
|
<path d="M22 28a3 3 0 0 0 3-3v-6a3 3 0 0 1 3-3 3 3 0 0 1-3-3V7a3 3 0 0 0-3-3"/>
|
||||||
|
<circle cx="16" cy="12.5" r="1.4" fill="currentColor" stroke="none"/>
|
||||||
|
<circle cx="16" cy="18" r="1.4" fill="currentColor" stroke="none"/>
|
||||||
|
<line x1="16" y1="18" x2="14.8" y2="21" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a class="rss-link" href="/<%= page.slug %>.rss" title="Subscribe via RSS" aria-label="Subscribe via RSS">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<path d="M4 11a9 9 0 0 1 9 9"/>
|
||||||
|
<path d="M4 4a16 16 0 0 1 16 16"/>
|
||||||
|
<circle cx="5" cy="19" r="1.5" fill="currentColor" stroke="none"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<% if (page.description) { %><div class="muted"><%= page.description %></div><% } %>
|
||||||
|
|
||||||
|
<div class="overall" style="background: <%= overallBg %>; border-color: <%= overallBorder %>; color: <%= overallFg %>;">
|
||||||
|
<span class="dot" style="background: <%= dotColor %>; box-shadow: 0 0 8px <%= dotGlow %>;"></span>
|
||||||
|
<span><%= overallText %></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (incidents.active.length > 0) { %>
|
||||||
|
<div class="incidents">
|
||||||
|
<% incidents.active.forEach(function(i) { %>
|
||||||
|
<div id="incident-<%= i.id %>" class="incident <%= i.severity %><%= i.resolved_at ? ' resolved' : '' %>">
|
||||||
|
<div class="incident-title"><%= i.title %></div>
|
||||||
|
<div class="incident-meta">
|
||||||
|
<span class="pill <%= i.status %>"><%= i.status %></span>
|
||||||
|
Started <%= fmtTimestamp(i.started_at) %>
|
||||||
|
<% if (i.resolved_at) { %> · Resolved <%= fmtTimestamp(i.resolved_at) %><% } %>
|
||||||
|
</div>
|
||||||
|
<% if (i.updates && i.updates.length > 0) { %>
|
||||||
|
<div class="incident-timeline">
|
||||||
|
<% i.updates.forEach(function(u) { %>
|
||||||
|
<div class="incident-update">
|
||||||
|
<div class="head">
|
||||||
|
<span class="status <%= u.status %>"><%= u.status %></span>
|
||||||
|
<span><%= fmtTimestamp(u.created_at) %></span>
|
||||||
|
</div>
|
||||||
|
<div class="body"><%~ u.body_html %></div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<%
|
||||||
|
const windowLabel = page.bar_frequency === 'hourly'
|
||||||
|
? ('Last ' + page.bar_count + ' hour' + (page.bar_count === 1 ? '' : 's'))
|
||||||
|
: ('Last ' + page.bar_count + ' day' + (page.bar_count === 1 ? '' : 's'));
|
||||||
|
function fmtTimestamp(iso) {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
%>
|
||||||
|
<div class="page-components">
|
||||||
|
<% groupOrder.forEach(function(gid, gi) {
|
||||||
|
const list = grouped[gid];
|
||||||
|
if (!list || list.length === 0) return;
|
||||||
|
const groupName = gid ? (groups.find(g => g.id === gid)?.name || '') : '';
|
||||||
|
// Aggregate status for the group header
|
||||||
|
const activeInGroup = list.filter(m => m.current_state !== 'paused');
|
||||||
|
const downInGroup = activeInGroup.filter(m => m.current_state === 'down').length;
|
||||||
|
const groupStatus = activeInGroup.length === 0 ? 'unknown' : downInGroup === activeInGroup.length ? 'down' : downInGroup > 0 ? 'degraded' : 'up';
|
||||||
|
const groupStatusLabel = groupStatus === 'up' ? 'Operational' : groupStatus === 'degraded' ? 'Degraded' : groupStatus === 'down' ? 'Down' : '';
|
||||||
|
const groupStatusColor = groupStatus === 'up' ? 'var(--bar-up)' : groupStatus === 'degraded' ? 'var(--bar-partial)' : groupStatus === 'down' ? 'var(--bar-down)' : 'var(--muted)';
|
||||||
|
%>
|
||||||
|
<% if (groupName) {
|
||||||
|
// Aggregate buckets across all monitors in the group (weighted average)
|
||||||
|
const aggBuckets = [];
|
||||||
|
const firstWithBuckets = list.find(m => m.buckets && m.buckets.length > 0);
|
||||||
|
if (firstWithBuckets) {
|
||||||
|
for (let bi = 0; bi < firstWithBuckets.buckets.length; bi++) {
|
||||||
|
let t = 0, u = 0, latSum = 0, latN = 0;
|
||||||
|
for (const m of list) {
|
||||||
|
if (!m.buckets || !m.buckets[bi]) continue;
|
||||||
|
t += m.buckets[bi].total;
|
||||||
|
u += m.buckets[bi].up;
|
||||||
|
if (m.buckets[bi].avg_latency != null) { latSum += m.buckets[bi].avg_latency * m.buckets[bi].total; latN += m.buckets[bi].total; }
|
||||||
|
}
|
||||||
|
aggBuckets.push({ start: firstWithBuckets.buckets[bi].start, total: t, up: u, avg_latency: latN > 0 ? Math.round(latSum / latN) : null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const aggTotal = aggBuckets.reduce((a, b) => a + b.total, 0);
|
||||||
|
const aggUp = aggBuckets.reduce((a, b) => a + b.up, 0);
|
||||||
|
const aggPct = aggTotal > 0 ? (100 * aggUp / aggTotal) : null;
|
||||||
|
%>
|
||||||
|
<div class="group-section">
|
||||||
|
<input type="checkbox" class="group-toggle" id="g-<%= gi %>">
|
||||||
|
<label class="group-header" for="g-<%= gi %>">
|
||||||
|
<div class="monitor-name">
|
||||||
|
<span class="dot" style="background: <%= groupStatusColor %>;"></span>
|
||||||
|
<span class="name"><%= groupName %></span>
|
||||||
|
</div>
|
||||||
|
<div class="monitor-meta">
|
||||||
|
<span><%= list.length %> monitor<%= list.length === 1 ? '' : 's' %></span>
|
||||||
|
<span class="uptime-pct <%= uptimeBand(aggPct) %>"><%= fmtUptime(aggPct) %></span>
|
||||||
|
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<div class="group-aggregate">
|
||||||
|
<div class="bars" aria-label="Group uptime">
|
||||||
|
<% aggBuckets.forEach(function(b) { %>
|
||||||
|
<div class="bar" style="background: <%= bucketColor(b) %>;" data-start="<%= b.start %>" data-total="<%= b.total %>" data-up="<%= b.up %>"<% if (b.avg_latency != null) { %> data-latency="<%= b.avg_latency %>"<% } %>></div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="group-body">
|
||||||
|
<% } %>
|
||||||
|
<% const inGroup = !!groupName; %>
|
||||||
|
<div class="monitors">
|
||||||
|
<% list.forEach(function(m) {
|
||||||
|
const buckets = m.buckets || [];
|
||||||
|
%>
|
||||||
|
<div class="monitor" data-monitor-id="<%= m.id %>">
|
||||||
|
<div class="monitor-header">
|
||||||
|
<div class="monitor-name">
|
||||||
|
<span class="dot" style="background: <%= statusColor(m.current_state) %>;"></span>
|
||||||
|
<span class="name"><%= m.display_name %></span>
|
||||||
|
</div>
|
||||||
|
<div class="monitor-meta">
|
||||||
|
<% if (m.current_state === 'paused') { %>
|
||||||
|
<span class="maintenance-pill" title="This service is paused for maintenance - checks are not running.">Maintenance</span>
|
||||||
|
<% } else { %>
|
||||||
|
<% if (page.show_response_time && m.avg_latency != null) { %><span><%= m.avg_latency %>ms</span><% } %>
|
||||||
|
<span class="uptime-pct <%= uptimeBand(m.uptime_pct) %>"><%= fmtUptime(m.uptime_pct) %></span>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bars" aria-label="<%= statusLabel(m.current_state) %>">
|
||||||
|
<% buckets.forEach(function(b) { %>
|
||||||
|
<div class="bar" style="background: <%= bucketColor(b) %>;" data-start="<%= b.start %>" data-total="<%= b.total %>" data-up="<%= b.up %>"<% if (b.avg_latency != null) { %> data-latency="<%= b.avg_latency %>"<% } %>></div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
<% if (groupName) { %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (incidents.recent.length > 0) { %>
|
||||||
|
<%
|
||||||
|
// Group past incidents by their started_at date (Y-M-D in local time).
|
||||||
|
// Atlassian-style: each date is its own section, incidents listed below.
|
||||||
|
var pastByDate = {};
|
||||||
|
var pastOrder = [];
|
||||||
|
for (var pi = 0; pi < incidents.recent.length; pi++) {
|
||||||
|
var inc = incidents.recent[pi];
|
||||||
|
var d = new Date(inc.started_at);
|
||||||
|
var key = d.getFullYear() + '-' + (d.getMonth()+1) + '-' + d.getDate();
|
||||||
|
if (!pastByDate[key]) {
|
||||||
|
pastByDate[key] = { date: d, items: [] };
|
||||||
|
pastOrder.push(key);
|
||||||
|
}
|
||||||
|
pastByDate[key].items.push(inc);
|
||||||
|
}
|
||||||
|
%>
|
||||||
|
<div class="past-incidents">
|
||||||
|
<h2>Past incidents</h2>
|
||||||
|
<% pastOrder.forEach(function(k) {
|
||||||
|
var day = pastByDate[k];
|
||||||
|
var dateLabel = day.date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
%>
|
||||||
|
<div class="past-day">
|
||||||
|
<h3 class="past-day-header"><%= dateLabel %></h3>
|
||||||
|
<% day.items.forEach(function(i) { %>
|
||||||
|
<div id="incident-<%= i.id %>" class="past-item">
|
||||||
|
<div class="past-item-title"><%= i.title %></div>
|
||||||
|
<% if (i.updates && i.updates.length > 0) { %>
|
||||||
|
<% i.updates.forEach(function(u) { %>
|
||||||
|
<div class="past-update">
|
||||||
|
<span class="past-update-status <%= u.status %>"><%= u.status %></span>
|
||||||
|
<span class="past-update-body"> - <%~ u.body_html %></span>
|
||||||
|
<div class="past-update-time"><%= fmtTimestamp(u.created_at) %></div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<% if (page.footer_text) { %><div><%= page.footer_text %></div><% } %>
|
||||||
|
<% if (page.show_powered_by) { %><div>Status powered by <a href="https://pingql.com">PingQL</a></div><% } %>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="bar-tooltip" role="tooltip"></div>
|
||||||
|
<script>window.PINGQL_PAGE = <%~ JSON.stringify({ slug: page.slug, bar_frequency: page.bar_frequency, show_response_time: !!page.show_response_time }) %>;</script>
|
||||||
|
<script src="/_static/expand.js?v=<%= it.expandJsHash %>" defer></script>
|
||||||
|
|
||||||
|
<% if (page.auto_refresh_s > 0) { %>
|
||||||
|
<script>
|
||||||
|
// Auto-refresh data without a full page reload. Polls /<slug>.json and
|
||||||
|
// patches just the bar/uptime/dot DOM nodes.
|
||||||
|
(function() {
|
||||||
|
const slug = <%~ JSON.stringify(page.slug) %>;
|
||||||
|
const intervalMs = Math.max(10, <%= page.auto_refresh_s %>) * 1000;
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/' + slug + '.json', { cache: 'no-store' });
|
||||||
|
if (!r.ok) return;
|
||||||
|
// Reload on next idle for simplicity. The JSON payload is already cached
|
||||||
|
// server-side; the visible diff is small.
|
||||||
|
location.reload();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
setTimeout(refresh, intervalMs);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<% } %>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex,nofollow">
|
||||||
|
<title>Password required</title>
|
||||||
|
<style>
|
||||||
|
body { background: #0a0a0a; color: #f1f5f9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
||||||
|
.card { background: #111827; border: 1px solid #1f2937; border-radius: 12px; padding: 2rem; max-width: 400px; width: 90%; }
|
||||||
|
h1 { font-size: 1.25rem; margin: 0 0 0.5rem; }
|
||||||
|
p { color: #94a3b8; font-size: 0.875rem; margin: 0 0 1.5rem; }
|
||||||
|
input { width: 100%; padding: 0.75rem 1rem; background: #0a0a0a; border: 1px solid #374151; border-radius: 8px; color: #f1f5f9; font-size: 1rem; box-sizing: border-box; }
|
||||||
|
input:focus { outline: none; border-color: #38bdf8; }
|
||||||
|
button { width: 100%; margin-top: 1rem; padding: 0.75rem; background: #0ea5e9; color: white; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; font-size: 1rem; }
|
||||||
|
button:hover { background: #0284c7; }
|
||||||
|
.err { color: #ef4444; font-size: 0.85rem; margin-top: 0.75rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Password required</h1>
|
||||||
|
<p>This status page is password protected.</p>
|
||||||
|
<form method="POST" action="/<%= it.slug %>/auth">
|
||||||
|
<input type="password" name="password" placeholder="Password" autofocus required>
|
||||||
|
<button type="submit">Unlock</button>
|
||||||
|
<% if (it.error) { %><div class="err"><%= it.error %></div><% } %>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"target": "ESNext",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["bun"],
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# web
|
|
||||||
|
|
||||||
To install dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun install
|
|
||||||
```
|
|
||||||
|
|
||||||
To run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
This project was created using `bun init` in bun v1.3.10. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
console.log("Hello via Bun!");
|
|
||||||
@@ -4,7 +4,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --hot src/index.ts",
|
"dev": "bun run --hot src/index.ts",
|
||||||
"start": "bun run src/index.ts",
|
"start": "bun run src/index.ts",
|
||||||
"build": "bun build src/index.ts --outdir dist"
|
"build": "bun build src/index.ts --outdir dist",
|
||||||
|
"css": "bunx @tailwindcss/cli -i src/tailwind.css -o src/dashboard/tailwind.css --minify",
|
||||||
|
"css:watch": "bunx @tailwindcss/cli -i src/tailwind.css -o src/dashboard/tailwind.css --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@elysiajs/cors": "^1.4.1",
|
"@elysiajs/cors": "^1.4.1",
|
||||||
@@ -13,7 +15,9 @@
|
|||||||
"postgres": "^3.4.8"
|
"postgres": "^3.4.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/cli": "^4.2.1",
|
||||||
"@types/bun": "^1.3.10",
|
"@types/bun": "^1.3.10",
|
||||||
|
"tailwindcss": "^4.2.1",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,3 +2,29 @@ body {
|
|||||||
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace;
|
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace;
|
||||||
background: #0a0a0a;
|
background: #0a0a0a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Dashboard card overrides ─── */
|
||||||
|
.monitor-card {
|
||||||
|
background: #141418;
|
||||||
|
border: 1px solid #232329;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||||
|
}
|
||||||
|
.monitor-card:hover {
|
||||||
|
border-color: #3b82f680;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5), 0 0 16px rgba(59, 130, 246, 0.06);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Stat cards ─── */
|
||||||
|
.stat-card {
|
||||||
|
background: #141418;
|
||||||
|
border: 1px solid #232329;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Table rows ─── */
|
||||||
|
.table-row-alt:nth-child(even) {
|
||||||
|
background: #0e0e12;
|
||||||
|
}
|
||||||
|
.table-row-alt:hover {
|
||||||
|
background: #1a1a1f;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
// PingQL Dashboard — shared utilities
|
const API_BASE = 'https://api.pingql.com';
|
||||||
// Auth is now cookie-based. No localStorage needed.
|
|
||||||
|
|
||||||
const API_BASE = window.location.origin;
|
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
window.location.href = '/dashboard/logout';
|
window.location.href = '/dashboard/logout';
|
||||||
}
|
}
|
||||||
|
|
||||||
// requireAuth is a no-op now — server redirects to /dashboard if not authed
|
|
||||||
function requireAuth() { return true; }
|
function requireAuth() { return true; }
|
||||||
|
|
||||||
async function api(path, opts = {}) {
|
async function api(path, opts = {}) {
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
...opts,
|
...opts,
|
||||||
credentials: 'same-origin', // send cookie automatically
|
credentials: 'include', // send cookie cross-origin to api.pingql.com
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...opts.headers,
|
...opts.headers,
|
||||||
@@ -29,9 +25,8 @@ async function api(path, opts = {}) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format relative time
|
|
||||||
function formatAgo(ms) {
|
function formatAgo(ms) {
|
||||||
const s = Math.floor(ms / 1000);
|
const s = Math.max(0, Math.floor(ms / 1000));
|
||||||
if (s < 60) return `${s}s ago`;
|
if (s < 60) return `${s}s ago`;
|
||||||
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||||||
if (s < 86400)return `${Math.floor(s / 3600)}h ago`;
|
if (s < 86400)return `${Math.floor(s / 3600)}h ago`;
|
||||||
@@ -44,13 +39,12 @@ function timeAgo(date) {
|
|||||||
return `<span class="timestamp" data-ts="${ts}">${formatAgo(elapsed)}</span>`;
|
return `<span class="timestamp" data-ts="${ts}">${formatAgo(elapsed)}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tick all live timestamps every second
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
document.querySelectorAll('.timestamp[data-ts]').forEach(el => {
|
document.querySelectorAll('.timestamp[data-ts]').forEach(el => {
|
||||||
const elapsed = Date.now() - Number(el.dataset.ts);
|
const elapsed = Date.now() - Number(el.dataset.ts);
|
||||||
el.textContent = formatAgo(elapsed);
|
el.textContent = formatAgo(elapsed);
|
||||||
});
|
});
|
||||||
}, 1000);
|
}, 100);
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
@@ -58,16 +52,13 @@ function escapeHtml(str) {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to live ping updates for the whole account via a single SSE stream.
|
|
||||||
// onPing receives each ping object (includes monitor_id).
|
|
||||||
// Returns an AbortController — call .abort() to close.
|
|
||||||
function watchAccount(onPing) {
|
function watchAccount(onPing) {
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
|
|
||||||
async function connect() {
|
async function connect() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/account/stream`, {
|
const res = await fetch(`${API_BASE}/account/stream`, {
|
||||||
credentials: 'same-origin',
|
credentials: 'include',
|
||||||
signal: ac.signal,
|
signal: ac.signal,
|
||||||
});
|
});
|
||||||
if (!res.ok || !res.body) return;
|
if (!res.ok || !res.body) return;
|
||||||
@@ -82,7 +73,13 @@ function watchAccount(onPing) {
|
|||||||
buf = lines.pop() ?? '';
|
buf = lines.pop() ?? '';
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith('data: ')) {
|
if (line.startsWith('data: ')) {
|
||||||
try { onPing(JSON.parse(line.slice(6))); } catch {}
|
try {
|
||||||
|
const ping = JSON.parse(line.slice(6));
|
||||||
|
if (ping.checked_at && new Date(ping.checked_at).getTime() > Date.now()) {
|
||||||
|
ping.checked_at = new Date().toISOString();
|
||||||
|
}
|
||||||
|
onPing(ping);
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,3 +92,55 @@ function watchAccount(onPing) {
|
|||||||
connect();
|
connect();
|
||||||
return ac;
|
return ac;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Status page edit form: Change/Remove flow for the password field. The
|
||||||
|
// section is only present when an existing page already has a password set;
|
||||||
|
// the helper is a no-op on every other page. Lives here (rather than inline
|
||||||
|
// in the .eta template) because Eta's parser chokes on stray `%` characters
|
||||||
|
// inside <script> blocks - see feedback_eta_no_inline_js.
|
||||||
|
function initPasswordSection() {
|
||||||
|
var section = document.querySelector('[data-password-section]');
|
||||||
|
if (!section) return;
|
||||||
|
var locked = section.querySelector('[data-password-locked]');
|
||||||
|
var inputWrap = section.querySelector('[data-password-input-wrap]');
|
||||||
|
var input = section.querySelector('[data-password-input]');
|
||||||
|
var removing = section.querySelector('[data-password-removing]');
|
||||||
|
var removeFlag = section.querySelector('[data-remove-password-flag]');
|
||||||
|
if (!locked || !inputWrap || !input || !removing || !removeFlag) return;
|
||||||
|
|
||||||
|
var change = section.querySelector('[data-password-change]');
|
||||||
|
var cancel = section.querySelector('[data-password-cancel]');
|
||||||
|
var remove = section.querySelector('[data-password-remove]');
|
||||||
|
var undoRemove = section.querySelector('[data-password-undo-remove]');
|
||||||
|
|
||||||
|
if (change) change.addEventListener('click', function() {
|
||||||
|
locked.classList.add('hidden');
|
||||||
|
inputWrap.classList.remove('hidden');
|
||||||
|
removing.classList.add('hidden');
|
||||||
|
removeFlag.value = '';
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
if (cancel) cancel.addEventListener('click', function() {
|
||||||
|
inputWrap.classList.add('hidden');
|
||||||
|
locked.classList.remove('hidden');
|
||||||
|
input.value = '';
|
||||||
|
});
|
||||||
|
if (remove) remove.addEventListener('click', function() {
|
||||||
|
locked.classList.add('hidden');
|
||||||
|
inputWrap.classList.add('hidden');
|
||||||
|
removing.classList.remove('hidden');
|
||||||
|
removeFlag.value = '1';
|
||||||
|
input.value = '';
|
||||||
|
});
|
||||||
|
if (undoRemove) undoRemove.addEventListener('click', function() {
|
||||||
|
removing.classList.add('hidden');
|
||||||
|
locked.classList.remove('hidden');
|
||||||
|
removeFlag.value = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initPasswordSection);
|
||||||
|
} else {
|
||||||
|
initPasswordSection();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,291 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>PingQL — Monitor Detail</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<style>
|
|
||||||
body { font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace; background: #0a0a0a; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="bg-[#0a0a0a] text-gray-100 min-h-screen">
|
|
||||||
<script src="/dashboard/app.js"></script>
|
|
||||||
<script src="/dashboard/query-builder.js"></script>
|
|
||||||
|
|
||||||
<!-- Nav -->
|
|
||||||
<nav class="border-b border-gray-800 px-6 py-4 flex items-center justify-between">
|
|
||||||
<a href="/dashboard/home" class="text-xl font-bold tracking-tight">Ping<span class="text-blue-400">QL</span></a>
|
|
||||||
<button onclick="logout()" class="text-gray-500 hover:text-gray-300 text-sm transition-colors">Logout</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="max-w-4xl mx-auto px-6 py-8">
|
|
||||||
<div class="mb-6">
|
|
||||||
<a href="/dashboard/home" class="text-sm text-gray-500 hover:text-gray-300 transition-colors">← Back</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="loading" class="text-center py-16 text-gray-600">Loading...</div>
|
|
||||||
<div id="content" class="hidden">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex items-center justify-between mb-8">
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span id="status-dot"></span>
|
|
||||||
<h2 id="monitor-name" class="text-xl font-semibold text-gray-100"></h2>
|
|
||||||
</div>
|
|
||||||
<p id="monitor-url" class="text-sm text-gray-500 mt-1"></p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button id="toggle-btn" class="text-sm px-4 py-2 rounded-lg border border-gray-700 hover:border-gray-600 transition-colors"></button>
|
|
||||||
<button id="delete-btn" class="text-sm px-4 py-2 rounded-lg border border-red-900/50 text-red-400 hover:bg-red-900/20 transition-colors">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stats row -->
|
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
|
|
||||||
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
|
||||||
<div class="text-xs text-gray-500 mb-1">Status</div>
|
|
||||||
<div id="stat-status" class="text-lg font-semibold"></div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
|
||||||
<div class="text-xs text-gray-500 mb-1">Avg Latency</div>
|
|
||||||
<div id="stat-latency" class="text-lg font-semibold text-gray-200"></div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
|
||||||
<div class="text-xs text-gray-500 mb-1">Uptime</div>
|
|
||||||
<div id="stat-uptime" class="text-lg font-semibold text-gray-200"></div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
|
||||||
<div class="text-xs text-gray-500 mb-1">Last Ping</div>
|
|
||||||
<div id="stat-last" class="text-lg font-semibold text-gray-200"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status history chart -->
|
|
||||||
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4 mb-8">
|
|
||||||
<h3 class="text-sm text-gray-400 mb-3">Response Time</h3>
|
|
||||||
<div id="latency-chart" class="h-32"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status bar (up/down timeline) -->
|
|
||||||
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4 mb-8">
|
|
||||||
<h3 class="text-sm text-gray-400 mb-3">Status History</h3>
|
|
||||||
<div id="status-bar" class="flex gap-0.5 h-8 rounded overflow-hidden"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Recent pings table -->
|
|
||||||
<div class="bg-gray-900 border border-gray-800 rounded-xl mb-8 overflow-hidden">
|
|
||||||
<div class="px-4 py-3 border-b border-gray-800">
|
|
||||||
<h3 class="text-sm text-gray-400">Recent Pings</h3>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr class="text-gray-500 text-xs">
|
|
||||||
<th class="text-left px-4 py-2 font-medium">Status</th>
|
|
||||||
<th class="text-left px-4 py-2 font-medium">Code</th>
|
|
||||||
<th class="text-left px-4 py-2 font-medium">Latency</th>
|
|
||||||
<th class="text-left px-4 py-2 font-medium">Time</th>
|
|
||||||
<th class="text-left px-4 py-2 font-medium">Error</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="pings-table" class="divide-y divide-gray-800/50"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Edit form -->
|
|
||||||
<div class="bg-gray-900 border border-gray-800 rounded-xl p-6">
|
|
||||||
<h3 class="text-sm text-gray-400 mb-4">Edit Monitor</h3>
|
|
||||||
<form id="edit-form" class="space-y-4">
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Name</label>
|
|
||||||
<input id="edit-name" type="text" class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 text-sm">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">URL</label>
|
|
||||||
<input id="edit-url" type="url" class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 text-sm">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Interval</label>
|
|
||||||
<select id="edit-interval" class="bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 text-sm">
|
|
||||||
<option value="10">10s</option>
|
|
||||||
<option value="30">30s</option>
|
|
||||||
<option value="60">1m</option>
|
|
||||||
<option value="300">5m</option>
|
|
||||||
<option value="600">10m</option>
|
|
||||||
<option value="1800">30m</option>
|
|
||||||
<option value="3600">1h</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Query</label>
|
|
||||||
<div id="edit-query-builder"></div>
|
|
||||||
</div>
|
|
||||||
<div id="edit-error" class="text-red-400 text-sm hidden"></div>
|
|
||||||
<button type="submit" class="bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium px-6 py-2.5 rounded-lg transition-colors">Save Changes</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
if (!requireAuth()) throw 'auth';
|
|
||||||
|
|
||||||
const monitorId = window.location.pathname.split('/').pop();
|
|
||||||
let editQuery = null;
|
|
||||||
|
|
||||||
const editQb = new QueryBuilder(document.getElementById('edit-query-builder'), (q) => {
|
|
||||||
editQuery = q;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
const data = await api(`/monitors/${monitorId}`);
|
|
||||||
document.getElementById('loading').classList.add('hidden');
|
|
||||||
document.getElementById('content').classList.remove('hidden');
|
|
||||||
|
|
||||||
const results = data.results || [];
|
|
||||||
const lastPing = results[0];
|
|
||||||
|
|
||||||
// Header
|
|
||||||
document.getElementById('monitor-name').textContent = data.name;
|
|
||||||
document.getElementById('monitor-url').textContent = data.url;
|
|
||||||
document.getElementById('status-dot').innerHTML = statusBadge(lastPing?.up);
|
|
||||||
|
|
||||||
// Toggle button
|
|
||||||
const toggleBtn = document.getElementById('toggle-btn');
|
|
||||||
toggleBtn.textContent = data.enabled ? 'Pause' : 'Resume';
|
|
||||||
toggleBtn.className = `text-sm px-4 py-2 rounded-lg border transition-colors ${data.enabled ? 'border-gray-700 hover:border-gray-600 text-gray-300' : 'border-green-800 hover:border-green-700 text-green-400'}`;
|
|
||||||
toggleBtn.onclick = async () => {
|
|
||||||
await api(`/monitors/${monitorId}/toggle`, { method: 'POST' });
|
|
||||||
load();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Delete button
|
|
||||||
document.getElementById('delete-btn').onclick = async () => {
|
|
||||||
if (!confirm('Delete this monitor and all its ping history?')) return;
|
|
||||||
await api(`/monitors/${monitorId}`, { method: 'DELETE' });
|
|
||||||
window.location.href = '/dashboard/home';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Stats
|
|
||||||
const upPings = results.filter(r => r.up);
|
|
||||||
const latencies = results.filter(r => r.latency_ms != null).map(r => r.latency_ms);
|
|
||||||
const avgLatency = latencies.length ? Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length) : null;
|
|
||||||
const uptime = results.length ? Math.round((upPings.length / results.length) * 100) : null;
|
|
||||||
|
|
||||||
document.getElementById('stat-status').innerHTML = lastPing
|
|
||||||
? (lastPing.up ? '<span class="text-green-400">Up</span>' : '<span class="text-red-400">Down</span>')
|
|
||||||
: '<span class="text-gray-500">—</span>';
|
|
||||||
document.getElementById('stat-latency').textContent = avgLatency != null ? `${avgLatency}ms` : '—';
|
|
||||||
document.getElementById('stat-uptime').textContent = uptime != null ? `${uptime}%` : '—';
|
|
||||||
document.getElementById('stat-last').textContent = lastPing ? timeAgo(lastPing.checked_at) : '—';
|
|
||||||
|
|
||||||
// Latency chart
|
|
||||||
renderLatencyChart(results.slice().reverse());
|
|
||||||
|
|
||||||
// Status bar
|
|
||||||
const statusBar = document.getElementById('status-bar');
|
|
||||||
const barPings = results.slice(0, 60).reverse();
|
|
||||||
statusBar.innerHTML = barPings.map(c =>
|
|
||||||
`<div class="flex-1 ${c.up ? 'bg-green-500/70' : 'bg-red-500/70'}" title="${new Date(c.checked_at).toLocaleString()} — ${c.up ? 'Up' : 'Down'} ${c.latency_ms ? c.latency_ms + 'ms' : ''}"></div>`
|
|
||||||
).join('') || '<div class="flex-1 bg-gray-800 text-center text-xs text-gray-600 leading-8">No data</div>';
|
|
||||||
|
|
||||||
// Pings table
|
|
||||||
document.getElementById('pings-table').innerHTML = results.slice(0, 30).map(c => `
|
|
||||||
<tr class="hover:bg-gray-800/50">
|
|
||||||
<td class="px-4 py-2">${c.up ? '<span class="text-green-400">Up</span>' : '<span class="text-red-400">Down</span>'}</td>
|
|
||||||
<td class="px-4 py-2 text-gray-300">${c.status_code ?? '—'}</td>
|
|
||||||
<td class="px-4 py-2 text-gray-300">${c.latency_ms != null ? c.latency_ms + 'ms' : '—'}</td>
|
|
||||||
<td class="px-4 py-2 text-gray-500">${timeAgo(c.checked_at)}</td>
|
|
||||||
<td class="px-4 py-2 text-red-400/70 text-xs truncate max-w-[200px]">${c.error ? escapeHtml(c.error) : ''}</td>
|
|
||||||
</tr>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
// Edit form
|
|
||||||
document.getElementById('edit-name').value = data.name;
|
|
||||||
document.getElementById('edit-url').value = data.url;
|
|
||||||
document.getElementById('edit-interval').value = String(data.interval_s);
|
|
||||||
editQuery = data.query;
|
|
||||||
editQb.setQuery(data.query);
|
|
||||||
} catch (e) {
|
|
||||||
document.getElementById('loading').innerHTML = `<span class="text-red-400">${escapeHtml(e.message)}</span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLatencyChart(pings) {
|
|
||||||
const container = document.getElementById('latency-chart');
|
|
||||||
const data = pings.filter(c => c.latency_ms != null);
|
|
||||||
if (data.length < 2) {
|
|
||||||
container.innerHTML = '<div class="h-full flex items-center justify-center text-gray-600 text-sm">Not enough data</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const values = data.map(c => c.latency_ms);
|
|
||||||
const ups = data.map(c => c.up);
|
|
||||||
const max = Math.max(...values, 1);
|
|
||||||
const min = Math.min(...values, 0);
|
|
||||||
const range = max - min || 1;
|
|
||||||
const w = container.clientWidth || 600;
|
|
||||||
const h = 128;
|
|
||||||
const step = w / Math.max(values.length - 1, 1);
|
|
||||||
|
|
||||||
const points = values.map((v, i) => {
|
|
||||||
const x = i * step;
|
|
||||||
const y = h - ((v - min) / range) * (h - 16) - 8;
|
|
||||||
return [x, y];
|
|
||||||
});
|
|
||||||
|
|
||||||
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0]},${p[1]}`).join(' ');
|
|
||||||
const areaD = pathD + ` L${points[points.length - 1][0]},${h} L${points[0][0]},${h} Z`;
|
|
||||||
|
|
||||||
// Dots for down events
|
|
||||||
const dots = points.map((p, i) =>
|
|
||||||
!ups[i] ? `<circle cx="${p[0]}" cy="${p[1]}" r="3" fill="#f87171"/>` : ''
|
|
||||||
).join('');
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<svg width="${w}" height="${h}" class="w-full">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.15"/>
|
|
||||||
<stop offset="100%" stop-color="#3b82f6" stop-opacity="0"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<path d="${areaD}" fill="url(#areaGrad)"/>
|
|
||||||
<path d="${pathD}" fill="none" stroke="#3b82f6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
${dots}
|
|
||||||
<text x="4" y="12" fill="#6b7280" font-size="10">${max}ms</text>
|
|
||||||
<text x="4" y="${h - 2}" fill="#6b7280" font-size="10">${min}ms</text>
|
|
||||||
</svg>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edit form submission
|
|
||||||
document.getElementById('edit-form').addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const errEl = document.getElementById('edit-error');
|
|
||||||
errEl.classList.add('hidden');
|
|
||||||
try {
|
|
||||||
const body = {
|
|
||||||
name: document.getElementById('edit-name').value.trim(),
|
|
||||||
url: document.getElementById('edit-url').value.trim(),
|
|
||||||
interval_s: Number(document.getElementById('edit-interval').value),
|
|
||||||
};
|
|
||||||
if (editQuery) body.query = editQuery;
|
|
||||||
await api(`/monitors/${monitorId}`, { method: 'PATCH', body });
|
|
||||||
load();
|
|
||||||
} catch (err) {
|
|
||||||
errEl.textContent = err.message;
|
|
||||||
errEl.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
load();
|
|
||||||
setInterval(load, 30000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,370 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>PingQL — Documentation</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<style>
|
|
||||||
body { font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace; background: #0a0a0a; }
|
|
||||||
pre, code { font-family: inherit; }
|
|
||||||
|
|
||||||
/* Sidebar nav */
|
|
||||||
.nav-link { display: block; padding: 0.3rem 0.75rem; font-size: 0.8rem; color: #6b7280; border-left: 2px solid transparent; transition: all 0.1s; }
|
|
||||||
.nav-link:hover { color: #d1d5db; border-left-color: #374151; }
|
|
||||||
.nav-link.active { color: #93c5fd; border-left-color: #3b82f6; }
|
|
||||||
.nav-section { font-size: 0.7rem; font-weight: 600; color: #374151; text-transform: uppercase; letter-spacing: 0.08em; padding: 0.75rem 0.75rem 0.25rem; margin-top: 0.5rem; }
|
|
||||||
|
|
||||||
/* Content */
|
|
||||||
.section { padding-top: 3rem; padding-bottom: 1rem; border-top: 1px solid #111827; margin-top: 2rem; }
|
|
||||||
.section:first-child { border-top: none; margin-top: 0; padding-top: 0; }
|
|
||||||
h2 { font-size: 1.2rem; font-weight: 700; color: #f9fafb; margin-bottom: 0.5rem; }
|
|
||||||
h3 { font-size: 0.85rem; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.06em; margin: 1.5rem 0 0.5rem; }
|
|
||||||
p { font-size: 0.875rem; color: #6b7280; line-height: 1.7; margin-bottom: 0.75rem; }
|
|
||||||
p a { color: #93c5fd; }
|
|
||||||
p a:hover { color: #bfdbfe; }
|
|
||||||
|
|
||||||
/* Code blocks */
|
|
||||||
.cb { background: #0f172a; border: 1px solid #1e293b; border-radius: 0.5rem; overflow: hidden; margin: 0.75rem 0 1.25rem; }
|
|
||||||
.cb-header { background: #0a1628; border-bottom: 1px solid #1e293b; padding: 0.4rem 0.85rem; display: flex; align-items: center; justify-content: space-between; }
|
|
||||||
.cb-lang { font-size: 0.7rem; color: #475569; }
|
|
||||||
.cb-copy { font-size: 0.7rem; color: #3b82f6; cursor: pointer; }
|
|
||||||
.cb-copy:hover { color: #93c5fd; }
|
|
||||||
.cb pre { padding: 0.85rem; font-size: 0.78rem; line-height: 1.65; color: #e2e8f0; overflow-x: auto; margin: 0; }
|
|
||||||
|
|
||||||
/* Syntax */
|
|
||||||
.k { color: #c084fc; } /* key */
|
|
||||||
.s { color: #34d399; } /* string */
|
|
||||||
.n { color: #f59e0b; } /* number */
|
|
||||||
.o { color: #60a5fa; } /* operator */
|
|
||||||
.c { color: #334155; } /* comment */
|
|
||||||
|
|
||||||
/* Tables */
|
|
||||||
table { width: 100%; border-collapse: collapse; font-size: 0.8rem; margin: 0.5rem 0 1.25rem; }
|
|
||||||
th { text-align: left; color: #4b5563; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; padding: 0.4rem 0.75rem; border-bottom: 1px solid #111827; }
|
|
||||||
td { color: #6b7280; padding: 0.45rem 0.75rem; border-bottom: 1px solid #0d1117; vertical-align: top; }
|
|
||||||
td:first-child { color: #93c5fd; white-space: nowrap; font-size: 0.78rem; }
|
|
||||||
td code { background: #1e293b; color: #94a3b8; padding: 0.1em 0.35em; border-radius: 0.2rem; font-size: 0.75rem; }
|
|
||||||
|
|
||||||
/* Endpoint blocks */
|
|
||||||
.endpoint { display: flex; align-items: baseline; gap: 0.75rem; margin-bottom: 0.5rem; }
|
|
||||||
.method { font-size: 0.7rem; font-weight: 700; padding: 0.15rem 0.45rem; border-radius: 0.2rem; }
|
|
||||||
.method.get { background: #052e16; color: #4ade80; }
|
|
||||||
.method.post { background: #172554; color: #93c5fd; }
|
|
||||||
.method.patch { background: #1c1917; color: #fb923c; }
|
|
||||||
.method.delete { background: #3f1215; color: #f87171; }
|
|
||||||
.path { color: #e2e8f0; font-size: 0.875rem; }
|
|
||||||
.endpoint-desc { font-size: 0.8rem; color: #4b5563; margin-bottom: 1rem; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="bg-[#0a0a0a] text-gray-100 min-h-screen">
|
|
||||||
|
|
||||||
<!-- Top bar -->
|
|
||||||
<div class="border-b border-gray-900 px-6 py-3 flex items-center justify-between">
|
|
||||||
<a href="/" class="text-lg font-bold">Ping<span class="text-blue-400">QL</span> <span class="text-gray-600 font-normal text-sm">docs</span></a>
|
|
||||||
<a href="/dashboard" class="text-sm text-gray-600 hover:text-gray-400 transition-colors">Dashboard →</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex max-w-6xl mx-auto">
|
|
||||||
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<aside class="w-52 shrink-0 sticky top-0 h-screen overflow-y-auto py-6 hidden md:block">
|
|
||||||
<div class="nav-section">Getting Started</div>
|
|
||||||
<a href="#overview" class="nav-link">Overview</a>
|
|
||||||
<a href="#auth" class="nav-link">Authentication</a>
|
|
||||||
|
|
||||||
<div class="nav-section">API Reference</div>
|
|
||||||
<a href="#account" class="nav-link">Account</a>
|
|
||||||
<a href="#monitors" class="nav-link">Monitors</a>
|
|
||||||
|
|
||||||
<div class="nav-section">Query Language</div>
|
|
||||||
<a href="#ql-fields" class="nav-link">Fields</a>
|
|
||||||
<a href="#ql-operators" class="nav-link">Operators</a>
|
|
||||||
<a href="#ql-json" class="nav-link">$json</a>
|
|
||||||
<a href="#ql-select" class="nav-link">$select</a>
|
|
||||||
<a href="#ql-logical" class="nav-link">Logical</a>
|
|
||||||
<a href="#ql-consider" class="nav-link">$consider</a>
|
|
||||||
<a href="#ql-examples" class="nav-link">Examples</a>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Main content -->
|
|
||||||
<main class="flex-1 px-10 py-8 max-w-3xl">
|
|
||||||
|
|
||||||
<!-- Overview -->
|
|
||||||
<div id="overview" class="section">
|
|
||||||
<h2>Overview</h2>
|
|
||||||
<p>PingQL is a developer-friendly uptime monitoring API. Monitors are defined with a URL, an interval, and an optional query that determines what "up" means for your service.</p>
|
|
||||||
<p>Base URL: <code style="color:#93c5fd;background:#0f172a;padding:0.15em 0.4em;border-radius:0.25rem;font-size:0.8rem">https://api.pingql.com</code></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Auth -->
|
|
||||||
<div id="auth" class="section">
|
|
||||||
<h2>Authentication</h2>
|
|
||||||
<p>All API requests require an account key passed as a Bearer token:</p>
|
|
||||||
<div class="cb">
|
|
||||||
<div class="cb-header"><span class="cb-lang">http</span></div>
|
|
||||||
<pre>Authorization: Bearer <your-64-char-hex-key></pre>
|
|
||||||
</div>
|
|
||||||
<p>Create an account at <a href="/dashboard">/dashboard</a> or via the API. Keys are 64-character hex strings (256-bit). Shown once at registration — store them securely.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Account -->
|
|
||||||
<div id="account" class="section">
|
|
||||||
<h2>Account</h2>
|
|
||||||
|
|
||||||
<h3>Register</h3>
|
|
||||||
<div class="endpoint"><span class="method post">POST</span><span class="path">/account/register</span></div>
|
|
||||||
<p class="endpoint-desc">Create a new account. Email is optional — used only for account recovery.</p>
|
|
||||||
<div class="cb">
|
|
||||||
<div class="cb-header"><span class="cb-lang">json — request body</span></div>
|
|
||||||
<pre>{ <span class="k">"email"</span>: <span class="s">"you@example.com"</span> } <span class="c">// optional</span></pre>
|
|
||||||
</div>
|
|
||||||
<div class="cb">
|
|
||||||
<div class="cb-header"><span class="cb-lang">json — response</span></div>
|
|
||||||
<pre>{ <span class="k">"key"</span>: <span class="s">"5bf5311b56d09254c8a1f0e3..."</span>, <span class="k">"email_registered"</span>: <span class="n">true</span> }</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Update Email</h3>
|
|
||||||
<div class="endpoint"><span class="method post">POST</span><span class="path">/account/email</span></div>
|
|
||||||
<p class="endpoint-desc">Set or update the recovery email for an existing account.</p>
|
|
||||||
<div class="cb">
|
|
||||||
<div class="cb-header"><span class="cb-lang">json — request body</span></div>
|
|
||||||
<pre>{ <span class="k">"email"</span>: <span class="s">"you@example.com"</span> }</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Monitors -->
|
|
||||||
<div id="monitors" class="section">
|
|
||||||
<h2>Monitors</h2>
|
|
||||||
|
|
||||||
<h3>List</h3>
|
|
||||||
<div class="endpoint"><span class="method get">GET</span><span class="path">/monitors/</span></div>
|
|
||||||
<p class="endpoint-desc">Returns all monitors for the authenticated account.</p>
|
|
||||||
|
|
||||||
<h3>Create</h3>
|
|
||||||
<div class="endpoint"><span class="method post">POST</span><span class="path">/monitors/</span></div>
|
|
||||||
<div class="cb">
|
|
||||||
<div class="cb-header"><span class="cb-lang">json — request body</span></div>
|
|
||||||
<pre>{
|
|
||||||
<span class="k">"name"</span>: <span class="s">"My API"</span>,
|
|
||||||
<span class="k">"url"</span>: <span class="s">"https://api.example.com/health"</span>,
|
|
||||||
<span class="k">"interval_s"</span>: <span class="n">60</span>, <span class="c">// check every 60 seconds (min: 10)</span>
|
|
||||||
<span class="k">"query"</span>: { ... } <span class="c">// optional — see Query Language below</span>
|
|
||||||
}</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Get</h3>
|
|
||||||
<div class="endpoint"><span class="method get">GET</span><span class="path">/monitors/:id</span></div>
|
|
||||||
<p class="endpoint-desc">Returns a monitor including its most recent ping results.</p>
|
|
||||||
|
|
||||||
<h3>Update</h3>
|
|
||||||
<div class="endpoint"><span class="method patch">PATCH</span><span class="path">/monitors/:id</span></div>
|
|
||||||
<p class="endpoint-desc">Update any field. All fields are optional.</p>
|
|
||||||
|
|
||||||
<h3>Delete</h3>
|
|
||||||
<div class="endpoint"><span class="method delete">DELETE</span><span class="path">/monitors/:id</span></div>
|
|
||||||
|
|
||||||
<h3>Toggle</h3>
|
|
||||||
<div class="endpoint"><span class="method post">POST</span><span class="path">/monitors/:id/toggle</span></div>
|
|
||||||
<p class="endpoint-desc">Enable or disable a monitor without deleting it.</p>
|
|
||||||
|
|
||||||
<h3>Ping History</h3>
|
|
||||||
<div class="endpoint"><span class="method get">GET</span><span class="path">/monitors/:id/pings?limit=100</span></div>
|
|
||||||
<p class="endpoint-desc">Returns recent ping results for a monitor. Max 1000.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- QL Fields -->
|
|
||||||
<div id="ql-fields" class="section">
|
|
||||||
<h2>Query Language — Fields</h2>
|
|
||||||
<p>A PingQL query is a JSON object evaluated against each ping. If it returns <code style="color:#4ade80;background:#052e16;padding:0.1em 0.35em;border-radius:0.2rem;font-size:0.78rem">true</code>, the monitor is <strong style="color:#4ade80">up</strong>. Default (no query): up when status < 400.</p>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>status</td><td>number</td><td>HTTP status code</td></tr>
|
|
||||||
<tr><td>body</td><td>string</td><td>Full response body as text</td></tr>
|
|
||||||
<tr><td>headers.<em>name</em></td><td>string</td><td>Response header, e.g. <code>headers.content-type</code></td></tr>
|
|
||||||
<tr><td>$responseTime</td><td>number</td><td>Request latency in milliseconds</td></tr>
|
|
||||||
<tr><td>$certExpiry</td><td>number</td><td>Days until SSL certificate expires</td></tr>
|
|
||||||
<tr><td>$json</td><td>object</td><td>JSONPath expression against response body</td></tr>
|
|
||||||
<tr><td>$select</td><td>object</td><td>CSS selector against response HTML</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- QL Operators -->
|
|
||||||
<div id="ql-operators" class="section">
|
|
||||||
<h2>Query Language — Operators</h2>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Operator</th><th>Description</th><th>Types</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>$eq</td><td>Equal to</td><td>any</td></tr>
|
|
||||||
<tr><td>$ne</td><td>Not equal to</td><td>any</td></tr>
|
|
||||||
<tr><td>$gt / $gte</td><td>Greater than / or equal</td><td>number</td></tr>
|
|
||||||
<tr><td>$lt / $lte</td><td>Less than / or equal</td><td>number</td></tr>
|
|
||||||
<tr><td>$contains</td><td>String contains substring</td><td>string</td></tr>
|
|
||||||
<tr><td>$startsWith</td><td>String starts with</td><td>string</td></tr>
|
|
||||||
<tr><td>$endsWith</td><td>String ends with</td><td>string</td></tr>
|
|
||||||
<tr><td>$regex</td><td>Matches regular expression</td><td>string</td></tr>
|
|
||||||
<tr><td>$exists</td><td>Field is present and non-null</td><td>any</td></tr>
|
|
||||||
<tr><td>$in</td><td>Value is in array</td><td>any</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div class="cb">
|
|
||||||
<div class="cb-header"><span class="cb-lang">json</span></div>
|
|
||||||
<pre><span class="c">// simple equality shorthand</span>
|
|
||||||
{ <span class="k">"status"</span>: <span class="n">200</span> }
|
|
||||||
|
|
||||||
<span class="c">// operator form</span>
|
|
||||||
{ <span class="k">"status"</span>: { <span class="o">"$lt"</span>: <span class="n">400</span> } }
|
|
||||||
{ <span class="k">"body"</span>: { <span class="o">"$contains"</span>: <span class="s">"healthy"</span> } }
|
|
||||||
{ <span class="k">"headers.content-type"</span>: { <span class="o">"$contains"</span>: <span class="s">"application/json"</span> } }</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- $json -->
|
|
||||||
<div id="ql-json" class="section">
|
|
||||||
<h2>$json — JSONPath</h2>
|
|
||||||
<p>Extract and compare a value from a JSON response body. The key is a dot-notation path starting with <code style="color:#93c5fd;background:#0f172a;padding:0.1em 0.35em;border-radius:0.2rem;font-size:0.78rem">$.</code></p>
|
|
||||||
<div class="cb">
|
|
||||||
<div class="cb-header"><span class="cb-lang">json</span></div>
|
|
||||||
<pre><span class="c">// response body: { "status": "ok", "db": { "connections": 12 } }</span>
|
|
||||||
|
|
||||||
{ <span class="o">"$json"</span>: { <span class="s">"$.status"</span>: { <span class="o">"$eq"</span>: <span class="s">"ok"</span> } } }
|
|
||||||
{ <span class="o">"$json"</span>: { <span class="s">"$.db.connections"</span>: { <span class="o">"$lt"</span>: <span class="n">100</span> } } }</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- $select -->
|
|
||||||
<div id="ql-select" class="section">
|
|
||||||
<h2>$select — CSS Selector</h2>
|
|
||||||
<p>Extract text content from an HTML response using a CSS selector. Useful for monitoring public pages without an API.</p>
|
|
||||||
<div class="cb">
|
|
||||||
<div class="cb-header"><span class="cb-lang">json</span></div>
|
|
||||||
<pre><span class="c">// matches if <h1> text is exactly "Example Domain"</span>
|
|
||||||
{ <span class="o">"$select"</span>: { <span class="s">"h1"</span>: { <span class="o">"$eq"</span>: <span class="s">"Example Domain"</span> } } }
|
|
||||||
|
|
||||||
<span class="c">// matches if status badge contains "operational"</span>
|
|
||||||
{ <span class="o">"$select"</span>: { <span class="s">".status-badge"</span>: { <span class="o">"$contains"</span>: <span class="s">"operational"</span> } } }</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Logical -->
|
|
||||||
<div id="ql-logical" class="section">
|
|
||||||
<h2>Logical Operators</h2>
|
|
||||||
<div class="cb">
|
|
||||||
<div class="cb-header"><span class="cb-lang">json</span></div>
|
|
||||||
<pre><span class="c">// $and — all conditions must match</span>
|
|
||||||
{ <span class="o">"$and"</span>: [{ <span class="k">"status"</span>: <span class="n">200</span> }, { <span class="k">"body"</span>: { <span class="o">"$contains"</span>: <span class="s">"ok"</span> } }] }
|
|
||||||
|
|
||||||
<span class="c">// $or — any condition must match</span>
|
|
||||||
{ <span class="o">"$or"</span>: [{ <span class="k">"status"</span>: <span class="n">200</span> }, { <span class="k">"status"</span>: <span class="n">204</span> }] }
|
|
||||||
|
|
||||||
<span class="c">// $not — invert a condition</span>
|
|
||||||
{ <span class="o">"$not"</span>: { <span class="k">"status"</span>: <span class="n">500</span> } }</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- $consider -->
|
|
||||||
<div id="ql-consider" class="section">
|
|
||||||
<h2>$consider</h2>
|
|
||||||
<p>By default, matching conditions mean the monitor is <strong style="color:#4ade80">up</strong>. Set <code style="color:#93c5fd;background:#0f172a;padding:0.1em 0.35em;border-radius:0.2rem;font-size:0.78rem">"$consider": "down"</code> to flip this — if the conditions match, the monitor is <strong style="color:#f87171">down</strong>.</p>
|
|
||||||
<div class="cb">
|
|
||||||
<div class="cb-header"><span class="cb-lang">json</span></div>
|
|
||||||
<pre><span class="c">// down if response time exceeds 2 seconds</span>
|
|
||||||
{ <span class="o">"$consider"</span>: <span class="s">"down"</span>, <span class="k">"$responseTime"</span>: { <span class="o">"$gt"</span>: <span class="n">2000</span> } }
|
|
||||||
|
|
||||||
<span class="c">// down if cert expires in less than 7 days</span>
|
|
||||||
{ <span class="o">"$consider"</span>: <span class="s">"down"</span>, <span class="k">"$certExpiry"</span>: { <span class="o">"$lt"</span>: <span class="n">7</span> } }
|
|
||||||
|
|
||||||
<span class="c">// down if any of these match</span>
|
|
||||||
{
|
|
||||||
<span class="o">"$consider"</span>: <span class="s">"down"</span>,
|
|
||||||
<span class="o">"$or"</span>: [
|
|
||||||
{ <span class="k">"status"</span>: { <span class="o">"$gte"</span>: <span class="n">500</span> } },
|
|
||||||
{ <span class="k">"$responseTime"</span>: { <span class="o">"$gt"</span>: <span class="n">5000</span> } }
|
|
||||||
]
|
|
||||||
}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Examples -->
|
|
||||||
<div id="ql-examples" class="section">
|
|
||||||
<h2>Examples</h2>
|
|
||||||
|
|
||||||
<h3>Basic health endpoint</h3>
|
|
||||||
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
|
|
||||||
<pre>{ <span class="k">"status"</span>: <span class="n">200</span>, <span class="k">"body"</span>: { <span class="o">"$contains"</span>: <span class="s">"healthy"</span> } }</pre></div>
|
|
||||||
|
|
||||||
<h3>JSON API response shape</h3>
|
|
||||||
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
|
|
||||||
<pre>{
|
|
||||||
<span class="o">"$and"</span>: [
|
|
||||||
{ <span class="k">"status"</span>: <span class="n">200</span> },
|
|
||||||
{ <span class="o">"$json"</span>: { <span class="s">"$.ok"</span>: { <span class="o">"$eq"</span>: <span class="n">true</span> } } }
|
|
||||||
]
|
|
||||||
}</pre></div>
|
|
||||||
|
|
||||||
<h3>Performance monitor (mark down if slow)</h3>
|
|
||||||
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
|
|
||||||
<pre>{ <span class="o">"$consider"</span>: <span class="s">"down"</span>, <span class="k">"$responseTime"</span>: { <span class="o">"$gt"</span>: <span class="n">1000</span> } }</pre></div>
|
|
||||||
|
|
||||||
<h3>Cert expiry alert</h3>
|
|
||||||
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
|
|
||||||
<pre>{ <span class="o">"$consider"</span>: <span class="s">"down"</span>, <span class="k">"$certExpiry"</span>: { <span class="o">"$lt"</span>: <span class="n">14</span> } }</pre></div>
|
|
||||||
|
|
||||||
<h3>Status page (HTML)</h3>
|
|
||||||
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
|
|
||||||
<pre>{ <span class="o">"$select"</span>: { <span class="s">".status-indicator"</span>: { <span class="o">"$eq"</span>: <span class="s">"All systems operational"</span> } } }</pre></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-12 pt-6 border-t border-gray-900 text-sm text-gray-600">
|
|
||||||
PingQL · <a href="/dashboard" class="hover:text-gray-400 transition-colors">Dashboard</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Highlight active nav link on scroll
|
|
||||||
const sections = document.querySelectorAll('[id]');
|
|
||||||
const navLinks = document.querySelectorAll('.nav-link');
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
|
||||||
entries.forEach(entry => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
navLinks.forEach(l => l.classList.remove('active'));
|
|
||||||
const link = document.querySelector(`.nav-link[href="#${entry.target.id}"]`);
|
|
||||||
if (link) link.classList.add('active');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, { rootMargin: '-20% 0px -70% 0px' });
|
|
||||||
sections.forEach(s => observer.observe(s));
|
|
||||||
|
|
||||||
// Copy buttons
|
|
||||||
document.querySelectorAll('.cb').forEach(cb => {
|
|
||||||
const btn = cb.querySelector('.cb-copy');
|
|
||||||
if (!btn) return;
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
const text = cb.querySelector('pre').innerText;
|
|
||||||
navigator.clipboard.writeText(text);
|
|
||||||
btn.textContent = 'Copied!';
|
|
||||||
setTimeout(() => btn.textContent = 'Copy', 1500);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// Add copy buttons to all codeblocks
|
|
||||||
document.querySelectorAll('.cb-header').forEach(h => {
|
|
||||||
if (!h.querySelector('.cb-copy')) {
|
|
||||||
const btn = document.createElement('button');
|
|
||||||
btn.className = 'cb-copy';
|
|
||||||
btn.textContent = 'Copy';
|
|
||||||
h.appendChild(btn);
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
const text = h.nextElementSibling.innerText;
|
|
||||||
navigator.clipboard.writeText(text);
|
|
||||||
btn.textContent = 'Copied!';
|
|
||||||
setTimeout(() => btn.textContent = 'Copy', 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="6" fill="#0a0a0a"/>
|
||||||
|
<text x="3" y="23" font-family="system-ui, sans-serif" font-weight="700" font-size="18" fill="#e5e7eb">P</text>
|
||||||
|
<text x="14" y="23" font-family="system-ui, sans-serif" font-weight="700" font-size="18" fill="#3b82f6">Q</text>
|
||||||
|
<circle cx="26" cy="6" r="3" fill="#22c55e">
|
||||||
|
<animate attributeName="opacity" values="1;0.4;1" dur="2s" repeatCount="indefinite"/>
|
||||||
|
</circle>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 501 B |
@@ -1,112 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>PingQL — Dashboard</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<style>
|
|
||||||
body { font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace; background: #0a0a0a; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="bg-[#0a0a0a] text-gray-100 min-h-screen">
|
|
||||||
<script src="/dashboard/app.js"></script>
|
|
||||||
|
|
||||||
<!-- Nav -->
|
|
||||||
<nav class="border-b border-gray-800 px-6 py-4 flex items-center justify-between">
|
|
||||||
<a href="/dashboard/home" class="text-xl font-bold tracking-tight">Ping<span class="text-blue-400">QL</span></a>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<a href="/dashboard/settings" class="text-gray-500 hover:text-gray-300 text-sm transition-colors">Settings</a>
|
|
||||||
<button onclick="logout()" class="text-gray-500 hover:text-gray-300 text-sm transition-colors">Logout</button>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<main class="max-w-5xl mx-auto px-6 py-8">
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h2 class="text-lg font-semibold text-gray-200">Monitors</h2>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div id="summary" class="text-sm text-gray-500"></div>
|
|
||||||
<a href="/dashboard/monitors/new" class="bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">+ New</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="monitors-list" class="space-y-3">
|
|
||||||
<div class="text-center py-16 text-gray-600">Loading...</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="empty-state" class="hidden text-center py-16">
|
|
||||||
<p class="text-gray-500 mb-4">No monitors yet</p>
|
|
||||||
<a href="/dashboard/monitors/new" class="bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium px-6 py-3 rounded-lg transition-colors inline-block">Create your first monitor</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
if (!requireAuth()) throw 'auth';
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
const monitors = await api('/monitors/');
|
|
||||||
const list = document.getElementById('monitors-list');
|
|
||||||
const emptyState = document.getElementById('empty-state');
|
|
||||||
const summary = document.getElementById('summary');
|
|
||||||
|
|
||||||
if (monitors.length === 0) {
|
|
||||||
list.classList.add('hidden');
|
|
||||||
emptyState.classList.remove('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch last ping for each monitor
|
|
||||||
const monitorsWithPings = await Promise.all(
|
|
||||||
monitors.map(async (m) => {
|
|
||||||
try {
|
|
||||||
const pings = await api(`/monitors/${m.id}/pings?limit=20`);
|
|
||||||
return { ...m, pings };
|
|
||||||
} catch {
|
|
||||||
return { ...m, pings: [] };
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const upCount = monitorsWithPings.filter(m => m.pings[0]?.up === true).length;
|
|
||||||
const downCount = monitorsWithPings.filter(m => m.pings[0]?.up === false).length;
|
|
||||||
summary.innerHTML = `<span class="text-green-400">${upCount} up</span> · <span class="${downCount > 0 ? 'text-red-400' : 'text-gray-500'}">${downCount} down</span> · ${monitors.length} total`;
|
|
||||||
|
|
||||||
list.innerHTML = monitorsWithPings.map(m => {
|
|
||||||
const lastPing = m.pings[0];
|
|
||||||
const latencies = m.pings.filter(c => c.latency_ms != null).map(c => c.latency_ms).reverse();
|
|
||||||
const avgLatency = latencies.length ? Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length) : null;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<a href="/dashboard/monitors/${m.id}" class="block bg-gray-900 hover:bg-gray-800/80 border border-gray-800 rounded-xl p-4 transition-colors group">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
|
||||||
${statusBadge(lastPing?.up)}
|
|
||||||
<div class="min-w-0">
|
|
||||||
<div class="font-medium text-gray-100 group-hover:text-white truncate">${escapeHtml(m.name)}</div>
|
|
||||||
<div class="text-xs text-gray-500 truncate">${escapeHtml(m.url)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-6 shrink-0 ml-4">
|
|
||||||
<div class="hidden sm:block">${sparkline(latencies)}</div>
|
|
||||||
<div class="text-right">
|
|
||||||
<div class="text-sm text-gray-300">${avgLatency != null ? avgLatency + 'ms' : '—'}</div>
|
|
||||||
<div class="text-xs text-gray-500">${lastPing ? timeAgo(lastPing.checked_at) : 'no pings'}</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs px-2 py-1 rounded ${m.enabled ? 'bg-gray-800 text-gray-400' : 'bg-yellow-900/30 text-yellow-500'}">${m.enabled ? m.interval_s + 's' : 'paused'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
} catch (e) {
|
|
||||||
document.getElementById('monitors-list').innerHTML = `<div class="text-center py-8 text-red-400">${escapeHtml(e.message)}</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
load();
|
|
||||||
setInterval(load, 30000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,548 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" class="dark scroll-smooth">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>PingQL — Uptime monitoring that thinks like a developer</title>
|
|
||||||
<meta name="description" content="Monitor uptime with a MongoDB-style query language. Inspect JSON, HTML, headers, certs, and more — not just ping.">
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
||||||
<script>
|
|
||||||
tailwind.config = {
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
fontFamily: {
|
|
||||||
mono: ['JetBrains Mono', 'SF Mono', 'Fira Code', 'ui-monospace', 'monospace'],
|
|
||||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
|
||||||
},
|
|
||||||
colors: {
|
|
||||||
brand: '#3b82f6',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
html { scroll-behavior: smooth; }
|
|
||||||
body { background: #0a0a0a; }
|
|
||||||
|
|
||||||
/* Grid background */
|
|
||||||
.grid-bg {
|
|
||||||
background-image:
|
|
||||||
linear-gradient(rgba(59, 130, 246, 0.03) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, rgba(59, 130, 246, 0.03) 1px, transparent 1px);
|
|
||||||
background-size: 64px 64px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hero glow */
|
|
||||||
.hero-glow {
|
|
||||||
background: radial-gradient(ellipse 60% 40% at 50% 0%, rgba(59, 130, 246, 0.12) 0%, transparent 70%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Terminal window */
|
|
||||||
.terminal {
|
|
||||||
background: #111111;
|
|
||||||
border: 1px solid #1e1e1e;
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 0 60px rgba(59, 130, 246, 0.06), 0 20px 60px rgba(0,0,0,0.5);
|
|
||||||
}
|
|
||||||
.terminal-bar {
|
|
||||||
background: #1a1a1a;
|
|
||||||
border-bottom: 1px solid #1e1e1e;
|
|
||||||
padding: 12px 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.terminal-dot {
|
|
||||||
width: 12px; height: 12px; border-radius: 50%;
|
|
||||||
}
|
|
||||||
.terminal-body {
|
|
||||||
padding: 20px 24px;
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.7;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Syntax highlighting */
|
|
||||||
.syn-key { color: #93c5fd; }
|
|
||||||
.syn-str { color: #86efac; }
|
|
||||||
.syn-num { color: #fbbf24; }
|
|
||||||
.syn-op { color: #c084fc; }
|
|
||||||
.syn-brace { color: #6b7280; }
|
|
||||||
.syn-comment { color: #4b5563; font-style: italic; }
|
|
||||||
|
|
||||||
/* Fade-in animation */
|
|
||||||
@keyframes fade-up {
|
|
||||||
from { opacity: 0; transform: translateY(24px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
.fade-up {
|
|
||||||
animation: fade-up 0.7s ease-out both;
|
|
||||||
}
|
|
||||||
.fade-up-delay-1 { animation-delay: 0.1s; }
|
|
||||||
.fade-up-delay-2 { animation-delay: 0.2s; }
|
|
||||||
.fade-up-delay-3 { animation-delay: 0.3s; }
|
|
||||||
.fade-up-delay-4 { animation-delay: 0.4s; }
|
|
||||||
|
|
||||||
/* Glow card */
|
|
||||||
.glow-card {
|
|
||||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.05) 0%, rgba(17, 17, 17, 1) 50%);
|
|
||||||
border: 1px solid #1e1e1e;
|
|
||||||
transition: border-color 0.3s, box-shadow 0.3s;
|
|
||||||
}
|
|
||||||
.glow-card:hover {
|
|
||||||
border-color: rgba(59, 130, 246, 0.3);
|
|
||||||
box-shadow: 0 0 30px rgba(59, 130, 246, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Comparison bar */
|
|
||||||
.ping-bar {
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pulse animation */
|
|
||||||
@keyframes pulse-dot {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.4; }
|
|
||||||
}
|
|
||||||
.pulse-dot {
|
|
||||||
animation: pulse-dot 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Typing cursor */
|
|
||||||
@keyframes blink {
|
|
||||||
0%, 50% { opacity: 1; }
|
|
||||||
51%, 100% { opacity: 0; }
|
|
||||||
}
|
|
||||||
.cursor {
|
|
||||||
display: inline-block;
|
|
||||||
width: 8px;
|
|
||||||
height: 18px;
|
|
||||||
background: #3b82f6;
|
|
||||||
animation: blink 1s step-end infinite;
|
|
||||||
vertical-align: text-bottom;
|
|
||||||
margin-left: 2px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="bg-[#0a0a0a] text-gray-100 font-sans antialiased grid-bg">
|
|
||||||
|
|
||||||
<!-- ─── HEADER ─── -->
|
|
||||||
<header class="fixed top-0 left-0 right-0 z-50 border-b border-gray-800/60 bg-[#0a0a0a]/80 backdrop-blur-md">
|
|
||||||
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
|
||||||
<a href="/" class="font-mono text-lg font-bold tracking-tight">Ping<span class="text-blue-400">QL</span></a>
|
|
||||||
<nav class="hidden md:flex items-center gap-7 text-sm text-gray-400">
|
|
||||||
<a href="/docs" class="hover:text-gray-200 transition-colors">Docs</a>
|
|
||||||
<a href="/docs#ql-fields" class="hover:text-gray-200 transition-colors">Query Language</a>
|
|
||||||
<a href="#privacy" class="hover:text-gray-200 transition-colors">Privacy</a>
|
|
||||||
<a href="#pricing" class="hover:text-gray-200 transition-colors">Pricing</a>
|
|
||||||
</nav>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<a href="/dashboard" class="text-sm text-gray-400 hover:text-gray-200 transition-colors">Sign in</a>
|
|
||||||
<a href="/dashboard" class="text-sm bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-lg font-medium transition-colors">Get started</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- ─── HERO ─── -->
|
|
||||||
<section class="relative min-h-screen flex items-start hero-glow pt-24">
|
|
||||||
<div class="max-w-6xl mx-auto px-6 pt-16 pb-12 w-full">
|
|
||||||
<div class="grid lg:grid-cols-2 gap-16 items-center">
|
|
||||||
<!-- Left -->
|
|
||||||
<div class="fade-up">
|
|
||||||
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full border border-gray-800 text-xs text-gray-400 mb-6 font-mono">
|
|
||||||
<span class="w-2 h-2 rounded-full bg-green-500 pulse-dot"></span>
|
|
||||||
All systems operational
|
|
||||||
</div>
|
|
||||||
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight leading-[1.1] mb-6">
|
|
||||||
Uptime monitoring<br>that <span class="text-brand">thinks like<br>a developer</span>
|
|
||||||
</h1>
|
|
||||||
<p class="text-lg text-gray-400 max-w-lg mb-8 leading-relaxed">
|
|
||||||
Go beyond simple pings. Write queries against status codes, JSON bodies, HTML selectors, headers, cert expiry — everything your response contains.
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-wrap gap-4">
|
|
||||||
<a href="/dashboard" class="inline-flex items-center gap-2 px-6 py-3 bg-brand hover:bg-blue-500 text-white font-medium rounded-lg transition-colors text-sm">
|
|
||||||
Get Started Free
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6"/></svg>
|
|
||||||
</a>
|
|
||||||
<a href="/docs" class="inline-flex items-center gap-2 px-6 py-3 border border-gray-700 hover:border-gray-500 text-gray-300 font-medium rounded-lg transition-colors text-sm">
|
|
||||||
Read the Docs
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Right: terminal -->
|
|
||||||
<div class="fade-up fade-up-delay-2">
|
|
||||||
<div class="terminal">
|
|
||||||
<div class="terminal-bar">
|
|
||||||
<div class="terminal-dot bg-[#ff5f57]"></div>
|
|
||||||
<div class="terminal-dot bg-[#ffbd2e]"></div>
|
|
||||||
<div class="terminal-dot bg-[#28c840]"></div>
|
|
||||||
<span class="ml-3 text-xs text-gray-500 font-mono">query.json</span>
|
|
||||||
</div>
|
|
||||||
<div class="terminal-body">
|
|
||||||
<pre><span class="syn-brace">{</span>
|
|
||||||
<span class="syn-key">"$and"</span><span class="syn-brace">:</span> <span class="syn-brace">[</span>
|
|
||||||
<span class="syn-brace">{</span> <span class="syn-key">"status"</span><span class="syn-brace">:</span> <span class="syn-brace">{</span> <span class="syn-op">"$lt"</span><span class="syn-brace">:</span> <span class="syn-num">400</span> <span class="syn-brace">}</span> <span class="syn-brace">}</span>,
|
|
||||||
<span class="syn-brace">{</span> <span class="syn-op">"$json"</span><span class="syn-brace">:</span> <span class="syn-brace">{</span> <span class="syn-str">"$.db.status"</span><span class="syn-brace">:</span> <span class="syn-brace">{</span> <span class="syn-op">"$eq"</span><span class="syn-brace">:</span> <span class="syn-str">"ok"</span> <span class="syn-brace">}</span> <span class="syn-brace">}</span> <span class="syn-brace">}</span>,
|
|
||||||
<span class="syn-brace">{</span> <span class="syn-op">"$certExpiry"</span><span class="syn-brace">:</span> <span class="syn-brace">{</span> <span class="syn-op">"$gt"</span><span class="syn-brace">:</span> <span class="syn-num">14</span> <span class="syn-brace">}</span> <span class="syn-brace">}</span>
|
|
||||||
<span class="syn-brace">]</span>
|
|
||||||
<span class="syn-brace">}</span></pre>
|
|
||||||
<div class="mt-4 pt-4 border-t border-gray-800 text-xs text-gray-500">
|
|
||||||
<span class="syn-comment">// status < 400 AND $.db.status = "ok" AND $certExpiry > 14 days</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ─── NOT JUST PING ─── -->
|
|
||||||
<section class="py-24 px-6">
|
|
||||||
<div class="max-w-5xl mx-auto">
|
|
||||||
<div class="text-center mb-16">
|
|
||||||
<h2 class="text-3xl sm:text-4xl font-bold tracking-tight mb-4">Not just ping</h2>
|
|
||||||
<p class="text-gray-400 max-w-2xl mx-auto text-lg">
|
|
||||||
Most monitoring tools ping a URL and check if it returns 200. That tells you almost nothing.
|
|
||||||
PingQL lets you query the <em>entire</em> response.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Comparison -->
|
|
||||||
<div class="grid md:grid-cols-2 gap-8 max-w-3xl mx-auto">
|
|
||||||
<!-- Others -->
|
|
||||||
<div class="rounded-xl border border-gray-800 bg-[#111] p-6">
|
|
||||||
<div class="text-sm text-gray-500 uppercase tracking-wider mb-4 font-mono">Others</div>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="w-3 h-3 rounded-full bg-green-500 shrink-0"></span>
|
|
||||||
<span class="text-gray-400 text-sm">GET /api/health → 200</span>
|
|
||||||
<span class="ml-auto text-green-500 text-xs font-mono">UP</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="w-3 h-3 rounded-full bg-red-500 shrink-0"></span>
|
|
||||||
<span class="text-gray-400 text-sm">GET /api/health → 500</span>
|
|
||||||
<span class="ml-auto text-red-500 text-xs font-mono">DOWN</span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 pt-4 border-t border-gray-800 text-xs text-gray-500">
|
|
||||||
That's it. That's all you get.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- PingQL -->
|
|
||||||
<div class="rounded-xl border border-blue-500/30 bg-[#111] p-6 shadow-[0_0_40px_rgba(59,130,246,0.06)]">
|
|
||||||
<div class="text-sm text-brand uppercase tracking-wider mb-4 font-mono">PingQL</div>
|
|
||||||
<div class="space-y-3 text-sm font-mono">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-green-400">✓</span>
|
|
||||||
<span class="text-gray-300">Status code assertions</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-green-400">✓</span>
|
|
||||||
<span class="text-gray-300">JSON body inspection</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-green-400">✓</span>
|
|
||||||
<span class="text-gray-300">HTML / CSS selector matching</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-green-400">✓</span>
|
|
||||||
<span class="text-gray-300">Response time thresholds</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-green-400">✓</span>
|
|
||||||
<span class="text-gray-300">SSL cert expiry checks</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-green-400">✓</span>
|
|
||||||
<span class="text-gray-300">Header & regex matching</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ─── FEATURES ─── -->
|
|
||||||
<section class="py-24 px-6">
|
|
||||||
<div class="max-w-6xl mx-auto">
|
|
||||||
<div class="text-center mb-16">
|
|
||||||
<h2 class="text-3xl sm:text-4xl font-bold tracking-tight mb-4">Everything you need</h2>
|
|
||||||
<p class="text-gray-400 text-lg">Powerful primitives, zero bloat.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
<!-- Feature 1 -->
|
|
||||||
<div class="glow-card rounded-xl p-6">
|
|
||||||
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
|
||||||
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5"/></svg>
|
|
||||||
</div>
|
|
||||||
<h3 class="font-semibold text-white mb-2">MongoDB-style queries</h3>
|
|
||||||
<p class="text-sm text-gray-400 leading-relaxed">Use <code class="text-brand font-mono text-xs">$and</code>, <code class="text-brand font-mono text-xs">$or</code>, <code class="text-brand font-mono text-xs">$lt</code>, <code class="text-brand font-mono text-xs">$gt</code>, <code class="text-brand font-mono text-xs">$regex</code> — a query language you already know.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Feature 2 -->
|
|
||||||
<div class="glow-card rounded-xl p-6">
|
|
||||||
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
|
||||||
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z"/></svg>
|
|
||||||
</div>
|
|
||||||
<h3 class="font-semibold text-white mb-2">CSS selector parsing</h3>
|
|
||||||
<p class="text-sm text-gray-400 leading-relaxed">Monitor any public page with no API. Use <code class="text-brand font-mono text-xs">$css</code> to extract text from HTML elements.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Feature 3 -->
|
|
||||||
<div class="glow-card rounded-xl p-6">
|
|
||||||
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
|
||||||
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>
|
|
||||||
</div>
|
|
||||||
<h3 class="font-semibold text-white mb-2">JSONPath inspection</h3>
|
|
||||||
<p class="text-sm text-gray-400 leading-relaxed">Drill into API responses with <code class="text-brand font-mono text-xs">$json</code> and JSONPath expressions. Assert on any nested field.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Feature 4 -->
|
|
||||||
<div class="glow-card rounded-xl p-6">
|
|
||||||
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
|
||||||
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/></svg>
|
|
||||||
</div>
|
|
||||||
<h3 class="font-semibold text-white mb-2">SSL cert monitoring</h3>
|
|
||||||
<p class="text-sm text-gray-400 leading-relaxed">Built-in <code class="text-brand font-mono text-xs">$certExpiry</code> operator. Get alerted before your cert expires, not after.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Feature 5 -->
|
|
||||||
<div class="glow-card rounded-xl p-6">
|
|
||||||
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
|
||||||
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
|
||||||
</div>
|
|
||||||
<h3 class="font-semibold text-white mb-2">Response time assertions</h3>
|
|
||||||
<p class="text-sm text-gray-400 leading-relaxed">Set thresholds on <code class="text-brand font-mono text-xs">responseTime</code>. Know when your API is slow, not just down.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Feature 6 -->
|
|
||||||
<div class="glow-card rounded-xl p-6">
|
|
||||||
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
|
||||||
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"/></svg>
|
|
||||||
</div>
|
|
||||||
<h3 class="font-semibold text-white mb-2">No-login account keys</h3>
|
|
||||||
<p class="text-sm text-gray-400 leading-relaxed">No email required. Get an account key, start monitoring. Privacy first, always.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ─── PRIVACY ─── -->
|
|
||||||
<section id="privacy" class="py-24 px-6">
|
|
||||||
<div class="max-w-4xl mx-auto">
|
|
||||||
<div class="rounded-2xl border border-gray-800 bg-gradient-to-b from-[#111] to-[#0a0a0a] p-8 sm:p-12">
|
|
||||||
<div class="flex items-start gap-4 mb-8">
|
|
||||||
<div class="w-12 h-12 rounded-xl bg-green-500/10 border border-green-500/20 flex items-center justify-center shrink-0">
|
|
||||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/></svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 class="text-3xl font-bold tracking-tight mb-2">Privacy by design</h2>
|
|
||||||
<p class="text-gray-400">We built PingQL for developers who care about their data.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid sm:grid-cols-2 gap-6">
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<span class="text-green-400 mt-0.5 shrink-0 font-mono text-sm">$</span>
|
|
||||||
<div>
|
|
||||||
<p class="text-white font-medium text-sm">No tracking, no analytics, no ads</p>
|
|
||||||
<p class="text-gray-500 text-xs mt-1">Zero third-party scripts. Zero cookies.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<span class="text-green-400 mt-0.5 shrink-0 font-mono text-sm">$</span>
|
|
||||||
<div>
|
|
||||||
<p class="text-white font-medium text-sm">Account keys, not passwords</p>
|
|
||||||
<p class="text-gray-500 text-xs mt-1">Be completely anonymous. No email required.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<span class="text-green-400 mt-0.5 shrink-0 font-mono text-sm">$</span>
|
|
||||||
<div>
|
|
||||||
<p class="text-white font-medium text-sm">Emails hashed if provided</p>
|
|
||||||
<p class="text-gray-500 text-xs mt-1">Optional email for recovery only. We hash it.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<span class="text-green-400 mt-0.5 shrink-0 font-mono text-sm">$</span>
|
|
||||||
<div>
|
|
||||||
<p class="text-white font-medium text-sm">We never sell data</p>
|
|
||||||
<p class="text-gray-500 text-xs mt-1">Your monitors, your data. Period.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8 pt-6 border-t border-gray-800 flex items-center gap-2 text-xs text-gray-500">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"/></svg>
|
|
||||||
Hosted in EU (Prague) — GDPR compliant
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ─── API EXAMPLE ─── -->
|
|
||||||
<section class="py-24 px-6">
|
|
||||||
<div class="max-w-5xl mx-auto">
|
|
||||||
<div class="text-center mb-16">
|
|
||||||
<h2 class="text-3xl sm:text-4xl font-bold tracking-tight mb-4">API-first, always</h2>
|
|
||||||
<p class="text-gray-400 text-lg max-w-2xl mx-auto">Create monitors, query results, and manage everything from scripts, your terminal, or anywhere with HTTP.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid lg:grid-cols-2 gap-6">
|
|
||||||
<!-- Create monitor -->
|
|
||||||
<div class="terminal">
|
|
||||||
<div class="terminal-bar">
|
|
||||||
<div class="terminal-dot bg-[#ff5f57]"></div>
|
|
||||||
<div class="terminal-dot bg-[#ffbd2e]"></div>
|
|
||||||
<div class="terminal-dot bg-[#28c840]"></div>
|
|
||||||
<span class="ml-3 text-xs text-gray-500 font-mono">Create a monitor</span>
|
|
||||||
</div>
|
|
||||||
<div class="terminal-body text-xs sm:text-[13px]">
|
|
||||||
<pre><span class="text-gray-500">$</span> <span class="text-white">curl</span> -X POST https://pingql.com/api/monitors \
|
|
||||||
-H <span class="syn-str">"X-Key: XXXX-XXXX-XXXX-XXXX"</span> \
|
|
||||||
-d <span class="syn-str">'{
|
|
||||||
"url": "https://api.example.com/health",
|
|
||||||
"interval": 60,
|
|
||||||
"query": {
|
|
||||||
"status": { "$lt": 400 },
|
|
||||||
"$json": {
|
|
||||||
"$.ok": { "$eq": true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}'</span>
|
|
||||||
|
|
||||||
<span class="text-gray-500">// → 201 Created</span>
|
|
||||||
<span class="syn-brace">{</span> <span class="syn-key">"id"</span>: <span class="syn-str">"mon_a1b2c3"</span>, <span class="syn-key">"status"</span>: <span class="syn-str">"active"</span> <span class="syn-brace">}</span></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Get results -->
|
|
||||||
<div class="terminal">
|
|
||||||
<div class="terminal-bar">
|
|
||||||
<div class="terminal-dot bg-[#ff5f57]"></div>
|
|
||||||
<div class="terminal-dot bg-[#ffbd2e]"></div>
|
|
||||||
<div class="terminal-dot bg-[#28c840]"></div>
|
|
||||||
<span class="ml-3 text-xs text-gray-500 font-mono">Check results</span>
|
|
||||||
</div>
|
|
||||||
<div class="terminal-body text-xs sm:text-[13px]">
|
|
||||||
<pre><span class="text-gray-500">$</span> <span class="text-white">curl</span> https://pingql.com/api/monitors/mon_a1b2c3 \
|
|
||||||
-H <span class="syn-str">"X-Key: XXXX-XXXX-XXXX-XXXX"</span>
|
|
||||||
|
|
||||||
<span class="syn-brace">{</span>
|
|
||||||
<span class="syn-key">"id"</span>: <span class="syn-str">"mon_a1b2c3"</span>,
|
|
||||||
<span class="syn-key">"url"</span>: <span class="syn-str">"https://api.example.com/health"</span>,
|
|
||||||
<span class="syn-key">"status"</span>: <span class="syn-str">"up"</span>,
|
|
||||||
<span class="syn-key">"lastCheck"</span>: <span class="syn-str">"2024-01-15T10:30:00Z"</span>,
|
|
||||||
<span class="syn-key">"responseTime"</span>: <span class="syn-num">142</span>,
|
|
||||||
<span class="syn-key">"uptime30d"</span>: <span class="syn-num">99.97</span>
|
|
||||||
<span class="syn-brace">}</span></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CLI teaser -->
|
|
||||||
<div class="mt-8 text-center">
|
|
||||||
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-gray-800 text-xs text-gray-500 font-mono">
|
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z"/></svg>
|
|
||||||
CLI coming soon — <span class="text-gray-400">pingql watch mon_a1b2c3</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ─── PRICING ─── -->
|
|
||||||
<section id="pricing" class="py-24 px-6">
|
|
||||||
<div class="max-w-3xl mx-auto text-center">
|
|
||||||
<h2 class="text-3xl sm:text-4xl font-bold tracking-tight mb-4">Simple pricing</h2>
|
|
||||||
<p class="text-gray-400 text-lg mb-12">Start for free. No credit card required.</p>
|
|
||||||
|
|
||||||
<div class="grid sm:grid-cols-2 gap-6 max-w-2xl mx-auto">
|
|
||||||
<!-- Free -->
|
|
||||||
<div class="glow-card rounded-xl p-8 text-left">
|
|
||||||
<div class="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">Free</div>
|
|
||||||
<div class="text-4xl font-bold mb-1">$0</div>
|
|
||||||
<div class="text-sm text-gray-500 mb-6">forever</div>
|
|
||||||
<ul class="space-y-3 text-sm text-gray-400">
|
|
||||||
<li class="flex items-center gap-2">
|
|
||||||
<svg class="w-4 h-4 text-green-400 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
|
||||||
Generous monitor limits
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center gap-2">
|
|
||||||
<svg class="w-4 h-4 text-green-400 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
|
||||||
Full query language
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center gap-2">
|
|
||||||
<svg class="w-4 h-4 text-green-400 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
|
||||||
API access
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center gap-2">
|
|
||||||
<svg class="w-4 h-4 text-green-400 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
|
||||||
No credit card
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<a href="/dashboard" class="mt-8 block text-center w-full py-3 bg-brand hover:bg-blue-500 text-white text-sm font-medium rounded-lg transition-colors">
|
|
||||||
Get Started
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pro -->
|
|
||||||
<div class="rounded-xl border border-gray-700 bg-[#111] p-8 text-left relative">
|
|
||||||
<div class="absolute -top-3 right-6 px-3 py-1 bg-gray-800 border border-gray-700 rounded-full text-xs text-gray-400 font-mono">
|
|
||||||
coming soon
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">Pro</div>
|
|
||||||
<div class="text-4xl font-bold mb-1 text-gray-500">TBD</div>
|
|
||||||
<div class="text-sm text-gray-600 mb-6">per month</div>
|
|
||||||
<ul class="space-y-3 text-sm text-gray-500">
|
|
||||||
<li class="flex items-center gap-2">
|
|
||||||
<svg class="w-4 h-4 text-gray-600 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
|
||||||
More monitors
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center gap-2">
|
|
||||||
<svg class="w-4 h-4 text-gray-600 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
|
||||||
Shorter intervals
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center gap-2">
|
|
||||||
<svg class="w-4 h-4 text-gray-600 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
|
||||||
Webhook notifications
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center gap-2">
|
|
||||||
<svg class="w-4 h-4 text-gray-600 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
|
||||||
Priority support
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="mt-8 block text-center w-full py-3 border border-gray-700 text-gray-500 text-sm font-medium rounded-lg cursor-default">
|
|
||||||
Coming Soon
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ─── FOOTER ─── -->
|
|
||||||
<footer class="border-t border-gray-800/50 py-12 px-6">
|
|
||||||
<div class="max-w-5xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-6">
|
|
||||||
<div class="flex items-center gap-6">
|
|
||||||
<a href="/" class="text-lg font-bold tracking-tight font-mono">Ping<span class="text-brand">QL</span></a>
|
|
||||||
<nav class="flex items-center gap-5 text-sm text-gray-500">
|
|
||||||
<a href="/dashboard" class="hover:text-gray-300 transition-colors">Dashboard</a>
|
|
||||||
<a href="/docs" class="hover:text-gray-300 transition-colors">Docs</a>
|
|
||||||
<a href="/privacy" class="hover:text-gray-300 transition-colors">Privacy</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-gray-600">
|
|
||||||
Built by Ico ♟
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>PingQL — New Monitor</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<style>
|
|
||||||
body { font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace; background: #0a0a0a; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="bg-[#0a0a0a] text-gray-100 min-h-screen">
|
|
||||||
<script src="/dashboard/app.js"></script>
|
|
||||||
<script src="/dashboard/query-builder.js"></script>
|
|
||||||
|
|
||||||
<!-- Nav -->
|
|
||||||
<nav class="border-b border-gray-800 px-6 py-4 flex items-center justify-between">
|
|
||||||
<a href="/dashboard/home" class="text-xl font-bold tracking-tight">Ping<span class="text-blue-400">QL</span></a>
|
|
||||||
<button onclick="logout()" class="text-gray-500 hover:text-gray-300 text-sm transition-colors">Logout</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="max-w-2xl mx-auto px-6 py-8">
|
|
||||||
<div class="mb-6">
|
|
||||||
<a href="/dashboard/home" class="text-sm text-gray-500 hover:text-gray-300 transition-colors">← Back to monitors</a>
|
|
||||||
<h2 class="text-lg font-semibold text-gray-200 mt-2">Create Monitor</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="create-form" class="space-y-6">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm text-gray-400 mb-1.5">Name</label>
|
|
||||||
<input id="name" type="text" required placeholder="Production API"
|
|
||||||
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm text-gray-400 mb-1.5">URL</label>
|
|
||||||
<input id="url" type="url" required placeholder="https://api.example.com/health"
|
|
||||||
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm text-gray-400 mb-1.5">Ping Interval</label>
|
|
||||||
<select id="interval"
|
|
||||||
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
|
||||||
<option value="10">10 seconds</option>
|
|
||||||
<option value="30">30 seconds</option>
|
|
||||||
<option value="60" selected>1 minute</option>
|
|
||||||
<option value="300">5 minutes</option>
|
|
||||||
<option value="600">10 minutes</option>
|
|
||||||
<option value="1800">30 minutes</option>
|
|
||||||
<option value="3600">1 hour</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm text-gray-400 mb-1.5">Query Conditions <span class="text-gray-600">(optional)</span></label>
|
|
||||||
<p class="text-xs text-gray-600 mb-3">Define when this monitor should be considered "up". Defaults to status < 400.</p>
|
|
||||||
<div id="query-builder"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="form-error" class="text-red-400 text-sm hidden"></div>
|
|
||||||
|
|
||||||
<button type="submit" id="submit-btn"
|
|
||||||
class="w-full bg-blue-600 hover:bg-blue-500 text-white font-medium py-3 rounded-lg transition-colors">
|
|
||||||
Create Monitor
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
if (!requireAuth()) throw 'auth';
|
|
||||||
|
|
||||||
let currentQuery = null;
|
|
||||||
const qb = new QueryBuilder(document.getElementById('query-builder'), (q) => {
|
|
||||||
currentQuery = q;
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('create-form').addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const btn = document.getElementById('submit-btn');
|
|
||||||
const errEl = document.getElementById('form-error');
|
|
||||||
errEl.classList.add('hidden');
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = 'Creating...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = {
|
|
||||||
name: document.getElementById('name').value.trim(),
|
|
||||||
url: document.getElementById('url').value.trim(),
|
|
||||||
interval_s: Number(document.getElementById('interval').value),
|
|
||||||
};
|
|
||||||
if (currentQuery) body.query = currentQuery;
|
|
||||||
|
|
||||||
await api('/monitors/', { method: 'POST', body });
|
|
||||||
window.location.href = '/dashboard/home';
|
|
||||||
} catch (err) {
|
|
||||||
errEl.textContent = err.message;
|
|
||||||
errEl.classList.remove('hidden');
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'Create Monitor';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,19 +1,18 @@
|
|||||||
// PingQL Visual Query Builder
|
|
||||||
|
|
||||||
const FIELDS = [
|
const FIELDS = [
|
||||||
{ name: 'status', label: 'Status Code', type: 'number', operators: ['$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$in'] },
|
{ name: 'status', label: 'Status Code', type: 'number', operators: ['$eq', '$ne', '$gt', '$ge', '$lt', '$le'] },
|
||||||
{ name: 'body', label: 'Response Body', type: 'string', operators: ['$eq', '$ne', '$contains', '$startsWith', '$endsWith', '$regex', '$exists'] },
|
{ name: 'body', label: 'Response Body', type: 'string', operators: ['$eq', '$ne', '$co', '$sw', '$ew', '$re', '$exists'] },
|
||||||
{ name: 'headers.*', label: 'Header', type: 'string', operators: ['$eq', '$ne', '$contains', '$startsWith', '$endsWith', '$regex', '$exists'] },
|
{ name: 'headers.*', label: 'Header', type: 'string', operators: ['$eq', '$ne', '$co', '$sw', '$ew', '$re', '$exists'] },
|
||||||
{ name: '$select', label: 'CSS Selector', type: 'selector', operators: ['$eq', '$ne', '$contains', '$startsWith', '$endsWith', '$regex'] },
|
{ name: '$html', label: 'HTML Selector', type: 'selector', operators: ['$eq', '$ne', '$co', '$sw', '$ew', '$re'] },
|
||||||
{ name: '$json', label: 'JSON Path', type: 'jsonpath', operators: ['$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$contains', '$regex'] },
|
{ name: '$json', label: 'JSON Path', type: 'jsonpath', operators: ['$eq', '$ne', '$gt', '$ge', '$lt', '$le', '$co', '$re'] },
|
||||||
{ name: '$responseTime', label: 'Response Time (ms)', type: 'number', operators: ['$eq', '$gt', '$gte', '$lt', '$lte'] },
|
{ name: '$time', label: 'Response Time (ms)', type: 'number', operators: ['$eq', '$gt', '$ge', '$lt', '$le'] },
|
||||||
{ name: '$certExpiry', label: 'Cert Expiry (days)', type: 'number', operators: ['$eq', '$gt', '$gte', '$lt', '$lte'] },
|
{ name: '$certExpiry', label: 'Cert Expiry (days)', type: 'number', operators: ['$eq', '$gt', '$ge', '$lt', '$le'] },
|
||||||
|
{ name: '$certIssuer', label: 'Cert Issuer', type: 'string', operators: ['$eq', '$ne', '$co', '$sw', '$ew', '$re'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
const OP_LABELS = {
|
const OP_LABELS = {
|
||||||
'$eq': '=', '$ne': '≠', '$gt': '>', '$gte': '≥', '$lt': '<', '$lte': '≤',
|
'$eq': '=', '$ne': '≠', '$gt': '>', '$ge': '≥', '$lt': '<', '$le': '≤',
|
||||||
'$contains': 'contains', '$startsWith': 'starts with', '$endsWith': 'ends with',
|
'$co': 'contains', '$sw': 'starts with', '$ew': 'ends with',
|
||||||
'$regex': 'matches regex', '$exists': 'exists', '$in': 'in',
|
'$re': 'matches regex', '$exists': 'exists',
|
||||||
};
|
};
|
||||||
|
|
||||||
class QueryBuilder {
|
class QueryBuilder {
|
||||||
@@ -21,22 +20,35 @@ class QueryBuilder {
|
|||||||
this.container = container;
|
this.container = container;
|
||||||
this.onChange = onChange;
|
this.onChange = onChange;
|
||||||
this.logic = '$and';
|
this.logic = '$and';
|
||||||
this.consider = 'up'; // 'up' | 'down'
|
this.invert = false; // true = wrap in $not (match = down)
|
||||||
this.rules = [this._emptyRule()];
|
this.rules = this._defaultRules();
|
||||||
this.render();
|
this.render();
|
||||||
|
// Notify the form that the default query is already populated, so submitting
|
||||||
|
// without edits sends the default explicitly instead of leaving it null.
|
||||||
|
if (this.onChange) this.onChange(this.getQuery());
|
||||||
}
|
}
|
||||||
|
|
||||||
_emptyRule() {
|
_emptyRule() {
|
||||||
return { field: 'status', operator: '$eq', value: '', headerName: '', selectorValue: '', jsonPath: '' };
|
return { field: 'status', operator: '$eq', value: '', headerName: '', selectorValue: '', jsonPath: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The runtime default when no query is set: status is 2xx. Pre-populate the
|
||||||
|
// builder with this so users see what "default" actually means and can edit
|
||||||
|
// it instead of guessing.
|
||||||
|
_defaultRules() {
|
||||||
|
return [
|
||||||
|
{ field: 'status', operator: '$ge', value: '200', headerName: '', selectorValue: '', jsonPath: '' },
|
||||||
|
{ field: 'status', operator: '$lt', value: '300', headerName: '', selectorValue: '', jsonPath: '' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
getQuery() {
|
getQuery() {
|
||||||
const conditions = this.rules
|
const conditions = this.rules
|
||||||
.map(r => this._ruleToQuery(r))
|
.map(r => this._ruleToQuery(r))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
if (conditions.length === 0) return null;
|
if (conditions.length === 0) return null;
|
||||||
const base = conditions.length === 1 ? conditions[0] : { [this.logic]: conditions };
|
const base = conditions.length === 1 ? conditions[0] : { [this.logic]: conditions };
|
||||||
if (this.consider === 'down') return { $consider: 'down', ...base };
|
if (this.invert) return { $not: base };
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,11 +58,11 @@ class QueryBuilder {
|
|||||||
|
|
||||||
const parsedVal = this._parseValue(value, field, operator);
|
const parsedVal = this._parseValue(value, field, operator);
|
||||||
|
|
||||||
if (field === '$responseTime' || field === '$certExpiry') {
|
if (field === '$time' || field === '$certExpiry' || field === '$certIssuer') {
|
||||||
return { [field]: { [operator]: parsedVal } };
|
return { [field]: { [operator]: parsedVal } };
|
||||||
}
|
}
|
||||||
if (field === '$select') {
|
if (field === '$html') {
|
||||||
return { '$select': { [rule.selectorValue || '*']: { [operator]: parsedVal } } };
|
return { '$html': { [rule.selectorValue || '*']: { [operator]: parsedVal } } };
|
||||||
}
|
}
|
||||||
if (field === '$json') {
|
if (field === '$json') {
|
||||||
return { '$json': { [rule.jsonPath || '$']: { [operator]: parsedVal } } };
|
return { '$json': { [rule.jsonPath || '$']: { [operator]: parsedVal } } };
|
||||||
@@ -61,22 +73,14 @@ class QueryBuilder {
|
|||||||
return { [headerField]: { [operator]: parsedVal } };
|
return { [headerField]: { [operator]: parsedVal } };
|
||||||
}
|
}
|
||||||
if (operator === '$exists') return { [field]: { '$exists': parsedVal } };
|
if (operator === '$exists') return { [field]: { '$exists': parsedVal } };
|
||||||
// Simple shorthand for $eq on basic fields
|
|
||||||
if (operator === '$eq') return { [field]: parsedVal };
|
if (operator === '$eq') return { [field]: parsedVal };
|
||||||
return { [field]: { [operator]: parsedVal } };
|
return { [field]: { [operator]: parsedVal } };
|
||||||
}
|
}
|
||||||
|
|
||||||
_parseValue(value, field, operator) {
|
_parseValue(value, field, operator) {
|
||||||
if (operator === '$exists') return value !== 'false' && value !== '0';
|
if (operator === '$exists') return value !== 'false' && value !== '0';
|
||||||
if (operator === '$in') {
|
|
||||||
return value.split(',').map(v => {
|
|
||||||
const trimmed = v.trim();
|
|
||||||
const n = Number(trimmed);
|
|
||||||
return isNaN(n) ? trimmed : n;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const fieldDef = FIELDS.find(f => f.name === field);
|
const fieldDef = FIELDS.find(f => f.name === field);
|
||||||
const numericOps = ['$eq', '$ne', '$gt', '$gte', '$lt', '$lte'];
|
const numericOps = ['$eq', '$ne', '$gt', '$ge', '$lt', '$le'];
|
||||||
if (fieldDef?.type === 'number' || (numericOps.includes(operator) && fieldDef?.type === 'jsonpath')) {
|
if (fieldDef?.type === 'number' || (numericOps.includes(operator) && fieldDef?.type === 'jsonpath')) {
|
||||||
const n = Number(value);
|
const n = Number(value);
|
||||||
return isNaN(n) ? value : n;
|
return isNaN(n) ? value : n;
|
||||||
@@ -86,16 +90,21 @@ class QueryBuilder {
|
|||||||
|
|
||||||
setQuery(query) {
|
setQuery(query) {
|
||||||
if (!query || typeof query !== 'object') {
|
if (!query || typeof query !== 'object') {
|
||||||
this.rules = [this._emptyRule()];
|
this.rules = this._defaultRules();
|
||||||
this.logic = '$and';
|
this.logic = '$and';
|
||||||
this.consider = 'up';
|
this.invert = false;
|
||||||
this.render();
|
this.render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip $consider before parsing rules
|
// Detect $not wrapper (inverted query = match means down)
|
||||||
this.consider = query.$consider === 'down' ? 'down' : 'up';
|
let q = query;
|
||||||
const q = Object.fromEntries(Object.entries(query).filter(([k]) => k !== '$consider'));
|
if ('$not' in query && Object.keys(query).length === 1) {
|
||||||
|
this.invert = true;
|
||||||
|
q = query.$not;
|
||||||
|
} else {
|
||||||
|
this.invert = false;
|
||||||
|
}
|
||||||
|
|
||||||
if ('$and' in q || '$or' in q) {
|
if ('$and' in q || '$or' in q) {
|
||||||
this.logic = '$and' in q ? '$and' : '$or';
|
this.logic = '$and' in q ? '$and' : '$or';
|
||||||
@@ -109,13 +118,14 @@ class QueryBuilder {
|
|||||||
|
|
||||||
if (this.rules.length === 0) this.rules = [this._emptyRule()];
|
if (this.rules.length === 0) this.rules = [this._emptyRule()];
|
||||||
this.render();
|
this.render();
|
||||||
|
this.onChange?.(this.getQuery());
|
||||||
}
|
}
|
||||||
|
|
||||||
_queryToRule(clause) {
|
_queryToRule(clause) {
|
||||||
if (!clause || typeof clause !== 'object') return this._emptyRule();
|
if (!clause || typeof clause !== 'object') return this._emptyRule();
|
||||||
|
|
||||||
if ('$responseTime' in clause || '$certExpiry' in clause) {
|
if ('$time' in clause || '$certExpiry' in clause || '$certIssuer' in clause) {
|
||||||
const field = '$responseTime' in clause ? '$responseTime' : '$certExpiry';
|
const field = '$time' in clause ? '$time' : '$certIssuer' in clause ? '$certIssuer' : '$certExpiry';
|
||||||
const ops = clause[field];
|
const ops = clause[field];
|
||||||
if (typeof ops === 'object') {
|
if (typeof ops === 'object') {
|
||||||
const [operator, value] = Object.entries(ops)[0] || ['$lt', ''];
|
const [operator, value] = Object.entries(ops)[0] || ['$lt', ''];
|
||||||
@@ -123,13 +133,13 @@ class QueryBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('$select' in clause) {
|
if ('$html' in clause) {
|
||||||
const selMap = clause.$select;
|
const selMap = clause.$html;
|
||||||
if (selMap && typeof selMap === 'object') {
|
if (selMap && typeof selMap === 'object') {
|
||||||
const [selectorValue, condition] = Object.entries(selMap)[0] || ['*', {}];
|
const [selectorValue, condition] = Object.entries(selMap)[0] || ['*', {}];
|
||||||
if (condition && typeof condition === 'object') {
|
if (condition && typeof condition === 'object') {
|
||||||
const [operator, value] = Object.entries(condition)[0] || ['$eq', ''];
|
const [operator, value] = Object.entries(condition)[0] || ['$eq', ''];
|
||||||
return { field: '$select', operator, value: String(value), headerName: '', selectorValue, jsonPath: '' };
|
return { field: '$html', operator, value: String(value), headerName: '', selectorValue, jsonPath: '' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,10 +182,10 @@ class QueryBuilder {
|
|||||||
this.container.innerHTML = `
|
this.container.innerHTML = `
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex items-center gap-2 mb-4 flex-wrap">
|
<div class="flex items-center gap-2 mb-4 flex-wrap">
|
||||||
<span class="text-sm text-gray-400">Consider</span>
|
<span class="text-sm text-gray-400">Mark</span>
|
||||||
<select id="qb-consider" class="bg-gray-800 border border-gray-700 text-sm rounded px-2 py-1 focus:border-blue-500 focus:outline-none font-medium ${this.consider === 'down' ? 'text-red-400' : 'text-green-400'}">
|
<select id="qb-consider" class="bg-gray-800 border border-gray-700 text-sm rounded px-2 py-1 focus:border-blue-500 focus:outline-none font-medium ${this.invert ? 'text-red-400' : 'text-green-400'}">
|
||||||
<option value="up" ${this.consider === 'up' ? 'selected' : ''}>UP</option>
|
<option value="up" ${!this.invert ? 'selected' : ''}>UP</option>
|
||||||
<option value="down" ${this.consider === 'down' ? 'selected' : ''}>DOWN</option>
|
<option value="down" ${this.invert ? 'selected' : ''}>DOWN</option>
|
||||||
</select>
|
</select>
|
||||||
<span class="text-sm text-gray-400">when</span>
|
<span class="text-sm text-gray-400">when</span>
|
||||||
<select id="qb-logic" class="bg-gray-800 border border-gray-700 text-gray-200 text-sm rounded px-2 py-1 focus:border-blue-500 focus:outline-none">
|
<select id="qb-logic" class="bg-gray-800 border border-gray-700 text-gray-200 text-sm rounded px-2 py-1 focus:border-blue-500 focus:outline-none">
|
||||||
@@ -196,14 +206,13 @@ class QueryBuilder {
|
|||||||
<span class="text-xs text-gray-500 font-mono">Query JSON</span>
|
<span class="text-xs text-gray-500 font-mono">Query JSON</span>
|
||||||
<button id="qb-copy" class="text-xs text-blue-400 hover:text-blue-300">Copy</button>
|
<button id="qb-copy" class="text-xs text-blue-400 hover:text-blue-300">Copy</button>
|
||||||
</div>
|
</div>
|
||||||
<pre id="qb-preview" class="text-xs text-gray-300 font-mono whitespace-pre-wrap overflow-x-auto">${query ? escapeHtml(JSON.stringify(query, null, 2)) : '<span class="text-gray-600">No conditions set</span>'}</pre>
|
<pre id="qb-preview" class="text-xs text-gray-300 font-mono whitespace-pre-wrap overflow-x-auto">${query ? escapeHtml(formatQueryJson(query)) : '<span class="text-gray-600">No conditions set</span>'}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Bind events
|
|
||||||
this.container.querySelector('#qb-consider').addEventListener('change', (e) => {
|
this.container.querySelector('#qb-consider').addEventListener('change', (e) => {
|
||||||
this.consider = e.target.value;
|
this.invert = e.target.value === 'down';
|
||||||
this.render();
|
this.render();
|
||||||
this.onChange?.(this.getQuery());
|
this.onChange?.(this.getQuery());
|
||||||
});
|
});
|
||||||
@@ -222,7 +231,7 @@ class QueryBuilder {
|
|||||||
|
|
||||||
this.container.querySelector('#qb-copy').addEventListener('click', () => {
|
this.container.querySelector('#qb-copy').addEventListener('click', () => {
|
||||||
const q = this.getQuery();
|
const q = this.getQuery();
|
||||||
navigator.clipboard.writeText(q ? JSON.stringify(q, null, 2) : '{}');
|
navigator.clipboard.writeText(q ? formatQueryJson(q) : '{}');
|
||||||
const btn = this.container.querySelector('#qb-copy');
|
const btn = this.container.querySelector('#qb-copy');
|
||||||
btn.textContent = 'Copied!';
|
btn.textContent = 'Copied!';
|
||||||
setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
|
setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
|
||||||
@@ -238,7 +247,7 @@ class QueryBuilder {
|
|||||||
const operators = fieldDef.operators;
|
const operators = fieldDef.operators;
|
||||||
|
|
||||||
const needsHeader = rule.field === 'headers.*';
|
const needsHeader = rule.field === 'headers.*';
|
||||||
const needsSelector = rule.field === '$select';
|
const needsSelector = rule.field === '$html';
|
||||||
const needsJsonPath = rule.field === '$json';
|
const needsJsonPath = rule.field === '$json';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -318,7 +327,50 @@ class QueryBuilder {
|
|||||||
const q = this.getQuery();
|
const q = this.getQuery();
|
||||||
const preview = this.container.querySelector('#qb-preview');
|
const preview = this.container.querySelector('#qb-preview');
|
||||||
if (preview) {
|
if (preview) {
|
||||||
preview.textContent = q ? JSON.stringify(q, null, 2) : 'No conditions set';
|
preview.textContent = q ? formatQueryJson(q) : 'No conditions set';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Standard 2-space JSON prettifier with one twist: any subtree whose single-line
|
||||||
|
// form fits within MAX_INLINE chars at its current column gets inlined. Breaks
|
||||||
|
// always use clean 2-space indent - never key-aligned.
|
||||||
|
const MAX_INLINE = 60;
|
||||||
|
|
||||||
|
function formatQueryJson(value, indent = 0, startCol = indent) {
|
||||||
|
const inline = inlineForm(value);
|
||||||
|
if (startCol + inline.length <= MAX_INLINE) return inline;
|
||||||
|
|
||||||
|
const childIndent = indent + 2;
|
||||||
|
const pad = ' '.repeat(childIndent);
|
||||||
|
const close = ' '.repeat(indent);
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) return '[]';
|
||||||
|
const items = value.map(v => pad + formatQueryJson(v, childIndent));
|
||||||
|
return '[\n' + items.join(',\n') + '\n' + close + ']';
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const entries = Object.entries(value);
|
||||||
|
if (entries.length === 0) return '{}';
|
||||||
|
const items = entries.map(([k, v]) => {
|
||||||
|
const keyStr = JSON.stringify(k) + ': ';
|
||||||
|
// Recurse at childIndent for break depth, but tell the recursion the
|
||||||
|
// value's actual column for the inline-fit check.
|
||||||
|
return pad + keyStr + formatQueryJson(v, childIndent, childIndent + keyStr.length);
|
||||||
|
});
|
||||||
|
return '{\n' + items.join(',\n') + '\n' + close + '}';
|
||||||
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inlineForm(value) {
|
||||||
|
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) return '[]';
|
||||||
|
return '[ ' + value.map(inlineForm).join(', ') + ' ]';
|
||||||
|
}
|
||||||
|
const entries = Object.entries(value);
|
||||||
|
if (entries.length === 0) return '{}';
|
||||||
|
return '{ ' + entries.map(([k, v]) => JSON.stringify(k) + ': ' + inlineForm(v)).join(', ') + ' }';
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,234 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" class="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>PingQL — Settings</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<style>
|
|
||||||
body { font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace; background: #0a0a0a; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="bg-[#0a0a0a] text-gray-100 min-h-screen">
|
|
||||||
|
|
||||||
<nav class="border-b border-gray-800 px-6 py-4 flex items-center justify-between">
|
|
||||||
<a href="/dashboard/home" class="text-xl font-bold tracking-tight">Ping<span class="text-blue-400">QL</span></a>
|
|
||||||
<div class="flex items-center gap-5 text-sm text-gray-500">
|
|
||||||
<a href="/dashboard/home" class="hover:text-gray-300 transition-colors">Monitors</a>
|
|
||||||
<span class="text-gray-300">Settings</span>
|
|
||||||
<button onclick="logout()" class="hover:text-gray-300 transition-colors">Logout</button>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="max-w-2xl mx-auto px-6 py-10 space-y-8">
|
|
||||||
|
|
||||||
<h1 class="text-xl font-semibold text-white">Settings</h1>
|
|
||||||
|
|
||||||
<!-- Account info -->
|
|
||||||
<section class="bg-gray-900 rounded-xl border border-gray-800 p-6">
|
|
||||||
<h2 class="text-sm font-semibold text-gray-300 mb-4">Account</h2>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Primary Key</label>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<code id="primary-key" class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-blue-400 text-sm tracking-widest"></code>
|
|
||||||
<button onclick="copyKey()" class="px-3 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-400 hover:text-white transition-colors text-xs">Copy</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Member since</label>
|
|
||||||
<p id="created-at" class="text-sm text-gray-400"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Email -->
|
|
||||||
<section class="bg-gray-900 rounded-xl border border-gray-800 p-6">
|
|
||||||
<h2 class="text-sm font-semibold text-gray-300 mb-1">Recovery Email</h2>
|
|
||||||
<p class="text-xs text-gray-600 mb-4">Used for account recovery only. Stored as a one-way hash — we can't read it.</p>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<input id="email-input" type="email" placeholder="you@example.com"
|
|
||||||
class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm">
|
|
||||||
<button onclick="saveEmail()" id="email-btn"
|
|
||||||
class="px-4 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-medium transition-colors">Save</button>
|
|
||||||
<button onclick="removeEmail()" id="remove-email-btn" class="hidden px-3 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-500 hover:text-red-400 text-xs transition-colors">Remove</button>
|
|
||||||
</div>
|
|
||||||
<p id="email-status" class="text-xs mt-2 hidden"></p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Reset primary key -->
|
|
||||||
<section class="bg-gray-900 rounded-xl border border-gray-800 p-6">
|
|
||||||
<h2 class="text-sm font-semibold text-gray-300 mb-1">Rotate Primary Key</h2>
|
|
||||||
<p class="text-xs text-gray-600 mb-4">Generates a new primary key. Your old key will stop working immediately. Sub-keys are not affected.</p>
|
|
||||||
<button onclick="confirmReset()" class="px-4 py-2.5 bg-gray-800 hover:bg-gray-700 border border-red-900/50 hover:border-red-700/50 text-red-400 rounded-lg text-sm transition-colors">Rotate Key</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Sub-keys -->
|
|
||||||
<section class="bg-gray-900 rounded-xl border border-gray-800 p-6">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-sm font-semibold text-gray-300">API Keys</h2>
|
|
||||||
<p class="text-xs text-gray-600 mt-0.5">Create separate keys for different apps, scripts, or teammates.</p>
|
|
||||||
</div>
|
|
||||||
<button onclick="showCreateKey()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-xs font-medium transition-colors">+ New Key</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Create key form (hidden by default) -->
|
|
||||||
<div id="create-key-form" class="hidden mb-4 p-4 bg-gray-800/50 rounded-lg border border-gray-700">
|
|
||||||
<label class="block text-xs text-gray-500 mb-1.5">Label</label>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<input id="key-label" type="text" placeholder="e.g. ci-pipeline, mobile-app"
|
|
||||||
class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm">
|
|
||||||
<button onclick="createKey()" class="px-4 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-medium transition-colors">Create</button>
|
|
||||||
<button onclick="hideCreateKey()" class="px-3 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-500 text-sm transition-colors">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- New key reveal (shown after creation) -->
|
|
||||||
<div id="new-key-reveal" class="hidden mb-4 p-4 bg-green-950/30 rounded-lg border border-green-900/50">
|
|
||||||
<p class="text-xs text-green-400 mb-2">Key created — copy it now.</p>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<code id="new-key-value" class="flex-1 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-blue-400 text-sm tracking-widest"></code>
|
|
||||||
<button onclick="copyNewKey()" class="px-3 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-400 hover:text-white text-xs transition-colors">Copy</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Keys list -->
|
|
||||||
<div id="keys-list" class="space-y-2">
|
|
||||||
<p class="text-xs text-gray-600 italic">No API keys yet.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script src="/dashboard/app.js"></script>
|
|
||||||
<script>
|
|
||||||
if (!requireAuth()) throw 'auth';
|
|
||||||
|
|
||||||
const key = localStorage.getItem('pingql_key');
|
|
||||||
|
|
||||||
async function loadSettings() {
|
|
||||||
const data = await api('/account/settings');
|
|
||||||
|
|
||||||
document.getElementById('primary-key').textContent = key;
|
|
||||||
document.getElementById('created-at').textContent = new Date(data.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
|
||||||
|
|
||||||
if (data.has_email) {
|
|
||||||
document.getElementById('remove-email-btn').classList.remove('hidden');
|
|
||||||
document.getElementById('email-input').placeholder = '●●●●●●●● (set)';
|
|
||||||
}
|
|
||||||
|
|
||||||
renderKeys(data.api_keys);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderKeys(keys) {
|
|
||||||
const el = document.getElementById('keys-list');
|
|
||||||
if (!keys.length) {
|
|
||||||
el.innerHTML = '<p class="text-xs text-gray-600 italic">No API keys yet.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
el.innerHTML = keys.map(k => `
|
|
||||||
<div class="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg border border-gray-700/50">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm text-gray-200">${escapeHtml(k.label)}</p>
|
|
||||||
<p class="text-xs text-gray-600 mt-0.5 font-mono">${k.id} · created ${new Date(k.created_at).toLocaleDateString()} ${k.last_used_at ? `· last used ${timeAgo(k.last_used_at)}` : '· never used'}</p>
|
|
||||||
</div>
|
|
||||||
<button onclick="deleteKey('${k.id}')" class="text-xs text-gray-600 hover:text-red-400 transition-colors px-2 py-1">Revoke</button>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyKey() {
|
|
||||||
navigator.clipboard.writeText(key);
|
|
||||||
const btn = event.target;
|
|
||||||
btn.textContent = 'Copied!';
|
|
||||||
setTimeout(() => btn.textContent = 'Copy', 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveEmail() {
|
|
||||||
const email = document.getElementById('email-input').value.trim();
|
|
||||||
if (!email) return;
|
|
||||||
const btn = document.getElementById('email-btn');
|
|
||||||
btn.disabled = true; btn.textContent = 'Saving...';
|
|
||||||
try {
|
|
||||||
await api('/account/email', { method: 'POST', body: { email } });
|
|
||||||
showStatus('email-status', 'Saved.', 'green');
|
|
||||||
document.getElementById('remove-email-btn').classList.remove('hidden');
|
|
||||||
document.getElementById('email-input').value = '';
|
|
||||||
document.getElementById('email-input').placeholder = '●●●●●●●● (set)';
|
|
||||||
} catch (e) {
|
|
||||||
showStatus('email-status', e.message, 'red');
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false; btn.textContent = 'Save';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeEmail() {
|
|
||||||
if (!confirm('Remove recovery email?')) return;
|
|
||||||
await api('/account/email', { method: 'POST', body: { email: null } });
|
|
||||||
document.getElementById('remove-email-btn').classList.add('hidden');
|
|
||||||
document.getElementById('email-input').placeholder = 'you@example.com';
|
|
||||||
showStatus('email-status', 'Email removed.', 'gray');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmReset() {
|
|
||||||
if (!confirm('Rotate your primary key?\n\nYour current key will stop working immediately. Make sure to copy the new one.')) return;
|
|
||||||
const data = await api('/account/reset-key', { method: 'POST', body: {} });
|
|
||||||
localStorage.setItem('pingql_key', data.key);
|
|
||||||
document.getElementById('primary-key').textContent = data.key;
|
|
||||||
alert(`New key: ${data.key}\n\nYour old key is now invalid.`);
|
|
||||||
location.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showCreateKey() {
|
|
||||||
document.getElementById('create-key-form').classList.remove('hidden');
|
|
||||||
document.getElementById('new-key-reveal').classList.add('hidden');
|
|
||||||
document.getElementById('key-label').focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideCreateKey() {
|
|
||||||
document.getElementById('create-key-form').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createKey() {
|
|
||||||
const label = document.getElementById('key-label').value.trim();
|
|
||||||
if (!label) return;
|
|
||||||
const data = await api('/account/keys', { method: 'POST', body: { label } });
|
|
||||||
document.getElementById('new-key-value').textContent = data.key;
|
|
||||||
document.getElementById('new-key-reveal').classList.remove('hidden');
|
|
||||||
hideCreateKey();
|
|
||||||
document.getElementById('key-label').value = '';
|
|
||||||
loadSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyNewKey() {
|
|
||||||
const val = document.getElementById('new-key-value').textContent;
|
|
||||||
navigator.clipboard.writeText(val);
|
|
||||||
const btn = event.target;
|
|
||||||
btn.textContent = 'Copied!';
|
|
||||||
btn.classList.add('text-green-400');
|
|
||||||
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('text-green-400'); }, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteKey(id) {
|
|
||||||
if (!confirm('Revoke this key? Any apps using it will stop working.')) return;
|
|
||||||
await api(`/account/keys/${id}`, { method: 'DELETE' });
|
|
||||||
loadSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showStatus(id, msg, color) {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
el.textContent = msg;
|
|
||||||
el.className = `text-xs mt-2 text-${color}-400`;
|
|
||||||
el.classList.remove('hidden');
|
|
||||||
setTimeout(() => el.classList.add('hidden'), 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(s) {
|
|
||||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
||||||
}
|
|
||||||
|
|
||||||
loadSettings();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
File diff suppressed because one or more lines are too long
+7
-55
@@ -1,62 +1,14 @@
|
|||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
|
import { migrate as sharedMigrate } from "../../shared/db";
|
||||||
|
|
||||||
const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@localhost:5432/pingql");
|
const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@localhost:5432/pingql", {
|
||||||
|
max: 20,
|
||||||
|
idle_timeout: 30,
|
||||||
|
connect_timeout: 10,
|
||||||
|
});
|
||||||
|
|
||||||
export default sql;
|
export default sql;
|
||||||
|
|
||||||
export async function migrate() {
|
export async function migrate() {
|
||||||
await sql`
|
await sharedMigrate(sql);
|
||||||
CREATE TABLE IF NOT EXISTS accounts (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
key TEXT NOT NULL UNIQUE,
|
|
||||||
email_hash TEXT,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
|
||||||
)
|
|
||||||
`;
|
|
||||||
|
|
||||||
await sql`
|
|
||||||
CREATE TABLE IF NOT EXISTS monitors (
|
|
||||||
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
||||||
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
url TEXT NOT NULL,
|
|
||||||
method TEXT NOT NULL DEFAULT 'GET',
|
|
||||||
request_headers JSONB,
|
|
||||||
request_body TEXT,
|
|
||||||
timeout_ms INTEGER NOT NULL DEFAULT 30000,
|
|
||||||
interval_s INTEGER NOT NULL DEFAULT 60,
|
|
||||||
query JSONB,
|
|
||||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
|
||||||
)
|
|
||||||
`;
|
|
||||||
|
|
||||||
await sql`
|
|
||||||
CREATE TABLE IF NOT EXISTS pings (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
|
||||||
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
status_code INTEGER,
|
|
||||||
latency_ms INTEGER,
|
|
||||||
up BOOLEAN NOT NULL,
|
|
||||||
error TEXT,
|
|
||||||
meta JSONB
|
|
||||||
)
|
|
||||||
`;
|
|
||||||
|
|
||||||
await sql`CREATE INDEX IF NOT EXISTS idx_pings_monitor ON pings(monitor_id, checked_at DESC)`;
|
|
||||||
await sql`CREATE INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`;
|
|
||||||
|
|
||||||
await sql`
|
|
||||||
CREATE TABLE IF NOT EXISTS api_keys (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
key TEXT NOT NULL UNIQUE,
|
|
||||||
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
||||||
label TEXT NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now(),
|
|
||||||
last_used_at TIMESTAMPTZ
|
|
||||||
)
|
|
||||||
`;
|
|
||||||
|
|
||||||
console.log("DB ready");
|
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-10
@@ -1,25 +1,23 @@
|
|||||||
import { Elysia } from "elysia";
|
import { Elysia } from "elysia";
|
||||||
import { cors } from "@elysiajs/cors";
|
import { cors } from "@elysiajs/cors";
|
||||||
import { ingest } from "./routes/pings";
|
|
||||||
import { monitors } from "./routes/monitors";
|
|
||||||
import { account } from "./routes/auth";
|
|
||||||
import { internal } from "./routes/internal";
|
|
||||||
import { dashboard } from "./routes/dashboard";
|
import { dashboard } from "./routes/dashboard";
|
||||||
|
import { account } from "./routes/auth";
|
||||||
import { migrate } from "./db";
|
import { migrate } from "./db";
|
||||||
|
|
||||||
await migrate();
|
await migrate();
|
||||||
|
|
||||||
|
import { SECURITY_HEADERS } from "../../shared/auth";
|
||||||
|
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
|
.onAfterHandle(({ set }) => {
|
||||||
|
Object.assign(set.headers, SECURITY_HEADERS);
|
||||||
|
})
|
||||||
.use(cors({
|
.use(cors({
|
||||||
origin: process.env.CORS_ORIGINS?.split(",") ?? ["https://pingql.com", "https://api.pingql.com"],
|
origin: process.env.CORS_ORIGINS?.split(",") ?? ["https://pingql.com"],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
}))
|
}))
|
||||||
.get("/", ({ set }) => { set.headers["content-type"] = "text/html"; return Bun.file(`${import.meta.dir}/dashboard/landing.html`); })
|
|
||||||
.use(dashboard)
|
.use(dashboard)
|
||||||
.use(account)
|
.use(account)
|
||||||
.use(monitors)
|
|
||||||
.use(ingest)
|
|
||||||
.use(internal)
|
|
||||||
.listen(3000);
|
.listen(3000);
|
||||||
|
|
||||||
console.log(`PingQL running at http://localhost:${app.server?.port}`);
|
console.log(`PingQL Web running at http://localhost:${app.server?.port}`);
|
||||||
|
|||||||
@@ -1,330 +0,0 @@
|
|||||||
/**
|
|
||||||
* PingQL Query Engine — TypeScript implementation
|
|
||||||
*
|
|
||||||
* MongoDB-inspired query language for evaluating HTTP response conditions.
|
|
||||||
* Mirrors the Rust implementation but also powers the visual query builder.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface QueryField {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
type: "number" | "string" | "boolean" | "object";
|
|
||||||
operators: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EvalContext {
|
|
||||||
status: number;
|
|
||||||
body: string;
|
|
||||||
headers: Record<string, string>;
|
|
||||||
latency_ms?: number;
|
|
||||||
cert_expiry_days?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ValidationError {
|
|
||||||
path: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Available fields ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function getAvailableFields(): QueryField[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: "status",
|
|
||||||
description: "HTTP status code (e.g. 200, 404, 500)",
|
|
||||||
type: "number",
|
|
||||||
operators: ["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "body",
|
|
||||||
description: "Response body as text",
|
|
||||||
type: "string",
|
|
||||||
operators: ["$eq", "$ne", "$contains", "$startsWith", "$endsWith", "$regex", "$exists"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "headers.*",
|
|
||||||
description: "Response header value (e.g. headers.content-type)",
|
|
||||||
type: "string",
|
|
||||||
operators: ["$eq", "$ne", "$contains", "$startsWith", "$endsWith", "$regex", "$exists"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "$select",
|
|
||||||
description: "CSS selector — returns text content of first matching element",
|
|
||||||
type: "string",
|
|
||||||
operators: ["$eq", "$ne", "$contains", "$startsWith", "$endsWith", "$regex"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "$json",
|
|
||||||
description: "JSONPath expression evaluated against response body (e.g. $.data.status)",
|
|
||||||
type: "string",
|
|
||||||
operators: ["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$contains", "$regex"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "$responseTime",
|
|
||||||
description: "Request latency in milliseconds",
|
|
||||||
type: "number",
|
|
||||||
operators: ["$eq", "$gt", "$gte", "$lt", "$lte"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "$certExpiry",
|
|
||||||
description: "Days until SSL certificate expires",
|
|
||||||
type: "number",
|
|
||||||
operators: ["$eq", "$gt", "$gte", "$lt", "$lte"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Evaluate ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function evaluate(query: unknown, ctx: EvalContext): boolean {
|
|
||||||
if (query === null || query === undefined) return true;
|
|
||||||
if (typeof query !== "object" || Array.isArray(query)) {
|
|
||||||
throw new Error("Query must be an object");
|
|
||||||
}
|
|
||||||
|
|
||||||
const q = query as Record<string, unknown>;
|
|
||||||
|
|
||||||
// $consider — "up" (default) or "down": flips result if conditions match
|
|
||||||
if ("$consider" in q) {
|
|
||||||
const consider = q.$consider as string;
|
|
||||||
const rest = Object.fromEntries(Object.entries(q).filter(([k]) => k !== "$consider"));
|
|
||||||
const matches = evaluate(rest, ctx);
|
|
||||||
return consider === "down" ? !matches : matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
// $and
|
|
||||||
if ("$and" in q) {
|
|
||||||
const clauses = q.$and;
|
|
||||||
if (!Array.isArray(clauses)) throw new Error("$and expects array");
|
|
||||||
return clauses.every((c) => evaluate(c, ctx));
|
|
||||||
}
|
|
||||||
|
|
||||||
// $or
|
|
||||||
if ("$or" in q) {
|
|
||||||
const clauses = q.$or;
|
|
||||||
if (!Array.isArray(clauses)) throw new Error("$or expects array");
|
|
||||||
return clauses.some((c) => evaluate(c, ctx));
|
|
||||||
}
|
|
||||||
|
|
||||||
// $not
|
|
||||||
if ("$not" in q) {
|
|
||||||
return !evaluate(q.$not, ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
// $responseTime
|
|
||||||
if ("$responseTime" in q) {
|
|
||||||
const val = ctx.latency_ms ?? 0;
|
|
||||||
return evalCondition(q.$responseTime, val);
|
|
||||||
}
|
|
||||||
|
|
||||||
// $certExpiry
|
|
||||||
if ("$certExpiry" in q) {
|
|
||||||
const val = ctx.cert_expiry_days ?? Infinity;
|
|
||||||
return evalCondition(q.$certExpiry, val);
|
|
||||||
}
|
|
||||||
|
|
||||||
// $select — { "$select": { "css.selector": { "$op": val } } }
|
|
||||||
if ("$select" in q) {
|
|
||||||
// Server-side: no DOM parser available, pass through (Rust runner evaluates)
|
|
||||||
// Validate structure only
|
|
||||||
const selMap = q.$select as Record<string, unknown>;
|
|
||||||
if (typeof selMap !== "object" || Array.isArray(selMap)) throw new Error("$select expects { selector: condition }");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// $json — { "$json": { "$.path": { "$op": val } } }
|
|
||||||
if ("$json" in q) {
|
|
||||||
const pathMap = q.$json as Record<string, unknown>;
|
|
||||||
if (typeof pathMap !== "object" || Array.isArray(pathMap)) throw new Error("$json expects { path: condition }");
|
|
||||||
for (const [path, condition] of Object.entries(pathMap)) {
|
|
||||||
const resolved = resolveJsonPath(ctx.body, path);
|
|
||||||
if (!evalCondition(condition, resolved)) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Field-level checks
|
|
||||||
for (const [field, condition] of Object.entries(q)) {
|
|
||||||
if (field.startsWith("$")) continue; // skip unknown $ ops
|
|
||||||
const fieldVal = resolveField(field, ctx);
|
|
||||||
if (!evalCondition(condition, fieldVal)) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveField(field: string, ctx: EvalContext): unknown {
|
|
||||||
switch (field) {
|
|
||||||
case "status":
|
|
||||||
case "status_code":
|
|
||||||
return ctx.status;
|
|
||||||
case "body":
|
|
||||||
return ctx.body;
|
|
||||||
default:
|
|
||||||
if (field.startsWith("headers.")) {
|
|
||||||
const key = field.slice(8).toLowerCase();
|
|
||||||
return ctx.headers[key] ?? null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveJsonPath(body: string, expr: string): unknown {
|
|
||||||
try {
|
|
||||||
const obj = JSON.parse(body);
|
|
||||||
// Simple dot-notation JSONPath: $.foo.bar[0].baz
|
|
||||||
const path = expr.replace(/^\$\.?/, "");
|
|
||||||
if (!path) return obj;
|
|
||||||
const parts = path.split(/\.|\[(\d+)\]/).filter(Boolean);
|
|
||||||
let current: unknown = obj;
|
|
||||||
for (const part of parts) {
|
|
||||||
if (current === null || current === undefined) return null;
|
|
||||||
current = (current as Record<string, unknown>)[part];
|
|
||||||
}
|
|
||||||
return current ?? null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function evalCondition(condition: unknown, fieldVal: unknown): boolean {
|
|
||||||
if (condition === null || condition === undefined) return true;
|
|
||||||
|
|
||||||
// Direct equality shorthand: { "status": 200 }
|
|
||||||
if (typeof condition === "number" || typeof condition === "string" || typeof condition === "boolean") {
|
|
||||||
return fieldVal === condition;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof condition === "object" && !Array.isArray(condition)) {
|
|
||||||
const ops = condition as Record<string, unknown>;
|
|
||||||
for (const [op, opVal] of Object.entries(ops)) {
|
|
||||||
if (!evalOp(op, fieldVal, opVal)) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function evalOp(op: string, fieldVal: unknown, opVal: unknown): boolean {
|
|
||||||
switch (op) {
|
|
||||||
case "$eq":
|
|
||||||
return fieldVal === opVal;
|
|
||||||
case "$ne":
|
|
||||||
return fieldVal !== opVal;
|
|
||||||
case "$gt":
|
|
||||||
return toNum(fieldVal) > toNum(opVal);
|
|
||||||
case "$gte":
|
|
||||||
return toNum(fieldVal) >= toNum(opVal);
|
|
||||||
case "$lt":
|
|
||||||
return toNum(fieldVal) < toNum(opVal);
|
|
||||||
case "$lte":
|
|
||||||
return toNum(fieldVal) <= toNum(opVal);
|
|
||||||
case "$contains":
|
|
||||||
return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.includes(opVal);
|
|
||||||
case "$startsWith":
|
|
||||||
return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.startsWith(opVal);
|
|
||||||
case "$endsWith":
|
|
||||||
return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.endsWith(opVal);
|
|
||||||
case "$regex": {
|
|
||||||
if (typeof fieldVal !== "string" || typeof opVal !== "string") return false;
|
|
||||||
if (opVal.length > 200) return false;
|
|
||||||
try {
|
|
||||||
return new RegExp(opVal).test(fieldVal);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "$exists":
|
|
||||||
return opVal ? fieldVal !== null && fieldVal !== undefined : fieldVal === null || fieldVal === undefined;
|
|
||||||
case "$in":
|
|
||||||
return Array.isArray(opVal) && opVal.includes(fieldVal);
|
|
||||||
default:
|
|
||||||
return true; // unknown op — skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toNum(v: unknown): number {
|
|
||||||
return typeof v === "number" ? v : Number(v) || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Validate ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const VALID_OPS = new Set([
|
|
||||||
"$eq", "$ne", "$gt", "$gte", "$lt", "$lte",
|
|
||||||
"$contains", "$startsWith", "$endsWith", "$regex",
|
|
||||||
"$exists", "$in",
|
|
||||||
"$select", "$json",
|
|
||||||
"$and", "$or", "$not",
|
|
||||||
"$responseTime", "$certExpiry",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const VALID_FIELDS = new Set([
|
|
||||||
"status", "status_code", "body",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function validateQuery(query: unknown, path = ""): ValidationError[] {
|
|
||||||
const errors: ValidationError[] = [];
|
|
||||||
|
|
||||||
if (query === null || query === undefined) return errors;
|
|
||||||
|
|
||||||
if (typeof query !== "object" || Array.isArray(query)) {
|
|
||||||
errors.push({ path: path || "$", message: "Query must be an object" });
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
const q = query as Record<string, unknown>;
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(q)) {
|
|
||||||
const keyPath = path ? `${path}.${key}` : key;
|
|
||||||
|
|
||||||
if (key === "$and" || key === "$or") {
|
|
||||||
if (!Array.isArray(value)) {
|
|
||||||
errors.push({ path: keyPath, message: `${key} expects an array` });
|
|
||||||
} else {
|
|
||||||
value.forEach((clause, i) => {
|
|
||||||
errors.push(...validateQuery(clause, `${keyPath}[${i}]`));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (key === "$not") {
|
|
||||||
errors.push(...validateQuery(value, keyPath));
|
|
||||||
} else if (key === "$responseTime" || key === "$certExpiry") {
|
|
||||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
||||||
errors.push({ path: keyPath, message: `${key} expects an operator object (e.g. { "$lt": 500 })` });
|
|
||||||
} else {
|
|
||||||
for (const op of Object.keys(value as Record<string, unknown>)) {
|
|
||||||
if (!VALID_OPS.has(op)) {
|
|
||||||
errors.push({ path: `${keyPath}.${op}`, message: `Unknown operator: ${op}` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (key === "$select" || key === "$json") {
|
|
||||||
if (typeof value !== "string") {
|
|
||||||
errors.push({ path: keyPath, message: `${key} expects a string` });
|
|
||||||
}
|
|
||||||
} else if (key.startsWith("$")) {
|
|
||||||
// It's an operator inside a field condition — skip validation here
|
|
||||||
} else {
|
|
||||||
// Field name
|
|
||||||
if (!VALID_FIELDS.has(key) && !key.startsWith("headers.")) {
|
|
||||||
errors.push({ path: keyPath, message: `Unknown field: ${key}. Use status, body, or headers.*` });
|
|
||||||
}
|
|
||||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
||||||
const ops = value as Record<string, unknown>;
|
|
||||||
for (const op of Object.keys(ops)) {
|
|
||||||
if (!op.startsWith("$")) continue;
|
|
||||||
if (!VALID_OPS.has(op)) {
|
|
||||||
errors.push({ path: `${keyPath}.${op}`, message: `Unknown operator: ${op}` });
|
|
||||||
}
|
|
||||||
if (op === "$regex" && typeof ops[op] === "string" && (ops[op] as string).length > 200) {
|
|
||||||
errors.push({ path: `${keyPath}.${op}`, message: "Regex pattern too long (max 200 characters)" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
+41
-59
@@ -1,26 +1,19 @@
|
|||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import { createHash } from "crypto";
|
|
||||||
import sql from "../db";
|
import sql from "../db";
|
||||||
|
import { createRateLimiter } from "../../../shared/rate-limit";
|
||||||
|
import { generateKey, hashEmail, resolveKey as sharedResolveKey, extractAuthKey, COOKIE_OPTS } from "../../../shared/auth";
|
||||||
|
|
||||||
function generateKey(): string {
|
function redir(to: string) {
|
||||||
return crypto.randomUUID();
|
return new Response(
|
||||||
|
`<!DOCTYPE html><html lang="en" class="dark"><head><meta charset="UTF-8"><meta http-equiv="refresh" content="0;url=${to}"><meta name="robots" content="noindex"><style>html,body{background:#0a0a0a;margin:0;height:100%}</style></head><body></body></html>`,
|
||||||
|
{ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hashEmail(email: string): string {
|
const checkAuthRateLimit = createRateLimiter();
|
||||||
return createHash("sha256").update(email.toLowerCase().trim()).digest("hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveKey(key: string): Promise<{ accountId: string; keyId: string | null } | null> {
|
async function resolveKey(key: string) {
|
||||||
const [account] = await sql`SELECT id FROM accounts WHERE key = ${key}`;
|
return sharedResolveKey(sql, key);
|
||||||
if (account) return { accountId: account.id, keyId: null };
|
|
||||||
|
|
||||||
const [apiKey] = await sql`SELECT id, account_id FROM api_keys WHERE key = ${key}`;
|
|
||||||
if (apiKey) {
|
|
||||||
sql`UPDATE api_keys SET last_used_at = now() WHERE id = ${apiKey.id}`.catch(() => {});
|
|
||||||
return { accountId: apiKey.account_id, keyId: apiKey.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { resolveKey };
|
export { resolveKey };
|
||||||
@@ -28,11 +21,7 @@ export { resolveKey };
|
|||||||
export function requireAuth(app: Elysia) {
|
export function requireAuth(app: Elysia) {
|
||||||
return app
|
return app
|
||||||
.derive(async ({ headers, cookie, set }) => {
|
.derive(async ({ headers, cookie, set }) => {
|
||||||
const authHeader = headers["authorization"] ?? "";
|
const key = extractAuthKey(headers, cookie);
|
||||||
const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim();
|
|
||||||
const cookieKey = cookie?.pingql_key?.value;
|
|
||||||
|
|
||||||
const key = bearer || cookieKey;
|
|
||||||
if (!key) {
|
if (!key) {
|
||||||
set.status = 401;
|
set.status = 401;
|
||||||
return { accountId: null as string | null, keyId: null as string | null };
|
return { accountId: null as string | null, keyId: null as string | null };
|
||||||
@@ -52,51 +41,44 @@ export function requireAuth(app: Elysia) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const COOKIE_OPTS = {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV !== "development",
|
|
||||||
sameSite: "lax" as const,
|
|
||||||
path: "/",
|
|
||||||
domain: process.env.COOKIE_DOMAIN ?? ".pingql.com",
|
|
||||||
maxAge: 60 * 60 * 24 * 365,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const account = new Elysia({ prefix: "/account" })
|
export const account = new Elysia({ prefix: "/account" })
|
||||||
|
|
||||||
.post("/login", async ({ body, cookie, set }) => {
|
.post("/login", async ({ body, cookie, set, request, error }) => {
|
||||||
|
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
|
||||||
|
if (!checkAuthRateLimit(ip, 10)) return error(429, { error: "Too many login attempts. Try again later." });
|
||||||
|
|
||||||
const key = (body.key as string)?.trim();
|
const key = (body.key as string)?.trim();
|
||||||
if (!key) { set.status = 400; return { error: "Key required" }; }
|
if (!key) { set.status = 400; return { error: "Key required" }; }
|
||||||
|
|
||||||
const resolved = await resolveKey(key);
|
const resolved = await resolveKey(key);
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
set.status = 401;
|
set.status = 401;
|
||||||
if ((body as any)._form) { set.redirect = "/dashboard?error=invalid"; return; }
|
if ((body as any)._form) return redir("/dashboard?error=invalid");
|
||||||
return { error: "Invalid account key" };
|
return { error: "Invalid account key" };
|
||||||
}
|
}
|
||||||
|
|
||||||
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
|
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
|
||||||
if ((body as any)._form) { set.redirect = "/dashboard/home"; return; }
|
if ((body as any)._form) return redir("/dashboard/home");
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}, { detail: { hide: true } })
|
}, { detail: { hide: true } })
|
||||||
|
|
||||||
.get("/logout", ({ cookie, set }) => {
|
.get("/logout", ({ cookie }) => {
|
||||||
cookie.pingql_key.remove();
|
cookie.pingql_key.set({ value: "", ...COOKIE_OPTS, maxAge: 0 });
|
||||||
set.redirect = "/dashboard";
|
return redir("/dashboard");
|
||||||
}, { detail: { hide: true } })
|
}, { detail: { hide: true } })
|
||||||
|
|
||||||
.post("/register", async ({ body, cookie }) => {
|
.post("/register", async ({ body, cookie, request, set, error }) => {
|
||||||
|
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
|
||||||
|
if (!checkAuthRateLimit(ip, 5)) return error(429, { error: "Too many registrations. Try again later." });
|
||||||
|
|
||||||
const key = generateKey();
|
const key = generateKey();
|
||||||
const emailHash = body.email ? hashEmail(body.email) : null;
|
const emailHash = (body as any).email ? hashEmail((body as any).email) : null;
|
||||||
await sql`INSERT INTO accounts (key, email_hash) VALUES (${key}, ${emailHash})`;
|
await sql`INSERT INTO accounts (key, email_hash) VALUES (${key}, ${emailHash})`;
|
||||||
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
|
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
|
||||||
return {
|
|
||||||
key,
|
if ((body as any)._form) return redir(`/dashboard/welcome?key=${encodeURIComponent(key)}`);
|
||||||
...(body.email ? { email_registered: true } : { email_registered: false }),
|
|
||||||
};
|
return { key, email_registered: !!emailHash };
|
||||||
}, {
|
|
||||||
body: t.Object({
|
|
||||||
email: t.Optional(t.String({ format: "email", description: "Optional. Used for account recovery only." })),
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
.use(requireAuth)
|
.use(requireAuth)
|
||||||
@@ -113,30 +95,30 @@ export const account = new Elysia({ prefix: "/account" })
|
|||||||
})
|
})
|
||||||
|
|
||||||
.post("/email", async ({ accountId, body }) => {
|
.post("/email", async ({ accountId, body }) => {
|
||||||
const emailHash = body.email ? hashEmail(body.email) : null;
|
const emailHash = (body as any).email ? hashEmail((body as any).email) : null;
|
||||||
await sql`UPDATE accounts SET email_hash = ${emailHash} WHERE id = ${accountId}`;
|
await sql`UPDATE accounts SET email_hash = ${emailHash} WHERE id = ${accountId}`;
|
||||||
|
if ((body as any)._form) return redir("/dashboard/settings");
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}, {
|
|
||||||
body: t.Object({
|
|
||||||
email: t.Optional(t.Nullable(t.String({ description: "Email for account recovery only." }))),
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
.post("/reset-key", async ({ accountId, cookie }) => {
|
.post("/reset-key", async ({ accountId, cookie, body }) => {
|
||||||
const key = generateKey();
|
const key = generateKey();
|
||||||
await sql`UPDATE accounts SET key = ${key} WHERE id = ${accountId}`;
|
await sql`UPDATE accounts SET key = ${key} WHERE id = ${accountId}`;
|
||||||
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
|
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
|
||||||
|
if ((body as any)?._form) return redir("/dashboard/settings");
|
||||||
return { key, message: "Primary key rotated. Your old key is now invalid." };
|
return { key, message: "Primary key rotated. Your old key is now invalid." };
|
||||||
})
|
})
|
||||||
|
|
||||||
.post("/keys", async ({ accountId, body }) => {
|
.post("/keys", async ({ accountId, body }) => {
|
||||||
const key = generateKey();
|
const key = generateKey();
|
||||||
const [created] = await sql`INSERT INTO api_keys (key, account_id, label) VALUES (${key}, ${accountId}, ${body.label}) RETURNING id`;
|
const [created] = await sql`INSERT INTO api_keys (key, account_id, label) VALUES (${key}, ${accountId}, ${(body as any).label}) RETURNING id`;
|
||||||
return { key, id: created.id, label: body.label };
|
if ((body as any)._form) return redir("/dashboard/settings");
|
||||||
}, {
|
return { key, id: created.id, label: (body as any).label };
|
||||||
body: t.Object({
|
})
|
||||||
label: t.String({ description: "A name for this key, e.g. 'ci-pipeline' or 'mobile-app'" }),
|
|
||||||
}),
|
.post("/keys/:id/delete", async ({ accountId, params }) => {
|
||||||
|
await sql`DELETE FROM api_keys WHERE id = ${params.id} AND account_id = ${accountId}`;
|
||||||
|
return redir("/dashboard/settings");
|
||||||
})
|
})
|
||||||
|
|
||||||
.delete("/keys/:id", async ({ accountId, params, error }) => {
|
.delete("/keys/:id", async ({ accountId, params, error }) => {
|
||||||
|
|||||||
+833
-101
File diff suppressed because it is too large
Load Diff
@@ -1,47 +0,0 @@
|
|||||||
/// Internal endpoints used by the Rust monitor runner.
|
|
||||||
/// Protected by MONITOR_TOKEN — not exposed to users.
|
|
||||||
|
|
||||||
import { Elysia } from "elysia";
|
|
||||||
import sql from "../db";
|
|
||||||
|
|
||||||
export async function pruneOldPings(retentionDays = 90) {
|
|
||||||
const result = await sql`DELETE FROM pings WHERE checked_at < now() - ${retentionDays + ' days'}::interval`;
|
|
||||||
return result.count;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run retention cleanup every hour
|
|
||||||
setInterval(() => {
|
|
||||||
const days = Number(process.env.PING_RETENTION_DAYS ?? 90);
|
|
||||||
pruneOldPings(days).catch((err) => console.error("Retention cleanup failed:", err));
|
|
||||||
}, 60 * 60 * 1000);
|
|
||||||
|
|
||||||
export const internal = new Elysia({ prefix: "/internal", detail: { hide: true } })
|
|
||||||
.derive(({ headers, error }) => {
|
|
||||||
if (headers["x-monitor-token"] !== process.env.MONITOR_TOKEN)
|
|
||||||
return error(401, { error: "Unauthorized" });
|
|
||||||
return {};
|
|
||||||
})
|
|
||||||
|
|
||||||
// Returns monitors that are due for a check
|
|
||||||
.get("/due", async () => {
|
|
||||||
|
|
||||||
return sql`
|
|
||||||
SELECT m.id, m.url, m.method, m.request_headers, m.request_body, m.timeout_ms, m.interval_s, m.query
|
|
||||||
FROM monitors m
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT checked_at FROM pings
|
|
||||||
WHERE monitor_id = m.id
|
|
||||||
ORDER BY checked_at DESC LIMIT 1
|
|
||||||
) last ON true
|
|
||||||
WHERE m.enabled = true
|
|
||||||
AND (last.checked_at IS NULL
|
|
||||||
OR last.checked_at < now() - (m.interval_s || ' seconds')::interval)
|
|
||||||
`;
|
|
||||||
})
|
|
||||||
|
|
||||||
// Manual retention cleanup trigger
|
|
||||||
.post("/prune", async () => {
|
|
||||||
const days = Number(process.env.PING_RETENTION_DAYS ?? 90);
|
|
||||||
const deleted = await pruneOldPings(days);
|
|
||||||
return { deleted, retention_days: days };
|
|
||||||
});
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import { Elysia, t } from "elysia";
|
|
||||||
import { requireAuth } from "./auth";
|
|
||||||
import sql from "../db";
|
|
||||||
import { validateMonitorUrl } from "../utils/ssrf";
|
|
||||||
|
|
||||||
const MonitorBody = t.Object({
|
|
||||||
name: t.String({ description: "Human-readable name" }),
|
|
||||||
url: t.String({ format: "uri", description: "URL to check" }),
|
|
||||||
method: t.Optional(t.String({ default: "GET", description: "HTTP method: GET, POST, PUT, PATCH, DELETE, HEAD" })),
|
|
||||||
request_headers: t.Optional(t.Any({ description: "Request headers as key-value object" })),
|
|
||||||
request_body: t.Optional(t.Nullable(t.String({ description: "Request body for POST/PUT/PATCH" }))),
|
|
||||||
timeout_ms: t.Optional(t.Number({ minimum: 1000, maximum: 60000, default: 30000, description: "Request timeout in ms" })),
|
|
||||||
interval_s: t.Optional(t.Number({ minimum: 10, default: 60, description: "Check interval in seconds" })),
|
|
||||||
query: t.Optional(t.Any({ description: "PingQL query — filter conditions for up/down" })),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const monitors = new Elysia({ prefix: "/monitors" })
|
|
||||||
.use(requireAuth)
|
|
||||||
|
|
||||||
// List monitors
|
|
||||||
.get("/", async ({ accountId }) => {
|
|
||||||
return sql`SELECT * FROM monitors WHERE account_id = ${accountId} ORDER BY created_at DESC`;
|
|
||||||
}, { detail: { summary: "List monitors", tags: ["monitors"] } })
|
|
||||||
|
|
||||||
// Create monitor
|
|
||||||
.post("/", async ({ accountId, body, error }) => {
|
|
||||||
// SSRF protection
|
|
||||||
const ssrfError = await validateMonitorUrl(body.url);
|
|
||||||
if (ssrfError) return error(400, { error: ssrfError });
|
|
||||||
|
|
||||||
// Monitor count limit
|
|
||||||
const [{ count }] = await sql`SELECT COUNT(*)::int AS count FROM monitors WHERE account_id = ${accountId}`;
|
|
||||||
const limit = Number(process.env.MAX_MONITORS_PER_ACCOUNT ?? 100);
|
|
||||||
if (count >= limit) return error(429, { error: `Monitor limit reached (max ${limit})` });
|
|
||||||
|
|
||||||
const [monitor] = await sql`
|
|
||||||
INSERT INTO monitors (account_id, name, url, method, request_headers, request_body, timeout_ms, interval_s, query)
|
|
||||||
VALUES (
|
|
||||||
${accountId}, ${body.name}, ${body.url},
|
|
||||||
${(body.method ?? 'GET').toUpperCase()},
|
|
||||||
${body.request_headers ? sql.json(body.request_headers) : null},
|
|
||||||
${body.request_body ?? null},
|
|
||||||
${body.timeout_ms ?? 30000},
|
|
||||||
${body.interval_s ?? 60},
|
|
||||||
${body.query ? sql.json(body.query) : null}
|
|
||||||
)
|
|
||||||
RETURNING *
|
|
||||||
`;
|
|
||||||
return monitor;
|
|
||||||
}, { body: MonitorBody, detail: { summary: "Create monitor", tags: ["monitors"] } })
|
|
||||||
|
|
||||||
// Get monitor + recent status
|
|
||||||
.get("/:id", async ({ accountId, params, error }) => {
|
|
||||||
const [monitor] = await sql`
|
|
||||||
SELECT * FROM monitors WHERE id = ${params.id} AND account_id = ${accountId}
|
|
||||||
`;
|
|
||||||
if (!monitor) return error(404, { error: "Not found" });
|
|
||||||
|
|
||||||
const results = await sql`
|
|
||||||
SELECT * FROM pings WHERE monitor_id = ${params.id}
|
|
||||||
ORDER BY checked_at DESC LIMIT 100
|
|
||||||
`;
|
|
||||||
return { ...monitor, results };
|
|
||||||
}, { detail: { summary: "Get monitor with results", tags: ["monitors"] } })
|
|
||||||
|
|
||||||
// Update monitor
|
|
||||||
.patch("/:id", async ({ accountId, params, body, error }) => {
|
|
||||||
// SSRF protection on URL change
|
|
||||||
if (body.url) {
|
|
||||||
const ssrfError = await validateMonitorUrl(body.url);
|
|
||||||
if (ssrfError) return error(400, { error: ssrfError });
|
|
||||||
}
|
|
||||||
|
|
||||||
const [monitor] = await sql`
|
|
||||||
UPDATE monitors SET
|
|
||||||
name = COALESCE(${body.name ?? null}, name),
|
|
||||||
url = COALESCE(${body.url ?? null}, url),
|
|
||||||
method = COALESCE(${body.method ? body.method.toUpperCase() : null}, method),
|
|
||||||
request_headers = COALESCE(${body.request_headers ? sql.json(body.request_headers) : null}, request_headers),
|
|
||||||
request_body = COALESCE(${body.request_body ?? null}, request_body),
|
|
||||||
timeout_ms = COALESCE(${body.timeout_ms ?? null}, timeout_ms),
|
|
||||||
interval_s = COALESCE(${body.interval_s ?? null}, interval_s),
|
|
||||||
query = COALESCE(${body.query ? sql.json(body.query) : null}, query)
|
|
||||||
WHERE id = ${params.id} AND account_id = ${accountId}
|
|
||||||
RETURNING *
|
|
||||||
`;
|
|
||||||
if (!monitor) return error(404, { error: "Not found" });
|
|
||||||
return monitor;
|
|
||||||
}, { body: t.Partial(MonitorBody), detail: { summary: "Update monitor", tags: ["monitors"] } })
|
|
||||||
|
|
||||||
// Delete monitor
|
|
||||||
.delete("/:id", async ({ accountId, params, error }) => {
|
|
||||||
const [deleted] = await sql`
|
|
||||||
DELETE FROM monitors WHERE id = ${params.id} AND account_id = ${accountId} RETURNING id
|
|
||||||
`;
|
|
||||||
if (!deleted) return error(404, { error: "Not found" });
|
|
||||||
return { deleted: true };
|
|
||||||
}, { detail: { summary: "Delete monitor", tags: ["monitors"] } })
|
|
||||||
|
|
||||||
// Toggle enabled
|
|
||||||
.post("/:id/toggle", async ({ accountId, params, error }) => {
|
|
||||||
const [monitor] = await sql`
|
|
||||||
UPDATE monitors SET enabled = NOT enabled
|
|
||||||
WHERE id = ${params.id} AND account_id = ${accountId}
|
|
||||||
RETURNING id, enabled
|
|
||||||
`;
|
|
||||||
if (!monitor) return error(404, { error: "Not found" });
|
|
||||||
return monitor;
|
|
||||||
}, { detail: { summary: "Toggle monitor on/off", tags: ["monitors"] } })
|
|
||||||
|
|
||||||
// Check history
|
|
||||||
.get("/:id/pings", async ({ accountId, params, query, error }) => {
|
|
||||||
const [monitor] = await sql`
|
|
||||||
SELECT id FROM monitors WHERE id = ${params.id} AND account_id = ${accountId}
|
|
||||||
`;
|
|
||||||
if (!monitor) return error(404, { error: "Not found" });
|
|
||||||
const limit = Math.min(Number(query.limit ?? 100), 1000);
|
|
||||||
return sql`
|
|
||||||
SELECT * FROM pings
|
|
||||||
WHERE monitor_id = ${params.id}
|
|
||||||
ORDER BY checked_at DESC LIMIT ${limit}
|
|
||||||
`;
|
|
||||||
}, { detail: { summary: "Get ping history", tags: ["monitors"] } });
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import { Elysia, t } from "elysia";
|
|
||||||
import sql from "../db";
|
|
||||||
import { resolveKey } from "./auth";
|
|
||||||
|
|
||||||
// ── SSE bus ───────────────────────────────────────────────────────────────────
|
|
||||||
type SSEController = ReadableStreamDefaultController<Uint8Array>;
|
|
||||||
const bus = new Map<string, Set<SSEController>>(); // keyed by accountId
|
|
||||||
const enc = new TextEncoder();
|
|
||||||
|
|
||||||
function publish(accountId: string, data: object) {
|
|
||||||
const subs = bus.get(accountId);
|
|
||||||
if (!subs?.size) return;
|
|
||||||
const msg = enc.encode(`data: ${JSON.stringify(data)}\n\n`);
|
|
||||||
for (const ctrl of subs) {
|
|
||||||
try { ctrl.enqueue(msg); } catch { subs.delete(ctrl); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeSSEStream(accountId: string): Response {
|
|
||||||
let ctrl: SSEController;
|
|
||||||
let heartbeat: Timer;
|
|
||||||
const stream = new ReadableStream<Uint8Array>({
|
|
||||||
start(c) {
|
|
||||||
ctrl = c;
|
|
||||||
if (!bus.has(accountId)) bus.set(accountId, new Set());
|
|
||||||
bus.get(accountId)!.add(ctrl);
|
|
||||||
ctrl.enqueue(enc.encode(": connected\n\n"));
|
|
||||||
heartbeat = setInterval(() => {
|
|
||||||
try { ctrl.enqueue(enc.encode(": heartbeat\n\n")); } catch { clearInterval(heartbeat); }
|
|
||||||
}, 10_000);
|
|
||||||
},
|
|
||||||
cancel() {
|
|
||||||
clearInterval(heartbeat);
|
|
||||||
bus.get(accountId)?.delete(ctrl);
|
|
||||||
if (bus.get(accountId)?.size === 0) bus.delete(accountId);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return new Response(stream, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "text/event-stream",
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
"Connection": "keep-alive",
|
|
||||||
"X-Accel-Buffering": "no",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Routes ────────────────────────────────────────────────────────────────────
|
|
||||||
export const ingest = new Elysia()
|
|
||||||
|
|
||||||
// Internal: called by Rust monitor runner
|
|
||||||
.post("/internal/ingest", async ({ body, headers, error }) => {
|
|
||||||
const token = headers["x-monitor-token"];
|
|
||||||
if (token !== process.env.MONITOR_TOKEN) return error(401, { error: "Unauthorized" });
|
|
||||||
|
|
||||||
const meta = body.meta ? { ...body.meta } : {};
|
|
||||||
if (body.cert_expiry_days != null) meta.cert_expiry_days = body.cert_expiry_days;
|
|
||||||
|
|
||||||
const [ping] = await sql`
|
|
||||||
INSERT INTO pings (monitor_id, status_code, latency_ms, up, error, meta)
|
|
||||||
VALUES (
|
|
||||||
${body.monitor_id},
|
|
||||||
${body.status_code ?? null},
|
|
||||||
${body.latency_ms ?? null},
|
|
||||||
${body.up},
|
|
||||||
${body.error ?? null},
|
|
||||||
${Object.keys(meta).length > 0 ? sql.json(meta) : null}
|
|
||||||
)
|
|
||||||
RETURNING *
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Look up account and publish to account-level bus
|
|
||||||
const [monitor] = await sql`SELECT account_id FROM monitors WHERE id = ${body.monitor_id}`;
|
|
||||||
if (monitor) publish(monitor.account_id, ping);
|
|
||||||
|
|
||||||
return { ok: true };
|
|
||||||
}, {
|
|
||||||
body: t.Object({
|
|
||||||
monitor_id: t.String(),
|
|
||||||
status_code: t.Optional(t.Number()),
|
|
||||||
latency_ms: t.Optional(t.Number()),
|
|
||||||
up: t.Boolean(),
|
|
||||||
error: t.Optional(t.Nullable(t.String())),
|
|
||||||
cert_expiry_days: t.Optional(t.Nullable(t.Number())),
|
|
||||||
meta: t.Optional(t.Any()),
|
|
||||||
}),
|
|
||||||
detail: { hide: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
// SSE: single stream for all of the account's monitors
|
|
||||||
.get("/account/stream", async ({ headers, cookie }) => {
|
|
||||||
const authHeader = headers["authorization"] ?? "";
|
|
||||||
const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim();
|
|
||||||
const key = bearer ?? cookie?.pingql_key?.value;
|
|
||||||
|
|
||||||
if (!key) return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
|
||||||
|
|
||||||
const resolved = await resolveKey(key);
|
|
||||||
if (!resolved) return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
|
||||||
|
|
||||||
const limit = Number(process.env.MAX_SSE_PER_ACCOUNT ?? 10);
|
|
||||||
if ((bus.get(resolved.accountId)?.size ?? 0) >= limit) {
|
|
||||||
return new Response(JSON.stringify({ error: "Too many connections" }), { status: 429 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return makeSSEStream(resolved.accountId);
|
|
||||||
}, { detail: { hide: true } });
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@source "../src/**/*.{ts,html,ejs}";
|
||||||
|
@source "../src/dashboard/**/*.{html,js}";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-brand: #3b82f6;
|
||||||
|
--color-surface: #141418;
|
||||||
|
--color-surface-hover: #1a1a1f;
|
||||||
|
--color-surface-solid: #141418;
|
||||||
|
--color-border-subtle: #232329;
|
||||||
|
--color-border-strong: #2e2e36;
|
||||||
|
--shadow-glow-sm: 0 0 20px rgba(59, 130, 246, 0.08);
|
||||||
|
--shadow-glow-md: 0 0 40px rgba(59, 130, 246, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Card base ─── */
|
||||||
|
@layer components {
|
||||||
|
.card {
|
||||||
|
background: #141418;
|
||||||
|
border: 1px solid #232329;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
border-color: #3b82f6aa;
|
||||||
|
box-shadow: 0 0 24px rgba(59, 130, 246, 0.08);
|
||||||
|
}
|
||||||
|
.card-static {
|
||||||
|
background: #141418;
|
||||||
|
border: 1px solid #232329;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Input focus glow ─── */
|
||||||
|
.input-base {
|
||||||
|
background: #0f0f12;
|
||||||
|
border: 1px solid #232329;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
.input-base:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15), 0 0 16px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Buttons ─── */
|
||||||
|
.btn-primary {
|
||||||
|
background: #2563eb;
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3), 0 0 12px rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #3b82f6;
|
||||||
|
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3), 0 0 20px rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: #1a1a1f;
|
||||||
|
border: 1px solid #2e2e36;
|
||||||
|
color: #d1d5db;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #222228;
|
||||||
|
border-color: #3a3a44;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Status dot glow ─── */
|
||||||
|
.dot-up {
|
||||||
|
background: #22c55e;
|
||||||
|
box-shadow: 0 0 10px rgba(34, 197, 94, 0.5), 0 0 4px rgba(34, 197, 94, 0.8);
|
||||||
|
}
|
||||||
|
.dot-down {
|
||||||
|
background: #ef4444;
|
||||||
|
box-shadow: 0 0 10px rgba(239, 68, 68, 0.5), 0 0 4px rgba(239, 68, 68, 0.8);
|
||||||
|
}
|
||||||
|
.dot-unknown {
|
||||||
|
background: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Section dividers ─── */
|
||||||
|
.divider {
|
||||||
|
border-color: #1e1e24;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* cache bust */
|
||||||
@@ -1,13 +1,3 @@
|
|||||||
export function sparkline(values: number[], width = 120, height = 32): string {
|
// Re-export from the shared module so apps/web and apps/status share one
|
||||||
if (!values.length) return '';
|
// sparkline implementation.
|
||||||
const max = Math.max(...values, 1);
|
export { sparkline, sparklineFromPings, pickBestRegion } from "../../../shared/render/sparkline";
|
||||||
const min = Math.min(...values, 0);
|
|
||||||
const range = max - min || 1;
|
|
||||||
const step = width / Math.max(values.length - 1, 1);
|
|
||||||
const points = values.map((v, i) => {
|
|
||||||
const x = i * step;
|
|
||||||
const y = height - ((v - min) / range) * (height - 4) - 2;
|
|
||||||
return `${x},${y}`;
|
|
||||||
}).join(' ');
|
|
||||||
return `<svg width="${width}" height="${height}" class="inline-block"><polyline points="${points}" fill="none" stroke="#60a5fa" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,412 @@
|
|||||||
|
<%~ include('./partials/head', { title: 'Upgrade' }) %>
|
||||||
|
<%~ include('./partials/nav', { nav: 'settings' }) %>
|
||||||
|
|
||||||
|
<%
|
||||||
|
const plan = it.account.plan;
|
||||||
|
const coins = it.coins || [];
|
||||||
|
const invoice = it.invoice;
|
||||||
|
const payApi = it.payApi || '';
|
||||||
|
const totalSpent = it.totalSpent || 0;
|
||||||
|
const maxLifetimeTier = it.maxLifetimeTier || 0;
|
||||||
|
const lifetimeBase = 140;
|
||||||
|
function ltPrice(base) { return Math.max(base - Math.min(totalSpent, base * 0.90), 1); }
|
||||||
|
const lt1 = ltPrice(lifetimeBase);
|
||||||
|
const lt2 = ltPrice(lifetimeBase * 2);
|
||||||
|
const lt4 = ltPrice(lifetimeBase * 4);
|
||||||
|
const hasCredit = totalSpent > 0;
|
||||||
|
%>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Show options when selected */
|
||||||
|
#plan-pro:checked ~ #pro-options { display: block; }
|
||||||
|
#plan-lifetime:checked ~ #lifetime-options { display: block; }
|
||||||
|
/* Highlight selected plan type */
|
||||||
|
#plan-pro:checked ~ .plan-labels label[for="plan-pro"] { border-color: rgb(59 130 246 / 0.5); }
|
||||||
|
#plan-lifetime:checked ~ .plan-labels label[for="plan-lifetime"] { border-color: rgb(234 179 8 / 0.5); }
|
||||||
|
/* Highlight selected coin */
|
||||||
|
.coin-radio:checked + label { border-color: rgb(59 130 246); }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<main class="max-w-2xl mx-auto px-8 py-8">
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/dashboard/settings" class="text-sm text-gray-500 hover:text-gray-300 transition-colors">← Back to settings</a>
|
||||||
|
<h1 class="text-xl font-semibold text-gray-100 mt-2">Upgrade Plan</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (!invoice) { %>
|
||||||
|
<% if (plan !== 'free') { %>
|
||||||
|
<div class="mb-6 rounded-lg border border-border-subtle bg-surface px-4 py-3 text-xs text-gray-400">
|
||||||
|
Buying a higher tier? Your current plan pauses and picks back up once the new one expires. If you'd rather combine them, reach out to support.
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<!-- ─── Step 1: Plan & coin selection (form, works without JS) ─── -->
|
||||||
|
<form action="/dashboard/checkout" method="POST" class="space-y-6">
|
||||||
|
|
||||||
|
<!-- Single hidden field submitted to server -->
|
||||||
|
<input type="hidden" name="plan" id="plan-value" value="pro">
|
||||||
|
|
||||||
|
<!-- Plan type selector (not submitted, drives UI) -->
|
||||||
|
<input type="radio" name="_planType" value="pro" id="plan-pro" class="hidden" checked>
|
||||||
|
<input type="radio" name="_planType" value="lifetime" id="plan-lifetime" class="hidden">
|
||||||
|
|
||||||
|
<div class="plan-labels grid grid-cols-2 gap-4">
|
||||||
|
<label for="plan-pro"
|
||||||
|
class="cursor-pointer text-left bg-surface border-2 border-border-subtle hover:border-blue-500/40 rounded-xl p-5 transition-colors">
|
||||||
|
<div class="text-xs text-gray-500 uppercase tracking-wider font-mono mb-1">Pro</div>
|
||||||
|
<div class="text-2xl font-bold text-gray-100">From $12<span class="text-sm font-normal text-gray-500">/mo</span></div>
|
||||||
|
<div class="text-xs text-gray-500 mt-2">200-800 monitors, 5s intervals</div>
|
||||||
|
</label>
|
||||||
|
<% if (maxLifetimeTier >= 3) { %>
|
||||||
|
<div class="text-left bg-surface border-2 border-border-subtle rounded-xl p-5 opacity-50 cursor-not-allowed relative">
|
||||||
|
<span class="absolute top-3 right-3 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-gray-500/15 text-gray-500 border border-gray-500/20">Owned</span>
|
||||||
|
<div class="text-xs text-gray-500 uppercase tracking-wider font-mono mb-1">Lifetime</div>
|
||||||
|
<div class="text-2xl font-bold text-gray-400">$<%= lifetimeBase %></div>
|
||||||
|
<div class="text-xs text-gray-600 mt-2">You have the highest lifetime tier</div>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<label for="plan-lifetime"
|
||||||
|
class="cursor-pointer text-left bg-surface border-2 border-border-subtle hover:border-yellow-500/40 rounded-xl p-5 transition-colors relative">
|
||||||
|
<span class="absolute top-3 right-3 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-yellow-500/15 text-yellow-500 border border-yellow-500/20">Launch Deal</span>
|
||||||
|
<div class="text-xs text-yellow-500/70 uppercase tracking-wider font-mono mb-1">Lifetime</div>
|
||||||
|
<% if (hasCredit) { %>
|
||||||
|
<div class="text-2xl font-bold text-gray-100">From $<%= Math.round(lt1) %> <span class="text-sm font-normal text-gray-600 line-through">$<%= lifetimeBase %></span></div>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="text-2xl font-bold text-gray-100">From $<%= lifetimeBase %></div>
|
||||||
|
<% } %>
|
||||||
|
<div class="text-xs text-gray-500 mt-2">One-time, 200-800 monitors forever</div>
|
||||||
|
</label>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pro tier + months (visible only when pro selected, via CSS) -->
|
||||||
|
<div id="pro-options" class="hidden space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-2">Tier</label>
|
||||||
|
<div id="tier-selector" class="grid grid-cols-3 gap-2">
|
||||||
|
<button type="button" class="tier-btn text-center bg-surface border-2 border-blue-500/50 rounded-lg py-2.5 transition-colors" data-plan="pro" data-type="pro">
|
||||||
|
<div class="text-sm font-semibold text-gray-200">1x</div>
|
||||||
|
<div class="text-xs text-gray-500">200 monitors · $12/mo</div>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="tier-btn text-center bg-surface border-2 border-border-subtle hover:border-blue-500/40 rounded-lg py-2.5 transition-colors" data-plan="pro2x" data-type="pro">
|
||||||
|
<div class="text-sm font-semibold text-gray-200">2x</div>
|
||||||
|
<div class="text-xs text-gray-500">400 monitors · $24/mo</div>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="tier-btn text-center bg-surface border-2 border-border-subtle hover:border-blue-500/40 rounded-lg py-2.5 transition-colors" data-plan="pro4x" data-type="pro">
|
||||||
|
<div class="text-sm font-semibold text-gray-200">4x</div>
|
||||||
|
<div class="text-xs text-gray-500">800 monitors · $48/mo</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-2">How many months?</label>
|
||||||
|
<select id="months-select" name="months" class="input-base px-4 py-2.5 text-gray-100">
|
||||||
|
<% for (let i = 1; i <= 12; i++) { %>
|
||||||
|
<option value="<%= i %>" data-base="<%= i * 12 %>"><%= i %> month<%= i > 1 ? 's' : '' %> - $<%= i * 12 %></option>
|
||||||
|
<% } %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lifetime tier (visible only when lifetime selected, via CSS) -->
|
||||||
|
<div id="lifetime-options" class="hidden space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-2">Tier</label>
|
||||||
|
<div id="lifetime-tier-selector" class="grid grid-cols-3 gap-2">
|
||||||
|
<% if (maxLifetimeTier < 1) { %>
|
||||||
|
<button type="button" class="lt-btn text-center bg-surface border-2 border-yellow-500/50 rounded-lg py-2.5 transition-colors" data-plan="lifetime">
|
||||||
|
<div class="text-sm font-semibold text-gray-200">1x</div>
|
||||||
|
<div class="text-xs text-gray-500">200 monitors · <% if (hasCredit) { %><span class="text-green-400">$<%= Math.round(lt1) %></span> <span class="line-through">$<%= lifetimeBase %></span><% } else { %>$<%= lifetimeBase %><% } %></div>
|
||||||
|
</button>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="text-center bg-surface border-2 border-border-subtle rounded-lg py-2.5 opacity-40 cursor-not-allowed">
|
||||||
|
<div class="text-sm font-semibold text-gray-400">1x</div>
|
||||||
|
<div class="text-xs text-gray-600">Owned</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<% if (maxLifetimeTier < 2) { %>
|
||||||
|
<button type="button" class="lt-btn text-center bg-surface border-2 border-border-subtle hover:border-yellow-500/40 rounded-lg py-2.5 transition-colors" data-plan="lifetime2x">
|
||||||
|
<div class="text-sm font-semibold text-gray-200">2x</div>
|
||||||
|
<div class="text-xs text-gray-500">400 monitors · <% if (hasCredit) { %><span class="text-green-400">$<%= Math.round(lt2) %></span> <span class="line-through">$<%= lifetimeBase * 2 %></span><% } else { %>$<%= lifetimeBase * 2 %><% } %></div>
|
||||||
|
</button>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="text-center bg-surface border-2 border-border-subtle rounded-lg py-2.5 opacity-40 cursor-not-allowed">
|
||||||
|
<div class="text-sm font-semibold text-gray-400">2x</div>
|
||||||
|
<div class="text-xs text-gray-600">Owned</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<button type="button" class="lt-btn text-center bg-surface border-2 border-border-subtle hover:border-yellow-500/40 rounded-lg py-2.5 transition-colors" data-plan="lifetime4x">
|
||||||
|
<div class="text-sm font-semibold text-gray-200">4x</div>
|
||||||
|
<div class="text-xs text-gray-500">800 monitors · <% if (hasCredit) { %><span class="text-green-400">$<%= Math.round(lt4) %></span> <span class="line-through">$<%= lifetimeBase * 4 %></span><% } else { %>$<%= lifetimeBase * 4 %><% } %></div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% if (hasCredit) { %>
|
||||||
|
<div class="text-xs text-green-400/70">$<%= Math.round(totalSpent) %> discount for money already spent</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Coin selection -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-2">Pay with</label>
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
<% coins.forEach(function(c, i) { %>
|
||||||
|
<input type="radio" name="coin" value="<%= c.id %>" id="coin-<%= c.id %>" class="hidden coin-radio" <%= i === 0 ? 'checked' : '' %>>
|
||||||
|
<label for="coin-<%= c.id %>"
|
||||||
|
class="cursor-pointer flex items-center gap-2 bg-surface border-2 border-border-subtle hover:border-border-strong rounded-lg px-3 py-2.5 transition-colors text-left">
|
||||||
|
<span class="text-sm font-medium text-gray-300"><%= c.ticker %></span>
|
||||||
|
<span class="text-xs text-gray-600"><%= c.label %></span>
|
||||||
|
</label>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full btn-primary py-3">
|
||||||
|
Continue to Payment
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<% } else { %>
|
||||||
|
<!-- ─── Step 2: Payment display ─── -->
|
||||||
|
<%
|
||||||
|
const inv = invoice;
|
||||||
|
const isPending = inv.status === 'pending' || inv.status === 'underpaid' || inv.status === 'confirming';
|
||||||
|
const received = parseFloat(inv.amount_received || '0');
|
||||||
|
const total = parseFloat(inv.amount_crypto);
|
||||||
|
const remaining = Math.max(0, total - received);
|
||||||
|
const expiresAt = new Date(inv.expires_at);
|
||||||
|
const now = new Date();
|
||||||
|
const remainingSec = Math.max(0, Math.floor((expiresAt.getTime() - now.getTime()) / 1000));
|
||||||
|
const mins = Math.floor(remainingSec / 60);
|
||||||
|
const secs = remainingSec % 60;
|
||||||
|
const pct = Math.min(100, Math.round((received / total) * 100));
|
||||||
|
const invPlanLabel = it.planLabels[inv.plan] || inv.plan;
|
||||||
|
%>
|
||||||
|
|
||||||
|
<% if (isPending) { %>
|
||||||
|
<noscript><meta http-equiv="refresh" content="5"></noscript>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<div class="card-static overflow-hidden" data-pay-status="<%= inv.status %>">
|
||||||
|
|
||||||
|
<!-- Header bar -->
|
||||||
|
<div class="px-6 py-4 border-b divider flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm font-medium text-gray-200"><%= invPlanLabel %></span>
|
||||||
|
<% if (inv.months) { %><span class="text-sm text-gray-500"><%= inv.months %>mo</span><% } %>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm font-medium text-gray-300">$<%= Number(inv.amount_usd).toFixed(2) %></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (inv.status === 'pending' || inv.status === 'underpaid') { %>
|
||||||
|
<div class="px-6 pt-7 pb-6">
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-5">
|
||||||
|
|
||||||
|
<!-- QR -->
|
||||||
|
<a href="<%= inv.pay_uri %>" class="inline-block">
|
||||||
|
<img src="<%= inv.qr_url %>" alt="QR Code" class="w-48 h-48 rounded-xl bg-white p-2" id="pay-qr">
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Amount -->
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-sm text-gray-500 mb-1.5">Send exactly</div>
|
||||||
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
<span class="text-2xl font-bold font-mono text-white"><%= received > 0 && remaining > 0 ? remaining.toFixed(8) : inv.amount_crypto %></span>
|
||||||
|
<span class="text-sm font-medium text-gray-400"><%= inv.coin_ticker %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Open in wallet -->
|
||||||
|
<a href="<%= inv.pay_uri %>" class="inline-flex items-center gap-2 text-sm text-gray-400 hover:text-gray-200 border border-border-subtle hover:border-border-strong rounded-lg px-4 py-2 transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/></svg>
|
||||||
|
Open in wallet
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<% if (received > 0 && remaining > 0) { %>
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="w-full h-1.5 rounded-full bg-gray-800 overflow-hidden">
|
||||||
|
<div class="h-full rounded-full bg-green-500 transition-all" data-progress-bar style="width: <%= pct %>%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between mt-2 text-sm">
|
||||||
|
<span class="text-green-400 font-mono"><%= received.toFixed(8) %> received</span>
|
||||||
|
<span class="text-gray-500 font-mono"><%= remaining.toFixed(8) %> left</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<!-- Address -->
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-sm text-gray-500">Address</span>
|
||||||
|
<button onclick="copyAddr()" id="copy-btn" class="text-sm text-gray-500 hover:text-gray-300 transition-colors">Copy</button>
|
||||||
|
</div>
|
||||||
|
<code class="block bg-surface-solid border border-border-subtle rounded-lg px-4 py-3 text-blue-400 text-sm font-mono select-all break-all leading-relaxed" id="pay-address"><%= inv.address %></code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer: status + timer -->
|
||||||
|
<div class="px-6 py-3.5 border-t divider flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full <%= inv.status === 'underpaid' ? 'bg-yellow-500' : 'bg-blue-500' %> animate-pulse"></span>
|
||||||
|
<span class="text-sm <%= inv.status === 'underpaid' ? 'text-yellow-400' : 'text-gray-400' %>"><%= inv.status === 'underpaid' ? 'Underpaid, send the rest' : 'Waiting for payment' %></span>
|
||||||
|
</div>
|
||||||
|
<span id="timer" class="text-sm text-gray-500 font-mono" data-left="<%= remainingSec %>"><%= mins %>:<%= String(secs).padStart(2, '0') %></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% } else if (inv.status === 'confirming') { %>
|
||||||
|
<div class="px-5 py-12 text-center">
|
||||||
|
<div class="w-14 h-14 mx-auto rounded-full bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-5">
|
||||||
|
<svg class="w-7 h-7 text-blue-500 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-base font-semibold text-white mb-1">Payment received</p>
|
||||||
|
<p class="text-sm text-gray-400">Waiting for network confirmation</p>
|
||||||
|
<% if (inv.address) { %>
|
||||||
|
<a href="https://transaction.st/address/<%= inv.address %>" target="_blank" rel="noopener" class="inline-block text-xs text-gray-500 hover:text-gray-300 transition-colors mt-4">View on explorer →</a>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% } else if (inv.status === 'paid') { %>
|
||||||
|
<div class="px-5 py-12 text-center">
|
||||||
|
<div class="w-14 h-14 mx-auto rounded-full bg-green-500/10 border border-green-500/20 flex items-center justify-center mb-5">
|
||||||
|
<svg class="w-7 h-7 text-green-400" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-base font-semibold text-white mb-1">Payment confirmed</p>
|
||||||
|
<p class="text-sm text-gray-400 mb-5">Your <%= invPlanLabel %> plan is now active.</p>
|
||||||
|
<div class="flex items-center justify-center gap-3">
|
||||||
|
<a href="/dashboard/settings" class="btn-primary px-5 py-2 text-sm">Settings</a>
|
||||||
|
<a href="/dashboard/checkout/<%= inv.id %>/receipt" target="_blank" class="btn-secondary px-5 py-2 text-sm">Receipt</a>
|
||||||
|
</div>
|
||||||
|
<% if (inv.address) { %>
|
||||||
|
<a href="https://transaction.st/address/<%= inv.address %>" target="_blank" rel="noopener" class="inline-block text-xs text-gray-500 hover:text-gray-300 transition-colors mt-4">View on explorer →</a>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% } else if (inv.status === 'expired') { %>
|
||||||
|
<div class="px-5 py-12 text-center">
|
||||||
|
<div class="w-14 h-14 mx-auto rounded-full bg-red-500/10 border border-red-500/20 flex items-center justify-center mb-5">
|
||||||
|
<svg class="w-7 h-7 text-red-400" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-base font-semibold text-white mb-1">Invoice expired</p>
|
||||||
|
<p class="text-sm text-gray-400 mb-5">This payment window has closed.</p>
|
||||||
|
<a href="/dashboard/checkout" class="btn-primary inline-block px-5 py-2 text-sm">Try again</a>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-600 text-center mt-4">You can close this page. Payments are detected automatically.</p>
|
||||||
|
<% } %>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Countdown timer
|
||||||
|
const timerEl = document.getElementById('timer');
|
||||||
|
if (timerEl) {
|
||||||
|
let left = parseInt(timerEl.dataset.left);
|
||||||
|
setInterval(() => {
|
||||||
|
left = Math.max(0, left - 1);
|
||||||
|
timerEl.textContent = Math.floor(left / 60) + ':' + String(left % 60).padStart(2, '0');
|
||||||
|
if (left <= 0) location.reload();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy address
|
||||||
|
function copyAddr() {
|
||||||
|
const addr = document.getElementById('pay-address');
|
||||||
|
const btn = document.getElementById('copy-btn');
|
||||||
|
if (addr) navigator.clipboard.writeText(addr.textContent);
|
||||||
|
if (btn) { btn.textContent = 'Copied'; setTimeout(() => btn.textContent = 'Copy', 1500); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll payment status instead of meta refresh
|
||||||
|
(function() {
|
||||||
|
const m = location.pathname.match(/\/dashboard\/checkout\/([^/]+)$/);
|
||||||
|
if (!m) return;
|
||||||
|
const id = m[1];
|
||||||
|
let lastStatus = document.querySelector('[data-pay-status]')?.dataset.payStatus;
|
||||||
|
if (!lastStatus || lastStatus === 'paid' || lastStatus === 'expired') return;
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/dashboard/checkout/' + id + '/status', { credentials: 'same-origin' });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status !== lastStatus) { location.reload(); return; }
|
||||||
|
// Update progress bar in-place for partial payments
|
||||||
|
const received = parseFloat(data.amount_received || '0');
|
||||||
|
const total = parseFloat(data.amount_crypto || '1');
|
||||||
|
const pct = Math.min(100, Math.round((received / total) * 100));
|
||||||
|
const bar = document.querySelector('[data-progress-bar]');
|
||||||
|
if (bar) bar.style.width = pct + '%';
|
||||||
|
} catch {}
|
||||||
|
setTimeout(poll, 1500);
|
||||||
|
}
|
||||||
|
setTimeout(poll, 1500);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const multipliers = { pro: 1, pro2x: 2, pro4x: 4 };
|
||||||
|
const lifetimePlans = { lifetime: 'lifetime', lifetime2x: 'lifetime2x', lifetime4x: 'lifetime4x' };
|
||||||
|
const planValue = document.getElementById('plan-value');
|
||||||
|
let selectedProTier = 'pro';
|
||||||
|
let selectedLifetimeTier = document.querySelector('.lt-btn')?.dataset.plan || 'lifetime';
|
||||||
|
|
||||||
|
// Plan type toggle (pro vs lifetime)
|
||||||
|
document.querySelectorAll('input[name="_planType"]').forEach(radio => {
|
||||||
|
radio.addEventListener('change', () => {
|
||||||
|
planValue.value = radio.value === 'lifetime' ? selectedLifetimeTier : selectedProTier;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pro tier buttons
|
||||||
|
document.querySelectorAll('.tier-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
selectedProTier = btn.dataset.plan;
|
||||||
|
planValue.value = selectedProTier;
|
||||||
|
document.querySelectorAll('.tier-btn').forEach(b => {
|
||||||
|
b.classList.remove('border-blue-500/50');
|
||||||
|
b.classList.add('border-border-subtle');
|
||||||
|
});
|
||||||
|
btn.classList.remove('border-border-subtle');
|
||||||
|
btn.classList.add('border-blue-500/50');
|
||||||
|
updateMonthPricing(selectedProTier);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lifetime tier buttons
|
||||||
|
document.querySelectorAll('.lt-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
selectedLifetimeTier = btn.dataset.plan;
|
||||||
|
planValue.value = selectedLifetimeTier;
|
||||||
|
document.querySelectorAll('.lt-btn').forEach(b => {
|
||||||
|
b.classList.remove('border-yellow-500/50');
|
||||||
|
b.classList.add('border-border-subtle');
|
||||||
|
});
|
||||||
|
btn.classList.remove('border-border-subtle');
|
||||||
|
btn.classList.add('border-yellow-500/50');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select first available lifetime tier by default
|
||||||
|
const firstLt = document.querySelector('.lt-btn');
|
||||||
|
if (firstLt) {
|
||||||
|
firstLt.classList.remove('border-border-subtle');
|
||||||
|
firstLt.classList.add('border-yellow-500/50');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMonthPricing(plan) {
|
||||||
|
const sel = document.getElementById('months-select');
|
||||||
|
if (!sel) return;
|
||||||
|
const mult = multipliers[plan] || 1;
|
||||||
|
for (const opt of sel.options) {
|
||||||
|
const base = parseInt(opt.dataset.base);
|
||||||
|
opt.textContent = opt.value + ' month' + (opt.value > 1 ? 's' : '') + ' - $' + (base * mult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<%~ include('./partials/foot') %>
|
||||||
+861
-183
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,725 @@
|
|||||||
|
<%~ include('./partials/public-head', { title: 'Documentation', nav: 'docs' }) %>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Code blocks use mono, rest uses sans */
|
||||||
|
.docs pre, .docs code { font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace; }
|
||||||
|
|
||||||
|
/* Sidebar nav */
|
||||||
|
.nav-link { display: block; padding: 0.3rem 0.75rem; font-size: 0.8rem; color: #6b7280; border-left: 2px solid transparent; transition: all 0.1s; }
|
||||||
|
.nav-link:hover { color: #d1d5db; border-left-color: #374151; }
|
||||||
|
.nav-link.active { color: #93c5fd; border-left-color: #3b82f6; }
|
||||||
|
.nav-section { font-size: 0.7rem; font-weight: 600; color: #374151; text-transform: uppercase; letter-spacing: 0.08em; padding: 0.75rem 0.75rem 0.25rem; margin-top: 0.5rem; }
|
||||||
|
|
||||||
|
/* Content - scoped to .docs */
|
||||||
|
.docs .section { padding-top: 3rem; padding-bottom: 1rem; border-top: 1px solid #111827; margin-top: 2rem; }
|
||||||
|
.docs .section:first-child { border-top: none; margin-top: 0; padding-top: 0; }
|
||||||
|
.docs h2 { font-size: 1.2rem; font-weight: 700; color: #f9fafb; margin-bottom: 0.5rem; }
|
||||||
|
.docs h3 { font-size: 0.85rem; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.06em; margin: 1.5rem 0 0.5rem; }
|
||||||
|
.docs p { font-size: 0.875rem; color: #6b7280; line-height: 1.7; margin-bottom: 0.75rem; }
|
||||||
|
.docs p a { color: #93c5fd; }
|
||||||
|
.docs p a:hover { color: #bfdbfe; }
|
||||||
|
.docs table { width: 100%; border-collapse: collapse; font-size: 0.8rem; margin: 0.5rem 0 1.25rem; }
|
||||||
|
.docs th { text-align: left; color: #4b5563; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; padding: 0.4rem 0.75rem; border-bottom: 1px solid #111827; }
|
||||||
|
.docs td { color: #6b7280; padding: 0.45rem 0.75rem; border-bottom: 1px solid #0d1117; vertical-align: top; }
|
||||||
|
.docs td:first-child { color: #93c5fd; white-space: nowrap; font-size: 0.78rem; }
|
||||||
|
.docs td code { background: #1e293b; color: #94a3b8; padding: 0.1em 0.35em; border-radius: 0.2rem; font-size: 0.75rem; }
|
||||||
|
|
||||||
|
/* Code blocks */
|
||||||
|
.cb { background: #0f172a; border: 1px solid #1e293b; border-radius: 0.5rem; overflow: hidden; margin: 0.75rem 0 1.25rem; }
|
||||||
|
.cb-header { background: #0a1628; border-bottom: 1px solid #1e293b; padding: 0.4rem 0.85rem; display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.cb-lang { font-size: 0.7rem; color: #475569; }
|
||||||
|
.cb-copy { font-size: 0.7rem; color: #3b82f6; cursor: pointer; }
|
||||||
|
.cb-copy:hover { color: #93c5fd; }
|
||||||
|
.cb pre { padding: 0.85rem; font-size: 0.78rem; line-height: 1.65; color: #e2e8f0; overflow-x: auto; margin: 0; }
|
||||||
|
|
||||||
|
/* Syntax */
|
||||||
|
.k { color: #c084fc; } .s { color: #34d399; } .n { color: #f59e0b; } .o { color: #60a5fa; } .c { color: #334155; }
|
||||||
|
|
||||||
|
/* Endpoint blocks */
|
||||||
|
.endpoint { display: flex; align-items: baseline; gap: 0.75rem; margin-bottom: 0.5rem; }
|
||||||
|
.method { font-size: 0.7rem; font-weight: 700; padding: 0.15rem 0.45rem; border-radius: 0.2rem; }
|
||||||
|
.method.get { background: #052e16; color: #4ade80; }
|
||||||
|
.method.post { background: #172554; color: #93c5fd; }
|
||||||
|
.method.patch { background: #1c1917; color: #fb923c; }
|
||||||
|
.method.delete { background: #3f1215; color: #f87171; }
|
||||||
|
.path { color: #e2e8f0; font-size: 0.875rem; }
|
||||||
|
.endpoint-desc { font-size: 0.8rem; color: #4b5563; margin-bottom: 1rem; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="flex max-w-6xl mx-auto pt-16">
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="w-52 shrink-0 sticky top-16 h-[calc(100vh-4rem)] overflow-y-auto py-6 hidden md:block">
|
||||||
|
<div class="nav-section">Getting Started</div>
|
||||||
|
<a href="#overview" class="nav-link">Overview</a>
|
||||||
|
<a href="#auth" class="nav-link">Authentication</a>
|
||||||
|
|
||||||
|
<div class="nav-section">API Reference</div>
|
||||||
|
<a href="#account" class="nav-link">Account</a>
|
||||||
|
<a href="#monitors" class="nav-link">Monitors</a>
|
||||||
|
<a href="#notifications" class="nav-link">Notifications</a>
|
||||||
|
<a href="#status-pages" class="nav-link">Status Pages</a>
|
||||||
|
<a href="#incidents" class="nav-link">Incidents</a>
|
||||||
|
|
||||||
|
<div class="nav-section">Query Language</div>
|
||||||
|
<a href="#ql-fields" class="nav-link">Fields</a>
|
||||||
|
<a href="#ql-operators" class="nav-link">Operators</a>
|
||||||
|
<a href="#ql-json" class="nav-link">$json</a>
|
||||||
|
<a href="#ql-select" class="nav-link">$html</a>
|
||||||
|
<a href="#ql-logical" class="nav-link">Logical</a>
|
||||||
|
|
||||||
|
<div class="nav-section">Guides</div>
|
||||||
|
<a href="#ql-examples" class="nav-link">Query examples</a>
|
||||||
|
<a href="#reliability" class="nav-link">Reliability & noise</a>
|
||||||
|
<a href="#webhook-payload" class="nav-link">Webhook payload</a>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="docs flex-1 px-10 py-8 max-w-3xl">
|
||||||
|
|
||||||
|
<!-- Overview -->
|
||||||
|
<div id="overview" class="section">
|
||||||
|
<h2>Overview</h2>
|
||||||
|
<p>PingQL is a developer-friendly uptime monitoring API. Monitors are defined with a URL, an interval, and an optional query that determines what "up" means for your service.</p>
|
||||||
|
<p>Base URL: <code style="color:#93c5fd;background:#0f172a;padding:0.15em 0.4em;border-radius:0.25rem;font-size:0.8rem">https://api.pingql.com</code></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auth -->
|
||||||
|
<div id="auth" class="section">
|
||||||
|
<h2>Authentication</h2>
|
||||||
|
<p>All API requests require an account key passed as a Bearer token:</p>
|
||||||
|
<div class="cb">
|
||||||
|
<div class="cb-header"><span class="cb-lang">http</span></div>
|
||||||
|
<pre>Authorization: Bearer <your-64-char-hex-key></pre>
|
||||||
|
</div>
|
||||||
|
<p>Create an account at <a href="/dashboard">/dashboard</a> or via the API. Keys are 64-character hex strings (256-bit). Shown once at registration — store them securely.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reliability -->
|
||||||
|
<!-- Account -->
|
||||||
|
<div id="account" class="section">
|
||||||
|
<h2>Account</h2>
|
||||||
|
|
||||||
|
<h3>Register</h3>
|
||||||
|
<div class="endpoint"><span class="method post">POST</span><span class="path">/account/register</span></div>
|
||||||
|
<p class="endpoint-desc">Create a new account. Email is optional and used only for account recovery.</p>
|
||||||
|
<div class="cb">
|
||||||
|
<div class="cb-header"><span class="cb-lang">json - request body</span></div>
|
||||||
|
<pre>{ <span class="k">"email"</span>: <span class="s">"you@example.com"</span> } <span class="c">// optional</span></pre>
|
||||||
|
</div>
|
||||||
|
<div class="cb">
|
||||||
|
<div class="cb-header"><span class="cb-lang">json - response</span></div>
|
||||||
|
<pre>{ <span class="k">"key"</span>: <span class="s">"5bf5311b56d09254c8a1f0e3..."</span>, <span class="k">"email_registered"</span>: <span class="n">true</span> }</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Update Email</h3>
|
||||||
|
<div class="endpoint"><span class="method post">POST</span><span class="path">/account/email</span></div>
|
||||||
|
<p class="endpoint-desc">Set or update the recovery email for an existing account.</p>
|
||||||
|
<div class="cb">
|
||||||
|
<div class="cb-header"><span class="cb-lang">json - request body</span></div>
|
||||||
|
<pre>{ <span class="k">"email"</span>: <span class="s">"you@example.com"</span> }</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Monitors -->
|
||||||
|
<div id="monitors" class="section">
|
||||||
|
<h2>Monitors</h2>
|
||||||
|
|
||||||
|
<h3>List</h3>
|
||||||
|
<div class="endpoint"><span class="method get">GET</span><span class="path">/monitors/</span></div>
|
||||||
|
<p class="endpoint-desc">Returns all monitors for the authenticated account.</p>
|
||||||
|
|
||||||
|
<h3>Create</h3>
|
||||||
|
<div class="endpoint"><span class="method post">POST</span><span class="path">/monitors/</span></div>
|
||||||
|
<div class="cb">
|
||||||
|
<div class="cb-header"><span class="cb-lang">json - request body</span></div>
|
||||||
|
<pre>{
|
||||||
|
<span class="k">"name"</span>: <span class="s">"My API"</span>,
|
||||||
|
<span class="k">"url"</span>: <span class="s">"https://api.example.com/health"</span>,
|
||||||
|
<span class="k">"interval_s"</span>: <span class="n">60</span>, <span class="c">// check every 60 seconds (min: 2)</span>
|
||||||
|
<span class="k">"method"</span>: <span class="s">"POST"</span>, <span class="c">// optional - default: GET</span>
|
||||||
|
<span class="k">"request_headers"</span>: { <span class="s">"X-Api-Key"</span>: <span class="s">"secret"</span> }, <span class="c">// optional</span>
|
||||||
|
<span class="k">"request_body"</span>: <span class="s">"{\"ping\": true}"</span>, <span class="c">// optional - Content-Type defaults to application/json</span>
|
||||||
|
<span class="k">"regions"</span>: [<span class="s">"eu-central"</span>, <span class="s">"us-west"</span>], <span class="c">// optional - default: all regions</span>
|
||||||
|
<span class="k">"timeout_ms"</span>: <span class="n">10000</span>, <span class="c">// optional - default: 10000</span>
|
||||||
|
<span class="k">"max_retries"</span>: <span class="n">2</span>, <span class="c">// optional - retry N times before declaring DOWN. Default: 0</span>
|
||||||
|
<span class="k">"retry_interval_s"</span>: <span class="n">30</span>, <span class="c">// optional - seconds between retries. Default: 30</span>
|
||||||
|
<span class="k">"resend_interval"</span>: <span class="n">10</span>, <span class="c">// optional - re-alert every Nth consecutive DOWN beat. 0 = never. Default: 0</span>
|
||||||
|
<span class="k">"cert_alert_days"</span>: <span class="n">0</span>, <span class="c">// optional - alert when TLS cert is within N days of expiry. 0 disables. Default: 0 (disabled)</span>
|
||||||
|
<span class="k">"max_redirects"</span>: <span class="n">1</span>, <span class="c">// optional - follow up to N redirects (0-4). Default: 1</span>
|
||||||
|
<span class="k">"query"</span>: { ... } <span class="c">// optional - see Query Language below</span>
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>name</td><td>string</td><td>Display name for the monitor</td></tr>
|
||||||
|
<tr><td>url</td><td>string</td><td>URL to monitor</td></tr>
|
||||||
|
<tr><td>interval_s</td><td>number</td><td>Check interval in seconds (min: 30 free, 2 pro)</td></tr>
|
||||||
|
<tr><td>method</td><td>string</td><td>HTTP method - GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS</td></tr>
|
||||||
|
<tr><td>request_headers</td><td>object</td><td>Custom headers as key-value pairs</td></tr>
|
||||||
|
<tr><td>request_body</td><td>string</td><td>Request body (Content-Type defaults to application/json)</td></tr>
|
||||||
|
<tr><td>regions</td><td>string[]</td><td>Regions to ping from: <code>eu-central</code>, <code>us-west</code>. Default: all</td></tr>
|
||||||
|
<tr><td>timeout_ms</td><td>number</td><td>Request timeout in milliseconds (default: 10000)</td></tr>
|
||||||
|
<tr><td>max_retries</td><td>number</td><td>Retry a failing check this many times before posting a DOWN result. Default: 0. Max: 10. See <a href="#reliability">Reliability</a>.</td></tr>
|
||||||
|
<tr><td>retry_interval_s</td><td>number</td><td>Seconds between retries. Default: 30.</td></tr>
|
||||||
|
<tr><td>resend_interval</td><td>number</td><td>If a monitor stays DOWN, re-fire a notification every Nth consecutive down beat. 0 disables resend. Default: 0.</td></tr>
|
||||||
|
<tr><td>cert_alert_days</td><td>number</td><td>Fire a separate <code>cert</code> notification when the TLS certificate is within N days of expiring. 0 disables. Default: 0 (disabled).</td></tr>
|
||||||
|
<tr><td>max_redirects</td><td>number</td><td>Follow up to N redirects (0-4). 0 = don't follow. Default: 1.</td></tr>
|
||||||
|
<tr><td>query</td><td>object</td><td>Query conditions - see below</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Get</h3>
|
||||||
|
<div class="endpoint"><span class="method get">GET</span><span class="path">/monitors/:id</span></div>
|
||||||
|
<p class="endpoint-desc">Returns a monitor including its most recent ping results.</p>
|
||||||
|
|
||||||
|
<h3>Update</h3>
|
||||||
|
<div class="endpoint"><span class="method patch">PATCH</span><span class="path">/monitors/:id</span></div>
|
||||||
|
<p class="endpoint-desc">Update any field. All fields are optional.</p>
|
||||||
|
|
||||||
|
<h3>Delete</h3>
|
||||||
|
<div class="endpoint"><span class="method delete">DELETE</span><span class="path">/monitors/:id</span></div>
|
||||||
|
|
||||||
|
<h3>Toggle</h3>
|
||||||
|
<div class="endpoint"><span class="method post">POST</span><span class="path">/monitors/:id/toggle</span></div>
|
||||||
|
<p class="endpoint-desc">Enable or disable a monitor without deleting it.</p>
|
||||||
|
|
||||||
|
<h3>Ping History</h3>
|
||||||
|
<div class="endpoint"><span class="method get">GET</span><span class="path">/monitors/:id/pings</span></div>
|
||||||
|
<p class="endpoint-desc">Returns recent ping results for a monitor. Each ping carries an <code>important</code> boolean - true on status transitions and resend ticks (the beats that triggered notifications).</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Param</th><th>Type</th><th>Description</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>limit</td><td>number</td><td>Results per page (default 100, max 1000).</td></tr>
|
||||||
|
<tr><td>skip</td><td>number</td><td>Number of results to skip. Use with <code>limit</code> for pagination.</td></tr>
|
||||||
|
<tr><td>filter</td><td>string</td><td><code>up</code>, <code>down</code>, or <code>events</code> (state transitions only). Omit for all pings.</td></tr>
|
||||||
|
<tr><td>before</td><td>number</td><td>Unix timestamp in seconds. Only returns pings older than this.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notifications -->
|
||||||
|
<div id="notifications" class="section">
|
||||||
|
<h2>Notifications</h2>
|
||||||
|
<p>Each notification channel holds a list of monitors it dispatches for. When an important beat fires (DOWN, recovery, or cert) on one of its attached monitors, the channel is dispatched. PingQL ships with a <strong>webhook</strong> provider; more (Discord, Slack, Email) are designed as drop-ins.</p>
|
||||||
|
|
||||||
|
<h3>List channels</h3>
|
||||||
|
<div class="endpoint"><span class="method get">GET</span><span class="path">/notifications/channels</span></div>
|
||||||
|
<p class="endpoint-desc">Returns all channels for the authenticated account.</p>
|
||||||
|
|
||||||
|
<h3>Create channel</h3>
|
||||||
|
<div class="endpoint"><span class="method post">POST</span><span class="path">/notifications/channels</span></div>
|
||||||
|
<div class="cb">
|
||||||
|
<div class="cb-header"><span class="cb-lang">json - request body</span></div>
|
||||||
|
<pre>{
|
||||||
|
<span class="k">"name"</span>: <span class="s">"On-call webhook"</span>,
|
||||||
|
<span class="k">"kind"</span>: <span class="s">"webhook"</span>,
|
||||||
|
<span class="k">"config"</span>: {
|
||||||
|
<span class="k">"url"</span>: <span class="s">"https://hooks.example.com/pingql"</span>,
|
||||||
|
<span class="k">"headers"</span>: { <span class="s">"X-Team"</span>: <span class="s">"infra"</span> }, <span class="c">// optional</span>
|
||||||
|
<span class="k">"secret"</span>: <span class="s">"shared-hmac-secret"</span> <span class="c">// optional - signs payloads</span>
|
||||||
|
},
|
||||||
|
<span class="k">"monitor_ids"</span>: [<span class="s">"<monitor-id>"</span>], <span class="c">// optional - monitors to dispatch for</span>
|
||||||
|
<span class="k">"enabled"</span>: <span class="n">true</span> <span class="c">// optional - default true</span>
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>name</td><td>string</td><td>Display name (up to 200 chars)</td></tr>
|
||||||
|
<tr><td>kind</td><td>string</td><td>Provider type. Currently only <code>webhook</code>.</td></tr>
|
||||||
|
<tr><td>config</td><td>object</td><td>Provider-specific config. For <code>webhook</code>, requires <code>url</code> (http/https). Optional <code>headers</code> object and <code>secret</code> for HMAC signing.</td></tr>
|
||||||
|
<tr><td>enabled</td><td>boolean</td><td>Disabled channels are skipped during dispatch but remain attached.</td></tr>
|
||||||
|
<tr><td>monitor_ids</td><td>string[]</td><td>Monitor IDs this channel dispatches for. See <a href="#notifications">Attaching monitors</a> below.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Update channel</h3>
|
||||||
|
<div class="endpoint"><span class="method patch">PATCH</span><span class="path">/notifications/channels/:id</span></div>
|
||||||
|
<p class="endpoint-desc">All fields optional. Provide a partial body to change name, config, enabled state, or <code>monitor_ids</code>.</p>
|
||||||
|
|
||||||
|
<h3>Delete channel</h3>
|
||||||
|
<div class="endpoint"><span class="method delete">DELETE</span><span class="path">/notifications/channels/:id</span></div>
|
||||||
|
<p class="endpoint-desc">Removes the channel and all monitor attachments to it.</p>
|
||||||
|
|
||||||
|
<h3>Test channel</h3>
|
||||||
|
<div class="endpoint"><span class="method post">POST</span><span class="path">/notifications/channels/:id/test</span></div>
|
||||||
|
<p class="endpoint-desc">Sends a synthetic <code>test</code> event through the channel and returns whether the provider accepted it. Useful to verify the URL and HMAC are wired correctly without waiting for a real outage.</p>
|
||||||
|
|
||||||
|
<h3>Attaching monitors to channels</h3>
|
||||||
|
<p>Pass <code>monitor_ids</code> as an array of monitor IDs when creating or patching a channel. The PATCH replaces the full set; pass an empty array to detach all monitors. Monitors can only be attached to channels in the same account.</p>
|
||||||
|
<div class="cb">
|
||||||
|
<div class="cb-header"><span class="cb-lang">http</span></div>
|
||||||
|
<pre>PATCH /notifications/channels/5fb1c0bf-…
|
||||||
|
Authorization: Bearer <key>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ <span class="k">"monitor_ids"</span>: [<span class="s">"abc123def456"</span>, <span class="s">"f0e1d2c3b4a59687"</span>] }</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Pages -->
|
||||||
|
<div id="status-pages" class="section">
|
||||||
|
<h2>Status Pages</h2>
|
||||||
|
<p>Create public status pages that display live uptime, heartbeat bars, and incidents for your monitors. Each page gets a unique slug and is publicly accessible at <code>pages.pingql.com/{slug}</code>.</p>
|
||||||
|
|
||||||
|
<h3>List pages</h3>
|
||||||
|
<div class="endpoint"><span class="method get">GET</span><span class="path">/pages</span></div>
|
||||||
|
<p class="endpoint-desc">Returns all status pages for the authenticated account.</p>
|
||||||
|
|
||||||
|
<h3>Create page</h3>
|
||||||
|
<div class="endpoint"><span class="method post">POST</span><span class="path">/pages</span></div>
|
||||||
|
<div class="cb">
|
||||||
|
<div class="cb-header"><span class="cb-lang">json - request body</span></div>
|
||||||
|
<pre>{
|
||||||
|
<span class="k">"slug"</span>: <span class="s">"my-app"</span>,
|
||||||
|
<span class="k">"title"</span>: <span class="s">"My App Status"</span>,
|
||||||
|
<span class="k">"groups"</span>: [
|
||||||
|
{ <span class="k">"name"</span>: <span class="s">"Core"</span>, <span class="k">"position"</span>: <span class="n">0</span> },
|
||||||
|
{ <span class="k">"name"</span>: <span class="s">"Integrations"</span>, <span class="k">"position"</span>: <span class="n">1</span> }
|
||||||
|
],
|
||||||
|
<span class="k">"monitors"</span>: [
|
||||||
|
{ <span class="k">"monitor_id"</span>: <span class="s">"a1b2c3d4e5f67890"</span>, <span class="k">"group_index"</span>: <span class="n">0</span> },
|
||||||
|
{ <span class="k">"monitor_id"</span>: <span class="s">"f0e1d2c3b4a59687"</span>, <span class="k">"group_index"</span>: <span class="n">1</span>, <span class="k">"display_name"</span>: <span class="s">"Stripe"</span> }
|
||||||
|
]
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>slug</td><td>string</td><td>URL slug (lowercase, hyphens, 1-80 chars). Must be unique.</td></tr>
|
||||||
|
<tr><td>title</td><td>string</td><td>Page title shown in the header (up to 200 chars).</td></tr>
|
||||||
|
<tr><td>description</td><td>string?</td><td>Shown below the title (up to 2000 chars).</td></tr>
|
||||||
|
<tr><td>theme</td><td>string?</td><td><code>auto</code>, <code>light</code>, or <code>dark</code>. Default <code>auto</code>.</td></tr>
|
||||||
|
<tr><td>password</td><td>string?</td><td>Plain text, hashed at write time. Pass <code>null</code> to clear.</td></tr>
|
||||||
|
|
||||||
|
<tr><td>bar_frequency</td><td>string?</td><td><code>hourly</code> or <code>daily</code>. Default <code>daily</code>.</td></tr>
|
||||||
|
<tr><td>bar_count</td><td>number?</td><td>How many bars to show (1-180). Default 90.</td></tr>
|
||||||
|
<tr><td>auto_refresh_s</td><td>number?</td><td>Auto-refresh interval in seconds (10-3600). Default 60.</td></tr>
|
||||||
|
<tr><td>custom_domain</td><td>string?</td><td>Custom domain for the page (e.g. <code>status.example.com</code>). CNAME to the status server IP. SSL is automatic.</td></tr>
|
||||||
|
<tr><td>custom_css</td><td>string?</td><td>Custom CSS injected after the default stylesheet (up to 50KB).</td></tr>
|
||||||
|
<tr><td>footer_text</td><td>string?</td><td>Text shown in the page footer (up to 5000 chars).</td></tr>
|
||||||
|
<tr><td>index_search</td><td>boolean?</td><td>Allow search engine indexing. Default <code>true</code>.</td></tr>
|
||||||
|
<tr><td>show_powered_by</td><td>boolean?</td><td>Show "Powered by PingQL" footer. Default <code>true</code>.</td></tr>
|
||||||
|
<tr><td>show_response_time</td><td>boolean?</td><td>Display response time per monitor. Default <code>true</code>.</td></tr>
|
||||||
|
<tr><td>groups</td><td>array?</td><td>Groups to organize monitors. Each has <code>name</code> (string) and optional <code>position</code> (number).</td></tr>
|
||||||
|
<tr><td>monitors</td><td>array?</td><td>Monitors to display. See monitor fields below.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Monitor fields</h3>
|
||||||
|
<p>Each entry in the <code>monitors</code> array accepts:</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>monitor_id</td><td>string</td><td>ID of a monitor you own.</td></tr>
|
||||||
|
<tr><td>group_index</td><td>number?</td><td>Index into the <code>groups</code> array (0-based). Omit for ungrouped.</td></tr>
|
||||||
|
<tr><td>display_name</td><td>string?</td><td>Override the monitor's name on this page.</td></tr>
|
||||||
|
<tr><td>position</td><td>number?</td><td>Sort order within the group.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Get page</h3>
|
||||||
|
<div class="endpoint"><span class="method get">GET</span><span class="path">/pages/:id</span></div>
|
||||||
|
<p class="endpoint-desc">Returns the page with its <code>groups</code> and <code>monitors</code> arrays, plus <code>has_password</code> (boolean).</p>
|
||||||
|
|
||||||
|
<h3>Update page</h3>
|
||||||
|
<div class="endpoint"><span class="method patch">PATCH</span><span class="path">/pages/:id</span></div>
|
||||||
|
<p class="endpoint-desc">All fields optional. If <code>groups</code> or <code>monitors</code> are provided, they replace the full set.</p>
|
||||||
|
|
||||||
|
<h3>Delete page</h3>
|
||||||
|
<div class="endpoint"><span class="method delete">DELETE</span><span class="path">/pages/:id</span></div>
|
||||||
|
<p class="endpoint-desc">Permanently removes the page and all associated groups and monitor links.</p>
|
||||||
|
|
||||||
|
<h3>Public endpoints</h3>
|
||||||
|
<p>Each published page is available at <code>pages.pingql.com/{slug}</code> in multiple formats:</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>URL</th><th>Description</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>/{slug}</td><td>HTML page with live uptime, bars, and incidents.</td></tr>
|
||||||
|
<tr><td>/{slug}.json</td><td>Full JSON payload (monitors, uptime, incidents).</td></tr>
|
||||||
|
<tr><td>/{slug}.rss</td><td>RSS feed of incidents.</td></tr>
|
||||||
|
<tr><td>/{slug}/badge.svg</td><td>Embeddable SVG status badge.</td></tr>
|
||||||
|
<tr><td>/{slug}/monitor/{id}.json</td><td>Per-monitor detail (uptime, regions, incidents).</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p>Password-protected pages return <code>404</code> for all formats unless a valid auth cookie is present. Post credentials via <code>POST /{slug}/auth</code> with a form-encoded <code>password</code> field.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Incidents -->
|
||||||
|
<div id="incidents" class="section">
|
||||||
|
<h2>Incidents</h2>
|
||||||
|
<p>Declare incidents, post updates as the situation develops, and link them to monitors and status pages. Active incidents appear on any linked status page automatically.</p>
|
||||||
|
|
||||||
|
<h3>List incidents</h3>
|
||||||
|
<div class="endpoint"><span class="method get">GET</span><span class="path">/incidents</span></div>
|
||||||
|
<p class="endpoint-desc">Returns the 200 most recent incidents, newest first.</p>
|
||||||
|
|
||||||
|
<h3>Create incident</h3>
|
||||||
|
<div class="endpoint"><span class="method post">POST</span><span class="path">/incidents</span></div>
|
||||||
|
<div class="cb">
|
||||||
|
<div class="cb-header"><span class="cb-lang">json - request body</span></div>
|
||||||
|
<pre>{
|
||||||
|
<span class="k">"title"</span>: <span class="s">"API returning 503s"</span>,
|
||||||
|
<span class="k">"status"</span>: <span class="s">"investigating"</span>,
|
||||||
|
<span class="k">"severity"</span>: <span class="s">"major"</span>,
|
||||||
|
<span class="k">"monitor_ids"</span>: [<span class="s">"a1b2c3d4e5f67890"</span>],
|
||||||
|
<span class="k">"status_page_ids"</span>: [<span class="s">"f8c1a2b3-..."</span>],
|
||||||
|
<span class="k">"initial_update"</span>: {
|
||||||
|
<span class="k">"body"</span>: <span class="s">"We're seeing elevated error rates on the API. Investigating now."</span>
|
||||||
|
}
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>title</td><td>string</td><td>Short description (up to 200 chars).</td></tr>
|
||||||
|
<tr><td>status</td><td>string</td><td><code>investigating</code>, <code>identified</code>, <code>monitoring</code>, or <code>resolved</code>.</td></tr>
|
||||||
|
<tr><td>severity</td><td>string?</td><td><code>minor</code>, <code>major</code>, or <code>critical</code>. Default <code>minor</code>.</td></tr>
|
||||||
|
<tr><td>pinned</td><td>boolean?</td><td>Pinned incidents show in the active section on status pages. Default <code>true</code>.</td></tr>
|
||||||
|
<tr><td>monitor_ids</td><td>array?</td><td>IDs of affected monitors.</td></tr>
|
||||||
|
<tr><td>status_page_ids</td><td>array?</td><td>IDs of status pages to display this incident on.</td></tr>
|
||||||
|
<tr><td>initial_update</td><td>object?</td><td>Create the first timeline update along with the incident. Has one field: <code>body</code> (string, supports basic markdown).</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Get incident</h3>
|
||||||
|
<div class="endpoint"><span class="method get">GET</span><span class="path">/incidents/:id</span></div>
|
||||||
|
<p class="endpoint-desc">Returns the incident with its <code>updates</code> timeline, <code>monitor_ids</code>, and <code>status_page_ids</code>.</p>
|
||||||
|
|
||||||
|
<h3>Update incident</h3>
|
||||||
|
<div class="endpoint"><span class="method patch">PATCH</span><span class="path">/incidents/:id</span></div>
|
||||||
|
<p class="endpoint-desc">All fields optional. Setting status to <code>resolved</code> automatically sets <code>resolved_at</code>. Changing to any other status clears it.</p>
|
||||||
|
|
||||||
|
<h3>Post update</h3>
|
||||||
|
<div class="endpoint"><span class="method post">POST</span><span class="path">/incidents/:id/updates</span></div>
|
||||||
|
<div class="cb">
|
||||||
|
<div class="cb-header"><span class="cb-lang">json - request body</span></div>
|
||||||
|
<pre>{
|
||||||
|
<span class="k">"status"</span>: <span class="s">"identified"</span>,
|
||||||
|
<span class="k">"body"</span>: <span class="s">"Root cause identified - a bad deploy at 14:02 UTC. Rolling back now."</span>
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
<p>The <code>body</code> field supports basic markdown: <code>**bold**</code>, <code>*italic*</code>, <code>`code`</code>, and <code>[links](url)</code>. The incident's status is automatically synced to the latest update's status.</p>
|
||||||
|
|
||||||
|
<h3>Delete incident</h3>
|
||||||
|
<div class="endpoint"><span class="method delete">DELETE</span><span class="path">/incidents/:id</span></div>
|
||||||
|
<p class="endpoint-desc">Permanently removes the incident, all its updates, and all monitor/page links.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- QL Fields -->
|
||||||
|
<div id="ql-fields" class="section">
|
||||||
|
<h2>Query Language - Fields</h2>
|
||||||
|
<p>A PingQL query is a JSON object evaluated against each ping. If it returns <code style="color:#4ade80;background:#052e16;padding:0.1em 0.35em;border-radius:0.2rem;font-size:0.78rem">true</code>, the monitor is <strong style="color:#4ade80">up</strong>. Default (no query): up only on a <strong>2xx</strong> status. Redirects and errors all count as DOWN.</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>status</td><td>number</td><td>HTTP status code</td></tr>
|
||||||
|
<tr><td>body</td><td>string</td><td>Full response body as text</td></tr>
|
||||||
|
<tr><td>size</td><td>number</td><td>Response body size in bytes</td></tr>
|
||||||
|
<tr><td>headers.<em>name</em></td><td>string</td><td>Response header, e.g. <code>headers.content-type</code></td></tr>
|
||||||
|
<tr><td>$time</td><td>number</td><td>Request latency in milliseconds</td></tr>
|
||||||
|
<tr><td>$certExpiry</td><td>number</td><td>Days until SSL certificate expires</td></tr>
|
||||||
|
<tr><td>$certIssuer</td><td>string</td><td>Certificate authority name (e.g. "Let's Encrypt")</td></tr>
|
||||||
|
<tr><td>$json</td><td>object</td><td>JSONPath expression against response body</td></tr>
|
||||||
|
<tr><td>$html</td><td>object</td><td>CSS selector against response HTML</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- QL Operators -->
|
||||||
|
<div id="ql-operators" class="section">
|
||||||
|
<h2>Query Language - Operators</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Operator</th><th>Description</th><th>Types</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>$eq</td><td>Equal to</td><td>any</td></tr>
|
||||||
|
<tr><td>$ne</td><td>Not equal to</td><td>any</td></tr>
|
||||||
|
<tr><td>$gt / $ge</td><td>Greater than / or equal</td><td>number</td></tr>
|
||||||
|
<tr><td>$lt / $le</td><td>Less than / or equal</td><td>number</td></tr>
|
||||||
|
<tr><td>$co</td><td>String contains substring</td><td>string</td></tr>
|
||||||
|
<tr><td>$sw</td><td>String starts with</td><td>string</td></tr>
|
||||||
|
<tr><td>$ew</td><td>String ends with</td><td>string</td></tr>
|
||||||
|
<tr><td>$re</td><td>Matches regular expression</td><td>string</td></tr>
|
||||||
|
<tr><td>$exists</td><td>Field is present and non-null</td><td>any</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="cb">
|
||||||
|
<div class="cb-header"><span class="cb-lang">json</span></div>
|
||||||
|
<pre><span class="c">// simple equality shorthand</span>
|
||||||
|
{ <span class="k">"status"</span>: <span class="n">200</span> }
|
||||||
|
|
||||||
|
<span class="c">// operator form</span>
|
||||||
|
{ <span class="k">"status"</span>: { <span class="o">"$lt"</span>: <span class="n">400</span> } }
|
||||||
|
{ <span class="k">"body"</span>: { <span class="o">"$co"</span>: <span class="s">"healthy"</span> } }
|
||||||
|
{ <span class="k">"headers.content-type"</span>: { <span class="o">"$co"</span>: <span class="s">"application/json"</span> } }</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- $json -->
|
||||||
|
<div id="ql-json" class="section">
|
||||||
|
<h2>$json - JSONPath</h2>
|
||||||
|
<p>Extract and compare a value from a JSON response body. The key is a dot-notation path starting with <code style="color:#93c5fd;background:#0f172a;padding:0.1em 0.35em;border-radius:0.2rem;font-size:0.78rem">$.</code></p>
|
||||||
|
<div class="cb">
|
||||||
|
<div class="cb-header"><span class="cb-lang">json</span></div>
|
||||||
|
<pre><span class="c">// response body: { "status": "ok", "db": { "connections": 12 } }</span>
|
||||||
|
|
||||||
|
{ <span class="o">"$json"</span>: { <span class="s">"$.status"</span>: { <span class="o">"$eq"</span>: <span class="s">"ok"</span> } } }
|
||||||
|
{ <span class="o">"$json"</span>: { <span class="s">"$.db.connections"</span>: { <span class="o">"$lt"</span>: <span class="n">100</span> } } }</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- $html -->
|
||||||
|
<div id="ql-select" class="section">
|
||||||
|
<h2>$html - HTML Selector</h2>
|
||||||
|
<p>Extract text content from an HTML response using a CSS selector. Useful for monitoring public pages without an API.</p>
|
||||||
|
<div class="cb">
|
||||||
|
<div class="cb-header"><span class="cb-lang">json</span></div>
|
||||||
|
<pre><span class="c">// matches if <h1> text is exactly "Example Domain"</span>
|
||||||
|
{ <span class="o">"$html"</span>: { <span class="s">"h1"</span>: { <span class="o">"$eq"</span>: <span class="s">"Example Domain"</span> } } }
|
||||||
|
|
||||||
|
<span class="c">// matches if status badge contains "operational"</span>
|
||||||
|
{ <span class="o">"$html"</span>: { <span class="s">".status-badge"</span>: { <span class="o">"$co"</span>: <span class="s">"operational"</span> } } }</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logical -->
|
||||||
|
<div id="ql-logical" class="section">
|
||||||
|
<h2>Logical Operators</h2>
|
||||||
|
<div class="cb">
|
||||||
|
<div class="cb-header"><span class="cb-lang">json</span></div>
|
||||||
|
<pre><span class="c">// $and - all conditions must match</span>
|
||||||
|
{ <span class="o">"$and"</span>: [{ <span class="k">"status"</span>: <span class="n">200</span> }, { <span class="k">"body"</span>: { <span class="o">"$co"</span>: <span class="s">"ok"</span> } }] }
|
||||||
|
|
||||||
|
<span class="c">// $or - any condition must match</span>
|
||||||
|
{ <span class="o">"$or"</span>: [{ <span class="k">"status"</span>: <span class="n">200</span> }, { <span class="k">"status"</span>: <span class="n">204</span> }] }
|
||||||
|
|
||||||
|
<span class="c">// $not - invert (object or array)</span>
|
||||||
|
{ <span class="o">"$not"</span>: { <span class="k">"status"</span>: <span class="n">500</span> } }
|
||||||
|
{ <span class="o">"$not"</span>: [{ <span class="k">"status"</span>: { <span class="o">"$ge"</span>: <span class="n">500</span> } }, { <span class="k">"$time"</span>: { <span class="o">"$gt"</span>: <span class="n">3000</span> } }] }</pre>
|
||||||
|
</div>
|
||||||
|
<p>All three accept both objects and arrays. An array is always implicit <code>$and</code> (all must match). A bare array as the entire query works too: <code>[{ "status": 200 }, { "body": { "$co": "ok" } }]</code></p>
|
||||||
|
<p>These nest freely. You can put <code>$or</code> inside <code>$and</code>, <code>$and</code> inside <code>$or</code>, or go as deep as you want:</p>
|
||||||
|
<div class="cb">
|
||||||
|
<div class="cb-header"><span class="cb-lang">json</span></div>
|
||||||
|
<pre>{
|
||||||
|
<span class="o">"$and"</span>: [
|
||||||
|
{ <span class="k">"status"</span>: <span class="n">200</span> },
|
||||||
|
{ <span class="o">"$or"</span>: [
|
||||||
|
{ <span class="o">"$json"</span>: { <span class="s">"$.region"</span>: { <span class="o">"$eq"</span>: <span class="s">"us"</span> } } },
|
||||||
|
{ <span class="o">"$json"</span>: { <span class="s">"$.region"</span>: { <span class="o">"$eq"</span>: <span class="s">"eu"</span> } } }
|
||||||
|
] }
|
||||||
|
]
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Query examples -->
|
||||||
|
<div id="ql-examples" class="section">
|
||||||
|
<h2>Query examples</h2>
|
||||||
|
|
||||||
|
<h3>Basic health endpoint</h3>
|
||||||
|
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
|
||||||
|
<pre>{ <span class="k">"status"</span>: <span class="n">200</span>, <span class="k">"body"</span>: { <span class="o">"$co"</span>: <span class="s">"healthy"</span> } }</pre></div>
|
||||||
|
|
||||||
|
<h3>JSON API response shape</h3>
|
||||||
|
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
|
||||||
|
<pre>{
|
||||||
|
<span class="o">"$and"</span>: [
|
||||||
|
{ <span class="k">"status"</span>: <span class="n">200</span> },
|
||||||
|
{ <span class="o">"$json"</span>: { <span class="s">"$.ok"</span>: { <span class="o">"$eq"</span>: <span class="n">true</span> } } }
|
||||||
|
]
|
||||||
|
}</pre></div>
|
||||||
|
|
||||||
|
<h3>Performance monitor (down if slow)</h3>
|
||||||
|
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
|
||||||
|
<pre>{ <span class="k">"$time"</span>: { <span class="o">"$lt"</span>: <span class="n">1000</span> } }</pre></div>
|
||||||
|
|
||||||
|
<h3>Cert expiry alert</h3>
|
||||||
|
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
|
||||||
|
<pre>{ <span class="k">"$certExpiry"</span>: { <span class="o">"$gt"</span>: <span class="n">14</span> } }</pre></div>
|
||||||
|
|
||||||
|
<h3>Status page (HTML)</h3>
|
||||||
|
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
|
||||||
|
<pre>{ <span class="o">"$html"</span>: { <span class="s">".status-indicator"</span>: { <span class="o">"$eq"</span>: <span class="s">"All systems operational"</span> } } }</pre></div>
|
||||||
|
|
||||||
|
<h3>Page down if a JSON queue is backed up</h3>
|
||||||
|
<p>Alert when a JSON field crosses a threshold. Most monitoring tools can't compose this without a script.</p>
|
||||||
|
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
|
||||||
|
<pre>{ <span class="o">"$json"</span>: { <span class="s">"$.queue.depth"</span>: { <span class="o">"$le"</span>: <span class="n">1000</span> } } }</pre></div>
|
||||||
|
|
||||||
|
<h3>Down if any signal looks bad</h3>
|
||||||
|
<p>Wrap <code>$or</code> in <code>$not</code> to mark down when any condition matches.</p>
|
||||||
|
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
|
||||||
|
<pre>{ <span class="o">"$not"</span>: {
|
||||||
|
<span class="o">"$or"</span>: [
|
||||||
|
{ <span class="k">"status"</span>: { <span class="o">"$ge"</span>: <span class="n">500</span> } },
|
||||||
|
{ <span class="k">"$time"</span>: { <span class="o">"$gt"</span>: <span class="n">3000</span> } },
|
||||||
|
{ <span class="o">"$json"</span>: { <span class="s">"$.healthy"</span>: { <span class="o">"$eq"</span>: <span class="n">false</span> } } },
|
||||||
|
{ <span class="o">"$html"</span>: { <span class="s">".error-banner"</span>: { <span class="o">"$exists"</span>: <span class="n">true</span> } } }
|
||||||
|
]
|
||||||
|
} }</pre></div>
|
||||||
|
|
||||||
|
<h3>Up only when everything matches</h3>
|
||||||
|
<p>Combine <code>$and</code> with header, body, and JSON checks for a strict definition of healthy.</p>
|
||||||
|
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
|
||||||
|
<pre>{
|
||||||
|
<span class="o">"$and"</span>: [
|
||||||
|
{ <span class="k">"status"</span>: <span class="n">200</span> },
|
||||||
|
{ <span class="k">"headers.content-type"</span>: { <span class="o">"$co"</span>: <span class="s">"application/json"</span> } },
|
||||||
|
{ <span class="o">"$json"</span>: { <span class="s">"$.version"</span>: { <span class="o">"$sw"</span>: <span class="s">"v2"</span> } } },
|
||||||
|
{ <span class="o">"$json"</span>: { <span class="s">"$.db.connections"</span>: { <span class="o">"$lt"</span>: <span class="n">100</span> } } }
|
||||||
|
]
|
||||||
|
}</pre></div>
|
||||||
|
|
||||||
|
<h3>Nested logic</h3>
|
||||||
|
<p><code>$and</code> and <code>$or</code> nest freely inside each other, as deep as you need.</p>
|
||||||
|
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
|
||||||
|
<pre>{
|
||||||
|
<span class="o">"$and"</span>: [
|
||||||
|
{ <span class="k">"status"</span>: { <span class="o">"$lt"</span>: <span class="n">400</span> } },
|
||||||
|
{ <span class="o">"$or"</span>: [
|
||||||
|
{ <span class="o">"$json"</span>: { <span class="s">"$.env"</span>: { <span class="o">"$eq"</span>: <span class="s">"production"</span> } } },
|
||||||
|
{ <span class="o">"$json"</span>: { <span class="s">"$.env"</span>: { <span class="o">"$eq"</span>: <span class="s">"staging"</span> } } }
|
||||||
|
] },
|
||||||
|
{ <span class="o">"$or"</span>: [
|
||||||
|
{ <span class="k">"$time"</span>: { <span class="o">"$lt"</span>: <span class="n">2000</span> } },
|
||||||
|
{ <span class="o">"$json"</span>: { <span class="s">"$.cache"</span>: { <span class="o">"$eq"</span>: <span class="s">"hit"</span> } } }
|
||||||
|
] }
|
||||||
|
]
|
||||||
|
}</pre></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reliability & alert noise -->
|
||||||
|
<div id="reliability" class="section">
|
||||||
|
<h2>Reliability & alert noise</h2>
|
||||||
|
<p>PingQL doesn't immediately fire on a single failed check. A few knobs let you tune how reactive vs. how stable the alerting is.</p>
|
||||||
|
|
||||||
|
<h3>Retries before DOWN</h3>
|
||||||
|
<p>If a check fails and <code>max_retries</code> is greater than zero, the runner waits <code>retry_interval_s</code> seconds and retries up to that many times <em>before</em> recording a DOWN result. A successful retry posts a single UP ping with <code>meta.retries</code> noting how many attempts it took. This kills almost all flapping caused by transient TCP resets, brief 5xx blips, or network jitter.</p>
|
||||||
|
|
||||||
|
<h3>Important beats & transitions</h3>
|
||||||
|
<p>Every check is recorded, but the <code>important</code> flag on a ping is only set when the monitor's state changes (UP↔DOWN) <em>for that region</em>. Notifications fire on important beats only - never on every routine check. State is tracked independently per region: if <code>us-west</code> goes DOWN, only a subsequent <code>us-west</code> UP clears it. <code>eu-central</code> being healthy will not silence a <code>us-west</code> outage.</p>
|
||||||
|
|
||||||
|
<h3>Resend interval</h3>
|
||||||
|
<p>For long outages, set <code>resend_interval</code> to re-fire the notification every Nth consecutive DOWN beat. With <code>resend_interval: 10</code>, a still-broken monitor produces an extra alert every 10 down checks. <code>0</code> (the default) means: alert once on the transition, then stay quiet until recovery.</p>
|
||||||
|
|
||||||
|
<h3>Cert expiry alerting</h3>
|
||||||
|
<p>For HTTPS monitors PingQL extracts the TLS leaf certificate's days-until-expiry on every check. When that drops at or below <code>cert_alert_days</code> for the first time, a separate <code>cert</code> notification fires (one per region). The flag clears when the cert is renewed, so each renewal cycle gets exactly one alert. Set <code>cert_alert_days: 0</code> to disable.</p>
|
||||||
|
|
||||||
|
<h3>Default empty query</h3>
|
||||||
|
<p>If you don't supply a <code>query</code>, the monitor is considered up only on a <strong style="color:#4ade80">2xx</strong> response. Redirects (3xx), client errors (4xx) and server errors (5xx) all count as DOWN. Use the QL if you want different behaviour.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Webhook payload -->
|
||||||
|
<div id="webhook-payload" class="section">
|
||||||
|
<h2>Webhook payload</h2>
|
||||||
|
<p>Webhook channels POST a JSON body to the configured URL on every event. The HTTP method is <code>POST</code>, the request times out after 5 seconds, and PingQL does not retry - the next important beat is the retry. Failures are logged but never block ingest.</p>
|
||||||
|
|
||||||
|
<h3>Headers</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Header</th><th>Description</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>content-type</td><td><code>application/json</code></td></tr>
|
||||||
|
<tr><td>user-agent</td><td><code>PingQL-Notifier/1</code></td></tr>
|
||||||
|
<tr><td>x-pingql-signature</td><td>Hex-encoded HMAC-SHA256 of the raw request body, keyed by <code>config.secret</code>. Only present when a secret is configured. Verify it server-side to confirm the request came from PingQL.</td></tr>
|
||||||
|
<tr><td><em>custom</em></td><td>Any headers from <code>config.headers</code> are forwarded as-is.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Body shape</h3>
|
||||||
|
<p>Every payload has the same envelope:</p>
|
||||||
|
<div class="cb">
|
||||||
|
<div class="cb-header"><span class="cb-lang">json</span></div>
|
||||||
|
<pre>{
|
||||||
|
<span class="k">"channel"</span>: { <span class="k">"id"</span>: <span class="s">"<uuid>"</span>, <span class="k">"name"</span>: <span class="s">"On-call webhook"</span> },
|
||||||
|
<span class="k">"event"</span>: { <span class="k">"kind"</span>: <span class="s">"down"</span> | <span class="s">"up"</span> | <span class="s">"cert"</span> | <span class="s">"test"</span>, ... }
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Event types</h3>
|
||||||
|
<p><code>down</code> - fired on the first DOWN important beat for a region, and again every <code>resend_interval</code>th consecutive down if configured.</p>
|
||||||
|
<div class="cb"><div class="cb-header"><span class="cb-lang">json - event</span></div>
|
||||||
|
<pre>{
|
||||||
|
<span class="k">"kind"</span>: <span class="s">"down"</span>,
|
||||||
|
<span class="k">"monitor"</span>: {
|
||||||
|
<span class="k">"id"</span>: <span class="s">"abc123def456"</span>,
|
||||||
|
<span class="k">"name"</span>: <span class="s">"My API"</span>,
|
||||||
|
<span class="k">"url"</span>: <span class="s">"https://api.example.com/health"</span>,
|
||||||
|
<span class="k">"region"</span>: <span class="s">"us-west"</span> <span class="c">// always present - runners default to "default" if REGION env var is unset</span>
|
||||||
|
},
|
||||||
|
<span class="k">"ping"</span>: {
|
||||||
|
<span class="k">"status_code"</span>: <span class="n">503</span>,
|
||||||
|
<span class="k">"latency_ms"</span>: <span class="n">412</span>,
|
||||||
|
<span class="k">"error"</span>: <span class="n">null</span>,
|
||||||
|
<span class="k">"checked_at"</span>: <span class="s">"2026-04-08T14:23:00.000Z"</span>
|
||||||
|
}
|
||||||
|
}</pre></div>
|
||||||
|
|
||||||
|
<p><code>up</code> - fired on recovery, only when <em>that same region</em> transitions back from DOWN. Same shape as <code>down</code>.</p>
|
||||||
|
|
||||||
|
<p><code>cert</code> - fired once per renewal cycle when the TLS leaf cert drops at or below <code>cert_alert_days</code> for a region.</p>
|
||||||
|
<div class="cb"><div class="cb-header"><span class="cb-lang">json - event</span></div>
|
||||||
|
<pre>{
|
||||||
|
<span class="k">"kind"</span>: <span class="s">"cert"</span>,
|
||||||
|
<span class="k">"monitor"</span>: { <span class="k">"id"</span>: <span class="s">"…"</span>, <span class="k">"name"</span>: <span class="s">"…"</span>, <span class="k">"url"</span>: <span class="s">"…"</span>, <span class="k">"region"</span>: <span class="s">"us-west"</span> },
|
||||||
|
<span class="k">"days"</span>: <span class="n">9</span> <span class="c">// days until certificate expires</span>
|
||||||
|
}</pre></div>
|
||||||
|
|
||||||
|
<p><code>test</code> - synthetic event from <code>POST /notifications/channels/:id/test</code>. The <code>monitor</code> object is a placeholder.</p>
|
||||||
|
<div class="cb"><div class="cb-header"><span class="cb-lang">json - event</span></div>
|
||||||
|
<pre>{
|
||||||
|
<span class="k">"kind"</span>: <span class="s">"test"</span>,
|
||||||
|
<span class="k">"monitor"</span>: { <span class="k">"id"</span>: <span class="s">"test"</span>, <span class="k">"name"</span>: <span class="s">"Test event"</span>, <span class="k">"url"</span>: <span class="s">"https://example.com"</span>, <span class="k">"region"</span>: <span class="s">""</span> }
|
||||||
|
}</pre></div>
|
||||||
|
|
||||||
|
<h3>Verifying the signature</h3>
|
||||||
|
<div class="cb">
|
||||||
|
<div class="cb-header"><span class="cb-lang">node</span></div>
|
||||||
|
<pre><span class="k">import</span> { createHmac, timingSafeEqual } <span class="k">from</span> <span class="s">"crypto"</span>;
|
||||||
|
|
||||||
|
<span class="k">function</span> verify(rawBody, headerSig, secret) {
|
||||||
|
<span class="k">const</span> expected = createHmac(<span class="s">"sha256"</span>, secret).update(rawBody).digest(<span class="s">"hex"</span>);
|
||||||
|
<span class="k">return</span> timingSafeEqual(Buffer.from(expected), Buffer.from(headerSig));
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
<p>Always verify against the <em>raw</em> request body before parsing JSON.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Highlight active nav link on scroll
|
||||||
|
const sections = document.querySelectorAll('[id]');
|
||||||
|
const navLinks = document.querySelectorAll('.nav-link');
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
navLinks.forEach(l => l.classList.remove('active'));
|
||||||
|
const link = document.querySelector(`.nav-link[href="#${entry.target.id}"]`);
|
||||||
|
if (link) link.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, { rootMargin: '-20% 0px -70% 0px' });
|
||||||
|
sections.forEach(s => observer.observe(s));
|
||||||
|
|
||||||
|
// Copy buttons
|
||||||
|
document.querySelectorAll('.cb-header').forEach(h => {
|
||||||
|
if (!h.querySelector('.cb-copy')) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'cb-copy';
|
||||||
|
btn.textContent = 'Copy';
|
||||||
|
h.appendChild(btn);
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const text = h.nextElementSibling.innerText;
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
btn.textContent = 'Copied!';
|
||||||
|
setTimeout(() => btn.textContent = 'Copy', 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<%~ include('./partials/public-foot') %>
|
||||||
+83
-55
@@ -12,37 +12,42 @@
|
|||||||
<h2 class="text-lg font-semibold text-gray-200">Monitors</h2>
|
<h2 class="text-lg font-semibold text-gray-200">Monitors</h2>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div id="summary" class="text-sm text-gray-500"><% if (it.monitors.length > 0) { %><span class="text-green-400"><%= upCount %> up</span> · <span class="<%= downCount > 0 ? 'text-red-400' : 'text-gray-500' %>"><%= downCount %> down</span> · <%= it.monitors.length %> total<% } %></div>
|
<div id="summary" class="text-sm text-gray-500"><% if (it.monitors.length > 0) { %><span class="text-green-400"><%= upCount %> up</span> · <span class="<%= downCount > 0 ? 'text-red-400' : 'text-gray-500' %>"><%= downCount %> down</span> · <%= it.monitors.length %> total<% } %></div>
|
||||||
<a href="/dashboard/monitors/new" class="bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">+ New</a>
|
<a href="/dashboard/monitors/new" class="btn-primary text-sm px-4 py-2">+ New</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="monitors-list" class="space-y-3">
|
<div id="monitors-list" class="space-y-3">
|
||||||
<% if (it.monitors.length === 0) { %>
|
<% if (it.monitors.length === 0) { %>
|
||||||
<div id="empty-state" class="text-center py-16">
|
<div id="empty-state" class="text-center py-20">
|
||||||
<p class="text-gray-500 mb-4">No monitors yet</p>
|
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gray-800/50 border border-border-subtle flex items-center justify-center">
|
||||||
<a href="/dashboard/monitors/new" class="bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium px-6 py-3 rounded-lg transition-colors inline-block">Create your first monitor</a>
|
<svg class="w-8 h-8 text-gray-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v6m3-3H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-500 mb-1">No monitors yet</p>
|
||||||
|
<p class="text-gray-600 text-sm mb-5">Create your first monitor to start tracking uptime</p>
|
||||||
|
<a href="/dashboard/monitors/new" class="btn-primary text-sm px-6 py-3 inline-block">Create your first monitor</a>
|
||||||
</div>
|
</div>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<% it.monitors.forEach(function(m) {
|
<% it.monitors.forEach(function(m) {
|
||||||
const latencies = (m.pings || []).filter(p => p.latency_ms != null).map(p => p.latency_ms);
|
const pingsForSparkline = (m.pings || []).filter(p => p.latency_ms != null);
|
||||||
const avgLatency = latencies.length ? Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length) : null;
|
const best = it.pickBestRegion(pingsForSparkline);
|
||||||
|
const avgLatency = best.latest;
|
||||||
%>
|
%>
|
||||||
<a href="/dashboard/monitors/<%= m.id %>" data-monitor-id="<%= m.id %>" class="block bg-gray-900 hover:bg-gray-800/80 border border-gray-800 rounded-xl p-4 transition-colors group">
|
<a href="/dashboard/monitors/<%= m.id %>" data-monitor-id="<%= m.id %>" class="block monitor-card rounded-xl p-4 group">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<span class="status-dot w-2.5 h-2.5 rounded-full <%= m.last_ping == null ? 'bg-gray-600' : (m.last_ping.up ? 'bg-green-500' : 'bg-red-500') %>"></span>
|
<span class="status-dot w-2.5 h-2.5 rounded-full shrink-0 <%= m.last_ping == null ? 'dot-unknown' : (m.last_ping.up ? 'dot-up' : 'dot-down') %>"></span>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="font-medium text-gray-100 group-hover:text-white truncate"><%= m.name %></div>
|
<div class="font-medium text-gray-100 group-hover:text-white truncate"><%= m.name %></div>
|
||||||
<div class="text-xs text-gray-500 truncate"><%= m.url %></div>
|
<div class="text-xs text-gray-500 truncate"><%= m.url %></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-6 shrink-0 ml-4">
|
<div class="flex items-center gap-6 shrink-0 ml-4">
|
||||||
<div class="hidden sm:block stat-sparkline" style="width:120px;height:32px"><%~ it.sparklineSSR(latencies) %></div>
|
<div class="hidden sm:block stat-sparkline" style="width:120px;height:32px"><%~ it.sparklineSSR(pingsForSparkline) %></div>
|
||||||
<div class="text-right">
|
<div class="text-right" style="width:72px">
|
||||||
<div class="text-sm text-gray-300 stat-latency"><%= avgLatency != null ? avgLatency + 'ms' : '—' %></div>
|
<div class="text-sm text-gray-300 stat-latency"><%= avgLatency != null ? avgLatency + 'ms' : '-' %></div>
|
||||||
<div class="text-xs text-gray-500 stat-last"><%~ m.last_ping ? it.timeAgoSSR(m.last_ping.checked_at) : 'no pings' %></div>
|
<div class="text-xs text-gray-500 stat-last"><%~ m.last_ping ? it.timeAgoSSR(m.last_ping.checked_at) : 'no pings' %></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs px-2 py-1 rounded <%= m.enabled ? 'bg-gray-800 text-gray-400' : 'bg-yellow-900/30 text-yellow-500' %>"><%= m.enabled ? m.interval_s + 's' : 'paused' %></div>
|
<div class="text-xs px-2 py-1 rounded <%= m.enabled ? 'bg-gray-800/50 text-gray-400 border border-border-subtle' : 'bg-yellow-900/20 text-yellow-500 border border-yellow-800/30' %>"><%= m.enabled ? m.interval_s + 's' : 'paused' %></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -75,62 +80,85 @@
|
|||||||
} catch {}
|
} catch {}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
// Client-side sparkline — seed with SSR data, update locally on SSE
|
const REGION_COLORS = <%~ JSON.stringify(it.regionColors) %>;
|
||||||
const _sparkData = {
|
|
||||||
<% it.monitors.forEach(function(m, i) {
|
|
||||||
const lats = (m.pings || []).filter(p => p.latency_ms != null).map(p => p.latency_ms);
|
|
||||||
%>'<%= m.id %>': [<%- lats.join(',') %>]<% if (i < it.monitors.length - 1) { %>,<% } %>
|
|
||||||
<% }) %>
|
|
||||||
};
|
|
||||||
|
|
||||||
function drawSparkline(values, el) {
|
// Per-monitor tracking: regions = {region: [vals]}, timeline = [{region, val}] in arrival order
|
||||||
const W = 120, H = 32;
|
const sparkData = {}; // mid → { regions: {region: [vals]}, timeline: [{region}] }
|
||||||
if (!values.length) { el.innerHTML = ''; return; }
|
// Seed from SSR data-vals and data-region
|
||||||
const max = Math.max(...values, 1);
|
document.querySelectorAll('[data-monitor-id]').forEach(card => {
|
||||||
const min = Math.min(...values, 0);
|
const mid = card.dataset.monitorId;
|
||||||
const range = max - min || 1;
|
const svg = card.querySelector('.stat-sparkline svg');
|
||||||
const step = W / Math.max(values.length - 1, 1);
|
if (!svg) return;
|
||||||
const pts = values.map((v, i) => {
|
const region = svg.dataset.region || '__none__';
|
||||||
const x = i * step;
|
const vals = svg.dataset.vals ? svg.dataset.vals.split(',').map(Number) : [];
|
||||||
const y = H - ((v - min) / range) * (H - 4) - 2;
|
sparkData[mid] = {
|
||||||
return `${x},${y}`;
|
regions: { [region]: vals },
|
||||||
}).join(' ');
|
timeline: vals.map(() => region),
|
||||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
};
|
||||||
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
});
|
||||||
svg.setAttribute('width', W);
|
|
||||||
svg.setAttribute('height', H);
|
function getBestRegion(monitorId) {
|
||||||
const pl = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
|
const data = sparkData[monitorId];
|
||||||
pl.setAttribute('points', pts);
|
if (!data || !data.timeline.length) return { region: '__none__', latest: null };
|
||||||
pl.setAttribute('fill', 'none');
|
|
||||||
pl.setAttribute('stroke', '#60a5fa');
|
// Only consider regions present in the 3 most recent pings
|
||||||
pl.setAttribute('stroke-width', '1.5');
|
const recentRegions = new Set(data.timeline.slice(-3));
|
||||||
pl.setAttribute('stroke-linecap', 'round');
|
|
||||||
pl.setAttribute('stroke-linejoin', 'round');
|
let bestRegion = '__none__', bestAvg = Infinity;
|
||||||
svg.appendChild(pl);
|
for (const [region, vals] of Object.entries(data.regions)) {
|
||||||
const old = el.querySelector('svg');
|
if (!recentRegions.has(region) || !vals.length) continue;
|
||||||
if (old) old.replaceWith(svg); else el.appendChild(svg);
|
const recent = vals.slice(-3);
|
||||||
|
const avg = recent.reduce((a, b) => a + b, 0) / recent.length;
|
||||||
|
if (avg < bestAvg) { bestAvg = avg; bestRegion = region; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const vals = data.regions[bestRegion] || [];
|
||||||
|
const latest = vals.length ? vals[vals.length - 1] : null;
|
||||||
|
return { region: bestRegion, latest };
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSE: update cards in realtime
|
function redrawSparkline(card, monitorId) {
|
||||||
|
const { region: bestRegion } = getBestRegion(monitorId);
|
||||||
|
const vals = (sparkData[monitorId]?.regions || {})[bestRegion];
|
||||||
|
if (!vals || !vals.length) return;
|
||||||
|
const color = REGION_COLORS[bestRegion] || '#60a5fa';
|
||||||
|
const W = 120, H = 32;
|
||||||
|
const max = Math.max(...vals, 1);
|
||||||
|
const min = Math.min(...vals, 0);
|
||||||
|
const range = max - min || 1;
|
||||||
|
const step = W / Math.max(vals.length - 1, 1);
|
||||||
|
const points = vals.map((v, i) => `${i * step},${H - ((v - min) / range) * (H - 4) - 2}`).join(' ');
|
||||||
|
const sparkEl = card.querySelector('.stat-sparkline');
|
||||||
|
if (sparkEl) {
|
||||||
|
sparkEl.innerHTML = `<svg width="${W}" height="${H}" class="inline-block" data-vals="${vals.join(',')}" data-region="${bestRegion}"><polyline points="${points}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE: on each ping, update text fields and redraw sparkline in place
|
||||||
watchAccount((ping) => {
|
watchAccount((ping) => {
|
||||||
const card = document.querySelector(`[data-monitor-id="${ping.monitor_id}"]`);
|
const card = document.querySelector(`[data-monitor-id="${ping.monitor_id}"]`);
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
|
|
||||||
// Status dot
|
// Status dot
|
||||||
const dot = card.querySelector('.status-dot');
|
const dot = card.querySelector('.status-dot');
|
||||||
if (dot) dot.className = `status-dot w-2.5 h-2.5 rounded-full ${ping.up ? 'bg-green-500' : 'bg-red-500'}`;
|
if (dot) dot.className = `status-dot w-2.5 h-2.5 rounded-full shrink-0 ${ping.up ? 'dot-up' : 'dot-down'}`;
|
||||||
|
|
||||||
// Last latency + last ping time
|
// Last ping time
|
||||||
if (ping.latency_ms != null) card.querySelector('.stat-latency').textContent = ping.latency_ms + 'ms';
|
|
||||||
card.querySelector('.stat-last').innerHTML = timeAgo(ping.checked_at);
|
card.querySelector('.stat-last').innerHTML = timeAgo(ping.checked_at);
|
||||||
|
|
||||||
// Sparkline — update buffer and redraw locally
|
// Update sparkline data per region, then redraw picking best region
|
||||||
if (ping.latency_ms != null) {
|
if (ping.latency_ms != null) {
|
||||||
const buf = _sparkData[ping.monitor_id] = _sparkData[ping.monitor_id] || [];
|
const mid = ping.monitor_id;
|
||||||
buf.push(ping.latency_ms);
|
const region = ping.region || '__none__';
|
||||||
if (buf.length > 20) buf.shift();
|
if (!sparkData[mid]) sparkData[mid] = { regions: {}, timeline: [] };
|
||||||
const sparkEl = card.querySelector('.stat-sparkline');
|
if (!sparkData[mid].regions[region]) sparkData[mid].regions[region] = [];
|
||||||
if (sparkEl) drawSparkline(buf, sparkEl);
|
sparkData[mid].regions[region].push(ping.latency_ms);
|
||||||
|
sparkData[mid].timeline.push(region);
|
||||||
|
if (sparkData[mid].regions[region].length > 60) sparkData[mid].regions[region].shift();
|
||||||
|
if (sparkData[mid].timeline.length > 120) sparkData[mid].timeline.shift();
|
||||||
|
redrawSparkline(card, mid);
|
||||||
|
const { latest } = getBestRegion(mid);
|
||||||
|
if (latest != null) card.querySelector('.stat-latency').textContent = latest + 'ms';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<%~ include('./partials/head', { title: it.isNew ? 'New incident' : 'Edit incident' }) %>
|
||||||
|
<%~ include('./partials/nav', { nav: 'incidents' }) %>
|
||||||
|
|
||||||
|
<%
|
||||||
|
const i = it.incident || {};
|
||||||
|
const allMonitors = it.allMonitors || [];
|
||||||
|
const allPages = it.allPages || [];
|
||||||
|
const attachedMonitors = new Set((it.incident?.monitor_ids || []));
|
||||||
|
const attachedPages = new Set((it.incident?.status_page_ids || []));
|
||||||
|
const updates = it.incident?.updates || [];
|
||||||
|
%>
|
||||||
|
|
||||||
|
<main class="max-w-3xl mx-auto px-8 py-10 space-y-6">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<a href="/dashboard/incidents" class="text-sm text-gray-500 hover:text-gray-300 transition-colors">← Back to incidents</a>
|
||||||
|
<h1 class="text-xl font-semibold text-white mt-2"><%= it.isNew ? 'New incident' : 'Edit incident' %></h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="<%= it.isNew ? '/dashboard/incidents/new' : '/dashboard/incidents/' + i.id + '/edit' %>" class="space-y-5 card-static p-6">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Title</label>
|
||||||
|
<input name="title" type="text" required value="<%= i.title || '' %>" placeholder="API latency degraded"
|
||||||
|
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<input type="checkbox" name="pinned" value="1" <%= (i.pinned !== false) ? 'checked' : '' %> class="accent-blue-500">
|
||||||
|
Pinned (show in active incidents on status pages)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Status</label>
|
||||||
|
<select name="status" class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
||||||
|
<% ['investigating','identified','monitoring','resolved'].forEach(function(s) { %>
|
||||||
|
<option value="<%= s %>" <%= (i.status || 'investigating') === s ? 'selected' : '' %>><%= s %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Severity</label>
|
||||||
|
<select name="severity" class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
||||||
|
<% ['minor','major','critical'].forEach(function(s) { %>
|
||||||
|
<option value="<%= s %>" <%= (i.severity || 'minor') === s ? 'selected' : '' %>><%= s %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Affected monitors</label>
|
||||||
|
<% if (allMonitors.length === 0) { %>
|
||||||
|
<p class="text-xs text-gray-600">No monitors yet.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<% allMonitors.forEach(function(m) { %>
|
||||||
|
<label class="flex items-center gap-2 bg-gray-900 border border-gray-800 hover:border-gray-600 rounded-lg px-3 py-2 cursor-pointer transition-colors">
|
||||||
|
<input type="checkbox" name="monitor_ids" value="<%= m.id %>" class="accent-blue-500" <%= attachedMonitors.has(m.id) ? 'checked' : '' %>>
|
||||||
|
<span class="text-sm text-gray-300"><%= m.name %></span>
|
||||||
|
</label>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Show on status pages</label>
|
||||||
|
<% if (allPages.length === 0) { %>
|
||||||
|
<p class="text-xs text-gray-600">No status pages yet. <a href="/dashboard/pages/new" class="text-blue-400 hover:text-blue-300">Create one</a>.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<% allPages.forEach(function(p) { %>
|
||||||
|
<label class="flex items-center gap-2 bg-gray-900 border border-gray-800 hover:border-gray-600 rounded-lg px-3 py-2 cursor-pointer transition-colors">
|
||||||
|
<input type="checkbox" name="status_page_ids" value="<%= p.id %>" class="accent-blue-500" <%= attachedPages.has(p.id) ? 'checked' : '' %>>
|
||||||
|
<span class="text-sm text-gray-300"><%= p.title %></span>
|
||||||
|
</label>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (it.isNew) { %>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Initial update</label>
|
||||||
|
<textarea name="initial_update_body" rows="4" placeholder="We're investigating reports of slow API responses." required
|
||||||
|
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500"></textarea>
|
||||||
|
<p class="text-xs text-gray-600 mt-1">Markdown supported: **bold**, *italic*, `code`, [link](https://...)</p>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-primary px-6 py-2.5 text-sm"><%= it.isNew ? 'Create incident' : 'Save changes' %></button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<% if (!it.isNew && updates.length > 0) { %>
|
||||||
|
<section class="card-static p-6">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300 mb-4">Timeline</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<% updates.slice().reverse().forEach(function(u) { %>
|
||||||
|
<div class="border-l-2 border-border-subtle pl-4">
|
||||||
|
<div class="text-xs text-gray-500 mb-1"><span class="text-gray-400 font-medium"><%= u.status %></span> · <%~ it.timeAgoSSR(u.created_at) %></div>
|
||||||
|
<div class="text-sm text-gray-300 incident-body"><%~ u.body_html %></div>
|
||||||
|
</div>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (!it.isNew) { %>
|
||||||
|
<section class="card-static p-6">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300 mb-4">Post update</h2>
|
||||||
|
<form method="POST" action="/dashboard/incidents/<%= i.id %>/update" class="space-y-3">
|
||||||
|
<select name="status" class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
||||||
|
<% ['investigating','identified','monitoring','resolved'].forEach(function(s) { %>
|
||||||
|
<option value="<%= s %>" <%= (i.status || 'investigating') === s ? 'selected' : '' %>><%= s %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
<textarea name="body" rows="3" placeholder="Update text" required
|
||||||
|
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500"></textarea>
|
||||||
|
<button type="submit" class="btn-primary px-6 py-2 text-sm">Post update</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
</main>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<%~ include('./partials/head', { title: 'Incidents' }) %>
|
||||||
|
<%~ include('./partials/nav', { nav: 'incidents' }) %>
|
||||||
|
|
||||||
|
<main class="max-w-3xl mx-auto px-8 py-10 space-y-8">
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-semibold text-white">Incidents</h1>
|
||||||
|
<a href="/dashboard/incidents/new" class="btn-primary inline-flex items-center gap-2 px-4 py-2 text-sm">+ New incident</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-gray-500 leading-relaxed">
|
||||||
|
Manually-posted incidents that show up on attached status pages with a timeline of updates.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<% if (!it.incidents || it.incidents.length === 0) { %>
|
||||||
|
<section class="card-static p-6 text-sm text-gray-500">No incidents yet.</section>
|
||||||
|
<% } else { %>
|
||||||
|
<% it.incidents.forEach(function(i) {
|
||||||
|
const sevColor = i.severity === 'critical' ? 'text-red-400 border-red-900/30'
|
||||||
|
: i.severity === 'major' ? 'text-amber-400 border-amber-900/30'
|
||||||
|
: 'text-gray-400 border-border-subtle';
|
||||||
|
const statusColor = i.status === 'resolved' ? 'bg-green-900/20 text-green-400 border-green-800/30'
|
||||||
|
: 'bg-yellow-900/20 text-yellow-400 border-yellow-800/30';
|
||||||
|
%>
|
||||||
|
<section class="card-static p-6">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-200 truncate"><%= i.title %></h2>
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded-full border <%= sevColor %>"><%= i.severity %></span>
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded-full border <%= statusColor %>"><%= i.status %></span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500">Started <%~ it.timeAgoSSR(i.started_at) %><% if (i.resolved_at) { %> · Resolved <%~ it.timeAgoSSR(i.resolved_at) %><% } %></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<a href="/dashboard/incidents/<%= i.id %>" class="px-3 py-1.5 rounded-lg border border-border-subtle text-gray-400 hover:text-gray-200 text-xs transition-colors">Open</a>
|
||||||
|
<form action="/dashboard/incidents/<%= i.id %>/delete" method="POST" class="inline" onsubmit="return confirm('Delete this incident?')">
|
||||||
|
<button type="submit" class="px-3 py-1.5 rounded-lg border border-red-900/30 text-red-400 hover:bg-red-900/20 hover:border-red-800/40 text-xs transition-colors">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<% }) %>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
</main>
|
||||||
@@ -0,0 +1,730 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="dark scroll-smooth">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PingQL - Uptime monitoring that thinks like a developer</title>
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
|
<meta name="description" content="Monitor uptime with a MongoDB-style query language. Inspect JSON, HTML, headers, certs, and more.">
|
||||||
|
<link rel="stylesheet" href="/assets/tailwind.css?v=<%= it.cssHash %>">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
body { background: #0a0a0a; }
|
||||||
|
|
||||||
|
/* Grid background */
|
||||||
|
.grid-bg {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(59, 130, 246, 0.03) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(59, 130, 246, 0.03) 1px, transparent 1px);
|
||||||
|
background-size: 64px 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero glow */
|
||||||
|
.hero-glow {
|
||||||
|
background: radial-gradient(ellipse 60% 40% at 50% 0%, rgba(59, 130, 246, 0.12) 0%, transparent 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Terminal window */
|
||||||
|
.terminal {
|
||||||
|
background: #131316;
|
||||||
|
border: 1px solid #232329;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 0 80px rgba(59, 130, 246, 0.08), 0 25px 60px rgba(0,0,0,0.6);
|
||||||
|
}
|
||||||
|
.terminal-bar {
|
||||||
|
background: #191920;
|
||||||
|
border-bottom: 1px solid #232329;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.terminal-dot {
|
||||||
|
width: 12px; height: 12px; border-radius: 50%;
|
||||||
|
}
|
||||||
|
.terminal-body {
|
||||||
|
padding: 20px 24px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Syntax highlighting */
|
||||||
|
.syn-key { color: #93c5fd; }
|
||||||
|
.syn-str { color: #86efac; }
|
||||||
|
.syn-num { color: #fbbf24; }
|
||||||
|
.syn-op { color: #c084fc; }
|
||||||
|
.syn-brace { color: #6b7280; }
|
||||||
|
.syn-comment { color: #4b5563; font-style: italic; }
|
||||||
|
|
||||||
|
/* Fade-in animation */
|
||||||
|
@keyframes fade-up {
|
||||||
|
from { opacity: 0; transform: translateY(24px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.fade-up {
|
||||||
|
animation: fade-up 0.7s ease-out both;
|
||||||
|
}
|
||||||
|
.fade-up-delay-1 { animation-delay: 0.1s; }
|
||||||
|
.fade-up-delay-2 { animation-delay: 0.2s; }
|
||||||
|
.fade-up-delay-3 { animation-delay: 0.3s; }
|
||||||
|
.fade-up-delay-4 { animation-delay: 0.4s; }
|
||||||
|
|
||||||
|
/* Glow card */
|
||||||
|
.glow-card {
|
||||||
|
background: #141418;
|
||||||
|
border: 1px solid #232329;
|
||||||
|
transition: border-color 0.3s, box-shadow 0.3s, transform 0.3s;
|
||||||
|
}
|
||||||
|
.glow-card:hover {
|
||||||
|
border-color: #3b82f650;
|
||||||
|
box-shadow: 0 0 30px rgba(59, 130, 246, 0.1), 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Comparison bar */
|
||||||
|
.ping-bar {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulse animation */
|
||||||
|
@keyframes pulse-dot {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
.pulse-dot {
|
||||||
|
animation: pulse-dot 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typing cursor */
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 50% { opacity: 1; }
|
||||||
|
51%, 100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
.cursor {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 18px;
|
||||||
|
background: #3b82f6;
|
||||||
|
animation: blink 1s step-end infinite;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pricing card tiers */
|
||||||
|
.pricing-free {
|
||||||
|
background: #141418;
|
||||||
|
border: 1px solid #232329;
|
||||||
|
}
|
||||||
|
.pricing-pro {
|
||||||
|
background: #151720;
|
||||||
|
border: 1px solid #3b82f650;
|
||||||
|
box-shadow: 0 0 40px rgba(59, 130, 246, 0.1), 0 0 80px rgba(59, 130, 246, 0.05);
|
||||||
|
}
|
||||||
|
.pricing-lifetime {
|
||||||
|
background: #18170f;
|
||||||
|
border: 1px solid #eab30840;
|
||||||
|
box-shadow: 0 0 30px rgba(234, 179, 8, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status page showcase bar tooltips */
|
||||||
|
.demo-bar { position: relative; }
|
||||||
|
.demo-bar .demo-tip {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: #1a1a1f;
|
||||||
|
border: 1px solid #2a2a30;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #94a3b8;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.demo-bar:hover .demo-tip { display: block; }
|
||||||
|
.demo-tip .tip-head { color: #64748b; font-size: 10px; margin-bottom: 4px; }
|
||||||
|
.demo-tip .tip-row { display: flex; justify-content: space-between; gap: 12px; }
|
||||||
|
.demo-tip .tip-row span:last-child { color: #e2e8f0; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||||
|
.demo-tip .tip-good { color: #10b981; }
|
||||||
|
.demo-tip .tip-warn { color: #f59e0b; }
|
||||||
|
.demo-tip .tip-bad { color: #ef4444; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-[#0a0a0a] text-gray-100 font-sans antialiased grid-bg">
|
||||||
|
|
||||||
|
<!-- ─── TOP BAR (dev warning + header, flush) ─── -->
|
||||||
|
<div class="fixed top-0 left-0 right-0 z-50">
|
||||||
|
<div class="bg-yellow-500/10 backdrop-blur-md border-b border-yellow-500/20 text-center py-1.5 px-4">
|
||||||
|
<p class="text-xs text-yellow-400">PingQL is in active development. Please don't rely on the service until this notice is removed.</p>
|
||||||
|
</div>
|
||||||
|
<header class="border-b border-border-subtle bg-[#0a0a0a]/70 backdrop-blur-md">
|
||||||
|
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
|
<a href="/" class="font-mono text-lg font-bold tracking-tight group">Ping<span class="text-blue-400 transition-all group-hover:drop-shadow-[0_0_8px_rgba(59,130,246,0.4)]">QL</span></a>
|
||||||
|
<nav class="hidden md:flex items-center gap-7 text-sm text-gray-400">
|
||||||
|
<a href="/docs" class="hover:text-gray-200 transition-colors">Docs</a>
|
||||||
|
<a href="/privacy" class="hover:text-gray-200 transition-colors">Privacy</a>
|
||||||
|
<a href="#pricing" class="hover:text-gray-200 transition-colors">Pricing</a>
|
||||||
|
</nav>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<a href="/dashboard" class="text-sm text-gray-400 hover:text-gray-200 transition-colors">Sign in</a>
|
||||||
|
<a href="/dashboard" class="text-sm text-white px-4 py-2 rounded-lg font-medium transition-all" style="background:#2563eb;box-shadow:0 1px 3px rgba(0,0,0,0.3),0 0 12px rgba(59,130,246,0.15)">Get started</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── HERO ─── -->
|
||||||
|
<section class="relative min-h-screen flex items-center hero-glow pt-16">
|
||||||
|
<div class="max-w-6xl mx-auto px-6 py-12 w-full">
|
||||||
|
<div class="grid lg:grid-cols-2 gap-16 items-center">
|
||||||
|
<!-- Left -->
|
||||||
|
<div class="fade-up">
|
||||||
|
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full border border-border-subtle text-xs text-gray-400 mb-6 font-mono">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-green-500 pulse-dot" style="box-shadow:0 0 8px rgba(34,197,94,0.4)"></span>
|
||||||
|
All systems operational
|
||||||
|
</div>
|
||||||
|
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight leading-[1.1] mb-6">
|
||||||
|
Uptime monitoring<br>that <span class="text-brand">thinks like<br>a developer</span>
|
||||||
|
</h1>
|
||||||
|
<p class="text-lg text-gray-400 max-w-lg mb-8 leading-relaxed">
|
||||||
|
Go beyond simple pings. Write queries against status codes, JSON bodies, HTML selectors, headers, and cert expiry.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<a href="/dashboard" class="inline-flex items-center gap-2 px-6 py-3 text-white font-medium rounded-lg transition-all text-sm" style="background:#2563eb;box-shadow:0 1px 3px rgba(0,0,0,0.3),0 0 12px rgba(59,130,246,0.15)">
|
||||||
|
Get Started Free
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6"/></svg>
|
||||||
|
</a>
|
||||||
|
<a href="/docs" class="inline-flex items-center gap-2 px-6 py-3 border border-border-subtle hover:border-border-strong text-gray-300 font-medium rounded-lg transition-colors text-sm">
|
||||||
|
Read the Docs
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Right: terminal -->
|
||||||
|
<div class="fade-up fade-up-delay-2">
|
||||||
|
<div class="terminal">
|
||||||
|
<div class="terminal-bar">
|
||||||
|
<div class="terminal-dot bg-[#ff5f57]"></div>
|
||||||
|
<div class="terminal-dot bg-[#ffbd2e]"></div>
|
||||||
|
<div class="terminal-dot bg-[#28c840]"></div>
|
||||||
|
<span class="ml-3 text-xs text-gray-500 font-mono">query.json</span>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-body">
|
||||||
|
<pre><span class="syn-brace">{</span>
|
||||||
|
<span class="syn-key">"$and"</span><span class="syn-brace">:</span> <span class="syn-brace">[</span>
|
||||||
|
<span class="syn-brace">{</span> <span class="syn-key">"status"</span><span class="syn-brace">:</span> <span class="syn-brace">{</span> <span class="syn-op">"$lt"</span><span class="syn-brace">:</span> <span class="syn-num">400</span> <span class="syn-brace">}</span> <span class="syn-brace">}</span>,
|
||||||
|
<span class="syn-brace">{</span> <span class="syn-op">"$json"</span><span class="syn-brace">:</span> <span class="syn-brace">{</span> <span class="syn-str">"$.db.status"</span><span class="syn-brace">:</span> <span class="syn-brace">{</span> <span class="syn-op">"$eq"</span><span class="syn-brace">:</span> <span class="syn-str">"ok"</span> <span class="syn-brace">}</span> <span class="syn-brace">}</span> <span class="syn-brace">}</span>,
|
||||||
|
<span class="syn-brace">{</span> <span class="syn-op">"$certExpiry"</span><span class="syn-brace">:</span> <span class="syn-brace">{</span> <span class="syn-op">"$gt"</span><span class="syn-brace">:</span> <span class="syn-num">14</span> <span class="syn-brace">}</span> <span class="syn-brace">}</span>
|
||||||
|
<span class="syn-brace">]</span>
|
||||||
|
<span class="syn-brace">}</span></pre>
|
||||||
|
<div class="mt-4 pt-4 border-t border-border-subtle text-xs text-gray-500">
|
||||||
|
<span class="syn-comment">// status < 400 AND $.db.status = "ok" AND $certExpiry > 14 days</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── NOT JUST PING ─── -->
|
||||||
|
<section class="py-24 px-6">
|
||||||
|
<div class="max-w-5xl mx-auto">
|
||||||
|
<div class="text-center mb-16">
|
||||||
|
<h2 class="text-3xl sm:text-4xl font-bold tracking-tight mb-4">Not just ping</h2>
|
||||||
|
<p class="text-gray-400 max-w-2xl mx-auto text-lg">
|
||||||
|
Most monitoring tools ping a URL and check if it returns 200. That tells you almost nothing.
|
||||||
|
PingQL lets you query the <em>entire</em> response.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comparison -->
|
||||||
|
<div class="grid md:grid-cols-2 gap-8 max-w-3xl mx-auto">
|
||||||
|
<!-- Others -->
|
||||||
|
<div class="rounded-xl border border-border-subtle bg-surface-solid p-6">
|
||||||
|
<div class="text-sm text-gray-500 uppercase tracking-wider mb-4 font-mono">Others</div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="w-3 h-3 rounded-full bg-green-500 shrink-0"></span>
|
||||||
|
<span class="text-gray-400 text-sm">GET /api/health → 200</span>
|
||||||
|
<span class="ml-auto text-green-500 text-xs font-mono">UP</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="w-3 h-3 rounded-full bg-red-500 shrink-0"></span>
|
||||||
|
<span class="text-gray-400 text-sm">GET /api/health → 500</span>
|
||||||
|
<span class="ml-auto text-red-500 text-xs font-mono">DOWN</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 pt-4 border-t border-border-subtle text-xs text-gray-500">
|
||||||
|
That's it. That's all you get.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PingQL -->
|
||||||
|
<div class="rounded-xl border border-blue-500/30 bg-surface-solid p-6 shadow-glow-sm">
|
||||||
|
<div class="text-sm text-brand uppercase tracking-wider mb-4 font-mono">PingQL</div>
|
||||||
|
<div class="space-y-3 text-sm font-mono">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-green-400">✓</span>
|
||||||
|
<span class="text-gray-300">Status code assertions</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-green-400">✓</span>
|
||||||
|
<span class="text-gray-300">JSON body inspection</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-green-400">✓</span>
|
||||||
|
<span class="text-gray-300">HTML / CSS selector matching</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-green-400">✓</span>
|
||||||
|
<span class="text-gray-300">Response time thresholds</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-green-400">✓</span>
|
||||||
|
<span class="text-gray-300">SSL cert expiry checks</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-green-400">✓</span>
|
||||||
|
<span class="text-gray-300">Header & regex matching</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── FEATURES ─── -->
|
||||||
|
<section class="py-24 px-6">
|
||||||
|
<div class="max-w-6xl mx-auto">
|
||||||
|
<div class="text-center mb-16">
|
||||||
|
<h2 class="text-3xl sm:text-4xl font-bold tracking-tight mb-4">Everything you need</h2>
|
||||||
|
<p class="text-gray-400 text-lg">Powerful primitives, zero bloat.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
|
|
||||||
|
<div class="glow-card rounded-xl p-6">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
||||||
|
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold text-white mb-2">Query language</h3>
|
||||||
|
<p class="text-sm text-gray-400 leading-relaxed">Set your own definition of "up". Query status codes, JSON fields, headers, HTML, and latency with MongoDB-like operators.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glow-card rounded-xl p-6">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
||||||
|
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold text-white mb-2">Public status pages</h3>
|
||||||
|
<p class="text-sm text-gray-400 leading-relaxed">Live uptime, heartbeat history, incidents, and RSS. Set one up in a minute and give your users somewhere to check.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glow-card rounded-xl p-6">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
||||||
|
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold text-white mb-2">Webhook notifications</h3>
|
||||||
|
<p class="text-sm text-gray-400 leading-relaxed">Fires on down, recovery, and cert expiry. HMAC-signed payloads for secure integrations. More providers coming soon.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glow-card rounded-xl p-6">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
||||||
|
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285zM12 15.75h.007v.008H12v-.008z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold text-white mb-2">Incident management</h3>
|
||||||
|
<p class="text-sm text-gray-400 leading-relaxed">Declare incidents, post updates, track resolution. Visitors see the full timeline on your status page automatically.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glow-card rounded-xl p-6">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
||||||
|
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold text-white mb-2">Retries & smart alerting</h3>
|
||||||
|
<p class="text-sm text-gray-400 leading-relaxed">Retry before declaring down. Resend alerts for persistent outages. No more false alarms from a single failed request.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glow-card rounded-xl p-6">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
||||||
|
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold text-white mb-2">SSL cert monitoring</h3>
|
||||||
|
<p class="text-sm text-gray-400 leading-relaxed">Get alerted days before your cert expires, not after. Per-region checks so regional resolvers are covered too.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glow-card rounded-xl p-6">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
||||||
|
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold text-white mb-2">Multi-region monitoring</h3>
|
||||||
|
<p class="text-sm text-gray-400 leading-relaxed">Independent health per region. US West going down doesn't mask EU Central being up. Each region tracks state separately.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glow-card rounded-xl p-6">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
||||||
|
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold text-white mb-2">JSONPath & CSS selectors</h3>
|
||||||
|
<p class="text-sm text-gray-400 leading-relaxed">Drill into JSON responses with <code class="text-brand font-mono text-xs">$json</code> or scrape any HTML page with <code class="text-brand font-mono text-xs">$html</code>. No API required.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glow-card rounded-xl p-6">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
||||||
|
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold text-white mb-2">Custom HTTP requests</h3>
|
||||||
|
<p class="text-sm text-gray-400 leading-relaxed">Send GET, POST, PUT, PATCH or HEAD with custom headers and request bodies. Monitor authenticated APIs end-to-end.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glow-card rounded-xl p-6">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
||||||
|
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold text-white mb-2">Sub-keys & API access</h3>
|
||||||
|
<p class="text-sm text-gray-400 leading-relaxed">Create scoped sub-keys for CI pipelines, scripts, or teammates. Full REST API, no dashboard required.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glow-card rounded-xl p-6">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
||||||
|
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold text-white mb-2">Privacy-first accounts</h3>
|
||||||
|
<p class="text-sm text-gray-400 leading-relaxed">No email required. No passwords. Just an account key. Emails are hashed if provided, so we literally can't read them.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glow-card rounded-xl p-6">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
||||||
|
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold text-white mb-2">Pay with crypto</h3>
|
||||||
|
<p class="text-sm text-gray-400 leading-relaxed">We accept <span class="text-gray-300">Bitcoin, Litecoin, Dogecoin, Bitcoin Cash, Dash,</span> and <span class="text-gray-300">eCash</span>. No card, no name, no billing address.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── STATUS PAGE SHOWCASE ─── -->
|
||||||
|
<section class="py-24 px-6">
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
<h2 class="text-3xl sm:text-4xl font-bold tracking-tight mb-4">Beautiful status pages, out of the box</h2>
|
||||||
|
<p class="text-gray-400 text-lg max-w-2xl mx-auto">Give your users a branded status page with live uptime, heartbeat history, and incident timelines. Password protection, custom CSS, RSS feeds, and embeddable badges included.</p>
|
||||||
|
<p class="mt-4"><a href="https://status.pingql.com" target="_blank" rel="noopener" class="text-blue-400 hover:text-blue-300 text-sm font-medium">See a live demo →</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-border-subtle overflow-hidden" style="background:#111114; box-shadow:0 0 60px rgba(59,130,246,0.06), 0 20px 50px rgba(0,0,0,0.5)">
|
||||||
|
<div style="background:#191920; border-bottom:1px solid #232329; padding:12px 16px; display:flex; align-items:center; gap:8px;">
|
||||||
|
<div style="width:12px;height:12px;border-radius:50%;background:#ff5f57"></div>
|
||||||
|
<div style="width:12px;height:12px;border-radius:50%;background:#ffbd2e"></div>
|
||||||
|
<div style="width:12px;height:12px;border-radius:50%;background:#28c840"></div>
|
||||||
|
<span class="ml-3 text-xs text-gray-500 font-mono">pages.pingql.com/your-app</span>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 sm:p-8">
|
||||||
|
<div class="text-lg font-semibold text-white mb-4">Acme Corp Status</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg px-4 py-3 flex items-center gap-3 mb-6" style="background:rgba(16,185,129,0.1); border:1px solid rgba(16,185,129,0.2)">
|
||||||
|
<span class="w-3 h-3 rounded-full bg-green-500 pulse-dot" style="box-shadow:0 0 6px rgba(34,197,94,0.4)"></span>
|
||||||
|
<span class="text-sm font-medium text-green-400">All systems operational</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="rounded-lg border border-border-subtle p-4" style="background:#141418">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-2.5 h-2.5 rounded-full bg-green-500"></span>
|
||||||
|
<span class="text-sm font-medium text-gray-200">Production API</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 text-xs text-gray-500">
|
||||||
|
<span>47ms</span>
|
||||||
|
<span class="font-semibold text-green-400">99.99%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-[0.1rem] h-5">
|
||||||
|
<div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-yellow-500/70 demo-bar"><div class="demo-tip"><div class="tip-head">Mar 28, 14:00 - 15:00</div><div class="tip-row"><span>Checks</span><span>120</span></div><div class="tip-row"><span>Successful</span><span>118</span></div><div class="tip-row"><span>Uptime</span><span class="tip-warn">98.33%</span></div><div class="tip-row"><span>Avg ping</span><span>52ms</span></div></div></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-border-subtle p-4" style="background:#141418">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-2.5 h-2.5 rounded-full bg-green-500"></span>
|
||||||
|
<span class="text-sm font-medium text-gray-200">Dashboard</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 text-xs text-gray-500">
|
||||||
|
<span>123ms</span>
|
||||||
|
<span class="font-semibold text-green-400">100%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-[0.1rem] h-5">
|
||||||
|
<div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-border-subtle p-4" style="background:#141418">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-2.5 h-2.5 rounded-full bg-green-500"></span>
|
||||||
|
<span class="text-sm font-medium text-gray-200">Payment Gateway</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 text-xs text-gray-500">
|
||||||
|
<span>89ms</span>
|
||||||
|
<span class="font-semibold text-green-400">99.97%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-[0.1rem] h-5">
|
||||||
|
<div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-red-500/70 demo-bar"><div class="demo-tip"><div class="tip-head">Mar 22, 03:00 - 04:00</div><div class="tip-row"><span>Checks</span><span>120</span></div><div class="tip-row"><span>Successful</span><span>0</span></div><div class="tip-row"><span>Uptime</span><span class="tip-bad">0.00%</span></div><div class="tip-row"><span>Avg ping</span><span>-</span></div></div></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 text-center text-xs text-gray-600">Powered by PingQL</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── PRIVACY ─── -->
|
||||||
|
<section id="privacy" class="py-24 px-6">
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<div class="rounded-2xl border border-border-subtle bg-gradient-to-b from-surface-solid to-[#0a0a0a] p-8 sm:p-12" style="box-shadow:0 1px 3px rgba(0,0,0,0.3)">
|
||||||
|
<div class="flex items-start gap-4 mb-8">
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-green-500/10 border border-green-500/20 flex items-center justify-center shrink-0">
|
||||||
|
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight mb-2">Privacy by design</h2>
|
||||||
|
<p class="text-gray-400">We built PingQL for developers who care about their data.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid sm:grid-cols-2 gap-6">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="text-green-400 mt-0.5 shrink-0 font-mono text-sm">$</span>
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-medium text-sm">No tracking, no analytics, no ads</p>
|
||||||
|
<p class="text-gray-500 text-xs mt-1">Zero third-party scripts. Zero cookies.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="text-green-400 mt-0.5 shrink-0 font-mono text-sm">$</span>
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-medium text-sm">Account keys, not passwords</p>
|
||||||
|
<p class="text-gray-500 text-xs mt-1">Be completely anonymous. No email required.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="text-green-400 mt-0.5 shrink-0 font-mono text-sm">$</span>
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-medium text-sm">Emails hashed if provided</p>
|
||||||
|
<p class="text-gray-500 text-xs mt-1">Optional email for recovery only. We hash it.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="text-green-400 mt-0.5 shrink-0 font-mono text-sm">$</span>
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-medium text-sm">We never sell data</p>
|
||||||
|
<p class="text-gray-500 text-xs mt-1">Your monitors, your data. Period.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── API EXAMPLE ─── -->
|
||||||
|
<section class="py-24 px-6">
|
||||||
|
<div class="max-w-5xl mx-auto">
|
||||||
|
<div class="text-center mb-16">
|
||||||
|
<h2 class="text-3xl sm:text-4xl font-bold tracking-tight mb-4">API-first, always</h2>
|
||||||
|
<p class="text-gray-400 text-lg max-w-2xl mx-auto">Create monitors, query results, and manage everything from scripts, your terminal, or anywhere with HTTP.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid lg:grid-cols-2 gap-6">
|
||||||
|
<!-- Create status page -->
|
||||||
|
<div class="terminal">
|
||||||
|
<div class="terminal-bar">
|
||||||
|
<div class="terminal-dot bg-[#ff5f57]"></div>
|
||||||
|
<div class="terminal-dot bg-[#ffbd2e]"></div>
|
||||||
|
<div class="terminal-dot bg-[#28c840]"></div>
|
||||||
|
<span class="ml-3 text-xs text-gray-500 font-mono">Create a status page</span>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-body text-xs sm:text-[13px]">
|
||||||
|
<pre><span class="text-gray-500">$</span> <span class="text-white">curl</span> -X POST https://pingql.com/api/pages \
|
||||||
|
-H <span class="syn-str">"X-Key: abcd-1234-efgh-5678"</span> \
|
||||||
|
-d <span class="syn-str">'{
|
||||||
|
"slug": "my-app",
|
||||||
|
"title": "My App Status",
|
||||||
|
"monitors": [
|
||||||
|
{ "monitor_id": "a1b2c3d4e5f67890" }
|
||||||
|
]
|
||||||
|
}'</span>
|
||||||
|
|
||||||
|
<span class="syn-brace">{</span>
|
||||||
|
<span class="syn-key">"id"</span>: <span class="syn-str">"f8c1a2b3-..."</span>,
|
||||||
|
<span class="syn-key">"slug"</span>: <span class="syn-str">"my-app"</span>,
|
||||||
|
<span class="syn-key">"title"</span>: <span class="syn-str">"My App Status"</span>,
|
||||||
|
<span class="syn-key">"theme"</span>: <span class="syn-str">"auto"</span>
|
||||||
|
<span class="syn-brace">}</span></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create monitor -->
|
||||||
|
<div class="terminal">
|
||||||
|
<div class="terminal-bar">
|
||||||
|
<div class="terminal-dot bg-[#ff5f57]"></div>
|
||||||
|
<div class="terminal-dot bg-[#ffbd2e]"></div>
|
||||||
|
<div class="terminal-dot bg-[#28c840]"></div>
|
||||||
|
<span class="ml-3 text-xs text-gray-500 font-mono">Create a monitor</span>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-body text-xs sm:text-[13px]">
|
||||||
|
<pre><span class="text-gray-500">$</span> <span class="text-white">curl</span> -X POST https://pingql.com/api/monitors \
|
||||||
|
-H <span class="syn-str">"X-Key: abcd-1234-efgh-5678"</span> \
|
||||||
|
-d <span class="syn-str">'{
|
||||||
|
"name": "Production API",
|
||||||
|
"url": "https://api.example.com/health",
|
||||||
|
"interval_s": 60,
|
||||||
|
"query": {
|
||||||
|
"status": { "$lt": 400 },
|
||||||
|
"$json": { "$.ok": { "$eq": true } }
|
||||||
|
}
|
||||||
|
}'</span>
|
||||||
|
|
||||||
|
<span class="syn-brace">{</span>
|
||||||
|
<span class="syn-key">"id"</span>: <span class="syn-str">"a1b2c3d4e5f67890"</span>,
|
||||||
|
<span class="syn-key">"name"</span>: <span class="syn-str">"Production API"</span>,
|
||||||
|
<span class="syn-key">"url"</span>: <span class="syn-str">"https://api.example.com/health"</span>,
|
||||||
|
<span class="syn-key">"enabled"</span>: <span class="syn-num">true</span>
|
||||||
|
<span class="syn-brace">}</span></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── PRICING ─── -->
|
||||||
|
<section id="pricing" class="py-24 px-6">
|
||||||
|
<div class="max-w-4xl mx-auto text-center">
|
||||||
|
<h2 class="text-3xl sm:text-4xl font-bold tracking-tight mb-4">Simple pricing</h2>
|
||||||
|
<p class="text-gray-400 text-lg mb-12">Start for free. No credit card required.</p>
|
||||||
|
|
||||||
|
<div class="grid sm:grid-cols-3 gap-6">
|
||||||
|
<!-- Free -->
|
||||||
|
<div class="pricing-free rounded-xl p-8 text-left">
|
||||||
|
<div class="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">Free</div>
|
||||||
|
<div class="text-4xl font-bold mb-1">$0</div>
|
||||||
|
<div class="text-sm text-gray-500 mb-6">forever</div>
|
||||||
|
<ul class="space-y-3 text-sm text-gray-400">
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-green-400 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
10 monitors
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-green-400 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
30s check interval
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-green-400 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
Single region per monitor
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-green-400 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
Status pages & incidents
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-green-400 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
Webhook & Email alerts
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pro -->
|
||||||
|
<div class="pricing-pro rounded-xl p-8 text-left">
|
||||||
|
<div class="text-xs text-blue-400 uppercase tracking-wider font-mono mb-2">Pro</div>
|
||||||
|
<div class="text-4xl font-bold mb-1">$12<span class="text-lg font-normal text-gray-500">-$48</span></div>
|
||||||
|
<div class="text-sm text-gray-500 mb-6">per month</div>
|
||||||
|
<ul class="space-y-3 text-sm text-gray-400">
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-blue-400 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
200–800 monitors
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-blue-400 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
5s check interval
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-blue-400 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
Multi-region monitoring
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-blue-400 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
Signal & Telegram alerts
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lifetime -->
|
||||||
|
<div class="pricing-lifetime rounded-xl p-8 text-left relative">
|
||||||
|
<span class="absolute top-3 right-3 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-yellow-500/15 text-yellow-500 border border-yellow-500/20">Launch Deal</span>
|
||||||
|
<div class="text-xs text-yellow-500 uppercase tracking-wider font-mono mb-2">Lifetime</div>
|
||||||
|
<div class="text-4xl font-bold mb-1">$140</div>
|
||||||
|
<div class="text-sm text-gray-500 mb-6">one-time</div>
|
||||||
|
<ul class="space-y-3 text-sm text-gray-400">
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-yellow-500 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
Everything in Pro
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-yellow-500 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
Pay once, use forever
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-yellow-500 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
All future updates
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-gray-500 mt-8 max-w-lg mx-auto">Start with Pro and upgrade to Lifetime whenever you're ready. What you've spent on Pro gets credited toward the lifetime price, up to 75% off.</p>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<a href="/dashboard" class="inline-flex items-center gap-2 px-8 py-3.5 text-white font-medium rounded-lg transition-all text-sm" style="background:#2563eb;box-shadow:0 1px 3px rgba(0,0,0,0.3),0 0 12px rgba(59,130,246,0.15)">
|
||||||
|
Get Started
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6"/></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── FOOTER ─── -->
|
||||||
|
<footer class="border-t border-border-subtle py-16 px-6">
|
||||||
|
<div class="max-w-5xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-6">
|
||||||
|
<div class="flex items-center gap-8">
|
||||||
|
<a href="/" class="text-lg font-bold tracking-tight font-mono">Ping<span class="text-brand">QL</span></a>
|
||||||
|
<nav class="flex items-center gap-6 text-sm text-gray-500">
|
||||||
|
<a href="/docs" class="hover:text-gray-300 transition-colors">Docs</a>
|
||||||
|
<a href="/privacy" class="hover:text-gray-300 transition-colors">Privacy</a>
|
||||||
|
<a href="/terms" class="hover:text-gray-300 transition-colors">Terms</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600">
|
||||||
|
© <%= new Date().getFullYear() %> PingQL
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -3,40 +3,47 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>PingQL — Sign In</title>
|
<title>PingQL - Sign In</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="/dashboard/app.css">
|
<link rel="stylesheet" href="/assets/tailwind.css?v=<%= it.cssHash %>">
|
||||||
|
<link rel="stylesheet" href="/assets/app.css?v=<%= it.cssHash %>">
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-[#0a0a0a] text-gray-100 min-h-screen flex items-center justify-center p-4">
|
<body class="bg-[#0a0a0a] text-gray-100 min-h-screen flex items-center justify-center p-4">
|
||||||
|
<div class="fixed top-0 left-0 right-0 z-50 bg-yellow-500/10 border-b border-yellow-500/20 text-center py-1.5 px-4">
|
||||||
|
<p class="text-xs text-yellow-400">PingQL is in active development. Please don't rely on the service until this notice is removed.</p>
|
||||||
|
</div>
|
||||||
<div class="w-full max-w-md">
|
<div class="w-full max-w-md">
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<h1 class="text-3xl font-bold tracking-tight">Ping<span class="text-blue-400">QL</span></h1>
|
<h1 class="text-3xl font-bold tracking-tight">Ping<span class="text-blue-400">QL</span></h1>
|
||||||
<p class="text-gray-500 text-sm mt-2">Uptime monitoring for developers</p>
|
<p class="text-gray-500 text-sm mt-2">Uptime monitoring for developers</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-gray-900 rounded-xl p-6 border border-gray-800" style="box-shadow:0 0 40px rgba(59,130,246,0.08)">
|
<div class="card-static p-6" style="box-shadow:0 0 60px rgba(59,130,246,0.08)">
|
||||||
|
|
||||||
<!-- Sign in form — works with or without JS -->
|
<!-- Sign in form - works with or without JS -->
|
||||||
<div id="screen-login">
|
<div id="screen-login">
|
||||||
<form id="login-form" action="/account/login" method="POST">
|
<form id="login-form" action="/account/login" method="POST">
|
||||||
<input type="hidden" name="_form" value="1">
|
<input type="hidden" name="_form" value="1">
|
||||||
<label class="block text-xs text-gray-500 uppercase tracking-wider mb-2">Account Key</label>
|
<label class="block text-xs text-gray-500 uppercase tracking-wider mb-2">Account Key</label>
|
||||||
<input id="key-input" name="key" type="text" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" autocomplete="off" spellcheck="false"
|
<input id="key-input" name="key" type="text" placeholder="Your account key" autocomplete="off" spellcheck="false"
|
||||||
class="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm font-mono tracking-wider"
|
class="w-full input-base px-4 py-3 text-gray-100 placeholder-gray-600 text-sm font-mono tracking-wider"
|
||||||
maxlength="36">
|
maxlength="64">
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="w-full mt-3 bg-blue-600 hover:bg-blue-500 text-white font-medium py-3 rounded-lg transition-colors">
|
class="w-full mt-3 btn-primary py-3">
|
||||||
Sign In
|
Sign In
|
||||||
</button>
|
</button>
|
||||||
<div id="login-error" class="text-red-400 text-sm mt-3 text-center hidden"></div>
|
<div id="login-error" class="text-red-400 text-sm mt-3 text-center hidden"></div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="mt-6 pt-5 border-t border-gray-800 text-center">
|
<div class="mt-6 pt-5 border-t divider text-center">
|
||||||
<p class="text-gray-500 text-sm mb-3">No account?</p>
|
<p class="text-gray-500 text-sm mb-3">No account?</p>
|
||||||
<button id="register-btn"
|
<form id="register-form" action="/account/register" method="POST">
|
||||||
class="w-full bg-gray-800 hover:bg-gray-700 border border-gray-700 text-gray-300 font-medium py-3 rounded-lg transition-colors">
|
<input type="hidden" name="_form" value="1">
|
||||||
Create Account
|
<button type="submit" id="register-btn"
|
||||||
</button>
|
class="w-full btn-secondary py-3">
|
||||||
|
Create Account
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -46,31 +53,31 @@
|
|||||||
<div class="w-8 h-8 rounded-full bg-green-500/20 flex items-center justify-center text-green-400 text-lg">✓</div>
|
<div class="w-8 h-8 rounded-full bg-green-500/20 flex items-center justify-center text-green-400 text-lg">✓</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-semibold text-white">Account created</p>
|
<p class="font-semibold text-white">Account created</p>
|
||||||
<p class="text-xs text-gray-500">Save your key — it's how you access your account</p>
|
<p class="text-xs text-gray-500">Save your key - it's how you access your account</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="block text-xs text-gray-500 uppercase tracking-wider mb-2">Your Account Key</label>
|
<label class="block text-xs text-gray-500 uppercase tracking-wider mb-2">Your Account Key</label>
|
||||||
<div class="flex gap-2 mb-5">
|
<div class="flex gap-2 mb-5">
|
||||||
<div id="new-key-display"
|
<div id="new-key-display"
|
||||||
class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-blue-400 text-sm font-mono select-all break-all"></div>
|
class="flex-1 bg-surface-solid border border-border-subtle rounded-lg px-4 py-3 text-blue-400 text-sm font-mono select-all break-all"></div>
|
||||||
<button id="copy-key-btn"
|
<button id="copy-key-btn"
|
||||||
class="px-4 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-400 hover:text-white transition-colors text-sm">
|
class="btn-secondary px-4 text-sm">
|
||||||
Copy
|
Copy
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pt-5 border-t border-gray-800">
|
<div class="pt-5 border-t divider">
|
||||||
<label class="block text-xs text-gray-500 uppercase tracking-wider mb-1">Email <span class="text-gray-600 normal-case">(optional — recovery only)</span></label>
|
<label class="block text-xs text-gray-500 uppercase tracking-wider mb-1">Email <span class="text-gray-600 normal-case">(optional, recovery only)</span></label>
|
||||||
<input id="email-input" type="email" placeholder="you@example.com"
|
<input id="email-input" type="email" placeholder="you@example.com"
|
||||||
class="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm mb-3">
|
class="w-full input-base px-4 py-3 text-gray-100 placeholder-gray-600 text-sm mb-3">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button id="save-email-btn"
|
<button id="save-email-btn"
|
||||||
class="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-medium py-2.5 rounded-lg transition-colors text-sm">
|
class="flex-1 btn-primary py-2.5 text-sm">
|
||||||
Save & Continue
|
Save & Continue
|
||||||
</button>
|
</button>
|
||||||
<button id="skip-email-btn"
|
<button id="skip-email-btn"
|
||||||
class="px-4 bg-gray-800 hover:bg-gray-700 border border-gray-700 text-gray-400 rounded-lg transition-colors text-sm">
|
class="btn-secondary px-4 text-sm">
|
||||||
Skip
|
Skip
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,8 +109,9 @@
|
|||||||
} catch { showError('Connection error'); }
|
} catch { showError('Connection error'); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register
|
// Register - JS-enhanced (overrides form POST for inline key display)
|
||||||
document.getElementById('register-btn').addEventListener('click', async () => {
|
document.getElementById('register-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/account/register', {
|
const res = await fetch('/account/register', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}),
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}),
|
||||||
+8
-128
@@ -1,4 +1,4 @@
|
|||||||
<%~ include('./partials/head', { title: 'New Monitor', scripts: ['/dashboard/query-builder.js'] }) %>
|
<%~ include('./partials/head', { title: 'New Monitor', scripts: ['/dashboard/query-builder.js?v=' + it.h.qb] }) %>
|
||||||
<%~ include('./partials/nav', { nav: 'monitors' }) %>
|
<%~ include('./partials/nav', { nav: 'monitors' }) %>
|
||||||
|
|
||||||
<main class="max-w-2xl mx-auto px-6 py-8">
|
<main class="max-w-2xl mx-auto px-6 py-8">
|
||||||
@@ -7,144 +7,24 @@
|
|||||||
<h2 class="text-lg font-semibold text-gray-200 mt-2">Create Monitor</h2>
|
<h2 class="text-lg font-semibold text-gray-200 mt-2">Create Monitor</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="create-form" class="space-y-6">
|
<%~ include('./partials/monitor-form', { _form: { monitor: {}, isEdit: false, prefix: '', bg: 'bg-surface-solid', border: 'border-border-subtle' }, plan: it.plan, regions: it.regions }) %>
|
||||||
<div>
|
|
||||||
<label class="block text-sm text-gray-400 mb-1.5">Name</label>
|
|
||||||
<input id="name" type="text" required placeholder="Production API"
|
|
||||||
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm text-gray-400 mb-1.5">URL</label>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<select id="method"
|
|
||||||
class="bg-gray-900 border border-gray-800 rounded-lg px-3 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500 font-mono text-sm">
|
|
||||||
<option>GET</option>
|
|
||||||
<option>POST</option>
|
|
||||||
<option>PUT</option>
|
|
||||||
<option>PATCH</option>
|
|
||||||
<option>DELETE</option>
|
|
||||||
<option>HEAD</option>
|
|
||||||
<option>OPTIONS</option>
|
|
||||||
</select>
|
|
||||||
<input id="url" type="url" required placeholder="https://api.example.com/health"
|
|
||||||
class="flex-1 bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Request Headers -->
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between mb-1.5">
|
|
||||||
<label class="text-sm text-gray-400">Headers <span class="text-gray-600">(optional)</span></label>
|
|
||||||
<button type="button" id="add-header" class="text-xs text-blue-400 hover:text-blue-300 transition-colors">+ Add header</button>
|
|
||||||
</div>
|
|
||||||
<div id="headers-list" class="space-y-2"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Request Body -->
|
|
||||||
<div id="body-section" class="hidden">
|
|
||||||
<label class="block text-sm text-gray-400 mb-1.5">Request Body <span class="text-gray-600">(optional)</span></label>
|
|
||||||
<textarea id="request-body" rows="4" placeholder='{"key": "value"}'
|
|
||||||
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 font-mono text-sm resize-y"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<div class="flex-1">
|
|
||||||
<label class="block text-sm text-gray-400 mb-1.5">Ping Interval</label>
|
|
||||||
<select id="interval"
|
|
||||||
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
|
||||||
<option value="10">10 seconds</option>
|
|
||||||
<option value="30">30 seconds</option>
|
|
||||||
<option value="60" selected>1 minute</option>
|
|
||||||
<option value="300">5 minutes</option>
|
|
||||||
<option value="600">10 minutes</option>
|
|
||||||
<option value="1800">30 minutes</option>
|
|
||||||
<option value="3600">1 hour</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<label class="block text-sm text-gray-400 mb-1.5">Timeout</label>
|
|
||||||
<select id="timeout"
|
|
||||||
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
|
||||||
<option value="5000">5 seconds</option>
|
|
||||||
<option value="10000">10 seconds</option>
|
|
||||||
<option value="30000" selected>30 seconds</option>
|
|
||||||
<option value="60000">60 seconds</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm text-gray-400 mb-1.5">Query Conditions <span class="text-gray-600">(optional)</span></label>
|
|
||||||
<p class="text-xs text-gray-600 mb-3">Define when this monitor should be considered "up". Defaults to status < 400.</p>
|
|
||||||
<div id="query-builder"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="form-error" class="text-red-400 text-sm hidden"></div>
|
|
||||||
|
|
||||||
<button type="submit" id="submit-btn"
|
|
||||||
class="w-full bg-blue-600 hover:bg-blue-500 text-white font-medium py-3 rounded-lg transition-colors">
|
|
||||||
Create Monitor
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const _prefix = '';
|
||||||
|
const _initialQuery = null;
|
||||||
|
<%~ include('./partials/monitor-form-js') %>
|
||||||
|
|
||||||
let currentQuery = null;
|
document.getElementById('form').addEventListener('submit', async (e) => {
|
||||||
// Show body section for non-GET methods
|
|
||||||
const methodSel = document.getElementById('method');
|
|
||||||
const bodySection = document.getElementById('body-section');
|
|
||||||
function updateBodyVisibility() {
|
|
||||||
const m = methodSel.value;
|
|
||||||
bodySection.classList.toggle('hidden', ['GET','HEAD','OPTIONS'].includes(m));
|
|
||||||
}
|
|
||||||
methodSel.addEventListener('change', updateBodyVisibility);
|
|
||||||
|
|
||||||
// Dynamic headers
|
|
||||||
document.getElementById('add-header').addEventListener('click', () => {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'header-row flex gap-2';
|
|
||||||
row.innerHTML = `
|
|
||||||
<input type="text" placeholder="Header name" class="hk flex-1 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm">
|
|
||||||
<input type="text" placeholder="Value" class="hv flex-1 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm">
|
|
||||||
<button type="button" onclick="this.parentElement.remove()" class="px-2 text-gray-600 hover:text-red-400 transition-colors text-sm">✕</button>
|
|
||||||
`;
|
|
||||||
document.getElementById('headers-list').appendChild(row);
|
|
||||||
});
|
|
||||||
|
|
||||||
const qb = new QueryBuilder(document.getElementById('query-builder'), (q) => {
|
|
||||||
currentQuery = q;
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('create-form').addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const btn = document.getElementById('submit-btn');
|
const btn = document.getElementById('submit-btn');
|
||||||
const errEl = document.getElementById('form-error');
|
const errEl = document.getElementById('error');
|
||||||
errEl.classList.add('hidden');
|
errEl.classList.add('hidden');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Creating...';
|
btn.textContent = 'Creating...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headers = {};
|
const body = collectFormData(_prefix);
|
||||||
document.querySelectorAll('.header-row').forEach(row => {
|
|
||||||
const k = row.querySelector('.hk').value.trim();
|
|
||||||
const v = row.querySelector('.hv').value.trim();
|
|
||||||
if (k) headers[k] = v;
|
|
||||||
});
|
|
||||||
|
|
||||||
const body = {
|
|
||||||
name: document.getElementById('name').value.trim(),
|
|
||||||
url: document.getElementById('url').value.trim(),
|
|
||||||
method: document.getElementById('method').value,
|
|
||||||
interval_s: Number(document.getElementById('interval').value),
|
|
||||||
timeout_ms: Number(document.getElementById('timeout').value),
|
|
||||||
};
|
|
||||||
if (Object.keys(headers).length) body.request_headers = headers;
|
|
||||||
const rb = document.getElementById('request-body').value.trim();
|
|
||||||
if (rb) body.request_body = rb;
|
|
||||||
if (currentQuery) body.query = currentQuery;
|
|
||||||
|
|
||||||
await api('/monitors/', { method: 'POST', body });
|
await api('/monitors/', { method: 'POST', body });
|
||||||
window.location.href = '/dashboard/home';
|
window.location.href = '/dashboard/home';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<%~ include('./partials/head', { title: 'Notifications' }) %>
|
||||||
|
<%~ include('./partials/nav', { nav: 'notifications' }) %>
|
||||||
|
|
||||||
|
<main class="max-w-3xl mx-auto px-8 py-10 space-y-8">
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-semibold text-white">Notification channels</h1>
|
||||||
|
<a href="#new" class="btn-primary inline-flex items-center gap-2 px-4 py-2 text-sm">+ New channel</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-gray-500 leading-relaxed">
|
||||||
|
Channels are dispatched on status transitions (the "important" beats) for every monitor you attach below. Webhooks POST a JSON payload to your URL.
|
||||||
|
More providers (Discord, Slack, Email, Telegram) will land here as drop-ins.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<% const monitors = it.monitors || []; %>
|
||||||
|
|
||||||
|
<% if (!it.channels || it.channels.length === 0) { %>
|
||||||
|
<section class="card-static p-6 text-sm text-gray-500">
|
||||||
|
No channels yet. Create one below to start receiving alerts.
|
||||||
|
</section>
|
||||||
|
<% } else { %>
|
||||||
|
<% it.channels.forEach(function(c) { %>
|
||||||
|
<section class="card-static p-6 space-y-4">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-200 truncate"><%= c.name %></h2>
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded-full bg-gray-800/50 border border-border-subtle text-gray-400 font-mono"><%= c.kind %></span>
|
||||||
|
<% if (!c.enabled) { %>
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded-full bg-yellow-900/20 border border-yellow-800/30 text-yellow-500">paused</span>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% if (c.kind === 'webhook' && c.config && c.config.url) { %>
|
||||||
|
<code class="text-xs text-gray-500 font-mono break-all"><%= c.config.url %></code>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<form action="/dashboard/notifications/<%= c.id %>/test" method="POST" class="inline">
|
||||||
|
<button type="submit" class="px-3 py-1.5 rounded-lg border border-border-subtle text-gray-400 hover:text-gray-200 text-xs transition-colors">Test</button>
|
||||||
|
</form>
|
||||||
|
<form action="/dashboard/notifications/<%= c.id %>/delete" method="POST" class="inline" onsubmit="return confirm('Delete channel \'<%= c.name %>\'? Monitors using it will lose this notification target.')">
|
||||||
|
<button type="submit" class="px-3 py-1.5 rounded-lg border border-red-900/30 text-red-400 hover:bg-red-900/20 hover:border-red-800/40 text-xs transition-colors">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="/dashboard/notifications/<%= c.id %>/edit" method="POST" class="space-y-3 pt-4 border-t border-border-subtle">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 mb-2">Attached monitors</label>
|
||||||
|
<% if (monitors.length === 0) { %>
|
||||||
|
<p class="text-xs text-gray-600">No monitors yet. <a href="/dashboard/monitors/new" class="text-blue-400 hover:text-blue-300">Create one</a> to attach it to this channel.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<% const attached = (c.monitor_ids || []); %>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<% monitors.forEach(function(m) { %>
|
||||||
|
<label class="flex items-center gap-2 bg-gray-900 border border-gray-800 hover:border-gray-600 rounded-lg px-3 py-2 cursor-pointer transition-colors">
|
||||||
|
<input type="checkbox" name="monitor_ids" value="<%= m.id %>" class="accent-blue-500" <%= attached.includes(m.id) ? 'checked' : '' %>>
|
||||||
|
<span class="text-sm text-gray-300 truncate max-w-[16rem]"><%= m.name %></span>
|
||||||
|
</label>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="px-4 py-1.5 rounded-lg border border-border-subtle text-gray-300 hover:text-white hover:border-gray-600 text-xs transition-colors">Save attachments</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<% }) %>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (it.testResult) { %>
|
||||||
|
<div class="text-sm <%= it.testResult.ok ? 'text-green-400' : 'text-red-400' %>">
|
||||||
|
<%= it.testResult.ok ? 'Test event sent successfully.' : ('Test failed: ' + it.testResult.error) %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<section id="new" class="card-static p-6">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300 mb-4">New webhook channel</h2>
|
||||||
|
<form action="/dashboard/notifications/new" method="POST" class="space-y-4">
|
||||||
|
<input type="hidden" name="kind" value="webhook">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Name</label>
|
||||||
|
<input name="name" type="text" required placeholder="On-call webhook"
|
||||||
|
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">URL</label>
|
||||||
|
<input name="url" type="url" required placeholder="https://hooks.example.com/pingql"
|
||||||
|
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 font-mono text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">HMAC secret <span class="text-gray-600">(optional)</span></label>
|
||||||
|
<input name="secret" type="text" placeholder="Used to sign payloads as X-PingQL-Signature"
|
||||||
|
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 font-mono text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Monitors <span class="text-gray-600">(optional - attach now or later)</span></label>
|
||||||
|
<% if (monitors.length === 0) { %>
|
||||||
|
<p class="text-xs text-gray-600">No monitors yet. <a href="/dashboard/monitors/new" class="text-blue-400 hover:text-blue-300">Create one</a> first, then attach it here.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<% monitors.forEach(function(m) { %>
|
||||||
|
<label class="flex items-center gap-2 bg-gray-900 border border-gray-800 hover:border-gray-600 rounded-lg px-3 py-2 cursor-pointer transition-colors">
|
||||||
|
<input type="checkbox" name="monitor_ids" value="<%= m.id %>" class="accent-blue-500">
|
||||||
|
<span class="text-sm text-gray-300 truncate max-w-[16rem]"><%= m.name %></span>
|
||||||
|
</label>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary px-6 py-2.5 text-sm">Create channel</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
@@ -3,10 +3,11 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>PingQL<%= it.title ? ` — ${it.title}` : '' %></title>
|
<title>PingQL<%= it.title ? ` - ${it.title}` : '' %></title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="/dashboard/app.css">
|
<link rel="stylesheet" href="/assets/tailwind.css?v=<%= it.h.css %>">
|
||||||
<script src="/dashboard/app.js?v=2"></script>
|
<link rel="stylesheet" href="/assets/app.css?v=<%= it.h.appCss %>">
|
||||||
|
<script src="/assets/app.js?v=<%= it.h.js %>"></script>
|
||||||
<% if (it.scripts) { it.scripts.forEach(function(s) { %>
|
<% if (it.scripts) { it.scripts.forEach(function(s) { %>
|
||||||
<script src="<%= s %>"></script>
|
<script src="<%= s %>"></script>
|
||||||
<% }) } %>
|
<% }) } %>
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
let _currentQuery = _initialQuery;
|
||||||
|
|
||||||
|
// Query builder
|
||||||
|
const _qb = new QueryBuilder(document.getElementById(_prefix + 'query-builder'), (q) => {
|
||||||
|
_currentQuery = q;
|
||||||
|
});
|
||||||
|
if (_initialQuery) _qb.setQuery(_initialQuery);
|
||||||
|
|
||||||
|
// Method/body visibility
|
||||||
|
const _methodSel = document.getElementById(_prefix + 'method');
|
||||||
|
const _bodySection = document.getElementById(_prefix + 'body-section');
|
||||||
|
_methodSel.addEventListener('change', () => {
|
||||||
|
_bodySection.classList.toggle('hidden', ['GET','HEAD','OPTIONS'].includes(_methodSel.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dynamic headers
|
||||||
|
document.querySelector('.' + (_prefix || '') + 'add-header').addEventListener('click', () => {
|
||||||
|
const container = document.getElementById(_prefix + 'headers-list');
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'header-row flex gap-2';
|
||||||
|
const bg = container.closest('form').querySelector('input')?.className.match(/bg-gray-\d+/)?.[0] || 'bg-gray-900';
|
||||||
|
const border = container.closest('form').querySelector('input')?.className.match(/border-gray-\d+/)?.[0] || 'border-gray-800';
|
||||||
|
row.innerHTML = `
|
||||||
|
<input type="text" name="header_key" placeholder="Header name" class="hk flex-1 ${bg} border ${border} rounded-lg px-3 py-2 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm">
|
||||||
|
<input type="text" name="header_value" placeholder="Value" class="hv flex-1 ${bg} border ${border} rounded-lg px-3 py-2 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm">
|
||||||
|
<button type="button" onclick="this.parentElement.remove()" class="px-2 text-gray-600 hover:text-red-400 transition-colors text-sm">✕</button>
|
||||||
|
`;
|
||||||
|
container.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collect form data into API body
|
||||||
|
function collectFormData(prefix) {
|
||||||
|
const headers = {};
|
||||||
|
document.querySelectorAll('#' + prefix + 'headers-list .header-row').forEach(row => {
|
||||||
|
const k = row.querySelector('.hk').value.trim();
|
||||||
|
const v = row.querySelector('.hv').value.trim();
|
||||||
|
if (k) headers[k] = v;
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
name: document.getElementById(prefix + 'name').value.trim(),
|
||||||
|
url: document.getElementById(prefix + 'url').value.trim(),
|
||||||
|
method: document.getElementById(prefix + 'method').value,
|
||||||
|
interval_s: Number(document.getElementById(prefix + 'interval').value),
|
||||||
|
timeout_ms: Number(document.getElementById(prefix + 'timeout').value),
|
||||||
|
max_retries: Number(document.getElementById(prefix + 'max-retries').value),
|
||||||
|
retry_interval_s: Number(document.getElementById(prefix + 'retry-interval').value),
|
||||||
|
resend_interval: Number(document.getElementById(prefix + 'resend-interval').value),
|
||||||
|
cert_alert_days: Number(document.getElementById(prefix + 'cert-alert-days').value),
|
||||||
|
max_redirects: Number(document.getElementById(prefix + 'max-redirects-follow').value),
|
||||||
|
};
|
||||||
|
if (Object.keys(headers).length) body.request_headers = headers;
|
||||||
|
else body.request_headers = null;
|
||||||
|
const rb = document.getElementById(prefix + 'request-body').value.trim();
|
||||||
|
body.request_body = rb || null;
|
||||||
|
body.regions = [...document.querySelectorAll('.' + prefix + 'region-check:checked')].map(el => el.value);
|
||||||
|
if (!body.regions.length) throw new Error('Select at least one region');
|
||||||
|
if (_currentQuery) body.query = _currentQuery;
|
||||||
|
return body;
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<%
|
||||||
|
const monitor = it._form?.monitor || {};
|
||||||
|
const isEdit = !!it._form?.isEdit;
|
||||||
|
const prefix = it._form?.prefix || '';
|
||||||
|
const bg = it._form?.bg || 'bg-gray-900';
|
||||||
|
const border = it._form?.border || 'border-gray-800';
|
||||||
|
const plan = it.plan || 'free';
|
||||||
|
const minInterval = { free: 30, pro: 5, pro2x: 5, pro4x: 5, lifetime: 5, lifetime2x: 5, lifetime4x: 5 }[plan] || 30;
|
||||||
|
const btnText = isEdit ? 'Save Changes' : 'Create Monitor';
|
||||||
|
const formId = prefix + 'form';
|
||||||
|
const methods = ['GET','POST','PUT','PATCH','DELETE','HEAD','OPTIONS'];
|
||||||
|
const allIntervals = [['2','2 seconds'],['5','5 seconds'],['10','10 seconds'],['20','20 seconds'],['30','30 seconds'],['60','1 minute'],['300','5 minutes'],['600','10 minutes'],['1800','30 minutes'],['3600','1 hour']];
|
||||||
|
const intervals = allIntervals.filter(([val]) => Number(val) >= minInterval);
|
||||||
|
const timeouts = [['5000','5 seconds'],['10000','10 seconds'],['20000','20 seconds'],['30000','30 seconds'],['40000','40 seconds'],['50000','50 seconds'],['60000','60 seconds']];
|
||||||
|
const regions = it.regions;
|
||||||
|
const curMethod = monitor.method || 'GET';
|
||||||
|
const bodyHidden = ['GET','HEAD','OPTIONS'].includes(curMethod);
|
||||||
|
%>
|
||||||
|
|
||||||
|
<form id="<%= formId %>" action="<%= isEdit ? '/dashboard/monitors/' + monitor.id + '/edit' : '/dashboard/monitors/new' %>" method="POST" class="space-y-<%= isEdit ? '5' : '6' %>">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Name</label>
|
||||||
|
<input id="<%= prefix %>name" name="name" type="text" required value="<%= monitor.name || '' %>" placeholder="Production API"
|
||||||
|
class="w-full <%= bg %> border <%= border %> rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">URL</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select id="<%= prefix %>method" name="method"
|
||||||
|
class="<%= bg %> border <%= border %> rounded-lg px-3 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500 font-mono text-sm">
|
||||||
|
<% methods.forEach(function(method) { %>
|
||||||
|
<option <%= curMethod === method ? 'selected' : '' %>><%= method %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
<input id="<%= prefix %>url" name="url" type="url" required value="<%= monitor.url || '' %>" placeholder="https://api.example.com/health"
|
||||||
|
class="flex-1 <%= bg %> border <%= border %> rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Request Headers -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-1.5">
|
||||||
|
<label class="text-sm text-gray-400">Headers <span class="text-gray-600">(optional)</span></label>
|
||||||
|
<button type="button" class="<%= prefix %>add-header text-xs text-blue-400 hover:text-blue-300 transition-colors">+ Add header</button>
|
||||||
|
</div>
|
||||||
|
<div id="<%= prefix %>headers-list" class="space-y-2">
|
||||||
|
<% if (monitor.request_headers && typeof monitor.request_headers === 'object') {
|
||||||
|
Object.entries(monitor.request_headers).forEach(function([k, v]) { %>
|
||||||
|
<div class="header-row flex gap-2">
|
||||||
|
<input type="text" name="header_key" value="<%= k %>" placeholder="Header name" class="hk flex-1 <%= bg %> border <%= border %> rounded-lg px-3 py-2 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm">
|
||||||
|
<input type="text" name="header_value" value="<%= v %>" placeholder="Value" class="hv flex-1 <%= bg %> border <%= border %> rounded-lg px-3 py-2 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm">
|
||||||
|
<button type="button" onclick="this.parentElement.remove()" class="px-2 text-gray-600 hover:text-red-400 transition-colors text-sm">✕</button>
|
||||||
|
</div>
|
||||||
|
<% }) } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Request Body -->
|
||||||
|
<div id="<%= prefix %>body-section" class="<%= bodyHidden ? 'hidden' : '' %>">
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Request Body <span class="text-gray-600">(optional)</span></label>
|
||||||
|
<textarea id="<%= prefix %>request-body" name="request_body" rows="4" placeholder='{"key": "value"}'
|
||||||
|
class="w-full <%= bg %> border <%= border %> rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 font-mono text-sm resize-y"><%= monitor.request_body || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5"><%= isEdit ? 'Interval' : 'Ping Interval' %></label>
|
||||||
|
<select id="<%= prefix %>interval" name="interval_s"
|
||||||
|
class="w-full <%= bg %> border <%= border %> rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
||||||
|
<% intervals.forEach(function([val, label]) { %>
|
||||||
|
<option value="<%= val %>" <%= String(monitor.interval_s || minInterval) === val ? 'selected' : '' %>><%= label %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Timeout</label>
|
||||||
|
<select id="<%= prefix %>timeout" name="timeout_ms"
|
||||||
|
class="w-full <%= bg %> border <%= border %> rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
||||||
|
<% timeouts.forEach(function([val, label]) { %>
|
||||||
|
<option value="<%= val %>" <%= String(monitor.timeout_ms || '10000') === val ? 'selected' : '' %>><%= label %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Retries before DOWN</label>
|
||||||
|
<select id="<%= prefix %>max-retries" name="max_retries"
|
||||||
|
class="w-full <%= bg %> border <%= border %> rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
||||||
|
<% [['0','None'],['1','1'],['2','2'],['3','3'],['5','5']].forEach(function([val, label]) { %>
|
||||||
|
<option value="<%= val %>" <%= String(monitor.max_retries ?? '0') === val ? 'selected' : '' %>><%= label %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<%
|
||||||
|
const allRetryGaps = [['5','5 seconds'],['15','15 seconds'],['30','30 seconds'],['60','1 minute'],['120','2 minutes'],['300','5 minutes']];
|
||||||
|
const retryGaps = allRetryGaps.filter(([val]) => Number(val) >= minInterval);
|
||||||
|
const defaultRetryGap = String(minInterval);
|
||||||
|
const curRetryRaw = Number(monitor.retry_interval_s);
|
||||||
|
const curRetry = (curRetryRaw >= minInterval) ? String(curRetryRaw) : defaultRetryGap;
|
||||||
|
%>
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Retry gap</label>
|
||||||
|
<select id="<%= prefix %>retry-interval" name="retry_interval_s"
|
||||||
|
class="w-full <%= bg %> border <%= border %> rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
||||||
|
<% retryGaps.forEach(function([val, label]) { %>
|
||||||
|
<option value="<%= val %>" <%= curRetry === val ? 'selected' : '' %>><%= label %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Re-alert every</label>
|
||||||
|
<select id="<%= prefix %>resend-interval" name="resend_interval"
|
||||||
|
class="w-full <%= bg %> border <%= border %> rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
||||||
|
<% [['0','Never'],['5','5 down beats'],['10','10 down beats'],['30','30 down beats'],['60','60 down beats']].forEach(function([val, label]) { %>
|
||||||
|
<option value="<%= val %>" <%= String(monitor.resend_interval ?? '0') === val ? 'selected' : '' %>><%= label %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Max redirects</label>
|
||||||
|
<select id="<%= prefix %>max-redirects-follow" name="max_redirects"
|
||||||
|
class="w-full <%= bg %> border <%= border %> rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
||||||
|
<% [['0','None'],['1','1'],['2','2'],['3','3'],['4','4']].forEach(function([val, label]) { %>
|
||||||
|
<option value="<%= val %>" <%= String(monitor.max_redirects ?? '1') === val ? 'selected' : '' %>><%= label %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%
|
||||||
|
const certDaysRaw = Number(monitor.cert_alert_days);
|
||||||
|
const certDaysSel = (certDaysRaw >= 1 && certDaysRaw <= 9) ? String(certDaysRaw) : '0';
|
||||||
|
%>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">TLS cert expiry alert</label>
|
||||||
|
<select id="<%= prefix %>cert-alert-days" name="cert_alert_days"
|
||||||
|
class="w-full <%= bg %> border <%= border %> rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
||||||
|
<option value="0" <%= certDaysSel === '0' ? 'selected' : '' %>>Disabled</option>
|
||||||
|
<% for (let d = 1; d <= 9; d++) { const v = String(d); %>
|
||||||
|
<option value="<%= v %>" <%= certDaysSel === v ? 'selected' : '' %>><%= d %> day<%= d === 1 ? '' : 's' %> before expiry</option>
|
||||||
|
<% } %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%
|
||||||
|
// Default to all regions if none selected
|
||||||
|
const selectedRegions = (monitor.regions && monitor.regions.length) ? monitor.regions : regions.map(r => r[0]);
|
||||||
|
%>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Regions</label>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<% regions.forEach(function([val, label]) { %>
|
||||||
|
<label class="flex items-center gap-2 <%= bg %> border <%= border %> hover:border-gray-600 rounded-lg px-3 py-2 cursor-pointer transition-colors">
|
||||||
|
<input type="checkbox" name="regions" value="<%= val %>" class="<%= prefix %>region-check accent-blue-500" <%= selectedRegions.includes(val) ? 'checked' : '' %>> <span class="text-sm text-gray-300"><%~ label %></span>
|
||||||
|
</label>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5"><%= isEdit ? 'Conditions' : 'Query Conditions' %> <span class="text-gray-600">(optional)</span></label>
|
||||||
|
<p class="text-xs text-gray-600 mb-3">Define <% if (isEdit) { %>up/down conditions<% } else { %>when this monitor should be considered "up"<% } %>.</p>
|
||||||
|
<div id="<%= prefix %>query-builder"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="<%= prefix %>error" class="text-red-400 text-sm hidden"></div>
|
||||||
|
|
||||||
|
<button type="submit" id="<%= prefix %>submit-btn"
|
||||||
|
class="<%= isEdit ? '' : 'w-full ' %>bg-blue-600 hover:bg-blue-500 text-white<%= isEdit ? ' text-sm' : '' %> font-medium px-6 py-<%= isEdit ? '2.5' : '3' %> rounded-lg transition-colors">
|
||||||
|
<%= btnText %>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
@@ -1,16 +1,11 @@
|
|||||||
<nav class="border-b border-gray-800 px-8 py-4 flex items-center justify-between">
|
<nav class="border-b border-border-subtle bg-[#0a0a0a]/70 backdrop-blur-md px-8 py-4 flex items-center justify-between sticky top-0 z-40">
|
||||||
<a href="/dashboard/home" class="text-xl font-bold tracking-tight">Ping<span class="text-blue-400">QL</span></a>
|
<a href="/dashboard/home" class="text-xl font-bold tracking-tight group">Ping<span class="text-blue-400 transition-all group-hover:drop-shadow-[0_0_8px_rgba(59,130,246,0.4)]">QL</span></a>
|
||||||
<div class="flex items-center gap-5 text-sm text-gray-500">
|
<div class="flex items-center gap-5 text-sm text-gray-500">
|
||||||
<% if (it.nav === 'monitors') { %>
|
<a href="/dashboard/home" class="<%= it.nav === 'monitors' ? 'text-gray-200 relative after:absolute after:bottom-[-18px] after:left-0 after:right-0 after:h-[2px] after:bg-blue-500 after:rounded-full' : 'hover:text-gray-300' %> transition-colors">Monitors</a>
|
||||||
<span class="text-gray-300">Monitors</span>
|
<a href="/dashboard/pages" class="<%= it.nav === 'pages' ? 'text-gray-200 relative after:absolute after:bottom-[-18px] after:left-0 after:right-0 after:h-[2px] after:bg-blue-500 after:rounded-full' : 'hover:text-gray-300' %> transition-colors">Pages</a>
|
||||||
<% } else { %>
|
<a href="/dashboard/incidents" class="<%= it.nav === 'incidents' ? 'text-gray-200 relative after:absolute after:bottom-[-18px] after:left-0 after:right-0 after:h-[2px] after:bg-blue-500 after:rounded-full' : 'hover:text-gray-300' %> transition-colors">Incidents</a>
|
||||||
<a href="/dashboard/home" class="hover:text-gray-300 transition-colors">Monitors</a>
|
<a href="/dashboard/notifications" class="<%= it.nav === 'notifications' ? 'text-gray-200 relative after:absolute after:bottom-[-18px] after:left-0 after:right-0 after:h-[2px] after:bg-blue-500 after:rounded-full' : 'hover:text-gray-300' %> transition-colors">Notifications</a>
|
||||||
<% } %>
|
<a href="/dashboard/settings" class="<%= it.nav === 'settings' ? 'text-gray-200 relative after:absolute after:bottom-[-18px] after:left-0 after:right-0 after:h-[2px] after:bg-blue-500 after:rounded-full' : 'hover:text-gray-300' %> transition-colors">Settings</a>
|
||||||
<% if (it.nav === 'settings') { %>
|
<a href="/account/logout" class="hover:text-gray-300 transition-colors">Logout</a>
|
||||||
<span class="text-gray-300">Settings</span>
|
|
||||||
<% } else { %>
|
|
||||||
<a href="/dashboard/settings" class="hover:text-gray-300 transition-colors">Settings</a>
|
|
||||||
<% } %>
|
|
||||||
<button onclick="logout()" class="hover:text-gray-300 transition-colors">Logout</button>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<footer class="border-t border-border-subtle py-16 px-6">
|
||||||
|
<div class="max-w-5xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-6">
|
||||||
|
<div class="flex items-center gap-8">
|
||||||
|
<a href="/" class="text-lg font-bold tracking-tight font-mono">Ping<span class="text-brand">QL</span></a>
|
||||||
|
<nav class="flex items-center gap-6 text-sm text-gray-500">
|
||||||
|
<a href="/docs" class="hover:text-gray-300 transition-colors">Docs</a>
|
||||||
|
<a href="/privacy" class="hover:text-gray-300 transition-colors">Privacy</a>
|
||||||
|
<a href="/terms" class="hover:text-gray-300 transition-colors">Terms</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600">© <%= new Date().getFullYear() %> PingQL</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="dark scroll-smooth">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PingQL<%= it.title ? ` - ${it.title}` : '' %></title>
|
||||||
|
<% if (it.description) { %><meta name="description" content="<%= it.description %>"><% } %>
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="/assets/tailwind.css?v=<%= it.cssHash %>">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.prose h2 { font-size: 1.25rem; font-weight: 600; color: #f3f4f6; margin-top: 2.5rem; margin-bottom: 0.75rem; }
|
||||||
|
.prose p { color: #9ca3af; line-height: 1.75; margin-bottom: 1rem; }
|
||||||
|
.prose ul { margin-bottom: 1rem; padding-left: 1.25rem; }
|
||||||
|
.prose li { color: #9ca3af; line-height: 1.75; margin-bottom: 0.25rem; list-style-type: disc; }
|
||||||
|
.prose strong { color: #e5e7eb; font-weight: 600; }
|
||||||
|
.prose a { color: #60a5fa; text-decoration: none; }
|
||||||
|
.prose a:hover { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-[#0a0a0a] text-gray-100 font-sans antialiased text-[15px]">
|
||||||
|
|
||||||
|
<header class="fixed top-0 left-0 right-0 z-50 border-b border-border-subtle bg-[#0a0a0a]/70 backdrop-blur-md">
|
||||||
|
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
|
<a href="/" class="font-mono text-lg font-bold tracking-tight group">Ping<span class="text-blue-400 transition-all group-hover:drop-shadow-[0_0_8px_rgba(59,130,246,0.4)]">QL</span></a>
|
||||||
|
<nav class="hidden md:flex items-center gap-7 text-sm text-gray-400">
|
||||||
|
<a href="/docs" class="<%= it.nav === 'docs' ? 'text-gray-200' : 'hover:text-gray-200' %> transition-colors">Docs</a>
|
||||||
|
<a href="/privacy" class="<%= it.nav === 'privacy' ? 'text-gray-200' : 'hover:text-gray-200' %> transition-colors">Privacy</a>
|
||||||
|
<a href="/terms" class="<%= it.nav === 'terms' ? 'text-gray-200' : 'hover:text-gray-200' %> transition-colors">Terms</a>
|
||||||
|
</nav>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<a href="/dashboard" class="text-sm text-gray-400 hover:text-gray-200 transition-colors">Sign in</a>
|
||||||
|
<a href="/dashboard" class="text-sm btn-primary px-4 py-2">Get started</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<%~ include('./partials/public-head', { title: 'Privacy', description: "PingQL's privacy policy. No tracking, no data sales, no ads.", nav: 'privacy' }) %>
|
||||||
|
|
||||||
|
<main class="max-w-2xl mx-auto px-6 pt-28 pb-16">
|
||||||
|
|
||||||
|
<!-- Hero -->
|
||||||
|
<div class="mb-12">
|
||||||
|
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full border border-green-800/50 text-xs text-green-400 mb-6 font-mono">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-green-500"></span>
|
||||||
|
Actually private
|
||||||
|
</div>
|
||||||
|
<h1 class="text-4xl font-bold tracking-tight mb-4">Privacy Policy</h1>
|
||||||
|
<p class="text-gray-400 text-lg leading-relaxed">
|
||||||
|
The short version: we collect the minimum needed to run the service. We don't sell it, share it, or monetize it. Ever.
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-600 mt-4">Last updated: March 2026</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- The industry problem -->
|
||||||
|
<div class="rounded-xl border border-red-900/30 bg-red-950/10 p-6 mb-10">
|
||||||
|
<h2 class="text-base font-semibold text-red-400 mb-3 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/></svg>
|
||||||
|
The rest of the industry
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-400 leading-relaxed">
|
||||||
|
Most SaaS monitoring tools are free or cheap because <strong>you're the product</strong>. They're backed by investors who need returns, which means your usage data, your infrastructure details, your URLs, your team. All of it gets fed into ad networks, sold to data brokers, or used to build "anonymized" datasets that aren't actually anonymous.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-400 leading-relaxed mt-3">
|
||||||
|
They require your email "for alerts" and then spam you, add you to nurture sequences, sell your address, and share it with their parent company's 47 "trusted partners." They embed Segment, Mixpanel, Intercom, Hotjar, Google Analytics, and a dozen other trackers on every page, harvesting your behavior and shipping it to Silicon Valley servers whether you want it or not.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-400 leading-relaxed mt-3">
|
||||||
|
That's the industry standard. We think it's garbage.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose">
|
||||||
|
|
||||||
|
<h2>What we collect</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Account key</strong> - a UUID you generate at signup. Stored in plaintext. It's your credential.</li>
|
||||||
|
<li><strong>Email (optional)</strong> - only if you provide one for account recovery. Stored as a one-way SHA-256 hash. We cannot read it, search for it, or recover it.</li>
|
||||||
|
<li><strong>Monitors</strong> - the URLs and configurations you create.</li>
|
||||||
|
<li><strong>Ping results</strong> - status codes, latency, up/down, error messages.</li>
|
||||||
|
</ul>
|
||||||
|
<p>That's it. No device fingerprints, no IP logs, no browser data, no analytics events, no session recordings.</p>
|
||||||
|
|
||||||
|
<h2>What we don't collect</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Your name, company, or any identity information</li>
|
||||||
|
<li>Your IP address (beyond what your browser sends in the HTTP request, which we don't log)</li>
|
||||||
|
<li>Browsing behavior or page analytics</li>
|
||||||
|
<li>Third-party tracking scripts of any kind</li>
|
||||||
|
<li>Cookies beyond the session cookie that keeps you logged in</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>What we never do</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Sell your data.</strong> Not to anyone, ever, for any reason.</li>
|
||||||
|
<li><strong>Share your data</strong> with advertisers, data brokers, or "partners."</li>
|
||||||
|
<li><strong>Use your monitor URLs or configs</strong> to build datasets, train models, or infer anything about your business.</li>
|
||||||
|
<li><strong>Send marketing email</strong> unless you explicitly ask for it (you won't, because we don't have that feature).</li>
|
||||||
|
<li><strong>Require your email.</strong> You can use PingQL completely anonymously.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Third parties</h2>
|
||||||
|
<p>
|
||||||
|
We use <strong>Cloudflare</strong> for DNS and DDoS protection. They see your IP when you connect. Their privacy policy applies to that layer.
|
||||||
|
That's the complete list of third parties with access to anything.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
No Segment. No Mixpanel. No Intercom. No Hotjar. No Google Analytics. No Facebook Pixel. Nothing.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Payments</h2>
|
||||||
|
<p>
|
||||||
|
We accept cryptocurrency. No card data, no billing address, no name. Payments can be entirely anonymous. We never store or see your payment details.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Data retention</h2>
|
||||||
|
<p>
|
||||||
|
Ping history is retained for 90 days by default. Account data is kept until you delete your account. If you want everything deleted, contact us and we'll do it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Security</h2>
|
||||||
|
<p>
|
||||||
|
All data in transit is encrypted via TLS. All data at rest is on encrypted disks. Your account key is the only credential. There's no password database to breach, no hashed passwords to crack, no OAuth tokens to steal.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Contact</h2>
|
||||||
|
<p>
|
||||||
|
Questions? <a href="mailto:hi@pingql.com">hi@pingql.com</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<%~ include('./partials/public-foot') %>
|
||||||
+148
-85
@@ -4,24 +4,91 @@
|
|||||||
<%
|
<%
|
||||||
const hasEmail = !!it.account.email_hash;
|
const hasEmail = !!it.account.email_hash;
|
||||||
const createdDate = new Date(it.account.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
const createdDate = new Date(it.account.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
const plan = it.account.plan || 'free';
|
||||||
|
const planLabel = it.planLabels[plan] || plan;
|
||||||
|
const limits = {
|
||||||
|
free: { monitors: 10, interval: '30s', regions: 1 },
|
||||||
|
pro: { monitors: 200, interval: '5s', regions: 'All' },
|
||||||
|
pro2x: { monitors: 400, interval: '5s', regions: 'All' },
|
||||||
|
pro4x: { monitors: 800, interval: '5s', regions: 'All' },
|
||||||
|
lifetime: { monitors: 200, interval: '5s', regions: 'All' },
|
||||||
|
lifetime2x: { monitors: 400, interval: '5s', regions: 'All' },
|
||||||
|
lifetime4x: { monitors: 800, interval: '5s', regions: 'All' },
|
||||||
|
}[plan] || { monitors: 10, interval: '30s', regions: 1 };
|
||||||
%>
|
%>
|
||||||
|
|
||||||
<main class="max-w-3xl mx-auto px-8 py-10 space-y-8">
|
<main class="max-w-3xl mx-auto px-8 py-10 space-y-8">
|
||||||
|
|
||||||
<h1 class="text-xl font-semibold text-white">Settings</h1>
|
<h1 class="text-xl font-semibold text-white">Settings</h1>
|
||||||
|
|
||||||
|
<!-- Plan -->
|
||||||
|
<section class="card-static p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300">Plan</h2>
|
||||||
|
<% if (plan === 'free') { %>
|
||||||
|
<span class="text-xs font-medium px-2.5 py-1 rounded-full bg-gray-800/50 border border-border-subtle text-gray-400">Free</span>
|
||||||
|
<% } else if (plan === 'pro' || plan === 'pro2x' || plan === 'pro4x') { %>
|
||||||
|
<span class="text-xs font-medium px-2.5 py-1 rounded-full bg-gradient-to-r from-blue-600/20 to-blue-500/10 border border-blue-500/30 text-blue-400"><%= planLabel %></span>
|
||||||
|
<% } else if (plan.startsWith('lifetime')) { %>
|
||||||
|
<span class="text-xs font-medium px-2.5 py-1 rounded-full bg-gradient-to-r from-yellow-600/20 to-yellow-500/10 border border-yellow-500/30 text-yellow-400"><%= planLabel %></span>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-semibold text-gray-200"><%= it.monitorCount %><span class="text-gray-600 text-sm font-normal">/<%= limits.monitors %></span></div>
|
||||||
|
<div class="text-xs text-gray-500">Monitors</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-semibold text-gray-200"><%= limits.interval %></div>
|
||||||
|
<div class="text-xs text-gray-500">Min Interval</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-semibold text-gray-200"><%= limits.regions %></div>
|
||||||
|
<div class="text-xs text-gray-500">Regions / Monitor</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% if (plan === 'free') { %>
|
||||||
|
<div class="mt-4 pt-4 border-t divider">
|
||||||
|
<a href="/dashboard/checkout" class="btn-primary inline-flex items-center gap-2 px-4 py-2 text-sm">Upgrade to Pro</a>
|
||||||
|
</div>
|
||||||
|
<% } else if ((plan === 'pro' || plan === 'pro2x' || plan === 'pro4x') && it.account.plan_expires_at) { %>
|
||||||
|
<div class="mt-4 pt-4 border-t divider text-xs text-gray-500">
|
||||||
|
<%= planLabel %> expires <%= new Date(it.account.plan_expires_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) %>.
|
||||||
|
<a href="/dashboard/checkout" class="text-blue-400 hover:text-blue-300 ml-1">Extend or upgrade</a>
|
||||||
|
<% const stack = typeof it.account.plan_stack === 'string' ? JSON.parse(it.account.plan_stack) : (it.account.plan_stack || []);
|
||||||
|
if (stack.length > 0) {
|
||||||
|
const parts = stack.map(function(s) {
|
||||||
|
const name = it.planLabels[s.plan] || s.plan;
|
||||||
|
return s.remaining_days == null ? name : name + ' (' + s.remaining_days + 'd)';
|
||||||
|
});
|
||||||
|
%>
|
||||||
|
<div class="mt-1 text-gray-600">Then: <%= parts.join(' → ') %></div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% } else if (plan.startsWith('lifetime') && plan !== 'lifetime4x') { %>
|
||||||
|
<div class="mt-4 pt-4 border-t divider text-xs text-gray-500">
|
||||||
|
<a href="/dashboard/checkout" class="text-yellow-400 hover:text-yellow-300">Upgrade to a higher tier</a>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Account info -->
|
<!-- Account info -->
|
||||||
<section class="bg-gray-900 rounded-xl border border-gray-800 p-6">
|
<section class="card-static p-6">
|
||||||
<h2 class="text-sm font-semibold text-gray-300 mb-4">Account</h2>
|
<h2 class="text-sm font-semibold text-gray-300 mb-4">Account</h2>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
|
<% if (!it.isSubKey) { %>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-gray-500 mb-1">Login Key</label>
|
<label class="block text-xs text-gray-500 mb-1">Login Key</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<code id="login-key-display" class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-blue-400 text-sm font-mono select-all"><%= it.loginKey %></code>
|
<code id="login-key-display" class="flex-1 bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-blue-400 text-sm font-mono select-all"><%= it.loginKey %></code>
|
||||||
<button onclick="copyLoginKey()" class="px-3 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-400 hover:text-white text-xs transition-colors">Copy</button>
|
<button onclick="copyLoginKey()" class="btn-secondary px-3 text-xs">Copy</button>
|
||||||
<button onclick="confirmReset()" class="px-3 bg-gray-800 hover:bg-gray-700 border border-red-900/50 hover:border-red-700/50 text-red-400 rounded-lg text-xs transition-colors">Rotate</button>
|
<form action="/account/reset-key" method="POST" class="inline" onsubmit="return confirm('Rotate your login key? Your current key stops working immediately.')">
|
||||||
|
<input type="hidden" name="_form" value="1">
|
||||||
|
<button type="submit" class="px-3 py-2.5 rounded-lg border border-red-900/30 text-red-400 hover:bg-red-900/20 hover:border-red-800/40 text-xs transition-colors">Rotate</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<% } %>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-gray-500 mb-1">Member since</label>
|
<label class="block text-xs text-gray-500 mb-1">Member since</label>
|
||||||
<p id="created-at" class="text-sm text-gray-400"><%= createdDate %></p>
|
<p id="created-at" class="text-sm text-gray-400"><%= createdDate %></p>
|
||||||
@@ -29,42 +96,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Email -->
|
<% if (it.isSubKey) { %>
|
||||||
<section class="bg-gray-900 rounded-xl border border-gray-800 p-6">
|
<section class="card-static p-6">
|
||||||
|
<p class="text-sm font-semibold text-gray-200 mb-1">Signed in with a sub-key</p>
|
||||||
|
<p class="text-xs text-gray-500 leading-relaxed">Sub-keys can manage monitors but can't access account credentials. Sign in with your main account key to manage your login key, recovery email, and sub-keys.</p>
|
||||||
|
</section>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<!-- Email (hidden for sub-key sessions) -->
|
||||||
|
<% if (!it.isSubKey) { %>
|
||||||
|
<section class="card-static p-6">
|
||||||
<h2 class="text-sm font-semibold text-gray-300 mb-1">Recovery Email</h2>
|
<h2 class="text-sm font-semibold text-gray-300 mb-1">Recovery Email</h2>
|
||||||
<p class="text-xs text-gray-600 mb-4">Used for account recovery only. Stored as a one-way hash — we can't read it.</p>
|
<p class="text-xs text-gray-600 mb-4">Used for account recovery only. Stored as a one-way hash, so we can't read it.</p>
|
||||||
<div class="flex gap-2">
|
<form action="/account/email" method="POST">
|
||||||
<input id="email-input" type="email" placeholder="<%= hasEmail ? '●●●●●●●● (set)' : 'you@example.com' %>"
|
<input type="hidden" name="_form" value="1">
|
||||||
class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm">
|
<div class="flex gap-2">
|
||||||
<button onclick="saveEmail()" id="email-btn"
|
<input id="email-input" name="email" type="email" placeholder="<%= hasEmail ? '●●●●●●●● (set)' : 'you@example.com' %>"
|
||||||
class="px-4 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-medium transition-colors">Save</button>
|
class="flex-1 input-base px-4 py-2.5 text-gray-100 placeholder-gray-600 text-sm">
|
||||||
<button onclick="removeEmail()" id="remove-email-btn" class="<%= hasEmail ? '' : 'hidden' %> px-3 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-500 hover:text-red-400 text-xs transition-colors">Remove</button>
|
<button type="submit" id="email-btn" class="btn-primary px-4 text-sm">Save</button>
|
||||||
</div>
|
<% if (hasEmail) { %>
|
||||||
|
<button type="submit" name="email" value="" class="btn-secondary px-3 text-xs text-gray-500 hover:!text-red-400">Remove</button>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
<p id="email-status" class="text-xs mt-2 hidden"></p>
|
<p id="email-status" class="text-xs mt-2 hidden"></p>
|
||||||
</section>
|
</section>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Sub-keys -->
|
<!-- Sub-keys (hidden for sub-key sessions) -->
|
||||||
<section class="bg-gray-900 rounded-xl border border-gray-800 p-6">
|
<% if (!it.isSubKey) { %>
|
||||||
|
<section class="card-static p-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-sm font-semibold text-gray-300">Sub-Keys</h2>
|
<h2 class="text-sm font-semibold text-gray-300">Sub-Keys</h2>
|
||||||
<p class="text-xs text-gray-600 mt-0.5">Create separate keys for different apps, scripts, or teammates.</p>
|
<p class="text-xs text-gray-600 mt-0.5">Create separate keys for different apps, scripts, or teammates.</p>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="showCreateKey()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-xs font-medium transition-colors">+ New Sub-Key</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create key form (hidden by default) -->
|
<!-- Create key form -->
|
||||||
<div id="create-key-form" class="hidden mb-4 p-4 bg-gray-800/50 rounded-lg border border-gray-700">
|
<form action="/account/keys" method="POST" class="mb-4 p-4 rounded-lg bg-surface border border-border-subtle">
|
||||||
<label class="block text-xs text-gray-500 mb-1.5">Label</label>
|
<input type="hidden" name="_form" value="1">
|
||||||
|
<label class="block text-xs text-gray-500 mb-1.5">New Sub-Key</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<input id="key-label" type="text" placeholder="e.g. ci-pipeline, mobile-app"
|
<input name="label" type="text" placeholder="e.g. ci-pipeline, mobile-app" required
|
||||||
class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm">
|
class="flex-1 input-base px-3 py-2 text-gray-100 placeholder-gray-600 text-sm">
|
||||||
<button onclick="createKey()" class="px-4 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-medium transition-colors">Create</button>
|
<button type="submit" class="btn-primary px-4 text-sm">Create</button>
|
||||||
<button onclick="hideCreateKey()" class="px-3 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-500 text-sm transition-colors">Cancel</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
|
|
||||||
<!-- Sub-keys list -->
|
<!-- Sub-keys list -->
|
||||||
<div id="keys-list" class="space-y-2">
|
<div id="keys-list" class="space-y-2">
|
||||||
@@ -72,14 +152,17 @@
|
|||||||
<p class="text-xs text-gray-600 italic">No sub-keys yet.</p>
|
<p class="text-xs text-gray-600 italic">No sub-keys yet.</p>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<% it.apiKeys.forEach(function(k) { %>
|
<% it.apiKeys.forEach(function(k) { %>
|
||||||
<div class="p-3 bg-gray-800/50 rounded-lg border border-gray-700/50">
|
<div class="p-3 rounded-lg bg-surface border border-border-subtle hover:border-border-strong transition-colors">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<p class="text-sm text-gray-200"><%= k.label %></p>
|
<p class="text-sm text-gray-200"><%= k.label %></p>
|
||||||
<button onclick="deleteKey('<%= k.id %>', this)" class="text-xs text-gray-600 hover:text-red-400 transition-colors px-2 py-1">Revoke</button>
|
<form action="/account/keys/<%= k.id %>/delete" method="POST" class="inline" onsubmit="return confirm('Revoke this key? Any apps using it will stop working.')">
|
||||||
|
<input type="hidden" name="_form" value="1">
|
||||||
|
<button type="submit" class="text-xs text-gray-600 hover:text-red-400 transition-colors px-2 py-1">Revoke</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<code class="flex-1 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-blue-400 text-xs font-mono select-all"><%= k.key %></code>
|
<code class="flex-1 bg-surface-solid border border-border-subtle rounded-lg px-3 py-2 text-blue-400 text-xs font-mono select-all"><%= k.key %></code>
|
||||||
<button onclick="copyKey(this, '<%= k.key %>')" class="px-3 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-400 hover:text-white text-xs transition-colors">Copy</button>
|
<button onclick="copyKey(this, '<%= k.key %>')" class="btn-secondary px-3 text-xs">Copy</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-600 mt-1.5">created <%= new Date(k.created_at).toLocaleDateString() %> <%~ k.last_used_at ? '· last used ' + it.timeAgoSSR(k.last_used_at) : '· never used' %></p>
|
<p class="text-xs text-gray-600 mt-1.5">created <%= new Date(k.created_at).toLocaleDateString() %> <%~ k.last_used_at ? '· last used ' + it.timeAgoSSR(k.last_used_at) : '· never used' %></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,36 +170,47 @@
|
|||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<!-- Invoices -->
|
||||||
|
<% if (it.invoices && it.invoices.length > 0) { %>
|
||||||
|
<section class="card-static p-6">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300 mb-4">Invoices</h2>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<% it.invoices.forEach(function(inv) {
|
||||||
|
const statusColors = { paid: 'green', confirming: 'blue', pending: 'yellow', underpaid: 'orange' };
|
||||||
|
const statusColor = statusColors[inv.status] || 'gray';
|
||||||
|
const date = new Date(inv.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
|
const planLabel = inv.plan.startsWith('lifetime') ? (it.planLabels[inv.plan] || 'Lifetime') : `${it.planLabels[inv.plan] || 'Pro'} × ${inv.months}mo`;
|
||||||
|
%>
|
||||||
|
<div class="flex items-center justify-between p-3 rounded-lg bg-surface border border-border-subtle hover:border-border-strong transition-colors">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-<%= statusColor %>-500 <%= inv.status !== 'paid' ? 'animate-pulse' : '' %>"></span>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm text-gray-200"><%= planLabel %></span>
|
||||||
|
<span class="text-xs text-gray-600 ml-2">$<%= Number(inv.amount_usd).toFixed(2) %> · <%= inv.coin.toUpperCase() %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-xs text-gray-500"><%= date %></span>
|
||||||
|
<% if (inv.status === 'pending' || inv.status === 'underpaid' || inv.status === 'confirming') { %>
|
||||||
|
<a href="/dashboard/checkout/<%= inv.public_id %>" class="text-xs text-blue-400 hover:text-blue-300">View</a>
|
||||||
|
<% } else if (inv.status === 'paid') { %>
|
||||||
|
<span class="text-xs text-green-500/70">Paid</span>
|
||||||
|
<a href="/dashboard/checkout/<%= inv.public_id %>/receipt" target="_blank" class="text-xs text-blue-400 hover:text-blue-300 transition-colors">Receipt</a>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// JS enhancements - clipboard copy (progressive, not required)
|
||||||
|
|
||||||
async function saveEmail() {
|
|
||||||
const email = document.getElementById('email-input').value.trim();
|
|
||||||
if (!email) return;
|
|
||||||
try {
|
|
||||||
await api('/account/email', { method: 'POST', body: { email } });
|
|
||||||
location.reload();
|
|
||||||
} catch (e) {
|
|
||||||
showStatus('email-status', e.message, 'red');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeEmail() {
|
|
||||||
if (!confirm('Remove recovery email?')) return;
|
|
||||||
await api('/account/email', { method: 'POST', body: { email: null } });
|
|
||||||
location.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmReset() {
|
|
||||||
if (!confirm('Rotate your login key? Your current key stops working immediately.')) return;
|
|
||||||
const data = await api('/account/reset-key', { method: 'POST', body: {} });
|
|
||||||
document.getElementById('login-key-display').textContent = data.key;
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyLoginKey() {
|
function copyLoginKey() {
|
||||||
const val = document.getElementById('login-key-display').textContent;
|
const val = document.getElementById('login-key-display').textContent;
|
||||||
navigator.clipboard.writeText(val);
|
navigator.clipboard.writeText(val);
|
||||||
@@ -125,42 +219,11 @@
|
|||||||
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('text-green-400'); }, 1500);
|
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('text-green-400'); }, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showCreateKey() {
|
|
||||||
document.getElementById('create-key-form').classList.remove('hidden');
|
|
||||||
document.getElementById('new-key-reveal').classList.add('hidden');
|
|
||||||
document.getElementById('key-label').focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideCreateKey() {
|
|
||||||
document.getElementById('create-key-form').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createKey() {
|
|
||||||
const label = document.getElementById('key-label').value.trim();
|
|
||||||
if (!label) return;
|
|
||||||
await api('/account/keys', { method: 'POST', body: { label } });
|
|
||||||
location.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyKey(btn, val) {
|
function copyKey(btn, val) {
|
||||||
navigator.clipboard.writeText(val);
|
navigator.clipboard.writeText(val);
|
||||||
btn.textContent = 'Copied!'; btn.classList.add('text-green-400');
|
btn.textContent = 'Copied!'; btn.classList.add('text-green-400');
|
||||||
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('text-green-400'); }, 1500);
|
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('text-green-400'); }, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteKey(id, btn) {
|
|
||||||
if (!confirm('Revoke this key? Any apps using it will stop working.')) return;
|
|
||||||
await api(`/account/keys/${id}`, { method: 'DELETE' });
|
|
||||||
btn.closest('.p-3').remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showStatus(id, msg, color) {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
el.textContent = msg;
|
|
||||||
el.className = `text-xs mt-2 text-${color}-400`;
|
|
||||||
el.classList.remove('hidden');
|
|
||||||
setTimeout(() => el.classList.add('hidden'), 3000);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<%~ include('./partials/foot') %>
|
<%~ include('./partials/foot') %>
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
<%~ include('./partials/head', { title: it.isNew ? 'New status page' : 'Edit status page' }) %>
|
||||||
|
<%~ include('./partials/nav', { nav: 'pages' }) %>
|
||||||
|
|
||||||
|
<%
|
||||||
|
const p = it.page || {};
|
||||||
|
const allMonitors = it.allMonitors || [];
|
||||||
|
const attachedRows = (it.page?.monitors || []);
|
||||||
|
const existingGroups = (it.page?.groups || []);
|
||||||
|
const attached = new Set(attachedRows.map(m => m.monitor_id));
|
||||||
|
// monitor_id -> existing per-page overrides
|
||||||
|
const displayNames = {};
|
||||||
|
const monitorGroups = {};
|
||||||
|
for (const r of attachedRows) {
|
||||||
|
displayNames[r.monitor_id] = r.display_name || '';
|
||||||
|
monitorGroups[r.monitor_id] = r.group_id || '';
|
||||||
|
}
|
||||||
|
// Map group UUID -> index for the form
|
||||||
|
const groupIdToIndex = {};
|
||||||
|
existingGroups.forEach(function(g, i) { groupIdToIndex[g.id] = String(i); });
|
||||||
|
// Render order: attached monitors first in their saved order, then any
|
||||||
|
// unattached ones (alphabetical) so the user can drag them in.
|
||||||
|
const attachedOrder = attachedRows
|
||||||
|
.map(r => allMonitors.find(m => m.id === r.monitor_id))
|
||||||
|
.filter(Boolean);
|
||||||
|
const unattached = allMonitors
|
||||||
|
.filter(m => !attached.has(m.id))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const orderedMonitors = [...attachedOrder, ...unattached];
|
||||||
|
%>
|
||||||
|
|
||||||
|
<main class="max-w-3xl mx-auto px-8 py-10">
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/dashboard/pages" class="text-sm text-gray-500 hover:text-gray-300 transition-colors">← Back to status pages</a>
|
||||||
|
<h1 class="text-xl font-semibold text-white mt-2"><%= it.isNew ? 'New status page' : 'Edit status page' %></h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="<%= it.isNew ? '/dashboard/pages/new' : '/dashboard/pages/' + p.id + '/edit' %>" class="space-y-6">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Slug</label>
|
||||||
|
<input id="slug-input" name="slug" type="text" required value="<%= p.slug || '' %>" placeholder="my-app" pattern="^[a-z0-9][a-z0-9-]*$"
|
||||||
|
class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 font-mono text-sm">
|
||||||
|
<p class="text-xs text-gray-600 mt-1">Public URL: <a id="slug-url" href="https://pages.pingql.com/<%= p.slug || '' %>" target="_blank" class="text-blue-400 hover:text-blue-300 font-mono">pages.pingql.com/<span id="slug-preview"><%= p.slug || '' %></span></a></p>
|
||||||
|
<script>
|
||||||
|
document.getElementById('slug-input').addEventListener('input', function() {
|
||||||
|
var v = this.value.trim();
|
||||||
|
document.getElementById('slug-preview').textContent = v || '';
|
||||||
|
document.getElementById('slug-url').href = 'https://pages.pingql.com/' + encodeURIComponent(v);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Custom domain <span class="text-gray-600">(optional)</span></label>
|
||||||
|
<input name="custom_domain" type="text" value="<%= p.custom_domain || '' %>" placeholder="status.example.com"
|
||||||
|
class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 font-mono text-sm">
|
||||||
|
<p class="text-xs text-gray-600 mt-1">Point a CNAME to <span class="text-blue-400 font-mono">pages.pingql.com</span> and enter the domain here. SSL is automatic.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Title</label>
|
||||||
|
<input name="title" type="text" required value="<%= p.title || '' %>" placeholder="My App Status"
|
||||||
|
class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Description <span class="text-gray-600">(optional)</span></label>
|
||||||
|
<textarea name="description" rows="2" placeholder="What this status page covers"
|
||||||
|
class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500"><%= p.description || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Theme</label>
|
||||||
|
<select name="theme" class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
||||||
|
<% ['auto','light','dark'].forEach(function(t) { %>
|
||||||
|
<option value="<%= t %>" <%= (p.theme || 'auto') === t ? 'selected' : '' %>><%= t %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Heartbeat bar granularity</label>
|
||||||
|
<select name="bar_frequency" class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
||||||
|
<% [['daily','One bar per day'],['hourly','One bar per hour']].forEach(function([v, label]) { %>
|
||||||
|
<option value="<%= v %>" <%= (p.bar_frequency || 'daily') === v ? 'selected' : '' %>><%= label %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Number of bars</label>
|
||||||
|
<select name="bar_count" class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
||||||
|
<% [12, 24, 48, 7, 14, 30, 60, 90, 120, 180].forEach(function(n) { %>
|
||||||
|
<option value="<%= n %>" <%= Number(p.bar_count || 90) === n ? 'selected' : '' %>><%= n %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-600 -mt-3">The heartbeat bar shows the last N hours or days. The four uptime cells (24h / 7d / 30d / 90d) are independent and always present.</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-1.5">
|
||||||
|
<label class="text-sm text-gray-400">Groups <span class="text-gray-600">(optional)</span></label>
|
||||||
|
<button type="button" id="add-group-btn" class="text-xs text-blue-400 hover:text-blue-300">+ Add group</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-600 mb-2">Monitors can be organized into collapsible groups on the public page. Drag to reorder.</p>
|
||||||
|
<div id="group-list" class="space-y-2">
|
||||||
|
<% existingGroups.forEach(function(g, i) { %>
|
||||||
|
<div class="group-edit-row flex items-center gap-3 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2" draggable="true">
|
||||||
|
<span class="drag-handle text-gray-600 cursor-grab select-none px-1" title="Drag to reorder">⋮⋮</span>
|
||||||
|
<input type="text" name="group_names" value="<%= g.name %>" placeholder="Group name" required
|
||||||
|
class="flex-1 text-sm bg-gray-950 border border-gray-800 rounded px-3 py-1.5 text-gray-200 placeholder-gray-600 focus:outline-none focus:border-blue-500">
|
||||||
|
<button type="button" onclick="this.parentElement.remove(); updateGroupDropdowns();" class="px-2 text-gray-600 hover:text-red-400 text-sm">✕</button>
|
||||||
|
</div>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Monitors</label>
|
||||||
|
<p class="text-xs text-gray-600 mb-2">Tick to attach. Drag to reorder. Assign to a group, override the display name, or pick a display mode per monitor.</p>
|
||||||
|
<% if (allMonitors.length === 0) { %>
|
||||||
|
<p class="text-xs text-gray-600">No monitors yet. <a href="/dashboard/monitors/new" class="text-blue-400 hover:text-blue-300">Create one</a> first.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div id="monitor-list" class="space-y-2">
|
||||||
|
<% orderedMonitors.forEach(function(m) {
|
||||||
|
const isAttached = attached.has(m.id);
|
||||||
|
const displayName = displayNames[m.id] || '';
|
||||||
|
const groupIdx = groupIdToIndex[monitorGroups[m.id]] || '';
|
||||||
|
%>
|
||||||
|
<div class="monitor-edit-row flex items-center gap-3 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2" draggable="true" data-monitor-id="<%= m.id %>">
|
||||||
|
<span class="drag-handle text-gray-600 cursor-grab select-none px-1" title="Drag to reorder">⋮⋮</span>
|
||||||
|
<input type="hidden" name="monitor_order" value="<%= m.id %>">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer min-w-0 flex-1">
|
||||||
|
<input type="checkbox" name="monitor_ids" value="<%= m.id %>" class="accent-blue-500" <%= isAttached ? 'checked' : '' %>>
|
||||||
|
<span class="text-sm text-gray-300 truncate"><%= m.name %></span>
|
||||||
|
</label>
|
||||||
|
<select name="monitor_group[<%= m.id %>]" class="group-select text-xs bg-gray-950 border border-gray-800 rounded px-2 py-1 text-gray-200 focus:outline-none focus:border-blue-500 shrink-0" data-current="<%= groupIdx %>">
|
||||||
|
<option value="">Ungrouped</option>
|
||||||
|
<% existingGroups.forEach(function(g, i) { %>
|
||||||
|
<option value="<%= i %>" <%= groupIdx === String(i) ? 'selected' : '' %>><%= g.name %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
<input type="text" name="display_name[<%= m.id %>]" value="<%= displayName %>" placeholder="Show as"
|
||||||
|
class="text-xs bg-gray-950 border border-gray-800 rounded px-2 py-1 text-gray-200 placeholder-gray-600 focus:outline-none focus:border-blue-500 w-36 shrink-0">
|
||||||
|
</div>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
// Drag-and-drop reorder for both groups and monitors
|
||||||
|
function enableDrag(listId, rowClass) {
|
||||||
|
var list = document.getElementById(listId);
|
||||||
|
if (!list) return;
|
||||||
|
var dragging = null;
|
||||||
|
list.addEventListener('dragstart', function(e) {
|
||||||
|
var row = e.target.closest('.' + rowClass);
|
||||||
|
if (!row) return;
|
||||||
|
dragging = row;
|
||||||
|
row.style.opacity = '0.4';
|
||||||
|
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
|
||||||
|
});
|
||||||
|
list.addEventListener('dragend', function(e) {
|
||||||
|
var row = e.target.closest('.' + rowClass);
|
||||||
|
if (row) row.style.opacity = '';
|
||||||
|
dragging = null;
|
||||||
|
});
|
||||||
|
list.addEventListener('dragover', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var row = e.target.closest('.' + rowClass);
|
||||||
|
if (!row || !dragging || dragging === row) return;
|
||||||
|
var rect = row.getBoundingClientRect();
|
||||||
|
var after = (e.clientY - rect.top) > rect.height / 2;
|
||||||
|
row.parentNode.insertBefore(dragging, after ? row.nextSibling : row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
enableDrag('group-list', 'group-edit-row');
|
||||||
|
enableDrag('monitor-list', 'monitor-edit-row');
|
||||||
|
|
||||||
|
// Add group button
|
||||||
|
document.getElementById('add-group-btn').addEventListener('click', function() {
|
||||||
|
var list = document.getElementById('group-list');
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'group-edit-row flex items-center gap-3 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2';
|
||||||
|
row.draggable = true;
|
||||||
|
row.innerHTML = '<span class="drag-handle text-gray-600 cursor-grab select-none px-1" title="Drag to reorder">\u22ee\u22ee</span>'
|
||||||
|
+ '<input type="text" name="group_names" value="" placeholder="Group name" required class="flex-1 text-sm bg-gray-950 border border-gray-800 rounded px-3 py-1.5 text-gray-200 placeholder-gray-600 focus:outline-none focus:border-blue-500">'
|
||||||
|
+ '<button type="button" onclick="this.parentElement.remove(); updateGroupDropdowns();" class="px-2 text-gray-600 hover:text-red-400 text-sm">✕</button>';
|
||||||
|
list.appendChild(row);
|
||||||
|
row.querySelector('input').focus();
|
||||||
|
updateGroupDropdowns();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync group dropdowns with current group names
|
||||||
|
window.updateGroupDropdowns = function() {
|
||||||
|
var names = [];
|
||||||
|
document.querySelectorAll('#group-list input[name="group_names"]').forEach(function(inp) {
|
||||||
|
names.push(inp.value || '(unnamed)');
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.group-select').forEach(function(sel) {
|
||||||
|
var cur = sel.value;
|
||||||
|
var html = '<option value="">Ungrouped</option>';
|
||||||
|
names.forEach(function(name, i) {
|
||||||
|
html += '<option value="' + i + '"' + (cur === String(i) ? ' selected' : '') + '>' + name.replace(/</g, '<') + '</option>';
|
||||||
|
});
|
||||||
|
sel.innerHTML = html;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// Update dropdowns when group names change
|
||||||
|
document.getElementById('group-list').addEventListener('input', function(e) {
|
||||||
|
if (e.target.name === 'group_names') updateGroupDropdowns();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-x-6 gap-y-2">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<input type="checkbox" name="show_response_time" value="1" <%= (p.show_response_time !== false) ? 'checked' : '' %> class="accent-blue-500">
|
||||||
|
Show response time
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<input type="checkbox" name="show_powered_by" value="1" <%= (p.show_powered_by !== false) ? 'checked' : '' %> class="accent-blue-500">
|
||||||
|
Show "Powered by PingQL"
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<input type="checkbox" name="index_search" value="1" <%= (p.index_search !== false) ? 'checked' : '' %> class="accent-blue-500">
|
||||||
|
Allow search engines
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Auto-refresh</label>
|
||||||
|
<select name="auto_refresh_s" class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
||||||
|
<% [['10','10 seconds'],['30','30 seconds'],['60','1 minute'],['120','2 minutes'],['300','5 minutes'],['600','10 minutes'],['3600','1 hour']].forEach(function([v, label]) { %>
|
||||||
|
<option value="<%= v %>" <%= String(p.auto_refresh_s || 60) === v ? 'selected' : '' %>><%= label %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% const hasPassword = !it.isNew && p.password_hash; %>
|
||||||
|
<div data-password-section>
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Password <span class="text-gray-600">(optional)</span></label>
|
||||||
|
<% if (hasPassword) { %>
|
||||||
|
<div data-password-locked class="flex items-center gap-3 bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5">
|
||||||
|
<span class="inline-flex items-center gap-2 text-sm text-gray-200">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
|
</svg>
|
||||||
|
Password is set
|
||||||
|
</span>
|
||||||
|
<div class="ml-auto flex gap-2">
|
||||||
|
<button type="button" data-password-change class="text-xs px-2.5 py-1 rounded border border-border-subtle text-gray-300 hover:text-white hover:border-blue-500">Change</button>
|
||||||
|
<button type="button" data-password-remove class="text-xs px-2.5 py-1 rounded border border-border-subtle text-gray-400 hover:text-red-400 hover:border-red-500">Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-password-input-wrap class="hidden mt-2">
|
||||||
|
<input data-password-input name="password" type="password" placeholder="New password"
|
||||||
|
class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
|
||||||
|
<button type="button" data-password-cancel class="mt-1 text-xs text-gray-500 hover:text-gray-300">Cancel - keep current password</button>
|
||||||
|
</div>
|
||||||
|
<div data-password-removing class="hidden mt-2 text-xs text-red-400">
|
||||||
|
Password will be removed when you save.
|
||||||
|
<button type="button" data-password-undo-remove class="ml-2 underline text-gray-400 hover:text-gray-200">Undo</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="remove_password" data-remove-password-flag value="">
|
||||||
|
<% } else { %>
|
||||||
|
<input name="password" type="password" placeholder="Leave blank for public access"
|
||||||
|
class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Custom CSS <span class="text-gray-600">(optional)</span></label>
|
||||||
|
<textarea name="custom_css" rows="4" placeholder=":root { --accent: #ff00ff; }"
|
||||||
|
class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 font-mono text-xs"><%= p.custom_css || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Footer text <span class="text-gray-600">(optional)</span></label>
|
||||||
|
<input name="footer_text" type="text" value="<%= p.footer_text || '' %>" placeholder="Contact us at support@example.com"
|
||||||
|
class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-primary px-6 py-2.5 text-sm"><%= it.isNew ? 'Create page' : 'Save changes' %></button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</main>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<%~ include('./partials/head', { title: 'Status pages' }) %>
|
||||||
|
<%~ include('./partials/nav', { nav: 'pages' }) %>
|
||||||
|
|
||||||
|
<main class="max-w-7xl mx-auto px-8 py-10 space-y-6">
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-semibold text-white">Status pages</h1>
|
||||||
|
<a href="/dashboard/pages/new" class="btn-primary inline-flex items-center gap-2 px-4 py-2 text-sm">+ New page</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (!it.pages || it.pages.length === 0) { %>
|
||||||
|
<div class="text-center py-20">
|
||||||
|
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gray-800/50 border border-border-subtle flex items-center justify-center">
|
||||||
|
<svg class="w-8 h-8 text-gray-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5a17.92 17.92 0 01-8.716-2.247m0 0A8.966 8.966 0 013 12c0-1.97.633-3.794 1.708-5.282"/></svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-500 mb-1">No status pages yet</p>
|
||||||
|
<p class="text-gray-600 text-sm mb-5">Create a public page so visitors can check your service status</p>
|
||||||
|
<a href="/dashboard/pages/new" class="btn-primary text-sm px-6 py-3 inline-block">Create your first page</a>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<% it.pages.forEach(function(p) { %>
|
||||||
|
<a href="/dashboard/pages/<%= p.id %>" class="block monitor-card rounded-xl p-4 group">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<span class="w-2.5 h-2.5 rounded-full shrink-0 dot-up"></span>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="font-medium text-gray-100 group-hover:text-white truncate"><%= p.title %></div>
|
||||||
|
<div class="text-xs text-gray-500 truncate">
|
||||||
|
<% if (p.custom_domain) { %>
|
||||||
|
<span class="text-blue-400"><%= p.custom_domain %></span>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="text-blue-400">pages.pingql.com/<%= p.slug %></span>
|
||||||
|
<% } %>
|
||||||
|
<% if (p.description) { %><span class="text-gray-600 mx-1.5">-</span><span><%= p.description %></span><% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 shrink-0 ml-4">
|
||||||
|
<div class="text-right hidden sm:block">
|
||||||
|
<div class="text-sm text-gray-300"><%= p.monitor_count %> monitor<%= p.monitor_count !== 1 ? 's' : '' %></div>
|
||||||
|
<div class="text-xs text-gray-500"><%~ it.timeAgoSSR(p.created_at) %></div>
|
||||||
|
</div>
|
||||||
|
<% if (p.protected) { %>
|
||||||
|
<span class="text-xs px-2 py-1 rounded bg-yellow-900/20 text-yellow-500 border border-yellow-800/30" title="Password protected">locked</span>
|
||||||
|
<% } %>
|
||||||
|
<form action="/dashboard/pages/<%= p.id %>/delete" method="POST" class="inline" onclick="event.stopPropagation(); event.preventDefault(); if(confirm('Delete status page \'<%= p.title %>\'? This cannot be undone.')) this.submit();">
|
||||||
|
<button type="button" class="px-3 py-1.5 rounded-lg border border-red-900/30 text-red-400 hover:bg-red-900/20 hover:border-red-800/40 text-xs transition-colors">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
</main>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user