From 636e2c5a4de7c708551bacc8f8b812dba0ce88d5 Mon Sep 17 00:00:00 2001 From: acoshift Date: Tue, 11 Aug 2026 14:03:35 +0700 Subject: [PATCH] add alert rules UI List/create/detail pages for metric alert rules (SPEC-metric-alerts.md Phase 1): status badges (ok/firing/nodata), combined create+edit form with deployment picker and notification-channels hint, detail page with transition history and a link to the deployment metrics chart. --- src/lib/alert.ts | 57 ++++ src/lib/nav.ts | 1 + src/lib/server/mock.ts | 152 ++++++++++ src/routes/(auth)/(project)/alert/+layout.ts | 8 + .../(auth)/(project)/alert/+page.svelte | 106 +++++++ src/routes/(auth)/(project)/alert/+page.ts | 3 + .../(project)/alert/create/+page.svelte | 276 ++++++++++++++++++ .../(auth)/(project)/alert/create/+page.ts | 31 ++ .../(project)/alert/detail/+page.svelte | 200 +++++++++++++ .../(auth)/(project)/alert/detail/+page.ts | 26 ++ src/types/api.d.ts | 56 ++++ tests/alert.spec.js | 265 +++++++++++++++++ tests/fixtures/mocks.js | 27 ++ 13 files changed, 1208 insertions(+) create mode 100644 src/lib/alert.ts create mode 100644 src/routes/(auth)/(project)/alert/+layout.ts create mode 100644 src/routes/(auth)/(project)/alert/+page.svelte create mode 100644 src/routes/(auth)/(project)/alert/+page.ts create mode 100644 src/routes/(auth)/(project)/alert/create/+page.svelte create mode 100644 src/routes/(auth)/(project)/alert/create/+page.ts create mode 100644 src/routes/(auth)/(project)/alert/detail/+page.svelte create mode 100644 src/routes/(auth)/(project)/alert/detail/+page.ts create mode 100644 tests/alert.spec.js diff --git a/src/lib/alert.ts b/src/lib/alert.ts new file mode 100644 index 00000000..d9a5d486 --- /dev/null +++ b/src/lib/alert.ts @@ -0,0 +1,57 @@ +/** + * Metric vocabulary + condition/target formatting shared by the alert list, + * create/edit form, and detail page. Mirrors api/alert.go's AlertMetrics / + * alertConditionString / alertTargetString (see AlertMetricCPU/Memory/ + * Requests/Egress and AlertPercentThresholdMax). + */ +import * as format from '$lib/format' + +export interface AlertMetricMeta { + value: string + label: string +} + +export const ALERT_METRICS: AlertMetricMeta[] = [ + { value: 'cpu', label: 'CPU (% of limit)' }, + { value: 'memory', label: 'Memory (% of limit)' }, + { value: 'requests', label: 'Requests (per minute)' }, + { value: 'egress', label: 'Egress (bytes per minute)' } +] + +export const ALERT_OPS = [ + { value: '>=', label: '>= (at or above)' }, + { value: '<=', label: '<= (at or below)' } +] + +export function alertMetricLabel (metric: string): string { + return ALERT_METRICS.find((m) => m.value === metric)?.label ?? metric +} + +/** + * Format a threshold/value for its metric's unit — percent for cpu/memory, + * binary bytes/min for egress, req/min otherwise. + */ +export function alertThresholdString (metric: string, value: number): string { + if (metric === 'cpu' || metric === 'memory') return `${value}%` + if (metric === 'egress') return `${format.storage(value)}/min` + return `${value}/min` +} + +/** + * Human-readable one-liner for a condition, e.g. "cpu >= 90% for 10m". + */ +export function alertConditionString (c: Api.AlertCondition): string { + return `${c.metric} ${c.op} ${alertThresholdString(c.metric, c.threshold)} for ${c.forMinutes}m` +} + +export function alertTargetString (t: Api.AlertTarget): string { + return `${t.location} / ${t.deployment}` +} + +/** + * lastValue is null before the first evaluation (or while nodata). + */ +export function alertValueString (metric: string, v: number | null | undefined): string { + if (v === null || v === undefined) return '—' + return alertThresholdString(metric, v) +} diff --git a/src/lib/nav.ts b/src/lib/nav.ts index d05e8739..0da3688e 100644 --- a/src/lib/nav.ts +++ b/src/lib/nav.ts @@ -37,5 +37,6 @@ export const projectMenu: ProjectMenuItem[] = [ { id: 'github', title: 'GitHub', icon: 'fa-code-branch', link: '/github', preview: true }, { id: 'scheduler', title: 'Scheduler', icon: 'fa-clock', link: '/scheduler', preview: true }, { id: 'notification', title: 'Notifications', icon: 'fa-bell', link: '/notification', preview: true }, + { id: 'alert', title: 'Alerts', icon: 'fa-bell-exclamation', link: '/alert', preview: true }, { id: 'audit-log', title: 'Audit Logs', icon: 'fa-clipboard-list', link: '/audit-log' } ] diff --git a/src/lib/server/mock.ts b/src/lib/server/mock.ts index 29100cbd..75fdfe88 100644 --- a/src/lib/server/mock.ts +++ b/src/lib/server/mock.ts @@ -1142,6 +1142,109 @@ const notificationDeliveries = [ { id: '1', startedAt: CREATED_AT, result: 'failed', httpStatus: 0, latencyMs: 30002, error: 'context deadline exceeded' } ] +// Alert — metric alert rules (project-scoped, location-less at the resource +// level; Target carries the location). Mutated by alert.create/update/delete +// within a session, mirroring githubLinks, so the create -> detail redirect +// and edit flows work offline. +interface AlertRuleFixture { + project: string + name: string + target: { location: string, deployment: string } + condition: { metric: string, op: string, threshold: number, forMinutes: number } + renotifyMinutes: number + disabled: boolean + status: 'ok' | 'firing' | 'nodata' + lastValue: number | null + firingSince: string | null + lastEvaluatedAt: string | null + createdAt: string + createdBy: string + updatedAt: string + updatedBy: string +} + +const alertRules: AlertRuleFixture[] = [ + { + project: 'acme', + name: 'web-cpu-high', + target: { location: LOCATION_ID, deployment: 'web' }, + condition: { metric: 'cpu', op: '>=', threshold: 90, forMinutes: 10 }, + renotifyMinutes: 0, + disabled: false, + status: 'ok', + lastValue: 42.5, + firingSince: null, + lastEvaluatedAt: CREATED_AT, + createdAt: CREATED_AT, + createdBy: USER_EMAIL, + updatedAt: CREATED_AT, + updatedBy: USER_EMAIL + }, + { + project: 'acme', + name: 'api-memory-high', + target: { location: LOCATION_ID, deployment: 'api' }, + condition: { metric: 'memory', op: '>=', threshold: 85, forMinutes: 5 }, + renotifyMinutes: 60, + disabled: false, + status: 'firing', + lastValue: 93.2, + firingSince: CREATED_AT, + lastEvaluatedAt: CREATED_AT, + createdAt: CREATED_AT, + createdBy: USER_EMAIL, + updatedAt: CREATED_AT, + updatedBy: USER_EMAIL + }, + { + // Targets a deployment that doesn't exist in the fixtures — demonstrates + // the "nodata" state (deployment paused/deleted, or no limit set). + project: 'acme', + name: 'worker-requests-drop', + target: { location: LOCATION_ID, deployment: 'worker' }, + condition: { metric: 'requests', op: '<=', threshold: 1, forMinutes: 15 }, + renotifyMinutes: 0, + disabled: false, + status: 'nodata', + lastValue: null, + firingSince: null, + lastEvaluatedAt: CREATED_AT, + createdAt: CREATED_AT, + createdBy: USER_EMAIL, + updatedAt: CREATED_AT, + updatedBy: USER_EMAIL + }, + { + project: 'acme', + name: 'website-egress-spike', + target: { location: LOCATION_ID, deployment: 'website' }, + condition: { metric: 'egress', op: '>=', threshold: 524288000, forMinutes: 10 }, + renotifyMinutes: 0, + disabled: true, + status: 'ok', + lastValue: 12345678, + firingSince: null, + lastEvaluatedAt: CREATED_AT, + createdAt: CREATED_AT, + createdBy: USER_EMAIL, + updatedAt: CREATED_AT, + updatedBy: USER_EMAIL + } +] + +interface AlertEventFixture { + at: string + transition: 'trigger' | 'resolve' | 'renotify' + value: number | null +} + +const alertEvents: AlertEventFixture[] = [ + { at: CREATED_AT, transition: 'renotify', value: 94.1 }, + { at: CREATED_AT, transition: 'trigger', value: 93.2 }, + { at: CREATED_AT, transition: 'resolve', value: 61.0 }, + { at: CREATED_AT, transition: 'trigger', value: 88.4 } +] + const roles = [ { role: 'viewer', @@ -2432,6 +2535,55 @@ const handlers: Record object> = { 'notification.deliveries': () => list(notificationDeliveries), 'notification.pull': () => ok({ project: 'acme', name: 'local-agent', events: [], cursor: 0, hasMore: false }), + 'alert.list': () => list(alertRules), + 'alert.get': (args) => { + const item = alertRules.find((a) => a.name === args?.name) + if (!item) return err('api: alert not found') + return ok(item) + }, + 'alert.create': (args) => { + if (alertRules.some((a) => a.name === args?.name)) return err('api: alert already exists') + alertRules.push({ + project: args?.project ?? 'acme', + name: args?.name ?? '', + target: args?.target ?? { location: LOCATION_ID, deployment: '' }, + condition: args?.condition ?? { metric: 'cpu', op: '>=', threshold: 90, forMinutes: 10 }, + renotifyMinutes: args?.renotifyMinutes ?? 0, + disabled: args?.disabled ?? false, + status: 'ok', + lastValue: null, + firingSince: null, + lastEvaluatedAt: null, + createdAt: CREATED_AT, + createdBy: USER_EMAIL, + updatedAt: CREATED_AT, + updatedBy: USER_EMAIL + }) + return ok({}) + }, + 'alert.update': (args) => { + const item = alertRules.find((a) => a.name === args?.name) + if (!item) return err('api: alert not found') + item.target = args?.target ?? item.target + item.condition = args?.condition ?? item.condition + item.renotifyMinutes = args?.renotifyMinutes ?? item.renotifyMinutes + item.disabled = args?.disabled ?? item.disabled + item.updatedAt = CREATED_AT + item.updatedBy = USER_EMAIL + return ok({}) + }, + 'alert.delete': (args) => { + const i = alertRules.findIndex((a) => a.name === args?.name) + if (i < 0) return err('api: alert not found') + alertRules.splice(i, 1) + return ok({}) + }, + 'alert.events': (args) => { + const item = alertRules.find((a) => a.name === args?.name) + if (!item) return err('api: alert not found') + return list(alertEvents) + }, + 'email.list': () => list([{ domain: 'mail.acme.example.com', createdAt: CREATED_AT }]), 'role.list': () => list(roles), diff --git a/src/routes/(auth)/(project)/alert/+layout.ts b/src/routes/(auth)/(project)/alert/+layout.ts new file mode 100644 index 00000000..8d985360 --- /dev/null +++ b/src/routes/(auth)/(project)/alert/+layout.ts @@ -0,0 +1,8 @@ +import type { LayoutLoad } from './$types' + +export const load: LayoutLoad = () => { + return { + menu: 'alert', + overrideRedirect: '/alert' + } +} diff --git a/src/routes/(auth)/(project)/alert/+page.svelte b/src/routes/(auth)/(project)/alert/+page.svelte new file mode 100644 index 00000000..570c3df1 --- /dev/null +++ b/src/routes/(auth)/(project)/alert/+page.svelte @@ -0,0 +1,106 @@ + + + it.name}> + {#snippet row(it)} + + + {it.name} + + + {alertTargetString(it.target)} + {alertConditionString(it.condition)} + + {#if it.disabled} + Disabled + {:else} + {it.status} + {/if} + + {alertValueString(it.condition.metric, it.lastValue)} + + + +
+ +
+
+
+ + {/snippet} +
+ + diff --git a/src/routes/(auth)/(project)/alert/+page.ts b/src/routes/(auth)/(project)/alert/+page.ts new file mode 100644 index 00000000..3e5bb37c --- /dev/null +++ b/src/routes/(auth)/(project)/alert/+page.ts @@ -0,0 +1,3 @@ +import { listLoad } from '$lib/loaders' + +export const load = listLoad('alert.list', 'alerts') diff --git a/src/routes/(auth)/(project)/alert/create/+page.svelte b/src/routes/(auth)/(project)/alert/create/+page.svelte new file mode 100644 index 00000000..a018ac5d --- /dev/null +++ b/src/routes/(auth)/(project)/alert/create/+page.svelte @@ -0,0 +1,276 @@ + + + + +
+ +
+
+

{isEdit ? 'Edit alert rule' : 'Create alert rule'}

+

Notify when a deployment metric crosses a threshold for a sustained period.

+
+
+ +
+
+
+ +
+ +
+
+ +
+
+
+ +
Target
+ +
+
+ + +
+ {/if} +
+ +
+
+
+ +
Condition
+ +
+ + +
+
+ +
+ +
+
+
+ +
+ +
+
+
+

+ Fires when {alertMetricLabel(form.metric)} stays {form.op} the threshold for the full window — a single missed minute of data doesn't reset the clock. +

+ +
+
+
+ +
Notification
+ + + + {#if form.renotify} +
+ +
+ +
+
+ {/if} + +

+ + Delivery uses your project's notification channels. + {#if !hasChannels} +
+ + + No notification channels exist yet — this rule will evaluate but reach nobody until you add one. + + {/if} +

+ +
+
+
+ + + +
+ +
+ + {isEdit ? 'Save' : 'Create'} + + +
+ + diff --git a/src/routes/(auth)/(project)/alert/create/+page.ts b/src/routes/(auth)/(project)/alert/create/+page.ts new file mode 100644 index 00000000..f5314ac5 --- /dev/null +++ b/src/routes/(auth)/(project)/alert/create/+page.ts @@ -0,0 +1,31 @@ +import { redirect, error } from '@sveltejs/kit' +import api from '$lib/api' +import type { PageLoad } from './$types' + +export const load: PageLoad = async ({ url, parent, fetch }) => { + const { project } = await parent() + const name = url.searchParams.get('name') + + let alert: Api.AlertItem | null = null + if (name) { + const res = await api.invoke('alert.get', { project, name }, fetch) + if (!res.ok) { + if (res.error?.notFound) redirect(302, `/alert?project=${project}`) + error(500, res.error?.message) + } + if (!res.result) redirect(302, `/alert?project=${project}`) + alert = res.result + } + + // Delivery uses the project's notification channels; a failure here shouldn't + // break the form, so default to "channels exist" (no warning) rather than + // false-alarming on a transient error. + const channels = await api.invoke>('notification.list', { project }, fetch) + const hasChannels = channels.ok ? (channels.result?.items.length ?? 0) > 0 : true + + return { + menu: 'alert', + alert, + hasChannels + } +} diff --git a/src/routes/(auth)/(project)/alert/detail/+page.svelte b/src/routes/(auth)/(project)/alert/detail/+page.svelte new file mode 100644 index 00000000..45c0f675 --- /dev/null +++ b/src/routes/(auth)/(project)/alert/detail/+page.svelte @@ -0,0 +1,200 @@ + + + + +
+ +
+
+

{alert.name}

+

+ {#if alert.disabled} + Disabled + {:else} + {alert.status} + {/if} +

+
+
+ + + Edit + +
+
+ +
+
+ + +
+
+ +
+
+
+ +
+ 0 ? `Every ${alert.renotifyMinutes} minutes` : 'Only on trigger/resolve'} readonly disabled> +
+
+
+ +
+ +
+
Evaluator state
+

Set by the alert-tick cron on every evaluation.

+
+ +
+
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
Transition history
+

The most recent state changes for this rule.

+
+ +
+ + + + + + + + + + + {#each events as ev, i (i)} + + + + + + {:else} + + {/each} + +
TimeTransitionValue
{format.fromNow(ev.at)} + {#if ev.transition === 'trigger'} + Trigger + {:else if ev.transition === 'resolve'} + Resolve + {:else} + Renotify + {/if} + {alertValueString(alert.condition.metric, ev.value)}
No transitions yet.
+
+ + + Delete + +
+
+ + diff --git a/src/routes/(auth)/(project)/alert/detail/+page.ts b/src/routes/(auth)/(project)/alert/detail/+page.ts new file mode 100644 index 00000000..0ede23f6 --- /dev/null +++ b/src/routes/(auth)/(project)/alert/detail/+page.ts @@ -0,0 +1,26 @@ +import { redirect, error } from '@sveltejs/kit' +import api from '$lib/api' +import type { PageLoad } from './$types' + +export const load: PageLoad = async ({ url, parent, fetch }) => { + const { project } = await parent() + const name = url.searchParams.get('name') + if (!name) redirect(302, `/alert?project=${project}`) + + const res = await api.invoke('alert.get', { project, name }, fetch) + if (!res.ok) { + if (res.error?.notFound) redirect(302, `/alert?project=${project}`) + error(500, res.error?.message) + } + if (!res.result) redirect(302, `/alert?project=${project}`) + + // Recent state transitions. A failure here shouldn't break the detail page, + // so the items default to empty. + const events = await api.invoke('alert.events', { project, name, limit: 50 }, fetch) + + return { + menu: 'alert', + alert: res.result, + events: events.result?.items ?? [] + } +} diff --git a/src/types/api.d.ts b/src/types/api.d.ts index ebabf6bd..c6170b25 100644 --- a/src/types/api.d.ts +++ b/src/types/api.d.ts @@ -1133,6 +1133,62 @@ declare namespace Api { items: NotificationDelivery[] } + // Alert — metric alert rules on deployment usage (project-scoped, + // location-less at the resource level; Target carries the location, like + // Notification carries its delivery config). Evaluated by an apiserver cron + // tick against the existing per-minute deployment_usages table; delivery + // reuses the notification-channels feature entirely. + export type AlertTarget = { + location: string + deployment: string + } + + // Threshold's unit depends on Metric: percent 0-100 for cpu/memory, req/min + // for requests, bytes/min for egress. Op defaults to ">=" server-side when + // left empty. + export type AlertCondition = { + metric: 'cpu' | 'memory' | 'requests' | 'egress' + op: '>=' | '<=' + threshold: number + forMinutes: number + } + + export type AlertItem = { + project: string + name: string + target: AlertTarget + condition: AlertCondition + // 0 = notify only on trigger/resolve transitions. + renotifyMinutes: number + disabled: boolean + // read-only evaluator state, set by the alert-tick cron. + status: 'ok' | 'firing' | 'nodata' + lastValue: number | null + firingSince: string | null + lastEvaluatedAt: string | null + createdAt: string + createdBy: string + updatedAt: string + updatedBy: string + } + + export type AlertListResult = { + project: string + items: AlertItem[] + } + + export type AlertEvent = { + at: string + transition: 'trigger' | 'resolve' | 'renotify' + value: number | null + } + + export type AlertEventsResult = { + project: string + name: string + items: AlertEvent[] + } + // cache.metrics reuses WafMetricsTimeRange. export type CacheMetricsSeries = { overrideId: string diff --git a/tests/alert.spec.js b/tests/alert.spec.js new file mode 100644 index 00000000..7e37ccfb --- /dev/null +++ b/tests/alert.spec.js @@ -0,0 +1,265 @@ +import { test, expect, setMocks, getRequestLog, pickSelect } from './helpers.js' +import { sampleAlertRule, sampleAlertEvent, sampleDeployment } from './fixtures/mocks.js' + +test.describe('alerts', () => { + test('lists alert rules with status badges', async ({ page }) => { + await setMocks({ + 'alert.list': { + ok: true, + result: { + items: [ + sampleAlertRule, + { + ...sampleAlertRule, + name: 'api-memory-high', + condition: { metric: 'memory', op: '>=', threshold: 85, forMinutes: 5 }, + status: 'firing', + lastValue: 93.2 + }, + { + ...sampleAlertRule, + name: 'worker-requests-drop', + condition: { metric: 'requests', op: '<=', threshold: 1, forMinutes: 15 }, + status: 'nodata', + lastValue: null + } + ] + } + } + }) + + await page.goto('/alert?project=test-project') + + const main = page.locator('.content-wrapper') + await expect(main.getByRole('heading', { name: 'Alerts' })).toBeVisible() + await expect(main.getByRole('link', { name: 'web-cpu-high' })).toBeVisible() + await expect(main.getByRole('link', { name: 'api-memory-high' })).toBeVisible() + await expect(main.getByRole('link', { name: 'worker-requests-drop' })).toBeVisible() + + await expect(main.getByRole('cell', { name: 'ok', exact: true })).toBeVisible() + await expect(main.getByRole('cell', { name: 'firing', exact: true })).toBeVisible() + await expect(main.getByRole('cell', { name: 'nodata', exact: true })).toBeVisible() + + // Condition + target render as a human-readable one-liner. + await expect(main.getByText('cpu >= 90% for 10m', { exact: true })).toBeVisible() + await expect(main.getByText('memory >= 85% for 5m', { exact: true })).toBeVisible() + await expect(main.getByText('gke / web', { exact: true }).first()).toBeVisible() + }) + + test('shows a disabled badge instead of the status for a disabled rule', async ({ page }) => { + await setMocks({ + 'alert.list': { ok: true, result: { items: [{ ...sampleAlertRule, disabled: true }] } } + }) + + await page.goto('/alert?project=test-project') + const main = page.locator('.content-wrapper') + await expect(main.getByText('Disabled')).toBeVisible() + await expect(main.getByRole('cell', { name: 'ok', exact: true })).toHaveCount(0) + }) + + test('empty state when no alert rules', async ({ page }) => { + await page.goto('/alert?project=test-project') + const main = page.locator('.content-wrapper') + await expect(main.getByText('Nothing here yet')).toBeVisible() + }) + + test('surfaces an API error in the list', async ({ page }) => { + await setMocks({ + 'alert.list': { ok: false, error: { message: 'api: internal error' } } + }) + await page.goto('/alert?project=test-project') + const main = page.locator('.content-wrapper') + await expect(main.getByText(/Something went wrong while loading this data/)).toBeVisible() + await expect(main.getByRole('button', { name: 'Try again' })).toBeVisible() + }) + + test('gates the create button when the create permission is missing', async ({ page }) => { + await setMocks({ + 'me.permissions': { ok: true, result: { permissions: ['alert.list'], admin: false } } + }) + await page.goto('/alert?project=test-project') + const main = page.locator('.content-wrapper') + await expect(main.getByRole('button', { name: 'Create rule' })).toBeDisabled() + await expect(main.getByRole('link', { name: 'Create rule' })).toHaveCount(0) + }) +}) + +test.describe('alert — create', () => { + test('submits alert.create with the target, condition, and notification settings', async ({ page }) => { + await setMocks({ + 'deployment.list': { ok: true, result: { items: [sampleDeployment] } }, + 'alert.create': { ok: true, result: {} }, + // The successful-create redirect lands on the detail page, which loads + // the rule by name — the created name must resolve. + 'alert.get': { + ok: true, + result: { + ...sampleAlertRule, + condition: { metric: 'requests', op: '>=', threshold: 500, forMinutes: 15 }, + renotifyMinutes: 120 + } + }, + 'alert.events': { ok: true, result: { items: [] } } + }) + + await page.goto('/alert/create?project=test-project') + + const main = page.locator('.content-wrapper') + await main.locator('#input-name').fill('web-cpu-high') + await pickSelect(page, 'input-location', 'gke') + await pickSelect(page, 'input-deployment', 'web') + await pickSelect(page, 'input-metric', 'Requests (per minute)') + await main.locator('#input-threshold').fill('500') + await main.locator('#input-for-minutes').fill('15') + await main.getByRole('checkbox', { name: 'Notify again while still firing' }).check() + await main.locator('#input-renotify-minutes').fill('120') + + await main.getByRole('button', { name: 'Create', exact: true }).click() + + await expect.poll(async () => { + const log = await getRequestLog() + return log.some((r) => r.path === '/alert.create') + }).toBeTruthy() + + const req = (await getRequestLog()).find((r) => r.path === '/alert.create') + if (!req) throw new Error('expected an alert.create request') + const body = JSON.parse(req.body) + expect(body.name).toBe('web-cpu-high') + expect(body.target).toEqual({ location: 'gke', deployment: 'web' }) + expect(body.condition).toEqual({ metric: 'requests', op: '>=', threshold: 500, forMinutes: 15 }) + expect(body.renotifyMinutes).toBe(120) + + await expect(page).toHaveURL(/\/alert\/detail\?project=test-project&name=web-cpu-high/) + }) + + test('shows the API error in a modal when create fails', async ({ page }) => { + await setMocks({ + 'deployment.list': { ok: true, result: { items: [sampleDeployment] } }, + 'alert.create': { ok: false, error: { message: 'api: alert already exists' } } + }) + + await page.goto('/alert/create?project=test-project') + + const main = page.locator('.content-wrapper') + await main.locator('#input-name').fill('web-cpu-high') + await pickSelect(page, 'input-location', 'gke') + await pickSelect(page, 'input-deployment', 'web') + await main.locator('#input-threshold').fill('90') + await main.locator('#input-for-minutes').fill('10') + await main.getByRole('button', { name: 'Create', exact: true }).click() + + await expect(page.locator('#app-modal')).toBeVisible() + await expect(page.locator('#app-modal')).toContainText('api: alert already exists') + }) + + test('disables Create when the create permission is missing', async ({ page }) => { + await setMocks({ + 'me.permissions': { ok: true, result: { permissions: ['alert.list'], admin: false } } + }) + await page.goto('/alert/create?project=test-project') + const main = page.locator('.content-wrapper') + await expect(main.getByRole('button', { name: 'Create', exact: true })).toBeDisabled() + }) + + test('warns when the project has no notification channels', async ({ page }) => { + await setMocks({ + 'notification.list': { ok: true, result: { items: [] } } + }) + await page.goto('/alert/create?project=test-project') + const main = page.locator('.content-wrapper') + await expect(main.getByText('No notification channels exist yet')).toBeVisible() + }) + + test('does not warn when notification channels exist', async ({ page }) => { + await setMocks({ + 'notification.list': { ok: true, result: { items: [{ project: 'test-project', name: 'ops-webhook' }] } } + }) + await page.goto('/alert/create?project=test-project') + const main = page.locator('.content-wrapper') + await expect(main.getByText('No notification channels exist yet')).toHaveCount(0) + }) +}) + +test.describe('alert — edit', () => { + test('seeds the form from the existing rule and submits alert.update', async ({ page }) => { + await setMocks({ + 'alert.get': { ok: true, result: sampleAlertRule }, + 'deployment.list': { ok: true, result: { items: [sampleDeployment] } }, + 'alert.update': { ok: true, result: {} } + }) + + await page.goto('/alert/create?project=test-project&name=web-cpu-high') + + const main = page.locator('.content-wrapper') + await expect(main.getByRole('heading', { name: 'Edit alert rule' })).toBeVisible() + await expect(main.locator('#input-name')).toHaveValue('web-cpu-high') + await expect(main.locator('#input-name')).toHaveAttribute('readonly', '') + await expect(main.locator('#input-threshold')).toHaveValue('90') + await expect(main.locator('#input-for-minutes')).toHaveValue('10') + + await main.locator('#input-threshold').fill('95') + await main.getByRole('button', { name: 'Save', exact: true }).click() + + await expect.poll(async () => { + const log = await getRequestLog() + return log.some((r) => r.path === '/alert.update') + }).toBeTruthy() + + const req = (await getRequestLog()).find((r) => r.path === '/alert.update') + if (!req) throw new Error('expected an alert.update request') + const body = JSON.parse(req.body) + expect(body.name).toBe('web-cpu-high') + expect(body.condition.threshold).toBe(95) + await expect(page).toHaveURL(/\/alert\/detail\?project=test-project&name=web-cpu-high/) + }) +}) + +test.describe('alert — detail', () => { + test('renders condition, status, and transition history', async ({ page }) => { + await setMocks({ + 'alert.get': { ok: true, result: sampleAlertRule }, + 'alert.events': { + ok: true, + result: { + items: [ + sampleAlertEvent, + { at: sampleAlertEvent.at, transition: 'resolve', value: 61 } + ] + } + } + }) + + await page.goto('/alert/detail?project=test-project&name=web-cpu-high') + + const main = page.locator('.content-wrapper') + await expect(main.getByRole('heading', { name: 'web-cpu-high', level: 4 })).toBeVisible() + await expect(main.locator('#d-condition')).toHaveValue('cpu >= 90% for 10m') + await expect(main.getByRole('link', { name: 'gke / web' })).toHaveAttribute( + 'href', '/deployment/metrics?project=test-project&location=gke&name=web' + ) + await expect(main.getByText('Trigger')).toBeVisible() + await expect(main.getByText('Resolve')).toBeVisible() + }) + + test('deletes the rule and returns to the list', async ({ page }) => { + await setMocks({ + 'alert.get': { ok: true, result: sampleAlertRule }, + 'alert.delete': { ok: true, result: {} } + }) + + await page.goto('/alert/detail?project=test-project&name=web-cpu-high') + + await page.getByRole('button', { name: 'Delete' }).click() + await page.locator('#app-modal-confirm').click() + + await expect.poll(async () => { + const log = await getRequestLog() + return log.some((r) => r.path === '/alert.delete') + }).toBeTruthy() + + const req = (await getRequestLog()).find((r) => r.path === '/alert.delete') + if (!req) throw new Error('expected an alert.delete request') + expect(JSON.parse(req.body)).toMatchObject({ name: 'web-cpu-high' }) + await expect(page).toHaveURL(/\/alert\?project=test-project/) + }) +}) diff --git a/tests/fixtures/mocks.js b/tests/fixtures/mocks.js index 4b25c957..2fe5664f 100644 --- a/tests/fixtures/mocks.js +++ b/tests/fixtures/mocks.js @@ -161,6 +161,10 @@ export function defaultMocks () { '/dropbox.list': { ok: true, result: { items: [] } + }, + '/alert.list': { + ok: true, + result: { items: [] } } } } @@ -395,6 +399,29 @@ export const sampleSchedulerJob = { updatedBy: '[email protected]' } +export const sampleAlertRule = { + project: 'test-project', + name: 'web-cpu-high', + target: { location: 'gke', deployment: 'web' }, + condition: { metric: 'cpu', op: '>=', threshold: 90, forMinutes: 10 }, + renotifyMinutes: 0, + disabled: false, + status: 'ok', + lastValue: 42.5, + firingSince: null, + lastEvaluatedAt: now, + createdAt: now, + createdBy: '[email protected]', + updatedAt: now, + updatedBy: '[email protected]' +} + +export const sampleAlertEvent = { + at: now, + transition: 'trigger', + value: 93.2 +} + export const sampleBillingAccount = { id: 'ba-1', name: 'Personal',