Before 404 fix nginx api route issue
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState, type KeyboardEvent } from "react";
|
||||||
import { useI18n } from "@/lib/i18n/useI18n";
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
|
|
||||||
type MachineRow = {
|
type MachineRow = {
|
||||||
@@ -49,6 +50,7 @@ function badgeClass(status?: string, offline?: boolean) {
|
|||||||
|
|
||||||
export default function MachinesClient({ initialMachines = [] }: { initialMachines?: MachineRow[] }) {
|
export default function MachinesClient({ initialMachines = [] }: { initialMachines?: MachineRow[] }) {
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
const [machines, setMachines] = useState<MachineRow[]>(() => initialMachines);
|
const [machines, setMachines] = useState<MachineRow[]>(() => initialMachines);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
@@ -151,6 +153,13 @@ export default function MachinesClient({ initialMachines = [] }: { initialMachin
|
|||||||
setTimeout(() => setCopyStatus(null), 2000);
|
setTimeout(() => setCopyStatus(null), 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCardKeyDown(event: KeyboardEvent<HTMLDivElement>, machineId: string) {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
router.push(`/machines/${machineId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const showCreateCard = showCreate || (!loading && machines.length === 0);
|
const showCreateCard = showCreate || (!loading && machines.length === 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -276,10 +285,13 @@ export default function MachinesClient({ initialMachines = [] }: { initialMachin
|
|||||||
const lastSeen = secondsAgo(hbTs, locale, t("common.never"));
|
const lastSeen = secondsAgo(hbTs, locale, t("common.never"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
href={`/machines/${m.id}`}
|
role="link"
|
||||||
className="rounded-2xl border border-white/10 bg-white/5 p-5 hover:bg-white/10"
|
tabIndex={0}
|
||||||
|
onClick={() => 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"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -316,7 +328,8 @@ export default function MachinesClient({ initialMachines = [] }: { initialMachin
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import DowntimeParetoCard from "@/components/analytics/DowntimeParetoCard";
|
import DowntimeParetoCard from "@/components/analytics/DowntimeParetoCard";
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { useI18n } from "@/lib/i18n/useI18n";
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
|
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
|
||||||
|
|
||||||
type Heartbeat = {
|
type Heartbeat = {
|
||||||
ts: string;
|
ts: string;
|
||||||
@@ -290,6 +291,8 @@ function rowsToWorkOrders(rows: WorkOrderRow[]): WorkOrderUpload[] {
|
|||||||
|
|
||||||
export default function MachineDetailClient() {
|
export default function MachineDetailClient() {
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
|
const { screenlessMode } = useScreenlessMode();
|
||||||
|
const router = useRouter();
|
||||||
const params = useParams<{ machineId: string }>();
|
const params = useParams<{ machineId: string }>();
|
||||||
const machineId = params?.machineId;
|
const machineId = params?.machineId;
|
||||||
|
|
||||||
@@ -306,6 +309,10 @@ export default function MachineDetailClient() {
|
|||||||
const [open, setOpen] = useState<null | "events" | "deviation" | "impact">(null);
|
const [open, setOpen] = useState<null | "events" | "deviation" | "impact">(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [uploadState, setUploadState] = useState<UploadState>({ status: "idle" });
|
const [uploadState, setUploadState] = useState<UploadState>({ status: "idle" });
|
||||||
|
const [canDelete, setCanDelete] = useState(false);
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -359,6 +366,27 @@ export default function MachineDetailClient() {
|
|||||||
};
|
};
|
||||||
}, [machineId, t]);
|
}, [machineId, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
|
||||||
|
async function loadRole() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/me", { cache: "no-store" });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!alive) return;
|
||||||
|
const role = data?.membership?.role;
|
||||||
|
setCanDelete(role === "OWNER" || role === "ADMIN");
|
||||||
|
} catch {
|
||||||
|
if (alive) setCanDelete(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadRole();
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open !== "events" || !machineId) return;
|
if (open !== "events" || !machineId) return;
|
||||||
|
|
||||||
@@ -470,6 +498,28 @@ export default function MachineDetailClient() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteMachine() {
|
||||||
|
if (!machineId) return;
|
||||||
|
|
||||||
|
setDeleting(true);
|
||||||
|
setDeleteError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/machines/${machineId}`, { method: "DELETE" });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || !data.ok) {
|
||||||
|
throw new Error(data.error || t("machines.delete.error.failed"));
|
||||||
|
}
|
||||||
|
router.push("/machines");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : null;
|
||||||
|
setDeleteError(message || t("machines.delete.error.failed"));
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
setConfirmDelete(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const uploadButtonLabel =
|
const uploadButtonLabel =
|
||||||
uploadState.status === "parsing"
|
uploadState.status === "parsing"
|
||||||
? t("machine.detail.workOrders.uploadParsing")
|
? t("machine.detail.workOrders.uploadParsing")
|
||||||
@@ -958,7 +1008,50 @@ export default function MachineDetailClient() {
|
|||||||
>
|
>
|
||||||
{t("machine.detail.back")}
|
{t("machine.detail.back")}
|
||||||
</Link>
|
</Link>
|
||||||
|
{canDelete && !confirmDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDelete(true)}
|
||||||
|
className="w-full rounded-xl border border-red-400/40 bg-red-500/20 px-4 py-2 text-sm text-red-100 hover:bg-red-500/30 sm:w-auto"
|
||||||
|
>
|
||||||
|
{t("machines.delete")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{canDelete && confirmDelete && (
|
||||||
|
<div className="w-full rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-100">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="text-xs text-red-100">
|
||||||
|
{t("machines.delete.confirm", {
|
||||||
|
name: machine?.name ?? t("machine.detail.titleFallback"),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void deleteMachine()}
|
||||||
|
disabled={deleting}
|
||||||
|
className="rounded-full border border-red-400/40 bg-red-500/20 px-3 py-1 text-[11px] font-semibold text-red-100 hover:bg-red-500/30 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{deleting ? t("machines.delete.loading") : t("machines.delete")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDelete(false)}
|
||||||
|
disabled={deleting}
|
||||||
|
className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] text-white hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{deleteError && (
|
||||||
|
<div className="w-full rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-200">
|
||||||
|
{deleteError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="text-left text-[11px] text-zinc-500 sm:text-right">
|
<div className="text-left text-[11px] text-zinc-500 sm:text-right">
|
||||||
{t("machine.detail.workOrders.uploadHint")}
|
{t("machine.detail.workOrders.uploadHint")}
|
||||||
</div>
|
</div>
|
||||||
@@ -1014,31 +1107,33 @@ export default function MachineDetailClient() {
|
|||||||
activeStoppage={activeStoppage}
|
activeStoppage={activeStoppage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 rounded-2xl border border-white/10 bg-white/5 p-5">
|
{!screenlessMode && (
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="mt-6 rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
<div>
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="text-sm font-semibold text-white">Downtime (preview)</div>
|
<div>
|
||||||
<div className="mt-1 text-xs text-zinc-400">Top reasons + quick pareto</div>
|
<div className="text-sm font-semibold text-white">Downtime (preview)</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-400">Top reasons + quick pareto</div>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/downtime?machineId=${encodeURIComponent(machineId)}&range=7d`}
|
||||||
|
className="rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
View full report →
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
|
||||||
href={`/downtime?machineId=${encodeURIComponent(machineId)}&range=7d`}
|
|
||||||
className="rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-white hover:bg-white/10"
|
|
||||||
>
|
|
||||||
View full report →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<DowntimeParetoCard
|
<DowntimeParetoCard
|
||||||
machineId={machineId}
|
machineId={machineId}
|
||||||
range="7d"
|
range="7d"
|
||||||
variant="summary"
|
variant="summary"
|
||||||
maxBars={5}
|
maxBars={5}
|
||||||
showCoverage={true}
|
showCoverage={true}
|
||||||
showOpenFullReport={false}
|
showOpenFullReport={false}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,316 +1,273 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { createHash } from "crypto";
|
import { z } from "zod";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireSession } from "@/lib/auth/requireSession";
|
import { requireSession } from "@/lib/auth/requireSession";
|
||||||
import { normalizeEvent } from "@/lib/events/normalizeEvent";
|
import { normalizeEvent } from "@/lib/events/normalizeEvent";
|
||||||
|
|
||||||
|
const machineIdSchema = z.string().uuid();
|
||||||
|
|
||||||
export async function GET(
|
const ALLOWED_EVENT_TYPES = new Set([
|
||||||
_req: NextRequest,
|
"slow-cycle",
|
||||||
{ params }: { params: Promise<{ machineId: string }> }
|
"microstop",
|
||||||
) {
|
"macrostop",
|
||||||
|
"offline",
|
||||||
|
"error",
|
||||||
|
"oee-drop",
|
||||||
|
"quality-spike",
|
||||||
|
"performance-degradation",
|
||||||
|
"predictive-oee-decline",
|
||||||
|
"alert-delivery-failed",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function canManageMachines(role?: string | null) {
|
||||||
|
return role === "OWNER" || role === "ADMIN";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumber(value: string | null, fallback: number) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, { 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 url = new URL(_req.url);
|
|
||||||
const eventsMode = url.searchParams.get("events") ?? "all";
|
|
||||||
const eventsOnly = url.searchParams.get("eventsOnly") === "1";
|
|
||||||
const eventsWindowSec = Number(url.searchParams.get("eventsWindowSec") ?? "21600"); // default 6h
|
|
||||||
const eventsWindowStart = new Date(Date.now() - Math.max(0, eventsWindowSec) * 1000);
|
|
||||||
const windowSec = Number(url.searchParams.get("windowSec") ?? "3600"); // default 1h
|
|
||||||
|
|
||||||
const { machineId } = await params;
|
const { machineId } = await params;
|
||||||
|
if (!machineIdSchema.safeParse(machineId).success) {
|
||||||
const machineBase = await prisma.machine.findFirst({
|
return NextResponse.json({ ok: false, error: "Invalid machine id" }, { status: 400 });
|
||||||
where: { id: machineId, orgId: session.orgId },
|
|
||||||
select: { id: true, updatedAt: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!machineBase) {
|
|
||||||
return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [heartbeatAgg, kpiAgg, eventAgg, cycleAgg, orgSettingsAgg] = await Promise.all([
|
const url = new URL(req.url);
|
||||||
prisma.machineHeartbeat.aggregate({
|
const windowSec = Math.max(0, parseNumber(url.searchParams.get("windowSec"), 3600));
|
||||||
where: { orgId: session.orgId, machineId },
|
const eventsWindowSec = Math.max(0, parseNumber(url.searchParams.get("eventsWindowSec"), 21600));
|
||||||
_max: { tsServer: true },
|
const eventsMode = url.searchParams.get("events") ?? "critical";
|
||||||
}),
|
const eventsOnly = url.searchParams.get("eventsOnly") === "1";
|
||||||
prisma.machineKpiSnapshot.aggregate({
|
|
||||||
where: { orgId: session.orgId, machineId },
|
const [machineRow, orgSettings, machineSettings] = await Promise.all([
|
||||||
_max: { tsServer: true },
|
prisma.machine.findFirst({
|
||||||
}),
|
where: { id: machineId, orgId: session.orgId },
|
||||||
prisma.machineEvent.aggregate({
|
select: {
|
||||||
where: { orgId: session.orgId, machineId, ts: { gte: eventsWindowStart } },
|
id: true,
|
||||||
_max: { tsServer: true },
|
name: true,
|
||||||
}),
|
code: true,
|
||||||
prisma.machineCycle.aggregate({
|
location: true,
|
||||||
where: { orgId: session.orgId, machineId },
|
createdAt: true,
|
||||||
_max: { ts: 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
prisma.orgSettings.findUnique({
|
prisma.orgSettings.findUnique({
|
||||||
where: { orgId: session.orgId },
|
where: { orgId: session.orgId },
|
||||||
select: { updatedAt: true, stoppageMultiplier: true, macroStoppageMultiplier: true },
|
select: { stoppageMultiplier: true, macroStoppageMultiplier: true },
|
||||||
|
}),
|
||||||
|
prisma.machineSettings.findUnique({
|
||||||
|
where: { machineId },
|
||||||
|
select: { overridesJson: true },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const toMs = (value?: Date | null) => (value ? value.getTime() : 0);
|
if (!machineRow) {
|
||||||
const lastModifiedMs = Math.max(
|
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
|
||||||
toMs(machineBase.updatedAt),
|
|
||||||
toMs(heartbeatAgg._max.tsServer),
|
|
||||||
toMs(kpiAgg._max.tsServer),
|
|
||||||
toMs(eventAgg._max.tsServer),
|
|
||||||
toMs(cycleAgg._max.ts),
|
|
||||||
toMs(orgSettingsAgg?.updatedAt)
|
|
||||||
);
|
|
||||||
|
|
||||||
const versionParts = [
|
|
||||||
session.orgId,
|
|
||||||
machineId,
|
|
||||||
eventsMode,
|
|
||||||
eventsOnly ? "1" : "0",
|
|
||||||
eventsWindowSec,
|
|
||||||
windowSec,
|
|
||||||
toMs(machineBase.updatedAt),
|
|
||||||
toMs(heartbeatAgg._max.tsServer),
|
|
||||||
toMs(kpiAgg._max.tsServer),
|
|
||||||
toMs(eventAgg._max.tsServer),
|
|
||||||
toMs(cycleAgg._max.ts),
|
|
||||||
toMs(orgSettingsAgg?.updatedAt),
|
|
||||||
];
|
|
||||||
|
|
||||||
const etag = `W/"${createHash("sha1").update(versionParts.join("|")).digest("hex")}"`;
|
|
||||||
const lastModified = new Date(lastModifiedMs || 0).toUTCString();
|
|
||||||
const responseHeaders = new Headers({
|
|
||||||
"Cache-Control": "private, no-cache, max-age=0, must-revalidate",
|
|
||||||
ETag: etag,
|
|
||||||
"Last-Modified": lastModified,
|
|
||||||
Vary: "Cookie",
|
|
||||||
});
|
|
||||||
|
|
||||||
const ifNoneMatch = _req.headers.get("if-none-match");
|
|
||||||
if (ifNoneMatch && ifNoneMatch === etag) {
|
|
||||||
return new NextResponse(null, { status: 304, headers: responseHeaders });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ifModifiedSince = _req.headers.get("if-modified-since");
|
const overrides = isPlainObject(machineSettings?.overridesJson) ? machineSettings?.overridesJson : {};
|
||||||
if (!ifNoneMatch && ifModifiedSince) {
|
const thresholdsOverride = isPlainObject(overrides.thresholds) ? overrides.thresholds : {};
|
||||||
const since = Date.parse(ifModifiedSince);
|
const stoppageMultiplier =
|
||||||
if (!Number.isNaN(since) && lastModifiedMs <= since) {
|
typeof thresholdsOverride.stoppageMultiplier === "number"
|
||||||
return new NextResponse(null, { status: 304, headers: responseHeaders });
|
? thresholdsOverride.stoppageMultiplier
|
||||||
}
|
: Number(orgSettings?.stoppageMultiplier ?? 1.5);
|
||||||
}
|
const macroStoppageMultiplier =
|
||||||
|
typeof thresholdsOverride.macroStoppageMultiplier === "number"
|
||||||
|
? thresholdsOverride.macroStoppageMultiplier
|
||||||
|
: Number(orgSettings?.macroStoppageMultiplier ?? 5);
|
||||||
|
|
||||||
const machine = await prisma.machine.findFirst({
|
const thresholds = {
|
||||||
where: { id: machineId, orgId: session.orgId },
|
stoppageMultiplier,
|
||||||
select: {
|
macroStoppageMultiplier,
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
code: true,
|
|
||||||
location: 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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!machine) {
|
|
||||||
return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const microMultiplier = Number(orgSettingsAgg?.stoppageMultiplier ?? 1.5);
|
|
||||||
const macroMultiplier = Math.max(
|
|
||||||
microMultiplier,
|
|
||||||
Number(orgSettingsAgg?.macroStoppageMultiplier ?? 5)
|
|
||||||
);
|
|
||||||
|
|
||||||
const rawEvents = await prisma.machineEvent.findMany({
|
|
||||||
where: {
|
|
||||||
orgId: session.orgId,
|
|
||||||
machineId,
|
|
||||||
ts: { gte: eventsWindowStart },
|
|
||||||
},
|
|
||||||
orderBy: { ts: "desc" },
|
|
||||||
take: 100, // pull more, we'll filter after normalization
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
ts: true,
|
|
||||||
topic: true,
|
|
||||||
eventType: true,
|
|
||||||
severity: true,
|
|
||||||
title: true,
|
|
||||||
description: true,
|
|
||||||
requiresAck: true,
|
|
||||||
data: true,
|
|
||||||
workOrderId: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const normalized = rawEvents.map((row) =>
|
|
||||||
normalizeEvent(row, { microMultiplier, macroMultiplier })
|
|
||||||
);
|
|
||||||
|
|
||||||
const ALLOWED_TYPES = new Set([
|
|
||||||
"slow-cycle",
|
|
||||||
"microstop",
|
|
||||||
"macrostop",
|
|
||||||
"offline",
|
|
||||||
"error",
|
|
||||||
"oee-drop",
|
|
||||||
"quality-spike",
|
|
||||||
"performance-degradation",
|
|
||||||
"predictive-oee-decline",
|
|
||||||
"alert-delivery-failed",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const allEvents = normalized.filter((e) => ALLOWED_TYPES.has(e.eventType));
|
|
||||||
|
|
||||||
const isCritical = (event: (typeof allEvents)[number]) => {
|
|
||||||
const severity = String(event.severity ?? "").toLowerCase();
|
|
||||||
return (
|
|
||||||
event.eventType === "macrostop" ||
|
|
||||||
event.requiresAck === true ||
|
|
||||||
severity === "critical" ||
|
|
||||||
severity === "error" ||
|
|
||||||
severity === "high"
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const eventsFiltered = eventsMode === "critical" ? allEvents.filter(isCritical) : allEvents;
|
const machine = {
|
||||||
const events = eventsFiltered.slice(0, 30);
|
...machineRow,
|
||||||
const eventsCountAll = allEvents.length;
|
effectiveCycleTime: null,
|
||||||
const eventsCountCritical = allEvents.filter(isCritical).length;
|
latestHeartbeat: machineRow.heartbeats[0] ?? null,
|
||||||
|
latestKpi: machineRow.kpiSnapshots[0] ?? null,
|
||||||
|
heartbeats: undefined,
|
||||||
|
kpiSnapshots: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
if (eventsOnly) {
|
const cycles = eventsOnly
|
||||||
return NextResponse.json(
|
? []
|
||||||
{ ok: true, events, eventsCountAll, eventsCountCritical },
|
: await prisma.machineCycle.findMany({
|
||||||
{ headers: responseHeaders }
|
where: {
|
||||||
);
|
orgId: session.orgId,
|
||||||
}
|
machineId,
|
||||||
|
ts: { gte: new Date(Date.now() - windowSec * 1000) },
|
||||||
|
},
|
||||||
|
orderBy: { ts: "asc" },
|
||||||
|
select: {
|
||||||
|
ts: true,
|
||||||
|
tsServer: true,
|
||||||
|
cycleCount: true,
|
||||||
|
actualCycleTime: true,
|
||||||
|
theoreticalCycleTime: true,
|
||||||
|
workOrderId: true,
|
||||||
|
sku: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const cyclesOut = cycles.map((row) => {
|
||||||
// ---- cycles window ----
|
const ts = row.tsServer ?? row.ts;
|
||||||
|
return {
|
||||||
const latestKpi = machine.kpiSnapshots[0] ?? null;
|
ts,
|
||||||
|
t: ts.getTime(),
|
||||||
// If KPI cycleTime missing, fallback to DB cycles (we fetch 1 first)
|
cycleCount: row.cycleCount ?? null,
|
||||||
const latestCycleForIdeal = await prisma.machineCycle.findFirst({
|
actual: row.actualCycleTime,
|
||||||
where: { orgId: session.orgId, machineId },
|
ideal: row.theoreticalCycleTime ?? null,
|
||||||
orderBy: { ts: "desc" },
|
workOrderId: row.workOrderId ?? null,
|
||||||
select: { theoreticalCycleTime: true },
|
sku: row.sku ?? null,
|
||||||
});
|
|
||||||
|
|
||||||
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(1000, 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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const latestCycle = rawCycles[0] ?? null;
|
|
||||||
|
|
||||||
let activeStoppage: {
|
|
||||||
state: "microstop" | "macrostop";
|
|
||||||
startedAt: string;
|
|
||||||
durationSec: number;
|
|
||||||
theoreticalCycleTime: number;
|
|
||||||
} | null = null;
|
|
||||||
|
|
||||||
if (latestCycle?.ts && effectiveCycleTime && effectiveCycleTime > 0) {
|
|
||||||
const elapsedSec = (Date.now() - latestCycle.ts.getTime()) / 1000;
|
|
||||||
const microThresholdSec = effectiveCycleTime * microMultiplier;
|
|
||||||
const macroThresholdSec = effectiveCycleTime * macroMultiplier;
|
|
||||||
|
|
||||||
if (elapsedSec >= microThresholdSec) {
|
|
||||||
const isMacro = elapsedSec >= macroThresholdSec;
|
|
||||||
const state = isMacro ? "macrostop" : "microstop";
|
|
||||||
const thresholdSec = isMacro ? macroThresholdSec : microThresholdSec;
|
|
||||||
const startedAtMs = latestCycle.ts.getTime() + thresholdSec * 1000;
|
|
||||||
|
|
||||||
activeStoppage = {
|
|
||||||
state,
|
|
||||||
startedAt: new Date(startedAtMs).toISOString(),
|
|
||||||
durationSec: Math.max(0, Math.floor(elapsedSec - thresholdSec)),
|
|
||||||
theoreticalCycleTime: effectiveCycleTime,
|
|
||||||
};
|
};
|
||||||
}
|
});
|
||||||
|
|
||||||
|
const eventWindowStart = new Date(Date.now() - eventsWindowSec * 1000);
|
||||||
|
const criticalSeverities = ["critical", "error", "high"];
|
||||||
|
const eventWhere = {
|
||||||
|
orgId: session.orgId,
|
||||||
|
machineId,
|
||||||
|
ts: { gte: eventWindowStart },
|
||||||
|
eventType: { in: Array.from(ALLOWED_EVENT_TYPES) },
|
||||||
|
...(eventsMode === "critical"
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ eventType: "macrostop" },
|
||||||
|
{ requiresAck: true },
|
||||||
|
{ severity: { in: criticalSeverities } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [rawEvents, eventsCountAll] = await Promise.all([
|
||||||
|
prisma.machineEvent.findMany({
|
||||||
|
where: eventWhere,
|
||||||
|
orderBy: { ts: "desc" },
|
||||||
|
take: eventsOnly ? 300 : 120,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
ts: true,
|
||||||
|
topic: true,
|
||||||
|
eventType: true,
|
||||||
|
severity: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
requiresAck: true,
|
||||||
|
data: true,
|
||||||
|
workOrderId: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.machineEvent.count({ where: eventWhere }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const normalized = rawEvents.map((row) =>
|
||||||
|
normalizeEvent(row, { microMultiplier: stoppageMultiplier, macroMultiplier: macroStoppageMultiplier })
|
||||||
|
);
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const deduped = normalized.filter((event) => {
|
||||||
|
const key = `${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;
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
machine,
|
||||||
|
events: deduped,
|
||||||
|
eventsCountAll,
|
||||||
|
cycles: cyclesOut,
|
||||||
|
thresholds,
|
||||||
|
activeStoppage: null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// chart-friendly: oldest -> newest + numeric timestamps
|
export async function DELETE(_req: Request, { params }: { params: Promise<{ machineId: string }> }) {
|
||||||
const cycles = rawCycles
|
const session = await requireSession();
|
||||||
.slice()
|
if (!session) {
|
||||||
.reverse()
|
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||||
.map((c) => ({
|
}
|
||||||
ts: c.ts,
|
|
||||||
t: c.ts.getTime(),
|
const { machineId } = await params;
|
||||||
cycleCount: c.cycleCount ?? null,
|
if (!machineIdSchema.safeParse(machineId).success) {
|
||||||
actual: c.actualCycleTime,
|
return NextResponse.json({ ok: false, error: "Invalid machine id" }, { status: 400 });
|
||||||
ideal: c.theoreticalCycleTime ?? null,
|
}
|
||||||
workOrderId: c.workOrderId ?? null,
|
|
||||||
sku: c.sku ?? null,
|
const membership = await prisma.orgUser.findUnique({
|
||||||
}));
|
where: {
|
||||||
return NextResponse.json(
|
orgId_userId: {
|
||||||
{
|
orgId: session.orgId,
|
||||||
ok: true,
|
userId: session.userId,
|
||||||
machine: {
|
|
||||||
id: machine.id,
|
|
||||||
name: machine.name,
|
|
||||||
code: machine.code,
|
|
||||||
location: machine.location,
|
|
||||||
latestHeartbeat: machine.heartbeats[0] ?? null,
|
|
||||||
latestKpi: machine.kpiSnapshots[0] ?? null,
|
|
||||||
effectiveCycleTime,
|
|
||||||
},
|
},
|
||||||
thresholds: {
|
|
||||||
stoppageMultiplier: microMultiplier,
|
|
||||||
macroStoppageMultiplier: macroMultiplier,
|
|
||||||
},
|
|
||||||
activeStoppage,
|
|
||||||
events,
|
|
||||||
eventsCountAll,
|
|
||||||
eventsCountCritical,
|
|
||||||
cycles,
|
|
||||||
},
|
},
|
||||||
{ headers: responseHeaders }
|
select: { role: true },
|
||||||
);
|
});
|
||||||
|
|
||||||
|
if (!canManageMachines(membership?.role)) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.machineCycle.deleteMany({
|
||||||
|
where: {
|
||||||
|
machineId,
|
||||||
|
orgId: session.orgId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return tx.machine.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: machineId,
|
||||||
|
orgId: session.orgId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.count === 0) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,10 @@
|
|||||||
"machines.create.default": "Create Machine",
|
"machines.create.default": "Create Machine",
|
||||||
"machines.create.error.nameRequired": "Machine name is required",
|
"machines.create.error.nameRequired": "Machine name is required",
|
||||||
"machines.create.error.failed": "Failed to create machine",
|
"machines.create.error.failed": "Failed to create machine",
|
||||||
|
"machines.delete": "Remove",
|
||||||
|
"machines.delete.loading": "Removing...",
|
||||||
|
"machines.delete.confirm": "Remove {name}? This will delete the machine and its data.",
|
||||||
|
"machines.delete.error.failed": "Failed to remove machine",
|
||||||
"machines.pairing.title": "Edge pairing code",
|
"machines.pairing.title": "Edge pairing code",
|
||||||
"machines.pairing.machine": "Machine:",
|
"machines.pairing.machine": "Machine:",
|
||||||
"machines.pairing.codeLabel": "Pairing code",
|
"machines.pairing.codeLabel": "Pairing code",
|
||||||
|
|||||||
@@ -118,6 +118,10 @@
|
|||||||
"machines.create.default": "Crear máquina",
|
"machines.create.default": "Crear máquina",
|
||||||
"machines.create.error.nameRequired": "El nombre de la máquina es obligatorio",
|
"machines.create.error.nameRequired": "El nombre de la máquina es obligatorio",
|
||||||
"machines.create.error.failed": "No se pudo crear la máquina",
|
"machines.create.error.failed": "No se pudo crear la máquina",
|
||||||
|
"machines.delete": "Eliminar",
|
||||||
|
"machines.delete.loading": "Eliminando...",
|
||||||
|
"machines.delete.confirm": "¿Eliminar {name}? Esto borrará la máquina y sus datos.",
|
||||||
|
"machines.delete.error.failed": "No se pudo eliminar la máquina",
|
||||||
"machines.pairing.title": "Código de emparejamiento",
|
"machines.pairing.title": "Código de emparejamiento",
|
||||||
"machines.pairing.machine": "Máquina:",
|
"machines.pairing.machine": "Máquina:",
|
||||||
"machines.pairing.codeLabel": "Código de emparejamiento",
|
"machines.pairing.codeLabel": "Código de emparejamiento",
|
||||||
|
|||||||
36
nousar_middleware.ts
Normal file
36
nousar_middleware.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
export function middleware(req: NextRequest) {
|
||||||
|
const { pathname, search } = req.nextUrl;
|
||||||
|
|
||||||
|
// Skip auth for public routes + Next internals
|
||||||
|
if (
|
||||||
|
pathname.startsWith("/_next") ||
|
||||||
|
pathname.startsWith("/favicon") ||
|
||||||
|
pathname.startsWith("/api") ||
|
||||||
|
pathname === "/login" ||
|
||||||
|
pathname === "/signup" ||
|
||||||
|
pathname === "/logout"
|
||||||
|
) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: replace with your real session cookie name
|
||||||
|
const sessionId = req.cookies.get("sessionId")?.value;
|
||||||
|
|
||||||
|
// Protect everything under the app
|
||||||
|
if (!sessionId) {
|
||||||
|
const url = req.nextUrl.clone();
|
||||||
|
url.pathname = "/login";
|
||||||
|
url.searchParams.set("next", pathname + search);
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit the middleware to relevant paths
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/((?!_next/static|_next/image).*)"],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user