Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions src/lib/alert.ts
Original file line number Diff line number Diff line change
@@ -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)
}
1 change: 1 addition & 0 deletions src/lib/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
]
152 changes: 152 additions & 0 deletions src/lib/server/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -2432,6 +2535,55 @@ const handlers: Record<string, (args: any) => 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),
Expand Down
8 changes: 8 additions & 0 deletions src/routes/(auth)/(project)/alert/+layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { LayoutLoad } from './$types'

export const load: LayoutLoad = () => {
return {
menu: 'alert',
overrideRedirect: '/alert'
}
}
106 changes: 106 additions & 0 deletions src/routes/(auth)/(project)/alert/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<script lang="ts">
import { onMount } from 'svelte'
import { invalidateAll } from '$app/navigation'
import type { PageData } from './$types'
import ListTable from '$lib/components/ListTable.svelte'
import { alertConditionString, alertTargetString, alertValueString } from '$lib/alert'
import { denyTooltip, getPermissionContext } from '$lib/permission'

const { can } = getPermissionContext()

const { data }: { data: PageData } = $props()

const project = $derived(data.project)
const alerts = $derived(data.alerts)
const error = $derived(data.error)

// Rules can flip firing/resolve at any minute (the alert-tick cron runs every
// minute), so keep the list fresh the same way the deployment metrics page
// does: a self-rescheduling timeout that reinvokes the page load, not a plain
// interval — this way a slow reload never overlaps the next tick.
const RELOAD_INTERVAL_MS = 60_000
let reloadTimeout: ReturnType<typeof setTimeout> | null = null

function scheduleReload () {
reloadTimeout && clearTimeout(reloadTimeout)
reloadTimeout = setTimeout(async () => {
await invalidateAll()
scheduleReload()
}, RELOAD_INTERVAL_MS)
}

onMount(() => {
scheduleReload()
return () => { reloadTimeout && clearTimeout(reloadTimeout) }
})
</script>

<ListTable
title="Alerts"
items={alerts}
{error}
noun="alert rule"
createPermission="alert.create"
createHref="/alert/create?project={project}"
createLabel="Create rule"
columns={['Name', 'Target', 'Condition', 'Status', 'Last value']}
actions
key={(it) => it.name}>
{#snippet row(it)}
<td>
<a class="link cell-name" href="/alert/detail?project={project}&name={it.name}">
{it.name}
</a>
</td>
<td><span class="font-mono text-sm text-content/70">{alertTargetString(it.target)}</span></td>
<td><span class="font-mono text-sm">{alertConditionString(it.condition)}</span></td>
<td>
{#if it.disabled}
<span class="inline-flex items-center gap-2 text-content/60"><i class="fa-solid fa-ban"></i> Disabled</span>
{:else}
<span class="status-badge" data-status={it.status}>{it.status}</span>
{/if}
</td>
<td class="tabular-nums">{alertValueString(it.condition.metric, it.lastValue)}</td>
<td>
<span class="inline-flex" title={can('alert.update') ? null : denyTooltip('alert.update')}>
<a
href={can('alert.update') ? `/alert/create?project=${project}&name=${it.name}` : null}
aria-label="Edit"
aria-disabled={can('alert.update') ? null : 'true'}>
<div class="icon-button">
<i class="fa-solid fa-pen"></i>
</div>
</a>
</span>
</td>
{/snippet}
</ListTable>

<style>
.status-badge {
display: inline-flex;
padding: 0.0625rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: capitalize;
color: hsl(var(--hsl-content) / 0.75);
background-color: hsl(var(--hsl-content) / 0.08);
}

.status-badge[data-status='ok'] {
color: hsl(var(--hsl-positive));
background-color: hsl(var(--hsl-positive) / 0.12);
}

.status-badge[data-status='firing'] {
color: hsl(var(--hsl-negative));
background-color: hsl(var(--hsl-negative) / 0.12);
}

.status-badge[data-status='nodata'] {
color: hsl(var(--hsl-content) / 0.55);
background-color: hsl(var(--hsl-content) / 0.08);
}
</style>
3 changes: 3 additions & 0 deletions src/routes/(auth)/(project)/alert/+page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { listLoad } from '$lib/loaders'

export const load = listLoad<Api.AlertItem, 'alerts'>('alert.list', 'alerts')
Loading
Loading