Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions runtime/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,101 @@ def submit_feedback(payload: dict) -> dict:
return {"status": "ok", "saved_to": str(fname)}


@app.get("/history")
def list_history() -> dict:
"""List past test runs from workspace."""
import json as _json

ws = get_settings().workspace_dir
runs: list[dict] = []

# Scan workspace/_demo and workspace/执行日志 for run outputs
for scan_dir in [ws / "_demo", ws / "执行日志"]:
if not scan_dir.exists():
continue
for f in sorted(scan_dir.rglob("*.json"), reverse=True):
try:
data = _json.loads(f.read_text(encoding="utf-8"))
if isinstance(data, dict) and "run_id" in data:
runs.append({
"run_id": data.get("run_id", f.stem),
"target": data.get("target", data.get("target_type", f.stem)),
"date": data.get("date", data.get("timestamp", "")),
"total": data.get("total", 0),
"passed": data.get("succeeded", data.get("passed", 0)),
"failed": data.get("failed", 0),
"duration_s": data.get("duration_s", data.get("duration_ms", 0) / 1000 if "duration_ms" in data else 0),
"confidence": data.get("confidence", 0),
})
except Exception:
continue

return {"runs": runs[:50]}


@app.get("/dashboard")
def get_dashboard() -> dict:
"""Aggregate quality metrics from all runs."""
import json as _json

ws = get_settings().workspace_dir
all_runs: list[dict] = []
expert_fails: dict[str, int] = {}

for scan_dir in [ws / "_demo", ws / "执行日志"]:
if not scan_dir.exists():
continue
for f in scan_dir.rglob("*.json"):
try:
data = _json.loads(f.read_text(encoding="utf-8"))
if isinstance(data, dict) and "total" in data:
all_runs.append(data)
if "results" in data and isinstance(data["results"], dict):
for node_id, r in data["results"].items():
if not r.get("ok") and r.get("name"):
name = r["name"]
expert_fails[name] = expert_fails.get(name, 0) + 1
except Exception:
continue

total = len(all_runs)
if total == 0:
return {
"total_runs": 0, "avg_pass_rate": 0, "avg_confidence": 0,
"total_test_cases": 0, "recent_runs": [], "top_failures": [],
}

pass_rates = [(r.get("succeeded", r.get("passed", 0)) / max(r.get("total", 1), 1)) for r in all_runs]
confidences = [r.get("confidence", 0) for r in all_runs if isinstance(r.get("confidence"), (int, float))]
total_cases = sum(r.get("total", 0) for r in all_runs)

top = sorted(expert_fails.items(), key=lambda x: -x[1])[:10]

recent = sorted(all_runs, key=lambda r: str(r.get("date", r.get("timestamp", ""))), reverse=True)[:10]
recent_summaries = [
{
"run_id": r.get("run_id", ""),
"target": r.get("target", r.get("target_type", "")),
"date": str(r.get("date", r.get("timestamp", ""))),
"total": r.get("total", 0),
"passed": r.get("succeeded", r.get("passed", 0)),
"failed": r.get("failed", 0),
"confidence": r.get("confidence", 0),
"duration_s": r.get("duration_s", 0),
}
for r in recent
]

return {
"total_runs": total,
"avg_pass_rate": sum(pass_rates) / total,
"avg_confidence": sum(confidences) / len(confidences) if confidences else 0,
"total_test_cases": total_cases,
"recent_runs": recent_summaries,
"top_failures": [{"expert": name, "fail_count": cnt} for name, cnt in top],
}


def _run_in_background(run_id: str, decision) -> None:
try:
summary = _kernel.execute_sync(run_id, decision)
Expand Down
14 changes: 13 additions & 1 deletion runtime/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Outlet, NavLink } from "react-router-dom";
import { Beaker, Upload, BookOpen, Settings, Stethoscope, MessageSquare } from "lucide-react";
import { Beaker, Upload, BookOpen, Settings, Stethoscope, MessageSquare, BarChart3, Clock } from "lucide-react";

export default function App() {
return (
Expand All @@ -25,6 +25,18 @@ export default function App() {
Catalog
</NavLink>
</li>
<li>
<NavLink to="/dashboard" className={({ isActive }) => (isActive ? "font-semibold" : "")}>
<BarChart3 className="inline w-4 h-4 mr-1" aria-hidden="true" />
Dashboard
</NavLink>
</li>
<li>
<NavLink to="/history" className={({ isActive }) => (isActive ? "font-semibold" : "")}>
<Clock className="inline w-4 h-4 mr-1" aria-hidden="true" />
History
</NavLink>
</li>
<li>
<NavLink to="/doctor" className={({ isActive }) => (isActive ? "font-semibold" : "")}>
<Stethoscope className="inline w-4 h-4 mr-1" aria-hidden="true" />
Expand Down
4 changes: 4 additions & 0 deletions runtime/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import CatalogPage from "./pages/CatalogPage";
import SettingsPage from "./pages/SettingsPage";
import DoctorPage from "./pages/DoctorPage";
import FeedbackPage from "./pages/FeedbackPage";
import DashboardPage from "./pages/DashboardPage";
import HistoryPage from "./pages/HistoryPage";
import "./index.css";

const queryClient = new QueryClient({
Expand All @@ -28,6 +30,8 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Route path="/catalog" element={<CatalogPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/doctor" element={<DoctorPage />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/history" element={<HistoryPage />} />
<Route path="/feedback" element={<FeedbackPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
Expand Down
116 changes: 116 additions & 0 deletions runtime/web/src/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { useEffect, useState } from "react";
import { BarChart3, TrendingUp, AlertTriangle, CheckCircle2, Clock, Activity } from "lucide-react";

interface RunSummary {
run_id: string;
target: string;
date: string;
total: number;
passed: number;
failed: number;
confidence: number;
duration_s: number;
}

interface DashboardData {
total_runs: number;
avg_pass_rate: number;
avg_confidence: number;
total_test_cases: number;
recent_runs: RunSummary[];
top_failures: { expert: string; fail_count: number }[];
}

const BASE = (import.meta as any).env?.VITE_API_BASE || "http://localhost:8800";

export default function DashboardPage() {
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");

useEffect(() => {
fetch(`${BASE}/dashboard`)
.then((r) => { if (!r.ok) throw new Error("No data yet"); return r.json(); })
.then(setData)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, []);

if (loading) return <div className="p-6"><Activity className="w-5 h-5 animate-spin inline mr-2" />Loading dashboard...</div>;
if (error || !data) {
return (
<div className="max-w-4xl mx-auto p-6 space-y-4">
<h2 className="text-xl font-semibold flex items-center gap-2"><BarChart3 className="w-5 h-5" /> AI Quality Dashboard</h2>
<div className="p-8 text-center border rounded-lg bg-slate-50">
<BarChart3 className="w-12 h-12 text-slate-300 mx-auto mb-3" />
<p className="text-slate-500">No test runs yet. Start a test to populate quality metrics.</p>
<p className="text-xs text-slate-400 mt-2">Metrics are collected from completed test runs in your workspace.</p>
</div>
</div>
);
}

const passRate = data.avg_pass_rate * 100;
const confRate = data.avg_confidence * 100;

return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<h2 className="text-xl font-semibold flex items-center gap-2">
<BarChart3 className="w-5 h-5" /> AI Quality Dashboard
</h2>

{/* KPI Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ label: "Total Runs", value: data.total_runs, icon: <Activity className="w-4 h-4" />, color: "bg-blue-50 border-blue-200" },
{ label: "Pass Rate", value: `${passRate.toFixed(1)}%`, icon: <CheckCircle2 className="w-4 h-4" />, color: passRate >= 80 ? "bg-green-50 border-green-200" : "bg-yellow-50 border-yellow-200" },
{ label: "Avg Confidence", value: `${confRate.toFixed(1)}%`, icon: <TrendingUp className="w-4 h-4" />, color: confRate >= 70 ? "bg-green-50 border-green-200" : "bg-yellow-50 border-yellow-200" },
{ label: "Test Cases", value: data.total_test_cases, icon: <Clock className="w-4 h-4" />, color: "bg-purple-50 border-purple-200" },
].map((kpi) => (
<div key={kpi.label} className={`p-4 rounded-lg border ${kpi.color}`}>
<div className="flex items-center gap-1.5 text-xs text-slate-500 mb-1">{kpi.icon}{kpi.label}</div>
<div className="text-2xl font-bold">{kpi.value}</div>
</div>
))}
</div>

{/* Recent Runs */}
<section>
<h3 className="text-sm font-medium text-slate-600 mb-3">Recent Test Runs</h3>
<div className="space-y-2">
{data.recent_runs.map((run) => (
<div key={run.run_id} className="flex items-center justify-between px-4 py-3 border rounded-lg hover:bg-slate-50">
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{run.target || run.run_id}</div>
<div className="text-xs text-slate-400">{run.date}</div>
</div>
<div className="flex items-center gap-4 text-sm">
<span className="text-green-600">{run.passed} pass</span>
{run.failed > 0 && <span className="text-red-600">{run.failed} fail</span>}
<span className="text-slate-400">{(run.confidence * 100).toFixed(0)}% conf</span>
<a href={`#/runs/${run.run_id}/report`} className="text-blue-600 hover:underline text-xs">Report →</a>
</div>
</div>
))}
</div>
</section>

{/* Top Failures */}
{data.top_failures.length > 0 && (
<section>
<h3 className="text-sm font-medium text-slate-600 mb-3 flex items-center gap-1.5">
<AlertTriangle className="w-4 h-4 text-amber-500" /> Top Failing Areas
</h3>
<div className="space-y-1.5">
{data.top_failures.map((f) => (
<div key={f.expert} className="flex items-center justify-between px-3 py-2 border rounded text-sm">
<span>{f.expert}</span>
<span className="text-red-600 font-medium">{f.fail_count} failures</span>
</div>
))}
</div>
</section>
)}
</div>
);
}
131 changes: 131 additions & 0 deletions runtime/web/src/pages/HistoryPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { useEffect, useState } from "react";
import { Clock, Search, Eye, Download, Share2 } from "lucide-react";

interface RunMeta {
run_id: string;
target: string;
date: string;
total: number;
passed: number;
failed: number;
duration_s: number;
confidence: number;
}

const BASE = (import.meta as any).env?.VITE_API_BASE || "http://localhost:8800";

export default function HistoryPage() {
const [runs, setRuns] = useState<RunMeta[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());

useEffect(() => {
fetch(`${BASE}/history`)
.then((r) => r.json())
.then((data) => setRuns(data.runs || []))
.catch(() => {})
.finally(() => setLoading(false));
}, []);

const filtered = runs.filter(
(r) => !search || r.target?.toLowerCase().includes(search.toLowerCase()) || r.run_id.includes(search)
);

const toggleSelect = (id: string) => {
const next = new Set(selected);
if (next.has(id)) next.delete(id); else next.add(id);
if (next.size > 2) return; // max 2 for compare
setSelected(next);
};

const exportBundle = async () => {
const toExport = Array.from(selected);
const bundle: Record<string, any> = {};
for (const id of toExport) {
try {
const r = await fetch(`${BASE}/report/${id}`);
if (r.ok) bundle[id] = await r.json();
} catch {}
}
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `tagent-bundle-${toExport.join("-")}.json`;
a.click();
URL.revokeObjectURL(url);
};

return (
<div className="max-w-4xl mx-auto p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold flex items-center gap-2">
<Clock className="w-5 h-5" /> Test History
</h2>
<div className="flex gap-2">
{selected.size === 2 && (
<button onClick={exportBundle} className="flex items-center gap-1.5 px-3 py-1.5 text-sm border rounded-lg hover:bg-slate-50">
<Share2 className="w-4 h-4" /> Export & Compare
</button>
)}
</div>
</div>

{/* Search */}
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-2.5 text-slate-400" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by target or run ID..."
className="w-full pl-9 pr-4 py-2 border rounded-lg text-sm"
/>
</div>

{loading ? (
<p className="text-sm text-slate-400">Loading history...</p>
) : filtered.length === 0 ? (
<div className="p-8 text-center border rounded-lg bg-slate-50">
<Clock className="w-10 h-10 text-slate-300 mx-auto mb-2" />
<p className="text-slate-500">No test history yet</p>
<p className="text-xs text-slate-400">Completed test runs will appear here automatically</p>
</div>
) : (
<div className="space-y-2">
{filtered.map((run) => (
<div
key={run.run_id}
className={`flex items-center gap-3 px-4 py-3 border rounded-lg hover:bg-slate-50 cursor-pointer transition ${selected.has(run.run_id) ? "border-blue-400 bg-blue-50 ring-1 ring-blue-400" : ""}`}
onClick={() => toggleSelect(run.run_id)}
>
<input type="checkbox" checked={selected.has(run.run_id)} onChange={() => {}} className="shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{run.target || "Untitled"}</div>
<div className="text-xs text-slate-400">{run.date} · {run.duration_s}s</div>
</div>
<div className="flex items-center gap-3 text-sm shrink-0">
<span className="text-green-600">{run.passed}/{run.total} pass</span>
{run.failed > 0 && <span className="text-red-600">{run.failed} fail</span>}
<a
href={`#/runs/${run.run_id}/report`}
onClick={(e) => e.stopPropagation()}
className="text-blue-600 hover:underline flex items-center gap-1"
>
<Eye className="w-3.5 h-3.5" /> View
</a>
</div>
</div>
))}
</div>
)}

{filtered.length > 0 && (
<p className="text-xs text-slate-400">
{selected.size === 0 ? "Select 2 runs to compare and export" : selected.size === 2 ? "2 selected — click Export & Compare" : `Select 1 more to compare (${2 - selected.size()} left)`}
</p>
)}
</div>
);
}
Loading