337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { Activity, CheckCircle2, Route, ShieldAlert, TriangleAlert } from "lucide-react";
|
|
import { useTranslations } from "next-intl";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export interface AwoooPStatusChain {
|
|
schema_version?: string;
|
|
source?: string;
|
|
source_id?: string | null;
|
|
incident_ids?: string[];
|
|
current_stage?: string | null;
|
|
stage_status?: string | null;
|
|
verdict?: string | null;
|
|
repair_state?: string | null;
|
|
verification?: string | null;
|
|
needs_human?: boolean | null;
|
|
next_step?: string | null;
|
|
blockers?: string[];
|
|
fetch_error?: string | null;
|
|
evidence?: {
|
|
auto_repair_records?: number | null;
|
|
operation_records?: number | null;
|
|
mcp_gateway_total?: number | null;
|
|
knowledge_entries?: number | null;
|
|
remediation_total?: number | null;
|
|
remediation_state?: string | null;
|
|
latest_route?: string | null;
|
|
latest_mode?: string | null;
|
|
latest_at?: string | null;
|
|
latest_preview?: string | null;
|
|
};
|
|
writes?: {
|
|
incident?: boolean | null;
|
|
auto_repair?: boolean | null;
|
|
};
|
|
mcp?: {
|
|
gateway?: {
|
|
total?: number | null;
|
|
success?: number | null;
|
|
failed?: number | null;
|
|
blocked?: number | null;
|
|
first_class_total?: number | null;
|
|
legacy_bridge_total?: number | null;
|
|
policy_enforced_total?: number | null;
|
|
stage?: string | null;
|
|
stage_status?: string | null;
|
|
};
|
|
legacy?: {
|
|
total?: number | null;
|
|
success?: number | null;
|
|
failed?: number | null;
|
|
};
|
|
top_tools?: Array<{
|
|
source?: string | null;
|
|
tool_name?: string | null;
|
|
total?: number | null;
|
|
success?: number | null;
|
|
failed?: number | null;
|
|
blocked?: number | null;
|
|
last_error?: string | null;
|
|
}>;
|
|
};
|
|
execution?: {
|
|
operation_total?: number | null;
|
|
latest_operation_type?: string | null;
|
|
latest_status?: string | null;
|
|
latest_actor?: string | null;
|
|
latest_action?: string | null;
|
|
latest_executor?: string | null;
|
|
playbook_ids?: string[];
|
|
playbook_paths?: string[];
|
|
ansible?: {
|
|
considered?: boolean | null;
|
|
record_total?: number | null;
|
|
candidate_count?: number | null;
|
|
not_used_reason?: string | null;
|
|
latest_operation_type?: string | null;
|
|
latest_status?: string | null;
|
|
latest_playbook_path?: string | null;
|
|
latest_check_mode?: string | null | boolean;
|
|
candidate_playbooks?: Array<{
|
|
catalog_id?: string | null;
|
|
playbook_path?: string | null;
|
|
risk_level?: string | null;
|
|
match_score?: number | null;
|
|
}>;
|
|
};
|
|
};
|
|
source_refs?: {
|
|
inbound_total?: number | null;
|
|
outbound_total?: number | null;
|
|
inbound_channels?: string[];
|
|
refs?: {
|
|
alert_ids?: string[];
|
|
sentry_issue_ids?: string[];
|
|
signoz_alerts?: string[];
|
|
fingerprints?: string[];
|
|
incident_ids?: string[];
|
|
};
|
|
correlation?: {
|
|
schema_version?: string;
|
|
status?: string | null;
|
|
missing_reason?: string | null;
|
|
direct_ref_total?: number | null;
|
|
candidate_total?: number | null;
|
|
provider_event_total?: number | null;
|
|
providers?: Record<string, {
|
|
direct_ref_total?: number | null;
|
|
candidate_total?: number | null;
|
|
latest_event_at?: string | null;
|
|
latest_heartbeat_at?: string | null;
|
|
}>;
|
|
top_candidates?: Array<{
|
|
provider?: string | null;
|
|
provider_event_id?: string | null;
|
|
stage?: string | null;
|
|
score?: number | null;
|
|
match_type?: string | null;
|
|
reasons?: string[];
|
|
received_at?: string | null;
|
|
}>;
|
|
};
|
|
latest_inbound?: {
|
|
channel_type?: string | null;
|
|
provider_event_id?: string | null;
|
|
content_type?: string | null;
|
|
is_duplicate?: boolean | null;
|
|
received_at?: string | null;
|
|
};
|
|
latest_outbound?: {
|
|
channel_type?: string | null;
|
|
message_type?: string | null;
|
|
send_status?: string | null;
|
|
sent_at?: string | null;
|
|
};
|
|
};
|
|
}
|
|
|
|
function toneClass(chain?: AwoooPStatusChain | null) {
|
|
if (!chain) return "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]";
|
|
if (chain.needs_human) return "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]";
|
|
if (chain.repair_state === "auto_repaired_verified") {
|
|
return "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]";
|
|
}
|
|
if (chain.repair_state === "executed" || chain.stage_status === "success") {
|
|
return "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]";
|
|
}
|
|
return "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]";
|
|
}
|
|
|
|
function chainIcon(chain?: AwoooPStatusChain | null) {
|
|
if (!chain) return Route;
|
|
if (chain.needs_human) return TriangleAlert;
|
|
if (chain.repair_state === "auto_repaired_verified") return CheckCircle2;
|
|
if (chain.repair_state === "blocked_manual_required") return ShieldAlert;
|
|
return Activity;
|
|
}
|
|
|
|
function boolValue(value: boolean | null | undefined, emptyLabel: string) {
|
|
if (value === true) return "true";
|
|
if (value === false) return "false";
|
|
return emptyLabel;
|
|
}
|
|
|
|
function valueOrEmpty(value: unknown, emptyLabel: string) {
|
|
if (value === null || value === undefined || value === "") return emptyLabel;
|
|
return String(value);
|
|
}
|
|
|
|
export function AwoooPStatusChainPanel({
|
|
chain,
|
|
compact = false,
|
|
className,
|
|
}: {
|
|
chain?: AwoooPStatusChain | null;
|
|
compact?: boolean;
|
|
className?: string;
|
|
}) {
|
|
const t = useTranslations("awooop.statusChain");
|
|
const Icon = chainIcon(chain);
|
|
const tone = toneClass(chain);
|
|
const emptyLabel = t("emptyValue");
|
|
const evidence = chain?.evidence ?? {};
|
|
const blockers = chain?.blockers ?? [];
|
|
const sourceCorrelation = chain?.source_refs?.correlation;
|
|
|
|
if (!chain) {
|
|
return (
|
|
<section className={cn("border border-[#e0ddd4] bg-white", compact ? "p-3" : "", className)}>
|
|
<div className={cn("flex items-start gap-3", !compact && "px-4 py-3")}>
|
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center border border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]">
|
|
<Route className="h-4 w-4" aria-hidden="true" />
|
|
</span>
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-[#141413]">{t("title")}</h3>
|
|
<p className="mt-1 text-xs leading-5 text-[#77736a]">{t("empty")}</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const metrics = [
|
|
{ label: t("fields.stage"), value: `${valueOrEmpty(chain.current_stage, emptyLabel)} / ${valueOrEmpty(chain.stage_status, emptyLabel)}` },
|
|
{ label: t("fields.repair"), value: valueOrEmpty(chain.repair_state, emptyLabel) },
|
|
{ label: t("fields.verification"), value: valueOrEmpty(chain.verification, emptyLabel) },
|
|
{ label: t("fields.nextStep"), value: valueOrEmpty(chain.next_step, emptyLabel) },
|
|
];
|
|
const evidenceMetrics = [
|
|
{ label: t("evidence.autoRepair"), value: evidence.auto_repair_records ?? 0 },
|
|
{ label: t("evidence.ops"), value: evidence.operation_records ?? 0 },
|
|
{ label: t("evidence.mcp"), value: evidence.mcp_gateway_total ?? 0 },
|
|
{ label: t("evidence.km"), value: evidence.knowledge_entries ?? 0 },
|
|
];
|
|
const sourceStatusLabels: Record<string, string> = {
|
|
linked: t("source.statuses.linked"),
|
|
candidate_found: t("source.statuses.candidateFound"),
|
|
provider_fresh_no_match: t("source.statuses.providerFreshNoMatch"),
|
|
missing: t("source.statuses.missing"),
|
|
no_incident_context: t("source.statuses.noIncidentContext"),
|
|
fetch_failed: t("source.statuses.fetchFailed"),
|
|
};
|
|
const sourceStatus = String(sourceCorrelation?.status ?? "missing");
|
|
const sourceProviderSummary = ["sentry", "signoz"].map((provider) => {
|
|
const providerItem = sourceCorrelation?.providers?.[provider];
|
|
return `${provider} ${providerItem?.direct_ref_total ?? 0}/${providerItem?.candidate_total ?? 0}`;
|
|
}).join(" · ");
|
|
|
|
return (
|
|
<section className={cn("border border-[#e0ddd4] bg-white", className)}>
|
|
<div className={cn(
|
|
"flex flex-wrap items-start justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3]",
|
|
compact ? "px-3 py-3" : "px-4 py-3"
|
|
)}>
|
|
<div className="flex min-w-0 items-start gap-3">
|
|
<span className={cn("flex h-9 w-9 shrink-0 items-center justify-center border", tone)}>
|
|
<Icon className="h-4 w-4" aria-hidden="true" />
|
|
</span>
|
|
<div className="min-w-0">
|
|
<h3 className="text-sm font-semibold text-[#141413]">{t("title")}</h3>
|
|
<p className="mt-1 text-xs leading-5 text-[#77736a]">
|
|
{t("subtitle", {
|
|
source: valueOrEmpty(chain.source, emptyLabel),
|
|
sourceId: valueOrEmpty(chain.source_id, emptyLabel),
|
|
})}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<span className={cn("shrink-0 border px-2 py-0.5 text-xs font-semibold", tone)}>
|
|
{chain.needs_human ? t("human.yes") : t("human.no")}
|
|
</span>
|
|
</div>
|
|
|
|
<div className={cn("grid gap-px bg-[#e0ddd4]", compact ? "md:grid-cols-2" : "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-sm font-semibold text-[#141413]" title={item.value}>
|
|
{item.value}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{!compact && (
|
|
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-5">
|
|
{evidenceMetrics.map((item) => (
|
|
<div key={item.label} className="bg-white px-4 py-3">
|
|
<p className="text-xs font-semibold text-[#77736a]">{item.label}</p>
|
|
<p className="mt-2 font-mono text-xl font-semibold text-[#141413]">{item.value}</p>
|
|
</div>
|
|
))}
|
|
<div className="min-w-0 bg-white px-4 py-3">
|
|
<p className="text-xs font-semibold text-[#77736a]">{t("evidence.adr100")}</p>
|
|
<p className="mt-2 truncate font-mono text-sm text-[#141413]" title={valueOrEmpty(evidence.latest_route, emptyLabel)}>
|
|
{valueOrEmpty(evidence.latest_route, emptyLabel)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-2">
|
|
<div className="min-w-0 bg-white px-4 py-3">
|
|
<p className="text-xs font-semibold text-[#77736a]">{t("fields.writes")}</p>
|
|
<p className="mt-2 font-mono text-sm text-[#141413]">
|
|
{t("writeFlags", {
|
|
incident: boolValue(chain.writes?.incident, emptyLabel),
|
|
autoRepair: boolValue(chain.writes?.auto_repair, emptyLabel),
|
|
})}
|
|
</p>
|
|
</div>
|
|
<div className="min-w-0 bg-white px-4 py-3">
|
|
<p className="text-xs font-semibold text-[#77736a]">{t("fields.verdict")}</p>
|
|
<p className="mt-2 truncate font-mono text-sm text-[#141413]" title={valueOrEmpty(chain.verdict, emptyLabel)}>
|
|
{valueOrEmpty(chain.verdict, emptyLabel)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{sourceCorrelation && (
|
|
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-3">
|
|
<div className="min-w-0 bg-white px-4 py-3">
|
|
<p className="text-xs font-semibold text-[#77736a]">{t("source.status")}</p>
|
|
<p className="mt-2 truncate font-mono text-sm text-[#141413]" title={sourceStatusLabels[sourceStatus] ?? sourceStatus}>
|
|
{sourceStatusLabels[sourceStatus] ?? valueOrEmpty(sourceCorrelation.status, emptyLabel)}
|
|
</p>
|
|
</div>
|
|
<div className="min-w-0 bg-white px-4 py-3">
|
|
<p className="text-xs font-semibold text-[#77736a]">{t("source.directCandidate")}</p>
|
|
<p className="mt-2 font-mono text-sm text-[#141413]">
|
|
{t("source.directCandidateValue", {
|
|
direct: sourceCorrelation.direct_ref_total ?? 0,
|
|
candidate: sourceCorrelation.candidate_total ?? 0,
|
|
})}
|
|
</p>
|
|
</div>
|
|
<div className="min-w-0 bg-white px-4 py-3">
|
|
<p className="text-xs font-semibold text-[#77736a]">{t("source.providers")}</p>
|
|
<p className="mt-2 truncate font-mono text-sm text-[#141413]" title={sourceProviderSummary}>
|
|
{sourceProviderSummary}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{blockers.length > 0 && (
|
|
<div className="border-t border-[#eee9dd] bg-[#fff7e8] px-4 py-3 text-xs leading-5 text-[#8a5a08]">
|
|
<span className="font-semibold">{t("blockers")}</span>{" "}
|
|
<span className="font-mono">{blockers.slice(0, compact ? 2 : 4).join(", ")}</span>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|