ops_handler.go 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776
  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/server/middleware"
  10. "github.com/Wei-Shaw/sub2api/internal/service"
  11. "github.com/gin-gonic/gin"
  12. )
  13. type OpsHandler struct {
  14. opsService *service.OpsService
  15. }
  16. // GetErrorLogByID returns ops error log detail.
  17. // GET /api/v1/admin/ops/errors/:id
  18. func (h *OpsHandler) GetErrorLogByID(c *gin.Context) {
  19. if h.opsService == nil {
  20. response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
  21. return
  22. }
  23. if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
  24. response.ErrorFrom(c, err)
  25. return
  26. }
  27. idStr := strings.TrimSpace(c.Param("id"))
  28. id, err := strconv.ParseInt(idStr, 10, 64)
  29. if err != nil || id <= 0 {
  30. response.BadRequest(c, "Invalid error id")
  31. return
  32. }
  33. detail, err := h.opsService.GetErrorLogByID(c.Request.Context(), id)
  34. if err != nil {
  35. response.ErrorFrom(c, err)
  36. return
  37. }
  38. response.Success(c, detail)
  39. }
  40. const (
  41. opsListViewErrors = "errors"
  42. opsListViewExcluded = "excluded"
  43. opsListViewAll = "all"
  44. )
  45. func parseOpsViewParam(c *gin.Context) string {
  46. if c == nil {
  47. return ""
  48. }
  49. v := strings.ToLower(strings.TrimSpace(c.Query("view")))
  50. switch v {
  51. case "", opsListViewErrors:
  52. return opsListViewErrors
  53. case opsListViewExcluded:
  54. return opsListViewExcluded
  55. case opsListViewAll:
  56. return opsListViewAll
  57. default:
  58. return opsListViewErrors
  59. }
  60. }
  61. func NewOpsHandler(opsService *service.OpsService) *OpsHandler {
  62. return &OpsHandler{opsService: opsService}
  63. }
  64. // GetErrorLogs lists ops error logs.
  65. // applyOpsErrorSortParams reads sort_by/sort_order query params into the filter.
  66. // Column whitelist and order normalization live in the repository; unknown
  67. // values degrade to the default (created_at DESC), mirroring the usage list.
  68. func applyOpsErrorSortParams(c *gin.Context, filter *service.OpsErrorLogFilter) {
  69. filter.SetSort(c.Query("sort_by"), c.Query("sort_order"))
  70. }
  71. // GET /api/v1/admin/ops/errors
  72. func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
  73. if h.opsService == nil {
  74. response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
  75. return
  76. }
  77. if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
  78. response.ErrorFrom(c, err)
  79. return
  80. }
  81. page, pageSize := response.ParsePagination(c)
  82. // Ops list can be larger than standard admin tables.
  83. if pageSize > 500 {
  84. pageSize = 500
  85. }
  86. startTime, endTime, err := parseOpsTimeRange(c, "1h")
  87. if err != nil {
  88. response.BadRequest(c, err.Error())
  89. return
  90. }
  91. filter := &service.OpsErrorLogFilter{Page: page, PageSize: pageSize}
  92. if !startTime.IsZero() {
  93. filter.StartTime = &startTime
  94. }
  95. if !endTime.IsZero() {
  96. filter.EndTime = &endTime
  97. }
  98. filter.View = parseOpsViewParam(c)
  99. filter.Phase = strings.TrimSpace(c.Query("phase"))
  100. filter.Owner = strings.TrimSpace(c.Query("error_owner"))
  101. filter.Source = strings.TrimSpace(c.Query("error_source"))
  102. filter.Query = strings.TrimSpace(c.Query("q"))
  103. filter.UserQuery = strings.TrimSpace(c.Query("user_query"))
  104. // Model 过滤:admin 走精确匹配(ModelFuzzy 默认 false,保持管理端语义)。
  105. // buildOpsErrorLogsWhere 以 COALESCE(requested_model, model) 比对。
  106. filter.Model = strings.TrimSpace(c.Query("model"))
  107. // 请求错误语义:client-visible status>=400 守卫恒生效(未设
  108. // IncludeRecoveredUpstream 时 phase=upstream 不再绕过守卫),故
  109. // phase=upstream 作为普通过滤条件保留——此前这里清空该值,导致
  110. // 错误类型下拉选「上游」等于不过滤。
  111. // 分类(用户侧粗分类码)→ phase/type ANY 条件,与用户端 /usage/errors 同一映射;
  112. // 未知分类返回空切片 = 不过滤。与 phase 参数可同时设置(AND 语义)。
  113. if cat := strings.TrimSpace(c.Query("category")); cat != "" {
  114. phases, types := service.CategoryToFilter(cat)
  115. filter.ErrorPhasesAny = phases
  116. filter.ErrorTypesAny = types
  117. }
  118. if platform := strings.TrimSpace(c.Query("platform")); platform != "" {
  119. filter.Platform = platform
  120. }
  121. if v := strings.TrimSpace(c.Query("group_id")); v != "" {
  122. id, err := strconv.ParseInt(v, 10, 64)
  123. if err != nil || id <= 0 {
  124. response.BadRequest(c, "Invalid group_id")
  125. return
  126. }
  127. filter.GroupID = &id
  128. }
  129. if v := strings.TrimSpace(c.Query("account_id")); v != "" {
  130. id, err := strconv.ParseInt(v, 10, 64)
  131. if err != nil || id <= 0 {
  132. response.BadRequest(c, "Invalid account_id")
  133. return
  134. }
  135. filter.AccountID = &id
  136. }
  137. if v := strings.TrimSpace(c.Query("user_id")); v != "" {
  138. id, err := strconv.ParseInt(v, 10, 64)
  139. if err != nil || id <= 0 {
  140. response.BadRequest(c, "Invalid user_id")
  141. return
  142. }
  143. filter.UserID = &id
  144. }
  145. if v := strings.TrimSpace(c.Query("api_key_id")); v != "" {
  146. id, err := strconv.ParseInt(v, 10, 64)
  147. if err != nil || id <= 0 {
  148. response.BadRequest(c, "Invalid api_key_id")
  149. return
  150. }
  151. filter.APIKeyID = &id
  152. }
  153. if v := strings.TrimSpace(c.Query("resolved")); v != "" {
  154. switch strings.ToLower(v) {
  155. case "1", "true", "yes":
  156. b := true
  157. filter.Resolved = &b
  158. case "0", "false", "no":
  159. b := false
  160. filter.Resolved = &b
  161. default:
  162. response.BadRequest(c, "Invalid resolved")
  163. return
  164. }
  165. }
  166. if statusCodesStr := strings.TrimSpace(c.Query("status_codes")); statusCodesStr != "" {
  167. parts := strings.Split(statusCodesStr, ",")
  168. out := make([]int, 0, len(parts))
  169. for _, part := range parts {
  170. p := strings.TrimSpace(part)
  171. if p == "" {
  172. continue
  173. }
  174. n, err := strconv.Atoi(p)
  175. if err != nil || n < 0 {
  176. response.BadRequest(c, "Invalid status_codes")
  177. return
  178. }
  179. out = append(out, n)
  180. }
  181. filter.StatusCodes = out
  182. }
  183. applyOpsErrorSortParams(c, filter)
  184. result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
  185. if err != nil {
  186. response.ErrorFrom(c, err)
  187. return
  188. }
  189. response.Paginated(c, result.Errors, int64(result.Total), result.Page, result.PageSize)
  190. }
  191. // ListRequestErrors lists client-visible request errors.
  192. // GET /api/v1/admin/ops/request-errors
  193. func (h *OpsHandler) ListRequestErrors(c *gin.Context) {
  194. if h.opsService == nil {
  195. response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
  196. return
  197. }
  198. if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
  199. response.ErrorFrom(c, err)
  200. return
  201. }
  202. page, pageSize := response.ParsePagination(c)
  203. if pageSize > 500 {
  204. pageSize = 500
  205. }
  206. startTime, endTime, err := parseOpsTimeRange(c, "1h")
  207. if err != nil {
  208. response.BadRequest(c, err.Error())
  209. return
  210. }
  211. filter := &service.OpsErrorLogFilter{Page: page, PageSize: pageSize}
  212. if !startTime.IsZero() {
  213. filter.StartTime = &startTime
  214. }
  215. if !endTime.IsZero() {
  216. filter.EndTime = &endTime
  217. }
  218. filter.View = parseOpsViewParam(c)
  219. filter.Phase = strings.TrimSpace(c.Query("phase"))
  220. filter.Owner = strings.TrimSpace(c.Query("error_owner"))
  221. filter.Source = strings.TrimSpace(c.Query("error_source"))
  222. filter.Query = strings.TrimSpace(c.Query("q"))
  223. filter.UserQuery = strings.TrimSpace(c.Query("user_query"))
  224. // Model 过滤:admin 走精确匹配(ModelFuzzy 默认 false,保持管理端语义)。
  225. // buildOpsErrorLogsWhere 以 COALESCE(requested_model, model) 比对。
  226. filter.Model = strings.TrimSpace(c.Query("model"))
  227. // 请求错误语义:client-visible status>=400 守卫恒生效(未设
  228. // IncludeRecoveredUpstream 时 phase=upstream 不再绕过守卫),故
  229. // phase=upstream 作为普通过滤条件保留——此前这里清空该值,导致
  230. // 错误类型下拉选「上游」等于不过滤。
  231. // 分类(用户侧粗分类码)→ phase/type ANY 条件,与用户端 /usage/errors 同一映射;
  232. // 未知分类返回空切片 = 不过滤。与 phase 参数可同时设置(AND 语义)。
  233. if cat := strings.TrimSpace(c.Query("category")); cat != "" {
  234. phases, types := service.CategoryToFilter(cat)
  235. filter.ErrorPhasesAny = phases
  236. filter.ErrorTypesAny = types
  237. }
  238. if platform := strings.TrimSpace(c.Query("platform")); platform != "" {
  239. filter.Platform = platform
  240. }
  241. if v := strings.TrimSpace(c.Query("group_id")); v != "" {
  242. id, err := strconv.ParseInt(v, 10, 64)
  243. if err != nil || id <= 0 {
  244. response.BadRequest(c, "Invalid group_id")
  245. return
  246. }
  247. filter.GroupID = &id
  248. }
  249. if v := strings.TrimSpace(c.Query("account_id")); v != "" {
  250. id, err := strconv.ParseInt(v, 10, 64)
  251. if err != nil || id <= 0 {
  252. response.BadRequest(c, "Invalid account_id")
  253. return
  254. }
  255. filter.AccountID = &id
  256. }
  257. if v := strings.TrimSpace(c.Query("resolved")); v != "" {
  258. switch strings.ToLower(v) {
  259. case "1", "true", "yes":
  260. b := true
  261. filter.Resolved = &b
  262. case "0", "false", "no":
  263. b := false
  264. filter.Resolved = &b
  265. default:
  266. response.BadRequest(c, "Invalid resolved")
  267. return
  268. }
  269. }
  270. if statusCodesStr := strings.TrimSpace(c.Query("status_codes")); statusCodesStr != "" {
  271. parts := strings.Split(statusCodesStr, ",")
  272. out := make([]int, 0, len(parts))
  273. for _, part := range parts {
  274. p := strings.TrimSpace(part)
  275. if p == "" {
  276. continue
  277. }
  278. n, err := strconv.Atoi(p)
  279. if err != nil || n < 0 {
  280. response.BadRequest(c, "Invalid status_codes")
  281. return
  282. }
  283. out = append(out, n)
  284. }
  285. filter.StatusCodes = out
  286. }
  287. applyOpsErrorSortParams(c, filter)
  288. result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
  289. if err != nil {
  290. response.ErrorFrom(c, err)
  291. return
  292. }
  293. response.Paginated(c, result.Errors, int64(result.Total), result.Page, result.PageSize)
  294. }
  295. // GetRequestError returns request error detail.
  296. // GET /api/v1/admin/ops/request-errors/:id
  297. func (h *OpsHandler) GetRequestError(c *gin.Context) {
  298. // same storage; just proxy to existing detail
  299. h.GetErrorLogByID(c)
  300. }
  301. // ListRequestErrorUpstreamErrors lists upstream error logs correlated to a request error.
  302. // GET /api/v1/admin/ops/request-errors/:id/upstream-errors
  303. func (h *OpsHandler) ListRequestErrorUpstreamErrors(c *gin.Context) {
  304. if h.opsService == nil {
  305. response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
  306. return
  307. }
  308. if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
  309. response.ErrorFrom(c, err)
  310. return
  311. }
  312. idStr := strings.TrimSpace(c.Param("id"))
  313. id, err := strconv.ParseInt(idStr, 10, 64)
  314. if err != nil || id <= 0 {
  315. response.BadRequest(c, "Invalid error id")
  316. return
  317. }
  318. // Load request error to get correlation keys.
  319. detail, err := h.opsService.GetErrorLogByID(c.Request.Context(), id)
  320. if err != nil {
  321. response.ErrorFrom(c, err)
  322. return
  323. }
  324. // Correlate by request_id/client_request_id.
  325. requestID := strings.TrimSpace(detail.RequestID)
  326. clientRequestID := strings.TrimSpace(detail.ClientRequestID)
  327. if requestID == "" && clientRequestID == "" {
  328. response.Paginated(c, []*service.OpsErrorLog{}, 0, 1, 10)
  329. return
  330. }
  331. page, pageSize := response.ParsePagination(c)
  332. if pageSize > 500 {
  333. pageSize = 500
  334. }
  335. // Keep correlation window wide enough so linked upstream errors
  336. // are discoverable even when UI defaults to 1h elsewhere.
  337. startTime, endTime, err := parseOpsTimeRange(c, "30d")
  338. if err != nil {
  339. response.BadRequest(c, err.Error())
  340. return
  341. }
  342. filter := &service.OpsErrorLogFilter{Page: page, PageSize: pageSize}
  343. if !startTime.IsZero() {
  344. filter.StartTime = &startTime
  345. }
  346. if !endTime.IsZero() {
  347. filter.EndTime = &endTime
  348. }
  349. filter.View = "all"
  350. filter.ErrorPhasesAny = []string{"upstream", "account_auth"}
  351. // Provider-health list includes recovered inference and credential rows.
  352. filter.IncludeRecoveredUpstream = true
  353. filter.Owner = "provider"
  354. filter.Source = strings.TrimSpace(c.Query("error_source"))
  355. filter.Query = strings.TrimSpace(c.Query("q"))
  356. if platform := strings.TrimSpace(c.Query("platform")); platform != "" {
  357. filter.Platform = platform
  358. }
  359. // Prefer exact match on request_id; if missing, fall back to client_request_id.
  360. if requestID != "" {
  361. filter.RequestID = requestID
  362. } else {
  363. filter.ClientRequestID = clientRequestID
  364. }
  365. applyOpsErrorSortParams(c, filter)
  366. result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
  367. if err != nil {
  368. response.ErrorFrom(c, err)
  369. return
  370. }
  371. // If client asks for details, expand each upstream error log to include upstream response fields.
  372. includeDetail := strings.TrimSpace(c.Query("include_detail"))
  373. if includeDetail == "1" || strings.EqualFold(includeDetail, "true") || strings.EqualFold(includeDetail, "yes") {
  374. details := make([]*service.OpsErrorLogDetail, 0, len(result.Errors))
  375. for _, item := range result.Errors {
  376. if item == nil {
  377. continue
  378. }
  379. d, err := h.opsService.GetErrorLogByID(c.Request.Context(), item.ID)
  380. if err != nil || d == nil {
  381. continue
  382. }
  383. details = append(details, d)
  384. }
  385. response.Paginated(c, details, int64(result.Total), result.Page, result.PageSize)
  386. return
  387. }
  388. response.Paginated(c, result.Errors, int64(result.Total), result.Page, result.PageSize)
  389. }
  390. // ResolveRequestError toggles resolved status.
  391. // PUT /api/v1/admin/ops/request-errors/:id/resolve
  392. func (h *OpsHandler) ResolveRequestError(c *gin.Context) {
  393. h.UpdateErrorResolution(c)
  394. }
  395. // ListUpstreamErrors lists independent upstream errors.
  396. // GET /api/v1/admin/ops/upstream-errors
  397. func (h *OpsHandler) ListUpstreamErrors(c *gin.Context) {
  398. if h.opsService == nil {
  399. response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
  400. return
  401. }
  402. if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
  403. response.ErrorFrom(c, err)
  404. return
  405. }
  406. page, pageSize := response.ParsePagination(c)
  407. if pageSize > 500 {
  408. pageSize = 500
  409. }
  410. startTime, endTime, err := parseOpsTimeRange(c, "1h")
  411. if err != nil {
  412. response.BadRequest(c, err.Error())
  413. return
  414. }
  415. filter := &service.OpsErrorLogFilter{Page: page, PageSize: pageSize}
  416. if !startTime.IsZero() {
  417. filter.StartTime = &startTime
  418. }
  419. if !endTime.IsZero() {
  420. filter.EndTime = &endTime
  421. }
  422. filter.View = parseOpsViewParam(c)
  423. filter.ErrorPhasesAny = []string{"upstream", "account_auth"}
  424. // Provider-health list includes recovered inference and credential rows.
  425. filter.IncludeRecoveredUpstream = true
  426. filter.Owner = "provider"
  427. filter.Source = strings.TrimSpace(c.Query("error_source"))
  428. filter.Query = strings.TrimSpace(c.Query("q"))
  429. if platform := strings.TrimSpace(c.Query("platform")); platform != "" {
  430. filter.Platform = platform
  431. }
  432. if v := strings.TrimSpace(c.Query("group_id")); v != "" {
  433. id, err := strconv.ParseInt(v, 10, 64)
  434. if err != nil || id <= 0 {
  435. response.BadRequest(c, "Invalid group_id")
  436. return
  437. }
  438. filter.GroupID = &id
  439. }
  440. if v := strings.TrimSpace(c.Query("account_id")); v != "" {
  441. id, err := strconv.ParseInt(v, 10, 64)
  442. if err != nil || id <= 0 {
  443. response.BadRequest(c, "Invalid account_id")
  444. return
  445. }
  446. filter.AccountID = &id
  447. }
  448. if v := strings.TrimSpace(c.Query("resolved")); v != "" {
  449. switch strings.ToLower(v) {
  450. case "1", "true", "yes":
  451. b := true
  452. filter.Resolved = &b
  453. case "0", "false", "no":
  454. b := false
  455. filter.Resolved = &b
  456. default:
  457. response.BadRequest(c, "Invalid resolved")
  458. return
  459. }
  460. }
  461. if statusCodesStr := strings.TrimSpace(c.Query("status_codes")); statusCodesStr != "" {
  462. parts := strings.Split(statusCodesStr, ",")
  463. out := make([]int, 0, len(parts))
  464. for _, part := range parts {
  465. p := strings.TrimSpace(part)
  466. if p == "" {
  467. continue
  468. }
  469. n, err := strconv.Atoi(p)
  470. if err != nil || n < 0 {
  471. response.BadRequest(c, "Invalid status_codes")
  472. return
  473. }
  474. out = append(out, n)
  475. }
  476. filter.StatusCodes = out
  477. }
  478. applyOpsErrorSortParams(c, filter)
  479. result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
  480. if err != nil {
  481. response.ErrorFrom(c, err)
  482. return
  483. }
  484. response.Paginated(c, result.Errors, int64(result.Total), result.Page, result.PageSize)
  485. }
  486. // GetUpstreamError returns upstream error detail.
  487. // GET /api/v1/admin/ops/upstream-errors/:id
  488. func (h *OpsHandler) GetUpstreamError(c *gin.Context) {
  489. h.GetErrorLogByID(c)
  490. }
  491. // ResolveUpstreamError toggles resolved status.
  492. // PUT /api/v1/admin/ops/upstream-errors/:id/resolve
  493. func (h *OpsHandler) ResolveUpstreamError(c *gin.Context) {
  494. h.UpdateErrorResolution(c)
  495. }
  496. // ==================== Existing endpoints ====================
  497. // ListRequestDetails returns a request-level list (success + error) for drill-down.
  498. // GET /api/v1/admin/ops/requests
  499. func (h *OpsHandler) ListRequestDetails(c *gin.Context) {
  500. if h.opsService == nil {
  501. response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
  502. return
  503. }
  504. if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
  505. response.ErrorFrom(c, err)
  506. return
  507. }
  508. page, pageSize := response.ParsePagination(c)
  509. if pageSize > 100 {
  510. pageSize = 100
  511. }
  512. startTime, endTime, err := parseOpsTimeRange(c, "1h")
  513. if err != nil {
  514. response.BadRequest(c, err.Error())
  515. return
  516. }
  517. filter := &service.OpsRequestDetailFilter{
  518. Page: page,
  519. PageSize: pageSize,
  520. StartTime: &startTime,
  521. EndTime: &endTime,
  522. }
  523. filter.Kind = strings.TrimSpace(c.Query("kind"))
  524. filter.Platform = strings.TrimSpace(c.Query("platform"))
  525. filter.Model = strings.TrimSpace(c.Query("model"))
  526. filter.RequestID = strings.TrimSpace(c.Query("request_id"))
  527. filter.Query = strings.TrimSpace(c.Query("q"))
  528. filter.Sort = strings.TrimSpace(c.Query("sort"))
  529. if v := strings.TrimSpace(c.Query("user_id")); v != "" {
  530. id, err := strconv.ParseInt(v, 10, 64)
  531. if err != nil || id <= 0 {
  532. response.BadRequest(c, "Invalid user_id")
  533. return
  534. }
  535. filter.UserID = &id
  536. }
  537. if v := strings.TrimSpace(c.Query("api_key_id")); v != "" {
  538. id, err := strconv.ParseInt(v, 10, 64)
  539. if err != nil || id <= 0 {
  540. response.BadRequest(c, "Invalid api_key_id")
  541. return
  542. }
  543. filter.APIKeyID = &id
  544. }
  545. if v := strings.TrimSpace(c.Query("account_id")); v != "" {
  546. id, err := strconv.ParseInt(v, 10, 64)
  547. if err != nil || id <= 0 {
  548. response.BadRequest(c, "Invalid account_id")
  549. return
  550. }
  551. filter.AccountID = &id
  552. }
  553. if v := strings.TrimSpace(c.Query("group_id")); v != "" {
  554. id, err := strconv.ParseInt(v, 10, 64)
  555. if err != nil || id <= 0 {
  556. response.BadRequest(c, "Invalid group_id")
  557. return
  558. }
  559. filter.GroupID = &id
  560. }
  561. if v := strings.TrimSpace(c.Query("min_duration_ms")); v != "" {
  562. parsed, err := strconv.Atoi(v)
  563. if err != nil || parsed < 0 {
  564. response.BadRequest(c, "Invalid min_duration_ms")
  565. return
  566. }
  567. filter.MinDurationMs = &parsed
  568. }
  569. if v := strings.TrimSpace(c.Query("max_duration_ms")); v != "" {
  570. parsed, err := strconv.Atoi(v)
  571. if err != nil || parsed < 0 {
  572. response.BadRequest(c, "Invalid max_duration_ms")
  573. return
  574. }
  575. filter.MaxDurationMs = &parsed
  576. }
  577. out, err := h.opsService.ListRequestDetails(c.Request.Context(), filter)
  578. if err != nil {
  579. // Invalid sort/kind/platform etc should be a bad request; keep it simple.
  580. if strings.Contains(strings.ToLower(err.Error()), "invalid") {
  581. response.BadRequest(c, err.Error())
  582. return
  583. }
  584. response.Error(c, http.StatusInternalServerError, "Failed to list request details")
  585. return
  586. }
  587. response.Paginated(c, out.Items, out.Total, out.Page, out.PageSize)
  588. }
  589. type opsResolveRequest struct {
  590. Resolved bool `json:"resolved"`
  591. }
  592. // UpdateErrorResolution allows manual resolve/unresolve.
  593. // PUT /api/v1/admin/ops/errors/:id/resolve
  594. func (h *OpsHandler) UpdateErrorResolution(c *gin.Context) {
  595. if h.opsService == nil {
  596. response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
  597. return
  598. }
  599. if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
  600. response.ErrorFrom(c, err)
  601. return
  602. }
  603. subject, ok := middleware.GetAuthSubjectFromContext(c)
  604. if !ok || subject.UserID <= 0 {
  605. response.Error(c, http.StatusUnauthorized, "Unauthorized")
  606. return
  607. }
  608. idStr := strings.TrimSpace(c.Param("id"))
  609. id, err := strconv.ParseInt(idStr, 10, 64)
  610. if err != nil || id <= 0 {
  611. response.BadRequest(c, "Invalid error id")
  612. return
  613. }
  614. var req opsResolveRequest
  615. if err := c.ShouldBindJSON(&req); err != nil {
  616. response.BadRequest(c, "Invalid request: "+err.Error())
  617. return
  618. }
  619. uid := subject.UserID
  620. if err := h.opsService.UpdateErrorResolution(c.Request.Context(), id, req.Resolved, &uid); err != nil {
  621. response.ErrorFrom(c, err)
  622. return
  623. }
  624. response.Success(c, gin.H{"ok": true})
  625. }
  626. func parseOpsTimeRange(c *gin.Context, defaultRange string) (time.Time, time.Time, error) {
  627. startStr := strings.TrimSpace(c.Query("start_time"))
  628. endStr := strings.TrimSpace(c.Query("end_time"))
  629. parseTS := func(s string) (time.Time, error) {
  630. if s == "" {
  631. return time.Time{}, nil
  632. }
  633. if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
  634. return t, nil
  635. }
  636. return time.Parse(time.RFC3339, s)
  637. }
  638. start, err := parseTS(startStr)
  639. if err != nil {
  640. return time.Time{}, time.Time{}, err
  641. }
  642. end, err := parseTS(endStr)
  643. if err != nil {
  644. return time.Time{}, time.Time{}, err
  645. }
  646. // start/end explicitly provided (even partially)
  647. if startStr != "" || endStr != "" {
  648. if end.IsZero() {
  649. end = time.Now()
  650. }
  651. if start.IsZero() {
  652. dur, _ := parseOpsDuration(defaultRange)
  653. start = end.Add(-dur)
  654. }
  655. if start.After(end) {
  656. return time.Time{}, time.Time{}, fmt.Errorf("invalid time range: start_time must be <= end_time")
  657. }
  658. if end.Sub(start) > 30*24*time.Hour {
  659. return time.Time{}, time.Time{}, fmt.Errorf("invalid time range: max window is 30 days")
  660. }
  661. return start, end, nil
  662. }
  663. // time_range fallback
  664. tr := strings.TrimSpace(c.Query("time_range"))
  665. if tr == "" {
  666. tr = defaultRange
  667. }
  668. dur, ok := parseOpsDuration(tr)
  669. if !ok {
  670. dur, _ = parseOpsDuration(defaultRange)
  671. }
  672. end = time.Now()
  673. start = end.Add(-dur)
  674. if end.Sub(start) > 30*24*time.Hour {
  675. return time.Time{}, time.Time{}, fmt.Errorf("invalid time range: max window is 30 days")
  676. }
  677. return start, end, nil
  678. }
  679. func parseOpsDuration(v string) (time.Duration, bool) {
  680. switch strings.TrimSpace(v) {
  681. case "5m":
  682. return 5 * time.Minute, true
  683. case "30m":
  684. return 30 * time.Minute, true
  685. case "1h":
  686. return time.Hour, true
  687. case "6h":
  688. return 6 * time.Hour, true
  689. case "24h":
  690. return 24 * time.Hour, true
  691. case "7d":
  692. return 7 * 24 * time.Hour, true
  693. case "30d":
  694. return 30 * 24 * time.Hour, true
  695. default:
  696. return 0, false
  697. }
  698. }