feat: add more services for sent notification

This commit is contained in:
2026-08-30 10:39:32 +07:00
parent 171433a857
commit 12e869cb29
35 changed files with 3011 additions and 403 deletions
+58
View File
@@ -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',
});
}
-25
View File
@@ -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>
</>
);
}
+7 -85
View File
@@ -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>
);
+40
View File
@@ -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 -29
View File
@@ -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
View File
@@ -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;
+16 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+195
View File
@@ -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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
}
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);
}
-91
View File
@@ -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;
}
}
+250
View File
@@ -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;
+25
View File
@@ -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) });
});
+11 -2
View File
@@ -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 });
}
+1 -69
View File
@@ -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) });
+3 -1
View File
@@ -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))),
]);
}