feat(settings): добавить Bot API URL и ячейку ошибок уведомлений
Docker / build (push) Failing after 26s
Docker / build (push) Failing after 26s
Монитор больше не ставит «Внимание» по скрытому счётчику. Telegram можно слать через локальный telegram-bot-api. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { countRecentFailedNotifications } from './system-monitor-popover'
|
||||
|
||||
const HOUR = 60 * 60 * 1000
|
||||
const now = Date.parse('2026-08-22T00:00:00.000Z')
|
||||
|
||||
describe('countRecentFailedNotifications', () => {
|
||||
it('counts only failed rows within the window', () => {
|
||||
const rows = [
|
||||
{ status: 'failed', createdAt: new Date(now - 2 * HOUR).toISOString() },
|
||||
{ status: 'sent', createdAt: new Date(now - 1 * HOUR).toISOString() },
|
||||
{ status: 'failed', createdAt: new Date(now - 30 * HOUR).toISOString() },
|
||||
]
|
||||
expect(countRecentFailedNotifications(rows, 24 * HOUR, now)).toBe(1)
|
||||
})
|
||||
|
||||
it('ignores rows without a valid createdAt', () => {
|
||||
expect(
|
||||
countRecentFailedNotifications(
|
||||
[{ status: 'failed', createdAt: null }, { status: 'failed' }],
|
||||
24 * HOUR,
|
||||
now,
|
||||
),
|
||||
).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import { useMemo, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Activity,
|
||||
ListChecks,
|
||||
Bell,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Wallet,
|
||||
@@ -103,6 +103,26 @@ function countCurrentSyncFailures(rows: SyncStatusRow[]): number {
|
||||
return failed
|
||||
}
|
||||
|
||||
type NotifyLogRow = {
|
||||
status?: string | null
|
||||
createdAt?: string | null
|
||||
}
|
||||
|
||||
const NOTIFY_FAIL_WINDOW_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
export function countRecentFailedNotifications(
|
||||
rows: NotifyLogRow[],
|
||||
maxAgeMs = NOTIFY_FAIL_WINDOW_MS,
|
||||
now = Date.now(),
|
||||
): number {
|
||||
return rows.filter((n) => {
|
||||
if (String(n.status ?? '').toLowerCase() !== 'failed') return false
|
||||
const ts = new Date(n.createdAt ?? '').getTime()
|
||||
if (Number.isNaN(ts)) return false
|
||||
return now - ts <= maxAgeMs
|
||||
}).length
|
||||
}
|
||||
|
||||
/** Live system monitor popover (app-shell pattern, VPS Tracker API data). */
|
||||
export function SystemMonitorPopover() {
|
||||
const statsQ = useQuery({ ...dashboardStatsQueryOptions(), refetchInterval: 30_000 })
|
||||
@@ -133,9 +153,7 @@ export function SystemMonitorPopover() {
|
||||
const failedSyncCount = countCurrentSyncFailures(syncQ.data ?? [])
|
||||
const recentSyncFailed = failedSyncCount > 0
|
||||
const syncAlert = staleSync || recentSyncFailed
|
||||
const failedNotifications = (notifyQ.data ?? []).filter(
|
||||
(n) => String(n.status ?? '').toLowerCase() === 'failed',
|
||||
).length
|
||||
const failedNotifications = countRecentFailedNotifications(notifyQ.data ?? [])
|
||||
const apiOk = Boolean(statsQ.data) || Boolean(snapQ.data)
|
||||
|
||||
const metrics = useMemo<MonitorMetric[]>(
|
||||
@@ -151,14 +169,14 @@ export function SystemMonitorPopover() {
|
||||
alert: syncAlert,
|
||||
},
|
||||
{
|
||||
id: 'inventory',
|
||||
label: 'Инвентарь',
|
||||
value: String(issuesCount),
|
||||
id: 'notifications',
|
||||
label: 'Уведомления',
|
||||
value: String(failedNotifications),
|
||||
unit: 'шт.',
|
||||
percent: Math.min(100, issuesCount * 15),
|
||||
icon: <ListChecks aria-hidden />,
|
||||
tone: issuesCount > 0 ? 'warning' : 'success',
|
||||
alert: issuesCount > 0,
|
||||
percent: Math.min(100, failedNotifications * 15),
|
||||
icon: <Bell aria-hidden />,
|
||||
tone: failedNotifications > 0 ? 'warning' : 'success',
|
||||
alert: failedNotifications > 0,
|
||||
},
|
||||
{
|
||||
id: 'runway',
|
||||
@@ -184,10 +202,19 @@ export function SystemMonitorPopover() {
|
||||
alert: downCount > 0,
|
||||
},
|
||||
],
|
||||
[downCount, failedSyncCount, issuesCount, lowBalance, recentSyncFailed, runwayDays, runwayLow, syncAlert],
|
||||
[
|
||||
downCount,
|
||||
failedNotifications,
|
||||
failedSyncCount,
|
||||
lowBalance,
|
||||
recentSyncFailed,
|
||||
runwayDays,
|
||||
runwayLow,
|
||||
syncAlert,
|
||||
],
|
||||
)
|
||||
|
||||
const spiking = metrics.some((m) => m.alert) || !apiOk || failedNotifications > 0
|
||||
const spiking = metrics.some((m) => m.alert) || !apiOk
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
@@ -247,10 +274,12 @@ export function SystemMonitorPopover() {
|
||||
</div>
|
||||
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
|
||||
API:{' '}
|
||||
<span className="text-foreground font-medium">{apiOk ? 'OK' : '—'}</span>
|
||||
<span className={cn('font-medium', apiOk ? 'text-foreground' : 'text-destructive')}>
|
||||
{apiOk ? 'OK' : '—'}
|
||||
</span>
|
||||
{' · '}
|
||||
Уведомлений с ошибкой:{' '}
|
||||
<span className="text-foreground font-medium tabular-nums">{failedNotifications}</span>
|
||||
Инвентарь:{' '}
|
||||
<span className="text-foreground font-medium tabular-nums">{issuesCount}</span>
|
||||
{stats?.lastGlobalSyncAt ? (
|
||||
<>
|
||||
{' · '}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
import { parseCustomFieldDefs } from '@cfdm/shared/contracts/custom-fields'
|
||||
import { DEFAULT_TELEGRAM_API_URL } from '@cfdm/shared/contracts/settings'
|
||||
import type { SettingsFormValues } from '@/lib/schemas'
|
||||
import type { Settings } from '@/types/entities'
|
||||
|
||||
@@ -18,6 +19,7 @@ export function settingsToFormValues(s: Settings): SettingsFormValues {
|
||||
syncTariffsIntervalMinutes: s.syncTariffsIntervalMinutes ?? 1440,
|
||||
telegramChatId: s.telegramChatId ?? '',
|
||||
telegramBotToken: '',
|
||||
telegramApiUrl: s.telegramApiUrl || DEFAULT_TELEGRAM_API_URL,
|
||||
notifyPaymentExpiryEnabled: s.notifyPaymentExpiryEnabled !== false,
|
||||
notifyNewTariffsEnabled: s.notifyNewTariffsEnabled !== false,
|
||||
notifyLowBalanceEnabled: s.notifyLowBalanceEnabled !== false,
|
||||
|
||||
@@ -162,6 +162,7 @@ export const api = {
|
||||
sendTelegramTest: (body?: {
|
||||
telegramBotToken?: string
|
||||
telegramChatId?: string
|
||||
telegramApiUrl?: string
|
||||
telegramMessageThreadId?: string
|
||||
}) =>
|
||||
fetchApi<{ ok: boolean; error?: string }>('/api/settings/telegram/test', {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from 'zod'
|
||||
import { apiTypeSchema as sharedApiTypeSchema } from '@cfdm/shared/contracts/provider'
|
||||
import { billingModeSchema as sharedBillingModeSchema } from '@cfdm/shared/contracts/provider-account'
|
||||
import { customFieldsSchema } from '@cfdm/shared/contracts/custom-fields'
|
||||
import { DEFAULT_TELEGRAM_API_URL } from '@cfdm/shared/contracts/settings'
|
||||
|
||||
export const vpsStatusSchema = z.enum(['active', 'paused', 'archived'])
|
||||
export const tariffTypeSchema = z.enum(['daily', 'monthly'])
|
||||
@@ -98,6 +99,11 @@ export const settingsSchema = z.object({
|
||||
uptimeCheckIntervalMinutes: z.coerce.number().min(1).optional().default(5),
|
||||
telegramChatId: z.string().optional().default(''),
|
||||
telegramBotToken: z.string().optional().default(''),
|
||||
telegramApiUrl: z
|
||||
.string()
|
||||
.url('Невалидный URL')
|
||||
.optional()
|
||||
.default(DEFAULT_TELEGRAM_API_URL),
|
||||
telegramMessageThreadId: z.string().optional().default(''),
|
||||
notifyPaymentExpiryEnabled: z.boolean().optional().default(true),
|
||||
notifyNewTariffsEnabled: z.boolean().optional().default(true),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { toast } from 'sonner'
|
||||
import { useMemo } from 'react'
|
||||
import { z } from 'zod'
|
||||
import { DEFAULT_TELEGRAM_API_URL } from '@cfdm/shared/contracts/settings'
|
||||
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
@@ -41,6 +42,7 @@ export const Route = createFileRoute('/_auth/settings/notifications')({
|
||||
const notifySchema = z.object({
|
||||
telegramChatId: z.string().optional().default(''),
|
||||
telegramBotToken: z.string().optional().default(''),
|
||||
telegramApiUrl: z.string().url('Невалидный URL').default(DEFAULT_TELEGRAM_API_URL),
|
||||
telegramMessageThreadId: z.string().optional().default(''),
|
||||
notifyPaymentExpiryEnabled: z.boolean().default(true),
|
||||
notifyNewTariffsEnabled: z.boolean().default(true),
|
||||
@@ -78,6 +80,7 @@ function SettingsNotificationsPage() {
|
||||
? {
|
||||
telegramChatId: formValues.telegramChatId ?? '',
|
||||
telegramBotToken: '',
|
||||
telegramApiUrl: formValues.telegramApiUrl || DEFAULT_TELEGRAM_API_URL,
|
||||
telegramMessageThreadId: formValues.telegramMessageThreadId ?? '',
|
||||
notifyPaymentExpiryEnabled: formValues.notifyPaymentExpiryEnabled ?? true,
|
||||
notifyNewTariffsEnabled: formValues.notifyNewTariffsEnabled ?? true,
|
||||
@@ -102,9 +105,11 @@ function SettingsNotificationsPage() {
|
||||
telegramChatId?: string
|
||||
telegramMessageThreadId?: string
|
||||
telegramBotToken?: string
|
||||
telegramApiUrl?: string
|
||||
} = {
|
||||
telegramChatId: values.telegramChatId?.trim() || undefined,
|
||||
telegramMessageThreadId: values.telegramMessageThreadId ?? '',
|
||||
telegramApiUrl: values.telegramApiUrl?.trim() || DEFAULT_TELEGRAM_API_URL,
|
||||
}
|
||||
if (token) payload.telegramBotToken = token
|
||||
return api.sendTelegramTest(payload)
|
||||
@@ -206,6 +211,19 @@ function SettingsNotificationsPage() {
|
||||
{...form.register('telegramBotToken')}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title="Bot API URL"
|
||||
description="Свой telegram-bot-api: http://127.0.0.1:8081 или https://bots.example.com (Let's Encrypt на reverse proxy)"
|
||||
labelFor="set-tg-api-url"
|
||||
stacked
|
||||
>
|
||||
<Input
|
||||
id="set-tg-api-url"
|
||||
className="w-full"
|
||||
placeholder={DEFAULT_TELEGRAM_API_URL}
|
||||
{...form.register('telegramApiUrl')}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title="Thread ID"
|
||||
description="Топик форума (необязательно)"
|
||||
|
||||
@@ -129,6 +129,7 @@ export interface Settings {
|
||||
webhookEnabled?: boolean
|
||||
telegramChatId?: string
|
||||
telegramBotToken?: string
|
||||
telegramApiUrl?: string
|
||||
telegramMessageThreadId?: string
|
||||
telegramBotTokenSet?: boolean
|
||||
customFields?: CustomFieldDef[]
|
||||
|
||||
Reference in New Issue
Block a user