From cc80acf2b9e3b938145a26c5d18385d5d217eb1c Mon Sep 17 00:00:00 2001 From: repair-agent Date: Tue, 17 Mar 2026 17:50:58 +0800 Subject: [PATCH] add UI --- app/models.py | 2 + repair_agent/agent/scheduler.py | 11 +- web/index.html | 7 +- web/src/api.ts | 4 + web/src/index.css | 473 ++++++++++++++++++++++++++++++++ web/src/pages/Dashboard.tsx | 214 +++++++++------ web/src/pages/ProjectList.tsx | 394 ++++++++++---------------- 7 files changed, 778 insertions(+), 327 deletions(-) diff --git a/app/models.py b/app/models.py index 261832f..591f2e6 100644 --- a/app/models.py +++ b/app/models.py @@ -27,6 +27,7 @@ class Project(SQLModel, table=True): repo_url: Optional[str] = Field(default=None) local_path: Optional[str] = Field(default=None) description: Optional[str] = Field(default=None) + repair_enabled: bool = Field(default=True) created_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow) @@ -36,6 +37,7 @@ class ProjectUpdate(SQLModel): repo_url: Optional[str] = None local_path: Optional[str] = None description: Optional[str] = None + repair_enabled: Optional[bool] = None class ErrorLog(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) diff --git a/repair_agent/agent/scheduler.py b/repair_agent/agent/scheduler.py index f1b3ddb..bc05a41 100644 --- a/repair_agent/agent/scheduler.py +++ b/repair_agent/agent/scheduler.py @@ -41,13 +41,18 @@ class RepairScheduler: @staticmethod def _fetch_projects_from_api() -> list[str]: - """从 Log Center API 动态获取所有已注册项目""" + """从 Log Center API 动态获取所有已启用修复的项目""" try: resp = httpx.get(f"{settings.log_center_url}/api/v1/projects", timeout=10) resp.raise_for_status() projects = resp.json().get("projects", []) - project_ids = [p["project_id"] for p in projects] - logger.info(f"从 API 获取到 {len(project_ids)} 个项目: {', '.join(project_ids)}") + # 只返回 repair_enabled=True 的项目 + enabled = [p for p in projects if p.get("repair_enabled", True)] + disabled = [p["project_id"] for p in projects if not p.get("repair_enabled", True)] + project_ids = [p["project_id"] for p in enabled] + logger.info(f"从 API 获取到 {len(project_ids)} 个已启用项目: {', '.join(project_ids)}") + if disabled: + logger.info(f" 已禁用修复的项目: {', '.join(disabled)}") return project_ids except Exception as e: logger.warning(f"从 API 获取项目列表失败: {e},回退到本地配置") diff --git a/web/index.html b/web/index.html index af88f03..59646d0 100644 --- a/web/index.html +++ b/web/index.html @@ -1,10 +1,13 @@ - + - web + + + + 日志中台 - Log Center
diff --git a/web/src/api.ts b/web/src/api.ts index 9d91b50..d97c9e0 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -100,6 +100,7 @@ export interface Project { repo_url: string | null; local_path: string | null; description: string | null; + repair_enabled: boolean; created_at: string; updated_at: string; } @@ -163,6 +164,9 @@ export const updateProject = (projectId: string, data: Partial) => export const deleteProject = (projectId: string) => api.delete(`/api/v1/projects/${projectId}`); +export const toggleProjectRepair = (projectId: string, enabled: boolean) => + api.put(`/api/v1/projects/${projectId}`, { repair_enabled: enabled }); + export const updateTaskStatus = (taskId: number, status: string, message?: string) => api.put(`/api/v1/tasks/${taskId}/status`, { status, message }); diff --git a/web/src/index.css b/web/src/index.css index 2168880..87df131 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1516,6 +1516,412 @@ td a:hover { } } +/* ============ Dashboard Hero ============ */ + +.dashboard-hero { + display: grid; + grid-template-columns: 1fr 200px; + gap: 16px; + margin-bottom: 24px; + align-items: stretch; +} + +.stats-grid-3 { + grid-template-columns: repeat(3, 1fr); +} + +.dashboard-ring-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.fix-rate-ring { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.fix-rate-svg { + width: 120px; + height: 120px; +} + +.fix-rate-text { + position: absolute; + display: flex; + flex-direction: column; + align-items: center; +} + +.fix-rate-number { + font-size: 24px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.fix-rate-label { + font-size: 11px; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 500; +} + +/* ============ Dashboard Charts ============ */ + +.dashboard-charts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.chart-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 20px; +} + +.chart-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.chart-card-title { + font-size: 14px; + font-weight: 600; +} + +.chart-card-total { + font-size: 12px; + color: var(--text-tertiary); +} + +/* ============ Bar Chart ============ */ + +.bar-chart { + display: flex; + flex-direction: column; + gap: 10px; +} + +.bar-chart-row { + display: flex; + align-items: center; + gap: 10px; +} + +.bar-chart-label { + font-size: 12px; + color: var(--text-secondary); + min-width: 70px; + text-align: right; + flex-shrink: 0; +} + +.bar-chart-track { + flex: 1; + height: 20px; + background: var(--bg-surface); + border-radius: 4px; + overflow: hidden; +} + +.bar-chart-fill { + height: 100%; + border-radius: 4px; + transition: width 0.5s ease; + min-width: 4px; +} + +.bar-chart-value { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + min-width: 32px; + text-align: right; + flex-shrink: 0; +} + +/* ============ Project Cards Grid ============ */ + +.project-cards-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); + gap: 16px; +} + +.project-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 20px; + display: flex; + flex-direction: column; + gap: 12px; + transition: border-color 0.15s ease; +} + +.project-card:hover { + border-color: #3f3f46; +} + +.project-card-disabled { + opacity: 0.65; +} + +.project-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.project-card-title { + display: flex; + flex-direction: column; + gap: 2px; +} + +.project-card-title strong { + font-size: 15px; +} + +.project-card-name { + font-size: 12px; + color: var(--text-tertiary); +} + +.project-card-desc { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + margin: 0; +} + +.project-card-actions { + display: flex; + gap: 4px; +} + +.btn-icon { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + padding: 0; +} + +.btn-icon-danger:hover { + color: var(--error) !important; + background: var(--error-muted) !important; +} + +.project-card-info { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 0; + border-top: 1px solid var(--border-subtle); +} + +.project-info-item { + display: flex; + gap: 8px; + font-size: 12px; +} + +.project-info-label { + color: var(--text-tertiary); + font-weight: 500; + min-width: 32px; + flex-shrink: 0; +} + +.project-card-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 10px; + border-top: 1px solid var(--border-subtle); +} + +.project-card-time { + font-size: 11px; + color: var(--text-tertiary); +} + +.project-card-edit { + display: flex; + flex-direction: column; + gap: 10px; +} + +.project-card-edit-header { + margin-bottom: 4px; +} + +.project-card-edit-actions { + display: flex; + gap: 8px; + margin-top: 4px; +} + +/* ============ Repair Toggle ============ */ + +.repair-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + background: none; + border: none; + cursor: pointer; + padding: 4px 0; + transition: opacity 0.15s ease; +} + +.repair-toggle:hover { + opacity: 0.85; +} + +.repair-toggle:disabled { + opacity: 0.5; + cursor: wait; +} + +.repair-toggle-track { + width: 36px; + height: 20px; + border-radius: 10px; + position: relative; + transition: background 0.2s ease; + flex-shrink: 0; +} + +.repair-toggle-on .repair-toggle-track { + background: var(--success); +} + +.repair-toggle-off .repair-toggle-track { + background: var(--bg-hover); + border: 1px solid var(--border); +} + +.repair-toggle-thumb { + position: absolute; + top: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: white; + transition: left 0.2s ease; +} + +.repair-toggle-on .repair-toggle-thumb { + left: 18px; +} + +.repair-toggle-off .repair-toggle-thumb { + left: 2px; +} + +.repair-toggle-label { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 500; +} + +.repair-toggle-on .repair-toggle-label { + color: var(--success); +} + +.repair-toggle-off .repair-toggle-label { + color: var(--text-tertiary); +} + +/* ============ Repair Summary Badge ============ */ + +.repair-summary { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--success-muted); + color: var(--success); + border-radius: 999px; + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +/* ============ Modal ============ */ + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: modal-fade-in 0.15s ease; +} + +.modal-content { + background: var(--bg-secondary); + border-radius: var(--radius-lg); + padding: 24px; + max-width: 440px; + width: 90%; + border: 1px solid var(--border); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + animation: modal-slide-up 0.2s ease; +} + +.modal-title { + margin: 0 0 8px 0; + font-size: 16px; + font-weight: 600; +} + +.modal-desc { + margin: 0 0 16px 0; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +@keyframes modal-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes modal-slide-up { + from { transform: translateY(8px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + /* ============ Safe area for notch devices ============ */ @supports (padding: env(safe-area-inset-bottom)) { @@ -1527,3 +1933,70 @@ td a:hover { padding-bottom: calc(16px + env(safe-area-inset-bottom)); } } + +/* ============ Responsive: Dashboard & Projects ============ */ + +@media (max-width: 1024px) { + .dashboard-hero { + grid-template-columns: 1fr; + } + + .stats-grid-3 { + grid-template-columns: repeat(3, 1fr); + } + + .dashboard-ring-card { + display: none; + } + + .dashboard-charts { + grid-template-columns: 1fr; + } + + .project-cards-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .stats-grid-3 { + grid-template-columns: repeat(2, 1fr); + } + + .bar-chart-label { + min-width: 56px; + font-size: 11px; + } + + .project-cards-grid { + grid-template-columns: 1fr; + gap: 12px; + } + + .project-card { + padding: 16px; + } + + .project-card-footer { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .repair-summary { + font-size: 11px; + padding: 4px 10px; + } + + .title-row { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } +} + +@media (max-width: 480px) { + .stats-grid-3 { + grid-template-columns: 1fr; + } +} diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 59d3727..2f4b0cc 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { Bug, CalendarPlus, TrendingUp, AlertTriangle } from 'lucide-react'; +import { Bug, CalendarPlus, AlertTriangle } from 'lucide-react'; import { getStats, type DashboardStats } from '../api'; const SOURCE_LABELS: Record = { @@ -20,6 +20,93 @@ const STATUS_LABELS: Record = { FIX_FAILED: '修复失败', }; +const STATUS_COLORS: Record = { + NEW: 'var(--accent)', + VERIFYING: 'var(--indigo)', + CANNOT_REPRODUCE: 'var(--text-tertiary)', + PENDING_FIX: 'var(--warning)', + FIXING: 'var(--indigo)', + FIXED: 'var(--success)', + VERIFIED: '#16a34a', + DEPLOYED: '#15803d', + FIX_FAILED: 'var(--error)', +}; + +const SOURCE_COLORS: Record = { + runtime: 'var(--text-secondary)', + cicd: '#60a5fa', + deployment: '#f472b6', +}; + +/** Horizontal bar chart for distribution data */ +function BarChart({ data, labels, colors }: { + data: Record; + labels: Record; + colors: Record; +}) { + const entries = Object.entries(data).filter(([, v]) => v > 0); + if (entries.length === 0) { + return
暂无数据
; + } + const maxVal = Math.max(...entries.map(([, v]) => v)); + + return ( +
+ {entries.map(([key, value]) => ( +
+ {labels[key] || key} +
+
+
+ {value} +
+ ))} +
+ ); +} + +/** Donut ring chart for fix rate */ +function FixRateRing({ rate }: { rate: number }) { + const r = 54; + const circumference = 2 * Math.PI * r; + const offset = circumference - (rate / 100) * circumference; + const color = rate >= 70 ? 'var(--success)' : rate >= 40 ? 'var(--warning)' : 'var(--error)'; + + return ( +
+ + + + +
+ {rate}% + 修复率 +
+
+ ); +} + export default function Dashboard() { const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); @@ -50,99 +137,70 @@ export default function Dashboard() { return
加载统计数据失败
; } + const pendingCount = (stats.status_distribution['NEW'] || 0) + + (stats.status_distribution['PENDING_FIX'] || 0); + return (

仪表盘

-

错误追踪系统概览

+

错误追踪与自动修复系统概览

-
-
-
- + {/* Hero: stats + fix rate ring */} +
+
+
+
+ +
+
缺陷总数
+
{stats.total_bugs}
+
+
+
+ +
+
今日新增
+
{stats.today_bugs}
+
+
+
+ +
+
待修复
+
{pendingCount}
-
缺陷总数
-
{stats.total_bugs}
-
-
- -
-
今日新增
-
{stats.today_bugs}
-
-
-
- -
-
修复率
-
{stats.fix_rate}%
-
-
-
- -
-
待修复
-
- {(stats.status_distribution['NEW'] || 0) + - (stats.status_distribution['PENDING_FIX'] || 0)} -
+
+
-
-
-
-

状态分布

+ {/* Charts: status + source distribution */} +
+
+
+

状态分布

+ 共 {stats.total_bugs} 个
- - - - - - - - - {Object.entries(stats.status_distribution).map(([status, count]) => ( - - - - - ))} - -
状态数量
- - {STATUS_LABELS[status] || status} - - {count}
+
{stats.source_distribution && ( -
-
-

来源分布

+
+
+

来源分布

- - - - - - - - - {Object.entries(stats.source_distribution).map(([source, count]) => ( - - - - - ))} - -
来源数量
- - {SOURCE_LABELS[source] || source} - - {count}
+
)}
diff --git a/web/src/pages/ProjectList.tsx b/web/src/pages/ProjectList.tsx index 517f757..05f8dc9 100644 --- a/web/src/pages/ProjectList.tsx +++ b/web/src/pages/ProjectList.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; -import { getProjects, updateProject, deleteProject, type Project } from '../api'; -import { Save, X, Pencil, Trash2 } from 'lucide-react'; +import { getProjects, updateProject, deleteProject, toggleProjectRepair, type Project } from '../api'; +import { Save, X, Pencil, Trash2, Wrench, ShieldCheck, ShieldOff } from 'lucide-react'; export default function ProjectList() { const [projects, setProjects] = useState([]); @@ -8,6 +8,7 @@ export default function ProjectList() { const [editingId, setEditingId] = useState(null); const [editForm, setEditForm] = useState({ name: '', repo_url: '', local_path: '', description: '' }); const [saving, setSaving] = useState(false); + const [togglingId, setTogglingId] = useState(null); // Delete confirmation state const [deleteTarget, setDeleteTarget] = useState(null); @@ -55,6 +56,22 @@ export default function ProjectList() { } }; + const handleToggleRepair = async (p: Project) => { + setTogglingId(p.project_id); + try { + await toggleProjectRepair(p.project_id, !p.repair_enabled); + setProjects(prev => prev.map(proj => + proj.project_id === p.project_id + ? { ...proj, repair_enabled: !proj.repair_enabled } + : proj + )); + } catch (err) { + console.error('切换修复状态失败:', err); + } finally { + setTogglingId(null); + } + }; + const openDeleteConfirm = (p: Project) => { setDeleteTarget(p); setDeleteConfirmName(''); @@ -93,247 +110,147 @@ export default function ProjectList() { return
; } + const enabledCount = projects.filter(p => p.repair_enabled).length; + return (
-

项目管理

-

管理项目的仓库地址和本地路径,供 Repair Agent 使用

+
+
+

项目管理

+

管理项目配置,控制 Repair Agent 的自动修复范围

+
+
+ + {enabledCount}/{projects.length} 个项目已启用自动修复 +
+
{projects.length === 0 ? (
暂无项目,首次上报日志后会自动创建
) : ( - <> - {/* Desktop table */} -
-
- - - - - - - - - - - - - - {projects.map((p) => ( - - {editingId === p.id ? ( - <> - - - - - - - - - ) : ( - <> - - - - - - - - - )} - - ))} - -
项目 ID名称仓库地址本地路径描述更新时间操作
{p.project_id} - setEditForm({ ...editForm, name: e.target.value })} - placeholder="项目名称" - className="edit-input" - /> - - setEditForm({ ...editForm, repo_url: e.target.value })} - placeholder="https://gitea.example.com/..." - className="edit-input" - /> - - setEditForm({ ...editForm, local_path: e.target.value })} - placeholder="/home/user/projects/..." - className="edit-input" - /> - - setEditForm({ ...editForm, description: e.target.value })} - placeholder="项目描述" - className="edit-input" - /> - - {new Date(p.updated_at).toLocaleString()} - -
- - -
-
{p.project_id}{p.name || -}{p.description || '-'} - {new Date(p.updated_at).toLocaleString()} - -
- - -
-
-
- - {/* Mobile card list */} -
- {projects.map((p) => ( -
- {editingId === p.id ? ( -
- {p.project_id} - setEditForm({ ...editForm, name: e.target.value })} - placeholder="项目名称" - className="edit-input" - /> - setEditForm({ ...editForm, repo_url: e.target.value })} - placeholder="仓库地址" - className="edit-input" - /> - setEditForm({ ...editForm, local_path: e.target.value })} - placeholder="本地路径" - className="edit-input" - /> - setEditForm({ ...editForm, description: e.target.value })} - placeholder="描述" - className="edit-input" - /> -
- - -
-
- ) : ( - <> -
- {p.project_id} -
- - -
-
- {p.name &&
{p.name}
} -
-
仓库:
-
路径:
-
- {p.description && ( -
{p.description}
- )} - - )} +
+ {projects.map((p) => ( +
+ {editingId === p.id ? ( +
+
+ {p.project_id} +
+ setEditForm({ ...editForm, name: e.target.value })} + placeholder="项目名称" + className="edit-input" + /> + setEditForm({ ...editForm, repo_url: e.target.value })} + placeholder="仓库地址" + className="edit-input" + /> + setEditForm({ ...editForm, local_path: e.target.value })} + placeholder="本地路径" + className="edit-input" + /> + setEditForm({ ...editForm, description: e.target.value })} + placeholder="描述" + className="edit-input" + /> +
+ + +
- ))} + ) : ( + <> +
+
+ {p.project_id} + {p.name && {p.name}} +
+
+ + +
+
+ + {p.description && ( +

{p.description}

+ )} + +
+
+ 仓库 + +
+
+ 路径 + +
+
+ +
+ + + 更新于 {new Date(p.updated_at).toLocaleDateString()} + +
+ + )}
-
- + ))} +
)} {/* Delete confirmation modal */} {deleteTarget && ( -
-
e.stopPropagation()} - > -

删除项目

-

+

+
e.stopPropagation()}> +

删除项目

+

此操作不可撤销。请输入项目 ID {deleteTarget.project_id} 以确认删除。

-
-