refactor tier 3
This commit is contained in:
@@ -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", {}));
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">← 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>
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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">← 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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user