feat: improve status page
This commit is contained in:
parent
60037edf21
commit
601c918e9f
|
|
@ -4,6 +4,7 @@ import sql from "../db";
|
||||||
|
|
||||||
const Theme = t.Union([t.Literal("auto"), t.Literal("light"), t.Literal("dark")]);
|
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 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 StatusPageBody = t.Object({
|
const StatusPageBody = t.Object({
|
||||||
slug: t.String({ minLength: 1, maxLength: 80, pattern: "^[a-z0-9][a-z0-9-]*$", description: "URL slug, lowercase + hyphens" }),
|
slug: t.String({ minLength: 1, maxLength: 80, pattern: "^[a-z0-9][a-z0-9-]*$", description: "URL slug, lowercase + hyphens" }),
|
||||||
|
|
@ -16,6 +17,7 @@ const StatusPageBody = t.Object({
|
||||||
show_response_time: t.Optional(t.Boolean()),
|
show_response_time: t.Optional(t.Boolean()),
|
||||||
show_cert_expiry: t.Optional(t.Boolean()),
|
show_cert_expiry: t.Optional(t.Boolean()),
|
||||||
default_window: t.Optional(Window),
|
default_window: t.Optional(Window),
|
||||||
|
display_mode: t.Optional(DisplayMode),
|
||||||
custom_css: t.Optional(t.Nullable(t.String({ maxLength: 50_000 }))),
|
custom_css: t.Optional(t.Nullable(t.String({ maxLength: 50_000 }))),
|
||||||
footer_text: t.Optional(t.Nullable(t.String({ maxLength: 5000 }))),
|
footer_text: t.Optional(t.Nullable(t.String({ maxLength: 5000 }))),
|
||||||
og_image_url: t.Optional(t.Nullable(t.String({ maxLength: 2048 }))),
|
og_image_url: t.Optional(t.Nullable(t.String({ maxLength: 2048 }))),
|
||||||
|
|
@ -121,14 +123,14 @@ export const statusPages = new Elysia({ prefix: "/status-pages" })
|
||||||
[row] = await sql`
|
[row] = await sql`
|
||||||
INSERT INTO status_pages (
|
INSERT INTO status_pages (
|
||||||
account_id, slug, title, description, theme, password_hash, index_search,
|
account_id, slug, title, description, theme, password_hash, index_search,
|
||||||
show_powered_by, show_response_time, show_cert_expiry, default_window,
|
show_powered_by, show_response_time, show_cert_expiry, default_window, display_mode,
|
||||||
custom_css, footer_text, og_image_url, analytics_html, auto_refresh_s
|
custom_css, footer_text, og_image_url, analytics_html, auto_refresh_s
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
${accountId}, ${body.slug}, ${body.title}, ${body.description ?? null},
|
${accountId}, ${body.slug}, ${body.title}, ${body.description ?? null},
|
||||||
${body.theme ?? 'auto'}, ${password_hash}, ${body.index_search ?? true},
|
${body.theme ?? 'auto'}, ${password_hash}, ${body.index_search ?? true},
|
||||||
${body.show_powered_by ?? true}, ${body.show_response_time ?? true},
|
${body.show_powered_by ?? true}, ${body.show_response_time ?? true},
|
||||||
${body.show_cert_expiry ?? false}, ${body.default_window ?? '24h'},
|
${body.show_cert_expiry ?? false}, ${body.default_window ?? '24h'}, ${body.display_mode ?? 'expanded'},
|
||||||
${css}, ${body.footer_text ?? null}, ${body.og_image_url ?? null},
|
${css}, ${body.footer_text ?? null}, ${body.og_image_url ?? null},
|
||||||
${body.analytics_html ?? null}, ${body.auto_refresh_s ?? 60}
|
${body.analytics_html ?? null}, ${body.auto_refresh_s ?? 60}
|
||||||
)
|
)
|
||||||
|
|
@ -184,6 +186,7 @@ export const statusPages = new Elysia({ prefix: "/status-pages" })
|
||||||
show_response_time = COALESCE(${body.show_response_time ?? null}, show_response_time),
|
show_response_time = COALESCE(${body.show_response_time ?? null}, show_response_time),
|
||||||
show_cert_expiry = COALESCE(${body.show_cert_expiry ?? null}, show_cert_expiry),
|
show_cert_expiry = COALESCE(${body.show_cert_expiry ?? null}, show_cert_expiry),
|
||||||
default_window = COALESCE(${body.default_window ?? null}, default_window),
|
default_window = COALESCE(${body.default_window ?? null}, default_window),
|
||||||
|
display_mode = COALESCE(${body.display_mode ?? null}, display_mode),
|
||||||
custom_css = CASE WHEN ${body.custom_css !== undefined} THEN ${css} ELSE custom_css END,
|
custom_css = CASE WHEN ${body.custom_css !== undefined} THEN ${css} ELSE custom_css END,
|
||||||
footer_text = COALESCE(${body.footer_text ?? null}, footer_text),
|
footer_text = COALESCE(${body.footer_text ?? null}, footer_text),
|
||||||
og_image_url = COALESCE(${body.og_image_url ?? null}, og_image_url),
|
og_image_url = COALESCE(${body.og_image_url ?? null}, og_image_url),
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,9 @@ export async function migrate(sql: any) {
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
await sql`CREATE INDEX IF NOT EXISTS idx_status_pages_account ON status_pages(account_id)`;
|
await sql`CREATE INDEX IF NOT EXISTS idx_status_pages_account ON status_pages(account_id)`;
|
||||||
|
// 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'`;
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
CREATE TABLE IF NOT EXISTS status_page_groups (
|
CREATE TABLE IF NOT EXISTS status_page_groups (
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export interface StatusPageRow {
|
||||||
show_response_time:boolean;
|
show_response_time:boolean;
|
||||||
show_cert_expiry: boolean;
|
show_cert_expiry: boolean;
|
||||||
default_window: Window;
|
default_window: Window;
|
||||||
|
display_mode: "compact" | "expanded";
|
||||||
custom_css: string | null;
|
custom_css: string | null;
|
||||||
footer_text: string | null;
|
footer_text: string | null;
|
||||||
og_image_url: string | null;
|
og_image_url: string | null;
|
||||||
|
|
@ -34,6 +35,13 @@ export interface StatusPageRow {
|
||||||
auto_refresh_s: number;
|
auto_refresh_s: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MultiWindowUptime {
|
||||||
|
d24: number | null;
|
||||||
|
d7: number | null;
|
||||||
|
d30: number | null;
|
||||||
|
d90: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MonitorRow {
|
export interface MonitorRow {
|
||||||
id: string;
|
id: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
|
|
@ -43,11 +51,70 @@ export interface MonitorRow {
|
||||||
current_state: "up" | "down" | "unknown";
|
current_state: "up" | "down" | "unknown";
|
||||||
region_states: Array<{ region: string; state: "up" | "down" | "unknown"; updated_at: string | null }>;
|
region_states: Array<{ region: string; state: "up" | "down" | "unknown"; updated_at: string | null }>;
|
||||||
uptime_pct: number | null; // for the page's default_window
|
uptime_pct: number | null; // for the page's default_window
|
||||||
|
uptime: MultiWindowUptime; // 24h / 7d / 30d / 90d row
|
||||||
buckets: Array<{ start: string; total: number; up: number }>; // bar chart input
|
buckets: Array<{ start: string; total: number; up: number }>; // bar chart input
|
||||||
avg_latency: number | null;
|
avg_latency: number | null;
|
||||||
latency_history: Array<{ region: string; latency_ms: number | null; ts: string }>;
|
latency_history: Array<{ region: string; latency_ms: number | null; ts: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Single SQL pass that produces all four uptime windows for a set of monitors.
|
||||||
|
// Reads only the rollup table; falls back to a pings aggregate when the rollup
|
||||||
|
// has nothing for these monitors yet (same pattern as loadMonitors).
|
||||||
|
export async function loadMultiWindowUptime(monitorIds: string[]): Promise<Record<string, MultiWindowUptime>> {
|
||||||
|
const empty: Record<string, MultiWindowUptime> = {};
|
||||||
|
if (monitorIds.length === 0) return empty;
|
||||||
|
for (const id of monitorIds) empty[id] = { d24: null, d7: null, d30: null, d90: null };
|
||||||
|
|
||||||
|
const ids = sql.array(monitorIds);
|
||||||
|
|
||||||
|
let rows = await sql<any[]>`
|
||||||
|
SELECT monitor_id,
|
||||||
|
(sum(up_count) FILTER (WHERE bucket_type='hourly' AND bucket_start > now() - interval '24 hours'))::float
|
||||||
|
/ NULLIF(sum(total) FILTER (WHERE bucket_type='hourly' AND bucket_start > now() - interval '24 hours'), 0) AS pct_24h,
|
||||||
|
(sum(up_count) FILTER (WHERE bucket_type='daily' AND bucket_start > now() - interval '7 days'))::float
|
||||||
|
/ NULLIF(sum(total) FILTER (WHERE bucket_type='daily' AND bucket_start > now() - interval '7 days'), 0) AS pct_7d,
|
||||||
|
(sum(up_count) FILTER (WHERE bucket_type='daily' AND bucket_start > now() - interval '30 days'))::float
|
||||||
|
/ NULLIF(sum(total) FILTER (WHERE bucket_type='daily' AND bucket_start > now() - interval '30 days'), 0) AS pct_30d,
|
||||||
|
(sum(up_count) FILTER (WHERE bucket_type='weekly' AND bucket_start > now() - interval '90 days'))::float
|
||||||
|
/ NULLIF(sum(total) FILTER (WHERE bucket_type='weekly' AND bucket_start > now() - interval '90 days'), 0) AS pct_90d
|
||||||
|
FROM monitor_uptime_rollup
|
||||||
|
WHERE monitor_id = ANY(${ids}::text[])
|
||||||
|
GROUP BY 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Fallback when the rollup is empty: aggregate directly from pings. Bounded
|
||||||
|
// by the 90d window so it's still cheap.
|
||||||
|
if (rows.length === 0) {
|
||||||
|
rows = await sql<any[]>`
|
||||||
|
SELECT monitor_id,
|
||||||
|
(count(*) FILTER (WHERE up AND checked_at > now() - interval '24 hours'))::float
|
||||||
|
/ NULLIF(count(*) FILTER (WHERE checked_at > now() - interval '24 hours'), 0) AS pct_24h,
|
||||||
|
(count(*) FILTER (WHERE up AND checked_at > now() - interval '7 days'))::float
|
||||||
|
/ NULLIF(count(*) FILTER (WHERE checked_at > now() - interval '7 days'), 0) AS pct_7d,
|
||||||
|
(count(*) FILTER (WHERE up AND checked_at > now() - interval '30 days'))::float
|
||||||
|
/ NULLIF(count(*) FILTER (WHERE checked_at > now() - interval '30 days'), 0) AS pct_30d,
|
||||||
|
(count(*) FILTER (WHERE up AND checked_at > now() - interval '90 days'))::float
|
||||||
|
/ NULLIF(count(*) FILTER (WHERE checked_at > now() - interval '90 days'), 0) AS pct_90d
|
||||||
|
FROM pings
|
||||||
|
WHERE monitor_id = ANY(${ids}::text[])
|
||||||
|
AND checked_at > now() - interval '90 days'
|
||||||
|
GROUP BY 1
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = empty;
|
||||||
|
const toPct = (v: any): number | null => v == null ? null : +(Number(v) * 100).toFixed(2);
|
||||||
|
for (const r of rows) {
|
||||||
|
out[r.monitor_id] = {
|
||||||
|
d24: toPct(r.pct_24h),
|
||||||
|
d7: toPct(r.pct_7d),
|
||||||
|
d30: toPct(r.pct_30d),
|
||||||
|
d90: toPct(r.pct_90d),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GroupRow {
|
export interface GroupRow {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -192,7 +259,10 @@ export async function loadMonitors(pageId: string, window: Window): Promise<Moni
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: tiny recent latency history for the sparkline (last 30 hourly buckets).
|
// Step 4: multi-window uptime row (24h / 7d / 30d / 90d) per monitor.
|
||||||
|
const multiWindow = await loadMultiWindowUptime(ids);
|
||||||
|
|
||||||
|
// Step 5: tiny recent latency history for the sparkline (last 30 hourly buckets).
|
||||||
const latRows = await sql<any[]>`
|
const latRows = await sql<any[]>`
|
||||||
SELECT monitor_id, region, bucket_start, avg_latency
|
SELECT monitor_id, region, bucket_start, avg_latency
|
||||||
FROM monitor_uptime_rollup
|
FROM monitor_uptime_rollup
|
||||||
|
|
@ -237,6 +307,7 @@ export async function loadMonitors(pageId: string, window: Window): Promise<Moni
|
||||||
current_state,
|
current_state,
|
||||||
region_states,
|
region_states,
|
||||||
uptime_pct,
|
uptime_pct,
|
||||||
|
uptime: multiWindow[m.id] ?? { d24: null, d7: null, d30: null, d90: null },
|
||||||
buckets,
|
buckets,
|
||||||
avg_latency,
|
avg_latency,
|
||||||
latency_history: latencyByMonitorList[m.id] ?? [],
|
latency_history: latencyByMonitorList[m.id] ?? [],
|
||||||
|
|
@ -282,6 +353,67 @@ export async function loadIncidents(pageId: string): Promise<{ active: IncidentS
|
||||||
return { active, recent };
|
return { active, recent };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MonitorDetailPayload {
|
||||||
|
monitor: MonitorRow;
|
||||||
|
incidents: IncidentSummary[]; // recent incidents that touch this monitor
|
||||||
|
generated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadMonitorDetail(slug: string, monitorId: string, window?: Window): Promise<MonitorDetailPayload | null> {
|
||||||
|
const page = await loadStatusPage(slug);
|
||||||
|
if (!page) return null;
|
||||||
|
// Confirm the monitor is actually attached to this page (and load any
|
||||||
|
// page-specific overrides at the same time).
|
||||||
|
const [link] = await sql<any[]>`
|
||||||
|
SELECT spm.monitor_id, COALESCE(spm.display_name, m.name) AS display_name, m.url, spm.group_id, spm.position
|
||||||
|
FROM status_page_monitors spm
|
||||||
|
JOIN monitors m ON m.id = spm.monitor_id
|
||||||
|
WHERE spm.status_page_id = ${page.id} AND spm.monitor_id = ${monitorId}
|
||||||
|
`;
|
||||||
|
if (!link) return null;
|
||||||
|
|
||||||
|
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);
|
||||||
|
const m = monitors.find((x) => x.id === monitorId);
|
||||||
|
if (!m) return null;
|
||||||
|
|
||||||
|
// Incidents touching this monitor (any status), most recent 20.
|
||||||
|
const incidentRows = await sql<any[]>`
|
||||||
|
SELECT i.*
|
||||||
|
FROM incidents i
|
||||||
|
JOIN incident_monitors im ON im.incident_id = i.id
|
||||||
|
WHERE im.monitor_id = ${monitorId} AND i.account_id = ${page.account_id}
|
||||||
|
ORDER BY i.started_at DESC
|
||||||
|
LIMIT 20
|
||||||
|
`;
|
||||||
|
let incidents: IncidentSummary[] = [];
|
||||||
|
if (incidentRows.length > 0) {
|
||||||
|
const ids = incidentRows.map((i) => i.id);
|
||||||
|
const updates = await sql<any[]>`
|
||||||
|
SELECT DISTINCT ON (incident_id) incident_id, body_html
|
||||||
|
FROM incident_updates
|
||||||
|
WHERE incident_id = ANY(${sql.array(ids)}::uuid[])
|
||||||
|
ORDER BY incident_id, created_at DESC
|
||||||
|
`;
|
||||||
|
const latestByIncident: Record<string, string> = {};
|
||||||
|
for (const u of updates) latestByIncident[u.incident_id] = u.body_html;
|
||||||
|
incidents = incidentRows.map((i) => ({
|
||||||
|
id: i.id,
|
||||||
|
title: i.title,
|
||||||
|
status: i.status,
|
||||||
|
severity: i.severity,
|
||||||
|
pinned: i.pinned,
|
||||||
|
started_at: i.started_at instanceof Date ? i.started_at.toISOString() : String(i.started_at),
|
||||||
|
resolved_at: i.resolved_at ? (i.resolved_at instanceof Date ? i.resolved_at.toISOString() : String(i.resolved_at)) : null,
|
||||||
|
latest_update_html: latestByIncident[i.id] ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { monitor: m, incidents, generated_at: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
export interface PagePayload {
|
export interface PagePayload {
|
||||||
page: Omit<StatusPageRow, "password_hash"> & { has_password: boolean };
|
page: Omit<StatusPageRow, "password_hash"> & { has_password: boolean };
|
||||||
groups: GroupRow[];
|
groups: GroupRow[];
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { Elysia } from "elysia";
|
||||||
import { Eta } from "eta";
|
import { Eta } from "eta";
|
||||||
import { resolve } from "path";
|
import { resolve } from "path";
|
||||||
import sql from "./db";
|
import sql from "./db";
|
||||||
import { loadStatusPage, loadPagePayload, type Window } from "./data";
|
import { loadStatusPage, loadPagePayload, loadMonitorDetail, type Window } from "./data";
|
||||||
import { renderRss } from "./render/rss";
|
import { renderRss } from "./render/rss";
|
||||||
import { renderBadge, badgeFromState } from "./render/badge";
|
import { renderBadge, badgeFromState } from "./render/badge";
|
||||||
import { cached } from "./cache";
|
import { cached } from "./cache";
|
||||||
|
|
@ -129,6 +129,31 @@ const app = new Elysia()
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Per-monitor detail JSON for the click-to-expand UI in compact mode.
|
||||||
|
// Path is /:slug/monitor/:idWithExt where idWithExt is e.g. "abc123.json".
|
||||||
|
// We strip the .json suffix in the handler — same trick as the slug route to
|
||||||
|
// dodge memoirist's "two params at the same position" rule.
|
||||||
|
.get("/:slug/monitor/:idWithExt", async ({ params, request, query }) => {
|
||||||
|
if (!allow(params.slug, clientIp(request))) return rateLimited();
|
||||||
|
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, 60, () => loadMonitorDetail(params.slug, monitorId, win));
|
||||||
|
if (!payload) {
|
||||||
|
return new Response(JSON.stringify({ error: "not found" }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"cache-control": "public, max-age=30, s-maxage=60",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
// PWA manifest
|
// PWA manifest
|
||||||
.get("/:slug/manifest.json", async ({ params }) => {
|
.get("/:slug/manifest.json", async ({ params }) => {
|
||||||
const page = await loadStatusPage(params.slug);
|
const page = await loadStatusPage(params.slug);
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,17 @@
|
||||||
if (b.up === 0) return 'var(--bar-down)';
|
if (b.up === 0) return 'var(--bar-down)';
|
||||||
return 'var(--bar-partial)';
|
return 'var(--bar-partial)';
|
||||||
}
|
}
|
||||||
|
function uptimeBand(p) {
|
||||||
|
if (p == null) return 'empty';
|
||||||
|
if (p >= 99.9) return 'good';
|
||||||
|
if (p >= 99.0) return 'warn';
|
||||||
|
return 'bad';
|
||||||
|
}
|
||||||
|
function fmtUptime(p) {
|
||||||
|
if (p == null) return '—';
|
||||||
|
if (p === 100) return '100%';
|
||||||
|
return p.toFixed(2) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
// Overall status: down if any monitor is down, degraded if any partial, else up.
|
// Overall status: down if any monitor is down, degraded if any partial, else up.
|
||||||
let overall = 'up';
|
let overall = 'up';
|
||||||
|
|
@ -106,6 +117,25 @@
|
||||||
.bar { flex: 1; min-width: 0; border-radius: 2px; transition: opacity 0.15s; }
|
.bar { flex: 1; min-width: 0; border-radius: 2px; transition: opacity 0.15s; }
|
||||||
.bar:hover { opacity: 0.8; }
|
.bar:hover { opacity: 0.8; }
|
||||||
.bars-meta { display: flex; justify-content: space-between; font-size: 0.7rem; color: var(--muted); margin-top: 0.4rem; }
|
.bars-meta { display: flex; justify-content: space-between; font-size: 0.7rem; color: var(--muted); margin-top: 0.4rem; }
|
||||||
|
/* 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); }
|
||||||
|
/* Compact mode: each monitor is a one-line button. Detail expands beneath. */
|
||||||
|
.monitor.compact { padding: 0; overflow: hidden; }
|
||||||
|
.monitor-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.85rem 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.expanded-state .monitor-row .chev { transform: rotate(90deg); }
|
||||||
|
.monitor-detail { padding: 0 1.25rem 1rem; border-top: 1px solid var(--border); display: none; }
|
||||||
|
.monitor.expanded-state .monitor-detail { display: block; padding-top: 1rem; }
|
||||||
|
.monitor-detail .skeleton { height: 80px; background: linear-gradient(90deg, var(--card), var(--bg), var(--card)); background-size: 200% 100%; animation: shimmer 1.2s infinite; border-radius: 6px; }
|
||||||
|
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||||||
.regions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem; font-size: 0.75rem; }
|
.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 { 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); }
|
.region.up { color: var(--green); border-color: rgba(16,185,129,0.3); }
|
||||||
|
|
@ -152,6 +182,13 @@
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
|
<%
|
||||||
|
const isCompact = page.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';
|
||||||
|
%>
|
||||||
<% groupOrder.forEach(function(gid) {
|
<% groupOrder.forEach(function(gid) {
|
||||||
const list = grouped[gid];
|
const list = grouped[gid];
|
||||||
if (!list || list.length === 0) return;
|
if (!list || list.length === 0) return;
|
||||||
|
|
@ -159,8 +196,29 @@
|
||||||
%>
|
%>
|
||||||
<% if (groupName) { %><div class="group-title"><%= groupName %></div><% } %>
|
<% if (groupName) { %><div class="group-title"><%= groupName %></div><% } %>
|
||||||
<div class="monitors">
|
<div class="monitors">
|
||||||
<% list.forEach(function(m) { %>
|
<% list.forEach(function(m) {
|
||||||
<div class="monitor">
|
const buckets = m.buckets || [];
|
||||||
|
const hasData = buckets.some(b => b.total > 0);
|
||||||
|
const u = m.uptime || { d24: null, d7: null, d30: null, d90: null };
|
||||||
|
%>
|
||||||
|
<% if (isCompact) { %>
|
||||||
|
<div class="monitor compact" data-monitor-id="<%= m.id %>">
|
||||||
|
<button type="button" class="monitor-row" aria-expanded="false">
|
||||||
|
<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">
|
||||||
|
<span class="uptime-pct"><%= fmtUptime(u[page.default_window === '24h' ? 'd24' : page.default_window === '7d' ? 'd7' : page.default_window === '30d' ? 'd30' : 'd90']) %></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>
|
||||||
|
</button>
|
||||||
|
<div class="monitor-detail" data-loaded="false">
|
||||||
|
<div class="skeleton"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="monitor" data-monitor-id="<%= m.id %>">
|
||||||
<div class="monitor-head">
|
<div class="monitor-head">
|
||||||
<div class="monitor-name">
|
<div class="monitor-name">
|
||||||
<span class="dot" style="background: <%= statusColor(m.current_state) %>;"></span>
|
<span class="dot" style="background: <%= statusColor(m.current_state) %>;"></span>
|
||||||
|
|
@ -168,17 +226,9 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="monitor-meta">
|
<div class="monitor-meta">
|
||||||
<% if (page.show_response_time && m.avg_latency != null) { %><span><%= m.avg_latency %>ms</span><% } %>
|
<% if (page.show_response_time && m.avg_latency != null) { %><span><%= m.avg_latency %>ms</span><% } %>
|
||||||
<span class="uptime-pct"><%= fmtPct(m.uptime_pct) %></span>
|
<span class="uptime-pct"><%= fmtUptime(u[page.default_window === '24h' ? 'd24' : page.default_window === '7d' ? 'd7' : page.default_window === '30d' ? 'd30' : 'd90']) %></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<%
|
|
||||||
const buckets = m.buckets || [];
|
|
||||||
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 hasData = buckets.some(b => b.total > 0);
|
|
||||||
%>
|
|
||||||
<div class="bars" title="<%= statusLabel(m.current_state) %>">
|
<div class="bars" title="<%= statusLabel(m.current_state) %>">
|
||||||
<% buckets.forEach(function(b) { %>
|
<% buckets.forEach(function(b) { %>
|
||||||
<div class="bar" style="background: <%= bucketColor(b) %>;" title="<%= b.total > 0 ? (Math.round(100 * b.up / b.total) + '% up') : 'no data' %>"></div>
|
<div class="bar" style="background: <%= bucketColor(b) %>;" title="<%= b.total > 0 ? (Math.round(100 * b.up / b.total) + '% up') : 'no data' %>"></div>
|
||||||
|
|
@ -186,7 +236,13 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="bars-meta">
|
<div class="bars-meta">
|
||||||
<span><%= windowLabel %></span>
|
<span><%= windowLabel %></span>
|
||||||
<span><%= hasData ? fmtPct(m.uptime_pct) + ' uptime' : 'awaiting data' %></span>
|
<span><%= hasData ? 'uptime over window' : 'awaiting data' %></span>
|
||||||
|
</div>
|
||||||
|
<div class="uptime-row">
|
||||||
|
<div class="uptime-cell <%= uptimeBand(u.d24) %>"><div class="label">24h</div><div class="value"><%= fmtUptime(u.d24) %></div></div>
|
||||||
|
<div class="uptime-cell <%= uptimeBand(u.d7) %>"><div class="label">7d</div><div class="value"><%= fmtUptime(u.d7) %></div></div>
|
||||||
|
<div class="uptime-cell <%= uptimeBand(u.d30) %>"><div class="label">30d</div><div class="value"><%= fmtUptime(u.d30) %></div></div>
|
||||||
|
<div class="uptime-cell <%= uptimeBand(u.d90) %>"><div class="label">90d</div><div class="value"><%= fmtUptime(u.d90) %></div></div>
|
||||||
</div>
|
</div>
|
||||||
<% if (m.region_states && m.region_states.length > 1) { %>
|
<% if (m.region_states && m.region_states.length > 1) { %>
|
||||||
<div class="regions">
|
<div class="regions">
|
||||||
|
|
@ -196,6 +252,7 @@
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
|
<% } %>
|
||||||
<% }); %>
|
<% }); %>
|
||||||
</div>
|
</div>
|
||||||
<% }); %>
|
<% }); %>
|
||||||
|
|
@ -220,6 +277,99 @@
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<% if (isCompact) { %>
|
||||||
|
<script>
|
||||||
|
// Click-to-expand for compact display mode. First click on a monitor row
|
||||||
|
// fetches /<slug>/monitor/<id>.json once and renders the detail inline;
|
||||||
|
// subsequent clicks just toggle visibility.
|
||||||
|
(function() {
|
||||||
|
const slug = <%~ JSON.stringify(page.slug) %>;
|
||||||
|
const defaultWindow = <%~ JSON.stringify(page.default_window) %>;
|
||||||
|
const showResponseTime = <%~ JSON.stringify(!!page.show_response_time) %>;
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
function statusColor(s) {
|
||||||
|
return s === 'up' ? 'var(--bar-up)' : s === 'down' ? 'var(--bar-down)' : 'var(--muted)';
|
||||||
|
}
|
||||||
|
function bucketColor(b) {
|
||||||
|
if (!b || b.total === 0) return 'var(--bar-empty)';
|
||||||
|
if (b.up === b.total) return 'var(--bar-up)';
|
||||||
|
if (b.up === 0) return 'var(--bar-down)';
|
||||||
|
return 'var(--bar-partial)';
|
||||||
|
}
|
||||||
|
function uptimeBand(p) {
|
||||||
|
if (p == null) return 'empty';
|
||||||
|
if (p >= 99.9) return 'good';
|
||||||
|
if (p >= 99.0) return 'warn';
|
||||||
|
return 'bad';
|
||||||
|
}
|
||||||
|
function fmtUptime(p) {
|
||||||
|
if (p == null) return '—';
|
||||||
|
if (p === 100) return '100%';
|
||||||
|
return p.toFixed(2) + '%';
|
||||||
|
}
|
||||||
|
function windowLabel(w) {
|
||||||
|
return w === '24h' ? 'Last 24 hours' : w === '7d' ? 'Last 7 days' : w === '30d' ? 'Last 30 days' : 'Last 90 days';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetail(payload) {
|
||||||
|
const m = payload.monitor;
|
||||||
|
const u = m.uptime || { d24: null, d7: null, d30: null, d90: null };
|
||||||
|
const buckets = m.buckets || [];
|
||||||
|
const hasData = buckets.some(b => b.total > 0);
|
||||||
|
const barsHtml = buckets.map(b =>
|
||||||
|
`<div class="bar" style="background: ${bucketColor(b)};" title="${b.total > 0 ? Math.round(100 * b.up / b.total) + '% up' : 'no data'}"></div>`
|
||||||
|
).join('');
|
||||||
|
const regionsHtml = (m.region_states && m.region_states.length > 1)
|
||||||
|
? `<div class="regions">${m.region_states.map(r => `<span class="region ${r.state}">${escapeHtml(r.region)}</span>`).join('')}</div>`
|
||||||
|
: '';
|
||||||
|
const latencyHtml = showResponseTime && m.avg_latency != null ? `<span>${m.avg_latency}ms · </span>` : '';
|
||||||
|
const incidentsHtml = (payload.incidents && payload.incidents.length > 0)
|
||||||
|
? `<div class="bars-meta" style="margin-top:0.75rem"><span>Recent incidents</span><span>${payload.incidents.length}</span></div>`
|
||||||
|
: '';
|
||||||
|
return `
|
||||||
|
<div class="bars" title="${m.current_state}">${barsHtml}</div>
|
||||||
|
<div class="bars-meta"><span>${windowLabel(defaultWindow)}</span><span>${latencyHtml}${hasData ? 'uptime over window' : 'awaiting data'}</span></div>
|
||||||
|
<div class="uptime-row">
|
||||||
|
<div class="uptime-cell ${uptimeBand(u.d24)}"><div class="label">24h</div><div class="value">${fmtUptime(u.d24)}</div></div>
|
||||||
|
<div class="uptime-cell ${uptimeBand(u.d7)}"><div class="label">7d</div><div class="value">${fmtUptime(u.d7)}</div></div>
|
||||||
|
<div class="uptime-cell ${uptimeBand(u.d30)}"><div class="label">30d</div><div class="value">${fmtUptime(u.d30)}</div></div>
|
||||||
|
<div class="uptime-cell ${uptimeBand(u.d90)}"><div class="label">90d</div><div class="value">${fmtUptime(u.d90)}</div></div>
|
||||||
|
</div>
|
||||||
|
${regionsHtml}
|
||||||
|
${incidentsHtml}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.monitor.compact').forEach(card => {
|
||||||
|
const id = card.dataset.monitorId;
|
||||||
|
const button = card.querySelector('.monitor-row');
|
||||||
|
const detail = card.querySelector('.monitor-detail');
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
const isOpen = card.classList.toggle('expanded-state');
|
||||||
|
button.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||||
|
if (isOpen && detail.dataset.loaded === 'false') {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/' + slug + '/monitor/' + encodeURIComponent(id) + '.json', { cache: 'no-store' });
|
||||||
|
if (!r.ok) {
|
||||||
|
detail.innerHTML = '<div style="color:var(--bar-down);font-size:0.85rem;padding:0.5rem 0">Failed to load detail.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = await r.json();
|
||||||
|
detail.innerHTML = renderDetail(payload);
|
||||||
|
detail.dataset.loaded = 'true';
|
||||||
|
} catch (e) {
|
||||||
|
detail.innerHTML = '<div style="color:var(--bar-down);font-size:0.85rem;padding:0.5rem 0">Failed to load detail.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
<% if (page.auto_refresh_s > 0) { %>
|
<% if (page.auto_refresh_s > 0) { %>
|
||||||
<script>
|
<script>
|
||||||
// Auto-refresh data without a full page reload. Polls /<slug>.json and
|
// Auto-refresh data without a full page reload. Polls /<slug>.json and
|
||||||
|
|
|
||||||
|
|
@ -636,7 +636,7 @@ export const dashboard = new Elysia()
|
||||||
`;
|
`;
|
||||||
if (!page) return redirect("/dashboard/status-pages");
|
if (!page) return redirect("/dashboard/status-pages");
|
||||||
const monitors = await sql`
|
const monitors = await sql`
|
||||||
SELECT monitor_id FROM status_page_monitors WHERE status_page_id = ${params.id}
|
SELECT monitor_id, display_name FROM status_page_monitors WHERE status_page_id = ${params.id}
|
||||||
`;
|
`;
|
||||||
const allMonitors = await sql`
|
const allMonitors = await sql`
|
||||||
SELECT id, name FROM monitors WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC
|
SELECT id, name FROM monitors WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC
|
||||||
|
|
@ -650,6 +650,21 @@ export const dashboard = new Elysia()
|
||||||
if (!resolved?.accountId) return redirect("/dashboard");
|
if (!resolved?.accountId) return redirect("/dashboard");
|
||||||
const b = body as any;
|
const b = body as any;
|
||||||
const monitorIds = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []);
|
const monitorIds = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []);
|
||||||
|
// The form posts per-monitor display name overrides as display_name[<id>].
|
||||||
|
// Bun's body parser surfaces them as nested object keys (display_name) or
|
||||||
|
// as flat "display_name[id]" string keys depending on parser version, so
|
||||||
|
// handle both. Empty strings mean "no override" — don't send them.
|
||||||
|
const displayNames: Record<string, string> = {};
|
||||||
|
if (b.display_name && typeof b.display_name === "object" && !Array.isArray(b.display_name)) {
|
||||||
|
for (const [k, v] of Object.entries(b.display_name)) {
|
||||||
|
if (typeof v === "string" && v.trim()) displayNames[k] = v.trim();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const k of Object.keys(b)) {
|
||||||
|
const m = k.match(/^display_name\[(.+)\]$/);
|
||||||
|
if (m && typeof b[k] === "string" && b[k].trim()) displayNames[m[1]!] = b[k].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
|
@ -662,13 +677,14 @@ export const dashboard = new Elysia()
|
||||||
description: b.description || null,
|
description: b.description || null,
|
||||||
theme: b.theme || "auto",
|
theme: b.theme || "auto",
|
||||||
default_window: b.default_window || "24h",
|
default_window: b.default_window || "24h",
|
||||||
|
display_mode: b.display_mode || "expanded",
|
||||||
show_response_time: !!b.show_response_time,
|
show_response_time: !!b.show_response_time,
|
||||||
show_powered_by: !!b.show_powered_by,
|
show_powered_by: !!b.show_powered_by,
|
||||||
index_search: !!b.index_search,
|
index_search: !!b.index_search,
|
||||||
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,
|
||||||
monitors: monitorIds.map((id: string, i: number) => ({ monitor_id: id, position: i })),
|
monitors: monitorIds.map((id: string, i: number) => ({ monitor_id: id, position: i, display_name: displayNames[id] ?? null })),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
@ -680,6 +696,17 @@ export const dashboard = new Elysia()
|
||||||
if (!resolved?.accountId) return redirect("/dashboard");
|
if (!resolved?.accountId) return redirect("/dashboard");
|
||||||
const b = body as any;
|
const b = body as any;
|
||||||
const monitorIds = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []);
|
const monitorIds = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []);
|
||||||
|
const displayNames: Record<string, string> = {};
|
||||||
|
if (b.display_name && typeof b.display_name === "object" && !Array.isArray(b.display_name)) {
|
||||||
|
for (const [k, v] of Object.entries(b.display_name)) {
|
||||||
|
if (typeof v === "string" && v.trim()) displayNames[k] = v.trim();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const k of Object.keys(b)) {
|
||||||
|
const m = k.match(/^display_name\[(.+)\]$/);
|
||||||
|
if (m && typeof b[k] === "string" && b[k].trim()) displayNames[m[1]!] = b[k].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
|
@ -689,12 +716,13 @@ export const dashboard = new Elysia()
|
||||||
description: b.description || null,
|
description: b.description || null,
|
||||||
theme: b.theme || "auto",
|
theme: b.theme || "auto",
|
||||||
default_window: b.default_window || "24h",
|
default_window: b.default_window || "24h",
|
||||||
|
display_mode: b.display_mode || "expanded",
|
||||||
show_response_time: !!b.show_response_time,
|
show_response_time: !!b.show_response_time,
|
||||||
show_powered_by: !!b.show_powered_by,
|
show_powered_by: !!b.show_powered_by,
|
||||||
index_search: !!b.index_search,
|
index_search: !!b.index_search,
|
||||||
custom_css: b.custom_css || null,
|
custom_css: b.custom_css || null,
|
||||||
footer_text: b.footer_text || null,
|
footer_text: b.footer_text || null,
|
||||||
monitors: monitorIds.map((id: string, i: number) => ({ monitor_id: id, position: i })),
|
monitors: monitorIds.map((id: string, i: number) => ({ monitor_id: id, position: i, display_name: displayNames[id] ?? null })),
|
||||||
};
|
};
|
||||||
// Only send `password` if the user actually typed something. An empty box
|
// Only send `password` if the user actually typed something. An empty box
|
||||||
// means "leave the existing password as-is" — sending null would clear it.
|
// means "leave the existing password as-is" — sending null would clear it.
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,11 @@
|
||||||
<%
|
<%
|
||||||
const p = it.page || {};
|
const p = it.page || {};
|
||||||
const allMonitors = it.allMonitors || [];
|
const allMonitors = it.allMonitors || [];
|
||||||
const attached = new Set((it.page?.monitors || []).map(m => m.monitor_id));
|
const attachedRows = (it.page?.monitors || []);
|
||||||
|
const attached = new Set(attachedRows.map(m => m.monitor_id));
|
||||||
|
// monitor_id → existing display_name override (or empty)
|
||||||
|
const displayNames = {};
|
||||||
|
for (const r of attachedRows) displayNames[r.monitor_id] = r.display_name || '';
|
||||||
%>
|
%>
|
||||||
|
|
||||||
<main class="max-w-3xl mx-auto px-8 py-10">
|
<main class="max-w-3xl mx-auto px-8 py-10">
|
||||||
|
|
@ -52,19 +56,35 @@
|
||||||
<% }) %>
|
<% }) %>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<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">Optional "Show as" field overrides the monitor name on this status page only. Leave blank to use the monitor's real name.</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 { %>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="space-y-2">
|
||||||
<% allMonitors.forEach(function(m) { %>
|
<% allMonitors.forEach(function(m) {
|
||||||
<label class="flex items-center gap-2 bg-gray-900 border border-gray-800 hover:border-gray-600 rounded-lg px-3 py-2 cursor-pointer transition-colors">
|
const isAttached = attached.has(m.id);
|
||||||
<input type="checkbox" name="monitor_ids" value="<%= m.id %>" class="accent-blue-500" <%= attached.has(m.id) ? 'checked' : '' %>>
|
const displayName = displayNames[m.id] || '';
|
||||||
<span class="text-sm text-gray-300"><%= m.name %></span>
|
%>
|
||||||
|
<div class="flex items-center gap-3 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer min-w-0 flex-1">
|
||||||
|
<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>
|
</label>
|
||||||
|
<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">
|
||||||
|
</div>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue