fix(awooop): reconnect approval decisions to run timeline
This commit is contained in:
@@ -1636,6 +1636,67 @@
|
||||
"timeout": "Timed out",
|
||||
"waitingApproval": "Waiting approval"
|
||||
}
|
||||
},
|
||||
"approvalDecision": {
|
||||
"back": "Back to Approval Queue",
|
||||
"viewTimeline": "View Run Timeline",
|
||||
"eyebrow": "Human Approval Gate",
|
||||
"title": "Approval Decision",
|
||||
"timeout": "Approval Deadline",
|
||||
"empty": "--",
|
||||
"errors": {
|
||||
"title": "Failed to load run data",
|
||||
"loadFailed": "Load failed",
|
||||
"missingProject": "Missing project_id; cannot submit approval decision",
|
||||
"actionFailed": "Action failed"
|
||||
},
|
||||
"success": {
|
||||
"approve": "Run approved. Returning to Timeline",
|
||||
"reject": "Run rejected. Returning to Timeline"
|
||||
},
|
||||
"notWaiting": {
|
||||
"title": "This run is not waiting for human approval",
|
||||
"detail": "Current state is {state}. This page will not show approve / reject; return to Run Timeline for the latest state."
|
||||
},
|
||||
"details": {
|
||||
"title": "Run Details",
|
||||
"runId": "Run ID",
|
||||
"project": "Project",
|
||||
"agent": "Agent",
|
||||
"state": "State",
|
||||
"traceId": "Trace ID",
|
||||
"trigger": "Trigger",
|
||||
"triggerRef": "Trigger Ref",
|
||||
"cost": "Cost",
|
||||
"attempts": "Attempts",
|
||||
"created": "Created",
|
||||
"timeout": "Timeout",
|
||||
"error": "Error",
|
||||
"empty": "Run data was not found."
|
||||
},
|
||||
"actions": {
|
||||
"approve": "Approve",
|
||||
"reject": "Reject"
|
||||
},
|
||||
"dialog": {
|
||||
"close": "Close",
|
||||
"cancel": "Cancel",
|
||||
"runId": "Run ID:",
|
||||
"approve": {
|
||||
"title": "Confirm Approval",
|
||||
"body": "After approval, the run resumes from the human gate and continues through Runtime / MCP Gateway.",
|
||||
"warning": "This decision is written to Run state, approval token, and audit trail.",
|
||||
"confirm": "Confirm Approval"
|
||||
},
|
||||
"reject": {
|
||||
"title": "Confirm Rejection",
|
||||
"body": "After rejection, the run is cancelled and will not continue automatic execution.",
|
||||
"reason": "Rejection reason",
|
||||
"placeholder": "Enter rejection reason...",
|
||||
"warning": "The reason is written to the audit trail for later review in Run Timeline.",
|
||||
"confirm": "Confirm Rejection"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1637,6 +1637,67 @@
|
||||
"timeout": "已超時",
|
||||
"waitingApproval": "等待審批"
|
||||
}
|
||||
},
|
||||
"approvalDecision": {
|
||||
"back": "返回審批佇列",
|
||||
"viewTimeline": "查看 Run Timeline",
|
||||
"eyebrow": "人工審批閘門",
|
||||
"title": "審批決策",
|
||||
"timeout": "審批期限",
|
||||
"empty": "--",
|
||||
"errors": {
|
||||
"title": "無法載入 Run 資料",
|
||||
"loadFailed": "載入失敗",
|
||||
"missingProject": "缺少 project_id,無法送出審批決策",
|
||||
"actionFailed": "操作失敗"
|
||||
},
|
||||
"success": {
|
||||
"approve": "Run 已核准,正在回到 Timeline",
|
||||
"reject": "Run 已拒絕,正在回到 Timeline"
|
||||
},
|
||||
"notWaiting": {
|
||||
"title": "此 Run 目前不在人工審批狀態",
|
||||
"detail": "目前狀態為 {state}。此頁不會顯示 approve / reject,請回 Run Timeline 檢查最新狀態。"
|
||||
},
|
||||
"details": {
|
||||
"title": "Run 詳情",
|
||||
"runId": "Run ID",
|
||||
"project": "Project",
|
||||
"agent": "Agent",
|
||||
"state": "狀態",
|
||||
"traceId": "Trace ID",
|
||||
"trigger": "Trigger",
|
||||
"triggerRef": "Trigger Ref",
|
||||
"cost": "Cost",
|
||||
"attempts": "Attempts",
|
||||
"created": "Created",
|
||||
"timeout": "Timeout",
|
||||
"error": "Error",
|
||||
"empty": "找不到 Run 資料。"
|
||||
},
|
||||
"actions": {
|
||||
"approve": "核准",
|
||||
"reject": "拒絕"
|
||||
},
|
||||
"dialog": {
|
||||
"close": "關閉",
|
||||
"cancel": "取消",
|
||||
"runId": "Run ID:",
|
||||
"approve": {
|
||||
"title": "確認核准",
|
||||
"body": "核准後,Run 會從人工閘門 resume,繼續交由 Runtime / MCP Gateway 執行。",
|
||||
"warning": "此決策會寫入 Run state、approval token 與 audit trail。",
|
||||
"confirm": "確認核准"
|
||||
},
|
||||
"reject": {
|
||||
"title": "確認拒絕",
|
||||
"body": "拒絕後,Run 會被取消,不會繼續自動執行。",
|
||||
"reason": "拒絕原因",
|
||||
"placeholder": "請輸入拒絕原因...",
|
||||
"warning": "拒絕原因會寫入 audit trail,供後續稽核與 Run Timeline 回看。",
|
||||
"confirm": "確認拒絕"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,231 +1,190 @@
|
||||
// =============================================================================
|
||||
// WOOO AIOps - AwoooP M8 審批決策頁面
|
||||
// WOOO AIOps - AwoooP Approval Decision
|
||||
// =============================================================================
|
||||
// 顯示 Run 詳情,Approve / Reject 各需 Dialog 確認
|
||||
// Reject 需填寫原因
|
||||
// 人工審批必須回到 Run State / Timeline,避免 Approval 成為獨立孤島。
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter, Link } from "@/i18n/routing";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import {
|
||||
ShieldCheck,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
X,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
import { Link, useRouter } from "@/i18n/routing";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface RunDetail {
|
||||
run_id: string;
|
||||
project_id: string;
|
||||
agent_id: string;
|
||||
state: string;
|
||||
trace_id?: string | null;
|
||||
trigger_type?: string | null;
|
||||
trigger_ref?: string | null;
|
||||
cost_usd?: number | string;
|
||||
attempt_count?: number;
|
||||
max_attempts?: number;
|
||||
error_code?: string | null;
|
||||
error_detail?: string | null;
|
||||
created_at: string;
|
||||
timeout_at?: string | null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 常數
|
||||
// =============================================================================
|
||||
interface RunDetailResponse {
|
||||
run: RunDetail;
|
||||
}
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
|
||||
|
||||
// =============================================================================
|
||||
// Sub Components
|
||||
// =============================================================================
|
||||
const STATE_STYLE: Record<string, string> = {
|
||||
waiting_approval: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]",
|
||||
running: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]",
|
||||
completed: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]",
|
||||
cancelled: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]",
|
||||
failed: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]",
|
||||
timeout: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]",
|
||||
};
|
||||
|
||||
function formatDate(value: string | null | undefined, locale: string, emptyLabel: string) {
|
||||
if (!value) return emptyLabel;
|
||||
return new Date(value).toLocaleString(locale === "zh-TW" ? "zh-TW" : "en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function runTimelineHref(runId: string, projectId?: string | null) {
|
||||
const query = projectId ? `?project_id=${encodeURIComponent(projectId)}` : "";
|
||||
return `/awooop/runs/${runId}${query}` as never;
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
emptyLabel,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | React.ReactNode;
|
||||
value?: string | number | null | React.ReactNode;
|
||||
emptyLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start py-3 border-b border-border last:border-0">
|
||||
<span className="w-32 flex-shrink-0 text-xs font-medium text-muted-foreground uppercase tracking-wider pt-0.5">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-sm text-foreground font-mono flex-1">{value}</span>
|
||||
<div className="grid gap-1 border-b border-[#eee9dd] py-3 last:border-0 md:grid-cols-[132px_1fr]">
|
||||
<dt className="text-xs font-semibold uppercase text-[#77736a]">{label}</dt>
|
||||
<dd className="min-w-0 break-words font-mono text-sm text-[#141413]">
|
||||
{value ?? emptyLabel}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Approve 確認 Dialog
|
||||
function ApproveDialog({
|
||||
function DecisionDialog({
|
||||
decision,
|
||||
runId,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
loading,
|
||||
}: {
|
||||
runId: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="approve-dialog-title"
|
||||
>
|
||||
<div className="bg-card border border-border rounded-xl shadow-2xl w-full max-w-md">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-900/40 rounded-lg">
|
||||
<CheckCircle className="w-5 h-5 text-green-400" aria-hidden="true" />
|
||||
</div>
|
||||
<h3 id="approve-dialog-title" className="text-base font-semibold text-foreground">
|
||||
確認核准
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-1.5 hover:bg-accent rounded-lg transition"
|
||||
aria-label="關閉"
|
||||
>
|
||||
<X className="w-4 h-4 text-muted-foreground" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-5">
|
||||
<p className="text-sm text-foreground mb-2">確定要核准此 Run?</p>
|
||||
<div className="bg-muted rounded-lg px-3 py-2">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
Run ID:{" "}
|
||||
<span className="text-brand-accent">{runId.slice(0, 8)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-3">
|
||||
核准後,Run 將繼續執行後續步驟。此操作無法撤銷。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 p-5 border-t border-border bg-muted/30 rounded-b-xl">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm text-muted-foreground hover:bg-accent rounded-lg transition disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium bg-green-600 text-white rounded-lg hover:bg-green-700 transition disabled:opacity-50"
|
||||
>
|
||||
{loading && <RefreshCw className="w-3.5 h-3.5 animate-spin" aria-hidden="true" />}
|
||||
<CheckCircle className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
確認核准
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Reject 確認 Dialog(需填原因)
|
||||
function RejectDialog({
|
||||
runId,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
loading,
|
||||
onConfirm,
|
||||
}: {
|
||||
decision: "approve" | "reject";
|
||||
runId: string;
|
||||
onConfirm: (reason: string) => void;
|
||||
onCancel: () => void;
|
||||
loading: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: (reason?: string) => void;
|
||||
}) {
|
||||
const t = useTranslations("awooop.approvalDecision.dialog");
|
||||
const [reason, setReason] = useState("");
|
||||
const isReject = decision === "reject";
|
||||
const titleId = `${decision}-dialog-title`;
|
||||
const tone = isReject
|
||||
? "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]"
|
||||
: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]";
|
||||
const Icon = isReject ? XCircle : CheckCircle;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="reject-dialog-title"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<div className="bg-card border border-border rounded-xl shadow-2xl w-full max-w-md">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-border">
|
||||
<div className="w-full max-w-md border border-[#e0ddd4] bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-[#e0ddd4] bg-[#faf9f3] px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-red-900/40 rounded-lg">
|
||||
<XCircle className="w-5 h-5 text-red-400" aria-hidden="true" />
|
||||
</div>
|
||||
<h3 id="reject-dialog-title" className="text-base font-semibold text-foreground">
|
||||
確認拒絕
|
||||
<span className={cn("flex h-9 w-9 items-center justify-center border", tone)}>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<h3 id={titleId} className="text-base font-semibold text-[#141413]">
|
||||
{t(`${decision}.title` as never)}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-1.5 hover:bg-accent rounded-lg transition"
|
||||
aria-label="關閉"
|
||||
className="flex h-8 w-8 items-center justify-center border border-[#d8d3c7] text-[#5f5b52] hover:border-[#d97757]"
|
||||
aria-label={t("close")}
|
||||
>
|
||||
<X className="w-4 h-4 text-muted-foreground" aria-hidden="true" />
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="bg-muted rounded-lg px-3 py-2">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
Run ID:{" "}
|
||||
<span className="text-red-400">{runId.slice(0, 8)}</span>
|
||||
<div className="space-y-4 px-5 py-4">
|
||||
<p className="text-sm leading-6 text-[#5f5b52]">
|
||||
{t(`${decision}.body` as never)}
|
||||
</p>
|
||||
<div className="border border-[#eee9dd] bg-[#faf9f3] px-3 py-2">
|
||||
<span className="font-mono text-xs text-[#5f5b52]">
|
||||
{t("runId")} <span className="text-[#141413]">{runId.slice(0, 8)}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="reject-reason"
|
||||
className="block text-sm font-medium text-foreground mb-2"
|
||||
>
|
||||
拒絕原因
|
||||
<span className="text-red-400 ml-1">*</span>
|
||||
{isReject && (
|
||||
<label className="block">
|
||||
<span className="text-sm font-semibold text-[#141413]">{t("reject.reason")}</span>
|
||||
<textarea
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
rows={3}
|
||||
placeholder={t("reject.placeholder")}
|
||||
className="mt-2 w-full resize-none border border-[#d8d3c7] bg-white px-3 py-2 text-sm text-[#141413] outline-none focus:border-[#d97757]"
|
||||
/>
|
||||
</label>
|
||||
<textarea
|
||||
id="reject-reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="請輸入拒絕原因..."
|
||||
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-red-500/50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
拒絕後,Run 將被終止。此操作無法撤銷。
|
||||
<p className="text-xs leading-5 text-[#77736a]">
|
||||
{t(`${decision}.warning` as never)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 p-5 border-t border-border bg-muted/30 rounded-b-xl">
|
||||
<div className="flex items-center justify-end gap-3 border-t border-[#e0ddd4] bg-[#faf9f3] px-5 py-4">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm text-muted-foreground hover:bg-accent rounded-lg transition disabled:opacity-50"
|
||||
className="border border-[#d8d3c7] bg-white px-4 py-2 text-sm font-semibold text-[#5f5b52] hover:border-[#d97757] disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onConfirm(reason.trim())}
|
||||
disabled={loading || reason.trim().length === 0}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium bg-red-600 text-white rounded-lg hover:bg-red-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => onConfirm(isReject ? reason.trim() : undefined)}
|
||||
disabled={loading || (isReject && reason.trim().length === 0)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 border px-4 py-2 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-50",
|
||||
tone
|
||||
)}
|
||||
>
|
||||
{loading && <RefreshCw className="w-3.5 h-3.5 animate-spin" aria-hidden="true" />}
|
||||
<XCircle className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
確認拒絕
|
||||
{loading && <RefreshCw className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t(`${decision}.confirm` as never)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,10 +192,6 @@ function RejectDialog({
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Component
|
||||
// =============================================================================
|
||||
|
||||
export default function ApprovalDecisionPage({
|
||||
params,
|
||||
}: {
|
||||
@@ -244,6 +199,10 @@ export default function ApprovalDecisionPage({
|
||||
}) {
|
||||
const { run_id } = params;
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("awooop.approvalDecision");
|
||||
const searchParams = useSearchParams();
|
||||
const projectId = searchParams.get("project_id") ?? "";
|
||||
|
||||
const [run, setRun] = useState<RunDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -251,253 +210,244 @@ export default function ApprovalDecisionPage({
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
|
||||
const [showApproveDialog, setShowApproveDialog] = useState(false);
|
||||
const [showRejectDialog, setShowRejectDialog] = useState(false);
|
||||
const [dialogDecision, setDialogDecision] = useState<"approve" | "reject" | null>(null);
|
||||
|
||||
const timelineHref = useMemo(
|
||||
() => runTimelineHref(run_id, run?.project_id || projectId || undefined),
|
||||
[projectId, run?.project_id, run_id]
|
||||
);
|
||||
|
||||
const fetchRun = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
// 使用 approvals API 取得單筆 run 資訊
|
||||
const res = await fetch(`${API_BASE}/api/v1/platform/approvals?run_id=${run_id}`);
|
||||
const query = new URLSearchParams();
|
||||
if (projectId) query.set("project_id", projectId);
|
||||
const suffix = query.toString() ? `?${query.toString()}` : "";
|
||||
const res = await fetch(`${API_BASE}/api/v1/platform/runs/${run_id}/detail${suffix}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const item = Array.isArray(data.items)
|
||||
? data.items.find((r: RunDetail) => r.run_id === run_id)
|
||||
: null;
|
||||
if (item) {
|
||||
setRun(item);
|
||||
} else {
|
||||
setRun({ run_id, project_id: "--", agent_id: "--", state: "--", created_at: "" });
|
||||
}
|
||||
const data: RunDetailResponse = await res.json();
|
||||
setRun(data.run);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "載入失敗");
|
||||
setError(err instanceof Error ? err.message : t("errors.loadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [run_id]);
|
||||
}, [projectId, run_id, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetchRun();
|
||||
}, [fetchRun]);
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (!run?.project_id || run.project_id === "--") {
|
||||
setActionError("缺少 project_id,無法送出審批決策");
|
||||
setShowApproveDialog(false);
|
||||
const handleDecision = async (decision: "approve" | "reject", reason?: string) => {
|
||||
if (!run?.project_id) {
|
||||
setActionError(t("errors.missingProject"));
|
||||
setDialogDecision(null);
|
||||
return;
|
||||
}
|
||||
setActionLoading(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/v1/platform/approvals/${run_id}/decide`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
project_id: run.project_id,
|
||||
decision: "approve",
|
||||
reason: null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
const res = await fetch(`${API_BASE}/api/v1/platform/approvals/${run_id}/decide`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
project_id: run.project_id,
|
||||
decision,
|
||||
reason: reason ?? null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setActionSuccess("Run 已成功核准");
|
||||
setShowApproveDialog(false);
|
||||
setTimeout(() => router.push("/awooop/approvals"), 1500);
|
||||
setActionSuccess(t(`success.${decision}` as never));
|
||||
setDialogDecision(null);
|
||||
setTimeout(() => router.push(timelineHref), 1200);
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "操作失敗");
|
||||
setShowApproveDialog(false);
|
||||
setActionError(err instanceof Error ? err.message : t("errors.actionFailed"));
|
||||
setDialogDecision(null);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (reason: string) => {
|
||||
if (!run?.project_id || run.project_id === "--") {
|
||||
setActionError("缺少 project_id,無法送出審批決策");
|
||||
setShowRejectDialog(false);
|
||||
return;
|
||||
}
|
||||
setActionLoading(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/v1/platform/approvals/${run_id}/decide`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
project_id: run.project_id,
|
||||
decision: "reject",
|
||||
reason,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setActionSuccess("Run 已拒絕");
|
||||
setShowRejectDialog(false);
|
||||
setTimeout(() => router.push("/awooop/approvals"), 1500);
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "操作失敗");
|
||||
setShowRejectDialog(false);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
const isWaitingApproval = run?.state === "waiting_approval";
|
||||
const stateClass = STATE_STYLE[run?.state ?? ""] ?? "border-[#d8d3c7] bg-white text-[#5f5b52]";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Dialogs */}
|
||||
{showApproveDialog && (
|
||||
<ApproveDialog
|
||||
{dialogDecision && (
|
||||
<DecisionDialog
|
||||
decision={dialogDecision}
|
||||
runId={run_id}
|
||||
onConfirm={handleApprove}
|
||||
onCancel={() => setShowApproveDialog(false)}
|
||||
loading={actionLoading}
|
||||
/>
|
||||
)}
|
||||
{showRejectDialog && (
|
||||
<RejectDialog
|
||||
runId={run_id}
|
||||
onConfirm={handleReject}
|
||||
onCancel={() => setShowRejectDialog(false)}
|
||||
loading={actionLoading}
|
||||
onCancel={() => setDialogDecision(null)}
|
||||
onConfirm={(reason) => handleDecision(dialogDecision, reason)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
{/* Back Link */}
|
||||
<Link
|
||||
href="/awooop/approvals"
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" aria-hidden="true" />
|
||||
返回審批佇列
|
||||
</Link>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<ShieldCheck className="w-5 h-5 text-brand-accent" aria-hidden="true" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">審批決策</h2>
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
{run_id.slice(0, 8)}...
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Link
|
||||
href="/awooop/approvals"
|
||||
className="inline-flex items-center gap-2 text-sm text-[#77736a] hover:text-[#141413]"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||
{t("back")}
|
||||
</Link>
|
||||
<Link
|
||||
href={timelineHref}
|
||||
className="inline-flex items-center gap-2 border border-[#d8d3c7] bg-white px-3 py-2 text-sm font-semibold text-[#5f5b52] hover:border-[#d97757]"
|
||||
>
|
||||
{t("viewTimeline")}
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Error / Success States */}
|
||||
<section className="border border-[#e0ddd4] bg-white">
|
||||
<div className="grid gap-px bg-[#e0ddd4] lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<div className="bg-white p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<ShieldCheck className="h-5 w-5 text-brand-accent" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-[#77736a]">{t("eyebrow")}</p>
|
||||
<h2 className="text-lg font-semibold text-[#141413]">{t("title")}</h2>
|
||||
<p className="mt-1 font-mono text-xs text-[#77736a]">{run_id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-5">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-[#77736a]">
|
||||
<Clock className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("timeout")}
|
||||
</div>
|
||||
<p className="mt-2 font-mono text-sm text-[#141413]">
|
||||
{formatDate(run?.timeout_at, locale, t("empty"))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 p-4 bg-red-900/20 border border-red-800/40 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" aria-hidden="true" />
|
||||
<div className="flex items-start gap-3 border border-[#e2a29b] bg-[#fff0ef] p-4 text-[#9f2f25]">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-300">無法載入 Run 資料</p>
|
||||
<p className="text-xs text-red-400 mt-1">{error}</p>
|
||||
<p className="text-sm font-semibold">{t("errors.title")}</p>
|
||||
<p className="mt-1 text-xs">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionError && (
|
||||
<div className="flex items-start gap-3 p-4 bg-red-900/20 border border-red-800/40 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" aria-hidden="true" />
|
||||
<p className="text-sm text-red-300">{actionError}</p>
|
||||
<div className="flex items-start gap-3 border border-[#e2a29b] bg-[#fff0ef] p-4 text-[#9f2f25]">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
<p className="text-sm">{actionError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionSuccess && (
|
||||
<div className="flex items-center gap-3 p-4 bg-green-900/20 border border-green-800/40 rounded-lg">
|
||||
<CheckCircle className="w-5 h-5 text-green-400 flex-shrink-0" aria-hidden="true" />
|
||||
<p className="text-sm font-medium text-green-300">{actionSuccess}</p>
|
||||
<div className="flex items-center gap-3 border border-[#9bc7a4] bg-[#f0faf2] p-4 text-[#17602a]">
|
||||
<CheckCircle className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
<p className="text-sm font-semibold">{actionSuccess}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Run Detail Card */}
|
||||
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border bg-muted/30">
|
||||
<span className="text-sm font-medium text-foreground">Run 詳情</span>
|
||||
{loading && (
|
||||
<RefreshCw className="w-4 h-4 animate-spin text-muted-foreground" aria-hidden="true" />
|
||||
)}
|
||||
{run && !isWaitingApproval && (
|
||||
<div className="flex items-start gap-3 border border-[#d9b36f] bg-[#fff7e8] p-4 text-[#8a5a08]">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{t("notWaiting.title")}</p>
|
||||
<p className="mt-1 text-xs leading-5">
|
||||
{t("notWaiting.detail", { state: run.state || t("empty") })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="border border-[#e0ddd4] bg-white">
|
||||
<div className="flex items-center justify-between border-b border-[#e0ddd4] bg-[#faf9f3] px-5 py-4">
|
||||
<h3 className="text-sm font-semibold text-[#141413]">{t("details.title")}</h3>
|
||||
{loading && <RefreshCw className="h-4 w-4 animate-spin text-[#77736a]" aria-hidden="true" />}
|
||||
</div>
|
||||
|
||||
<div className="px-5">
|
||||
<dl className="px-5">
|
||||
{loading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center py-3 border-b border-border last:border-0"
|
||||
>
|
||||
<div className="w-32 h-4 bg-muted animate-pulse rounded mr-4" />
|
||||
<div className="h-4 bg-muted animate-pulse rounded w-48" />
|
||||
Array.from({ length: 7 }).map((_, index) => (
|
||||
<div key={index} className="grid gap-3 border-b border-[#eee9dd] py-3 md:grid-cols-[132px_1fr]">
|
||||
<div className="h-4 animate-pulse bg-[#f2efe6]" />
|
||||
<div className="h-4 w-full max-w-md animate-pulse bg-[#f2efe6]" />
|
||||
</div>
|
||||
))
|
||||
) : run ? (
|
||||
<>
|
||||
<DetailRow label="Run ID" value={run.run_id} />
|
||||
<DetailRow label="Project ID" value={run.project_id || "--"} />
|
||||
<DetailRow label="Agent" value={run.agent_id || "--"} />
|
||||
<DetailRow label={t("details.runId")} value={run.run_id} emptyLabel={t("empty")} />
|
||||
<DetailRow label={t("details.project")} value={run.project_id} emptyLabel={t("empty")} />
|
||||
<DetailRow label={t("details.agent")} value={run.agent_id} emptyLabel={t("empty")} />
|
||||
<DetailRow
|
||||
label="狀態"
|
||||
label={t("details.state")}
|
||||
value={
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-yellow-900/40 text-yellow-300 border border-yellow-600/40">
|
||||
{run.state || "--"}
|
||||
<span className={cn("inline-flex border px-2 py-0.5 text-xs font-semibold", stateClass)}>
|
||||
{run.state || t("empty")}
|
||||
</span>
|
||||
}
|
||||
emptyLabel={t("empty")}
|
||||
/>
|
||||
<DetailRow label={t("details.traceId")} value={run.trace_id} emptyLabel={t("empty")} />
|
||||
<DetailRow label={t("details.trigger")} value={run.trigger_type} emptyLabel={t("empty")} />
|
||||
<DetailRow label={t("details.triggerRef")} value={run.trigger_ref} emptyLabel={t("empty")} />
|
||||
<DetailRow
|
||||
label={t("details.cost")}
|
||||
value={run.cost_usd === undefined ? null : `$${Number(run.cost_usd ?? 0).toFixed(4)}`}
|
||||
emptyLabel={t("empty")}
|
||||
/>
|
||||
<DetailRow
|
||||
label="建立時間"
|
||||
label={t("details.attempts")}
|
||||
value={
|
||||
run.created_at
|
||||
? new Date(run.created_at).toLocaleString("zh-TW")
|
||||
: "--"
|
||||
run.attempt_count === undefined || run.max_attempts === undefined
|
||||
? null
|
||||
: `${run.attempt_count}/${run.max_attempts}`
|
||||
}
|
||||
emptyLabel={t("empty")}
|
||||
/>
|
||||
<DetailRow
|
||||
label={t("details.created")}
|
||||
value={formatDate(run.created_at, locale, t("empty"))}
|
||||
emptyLabel={t("empty")}
|
||||
/>
|
||||
<DetailRow
|
||||
label={t("details.timeout")}
|
||||
value={formatDate(run.timeout_at, locale, t("empty"))}
|
||||
emptyLabel={t("empty")}
|
||||
/>
|
||||
<DetailRow
|
||||
label={t("details.error")}
|
||||
value={run.error_detail || run.error_code}
|
||||
emptyLabel={t("empty")}
|
||||
/>
|
||||
{run.timeout_at && (
|
||||
<DetailRow
|
||||
label="超時時間"
|
||||
value={new Date(run.timeout_at).toLocaleString("zh-TW")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-12 text-center text-sm text-[#77736a]">{t("details.empty")}</div>
|
||||
)}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* Action Buttons */}
|
||||
{!loading && run && !actionSuccess && (
|
||||
<div className="flex items-center gap-3">
|
||||
{!loading && run && isWaitingApproval && !actionSuccess && (
|
||||
<section className="grid gap-3 md:grid-cols-2">
|
||||
<button
|
||||
onClick={() => setShowApproveDialog(true)}
|
||||
onClick={() => setDialogDecision("approve")}
|
||||
disabled={actionLoading}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-2 py-3 px-6",
|
||||
"text-sm font-semibold rounded-xl border transition-all duration-150",
|
||||
"bg-green-900/30 text-green-300 border-green-600/40",
|
||||
"hover:bg-green-900/60 hover:border-green-500/60",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-green-500/50"
|
||||
)}
|
||||
className="inline-flex items-center justify-center gap-2 border border-[#9bc7a4] bg-[#f0faf2] px-6 py-3 text-sm font-semibold text-[#17602a] hover:bg-[#e4f6e7] disabled:opacity-50"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" aria-hidden="true" />
|
||||
核准
|
||||
<CheckCircle className="h-4 w-4" aria-hidden="true" />
|
||||
{t("actions.approve")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRejectDialog(true)}
|
||||
onClick={() => setDialogDecision("reject")}
|
||||
disabled={actionLoading}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-2 py-3 px-6",
|
||||
"text-sm font-semibold rounded-xl border transition-all duration-150",
|
||||
"bg-red-900/30 text-red-300 border-red-600/40",
|
||||
"hover:bg-red-900/60 hover:border-red-500/60",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/50"
|
||||
)}
|
||||
className="inline-flex items-center justify-center gap-2 border border-[#e2a29b] bg-[#fff0ef] px-6 py-3 text-sm font-semibold text-[#9f2f25] hover:bg-[#ffe4e1] disabled:opacity-50"
|
||||
>
|
||||
<XCircle className="w-4 h-4" aria-hidden="true" />
|
||||
拒絕
|
||||
<XCircle className="h-4 w-4" aria-hidden="true" />
|
||||
{t("actions.reject")}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user