diff --git a/apps/web/src/page/alerts/hub/derive.ts b/apps/web/src/page/alerts/hub/derive.ts index 76dbac7555..1de7645a33 100644 --- a/apps/web/src/page/alerts/hub/derive.ts +++ b/apps/web/src/page/alerts/hub/derive.ts @@ -34,7 +34,7 @@ export const overviewOf = (model: Model): Option.Option => export const destinationsOf = (model: Model): ReadonlyArray => (model.destinations ?? []).map(rowToAlertDestinationDocument) -export type AlertsTab = "inbox" | "rules" | "uptime" | "routing" +export type AlertsTab = "inbox" | "rules" | "uptime" | "status-pages" | "routing" /** Legacy `tab` values map onto the current tabs so old links keep resolving. */ export const resolveTab = (tab: string | undefined): AlertsTab => { @@ -43,6 +43,8 @@ export const resolveTab = (tab: string | undefined): AlertsTab => { return "rules" case "uptime": return "uptime" + case "status-pages": + return "status-pages" case "routing": case "settings": return "routing" diff --git a/apps/web/src/page/alerts/hub/message.ts b/apps/web/src/page/alerts/hub/message.ts index bfe9b8db9a..9106d308f5 100644 --- a/apps/web/src/page/alerts/hub/message.ts +++ b/apps/web/src/page/alerts/hub/message.ts @@ -13,6 +13,7 @@ import { LoadFailure } from "../../../data/load-failure" import * as Dialog from "../../../ui/dialog" import * as Menu from "../../../ui/menu" import * as DestinationDialog from "../destination/dialog" +import * as StatusPages from "../status-pages/list" import { TestOutcome, UptimeKindFilter, UptimeStatusFilter } from "./model" import { ServiceNamespaces } from "./uptime-groups" @@ -68,5 +69,6 @@ export const Message = defineMessageUnion({ ClickedConfirmDeleteMonitor: {}, CompletedDeleteMonitor: { monitorId: UptimeMonitorId, result: outcome(Schema.Void) }, GotDeleteMonitorDialogMessage: { message: Dialog.Message }, + GotStatusPagesMessage: { message: StatusPages.Message }, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/hub/model.ts b/apps/web/src/page/alerts/hub/model.ts index 8fcfa5d624..ad8dc80fcf 100644 --- a/apps/web/src/page/alerts/hub/model.ts +++ b/apps/web/src/page/alerts/hub/model.ts @@ -14,6 +14,7 @@ import { AlertsSearch } from "../../../route/search" import * as Dialog from "../../../ui/dialog" import * as Menu from "../../../ui/menu" import * as DestinationDialog from "../destination/dialog" +import * as StatusPages from "../status-pages/list" import { ServiceNamespaces } from "./uptime-groups" // The `/alerts` hub. @@ -73,5 +74,7 @@ export const Model = Schema.Struct({ deletingMonitorId: Schema.NullOr(UptimeMonitorId), confirmDeleteMonitorId: Schema.NullOr(UptimeMonitorId), deleteMonitorDialog: Dialog.Model, + /** Status pages tab: its pages, read only while the tab is open, and the create dialog. */ + statusPages: StatusPages.Model, }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index d3521e1882..a6c988839f 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -14,6 +14,7 @@ import type { AppRoute } from "../../../route" import * as Dialog from "../../../ui/dialog" import * as Menu from "../../../ui/menu" import * as DestinationDialog from "../destination/dialog" +import * as StatusPages from "../status-pages/list" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" import { alertsHref, createHref, ruleHref } from "../shared/links" import { editMonitorHref, newMonitorHref, UPTIME_LIST_LIMIT } from "../shared/uptime-commands" @@ -114,6 +115,21 @@ const requestMonitors = (model: Model, context: PageContext, mode: Keyed.ReadMod }) } +/** The Status pages tab reads its pages only while it is open. */ +const requestStatusPages = (model: Model, context: PageContext, mode: Keyed.ReadMode): Requested => { + if (resolveTab(model.search.tab) !== "status-pages") return { model, commands: [] } + return liftStatusPages(model, StatusPages.request(model.statusPages, context, mode)) +} + +/** The tab's step as the hub's: its Commands wrapped, its navigation and toasts passed up. */ +const liftStatusPages = (model: Model, step: StatusPages.Return) => ({ + model: { ...model, statusPages: step.model }, + commands: FoldkitCommand.mapMessages(step.commands ?? [], (message) => + Message.GotStatusPagesMessage({ message }), + ), + outMessage: step.outMessage, +}) + /** `reactivityKeys: ["alertDeliveryEvents"]`: both reads revalidate. */ const refreshDeliveries = (model: Model, context: PageContext) => { const overview = requestDeliveries(model, context, "Refresh") @@ -154,18 +170,21 @@ export const init = (route: HubRoute, context: PageContext): Return => { deletingMonitorId: null, confirmDeleteMonitorId: null, deleteMonitorDialog: Dialog.init(DELETE_MONITOR_DIALOG_ID, "alert-dialog"), + statusPages: StatusPages.init(), } const deliveries = requestDeliveries(model, context, "Load") const routing = requestRoutingDeliveries(deliveries.model, context, "Load") const monitors = requestMonitors(routing.model, context, "Load") + const statusPages = requestStatusPages(monitors.model, context, "Load") return { - model: monitors.model, + model: statusPages.model, commands: [ ReadHubClock(), FetchHubSession({ orgId: context.orgId }), ...deliveries.commands, ...routing.commands, ...monitors.commands, + ...statusPages.commands, ], } } @@ -173,7 +192,11 @@ export const init = (route: HubRoute, context: PageContext): Return => { export const routeChanged = (model: Model, route: HubRoute, context: PageContext): Return => { const routing = requestRoutingDeliveries({ ...model, search: route.search }, context, "Load") const monitors = requestMonitors(routing.model, context, "Load") - return { model: monitors.model, commands: [...routing.commands, ...monitors.commands] } + const statusPages = requestStatusPages(monitors.model, context, "Load") + return { + model: statusPages.model, + commands: [...routing.commands, ...monitors.commands, ...statusPages.commands], + } } const navigate = (search: Model["search"]): PageOutMessage => @@ -537,6 +560,8 @@ export const update = (model: Model, message: Message, context: PageContext): Re }) }, ClickedConfirmDeleteMonitor: () => confirmDeleteMonitor(model), + GotStatusPagesMessage: ({ message: child }) => + liftStatusPages(model, StatusPages.update(model.statusPages, child, context)), GotDeleteMonitorDialogMessage: ({ message: child }) => model.deletingMonitorId !== null ? { model } diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index 4e2c387f23..cf28870459 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -21,6 +21,7 @@ import { alertsEmptyState } from "./view-empty" import { inboxTab } from "./view-inbox" import { routingTab } from "./view-routing" import * as DestinationDialogView from "../destination/view" +import * as StatusPages from "../status-pages/list" import { rulesTab } from "./view-rules" import { deleteMonitorDialog, uptimeTab } from "./view-uptime" @@ -32,6 +33,7 @@ const ALERTS_TABS: ReadonlyArray<{ value: AlertsTab; label: string }> = [ { value: "inbox", label: "Inbox" }, { value: "rules", label: "Rules" }, { value: "uptime", label: "Uptime" }, + { value: "status-pages", label: "Status pages" }, { value: "routing", label: "Routing" }, ] @@ -51,15 +53,17 @@ const tabBar = ( const count = tab.value === "uptime" ? Option.getOrUndefined(monitorCount) - : Option.match(overview, { - onNone: () => undefined, - onSome: (data) => - tab.value === "inbox" - ? data.openIncidents.length - : tab.value === "rules" - ? data.rules.length - : undefined, - }) + : tab.value === "status-pages" + ? StatusPages.pageCount(model.statusPages) + : Option.match(overview, { + onNone: () => undefined, + onSome: (data) => + tab.value === "inbox" + ? data.openIncidents.length + : tab.value === "rules" + ? data.rules.length + : undefined, + }) return { key: tab.value, href: alertsHref({ ...model.search, tab: tab.value }), @@ -160,11 +164,23 @@ const newRuleButton = (h: H, model: Model): Html => ], ) -// Destinations are a Routing concern, so "New destination" only shows on that tab. +/** Opens the Status pages tab's create dialog. */ +const newStatusPage = Message.GotStatusPagesMessage({ message: StatusPages.Message.ClickedNew() }) + +// Destinations are a Routing concern, so "New destination" only shows on that tab; pages likewise. const headerActions = (h: H, model: Model, tab: AlertsTab): Html => h.div( [h.Class("flex items-center gap-2")], [ + ...(model.isAdmin && tab === "status-pages" + ? [ + button( + h, + { size: "sm", variant: "outline", attributes: [h.OnClick(newStatusPage)] }, + [icon(h, "PlusIcon"), "New status page"], + ), + ] + : []), ...(model.isAdmin && tab === "routing" ? [ button( @@ -273,7 +289,20 @@ export const view = Submodel.defineView((model, }) : tab === "uptime" ? uptimeTab(h, model, model.nowMs ?? 0, inputs.context.pinnedNamespace) - : hubBody(h, model, overview, tab), + : tab === "status-pages" + ? h.submodel({ + slotId: "alerts-status-pages", + model: model.statusPages, + view: StatusPages.view, + viewInputs: { + isAdmin: model.isAdmin, + nowMs: model.nowMs ?? 0, + timeZone: inputs.context.timeZone, + }, + toParentMessage: (message) => + Message.GotStatusPagesMessage({ message }), + }) + : hubBody(h, model, overview, tab), ], ), deleteDialog(h, model), diff --git a/apps/web/src/page/alerts/shared/status-page-commands.ts b/apps/web/src/page/alerts/shared/status-page-commands.ts new file mode 100644 index 0000000000..fcb0c184d7 --- /dev/null +++ b/apps/web/src/page/alerts/shared/status-page-commands.ts @@ -0,0 +1,193 @@ +import { type StatusPageComponentId, StatusPageId, type StatusPageIncidentId } from "@maple/domain" +import { + StatusPagePublicId, + type V2StatusPageComponentCreateParams, + type V2StatusPageCreateParams, + type V2StatusPageIncidentCreateParams, + type V2StatusPageIncidentPostUpdateParams, + type V2StatusPageIncidentUpdateParams, + type V2StatusPageUpdateParams, +} from "@maple/domain/http/v2" +import { Effect, Option, Schema } from "effect" +import { toLoadFailure } from "../../../data/load-failure" +import { MapleApiV2Client } from "../../../platform/api-clients" +import { AppRoute, hrefOf } from "../../../route" +import { listMonitors } from "./uptime-commands" +import { summarizePage } from "./status-page-derive" + +// The status page reads and writes, as Effects the pages' Commands wrap, and their links. + +/** One page of each list: far past any plan's allowance of pages, components or open incidents. */ +const LIST_LIMIT = 100 +/** Recent incidents the editor lists: the open ones plus the latest closed. */ +const INCIDENT_LIMIT = 50 + +const listPages = Effect.gen(function* () { + const client = yield* MapleApiV2Client + return (yield* client.statusPages.list({ query: { limit: LIST_LIMIT } })).data +}) + +const listPageComponents = (id: StatusPageId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return (yield* client.statusPages.listComponents({ params: { id }, query: { limit: LIST_LIMIT } })) + .data + }) + +const listOpenIncidents = Effect.gen(function* () { + const client = yield* MapleApiV2Client + return (yield* client.statusPageIncidents.list({ query: { state: "open", limit: LIST_LIMIT } })).data +}) + +/** The hub's rows: each page with its components' worst status and its open incident count. */ +export const listPageSummaries = Effect.gen(function* () { + const pages = yield* listPages + if (pages.length === 0) return [] + const [incidents, monitors, components] = yield* Effect.all( + [ + listOpenIncidents, + // A failed monitor read fails the tab: without it every monitored component would read green. + listMonitors, + Effect.forEach(pages, (page) => listPageComponents(page.id), { concurrency: 4 }), + ], + { concurrency: "unbounded" }, + ) + return pages.map((page, index) => + summarizePage({ + page, + components: components[index] ?? [], + incidents: incidents.filter((incident) => incident.page_id === page.id), + monitors, + }), + ) +}).pipe(Effect.mapError(toLoadFailure)) + +export const retrievePage = (id: StatusPageId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.statusPages.retrieve({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const listComponents = (id: StatusPageId) => + listPageComponents(id).pipe(Effect.mapError(toLoadFailure)) + +/** The page's incidents and maintenance, open and closed, newest first. */ +export const listPageIncidents = (id: StatusPageId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + const response = yield* client.statusPageIncidents.list({ + query: { page_id: id, state: "all", limit: INCIDENT_LIMIT }, + }) + return response.data + }).pipe(Effect.mapError(toLoadFailure)) + +export const createPage = (payload: V2StatusPageCreateParams) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.statusPages.create({ payload }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const updatePage = (id: StatusPageId, payload: V2StatusPageUpdateParams) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.statusPages.update({ params: { id }, payload }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const deletePage = (id: StatusPageId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + yield* client.statusPages.delete({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const publishPage = (id: StatusPageId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.statusPages.publish({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const rotateBreakGlass = (id: StatusPageId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.statusPages.rotateBreakGlass({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const revokeBreakGlass = (id: StatusPageId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.statusPages.revokeBreakGlass({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + +/** Adds a component, or updates `componentId`. */ +export const saveComponent = ( + id: StatusPageId, + componentId: StatusPageComponentId | null, + payload: V2StatusPageComponentCreateParams, +) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return componentId === null + ? yield* client.statusPages.createComponent({ params: { id }, payload }) + : yield* client.statusPages.updateComponent({ + params: { id, component_id: componentId }, + payload, + }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const deleteComponent = (id: StatusPageId, componentId: StatusPageComponentId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + yield* client.statusPages.deleteComponent({ params: { id, component_id: componentId } }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const reorderComponents = (id: StatusPageId, componentIds: ReadonlyArray) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + const response = yield* client.statusPages.reorderComponents({ + params: { id }, + payload: { component_ids: componentIds }, + }) + return response.data + }).pipe(Effect.mapError(toLoadFailure)) + +export const createIncident = (payload: V2StatusPageIncidentCreateParams) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.statusPageIncidents.create({ payload }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const updateIncident = (id: StatusPageIncidentId, payload: V2StatusPageIncidentUpdateParams) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.statusPageIncidents.update({ params: { id }, payload }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const postIncidentUpdate = (id: StatusPageIncidentId, payload: V2StatusPageIncidentPostUpdateParams) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.statusPageIncidents.postUpdate({ params: { id }, payload }) + }).pipe(Effect.mapError(toLoadFailure)) + +// LINKS + +const encodePublicId = Schema.encodeSync(StatusPagePublicId) +const decodePublicId = Schema.decodeUnknownOption(StatusPagePublicId) +const decodeInternalId = Schema.decodeUnknownOption(StatusPageId) + +/** The `stp_…` id in a URL, or a raw UUID, as the internal id. */ +export const pageIdFromUrl = (raw: string): Option.Option => + Option.orElse(decodePublicId(raw), () => decodeInternalId(raw)) + +export type EditorTab = "components" | "incidents" | "settings" + +export const statusPageHref = (id: StatusPageId, tab?: EditorTab): string => + hrefOf( + AppRoute.AlertsStatusPage({ + pageId: encodePublicId(id), + search: tab === undefined || tab === "components" ? {} : { tab }, + }), + ) + +export const statusPagesTabHref = (): string => hrefOf(AppRoute.Alerts({ search: { tab: "status-pages" } })) + +/** Where the page is served. The status host is not configured yet, so it is a path for now. */ +export const publicPath = (slug: string): string => `/s/${slug}` diff --git a/apps/web/src/page/alerts/shared/status-page-derive.test.ts b/apps/web/src/page/alerts/shared/status-page-derive.test.ts new file mode 100644 index 0000000000..86004f46dd --- /dev/null +++ b/apps/web/src/page/alerts/shared/status-page-derive.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "vitest" +import { component, componentId, incident, statusPage } from "../status-pages/test-pages" +import { summarizePage } from "./status-page-derive" +import { downMonitor, httpMonitor } from "./uptime-test-monitors" + +describe("status page summary", () => { + test("a component whose monitor is down puts the page in a major outage", () => { + const down = downMonitor() + const summary = summarizePage({ + page: statusPage(), + components: [component(1), component(2, { monitor_id: down.id })], + incidents: [], + monitors: [httpMonitor(), down], + }) + expect(summary.overall).toBe("major_outage") + expect(summary.componentCount).toBe(2) + }) + + test("a draft incident counts as open but does not change what visitors see", () => { + const summary = summarizePage({ + page: statusPage(), + components: [component(1)], + incidents: [incident(1, { published: false, impact: "critical" })], + monitors: [], + }) + expect(summary.overall).toBe("operational") + expect(summary.openIncidents).toBe(1) + }) + + test("a published incident degrades only the components it names, and a resolved one is not open", () => { + const summary = summarizePage({ + page: statusPage(), + components: [component(1), component(2)], + incidents: [ + incident(1, { impact: "minor", component_ids: [componentId(2)] }), + incident(2, { status: "resolved", impact: "critical" }), + ], + monitors: [], + }) + expect(summary.overall).toBe("degraded") + expect(summary.openIncidents).toBe(1) + }) +}) diff --git a/apps/web/src/page/alerts/shared/status-page-derive.ts b/apps/web/src/page/alerts/shared/status-page-derive.ts new file mode 100644 index 0000000000..057846211f --- /dev/null +++ b/apps/web/src/page/alerts/shared/status-page-derive.ts @@ -0,0 +1,113 @@ +import { + StatusPageComponentStatus, + type StatusPageIncidentImpact, + type StatusPageIncidentKind, + type StatusPageIncidentStatus, +} from "@maple/domain" +import { + V2StatusPage, + type V2StatusPageComponent, + type V2StatusPageIncident, + type V2UptimeMonitor, +} from "@maple/domain/http/v2" +import { + deriveComponentStatus, + incidentComponentStatus, + isActiveIncident, + worstStatus, +} from "@maple/domain/status-page" +import type { Tone } from "@maple/ui/lib/tone" +import { Schema } from "effect" + +// Pure reads of status pages shared by the hub's tab and the editor. + +/** A hub row: the page, its components' worst status and its open incidents. */ +export const StatusPageSummary = Schema.Struct({ + page: V2StatusPage, + overall: StatusPageComponentStatus, + componentCount: Schema.Number, + openIncidents: Schema.Number, +}) +export type StatusPageSummary = typeof StatusPageSummary.Type + +export const COMPONENT_STATUS_LABEL = { + operational: "Operational", + maintenance: "Maintenance", + degraded: "Degraded", + partial_outage: "Partial outage", + major_outage: "Major outage", +} as const satisfies Record + +export const COMPONENT_STATUS_TONE = { + operational: "ok", + maintenance: "info", + degraded: "warn", + partial_outage: "warn", + major_outage: "crit", +} as const satisfies Record + +export const IMPACT_LABEL = { + none: "None", + minor: "Minor", + major: "Major", + critical: "Critical", +} as const satisfies Record + +export const INCIDENT_STATUS_LABEL = { + investigating: "Investigating", + identified: "Identified", + monitoring: "Monitoring", + resolved: "Resolved", + scheduled: "Scheduled", + in_progress: "In progress", + completed: "Completed", +} as const satisfies Record + +/** The statuses an incident or a maintenance moves through, the closing one last. */ +export const STATUSES_OF_KIND = { + incident: ["investigating", "identified", "monitoring", "resolved"], + maintenance: ["scheduled", "in_progress", "completed"], +} as const satisfies Record> + +export const closingStatus = (kind: StatusPageIncidentKind): StatusPageIncidentStatus => + kind === "incident" ? "resolved" : "completed" + +/** Not yet resolved or completed. */ +export const isOpenIncident = (incident: V2StatusPageIncident): boolean => + incident.status !== "resolved" && incident.status !== "completed" + +/** What a visitor sees for one component: monitor, override and published active incidents. */ +export const componentStatus = ( + component: V2StatusPageComponent, + monitors: ReadonlyArray, + incidents: ReadonlyArray, +): StatusPageComponentStatus => + deriveComponentStatus({ + monitorStatus: monitors.find((monitor) => monitor.id === component.monitor_id)?.state.status ?? null, + manualStatus: component.manual_status, + incidentStatuses: incidents + .filter( + (incident) => + incident.published && + isActiveIncident(incident) && + incident.component_ids.includes(component.id), + ) + .map(incidentComponentStatus), + }) + +export const summarizePage = (input: { + readonly page: V2StatusPage + readonly components: ReadonlyArray + readonly incidents: ReadonlyArray + readonly monitors: ReadonlyArray +}): StatusPageSummary => ({ + page: input.page, + overall: worstStatus( + input.components.map((component) => componentStatus(component, input.monitors, input.incidents)), + ), + componentCount: input.components.length, + openIncidents: input.incidents.filter(isOpenIncident).length, +}) + +/** Whether visitors see an older revision than the page has now. */ +export const hasUnpublishedChanges = (page: V2StatusPage): boolean => page.published_revision < page.revision diff --git a/apps/web/src/page/alerts/status-pages/editor/command.ts b/apps/web/src/page/alerts/status-pages/editor/command.ts new file mode 100644 index 0000000000..6a0366b742 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/editor/command.ts @@ -0,0 +1,194 @@ +import { StatusPageComponentId, StatusPageId, StatusPageIncidentId } from "@maple/domain" +import { + V2StatusPageComponentCreateParams, + V2StatusPageIncidentCreateParams, + V2StatusPageIncidentPostUpdateParams, + V2StatusPageUpdateParams, +} from "@maple/domain/http/v2" +import { Clock, Effect, Schema } from "effect" +import { Command } from "foldkit" +import { readIsAdmin } from "../../shared/commands" +import { + createIncident, + deleteComponent, + deletePage, + listComponents, + listPageIncidents, + postIncidentUpdate, + publishPage, + reorderComponents, + retrievePage, + revokeBreakGlass, + rotateBreakGlass, + saveComponent, + updateIncident, + updatePage, +} from "../../shared/status-page-commands" +import { listMonitors } from "../../shared/uptime-commands" +import { Message } from "./message" + +export const ReadEditorClock = Command.define("ReadStatusPageClock", { + messages: [Message.ReadClock], + execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.ReadClock({ nowMs })), +}) + +export const FetchEditorSession = Command.define("FetchStatusPageSession", { + args: { orgId: Schema.String }, + messages: [Message.CompletedFetchSession], + execute: ({ orgId }) => + Effect.map(readIsAdmin, (isAdmin) => Message.CompletedFetchSession({ orgId, isAdmin })), +}) + +export const FetchPage = Command.define("FetchStatusPage", { + args: { key: Schema.String, pageId: StatusPageId }, + messages: [Message.CompletedFetchPage], + execute: ({ key, pageId }) => + retrievePage(pageId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchPage({ key, result })), + ), +}) + +export const FetchComponents = Command.define("FetchStatusPageComponents", { + args: { key: Schema.String, pageId: StatusPageId }, + messages: [Message.CompletedFetchComponents], + execute: ({ key, pageId }) => + listComponents(pageId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchComponents({ key, result })), + ), +}) + +export const FetchIncidents = Command.define("FetchStatusPageIncidents", { + args: { key: Schema.String, pageId: StatusPageId }, + messages: [Message.CompletedFetchIncidents], + execute: ({ key, pageId }) => + listPageIncidents(pageId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchIncidents({ key, result })), + ), +}) + +export const FetchEditorMonitors = Command.define("FetchStatusPageMonitors", { + args: { key: Schema.String }, + messages: [Message.CompletedFetchMonitors], + execute: ({ key }) => + listMonitors.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchMonitors({ key, result })), + ), +}) + +export const SaveComponent = Command.define("SaveStatusPageComponent", { + args: { + pageId: StatusPageId, + componentId: Schema.NullOr(StatusPageComponentId), + payload: V2StatusPageComponentCreateParams, + }, + messages: [Message.CompletedSaveComponent], + execute: ({ pageId, componentId, payload }) => + saveComponent(pageId, componentId, payload).pipe( + Effect.result, + Effect.map((result) => Message.CompletedSaveComponent({ result })), + ), +}) + +export const ReorderComponents = Command.define("ReorderStatusPageComponents", { + args: { pageId: StatusPageId, componentIds: Schema.Array(StatusPageComponentId) }, + messages: [Message.CompletedReorder], + execute: ({ pageId, componentIds }) => + reorderComponents(pageId, componentIds).pipe( + Effect.result, + Effect.map((result) => Message.CompletedReorder({ result })), + ), +}) + +export const DeleteComponent = Command.define("DeleteStatusPageComponent", { + args: { pageId: StatusPageId, componentId: StatusPageComponentId }, + messages: [Message.CompletedDeleteComponent], + execute: ({ pageId, componentId }) => + deleteComponent(pageId, componentId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedDeleteComponent({ result })), + ), +}) + +export const CreateIncident = Command.define("CreateStatusPageIncident", { + args: { payload: V2StatusPageIncidentCreateParams }, + messages: [Message.CompletedCreateIncident], + execute: ({ payload }) => + createIncident(payload).pipe( + Effect.result, + Effect.map((result) => Message.CompletedCreateIncident({ result })), + ), +}) + +export const PostIncidentUpdate = Command.define("PostStatusPageIncidentUpdate", { + args: { incidentId: StatusPageIncidentId, payload: V2StatusPageIncidentPostUpdateParams }, + messages: [Message.CompletedPostUpdate], + execute: ({ incidentId, payload }) => + postIncidentUpdate(incidentId, payload).pipe( + Effect.result, + Effect.map((result) => Message.CompletedPostUpdate({ result })), + ), +}) + +export const PublishIncident = Command.define("PublishStatusPageIncident", { + args: { incidentId: StatusPageIncidentId }, + messages: [Message.CompletedPublishIncident], + execute: ({ incidentId }) => + updateIncident(incidentId, { published: true }).pipe( + Effect.result, + Effect.map((result) => Message.CompletedPublishIncident({ incidentId, result })), + ), +}) + +export const SaveSettings = Command.define("SaveStatusPageSettings", { + args: { pageId: StatusPageId, payload: V2StatusPageUpdateParams }, + messages: [Message.CompletedSaveSettings], + execute: ({ pageId, payload }) => + updatePage(pageId, payload).pipe( + Effect.result, + Effect.map((result) => Message.CompletedSaveSettings({ result })), + ), +}) + +export const PublishPage = Command.define("PublishStatusPage", { + args: { pageId: StatusPageId }, + messages: [Message.CompletedPublish], + execute: ({ pageId }) => + publishPage(pageId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedPublish({ result })), + ), +}) + +export const RotateToken = Command.define("RotateStatusPageBreakGlassToken", { + args: { pageId: StatusPageId }, + messages: [Message.CompletedRotateToken], + execute: ({ pageId }) => + rotateBreakGlass(pageId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedRotateToken({ result })), + ), +}) + +export const RevokeToken = Command.define("RevokeStatusPageBreakGlassToken", { + args: { pageId: StatusPageId }, + messages: [Message.CompletedRevokeToken], + execute: ({ pageId }) => + revokeBreakGlass(pageId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedRevokeToken({ result })), + ), +}) + +export const DeletePage = Command.define("DeleteStatusPage", { + args: { pageId: StatusPageId }, + messages: [Message.CompletedDeletePage], + execute: ({ pageId }) => + deletePage(pageId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedDeletePage({ result })), + ), +}) diff --git a/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts b/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts new file mode 100644 index 0000000000..52f4b2b7b5 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts @@ -0,0 +1,474 @@ +import { OrgId } from "@maple/domain" +import { StatusPagePublicId, type V2StatusPage } from "@maple/domain/http/v2" +import { Result, Schema } from "effect" +import { + click, + expect as expectView, + given, + Mount, + role, + scene, + selector, + text, + type, + withViewInputs, +} from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { toLoadFailure } from "../../../../data/load-failure" +import * as Dialog from "../../../../ui/dialog" +import { PageOutMessage, type PageContext } from "../../../contract" +import { statusPagesTabHref } from "../../shared/status-page-commands" +import { ORG } from "../../shared/test-rows" +import { downMonitor, httpMonitor } from "../../shared/uptime-test-monitors" +import { component, componentId, incident, incidentId, statusPage } from "../test-pages" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { moved } from "./update-components" +import { view as pageView } from "./view" + +const orgId = Schema.decodeSync(OrgId)(ORG) +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + (commands ?? []).map(({ name }) => name) +const PUBLIC_ID = Schema.encodeSync(StatusPagePublicId)(statusPage().id) + +type Fixture = Readonly<{ + tab?: string + page?: V2StatusPage + isAdmin?: boolean + components?: ReturnType[] + incidents?: ReturnType[] + monitorsFail?: boolean + componentsFail?: boolean +}> + +const loaded = (fixture: Fixture = {}): Model => { + const entered = init( + { + _tag: "AlertsStatusPage", + pageId: PUBLIC_ID, + search: fixture.tab === undefined ? {} : { tab: fixture.tab }, + }, + context, + ).model + return [ + Message.ReadClock({ nowMs: Date.UTC(2026, 9, 1, 12) }), + Message.CompletedFetchSession({ orgId: ORG, isAdmin: fixture.isAdmin ?? true }), + Message.CompletedFetchPage({ + key: entered.page.key ?? "", + result: Result.succeed(fixture.page ?? statusPage()), + }), + Message.CompletedFetchComponents({ + key: entered.components.key ?? "", + result: fixture.componentsFail + ? Result.fail(toLoadFailure(new Error("components unavailable"))) + : Result.succeed(fixture.components ?? [component(1), component(2), component(3)]), + }), + Message.CompletedFetchIncidents({ + key: entered.incidents.key ?? "", + result: Result.succeed(fixture.incidents ?? []), + }), + Message.CompletedFetchMonitors({ + key: entered.monitors.key ?? "", + result: fixture.monitorsFail + ? Result.fail(toLoadFailure(new Error("monitors unavailable"))) + : Result.succeed([httpMonitor(), downMonitor()]), + }), + ].reduce((model, message) => pageUpdate(model, message).model, entered) +} + +const mounted = () => Mount.resolve(Dialog.MountDialog, Dialog.Message.CompletedMountDialog()) + +describe("status page editor", () => { + test("entering reads the page, its components, incidents and the monitors", () => { + const entered = init({ _tag: "AlertsStatusPage", pageId: PUBLIC_ID, search: {} }, context) + const sent = names(entered.commands) + expect(sent).toContain("FetchStatusPage") + expect(sent).toContain("FetchStatusPageComponents") + expect(sent).toContain("FetchStatusPageIncidents") + expect(sent).toContain("FetchStatusPageMonitors") + }) + + test("an id that is not a page id shows not found without a read", () => { + const entered = init({ _tag: "AlertsStatusPage", pageId: "nope", search: {} }, context) + expect(names(entered.commands)).not.toContain("FetchStatusPage") + scene( + { update: pageUpdate, view }, + given(entered.model), + expectView(text("Status page not found")).toExist(), + ) + }) + + test("the header shows the worst component, here one whose monitor is down", () => { + const model = loaded({ components: [component(1), component(2, { monitor_id: downMonitor().id })] }) + scene( + { update: pageUpdate, view }, + given(model), + expectView(role("heading", { name: /^Acme Status\s*Major outage$/u })).toExist(), + expectView(text("/s/acme-status")).toExist(), + ) + }) +}) + +describe("read failures", () => { + test("a failed monitors read shows an error, not every component Operational", () => { + const model = loaded({ + components: [component(1, { monitor_id: downMonitor().id })], + monitorsFail: true, + }) + scene( + { update: pageUpdate, view }, + given(model), + expectView(text("Couldn't load components")).toExist(), + expectView(role("heading", { name: /^Acme Status$/u })).toExist(), + expectView(text("Operational")).not.toExist(), + ) + }) +}) + +describe("read failures on the incidents tab", () => { + test("a failed components read shows an error rather than incidents with no components", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ tab: "incidents", componentsFail: true })), + expectView(text("Couldn't load incidents")).toExist(), + expectView(role("button", { name: "New incident" })).not.toExist(), + ) + }) +}) + +describe("components tab", () => { + test("moving a component down sends the whole new order once and shows it at once", () => { + const moving = pageUpdate( + loaded(), + Message.ClickedMoveComponent({ componentId: componentId(1), offset: 1 }), + ) + expect(moving.commands).toMatchObject([ + { + name: "ReorderStatusPageComponents", + args: { componentIds: [componentId(2), componentId(1), componentId(3)] }, + }, + ]) + expect( + pageUpdate(moving.model, Message.ClickedMoveComponent({ componentId: componentId(2), offset: 1 })) + .commands ?? [], + ).toEqual([]) + const done = pageUpdate(moving.model, Message.CompletedReorder({ result: Result.succeed([]) })) + expect(names(done.commands)).toContain("FetchStatusPageComponents") + }) + + test("the first component cannot move up and the last cannot move down", () => { + const rows = [component(1), component(2)] + expect(moved(rows, componentId(1), -1)).toBeNull() + expect(moved(rows, componentId(2), 1)).toBeNull() + scene( + { update: pageUpdate, view }, + given(loaded({ components: rows })), + expectView(role("button", { name: "Move Component 1 up" })).toBeDisabled(), + expectView(role("button", { name: "Move Component 2 down" })).toBeDisabled(), + expectView(role("button", { name: "Move Component 1 down" })).not.toBeDisabled(), + ) + }) + + test("adding a component links the picked monitor and sends the cleared fields as null", () => { + const monitor = downMonitor() + const opened = pageUpdate(loaded(), Message.ClickedAddComponent()).model + const filled = [ + Message.ChangedComponentField({ field: "name", value: " Payments " }), + Message.ChangedComponentField({ field: "monitorId", value: monitor.id }), + Message.ToggledComponentUptime(), + ].reduce((model, message) => pageUpdate(model, message).model, opened) + const saving = pageUpdate(filled, Message.SubmittedComponent()) + expect(saving.commands).toMatchObject([ + { + name: "SaveStatusPageComponent", + args: { + componentId: null, + payload: { + name: "Payments", + description: null, + group: null, + monitor_id: monitor.id, + manual_status: null, + show_uptime: false, + }, + }, + }, + ]) + }) + + test("editing fills the dialog from the component", () => { + const target = component(2, { name: "API", group: "Core", manual_status: "degraded" }) + const opened = pageUpdate( + loaded({ components: [component(1), target] }), + Message.ClickedEditComponent({ componentId: target.id }), + ).model + scene( + { update: pageUpdate, view }, + given(opened), + mounted(), + expectView(role("textbox", { name: "Name" })).toHaveValue("API"), + expectView(role("textbox", { name: "Group" })).toHaveValue("Core"), + expectView(role("button", { name: "Save component" })).toExist(), + ) + }) + + test("members see the components without the controls to change them", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ isAdmin: false })), + expectView(text("Component 1")).toExist(), + expectView(role("button", { name: "Add component" })).not.toExist(), + expectView(role("button", { name: "Edit Component 1" })).not.toExist(), + ) + }) + + test("removing a component asks first, then deletes it once", () => { + const asking = pageUpdate(loaded(), Message.ClickedDeleteComponent({ componentId: componentId(2) })) + expect(asking.model.confirmDeleteComponentId).toBe(componentId(2)) + const deleting = pageUpdate(asking.model, Message.ClickedConfirmDeleteComponent()) + expect(deleting.commands).toMatchObject([ + { name: "DeleteStatusPageComponent", args: { componentId: componentId(2) } }, + ]) + expect(pageUpdate(deleting.model, Message.ClickedConfirmDeleteComponent()).commands ?? []).toEqual([]) + }) +}) + +describe("incidents tab", () => { + test("open incidents carry their actions, and a draft can be published", () => { + const model = loaded({ + tab: "incidents", + incidents: [ + incident(1, { published: false }), + incident(2, { status: "resolved", title: "Old outage" }), + ], + }) + scene( + { update: pageUpdate, view }, + given(model), + expectView(text("Draft")).toExist(), + expectView(role("button", { name: "Publish draft" })).toExist(), + expectView(role("button", { name: "Resolve" })).toExist(), + expectView(text("Old outage")).toExist(), + ) + expect( + pageUpdate(model, Message.ClickedPublishIncident({ incidentId: incidentId(1) })).commands, + ).toMatchObject([{ name: "PublishStatusPageIncident", args: { incidentId: incidentId(1) } }]) + }) + + test("resolving posts a resolved update with the closing message", () => { + const model = loaded({ tab: "incidents", incidents: [incident(1)] }) + const opened = pageUpdate( + model, + Message.ClickedPostUpdate({ incidentId: incidentId(1), isResolving: true }), + ) + const posting = pageUpdate(opened.model, Message.SubmittedUpdate()) + expect(posting.commands).toMatchObject([ + { + name: "PostStatusPageIncidentUpdate", + args: { + incidentId: incidentId(1), + payload: { status: "resolved", body: "This incident has been resolved." }, + }, + }, + ]) + }) + + test("Post update moves the status one step along and needs a message", () => { + const model = loaded({ tab: "incidents", incidents: [incident(1, { status: "identified" })] }) + const opened = pageUpdate( + model, + Message.ClickedPostUpdate({ incidentId: incidentId(1), isResolving: false }), + ) + expect(opened.model.updateForm?.status).toBe("monitoring") + expect(pageUpdate(opened.model, Message.SubmittedUpdate()).commands ?? []).toEqual([]) + }) + + test("an incident saved as a draft names its components and is not published", () => { + const opened = pageUpdate( + loaded({ tab: "incidents" }), + Message.ClickedNewIncident({ kind: "incident" }), + ).model + const filled = [ + Message.ChangedIncidentField({ field: "title", value: "Slow checkout" }), + Message.ChangedIncidentField({ field: "impact", value: "critical" }), + Message.ToggledIncidentComponent({ componentId: componentId(2) }), + Message.ChangedIncidentField({ field: "body", value: "Checkout is slow." }), + Message.PickedIncidentPublished({ isPublished: false }), + ].reduce((current, message) => pageUpdate(current, message).model, opened) + expect(pageUpdate(filled, Message.SubmittedIncident()).commands).toMatchObject([ + { + name: "CreateStatusPageIncident", + args: { + payload: { + kind: "incident", + title: "Slow checkout", + impact: "critical", + status: "investigating", + component_ids: [componentId(2)], + body: "Checkout is slow.", + published: false, + }, + }, + }, + ]) + }) + + test("maintenance needs a window that ends after it starts, read in the viewer's zone", () => { + const zoned: PageContext = { ...context, timeZone: "Europe/Berlin" } + const opened = update( + loaded({ tab: "incidents" }), + Message.ClickedNewIncident({ kind: "maintenance" }), + zoned, + ).model + const filled = [ + Message.ChangedIncidentField({ field: "title", value: "Database upgrade" }), + Message.ChangedIncidentField({ field: "body", value: "Brief read-only periods." }), + Message.ChangedIncidentField({ field: "scheduledStart", value: "2026-10-30T22:00" }), + Message.ChangedIncidentField({ field: "scheduledEnd", value: "2026-10-30T21:00" }), + ].reduce((current, message) => update(current, message, zoned).model, opened) + expect(update(filled, Message.SubmittedIncident(), zoned).commands ?? []).toEqual([]) + const fixed = update( + filled, + Message.ChangedIncidentField({ field: "scheduledEnd", value: "2026-10-31T01:00" }), + zoned, + ).model + expect(update(fixed, Message.SubmittedIncident(), zoned).commands).toMatchObject([ + { + name: "CreateStatusPageIncident", + args: { + payload: { + kind: "maintenance", + status: "scheduled", + scheduled_start: "2026-10-30T21:00:00.000Z", + scheduled_end: "2026-10-31T00:00:00.000Z", + }, + }, + }, + ]) + }) + + test("the composer lists what is missing before it can be sent", () => { + const opened = pageUpdate( + loaded({ tab: "incidents" }), + Message.ClickedNewIncident({ kind: "maintenance" }), + ).model + scene( + { update: pageUpdate, view }, + given(opened), + mounted(), + expectView(text("Add a title.")).toExist(), + type(role("textbox", { name: "Title" }), "Database upgrade"), + type(role("textbox", { name: "Message" }), "Brief read-only periods."), + expectView(text("Set when the maintenance starts and ends.")).toExist(), + expectView(role("button", { name: "Schedule" })).toBeDisabled(), + ) + }) +}) + +describe("settings tab", () => { + test("an edit raises the save bar, and a bad color blocks the save", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ tab: "settings" })), + expectView(text("Unsaved changes")).not.toExist(), + type(role("textbox", { name: "Brand color" }), "teal"), + expectView(text("Unsaved changes")).toExist(), + expectView(text("Use a hex color like #1a2b3c.")).toExist(), + expectView(role("button", { name: "Save" })).toBeDisabled(), + ) + }) + + test("saving sends every field and settles once the page matches", () => { + const edited = [ + Message.ChangedSetting({ field: "brandColor", value: "#1A2B3C" }), + Message.ChangedSetting({ field: "autoIncidents", value: "publish" }), + Message.ToggledPoweredBy(), + ].reduce((model, message) => pageUpdate(model, message).model, loaded({ tab: "settings" })) + const saving = pageUpdate(edited, Message.ClickedSaveSettings()) + expect(saving.commands).toMatchObject([ + { + name: "SaveStatusPageSettings", + args: { + payload: { + name: "Acme Status", + slug: "acme-status", + brand_color: "#1a2b3c", + show_powered_by: false, + auto_incidents: "publish", + website_url: null, + }, + }, + }, + ]) + const saved = statusPage({ + brand_color: "#1A2B3C", + show_powered_by: false, + auto_incidents: "publish", + }) + const done = pageUpdate( + saving.model, + Message.CompletedSaveSettings({ result: Result.succeed(saved) }), + ) + expect(done.model.settingsEdits).toBeNull() + }) + + test("the first break-glass token is issued at once and shown once", () => { + const issuing = pageUpdate(loaded({ tab: "settings" }), Message.ClickedRotateToken()) + expect(names(issuing.commands)).toEqual(["RotateStatusPageBreakGlassToken"]) + const issued = pageUpdate( + issuing.model, + Message.CompletedRotateToken({ + result: Result.succeed({ + id: statusPage().id, + object: "status_page.break_glass_token", + token: "mstat_secret_value_123456", + token_suffix: "123456", + }), + }), + ).model + scene( + { update: pageUpdate, view }, + given(issued), + expectView(selector('[id="status-page-break-glass-token"]')).toHaveValue( + "mstat_secret_value_123456", + ), + click(role("button", { name: "Done" })), + expectView( + text("Store it outside Maple, in your password manager. It is not shown again."), + ).not.toExist(), + ) + }) + + test("replacing or revoking an issued token asks first", () => { + const model = loaded({ tab: "settings", page: statusPage({ break_glass_token_suffix: "ABC123" }) }) + const replacing = pageUpdate(model, Message.ClickedRotateToken()) + expect(names(replacing.commands)).not.toContain("RotateStatusPageBreakGlassToken") + expect(replacing.model.breakGlassDialog.isOpen).toBe(true) + const revoking = pageUpdate(model, Message.ClickedRevokeToken()) + expect(revoking.model.breakGlassAction).toBe("revoke") + expect(pageUpdate(revoking.model, Message.ClickedConfirmBreakGlass()).commands).toMatchObject([ + { name: "RevokeStatusPageBreakGlassToken" }, + ]) + }) + + test("deleting the page confirms, then returns to the status pages tab", () => { + const asking = pageUpdate(loaded({ tab: "settings" }), Message.ClickedDeletePage()) + const deleting = pageUpdate(asking.model, Message.ClickedConfirmDeletePage()) + expect(names(deleting.commands)).toEqual(["DeleteStatusPage"]) + const done = pageUpdate( + deleting.model, + Message.CompletedDeletePage({ result: Result.succeed(undefined) }), + ) + expect(done.outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: statusPagesTabHref(), + isReplace: true, + toast: { type: "success", title: "Status page deleted", description: "" }, + }), + ) + }) +}) diff --git a/apps/web/src/page/alerts/status-pages/editor/form.test.ts b/apps/web/src/page/alerts/status-pages/editor/form.test.ts new file mode 100644 index 0000000000..7ad360dfc7 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/editor/form.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest" +import { isSameSettings, type SettingsForm } from "./form" + +const saved: SettingsForm = { + name: "Acme!", + slug: "acme", + description: "", + websiteUrl: "https://acme.example", + logoUrl: "", + brandColor: "#aabbcc", + showPoweredBy: true, + autoIncidents: "off", +} + +describe("isSameSettings", () => { + it("treats a draft the API stored trimmed and lower-cased as saved", () => { + const draft = { ...saved, name: "Acme! ", websiteUrl: " https://acme.example", brandColor: "#AABBCC" } + expect(isSameSettings(draft, saved)).toBe(true) + }) + + it("still sees a real change", () => { + expect(isSameSettings({ ...saved, brandColor: "#aabbcd" }, saved)).toBe(false) + }) +}) diff --git a/apps/web/src/page/alerts/status-pages/editor/form.ts b/apps/web/src/page/alerts/status-pages/editor/form.ts new file mode 100644 index 0000000000..3a597dec55 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/editor/form.ts @@ -0,0 +1,306 @@ +import { + StatusPageAutoIncidents, + StatusPageComponentId, + StatusPageComponentStatus, + StatusPageIncidentId, + StatusPageIncidentImpact, + StatusPageIncidentKind, + StatusPageIncidentStatus, + UptimeMonitorId, +} from "@maple/domain" +import type { + V2StatusPage, + V2StatusPageComponent, + V2StatusPageComponentCreateParams, + V2StatusPageIncidentCreateParams, + V2StatusPageUpdateParams, +} from "@maple/domain/http/v2" +import { DateTime, Option, Schema } from "effect" +import { closingStatus, STATUSES_OF_KIND } from "../../shared/status-page-derive" +import { slugIssue } from "../slug" + +// The editor's drafts: each form as its inputs hold it, and the payloads built from them. + +// COMPONENTS + +export const ComponentTextField = Schema.Literals([ + "name", + "description", + "group", + "monitorId", + "manualStatus", +]) +export type ComponentTextField = typeof ComponentTextField.Type + +export const ComponentForm = Schema.Struct({ + /** The component being edited; `null` adds one. */ + editingId: Schema.NullOr(StatusPageComponentId), + name: Schema.String, + description: Schema.String, + group: Schema.String, + /** The linked monitor's id, `""` for none. */ + monitorId: Schema.String, + /** The override, `""` to follow the monitor and incidents. */ + manualStatus: Schema.String, + showUptime: Schema.Boolean, +}) +export type ComponentForm = typeof ComponentForm.Type + +export const emptyComponentForm: ComponentForm = { + editingId: null, + name: "", + description: "", + group: "", + monitorId: "", + manualStatus: "", + showUptime: true, +} + +export const componentFormOf = (component: V2StatusPageComponent): ComponentForm => ({ + editingId: component.id, + name: component.name, + description: component.description ?? "", + group: component.group ?? "", + monitorId: component.monitor_id ?? "", + manualStatus: component.manual_status ?? "", + showUptime: component.show_uptime, +}) + +const orNull = (value: string): string | null => (value.trim() === "" ? null : value.trim()) +const decodeMonitorId = Schema.decodeUnknownOption(UptimeMonitorId) +const decodeComponentStatus = Schema.decodeUnknownOption(StatusPageComponentStatus) + +/** The whole component, sent on create and update alike so a cleared field is cleared. */ +export const componentPayload = (form: ComponentForm): V2StatusPageComponentCreateParams => ({ + name: form.name.trim(), + description: orNull(form.description), + group: orNull(form.group), + monitor_id: Option.getOrNull(decodeMonitorId(form.monitorId)), + manual_status: Option.getOrNull(decodeComponentStatus(form.manualStatus)), + show_uptime: form.showUptime, +}) + +// INCIDENTS + +export const IncidentTextField = Schema.Literals([ + "title", + "impact", + "status", + "body", + "scheduledStart", + "scheduledEnd", +]) +export type IncidentTextField = typeof IncidentTextField.Type + +export const IncidentForm = Schema.Struct({ + kind: StatusPageIncidentKind, + title: Schema.String, + impact: StatusPageIncidentImpact, + status: StatusPageIncidentStatus, + componentIds: Schema.Array(StatusPageComponentId), + body: Schema.String, + /** `false` saves a draft visitors do not see. */ + isPublished: Schema.Boolean, + /** Maintenance only: the window as `datetime-local` values, in the viewer's time zone. */ + scheduledStart: Schema.String, + scheduledEnd: Schema.String, +}) +export type IncidentForm = typeof IncidentForm.Type + +export const emptyIncidentForm = (kind: StatusPageIncidentKind): IncidentForm => ({ + kind, + title: "", + impact: kind === "incident" ? "minor" : "none", + status: kind === "incident" ? "investigating" : "scheduled", + componentIds: [], + body: "", + isPublished: true, + scheduledStart: "", + scheduledEnd: "", +}) + +const LOCAL_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/u + +/** A `datetime-local` value read as wall-clock time in `timeZone`, as an ISO instant. */ +export const localToIso = (value: string, timeZone: string): Option.Option => { + const match = LOCAL_DATE_TIME.exec(value) + if (match === null) return Option.none() + const [year, month, day, hour, minute] = match.slice(1).map(Number) + return Option.map( + DateTime.makeZoned({ year, month, day, hour, minute }, { timeZone, adjustForTimeZone: true }), + DateTime.formatIso, + ) +} + +/** What stops the incident from being sent, in reading order; empty when it can go. */ +export const incidentIssues = (form: IncidentForm, timeZone: string): ReadonlyArray => { + const start = localToIso(form.scheduledStart, timeZone) + const end = localToIso(form.scheduledEnd, timeZone) + const windowIssue = + form.kind !== "maintenance" + ? null + : Option.isNone(start) || Option.isNone(end) + ? "Set when the maintenance starts and ends." + : start.value >= end.value + ? "The maintenance must end after it starts." + : null + return [ + ...(form.title.trim() === "" ? ["Add a title."] : []), + ...(form.body.trim() === "" ? ["Write the first update."] : []), + ...(windowIssue === null ? [] : [windowIssue]), + ] +} + +export const incidentPayload = ( + form: IncidentForm, + pageId: V2StatusPage["id"], + timeZone: string, +): V2StatusPageIncidentCreateParams => { + const base: V2StatusPageIncidentCreateParams = { + page_id: pageId, + kind: form.kind, + title: form.title.trim(), + impact: form.impact, + status: form.status, + body: form.body.trim(), + component_ids: form.componentIds, + published: form.isPublished, + } + if (form.kind !== "maintenance") return base + return Option.match( + Option.all([localToIso(form.scheduledStart, timeZone), localToIso(form.scheduledEnd, timeZone)]), + { + onNone: () => base, + onSome: ([start, end]) => ({ ...base, scheduled_start: start, scheduled_end: end }), + }, + ) +} + +// UPDATES + +export const UpdateForm = Schema.Struct({ + incidentId: StatusPageIncidentId, + kind: StatusPageIncidentKind, + status: StatusPageIncidentStatus, + body: Schema.String, +}) +export type UpdateForm = typeof UpdateForm.Type + +/** The next status along, or the closing one for Resolve. */ +export const updateFormFor = ( + incident: { + readonly id: StatusPageIncidentId + readonly kind: StatusPageIncidentKind + readonly status: StatusPageIncidentStatus + }, + isResolving: boolean, +): UpdateForm => { + const statuses: ReadonlyArray = STATUSES_OF_KIND[incident.kind] + const next = statuses[statuses.indexOf(incident.status) + 1] ?? incident.status + const status = isResolving ? closingStatus(incident.kind) : next + return { + incidentId: incident.id, + kind: incident.kind, + status, + body: isResolving + ? incident.kind === "incident" + ? "This incident has been resolved." + : "The maintenance is complete." + : "", + } +} + +// SETTINGS + +export const SettingsTextField = Schema.Literals([ + "name", + "slug", + "description", + "websiteUrl", + "logoUrl", + "brandColor", + "autoIncidents", +]) +export type SettingsTextField = typeof SettingsTextField.Type + +export const SettingsForm = Schema.Struct({ + name: Schema.String, + slug: Schema.String, + description: Schema.String, + websiteUrl: Schema.String, + logoUrl: Schema.String, + brandColor: Schema.String, + showPoweredBy: Schema.Boolean, + autoIncidents: StatusPageAutoIncidents, +}) +export type SettingsForm = typeof SettingsForm.Type + +export const settingsFormOf = (page: V2StatusPage): SettingsForm => ({ + name: page.name, + slug: page.slug, + description: page.description ?? "", + websiteUrl: page.website_url ?? "", + logoUrl: page.logo_url ?? "", + brandColor: page.brand_color ?? "", + showPoweredBy: page.show_powered_by, + autoIncidents: page.auto_incidents, +}) + +/** The values the API stores: trimmed, the color lower-cased, as `settingsPayload` sends them. */ +const normalizedSettings = (form: SettingsForm): SettingsForm => ({ + ...form, + name: form.name.trim(), + description: form.description.trim(), + websiteUrl: form.websiteUrl.trim(), + logoUrl: form.logoUrl.trim(), + brandColor: form.brandColor.trim().toLowerCase(), +}) + +/** Same once normalized, so a saved draft whose input differed only in case or spaces settles. */ +export const isSameSettings = (left: SettingsForm, right: SettingsForm): boolean => + sameNormalized(normalizedSettings(left), normalizedSettings(right)) + +const sameNormalized = (a: SettingsForm, b: SettingsForm): boolean => + a.name === b.name && + a.slug === b.slug && + a.description === b.description && + a.websiteUrl === b.websiteUrl && + a.logoUrl === b.logoUrl && + a.brandColor === b.brandColor && + a.showPoweredBy === b.showPoweredBy && + a.autoIncidents === b.autoIncidents + +const HEX_COLOR = /^#[0-9a-f]{6}$/iu +const isHttpsUrl = (value: string): boolean => /^https:\/\/\S+$/u.test(value) +const isHttpUrl = (value: string): boolean => /^https?:\/\/\S+$/u.test(value) + +/** Each field's problem, or `null`: the API refuses the same values. */ +export const settingsIssues = (form: SettingsForm): Readonly> => ({ + name: form.name.trim() === "" ? "Add a name." : null, + slug: slugIssue(form.slug), + description: null, + websiteUrl: + form.websiteUrl.trim() === "" || isHttpUrl(form.websiteUrl.trim()) + ? null + : "Use an http or https URL.", + logoUrl: form.logoUrl.trim() === "" || isHttpsUrl(form.logoUrl.trim()) ? null : "Use an https URL.", + brandColor: + form.brandColor.trim() === "" || HEX_COLOR.test(form.brandColor.trim()) + ? null + : "Use a hex color like #1a2b3c.", + autoIncidents: null, +}) + +export const hasSettingsIssues = (form: SettingsForm): boolean => + Object.values(settingsIssues(form)).some((issue) => issue !== null) + +export const settingsPayload = (form: SettingsForm): V2StatusPageUpdateParams => ({ + name: form.name.trim(), + slug: form.slug, + description: orNull(form.description), + website_url: orNull(form.websiteUrl), + logo_url: orNull(form.logoUrl), + brand_color: orNull(form.brandColor)?.toLowerCase() ?? null, + show_powered_by: form.showPoweredBy, + auto_incidents: form.autoIncidents, +}) diff --git a/apps/web/src/page/alerts/status-pages/editor/index.ts b/apps/web/src/page/alerts/status-pages/editor/index.ts new file mode 100644 index 0000000000..e6d6f23a68 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/editor/index.ts @@ -0,0 +1,20 @@ +import { definePage } from "../../../contract" +import { Message } from "./message" +import { Model } from "./model" +import { init, routeChanged, update } from "./update" +import { breadcrumbsOf, view } from "./view" + +/** `/alerts/status-pages/$pageId`: one instance per page; the tabs are search. */ +export const page = definePage( + "AlertsStatusPage", + { Model, Message }, + { + routes: ["AlertsStatusPage"], + key: (route) => route.pageId, + init, + update, + routeChanged, + view, + breadcrumbs: breadcrumbsOf, + }, +) diff --git a/apps/web/src/page/alerts/status-pages/editor/message.ts b/apps/web/src/page/alerts/status-pages/editor/message.ts new file mode 100644 index 0000000000..c9188ff60a --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/editor/message.ts @@ -0,0 +1,80 @@ +import { StatusPageComponentId, StatusPageIncidentId, StatusPageIncidentKind } from "@maple/domain" +import { + V2StatusPage, + V2StatusPageBreakGlassToken, + V2StatusPageComponent, + V2StatusPageIncident, + V2StatusPagePublishResult, + V2UptimeMonitor, +} from "@maple/domain/http/v2" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { LoadFailure } from "../../../../data/load-failure" +import * as CopyButton from "../../../../ui/copy-button" +import * as Dialog from "../../../../ui/dialog" +import { ComponentTextField, IncidentTextField, SettingsTextField } from "./form" + +const outcome = (success: S) => Schema.Result(success, LoadFailure) + +export const UpdateTextField = Schema.Literals(["status", "body"]) + +export const Message = defineMessageUnion({ + ReadClock: { nowMs: Schema.Number }, + CompletedFetchPage: { key: Schema.String, result: outcome(V2StatusPage) }, + CompletedFetchComponents: { key: Schema.String, result: outcome(Schema.Array(V2StatusPageComponent)) }, + CompletedFetchIncidents: { key: Schema.String, result: outcome(Schema.Array(V2StatusPageIncident)) }, + CompletedFetchMonitors: { key: Schema.String, result: outcome(Schema.Array(V2UptimeMonitor)) }, + CompletedFetchSession: { orgId: Schema.String, isAdmin: Schema.Boolean }, + ClickedRetry: {}, + // Components + ClickedAddComponent: {}, + ClickedEditComponent: { componentId: StatusPageComponentId }, + ChangedComponentField: { field: ComponentTextField, value: Schema.String }, + ToggledComponentUptime: {}, + SubmittedComponent: {}, + CompletedSaveComponent: { result: outcome(V2StatusPageComponent) }, + GotComponentDialogMessage: { message: Dialog.Message }, + ClickedMoveComponent: { componentId: StatusPageComponentId, offset: Schema.Number }, + CompletedReorder: { result: outcome(Schema.Array(V2StatusPageComponent)) }, + ClickedDeleteComponent: { componentId: StatusPageComponentId }, + ClickedConfirmDeleteComponent: {}, + CompletedDeleteComponent: { result: outcome(Schema.Void) }, + GotDeleteComponentDialogMessage: { message: Dialog.Message }, + // Incidents + ClickedNewIncident: { kind: StatusPageIncidentKind }, + ChangedIncidentField: { field: IncidentTextField, value: Schema.String }, + ToggledIncidentComponent: { componentId: StatusPageComponentId }, + PickedIncidentPublished: { isPublished: Schema.Boolean }, + SubmittedIncident: {}, + CompletedCreateIncident: { result: outcome(V2StatusPageIncident) }, + GotIncidentDialogMessage: { message: Dialog.Message }, + ClickedPostUpdate: { incidentId: StatusPageIncidentId, isResolving: Schema.Boolean }, + ChangedUpdateField: { field: UpdateTextField, value: Schema.String }, + SubmittedUpdate: {}, + CompletedPostUpdate: { result: outcome(V2StatusPageIncident) }, + GotUpdateDialogMessage: { message: Dialog.Message }, + ClickedPublishIncident: { incidentId: StatusPageIncidentId }, + CompletedPublishIncident: { incidentId: StatusPageIncidentId, result: outcome(V2StatusPageIncident) }, + // Settings + ChangedSetting: { field: SettingsTextField, value: Schema.String }, + ToggledPoweredBy: {}, + ClickedDiscardSettings: {}, + ClickedSaveSettings: {}, + CompletedSaveSettings: { result: outcome(V2StatusPage) }, + ClickedPublish: {}, + CompletedPublish: { result: outcome(V2StatusPagePublishResult) }, + ClickedRotateToken: {}, + ClickedRevokeToken: {}, + ClickedConfirmBreakGlass: {}, + CompletedRotateToken: { result: outcome(V2StatusPageBreakGlassToken) }, + CompletedRevokeToken: { result: outcome(V2StatusPage) }, + GotBreakGlassDialogMessage: { message: Dialog.Message }, + GotTokenCopyMessage: { message: CopyButton.Message }, + ClickedDismissToken: {}, + // Page + ClickedDeletePage: {}, + ClickedConfirmDeletePage: {}, + CompletedDeletePage: { result: outcome(Schema.Void) }, + GotDeletePageDialogMessage: { message: Dialog.Message }, +}) +export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/status-pages/editor/model.ts b/apps/web/src/page/alerts/status-pages/editor/model.ts new file mode 100644 index 0000000000..1aa43ee0b0 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/editor/model.ts @@ -0,0 +1,71 @@ +import { StatusPageComponentId, StatusPageId, StatusPageIncidentId } from "@maple/domain" +import { + V2StatusPage, + V2StatusPageComponent, + V2StatusPageIncident, + V2UptimeMonitor, +} from "@maple/domain/http/v2" +import { Schema } from "effect" +import { KeyedSchema } from "../../../../data/keyed" +import { LoadFailure } from "../../../../data/load-failure" +import { AlertsStatusPageSearch } from "../../../../route/search" +import * as CopyButton from "../../../../ui/copy-button" +import * as Dialog from "../../../../ui/dialog" +import { ComponentForm, IncidentForm, SettingsForm, UpdateForm } from "./form" + +// `/alerts/status-pages/$pageId`: one status page, its components, incidents and settings. + +export const BreakGlassAction = Schema.Literals(["rotate", "revoke"]) +export type BreakGlassAction = typeof BreakGlassAction.Type + +/** A freshly issued break-glass token: shown once, never read back. */ +export const RevealedToken = Schema.Struct({ token: Schema.String, suffix: Schema.String }) + +export const Model = Schema.Struct({ + search: AlertsStatusPageSearch, + /** The URL segment as written; `pageId` is `null` when it is not a page id. */ + rawPageId: Schema.String, + pageId: Schema.NullOr(StatusPageId), + nowMs: Schema.NullOr(Schema.Number), + page: KeyedSchema(V2StatusPage, LoadFailure), + components: KeyedSchema(Schema.Array(V2StatusPageComponent), LoadFailure), + /** Open and recent incidents and maintenance, newest first. */ + incidents: KeyedSchema(Schema.Array(V2StatusPageIncident), LoadFailure), + /** The org's monitors: the component picker and each linked component's status. */ + monitors: KeyedSchema(Schema.Array(V2UptimeMonitor), LoadFailure), + isAdmin: Schema.Boolean, + isSessionLoaded: Schema.Boolean, + // Components + componentDialog: Dialog.Model, + componentForm: ComponentForm, + isSavingComponent: Schema.Boolean, + componentFailure: Schema.NullOr(LoadFailure), + confirmDeleteComponentId: Schema.NullOr(StatusPageComponentId), + deleteComponentDialog: Dialog.Model, + isDeletingComponent: Schema.Boolean, + isReordering: Schema.Boolean, + // Incidents + incidentDialog: Dialog.Model, + incidentForm: IncidentForm, + isCreatingIncident: Schema.Boolean, + incidentFailure: Schema.NullOr(LoadFailure), + updateDialog: Dialog.Model, + updateForm: Schema.NullOr(UpdateForm), + isPostingUpdate: Schema.Boolean, + updateFailure: Schema.NullOr(LoadFailure), + publishingIncidentIds: Schema.Array(StatusPageIncidentId), + // Settings + /** Edits over the saved page; `null` while the form shows the page as saved. */ + settingsEdits: Schema.NullOr(SettingsForm), + isSavingSettings: Schema.Boolean, + settingsFailure: Schema.NullOr(LoadFailure), + isPublishing: Schema.Boolean, + breakGlassDialog: Dialog.Model, + breakGlassAction: Schema.NullOr(BreakGlassAction), + isChangingBreakGlass: Schema.Boolean, + revealedToken: Schema.NullOr(RevealedToken), + tokenCopy: CopyButton.Model, + deletePageDialog: Dialog.Model, + isDeletingPage: Schema.Boolean, +}) +export type Model = typeof Model.Type diff --git a/apps/web/src/page/alerts/status-pages/editor/reads.ts b/apps/web/src/page/alerts/status-pages/editor/reads.ts new file mode 100644 index 0000000000..84420f3a88 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/editor/reads.ts @@ -0,0 +1,115 @@ +import { Option } from "effect" +import { AsyncData, Command as FoldkitCommand } from "foldkit" +import * as Keyed from "../../../../data/keyed" +import type { LoadFailure } from "../../../../data/load-failure" +import { requestKey } from "../../../../data/request-key" +import * as Dialog from "../../../../ui/dialog" +import { type PageContext, PageOutMessage, type PageReturn } from "../../../contract" +import { UPTIME_LIST_LIMIT } from "../../shared/uptime-commands" +import { FetchComponents, FetchEditorMonitors, FetchIncidents, FetchPage } from "./command" +import { Message } from "./message" +import type { Model } from "./model" + +// The editor's reads, the re-reads after a write, and the small helpers every update file shares. + +export type Return = PageReturn + +const pageKey = (context: PageContext, pageId: string) => + requestKey(context.orgId, "statusPages.retrieve", { pageId }) +const componentsKey = (context: PageContext, pageId: string) => + requestKey(context.orgId, "statusPages.listComponents", { pageId }) +const incidentsKey = (context: PageContext, pageId: string) => + requestKey(context.orgId, "statusPageIncidents.list", { pageId }) +const monitorsKey = (context: PageContext) => + requestKey(context.orgId, "uptimeMonitors.list", { query: { limit: UPTIME_LIST_LIMIT } }) + +/** Joins steps left to right: each one's Model feeds the next, Commands add up. */ +export const chain = (model: Model, ...steps: ReadonlyArray<(model: Model) => Return>): Return => + steps.reduce( + (previous, step) => { + const next = step(previous.model) + return { + model: next.model, + commands: [...(previous.commands ?? []), ...(next.commands ?? [])], + outMessage: next.outMessage ?? previous.outMessage, + } + }, + { model }, + ) + +export const readPage = + (context: PageContext, mode: Keyed.ReadMode) => + (model: Model): Return => { + const id = model.pageId + if (id === null) return { model } + return Option.match(Keyed.requestFor(model.page, pageKey(context, id), mode), { + onNone: () => ({ model }), + onSome: (page) => ({ + model: { ...model, page }, + commands: [FetchPage({ key: page.key, pageId: id })], + }), + }) + } + +export const readComponents = + (context: PageContext, mode: Keyed.ReadMode) => + (model: Model): Return => { + const id = model.pageId + if (id === null) return { model } + return Option.match(Keyed.requestFor(model.components, componentsKey(context, id), mode), { + onNone: () => ({ model }), + onSome: (components) => ({ + model: { ...model, components }, + commands: [FetchComponents({ key: components.key, pageId: id })], + }), + }) + } + +export const readIncidents = + (context: PageContext, mode: Keyed.ReadMode) => + (model: Model): Return => { + const id = model.pageId + if (id === null) return { model } + return Option.match(Keyed.requestFor(model.incidents, incidentsKey(context, id), mode), { + onNone: () => ({ model }), + onSome: (incidents) => ({ + model: { ...model, incidents }, + commands: [FetchIncidents({ key: incidents.key, pageId: id })], + }), + }) + } + +export const readMonitors = + (context: PageContext, mode: Keyed.ReadMode) => + (model: Model): Return => + model.pageId === null + ? { model } + : Option.match(Keyed.requestFor(model.monitors, monitorsKey(context), mode), { + onNone: () => ({ model }), + onSome: (monitors) => ({ + model: { ...model, monitors }, + commands: [FetchEditorMonitors({ key: monitors.key })], + }), + }) + +export const pageOf = (model: Model) => AsyncData.getData(model.page.data) +export const componentsOf = (model: Model) => AsyncData.getOrElse(model.components.data, () => []) +export const incidentsOf = (model: Model) => AsyncData.getOrElse(model.incidents.data, () => []) +export const monitorsOf = (model: Model) => AsyncData.getOrElse(model.monitors.data, () => []) + +export const toast = (type: "success" | "error", title: string, description = ""): PageOutMessage => + PageOutMessage.RequestedToast({ type, title, description }) + +export const errorToast = (title: string, failure: LoadFailure): PageOutMessage => + toast("error", title, failure.message) + +/** A dialog's step lifted into the page: its Model written back, its Commands wrapped. */ +export const liftDialog = ( + model: Model, + step: ReturnType, + write: (model: Model, dialog: Dialog.Model) => Model, + wrap: (message: Dialog.Message) => Message, +): Return => ({ + model: write(model, step.model), + commands: FoldkitCommand.mapMessages(step.commands ?? [], wrap), +}) diff --git a/apps/web/src/page/alerts/status-pages/editor/update-components.ts b/apps/web/src/page/alerts/status-pages/editor/update-components.ts new file mode 100644 index 0000000000..91652f03b7 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/editor/update-components.ts @@ -0,0 +1,184 @@ +import type { StatusPageComponentId } from "@maple/domain" +import type { V2StatusPageComponent } from "@maple/domain/http/v2" +import { Result } from "effect" +import { AsyncData } from "foldkit" +import type { LoadFailure } from "../../../../data/load-failure" +import * as Dialog from "../../../../ui/dialog" +import type { PageContext } from "../../../contract" +import { DeleteComponent, ReorderComponents, SaveComponent } from "./command" +import { type ComponentTextField, componentFormOf, componentPayload, emptyComponentForm } from "./form" +import { Message } from "./message" +import type { Model } from "./model" +import { + chain, + componentsOf, + errorToast, + liftDialog, + readComponents, + readPage, + type Return, + toast, +} from "./reads" + +// The Components tab: add and edit in a dialog, delete behind a confirm, move up and down. + +const componentDialog = (model: Model, step: ReturnType): Return => + liftDialog( + model, + step, + (next, componentDialog) => ({ ...next, componentDialog }), + (message) => Message.GotComponentDialogMessage({ message }), + ) + +export const deleteComponentDialog = (model: Model, step: ReturnType): Return => { + const lifted = liftDialog( + model, + step, + (next, deleteComponentDialog) => ({ ...next, deleteComponentDialog }), + (message) => Message.GotDeleteComponentDialogMessage({ message }), + ) + return step.outMessage?._tag === "Closed" + ? { ...lifted, model: { ...lifted.model, confirmDeleteComponentId: null } } + : lifted +} + +export const openComponent = (model: Model, componentId: StatusPageComponentId | null): Return => { + const existing = componentsOf(model).find((component) => component.id === componentId) + const form = existing === undefined ? emptyComponentForm : componentFormOf(existing) + return componentDialog( + { ...model, componentForm: form, componentFailure: null }, + Dialog.open(model.componentDialog), + ) +} + +export const changeComponent = (model: Model, field: ComponentTextField, value: string): Return => ({ + model: { ...model, componentForm: { ...model.componentForm, [field]: value } }, +}) + +export const submitComponent = (model: Model): Return => + model.pageId === null || model.isSavingComponent || model.componentForm.name.trim() === "" + ? { model } + : { + model: { ...model, isSavingComponent: true, componentFailure: null }, + commands: [ + SaveComponent({ + pageId: model.pageId, + componentId: model.componentForm.editingId, + payload: componentPayload(model.componentForm), + }), + ], + } + +/** Components and the page revision change together: both are read again. */ +const reread = (model: Model, context: PageContext): Return => + chain(model, readComponents(context, "Supersede"), readPage(context, "Supersede")) + +export const savedComponent = ( + model: Model, + result: Result.Result, + context: PageContext, +): Return => { + const settled = { ...model, isSavingComponent: false } + return Result.match(result, { + onSuccess: (component): Return => { + const closed = componentDialog(settled, Dialog.close(settled.componentDialog)) + const refreshed = reread(closed.model, context) + return { + model: refreshed.model, + commands: [...(closed.commands ?? []), ...(refreshed.commands ?? [])], + outMessage: toast( + "success", + model.componentForm.editingId === null ? "Component added" : "Component saved", + component.name, + ), + } + }, + onFailure: (componentFailure): Return => ({ model: { ...settled, componentFailure } }), + }) +} + +/** The list with `componentId` moved by `offset`, or `null` when it cannot move that way. */ +export const moved = ( + components: ReadonlyArray, + componentId: StatusPageComponentId, + offset: number, +): ReadonlyArray | null => { + const from = components.findIndex((component) => component.id === componentId) + const to = from + offset + const item = components[from] + if (from < 0 || to < 0 || to >= components.length || item === undefined) return null + const rest = components.filter((_, index) => index !== from) + return [...rest.slice(0, to), item, ...rest.slice(to)] +} + +/** Moves the row at once; the server's order lands with the re-read. */ +export const moveComponent = (model: Model, componentId: StatusPageComponentId, offset: number): Return => { + const next = moved(componentsOf(model), componentId, offset) + if (model.pageId === null || model.isReordering || next === null) return { model } + return { + model: { + ...model, + isReordering: true, + components: { ...model.components, data: AsyncData.map(model.components.data, () => next) }, + }, + commands: [ + ReorderComponents({ pageId: model.pageId, componentIds: next.map((component) => component.id) }), + ], + } +} + +export const reordered = ( + model: Model, + result: Result.Result, LoadFailure>, + context: PageContext, +): Return => { + const refreshed = reread({ ...model, isReordering: false }, context) + return Result.match(result, { + onSuccess: (): Return => refreshed, + onFailure: (failure): Return => ({ + ...refreshed, + outMessage: errorToast("Couldn't reorder the components", failure), + }), + }) +} + +export const askDeleteComponent = (model: Model, componentId: StatusPageComponentId): Return => + deleteComponentDialog( + { ...model, confirmDeleteComponentId: componentId }, + Dialog.open(model.deleteComponentDialog), + ) + +export const confirmDeleteComponent = (model: Model): Return => { + const id = model.confirmDeleteComponentId + return model.pageId === null || id === null || model.isDeletingComponent + ? { model } + : { + model: { ...model, isDeletingComponent: true }, + commands: [DeleteComponent({ pageId: model.pageId, componentId: id })], + } +} + +export const deletedComponent = ( + model: Model, + result: Result.Result, + context: PageContext, +): Return => { + const settled = { ...model, isDeletingComponent: false } + return Result.match(result, { + onSuccess: (): Return => { + const closed = deleteComponentDialog(settled, Dialog.close(settled.deleteComponentDialog)) + const refreshed = reread(closed.model, context) + return { + model: refreshed.model, + commands: [...(closed.commands ?? []), ...(refreshed.commands ?? [])], + outMessage: toast("success", "Component removed"), + } + }, + onFailure: (failure): Return => ({ + model: settled, + outMessage: errorToast("Couldn't remove the component", failure), + }), + }) +} + +export { componentDialog } diff --git a/apps/web/src/page/alerts/status-pages/editor/update-incidents.ts b/apps/web/src/page/alerts/status-pages/editor/update-incidents.ts new file mode 100644 index 0000000000..b7d5c5bed4 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/editor/update-incidents.ts @@ -0,0 +1,227 @@ +import type { StatusPageComponentId, StatusPageIncidentId, StatusPageIncidentKind } from "@maple/domain" +import { StatusPageIncidentImpact, StatusPageIncidentStatus } from "@maple/domain" +import type { V2StatusPageIncident } from "@maple/domain/http/v2" +import { Option, Result, Schema } from "effect" +import type { LoadFailure } from "../../../../data/load-failure" +import * as Dialog from "../../../../ui/dialog" +import type { PageContext } from "../../../contract" +import { isOpenIncident, STATUSES_OF_KIND } from "../../shared/status-page-derive" +import { CreateIncident, PostIncidentUpdate, PublishIncident } from "./command" +import { + emptyIncidentForm, + type IncidentTextField, + incidentIssues, + incidentPayload, + updateFormFor, +} from "./form" +import { Message } from "./message" +import type { Model } from "./model" +import { + chain, + errorToast, + incidentsOf, + liftDialog, + readIncidents, + readPage, + type Return, + toast, +} from "./reads" + +// The Incidents tab: the composer, posting an update or resolving, publishing a draft. + +const decodeImpact = Schema.decodeUnknownOption(StatusPageIncidentImpact) +const decodeStatus = Schema.decodeUnknownOption(StatusPageIncidentStatus) + +export const incidentDialog = (model: Model, step: ReturnType): Return => + liftDialog( + model, + step, + (next, incidentDialog) => ({ ...next, incidentDialog }), + (message) => Message.GotIncidentDialogMessage({ message }), + ) + +export const updateDialog = (model: Model, step: ReturnType): Return => + liftDialog( + model, + step, + (next, updateDialog) => ({ ...next, updateDialog }), + (message) => Message.GotUpdateDialogMessage({ message }), + ) + +/** Incidents and the page revision change together: both are read again. */ +const reread = (model: Model, context: PageContext): Return => + chain(model, readIncidents(context, "Supersede"), readPage(context, "Supersede")) + +export const openComposer = (model: Model, kind: StatusPageIncidentKind): Return => + incidentDialog( + { ...model, incidentForm: emptyIncidentForm(kind), incidentFailure: null }, + Dialog.open(model.incidentDialog), + ) + +/** Impact and status arrive as select values; anything else is not one of theirs and is dropped. */ +export const changeIncident = (model: Model, field: IncidentTextField, value: string): Return => { + const form = model.incidentForm + switch (field) { + case "impact": + return Option.match(decodeImpact(value), { + onNone: () => ({ model }), + onSome: (impact) => ({ model: { ...model, incidentForm: { ...form, impact } } }), + }) + case "status": + return Option.match(decodeStatus(value), { + onNone: () => ({ model }), + onSome: (status) => ({ model: { ...model, incidentForm: { ...form, status } } }), + }) + default: + return { model: { ...model, incidentForm: { ...form, [field]: value } } } + } +} + +export const toggleIncidentComponent = (model: Model, componentId: StatusPageComponentId): Return => { + const ids = model.incidentForm.componentIds + return { + model: { + ...model, + incidentForm: { + ...model.incidentForm, + componentIds: ids.includes(componentId) + ? ids.filter((id) => id !== componentId) + : [...ids, componentId], + }, + }, + } +} + +export const submitIncident = (model: Model, context: PageContext): Return => + model.pageId === null || + model.isCreatingIncident || + incidentIssues(model.incidentForm, context.timeZone).length > 0 + ? { model } + : { + model: { ...model, isCreatingIncident: true, incidentFailure: null }, + commands: [ + CreateIncident({ + payload: incidentPayload(model.incidentForm, model.pageId, context.timeZone), + }), + ], + } + +export const createdIncident = ( + model: Model, + result: Result.Result, + context: PageContext, +): Return => { + const settled = { ...model, isCreatingIncident: false } + return Result.match(result, { + onSuccess: (incident): Return => { + const closed = incidentDialog(settled, Dialog.close(settled.incidentDialog)) + const refreshed = reread(closed.model, context) + const noun = incident.kind === "incident" ? "Incident" : "Maintenance" + return { + model: refreshed.model, + commands: [...(closed.commands ?? []), ...(refreshed.commands ?? [])], + outMessage: toast( + "success", + incident.published ? `${noun} published` : `${noun} saved as a draft`, + incident.title, + ), + } + }, + onFailure: (incidentFailure): Return => ({ model: { ...settled, incidentFailure } }), + }) +} + +export const openUpdate = (model: Model, incidentId: StatusPageIncidentId, isResolving: boolean): Return => { + const incident = incidentsOf(model).find((row) => row.id === incidentId) + return incident === undefined || !isOpenIncident(incident) + ? { model } + : updateDialog( + { ...model, updateForm: updateFormFor(incident, isResolving), updateFailure: null }, + Dialog.open(model.updateDialog), + ) +} + +export const changeUpdate = (model: Model, field: "status" | "body", value: string): Return => { + const form = model.updateForm + if (form === null) return { model } + if (field === "body") return { model: { ...model, updateForm: { ...form, body: value } } } + const allowed: ReadonlyArray = STATUSES_OF_KIND[form.kind] + return Option.match(decodeStatus(value), { + onNone: () => ({ model }), + onSome: (status) => + allowed.includes(status) ? { model: { ...model, updateForm: { ...form, status } } } : { model }, + }) +} + +export const submitUpdate = (model: Model): Return => { + const form = model.updateForm + return form === null || model.isPostingUpdate || form.body.trim() === "" + ? { model } + : { + model: { ...model, isPostingUpdate: true, updateFailure: null }, + commands: [ + PostIncidentUpdate({ + incidentId: form.incidentId, + payload: { status: form.status, body: form.body.trim() }, + }), + ], + } +} + +export const postedUpdate = ( + model: Model, + result: Result.Result, + context: PageContext, +): Return => { + const settled = { ...model, isPostingUpdate: false } + return Result.match(result, { + onSuccess: (incident): Return => { + const closed = updateDialog(settled, Dialog.close(settled.updateDialog)) + const refreshed = reread(closed.model, context) + return { + model: refreshed.model, + commands: [...(closed.commands ?? []), ...(refreshed.commands ?? [])], + outMessage: toast( + "success", + isOpenIncident(incident) + ? "Update posted" + : incident.kind === "incident" + ? "Incident resolved" + : "Maintenance completed", + incident.title, + ), + } + }, + onFailure: (updateFailure): Return => ({ model: { ...settled, updateFailure } }), + }) +} + +export const publishIncident = (model: Model, incidentId: StatusPageIncidentId): Return => + model.publishingIncidentIds.includes(incidentId) + ? { model } + : { + model: { ...model, publishingIncidentIds: [...model.publishingIncidentIds, incidentId] }, + commands: [PublishIncident({ incidentId })], + } + +export const publishedIncident = ( + model: Model, + incidentId: StatusPageIncidentId, + result: Result.Result, + context: PageContext, +): Return => { + const settled = { + ...model, + publishingIncidentIds: model.publishingIncidentIds.filter((id) => id !== incidentId), + } + return Result.match(result, { + onSuccess: (incident): Return => ({ + ...reread(settled, context), + outMessage: toast("success", "Draft published", incident.title), + }), + onFailure: (failure): Return => ({ + model: settled, + outMessage: errorToast("Couldn't publish the draft", failure), + }), + }) +} diff --git a/apps/web/src/page/alerts/status-pages/editor/update-settings.ts b/apps/web/src/page/alerts/status-pages/editor/update-settings.ts new file mode 100644 index 0000000000..3b87011835 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/editor/update-settings.ts @@ -0,0 +1,258 @@ +import { StatusPageAutoIncidents } from "@maple/domain" +import type { + V2StatusPage, + V2StatusPageBreakGlassToken, + V2StatusPagePublishResult, +} from "@maple/domain/http/v2" +import { Option, Result, Schema } from "effect" +import { AsyncData, Command as FoldkitCommand } from "foldkit" +import type { LoadFailure } from "../../../../data/load-failure" +import * as CopyButton from "../../../../ui/copy-button" +import * as Dialog from "../../../../ui/dialog" +import { type PageContext, PageOutMessage } from "../../../contract" +import { statusPagesTabHref } from "../../shared/status-page-commands" +import { DeletePage, PublishPage, RevokeToken, RotateToken, SaveSettings } from "./command" +import { + hasSettingsIssues, + isSameSettings, + type SettingsForm, + type SettingsTextField, + settingsFormOf, + settingsPayload, +} from "./form" +import { Message } from "./message" +import type { BreakGlassAction, Model } from "./model" +import { errorToast, liftDialog, pageOf, readPage, type Return, toast } from "./reads" + +// The Settings tab: the page's fields behind a save bar, Publish now, the break-glass token, delete. + +const decodeAutoIncidents = Schema.decodeUnknownOption(StatusPageAutoIncidents) + +/** What the form shows: the edits, else the saved page. */ +export const settingsOf = (model: Model): Option.Option => + model.settingsEdits === null + ? Option.map(pageOf(model), settingsFormOf) + : Option.some(model.settingsEdits) + +export const isSettingsDirty = (model: Model): boolean => + model.settingsEdits !== null && + Option.match(pageOf(model), { + onNone: () => false, + onSome: (page) => + model.settingsEdits !== null && !isSameSettings(model.settingsEdits, settingsFormOf(page)), + }) + +const withSettings = (model: Model, change: (form: SettingsForm) => SettingsForm): Return => + Option.match(settingsOf(model), { + onNone: () => ({ model }), + onSome: (form) => ({ model: { ...model, settingsEdits: change(form) } }), + }) + +export const changeSetting = (model: Model, field: SettingsTextField, value: string): Return => + field === "autoIncidents" + ? Option.match(decodeAutoIncidents(value), { + onNone: () => ({ model }), + onSome: (autoIncidents) => withSettings(model, (form) => ({ ...form, autoIncidents })), + }) + : withSettings(model, (form) => ({ + ...form, + [field]: field === "slug" ? value.trim().toLowerCase() : value, + })) + +export const togglePoweredBy = (model: Model): Return => + withSettings(model, (form) => ({ ...form, showPoweredBy: !form.showPoweredBy })) + +export const saveSettings = (model: Model): Return => { + const edits = model.settingsEdits + return model.pageId === null || + edits === null || + model.isSavingSettings || + !model.isAdmin || + hasSettingsIssues(edits) + ? { model } + : { + model: { ...model, isSavingSettings: true, settingsFailure: null }, + commands: [SaveSettings({ pageId: model.pageId, payload: settingsPayload(edits) })], + } +} + +/** The saved page replaces the read; edits made while it was in flight stay. */ +const withPage = (model: Model, page: V2StatusPage): Model => ({ + ...model, + page: { ...model.page, data: AsyncData.map(model.page.data, () => page) }, +}) + +export const savedSettings = (model: Model, result: Result.Result): Return => + Result.match(result, { + onSuccess: (page): Return => { + const next = withPage({ ...model, isSavingSettings: false }, page) + const isCaughtUp = + next.settingsEdits !== null && isSameSettings(next.settingsEdits, settingsFormOf(page)) + return { + model: isCaughtUp ? { ...next, settingsEdits: null } : next, + outMessage: toast("success", "Settings saved"), + } + }, + onFailure: (settingsFailure): Return => ({ + model: { ...model, isSavingSettings: false, settingsFailure }, + }), + }) + +export const publish = (model: Model): Return => + model.pageId === null || model.isPublishing || !model.isAdmin + ? { model } + : { model: { ...model, isPublishing: true }, commands: [PublishPage({ pageId: model.pageId })] } + +const PUBLISH_COPY = { + published: "Published", + unchanged: "Already up to date", + queued: "Publish queued", +} as const satisfies Record + +export const published = ( + model: Model, + result: Result.Result, + context: PageContext, +): Return => { + const settled = { ...model, isPublishing: false } + return Result.match(result, { + onSuccess: (outcome): Return => ({ + ...readPage(context, "Supersede")(settled), + outMessage: toast( + "success", + PUBLISH_COPY[outcome.outcome], + outcome.outcome === "queued" ? "It goes out with the next minutely publish." : "", + ), + }), + onFailure: (failure): Return => ({ + model: settled, + outMessage: errorToast("Couldn't publish", failure), + }), + }) +} + +// BREAK-GLASS + +export const breakGlassDialog = (model: Model, step: ReturnType): Return => { + const lifted = liftDialog( + model, + step, + (next, breakGlassDialog) => ({ ...next, breakGlassDialog }), + (message) => Message.GotBreakGlassDialogMessage({ message }), + ) + return step.outMessage?._tag === "Closed" + ? { ...lifted, model: { ...lifted.model, breakGlassAction: null } } + : lifted +} + +/** The first token is issued at once; replacing or revoking one asks first. */ +export const askBreakGlass = (model: Model, action: BreakGlassAction): Return => { + const hasToken = Option.match(pageOf(model), { + onNone: () => false, + onSome: (page) => page.break_glass_token_suffix !== null, + }) + if (action === "rotate" && !hasToken) return runBreakGlass({ ...model, breakGlassAction: "rotate" }) + return breakGlassDialog({ ...model, breakGlassAction: action }, Dialog.open(model.breakGlassDialog)) +} + +export const runBreakGlass = (model: Model): Return => { + const action = model.breakGlassAction + return model.pageId === null || action === null || model.isChangingBreakGlass || !model.isAdmin + ? { model } + : { + model: { ...model, isChangingBreakGlass: true }, + commands: [ + action === "rotate" + ? RotateToken({ pageId: model.pageId }) + : RevokeToken({ pageId: model.pageId }), + ], + } +} + +const closeBreakGlass = (model: Model): Return => + model.breakGlassDialog.isOpen + ? breakGlassDialog(model, Dialog.close(model.breakGlassDialog)) + : { model: { ...model, breakGlassAction: null } } + +export const rotated = ( + model: Model, + result: Result.Result, + context: PageContext, +): Return => { + const settled = { ...model, isChangingBreakGlass: false } + return Result.match(result, { + onSuccess: (token): Return => { + const closed = closeBreakGlass({ + ...settled, + revealedToken: { token: token.token, suffix: token.token_suffix }, + tokenCopy: CopyButton.init(), + }) + const refreshed = readPage(context, "Supersede")(closed.model) + return { + model: refreshed.model, + commands: [...(closed.commands ?? []), ...(refreshed.commands ?? [])], + } + }, + onFailure: (failure): Return => ({ + model: settled, + outMessage: errorToast("Couldn't issue a token", failure), + }), + }) +} + +export const revoked = (model: Model, result: Result.Result): Return => { + const settled = { ...model, isChangingBreakGlass: false } + return Result.match(result, { + onSuccess: (page): Return => ({ + ...closeBreakGlass(withPage({ ...settled, revealedToken: null }, page)), + outMessage: toast("success", "Break-glass token revoked"), + }), + onFailure: (failure): Return => ({ + model: settled, + outMessage: errorToast("Couldn't revoke the token", failure), + }), + }) +} + +export const updateTokenCopy = (model: Model, message: CopyButton.Message): Return => { + const step = CopyButton.update(model.tokenCopy, message) + return { + model: { ...model, tokenCopy: step.model }, + commands: FoldkitCommand.mapMessages(step.commands ?? [], (child) => + Message.GotTokenCopyMessage({ message: child }), + ), + } +} + +// DELETE + +export const deletePageDialog = (model: Model, step: ReturnType): Return => + liftDialog( + model, + step, + (next, deletePageDialog) => ({ ...next, deletePageDialog }), + (message) => Message.GotDeletePageDialogMessage({ message }), + ) + +export const confirmDeletePage = (model: Model): Return => + model.pageId === null || model.isDeletingPage || !model.isAdmin + ? { model } + : { model: { ...model, isDeletingPage: true }, commands: [DeletePage({ pageId: model.pageId })] } + +export const deletedPage = (model: Model, result: Result.Result): Return => { + const settled = { ...model, isDeletingPage: false } + return Result.match(result, { + onSuccess: (): Return => ({ + ...deletePageDialog(settled, Dialog.close(settled.deletePageDialog)), + outMessage: PageOutMessage.RequestedNavigation({ + url: statusPagesTabHref(), + isReplace: true, + toast: { type: "success", title: "Status page deleted", description: "" }, + }), + }), + onFailure: (failure): Return => ({ + model: settled, + outMessage: errorToast("Couldn't delete the status page", failure), + }), + }) +} diff --git a/apps/web/src/page/alerts/status-pages/editor/update.ts b/apps/web/src/page/alerts/status-pages/editor/update.ts new file mode 100644 index 0000000000..9bde532173 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/editor/update.ts @@ -0,0 +1,232 @@ +import { Option } from "effect" +import * as Keyed from "../../../../data/keyed" +import type { AppRoute } from "../../../../route" +import * as CopyButton from "../../../../ui/copy-button" +import * as Dialog from "../../../../ui/dialog" +import type { PageContext } from "../../../contract" +import { type EditorTab, pageIdFromUrl } from "../../shared/status-page-commands" +import { FetchEditorSession, ReadEditorClock } from "./command" +import { emptyComponentForm, emptyIncidentForm } from "./form" +import { Message } from "./message" +import type { Model } from "./model" +import { chain, readComponents, readIncidents, readMonitors, readPage, type Return } from "./reads" +import { + askDeleteComponent, + changeComponent, + componentDialog, + confirmDeleteComponent, + deleteComponentDialog, + deletedComponent, + moveComponent, + openComponent, + reordered, + savedComponent, + submitComponent, +} from "./update-components" +import { + changeIncident, + changeUpdate, + createdIncident, + incidentDialog, + openComposer, + openUpdate, + postedUpdate, + publishedIncident, + publishIncident, + submitIncident, + submitUpdate, + toggleIncidentComponent, + updateDialog, +} from "./update-incidents" +import { + askBreakGlass, + breakGlassDialog, + changeSetting, + confirmDeletePage, + deletedPage, + deletePageDialog, + published, + publish, + revoked, + rotated, + runBreakGlass, + savedSettings, + saveSettings, + togglePoweredBy, + updateTokenCopy, +} from "./update-settings" + +type EditorRoute = Extract + +/** Unknown `tab` values open Components, so an old or mistyped link still lands. */ +export const resolveTab = (tab: string | undefined): EditorTab => + tab === "incidents" || tab === "settings" ? tab : "components" + +export const init = (route: EditorRoute, context: PageContext): Return => { + const model: Model = { + search: route.search, + rawPageId: route.pageId, + pageId: Option.getOrNull(pageIdFromUrl(route.pageId)), + nowMs: null, + page: Keyed.idle(), + components: Keyed.idle(), + incidents: Keyed.idle(), + monitors: Keyed.idle(), + isAdmin: false, + isSessionLoaded: false, + componentDialog: Dialog.init("status-page-component", "dialog"), + componentForm: emptyComponentForm, + isSavingComponent: false, + componentFailure: null, + confirmDeleteComponentId: null, + deleteComponentDialog: Dialog.init("status-page-delete-component", "alert-dialog"), + isDeletingComponent: false, + isReordering: false, + incidentDialog: Dialog.init("status-page-incident", "dialog"), + incidentForm: emptyIncidentForm("incident"), + isCreatingIncident: false, + incidentFailure: null, + updateDialog: Dialog.init("status-page-update", "dialog"), + updateForm: null, + isPostingUpdate: false, + updateFailure: null, + publishingIncidentIds: [], + settingsEdits: null, + isSavingSettings: false, + settingsFailure: null, + isPublishing: false, + breakGlassDialog: Dialog.init("status-page-break-glass", "alert-dialog"), + breakGlassAction: null, + isChangingBreakGlass: false, + revealedToken: null, + tokenCopy: CopyButton.init(), + deletePageDialog: Dialog.init("status-page-delete", "alert-dialog"), + isDeletingPage: false, + } + const reads = chain( + model, + readPage(context, "Load"), + readComponents(context, "Load"), + readIncidents(context, "Load"), + readMonitors(context, "Load"), + ) + return { + model: reads.model, + commands: [ + ReadEditorClock(), + FetchEditorSession({ orgId: context.orgId }), + ...(reads.commands ?? []), + ], + } +} + +/** A tab switch is a search change: the reads are already in the Model. */ +export const routeChanged = (model: Model, route: EditorRoute): Return => ({ + model: { ...model, search: route.search }, +}) + +/** The dialogs ignore dismissal while their write is in flight, as `ConfirmDialog` does. */ +const whenIdle = (isBusy: boolean, model: Model, run: () => Return): Return => (isBusy ? { model } : run()) + +export const update = (model: Model, message: Message, context: PageContext): Return => + Message.match(message, { + ReadClock: ({ nowMs }) => ({ model: { ...model, nowMs } }), + CompletedFetchPage: ({ key, result }) => ({ + model: { ...model, page: Keyed.accept(model.page, key, result) }, + }), + CompletedFetchComponents: ({ key, result }) => ({ + model: { ...model, components: Keyed.accept(model.components, key, result) }, + }), + CompletedFetchIncidents: ({ key, result }) => ({ + model: { ...model, incidents: Keyed.accept(model.incidents, key, result) }, + }), + CompletedFetchMonitors: ({ key, result }) => ({ + model: { ...model, monitors: Keyed.accept(model.monitors, key, result) }, + }), + CompletedFetchSession: ({ orgId, isAdmin }) => + orgId === context.orgId ? { model: { ...model, isAdmin, isSessionLoaded: true } } : { model }, + ClickedRetry: () => + chain( + model, + readPage(context, "Supersede"), + readComponents(context, "Supersede"), + readIncidents(context, "Supersede"), + readMonitors(context, "Supersede"), + ), + // Components + ClickedAddComponent: () => openComponent(model, null), + ClickedEditComponent: ({ componentId }) => openComponent(model, componentId), + ChangedComponentField: ({ field, value }) => changeComponent(model, field, value), + ToggledComponentUptime: () => ({ + model: { + ...model, + componentForm: { ...model.componentForm, showUptime: !model.componentForm.showUptime }, + }, + }), + SubmittedComponent: () => submitComponent(model), + CompletedSaveComponent: ({ result }) => savedComponent(model, result, context), + GotComponentDialogMessage: ({ message: child }) => + whenIdle(model.isSavingComponent, model, () => + componentDialog(model, Dialog.update(model.componentDialog, child)), + ), + ClickedMoveComponent: ({ componentId, offset }) => moveComponent(model, componentId, offset), + CompletedReorder: ({ result }) => reordered(model, result, context), + ClickedDeleteComponent: ({ componentId }) => askDeleteComponent(model, componentId), + ClickedConfirmDeleteComponent: () => confirmDeleteComponent(model), + CompletedDeleteComponent: ({ result }) => deletedComponent(model, result, context), + GotDeleteComponentDialogMessage: ({ message: child }) => + whenIdle(model.isDeletingComponent, model, () => + deleteComponentDialog(model, Dialog.update(model.deleteComponentDialog, child)), + ), + // Incidents + ClickedNewIncident: ({ kind }) => openComposer(model, kind), + ChangedIncidentField: ({ field, value }) => changeIncident(model, field, value), + ToggledIncidentComponent: ({ componentId }) => toggleIncidentComponent(model, componentId), + PickedIncidentPublished: ({ isPublished }) => ({ + model: { ...model, incidentForm: { ...model.incidentForm, isPublished } }, + }), + SubmittedIncident: () => submitIncident(model, context), + CompletedCreateIncident: ({ result }) => createdIncident(model, result, context), + GotIncidentDialogMessage: ({ message: child }) => + whenIdle(model.isCreatingIncident, model, () => + incidentDialog(model, Dialog.update(model.incidentDialog, child)), + ), + ClickedPostUpdate: ({ incidentId, isResolving }) => openUpdate(model, incidentId, isResolving), + ChangedUpdateField: ({ field, value }) => changeUpdate(model, field, value), + SubmittedUpdate: () => submitUpdate(model), + CompletedPostUpdate: ({ result }) => postedUpdate(model, result, context), + GotUpdateDialogMessage: ({ message: child }) => + whenIdle(model.isPostingUpdate, model, () => + updateDialog(model, Dialog.update(model.updateDialog, child)), + ), + ClickedPublishIncident: ({ incidentId }) => publishIncident(model, incidentId), + CompletedPublishIncident: ({ incidentId, result }) => + publishedIncident(model, incidentId, result, context), + // Settings + ChangedSetting: ({ field, value }) => changeSetting(model, field, value), + ToggledPoweredBy: () => togglePoweredBy(model), + ClickedDiscardSettings: () => ({ model: { ...model, settingsEdits: null, settingsFailure: null } }), + ClickedSaveSettings: () => saveSettings(model), + CompletedSaveSettings: ({ result }) => savedSettings(model, result), + ClickedPublish: () => publish(model), + CompletedPublish: ({ result }) => published(model, result, context), + ClickedRotateToken: () => askBreakGlass(model, "rotate"), + ClickedRevokeToken: () => askBreakGlass(model, "revoke"), + ClickedConfirmBreakGlass: () => runBreakGlass(model), + CompletedRotateToken: ({ result }) => rotated(model, result, context), + CompletedRevokeToken: ({ result }) => revoked(model, result), + GotBreakGlassDialogMessage: ({ message: child }) => + whenIdle(model.isChangingBreakGlass, model, () => + breakGlassDialog(model, Dialog.update(model.breakGlassDialog, child)), + ), + GotTokenCopyMessage: ({ message: child }) => updateTokenCopy(model, child), + ClickedDismissToken: () => ({ model: { ...model, revealedToken: null } }), + // Page + ClickedDeletePage: () => deletePageDialog(model, Dialog.open(model.deletePageDialog)), + ClickedConfirmDeletePage: () => confirmDeletePage(model), + CompletedDeletePage: ({ result }) => deletedPage(model, result), + GotDeletePageDialogMessage: ({ message: child }) => + whenIdle(model.isDeletingPage, model, () => + deletePageDialog(model, Dialog.update(model.deletePageDialog, child)), + ), + }) diff --git a/apps/web/src/page/alerts/status-pages/editor/view-components.ts b/apps/web/src/page/alerts/status-pages/editor/view-components.ts new file mode 100644 index 0000000000..add5ac37dd --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/editor/view-components.ts @@ -0,0 +1,316 @@ +import { StatusPageComponentStatus } from "@maple/domain" +import type { V2StatusPageComponent, V2UptimeMonitor } from "@maple/domain/http/v2" +import { TONE_FILL } from "@maple/ui/lib/tone" +import { cn } from "@maple/ui/lib/utils" +import type { Html, HtmlBuilder } from "foldkit/html" +import { alert, alertDescription, alertTitle } from "../../../../ui/alert" +import { button } from "../../../../ui/button" +import * as Checkbox from "../../../../ui/checkbox" +import { confirmDialog } from "../../../../ui/confirm-dialog" +import { badge } from "../../../../ui/display" +import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "../../../../ui/empty" +import { formDialog } from "../../../../ui/form-dialog" +import { icon } from "../../../../ui/icon" +import { iconButton } from "../../../../ui/icon-button" +import { failureMessage } from "../../../../data/load-failure" +import { sectionHeading } from "../../../kit/section-heading" +import { emptyActions } from "../../../kit/signal-empty-state" +import { + COMPONENT_STATUS_LABEL, + COMPONENT_STATUS_TONE, + componentStatus, +} from "../../shared/status-page-derive" +import { selectField, textareaField, textField } from "../fields" +import { Message } from "./message" +import type { Model } from "./model" +import { componentsOf, incidentsOf, monitorsOf } from "./reads" + +// The Components tab: each line of the page in order, and the add or edit dialog. + +type H = HtmlBuilder + +const statusChip = (h: H, status: StatusPageComponentStatus): Html => + h.span( + [h.Class("flex items-center gap-1.5 text-xs text-muted-foreground")], + [ + h.span( + [ + h.AriaHidden(true), + h.Class( + cn( + "inline-block size-2 shrink-0 rounded-[2px]", + TONE_FILL[COMPONENT_STATUS_TONE[status]], + ), + ), + ], + [], + ), + COMPONENT_STATUS_LABEL[status], + ], + ) + +/** What drives the line: its monitor, the override, or nothing but incidents. */ +const sourceLine = (component: V2StatusPageComponent, monitors: ReadonlyArray): string => { + const monitor = monitors.find((row) => row.id === component.monitor_id) + const parts = [ + component.monitor_id === null ? "No monitor" : `Monitor ${monitor?.name ?? "not found"}`, + ...(component.manual_status === null + ? [] + : [`set to ${COMPONENT_STATUS_LABEL[component.manual_status].toLowerCase()}`]), + ...(component.show_uptime && component.monitor_id !== null ? ["uptime bars shown"] : []), + ] + return parts.join(" · ") +} + +const rowActions = ( + h: H, + model: Model, + component: V2StatusPageComponent, + index: number, + count: number, +): Html => + h.div( + [h.Class("flex shrink-0 items-center gap-0.5")], + [ + iconButton( + h, + { + label: `Move ${component.name} up`, + isDisabled: index === 0 || model.isReordering, + attributes: [ + h.OnClick(Message.ClickedMoveComponent({ componentId: component.id, offset: -1 })), + ], + }, + [icon(h, "ArrowUpIcon")], + ), + iconButton( + h, + { + label: `Move ${component.name} down`, + isDisabled: index === count - 1 || model.isReordering, + attributes: [ + h.OnClick(Message.ClickedMoveComponent({ componentId: component.id, offset: 1 })), + ], + }, + [icon(h, "ArrowDownIcon")], + ), + iconButton( + h, + { + label: `Edit ${component.name}`, + attributes: [h.OnClick(Message.ClickedEditComponent({ componentId: component.id }))], + }, + [icon(h, "PencilIcon")], + ), + iconButton( + h, + { + label: `Remove ${component.name}`, + attributes: [h.OnClick(Message.ClickedDeleteComponent({ componentId: component.id }))], + }, + [icon(h, "TrashIcon")], + ), + ], + ) + +const componentRow = ( + h: H, + model: Model, + component: V2StatusPageComponent, + index: number, + count: number, +): Html => { + const monitors = monitorsOf(model) + return h.li( + [h.Key(component.id), h.Class("flex items-center gap-4 py-3 pr-2 pl-4")], + [ + h.div( + [h.Class("min-w-0 flex-1")], + [ + h.div( + [h.Class("flex min-w-0 items-center gap-2")], + [ + h.span([h.Class("truncate text-sm font-medium")], [component.name]), + ...(component.group === null + ? [] + : [badge(h, { variant: "secondary" }, [component.group])]), + ], + ), + h.p( + [h.Class("truncate text-xs text-muted-foreground")], + [sourceLine(component, monitors)], + ), + ], + ), + statusChip(h, componentStatus(component, monitors, incidentsOf(model))), + ...(model.isAdmin ? [rowActions(h, model, component, index, count)] : []), + ], + ) +} + +const emptyComponents = (h: H, model: Model): Html => + empty( + h, + [ + emptyHeader(h, [ + emptyMedia(h, { variant: "icon" }, [icon(h, "LayersIcon", { size: 18 })]), + emptyTitle(h, ["No components yet"]), + emptyDescription(h, [ + "Components are the lines visitors see, like API or Dashboard. Link one to an uptime monitor and it goes down when the monitor does.", + ]), + ]), + ...(model.isAdmin + ? [ + emptyActions(h, [ + button( + h, + { size: "sm", attributes: [h.OnClick(Message.ClickedAddComponent())] }, + [icon(h, "PlusIcon"), "Add component"], + ), + ]), + ] + : []), + ], + "rounded-md border py-12", + ) + +export const componentsTab = (h: H, model: Model): Html => { + const components = componentsOf(model) + return h.section( + [h.AriaLabelledBy("status-page-components"), h.Class("flex flex-col gap-3")], + [ + sectionHeading(h, { + id: "status-page-components", + title: "Components", + count: components.length === 0 ? undefined : String(components.length), + actions: + model.isAdmin && components.length > 0 + ? button( + h, + { + size: "sm", + variant: "outline", + attributes: [h.OnClick(Message.ClickedAddComponent())], + }, + [icon(h, "PlusIcon"), "Add component"], + ) + : undefined, + }), + components.length === 0 + ? emptyComponents(h, model) + : h.ul( + [h.Class("divide-y rounded-md border")], + components.map((component, index) => + componentRow(h, model, component, index, components.length), + ), + ), + ], + ) +} + +const MANUAL_STATUS_ENTRIES = [ + { value: "", label: "Follow the monitor and incidents" }, + ...StatusPageComponentStatus.literals.map((status) => ({ + value: status, + label: COMPONENT_STATUS_LABEL[status], + })), +] + +export const componentDialog = (h: H, model: Model): Html => { + const form = model.componentForm + const monitorEntries = [ + { value: "", label: "None" }, + ...monitorsOf(model).map((monitor) => ({ value: monitor.id, label: monitor.name })), + ] + const uptimeInputId = "status-page-component-uptime" + return formDialog(h, { + slotId: "status-page-component", + model: model.componentDialog, + toParentMessage: (message) => Message.GotComponentDialogMessage({ message }), + title: form.editingId === null ? "Add component" : "Edit component", + description: "Its status is the worst of its monitor, the override and the incidents naming it.", + onSubmit: Message.SubmittedComponent(), + submitLabel: form.editingId === null ? "Add component" : "Save component", + isPending: model.isSavingComponent, + isSubmitDisabled: form.name.trim() === "", + fields: [ + h.div( + [h.Class("flex flex-col gap-4")], + [ + textField(h, { + id: "status-page-component-name", + label: "Name", + value: form.name, + placeholder: "API", + onInput: (value) => Message.ChangedComponentField({ field: "name", value }), + }), + textareaField(h, { + id: "status-page-component-description", + label: "Description", + value: form.description, + hint: "Optional. Shown under the name.", + onInput: (value) => Message.ChangedComponentField({ field: "description", value }), + }), + textField(h, { + id: "status-page-component-group", + label: "Group", + value: form.group, + placeholder: "Core services", + hint: "Optional. Components with the same group are listed under one heading.", + onInput: (value) => Message.ChangedComponentField({ field: "group", value }), + }), + selectField(h, { + id: "status-page-component-monitor", + label: "Uptime monitor", + value: form.monitorId, + entries: monitorEntries, + hint: "A monitor that is down shows the component as a major outage.", + onInput: (value) => Message.ChangedComponentField({ field: "monitorId", value }), + }), + selectField(h, { + id: "status-page-component-status", + label: "Status override", + value: form.manualStatus, + entries: MANUAL_STATUS_ENTRIES, + onInput: (value) => Message.ChangedComponentField({ field: "manualStatus", value }), + }), + h.label( + [h.Class("flex items-center gap-2 text-sm"), h.Id(Checkbox.labelId(uptimeInputId))], + [ + ...Checkbox.view(h, { + model: { isChecked: form.showUptime }, + toParentMessage: () => Message.ToggledComponentUptime(), + id: `${uptimeInputId}-root`, + inputId: uptimeInputId, + }), + "Show 90 days of uptime bars", + ], + ), + ...(model.componentFailure === null + ? [] + : [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, ["Couldn't save the component"]), + alertDescription(h, [failureMessage(model.componentFailure)]), + ]), + ]), + ], + ), + ], + }) +} + +export const deleteComponentDialog = (h: H, model: Model): Html => { + const target = componentsOf(model).find((component) => component.id === model.confirmDeleteComponentId) + return confirmDialog(h, { + slotId: "status-page-delete-component", + model: model.deleteComponentDialog, + toParentMessage: (message) => Message.GotDeleteComponentDialogMessage({ message }), + title: `Remove ${target?.name ?? "component"}?`, + description: "It disappears from the page with the next publish. Incidents keep their history.", + confirmLabel: "Remove component", + isPending: model.isDeletingComponent, + onConfirm: Message.ClickedConfirmDeleteComponent(), + }) +} diff --git a/apps/web/src/page/alerts/status-pages/editor/view-incidents.ts b/apps/web/src/page/alerts/status-pages/editor/view-incidents.ts new file mode 100644 index 0000000000..378fbc041b --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/editor/view-incidents.ts @@ -0,0 +1,501 @@ +import { StatusPageIncidentImpact } from "@maple/domain" +import type { V2StatusPageIncident } from "@maple/domain/http/v2" +import { formatTimestampInTimezone } from "@maple/web-data/lib/timezone-format" +import type { Html, HtmlBuilder } from "foldkit/html" +import { alert, alertDescription, alertTitle } from "../../../../ui/alert" +import { button } from "../../../../ui/button" +import * as Checkbox from "../../../../ui/checkbox" +import { badge } from "../../../../ui/display" +import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "../../../../ui/empty" +import { formDialog } from "../../../../ui/form-dialog" +import { icon } from "../../../../ui/icon" +import { segmentedSelect } from "../../../../ui/segmented-select" +import { failureMessage } from "../../../../data/load-failure" +import { relativeTime } from "../../../kit/relative-time" +import { sectionHeading } from "../../../kit/section-heading" +import { + closingStatus, + IMPACT_LABEL, + INCIDENT_STATUS_LABEL, + isOpenIncident, + STATUSES_OF_KIND, +} from "../../shared/status-page-derive" +import { selectField, textareaField, textField } from "../fields" +import { incidentIssues } from "./form" +import { Message } from "./message" +import type { Model } from "./model" +import { componentsOf, incidentsOf } from "./reads" + +// The Incidents tab: open incidents and maintenance with their actions, then the closed ones. + +type H = HtmlBuilder + +export type TimeInputs = Readonly<{ nowMs: number; timeZone: string }> + +const IMPACT_TONE = { + none: "muted", + minor: "warn", + major: "warn", + critical: "crit", +} as const satisfies Record + +const componentNames = (model: Model, incident: V2StatusPageIncident): string => { + const names = componentsOf(model) + .filter((component) => incident.component_ids.includes(component.id)) + .map((component) => component.name) + return names.length === 0 ? "No components named" : names.join(", ") +} + +/** "Oct 28, 14:00 to 16:00": the maintenance window in the viewer's zone. */ +const windowLine = (incident: V2StatusPageIncident, timeZone: string): string | null => + incident.scheduled_start === null || incident.scheduled_end === null + ? null + : `${formatTimestampInTimezone(incident.scheduled_start, { timeZone, style: "range" })} to ${formatTimestampInTimezone(incident.scheduled_end, { timeZone, style: "range" })}` + +const incidentBadges = (h: H, incident: V2StatusPageIncident): ReadonlyArray => [ + badge(h, { variant: "outline" }, [incident.kind === "incident" ? "Incident" : "Maintenance"]), + badge(h, { variant: isOpenIncident(incident) ? "info" : "muted" }, [ + INCIDENT_STATUS_LABEL[incident.status], + ]), + ...(incident.kind === "incident" + ? [badge(h, { variant: IMPACT_TONE[incident.impact] }, [`${IMPACT_LABEL[incident.impact]} impact`])] + : []), + ...(incident.published ? [] : [badge(h, { variant: "meta" }, ["Draft"])]), +] + +const openActions = (h: H, model: Model, incident: V2StatusPageIncident): Html => + h.div( + [h.Class("flex shrink-0 items-center gap-2")], + [ + ...(incident.published + ? [] + : [ + button( + h, + { + size: "sm", + variant: "outline", + isLoading: model.publishingIncidentIds.includes(incident.id), + attributes: [ + h.OnClick(Message.ClickedPublishIncident({ incidentId: incident.id })), + ], + }, + ["Publish draft"], + ), + ]), + button( + h, + { + size: "sm", + variant: "outline", + attributes: [ + h.OnClick(Message.ClickedPostUpdate({ incidentId: incident.id, isResolving: false })), + ], + }, + ["Post update"], + ), + button( + h, + { + size: "sm", + variant: "outline", + attributes: [ + h.OnClick(Message.ClickedPostUpdate({ incidentId: incident.id, isResolving: true })), + ], + }, + [incident.kind === "incident" ? "Resolve" : "Complete"], + ), + ], + ) + +const openCard = (h: H, model: Model, incident: V2StatusPageIncident, time: TimeInputs): Html => { + const latest = incident.updates[0] + const window = windowLine(incident, time.timeZone) + return h.li( + [h.Key(incident.id), h.Class("flex flex-col gap-3 p-4")], + [ + h.div( + [h.Class("flex flex-wrap items-start justify-between gap-3")], + [ + h.div( + [h.Class("flex min-w-0 flex-col gap-1.5")], + [ + h.h3([h.Class("text-sm font-medium")], [incident.title]), + h.div( + [h.Class("flex flex-wrap items-center gap-1.5")], + incidentBadges(h, incident), + ), + ], + ), + openActions(h, model, incident), + ], + ), + h.p( + [h.Class("text-xs text-muted-foreground")], + [componentNames(model, incident), ...(window === null ? [] : [` · ${window}`])], + ), + ...(latest === undefined + ? [] + : [ + h.div( + [h.Class("rounded-md bg-muted/40 px-3 py-2")], + [ + h.p([h.Class("text-sm whitespace-pre-wrap")], [latest.body]), + h.p( + [h.Class("mt-1 text-xs text-muted-foreground")], + [ + `${INCIDENT_STATUS_LABEL[latest.status]} · `, + relativeTime(h, { + value: latest.created_at, + nowMs: time.nowMs, + timeZone: time.timeZone, + tooltip: "title", + }), + ], + ), + ], + ), + ]), + ], + ) +} + +const closedRow = (h: H, incident: V2StatusPageIncident, time: TimeInputs): Html => + h.li( + [h.Key(incident.id), h.Class("flex items-center justify-between gap-4 px-4 py-2.5")], + [ + h.div( + [h.Class("flex min-w-0 items-center gap-2")], + [ + h.span([h.Class("truncate text-sm")], [incident.title]), + badge(h, { variant: "outline" }, [ + incident.kind === "incident" ? "Incident" : "Maintenance", + ]), + ], + ), + h.span( + [h.Class("shrink-0 text-xs text-muted-foreground")], + [ + `${INCIDENT_STATUS_LABEL[incident.status]} `, + relativeTime(h, { + value: incident.resolved_at ?? incident.updated_at, + nowMs: time.nowMs, + timeZone: time.timeZone, + tooltip: "title", + }), + ], + ), + ], + ) + +const emptyIncidents = (h: H): Html => + empty( + h, + [ + emptyHeader(h, [ + emptyMedia(h, { variant: "icon" }, [icon(h, "CircleCheckIcon", { size: 18 })]), + emptyTitle(h, ["No incidents"]), + emptyDescription(h, [ + "When something breaks, open an incident and post updates as you learn more. Planned work goes in as scheduled maintenance.", + ]), + ]), + ], + "rounded-md border py-12", + ) + +const headerActions = (h: H): Html => + h.div( + [h.Class("flex items-center gap-2")], + [ + button( + h, + { + size: "sm", + variant: "outline", + attributes: [h.OnClick(Message.ClickedNewIncident({ kind: "maintenance" }))], + }, + [icon(h, "CalendarIcon"), "Schedule maintenance"], + ), + button( + h, + { size: "sm", attributes: [h.OnClick(Message.ClickedNewIncident({ kind: "incident" }))] }, + [icon(h, "PlusIcon"), "New incident"], + ), + ], + ) + +export const incidentsTab = (h: H, model: Model, time: TimeInputs): Html => { + const incidents = incidentsOf(model) + const open = incidents.filter(isOpenIncident) + const closed = incidents.filter((incident) => !isOpenIncident(incident)) + return h.div( + [h.Class("flex flex-col gap-8")], + [ + h.section( + [h.AriaLabelledBy("status-page-open-incidents"), h.Class("flex flex-col gap-3")], + [ + sectionHeading(h, { + id: "status-page-open-incidents", + title: "Open", + count: open.length === 0 ? undefined : String(open.length), + actions: headerActions(h), + }), + open.length === 0 + ? emptyIncidents(h) + : h.ul( + [h.Class("divide-y rounded-md border")], + open.map((incident) => openCard(h, model, incident, time)), + ), + ], + ), + ...(closed.length === 0 + ? [] + : [ + h.section( + [ + h.AriaLabelledBy("status-page-closed-incidents"), + h.Class("flex flex-col gap-3"), + ], + [ + sectionHeading(h, { id: "status-page-closed-incidents", title: "Recent" }), + h.ul( + [h.Class("divide-y rounded-md border")], + closed.map((incident) => closedRow(h, incident, time)), + ), + ], + ), + ]), + ], + ) +} + +const statusEntries = (kind: V2StatusPageIncident["kind"], isOpening: boolean) => + STATUSES_OF_KIND[kind] + .filter((status) => !isOpening || status !== closingStatus(kind)) + .map((status) => ({ value: status, label: INCIDENT_STATUS_LABEL[status] })) + +const IMPACT_ENTRIES = StatusPageIncidentImpact.literals.map((impact) => ({ + value: impact, + label: IMPACT_LABEL[impact], +})) + +const componentPicker = (h: H, model: Model): Html => { + const components = componentsOf(model) + return h.fieldset( + [h.Class("flex flex-col gap-1.5")], + [ + h.legend([h.Class("mb-1.5 text-xs text-muted-foreground")], ["Affected components"]), + components.length === 0 + ? h.p([h.Class("text-xs text-muted-foreground")], ["The page has no components yet."]) + : h.div( + [h.Class("grid grid-cols-2 gap-x-4 gap-y-2")], + components.map((component) => { + const inputId = `status-page-incident-component-${component.id}` + return h.label( + [ + h.Key(component.id), + h.Class("flex items-center gap-2 text-sm"), + h.Id(Checkbox.labelId(inputId)), + ], + [ + ...Checkbox.view(h, { + model: { + isChecked: model.incidentForm.componentIds.includes(component.id), + }, + toParentMessage: () => + Message.ToggledIncidentComponent({ componentId: component.id }), + id: `${inputId}-root`, + inputId, + }), + h.span([h.Class("truncate")], [component.name]), + ], + ) + }), + ), + ], + ) +} + +export const incidentDialog = (h: H, model: Model, timeZone: string): Html => { + const form = model.incidentForm + const isMaintenance = form.kind === "maintenance" + const issues = incidentIssues(form, timeZone) + return formDialog(h, { + slotId: "status-page-incident", + model: model.incidentDialog, + toParentMessage: (message) => Message.GotIncidentDialogMessage({ message }), + title: isMaintenance ? "Schedule maintenance" : "New incident", + description: isMaintenance + ? "Components show as under maintenance while it is in progress." + : "The first update is what visitors read. Post more as you learn more.", + onSubmit: Message.SubmittedIncident(), + submitLabel: form.isPublished ? (isMaintenance ? "Schedule" : "Publish incident") : "Save draft", + isPending: model.isCreatingIncident, + isSubmitDisabled: issues.length > 0, + footerStart: + issues.length === 0 + ? undefined + : h.p([h.Class("text-xs text-muted-foreground")], [issues[0] ?? ""]), + fields: [ + h.div( + [h.Class("flex flex-col gap-4")], + [ + textField(h, { + id: "status-page-incident-title", + label: "Title", + value: form.title, + placeholder: isMaintenance ? "Database upgrade" : "Elevated API errors", + onInput: (value) => Message.ChangedIncidentField({ field: "title", value }), + }), + h.div( + [h.Class("grid gap-4 sm:grid-cols-2")], + [ + selectField(h, { + id: "status-page-incident-status", + label: "Status", + value: form.status, + entries: statusEntries(form.kind, true), + onInput: (value) => Message.ChangedIncidentField({ field: "status", value }), + }), + ...(isMaintenance + ? [] + : [ + selectField(h, { + id: "status-page-incident-impact", + label: "Impact", + value: form.impact, + entries: IMPACT_ENTRIES, + onInput: (value) => + Message.ChangedIncidentField({ field: "impact", value }), + }), + ]), + ], + ), + ...(isMaintenance + ? [ + h.div( + [h.Class("grid gap-4 sm:grid-cols-2")], + [ + textField(h, { + id: "status-page-incident-start", + label: "Starts", + type: "datetime-local", + value: form.scheduledStart, + onInput: (value) => + Message.ChangedIncidentField({ + field: "scheduledStart", + value, + }), + }), + textField(h, { + id: "status-page-incident-end", + label: "Ends", + type: "datetime-local", + value: form.scheduledEnd, + hint: `Times are in ${timeZone}.`, + onInput: (value) => + Message.ChangedIncidentField({ + field: "scheduledEnd", + value, + }), + }), + ], + ), + ] + : []), + componentPicker(h, model), + textareaField(h, { + id: "status-page-incident-body", + label: "Message", + value: form.body, + placeholder: isMaintenance + ? "We are upgrading the primary database. Expect brief read-only periods." + : "We are looking into elevated error rates on the API.", + onInput: (value) => Message.ChangedIncidentField({ field: "body", value }), + }), + h.div( + [h.Class("flex flex-col gap-1.5")], + [ + h.span([h.Class("text-xs text-muted-foreground")], ["Visibility"]), + segmentedSelect(h, { + ariaLabel: "Visibility", + size: "sm", + value: form.isPublished ? "publish" : "draft", + onChange: (value) => + Message.PickedIncidentPublished({ isPublished: value === "publish" }), + options: [ + { value: "publish", label: "Publish now" }, + { value: "draft", label: "Save as draft" }, + ], + }), + ], + ), + ...(model.incidentFailure === null + ? [] + : [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, [ + isMaintenance ? "Couldn't schedule it" : "Couldn't open the incident", + ]), + alertDescription(h, [failureMessage(model.incidentFailure)]), + ]), + ]), + ], + ), + ], + }) +} + +export const updateDialog = (h: H, model: Model): Html => { + const form = model.updateForm + const incident = incidentsOf(model).find((row) => row.id === form?.incidentId) + return formDialog(h, { + slotId: "status-page-update", + model: model.updateDialog, + toParentMessage: (message) => Message.GotUpdateDialogMessage({ message }), + title: "Post an update", + description: incident?.title ?? "", + onSubmit: Message.SubmittedUpdate(), + submitLabel: "Post update", + isPending: model.isPostingUpdate, + isSubmitDisabled: form === null || form.body.trim() === "", + fields: + form === null + ? [] + : [ + h.div( + [h.Class("flex flex-col gap-4")], + [ + selectField(h, { + id: "status-page-update-status", + label: "Status", + value: form.status, + entries: statusEntries(form.kind, false), + hint: + form.status === closingStatus(form.kind) + ? "This closes it on the page." + : undefined, + onInput: (value) => + Message.ChangedUpdateField({ field: "status", value }), + }), + textareaField(h, { + id: "status-page-update-body", + label: "Message", + value: form.body, + onInput: (value) => Message.ChangedUpdateField({ field: "body", value }), + }), + ...(model.updateFailure === null + ? [] + : [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, ["Couldn't post the update"]), + alertDescription(h, [failureMessage(model.updateFailure)]), + ]), + ]), + ], + ), + ], + }) +} diff --git a/apps/web/src/page/alerts/status-pages/editor/view-settings.ts b/apps/web/src/page/alerts/status-pages/editor/view-settings.ts new file mode 100644 index 0000000000..0b08a472ae --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/editor/view-settings.ts @@ -0,0 +1,373 @@ +import type { V2StatusPage } from "@maple/domain/http/v2" +import { Option } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { alert, alertDescription, alertTitle } from "../../../../ui/alert" +import { button } from "../../../../ui/button" +import { confirmDialog } from "../../../../ui/confirm-dialog" +import { copyableField } from "../../../../ui/copyable-field" +import { icon } from "../../../../ui/icon" +import { panel } from "../../../../ui/panel" +import { failureMessage } from "../../../../data/load-failure" +import { saveBar } from "../../../settings/kit/save-bar" +import { adminOnlyNote, settingsSection, settingsSections } from "../../../settings/kit/section" +import { switchRow } from "../../../settings/kit/switch-row" +import { publicPath } from "../../shared/status-page-commands" +import { selectField, textareaField, textField } from "../fields" +import { type SettingsForm, settingsFormOf, settingsIssues } from "./form" +import { Message } from "./message" +import type { Model } from "./model" +import { isSettingsDirty, settingsOf } from "./update-settings" + +// The Settings tab: the page's fields, the break-glass token and deleting the page. + +type H = HtmlBuilder + +const AUTO_INCIDENT_ENTRIES = [ + { value: "off", label: "Off" }, + { value: "draft", label: "Open a draft for me to publish" }, + { value: "publish", label: "Open and publish an incident" }, +] + +const pageSection = (h: H, model: Model, form: SettingsForm): Html => { + const issues = settingsIssues(form) + const isReadOnly = !model.isAdmin + return settingsSection( + h, + { + id: "status-page-settings-page", + title: "Page", + description: ["What visitors see at the top of the page."], + }, + [ + h.div( + [h.Class("grid gap-4 sm:grid-cols-2")], + [ + textField(h, { + id: "status-page-name", + label: "Name", + value: form.name, + isDisabled: isReadOnly, + error: issues.name, + onInput: (value) => Message.ChangedSetting({ field: "name", value }), + }), + textField(h, { + id: "status-page-slug", + label: "Address", + value: form.slug, + isMono: true, + isDisabled: isReadOnly, + error: issues.slug, + hint: `${publicPath(form.slug)}. Changing it moves the page; the old address stops working.`, + onInput: (value) => Message.ChangedSetting({ field: "slug", value }), + }), + textareaField(h, { + id: "status-page-description", + label: "Description", + value: form.description, + isDisabled: isReadOnly, + className: "sm:col-span-2", + onInput: (value) => Message.ChangedSetting({ field: "description", value }), + }), + textField(h, { + id: "status-page-website", + label: "Website URL", + value: form.websiteUrl, + placeholder: "https://acme.example", + isDisabled: isReadOnly, + error: issues.websiteUrl, + onInput: (value) => Message.ChangedSetting({ field: "websiteUrl", value }), + }), + textField(h, { + id: "status-page-logo", + label: "Logo URL", + value: form.logoUrl, + placeholder: "https://acme.example/logo.svg", + isDisabled: isReadOnly, + error: issues.logoUrl, + onInput: (value) => Message.ChangedSetting({ field: "logoUrl", value }), + }), + h.div( + [h.Class("flex items-end gap-2")], + [ + textField(h, { + id: "status-page-brand-color", + label: "Brand color", + value: form.brandColor, + placeholder: "#1a2b3c", + isMono: true, + isDisabled: isReadOnly, + error: issues.brandColor, + className: "flex-1", + onInput: (value) => Message.ChangedSetting({ field: "brandColor", value }), + }), + ...(issues.brandColor === null && form.brandColor.trim() !== "" + ? [ + h.span( + [ + h.AriaHidden(true), + h.Class("mb-1.5 size-6 shrink-0 rounded-md border"), + h.Style({ backgroundColor: form.brandColor.trim() }), + ], + [], + ), + ] + : []), + ], + ), + ], + ), + ], + ) +} + +const behaviourSection = (h: H, model: Model, form: SettingsForm): Html => + settingsSection(h, { id: "status-page-settings-behaviour", title: "Behaviour", padded: false }, [ + h.div( + [h.Class("divide-y")], + [ + switchRow(h, { + id: "status-page-powered-by", + label: "Show powered by Maple", + description: "A small credit in the page footer.", + isChecked: form.showPoweredBy, + isDisabled: !model.isAdmin, + onToggle: Message.ToggledPoweredBy(), + className: "p-4", + }), + h.div( + [h.Class("p-4")], + [ + selectField(h, { + id: "status-page-auto-incidents", + label: "When a linked monitor goes down", + value: form.autoIncidents, + entries: AUTO_INCIDENT_ENTRIES, + isDisabled: !model.isAdmin, + hint: "A draft waits for you on the Incidents tab; a published one shows on the page at once.", + onInput: (value) => Message.ChangedSetting({ field: "autoIncidents", value }), + }), + ], + ), + ], + ), + ]) + +const revealedToken = (h: H, model: Model): ReadonlyArray => + model.revealedToken === null + ? [] + : [ + h.div( + [h.Class("flex flex-col gap-2 rounded-md border bg-muted/30 p-3")], + [ + copyableField(h, { + id: "status-page-break-glass-token", + value: model.revealedToken.token, + copyLabel: "break-glass token", + copy: model.tokenCopy, + toCopyMessage: (message) => Message.GotTokenCopyMessage({ message }), + }), + h.div( + [h.Class("flex items-center justify-between gap-3")], + [ + h.p( + [h.Class("text-xs text-muted-foreground")], + [ + "Store it outside Maple, in your password manager. It is not shown again.", + ], + ), + button( + h, + { + size: "sm", + variant: "ghost", + attributes: [h.OnClick(Message.ClickedDismissToken())], + }, + ["Done"], + ), + ], + ), + ], + ), + ] + +const breakGlassSection = (h: H, model: Model, page: V2StatusPage): Html => { + const suffix = page.break_glass_token_suffix + return settingsSection( + h, + { + id: "status-page-settings-break-glass", + title: "Break-glass token", + description: [ + "If Maple itself is down, post updates straight to the page with this token at ", + h.code([h.Class("font-mono text-xs")], [`${publicPath(page.slug)}/_manage`]), + ". The page keeps serving from edge storage either way.", + ], + }, + [ + h.div( + [h.Class("flex flex-col gap-3")], + [ + h.div( + [h.Class("flex flex-wrap items-center justify-between gap-3")], + [ + h.p( + [h.Class("text-sm")], + suffix === null + ? [h.span([h.Class("text-muted-foreground")], ["No token issued"])] + : [ + "Active token ending in ", + h.code([h.Class("font-mono text-xs")], [suffix]), + ], + ), + ...(model.isAdmin + ? [ + h.div( + [h.Class("flex items-center gap-2")], + [ + ...(suffix === null + ? [] + : [ + button( + h, + { + size: "sm", + variant: "destructive-outline", + attributes: [ + h.OnClick( + Message.ClickedRevokeToken(), + ), + ], + }, + ["Revoke"], + ), + ]), + button( + h, + { + size: "sm", + variant: "outline", + isLoading: + model.isChangingBreakGlass && + model.breakGlassAction === "rotate", + attributes: [h.OnClick(Message.ClickedRotateToken())], + }, + [ + icon(h, "KeyIcon"), + suffix === null ? "Issue token" : "Replace token", + ], + ), + ], + ), + ] + : []), + ], + ), + ...revealedToken(h, model), + ], + ), + ], + ) +} + +const dangerZone = (h: H): Html => + settingsSection(h, { id: "status-page-settings-danger", title: "Danger zone", framed: false }, [ + panel(h, { className: "border-destructive/40" }, [ + h.div( + [h.Class("flex items-start justify-between gap-6 p-4")], + [ + h.div( + [h.Class("space-y-1")], + [ + h.p([h.Class("text-sm font-medium text-foreground")], ["Delete status page"]), + h.p( + [h.Class("font-prose text-xs text-muted-foreground")], + [ + "Takes the page offline and deletes its components and incidents. This cannot be undone.", + ], + ), + ], + ), + button( + h, + { + variant: "destructive", + size: "sm", + attributes: [h.OnClick(Message.ClickedDeletePage())], + }, + ["Delete status page"], + ), + ], + ), + ]), + ]) + +export const settingsTab = (h: H, model: Model, page: V2StatusPage): Html => { + const form = Option.getOrElse(settingsOf(model), () => settingsFormOf(page)) + const issues = settingsIssues(form) + const hasIssues = Object.values(issues).some((issue) => issue !== null) + return h.div( + [h.Class("flex max-w-4xl flex-col")], + [ + settingsSections(h, [ + ...(model.isAdmin + ? [] + : [ + adminOnlyNote(h, [ + "Only org admins can change a status page. You can still post incident updates.", + ]), + ]), + pageSection(h, model, form), + behaviourSection(h, model, form), + breakGlassSection(h, model, page), + ...(model.isAdmin ? [dangerZone(h)] : []), + ]), + ...(model.settingsFailure === null + ? [] + : [ + alert(h, { variant: "crit", size: "sm", className: "mt-6" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, ["Couldn't save the settings"]), + alertDescription(h, [failureMessage(model.settingsFailure)]), + ]), + ]), + ...saveBar(h, { + isDirty: isSettingsDirty(model), + isPending: model.isSavingSettings, + context: hasIssues ? "Fix the fields marked above." : undefined, + onDiscard: Message.ClickedDiscardSettings(), + onSave: Message.ClickedSaveSettings(), + isSaveDisabled: hasIssues, + }), + ], + ) +} + +export const breakGlassDialog = (h: H, model: Model): Html => + confirmDialog(h, { + slotId: "status-page-break-glass", + model: model.breakGlassDialog, + toParentMessage: (message) => Message.GotBreakGlassDialogMessage({ message }), + title: + model.breakGlassAction === "revoke" + ? "Revoke the break-glass token?" + : "Replace the break-glass token?", + description: + model.breakGlassAction === "revoke" + ? "Updates posted with it are refused from now on. You can issue a new one later." + : "The current token stops working at once. The new one is shown once.", + confirmLabel: model.breakGlassAction === "revoke" ? "Revoke token" : "Replace token", + isPending: model.isChangingBreakGlass, + onConfirm: Message.ClickedConfirmBreakGlass(), + }) + +export const deletePageDialog = (h: H, model: Model, page: V2StatusPage): Html => + confirmDialog(h, { + slotId: "status-page-delete", + model: model.deletePageDialog, + toParentMessage: (message) => Message.GotDeletePageDialogMessage({ message }), + title: `Delete ${page.name}?`, + description: `${publicPath(page.slug)} goes offline and its components and incidents are deleted. This cannot be undone.`, + confirmLabel: "Delete status page", + isPending: model.isDeletingPage, + onConfirm: Message.ClickedConfirmDeletePage(), + }) diff --git a/apps/web/src/page/alerts/status-pages/editor/view.ts b/apps/web/src/page/alerts/status-pages/editor/view.ts new file mode 100644 index 0000000000..4fe92c0256 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/editor/view.ts @@ -0,0 +1,258 @@ +import type { StatusPageComponentStatus } from "@maple/domain" +import type { V2StatusPage } from "@maple/domain/http/v2" +import { worstStatus } from "@maple/domain/status-page" +import { Option } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { button } from "../../../../ui/button" +import type { LoadFailure } from "../../../../data/load-failure" +import { badge } from "../../../../ui/display" +import { errorState } from "../../../../ui/error-state" +import { icon } from "../../../../ui/icon" +import { skeleton } from "../../../../ui/skeleton" +import type { PageViewInputs } from "../../../contract" +import { dashboardPage } from "../../../kit/dashboard-page" +import { detailActions, detailTitle, detailTitleSkeleton } from "../../../kit/detail-header" +import { relativeTime } from "../../../kit/relative-time" +import { resourceNotFound } from "../../../kit/resource-not-found" +import { underlineTabClass, underlineTabStrip } from "../../../kit/underline-tabs" +import { + type EditorTab, + publicPath, + statusPageHref, + statusPagesTabHref, +} from "../../shared/status-page-commands" +import { + COMPONENT_STATUS_LABEL, + COMPONENT_STATUS_TONE, + componentStatus, + hasUnpublishedChanges, + isOpenIncident, +} from "../../shared/status-page-derive" +import { Message } from "./message" +import type { Model } from "./model" +import { componentsOf, incidentsOf, monitorsOf, pageOf } from "./reads" +import { resolveTab } from "./update" +import { componentDialog, componentsTab, deleteComponentDialog } from "./view-components" +import { incidentDialog, incidentsTab, updateDialog } from "./view-incidents" +import { breakGlassDialog, deletePageDialog, settingsTab } from "./view-settings" + +// `/alerts/status-pages/$pageId`: the header, the tabs, and the dialogs every tab opens. + +type H = HtmlBuilder + +const TABS: ReadonlyArray<{ value: EditorTab; label: string }> = [ + { value: "components", label: "Components" }, + { value: "incidents", label: "Incidents" }, + { value: "settings", label: "Settings" }, +] + +const tabBar = (h: H, page: V2StatusPage, active: EditorTab, openIncidents: number): Html => + underlineTabStrip( + h, + { label: "Status page sections", navigation: true }, + TABS.map((tab) => + h.a( + [ + h.Key(tab.value), + h.Href(statusPageHref(page.id, tab.value)), + ...(tab.value === active ? [h.AriaCurrent("page")] : []), + h.Class(underlineTabClass(tab.value === active)), + ], + [ + tab.label, + ...(tab.value === "incidents" && openIncidents > 0 + ? [ + h.span( + [h.Class("font-mono text-xs tabular-nums text-severity-warn")], + [String(openIncidents)], + ), + ] + : []), + ], + ), + ), + ) + +/** The banner a visitor sees now: the worst component, from what is loaded. */ +const overallOf = (model: Model): StatusPageComponentStatus => + worstStatus( + componentsOf(model).map((component) => + componentStatus(component, monitorsOf(model), incidentsOf(model)), + ), + ) + +/** When visitors last got the page, or why they did not. */ +const publishLine = (h: H, model: Model, page: V2StatusPage, timeZone: string): Html => { + const className = "text-xs text-muted-foreground" + if (page.last_publish_error !== null) + return h.span( + [h.Class("text-xs text-destructive")], + [`Last publish failed: ${page.last_publish_error}`], + ) + if (page.published_at === null) return h.span([h.Class(className)], ["Not published yet"]) + return h.span( + [h.Class(className)], + [ + relativeTime(h, { + value: page.published_at, + nowMs: model.nowMs ?? 0, + timeZone, + tooltip: "title", + prefix: "Published", + }), + ...(hasUnpublishedChanges(page) ? [" · changes go out within a minute"] : []), + ], + ) +} + +const headerActions = (h: H, model: Model): Html => + detailActions(h, [ + ...(model.isAdmin + ? [ + button( + h, + { + size: "sm", + variant: "outline", + isLoading: model.isPublishing, + attributes: [h.OnClick(Message.ClickedPublish())], + }, + [icon(h, "PaperPlaneIcon"), "Publish now"], + ), + ] + : []), + ]) + +/** The read a tab waits on: its failure, and whether its data is in. */ +const tabRead = (model: Model, tab: EditorTab): { failure: Option.Option; isReady: boolean } => { + switch (tab) { + case "settings": + return { failure: Option.none(), isReady: true } + // Incidents name components and the composer picks them, so a failed components read shows too. + case "incidents": + return { + failure: AsyncData.getError(model.incidents.data).pipe( + Option.orElse(() => AsyncData.getError(model.components.data)), + ), + isReady: AsyncData.hasData(model.incidents.data) && AsyncData.hasData(model.components.data), + } + // Statuses need monitors and incidents too, and the dialog's monitor picker needs monitors: + // a failure in either must show as one, not as every component Operational. + case "components": + return { + failure: AsyncData.getError(model.components.data).pipe( + Option.orElse(() => AsyncData.getError(model.monitors.data)), + Option.orElse(() => AsyncData.getError(model.incidents.data)), + ), + isReady: + AsyncData.hasData(model.components.data) && + AsyncData.hasData(model.monitors.data) && + AsyncData.hasData(model.incidents.data), + } + } +} + +const tabBody = (h: H, model: Model, page: V2StatusPage, tab: EditorTab, timeZone: string): Html => { + const read = tabRead(model, tab) + if (Option.isSome(read.failure)) + return errorState(h, { + failure: read.failure.value, + title: tab === "incidents" ? "Couldn't load incidents" : "Couldn't load components", + onRetry: Message.ClickedRetry(), + className: "py-12", + }) + if (!read.isReady) return skeleton(h, "h-48 w-full") + switch (tab) { + case "components": + return componentsTab(h, model) + case "incidents": + return incidentsTab(h, model, { nowMs: model.nowMs ?? 0, timeZone }) + case "settings": + return settingsTab(h, model, page) + } +} + +const loaded = (h: H, model: Model, page: V2StatusPage, inputs: PageViewInputs): Html => { + const tab = resolveTab(model.search.tab) + const timeZone = inputs.context.timeZone + const overall = overallOf(model) + // The badge only claims a status once every input to it has loaded. + const hasComponents = + AsyncData.hasData(model.components.data) && + AsyncData.hasData(model.monitors.data) && + AsyncData.hasData(model.incidents.data) && + componentsOf(model).length > 0 + return h.div( + [h.Class("contents")], + [ + dashboardPage( + h, + { + titleContent: detailTitle(h, { + kind: ["Status page", publicPath(page.slug)], + title: [page.name], + adornment: hasComponents + ? badge(h, { variant: COMPONENT_STATUS_TONE[overall] }, [ + COMPONENT_STATUS_LABEL[overall], + ]) + : h.empty, + meta: [publishLine(h, model, page, timeZone)], + }), + actions: headerActions(h, model), + tabs: tabBar(h, page, tab, incidentsOf(model).filter(isOpenIncident).length), + gap: "md", + }, + [tabBody(h, model, page, tab, timeZone)], + ), + componentDialog(h, model), + deleteComponentDialog(h, model), + incidentDialog(h, model, timeZone), + updateDialog(h, model), + breakGlassDialog(h, model), + deletePageDialog(h, model, page), + ], + ) +} + +const notFound = (h: H, model: Model): Html => + dashboardPage(h, {}, [ + resourceNotFound(h, { + title: "Status page not found", + description: [ + "No status page has the id ", + h.code([h.Class("font-mono")], [model.rawPageId]), + ".", + ], + back: { href: statusPagesTabHref(), label: "Back to status pages" }, + }), + ]) + +export const view = Submodel.defineView((model, inputs, h) => { + if (model.pageId === null) return notFound(h, model) + const data = model.page.data + if (AsyncData.isFailure(data)) + return data.error.code === "status_page_not_found" + ? notFound(h, model) + : dashboardPage(h, {}, [ + errorState(h, { + failure: data.error, + title: "Couldn't load the status page", + onRetry: Message.ClickedRetry(), + }), + ]) + return Option.match(pageOf(model), { + onNone: () => + dashboardPage(h, { titleContent: detailTitleSkeleton(h) }, [ + skeleton(h, "h-9 w-1/2"), + skeleton(h, "h-48 w-full"), + ]), + onSome: (page) => loaded(h, model, page, inputs), + }) +}) + +/** The H1 names the page, so the trail stops at the tab. */ +export const breadcrumbsOf = () => [ + { label: "Alerts", href: "/alerts" }, + { label: "Status pages", href: statusPagesTabHref() }, +] diff --git a/apps/web/src/page/alerts/status-pages/fields.ts b/apps/web/src/page/alerts/status-pages/fields.ts new file mode 100644 index 0000000000..a021ab0a82 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/fields.ts @@ -0,0 +1,110 @@ +import { cn } from "@maple/ui/lib/utils" +import type { Html, HtmlBuilder } from "foldkit/html" +import { input } from "../../../ui/input" +import { label } from "../../../ui/label" +import { nativeOption, nativeSelect } from "../../../ui/native-select" +import { textarea } from "../../../ui/textarea" + +// The labelled inputs the status page forms share: label over control, hint or error under it. + +type Common = Readonly<{ + id: string + label: string + hint?: string + /** Replaces the hint, in the destructive colour. */ + error?: string | null + className?: string + isDisabled?: boolean + /** Kept for screen readers when a row already names the control. */ + isLabelHidden?: boolean + onInput: (value: string) => M +}> + +const frame = (h: HtmlBuilder, options: Common, control: Html): Html => { + const error = options.error ?? null + return h.div( + [h.Class(cn("flex flex-col gap-1.5", options.className))], + [ + label( + h, + { + className: options.isLabelHidden === true ? "sr-only" : "text-xs text-muted-foreground", + attributes: [h.For(options.id)], + }, + [options.label], + ), + control, + ...(error !== null + ? [h.p([h.Id(`${options.id}-error`), h.Class("text-xs text-destructive")], [error])] + : options.hint === undefined + ? [] + : [h.p([h.Class("text-xs text-muted-foreground")], [options.hint])]), + ], + ) +} + +export const textField = ( + h: HtmlBuilder, + options: Common & Readonly<{ value: string; placeholder?: string; type?: string; isMono?: boolean }>, +): Html => + frame( + h, + options, + input(h, { + id: options.id, + type: options.type, + isDisabled: options.isDisabled, + controlClassName: options.isMono === true ? "font-mono" : undefined, + attributes: [ + h.Value(options.value), + h.OnInput(options.onInput), + ...(options.placeholder === undefined ? [] : [h.Placeholder(options.placeholder)]), + ...((options.error ?? null) === null + ? [] + : [h.AriaInvalid(true), h.AriaDescribedBy(`${options.id}-error`)]), + ], + }), + ) + +export const textareaField = ( + h: HtmlBuilder, + options: Common & Readonly<{ value: string; placeholder?: string }>, +): Html => + frame( + h, + options, + textarea(h, { + id: options.id, + isDisabled: options.isDisabled, + attributes: [ + h.Value(options.value), + h.OnInput(options.onInput), + ...(options.placeholder === undefined ? [] : [h.Placeholder(options.placeholder)]), + ], + }), + ) + +export const selectField = ( + h: HtmlBuilder, + options: Common & + Readonly<{ value: string; entries: ReadonlyArray<{ value: string; label: string }> }>, +): Html => + frame( + h, + options, + nativeSelect( + h, + { + defaultValue: options.value, + className: "w-full", + isDisabled: options.isDisabled, + attributes: [ + // Uncontrolled: keyed on the value so a reset or a reopened form rebuilds it. + h.Key(`${options.id}-${options.value}`), + h.Id(options.id), + h.OnChange(options.onInput), + ], + }, + options.entries.map((entry) => nativeOption(entry.value, entry.label)), + ), + ) diff --git a/apps/web/src/page/alerts/status-pages/list/command.ts b/apps/web/src/page/alerts/status-pages/list/command.ts new file mode 100644 index 0000000000..e9b75b96a1 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/list/command.ts @@ -0,0 +1,25 @@ +import { V2StatusPageCreateParams } from "@maple/domain/http/v2" +import { Effect, Schema } from "effect" +import { Command } from "foldkit" +import { createPage, listPageSummaries } from "../../shared/status-page-commands" +import { Message } from "./message" + +export const FetchStatusPages = Command.define("FetchStatusPageSummaries", { + args: { key: Schema.String }, + messages: [Message.CompletedFetchPages], + execute: ({ key }) => + listPageSummaries.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchPages({ key, result })), + ), +}) + +export const CreateStatusPage = Command.define("CreateStatusPage", { + args: { payload: V2StatusPageCreateParams }, + messages: [Message.CompletedCreate], + execute: ({ payload }) => + createPage(payload).pipe( + Effect.result, + Effect.map((result) => Message.CompletedCreate({ result })), + ), +}) diff --git a/apps/web/src/page/alerts/status-pages/list/index.ts b/apps/web/src/page/alerts/status-pages/list/index.ts new file mode 100644 index 0000000000..9aa9b4b534 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/list/index.ts @@ -0,0 +1,4 @@ +export { Message } from "./message" +export { Model } from "./model" +export { init, request, type Return, update } from "./update" +export { pageCount, view } from "./view" diff --git a/apps/web/src/page/alerts/status-pages/list/message.ts b/apps/web/src/page/alerts/status-pages/list/message.ts new file mode 100644 index 0000000000..394bb18df1 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/list/message.ts @@ -0,0 +1,20 @@ +import { V2StatusPage } from "@maple/domain/http/v2" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { LoadFailure } from "../../../../data/load-failure" +import * as Dialog from "../../../../ui/dialog" +import { StatusPageSummary } from "../../shared/status-page-derive" + +const outcome = (success: S) => Schema.Result(success, LoadFailure) + +export const Message = defineMessageUnion({ + CompletedFetchPages: { key: Schema.String, result: outcome(Schema.Array(StatusPageSummary)) }, + ClickedRetry: {}, + ClickedNew: {}, + ChangedName: { value: Schema.String }, + ChangedSlug: { value: Schema.String }, + SubmittedCreate: {}, + CompletedCreate: { result: outcome(V2StatusPage) }, + GotCreateDialogMessage: { message: Dialog.Message }, +}) +export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/status-pages/list/model.ts b/apps/web/src/page/alerts/status-pages/list/model.ts new file mode 100644 index 0000000000..58f46d8ee9 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/list/model.ts @@ -0,0 +1,20 @@ +import { Schema } from "effect" +import { KeyedSchema } from "../../../../data/keyed" +import { LoadFailure } from "../../../../data/load-failure" +import * as Dialog from "../../../../ui/dialog" +import { StatusPageSummary } from "../../shared/status-page-derive" + +// The hub's Status pages tab: the pages with their state, and the create dialog. + +export const Model = Schema.Struct({ + /** `statusPages.list` with each page's components and open incidents, read while the tab is open. */ + pages: KeyedSchema(Schema.Array(StatusPageSummary), LoadFailure), + createDialog: Dialog.Model, + name: Schema.String, + slug: Schema.String, + /** The user typed an address; until then it follows the name. */ + isSlugEdited: Schema.Boolean, + isCreating: Schema.Boolean, + createFailure: Schema.NullOr(LoadFailure), +}) +export type Model = typeof Model.Type diff --git a/apps/web/src/page/alerts/status-pages/list/status-pages-tab.scene.test.ts b/apps/web/src/page/alerts/status-pages/list/status-pages-tab.scene.test.ts new file mode 100644 index 0000000000..0c8b150b82 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/list/status-pages-tab.scene.test.ts @@ -0,0 +1,175 @@ +import { OrgId } from "@maple/domain" +import { Result, Schema } from "effect" +import { expect as expectView, given, Mount, role, scene, text, type, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { toLoadFailure } from "../../../../data/load-failure" +import * as Dialog from "../../../../ui/dialog" +import { PageOutMessage, type PageContext } from "../../../contract" +import { Message as HubMessage } from "../../hub/message" +import type { Model as HubModel } from "../../hub/model" +import { init, update } from "../../hub/update" +import { view as hubView } from "../../hub/view" +import { statusPageHref } from "../../shared/status-page-commands" +import type { StatusPageSummary } from "../../shared/status-page-derive" +import { NOW, ORG } from "../../shared/test-rows" +import { statusPage } from "../test-pages" +import { Message } from "./message" + +const orgId = Schema.decodeSync(OrgId)(ORG) +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const hubUpdate = (model: HubModel, message: HubMessage) => update(model, message, context) +const view = withViewInputs(hubView, { context })() +const tab = (message: Message) => HubMessage.GotStatusPagesMessage({ message }) +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + (commands ?? []).map(({ name }) => name) + +const onTab = (isAdmin = true): HubModel => { + const entered = init({ _tag: "Alerts", search: { tab: "status-pages" } }, context).model + return [ + HubMessage.ReadClock({ nowMs: NOW }), + HubMessage.CompletedFetchSession({ orgId: ORG, isAdmin }), + ].reduce((model, message) => hubUpdate(model, message).model, entered) +} + +const withPages = (pages: ReadonlyArray, isAdmin = true): HubModel => { + const model = onTab(isAdmin) + return hubUpdate( + model, + tab( + Message.CompletedFetchPages({ + key: model.statusPages.pages.key ?? "", + result: Result.succeed(pages), + }), + ), + ).model +} + +const opened = (): HubModel => hubUpdate(withPages([]), tab(Message.ClickedNew())).model + +describe("alerts hub, status pages tab", () => { + test("opening the tab reads the pages, and no other tab does", () => { + const entered = init({ _tag: "Alerts", search: { tab: "status-pages" } }, context) + expect(names(entered.commands)).toContain("FetchStatusPageSummaries") + const inbox = init({ _tag: "Alerts", search: {} }, context) + expect(names(inbox.commands)).not.toContain("FetchStatusPageSummaries") + }) + + test("an org without pages gets the create on-ramp, members are told who can", () => { + scene( + { update: hubUpdate, view }, + given(withPages([])), + expectView(text("No status pages yet")).toExist(), + expectView(role("button", { name: "Create a status page" })).toExist(), + ) + scene( + { update: hubUpdate, view }, + given(withPages([], false)), + expectView(role("button", { name: "Create a status page" })).not.toExist(), + expectView(text("Ask an org admin to create one.")).toExist(), + ) + }) + + test("a row shows the name, address, status, open incidents and publish state", () => { + const summary: StatusPageSummary = { + page: statusPage({ published_at: null }), + overall: "partial_outage", + componentCount: 3, + openIncidents: 2, + } + scene( + { update: hubUpdate, view }, + given(withPages([summary])), + expectView(role("link", { name: "Acme Status" })).toHaveAttr( + "href", + statusPageHref(summary.page.id), + ), + expectView(text("/s/acme-status")).toExist(), + expectView(text("Partial outage")).toExist(), + expectView(text("2")).toExist(), + expectView(text("Not published")).toExist(), + ) + }) + + test("a failed read offers a retry that reads again", () => { + const model = onTab() + const failed = hubUpdate( + model, + tab( + Message.CompletedFetchPages({ + key: model.statusPages.pages.key ?? "", + result: Result.fail(toLoadFailure(new Error("down"))), + }), + ), + ).model + scene( + { update: hubUpdate, view }, + given(failed), + expectView(text("Couldn't load status pages")).toExist(), + ) + expect(names(hubUpdate(failed, tab(Message.ClickedRetry())).commands)).toEqual([ + "FetchStatusPageSummaries", + ]) + }) + + test("the address follows the name until it is typed", () => { + scene( + { update: hubUpdate, view }, + given(opened()), + Mount.resolve(Dialog.MountDialog, Dialog.Message.CompletedMountDialog()), + type(role("textbox", { name: "Name" }), "Acme Cloud Status"), + expectView(role("textbox", { name: "Address" })).toHaveValue("acme-cloud-status"), + type(role("textbox", { name: "Address" }), "status"), + type(role("textbox", { name: "Name" }), "Acme"), + expectView(role("textbox", { name: "Address" })).toHaveValue("status"), + ) + }) + + test("an address the API would refuse says why and cannot be submitted", () => { + const typed = [Message.ChangedName({ value: "Acme" }), Message.ChangedSlug({ value: "a" })].reduce( + (model, message) => hubUpdate(model, tab(message)).model, + opened(), + ) + scene( + { update: hubUpdate, view }, + given(typed), + Mount.resolve(Dialog.MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text("Use 3 to 48 lowercase letters, digits or inner hyphens.")).toExist(), + expectView(role("button", { name: "Create status page" })).toBeDisabled(), + ) + expect(hubUpdate(typed, tab(Message.SubmittedCreate())).commands ?? []).toEqual([]) + }) + + test("creating sends the name and address once, then opens the new page", () => { + const typed = hubUpdate(opened(), tab(Message.ChangedName({ value: "Acme Status" }))).model + const creating = hubUpdate(typed, tab(Message.SubmittedCreate())) + expect(creating.commands).toMatchObject([ + { name: "CreateStatusPage", args: { payload: { name: "Acme Status", slug: "acme-status" } } }, + ]) + expect(hubUpdate(creating.model, tab(Message.SubmittedCreate())).commands ?? []).toEqual([]) + const page = statusPage() + const done = hubUpdate(creating.model, tab(Message.CompletedCreate({ result: Result.succeed(page) }))) + expect(done.outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: statusPageHref(page.id), + isReplace: false, + toast: { type: "success", title: "Status page created", description: "" }, + }), + ) + }) + + test("a refused create keeps the dialog open with the server's reason", () => { + const typed = hubUpdate(opened(), tab(Message.ChangedName({ value: "Acme Status" }))).model + const creating = hubUpdate(typed, tab(Message.SubmittedCreate())).model + const failed = hubUpdate( + creating, + tab(Message.CompletedCreate({ result: Result.fail(toLoadFailure(new Error("slug taken"))) })), + ).model + expect(failed.statusPages.createDialog.isOpen).toBe(true) + scene( + { update: hubUpdate, view }, + given(failed), + Mount.resolve(Dialog.MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text("Couldn't create the page")).toExist(), + ) + }) +}) diff --git a/apps/web/src/page/alerts/status-pages/list/update.ts b/apps/web/src/page/alerts/status-pages/list/update.ts new file mode 100644 index 0000000000..9985bba446 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/list/update.ts @@ -0,0 +1,97 @@ +import { Option, Result } from "effect" +import { type Command, Command as FoldkitCommand } from "foldkit" +import * as Keyed from "../../../../data/keyed" +import { requestKey } from "../../../../data/request-key" +import type { Resources } from "../../../../platform/resources" +import * as Dialog from "../../../../ui/dialog" +import { type PageContext, PageOutMessage } from "../../../contract" +import { statusPageHref } from "../../shared/status-page-commands" +import { slugFromName, slugIssue } from "../slug" +import { CreateStatusPage, FetchStatusPages } from "./command" +import { Message } from "./message" +import type { Model } from "./model" + +export const CREATE_DIALOG_ID = "status-pages-create" + +export type Return = Readonly<{ + model: Model + commands?: ReadonlyArray> + outMessage?: PageOutMessage +}> + +export const init = (): Model => ({ + pages: Keyed.idle(), + createDialog: Dialog.init(CREATE_DIALOG_ID, "dialog"), + name: "", + slug: "", + isSlugEdited: false, + isCreating: false, + createFailure: null, +}) + +const pagesKey = (context: PageContext) => requestKey(context.orgId, "statusPages.summaries", {}) + +/** Reads the pages under `mode`; the hub calls it when the tab opens. */ +export const request = (model: Model, context: PageContext, mode: Keyed.ReadMode): Return => + Option.match(Keyed.requestFor(model.pages, pagesKey(context), mode), { + onNone: () => ({ model }), + onSome: (pages) => ({ model: { ...model, pages }, commands: [FetchStatusPages({ key: pages.key })] }), + }) + +const liftDialog = (model: Model, step: ReturnType): Return => ({ + model: { ...model, createDialog: step.model }, + commands: FoldkitCommand.mapMessages(step.commands ?? [], (message) => + Message.GotCreateDialogMessage({ message }), + ), +}) + +/** What stops the create: the name, then the address. */ +export const createIssue = (model: Model): string | null => + model.name.trim() === "" ? "Add a name." : slugIssue(model.slug) + +const create = (model: Model): Return => + model.isCreating || createIssue(model) !== null + ? { model } + : { + model: { ...model, isCreating: true, createFailure: null }, + commands: [CreateStatusPage({ payload: { name: model.name.trim(), slug: model.slug } })], + } + +export const update = (model: Model, message: Message, context: PageContext): Return => + Message.match(message, { + CompletedFetchPages: ({ key, result }) => ({ + model: { ...model, pages: Keyed.accept(model.pages, key, result) }, + }), + ClickedRetry: () => request(model, context, "Supersede"), + ClickedNew: () => + liftDialog( + { ...model, name: "", slug: "", isSlugEdited: false, createFailure: null }, + Dialog.open(model.createDialog), + ), + ChangedName: ({ value }) => ({ + model: { ...model, name: value, slug: model.isSlugEdited ? model.slug : slugFromName(value) }, + }), + ChangedSlug: ({ value }) => { + const slug = value.trim().toLowerCase() + // Clearing the address (spaces included) hands it back to the name. + return { model: { ...model, slug, isSlugEdited: slug !== "" } } + }, + SubmittedCreate: () => create(model), + CompletedCreate: ({ result }) => + Result.match(result, { + onSuccess: (page): Return => ({ + ...liftDialog({ ...model, isCreating: false }, Dialog.close(model.createDialog)), + outMessage: PageOutMessage.RequestedNavigation({ + url: statusPageHref(page.id), + isReplace: false, + toast: { type: "success", title: "Status page created", description: "" }, + }), + }), + onFailure: (createFailure): Return => ({ + model: { ...model, isCreating: false, createFailure }, + }), + }), + // A create in flight keeps the dialog open. + GotCreateDialogMessage: ({ message: child }) => + model.isCreating ? { model } : liftDialog(model, Dialog.update(model.createDialog, child)), + }) diff --git a/apps/web/src/page/alerts/status-pages/list/view.ts b/apps/web/src/page/alerts/status-pages/list/view.ts new file mode 100644 index 0000000000..a4dd844c91 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/list/view.ts @@ -0,0 +1,257 @@ +import { countLabel } from "@maple/ui/lib/format" +import { cn } from "@maple/ui/lib/utils" +import { Option } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { alert, alertDescription, alertTitle } from "../../../../ui/alert" +import { button } from "../../../../ui/button" +import { badge } from "../../../../ui/display" +import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "../../../../ui/empty" +import { errorState } from "../../../../ui/error-state" +import { eyebrow } from "../../../../ui/eyebrow" +import { formDialog } from "../../../../ui/form-dialog" +import { icon } from "../../../../ui/icon" +import { skeleton } from "../../../../ui/skeleton" +import { rowLink } from "../../../../ui/table" +import { failureMessage } from "../../../../data/load-failure" +import { relativeTime } from "../../../kit/relative-time" +import { emptyActions } from "../../../kit/signal-empty-state" +import { publicPath, statusPageHref } from "../../shared/status-page-commands" +import { + COMPONENT_STATUS_LABEL, + COMPONENT_STATUS_TONE, + type StatusPageSummary, +} from "../../shared/status-page-derive" +import { textField } from "../fields" +import { slugIssue } from "../slug" +import { Message } from "./message" +import type { Model } from "./model" +import { createIssue } from "./update" + +// The Status pages tab: the page table, its empty on-ramp and the create dialog. + +type H = HtmlBuilder + +export type ViewInputs = Readonly<{ isAdmin: boolean; nowMs: number; timeZone: string }> + +const ROW_GRID = + "grid grid-cols-[minmax(220px,1.6fr)_minmax(130px,0.8fr)_96px_112px_128px] items-center gap-x-5" + +/** No page yet: what a status page is and the first one to create. */ +const emptyState = (h: H, isAdmin: boolean): Html => + empty( + h, + [ + emptyHeader(h, [ + emptyMedia(h, { variant: "icon" }, [icon(h, "CircleCheckIcon", { size: 18 })]), + emptyTitle(h, ["No status pages yet"]), + emptyDescription(h, [ + "A public page that tells your customers what is up. Components follow your uptime monitors, incidents are written by you, and the page stays online when Maple is not.", + ]), + ]), + ...(isAdmin + ? [ + emptyActions(h, [ + button(h, { size: "sm", attributes: [h.OnClick(Message.ClickedNew())] }, [ + icon(h, "PlusIcon"), + "Create a status page", + ]), + ]), + ] + : [h.p([h.Class("text-xs text-muted-foreground")], ["Ask an org admin to create one."])]), + ], + "py-12", + ) + +const lastPublished = (h: H, summary: StatusPageSummary, inputs: ViewInputs): Html => { + const page = summary.page + if (page.last_publish_error !== null) + return h.span( + [h.Class("text-right text-xs text-destructive"), h.Title(page.last_publish_error)], + ["Publish failed"], + ) + return page.published_at === null + ? h.span([h.Class("text-right text-xs text-muted-foreground")], ["Not published"]) + : relativeTime(h, { + value: page.published_at, + nowMs: inputs.nowMs, + timeZone: inputs.timeZone, + tooltip: "title", + className: "text-right text-xs text-muted-foreground", + }) +} + +const pageRow = (h: H, summary: StatusPageSummary, inputs: ViewInputs): Html => { + const page = summary.page + return h.li( + [h.Key(page.id), h.Class("relative py-3 pr-4 pl-4 hover:bg-muted/25")], + [ + h.div( + [h.Class(ROW_GRID)], + [ + h.div( + [h.Class("min-w-0")], + [ + rowLink( + h, + { + href: statusPageHref(page.id), + className: "block truncate text-sm font-medium", + }, + [page.name], + ), + h.p( + [h.Class("truncate font-mono text-xs text-muted-foreground")], + [publicPath(page.slug)], + ), + ], + ), + h.span( + [], + [ + summary.componentCount === 0 + ? h.span([h.Class("text-xs text-muted-foreground")], ["No components"]) + : badge(h, { variant: COMPONENT_STATUS_TONE[summary.overall] }, [ + COMPONENT_STATUS_LABEL[summary.overall], + ]), + ], + ), + h.span( + [h.Class("text-right font-mono text-xs tabular-nums text-muted-foreground")], + [String(summary.componentCount)], + ), + h.span( + [ + h.Class( + cn( + "text-right font-mono text-xs tabular-nums", + summary.openIncidents > 0 + ? "text-severity-warn" + : "text-muted-foreground", + ), + ), + ], + [String(summary.openIncidents)], + ), + lastPublished(h, summary, inputs), + ], + ), + ], + ) +} + +const table = (h: H, pages: ReadonlyArray, inputs: ViewInputs): Html => + h.div( + [h.Class("overflow-x-auto rounded-md border")], + [ + h.div( + [h.Class("min-w-[720px]")], + [ + h.div( + [h.Class(cn(ROW_GRID, "border-b py-2 pr-4 pl-4"))], + [ + eyebrow(h, {}, ["Page"]), + eyebrow(h, {}, ["Status"]), + eyebrow(h, { className: "text-right" }, ["Components"]), + eyebrow(h, { className: "text-right" }, ["Open incidents"]), + eyebrow(h, { className: "text-right" }, ["Last published"]), + ], + ), + h.ul( + [h.Class("divide-y")], + pages.map((summary) => pageRow(h, summary, inputs)), + ), + ], + ), + ], + ) + +const createDialog = (h: H, model: Model): Html => { + const issue = createIssue(model) + const slugError = model.slug === "" ? null : slugIssue(model.slug) + return formDialog(h, { + slotId: "status-pages-create", + model: model.createDialog, + toParentMessage: (message) => Message.GotCreateDialogMessage({ message }), + title: "Create a status page", + description: "Add components and publish it from the page that opens next.", + onSubmit: Message.SubmittedCreate(), + submitLabel: "Create status page", + isPending: model.isCreating, + isSubmitDisabled: issue !== null, + fields: [ + h.div( + [h.Class("flex flex-col gap-4")], + [ + textField(h, { + id: "status-page-create-name", + label: "Name", + value: model.name, + placeholder: "Acme Status", + onInput: (value) => Message.ChangedName({ value }), + }), + textField(h, { + id: "status-page-create-slug", + label: "Address", + value: model.slug, + placeholder: "acme-status", + isMono: true, + hint: + model.slug === "" + ? "Lowercase letters, digits and hyphens." + : publicPath(model.slug), + error: slugError, + onInput: (value) => Message.ChangedSlug({ value }), + }), + ...(model.createFailure === null + ? [] + : [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, ["Couldn't create the page"]), + alertDescription(h, [failureMessage(model.createFailure)]), + ]), + ]), + ], + ), + ], + }) +} + +const body = (h: H, model: Model, inputs: ViewInputs): Html => { + const data = model.pages.data + if (AsyncData.isFailure(data)) + return errorState(h, { + failure: data.error, + title: "Couldn't load status pages", + onRetry: Message.ClickedRetry(), + className: "py-12", + }) + return Option.match(AsyncData.getData(data), { + onNone: () => + h.div([h.Class("flex flex-col gap-4")], [skeleton(h, "h-9 w-1/3"), skeleton(h, "h-48 w-full")]), + onSome: (pages) => + pages.length === 0 + ? emptyState(h, inputs.isAdmin) + : h.div( + [h.Class("flex flex-col gap-3")], + [ + h.p( + [h.Class("text-xs text-muted-foreground")], + [ + `${countLabel(pages.length, "page")}. Each one is served from edge storage, so it stays up when Maple does not.`, + ], + ), + table(h, pages, inputs), + ], + ), + }) +} + +export const view = Submodel.defineView((model, inputs, h) => + h.div([h.Class("contents")], [body(h, model, inputs), createDialog(h, model)]), +) + +/** The pages once read, for the tab's count. */ +export const pageCount = (model: Model): number | undefined => + Option.getOrUndefined(Option.map(AsyncData.getData(model.pages.data), (pages) => pages.length)) diff --git a/apps/web/src/page/alerts/status-pages/slug.test.ts b/apps/web/src/page/alerts/status-pages/slug.test.ts new file mode 100644 index 0000000000..cbc5061717 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/slug.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest" +import { slugFromName } from "./slug" + +describe("slugFromName", () => { + it("keeps accented letters as their base letter instead of splitting them with hyphens", () => { + expect(slugFromName("Crème Brûlée")).toBe("creme-brulee") + expect(slugFromName("Über Status")).toBe("uber-status") + }) + + it("collapses punctuation and trims hyphens at the ends", () => { + expect(slugFromName(" Acme Status! ")).toBe("acme-status") + }) +}) diff --git a/apps/web/src/page/alerts/status-pages/slug.ts b/apps/web/src/page/alerts/status-pages/slug.ts new file mode 100644 index 0000000000..0b1dc72017 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/slug.ts @@ -0,0 +1,28 @@ +import { StatusPageSlug } from "@maple/domain/status-page" +import { Schema } from "effect" + +// The page address: suggested from the name until typed, checked as the API checks it. + +const SLUG_MAX = 48 + +/** `Acme Status!` as `acme-status`: lowercase letters and digits, single inner hyphens. */ +export const slugFromName = (name: string): string => + name + .toLowerCase() + .normalize("NFKD") + // NFKD splits accented letters into base and mark; drop the mark so "Crème" stays "creme". + .replace(/\p{M}+/gu, "") + .replace(/[^a-z0-9]+/gu, "-") + .replace(/^-+|-+$/gu, "") + .slice(0, SLUG_MAX) + .replace(/-+$/u, "") + +const isSlug = Schema.is(StatusPageSlug) + +/** Why a slug would be refused, or `null` when it is fine. */ +export const slugIssue = (slug: string): string | null => + slug === "" + ? "Add an address." + : isSlug(slug) + ? null + : "Use 3 to 48 lowercase letters, digits or inner hyphens." diff --git a/apps/web/src/page/alerts/status-pages/test-pages.ts b/apps/web/src/page/alerts/status-pages/test-pages.ts new file mode 100644 index 0000000000..1ba1501abf --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/test-pages.ts @@ -0,0 +1,88 @@ +import { + StatusPageComponentId, + StatusPageId, + StatusPageIncidentId, + StatusPageIncidentUpdateId, +} from "@maple/domain" +import type { V2StatusPage, V2StatusPageComponent, V2StatusPageIncident } from "@maple/domain/http/v2" +import { Schema } from "effect" +import { NOW } from "../shared/test-rows" + +// Status pages as the v2 API decodes them, for the scene tests. + +const iso = (msBefore: number) => new Date(NOW - msBefore).toISOString() +const MINUTE = 60_000 + +export const pageId = (n: number) => + Schema.decodeSync(StatusPageId)(`0199a010-0000-7000-8000-00000000000${n}`) +export const componentId = (n: number) => + Schema.decodeSync(StatusPageComponentId)(`0199a011-0000-7000-8000-00000000000${n}`) +export const incidentId = (n: number) => + Schema.decodeSync(StatusPageIncidentId)(`0199a012-0000-7000-8000-00000000000${n}`) +const updateId = (n: number) => + Schema.decodeSync(StatusPageIncidentUpdateId)(`0199a013-0000-7000-8000-00000000000${n}`) + +export const statusPage = (fields: Partial = {}): V2StatusPage => ({ + id: pageId(1), + object: "status_page", + name: "Acme Status", + slug: "acme-status", + description: null, + website_url: null, + logo_url: null, + brand_color: null, + show_powered_by: true, + auto_incidents: "draft", + break_glass_token_suffix: null, + revision: 3, + published_revision: 3, + published_at: iso(5 * MINUTE), + last_publish_error: null, + created_at: iso(60 * MINUTE), + updated_at: iso(5 * MINUTE), + ...fields, +}) + +export const component = (n: number, fields: Partial = {}): V2StatusPageComponent => ({ + id: componentId(n), + object: "status_page_component", + page_id: pageId(1), + name: `Component ${n}`, + description: null, + group: null, + position: n - 1, + monitor_id: null, + manual_status: null, + show_uptime: true, + created_at: iso(60 * MINUTE), + updated_at: iso(60 * MINUTE), + ...fields, +}) + +export const incident = (n: number, fields: Partial = {}): V2StatusPageIncident => ({ + id: incidentId(n), + object: "status_page_incident", + page_id: pageId(1), + kind: "incident", + title: `Incident ${n}`, + impact: "major", + status: "investigating", + published: true, + scheduled_start: null, + scheduled_end: null, + started_at: iso(10 * MINUTE), + resolved_at: null, + component_ids: [componentId(1)], + updates: [ + { + id: updateId(n), + status: "investigating", + body: "We are looking into elevated error rates.", + source: "dashboard", + created_at: iso(10 * MINUTE), + }, + ], + created_at: iso(10 * MINUTE), + updated_at: iso(10 * MINUTE), + ...fields, +}) diff --git a/apps/web/src/page/registry.ts b/apps/web/src/page/registry.ts index 905ede7194..e1e3d0758f 100644 --- a/apps/web/src/page/registry.ts +++ b/apps/web/src/page/registry.ts @@ -25,6 +25,7 @@ import * as AnomaliesList from "./anomalies/list" import * as AnomalyDetail from "./anomalies/detail" import * as AlertIncident from "./alerts/incident" import * as AlertRule from "./alerts/rule" +import * as AlertsStatusPage from "./alerts/status-pages/editor" import * as AlertsUptimeMonitor from "./alerts/uptime-monitor" import * as AlertsUptimeNew from "./alerts/uptime-form" import * as IngestRuleEditor from "./settings/ingest-rule" @@ -111,6 +112,7 @@ export const pages = [ AlertRule.page, AlertsUptimeMonitor.page, AlertsUptimeNew.page, + AlertsStatusPage.page, AlertEditor.page, ServiceMap.page, InfraHosts.page, diff --git a/apps/web/src/route/app-route.ts b/apps/web/src/route/app-route.ts index 937cb46e53..8425e76310 100644 --- a/apps/web/src/route/app-route.ts +++ b/apps/web/src/route/app-route.ts @@ -34,6 +34,7 @@ export const AppRoute = defineRouteUnion({ AlertsUptimeMonitor: { monitorId: Schema.String, search: Search.AlertsUptimeMonitorSearch }, IngestRuleNew: { search: Search.IngestRuleNewSearch }, IngestRule: { ruleId: Schema.String }, + AlertsStatusPage: { pageId: Schema.String, search: Search.AlertsStatusPageSearch }, Anomaly: { incidentId: AnomalyIncidentId }, CodeReviewCi: { search: Search.CodeReviewCiSearch }, CodeReviewIssues: { search: Search.CodeReviewIssuesSearch }, diff --git a/apps/web/src/route/route.test.ts b/apps/web/src/route/route.test.ts index 9563a2d640..fa1b5454e8 100644 --- a/apps/web/src/route/route.test.ts +++ b/apps/web/src/route/route.test.ts @@ -40,6 +40,16 @@ describe("routes", () => { expect(hrefOf(monitor)).toBe("/alerts/uptime/upm_abc") }) + test("a status page route keeps its tab and prints back", () => { + const route = parse("/alerts/status-pages/stp_abc?tab=%22incidents%22") + expect(route).toMatchObject({ + _tag: "AlertsStatusPage", + pageId: "stp_abc", + search: { tab: "incidents" }, + }) + expect(hrefOf(route)).toBe("/alerts/status-pages/stp_abc?tab=incidents") + }) + test("a literal sibling wins over a param route", () => { expect(parse("/infra/hosts")._tag).toBe("InfraHosts") expect(parse("/alerts/create")._tag).toBe("AlertsCreate") diff --git a/apps/web/src/route/routers.ts b/apps/web/src/route/routers.ts index 32f7183114..cd9a148ba8 100644 --- a/apps/web/src/route/routers.ts +++ b/apps/web/src/route/routers.ts @@ -96,6 +96,12 @@ const routers = { slash(segment("ruleId")), Route.mapTo(AppRoute.IngestRule), ), + AlertsStatusPage: pipe( + path("alerts", "status-pages"), + slash(segment("pageId")), + search(Search.AlertsStatusPageSearch), + Route.mapTo(AppRoute.AlertsStatusPage), + ), Anomaly: pipe( path("anomalies"), slash(schemaSegment("incidentId", AnomalyIncidentId)), @@ -410,6 +416,7 @@ export const urlToAppRoute = Route.parseUrlWithFallback( routers.AlertsUptimeMonitor, routers.IngestRuleNew, routers.IngestRule, + routers.AlertsStatusPage, routers.DashboardsTemplates, routers.SourcesNew, routers.InfraDiscover, diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index 6f7b32751b..01af1a391f 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -337,6 +337,11 @@ export const IngestRuleNewSearch = Schema.Struct({ template: Schema.optional(Schema.String), }) +/** `/alerts/status-pages/$pageId`: `tab` is `components`, `incidents` or `settings`. */ +export const AlertsStatusPageSearch = Schema.Struct({ + tab: Schema.optional(Schema.String), +}) + export const CodeReviewCiSearch = Schema.Struct(CodeReviewCiSearchFields) export const CodeReviewIssuesSearch = Schema.Struct(CodeReviewIssuesSearchFields) diff --git a/apps/web/src/view.lazy.test.ts b/apps/web/src/view.lazy.test.ts index 7469e2fc6e..67a59d0160 100644 --- a/apps/web/src/view.lazy.test.ts +++ b/apps/web/src/view.lazy.test.ts @@ -42,6 +42,7 @@ const SAMPLE_PATHS = [ "/alerts/create", "/alerts/uptime/new", "/alerts/uptime/upm_abc", + "/alerts/status-pages/stp_abc", "/service-map", "/infra/hosts", "/infra/hosts/web-1",