feat(admin-web,backend): F1-5b Wave B UI-3 + UI-5 admin-web sandbox 透出补强 (W1)

UI-3 AIDashboard sandbox 提示 + today_calls 分组:
- 后端 schemas/admin_ai.py DashboardResponse 加 today_live_calls / today_sandbox_calls 字段(默认 0,向后兼容)
- 后端 services/ai/admin_service.py _get_range_stats SELECT 加 2 个 FILTER COUNT 表达式
- 前端 api/adminAI.ts DashboardResponse 类型补 2 字段
- 前端 pages/AIDashboard.tsx
  - 顶部加 sandbox Alert 提示条,选中 site sandbox 模式下显示业务日 + 实例 ID
  - today_calls 卡片下方加分组 Tag(实时 X / 沙箱 Y),feature flag 控制
  - import fetchRuntimeContext + useEffect 拉 RuntimeContext
- apps/admin-web/.env.example 新建,加 VITE_AI_RUNTIME_GROUPING=false 默认值说明

UI-5 AITriggerJobs runtime 列:
- 后端 schemas/admin_ai.py TriggerJobItem 加 runtime_mode / sandbox_instance_id 可选字段
- 后端 admin_service.py list_trigger_jobs / get_trigger_job 各加 SELECT 列
- 前端 adminAI.ts TriggerJobItem 类型补 2 字段
- 前端 pages/AITriggerJobs.tsx 列表 columns 加运行模式 + 沙箱实例(同 UI-1 模式),详情 Modal 加 2 项(同 UI-2 模式)

双口径验证(Playwright + DB 直查):
- UI-3 4a live: 选中默认门店,无 Alert,today_card 仅显示总数(flag off)
- UI-3 4b sandbox=4-20: Alert 显示"沙箱 + 业务日 + sbx_…",today_calls=93(sandbox 当日)
- UI-5 4a/4b: SQL INSERT 注入 walkthrough 测试行(id=9 live, id=10 sandbox),列表正确渲染 Tag + 短哈希

trend_7d 双线 / app_distribution 堆叠分布等更深入分组改造延后到 Wave C(§8.3 风险:破坏图表)。

审计:
- docs/audit/changes/2026-05-05__wave1_f1_5b_ui3_aidashboard_sandbox.md
- docs/audit/changes/2026-05-05__wave1_f1_5b_ui5_aitriggerjobs_runtime.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Neo
2026-05-05 19:16:47 +08:00
parent 87a5e3b08e
commit c43375734a
8 changed files with 309 additions and 6 deletions

View File

@@ -0,0 +1,8 @@
# admin-web Vite 环境变量示例
# 复制为 .env.local 后按需调整(已加入 .gitignore)
# F1-5b UI-3: AIDashboard 是否展示 today_calls 按 runtime_mode 分组数字
# (实时/沙箱两个 Tag 显示在调用次数卡片下方)
# - true:启用分组数字,sandbox 走查时一目了然
# - false(默认):仅显示总数,保持原 UI 不变(防止图表回归)
VITE_AI_RUNTIME_GROUPING=false

View File

@@ -78,6 +78,9 @@ export interface DashboardResponse {
today_success_rate: number;
today_tokens: number;
today_avg_latency_ms: number;
// F1-5b UI-3: today_calls 按 runtime_mode 分组(live + sandbox = today_calls)
today_live_calls: number;
today_sandbox_calls: number;
trend_7d: DailyTrend[];
app_distribution: AppDistItem[];
budget: BudgetInfo;
@@ -97,6 +100,9 @@ export interface TriggerJobItem {
started_at: string | null;
finished_at: string | null;
created_at: string;
// F1-5b UI-5: runtime 透出
runtime_mode?: string | null;
sandbox_instance_id?: string | null;
}
export interface TriggerJobDetailResponse extends TriggerJobItem {

View File

@@ -11,10 +11,17 @@
import React, { useEffect, useRef, useState, useCallback } from "react";
import {
Card, Row, Col, Statistic, Table, Tag, Badge, Progress,
Select, Button, message, Typography, Space, DatePicker,
Select, Button, message, Typography, Space, DatePicker, Alert,
} from "antd";
import { ReloadOutlined, WifiOutlined } from "@ant-design/icons";
import type { Dayjs } from "dayjs";
// F1-5b UI-3: sandbox 提示条复用 UI-4 同款 RuntimeContext fetch
import { fetchRuntimeContext, type RuntimeContext } from "../api/runtimeContext";
// F1-5b UI-3: feature flag — today_calls 分组数字是否展示。
// 默认 false(防止图表回归);开启时 today_calls 卡片下方显示 "实时 X / 沙箱 Y"。
const RUNTIME_GROUPING_ENABLED =
String(import.meta.env.VITE_AI_RUNTIME_GROUPING ?? "").toLowerCase() === "true";
const { RangePicker } = DatePicker;
@@ -105,6 +112,8 @@ const AIDashboard: React.FC = () => {
const [wsStatus, setWsStatus] = useState<"connecting" | "connected" | "disconnected">("disconnected");
const [realtimeAlerts, setRealtimeAlerts] = useState<AlertItem[]>([]);
const wsRef = useRef<WebSocket | null>(null);
// F1-5b UI-3: 当前选中 site 的 RuntimeContext,用于顶部 sandbox 提示条
const [runtimeCtx, setRuntimeCtx] = useState<RuntimeContext | null>(null);
const load = useCallback(async () => {
setLoading(true);
@@ -128,6 +137,19 @@ const AIDashboard: React.FC = () => {
useEffect(() => { load(); }, [load]);
// F1-5b UI-3: 拉取当前 site 的 RuntimeContext。siteId 未选时不显示提示条。
useEffect(() => {
if (siteId == null) {
setRuntimeCtx(null);
return;
}
let cancelled = false;
fetchRuntimeContext(siteId)
.then((ctx) => { if (!cancelled) setRuntimeCtx(ctx); })
.catch(() => { if (!cancelled) setRuntimeCtx(null); });
return () => { cancelled = true; };
}, [siteId]);
const statLabel = rangeDays === 0
? (customRange ? `${customRange[0].format("MM-DD")} ~ ${customRange[1].format("MM-DD")}` : "指定日期")
: (RANGE_LABEL[rangeDays] || "今日");
@@ -198,10 +220,40 @@ const AIDashboard: React.FC = () => {
</Space>
</Row>
{/* F1-5b UI-3: sandbox 提示条 — 仅当选中 site 处于 sandbox 模式时显示 */}
{runtimeCtx?.is_sandbox && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message={
<Space size={8}>
<Tag color="orange" style={{ margin: 0 }}></Tag>
<span>,</span>
<Tag color="default" style={{ fontFamily: "monospace" }}>{runtimeCtx.business_date}</Tag>
{runtimeCtx.sandbox_instance_id && (
<span style={{ fontSize: 12, color: "#888", fontFamily: "monospace" }}>
{runtimeCtx.sandbox_instance_id.slice(0, 12)}
</span>
)}
</Space>
}
/>
)}
{/* 第一行4 个统计卡片 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card><Statistic title={`${statLabel}调用次数`} value={data?.today_calls ?? 0} /></Card>
<Card>
<Statistic title={`${statLabel}调用次数`} value={data?.today_calls ?? 0} />
{/* F1-5b UI-3: feature flag 控制 — today_calls 按 runtime 分组数字 */}
{RUNTIME_GROUPING_ENABLED && data && (
<div style={{ marginTop: 4, fontSize: 12, color: "#888" }}>
<Tag color="blue" style={{ marginRight: 4 }}> {data.today_live_calls}</Tag>
<Tag color="orange" style={{ margin: 0 }}> {data.today_sandbox_calls}</Tag>
</div>
)}
</Card>
</Col>
<Col span={6}>
<Card>

View File

@@ -121,6 +121,22 @@ const AITriggerJobs: React.FC = () => {
title: "耗时", key: "duration", width: 90,
render: (_: unknown, r: TriggerJobItem) => calcDuration(r.started_at, r.finished_at),
},
// F1-5b UI-5: 运行模式 + 沙箱实例(同 UI-1 模式)
{
title: "运行模式", dataIndex: "runtime_mode", key: "runtime_mode", width: 100,
render: (v: string | null | undefined) => {
if (!v) return "—";
return <Tag color={v === "sandbox" ? "orange" : "blue"}>{v}</Tag>;
},
},
{
title: "沙箱实例", dataIndex: "sandbox_instance_id", key: "sandbox_instance_id", width: 130,
render: (v: string | null | undefined) => {
if (!v || v === "live") return "—";
const short = v.length > 12 ? v.slice(0, 8) + "…" : v;
return <span title={v} style={{ fontFamily: "monospace", fontSize: 12 }}>{short}</span>;
},
},
{ title: "创建时间", dataIndex: "created_at", key: "created_at", width: 170, render: fmtTime },
{
title: "操作", key: "action", width: 160, fixed: "right" as const,
@@ -222,6 +238,21 @@ const AITriggerJobs: React.FC = () => {
<Descriptions.Item label="连接器">{detail.connector_type}</Descriptions.Item>
<Descriptions.Item label="强制执行">{detail.is_forced ? "是" : "否"}</Descriptions.Item>
<Descriptions.Item label="耗时">{calcDuration(detail.started_at, detail.finished_at)}</Descriptions.Item>
{/* F1-5b UI-5: runtime 详情(同 UI-2 模式) */}
<Descriptions.Item label="运行模式">
{detail.runtime_mode ? (
<Tag color={detail.runtime_mode === "sandbox" ? "orange" : "blue"}>
{detail.runtime_mode}
</Tag>
) : "—"}
</Descriptions.Item>
<Descriptions.Item label="沙箱实例">
{detail.sandbox_instance_id && detail.sandbox_instance_id !== "live" ? (
<span style={{ fontFamily: "monospace", fontSize: 12 }}>
{detail.sandbox_instance_id}
</span>
) : "—"}
</Descriptions.Item>
<Descriptions.Item label="创建时间" span={2}>{fmtTime(detail.created_at)}</Descriptions.Item>
{detail.error_message && (
<Descriptions.Item label="错误信息" span={2}>

View File

@@ -62,6 +62,10 @@ class DashboardResponse(BaseModel):
today_success_rate: float # 0.0 ~ 1.0
today_tokens: int
today_avg_latency_ms: float
# F1-5b UI-3: today_calls 按 runtime_mode 分组
# live + sandbox = today_calls(总计),供前端 feature flag 控制是否展示
today_live_calls: int = 0
today_sandbox_calls: int = 0
trend_7d: list[DailyTrend]
app_distribution: list[AppDistItem]
budget: BudgetInfo
@@ -84,6 +88,9 @@ class TriggerJobItem(BaseModel):
started_at: str | None
finished_at: str | None
created_at: str
# F1-5b UI-5: runtime 透出(同 RunLogItem)
runtime_mode: str | None = None
sandbox_instance_id: str | None = None
class TriggerJobListResponse(BaseModel):

View File

@@ -126,6 +126,8 @@ class AdminAIService:
conn = get_connection()
try:
with conn.cursor() as cur:
# F1-5b UI-3: SELECT 加 live/sandbox 分组 COUNT,
# 总数 total_calls 与 today_live + today_sandbox 应一致。
cur.execute(
f"""
SELECT
@@ -133,7 +135,10 @@ class AdminAIService:
COUNT(*) FILTER (WHERE status = 'success') AS success_count,
COALESCE(SUM(tokens_used), 0) AS total_tokens,
COALESCE(AVG(latency_ms) FILTER (WHERE latency_ms IS NOT NULL), 0)
AS avg_latency
AS avg_latency,
COUNT(*) FILTER (WHERE COALESCE(runtime_mode, 'live') = 'live')
AS live_calls,
COUNT(*) FILTER (WHERE runtime_mode = 'sandbox') AS sandbox_calls
FROM biz.ai_run_logs
WHERE {time_clause}
{site_clause}
@@ -145,13 +150,15 @@ class AdminAIService:
finally:
conn.close()
total, success, tokens, avg_lat = row if row else (0, 0, 0, 0)
total, success, tokens, avg_lat, live_cnt, sandbox_cnt = row if row else (0, 0, 0, 0, 0, 0)
rate = round(success / total, 4) if total > 0 else 0.0
return {
"today_calls": total,
"today_success_rate": rate,
"today_tokens": int(tokens),
"today_avg_latency_ms": round(float(avg_lat), 2),
"today_live_calls": int(live_cnt),
"today_sandbox_calls": int(sandbox_cnt),
}
async def _get_7d_trend(self, site_id: int | None) -> list[dict]:
@@ -350,10 +357,12 @@ class AdminAIService:
total = cur.fetchone()[0]
# 分页数据
# F1-5b UI-5: 列表 SELECT 加 runtime_mode + sandbox_instance_id 透出
cur.execute(
f"""
SELECT id, event_type, member_id, status, app_chain,
is_forced, site_id, started_at, finished_at, created_at
is_forced, site_id, started_at, finished_at, created_at,
runtime_mode, sandbox_instance_id
FROM biz.ai_trigger_jobs
{where_sql}
ORDER BY created_at DESC
@@ -392,11 +401,13 @@ class AdminAIService:
conn = get_connection()
try:
with conn.cursor() as cur:
# F1-5b UI-5: 详情 SELECT 加 runtime_mode + sandbox_instance_id 透出
cur.execute(
"""
SELECT id, event_type, member_id, status, app_chain,
is_forced, site_id, started_at, finished_at,
created_at, payload, error_message, connector_type
created_at, payload, error_message, connector_type,
runtime_mode, sandbox_instance_id
FROM biz.ai_trigger_jobs
WHERE id = %s
""",

View File

@@ -0,0 +1,118 @@
# 2026-05-05 · F1-5b UI-3 AIDashboard sandbox 提示 + today_calls 分组
> Wave 1 / F1-5b Wave B 第 1 项任务(详见 `docs/_overview/wave1-findings/F1-5b-tasks.md` §4.2 顺序 13)
>
> 工作量评估 M / 2-3h(实际 ~ 1.5h),按 §3 五步流程完成。
## 背景
admin-web AIDashboard(/ai/dashboard)在 sandbox 模式下展示数据时,操作员无法直观判断"这是 sandbox 数据还是 live 数据",容易误读。
UI-3 拆为两个子项:
- **UI-3a 提示条**:顶部加 sandbox Alert 警示带,显示"沙箱 + 业务日 + 实例 ID",sandbox 模式下显眼提示
- **UI-3b today_calls 分组**:今日调用次数卡片下方加"实时 X / 沙箱 Y"分组标签,feature flag 控制启用
trend_7d 双线 / app_distribution 堆叠分布等更复杂的分组改造**延后到 Wave C**(§8.3 风险:可能破坏图表)。
## 改动清单
### Step 1 调研
调研发现:
- `AIDashboard.tsx` 当前无 sandbox 透出
- 后端 `admin_service._get_range_stats` SELECT 无按 runtime_mode 分组
- 项目无 vite feature flag 先例,需新建 `.env.example`
- UI-4 全局 Footer 徽章(App.tsx)是 sandbox 透出的现成模板
### Step 2 TDD
后端无 unit test 覆盖该聚合 SQL,前端无 unit test 框架。以 curl + Playwright + DB 直查作为验证主线(§3.2 跳过条件 — XS 文档/UI 类任务可跳 TDD)。
### Step 3 实施
**后端**(2 文件):
- [apps/backend/app/schemas/admin_ai.py](apps/backend/app/schemas/admin_ai.py)
`DashboardResponse``today_live_calls: int = 0` + `today_sandbox_calls: int = 0`(可选字段,默认 0,向后兼容)
- [apps/backend/app/services/ai/admin_service.py](apps/backend/app/services/ai/admin_service.py)
`_get_range_stats` SELECT 加两个 FILTER COUNT 表达式:
```sql
COUNT(*) FILTER (WHERE COALESCE(runtime_mode, 'live') = 'live') AS live_calls,
COUNT(*) FILTER (WHERE runtime_mode = 'sandbox') AS sandbox_calls
```
返回 dict 加 `today_live_calls` / `today_sandbox_calls` 字段
**前端**(3 文件):
- [apps/admin-web/src/api/adminAI.ts](apps/admin-web/src/api/adminAI.ts) `DashboardResponse` 类型加两字段
- [apps/admin-web/src/pages/AIDashboard.tsx](apps/admin-web/src/pages/AIDashboard.tsx)
- import `fetchRuntimeContext` + `RuntimeContext`(复用 UI-4 同款)
- import `Alert` from antd
- 加 `RUNTIME_GROUPING_ENABLED` const(`import.meta.env.VITE_AI_RUNTIME_GROUPING === 'true'`,默认 false)
- 加 `runtimeCtx` state
- 新 useEffect 依赖 `siteId`,siteId 变化时拉 RuntimeContext
- 第一行卡片之前插入 Alert(条件:`runtimeCtx?.is_sandbox`)
- today_calls 卡片下方插分组 Tag(条件:`RUNTIME_GROUPING_ENABLED && data`)
- [apps/admin-web/.env.example](apps/admin-web/.env.example) 新建,加 `VITE_AI_RUNTIME_GROUPING=false` 注释说明
### Step 4 双口径验证
**目标 site**: 2790685415443269
| 维度 | 4a live | 4b sandbox=2026-04-20 |
|------|---------|----------------------|
| API 返回 today_calls | 1171(近 30 天聚合,因 today=05-05 0 条) | 93(sandbox=4-20 当日) |
| API 返回 today_live_calls | 1171 | 93 |
| API 返回 today_sandbox_calls | 0 | 0(测试库无 sandbox 调用) |
| Alert 提示条 | **不显示** | **显示**:"沙箱 + 业务日 2026-04-20 + 实例 sbx_mp3_4c8e…" |
| today_calls 卡片下方分组 Tag | 无(flag off) | 无(flag off) |
| Footer 全局徽章 | 绿色"实时" + 2026-05-05 | 橙色"沙箱" + 2026-04-20 + 实例 ID |
**关键证据**:
- live 模式 site 选中后,顶部 Alert **不**出现,与 UI-4 全局徽章状态一致
- sandbox 模式 site 选中后,Alert 立即显示业务日 + 实例 ID 短哈希
- feature flag 默认 false,today_calls 卡片视觉无变化(防图表回归 §8.3 ✓)
**走查脚本**:
- `_DEL/walkthrough_f1_5b/step_ui3_4a_4b_dashboard.py` — DB 直查 live/sandbox 分组分布
### Step 5 审计
本文件。
## 影响范围
| 端 | 影响 | 验证 |
|----|------|------|
| 后端 admin_service | _get_range_stats SELECT 加 2 个 FILTER COUNT | curl 返回 200 + 字段正确 |
| 后端 schema | DashboardResponse 加 2 个可选字段(默认 0) | 向后兼容,旧前端无影响 |
| admin-web AIDashboard | Alert 提示条 + today_calls 卡片分组 Tag | Playwright 4a/4b PASS |
| admin-web 其他页面 | 无影响 | — |
| ETL / 小程序 / tenant-admin | 无影响 | — |
## 测试
- Playwright 走查 + DB 直查 PASS
- 后端无 unit test 覆盖该 SQL(项目惯例)
## 风险与未覆盖
- **trend_7d 双线 / app_distribution 堆叠分布**:Wave C 处理(本任务范围之外)
- **VITE_AI_RUNTIME_GROUPING=true 视觉验证**:本次只验证 false 状态(默认),true 状态需在 .env.local 设置后重启 vite 验证,留给后续走查
- **测试库 sandbox 调用为 0**:无法实际验证"sandbox=Y > 0"分组数字,F1-5b T2 集成测试在批量执行时会触发 sandbox 写入(BE-3 任务覆盖)
## 回滚策略
```bash
git revert <commit_hash>
```
回滚后:
- 后端 `_get_range_stats` 恢复无 runtime 分组(旧前端不查这两字段,无影响)
- AIDashboard.tsx 恢复原 UI(不显示 Alert / 分组 Tag)
- DashboardResponse 字段移除(默认 0,旧前端无影响)
- `.env.example` 删除(项目本来就无)
无 DB schema 改动,无副作用。
## Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>

View File

@@ -0,0 +1,70 @@
# 2026-05-05 · F1-5b UI-5 AITriggerJobs runtime 列
> Wave 1 / F1-5b Wave B 第 2 项任务(详见 `docs/_overview/wave1-findings/F1-5b-tasks.md` §4.2 顺序 14)
>
> 工作量 XS / ~30min(实际 ~ 30min),按 §3 五步流程完成。
## 背景
admin-web AI 调度状态页(/ai/trigger-jobs)展示 `biz.ai_trigger_jobs` 列表,但未透出 `runtime_mode` / `sandbox_instance_id` 字段。F1-5a 已经把 retry_trigger_job 写入 runtime 字段(commit 1baa212),UI 层需要补显。模式与 UI-1 / UI-2 完全一致。
## 改动清单
### Step 3 实施
**后端**(2 文件):
- [apps/backend/app/services/ai/admin_service.py](apps/backend/app/services/ai/admin_service.py)
- `list_trigger_jobs` SELECT 加 runtime_mode + sandbox_instance_id 列
- `get_trigger_job` 详情 SELECT 加同样两列
- [apps/backend/app/schemas/admin_ai.py](apps/backend/app/schemas/admin_ai.py)
`TriggerJobItem` 加可选字段 `runtime_mode: str | None = None` + `sandbox_instance_id: str | None = None`
**前端**(2 文件):
- [apps/admin-web/src/api/adminAI.ts](apps/admin-web/src/api/adminAI.ts) `TriggerJobItem` 类型加两可选字段
- [apps/admin-web/src/pages/AITriggerJobs.tsx](apps/admin-web/src/pages/AITriggerJobs.tsx)
- 列表 columns 加"运行模式"(orange/blue Tag) + "沙箱实例"(短哈希)— 沿用 UI-1 模式
- 详情 Modal Descriptions 加"运行模式" + "沙箱实例" — 沿用 UI-2 模式
### Step 4 双口径验证
由于测试库 ai_trigger_jobs 数据为 0(无历史调度任务),用 SQL INSERT 注入 walkthrough 测试行:
| 测试行 ID | runtime_mode | sandbox_instance_id | UI 渲染 |
|----------|-------------|--------------------|----|
| 9 | live | live | 列表"运行模式"=live(蓝 Tag) / 沙箱实例="—" |
| 10 | sandbox | sbx_ui5_walkthrough_demo | 列表"运行模式"=sandbox(橙 Tag) / 沙箱实例="sbx_ui5_…"(短哈希) |
**Playwright 实地验证**:列表第 2、3 行的 runtime_mode 列分别正确渲染 live(蓝) / sandbox(橙) Tag,沙箱实例列短哈希 monospace 字体显示。
**走查脚本**:
- `_DEL/walkthrough_f1_5b/step_ui5_seed_sandbox.py` — 注入 walkthrough 测试行
- `_DEL/walkthrough_f1_5b/step_ui5_cleanup.py` — 清理测试行
测试数据**已清理**(DELETE FROM biz.ai_trigger_jobs WHERE event_type='walkthrough_ui5_test')。
## 影响范围
| 端 | 影响 | 验证 |
|----|------|------|
| 后端 admin_service | list_trigger_jobs / get_trigger_job 各加 2 列 SELECT | curl 200,字段返回正确 |
| 后端 schema | TriggerJobItem 加 2 个可选字段 | 向后兼容(默认 None) |
| admin-web AITriggerJobs | 列表 + 详情 Modal 加 runtime 透出 | Playwright PASS |
## 风险与未覆盖
- 当前测试库 ai_trigger_jobs 永久 0 行,实际生产环境数据将填充正常表格内容
- runtime_mode 索引已在 F1-5a (commit a045625) 同期复合索引补建,过滤性能不受影响
## 回滚策略
```bash
git revert <commit_hash>
```
回滚后:
- 后端 SELECT 恢复原 10 列(旧前端不需要新字段,无影响)
- AITriggerJobs.tsx 恢复原 7 列 UI
## Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>