ops_dashboard_handler.go 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. package admin
  2. import (
  3. "fmt"
  4. "net/http"
  5. "strconv"
  6. "strings"
  7. "time"
  8. "github.com/Wei-Shaw/sub2api/internal/pkg/response"
  9. "github.com/Wei-Shaw/sub2api/internal/service"
  10. "github.com/gin-gonic/gin"
  11. )
  12. // GetDashboardOverview returns vNext ops dashboard overview (raw path).
  13. // GET /api/v1/admin/ops/dashboard/overview
  14. func (h *OpsHandler) GetDashboardOverview(c *gin.Context) {
  15. if h.opsService == nil {
  16. response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
  17. return
  18. }
  19. if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
  20. response.ErrorFrom(c, err)
  21. return
  22. }
  23. startTime, endTime, err := parseOpsTimeRange(c, "1h")
  24. if err != nil {
  25. response.BadRequest(c, err.Error())
  26. return
  27. }
  28. filter := &service.OpsDashboardFilter{
  29. StartTime: startTime,
  30. EndTime: endTime,
  31. Platform: strings.TrimSpace(c.Query("platform")),
  32. QueryMode: parseOpsQueryMode(c),
  33. }
  34. if v := strings.TrimSpace(c.Query("group_id")); v != "" {
  35. id, err := strconv.ParseInt(v, 10, 64)
  36. if err != nil || id <= 0 {
  37. response.BadRequest(c, "Invalid group_id")
  38. return
  39. }
  40. filter.GroupID = &id
  41. }
  42. data, err := h.opsService.GetDashboardOverview(c.Request.Context(), filter)
  43. if err != nil {
  44. response.ErrorFrom(c, err)
  45. return
  46. }
  47. response.Success(c, data)
  48. }
  49. // GetDashboardThroughputTrend returns throughput time series (raw path).
  50. // GET /api/v1/admin/ops/dashboard/throughput-trend
  51. func (h *OpsHandler) GetDashboardThroughputTrend(c *gin.Context) {
  52. if h.opsService == nil {
  53. response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
  54. return
  55. }
  56. if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
  57. response.ErrorFrom(c, err)
  58. return
  59. }
  60. startTime, endTime, err := parseOpsTimeRange(c, "1h")
  61. if err != nil {
  62. response.BadRequest(c, err.Error())
  63. return
  64. }
  65. filter := &service.OpsDashboardFilter{
  66. StartTime: startTime,
  67. EndTime: endTime,
  68. Platform: strings.TrimSpace(c.Query("platform")),
  69. QueryMode: parseOpsQueryMode(c),
  70. }
  71. if v := strings.TrimSpace(c.Query("group_id")); v != "" {
  72. id, err := strconv.ParseInt(v, 10, 64)
  73. if err != nil || id <= 0 {
  74. response.BadRequest(c, "Invalid group_id")
  75. return
  76. }
  77. filter.GroupID = &id
  78. }
  79. bucketSeconds := pickThroughputBucketSeconds(endTime.Sub(startTime))
  80. data, err := h.opsService.GetThroughputTrend(c.Request.Context(), filter, bucketSeconds)
  81. if err != nil {
  82. response.ErrorFrom(c, err)
  83. return
  84. }
  85. response.Success(c, data)
  86. }
  87. // GetDashboardLatencyHistogram returns the latency distribution histogram (success requests).
  88. // GET /api/v1/admin/ops/dashboard/latency-histogram
  89. func (h *OpsHandler) GetDashboardLatencyHistogram(c *gin.Context) {
  90. if h.opsService == nil {
  91. response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
  92. return
  93. }
  94. if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
  95. response.ErrorFrom(c, err)
  96. return
  97. }
  98. startTime, endTime, err := parseOpsTimeRange(c, "1h")
  99. if err != nil {
  100. response.BadRequest(c, err.Error())
  101. return
  102. }
  103. filter := &service.OpsDashboardFilter{
  104. StartTime: startTime,
  105. EndTime: endTime,
  106. Platform: strings.TrimSpace(c.Query("platform")),
  107. QueryMode: parseOpsQueryMode(c),
  108. }
  109. if v := strings.TrimSpace(c.Query("group_id")); v != "" {
  110. id, err := strconv.ParseInt(v, 10, 64)
  111. if err != nil || id <= 0 {
  112. response.BadRequest(c, "Invalid group_id")
  113. return
  114. }
  115. filter.GroupID = &id
  116. }
  117. data, err := h.opsService.GetLatencyHistogram(c.Request.Context(), filter)
  118. if err != nil {
  119. response.ErrorFrom(c, err)
  120. return
  121. }
  122. response.Success(c, data)
  123. }
  124. // GetDashboardErrorTrend returns error counts time series (raw path).
  125. // GET /api/v1/admin/ops/dashboard/error-trend
  126. func (h *OpsHandler) GetDashboardErrorTrend(c *gin.Context) {
  127. if h.opsService == nil {
  128. response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
  129. return
  130. }
  131. if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
  132. response.ErrorFrom(c, err)
  133. return
  134. }
  135. startTime, endTime, err := parseOpsTimeRange(c, "1h")
  136. if err != nil {
  137. response.BadRequest(c, err.Error())
  138. return
  139. }
  140. filter := &service.OpsDashboardFilter{
  141. StartTime: startTime,
  142. EndTime: endTime,
  143. Platform: strings.TrimSpace(c.Query("platform")),
  144. QueryMode: parseOpsQueryMode(c),
  145. }
  146. if v := strings.TrimSpace(c.Query("group_id")); v != "" {
  147. id, err := strconv.ParseInt(v, 10, 64)
  148. if err != nil || id <= 0 {
  149. response.BadRequest(c, "Invalid group_id")
  150. return
  151. }
  152. filter.GroupID = &id
  153. }
  154. bucketSeconds := pickThroughputBucketSeconds(endTime.Sub(startTime))
  155. data, err := h.opsService.GetErrorTrend(c.Request.Context(), filter, bucketSeconds)
  156. if err != nil {
  157. response.ErrorFrom(c, err)
  158. return
  159. }
  160. response.Success(c, data)
  161. }
  162. // GetDashboardErrorDistribution returns error distribution by status code (raw path).
  163. // GET /api/v1/admin/ops/dashboard/error-distribution
  164. func (h *OpsHandler) GetDashboardErrorDistribution(c *gin.Context) {
  165. if h.opsService == nil {
  166. response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
  167. return
  168. }
  169. if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
  170. response.ErrorFrom(c, err)
  171. return
  172. }
  173. startTime, endTime, err := parseOpsTimeRange(c, "1h")
  174. if err != nil {
  175. response.BadRequest(c, err.Error())
  176. return
  177. }
  178. filter := &service.OpsDashboardFilter{
  179. StartTime: startTime,
  180. EndTime: endTime,
  181. Platform: strings.TrimSpace(c.Query("platform")),
  182. QueryMode: parseOpsQueryMode(c),
  183. }
  184. if v := strings.TrimSpace(c.Query("group_id")); v != "" {
  185. id, err := strconv.ParseInt(v, 10, 64)
  186. if err != nil || id <= 0 {
  187. response.BadRequest(c, "Invalid group_id")
  188. return
  189. }
  190. filter.GroupID = &id
  191. }
  192. data, err := h.opsService.GetErrorDistribution(c.Request.Context(), filter)
  193. if err != nil {
  194. response.ErrorFrom(c, err)
  195. return
  196. }
  197. response.Success(c, data)
  198. }
  199. // GetDashboardOpenAITokenStats returns OpenAI token efficiency stats grouped by model.
  200. // GET /api/v1/admin/ops/dashboard/openai-token-stats
  201. func (h *OpsHandler) GetDashboardOpenAITokenStats(c *gin.Context) {
  202. if h.opsService == nil {
  203. response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
  204. return
  205. }
  206. if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
  207. response.ErrorFrom(c, err)
  208. return
  209. }
  210. filter, err := parseOpsOpenAITokenStatsFilter(c)
  211. if err != nil {
  212. response.BadRequest(c, err.Error())
  213. return
  214. }
  215. data, err := h.opsService.GetOpenAITokenStats(c.Request.Context(), filter)
  216. if err != nil {
  217. response.ErrorFrom(c, err)
  218. return
  219. }
  220. response.Success(c, data)
  221. }
  222. func parseOpsOpenAITokenStatsFilter(c *gin.Context) (*service.OpsOpenAITokenStatsFilter, error) {
  223. if c == nil {
  224. return nil, fmt.Errorf("invalid request")
  225. }
  226. timeRange := strings.TrimSpace(c.Query("time_range"))
  227. if timeRange == "" {
  228. timeRange = "30d"
  229. }
  230. dur, ok := parseOpsOpenAITokenStatsDuration(timeRange)
  231. if !ok {
  232. return nil, fmt.Errorf("invalid time_range")
  233. }
  234. end := time.Now().UTC()
  235. start := end.Add(-dur)
  236. filter := &service.OpsOpenAITokenStatsFilter{
  237. TimeRange: timeRange,
  238. StartTime: start,
  239. EndTime: end,
  240. Platform: strings.TrimSpace(c.Query("platform")),
  241. }
  242. if v := strings.TrimSpace(c.Query("group_id")); v != "" {
  243. id, err := strconv.ParseInt(v, 10, 64)
  244. if err != nil || id <= 0 {
  245. return nil, fmt.Errorf("invalid group_id")
  246. }
  247. filter.GroupID = &id
  248. }
  249. topNRaw := strings.TrimSpace(c.Query("top_n"))
  250. pageRaw := strings.TrimSpace(c.Query("page"))
  251. pageSizeRaw := strings.TrimSpace(c.Query("page_size"))
  252. if topNRaw != "" && (pageRaw != "" || pageSizeRaw != "") {
  253. return nil, fmt.Errorf("invalid query: top_n cannot be used with page/page_size")
  254. }
  255. if topNRaw != "" {
  256. topN, err := strconv.Atoi(topNRaw)
  257. if err != nil || topN < 1 || topN > 100 {
  258. return nil, fmt.Errorf("invalid top_n")
  259. }
  260. filter.TopN = topN
  261. return filter, nil
  262. }
  263. filter.Page = 1
  264. filter.PageSize = 20
  265. if pageRaw != "" {
  266. page, err := strconv.Atoi(pageRaw)
  267. if err != nil || page < 1 {
  268. return nil, fmt.Errorf("invalid page")
  269. }
  270. filter.Page = page
  271. }
  272. if pageSizeRaw != "" {
  273. pageSize, err := strconv.Atoi(pageSizeRaw)
  274. if err != nil || pageSize < 1 || pageSize > 100 {
  275. return nil, fmt.Errorf("invalid page_size")
  276. }
  277. filter.PageSize = pageSize
  278. }
  279. return filter, nil
  280. }
  281. func parseOpsOpenAITokenStatsDuration(v string) (time.Duration, bool) {
  282. switch strings.TrimSpace(v) {
  283. case "30m":
  284. return 30 * time.Minute, true
  285. case "1h":
  286. return time.Hour, true
  287. case "1d":
  288. return 24 * time.Hour, true
  289. case "15d":
  290. return 15 * 24 * time.Hour, true
  291. case "30d":
  292. return 30 * 24 * time.Hour, true
  293. default:
  294. return 0, false
  295. }
  296. }
  297. func pickThroughputBucketSeconds(window time.Duration) int {
  298. // Keep buckets predictable and avoid huge responses.
  299. switch {
  300. case window <= 2*time.Hour:
  301. return 60
  302. case window <= 24*time.Hour:
  303. return 300
  304. default:
  305. return 3600
  306. }
  307. }
  308. func parseOpsQueryMode(c *gin.Context) service.OpsQueryMode {
  309. if c == nil {
  310. return ""
  311. }
  312. raw := strings.TrimSpace(c.Query("mode"))
  313. if raw == "" {
  314. // Empty means "use server default" (DB setting ops_query_mode_default).
  315. return ""
  316. }
  317. return service.ParseOpsQueryMode(raw)
  318. }