package detect import ( "context" "strings" "time" "github.com/wangjia/pangolin/server/internal/scheduler/probe" ) // NodeSignal is the normalised set of detection signals for a single node // produced by one Tick. type NodeSignal struct { NodeID string // DomesticTotalISPs is the count of distinct domestic (CN) ISPs for which // probe data was available this cycle. Vantages with no data are excluded // from both numerator and denominator per spec. DomesticTotalISPs int // DomesticFailISPs is the count of domestic ISPs judged to be failing. // An ISP is failing if ANY vantage from that ISP indicates a failure at // the highest available layer (L3 > L2 > L1; see isReportFailed). DomesticFailISPs int // OverseasHasData is true when at least one non-CN vantage reported this cycle. OverseasHasData bool // OverseasOK is true when every overseas vantage passed L1 this cycle. // Only meaningful when OverseasHasData is true. OverseasOK bool // TrafficDropPct is the percentage drop in online-connection count computed // by comparing the first half vs the second half of the 15-minute // GetLoadHistory window. Zero when insufficient history is available. TrafficDropPct float64 // TrafficBaseline is the online-connection count from the most recent load // sample. Used by the traffic-warning rule's minimum-baseline guard. TrafficBaseline int } // ProbeSnapshotter lets the Engine read probe snapshots without depending on // the concrete *probe.Store (facilitates mocking in unit tests). type ProbeSnapshotter interface { SnapshotsByNode(ctx context.Context, nodeID string) (map[string]probe.ProbeSnapshot, error) } // computeSignals builds a NodeSignal for nodeID from the current probe // snapshots and the 15-minute load history. // // ISP normalisation: third-party probes (15C) use a "3rd-" prefix on their // ISP names (e.g. "3rd-ChinaTelecom"). This prefix is stripped before // grouping so that first-party (15B) and third-party vantages from the same // ISP are merged, per spec. func computeSignals( ctx context.Context, nodeID string, snapshots map[string]probe.ProbeSnapshot, lc LifecycleService, ) NodeSignal { sig := NodeSignal{NodeID: nodeID} // domesticISPs: normalised ISP name → whether any vantage from that ISP // shows a failure this cycle. domesticISPs := make(map[string]bool) overseasTotal := 0 overseasOKCount := 0 for _, snap := range snapshots { v := snap.Vantage r := snap.Report switch { case v.Country == "CN": // Domestic vantage. Normalise ISP name and merge failures: // any failing vantage from the same ISP marks that ISP as failed. isp := normaliseISP(v.ISP) failed := isReportFailed(r) if prev, seen := domesticISPs[isp]; seen { domesticISPs[isp] = prev || failed } else { domesticISPs[isp] = failed } case v.Country != "": // Overseas vantage (any country other than CN). overseasTotal++ if r.L1.OK { overseasOKCount++ } // Empty Country: vantage identity unknown – excluded from both pools. } } sig.DomesticTotalISPs = len(domesticISPs) for _, failed := range domesticISPs { if failed { sig.DomesticFailISPs++ } } sig.OverseasHasData = overseasTotal > 0 sig.OverseasOK = overseasTotal > 0 && overseasOKCount == overseasTotal // Traffic drop: compare the older half of the 15-minute window to the // newer half. Zero is returned when there is too little history. history, err := lc.GetLoadHistory(ctx, nodeID, 15*time.Minute) if err == nil && len(history) > 0 { sig.TrafficBaseline = history[len(history)-1].Online if len(history) >= 2 { sig.TrafficDropPct = trafficDropPct(history) } } return sig } // isReportFailed determines whether a single NodeReport indicates a failure // at the highest available measurement layer. // // Priority (highest to lowest): // - L3 present → L3.OK is the authoritative verdict. // A failing L3 is recorded even when L1/L2 passed (e.g. DPI/SNI reset // at the application layer after a successful TCP/TLS handshake). // - L3 absent → check L1 then L2. func isReportFailed(r probe.NodeReport) bool { if r.L3 != nil { return !r.L3.OK } if !r.L1.OK { return true } if r.L2 != nil && !r.L2.OK { return true } return false } // normaliseISP strips the "3rd-" prefix that AliyunSyntheticAgent (15C) adds // to distinguish third-party vantages, yielding the canonical ISP name used // for grouping. func normaliseISP(isp string) string { return strings.TrimPrefix(isp, "3rd-") } // trafficDropPct computes the percentage drop between the first and second // halves of the load-history slice (oldest → newest). // Returns 0 when the baseline (first-half average) is zero or when the // second half average is higher (traffic increased). func trafficDropPct(history []LoadPoint) float64 { n := len(history) if n < 2 { return 0 } half := n / 2 older := avgOnline(history[:half]) newer := avgOnline(history[half:]) if older == 0 { return 0 } drop := older - newer if drop <= 0 { return 0 } return float64(drop) / float64(older) * 100 } func avgOnline(pts []LoadPoint) int { if len(pts) == 0 { return 0 } sum := 0 for _, p := range pts { sum += p.Online } return sum / len(pts) }