recent changes
This commit is contained in:
308
scripts/backfill-downtime-reasons.mjs
Normal file
308
scripts/backfill-downtime-reasons.mjs
Normal file
@@ -0,0 +1,308 @@
|
||||
#!/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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user