feat: refactor stage 2
This commit is contained in:
parent
1f01a00ad6
commit
6adeeeb6ea
|
|
@ -3,6 +3,7 @@ import { ingest } from "./routes/pings";
|
||||||
import { monitors } from "./routes/monitors";
|
import { monitors } from "./routes/monitors";
|
||||||
import { account } from "./routes/auth";
|
import { account } from "./routes/auth";
|
||||||
import { internal } from "./routes/internal";
|
import { internal } from "./routes/internal";
|
||||||
|
import { channels } from "./routes/channels";
|
||||||
import { migrate } from "./db";
|
import { migrate } from "./db";
|
||||||
import { SECURITY_HEADERS } from "../../shared/auth";
|
import { SECURITY_HEADERS } from "../../shared/auth";
|
||||||
|
|
||||||
|
|
@ -16,6 +17,7 @@ const elysia = new Elysia()
|
||||||
}))
|
}))
|
||||||
.use(account)
|
.use(account)
|
||||||
.use(monitors)
|
.use(monitors)
|
||||||
|
.use(channels)
|
||||||
.use(ingest)
|
.use(ingest)
|
||||||
.use(internal);
|
.use(internal);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
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> {
|
||||||
|
const channels = await sql<ChannelRow[]>`
|
||||||
|
SELECT c.id, c.account_id, c.name, c.kind, c.config, c.enabled
|
||||||
|
FROM notification_channels c
|
||||||
|
JOIN monitor_notifications mn ON mn.channel_id = c.id
|
||||||
|
WHERE mn.monitor_id = ${monitorId} AND c.enabled = true
|
||||||
|
`;
|
||||||
|
if (channels.length === 0) return;
|
||||||
|
await Promise.all(channels.map((c) => dispatch(c, event)));
|
||||||
|
}
|
||||||
|
|
||||||
|
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,112 @@
|
||||||
|
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()),
|
||||||
|
});
|
||||||
|
|
||||||
|
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, 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 [row] = await sql`
|
||||||
|
INSERT INTO notification_channels (account_id, name, kind, config, enabled)
|
||||||
|
VALUES (${accountId}, ${body.name}, ${body.kind}, ${sql.json(body.config)}, ${body.enabled ?? true})
|
||||||
|
RETURNING id, name, kind, config, enabled, 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 [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)
|
||||||
|
WHERE id = ${params.id} AND account_id = ${accountId}
|
||||||
|
RETURNING id, name, kind, config, enabled, 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"] } });
|
||||||
|
|
@ -15,10 +15,27 @@ const MonitorBody = t.Object({
|
||||||
max_retries: t.Optional(t.Number({ minimum: 0, maximum: 10, default: 0, description: "Retry a failing check up to N times before declaring DOWN" })),
|
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" })),
|
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." })),
|
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: 14, description: "Alert when TLS cert is within N days of expiry. 0 disables." })),
|
||||||
query: t.Optional(t.Any({ description: "PingQL query — filter conditions for up/down" })),
|
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." })),
|
regions: t.Optional(t.Array(t.String(), { description: "Regions to run checks from. Empty array = all regions." })),
|
||||||
|
channel_ids: t.Optional(t.Array(t.String(), { description: "Notification channel IDs to attach to this monitor." })),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function replaceMonitorChannels(monitorId: string, accountId: string, channelIds: string[]) {
|
||||||
|
await sql`DELETE FROM monitor_notifications WHERE monitor_id = ${monitorId}`;
|
||||||
|
if (channelIds.length === 0) return;
|
||||||
|
// Only attach channels that belong to the same account.
|
||||||
|
const owned = await sql<{ id: string }[]>`
|
||||||
|
SELECT id FROM notification_channels
|
||||||
|
WHERE account_id = ${accountId} AND id = ANY(${sql.array(channelIds)})
|
||||||
|
`;
|
||||||
|
if (owned.length === 0) return;
|
||||||
|
await sql`
|
||||||
|
INSERT INTO monitor_notifications (monitor_id, channel_id)
|
||||||
|
SELECT ${monitorId}, id FROM UNNEST(${sql.array(owned.map((o) => o.id))}::uuid[]) AS id
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
export const monitors = new Elysia({ prefix: "/monitors" })
|
export const monitors = new Elysia({ prefix: "/monitors" })
|
||||||
.use(requireAuth)
|
.use(requireAuth)
|
||||||
|
|
||||||
|
|
@ -50,7 +67,7 @@ export const monitors = new Elysia({ prefix: "/monitors" })
|
||||||
const ssrfError = await validateMonitorUrl(body.url);
|
const ssrfError = await validateMonitorUrl(body.url);
|
||||||
if (ssrfError) { set.status = 400; return { error: ssrfError }; }
|
if (ssrfError) { set.status = 400; return { error: ssrfError }; }
|
||||||
const [monitor] = await sql`
|
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, query, regions)
|
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, query, regions)
|
||||||
VALUES (
|
VALUES (
|
||||||
${accountId}, ${body.name}, ${body.url},
|
${accountId}, ${body.name}, ${body.url},
|
||||||
${(body.method ?? 'GET').toUpperCase()},
|
${(body.method ?? 'GET').toUpperCase()},
|
||||||
|
|
@ -61,11 +78,13 @@ export const monitors = new Elysia({ prefix: "/monitors" })
|
||||||
${body.max_retries ?? 0},
|
${body.max_retries ?? 0},
|
||||||
${body.retry_interval_s ?? 30},
|
${body.retry_interval_s ?? 30},
|
||||||
${body.resend_interval ?? 0},
|
${body.resend_interval ?? 0},
|
||||||
|
${body.cert_alert_days ?? 14},
|
||||||
${body.query ? sql.json(body.query) : null},
|
${body.query ? sql.json(body.query) : null},
|
||||||
${sql.array(regions)}
|
${sql.array(regions)}
|
||||||
)
|
)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
if (body.channel_ids) await replaceMonitorChannels(monitor.id, accountId, body.channel_ids);
|
||||||
return monitor;
|
return monitor;
|
||||||
}, { body: MonitorBody, detail: { summary: "Create monitor", tags: ["monitors"] } })
|
}, { body: MonitorBody, detail: { summary: "Create monitor", tags: ["monitors"] } })
|
||||||
|
|
||||||
|
|
@ -79,7 +98,10 @@ export const monitors = new Elysia({ prefix: "/monitors" })
|
||||||
SELECT * FROM pings WHERE monitor_id = ${params.id}
|
SELECT * FROM pings WHERE monitor_id = ${params.id}
|
||||||
ORDER BY checked_at DESC LIMIT 100
|
ORDER BY checked_at DESC LIMIT 100
|
||||||
`;
|
`;
|
||||||
return { ...monitor, results };
|
const channels = await sql<{ channel_id: string }[]>`
|
||||||
|
SELECT channel_id FROM monitor_notifications WHERE monitor_id = ${params.id}
|
||||||
|
`;
|
||||||
|
return { ...monitor, results, channel_ids: channels.map((c) => c.channel_id) };
|
||||||
}, { detail: { summary: "Get monitor with results", tags: ["monitors"] } })
|
}, { detail: { summary: "Get monitor with results", tags: ["monitors"] } })
|
||||||
|
|
||||||
.patch("/:id", async ({ accountId, plan, params, body, set }) => {
|
.patch("/:id", async ({ accountId, plan, params, body, set }) => {
|
||||||
|
|
@ -112,12 +134,14 @@ export const monitors = new Elysia({ prefix: "/monitors" })
|
||||||
max_retries = COALESCE(${body.max_retries ?? null}, max_retries),
|
max_retries = COALESCE(${body.max_retries ?? null}, max_retries),
|
||||||
retry_interval_s = COALESCE(${body.retry_interval_s ?? null}, retry_interval_s),
|
retry_interval_s = COALESCE(${body.retry_interval_s ?? null}, retry_interval_s),
|
||||||
resend_interval = COALESCE(${body.resend_interval ?? null}, resend_interval),
|
resend_interval = COALESCE(${body.resend_interval ?? null}, resend_interval),
|
||||||
|
cert_alert_days = COALESCE(${body.cert_alert_days ?? null}, cert_alert_days),
|
||||||
query = COALESCE(${body.query ? sql.json(body.query) : null}, query),
|
query = COALESCE(${body.query ? sql.json(body.query) : null}, query),
|
||||||
regions = COALESCE(${body.regions ? sql.array(body.regions) : null}, regions)
|
regions = COALESCE(${body.regions ? sql.array(body.regions) : null}, regions)
|
||||||
WHERE id = ${params.id} AND account_id = ${accountId}
|
WHERE id = ${params.id} AND account_id = ${accountId}
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
if (!monitor) { set.status = 404; return { error: "Not found" }; }
|
if (!monitor) { set.status = 404; return { error: "Not found" }; }
|
||||||
|
if (body.channel_ids) await replaceMonitorChannels(monitor.id, accountId, body.channel_ids);
|
||||||
return monitor;
|
return monitor;
|
||||||
}, { body: t.Partial(MonitorBody), detail: { summary: "Update monitor", tags: ["monitors"] } })
|
}, { body: t.Partial(MonitorBody), detail: { summary: "Update monitor", tags: ["monitors"] } })
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Elysia, t } from "elysia";
|
||||||
import sql from "../db";
|
import sql from "../db";
|
||||||
import { resolveKey } from "./auth";
|
import { resolveKey } from "./auth";
|
||||||
import { extractAuthKey, safeTokenCompare } from "../../../shared/auth";
|
import { extractAuthKey, safeTokenCompare } from "../../../shared/auth";
|
||||||
|
import { dispatchForMonitor, type NotificationEvent } from "../notifications";
|
||||||
|
|
||||||
type SSEController = ReadableStreamDefaultController<Uint8Array>;
|
type SSEController = ReadableStreamDefaultController<Uint8Array>;
|
||||||
const bus = new Map<string, Set<SSEController>>(); // keyed by accountId
|
const bus = new Map<string, Set<SSEController>>(); // keyed by accountId
|
||||||
|
|
@ -56,7 +57,7 @@ export const ingest = new Elysia()
|
||||||
if (!safeTokenCompare(token, process.env.MONITOR_TOKEN)) { set.status = 401; return { error: "Unauthorized" }; }
|
if (!safeTokenCompare(token, process.env.MONITOR_TOKEN)) { set.status = 401; return { error: "Unauthorized" }; }
|
||||||
|
|
||||||
const [monitor_check] = await sql`
|
const [monitor_check] = await sql`
|
||||||
SELECT id, account_id, last_state, consecutive_down, resend_interval
|
SELECT id, account_id, name, url, resend_interval, cert_alert_days
|
||||||
FROM monitors WHERE id = ${body.monitor_id}
|
FROM monitors WHERE id = ${body.monitor_id}
|
||||||
`;
|
`;
|
||||||
if (!monitor_check) { set.status = 404; return { error: "Monitor not found" }; }
|
if (!monitor_check) { set.status = 404; return { error: "Monitor not found" }; }
|
||||||
|
|
@ -71,11 +72,20 @@ export const ingest = new Elysia()
|
||||||
const scheduledAt = body.scheduled_at ? new Date(body.scheduled_at) : null;
|
const scheduledAt = body.scheduled_at ? new Date(body.scheduled_at) : null;
|
||||||
const jitterMs = body.jitter_ms ?? null;
|
const jitterMs = body.jitter_ms ?? null;
|
||||||
|
|
||||||
// Important-beat + resend bookkeeping. Important = transition or resend tick.
|
// Per-region transition state. Empty string = unspecified/single-region.
|
||||||
|
const region = body.region ?? '';
|
||||||
|
const [stateRow] = await sql`
|
||||||
|
SELECT last_state, consecutive_down, cert_alert_sent
|
||||||
|
FROM monitor_region_state
|
||||||
|
WHERE monitor_id = ${body.monitor_id} AND region = ${region}
|
||||||
|
`;
|
||||||
|
|
||||||
const newState = body.up ? 'up' : 'down';
|
const newState = body.up ? 'up' : 'down';
|
||||||
const prevState: string | null = monitor_check.last_state;
|
const prevState: string | null = stateRow?.last_state ?? null;
|
||||||
let consecutiveDown: number = monitor_check.consecutive_down ?? 0;
|
let consecutiveDown: number = stateRow?.consecutive_down ?? 0;
|
||||||
|
let certAlertSent: boolean = stateRow?.cert_alert_sent ?? false;
|
||||||
const resendInterval: number = monitor_check.resend_interval ?? 0;
|
const resendInterval: number = monitor_check.resend_interval ?? 0;
|
||||||
|
const certAlertDays: number = monitor_check.cert_alert_days ?? 0;
|
||||||
let important = false;
|
let important = false;
|
||||||
|
|
||||||
if (prevState !== newState) {
|
if (prevState !== newState) {
|
||||||
|
|
@ -90,10 +100,26 @@ export const ingest = new Elysia()
|
||||||
consecutiveDown = 0;
|
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`
|
await sql`
|
||||||
UPDATE monitors
|
INSERT INTO monitor_region_state (monitor_id, region, last_state, consecutive_down, cert_alert_sent, updated_at)
|
||||||
SET last_state = ${newState}, consecutive_down = ${consecutiveDown}
|
VALUES (${body.monitor_id}, ${region}, ${newState}, ${consecutiveDown}, ${certAlertSent}, now())
|
||||||
WHERE id = ${body.monitor_id}
|
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`
|
const [ping] = await sql`
|
||||||
|
|
@ -121,6 +147,33 @@ export const ingest = new Elysia()
|
||||||
|
|
||||||
publish(monitor_check.account_id, ping);
|
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)) {
|
||||||
|
void dispatchForMonitor(monitor_check.id, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (certEvent && days != null) {
|
||||||
|
void dispatchForMonitor(monitor_check.id, { kind: "cert", monitor: monitorCtx, days });
|
||||||
|
}
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}, {
|
}, {
|
||||||
body: t.Object({
|
body: t.Object({
|
||||||
|
|
|
||||||
|
|
@ -72,10 +72,48 @@ export async function migrate(sql: any) {
|
||||||
await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS max_retries INTEGER NOT NULL DEFAULT 0`;
|
await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS max_retries INTEGER NOT NULL DEFAULT 0`;
|
||||||
await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS retry_interval_s INTEGER NOT NULL DEFAULT 30`;
|
await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS retry_interval_s INTEGER NOT NULL DEFAULT 30`;
|
||||||
await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS resend_interval INTEGER NOT NULL DEFAULT 0`;
|
await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS resend_interval INTEGER NOT NULL DEFAULT 0`;
|
||||||
await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS consecutive_down INTEGER NOT NULL DEFAULT 0`;
|
await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS cert_alert_days INTEGER NOT NULL DEFAULT 14`;
|
||||||
await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS last_state TEXT`;
|
|
||||||
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS important BOOLEAN NOT NULL DEFAULT false`;
|
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS important BOOLEAN NOT NULL DEFAULT false`;
|
||||||
|
|
||||||
|
// Per-region transition state. region='' for unspecified/single-region monitors.
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
// Drop the now-stale per-monitor state columns from the previous slice.
|
||||||
|
await sql`ALTER TABLE monitors DROP COLUMN IF EXISTS last_state`;
|
||||||
|
await sql`ALTER TABLE monitors DROP COLUMN IF EXISTS consecutive_down`;
|
||||||
|
|
||||||
|
// Notifications: modular providers, webhook only for now.
|
||||||
|
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,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`CREATE INDEX IF NOT EXISTS idx_notification_channels_account ON notification_channels(account_id)`;
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS monitor_notifications (
|
||||||
|
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
||||||
|
channel_id UUID NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (monitor_id, channel_id)
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`CREATE INDEX IF NOT EXISTS idx_monitor_notifications_channel ON monitor_notifications(channel_id)`;
|
||||||
|
|
||||||
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_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 INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -336,9 +336,80 @@ export const dashboard = new Elysia()
|
||||||
.get("/dashboard/monitors/new", async ({ cookie, headers }) => {
|
.get("/dashboard/monitors/new", async ({ cookie, headers }) => {
|
||||||
const resolved = await getAccountId(cookie, headers);
|
const resolved = await getAccountId(cookie, headers);
|
||||||
const accountId = resolved?.accountId ?? null;
|
const accountId = resolved?.accountId ?? null;
|
||||||
const keyId = resolved?.keyId ?? null;
|
|
||||||
if (!accountId) return redirect("/dashboard");
|
if (!accountId) return redirect("/dashboard");
|
||||||
return html("new", { nav: "monitors", plan: resolved?.plan || "free" });
|
const channels = await sql`
|
||||||
|
SELECT id, name, kind FROM notification_channels
|
||||||
|
WHERE account_id = ${accountId} AND enabled = true
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`;
|
||||||
|
return html("new", { nav: "monitors", plan: resolved?.plan || "free", channels });
|
||||||
|
})
|
||||||
|
|
||||||
|
.get("/dashboard/notifications", async ({ cookie, headers, query }) => {
|
||||||
|
const resolved = await getAccountId(cookie, headers);
|
||||||
|
if (!resolved?.accountId) return redirect("/dashboard");
|
||||||
|
const channels = await sql`
|
||||||
|
SELECT id, name, kind, config, enabled, created_at
|
||||||
|
FROM notification_channels
|
||||||
|
WHERE account_id = ${resolved.accountId}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`;
|
||||||
|
const testResult = query.test === "ok" ? { ok: true } : query.test_error ? { ok: false, error: String(query.test_error) } : null;
|
||||||
|
return html("notifications", { nav: "notifications", channels, testResult });
|
||||||
|
})
|
||||||
|
|
||||||
|
.post("/dashboard/notifications/new", async ({ cookie, headers, body }) => {
|
||||||
|
const resolved = await getAccountId(cookie, headers);
|
||||||
|
if (!resolved?.accountId) return redirect("/dashboard");
|
||||||
|
const b = body as any;
|
||||||
|
const kind = b.kind || "webhook";
|
||||||
|
const config: any = {};
|
||||||
|
if (kind === "webhook") {
|
||||||
|
config.url = (b.url || "").trim();
|
||||||
|
if (b.secret) config.secret = b.secret;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
||||||
|
const key = cookie?.pingql_key?.value;
|
||||||
|
await fetch(`${apiUrl}/notifications/channels/`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` },
|
||||||
|
body: JSON.stringify({ name: (b.name || "").trim(), kind, config }),
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
return redirect("/dashboard/notifications");
|
||||||
|
})
|
||||||
|
|
||||||
|
.post("/dashboard/notifications/:id/delete", async ({ cookie, headers, params }) => {
|
||||||
|
const resolved = await getAccountId(cookie, headers);
|
||||||
|
if (!resolved?.accountId) return redirect("/dashboard");
|
||||||
|
try {
|
||||||
|
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
||||||
|
const key = cookie?.pingql_key?.value;
|
||||||
|
await fetch(`${apiUrl}/notifications/channels/${params.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "Authorization": `Bearer ${key}` },
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
return redirect("/dashboard/notifications");
|
||||||
|
})
|
||||||
|
|
||||||
|
.post("/dashboard/notifications/:id/test", async ({ cookie, headers, params }) => {
|
||||||
|
const resolved = await getAccountId(cookie, headers);
|
||||||
|
if (!resolved?.accountId) return redirect("/dashboard");
|
||||||
|
try {
|
||||||
|
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
||||||
|
const key = cookie?.pingql_key?.value;
|
||||||
|
const res = await fetch(`${apiUrl}/notifications/channels/${params.id}/test`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Authorization": `Bearer ${key}` },
|
||||||
|
});
|
||||||
|
if (res.ok) return redirect("/dashboard/notifications?test=ok");
|
||||||
|
const data: any = await res.json().catch(() => ({}));
|
||||||
|
return redirect(`/dashboard/notifications?test_error=${encodeURIComponent(data.error || res.statusText)}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
return redirect(`/dashboard/notifications?test_error=${encodeURIComponent(e?.message || "request failed")}`);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
.get("/dashboard/home/data", async ({ cookie, headers }) => {
|
.get("/dashboard/home/data", async ({ cookie, headers }) => {
|
||||||
|
|
@ -370,8 +441,17 @@ export const dashboard = new Elysia()
|
||||||
SELECT * FROM pings WHERE monitor_id = ${params.id}
|
SELECT * FROM pings WHERE monitor_id = ${params.id}
|
||||||
ORDER BY checked_at DESC LIMIT 200
|
ORDER BY checked_at DESC LIMIT 200
|
||||||
`;
|
`;
|
||||||
|
const channels = await sql`
|
||||||
|
SELECT id, name, kind FROM notification_channels
|
||||||
|
WHERE account_id = ${accountId} AND enabled = true
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`;
|
||||||
|
const attached = await sql<{ channel_id: string }[]>`
|
||||||
|
SELECT channel_id FROM monitor_notifications WHERE monitor_id = ${params.id}
|
||||||
|
`;
|
||||||
|
monitor.channel_ids = attached.map((a) => a.channel_id);
|
||||||
|
|
||||||
return html("detail", { nav: "monitors", monitor, pings, plan: resolved?.plan || "free" });
|
return html("detail", { nav: "monitors", monitor, pings, plan: resolved?.plan || "free", channels });
|
||||||
})
|
})
|
||||||
|
|
||||||
.get("/dashboard/monitors/:id/chart", async ({ cookie, headers, params }) => {
|
.get("/dashboard/monitors/:id/chart", async ({ cookie, headers, params }) => {
|
||||||
|
|
@ -445,6 +525,8 @@ export const dashboard = new Elysia()
|
||||||
max_retries: Number(b.max_retries) || 0,
|
max_retries: Number(b.max_retries) || 0,
|
||||||
retry_interval_s: Number(b.retry_interval_s) || 30,
|
retry_interval_s: Number(b.retry_interval_s) || 30,
|
||||||
resend_interval: Number(b.resend_interval) || 0,
|
resend_interval: Number(b.resend_interval) || 0,
|
||||||
|
cert_alert_days: b.cert_alert_days != null ? Number(b.cert_alert_days) : 14,
|
||||||
|
channel_ids: Array.isArray(b.channel_ids) ? b.channel_ids : (b.channel_ids ? [b.channel_ids] : []),
|
||||||
regions,
|
regions,
|
||||||
request_headers: Object.keys(requestHeaders).length ? requestHeaders : null,
|
request_headers: Object.keys(requestHeaders).length ? requestHeaders : null,
|
||||||
request_body: b.request_body || null,
|
request_body: b.request_body || null,
|
||||||
|
|
@ -485,6 +567,8 @@ export const dashboard = new Elysia()
|
||||||
max_retries: Number(b.max_retries) || 0,
|
max_retries: Number(b.max_retries) || 0,
|
||||||
retry_interval_s: Number(b.retry_interval_s) || 30,
|
retry_interval_s: Number(b.retry_interval_s) || 30,
|
||||||
resend_interval: Number(b.resend_interval) || 0,
|
resend_interval: Number(b.resend_interval) || 0,
|
||||||
|
cert_alert_days: b.cert_alert_days != null ? Number(b.cert_alert_days) : 14,
|
||||||
|
channel_ids: Array.isArray(b.channel_ids) ? b.channel_ids : (b.channel_ids ? [b.channel_ids] : []),
|
||||||
regions,
|
regions,
|
||||||
request_headers: Object.keys(requestHeaders).length ? requestHeaders : null,
|
request_headers: Object.keys(requestHeaders).length ? requestHeaders : null,
|
||||||
request_body: b.request_body || null,
|
request_body: b.request_body || null,
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,7 @@
|
||||||
<!-- Edit form -->
|
<!-- Edit form -->
|
||||||
<div class="card-static p-6">
|
<div class="card-static p-6">
|
||||||
<h3 class="text-sm text-gray-400 mb-4">Edit Monitor</h3>
|
<h3 class="text-sm text-gray-400 mb-4">Edit Monitor</h3>
|
||||||
<%~ include('./partials/monitor-form', { _form: { monitor: m, isEdit: true, prefix: 'edit-', bg: 'bg-gray-800/50', border: 'border-border-subtle' }, plan: it.plan }) %>
|
<%~ include('./partials/monitor-form', { _form: { monitor: m, isEdit: true, prefix: 'edit-', bg: 'bg-gray-800/50', border: 'border-border-subtle' }, plan: it.plan, regions: it.regions, channels: it.channels }) %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
<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>
|
||||||
|
|
||||||
<%~ include('./partials/monitor-form', { _form: { monitor: {}, isEdit: false, prefix: '', bg: 'bg-surface-solid', border: 'border-border-subtle' }, plan: it.plan }) %>
|
<%~ include('./partials/monitor-form', { _form: { monitor: {}, isEdit: false, prefix: '', bg: 'bg-surface-solid', border: 'border-border-subtle' }, plan: it.plan, regions: it.regions, channels: it.channels }) %>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
<%~ 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). Webhooks POST a JSON payload to your URL.
|
||||||
|
More providers (Discord, Slack, Email, Telegram) will land here as drop-ins.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<% 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">
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
<button type="submit" class="btn-primary px-6 py-2.5 text-sm">Create channel</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
@ -46,7 +46,9 @@
|
||||||
max_retries: Number(document.getElementById(prefix + 'max-retries').value),
|
max_retries: Number(document.getElementById(prefix + 'max-retries').value),
|
||||||
retry_interval_s: Number(document.getElementById(prefix + 'retry-interval').value),
|
retry_interval_s: Number(document.getElementById(prefix + 'retry-interval').value),
|
||||||
resend_interval: Number(document.getElementById(prefix + 'resend-interval').value),
|
resend_interval: Number(document.getElementById(prefix + 'resend-interval').value),
|
||||||
|
cert_alert_days: Number(document.getElementById(prefix + 'cert-alert-days').value),
|
||||||
};
|
};
|
||||||
|
body.channel_ids = [...document.querySelectorAll('.' + prefix + 'channel-check:checked')].map(el => el.value);
|
||||||
if (Object.keys(headers).length) body.request_headers = headers;
|
if (Object.keys(headers).length) body.request_headers = headers;
|
||||||
else body.request_headers = null;
|
else body.request_headers = null;
|
||||||
const rb = document.getElementById(prefix + 'request-body').value.trim();
|
const rb = document.getElementById(prefix + 'request-body').value.trim();
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,37 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<% [['0','Disabled'],['7','7 days before expiry'],['14','14 days before expiry'],['30','30 days before expiry'],['60','60 days before expiry']].forEach(function([val, label]) { %>
|
||||||
|
<option value="<%= val %>" <%= String(monitor.cert_alert_days ?? '14') === val ? 'selected' : '' %>><%= label %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%
|
||||||
|
const channels = it.channels || [];
|
||||||
|
const attached = (monitor.channel_ids && monitor.channel_ids.length) ? monitor.channel_ids : [];
|
||||||
|
%>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Notification channels <span class="text-gray-600">(optional)</span></label>
|
||||||
|
<% if (channels.length === 0) { %>
|
||||||
|
<p class="text-xs text-gray-600">No channels yet. <a href="/dashboard/notifications" class="text-blue-400 hover:text-blue-300">Create one</a> to get alerted on transitions.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<% channels.forEach(function(c) { %>
|
||||||
|
<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="channel_ids" value="<%= c.id %>" class="<%= prefix %>channel-check accent-blue-500" <%= attached.includes(c.id) ? 'checked' : '' %>>
|
||||||
|
<span class="text-sm text-gray-300"><%= c.name %></span>
|
||||||
|
<span class="text-xs text-gray-600 font-mono"><%= c.kind %></span>
|
||||||
|
</label>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
<%
|
<%
|
||||||
// Default to all regions if none selected
|
// Default to all regions if none selected
|
||||||
const selectedRegions = (monitor.regions && monitor.regions.length) ? monitor.regions : regions.map(r => r[0]);
|
const selectedRegions = (monitor.regions && monitor.regions.length) ? monitor.regions : regions.map(r => r[0]);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
<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>
|
<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">
|
||||||
<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>
|
<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>
|
||||||
|
<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>
|
<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>
|
||||||
<a href="/account/logout" class="hover:text-gray-300 transition-colors">Logout</a>
|
<a href="/account/logout" class="hover:text-gray-300 transition-colors">Logout</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue