feat: cookie-based auth, SSR dashboard, JS-optional login
This commit is contained in:
parent
8e4cb84599
commit
ef56b47b09
|
|
@ -1,41 +1,27 @@
|
|||
// PingQL Dashboard — shared utilities
|
||||
// Auth is now cookie-based. No localStorage needed.
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
function getAccountKey() {
|
||||
return localStorage.getItem('pingql_key');
|
||||
}
|
||||
|
||||
function setAccountKey(key) {
|
||||
localStorage.setItem('pingql_key', key);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('pingql_key');
|
||||
window.location.href = '/dashboard';
|
||||
window.location.href = '/dashboard/logout';
|
||||
}
|
||||
|
||||
function requireAuth() {
|
||||
if (!getAccountKey()) {
|
||||
window.location.href = '/dashboard';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// requireAuth is a no-op now — server redirects to /dashboard if not authed
|
||||
function requireAuth() { return true; }
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const key = getAccountKey();
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...opts,
|
||||
credentials: 'same-origin', // send cookie automatically
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(key ? { Authorization: `Bearer ${key}` } : {}),
|
||||
...opts.headers,
|
||||
},
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
logout();
|
||||
window.location.href = '/dashboard';
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
const data = await res.json();
|
||||
|
|
@ -97,15 +83,12 @@ function escapeHtml(str) {
|
|||
// Subscribe to live ping updates for a monitor via SSE (fetch-based for auth header support)
|
||||
// Returns an AbortController — call .abort() to close
|
||||
function watchMonitor(monitorId, onPing) {
|
||||
const key = localStorage.getItem('pingql_key');
|
||||
if (!key) return null;
|
||||
|
||||
const ac = new AbortController();
|
||||
|
||||
async function connect() {
|
||||
try {
|
||||
const res = await fetch(`/monitors/${monitorId}/stream`, {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
credentials: 'same-origin',
|
||||
signal: ac.signal,
|
||||
});
|
||||
if (!res.ok || !res.body) return;
|
||||
|
|
|
|||
|
|
@ -3,13 +3,9 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PingQL — Login</title>
|
||||
<title>PingQL — Sign In</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body { font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace; background: #0a0a0a; }
|
||||
.glow { box-shadow: 0 0 40px rgba(59, 130, 246, 0.08); }
|
||||
.key-display { letter-spacing: 0.15em; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/dashboard/app.css">
|
||||
</head>
|
||||
<body class="bg-[#0a0a0a] text-gray-100 min-h-screen flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md">
|
||||
|
|
@ -18,17 +14,23 @@
|
|||
<p class="text-gray-500 text-sm mt-2">Uptime monitoring for developers</p>
|
||||
</div>
|
||||
|
||||
<!-- Login form -->
|
||||
<div id="screen-login" class="bg-gray-900 rounded-xl p-6 glow border border-gray-800">
|
||||
<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)">
|
||||
|
||||
<!-- Sign in form — works with or without JS -->
|
||||
<div id="screen-login">
|
||||
<form id="login-form" action="/account/login" method="POST">
|
||||
<input type="hidden" name="_form" value="1">
|
||||
<label class="block text-xs text-gray-500 uppercase tracking-wider mb-2">Account Key</label>
|
||||
<input id="key-input" type="text" placeholder="XXXX-XXXX-XXXX-XXXX"
|
||||
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 key-display text-center text-lg"
|
||||
maxlength="19" autocomplete="off" spellping="false">
|
||||
<button id="login-btn"
|
||||
<input id="key-input" name="key" type="text" placeholder="XXXX-XXXX-XXXX-XXXX" 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 tracking-widest text-center text-lg font-mono"
|
||||
maxlength="19">
|
||||
<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">
|
||||
Sign In
|
||||
</button>
|
||||
<div id="login-error" class="text-red-400 text-sm mt-3 text-center hidden"></div>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 pt-5 border-t border-gray-800 text-center">
|
||||
<p class="text-gray-500 text-sm mb-3">No account?</p>
|
||||
<button id="register-btn"
|
||||
|
|
@ -38,38 +40,34 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Post-registration screen: show key + optional email -->
|
||||
<!-- Post-registration: show new key -->
|
||||
<div id="screen-new-account" class="hidden">
|
||||
<div class="bg-gray-900 rounded-xl p-6 glow border border-gray-800">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<div class="w-8 h-8 rounded-full bg-green-500/20 flex items-center justify-center text-green-400 text-lg">✓</div>
|
||||
<div>
|
||||
<p class="font-semibold text-white">Account created</p>
|
||||
<p class="text-xs text-gray-500">Save your key — it's the only way to log in</p>
|
||||
<p class="text-xs text-gray-500">Save your key — it's how you access your account</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="block text-xs text-gray-500 uppercase tracking-wider mb-2">Your Account Key</label>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 mb-5">
|
||||
<div id="new-key-display"
|
||||
class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-blue-400 key-display text-center text-lg font-bold select-all"></div>
|
||||
class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-blue-400 tracking-widest text-center text-lg font-bold font-mono select-all"></div>
|
||||
<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">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Optional email -->
|
||||
<div class="mt-6 pt-5 border-t border-gray-800">
|
||||
<label class="block text-xs text-gray-500 uppercase tracking-wider mb-1">Email <span class="text-gray-600 normal-case">(optional)</span></label>
|
||||
<p class="text-xs text-gray-600 mb-3">Used for account recovery only. Never shared.</p>
|
||||
<div class="pt-5 border-t border-gray-800">
|
||||
<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"
|
||||
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">
|
||||
<div class="flex gap-2 mt-3">
|
||||
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">
|
||||
<div class="flex gap-2">
|
||||
<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">
|
||||
Save Email & Continue
|
||||
Save & Continue
|
||||
</button>
|
||||
<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">
|
||||
|
|
@ -86,86 +84,68 @@
|
|||
const API = '';
|
||||
let newKey = null;
|
||||
|
||||
// Already logged in?
|
||||
if (localStorage.getItem('pingql_key')) {
|
||||
window.location.href = '/dashboard/home';
|
||||
}
|
||||
|
||||
// --- Login ---
|
||||
// Auto-format key input
|
||||
const keyInput = document.getElementById('key-input');
|
||||
document.getElementById('login-btn').addEventListener('click', login);
|
||||
keyInput.addEventListener('keydown', e => { if (e.key === 'Enter') login(); });
|
||||
|
||||
if (keyInput) {
|
||||
keyInput.addEventListener('input', e => {
|
||||
let v = e.target.value.replace(/[^a-zA-Z0-9]/g, '').toUpperCase().slice(0, 16);
|
||||
const parts = v.match(/.{1,4}/g) || [];
|
||||
e.target.value = parts.join('-');
|
||||
e.target.value = (v.match(/.{1,4}/g) || []).join('-');
|
||||
});
|
||||
|
||||
async function login() {
|
||||
const key = keyInput.value.trim();
|
||||
if (key.length < 19) return showError('Enter a valid account key');
|
||||
setLoading('login-btn', true, 'Verifying...');
|
||||
try {
|
||||
const res = await fetch(`${API}/monitors/`, { headers: { Authorization: `Bearer ${key}` } });
|
||||
if (res.status === 401) return showError('Invalid account key');
|
||||
localStorage.setItem('pingql_key', key);
|
||||
window.location.href = '/dashboard/home';
|
||||
} catch { showError('Connection error'); }
|
||||
finally { setLoading('login-btn', false, 'Sign In'); }
|
||||
}
|
||||
|
||||
// --- Register ---
|
||||
document.getElementById('register-btn').addEventListener('click', async () => {
|
||||
setLoading('register-btn', true, 'Creating...');
|
||||
// JS-enhanced login (overrides form POST for better UX)
|
||||
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const key = keyInput.value.trim();
|
||||
if (key.length < 19) return showError('Enter a valid account key');
|
||||
try {
|
||||
const res = await fetch(`${API}/account/register`, {
|
||||
const res = await fetch('/account/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
body: JSON.stringify({ key }),
|
||||
});
|
||||
if (!res.ok) return showError('Invalid account key');
|
||||
window.location.href = '/dashboard/home';
|
||||
} catch { showError('Connection error'); }
|
||||
});
|
||||
|
||||
// Register
|
||||
document.getElementById('register-btn').addEventListener('click', async () => {
|
||||
try {
|
||||
const res = await fetch('/account/register', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.key) return showError(data.error || 'Failed to create account');
|
||||
|
||||
if (!res.ok || !data.key) return showError(data.error || 'Failed');
|
||||
newKey = data.key;
|
||||
localStorage.setItem('pingql_key', newKey);
|
||||
|
||||
// Switch to new account screen
|
||||
// Set cookie via login endpoint
|
||||
await fetch('/account/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: newKey }),
|
||||
});
|
||||
|
||||
document.getElementById('screen-login').classList.add('hidden');
|
||||
document.getElementById('screen-new-account').classList.remove('hidden');
|
||||
document.getElementById('new-key-display').textContent = newKey;
|
||||
} catch { showError('Connection error'); }
|
||||
finally { setLoading('register-btn', false, 'Create Account'); }
|
||||
});
|
||||
|
||||
// --- Copy key ---
|
||||
document.getElementById('copy-key-btn').addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(newKey).then(() => {
|
||||
const btn = document.getElementById('copy-key-btn');
|
||||
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'); }, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Save email ---
|
||||
document.getElementById('save-email-btn').addEventListener('click', async () => {
|
||||
const email = document.getElementById('email-input').value.trim();
|
||||
if (!email) return document.getElementById('skip-email-btn').click();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
document.getElementById('email-error').textContent = 'Enter a valid email address';
|
||||
document.getElementById('email-error').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
setLoading('save-email-btn', true, 'Saving...');
|
||||
if (!email) { document.getElementById('skip-email-btn').click(); return; }
|
||||
try {
|
||||
await fetch(`${API}/account/email`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${newKey}` },
|
||||
body: JSON.stringify({ email }),
|
||||
await fetch('/account/email', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }),
|
||||
});
|
||||
} catch { /* non-critical, continue anyway */ }
|
||||
finally { setLoading('save-email-btn', false, 'Save Email & Continue'); }
|
||||
} catch {}
|
||||
window.location.href = '/dashboard/home';
|
||||
});
|
||||
|
||||
|
|
@ -173,17 +153,9 @@
|
|||
window.location.href = '/dashboard/home';
|
||||
});
|
||||
|
||||
// --- Helpers ---
|
||||
function showError(msg) {
|
||||
const el = document.getElementById('login-error');
|
||||
el.textContent = msg;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function setLoading(id, loading, label) {
|
||||
const btn = document.getElementById(id);
|
||||
btn.disabled = loading;
|
||||
btn.textContent = label;
|
||||
el.textContent = msg; el.classList.remove('hidden');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -12,23 +12,38 @@ function hashEmail(email: string): string {
|
|||
return createHash("sha256").update(email.toLowerCase().trim()).digest("hex");
|
||||
}
|
||||
|
||||
async function resolveKey(key: string): Promise<{ accountId: string; keyId: string | null } | null> {
|
||||
const [account] = await sql`SELECT id FROM accounts WHERE id = ${key}`;
|
||||
if (account) return { accountId: account.id, keyId: null };
|
||||
|
||||
const [apiKey] = await sql`SELECT id, account_id FROM api_keys WHERE id = ${key}`;
|
||||
if (apiKey) {
|
||||
sql`UPDATE api_keys SET last_used_at = now() WHERE id = ${key}`.catch(() => {});
|
||||
return { accountId: apiKey.account_id, keyId: apiKey.id };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Exported for SSR use in dashboard route
|
||||
export { resolveKey };
|
||||
|
||||
export function requireAuth(app: Elysia) {
|
||||
return app
|
||||
.derive(async ({ headers, set }) => {
|
||||
const key = headers["authorization"]?.replace("Bearer ", "").trim();
|
||||
.derive(async ({ headers, cookie, set }) => {
|
||||
// 1. Bearer token (API clients)
|
||||
const bearer = headers["authorization"]?.replace("Bearer ", "").trim();
|
||||
// 2. Cookie (dashboard / SSR)
|
||||
const cookieKey = cookie?.pingql_key?.value;
|
||||
|
||||
const key = bearer || cookieKey;
|
||||
if (!key) {
|
||||
set.status = 401;
|
||||
return { accountId: null as string | null, keyId: null as string | null };
|
||||
}
|
||||
|
||||
const [account] = await sql`SELECT id FROM accounts WHERE id = ${key}`;
|
||||
if (account) return { accountId: account.id as string, keyId: null as string | null };
|
||||
|
||||
const [apiKey] = await sql`SELECT id, account_id FROM api_keys WHERE id = ${key}`;
|
||||
if (apiKey) {
|
||||
sql`UPDATE api_keys SET last_used_at = now() WHERE id = ${key}`.catch(() => {});
|
||||
return { accountId: apiKey.account_id as string, keyId: apiKey.id as string };
|
||||
}
|
||||
const resolved = await resolveKey(key);
|
||||
if (resolved) return { accountId: resolved.accountId, keyId: resolved.keyId };
|
||||
|
||||
set.status = 401;
|
||||
return { accountId: null as string | null, keyId: null as string | null };
|
||||
|
|
@ -41,8 +56,42 @@ export function requireAuth(app: Elysia) {
|
|||
});
|
||||
}
|
||||
|
||||
const COOKIE_OPTS = {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 365, // 1 year
|
||||
};
|
||||
|
||||
export const account = new Elysia({ prefix: "/account" })
|
||||
|
||||
// ── Login (sets cookie) ──────────────────────────────────────────────
|
||||
.post("/login", async ({ body, cookie, set }) => {
|
||||
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 it's a form POST, redirect back with error
|
||||
if ((body as any)._form) { set.redirect = "/dashboard?error=invalid"; return; }
|
||||
return { error: "Invalid account key" };
|
||||
}
|
||||
|
||||
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
|
||||
|
||||
// Form POST → redirect to dashboard
|
||||
if ((body as any)._form) { set.redirect = "/dashboard/home"; return; }
|
||||
return { ok: true };
|
||||
}, { detail: { hide: true } })
|
||||
|
||||
// ── Logout ───────────────────────────────────────────────────────────
|
||||
.get("/logout", ({ cookie, set }) => {
|
||||
cookie.pingql_key.remove();
|
||||
set.redirect = "/dashboard";
|
||||
}, { detail: { hide: true } })
|
||||
|
||||
// ── Register ────────────────────────────────────────────────────────
|
||||
.post("/register", async ({ body }) => {
|
||||
const key = generateKey();
|
||||
|
|
|
|||
|
|
@ -1,16 +1,28 @@
|
|||
import { Elysia } from "elysia";
|
||||
import { Eta } from "eta";
|
||||
import { resolve } from "path";
|
||||
import { resolveKey } from "./auth";
|
||||
import sql from "../db";
|
||||
|
||||
const eta = new Eta({ views: resolve(import.meta.dir, "../views"), cache: true, defaultExtension: ".ejs" });
|
||||
|
||||
function render(template: string, data: Record<string, unknown> = {}) {
|
||||
function html(template: string, data: Record<string, unknown> = {}) {
|
||||
return new Response(eta.render(template, data), {
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
// Static dashboard assets
|
||||
function redirect(to: string) {
|
||||
return new Response(null, { status: 302, headers: { Location: to } });
|
||||
}
|
||||
|
||||
async function getAccountId(cookie: any, headers: any): Promise<string | null> {
|
||||
const key = cookie?.pingql_key?.value || headers["authorization"]?.replace("Bearer ", "").trim();
|
||||
if (!key) return null;
|
||||
const resolved = await resolveKey(key);
|
||||
return resolved?.accountId ?? null;
|
||||
}
|
||||
|
||||
const dashDir = resolve(import.meta.dir, "../dashboard");
|
||||
|
||||
export const dashboard = new Elysia()
|
||||
|
|
@ -18,14 +30,70 @@ export const dashboard = new Elysia()
|
|||
.get("/dashboard/app.css", () => Bun.file(`${dashDir}/app.css`))
|
||||
.get("/dashboard/query-builder.js", () => Bun.file(`${dashDir}/query-builder.js`))
|
||||
|
||||
// Auth / login page (static — no nav needed)
|
||||
.get("/dashboard", () => Bun.file(`${dashDir}/index.html`))
|
||||
// Login page
|
||||
.get("/dashboard", ({ cookie }) => {
|
||||
if (cookie?.pingql_key?.value) return redirect("/dashboard/home");
|
||||
return Bun.file(`${dashDir}/index.html`);
|
||||
})
|
||||
|
||||
// Rendered pages
|
||||
.get("/dashboard/home", () => render("home", { nav: "monitors" }))
|
||||
.get("/dashboard/settings", () => render("settings", { nav: "settings" }))
|
||||
.get("/dashboard/monitors/new", () => render("new", { nav: "monitors" }))
|
||||
.get("/dashboard/monitors/:id", () => render("detail", { nav: "monitors" }))
|
||||
// Logout
|
||||
.get("/dashboard/logout", ({ cookie }) => {
|
||||
cookie.pingql_key?.remove();
|
||||
return redirect("/dashboard");
|
||||
})
|
||||
|
||||
// Docs (static)
|
||||
// Home — SSR monitor list
|
||||
.get("/dashboard/home", async ({ cookie, headers }) => {
|
||||
const accountId = await getAccountId(cookie, headers);
|
||||
if (!accountId) return redirect("/dashboard");
|
||||
|
||||
const monitors = await sql`
|
||||
SELECT m.*, (
|
||||
SELECT row_to_json(p) FROM pings p
|
||||
WHERE p.monitor_id = m.id ORDER BY p.checked_at DESC LIMIT 1
|
||||
) as last_ping
|
||||
FROM monitors m WHERE m.account_id = ${accountId}
|
||||
ORDER BY m.created_at DESC
|
||||
`;
|
||||
|
||||
return html("home", { nav: "monitors", monitors, accountId });
|
||||
})
|
||||
|
||||
// Settings — SSR account info
|
||||
.get("/dashboard/settings", async ({ cookie, headers }) => {
|
||||
const accountId = await getAccountId(cookie, headers);
|
||||
if (!accountId) return redirect("/dashboard");
|
||||
|
||||
const [acc] = await sql`SELECT id, email_hash, created_at FROM accounts WHERE id = ${accountId}`;
|
||||
const apiKeys = await sql`SELECT id, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`;
|
||||
|
||||
return html("settings", { nav: "settings", account: acc, apiKeys, accountId });
|
||||
})
|
||||
|
||||
// New monitor
|
||||
.get("/dashboard/monitors/new", async ({ cookie, headers }) => {
|
||||
const accountId = await getAccountId(cookie, headers);
|
||||
if (!accountId) return redirect("/dashboard");
|
||||
return html("new", { nav: "monitors", scripts: ["/dashboard/query-builder.js"] });
|
||||
})
|
||||
|
||||
// Monitor detail — SSR with initial data
|
||||
.get("/dashboard/monitors/:id", async ({ cookie, headers, params }) => {
|
||||
const accountId = await getAccountId(cookie, headers);
|
||||
if (!accountId) return redirect("/dashboard");
|
||||
|
||||
const [monitor] = await sql`
|
||||
SELECT * FROM monitors WHERE id = ${params.id} AND account_id = ${accountId}
|
||||
`;
|
||||
if (!monitor) return redirect("/dashboard/home");
|
||||
|
||||
const pings = await sql`
|
||||
SELECT * FROM pings WHERE monitor_id = ${params.id}
|
||||
ORDER BY checked_at DESC LIMIT 100
|
||||
`;
|
||||
|
||||
return html("detail", { nav: "monitors", monitor, pings, scripts: ["/dashboard/query-builder.js"] });
|
||||
})
|
||||
|
||||
// Docs
|
||||
.get("/docs", () => Bun.file(`${dashDir}/docs.html`));
|
||||
|
|
|
|||
|
|
@ -85,8 +85,9 @@ export const ingest = new Elysia()
|
|||
})
|
||||
|
||||
// SSE: stream live pings — auth via Bearer header
|
||||
.get("/monitors/:id/stream", async ({ params, headers, error }) => {
|
||||
const key = headers["authorization"]?.replace("Bearer ", "").trim();
|
||||
.get("/monitors/:id/stream", async ({ params, headers, cookie, error }) => {
|
||||
const key = headers["authorization"]?.replace("Bearer ", "").trim()
|
||||
?? cookie?.pingql_key?.value;
|
||||
|
||||
if (!key) return error(401, { error: "Unauthorized" });
|
||||
|
||||
|
|
|
|||
|
|
@ -152,7 +152,6 @@
|
|||
</main>
|
||||
|
||||
<script>
|
||||
if (!requireAuth()) throw 'auth';
|
||||
|
||||
const monitorId = window.location.pathname.split('/').pop();
|
||||
let editQuery = null;
|
||||
|
|
|
|||
|
|
@ -12,17 +12,39 @@
|
|||
</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">
|
||||
<% if (it.monitors && it.monitors.length === 0) { %>
|
||||
<div id="empty-state" class="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>
|
||||
<% } else if (it.monitors) { %>
|
||||
<% it.monitors.forEach(function(m) { %>
|
||||
<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">
|
||||
<div class="flex items-center justify-between">
|
||||
<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>
|
||||
<div class="min-w-0">
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex items-center gap-6 shrink-0 ml-4">
|
||||
<div class="text-right">
|
||||
<div class="text-sm text-gray-300 stat-latency"><%= m.last_ping ? m.last_ping.latency_ms + 'ms' : '—' %></div>
|
||||
<div class="text-xs text-gray-500 stat-last"><%= m.last_ping ? '<span class="timestamp" data-ts="' + new Date(m.last_ping.checked_at).getTime() + '">just now</span>' : '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>
|
||||
<% }) %>
|
||||
<% } else { %>
|
||||
<div class="text-center py-16 text-gray-600">Loading...</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<script>
|
||||
if (!requireAuth()) throw 'auth';
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -90,7 +90,6 @@
|
|||
</main>
|
||||
|
||||
<script>
|
||||
if (!requireAuth()) throw 'auth';
|
||||
|
||||
let currentQuery = null;
|
||||
// Show body section for non-GET methods
|
||||
|
|
|
|||
|
|
@ -84,14 +84,10 @@
|
|||
|
||||
|
||||
<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('primary-key').textContent = data.account_id;
|
||||
document.getElementById('created-at').textContent = new Date(data.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
|
||||
if (data.has_email) {
|
||||
|
|
@ -155,7 +151,8 @@
|
|||
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);
|
||||
// Re-auth with new key (updates cookie)
|
||||
await fetch('/account/login', { method: 'POST', credentials: 'same-origin', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ key: data.key }) });
|
||||
document.getElementById('primary-key').textContent = data.key;
|
||||
location.reload();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue