// 站点拨测可观测分析 — 按协议聚合指标与异常事件 // Logan.Li import type { SiteResult, SiteTask } from '../helpers'; import { fmtMs, fmtTime, normalizeProbeType } from '../helpers'; import i18n from '@/i18n'; export interface ProbeEvent { id: string; at: string; timeLabel: string; severity: 'info' | 'warn' | 'error'; title: string; detail?: string; } export interface HttpObsSummary { expectedCode: number; okCount: number; err4xx: number; err5xx: number; otherErr: number; lastCode?: number; lastCodeTone: string; events: ProbeEvent[]; chart: Array<{ time: string; code: number; tone: string; ok: boolean }>; } export interface SslObsSummary { expiry?: string; issuer?: string; days?: number; daysTone: string; handshakeMs: number; events: ProbeEvent[]; chart: Array<{ time: string; days: number }>; } export interface LatencyObsSummary { avg: number; min: number; max: number; p95: number; jitter: number; spikeCount: number; events: ProbeEvent[]; chart: Array<{ time: string; latency: number; failed: boolean; spike: boolean }>; } export interface ConnectivityObsSummary { upRate: string; failCount: number; lastFailAt?: string; events: ProbeEvent[]; chart: Array<{ time: string; up: number; failed: boolean }>; } const tooltipOrder = (results: SiteResult[]) => [...results].reverse(); function httpCodeTone(code: number, expected: number): string { if (code === expected || (expected === 200 && code >= 200 && code < 300)) return 'dg-text-green'; if (code >= 500) return 'dg-text-red'; if (code >= 400) return 'dg-text-yellow'; return 'dg-text-red'; } function pushEvent(events: ProbeEvent[], r: SiteResult, title: string, severity: ProbeEvent['severity'], detail?: string) { events.push({ id: `${r.id}-${title}`, at: r.checked_at, timeLabel: fmtTime(r.checked_at), severity, title, detail, }); } export function analyzeHttpResults(results: SiteResult[], task: SiteTask): HttpObsSummary { const expected = task.expected_status_code || 200; const chart = tooltipOrder(results).map((r) => { const code = r.http_status_code ?? (r.status === 'success' ? expected : 0); const ok = r.status === 'success' && (code === expected || (expected === 200 && code >= 200 && code < 300)); return { time: fmtTime(r.checked_at).slice(-5), code, tone: code > 0 ? httpCodeTone(code, expected) : 'dg-text-red', ok }; }); let okCount = 0; let err4xx = 0; let err5xx = 0; let otherErr = 0; const events: ProbeEvent[] = []; for (const r of results) { const code = r.http_status_code ?? 0; if (r.status === 'success' && (code === expected || (expected === 200 && code >= 200 && code < 300))) { okCount++; continue; } if (code >= 500) { err5xx++; pushEvent(events, r, `HTTP ${code}`, 'error', r.error_message || i18n.t('monitor.lbl.siteServerError')); } else if (code >= 400) { err4xx++; pushEvent(events, r, `HTTP ${code}`, 'warn', r.error_message || i18n.t('monitor.lbl.siteClientError')); } else if (r.status === 'timeout') { otherErr++; pushEvent(events, r, i18n.t('common.timeoutError'), 'error', r.error_message); } else { otherErr++; pushEvent(events, r, code > 0 ? `HTTP ${code}` : i18n.t('monitor.lbl.siteRequestFailed'), 'error', r.error_message); } } const latest = results[0]; const lastCode = latest?.http_status_code; return { expectedCode: expected, okCount, err4xx, err5xx, otherErr, lastCode, lastCodeTone: lastCode ? httpCodeTone(lastCode, expected) : 'dg-text-red', events: events.slice(0, 8), chart, }; } export function analyzeSslResults(results: SiteResult[], task: SiteTask): SslObsSummary { const latest = results.find((r) => r.ssl_days_remaining != null) ?? results[0]; const days = latest?.ssl_days_remaining ?? task.ssl_days_remaining; const expiry = latest?.ssl_cert_expiry?.slice(0, 10) ?? task.ssl_cert_expiry?.slice(0, 10); const issuer = latest?.ssl_cert_issuer; const events: ProbeEvent[] = []; for (const r of results) { if (r.status !== 'success') { pushEvent(events, r, i18n.t('monitor.lbl.siteRequestTimeout'), 'error', r.error_message); } else if (r.ssl_days_remaining != null && r.ssl_days_remaining < 30) { pushEvent(events, r, i18n.t('monitor.siteMonitor.certRemaining', { days: r.ssl_days_remaining }), r.ssl_days_remaining <= 7 ? 'error' : 'warn', expiry); } } const chart = tooltipOrder(results) .filter((r) => r.ssl_days_remaining != null) .map((r) => ({ time: fmtTime(r.checked_at).slice(-5), days: r.ssl_days_remaining as number })); const tone = days == null ? 'text-[var(--dg-text-muted)]' : days <= 7 ? 'dg-text-red' : days <= 30 ? 'dg-text-yellow' : 'dg-text-green'; const handshakeMs = results.length ? results.reduce((s, r) => s + (r.response_time || r.total_time || 0), 0) / results.length : 0; return { expiry, issuer, days, daysTone: tone, handshakeMs, events: events.slice(0, 8), chart }; } function analyzeLatencyResults(results: SiteResult[], label: string): LatencyObsSummary { const latencies = results.map((r) => r.response_time ?? r.total_time ?? 0).filter((v) => v > 0); const sorted = [...latencies].sort((a, b) => a - b); const avg = latencies.length ? latencies.reduce((a, b) => a + b, 0) / latencies.length : 0; const min = sorted[0] ?? 0; const max = sorted[sorted.length - 1] ?? 0; const p95 = sorted.length ? sorted[Math.floor(sorted.length * 0.95)] ?? max : 0; const jitter = max - min; const spikeThreshold = Math.max(avg * 1.8, avg + 50); const events: ProbeEvent[] = []; let spikeCount = 0; const chart = tooltipOrder(results).map((r) => { const latency = r.response_time ?? r.total_time ?? 0; const failed = r.status !== 'success'; const spike = !failed && latency > spikeThreshold; if (failed) pushEvent(events, r, i18n.t('monitor.siteMonitor.checkFailed', { label }), 'error', r.error_message || r.remote_ip); else if (spike) { spikeCount++; pushEvent(events, r, i18n.t('monitor.siteMonitor.latencyFluctuation', { label }), 'warn', fmtMs(latency)); } return { time: fmtTime(r.checked_at).slice(-5), latency, failed, spike }; }); return { avg, min, max, p95, jitter, spikeCount, events: events.slice(0, 8), chart }; } export function analyzePingResults(results: SiteResult[]): LatencyObsSummary { return analyzeLatencyResults(results, 'PING'); } export function analyzeDnsResults(results: SiteResult[]): LatencyObsSummary { return analyzeLatencyResults(results, i18n.t('cloud.domainResolve')); } export function analyzeTcpResults(results: SiteResult[]): ConnectivityObsSummary { const successCount = results.filter((r) => r.status === 'success').length; const failCount = results.length - successCount; const upRate = results.length > 0 ? `${(successCount / results.length * 100).toFixed(1)}%` : '--'; const events: ProbeEvent[] = []; let lastFailAt: string | undefined; const chart = tooltipOrder(results).map((r) => { const failed = r.status !== 'success'; if (failed) { lastFailAt = r.checked_at; pushEvent(events, r, i18n.t('monitor.lbl.siteConnFailed'), 'error', r.error_message || i18n.t('aiops.llmTestFailed')); } return { time: fmtTime(r.checked_at).slice(-5), up: failed ? 0 : 100, failed }; }); return { upRate, failCount, lastFailAt: lastFailAt ? fmtTime(lastFailAt) : undefined, events: events.slice(0, 8), chart }; } export function getProbeObsSummary(task: SiteTask, results: SiteResult[]) { const type = normalizeProbeType(task.type); switch (type) { case 'HTTP': return { type, http: analyzeHttpResults(results, task) } as const; case 'SSL': return { type, ssl: analyzeSslResults(results, task) } as const; case 'PING': return { type, latency: analyzePingResults(results) } as const; case 'DNS': return { type, latency: analyzeDnsResults(results) } as const; case 'TCP': return { type, tcp: analyzeTcpResults(results) } as const; default: return { type: 'HTTP' as const, http: analyzeHttpResults(results, task) }; } }