Compare commits
4 Commits
0e9b2dd72d
...
a369a69978
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a369a69978 | ||
|
|
1fe0b4dbf9 | ||
|
|
945ff2dc09 | ||
|
|
ffc39a5c90 |
@@ -3,6 +3,24 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
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 = {
|
type Heartbeat = {
|
||||||
@@ -38,6 +56,21 @@ type EventRow = {
|
|||||||
requiresAck: boolean;
|
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 = {
|
type MachineDetail = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -55,6 +88,20 @@ export default function MachineDetailClient() {
|
|||||||
const [machine, setMachine] = useState<MachineDetail | null>(null);
|
const [machine, setMachine] = useState<MachineDetail | null>(null);
|
||||||
const [events, setEvents] = useState<EventRow[]>([]);
|
const [events, setEvents] = useState<EventRow[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!machineId) return; // <-- IMPORTANT guard
|
if (!machineId) return; // <-- IMPORTANT guard
|
||||||
@@ -63,11 +110,14 @@ export default function MachineDetailClient() {
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/machines/${machineId}`, {
|
const res = await fetch(`/api/machines/${machineId}?windowSec=10800`, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
});
|
});
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
|
|
||||||
@@ -79,6 +129,7 @@ export default function MachineDetailClient() {
|
|||||||
|
|
||||||
setMachine(json.machine ?? null);
|
setMachine(json.machine ?? null);
|
||||||
setEvents(json.events ?? []);
|
setEvents(json.events ?? []);
|
||||||
|
setCycles(json.cycles ?? []);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -86,6 +137,7 @@ export default function MachineDetailClient() {
|
|||||||
setError("Network error");
|
setError("Network error");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
load();
|
load();
|
||||||
@@ -96,6 +148,8 @@ export default function MachineDetailClient() {
|
|||||||
};
|
};
|
||||||
}, [machineId]);
|
}, [machineId]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function fmtPct(v?: number | null) {
|
function fmtPct(v?: number | null) {
|
||||||
if (v === null || v === undefined || Number.isNaN(v)) return "—";
|
if (v === null || v === undefined || Number.isNaN(v)) return "—";
|
||||||
return `${v.toFixed(1)}%`;
|
return `${v.toFixed(1)}%`;
|
||||||
@@ -139,6 +193,378 @@ export default function MachineDetailClient() {
|
|||||||
const kpi = machine?.latestKpi ?? null;
|
const kpi = machine?.latestKpi ?? null;
|
||||||
const offline = useMemo(() => isOffline(hb?.ts), [hb?.ts]);
|
const offline = useMemo(() => isOffline(hb?.ts), [hb?.ts]);
|
||||||
const statusLabel = offline ? "OFFLINE" : (hb?.status ?? "UNKNOWN");
|
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 MachineActivityTimeline({
|
||||||
|
segments,
|
||||||
|
windowSec,
|
||||||
|
}: {
|
||||||
|
segments: TimelineSeg[];
|
||||||
|
windowSec: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-white">Machine Activity Timeline</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-400">Análisis en tiempo real de ciclos de producción</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-400">{windowSec}s</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-zinc-300">
|
||||||
|
{(["normal","slow","microstop","macrostop"] as const).map((k) => (
|
||||||
|
<div key={k} className="flex items-center gap-2">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: BUCKET[k].dot }} />
|
||||||
|
<span>{BUCKET[k].label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 rounded-2xl border border-white/10 bg-black/25 p-4">
|
||||||
|
{/* time marks */}
|
||||||
|
<div className="mb-2 flex justify-between text-[11px] text-zinc-500">
|
||||||
|
<span>0s</span>
|
||||||
|
<span>3h</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* strip */}
|
||||||
|
<div className="flex h-14 w-full overflow-hidden rounded-2xl">
|
||||||
|
{segments.length === 0 ? (
|
||||||
|
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-400">
|
||||||
|
No timeline data yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
key={`${seg.start}-${seg.end}-${idx}`}
|
||||||
|
title={`${meta.label}: ${seg.durationSec.toFixed(1)}s`}
|
||||||
|
className="h-full"
|
||||||
|
style={{
|
||||||
|
width: `${wPct}%`,
|
||||||
|
background: meta.dot,
|
||||||
|
boxShadow: glow,
|
||||||
|
opacity: 0.95,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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]);
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
@@ -201,6 +627,10 @@ export default function MachineDetailClient() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<MachineActivityTimeline segments={timeline.segments} windowSec={timeline.windowSec} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Work order + recent events */}
|
{/* Work order + recent events */}
|
||||||
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
|
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-1">
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-1">
|
||||||
@@ -228,11 +658,11 @@ export default function MachineDetailClient() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 text-xs text-zinc-400">
|
<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>
|
</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="mb-3 flex items-center justify-between">
|
||||||
<div className="text-sm font-semibold text-white">Recent Events</div>
|
<div className="text-sm font-semibold text-white">Recent Events</div>
|
||||||
<div className="text-xs text-zinc-400">{events.length} shown</div>
|
<div className="text-xs text-zinc-400">{events.length} shown</div>
|
||||||
@@ -240,8 +670,8 @@ export default function MachineDetailClient() {
|
|||||||
|
|
||||||
{events.length === 0 ? (
|
{events.length === 0 ? (
|
||||||
<div className="text-sm text-zinc-400">No events yet.</div>
|
<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) => (
|
{events.map((e) => (
|
||||||
<div key={e.id} className="rounded-xl border border-white/10 bg-black/20 p-4">
|
<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">
|
<div className="flex items-start justify-between gap-3">
|
||||||
@@ -274,7 +704,250 @@ export default function MachineDetailClient() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
45
app/api/ingest/cycle/route.ts
Normal file
45
app/api/ingest/cycle/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
@@ -1,77 +1,336 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { normalizeEventV1 } from "@/lib/contracts/v1";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
function getClientIp(req: Request) {
|
||||||
const apiKey = req.headers.get("x-api-key");
|
const xf = req.headers.get("x-forwarded-for");
|
||||||
if (!apiKey) return NextResponse.json({ ok: false, error: "Missing api key" }, { status: 401 });
|
if (xf) return xf.split(",")[0]?.trim() || null;
|
||||||
|
return req.headers.get("x-real-ip") || null;
|
||||||
|
}
|
||||||
|
|
||||||
const body = await req.json().catch(() => null);
|
function parseSeqToBigInt(seq: unknown): bigint | null {
|
||||||
if (!body?.machineId || !body?.event) {
|
if (seq === null || seq === undefined) return null;
|
||||||
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
|
if (typeof seq === "number") {
|
||||||
|
if (!Number.isInteger(seq) || seq < 0) return null;
|
||||||
|
return BigInt(seq);
|
||||||
}
|
}
|
||||||
|
if (typeof seq === "string" && /^\d+$/.test(seq)) return BigInt(seq);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const machine = await prisma.machine.findFirst({
|
const normalizeType = (t: any) =>
|
||||||
where: { id: String(body.machineId), apiKey },
|
String(t ?? "")
|
||||||
select: { id: true, orgId: true },
|
.trim()
|
||||||
});
|
.toLowerCase()
|
||||||
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
.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",
|
||||||
|
|
||||||
// Convert ms epoch -> Date if provided
|
// legacy / synonyms
|
||||||
|
"macroparo": "macrostop",
|
||||||
|
"macro-stop": "macrostop",
|
||||||
|
"microparo": "microstop",
|
||||||
const e = body.event;
|
"micro-paro": "microstop",
|
||||||
|
"down": "stop",
|
||||||
const ts =
|
};
|
||||||
typeof e?.data?.timestamp === "number"
|
|
||||||
? new Date(e.data.timestamp)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
|
|
||||||
const ALLOWED_TYPES = new Set([
|
const ALLOWED_TYPES = new Set([
|
||||||
"slow-cycle",
|
"slow-cycle",
|
||||||
"anomaly-detected",
|
|
||||||
"performance-degradation",
|
|
||||||
"scrap-spike",
|
|
||||||
"down",
|
|
||||||
"microstop",
|
"microstop",
|
||||||
|
"macrostop",
|
||||||
|
"oee-drop",
|
||||||
|
"quality-spike",
|
||||||
|
"performance-degradation",
|
||||||
|
"predictive-oee-decline",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ALLOWED_SEVERITIES = new Set(["warning", "critical"]);
|
// thresholds for stop classification (tune later / move to machine config)
|
||||||
|
const MICROSTOP_SEC = 60;
|
||||||
|
const MACROSTOP_SEC = 300;
|
||||||
|
|
||||||
// Drop generic/noise
|
export async function POST(req: Request) {
|
||||||
if (!ALLOWED_SEVERITIES.has(sev) || !ALLOWED_TYPES.has(typ)) {
|
const endpoint = "/api/ingest/event";
|
||||||
return NextResponse.json({ ok: true, skipped: true }, { status: 200 });
|
const ip = getClientIp(req);
|
||||||
}
|
const userAgent = req.headers.get("user-agent");
|
||||||
|
|
||||||
if (!title) return NextResponse.json({ ok: true, skipped: true }, { status: 200 });
|
let rawBody: any = null;
|
||||||
|
let orgId: string | null = null;
|
||||||
|
let machineId: string | null = null;
|
||||||
|
let schemaVersion: string | null = null;
|
||||||
const row = await prisma.machineEvent.create({
|
let seq: bigint | null = null;
|
||||||
data: {
|
let tsDeviceDate: Date | null = null;
|
||||||
orgId: machine.orgId,
|
|
||||||
machineId: machine.id,
|
try {
|
||||||
ts: ts ?? undefined,
|
// 1) Auth header exists
|
||||||
|
const apiKey = req.headers.get("x-api-key");
|
||||||
topic: e.topic ? String(e.topic) : "event",
|
if (!apiKey) {
|
||||||
eventType: e.anomaly_type ? String(e.anomaly_type) : "unknown",
|
await prisma.ingestLog.create({
|
||||||
severity: e.severity ? String(e.severity) : "info",
|
data: {
|
||||||
requiresAck: !!e.requires_ack,
|
endpoint,
|
||||||
title: e.title ? String(e.title) : "Event",
|
ok: false,
|
||||||
description: e.description ? String(e.description) : null,
|
status: 401,
|
||||||
|
errorCode: "MISSING_API_KEY",
|
||||||
data: e.data ?? e, // store full blob
|
errorMsg: "Missing api key",
|
||||||
|
ip,
|
||||||
workOrderId: e?.data?.work_order_id ? String(e.data.work_order_id) : null,
|
userAgent,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return NextResponse.json({ ok: false, error: "Missing api key" }, { status: 401 });
|
||||||
return NextResponse.json({ ok: true, id: row.id, ts: row.ts });
|
}
|
||||||
|
|
||||||
|
// 2) Parse JSON
|
||||||
|
rawBody = await req.json().catch(() => null);
|
||||||
|
|
||||||
|
// 3) Reject arrays at the contract boundary (Phase 0 rule)
|
||||||
|
// Edge MUST split arrays into one event per POST.
|
||||||
|
if (rawBody?.event && Array.isArray(rawBody.event)) {
|
||||||
|
await prisma.ingestLog.create({
|
||||||
|
data: {
|
||||||
|
endpoint,
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
errorCode: "EVENT_ARRAY_NOT_ALLOWED",
|
||||||
|
errorMsg: "Edge must split arrays; send one event per request.",
|
||||||
|
body: rawBody,
|
||||||
|
machineId: rawBody?.machineId ? String(rawBody.machineId) : null,
|
||||||
|
ip,
|
||||||
|
userAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: "Invalid payload", detail: "event array not allowed; split on edge" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Normalize to v1 (legacy tolerated)
|
||||||
|
const normalized = normalizeEventV1(rawBody);
|
||||||
|
if (!normalized.ok) {
|
||||||
|
await prisma.ingestLog.create({
|
||||||
|
data: {
|
||||||
|
endpoint,
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
errorCode: "INVALID_PAYLOAD",
|
||||||
|
errorMsg: normalized.error,
|
||||||
|
body: rawBody,
|
||||||
|
ip,
|
||||||
|
userAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: false, error: "Invalid payload", detail: normalized.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = normalized.value;
|
||||||
|
|
||||||
|
schemaVersion = body.schemaVersion;
|
||||||
|
machineId = body.machineId;
|
||||||
|
seq = parseSeqToBigInt(body.seq);
|
||||||
|
tsDeviceDate = new Date(body.tsDevice);
|
||||||
|
|
||||||
|
// 5) Authorize machineId + apiKey
|
||||||
|
const machine = await prisma.machine.findFirst({
|
||||||
|
where: { id: machineId, apiKey },
|
||||||
|
select: { id: true, orgId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!machine) {
|
||||||
|
await prisma.ingestLog.create({
|
||||||
|
data: {
|
||||||
|
endpoint,
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
errorCode: "UNAUTHORIZED",
|
||||||
|
errorMsg: "Unauthorized (machineId/apiKey mismatch)",
|
||||||
|
body: rawBody,
|
||||||
|
machineId,
|
||||||
|
schemaVersion,
|
||||||
|
seq,
|
||||||
|
tsDevice: tsDeviceDate,
|
||||||
|
ip,
|
||||||
|
userAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
orgId = machine.orgId;
|
||||||
|
|
||||||
|
// 6) Canonicalize + classify type (keep for now; later move to edge in A1)
|
||||||
|
const ev = body.event;
|
||||||
|
|
||||||
|
const rawType =
|
||||||
|
(ev as any).eventType ?? (ev as any).anomaly_type ?? (ev as any).topic ?? (body as any).topic ?? "";
|
||||||
|
const typ0 = normalizeType(rawType);
|
||||||
|
const typ = CANON_TYPE[typ0] ?? typ0;
|
||||||
|
|
||||||
|
let finalType = typ;
|
||||||
|
|
||||||
|
// Stop classification -> microstop/macrostop
|
||||||
|
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 {
|
||||||
|
finalType = "microstop";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_TYPES.has(finalType)) {
|
||||||
|
await prisma.ingestLog.create({
|
||||||
|
data: {
|
||||||
|
orgId,
|
||||||
|
machineId: machine.id,
|
||||||
|
endpoint,
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
errorCode: "TYPE_NOT_ALLOWED",
|
||||||
|
errorMsg: `Event type not allowed: ${finalType}`,
|
||||||
|
schemaVersion,
|
||||||
|
seq,
|
||||||
|
tsDevice: tsDeviceDate,
|
||||||
|
body: rawBody,
|
||||||
|
ip,
|
||||||
|
userAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: "Invalid event type", detail: finalType },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine severity
|
||||||
|
let sev = String((ev as any).severity ?? "").trim().toLowerCase();
|
||||||
|
if (!sev) sev = "warning";
|
||||||
|
|
||||||
|
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
|
||||||
|
const rawData = (ev as any).data ?? ev;
|
||||||
|
const dataObj =
|
||||||
|
typeof rawData === "string"
|
||||||
|
? (() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawData);
|
||||||
|
} catch {
|
||||||
|
return { raw: rawData };
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
: rawData;
|
||||||
|
|
||||||
|
// Prefer work_order_id always
|
||||||
|
const 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;
|
||||||
|
|
||||||
|
const sku =
|
||||||
|
(ev as any)?.sku ? String((ev as any).sku)
|
||||||
|
: (ev as any)?.data?.sku ? String((ev as any).data.sku)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 7) Store event with Phase 0 meta
|
||||||
|
const row = await prisma.machineEvent.create({
|
||||||
|
data: {
|
||||||
|
orgId,
|
||||||
|
machineId: machine.id,
|
||||||
|
|
||||||
|
// Phase 0 meta
|
||||||
|
schemaVersion,
|
||||||
|
seq,
|
||||||
|
ts: tsDeviceDate,
|
||||||
|
|
||||||
|
topic: String((ev as any).topic ?? finalType),
|
||||||
|
eventType: finalType,
|
||||||
|
severity: sev,
|
||||||
|
requiresAck: !!(ev as any).requires_ack,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
data: dataObj,
|
||||||
|
workOrderId,
|
||||||
|
sku,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optional: update machine last seen
|
||||||
|
await prisma.machine.update({
|
||||||
|
where: { id: machine.id },
|
||||||
|
data: {
|
||||||
|
schemaVersion,
|
||||||
|
seq,
|
||||||
|
tsDevice: tsDeviceDate,
|
||||||
|
tsServer: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 8) Ingest log success
|
||||||
|
await prisma.ingestLog.create({
|
||||||
|
data: {
|
||||||
|
orgId,
|
||||||
|
machineId: machine.id,
|
||||||
|
endpoint,
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
schemaVersion,
|
||||||
|
seq,
|
||||||
|
tsDevice: tsDeviceDate,
|
||||||
|
body: rawBody,
|
||||||
|
ip,
|
||||||
|
userAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
createdCount: 1,
|
||||||
|
created: [{ id: row.id, ts: row.ts, eventType: row.eventType }],
|
||||||
|
skippedCount: 0,
|
||||||
|
skipped: [],
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.message ? String(err.message) : "Unknown error";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.ingestLog.create({
|
||||||
|
data: {
|
||||||
|
orgId,
|
||||||
|
machineId,
|
||||||
|
endpoint,
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
errorCode: "SERVER_ERROR",
|
||||||
|
errorMsg: msg,
|
||||||
|
schemaVersion,
|
||||||
|
seq,
|
||||||
|
tsDevice: tsDeviceDate ?? undefined,
|
||||||
|
body: rawBody,
|
||||||
|
ip,
|
||||||
|
userAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: false, error: "Server error", detail: msg }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,168 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { normalizeHeartbeatV1 } from "@/lib/contracts/v1";
|
||||||
|
|
||||||
|
function getClientIp(req: Request) {
|
||||||
|
const xf = req.headers.get("x-forwarded-for");
|
||||||
|
if (xf) return xf.split(",")[0]?.trim() || null;
|
||||||
|
return req.headers.get("x-real-ip") || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSeqToBigInt(seq: unknown): bigint | null {
|
||||||
|
if (seq === null || seq === undefined) return null;
|
||||||
|
if (typeof seq === "number") {
|
||||||
|
if (!Number.isInteger(seq) || seq < 0) return null;
|
||||||
|
return BigInt(seq);
|
||||||
|
}
|
||||||
|
if (typeof seq === "string" && /^\d+$/.test(seq)) return BigInt(seq);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const apiKey = req.headers.get("x-api-key");
|
const endpoint = "/api/ingest/heartbeat";
|
||||||
if (!apiKey) return NextResponse.json({ ok: false, error: "Missing api key" }, { status: 401 });
|
const ip = getClientIp(req);
|
||||||
|
const userAgent = req.headers.get("user-agent");
|
||||||
|
|
||||||
const body = await req.json().catch(() => null);
|
let rawBody: any = null;
|
||||||
if (!body?.machineId || !body?.status) {
|
let orgId: string | null = null;
|
||||||
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
|
let machineId: string | null = null;
|
||||||
|
let seq: bigint | null = null;
|
||||||
|
let schemaVersion: string | null = null;
|
||||||
|
let tsDeviceDate: Date | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1) Auth header exists
|
||||||
|
const apiKey = req.headers.get("x-api-key");
|
||||||
|
if (!apiKey) {
|
||||||
|
await prisma.ingestLog.create({
|
||||||
|
data: { endpoint, ok: false, status: 401, errorCode: "MISSING_API_KEY", errorMsg: "Missing api key", ip, userAgent },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: false, error: "Missing api key" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Parse JSON
|
||||||
|
rawBody = await req.json().catch(() => null);
|
||||||
|
|
||||||
|
// 3) Normalize to v1 (legacy tolerated)
|
||||||
|
const normalized = normalizeHeartbeatV1(rawBody);
|
||||||
|
if (!normalized.ok) {
|
||||||
|
await prisma.ingestLog.create({
|
||||||
|
data: { endpoint, ok: false, status: 400, errorCode: "INVALID_PAYLOAD", errorMsg: normalized.error, body: rawBody, ip, userAgent },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: false, error: "Invalid payload", detail: normalized.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = normalized.value;
|
||||||
|
schemaVersion = body.schemaVersion;
|
||||||
|
machineId = body.machineId;
|
||||||
|
seq = parseSeqToBigInt(body.seq);
|
||||||
|
tsDeviceDate = new Date(body.tsDevice);
|
||||||
|
|
||||||
|
// 4) Authorize machineId + apiKey
|
||||||
|
const machine = await prisma.machine.findFirst({
|
||||||
|
where: { id: machineId, apiKey },
|
||||||
|
select: { id: true, orgId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!machine) {
|
||||||
|
await prisma.ingestLog.create({
|
||||||
|
data: {
|
||||||
|
endpoint,
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
errorCode: "UNAUTHORIZED",
|
||||||
|
errorMsg: "Unauthorized (machineId/apiKey mismatch)",
|
||||||
|
body: rawBody,
|
||||||
|
machineId,
|
||||||
|
schemaVersion,
|
||||||
|
seq,
|
||||||
|
tsDevice: tsDeviceDate,
|
||||||
|
ip,
|
||||||
|
userAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
orgId = machine.orgId;
|
||||||
|
|
||||||
|
// 5) Store heartbeat
|
||||||
|
// Keep your legacy fields, but store meta fields too.
|
||||||
|
const hb = await prisma.machineHeartbeat.create({
|
||||||
|
data: {
|
||||||
|
orgId,
|
||||||
|
machineId: machine.id,
|
||||||
|
|
||||||
|
// Phase 0 meta
|
||||||
|
schemaVersion,
|
||||||
|
seq,
|
||||||
|
ts: tsDeviceDate,
|
||||||
|
|
||||||
|
// Legacy payload compatibility
|
||||||
|
status: body.status ? String(body.status) : (body.online ? "RUN" : "STOP"),
|
||||||
|
message: body.message ? String(body.message) : null,
|
||||||
|
ip: body.ip ? String(body.ip) : null,
|
||||||
|
fwVersion: body.fwVersion ? String(body.fwVersion) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optional: update machine last seen (same as KPI)
|
||||||
|
await prisma.machine.update({
|
||||||
|
where: { id: machine.id },
|
||||||
|
data: {
|
||||||
|
schemaVersion,
|
||||||
|
seq,
|
||||||
|
tsDevice: tsDeviceDate,
|
||||||
|
tsServer: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6) Ingest log success
|
||||||
|
await prisma.ingestLog.create({
|
||||||
|
data: {
|
||||||
|
orgId,
|
||||||
|
machineId: machine.id,
|
||||||
|
endpoint,
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
schemaVersion,
|
||||||
|
seq,
|
||||||
|
tsDevice: tsDeviceDate,
|
||||||
|
body: rawBody,
|
||||||
|
ip,
|
||||||
|
userAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
id: hb.id,
|
||||||
|
tsDevice: hb.ts,
|
||||||
|
tsServer: hb.tsServer,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.message ? String(err.message) : "Unknown error";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.ingestLog.create({
|
||||||
|
data: {
|
||||||
|
orgId,
|
||||||
|
machineId,
|
||||||
|
endpoint,
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
errorCode: "SERVER_ERROR",
|
||||||
|
errorMsg: msg,
|
||||||
|
schemaVersion,
|
||||||
|
seq,
|
||||||
|
tsDevice: tsDeviceDate ?? undefined,
|
||||||
|
body: rawBody,
|
||||||
|
ip,
|
||||||
|
userAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: false, error: "Server error", detail: msg }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
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 hb = await prisma.machineHeartbeat.create({
|
|
||||||
data: {
|
|
||||||
orgId: machine.orgId,
|
|
||||||
machineId: machine.id,
|
|
||||||
status: String(body.status),
|
|
||||||
message: body.message ? String(body.message) : null,
|
|
||||||
ip: body.ip ? String(body.ip) : null,
|
|
||||||
fwVersion: body.fwVersion ? String(body.fwVersion) : null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, id: hb.id, ts: hb.ts });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +1,217 @@
|
|||||||
|
// mis-control-tower/app/api/ingest/kpi/route.ts
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { normalizeSnapshotV1 } from "@/lib/contracts/v1";
|
||||||
|
|
||||||
|
function getClientIp(req: Request) {
|
||||||
|
const xf = req.headers.get("x-forwarded-for");
|
||||||
|
if (xf) return xf.split(",")[0]?.trim() || null;
|
||||||
|
return req.headers.get("x-real-ip") || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSeqToBigInt(seq: unknown): bigint | null {
|
||||||
|
if (seq === null || seq === undefined) return null;
|
||||||
|
if (typeof seq === "number") {
|
||||||
|
if (!Number.isInteger(seq) || seq < 0) return null;
|
||||||
|
return BigInt(seq);
|
||||||
|
}
|
||||||
|
if (typeof seq === "string" && /^\d+$/.test(seq)) return BigInt(seq);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const apiKey = req.headers.get("x-api-key");
|
const endpoint = "/api/ingest/kpi";
|
||||||
if (!apiKey) return NextResponse.json({ ok: false, error: "Missing api key" }, { status: 401 });
|
const startedAt = Date.now();
|
||||||
|
const ip = getClientIp(req);
|
||||||
|
const userAgent = req.headers.get("user-agent");
|
||||||
|
|
||||||
const body = await req.json().catch(() => null);
|
let rawBody: any = null;
|
||||||
if (!body?.machineId || !body?.kpis) {
|
let orgId: string | null = null;
|
||||||
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
|
let machineId: string | null = null;
|
||||||
|
let seq: bigint | null = null;
|
||||||
|
let schemaVersion: string | null = null;
|
||||||
|
let tsDeviceDate: Date | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiKey = req.headers.get("x-api-key");
|
||||||
|
if (!apiKey) {
|
||||||
|
await prisma.ingestLog.create({
|
||||||
|
data: {
|
||||||
|
endpoint,
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
errorCode: "MISSING_API_KEY",
|
||||||
|
errorMsg: "Missing api key",
|
||||||
|
ip,
|
||||||
|
userAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: false, error: "Missing api key" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
rawBody = await req.json().catch(() => null);
|
||||||
|
const normalized = normalizeSnapshotV1(rawBody);
|
||||||
|
if (!normalized.ok) {
|
||||||
|
await prisma.ingestLog.create({
|
||||||
|
data: {
|
||||||
|
endpoint,
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
errorCode: "INVALID_PAYLOAD",
|
||||||
|
errorMsg: normalized.error,
|
||||||
|
body: rawBody,
|
||||||
|
ip,
|
||||||
|
userAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: false, error: "Invalid payload", detail: normalized.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = normalized.value;
|
||||||
|
|
||||||
|
schemaVersion = body.schemaVersion;
|
||||||
|
machineId = body.machineId;
|
||||||
|
seq = parseSeqToBigInt(body.seq);
|
||||||
|
tsDeviceDate = new Date(body.tsDevice);
|
||||||
|
|
||||||
|
// Auth: machineId + apiKey must match
|
||||||
|
const machine = await prisma.machine.findFirst({
|
||||||
|
where: { id: machineId, apiKey },
|
||||||
|
select: { id: true, orgId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!machine) {
|
||||||
|
await prisma.ingestLog.create({
|
||||||
|
data: {
|
||||||
|
endpoint,
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
errorCode: "UNAUTHORIZED",
|
||||||
|
errorMsg: "Unauthorized (machineId/apiKey mismatch)",
|
||||||
|
body: rawBody,
|
||||||
|
machineId,
|
||||||
|
schemaVersion,
|
||||||
|
seq,
|
||||||
|
tsDevice: tsDeviceDate,
|
||||||
|
ip,
|
||||||
|
userAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
orgId = machine.orgId;
|
||||||
|
|
||||||
|
const wo = body.activeWorkOrder ?? {};
|
||||||
|
const k = body.kpis ?? {};
|
||||||
|
const safeCycleTime =
|
||||||
|
typeof body.cycleTime === "number" && body.cycleTime > 0
|
||||||
|
? body.cycleTime
|
||||||
|
: (typeof (wo as any).cycleTime === "number" && (wo as any).cycleTime > 0 ? (wo as any).cycleTime : null);
|
||||||
|
|
||||||
|
const safeCavities =
|
||||||
|
typeof body.cavities === "number" && body.cavities > 0
|
||||||
|
? body.cavities
|
||||||
|
: (typeof (wo as any).cavities === "number" && (wo as any).cavities > 0 ? (wo as any).cavities : null);
|
||||||
|
// Write snapshot (ts = tsDevice; tsServer auto)
|
||||||
|
const row = await prisma.machineKpiSnapshot.create({
|
||||||
|
data: {
|
||||||
|
orgId,
|
||||||
|
machineId: machine.id,
|
||||||
|
|
||||||
|
// Phase 0 meta
|
||||||
|
schemaVersion,
|
||||||
|
seq,
|
||||||
|
ts: tsDeviceDate, // store device-time in ts; server-time goes to ts_server
|
||||||
|
|
||||||
|
// Work order fields
|
||||||
|
workOrderId: wo.id ? String(wo.id) : null,
|
||||||
|
sku: wo.sku ? String(wo.sku) : null,
|
||||||
|
target: typeof wo.target === "number" ? Math.trunc(wo.target) : null,
|
||||||
|
good: typeof wo.good === "number" ? Math.trunc(wo.good) : null,
|
||||||
|
scrap: typeof wo.scrap === "number" ? Math.trunc(wo.scrap) : null,
|
||||||
|
|
||||||
|
// Counters
|
||||||
|
cycleCount: typeof body.cycle_count === "number" ? body.cycle_count : null,
|
||||||
|
goodParts: typeof body.good_parts === "number" ? body.good_parts : null,
|
||||||
|
scrapParts: typeof body.scrap_parts === "number" ? body.scrap_parts : null,
|
||||||
|
cavities: safeCavities,
|
||||||
|
|
||||||
|
// Cycle times
|
||||||
|
cycleTime: safeCycleTime,
|
||||||
|
actualCycle: typeof body.actualCycleTime === "number" ? body.actualCycleTime : null,
|
||||||
|
|
||||||
|
// KPIs (0..100)
|
||||||
|
availability: typeof k.availability === "number" ? k.availability : null,
|
||||||
|
performance: typeof k.performance === "number" ? k.performance : null,
|
||||||
|
quality: typeof k.quality === "number" ? k.quality : null,
|
||||||
|
oee: typeof k.oee === "number" ? k.oee : null,
|
||||||
|
|
||||||
|
trackingEnabled: typeof body.trackingEnabled === "boolean" ? body.trackingEnabled : null,
|
||||||
|
productionStarted: typeof body.productionStarted === "boolean" ? body.productionStarted : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optional but useful: update machine "last seen" meta fields
|
||||||
|
await prisma.machine.update({
|
||||||
|
where: { id: machine.id },
|
||||||
|
data: {
|
||||||
|
schemaVersion,
|
||||||
|
seq,
|
||||||
|
tsDevice: tsDeviceDate,
|
||||||
|
tsServer: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.ingestLog.create({
|
||||||
|
data: {
|
||||||
|
orgId,
|
||||||
|
machineId: machine.id,
|
||||||
|
endpoint,
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
schemaVersion,
|
||||||
|
seq,
|
||||||
|
tsDevice: tsDeviceDate,
|
||||||
|
body: rawBody,
|
||||||
|
ip,
|
||||||
|
userAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
id: row.id,
|
||||||
|
tsDevice: row.ts,
|
||||||
|
tsServer: row.tsServer,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.message ? String(err.message) : "Unknown error";
|
||||||
|
|
||||||
|
// Never fail the request because logging failed
|
||||||
|
try {
|
||||||
|
await prisma.ingestLog.create({
|
||||||
|
data: {
|
||||||
|
orgId,
|
||||||
|
machineId,
|
||||||
|
endpoint,
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
errorCode: "SERVER_ERROR",
|
||||||
|
errorMsg: msg,
|
||||||
|
schemaVersion,
|
||||||
|
seq,
|
||||||
|
tsDevice: tsDeviceDate ?? undefined,
|
||||||
|
body: rawBody,
|
||||||
|
ip,
|
||||||
|
userAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: false, error: "Server error", detail: msg }, { status: 500 });
|
||||||
|
} finally {
|
||||||
|
// (If later you add latency_ms to IngestLog, you can store Date.now() - startedAt here.)
|
||||||
|
void startedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 wo = body.activeWorkOrder ?? {};
|
|
||||||
const k = body.kpis ?? {};
|
|
||||||
|
|
||||||
const row = await prisma.machineKpiSnapshot.create({
|
|
||||||
data: {
|
|
||||||
orgId: machine.orgId,
|
|
||||||
machineId: machine.id,
|
|
||||||
|
|
||||||
workOrderId: wo.id ? String(wo.id) : null,
|
|
||||||
sku: wo.sku ? String(wo.sku) : null,
|
|
||||||
|
|
||||||
target: typeof wo.target === "number" ? wo.target : null,
|
|
||||||
good: typeof wo.good === "number" ? wo.good : null,
|
|
||||||
scrap: typeof wo.scrap === "number" ? wo.scrap : null,
|
|
||||||
|
|
||||||
cycleCount: typeof body.cycle_count === "number" ? body.cycle_count : null,
|
|
||||||
goodParts: typeof body.good_parts === "number" ? body.good_parts : null,
|
|
||||||
|
|
||||||
cycleTime: typeof body.cycleTime === "number" ? body.cycleTime : null,
|
|
||||||
|
|
||||||
availability: typeof k.availability === "number" ? k.availability : null,
|
|
||||||
performance: typeof k.performance === "number" ? k.performance : null,
|
|
||||||
quality: typeof k.quality === "number" ? k.quality : null,
|
|
||||||
oee: typeof k.oee === "number" ? k.oee : null,
|
|
||||||
|
|
||||||
trackingEnabled: typeof body.trackingEnabled === "boolean" ? body.trackingEnabled : null,
|
|
||||||
productionStarted: typeof body.productionStarted === "boolean" ? body.productionStarted : null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, id: row.id, ts: row.ts });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,169 @@ import type { NextRequest } from "next/server";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireSession } from "@/lib/auth/requireSession";
|
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(
|
export async function GET(
|
||||||
_req: NextRequest,
|
_req: NextRequest,
|
||||||
{ params }: { params: { machineId: string } }
|
{ params }: { params: Promise<{ machineId: string }> }
|
||||||
) {
|
) {
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { machineId } = params;
|
const { machineId } = await params;
|
||||||
|
|
||||||
|
|
||||||
const machine = await prisma.machine.findFirst({
|
const machine = await prisma.machine.findFirst({
|
||||||
where: { id: machineId, orgId: session.orgId },
|
where: { id: machineId, orgId: session.orgId },
|
||||||
@@ -51,18 +203,106 @@ export async function GET(
|
|||||||
return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
|
return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const events = await prisma.machineEvent.findMany({
|
const rawEvents = await prisma.machineEvent.findMany({
|
||||||
where: {
|
where: {
|
||||||
orgId: session.orgId,
|
orgId: session.orgId,
|
||||||
machineId,
|
machineId,
|
||||||
severity: { in: ["warning", "critical"] },
|
},
|
||||||
eventType: { in: ["slow-cycle", "anomaly-detected", "performance-degradation", "scrap-spike", "down", "microstop"] },
|
orderBy: { ts: "desc" },
|
||||||
},
|
take: 100, // pull more, we'll filter after normalization
|
||||||
orderBy: { ts: "desc" },
|
select: {
|
||||||
take: 30,
|
id: true,
|
||||||
select: { /* same as now */ },
|
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);
|
||||||
|
|
||||||
|
|
||||||
|
// ---- 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: takeCycles,
|
||||||
|
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,
|
||||||
|
t: c.ts.getTime(),
|
||||||
|
cycleCount: c.cycleCount ?? null,
|
||||||
|
actual: c.actualCycleTime,
|
||||||
|
ideal: c.theoreticalCycleTime ?? null,
|
||||||
|
workOrderId: c.workOrderId ?? null,
|
||||||
|
sku: c.sku ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
machine: {
|
machine: {
|
||||||
@@ -72,7 +312,14 @@ export async function GET(
|
|||||||
location: machine.location,
|
location: machine.location,
|
||||||
latestHeartbeat: machine.heartbeats[0] ?? null,
|
latestHeartbeat: machine.heartbeats[0] ?? null,
|
||||||
latestKpi: machine.kpiSnapshots[0] ?? null,
|
latestKpi: machine.kpiSnapshots[0] ?? null,
|
||||||
|
effectiveCycleTime
|
||||||
|
|
||||||
},
|
},
|
||||||
events,
|
events,
|
||||||
|
cycles
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -24,3 +24,8 @@ body {
|
|||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
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; }
|
||||||
|
|
||||||
|
|||||||
74
data_validation_edge_contract.md
Normal file
74
data_validation_edge_contract.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# MIS Edge → Cloud Contract (v1.0)
|
||||||
|
|
||||||
|
All ingest payloads MUST include these top-level meta fields:
|
||||||
|
|
||||||
|
- schemaVersion: "1.0"
|
||||||
|
- machineId: UUID
|
||||||
|
- tsDevice: epoch milliseconds (number)
|
||||||
|
- seq: monotonic integer per machine (persisted across reboots)
|
||||||
|
|
||||||
|
## POST /api/ingest/heartbeat
|
||||||
|
{
|
||||||
|
"schemaVersion": "1.0",
|
||||||
|
"machineId": "uuid",
|
||||||
|
"tsDevice": 1766427568335,
|
||||||
|
"seq": 123,
|
||||||
|
"online": true,
|
||||||
|
"message": "NR heartbeat",
|
||||||
|
"ip": "192.168.18.33",
|
||||||
|
"fwVersion": "raspi-nodered-1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
## POST /api/ingest/kpi (snapshot)
|
||||||
|
{
|
||||||
|
"schemaVersion": "1.0",
|
||||||
|
"machineId": "uuid",
|
||||||
|
"tsDevice": 1766427568335,
|
||||||
|
"seq": 124,
|
||||||
|
"activeWorkOrder": { "id": "OT-10001", "sku": "YoguFrut", "target": 600000, "good": 312640, "scrap": 0 },
|
||||||
|
"cycle_count": 31264,
|
||||||
|
"good_parts": 312640,
|
||||||
|
"trackingEnabled": true,
|
||||||
|
"productionStarted": true,
|
||||||
|
"cycleTime": 14,
|
||||||
|
"kpis": { "oee": 100, "availability": 100, "performance": 100, "quality": 100 }
|
||||||
|
}
|
||||||
|
|
||||||
|
## POST /api/ingest/cycle
|
||||||
|
{
|
||||||
|
"schemaVersion": "1.0",
|
||||||
|
"machineId": "uuid",
|
||||||
|
"tsDevice": 1766427568335,
|
||||||
|
"seq": 125,
|
||||||
|
"cycle": {
|
||||||
|
"timestamp": 1766427568335,
|
||||||
|
"cycle_count": 31264,
|
||||||
|
"actual_cycle_time": 10.141,
|
||||||
|
"theoretical_cycle_time": 14,
|
||||||
|
"work_order_id": "OT-10001",
|
||||||
|
"sku": "YoguFrut",
|
||||||
|
"cavities": 10,
|
||||||
|
"good_delta": 10,
|
||||||
|
"scrap_total": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
## POST /api/ingest/event
|
||||||
|
Edge MUST split arrays; cloud expects one event per request.
|
||||||
|
{
|
||||||
|
"schemaVersion": "1.0",
|
||||||
|
"machineId": "uuid",
|
||||||
|
"tsDevice": 1766427568335,
|
||||||
|
"seq": 126,
|
||||||
|
"event": {
|
||||||
|
"anomaly_type": "slow-cycle",
|
||||||
|
"severity": "warning",
|
||||||
|
"title": "Slow Cycle Detected",
|
||||||
|
"description": "Cycle took 23.6s",
|
||||||
|
"timestamp": 1766427568335,
|
||||||
|
"work_order_id": "OT-10001",
|
||||||
|
"cycle_count": 31265,
|
||||||
|
"data": {},
|
||||||
|
"kpi_snapshot": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
210
lib/contracts/v1.ts
Normal file
210
lib/contracts/v1.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
// /home/mdares/mis-control-tower/lib/contracts/v1.ts
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 0: freeze schema version string now and never change it without bumping.
|
||||||
|
* If you later create v2, make a new file or new constant.
|
||||||
|
*/
|
||||||
|
export const SCHEMA_VERSION = "1.0";
|
||||||
|
|
||||||
|
// KPI scale is frozen as 0..100 (you confirmed)
|
||||||
|
const KPI_0_100 = z.number().min(0).max(100);
|
||||||
|
|
||||||
|
export const SnapshotV1 = z
|
||||||
|
.object({
|
||||||
|
schemaVersion: z.literal(SCHEMA_VERSION),
|
||||||
|
machineId: z.string().uuid(),
|
||||||
|
tsDevice: z.number().int().nonnegative(), // epoch ms
|
||||||
|
// IMPORTANT: seq should be sent as string if it can ever exceed JS safe int
|
||||||
|
seq: z.union([z.number().int().nonnegative(), z.string().regex(/^\d+$/)]),
|
||||||
|
|
||||||
|
// current shape (keep it flat so Node-RED changes are minimal)
|
||||||
|
activeWorkOrder: z
|
||||||
|
.object({
|
||||||
|
id: z.string(),
|
||||||
|
sku: z.string().optional(),
|
||||||
|
target: z.number().optional(),
|
||||||
|
good: z.number().optional(),
|
||||||
|
scrap: z.number().optional(),
|
||||||
|
})
|
||||||
|
.partial()
|
||||||
|
.optional(),
|
||||||
|
|
||||||
|
cycle_count: z.number().int().nonnegative().optional(),
|
||||||
|
good_parts: z.number().int().nonnegative().optional(),
|
||||||
|
scrap_parts: z.number().int().nonnegative().optional(),
|
||||||
|
cavities: z.number().int().positive().optional(),
|
||||||
|
|
||||||
|
cycleTime: z.number().nonnegative().optional(), // theoretical/target cycle time
|
||||||
|
actualCycleTime: z.number().nonnegative().optional(), // optional
|
||||||
|
|
||||||
|
trackingEnabled: z.boolean().optional(),
|
||||||
|
productionStarted: z.boolean().optional(),
|
||||||
|
|
||||||
|
kpis: z.object({
|
||||||
|
oee: KPI_0_100,
|
||||||
|
availability: KPI_0_100,
|
||||||
|
performance: KPI_0_100,
|
||||||
|
quality: KPI_0_100,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TEMPORARY: Accept your current legacy payload while Node-RED is not sending
|
||||||
|
* schemaVersion/tsDevice/seq yet. Remove this once edge is upgraded.
|
||||||
|
*/
|
||||||
|
const SnapshotLegacy = z
|
||||||
|
.object({
|
||||||
|
machineId: z.any(),
|
||||||
|
kpis: z.any(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export type SnapshotV1Type = z.infer<typeof SnapshotV1>;
|
||||||
|
|
||||||
|
export function normalizeSnapshotV1(raw: unknown): { ok: true; value: SnapshotV1Type } | { ok: false; error: string } {
|
||||||
|
const strict = SnapshotV1.safeParse(raw);
|
||||||
|
if (strict.success) return { ok: true, value: strict.data };
|
||||||
|
|
||||||
|
// Legacy fallback (temporary)
|
||||||
|
const legacy = SnapshotLegacy.safeParse(raw);
|
||||||
|
if (!legacy.success) {
|
||||||
|
return { ok: false, error: strict.error.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
const b: any = legacy.data;
|
||||||
|
|
||||||
|
// Build a "best effort" SnapshotV1 so ingest works during transition.
|
||||||
|
// seq is intentionally set to "0" if missing (so you can still store);
|
||||||
|
// once Node-RED emits real seq, dedupe and ordering become reliable.
|
||||||
|
const migrated: any = {
|
||||||
|
schemaVersion: SCHEMA_VERSION,
|
||||||
|
machineId: String(b.machineId),
|
||||||
|
tsDevice: typeof b.tsDevice === "number" ? b.tsDevice : Date.now(),
|
||||||
|
seq: typeof b.seq === "number" || typeof b.seq === "string" ? b.seq : "0",
|
||||||
|
...b,
|
||||||
|
};
|
||||||
|
|
||||||
|
const recheck = SnapshotV1.safeParse(migrated);
|
||||||
|
if (!recheck.success) return { ok: false, error: recheck.error.message };
|
||||||
|
return { ok: true, value: recheck.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
const HeartbeatV1 = z.object({
|
||||||
|
schemaVersion: z.literal(SCHEMA_VERSION),
|
||||||
|
machineId: z.string().uuid(),
|
||||||
|
tsDevice: z.number().int().nonnegative(),
|
||||||
|
seq: z.union([z.number().int().nonnegative(), z.string().regex(/^\d+$/)]),
|
||||||
|
|
||||||
|
// legacy shape you currently send: status/message/ip/fwVersion
|
||||||
|
status: z.string().optional(),
|
||||||
|
message: z.string().optional(),
|
||||||
|
ip: z.string().optional(),
|
||||||
|
fwVersion: z.string().optional(),
|
||||||
|
|
||||||
|
// new canonical boolean
|
||||||
|
online: z.boolean().optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
export function normalizeHeartbeatV1(raw: unknown) {
|
||||||
|
const strict = HeartbeatV1.safeParse(raw);
|
||||||
|
if (strict.success) return { ok: true as const, value: strict.data };
|
||||||
|
|
||||||
|
// legacy fallback: allow missing meta
|
||||||
|
const legacy = z.object({ machineId: z.any() }).passthrough().safeParse(raw);
|
||||||
|
if (!legacy.success) return { ok: false as const, error: strict.error.message };
|
||||||
|
|
||||||
|
const b: any = legacy.data;
|
||||||
|
const migrated: any = {
|
||||||
|
schemaVersion: SCHEMA_VERSION,
|
||||||
|
machineId: String(b.machineId),
|
||||||
|
tsDevice: typeof b.tsDevice === "number" ? b.tsDevice : Date.now(),
|
||||||
|
seq: typeof b.seq === "number" || typeof b.seq === "string" ? b.seq : "0",
|
||||||
|
...b,
|
||||||
|
};
|
||||||
|
|
||||||
|
const recheck = HeartbeatV1.safeParse(migrated);
|
||||||
|
if (!recheck.success) return { ok: false as const, error: recheck.error.message };
|
||||||
|
return { ok: true as const, value: recheck.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
const CycleV1 = z.object({
|
||||||
|
schemaVersion: z.literal(SCHEMA_VERSION),
|
||||||
|
machineId: z.string().uuid(),
|
||||||
|
tsDevice: z.number().int().nonnegative(),
|
||||||
|
seq: z.union([z.number().int().nonnegative(), z.string().regex(/^\d+$/)]),
|
||||||
|
|
||||||
|
cycle: z.object({
|
||||||
|
timestamp: z.number().int().positive(),
|
||||||
|
cycle_count: z.number().int().nonnegative(),
|
||||||
|
actual_cycle_time: z.number(),
|
||||||
|
theoretical_cycle_time: z.number().optional(),
|
||||||
|
work_order_id: z.string(),
|
||||||
|
sku: z.string().optional(),
|
||||||
|
cavities: z.number().optional(),
|
||||||
|
good_delta: z.number().optional(),
|
||||||
|
scrap_total: z.number().optional(),
|
||||||
|
}).passthrough(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
export function normalizeCycleV1(raw: unknown) {
|
||||||
|
const strict = CycleV1.safeParse(raw);
|
||||||
|
if (strict.success) return { ok: true as const, value: strict.data };
|
||||||
|
|
||||||
|
// legacy fallback: { machineId, cycle }
|
||||||
|
const legacy = z.object({ machineId: z.any(), cycle: z.any() }).passthrough().safeParse(raw);
|
||||||
|
if (!legacy.success) return { ok: false as const, error: strict.error.message };
|
||||||
|
|
||||||
|
const b: any = legacy.data;
|
||||||
|
const tsDevice = typeof b.tsDevice === "number" ? b.tsDevice : (b.cycle?.timestamp ?? Date.now());
|
||||||
|
const seq = typeof b.seq === "number" || typeof b.seq === "string" ? b.seq : (b.cycle?.cycle_count ?? "0");
|
||||||
|
|
||||||
|
const migrated: any = { schemaVersion: SCHEMA_VERSION, machineId: String(b.machineId), tsDevice, seq, ...b };
|
||||||
|
const recheck = CycleV1.safeParse(migrated);
|
||||||
|
if (!recheck.success) return { ok: false as const, error: recheck.error.message };
|
||||||
|
return { ok: true as const, value: recheck.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
const EventV1 = z.object({
|
||||||
|
schemaVersion: z.literal(SCHEMA_VERSION),
|
||||||
|
machineId: z.string().uuid(),
|
||||||
|
tsDevice: z.number().int().nonnegative(),
|
||||||
|
seq: z.union([z.number().int().nonnegative(), z.string().regex(/^\d+$/)]),
|
||||||
|
|
||||||
|
// IMPORTANT: event must be an object, not an array
|
||||||
|
event: z.object({
|
||||||
|
anomaly_type: z.string(),
|
||||||
|
severity: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
timestamp: z.number().int().positive(),
|
||||||
|
work_order_id: z.string(),
|
||||||
|
cycle_count: z.number().optional(),
|
||||||
|
data: z.any().optional(),
|
||||||
|
kpi_snapshot: z.any().optional(),
|
||||||
|
}).passthrough(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
export function normalizeEventV1(raw: unknown) {
|
||||||
|
const strict = EventV1.safeParse(raw);
|
||||||
|
if (strict.success) return { ok: true as const, value: strict.data };
|
||||||
|
|
||||||
|
// legacy fallback: allow missing meta, but STILL reject arrays later
|
||||||
|
const legacy = z.object({ machineId: z.any(), event: z.any() }).passthrough().safeParse(raw);
|
||||||
|
if (!legacy.success) return { ok: false as const, error: strict.error.message };
|
||||||
|
|
||||||
|
const b: any = legacy.data;
|
||||||
|
const tsDevice = typeof b.tsDevice === "number" ? b.tsDevice : (b.event?.timestamp ?? Date.now());
|
||||||
|
const migrated: any = {
|
||||||
|
schemaVersion: SCHEMA_VERSION,
|
||||||
|
machineId: String(b.machineId),
|
||||||
|
tsDevice,
|
||||||
|
seq: typeof b.seq === "number" || typeof b.seq === "string" ? b.seq : "0",
|
||||||
|
...b,
|
||||||
|
};
|
||||||
|
|
||||||
|
const recheck = EventV1.safeParse(migrated);
|
||||||
|
if (!recheck.success) return { ok: false as const, error: recheck.error.message };
|
||||||
|
return { ok: true as const, value: recheck.data };
|
||||||
|
}
|
||||||
468
package-lock.json
generated
468
package-lock.json
generated
@@ -13,7 +13,9 @@
|
|||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.561.0",
|
||||||
"next": "16.0.10",
|
"next": "16.0.10",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
"react-dom": "19.2.1"
|
"react-dom": "19.2.1",
|
||||||
|
"recharts": "^3.6.0",
|
||||||
|
"zod": "^4.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -21,6 +23,7 @@
|
|||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"dotenv-cli": "^11.0.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.10",
|
"eslint-config-next": "16.0.10",
|
||||||
"prisma": "^6.19.1",
|
"prisma": "^6.19.1",
|
||||||
@@ -1316,6 +1319,42 @@
|
|||||||
"@prisma/debug": "6.19.1"
|
"@prisma/debug": "6.19.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "11.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.0.1.tgz",
|
||||||
|
"integrity": "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rtsao/scc": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
@@ -1327,7 +1366,12 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
"devOptional": true,
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
@@ -1631,6 +1675,69 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
|
||||||
|
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -1666,7 +1773,7 @@
|
|||||||
"version": "19.2.7",
|
"version": "19.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -1682,6 +1789,12 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.50.0",
|
"version": "8.50.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.0.tgz",
|
||||||
@@ -2514,9 +2627,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.9.7",
|
"version": "2.9.9",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.7.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.9.tgz",
|
||||||
"integrity": "sha512-k9xFKplee6KIio3IDbwj+uaCLpqzOwakOgmqzPezM0sFJlFKcg30vk2wOiAJtkTSfx0SSQDSe8q+mWA/fSH5Zg==",
|
"integrity": "sha512-V8fbOCSeOFvlDj7LLChUcqbZrdKD9RU/VR260piF1790vT0mfLSwGc/Qzxv3IqiTukOpNtItePa0HBpMAj7MDg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -2753,6 +2866,15 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -2823,9 +2945,130 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
@@ -2905,6 +3148,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/deep-is": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
@@ -3008,6 +3257,51 @@
|
|||||||
"url": "https://dotenvx.com"
|
"url": "https://dotenvx.com"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dotenv-cli": {
|
||||||
|
"version": "11.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-11.0.0.tgz",
|
||||||
|
"integrity": "sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cross-spawn": "^7.0.6",
|
||||||
|
"dotenv": "^17.1.0",
|
||||||
|
"dotenv-expand": "^12.0.0",
|
||||||
|
"minimist": "^1.2.6"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"dotenv": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dotenv-cli/node_modules/dotenv": {
|
||||||
|
"version": "17.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||||
|
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dotenv-expand": {
|
||||||
|
"version": "12.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz",
|
||||||
|
"integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^16.4.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -3249,6 +3543,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.43.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz",
|
||||||
|
"integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@@ -3696,6 +4000,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/exsolve": {
|
"node_modules/exsolve": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
||||||
@@ -4200,6 +4510,16 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -4242,6 +4562,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-array-buffer": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||||
@@ -5845,9 +6174,31 @@
|
|||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
@@ -5862,6 +6213,51 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -5906,6 +6302,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -6495,6 +6897,12 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyexec": {
|
"node_modules/tinyexec": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||||
@@ -6802,9 +7210,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
"integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==",
|
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -6842,6 +7250,37 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -6978,10 +7417,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz",
|
||||||
"integrity": "sha512-Bd5fw9wlIhtqCCxotZgdTOMwGm1a0u75wARVEY9HMs1X17trvA/lMi4+MGK5EUfYkXVTbX8UDiDKW4OgzHVUZw==",
|
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.561.0",
|
||||||
"next": "16.0.10",
|
"next": "16.0.10",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
"react-dom": "19.2.1"
|
"react-dom": "19.2.1",
|
||||||
|
"recharts": "^3.6.0",
|
||||||
|
"zod": "^4.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -22,6 +24,7 @@
|
|||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"dotenv-cli": "^11.0.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.10",
|
"eslint-config-next": "16.0.10",
|
||||||
"prisma": "^6.19.1",
|
"prisma": "^6.19.1",
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MachineCycle" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"orgId" TEXT NOT NULL,
|
||||||
|
"machineId" TEXT NOT NULL,
|
||||||
|
"ts" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"cycleCount" INTEGER,
|
||||||
|
"actualCycleTime" DOUBLE PRECISION NOT NULL,
|
||||||
|
"theoreticalCycleTime" DOUBLE PRECISION,
|
||||||
|
"workOrderId" TEXT,
|
||||||
|
"sku" TEXT,
|
||||||
|
"cavities" INTEGER,
|
||||||
|
"goodDelta" INTEGER,
|
||||||
|
"scrapDelta" INTEGER,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "MachineCycle_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MachineCycle_orgId_machineId_ts_idx" ON "MachineCycle"("orgId", "machineId", "ts");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MachineCycle_orgId_machineId_cycleCount_idx" ON "MachineCycle"("orgId", "machineId", "cycleCount");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MachineCycle" ADD CONSTRAINT "MachineCycle_machineId_fkey" FOREIGN KEY ("machineId") REFERENCES "Machine"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
55
prisma/migrations/20251222235834_ingest_log/migration.sql
Normal file
55
prisma/migrations/20251222235834_ingest_log/migration.sql
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Machine" ADD COLUMN "schema_version" TEXT,
|
||||||
|
ADD COLUMN "seq" BIGINT,
|
||||||
|
ADD COLUMN "ts" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ADD COLUMN "ts_server" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "MachineCycle" ADD COLUMN "schema_version" TEXT,
|
||||||
|
ADD COLUMN "seq" BIGINT,
|
||||||
|
ADD COLUMN "ts_server" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "MachineEvent" ADD COLUMN "schema_version" TEXT,
|
||||||
|
ADD COLUMN "seq" BIGINT,
|
||||||
|
ADD COLUMN "ts_server" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "MachineHeartbeat" ADD COLUMN "schema_version" TEXT,
|
||||||
|
ADD COLUMN "seq" BIGINT,
|
||||||
|
ADD COLUMN "ts_server" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "MachineKpiSnapshot" ADD COLUMN "schema_version" TEXT,
|
||||||
|
ADD COLUMN "seq" BIGINT,
|
||||||
|
ADD COLUMN "ts_server" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "IngestLog" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"orgId" TEXT,
|
||||||
|
"machineId" TEXT,
|
||||||
|
"endpoint" TEXT NOT NULL,
|
||||||
|
"schemaVersion" TEXT,
|
||||||
|
"seq" BIGINT,
|
||||||
|
"tsDevice" TIMESTAMP(3),
|
||||||
|
"tsServer" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"ok" BOOLEAN NOT NULL,
|
||||||
|
"status" INTEGER NOT NULL,
|
||||||
|
"errorCode" TEXT,
|
||||||
|
"errorMsg" TEXT,
|
||||||
|
"body" JSONB,
|
||||||
|
"ip" TEXT,
|
||||||
|
"userAgent" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "IngestLog_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "IngestLog_endpoint_tsServer_idx" ON "IngestLog"("endpoint", "tsServer");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "IngestLog_machineId_tsServer_idx" ON "IngestLog"("machineId", "tsServer");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "IngestLog_machineId_seq_idx" ON "IngestLog"("machineId", "seq");
|
||||||
@@ -76,11 +76,17 @@ model Machine {
|
|||||||
location String?
|
location String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
tsDevice DateTime @default(now()) @map("ts")
|
||||||
|
tsServer DateTime @default(now()) @map("ts_server")
|
||||||
|
schemaVersion String? @map("schema_version")
|
||||||
|
seq BigInt? @map("seq")
|
||||||
|
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
heartbeats MachineHeartbeat[]
|
heartbeats MachineHeartbeat[]
|
||||||
kpiSnapshots MachineKpiSnapshot[]
|
kpiSnapshots MachineKpiSnapshot[]
|
||||||
events MachineEvent[]
|
events MachineEvent[]
|
||||||
|
cycles MachineCycle[]
|
||||||
|
|
||||||
|
|
||||||
@@unique([orgId, name])
|
@@unique([orgId, name])
|
||||||
@@index([orgId])
|
@@index([orgId])
|
||||||
@@ -91,6 +97,9 @@ model MachineHeartbeat {
|
|||||||
orgId String
|
orgId String
|
||||||
machineId String
|
machineId String
|
||||||
ts DateTime @default(now())
|
ts DateTime @default(now())
|
||||||
|
tsServer DateTime @default(now()) @map("ts_server")
|
||||||
|
schemaVersion String? @map("schema_version")
|
||||||
|
seq BigInt? @map("seq")
|
||||||
|
|
||||||
status String
|
status String
|
||||||
message String?
|
message String?
|
||||||
@@ -129,6 +138,9 @@ model MachineKpiSnapshot {
|
|||||||
|
|
||||||
trackingEnabled Boolean?
|
trackingEnabled Boolean?
|
||||||
productionStarted Boolean?
|
productionStarted Boolean?
|
||||||
|
tsServer DateTime @default(now()) @map("ts_server")
|
||||||
|
schemaVersion String? @map("schema_version")
|
||||||
|
seq BigInt? @map("seq")
|
||||||
|
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
|
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
|
||||||
@@ -148,6 +160,9 @@ model MachineEvent {
|
|||||||
requiresAck Boolean @default(false)
|
requiresAck Boolean @default(false)
|
||||||
title String
|
title String
|
||||||
description String?
|
description String?
|
||||||
|
tsServer DateTime @default(now()) @map("ts_server")
|
||||||
|
schemaVersion String? @map("schema_version")
|
||||||
|
seq BigInt? @map("seq")
|
||||||
|
|
||||||
// store the raw data blob so we don't lose fields
|
// store the raw data blob so we don't lose fields
|
||||||
data Json?
|
data Json?
|
||||||
@@ -161,3 +176,54 @@ model MachineEvent {
|
|||||||
@@index([orgId, machineId, ts])
|
@@index([orgId, machineId, ts])
|
||||||
@@index([orgId, machineId, eventType, ts])
|
@@index([orgId, machineId, eventType, ts])
|
||||||
}
|
}
|
||||||
|
model MachineCycle {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
orgId String
|
||||||
|
machineId String
|
||||||
|
ts DateTime @default(now())
|
||||||
|
|
||||||
|
cycleCount Int?
|
||||||
|
actualCycleTime Float
|
||||||
|
theoreticalCycleTime Float?
|
||||||
|
|
||||||
|
workOrderId String?
|
||||||
|
sku String?
|
||||||
|
|
||||||
|
cavities Int?
|
||||||
|
goodDelta Int?
|
||||||
|
scrapDelta Int?
|
||||||
|
tsServer DateTime @default(now()) @map("ts_server")
|
||||||
|
schemaVersion String? @map("schema_version")
|
||||||
|
seq BigInt? @map("seq")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
machine Machine @relation(fields: [machineId], references: [id])
|
||||||
|
@@index([orgId, machineId, ts])
|
||||||
|
@@index([orgId, machineId, cycleCount])
|
||||||
|
}
|
||||||
|
|
||||||
|
model IngestLog {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
orgId String?
|
||||||
|
machineId String?
|
||||||
|
endpoint String
|
||||||
|
schemaVersion String?
|
||||||
|
seq BigInt?
|
||||||
|
tsDevice DateTime?
|
||||||
|
tsServer DateTime @default(now())
|
||||||
|
|
||||||
|
ok Boolean
|
||||||
|
status Int
|
||||||
|
errorCode String?
|
||||||
|
errorMsg String?
|
||||||
|
body Json?
|
||||||
|
ip String?
|
||||||
|
userAgent String?
|
||||||
|
|
||||||
|
@@index([endpoint, tsServer])
|
||||||
|
@@index([machineId, tsServer])
|
||||||
|
@@index([machineId, seq])
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user