pre-bemis
This commit is contained in:
@@ -1,5 +1,14 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { normalizeEvent } from "@/lib/events/normalizeEvent";
|
||||
import { logLine } from "@/lib/logger";
|
||||
import { elapsedMs, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming";
|
||||
import type { OverviewEventRow, OverviewMachineRow } from "@/lib/overview/types";
|
||||
import {
|
||||
fetchLatestHeartbeats,
|
||||
fetchLatestKpis,
|
||||
fetchMachineBase,
|
||||
mergeMachineOverviewRows,
|
||||
} from "@/lib/machines/withLatest";
|
||||
|
||||
const ALLOWED_TYPES = new Set([
|
||||
"slow-cycle",
|
||||
@@ -37,157 +46,169 @@ export async function getOverviewData({
|
||||
eventsWindowSec = 21600,
|
||||
eventMachines = 6,
|
||||
orgSettings,
|
||||
}: OverviewParams) {
|
||||
const machines = await prisma.machine.findMany({
|
||||
where: { orgId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
code: true,
|
||||
location: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
heartbeats: {
|
||||
orderBy: { tsServer: "desc" },
|
||||
take: 1,
|
||||
select: { ts: true, tsServer: true, status: true, message: true, ip: true, fwVersion: true },
|
||||
},
|
||||
kpiSnapshots: {
|
||||
orderBy: { ts: "desc" },
|
||||
take: 1,
|
||||
select: {
|
||||
ts: true,
|
||||
oee: true,
|
||||
availability: true,
|
||||
performance: true,
|
||||
quality: true,
|
||||
workOrderId: true,
|
||||
sku: true,
|
||||
good: true,
|
||||
scrap: true,
|
||||
target: true,
|
||||
cycleTime: true,
|
||||
}: OverviewParams): Promise<{ machines: OverviewMachineRow[]; events: OverviewEventRow[] }> {
|
||||
const perfEnabled = PERF_LOGS_ENABLED;
|
||||
const timings: Record<string, number> = {};
|
||||
const totalStart = nowMs();
|
||||
|
||||
try {
|
||||
const machinesStart = nowMs();
|
||||
const machines = await fetchMachineBase(orgId);
|
||||
if (perfEnabled) timings.machinesQuery = elapsedMs(machinesStart);
|
||||
|
||||
const heartbeatStart = nowMs();
|
||||
const machineIds = machines.map((machine) => machine.id);
|
||||
const heartbeats = await fetchLatestHeartbeats(orgId, machineIds);
|
||||
if (perfEnabled) timings.heartbeatsQuery = elapsedMs(heartbeatStart);
|
||||
|
||||
const kpiStart = nowMs();
|
||||
const kpis = await fetchLatestKpis(orgId, machineIds);
|
||||
if (perfEnabled) timings.kpiQuery = elapsedMs(kpiStart);
|
||||
|
||||
const machineRows: OverviewMachineRow[] = mergeMachineOverviewRows({
|
||||
machines,
|
||||
heartbeats,
|
||||
kpis,
|
||||
includeKpi: true,
|
||||
});
|
||||
|
||||
const safeEventMachines = Number.isFinite(eventMachines) ? Math.max(1, Math.floor(eventMachines)) : 6;
|
||||
const safeWindowSec = Number.isFinite(eventsWindowSec) ? eventsWindowSec : 21600;
|
||||
|
||||
const topMachines = machineRows
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const at = heartbeatTime(a.latestHeartbeat);
|
||||
const bt = heartbeatTime(b.latestHeartbeat);
|
||||
const atMs = at ? at.getTime() : 0;
|
||||
const btMs = bt ? bt.getTime() : 0;
|
||||
return btMs - atMs;
|
||||
})
|
||||
.slice(0, safeEventMachines);
|
||||
|
||||
const targetIds = topMachines.map((m) => m.id);
|
||||
|
||||
let events: OverviewEventRow[] = [];
|
||||
|
||||
if (targetIds.length) {
|
||||
let settings = orgSettings ?? null;
|
||||
if (!settings) {
|
||||
const settingsStart = nowMs();
|
||||
settings = await prisma.orgSettings.findUnique({
|
||||
where: { orgId },
|
||||
select: { stoppageMultiplier: true, macroStoppageMultiplier: true },
|
||||
});
|
||||
if (perfEnabled) timings.orgSettingsQuery = elapsedMs(settingsStart);
|
||||
}
|
||||
|
||||
const microMultiplier = Number(settings?.stoppageMultiplier ?? 1.5);
|
||||
const macroMultiplier = Math.max(microMultiplier, Number(settings?.macroStoppageMultiplier ?? 5));
|
||||
const windowStart = new Date(Date.now() - Math.max(0, safeWindowSec) * 1000);
|
||||
|
||||
const eventsStart = nowMs();
|
||||
const rawEvents = await prisma.machineEvent.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
machineId: { in: targetIds },
|
||||
ts: { gte: windowStart },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
orderBy: { ts: "desc" },
|
||||
take: Math.min(300, Math.max(60, targetIds.length * 40)),
|
||||
select: {
|
||||
id: true,
|
||||
ts: true,
|
||||
topic: true,
|
||||
eventType: true,
|
||||
severity: true,
|
||||
title: true,
|
||||
description: true,
|
||||
requiresAck: true,
|
||||
data: true,
|
||||
workOrderId: true,
|
||||
machineId: true,
|
||||
machine: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
if (perfEnabled) timings.eventsQuery = elapsedMs(eventsStart);
|
||||
|
||||
const machineRows = machines.map((m) => ({
|
||||
...m,
|
||||
latestHeartbeat: m.heartbeats[0] ?? null,
|
||||
latestKpi: m.kpiSnapshots[0] ?? null,
|
||||
heartbeats: undefined,
|
||||
kpiSnapshots: undefined,
|
||||
}));
|
||||
const normalizeStart = nowMs();
|
||||
const normalized = rawEvents
|
||||
.map((row) => ({
|
||||
...normalizeEvent(row, { microMultiplier, macroMultiplier }),
|
||||
machineId: row.machineId,
|
||||
machineName: row.machine?.name ?? null,
|
||||
source: "ingested" as const,
|
||||
}))
|
||||
.filter((event) => event.ts);
|
||||
if (perfEnabled) timings.eventsNormalize = elapsedMs(normalizeStart);
|
||||
|
||||
const safeEventMachines = Number.isFinite(eventMachines) ? Math.max(1, Math.floor(eventMachines)) : 6;
|
||||
const safeWindowSec = Number.isFinite(eventsWindowSec) ? eventsWindowSec : 21600;
|
||||
const filterStart = nowMs();
|
||||
const allowed = normalized.filter((event) => ALLOWED_TYPES.has(event.eventType));
|
||||
const isCritical = (event: (typeof allowed)[number]) => {
|
||||
const severity = String(event.severity ?? "").toLowerCase();
|
||||
return (
|
||||
event.eventType === "macrostop" ||
|
||||
event.requiresAck === true ||
|
||||
severity === "critical" ||
|
||||
severity === "error" ||
|
||||
severity === "high"
|
||||
);
|
||||
};
|
||||
|
||||
const topMachines = machineRows
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const at = heartbeatTime(a.latestHeartbeat);
|
||||
const bt = heartbeatTime(b.latestHeartbeat);
|
||||
const atMs = at ? at.getTime() : 0;
|
||||
const btMs = bt ? bt.getTime() : 0;
|
||||
return btMs - atMs;
|
||||
})
|
||||
.slice(0, safeEventMachines);
|
||||
const filtered = eventsMode === "critical" ? allowed.filter(isCritical) : allowed;
|
||||
|
||||
const targetIds = topMachines.map((m) => m.id);
|
||||
const seen = new Set<string>();
|
||||
const deduped = filtered.filter((event) => {
|
||||
const key = `${event.machineId}-${event.eventType}-${event.ts ?? ""}-${event.title}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
let events = [] as Array<{
|
||||
id: string;
|
||||
ts: Date | null;
|
||||
topic: string;
|
||||
eventType: string;
|
||||
severity: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
requiresAck: boolean;
|
||||
workOrderId?: string | null;
|
||||
machineId: string;
|
||||
machineName?: string | null;
|
||||
source: "ingested";
|
||||
}>;
|
||||
deduped.sort((a, b) => {
|
||||
const at = a.ts ? a.ts.getTime() : 0;
|
||||
const bt = b.ts ? b.ts.getTime() : 0;
|
||||
return bt - at;
|
||||
});
|
||||
|
||||
if (targetIds.length) {
|
||||
let settings = orgSettings ?? null;
|
||||
if (!settings) {
|
||||
settings = await prisma.orgSettings.findUnique({
|
||||
where: { orgId },
|
||||
select: { stoppageMultiplier: true, macroStoppageMultiplier: true },
|
||||
events = deduped.slice(0, 30);
|
||||
if (perfEnabled) timings.eventsFilter = elapsedMs(filterStart);
|
||||
}
|
||||
|
||||
if (perfEnabled) {
|
||||
timings.total = elapsedMs(totalStart);
|
||||
logLine("perf.overview.getOverviewData", {
|
||||
orgId,
|
||||
eventsMode,
|
||||
eventsWindowSec,
|
||||
eventMachines,
|
||||
timings,
|
||||
counts: {
|
||||
machines: machineRows.length,
|
||||
events: events.length,
|
||||
targetMachines: targetIds.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const microMultiplier = Number(settings?.stoppageMultiplier ?? 1.5);
|
||||
const macroMultiplier = Math.max(microMultiplier, Number(settings?.macroStoppageMultiplier ?? 5));
|
||||
const windowStart = new Date(Date.now() - Math.max(0, safeWindowSec) * 1000);
|
||||
|
||||
const rawEvents = await prisma.machineEvent.findMany({
|
||||
where: {
|
||||
return { machines: machineRows, events };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const stack = err instanceof Error ? err.stack : undefined;
|
||||
if (perfEnabled) {
|
||||
timings.total = elapsedMs(totalStart);
|
||||
logLine("perf.overview.getOverviewData.error", {
|
||||
orgId,
|
||||
machineId: { in: targetIds },
|
||||
ts: { gte: windowStart },
|
||||
},
|
||||
orderBy: { ts: "desc" },
|
||||
take: Math.min(300, Math.max(60, targetIds.length * 40)),
|
||||
select: {
|
||||
id: true,
|
||||
ts: true,
|
||||
topic: true,
|
||||
eventType: true,
|
||||
severity: true,
|
||||
title: true,
|
||||
description: true,
|
||||
requiresAck: true,
|
||||
data: true,
|
||||
workOrderId: true,
|
||||
machineId: true,
|
||||
machine: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const normalized = rawEvents
|
||||
.map((row) => ({
|
||||
...normalizeEvent(row, { microMultiplier, macroMultiplier }),
|
||||
machineId: row.machineId,
|
||||
machineName: row.machine?.name ?? null,
|
||||
source: "ingested" as const,
|
||||
}))
|
||||
.filter((event) => event.ts);
|
||||
|
||||
const allowed = normalized.filter((event) => ALLOWED_TYPES.has(event.eventType));
|
||||
const isCritical = (event: (typeof allowed)[number]) => {
|
||||
const severity = String(event.severity ?? "").toLowerCase();
|
||||
return (
|
||||
event.eventType === "macrostop" ||
|
||||
event.requiresAck === true ||
|
||||
severity === "critical" ||
|
||||
severity === "error" ||
|
||||
severity === "high"
|
||||
);
|
||||
};
|
||||
|
||||
const filtered = eventsMode === "critical" ? allowed.filter(isCritical) : allowed;
|
||||
|
||||
const seen = new Set<string>();
|
||||
const deduped = filtered.filter((event) => {
|
||||
const key = `${event.machineId}-${event.eventType}-${event.ts ?? ""}-${event.title}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
deduped.sort((a, b) => {
|
||||
const at = a.ts ? a.ts.getTime() : 0;
|
||||
const bt = b.ts ? b.ts.getTime() : 0;
|
||||
return bt - at;
|
||||
});
|
||||
|
||||
events = deduped.slice(0, 30);
|
||||
eventsMode,
|
||||
eventsWindowSec,
|
||||
eventMachines,
|
||||
timings,
|
||||
message,
|
||||
stack,
|
||||
});
|
||||
}
|
||||
logLine("getOverviewData.error", { message, stack });
|
||||
console.error("[getOverviewData]", err);
|
||||
return { machines: [], events: [] };
|
||||
}
|
||||
|
||||
return { machines: machineRows, events };
|
||||
}
|
||||
|
||||
102
lib/overview/getOverviewSummary.ts
Normal file
102
lib/overview/getOverviewSummary.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { logLine } from "@/lib/logger";
|
||||
import { elapsedMs, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming";
|
||||
import type { OverviewMachineRow } from "@/lib/overview/types";
|
||||
import {
|
||||
fetchLatestHeartbeats,
|
||||
fetchMachineBase,
|
||||
mergeMachineOverviewRows,
|
||||
} from "@/lib/machines/withLatest";
|
||||
|
||||
type OverviewSummaryParams = {
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
const SUMMARY_CACHE_TTL_MS = 10000;
|
||||
const summaryCache = new Map<string, { value: OverviewMachineRow[]; expiresAt: number; cachedAt: number }>();
|
||||
const summaryInFlight = new Map<string, Promise<{ machines: OverviewMachineRow[] }>>();
|
||||
|
||||
export async function getOverviewSummary({
|
||||
orgId,
|
||||
}: OverviewSummaryParams): Promise<{ machines: OverviewMachineRow[] }> {
|
||||
const now = Date.now();
|
||||
const cached = summaryCache.get(orgId);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
if (PERF_LOGS_ENABLED) {
|
||||
logLine("perf.overview.summary", {
|
||||
orgId,
|
||||
cached: true,
|
||||
timings: { total: 0 },
|
||||
ageMs: now - cached.cachedAt,
|
||||
counts: { machines: cached.value.length },
|
||||
});
|
||||
}
|
||||
return { machines: cached.value };
|
||||
}
|
||||
|
||||
const inFlight = summaryInFlight.get(orgId);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
const promise = fetchOverviewSummary({ orgId })
|
||||
.then((result) => {
|
||||
summaryCache.set(orgId, {
|
||||
value: result.machines,
|
||||
cachedAt: now,
|
||||
expiresAt: now + SUMMARY_CACHE_TTL_MS,
|
||||
});
|
||||
summaryInFlight.delete(orgId);
|
||||
return result;
|
||||
})
|
||||
.catch((err) => {
|
||||
summaryInFlight.delete(orgId);
|
||||
throw err;
|
||||
});
|
||||
|
||||
summaryInFlight.set(orgId, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function fetchOverviewSummary({
|
||||
orgId,
|
||||
}: OverviewSummaryParams): Promise<{ machines: OverviewMachineRow[] }> {
|
||||
const perfEnabled = PERF_LOGS_ENABLED;
|
||||
const totalStart = nowMs();
|
||||
const timings: Record<string, number> = {};
|
||||
|
||||
try {
|
||||
const machinesStart = nowMs();
|
||||
const machines = await fetchMachineBase(orgId);
|
||||
if (perfEnabled) timings.machinesQuery = elapsedMs(machinesStart);
|
||||
|
||||
const heartbeatStart = nowMs();
|
||||
const machineIds = machines.map((machine) => machine.id);
|
||||
const heartbeats = await fetchLatestHeartbeats(orgId, machineIds);
|
||||
if (perfEnabled) timings.heartbeatsQuery = elapsedMs(heartbeatStart);
|
||||
|
||||
const machineRows: OverviewMachineRow[] = mergeMachineOverviewRows({
|
||||
machines,
|
||||
heartbeats,
|
||||
includeKpi: false,
|
||||
});
|
||||
|
||||
if (perfEnabled) {
|
||||
timings.total = elapsedMs(totalStart);
|
||||
logLine("perf.overview.summary", {
|
||||
orgId,
|
||||
timings,
|
||||
counts: { machines: machineRows.length },
|
||||
});
|
||||
}
|
||||
|
||||
return { machines: machineRows };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const stack = err instanceof Error ? err.stack : undefined;
|
||||
if (perfEnabled) {
|
||||
timings.total = elapsedMs(totalStart);
|
||||
logLine("perf.overview.summary.error", { orgId, timings, message, stack });
|
||||
}
|
||||
logLine("getOverviewSummary.error", { message, stack });
|
||||
console.error("[getOverviewSummary]", err);
|
||||
return { machines: [] };
|
||||
}
|
||||
}
|
||||
51
lib/overview/types.ts
Normal file
51
lib/overview/types.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export type OverviewLatestHeartbeat = {
|
||||
ts: Date;
|
||||
tsServer?: Date | null;
|
||||
status: string;
|
||||
message?: string | null;
|
||||
ip?: string | null;
|
||||
fwVersion?: string | null;
|
||||
};
|
||||
|
||||
export type OverviewLatestKpi = {
|
||||
ts: Date;
|
||||
oee?: number | null;
|
||||
availability?: number | null;
|
||||
performance?: number | null;
|
||||
quality?: number | null;
|
||||
workOrderId?: string | null;
|
||||
sku?: string | null;
|
||||
good?: number | null;
|
||||
scrap?: number | null;
|
||||
target?: number | null;
|
||||
cycleTime?: number | null;
|
||||
};
|
||||
|
||||
export type OverviewMachineRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string | null;
|
||||
location?: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
latestHeartbeat: OverviewLatestHeartbeat | null;
|
||||
latestKpi: OverviewLatestKpi | null;
|
||||
heartbeats?: undefined;
|
||||
kpiSnapshots?: undefined;
|
||||
};
|
||||
|
||||
export type OverviewEventRow = {
|
||||
id: string;
|
||||
ts: Date | null;
|
||||
topic: string;
|
||||
eventType: string;
|
||||
severity: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
requiresAck: boolean;
|
||||
workOrderId?: string | null;
|
||||
machineId: string;
|
||||
machineName?: string | null;
|
||||
source: "ingested";
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user