This commit is contained in:
nate 2026-04-10 03:05:01 +04:00
parent e376fd64fc
commit 94232f5851
9 changed files with 31 additions and 214 deletions

View File

@ -3,7 +3,6 @@ import { requireAuth } from "./auth";
import sql from "../db"; 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 DisplayMode = t.Union([t.Literal("compact"), t.Literal("expanded")]);
const BarFrequency = t.Union([t.Literal("hourly"), t.Literal("daily")]); const BarFrequency = t.Union([t.Literal("hourly"), t.Literal("daily")]);
const StatusPageBody = t.Object({ const StatusPageBody = t.Object({
@ -16,7 +15,6 @@ const StatusPageBody = t.Object({
show_powered_by: t.Optional(t.Boolean()), show_powered_by: t.Optional(t.Boolean()),
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()),
display_mode: t.Optional(DisplayMode),
bar_frequency: t.Optional(BarFrequency), bar_frequency: t.Optional(BarFrequency),
bar_count: t.Optional(t.Number({ minimum: 1, maximum: 180 })), bar_count: t.Optional(t.Number({ minimum: 1, maximum: 180 })),
custom_css: t.Optional(t.Nullable(t.String({ maxLength: 50_000 }))), custom_css: t.Optional(t.Nullable(t.String({ maxLength: 50_000 }))),
@ -32,7 +30,6 @@ const StatusPageBody = t.Object({
monitor_id: t.String(), monitor_id: t.String(),
group_index: t.Optional(t.Nullable(t.Number())), group_index: t.Optional(t.Nullable(t.Number())),
display_name: t.Optional(t.Nullable(t.String({ maxLength: 200 }))), display_name: t.Optional(t.Nullable(t.String({ maxLength: 200 }))),
display_mode: t.Optional(t.Nullable(DisplayMode)),
position: t.Optional(t.Number()), position: t.Optional(t.Number()),
}))), }))),
}); });
@ -55,7 +52,7 @@ async function replaceGroupsAndMonitors(
pageId: string, pageId: string,
accountId: string, accountId: string,
groups: { name: string; position?: number }[] | undefined, 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) { if (groups !== undefined) {
await sql`DELETE FROM status_page_groups WHERE status_page_id = ${pageId}`; await sql`DELETE FROM status_page_groups WHERE status_page_id = ${pageId}`;
@ -99,13 +96,12 @@ async function replaceGroupsAndMonitors(
monitor_id: m.monitor_id, monitor_id: m.monitor_id,
group_id: groupId, group_id: groupId,
display_name: m.display_name ?? null, display_name: m.display_name ?? null,
display_mode: m.display_mode ?? null,
position: m.position ?? i, position: m.position ?? i,
}); });
} }
if (rows.length > 0) { if (rows.length > 0) {
await sql` 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` [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, display_mode, show_powered_by, show_response_time, show_cert_expiry,
bar_frequency, bar_count, bar_frequency, bar_count,
custom_css, footer_text, og_image_url, analytics_html, auto_refresh_s 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}, ${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.display_mode ?? 'expanded'}, ${body.show_cert_expiry ?? false},
${body.bar_frequency ?? 'daily'}, ${body.bar_count ?? 90}, ${body.bar_frequency ?? 'daily'}, ${body.bar_count ?? 90},
${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}
@ -195,7 +191,6 @@ export const statusPages = new Elysia({ prefix: "/pages" })
show_powered_by = COALESCE(${body.show_powered_by ?? null}, show_powered_by), 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_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),
display_mode = COALESCE(${body.display_mode ?? null}, display_mode),
bar_frequency = COALESCE(${body.bar_frequency ?? null}, bar_frequency), bar_frequency = COALESCE(${body.bar_frequency ?? null}, bar_frequency),
bar_count = COALESCE(${body.bar_count ?? null}, bar_count), bar_count = COALESCE(${body.bar_count ?? null}, bar_count),
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,

View File

@ -130,7 +130,6 @@ export async function migrate(sql: any) {
show_powered_by BOOLEAN NOT NULL DEFAULT true, show_powered_by BOOLEAN NOT NULL DEFAULT true,
show_response_time BOOLEAN NOT NULL DEFAULT true, show_response_time BOOLEAN NOT NULL DEFAULT true,
show_cert_expiry BOOLEAN NOT NULL DEFAULT false, show_cert_expiry BOOLEAN NOT NULL DEFAULT false,
display_mode TEXT NOT NULL DEFAULT 'expanded',
bar_frequency TEXT NOT NULL DEFAULT 'daily', bar_frequency TEXT NOT NULL DEFAULT 'daily',
bar_count INTEGER NOT NULL DEFAULT 90, bar_count INTEGER NOT NULL DEFAULT 90,
custom_css TEXT, custom_css TEXT,
@ -162,7 +161,6 @@ export async function migrate(sql: any) {
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
group_id UUID REFERENCES status_page_groups(id) ON DELETE SET NULL, group_id UUID REFERENCES status_page_groups(id) ON DELETE SET NULL,
display_name TEXT, display_name TEXT,
display_mode TEXT,
position INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (status_page_id, monitor_id) PRIMARY KEY (status_page_id, monitor_id)
) )

View File

@ -4,7 +4,6 @@
import sql from "./db"; import sql from "./db";
export type Window = "24h" | "7d" | "30d" | "90d";
export type BucketType = "hourly" | "daily"; export type BucketType = "hourly" | "daily";
export interface StatusPageRow { export interface StatusPageRow {
@ -19,7 +18,6 @@ export interface StatusPageRow {
show_powered_by: boolean; show_powered_by: boolean;
show_response_time:boolean; show_response_time:boolean;
show_cert_expiry: boolean; show_cert_expiry: boolean;
display_mode: "compact" | "expanded";
bar_frequency: BucketType; bar_frequency: BucketType;
bar_count: number; bar_count: number;
custom_css: string | null; custom_css: string | null;
@ -29,13 +27,6 @@ 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;
@ -49,14 +40,12 @@ export interface MonitorRow {
// looks up groups by this token. // looks up groups by this token.
group_id: string | null; group_id: string | null;
position: number; 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 // 'paused' means the monitor was disabled in the dashboard - the runner has
// stopped checking it, and the public page should treat it as planned // stopped checking it, and the public page should treat it as planned
// maintenance rather than an outage. // maintenance rather than an outage.
current_state: "up" | "down" | "unknown" | "paused"; current_state: "up" | "down" | "unknown" | "paused";
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; 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 buckets: Array<{ start: string; total: number; up: number; avg_latency: number | null }>; // 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 }>;
@ -107,8 +96,6 @@ export async function loadGroups(pageId: string): Promise<GroupRow[]> {
export async function loadMonitors( export async function loadMonitors(
pageId: string, pageId: string,
window: Window,
pageDisplayMode: "compact" | "expanded" = "expanded",
barFrequency: BucketType = "daily", barFrequency: BucketType = "daily",
barCount: number = 90, barCount: number = 90,
): Promise<MonitorRow[]> { ): Promise<MonitorRow[]> {
@ -126,7 +113,6 @@ export async function loadMonitors(
m.enabled AS enabled, m.enabled AS enabled,
spm.group_id, spm.group_id,
spm.position, spm.position,
spm.display_mode AS spm_display_mode
FROM status_page_monitors spm FROM status_page_monitors spm
JOIN monitors m ON m.id = spm.monitor_id JOIN monitors m ON m.id = spm.monitor_id
WHERE spm.status_page_id = ${pageId} 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) // 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 // 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) // 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 // latByMonitor[mid][] → 30h hourly latency sparkline rows
const barIndexed: Record<string, Record<string, { total: number; up: number }>> = {}; const barIndexed: Record<string, Record<string, { total: number; up: number }>> = {};
const barRegionLat: Record<string, Record<string, { sum: number; n: number }>> = {}; const barRegionLat: Record<string, Record<string, { sum: number; n: number }>> = {};
const barRegionBucketLat: Record<string, Record<string, Record<string, 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 latByMonitor: Record<string, MonitorRow["latency_history"]> = {};
const nowMs = Date.now(); 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; const ms30h = 30 * 3600_000;
for (const r of rollupRows) { 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. // 30h hourly latency sparkline.
if (bt === "hourly" && nowMs - startMs < ms30h) { if (bt === "hourly" && nowMs - startMs < ms30h) {
if (!latByMonitor[mid]) latByMonitor[mid] = []; 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; const latencyByMonitorList = latByMonitor;
return monitorRows.map((m) => { return monitorRows.map((m) => {
@ -409,20 +347,14 @@ export async function loadMonitors(
uptime_pct = tot > 0 ? (100 * upT / tot) : null; uptime_pct = tot > 0 ? (100 * upT / tot) : null;
} }
const avg_latency = fastestLatency[m.id] ?? 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 { return {
id: m.id, id: m.id,
display_name: m.display_name, display_name: m.display_name,
group_id: m.group_id, group_id: m.group_id,
position: m.position, position: m.position,
display_mode,
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] ?? [],
@ -483,7 +415,7 @@ export interface MonitorDetailPayload {
generated_at: string; 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); const page = await loadStatusPage(slug);
if (!page) return null; if (!page) return null;
// Existence check only - confirm the monitor is actually attached to this // 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; if (!link) return null;
const win = (window ?? '24h') as Window;
// Reuse the bulk loader with a single-monitor list - keeps the bucket/state // 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 // 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 // the page's groups so we can redact the monitor's group_id (UUID → public
// position-as-string token), matching what /:slug.json emits. // position-as-string token), matching what /:slug.json emits.
const [allGroups, allMonitors] = await Promise.all([ const [allGroups, allMonitors] = await Promise.all([
loadGroups(page.id), 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 { monitors } = redactGroupsAndMonitors(allGroups, allMonitors);
const m = monitors.find((x) => x.id === monitorId); const m = monitors.find((x) => x.id === monitorId);
@ -570,7 +501,6 @@ export interface PublicPageView {
show_powered_by: boolean; show_powered_by: boolean;
show_response_time: boolean; show_response_time: boolean;
show_cert_expiry: boolean; show_cert_expiry: boolean;
display_mode: "compact" | "expanded";
bar_frequency: BucketType; bar_frequency: BucketType;
bar_count: number; bar_count: number;
custom_css: string | null; custom_css: string | null;
@ -609,7 +539,6 @@ function redactPageForPublic(p: StatusPageRow): PublicPageView {
show_powered_by: p.show_powered_by, show_powered_by: p.show_powered_by,
show_response_time: p.show_response_time, show_response_time: p.show_response_time,
show_cert_expiry: p.show_cert_expiry, show_cert_expiry: p.show_cert_expiry,
display_mode: p.display_mode,
bar_frequency: p.bar_frequency, bar_frequency: p.bar_frequency,
bar_count: p.bar_count, bar_count: p.bar_count,
custom_css: p.custom_css, custom_css: p.custom_css,
@ -635,20 +564,19 @@ function redactGroupsAndMonitors(
name: g.name, name: g.name,
position: g.position, position: g.position,
})); }));
const publicMonitors = monitors.map((m) => { const publicMonitors = monitors.map((m) => ({
const { uptime, ...rest } = m; ...m,
return { ...rest, group_id: m.group_id ? (idMap.get(m.group_id) ?? null) : null }; group_id: m.group_id ? (idMap.get(m.group_id) ?? null) : null,
}); }));
return { groups: publicGroups, monitors: publicMonitors }; 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); const page = await loadStatusPage(slug);
if (!page) return null; if (!page) return null;
const win = (window ?? '24h') as Window;
const [rawGroups, rawMonitors, incidents] = await Promise.all([ const [rawGroups, rawMonitors, incidents] = await Promise.all([
loadGroups(page.id), 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), loadIncidents(page.id),
]); ]);
const { groups, monitors } = redactGroupsAndMonitors(rawGroups, rawMonitors); const { groups, monitors } = redactGroupsAndMonitors(rawGroups, rawMonitors);

View File

@ -3,7 +3,7 @@ import { Eta } from "eta";
import { resolve } from "path"; import { resolve } from "path";
import { createHash } from "crypto"; import { createHash } from "crypto";
import sql from "./db"; import sql from "./db";
import { loadStatusPage, loadPagePayload, loadMonitorDetail, type Window } from "./data"; import { loadStatusPage, loadPagePayload, loadMonitorDetail } 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";
@ -132,7 +132,7 @@ async function renderHtml(slug: string, request: Request): Promise<Response> {
return new Response(html, { headers }); 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 // 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 // the live password_hash, otherwise rotating a password leaves a 15s
// window where old cookies still validate against the stale row. // 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 // fall back to HTML scraping (which gets the password form, also a 401-ish
// signal but more expensive to parse and less stable). // signal but more expensive to parse and less stable).
if (!page || !isAuthorised(page, request)) return jsonNotFound(); if (!page || !isAuthorised(page, request)) return jsonNotFound();
const cacheKey = `payload:${slug}:${win ?? '24h'}`; const payload = await cached(`payload:${slug}`, 15, () => loadPagePayload(slug));
const payload = await cached(cacheKey, 15, () => loadPagePayload(slug, win));
if (!payload) return jsonNotFound(); if (!payload) return jsonNotFound();
// Password-protected JSON must be private - same reasoning as renderHtml. // Password-protected JSON must be private - same reasoning as renderHtml.
const cacheControl = page.password_hash const cacheControl = page.password_hash
@ -208,7 +207,7 @@ const app = new Elysia()
.get("/:slug", async ({ params, request, query }) => { .get("/:slug", async ({ params, request, query }) => {
const { slug, format } = splitSlugAndFormat(params.slug); const { slug, format } = splitSlugAndFormat(params.slug);
if (!allow(slug, clientIp(request))) return rateLimited(); 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); if (format === "rss") return renderRssResp(slug, request);
return renderHtml(slug, request); return renderHtml(slug, request);
}) })
@ -253,9 +252,8 @@ const app = new Elysia()
if (!page || !isAuthorised(page, request)) return jsonNotFound(); if (!page || !isAuthorised(page, request)) return jsonNotFound();
const idWithExt = params.idWithExt; const idWithExt = params.idWithExt;
const monitorId = idWithExt.endsWith(".json") ? idWithExt.slice(0, -5) : 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}`;
const cacheKey = `monitor:${params.slug}:${monitorId}:${win ?? ''}`; const payload = await cached(cacheKey, 15, () => loadMonitorDetail(params.slug, monitorId));
const payload = await cached(cacheKey, 15, () => loadMonitorDetail(params.slug, monitorId, win));
if (!payload) return jsonNotFound(); if (!payload) return jsonNotFound();
const cacheControl = page.password_hash const cacheControl = page.password_hash
? "private, no-store, must-revalidate" ? "private, no-store, must-revalidate"

View File

@ -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-body { display: none; padding: 0 0.75rem 0.75rem; }
.group-toggle:checked ~ .group-body { display: block; } .group-toggle:checked ~ .group-body { display: block; }
.group-body .monitors { gap: 0.35rem; } .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; } .page-components { display: flex; flex-direction: column; gap: 0.5rem; }
.monitors { display: flex; flex-direction: column; gap: 0.5rem; } .monitors { display: flex; flex-direction: column; gap: 0.5rem; }
/* All monitors share one structure: a clickable header row + a collapsible /* All monitors share one structure: a clickable header row + a collapsible
@ -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.warn { color: var(--bar-partial); }
#bar-tooltip .pct.bad { color: var(--bar-down); } #bar-tooltip .pct.bad { color: var(--bar-down); }
/* Multi-window uptime row: four labelled cells side by side. */ /* 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; } .monitor-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem 1.25rem 0; }
.uptime-cell { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem 0.65rem; } .monitor .bars { padding: 0.5rem 1.25rem 1rem; }
.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; }
.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); }
@ -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 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-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; } .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; } 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 { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }

View File

@ -95,19 +95,6 @@
: overall === 'degraded' ? 'rgba(245,158,11,0.4)' : overall === 'degraded' ? 'rgba(245,158,11,0.4)'
: 'rgba(239,68,68,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> %><!DOCTYPE html>
<html lang="en" class="<%= themeClass %>"> <html lang="en" class="<%= themeClass %>">
<head> <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' const windowLabel = page.bar_frequency === 'hourly'
? ('Last ' + page.bar_count + ' hour' + (page.bar_count === 1 ? '' : 's')) ? ('Last ' + page.bar_count + ' hour' + (page.bar_count === 1 ? '' : 's'))
: ('Last ' + page.bar_count + ' day' + (page.bar_count === 1 ? '' : 's')); : ('Last ' + page.bar_count + ' day' + (page.bar_count === 1 ? '' : 's'));
@ -253,35 +235,9 @@
<div class="monitors"> <div class="monitors">
<% list.forEach(function(m) { <% list.forEach(function(m) {
const buckets = m.buckets || []; 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 %>"> <div class="monitor" data-monitor-id="<%= m.id %>">
<input type="checkbox" class="monitor-toggle" id="m-<%= m.id %>" <%= startsOpen ? 'checked' : '' %>> <div class="monitor-header">
<label class="monitor-row" for="m-<%= m.id %>">
<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>
<span class="name"><%= m.display_name %></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><% } %> <% 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> <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> </div>
</label> </div>
<div class="monitor-detail">
<div class="bars" aria-label="<%= statusLabel(m.current_state) %>"> <div class="bars" aria-label="<%= statusLabel(m.current_state) %>">
<% buckets.forEach(function(b) { %> <% 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 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 class="bars-meta">
<span><%= windowLabel %></span>
<span><%= hasData ? 'uptime over window' : 'awaiting data' %></span>
</div> </div>
</div>
</div>
<% } %>
<% }); %> <% }); %>
</div> </div>
<% if (groupName) { %> <% if (groupName) { %>
@ -362,15 +310,6 @@
</div> </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> <footer>
<% if (page.footer_text) { %><div><%= page.footer_text %></div><% } %> <% 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><% } %> <% if (page.show_powered_by) { %><div>Status powered by <a href="https://pingql.com">PingQL</a></div><% } %>

View File

@ -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_order - full list of monitor IDs in DOM order (every row, not just checked)
// monitor_ids - only the *checked* IDs, also in DOM order // monitor_ids - only the *checked* IDs, also in DOM order
// display_name[<id>] - optional per-page name override // 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 // 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 }`) or as flat string keys
// (`b['display_name[id]'] = value`) depending on parser version, so handle both. // (`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): { function parseStatusPageForm(b: any): {
groupsForApi: Array<{ name: string; position: number }>; 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) // Parse groups from form (ordered array of names)
const groupNames: string[] = Array.isArray(b.group_names) ? b.group_names : (b.group_names ? [b.group_names] : []); const 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 checked: string[] = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []);
const checkedSet = new Set(checked); const checkedSet = new Set(checked);
const displayNames = pickMap(b, "display_name"); const displayNames = pickMap(b, "display_name");
const displayModes = pickMap(b, "display_mode");
const monitorGroupMap = pickMap(b, "monitor_group"); const monitorGroupMap = pickMap(b, "monitor_group");
const seen = new Set<string>(); 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) { function addMonitor(id: string) {
if (seen.has(id)) return; if (seen.has(id)) return;
@ -200,7 +198,6 @@ function parseStatusPageForm(b: any): {
position: monitorsForApi.length, position: monitorsForApi.length,
group_index: (groupIndex !== null && groupIndex >= 0 && groupIndex < groupsForApi.length) ? groupIndex : null, group_index: (groupIndex !== null && groupIndex >= 0 && groupIndex < groupsForApi.length) ? groupIndex : null,
display_name: displayNames[id] ?? null, display_name: displayNames[id] ?? null,
display_mode: (displayModes[id] === "compact" || displayModes[id] === "expanded") ? displayModes[id]! : null,
}); });
} }
@ -708,7 +705,7 @@ export const dashboard = new Elysia()
`; `;
if (!page) return redirect("/dashboard/status-pages"); if (!page) return redirect("/dashboard/status-pages");
const [monitors, groups, allMonitors] = await Promise.all([ 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, 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`, 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, description: b.description || null,
theme: b.theme || "auto", theme: b.theme || "auto",
display_mode: b.display_mode || "expanded",
bar_frequency: b.bar_frequency || "daily", bar_frequency: b.bar_frequency || "daily",
bar_count: Number(b.bar_count) || 90, bar_count: Number(b.bar_count) || 90,
show_response_time: !!b.show_response_time, show_response_time: !!b.show_response_time,
@ -767,7 +764,6 @@ export const dashboard = new Elysia()
title: b.title, title: b.title,
description: b.description || null, description: b.description || null,
theme: b.theme || "auto", theme: b.theme || "auto",
display_mode: b.display_mode || "expanded",
bar_frequency: b.bar_frequency || "daily", bar_frequency: b.bar_frequency || "daily",
bar_count: Number(b.bar_count) || 90, bar_count: Number(b.bar_count) || 90,
show_response_time: !!b.show_response_time, show_response_time: !!b.show_response_time,

View File

@ -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>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>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>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_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>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> <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>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>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_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> <tr><td>position</td><td>number?</td><td>Sort order within the group.</td></tr>
</tbody> </tbody>
</table> </table>

View File

@ -9,11 +9,9 @@
const attached = new Set(attachedRows.map(m => m.monitor_id)); const attached = new Set(attachedRows.map(m => m.monitor_id));
// monitor_id -> existing per-page overrides // monitor_id -> existing per-page overrides
const displayNames = {}; const displayNames = {};
const displayModes = {};
const monitorGroups = {}; const monitorGroups = {};
for (const r of attachedRows) { for (const r of attachedRows) {
displayNames[r.monitor_id] = r.display_name || ''; displayNames[r.monitor_id] = r.display_name || '';
displayModes[r.monitor_id] = r.display_mode || '';
monitorGroups[r.monitor_id] = r.group_id || ''; monitorGroups[r.monitor_id] = r.group_id || '';
} }
// Map group UUID -> index for the form // Map group UUID -> index for the form
@ -67,14 +65,6 @@
<% }) %> <% }) %>
</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 class="flex gap-4"> <div class="flex gap-4">
@ -125,7 +115,6 @@
<% orderedMonitors.forEach(function(m) { <% orderedMonitors.forEach(function(m) {
const isAttached = attached.has(m.id); const isAttached = attached.has(m.id);
const displayName = displayNames[m.id] || ''; const displayName = displayNames[m.id] || '';
const displayMode = displayModes[m.id] || '';
const groupIdx = groupIdToIndex[monitorGroups[m.id]] || ''; const groupIdx = groupIdToIndex[monitorGroups[m.id]] || '';
%> %>
<div class="monitor-edit-row flex items-center gap-3 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2" draggable="true" data-monitor-id="<%= m.id %>"> <div class="monitor-edit-row flex items-center gap-3 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2" draggable="true" data-monitor-id="<%= m.id %>">
@ -141,12 +130,6 @@
<option value="<%= i %>" <%= groupIdx === String(i) ? 'selected' : '' %>><%= g.name %></option> <option value="<%= i %>" <%= groupIdx === String(i) ? 'selected' : '' %>><%= g.name %></option>
<% }) %> <% }) %>
</select> </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" <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"> class="text-xs bg-gray-950 border border-gray-800 rounded px-2 py-1 text-gray-200 placeholder-gray-600 focus:outline-none focus:border-blue-500 w-36 shrink-0">
</div> </div>