feat: refactor stage 2

This commit is contained in:
2026-04-08 13:00:52 +04:00
parent 1f01a00ad6
commit 6adeeeb6ea
15 changed files with 551 additions and 16 deletions
+87 -3
View File
@@ -336,9 +336,80 @@ export const dashboard = new Elysia()
.get("/dashboard/monitors/new", async ({ cookie, headers }) => {
const resolved = await getAccountId(cookie, headers);
const accountId = resolved?.accountId ?? null;
const keyId = resolved?.keyId ?? null;
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 }) => {
@@ -370,8 +441,17 @@ export const dashboard = new Elysia()
SELECT * FROM pings WHERE monitor_id = ${params.id}
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 }) => {
@@ -445,6 +525,8 @@ export const dashboard = new Elysia()
max_retries: Number(b.max_retries) || 0,
retry_interval_s: Number(b.retry_interval_s) || 30,
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,
request_headers: Object.keys(requestHeaders).length ? requestHeaders : null,
request_body: b.request_body || null,
@@ -485,6 +567,8 @@ export const dashboard = new Elysia()
max_retries: Number(b.max_retries) || 0,
retry_interval_s: Number(b.retry_interval_s) || 30,
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,
request_headers: Object.keys(requestHeaders).length ? requestHeaders : null,
request_body: b.request_body || null,
+1 -1
View File
@@ -170,7 +170,7 @@
<!-- Edit form -->
<div class="card-static p-6">
<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>
</main>
+1 -1
View File
@@ -7,7 +7,7 @@
<h2 class="text-lg font-semibold text-gray-200 mt-2">Create Monitor</h2>
</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>
<script>
+78
View File
@@ -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),
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),
};
body.channel_ids = [...document.querySelectorAll('.' + prefix + 'channel-check:checked')].map(el => el.value);
if (Object.keys(headers).length) body.request_headers = headers;
else body.request_headers = null;
const rb = document.getElementById(prefix + 'request-body').value.trim();
@@ -114,6 +114,37 @@
</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
const selectedRegions = (monitor.regions && monitor.regions.length) ? monitor.regions : regions.map(r => r[0]);
+1
View File
@@ -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>
<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/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="/account/logout" class="hover:text-gray-300 transition-colors">Logout</a>
</div>