package latencylog import ( "bufio" "fmt" "html/template" "os" "path/filepath" "strings" "omnisocketgo/cmd/internal/protocol" ) const summaryChartHTMLTemplate = ` Latency Summary Chart

Latency Summary

A simple per-message end-to-end latency chart generated from summarized JSONL records.

Messages
{{.TotalMessages}}
With End-To-End
{{.MessagesWithEndToEnd}}
Average End-To-End
{{.AverageEndToEnd}}
Max End-To-End
{{.MaxEndToEnd}}
{{range .Legend}} {{.Label}} {{end}}
{{if .Rows}}
{{range .Rows}}

{{.Title}}

{{.EndToEnd}}
{{.Subtitle}}
{{.ApproxRTT}}
{{if .RatioMetrics}}
{{range .RatioMetrics}} {{.Label}} {{.Value}} {{end}}
{{end}}
{{range .Segments}}
{{end}}
{{if .Segments}}
{{range .Segments}} {{.Label}} {{.Value}} {{end}}
{{end}} {{if .MissingTimestamps}}
Missing timestamps: {{.MissingTimestamps}}
{{end}}
{{end}}
{{else}}
No summarized messages were available for chart rendering.
{{end}}
` type summaryChartPage struct { TotalMessages int MessagesWithEndToEnd int AverageEndToEnd string MaxEndToEnd string Legend []summaryChartLegendItem Rows []summaryChartRow } type summaryChartLegendItem struct { Label string Color string } type summaryChartRow struct { Title string Subtitle string EndToEnd string ApproxRTT string MissingTimestamps string RatioMetrics []summaryChartValue Segments []summaryChartSegment } type summaryChartSegment struct { Label string Value string Color string WidthPercent float64 } type summaryChartValue struct { Label string Value string } type summaryChartSegmentMetric struct { label string value *int64 color string } // WriteSummariesHTMLChart 将整理结果写成一个可直接在浏览器中打开的简单 HTML 图表。 func WriteSummariesHTMLChart(path string, summaries []Summary) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return fmt.Errorf("latencylog: create chart dir for %s: %w", path, err) } file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) if err != nil { return fmt.Errorf("latencylog: open chart file %s: %w", path, err) } defer file.Close() page := buildSummaryChartPage(summaries) tmpl, err := template.New("summary-chart").Parse(summaryChartHTMLTemplate) if err != nil { return fmt.Errorf("latencylog: parse chart template: %w", err) } writer := bufio.NewWriter(file) if err := tmpl.Execute(writer, page); err != nil { return fmt.Errorf("latencylog: render chart %s: %w", path, err) } if err := writer.Flush(); err != nil { return fmt.Errorf("latencylog: flush chart %s: %w", path, err) } return nil } func buildSummaryChartPage(summaries []Summary) summaryChartPage { page := summaryChartPage{ TotalMessages: len(summaries), Legend: []summaryChartLegendItem{ {Label: "A processing", Color: "var(--a-proc)"}, {Label: "A queue", Color: "var(--a-queue)"}, {Label: "A-B transport + propagation", Color: "var(--transport)"}, {Label: "B processing", Color: "var(--b-proc)"}, {Label: "Unknown / missing", Color: "var(--unknown)"}, }, Rows: make([]summaryChartRow, 0, len(summaries)), } var ( endToEndValues []int64 totalEndToEnd int64 maxEndToEnd int64 ) for _, summary := range summaries { page.Rows = append(page.Rows, buildSummaryChartRow(summary)) if summary.EndToEndLatencyNS == nil { continue } endToEnd := *summary.EndToEndLatencyNS endToEndValues = append(endToEndValues, endToEnd) totalEndToEnd += endToEnd if endToEnd > maxEndToEnd { maxEndToEnd = endToEnd } } page.MessagesWithEndToEnd = len(endToEndValues) page.AverageEndToEnd = "n/a" page.MaxEndToEnd = "n/a" if len(endToEndValues) > 0 { page.AverageEndToEnd = formatLatencyNS(totalEndToEnd / int64(len(endToEndValues))) page.MaxEndToEnd = formatLatencyNS(maxEndToEnd) } return page } func buildSummaryChartRow(summary Summary) summaryChartRow { row := summaryChartRow{ Title: buildSummaryChartTitle(summary), Subtitle: buildSummaryChartSubtitle(summary), EndToEnd: "End-to-end: n/a", ApproxRTT: "Approx RTT: n/a", MissingTimestamps: strings.Join(summary.MissingTimestamps, ", "), } if summary.ApproxRTTNS != nil && *summary.ApproxRTTNS > 0 { row.ApproxRTT = fmt.Sprintf("Approx RTT: %s", formatLatencyNS(*summary.ApproxRTTNS)) } ratioMetrics := []struct { label string value *float64 }{ {label: "A processing bitrate", value: summary.AProcessingBitrateBPS}, {label: "A-B transport + propagation bitrate", value: summary.ABTransportPropagationBitrateBPS}, {label: "End-to-end bitrate", value: summary.EndToEndBitrateBPS}, } for _, metric := range ratioMetrics { if metric.value == nil || *metric.value <= 0 { continue } row.RatioMetrics = append(row.RatioMetrics, summaryChartValue{ Label: metric.label, Value: formatBitrateBPS(*metric.value), }) } if summary.EndToEndLatencyNS == nil || *summary.EndToEndLatencyNS <= 0 { return row } total := *summary.EndToEndLatencyNS row.EndToEnd = fmt.Sprintf("End-to-end: %s", formatLatencyNS(total)) metrics := []summaryChartSegmentMetric{ {label: "A processing", value: summary.AProcessingLatencyNS, color: "var(--a-proc)"}, {label: "A queue", value: summary.AQueueLatencyNS, color: "var(--a-queue)"}, {label: "A-B transport + propagation", value: summary.ABTransportPropagationNS, color: "var(--transport)"}, {label: "B processing", value: summary.BProcessingLatencyNS, color: "var(--b-proc)"}, } var knownTotal int64 for _, metric := range metrics { if metric.value == nil || *metric.value <= 0 { continue } knownTotal += *metric.value } scaleTotal := total if knownTotal > scaleTotal { scaleTotal = knownTotal } if scaleTotal <= 0 { return row } for _, metric := range metrics { if metric.value == nil || *metric.value <= 0 { continue } row.Segments = append(row.Segments, summaryChartSegment{ Label: metric.label, Value: formatLatencyNS(*metric.value), Color: metric.color, WidthPercent: float64(*metric.value) * 100 / float64(scaleTotal), }) } if remaining := total - knownTotal; remaining > 0 { row.Segments = append(row.Segments, summaryChartSegment{ Label: "Unknown / missing", Value: formatLatencyNS(remaining), Color: "var(--unknown)", WidthPercent: float64(remaining) * 100 / float64(scaleTotal), }) } return row } func buildSummaryChartTitle(summary Summary) string { if summary.MessageType == protocol.MessageTypeFile && summary.FileName != "" { return fmt.Sprintf("%s #%d (%s)", summary.MessageType, summary.MessageID, summary.FileName) } return fmt.Sprintf("%s #%d", summary.MessageType, summary.MessageID) } func buildSummaryChartSubtitle(summary Summary) string { parts := []string{ fmt.Sprintf("%s -> %s", summary.From, summary.To), fmt.Sprintf("%d bytes", summary.BodySize), } if summary.MessageType == protocol.MessageTypeFile && summary.FileName != "" { parts = append(parts, fmt.Sprintf("file: %s", summary.FileName)) } return strings.Join(parts, " | ") } func formatLatencyNS(ns int64) string { return fmt.Sprintf("%.3f ms", float64(ns)/1_000_000) } func formatBitrateBPS(bitsPerSecond float64) string { return fmt.Sprintf("%.3f Mb/s", bitsPerSecond/1_000_000) }