feat(api, web): тест Telegram из формы, подсказки ошибок API и UX настроек
Docker / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Тест отправки использует значения формы без предварительного сохранения; пустой токен при сохранении не затирает сохранённый. Добавлены подсказки по частым ошибкам Telegram API и колонка ошибок в журнале уведомлений. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -43,61 +43,85 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
|
||||
const projectItems = useMemo(() => snapshot?.serverProjects ?? [], [snapshot])
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={onOpenChange} title="Поиск" description="VPS, аккаунты, проекты и навигация">
|
||||
<Command>
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Поиск"
|
||||
description="VPS, аккаунты, проекты и навигация"
|
||||
className="sm:max-w-lg"
|
||||
>
|
||||
<Command className="**:data-[selected=true]:bg-muted **:data-selected:bg-transparent">
|
||||
<CommandInput placeholder="IP, DNS, проект, аккаунт…" />
|
||||
<CommandList>
|
||||
<CommandList className="max-h-96">
|
||||
<CommandEmpty>Ничего не найдено</CommandEmpty>
|
||||
<CommandGroup heading="Навигация">
|
||||
<CommandItem onSelect={() => go('/dashboard')}>
|
||||
<LayoutDashboardIcon />
|
||||
Дашборд
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => go('/vps')}>
|
||||
<ServerIcon />
|
||||
Все VPS
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => go('/dashboard')}>
|
||||
<LayoutDashboardIcon />
|
||||
<span>Дашборд</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => go('/vps')}>
|
||||
<ServerIcon />
|
||||
<span>Все VPS</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="VPS">
|
||||
{vpsItems.slice(0, 50).map((v) => (
|
||||
<CommandItem key={v.id} value={`${v.ip} ${v.dns} ${v.project}`} onSelect={() => go('/vps/$vpsId', { vpsId: v.id })}>
|
||||
<ServerIcon />
|
||||
<span>{v.ip || v.dns || v.id}</span>
|
||||
{v.project ? <span className="text-muted-foreground text-xs">· {v.project}</span> : null}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Аккаунты">
|
||||
{accountItems.map((a) => (
|
||||
<CommandItem
|
||||
key={a.id}
|
||||
value={`${a.name} ${providerById.get(a.providerId)?.name ?? ''}`}
|
||||
onSelect={() => go('/accounts')}
|
||||
>
|
||||
<WalletIcon />
|
||||
{a.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Проекты">
|
||||
{projectItems.map((p) => {
|
||||
const row = p as { id: string; name: string }
|
||||
return (
|
||||
<CommandItem key={row.id} value={row.name} onSelect={() => go('/vps', { project: row.name })}>
|
||||
<FolderKanbanIcon />
|
||||
{row.name}
|
||||
{vpsItems.slice(0, 50).map((v) => (
|
||||
<CommandItem
|
||||
key={v.id}
|
||||
value={`${v.ip} ${v.dns} ${v.project}`}
|
||||
onSelect={() => go('/vps/$vpsId', { vpsId: v.id })}
|
||||
>
|
||||
<ServerIcon />
|
||||
<span className="truncate">{v.ip || v.dns || v.id}</span>
|
||||
{v.project ? (
|
||||
<span className="text-muted-foreground text-xs">{v.project}</span>
|
||||
) : null}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Аккаунты">
|
||||
{accountItems.map((a) => (
|
||||
<CommandItem
|
||||
key={a.id}
|
||||
value={`${a.name} ${providerById.get(a.providerId)?.name ?? ''}`}
|
||||
onSelect={() => go('/accounts')}
|
||||
>
|
||||
<WalletIcon />
|
||||
<span className="truncate">{a.name}</span>
|
||||
{providerById.get(a.providerId)?.name ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{providerById.get(a.providerId)?.name}
|
||||
</span>
|
||||
) : null}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Проекты">
|
||||
{projectItems.map((p) => {
|
||||
const row = p as { id: string; name: string }
|
||||
return (
|
||||
<CommandItem
|
||||
key={row.id}
|
||||
value={row.name}
|
||||
onSelect={() => go('/vps', { project: row.name })}
|
||||
>
|
||||
<FolderKanbanIcon />
|
||||
<span className="truncate">{row.name}</span>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Хостеры">
|
||||
{(snapshot?.providers ?? []).map((p) => (
|
||||
<CommandItem key={p.id} value={p.name} onSelect={() => go('/providers')}>
|
||||
<Building2Icon />
|
||||
{p.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
{(snapshot?.providers ?? []).map((p) => (
|
||||
<CommandItem key={p.id} value={p.name} onSelect={() => go('/providers')}>
|
||||
<Building2Icon />
|
||||
<span className="truncate">{p.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
@@ -108,8 +108,15 @@ export const api = {
|
||||
}),
|
||||
|
||||
fetchSyncStatus: () => fetchApi('/api/sync/status'),
|
||||
sendTelegramTest: () =>
|
||||
fetchApi<{ ok: boolean; error?: string }>('/api/settings/telegram/test', { method: 'POST' }),
|
||||
sendTelegramTest: (body?: {
|
||||
telegramBotToken?: string
|
||||
telegramChatId?: string
|
||||
telegramMessageThreadId?: string
|
||||
}) =>
|
||||
fetchApi<{ ok: boolean; error?: string }>('/api/settings/telegram/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body ?? {}),
|
||||
}),
|
||||
|
||||
sendWebhookTest: () =>
|
||||
fetchApi<{ ok: boolean; error?: string }>('/api/settings/webhook/test', { method: 'POST' }),
|
||||
|
||||
@@ -42,7 +42,7 @@ function settingsToFormValues(s: Settings): SettingsFormValues {
|
||||
syncIntervalMinutes: s.syncIntervalMinutes ?? 60,
|
||||
syncTariffsIntervalMinutes: s.syncTariffsIntervalMinutes ?? 1440,
|
||||
telegramChatId: s.telegramChatId ?? '',
|
||||
telegramBotToken: s.telegramBotToken ?? '',
|
||||
telegramBotToken: '',
|
||||
notifyPaymentExpiryEnabled: s.notifyPaymentExpiryEnabled !== false,
|
||||
notifyNewTariffsEnabled: s.notifyNewTariffsEnabled !== false,
|
||||
notifyLowBalanceEnabled: s.notifyLowBalanceEnabled !== false,
|
||||
@@ -57,6 +57,26 @@ function settingsToFormValues(s: Settings): SettingsFormValues {
|
||||
}
|
||||
}
|
||||
|
||||
function buildSettingsSavePayload(r: SettingsFormValues): SettingsFormValues {
|
||||
const { telegramBotToken, ...rest } = r
|
||||
const token = telegramBotToken?.trim() ?? ''
|
||||
return token ? { ...rest, telegramBotToken: token } : (rest as SettingsFormValues)
|
||||
}
|
||||
|
||||
function buildTelegramTestPayload(values: SettingsFormValues) {
|
||||
const token = values.telegramBotToken?.trim() ?? ''
|
||||
const payload: {
|
||||
telegramChatId?: string
|
||||
telegramMessageThreadId?: string
|
||||
telegramBotToken?: string
|
||||
} = {
|
||||
telegramChatId: values.telegramChatId?.trim() || undefined,
|
||||
telegramMessageThreadId: values.telegramMessageThreadId ?? '',
|
||||
}
|
||||
if (token) payload.telegramBotToken = token
|
||||
return payload
|
||||
}
|
||||
|
||||
function BoolSelect({
|
||||
id,
|
||||
label,
|
||||
@@ -96,7 +116,7 @@ function SettingsPage() {
|
||||
|
||||
const upsertMut = useMutation({
|
||||
mutationFn: (patch: SettingsFormValues) => {
|
||||
const payload = { ...patch }
|
||||
const payload = buildSettingsSavePayload(patch)
|
||||
if (current?.id) return api.update<Settings>('settings', current.id, payload)
|
||||
return api.create<Settings>('settings', {
|
||||
id: 'settings-main',
|
||||
@@ -114,15 +134,15 @@ function SettingsPage() {
|
||||
})
|
||||
|
||||
const telegramTestMut = useMutation({
|
||||
mutationFn: () => api.sendTelegramTest(),
|
||||
mutationFn: () => api.sendTelegramTest(buildTelegramTestPayload(form.getValues())),
|
||||
onSuccess: (data) => {
|
||||
if (!data.ok) {
|
||||
toast.error(data.error ?? 'Ошибка Telegram')
|
||||
toast.error(data.error ?? 'Ошибка Telegram', { duration: 10_000 })
|
||||
return
|
||||
}
|
||||
toast.success('Тестовое сообщение отправлено')
|
||||
},
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка отправки'),
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка отправки', { duration: 10_000 }),
|
||||
})
|
||||
|
||||
const webhookTestMut = useMutation({
|
||||
@@ -319,7 +339,10 @@ function SettingsPage() {
|
||||
<Input
|
||||
id="set-tg-token"
|
||||
type="password"
|
||||
placeholder="123456:ABC-DEF..."
|
||||
autoComplete="new-password"
|
||||
placeholder={
|
||||
current?.telegramBotTokenSet ? 'Токен установлен — введите новый для замены' : '123456:ABC-DEF...'
|
||||
}
|
||||
{...form.register('telegramBotToken')}
|
||||
/>
|
||||
</FormField>
|
||||
@@ -465,10 +488,16 @@ function SettingsPage() {
|
||||
<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) => (
|
||||
{notificationRows.map((row) => {
|
||||
const errorText =
|
||||
row.status === 'failed' && row.payload?.error != null
|
||||
? String(row.payload.error)
|
||||
: ''
|
||||
return (
|
||||
<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')}
|
||||
@@ -476,8 +505,12 @@ function SettingsPage() {
|
||||
<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>
|
||||
<td className="max-w-xs px-3 py-2 text-xs text-destructive break-words">
|
||||
{errorText || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user