11import crypto from 'crypto' ;
22import { getDb } from './database' ;
33import { monitorConfigStore , MonitorTarget } from './monitorConfigStore' ;
4- import { HealthStatus } from './monitorStore' ;
4+ import { HealthStatus , monitorStore } from './monitorStore' ;
5+ import { providerStore } from './providerStore' ;
6+ import { testProviderConnection } from '../providers/adapter' ;
57
68type AlertType = 'down' | 'reminder' | 'recovery' ;
79
@@ -12,6 +14,19 @@ interface AlertMetrics {
1214 errorMessage ?: string ;
1315}
1416
17+ /** Pending confirmation: target detected down, awaiting re-check */
18+ interface PendingConfirmation {
19+ target : MonitorTarget ;
20+ metrics : AlertMetrics ;
21+ type : AlertType ;
22+ scheduledAt : number ; // when to re-check (ms timestamp)
23+ }
24+
25+ const CONFIRM_DELAY_MS = 60 * 1000 ; // 1 minute
26+
27+ // In-memory queue of targets awaiting confirmation
28+ const pendingConfirmations = new Map < string , PendingConfirmation > ( ) ; // key: "providerId::modelName"
29+
1530/** Get the previous health status for a target (skip the just-inserted ping) */
1631function getPreviousStatus ( providerId : string , modelName : string ) : HealthStatus | null {
1732 const db = getDb ( ) ;
@@ -160,6 +175,108 @@ async function sendFeishuAlert(
160175 }
161176}
162177
178+ /** Re-probe a single target to confirm its status */
179+ async function confirmProbe ( target : MonitorTarget ) : Promise < { status : HealthStatus ; metrics : AlertMetrics } | null > {
180+ const provider = providerStore . get ( target . providerId ) ;
181+ if ( ! provider ) return null ;
182+
183+ const apiKey = providerStore . getDecryptedApiKey ( target . providerId ) ;
184+ if ( ! apiKey ) return null ;
185+
186+ try {
187+ const result = await testProviderConnection ( {
188+ endpoint : provider . endpoint ,
189+ apiKey,
190+ format : provider . format ,
191+ modelName : target . modelName ,
192+ } ) ;
193+
194+ const pingStatus = result . success ? 'ok' : 'error' ;
195+ const thresholds = monitorConfigStore . getConfig ( ) . healthThresholds ;
196+ let healthStatus : HealthStatus = 'down' ;
197+ if ( pingStatus === 'ok' ) {
198+ const tps = result . latencyMs > 0 ? ( result . outputTokens / result . latencyMs ) * 1000 : 0 ;
199+ if ( result . outputTokens > 0 && result . outputTokens < thresholds . minOutputTokens ) healthStatus = 'down' ;
200+ else if ( tps > 0 && tps < thresholds . tpsVerySlowThreshold ) healthStatus = 'very_slow' ;
201+ else if ( tps > 0 && tps < thresholds . tpsSlowThreshold ) healthStatus = 'slow' ;
202+ else if ( result . ttftMs >= thresholds . ttftSlowMs ) healthStatus = 'slow' ;
203+ else healthStatus = 'healthy' ;
204+ }
205+
206+ const metrics : AlertMetrics = {
207+ latencyMs : result . latencyMs ,
208+ ttftMs : result . ttftMs ,
209+ outputTokens : result . outputTokens ,
210+ errorMessage : result . error || undefined ,
211+ } ;
212+
213+ // Record the confirmation ping
214+ const isoNow = new Date ( ) . toISOString ( ) ;
215+ monitorStore . insertPing ( {
216+ providerId : target . providerId ,
217+ providerName : target . providerName ,
218+ modelName : target . modelName ,
219+ status : pingStatus ,
220+ healthStatus,
221+ latencyMs : metrics . latencyMs ,
222+ ttftMs : metrics . ttftMs ,
223+ outputTokens : metrics . outputTokens ,
224+ responseText : result . responseText ,
225+ errorMessage : metrics . errorMessage ,
226+ checkedAt : isoNow ,
227+ } ) ;
228+
229+ return { status : healthStatus , metrics } ;
230+ } catch ( err : any ) {
231+ return {
232+ status : 'down' as HealthStatus ,
233+ metrics : { latencyMs : 0 , ttftMs : 0 , outputTokens : 0 , errorMessage : err . message } ,
234+ } ;
235+ }
236+ }
237+
238+ /** Process pending confirmations — called every minute by scheduler */
239+ export async function processPendingConfirmations ( ) : Promise < void > {
240+ if ( pendingConfirmations . size === 0 ) return ;
241+ const now = Date . now ( ) ;
242+
243+ const ready : PendingConfirmation [ ] = [ ] ;
244+ for ( const [ key , pending ] of pendingConfirmations ) {
245+ if ( now >= pending . scheduledAt ) {
246+ ready . push ( pending ) ;
247+ pendingConfirmations . delete ( key ) ;
248+ }
249+ }
250+
251+ for ( const pending of ready ) {
252+ const confirmed = await confirmProbe ( pending . target ) ;
253+ if ( ! confirmed ) continue ;
254+
255+ const isStillDown = confirmed . status === 'down' || confirmed . status === 'very_slow' ;
256+ if ( isStillDown ) {
257+ // Confirmed — send the alert
258+ const config = monitorConfigStore . getConfig ( ) ;
259+ try {
260+ await sendFeishuAlert (
261+ config . alertWebhookUrl ! ,
262+ config . alertWebhookSecret || undefined ,
263+ ( config . alertLanguage as 'en' | 'zh' ) || 'en' ,
264+ pending . type ,
265+ pending . target ,
266+ confirmed . metrics ,
267+ ) ;
268+ monitorConfigStore . updateLastAlertAt ( pending . target . providerId , pending . target . modelName ) ;
269+ } catch ( err ) {
270+ console . error ( '[Alert] Failed to send confirmed notification:' , err ) ;
271+ }
272+ } else {
273+ console . log (
274+ `[Alert] Confirmation check passed for ${ pending . target . providerId } /${ pending . target . modelName } , skipping alert` ,
275+ ) ;
276+ }
277+ }
278+ }
279+
163280/** Main entry: check and send alert if needed */
164281export async function processAlert (
165282 target : MonitorTarget ,
@@ -176,17 +293,35 @@ export async function processAlert(
176293 const decision = shouldSendAlert ( target , currentStatus , reminderMinutes ) ;
177294 if ( ! decision ) return ;
178295
179- try {
180- await sendFeishuAlert (
181- webhookUrl ,
182- config . alertWebhookSecret || undefined ,
183- config . alertLanguage || 'en' ,
184- decision . type ,
185- target ,
186- metrics ,
187- ) ;
188- monitorConfigStore . updateLastAlertAt ( target . providerId , target . modelName ) ;
189- } catch ( err ) {
190- console . error ( '[Alert] Failed to send notification:' , err ) ;
296+ // Recovery alerts are sent immediately (no confirmation needed)
297+ if ( decision . type === 'recovery' ) {
298+ try {
299+ await sendFeishuAlert (
300+ webhookUrl ,
301+ config . alertWebhookSecret || undefined ,
302+ config . alertLanguage || 'en' ,
303+ decision . type ,
304+ target ,
305+ metrics ,
306+ ) ;
307+ monitorConfigStore . updateLastAlertAt ( target . providerId , target . modelName ) ;
308+ } catch ( err ) {
309+ console . error ( '[Alert] Failed to send notification:' , err ) ;
310+ }
311+ return ;
191312 }
313+
314+ // Down/reminder: queue for confirmation in 1 minute
315+ const key = `${ target . providerId } ::${ target . modelName } ` ;
316+ if ( pendingConfirmations . has ( key ) ) return ; // already pending
317+
318+ pendingConfirmations . set ( key , {
319+ target,
320+ metrics,
321+ type : decision . type ,
322+ scheduledAt : Date . now ( ) + CONFIRM_DELAY_MS ,
323+ } ) ;
324+ console . log (
325+ `[Alert] Queued confirmation check for ${ target . providerId } /${ target . modelName } in ${ CONFIRM_DELAY_MS / 1000 } s` ,
326+ ) ;
192327}
0 commit comments