fix(web): add incident audit timeline to run detail
This commit is contained in:
@@ -4592,6 +4592,29 @@
|
||||
"count": "{count} 筆",
|
||||
"empty": "尚無時間線資料。"
|
||||
},
|
||||
"incidentAudit": {
|
||||
"title": "Incident 稽核時間線",
|
||||
"empty": "尚無 Incident 稽核時間線。",
|
||||
"eventsEmpty": "尚無可顯示的稽核事件。",
|
||||
"stagesTitle": "處理階段",
|
||||
"matchingTitle": "匹配與採用證據",
|
||||
"eventsTitle": "稽核事件",
|
||||
"playbook": "PlayBook / Ansible",
|
||||
"executor": "Executor",
|
||||
"km": "KM",
|
||||
"candidateDetail": "score={score}; state={state}; reasons={reasons}",
|
||||
"matchingEmpty": "尚無 Sentry / SigNoz 候選匹配;原因:{reason}",
|
||||
"status": {
|
||||
"linked": "已連到 Incident timeline",
|
||||
"empty": "尚無 Incident timeline"
|
||||
},
|
||||
"metrics": {
|
||||
"stages": "階段",
|
||||
"events": "事件",
|
||||
"matches": "Direct / Candidate / Applied",
|
||||
"verification": "Final verification"
|
||||
}
|
||||
},
|
||||
"gateway": {
|
||||
"title": "MCP 閘道",
|
||||
"emptyState": "尚無紀錄",
|
||||
@@ -4705,6 +4728,7 @@
|
||||
"completed": "已完成",
|
||||
"error": "錯誤",
|
||||
"failed": "失敗",
|
||||
"info": "資訊",
|
||||
"pending": "待執行",
|
||||
"received": "已接收",
|
||||
"running": "執行中",
|
||||
@@ -4714,6 +4738,7 @@
|
||||
"callbackReplyRescueSent": "Callback 救援",
|
||||
"callbackReplyFailed": "Callback 失敗",
|
||||
"shadow": "Shadow",
|
||||
"skipped": "略過",
|
||||
"success": "成功",
|
||||
"timeout": "已超時",
|
||||
"warning": "警告",
|
||||
|
||||
@@ -4592,6 +4592,29 @@
|
||||
"count": "{count} 筆",
|
||||
"empty": "尚無時間線資料。"
|
||||
},
|
||||
"incidentAudit": {
|
||||
"title": "Incident 稽核時間線",
|
||||
"empty": "尚無 Incident 稽核時間線。",
|
||||
"eventsEmpty": "尚無可顯示的稽核事件。",
|
||||
"stagesTitle": "處理階段",
|
||||
"matchingTitle": "匹配與採用證據",
|
||||
"eventsTitle": "稽核事件",
|
||||
"playbook": "PlayBook / Ansible",
|
||||
"executor": "Executor",
|
||||
"km": "KM",
|
||||
"candidateDetail": "score={score}; state={state}; reasons={reasons}",
|
||||
"matchingEmpty": "尚無 Sentry / SigNoz 候選匹配;原因:{reason}",
|
||||
"status": {
|
||||
"linked": "已連到 Incident timeline",
|
||||
"empty": "尚無 Incident timeline"
|
||||
},
|
||||
"metrics": {
|
||||
"stages": "階段",
|
||||
"events": "事件",
|
||||
"matches": "Direct / Candidate / Applied",
|
||||
"verification": "Final verification"
|
||||
}
|
||||
},
|
||||
"gateway": {
|
||||
"title": "MCP 閘道",
|
||||
"emptyState": "尚無紀錄",
|
||||
@@ -4705,6 +4728,7 @@
|
||||
"completed": "已完成",
|
||||
"error": "錯誤",
|
||||
"failed": "失敗",
|
||||
"info": "資訊",
|
||||
"pending": "待執行",
|
||||
"received": "已接收",
|
||||
"running": "執行中",
|
||||
@@ -4714,6 +4738,7 @@
|
||||
"callbackReplyRescueSent": "Callback 救援",
|
||||
"callbackReplyFailed": "Callback 失敗",
|
||||
"shadow": "Shadow",
|
||||
"skipped": "略過",
|
||||
"success": "成功",
|
||||
"timeout": "已超時",
|
||||
"warning": "警告",
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
BookOpenCheck,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FileSearch,
|
||||
@@ -217,6 +218,38 @@ interface RunDetailResponse {
|
||||
};
|
||||
}
|
||||
|
||||
interface IncidentTimelineEvent {
|
||||
stage: string;
|
||||
status: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
actor?: string | null;
|
||||
timestamp?: string | null;
|
||||
source_table?: string | null;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface IncidentTimelineStage extends IncidentTimelineEvent {
|
||||
label: string;
|
||||
events?: IncidentTimelineEvent[];
|
||||
}
|
||||
|
||||
interface IncidentTimelineResponse {
|
||||
incident_id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
severity: string;
|
||||
started_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
resolved_at?: string | null;
|
||||
affected_services?: string[];
|
||||
approval_ids?: string[];
|
||||
timeline: IncidentTimelineStage[];
|
||||
events: IncidentTimelineEvent[];
|
||||
ascii_timeline: string;
|
||||
reconciliation?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
|
||||
const AUTO_REFRESH_INTERVAL = 30_000;
|
||||
|
||||
@@ -275,8 +308,10 @@ const STATUS_STYLE: Record<string, string> = {
|
||||
callback_reply_fallback_sent: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]",
|
||||
callback_reply_rescue_sent: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]",
|
||||
callback_reply_failed: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]",
|
||||
info: "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]",
|
||||
running: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]",
|
||||
received: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]",
|
||||
skipped: "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]",
|
||||
waiting_approval: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]",
|
||||
pending: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]",
|
||||
shadow: "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]",
|
||||
@@ -294,6 +329,7 @@ const STATUS_TRANSLATION_KEYS: Record<string, string> = {
|
||||
completed: "statuses.completed",
|
||||
error: "statuses.error",
|
||||
failed: "statuses.failed",
|
||||
info: "statuses.info",
|
||||
pending: "statuses.pending",
|
||||
received: "statuses.received",
|
||||
running: "statuses.running",
|
||||
@@ -303,6 +339,7 @@ const STATUS_TRANSLATION_KEYS: Record<string, string> = {
|
||||
callback_reply_rescue_sent: "statuses.callbackReplyRescueSent",
|
||||
callback_reply_failed: "statuses.callbackReplyFailed",
|
||||
shadow: "statuses.shadow",
|
||||
skipped: "statuses.skipped",
|
||||
success: "statuses.success",
|
||||
timeout: "statuses.timeout",
|
||||
waiting_approval: "statuses.waitingApproval",
|
||||
@@ -372,6 +409,15 @@ function booleanLabel(value: boolean | null | undefined, emptyLabel: string) {
|
||||
return emptyLabel;
|
||||
}
|
||||
|
||||
function metadataValue(data: Record<string, unknown> | undefined, keys: string[]) {
|
||||
if (!data) return null;
|
||||
for (const key of keys) {
|
||||
const value = data[key];
|
||||
if (value !== null && value !== undefined && value !== "") return String(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function RunActionPanel({
|
||||
run,
|
||||
counts,
|
||||
@@ -911,6 +957,232 @@ function RemediationEvidencePanel({
|
||||
);
|
||||
}
|
||||
|
||||
function IncidentAuditTimelinePanel({
|
||||
timeline,
|
||||
chain,
|
||||
loading,
|
||||
locale,
|
||||
emptyLabel,
|
||||
statusLabel,
|
||||
}: {
|
||||
timeline?: IncidentTimelineResponse | null;
|
||||
chain?: AwoooPStatusChain | null;
|
||||
loading: boolean;
|
||||
locale: string;
|
||||
emptyLabel: string;
|
||||
statusLabel: (status: string) => string;
|
||||
}) {
|
||||
const t = useTranslations("runDetail.incidentAudit");
|
||||
const incidentId = chain?.source_id ?? timeline?.incident_id ?? null;
|
||||
const stages = timeline?.timeline?.filter((stage) => stage.status !== "skipped") ?? [];
|
||||
const sourceCorrelation = chain?.source_refs?.correlation;
|
||||
const candidates = sourceCorrelation?.top_candidates ?? [];
|
||||
const ansible = chain?.execution?.ansible;
|
||||
const verifier = timeline?.timeline?.find((stage) => stage.stage === "verifier");
|
||||
const kmStage = timeline?.timeline?.find((stage) => stage.stage === "km");
|
||||
const executor = timeline?.timeline?.find((stage) => stage.stage === "executor");
|
||||
const sourceReason = sourceCorrelation?.missing_reason ?? emptyLabel;
|
||||
const selectedPlaybook = chain?.execution?.playbook_paths?.[0]
|
||||
?? chain?.execution?.playbook_ids?.[0]
|
||||
?? ansible?.latest_catalog_id
|
||||
?? ansible?.latest_playbook_path
|
||||
?? ansible?.candidate_playbooks?.[0]?.playbook_path
|
||||
?? ansible?.candidate_playbooks?.[0]?.catalog_id
|
||||
?? emptyLabel;
|
||||
const importantEvents = (timeline?.events ?? [])
|
||||
.filter((event) => (
|
||||
event.source_table === "automation_operation_log"
|
||||
|| event.source_table === "knowledge_entries"
|
||||
|| event.source_table === "incident_evidence"
|
||||
|| event.source_table === "alert_operation_log"
|
||||
|| event.stage === "verifier"
|
||||
|| event.stage === "km"
|
||||
|| event.stage === "executor"
|
||||
|| event.stage === "ai_router"
|
||||
))
|
||||
.slice(-8)
|
||||
.reverse();
|
||||
const metrics = [
|
||||
{ label: t("metrics.stages"), value: stages.length },
|
||||
{ label: t("metrics.events"), value: timeline?.events?.length ?? 0 },
|
||||
{
|
||||
label: t("metrics.matches"),
|
||||
value: `${sourceCorrelation?.direct_ref_total ?? 0}/${sourceCorrelation?.candidate_total ?? 0}/${sourceCorrelation?.applied_link_total ?? 0}`,
|
||||
},
|
||||
{ label: t("metrics.verification"), value: verifier?.status ?? chain?.verification ?? emptyLabel },
|
||||
];
|
||||
|
||||
if (loading && !timeline) {
|
||||
return (
|
||||
<section className="border border-[#e0ddd4] bg-white">
|
||||
<div className="border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
|
||||
<div className="h-5 w-48 animate-pulse bg-[#f2efe6]" />
|
||||
</div>
|
||||
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="bg-white px-4 py-3">
|
||||
<div className="h-4 w-24 animate-pulse bg-[#f2efe6]" />
|
||||
<div className="mt-2 h-7 w-16 animate-pulse bg-[#f2efe6]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="border border-[#e0ddd4] bg-white">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileSearch className="h-4 w-4 text-brand-accent" aria-hidden="true" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[#141413]">{t("title")}</h3>
|
||||
<p className="mt-1 font-mono text-xs text-[#77736a]">{incidentId ?? emptyLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn("border px-2 py-0.5 text-xs font-semibold", timeline ? statusClass("received") : statusClass("pending"))}>
|
||||
{timeline ? t("status.linked") : t("status.empty")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-4">
|
||||
{metrics.map((item) => (
|
||||
<div key={item.label} className="min-w-0 bg-white px-4 py-3">
|
||||
<p className="text-xs font-semibold text-[#77736a]">{item.label}</p>
|
||||
<p className="mt-2 truncate font-mono text-xl font-semibold text-[#141413]" title={String(item.value)}>
|
||||
{item.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-px bg-[#e0ddd4] lg:grid-cols-[1fr_1fr]">
|
||||
<div className="min-w-0 bg-white p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Route className="h-4 w-4 text-brand-accent" aria-hidden="true" />
|
||||
<h4 className="text-sm font-semibold text-[#141413]">{t("stagesTitle")}</h4>
|
||||
</div>
|
||||
{stages.length > 0 ? (
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||
{stages.slice(0, 10).map((stage) => (
|
||||
<div key={stage.stage} className="min-w-0 border border-[#eee9dd] bg-[#faf9f3] px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-xs font-semibold text-[#77736a]">{stage.label}</span>
|
||||
<span className={cn("shrink-0 border px-2 py-0.5 text-[11px] font-semibold", statusClass(stage.status))}>
|
||||
{statusLabel(stage.status)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 truncate text-sm font-semibold text-[#141413]" title={stage.title}>
|
||||
{stage.title}
|
||||
</p>
|
||||
<p className="mt-1 truncate font-mono text-xs text-[#77736a]" title={stage.source_table ?? emptyLabel}>
|
||||
{stage.source_table ?? emptyLabel}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-[#77736a]">{t("empty")}</p>
|
||||
)}
|
||||
{timeline?.ascii_timeline && (
|
||||
<p className="mt-3 break-words border border-[#eee9dd] bg-white px-3 py-2 font-mono text-xs leading-5 text-[#5f5b52]">
|
||||
{timeline.ascii_timeline}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 bg-white p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SearchCheck className="h-4 w-4 text-brand-accent" aria-hidden="true" />
|
||||
<h4 className="text-sm font-semibold text-[#141413]">{t("matchingTitle")}</h4>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-px border border-[#e0ddd4] bg-[#e0ddd4] md:grid-cols-3">
|
||||
<div className="min-w-0 bg-white px-3 py-2">
|
||||
<p className="text-xs font-semibold text-[#77736a]">{t("playbook")}</p>
|
||||
<p className="mt-1 truncate font-mono text-xs text-[#141413]" title={selectedPlaybook}>{selectedPlaybook}</p>
|
||||
</div>
|
||||
<div className="min-w-0 bg-white px-3 py-2">
|
||||
<p className="text-xs font-semibold text-[#77736a]">{t("executor")}</p>
|
||||
<p className="mt-1 truncate font-mono text-xs text-[#141413]" title={executor?.title ?? emptyLabel}>{executor?.status ?? emptyLabel}</p>
|
||||
</div>
|
||||
<div className="min-w-0 bg-white px-3 py-2">
|
||||
<p className="text-xs font-semibold text-[#77736a]">{t("km")}</p>
|
||||
<p className="mt-1 truncate font-mono text-xs text-[#141413]" title={kmStage?.title ?? emptyLabel}>{kmStage?.status ?? emptyLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{candidates.length > 0 ? (
|
||||
<div className="mt-3 divide-y divide-[#eee9dd] border border-[#eee9dd]">
|
||||
{candidates.slice(0, 3).map((candidate) => (
|
||||
<div key={`${candidate.provider}:${candidate.provider_event_id}`} className="grid gap-2 px-3 py-3 md:grid-cols-[96px_1fr]">
|
||||
<span className="font-mono text-xs font-semibold text-[#5f5b52]">{candidate.provider ?? emptyLabel}</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-mono text-xs font-semibold text-[#141413]" title={candidate.provider_event_id ?? emptyLabel}>
|
||||
{candidate.provider_event_id ?? emptyLabel}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-xs text-[#77736a]" title={(candidate.reasons ?? []).join(", ")}>
|
||||
{t("candidateDetail", {
|
||||
score: candidate.score ?? 0,
|
||||
state: candidate.link_state ?? emptyLabel,
|
||||
reasons: (candidate.reasons ?? []).join(", ") || emptyLabel,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 border border-[#eee9dd] bg-[#faf9f3] px-3 py-3 text-sm leading-6 text-[#5f5b52]">
|
||||
{t("matchingEmpty", { reason: sourceReason })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#e0ddd4] bg-[#faf9f3] px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpenCheck className="h-4 w-4 text-brand-accent" aria-hidden="true" />
|
||||
<h4 className="text-sm font-semibold text-[#141413]">{t("eventsTitle")}</h4>
|
||||
</div>
|
||||
</div>
|
||||
{importantEvents.length > 0 ? (
|
||||
<div className="divide-y divide-[#eee9dd]">
|
||||
{importantEvents.map((event, index) => {
|
||||
const primaryMeta = metadataValue(event.data, ["operation_type", "verification_result", "playbook_id", "matched_playbook_id"]);
|
||||
const secondaryMeta = metadataValue(event.data, ["status", "duration_ms", "execution_kind", "repair_executed"]);
|
||||
return (
|
||||
<article key={`${event.stage}-${event.timestamp}-${index}`} className="grid gap-3 px-4 py-3 md:grid-cols-[132px_1fr]">
|
||||
<div className="font-mono text-xs text-[#77736a]">
|
||||
{formatTime(event.timestamp, locale, emptyLabel)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={cn("border px-2 py-0.5 text-xs font-semibold", statusClass(event.status))}>
|
||||
{statusLabel(event.status)}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-[#77736a]">{event.source_table ?? emptyLabel}</span>
|
||||
</div>
|
||||
<p className="mt-2 break-words text-sm font-semibold text-[#141413]">{event.title}</p>
|
||||
{event.description && (
|
||||
<p className="mt-1 line-clamp-2 text-sm leading-6 text-[#5f5b52]">{event.description}</p>
|
||||
)}
|
||||
{(primaryMeta || secondaryMeta) && (
|
||||
<p className="mt-2 truncate font-mono text-xs text-[#77736a]" title={[primaryMeta, secondaryMeta].filter(Boolean).join(" / ")}>
|
||||
{[primaryMeta, secondaryMeta].filter(Boolean).join(" / ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-8 text-sm text-[#77736a]">{t("eventsEmpty")}</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineRow({
|
||||
item,
|
||||
locale,
|
||||
@@ -973,6 +1245,8 @@ export default function RunDetailPage({
|
||||
|
||||
const [detail, setDetail] = useState<RunDetailResponse | null>(null);
|
||||
const [dossier, setDossier] = useState<ChannelEventDossierResponse | null>(null);
|
||||
const [incidentTimeline, setIncidentTimeline] = useState<IncidentTimelineResponse | null>(null);
|
||||
const [incidentTimelineLoading, setIncidentTimelineLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
||||
@@ -987,6 +1261,23 @@ export default function RunDetailPage({
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: RunDetailResponse = await res.json();
|
||||
setDetail(data);
|
||||
const timelineIncidentId = data.awooop_status_chain?.source_id
|
||||
|| data.remediation_history?.incident_ids?.[0]
|
||||
|| null;
|
||||
if (timelineIncidentId) {
|
||||
setIncidentTimelineLoading(true);
|
||||
try {
|
||||
const timelineRes = await fetch(`${API_BASE}/api/v1/incidents/${encodeURIComponent(timelineIncidentId)}/timeline`);
|
||||
setIncidentTimeline(timelineRes.ok ? await timelineRes.json() as IncidentTimelineResponse : null);
|
||||
} catch {
|
||||
setIncidentTimeline(null);
|
||||
} finally {
|
||||
setIncidentTimelineLoading(false);
|
||||
}
|
||||
} else {
|
||||
setIncidentTimeline(null);
|
||||
setIncidentTimelineLoading(false);
|
||||
}
|
||||
const dossierProjectId = projectId || data.run?.project_id;
|
||||
const dossierQuery = new URLSearchParams();
|
||||
dossierQuery.set("run_id", run_id);
|
||||
@@ -1000,6 +1291,8 @@ export default function RunDetailPage({
|
||||
}
|
||||
setLastRefresh(new Date());
|
||||
} catch (err) {
|
||||
setIncidentTimeline(null);
|
||||
setIncidentTimelineLoading(false);
|
||||
setError(err instanceof Error ? err.message : t("errors.loadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -1114,6 +1407,15 @@ export default function RunDetailPage({
|
||||
|
||||
<AwoooPStatusChainPanel chain={detail?.awooop_status_chain} />
|
||||
|
||||
<IncidentAuditTimelinePanel
|
||||
timeline={incidentTimeline}
|
||||
chain={detail?.awooop_status_chain}
|
||||
loading={incidentTimelineLoading}
|
||||
locale={locale}
|
||||
emptyLabel={t("empty")}
|
||||
statusLabel={statusLabel}
|
||||
/>
|
||||
|
||||
<RunActionPanel run={run} counts={detail?.counts} emptyLabel={t("empty")} />
|
||||
|
||||
<OwnerResponseValidationDetailBoundaryPanel />
|
||||
|
||||
Reference in New Issue
Block a user