diff --git a/backend/app/api/v1/endpoints/ticket.py b/backend/app/api/v1/endpoints/ticket.py index fff0462..d24ace7 100644 --- a/backend/app/api/v1/endpoints/ticket.py +++ b/backend/app/api/v1/endpoints/ticket.py @@ -2,6 +2,7 @@ from typing import Optional, List, Dict, Any from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel from sqlalchemy import select, func, or_ from sqlalchemy.ext.asyncio import AsyncSession @@ -16,6 +17,12 @@ from app.services.audit_engine import SQLAuditEngine from app.services.audit_log_service import AuditLogService import json + +class AuditRequest(BaseModel): + sql_content: str + instance_id: int + database: str = "" + router = APIRouter(prefix="/tickets", tags=["工单管理"]) @@ -164,6 +171,33 @@ async def ticket_statistics( } +@router.post("/audit") +async def audit_sql( + data: AuditRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + # Verify instance exists + inst_result = await db.execute(select(Instance).where(Instance.id == data.instance_id)) + instance = inst_result.scalar_one_or_none() + if not instance: + raise HTTPException(status_code=400, detail="数据源不存在") + + env = "production" if instance.env == "production" else "development" + engine = SQLAuditEngine(env=env) + report = engine.audit(data.sql_content) + + return { + "score": report.score, + "passed": report.passed, + "has_warnings": report.has_warnings, + "findings": [ + {"rule": f.rule, "level": f.level.value, "message": f.message} + for f in report.findings + ], + } + + @router.get("/{ticket_id}") async def get_ticket( ticket_id: int, @@ -216,6 +250,169 @@ async def review_ticket( return await _enrich_ticket(ticket, db) +@router.get("/{ticket_id}/timeline") +async def get_ticket_timeline( + ticket_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = await db.execute(select(Ticket).where(Ticket.id == ticket_id)) + ticket = result.scalar_one_or_none() + if not ticket: + raise HTTPException(status_code=404, detail="工单不存在") + + if current_user.role == "developer" and ticket.applicant_id != current_user.id: + raise HTTPException(status_code=403, detail="无权查看该工单") + + # Fetch audit logs for this ticket + from app.models.audit_log import AuditLog + log_stmt = ( + select(AuditLog) + .where(AuditLog.target_type == "ticket", AuditLog.target_id == ticket_id) + .order_by(AuditLog.created_at.asc()) + ) + log_result = await db.execute(log_stmt) + logs = log_result.scalars().all() + + timeline = [] + + # Creation event + applicant_name = "未知" + if ticket.applicant_id: + r = await db.execute(select(User).where(User.id == ticket.applicant_id)) + applicant = r.scalar_one_or_none() + if applicant: + applicant_name = applicant.display_name + + timeline.append({ + "action": "CREATE_TICKET", + "label": "创建工单", + "user": applicant_name, + "time": ticket.create_time.isoformat() if ticket.create_time else None, + "detail": "提交 SQL 变更工单", + }) + + # From audit logs + for log in logs: + if log.action == "CREATE_TICKET": + continue # already handled + r = await db.execute(select(User).where(User.id == log.user_id)) + u = r.scalar_one_or_none() + uname = u.display_name if u else "未知" + + label_map = { + "REVIEW_TICKET": "审核工单", + "EXECUTE_TICKET": "执行工单", + "CANCEL_TICKET": "取消工单", + "ROLLBACK_TICKET": "回滚工单", + } + timeline.append({ + "action": log.action, + "label": label_map.get(log.action, log.action), + "user": uname, + "time": log.created_at.isoformat() if log.created_at else None, + "detail": log.detail, + }) + + # Status-based synthetic events if no log entries cover them + if ticket.review_time: + has_review = any(t["action"] == "REVIEW_TICKET" for t in timeline) + if not has_review: + approver_name = "未知" + if ticket.approver_id: + r = await db.execute(select(User).where(User.id == ticket.approver_id)) + a = r.scalar_one_or_none() + if a: + approver_name = a.display_name + timeline.append({ + "action": "REVIEW_TICKET", + "label": "审核工单", + "user": approver_name, + "time": ticket.review_time.isoformat() if ticket.review_time else None, + "detail": "工单状态变更为 " + str(ticket.status), + }) + + if ticket.execute_time: + has_exec = any(t["action"] == "EXECUTE_TICKET" for t in timeline) + if not has_exec: + timeline.append({ + "action": "EXECUTE_TICKET", + "label": "执行工单", + "user": "系统", + "time": ticket.execute_time.isoformat() if ticket.execute_time else None, + "detail": ticket.execute_result or "执行完成", + }) + + return {"ticket_id": ticket_id, "timeline": timeline} + + +@router.post("/{ticket_id}/cancel") +async def cancel_ticket( + ticket_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = await db.execute(select(Ticket).where(Ticket.id == ticket_id)) + ticket = result.scalar_one_or_none() + if not ticket: + raise HTTPException(status_code=404, detail="工单不存在") + + # Only applicant or admin can cancel + if ticket.applicant_id != current_user.id and current_user.role != "admin": + raise HTTPException(status_code=403, detail="只有申请人或管理员可以取消工单") + + if ticket.status not in ("pending", "reviewing"): + raise HTTPException(status_code=400, detail="只能取消待审批或审批中的工单") + + ticket.status = "rejected" + ticket.audit_result = "工单被取消" + + await AuditLogService.log( + db, user_id=current_user.id, action="CANCEL_TICKET", + target_type="ticket", target_id=ticket.id, + detail="取消工单", + ) + + await db.commit() + await db.refresh(ticket) + return await _enrich_ticket(ticket, db) + + +@router.post("/{ticket_id}/rollback") +async def rollback_ticket( + ticket_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_role("admin", "dba")), +): + result = await db.execute(select(Ticket).where(Ticket.id == ticket_id)) + ticket = result.scalar_one_or_none() + if not ticket: + raise HTTPException(status_code=404, detail="工单不存在") + + if ticket.status not in ("success", "failed"): + raise HTTPException(status_code=400, detail="只能回滚已执行(成功或失败)的工单") + + # Simplified rollback — return info message + rollback_sql = ticket.rollback_sql or "未提供回滚 SQL" + + await AuditLogService.log( + db, user_id=current_user.id, action="ROLLBACK_TICKET", + target_type="ticket", target_id=ticket.id, + detail="请求回滚工单", + ) + + ticket.status = "rollback" + await db.commit() + await db.refresh(ticket) + + return { + "message": "回滚请求已记录,请手动执行以下回滚 SQL", + "ticket_id": ticket_id, + "rollback_sql": rollback_sql, + "status": ticket.status, + } + + @router.post("/{ticket_id}/execute") async def execute_ticket( ticket_id: int, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3698aba..20cc988 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,10 @@ import AutoTaskPage from '@/pages/AutoTaskPage'; import SettingsPage from '@/pages/SettingsPage'; import AuditLogsPage from '@/pages/AuditLogsPage'; import DataDictionaryPage from '@/pages/DataDictionaryPage'; +import InstanceDetailPage from '@/pages/InstanceDetailPage'; +import RedisDetailPage from '@/pages/RedisDetailPage'; +import TicketCreatePage from '@/pages/TicketCreatePage'; +import TicketDetailPage from '@/pages/TicketDetailPage'; // 路由守卫:未登录跳转登录页,无权限跳转仪表盘 function RouteGuard() { @@ -52,8 +56,12 @@ function AppRoutes() { } /> } /> } /> + } /> + } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/ticket.ts b/frontend/src/api/ticket.ts index 4d4efff..7bb98f9 100644 --- a/frontend/src/api/ticket.ts +++ b/frontend/src/api/ticket.ts @@ -1,11 +1,34 @@ import request from './request'; -export const ticketApi = { - list: (params?: any) => request.get('/tickets', { params }), - get: (id: number) => request.get(`/tickets/${id}`), - create: (data: any) => request.post('/tickets', data), - review: (id: number, data: { action: 'approve' | 'reject'; comment: string }) => - request.post(`/tickets/${id}/review`, data), - execute: (id: number) => request.post(`/tickets/${id}/execute`), - statistics: () => request.get('/tickets/statistics'), +export var ticketApi = { + list: function (params?: any) { + return request.get('/tickets', { params: params }); + }, + get: function (id: number) { + return request.get('/tickets/' + id); + }, + create: function (data: any) { + return request.post('/tickets', data); + }, + review: function (id: number, data: { action: 'approve' | 'reject'; comment: string }) { + return request.post('/tickets/' + id + '/review', data); + }, + execute: function (id: number) { + return request.post('/tickets/' + id + '/execute'); + }, + statistics: function () { + return request.get('/tickets/statistics'); + }, + timeline: function (id: number) { + return request.get('/tickets/' + id + '/timeline'); + }, + cancel: function (id: number) { + return request.post('/tickets/' + id + '/cancel'); + }, + rollback: function (id: number) { + return request.post('/tickets/' + id + '/rollback'); + }, + audit: function (data: { sql_content: string; instance_id: number; database: string }) { + return request.post('/tickets/audit', data); + }, }; diff --git a/frontend/src/pages/InstanceDetailPage.tsx b/frontend/src/pages/InstanceDetailPage.tsx new file mode 100644 index 0000000..b0f8be8 --- /dev/null +++ b/frontend/src/pages/InstanceDetailPage.tsx @@ -0,0 +1,289 @@ +import { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { ArrowLeft, Database, Table2, Users, Clock, Cpu, Play, RefreshCw } from 'lucide-react'; +import { instanceDetailApi } from '@/api/instanceDetail'; +import { instanceApi } from '@/api/instance'; +import { queryApi } from '@/api/query'; + +interface InstanceInfo { + id: number; name: string; db_type: string; host: string; port: number; + status: string; version: string | null; env: string; +} + +type TabKey = 'overview' | 'tables' | 'query' | 'users' | 'slow' | 'process'; + +export default function InstanceDetailPage() { + var params = useParams<{ id: string }>(); + var navigate = useNavigate(); + var instanceId = Number(params.id); + + var [instance, setInstance] = useState(null); + var [activeTab, setActiveTab] = useState('overview'); + var [databases, setDatabases] = useState([]); + var [selectedDb, setSelectedDb] = useState(''); + var [tables, setTables] = useState([]); + var [users, setUsers] = useState([]); + var [slowQueries, setSlowQueries] = useState([]); + var [processes, setProcesses] = useState([]); + var [sqlText, setSqlText] = useState(''); + var [queryResult, setQueryResult] = useState(null); + var [loading, setLoading] = useState(false); + + var tabs: { key: TabKey; label: string; icon: React.ElementType }[] = [ + { key: 'overview', label: '概览', icon: Database }, + { key: 'tables', label: '表管理', icon: Table2 }, + { key: 'query', label: 'SQL查询', icon: Play }, + { key: 'users', label: '用户权限', icon: Users }, + { key: 'slow', label: '慢查询', icon: Clock }, + { key: 'process', label: '进程列表', icon: Cpu }, + ]; + + useEffect(function () { + instanceApi.get(instanceId).then(function (res: any) { + setInstance(res); + }).catch(function () {}); + }, [instanceId]); + + useEffect(function () { + if (activeTab === 'overview' || activeTab === 'tables') { + instanceDetailApi.listDatabases(instanceId).then(function (res: any) { + var dbs = res.databases || res || []; + setDatabases(Array.isArray(dbs) ? dbs : []); + if (dbs.length > 0 && !selectedDb) setSelectedDb(dbs[0]); + }).catch(function () { setDatabases([]); }); + } + if (activeTab === 'users') { + instanceDetailApi.listUsers(instanceId).then(function (res: any) { + setUsers(res.users || res || []); + }).catch(function () { setUsers([]); }); + } + if (activeTab === 'slow') { + instanceDetailApi.getSlowQueries(instanceId, selectedDb).then(function (res: any) { + setSlowQueries(res.queries || res || []); + }).catch(function () { setSlowQueries([]); }); + } + if (activeTab === 'process') { + instanceDetailApi.getProcesslist(instanceId).then(function (res: any) { + setProcesses(res.processes || res || []); + }).catch(function () { setProcesses([]); }); + } + }, [activeTab, selectedDb, instanceId]); + + useEffect(function () { + if (activeTab === 'tables' && selectedDb) { + instanceDetailApi.listTables(instanceId, selectedDb).then(function (res: any) { + setTables(res.tables || res || []); + }).catch(function () { setTables([]); }); + } + }, [activeTab, selectedDb, instanceId]); + + var handleQuery = function () { + if (!sqlText.trim()) return; + setLoading(true); + queryApi.execute({ instance_id: instanceId, database: selectedDb, sql: sqlText }).then(function (res: any) { + setQueryResult(res); + }).catch(function (e: any) { + setQueryResult({ error: e?.response?.data?.detail || '查询失败' }); + }).finally(function () { setLoading(false); }); + }; + + var handleKill = function (pid: number) { + if (!confirm('确定 Kill 进程 ' + pid + '?')) return; + instanceDetailApi.killProcess(instanceId, pid).then(function () { + instanceDetailApi.getProcesslist(instanceId).then(function (res: any) { + setProcesses(res.processes || res || []); + }); + }).catch(function () {}); + }; + + if (!instance) return
加载中...
; + + return ( +
+ {/* 头部 */} +
+
+ +
+

{instance.name}

+

{instance.host}:{instance.port} · {instance.version || '-'} · {instance.env}

+
+
+
+ {instance.status === 'connected' || instance.status === 'online' ? '在线' : '离线'} +
+
+ + {/* Tab 导航 */} +
+ {tabs.map(function (tab) { + var Icon = tab.icon; + return ( + + ); + })} +
+ + {/* 概览 */} + {activeTab === 'overview' && ( +
+
+
{databases.length}
数据库
+
{instance.status === 'connected' ? '在线' : '离线'}
状态
+
{instance.version || '-'}
版本
+
+
+

数据库列表

+
+ {databases.map(function (db) { + return
{db}
; + })} +
+
+
+ )} + + {/* 表管理 */} + {activeTab === 'tables' && ( +
+
+ + +
+ {tables.length === 0 ?
选择数据库查看表列表
: ( +
+ + + + + + {tables.map(function (t: any, i: number) { + return + + + + + + ; + })} + +
表名引擎行数大小注释
{t.Name || t.name}{t.Engine || t.engine || '-'}{t.Rows || t.rows || '-'}{t.Data_length ? (t.Data_length / 1024 / 1024).toFixed(2) + ' MB' : '-'}{t.Comment || t.comment || '-'}
+
+ )} +
+ )} + + {/* SQL查询 */} + {activeTab === 'query' && ( +
+
+ +
+