Initial scaffold: web API (Bun/Elysia) + monitor (Rust/Tokio)
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import postgres from "postgres";
|
||||
|
||||
const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@localhost:5432/pingql");
|
||||
|
||||
export default sql;
|
||||
|
||||
// Run migrations on startup
|
||||
export async function migrate() {
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY, -- random 16-digit key
|
||||
email_hash TEXT, -- optional, for recovery only
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
)
|
||||
`;
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS monitors (
|
||||
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
interval_s INTEGER NOT NULL DEFAULT 60, -- check interval in seconds
|
||||
query JSONB, -- pingql query filter
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
)
|
||||
`;
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS check_results (
|
||||
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 -- headers, body snippet, etc.
|
||||
)
|
||||
`;
|
||||
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_results_monitor ON check_results(monitor_id, checked_at DESC)`;
|
||||
|
||||
console.log("DB ready");
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { cors } from "@elysiajs/cors";
|
||||
import { swagger } from "@elysiajs/swagger";
|
||||
import { checks } from "./routes/checks";
|
||||
import { monitors } from "./routes/monitors";
|
||||
import { auth } from "./routes/auth";
|
||||
import { internal } from "./routes/internal";
|
||||
import { migrate } from "./db";
|
||||
|
||||
await migrate();
|
||||
|
||||
const app = new Elysia()
|
||||
.use(cors())
|
||||
.use(swagger({ path: "/docs", documentation: { info: { title: "PingQL API", version: "0.1.0" } } }))
|
||||
.get("/", () => ({ name: "PingQL", version: "0.1.0", docs: "/docs" }))
|
||||
.use(auth)
|
||||
.use(monitors)
|
||||
.use(checks)
|
||||
.use(internal)
|
||||
.listen(3000);
|
||||
|
||||
console.log(`PingQL running at http://localhost:${app.server?.port}`);
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { randomBytes, createHash } from "crypto";
|
||||
import sql from "../db";
|
||||
|
||||
// Generate a memorable 16-digit account key: XXXX-XXXX-XXXX-XXXX
|
||||
function generateAccountKey(): string {
|
||||
const bytes = randomBytes(8);
|
||||
const hex = bytes.toString("hex").toUpperCase();
|
||||
return `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`;
|
||||
}
|
||||
|
||||
// Middleware: validate account key from Authorization header
|
||||
export function requireAuth(app: Elysia) {
|
||||
return app.derive(async ({ headers, error }) => {
|
||||
const key = headers["authorization"]?.replace("Bearer ", "").trim();
|
||||
if (!key) return error(401, { error: "Missing account key. Use: Authorization: Bearer <key>" });
|
||||
|
||||
const [account] = await sql`SELECT id FROM accounts WHERE id = ${key}`;
|
||||
if (!account) return error(401, { error: "Invalid account key" });
|
||||
|
||||
return { accountId: account.id };
|
||||
});
|
||||
}
|
||||
|
||||
export const auth = new Elysia({ prefix: "/auth" })
|
||||
// Create a new account — no email required
|
||||
.post("/register", async ({ body }) => {
|
||||
const key = generateAccountKey();
|
||||
const emailHash = body.email
|
||||
? createHash("sha256").update(body.email.toLowerCase().trim()).digest("hex")
|
||||
: null;
|
||||
|
||||
await sql`INSERT INTO accounts (id, email_hash) VALUES (${key}, ${emailHash})`;
|
||||
|
||||
return {
|
||||
key,
|
||||
message: "Save this key — it's your only credential. We don't store it.",
|
||||
...(body.email ? { email_registered: true } : { email_registered: false }),
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
email: t.Optional(t.String({ format: "email", description: "Optional. Only used for account recovery." })),
|
||||
}),
|
||||
detail: { summary: "Create account", tags: ["auth"] },
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { requireAuth } from "./auth";
|
||||
import sql from "../db";
|
||||
|
||||
export const checks = new Elysia({ prefix: "/checks" })
|
||||
.use(requireAuth)
|
||||
|
||||
// Get recent results for a monitor
|
||||
.get("/:monitorId", async ({ accountId, params, query, error }) => {
|
||||
// Verify ownership
|
||||
const [monitor] = await sql`
|
||||
SELECT id FROM monitors WHERE id = ${params.monitorId} 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 check_results
|
||||
WHERE monitor_id = ${params.monitorId}
|
||||
ORDER BY checked_at DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
}, { detail: { summary: "Get check history", tags: ["checks"] } })
|
||||
|
||||
// Internal endpoint: monitor runner posts results here
|
||||
.post("/ingest", async ({ body, headers, error }) => {
|
||||
const token = headers["x-monitor-token"];
|
||||
if (token !== process.env.MONITOR_TOKEN) return error(401, { error: "Unauthorized" });
|
||||
|
||||
await sql`
|
||||
INSERT INTO check_results (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},
|
||||
${body.meta ? sql.json(body.meta) : null}
|
||||
)
|
||||
`;
|
||||
return { ok: true };
|
||||
}, {
|
||||
detail: { summary: "Ingest check result (monitor runner only)", tags: ["internal"] },
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/// 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 const internal = new Elysia({ prefix: "/internal" })
|
||||
.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.interval_s, m.query
|
||||
FROM monitors m
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT checked_at FROM check_results
|
||||
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)
|
||||
`;
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { requireAuth } from "./auth";
|
||||
import sql from "../db";
|
||||
|
||||
const MonitorBody = t.Object({
|
||||
name: t.String({ description: "Human-readable name" }),
|
||||
url: t.String({ format: "uri", description: "URL to check" }),
|
||||
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 }) => {
|
||||
const [monitor] = await sql`
|
||||
INSERT INTO monitors (account_id, name, url, interval_s, query)
|
||||
VALUES (${accountId}, ${body.name}, ${body.url}, ${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 check_results 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 }) => {
|
||||
const [monitor] = await sql`
|
||||
UPDATE monitors SET
|
||||
name = COALESCE(${body.name ?? null}, name),
|
||||
url = COALESCE(${body.url ?? null}, url),
|
||||
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"] } });
|
||||
Reference in New Issue
Block a user