This commit is contained in:
Marcelo Dares
2025-12-18 22:22:20 +00:00
parent 0e9b2dd72d
commit 398fb01c21
8 changed files with 1364 additions and 76 deletions

View File

@@ -3,6 +3,24 @@
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { ComposedChart } from "recharts";
import { Cell } from "recharts";
import {
ResponsiveContainer,
ScatterChart,
Scatter,
LineChart,
Line,
CartesianGrid,
XAxis,
YAxis,
Tooltip,
ReferenceLine,
BarChart,
Bar,
} from "recharts";
type Heartbeat = {
@@ -38,6 +56,21 @@ type EventRow = {
requiresAck: boolean;
};
type CycleRow = {
ts: string; // ISO
t: number; // epoch ms
cycleCount: number | null;
actual: number; // seconds
ideal: number | null;
workOrderId: string | null;
sku: string | null;
};
type CycleDerivedRow = CycleRow & {
extra: number | null;
bucket: "normal" | "slow" | "microstop" | "macrostop" | "unknown";
};
type MachineDetail = {
id: string;
name: string;
@@ -55,6 +88,20 @@ export default function MachineDetailClient() {
const [machine, setMachine] = useState<MachineDetail | null>(null);
const [events, setEvents] = useState<EventRow[]>([]);
const [error, setError] = useState<string | null>(null);
const [cycles, setCycles] = useState<CycleRow[]>([]);
const [open, setOpen] = useState<null | "events" | "deviation" | "impact">(null);
const BUCKET = {
normal: { label: "Ciclo Normal", dot: "#12D18E", glow: "rgba(18,209,142,.35)", chip: "bg-emerald-500/15 text-emerald-300 border-emerald-500/20" },
slow: { label: "Ciclo Lento", dot: "#F7B500", glow: "rgba(247,181,0,.35)", chip: "bg-yellow-500/15 text-yellow-300 border-yellow-500/20" },
microstop:{ label: "Microparo", dot: "#FF7A00", glow: "rgba(255,122,0,.35)", chip: "bg-orange-500/15 text-orange-300 border-orange-500/20" },
macrostop:{ label: "Macroparo", dot: "#FF3B5C", glow: "rgba(255,59,92,.35)", chip: "bg-rose-500/15 text-rose-300 border-rose-500/20" },
unknown: { label: "Desconocido", dot: "#A1A1AA", glow: "rgba(161,161,170,.25)", chip: "bg-white/10 text-zinc-200 border-white/10" },
} as const;
useEffect(() => {
if (!machineId) return; // <-- IMPORTANT guard
@@ -68,6 +115,9 @@ export default function MachineDetailClient() {
credentials: "include",
});
const json = await res.json();
if (!alive) return;
@@ -79,6 +129,7 @@ export default function MachineDetailClient() {
setMachine(json.machine ?? null);
setEvents(json.events ?? []);
setCycles(json.cycles ?? []);
setError(null);
setLoading(false);
} catch {
@@ -86,6 +137,7 @@ export default function MachineDetailClient() {
setError("Network error");
setLoading(false);
}
}
load();
@@ -96,6 +148,8 @@ export default function MachineDetailClient() {
};
}, [machineId]);
function fmtPct(v?: number | null) {
if (v === null || v === undefined || Number.isNaN(v)) return "—";
return `${v.toFixed(1)}%`;
@@ -139,6 +193,222 @@ export default function MachineDetailClient() {
const kpi = machine?.latestKpi ?? null;
const offline = useMemo(() => isOffline(hb?.ts), [hb?.ts]);
const statusLabel = offline ? "OFFLINE" : (hb?.status ?? "UNKNOWN");
const cycleTarget = (machine as any)?.effectiveCycleTime ?? kpi?.cycleTime ?? null;
const ActiveRing = (props: any) => {
const { cx, cy, fill } = props;
if (cx == null || cy == null) return null;
return (
<g>
<circle cx={cx} cy={cy} r={7} fill="transparent" stroke="white" strokeWidth={2} />
<circle cx={cx} cy={cy} r={4} fill={fill} />
</g>
);
};
function MiniCard({
title,
subtitle,
value,
onClick,
}: {
title: string;
subtitle: string;
value: string;
onClick?: () => void;
}) {
const clickable = typeof onClick === "function";
if (clickable) {
return (
<button
type="button"
onClick={onClick}
className="rounded-2xl border border-white/10 bg-white/5 p-5 text-left hover:bg-white/10 transition cursor-pointer"
>
<div className="text-sm font-semibold text-white">{title}</div>
<div className="mt-1 text-xs text-zinc-400">{subtitle}</div>
<div className="mt-4 text-3xl font-semibold text-white">{value}</div>
</button>
);
}
return (
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 text-left">
<div className="text-sm font-semibold text-white">{title}</div>
<div className="mt-1 text-xs text-zinc-400">{subtitle}</div>
<div className="mt-4 text-3xl font-semibold text-white">{value}</div>
</div>
);
}
function Modal({
open,
onClose,
title,
children,
}: {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* overlay */}
<div className="absolute inset-0 bg-black/70" onClick={onClose} />
{/* panel */}
<div className="relative w-full max-w-5xl overflow-hidden rounded-3xl border border-white/10 bg-zinc-950/80 p-6 shadow-2xl backdrop-blur-xl">
{/* gradient wash (Step 2) */}
<div
className="pointer-events-none absolute inset-0 opacity-60"
style={{
background:
"radial-gradient(900px 400px at 20% 10%, rgba(16,185,129,.18), transparent 60%)," +
"radial-gradient(900px 400px at 85% 30%, rgba(59,130,246,.14), transparent 60%)," +
"radial-gradient(900px 500px at 50% 100%, rgba(244,63,94,.10), transparent 60%)",
}}
/>
{/* content */}
<div className="relative">
<div className="mb-4 flex items-center justify-between">
<div className="text-lg font-semibold text-white">{title}</div>
<button
onClick={onClose}
className="rounded-lg border border-white/10 bg-white/5 px-3 py-1 text-sm text-white hover:bg-white/10"
>
</button>
</div>
{children}
</div>
</div>
</div>
);
}
function CycleTooltip({ active, payload, label }: any) {
if (!active || !payload?.length) return null;
const p = payload[0]?.payload;
if (!p) return null;
const ideal = p.ideal ?? null;
const actual = p.actual ?? null;
const deltaPct = p.deltaPct ?? null;
return (
<div className="rounded-xl border border-white/10 bg-zinc-950/95 px-4 py-3 shadow-lg">
<div className="text-sm font-semibold text-white">Ciclo: {label}</div>
<div className="mt-2 space-y-1 text-xs text-zinc-300">
<div>Duración: <span className="text-white">{actual?.toFixed(2)}s</span></div>
<div>Ideal: <span className="text-white">{ideal != null ? `${ideal.toFixed(2)}s` : "—"}</span></div>
<div>Desviación: <span className="text-white">{deltaPct != null ? `${deltaPct.toFixed(1)}%` : "—"}</span></div>
</div>
</div>
);
}
const TOL = 0.10;
function hasIdealAndActual(r: CycleDerivedRow): r is CycleDerivedRow & { ideal: number; actual: number } {
return r.ideal != null && r.actual != null && r.ideal > 0;
}
const cycleDerived = useMemo(() => {
const rows = cycles ?? [];
const mapped: CycleDerivedRow[] = rows.map((c) => {
const ideal = c.ideal ?? null;
const actual = c.actual ?? null;
const extra = ideal != null && actual != null ? actual - ideal : null;
let bucket: CycleDerivedRow["bucket"] = "unknown";
if (ideal != null && actual != null) {
if (actual <= ideal * (1 + TOL)) bucket = "normal";
else if (extra != null && extra <= 1) bucket = "slow";
else if (extra != null && extra <= 10) bucket = "microstop";
else bucket = "macrostop";
}
return { ...c, ideal, actual, extra, bucket };
});
const counts = mapped.reduce(
(acc, r) => {
acc.total += 1;
acc[r.bucket] += 1;
if (r.extra != null && r.extra > 0) acc.extraTotal += r.extra;
return acc;
},
{ total: 0, normal: 0, slow: 0, microstop: 0, macrostop: 0, unknown: 0, extraTotal: 0 }
);
const deltas = mapped
.filter(hasIdealAndActual)
.map((r) => ((r.actual - r.ideal) / r.ideal) * 100);
const avgDeltaPct = deltas.length ? deltas.reduce((a, b) => a + b, 0) / deltas.length : null;
return { mapped, counts, avgDeltaPct };
}, [cycles]);
const deviationSeries = useMemo(() => {
// use last N cycles to keep chart readable
const last = cycleDerived.mapped.slice(-100);
return last
.map((r, idx) => {
const ideal = r.ideal;
const actual = r.actual;
if (ideal == null || actual == null || ideal <= 0) return null;
const deltaPct = ((actual - ideal) / ideal) * 100;
return {
i: idx + 1, // x-axis index (cycle order)
actual,
ideal,
deltaPct,
bucket: r.bucket,
};
})
.filter(Boolean) as Array<{
i: number;
actual: number;
ideal: number;
deltaPct: number;
bucket: string;
}>;
}, [cycleDerived.mapped]);
const impactAgg = useMemo(() => {
// sum extra seconds by bucket
const buckets = { slow: 0, microstop: 0, macrostop: 0 } as Record<string, number>;
for (const r of cycleDerived.mapped) {
if (!r.extra || r.extra <= 0) continue;
if (r.bucket === "slow" || r.bucket === "microstop" || r.bucket === "macrostop") {
buckets[r.bucket] += r.extra;
}
}
const rows = [
{ name: "Slow", seconds: Math.round(buckets.slow * 10) / 10 },
{ name: "Microstop", seconds: Math.round(buckets.microstop * 10) / 10 },
{ name: "Macrostop", seconds: Math.round(buckets.macrostop * 10) / 10 },
];
const total = rows.reduce((a, b) => a + b.seconds, 0);
return { rows, total };
}, [cycleDerived.mapped]);
return (
<div className="p-6">
@@ -228,11 +498,11 @@ export default function MachineDetailClient() {
</div>
<div className="mt-4 text-xs text-zinc-400">
Cycle target: <span className="text-white">{kpi?.cycleTime ? `${kpi.cycleTime}s` : "—"}</span>
Cycle target: <span className="text-white">{cycleTarget ? `${cycleTarget}s` : "—"}</span>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-2">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-2 flex flex-col">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-semibold text-white">Recent Events</div>
<div className="text-xs text-zinc-400">{events.length} shown</div>
@@ -240,8 +510,8 @@ export default function MachineDetailClient() {
{events.length === 0 ? (
<div className="text-sm text-zinc-400">No events yet.</div>
) : (
<div className="space-y-3">
) : (
<div className="h-[300px] overflow-y-auto no-scrollbar space-y-3">
{events.map((e) => (
<div key={e.id} className="rounded-xl border border-white/10 bg-black/20 p-4">
<div className="flex items-start justify-between gap-3">
@@ -274,7 +544,250 @@ export default function MachineDetailClient() {
)}
</div>
</div>
{/* Mini analysis cards */}
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-3">
<MiniCard
title="Eventos Detectados"
subtitle="Conteo por tipo (ciclos)"
value={`${cycleDerived.counts.slow + cycleDerived.counts.microstop + cycleDerived.counts.macrostop}`}
onClick={() => setOpen("events")}
/>
<MiniCard
title="Ciclo Real vs Estándar"
subtitle="Desviación promedio"
value={cycleDerived.avgDeltaPct == null ? "—" : `${cycleDerived.avgDeltaPct.toFixed(1)}%`}
onClick={() => setOpen("deviation")}
/>
<MiniCard
title="Impacto en Producción"
subtitle="Tiempo extra vs ideal"
value={`${Math.round(cycleDerived.counts.extraTotal)}s`}
onClick={() => setOpen("impact")}
/>
</div>
<Modal
open={open === "events"}
onClose={() => setOpen(null)}
title="Eventos Detectados"
>
<div className="max-h-[60vh] overflow-y-auto space-y-2 no-scrollbar">
{cycleDerived.mapped
.filter((r) => r.bucket !== "normal" && r.bucket !== "unknown")
.slice()
.reverse()
.map((r, idx) => {
const meta = BUCKET[r.bucket as keyof typeof BUCKET];
return (
<div
key={r.t ?? r.ts ?? idx}
className="rounded-xl border border-white/10 bg-white/5 p-3 flex items-center justify-between gap-3"
>
<div className="flex items-center gap-3 min-w-0">
{/* left accent dot */}
<span
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: meta.dot, boxShadow: `0 0 14px ${meta.glow}` }}
/>
<div className="min-w-0">
<div className="flex items-center gap-2">
{/* colored chip */}
<span className={`rounded-full border px-2 py-0.5 text-xs ${meta.chip}`}>
{meta.label}
</span>
<span className="text-sm text-white truncate">
{r.actual?.toFixed(2)}s
{r.ideal != null ? ` (ideal ${r.ideal.toFixed(2)}s)` : ""}
</span>
</div>
</div>
</div>
<div className="text-xs text-zinc-400 shrink-0">{timeAgo(r.ts)}</div>
</div>
);
})}
</div>
</Modal>
<Modal
open={open === "deviation"}
onClose={() => setOpen(null)}
title="Ciclo Real vs Estándar"
>
<div className="space-y-4">
{/* Summary cards */}
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="text-xs text-zinc-400">Ciclo estándar (ideal)</div>
<div className="mt-2 text-2xl font-semibold text-white">
{cycleTarget ? `${Number(cycleTarget).toFixed(1)}s` : "—"}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="text-xs text-zinc-400">Desviación promedio</div>
<div className="mt-2 text-2xl font-semibold text-white">
{cycleDerived.avgDeltaPct == null ? "—" : `${cycleDerived.avgDeltaPct.toFixed(1)}%`}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="text-xs text-zinc-400">Muestra</div>
<div className="mt-2 text-2xl font-semibold text-white">
{deviationSeries.length} ciclos
</div>
</div>
</div>
{/* Chart */}
<div className="h-[380px] rounded-3xl border border-white/10 bg-black/30 p-4 shadow-[0_0_30px_rgba(0,0,0,0.6)] backdrop-blur">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={deviationSeries}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.08)" />
<XAxis
dataKey="i"
type="number"
domain={[1, "dataMax"]}
allowDecimals={false}
tick={{ fill: "#a1a1aa" }}
/>
<YAxis
tick={{ fill: "#a1a1aa" }}
domain={
kpi?.cycleTime
? [
Math.max(0, kpi.cycleTime * (1 - TOL) - 2),
kpi.cycleTime * (1 + TOL) + 2,
]
: ["auto", "auto"]
}
/>
<Tooltip content={<CycleTooltip />} cursor={{ stroke: "rgba(255,255,255,0.15)" }} />
{/* Ideal center line */}
{kpi?.cycleTime ? (
<>
<ReferenceLine y={kpi.cycleTime} stroke="rgba(18,209,142,0.6)" strokeWidth={2} />
{/* ±10% tolerance band lines */}
<ReferenceLine
y={kpi.cycleTime * (1 - TOL)}
stroke="rgba(247,181,0,0.7)"
strokeDasharray="6 6"
/>
<ReferenceLine
y={kpi.cycleTime * (1 + TOL)}
stroke="rgba(247,181,0,0.7)"
strokeDasharray="6 6"
/>
</>
) : null}
{/* Optional: ideal line from series */}
<Line
dataKey="ideal"
dot={false}
activeDot={false}
stroke="rgba(255,255,255,0.35)"
/>
{/* ONE scatter so hover always matches */}
<Scatter
dataKey="actual"
isAnimationActive={false}
activeShape={<ActiveRing />}
shape={(props: any) => {
const { cx, cy, payload } = props;
const meta = BUCKET[payload.bucket as keyof typeof BUCKET] ?? BUCKET.unknown;
return (
<circle
cx={cx}
cy={cy}
r={5}
fill={meta.dot}
style={{ filter: `drop-shadow(0 0 8px ${meta.glow})` }}
/>
);
}}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
<div className="text-xs text-zinc-400">
Tip: la línea tenue es el ideal. Cada punto es un ciclo real.
</div>
</div>
</Modal>
<Modal
open={open === "impact"}
onClose={() => setOpen(null)}
title="Impacto en Producción"
>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="text-xs text-zinc-400">Tiempo extra total</div>
<div className="mt-2 text-2xl font-semibold text-white">
{Math.round(impactAgg.total)}s
</div>
</div>
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="text-xs text-zinc-400">Microstops</div>
<div className="mt-2 text-2xl font-semibold text-white">
{Math.round((impactAgg.rows.find(r => r.name === "Microstop")?.seconds ?? 0))}s
</div>
</div>
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="text-xs text-zinc-400">Macroparos</div>
<div className="mt-2 text-2xl font-semibold text-white">
{Math.round((impactAgg.rows.find(r => r.name === "Macrostop")?.seconds ?? 0))}s
</div>
</div>
</div>
<div className="h-[380px] rounded-3xl border border-white/10 bg-black/30 p-4 shadow-[0_0_30px_rgba(0,0,0,0.6)] backdrop-blur">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={impactAgg.rows}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fill: "#a1a1aa" }} />
<YAxis tick={{ fill: "#a1a1aa" }} />
<Tooltip
shared={false}
contentStyle={{ background: "rgba(0,0,0,0.85)", border: "1px solid rgba(255,255,255,0.1)" }}
labelStyle={{ color: "#fff" }}
formatter={(val: any) => [`${Number(val).toFixed(1)}s`, "Tiempo extra"]}
/>
<Bar dataKey="seconds" radius={[10, 10, 0, 0]} isAnimationActive={false}>
{impactAgg.rows.map((row, idx) => {
const key =
row.name === "Slow" ? "slow" :
row.name === "Microstop" ? "microstop" :
"macrostop";
return <Cell key={idx} fill={BUCKET[key].dot} />;
})}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<div className="text-xs text-zinc-400">
Esto es tiempo perdido vs ideal, distribuido por tipo de evento.
</div>
</div>
</Modal>
</>
)}
</div>
);

View File

@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
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 });
const body = await req.json().catch(() => null);
if (!body?.machineId || !body?.cycle) {
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 });
const c = body.cycle;
const tsMs =
(typeof c.timestamp === "number" && c.timestamp) ||
(typeof c.ts === "number" && c.ts) ||
(typeof c.event_timestamp === "number" && c.event_timestamp) ||
undefined;
const ts = tsMs ? new Date(tsMs) : new Date();
const row = await prisma.machineCycle.create({
data: {
orgId: machine.orgId,
machineId: machine.id,
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,
workOrderId: c.work_order_id ? String(c.work_order_id) : null,
sku: c.sku ? String(c.sku) : null,
cavities: typeof c.cavities === "number" ? c.cavities : null,
goodDelta: typeof c.good_delta === "number" ? c.good_delta : null,
scrapDelta: typeof c.scrap_delta === "number" ? c.scrap_delta : null,
},
});
return NextResponse.json({ ok: true, id: row.id, ts: row.ts });
}

View File

@@ -1,77 +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<string, string> = {
// 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 e = body.event;
for (const ev of events) {
if (!ev || typeof ev !== "object") {
skipped.push({ reason: "invalid_event_object" });
continue;
}
const ts =
typeof e?.data?.timestamp === "number"
? new Date(e.data.timestamp)
: undefined;
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;
// normalize inputs from event
const sev = String(e.severity ?? "").toLowerCase();
const typ = String(e.eventType ?? e.anomaly_type ?? "").toLowerCase();
const title = String(e.title ?? "").trim();
// 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 ALLOWED_TYPES = new Set([
"slow-cycle",
"anomaly-detected",
"performance-degradation",
"scrap-spike",
"down",
"microstop",
]);
const ts = tsMs ? new Date(tsMs) : new Date();
const ALLOWED_SEVERITIES = new Set(["warning", "critical"]);
// Severity defaulting (do not skip on severity — store for audit)
let sev = String((ev as any).severity ?? "").trim().toLowerCase();
if (!sev) sev = "warning";
// Drop generic/noise
if (!ALLOWED_SEVERITIES.has(sev) || !ALLOWED_TYPES.has(typ)) {
return NextResponse.json({ ok: true, skipped: true }, { status: 200 });
}
if (!title) return NextResponse.json({ ok: true, skipped: true }, { status: 200 });
const row = await prisma.machineEvent.create({
data: {
orgId: machine.orgId,
machineId: machine.id,
ts: ts ?? undefined,
topic: e.topic ? String(e.topic) : "event",
eventType: e.anomaly_type ? String(e.anomaly_type) : "unknown",
severity: e.severity ? String(e.severity) : "info",
requiresAck: !!e.requires_ack,
title: e.title ? String(e.title) : "Event",
description: e.description ? String(e.description) : null,
data: e.data ?? e, // store full blob
workOrderId: e?.data?.work_order_id ? String(e.data.work_order_id) : null,
},
});
return NextResponse.json({ ok: true, id: row.id, ts: row.ts });
// 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 });
}

View File

@@ -3,17 +3,169 @@ import type { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
function normalizeEvent(row: any) {
// -----------------------------
// 1) Parse row.data safely
// data may be:
// - object
// - array of objects
// - JSON string of either
// -----------------------------
const raw = row.data;
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 ?? {};
const normalizeType = (t: any) =>
String(t ?? "")
.trim()
.toLowerCase()
.replace(/_/g, "-");
// -----------------------------
// 2) Alias mapping (canonical types)
// -----------------------------
const ALIAS: Record<string, string> = {
// Spanish / synonyms
macroparo: "macrostop",
"macro-stop": "macrostop",
macro_stop: "macrostop",
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)
? "slow-cycle"
: "unknown");
const eventTypeRaw = normalizeType(inferredType);
let eventType = ALIAS[eventTypeRaw] ?? eventTypeRaw;
// -----------------------------
// 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) ??
blob?.severity ??
inner?.severity ??
"info"
)
.trim()
.toLowerCase();
const title =
String(
(row.title && row.title !== "Event" ? row.title : null) ??
blob?.title ??
inner?.title ??
(eventType === "slow-cycle" ? "Slow Cycle Detected" : "Event")
).trim();
const description =
row.description ??
blob?.description ??
inner?.description ??
(eventType === "slow-cycle" &&
(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 =
row.ts ??
(typeof blob?.timestamp === "number" ? new Date(blob.timestamp) : null) ??
(typeof inner?.timestamp === "number" ? new Date(inner.timestamp) : null) ??
null;
const workOrderId =
row.workOrderId ??
blob?.work_order_id ??
inner?.work_order_id ??
null;
return {
id: row.id,
ts,
topic: String(row.topic ?? blob?.topic ?? eventType),
eventType,
severity,
title,
description,
requiresAck: !!row.requiresAck,
workOrderId,
};
}
export async function GET(
_req: NextRequest,
{ params }: { params: { machineId: string } }
{ params }: { params: Promise<{ machineId: string }> }
) {
const session = await requireSession();
if (!session) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const { machineId } = params;
const { machineId } = await params;
const machine = await prisma.machine.findFirst({
where: { id: machineId, orgId: session.orgId },
@@ -51,18 +203,89 @@ export async function GET(
return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
}
const events = await prisma.machineEvent.findMany({
where: {
orgId: session.orgId,
machineId,
severity: { in: ["warning", "critical"] },
eventType: { in: ["slow-cycle", "anomaly-detected", "performance-degradation", "scrap-spike", "down", "microstop"] },
},
orderBy: { ts: "desc" },
take: 30,
select: { /* same as now */ },
const rawEvents = await prisma.machineEvent.findMany({
where: {
orgId: session.orgId,
machineId,
},
orderBy: { ts: "desc" },
take: 100, // pull more, we'll filter after normalization
select: {
id: true,
ts: true,
topic: true,
eventType: true,
severity: true,
title: true,
description: true,
requiresAck: true,
data: true,
workOrderId: true,
},
});
const normalized = rawEvents.map(normalizeEvent);
const ALLOWED_TYPES = new Set([
"slow-cycle",
"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) =>
["slow-cycle", "microstop", "macrostop"].includes(e.eventType) ||
["warning", "critical", "error"].includes(e.severity)
)
.slice(0, 30);
const rawCycles = await prisma.machineCycle.findMany({
where: { orgId: session.orgId, machineId },
orderBy: { ts: "desc" },
take: 200,
select: {
ts: true,
cycleCount: true,
actualCycleTime: true,
theoreticalCycleTime: true,
workOrderId: true,
sku: true,
},
});
// chart-friendly: oldest -> newest + numeric timestamps
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
cycleCount: c.cycleCount ?? null,
actual: c.actualCycleTime, // rename to what chart expects
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;
return NextResponse.json({
ok: true,
machine: {
@@ -72,7 +295,14 @@ export async function GET(
location: machine.location,
latestHeartbeat: machine.heartbeats[0] ?? null,
latestKpi: machine.kpiSnapshots[0] ?? null,
effectiveCycleTime
},
events,
cycles
});
}

View File

@@ -24,3 +24,8 @@ body {
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
/* Hide scrollbar but keep scrolling */
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }