feat(awooop): surface callback replies on run list
This commit is contained in:
@@ -217,6 +217,9 @@ async def list_runs(
|
||||
"created_at": r.created_at,
|
||||
"timeout_at": r.timeout_at,
|
||||
"remediation_summary": remediation_summaries.get(r.run_id),
|
||||
"callback_reply_summary": _run_callback_reply_summary(
|
||||
outbound_by_run.get(r.run_id, [])
|
||||
),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -382,6 +385,69 @@ def _outbound_timeline_metadata(
|
||||
return metadata
|
||||
|
||||
|
||||
def _run_callback_reply_summary(
|
||||
outbound_messages: list[AwoooPOutboundMessage],
|
||||
) -> dict[str, Any]:
|
||||
"""Summarize Telegram detail/history callback reply delivery for Run List."""
|
||||
callback_rows: list[tuple[AwoooPOutboundMessage, dict[str, Any]]] = []
|
||||
for row in outbound_messages:
|
||||
callback_reply = _outbound_callback_reply(row.source_envelope)
|
||||
if callback_reply:
|
||||
callback_rows.append((row, callback_reply))
|
||||
|
||||
if not callback_rows:
|
||||
return {
|
||||
"schema_version": "awooop_run_callback_reply_summary_v1",
|
||||
"status": "no_callback",
|
||||
"total": 0,
|
||||
"sent": 0,
|
||||
"fallback_sent": 0,
|
||||
"rescue_sent": 0,
|
||||
"failed": 0,
|
||||
"needs_human": False,
|
||||
"latest_status": None,
|
||||
"latest_action": None,
|
||||
"latest_incident_id": None,
|
||||
"latest_at": None,
|
||||
"latest_provider_message_id": None,
|
||||
}
|
||||
|
||||
sorted_rows = sorted(
|
||||
callback_rows,
|
||||
key=lambda item: str(item[0].sent_at or item[0].queued_at or ""),
|
||||
reverse=True,
|
||||
)
|
||||
latest_row, latest_callback = sorted_rows[0]
|
||||
statuses = [
|
||||
str(callback.get("status") or "")
|
||||
for _, callback in sorted_rows
|
||||
]
|
||||
failed = statuses.count("callback_reply_failed")
|
||||
latest_status = str(latest_callback.get("status") or "")
|
||||
summary_status = {
|
||||
"callback_reply_sent": "sent",
|
||||
"callback_reply_fallback_sent": "fallback_sent",
|
||||
"callback_reply_rescue_sent": "rescue_sent",
|
||||
"callback_reply_failed": "failed",
|
||||
}.get(latest_status, "observed")
|
||||
|
||||
return {
|
||||
"schema_version": "awooop_run_callback_reply_summary_v1",
|
||||
"status": summary_status,
|
||||
"total": len(sorted_rows),
|
||||
"sent": statuses.count("callback_reply_sent"),
|
||||
"fallback_sent": statuses.count("callback_reply_fallback_sent"),
|
||||
"rescue_sent": statuses.count("callback_reply_rescue_sent"),
|
||||
"failed": failed,
|
||||
"needs_human": failed > 0 or latest_status == "callback_reply_failed",
|
||||
"latest_status": latest_status or None,
|
||||
"latest_action": latest_callback.get("action"),
|
||||
"latest_incident_id": latest_callback.get("incident_id"),
|
||||
"latest_at": latest_row.sent_at or latest_row.queued_at,
|
||||
"latest_provider_message_id": latest_row.provider_message_id,
|
||||
}
|
||||
|
||||
|
||||
def _mcp_gateway_summary_row(row: AwoooPMcpGatewayAudit) -> dict[str, Any]:
|
||||
"""Convert SQLAlchemy audit rows into the truth-chain summary shape."""
|
||||
return {
|
||||
@@ -441,6 +507,7 @@ def _collect_run_incident_ids(
|
||||
_append_incident_ids_from_text(incident_ids, event.content_redacted)
|
||||
|
||||
for message in outbound_messages:
|
||||
_append_incident_ids_from_source_envelope(incident_ids, message.source_envelope)
|
||||
_append_incident_ids_from_text(incident_ids, message.content_preview)
|
||||
_append_incident_ids_from_text(incident_ids, message.send_error)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from src.services.platform_operator_service import (
|
||||
_remediation_summary_matches_incident_id,
|
||||
_remediation_summary_matches_status,
|
||||
_remediation_timeline_summary,
|
||||
_run_callback_reply_summary,
|
||||
_run_remediation_list_summary,
|
||||
_timeline_sort_key,
|
||||
)
|
||||
@@ -129,6 +130,11 @@ def test_collect_run_incident_ids_reads_source_refs_and_legacy_text() -> None:
|
||||
]
|
||||
outbound_messages = [
|
||||
SimpleNamespace(
|
||||
source_envelope={
|
||||
"source_refs": {
|
||||
"incident_ids": ["INC-20260518-CB0001"],
|
||||
},
|
||||
},
|
||||
content_preview="詳情:INC-20260513-79ED5E",
|
||||
send_error=None,
|
||||
)
|
||||
@@ -140,7 +146,84 @@ def test_collect_run_incident_ids_reads_source_refs_and_legacy_text() -> None:
|
||||
outbound_messages=outbound_messages,
|
||||
)
|
||||
|
||||
assert incident_ids == ["INC-20260514-F85F21", "INC-20260513-79ED5E"]
|
||||
assert incident_ids == [
|
||||
"INC-20260514-F85F21",
|
||||
"INC-20260518-CB0001",
|
||||
"INC-20260513-79ED5E",
|
||||
]
|
||||
|
||||
|
||||
def test_run_callback_reply_summary_marks_latest_fallback() -> None:
|
||||
summary = _run_callback_reply_summary([
|
||||
SimpleNamespace(
|
||||
source_envelope={
|
||||
"callback_reply": {
|
||||
"status": "callback_reply_sent",
|
||||
"action": "detail",
|
||||
"incident_id": "INC-20260513-79ED5E",
|
||||
}
|
||||
},
|
||||
sent_at=datetime(2026, 5, 18, 6, 1, 0),
|
||||
queued_at=datetime(2026, 5, 18, 6, 1, 0),
|
||||
provider_message_id="100",
|
||||
),
|
||||
SimpleNamespace(
|
||||
source_envelope={
|
||||
"callback_reply": {
|
||||
"status": "callback_reply_fallback_sent",
|
||||
"action": "history",
|
||||
"incident_id": "INC-20260513-79ED5E",
|
||||
}
|
||||
},
|
||||
sent_at=datetime(2026, 5, 18, 6, 2, 0),
|
||||
queued_at=datetime(2026, 5, 18, 6, 2, 0),
|
||||
provider_message_id="101",
|
||||
),
|
||||
])
|
||||
|
||||
assert summary["status"] == "fallback_sent"
|
||||
assert summary["total"] == 2
|
||||
assert summary["sent"] == 1
|
||||
assert summary["fallback_sent"] == 1
|
||||
assert summary["latest_action"] == "history"
|
||||
assert summary["latest_incident_id"] == "INC-20260513-79ED5E"
|
||||
assert summary["latest_provider_message_id"] == "101"
|
||||
assert summary["needs_human"] is False
|
||||
|
||||
|
||||
def test_run_callback_reply_summary_marks_failed_as_human_attention() -> None:
|
||||
summary = _run_callback_reply_summary([
|
||||
SimpleNamespace(
|
||||
source_envelope={
|
||||
"callback_reply": {
|
||||
"status": "callback_reply_failed",
|
||||
"action": "detail",
|
||||
"incident_id": "INC-20260513-79ED5E",
|
||||
}
|
||||
},
|
||||
sent_at=None,
|
||||
queued_at=datetime(2026, 5, 18, 6, 3, 0),
|
||||
provider_message_id="telegram_callback_reply:failed",
|
||||
)
|
||||
])
|
||||
|
||||
assert summary["status"] == "failed"
|
||||
assert summary["failed"] == 1
|
||||
assert summary["needs_human"] is True
|
||||
|
||||
|
||||
def test_run_callback_reply_summary_marks_no_callback() -> None:
|
||||
summary = _run_callback_reply_summary([
|
||||
SimpleNamespace(
|
||||
source_envelope={},
|
||||
sent_at=datetime(2026, 5, 18, 6, 1, 0),
|
||||
queued_at=datetime(2026, 5, 18, 6, 1, 0),
|
||||
provider_message_id="100",
|
||||
)
|
||||
])
|
||||
|
||||
assert summary["status"] == "no_callback"
|
||||
assert summary["total"] == 0
|
||||
|
||||
|
||||
def test_remediation_timeline_summary_surfaces_route_and_write_flags() -> None:
|
||||
|
||||
@@ -1820,6 +1820,7 @@
|
||||
},
|
||||
"listEvidence": {
|
||||
"column": "AI Evidence",
|
||||
"callbackColumn": "TG Callback",
|
||||
"count": "{count} dry-runs",
|
||||
"mcpCount": "{count} MCP investigations",
|
||||
"route": "MCP: {route}",
|
||||
@@ -1862,12 +1863,36 @@
|
||||
"manualGateDetail": "AI is stopped at the approval gate and needs approve / reject",
|
||||
"writeObserved": "Write flags",
|
||||
"writeObservedDetail": "Verify whether this is the expected auto-repair result",
|
||||
"callbackObserved": "TG Callback",
|
||||
"callbackObservedDetail": "Detail / history replies are tracked; failed {failed}",
|
||||
"noEvidence": "Missing AI evidence",
|
||||
"noEvidenceDetail": "The list row is not linked to ADR-100 dry-run or MCP evidence yet",
|
||||
"approvalReadOnlyDetail": "Read-only remediation evidence is visible before approval",
|
||||
"approvalNoEvidenceDetail": "Approval still lacks AI evidence; inspect Run Timeline"
|
||||
}
|
||||
},
|
||||
"callbackReply": {
|
||||
"count": "{total} items; fallback {fallback}; failed {failed}",
|
||||
"emptyShort": "No detail / history callback yet",
|
||||
"latest": "{action} · {incidentId}",
|
||||
"needsHuman": "Callback failure needs human review",
|
||||
"statuses": {
|
||||
"noCallback": "No callback",
|
||||
"sent": "Delivered",
|
||||
"fallbackSent": "Fallback delivered",
|
||||
"rescueSent": "Rescue delivered",
|
||||
"failed": "Delivery failed",
|
||||
"observed": "Recorded"
|
||||
},
|
||||
"details": {
|
||||
"noCallback": "This run has no detail / history callback reply evidence yet.",
|
||||
"sent": "The Telegram callback reply was delivered with the original format.",
|
||||
"fallbackSent": "The Telegram HTML reply failed, then plain-text fallback was delivered.",
|
||||
"rescueSent": "The Telegram fallback also failed, then rescue plain text was delivered.",
|
||||
"failed": "The Telegram callback reply ultimately failed to deliver and needs human review.",
|
||||
"observed": "The Telegram callback reply was recorded with a non-standard status."
|
||||
}
|
||||
},
|
||||
"incidentEvidence": {
|
||||
"title": "Incident Evidence",
|
||||
"subtitle": "Telegram, Run, Approval, and Work Item share the same remediation evidence",
|
||||
|
||||
@@ -1821,6 +1821,7 @@
|
||||
},
|
||||
"listEvidence": {
|
||||
"column": "AI 證據",
|
||||
"callbackColumn": "TG Callback",
|
||||
"count": "試跑 {count} 次",
|
||||
"mcpCount": "MCP 調查 {count} 次",
|
||||
"route": "MCP:{route}",
|
||||
@@ -1863,12 +1864,36 @@
|
||||
"manualGateDetail": "AI 已停在 approval gate,需 approve / reject",
|
||||
"writeObserved": "寫入旗標",
|
||||
"writeObservedDetail": "需確認是否為預期自動修復結果",
|
||||
"callbackObserved": "TG Callback",
|
||||
"callbackObservedDetail": "詳情 / 歷史回覆已追蹤;失敗 {failed} 筆",
|
||||
"noEvidence": "缺 AI 證據",
|
||||
"noEvidenceDetail": "列表尚未連到 ADR-100 dry-run 或 MCP evidence",
|
||||
"approvalReadOnlyDetail": "審批前已有只讀補救證據可回看",
|
||||
"approvalNoEvidenceDetail": "審批前仍缺 AI 證據,需進 Run Timeline 檢查"
|
||||
}
|
||||
},
|
||||
"callbackReply": {
|
||||
"count": "{total} 筆;fallback {fallback};失敗 {failed}",
|
||||
"emptyShort": "尚無詳情 / 歷史 callback",
|
||||
"latest": "{action} · {incidentId}",
|
||||
"needsHuman": "Callback 失敗需人工確認",
|
||||
"statuses": {
|
||||
"noCallback": "尚無 Callback",
|
||||
"sent": "已送達",
|
||||
"fallbackSent": "Fallback 已送達",
|
||||
"rescueSent": "救援已送達",
|
||||
"failed": "送達失敗",
|
||||
"observed": "已記錄"
|
||||
},
|
||||
"details": {
|
||||
"noCallback": "此 Run 尚未有詳情 / 歷史 callback reply 證據。",
|
||||
"sent": "Telegram callback reply 已用原格式送達。",
|
||||
"fallbackSent": "Telegram HTML 回覆失敗後,已用純文字 fallback 送達。",
|
||||
"rescueSent": "Telegram fallback 仍失敗後,已用救援純文字送達。",
|
||||
"failed": "Telegram callback reply 最終送達失敗,需人工確認。",
|
||||
"observed": "Telegram callback reply 已記錄,但狀態不屬於標準分類。"
|
||||
}
|
||||
},
|
||||
"incidentEvidence": {
|
||||
"title": "Incident Evidence",
|
||||
"subtitle": "Telegram、Run、Approval 與 Work Item 共用同一組補救證據",
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Cpu,
|
||||
ListChecks,
|
||||
SearchCheck,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
@@ -40,6 +41,13 @@ type RunState =
|
||||
| "timeout";
|
||||
|
||||
type RunLane = "intake" | "diagnosis" | "approval" | "execution" | "done" | "manual";
|
||||
type CallbackReplyStatus =
|
||||
| "no_callback"
|
||||
| "sent"
|
||||
| "fallback_sent"
|
||||
| "rescue_sent"
|
||||
| "failed"
|
||||
| "observed";
|
||||
type RemediationStatus =
|
||||
| "no_evidence"
|
||||
| "mcp_observed"
|
||||
@@ -74,6 +82,22 @@ interface RemediationSummary {
|
||||
latest_mcp_server?: string | null;
|
||||
}
|
||||
|
||||
interface CallbackReplySummary {
|
||||
schema_version?: string;
|
||||
status?: CallbackReplyStatus | string;
|
||||
total?: number;
|
||||
sent?: number;
|
||||
fallback_sent?: number;
|
||||
rescue_sent?: number;
|
||||
failed?: number;
|
||||
needs_human?: boolean;
|
||||
latest_status?: string | null;
|
||||
latest_action?: string | null;
|
||||
latest_incident_id?: string | null;
|
||||
latest_at?: string | null;
|
||||
latest_provider_message_id?: string | null;
|
||||
}
|
||||
|
||||
interface Run {
|
||||
run_id: string;
|
||||
project_id: string;
|
||||
@@ -84,6 +108,7 @@ interface Run {
|
||||
step_count: number;
|
||||
created_at: string;
|
||||
remediation_summary?: RemediationSummary | null;
|
||||
callback_reply_summary?: CallbackReplySummary | null;
|
||||
}
|
||||
|
||||
interface Tenant {
|
||||
@@ -282,6 +307,46 @@ const REMEDIATION_FILTER_OPTIONS: RemediationStatus[] = [
|
||||
"no_evidence",
|
||||
];
|
||||
|
||||
const CALLBACK_REPLY_CONFIG: Record<
|
||||
CallbackReplyStatus,
|
||||
{
|
||||
labelKey: string;
|
||||
detailKey: string;
|
||||
className: string;
|
||||
}
|
||||
> = {
|
||||
no_callback: {
|
||||
labelKey: "statuses.noCallback",
|
||||
detailKey: "details.noCallback",
|
||||
className: "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]",
|
||||
},
|
||||
sent: {
|
||||
labelKey: "statuses.sent",
|
||||
detailKey: "details.sent",
|
||||
className: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]",
|
||||
},
|
||||
fallback_sent: {
|
||||
labelKey: "statuses.fallbackSent",
|
||||
detailKey: "details.fallbackSent",
|
||||
className: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]",
|
||||
},
|
||||
rescue_sent: {
|
||||
labelKey: "statuses.rescueSent",
|
||||
detailKey: "details.rescueSent",
|
||||
className: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]",
|
||||
},
|
||||
failed: {
|
||||
labelKey: "statuses.failed",
|
||||
detailKey: "details.failed",
|
||||
className: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]",
|
||||
},
|
||||
observed: {
|
||||
labelKey: "statuses.observed",
|
||||
detailKey: "details.observed",
|
||||
className: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]",
|
||||
},
|
||||
};
|
||||
|
||||
function getRunLane(state: RunState): RunLane {
|
||||
if (state === "pending") return "intake";
|
||||
if (state === "waiting_tool") return "diagnosis";
|
||||
@@ -355,6 +420,20 @@ function normalizeRemediationStatus(summary?: RemediationSummary | null): Remedi
|
||||
return "no_evidence";
|
||||
}
|
||||
|
||||
function normalizeCallbackReplyStatus(summary?: CallbackReplySummary | null): CallbackReplyStatus {
|
||||
const statusValue = summary?.status;
|
||||
if (
|
||||
statusValue === "sent" ||
|
||||
statusValue === "fallback_sent" ||
|
||||
statusValue === "rescue_sent" ||
|
||||
statusValue === "failed" ||
|
||||
statusValue === "observed"
|
||||
) {
|
||||
return statusValue;
|
||||
}
|
||||
return "no_callback";
|
||||
}
|
||||
|
||||
function RemediationEvidenceCell({ summary }: { summary?: RemediationSummary | null }) {
|
||||
const t = useTranslations("awooop.listEvidence");
|
||||
const status = normalizeRemediationStatus(summary);
|
||||
@@ -399,6 +478,56 @@ function RemediationEvidenceCell({ summary }: { summary?: RemediationSummary | n
|
||||
);
|
||||
}
|
||||
|
||||
function CallbackReplyCell({ summary }: { summary?: CallbackReplySummary | null }) {
|
||||
const t = useTranslations("awooop.callbackReply");
|
||||
const status = normalizeCallbackReplyStatus(summary);
|
||||
const config = CALLBACK_REPLY_CONFIG[status];
|
||||
const total = summary?.total ?? 0;
|
||||
const latestAction = summary?.latest_action ? String(summary.latest_action) : null;
|
||||
const latestIncidentId = summary?.latest_incident_id ? String(summary.latest_incident_id) : null;
|
||||
const countText = total > 0
|
||||
? t("count", {
|
||||
total,
|
||||
failed: summary?.failed ?? 0,
|
||||
fallback: (summary?.fallback_sent ?? 0) + (summary?.rescue_sent ?? 0),
|
||||
})
|
||||
: t("emptyShort");
|
||||
const latestText = latestAction || latestIncidentId
|
||||
? t("latest", {
|
||||
action: latestAction ?? "--",
|
||||
incidentId: latestIncidentId ?? "--",
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[210px] flex-col gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex w-fit items-center gap-1.5 border px-2 py-0.5 text-xs font-semibold",
|
||||
config.className
|
||||
)}
|
||||
title={t(config.detailKey)}
|
||||
>
|
||||
<Send className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t(config.labelKey)}
|
||||
</span>
|
||||
<span className={cn("text-xs leading-5", total > 0 ? "text-[#5f5b52]" : "text-[#77736a]")}>
|
||||
{countText}
|
||||
</span>
|
||||
{latestText && (
|
||||
<span className="truncate font-mono text-xs text-[#77736a]">
|
||||
{latestText}
|
||||
</span>
|
||||
)}
|
||||
{summary?.needs_human && (
|
||||
<span className="text-xs font-semibold text-[#9f2f25]">
|
||||
{t("needsHuman")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function linkedIncidentIds(summary?: RemediationSummary | null): string[] {
|
||||
const rawIds = summary?.incident_ids ?? [];
|
||||
return Array.from(
|
||||
@@ -492,6 +621,9 @@ function RunRow({ run }: { run: Run }) {
|
||||
<td className="px-4 py-3">
|
||||
<RemediationEvidenceCell summary={run.remediation_summary} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<CallbackReplyCell summary={run.callback_reply_summary} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<ShadowBadge isShadow={run.is_shadow} />
|
||||
</td>
|
||||
@@ -707,6 +839,12 @@ export default function RunsPage() {
|
||||
(run) => normalizeRemediationStatus(run.remediation_summary) === "no_evidence"
|
||||
).length,
|
||||
manualGate: runs.filter((run) => run.remediation_summary?.human_gate_open).length,
|
||||
callbackObserved: runs.filter(
|
||||
(run) => normalizeCallbackReplyStatus(run.callback_reply_summary) !== "no_callback"
|
||||
).length,
|
||||
callbackFailed: runs.filter(
|
||||
(run) => normalizeCallbackReplyStatus(run.callback_reply_summary) === "failed"
|
||||
).length,
|
||||
};
|
||||
}, [runs]);
|
||||
|
||||
@@ -762,7 +900,7 @@ export default function RunsPage() {
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-px border border-[#e0ddd4] bg-[#e0ddd4] md:grid-cols-2 xl:grid-cols-5">
|
||||
<section className="grid gap-px border border-[#e0ddd4] bg-[#e0ddd4] md:grid-cols-2 xl:grid-cols-6">
|
||||
{[
|
||||
{
|
||||
label: tEvidence("summary.mcpObserved"),
|
||||
@@ -792,6 +930,17 @@ export default function RunsPage() {
|
||||
icon: TriangleAlert,
|
||||
className: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]",
|
||||
},
|
||||
{
|
||||
label: tEvidence("summary.callbackObserved"),
|
||||
value: evidenceSummary.callbackObserved,
|
||||
detail: tEvidence("summary.callbackObservedDetail", {
|
||||
failed: evidenceSummary.callbackFailed,
|
||||
}),
|
||||
icon: Send,
|
||||
className: evidenceSummary.callbackFailed > 0
|
||||
? "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]"
|
||||
: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]",
|
||||
},
|
||||
{
|
||||
label: tEvidence("summary.noEvidence"),
|
||||
value: evidenceSummary.noEvidence,
|
||||
@@ -940,6 +1089,9 @@ export default function RunsPage() {
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{tEvidence("column")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{tEvidence("callbackColumn")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Shadow
|
||||
</th>
|
||||
@@ -955,7 +1107,7 @@ export default function RunsPage() {
|
||||
{loading ? (
|
||||
Array.from({ length: 8 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-border">
|
||||
{Array.from({ length: 10 }).map((_, j) => (
|
||||
{Array.from({ length: 11 }).map((_, j) => (
|
||||
<td key={j} className="px-4 py-3">
|
||||
<div className="h-5 bg-muted animate-pulse rounded w-20" />
|
||||
</td>
|
||||
@@ -964,7 +1116,7 @@ export default function RunsPage() {
|
||||
))
|
||||
) : runs.length === 0 && !error ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="px-4 py-16 text-center">
|
||||
<td colSpan={11} className="px-4 py-16 text-center">
|
||||
<Activity className="w-10 h-10 text-muted-foreground/30 mx-auto mb-3" aria-hidden="true" />
|
||||
<p className="text-sm text-muted-foreground">尚無 Run 資料</p>
|
||||
</td>
|
||||
|
||||
Reference in New Issue
Block a user