Recent changes
This commit is contained in:
@@ -291,8 +291,11 @@ export async function POST(req: Request) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const evData = asRecord(evRecord.data) ?? {};
|
const evData = asRecord(evRecord.data) ?? {};
|
||||||
const evReason = asRecord(evRecord.reason) ?? asRecord(evData.reason);
|
// We'll re-check reason again after parsing `data` (it may be a JSON string)
|
||||||
|
let evReason = asRecord(evRecord.reason) ?? asRecord(evData.reason);
|
||||||
const evDowntime = asRecord(evRecord.downtime) ?? asRecord(evData.downtime);
|
const evDowntime = asRecord(evRecord.downtime) ?? asRecord(evData.downtime);
|
||||||
|
// Some producers nest the reason under `downtime.reason`
|
||||||
|
if (!evReason) evReason = asRecord(evDowntime?.reason);
|
||||||
|
|
||||||
const rawType = evRecord.eventType ?? evRecord.anomaly_type ?? evRecord.topic ?? bodyRecord.topic ?? "";
|
const rawType = evRecord.eventType ?? evRecord.anomaly_type ?? evRecord.topic ?? bodyRecord.topic ?? "";
|
||||||
const typ0 = normalizeType(rawType);
|
const typ0 = normalizeType(rawType);
|
||||||
@@ -313,11 +316,13 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
// Stop classification -> microstop/macrostop
|
// Stop classification -> microstop/macrostop
|
||||||
let finalType = typ;
|
let finalType = typ;
|
||||||
|
let stopSecForReason: number | null = null;
|
||||||
if (typ === "stop") {
|
if (typ === "stop") {
|
||||||
const stopSec =
|
const stopSec =
|
||||||
(typeof evData.stoppage_duration_seconds === "number" && evData.stoppage_duration_seconds) ||
|
(typeof evData.stoppage_duration_seconds === "number" && evData.stoppage_duration_seconds) ||
|
||||||
(typeof evData.stop_duration_seconds === "number" && evData.stop_duration_seconds) ||
|
(typeof evData.stop_duration_seconds === "number" && evData.stop_duration_seconds) ||
|
||||||
null;
|
null;
|
||||||
|
stopSecForReason = stopSec != null ? Number(stopSec) : null;
|
||||||
|
|
||||||
if (stopSec != null) {
|
if (stopSec != null) {
|
||||||
const theoretical = Number(evData.theoretical_cycle_time ?? evData.theoreticalCycleTime ?? 0) || 0;
|
const theoretical = Number(evData.theoretical_cycle_time ?? evData.theoreticalCycleTime ?? 0) || 0;
|
||||||
@@ -378,6 +383,28 @@ export async function POST(req: Request) {
|
|||||||
if (evReason && dataObj.reason == null) dataObj.reason = evReason;
|
if (evReason && dataObj.reason == null) dataObj.reason = evReason;
|
||||||
if (evDowntime && dataObj.downtime == null) dataObj.downtime = evDowntime;
|
if (evDowntime && dataObj.downtime == null) dataObj.downtime = evDowntime;
|
||||||
|
|
||||||
|
// If `data` was a JSON string, the earlier evReason lookup would miss it.
|
||||||
|
// Re-check here using the normalized object we will persist.
|
||||||
|
if (!evReason) evReason = asRecord(dataObj.reason);
|
||||||
|
if (!evReason) evReason = asRecord(asRecord(dataObj.downtime)?.reason);
|
||||||
|
|
||||||
|
// If we have a reasonText but missing ids, derive ids from the path-like string.
|
||||||
|
if (evReason) {
|
||||||
|
const hasCat = clampText((evReason as any).categoryId, 64) ?? clampText((evReason as any).categoryLabel, 120);
|
||||||
|
const hasDet = clampText((evReason as any).detailId, 64) ?? clampText((evReason as any).detailLabel, 120);
|
||||||
|
const rt = clampText((evReason as any).reasonText, 240);
|
||||||
|
if ((!hasCat || !hasDet) && rt) {
|
||||||
|
const parsed = parseReasonTextPath(rt);
|
||||||
|
const next = { ...evReason } as Record<string, unknown>;
|
||||||
|
// Preserve any explicit ids; only fill gaps.
|
||||||
|
if ((next as any).categoryId == null && parsed.category) next.categoryId = canonicalText(parsed.category);
|
||||||
|
if ((next as any).categoryLabel == null && parsed.category) next.categoryLabel = parsed.category;
|
||||||
|
if ((next as any).detailId == null && parsed.detail) next.detailId = canonicalText(parsed.detail);
|
||||||
|
if ((next as any).detailLabel == null && parsed.detail) next.detailLabel = parsed.detail;
|
||||||
|
evReason = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const activeWorkOrder = asRecord(evRecord.activeWorkOrder);
|
const activeWorkOrder = asRecord(evRecord.activeWorkOrder);
|
||||||
const dataActiveWorkOrder = asRecord(evData.activeWorkOrder);
|
const dataActiveWorkOrder = asRecord(evData.activeWorkOrder);
|
||||||
|
|
||||||
@@ -410,19 +437,34 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
created.push({ id: row.id, ts: row.ts, eventType: row.eventType });
|
created.push({ id: row.id, ts: row.ts, eventType: row.eventType });
|
||||||
|
|
||||||
if (evReason) {
|
// If the payload carries a `reason`, create the corresponding ReasonEntry.
|
||||||
|
// If it doesn't, still create an "UNCLASSIFIED" downtime ReasonEntry for stop events so the dashboard can show coverage.
|
||||||
|
if (evReason || finalType === "microstop" || finalType === "macrostop" || finalType === "downtime-acknowledged") {
|
||||||
|
const reasonRaw: Record<string, unknown> =
|
||||||
|
evReason ??
|
||||||
|
({
|
||||||
|
type: "downtime",
|
||||||
|
categoryId: "unclassified",
|
||||||
|
detailId: "unclassified",
|
||||||
|
categoryLabel: "Unclassified",
|
||||||
|
detailLabel: "Unclassified",
|
||||||
|
reasonCode: "UNCLASSIFIED",
|
||||||
|
reasonText: "Unclassified",
|
||||||
|
incidentKey: row.id,
|
||||||
|
} as Record<string, unknown>);
|
||||||
|
|
||||||
const inferredKind: ReasonCatalogKind =
|
const inferredKind: ReasonCatalogKind =
|
||||||
String(evReason.type ?? "").toLowerCase() === "scrap" || finalType === "scrap-manual-entry"
|
String(reasonRaw.type ?? "").toLowerCase() === "scrap" || finalType === "scrap-manual-entry"
|
||||||
? "scrap"
|
? "scrap"
|
||||||
: "downtime";
|
: "downtime";
|
||||||
const resolved = resolveReason(evReason, inferredKind, reasonCatalog, reasonCatalog.version);
|
const resolved = resolveReason(reasonRaw, inferredKind, reasonCatalog, reasonCatalog.version);
|
||||||
|
|
||||||
if (resolved.reasonCode) {
|
if (resolved.reasonCode) {
|
||||||
const reasonId =
|
const reasonId =
|
||||||
clampText(evReason.reasonId, 128) ??
|
clampText(reasonRaw.reasonId, 128) ??
|
||||||
(inferredKind === "downtime"
|
(inferredKind === "downtime"
|
||||||
? `evt:${machine.id}:downtime:${clampText(evReason.incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id}`
|
? `evt:${machine.id}:downtime:${clampText((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id}`
|
||||||
: `evt:${machine.id}:scrap:${clampText(evReason.scrapEntryId, 128) ?? row.id}`);
|
: `evt:${machine.id}:scrap:${clampText(reasonRaw.scrapEntryId, 128) ?? row.id}`);
|
||||||
|
|
||||||
const workOrderId =
|
const workOrderId =
|
||||||
clampText(evRecord.work_order_id, 64) ??
|
clampText(evRecord.work_order_id, 64) ??
|
||||||
@@ -441,7 +483,7 @@ export async function POST(req: Request) {
|
|||||||
source: "ingest:event",
|
source: "ingest:event",
|
||||||
eventId: row.id,
|
eventId: row.id,
|
||||||
eventType: row.eventType,
|
eventType: row.eventType,
|
||||||
incidentKey: clampText(evReason.incidentKey ?? evDowntime?.incidentKey, 128),
|
incidentKey: clampText((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey, 128),
|
||||||
anomalyType:
|
anomalyType:
|
||||||
clampText(evRecord.anomalyType, 64) ??
|
clampText(evRecord.anomalyType, 64) ??
|
||||||
clampText(evDowntime?.anomalyType, 64) ??
|
clampText(evDowntime?.anomalyType, 64) ??
|
||||||
@@ -459,11 +501,12 @@ export async function POST(req: Request) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (inferredKind === "downtime") {
|
if (inferredKind === "downtime") {
|
||||||
const incidentKey = clampText(evReason.incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id;
|
const incidentKey = clampText((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id;
|
||||||
const durationSeconds =
|
const durationSeconds =
|
||||||
numberFrom(evDowntime?.durationSeconds) ??
|
numberFrom(evDowntime?.durationSeconds) ??
|
||||||
numberFrom(evData.stoppage_duration_seconds) ??
|
numberFrom(evData.stoppage_duration_seconds) ??
|
||||||
numberFrom(evData.stop_duration_seconds) ??
|
numberFrom(evData.stop_duration_seconds) ??
|
||||||
|
(stopSecForReason != null ? stopSecForReason : null) ??
|
||||||
null;
|
null;
|
||||||
const episodeEndTsMs =
|
const episodeEndTsMs =
|
||||||
numberFrom(evDowntime?.episodeEndTsMs) ??
|
numberFrom(evDowntime?.episodeEndTsMs) ??
|
||||||
@@ -492,7 +535,7 @@ export async function POST(req: Request) {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const scrapEntryId =
|
const scrapEntryId =
|
||||||
clampText(evReason.scrapEntryId, 128) ??
|
clampText((reasonRaw as any).scrapEntryId, 128) ??
|
||||||
clampText(evRecord.id, 128) ??
|
clampText(evRecord.id, 128) ??
|
||||||
clampText(evRecord.eventId, 128) ??
|
clampText(evRecord.eventId, 128) ??
|
||||||
row.id;
|
row.id;
|
||||||
@@ -512,14 +555,14 @@ export async function POST(req: Request) {
|
|||||||
kind: "scrap",
|
kind: "scrap",
|
||||||
scrapEntryId,
|
scrapEntryId,
|
||||||
scrapQty,
|
scrapQty,
|
||||||
scrapUnit: clampText(evReason.scrapUnit, 16) ?? null,
|
scrapUnit: clampText((reasonRaw as any).scrapUnit, 16) ?? null,
|
||||||
...commonWrite,
|
...commonWrite,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
kind: "scrap",
|
kind: "scrap",
|
||||||
scrapEntryId,
|
scrapEntryId,
|
||||||
scrapQty,
|
scrapQty,
|
||||||
scrapUnit: clampText(evReason.scrapUnit, 16) ?? null,
|
scrapUnit: clampText((reasonRaw as any).scrapUnit, 16) ?? null,
|
||||||
...commonWrite,
|
...commonWrite,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ type ApiParetoRes = {
|
|||||||
total?: number;
|
total?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LegacyParetoItem = {
|
||||||
|
reasonCode?: string;
|
||||||
|
reasonLabel?: string;
|
||||||
|
value?: number; // minutes (downtime) or qty (scrap)
|
||||||
|
count?: number;
|
||||||
|
cumPct?: number;
|
||||||
|
};
|
||||||
|
|
||||||
type ApiDowntimeEvent = {
|
type ApiDowntimeEvent = {
|
||||||
id: string;
|
id: string;
|
||||||
episodeId: string | null;
|
episodeId: string | null;
|
||||||
@@ -104,6 +112,139 @@ function fmtDT(iso: string | null) {
|
|||||||
return d.toLocaleString("en-US", { hour12: true });
|
return d.toLocaleString("en-US", { hour12: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeParetoRes(input: ApiParetoRes): ApiParetoRes {
|
||||||
|
const rows = Array.isArray(input?.rows) ? input.rows : [];
|
||||||
|
if (rows.length > 0) return input;
|
||||||
|
|
||||||
|
// Support a legacy envelope where the server returns `items[]` instead of `rows[]`.
|
||||||
|
const legacyItems = (input as any)?.items as unknown;
|
||||||
|
if (!Array.isArray(legacyItems) || legacyItems.length === 0) return input;
|
||||||
|
|
||||||
|
const items = legacyItems as LegacyParetoItem[];
|
||||||
|
const safeItems = items
|
||||||
|
.map((it) => ({
|
||||||
|
reasonCode: String(it?.reasonCode ?? "").trim(),
|
||||||
|
reasonLabel: String(it?.reasonLabel ?? it?.reasonCode ?? "").trim(),
|
||||||
|
value: typeof it?.value === "number" && Number.isFinite(it.value) ? it.value : 0,
|
||||||
|
count: typeof it?.count === "number" && Number.isFinite(it.count) ? it.count : 0,
|
||||||
|
}))
|
||||||
|
.filter((x) => x.reasonCode);
|
||||||
|
|
||||||
|
// Legacy `items` are usually pre-sorted by value desc; enforce it anyway.
|
||||||
|
safeItems.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
|
const total = safeItems.reduce((acc, x) => acc + x.value, 0);
|
||||||
|
let cum = 0;
|
||||||
|
let threshold80Index: number | null = null;
|
||||||
|
|
||||||
|
const outRows: ApiParetoRow[] = safeItems.map((x, idx) => {
|
||||||
|
const pctOfTotal = total > 0 ? (x.value / total) * 100 : 0;
|
||||||
|
cum += x.value;
|
||||||
|
const cumulativePct = total > 0 ? (cum / total) * 100 : 0;
|
||||||
|
if (threshold80Index === null && cumulativePct >= 80) threshold80Index = idx;
|
||||||
|
|
||||||
|
return {
|
||||||
|
reasonCode: x.reasonCode,
|
||||||
|
reasonLabel: x.reasonLabel || x.reasonCode,
|
||||||
|
minutesLost: input.kind === "scrap" ? undefined : x.value,
|
||||||
|
scrapQty: input.kind === "scrap" ? x.value : undefined,
|
||||||
|
pctOfTotal,
|
||||||
|
cumulativePct,
|
||||||
|
count: x.count,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const threshold80 =
|
||||||
|
threshold80Index === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
index: threshold80Index,
|
||||||
|
reasonCode: outRows[threshold80Index].reasonCode,
|
||||||
|
reasonLabel: outRows[threshold80Index].reasonLabel,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...input,
|
||||||
|
rows: outRows,
|
||||||
|
top3: outRows.slice(0, 3),
|
||||||
|
threshold80,
|
||||||
|
totalMinutesLost: input.kind === "scrap" ? undefined : total,
|
||||||
|
totalScrap: input.kind === "scrap" ? total : undefined,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildParetoFromEvents(events: ApiDowntimeEvent[]): ApiParetoRes | null {
|
||||||
|
if (!Array.isArray(events) || events.length === 0) return null;
|
||||||
|
|
||||||
|
const byCode = new Map<
|
||||||
|
string,
|
||||||
|
{ reasonCode: string; reasonLabel: string; minutes: number; count: number }
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const e of events) {
|
||||||
|
const reasonCode = String(e?.reasonCode ?? "").trim();
|
||||||
|
if (!reasonCode) continue;
|
||||||
|
const reasonLabel = String(e?.reasonLabel ?? reasonCode).trim() || reasonCode;
|
||||||
|
const minutes =
|
||||||
|
(typeof e?.durationMinutes === "number" && Number.isFinite(e.durationMinutes)
|
||||||
|
? e.durationMinutes
|
||||||
|
: null) ??
|
||||||
|
(typeof e?.durationSeconds === "number" && Number.isFinite(e.durationSeconds)
|
||||||
|
? e.durationSeconds / 60
|
||||||
|
: 0);
|
||||||
|
|
||||||
|
const slot =
|
||||||
|
byCode.get(reasonCode) ?? { reasonCode, reasonLabel, minutes: 0, count: 0 };
|
||||||
|
slot.minutes += Math.max(0, minutes);
|
||||||
|
slot.count += 1;
|
||||||
|
// prefer the most recent non-empty label if they differ
|
||||||
|
if (reasonLabel && reasonLabel !== reasonCode) slot.reasonLabel = reasonLabel;
|
||||||
|
byCode.set(reasonCode, slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [...byCode.values()].filter((x) => x.minutes > 0 || x.count > 0);
|
||||||
|
items.sort((a, b) => b.minutes - a.minutes);
|
||||||
|
|
||||||
|
const totalMinutesLost = items.reduce((acc, x) => acc + x.minutes, 0);
|
||||||
|
let cum = 0;
|
||||||
|
let threshold80Index: number | null = null;
|
||||||
|
|
||||||
|
const rows: ApiParetoRow[] = items.map((x, idx) => {
|
||||||
|
const pctOfTotal = totalMinutesLost > 0 ? (x.minutes / totalMinutesLost) * 100 : 0;
|
||||||
|
cum += x.minutes;
|
||||||
|
const cumulativePct = totalMinutesLost > 0 ? (cum / totalMinutesLost) * 100 : 0;
|
||||||
|
if (threshold80Index === null && cumulativePct >= 80) threshold80Index = idx;
|
||||||
|
return {
|
||||||
|
reasonCode: x.reasonCode,
|
||||||
|
reasonLabel: x.reasonLabel,
|
||||||
|
minutesLost: Math.round(x.minutes * 10) / 10,
|
||||||
|
pctOfTotal,
|
||||||
|
cumulativePct,
|
||||||
|
count: x.count,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const threshold80 =
|
||||||
|
threshold80Index === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
index: threshold80Index,
|
||||||
|
reasonCode: rows[threshold80Index].reasonCode,
|
||||||
|
reasonLabel: rows[threshold80Index].reasonLabel,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
kind: "downtime",
|
||||||
|
totalMinutesLost: Math.round(totalMinutesLost * 10) / 10,
|
||||||
|
rows,
|
||||||
|
top3: rows.slice(0, 3),
|
||||||
|
threshold80,
|
||||||
|
total: totalMinutesLost,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
type ApiCoverageRes = {
|
type ApiCoverageRes = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -1178,6 +1319,7 @@ export default function DowntimePageClient() {
|
|||||||
|
|
||||||
const [eventsLimit, setEventsLimit] = useState<number>(200);
|
const [eventsLimit, setEventsLimit] = useState<number>(200);
|
||||||
const [eventsBefore, setEventsBefore] = useState<string | null>(null);
|
const [eventsBefore, setEventsBefore] = useState<string | null>(null);
|
||||||
|
const debug = sp.get("debug") === "1";
|
||||||
|
|
||||||
// simple client filter (fast): text search on machine/reason/wo
|
// simple client filter (fast): text search on machine/reason/wo
|
||||||
const [eventSearch, setEventSearch] = useState("");
|
const [eventSearch, setEventSearch] = useState("");
|
||||||
@@ -1236,13 +1378,13 @@ export default function DowntimePageClient() {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const j1 = (await r1.json().catch(() => ({}))) as ApiParetoRes;
|
const j1raw = (await r1.json().catch(() => ({}))) as ApiParetoRes;
|
||||||
const j2 = (await r2.json().catch(() => ({}))) as ApiCoverageRes;
|
const j2 = (await r2.json().catch(() => ({}))) as ApiCoverageRes;
|
||||||
|
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
|
|
||||||
if (!r1.ok || j1.ok === false) {
|
if (!r1.ok || j1raw.ok === false) {
|
||||||
setErr(j1?.error ?? "Failed to load pareto");
|
setErr(j1raw?.error ?? "Failed to load pareto");
|
||||||
setPareto(null);
|
setPareto(null);
|
||||||
setCoverage(null);
|
setCoverage(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -1256,7 +1398,7 @@ export default function DowntimePageClient() {
|
|||||||
setCoverage(j2);
|
setCoverage(j2);
|
||||||
}
|
}
|
||||||
|
|
||||||
setPareto(j1);
|
setPareto(normalizeParetoRes(j1raw));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
@@ -1355,7 +1497,23 @@ export default function DowntimePageClient() {
|
|||||||
}, [range, machineId, reasonCode, eventsLimit, eventsBefore]);
|
}, [range, machineId, reasonCode, eventsLimit, eventsBefore]);
|
||||||
|
|
||||||
// Derived data
|
// Derived data
|
||||||
const baseRows = pareto?.rows ?? [];
|
const events = eventsRes?.events ?? [];
|
||||||
|
const paretoEffective = useMemo(() => {
|
||||||
|
const normalized = pareto ? normalizeParetoRes(pareto) : null;
|
||||||
|
if (normalized?.rows && normalized.rows.length > 0) return normalized;
|
||||||
|
const fromEvents = buildParetoFromEvents(events);
|
||||||
|
if (!fromEvents) return normalized;
|
||||||
|
return {
|
||||||
|
...fromEvents,
|
||||||
|
range: (eventsRes?.range as any) ?? normalized?.range,
|
||||||
|
start: eventsRes?.start ?? normalized?.start,
|
||||||
|
orgId: eventsRes?.orgId ?? normalized?.orgId,
|
||||||
|
machineId: eventsRes?.machineId ?? normalized?.machineId ?? null,
|
||||||
|
};
|
||||||
|
}, [pareto, events, eventsRes?.orgId, eventsRes?.machineId, eventsRes?.range, eventsRes?.start]);
|
||||||
|
const usingEventsFallback = (paretoEffective?.rows?.length ?? 0) > 0 && (pareto?.rows?.length ?? 0) === 0 && events.length > 0;
|
||||||
|
|
||||||
|
const baseRows = paretoEffective?.rows ?? [];
|
||||||
const metricRowsAll = useMemo(() => computeMetricRows(baseRows, metric), [baseRows, metric]);
|
const metricRowsAll = useMemo(() => computeMetricRows(baseRows, metric), [baseRows, metric]);
|
||||||
|
|
||||||
const metricRowsFiltered = useMemo(() => {
|
const metricRowsFiltered = useMemo(() => {
|
||||||
@@ -1386,7 +1544,7 @@ export default function DowntimePageClient() {
|
|||||||
}));
|
}));
|
||||||
}, [catalogRows]);
|
}, [catalogRows]);
|
||||||
|
|
||||||
const totalMinutes = pareto?.totalMinutesLost ?? 0;
|
const totalMinutes = paretoEffective?.totalMinutesLost ?? 0;
|
||||||
const totalStops = useMemo(
|
const totalStops = useMemo(
|
||||||
() => baseRows.reduce((acc, r) => acc + (r.count ?? 0), 0),
|
() => baseRows.reduce((acc, r) => acc + (r.count ?? 0), 0),
|
||||||
[baseRows]
|
[baseRows]
|
||||||
@@ -1401,10 +1559,10 @@ export default function DowntimePageClient() {
|
|||||||
|
|
||||||
const threshold80Index = useMemo(() => {
|
const threshold80Index = useMemo(() => {
|
||||||
// If API threshold80 exists, it’s based on minutes. For count metric, compute locally.
|
// If API threshold80 exists, it’s based on minutes. For count metric, compute locally.
|
||||||
if (metric === "minutes") return pareto?.threshold80?.index ?? null;
|
if (metric === "minutes") return paretoEffective?.threshold80?.index ?? null;
|
||||||
const idx = metricRowsAll.findIndex((r) => (r.cumulativePct ?? 0) >= 80);
|
const idx = metricRowsAll.findIndex((r) => (r.cumulativePct ?? 0) >= 80);
|
||||||
return idx >= 0 ? idx : null;
|
return idx >= 0 ? idx : null;
|
||||||
}, [metric, pareto?.threshold80?.index, metricRowsAll]);
|
}, [metric, paretoEffective?.threshold80?.index, metricRowsAll]);
|
||||||
|
|
||||||
const heroData = useMemo(() => {
|
const heroData = useMemo(() => {
|
||||||
// Keep hero readable: top 12 (like your screenshot)
|
// Keep hero readable: top 12 (like your screenshot)
|
||||||
@@ -1420,8 +1578,7 @@ export default function DowntimePageClient() {
|
|||||||
}));
|
}));
|
||||||
}, [metricRowsAll]);
|
}, [metricRowsAll]);
|
||||||
|
|
||||||
const totalDowntimeMin = pareto?.totalMinutesLost ?? 0;
|
const totalDowntimeMin = paretoEffective?.totalMinutesLost ?? 0;
|
||||||
const events = eventsRes?.events ?? [];
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEventsBefore(null);
|
setEventsBefore(null);
|
||||||
@@ -1805,8 +1962,51 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{debug ? (
|
||||||
|
<div className="mt-6 rounded-2xl border border-white/10 bg-black/30 p-4 text-xs text-zinc-300">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="font-semibold text-white">Debug</div>
|
||||||
|
<div className="text-[11px] text-zinc-500">
|
||||||
|
Disable with <span className="text-zinc-300">debug=0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||||
|
<div className="text-[11px] text-zinc-500">Status</div>
|
||||||
|
<div className="mt-1 text-zinc-200">
|
||||||
|
loading={String(loading)} · err={err ?? "null"} · eventsLoading={String(eventsLoading)} · eventsErr=
|
||||||
|
{eventsErr ?? "null"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||||
|
<div className="text-[11px] text-zinc-500">Filters</div>
|
||||||
|
<div className="mt-1 text-zinc-200">
|
||||||
|
range={range} · machineId={machineId ?? "null"} · reasonCode={reasonCode ?? "null"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||||
|
<div className="text-[11px] text-zinc-500">API payload sizes</div>
|
||||||
|
<div className="mt-1 text-zinc-200">
|
||||||
|
pareto.rows={(pareto?.rows?.length ?? 0)} · events={(eventsRes?.events?.length ?? 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||||
|
<div className="text-[11px] text-zinc-500">Effective (used by UI)</div>
|
||||||
|
<div className="mt-1 text-zinc-200">
|
||||||
|
rows={(paretoEffective?.rows?.length ?? 0)} · usingEventsFallback={String(usingEventsFallback)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{!loading && !err && (
|
{!loading && !err && (
|
||||||
<>
|
<>
|
||||||
|
{eventsErr ? (
|
||||||
|
<div className="mt-6 rounded-2xl border border-amber-500/20 bg-amber-500/10 p-4 text-sm text-amber-100">
|
||||||
|
Events list unavailable: {eventsErr}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{/* KPI strip */}
|
{/* KPI strip */}
|
||||||
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-8">
|
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-8">
|
||||||
<KPI
|
<KPI
|
||||||
|
|||||||
1
flows (61) (1).json
Normal file
1
flows (61) (1).json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user