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,
}));