Skip to content

Commit 6136eeb

Browse files
committed
Release v2.13.1
1 parent 3aec0b1 commit 6136eeb

4 files changed

Lines changed: 162 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
66

7+
## [2.13.1] - 2026-05-11
8+
9+
### Changed
10+
- Alert confirmation: down/reminder alerts now require a second probe after 1 minute to reduce false positives
11+
- Recovery alerts are still sent immediately without confirmation
12+
- Switch docker-compose.yml to use Docker Hub image (`idemerge/llm-api-bench`)
13+
- Remove unused variables flagged by code quality analysis
14+
715
## [2.13.0] - 2026-05-11
816

917
### Added

backend/src/services/alertNotifier.ts

Lines changed: 148 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import crypto from 'crypto';
22
import { getDb } from './database';
33
import { 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

68
type 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) */
1631
function 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 */
164281
export 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
}

backend/src/services/monitorScheduler.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { providerStore } from './providerStore';
33
import { monitorConfigStore, MonitorTarget } from './monitorConfigStore';
44
import { testProviderConnection } from '../providers/adapter';
55
import { monitorStore, HealthStatus } from './monitorStore';
6-
import { processAlert } from './alertNotifier';
6+
import { processAlert, processPendingConfirmations } from './alertNotifier';
77

88
function classifyHealth(status: string, latencyMs: number, ttftMs: number, outputTokens: number): HealthStatus {
99
const thresholds = monitorConfigStore.getConfig().healthThresholds;
@@ -153,6 +153,10 @@ export function startScheduler() {
153153
runCheck(false).catch((err) => {
154154
console.error('[Monitor] Scheduled check failed:', err);
155155
});
156+
// Process alert confirmation queue
157+
processPendingConfirmations().catch((err) => {
158+
console.error('[Monitor] Alert confirmation failed:', err);
159+
});
156160
});
157161

158162
// Daily cleanup at 3am — remove pings older than 7 days

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "frontend",
33
"private": true,
4-
"version": "2.13.0",
4+
"version": "2.13.1",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

0 commit comments

Comments
 (0)