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:
@@ -22,6 +22,7 @@ import { ratesProxyRoutes } from './routes/rates-proxy.js'
|
||||
import { migrateRoutes } from './routes/migrate.js'
|
||||
import { dashboardRoutes } from './routes/dashboard.js'
|
||||
import { auditRoutes } from './routes/audit.js'
|
||||
import { notificationsRoutes } from './routes/notifications.js'
|
||||
import { startScheduler } from './services/scheduler.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
@@ -56,6 +57,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
await app.register(migrateRoutes)
|
||||
await app.register(dashboardRoutes)
|
||||
await app.register(auditRoutes)
|
||||
await app.register(notificationsRoutes)
|
||||
|
||||
const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist')
|
||||
if (existsSync(staticDir)) {
|
||||
@@ -88,4 +90,6 @@ async function start() {
|
||||
}
|
||||
}
|
||||
|
||||
void start()
|
||||
if (!process.env.VITEST) {
|
||||
void start()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { notificationRepository } from '@cfdm/db/repositories/notifications'
|
||||
|
||||
export const notificationsRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get<{ Querystring: { limit?: string } }>('/api/notifications/log', async (req) => {
|
||||
const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 50))
|
||||
return notificationRepository.listRecent(limit)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { closeDb } from '@cfdm/db'
|
||||
import { settingsRepository } from '@cfdm/db/repositories/settings'
|
||||
import { resetTestDb } from '@cfdm/db/test-setup'
|
||||
import { buildApp } from '../index.js'
|
||||
|
||||
describe('settings telegram test', () => {
|
||||
let app: Awaited<ReturnType<typeof buildApp>>
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTestDb()
|
||||
settingsRepository.upsert('settings-main', {
|
||||
telegramBotToken: 'token',
|
||||
telegramChatId: '123',
|
||||
})
|
||||
app = await buildApp()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close()
|
||||
vi.unstubAllGlobals()
|
||||
closeDb()
|
||||
})
|
||||
|
||||
it('returns telegram API error', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () =>
|
||||
Response.json({ ok: false, description: 'Bad Request: chat not found' }),
|
||||
),
|
||||
)
|
||||
const res = await app.inject({ method: 'POST', url: '/api/settings/telegram/test' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = res.json() as { ok: boolean; error?: string }
|
||||
expect(body.ok).toBe(false)
|
||||
expect(body.error).toContain('chat not found')
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { settingsSchema } from '@cfdm/shared/contracts/settings'
|
||||
|
||||
import { restartScheduler } from '../services/scheduler.js'
|
||||
import { sendTelegramMessage } from '../services/telegram.js'
|
||||
import { deliverWebhook } from '../services/notifications/channels.js'
|
||||
|
||||
export const settingsRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/api/settings', async () => settingsRepository.list())
|
||||
@@ -34,12 +35,26 @@ export const settingsRoutes: FastifyPluginAsync = async (app) => {
|
||||
if (!settings?.telegramBotToken?.trim() || !settings.telegramChatId?.trim()) {
|
||||
return { ok: false, error: 'Укажите токен бота и chat ID в настройках' }
|
||||
}
|
||||
await sendTelegramMessage(
|
||||
const result = await sendTelegramMessage(
|
||||
settings.telegramBotToken,
|
||||
settings.telegramChatId,
|
||||
'✅ VPS Tracker: тестовое сообщение',
|
||||
settings.telegramMessageThreadId,
|
||||
)
|
||||
return { ok: true }
|
||||
return result.ok ? { ok: true } : { ok: false, error: result.error ?? 'Ошибка Telegram API' }
|
||||
})
|
||||
|
||||
app.post('/api/settings/webhook/test', async () => {
|
||||
const settings = settingsRepository.getRow('settings-main')
|
||||
if (!settings?.webhookEnabled) {
|
||||
return { ok: false, error: 'Включите webhook в настройках' }
|
||||
}
|
||||
const result = await deliverWebhook(settings, {
|
||||
event: 'test',
|
||||
message: 'VPS Tracker: тестовое webhook-сообщение',
|
||||
timestamp: new Date().toISOString(),
|
||||
data: { source: 'settings_test' },
|
||||
})
|
||||
return result.ok ? { ok: true } : { ok: false, error: result.error ?? 'Ошибка webhook' }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -123,6 +123,11 @@ export function importJsonSnapshot(data: BackupPayload): void {
|
||||
customFields: customFields != null ? String(customFields) : null,
|
||||
notifyLowBalanceEnabled: s.notifyLowBalanceEnabled ? 1 : 0,
|
||||
notifySyncDigestEnabled: s.notifySyncDigestEnabled ? 1 : 0,
|
||||
notifyVpsDownEnabled: s.notifyVpsDownEnabled ? 1 : 0,
|
||||
webhookUrl: String(s.webhookUrl ?? ''),
|
||||
webhookEnabled: s.webhookEnabled ? 1 : 0,
|
||||
notifyIntervalMinutes: Number(s.notifyIntervalMinutes) || 60,
|
||||
uptimeCheckIntervalMinutes: Number(s.uptimeCheckIntervalMinutes) || 5,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { sendTelegramMessage } from '../telegram.js'
|
||||
import type { WebhookPayload } from '../webhook.js'
|
||||
import type { NotificationChannel, SettingsNotifyRow } from './types.js'
|
||||
|
||||
export async function deliverTelegram(
|
||||
settings: SettingsNotifyRow,
|
||||
messageHtml: string,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const token = settings.telegramBotToken?.trim()
|
||||
const chatId = settings.telegramChatId?.trim()
|
||||
if (!token || !chatId) return { ok: false, error: 'Telegram не настроен' }
|
||||
return sendTelegramMessage(token, chatId, messageHtml, settings.telegramMessageThreadId)
|
||||
}
|
||||
|
||||
export async function deliverWebhook(
|
||||
settings: SettingsNotifyRow,
|
||||
payload: WebhookPayload,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
if (!settings.webhookEnabled) return { ok: false, error: 'Webhook выключен' }
|
||||
const url = settings.webhookUrl?.trim()
|
||||
if (!url) return { ok: false, error: 'Webhook URL не указан' }
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) }
|
||||
}
|
||||
}
|
||||
|
||||
export function activeChannels(settings: SettingsNotifyRow): NotificationChannel[] {
|
||||
const channels: NotificationChannel[] = []
|
||||
if (settings.telegramBotToken?.trim() && settings.telegramChatId?.trim()) channels.push('telegram')
|
||||
if (settings.webhookEnabled && settings.webhookUrl?.trim()) channels.push('webhook')
|
||||
return channels
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { closeDb } from '@cfdm/db'
|
||||
import { notificationRepository } from '@cfdm/db/repositories/notifications'
|
||||
import { resetTestDb } from '@cfdm/db/test-setup'
|
||||
import { shouldSkipDedup, markDedupSent } from './dedup.js'
|
||||
|
||||
describe('notification dedup', () => {
|
||||
beforeEach(() => {
|
||||
resetTestDb()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
closeDb()
|
||||
})
|
||||
|
||||
it('skips duplicate fingerprint mode', () => {
|
||||
expect(shouldSkipDedup('sync_digest', 'fp-1', 'fingerprint')).toBe(false)
|
||||
markDedupSent('sync_digest', 'fp-1', 'fingerprint')
|
||||
expect(shouldSkipDedup('sync_digest', 'fp-1', 'fingerprint')).toBe(true)
|
||||
expect(shouldSkipDedup('sync_digest', 'fp-2', 'fingerprint')).toBe(false)
|
||||
})
|
||||
|
||||
it('skips state_transition when status unchanged', () => {
|
||||
markDedupSent('vps_down', 'host-a', 'state_transition', 'vps_health:vps_down', 'host-a')
|
||||
expect(
|
||||
shouldSkipDedup('vps_down', 'host-a', 'state_transition', 'vps_health:vps_down', 'host-a'),
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldSkipDedup('vps_down', 'host-a|host-b', 'state_transition', 'vps_health:vps_down', 'host-a|host-b'),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('logs skipped entries via repository', () => {
|
||||
notificationRepository.append({
|
||||
event: 'test',
|
||||
channel: 'webhook',
|
||||
status: 'skipped',
|
||||
fingerprint: 'x',
|
||||
message: 'msg',
|
||||
})
|
||||
const rows = notificationRepository.listRecent(5)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]?.status).toBe('skipped')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { notificationRepository } from '@cfdm/db/repositories/notifications'
|
||||
import type { DedupMode } from './types.js'
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
export function shouldSkipDedup(
|
||||
event: string,
|
||||
fingerprint: string,
|
||||
mode: DedupMode,
|
||||
stateKey?: string,
|
||||
newStatus?: string,
|
||||
): boolean {
|
||||
const key = stateKey ?? event
|
||||
const state = notificationRepository.getState(key)
|
||||
const now = Date.now()
|
||||
|
||||
if (mode === 'state_transition') {
|
||||
if (!newStatus) return false
|
||||
if (state?.lastStatus === newStatus) return true
|
||||
return false
|
||||
}
|
||||
|
||||
if (mode === 'fingerprint') {
|
||||
if (state?.lastFingerprint === fingerprint) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// daily
|
||||
if (state?.lastFingerprint === fingerprint && state.lastSentAt) {
|
||||
const last = new Date(state.lastSentAt).getTime()
|
||||
if (!Number.isNaN(last) && now - last < DAY_MS) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function markDedupSent(
|
||||
event: string,
|
||||
fingerprint: string,
|
||||
mode: DedupMode,
|
||||
stateKey?: string,
|
||||
newStatus?: string,
|
||||
): void {
|
||||
const key = stateKey ?? event
|
||||
notificationRepository.upsertState(key, {
|
||||
lastFingerprint: fingerprint,
|
||||
lastSentAt: new Date().toISOString(),
|
||||
lastStatus: mode === 'state_transition' ? newStatus : undefined,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { closeDb } from '@cfdm/db'
|
||||
import { resetTestDb } from '@cfdm/db/test-setup'
|
||||
import { publishNotification } from './engine.js'
|
||||
import type { NotificationPayload } from './types.js'
|
||||
|
||||
describe('notification engine', () => {
|
||||
beforeEach(() => {
|
||||
resetTestDb()
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response('ok', { status: 200 })),
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
closeDb()
|
||||
})
|
||||
|
||||
it('sends webhook without telegram configured', async () => {
|
||||
const payload: NotificationPayload = {
|
||||
event: 'payment_expiry',
|
||||
fingerprint: 'fp-test',
|
||||
messagePlain: 'test message',
|
||||
dedup: 'fingerprint',
|
||||
}
|
||||
const result = await publishNotification(
|
||||
{
|
||||
notifyPaymentExpiryEnabled: true,
|
||||
webhookEnabled: true,
|
||||
webhookUrl: 'https://example.com/hook',
|
||||
},
|
||||
payload,
|
||||
)
|
||||
expect(result).toBe('sent')
|
||||
expect(fetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips when event disabled', async () => {
|
||||
const payload: NotificationPayload = {
|
||||
event: 'payment_expiry',
|
||||
fingerprint: 'fp-test',
|
||||
messagePlain: 'test',
|
||||
dedup: 'fingerprint',
|
||||
}
|
||||
const result = await publishNotification(
|
||||
{
|
||||
notifyPaymentExpiryEnabled: false,
|
||||
webhookEnabled: true,
|
||||
webhookUrl: 'https://example.com/hook',
|
||||
},
|
||||
payload,
|
||||
)
|
||||
expect(result).toBe('skipped')
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { notificationRepository } from '@cfdm/db/repositories/notifications'
|
||||
import { activeChannels, deliverTelegram, deliverWebhook } from './channels.js'
|
||||
import { markDedupSent, shouldSkipDedup } from './dedup.js'
|
||||
import { eventEnabled, type NotificationPayload, type SettingsNotifyRow } from './types.js'
|
||||
|
||||
export async function publishNotification(
|
||||
settings: SettingsNotifyRow,
|
||||
payload: NotificationPayload,
|
||||
): Promise<'sent' | 'skipped' | 'failed'> {
|
||||
if (!eventEnabled(settings, payload.event)) return 'skipped'
|
||||
const channels = activeChannels(settings)
|
||||
if (channels.length === 0) return 'skipped'
|
||||
|
||||
if (
|
||||
shouldSkipDedup(
|
||||
payload.event,
|
||||
payload.fingerprint,
|
||||
payload.dedup,
|
||||
payload.stateKey,
|
||||
payload.newStatus,
|
||||
)
|
||||
) {
|
||||
for (const channel of channels) {
|
||||
notificationRepository.append({
|
||||
event: payload.event,
|
||||
channel,
|
||||
status: 'skipped',
|
||||
fingerprint: payload.fingerprint,
|
||||
message: payload.messagePlain,
|
||||
payload: payload.data,
|
||||
})
|
||||
}
|
||||
return 'skipped'
|
||||
}
|
||||
|
||||
let anySent = false
|
||||
let anyFailed = false
|
||||
|
||||
for (const channel of channels) {
|
||||
if (channel === 'telegram') {
|
||||
const result = await deliverTelegram(settings, payload.messageHtml ?? payload.messagePlain)
|
||||
notificationRepository.append({
|
||||
event: payload.event,
|
||||
channel,
|
||||
status: result.ok ? 'sent' : 'failed',
|
||||
fingerprint: payload.fingerprint,
|
||||
message: payload.messagePlain,
|
||||
payload: { ...payload.data, error: result.error },
|
||||
})
|
||||
if (result.ok) anySent = true
|
||||
else anyFailed = true
|
||||
} else {
|
||||
const result = await deliverWebhook(settings, {
|
||||
event: payload.event,
|
||||
message: payload.messagePlain,
|
||||
data: payload.data,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
notificationRepository.append({
|
||||
event: payload.event,
|
||||
channel,
|
||||
status: result.ok ? 'sent' : 'failed',
|
||||
fingerprint: payload.fingerprint,
|
||||
message: payload.messagePlain,
|
||||
payload: { ...payload.data, error: result.error },
|
||||
})
|
||||
if (result.ok) anySent = true
|
||||
else anyFailed = true
|
||||
}
|
||||
}
|
||||
|
||||
if (anySent) {
|
||||
markDedupSent(
|
||||
payload.event,
|
||||
payload.fingerprint,
|
||||
payload.dedup,
|
||||
payload.stateKey,
|
||||
payload.newStatus,
|
||||
)
|
||||
}
|
||||
|
||||
if (anySent) return 'sent'
|
||||
if (anyFailed) return 'failed'
|
||||
return 'skipped'
|
||||
}
|
||||
|
||||
export async function publishMany(
|
||||
settings: SettingsNotifyRow,
|
||||
payloads: (NotificationPayload | null)[],
|
||||
): Promise<void> {
|
||||
for (const payload of payloads) {
|
||||
if (payload) await publishNotification(settings, payload)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { desc } from 'drizzle-orm'
|
||||
import { getDb, schema } from '@cfdm/db'
|
||||
import { getPaidUntilDate } from '@cfdm/shared/utils/paid-until'
|
||||
import type { NotificationPayload } from './types.js'
|
||||
|
||||
const UPCOMING_DAYS = 7
|
||||
|
||||
type VpsRow = typeof schema.vps.$inferSelect
|
||||
|
||||
export function buildPaymentExpiryNotification(now = new Date()): NotificationPayload | null {
|
||||
const db = getDb()
|
||||
const vpsList = db.select().from(schema.vps).orderBy(desc(schema.vps.createdAt)).all()
|
||||
const providerAccounts = db.select().from(schema.providerAccounts).all()
|
||||
const payments = db.select().from(schema.payments).all()
|
||||
const balanceLedger = db.select().from(schema.balanceLedger).all()
|
||||
const providers = db.select().from(schema.providers).all()
|
||||
|
||||
const threshold = new Date(now)
|
||||
threshold.setDate(threshold.getDate() + UPCOMING_DAYS)
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const ctx = { vps: vpsList, providerAccounts, payments, balanceLedger, now }
|
||||
|
||||
const upcoming: { vps: VpsRow; paidUntil: Date; provider: string }[] = []
|
||||
for (const vps of vpsList) {
|
||||
if (vps.status !== 'active') continue
|
||||
const paidUntil = getPaidUntilDate(vps, ctx)
|
||||
if (!paidUntil || paidUntil > threshold || paidUntil < todayStart) continue
|
||||
const provider = providers.find((p) => p.id === vps.providerId)
|
||||
upcoming.push({ vps, paidUntil, provider: provider?.name || '-' })
|
||||
}
|
||||
upcoming.sort((a, b) => a.paidUntil.getTime() - b.paidUntil.getTime())
|
||||
if (upcoming.length === 0) return null
|
||||
|
||||
const lines = upcoming.slice(0, 10).map(({ vps, paidUntil, provider }) => {
|
||||
const dateStr = paidUntil.toLocaleDateString('ru-RU')
|
||||
return `• ${vps.dns || vps.ip} (${provider}) — до ${dateStr}`
|
||||
})
|
||||
const plain = `Истекает оплата (ближайшие ${UPCOMING_DAYS} дней):\n\n${lines.join('\n')}`
|
||||
const html = `⚠️ <b>Истекает оплата</b> (ближайшие ${UPCOMING_DAYS} дней):\n\n${lines.join('\n')}`
|
||||
const fingerprint = upcoming
|
||||
.map(({ vps, paidUntil }) => `${vps.id}:${paidUntil.toISOString().slice(0, 10)}`)
|
||||
.sort()
|
||||
.join('|')
|
||||
|
||||
return {
|
||||
event: 'payment_expiry',
|
||||
fingerprint,
|
||||
messagePlain: plain,
|
||||
messageHtml: html,
|
||||
data: { count: upcoming.length, vpsIds: upcoming.map((u) => u.vps.id) },
|
||||
dedup: 'daily',
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSyncDigestNotification(digestLines: string[]): NotificationPayload | null {
|
||||
const changed = digestLines.filter((line) => !line.includes('без изменений'))
|
||||
if (changed.length === 0) return null
|
||||
const plain = `Синхронизация VPS:\n\n${changed.join('\n')}`
|
||||
const html = `📋 <b>Синхронизация VPS</b>\n\n${changed.join('\n')}`
|
||||
return {
|
||||
event: 'sync_digest',
|
||||
fingerprint: changed.sort().join('|'),
|
||||
messagePlain: plain,
|
||||
messageHtml: html,
|
||||
data: { lines: changed },
|
||||
dedup: 'fingerprint',
|
||||
}
|
||||
}
|
||||
|
||||
export function buildLowBalanceNotification(lines: string[]): NotificationPayload | null {
|
||||
if (lines.length === 0) return null
|
||||
const plain = `Низкий баланс:\n\n${lines.join('\n')}`
|
||||
const html = `💰 <b>Низкий баланс</b>\n\n${lines.join('\n')}`
|
||||
return {
|
||||
event: 'low_balance',
|
||||
fingerprint: lines.sort().join('|'),
|
||||
messagePlain: plain,
|
||||
messageHtml: html,
|
||||
data: { lines },
|
||||
dedup: 'daily',
|
||||
}
|
||||
}
|
||||
|
||||
export function buildNewTariffsNotification(
|
||||
providerName: string,
|
||||
tariffs: { name?: string | null; price?: string | null }[],
|
||||
): NotificationPayload | null {
|
||||
if (tariffs.length === 0) return null
|
||||
const lines = tariffs.slice(0, 15).map((t) => `• ${t.name || '—'} — ${t.price || '—'}`)
|
||||
const plain = `Новые тарифы (${providerName}):\n\n${lines.join('\n')}`
|
||||
const html = `🆕 <b>Новые тарифы</b> (${providerName}):\n\n${lines.join('\n')}`
|
||||
const fingerprint = tariffs
|
||||
.map((t) => `${t.name}:${t.price}`)
|
||||
.sort()
|
||||
.join('|')
|
||||
return {
|
||||
event: 'new_tariffs',
|
||||
fingerprint: `${providerName}:${fingerprint}`,
|
||||
messagePlain: plain,
|
||||
messageHtml: html,
|
||||
data: { providerName, count: tariffs.length },
|
||||
dedup: 'fingerprint',
|
||||
}
|
||||
}
|
||||
|
||||
export function buildVpsHealthNotification(
|
||||
event: 'vps_down' | 'vps_up',
|
||||
hosts: { id: string; label: string }[],
|
||||
): NotificationPayload | null {
|
||||
if (hosts.length === 0) return null
|
||||
const lines = hosts.map((h) => `• ${h.label}`)
|
||||
const title = event === 'vps_down' ? 'VPS недоступны' : 'VPS восстановлены'
|
||||
const icon = event === 'vps_down' ? '🔴' : '🟢'
|
||||
const plain = `${title}:\n\n${lines.join('\n')}`
|
||||
const html = `${icon} <b>${title}</b>\n\n${lines.join('\n')}`
|
||||
const fingerprint = hosts
|
||||
.map((h) => h.id)
|
||||
.sort()
|
||||
.join('|')
|
||||
return {
|
||||
event,
|
||||
fingerprint,
|
||||
messagePlain: plain,
|
||||
messageHtml: html,
|
||||
data: { hosts: hosts.map((h) => h.id) },
|
||||
dedup: 'state_transition',
|
||||
stateKey: `vps_health:${event}`,
|
||||
newStatus: fingerprint,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
export const NOTIFICATION_EVENTS = [
|
||||
'payment_expiry',
|
||||
'sync_digest',
|
||||
'low_balance',
|
||||
'new_tariffs',
|
||||
'vps_down',
|
||||
'vps_up',
|
||||
] as const
|
||||
|
||||
export type NotificationEvent = (typeof NOTIFICATION_EVENTS)[number]
|
||||
|
||||
export type NotificationChannel = 'telegram' | 'webhook'
|
||||
|
||||
export type DedupMode = 'daily' | 'fingerprint' | 'state_transition'
|
||||
|
||||
export interface NotificationPayload {
|
||||
event: NotificationEvent
|
||||
fingerprint: string
|
||||
messagePlain: string
|
||||
messageHtml?: string
|
||||
data?: Record<string, unknown>
|
||||
dedup: DedupMode
|
||||
stateKey?: string
|
||||
newStatus?: string
|
||||
}
|
||||
|
||||
export interface SettingsNotifyRow {
|
||||
telegramBotToken?: string | null
|
||||
telegramChatId?: string | null
|
||||
telegramMessageThreadId?: string | null
|
||||
webhookUrl?: string | null
|
||||
webhookEnabled?: number | boolean | null
|
||||
notifyPaymentExpiryEnabled?: number | boolean | null
|
||||
notifyNewTariffsEnabled?: number | boolean | null
|
||||
notifyLowBalanceEnabled?: number | boolean | null
|
||||
notifySyncDigestEnabled?: number | boolean | null
|
||||
notifyVpsDownEnabled?: number | boolean | null
|
||||
}
|
||||
|
||||
export function isNotifyFlagEnabled(flag: number | boolean | null | undefined): boolean {
|
||||
return flag !== 0 && flag !== false
|
||||
}
|
||||
|
||||
export function eventEnabled(settings: SettingsNotifyRow, event: NotificationEvent): boolean {
|
||||
switch (event) {
|
||||
case 'payment_expiry':
|
||||
return isNotifyFlagEnabled(settings.notifyPaymentExpiryEnabled)
|
||||
case 'sync_digest':
|
||||
return isNotifyFlagEnabled(settings.notifySyncDigestEnabled)
|
||||
case 'low_balance':
|
||||
return isNotifyFlagEnabled(settings.notifyLowBalanceEnabled)
|
||||
case 'new_tariffs':
|
||||
return isNotifyFlagEnabled(settings.notifyNewTariffsEnabled)
|
||||
case 'vps_down':
|
||||
case 'vps_up':
|
||||
return isNotifyFlagEnabled(settings.notifyVpsDownEnabled)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,27 @@
|
||||
import { and, desc, eq, sql } from 'drizzle-orm'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { getDb, schema } from '@cfdm/db'
|
||||
import { settingsRepository } from '@cfdm/db/repositories/settings'
|
||||
|
||||
import { billmanagerAccountRowForSync } from './billmanager/context.js'
|
||||
import { runBillmanagerAccountSync } from './billmanager/sync-job.js'
|
||||
import { sendTelegramMessage } from './telegram.js'
|
||||
import { notifyWebhook } from './webhook.js'
|
||||
import { runVpsUptimeChecks } from './uptime-check.js'
|
||||
import { publishMany, publishNotification } from './notifications/engine.js'
|
||||
import {
|
||||
buildLowBalanceNotification,
|
||||
buildNewTariffsNotification,
|
||||
buildPaymentExpiryNotification,
|
||||
buildSyncDigestNotification,
|
||||
buildVpsHealthNotification,
|
||||
} from './notifications/rules.js'
|
||||
|
||||
let syncIntervalId: ReturnType<typeof setInterval> | null = null
|
||||
let syncTariffsIntervalId: ReturnType<typeof setInterval> | null = null
|
||||
let notifyIntervalId: ReturnType<typeof setInterval> | null = null
|
||||
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const UPCOMING_DAYS = 7
|
||||
const SETTINGS_ID = 'settings-main'
|
||||
|
||||
type AccountRow = typeof schema.providerAccounts.$inferSelect
|
||||
type VpsRow = typeof schema.vps.$inferSelect
|
||||
type PaymentRow = typeof schema.payments.$inferSelect
|
||||
type LedgerRow = typeof schema.balanceLedger.$inferSelect
|
||||
|
||||
function getBillmanagerAccounts(): NonNullable<ReturnType<typeof billmanagerAccountRowForSync>>[] {
|
||||
const db = getDb()
|
||||
@@ -37,127 +40,15 @@ function getBillmanagerAccounts(): NonNullable<ReturnType<typeof billmanagerAcco
|
||||
.filter((a): a is NonNullable<typeof a> => a != null)
|
||||
}
|
||||
|
||||
function getAccountBalance(
|
||||
accountId: string,
|
||||
providerAccounts: AccountRow[],
|
||||
balanceLedger: LedgerRow[],
|
||||
): number {
|
||||
const account = providerAccounts.find((a) => a.id === accountId)
|
||||
if (account?.balanceApi != null && Number.isFinite(Number(account.balanceApi))) {
|
||||
return Number(account.balanceApi)
|
||||
export async function runNotificationTick(): Promise<void> {
|
||||
try {
|
||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||
if (!settings) return
|
||||
const payload = buildPaymentExpiryNotification()
|
||||
if (payload) await publishNotification(settings, payload)
|
||||
} catch (err) {
|
||||
console.warn('Notification tick error:', err instanceof Error ? err.message : err)
|
||||
}
|
||||
const rows = balanceLedger.filter((row) => row.providerAccountId === accountId)
|
||||
const credits = rows
|
||||
.filter((row) => row.direction === 'credit')
|
||||
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||
const debits = rows
|
||||
.filter((row) => row.direction === 'debit')
|
||||
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||
return credits - debits
|
||||
}
|
||||
|
||||
function getPaidUntilDate(
|
||||
vps: VpsRow,
|
||||
providerAccounts: AccountRow[],
|
||||
payments: PaymentRow[],
|
||||
balanceLedger: LedgerRow[],
|
||||
now: Date,
|
||||
): Date | null {
|
||||
if (vps.status !== 'active') return null
|
||||
const account = providerAccounts.find((a) => a.id === vps.providerAccountId)
|
||||
const tariffType = vps.tariffType || (Number(vps.dailyRate || 0) > 0 ? 'daily' : 'monthly')
|
||||
const isDailyBilling = tariffType === 'daily' || account?.billingMode === 'daily'
|
||||
|
||||
let paidUntilFromApi: Date | null = null
|
||||
if (vps.paidUntil) {
|
||||
const d = new Date(vps.paidUntil)
|
||||
paidUntilFromApi = Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
const isPaidUntilNextDay =
|
||||
paidUntilFromApi &&
|
||||
(() => {
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const diffDays = Math.round((paidUntilFromApi.getTime() - today.getTime()) / (24 * 60 * 60 * 1000))
|
||||
return diffDays >= 0 && diffDays <= 2
|
||||
})()
|
||||
|
||||
const shouldCalculateFromBalance = isDailyBilling || isPaidUntilNextDay
|
||||
if (!shouldCalculateFromBalance && paidUntilFromApi) return paidUntilFromApi
|
||||
|
||||
const dailyRate = Number(vps.dailyRate || 0)
|
||||
const monthlyRate = Number(vps.monthlyRate || 0)
|
||||
const burnRate = tariffType === 'daily' ? dailyRate : monthlyRate / 30
|
||||
if (!Number.isFinite(burnRate) || burnRate <= 0) return paidUntilFromApi
|
||||
|
||||
const accountBalance = getAccountBalance(vps.providerAccountId ?? '', providerAccounts, balanceLedger)
|
||||
const activeInAccount = getDb()
|
||||
.select({ id: schema.vps.id })
|
||||
.from(schema.vps)
|
||||
.where(
|
||||
and(eq(schema.vps.providerAccountId, vps.providerAccountId ?? ''), eq(schema.vps.status, 'active')),
|
||||
)
|
||||
.all().length
|
||||
const allocatedBalance = activeInAccount > 0 ? Math.max(0, accountBalance) / activeInAccount : 0
|
||||
const directPayments = payments
|
||||
.filter((p) => p.vpsId === vps.id && p.type === 'direct_vps_payment')
|
||||
.reduce((acc, p) => acc + Number(p.amount || 0), 0)
|
||||
const funds = directPayments + allocatedBalance
|
||||
const coveredDays = Math.floor(funds / burnRate)
|
||||
if (!Number.isFinite(coveredDays) || coveredDays <= 0) return paidUntilFromApi
|
||||
|
||||
const paidUntil = new Date(now)
|
||||
paidUntil.setDate(paidUntil.getDate() + coveredDays)
|
||||
return paidUntil
|
||||
}
|
||||
|
||||
async function sendPaymentExpiryNotifications(): Promise<void> {
|
||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||
if (
|
||||
!settings?.notifyPaymentExpiryEnabled ||
|
||||
!settings.telegramBotToken?.trim() ||
|
||||
!settings.telegramChatId?.trim()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const vpsList = db.select().from(schema.vps).orderBy(desc(schema.vps.createdAt)).all()
|
||||
const providerAccounts = db.select().from(schema.providerAccounts).all()
|
||||
const payments = db.select().from(schema.payments).all()
|
||||
const balanceLedger = db.select().from(schema.balanceLedger).all()
|
||||
const providers = db.select().from(schema.providers).all()
|
||||
|
||||
const now = new Date()
|
||||
const threshold = new Date(now)
|
||||
threshold.setDate(threshold.getDate() + UPCOMING_DAYS)
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
|
||||
const upcoming: { vps: VpsRow; paidUntil: Date; provider: string }[] = []
|
||||
for (const vps of vpsList) {
|
||||
if (vps.status !== 'active') continue
|
||||
const paidUntil = getPaidUntilDate(vps, providerAccounts, payments, balanceLedger, now)
|
||||
if (!paidUntil || paidUntil > threshold || paidUntil < todayStart) continue
|
||||
const provider = providers.find((p) => p.id === vps.providerId)
|
||||
upcoming.push({ vps, paidUntil, provider: provider?.name || '-' })
|
||||
}
|
||||
upcoming.sort((a, b) => a.paidUntil.getTime() - b.paidUntil.getTime())
|
||||
if (upcoming.length === 0) return
|
||||
|
||||
const lines = upcoming.slice(0, 10).map(({ vps, paidUntil, provider }) => {
|
||||
const dateStr = paidUntil.toLocaleDateString('ru-RU')
|
||||
return `• ${vps.dns || vps.ip} (${provider}) — до ${dateStr}`
|
||||
})
|
||||
const text = `⚠️ <b>Истекает оплата</b> (ближайшие ${UPCOMING_DAYS} дней):\n\n${lines.join('\n')}`
|
||||
await sendTelegramMessage(
|
||||
settings.telegramBotToken,
|
||||
settings.telegramChatId,
|
||||
text,
|
||||
settings.telegramMessageThreadId,
|
||||
)
|
||||
await notifyWebhook(settings, 'payment_expiry', text.replace(/<[^>]+>/g, ''), {
|
||||
count: upcoming.length,
|
||||
})
|
||||
}
|
||||
|
||||
export async function runScheduledSync(): Promise<void> {
|
||||
@@ -168,9 +59,6 @@ export async function runScheduledSync(): Promise<void> {
|
||||
const accounts = getBillmanagerAccounts()
|
||||
const digestLines: string[] = []
|
||||
const lowBalanceLines: string[] = []
|
||||
const token = settings.telegramBotToken?.trim()
|
||||
const chatId = settings.telegramChatId?.trim()
|
||||
const canTg = Boolean(token && chatId)
|
||||
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
@@ -185,7 +73,6 @@ export async function runScheduledSync(): Promise<void> {
|
||||
const apiBal = result.balance?.balance
|
||||
const threshold = account.balanceAlertBelow
|
||||
if (
|
||||
canTg &&
|
||||
settings.notifyLowBalanceEnabled &&
|
||||
threshold != null &&
|
||||
Number.isFinite(Number(threshold)) &&
|
||||
@@ -202,20 +89,10 @@ export async function runScheduledSync(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (canTg && settings.notifySyncDigestEnabled && digestLines.length > 0) {
|
||||
const msg = `📋 <b>Синхронизация VPS</b>\n\n${digestLines.join('\n')}`
|
||||
await sendTelegramMessage(token!, chatId!, msg, settings.telegramMessageThreadId)
|
||||
await notifyWebhook(settings, 'sync_digest', msg.replace(/<[^>]+>/g, ''), { lines: digestLines })
|
||||
}
|
||||
if (canTg && settings.notifyLowBalanceEnabled && lowBalanceLines.length > 0) {
|
||||
const msg = `💰 <b>Низкий баланс</b>\n\n${lowBalanceLines.join('\n')}`
|
||||
await sendTelegramMessage(token!, chatId!, msg, settings.telegramMessageThreadId)
|
||||
await notifyWebhook(settings, 'low_balance', msg.replace(/<[^>]+>/g, ''), { lines: lowBalanceLines })
|
||||
}
|
||||
|
||||
if (settings.notifyPaymentExpiryEnabled) {
|
||||
await sendPaymentExpiryNotifications()
|
||||
}
|
||||
await publishMany(settings, [
|
||||
buildSyncDigestNotification(digestLines),
|
||||
buildLowBalanceNotification(lowBalanceLines),
|
||||
])
|
||||
} catch (err) {
|
||||
console.warn('Scheduled sync error:', err instanceof Error ? err.message : err)
|
||||
}
|
||||
@@ -233,21 +110,14 @@ export async function runScheduledSyncTariffs(): Promise<void> {
|
||||
try {
|
||||
const result = await runBillmanagerAccountSync(account, { skipVpsPayments: true })
|
||||
const newTariffs = result.newTariffs || []
|
||||
if (
|
||||
newTariffs.length > 0 &&
|
||||
settings.notifyNewTariffsEnabled &&
|
||||
settings.telegramBotToken?.trim() &&
|
||||
settings.telegramChatId?.trim()
|
||||
) {
|
||||
if (newTariffs.length > 0 && settings.notifyNewTariffsEnabled) {
|
||||
const provider = providers.find((p) => p.id === account.providerId)
|
||||
const providerName = provider?.name || account.name || '-'
|
||||
const lines = newTariffs.slice(0, 15).map((t) => `• ${t.name || '—'} — ${t.price || '—'}`)
|
||||
await sendTelegramMessage(
|
||||
settings.telegramBotToken,
|
||||
settings.telegramChatId,
|
||||
`🆕 <b>Новые тарифы</b> (${providerName}):\n\n${lines.join('\n')}`,
|
||||
settings.telegramMessageThreadId,
|
||||
const payload = buildNewTariffsNotification(
|
||||
providerName,
|
||||
newTariffs.map((t) => ({ name: t.name, price: t.price })),
|
||||
)
|
||||
if (payload) await publishNotification(settings, payload)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Sync tariffs failed for account ${account.id}:`, err instanceof Error ? err.message : err)
|
||||
@@ -261,23 +131,19 @@ export async function runScheduledSyncTariffs(): Promise<void> {
|
||||
export async function runScheduledUptimeChecks(): Promise<void> {
|
||||
try {
|
||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||
const { checked, down } = await runVpsUptimeChecks()
|
||||
if (down > 0 && settings) {
|
||||
const msg = `VPS недоступны: ${down} из ${checked}`
|
||||
if (
|
||||
settings.notifyVpsDownEnabled &&
|
||||
settings.telegramBotToken?.trim() &&
|
||||
settings.telegramChatId?.trim()
|
||||
) {
|
||||
await sendTelegramMessage(
|
||||
settings.telegramBotToken,
|
||||
settings.telegramChatId,
|
||||
`🔴 <b>${msg}</b>`,
|
||||
settings.telegramMessageThreadId,
|
||||
)
|
||||
}
|
||||
await notifyWebhook(settings, 'vps_down', msg, { checked, down })
|
||||
}
|
||||
if (!settings) return
|
||||
|
||||
const { newlyDown, newlyUp } = await runVpsUptimeChecks()
|
||||
await publishMany(settings, [
|
||||
buildVpsHealthNotification(
|
||||
'vps_down',
|
||||
newlyDown.map((h) => ({ id: h.id, label: h.label })),
|
||||
),
|
||||
buildVpsHealthNotification(
|
||||
'vps_up',
|
||||
newlyUp.map((h) => ({ id: h.id, label: h.label })),
|
||||
),
|
||||
])
|
||||
} catch (err) {
|
||||
console.warn('Uptime check error:', err instanceof Error ? err.message : err)
|
||||
}
|
||||
@@ -288,22 +154,37 @@ export function startScheduler(): void {
|
||||
syncIntervalId = null
|
||||
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
|
||||
syncTariffsIntervalId = null
|
||||
if (notifyIntervalId) clearInterval(notifyIntervalId)
|
||||
notifyIntervalId = null
|
||||
if (uptimeIntervalId) clearInterval(uptimeIntervalId)
|
||||
uptimeIntervalId = null
|
||||
|
||||
try {
|
||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||
if (!settings?.syncEnabled) return
|
||||
if (!settings) return
|
||||
|
||||
const interval = Math.max(15, Number(settings.syncIntervalMinutes) || 60)
|
||||
const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440)
|
||||
syncIntervalId = setInterval(() => void runScheduledSync(), interval * 60 * 1000)
|
||||
syncTariffsIntervalId = setInterval(() => void runScheduledSyncTariffs(), tariffsInterval * 60 * 1000)
|
||||
uptimeIntervalId = setInterval(() => void runScheduledUptimeChecks(), 5 * 60 * 1000)
|
||||
const notifyInterval = Math.max(15, Number(settings.notifyIntervalMinutes) || 60)
|
||||
const uptimeInterval = Math.max(1, Number(settings.uptimeCheckIntervalMinutes) || 5)
|
||||
|
||||
notifyIntervalId = setInterval(() => void runNotificationTick(), notifyInterval * 60 * 1000)
|
||||
uptimeIntervalId = setInterval(() => void runScheduledUptimeChecks(), uptimeInterval * 60 * 1000)
|
||||
void runNotificationTick()
|
||||
void runScheduledUptimeChecks()
|
||||
console.log(
|
||||
`Scheduled sync enabled: VPS/payments every ${interval} min, tariffs every ${tariffsInterval} min, uptime every 5 min`,
|
||||
)
|
||||
|
||||
const parts = [`notify every ${notifyInterval} min`, `uptime every ${uptimeInterval} min`]
|
||||
|
||||
if (settings.syncEnabled) {
|
||||
const interval = Math.max(15, Number(settings.syncIntervalMinutes) || 60)
|
||||
const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440)
|
||||
syncIntervalId = setInterval(() => void runScheduledSync(), interval * 60 * 1000)
|
||||
syncTariffsIntervalId = setInterval(
|
||||
() => void runScheduledSyncTariffs(),
|
||||
tariffsInterval * 60 * 1000,
|
||||
)
|
||||
parts.unshift(`sync every ${interval} min`, `tariffs every ${tariffsInterval} min`)
|
||||
}
|
||||
|
||||
console.log(`Scheduler: ${parts.join(', ')}`)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -314,6 +195,8 @@ export function stopScheduler(): void {
|
||||
syncIntervalId = null
|
||||
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
|
||||
syncTariffsIntervalId = null
|
||||
if (notifyIntervalId) clearInterval(notifyIntervalId)
|
||||
notifyIntervalId = null
|
||||
if (uptimeIntervalId) clearInterval(uptimeIntervalId)
|
||||
uptimeIntervalId = null
|
||||
}
|
||||
|
||||
@@ -2,20 +2,27 @@
|
||||
* Telegram Bot API — отправка уведомлений
|
||||
*/
|
||||
|
||||
export interface TelegramSendResult {
|
||||
ok: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function sendTelegramMessage(
|
||||
token: string,
|
||||
chatIds: string | string[],
|
||||
text: string,
|
||||
messageThreadId?: string | number | null,
|
||||
): Promise<void> {
|
||||
if (!token?.trim() || !text?.trim()) return
|
||||
): Promise<TelegramSendResult> {
|
||||
if (!token?.trim() || !text?.trim()) {
|
||||
return { ok: false, error: 'Пустой токен или текст' }
|
||||
}
|
||||
const ids = Array.isArray(chatIds)
|
||||
? chatIds
|
||||
: String(chatIds || '')
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean)
|
||||
if (ids.length === 0) return
|
||||
if (ids.length === 0) return { ok: false, error: 'Не указан chat ID' }
|
||||
|
||||
const threadId = messageThreadId != null && messageThreadId !== '' ? Number(messageThreadId) : null
|
||||
const payload: Record<string, unknown> = {
|
||||
@@ -26,6 +33,9 @@ export async function sendTelegramMessage(
|
||||
if (Number.isFinite(threadId)) payload.message_thread_id = threadId
|
||||
|
||||
const url = `https://api.telegram.org/bot${token.trim()}/sendMessage`
|
||||
const errors: string[] = []
|
||||
let anyOk = false
|
||||
|
||||
for (const chatId of ids) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
@@ -34,12 +44,20 @@ export async function sendTelegramMessage(
|
||||
body: JSON.stringify({ ...payload, chat_id: chatId }),
|
||||
})
|
||||
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; description?: string }
|
||||
if (!data.ok) {
|
||||
console.warn(`Telegram sendMessage failed for chat ${chatId}:`, data.description || res.statusText)
|
||||
if (data.ok) {
|
||||
anyOk = true
|
||||
} else {
|
||||
const err = data.description || res.statusText || 'Unknown error'
|
||||
errors.push(`${chatId}: ${err}`)
|
||||
console.warn(`Telegram sendMessage failed for chat ${chatId}:`, err)
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
errors.push(`${chatId}: ${message}`)
|
||||
console.warn(`Telegram sendMessage error for chat ${chatId}:`, message)
|
||||
}
|
||||
}
|
||||
|
||||
if (anyOk) return { ok: true }
|
||||
return { ok: false, error: errors.join('; ') || 'Не удалось отправить' }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,20 @@ import { getDb, schema } from '@cfdm/db'
|
||||
|
||||
const CHECK_TIMEOUT_MS = 5000
|
||||
|
||||
export interface VpsHealthTransition {
|
||||
id: string
|
||||
label: string
|
||||
previousStatus: string | null
|
||||
currentStatus: 'up' | 'down'
|
||||
}
|
||||
|
||||
export interface UptimeCheckResult {
|
||||
checked: number
|
||||
down: number
|
||||
newlyDown: VpsHealthTransition[]
|
||||
newlyUp: VpsHealthTransition[]
|
||||
}
|
||||
|
||||
function tcpCheck(host: string, port: number): Promise<{ ok: boolean; latencyMs: number; error?: string }> {
|
||||
const started = Date.now()
|
||||
return new Promise((resolve) => {
|
||||
@@ -19,7 +33,7 @@ function tcpCheck(host: string, port: number): Promise<{ ok: boolean; latencyMs:
|
||||
})
|
||||
}
|
||||
|
||||
export async function runVpsUptimeChecks(): Promise<{ checked: number; down: number }> {
|
||||
export async function runVpsUptimeChecks(): Promise<UptimeCheckResult> {
|
||||
const db = getDb()
|
||||
const rows = db
|
||||
.select()
|
||||
@@ -29,6 +43,8 @@ export async function runVpsUptimeChecks(): Promise<{ checked: number; down: num
|
||||
|
||||
let checked = 0
|
||||
let down = 0
|
||||
const newlyDown: VpsHealthTransition[] = []
|
||||
const newlyUp: VpsHealthTransition[] = []
|
||||
const now = new Date().toISOString()
|
||||
|
||||
for (const row of rows) {
|
||||
@@ -38,8 +54,16 @@ export async function runVpsUptimeChecks(): Promise<{ checked: number; down: num
|
||||
const port = Number(row.sshPort) || 22
|
||||
const result = await tcpCheck(host, port)
|
||||
checked++
|
||||
const status = result.ok ? 'up' : 'down'
|
||||
if (!result.ok) down++
|
||||
const status: 'up' | 'down' = result.ok ? 'up' : 'down'
|
||||
if (status === 'down') down++
|
||||
|
||||
const previous = row.lastHealthStatus
|
||||
const label = row.dns || row.ip || row.id
|
||||
if (status === 'down' && previous !== 'down') {
|
||||
newlyDown.push({ id: row.id, label, previousStatus: previous, currentStatus: 'down' })
|
||||
} else if (status === 'up' && previous === 'down') {
|
||||
newlyUp.push({ id: row.id, label, previousStatus: previous, currentStatus: 'up' })
|
||||
}
|
||||
|
||||
db.insert(schema.vpsHealthChecks)
|
||||
.values({
|
||||
@@ -61,7 +85,7 @@ export async function runVpsUptimeChecks(): Promise<{ checked: number; down: num
|
||||
.run()
|
||||
}
|
||||
|
||||
return { checked, down }
|
||||
return { checked, down, newlyDown, newlyUp }
|
||||
}
|
||||
|
||||
export function listRecentHealthChecks(vpsId: string, limit = 20) {
|
||||
|
||||
Reference in New Issue
Block a user