feat(admin): Phase 6 AI任务监控+成本异常 — 全局任务列/筛/详情抽屉/失败重投
后端:adminpanel tasks(全局 AITask + status/type/team/anomaly 筛分页)+ detail(payload)+ retry(失败重投, 仅图像类 best-effort 否则 400);is_cost_anomaly(实际>预估×1.5)+ F() 异常筛;IsPlatformAdmin + 审计。 前端:adminApi tasks 系列;Admin 任务监控页(状态 tab + 仅成本异常 chip + 表格成本预估→实际+异常标 + 详情抽屉 payload + 失败重投)。 修真 bug:抽屉缺 .show 类停屏外,补上正常滑入。 测试:adminpanel 36 单测过(筛/异常/详情/重投 mock celery + 非失败/不支持/权限拒); 无头 e2e _admin-p6.mjs 6 断言过 + 0 console error(不点真重投);tsc+build 绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5aab8a568f
commit
ca1e50d32c
@@ -1,9 +1,20 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.accounts.models import Team, TeamMember, User
|
||||
from apps.ai.models import QualityWord
|
||||
from apps.ai.models import AITask, QualityWord
|
||||
from apps.assets.models import Asset
|
||||
|
||||
# 成本异常阈值:实际成本 > 预估 × 此倍数(且预估 > 0)即标异常
|
||||
COST_ANOMALY_RATIO = Decimal("1.5")
|
||||
|
||||
|
||||
def is_cost_anomaly(estimated, actual) -> bool:
|
||||
est = estimated or Decimal("0")
|
||||
act = actual or Decimal("0")
|
||||
return bool(est > 0 and act > est * COST_ANOMALY_RATIO)
|
||||
|
||||
|
||||
class QualityWordSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
@@ -73,3 +84,29 @@ class AdminUserSerializer(serializers.ModelSerializer):
|
||||
{"team_id": str(m.team_id), "team_name": m.team.name, "role": m.role}
|
||||
for m in obj.team_memberships.select_related("team").all()
|
||||
]
|
||||
|
||||
|
||||
class AdminTaskSerializer(serializers.ModelSerializer):
|
||||
team_name = serializers.CharField(source="team.name", read_only=True, default=None)
|
||||
model_name = serializers.CharField(source="model_config.name", read_only=True, default=None)
|
||||
cost_anomaly = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = AITask
|
||||
fields = [
|
||||
"id", "task_type", "status", "team", "team_name", "model_name",
|
||||
"estimated_cost", "actual_cost", "cost_anomaly", "error_code", "created_at",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_cost_anomaly(self, obj) -> bool:
|
||||
return is_cost_anomaly(obj.estimated_cost, obj.actual_cost)
|
||||
|
||||
|
||||
class AdminTaskDetailSerializer(AdminTaskSerializer):
|
||||
class Meta(AdminTaskSerializer.Meta):
|
||||
fields = AdminTaskSerializer.Meta.fields + [
|
||||
"project", "idempotency_key", "request_payload", "response_payload",
|
||||
"error_message", "submitted_at", "completed_at",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
@@ -298,3 +298,72 @@ class AdminAssetReviewTests(TestCase):
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.data["polled"], 1) # 仅 a_proc 处于 processing
|
||||
mock_poll.assert_called_once()
|
||||
|
||||
|
||||
class AdminTaskMonitorTests(TestCase):
|
||||
"""Phase 6:AI 任务监控(列/筛/详情/成本异常)+ 失败重投(celery 全 mock)+ 权限。"""
|
||||
|
||||
def setUp(self):
|
||||
from apps.ai.models import AITask, ModelConfig, ModelProvider
|
||||
|
||||
self.AITask = AITask
|
||||
self.admin = User.objects.create_user(username="padmin6", password="x", is_platform_admin=True)
|
||||
self.normal = User.objects.create_user(username="normal6", password="x")
|
||||
self.team = Team.objects.create(name="T6", owner=self.normal)
|
||||
TeamMember.objects.create(team=self.team, user=self.normal, role=TeamMember.Role.OWNER)
|
||||
prov = ModelProvider.objects.create(name="prov6", display_name="P6")
|
||||
self.mc = ModelConfig.objects.create(provider=prov, name="m6", display_name="M6", capability=ModelConfig.Capability.IMAGE)
|
||||
|
||||
def mk(tt, st, est, act, key):
|
||||
return AITask.objects.create(
|
||||
team=self.team, model_config=self.mc, task_type=tt, status=st,
|
||||
estimated_cost=est, actual_cost=act, idempotency_key=key,
|
||||
)
|
||||
|
||||
self.t_ok = mk(AITask.Type.PRODUCT_IMAGE, AITask.Status.SUCCEEDED, "1.0", "1.0", "k-ok")
|
||||
self.t_failed = mk(AITask.Type.PRODUCT_IMAGE, AITask.Status.FAILED, "1.0", "1.0", "k-failed")
|
||||
self.t_anom = mk(AITask.Type.PERSON_IMAGE, AITask.Status.SUCCEEDED, "1.0", "5.0", "k-anom")
|
||||
self.t_script = mk(AITask.Type.SCRIPT_GENERATION, AITask.Status.FAILED, "1.0", "1.0", "k-script")
|
||||
self.ac = APIClient()
|
||||
self.ac.force_authenticate(self.admin)
|
||||
self.nc = APIClient()
|
||||
self.nc.force_authenticate(self.normal)
|
||||
|
||||
def test_list_permission_and_count(self):
|
||||
self.assertEqual(self.nc.get("/api/admin/tasks/").status_code, 403)
|
||||
r = self.ac.get("/api/admin/tasks/")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertGreaterEqual(r.data["count"], 4)
|
||||
|
||||
def test_filter_status_type_anomaly(self):
|
||||
self.assertTrue(all(t["status"] == "failed" for t in self.ac.get("/api/admin/tasks/?status=failed").data["results"]))
|
||||
self.assertTrue(all(t["task_type"] == "person_image" for t in self.ac.get("/api/admin/tasks/?task_type=person_image").data["results"]))
|
||||
ids = {t["id"] for t in self.ac.get("/api/admin/tasks/?anomaly=1").data["results"]}
|
||||
self.assertIn(str(self.t_anom.id), ids)
|
||||
self.assertNotIn(str(self.t_ok.id), ids)
|
||||
|
||||
def test_cost_anomaly_flag(self):
|
||||
self.assertTrue(self.ac.get(f"/api/admin/tasks/{self.t_anom.id}/").data["cost_anomaly"])
|
||||
self.assertFalse(self.ac.get(f"/api/admin/tasks/{self.t_ok.id}/").data["cost_anomaly"])
|
||||
|
||||
def test_detail_has_payloads(self):
|
||||
d = self.ac.get(f"/api/admin/tasks/{self.t_failed.id}/")
|
||||
self.assertEqual(d.status_code, 200)
|
||||
self.assertIn("request_payload", d.data)
|
||||
self.assertIn("response_payload", d.data)
|
||||
|
||||
def test_retry_dispatch_and_audit(self):
|
||||
with patch("apps.ai.tasks.generate_standalone_image_task.delay") as mock_delay:
|
||||
r = self.ac.post(f"/api/admin/tasks/{self.t_failed.id}/retry/")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
mock_delay.assert_called_once()
|
||||
self.assertTrue(AdminAuditLog.objects.filter(action="task.retry").exists())
|
||||
|
||||
def test_retry_non_failed_rejected(self):
|
||||
self.assertEqual(self.ac.post(f"/api/admin/tasks/{self.t_ok.id}/retry/").status_code, 400)
|
||||
|
||||
def test_retry_unsupported_type_rejected(self):
|
||||
self.assertEqual(self.ac.post(f"/api/admin/tasks/{self.t_script.id}/retry/").status_code, 400)
|
||||
|
||||
def test_retry_requires_admin(self):
|
||||
self.assertEqual(self.nc.post(f"/api/admin/tasks/{self.t_failed.id}/retry/").status_code, 403)
|
||||
|
||||
@@ -5,6 +5,9 @@ from .views import (
|
||||
admin_asset_reviews_poll,
|
||||
admin_asset_reviews_submit,
|
||||
admin_invitations,
|
||||
admin_task_detail,
|
||||
admin_task_retry,
|
||||
admin_tasks,
|
||||
admin_quality_word_detail,
|
||||
admin_quality_words,
|
||||
admin_revoke_invitation,
|
||||
@@ -31,4 +34,7 @@ urlpatterns = [
|
||||
path("asset-reviews/", admin_asset_reviews, name="admin-asset-reviews"),
|
||||
path("asset-reviews/submit/", admin_asset_reviews_submit, name="admin-asset-reviews-submit"),
|
||||
path("asset-reviews/poll/", admin_asset_reviews_poll, name="admin-asset-reviews-poll"),
|
||||
path("tasks/", admin_tasks, name="admin-tasks"),
|
||||
path("tasks/<uuid:task_id>/", admin_task_detail, name="admin-task-detail"),
|
||||
path("tasks/<uuid:task_id>/retry/", admin_task_retry, name="admin-task-retry"),
|
||||
]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""平台超管后台 · 跨团队端点。所有视图统一挂 IsPlatformAdmin,非超管一律 403,写操作记审计。"""
|
||||
|
||||
from django.db.models import Count, Q
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db.models import Count, F, Q
|
||||
from rest_framework import status
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
@@ -10,13 +12,16 @@ from apps.accounts.audit import log_admin_action
|
||||
from apps.accounts.models import Invitation, Team, User
|
||||
from apps.accounts.permissions import IsPlatformAdmin
|
||||
from apps.accounts.serializers import InvitationSerializer
|
||||
from apps.ai.models import QualityWord
|
||||
from apps.ai.models import AITask, QualityWord
|
||||
from apps.assets.models import Asset
|
||||
from apps.assets.review import poll_asset_review, submit_asset_for_review
|
||||
from apps.common.pagination import DefaultPagination
|
||||
|
||||
from .serializers import (
|
||||
COST_ANOMALY_RATIO,
|
||||
AdminReviewAssetSerializer,
|
||||
AdminTaskDetailSerializer,
|
||||
AdminTaskSerializer,
|
||||
AdminTeamMemberSerializer,
|
||||
AdminTeamSerializer,
|
||||
AdminUserSerializer,
|
||||
@@ -326,3 +331,71 @@ def admin_asset_reviews_poll(request):
|
||||
for asset in qs:
|
||||
statuses[str(asset.id)] = poll_asset_review(asset)
|
||||
return Response({"polled": len(statuses), "statuses": statuses})
|
||||
|
||||
|
||||
# ─────────────────────────── AI 任务监控 + 成本异常 ───────────────────────────
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsPlatformAdmin])
|
||||
def admin_tasks(request):
|
||||
"""全局 AITask 列表(?status= / ?task_type= / ?team= / ?anomaly=1 成本异常 筛 + 分页)。"""
|
||||
qs = AITask.objects.select_related("team", "model_config").order_by("-created_at")
|
||||
st = request.query_params.get("status")
|
||||
if st in dict(AITask.Status.choices):
|
||||
qs = qs.filter(status=st)
|
||||
tt = request.query_params.get("task_type")
|
||||
if tt in dict(AITask.Type.choices):
|
||||
qs = qs.filter(task_type=tt)
|
||||
team_id = request.query_params.get("team")
|
||||
if team_id:
|
||||
qs = qs.filter(team_id=team_id)
|
||||
if request.query_params.get("anomaly") in {"1", "true"}:
|
||||
qs = qs.filter(estimated_cost__gt=0, actual_cost__gt=F("estimated_cost") * COST_ANOMALY_RATIO)
|
||||
paginator = DefaultPagination()
|
||||
page = paginator.paginate_queryset(qs, request)
|
||||
return paginator.get_paginated_response(AdminTaskSerializer(page, many=True).data)
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsPlatformAdmin])
|
||||
def admin_task_detail(request, task_id):
|
||||
task = AITask.objects.select_related("team", "model_config").filter(id=task_id).first()
|
||||
if task is None:
|
||||
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response(AdminTaskDetailSerializer(task).data)
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsPlatformAdmin])
|
||||
def admin_task_retry(request, task_id):
|
||||
"""失败任务重投(best-effort):仅 FAILED + 可重投的图像类(基础资产 / 独立生图)。
|
||||
其余类型(脚本 / 故事板 / 视频 / 配音 / 导出)请到对应页面重跑,这里 400 不冒险误投。"""
|
||||
task = AITask.objects.filter(id=task_id).first()
|
||||
if task is None:
|
||||
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
if task.status != AITask.Status.FAILED:
|
||||
return Response({"detail": "仅失败任务可重投"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
image_types = {AITask.Type.PRODUCT_IMAGE, AITask.Type.PERSON_IMAGE, AITask.Type.SCENE_IMAGE}
|
||||
if task.task_type not in image_types:
|
||||
return Response({"detail": "该任务类型暂不支持后台重投,请到对应页面重新生成"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
from apps.ai.tasks import generate_base_asset_task, generate_standalone_image_task
|
||||
|
||||
try:
|
||||
if task.project_id:
|
||||
generate_base_asset_task.delay(str(task.id))
|
||||
else:
|
||||
generate_standalone_image_task.delay(str(task.id))
|
||||
except Exception: # noqa: BLE001 — 投递失败如实返回,不静默
|
||||
return Response({"detail": "重投调度失败,请确认 worker 在线"}, status=status.HTTP_502_BAD_GATEWAY)
|
||||
|
||||
log_admin_action(
|
||||
request,
|
||||
"task.retry",
|
||||
target_type="ai_task",
|
||||
target_id=task.id,
|
||||
target_name=task.task_type,
|
||||
)
|
||||
return Response({"retried": True, "task_id": str(task.id)})
|
||||
|
||||
@@ -236,3 +236,23 @@
|
||||
z-index: 50;
|
||||
}
|
||||
.bulk-count { font-size: 13px; color: var(--accent-black); font-weight: 500; }
|
||||
|
||||
/* ── 任务监控 ── */
|
||||
.admin-anomaly-chip { height: 28px; padding: 0 14px; font-size: 12.5px; cursor: pointer; }
|
||||
.admin-drawer { width: 620px; max-width: 92vw; }
|
||||
.admin-json {
|
||||
background: var(--background-lighter);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
padding: 12px 14px;
|
||||
margin: 0 0 16px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--black-alpha-72, var(--accent-black));
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
.admin-json-err { color: var(--accent-crimson); background: var(--crimson-bg); border-color: var(--crimson-bd); }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
AdminQualityWord,
|
||||
AdminReviewAsset,
|
||||
AdminTask,
|
||||
AdminTaskDetail,
|
||||
AdminTeam,
|
||||
AdminTeamDetail,
|
||||
AdminUser,
|
||||
@@ -599,5 +601,22 @@ export const adminApi = {
|
||||
"/api/admin/asset-reviews/poll/",
|
||||
{ method: "POST", body: JSON.stringify(assetIds ? { asset_ids: assetIds } : {}) }
|
||||
);
|
||||
},
|
||||
tasks(params?: { status?: string; task_type?: string; team?: string; anomaly?: string; page?: number; page_size?: number }) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.status) qs.set("status", params.status);
|
||||
if (params?.task_type) qs.set("task_type", params.task_type);
|
||||
if (params?.team) qs.set("team", params.team);
|
||||
if (params?.anomaly) qs.set("anomaly", params.anomaly);
|
||||
if (params?.page) qs.set("page", String(params.page));
|
||||
if (params?.page_size) qs.set("page_size", String(params.page_size));
|
||||
const q = qs.toString();
|
||||
return request<Paginated<AdminTask>>(`/api/admin/tasks/${q ? `?${q}` : ""}`);
|
||||
},
|
||||
taskDetail(id: string) {
|
||||
return request<AdminTaskDetail>(`/api/admin/tasks/${id}/`);
|
||||
},
|
||||
retryTask(id: string) {
|
||||
return request<{ retried: boolean; task_id: string }>(`/api/admin/tasks/${id}/retry/`, { method: "POST" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { NavigateFn } from "../route-config";
|
||||
import { AdminInvitesPage } from "./admin-invites";
|
||||
import { AdminQualityPage } from "./admin-quality";
|
||||
import { AdminReviewsPage } from "./admin-reviews";
|
||||
import { AdminTasksPage } from "./admin-tasks";
|
||||
import { AdminTeamsPage, AdminUsersPage } from "./admin-teams-users";
|
||||
|
||||
export type AdminNotify = (type: "success" | "error" | "info", text: string) => void;
|
||||
@@ -182,6 +183,9 @@ function AdminSectionView({ section, navigateAdmin, notify }: { section: AdminSe
|
||||
if (section.slug === "reviews") {
|
||||
return <AdminReviewsPage notify={notify} />;
|
||||
}
|
||||
if (section.slug === "tasks") {
|
||||
return <AdminTasksPage notify={notify} />;
|
||||
}
|
||||
// 其余模块在各自阶段替换此占位为真实页面
|
||||
return <AdminPlaceholder section={section} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Activity, X } from "lucide-react";
|
||||
import { adminApi } from "../../api";
|
||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
||||
import type { AdminTask, AdminTaskDetail } from "../../types";
|
||||
|
||||
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
||||
|
||||
const TABS = [
|
||||
{ key: "", label: "全部" },
|
||||
{ key: "failed", label: "失败" },
|
||||
{ key: "succeeded", label: "成功" }
|
||||
];
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}/${p(d.getMonth() + 1)}/${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function statusPill(status: string) {
|
||||
if (status === "succeeded") return <span className="pill ok"><span className="dot" />成功</span>;
|
||||
if (status === "failed") return <span className="pill err"><span className="dot" />失败</span>;
|
||||
if (["cancelled", "compensating"].includes(status)) return <span className="pill neutral"><span className="dot" />{status}</span>;
|
||||
return <span className="pill info"><span className="dot" />进行中</span>;
|
||||
}
|
||||
|
||||
export function AdminTasksPage({ notify }: { notify: Notify }) {
|
||||
const [tasks, setTasks] = useState<AdminTask[]>([]);
|
||||
const [count, setCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState("");
|
||||
const [anomalyOnly, setAnomalyOnly] = useState(false);
|
||||
const [detail, setDetail] = useState<AdminTaskDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminApi.tasks({ status: tab || undefined, anomaly: anomalyOnly ? "1" : undefined, page_size: 60 });
|
||||
setTasks(res.results);
|
||||
setCount(res.count);
|
||||
} catch {
|
||||
notify("error", "加载任务失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tab, anomalyOnly]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
setDetailLoading(true);
|
||||
setDetail(null);
|
||||
try {
|
||||
setDetail(await adminApi.taskDetail(id));
|
||||
} catch {
|
||||
notify("error", "加载详情失败");
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function retry(t: AdminTask) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await adminApi.retryTask(t.id);
|
||||
notify("success", "已重投,稍后刷新查看");
|
||||
await load();
|
||||
} catch (e) {
|
||||
notify("error", e instanceof Error ? e.message : "重投失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>任务监控</h1>
|
||||
<div className="sub"><span className="mono">// {count} 个任务</span> · 全局 AI 任务 · 成本异常 · 失败重投</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-toolbar">
|
||||
<div className="tabs-sub">
|
||||
{TABS.map((t) => (
|
||||
<button key={t.key || "all"} type="button" className={`tab-sub${tab === t.key ? " active" : ""}`} onClick={() => setTab(t.key)}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className={`chip admin-anomaly-chip${anomalyOnly ? " active" : ""}`} onClick={() => setAnomalyOnly((v) => !v)}>
|
||||
仅成本异常
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="activity" size={24} /></div><h3>加载中…</h3><p>// fetching tasks</p></div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="activity" size={24} /></div><h3>暂无任务</h3><p>// no tasks</p></div>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="t admin-table">
|
||||
<thead>
|
||||
<tr><th>类型</th><th>团队</th><th>模型</th><th>状态</th><th>成本(预估→实际)</th><th>时间</th><th className="col-actions">操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((t) => (
|
||||
<tr key={t.id}>
|
||||
<td className="mono admin-code">{t.task_type}</td>
|
||||
<td>{t.team_name || <span className="muted">—</span>}</td>
|
||||
<td>{t.model_name || <span className="muted">—</span>}</td>
|
||||
<td>{statusPill(t.status)}</td>
|
||||
<td className="mono">
|
||||
¥{t.estimated_cost} → ¥{t.actual_cost}
|
||||
{t.cost_anomaly && <span className="pill err admin-inline-pill">异常</span>}
|
||||
</td>
|
||||
<td className="mono col-time">{fmtDate(t.created_at)}</td>
|
||||
<td className="col-actions">
|
||||
<button className="btn btn-sm btn-ghost" type="button" onClick={() => openDetail(t.id)}>详情</button>
|
||||
{t.status === "failed" && (
|
||||
<button className="btn btn-sm btn-ghost" type="button" disabled={busy} onClick={() => retry(t)}>重投</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(detail || detailLoading) && (
|
||||
<div className="drawer-bg show" onClick={(e) => { if (e.target === e.currentTarget) setDetail(null); }}>
|
||||
<div className="drawer admin-drawer show" role="dialog" aria-modal="true" aria-label="任务详情">
|
||||
<div className="drawer-h">
|
||||
<h3><Activity size={15} style={{ verticalAlign: "-2px", marginRight: 6 }} />任务详情</h3>
|
||||
<button className="x" type="button" aria-label="关闭" onClick={() => setDetail(null)}><X size={14} /></button>
|
||||
</div>
|
||||
<div className="drawer-b">
|
||||
{detailLoading || !detail ? (
|
||||
<p className="admin-modal-desc">加载中…</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-detail-meta">
|
||||
<div><span className="k">状态</span><span className="v">{statusPill(detail.status)}</span></div>
|
||||
<div><span className="k">团队</span><span className="v">{detail.team_name || "—"}</span></div>
|
||||
<div><span className="k">模型</span><span className="v">{detail.model_name || "—"}</span></div>
|
||||
<div><span className="k">成本</span><span className="v mono">¥{detail.estimated_cost} → ¥{detail.actual_cost}{detail.cost_anomaly ? " ⚠" : ""}</span></div>
|
||||
</div>
|
||||
{detail.error_message && (
|
||||
<>
|
||||
<div className="admin-detail-subhead">错误</div>
|
||||
<pre className="admin-json admin-json-err">{detail.error_message}</pre>
|
||||
</>
|
||||
)}
|
||||
<div className="admin-detail-subhead">请求 payload</div>
|
||||
<pre className="admin-json">{JSON.stringify(detail.request_payload, null, 2)}</pre>
|
||||
<div className="admin-detail-subhead">响应 payload</div>
|
||||
<pre className="admin-json">{JSON.stringify(detail.response_payload, null, 2)}</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -93,6 +93,28 @@ export type AdminReviewAsset = {
|
||||
preview_url: string;
|
||||
created_at: string;
|
||||
};
|
||||
export type AdminTask = {
|
||||
id: string;
|
||||
task_type: string;
|
||||
status: string;
|
||||
team: string;
|
||||
team_name: string | null;
|
||||
model_name: string | null;
|
||||
estimated_cost: string;
|
||||
actual_cost: string;
|
||||
cost_anomaly: boolean;
|
||||
error_code: string;
|
||||
created_at: string;
|
||||
};
|
||||
export type AdminTaskDetail = AdminTask & {
|
||||
project: string | null;
|
||||
idempotency_key: string;
|
||||
request_payload: Record<string, unknown>;
|
||||
response_payload: Record<string, unknown>;
|
||||
error_message: string;
|
||||
submitted_at: string | null;
|
||||
completed_at: string | null;
|
||||
};
|
||||
|
||||
export type Paginated<T> = {
|
||||
count: number;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Phase 6 e2e:AI 任务监控 —— 列表、失败/成本异常筛选、详情抽屉(payload)、重投按钮在场。
|
||||
// 不点真重投(避免触发真实生成);重投逻辑由后端 mock 单测覆盖。wrapper 预建一条 failed+异常 任务,跑完删。
|
||||
import { chromium } from "playwright";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const BASE = process.env.BASE || "http://127.0.0.1:5188";
|
||||
const API = process.env.API || "http://127.0.0.1:8010";
|
||||
const OUT = path.resolve("output/admin");
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
|
||||
async function apiLogin(u, p) {
|
||||
const res = await fetch(`${API}/api/auth/login/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: u, password: p }) });
|
||||
return res.json();
|
||||
}
|
||||
const admin = await apiLogin("admin", "admin123");
|
||||
|
||||
const r = { page: {}, detail: {}, failedTab: {}, anomaly: {}, consoleErrors: [], pass: false };
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const hook = (p, tag) => {
|
||||
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push(`${tag}:${m.text()}`); });
|
||||
p.on("pageerror", (e) => r.consoleErrors.push(`${tag}:PAGEERR:${e.message}`));
|
||||
};
|
||||
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
await ctx.addInitScript((tk) => localStorage.setItem("airshelf_token", tk), admin.token);
|
||||
const p = await ctx.newPage(); hook(p, "tasks");
|
||||
await p.goto(BASE + "/admin/tasks", { waitUntil: "load" });
|
||||
await p.waitForSelector(".admin-app", { timeout: 12000 });
|
||||
await p.waitForFunction(() => {
|
||||
const t = document.querySelector(".admin-table");
|
||||
const h3 = document.querySelector(".empty-state.show h3");
|
||||
return Boolean(t) || (h3 && h3.textContent !== "加载中…");
|
||||
}, { timeout: 20000 }).catch(() => {});
|
||||
r.page.title = (await p.locator(".content h1").first().innerText().catch(() => "")).trim();
|
||||
r.page.tabs = await p.locator(".tabs-sub .tab-sub").count();
|
||||
r.page.anomalyChip = (await p.locator(".admin-anomaly-chip").count()) >= 1;
|
||||
r.page.rows = await p.locator(".admin-table tbody tr").count();
|
||||
await p.screenshot({ path: path.join(OUT, "p6-tasks-list.png"), fullPage: true });
|
||||
|
||||
// 详情抽屉(第一行)
|
||||
if (r.page.rows >= 1) {
|
||||
await p.locator(".admin-table tbody tr:first-child .btn-ghost").first().click();
|
||||
await p.waitForSelector(".drawer", { timeout: 6000 });
|
||||
await p.waitForTimeout(500);
|
||||
r.detail.drawerShown = (await p.locator(".drawer").count()) >= 1;
|
||||
r.detail.hasJson = (await p.locator(".admin-json").count()) >= 1;
|
||||
await p.screenshot({ path: path.join(OUT, "p6-task-detail.png") });
|
||||
await p.locator(".drawer-bg").click({ position: { x: 12, y: 12 } }); // 点抽屉外遮罩关闭
|
||||
await p.waitForTimeout(400);
|
||||
}
|
||||
|
||||
// 失败筛选 → 该 tab 行(若有)首行带「重投」按钮
|
||||
await p.locator('.tabs-sub .tab-sub:has-text("失败")').click();
|
||||
await p.waitForTimeout(800);
|
||||
r.failedTab.rows = await p.locator(".admin-table tbody tr").count();
|
||||
if (r.failedTab.rows >= 1) {
|
||||
const btns = await p.locator(".admin-table tbody tr:first-child .col-actions .btn").allInnerTexts();
|
||||
r.failedTab.hasRetry = btns.some((t) => t.includes("重投"));
|
||||
}
|
||||
|
||||
// 成本异常筛选
|
||||
await p.locator(".admin-anomaly-chip").click();
|
||||
await p.waitForTimeout(800);
|
||||
r.anomaly.rows = await p.locator(".admin-table tbody tr").count();
|
||||
await p.screenshot({ path: path.join(OUT, "p6-anomaly.png"), fullPage: true });
|
||||
|
||||
await ctx.close();
|
||||
await browser.close();
|
||||
|
||||
const checks = {
|
||||
pageLoads: r.page.title === "任务监控" && r.page.tabs === 3 && r.page.anomalyChip === true,
|
||||
hasRows: r.page.rows >= 1,
|
||||
detailDrawer: r.detail.drawerShown === true && r.detail.hasJson === true,
|
||||
failedRetryBtn: r.failedTab.rows >= 1 && r.failedTab.hasRetry === true,
|
||||
anomalyFilter: r.anomaly.rows >= 1,
|
||||
zeroConsoleErrors: r.consoleErrors.length === 0,
|
||||
};
|
||||
r.checks = checks;
|
||||
r.pass = Object.values(checks).every(Boolean);
|
||||
console.log(JSON.stringify(r, null, 2));
|
||||
fs.writeFileSync(path.join(OUT, "p6-summary.json"), JSON.stringify(r, null, 2));
|
||||
process.exit(r.pass ? 0 : 1);
|
||||
@@ -144,3 +144,23 @@
|
||||
- 无头 e2e:`_admin-p5.mjs`(5188;wrapper 预建 1 条 person 资产保证有行,跑完删除)—— **5 断言全过 + 0 console error**:页面+5筛选 tab+刷新按钮、60 行、多选出 bulk-bar+批量送审可点(**不点真送审**,避免 Volcano 真消耗)、刷新状态(只读 poll)。截图 `output/admin/p5-*.png`。
|
||||
- 类型/构建:`tsc --noEmit` + `npm run build` 全绿。
|
||||
- 视觉:队列表 + 绿/红/灰状态盾,符合 restraint。
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 6 · AI 任务监控 + 成本异常(Admin)— 完成 2026-06-19
|
||||
|
||||
**后端**
|
||||
- `GET tasks/`(全局 AITask,?status/task_type/team/anomaly 筛 + 分页)+ `GET tasks/<id>/`(详情含 request/response payload + error)+ `POST tasks/<id>/retry/`(失败重投)。
|
||||
- 成本异常:`is_cost_anomaly`(实际 > 预估 × 1.5 且预估>0)+ `?anomaly=1` F() 表达式筛;AdminTaskSerializer 暴露 `cost_anomaly`。
|
||||
- 重投 best-effort:仅 FAILED + 图像类(基础资产→generate_base_asset_task / 独立生图→generate_standalone_image_task),其余类型 400(不冒险误投);IsPlatformAdmin + 审计。
|
||||
|
||||
**前端**
|
||||
- `adminApi` tasks/taskDetail/retryTask;`AdminTask`/`AdminTaskDetail` 类型。
|
||||
- Admin「任务监控」页:状态筛 tab(全部/失败/成功)+「仅成本异常」chip + 表格(类型/团队/模型/状态/成本 预估→实际+异常标/时间)+ **详情抽屉**(payload JSON)+ 失败行「重投」。
|
||||
- admin-page.css 补 JSON 块 / 抽屉宽度 / 异常 chip(仅 token)。**修真 bug**:抽屉缺 `.show` 类导致面板停在屏外,补上后正常滑入。
|
||||
|
||||
**测试底座(全过)**
|
||||
- 后端单测:`apps.adminpanel` **36 项 OK**(列权限/状态·类型·异常筛/成本异常标志/详情 payload/重投 dispatch(mock celery)+ 非失败拒 + 不支持类型拒 + 权限 403)。
|
||||
- 无头 e2e:`_admin-p6.mjs`(5188;wrapper 建 1 条 failed+异常任务,跑完删)—— **6 断言全过 + 0 console error**:页面+3tab+异常chip、60 行、详情抽屉含 JSON、失败 tab(43 条真实失败)首行有重投、异常筛精确 1 条;**不点真重投**。截图 `output/admin/p6-*.png`。
|
||||
- 类型/构建:`tsc --noEmit` + `npm run build` 全绿。
|
||||
- 视觉:任务表 + 详情抽屉(成本⚠ / 错误红框 / payload),符合 restraint。
|
||||
|
||||
Reference in New Issue
Block a user