diff --git a/packages/cli/src/ai-context/references/configure-supporting-constructs.md b/packages/cli/src/ai-context/references/configure-supporting-constructs.md index 7b2aea30d..d2b12b137 100644 --- a/packages/cli/src/ai-context/references/configure-supporting-constructs.md +++ b/packages/cli/src/ai-context/references/configure-supporting-constructs.md @@ -15,6 +15,50 @@ +## Status Page V3 (components) + +- Import `StatusPageV3`, `StatusPageV3Component` and `StatusPageV3AutomationRule` from `checkly/constructs`. +- A v3 status page has no cards or services. Its structure is declared with `StatusPageV3Component` constructs that point at the page via `statusPage`; nest a `SERVICE` under a `GROUP` via `parent`. +- `StatusPageV3AutomationRule` opens one incident impacting the listed components when a check whose tags overlap with the rule's `tags` fails, and resolves it on recovery. Requires the automated incident management add-on. +- A logical id deployed as a `StatusPage` cannot be redeployed as a `StatusPageV3` (or vice versa); use a new logical id. + +```ts +import { StatusPageV3, StatusPageV3AutomationRule, StatusPageV3Component } from 'checkly/constructs' + +const statusPage = new StatusPageV3('example-status-page-v3', { + name: 'Example Status Page', + url: 'example-status-page-v3', + customDomain: 'status.example.com', + defaultTheme: 'AUTO', +}) + +const webApp = new StatusPageV3Component('example-web-app-group', { + statusPage, + type: 'GROUP', + name: 'Web application', + displayOrder: 1, +}) + +const signUp = new StatusPageV3Component('example-sign-up-service', { + statusPage, + parent: webApp, + type: 'SERVICE', + name: 'Sign up', + description: 'The sign up flow', + displayOrder: 1, +}) + +new StatusPageV3AutomationRule('example-api-down-rule', { + statusPage, + name: 'API down', + firstUpdate: 'The API is down, we are investigating.', + lastUpdate: 'The API has recovered.', + tags: ['api:public'], + coolDownMinutes: 5, + components: [{ component: signUp, targetImpact: 'MAJOR_OUTAGE' }], +}) +``` + ## Dashboard - Import the `Dashboard` construct from `checkly/constructs`. diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index f1aa16ff8..155292d8c 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -10,6 +10,7 @@ import { MaintenanceWindow, PrivateLocation, PrivateLocationCheckAssignment, PrivateLocationGroupAssignment, Project, ProjectData, Diagnostics, Session, StatusPage, StatusPageService, + StatusPageV3Component, StatusPageV3AutomationRule, } from '../constructs/index.js' import chalk from 'chalk' import { splitConfigFilePath, getGitInformation } from '../services/util.js' @@ -41,6 +42,8 @@ const PRETTY_RESOURCE_TYPES: Record = { [Dashboard.__checklyType]: 'Dashboard', [StatusPage.__checklyType]: 'StatusPage', [StatusPageService.__checklyType]: 'StatusPageService', + [StatusPageV3Component.__checklyType]: 'StatusPageV3Component', + [StatusPageV3AutomationRule.__checklyType]: 'StatusPageV3AutomationRule', } // Internal resources that users don't create directly. They are reported as diff --git a/packages/cli/src/commands/import/plan.ts b/packages/cli/src/commands/import/plan.ts index 0a338567e..36913a762 100644 --- a/packages/cli/src/commands/import/plan.ts +++ b/packages/cli/src/commands/import/plan.ts @@ -171,6 +171,8 @@ future deployments include the imported resources.` 'check-group': new Map(), 'private-location': new Map(), 'status-page-service': new Map(), + 'status-page': new Map(), + 'status-page-component': new Map(), } if (debugImportPlanInputFile) { @@ -1192,6 +1194,12 @@ ${chalk.cyan('For safety, resources are not deletable until the plan has been co case 'status-page-service': context.registerFriendStatusPageService(resource.physicalId, friendExport) break + case 'status-page': + context.registerFriendStatusPage(resource.physicalId, friendExport) + break + case 'status-page-component': + context.registerFriendStatusPageComponent(resource.physicalId, friendExport) + break } } catch (cause) { throw new Error(`Failed to process friend resource '${resource.type}:${resource.physicalId}' (${resource.logicalId}): ${cause}`, { cause }) @@ -1590,6 +1598,8 @@ const importables = { 'private-location': uuidPhysicalId, 'status-page-service': uuidPhysicalId, 'status-page': uuidPhysicalId, + 'status-page-component': uuidPhysicalId, + 'status-page-automation-rule': uuidPhysicalId, } function isFilterable (type: string): boolean { diff --git a/packages/cli/src/constructs/__tests__/status-page-v3-codegen.spec.ts b/packages/cli/src/constructs/__tests__/status-page-v3-codegen.spec.ts new file mode 100644 index 000000000..c3027381f --- /dev/null +++ b/packages/cli/src/constructs/__tests__/status-page-v3-codegen.spec.ts @@ -0,0 +1,186 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { ConstructCodegen, sortResources } from '../construct-codegen.js' +import { Context } from '../internal/codegen/index.js' +import { Program } from '../../sourcegen/index.js' +import type { StatusPageV3Resource } from '../status-page-v3-codegen.js' +import type { StatusPageV3ComponentResource } from '../status-page-v3-component-codegen.js' +import type { StatusPageV3AutomationRuleResource } from '../status-page-v3-automation-rule-codegen.js' + +const PAGE_ID = '11111111-1111-4111-8111-111111111111' +const GROUP_ID = '22222222-2222-4222-8222-222222222222' +const SERVICE_ID = '33333333-3333-4333-8333-333333333333' +const RULE_ID = '44444444-4444-4444-8444-444444444444' +const FOREIGN_PAGE_ID = '55555555-5555-4555-8555-555555555555' +const FOREIGN_COMPONENT_ID = '66666666-6666-4666-8666-666666666666' + +const page: StatusPageV3Resource = { + id: PAGE_ID, + version: 3, + name: 'ACME Status', + url: 'acme-status', + description: 'All systems', + defaultTheme: 'DARK', + allowIndexing: false, +} + +const group: StatusPageV3ComponentResource = { + id: GROUP_ID, + statusPageId: PAGE_ID, + parentId: null, + type: 'GROUP', + name: 'Platform', + displayOrder: 0, +} + +const service: StatusPageV3ComponentResource = { + id: SERVICE_ID, + statusPageId: PAGE_ID, + parentId: GROUP_ID, + type: 'SERVICE', + name: 'Public API', + description: 'REST API', + hidden: true, + displayOrder: 1, +} + +const rule: StatusPageV3AutomationRuleResource = { + id: RULE_ID, + statusPageId: PAGE_ID, + name: 'API outage', + enabled: false, + firstUpdate: 'Investigating', + lastUpdate: 'Resolved', + notifySubscribers: false, + tags: ['api', 'prod'], + coolDownWindowMinutes: 10, + components: [{ componentId: SERVICE_ID, targetImpact: 'MAJOR_OUTAGE' }], +} + +async function generate (rootDirectory: string, resources: Array<{ type: any, logicalId: string, payload: any }>) { + const program = new Program({ + rootDirectory, + constructFileSuffix: '.check', + specFileSuffix: '.spec', + language: 'typescript', + }) + const codegen = new ConstructCodegen(program) + const context = new Context() + + sortResources(resources) + for (const resource of resources) { + codegen.prepare(resource.logicalId, resource, context) + } + for (const resource of resources) { + codegen.gencode(resource.logicalId, resource, context) + } + await program.realize() + + // Keyed by POSIX-style relative path so lookups read the same on Windows. + const sources: Record = {} + for (const filePath of program.paths) { + const key = path.relative(rootDirectory, filePath).split(path.sep).join(path.posix.sep) + sources[key] = await readFile(filePath, 'utf8') + } + return sources +} + +describe('StatusPageV3 codegen', () => { + let rootDirectory: string + + beforeEach(async () => { + rootDirectory = await mkdtemp(path.join(tmpdir(), 'status-page-v3-codegen-')) + }) + + afterEach(async () => { + await rm(rootDirectory, { recursive: true, force: true }) + }) + + it('generates a v3 page, nested components and an automation rule that reference each other', async () => { + const sources = await generate(rootDirectory, [ + { type: 'status-page-automation-rule', logicalId: 'api-outage', payload: rule }, + { type: 'status-page-component', logicalId: 'public-api', payload: service }, + { type: 'status-page-component', logicalId: 'platform', payload: group }, + { type: 'status-page', logicalId: 'acme', payload: page }, + ]) + + const pageSource = sources['resources/status-pages/acme-status.check.ts'] + expect(pageSource).toContain('import { StatusPageV3 } from \'checkly/constructs\'') + expect(pageSource).toContain('export const acmeStatusPage = new StatusPageV3(\'acme\', {') + expect(pageSource).toContain('description: \'All systems\'') + expect(pageSource).toContain('defaultTheme: \'DARK\'') + expect(pageSource).toContain('allowIndexing: false') + expect(pageSource).not.toContain('cards') + + const groupSource = sources['resources/status-pages/components/platform.check.ts'] + expect(groupSource).toContain('import { acmeStatusPage } from \'../acme-status.check\'') + expect(groupSource).toContain('export const platformComponent = new StatusPageV3Component(\'platform\', {') + expect(groupSource).toContain('statusPage: acmeStatusPage') + expect(groupSource).toContain('type: \'GROUP\'') + expect(groupSource).toContain('displayOrder: 0') + expect(groupSource).not.toContain('parent:') + + const serviceSource = sources['resources/status-pages/components/public-api.check.ts'] + expect(serviceSource).toContain('import { platformComponent } from \'./platform.check\'') + expect(serviceSource).toContain('parent: platformComponent') + expect(serviceSource).toContain('hidden: true') + expect(serviceSource).toContain('description: \'REST API\'') + // SERVICE is the default and is left implicit. + expect(serviceSource).not.toContain('type: \'SERVICE\'') + + const ruleSource = sources['resources/status-pages/automation-rules/api-outage.check.ts'] + expect(ruleSource).toContain('new StatusPageV3AutomationRule(\'api-outage\', {') + expect(ruleSource).toContain('statusPage: acmeStatusPage') + expect(ruleSource).toContain('enabled: false') + expect(ruleSource).toContain('notifySubscribers: false') + expect(ruleSource).toContain('coolDownMinutes: 10') + expect(ruleSource).toMatch(/tags: \[\s*'api',\s*'prod',?\s*\]/) + expect(ruleSource).toContain('component: publicApiComponent') + expect(ruleSource).toContain('targetImpact: \'MAJOR_OUTAGE\'') + }) + + it('falls back to fromId() references for resources outside the plan', async () => { + const sources = await generate(rootDirectory, [ + { + type: 'status-page-component', + logicalId: 'orphan', + payload: { ...service, statusPageId: FOREIGN_PAGE_ID, parentId: FOREIGN_COMPONENT_ID }, + }, + ]) + + const source = sources['resources/status-pages/components/public-api.check.ts'] + expect(source).toContain(`statusPage: StatusPageV3.fromId('${FOREIGN_PAGE_ID}')`) + expect(source).toContain(`parent: StatusPageV3Component.fromId('${FOREIGN_COMPONENT_ID}')`) + }) + + it('still generates v2 pages through the shared status-page type', async () => { + const sources = await generate(rootDirectory, [ + { + type: 'status-page', + logicalId: 'legacy', + payload: { id: PAGE_ID, version: 2, name: 'Legacy', url: 'legacy', cards: [] }, + }, + ]) + + const source = sources['resources/status-pages/legacy.check.ts'] + expect(source).toContain('new StatusPage(\'legacy\', {') + expect(source).toContain('cards: []') + }) + + it('describes each generation distinctly', () => { + const codegen = new ConstructCodegen(new Program({ + rootDirectory, + constructFileSuffix: '.check', + specFileSuffix: '.spec', + language: 'typescript', + })) + expect(codegen.describe({ type: 'status-page', logicalId: 'a', payload: page })).toBe('Status Page (v3): ACME Status') + expect(codegen.describe({ type: 'status-page', logicalId: 'b', payload: { ...page, version: 2 } })).toBe('Status Page: ACME Status') + expect(codegen.describe({ type: 'status-page-component', logicalId: 'c', payload: group })).toBe('Status Page Component: Platform') + expect(codegen.describe({ type: 'status-page-automation-rule', logicalId: 'd', payload: rule })).toBe('Status Page Automation Rule: API outage') + }) +}) diff --git a/packages/cli/src/constructs/__tests__/status-page-v3.spec.ts b/packages/cli/src/constructs/__tests__/status-page-v3.spec.ts new file mode 100644 index 000000000..c69c9443a --- /dev/null +++ b/packages/cli/src/constructs/__tests__/status-page-v3.spec.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, beforeEach } from 'vitest' + +import { StatusPageV3, StatusPageV3AutomationRule, StatusPageV3Component, Diagnostics } from '../index.js' +import { Project } from '../project.js' +import { Session } from '../session.js' + +const newProject = () => { + const project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + Session.project = project + return project +} + +describe('StatusPageV3', () => { + beforeEach(() => { + newProject() + }) + + it('shares the status-page type key and synthesizes the v3 discriminator', () => { + const page = new StatusPageV3('acme', { + name: 'ACME', + url: 'acme-status', + defaultTheme: 'DARK', + termsOfServiceLink: 'https://acme.example/terms', + allowIndexing: false, + }) + + expect(page.type).toBe('status-page') + expect(page.synthesize()).toEqual(expect.objectContaining({ + name: 'ACME', + url: 'acme-status', + defaultTheme: 'DARK', + termsOfServiceLink: 'https://acme.example/terms', + allowIndexing: false, + version: 3, + })) + expect(page.synthesize()).not.toHaveProperty('cards') + }) + + it('should produce a diagnostic if the same logicalId is used twice', async () => { + const project = newProject() + + new StatusPageV3('foo', { name: 'foo', url: 'foo' }) + new StatusPageV3('foo', { name: 'foo', url: 'foo' }) + + const diagnostics = new Diagnostics() + await project.validate(diagnostics) + expect(diagnostics.isFatal()).toBe(true) + expect(diagnostics.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ message: expect.stringContaining('already exists') }), + ])) + }) + + it('should validate that fromId() receives a valid UUID', async () => { + const valid = StatusPageV3.fromId('e79b4cf8-467e-4902-917d-82b155b42024') + const validDiags = new Diagnostics() + await valid.validate(validDiags) + expect(validDiags.isFatal()).toEqual(false) + expect(valid.synthesize()).toBeNull() + + const invalid = StatusPageV3.fromId('not-a-uuid') + const invalidDiags = new Diagnostics() + await invalid.validate(invalidDiags) + expect(invalidDiags.isFatal()).toEqual(true) + }) +}) + +describe('StatusPageV3Component', () => { + beforeEach(() => { + newProject() + }) + + it('synthesizes refs to its page and parent', () => { + const page = new StatusPageV3('acme', { name: 'ACME', url: 'acme-status' }) + const group = new StatusPageV3Component('web-app', { statusPage: page, type: 'GROUP', name: 'Web', displayOrder: 1 }) + const service = new StatusPageV3Component('login', { + statusPage: page, + parent: group, + name: 'Login', + hidden: true, + displayOrder: 2, + }) + + expect(group.synthesize()).toEqual({ + statusPageId: { ref: 'acme' }, + parentId: null, + type: 'GROUP', + name: 'Web', + description: undefined, + hidden: undefined, + displayOrder: 1, + }) + expect(service.synthesize()).toEqual(expect.objectContaining({ + statusPageId: { ref: 'acme' }, + parentId: { ref: 'web-app' }, + type: 'SERVICE', + hidden: true, + })) + }) + + it('accepts a page referenced by id', () => { + const page = StatusPageV3.fromId('e79b4cf8-467e-4902-917d-82b155b42024') + const component = new StatusPageV3Component('login', { statusPage: page, name: 'Login', displayOrder: 1 }) + expect(component.synthesize().statusPageId).toEqual({ ref: page.logicalId }) + }) + + it('rejects a parent that is not a GROUP', async () => { + const page = new StatusPageV3('acme', { name: 'ACME', url: 'acme-status' }) + const sibling = new StatusPageV3Component('sibling', { statusPage: page, name: 'Sibling', displayOrder: 1 }) + const child = new StatusPageV3Component('child', { statusPage: page, parent: sibling, name: 'Child', displayOrder: 1 }) + + const diagnostics = new Diagnostics() + await child.validate(diagnostics) + expect(diagnostics.isFatal()).toBe(true) + expect(diagnostics.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ message: expect.stringContaining('GROUP') }), + ])) + }) + + it('rejects a parent on another status page', async () => { + const pageA = new StatusPageV3('a', { name: 'A', url: 'a' }) + const pageB = new StatusPageV3('b', { name: 'B', url: 'b' }) + const group = new StatusPageV3Component('group', { statusPage: pageA, type: 'GROUP', name: 'G', displayOrder: 1 }) + const child = new StatusPageV3Component('child', { statusPage: pageB, parent: group, name: 'C', displayOrder: 1 }) + + const diagnostics = new Diagnostics() + await child.validate(diagnostics) + expect(diagnostics.isFatal()).toBe(true) + expect(diagnostics.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ message: expect.stringContaining('same status page') }), + ])) + }) +}) + +describe('StatusPageV3AutomationRule', () => { + beforeEach(() => { + newProject() + }) + + it('synthesizes the backend field names and component refs', () => { + const page = new StatusPageV3('acme', { name: 'ACME', url: 'acme-status' }) + const email = new StatusPageV3Component('email', { statusPage: page, name: 'Email', displayOrder: 1 }) + const rule = new StatusPageV3AutomationRule('api-down', { + statusPage: page, + name: 'API down', + firstUpdate: 'Investigating', + lastUpdate: 'Resolved', + tags: ['api:public'], + coolDownMinutes: 10, + components: [{ component: email, targetImpact: 'MAJOR_OUTAGE' }], + }) + + expect(rule.synthesize()).toEqual({ + statusPageId: { ref: 'acme' }, + name: 'API down', + enabled: undefined, + firstUpdate: 'Investigating', + lastUpdate: 'Resolved', + notifySubscribers: undefined, + tags: ['api:public'], + coolDownWindowMinutes: 10, + components: [{ componentId: { ref: 'email' }, targetImpact: 'MAJOR_OUTAGE' }], + }) + }) + + it('rejects an empty tag list and components from another page', async () => { + const pageA = new StatusPageV3('a', { name: 'A', url: 'a' }) + const pageB = new StatusPageV3('b', { name: 'B', url: 'b' }) + const foreign = new StatusPageV3Component('foreign', { statusPage: pageB, name: 'F', displayOrder: 1 }) + const rule = new StatusPageV3AutomationRule('rule', { + statusPage: pageA, + name: 'r', + firstUpdate: 'a', + lastUpdate: 'b', + tags: [], + components: [{ component: foreign, targetImpact: 'MAJOR_OUTAGE' }], + }) + + const diagnostics = new Diagnostics() + await rule.validate(diagnostics) + expect(diagnostics.isFatal()).toBe(true) + const messages = diagnostics.observations.map(o => o.message) + expect(messages).toEqual(expect.arrayContaining([ + expect.stringContaining('at least one tag'), + expect.stringContaining('another status page'), + ])) + }) +}) diff --git a/packages/cli/src/constructs/construct-codegen.ts b/packages/cli/src/constructs/construct-codegen.ts index c7117333c..13a65e16f 100644 --- a/packages/cli/src/constructs/construct-codegen.ts +++ b/packages/cli/src/constructs/construct-codegen.ts @@ -12,6 +12,8 @@ import { PrivateLocationCheckAssignmentCodegen } from './private-location-check- import { PrivateLocationGroupAssignmentCodegen } from './private-location-group-assignment-codegen.js' import { StatusPageServiceCodegen } from './status-page-service-codegen.js' import { StatusPageCodegen } from './status-page-codegen.js' +import { StatusPageV3AutomationRuleCodegen } from './status-page-v3-automation-rule-codegen.js' +import { StatusPageV3ComponentCodegen } from './status-page-v3-component-codegen.js' export type ResourceType = 'alert-channel-subscription' @@ -25,6 +27,8 @@ export type ResourceType = | 'private-location' | 'status-page' | 'status-page-service' + | 'status-page-component' + | 'status-page-automation-rule' interface Resource { type: ResourceType @@ -44,6 +48,9 @@ const resourceOrder: Record = { 'private-location': 910, 'status-page': 500, 'status-page-service': 510, + // Components reference their page (and parent), rules reference both. + 'status-page-component': 490, + 'status-page-automation-rule': 480, } export function sortResources (resources: Resource[]): Resource[] { @@ -66,6 +73,8 @@ export class ConstructCodegen extends Codegen { privateLocationGroupAssignmentCodegen: PrivateLocationGroupAssignmentCodegen statusPageCodegen: StatusPageCodegen statusPageServiceCodegen: StatusPageServiceCodegen + statusPageComponentCodegen: StatusPageV3ComponentCodegen + statusPageAutomationRuleCodegen: StatusPageV3AutomationRuleCodegen codegensByType: Record> constructor (program: Program) { @@ -81,6 +90,8 @@ export class ConstructCodegen extends Codegen { this.privateLocationGroupAssignmentCodegen = new PrivateLocationGroupAssignmentCodegen(program) this.statusPageCodegen = new StatusPageCodegen(program) this.statusPageServiceCodegen = new StatusPageServiceCodegen(program) + this.statusPageComponentCodegen = new StatusPageV3ComponentCodegen(program) + this.statusPageAutomationRuleCodegen = new StatusPageV3AutomationRuleCodegen(program) this.codegensByType = { 'alert-channel-subscription': this.alertChannelSubscriptionCodegen, @@ -94,6 +105,8 @@ export class ConstructCodegen extends Codegen { 'private-location': this.privateLocationCodegen, 'status-page': this.statusPageCodegen, 'status-page-service': this.statusPageServiceCodegen, + 'status-page-component': this.statusPageComponentCodegen, + 'status-page-automation-rule': this.statusPageAutomationRuleCodegen, } } diff --git a/packages/cli/src/constructs/index.ts b/packages/cli/src/constructs/index.ts index f3d0cb2a8..f4622814d 100644 --- a/packages/cli/src/constructs/index.ts +++ b/packages/cli/src/constructs/index.ts @@ -36,6 +36,9 @@ export * from './msteams-alert-channel.js' export * from './telegram-alert-channel.js' export * from './status-page.js' export * from './status-page-service.js' +export * from './status-page-v3.js' +export * from './status-page-v3-component.js' +export * from './status-page-v3-automation-rule.js' export * from './incident.js' export * from './playwright-check.js' export * from './engine.js' diff --git a/packages/cli/src/constructs/internal/codegen/context.ts b/packages/cli/src/constructs/internal/codegen/context.ts index 06beb0b8a..16f773169 100644 --- a/packages/cli/src/constructs/internal/codegen/context.ts +++ b/packages/cli/src/constructs/internal/codegen/context.ts @@ -137,6 +137,12 @@ export class Context { #statusPageServiceVariablesByPhysicalId = new Map() #statusPageServiceFriendVariablesByPhysicalId = new Map() + #statusPageVariablesByPhysicalId = new Map() + #statusPageFriendVariablesByPhysicalId = new Map() + + #statusPageComponentVariablesByPhysicalId = new Map() + #statusPageComponentFriendVariablesByPhysicalId = new Map() + #knownSecrets = new Set() #knownFilePaths = new Map() @@ -473,6 +479,68 @@ export class Context { return locator } + registerStatusPage (physicalId: string, name: string, file: GeneratedFile): GeneratedVariableLocator { + const preferredId = new IdentifierValue(formatVariable('page', name)) + const locator = new GeneratedVariableLocator(preferredId, file) + locator.id = this.#reserveIdentifierForLocator(file.path, locator) + this.#statusPageVariablesByPhysicalId.set(physicalId, locator) + return locator + } + + lookupStatusPage (physicalId: string): GeneratedVariableLocator { + const locator = this.#statusPageVariablesByPhysicalId.get(physicalId) + if (locator === undefined) { + throw new MissingContextVariableMappingError() + } + return locator + } + + registerFriendStatusPage (physicalId: string, friend: ConstructExport): FriendVariableLocator { + const id = new IdentifierValue(friend.exportName) + const locator = new FriendVariableLocator(id, friend.filePath) + this.#statusPageFriendVariablesByPhysicalId.set(physicalId, locator) + return locator + } + + lookupFriendStatusPage (physicalId: string): FriendVariableLocator { + const locator = this.#statusPageFriendVariablesByPhysicalId.get(physicalId) + if (locator === undefined) { + throw new MissingContextVariableMappingError() + } + return locator + } + + registerStatusPageComponent (physicalId: string, name: string, file: GeneratedFile): GeneratedVariableLocator { + const preferredId = new IdentifierValue(formatVariable('component', name)) + const locator = new GeneratedVariableLocator(preferredId, file) + locator.id = this.#reserveIdentifierForLocator(file.path, locator) + this.#statusPageComponentVariablesByPhysicalId.set(physicalId, locator) + return locator + } + + lookupStatusPageComponent (physicalId: string): GeneratedVariableLocator { + const locator = this.#statusPageComponentVariablesByPhysicalId.get(physicalId) + if (locator === undefined) { + throw new MissingContextVariableMappingError() + } + return locator + } + + registerFriendStatusPageComponent (physicalId: string, friend: ConstructExport): FriendVariableLocator { + const id = new IdentifierValue(friend.exportName) + const locator = new FriendVariableLocator(id, friend.filePath) + this.#statusPageComponentFriendVariablesByPhysicalId.set(physicalId, locator) + return locator + } + + lookupFriendStatusPageComponent (physicalId: string): FriendVariableLocator { + const locator = this.#statusPageComponentFriendVariablesByPhysicalId.get(physicalId) + if (locator === undefined) { + throw new MissingContextVariableMappingError() + } + return locator + } + registerKnownSecret (name: string): boolean { if (this.#knownSecrets.has(name)) { return false diff --git a/packages/cli/src/constructs/project-bundle.ts b/packages/cli/src/constructs/project-bundle.ts index e585d57f3..228ff7603 100644 --- a/packages/cli/src/constructs/project-bundle.ts +++ b/packages/cli/src/constructs/project-bundle.ts @@ -39,6 +39,11 @@ export class ProjectBundle implements Bundle { // later than resource B. ...this.synthesizeRecord(this.data['status-page-service']), ...this.synthesizeRecord(this.data['status-page']), + // v3: components reference their page (and parent group), rules + // reference the page and components. Declaration order keeps parents + // before children within components. + ...this.synthesizeRecord(this.data['status-page-component']), + ...this.synthesizeRecord(this.data['status-page-automation-rule']), ...this.synthesizeRecord(this.data['check-group']), ...this.synthesizeRecord(this.data.check), ...this.synthesizeRecord(this.data['alert-channel']), diff --git a/packages/cli/src/constructs/project.ts b/packages/cli/src/constructs/project.ts index eeea225aa..bb6547d63 100644 --- a/packages/cli/src/constructs/project.ts +++ b/packages/cli/src/constructs/project.ts @@ -7,6 +7,7 @@ import { Check, AlertChannelSubscription, AlertChannel, CheckGroup, MaintenanceWindow, Dashboard, PrivateLocation, HeartbeatMonitor, PrivateLocationCheckAssignment, PrivateLocationGroupAssignment, StatusPage, StatusPageService, PlaywrightCheck, + StatusPageV3, StatusPageV3Component, StatusPageV3AutomationRule, } from './/index.js' import { Diagnostics, WarningDiagnostic } from './diagnostics.js' import { @@ -47,8 +48,10 @@ export type Resources = { 'private-location-check-assignment': PrivateLocationCheckAssignment 'private-location-group-assignment': PrivateLocationGroupAssignment 'dashboard': Dashboard - 'status-page': StatusPage + 'status-page': StatusPage | StatusPageV3 'status-page-service': StatusPageService + 'status-page-component': StatusPageV3Component + 'status-page-automation-rule': StatusPageV3AutomationRule } export type ProjectData = { @@ -72,6 +75,8 @@ export class Project extends Construct { 'dashboard': {}, 'status-page': {}, 'status-page-service': {}, + 'status-page-component': {}, + 'status-page-automation-rule': {}, } static readonly __checklyType = 'project' diff --git a/packages/cli/src/constructs/status-page-codegen.ts b/packages/cli/src/constructs/status-page-codegen.ts index aafeae441..9b7eee583 100644 --- a/packages/cli/src/constructs/status-page-codegen.ts +++ b/packages/cli/src/constructs/status-page-codegen.ts @@ -1,7 +1,8 @@ import { Codegen, Context } from './internal/codegen/index.js' -import { expr, ident } from '../sourcegen/index.js' +import { expr, ident, Program } from '../sourcegen/index.js' import { StatusPageServiceResource, valueForStatusPageServiceFromId } from './status-page-service-codegen.js' import { StatusPageTheme } from './status-page.js' +import { StatusPageV3Codegen, StatusPageV3Resource } from './status-page-v3-codegen.js' export interface StatusPageCardResource { id: string @@ -9,10 +10,11 @@ export interface StatusPageCardResource { services: StatusPageServiceResource[] } -export interface StatusPageResource { +export interface StatusPageV2Resource { id: string name: string url: string + version?: 2 cards: StatusPageCardResource[] customDomain?: string logo?: string @@ -21,14 +23,44 @@ export interface StatusPageResource { defaultTheme?: StatusPageTheme } +// Both generations share the `status-page` resource type; the payload's +// `version` tells them apart. +export type StatusPageResource = StatusPageV2Resource | StatusPageV3Resource + +function isV3 (resource: StatusPageResource): resource is StatusPageV3Resource { + return resource.version === 3 +} + const construct = 'StatusPage' export class StatusPageCodegen extends Codegen { + v3Codegen: StatusPageV3Codegen + + constructor (program: Program) { + super(program) + this.v3Codegen = new StatusPageV3Codegen(program) + } + describe (resource: StatusPageResource): string { + if (isV3(resource)) { + return this.v3Codegen.describe(resource) + } + return `Status Page: ${resource.name}` } + prepare (logicalId: string, resource: StatusPageResource, context: Context): void { + if (isV3(resource)) { + this.v3Codegen.prepare(logicalId, resource, context) + } + } + gencode (logicalId: string, resource: StatusPageResource, context: Context): void { + if (isV3(resource)) { + this.v3Codegen.gencode(logicalId, resource, context) + return + } + const filePath = context.filePath('resources/status-pages', resource.name, { unique: true, }) diff --git a/packages/cli/src/constructs/status-page-service.ts b/packages/cli/src/constructs/status-page-service.ts index 8a4361b92..deb2bf09c 100644 --- a/packages/cli/src/constructs/status-page-service.ts +++ b/packages/cli/src/constructs/status-page-service.ts @@ -38,7 +38,15 @@ export class StatusPageServiceRef extends Construct { } /** - * Creates a Service for Status Pages + * Creates a Service for Status Pages. + * + * We strongly recommend upgrading to {@link StatusPageV3} and using {@link StatusPageV3Component}. + * + * The original Status Page works with cards and services and per-check + * incident automations. The new, v3 Status Page works with components, a simpler + * impact based system and automations directly controllable from a status page. + * + * @deprecated Use {@link StatusPageV3Component} instead. */ export class StatusPageService extends Construct { name: string diff --git a/packages/cli/src/constructs/status-page-v3-automation-rule-codegen.ts b/packages/cli/src/constructs/status-page-v3-automation-rule-codegen.ts new file mode 100644 index 000000000..f3a7c3578 --- /dev/null +++ b/packages/cli/src/constructs/status-page-v3-automation-rule-codegen.ts @@ -0,0 +1,81 @@ +import { Codegen, Context } from './internal/codegen/index.js' +import { expr, ident } from '../sourcegen/index.js' +import { StatusPageV3TargetImpact } from './status-page-v3-automation-rule.js' +import { valueForStatusPageV3Ref } from './status-page-v3-codegen.js' +import { valueForStatusPageV3ComponentRef } from './status-page-v3-component-codegen.js' + +export interface StatusPageV3AutomationRuleComponentResource { + componentId: string + targetImpact: StatusPageV3TargetImpact +} + +export interface StatusPageV3AutomationRuleResource { + id: string + statusPageId: string + name: string + enabled?: boolean | null + firstUpdate: string + lastUpdate: string + notifySubscribers?: boolean | null + tags: string[] + coolDownWindowMinutes?: number | null + components: StatusPageV3AutomationRuleComponentResource[] +} + +const construct = 'StatusPageV3AutomationRule' + +export class StatusPageV3AutomationRuleCodegen extends Codegen { + describe (resource: StatusPageV3AutomationRuleResource): string { + return `Status Page Automation Rule: ${resource.name}` + } + + gencode (logicalId: string, resource: StatusPageV3AutomationRuleResource, context: Context): void { + const filePath = context.filePath('resources/status-pages/automation-rules', resource.name, { + unique: true, + }) + + const file = this.program.generatedConstructFile(filePath.fullPath) + + file.namedImport(construct, 'checkly/constructs') + + file.section(expr(ident(construct), builder => { + builder.new(builder => { + builder.string(logicalId) + builder.object(builder => { + builder.value('statusPage', valueForStatusPageV3Ref(file, resource.statusPageId, context)) + builder.string('name', resource.name) + + if (resource.enabled === false) { + builder.boolean('enabled', false) + } + + builder.string('firstUpdate', resource.firstUpdate) + builder.string('lastUpdate', resource.lastUpdate) + + if (resource.notifySubscribers === false) { + builder.boolean('notifySubscribers', false) + } + + builder.array('tags', builder => { + for (const tag of resource.tags) { + builder.string(tag) + } + }) + + if (resource.coolDownWindowMinutes !== undefined && resource.coolDownWindowMinutes !== null) { + builder.number('coolDownMinutes', resource.coolDownWindowMinutes) + } + + builder.array('components', builder => { + for (const { componentId, targetImpact } of resource.components) { + builder.object(builder => { + builder.value('component', valueForStatusPageV3ComponentRef(file, componentId, context)) + builder.string('targetImpact', targetImpact) + }) + } + }) + }) + }) + })) + } +} diff --git a/packages/cli/src/constructs/status-page-v3-automation-rule.ts b/packages/cli/src/constructs/status-page-v3-automation-rule.ts new file mode 100644 index 000000000..dcde043d1 --- /dev/null +++ b/packages/cli/src/constructs/status-page-v3-automation-rule.ts @@ -0,0 +1,175 @@ +import { Construct } from './construct.js' +import { InvalidPropertyValueDiagnostic } from './construct-diagnostics.js' +import { Diagnostics } from './diagnostics.js' +import { Ref } from './ref.js' +import { Session } from './session.js' +import { StatusPageV3, StatusPageV3Ref } from './status-page-v3.js' +import { StatusPageV3Component, StatusPageV3ComponentRef } from './status-page-v3-component.js' + +/** + * The impact an automated incident sets on a component. Every non-operational + * component status. + */ +export type StatusPageV3TargetImpact = + | 'UNDER_MAINTENANCE' + | 'DEGRADED_PERFORMANCE' + | 'PARTIAL_OUTAGE' + | 'MAJOR_OUTAGE' + +export interface StatusPageV3AutomationRuleComponentProps { + /** + * The component the automated incident impacts. + */ + component: StatusPageV3Component | StatusPageV3ComponentRef + /** + * The impact set on the component while the incident is open. + */ + targetImpact: StatusPageV3TargetImpact +} + +export interface StatusPageV3AutomationRuleProps { + /** + * The v3 status page this rule belongs to. + */ + statusPage: StatusPageV3 | StatusPageV3Ref + /** + * The name of the rule. + */ + name: string + /** + * A disabled rule never opens incidents. Defaults to true. + */ + enabled?: boolean + /** + * Body of the status update that opens the incident. + */ + firstUpdate: string + /** + * Body of the status update that resolves the incident. + */ + lastUpdate: string + /** + * Whether subscribers are notified of the automated updates. Defaults to true. + */ + notifySubscribers?: boolean + /** + * A failing check matches this rule when it, or its group, carries ANY of + * these tags. At least one tag is required. + */ + tags: string[] + /** + * Minimum minutes after an automated incident before this rule may open + * the next one. 0 disables the cool down. Defaults to 5. + */ + coolDownMinutes?: number + /** + * The components an automated incident impacts, with the impact each gets. + */ + components: StatusPageV3AutomationRuleComponentProps[] +} + +/** + * Creates an Automation Rule for a v3 Status Page. + * + * When a check whose tags overlap with the rule's tags fails, Checkly opens + * one incident on the page impacting the listed components, and resolves it + * when the check recovers. + */ +export class StatusPageV3AutomationRule extends Construct { + statusPage: StatusPageV3 | StatusPageV3Ref + name: string + enabled?: boolean + firstUpdate: string + lastUpdate: string + notifySubscribers?: boolean + tags: string[] + coolDownMinutes?: number + components: StatusPageV3AutomationRuleComponentProps[] + + static readonly __checklyType = 'status-page-automation-rule' + + /** + * Constructs the Automation Rule instance + * + * @param logicalId unique project-scoped resource name identification + * @param props automation rule configuration properties + * + * {@link https://www.checklyhq.com/docs/constructs/status-page-v3-automation-rule/ Read more in the docs} + */ + constructor (logicalId: string, props: StatusPageV3AutomationRuleProps) { + super(StatusPageV3AutomationRule.__checklyType, logicalId) + this.statusPage = props.statusPage + this.name = props.name + this.enabled = props.enabled + this.firstUpdate = props.firstUpdate + this.lastUpdate = props.lastUpdate + this.notifySubscribers = props.notifySubscribers + this.tags = props.tags + this.coolDownMinutes = props.coolDownMinutes + this.components = props.components + + Session.registerConstruct(this) + } + + describe (): string { + return `StatusPageV3AutomationRule:${this.logicalId}` + } + + async validate (diagnostics: Diagnostics): Promise { + await super.validate(diagnostics) + + if (!(this.statusPage instanceof StatusPageV3) && !(this.statusPage instanceof StatusPageV3Ref)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'statusPage', + new Error('Value must be a StatusPageV3 construct or StatusPageV3.fromId().'), + )) + } + + if (!Array.isArray(this.tags) || this.tags.length === 0) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'tags', + new Error('Value must contain at least one tag.'), + )) + } + + if (this.coolDownMinutes !== undefined && (!Number.isInteger(this.coolDownMinutes) || this.coolDownMinutes < 0)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'coolDownMinutes', + new Error('Value must be a non-negative integer.'), + )) + } + + for (const { component, targetImpact } of this.components ?? []) { + if (targetImpact === ('OPERATIONAL' as string)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'components', + new Error(`"${component.logicalId}": targetImpact cannot be OPERATIONAL.`), + )) + } + // A referenced component (fromId) can only be checked on the backend. + if (component instanceof StatusPageV3Component && component.statusPage !== this.statusPage) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'components', + new Error(`"${component.logicalId}" belongs to another status page than this rule.`), + )) + } + } + } + + synthesize (): any | null { + return { + statusPageId: Ref.from(this.statusPage.logicalId), + name: this.name, + enabled: this.enabled, + firstUpdate: this.firstUpdate, + lastUpdate: this.lastUpdate, + notifySubscribers: this.notifySubscribers, + tags: this.tags, + coolDownWindowMinutes: this.coolDownMinutes, + components: this.components.map(({ component, targetImpact }) => ({ + componentId: Ref.from(component.logicalId), + targetImpact, + })), + } + } +} diff --git a/packages/cli/src/constructs/status-page-v3-codegen.ts b/packages/cli/src/constructs/status-page-v3-codegen.ts new file mode 100644 index 000000000..ceae9fd8a --- /dev/null +++ b/packages/cli/src/constructs/status-page-v3-codegen.ts @@ -0,0 +1,148 @@ +import { Codegen, Context } from './internal/codegen/index.js' +import { decl, expr, GeneratedFile, ident, Value } from '../sourcegen/index.js' +import { StatusPageTheme } from './status-page.js' + +export interface StatusPageV3Resource { + id: string + name: string + url: string + version: 3 + customDomain?: string | null + description?: string | null + logo?: string | null + logoDark?: string | null + redirectTo?: string | null + favicon?: string | null + defaultTheme?: StatusPageTheme | null + privacyPolicyLink?: string | null + termsOfServiceLink?: string | null + footerText?: string | null + googleAnalyticsTag?: string | null + allowIndexing?: boolean | null +} + +const construct = 'StatusPageV3' + +export function valueForStatusPageV3FromId (genfile: GeneratedFile, physicalId: string): Value { + genfile.namedImport(construct, 'checkly/constructs') + + return expr(ident(construct), builder => { + builder.member(ident('fromId')) + builder.call(builder => { + builder.string(physicalId) + }) + }) +} + +/** + * Resolves a reference to a v3 status page: an imported page, a page already + * declared in the project (friend), or a `fromId()` reference as fallback. + */ +export function valueForStatusPageV3Ref ( + genfile: GeneratedFile, + physicalId: string, + context: Context, +): Value { + try { + const variable = context.lookupStatusPage(physicalId) + return context.importVariable(variable, genfile) + } catch { + try { + const variable = context.lookupFriendStatusPage(physicalId) + return context.importFriendVariable(variable, genfile) + } catch { + return valueForStatusPageV3FromId(genfile, physicalId) + } + } +} + +/** + * v2 and v3 pages share the `status-page` resource type. `StatusPageCodegen` + * dispatches here when the payload carries `version: 3`. + */ +export class StatusPageV3Codegen extends Codegen { + describe (resource: StatusPageV3Resource): string { + return `Status Page (v3): ${resource.name}` + } + + prepare (logicalId: string, resource: StatusPageV3Resource, context: Context): void { + const filePath = context.filePath('resources/status-pages', resource.name, { + unique: true, + }) + + context.registerStatusPage( + resource.id, + resource.name, + this.program.generatedConstructFile(filePath.fullPath), + ) + } + + gencode (logicalId: string, resource: StatusPageV3Resource, context: Context): void { + const { id, file } = context.lookupStatusPage(resource.id) + + file.namedImport(construct, 'checkly/constructs') + + file.section(decl(id, builder => { + builder.variable(expr(ident(construct), builder => { + builder.new(builder => { + builder.string(logicalId) + builder.object(builder => { + builder.string('name', resource.name) + builder.string('url', resource.url) + + if (resource.customDomain) { + builder.string('customDomain', resource.customDomain) + } + + if (resource.description) { + builder.string('description', resource.description) + } + + if (resource.logo) { + builder.string('logo', resource.logo) + } + + if (resource.logoDark) { + builder.string('logoDark', resource.logoDark) + } + + if (resource.redirectTo) { + builder.string('redirectTo', resource.redirectTo) + } + + if (resource.favicon) { + builder.string('favicon', resource.favicon) + } + + if (resource.defaultTheme) { + builder.string('defaultTheme', resource.defaultTheme) + } + + if (resource.privacyPolicyLink) { + builder.string('privacyPolicyLink', resource.privacyPolicyLink) + } + + if (resource.termsOfServiceLink) { + builder.string('termsOfServiceLink', resource.termsOfServiceLink) + } + + if (resource.footerText) { + builder.string('footerText', resource.footerText) + } + + if (resource.googleAnalyticsTag) { + builder.string('googleAnalyticsTag', resource.googleAnalyticsTag) + } + + // Indexing is on by default; only the opt-out is worth spelling out. + if (resource.allowIndexing === false) { + builder.boolean('allowIndexing', false) + } + }) + }) + })) + + builder.export() + })) + } +} diff --git a/packages/cli/src/constructs/status-page-v3-component-codegen.ts b/packages/cli/src/constructs/status-page-v3-component-codegen.ts new file mode 100644 index 000000000..5b4f74020 --- /dev/null +++ b/packages/cli/src/constructs/status-page-v3-component-codegen.ts @@ -0,0 +1,108 @@ +import { Codegen, Context } from './internal/codegen/index.js' +import { decl, expr, GeneratedFile, ident, Value } from '../sourcegen/index.js' +import { StatusPageV3ComponentType } from './status-page-v3-component.js' +import { valueForStatusPageV3Ref } from './status-page-v3-codegen.js' + +export interface StatusPageV3ComponentResource { + id: string + statusPageId: string + parentId?: string | null + type: StatusPageV3ComponentType + name: string + description?: string | null + hidden?: boolean | null + displayOrder: number +} + +const construct = 'StatusPageV3Component' + +export function valueForStatusPageV3ComponentFromId (genfile: GeneratedFile, physicalId: string): Value { + genfile.namedImport(construct, 'checkly/constructs') + + return expr(ident(construct), builder => { + builder.member(ident('fromId')) + builder.call(builder => { + builder.string(physicalId) + }) + }) +} + +/** + * Resolves a reference to a component: an imported component, one already + * declared in the project (friend), or a `fromId()` reference as fallback. + */ +export function valueForStatusPageV3ComponentRef ( + genfile: GeneratedFile, + physicalId: string, + context: Context, +): Value { + try { + const variable = context.lookupStatusPageComponent(physicalId) + return context.importVariable(variable, genfile) + } catch { + try { + const variable = context.lookupFriendStatusPageComponent(physicalId) + return context.importFriendVariable(variable, genfile) + } catch { + return valueForStatusPageV3ComponentFromId(genfile, physicalId) + } + } +} + +export class StatusPageV3ComponentCodegen extends Codegen { + describe (resource: StatusPageV3ComponentResource): string { + return `Status Page Component: ${resource.name}` + } + + prepare (logicalId: string, resource: StatusPageV3ComponentResource, context: Context): void { + const filePath = context.filePath('resources/status-pages/components', resource.name, { + unique: true, + }) + + context.registerStatusPageComponent( + resource.id, + resource.name, + this.program.generatedConstructFile(filePath.fullPath), + ) + } + + gencode (logicalId: string, resource: StatusPageV3ComponentResource, context: Context): void { + const { id, file } = context.lookupStatusPageComponent(resource.id) + + file.namedImport(construct, 'checkly/constructs') + + file.section(decl(id, builder => { + builder.variable(expr(ident(construct), builder => { + builder.new(builder => { + builder.string(logicalId) + builder.object(builder => { + builder.value('statusPage', valueForStatusPageV3Ref(file, resource.statusPageId, context)) + + // SERVICE is the construct's default. + if (resource.type === 'GROUP') { + builder.string('type', resource.type) + } + + builder.string('name', resource.name) + + if (resource.description) { + builder.string('description', resource.description) + } + + if (resource.hidden === true) { + builder.boolean('hidden', true) + } + + builder.number('displayOrder', resource.displayOrder) + + if (resource.parentId) { + builder.value('parent', valueForStatusPageV3ComponentRef(file, resource.parentId, context)) + } + }) + }) + })) + + builder.export() + })) + } +} diff --git a/packages/cli/src/constructs/status-page-v3-component.ts b/packages/cli/src/constructs/status-page-v3-component.ts new file mode 100644 index 000000000..824dfb298 --- /dev/null +++ b/packages/cli/src/constructs/status-page-v3-component.ts @@ -0,0 +1,177 @@ +import { Construct } from './construct.js' +import { InvalidPropertyValueDiagnostic } from './construct-diagnostics.js' +import { Diagnostics } from './diagnostics.js' +import { validatePhysicalIdIsUuid } from './internal/common-diagnostics.js' +import { Ref } from './ref.js' +import { Session } from './session.js' +import { StatusPageV3, StatusPageV3Ref } from './status-page-v3.js' + +export type StatusPageV3ComponentType = 'SERVICE' | 'GROUP' + +export interface StatusPageV3ComponentProps { + /** + * The v3 status page this component belongs to. A component belongs to + * exactly one page and cannot be moved to another one later. + */ + statusPage: StatusPageV3 | StatusPageV3Ref + /** + * `SERVICE` (a monitored thing with its own status) or `GROUP` (a + * container for other components). Defaults to `SERVICE`. + */ + type?: StatusPageV3ComponentType + /** + * The name shown on the status page. + */ + name: string + /** + * An optional description shown next to the name. + */ + description?: string + /** + * Hide the component from the public page while keeping it available for + * incidents and automation. Defaults to false. + */ + hidden?: boolean + /** + * Position among its siblings; lower comes first. + */ + displayOrder: number + /** + * The GROUP component to nest this component under. Must be on the same + * status page. + */ + parent?: StatusPageV3Component | StatusPageV3ComponentRef +} + +/** + * Creates a reference to an existing v3 Status Page Component. + * + * Use {@link StatusPageV3Component.fromId()} instead of instantiating this class directly. + */ +export class StatusPageV3ComponentRef extends Construct { + constructor (logicalId: string, physicalId: string) { + super(StatusPageV3Component.__checklyType, logicalId, physicalId, false) + Session.registerConstruct(this) + } + + describe (): string { + return `StatusPageV3ComponentRef:${this.logicalId}` + } + + async validate (diagnostics: Diagnostics): Promise { + await super.validate(diagnostics) + await validatePhysicalIdIsUuid(diagnostics, 'StatusPageV3Component', this.physicalId) + } + + synthesize () { + return null + } +} + +/** + * Creates a Component of a v3 Status Page + */ +export class StatusPageV3Component extends Construct { + statusPage: StatusPageV3 | StatusPageV3Ref + // Not `type`: that is the Construct's resource-type key. + componentType: StatusPageV3ComponentType + name: string + description?: string + hidden?: boolean + displayOrder: number + parent?: StatusPageV3Component | StatusPageV3ComponentRef + + static readonly __checklyType = 'status-page-component' + + /** + * Constructs the Status Page Component instance + * + * @param logicalId unique project-scoped resource name identification + * @param props component configuration properties + * + * {@link https://www.checklyhq.com/docs/constructs/status-page-v3-component/ Read more in the docs} + */ + constructor (logicalId: string, props: StatusPageV3ComponentProps) { + super(StatusPageV3Component.__checklyType, logicalId) + this.statusPage = props.statusPage + this.componentType = props.type ?? 'SERVICE' + this.name = props.name + this.description = props.description + this.hidden = props.hidden + this.displayOrder = props.displayOrder + this.parent = props.parent + + Session.registerConstruct(this) + } + + describe (): string { + return `StatusPageV3Component:${this.logicalId}` + } + + /** + * @param id - The UUID of the existing status page component + */ + static fromId (id: string) { + return new StatusPageV3ComponentRef(`status-page-component-${id}`, id) + } + + async validate (diagnostics: Diagnostics): Promise { + await super.validate(diagnostics) + + if (!(this.statusPage instanceof StatusPageV3) && !(this.statusPage instanceof StatusPageV3Ref)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'statusPage', + new Error('Value must be a StatusPageV3 construct or StatusPageV3.fromId().'), + )) + } + + if (this.componentType !== 'SERVICE' && this.componentType !== 'GROUP') { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'type', + new Error(`Value must be "SERVICE" or "GROUP".`), + )) + } + + if (this.hidden !== undefined && typeof this.hidden !== 'boolean') { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'hidden', + new Error('Value must be a boolean.'), + )) + } + + if (!Number.isInteger(this.displayOrder)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'displayOrder', + new Error('Value must be an integer.'), + )) + } + + // A referenced parent (fromId) can only be checked on the backend. + if (this.parent instanceof StatusPageV3Component) { + if (this.parent.componentType !== 'GROUP') { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'parent', + new Error(`Value must be a GROUP component ("${this.parent.logicalId}" is a ${this.parent.componentType}).`), + )) + } + if (this.parent.statusPage !== this.statusPage) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'parent', + new Error(`Value must be a component of the same status page ("${this.parent.logicalId}" belongs to another page).`), + )) + } + } + } + + synthesize (): any | null { + return { + statusPageId: Ref.from(this.statusPage.logicalId), + parentId: this.parent ? Ref.from(this.parent.logicalId) : null, + type: this.componentType, + name: this.name, + description: this.description, + hidden: this.hidden, + displayOrder: this.displayOrder, + } + } +} diff --git a/packages/cli/src/constructs/status-page-v3.ts b/packages/cli/src/constructs/status-page-v3.ts new file mode 100644 index 000000000..af2b64ed3 --- /dev/null +++ b/packages/cli/src/constructs/status-page-v3.ts @@ -0,0 +1,183 @@ +import { Construct } from './construct.js' +import { Diagnostics } from './diagnostics.js' +import { validatePhysicalIdIsUuid } from './internal/common-diagnostics.js' +import { Session } from './session.js' +import type { StatusPageTheme } from './status-page.js' + +export interface StatusPageV3Props { + /** + * The name of the status page. + */ + name: string + /** + * The URL of the status page. + */ + url: string + /** + * A custom user domain, e.g. "status.example.com". See the docs on updating your DNS and SSL usage. + */ + customDomain?: string + /** + * A short description shown on the status page. + */ + description?: string + /** + * A URL pointing to an image file that serves as the logo for the status page. + */ + logo?: string + /** + * A URL pointing to an image file that serves as the logo in dark mode. + */ + logoDark?: string + /** + * The URL that clicking the logo should redirect the user to. + */ + redirectTo?: string + /** + * A URL pointing to an image file to be used as the favicon for the status page. + */ + favicon?: string + /** + * The default theme of the status page. + */ + defaultTheme?: StatusPageTheme + /** + * A link to your privacy policy, shown in the page footer. + */ + privacyPolicyLink?: string + /** + * A link to your terms of service, shown in the page footer. + */ + termsOfServiceLink?: string + /** + * Free-form footer text. + */ + footerText?: string + /** + * A Google Analytics tag ID (e.g. "G-XXXXXXXXXX") to embed on the public page. + */ + googleAnalyticsTag?: string + /** + * Whether search engines may index the public page. Defaults to true. + */ + allowIndexing?: boolean +} + +/** + * Creates a reference to an existing v3 Status Page. + * + * References link existing resources to a project without managing them — + * typically so components and automation rules declared in code can attach + * to a page that was created in the UI. + * + * Use {@link StatusPageV3.fromId()} instead of instantiating this class directly. + */ +export class StatusPageV3Ref extends Construct { + constructor (logicalId: string, physicalId: string) { + super(StatusPageV3.__checklyType, logicalId, physicalId, false) + Session.registerConstruct(this) + } + + describe (): string { + return `StatusPageV3Ref:${this.logicalId}` + } + + async validate (diagnostics: Diagnostics): Promise { + await super.validate(diagnostics) + await validatePhysicalIdIsUuid(diagnostics, 'StatusPageV3', this.physicalId) + } + + synthesize () { + return null + } +} + +/** + * Creates a v3 (components-based) Status Page. + * + * Unlike {@link StatusPage}, a v3 page has no cards or services. Its structure + * is declared with {@link StatusPageV3Component} constructs that point at the + * page, and incidents can be automated with {@link StatusPageV3AutomationRule}. + * + * A page's generation cannot change in place: a logical id that was deployed + * as a `StatusPage` cannot be redeployed as a `StatusPageV3` (or vice versa). + */ +export class StatusPageV3 extends Construct { + name: string + url: string + customDomain?: string + description?: string + logo?: string + logoDark?: string + redirectTo?: string + favicon?: string + defaultTheme?: StatusPageTheme + privacyPolicyLink?: string + termsOfServiceLink?: string + footerText?: string + googleAnalyticsTag?: string + allowIndexing?: boolean + + // Same resource type as the v2 page: both live in one table and are told + // apart by the `version` discriminator synthesized below. + static readonly __checklyType = 'status-page' + + /** + * Constructs the v3 Status Page instance + * + * @param logicalId unique project-scoped resource name identification + * @param props status page configuration properties + * + * {@link https://www.checklyhq.com/docs/constructs/status-page-v3/ Read more in the docs} + */ + constructor (logicalId: string, props: StatusPageV3Props) { + super(StatusPageV3.__checklyType, logicalId) + this.name = props.name + this.url = props.url + this.customDomain = props.customDomain + this.description = props.description + this.logo = props.logo + this.logoDark = props.logoDark + this.redirectTo = props.redirectTo + this.favicon = props.favicon + this.defaultTheme = props.defaultTheme + this.privacyPolicyLink = props.privacyPolicyLink + this.termsOfServiceLink = props.termsOfServiceLink + this.footerText = props.footerText + this.googleAnalyticsTag = props.googleAnalyticsTag + this.allowIndexing = props.allowIndexing + + Session.registerConstruct(this) + } + + describe (): string { + return `StatusPageV3:${this.logicalId}` + } + + /** + * @param id - The UUID of the existing v3 status page + */ + static fromId (id: string) { + return new StatusPageV3Ref(`status-page-${id}`, id) + } + + synthesize (): any | null { + return { + name: this.name, + url: this.url, + customDomain: this.customDomain, + description: this.description, + logo: this.logo, + logoDark: this.logoDark, + redirectTo: this.redirectTo, + favicon: this.favicon, + defaultTheme: this.defaultTheme, + privacyPolicyLink: this.privacyPolicyLink, + termsOfServiceLink: this.termsOfServiceLink, + footerText: this.footerText, + googleAnalyticsTag: this.googleAnalyticsTag, + allowIndexing: this.allowIndexing, + version: 3, + } + } +} diff --git a/packages/cli/src/constructs/status-page.ts b/packages/cli/src/constructs/status-page.ts index 6380fa7f2..311e29e8b 100644 --- a/packages/cli/src/constructs/status-page.ts +++ b/packages/cli/src/constructs/status-page.ts @@ -2,6 +2,8 @@ import { Construct } from './construct.js' import { Session } from './session.js' import { StatusPageService } from './status-page-service.js' import { Ref } from './ref.js' +import { Diagnostics } from './diagnostics.js' +import { DeprecatedConstructDiagnostic } from './construct-diagnostics.js' export interface StatusPageCardProps { /** @@ -52,7 +54,15 @@ export interface StatusPageProps { } /** - * Creates a Status Page + * Creates a Status Page. + * + * We strongly recommend upgrading to {@link StatusPageV3}. + * + * The original Status Page works with cards and services and per-check + * incident automations. The new, v3 Status Page works with components, a simpler + * impact based system and automations directly controllable from a status page. + * + * @deprecated Use {@link StatusPageV3} instead. */ export class StatusPage extends Construct { name: string @@ -92,6 +102,19 @@ export class StatusPage extends Construct { return `StatusPage:${this.logicalId}` } + // eslint-disable-next-line require-await + protected async onBeforeValidate (diagnostics: Diagnostics): Promise { + diagnostics.add(new DeprecatedConstructDiagnostic( + 'StatusPage', + new Error('Please update to StatusPageV3 which is simpler to use and has more, advanced features.'), + )) + } + + async validate (diagnostics: Diagnostics): Promise { + await super.validate(diagnostics) + await this.onBeforeValidate(diagnostics) + } + synthesize (): any | null { return { name: this.name, diff --git a/packages/cli/src/rest/projects.ts b/packages/cli/src/rest/projects.ts index 4ee94251a..abb7b1cb6 100644 --- a/packages/cli/src/rest/projects.ts +++ b/packages/cli/src/rest/projects.ts @@ -52,11 +52,25 @@ export interface StatusPageServiceFriendResource { physicalId: string } +export interface StatusPageFriendResource { + type: 'status-page' + logicalId: string + physicalId: string +} + +export interface StatusPageComponentFriendResource { + type: 'status-page-component' + logicalId: string + physicalId: string +} + export type FriendResourceSync = AlertChannelFriendResource | CheckGroupFriendResource | PrivateLocationFriendResource | StatusPageServiceFriendResource + | StatusPageFriendResource + | StatusPageComponentFriendResource export interface AuxiliaryResourceSync { physicalId?: string | number