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 d2b12b13..5bfe0172 100644 --- a/packages/cli/src/ai-context/references/configure-supporting-constructs.md +++ b/packages/cli/src/ai-context/references/configure-supporting-constructs.md @@ -21,6 +21,7 @@ - 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. +- Type-specific settings are top-level props checked against `type`: both types take `showHistoricalData` (default `true`); only a `GROUP` takes `expandedByDefault` (default `false`). Omit them to keep the defaults. ```ts import { StatusPageV3, StatusPageV3AutomationRule, StatusPageV3Component } from 'checkly/constructs' @@ -37,6 +38,7 @@ const webApp = new StatusPageV3Component('example-web-app-group', { type: 'GROUP', name: 'Web application', displayOrder: 1, + expandedByDefault: true, }) const signUp = new StatusPageV3Component('example-sign-up-service', { @@ -46,6 +48,7 @@ const signUp = new StatusPageV3Component('example-sign-up-service', { name: 'Sign up', description: 'The sign up flow', displayOrder: 1, + showHistoricalData: false, }) new StatusPageV3AutomationRule('example-api-down-rule', { 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 index c3027381..6ecf5a20 100644 --- a/packages/cli/src/constructs/__tests__/status-page-v3-codegen.spec.ts +++ b/packages/cli/src/constructs/__tests__/status-page-v3-codegen.spec.ts @@ -26,6 +26,7 @@ const page: StatusPageV3Resource = { description: 'All systems', defaultTheme: 'DARK', allowIndexing: false, + supportLink: 'https://acme.example/support', } const group: StatusPageV3ComponentResource = { @@ -35,6 +36,7 @@ const group: StatusPageV3ComponentResource = { type: 'GROUP', name: 'Platform', displayOrder: 0, + configuration: { expandedByDefault: true, showHistoricalData: true }, } const service: StatusPageV3ComponentResource = { @@ -46,6 +48,7 @@ const service: StatusPageV3ComponentResource = { description: 'REST API', hidden: true, displayOrder: 1, + configuration: { showHistoricalData: false }, } const rule: StatusPageV3AutomationRuleResource = { @@ -114,6 +117,7 @@ describe('StatusPageV3 codegen', () => { expect(pageSource).toContain('description: \'All systems\'') expect(pageSource).toContain('defaultTheme: \'DARK\'') expect(pageSource).toContain('allowIndexing: false') + expect(pageSource).toContain('supportLink: \'https://acme.example/support\'') expect(pageSource).not.toContain('cards') const groupSource = sources['resources/status-pages/components/platform.check.ts'] @@ -122,6 +126,10 @@ describe('StatusPageV3 codegen', () => { expect(groupSource).toContain('statusPage: acmeStatusPage') expect(groupSource).toContain('type: \'GROUP\'') expect(groupSource).toContain('displayOrder: 0') + expect(groupSource).toContain('expandedByDefault: true') + expect(groupSource).not.toContain('configuration') + // Only the value that differs from the backend default is generated. + expect(groupSource).not.toContain('showHistoricalData') expect(groupSource).not.toContain('parent:') const serviceSource = sources['resources/status-pages/components/public-api.check.ts'] @@ -129,6 +137,7 @@ describe('StatusPageV3 codegen', () => { expect(serviceSource).toContain('parent: platformComponent') expect(serviceSource).toContain('hidden: true') expect(serviceSource).toContain('description: \'REST API\'') + expect(serviceSource).toContain('showHistoricalData: false') // SERVICE is the default and is left implicit. expect(serviceSource).not.toContain('type: \'SERVICE\'') @@ -143,6 +152,28 @@ describe('StatusPageV3 codegen', () => { expect(ruleSource).toContain('targetImpact: \'MAJOR_OUTAGE\'') }) + it('leaves settings that only restate the backend defaults implicit', async () => { + const sources = await generate(rootDirectory, [ + { + type: 'status-page-component', + logicalId: 'platform', + payload: { ...group, configuration: { expandedByDefault: false, showHistoricalData: true } }, + }, + { + type: 'status-page-component', + logicalId: 'public-api', + payload: { ...service, configuration: { showHistoricalData: true } }, + }, + { type: 'status-page', logicalId: 'acme', payload: page }, + ]) + + for (const file of ['platform', 'public-api']) { + const source = sources[`resources/status-pages/components/${file}.check.ts`] + expect(source).not.toContain('expandedByDefault') + expect(source).not.toContain('showHistoricalData') + } + }) + it('falls back to fromId() references for resources outside the plan', async () => { const sources = await generate(rootDirectory, [ { diff --git a/packages/cli/src/constructs/__tests__/status-page-v3.spec.ts b/packages/cli/src/constructs/__tests__/status-page-v3.spec.ts index c69c9443..acd34155 100644 --- a/packages/cli/src/constructs/__tests__/status-page-v3.spec.ts +++ b/packages/cli/src/constructs/__tests__/status-page-v3.spec.ts @@ -24,6 +24,7 @@ describe('StatusPageV3', () => { url: 'acme-status', defaultTheme: 'DARK', termsOfServiceLink: 'https://acme.example/terms', + supportLink: 'https://acme.example/support', allowIndexing: false, }) @@ -33,6 +34,7 @@ describe('StatusPageV3', () => { url: 'acme-status', defaultTheme: 'DARK', termsOfServiceLink: 'https://acme.example/terms', + supportLink: 'https://acme.example/support', allowIndexing: false, version: 3, })) @@ -91,6 +93,7 @@ describe('StatusPageV3Component', () => { description: undefined, hidden: undefined, displayOrder: 1, + configuration: undefined, }) expect(service.synthesize()).toEqual(expect.objectContaining({ statusPageId: { ref: 'acme' }, @@ -106,6 +109,61 @@ describe('StatusPageV3Component', () => { expect(component.synthesize().statusPageId).toEqual({ ref: page.logicalId }) }) + it('synthesizes the type-specific settings as the backend configuration', async () => { + const page = new StatusPageV3('acme', { name: 'ACME', url: 'acme-status' }) + const group = new StatusPageV3Component('web-app', { + statusPage: page, + type: 'GROUP', + name: 'Web', + displayOrder: 1, + expandedByDefault: true, + }) + const service = new StatusPageV3Component('login', { + statusPage: page, + name: 'Login', + displayOrder: 2, + showHistoricalData: false, + }) + + // Only what is set: the backend fills the defaults for the rest. + expect(group.synthesize().configuration).toEqual({ expandedByDefault: true }) + expect(service.synthesize().configuration).toEqual({ showHistoricalData: false }) + + for (const component of [group, service]) { + const diagnostics = new Diagnostics() + await component.validate(diagnostics) + expect(diagnostics.isFatal()).toBe(false) + } + }) + + it('rejects a setting that belongs to the other component type', async () => { + const page = new StatusPageV3('acme', { name: 'ACME', url: 'acme-status' }) + // Loosely typed on purpose: the props union already stops this in TypeScript. + const props: any = { statusPage: page, name: 'Login', displayOrder: 1, expandedByDefault: true } + const service = new StatusPageV3Component('login', props) + + const diagnostics = new Diagnostics() + await service.validate(diagnostics) + expect(diagnostics.isFatal()).toBe(true) + expect(diagnostics.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ message: expect.stringContaining('SERVICE') }), + ])) + expect(service.synthesize().configuration).toBeUndefined() + }) + + it('rejects non-boolean settings', async () => { + const page = new StatusPageV3('acme', { name: 'ACME', url: 'acme-status' }) + const props: any = { statusPage: page, name: 'Login', displayOrder: 1, showHistoricalData: 'yes' } + const service = new StatusPageV3Component('login', props) + + const diagnostics = new Diagnostics() + await service.validate(diagnostics) + expect(diagnostics.isFatal()).toBe(true) + expect(diagnostics.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ message: expect.stringContaining('boolean') }), + ])) + }) + 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 }) diff --git a/packages/cli/src/constructs/internal/status-page-v3-component-configuration.ts b/packages/cli/src/constructs/internal/status-page-v3-component-configuration.ts new file mode 100644 index 00000000..6c7a8dc5 --- /dev/null +++ b/packages/cli/src/constructs/internal/status-page-v3-component-configuration.ts @@ -0,0 +1,14 @@ +import type { StatusPageV3ComponentType } from '../status-page-v3-component.js' + +/** + * The backend's per-type `configuration` properties and their defaults + * (`@checkly/shapes/status-pages-component` in the monorepo). The construct + * surfaces them as top-level props, derives which ones each type accepts + * from this table, and the import codegen elides values that only restate a + * default, so a new property is one entry here plus the prop on the + * construct. + */ +export const defaultConfigurationByType: Record> = { + SERVICE: { showHistoricalData: true }, + GROUP: { expandedByDefault: false, showHistoricalData: true }, +} diff --git a/packages/cli/src/constructs/status-page-v3-codegen.ts b/packages/cli/src/constructs/status-page-v3-codegen.ts index ceae9fd8..7a4628b6 100644 --- a/packages/cli/src/constructs/status-page-v3-codegen.ts +++ b/packages/cli/src/constructs/status-page-v3-codegen.ts @@ -16,6 +16,7 @@ export interface StatusPageV3Resource { defaultTheme?: StatusPageTheme | null privacyPolicyLink?: string | null termsOfServiceLink?: string | null + supportLink?: string | null footerText?: string | null googleAnalyticsTag?: string | null allowIndexing?: boolean | null @@ -126,6 +127,10 @@ export class StatusPageV3Codegen extends Codegen { builder.string('termsOfServiceLink', resource.termsOfServiceLink) } + if (resource.supportLink) { + builder.string('supportLink', resource.supportLink) + } + if (resource.footerText) { builder.string('footerText', resource.footerText) } diff --git a/packages/cli/src/constructs/status-page-v3-component-codegen.ts b/packages/cli/src/constructs/status-page-v3-component-codegen.ts index 5b4f7402..b5f66285 100644 --- a/packages/cli/src/constructs/status-page-v3-component-codegen.ts +++ b/packages/cli/src/constructs/status-page-v3-component-codegen.ts @@ -1,6 +1,7 @@ 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 { defaultConfigurationByType } from './internal/status-page-v3-component-configuration.js' import { valueForStatusPageV3Ref } from './status-page-v3-codegen.js' export interface StatusPageV3ComponentResource { @@ -12,6 +13,16 @@ export interface StatusPageV3ComponentResource { description?: string | null hidden?: boolean | null displayOrder: number + configuration?: Record | null +} + +// The configuration's properties are top-level props on the construct. The +// backend fills the defaults when one is omitted, so only values that differ +// are worth generating. +function nonDefaultConfiguration (resource: StatusPageV3ComponentResource): Array<[string, boolean]> { + const defaults = defaultConfigurationByType[resource.type] ?? {} + return Object.entries(resource.configuration ?? {}) + .filter((entry): entry is [string, boolean] => typeof entry[1] === 'boolean' && defaults[entry[0]] !== entry[1]) } const construct = 'StatusPageV3Component' @@ -95,6 +106,10 @@ export class StatusPageV3ComponentCodegen extends Codegen { + return { showHistoricalData: this.showHistoricalData, expandedByDefault: this.expandedByDefault } + } + + private configuration (): Record | undefined { + const allowedKeys = Object.keys(defaultConfigurationByType[this.componentType] ?? {}) + const entries = Object.entries(this.settings()) + .filter((entry): entry is [string, boolean] => allowedKeys.includes(entry[0]) && entry[1] !== undefined) + return entries.length > 0 ? Object.fromEntries(entries) : undefined + } + + // The props type already pairs each setting with `type`; this repeats the + // check at runtime for JavaScript users and loosely typed objects, matching + // what the backend rejects. + private validateSettings (diagnostics: Diagnostics): void { + const allowedKeys = Object.keys(defaultConfigurationByType[this.componentType] ?? {}) + for (const [key, value] of Object.entries(this.settings())) { + if (value === undefined) { + continue + } + if (!allowedKeys.includes(key)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + key, + new Error(`Not supported for a ${this.componentType} component.`), + )) + } else if (typeof value !== 'boolean') { + diagnostics.add(new InvalidPropertyValueDiagnostic( + key, + new Error('Value must be a boolean.'), + )) + } + } + } + synthesize (): any | null { return { statusPageId: Ref.from(this.statusPage.logicalId), @@ -172,6 +254,7 @@ export class StatusPageV3Component extends Construct { description: this.description, hidden: this.hidden, displayOrder: this.displayOrder, + configuration: this.configuration(), } } } diff --git a/packages/cli/src/constructs/status-page-v3.ts b/packages/cli/src/constructs/status-page-v3.ts index af2b64ed..d3729cfe 100644 --- a/packages/cli/src/constructs/status-page-v3.ts +++ b/packages/cli/src/constructs/status-page-v3.ts @@ -49,6 +49,10 @@ export interface StatusPageV3Props { * A link to your terms of service, shown in the page footer. */ termsOfServiceLink?: string + /** + * A link to your support channel, shown in the page footer. + */ + supportLink?: string /** * Free-form footer text. */ @@ -114,6 +118,7 @@ export class StatusPageV3 extends Construct { defaultTheme?: StatusPageTheme privacyPolicyLink?: string termsOfServiceLink?: string + supportLink?: string footerText?: string googleAnalyticsTag?: string allowIndexing?: boolean @@ -143,6 +148,7 @@ export class StatusPageV3 extends Construct { this.defaultTheme = props.defaultTheme this.privacyPolicyLink = props.privacyPolicyLink this.termsOfServiceLink = props.termsOfServiceLink + this.supportLink = props.supportLink this.footerText = props.footerText this.googleAnalyticsTag = props.googleAnalyticsTag this.allowIndexing = props.allowIndexing @@ -174,6 +180,7 @@ export class StatusPageV3 extends Construct { defaultTheme: this.defaultTheme, privacyPolicyLink: this.privacyPolicyLink, termsOfServiceLink: this.termsOfServiceLink, + supportLink: this.supportLink, footerText: this.footerText, googleAnalyticsTag: this.googleAnalyticsTag, allowIndexing: this.allowIndexing,