fix: choose buckets
This commit is contained in:
parent
db9c63a096
commit
27d8630611
|
|
@ -5,6 +5,7 @@ import sql from "../db";
|
|||
const Theme = t.Union([t.Literal("auto"), t.Literal("light"), t.Literal("dark")]);
|
||||
const Window = t.Union([t.Literal("24h"), t.Literal("7d"), t.Literal("30d"), t.Literal("90d")]);
|
||||
const DisplayMode = t.Union([t.Literal("compact"), t.Literal("expanded")]);
|
||||
const BarFrequency = t.Union([t.Literal("hourly"), t.Literal("daily")]);
|
||||
|
||||
const StatusPageBody = t.Object({
|
||||
slug: t.String({ minLength: 1, maxLength: 80, pattern: "^[a-z0-9][a-z0-9-]*$", description: "URL slug, lowercase + hyphens" }),
|
||||
|
|
@ -18,6 +19,8 @@ const StatusPageBody = t.Object({
|
|||
show_cert_expiry: t.Optional(t.Boolean()),
|
||||
default_window: t.Optional(Window),
|
||||
display_mode: t.Optional(DisplayMode),
|
||||
bar_frequency: t.Optional(BarFrequency),
|
||||
bar_count: t.Optional(t.Number({ minimum: 1, maximum: 180 })),
|
||||
custom_css: t.Optional(t.Nullable(t.String({ maxLength: 50_000 }))),
|
||||
footer_text: t.Optional(t.Nullable(t.String({ maxLength: 5000 }))),
|
||||
og_image_url: t.Optional(t.Nullable(t.String({ maxLength: 2048 }))),
|
||||
|
|
@ -126,6 +129,7 @@ export const statusPages = new Elysia({ prefix: "/status-pages" })
|
|||
INSERT INTO status_pages (
|
||||
account_id, slug, title, description, theme, password_hash, index_search,
|
||||
show_powered_by, show_response_time, show_cert_expiry, default_window, display_mode,
|
||||
bar_frequency, bar_count,
|
||||
custom_css, footer_text, og_image_url, analytics_html, auto_refresh_s
|
||||
)
|
||||
VALUES (
|
||||
|
|
@ -133,6 +137,7 @@ export const statusPages = new Elysia({ prefix: "/status-pages" })
|
|||
${body.theme ?? 'auto'}, ${password_hash}, ${body.index_search ?? true},
|
||||
${body.show_powered_by ?? true}, ${body.show_response_time ?? true},
|
||||
${body.show_cert_expiry ?? false}, ${body.default_window ?? '24h'}, ${body.display_mode ?? 'expanded'},
|
||||
${body.bar_frequency ?? 'daily'}, ${body.bar_count ?? 90},
|
||||
${css}, ${body.footer_text ?? null}, ${body.og_image_url ?? null},
|
||||
${body.analytics_html ?? null}, ${body.auto_refresh_s ?? 60}
|
||||
)
|
||||
|
|
@ -189,6 +194,8 @@ export const statusPages = new Elysia({ prefix: "/status-pages" })
|
|||
show_cert_expiry = COALESCE(${body.show_cert_expiry ?? null}, show_cert_expiry),
|
||||
default_window = COALESCE(${body.default_window ?? null}, default_window),
|
||||
display_mode = COALESCE(${body.display_mode ?? null}, display_mode),
|
||||
bar_frequency = COALESCE(${body.bar_frequency ?? null}, bar_frequency),
|
||||
bar_count = COALESCE(${body.bar_count ?? null}, bar_count),
|
||||
custom_css = CASE WHEN ${body.custom_css !== undefined} THEN ${css} ELSE custom_css END,
|
||||
footer_text = COALESCE(${body.footer_text ?? null}, footer_text),
|
||||
og_image_url = COALESCE(${body.og_image_url ?? null}, og_image_url),
|
||||
|
|
|
|||
|
|
@ -155,6 +155,10 @@ export async function migrate(sql: any) {
|
|||
// Display mode: 'compact' = one-line rows with click-to-expand details,
|
||||
// 'expanded' = full detail card always visible (default).
|
||||
await sql`ALTER TABLE status_pages ADD COLUMN IF NOT EXISTS display_mode TEXT NOT NULL DEFAULT 'expanded'`;
|
||||
// Heartbeat bar settings: granularity + how many bars. Independent from
|
||||
// default_window (which still drives the primary uptime % + multi-window cells).
|
||||
await sql`ALTER TABLE status_pages ADD COLUMN IF NOT EXISTS bar_frequency TEXT NOT NULL DEFAULT 'daily'`;
|
||||
await sql`ALTER TABLE status_pages ADD COLUMN IF NOT EXISTS bar_count INTEGER NOT NULL DEFAULT 90`;
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS status_page_groups (
|
||||
|
|
|
|||
|
|
@ -7,13 +7,6 @@ import sql from "./db";
|
|||
export type Window = "24h" | "7d" | "30d" | "90d";
|
||||
export type BucketType = "hourly" | "daily";
|
||||
|
||||
const WINDOW_TO_BUCKET: Record<Window, { bucket: BucketType; count: number }> = {
|
||||
"24h": { bucket: "hourly", count: 24 },
|
||||
"7d": { bucket: "daily", count: 7 },
|
||||
"30d": { bucket: "daily", count: 30 },
|
||||
"90d": { bucket: "daily", count: 90 }, // 90 daily bars; 90 rows per monitor, cached.
|
||||
};
|
||||
|
||||
export interface StatusPageRow {
|
||||
id: string;
|
||||
account_id: string;
|
||||
|
|
@ -28,6 +21,8 @@ export interface StatusPageRow {
|
|||
show_cert_expiry: boolean;
|
||||
default_window: Window;
|
||||
display_mode: "compact" | "expanded";
|
||||
bar_frequency: BucketType;
|
||||
bar_count: number;
|
||||
custom_css: string | null;
|
||||
footer_text: string | null;
|
||||
og_image_url: string | null;
|
||||
|
|
@ -153,7 +148,13 @@ export async function loadGroups(pageId: string): Promise<GroupRow[]> {
|
|||
`;
|
||||
}
|
||||
|
||||
export async function loadMonitors(pageId: string, window: Window, pageDisplayMode: "compact" | "expanded" = "expanded"): Promise<MonitorRow[]> {
|
||||
export async function loadMonitors(
|
||||
pageId: string,
|
||||
window: Window,
|
||||
pageDisplayMode: "compact" | "expanded" = "expanded",
|
||||
barFrequency: BucketType = "daily",
|
||||
barCount: number = 90,
|
||||
): Promise<MonitorRow[]> {
|
||||
// Step 1: page → monitors with display overrides + group + position.
|
||||
const monitorRows = await sql<any[]>`
|
||||
SELECT
|
||||
|
|
@ -188,11 +189,13 @@ export async function loadMonitors(pageId: string, window: Window, pageDisplayMo
|
|||
});
|
||||
}
|
||||
|
||||
// Step 3: uptime rollup buckets covering the requested window. We keep
|
||||
// region in the result so JS can pick the fastest region per monitor and
|
||||
// emit per-bucket latency from just that region (status pages are
|
||||
// customer-facing, we show our best foot forward).
|
||||
const { bucket, count } = WINDOW_TO_BUCKET[window];
|
||||
// Step 3: uptime rollup buckets covering the requested window. The bar
|
||||
// frequency + count are admin-controlled per page (independent of the
|
||||
// multi-window uptime cells). We keep region in the result so JS can pick
|
||||
// the fastest region per monitor and emit per-bucket latency from just
|
||||
// that region (status pages are customer-facing — show the best line).
|
||||
const bucket: BucketType = barFrequency;
|
||||
const count = Math.max(1, Math.min(180, barCount));
|
||||
const truncUnit = bucket === "hourly" ? "hour" : "day";
|
||||
const intervalLiteral = `${count} ${truncUnit}s`;
|
||||
let rollupRows = await sql<any[]>`
|
||||
|
|
@ -432,7 +435,7 @@ export async function loadMonitorDetail(slug: string, monitorId: string, window?
|
|||
const win = (window ?? page.default_window) as Window;
|
||||
// Reuse the bulk loader with a single-monitor list — keeps the bucket/state
|
||||
// logic in one place. Cheap because we're querying for one ID.
|
||||
const monitors = await loadMonitors(page.id, win, page.display_mode);
|
||||
const monitors = await loadMonitors(page.id, win, page.display_mode, page.bar_frequency, page.bar_count);
|
||||
const m = monitors.find((x) => x.id === monitorId);
|
||||
if (!m) return null;
|
||||
|
||||
|
|
@ -493,7 +496,7 @@ export async function loadPagePayload(slug: string, window?: Window): Promise<Pa
|
|||
const win = (window ?? page.default_window) as Window;
|
||||
const [groups, monitors, incidents] = await Promise.all([
|
||||
loadGroups(page.id),
|
||||
loadMonitors(page.id, win, page.display_mode),
|
||||
loadMonitors(page.id, win, page.display_mode, page.bar_frequency, page.bar_count),
|
||||
loadIncidents(page.id),
|
||||
]);
|
||||
const { password_hash, ...publicPage } = page;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
// class on click. Reads its config from window.PINGQL_PAGE.
|
||||
(function () {
|
||||
var cfg = window.PINGQL_PAGE || {};
|
||||
var defaultWindow = cfg.default_window || "24h";
|
||||
var barFrequency = cfg.bar_frequency || "daily";
|
||||
|
||||
// ── Click to expand/collapse ──────────────────────────────────────────
|
||||
var rows = document.querySelectorAll(".monitor .monitor-row");
|
||||
|
|
@ -23,13 +23,13 @@
|
|||
var tooltip = document.getElementById("bar-tooltip");
|
||||
if (!tooltip) return;
|
||||
|
||||
var bucketSpanMs = (defaultWindow === "24h") ? 3600 * 1000 : 86400 * 1000;
|
||||
var bucketSpanMs = (barFrequency === "hourly") ? 3600 * 1000 : 86400 * 1000;
|
||||
function fmtBucketRange(startIso) {
|
||||
var start = new Date(startIso);
|
||||
var end = new Date(start.getTime() + bucketSpanMs);
|
||||
var dateOpts = { month: "short", day: "numeric" };
|
||||
var timeOpts = { hour: "2-digit", minute: "2-digit" };
|
||||
if (defaultWindow === "24h") {
|
||||
if (barFrequency === "hourly") {
|
||||
return start.toLocaleDateString(undefined, dateOpts) + ", " +
|
||||
start.toLocaleTimeString(undefined, timeOpts) + " — " +
|
||||
end.toLocaleTimeString(undefined, timeOpts);
|
||||
|
|
|
|||
|
|
@ -257,10 +257,9 @@
|
|||
// page-level isCompact is kept only as a hint for whether to emit the
|
||||
// expand JS at all.
|
||||
const anyCompact = monitors.some(m => m.display_mode === 'compact');
|
||||
const windowLabel = page.default_window === '24h' ? 'Last 24 hours'
|
||||
: page.default_window === '7d' ? 'Last 7 days'
|
||||
: page.default_window === '30d' ? 'Last 30 days'
|
||||
: 'Last 90 days';
|
||||
const windowLabel = page.bar_frequency === 'hourly'
|
||||
? ('Last ' + page.bar_count + ' hour' + (page.bar_count === 1 ? '' : 's'))
|
||||
: ('Last ' + page.bar_count + ' day' + (page.bar_count === 1 ? '' : 's'));
|
||||
function fmtTimestamp(iso) {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
|
|
@ -366,7 +365,7 @@
|
|||
</main>
|
||||
|
||||
<div id="bar-tooltip" role="tooltip"></div>
|
||||
<script>window.PINGQL_PAGE = <%~ JSON.stringify({ slug: page.slug, default_window: page.default_window, show_response_time: !!page.show_response_time }) %>;</script>
|
||||
<script>window.PINGQL_PAGE = <%~ JSON.stringify({ slug: page.slug, default_window: page.default_window, bar_frequency: page.bar_frequency, show_response_time: !!page.show_response_time }) %>;</script>
|
||||
<script src="/_static/expand.js?v=<%= it.expandJsHash %>" defer></script>
|
||||
|
||||
<% if (page.auto_refresh_s > 0) { %>
|
||||
|
|
|
|||
|
|
@ -728,6 +728,8 @@ export const dashboard = new Elysia()
|
|||
theme: b.theme || "auto",
|
||||
default_window: b.default_window || "24h",
|
||||
display_mode: b.display_mode || "expanded",
|
||||
bar_frequency: b.bar_frequency || "daily",
|
||||
bar_count: Number(b.bar_count) || 90,
|
||||
show_response_time: !!b.show_response_time,
|
||||
show_powered_by: !!b.show_powered_by,
|
||||
index_search: !!b.index_search,
|
||||
|
|
@ -756,6 +758,8 @@ export const dashboard = new Elysia()
|
|||
theme: b.theme || "auto",
|
||||
default_window: b.default_window || "24h",
|
||||
display_mode: b.display_mode || "expanded",
|
||||
bar_frequency: b.bar_frequency || "daily",
|
||||
bar_count: Number(b.bar_count) || 90,
|
||||
show_response_time: !!b.show_response_time,
|
||||
show_powered_by: !!b.show_powered_by,
|
||||
index_search: !!b.index_search,
|
||||
|
|
|
|||
|
|
@ -79,6 +79,26 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm text-gray-400 mb-1.5">Heartbeat bar granularity</label>
|
||||
<select name="bar_frequency" class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
||||
<% [['daily','One bar per day'],['hourly','One bar per hour']].forEach(function([v, label]) { %>
|
||||
<option value="<%= v %>" <%= (p.bar_frequency || 'daily') === v ? 'selected' : '' %>><%= label %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm text-gray-400 mb-1.5">Number of bars</label>
|
||||
<select name="bar_count" class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
||||
<% [12, 24, 48, 7, 14, 30, 60, 90, 120, 180].forEach(function(n) { %>
|
||||
<option value="<%= n %>" <%= Number(p.bar_count || 90) === n ? 'selected' : '' %>><%= n %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue