diff --git a/app/(app)/machines/[machineId]/MachineDetailClient.tsx b/app/(app)/machines/[machineId]/MachineDetailClient.tsx index a368533..d4707f6 100644 --- a/app/(app)/machines/[machineId]/MachineDetailClient.tsx +++ b/app/(app)/machines/[machineId]/MachineDetailClient.tsx @@ -110,7 +110,7 @@ export default function MachineDetailClient() { async function load() { try { - const res = await fetch(`/api/machines/${machineId}`, { + const res = await fetch(`/api/machines/${machineId}?windowSec=10800`, { cache: "no-store", credentials: "include", }); @@ -242,6 +242,77 @@ export default function MachineDetailClient() { ); } + + function MachineActivityTimeline({ + segments, + windowSec, + }: { + segments: TimelineSeg[]; + windowSec: number; + }) { + return ( +
+
+
+
Machine Activity Timeline
+
Análisis en tiempo real de ciclos de producción
+
+
{windowSec}s
+
+ +
+ {(["normal","slow","microstop","macrostop"] as const).map((k) => ( +
+ + {BUCKET[k].label} +
+ ))} +
+ +
+ {/* time marks */} +
+ 0s + 3h +
+ + {/* strip */} +
+ {segments.length === 0 ? ( +
+ No timeline data yet. +
+ ) : ( + segments.map((seg, idx) => { + const wPct = Math.max(0.25, (seg.durationSec / windowSec) * 100); // min width for visibility + const meta = BUCKET[seg.state]; + + const glow = + seg.state === "microstop" || seg.state === "macrostop" + ? `0 0 22px ${meta.glow}` + : `0 0 12px ${meta.glow}`; + + return ( +
+ ); + }) + )} +
+
+
+ ); + } + function Modal({ open, onClose, @@ -409,6 +480,91 @@ export default function MachineDetailClient() { return { rows, total }; }, [cycleDerived.mapped]); + type TimelineState = "normal" | "slow" | "microstop" | "macrostop"; +type TimelineSeg = { + start: number; // ms + end: number; // ms + durationSec: number; + state: TimelineState; +}; + +function classifyGap(dtSec: number, idealSec: number): TimelineState { + const SLOW_X = 1.5; + const STOP_X = 3.0; + const MACRO_X = 10.0; + + if (dtSec <= idealSec * SLOW_X) return "normal"; + if (dtSec <= idealSec * STOP_X) return "slow"; + if (dtSec <= idealSec * MACRO_X) return "microstop"; + return "macrostop"; +} + +function mergeAdjacent(segs: TimelineSeg[]): TimelineSeg[] { + if (!segs.length) return []; + const out: TimelineSeg[] = [segs[0]]; + for (let i = 1; i < segs.length; i++) { + const prev = out[out.length - 1]; + const cur = segs[i]; + // merge if same state and touching + if (cur.state === prev.state && cur.start <= prev.end + 1) { + prev.end = Math.max(prev.end, cur.end); + prev.durationSec = (prev.end - prev.start) / 1000; + } else { + out.push(cur); + } + } + return out; +} + +const timeline = useMemo(() => { + const rows = cycles ?? []; + if (rows.length < 2) { + return { windowSec: 10800, segments: [] as TimelineSeg[], start: null as number | null, end: null as number | null }; + } + + // window: last 180s (like your screenshot) + const windowSec = 10800; + const end = rows[rows.length - 1].t; + const start = end - windowSec * 1000; + + // keep cycles that overlap window (need one cycle before start to build first interval) + const idxFirst = Math.max( + 0, + rows.findIndex(r => r.t >= start) - 1 + ); + const sliced = rows.slice(idxFirst); + + const segs: TimelineSeg[] = []; + + for (let i = 1; i < sliced.length; i++) { + const prev = sliced[i - 1]; + const cur = sliced[i]; + + const s = Math.max(prev.t, start); + const e = Math.min(cur.t, end); + if (e <= s) continue; + + const dtSec = (cur.t - prev.t) / 1000; + + const ideal = (cur.ideal ?? prev.ideal ?? cycleTarget ?? 0) as number; + if (!ideal || ideal <= 0) continue; + + const state = classifyGap(dtSec, ideal); + + segs.push({ + start: s, + end: e, + durationSec: (e - s) / 1000, + state, + }); + } + + const segments = mergeAdjacent(segs); + + return { windowSec, segments, start, end }; +}, [cycles, cycleTarget]); + + return (
@@ -471,6 +627,10 @@ export default function MachineDetailClient() {
+
+ +
+ {/* Work order + recent events */}
diff --git a/app/api/ingest/cycle/route.ts b/app/api/ingest/cycle/route.ts index 91f7c75..11dd6c7 100644 --- a/app/api/ingest/cycle/route.ts +++ b/app/api/ingest/cycle/route.ts @@ -30,7 +30,7 @@ export async function POST(req: Request) { data: { orgId: machine.orgId, machineId: machine.id, - ts, + ts, cycleCount: typeof c.cycle_count === "number" ? c.cycle_count : null, actualCycleTime: Number(c.actual_cycle_time), theoreticalCycleTime: c.theoretical_cycle_time != null ? Number(c.theoretical_cycle_time) : null, @@ -41,6 +41,5 @@ export async function POST(req: Request) { scrapDelta: typeof c.scrap_delta === "number" ? c.scrap_delta : null, }, }); - return NextResponse.json({ ok: true, id: row.id, ts: row.ts }); } diff --git a/app/api/ingest/event/route.ts b/app/api/ingest/event/route.ts index cc14edd..8d6f0f6 100644 --- a/app/api/ingest/event/route.ts +++ b/app/api/ingest/event/route.ts @@ -1,107 +1,153 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; +const normalizeType = (t: any) => + String(t ?? "") + .trim() + .toLowerCase() + .replace(/_/g, "-"); + +const CANON_TYPE: Record = { + // Node-RED + "production-stopped": "stop", + "oee-drop": "oee-drop", + "quality-spike": "quality-spike", + "predictive-oee-decline": "predictive-oee-decline", + "performance-degradation": "performance-degradation", + + // legacy / synonyms + "macroparo": "macrostop", + "macro-stop": "macrostop", + "microparo": "microstop", + "micro-paro": "microstop", + "down": "stop", +}; + +const ALLOWED_TYPES = new Set([ + "slow-cycle", + "microstop", + "macrostop", + "oee-drop", + "quality-spike", + "performance-degradation", + "predictive-oee-decline", +]); + +// thresholds for stop classification (tune later / move to machine config) +const MICROSTOP_SEC = 60; +const MACROSTOP_SEC = 300; + export async function POST(req: Request) { const apiKey = req.headers.get("x-api-key"); - if (!apiKey) return NextResponse.json({ ok: false, error: "Missing api key" }, { status: 401 }); + if (!apiKey) { + return NextResponse.json({ ok: false, error: "Missing api key" }, { status: 401 }); + } const body = await req.json().catch(() => null); if (!body?.machineId || !body?.event) { return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 }); } + const machine = await prisma.machine.findFirst({ where: { id: String(body.machineId), apiKey }, select: { id: true, orgId: true }, }); - if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + if (!machine) { + return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + } - + // Normalize to array (Node-RED sends array of anomalies) + const rawEvent = body.event; + const events = Array.isArray(rawEvent) ? rawEvent : [rawEvent]; - // Convert ms epoch -> Date if provided + const created: { id: string; ts: Date; eventType: string }[] = []; + const skipped: any[] = []; - - -const rawEvent = body.event; -const e = Array.isArray(rawEvent) ? rawEvent[0] : rawEvent; - -if (!e || typeof e !== "object") { - return NextResponse.json({ ok: false, error: "Invalid event object" }, { status: 400 }); -} -const rawType = - e.eventType ?? e.anomaly_type ?? e.topic ?? body.topic ?? ""; - -const normalizeType = (t: string) => - String(t) - .trim() - .toLowerCase() - .replace(/_/g, "-"); - -const typ = normalizeType(rawType); -const sev = String(e.severity ?? "").trim().toLowerCase(); - -// accept these types -const ALLOWED_TYPES = new Set([ - "slow-cycle", - "anomaly-detected", - "performance-degradation", - "scrap-spike", - "down", - "microstop", -]); - -if (!ALLOWED_TYPES.has(typ)) { - return NextResponse.json({ ok: true, skipped: true, reason: "type_not_allowed", typ, sev }, { status: 200 }); -} - -// optional: severity enforcement only for SOME types (not slow-cycle) -const NEEDS_HIGH_SEV = new Set(["down", "scrap-spike"]); -const ALLOWED_SEVERITIES = new Set(["warning", "critical", "error"]); - -if (NEEDS_HIGH_SEV.has(typ) && !ALLOWED_SEVERITIES.has(sev)) { - return NextResponse.json({ ok: true, skipped: true, reason: "severity_too_low", typ, sev }, { status: 200 }); -} - -// timestamp handling (support multiple field names) -const tsMs = - (typeof (e as any)?.timestamp === "number" && (e as any).timestamp) || - (typeof e?.data?.timestamp === "number" && e.data.timestamp) || - (typeof e?.data?.event_timestamp === "number" && e.data.event_timestamp) || - (typeof e?.data?.ts === "number" && e.data.ts) || - undefined; - -const ts = tsMs ? new Date(tsMs) : new Date(); // default to now if missing - -const title = - String(e.title ?? "").trim() || - (typ === "slow-cycle" ? "Slow Cycle Detected" : "Event"); - -const description = e.description - ? String(e.description) - : null; - -const row = await prisma.machineEvent.create({ - data: { - orgId: machine.orgId, - machineId: machine.id, - ts, - - topic: String(e.topic ?? typ), - eventType: typ, // ✅ store normalized type - severity: sev || "info", // ✅ store normalized severity - requiresAck: !!e.requires_ack, - title, - description, - - data: e.data ?? e, - - workOrderId: - (e as any)?.work_order_id ? String((e as any).work_order_id) - : e?.data?.work_order_id ? String(e.data.work_order_id) - : null, - }, -}); - -return NextResponse.json({ ok: true, id: row.id, ts: row.ts }); + for (const ev of events) { + if (!ev || typeof ev !== "object") { + skipped.push({ reason: "invalid_event_object" }); + continue; + } + const rawType = (ev as any).eventType ?? (ev as any).anomaly_type ?? (ev as any).topic ?? body.topic ?? ""; + const typ0 = normalizeType(rawType); + const typ = CANON_TYPE[typ0] ?? typ0; + + // Determine timestamp + const tsMs = + (typeof (ev as any)?.timestamp === "number" && (ev as any).timestamp) || + (typeof (ev as any)?.data?.timestamp === "number" && (ev as any).data.timestamp) || + (typeof (ev as any)?.data?.event_timestamp === "number" && (ev as any).data.event_timestamp) || + null; + + const ts = tsMs ? new Date(tsMs) : new Date(); + + // Severity defaulting (do not skip on severity — store for audit) + let sev = String((ev as any).severity ?? "").trim().toLowerCase(); + if (!sev) sev = "warning"; + + // Stop classification -> microstop/macrostop + let finalType = typ; + if (typ === "stop") { + const stopSec = + (typeof (ev as any)?.data?.stoppage_duration_seconds === "number" && (ev as any).data.stoppage_duration_seconds) || + (typeof (ev as any)?.data?.stop_duration_seconds === "number" && (ev as any).data.stop_duration_seconds) || + null; + + if (stopSec != null) { + finalType = stopSec >= MACROSTOP_SEC ? "macrostop" : "microstop"; + } else { + // missing duration -> conservative + finalType = "microstop"; + } + } + + if (!ALLOWED_TYPES.has(finalType)) { + skipped.push({ reason: "type_not_allowed", typ: finalType, sev }); + continue; + } + + const title = + String((ev as any).title ?? "").trim() || + (finalType === "slow-cycle" ? "Slow Cycle Detected" : + finalType === "macrostop" ? "Macrostop Detected" : + finalType === "microstop" ? "Microstop Detected" : + "Event"); + + const description = (ev as any).description ? String((ev as any).description) : null; + + // store full blob, ensure object + const rawData = (ev as any).data ?? ev; + const dataObj = typeof rawData === "string" ? (() => { + try { return JSON.parse(rawData); } catch { return { raw: rawData }; } + })() : rawData; + + const row = await prisma.machineEvent.create({ + data: { + orgId: machine.orgId, + machineId: machine.id, + ts, + topic: String((ev as any).topic ?? finalType), + eventType: finalType, + severity: sev, + requiresAck: !!(ev as any).requires_ack, + title, + description, + data: dataObj, + workOrderId: + (ev as any)?.work_order_id ? String((ev as any).work_order_id) + : (ev as any)?.data?.work_order_id ? String((ev as any).data.work_order_id) + : null, + sku: + (ev as any)?.sku ? String((ev as any).sku) + : (ev as any)?.data?.sku ? String((ev as any).data.sku) + : null, + }, + }); + + created.push({ id: row.id, ts: row.ts, eventType: row.eventType }); + } + + return NextResponse.json({ ok: true, createdCount: created.length, created, skippedCount: skipped.length, skipped }); } diff --git a/app/api/machines/[machineId]/route.ts b/app/api/machines/[machineId]/route.ts index bd345fb..ce9def6 100644 --- a/app/api/machines/[machineId]/route.ts +++ b/app/api/machines/[machineId]/route.ts @@ -4,9 +4,26 @@ import { prisma } from "@/lib/prisma"; import { requireSession } from "@/lib/auth/requireSession"; function normalizeEvent(row: any) { - // data can be object OR [object] + // ----------------------------- + // 1) Parse row.data safely + // data may be: + // - object + // - array of objects + // - JSON string of either + // ----------------------------- const raw = row.data; - const blob = Array.isArray(raw) ? raw[0] : raw; + + let parsed: any = raw; + if (typeof raw === "string") { + try { + parsed = JSON.parse(raw); + } catch { + parsed = raw; // keep as string if not JSON + } + } + + // data can be object OR [object] + const blob = Array.isArray(parsed) ? parsed[0] : parsed; // some payloads nest details under blob.data const inner = blob?.data ?? blob ?? {}; @@ -17,21 +34,71 @@ function normalizeEvent(row: any) { .toLowerCase() .replace(/_/g, "-"); - // Prefer the DB columns if they are meaningful - const fromDbType = row.eventType && row.eventType !== "unknown" ? row.eventType : null; - const fromBlobType = blob?.anomaly_type ?? blob?.eventType ?? blob?.topic ?? inner?.anomaly_type ?? inner?.eventType ?? null; + // ----------------------------- + // 2) Alias mapping (canonical types) + // ----------------------------- + const ALIAS: Record = { + // Spanish / synonyms + macroparo: "macrostop", + "macro-stop": "macrostop", + macro_stop: "macrostop", - // infer slow-cycle if the signature exists + microparo: "microstop", + "micro-paro": "microstop", + micro_stop: "microstop", + + // Node-RED types + "production-stopped": "stop", // we'll classify to micro/macro below + + // legacy / generic + down: "stop", + }; + + // ----------------------------- + // 3) Determine event type from DB or blob + // ----------------------------- + const fromDbType = + row.eventType && row.eventType !== "unknown" ? row.eventType : null; + + const fromBlobType = + blob?.anomaly_type ?? + blob?.eventType ?? + blob?.topic ?? + inner?.anomaly_type ?? + inner?.eventType ?? + null; + + // infer slow-cycle if signature exists const inferredType = fromDbType ?? fromBlobType ?? - ((inner?.actual_cycle_time && inner?.theoretical_cycle_time) || (blob?.actual_cycle_time && blob?.theoretical_cycle_time) + ((inner?.actual_cycle_time && inner?.theoretical_cycle_time) || + (blob?.actual_cycle_time && blob?.theoretical_cycle_time) ? "slow-cycle" : "unknown"); + const eventTypeRaw = normalizeType(inferredType); + let eventType = ALIAS[eventTypeRaw] ?? eventTypeRaw; - const eventType = normalizeType(inferredType); + // ----------------------------- + // 4) Optional: classify "stop" into micro/macro based on duration if present + // (keeps old rows usable even if they stored production-stopped) + // ----------------------------- + if (eventType === "stop") { + const stopSec = + (typeof inner?.stoppage_duration_seconds === "number" && inner.stoppage_duration_seconds) || + (typeof blob?.stoppage_duration_seconds === "number" && blob.stoppage_duration_seconds) || + (typeof inner?.stop_duration_seconds === "number" && inner.stop_duration_seconds) || + null; + // tune these thresholds to match your MES spec + const MACROSTOP_SEC = 300; // 5 min + eventType = stopSec != null && stopSec >= MACROSTOP_SEC ? "macrostop" : "microstop"; + } + + // ----------------------------- + // 5) Severity, title, description, timestamp + // ----------------------------- const severity = String( (row.severity && row.severity !== "info" ? row.severity : null) ?? @@ -55,10 +122,10 @@ function normalizeEvent(row: any) { blob?.description ?? inner?.description ?? (eventType === "slow-cycle" && - inner?.actual_cycle_time && - inner?.theoretical_cycle_time && - inner?.delta_percent != null - ? `Cycle took ${Number(inner.actual_cycle_time).toFixed(1)}s (+${inner.delta_percent}% vs ${Number(inner.theoretical_cycle_time).toFixed(1)}s objetivo)` + (inner?.actual_cycle_time ?? blob?.actual_cycle_time) && + (inner?.theoretical_cycle_time ?? blob?.theoretical_cycle_time) && + (inner?.delta_percent ?? blob?.delta_percent) != null + ? `Cycle took ${Number(inner?.actual_cycle_time ?? blob?.actual_cycle_time).toFixed(1)}s (+${Number(inner?.delta_percent ?? blob?.delta_percent)}% vs ${Number(inner?.theoretical_cycle_time ?? blob?.theoretical_cycle_time).toFixed(1)}s objetivo)` : null); const ts = @@ -161,24 +228,54 @@ export async function GET( const ALLOWED_TYPES = new Set([ "slow-cycle", - "anomaly-detected", - "performance-degradation", - "scrap-spike", - "down", "microstop", + "macrostop", + "oee-drop", + "quality-spike", + "performance-degradation", + "predictive-oee-decline", ]); const events = normalized .filter((e) => ALLOWED_TYPES.has(e.eventType)) // keep slow-cycle even if severity is info, otherwise require warning/critical/error - .filter((e) => e.eventType === "slow-cycle" || ["warning", "critical", "error"].includes(e.severity)) + .filter((e) => + ["slow-cycle", "microstop", "macrostop"].includes(e.eventType) || + ["warning", "critical", "error"].includes(e.severity) + ) .slice(0, 30); +// ---- cycles window ---- +const url = new URL(_req.url); +const windowSec = Number(url.searchParams.get("windowSec") ?? "10800"); // default 3h + +const latestKpi = machine.kpiSnapshots[0] ?? null; + +// If KPI cycleTime missing, fallback to DB cycles (we fetch 1 first) +const latestCycleForIdeal = await prisma.machineCycle.findFirst({ + where: { orgId: session.orgId, machineId }, + orderBy: { ts: "desc" }, + select: { theoreticalCycleTime: true }, +}); + +const effectiveCycleTime = + latestKpi?.cycleTime ?? + latestCycleForIdeal?.theoreticalCycleTime ?? + null; + +// Estimate how many cycles we need to cover the window. +// Add buffer so the chart doesn’t look “tight”. +const estCycleSec = Math.max(1, Number(effectiveCycleTime ?? 14)); +const needed = Math.ceil(windowSec / estCycleSec) + 50; + +// Safety cap to avoid crazy payloads +const takeCycles = Math.min(5000, Math.max(200, needed)); + const rawCycles = await prisma.machineCycle.findMany({ where: { orgId: session.orgId, machineId }, orderBy: { ts: "desc" }, - take: 200, + take: takeCycles, select: { ts: true, cycleCount: true, @@ -194,23 +291,14 @@ const cycles = rawCycles .slice() .reverse() .map((c) => ({ - ts: c.ts, // keep Date for “time ago” UI - t: c.ts.getTime(), // numeric x-axis for charts + ts: c.ts, + t: c.ts.getTime(), cycleCount: c.cycleCount ?? null, - actual: c.actualCycleTime, // rename to what chart expects + actual: c.actualCycleTime, ideal: c.theoreticalCycleTime ?? null, workOrderId: c.workOrderId ?? null, sku: c.sku ?? null, - } -)); - -const latestKpi = machine.kpiSnapshots[0] ?? null; - -// rawCycles is ordered DESC, so [0] is the most recent cycle row -const latestCycleIdeal = rawCycles[0]?.theoreticalCycleTime ?? null; - -// REAL effective value (not mock): prefer KPI if present, else fallback to cycles table -const effectiveCycleTime = latestKpi?.cycleTime ?? latestCycleIdeal ?? null; + }));