clean up
This commit is contained in:
parent
e376fd64fc
commit
94232f5851
|
|
@ -3,7 +3,6 @@ import { requireAuth } from "./auth";
|
|||
import sql from "../db";
|
||||
|
||||
const Theme = t.Union([t.Literal("auto"), t.Literal("light"), t.Literal("dark")]);
|
||||
const DisplayMode = t.Union([t.Literal("compact"), t.Literal("expanded")]);
|
||||
const BarFrequency = t.Union([t.Literal("hourly"), t.Literal("daily")]);
|
||||
|
||||
const StatusPageBody = t.Object({
|
||||
|
|
@ -16,7 +15,6 @@ const StatusPageBody = t.Object({
|
|||
show_powered_by: t.Optional(t.Boolean()),
|
||||
show_response_time: t.Optional(t.Boolean()),
|
||||
show_cert_expiry: t.Optional(t.Boolean()),
|
||||
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 }))),
|
||||
|
|
@ -32,7 +30,6 @@ const StatusPageBody = t.Object({
|
|||
monitor_id: t.String(),
|
||||
group_index: t.Optional(t.Nullable(t.Number())),
|
||||
display_name: t.Optional(t.Nullable(t.String({ maxLength: 200 }))),
|
||||
display_mode: t.Optional(t.Nullable(DisplayMode)),
|
||||
position: t.Optional(t.Number()),
|
||||
}))),
|
||||
});
|
||||
|
|
@ -55,7 +52,7 @@ async function replaceGroupsAndMonitors(
|
|||
pageId: string,
|
||||
accountId: string,
|
||||
groups: { name: string; position?: number }[] | undefined,
|
||||
monitorsList: { monitor_id: string; group_index?: number | null; display_name?: string | null; display_mode?: "compact" | "expanded" | null; position?: number }[] | undefined,
|
||||
monitorsList: { monitor_id: string; group_index?: number | null; display_name?: string | null; position?: number }[] | undefined,
|
||||
) {
|
||||
if (groups !== undefined) {
|
||||
await sql`DELETE FROM status_page_groups WHERE status_page_id = ${pageId}`;
|
||||
|
|
@ -99,13 +96,12 @@ async function replaceGroupsAndMonitors(
|
|||
monitor_id: m.monitor_id,
|
||||
group_id: groupId,
|
||||
display_name: m.display_name ?? null,
|
||||
display_mode: m.display_mode ?? null,
|
||||
position: m.position ?? i,
|
||||
});
|
||||
}
|
||||
if (rows.length > 0) {
|
||||
await sql`
|
||||
INSERT INTO status_page_monitors ${sql(rows, "status_page_id", "monitor_id", "group_id", "display_name", "display_mode", "position")}
|
||||
INSERT INTO status_page_monitors ${sql(rows, "status_page_id", "monitor_id", "group_id", "display_name", "position")}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -131,7 +127,7 @@ export const statusPages = new Elysia({ prefix: "/pages" })
|
|||
[row] = await sql`
|
||||
INSERT INTO status_pages (
|
||||
account_id, slug, title, description, theme, password_hash, index_search,
|
||||
show_powered_by, show_response_time, show_cert_expiry, display_mode,
|
||||
show_powered_by, show_response_time, show_cert_expiry,
|
||||
bar_frequency, bar_count,
|
||||
custom_css, footer_text, og_image_url, analytics_html, auto_refresh_s
|
||||
)
|
||||
|
|
@ -139,7 +135,7 @@ export const statusPages = new Elysia({ prefix: "/pages" })
|
|||
${accountId}, ${body.slug}, ${body.title}, ${body.description ?? null},
|
||||
${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.display_mode ?? 'expanded'},
|
||||
${body.show_cert_expiry ?? false},
|
||||
${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}
|
||||
|
|
@ -195,7 +191,6 @@ export const statusPages = new Elysia({ prefix: "/pages" })
|
|||
show_powered_by = COALESCE(${body.show_powered_by ?? null}, show_powered_by),
|
||||
show_response_time = COALESCE(${body.show_response_time ?? null}, show_response_time),
|
||||
show_cert_expiry = COALESCE(${body.show_cert_expiry ?? null}, show_cert_expiry),
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -130,7 +130,6 @@ export async function migrate(sql: any) {
|
|||
show_powered_by BOOLEAN NOT NULL DEFAULT true,
|
||||
show_response_time BOOLEAN NOT NULL DEFAULT true,
|
||||
show_cert_expiry BOOLEAN NOT NULL DEFAULT false,
|
||||
display_mode TEXT NOT NULL DEFAULT 'expanded',
|
||||
bar_frequency TEXT NOT NULL DEFAULT 'daily',
|
||||
bar_count INTEGER NOT NULL DEFAULT 90,
|
||||
custom_css TEXT,
|
||||
|
|
@ -162,7 +161,6 @@ export async function migrate(sql: any) {
|
|||
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
||||
group_id UUID REFERENCES status_page_groups(id) ON DELETE SET NULL,
|
||||
display_name TEXT,
|
||||
display_mode TEXT,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (status_page_id, monitor_id)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
import sql from "./db";
|
||||
|
||||
export type Window = "24h" | "7d" | "30d" | "90d";
|
||||
export type BucketType = "hourly" | "daily";
|
||||
|
||||
export interface StatusPageRow {
|
||||
|
|
@ -19,7 +18,6 @@ export interface StatusPageRow {
|
|||
show_powered_by: boolean;
|
||||
show_response_time:boolean;
|
||||
show_cert_expiry: boolean;
|
||||
display_mode: "compact" | "expanded";
|
||||
bar_frequency: BucketType;
|
||||
bar_count: number;
|
||||
custom_css: string | null;
|
||||
|
|
@ -29,13 +27,6 @@ export interface StatusPageRow {
|
|||
auto_refresh_s: number;
|
||||
}
|
||||
|
||||
export interface MultiWindowUptime {
|
||||
d24: number | null;
|
||||
d7: number | null;
|
||||
d30: number | null;
|
||||
d90: number | null;
|
||||
}
|
||||
|
||||
export interface MonitorRow {
|
||||
id: string;
|
||||
display_name: string;
|
||||
|
|
@ -49,14 +40,12 @@ export interface MonitorRow {
|
|||
// looks up groups by this token.
|
||||
group_id: string | null;
|
||||
position: number;
|
||||
display_mode: "compact" | "expanded"; // resolved (per-monitor override → page default → 'expanded')
|
||||
// 'paused' means the monitor was disabled in the dashboard - the runner has
|
||||
// stopped checking it, and the public page should treat it as planned
|
||||
// maintenance rather than an outage.
|
||||
current_state: "up" | "down" | "unknown" | "paused";
|
||||
region_states: Array<{ region: string; state: "up" | "down" | "unknown"; updated_at: string | null }>;
|
||||
uptime_pct: number | null;
|
||||
uptime: MultiWindowUptime; // 24h / 7d / 30d / 90d row
|
||||
buckets: Array<{ start: string; total: number; up: number; avg_latency: number | null }>; // bar chart input
|
||||
avg_latency: number | null;
|
||||
latency_history: Array<{ region: string; latency_ms: number | null; ts: string }>;
|
||||
|
|
@ -107,8 +96,6 @@ export async function loadGroups(pageId: string): Promise<GroupRow[]> {
|
|||
|
||||
export async function loadMonitors(
|
||||
pageId: string,
|
||||
window: Window,
|
||||
pageDisplayMode: "compact" | "expanded" = "expanded",
|
||||
barFrequency: BucketType = "daily",
|
||||
barCount: number = 90,
|
||||
): Promise<MonitorRow[]> {
|
||||
|
|
@ -126,7 +113,6 @@ export async function loadMonitors(
|
|||
m.enabled AS enabled,
|
||||
spm.group_id,
|
||||
spm.position,
|
||||
spm.display_mode AS spm_display_mode
|
||||
FROM status_page_monitors spm
|
||||
JOIN monitors m ON m.id = spm.monitor_id
|
||||
WHERE spm.status_page_id = ${pageId}
|
||||
|
|
@ -230,33 +216,14 @@ export async function loadMonitors(
|
|||
// barIndexed[mid][isoStart] → cross-region {total, up} for bar coloring (only rows of barFrequency)
|
||||
// barRegionLat[mid][region] → weighted latency over the bar window for picking fastest region
|
||||
// barRegionBucketLat[mid][region][iso] → per-bucket latency in the fastest region (only rows of barFrequency)
|
||||
// windowTotals[mid][windowKey] → {up, total} per uptime window (24h/7d/30d/90d)
|
||||
// latByMonitor[mid][] → 30h hourly latency sparkline rows
|
||||
const barIndexed: Record<string, Record<string, { total: number; up: number }>> = {};
|
||||
const barRegionLat: Record<string, Record<string, { sum: number; n: number }>> = {};
|
||||
const barRegionBucketLat: Record<string, Record<string, Record<string, number>>> = {};
|
||||
|
||||
type WindowKey = "d24" | "d7" | "d30" | "d90";
|
||||
const windowTotals: Record<string, Record<WindowKey, { up: number; total: number }>> = {};
|
||||
const initWindowTotals = (mid: string) => {
|
||||
if (!windowTotals[mid]) {
|
||||
windowTotals[mid] = {
|
||||
d24: { up: 0, total: 0 },
|
||||
d7: { up: 0, total: 0 },
|
||||
d30: { up: 0, total: 0 },
|
||||
d90: { up: 0, total: 0 },
|
||||
};
|
||||
}
|
||||
return windowTotals[mid]!;
|
||||
};
|
||||
|
||||
const latByMonitor: Record<string, MonitorRow["latency_history"]> = {};
|
||||
|
||||
const nowMs = Date.now();
|
||||
const ms24h = 24 * 3600_000;
|
||||
const ms7d = 7 * 86_400_000;
|
||||
const ms30d = 30 * 86_400_000;
|
||||
const ms90d = 90 * 86_400_000;
|
||||
const ms30h = 30 * 3600_000;
|
||||
|
||||
for (const r of rollupRows) {
|
||||
|
|
@ -290,20 +257,6 @@ export async function loadMonitors(
|
|||
}
|
||||
}
|
||||
|
||||
// Multi-window uptime accumulators. 24h uses hourly buckets; 7d/30d/90d
|
||||
// use daily buckets - same as the old loadMultiWindowUptime SQL did.
|
||||
// Strict `<` to match the old SQL's `bucket_start > now() - interval`.
|
||||
const wt = initWindowTotals(mid);
|
||||
if (bt === "hourly" && nowMs - startMs < ms24h) {
|
||||
wt.d24.up += up; wt.d24.total += total;
|
||||
}
|
||||
if (bt === "daily") {
|
||||
const age = nowMs - startMs;
|
||||
if (age < ms7d) { wt.d7.up += up; wt.d7.total += total; }
|
||||
if (age < ms30d) { wt.d30.up += up; wt.d30.total += total; }
|
||||
if (age < ms90d) { wt.d90.up += up; wt.d90.total += total; }
|
||||
}
|
||||
|
||||
// 30h hourly latency sparkline.
|
||||
if (bt === "hourly" && nowMs - startMs < ms30h) {
|
||||
if (!latByMonitor[mid]) latByMonitor[mid] = [];
|
||||
|
|
@ -370,21 +323,6 @@ export async function loadMonitors(
|
|||
});
|
||||
}
|
||||
|
||||
// Multi-window uptime is a straight read from the windowTotals accumulator.
|
||||
// We deliberately do NOT round to 2 decimals here - the formatter on the
|
||||
// public page truncates (not rounds) to 2 decimals so a value like 99.9999%
|
||||
// doesn't visually round up to "100.00%". Pre-rounding here would erase that
|
||||
// information before the formatter ever sees it.
|
||||
const multiWindow: Record<string, MultiWindowUptime> = {};
|
||||
const toPct = (up: number, total: number): number | null =>
|
||||
total > 0 ? (100 * up / total) : null;
|
||||
for (const id of ids) {
|
||||
const wt = windowTotals[id];
|
||||
multiWindow[id] = wt
|
||||
? { d24: toPct(wt.d24.up, wt.d24.total), d7: toPct(wt.d7.up, wt.d7.total), d30: toPct(wt.d30.up, wt.d30.total), d90: toPct(wt.d90.up, wt.d90.total) }
|
||||
: { d24: null, d7: null, d30: null, d90: null };
|
||||
}
|
||||
|
||||
const latencyByMonitorList = latByMonitor;
|
||||
|
||||
return monitorRows.map((m) => {
|
||||
|
|
@ -409,20 +347,14 @@ export async function loadMonitors(
|
|||
uptime_pct = tot > 0 ? (100 * upT / tot) : null;
|
||||
}
|
||||
const avg_latency = fastestLatency[m.id] ?? 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 {
|
||||
id: m.id,
|
||||
display_name: m.display_name,
|
||||
group_id: m.group_id,
|
||||
position: m.position,
|
||||
display_mode,
|
||||
current_state,
|
||||
region_states,
|
||||
uptime_pct,
|
||||
uptime: multiWindow[m.id] ?? { d24: null, d7: null, d30: null, d90: null },
|
||||
buckets,
|
||||
avg_latency,
|
||||
latency_history: latencyByMonitorList[m.id] ?? [],
|
||||
|
|
@ -483,7 +415,7 @@ export interface MonitorDetailPayload {
|
|||
generated_at: string;
|
||||
}
|
||||
|
||||
export async function loadMonitorDetail(slug: string, monitorId: string, window?: Window): Promise<MonitorDetailPayload | null> {
|
||||
export async function loadMonitorDetail(slug: string, monitorId: string): Promise<MonitorDetailPayload | null> {
|
||||
const page = await loadStatusPage(slug);
|
||||
if (!page) return null;
|
||||
// Existence check only - confirm the monitor is actually attached to this
|
||||
|
|
@ -497,14 +429,13 @@ export async function loadMonitorDetail(slug: string, monitorId: string, window?
|
|||
`;
|
||||
if (!link) return null;
|
||||
|
||||
const win = (window ?? '24h') 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. We also need
|
||||
// the page's groups so we can redact the monitor's group_id (UUID → public
|
||||
// position-as-string token), matching what /:slug.json emits.
|
||||
const [allGroups, allMonitors] = await Promise.all([
|
||||
loadGroups(page.id),
|
||||
loadMonitors(page.id, win, page.display_mode, page.bar_frequency, page.bar_count),
|
||||
loadMonitors(page.id, page.bar_frequency, page.bar_count),
|
||||
]);
|
||||
const { monitors } = redactGroupsAndMonitors(allGroups, allMonitors);
|
||||
const m = monitors.find((x) => x.id === monitorId);
|
||||
|
|
@ -570,7 +501,6 @@ export interface PublicPageView {
|
|||
show_powered_by: boolean;
|
||||
show_response_time: boolean;
|
||||
show_cert_expiry: boolean;
|
||||
display_mode: "compact" | "expanded";
|
||||
bar_frequency: BucketType;
|
||||
bar_count: number;
|
||||
custom_css: string | null;
|
||||
|
|
@ -609,7 +539,6 @@ function redactPageForPublic(p: StatusPageRow): PublicPageView {
|
|||
show_powered_by: p.show_powered_by,
|
||||
show_response_time: p.show_response_time,
|
||||
show_cert_expiry: p.show_cert_expiry,
|
||||
display_mode: p.display_mode,
|
||||
bar_frequency: p.bar_frequency,
|
||||
bar_count: p.bar_count,
|
||||
custom_css: p.custom_css,
|
||||
|
|
@ -635,20 +564,19 @@ function redactGroupsAndMonitors(
|
|||
name: g.name,
|
||||
position: g.position,
|
||||
}));
|
||||
const publicMonitors = monitors.map((m) => {
|
||||
const { uptime, ...rest } = m;
|
||||
return { ...rest, group_id: m.group_id ? (idMap.get(m.group_id) ?? null) : null };
|
||||
});
|
||||
const publicMonitors = monitors.map((m) => ({
|
||||
...m,
|
||||
group_id: m.group_id ? (idMap.get(m.group_id) ?? null) : null,
|
||||
}));
|
||||
return { groups: publicGroups, monitors: publicMonitors };
|
||||
}
|
||||
|
||||
export async function loadPagePayload(slug: string, window?: Window): Promise<PagePayload | null> {
|
||||
export async function loadPagePayload(slug: string): Promise<PagePayload | null> {
|
||||
const page = await loadStatusPage(slug);
|
||||
if (!page) return null;
|
||||
const win = (window ?? '24h') as Window;
|
||||
const [rawGroups, rawMonitors, incidents] = await Promise.all([
|
||||
loadGroups(page.id),
|
||||
loadMonitors(page.id, win, page.display_mode, page.bar_frequency, page.bar_count),
|
||||
loadMonitors(page.id, page.bar_frequency, page.bar_count),
|
||||
loadIncidents(page.id),
|
||||
]);
|
||||
const { groups, monitors } = redactGroupsAndMonitors(rawGroups, rawMonitors);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Eta } from "eta";
|
|||
import { resolve } from "path";
|
||||
import { createHash } from "crypto";
|
||||
import sql from "./db";
|
||||
import { loadStatusPage, loadPagePayload, loadMonitorDetail, type Window } from "./data";
|
||||
import { loadStatusPage, loadPagePayload, loadMonitorDetail } from "./data";
|
||||
import { renderRss } from "./render/rss";
|
||||
import { renderBadge, badgeFromState } from "./render/badge";
|
||||
import { cached } from "./cache";
|
||||
|
|
@ -132,7 +132,7 @@ async function renderHtml(slug: string, request: Request): Promise<Response> {
|
|||
return new Response(html, { headers });
|
||||
}
|
||||
|
||||
async function renderJson(slug: string, request: Request, win?: Window): Promise<Response> {
|
||||
async function renderJson(slug: string, request: Request): Promise<Response> {
|
||||
// Same as renderHtml: never cache the page row. The auth check has to see
|
||||
// the live password_hash, otherwise rotating a password leaves a 15s
|
||||
// window where old cookies still validate against the stale row.
|
||||
|
|
@ -143,8 +143,7 @@ async function renderJson(slug: string, request: Request, win?: Window): Promise
|
|||
// fall back to HTML scraping (which gets the password form, also a 401-ish
|
||||
// signal but more expensive to parse and less stable).
|
||||
if (!page || !isAuthorised(page, request)) return jsonNotFound();
|
||||
const cacheKey = `payload:${slug}:${win ?? '24h'}`;
|
||||
const payload = await cached(cacheKey, 15, () => loadPagePayload(slug, win));
|
||||
const payload = await cached(`payload:${slug}`, 15, () => loadPagePayload(slug));
|
||||
if (!payload) return jsonNotFound();
|
||||
// Password-protected JSON must be private - same reasoning as renderHtml.
|
||||
const cacheControl = page.password_hash
|
||||
|
|
@ -208,7 +207,7 @@ const app = new Elysia()
|
|||
.get("/:slug", async ({ params, request, query }) => {
|
||||
const { slug, format } = splitSlugAndFormat(params.slug);
|
||||
if (!allow(slug, clientIp(request))) return rateLimited();
|
||||
if (format === "json") return renderJson(slug, request, (query as any)?.window as Window | undefined);
|
||||
if (format === "json") return renderJson(slug, request);
|
||||
if (format === "rss") return renderRssResp(slug, request);
|
||||
return renderHtml(slug, request);
|
||||
})
|
||||
|
|
@ -253,9 +252,8 @@ const app = new Elysia()
|
|||
if (!page || !isAuthorised(page, request)) return jsonNotFound();
|
||||
const idWithExt = params.idWithExt;
|
||||
const monitorId = idWithExt.endsWith(".json") ? idWithExt.slice(0, -5) : idWithExt;
|
||||
const win = (query as any)?.window as Window | undefined;
|
||||
const cacheKey = `monitor:${params.slug}:${monitorId}:${win ?? ''}`;
|
||||
const payload = await cached(cacheKey, 15, () => loadMonitorDetail(params.slug, monitorId, win));
|
||||
const cacheKey = `monitor:${params.slug}:${monitorId}`;
|
||||
const payload = await cached(cacheKey, 15, () => loadMonitorDetail(params.slug, monitorId));
|
||||
if (!payload) return jsonNotFound();
|
||||
const cacheControl = page.password_hash
|
||||
? "private, no-store, must-revalidate"
|
||||
|
|
|
|||
|
|
@ -55,10 +55,6 @@ h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem; }
|
|||
.group-body { display: none; padding: 0 0.75rem 0.75rem; }
|
||||
.group-toggle:checked ~ .group-body { display: block; }
|
||||
.group-body .monitors { gap: 0.35rem; }
|
||||
/* Compact monitor inside a group - no collapse, no uptime cells */
|
||||
.monitor-compact { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 0.75rem 1rem; }
|
||||
.monitor-compact-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 0.5rem; }
|
||||
.monitor-compact .bars { height: 24px; }
|
||||
.page-components { 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
|
||||
|
|
@ -94,22 +90,8 @@ h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem; }
|
|||
#bar-tooltip .pct.warn { color: var(--bar-partial); }
|
||||
#bar-tooltip .pct.bad { color: var(--bar-down); }
|
||||
/* Multi-window uptime row: four labelled cells side by side. */
|
||||
.uptime-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.5rem; margin-top: 0.75rem; }
|
||||
.uptime-cell { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem 0.65rem; }
|
||||
.uptime-cell .label { font-size: 0.65rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.2rem; }
|
||||
.uptime-cell .value { font-variant-numeric: tabular-nums; font-weight: 600; font-size: 0.95rem; }
|
||||
.uptime-cell.good .value { color: var(--bar-up); }
|
||||
.uptime-cell.warn .value { color: var(--bar-partial); }
|
||||
.uptime-cell.bad .value { color: var(--bar-down); }
|
||||
.uptime-cell.empty .value { color: var(--muted); }
|
||||
/* Header row is always clickable; detail panel collapses/expands via hidden checkbox. */
|
||||
.monitor-toggle { display: none; }
|
||||
.monitor-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem 1.25rem; cursor: pointer; background: transparent; border: none; color: inherit; width: 100%; text-align: left; font-family: inherit; font-size: inherit; }
|
||||
.monitor-row:hover { background: rgba(255,255,255,0.02); }
|
||||
.monitor-row .chev { color: var(--muted); transition: transform 0.15s; flex-shrink: 0; }
|
||||
.monitor-toggle:checked ~ .monitor-row .chev { transform: rotate(90deg); }
|
||||
.monitor-detail { padding: 0 1.25rem 1rem; border-top: 1px solid var(--border); display: none; }
|
||||
.monitor-toggle:checked ~ .monitor-detail { display: block; padding-top: 1rem; }
|
||||
.monitor-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem 1.25rem 0; }
|
||||
.monitor .bars { padding: 0.5rem 1.25rem 1rem; }
|
||||
.regions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem; font-size: 0.75rem; }
|
||||
.region { padding: 0.15rem 0.5rem; border-radius: 999px; border: 1px solid var(--border); }
|
||||
.region.up { color: var(--green); border-color: rgba(16,185,129,0.3); }
|
||||
|
|
@ -160,7 +142,6 @@ h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem; }
|
|||
.past-update-body p { display: inline; margin: 0; }
|
||||
.past-update-body code { background: var(--bg); padding: 0.05em 0.3em; border-radius: 3px; font-size: 0.85em; }
|
||||
.past-update-time { color: var(--muted); font-size: 0.7rem; margin-top: 0.15rem; }
|
||||
.page-uptime { margin-top: 2rem; }
|
||||
footer { 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:hover { text-decoration: underline; }
|
||||
|
|
|
|||
|
|
@ -95,19 +95,6 @@
|
|||
: overall === 'degraded' ? 'rgba(245,158,11,0.4)'
|
||||
: 'rgba(239,68,68,0.4)';
|
||||
|
||||
// Page-wide aggregate uptime - average across active monitors
|
||||
const pageUptimeAcc = { d24: { sum: 0, n: 0 }, d7: { sum: 0, n: 0 }, d30: { sum: 0, n: 0 }, d90: { sum: 0, n: 0 } };
|
||||
for (const m of monitors) {
|
||||
if (m.current_state === 'paused') continue;
|
||||
const u = m.uptime || {};
|
||||
for (const key of ['d24', 'd7', 'd30', 'd90']) {
|
||||
if (u[key] != null) { pageUptimeAcc[key].sum += u[key]; pageUptimeAcc[key].n++; }
|
||||
}
|
||||
}
|
||||
function pageUptimePct(key) {
|
||||
const a = pageUptimeAcc[key];
|
||||
return a.n > 0 ? (a.sum / a.n) : null;
|
||||
}
|
||||
%><!DOCTYPE html>
|
||||
<html lang="en" class="<%= themeClass %>">
|
||||
<head>
|
||||
|
|
@ -182,11 +169,6 @@
|
|||
<% } %>
|
||||
|
||||
<%
|
||||
// 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.bar_frequency === 'hourly'
|
||||
? ('Last ' + page.bar_count + ' hour' + (page.bar_count === 1 ? '' : 's'))
|
||||
: ('Last ' + page.bar_count + ' day' + (page.bar_count === 1 ? '' : 's'));
|
||||
|
|
@ -253,35 +235,9 @@
|
|||
<div class="monitors">
|
||||
<% list.forEach(function(m) {
|
||||
const buckets = m.buckets || [];
|
||||
const hasData = buckets.some(b => b.total > 0);
|
||||
const startsOpen = m.display_mode !== 'compact';
|
||||
%>
|
||||
<% if (inGroup) { %>
|
||||
<div class="monitor-compact" data-monitor-id="<%= m.id %>">
|
||||
<div class="monitor-compact-header">
|
||||
<div class="monitor-name">
|
||||
<span class="dot" style="background: <%= statusColor(m.current_state) %>;"></span>
|
||||
<span class="name"><%= m.display_name %></span>
|
||||
</div>
|
||||
<div class="monitor-meta">
|
||||
<% if (m.current_state === 'paused') { %>
|
||||
<span class="maintenance-pill" title="This service is paused for maintenance - checks are not running.">Maintenance</span>
|
||||
<% } else { %>
|
||||
<% if (page.show_response_time && m.avg_latency != null) { %><span><%= m.avg_latency %>ms</span><% } %>
|
||||
<span class="uptime-pct <%= uptimeBand(m.uptime_pct) %>"><%= fmtUptime(m.uptime_pct) %></span>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bars" aria-label="<%= statusLabel(m.current_state) %>">
|
||||
<% buckets.forEach(function(b) { %>
|
||||
<div class="bar" style="background: <%= bucketColor(b) %>;" data-start="<%= b.start %>" data-total="<%= b.total %>" data-up="<%= b.up %>"<% if (b.avg_latency != null) { %> data-latency="<%= b.avg_latency %>"<% } %>></div>
|
||||
<% }); %>
|
||||
</div>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<div class="monitor" data-monitor-id="<%= m.id %>">
|
||||
<input type="checkbox" class="monitor-toggle" id="m-<%= m.id %>" <%= startsOpen ? 'checked' : '' %>>
|
||||
<label class="monitor-row" for="m-<%= m.id %>">
|
||||
<div class="monitor-header">
|
||||
<div class="monitor-name">
|
||||
<span class="dot" style="background: <%= statusColor(m.current_state) %>;"></span>
|
||||
<span class="name"><%= m.display_name %></span>
|
||||
|
|
@ -293,22 +249,14 @@
|
|||
<% if (page.show_response_time && m.avg_latency != null) { %><span><%= m.avg_latency %>ms</span><% } %>
|
||||
<span class="uptime-pct <%= uptimeBand(m.uptime_pct) %>"><%= fmtUptime(m.uptime_pct) %></span>
|
||||
<% } %>
|
||||
<svg class="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>
|
||||
</div>
|
||||
</label>
|
||||
<div class="monitor-detail">
|
||||
<div class="bars" aria-label="<%= statusLabel(m.current_state) %>">
|
||||
<% buckets.forEach(function(b) { %>
|
||||
<div class="bar" style="background: <%= bucketColor(b) %>;" data-start="<%= b.start %>" data-total="<%= b.total %>" data-up="<%= b.up %>"<% if (b.avg_latency != null) { %> data-latency="<%= b.avg_latency %>"<% } %>></div>
|
||||
<% }); %>
|
||||
</div>
|
||||
<div class="bars-meta">
|
||||
<span><%= windowLabel %></span>
|
||||
<span><%= hasData ? 'uptime over window' : 'awaiting data' %></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bars" aria-label="<%= statusLabel(m.current_state) %>">
|
||||
<% buckets.forEach(function(b) { %>
|
||||
<div class="bar" style="background: <%= bucketColor(b) %>;" data-start="<%= b.start %>" data-total="<%= b.total %>" data-up="<%= b.up %>"<% if (b.avg_latency != null) { %> data-latency="<%= b.avg_latency %>"<% } %>></div>
|
||||
<% }); %>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
<% }); %>
|
||||
</div>
|
||||
<% if (groupName) { %>
|
||||
|
|
@ -362,15 +310,6 @@
|
|||
</div>
|
||||
<% } %>
|
||||
|
||||
<div class="page-uptime">
|
||||
<div class="uptime-row">
|
||||
<div class="uptime-cell <%= uptimeBand(pageUptimePct('d24')) %>"><div class="label">24h</div><div class="value"><%= fmtUptime(pageUptimePct('d24')) %></div></div>
|
||||
<div class="uptime-cell <%= uptimeBand(pageUptimePct('d7')) %>"><div class="label">7d</div><div class="value"><%= fmtUptime(pageUptimePct('d7')) %></div></div>
|
||||
<div class="uptime-cell <%= uptimeBand(pageUptimePct('d30')) %>"><div class="label">30d</div><div class="value"><%= fmtUptime(pageUptimePct('d30')) %></div></div>
|
||||
<div class="uptime-cell <%= uptimeBand(pageUptimePct('d90')) %>"><div class="label">90d</div><div class="value"><%= fmtUptime(pageUptimePct('d90')) %></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<% if (page.footer_text) { %><div><%= page.footer_text %></div><% } %>
|
||||
<% if (page.show_powered_by) { %><div>Status powered by <a href="https://pingql.com">PingQL</a></div><% } %>
|
||||
|
|
|
|||
|
|
@ -150,7 +150,6 @@ async function getAccountId(cookie: any, headers: any): Promise<{ accountId: str
|
|||
// 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.
|
||||
|
|
@ -172,7 +171,7 @@ function pickMap(b: any, prefix: string): Record<string, string> {
|
|||
|
||||
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 }>;
|
||||
monitorsForApi: Array<{ monitor_id: string; position: number; group_index: number | null; display_name: 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] : []);
|
||||
|
|
@ -184,11 +183,10 @@ function parseStatusPageForm(b: any): {
|
|||
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");
|
||||
|
||||
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 }> = [];
|
||||
const monitorsForApi: Array<{ monitor_id: string; position: number; group_index: number | null; display_name: string | null }> = [];
|
||||
|
||||
function addMonitor(id: string) {
|
||||
if (seen.has(id)) return;
|
||||
|
|
@ -200,7 +198,6 @@ function parseStatusPageForm(b: any): {
|
|||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -708,7 +705,7 @@ export const dashboard = new Elysia()
|
|||
`;
|
||||
if (!page) return redirect("/dashboard/status-pages");
|
||||
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 monitor_id, display_name, 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`,
|
||||
]);
|
||||
|
|
@ -734,7 +731,7 @@ export const dashboard = new Elysia()
|
|||
description: b.description || null,
|
||||
theme: b.theme || "auto",
|
||||
|
||||
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,
|
||||
|
|
@ -767,7 +764,6 @@ export const dashboard = new Elysia()
|
|||
title: b.title,
|
||||
description: b.description || null,
|
||||
theme: b.theme || "auto",
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -293,7 +293,7 @@ Content-Type: application/json
|
|||
<tr><td>description</td><td>string?</td><td>Shown below the title (up to 2000 chars).</td></tr>
|
||||
<tr><td>theme</td><td>string?</td><td><code>auto</code>, <code>light</code>, or <code>dark</code>. Default <code>auto</code>.</td></tr>
|
||||
<tr><td>password</td><td>string?</td><td>Plain text, hashed at write time. Pass <code>null</code> to clear.</td></tr>
|
||||
<tr><td>display_mode</td><td>string?</td><td><code>compact</code> or <code>expanded</code>. Default <code>expanded</code>.</td></tr>
|
||||
|
||||
<tr><td>bar_frequency</td><td>string?</td><td><code>hourly</code> or <code>daily</code>. Default <code>daily</code>.</td></tr>
|
||||
<tr><td>bar_count</td><td>number?</td><td>How many bars to show (1-180). Default 90.</td></tr>
|
||||
<tr><td>auto_refresh_s</td><td>number?</td><td>Auto-refresh interval in seconds (10-3600). Default 60.</td></tr>
|
||||
|
|
@ -318,7 +318,6 @@ Content-Type: application/json
|
|||
<tr><td>monitor_id</td><td>string</td><td>ID of a monitor you own.</td></tr>
|
||||
<tr><td>group_index</td><td>number?</td><td>Index into the <code>groups</code> array (0-based). Omit for ungrouped.</td></tr>
|
||||
<tr><td>display_name</td><td>string?</td><td>Override the monitor's name on this page.</td></tr>
|
||||
<tr><td>display_mode</td><td>string?</td><td><code>compact</code> or <code>expanded</code>. Overrides the page default for this monitor.</td></tr>
|
||||
<tr><td>position</td><td>number?</td><td>Sort order within the group.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -9,11 +9,9 @@
|
|||
const attached = new Set(attachedRows.map(m => m.monitor_id));
|
||||
// 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
|
||||
|
|
@ -67,14 +65,6 @@
|
|||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm text-gray-400 mb-1.5">Display mode</label>
|
||||
<select name="display_mode" 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">
|
||||
<% [['expanded','Expanded - show full detail per monitor'],['compact','Compact - one line per monitor, click to expand']].forEach(function([v, label]) { %>
|
||||
<option value="<%= v %>" <%= (p.display_mode || 'expanded') === v ? 'selected' : '' %>><%= label %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
|
|
@ -125,7 +115,6 @@
|
|||
<% orderedMonitors.forEach(function(m) {
|
||||
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 %>">
|
||||
|
|
@ -141,12 +130,6 @@
|
|||
<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"
|
||||
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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue