Resolving polling/server migration
This commit is contained in:
@@ -329,7 +329,7 @@ export default function MachineDetailClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
load();
|
load();
|
||||||
const timer = setInterval(load, 5000);
|
const timer = setInterval(load, 15000);
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
@@ -605,7 +605,7 @@ export default function MachineDetailClient() {
|
|||||||
<div className="mt-4 rounded-2xl border border-white/10 bg-black/25 p-4">
|
<div className="mt-4 rounded-2xl border border-white/10 bg-black/25 p-4">
|
||||||
<div className="mb-2 flex justify-between text-[11px] text-zinc-500">
|
<div className="mb-2 flex justify-between text-[11px] text-zinc-500">
|
||||||
<span>0s</span>
|
<span>0s</span>
|
||||||
<span>3h</span>
|
<span>1h</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex h-14 w-full overflow-hidden rounded-2xl">
|
<div className="flex h-14 w-full overflow-hidden rounded-2xl">
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export default function MachinesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
load();
|
load();
|
||||||
const t = setInterval(load, 5000);
|
const t = setInterval(load, 15000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
@@ -322,4 +322,3 @@ export default function MachinesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export default function OverviewPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
load();
|
load();
|
||||||
const t = setInterval(load, 15000);
|
const t = setInterval(load, 30000);
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
clearInterval(t);
|
clearInterval(t);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getMachineAuth } from "@/lib/machineAuthCache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
function unwrapEnvelope(raw: any) {
|
function unwrapEnvelope(raw: any) {
|
||||||
@@ -38,24 +39,21 @@ const intFromAny = z.preprocess((value) => {
|
|||||||
return value;
|
return value;
|
||||||
}, z.number().int().finite());
|
}, z.number().int().finite());
|
||||||
|
|
||||||
const cyclePayloadSchema = z
|
const machineIdSchema = z.string().uuid();
|
||||||
|
|
||||||
|
const cycleSchema = z
|
||||||
.object({
|
.object({
|
||||||
machineId: z.string().uuid(),
|
actual_cycle_time: numberFromAny,
|
||||||
cycle: z
|
theoretical_cycle_time: numberFromAny.optional(),
|
||||||
.object({
|
cycle_count: intFromAny.optional(),
|
||||||
actual_cycle_time: numberFromAny,
|
work_order_id: z.string().trim().max(64).optional(),
|
||||||
theoretical_cycle_time: numberFromAny.optional(),
|
sku: z.string().trim().max(64).optional(),
|
||||||
cycle_count: intFromAny.optional(),
|
cavities: intFromAny.optional(),
|
||||||
work_order_id: z.string().trim().max(64).optional(),
|
good_delta: intFromAny.optional(),
|
||||||
sku: z.string().trim().max(64).optional(),
|
scrap_delta: intFromAny.optional(),
|
||||||
cavities: intFromAny.optional(),
|
timestamp: numberFromAny.optional(),
|
||||||
good_delta: intFromAny.optional(),
|
ts: numberFromAny.optional(),
|
||||||
scrap_delta: intFromAny.optional(),
|
event_timestamp: numberFromAny.optional(),
|
||||||
timestamp: numberFromAny.optional(),
|
|
||||||
ts: numberFromAny.optional(),
|
|
||||||
event_timestamp: numberFromAny.optional(),
|
|
||||||
})
|
|
||||||
.passthrough(),
|
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|
||||||
@@ -66,44 +64,60 @@ export async function POST(req: Request) {
|
|||||||
let body = await req.json().catch(() => null);
|
let body = await req.json().catch(() => null);
|
||||||
body = unwrapEnvelope(body);
|
body = unwrapEnvelope(body);
|
||||||
|
|
||||||
const parsed = cyclePayloadSchema.safeParse(body);
|
const machineId = body?.machineId ?? body?.machine_id ?? body?.machine?.id;
|
||||||
if (!parsed.success) {
|
if (!machineId || !machineIdSchema.safeParse(String(machineId)).success) {
|
||||||
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
|
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const machine = await prisma.machine.findFirst({
|
const machine = await getMachineAuth(String(machineId), apiKey);
|
||||||
where: { id: parsed.data.machineId, apiKey },
|
|
||||||
select: { id: true, orgId: true },
|
|
||||||
});
|
|
||||||
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const c = parsed.data.cycle;
|
|
||||||
const raw = body as any;
|
const raw = body as any;
|
||||||
|
const cyclesRaw = raw?.cycles ?? raw?.cycle;
|
||||||
|
if (!cyclesRaw) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const tsMs =
|
const cycleList = Array.isArray(cyclesRaw) ? cyclesRaw : [cyclesRaw];
|
||||||
(typeof c.timestamp === "number" && c.timestamp) ||
|
const parsedCycles = z.array(cycleSchema).safeParse(cycleList);
|
||||||
(typeof c.ts === "number" && c.ts) ||
|
if (!parsedCycles.success) {
|
||||||
(typeof c.event_timestamp === "number" && c.event_timestamp) ||
|
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackTsMs =
|
||||||
(typeof raw?.tsMs === "number" && raw.tsMs) ||
|
(typeof raw?.tsMs === "number" && raw.tsMs) ||
|
||||||
(typeof raw?.tsDevice === "number" && raw.tsDevice) ||
|
(typeof raw?.tsDevice === "number" && raw.tsDevice) ||
|
||||||
undefined;
|
undefined;
|
||||||
|
|
||||||
const ts = tsMs ? new Date(tsMs) : new Date();
|
const rows = parsedCycles.data.map((data) => {
|
||||||
|
const tsMs =
|
||||||
|
(typeof data.timestamp === "number" && data.timestamp) ||
|
||||||
|
(typeof data.ts === "number" && data.ts) ||
|
||||||
|
(typeof data.event_timestamp === "number" && data.event_timestamp) ||
|
||||||
|
fallbackTsMs;
|
||||||
|
|
||||||
const row = await prisma.machineCycle.create({
|
const ts = tsMs ? new Date(tsMs) : new Date();
|
||||||
data: {
|
|
||||||
|
return {
|
||||||
orgId: machine.orgId,
|
orgId: machine.orgId,
|
||||||
machineId: machine.id,
|
machineId: machine.id,
|
||||||
ts,
|
ts,
|
||||||
cycleCount: typeof c.cycle_count === "number" ? c.cycle_count : null,
|
cycleCount: typeof data.cycle_count === "number" ? data.cycle_count : null,
|
||||||
actualCycleTime: c.actual_cycle_time,
|
actualCycleTime: data.actual_cycle_time,
|
||||||
theoreticalCycleTime: typeof c.theoretical_cycle_time === "number" ? c.theoretical_cycle_time : null,
|
theoreticalCycleTime: typeof data.theoretical_cycle_time === "number" ? data.theoretical_cycle_time : null,
|
||||||
workOrderId: c.work_order_id ? String(c.work_order_id) : null,
|
workOrderId: data.work_order_id ? String(data.work_order_id) : null,
|
||||||
sku: c.sku ? String(c.sku) : null,
|
sku: data.sku ? String(data.sku) : null,
|
||||||
cavities: typeof c.cavities === "number" ? c.cavities : null,
|
cavities: typeof data.cavities === "number" ? data.cavities : null,
|
||||||
goodDelta: typeof c.good_delta === "number" ? c.good_delta : null,
|
goodDelta: typeof data.good_delta === "number" ? data.good_delta : null,
|
||||||
scrapDelta: typeof c.scrap_delta === "number" ? c.scrap_delta : null,
|
scrapDelta: typeof data.scrap_delta === "number" ? data.scrap_delta : null,
|
||||||
},
|
};
|
||||||
});
|
});
|
||||||
return NextResponse.json({ ok: true, id: row.id, ts: row.ts });
|
|
||||||
|
if (rows.length === 1) {
|
||||||
|
const row = await prisma.machineCycle.create({ data: rows[0] });
|
||||||
|
return NextResponse.json({ ok: true, id: row.id, ts: row.ts });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.machineCycle.createMany({ data: rows });
|
||||||
|
return NextResponse.json({ ok: true, count: result.count });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getMachineAuth } from "@/lib/machineAuthCache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const normalizeType = (t: any) =>
|
const normalizeType = (t: any) =>
|
||||||
@@ -83,10 +84,7 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ ok: false, error: "Invalid machine id" }, { status: 400 });
|
return NextResponse.json({ ok: false, error: "Invalid machine id" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const machine = await prisma.machine.findFirst({
|
const machine = await getMachineAuth(String(machineId), apiKey);
|
||||||
where: { id: String(machineId), apiKey },
|
|
||||||
select: { id: true, orgId: true },
|
|
||||||
});
|
|
||||||
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||||
const orgSettings = await prisma.orgSettings.findUnique({
|
const orgSettings = await prisma.orgSettings.findUnique({
|
||||||
where: { orgId: machine.orgId },
|
where: { orgId: machine.orgId },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getMachineAuth } from "@/lib/machineAuthCache";
|
||||||
import { normalizeHeartbeatV1 } from "@/lib/contracts/v1";
|
import { normalizeHeartbeatV1 } from "@/lib/contracts/v1";
|
||||||
|
|
||||||
function getClientIp(req: Request) {
|
function getClientIp(req: Request) {
|
||||||
@@ -59,10 +60,7 @@ export async function POST(req: Request) {
|
|||||||
tsDeviceDate = new Date(body.tsDevice);
|
tsDeviceDate = new Date(body.tsDevice);
|
||||||
|
|
||||||
// 4) Authorize machineId + apiKey
|
// 4) Authorize machineId + apiKey
|
||||||
const machine = await prisma.machine.findFirst({
|
const machine = await getMachineAuth(machineId, apiKey);
|
||||||
where: { id: machineId, apiKey },
|
|
||||||
select: { id: true, orgId: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!machine) {
|
if (!machine) {
|
||||||
await prisma.ingestLog.create({
|
await prisma.ingestLog.create({
|
||||||
@@ -117,23 +115,6 @@ export async function POST(req: Request) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
id: hb.id,
|
id: hb.id,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// mis-control-tower/app/api/ingest/kpi/route.ts
|
// 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 { getMachineAuth } from "@/lib/machineAuthCache";
|
||||||
import { normalizeSnapshotV1 } from "@/lib/contracts/v1";
|
import { normalizeSnapshotV1 } from "@/lib/contracts/v1";
|
||||||
|
|
||||||
function getClientIp(req: Request) {
|
function getClientIp(req: Request) {
|
||||||
@@ -75,10 +76,7 @@ export async function POST(req: Request) {
|
|||||||
tsDeviceDate = new Date(body.tsDevice);
|
tsDeviceDate = new Date(body.tsDevice);
|
||||||
|
|
||||||
// Auth: machineId + apiKey must match
|
// Auth: machineId + apiKey must match
|
||||||
const machine = await prisma.machine.findFirst({
|
const machine = await getMachineAuth(machineId, apiKey);
|
||||||
where: { id: machineId, apiKey },
|
|
||||||
select: { id: true, orgId: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!machine) {
|
if (!machine) {
|
||||||
await prisma.ingestLog.create({
|
await prisma.ingestLog.create({
|
||||||
@@ -165,22 +163,6 @@ export async function POST(req: Request) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
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({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ const events = normalized
|
|||||||
|
|
||||||
// ---- cycles window ----
|
// ---- cycles window ----
|
||||||
const url = new URL(_req.url);
|
const url = new URL(_req.url);
|
||||||
const windowSec = Number(url.searchParams.get("windowSec") ?? "10800"); // default 3h
|
const windowSec = Number(url.searchParams.get("windowSec") ?? "3600"); // default 1h
|
||||||
|
|
||||||
const latestKpi = machine.kpiSnapshots[0] ?? null;
|
const latestKpi = machine.kpiSnapshots[0] ?? null;
|
||||||
|
|
||||||
@@ -297,7 +297,7 @@ const estCycleSec = Math.max(1, Number(effectiveCycleTime ?? 14));
|
|||||||
const needed = Math.ceil(windowSec / estCycleSec) + 50;
|
const needed = Math.ceil(windowSec / estCycleSec) + 50;
|
||||||
|
|
||||||
// Safety cap to avoid crazy payloads
|
// Safety cap to avoid crazy payloads
|
||||||
const takeCycles = Math.min(5000, Math.max(200, needed));
|
const takeCycles = Math.min(1000, Math.max(200, needed));
|
||||||
|
|
||||||
const rawCycles = await prisma.machineCycle.findMany({
|
const rawCycles = await prisma.machineCycle.findMany({
|
||||||
where: { orgId: session.orgId, machineId },
|
where: { orgId: session.orgId, machineId },
|
||||||
|
|||||||
@@ -6,11 +6,16 @@ let initialized = false;
|
|||||||
let currentLocale: Locale = "en";
|
let currentLocale: Locale = "en";
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
const isBrowser = () => typeof document !== "undefined";
|
||||||
|
|
||||||
function readCookieLocale(): Locale | null {
|
function readCookieLocale(): Locale | null {
|
||||||
|
if (!isBrowser()) return null;
|
||||||
|
|
||||||
const match = document.cookie
|
const match = document.cookie
|
||||||
.split(";")
|
.split(";")
|
||||||
.map((part) => part.trim())
|
.map((part) => part.trim())
|
||||||
.find((part) => part.startsWith(`${LOCALE_COOKIE}=`));
|
.find((part) => part.startsWith(`${LOCALE_COOKIE}=`));
|
||||||
|
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
const value = match.split("=")[1];
|
const value = match.split("=")[1];
|
||||||
if (value === "es-MX" || value === "en") return value;
|
if (value === "es-MX" || value === "en") return value;
|
||||||
@@ -18,10 +23,14 @@ function readCookieLocale(): Locale | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readLocaleFromDocument(): Locale {
|
function readLocaleFromDocument(): Locale {
|
||||||
|
if (!isBrowser()) return "en";
|
||||||
|
|
||||||
const cookieLocale = readCookieLocale();
|
const cookieLocale = readCookieLocale();
|
||||||
if (cookieLocale) return cookieLocale;
|
if (cookieLocale) return cookieLocale;
|
||||||
|
|
||||||
const docLang = document.documentElement.getAttribute("lang");
|
const docLang = document.documentElement.getAttribute("lang");
|
||||||
if (docLang === "es-MX" || docLang === "en") return docLang;
|
if (docLang === "es-MX" || docLang === "en") return docLang;
|
||||||
|
|
||||||
return "en";
|
return "en";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,20 +41,18 @@ function ensureInitialized() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getLocaleSnapshot(): Locale {
|
export function getLocaleSnapshot(): Locale {
|
||||||
if (typeof document === "undefined") return "en";
|
if (!isBrowser()) return "en";
|
||||||
ensureInitialized();
|
ensureInitialized();
|
||||||
return currentLocale;
|
return currentLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function subscribeLocale(listener: () => void) {
|
export function subscribeLocale(listener: () => void) {
|
||||||
listeners.add(listener);
|
listeners.add(listener);
|
||||||
return () => {
|
return () => listeners.delete(listener);
|
||||||
listeners.delete(listener);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setLocale(next: Locale) {
|
export function setLocale(next: Locale) {
|
||||||
if (typeof document !== "undefined") {
|
if (isBrowser()) {
|
||||||
document.documentElement.setAttribute("lang", next);
|
document.documentElement.setAttribute("lang", next);
|
||||||
document.cookie = `${LOCALE_COOKIE}=${next}; Path=/; Max-Age=31536000; SameSite=Lax`;
|
document.cookie = `${LOCALE_COOKIE}=${next}; Path=/; Max-Age=31536000; SameSite=Lax`;
|
||||||
}
|
}
|
||||||
|
|||||||
38
lib/machineAuthCache.ts
Normal file
38
lib/machineAuthCache.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
type MachineAuth = { id: string; orgId: string };
|
||||||
|
|
||||||
|
const TTL_MS = 60_000;
|
||||||
|
const MAX_SIZE = 1000;
|
||||||
|
const cache = new Map<string, { value: MachineAuth; expiresAt: number }>();
|
||||||
|
|
||||||
|
function makeKey(machineId: string, apiKey: string) {
|
||||||
|
return `${machineId}:${apiKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMachineAuth(machineId: string, apiKey: string) {
|
||||||
|
const key = makeKey(machineId, apiKey);
|
||||||
|
const now = Date.now();
|
||||||
|
const hit = cache.get(key);
|
||||||
|
|
||||||
|
if (hit && hit.expiresAt > now) {
|
||||||
|
return hit.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const machine = await prisma.machine.findFirst({
|
||||||
|
where: { id: machineId, apiKey },
|
||||||
|
select: { id: true, orgId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!machine) {
|
||||||
|
cache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cache.size > MAX_SIZE) {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(key, { value: machine, expiresAt: now + TTL_MS });
|
||||||
|
return machine;
|
||||||
|
}
|
||||||
1422
package-lock.json
generated
1422
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,7 @@
|
|||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.1",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"xlsx": "^0.20.2",
|
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz",
|
||||||
"zod": "^4.2.1"
|
"zod": "^4.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Reference in New Issue
Block a user