dashboard.go 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. package analytics
  2. import (
  3. "context"
  4. "fmt"
  5. "sort"
  6. "time"
  7. "github.com/0xJacky/Nginx-UI/internal/nginx_log/searcher"
  8. "github.com/uozi-tech/cosy/logger"
  9. )
  10. // GetDashboardAnalytics generates comprehensive dashboard analytics
  11. func (s *service) GetDashboardAnalytics(ctx context.Context, req *DashboardQueryRequest) (*DashboardAnalytics, error) {
  12. if req == nil {
  13. return nil, fmt.Errorf("request cannot be nil")
  14. }
  15. if err := s.ValidateTimeRange(req.StartTime, req.EndTime); err != nil {
  16. return nil, fmt.Errorf("invalid time range: %w", err)
  17. }
  18. searchReq := &searcher.SearchRequest{
  19. StartTime: &req.StartTime,
  20. EndTime: &req.EndTime,
  21. LogPaths: req.LogPaths,
  22. IncludeFacets: true,
  23. FacetFields: []string{"path_exact", "browser", "os", "device_type", "ip"},
  24. FacetSize: 10,
  25. UseCache: true,
  26. SortBy: "timestamp",
  27. SortOrder: "desc",
  28. }
  29. // Execute search
  30. result, err := s.searcher.Search(ctx, searchReq)
  31. if err != nil {
  32. return nil, fmt.Errorf("failed to search logs for dashboard: %w", err)
  33. }
  34. // --- DIAGNOSTIC LOGGING ---
  35. logger.Debugf("Dashboard search completed. Total Hits: %d, Returned Hits: %d, Facets: %d",
  36. result.TotalHits, len(result.Hits), len(result.Facets))
  37. if result.TotalHits > uint64(len(result.Hits)) {
  38. logger.Warnf("Dashboard sampling: using %d/%d documents for time calculations (%.1f%% coverage)",
  39. len(result.Hits), result.TotalHits, float64(len(result.Hits))/float64(result.TotalHits)*100)
  40. }
  41. // --- END DIAGNOSTIC LOGGING ---
  42. // Initialize analytics with empty slices
  43. analytics := &DashboardAnalytics{}
  44. // Calculate analytics if we have results
  45. if result.TotalHits > 0 {
  46. analytics.HourlyStats = s.calculateHourlyStats(result, req.StartTime, req.EndTime)
  47. analytics.DailyStats = s.calculateDailyStats(result, req.StartTime, req.EndTime)
  48. analytics.TopURLs = s.calculateTopURLs(result)
  49. analytics.Browsers = s.calculateBrowserStats(result)
  50. analytics.OperatingSystems = s.calculateOSStats(result)
  51. analytics.Devices = s.calculateDeviceStats(result)
  52. } else {
  53. // Ensure slices are initialized even if there are no hits
  54. analytics.HourlyStats = make([]HourlyAccessStats, 0)
  55. analytics.DailyStats = make([]DailyAccessStats, 0)
  56. analytics.TopURLs = make([]URLAccessStats, 0)
  57. analytics.Browsers = make([]BrowserAccessStats, 0)
  58. analytics.OperatingSystems = make([]OSAccessStats, 0)
  59. analytics.Devices = make([]DeviceAccessStats, 0)
  60. }
  61. // Calculate summary
  62. analytics.Summary = s.calculateDashboardSummary(analytics, result)
  63. return analytics, nil
  64. }
  65. // calculateHourlyStats calculates hourly access statistics.
  66. // Returns 48 hours of data centered around the end_date to support all timezones.
  67. func (s *service) calculateHourlyStats(result *searcher.SearchResult, startTime, endTime int64) []HourlyAccessStats {
  68. // Use a map with timestamp as key for easier processing
  69. hourlyMap := make(map[int64]*HourlyAccessStats)
  70. uniqueIPsPerHour := make(map[int64]map[string]bool)
  71. // Calculate 48 hours range: from UTC end_date minus 12 hours to plus 36 hours
  72. // This covers UTC-12 to UTC+14 timezones
  73. endDate := time.Unix(endTime, 0).UTC()
  74. endDateStart := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, time.UTC)
  75. // Create hourly buckets for 48 hours (12 hours before to 36 hours after the UTC date boundary)
  76. rangeStart := endDateStart.Add(-12 * time.Hour)
  77. rangeEnd := endDateStart.Add(36 * time.Hour)
  78. // Initialize hourly buckets
  79. for t := rangeStart; t.Before(rangeEnd); t = t.Add(time.Hour) {
  80. timestamp := t.Unix()
  81. hourlyMap[timestamp] = &HourlyAccessStats{
  82. Hour: t.Hour(),
  83. UV: 0,
  84. PV: 0,
  85. Timestamp: timestamp,
  86. }
  87. uniqueIPsPerHour[timestamp] = make(map[string]bool)
  88. }
  89. // Process search results - count hits within the 48-hour window
  90. for _, hit := range result.Hits {
  91. if timestampField, ok := hit.Fields["timestamp"]; ok {
  92. if timestampFloat, ok := timestampField.(float64); ok {
  93. timestamp := int64(timestampFloat)
  94. // Check if this hit falls within our 48-hour window
  95. if timestamp >= rangeStart.Unix() && timestamp < rangeEnd.Unix() {
  96. // Round down to the hour
  97. t := time.Unix(timestamp, 0).UTC()
  98. hourTimestamp := time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, time.UTC).Unix()
  99. if stats, exists := hourlyMap[hourTimestamp]; exists {
  100. stats.PV++
  101. if ipField, ok := hit.Fields["ip"]; ok {
  102. if ip, ok := ipField.(string); ok && ip != "" {
  103. if !uniqueIPsPerHour[hourTimestamp][ip] {
  104. uniqueIPsPerHour[hourTimestamp][ip] = true
  105. stats.UV++
  106. }
  107. }
  108. }
  109. }
  110. }
  111. }
  112. }
  113. }
  114. // Convert to slice and sort by timestamp
  115. var stats []HourlyAccessStats
  116. for _, stat := range hourlyMap {
  117. stats = append(stats, *stat)
  118. }
  119. sort.Slice(stats, func(i, j int) bool {
  120. return stats[i].Timestamp < stats[j].Timestamp
  121. })
  122. return stats
  123. }
  124. // calculateDailyStats calculates daily access statistics
  125. func (s *service) calculateDailyStats(result *searcher.SearchResult, startTime, endTime int64) []DailyAccessStats {
  126. dailyMap := make(map[string]*DailyAccessStats)
  127. uniqueIPsPerDay := make(map[string]map[string]bool)
  128. // Initialize daily buckets for the entire time range
  129. start := time.Unix(startTime, 0)
  130. end := time.Unix(endTime, 0)
  131. for t := start; t.Before(end) || t.Equal(end); t = t.AddDate(0, 0, 1) {
  132. dateStr := t.Format("2006-01-02")
  133. if _, exists := dailyMap[dateStr]; !exists {
  134. dailyMap[dateStr] = &DailyAccessStats{
  135. Date: dateStr,
  136. UV: 0,
  137. PV: 0,
  138. Timestamp: t.Unix(),
  139. }
  140. uniqueIPsPerDay[dateStr] = make(map[string]bool)
  141. }
  142. }
  143. // Process search results
  144. for _, hit := range result.Hits {
  145. if timestampField, ok := hit.Fields["timestamp"]; ok {
  146. if timestampFloat, ok := timestampField.(float64); ok {
  147. timestamp := int64(timestampFloat)
  148. t := time.Unix(timestamp, 0)
  149. dateStr := t.Format("2006-01-02")
  150. if stats, exists := dailyMap[dateStr]; exists {
  151. stats.PV++
  152. if ipField, ok := hit.Fields["ip"]; ok {
  153. if ip, ok := ipField.(string); ok && ip != "" {
  154. if !uniqueIPsPerDay[dateStr][ip] {
  155. uniqueIPsPerDay[dateStr][ip] = true
  156. stats.UV++
  157. }
  158. }
  159. }
  160. }
  161. }
  162. }
  163. }
  164. // Convert to slice and sort
  165. var stats []DailyAccessStats
  166. for _, stat := range dailyMap {
  167. stats = append(stats, *stat)
  168. }
  169. sort.Slice(stats, func(i, j int) bool {
  170. return stats[i].Timestamp < stats[j].Timestamp
  171. })
  172. return stats
  173. }
  174. // calculateTopURLs calculates top URL statistics from facets
  175. func (s *service) calculateTopURLs(result *searcher.SearchResult) []URLAccessStats {
  176. return calculateTopFieldStats(result.Facets["path_exact"], int(result.TotalHits), func(term string, count int, percent float64) URLAccessStats {
  177. return URLAccessStats{URL: term, Visits: count, Percent: percent}
  178. })
  179. }
  180. // calculateBrowserStats calculates browser statistics from facets
  181. func (s *service) calculateBrowserStats(result *searcher.SearchResult) []BrowserAccessStats {
  182. return calculateTopFieldStats(result.Facets["browser"], int(result.TotalHits), func(term string, count int, percent float64) BrowserAccessStats {
  183. return BrowserAccessStats{Browser: term, Count: count, Percent: percent}
  184. })
  185. }
  186. // calculateOSStats calculates operating system statistics from facets
  187. func (s *service) calculateOSStats(result *searcher.SearchResult) []OSAccessStats {
  188. return calculateTopFieldStats(result.Facets["os"], int(result.TotalHits), func(term string, count int, percent float64) OSAccessStats {
  189. return OSAccessStats{OS: term, Count: count, Percent: percent}
  190. })
  191. }
  192. // calculateDeviceStats calculates device statistics from facets
  193. func (s *service) calculateDeviceStats(result *searcher.SearchResult) []DeviceAccessStats {
  194. return calculateTopFieldStats(result.Facets["device_type"], int(result.TotalHits), func(term string, count int, percent float64) DeviceAccessStats {
  195. return DeviceAccessStats{Device: term, Count: count, Percent: percent}
  196. })
  197. }
  198. // calculateTopFieldStats is a generic function to calculate top N items from a facet result.
  199. func calculateTopFieldStats[T any](
  200. facet *searcher.Facet,
  201. totalHits int,
  202. creator func(term string, count int, percent float64) T,
  203. ) []T {
  204. if facet == nil || totalHits == 0 {
  205. return []T{}
  206. }
  207. var items []T
  208. for _, term := range facet.Terms {
  209. percent := float64(term.Count) / float64(totalHits) * 100
  210. items = append(items, creator(term.Term, term.Count, percent))
  211. }
  212. return items
  213. }
  214. // calculateDashboardSummary calculates summary statistics
  215. func (s *service) calculateDashboardSummary(analytics *DashboardAnalytics, result *searcher.SearchResult) DashboardSummary {
  216. // Calculate total UV from IP facet, which is now reliable.
  217. totalUV := 0
  218. if result.Facets != nil {
  219. if ipFacet, ok := result.Facets["ip"]; ok {
  220. // The total number of unique terms in the facet is the UV count.
  221. totalUV = ipFacet.Total
  222. }
  223. }
  224. totalPV := int(result.TotalHits)
  225. // Calculate average daily UV and PV
  226. var avgDailyUV, avgDailyPV float64
  227. if len(analytics.DailyStats) > 0 {
  228. var sumUV, sumPV int
  229. for _, daily := range analytics.DailyStats {
  230. sumUV += daily.UV
  231. sumPV += daily.PV
  232. }
  233. if len(analytics.DailyStats) > 0 {
  234. avgDailyUV = float64(sumUV) / float64(len(analytics.DailyStats))
  235. avgDailyPV = float64(sumPV) / float64(len(analytics.DailyStats))
  236. }
  237. }
  238. // Find peak hour
  239. var peakHour, peakHourTraffic int
  240. for _, hourly := range analytics.HourlyStats {
  241. if hourly.PV > peakHourTraffic {
  242. peakHour = hourly.Hour
  243. peakHourTraffic = hourly.PV
  244. }
  245. }
  246. return DashboardSummary{
  247. TotalUV: totalUV,
  248. TotalPV: totalPV,
  249. AvgDailyUV: avgDailyUV,
  250. AvgDailyPV: avgDailyPV,
  251. PeakHour: peakHour,
  252. PeakHourTraffic: peakHourTraffic,
  253. }
  254. }