feat: add group system

This commit is contained in:
nate 2026-04-10 02:25:46 +04:00
parent 51d7050d66
commit 1176d4e1b2
4 changed files with 157 additions and 55 deletions

View File

@ -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 { 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; } .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; } } @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; } .monitors { display: flex; flex-direction: column; gap: 0.5rem; }
/* All monitors share one structure: a clickable header row + a collapsible /* All monitors share one structure: a clickable header row + a collapsible
detail panel. display_mode just controls whether `expanded-state` is set detail panel. display_mode just controls whether `expanded-state` is set

View File

@ -181,12 +181,29 @@
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); 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]; const list = grouped[gid];
if (!list || list.length === 0) return; if (!list || list.length === 0) return;
const groupName = gid ? (groups.find(g => g.id === gid)?.name || '') : ''; 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) { %><div class="group-title"><%= groupName %></div><% } %> <% if (groupName) { %>
<div class="group-section">
<input type="checkbox" class="group-toggle" id="g-<%= gi %>" checked>
<label class="group-header" for="g-<%= gi %>">
<div class="group-header-left">
<svg class="group-chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
<span class="group-name"><%= groupName %></span>
</div>
<span class="group-status" style="color: <%= groupStatusColor %>;"><%= groupStatusLabel %></span>
</label>
<div class="group-body">
<% } %>
<div class="monitors"> <div class="monitors">
<% list.forEach(function(m) { <% list.forEach(function(m) {
const buckets = m.buckets || []; const buckets = m.buckets || [];
@ -233,6 +250,10 @@
</div> </div>
<% }); %> <% }); %>
</div> </div>
<% if (groupName) { %>
</div>
</div>
<% } %>
<% }); %> <% }); %>
<% if (incidents.recent.length > 0) { %> <% if (incidents.recent.length > 0) { %>

View File

@ -170,43 +170,44 @@ function pickMap(b: any, prefix: string): Record<string, string> {
return out; return out;
} }
function parseStatusPageMonitors(b: any): { function parseStatusPageForm(b: any): {
monitorIds: string[]; groupsForApi: Array<{ name: string; position: number }>;
monitorsForApi: Array<{ monitor_id: string; position: number; display_name: string | null; display_mode: string | null }>; 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 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 checked: string[] = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []);
const checkedSet = new Set(checked); const checkedSet = new Set(checked);
const displayNames = pickMap(b, "display_name"); const displayNames = pickMap(b, "display_name");
const displayModes = pickMap(b, "display_mode"); const displayModes = pickMap(b, "display_mode");
const monitorGroupMap = pickMap(b, "monitor_group");
// Walk the rendered order and keep only the checked monitors. Position is const seen = new Set<string>();
// their index in this filtered list. const monitorsForApi: Array<{ monitor_id: string; position: number; group_index: number | null; display_name: string | null; display_mode: string | null }> = [];
const monitorIds: string[] = [];
const monitorsForApi: Array<{ monitor_id: string; position: number; display_name: string | null; display_mode: string | null }> = []; function addMonitor(id: string) {
for (const id of order) { if (seen.has(id)) return;
if (!checkedSet.has(id)) continue; seen.add(id);
const gi = monitorGroupMap[id];
const groupIndex = gi !== undefined && gi !== '' ? Number(gi) : null;
monitorsForApi.push({ monitorsForApi.push({
monitor_id: id, 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_name: displayNames[id] ?? null,
display_mode: (displayModes[id] === "compact" || displayModes[id] === "expanded") ? displayModes[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 order) { if (checkedSet.has(id)) addMonitor(id); }
for (const id of checked) { for (const id of checked) { addMonitor(id); }
if (monitorIds.includes(id)) continue;
monitorsForApi.push({ return { groupsForApi, monitorsForApi };
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");
@ -706,15 +707,13 @@ export const dashboard = new Elysia()
SELECT * FROM status_pages WHERE id = ${params.id} AND account_id = ${resolved.accountId} SELECT * FROM status_pages WHERE id = ${params.id} AND account_id = ${resolved.accountId}
`; `;
if (!page) return redirect("/dashboard/status-pages"); if (!page) return redirect("/dashboard/status-pages");
const monitors = await sql` const [monitors, groups, allMonitors] = await Promise.all([
SELECT monitor_id, display_name, display_mode sql`SELECT monitor_id, display_name, display_mode, group_id FROM status_page_monitors WHERE status_page_id = ${params.id} ORDER BY position ASC`,
FROM status_page_monitors WHERE status_page_id = ${params.id} sql`SELECT id, name, position FROM status_page_groups WHERE status_page_id = ${params.id} ORDER BY position ASC`,
ORDER BY position ASC sql`SELECT id, name FROM monitors WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC`,
`; ]);
const allMonitors = await sql`
SELECT id, name FROM monitors WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC
`;
page.monitors = monitors; page.monitors = monitors;
page.groups = groups;
return html("status-page-edit", { nav: "status-pages", isNew: false, page, allMonitors }); 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); 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 { monitorsForApi } = parseStatusPageMonitors(b); const { groupsForApi, monitorsForApi } = parseStatusPageForm(b);
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;
@ -747,6 +746,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,
groups: groupsForApi,
monitors: monitorsForApi, monitors: monitorsForApi,
}), }),
}); });
@ -758,7 +758,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 { monitorsForApi } = parseStatusPageMonitors(b); const { groupsForApi, monitorsForApi } = parseStatusPageForm(b);
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;

View File

@ -5,14 +5,20 @@
const p = it.page || {}; const p = it.page || {};
const allMonitors = it.allMonitors || []; const allMonitors = it.allMonitors || [];
const attachedRows = (it.page?.monitors || []); const attachedRows = (it.page?.monitors || []);
const existingGroups = (it.page?.groups || []);
const attached = new Set(attachedRows.map(m => m.monitor_id)); 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 displayNames = {};
const displayModes = {}; const displayModes = {};
const monitorGroups = {};
for (const r of attachedRows) { for (const r of attachedRows) {
displayNames[r.monitor_id] = r.display_name || ''; displayNames[r.monitor_id] = r.display_name || '';
displayModes[r.monitor_id] = r.display_mode || ''; 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 // Render order: attached monitors first in their saved order, then any
// unattached ones (alphabetical) so the user can drag them in. // unattached ones (alphabetical) so the user can drag them in.
const attachedOrder = attachedRows const attachedOrder = attachedRows
@ -91,9 +97,27 @@
</div> </div>
<p class="text-xs text-gray-600 -mt-3">The heartbeat bar shows the last N hours or days. The four uptime cells (24h / 7d / 30d / 90d) are independent and always present.</p> <p class="text-xs text-gray-600 -mt-3">The heartbeat bar shows the last N hours or days. The four uptime cells (24h / 7d / 30d / 90d) are independent and always present.</p>
<div>
<div class="flex items-center justify-between mb-1.5">
<label class="text-sm text-gray-400">Groups <span class="text-gray-600">(optional)</span></label>
<button type="button" id="add-group-btn" class="text-xs text-blue-400 hover:text-blue-300">+ Add group</button>
</div>
<p class="text-xs text-gray-600 mb-2">Monitors can be organized into collapsible groups on the public page. Drag to reorder.</p>
<div id="group-list" class="space-y-2">
<% existingGroups.forEach(function(g, i) { %>
<div class="group-edit-row flex items-center gap-3 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2" draggable="true">
<span class="drag-handle text-gray-600 cursor-grab select-none px-1" title="Drag to reorder">⋮⋮</span>
<input type="text" name="group_names" value="<%= g.name %>" placeholder="Group name" required
class="flex-1 text-sm bg-gray-950 border border-gray-800 rounded px-3 py-1.5 text-gray-200 placeholder-gray-600 focus:outline-none focus:border-blue-500">
<button type="button" onclick="this.parentElement.remove(); updateGroupDropdowns();" class="px-2 text-gray-600 hover:text-red-400 text-sm">&#10005;</button>
</div>
<% }) %>
</div>
</div>
<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">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> <p class="text-xs text-gray-600 mb-2">Tick to attach. Drag to reorder. Assign to a group, override the display name, or pick a display mode per monitor.</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 { %>
@ -102,6 +126,7 @@
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] || ''; const displayMode = displayModes[m.id] || '';
const groupIdx = groupIdToIndex[monitorGroups[m.id]] || '';
%> %>
<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 %>"> <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> <span class="drag-handle text-gray-600 cursor-grab select-none px-1" title="Drag to reorder">⋮⋮</span>
@ -110,42 +135,86 @@
<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="monitor_group[<%= m.id %>]" class="group-select 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" data-current="<%= groupIdx %>">
<option value="">Ungrouped</option>
<% existingGroups.forEach(function(g, i) { %>
<option value="<%= i %>" <%= groupIdx === String(i) ? 'selected' : '' %>><%= g.name %></option>
<% }) %>
</select>
<select name="display_mode[<%= m.id %>]" <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"> 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="" <%= displayMode === '' ? 'selected' : '' %>>Default</option>
<option value="expanded" <%= displayMode === 'expanded' ? 'selected' : '' %>>Expanded</option> <option value="expanded" <%= displayMode === 'expanded' ? 'selected' : '' %>>Expanded</option>
<option value="compact" <%= displayMode === 'compact' ? 'selected' : '' %>>Compact</option> <option value="compact" <%= displayMode === 'compact' ? 'selected' : '' %>>Compact</option>
</select> </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"
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-36 shrink-0">
</div> </div>
<% }) %> <% }) %>
</div> </div>
<script> <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() { (function() {
const list = document.getElementById('monitor-list'); // Drag-and-drop reorder for both groups and monitors
if (!list) return; function enableDrag(listId, rowClass) {
let dragging = null; var list = document.getElementById(listId);
list.querySelectorAll('.monitor-edit-row').forEach((row) => { if (!list) return;
row.addEventListener('dragstart', (e) => { var dragging = null;
list.addEventListener('dragstart', function(e) {
var row = e.target.closest('.' + rowClass);
if (!row) return;
dragging = row; dragging = row;
row.style.opacity = '0.4'; row.style.opacity = '0.4';
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'; if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
}); });
row.addEventListener('dragend', () => { list.addEventListener('dragend', function(e) {
row.style.opacity = ''; var row = e.target.closest('.' + rowClass);
if (row) row.style.opacity = '';
dragging = null; dragging = null;
}); });
row.addEventListener('dragover', (e) => { list.addEventListener('dragover', function(e) {
e.preventDefault(); e.preventDefault();
if (!dragging || dragging === row) return; var row = e.target.closest('.' + rowClass);
const rect = row.getBoundingClientRect(); if (!row || !dragging || dragging === row) return;
const after = (e.clientY - rect.top) > rect.height / 2; var rect = row.getBoundingClientRect();
var after = (e.clientY - rect.top) > rect.height / 2;
row.parentNode.insertBefore(dragging, after ? row.nextSibling : row); row.parentNode.insertBefore(dragging, after ? row.nextSibling : row);
}); });
}
enableDrag('group-list', 'group-edit-row');
enableDrag('monitor-list', 'monitor-edit-row');
// Add group button
document.getElementById('add-group-btn').addEventListener('click', function() {
var list = document.getElementById('group-list');
var row = document.createElement('div');
row.className = 'group-edit-row flex items-center gap-3 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2';
row.draggable = true;
row.innerHTML = '<span class="drag-handle text-gray-600 cursor-grab select-none px-1" title="Drag to reorder">\u22ee\u22ee</span>'
+ '<input type="text" name="group_names" value="" placeholder="Group name" required class="flex-1 text-sm bg-gray-950 border border-gray-800 rounded px-3 py-1.5 text-gray-200 placeholder-gray-600 focus:outline-none focus:border-blue-500">'
+ '<button type="button" onclick="this.parentElement.remove(); updateGroupDropdowns();" class="px-2 text-gray-600 hover:text-red-400 text-sm">&#10005;</button>';
list.appendChild(row);
row.querySelector('input').focus();
updateGroupDropdowns();
});
// Sync group dropdowns with current group names
window.updateGroupDropdowns = function() {
var names = [];
document.querySelectorAll('#group-list input[name="group_names"]').forEach(function(inp) {
names.push(inp.value || '(unnamed)');
});
document.querySelectorAll('.group-select').forEach(function(sel) {
var cur = sel.value;
var html = '<option value="">Ungrouped</option>';
names.forEach(function(name, i) {
html += '<option value="' + i + '"' + (cur === String(i) ? ' selected' : '') + '>' + name.replace(/</g, '&lt;') + '</option>';
});
sel.innerHTML = html;
});
};
// Update dropdowns when group names change
document.getElementById('group-list').addEventListener('input', function(e) {
if (e.target.name === 'group_names') updateGroupDropdowns();
}); });
})(); })();
</script> </script>