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

View File

@ -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) { %><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">
<% list.forEach(function(m) {
const buckets = m.buckets || [];
@ -233,6 +250,10 @@
</div>
<% }); %>
</div>
<% if (groupName) { %>
</div>
</div>
<% } %>
<% }); %>
<% if (incidents.recent.length > 0) { %>

View File

@ -170,43 +170,44 @@ function pickMap(b: any, prefix: string): Record<string, string> {
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<string>();
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;

View File

@ -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 @@
</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>
<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>
<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) { %>
<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 { %>
@ -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]] || '';
%>
<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>
@ -110,42 +135,86 @@
<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="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 %>]"
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">
<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-36 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');
// Drag-and-drop reorder for both groups and monitors
function enableDrag(listId, rowClass) {
var list = document.getElementById(listId);
if (!list) return;
let dragging = null;
list.querySelectorAll('.monitor-edit-row').forEach((row) => {
row.addEventListener('dragstart', (e) => {
var dragging = null;
list.addEventListener('dragstart', function(e) {
var row = e.target.closest('.' + rowClass);
if (!row) return;
dragging = row;
row.style.opacity = '0.4';
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
});
row.addEventListener('dragend', () => {
row.style.opacity = '';
list.addEventListener('dragend', function(e) {
var row = e.target.closest('.' + rowClass);
if (row) row.style.opacity = '';
dragging = null;
});
row.addEventListener('dragover', (e) => {
list.addEventListener('dragover', function(e) {
e.preventDefault();
if (!dragging || dragging === row) return;
const rect = row.getBoundingClientRect();
const after = (e.clientY - rect.top) > rect.height / 2;
var row = e.target.closest('.' + rowClass);
if (!row || !dragging || dragging === row) return;
var rect = row.getBoundingClientRect();
var after = (e.clientY - rect.top) > rect.height / 2;
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>