fix(awooop): surface legacy HITL backlog
This commit is contained in:
@@ -20,6 +20,7 @@ from pydantic import BaseModel
|
|||||||
from src.core.config import settings
|
from src.core.config import settings
|
||||||
from src.core.logging import get_logger
|
from src.core.logging import get_logger
|
||||||
from src.core.sse import EventPublisher, EventType, SSEEvent, get_publisher
|
from src.core.sse import EventPublisher, EventType, SSEEvent, get_publisher
|
||||||
|
from src.services.dashboard_metrics_service import fetch_pending_approval_count
|
||||||
from src.services.host_aggregator import AggregatedStatus, HostAggregator
|
from src.services.host_aggregator import AggregatedStatus, HostAggregator
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -141,12 +142,14 @@ async def dashboard_update_loop(publisher: EventPublisher) -> None:
|
|||||||
try:
|
try:
|
||||||
# Fetch aggregated status
|
# Fetch aggregated status
|
||||||
status = await HostAggregator.fetch_all()
|
status = await HostAggregator.fetch_all()
|
||||||
|
pending_approvals = await fetch_pending_approval_count()
|
||||||
|
|
||||||
# Publish to all connected clients
|
# Publish to all connected clients
|
||||||
event = SSEEvent(
|
event = SSEEvent(
|
||||||
type=EventType.HOST_UPDATE,
|
type=EventType.HOST_UPDATE,
|
||||||
data={
|
data={
|
||||||
"overall_status": status.overall_status,
|
"overall_status": status.overall_status,
|
||||||
|
"pending_approvals": pending_approvals,
|
||||||
"hosts": [
|
"hosts": [
|
||||||
{
|
{
|
||||||
"ip": h.ip,
|
"ip": h.ip,
|
||||||
@@ -206,7 +209,9 @@ async def get_dashboard() -> DashboardResponse:
|
|||||||
logger.info("dashboard_fetch")
|
logger.info("dashboard_fetch")
|
||||||
|
|
||||||
status = await HostAggregator.fetch_all()
|
status = await HostAggregator.fetch_all()
|
||||||
return aggregated_to_response(status)
|
response = aggregated_to_response(status)
|
||||||
|
response.pending_approvals = await fetch_pending_approval_count()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dashboard/stream")
|
@router.get("/dashboard/stream")
|
||||||
|
|||||||
31
apps/api/src/services/dashboard_metrics_service.py
Normal file
31
apps/api/src/services/dashboard_metrics_service.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""
|
||||||
|
Dashboard Metrics Service
|
||||||
|
=========================
|
||||||
|
Small DB-backed counters used by the war-room dashboard.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from src.core.logging import get_logger
|
||||||
|
from src.db.base import get_db_context
|
||||||
|
|
||||||
|
logger = get_logger("awoooi.dashboard_metrics")
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_pending_approval_count() -> int:
|
||||||
|
"""Read the live HITL backlog from approval_records."""
|
||||||
|
try:
|
||||||
|
async with get_db_context() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT count(*)
|
||||||
|
FROM approval_records
|
||||||
|
WHERE upper(status::text) = 'PENDING'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return int(result.scalar_one() or 0)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("dashboard_pending_approval_count_failed", error=str(exc))
|
||||||
|
return 0
|
||||||
@@ -1680,6 +1680,29 @@
|
|||||||
"escalate_verification_failure": "Escalate verification failure",
|
"escalate_verification_failure": "Escalate verification failure",
|
||||||
"inspect_degraded_evidence": "Inspect degraded evidence"
|
"inspect_degraded_evidence": "Inspect degraded evidence"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"legacyHitl": {
|
||||||
|
"title": "Legacy HITL Pending",
|
||||||
|
"subtitle": "These items come from approval_records, not AwoooP run approvals. They still need to be visible in the operator console.",
|
||||||
|
"openAuthorizations": "Open Authorizations",
|
||||||
|
"loadFailed": "Failed to load Legacy HITL backlog: {error}",
|
||||||
|
"tableLabel": "Legacy HITL pending approvals",
|
||||||
|
"moreRows": "Showing the latest 8 items. Open Authorizations for the remaining {count}.",
|
||||||
|
"noTelegram": "no TG",
|
||||||
|
"telegramRef": "TG #{id}",
|
||||||
|
"summary": {
|
||||||
|
"pending": "Pending",
|
||||||
|
"noTelegram": "No Telegram message",
|
||||||
|
"observe": "Observe/no action",
|
||||||
|
"critical": "Critical"
|
||||||
|
},
|
||||||
|
"columns": {
|
||||||
|
"risk": "Risk",
|
||||||
|
"action": "Action",
|
||||||
|
"incident": "Incident",
|
||||||
|
"source": "Source",
|
||||||
|
"created": "Created"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
|
|||||||
@@ -1681,6 +1681,29 @@
|
|||||||
"escalate_verification_failure": "升級驗證失敗",
|
"escalate_verification_failure": "升級驗證失敗",
|
||||||
"inspect_degraded_evidence": "檢查降級證據"
|
"inspect_degraded_evidence": "檢查降級證據"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"legacyHitl": {
|
||||||
|
"title": "Legacy HITL 待人工處理",
|
||||||
|
"subtitle": "這批來自 approval_records,不屬於 AwoooP run approval;仍需在前台可見。",
|
||||||
|
"openAuthorizations": "開啟授權中心",
|
||||||
|
"loadFailed": "Legacy HITL backlog 載入失敗:{error}",
|
||||||
|
"tableLabel": "Legacy HITL 待人工處理",
|
||||||
|
"moreRows": "只顯示最新 8 筆,其餘 {count} 筆請到授權中心處理。",
|
||||||
|
"noTelegram": "no TG",
|
||||||
|
"telegramRef": "TG #{id}",
|
||||||
|
"summary": {
|
||||||
|
"pending": "待處理",
|
||||||
|
"noTelegram": "無 Telegram 訊息",
|
||||||
|
"observe": "觀察/無動作",
|
||||||
|
"critical": "Critical"
|
||||||
|
},
|
||||||
|
"columns": {
|
||||||
|
"risk": "風險",
|
||||||
|
"action": "動作",
|
||||||
|
"incident": "事件",
|
||||||
|
"source": "來源",
|
||||||
|
"created": "建立"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
ExternalLink,
|
||||||
Filter,
|
Filter,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
|
MessageSquareWarning,
|
||||||
SearchCheck,
|
SearchCheck,
|
||||||
TriangleAlert,
|
TriangleAlert,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
@@ -65,6 +67,20 @@ interface Approval {
|
|||||||
awooop_status_chain?: AwoooPStatusChain | null;
|
awooop_status_chain?: AwoooPStatusChain | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LegacyApproval {
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
description?: string | null;
|
||||||
|
status: string;
|
||||||
|
risk_level?: string | null;
|
||||||
|
incident_id?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
expires_at?: string | null;
|
||||||
|
telegram_message_id?: number | null;
|
||||||
|
current_signatures?: number | null;
|
||||||
|
required_signatures?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// 常數
|
// 常數
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -94,6 +110,18 @@ function formatRemaining(ms: number): string {
|
|||||||
return `${seconds}s`;
|
return `${seconds}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatLocalTime(value?: string | null): string {
|
||||||
|
if (!value) return "--";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return "--";
|
||||||
|
return date.toLocaleString("zh-TW", {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Sub Components
|
// Sub Components
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -209,6 +237,137 @@ function RemediationEvidenceCell({ summary }: { summary?: RemediationSummary | n
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LegacyHitlBacklogPanel({
|
||||||
|
approvals,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
approvals: LegacyApproval[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}) {
|
||||||
|
const tLegacy = useTranslations("awooop.approvals.legacyHitl");
|
||||||
|
const noTelegramCount = approvals.filter((approval) => !approval.telegram_message_id).length;
|
||||||
|
const criticalCount = approvals.filter(
|
||||||
|
(approval) => approval.risk_level?.toLowerCase() === "critical"
|
||||||
|
).length;
|
||||||
|
const observeCount = approvals.filter((approval) =>
|
||||||
|
/^(OBSERVE|NO_ACTION)/i.test(approval.action)
|
||||||
|
).length;
|
||||||
|
|
||||||
|
if (!loading && approvals.length === 0 && !error) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="border border-[#d9b36f] bg-[#fffaf0]">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-[#ead9b4] bg-[#fff7e8] px-4 py-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<MessageSquareWarning className="mt-0.5 h-5 w-5 text-[#8a5a08]" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-[#141413]">{tLegacy("title")}</h3>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-[#5f5b52]">{tLegacy("subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/authorizations"
|
||||||
|
className="inline-flex items-center gap-2 border border-[#d9b36f] bg-white px-3 py-2 text-xs font-semibold text-[#8a5a08] transition hover:bg-[#fff3d6]"
|
||||||
|
>
|
||||||
|
{tLegacy("openAuthorizations")}
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-px bg-[#ead9b4] md:grid-cols-4">
|
||||||
|
{[
|
||||||
|
[tLegacy("summary.pending"), approvals.length],
|
||||||
|
[tLegacy("summary.noTelegram"), noTelegramCount],
|
||||||
|
[tLegacy("summary.observe"), observeCount],
|
||||||
|
[tLegacy("summary.critical"), criticalCount],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<div key={label} className="bg-white px-4 py-3">
|
||||||
|
<p className="text-xs font-semibold text-[#77736a]">{label}</p>
|
||||||
|
<p className="mt-2 font-mono text-2xl font-semibold text-[#141413]">{value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-2 border-t border-[#ead9b4] bg-[#fff0ef] px-4 py-3 text-xs text-[#9f2f25]">
|
||||||
|
<AlertCircle className="mt-0.5 h-4 w-4" aria-hidden="true" />
|
||||||
|
{tLegacy("loadFailed", { error })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(loading || approvals.length > 0) && (
|
||||||
|
<div className="overflow-x-auto bg-white">
|
||||||
|
<table className="w-full" aria-label={tLegacy("tableLabel")}>
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-[#e0ddd4] bg-[#faf9f3]">
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-[#77736a]">{tLegacy("columns.risk")}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-[#77736a]">{tLegacy("columns.action")}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-[#77736a]">{tLegacy("columns.incident")}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-[#77736a]">{tLegacy("columns.source")}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-[#77736a]">{tLegacy("columns.created")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 4 }).map((_, row) => (
|
||||||
|
<tr key={row} className="border-b border-[#f0ede5]">
|
||||||
|
{Array.from({ length: 5 }).map((_, cell) => (
|
||||||
|
<td key={cell} className="px-4 py-3">
|
||||||
|
<div className="h-4 w-24 animate-pulse rounded bg-[#f0ede5]" />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
approvals.slice(0, 8).map((approval) => {
|
||||||
|
const risk = approval.risk_level?.toLowerCase() ?? "unknown";
|
||||||
|
const riskClass =
|
||||||
|
risk === "critical"
|
||||||
|
? "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]"
|
||||||
|
: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]";
|
||||||
|
return (
|
||||||
|
<tr key={approval.id} className="border-b border-[#f0ede5]">
|
||||||
|
<td className="px-4 py-3 align-top">
|
||||||
|
<span className={cn("inline-flex border px-2 py-0.5 text-xs font-semibold", riskClass)}>
|
||||||
|
{risk}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="max-w-[420px] px-4 py-3 align-top">
|
||||||
|
<p className="truncate text-sm font-semibold text-[#141413]">{approval.action}</p>
|
||||||
|
<p className="mt-1 line-clamp-2 text-xs leading-5 text-[#5f5b52]">
|
||||||
|
{approval.description || "--"}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 align-top font-mono text-xs text-[#5f5b52]">
|
||||||
|
{approval.incident_id || "--"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 align-top text-xs text-[#5f5b52]">
|
||||||
|
{approval.telegram_message_id
|
||||||
|
? tLegacy("telegramRef", { id: approval.telegram_message_id })
|
||||||
|
: tLegacy("noTelegram")}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 align-top text-xs text-[#5f5b52]">
|
||||||
|
{formatLocalTime(approval.created_at)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{!loading && approvals.length > 8 && (
|
||||||
|
<div className="border-t border-[#e0ddd4] bg-[#faf9f3] px-4 py-3 text-xs text-[#5f5b52]">
|
||||||
|
{tLegacy("moreRows", { count: approvals.length - 8 })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type ApprovalSourceFlowStatus = "verified" | "applied" | "evidence" | "provider" | "waiting";
|
type ApprovalSourceFlowStatus = "verified" | "applied" | "evidence" | "provider" | "waiting";
|
||||||
|
|
||||||
function approvalSourceFlowStatus(chain?: AwoooPStatusChain | null): ApprovalSourceFlowStatus {
|
function approvalSourceFlowStatus(chain?: AwoooPStatusChain | null): ApprovalSourceFlowStatus {
|
||||||
@@ -729,24 +888,49 @@ export default function ApprovalsPage() {
|
|||||||
const tEvidence = useTranslations("awooop.listEvidence");
|
const tEvidence = useTranslations("awooop.listEvidence");
|
||||||
const tStatusChain = useTranslations("awooop.statusChain");
|
const tStatusChain = useTranslations("awooop.statusChain");
|
||||||
const [approvals, setApprovals] = useState<Approval[]>([]);
|
const [approvals, setApprovals] = useState<Approval[]>([]);
|
||||||
|
const [legacyApprovals, setLegacyApprovals] = useState<LegacyApproval[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [legacyError, setLegacyError] = useState<string | null>(null);
|
||||||
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
||||||
const [evidenceFilter, setEvidenceFilter] = useState<"" | RemediationStatus>("");
|
const [evidenceFilter, setEvidenceFilter] = useState<"" | RemediationStatus>("");
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
const fetchApprovals = useCallback(async () => {
|
const fetchApprovals = useCallback(async () => {
|
||||||
|
const fetchJson = async (url: string) => {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setLegacyError(null);
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (evidenceFilter) params.set("remediation_status", evidenceFilter);
|
if (evidenceFilter) params.set("remediation_status", evidenceFilter);
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
const res = await fetch(
|
const [platformResult, legacyResult] = await Promise.allSettled([
|
||||||
`${API_BASE}/api/v1/platform/approvals${qs ? `?${qs}` : ""}`
|
fetchJson(`${API_BASE}/api/v1/platform/approvals${qs ? `?${qs}` : ""}`),
|
||||||
);
|
fetchJson(`${API_BASE}/api/v1/approvals/pending`),
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
]);
|
||||||
const data = await res.json();
|
|
||||||
setApprovals(Array.isArray(data.items) ? data.items : []);
|
if (platformResult.status === "fulfilled") {
|
||||||
|
const data = platformResult.value;
|
||||||
|
setApprovals(Array.isArray(data.items) ? data.items : []);
|
||||||
|
} else {
|
||||||
|
setApprovals([]);
|
||||||
|
setError(platformResult.reason instanceof Error ? platformResult.reason.message : "載入失敗");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacyResult.status === "fulfilled") {
|
||||||
|
const data = legacyResult.value;
|
||||||
|
const pending = Array.isArray(data) ? data : data.approvals ?? [];
|
||||||
|
setLegacyApprovals(Array.isArray(pending) ? pending : []);
|
||||||
|
} else {
|
||||||
|
setLegacyApprovals([]);
|
||||||
|
setLegacyError(legacyResult.reason instanceof Error ? legacyResult.reason.message : "載入失敗");
|
||||||
|
}
|
||||||
|
|
||||||
setLastRefresh(new Date());
|
setLastRefresh(new Date());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "載入失敗");
|
setError(err instanceof Error ? err.message : "載入失敗");
|
||||||
@@ -769,6 +953,8 @@ export default function ApprovalsPage() {
|
|||||||
};
|
};
|
||||||
}, [fetchApprovals]);
|
}, [fetchApprovals]);
|
||||||
|
|
||||||
|
const legacyPendingCount = legacyApprovals.length;
|
||||||
|
const totalPendingCount = approvals.length + legacyPendingCount;
|
||||||
const criticalCount = approvals.filter((a) => {
|
const criticalCount = approvals.filter((a) => {
|
||||||
const ms = getRemainingMs(a.timeout_at);
|
const ms = getRemainingMs(a.timeout_at);
|
||||||
return ms !== null && ms <= 5 * 60 * 1000;
|
return ms !== null && ms <= 5 * 60 * 1000;
|
||||||
@@ -790,8 +976,8 @@ export default function ApprovalsPage() {
|
|||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
label: "待人工決策",
|
label: "待人工決策",
|
||||||
value: approvals.length,
|
value: totalPendingCount,
|
||||||
detail: "等待批准 / 駁回的唯一佇列",
|
detail: `AwoooP ${approvals.length} / Legacy HITL ${legacyPendingCount}`,
|
||||||
icon: ShieldCheck,
|
icon: ShieldCheck,
|
||||||
className: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]",
|
className: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]",
|
||||||
},
|
},
|
||||||
@@ -833,11 +1019,13 @@ export default function ApprovalsPage() {
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
approvals.length,
|
approvals.length,
|
||||||
|
legacyPendingCount,
|
||||||
criticalCount,
|
criticalCount,
|
||||||
expiredCount,
|
expiredCount,
|
||||||
mcpObservedCount,
|
mcpObservedCount,
|
||||||
noEvidenceCount,
|
noEvidenceCount,
|
||||||
readOnlyEvidenceCount,
|
readOnlyEvidenceCount,
|
||||||
|
totalPendingCount,
|
||||||
tEvidence,
|
tEvidence,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -910,6 +1098,11 @@ export default function ApprovalsPage() {
|
|||||||
<SecurityOwnerResponseGatePanel />
|
<SecurityOwnerResponseGatePanel />
|
||||||
<GitHubPrimaryReadinessApprovalBoundaryPanel />
|
<GitHubPrimaryReadinessApprovalBoundaryPanel />
|
||||||
<OwnerResponseValidationApprovalBoundaryPanel />
|
<OwnerResponseValidationApprovalBoundaryPanel />
|
||||||
|
<LegacyHitlBacklogPanel
|
||||||
|
approvals={legacyApprovals}
|
||||||
|
loading={loading}
|
||||||
|
error={legacyError}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3 border border-[#e0ddd4] bg-white p-4">
|
<div className="flex flex-wrap items-center gap-3 border border-[#e0ddd4] bg-white p-4">
|
||||||
<Filter className="w-4 h-4 text-muted-foreground flex-shrink-0" aria-hidden="true" />
|
<Filter className="w-4 h-4 text-muted-foreground flex-shrink-0" aria-hidden="true" />
|
||||||
|
|||||||
Reference in New Issue
Block a user