From 4299ef34787d05436295eb5cf9d174e2ebaabe30 Mon Sep 17 00:00:00 2001 From: Marcelo Date: Fri, 1 May 2026 06:19:30 +0000 Subject: [PATCH] pulse stop --- app/(app)/machines/MachinesClient.tsx | 56 ++++++++++++++++++++--- app/api/machines/route.ts | 6 +++ lib/i18n/en.json | 2 + lib/i18n/es-MX.json | 2 + lib/machines/withLatest.ts | 66 ++++++++++++++++++++++++++- 5 files changed, 124 insertions(+), 8 deletions(-) diff --git a/app/(app)/machines/MachinesClient.tsx b/app/(app)/machines/MachinesClient.tsx index 2576063..5135773 100644 --- a/app/(app)/machines/MachinesClient.tsx +++ b/app/(app)/machines/MachinesClient.tsx @@ -19,6 +19,12 @@ type MachineRow = { ip?: string | null; fwVersion?: string | null; }; + latestMacrostop?: null | { + machineId: string; + ts: string; + status: "active" | "resolved" | "unknown"; + startedAtMs: number; + }; }; const LIVE_REFRESH_MS = 5000; const OFFLINE_MS = RECAP_HEARTBEAT_STALE_MS; @@ -51,6 +57,21 @@ function badgeClass(status?: string, offline?: boolean) { return "bg-white/10 text-white"; } +const MACROSTOP_FRESH_MS = 2 * 60 * 1000; + +function isMacrostopActive(macrostop: MachineRow["latestMacrostop"]) { + if (!macrostop) return false; + if (macrostop.status !== "active") return false; + // Fresh if last refresh was within 2 min — Node-RED refreshes every 10s, + // so anything older means the stoppage already ended without resolution event. + return Date.now() - new Date(macrostop.ts).getTime() <= MACROSTOP_FRESH_MS; +} + +function ongoingMacrostopMin(macrostop: MachineRow["latestMacrostop"]) { + if (!macrostop) return 0; + return Math.max(0, Math.floor((Date.now() - macrostop.startedAtMs) / 60000)); +} + export default function MachinesClient({ initialMachines = [] }: { initialMachines?: MachineRow[] }) { const { t, locale } = useI18n(); const router = useRouter(); @@ -292,9 +313,28 @@ export default function MachinesClient({ initialMachines = [] }: { initialMachin const hbTs = hb?.tsServer ?? hb?.ts; const offline = isOffline(hbTs); const normalizedStatus = normalizeStatus(hb?.status); - const statusLabel = offline ? t("machines.status.offline") : (normalizedStatus || t("machines.status.unknown")); const lastSeen = secondsAgo(hbTs, locale, t("common.never")); + const macrostopActive = isMacrostopActive(m.latestMacrostop); + const stoppedMin = macrostopActive ? ongoingMacrostopMin(m.latestMacrostop) : 0; + + // Production-state badge: STOPPED if active macrostop, else heartbeat-based. + const productionBadgeLabel = offline + ? t("machines.status.offline") + : macrostopActive + ? t("machines.status.stopped") + : (normalizedStatus || t("machines.status.unknown")); + + const productionBadgeClass = offline + ? "bg-white/10 text-zinc-300" + : macrostopActive + ? "bg-red-500/20 text-red-200 ring-2 ring-red-500/50 animate-pulse" + : badgeClass(normalizedStatus, offline); + + const cardClass = macrostopActive + ? "cursor-pointer rounded-2xl border border-red-500/60 bg-red-500/10 p-5 ring-2 ring-red-500/40 animate-pulse hover:bg-red-500/15" + : "cursor-pointer rounded-2xl border border-white/10 bg-white/5 p-5 hover:bg-white/10"; + return (
router.push(`/machines/${m.id}`)} onKeyDown={(event) => handleCardKeyDown(event, m.id)} - className="cursor-pointer rounded-2xl border border-white/10 bg-white/5 p-5 hover:bg-white/10" + className={cardClass} >
@@ -310,15 +350,17 @@ export default function MachinesClient({ initialMachines = [] }: { initialMachin
{m.code ? m.code : t("common.na")} - {t("machines.lastSeen", { time: lastSeen })}
+ {macrostopActive ? ( +
+ {t("machines.stoppedFor", { min: stoppedMin })} +
+ ) : null}
- {statusLabel} + {productionBadgeLabel}
diff --git a/app/api/machines/route.ts b/app/api/machines/route.ts index 97f1f54..a9277c3 100644 --- a/app/api/machines/route.ts +++ b/app/api/machines/route.ts @@ -9,6 +9,7 @@ import { requireSession } from "@/lib/auth/requireSession"; import { fetchLatestHeartbeats, fetchLatestKpis, + fetchLatestMacrostops, fetchMachineBase, mergeMachineOverviewRows, } from "@/lib/machines/withLatest"; @@ -58,6 +59,10 @@ export async function GET(req: Request) { if (perfEnabled) timings.kpiQuery = elapsedMs(kpiStart); } + const macrostopStart = nowMs(); + const macrostops = await fetchLatestMacrostops(session.orgId, machineIds); + if (perfEnabled) timings.macrostopsQuery = elapsedMs(macrostopStart); + const postQueryStart = nowMs(); // flatten latest heartbeat for UI convenience @@ -65,6 +70,7 @@ export async function GET(req: Request) { machines, heartbeats, kpis, + macrostops, includeKpi, }); diff --git a/lib/i18n/en.json b/lib/i18n/en.json index 5f3c9ab..130ff11 100644 --- a/lib/i18n/en.json +++ b/lib/i18n/en.json @@ -112,6 +112,8 @@ "recap.title": "Recap", "recap.subtitle": "Last 24h", "recap.card.stoppedFor": "Stopped for {min} min", + "machines.status.stopped": "STOPPED", + "machines.stoppedFor": "Stopped for {min} min", "recap.grid.title": "Machine recap", "recap.grid.subtitle": "Last 24h · click to open details", "recap.grid.updatedAgo": "Updated {sec}s ago", diff --git a/lib/i18n/es-MX.json b/lib/i18n/es-MX.json index 27b63f2..50b7b38 100644 --- a/lib/i18n/es-MX.json +++ b/lib/i18n/es-MX.json @@ -119,6 +119,8 @@ "recap.title": "Resumen", "recap.subtitle": "Últimas 24h", "recap.card.stoppedFor": "Detenida hace {min} min", + "machines.status.stopped": "DETENIDA", + "machines.stoppedFor": "Detenida hace {min} min", "recap.grid.title": "Resumen de máquinas", "recap.grid.subtitle": "Últimas 24h · click para ver detalle", "recap.grid.updatedAgo": "Actualizado hace {sec}s", diff --git a/lib/machines/withLatest.ts b/lib/machines/withLatest.ts index cf2edcd..b8d5ab3 100644 --- a/lib/machines/withLatest.ts +++ b/lib/machines/withLatest.ts @@ -31,6 +31,15 @@ type LatestKpiRow = { cycleTime?: number | null; }; +export type LatestMacrostopRow = { + machineId: string; + ts: Date; + status: "active" | "resolved" | "unknown"; + startedAtMs: number; +}; + +const MACROSTOP_LOOKBACK_MS = 5 * 60 * 1000; + export async function fetchMachineBase(orgId: string): Promise { return prisma.machine.findMany({ where: { orgId }, @@ -93,20 +102,75 @@ export async function fetchLatestKpis( }); } +export async function fetchLatestMacrostops( + orgId: string, + machineIds: string[] +): Promise { + if (!machineIds.length) return []; + + const rows = await prisma.machineEvent.findMany({ + where: { + orgId, + machineId: { in: machineIds }, + eventType: "macrostop", + ts: { gte: new Date(Date.now() - MACROSTOP_LOOKBACK_MS) }, + }, + orderBy: [{ machineId: "asc" }, { ts: "desc" }], + select: { machineId: true, ts: true, data: true }, + }); + + const byMachine = new Map(); + for (const row of rows) { + if (byMachine.has(row.machineId)) continue; + + let parsed: unknown = row.data; + if (typeof parsed === "string") { + try { parsed = JSON.parse(parsed); } catch { parsed = null; } + } + const data: Record = + parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + + const isAutoAck = + data.is_auto_ack === true || data.isAutoAck === true || + data.is_auto_ack === "true" || data.isAutoAck === "true"; + if (isAutoAck) continue; + + const rawStatus = String(data.status ?? "").trim().toLowerCase(); + const status: LatestMacrostopRow["status"] = + rawStatus === "active" ? "active" : rawStatus === "resolved" ? "resolved" : "unknown"; + + const lastCycleTs = Number(data.last_cycle_timestamp); + const startedAtMs = Number.isFinite(lastCycleTs) && lastCycleTs > 0 + ? lastCycleTs + : row.ts.getTime(); + + byMachine.set(row.machineId, { machineId: row.machineId, ts: row.ts, status, startedAtMs }); + } + + return Array.from(byMachine.values()); +} + + export function mergeMachineOverviewRows(params: { machines: MachineBaseRow[]; heartbeats: LatestHeartbeatRow[]; kpis?: LatestKpiRow[]; + macrostops?: LatestMacrostopRow[]; includeKpi?: boolean; }): OverviewMachineRow[] { - const { machines, heartbeats, kpis = [], includeKpi = false } = params; + const { machines, heartbeats, kpis = [], macrostops = [], includeKpi = false } = params; const heartbeatMap = new Map(heartbeats.map((row) => [row.machineId, row])); const kpiMap = new Map(kpis.map((row) => [row.machineId, row])); + const macrostopMap = new Map(macrostops.map((row) => [row.machineId, row])); + return machines.map((machine) => ({ ...machine, latestHeartbeat: (heartbeatMap.get(machine.id) ?? null) as OverviewMachineRow["latestHeartbeat"], latestKpi: includeKpi ? (kpiMap.get(machine.id) ?? null) : null, + latestMacrostop: macrostopMap.get(machine.id) ?? null, heartbeats: undefined, kpiSnapshots: undefined, }));