update: status page, api

This commit is contained in:
nate 2026-04-08 16:38:11 +04:00
parent 5b3994b042
commit 8f7ac6bb4b
6 changed files with 275 additions and 86 deletions

View File

@ -31,6 +31,7 @@ const StatusPageBody = t.Object({
monitor_id: t.String(),
group_index: t.Optional(t.Nullable(t.Number())),
display_name: t.Optional(t.Nullable(t.String({ maxLength: 200 }))),
display_mode: t.Optional(t.Nullable(DisplayMode)),
position: t.Optional(t.Number()),
}))),
});
@ -53,7 +54,7 @@ async function replaceGroupsAndMonitors(
pageId: string,
accountId: string,
groups: { name: string; position?: number }[] | undefined,
monitorsList: { monitor_id: string; group_index?: number | null; display_name?: string | null; position?: number }[] | undefined,
monitorsList: { monitor_id: string; group_index?: number | null; display_name?: string | null; display_mode?: "compact" | "expanded" | null; position?: number }[] | undefined,
) {
if (groups !== undefined) {
await sql`DELETE FROM status_page_groups WHERE status_page_id = ${pageId}`;
@ -92,12 +93,13 @@ async function replaceGroupsAndMonitors(
monitor_id: m.monitor_id,
group_id: groupId,
display_name: m.display_name ?? null,
display_mode: m.display_mode ?? null,
position: m.position ?? i,
});
}
if (rows.length > 0) {
await sql`
INSERT INTO status_page_monitors ${sql(rows, "status_page_id", "monitor_id", "group_id", "display_name", "position")}
INSERT INTO status_page_monitors ${sql(rows, "status_page_id", "monitor_id", "group_id", "display_name", "display_mode", "position")}
`;
}
}

View File

@ -177,6 +177,8 @@ export async function migrate(sql: any) {
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_status_page_monitors_monitor ON status_page_monitors(monitor_id)`;
// Per-monitor display mode override. NULL = inherit status_pages.display_mode.
await sql`ALTER TABLE status_page_monitors ADD COLUMN IF NOT EXISTS display_mode TEXT`;
await sql`
CREATE TABLE IF NOT EXISTS incidents (

View File

@ -48,6 +48,7 @@ export interface MonitorRow {
url: string;
group_id: string | null;
position: number;
display_mode: "compact" | "expanded"; // resolved (per-monitor override → page default → 'expanded')
current_state: "up" | "down" | "unknown";
region_states: Array<{ region: string; state: "up" | "down" | "unknown"; updated_at: string | null }>;
uptime_pct: number | null; // for the page's default_window
@ -121,6 +122,13 @@ export interface GroupRow {
position: number;
}
export interface IncidentUpdateRow {
id: string;
status: string;
body_html: string;
created_at: string;
}
export interface IncidentSummary {
id: string;
title: string;
@ -129,7 +137,7 @@ export interface IncidentSummary {
pinned: boolean;
started_at: string;
resolved_at: string | null;
latest_update_html: string | null;
updates: IncidentUpdateRow[]; // full timeline, newest first
}
export async function loadStatusPage(slug: string): Promise<StatusPageRow | null> {
@ -145,7 +153,7 @@ export async function loadGroups(pageId: string): Promise<GroupRow[]> {
`;
}
export async function loadMonitors(pageId: string, window: Window): Promise<MonitorRow[]> {
export async function loadMonitors(pageId: string, window: Window, pageDisplayMode: "compact" | "expanded" = "expanded"): Promise<MonitorRow[]> {
// Step 1: page → monitors with display overrides + group + position.
const monitorRows = await sql<any[]>`
SELECT
@ -153,7 +161,8 @@ export async function loadMonitors(pageId: string, window: Window): Promise<Moni
COALESCE(spm.display_name, m.name) AS display_name,
m.url,
spm.group_id,
spm.position
spm.position,
spm.display_mode AS spm_display_mode
FROM status_page_monitors spm
JOIN monitors m ON m.id = spm.monitor_id
WHERE spm.status_page_id = ${pageId}
@ -298,12 +307,17 @@ export async function loadMonitors(pageId: string, window: Window): Promise<Moni
}
const latAcc = latencyByMonitor[m.id];
const avg_latency = latAcc && latAcc.n > 0 ? Math.round(latAcc.sum / latAcc.n) : null;
// Per-monitor display mode override → page default → 'expanded'.
const display_mode = (m.spm_display_mode === 'compact' || m.spm_display_mode === 'expanded')
? m.spm_display_mode
: pageDisplayMode;
return {
id: m.id,
display_name: m.display_name,
url: m.url,
group_id: m.group_id,
position: m.position,
display_mode,
current_state,
region_states,
uptime_pct,
@ -327,15 +341,24 @@ export async function loadIncidents(pageId: string): Promise<{ active: IncidentS
if (incidents.length === 0) return { active: [], recent: [] };
const ids = incidents.map((i) => i.id);
// Latest update html per incident.
const latestUpdates = await sql<any[]>`
SELECT DISTINCT ON (incident_id) incident_id, body_html, status, created_at
// Full timeline per incident (newest first), so the public page can show the
// entire course of events on both active and resolved incidents.
const allUpdates = await sql<any[]>`
SELECT id, incident_id, status, body_html, created_at
FROM incident_updates
WHERE incident_id = ANY(${sql.array(ids)}::uuid[])
ORDER BY incident_id, created_at DESC
ORDER BY created_at DESC
`;
const latestByIncident: Record<string, string> = {};
for (const u of latestUpdates) latestByIncident[u.incident_id] = u.body_html;
const updatesByIncident: Record<string, IncidentUpdateRow[]> = {};
for (const u of allUpdates) {
if (!updatesByIncident[u.incident_id]) updatesByIncident[u.incident_id] = [];
updatesByIncident[u.incident_id]!.push({
id: u.id,
status: u.status,
body_html: u.body_html,
created_at: u.created_at instanceof Date ? u.created_at.toISOString() : String(u.created_at),
});
}
const enriched: IncidentSummary[] = incidents.map((i) => ({
id: i.id,
@ -345,7 +368,7 @@ export async function loadIncidents(pageId: string): Promise<{ active: IncidentS
pinned: i.pinned,
started_at: i.started_at instanceof Date ? i.started_at.toISOString() : String(i.started_at),
resolved_at: i.resolved_at ? (i.resolved_at instanceof Date ? i.resolved_at.toISOString() : String(i.resolved_at)) : null,
latest_update_html: latestByIncident[i.id] ?? null,
updates: updatesByIncident[i.id] ?? [],
}));
const active = enriched.filter((i) => i.pinned && !i.resolved_at);
@ -375,11 +398,11 @@ export async function loadMonitorDetail(slug: string, monitorId: string, window?
const win = (window ?? page.default_window) as Window;
// Reuse the bulk loader with a single-monitor list — keeps the bucket/state
// logic in one place. Cheap because we're querying for one ID.
const monitors = await loadMonitors(page.id, win);
const monitors = await loadMonitors(page.id, win, page.display_mode);
const m = monitors.find((x) => x.id === monitorId);
if (!m) return null;
// Incidents touching this monitor (any status), most recent 20.
// Incidents touching this monitor (any status), most recent 20, full timeline.
const incidentRows = await sql<any[]>`
SELECT i.*
FROM incidents i
@ -391,14 +414,22 @@ export async function loadMonitorDetail(slug: string, monitorId: string, window?
let incidents: IncidentSummary[] = [];
if (incidentRows.length > 0) {
const ids = incidentRows.map((i) => i.id);
const updates = await sql<any[]>`
SELECT DISTINCT ON (incident_id) incident_id, body_html
const allUpdates = await sql<any[]>`
SELECT id, incident_id, status, body_html, created_at
FROM incident_updates
WHERE incident_id = ANY(${sql.array(ids)}::uuid[])
ORDER BY incident_id, created_at DESC
ORDER BY created_at DESC
`;
const latestByIncident: Record<string, string> = {};
for (const u of updates) latestByIncident[u.incident_id] = u.body_html;
const updatesByIncident: Record<string, IncidentUpdateRow[]> = {};
for (const u of allUpdates) {
if (!updatesByIncident[u.incident_id]) updatesByIncident[u.incident_id] = [];
updatesByIncident[u.incident_id]!.push({
id: u.id,
status: u.status,
body_html: u.body_html,
created_at: u.created_at instanceof Date ? u.created_at.toISOString() : String(u.created_at),
});
}
incidents = incidentRows.map((i) => ({
id: i.id,
title: i.title,
@ -407,7 +438,7 @@ export async function loadMonitorDetail(slug: string, monitorId: string, window?
pinned: i.pinned,
started_at: i.started_at instanceof Date ? i.started_at.toISOString() : String(i.started_at),
resolved_at: i.resolved_at ? (i.resolved_at instanceof Date ? i.resolved_at.toISOString() : String(i.resolved_at)) : null,
latest_update_html: latestByIncident[i.id] ?? null,
updates: updatesByIncident[i.id] ?? [],
}));
}
@ -428,7 +459,7 @@ export async function loadPagePayload(slug: string, window?: Window): Promise<Pa
const win = (window ?? page.default_window) as Window;
const [groups, monitors, incidents] = await Promise.all([
loadGroups(page.id),
loadMonitors(page.id, win),
loadMonitors(page.id, win, page.display_mode),
loadIncidents(page.id),
]);
const { password_hash, ...publicPage } = page;

View File

@ -141,18 +141,33 @@
.region.up { color: var(--green); border-color: rgba(16,185,129,0.3); }
.region.down { color: var(--red); border-color: rgba(239,68,68,0.3); }
.incidents { margin-bottom: 2rem; }
.incident { background: var(--card); border-left: 4px solid var(--amber); border-radius: 8px; padding: 1rem 1.25rem; margin-bottom: 1rem; }
.incident.critical { border-left-color: var(--red); }
.incident.major { border-left-color: var(--amber); }
.incident { background: var(--card); border-left: 4px solid var(--bar-partial); border-radius: 8px; padding: 1rem 1.25rem; margin-bottom: 1rem; }
.incident.critical { border-left-color: var(--bar-down); }
.incident.major { border-left-color: var(--bar-partial); }
.incident.minor { border-left-color: var(--muted); }
.incident.resolved { border-left-color: var(--bar-up); opacity: 0.85; }
.incident-title { font-weight: 600; margin-bottom: 0.25rem; }
.incident-meta { color: var(--muted); font-size: 0.8rem; margin-bottom: 0.5rem; }
.incident-body p { margin: 0.5rem 0; }
.incident-body code { background: var(--bg); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.85em; }
.incident-meta .pill { display: inline-block; padding: 0.05rem 0.4rem; border-radius: 999px; border: 1px solid var(--border); margin-right: 0.4rem; text-transform: capitalize; }
.incident-meta .pill.investigating { color: var(--bar-partial); border-color: rgba(245,158,11,0.3); }
.incident-meta .pill.identified { color: var(--bar-partial); border-color: rgba(245,158,11,0.3); }
.incident-meta .pill.monitoring { color: var(--accent); border-color: rgba(14,165,233,0.3); }
.incident-meta .pill.resolved { color: var(--bar-up); border-color: rgba(16,185,129,0.3); }
/* Full incident timeline (newest at top). One block per update. */
.incident-timeline { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--border); }
.incident-update { padding: 0.5rem 0; border-bottom: 1px dashed var(--border); }
.incident-update:last-child { border-bottom: none; padding-bottom: 0; }
.incident-update .head { font-size: 0.75rem; color: var(--muted); margin-bottom: 0.2rem; }
.incident-update .head .status { display: inline-block; padding: 0.05rem 0.4rem; border-radius: 4px; font-weight: 600; text-transform: capitalize; margin-right: 0.4rem; }
.incident-update .head .status.investigating { background: rgba(245,158,11,0.15); color: var(--bar-partial); }
.incident-update .head .status.identified { background: rgba(245,158,11,0.15); color: var(--bar-partial); }
.incident-update .head .status.monitoring { background: rgba(14,165,233,0.15); color: var(--accent); }
.incident-update .head .status.resolved { background: rgba(16,185,129,0.15); color: var(--bar-up); }
.incident-update .body { font-size: 0.875rem; }
.incident-update .body p { margin: 0.25rem 0; }
.incident-update .body code { background: var(--bg); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.85em; }
.past-incidents { margin-top: 3rem; }
.past-incidents h2 { font-size: 1.1rem; margin-bottom: 1rem; }
.past { padding: 0.75rem 0; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; gap: 1rem; }
.past-title { font-weight: 500; }
.past-meta { color: var(--muted); font-size: 0.8rem; }
footer { margin-top: 4rem; padding-top: 2rem; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.8rem; text-align: center; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
@ -170,24 +185,59 @@
<span><%= overallText %></span>
</div>
<%
// Shared timeline render — used for both active (top of page) and past
// (bottom) incidents. Standard status-page pattern: every update from
// every status the incident has been in, newest first.
function renderIncident(i) {
const klass = i.resolved_at ? `incident ${i.severity} resolved` : `incident ${i.severity}`;
const startedFmt = fmtTimestamp(i.started_at);
const resolvedFmt = i.resolved_at ? fmtTimestamp(i.resolved_at) : null;
let html = `<div id="incident-${i.id}" class="${klass}">`;
html += `<div class="incident-title">${escapeHtmlSSR(i.title)}</div>`;
html += `<div class="incident-meta"><span class="pill ${i.status}">${i.status}</span>`;
html += `Started ${startedFmt}`;
if (resolvedFmt) html += ` · Resolved ${resolvedFmt}`;
html += `</div>`;
if (i.updates && i.updates.length > 0) {
html += `<div class="incident-timeline">`;
for (const u of i.updates) {
html += `<div class="incident-update">`;
html += `<div class="head"><span class="status ${u.status}">${u.status}</span><span>${fmtTimestamp(u.created_at)}</span></div>`;
html += `<div class="body">${u.body_html}</div>`;
html += `</div>`;
}
html += `</div>`;
}
html += `</div>`;
return html;
}
// Eta doesn't have a built-in HTML escape helper exposed at template
// scope, so define a tiny one inline. Only used for incident titles.
function escapeHtmlSSR(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
%>
<% if (incidents.active.length > 0) { %>
<div class="incidents">
<% incidents.active.forEach(function(i) { %>
<div id="incident-<%= i.id %>" class="incident <%= i.severity %>">
<div class="incident-title"><%= i.title %></div>
<div class="incident-meta"><%= i.status %> · started <%= new Date(i.started_at).toLocaleString() %></div>
<% if (i.latest_update_html) { %><div class="incident-body"><%~ i.latest_update_html %></div><% } %>
</div>
<% }); %>
<% incidents.active.forEach(function(i) { %><%~ renderIncident(i) %><% }); %>
</div>
<% } %>
<%
const isCompact = page.display_mode === 'compact';
// Per-monitor mode now lives on each MonitorRow.display_mode (already
// resolved against the page-level fallback by the data layer). The
// page-level isCompact is kept only as a hint for whether to emit the
// expand JS at all.
const anyCompact = monitors.some(m => m.display_mode === 'compact');
const windowLabel = page.default_window === '24h' ? 'Last 24 hours'
: page.default_window === '7d' ? 'Last 7 days'
: page.default_window === '30d' ? 'Last 30 days'
: 'Last 90 days';
function fmtTimestamp(iso) {
const d = new Date(iso);
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
%>
<% groupOrder.forEach(function(gid) {
const list = grouped[gid];
@ -200,8 +250,9 @@
const buckets = m.buckets || [];
const hasData = buckets.some(b => b.total > 0);
const u = m.uptime || { d24: null, d7: null, d30: null, d90: null };
const monitorIsCompact = m.display_mode === 'compact';
%>
<% if (isCompact) { %>
<% if (monitorIsCompact) { %>
<div class="monitor compact" data-monitor-id="<%= m.id %>">
<button type="button" class="monitor-row" aria-expanded="false">
<div class="monitor-name">
@ -260,14 +311,7 @@
<% if (incidents.recent.length > 0) { %>
<div class="past-incidents">
<h2>Past incidents</h2>
<% incidents.recent.forEach(function(i) { %>
<div class="past">
<div>
<div class="past-title"><%= i.title %></div>
<div class="past-meta"><%= i.status %> · <%= new Date(i.started_at).toLocaleDateString() %></div>
</div>
</div>
<% }); %>
<% incidents.recent.forEach(function(i) { %><%~ renderIncident(i) %><% }); %>
</div>
<% } %>
@ -277,7 +321,7 @@
</footer>
</main>
<% if (isCompact) { %>
<% if (anyCompact) { %>
<script>
// Click-to-expand for compact display mode. First click on a monitor row
// fetches /<slug>/monitor/<id>.json once and renders the detail inline;
@ -326,8 +370,29 @@
? `<div class="regions">${m.region_states.map(r => `<span class="region ${r.state}">${escapeHtml(r.region)}</span>`).join('')}</div>`
: '';
const latencyHtml = showResponseTime && m.avg_latency != null ? `<span>${m.avg_latency}ms · </span>` : '';
// Render the full incident timeline for any incidents touching this monitor.
function fmtTs(iso) {
const d = new Date(iso);
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function renderIncidentClient(i) {
const klass = i.resolved_at ? `incident ${i.severity} resolved` : `incident ${i.severity}`;
let h = `<div class="${klass}" style="margin-top:1rem"><div class="incident-title">${escapeHtml(i.title)}</div>`;
h += `<div class="incident-meta"><span class="pill ${i.status}">${i.status}</span>Started ${fmtTs(i.started_at)}`;
if (i.resolved_at) h += ` · Resolved ${fmtTs(i.resolved_at)}`;
h += `</div>`;
if (i.updates && i.updates.length > 0) {
h += `<div class="incident-timeline">`;
for (const u of i.updates) {
h += `<div class="incident-update"><div class="head"><span class="status ${u.status}">${u.status}</span><span>${fmtTs(u.created_at)}</span></div><div class="body">${u.body_html}</div></div>`;
}
h += `</div>`;
}
h += `</div>`;
return h;
}
const incidentsHtml = (payload.incidents && payload.incidents.length > 0)
? `<div class="bars-meta" style="margin-top:0.75rem"><span>Recent incidents</span><span>${payload.incidents.length}</span></div>`
? payload.incidents.map(renderIncidentClient).join('')
: '';
return `
<div class="bars" title="${m.current_state}">${barsHtml}</div>

View File

@ -146,6 +146,69 @@ async function getAccountId(cookie: any, headers: any): Promise<{ accountId: str
return await resolveKey(key) ?? null;
}
// Parse the status page edit form's monitor list. The form posts:
// monitor_order — full list of monitor IDs in DOM order (every row, not just checked)
// monitor_ids — only the *checked* IDs, also in DOM order
// display_name[<id>] — optional per-page name override
// display_mode[<id>] — '', 'compact', or 'expanded'
// Bun's body parser surfaces bracket-keyed fields either as nested objects
// (`b.display_name = { id: value }`) or as flat string keys
// (`b['display_name[id]'] = value`) depending on parser version, so handle both.
function pickMap(b: any, prefix: string): Record<string, string> {
const out: Record<string, string> = {};
if (b[prefix] && typeof b[prefix] === "object" && !Array.isArray(b[prefix])) {
for (const [k, v] of Object.entries(b[prefix])) {
if (typeof v === "string" && v.trim()) out[k] = v.trim();
}
} else {
const re = new RegExp(`^${prefix}\\[(.+)\\]$`);
for (const k of Object.keys(b)) {
const m = k.match(re);
if (m && typeof b[k] === "string" && b[k].trim()) out[m[1]!] = b[k].trim();
}
}
return out;
}
function parseStatusPageMonitors(b: any): {
monitorIds: string[];
monitorsForApi: Array<{ monitor_id: string; position: number; display_name: string | null; display_mode: string | null }>;
} {
const order: string[] = Array.isArray(b.monitor_order) ? b.monitor_order : (b.monitor_order ? [b.monitor_order] : []);
const checked: string[] = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []);
const checkedSet = new Set(checked);
const displayNames = pickMap(b, "display_name");
const displayModes = pickMap(b, "display_mode");
// Walk the rendered order and keep only the checked monitors. Position is
// their index in this filtered list.
const monitorIds: string[] = [];
const monitorsForApi: Array<{ monitor_id: string; position: number; display_name: string | null; display_mode: string | null }> = [];
for (const id of order) {
if (!checkedSet.has(id)) continue;
monitorsForApi.push({
monitor_id: id,
position: monitorIds.length,
display_name: displayNames[id] ?? null,
display_mode: (displayModes[id] === "compact" || displayModes[id] === "expanded") ? displayModes[id]! : null,
});
monitorIds.push(id);
}
// If the form somehow posted a checked ID that wasn't in the order list
// (shouldn't happen, defensive), append it at the end.
for (const id of checked) {
if (monitorIds.includes(id)) continue;
monitorsForApi.push({
monitor_id: id,
position: monitorIds.length,
display_name: displayNames[id] ?? null,
display_mode: (displayModes[id] === "compact" || displayModes[id] === "expanded") ? displayModes[id]! : null,
});
monitorIds.push(id);
}
return { monitorIds, monitorsForApi };
}
const dashDir = resolve(import.meta.dir, "../dashboard");
export const dashboard = new Elysia()
@ -636,7 +699,9 @@ export const dashboard = new Elysia()
`;
if (!page) return redirect("/dashboard/status-pages");
const monitors = await sql`
SELECT monitor_id, display_name FROM status_page_monitors WHERE status_page_id = ${params.id}
SELECT monitor_id, display_name, display_mode
FROM status_page_monitors WHERE status_page_id = ${params.id}
ORDER BY position ASC
`;
const allMonitors = await sql`
SELECT id, name FROM monitors WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC
@ -649,22 +714,7 @@ export const dashboard = new Elysia()
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] : []);
// The form posts per-monitor display name overrides as display_name[<id>].
// Bun's body parser surfaces them as nested object keys (display_name) or
// as flat "display_name[id]" string keys depending on parser version, so
// handle both. Empty strings mean "no override" — don't send them.
const displayNames: Record<string, string> = {};
if (b.display_name && typeof b.display_name === "object" && !Array.isArray(b.display_name)) {
for (const [k, v] of Object.entries(b.display_name)) {
if (typeof v === "string" && v.trim()) displayNames[k] = v.trim();
}
} else {
for (const k of Object.keys(b)) {
const m = k.match(/^display_name\[(.+)\]$/);
if (m && typeof b[k] === "string" && b[k].trim()) displayNames[m[1]!] = b[k].trim();
}
}
const { monitorsForApi } = parseStatusPageMonitors(b);
try {
const apiUrl = process.env.API_URL || "https://api.pingql.com";
const key = cookie?.pingql_key?.value;
@ -684,7 +734,7 @@ export const dashboard = new Elysia()
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, display_name: displayNames[id] ?? null })),
monitors: monitorsForApi,
}),
});
} catch {}
@ -695,18 +745,7 @@ export const dashboard = new Elysia()
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 displayNames: Record<string, string> = {};
if (b.display_name && typeof b.display_name === "object" && !Array.isArray(b.display_name)) {
for (const [k, v] of Object.entries(b.display_name)) {
if (typeof v === "string" && v.trim()) displayNames[k] = v.trim();
}
} else {
for (const k of Object.keys(b)) {
const m = k.match(/^display_name\[(.+)\]$/);
if (m && typeof b[k] === "string" && b[k].trim()) displayNames[m[1]!] = b[k].trim();
}
}
const { monitorsForApi } = parseStatusPageMonitors(b);
try {
const apiUrl = process.env.API_URL || "https://api.pingql.com";
const key = cookie?.pingql_key?.value;
@ -722,7 +761,7 @@ export const dashboard = new Elysia()
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, display_name: displayNames[id] ?? null })),
monitors: monitorsForApi,
};
// Only send `password` if the user actually typed something. An empty box
// means "leave the existing password as-is" — sending null would clear it.

View File

@ -6,9 +6,22 @@
const allMonitors = it.allMonitors || [];
const attachedRows = (it.page?.monitors || []);
const attached = new Set(attachedRows.map(m => m.monitor_id));
// monitor_id → existing display_name override (or empty)
// monitor_id → existing per-page overrides
const displayNames = {};
for (const r of attachedRows) displayNames[r.monitor_id] = r.display_name || '';
const displayModes = {};
for (const r of attachedRows) {
displayNames[r.monitor_id] = r.display_name || '';
displayModes[r.monitor_id] = r.display_mode || '';
}
// Render order: attached monitors first in their saved order, then any
// unattached ones (alphabetical) so the user can drag them in.
const attachedOrder = attachedRows
.map(r => allMonitors.find(m => m.id === r.monitor_id))
.filter(Boolean);
const unattached = allMonitors
.filter(m => !attached.has(m.id))
.sort((a, b) => a.name.localeCompare(b.name));
const orderedMonitors = [...attachedOrder, ...unattached];
%>
<main class="max-w-3xl mx-auto px-8 py-10">
@ -68,25 +81,62 @@
<div>
<label class="block text-sm text-gray-400 mb-1.5">Monitors</label>
<p class="text-xs text-gray-600 mb-2">Optional "Show as" field overrides the monitor name on this status page only. Leave blank to use the monitor's real name.</p>
<p class="text-xs text-gray-600 mb-2">Tick to attach. Drag to reorder. "Show as" overrides the name on this page only. "Mode" picks compact or expanded for that one monitor (or leave blank to use the page default).</p>
<% 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="space-y-2">
<% allMonitors.forEach(function(m) {
<div id="monitor-list" class="space-y-2">
<% orderedMonitors.forEach(function(m) {
const isAttached = attached.has(m.id);
const displayName = displayNames[m.id] || '';
const displayMode = displayModes[m.id] || '';
%>
<div class="flex items-center gap-3 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2">
<div class="monitor-edit-row flex items-center gap-3 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2" draggable="true" data-monitor-id="<%= m.id %>">
<span class="drag-handle text-gray-600 cursor-grab select-none px-1" title="Drag to reorder">⋮⋮</span>
<input type="hidden" name="monitor_order" value="<%= m.id %>">
<label class="flex items-center gap-2 cursor-pointer min-w-0 flex-1">
<input type="checkbox" name="monitor_ids" value="<%= m.id %>" class="accent-blue-500" <%= isAttached ? 'checked' : '' %>>
<span class="text-sm text-gray-300 truncate"><%= m.name %></span>
</label>
<select name="display_mode[<%= m.id %>]"
class="text-xs bg-gray-950 border border-gray-800 rounded px-2 py-1 text-gray-200 focus:outline-none focus:border-blue-500 shrink-0">
<option value="" <%= displayMode === '' ? 'selected' : '' %>>Default</option>
<option value="expanded" <%= displayMode === 'expanded' ? 'selected' : '' %>>Expanded</option>
<option value="compact" <%= displayMode === 'compact' ? 'selected' : '' %>>Compact</option>
</select>
<input type="text" name="display_name[<%= m.id %>]" value="<%= displayName %>" placeholder="Show as (optional)"
class="text-xs bg-gray-950 border border-gray-800 rounded px-2 py-1 text-gray-200 placeholder-gray-600 focus:outline-none focus:border-blue-500 w-48 shrink-0">
</div>
<% }) %>
</div>
<script>
// Tiny vanilla drag-and-drop reorder. The DOM order at submit time is
// the canonical order — each row carries a hidden "monitor_order" input
// that gets posted in DOM order naturally.
(function() {
const list = document.getElementById('monitor-list');
if (!list) return;
let dragging = null;
list.querySelectorAll('.monitor-edit-row').forEach((row) => {
row.addEventListener('dragstart', (e) => {
dragging = row;
row.style.opacity = '0.4';
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
});
row.addEventListener('dragend', () => {
row.style.opacity = '';
dragging = null;
});
row.addEventListener('dragover', (e) => {
e.preventDefault();
if (!dragging || dragging === row) return;
const rect = row.getBoundingClientRect();
const after = (e.clientY - rect.top) > rect.height / 2;
row.parentNode.insertBefore(dragging, after ? row.nextSibling : row);
});
});
})();
</script>
<% } %>
</div>