feat(api, web): добавить поддержку уведомлений и журнал уведомлений
Docker / build (push) Has been cancelled

Добавлены новые функции для отправки уведомлений через Telegram и webhook, включая настройки для интервалов уведомлений и проверки uptime. Реализован журнал уведомлений для отслеживания статуса отправленных сообщений. Обновлены схемы и интерфейсы для поддержки новых полей и функционала.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-29 00:36:05 +07:00
co-authored by Cursor
parent 2c66d0c0ee
commit 7f91bc3624
28 changed files with 1046 additions and 437 deletions
@@ -41,6 +41,7 @@ export const VPS_FORM_EMPTY: VpsFormValues = {
paidUntil: '',
project: '',
notes: '',
monitoringEnabled: false,
userOverrides: [] as string[],
customData: {} as Record<string, string | number | boolean>,
}
@@ -66,6 +67,7 @@ export function vpsFormFromRow(v: Vps): VpsFormValues {
paidUntil: v.paidUntil ?? '',
project: v.project ?? '',
notes: v.notes ?? '',
monitoringEnabled: Boolean((v as Vps & { monitoringEnabled?: boolean }).monitoringEnabled),
userOverrides: parseUserOverrides((v as Vps & { userOverrides?: unknown }).userOverrides),
customData: parseCustomData((v as Vps & { customData?: unknown }).customData),
}
@@ -284,6 +286,27 @@ export function VpsEditSheet({
/>
</FormField>
</div>
<Controller
control={control}
name="monitoringEnabled"
render={({ field }) => (
<FormField label="Мониторинг uptime" htmlFor="vps-monitoring">
<SelectField
triggerId="vps-monitoring"
triggerClassName="w-32"
value={field.value ? 'on' : 'off'}
onValueChange={(v) => field.onChange((v ?? 'off') === 'on')}
options={[
{ value: 'on', label: 'Вкл' },
{ value: 'off', label: 'Выкл' },
]}
/>
<p className="mt-1 text-xs text-muted-foreground">
TCP-проверка SSH-порта; уведомление при переходе в down/up
</p>
</FormField>
)}
/>
<div className="grid grid-cols-3 gap-3">
<FormField label="Валюта" htmlFor="vps-cur" error={errors.currency?.message}>
<Input id="vps-cur" {...register('currency')} />
+9 -1
View File
@@ -109,7 +109,15 @@ export const api = {
fetchSyncStatus: () => fetchApi('/api/sync/status'),
sendTelegramTest: () =>
fetchApi('/api/settings/telegram/test', { method: 'POST' }),
fetchApi<{ ok: boolean; error?: string }>('/api/settings/telegram/test', { method: 'POST' }),
sendWebhookTest: () =>
fetchApi<{ ok: boolean; error?: string }>('/api/settings/webhook/test', { method: 'POST' }),
fetchNotificationLog: (limit = 50) =>
fetchApi<import('@/types/entities').NotificationLogRow[]>(
`/api/notifications/log?limit=${limit}`,
),
fetchProjectSuggestions: (q = '', limit = 25) => {
const params = new URLSearchParams()
+3
View File
@@ -59,6 +59,7 @@ export const vpsSchema = z.object({
paidUntil: z.string().optional().default(''),
project: z.string().optional().default(''),
notes: z.string().optional().default(''),
monitoringEnabled: z.boolean().optional().default(false),
userOverrides: z.array(z.string()).optional().default([]),
customData: z.record(z.union([z.string(), z.number(), z.boolean()])).optional().default({}),
})
@@ -91,6 +92,8 @@ export const settingsSchema = z.object({
syncEnabled: z.boolean().optional().default(true),
syncIntervalMinutes: z.coerce.number().min(15).optional().default(60),
syncTariffsIntervalMinutes: z.coerce.number().min(60).optional().default(1440),
notifyIntervalMinutes: z.coerce.number().min(15).optional().default(60),
uptimeCheckIntervalMinutes: z.coerce.number().min(1).optional().default(5),
telegramChatId: z.string().optional().default(''),
telegramBotToken: z.string().optional().default(''),
telegramMessageThreadId: z.string().optional().default(''),
+105 -6
View File
@@ -4,6 +4,7 @@ import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from 'sonner'
import { DownloadIcon, UploadIcon } from 'lucide-react'
import { useMemo } from 'react'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
@@ -21,7 +22,7 @@ import { FormField } from '@/components/form-field'
import { Button } from '@cfdm/ui/components/button'
import { settingsSchema, type SettingsFormValues } from '@/lib/schemas'
import { CustomFieldsEditor } from '@/components/domain/custom-fields-editor'
import type { Settings } from '@/types/entities'
import type { NotificationLogRow, Settings } from '@/types/entities'
export const Route = createFileRoute('/_auth/settings')({
loader: ({ context: { queryClient } }) =>
@@ -46,11 +47,13 @@ function settingsToFormValues(s: Settings): SettingsFormValues {
notifyNewTariffsEnabled: s.notifyNewTariffsEnabled !== false,
notifyLowBalanceEnabled: s.notifyLowBalanceEnabled !== false,
notifySyncDigestEnabled: s.notifySyncDigestEnabled !== false,
notifyVpsDownEnabled: (s as Settings & { notifyVpsDownEnabled?: boolean }).notifyVpsDownEnabled !== false,
webhookUrl: (s as Settings & { webhookUrl?: string }).webhookUrl ?? '',
webhookEnabled: (s as Settings & { webhookEnabled?: boolean }).webhookEnabled === true,
notifyVpsDownEnabled: s.notifyVpsDownEnabled !== false,
notifyIntervalMinutes: s.notifyIntervalMinutes ?? 60,
uptimeCheckIntervalMinutes: s.uptimeCheckIntervalMinutes ?? 5,
webhookUrl: s.webhookUrl ?? '',
webhookEnabled: s.webhookEnabled === true,
customFields: parseCustomFieldDefs(s.customFields),
telegramMessageThreadId: (s as Settings & { telegramMessageThreadId?: string }).telegramMessageThreadId ?? '',
telegramMessageThreadId: s.telegramMessageThreadId ?? '',
}
}
@@ -103,6 +106,7 @@ function SettingsPage() {
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
void refetchLog()
toast.success('Настройки сохранены')
form.reset(form.getValues())
},
@@ -111,10 +115,38 @@ function SettingsPage() {
const telegramTestMut = useMutation({
mutationFn: () => api.sendTelegramTest(),
onSuccess: () => toast.success('Тестовое сообщение отправлено'),
onSuccess: (data) => {
if (!data.ok) {
toast.error(data.error ?? 'Ошибка Telegram')
return
}
toast.success('Тестовое сообщение отправлено')
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка отправки'),
})
const webhookTestMut = useMutation({
mutationFn: () => api.sendWebhookTest(),
onSuccess: (data) => {
if (!data.ok) {
toast.error(data.error ?? 'Ошибка webhook')
return
}
toast.success('Тестовый webhook отправлен')
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка отправки'),
})
const { data: notificationLog = [], refetch: refetchLog } = useQuery({
queryKey: ['notifications', 'log'],
queryFn: () => api.fetchNotificationLog(30),
})
const notificationRows = useMemo(
() => notificationLog as NotificationLogRow[],
[notificationLog],
)
const backupActions = (
<div className="flex flex-wrap gap-2">
<Button
@@ -326,6 +358,28 @@ function SettingsPage() {
<FormField label="Интервал тарифов (мин)" htmlFor="set-tariff-int">
<Input id="set-tariff-int" type="number" min={60} {...form.register('syncTariffsIntervalMinutes')} />
</FormField>
</FieldGroup>
</CardContent>
</Card>
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Уведомления</CardTitle>
<CardDescription>События, интервалы и каналы доставки</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<FormField label="Интервал проверки оплаты (мин)" htmlFor="set-notify-int">
<Input id="set-notify-int" type="number" min={15} {...form.register('notifyIntervalMinutes')} />
</FormField>
<FormField label="Интервал uptime-проверки (мин)" htmlFor="set-uptime-int">
<Input
id="set-uptime-int"
type="number"
min={1}
{...form.register('uptimeCheckIntervalMinutes')}
/>
</FormField>
<Controller
control={form.control}
name="notifyLowBalanceEnabled"
@@ -382,10 +436,55 @@ function SettingsPage() {
<FormField label="Webhook URL" htmlFor="set-webhook-url" error={form.formState.errors.webhookUrl?.message}>
<Input id="set-webhook-url" placeholder="https://hooks.example.com/..." {...form.register('webhookUrl')} />
</FormField>
<LoadingButton
type="button"
variant="outline"
onClick={() => webhookTestMut.mutate()}
loading={webhookTestMut.isPending}
>
Тест webhook
</LoadingButton>
</FieldGroup>
</CardContent>
</Card>
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Журнал уведомлений</CardTitle>
<CardDescription>Последние попытки доставки (Telegram и webhook)</CardDescription>
</CardHeader>
<CardContent>
{notificationRows.length === 0 ? (
<p className="text-sm text-muted-foreground">Записей пока нет</p>
) : (
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50 text-left">
<th className="px-3 py-2 font-medium">Время</th>
<th className="px-3 py-2 font-medium">Событие</th>
<th className="px-3 py-2 font-medium">Канал</th>
<th className="px-3 py-2 font-medium">Статус</th>
</tr>
</thead>
<tbody>
{notificationRows.map((row) => (
<tr key={row.id} className="border-b last:border-0">
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
{new Date(row.createdAt).toLocaleString('ru-RU')}
</td>
<td className="px-3 py-2">{row.event}</td>
<td className="px-3 py-2">{row.channel}</td>
<td className="px-3 py-2">{row.status}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Кастомные поля VPS</CardTitle>
+18
View File
@@ -116,8 +116,15 @@ export interface Settings {
notifySyncDigestEnabled?: boolean
notifyPaymentExpiryEnabled?: boolean
notifyNewTariffsEnabled?: boolean
notifyVpsDownEnabled?: boolean
notifyIntervalMinutes?: number
uptimeCheckIntervalMinutes?: number
webhookUrl?: string
webhookEnabled?: boolean
telegramChatId?: string
telegramBotToken?: string
telegramMessageThreadId?: string
telegramBotTokenSet?: boolean
customFields?: CustomFieldDef[]
}
@@ -165,6 +172,17 @@ export interface RatesData {
date?: string
}
export interface NotificationLogRow {
id: string
event: string
channel: 'telegram' | 'webhook'
status: 'sent' | 'failed' | 'skipped'
fingerprint: string | null
message: string | null
payload: Record<string, unknown> | null
createdAt: string
}
export interface DataSnapshot {
vps: Vps[]
providers: Provider[]