309 lines
7.9 KiB
JavaScript
309 lines
7.9 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { PrismaClient } from "@prisma/client";
|
|
|
|
const prisma = new PrismaClient();
|
|
const NON_AUTHORITATIVE_REASON_CODES = new Set(["PENDIENTE", "UNCLASSIFIED"]);
|
|
|
|
function parseArgs(argv) {
|
|
const out = {
|
|
dryRun: false,
|
|
since: "30d",
|
|
orgId: null,
|
|
machineId: null,
|
|
};
|
|
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const token = argv[i];
|
|
if (token === "--dry-run") {
|
|
out.dryRun = true;
|
|
continue;
|
|
}
|
|
if (token === "--since") {
|
|
out.since = argv[i + 1] || out.since;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (token === "--org-id") {
|
|
out.orgId = argv[i + 1] || null;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (token === "--machine-id") {
|
|
out.machineId = argv[i + 1] || null;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
throw new Error(`Unknown argument: ${token}`);
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
function parseSince(value) {
|
|
const now = Date.now();
|
|
const text = String(value || "30d").trim().toLowerCase();
|
|
const relative = text.match(/^(\d+)\s*([dhm])$/);
|
|
if (relative) {
|
|
const amount = Number(relative[1]);
|
|
const unit = relative[2];
|
|
const factor = unit === "d" ? 24 * 60 * 60 * 1000 : unit === "h" ? 60 * 60 * 1000 : 60 * 1000;
|
|
return new Date(now - amount * factor);
|
|
}
|
|
const dt = new Date(value);
|
|
if (Number.isNaN(dt.getTime())) {
|
|
throw new Error(`Invalid --since value: ${value}. Use ISO date, or relative like 30d / 12h / 90m.`);
|
|
}
|
|
return dt;
|
|
}
|
|
|
|
function asRecord(value) {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
}
|
|
|
|
function clampText(value, maxLen) {
|
|
if (value === null || value === undefined) return null;
|
|
const text = String(value).trim().replace(/[\u0000-\u001f\u007f]/g, "");
|
|
if (!text) return null;
|
|
return text.length > maxLen ? text.slice(0, maxLen) : text;
|
|
}
|
|
|
|
function canonicalId(input) {
|
|
const text = String(input ?? "")
|
|
.normalize("NFD")
|
|
.replace(/[\u0300-\u036f]/g, "")
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
return text || null;
|
|
}
|
|
|
|
function toReasonCode(categoryId, detailId) {
|
|
const cat = canonicalId(categoryId);
|
|
const det = canonicalId(detailId);
|
|
if (!cat || !det) return null;
|
|
return `${cat}__${det}`.toUpperCase();
|
|
}
|
|
|
|
function isNonAuthoritativeReasonCode(code) {
|
|
const normalized = clampText(code, 64)?.toUpperCase();
|
|
return !!normalized && NON_AUTHORITATIVE_REASON_CODES.has(normalized);
|
|
}
|
|
|
|
function extractReasonPayload(data) {
|
|
const rec = asRecord(data);
|
|
if (!rec) return null;
|
|
const direct = asRecord(rec.reason);
|
|
if (direct) return direct;
|
|
const downtime = asRecord(rec.downtime);
|
|
const nested = asRecord(downtime?.reason);
|
|
return nested || null;
|
|
}
|
|
|
|
function extractIncidentKey(data, reason) {
|
|
const rec = asRecord(data);
|
|
const downtime = asRecord(rec?.downtime);
|
|
return (
|
|
clampText(rec?.incidentKey, 128) ??
|
|
clampText(downtime?.incidentKey, 128) ??
|
|
clampText(reason?.incidentKey, 128) ??
|
|
null
|
|
);
|
|
}
|
|
|
|
function normalizeAckReason(reasonRaw) {
|
|
const categoryId = clampText(reasonRaw?.categoryId, 64);
|
|
const detailId = clampText(reasonRaw?.detailId, 64);
|
|
const categoryLabel = clampText(reasonRaw?.categoryLabel, 120);
|
|
const detailLabel = clampText(reasonRaw?.detailLabel, 120);
|
|
|
|
const reasonCode =
|
|
clampText(reasonRaw?.reasonCode, 64)?.toUpperCase() ??
|
|
toReasonCode(categoryId ?? categoryLabel, detailId ?? detailLabel) ??
|
|
null;
|
|
if (!reasonCode) return null;
|
|
|
|
const reasonLabel =
|
|
clampText(reasonRaw?.reasonText, 240) ??
|
|
(categoryLabel && detailLabel ? `${categoryLabel} > ${detailLabel}` : null) ??
|
|
detailLabel ??
|
|
categoryLabel ??
|
|
reasonCode;
|
|
|
|
return {
|
|
type: "downtime",
|
|
categoryId,
|
|
categoryLabel,
|
|
detailId,
|
|
detailLabel,
|
|
reasonCode,
|
|
reasonLabel,
|
|
reasonText: reasonLabel,
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const since = parseSince(args.since);
|
|
|
|
const where = {
|
|
eventType: "downtime-acknowledged",
|
|
ts: { gte: since },
|
|
...(args.orgId ? { orgId: args.orgId } : {}),
|
|
...(args.machineId ? { machineId: args.machineId } : {}),
|
|
};
|
|
|
|
const ackEvents = await prisma.machineEvent.findMany({
|
|
where,
|
|
orderBy: { ts: "desc" },
|
|
select: {
|
|
id: true,
|
|
orgId: true,
|
|
machineId: true,
|
|
ts: true,
|
|
data: true,
|
|
},
|
|
});
|
|
|
|
const latestByIncident = new Map();
|
|
for (const event of ackEvents) {
|
|
const reasonRaw = extractReasonPayload(event.data);
|
|
if (!reasonRaw) continue;
|
|
const normalized = normalizeAckReason(reasonRaw);
|
|
if (!normalized) continue;
|
|
if (isNonAuthoritativeReasonCode(normalized.reasonCode)) continue;
|
|
|
|
const incidentKey = extractIncidentKey(event.data, reasonRaw);
|
|
if (!incidentKey) continue;
|
|
|
|
const mapKey = `${event.orgId}::${incidentKey}`;
|
|
if (latestByIncident.has(mapKey)) continue;
|
|
latestByIncident.set(mapKey, {
|
|
orgId: event.orgId,
|
|
machineId: event.machineId,
|
|
incidentKey,
|
|
eventId: event.id,
|
|
eventTs: event.ts,
|
|
reason: normalized,
|
|
});
|
|
}
|
|
|
|
let scanned = 0;
|
|
let candidates = 0;
|
|
let updated = 0;
|
|
let missingReasonEntry = 0;
|
|
let alreadyManual = 0;
|
|
let skippedNonPendingIncoming = 0;
|
|
const samples = [];
|
|
|
|
for (const item of latestByIncident.values()) {
|
|
scanned += 1;
|
|
const existing = await prisma.reasonEntry.findFirst({
|
|
where: {
|
|
orgId: item.orgId,
|
|
kind: "downtime",
|
|
episodeId: item.incidentKey,
|
|
},
|
|
select: {
|
|
id: true,
|
|
reasonCode: true,
|
|
reasonLabel: true,
|
|
reasonText: true,
|
|
capturedAt: true,
|
|
schemaVersion: true,
|
|
},
|
|
});
|
|
|
|
if (!existing) {
|
|
missingReasonEntry += 1;
|
|
continue;
|
|
}
|
|
if (!isNonAuthoritativeReasonCode(existing.reasonCode)) {
|
|
alreadyManual += 1;
|
|
continue;
|
|
}
|
|
if (isNonAuthoritativeReasonCode(item.reason.reasonCode)) {
|
|
skippedNonPendingIncoming += 1;
|
|
continue;
|
|
}
|
|
|
|
candidates += 1;
|
|
const next = {
|
|
reasonCode: item.reason.reasonCode,
|
|
reasonLabel: item.reason.reasonLabel ?? item.reason.reasonCode,
|
|
reasonText: item.reason.reasonText ?? item.reason.reasonLabel ?? item.reason.reasonCode,
|
|
schemaVersion: Math.max(1, Number(existing.schemaVersion || 1)),
|
|
meta: {
|
|
source: "backfill:downtime-acknowledged",
|
|
eventId: item.eventId,
|
|
eventTs: item.eventTs.toISOString(),
|
|
incidentKey: item.incidentKey,
|
|
reason: {
|
|
type: "downtime",
|
|
categoryId: item.reason.categoryId,
|
|
categoryLabel: item.reason.categoryLabel,
|
|
detailId: item.reason.detailId,
|
|
detailLabel: item.reason.detailLabel,
|
|
reasonText: item.reason.reasonText,
|
|
},
|
|
},
|
|
};
|
|
|
|
samples.push({
|
|
reasonEntryId: existing.id,
|
|
orgId: item.orgId,
|
|
machineId: item.machineId,
|
|
incidentKey: item.incidentKey,
|
|
from: {
|
|
reasonCode: existing.reasonCode,
|
|
reasonLabel: existing.reasonLabel,
|
|
reasonText: existing.reasonText,
|
|
},
|
|
to: {
|
|
reasonCode: next.reasonCode,
|
|
reasonLabel: next.reasonLabel,
|
|
reasonText: next.reasonText,
|
|
},
|
|
});
|
|
|
|
if (!args.dryRun) {
|
|
await prisma.reasonEntry.update({
|
|
where: { id: existing.id },
|
|
data: next,
|
|
});
|
|
updated += 1;
|
|
}
|
|
}
|
|
|
|
const summary = {
|
|
ok: true,
|
|
mode: args.dryRun ? "dry-run" : "apply",
|
|
since: since.toISOString(),
|
|
filters: {
|
|
orgId: args.orgId,
|
|
machineId: args.machineId,
|
|
},
|
|
eventsRead: ackEvents.length,
|
|
incidentsDeduped: latestByIncident.size,
|
|
scanned,
|
|
candidates,
|
|
updated,
|
|
missingReasonEntry,
|
|
alreadyManual,
|
|
skippedNonPendingIncoming,
|
|
sampleUpdates: samples.slice(0, 25),
|
|
};
|
|
|
|
console.log(JSON.stringify(summary, null, 2));
|
|
}
|
|
|
|
main()
|
|
.catch((err) => {
|
|
console.error("[backfill-downtime-reasons] failed:", err);
|
|
process.exitCode = 1;
|
|
})
|
|
.finally(async () => {
|
|
await prisma.$disconnect();
|
|
});
|
|
|