update: status page, api
This commit is contained in:
parent
5b3994b042
commit
8f7ac6bb4b
|
|
@ -31,6 +31,7 @@ const StatusPageBody = t.Object({
|
||||||
monitor_id: t.String(),
|
monitor_id: t.String(),
|
||||||
group_index: t.Optional(t.Nullable(t.Number())),
|
group_index: t.Optional(t.Nullable(t.Number())),
|
||||||
display_name: t.Optional(t.Nullable(t.String({ maxLength: 200 }))),
|
display_name: t.Optional(t.Nullable(t.String({ maxLength: 200 }))),
|
||||||
|
display_mode: t.Optional(t.Nullable(DisplayMode)),
|
||||||
position: t.Optional(t.Number()),
|
position: t.Optional(t.Number()),
|
||||||
}))),
|
}))),
|
||||||
});
|
});
|
||||||
|
|
@ -53,7 +54,7 @@ async function replaceGroupsAndMonitors(
|
||||||
pageId: string,
|
pageId: string,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
groups: { name: string; position?: number }[] | undefined,
|
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) {
|
if (groups !== undefined) {
|
||||||
await sql`DELETE FROM status_page_groups WHERE status_page_id = ${pageId}`;
|
await sql`DELETE FROM status_page_groups WHERE status_page_id = ${pageId}`;
|
||||||
|
|
@ -92,12 +93,13 @@ async function replaceGroupsAndMonitors(
|
||||||
monitor_id: m.monitor_id,
|
monitor_id: m.monitor_id,
|
||||||
group_id: groupId,
|
group_id: groupId,
|
||||||
display_name: m.display_name ?? null,
|
display_name: m.display_name ?? null,
|
||||||
|
display_mode: m.display_mode ?? null,
|
||||||
position: m.position ?? i,
|
position: m.position ?? i,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
await sql`
|
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")}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)`;
|
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`
|
await sql`
|
||||||
CREATE TABLE IF NOT EXISTS incidents (
|
CREATE TABLE IF NOT EXISTS incidents (
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ export interface MonitorRow {
|
||||||
url: string;
|
url: string;
|
||||||
group_id: string | null;
|
group_id: string | null;
|
||||||
position: number;
|
position: number;
|
||||||
|
display_mode: "compact" | "expanded"; // resolved (per-monitor override → page default → 'expanded')
|
||||||
current_state: "up" | "down" | "unknown";
|
current_state: "up" | "down" | "unknown";
|
||||||
region_states: Array<{ region: string; state: "up" | "down" | "unknown"; updated_at: string | null }>;
|
region_states: Array<{ region: string; state: "up" | "down" | "unknown"; updated_at: string | null }>;
|
||||||
uptime_pct: number | null; // for the page's default_window
|
uptime_pct: number | null; // for the page's default_window
|
||||||
|
|
@ -121,6 +122,13 @@ export interface GroupRow {
|
||||||
position: number;
|
position: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IncidentUpdateRow {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
body_html: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IncidentSummary {
|
export interface IncidentSummary {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -129,7 +137,7 @@ export interface IncidentSummary {
|
||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
started_at: string;
|
started_at: string;
|
||||||
resolved_at: string | null;
|
resolved_at: string | null;
|
||||||
latest_update_html: string | null;
|
updates: IncidentUpdateRow[]; // full timeline, newest first
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadStatusPage(slug: string): Promise<StatusPageRow | null> {
|
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.
|
// Step 1: page → monitors with display overrides + group + position.
|
||||||
const monitorRows = await sql<any[]>`
|
const monitorRows = await sql<any[]>`
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -153,7 +161,8 @@ export async function loadMonitors(pageId: string, window: Window): Promise<Moni
|
||||||
COALESCE(spm.display_name, m.name) AS display_name,
|
COALESCE(spm.display_name, m.name) AS display_name,
|
||||||
m.url,
|
m.url,
|
||||||
spm.group_id,
|
spm.group_id,
|
||||||
spm.position
|
spm.position,
|
||||||
|
spm.display_mode AS spm_display_mode
|
||||||
FROM status_page_monitors spm
|
FROM status_page_monitors spm
|
||||||
JOIN monitors m ON m.id = spm.monitor_id
|
JOIN monitors m ON m.id = spm.monitor_id
|
||||||
WHERE spm.status_page_id = ${pageId}
|
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 latAcc = latencyByMonitor[m.id];
|
||||||
const avg_latency = latAcc && latAcc.n > 0 ? Math.round(latAcc.sum / latAcc.n) : null;
|
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 {
|
return {
|
||||||
id: m.id,
|
id: m.id,
|
||||||
display_name: m.display_name,
|
display_name: m.display_name,
|
||||||
url: m.url,
|
url: m.url,
|
||||||
group_id: m.group_id,
|
group_id: m.group_id,
|
||||||
position: m.position,
|
position: m.position,
|
||||||
|
display_mode,
|
||||||
current_state,
|
current_state,
|
||||||
region_states,
|
region_states,
|
||||||
uptime_pct,
|
uptime_pct,
|
||||||
|
|
@ -327,15 +341,24 @@ export async function loadIncidents(pageId: string): Promise<{ active: IncidentS
|
||||||
if (incidents.length === 0) return { active: [], recent: [] };
|
if (incidents.length === 0) return { active: [], recent: [] };
|
||||||
|
|
||||||
const ids = incidents.map((i) => i.id);
|
const ids = incidents.map((i) => i.id);
|
||||||
// Latest update html per incident.
|
// Full timeline per incident (newest first), so the public page can show the
|
||||||
const latestUpdates = await sql<any[]>`
|
// entire course of events on both active and resolved incidents.
|
||||||
SELECT DISTINCT ON (incident_id) incident_id, body_html, status, created_at
|
const allUpdates = await sql<any[]>`
|
||||||
|
SELECT id, incident_id, status, body_html, created_at
|
||||||
FROM incident_updates
|
FROM incident_updates
|
||||||
WHERE incident_id = ANY(${sql.array(ids)}::uuid[])
|
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> = {};
|
const updatesByIncident: Record<string, IncidentUpdateRow[]> = {};
|
||||||
for (const u of latestUpdates) latestByIncident[u.incident_id] = u.body_html;
|
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) => ({
|
const enriched: IncidentSummary[] = incidents.map((i) => ({
|
||||||
id: i.id,
|
id: i.id,
|
||||||
|
|
@ -345,7 +368,7 @@ export async function loadIncidents(pageId: string): Promise<{ active: IncidentS
|
||||||
pinned: i.pinned,
|
pinned: i.pinned,
|
||||||
started_at: i.started_at instanceof Date ? i.started_at.toISOString() : String(i.started_at),
|
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,
|
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);
|
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;
|
const win = (window ?? page.default_window) as Window;
|
||||||
// Reuse the bulk loader with a single-monitor list — keeps the bucket/state
|
// 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.
|
// 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);
|
const m = monitors.find((x) => x.id === monitorId);
|
||||||
if (!m) return null;
|
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[]>`
|
const incidentRows = await sql<any[]>`
|
||||||
SELECT i.*
|
SELECT i.*
|
||||||
FROM incidents i
|
FROM incidents i
|
||||||
|
|
@ -391,14 +414,22 @@ export async function loadMonitorDetail(slug: string, monitorId: string, window?
|
||||||
let incidents: IncidentSummary[] = [];
|
let incidents: IncidentSummary[] = [];
|
||||||
if (incidentRows.length > 0) {
|
if (incidentRows.length > 0) {
|
||||||
const ids = incidentRows.map((i) => i.id);
|
const ids = incidentRows.map((i) => i.id);
|
||||||
const updates = await sql<any[]>`
|
const allUpdates = await sql<any[]>`
|
||||||
SELECT DISTINCT ON (incident_id) incident_id, body_html
|
SELECT id, incident_id, status, body_html, created_at
|
||||||
FROM incident_updates
|
FROM incident_updates
|
||||||
WHERE incident_id = ANY(${sql.array(ids)}::uuid[])
|
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> = {};
|
const updatesByIncident: Record<string, IncidentUpdateRow[]> = {};
|
||||||
for (const u of updates) latestByIncident[u.incident_id] = u.body_html;
|
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) => ({
|
incidents = incidentRows.map((i) => ({
|
||||||
id: i.id,
|
id: i.id,
|
||||||
title: i.title,
|
title: i.title,
|
||||||
|
|
@ -407,7 +438,7 @@ export async function loadMonitorDetail(slug: string, monitorId: string, window?
|
||||||
pinned: i.pinned,
|
pinned: i.pinned,
|
||||||
started_at: i.started_at instanceof Date ? i.started_at.toISOString() : String(i.started_at),
|
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,
|
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 win = (window ?? page.default_window) as Window;
|
||||||
const [groups, monitors, incidents] = await Promise.all([
|
const [groups, monitors, incidents] = await Promise.all([
|
||||||
loadGroups(page.id),
|
loadGroups(page.id),
|
||||||
loadMonitors(page.id, win),
|
loadMonitors(page.id, win, page.display_mode),
|
||||||
loadIncidents(page.id),
|
loadIncidents(page.id),
|
||||||
]);
|
]);
|
||||||
const { password_hash, ...publicPage } = page;
|
const { password_hash, ...publicPage } = page;
|
||||||
|
|
|
||||||
|
|
@ -141,18 +141,33 @@
|
||||||
.region.up { color: var(--green); border-color: rgba(16,185,129,0.3); }
|
.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); }
|
.region.down { color: var(--red); border-color: rgba(239,68,68,0.3); }
|
||||||
.incidents { margin-bottom: 2rem; }
|
.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 { 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(--red); }
|
.incident.critical { border-left-color: var(--bar-down); }
|
||||||
.incident.major { border-left-color: var(--amber); }
|
.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-title { font-weight: 600; margin-bottom: 0.25rem; }
|
||||||
.incident-meta { color: var(--muted); font-size: 0.8rem; margin-bottom: 0.5rem; }
|
.incident-meta { color: var(--muted); font-size: 0.8rem; margin-bottom: 0.5rem; }
|
||||||
.incident-body p { margin: 0.5rem 0; }
|
.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-body code { background: var(--bg); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.85em; }
|
.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 { margin-top: 3rem; }
|
||||||
.past-incidents h2 { font-size: 1.1rem; margin-bottom: 1rem; }
|
.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; }
|
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 { color: var(--accent); text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
|
|
@ -170,24 +185,59 @@
|
||||||
<span><%= overallText %></span>
|
<span><%= overallText %></span>
|
||||||
</div>
|
</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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
%>
|
||||||
<% if (incidents.active.length > 0) { %>
|
<% if (incidents.active.length > 0) { %>
|
||||||
<div class="incidents">
|
<div class="incidents">
|
||||||
<% incidents.active.forEach(function(i) { %>
|
<% incidents.active.forEach(function(i) { %><%~ renderIncident(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>
|
|
||||||
<% }); %>
|
|
||||||
</div>
|
</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'
|
const windowLabel = page.default_window === '24h' ? 'Last 24 hours'
|
||||||
: page.default_window === '7d' ? 'Last 7 days'
|
: page.default_window === '7d' ? 'Last 7 days'
|
||||||
: page.default_window === '30d' ? 'Last 30 days'
|
: page.default_window === '30d' ? 'Last 30 days'
|
||||||
: 'Last 90 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) {
|
<% groupOrder.forEach(function(gid) {
|
||||||
const list = grouped[gid];
|
const list = grouped[gid];
|
||||||
|
|
@ -200,8 +250,9 @@
|
||||||
const buckets = m.buckets || [];
|
const buckets = m.buckets || [];
|
||||||
const hasData = buckets.some(b => b.total > 0);
|
const hasData = buckets.some(b => b.total > 0);
|
||||||
const u = m.uptime || { d24: null, d7: null, d30: null, d90: null };
|
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 %>">
|
<div class="monitor compact" data-monitor-id="<%= m.id %>">
|
||||||
<button type="button" class="monitor-row" aria-expanded="false">
|
<button type="button" class="monitor-row" aria-expanded="false">
|
||||||
<div class="monitor-name">
|
<div class="monitor-name">
|
||||||
|
|
@ -260,14 +311,7 @@
|
||||||
<% if (incidents.recent.length > 0) { %>
|
<% if (incidents.recent.length > 0) { %>
|
||||||
<div class="past-incidents">
|
<div class="past-incidents">
|
||||||
<h2>Past incidents</h2>
|
<h2>Past incidents</h2>
|
||||||
<% incidents.recent.forEach(function(i) { %>
|
<% incidents.recent.forEach(function(i) { %><%~ renderIncident(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>
|
|
||||||
<% }); %>
|
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
|
|
@ -277,7 +321,7 @@
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<% if (isCompact) { %>
|
<% if (anyCompact) { %>
|
||||||
<script>
|
<script>
|
||||||
// Click-to-expand for compact display mode. First click on a monitor row
|
// Click-to-expand for compact display mode. First click on a monitor row
|
||||||
// fetches /<slug>/monitor/<id>.json once and renders the detail inline;
|
// 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>`
|
? `<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>` : '';
|
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)
|
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 `
|
return `
|
||||||
<div class="bars" title="${m.current_state}">${barsHtml}</div>
|
<div class="bars" title="${m.current_state}">${barsHtml}</div>
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,69 @@ async function getAccountId(cookie: any, headers: any): Promise<{ accountId: str
|
||||||
return await resolveKey(key) ?? null;
|
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");
|
const dashDir = resolve(import.meta.dir, "../dashboard");
|
||||||
|
|
||||||
export const dashboard = new Elysia()
|
export const dashboard = new Elysia()
|
||||||
|
|
@ -636,7 +699,9 @@ export const dashboard = new Elysia()
|
||||||
`;
|
`;
|
||||||
if (!page) return redirect("/dashboard/status-pages");
|
if (!page) return redirect("/dashboard/status-pages");
|
||||||
const monitors = await sql`
|
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`
|
const allMonitors = await sql`
|
||||||
SELECT id, name FROM monitors WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC
|
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);
|
const resolved = await getAccountId(cookie, headers);
|
||||||
if (!resolved?.accountId) return redirect("/dashboard");
|
if (!resolved?.accountId) return redirect("/dashboard");
|
||||||
const b = body as any;
|
const b = body as any;
|
||||||
const monitorIds = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []);
|
const { monitorsForApi } = parseStatusPageMonitors(b);
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
||||||
const key = cookie?.pingql_key?.value;
|
const key = cookie?.pingql_key?.value;
|
||||||
|
|
@ -684,7 +734,7 @@ export const dashboard = new Elysia()
|
||||||
password: b.password || undefined,
|
password: b.password || undefined,
|
||||||
custom_css: b.custom_css || null,
|
custom_css: b.custom_css || null,
|
||||||
footer_text: b.footer_text || 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 {}
|
} catch {}
|
||||||
|
|
@ -695,18 +745,7 @@ export const dashboard = new Elysia()
|
||||||
const resolved = await getAccountId(cookie, headers);
|
const resolved = await getAccountId(cookie, headers);
|
||||||
if (!resolved?.accountId) return redirect("/dashboard");
|
if (!resolved?.accountId) return redirect("/dashboard");
|
||||||
const b = body as any;
|
const b = body as any;
|
||||||
const monitorIds = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []);
|
const { monitorsForApi } = parseStatusPageMonitors(b);
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
||||||
const key = cookie?.pingql_key?.value;
|
const key = cookie?.pingql_key?.value;
|
||||||
|
|
@ -722,7 +761,7 @@ export const dashboard = new Elysia()
|
||||||
index_search: !!b.index_search,
|
index_search: !!b.index_search,
|
||||||
custom_css: b.custom_css || null,
|
custom_css: b.custom_css || null,
|
||||||
footer_text: b.footer_text || 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
|
// Only send `password` if the user actually typed something. An empty box
|
||||||
// means "leave the existing password as-is" — sending null would clear it.
|
// means "leave the existing password as-is" — sending null would clear it.
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,22 @@
|
||||||
const allMonitors = it.allMonitors || [];
|
const allMonitors = it.allMonitors || [];
|
||||||
const attachedRows = (it.page?.monitors || []);
|
const attachedRows = (it.page?.monitors || []);
|
||||||
const attached = new Set(attachedRows.map(m => m.monitor_id));
|
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 = {};
|
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">
|
<main class="max-w-3xl mx-auto px-8 py-10">
|
||||||
|
|
@ -68,25 +81,62 @@
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-400 mb-1.5">Monitors</label>
|
<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) { %>
|
<% 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>
|
<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 { %>
|
<% } else { %>
|
||||||
<div class="space-y-2">
|
<div id="monitor-list" class="space-y-2">
|
||||||
<% allMonitors.forEach(function(m) {
|
<% orderedMonitors.forEach(function(m) {
|
||||||
const isAttached = attached.has(m.id);
|
const isAttached = attached.has(m.id);
|
||||||
const displayName = displayNames[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">
|
<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' : '' %>>
|
<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>
|
<span class="text-sm text-gray-300 truncate"><%= m.name %></span>
|
||||||
</label>
|
</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)"
|
<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">
|
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>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue