From fe8e750e2878503c95ac2ca0a317c23fe1996c53 Mon Sep 17 00:00:00 2001 From: tnolet Date: Fri, 11 Sep 2026 12:02:13 +0200 Subject: [PATCH 1/2] feat(constructs): status page component configuration and supportLink - `StatusPageV3Component` takes a `configuration` typed by `type`: a SERVICE accepts `{ showHistoricalData?: boolean }`, a GROUP accepts `{ expandedByDefault?: boolean, showHistoricalData?: boolean }`. The props are a discriminated union, so the wrong shape fails to compile; `validate()` repeats the check at runtime for loosely typed callers. - Import codegen generates `configuration` only for values that differ from the backend defaults. - `StatusPageV3` gains `supportLink`, shown in the page footer next to the privacy policy and terms of service links. Requires the matching backend change (checkly/monorepo). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XNTMz7yhDuvjdoK7ajzKFp --- .../configure-supporting-constructs.md | 3 + .../__tests__/status-page-v3-codegen.spec.ts | 28 ++++ .../__tests__/status-page-v3.spec.ts | 56 ++++++++ .../status-page-v3-component-configuration.ts | 13 ++ .../src/constructs/status-page-v3-codegen.ts | 5 + .../status-page-v3-component-codegen.ts | 21 ++- .../constructs/status-page-v3-component.ts | 121 +++++++++++++++++- packages/cli/src/constructs/status-page-v3.ts | 7 + 8 files changed, 247 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/constructs/internal/status-page-v3-component-configuration.ts 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..5f61f8e6 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. +- `configuration` holds type-specific settings and is typed by `type`: a `SERVICE` takes `{ showHistoricalData?: boolean }` (default `true`), a `GROUP` takes `{ expandedByDefault?: boolean, showHistoricalData?: boolean }` (defaults `false` and `true`). Omit it 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, + configuration: { 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, + configuration: { 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..b99eb52b 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('configuration: {') + expect(groupSource).toContain('expandedByDefault: true') + // 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,25 @@ describe('StatusPageV3 codegen', () => { expect(ruleSource).toContain('targetImpact: \'MAJOR_OUTAGE\'') }) + it('leaves a configuration that only carries 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 }, + ]) + + expect(sources['resources/status-pages/components/platform.check.ts']).not.toContain('configuration') + expect(sources['resources/status-pages/components/public-api.check.ts']).not.toContain('configuration') + }) + 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..e82df437 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,59 @@ describe('StatusPageV3Component', () => { expect(component.synthesize().statusPageId).toEqual({ ref: page.logicalId }) }) + it('synthesizes the type-specific configuration as-is', 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, + configuration: { expandedByDefault: true, showHistoricalData: false }, + }) + const service = new StatusPageV3Component('login', { + statusPage: page, + name: 'Login', + displayOrder: 2, + configuration: { showHistoricalData: false }, + }) + + expect(group.synthesize().configuration).toEqual({ expandedByDefault: true, showHistoricalData: false }) + 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 configuration 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, configuration: { 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('"expandedByDefault"') }), + ])) + }) + + it('rejects non-boolean configuration values', async () => { + const page = new StatusPageV3('acme', { name: 'ACME', url: 'acme-status' }) + const props: any = { statusPage: page, name: 'Login', displayOrder: 1, configuration: { 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..4e8024f0 --- /dev/null +++ b/packages/cli/src/constructs/internal/status-page-v3-component-configuration.ts @@ -0,0 +1,13 @@ +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 + * derives its allowed keys from it and the import codegen elides values that + * only restate a default, so a new property is one entry here plus the + * interface 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..32550764 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 { StatusPageV3ComponentConfiguration, 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,15 @@ export interface StatusPageV3ComponentResource { description?: string | null hidden?: boolean | null displayOrder: number + configuration?: StatusPageV3ComponentConfiguration | null +} + +// The backend fills the defaults when a configuration 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 +105,15 @@ export class StatusPageV3ComponentCodegen extends Codegen 0) { + builder.object('configuration', builder => { + for (const [key, value] of configuration) { + builder.boolean(key, value) + } + }) + } + if (resource.parentId) { builder.value('parent', valueForStatusPageV3ComponentRef(file, resource.parentId, context)) } diff --git a/packages/cli/src/constructs/status-page-v3-component.ts b/packages/cli/src/constructs/status-page-v3-component.ts index 824dfb29..b2319a19 100644 --- a/packages/cli/src/constructs/status-page-v3-component.ts +++ b/packages/cli/src/constructs/status-page-v3-component.ts @@ -2,23 +2,57 @@ import { Construct } from './construct.js' import { InvalidPropertyValueDiagnostic } from './construct-diagnostics.js' import { Diagnostics } from './diagnostics.js' import { validatePhysicalIdIsUuid } from './internal/common-diagnostics.js' +import { defaultConfigurationByType } from './internal/status-page-v3-component-configuration.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 { +/** + * Type-specific settings of a `SERVICE` component. + * + * Mirrors the backend contract (`@checkly/shapes/status-pages-component` in + * the monorepo): an omitted key takes the backend default, an unknown key is + * rejected at deploy time. + */ +export interface StatusPageV3ServiceComponentConfiguration { + /** + * Show the historical status (the uptime bar) of the component on the + * status page. Defaults to true. + */ + showHistoricalData?: boolean +} + +/** + * Type-specific settings of a `GROUP` component. + * + * Mirrors the backend contract (`@checkly/shapes/status-pages-component` in + * the monorepo): an omitted key takes the backend default, an unknown key is + * rejected at deploy time. + */ +export interface StatusPageV3GroupComponentConfiguration { + /** + * Render the group expanded when the status page loads. Defaults to false. + */ + expandedByDefault?: boolean + /** + * Show the historical status (the uptime bar) of the group on the status + * page. Defaults to true. + */ + showHistoricalData?: boolean +} + +export type StatusPageV3ComponentConfiguration = + | StatusPageV3ServiceComponentConfiguration + | StatusPageV3GroupComponentConfiguration + +interface StatusPageV3ComponentBaseProps { /** * 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. */ @@ -43,6 +77,36 @@ export interface StatusPageV3ComponentProps { parent?: StatusPageV3Component | StatusPageV3ComponentRef } +export interface StatusPageV3ServiceComponentProps extends StatusPageV3ComponentBaseProps { + /** + * `SERVICE`: a monitored thing with its own status. This is the default. + */ + type?: 'SERVICE' + /** + * Settings specific to a SERVICE component. Omit to use the defaults. + */ + configuration?: StatusPageV3ServiceComponentConfiguration +} + +export interface StatusPageV3GroupComponentProps extends StatusPageV3ComponentBaseProps { + /** + * `GROUP`: a container for other components. + */ + type: 'GROUP' + /** + * Settings specific to a GROUP component. Omit to use the defaults. + */ + configuration?: StatusPageV3GroupComponentConfiguration +} + +/** + * Discriminated on `type`: with `type: 'GROUP'` the `configuration` is the + * group one, otherwise (including when `type` is omitted) the service one. + */ +export type StatusPageV3ComponentProps = + | StatusPageV3ServiceComponentProps + | StatusPageV3GroupComponentProps + /** * Creates a reference to an existing v3 Status Page Component. * @@ -80,6 +144,7 @@ export class StatusPageV3Component extends Construct { hidden?: boolean displayOrder: number parent?: StatusPageV3Component | StatusPageV3ComponentRef + configuration?: StatusPageV3ComponentConfiguration static readonly __checklyType = 'status-page-component' @@ -100,6 +165,7 @@ export class StatusPageV3Component extends Construct { this.hidden = props.hidden this.displayOrder = props.displayOrder this.parent = props.parent + this.configuration = props.configuration Session.registerConstruct(this) } @@ -146,6 +212,8 @@ export class StatusPageV3Component extends Construct { )) } + this.validateConfiguration(diagnostics) + // A referenced parent (fromId) can only be checked on the backend. if (this.parent instanceof StatusPageV3Component) { if (this.parent.componentType !== 'GROUP') { @@ -163,6 +231,46 @@ export class StatusPageV3Component extends Construct { } } + // The props type already pairs `configuration` with `type`; this repeats + // the check at runtime for JavaScript users and loosely typed objects, + // matching what the backend rejects. + private validateConfiguration (diagnostics: Diagnostics): void { + if (this.configuration === undefined) { + return + } + + if (typeof this.configuration !== 'object' || this.configuration === null || Array.isArray(this.configuration)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'configuration', + new Error('Value must be an object.'), + )) + return + } + + const allowedKeys = Object.keys(defaultConfigurationByType[this.componentType] ?? {}) + const unknownKeys = Object.keys(this.configuration).filter(key => !allowedKeys.includes(key)) + if (unknownKeys.length > 0) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'configuration', + new Error( + `Unknown ${unknownKeys.length === 1 ? 'property' : 'properties'} for a ${this.componentType} component: ` + + `${unknownKeys.map(key => `"${key}"`).join(', ')}. ` + + `Allowed: ${allowedKeys.map(key => `"${key}"`).join(', ')}.`, + ), + )) + } + + const values: Record = { ...this.configuration } + for (const key of allowedKeys) { + if (values[key] !== undefined && typeof values[key] !== 'boolean') { + diagnostics.add(new InvalidPropertyValueDiagnostic( + `configuration.${key}`, + new Error('Value must be a boolean.'), + )) + } + } + } + synthesize (): any | null { return { statusPageId: Ref.from(this.statusPage.logicalId), @@ -172,6 +280,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, From 8264b25b8e55d91ae3ba04481667a118453b827d Mon Sep 17 00:00:00 2001 From: tnolet Date: Fri, 11 Sep 2026 16:14:05 +0200 Subject: [PATCH 2/2] feat(constructs): surface component settings as top-level props `expandedByDefault` and `showHistoricalData` are props on `StatusPageV3Component` rather than a nested `configuration` object. The props stay a discriminated union on `type`: `expandedByDefault` is only accepted with `type: 'GROUP'` (declared `never` on the SERVICE branch so the omitted-type case is caught too). `synthesize()` folds whichever settings are set into the backend's `configuration`, leaving omitted ones to the backend defaults. Codegen emits the flat props. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XNTMz7yhDuvjdoK7ajzKFp --- .../configure-supporting-constructs.md | 6 +- .../__tests__/status-page-v3-codegen.spec.ts | 11 +- .../__tests__/status-page-v3.spec.ts | 20 +-- .../status-page-v3-component-configuration.ts | 9 +- .../status-page-v3-component-codegen.ts | 18 +-- .../constructs/status-page-v3-component.ts | 130 +++++++----------- 6 files changed, 85 insertions(+), 109 deletions(-) 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 5f61f8e6..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,7 +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. -- `configuration` holds type-specific settings and is typed by `type`: a `SERVICE` takes `{ showHistoricalData?: boolean }` (default `true`), a `GROUP` takes `{ expandedByDefault?: boolean, showHistoricalData?: boolean }` (defaults `false` and `true`). Omit it to keep the defaults. +- 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' @@ -38,7 +38,7 @@ const webApp = new StatusPageV3Component('example-web-app-group', { type: 'GROUP', name: 'Web application', displayOrder: 1, - configuration: { expandedByDefault: true }, + expandedByDefault: true, }) const signUp = new StatusPageV3Component('example-sign-up-service', { @@ -48,7 +48,7 @@ const signUp = new StatusPageV3Component('example-sign-up-service', { name: 'Sign up', description: 'The sign up flow', displayOrder: 1, - configuration: { showHistoricalData: false }, + 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 b99eb52b..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 @@ -126,8 +126,8 @@ describe('StatusPageV3 codegen', () => { expect(groupSource).toContain('statusPage: acmeStatusPage') expect(groupSource).toContain('type: \'GROUP\'') expect(groupSource).toContain('displayOrder: 0') - expect(groupSource).toContain('configuration: {') 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:') @@ -152,7 +152,7 @@ describe('StatusPageV3 codegen', () => { expect(ruleSource).toContain('targetImpact: \'MAJOR_OUTAGE\'') }) - it('leaves a configuration that only carries the backend defaults implicit', async () => { + it('leaves settings that only restate the backend defaults implicit', async () => { const sources = await generate(rootDirectory, [ { type: 'status-page-component', @@ -167,8 +167,11 @@ describe('StatusPageV3 codegen', () => { { type: 'status-page', logicalId: 'acme', payload: page }, ]) - expect(sources['resources/status-pages/components/platform.check.ts']).not.toContain('configuration') - expect(sources['resources/status-pages/components/public-api.check.ts']).not.toContain('configuration') + 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 () => { 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 e82df437..acd34155 100644 --- a/packages/cli/src/constructs/__tests__/status-page-v3.spec.ts +++ b/packages/cli/src/constructs/__tests__/status-page-v3.spec.ts @@ -109,23 +109,24 @@ describe('StatusPageV3Component', () => { expect(component.synthesize().statusPageId).toEqual({ ref: page.logicalId }) }) - it('synthesizes the type-specific configuration as-is', async () => { + 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, - configuration: { expandedByDefault: true, showHistoricalData: false }, + expandedByDefault: true, }) const service = new StatusPageV3Component('login', { statusPage: page, name: 'Login', displayOrder: 2, - configuration: { showHistoricalData: false }, + showHistoricalData: false, }) - expect(group.synthesize().configuration).toEqual({ expandedByDefault: true, 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]) { @@ -135,23 +136,24 @@ describe('StatusPageV3Component', () => { } }) - it('rejects a configuration that belongs to the other component type', async () => { + 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, configuration: { expandedByDefault: true } } + 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('"expandedByDefault"') }), + expect.objectContaining({ message: expect.stringContaining('SERVICE') }), ])) + expect(service.synthesize().configuration).toBeUndefined() }) - it('rejects non-boolean configuration values', async () => { + 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, configuration: { showHistoricalData: 'yes' } } + const props: any = { statusPage: page, name: 'Login', displayOrder: 1, showHistoricalData: 'yes' } const service = new StatusPageV3Component('login', props) const diagnostics = new Diagnostics() 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 index 4e8024f0..6c7a8dc5 100644 --- a/packages/cli/src/constructs/internal/status-page-v3-component-configuration.ts +++ b/packages/cli/src/constructs/internal/status-page-v3-component-configuration.ts @@ -1,11 +1,12 @@ import type { StatusPageV3ComponentType } from '../status-page-v3-component.js' /** - * The backend's per-type configuration properties and their defaults + * The backend's per-type `configuration` properties and their defaults * (`@checkly/shapes/status-pages-component` in the monorepo). The construct - * derives its allowed keys from it and the import codegen elides values that - * only restate a default, so a new property is one entry here plus the - * interface on 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 }, 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 32550764..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,6 @@ import { Codegen, Context } from './internal/codegen/index.js' import { decl, expr, GeneratedFile, ident, Value } from '../sourcegen/index.js' -import { StatusPageV3ComponentConfiguration, StatusPageV3ComponentType } from './status-page-v3-component.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' @@ -13,11 +13,12 @@ export interface StatusPageV3ComponentResource { description?: string | null hidden?: boolean | null displayOrder: number - configuration?: StatusPageV3ComponentConfiguration | null + configuration?: Record | null } -// The backend fills the defaults when a configuration is omitted, so only -// values that differ are worth generating. +// 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 ?? {}) @@ -105,13 +106,8 @@ export class StatusPageV3ComponentCodegen extends Codegen 0) { - builder.object('configuration', builder => { - for (const [key, value] of configuration) { - builder.boolean(key, value) - } - }) + for (const [key, value] of nonDefaultConfiguration(resource)) { + builder.boolean(key, value) } if (resource.parentId) { diff --git a/packages/cli/src/constructs/status-page-v3-component.ts b/packages/cli/src/constructs/status-page-v3-component.ts index b2319a19..18ffd3e8 100644 --- a/packages/cli/src/constructs/status-page-v3-component.ts +++ b/packages/cli/src/constructs/status-page-v3-component.ts @@ -9,44 +9,6 @@ import { StatusPageV3, StatusPageV3Ref } from './status-page-v3.js' export type StatusPageV3ComponentType = 'SERVICE' | 'GROUP' -/** - * Type-specific settings of a `SERVICE` component. - * - * Mirrors the backend contract (`@checkly/shapes/status-pages-component` in - * the monorepo): an omitted key takes the backend default, an unknown key is - * rejected at deploy time. - */ -export interface StatusPageV3ServiceComponentConfiguration { - /** - * Show the historical status (the uptime bar) of the component on the - * status page. Defaults to true. - */ - showHistoricalData?: boolean -} - -/** - * Type-specific settings of a `GROUP` component. - * - * Mirrors the backend contract (`@checkly/shapes/status-pages-component` in - * the monorepo): an omitted key takes the backend default, an unknown key is - * rejected at deploy time. - */ -export interface StatusPageV3GroupComponentConfiguration { - /** - * Render the group expanded when the status page loads. Defaults to false. - */ - expandedByDefault?: boolean - /** - * Show the historical status (the uptime bar) of the group on the status - * page. Defaults to true. - */ - showHistoricalData?: boolean -} - -export type StatusPageV3ComponentConfiguration = - | StatusPageV3ServiceComponentConfiguration - | StatusPageV3GroupComponentConfiguration - interface StatusPageV3ComponentBaseProps { /** * The v3 status page this component belongs to. A component belongs to @@ -83,9 +45,14 @@ export interface StatusPageV3ServiceComponentProps extends StatusPageV3Component */ type?: 'SERVICE' /** - * Settings specific to a SERVICE component. Omit to use the defaults. + * Show the historical status (the uptime bar) of the component on the + * status page. Defaults to true. */ - configuration?: StatusPageV3ServiceComponentConfiguration + showHistoricalData?: boolean + /** + * Only available on a `GROUP` component. + */ + expandedByDefault?: never } export interface StatusPageV3GroupComponentProps extends StatusPageV3ComponentBaseProps { @@ -94,14 +61,20 @@ export interface StatusPageV3GroupComponentProps extends StatusPageV3ComponentBa */ type: 'GROUP' /** - * Settings specific to a GROUP component. Omit to use the defaults. + * Render the group expanded when the status page loads. Defaults to false. */ - configuration?: StatusPageV3GroupComponentConfiguration + expandedByDefault?: boolean + /** + * Show the historical status (the uptime bar) of the group on the status + * page. Defaults to true. + */ + showHistoricalData?: boolean } /** - * Discriminated on `type`: with `type: 'GROUP'` the `configuration` is the - * group one, otherwise (including when `type` is omitted) the service one. + * Discriminated on `type`: `expandedByDefault` is only available with + * `type: 'GROUP'`. The type-specific settings are sent to the backend as the + * component's `configuration`; an omitted setting takes the backend default. */ export type StatusPageV3ComponentProps = | StatusPageV3ServiceComponentProps @@ -144,7 +117,8 @@ export class StatusPageV3Component extends Construct { hidden?: boolean displayOrder: number parent?: StatusPageV3Component | StatusPageV3ComponentRef - configuration?: StatusPageV3ComponentConfiguration + showHistoricalData?: boolean + expandedByDefault?: boolean static readonly __checklyType = 'status-page-component' @@ -165,7 +139,10 @@ export class StatusPageV3Component extends Construct { this.hidden = props.hidden this.displayOrder = props.displayOrder this.parent = props.parent - this.configuration = props.configuration + this.showHistoricalData = props.showHistoricalData + // Only a GROUP prop; read it regardless so validate() can report it on + // a SERVICE for loosely typed callers. + this.expandedByDefault = 'expandedByDefault' in props ? props.expandedByDefault : undefined Session.registerConstruct(this) } @@ -212,7 +189,7 @@ export class StatusPageV3Component extends Construct { )) } - this.validateConfiguration(diagnostics) + this.validateSettings(diagnostics) // A referenced parent (fromId) can only be checked on the backend. if (this.parent instanceof StatusPageV3Component) { @@ -231,40 +208,37 @@ export class StatusPageV3Component extends Construct { } } - // The props type already pairs `configuration` with `type`; this repeats - // the check at runtime for JavaScript users and loosely typed objects, - // matching what the backend rejects. - private validateConfiguration (diagnostics: Diagnostics): void { - if (this.configuration === undefined) { - return - } - - if (typeof this.configuration !== 'object' || this.configuration === null || Array.isArray(this.configuration)) { - diagnostics.add(new InvalidPropertyValueDiagnostic( - 'configuration', - new Error('Value must be an object.'), - )) - return - } + // The backend `configuration` of this component: every type-specific + // setting that is set, keyed as the API expects. Unset ones are left out so + // the backend fills its defaults; nothing set means no configuration. + private settings (): Record { + return { showHistoricalData: this.showHistoricalData, expandedByDefault: this.expandedByDefault } + } + private configuration (): Record | undefined { const allowedKeys = Object.keys(defaultConfigurationByType[this.componentType] ?? {}) - const unknownKeys = Object.keys(this.configuration).filter(key => !allowedKeys.includes(key)) - if (unknownKeys.length > 0) { - diagnostics.add(new InvalidPropertyValueDiagnostic( - 'configuration', - new Error( - `Unknown ${unknownKeys.length === 1 ? 'property' : 'properties'} for a ${this.componentType} component: ` - + `${unknownKeys.map(key => `"${key}"`).join(', ')}. ` - + `Allowed: ${allowedKeys.map(key => `"${key}"`).join(', ')}.`, - ), - )) - } + 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 + } - const values: Record = { ...this.configuration } - for (const key of allowedKeys) { - if (values[key] !== undefined && typeof values[key] !== 'boolean') { + // 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( - `configuration.${key}`, + key, new Error('Value must be a boolean.'), )) } @@ -280,7 +254,7 @@ export class StatusPageV3Component extends Construct { description: this.description, hidden: this.hidden, displayOrder: this.displayOrder, - configuration: this.configuration, + configuration: this.configuration(), } } }