mirror of
https://github.com/Nezumi-2711/uptime-monitoring.git
synced 2026-09-22 13:48:31 +00:00
feat: add more services for sent notification
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
CREATE TABLE `notification_channel_monitors` (
|
||||
`channel_id` integer NOT NULL,
|
||||
`monitor_id` integer NOT NULL,
|
||||
PRIMARY KEY(`channel_id`, `monitor_id`),
|
||||
FOREIGN KEY (`channel_id`) REFERENCES `notification_channels`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`monitor_id`) REFERENCES `monitors`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `notification_channel_monitors_monitor_id_idx` ON `notification_channel_monitors` (`monitor_id`);--> statement-breakpoint
|
||||
CREATE TABLE `notification_channels` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`config` text NOT NULL,
|
||||
`enabled` integer DEFAULT true NOT NULL,
|
||||
`notify_manual` integer DEFAULT true NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `notification_channels_enabled_idx` ON `notification_channels` (`enabled`);--> statement-breakpoint
|
||||
CREATE TABLE `notification_deliveries` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`channel_id` integer NOT NULL,
|
||||
`incident_id` integer,
|
||||
`monitor_id` integer,
|
||||
`event` text NOT NULL,
|
||||
`ok` integer NOT NULL,
|
||||
`status_code` integer,
|
||||
`error` text,
|
||||
`attempts` integer DEFAULT 1 NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`channel_id`) REFERENCES `notification_channels`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`incident_id`) REFERENCES `incidents`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`monitor_id`) REFERENCES `monitors`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `notification_deliveries_channel_id_created_at_idx` ON `notification_deliveries` (`channel_id`,`created_at`);--> statement-breakpoint
|
||||
INSERT INTO `notification_channels` (`name`, `type`, `config`, `enabled`, `notify_manual`, `created_at`, `updated_at`)
|
||||
SELECT 'Legacy webhook', 'webhook', json_object('url', `webhook_url`), `webhook_enabled`, 1, `created_at`, `updated_at`
|
||||
FROM `notification_settings`
|
||||
WHERE `webhook_url` IS NOT NULL;--> statement-breakpoint
|
||||
DROP TABLE `notification_settings`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,13 @@
|
||||
"when": 1788057318682,
|
||||
"tag": "0007_true_mauler",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1788059207578,
|
||||
"tag": "0008_aromatic_chat",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { deleteJson, getJson, patchJson, postJson } from './http';
|
||||
|
||||
export type ChannelType = 'slack' | 'discord' | 'telegram' | 'webhook';
|
||||
export type ChannelConfigInput = { url: string } | { botToken: string; chatId: string };
|
||||
export type NotificationDelivery = {
|
||||
id: number;
|
||||
channelId: number;
|
||||
incidentId: number | null;
|
||||
monitorId: number | null;
|
||||
event: string;
|
||||
ok: boolean;
|
||||
statusCode: number | null;
|
||||
error: string | null;
|
||||
attempts: number;
|
||||
createdAt: string;
|
||||
};
|
||||
export type NotificationChannel = {
|
||||
id: number;
|
||||
name: string;
|
||||
type: ChannelType;
|
||||
config: { configSet: boolean; url?: string; botToken?: string; chatId?: string };
|
||||
enabled: boolean;
|
||||
notifyManual: boolean;
|
||||
monitorIds: number[];
|
||||
lastDelivery: NotificationDelivery | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
export type NotificationChannelInput = {
|
||||
name: string;
|
||||
type: ChannelType;
|
||||
config?: ChannelConfigInput;
|
||||
enabled: boolean;
|
||||
notifyManual: boolean;
|
||||
monitorIds: number[];
|
||||
};
|
||||
|
||||
export function getNotificationChannels(signal?: AbortSignal) {
|
||||
return getJson<{ channels: NotificationChannel[] }>('/api/channels', { signal, credentials: 'same-origin' });
|
||||
}
|
||||
export function createNotificationChannel(input: NotificationChannelInput) {
|
||||
return postJson<{ channel: NotificationChannel }>('/api/channels', input);
|
||||
}
|
||||
export function updateNotificationChannel(id: number, input: Partial<NotificationChannelInput>) {
|
||||
return patchJson<{ channel: NotificationChannel }>(`/api/channels/${id}`, input);
|
||||
}
|
||||
export function deleteNotificationChannel(id: number) {
|
||||
return deleteJson<{ ok: true }>(`/api/channels/${id}`);
|
||||
}
|
||||
export function testNotificationChannel(id: number) {
|
||||
return postJson<{ ok: true }>(`/api/channels/${id}/test`);
|
||||
}
|
||||
export function getNotificationDeliveries(id: number, limit = 20, signal?: AbortSignal) {
|
||||
return getJson<{ deliveries: NotificationDelivery[] }>(`/api/channels/${id}/deliveries?limit=${limit}`, {
|
||||
signal,
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
}
|
||||
@@ -1,15 +1,5 @@
|
||||
import { getJson, postJson, putJson } from './http';
|
||||
|
||||
export type NotificationSettings = {
|
||||
id: number;
|
||||
webhookUrl: string | null;
|
||||
webhookEnabled: boolean;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type NotificationSettingsInput = Pick<NotificationSettings, 'webhookUrl' | 'webhookEnabled'>;
|
||||
|
||||
export type AiSettings = {
|
||||
id: number;
|
||||
enabled: boolean;
|
||||
@@ -28,21 +18,6 @@ export type AiSettingsInput = {
|
||||
apiKey?: string | null;
|
||||
};
|
||||
|
||||
export function getNotificationSettings(signal?: AbortSignal) {
|
||||
return getJson<{ settings: NotificationSettings }>('/api/settings/notifications', {
|
||||
signal,
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
}
|
||||
|
||||
export function updateNotificationSettings(input: NotificationSettingsInput) {
|
||||
return putJson<{ settings: NotificationSettings }>('/api/settings/notifications', input);
|
||||
}
|
||||
|
||||
export function testNotificationWebhook() {
|
||||
return postJson<{ ok: true }>('/api/settings/notifications/test');
|
||||
}
|
||||
|
||||
export function getAiSettings(signal?: AbortSignal) {
|
||||
return getJson<{ settings: AiSettings }>('/api/settings/ai', {
|
||||
signal,
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { type FormEvent, useState } from 'react';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import type { ChannelType, NotificationChannel, NotificationChannelInput } from '../../api/channels';
|
||||
import { useCreateNotificationChannelMutation, useUpdateNotificationChannelMutation } from '../../queries/channels';
|
||||
import { useMonitorsQuery } from '../../queries/monitors';
|
||||
|
||||
type FormState = {
|
||||
name: string;
|
||||
type: ChannelType;
|
||||
url: string;
|
||||
botToken: string;
|
||||
chatId: string;
|
||||
enabled: boolean;
|
||||
notifyManual: boolean;
|
||||
monitorIds: number[];
|
||||
};
|
||||
|
||||
function initialForm(editing: NotificationChannel | null): FormState {
|
||||
return {
|
||||
name: editing?.name ?? '',
|
||||
type: editing?.type ?? 'slack',
|
||||
url: '',
|
||||
botToken: '',
|
||||
chatId: editing?.config.chatId ?? '',
|
||||
enabled: editing?.enabled ?? true,
|
||||
notifyManual: editing?.notifyManual ?? true,
|
||||
monitorIds: editing?.monitorIds ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function NotificationChannelDialog({ editing, onClose }: { editing: NotificationChannel | null; onClose: () => void }) {
|
||||
const createMutation = useCreateNotificationChannelMutation();
|
||||
const updateMutation = useUpdateNotificationChannelMutation();
|
||||
const monitorsQuery = useMonitorsQuery();
|
||||
const [form, setForm] = useState(() => initialForm(editing));
|
||||
const mutation = editing ? updateMutation : createMutation;
|
||||
const monitors = monitorsQuery.data?.monitors ?? [];
|
||||
const selected = monitors.filter((monitor) => form.monitorIds.includes(monitor.id));
|
||||
|
||||
function toggleMonitor(id: number, checked: boolean) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
monitorIds: checked ? [...current.monitorIds, id] : current.monitorIds.filter((monitorId) => monitorId !== id),
|
||||
}));
|
||||
}
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const secretEntered = form.type === 'telegram' ? Boolean(form.botToken.trim()) : Boolean(form.url.trim());
|
||||
const config = form.type === 'telegram' ? { botToken: form.botToken.trim(), chatId: form.chatId.trim() } : { url: form.url.trim() };
|
||||
const input: NotificationChannelInput = {
|
||||
name: form.name.trim(),
|
||||
type: form.type,
|
||||
...(editing && editing.type === form.type && !secretEntered ? {} : { config }),
|
||||
enabled: form.enabled,
|
||||
notifyManual: form.notifyManual,
|
||||
monitorIds: form.monitorIds,
|
||||
};
|
||||
if (editing) updateMutation.mutate({ id: editing.id, input }, { onSuccess: onClose });
|
||||
else createMutation.mutate(input, { onSuccess: onClose });
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && !mutation.isPending && onClose()}>
|
||||
<DialogContent className="channel-dialog">
|
||||
<DialogHeader className="channel-dialog-header">
|
||||
<p className="overline">Alert destination</p>
|
||||
<DialogTitle>{editing ? `Edit ${editing.name}` : 'Add notification channel'}</DialogTitle>
|
||||
<DialogDescription>Send incident activity to a team tool or custom integration.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="channel-form" onSubmit={submit}>
|
||||
<label className="field" htmlFor="channel-name">
|
||||
<span>Name</span>
|
||||
<Input
|
||||
id="channel-name"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
||||
maxLength={100}
|
||||
placeholder="Platform alerts"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<div className="field">
|
||||
<span id="channel-type-label">Provider</span>
|
||||
<Select value={form.type} onValueChange={(type) => setForm({ ...form, type: type as ChannelType, url: '', botToken: '' })}>
|
||||
<SelectTrigger aria-labelledby="channel-type-label">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="slack">Slack</SelectItem>
|
||||
<SelectItem value="discord">Discord</SelectItem>
|
||||
<SelectItem value="telegram">Telegram</SelectItem>
|
||||
<SelectItem value="webhook">Raw webhook</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{form.type === 'telegram' ? (
|
||||
<>
|
||||
<label className="field" htmlFor="channel-token">
|
||||
<span>Bot token</span>
|
||||
<Input
|
||||
id="channel-token"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={form.botToken}
|
||||
onChange={(event) => setForm({ ...form, botToken: event.target.value })}
|
||||
placeholder={editing?.config.botToken ?? '123456:ABC…'}
|
||||
required={!editing || editing.type !== form.type}
|
||||
/>
|
||||
</label>
|
||||
<label className="field" htmlFor="channel-chat-id">
|
||||
<span>Chat ID</span>
|
||||
<Input
|
||||
id="channel-chat-id"
|
||||
value={form.chatId}
|
||||
onChange={(event) => setForm({ ...form, chatId: event.target.value })}
|
||||
placeholder="-100123456789"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<label className="field channel-endpoint-field" htmlFor="channel-url">
|
||||
<span>{form.type === 'webhook' ? 'Webhook URL' : `${form.type === 'slack' ? 'Slack' : 'Discord'} webhook URL`}</span>
|
||||
<Input
|
||||
id="channel-url"
|
||||
type="url"
|
||||
value={form.url}
|
||||
onChange={(event) => setForm({ ...form, url: event.target.value })}
|
||||
placeholder={editing?.config.url ?? 'https://hooks.example.com/…'}
|
||||
required={!editing || editing.type !== form.type}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<fieldset className="maintenance-services channel-services">
|
||||
<legend>Services</legend>
|
||||
<DropdownMenuPrimitive.Root>
|
||||
<DropdownMenuPrimitive.Trigger asChild>
|
||||
<button className="maintenance-service-select" type="button">
|
||||
<span className={selected.length === 0 ? 'is-placeholder' : undefined}>
|
||||
{selected.length === 0
|
||||
? 'All services'
|
||||
: selected.length === 1
|
||||
? selected[0].name
|
||||
: `${selected[0].name} +${selected.length - 1} more`}
|
||||
</span>
|
||||
<span className="maintenance-service-select-meta">
|
||||
<small>{selected.length || 'Any'}</small>
|
||||
<ChevronDown />
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuPrimitive.Trigger>
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content className="maintenance-service-menu" sideOffset={5} align="start">
|
||||
{monitors.map((monitor) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
key={monitor.id}
|
||||
className="maintenance-service-option"
|
||||
checked={form.monitorIds.includes(monitor.id)}
|
||||
onCheckedChange={(checked) => toggleMonitor(monitor.id, checked === true)}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
<span className="maintenance-service-check">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<span>{monitor.name}</span>
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))}
|
||||
</DropdownMenuPrimitive.Content>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
</DropdownMenuPrimitive.Root>
|
||||
<small className="field-helper">Leave empty to notify for every service.</small>
|
||||
</fieldset>
|
||||
<div className="settings-toggle channel-toggle-option">
|
||||
<Switch id="channel-enabled" checked={form.enabled} onCheckedChange={(enabled) => setForm({ ...form, enabled })} />
|
||||
<label htmlFor="channel-enabled">
|
||||
<strong>Enable channel</strong>
|
||||
<small>Allow automated downtime and recovery alerts.</small>
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-toggle channel-toggle-option">
|
||||
<Switch
|
||||
id="channel-manual"
|
||||
checked={form.notifyManual}
|
||||
onCheckedChange={(notifyManual) => setForm({ ...form, notifyManual })}
|
||||
/>
|
||||
<label htmlFor="channel-manual">
|
||||
<strong>Manual incident updates</strong>
|
||||
<small>Notify this channel when an admin publishes or updates an incident.</small>
|
||||
</label>
|
||||
</div>
|
||||
{form.type === 'webhook' && (
|
||||
<div className="channel-payload-preview">
|
||||
<span>Raw payload</span>
|
||||
<pre>{`{ "event": "down", "monitor": { … }, "statusCode": 500, "error": "…", "at": "…" }`}</pre>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-actions compact-actions channel-dialog-footer">
|
||||
<Button variant="unstyled" className="secondary-button" type="button" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="unstyled" className="primary-button" type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? 'Saving…' : editing ? 'Save changes' : 'Add channel'}
|
||||
</Button>
|
||||
</div>
|
||||
{mutation.isError && (
|
||||
<p className="form-error" role="alert">
|
||||
{mutation.error.message}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useState } from 'react';
|
||||
import { History, Pencil, Plus, Send, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Empty, EmptyDescription, EmptyTitle } from '@/components/ui/empty';
|
||||
import type { NotificationChannel } from '../../api/channels';
|
||||
import {
|
||||
useDeleteNotificationChannelMutation,
|
||||
useNotificationDeliveriesQuery,
|
||||
useNotificationChannelsQuery,
|
||||
useTestNotificationChannelMutation,
|
||||
} from '../../queries/channels';
|
||||
import { useMonitorsQuery } from '../../queries/monitors';
|
||||
import { NotificationChannelDialog } from './NotificationChannelDialog';
|
||||
|
||||
function DeliveryHistory({ channel }: { channel: NotificationChannel }) {
|
||||
const deliveriesQuery = useNotificationDeliveriesQuery(channel.id);
|
||||
if (deliveriesQuery.isPending) return <div className="channel-history-state">Loading delivery history…</div>;
|
||||
if (deliveriesQuery.isError) return <div className="channel-history-state form-error">Unable to load delivery history.</div>;
|
||||
if (deliveriesQuery.data.deliveries.length === 0) return <div className="channel-history-state">No deliveries recorded yet.</div>;
|
||||
return (
|
||||
<div className="channel-history" aria-label={`${channel.name} delivery history`}>
|
||||
{deliveriesQuery.data.deliveries.map((delivery) => (
|
||||
<div className="channel-history-row" key={delivery.id}>
|
||||
<Badge variant={delivery.ok ? 'online' : 'offline'}>{delivery.ok ? 'Delivered' : 'Failed'}</Badge>
|
||||
<strong>{delivery.event.replaceAll('_', ' ')}</strong>
|
||||
<span>{new Date(delivery.createdAt).toLocaleString()}</span>
|
||||
<span>{delivery.statusCode ? `HTTP ${delivery.statusCode}` : delivery.error}</span>
|
||||
<small>
|
||||
{delivery.attempts} attempt{delivery.attempts === 1 ? '' : 's'}
|
||||
</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotificationChannelsPanel() {
|
||||
const channelsQuery = useNotificationChannelsQuery();
|
||||
const monitorsQuery = useMonitorsQuery();
|
||||
const deleteMutation = useDeleteNotificationChannelMutation();
|
||||
const testMutation = useTestNotificationChannelMutation();
|
||||
const [dialog, setDialog] = useState<{ open: boolean; editing: NotificationChannel | null }>({ open: false, editing: null });
|
||||
const [deleting, setDeleting] = useState<NotificationChannel | null>(null);
|
||||
const [historyId, setHistoryId] = useState<number | null>(null);
|
||||
const monitorNames = new Map(monitorsQuery.data?.monitors.map((monitor) => [monitor.id, monitor.name]));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="channel-panel-header">
|
||||
<p>Route automatic and manual incident activity to the right team.</p>
|
||||
<Button variant="unstyled" className="secondary-button" type="button" onClick={() => setDialog({ open: true, editing: null })}>
|
||||
<Plus /> Add channel
|
||||
</Button>
|
||||
</div>
|
||||
{channelsQuery.isPending ? (
|
||||
<div className="table-empty">Loading notification channels…</div>
|
||||
) : channelsQuery.isError ? (
|
||||
<Empty variant="error" className="m-6">
|
||||
<EmptyTitle>Unable to load notification channels</EmptyTitle>
|
||||
</Empty>
|
||||
) : channelsQuery.data.channels.length === 0 ? (
|
||||
<Empty className="channel-empty">
|
||||
<EmptyTitle>No notification channels</EmptyTitle>
|
||||
<EmptyDescription>Add Slack, Discord, Telegram, or a raw webhook destination.</EmptyDescription>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="channel-list">
|
||||
{channelsQuery.data.channels.map((channel) => (
|
||||
<article className="channel-row" key={channel.id}>
|
||||
<div className="channel-row-summary">
|
||||
<div className="channel-main">
|
||||
<div className="channel-title">
|
||||
<strong>{channel.name}</strong>
|
||||
<Badge variant="maintenance">{channel.type}</Badge>
|
||||
{!channel.enabled && <span className="maintenance-disabled">Disabled</span>}
|
||||
</div>
|
||||
<div className="channel-delivery">
|
||||
{channel.lastDelivery ? (
|
||||
<Badge variant={channel.lastDelivery.ok ? 'online' : 'offline'}>
|
||||
{channel.lastDelivery.ok ? 'Delivered' : 'Failed'}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="pending">No deliveries</Badge>
|
||||
)}
|
||||
{channel.lastDelivery && (
|
||||
<span>
|
||||
{new Date(channel.lastDelivery.createdAt).toLocaleString()} · {channel.lastDelivery.event.replaceAll('_', ' ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="channel-services">
|
||||
{channel.monitorIds.length === 0 ? (
|
||||
<em>All services</em>
|
||||
) : (
|
||||
channel.monitorIds.map((id) => <span key={id}>{monitorNames.get(id) ?? `Service ${id}`}</span>)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="channel-actions">
|
||||
<Button
|
||||
variant="unstyled"
|
||||
className="secondary-button compact-button"
|
||||
type="button"
|
||||
disabled={testMutation.isPending}
|
||||
onClick={() => testMutation.mutate(channel.id)}
|
||||
>
|
||||
<Send /> Test
|
||||
</Button>
|
||||
<Button
|
||||
variant="unstyled"
|
||||
className="secondary-button compact-button"
|
||||
type="button"
|
||||
aria-expanded={historyId === channel.id}
|
||||
onClick={() => setHistoryId((current) => (current === channel.id ? null : channel.id))}
|
||||
>
|
||||
<History /> History
|
||||
</Button>
|
||||
<Button
|
||||
variant="unstyled"
|
||||
className="icon-button"
|
||||
type="button"
|
||||
aria-label={`Edit ${channel.name}`}
|
||||
onClick={() => setDialog({ open: true, editing: channel })}
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button
|
||||
variant="unstyled"
|
||||
className="icon-button danger-icon-button"
|
||||
type="button"
|
||||
aria-label={`Delete ${channel.name}`}
|
||||
onClick={() => setDeleting(channel)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{historyId === channel.id && <DeliveryHistory channel={channel} />}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{testMutation.isError && <p className="form-error channel-feedback">{testMutation.error.message}</p>}
|
||||
{testMutation.isSuccess && <p className="settings-success channel-feedback">Test notification delivered.</p>}
|
||||
{dialog.open && <NotificationChannelDialog editing={dialog.editing} onClose={() => setDialog({ open: false, editing: null })} />}
|
||||
<AlertDialog open={deleting !== null} onOpenChange={(open) => !open && !deleteMutation.isPending && setDeleting(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<p className="overline">Confirm</p>
|
||||
<AlertDialogTitle>Delete {deleting?.name}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>This removes its routing and delivery history. Incident history is unchanged.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="form-actions compact-actions">
|
||||
<AlertDialogCancel>
|
||||
<Button variant="unstyled" className="secondary-button" type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction>
|
||||
<Button
|
||||
variant="unstyled"
|
||||
className="danger-button"
|
||||
type="button"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
if (deleting) deleteMutation.mutate(deleting.id, { onSuccess: () => setDeleting(null) });
|
||||
}}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting…' : 'Delete'}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +1,16 @@
|
||||
import { type FormEvent, useState } from 'react';
|
||||
import { ArrowLeft, BellRing, Send, Sparkles, Wrench } from 'lucide-react';
|
||||
import { ArrowLeft, BellRing, Sparkles, Wrench } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Empty, EmptyTitle } from '@/components/ui/empty';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import type { AiSettings, NotificationSettings } from '../api/settings';
|
||||
import type { AiSettings } from '../api/settings';
|
||||
import { AppHeader } from '../components/AppHeader';
|
||||
import { MaintenanceWindowsPanel } from '../components/settings/MaintenanceWindowsPanel';
|
||||
import { NotificationChannelsPanel } from '../components/settings/NotificationChannelsPanel';
|
||||
import { navigate } from '../lib/router';
|
||||
import {
|
||||
useAiSettingsQuery,
|
||||
useNotificationSettingsQuery,
|
||||
useTestAiSettingsMutation,
|
||||
useTestNotificationWebhookMutation,
|
||||
useUpdateAiSettingsMutation,
|
||||
useUpdateNotificationSettingsMutation,
|
||||
} from '../queries/settings';
|
||||
|
||||
function SettingsForm({ settings }: { settings: NotificationSettings }) {
|
||||
const [webhookUrl, setWebhookUrl] = useState(settings.webhookUrl ?? '');
|
||||
const [webhookEnabled, setWebhookEnabled] = useState(settings.webhookEnabled);
|
||||
const updateMutation = useUpdateNotificationSettingsMutation();
|
||||
const testMutation = useTestNotificationWebhookMutation();
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
updateMutation.mutate({ webhookUrl: webhookUrl.trim() || null, webhookEnabled });
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="settings-form" onSubmit={submit}>
|
||||
<label className="field" htmlFor="webhook-url">
|
||||
<span>Webhook URL</span>
|
||||
<Input
|
||||
id="webhook-url"
|
||||
type="url"
|
||||
value={webhookUrl}
|
||||
onChange={(event) => setWebhookUrl(event.target.value)}
|
||||
placeholder="https://hooks.example.com/services/…"
|
||||
/>
|
||||
</label>
|
||||
<div className="settings-toggle">
|
||||
<Switch id="webhook-enabled" checked={webhookEnabled} onCheckedChange={setWebhookEnabled} />
|
||||
<label htmlFor="webhook-enabled">
|
||||
<strong>Enable incident alerts</strong>
|
||||
<small>Send a webhook when a monitor goes down and when it recovers.</small>
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-actions">
|
||||
<Button
|
||||
variant="unstyled"
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={() => testMutation.mutate()}
|
||||
disabled={!settings.webhookUrl || testMutation.isPending}
|
||||
>
|
||||
<Send /> {testMutation.isPending ? 'Sending…' : 'Send test'}
|
||||
</Button>
|
||||
<Button variant="unstyled" className="primary-button" type="submit" disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? 'Saving…' : 'Save settings'}
|
||||
</Button>
|
||||
</div>
|
||||
{updateMutation.isSuccess && <p className="settings-success">Notification settings saved.</p>}
|
||||
{testMutation.isSuccess && <p className="settings-success">Test webhook delivered successfully.</p>}
|
||||
{(updateMutation.isError || testMutation.isError) && (
|
||||
<p className="form-error">{(updateMutation.error ?? testMutation.error)?.message ?? 'Request failed'}</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
import { useAiSettingsQuery, useTestAiSettingsMutation, useUpdateAiSettingsMutation } from '../queries/settings';
|
||||
|
||||
function AiSettingsForm({ settings }: { settings: AiSettings }) {
|
||||
const [baseUrl, setBaseUrl] = useState(settings.baseUrl ?? 'https://api.openai.com/v1');
|
||||
@@ -148,7 +89,6 @@ function AiSettingsForm({ settings }: { settings: AiSettings }) {
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const settingsQuery = useNotificationSettingsQuery();
|
||||
const aiSettingsQuery = useAiSettingsQuery();
|
||||
return (
|
||||
<div className="dashboard-shell">
|
||||
@@ -169,19 +109,11 @@ export function SettingsPage() {
|
||||
<BellRing />
|
||||
</span>
|
||||
<div>
|
||||
<h2>Incident webhook</h2>
|
||||
<p>Upwatch sends a compact JSON payload for down and recovery events. Delivery failures never interrupt monitoring.</p>
|
||||
<h2>Notification channels</h2>
|
||||
<p>Route incidents to Slack, Discord, Telegram, or existing webhook integrations, with delivery history.</p>
|
||||
</div>
|
||||
</div>
|
||||
{settingsQuery.isPending ? (
|
||||
<div className="table-empty">Loading settings…</div>
|
||||
) : settingsQuery.isError ? (
|
||||
<Empty variant="error" className="m-6">
|
||||
<EmptyTitle>Unable to load notification settings</EmptyTitle>
|
||||
</Empty>
|
||||
) : (
|
||||
<SettingsForm key={settingsQuery.data.settings.updatedAt ?? 'new'} settings={settingsQuery.data.settings} />
|
||||
)}
|
||||
<NotificationChannelsPanel />
|
||||
</section>
|
||||
</Card>
|
||||
<Card asChild>
|
||||
@@ -222,16 +154,6 @@ export function SettingsPage() {
|
||||
<MaintenanceWindowsPanel />
|
||||
</section>
|
||||
</Card>
|
||||
<section className="payload-preview">
|
||||
<p className="overline">Payload preview</p>
|
||||
<pre>{`{
|
||||
"event": "down",
|
||||
"monitor": { "id": 12, "name": "API", "url": "https://api.example.com" },
|
||||
"statusCode": 500,
|
||||
"error": "Expected HTTP 200, received 500",
|
||||
"at": "2026-08-28T03:25:00.000Z"
|
||||
}`}</pre>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
createNotificationChannel,
|
||||
deleteNotificationChannel,
|
||||
getNotificationChannels,
|
||||
getNotificationDeliveries,
|
||||
testNotificationChannel,
|
||||
updateNotificationChannel,
|
||||
type NotificationChannelInput,
|
||||
} from '../api/channels';
|
||||
import { queryClient } from '../lib/query-client';
|
||||
|
||||
export const channelKeys = {
|
||||
all: ['channels'] as const,
|
||||
deliveries: (id: number) => ['channels', id, 'deliveries'] as const,
|
||||
};
|
||||
export function useNotificationChannelsQuery() {
|
||||
return useQuery({ queryKey: channelKeys.all, queryFn: ({ signal }) => getNotificationChannels(signal), refetchInterval: 30_000 });
|
||||
}
|
||||
function invalidateChannels() {
|
||||
return queryClient.invalidateQueries({ queryKey: channelKeys.all });
|
||||
}
|
||||
export function useCreateNotificationChannelMutation() {
|
||||
return useMutation({ mutationFn: (input: NotificationChannelInput) => createNotificationChannel(input), onSuccess: invalidateChannels });
|
||||
}
|
||||
export function useUpdateNotificationChannelMutation() {
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: number; input: Partial<NotificationChannelInput> }) => updateNotificationChannel(id, input),
|
||||
onSuccess: invalidateChannels,
|
||||
});
|
||||
}
|
||||
export function useDeleteNotificationChannelMutation() {
|
||||
return useMutation({ mutationFn: deleteNotificationChannel, onSuccess: invalidateChannels });
|
||||
}
|
||||
export function useTestNotificationChannelMutation() {
|
||||
return useMutation({ mutationFn: testNotificationChannel, onSuccess: invalidateChannels });
|
||||
}
|
||||
export function useNotificationDeliveriesQuery(id: number) {
|
||||
return useQuery({ queryKey: channelKeys.deliveries(id), queryFn: ({ signal }) => getNotificationDeliveries(id, 20, signal) });
|
||||
}
|
||||
@@ -1,40 +1,12 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
getAiSettings,
|
||||
getNotificationSettings,
|
||||
testAiSettings,
|
||||
testNotificationWebhook,
|
||||
updateAiSettings,
|
||||
updateNotificationSettings,
|
||||
type AiSettingsInput,
|
||||
type NotificationSettingsInput,
|
||||
} from '../api/settings';
|
||||
import { getAiSettings, testAiSettings, updateAiSettings, type AiSettingsInput } from '../api/settings';
|
||||
import { queryClient } from '../lib/query-client';
|
||||
|
||||
export const settingsKeys = {
|
||||
all: ['settings'] as const,
|
||||
notifications: () => [...settingsKeys.all, 'notifications'] as const,
|
||||
ai: () => [...settingsKeys.all, 'ai'] as const,
|
||||
};
|
||||
|
||||
export function useNotificationSettingsQuery() {
|
||||
return useQuery({
|
||||
queryKey: settingsKeys.notifications(),
|
||||
queryFn: ({ signal }) => getNotificationSettings(signal),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateNotificationSettingsMutation() {
|
||||
return useMutation({
|
||||
mutationFn: (input: NotificationSettingsInput) => updateNotificationSettings(input),
|
||||
onSuccess: (data) => queryClient.setQueryData(settingsKeys.notifications(), data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTestNotificationWebhookMutation() {
|
||||
return useMutation({ mutationFn: testNotificationWebhook });
|
||||
}
|
||||
|
||||
export function useAiSettingsQuery() {
|
||||
return useQuery({
|
||||
queryKey: settingsKeys.ai(),
|
||||
|
||||
+299
-2
@@ -1403,6 +1403,256 @@ button {
|
||||
color: #16754f;
|
||||
background: #f2fbf7;
|
||||
}
|
||||
.channel-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 20px 26px;
|
||||
border-bottom: 1px solid #ededed;
|
||||
}
|
||||
.channel-panel-header p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.channel-panel-header svg,
|
||||
.channel-actions svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
.channel-empty {
|
||||
padding: 44px 24px;
|
||||
}
|
||||
.channel-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 21px 26px;
|
||||
}
|
||||
.channel-row-summary {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
.channel-row + .channel-row {
|
||||
border-top: 1px solid #ededed;
|
||||
}
|
||||
.channel-main {
|
||||
min-width: 0;
|
||||
}
|
||||
.channel-title,
|
||||
.channel-delivery,
|
||||
.channel-services,
|
||||
.channel-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.channel-title > strong {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.channel-delivery {
|
||||
margin: 8px 0 10px;
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
}
|
||||
.channel-services span,
|
||||
.channel-services em {
|
||||
padding: 4px 7px;
|
||||
border: 1px solid #dedede;
|
||||
border-radius: 5px;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
color: var(--muted);
|
||||
background: #fafafa;
|
||||
}
|
||||
.channel-actions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.compact-button {
|
||||
min-height: 34px;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
.channel-feedback {
|
||||
margin: 0 26px 20px;
|
||||
}
|
||||
.channel-history {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
gap: 1px;
|
||||
margin-top: 18px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #e6e6e6;
|
||||
}
|
||||
.channel-history-row {
|
||||
display: grid;
|
||||
grid-template-columns: 78px minmax(100px, 0.8fr) minmax(145px, 1fr) minmax(120px, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
font-size: 11px;
|
||||
background: #fff;
|
||||
}
|
||||
.channel-history-row strong {
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.channel-history-row span,
|
||||
.channel-history-row small,
|
||||
.channel-history-state {
|
||||
color: var(--muted);
|
||||
}
|
||||
.channel-history-state {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.channel-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 17px 16px;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 20px 26px 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-color: #bdc9c3 transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.channel-dialog {
|
||||
display: flex;
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
max-width: 720px;
|
||||
max-height: min(90dvh, 760px);
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 24px 70px rgb(22 62 45 / 0.15),
|
||||
0 4px 16px rgb(0 0 0 / 0.07);
|
||||
}
|
||||
.channel-dialog-header {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
gap: 5px;
|
||||
padding: 24px 64px 20px 26px;
|
||||
border-bottom: 1px solid #e9ecea;
|
||||
background: linear-gradient(180deg, #fff, #fdfefd);
|
||||
}
|
||||
.channel-dialog-header .overline {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.channel-dialog-header [data-slot='dialog-title'] {
|
||||
font-size: 20px;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.35px;
|
||||
}
|
||||
.channel-dialog-header [data-slot='dialog-description'] {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.channel-dialog > [data-slot='dialog-close'] {
|
||||
top: 19px;
|
||||
right: 20px;
|
||||
}
|
||||
.channel-form > .field {
|
||||
min-width: 0;
|
||||
}
|
||||
.channel-form .field {
|
||||
gap: 6px;
|
||||
}
|
||||
.channel-endpoint-field,
|
||||
.channel-form > .channel-services,
|
||||
.channel-form > .channel-payload-preview,
|
||||
.channel-form > .form-error,
|
||||
.channel-form > .form-actions.channel-dialog-footer {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.channel-form > .channel-services {
|
||||
display: block;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid #edf0ee;
|
||||
}
|
||||
.channel-form > .channel-services legend {
|
||||
float: left;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
.channel-form > .channel-services .maintenance-service-select {
|
||||
clear: both;
|
||||
}
|
||||
.channel-form > .channel-services .field-helper {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.channel-toggle-option {
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
padding: 14px;
|
||||
border: 1px solid #e2e6e4;
|
||||
border-radius: 8px;
|
||||
background: #fbfcfb;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
background-color 160ms ease;
|
||||
}
|
||||
.channel-toggle-option:hover {
|
||||
border-color: #cbd6d1;
|
||||
background: #f8fbf9;
|
||||
}
|
||||
.channel-toggle-option [role='switch'] {
|
||||
margin-top: 1px;
|
||||
}
|
||||
.channel-toggle-option label {
|
||||
min-width: 0;
|
||||
}
|
||||
.channel-toggle-option small {
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.channel-dialog-footer {
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 9px;
|
||||
width: auto;
|
||||
margin: 3px -26px 0;
|
||||
padding: 15px 26px;
|
||||
border-top: 1px solid #e4e8e6;
|
||||
background: rgb(250 251 250 / 0.97);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.channel-dialog-footer .primary-button,
|
||||
.channel-dialog-footer .secondary-button {
|
||||
min-height: 38px;
|
||||
padding: 7px 15px;
|
||||
}
|
||||
.channel-payload-preview {
|
||||
padding: 12px;
|
||||
border: 1px solid #e3e3e3;
|
||||
border-radius: 6px;
|
||||
background: #fafafa;
|
||||
}
|
||||
.channel-payload-preview > span {
|
||||
display: block;
|
||||
margin-bottom: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.channel-payload-preview pre {
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.maintenance-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2444,6 +2694,36 @@ button {
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.channel-dialog {
|
||||
width: calc(100vw - 20px);
|
||||
max-height: calc(100dvh - 20px);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.channel-dialog-header {
|
||||
padding: 21px 54px 18px 20px;
|
||||
}
|
||||
.channel-dialog > [data-slot='dialog-close'] {
|
||||
top: 16px;
|
||||
right: 14px;
|
||||
}
|
||||
.channel-form {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 18px 20px 0;
|
||||
}
|
||||
.channel-endpoint-field,
|
||||
.channel-form > .channel-services,
|
||||
.channel-form > .channel-payload-preview,
|
||||
.channel-form > .form-error,
|
||||
.channel-dialog-footer {
|
||||
grid-column: auto;
|
||||
}
|
||||
.channel-dialog-footer {
|
||||
margin: 3px -20px 0;
|
||||
padding: 14px 20px;
|
||||
}
|
||||
.channel-dialog-footer button {
|
||||
flex: 1;
|
||||
}
|
||||
.auth-page {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
@@ -2525,15 +2805,32 @@ button {
|
||||
padding: 20px;
|
||||
}
|
||||
.maintenance-panel-header,
|
||||
.maintenance-window-row {
|
||||
.maintenance-window-row,
|
||||
.channel-panel-header,
|
||||
.channel-row {
|
||||
padding-right: 20px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.maintenance-panel-header,
|
||||
.maintenance-window-row {
|
||||
.maintenance-window-row,
|
||||
.channel-panel-header,
|
||||
.channel-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
.channel-actions {
|
||||
align-self: stretch;
|
||||
}
|
||||
.channel-row-summary {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
.channel-history {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.channel-history-row {
|
||||
min-width: 650px;
|
||||
}
|
||||
.maintenance-window-actions {
|
||||
align-self: flex-end;
|
||||
margin-top: -48px;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { generateIncidentMessage } from '../ai/incident-message';
|
||||
import { getDb } from '../db/client';
|
||||
import { monitors } from '../db/schema';
|
||||
import { loadActiveMaintenance } from '../maintenance/windows';
|
||||
import { sendIncidentAlert } from '../notifications/webhook';
|
||||
import { dispatchNotification, MAX_NOTIFICATIONS_PER_RUN, type NotificationBudget } from '../notifications/dispatch';
|
||||
import { type AlertTransition, buildResultStatements } from './persist-result';
|
||||
import { runCheck, runCheckWithRetries, type RetryBudget } from './run-check';
|
||||
|
||||
@@ -72,16 +72,25 @@ export async function runDueChecks(env: Env, ctx?: Pick<ExecutionContext, 'waitU
|
||||
await db.batch(statements as [(typeof statements)[number], ...typeof statements]);
|
||||
|
||||
let aiMessagesQueued = 0;
|
||||
const notificationBudget: NotificationBudget = { remaining: MAX_NOTIFICATIONS_PER_RUN };
|
||||
const notifications = persisted.flatMap((item) => {
|
||||
if (item.transition !== 'opened' && item.transition !== 'resolved') return [];
|
||||
const kind: AlertTransition = item.transition;
|
||||
const work: Promise<unknown>[] = [
|
||||
sendIncidentAlert(env, {
|
||||
monitor: item.monitor,
|
||||
kind,
|
||||
result: item.result,
|
||||
at: item.checkedAt,
|
||||
}),
|
||||
dispatchNotification(
|
||||
env,
|
||||
{
|
||||
monitor: { id: item.monitor.id, name: item.monitor.name, url: item.monitor.url },
|
||||
kind: kind === 'opened' ? 'down' : 'recovered',
|
||||
incidentId: null,
|
||||
title: kind === 'opened' ? `${item.monitor.name} is down` : `${item.monitor.name} recovered`,
|
||||
body: item.result.error,
|
||||
statusCode: item.result.statusCode,
|
||||
error: item.result.error,
|
||||
at: item.checkedAt,
|
||||
},
|
||||
notificationBudget,
|
||||
),
|
||||
];
|
||||
if (item.transition === 'opened' && item.monitor.alertsEnabled && aiMessagesQueued < MAX_AI_MESSAGES_PER_RUN) {
|
||||
aiMessagesQueued += 1;
|
||||
|
||||
+50
-8
@@ -7,14 +7,6 @@ export const adminCredentials = sqliteTable('admin_credentials', {
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
});
|
||||
|
||||
export const notificationSettings = sqliteTable('notification_settings', {
|
||||
id: integer('id').primaryKey(),
|
||||
webhookUrl: text('webhook_url'),
|
||||
webhookEnabled: integer('webhook_enabled', { mode: 'boolean' }).notNull().default(false),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
});
|
||||
|
||||
export const aiSettings = sqliteTable('ai_settings', {
|
||||
id: integer('id').primaryKey(),
|
||||
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(false),
|
||||
@@ -172,6 +164,56 @@ export const incidentUpdates = sqliteTable(
|
||||
(table) => [index('incident_updates_incident_id_created_at_idx').on(table.incidentId, table.createdAt)],
|
||||
);
|
||||
|
||||
export const notificationChannels = sqliteTable(
|
||||
'notification_channels',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
name: text('name').notNull(),
|
||||
type: text('type').notNull(),
|
||||
config: text('config').notNull(),
|
||||
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
notifyManual: integer('notify_manual', { mode: 'boolean' }).notNull().default(true),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
},
|
||||
(table) => [index('notification_channels_enabled_idx').on(table.enabled)],
|
||||
);
|
||||
|
||||
export const notificationChannelMonitors = sqliteTable(
|
||||
'notification_channel_monitors',
|
||||
{
|
||||
channelId: integer('channel_id')
|
||||
.notNull()
|
||||
.references(() => notificationChannels.id, { onDelete: 'cascade' }),
|
||||
monitorId: integer('monitor_id')
|
||||
.notNull()
|
||||
.references(() => monitors.id, { onDelete: 'cascade' }),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.channelId, table.monitorId] }),
|
||||
index('notification_channel_monitors_monitor_id_idx').on(table.monitorId),
|
||||
],
|
||||
);
|
||||
|
||||
export const notificationDeliveries = sqliteTable(
|
||||
'notification_deliveries',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
channelId: integer('channel_id')
|
||||
.notNull()
|
||||
.references(() => notificationChannels.id, { onDelete: 'cascade' }),
|
||||
incidentId: integer('incident_id').references(() => incidents.id, { onDelete: 'cascade' }),
|
||||
monitorId: integer('monitor_id').references(() => monitors.id, { onDelete: 'cascade' }),
|
||||
event: text('event').notNull(),
|
||||
ok: integer('ok', { mode: 'boolean' }).notNull(),
|
||||
statusCode: integer('status_code'),
|
||||
error: text('error'),
|
||||
attempts: integer('attempts').notNull().default(1),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
},
|
||||
(table) => [index('notification_deliveries_channel_id_created_at_idx').on(table.channelId, table.createdAt)],
|
||||
);
|
||||
|
||||
export const monitorDailyStats = sqliteTable(
|
||||
'monitor_daily_stats',
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Hono } from 'hono';
|
||||
import { csrf } from 'hono/csrf';
|
||||
import { runDueChecks } from './checks/run-due-checks';
|
||||
import authRoutes from './routes/auth';
|
||||
import channelRoutes from './routes/channels';
|
||||
import incidentRoutes from './routes/incidents';
|
||||
import maintenanceRoutes from './routes/maintenance';
|
||||
import monitorRoutes from './routes/monitors';
|
||||
@@ -27,6 +28,7 @@ app.get('/api/health', async (context) => {
|
||||
});
|
||||
|
||||
app.route('/', authRoutes);
|
||||
app.route('/api/channels', channelRoutes);
|
||||
app.route('/api/incidents', incidentRoutes);
|
||||
app.route('/api/maintenance', maintenanceRoutes);
|
||||
app.route('/api/monitors', monitorRoutes);
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { and, desc, eq, inArray, isNotNull, isNull } from 'drizzle-orm';
|
||||
import { getDb } from '../db/client';
|
||||
import { incidents, incidentMonitors, notificationChannelMonitors, notificationChannels, notificationDeliveries } from '../db/schema';
|
||||
import {
|
||||
CHANNEL_TYPES,
|
||||
formatChannel,
|
||||
parseChannelConfig,
|
||||
type ChannelType,
|
||||
type NotificationEvent,
|
||||
type OutboundRequest,
|
||||
} from './providers';
|
||||
|
||||
export const MAX_NOTIFICATIONS_PER_RUN = 40;
|
||||
export type NotificationBudget = { remaining: number };
|
||||
|
||||
type DeliveryResult = { ok: boolean; statusCode: number | null; error: string | null; attempts: number };
|
||||
|
||||
function isChannelType(value: string): value is ChannelType {
|
||||
return CHANNEL_TYPES.some((type) => type === value);
|
||||
}
|
||||
|
||||
async function sendRequest(request: OutboundRequest): Promise<DeliveryResult> {
|
||||
let attempts = 0;
|
||||
for (;;) {
|
||||
attempts += 1;
|
||||
try {
|
||||
const response = await fetch(request.url, { method: 'POST', headers: request.headers, body: request.body });
|
||||
await response.body?.cancel();
|
||||
if (response.ok) return { ok: true, statusCode: response.status, error: null, attempts };
|
||||
const retryable = response.status === 429 || response.status >= 500;
|
||||
if (!retryable || attempts >= 2) {
|
||||
return { ok: false, statusCode: response.status, error: `HTTP ${response.status}`, attempts };
|
||||
}
|
||||
} catch (error) {
|
||||
if (attempts >= 2) {
|
||||
return {
|
||||
ok: false,
|
||||
statusCode: null,
|
||||
error: error instanceof Error ? error.message.slice(0, 500) : String(error).slice(0, 500),
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
|
||||
async function persistDeliveries(env: Env, event: NotificationEvent, results: Array<{ channelId: number; result: DeliveryResult }>) {
|
||||
if (results.length === 0) return;
|
||||
const db = getDb(env);
|
||||
const createdAt = new Date();
|
||||
const statements = results.map(({ channelId, result }) =>
|
||||
db.insert(notificationDeliveries).values({
|
||||
channelId,
|
||||
incidentId: event.incidentId,
|
||||
monitorId: event.monitor?.id ?? null,
|
||||
event: event.kind,
|
||||
ok: result.ok,
|
||||
statusCode: result.statusCode,
|
||||
error: result.error,
|
||||
attempts: result.attempts,
|
||||
createdAt,
|
||||
}),
|
||||
);
|
||||
await db.batch(statements as [(typeof statements)[number], ...typeof statements]);
|
||||
}
|
||||
|
||||
export async function dispatchNotification(env: Env, event: NotificationEvent, budget?: NotificationBudget): Promise<void> {
|
||||
const db = getDb(env);
|
||||
let effectiveEvent = event;
|
||||
if (event.incidentId === null && event.monitor && (event.kind === 'down' || event.kind === 'recovered')) {
|
||||
const [incident] = await db
|
||||
.select({ id: incidents.id })
|
||||
.from(incidents)
|
||||
.innerJoin(incidentMonitors, eq(incidentMonitors.incidentId, incidents.id))
|
||||
.where(
|
||||
and(
|
||||
eq(incidentMonitors.monitorId, event.monitor.id),
|
||||
eq(incidents.source, 'auto'),
|
||||
event.kind === 'down' ? isNull(incidents.resolvedAt) : isNotNull(incidents.resolvedAt),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(incidents.updatedAt))
|
||||
.limit(1);
|
||||
if (incident) effectiveEvent = { ...event, incidentId: incident.id };
|
||||
}
|
||||
let targetMonitorIds = event.monitor ? [event.monitor.id] : [];
|
||||
if (!event.monitor && event.incidentId !== null) {
|
||||
const rows = await db
|
||||
.select({ monitorId: incidentMonitors.monitorId })
|
||||
.from(incidentMonitors)
|
||||
.where(eq(incidentMonitors.incidentId, event.incidentId));
|
||||
targetMonitorIds = rows.map((row) => row.monitorId);
|
||||
}
|
||||
if (event.monitor) {
|
||||
const monitor = await env.DB.prepare('SELECT alerts_enabled FROM monitors WHERE id = ?').bind(event.monitor.id).first<{
|
||||
alerts_enabled: number;
|
||||
}>();
|
||||
if (!monitor || monitor.alerts_enabled !== 1) return;
|
||||
}
|
||||
|
||||
const channels = await db
|
||||
.select()
|
||||
.from(notificationChannels)
|
||||
.where(
|
||||
event.kind === 'manual_opened' || event.kind === 'manual_update'
|
||||
? and(eq(notificationChannels.enabled, true), eq(notificationChannels.notifyManual, true))
|
||||
: eq(notificationChannels.enabled, true),
|
||||
);
|
||||
if (channels.length === 0) return;
|
||||
|
||||
const assignments = await db
|
||||
.select()
|
||||
.from(notificationChannelMonitors)
|
||||
.where(
|
||||
inArray(
|
||||
notificationChannelMonitors.channelId,
|
||||
channels.map((channel) => channel.id),
|
||||
),
|
||||
);
|
||||
const monitorIdsByChannel = new Map<number, number[]>();
|
||||
for (const assignment of assignments) {
|
||||
const ids = monitorIdsByChannel.get(assignment.channelId);
|
||||
if (ids) ids.push(assignment.monitorId);
|
||||
else monitorIdsByChannel.set(assignment.channelId, [assignment.monitorId]);
|
||||
}
|
||||
const routed = channels.filter((channel) => {
|
||||
const monitorIds = monitorIdsByChannel.get(channel.id) ?? [];
|
||||
return monitorIds.length === 0 || monitorIds.some((monitorId) => targetMonitorIds.includes(monitorId));
|
||||
});
|
||||
|
||||
const available = Math.max(0, budget?.remaining ?? MAX_NOTIFICATIONS_PER_RUN);
|
||||
const sendable = routed.slice(0, available);
|
||||
if (budget) budget.remaining -= sendable.length;
|
||||
const skipped = routed.slice(sendable.length).map((channel) => ({
|
||||
channelId: channel.id,
|
||||
result: { ok: false, statusCode: null, error: 'skipped: per-run limit', attempts: 0 },
|
||||
}));
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
sendable.map(async (channel) => {
|
||||
if (!isChannelType(channel.type)) throw new Error(`Unsupported channel type: ${channel.type}`);
|
||||
let rawConfig: unknown;
|
||||
try {
|
||||
rawConfig = JSON.parse(channel.config);
|
||||
} catch {
|
||||
throw new Error('Invalid stored channel configuration');
|
||||
}
|
||||
const config = parseChannelConfig(channel.type, rawConfig);
|
||||
if (typeof config === 'string') throw new Error(config);
|
||||
return { channelId: channel.id, result: await sendRequest(formatChannel(channel.type, config, effectiveEvent)) };
|
||||
}),
|
||||
);
|
||||
const delivered = settled.map((result, index) =>
|
||||
result.status === 'fulfilled'
|
||||
? result.value
|
||||
: {
|
||||
channelId: sendable[index].id,
|
||||
result: {
|
||||
ok: false,
|
||||
statusCode: null,
|
||||
error: result.reason instanceof Error ? result.reason.message.slice(0, 500) : String(result.reason).slice(0, 500),
|
||||
attempts: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
await persistDeliveries(env, effectiveEvent, [...delivered, ...skipped]);
|
||||
}
|
||||
|
||||
export async function dispatchTest(env: Env, channelId: number): Promise<{ ok: boolean; error: string | null }> {
|
||||
const db = getDb(env);
|
||||
const [channel] = await db.select().from(notificationChannels).where(eq(notificationChannels.id, channelId)).limit(1);
|
||||
if (!channel) return { ok: false, error: 'Notification channel not found' };
|
||||
const event: NotificationEvent = {
|
||||
kind: 'test',
|
||||
monitor: null,
|
||||
incidentId: null,
|
||||
title: 'Upwatch test',
|
||||
body: 'Your notification channel is configured correctly.',
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
at: new Date(),
|
||||
};
|
||||
let result: DeliveryResult;
|
||||
try {
|
||||
if (!isChannelType(channel.type)) throw new Error(`Unsupported channel type: ${channel.type}`);
|
||||
const config = parseChannelConfig(channel.type, JSON.parse(channel.config) as unknown);
|
||||
if (typeof config === 'string') throw new Error(config);
|
||||
result = await sendRequest(formatChannel(channel.type, config, event));
|
||||
} catch (error) {
|
||||
result = { ok: false, statusCode: null, error: error instanceof Error ? error.message : String(error), attempts: 0 };
|
||||
}
|
||||
await persistDeliveries(env, event, [{ channelId, result }]);
|
||||
return { ok: result.ok, error: result.error };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Provider, UrlConfig } from './types';
|
||||
import { eventColor, eventLabel, parseSafeUrl, secretPreview } from './types';
|
||||
|
||||
export const discordProvider: Provider<UrlConfig> = {
|
||||
parseConfig: parseSafeUrl,
|
||||
maskConfig: (config) => ({ url: secretPreview(config.url), configSet: true }),
|
||||
format(config, event) {
|
||||
const fields = [
|
||||
event.monitor ? { name: 'Service', value: event.monitor.name, inline: true } : null,
|
||||
event.statusCode !== null ? { name: 'Status code', value: String(event.statusCode), inline: true } : null,
|
||||
event.error ? { name: 'Error', value: event.error.slice(0, 1024), inline: false } : null,
|
||||
].filter(Boolean);
|
||||
return {
|
||||
url: config.url,
|
||||
headers: { 'Content-Type': 'application/json', 'User-Agent': 'Upwatch/1.0 (+notification)' },
|
||||
body: JSON.stringify({
|
||||
embeds: [
|
||||
{
|
||||
title: `${eventLabel(event.kind)} · ${event.monitor?.name ?? 'Upwatch'}`,
|
||||
...(event.monitor ? { url: event.monitor.url } : {}),
|
||||
description: event.body ?? event.title,
|
||||
color: Number.parseInt(eventColor(event.kind).slice(1), 16),
|
||||
fields,
|
||||
timestamp: event.at.toISOString(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { discordProvider } from './discord';
|
||||
import { slackProvider } from './slack';
|
||||
import { telegramProvider } from './telegram';
|
||||
import type { ChannelConfig, ChannelType, NotificationEvent, OutboundRequest } from './types';
|
||||
import { webhookProvider } from './webhook';
|
||||
|
||||
export * from './types';
|
||||
export { discordProvider, slackProvider, telegramProvider, webhookProvider };
|
||||
|
||||
export function parseChannelConfig(type: ChannelType, value: unknown): ChannelConfig | string {
|
||||
if (type === 'telegram') return telegramProvider.parseConfig(value);
|
||||
return ({ slack: slackProvider, discord: discordProvider, webhook: webhookProvider } as const)[type].parseConfig(value);
|
||||
}
|
||||
|
||||
export function maskChannelConfig(type: ChannelType, config: ChannelConfig) {
|
||||
if (type === 'telegram') return telegramProvider.maskConfig(config as { botToken: string; chatId: string });
|
||||
return ({ slack: slackProvider, discord: discordProvider, webhook: webhookProvider } as const)[type].maskConfig(
|
||||
config as { url: string },
|
||||
);
|
||||
}
|
||||
|
||||
export function formatChannel(type: ChannelType, config: ChannelConfig, event: NotificationEvent): OutboundRequest {
|
||||
if (type === 'telegram') return telegramProvider.format(config as { botToken: string; chatId: string }, event);
|
||||
return ({ slack: slackProvider, discord: discordProvider, webhook: webhookProvider } as const)[type].format(
|
||||
config as { url: string },
|
||||
event,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Provider, UrlConfig } from './types';
|
||||
import { eventColor, eventLabel, parseSafeUrl, secretPreview } from './types';
|
||||
|
||||
export const slackProvider: Provider<UrlConfig> = {
|
||||
parseConfig: parseSafeUrl,
|
||||
maskConfig: (config) => ({ url: secretPreview(config.url), configSet: true }),
|
||||
format(config, event) {
|
||||
const fields = [
|
||||
event.monitor ? { type: 'mrkdwn', text: `*Service*\n${event.monitor.name}` } : null,
|
||||
event.monitor ? { type: 'mrkdwn', text: `*URL*\n${event.monitor.url}` } : null,
|
||||
event.statusCode !== null ? { type: 'mrkdwn', text: `*Status code*\n${event.statusCode}` } : null,
|
||||
{ type: 'mrkdwn', text: `*Time*\n${event.at.toISOString()}` },
|
||||
].filter(Boolean);
|
||||
return {
|
||||
url: config.url,
|
||||
headers: { 'Content-Type': 'application/json', 'User-Agent': 'Upwatch/1.0 (+notification)' },
|
||||
body: JSON.stringify({
|
||||
attachments: [
|
||||
{
|
||||
color: eventColor(event.kind),
|
||||
blocks: [
|
||||
{ type: 'header', text: { type: 'plain_text', text: `${eventLabel(event.kind)} · ${event.monitor?.name ?? 'Upwatch'}` } },
|
||||
{ type: 'section', text: { type: 'mrkdwn', text: event.body ?? event.title }, fields },
|
||||
...(event.error ? [{ type: 'context', elements: [{ type: 'mrkdwn', text: `Error: ${event.error}` }] }] : []),
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Provider, TelegramConfig } from './types';
|
||||
import { eventLabel, isRecord, secretPreview } from './types';
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
||||
}
|
||||
|
||||
export const telegramProvider: Provider<TelegramConfig> = {
|
||||
parseConfig(value) {
|
||||
if (!isRecord(value)) return 'Invalid Telegram configuration';
|
||||
const botToken = typeof value.botToken === 'string' ? value.botToken.trim() : '';
|
||||
const chatId = typeof value.chatId === 'string' ? value.chatId.trim() : '';
|
||||
if (!botToken || !/^[\w-]+:[\w-]+$/.test(botToken)) return 'Enter a valid Telegram bot token';
|
||||
if (!chatId || chatId.length > 100) return 'Enter a valid Telegram chat ID';
|
||||
return { botToken, chatId };
|
||||
},
|
||||
maskConfig: (config) => ({ botToken: secretPreview(config.botToken), chatId: config.chatId, configSet: true }),
|
||||
format(config, event) {
|
||||
const lines = [
|
||||
`<b>${escapeHtml(eventLabel(event.kind))}</b>`,
|
||||
`<b>${escapeHtml(event.monitor?.name ?? event.title)}</b>`,
|
||||
event.monitor ? escapeHtml(event.monitor.url) : null,
|
||||
event.body ? escapeHtml(event.body) : null,
|
||||
event.statusCode !== null ? `Status: <code>${event.statusCode}</code>` : null,
|
||||
event.error ? `Error: <code>${escapeHtml(event.error)}</code>` : null,
|
||||
escapeHtml(event.at.toISOString()),
|
||||
].filter(Boolean);
|
||||
return {
|
||||
url: `https://api.telegram.org/bot${config.botToken}/sendMessage`,
|
||||
headers: { 'Content-Type': 'application/json', 'User-Agent': 'Upwatch/1.0 (+notification)' },
|
||||
body: JSON.stringify({ chat_id: config.chatId, text: lines.join('\n'), parse_mode: 'HTML', disable_web_page_preview: true }),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { isSafeRemoteUrl } from '../../lib/safe-url';
|
||||
|
||||
export const CHANNEL_TYPES = ['slack', 'discord', 'telegram', 'webhook'] as const;
|
||||
export type ChannelType = (typeof CHANNEL_TYPES)[number];
|
||||
|
||||
export type UrlConfig = { url: string };
|
||||
export type TelegramConfig = { botToken: string; chatId: string };
|
||||
export type ChannelConfig = UrlConfig | TelegramConfig;
|
||||
|
||||
export type NotificationEvent = {
|
||||
kind: 'down' | 'recovered' | 'manual_opened' | 'manual_update' | 'test';
|
||||
monitor: { id: number; name: string; url: string } | null;
|
||||
incidentId: number | null;
|
||||
title: string;
|
||||
body: string | null;
|
||||
statusCode: number | null;
|
||||
error: string | null;
|
||||
at: Date;
|
||||
};
|
||||
|
||||
export type OutboundRequest = { url: string; headers: Record<string, string>; body: string };
|
||||
|
||||
export type Provider<TConfig extends ChannelConfig> = {
|
||||
parseConfig(value: unknown): TConfig | string;
|
||||
maskConfig(config: TConfig): Record<string, unknown>;
|
||||
format(config: TConfig, event: NotificationEvent): OutboundRequest;
|
||||
};
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function parseSafeUrl(value: unknown): UrlConfig | string {
|
||||
if (!isRecord(value) || typeof value.url !== 'string') return 'A URL is required';
|
||||
try {
|
||||
const url = new URL(value.url.trim());
|
||||
if (!isSafeRemoteUrl(url)) throw new Error('unsafe URL');
|
||||
return { url: url.toString() };
|
||||
} catch {
|
||||
return 'Enter a valid public http or https URL';
|
||||
}
|
||||
}
|
||||
|
||||
export function secretPreview(value: string) {
|
||||
return `••••••${value.slice(-4)}`;
|
||||
}
|
||||
|
||||
export function eventLabel(kind: NotificationEvent['kind']) {
|
||||
return {
|
||||
down: 'Service down',
|
||||
recovered: 'Service recovered',
|
||||
manual_opened: 'Incident opened',
|
||||
manual_update: 'Incident update',
|
||||
test: 'Test notification',
|
||||
}[kind];
|
||||
}
|
||||
|
||||
export function eventColor(kind: NotificationEvent['kind']) {
|
||||
if (kind === 'down' || kind === 'manual_opened') return '#dc2626';
|
||||
if (kind === 'recovered') return '#16a34a';
|
||||
return '#2563eb';
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { NotificationEvent, Provider, UrlConfig } from './types';
|
||||
import { parseSafeUrl, secretPreview } from './types';
|
||||
|
||||
export const webhookProvider: Provider<UrlConfig> = {
|
||||
parseConfig: parseSafeUrl,
|
||||
maskConfig: (config) => ({ url: secretPreview(config.url), configSet: true }),
|
||||
format(config, event) {
|
||||
return {
|
||||
url: config.url,
|
||||
headers: { 'Content-Type': 'application/json', 'User-Agent': 'Upwatch/1.0 (+incident webhook)' },
|
||||
body: JSON.stringify({
|
||||
event: event.kind === 'manual_opened' ? 'down' : event.kind === 'manual_update' ? 'down' : event.kind,
|
||||
monitor: event.monitor ?? { id: 0, name: 'Upwatch test', url: 'https://example.com/health' },
|
||||
statusCode: event.statusCode,
|
||||
error: event.error,
|
||||
at: event.at.toISOString(),
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export function formatLegacyWebhook(config: UrlConfig, event: NotificationEvent) {
|
||||
return webhookProvider.format(config, event);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { CheckResult, Monitor } from '../checks/run-check';
|
||||
import { getDb } from '../db/client';
|
||||
import { notificationSettings } from '../db/schema';
|
||||
|
||||
export type IncidentAlert = {
|
||||
monitor: Monitor;
|
||||
kind: 'opened' | 'resolved';
|
||||
result: CheckResult;
|
||||
at: Date;
|
||||
};
|
||||
|
||||
type WebhookPayload = {
|
||||
event: 'down' | 'recovered' | 'test';
|
||||
monitor: { id: number; name: string; url: string };
|
||||
statusCode: number | null;
|
||||
error: string | null;
|
||||
at: string;
|
||||
};
|
||||
|
||||
async function postWebhook(url: string, payload: WebhookPayload): Promise<boolean> {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Upwatch/1.0 (+incident webhook)',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
await response.body?.cancel();
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
export async function sendTestWebhook(url: string): Promise<boolean> {
|
||||
try {
|
||||
return await postWebhook(url, {
|
||||
event: 'test',
|
||||
monitor: { id: 0, name: 'Upwatch test', url: 'https://example.com/health' },
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
message: 'test webhook failed',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendIncidentAlert(env: Env, alert: IncidentAlert): Promise<boolean> {
|
||||
if (!alert.monitor.alertsEnabled) return false;
|
||||
|
||||
try {
|
||||
const [settings] = await getDb(env).select().from(notificationSettings).where(eq(notificationSettings.id, 1)).limit(1);
|
||||
if (!settings?.webhookEnabled || !settings.webhookUrl) return false;
|
||||
|
||||
const ok = await postWebhook(settings.webhookUrl, {
|
||||
event: alert.kind === 'opened' ? 'down' : 'recovered',
|
||||
monitor: {
|
||||
id: alert.monitor.id,
|
||||
name: alert.monitor.name,
|
||||
url: alert.monitor.url,
|
||||
},
|
||||
statusCode: alert.result.statusCode,
|
||||
error: alert.result.error,
|
||||
at: alert.at.toISOString(),
|
||||
});
|
||||
if (!ok) {
|
||||
console.warn(
|
||||
JSON.stringify({
|
||||
message: 'incident webhook returned an error',
|
||||
monitorId: alert.monitor.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return ok;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
message: 'incident webhook failed',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
monitorId: alert.monitor.id,
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { desc, eq, inArray } from 'drizzle-orm';
|
||||
import { Hono } from 'hono';
|
||||
import { getDb, type Database } from '../db/client';
|
||||
import { notificationChannelMonitors, notificationChannels, notificationDeliveries, monitors } from '../db/schema';
|
||||
import { requireAuth, type AuthVariables } from '../lib/require-auth';
|
||||
import { dispatchTest } from '../notifications/dispatch';
|
||||
import { CHANNEL_TYPES, maskChannelConfig, parseChannelConfig, type ChannelConfig, type ChannelType } from '../notifications/providers';
|
||||
import { parseInteger } from './monitors';
|
||||
|
||||
type ParsedChannelInput = {
|
||||
name?: string;
|
||||
type?: ChannelType;
|
||||
config?: ChannelConfig;
|
||||
enabled?: boolean;
|
||||
notifyManual?: boolean;
|
||||
monitorIds?: number[];
|
||||
};
|
||||
type ParseResult = { ok: true; value: ParsedChannelInput } | { ok: false; message: string };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseId(raw: string) {
|
||||
const parsed = parseInteger(Number(raw), 'id', 1, Number.MAX_SAFE_INTEGER);
|
||||
return parsed.ok ? parsed.value : null;
|
||||
}
|
||||
|
||||
function isChannelType(value: unknown): value is ChannelType {
|
||||
return typeof value === 'string' && CHANNEL_TYPES.some((type) => type === value);
|
||||
}
|
||||
|
||||
export function parseChannelInput(body: unknown, partial = false, currentType?: ChannelType): ParseResult {
|
||||
if (!isRecord(body)) return { ok: false, message: 'Invalid request body' };
|
||||
const value: ParsedChannelInput = {};
|
||||
if (!partial || 'name' in body) {
|
||||
if (typeof body.name !== 'string' || body.name.trim().length < 1 || body.name.trim().length > 100) {
|
||||
return { ok: false, message: 'Name must be between 1 and 100 characters' };
|
||||
}
|
||||
value.name = body.name.trim();
|
||||
}
|
||||
if (!partial || 'type' in body) {
|
||||
if (!isChannelType(body.type)) return { ok: false, message: 'type must be slack, discord, telegram, or webhook' };
|
||||
value.type = body.type;
|
||||
}
|
||||
const effectiveType = value.type ?? currentType;
|
||||
if (!partial || 'config' in body || ('type' in body && body.type !== currentType)) {
|
||||
if (!effectiveType) return { ok: false, message: 'A channel type is required' };
|
||||
const config = parseChannelConfig(effectiveType, body.config);
|
||||
if (typeof config === 'string') return { ok: false, message: config };
|
||||
value.config = config;
|
||||
}
|
||||
for (const field of ['enabled', 'notifyManual'] as const) {
|
||||
if (!partial || field in body) {
|
||||
if (typeof body[field] !== 'boolean') return { ok: false, message: `${field} must be a boolean` };
|
||||
value[field] = body[field];
|
||||
}
|
||||
}
|
||||
if (!partial || 'monitorIds' in body) {
|
||||
if (!Array.isArray(body.monitorIds) || body.monitorIds.some((id) => !Number.isSafeInteger(id) || id <= 0)) {
|
||||
return { ok: false, message: 'monitorIds must be an array of positive integers' };
|
||||
}
|
||||
value.monitorIds = [...new Set(body.monitorIds as number[])];
|
||||
}
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
async function allMonitorsExist(db: Database, monitorIds: number[]) {
|
||||
if (monitorIds.length === 0) return true;
|
||||
const rows = await db.select({ id: monitors.id }).from(monitors).where(inArray(monitors.id, monitorIds));
|
||||
return rows.length === monitorIds.length;
|
||||
}
|
||||
|
||||
function publicChannel(channel: typeof notificationChannels.$inferSelect, monitorIds: number[], lastDelivery: unknown) {
|
||||
const type = channel.type as ChannelType;
|
||||
let config: Record<string, unknown> = { configSet: false };
|
||||
if (isChannelType(type)) {
|
||||
try {
|
||||
const parsed = parseChannelConfig(type, JSON.parse(channel.config) as unknown);
|
||||
if (typeof parsed !== 'string') config = maskChannelConfig(type, parsed);
|
||||
} catch {
|
||||
config = { configSet: false };
|
||||
}
|
||||
}
|
||||
return { ...channel, type, config, monitorIds, lastDelivery };
|
||||
}
|
||||
|
||||
const channelRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>();
|
||||
channelRoutes.use('*', requireAuth);
|
||||
|
||||
channelRoutes.get('/', async (context) => {
|
||||
const db = getDb(context.env);
|
||||
const [channels, assignments] = await Promise.all([
|
||||
db.select().from(notificationChannels).orderBy(notificationChannels.createdAt),
|
||||
db.select().from(notificationChannelMonitors),
|
||||
]);
|
||||
const monitorIdsByChannel = new Map<number, number[]>();
|
||||
for (const assignment of assignments) {
|
||||
const ids = monitorIdsByChannel.get(assignment.channelId);
|
||||
if (ids) ids.push(assignment.monitorId);
|
||||
else monitorIdsByChannel.set(assignment.channelId, [assignment.monitorId]);
|
||||
}
|
||||
const lastDeliveries = await Promise.all(
|
||||
channels.map(async (channel) => {
|
||||
const [delivery] = await db
|
||||
.select()
|
||||
.from(notificationDeliveries)
|
||||
.where(eq(notificationDeliveries.channelId, channel.id))
|
||||
.orderBy(desc(notificationDeliveries.createdAt))
|
||||
.limit(1);
|
||||
return delivery ?? null;
|
||||
}),
|
||||
);
|
||||
return context.json({
|
||||
channels: channels.map((channel, index) => publicChannel(channel, monitorIdsByChannel.get(channel.id) ?? [], lastDeliveries[index])),
|
||||
});
|
||||
});
|
||||
|
||||
channelRoutes.post('/', async (context) => {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await context.req.json();
|
||||
} catch {
|
||||
return context.json({ message: 'Invalid request body' }, 400);
|
||||
}
|
||||
const parsed = parseChannelInput(body);
|
||||
if (!parsed.ok) return context.json({ message: parsed.message }, 400);
|
||||
const db = getDb(context.env);
|
||||
const monitorIds = parsed.value.monitorIds!;
|
||||
if (!(await allMonitorsExist(db, monitorIds))) return context.json({ message: 'One or more monitors do not exist' }, 400);
|
||||
const now = new Date();
|
||||
const [channel] = await db
|
||||
.insert(notificationChannels)
|
||||
.values({
|
||||
name: parsed.value.name!,
|
||||
type: parsed.value.type!,
|
||||
config: JSON.stringify(parsed.value.config),
|
||||
enabled: parsed.value.enabled!,
|
||||
notifyManual: parsed.value.notifyManual!,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
if (monitorIds.length > 0) {
|
||||
await db.batch(
|
||||
monitorIds.map((monitorId) => db.insert(notificationChannelMonitors).values({ channelId: channel.id, monitorId })) as [
|
||||
ReturnType<typeof db.insert>,
|
||||
...ReturnType<typeof db.insert>[],
|
||||
],
|
||||
);
|
||||
}
|
||||
return context.json({ channel: publicChannel(channel, monitorIds, null) }, 201);
|
||||
});
|
||||
|
||||
channelRoutes.patch('/:id', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Notification channel not found' }, 404);
|
||||
const db = getDb(context.env);
|
||||
const [existing] = await db.select().from(notificationChannels).where(eq(notificationChannels.id, id)).limit(1);
|
||||
if (!existing || !isChannelType(existing.type)) return context.json({ message: 'Notification channel not found' }, 404);
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await context.req.json();
|
||||
} catch {
|
||||
return context.json({ message: 'Invalid request body' }, 400);
|
||||
}
|
||||
const parsed = parseChannelInput(body, true, existing.type);
|
||||
if (!parsed.ok) return context.json({ message: parsed.message }, 400);
|
||||
if (Object.keys(parsed.value).length === 0) return context.json({ message: 'Provide at least one field to update' }, 400);
|
||||
if (parsed.value.monitorIds && !(await allMonitorsExist(db, parsed.value.monitorIds))) {
|
||||
return context.json({ message: 'One or more monitors do not exist' }, 400);
|
||||
}
|
||||
const { monitorIds, config, ...changes } = parsed.value;
|
||||
const statements: Parameters<Database['batch']>[0][number][] = [
|
||||
db
|
||||
.update(notificationChannels)
|
||||
.set({ ...changes, ...(config ? { config: JSON.stringify(config) } : {}), updatedAt: new Date() })
|
||||
.where(eq(notificationChannels.id, id)),
|
||||
];
|
||||
if (monitorIds) {
|
||||
statements.push(db.delete(notificationChannelMonitors).where(eq(notificationChannelMonitors.channelId, id)));
|
||||
statements.push(...monitorIds.map((monitorId) => db.insert(notificationChannelMonitors).values({ channelId: id, monitorId })));
|
||||
}
|
||||
await db.batch(statements as [(typeof statements)[number], ...typeof statements]);
|
||||
const [channel] = await db.select().from(notificationChannels).where(eq(notificationChannels.id, id)).limit(1);
|
||||
const assignments = await db
|
||||
.select({ monitorId: notificationChannelMonitors.monitorId })
|
||||
.from(notificationChannelMonitors)
|
||||
.where(eq(notificationChannelMonitors.channelId, id));
|
||||
return context.json({
|
||||
channel: publicChannel(
|
||||
channel,
|
||||
assignments.map((row) => row.monitorId),
|
||||
null,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
channelRoutes.delete('/:id', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Notification channel not found' }, 404);
|
||||
const db = getDb(context.env);
|
||||
const [channel] = await db
|
||||
.select({ id: notificationChannels.id })
|
||||
.from(notificationChannels)
|
||||
.where(eq(notificationChannels.id, id))
|
||||
.limit(1);
|
||||
if (!channel) return context.json({ message: 'Notification channel not found' }, 404);
|
||||
await db.batch([
|
||||
db.delete(notificationChannelMonitors).where(eq(notificationChannelMonitors.channelId, id)),
|
||||
db.delete(notificationDeliveries).where(eq(notificationDeliveries.channelId, id)),
|
||||
db.delete(notificationChannels).where(eq(notificationChannels.id, id)),
|
||||
]);
|
||||
return context.json({ ok: true });
|
||||
});
|
||||
|
||||
channelRoutes.post('/:id/test', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Notification channel not found' }, 404);
|
||||
const result = await dispatchTest(context.env, id);
|
||||
if (!result.ok)
|
||||
return context.json(
|
||||
{ message: result.error ?? 'Notification delivery failed' },
|
||||
result.error === 'Notification channel not found' ? 404 : 502,
|
||||
);
|
||||
return context.json({ ok: true });
|
||||
});
|
||||
|
||||
channelRoutes.get('/:id/deliveries', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Notification channel not found' }, 404);
|
||||
const rawLimit = Number(context.req.query('limit') ?? 20);
|
||||
const limit = Number.isSafeInteger(rawLimit) ? Math.min(100, Math.max(1, rawLimit)) : 20;
|
||||
const db = getDb(context.env);
|
||||
const [channel] = await db
|
||||
.select({ id: notificationChannels.id })
|
||||
.from(notificationChannels)
|
||||
.where(eq(notificationChannels.id, id))
|
||||
.limit(1);
|
||||
if (!channel) return context.json({ message: 'Notification channel not found' }, 404);
|
||||
const deliveries = await db
|
||||
.select()
|
||||
.from(notificationDeliveries)
|
||||
.where(eq(notificationDeliveries.channelId, id))
|
||||
.orderBy(desc(notificationDeliveries.createdAt))
|
||||
.limit(limit);
|
||||
return context.json({ deliveries });
|
||||
});
|
||||
|
||||
export default channelRoutes;
|
||||
@@ -4,6 +4,7 @@ import { IncidentDraftError, draftIncidentUpdate, type IncidentStatus } from '..
|
||||
import { getDb, type Database } from '../db/client';
|
||||
import { incidentMonitors, incidents, incidentUpdates, monitors } from '../db/schema';
|
||||
import { requireAuth, type AuthVariables } from '../lib/require-auth';
|
||||
import { dispatchNotification } from '../notifications/dispatch';
|
||||
import { parseInteger } from './monitors';
|
||||
|
||||
const STATUSES = new Set<IncidentStatus>(['investigating', 'identified', 'monitoring', 'resolved']);
|
||||
@@ -227,6 +228,18 @@ incidentRoutes.post('/', async (context) => {
|
||||
const results = await context.env.DB.batch(statements);
|
||||
const id = Number(results[0].meta.last_row_id);
|
||||
const incident = await loadIncident(db, id);
|
||||
context.executionCtx.waitUntil(
|
||||
dispatchNotification(context.env, {
|
||||
kind: 'manual_opened',
|
||||
monitor: null,
|
||||
incidentId: id,
|
||||
title: parsed.value.title!,
|
||||
body: parsed.value.body!,
|
||||
statusCode: null,
|
||||
error: null,
|
||||
at: new Date(now),
|
||||
}),
|
||||
);
|
||||
return context.json({ incident }, 201);
|
||||
});
|
||||
|
||||
@@ -328,6 +341,18 @@ incidentRoutes.post('/:id/updates', async (context) => {
|
||||
})
|
||||
.where(eq(incidents.id, id)),
|
||||
]);
|
||||
context.executionCtx.waitUntil(
|
||||
dispatchNotification(context.env, {
|
||||
kind: resolved ? 'recovered' : 'manual_update',
|
||||
monitor: null,
|
||||
incidentId: id,
|
||||
title: existing.title ?? `Incident ${id}`,
|
||||
body: parsed.value.body,
|
||||
statusCode: null,
|
||||
error: null,
|
||||
at: now,
|
||||
}),
|
||||
);
|
||||
return context.json({ incident: await loadIncident(db, id) });
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { checks, incidentMonitors, incidents, maintenanceWindowMonitors, monitor
|
||||
import { requireAuth, type AuthVariables } from '../lib/require-auth';
|
||||
import { loadActiveMaintenance } from '../maintenance/windows';
|
||||
import { isSafeRemoteUrl } from '../lib/safe-url';
|
||||
import { sendIncidentAlert } from '../notifications/webhook';
|
||||
import { dispatchNotification } from '../notifications/dispatch';
|
||||
|
||||
type MonitorMethod = 'GET' | 'HEAD' | 'POST';
|
||||
|
||||
@@ -576,7 +576,16 @@ monitorRoutes.post('/:id/check', async (context) => {
|
||||
const { statements, transition } = buildResultStatements(db, monitor, result, checkedAt, activeMaintenance.has(monitor.id));
|
||||
await db.batch(statements as [(typeof statements)[number], ...typeof statements]);
|
||||
if (transition === 'opened' || transition === 'resolved') {
|
||||
await sendIncidentAlert(context.env, { monitor, kind: transition, result, at: checkedAt });
|
||||
await dispatchNotification(context.env, {
|
||||
monitor: { id: monitor.id, name: monitor.name, url: monitor.url },
|
||||
kind: transition === 'opened' ? 'down' : 'recovered',
|
||||
incidentId: null,
|
||||
title: transition === 'opened' ? `${monitor.name} is down` : `${monitor.name} recovered`,
|
||||
body: result.error,
|
||||
statusCode: result.statusCode,
|
||||
error: result.error,
|
||||
at: checkedAt,
|
||||
});
|
||||
if (transition === 'opened') {
|
||||
await generateIncidentMessage(context.env, { monitor, result });
|
||||
}
|
||||
|
||||
@@ -4,15 +4,9 @@ import { requestCompletion } from '../ai/client';
|
||||
import { SAMPLE_INCIDENT_CONTEXT } from '../ai/incident-context';
|
||||
import { INCIDENT_MESSAGE_SYSTEM_PROMPT, sanitizeIncidentMessage } from '../ai/incident-message';
|
||||
import { getDb } from '../db/client';
|
||||
import { aiSettings, notificationSettings } from '../db/schema';
|
||||
import { aiSettings } from '../db/schema';
|
||||
import { requireAuth, type AuthVariables } from '../lib/require-auth';
|
||||
import { isSafeRemoteUrl } from '../lib/safe-url';
|
||||
import { sendTestWebhook } from '../notifications/webhook';
|
||||
|
||||
type NotificationInput = {
|
||||
webhookUrl: string | null;
|
||||
webhookEnabled: boolean;
|
||||
};
|
||||
|
||||
type AiInput = {
|
||||
enabled: boolean;
|
||||
@@ -21,30 +15,6 @@ type AiInput = {
|
||||
apiKey?: string;
|
||||
};
|
||||
|
||||
function parseNotificationInput(value: unknown): NotificationInput | string {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return 'Invalid request body';
|
||||
}
|
||||
const body = value as Record<string, unknown>;
|
||||
if (typeof body.webhookEnabled !== 'boolean') {
|
||||
return 'webhookEnabled must be a boolean';
|
||||
}
|
||||
const rawUrl = typeof body.webhookUrl === 'string' ? body.webhookUrl.trim() : body.webhookUrl;
|
||||
if (rawUrl !== null && typeof rawUrl !== 'string') return 'webhookUrl must be a URL or null';
|
||||
let webhookUrl = rawUrl || null;
|
||||
if (webhookUrl) {
|
||||
try {
|
||||
const url = new URL(webhookUrl);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('protocol');
|
||||
webhookUrl = url.toString();
|
||||
} catch {
|
||||
return 'Enter a valid http or https webhook URL';
|
||||
}
|
||||
}
|
||||
if (body.webhookEnabled && !webhookUrl) return 'A webhook URL is required when alerts are enabled';
|
||||
return { webhookUrl, webhookEnabled: body.webhookEnabled };
|
||||
}
|
||||
|
||||
function parseAiInput(value: unknown): AiInput | string {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return 'Invalid request body';
|
||||
@@ -95,44 +65,6 @@ function publicAiSettings(settings: typeof aiSettings.$inferSelect | undefined)
|
||||
const settingsRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>();
|
||||
settingsRoutes.use('*', requireAuth);
|
||||
|
||||
settingsRoutes.get('/notifications', async (context) => {
|
||||
const [settings] = await getDb(context.env).select().from(notificationSettings).where(eq(notificationSettings.id, 1)).limit(1);
|
||||
return context.json({
|
||||
settings: settings ?? { id: 1, webhookUrl: null, webhookEnabled: false, createdAt: null, updatedAt: null },
|
||||
});
|
||||
});
|
||||
|
||||
settingsRoutes.put('/notifications', async (context) => {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await context.req.json();
|
||||
} catch {
|
||||
return context.json({ message: 'Invalid request body' }, 400);
|
||||
}
|
||||
const input = parseNotificationInput(body);
|
||||
if (typeof input === 'string') return context.json({ message: input }, 400);
|
||||
|
||||
const db = getDb(context.env);
|
||||
const now = new Date();
|
||||
const [settings] = await db
|
||||
.insert(notificationSettings)
|
||||
.values({ id: 1, ...input, createdAt: now, updatedAt: now })
|
||||
.onConflictDoUpdate({
|
||||
target: notificationSettings.id,
|
||||
set: { ...input, updatedAt: now },
|
||||
})
|
||||
.returning();
|
||||
return context.json({ settings });
|
||||
});
|
||||
|
||||
settingsRoutes.post('/notifications/test', async (context) => {
|
||||
const [settings] = await getDb(context.env).select().from(notificationSettings).where(eq(notificationSettings.id, 1)).limit(1);
|
||||
if (!settings?.webhookUrl) return context.json({ message: 'Save a webhook URL first' }, 400);
|
||||
const delivered = await sendTestWebhook(settings.webhookUrl);
|
||||
if (!delivered) return context.json({ message: 'Webhook delivery failed' }, 502);
|
||||
return context.json({ ok: true });
|
||||
});
|
||||
|
||||
settingsRoutes.get('/ai', async (context) => {
|
||||
const [settings] = await getDb(context.env).select().from(aiSettings).where(eq(aiSettings.id, 1)).limit(1);
|
||||
return context.json({ settings: publicAiSettings(settings) });
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { lt } from 'drizzle-orm';
|
||||
import { getDb } from '../db/client';
|
||||
import { checks, loginAttempts, monitorDailyStats, sessions } from '../db/schema';
|
||||
import { checks, loginAttempts, monitorDailyStats, notificationDeliveries, sessions } from '../db/schema';
|
||||
|
||||
const LOGIN_ATTEMPT_RETENTION_MS = 60 * 60 * 1000;
|
||||
const CHECK_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const DAILY_STATS_RETENTION_MS = 400 * 24 * 60 * 60 * 1000;
|
||||
const DELIVERY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export async function cleanupExpiredAuthRecords(env: Env) {
|
||||
const db = getDb(env);
|
||||
@@ -15,5 +16,6 @@ export async function cleanupExpiredAuthRecords(env: Env) {
|
||||
db.delete(loginAttempts).where(lt(loginAttempts.attemptedAt, new Date(now.getTime() - LOGIN_ATTEMPT_RETENTION_MS))),
|
||||
db.delete(checks).where(lt(checks.checkedAt, new Date(now.getTime() - CHECK_RETENTION_MS))),
|
||||
db.delete(monitorDailyStats).where(lt(monitorDailyStats.day, new Date(now.getTime() - DAILY_STATS_RETENTION_MS))),
|
||||
db.delete(notificationDeliveries).where(lt(notificationDeliveries.createdAt, new Date(now.getTime() - DELIVERY_RETENTION_MS))),
|
||||
]);
|
||||
}
|
||||
|
||||
+3
-1
@@ -42,7 +42,9 @@ async function resetDatabase() {
|
||||
env.DB.prepare('DELETE FROM incidents'),
|
||||
env.DB.prepare('DELETE FROM monitor_daily_stats'),
|
||||
env.DB.prepare('DELETE FROM ai_settings'),
|
||||
env.DB.prepare('DELETE FROM notification_settings'),
|
||||
env.DB.prepare('DELETE FROM notification_deliveries'),
|
||||
env.DB.prepare('DELETE FROM notification_channel_monitors'),
|
||||
env.DB.prepare('DELETE FROM notification_channels'),
|
||||
env.DB.prepare('DELETE FROM monitors'),
|
||||
env.DB.prepare('DELETE FROM login_attempts'),
|
||||
env.DB.prepare('DELETE FROM sessions'),
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { applyD1Migrations, type D1Migration } from 'cloudflare:test';
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { dispatchNotification } from '../src/worker/notifications/dispatch';
|
||||
import { discordProvider, slackProvider, telegramProvider, webhookProvider } from '../src/worker/notifications/providers';
|
||||
import { parseChannelInput } from '../src/worker/routes/channels';
|
||||
|
||||
const event = {
|
||||
kind: 'down' as const,
|
||||
monitor: { id: 7, name: 'API <prod>', url: 'https://api.example.com' },
|
||||
incidentId: null,
|
||||
title: 'API is down',
|
||||
body: 'Requests are failing.',
|
||||
statusCode: 500,
|
||||
error: 'Expected <200>',
|
||||
at: new Date('2026-08-28T03:25:00Z'),
|
||||
};
|
||||
|
||||
async function insertChannel(name: string, config: string, overrides = '') {
|
||||
const now = Date.now();
|
||||
const result = await env.DB.prepare(
|
||||
`INSERT INTO notification_channels (name, type, config, enabled, notify_manual, created_at, updated_at)
|
||||
VALUES (?, 'webhook', ?, 1, 1, ?, ?) ${overrides}`,
|
||||
)
|
||||
.bind(name, config, now, now)
|
||||
.run();
|
||||
return Number(result.meta.last_row_id);
|
||||
}
|
||||
|
||||
describe('notification channels', () => {
|
||||
beforeAll(async () => {
|
||||
const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] };
|
||||
await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await env.DB.batch([
|
||||
env.DB.prepare('DELETE FROM notification_deliveries'),
|
||||
env.DB.prepare('DELETE FROM notification_channel_monitors'),
|
||||
env.DB.prepare('DELETE FROM notification_channels'),
|
||||
env.DB.prepare('DELETE FROM incident_monitors'),
|
||||
env.DB.prepare('DELETE FROM incidents'),
|
||||
env.DB.prepare('DELETE FROM monitors'),
|
||||
]);
|
||||
const now = Date.now();
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO monitors (id, name, url, method, expected_status, interval_seconds, timeout_ms, enabled, alerts_enabled,
|
||||
retry_count, failure_threshold, consecutive_failures, last_ok, created_at, updated_at)
|
||||
VALUES (7, 'API', 'https://api.example.com', 'GET', 200, 300, 10000, 1, 1, 0, 1, 0, 1, ?, ?)`,
|
||||
)
|
||||
.bind(now, now)
|
||||
.run();
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('formats provider-specific payloads and preserves the raw webhook payload', () => {
|
||||
const webhook = webhookProvider.format({ url: 'https://hooks.example.com' }, event);
|
||||
expect(JSON.parse(webhook.body)).toEqual({
|
||||
event: 'down',
|
||||
monitor: event.monitor,
|
||||
statusCode: 500,
|
||||
error: 'Expected <200>',
|
||||
at: '2026-08-28T03:25:00.000Z',
|
||||
});
|
||||
const testWebhook = webhookProvider.format(
|
||||
{ url: 'https://hooks.example.com' },
|
||||
{ ...event, kind: 'test', monitor: null, incidentId: null, title: 'Upwatch test', body: null, statusCode: 200, error: null },
|
||||
);
|
||||
expect(JSON.parse(testWebhook.body).monitor).toEqual({ id: 0, name: 'Upwatch test', url: 'https://example.com/health' });
|
||||
expect(JSON.parse(slackProvider.format({ url: 'https://hooks.slack.com/test' }, event).body).attachments[0].color).toBe('#dc2626');
|
||||
expect(JSON.parse(discordProvider.format({ url: 'https://discord.com/api/webhooks/test' }, event).body).embeds[0]).toMatchObject({
|
||||
color: 14427686,
|
||||
timestamp: '2026-08-28T03:25:00.000Z',
|
||||
});
|
||||
const telegram = telegramProvider.format({ botToken: '123:abc', chatId: '-10' }, event);
|
||||
expect(telegram.url).toBe('https://api.telegram.org/bot123:abc/sendMessage');
|
||||
expect(JSON.parse(telegram.body).text).toContain('API <prod>');
|
||||
});
|
||||
|
||||
it('rejects private channel URLs and unknown providers', () => {
|
||||
expect(
|
||||
parseChannelInput({
|
||||
name: 'Local',
|
||||
type: 'webhook',
|
||||
config: { url: 'http://127.0.0.1/x' },
|
||||
enabled: true,
|
||||
notifyManual: true,
|
||||
monitorIds: [],
|
||||
}),
|
||||
).toMatchObject({ ok: false });
|
||||
expect(
|
||||
parseChannelInput({ name: 'Unknown', type: 'email', config: {}, enabled: true, notifyManual: true, monitorIds: [] }),
|
||||
).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it('fans out, records delivery, and retries a server failure once', async () => {
|
||||
await insertChannel('All services', '{"url":"https://hooks.example.test/events"}');
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 500 }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
await dispatchNotification(env, event);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const delivery = await env.DB.prepare('SELECT ok, status_code, attempts FROM notification_deliveries').first<{
|
||||
ok: number;
|
||||
status_code: number;
|
||||
attempts: number;
|
||||
}>();
|
||||
expect(delivery).toEqual({ ok: 1, status_code: 204, attempts: 2 });
|
||||
});
|
||||
|
||||
it('does not retry a 400 response', async () => {
|
||||
await insertChannel('All services', '{"url":"https://hooks.example.test/events"}');
|
||||
const fetchMock = vi.fn(async () => new Response(null, { status: 400 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
await dispatchNotification(env, event);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const delivery = await env.DB.prepare('SELECT ok, error, attempts FROM notification_deliveries').first();
|
||||
expect(delivery).toEqual({ ok: 0, error: 'HTTP 400', attempts: 1 });
|
||||
});
|
||||
|
||||
it('honors monitor assignments and alertsEnabled', async () => {
|
||||
const channelId = await insertChannel('Other service', '{"url":"https://hooks.example.test/events"}');
|
||||
const now = Date.now();
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO monitors (id, name, url, method, expected_status, interval_seconds, timeout_ms, enabled, alerts_enabled,
|
||||
retry_count, failure_threshold, consecutive_failures, created_at, updated_at)
|
||||
VALUES (8, 'Web', 'https://web.example.com', 'GET', 200, 300, 10000, 1, 1, 0, 1, 0, ?, ?)`,
|
||||
)
|
||||
.bind(now, now)
|
||||
.run();
|
||||
await env.DB.prepare('INSERT INTO notification_channel_monitors (channel_id, monitor_id) VALUES (?, 8)').bind(channelId).run();
|
||||
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
await dispatchNotification(env, event);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
await env.DB.prepare('UPDATE monitors SET alerts_enabled = 0 WHERE id = 7').run();
|
||||
await env.DB.prepare('DELETE FROM notification_channel_monitors').run();
|
||||
await dispatchNotification(env, event);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+5
-2
@@ -13,7 +13,9 @@ async function clearMonitoringTables() {
|
||||
env.DB.prepare('DELETE FROM incidents'),
|
||||
env.DB.prepare('DELETE FROM monitor_daily_stats'),
|
||||
env.DB.prepare('DELETE FROM ai_settings'),
|
||||
env.DB.prepare('DELETE FROM notification_settings'),
|
||||
env.DB.prepare('DELETE FROM notification_deliveries'),
|
||||
env.DB.prepare('DELETE FROM notification_channel_monitors'),
|
||||
env.DB.prepare('DELETE FROM notification_channels'),
|
||||
env.DB.prepare('DELETE FROM monitors'),
|
||||
]);
|
||||
}
|
||||
@@ -225,7 +227,8 @@ describe('scheduled monitor checks', () => {
|
||||
const now = Date.now();
|
||||
await env.DB.batch([
|
||||
env.DB.prepare(
|
||||
"INSERT INTO notification_settings (id, webhook_url, webhook_enabled, created_at, updated_at) VALUES (1, 'https://hooks.example.test/events', 1, ?, ?)",
|
||||
`INSERT INTO notification_channels (name, type, config, enabled, notify_manual, created_at, updated_at)
|
||||
VALUES ('Legacy webhook', 'webhook', '{"url":"https://hooks.example.test/events"}', 1, 1, ?, ?)`,
|
||||
).bind(now, now),
|
||||
env.DB.prepare(
|
||||
"INSERT INTO ai_settings (id, enabled, base_url, api_key, model, created_at, updated_at) VALUES (1, 1, 'https://ai.example.test/v1', 'secret', 'test-model', ?, ?)",
|
||||
|
||||
@@ -6,6 +6,9 @@ import { hashPassword } from '../src/worker/lib/password';
|
||||
const PASSWORD = 'correct-horse-battery-staple';
|
||||
async function reset() {
|
||||
await env.DB.batch([
|
||||
env.DB.prepare('DELETE FROM notification_deliveries'),
|
||||
env.DB.prepare('DELETE FROM notification_channel_monitors'),
|
||||
env.DB.prepare('DELETE FROM notification_channels'),
|
||||
env.DB.prepare('DELETE FROM incident_updates'),
|
||||
env.DB.prepare('DELETE FROM incident_monitors'),
|
||||
env.DB.prepare('DELETE FROM incidents'),
|
||||
|
||||
@@ -28,7 +28,9 @@ async function resetDatabase() {
|
||||
env.DB.prepare('DELETE FROM incident_monitors'),
|
||||
env.DB.prepare('DELETE FROM incidents'),
|
||||
env.DB.prepare('DELETE FROM monitor_daily_stats'),
|
||||
env.DB.prepare('DELETE FROM notification_settings'),
|
||||
env.DB.prepare('DELETE FROM notification_deliveries'),
|
||||
env.DB.prepare('DELETE FROM notification_channel_monitors'),
|
||||
env.DB.prepare('DELETE FROM notification_channels'),
|
||||
env.DB.prepare('DELETE FROM monitors'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ async function seedAdmin() {
|
||||
env.DB.prepare('DELETE FROM incident_monitors'),
|
||||
env.DB.prepare('DELETE FROM incidents'),
|
||||
env.DB.prepare('DELETE FROM monitor_daily_stats'),
|
||||
env.DB.prepare('DELETE FROM notification_settings'),
|
||||
env.DB.prepare('DELETE FROM notification_deliveries'),
|
||||
env.DB.prepare('DELETE FROM notification_channel_monitors'),
|
||||
env.DB.prepare('DELETE FROM notification_channels'),
|
||||
env.DB.prepare('DELETE FROM monitors'),
|
||||
env.DB.prepare('DELETE FROM login_attempts'),
|
||||
env.DB.prepare('DELETE FROM sessions'),
|
||||
|
||||
+3
-1
@@ -26,7 +26,9 @@ async function resetDatabase() {
|
||||
env.DB.prepare('DELETE FROM checks'),
|
||||
env.DB.prepare('DELETE FROM incidents'),
|
||||
env.DB.prepare('DELETE FROM monitor_daily_stats'),
|
||||
env.DB.prepare('DELETE FROM notification_settings'),
|
||||
env.DB.prepare('DELETE FROM notification_deliveries'),
|
||||
env.DB.prepare('DELETE FROM notification_channel_monitors'),
|
||||
env.DB.prepare('DELETE FROM notification_channels'),
|
||||
env.DB.prepare('DELETE FROM monitors'),
|
||||
env.DB.prepare('DELETE FROM login_attempts'),
|
||||
env.DB.prepare('DELETE FROM sessions'),
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { applyD1Migrations, type D1Migration } from 'cloudflare:test';
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { sendIncidentAlert } from '../src/worker/notifications/webhook';
|
||||
import type { Monitor } from '../src/worker/checks/run-check';
|
||||
|
||||
describe('incident webhooks', () => {
|
||||
beforeAll(async () => {
|
||||
const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] };
|
||||
await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await env.DB.prepare('DELETE FROM notification_settings').run();
|
||||
const now = Date.now();
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO notification_settings (id, webhook_url, webhook_enabled, created_at, updated_at) VALUES (1, 'https://hooks.example.test/events', 1, ?, ?)",
|
||||
)
|
||||
.bind(now, now)
|
||||
.run();
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
const monitor = {
|
||||
id: 7,
|
||||
name: 'API',
|
||||
url: 'https://api.example.com',
|
||||
method: 'GET',
|
||||
expectedStatus: 200,
|
||||
intervalSeconds: 300,
|
||||
timeoutMs: 10000,
|
||||
enabled: true,
|
||||
alertsEnabled: true,
|
||||
retryCount: 1,
|
||||
failureThreshold: 2,
|
||||
consecutiveFailures: 0,
|
||||
lastOk: true,
|
||||
lastStatusCode: 200,
|
||||
lastLatencyMs: 30,
|
||||
lastError: null,
|
||||
lastCheckedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} satisfies Monitor;
|
||||
|
||||
it('sends the compact down payload', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const at = new Date('2026-08-28T03:25:00Z');
|
||||
expect(
|
||||
await sendIncidentAlert(env, { monitor, kind: 'opened', result: { ok: false, statusCode: 500, latencyMs: 42, error: 'Down' }, at }),
|
||||
).toBe(true);
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(String(init?.body))).toEqual({
|
||||
event: 'down',
|
||||
monitor: { id: 7, name: 'API', url: 'https://api.example.com' },
|
||||
statusCode: 500,
|
||||
error: 'Down',
|
||||
at: '2026-08-28T03:25:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('swallows webhook network failures', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
throw new Error('network unavailable');
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
sendIncidentAlert(env, {
|
||||
monitor,
|
||||
kind: 'resolved',
|
||||
result: { ok: true, statusCode: 200, latencyMs: 20, error: null },
|
||||
at: new Date(),
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user