diff --git a/apps/status/src/static/app.css b/apps/status/src/static/app.css index 52d5a2d..0d6667a 100644 --- a/apps/status/src/static/app.css +++ b/apps/status/src/static/app.css @@ -44,7 +44,19 @@ h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem; } .overall { padding: 1rem 1.25rem; border-radius: 10px; font-weight: 600; font-size: 1rem; margin: 1.5rem 0 2rem; display: flex; align-items: center; gap: 0.75rem; border: 1px solid; } .overall .dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; animation: pulse-dot 2s ease-in-out infinite; } @keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } -.group-title { font-size: 0.85rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; margin: 2rem 0 0.75rem; } +.group-section { margin: 1.5rem 0; background: var(--card); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; } +.group-toggle { display: none; } +.group-header { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1.25rem; cursor: pointer; } +.group-header:hover { background: rgba(255,255,255,0.02); } +.group-header-left { display: flex; align-items: center; gap: 0.5rem; } +.group-chev { color: var(--muted); transition: transform 0.15s; flex-shrink: 0; } +.group-toggle:checked ~ .group-header .group-chev { transform: rotate(90deg); } +.group-name { font-size: 0.85rem; font-weight: 600; color: var(--fg); } +.group-status { font-size: 0.75rem; font-weight: 600; } +.group-body { display: none; padding: 0 0.75rem 0.75rem; } +.group-toggle:checked ~ .group-body { display: block; } +.group-body .monitors { gap: 0.35rem; } +.group-body .monitor { border-radius: 8px; } .monitors { display: flex; flex-direction: column; gap: 0.5rem; } /* All monitors share one structure: a clickable header row + a collapsible detail panel. display_mode just controls whether `expanded-state` is set diff --git a/apps/status/src/views/page.ejs b/apps/status/src/views/page.ejs index b61760e..6a9c55b 100644 --- a/apps/status/src/views/page.ejs +++ b/apps/status/src/views/page.ejs @@ -181,12 +181,29 @@ return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } %> - <% groupOrder.forEach(function(gid) { + <% groupOrder.forEach(function(gid, gi) { const list = grouped[gid]; if (!list || list.length === 0) return; const groupName = gid ? (groups.find(g => g.id === gid)?.name || '') : ''; + // Aggregate status for the group header + const activeInGroup = list.filter(m => m.current_state !== 'paused'); + const downInGroup = activeInGroup.filter(m => m.current_state === 'down').length; + const groupStatus = activeInGroup.length === 0 ? 'unknown' : downInGroup === activeInGroup.length ? 'down' : downInGroup > 0 ? 'degraded' : 'up'; + const groupStatusLabel = groupStatus === 'up' ? 'Operational' : groupStatus === 'degraded' ? 'Degraded' : groupStatus === 'down' ? 'Down' : ''; + const groupStatusColor = groupStatus === 'up' ? 'var(--bar-up)' : groupStatus === 'degraded' ? 'var(--bar-partial)' : groupStatus === 'down' ? 'var(--bar-down)' : 'var(--muted)'; %> - <% if (groupName) { %>
<%= groupName %>
<% } %> + <% if (groupName) { %> +
+ + +
+ <% } %>
<% list.forEach(function(m) { const buckets = m.buckets || []; @@ -233,6 +250,10 @@
<% }); %>
+ <% if (groupName) { %> +
+ + <% } %> <% }); %> <% if (incidents.recent.length > 0) { %> diff --git a/apps/web/src/routes/dashboard.ts b/apps/web/src/routes/dashboard.ts index 0a1ebed..30ab1a7 100644 --- a/apps/web/src/routes/dashboard.ts +++ b/apps/web/src/routes/dashboard.ts @@ -170,43 +170,44 @@ function pickMap(b: any, prefix: string): Record { return out; } -function parseStatusPageMonitors(b: any): { - monitorIds: string[]; - monitorsForApi: Array<{ monitor_id: string; position: number; display_name: string | null; display_mode: string | null }>; +function parseStatusPageForm(b: any): { + groupsForApi: Array<{ name: string; position: number }>; + monitorsForApi: Array<{ monitor_id: string; position: number; group_index: number | null; display_name: string | null; display_mode: string | null }>; } { + // Parse groups from form (ordered array of names) + const groupNames: string[] = Array.isArray(b.group_names) ? b.group_names : (b.group_names ? [b.group_names] : []); + const groupsForApi = groupNames + .filter((n: string) => n && n.trim()) + .map((n: string, i: number) => ({ name: n.trim(), position: i })); + 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"); + const monitorGroupMap = pickMap(b, "monitor_group"); - // 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; + const seen = new Set(); + const monitorsForApi: Array<{ monitor_id: string; position: number; group_index: number | null; display_name: string | null; display_mode: string | null }> = []; + + function addMonitor(id: string) { + if (seen.has(id)) return; + seen.add(id); + const gi = monitorGroupMap[id]; + const groupIndex = gi !== undefined && gi !== '' ? Number(gi) : null; monitorsForApi.push({ monitor_id: id, - position: monitorIds.length, + position: monitorsForApi.length, + group_index: (groupIndex !== null && groupIndex >= 0 && groupIndex < groupsForApi.length) ? groupIndex : null, 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 }; + + for (const id of order) { if (checkedSet.has(id)) addMonitor(id); } + for (const id of checked) { addMonitor(id); } + + return { groupsForApi, monitorsForApi }; } const dashDir = resolve(import.meta.dir, "../dashboard"); @@ -706,15 +707,13 @@ export const dashboard = new Elysia() 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, 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 - `; + const [monitors, groups, allMonitors] = await Promise.all([ + sql`SELECT monitor_id, display_name, display_mode, group_id FROM status_page_monitors WHERE status_page_id = ${params.id} ORDER BY position ASC`, + sql`SELECT id, name, position FROM status_page_groups WHERE status_page_id = ${params.id} ORDER BY position ASC`, + sql`SELECT id, name FROM monitors WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC`, + ]); page.monitors = monitors; + page.groups = groups; return html("status-page-edit", { nav: "status-pages", isNew: false, page, allMonitors }); }) @@ -722,7 +721,7 @@ export const dashboard = new Elysia() const resolved = await getAccountId(cookie, headers); if (!resolved?.accountId) return redirect("/dashboard"); const b = body as any; - const { monitorsForApi } = parseStatusPageMonitors(b); + const { groupsForApi, monitorsForApi } = parseStatusPageForm(b); try { const apiUrl = process.env.API_URL || "https://api.pingql.com"; const key = cookie?.pingql_key?.value; @@ -747,6 +746,7 @@ export const dashboard = new Elysia() password: b.password || undefined, custom_css: b.custom_css || null, footer_text: b.footer_text || null, + groups: groupsForApi, monitors: monitorsForApi, }), }); @@ -758,7 +758,7 @@ export const dashboard = new Elysia() const resolved = await getAccountId(cookie, headers); if (!resolved?.accountId) return redirect("/dashboard"); const b = body as any; - const { monitorsForApi } = parseStatusPageMonitors(b); + const { groupsForApi, monitorsForApi } = parseStatusPageForm(b); try { const apiUrl = process.env.API_URL || "https://api.pingql.com"; const key = cookie?.pingql_key?.value; diff --git a/apps/web/src/views/status-page-edit.ejs b/apps/web/src/views/status-page-edit.ejs index 52cf733..b425e6b 100644 --- a/apps/web/src/views/status-page-edit.ejs +++ b/apps/web/src/views/status-page-edit.ejs @@ -5,14 +5,20 @@ const p = it.page || {}; const allMonitors = it.allMonitors || []; const attachedRows = (it.page?.monitors || []); + const existingGroups = (it.page?.groups || []); const attached = new Set(attachedRows.map(m => m.monitor_id)); - // monitor_id → existing per-page overrides + // monitor_id -> existing per-page overrides const displayNames = {}; const displayModes = {}; + const monitorGroups = {}; for (const r of attachedRows) { displayNames[r.monitor_id] = r.display_name || ''; displayModes[r.monitor_id] = r.display_mode || ''; + monitorGroups[r.monitor_id] = r.group_id || ''; } + // Map group UUID -> index for the form + const groupIdToIndex = {}; + existingGroups.forEach(function(g, i) { groupIdToIndex[g.id] = String(i); }); // Render order: attached monitors first in their saved order, then any // unattached ones (alphabetical) so the user can drag them in. const attachedOrder = attachedRows @@ -91,9 +97,27 @@

The heartbeat bar shows the last N hours or days. The four uptime cells (24h / 7d / 30d / 90d) are independent and always present.

+
+
+ + +
+

Monitors can be organized into collapsible groups on the public page. Drag to reorder.

+
+ <% existingGroups.forEach(function(g, i) { %> +
+ ⋮⋮ + + +
+ <% }) %> +
+
+
-

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).

+

Tick to attach. Drag to reorder. Assign to a group, override the display name, or pick a display mode per monitor.

<% if (allMonitors.length === 0) { %>

No monitors yet. Create one first.

<% } else { %> @@ -102,6 +126,7 @@ const isAttached = attached.has(m.id); const displayName = displayNames[m.id] || ''; const displayMode = displayModes[m.id] || ''; + const groupIdx = groupIdToIndex[monitorGroups[m.id]] || ''; %>
⋮⋮ @@ -110,42 +135,86 @@ > <%= m.name %> + - +
<% }) %>