diff --git a/backend/internal/handler/ticket.go b/backend/internal/handler/ticket.go index 2525df0..93efe68 100644 --- a/backend/internal/handler/ticket.go +++ b/backend/internal/handler/ticket.go @@ -16,6 +16,24 @@ import ( "github.com/gin-gonic/gin" ) +// SqlExecItem records the execution result of a single SQL statement +type SqlExecItem struct { + SQL string `json:"sql"` + Status string `json:"status"` // success / fail + AffectedRows int64 `json:"affected_rows"` + DurationMs int64 `json:"duration_ms"` + Error string `json:"error,omitempty"` +} + +// SqlExecResult is the structured execution result stored as JSON +type SqlExecResult struct { + Status string `json:"status"` // success / failed + TotalMs int64 `json:"total_ms"` + Items []SqlExecItem `json:"items"` + StartTime string `json:"start_time,omitempty"` + EndTime string `json:"end_time,omitempty"` +} + // GET /api/v1/tickets/statistics func TicketStatistics(c *gin.Context) { userID, _, role := middleware.GetCurrentUser(c) @@ -547,16 +565,34 @@ func ExecuteTicket(c *gin.Context) { if len(parts) == 0 { execErr = "empty command" } else { - cmd := strings.ToUpper(parts[0]) args := make([]interface{}, len(parts)) for i, p := range parts { args[i] = p } + execStart := time.Now() result, err := client.Do(ctx, args...).Result() + stmtMs := time.Since(execStart).Milliseconds() + execRes := SqlExecResult{ + Status: "success", + TotalMs: stmtMs, + StartTime: execStart.UTC().Format(time.RFC3339), + EndTime: time.Now().UTC().Format(time.RFC3339), + } if err != nil { - execErr = fmt.Sprintf("%s failed: %v", cmd, err) + execRes.Status = "failed" + execRes.Items = []SqlExecItem{{ + SQL: ticket.SQLContent, Status: "fail", DurationMs: stmtMs, Error: err.Error(), + }} + execErr = err.Error() } else { - execResult = fmt.Sprintf("[OK] %s → %v", ticket.SQLContent, result) + resultStr := fmt.Sprintf("%v", result) + execRes.Items = []SqlExecItem{{ + SQL: ticket.SQLContent, Status: "success", DurationMs: stmtMs, + Error: resultStr, + }} + } + if jsonBytes, jsonErr := json.Marshal(execRes); jsonErr == nil { + execResult = string(jsonBytes) } } } @@ -582,12 +618,31 @@ func ExecuteTicket(c *gin.Context) { execTimeout := getSettingInt("executor_timeout", 300) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(execTimeout)*time.Second) defer cancel() - result, ghErr := ghost.RunGhOst(ctx, *ghostTask, getGhostPath()) - if ghErr != nil { - execErr = fmt.Sprintf("gh-ost 执行失败: %s\n%s", result.Error, ghErr.Error()) - } else { - execResult = fmt.Sprintf("[gh-ost OK] %s\n%s", truncateStr(ticket.SQLContent, 80), result.Output) - } + execStart := time.Now() + result, ghErr := ghost.RunGhOst(ctx, *ghostTask, getGhostPath()) + stmtMs := time.Since(execStart).Milliseconds() + ghRes := SqlExecResult{ + Status: "success", + TotalMs: stmtMs, + StartTime: execStart.UTC().Format(time.RFC3339), + EndTime: time.Now().UTC().Format(time.RFC3339), + } + if ghErr != nil { + ghRes.Status = "failed" + ghRes.Items = []SqlExecItem{{ + SQL: ticket.SQLContent, Status: "fail", DurationMs: stmtMs, + Error: fmt.Sprintf("%s\n%s", result.Error, ghErr.Error()), + }} + execErr = ghErr.Error() + } else { + ghRes.Items = []SqlExecItem{{ + SQL: ticket.SQLContent, Status: "success", DurationMs: stmtMs, + Error: result.Output, + }} + } + if jsonBytes, jsonErr := json.Marshal(ghRes); jsonErr == nil { + execResult = string(jsonBytes) + } // Skip normal execution path goto executionDone } @@ -644,26 +699,35 @@ func ExecuteTicket(c *gin.Context) { return } } - // Split multi-statement by semicolons + // Split multi-statement by semicolons + execStart := time.Now() statements := splitSQL(ticket.SQLContent) - var results []string + execRes := SqlExecResult{Status: "success", StartTime: execStart.UTC().Format(time.RFC3339)} for _, stmt := range statements { stmt = strings.TrimSpace(stmt) if stmt == "" { continue } - _, err := db.ExecContext(ctx, stmt) + stmtStart := time.Now() + sqlResult, err := db.ExecContext(ctx, stmt) + stmtMs := time.Since(stmtStart).Milliseconds() if err != nil { - results = append(results, fmt.Sprintf("[FAIL] %s: %v", truncateStr(stmt, 50), err)) - // stop_on_error + execRes.Items = append(execRes.Items, SqlExecItem{ + SQL: stmt, Status: "fail", DurationMs: stmtMs, Error: err.Error(), + }) + execRes.Status = "failed" execErr = err.Error() break - } else { - results = append(results, fmt.Sprintf("[OK] %s", truncateStr(stmt, 50))) } + affected, _ := sqlResult.RowsAffected() + execRes.Items = append(execRes.Items, SqlExecItem{ + SQL: stmt, Status: "success", AffectedRows: affected, DurationMs: stmtMs, + }) } - if len(results) > 0 { - execResult = strings.Join(results, "\n") + execRes.TotalMs = time.Since(execStart).Milliseconds() + execRes.EndTime = time.Now().UTC().Format(time.RFC3339) + if jsonBytes, err := json.Marshal(execRes); err == nil { + execResult = string(jsonBytes) } } } diff --git a/frontend/src/pages/TicketsPage.tsx b/frontend/src/pages/TicketsPage.tsx index e57cd95..6812a97 100644 --- a/frontend/src/pages/TicketsPage.tsx +++ b/frontend/src/pages/TicketsPage.tsx @@ -84,16 +84,20 @@ interface TicketStatistics { failed: number; } -interface ExecuteResult { - status: string; +interface SqlExecItem { + sql: string; + status: string; // success / fail affected_rows: number; duration_ms: number; - validation_passed: boolean; - warnings: string[]; - errors: string[]; - raw_output: string; - before_snapshot?: string; - after_snapshot?: string; + error: string; +} + +interface ExecuteResult { + status: string; // success / failed + total_ms: number; + items: SqlExecItem[]; + start_time?: string; + end_time?: string; } /* ------------------------------------------------------------------ */ @@ -725,8 +729,6 @@ function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove, canExe { label: '目标库', value: ticket.target_database }, { label: '数据库类型', value: ticket.db_type.toUpperCase() }, { label: '创建时间', value: new Date(ticket.create_time).toLocaleString() }, - { label: '变更原因', value: ticket.reason || '-' }, - { label: '优先级', value: ticket.priority === 'high' ? '高' : ticket.priority === 'low' ? '低' : '中' }, ].map((item, idx) => (
{item.label} @@ -745,15 +747,15 @@ function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove, canExe
- {/* Execute Result — 结构化展示 */} + {/* Execute Result — 结构化表格展示 */} {execResult && (

执行结果

- {/* Summary */} -
+ {/* Summary row */} +
状态
@@ -761,53 +763,54 @@ function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove, canExe
-
影响行数
-
{execResult.affected_rows}
+
总耗时
+
{execResult.total_ms}ms
-
执行耗时
-
{execResult.duration_ms}ms
-
-
-
验证
-
- {execResult.validation_passed ? '通过' : '未通过'} -
+
SQL 数量
+
{execResult.items?.length || 0}
- {/* Raw Output */} - {execResult.raw_output && ( -
-
原始输出
-
-                    {execResult.raw_output}
-                  
-
- )} - - {/* Errors */} - {execResult.errors && execResult.errors.length > 0 && ( -
-
错误信息
- {execResult.errors.map((err, i) => ( -
- - {err} -
- ))} -
- )} - - {/* Warnings */} - {execResult.warnings && execResult.warnings.length > 0 && ( -
-
警告信息
- {execResult.warnings.map((w, i) => ( -
- {w} -
- ))} + {/* Per-SQL result table */} + {execResult.items && execResult.items.length > 0 && ( +
+ + + + + + + + + + + + + {execResult.items.map((item, idx) => ( + + + + + + + + + ))} + +
#SQL 语句执行结果影响行数执行时间错误信息
{idx + 1} +
{item.sql}
+
+ + {item.status === 'success' ? '成功' : '失败'} + + {item.affected_rows}{item.duration_ms}ms + {item.error && ( + + {item.error.length > 80 ? item.error.substring(0, 80) + '...' : item.error} + + )} +
)}