diff --git a/src/client.ts b/src/client.ts index 602f2df..6e82f7d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -15,6 +15,7 @@ import {Dependencies} from './resources/dependencies.js' import {DeployLock} from './resources/deploy-lock.js' import {Status} from './resources/status.js' import {StatusPages} from './resources/status-pages.js' +import {MaintenanceWindows} from './resources/maintenance-windows.js' /** * DevHelm API client. @@ -50,6 +51,7 @@ export class Devhelm { readonly deployLock: DeployLock readonly status: Status readonly statusPages: StatusPages + readonly maintenanceWindows: MaintenanceWindows constructor(config: DevhelmConfig) { const client = buildClient(config) @@ -68,5 +70,6 @@ export class Devhelm { this.deployLock = new DeployLock(client) this.status = new Status(client) this.statusPages = new StatusPages(client) + this.maintenanceWindows = new MaintenanceWindows(client) } } diff --git a/src/index.ts b/src/index.ts index 251e87e..66f92e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,7 @@ export type { DeployLockDto, AssertionTestResultDto, MonitorTestResultDto, + MaintenanceWindowDto, IncidentTimelineDto, CheckTraceDto, PolicySnapshotDto, @@ -60,6 +61,8 @@ export type { UpdateWebhookEndpointRequest, CreateApiKeyRequest, AcquireDeployLockRequest, + CreateMaintenanceWindowRequest, + UpdateMaintenanceWindowRequest, StatusPageDto, StatusPageComponentDto, StatusPageComponentGroupDto, @@ -109,3 +112,5 @@ export {Dependencies} from './resources/dependencies.js' export {DeployLock} from './resources/deploy-lock.js' export {Status} from './resources/status.js' export {StatusPages} from './resources/status-pages.js' +export {MaintenanceWindows} from './resources/maintenance-windows.js' +export type {MaintenanceWindowFilters} from './resources/maintenance-windows.js' diff --git a/src/resources/maintenance-windows.ts b/src/resources/maintenance-windows.ts new file mode 100644 index 0000000..f37e872 --- /dev/null +++ b/src/resources/maintenance-windows.ts @@ -0,0 +1,157 @@ +import type {ApiClient} from '../http.js' +import type { + MaintenanceWindowDto, + CreateMaintenanceWindowRequest, + UpdateMaintenanceWindowRequest, + Page, +} from '../types.js' +import { + MaintenanceWindowDtoSchema, + CreateMaintenanceWindowRequestSchema, + UpdateMaintenanceWindowRequestSchema, +} from '../schemas.js' +import {apiGet, fetchSingle, fetchVoid} from '../http.js' +import {parsePage, validateRequest} from '../validation.js' + +/** + * Filters for listing maintenance windows. + * + * - `monitorId` scopes results to a single monitor. + * - `filter` is a server-side status bucket: `"active"` for windows currently + * in progress, `"upcoming"` for windows whose `startsAt` is in the future. + * Omit to return every window in the org regardless of status. + */ +export interface MaintenanceWindowFilters { + monitorId?: string + filter?: 'active' | 'upcoming' +} + +/** + * Schedule planned downtime so DevHelm suppresses alerts during the window. + * + * Maintenance windows are scoped to a single monitor (set `monitorId`) or + * org-wide (leave `monitorId` unset / `null`). Recurring windows are + * expressed via an iCal RRULE in `repeatRule`. + */ +export class MaintenanceWindows { + constructor(private readonly client: ApiClient) {} + + /** + * List maintenance windows for the calling org. Auto-paginates through + * every page so callers receive the full set in a single array. + * + * @example + * ```ts + * const windows = await client.maintenanceWindows.list({filter: 'upcoming'}) + * ``` + */ + async list(filters: MaintenanceWindowFilters = {}): Promise { + const path = '/api/v1/maintenance-windows' + const baseQuery: Record = {} + if (filters.monitorId !== undefined) baseQuery['monitorId'] = filters.monitorId + if (filters.filter !== undefined) baseQuery['filter'] = filters.filter + + const all: MaintenanceWindowDto[] = [] + let page = 0 + const size = 200 + while (true) { + const raw = await apiGet(this.client, path, {...baseQuery, page, size}) + const validated = parsePage(MaintenanceWindowDtoSchema, raw, path) + all.push(...validated.data) + if (!validated.hasNext) break + page++ + } + return all + } + + /** + * List maintenance windows with manual page control. Useful for callers + * that render large windows in a paginated UI. + * + * @example + * ```ts + * const page = await client.maintenanceWindows.listPage(0, 50) + * ``` + */ + async listPage( + page: number, + size: number, + filters: MaintenanceWindowFilters = {}, + ): Promise> { + const path = '/api/v1/maintenance-windows' + const query: Record = {page, size} + if (filters.monitorId !== undefined) query['monitorId'] = filters.monitorId + if (filters.filter !== undefined) query['filter'] = filters.filter + const raw = await apiGet(this.client, path, query) + const validated = parsePage(MaintenanceWindowDtoSchema, raw, path) + return { + data: validated.data, + hasNext: validated.hasNext, + hasPrev: validated.hasPrev, + totalElements: validated.totalElements ?? null, + totalPages: validated.totalPages ?? null, + } + } + + /** + * Fetch a single maintenance window by UUID. + * + * @example + * ```ts + * const window = await client.maintenanceWindows.get(id) + * ``` + */ + async get(id: string): Promise { + return fetchSingle(this.client, 'GET', `/api/v1/maintenance-windows/${id}`, MaintenanceWindowDtoSchema) + } + + /** + * Schedule a new maintenance window. Set `monitorId` to `null` (or omit it) + * to create an org-wide window that suppresses every monitor's alerts. + * + * @example + * ```ts + * const window = await client.maintenanceWindows.create({ + * monitorId: '6f1a...', + * startsAt: '2026-06-01T03:00:00Z', + * endsAt: '2026-06-01T04:00:00Z', + * reason: 'Quarterly db migration', + * }) + * ``` + */ + async create(body: CreateMaintenanceWindowRequest): Promise { + validateRequest(CreateMaintenanceWindowRequestSchema, body, 'maintenanceWindows.create') + return fetchSingle(this.client, 'POST', '/api/v1/maintenance-windows', MaintenanceWindowDtoSchema, body) + } + + /** + * Update an existing maintenance window — adjust the start/end, swap the + * monitor it covers, or change the recurrence rule. + * + * @example + * ```ts + * await client.maintenanceWindows.update(id, { + * startsAt: '2026-06-01T04:00:00Z', + * endsAt: '2026-06-01T05:00:00Z', + * }) + * ``` + */ + async update(id: string, body: UpdateMaintenanceWindowRequest): Promise { + validateRequest(UpdateMaintenanceWindowRequestSchema, body, 'maintenanceWindows.update') + return fetchSingle(this.client, 'PUT', `/api/v1/maintenance-windows/${id}`, MaintenanceWindowDtoSchema, body) + } + + /** + * Cancel (delete) a maintenance window. After this call, alerts will fire + * normally for the affected monitor — even if the window's `endsAt` is + * still in the future. + * + * @example + * ```ts + * await client.maintenanceWindows.cancel(id) + * ``` + */ + async cancel(id: string): Promise { + return fetchVoid(this.client, `/api/v1/maintenance-windows/${id}`) + } +} diff --git a/src/schemas.ts b/src/schemas.ts index d10b16c..360c6a1 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -31,6 +31,7 @@ export const DashboardOverviewDtoSchema = schemas.DashboardOverviewDto export const DeployLockDtoSchema = schemas.DeployLockDto export const AssertionTestResultDtoSchema = schemas.AssertionTestResultDto export const MonitorTestResultDtoSchema = schemas.MonitorTestResultDto +export const MaintenanceWindowDtoSchema = schemas.MaintenanceWindowDto // ── Forensic DTO schemas ──────────────────────────────────────────── @@ -75,6 +76,8 @@ export const CreateWebhookEndpointRequestSchema = schemas.CreateWebhookEndpointR export const UpdateWebhookEndpointRequestSchema = schemas.UpdateWebhookEndpointRequest export const CreateApiKeyRequestSchema = schemas.CreateApiKeyRequest export const AcquireDeployLockRequestSchema = schemas.AcquireDeployLockRequest +export const CreateMaintenanceWindowRequestSchema = schemas.CreateMaintenanceWindowRequest +export const UpdateMaintenanceWindowRequestSchema = schemas.UpdateMaintenanceWindowRequest // ── Status Page Request schemas ───────────────────────────────────── @@ -124,6 +127,7 @@ export const SingleValueResponseServiceSubscriptionDtoSchema = schemas.SingleVal export const SingleValueResponseDashboardOverviewDtoSchema = schemas.SingleValueResponseDashboardOverviewDto export const SingleValueResponseDeployLockDtoSchema = schemas.SingleValueResponseDeployLockDto export const SingleValueResponseMonitorTestResultDtoSchema = schemas.SingleValueResponseMonitorTestResultDto +export const SingleValueResponseMaintenanceWindowDtoSchema = schemas.SingleValueResponseMaintenanceWindowDto export const SingleValueResponseStatusPageDtoSchema = schemas.SingleValueResponseStatusPageDto export const SingleValueResponseStatusPageComponentDtoSchema = schemas.SingleValueResponseStatusPageComponentDto export const SingleValueResponseStatusPageComponentGroupDtoSchema = schemas.SingleValueResponseStatusPageComponentGroupDto @@ -147,6 +151,7 @@ export const TableValueResultWebhookEndpointDtoSchema = schemas.TableValueResult export const TableValueResultApiKeyDtoSchema = schemas.TableValueResultApiKeyDto export const TableValueResultServiceSubscriptionDtoSchema = schemas.TableValueResultServiceSubscriptionDto export const TableValueResultMonitorVersionDtoSchema = schemas.TableValueResultMonitorVersionDto +export const TableValueResultMaintenanceWindowDtoSchema = schemas.TableValueResultMaintenanceWindowDto export const TableValueResultStatusPageDtoSchema = schemas.TableValueResultStatusPageDto export const TableValueResultStatusPageComponentDtoSchema = schemas.TableValueResultStatusPageComponentDto export const TableValueResultStatusPageComponentGroupDtoSchema = schemas.TableValueResultStatusPageComponentGroupDto diff --git a/src/types.ts b/src/types.ts index cbec11a..115316c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,6 +42,7 @@ export type DashboardOverviewDto = z.infer export type DeployLockDto = z.infer export type AssertionTestResultDto = z.infer export type MonitorTestResultDto = z.infer +export type MaintenanceWindowDto = z.infer // ── Forensic DTOs ───────────────────────────────────────────────────── @@ -86,6 +87,8 @@ export type CreateWebhookEndpointRequest = z.infer export type CreateApiKeyRequest = z.infer export type AcquireDeployLockRequest = z.infer +export type CreateMaintenanceWindowRequest = z.infer +export type UpdateMaintenanceWindowRequest = z.infer // ── Status Page Request types ───────────────────────────────────────── diff --git a/test/client.test.ts b/test/client.test.ts index ecf6a90..a27946b 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -19,6 +19,7 @@ describe('Devhelm client', () => { expect(client.deployLock).toBeDefined() expect(client.status).toBeDefined() expect(client.statusPages).toBeDefined() + expect(client.maintenanceWindows).toBeDefined() }) it('resource modules have expected CRUD methods', () => { diff --git a/test/maintenance-windows.test.ts b/test/maintenance-windows.test.ts new file mode 100644 index 0000000..c090fc9 --- /dev/null +++ b/test/maintenance-windows.test.ts @@ -0,0 +1,268 @@ +/** + * MaintenanceWindows resource tests. + * + * The HTTP layer is exercised end-to-end by stubbing `globalThis.fetch` — + * this is the same approach used in the surface-telemetry tests in + * `test/http.test.ts`. Capturing the live `Request` lets us assert on the + * exact path, method, query string, and body the SDK puts on the wire, + * without re-implementing the openapi-fetch envelope shape. + */ +import {describe, it, expect, beforeEach, afterEach} from 'vitest' +import {Devhelm} from '../src/index.js' +import type {MaintenanceWindowDto} from '../src/index.js' + +interface CapturedRequest { + method: string + url: URL + body: string | null +} + +const VALID_WINDOW: MaintenanceWindowDto = { + id: '550e8400-e29b-41d4-a716-446655440000', + monitorId: '550e8400-e29b-41d4-a716-446655440001', + organizationId: 1, + startsAt: '2026-06-01T03:00:00Z', + endsAt: '2026-06-01T04:00:00Z', + repeatRule: null, + reason: 'Quarterly db migration', + suppressAlerts: true, + createdAt: '2026-05-30T00:00:00Z', +} + +describe('MaintenanceWindows resource', () => { + const originalFetch = globalThis.fetch + let captured: CapturedRequest[] = [] + let nextResponse: (req: CapturedRequest) => Response + + beforeEach(() => { + captured = [] + nextResponse = () => new Response('{"data":null}', { + status: 200, + headers: {'Content-Type': 'application/json'}, + }) + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = input instanceof Request ? input : new Request(input, init) + const url = new URL(req.url) + const body = req.body ? await req.text() : null + const c: CapturedRequest = {method: req.method, url, body} + captured.push(c) + return nextResponse(c) + } + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + function buildClient() { + return new Devhelm({token: 't', baseUrl: 'http://localhost:0'}) + } + + it('exposes maintenanceWindows with the expected CRUD methods', () => { + const c = buildClient() + expect(c.maintenanceWindows).toBeDefined() + expect(typeof c.maintenanceWindows.list).toBe('function') + expect(typeof c.maintenanceWindows.listPage).toBe('function') + expect(typeof c.maintenanceWindows.get).toBe('function') + expect(typeof c.maintenanceWindows.create).toBe('function') + expect(typeof c.maintenanceWindows.update).toBe('function') + expect(typeof c.maintenanceWindows.cancel).toBe('function') + }) + + it('create posts to /api/v1/maintenance-windows with the request body', async () => { + nextResponse = () => new Response(JSON.stringify({data: VALID_WINDOW}), { + status: 201, + headers: {'Content-Type': 'application/json'}, + }) + const c = buildClient() + const result = await c.maintenanceWindows.create({ + monitorId: '550e8400-e29b-41d4-a716-446655440001', + startsAt: '2026-06-01T03:00:00Z', + endsAt: '2026-06-01T04:00:00Z', + reason: 'Quarterly db migration', + suppressAlerts: true, + }) + + expect(captured).toHaveLength(1) + const [req] = captured + expect(req.method).toBe('POST') + expect(req.url.pathname).toBe('/api/v1/maintenance-windows') + expect(req.url.search).toBe('') + const body = JSON.parse(req.body ?? '{}') + expect(body).toEqual({ + monitorId: '550e8400-e29b-41d4-a716-446655440001', + startsAt: '2026-06-01T03:00:00Z', + endsAt: '2026-06-01T04:00:00Z', + reason: 'Quarterly db migration', + suppressAlerts: true, + }) + expect(result.id).toBe(VALID_WINDOW.id) + expect(result.suppressAlerts).toBe(true) + }) + + it('create rejects payloads missing required fields before hitting the network', async () => { + const c = buildClient() + await expect( + c.maintenanceWindows.create({ + // intentionally missing startsAt/endsAt to trigger client-side validation + } as unknown as Parameters[0]), + ).rejects.toThrow(/validation/i) + expect(captured).toHaveLength(0) + }) + + it('list forwards monitorId and filter as query params and unwraps the page envelope', async () => { + nextResponse = () => new Response(JSON.stringify({ + data: [VALID_WINDOW], + hasNext: false, + hasPrev: false, + totalElements: 1, + totalPages: 1, + }), {status: 200, headers: {'Content-Type': 'application/json'}}) + + const c = buildClient() + const result = await c.maintenanceWindows.list({ + monitorId: '550e8400-e29b-41d4-a716-446655440001', + filter: 'active', + }) + + expect(captured).toHaveLength(1) + const [req] = captured + expect(req.method).toBe('GET') + expect(req.url.pathname).toBe('/api/v1/maintenance-windows') + expect(req.url.searchParams.get('monitorId')).toBe('550e8400-e29b-41d4-a716-446655440001') + expect(req.url.searchParams.get('filter')).toBe('active') + expect(req.url.searchParams.get('page')).toBe('0') + expect(req.url.searchParams.get('size')).toBe('200') + expect(result).toHaveLength(1) + expect(result[0].id).toBe(VALID_WINDOW.id) + }) + + it('list omits filter params when no filters are provided', async () => { + nextResponse = () => new Response(JSON.stringify({ + data: [], + hasNext: false, + hasPrev: false, + totalElements: 0, + totalPages: 0, + }), {status: 200, headers: {'Content-Type': 'application/json'}}) + + const c = buildClient() + const result = await c.maintenanceWindows.list() + + expect(captured).toHaveLength(1) + const [req] = captured + expect(req.url.searchParams.has('monitorId')).toBe(false) + expect(req.url.searchParams.has('filter')).toBe(false) + expect(result).toEqual([]) + }) + + it('list auto-paginates while hasNext is true', async () => { + let call = 0 + nextResponse = () => { + call++ + if (call === 1) { + return new Response(JSON.stringify({ + data: [VALID_WINDOW], + hasNext: true, + hasPrev: false, + totalElements: 2, + totalPages: 2, + }), {status: 200, headers: {'Content-Type': 'application/json'}}) + } + return new Response(JSON.stringify({ + data: [{...VALID_WINDOW, id: '550e8400-e29b-41d4-a716-446655440099'}], + hasNext: false, + hasPrev: true, + totalElements: 2, + totalPages: 2, + }), {status: 200, headers: {'Content-Type': 'application/json'}}) + } + + const c = buildClient() + const result = await c.maintenanceWindows.list() + + expect(captured).toHaveLength(2) + expect(captured[0].url.searchParams.get('page')).toBe('0') + expect(captured[1].url.searchParams.get('page')).toBe('1') + expect(result).toHaveLength(2) + }) + + it('listPage forwards explicit page/size + filters', async () => { + nextResponse = () => new Response(JSON.stringify({ + data: [VALID_WINDOW], + hasNext: true, + hasPrev: false, + totalElements: 42, + totalPages: 5, + }), {status: 200, headers: {'Content-Type': 'application/json'}}) + + const c = buildClient() + const page = await c.maintenanceWindows.listPage(2, 10, {filter: 'upcoming'}) + + const [req] = captured + expect(req.url.searchParams.get('page')).toBe('2') + expect(req.url.searchParams.get('size')).toBe('10') + expect(req.url.searchParams.get('filter')).toBe('upcoming') + expect(page.totalElements).toBe(42) + expect(page.totalPages).toBe(5) + expect(page.hasNext).toBe(true) + }) + + it('get fetches the window by id from the right URL', async () => { + nextResponse = () => new Response(JSON.stringify({data: VALID_WINDOW}), { + status: 200, + headers: {'Content-Type': 'application/json'}, + }) + + const c = buildClient() + const result = await c.maintenanceWindows.get(VALID_WINDOW.id) + + const [req] = captured + expect(req.method).toBe('GET') + expect(req.url.pathname).toBe(`/api/v1/maintenance-windows/${VALID_WINDOW.id}`) + expect(result.id).toBe(VALID_WINDOW.id) + }) + + it('update PUTs the request body to /maintenance-windows/{id}', async () => { + const updated: MaintenanceWindowDto = { + ...VALID_WINDOW, + reason: 'Rescheduled — vendor delay', + endsAt: '2026-06-01T05:00:00Z', + } + nextResponse = () => new Response(JSON.stringify({data: updated}), { + status: 200, + headers: {'Content-Type': 'application/json'}, + }) + + const c = buildClient() + const result = await c.maintenanceWindows.update(VALID_WINDOW.id, { + startsAt: '2026-06-01T03:00:00Z', + endsAt: '2026-06-01T05:00:00Z', + reason: 'Rescheduled — vendor delay', + }) + + expect(captured).toHaveLength(1) + const [req] = captured + expect(req.method).toBe('PUT') + expect(req.url.pathname).toBe(`/api/v1/maintenance-windows/${VALID_WINDOW.id}`) + const body = JSON.parse(req.body ?? '{}') + expect(body).toEqual({ + startsAt: '2026-06-01T03:00:00Z', + endsAt: '2026-06-01T05:00:00Z', + reason: 'Rescheduled — vendor delay', + }) + expect(result.reason).toBe('Rescheduled — vendor delay') + }) + + it('cancel DELETEs /maintenance-windows/{id} and resolves to void', async () => { + nextResponse = () => new Response(null, {status: 204}) + const c = buildClient() + const result = await c.maintenanceWindows.cancel(VALID_WINDOW.id) + + expect(captured).toHaveLength(1) + const [req] = captured + expect(req.method).toBe('DELETE') + expect(req.url.pathname).toBe(`/api/v1/maintenance-windows/${VALID_WINDOW.id}`) + expect(result).toBeUndefined() + }) +}) diff --git a/test/spec-paths.test.ts b/test/spec-paths.test.ts index c444b05..d6db375 100644 --- a/test/spec-paths.test.ts +++ b/test/spec-paths.test.ts @@ -131,6 +131,13 @@ const SDK_ENDPOINTS: ReadonlyArray = [ // dashboard ['get', '/api/v1/dashboard/overview'], + // maintenance-windows + ['get', '/api/v1/maintenance-windows'], + ['post', '/api/v1/maintenance-windows'], + ['get', '/api/v1/maintenance-windows/{id}'], + ['put', '/api/v1/maintenance-windows/{id}'], + ['delete', '/api/v1/maintenance-windows/{id}'], + // status-pages ['get', '/api/v1/status-pages'], ['post', '/api/v1/status-pages'],