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 rest_framework import serializers
|
||||||
|
|
||||||
from apps.accounts.models import Team, TeamMember, User
|
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
|
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 QualityWordSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -73,3 +84,29 @@ class AdminUserSerializer(serializers.ModelSerializer):
|
|||||||
{"team_id": str(m.team_id), "team_name": m.team.name, "role": m.role}
|
{"team_id": str(m.team_id), "team_name": m.team.name, "role": m.role}
|
||||||
for m in obj.team_memberships.select_related("team").all()
|
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.status_code, 200)
|
||||||
self.assertEqual(r.data["polled"], 1) # 仅 a_proc 处于 processing
|
self.assertEqual(r.data["polled"], 1) # 仅 a_proc 处于 processing
|
||||||
mock_poll.assert_called_once()
|
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_poll,
|
||||||
admin_asset_reviews_submit,
|
admin_asset_reviews_submit,
|
||||||
admin_invitations,
|
admin_invitations,
|
||||||
|
admin_task_detail,
|
||||||
|
admin_task_retry,
|
||||||
|
admin_tasks,
|
||||||
admin_quality_word_detail,
|
admin_quality_word_detail,
|
||||||
admin_quality_words,
|
admin_quality_words,
|
||||||
admin_revoke_invitation,
|
admin_revoke_invitation,
|
||||||
@@ -31,4 +34,7 @@ urlpatterns = [
|
|||||||
path("asset-reviews/", admin_asset_reviews, name="admin-asset-reviews"),
|
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/submit/", admin_asset_reviews_submit, name="admin-asset-reviews-submit"),
|
||||||
path("asset-reviews/poll/", admin_asset_reviews_poll, name="admin-asset-reviews-poll"),
|
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,写操作记审计。"""
|
"""平台超管后台 · 跨团队端点。所有视图统一挂 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 import status
|
||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
from rest_framework.decorators import api_view, permission_classes
|
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.models import Invitation, Team, User
|
||||||
from apps.accounts.permissions import IsPlatformAdmin
|
from apps.accounts.permissions import IsPlatformAdmin
|
||||||
from apps.accounts.serializers import InvitationSerializer
|
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.models import Asset
|
||||||
from apps.assets.review import poll_asset_review, submit_asset_for_review
|
from apps.assets.review import poll_asset_review, submit_asset_for_review
|
||||||
from apps.common.pagination import DefaultPagination
|
from apps.common.pagination import DefaultPagination
|
||||||
|
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
|
COST_ANOMALY_RATIO,
|
||||||
AdminReviewAssetSerializer,
|
AdminReviewAssetSerializer,
|
||||||
|
AdminTaskDetailSerializer,
|
||||||
|
AdminTaskSerializer,
|
||||||
AdminTeamMemberSerializer,
|
AdminTeamMemberSerializer,
|
||||||
AdminTeamSerializer,
|
AdminTeamSerializer,
|
||||||
AdminUserSerializer,
|
AdminUserSerializer,
|
||||||
@@ -326,3 +331,71 @@ def admin_asset_reviews_poll(request):
|
|||||||
for asset in qs:
|
for asset in qs:
|
||||||
statuses[str(asset.id)] = poll_asset_review(asset)
|
statuses[str(asset.id)] = poll_asset_review(asset)
|
||||||
return Response({"polled": len(statuses), "statuses": statuses})
|
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;
|
z-index: 50;
|
||||||
}
|
}
|
||||||
.bulk-count { font-size: 13px; color: var(--accent-black); font-weight: 500; }
|
.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 {
|
import type {
|
||||||
AdminQualityWord,
|
AdminQualityWord,
|
||||||
AdminReviewAsset,
|
AdminReviewAsset,
|
||||||
|
AdminTask,
|
||||||
|
AdminTaskDetail,
|
||||||
AdminTeam,
|
AdminTeam,
|
||||||
AdminTeamDetail,
|
AdminTeamDetail,
|
||||||
AdminUser,
|
AdminUser,
|
||||||
@@ -599,5 +601,22 @@ export const adminApi = {
|
|||||||
"/api/admin/asset-reviews/poll/",
|
"/api/admin/asset-reviews/poll/",
|
||||||
{ method: "POST", body: JSON.stringify(assetIds ? { asset_ids: assetIds } : {}) }
|
{ 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 { AdminInvitesPage } from "./admin-invites";
|
||||||
import { AdminQualityPage } from "./admin-quality";
|
import { AdminQualityPage } from "./admin-quality";
|
||||||
import { AdminReviewsPage } from "./admin-reviews";
|
import { AdminReviewsPage } from "./admin-reviews";
|
||||||
|
import { AdminTasksPage } from "./admin-tasks";
|
||||||
import { AdminTeamsPage, AdminUsersPage } from "./admin-teams-users";
|
import { AdminTeamsPage, AdminUsersPage } from "./admin-teams-users";
|
||||||
|
|
||||||
export type AdminNotify = (type: "success" | "error" | "info", text: string) => void;
|
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") {
|
if (section.slug === "reviews") {
|
||||||
return <AdminReviewsPage notify={notify} />;
|
return <AdminReviewsPage notify={notify} />;
|
||||||
}
|
}
|
||||||
|
if (section.slug === "tasks") {
|
||||||
|
return <AdminTasksPage notify={notify} />;
|
||||||
|
}
|
||||||
// 其余模块在各自阶段替换此占位为真实页面
|
// 其余模块在各自阶段替换此占位为真实页面
|
||||||
return <AdminPlaceholder section={section} />;
|
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;
|
preview_url: string;
|
||||||
created_at: 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> = {
|
export type Paginated<T> = {
|
||||||
count: number;
|
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`。
|
- 无头 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` 全绿。
|
- 类型/构建:`tsc --noEmit` + `npm run build` 全绿。
|
||||||
- 视觉:队列表 + 绿/红/灰状态盾,符合 restraint。
|
- 视觉:队列表 + 绿/红/灰状态盾,符合 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