feat(api, web): добавить поддержку уведомлений и журнал уведомлений
Docker / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Добавлены новые функции для отправки уведомлений через Telegram и webhook, включая настройки для интервалов уведомлений и проверки uptime. Реализован журнал уведомлений для отслеживания статуса отправленных сообщений. Обновлены схемы и интерфейсы для поддержки новых полей и функционала. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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')} />
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(''),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user