refactor tier 3

This commit is contained in:
2026-04-08 15:26:17 +04:00
parent c74ee9856e
commit 5bf02b47d5
30 changed files with 2263 additions and 58 deletions
+232
View File
@@ -607,6 +607,238 @@ export const dashboard = new Elysia()
return redirect(`/dashboard/monitors/${params.id}`);
})
// ── Status pages ──────────────────────────────────────────────────
.get("/dashboard/status-pages", async ({ cookie, headers }) => {
const resolved = await getAccountId(cookie, headers);
if (!resolved?.accountId) return redirect("/dashboard");
const pages = await sql`
SELECT id, slug, title, description, theme, default_window
FROM status_pages WHERE account_id = ${resolved.accountId}
ORDER BY created_at DESC
`;
return html("status-pages", { nav: "status-pages", pages });
})
.get("/dashboard/status-pages/new", async ({ cookie, headers }) => {
const resolved = await getAccountId(cookie, headers);
if (!resolved?.accountId) return redirect("/dashboard");
const allMonitors = await sql`
SELECT id, name FROM monitors WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC
`;
return html("status-page-edit", { nav: "status-pages", isNew: true, page: null, allMonitors });
})
.get("/dashboard/status-pages/:id", async ({ cookie, headers, params }) => {
const resolved = await getAccountId(cookie, headers);
if (!resolved?.accountId) return redirect("/dashboard");
const [page] = await sql`
SELECT * FROM status_pages WHERE id = ${params.id} AND account_id = ${resolved.accountId}
`;
if (!page) return redirect("/dashboard/status-pages");
const monitors = await sql`
SELECT monitor_id FROM status_page_monitors WHERE status_page_id = ${params.id}
`;
const allMonitors = await sql`
SELECT id, name FROM monitors WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC
`;
page.monitors = monitors;
return html("status-page-edit", { nav: "status-pages", isNew: false, page, allMonitors });
})
.post("/dashboard/status-pages/new", async ({ cookie, headers, body }) => {
const resolved = await getAccountId(cookie, headers);
if (!resolved?.accountId) return redirect("/dashboard");
const b = body as any;
const monitorIds = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []);
try {
const apiUrl = process.env.API_URL || "https://api.pingql.com";
const key = cookie?.pingql_key?.value;
await fetch(`${apiUrl}/status-pages/`, {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` },
body: JSON.stringify({
slug: (b.slug || "").trim(),
title: b.title,
description: b.description || null,
theme: b.theme || "auto",
default_window: b.default_window || "24h",
show_response_time: !!b.show_response_time,
show_powered_by: !!b.show_powered_by,
index_search: !!b.index_search,
password: b.password || undefined,
custom_css: b.custom_css || null,
footer_text: b.footer_text || null,
monitors: monitorIds.map((id: string, i: number) => ({ monitor_id: id, position: i })),
}),
});
} catch {}
return redirect("/dashboard/status-pages");
})
.post("/dashboard/status-pages/:id/edit", async ({ cookie, headers, params, body }) => {
const resolved = await getAccountId(cookie, headers);
if (!resolved?.accountId) return redirect("/dashboard");
const b = body as any;
const monitorIds = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []);
try {
const apiUrl = process.env.API_URL || "https://api.pingql.com";
const key = cookie?.pingql_key?.value;
const payload: any = {
slug: (b.slug || "").trim(),
title: b.title,
description: b.description || null,
theme: b.theme || "auto",
default_window: b.default_window || "24h",
show_response_time: !!b.show_response_time,
show_powered_by: !!b.show_powered_by,
index_search: !!b.index_search,
custom_css: b.custom_css || null,
footer_text: b.footer_text || null,
monitors: monitorIds.map((id: string, i: number) => ({ monitor_id: id, position: i })),
};
// Only send `password` if the user actually typed something. An empty box
// means "leave the existing password as-is" — sending null would clear it.
if (b.password) payload.password = b.password;
await fetch(`${apiUrl}/status-pages/${params.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` },
body: JSON.stringify(payload),
});
} catch {}
return redirect("/dashboard/status-pages");
})
.post("/dashboard/status-pages/: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}/status-pages/${params.id}`, {
method: "DELETE",
headers: { "Authorization": `Bearer ${key}` },
});
} catch {}
return redirect("/dashboard/status-pages");
})
// ── Incidents ─────────────────────────────────────────────────────
.get("/dashboard/incidents", async ({ cookie, headers }) => {
const resolved = await getAccountId(cookie, headers);
if (!resolved?.accountId) return redirect("/dashboard");
const incidents = await sql`
SELECT id, title, status, severity, pinned, started_at, resolved_at
FROM incidents WHERE account_id = ${resolved.accountId}
ORDER BY started_at DESC LIMIT 200
`;
return html("incidents", { nav: "incidents", incidents });
})
.get("/dashboard/incidents/new", async ({ cookie, headers }) => {
const resolved = await getAccountId(cookie, headers);
if (!resolved?.accountId) return redirect("/dashboard");
const allMonitors = await sql`SELECT id, name FROM monitors WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC`;
const allPages = await sql`SELECT id, title FROM status_pages WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC`;
return html("incident-edit", { nav: "incidents", isNew: true, incident: null, allMonitors, allPages });
})
.get("/dashboard/incidents/:id", async ({ cookie, headers, params }) => {
const resolved = await getAccountId(cookie, headers);
if (!resolved?.accountId) return redirect("/dashboard");
const [incident] = await sql`
SELECT * FROM incidents WHERE id = ${params.id} AND account_id = ${resolved.accountId}
`;
if (!incident) return redirect("/dashboard/incidents");
const updates = await sql`SELECT * FROM incident_updates WHERE incident_id = ${params.id} ORDER BY created_at ASC`;
const monitors = await sql`SELECT monitor_id FROM incident_monitors WHERE incident_id = ${params.id}`;
const pages = await sql`SELECT status_page_id FROM incident_status_pages WHERE incident_id = ${params.id}`;
incident.updates = updates;
incident.monitor_ids = monitors.map((m: any) => m.monitor_id);
incident.status_page_ids = pages.map((p: any) => p.status_page_id);
const allMonitors = await sql`SELECT id, name FROM monitors WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC`;
const allPages = await sql`SELECT id, title FROM status_pages WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC`;
return html("incident-edit", { nav: "incidents", isNew: false, incident, allMonitors, allPages });
})
.post("/dashboard/incidents/new", async ({ cookie, headers, body }) => {
const resolved = await getAccountId(cookie, headers);
if (!resolved?.accountId) return redirect("/dashboard");
const b = body as any;
const monitorIds = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []);
const pageIds = Array.isArray(b.status_page_ids) ? b.status_page_ids : (b.status_page_ids ? [b.status_page_ids] : []);
try {
const apiUrl = process.env.API_URL || "https://api.pingql.com";
const key = cookie?.pingql_key?.value;
await fetch(`${apiUrl}/incidents/`, {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` },
body: JSON.stringify({
title: b.title,
status: b.status || "investigating",
severity: b.severity || "minor",
monitor_ids: monitorIds,
status_page_ids: pageIds,
initial_update: { body: b.initial_update_body || "Investigating." },
}),
});
} catch {}
return redirect("/dashboard/incidents");
})
.post("/dashboard/incidents/:id/edit", async ({ cookie, headers, params, body }) => {
const resolved = await getAccountId(cookie, headers);
if (!resolved?.accountId) return redirect("/dashboard");
const b = body as any;
const monitorIds = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []);
const pageIds = Array.isArray(b.status_page_ids) ? b.status_page_ids : (b.status_page_ids ? [b.status_page_ids] : []);
try {
const apiUrl = process.env.API_URL || "https://api.pingql.com";
const key = cookie?.pingql_key?.value;
await fetch(`${apiUrl}/incidents/${params.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` },
body: JSON.stringify({
title: b.title,
status: b.status,
severity: b.severity,
monitor_ids: monitorIds,
status_page_ids: pageIds,
}),
});
} catch {}
return redirect(`/dashboard/incidents/${params.id}`);
})
.post("/dashboard/incidents/:id/update", async ({ cookie, headers, params, body }) => {
const resolved = await getAccountId(cookie, headers);
if (!resolved?.accountId) return redirect("/dashboard");
const b = body as any;
try {
const apiUrl = process.env.API_URL || "https://api.pingql.com";
const key = cookie?.pingql_key?.value;
await fetch(`${apiUrl}/incidents/${params.id}/updates`, {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` },
body: JSON.stringify({ status: b.status, body: b.body }),
});
} catch {}
return redirect(`/dashboard/incidents/${params.id}`);
})
.post("/dashboard/incidents/: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}/incidents/${params.id}`, {
method: "DELETE",
headers: { "Authorization": `Bearer ${key}` },
});
} catch {}
return redirect("/dashboard/incidents");
})
.get("/docs", () => html("docs", {}))
.get("/privacy", () => html("privacy", {}))
.get("/terms", () => html("tos", {}));
+3 -50
View File
@@ -1,50 +1,3 @@
export function sparkline(values: number[], width = 120, height = 32, color = '#60a5fa', region = '__none__'): string {
if (!values.length) return '';
const max = Math.max(...values, 1);
const min = Math.min(...values, 0);
const range = max - min || 1;
const step = width / Math.max(values.length - 1, 1);
const points = values.map((v, i) => {
const x = i * step;
const y = height - ((v - min) / range) * (height - 4) - 2;
return `${x},${y}`;
}).join(' ');
return `<svg width="${width}" height="${height}" class="inline-block" data-vals="${values.join(',')}" data-region="${region}"><polyline points="${points}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
}
import { REGION_COLORS } from "../../../shared/plans";
export function pickBestRegion(pings: Array<{latency_ms?: number|null, region?: string|null}>): { region: string, values: number[], latest: number | null } {
const withLatency = pings.filter(p => p.latency_ms != null);
if (!withLatency.length) return { region: '__none__', values: [], latest: null };
const byRegion: Record<string, number[]> = {};
for (const p of withLatency) {
const key = p.region || '__none__';
if (!byRegion[key]) byRegion[key] = [];
byRegion[key].push(p.latency_ms!);
}
const recentRegions = new Set(
withLatency.slice(-3).map(p => p.region || '__none__')
);
let bestRegion = '__none__';
let bestAvg = Infinity;
for (const [region, vals] of Object.entries(byRegion)) {
if (!recentRegions.has(region)) continue;
const recent = vals.slice(-3);
const avg = recent.reduce((a, b) => a + b, 0) / recent.length;
if (avg < bestAvg) { bestAvg = avg; bestRegion = region; }
}
const values = byRegion[bestRegion] || [];
return { region: bestRegion, values, latest: values.length ? values[values.length - 1] : null };
}
export function sparklineFromPings(pings: Array<{latency_ms?: number|null, region?: string|null}>, width = 120, height = 32): string {
const { region, values } = pickBestRegion(pings);
if (!values.length) return '';
const color = REGION_COLORS[region] || '#60a5fa';
return sparkline(values, width, height, color, region);
}
// Re-export from the shared module so apps/web and apps/status share one
// sparkline implementation.
export { sparkline, sparklineFromPings, pickBestRegion } from "../../../shared/render/sparkline";
+33
View File
@@ -482,6 +482,39 @@ Content-Type: application/json
<h3>Status page (HTML)</h3>
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
<pre>{ <span class="o">"$select"</span>: { <span class="s">".status-indicator"</span>: { <span class="o">"$eq"</span>: <span class="s">"All systems operational"</span> } } }</pre></div>
<h3>Page down if a JSON queue is backed up</h3>
<p>The killer demo: alert when a JSON field crosses a threshold. Uptime Kuma's keyword/json-query checks can't compose this — you'd need a script. Here it's one expression.</p>
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
<pre>{
<span class="o">"$consider"</span>: <span class="s">"down"</span>,
<span class="o">"$json"</span>: { <span class="s">"$.queue.depth"</span>: { <span class="o">"$gt"</span>: <span class="n">1000</span> } }
}</pre></div>
<h3>Down if any signal looks bad</h3>
<p>Compose multiple conditions with <code>$or</code> and flip the result with <code>$consider</code>. Each condition can mix status, body, headers, JSON, and CSS-selector checks freely.</p>
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
<pre>{
<span class="o">"$consider"</span>: <span class="s">"down"</span>,
<span class="o">"$or"</span>: [
{ <span class="k">"status"</span>: { <span class="o">"$gte"</span>: <span class="n">500</span> } },
{ <span class="k">"$responseTime"</span>: { <span class="o">"$gt"</span>: <span class="n">3000</span> } },
{ <span class="o">"$json"</span>: { <span class="s">"$.healthy"</span>: { <span class="o">"$eq"</span>: <span class="n">false</span> } } },
{ <span class="o">"$select"</span>: { <span class="s">".error-banner"</span>: { <span class="o">"$exists"</span>: <span class="n">true</span> } } }
]
}</pre></div>
<h3>Up only when everything matches</h3>
<p>Combine <code>$and</code> with header, body, and JSON checks for a strict definition of healthy.</p>
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
<pre>{
<span class="o">"$and"</span>: [
{ <span class="k">"status"</span>: <span class="n">200</span> },
{ <span class="k">"headers.content-type"</span>: { <span class="o">"$contains"</span>: <span class="s">"application/json"</span> } },
{ <span class="o">"$json"</span>: { <span class="s">"$.version"</span>: { <span class="o">"$startsWith"</span>: <span class="s">"v2"</span> } } },
{ <span class="o">"$json"</span>: { <span class="s">"$.db.connections"</span>: { <span class="o">"$lt"</span>: <span class="n">100</span> } } }
]
}</pre></div>
</div>
</main>
+121
View File
@@ -0,0 +1,121 @@
<%~ include('./partials/head', { title: it.isNew ? 'New incident' : 'Edit incident' }) %>
<%~ include('./partials/nav', { nav: 'incidents' }) %>
<%
const i = it.incident || {};
const allMonitors = it.allMonitors || [];
const allPages = it.allPages || [];
const attachedMonitors = new Set((it.incident?.monitor_ids || []));
const attachedPages = new Set((it.incident?.status_page_ids || []));
const updates = it.incident?.updates || [];
%>
<main class="max-w-3xl mx-auto px-8 py-10 space-y-6">
<div>
<a href="/dashboard/incidents" class="text-sm text-gray-500 hover:text-gray-300 transition-colors">&larr; Back to incidents</a>
<h1 class="text-xl font-semibold text-white mt-2"><%= it.isNew ? 'New incident' : 'Edit incident' %></h1>
</div>
<form method="POST" action="<%= it.isNew ? '/dashboard/incidents/new' : '/dashboard/incidents/' + i.id + '/edit' %>" class="space-y-5 card-static p-6">
<div>
<label class="block text-sm text-gray-400 mb-1.5">Title</label>
<input name="title" type="text" required value="<%= i.title || '' %>" placeholder="API latency degraded"
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
</div>
<div class="flex gap-4">
<div class="flex-1">
<label class="block text-sm text-gray-400 mb-1.5">Status</label>
<select name="status" class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
<% ['investigating','identified','monitoring','resolved'].forEach(function(s) { %>
<option value="<%= s %>" <%= (i.status || 'investigating') === s ? 'selected' : '' %>><%= s %></option>
<% }) %>
</select>
</div>
<div class="flex-1">
<label class="block text-sm text-gray-400 mb-1.5">Severity</label>
<select name="severity" class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
<% ['minor','major','critical'].forEach(function(s) { %>
<option value="<%= s %>" <%= (i.severity || 'minor') === s ? 'selected' : '' %>><%= s %></option>
<% }) %>
</select>
</div>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1.5">Affected monitors</label>
<% if (allMonitors.length === 0) { %>
<p class="text-xs text-gray-600">No monitors yet.</p>
<% } else { %>
<div class="flex flex-wrap gap-2">
<% allMonitors.forEach(function(m) { %>
<label class="flex items-center gap-2 bg-gray-900 border border-gray-800 hover:border-gray-600 rounded-lg px-3 py-2 cursor-pointer transition-colors">
<input type="checkbox" name="monitor_ids" value="<%= m.id %>" class="accent-blue-500" <%= attachedMonitors.has(m.id) ? 'checked' : '' %>>
<span class="text-sm text-gray-300"><%= m.name %></span>
</label>
<% }) %>
</div>
<% } %>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1.5">Show on status pages</label>
<% if (allPages.length === 0) { %>
<p class="text-xs text-gray-600">No status pages yet. <a href="/dashboard/status-pages/new" class="text-blue-400 hover:text-blue-300">Create one</a>.</p>
<% } else { %>
<div class="flex flex-wrap gap-2">
<% allPages.forEach(function(p) { %>
<label class="flex items-center gap-2 bg-gray-900 border border-gray-800 hover:border-gray-600 rounded-lg px-3 py-2 cursor-pointer transition-colors">
<input type="checkbox" name="status_page_ids" value="<%= p.id %>" class="accent-blue-500" <%= attachedPages.has(p.id) ? 'checked' : '' %>>
<span class="text-sm text-gray-300"><%= p.title %></span>
</label>
<% }) %>
</div>
<% } %>
</div>
<% if (it.isNew) { %>
<div>
<label class="block text-sm text-gray-400 mb-1.5">Initial update</label>
<textarea name="initial_update_body" rows="4" placeholder="We're investigating reports of slow API responses." required
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500"></textarea>
<p class="text-xs text-gray-600 mt-1">Markdown supported: **bold**, *italic*, `code`, [link](https://...)</p>
</div>
<% } %>
<button type="submit" class="btn-primary px-6 py-2.5 text-sm"><%= it.isNew ? 'Create incident' : 'Save changes' %></button>
</form>
<% if (!it.isNew && updates.length > 0) { %>
<section class="card-static p-6">
<h2 class="text-sm font-semibold text-gray-300 mb-4">Timeline</h2>
<div class="space-y-4">
<% updates.slice().reverse().forEach(function(u) { %>
<div class="border-l-2 border-border-subtle pl-4">
<div class="text-xs text-gray-500 mb-1"><span class="text-gray-400 font-medium"><%= u.status %></span> · <%~ it.timeAgoSSR(u.created_at) %></div>
<div class="text-sm text-gray-300 incident-body"><%~ u.body_html %></div>
</div>
<% }) %>
</div>
</section>
<% } %>
<% if (!it.isNew) { %>
<section class="card-static p-6">
<h2 class="text-sm font-semibold text-gray-300 mb-4">Post update</h2>
<form method="POST" action="/dashboard/incidents/<%= i.id %>/update" class="space-y-3">
<select name="status" class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
<% ['investigating','identified','monitoring','resolved'].forEach(function(s) { %>
<option value="<%= s %>" <%= (i.status || 'investigating') === s ? 'selected' : '' %>><%= s %></option>
<% }) %>
</select>
<textarea name="body" rows="3" placeholder="Update text" required
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500"></textarea>
<button type="submit" class="btn-primary px-6 py-2 text-sm">Post update</button>
</form>
</section>
<% } %>
</main>
+46
View File
@@ -0,0 +1,46 @@
<%~ include('./partials/head', { title: 'Incidents' }) %>
<%~ include('./partials/nav', { nav: 'incidents' }) %>
<main class="max-w-3xl mx-auto px-8 py-10 space-y-8">
<div class="flex items-center justify-between">
<h1 class="text-xl font-semibold text-white">Incidents</h1>
<a href="/dashboard/incidents/new" class="btn-primary inline-flex items-center gap-2 px-4 py-2 text-sm">+ New incident</a>
</div>
<p class="text-sm text-gray-500 leading-relaxed">
Manually-posted incidents that show up on attached status pages with a timeline of updates.
</p>
<% if (!it.incidents || it.incidents.length === 0) { %>
<section class="card-static p-6 text-sm text-gray-500">No incidents yet.</section>
<% } else { %>
<% it.incidents.forEach(function(i) {
const sevColor = i.severity === 'critical' ? 'text-red-400 border-red-900/30'
: i.severity === 'major' ? 'text-amber-400 border-amber-900/30'
: 'text-gray-400 border-border-subtle';
const statusColor = i.status === 'resolved' ? 'bg-green-900/20 text-green-400 border-green-800/30'
: 'bg-yellow-900/20 text-yellow-400 border-yellow-800/30';
%>
<section class="card-static p-6">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<h2 class="text-sm font-semibold text-gray-200 truncate"><%= i.title %></h2>
<span class="text-xs px-2 py-0.5 rounded-full border <%= sevColor %>"><%= i.severity %></span>
<span class="text-xs px-2 py-0.5 rounded-full border <%= statusColor %>"><%= i.status %></span>
</div>
<div class="text-xs text-gray-500">Started <%~ it.timeAgoSSR(i.started_at) %><% if (i.resolved_at) { %> · Resolved <%~ it.timeAgoSSR(i.resolved_at) %><% } %></div>
</div>
<div class="flex items-center gap-2 shrink-0">
<a href="/dashboard/incidents/<%= i.id %>" class="px-3 py-1.5 rounded-lg border border-border-subtle text-gray-400 hover:text-gray-200 text-xs transition-colors">Open</a>
<form action="/dashboard/incidents/<%= i.id %>/delete" method="POST" class="inline" onsubmit="return confirm('Delete this incident?')">
<button type="submit" class="px-3 py-1.5 rounded-lg border border-red-900/30 text-red-400 hover:bg-red-900/20 hover:border-red-800/40 text-xs transition-colors">Delete</button>
</form>
</div>
</div>
</section>
<% }) %>
<% } %>
</main>
+2
View File
@@ -2,6 +2,8 @@
<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/status-pages" class="<%= it.nav === 'status-pages' ? 'text-gray-200 relative after:absolute after:bottom-[-18px] after:left-0 after:right-0 after:h-[2px] after:bg-blue-500 after:rounded-full' : 'hover:text-gray-300' %> transition-colors">Status pages</a>
<a href="/dashboard/incidents" class="<%= it.nav === 'incidents' ? 'text-gray-200 relative after:absolute after:bottom-[-18px] after:left-0 after:right-0 after:h-[2px] after:bg-blue-500 after:rounded-full' : 'hover:text-gray-300' %> transition-colors">Incidents</a>
<a href="/dashboard/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>
+109
View File
@@ -0,0 +1,109 @@
<%~ include('./partials/head', { title: it.isNew ? 'New status page' : 'Edit status page' }) %>
<%~ include('./partials/nav', { nav: 'status-pages' }) %>
<%
const p = it.page || {};
const allMonitors = it.allMonitors || [];
const attached = new Set((it.page?.monitors || []).map(m => m.monitor_id));
%>
<main class="max-w-3xl mx-auto px-8 py-10">
<div class="mb-6">
<a href="/dashboard/status-pages" class="text-sm text-gray-500 hover:text-gray-300 transition-colors">&larr; Back to status pages</a>
<h1 class="text-xl font-semibold text-white mt-2"><%= it.isNew ? 'New status page' : 'Edit status page' %></h1>
</div>
<form method="POST" action="<%= it.isNew ? '/dashboard/status-pages/new' : '/dashboard/status-pages/' + p.id + '/edit' %>" class="space-y-6">
<div>
<label class="block text-sm text-gray-400 mb-1.5">Slug</label>
<input name="slug" type="text" required value="<%= p.slug || '' %>" placeholder="my-app" pattern="^[a-z0-9][a-z0-9-]*$"
class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 font-mono text-sm">
<p class="text-xs text-gray-600 mt-1">Public URL: <span class="text-blue-400 font-mono">status.pingql.com/<your-slug></span></p>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1.5">Title</label>
<input name="title" type="text" required value="<%= p.title || '' %>" placeholder="My App Status"
class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
</div>
<div>
<label class="block text-sm text-gray-400 mb-1.5">Description <span class="text-gray-600">(optional)</span></label>
<textarea name="description" rows="2" placeholder="What this status page covers"
class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500"><%= p.description || '' %></textarea>
</div>
<div class="flex gap-4">
<div class="flex-1">
<label class="block text-sm text-gray-400 mb-1.5">Theme</label>
<select name="theme" class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
<% ['auto','light','dark'].forEach(function(t) { %>
<option value="<%= t %>" <%= (p.theme || 'auto') === t ? 'selected' : '' %>><%= t %></option>
<% }) %>
</select>
</div>
<div class="flex-1">
<label class="block text-sm text-gray-400 mb-1.5">Default window</label>
<select name="default_window" class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
<% ['24h','7d','30d','90d'].forEach(function(w) { %>
<option value="<%= w %>" <%= (p.default_window || '24h') === w ? 'selected' : '' %>><%= w %></option>
<% }) %>
</select>
</div>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1.5">Monitors</label>
<% if (allMonitors.length === 0) { %>
<p class="text-xs text-gray-600">No monitors yet. <a href="/dashboard/monitors/new" class="text-blue-400 hover:text-blue-300">Create one</a> first.</p>
<% } else { %>
<div class="flex flex-wrap gap-2">
<% allMonitors.forEach(function(m) { %>
<label class="flex items-center gap-2 bg-gray-900 border border-gray-800 hover:border-gray-600 rounded-lg px-3 py-2 cursor-pointer transition-colors">
<input type="checkbox" name="monitor_ids" value="<%= m.id %>" class="accent-blue-500" <%= attached.has(m.id) ? 'checked' : '' %>>
<span class="text-sm text-gray-300"><%= m.name %></span>
</label>
<% }) %>
</div>
<% } %>
</div>
<div class="flex gap-6">
<label class="flex items-center gap-2 text-sm text-gray-400">
<input type="checkbox" name="show_response_time" value="1" <%= (p.show_response_time !== false) ? 'checked' : '' %> class="accent-blue-500">
Show response time
</label>
<label class="flex items-center gap-2 text-sm text-gray-400">
<input type="checkbox" name="show_powered_by" value="1" <%= (p.show_powered_by !== false) ? 'checked' : '' %> class="accent-blue-500">
Show "Powered by PingQL"
</label>
<label class="flex items-center gap-2 text-sm text-gray-400">
<input type="checkbox" name="index_search" value="1" <%= (p.index_search !== false) ? 'checked' : '' %> class="accent-blue-500">
Allow search engines
</label>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1.5">Password <span class="text-gray-600">(optional, leave blank to remove)</span></label>
<input name="password" type="password" placeholder="Leave blank for public access"
class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
</div>
<div>
<label class="block text-sm text-gray-400 mb-1.5">Custom CSS <span class="text-gray-600">(optional)</span></label>
<textarea name="custom_css" rows="4" placeholder=":root { --accent: #ff00ff; }"
class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 font-mono text-xs"><%= p.custom_css || '' %></textarea>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1.5">Footer text <span class="text-gray-600">(optional)</span></label>
<input name="footer_text" type="text" value="<%= p.footer_text || '' %>" placeholder="Contact us at support@example.com"
class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
</div>
<button type="submit" class="btn-primary px-6 py-2.5 text-sm"><%= it.isNew ? 'Create page' : 'Save changes' %></button>
</form>
</main>
+42
View File
@@ -0,0 +1,42 @@
<%~ include('./partials/head', { title: 'Status pages' }) %>
<%~ include('./partials/nav', { nav: 'status-pages' }) %>
<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">Status pages</h1>
<a href="/dashboard/status-pages/new" class="btn-primary inline-flex items-center gap-2 px-4 py-2 text-sm">+ New page</a>
</div>
<p class="text-sm text-gray-500 leading-relaxed">
Public pages people can visit during an outage. Each page picks a slug, the monitors to display, and optional branding.
</p>
<% if (!it.pages || it.pages.length === 0) { %>
<section class="card-static p-6 text-sm text-gray-500">
No status pages yet. Create one to get a public URL like <code class="text-blue-400">status.pingql.com/your-slug</code>.
</section>
<% } else { %>
<% it.pages.forEach(function(p) { %>
<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"><%= p.title %></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"><%= p.theme %></span>
</div>
<a href="https://status.pingql.com/<%= p.slug %>" target="_blank" class="text-xs text-blue-400 hover:text-blue-300 font-mono break-all">status.pingql.com/<%= p.slug %></a>
<% if (p.description) { %><p class="text-xs text-gray-500 mt-1"><%= p.description %></p><% } %>
</div>
<div class="flex items-center gap-2 shrink-0">
<a href="/dashboard/status-pages/<%= p.id %>" class="px-3 py-1.5 rounded-lg border border-border-subtle text-gray-400 hover:text-gray-200 text-xs transition-colors">Edit</a>
<form action="/dashboard/status-pages/<%= p.id %>/delete" method="POST" class="inline" onsubmit="return confirm('Delete status page \'<%= p.title %>\'? This cannot be undone.')">
<button type="submit" class="px-3 py-1.5 rounded-lg border border-red-900/30 text-red-400 hover:bg-red-900/20 hover:border-red-800/40 text-xs transition-colors">Delete</button>
</form>
</div>
</div>
</section>
<% }) %>
<% } %>
</main>