pre-bemis

This commit is contained in:
Marcelo
2026-04-22 05:04:19 +00:00
parent ac1a7900c8
commit 80d27f83b6
91 changed files with 11769 additions and 820 deletions

84
LOGGING.md Normal file
View File

@@ -0,0 +1,84 @@
# Logging & debugging errors
## Where errors are logged
### 1. **Log file** (JSON lines)
- **Path:** `LOG_FILE` env var, or **`/tmp/mis-control-tower.log`** if unset.
- **Contents:** JSON lines for `requireSession.error`, `getOverviewData.error`, `OverviewPage.getOverviewData.error`, plus any `logLine(...)` usage (e.g. health, signup).
**View recent entries:**
```bash
tail -f /tmp/mis-control-tower.log
```
**Or with a custom path:**
```bashls -la
export LOG_FILE=/var/log/mis-control-tower.log
# then start the app; tail that path
tail -f /var/log/mis-control-tower.log
```
### 2. **Process stdout / stderr**
- **`console.error`** and **`console.log`** go to the process that runs Next.js.
- **Dev:** terminal where you run `npm run dev`.
- **Production:** PM2 logs (`pm2 logs`), Docker (`docker logs ...`), systemd (`journalctl -u your-service -f`), etc.
### 3. **Debug logs API** (optional)
- **URL:** `GET /api/debug/logs?key=YOUR_DEBUG_LOGS_KEY`
- **Purpose:** Returns the last 100 lines of the log file as JSON.
- **Setup:** Add to `.env`:
```
DEBUG_LOGS_KEY=your-secret-string
```
- **Usage:**
`curl "https://mis.maliountech.com.mx/api/debug/logs?key=your-secret-string"`
- If `DEBUG_LOGS_KEY` is unset or the `key` param is wrong, the route returns 401.
## Error events we log
| Event | When |
|-------|------|
| `requireSession.error` | Session lookup (cookies / DB) fails |
| `getOverviewData.error` | Overview data fetch (DB) fails |
| `OverviewPage.getOverviewData.error` | Overview page catch-around fetch fails |
Each includes `message` and `stack` when available.
## Quick checks when you see "Internal Server Error"
1. **Tail the log file:**
`tail -f /tmp/mis-control-tower.log`
(or `$LOG_FILE` if you set it.)
2. **Check process logs:**
Wherever `next start` or `npm run dev` runs (PM2, Docker, systemd). Look for `[requireSession]`, `[getOverviewData]`, `[OverviewPage]`, or `[middleware]`.
3. **Call the debug API** (if configured):
`curl "https://your-domain/api/debug/logs?key=YOUR_DEBUG_LOGS_KEY"`
and inspect the `entries` array for recent errors.
## KPI quality trace (Node-RED vs processing)
Use this when `Quality` is shown as `0` and you need to see exactly what was received and saved.
1. Enable trace logging:
`TRACE_KPI_INGEST=1`
2. Send KPI payloads as usual from Node-RED.
3. Inspect logs:
`tail -f /tmp/mis-control-tower.log`
or:
`curl "https://your-domain/api/debug/logs?key=YOUR_DEBUG_LOGS_KEY"`
4. Look for event `ingest.kpi.trace`, which includes:
`trace.rawQualityCandidates` (raw payload values found at multiple paths),
`trace.normalizedQuality` (post-normalization),
`trace.persistedQuality` (value written to DB).
Optional one-shot trace without env var:
- Send header `x-debug-ingest: 1` on a KPI request.
- The response will include a `trace` object with the same quality details.

View File

@@ -75,7 +75,24 @@ sudo systemctl daemon-reload
sudo systemctl enable --now mis-control-tower-reminders.timer
```
## Learn More
## Production build and deploy
**Dev uses Turbopack, production build uses Webpack.** Next.js 16 defaults to Turbopack for both, but Turbopack production builds have known issues. This project uses:
- `npm run dev``next dev --turbopack` (fast dev)
- `npm run build``next build --webpack` (stable production build)
**When deploying** (e.g. for `https://mis.maliountech.com.mx`):
1. **Build:** Run `npm run build` (Webpack).
2. **Start:** Run `npm run start` (or your process manager) to serve the built app.
3. If you previously built with Turbopack, run `rm -rf .next` then `npm run build` for a clean Webpack build.
4. Hard-refresh the browser (or clear site data) after redeploying so clients dont load old Turbopack chunks.
## Logging and debugging
See **[LOGGING.md](./LOGGING.md)** for where errors are logged (log file, process stdout, optional `/api/debug/logs`), how to tail them, and how to debug "Internal Server Error".
To learn more about Next.js, take a look at the following resources:

31
app/(app)/error.tsx Normal file
View File

@@ -0,0 +1,31 @@
"use client";
import { useEffect } from "react";
export default function AppError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("[App Error]", error);
}, [error]);
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 p-6">
<h2 className="text-lg font-semibold text-white">Something went wrong</h2>
<p className="max-w-md text-center text-sm text-zinc-400">
An error occurred while loading this page. Please try again.
</p>
<button
type="button"
onClick={() => reset()}
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10"
>
Try again
</button>
</div>
);
}

View File

@@ -76,6 +76,8 @@ export default function FinancialClient({
const [currencyFilter, setCurrencyFilter] = useState("");
const [loading, setLoading] = useState(() => initialMachines.length === 0);
const skipInitialImpactRef = useRef(true);
const forceRefreshRef = useRef(false);
const [refreshSeed, setRefreshSeed] = useState(0);
const locations = useMemo(() => {
const seen = new Set<string>();
@@ -158,6 +160,8 @@ export default function FinancialClient({
if (locationFilter) params.set("location", locationFilter);
if (skuFilter) params.set("sku", skuFilter);
if (currencyFilter) params.set("currency", currencyFilter);
const forceRefresh = forceRefreshRef.current;
if (forceRefresh) params.set("refresh", "1");
try {
const res = await fetch(`/api/financial/impact?${params.toString()}`, {
@@ -169,6 +173,8 @@ export default function FinancialClient({
setImpact(json);
} catch {
if (alive) setImpact(null);
} finally {
if (forceRefresh) forceRefreshRef.current = false;
}
}
@@ -177,7 +183,7 @@ export default function FinancialClient({
alive = false;
controller.abort();
};
}, [currencyFilter, initialImpact, locationFilter, machineFilter, range, role, skuFilter]);
}, [currencyFilter, initialImpact, locationFilter, machineFilter, range, refreshSeed, role, skuFilter]);
const selectedSummary = impact?.currencySummaries?.[0] ?? null;
const chartData = selectedSummary?.byDay ?? [];
@@ -193,6 +199,10 @@ export default function FinancialClient({
const htmlHref = `/api/financial/export/pdf?${exportQuery}`;
const csvHref = `/api/financial/export/excel?${exportQuery}`;
const handleRefresh = () => {
forceRefreshRef.current = true;
setRefreshSeed((prev) => prev + 1);
};
if (role && role !== "OWNER") {
return (
@@ -212,6 +222,13 @@ export default function FinancialClient({
<p className="text-sm text-zinc-400">{t("financial.subtitle")}</p>
</div>
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row">
<button
type="button"
onClick={handleRefresh}
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-center text-sm text-zinc-200 hover:bg-white/10 sm:w-auto"
>
{t("financial.refresh")}
</button>
<a
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-center text-sm text-zinc-200 hover:bg-white/10 sm:w-auto"
href={htmlHref}
@@ -288,7 +305,7 @@ export default function FinancialClient({
</div>
<div className="mt-4 h-64">
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer width="100%" height="100%" minHeight={200}>
<AreaChart data={chartData}>
<defs>
<linearGradient id="slowFill" x1="0" y1="0" x2="0" y2="1">

View File

@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { computeFinancialImpact } from "@/lib/financial/impact";
import { getFinancialImpactCached } from "@/lib/financial/cache";
import FinancialClient from "./FinancialClient";
const RANGE_MS = 7 * 24 * 60 * 60 * 1000;
@@ -28,7 +28,7 @@ export default async function FinancialPage() {
const end = new Date();
const start = new Date(end.getTime() - RANGE_MS);
const impact = await computeFinancialImpact({
const impact = await getFinancialImpactCached({
orgId: session.orgId,
start,
end,

View File

@@ -1,31 +1,22 @@
import { AppShell } from "@/components/layout/AppShell";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
const COOKIE_NAME = "mis_session";
export default async function AppLayout({ children }: { children: React.ReactNode }) {
export const dynamic = "force-dynamic";
export default async function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
const cookieJar = await cookies();
const sessionId = cookieJar.get(COOKIE_NAME)?.value;
const themeCookie = cookieJar.get("mis_theme")?.value;
const initialTheme = themeCookie === "light" ? "light" : "dark";
if (!sessionId) redirect("/login?next=/machines");
// validate session in DB (dont trust cookie existence)
const session = await prisma.session.findFirst({
where: {
id: sessionId,
revokedAt: null,
expiresAt: { gt: new Date() },
},
include: { user: true, org: true },
});
if (!session || !session.user?.isActive || !session.user?.emailVerifiedAt) {
redirect("/login?next=/machines");
}
if (!sessionId) redirect("/login");
return <AppShell initialTheme={initialTheme}>{children}</AppShell>;
}

13
app/(app)/loading.tsx Normal file
View File

@@ -0,0 +1,13 @@
export default function AppLoading() {
return (
<div className="p-4 sm:p-6 space-y-6 animate-pulse">
<div className="h-7 w-48 rounded-lg bg-white/10" />
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, idx) => (
<div key={idx} className="h-28 rounded-2xl border border-white/10 bg-white/5" />
))}
</div>
<div className="h-80 rounded-2xl border border-white/10 bg-white/5" />
</div>
);
}

View File

@@ -19,6 +19,7 @@ type MachineRow = {
fwVersion?: string | null;
};
};
const LIVE_REFRESH_MS = 5000;
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
if (!ts) return fallback;
@@ -52,7 +53,7 @@ export default function MachinesClient({ initialMachines = [] }: { initialMachin
const { t, locale } = useI18n();
const router = useRouter();
const [machines, setMachines] = useState<MachineRow[]>(() => initialMachines);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(() => initialMachines.length === 0);
const [showCreate, setShowCreate] = useState(false);
const [createName, setCreateName] = useState("");
const [createCode, setCreateCode] = useState("");
@@ -69,28 +70,36 @@ export default function MachinesClient({ initialMachines = [] }: { initialMachin
useEffect(() => {
let alive = true;
let timer: ReturnType<typeof setTimeout> | null = null;
async function load() {
async function load(initial: boolean) {
try {
if (!initial && typeof document !== "undefined" && document.hidden) {
return;
}
const res = await fetch("/api/machines", { cache: "no-store" });
const json = await res.json();
if (alive) {
setMachines(json.machines ?? []);
setLoading(false);
if (initial) setLoading(false);
}
} catch {
if (alive) setLoading(false);
if (alive && initial) setLoading(false);
} finally {
if (!alive) return;
timer = setTimeout(() => {
void load(false);
}, LIVE_REFRESH_MS);
}
}
load();
const t = setInterval(load, 15000);
void load(initialMachines.length === 0);
return () => {
alive = false;
clearInterval(t);
if (timer) clearTimeout(timer);
};
}, []);
}, [initialMachines.length]);
async function createMachine() {
if (!createName.trim()) {

View File

@@ -128,6 +128,7 @@ const TOL = 0.10;
const DEFAULT_MICRO_MULT = 1.5;
const DEFAULT_MACRO_MULT = 5;
const NORMAL_TOL_SEC = 0.1;
const LIVE_REFRESH_MS = 5000;
const BUCKET = {
normal: {
@@ -289,6 +290,24 @@ function rowsToWorkOrders(rows: WorkOrderRow[]): WorkOrderUpload[] {
return out;
}
function toErrorMessage(value: unknown, fallback: string): string {
if (typeof value === "string" && value.trim().length > 0) return value;
if (value && typeof value === "object") {
const maybeMessage =
("message" in value && typeof value.message === "string" && value.message) ||
("error" in value && typeof value.error === "string" && value.error) ||
("text" in value && typeof value.text === "string" && value.text) ||
null;
if (maybeMessage && maybeMessage.trim().length > 0) return maybeMessage;
try {
return JSON.stringify(value);
} catch {
return fallback;
}
}
return fallback;
}
export default function MachineDetailClient() {
const { t, locale } = useI18n();
const { screenlessMode } = useScreenlessMode();
@@ -319,9 +338,14 @@ export default function MachineDetailClient() {
if (!machineId) return;
let alive = true;
let timer: ReturnType<typeof setTimeout> | null = null;
async function load() {
async function load(initial: boolean) {
try {
if (!initial && typeof document !== "undefined" && document.hidden) {
return;
}
const res = await fetch(`/api/machines/${machineId}?windowSec=3600&events=critical`, {
cache: "no-cache",
credentials: "include",
@@ -329,7 +353,7 @@ export default function MachineDetailClient() {
if (res.status === 304) {
if (!alive) return;
setLoading(false);
if (initial) setLoading(false);
return;
}
@@ -338,8 +362,8 @@ export default function MachineDetailClient() {
if (!alive) return;
if (!res.ok || json?.ok === false) {
setError(json?.error ?? t("machine.detail.error.failed"));
setLoading(false);
setError(toErrorMessage(json?.error, t("machine.detail.error.failed")));
if (initial) setLoading(false);
return;
}
@@ -350,19 +374,25 @@ export default function MachineDetailClient() {
setThresholds(json.thresholds ?? null);
setActiveStoppage(json.activeStoppage ?? null);
setError(null);
setLoading(false);
if (initial) setLoading(false);
} catch {
if (!alive) return;
if (initial) {
setError(t("machine.detail.error.network"));
setLoading(false);
}
} finally {
if (!alive) return;
timer = setTimeout(() => {
void load(false);
}, LIVE_REFRESH_MS);
}
}
load();
const timer = setInterval(load, 15000);
void load(true);
return () => {
alive = false;
clearInterval(timer);
if (timer) clearTimeout(timer);
};
}, [machineId, t]);
@@ -479,7 +509,7 @@ export default function MachineDetailClient() {
} else {
setUploadState({
status: "error",
message: json?.error ?? t("machine.detail.workOrders.uploadError"),
message: toErrorMessage(json?.error, t("machine.detail.workOrders.uploadError")),
});
}
event.target.value = "";
@@ -508,7 +538,7 @@ export default function MachineDetailClient() {
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"));
throw new Error(toErrorMessage(data?.error, t("machines.delete.error.failed")));
}
router.push("/machines");
} catch (err: unknown) {
@@ -886,9 +916,10 @@ export default function MachineDetailClient() {
const cycleDerived = useMemo(() => {
const rows = cycles ?? [];
const fallbackIdeal = cycleTarget && cycleTarget > 0 ? cycleTarget : null;
const mapped: CycleDerivedRow[] = rows.map((cycle) => {
const ideal = cycle.ideal ?? null;
const ideal = cycle.ideal ?? fallbackIdeal;
const actual = cycle.actual ?? null;
const extra = ideal != null && actual != null ? actual - ideal : null;
@@ -914,7 +945,7 @@ export default function MachineDetailClient() {
const avgDeltaPct = deltas.length ? deltas.reduce((a, b) => a + b, 0) / deltas.length : null;
return { mapped, counts, avgDeltaPct };
}, [cycles, thresholds]);
}, [cycles, cycleTarget, thresholds]);
const deviationSeries = useMemo(() => {
const last = cycleDerived.mapped.slice(-100);
@@ -1313,7 +1344,7 @@ export default function MachineDetailClient() {
className="h-[380px] rounded-3xl border border-white/10 bg-black/30 p-4 backdrop-blur"
style={{ boxShadow: "var(--app-chart-shadow)" }}
>
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer width="100%" height="100%" minHeight={200}>
<ComposedChart data={deviationSeries}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
<XAxis
@@ -1407,7 +1438,7 @@ export default function MachineDetailClient() {
className="h-[380px] rounded-3xl border border-white/10 bg-black/30 p-4 backdrop-blur"
style={{ boxShadow: "var(--app-chart-shadow)" }}
>
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer width="100%" height="100%" minHeight={200}>
<BarChart data={impactAgg.rows}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
<XAxis dataKey="label" tick={{ fill: "var(--app-chart-tick)" }} />

View File

@@ -0,0 +1,22 @@
export default function MachinesLoading() {
return (
<div className="p-4 sm:p-6 space-y-6 animate-pulse">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-2">
<div className="h-6 w-36 rounded-lg bg-white/10" />
<div className="h-4 w-60 rounded-lg bg-white/5" />
</div>
<div className="flex w-full gap-2 sm:w-auto">
<div className="h-9 w-full rounded-xl border border-emerald-400/40 bg-emerald-500/10 sm:w-36" />
<div className="h-9 w-full rounded-xl border border-white/10 bg-white/5 sm:w-32" />
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, idx) => (
<div key={idx} className="h-40 rounded-2xl border border-white/10 bg-white/5" />
))}
</div>
</div>
);
}

View File

@@ -1,6 +1,10 @@
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import {
fetchLatestHeartbeats,
fetchMachineBase,
mergeMachineOverviewRows,
} from "@/lib/machines/withLatest";
import MachinesClient from "./MachinesClient";
function toIso(value?: Date | null) {
@@ -11,34 +15,32 @@ export default async function MachinesPage() {
const session = await requireSession();
if (!session) redirect("/login?next=/machines");
const machines = await prisma.machine.findMany({
where: { orgId: session.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 },
},
},
const machines = await fetchMachineBase(session.orgId);
const heartbeats = await fetchLatestHeartbeats(
session.orgId,
machines.map((machine) => machine.id)
);
const rows = mergeMachineOverviewRows({
machines,
heartbeats,
includeKpi: false,
});
const initialMachines = machines.map((machine) => ({
...machine,
latestHeartbeat: machine.heartbeats[0]
const initialMachines = rows.map((machine) => ({
id: machine.id,
name: machine.name,
code: machine.code ?? null,
location: machine.location ?? null,
latestHeartbeat: machine.latestHeartbeat
? {
...machine.heartbeats[0],
ts: toIso(machine.heartbeats[0].ts) ?? "",
tsServer: toIso(machine.heartbeats[0].tsServer),
ts: toIso(machine.latestHeartbeat.ts) ?? "",
tsServer: toIso(machine.latestHeartbeat.tsServer),
status: machine.latestHeartbeat.status,
message: machine.latestHeartbeat.message ?? null,
ip: machine.latestHeartbeat.ip ?? null,
fwVersion: machine.latestHeartbeat.fwVersion ?? null,
}
: null,
heartbeats: undefined,
}));
return <MachinesClient initialMachines={initialMachines} />;

View File

@@ -1,57 +1,13 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n";
type Heartbeat = {
ts: string;
tsServer?: string | null;
status: string;
message?: string | null;
ip?: string | null;
fwVersion?: string | null;
};
type Kpi = {
ts: string;
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;
};
type MachineRow = {
id: string;
name: string;
code?: string | null;
location?: string | null;
latestHeartbeat: Heartbeat | null;
latestKpi?: Kpi | null;
};
type EventRow = {
id: string;
ts: string;
topic?: string;
eventType: string;
severity: string;
title: string;
description?: string | null;
requiresAck: boolean;
machineId?: string;
machineName?: string;
source: "ingested";
};
import type { EventRow, Heartbeat, MachineRow } from "./types";
const OFFLINE_MS = 30000;
const MAX_EVENT_MACHINES = 6;
const OverviewTimeline = lazy(() => import("./OverviewTimeline"));
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
if (!ts) return fallback;
@@ -87,17 +43,20 @@ function fmtNum(v?: number | null) {
return `${Math.round(v)}`;
}
function severityClass(sev?: string) {
const s = (sev ?? "").toLowerCase();
if (s === "critical") return "bg-red-500/15 text-red-300";
if (s === "warning") return "bg-yellow-500/15 text-yellow-300";
if (s === "info") return "bg-blue-500/15 text-blue-300";
return "bg-white/10 text-zinc-200";
}
function sourceClass(src: EventRow["source"]) {
if (src === "ingested") return "bg-white/10 text-zinc-200";
return "bg-white/10 text-zinc-200";
function OverviewTimelineSkeleton() {
return (
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-2">
<div className="mb-3 flex items-center justify-between">
<div className="h-4 w-32 rounded bg-white/10" />
<div className="h-3 w-20 rounded bg-white/5" />
</div>
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, idx) => (
<div key={idx} className="h-20 rounded-xl border border-white/10 bg-black/20" />
))}
</div>
</div>
);
}
export default function OverviewClient({
@@ -111,7 +70,7 @@ export default function OverviewClient({
const [machines, setMachines] = useState<MachineRow[]>(() => initialMachines);
const [events, setEvents] = useState<EventRow[]>(() => initialEvents);
const [loading, setLoading] = useState(false);
const [eventsLoading, setEventsLoading] = useState(false);
const [eventsLoading, setEventsLoading] = useState(() => initialEvents.length === 0);
useEffect(() => {
let alive = true;
@@ -119,9 +78,12 @@ export default function OverviewClient({
async function load() {
try {
setEventsLoading(true);
const res = await fetch(`/api/overview?events=critical&eventMachines=${MAX_EVENT_MACHINES}`, {
const res = await fetch(
`/api/overview?detail=1&events=critical&eventMachines=${MAX_EVENT_MACHINES}`,
{
cache: "no-cache",
});
}
);
if (res.status === 304) {
if (alive) setLoading(false);
return;
@@ -166,6 +128,7 @@ export default function OverviewClient({
let goodSum = 0;
let scrapSum = 0;
let targetSum = 0;
let hasKpi = false;
for (const m of machines) {
const hb = m.latestHeartbeat;
@@ -183,22 +146,35 @@ export default function OverviewClient({
if (k?.oee != null) {
oeeSum += Number(k.oee);
oeeCount += 1;
hasKpi = true;
}
if (k?.availability != null) {
availSum += Number(k.availability);
availCount += 1;
hasKpi = true;
}
if (k?.performance != null) {
perfSum += Number(k.performance);
perfCount += 1;
hasKpi = true;
}
if (k?.quality != null) {
qualSum += Number(k.quality);
qualCount += 1;
hasKpi = true;
}
if (k?.good != null) {
goodSum += Number(k.good);
hasKpi = true;
}
if (k?.scrap != null) {
scrapSum += Number(k.scrap);
hasKpi = true;
}
if (k?.target != null) {
targetSum += Number(k.target);
hasKpi = true;
}
if (k?.good != null) goodSum += Number(k.good);
if (k?.scrap != null) scrapSum += Number(k.scrap);
if (k?.target != null) targetSum += Number(k.target);
}
return {
@@ -212,9 +188,9 @@ export default function OverviewClient({
availability: availCount ? availSum / availCount : null,
performance: perfCount ? perfSum / perfCount : null,
quality: qualCount ? qualSum / qualCount : null,
goodSum,
scrapSum,
targetSum,
goodSum: hasKpi ? goodSum : null,
scrapSum: hasKpi ? scrapSum : null,
targetSum: hasKpi ? targetSum : null,
};
}, [machines]);
@@ -238,27 +214,6 @@ export default function OverviewClient({
return list;
}, [machines]);
const formatEventType = (eventType?: string) => {
if (!eventType) return "";
const key = `overview.event.${eventType}`;
const label = t(key);
return label === key ? eventType : label;
};
const formatSource = (source?: string) => {
if (!source) return "";
const key = `overview.source.${source}`;
const label = t(key);
return label === key ? source : label;
};
const formatSeverity = (severity?: string) => {
if (!severity) return "";
const key = `overview.severity.${severity}`;
const label = t(key);
return label === key ? severity.toUpperCase() : label;
};
return (
<div className="p-4 sm:p-6">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
@@ -409,56 +364,9 @@ export default function OverviewClient({
)}
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-2">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-semibold text-white">{t("overview.timeline")}</div>
<div className="text-xs text-zinc-400">
{events.length} {t("overview.items")}
</div>
</div>
{events.length === 0 && !eventsLoading ? (
<div className="text-sm text-zinc-400">{t("overview.noEvents")}</div>
) : (
<div className="h-[360px] space-y-3 overflow-y-auto no-scrollbar">
{events.map((e) => (
<div key={`${e.id}-${e.source}`} className="rounded-xl border border-white/10 bg-black/20 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs ${severityClass(e.severity)}`}>
{formatSeverity(e.severity)}
</span>
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-zinc-200">
{formatEventType(e.eventType)}
</span>
<span className={`rounded-full px-2 py-0.5 text-xs ${sourceClass(e.source)}`}>
{formatSource(e.source)}
</span>
{e.requiresAck ? (
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white">
{t("overview.ack")}
</span>
) : null}
</div>
<div className="mt-2 truncate text-sm font-semibold text-white">
{e.machineName ? `${e.machineName}: ` : ""}
{e.title}
</div>
{e.description ? (
<div className="mt-1 text-sm text-zinc-300">{e.description}</div>
) : null}
</div>
<div className="shrink-0 text-xs text-zinc-400">
{secondsAgo(e.ts, locale, t("common.never"))}
</div>
</div>
</div>
))}
</div>
)}
</div>
<Suspense fallback={<OverviewTimelineSkeleton />}>
<OverviewTimeline events={events} eventsLoading={eventsLoading} locale={locale} t={t} />
</Suspense>
</div>
</div>
);

View File

@@ -0,0 +1,129 @@
"use client";
import type { EventRow } from "./types";
type Translator = (key: string, vars?: Record<string, string | number>) => string;
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
if (!ts) return fallback;
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
if (diff < 60) return rtf.format(-diff, "second");
if (diff < 3600) return rtf.format(-Math.floor(diff / 60), "minute");
return rtf.format(-Math.floor(diff / 3600), "hour");
}
function severityClass(sev?: string) {
const s = (sev ?? "").toLowerCase();
if (s === "critical") return "bg-red-500/15 text-red-300";
if (s === "warning") return "bg-yellow-500/15 text-yellow-300";
if (s === "info") return "bg-blue-500/15 text-blue-300";
return "bg-white/10 text-zinc-200";
}
function sourceClass(src: EventRow["source"]) {
if (src === "ingested") return "bg-white/10 text-zinc-200";
return "bg-white/10 text-zinc-200";
}
function formatEventType(eventType: string | undefined, t: Translator) {
if (!eventType) return "";
const key = `overview.event.${eventType}`;
const label = t(key);
return label === key ? eventType : label;
}
function formatSource(source: string | undefined, t: Translator) {
if (!source) return "";
const key = `overview.source.${source}`;
const label = t(key);
return label === key ? source : label;
}
function formatSeverity(severity: string | undefined, t: Translator) {
if (!severity) return "";
const key = `overview.severity.${severity}`;
const label = t(key);
return label === key ? severity.toUpperCase() : label;
}
export default function OverviewTimeline({
events,
eventsLoading,
locale,
t,
}: {
events: EventRow[];
eventsLoading: boolean;
locale: string;
t: Translator;
}) {
if (eventsLoading && events.length === 0) {
return (
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-2 animate-pulse">
<div className="mb-3 flex items-center justify-between">
<div className="h-4 w-32 rounded bg-white/10" />
<div className="h-3 w-20 rounded bg-white/5" />
</div>
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, idx) => (
<div key={idx} className="h-20 rounded-xl border border-white/10 bg-black/20" />
))}
</div>
</div>
);
}
return (
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-2">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-semibold text-white">{t("overview.timeline")}</div>
<div className="text-xs text-zinc-400">
{events.length} {t("overview.items")}
</div>
</div>
{events.length === 0 && !eventsLoading ? (
<div className="text-sm text-zinc-400">{t("overview.noEvents")}</div>
) : (
<div className="h-[360px] space-y-3 overflow-y-auto no-scrollbar">
{events.map((e) => (
<div key={`${e.id}-${e.source}`} className="rounded-xl border border-white/10 bg-black/20 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs ${severityClass(e.severity)}`}>
{formatSeverity(e.severity, t)}
</span>
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-zinc-200">
{formatEventType(e.eventType, t)}
</span>
<span className={`rounded-full px-2 py-0.5 text-xs ${sourceClass(e.source)}`}>
{formatSource(e.source, t)}
</span>
{e.requiresAck ? (
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white">
{t("overview.ack")}
</span>
) : null}
</div>
<div className="mt-2 truncate text-sm font-semibold text-white">
{e.machineName ? `${e.machineName}: ` : ""}
{e.title}
</div>
{e.description ? (
<div className="mt-1 text-sm text-zinc-300">{e.description}</div>
) : null}
</div>
<div className="shrink-0 text-xs text-zinc-400">
{secondsAgo(e.ts, locale, t("common.never"))}
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,30 @@
export default function OverviewLoading() {
return (
<div className="p-4 sm:p-6 space-y-6 animate-pulse">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-2">
<div className="h-6 w-40 rounded-lg bg-white/10" />
<div className="h-4 w-64 rounded-lg bg-white/5" />
</div>
<div className="h-9 w-40 rounded-xl border border-white/10 bg-white/5" />
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, idx) => (
<div key={idx} className="h-36 rounded-2xl border border-white/10 bg-white/5" />
))}
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, idx) => (
<div key={idx} className="h-24 rounded-2xl border border-white/10 bg-white/5" />
))}
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<div className="h-64 rounded-2xl border border-white/10 bg-white/5 xl:col-span-1" />
<div className="h-64 rounded-2xl border border-white/10 bg-white/5 xl:col-span-2" />
</div>
</div>
);
}

View File

@@ -1,6 +1,8 @@
import { redirect } from "next/navigation";
import { requireSession } from "@/lib/auth/requireSession";
import { getOverviewData } from "@/lib/overview/getOverviewData";
import { getOverviewSummary } from "@/lib/overview/getOverviewSummary";
import type { getOverviewData } from "@/lib/overview/getOverviewData";
import { logLine } from "@/lib/logger";
import OverviewClient from "./OverviewClient";
function toIso(value?: Date | null) {
@@ -11,12 +13,18 @@ export default async function OverviewPage() {
const session = await requireSession();
if (!session) redirect("/login?next=/overview");
const { machines, events } = await getOverviewData({
orgId: session.orgId,
eventsMode: "critical",
eventsWindowSec: 21600,
eventMachines: 6,
});
let machines: Awaited<ReturnType<typeof getOverviewData>>["machines"];
let events: Awaited<ReturnType<typeof getOverviewData>>["events"] = [];
try {
const data = await getOverviewSummary({ orgId: session.orgId });
machines = data.machines;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
logLine("OverviewPage.getOverviewSummary.error", { message, stack });
console.error("[OverviewPage] getOverviewSummary:", err);
machines = [];
}
const initialMachines = machines.map((machine) => ({
...machine,

View File

@@ -0,0 +1,45 @@
export type Heartbeat = {
ts: string;
tsServer?: string | null;
status: string;
message?: string | null;
ip?: string | null;
fwVersion?: string | null;
};
export type Kpi = {
ts: string;
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 MachineRow = {
id: string;
name: string;
code?: string | null;
location?: string | null;
latestHeartbeat: Heartbeat | null;
latestKpi?: Kpi | null;
};
export type EventRow = {
id: string;
ts: string;
topic?: string;
eventType: string;
severity: string;
title: string;
description?: string | null;
requiresAck: boolean;
machineId?: string;
machineName?: string;
source: "ingested";
};

View File

@@ -0,0 +1,249 @@
"use client";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
type Translator = (key: string, vars?: Record<string, string | number>) => string;
type TooltipPayload<T> = { payload?: T; name?: string; value?: number | string };
type SimpleTooltipProps<T> = {
active?: boolean;
payload?: Array<TooltipPayload<T>>;
label?: string | number;
};
type ChartPoint = { ts: string; label: string; value: number };
type CycleHistogramRow = {
label: string;
count: number;
rangeStart?: number;
rangeEnd?: number;
overflow?: "low" | "high";
minValue?: number;
maxValue?: number;
};
function CycleTooltip({ active, payload, t }: SimpleTooltipProps<CycleHistogramRow> & { t: Translator }) {
if (!active || !payload?.length) return null;
const p = payload[0]?.payload;
if (!p) return null;
let detail = "";
if (p.overflow === "low") {
detail = `${t("reports.tooltip.below")} ${p.rangeEnd?.toFixed(1)}s`;
} else if (p.overflow === "high") {
detail = `${t("reports.tooltip.above")} ${p.rangeStart?.toFixed(1)}s`;
} else if (p.rangeStart != null && p.rangeEnd != null) {
detail = `${p.rangeStart.toFixed(1)}s - ${p.rangeEnd.toFixed(1)}s`;
}
const extreme =
p.overflow && (p.minValue != null || p.maxValue != null)
? `${t("reports.tooltip.extremes")}: ${p.minValue?.toFixed(1) ?? "--"}s - ${p.maxValue?.toFixed(1) ?? "--"}s`
: "";
return (
<div className="rounded-xl border border-white/10 bg-zinc-950/95 px-4 py-3 shadow-lg">
<div className="text-sm font-semibold text-white">{p.label}</div>
<div className="mt-2 space-y-1 text-xs text-zinc-300">
<div>
{t("reports.tooltip.cycles")}: <span className="text-white">{p.count}</span>
</div>
{detail ? (
<div>
{t("reports.tooltip.range")}: <span className="text-white">{detail}</span>
</div>
) : null}
{extreme ? <div className="text-zinc-400">{extreme}</div> : null}
</div>
</div>
);
}
function DowntimeTooltip({
active,
payload,
t,
}: SimpleTooltipProps<{ name?: string; value?: number }> & { t: Translator }) {
if (!active || !payload?.length) return null;
const row = payload[0]?.payload ?? {};
const label = row.name ?? payload[0]?.name ?? "";
const value = row.value ?? payload[0]?.value ?? 0;
return (
<div className="rounded-xl border border-white/10 bg-zinc-950/95 px-4 py-3 shadow-lg">
<div className="text-sm font-semibold text-white">{label}</div>
<div className="mt-2 text-xs text-zinc-300">
{t("reports.tooltip.downtime")}: <span className="text-white">{Number(value)} min</span>
</div>
</div>
);
}
export default function ReportsCharts({
oeeSeries,
downtimeSeries,
downtimeColors,
cycleHistogram,
scrapSeries,
lossRows,
locale,
t,
}: {
oeeSeries: ChartPoint[];
downtimeSeries: { name: string; value: number }[];
downtimeColors: Record<string, string>;
cycleHistogram: CycleHistogramRow[];
scrapSeries: ChartPoint[];
lossRows: Array<{ label: string; value: string }>;
locale: string;
t: Translator;
}) {
return (
<>
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-2 text-sm font-semibold text-white">{t("reports.oeeTrend")}</div>
<div className="h-[260px] rounded-2xl border border-white/10 bg-black/25 p-4">
{oeeSeries.length ? (
<ResponsiveContainer width="100%" height="100%" minHeight={200}>
<LineChart data={oeeSeries}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
<XAxis dataKey="label" tick={{ fill: "var(--app-chart-tick)" }} />
<YAxis domain={[0, 100]} tick={{ fill: "var(--app-chart-tick)" }} />
<Tooltip
contentStyle={{
background: "var(--app-chart-tooltip-bg)",
border: "1px solid var(--app-chart-tooltip-border)",
}}
labelStyle={{ color: "var(--app-chart-label)" }}
labelFormatter={(_, payload) => {
const row = payload?.[0]?.payload;
return row?.ts ? new Date(row.ts).toLocaleString(locale) : "";
}}
formatter={(val: number | string | undefined) => [
val == null ? "--" : `${Number(val).toFixed(1)}%`,
"OEE",
]}
/>
<Line type="monotone" dataKey="value" stroke="#34d399" dot={false} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
{t("reports.noTrend")}
</div>
)}
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-2 text-sm font-semibold text-white">{t("reports.downtimePareto")}</div>
<div className="h-[260px] rounded-2xl border border-white/10 bg-black/25 p-4">
{downtimeSeries.length ? (
<ResponsiveContainer width="100%" height="100%" minHeight={200}>
<BarChart data={downtimeSeries}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
<XAxis dataKey="name" tick={{ fill: "var(--app-chart-tick)" }} />
<YAxis tick={{ fill: "var(--app-chart-tick)" }} />
<Tooltip content={<DowntimeTooltip t={t} />} />
<Bar dataKey="value" radius={[10, 10, 0, 0]} isAnimationActive={false}>
{downtimeSeries.map((row, idx) => (
<Cell key={`${row.name}-${idx}`} fill={downtimeColors[row.name] ?? "#94a3b8"} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
{t("reports.noTrend")}
</div>
)}
</div>
</div>
</div>
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-2 text-sm font-semibold text-white">{t("reports.cycleDistribution")}</div>
<div className="h-[220px] rounded-2xl border border-white/10 bg-black/25 p-4">
{cycleHistogram.length ? (
<ResponsiveContainer width="100%" height="100%" minHeight={200}>
<BarChart data={cycleHistogram}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
<XAxis dataKey="label" tick={{ fill: "var(--app-chart-tick)", fontSize: 10 }} />
<YAxis tick={{ fill: "var(--app-chart-tick)" }} />
<Tooltip content={<CycleTooltip t={t} />} />
<Bar dataKey="count" radius={[8, 8, 0, 0]} fill="#60a5fa" isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
{t("reports.noCycle")}
</div>
)}
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-2 text-sm font-semibold text-white">{t("reports.scrapTrend")}</div>
<div className="h-[220px] rounded-2xl border border-white/10 bg-black/25 p-4">
{scrapSeries.length ? (
<ResponsiveContainer width="100%" height="100%" minHeight={200}>
<LineChart data={scrapSeries}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
<XAxis dataKey="label" tick={{ fill: "var(--app-chart-tick)" }} />
<YAxis domain={[0, 100]} tick={{ fill: "var(--app-chart-tick)" }} />
<Tooltip
contentStyle={{
background: "var(--app-chart-tooltip-bg)",
border: "1px solid var(--app-chart-tooltip-border)",
}}
labelStyle={{ color: "var(--app-chart-label)" }}
labelFormatter={(_, payload) => {
const row = payload?.[0]?.payload;
return row?.ts ? new Date(row.ts).toLocaleString(locale) : "";
}}
formatter={(val: number | string | undefined) => [
val == null ? "--" : `${Number(val).toFixed(1)}%`,
t("reports.scrapRate"),
]}
/>
<Line type="monotone" dataKey="value" stroke="#f97316" dot={false} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
{t("reports.noDowntime")}
</div>
)}
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-2 text-sm font-semibold text-white">{t("reports.topLossDrivers")}</div>
<div className="space-y-3 text-sm text-zinc-300">
{lossRows.map((row) => (
<div
key={row.label}
className="flex items-center justify-between rounded-xl border border-white/10 bg-black/20 p-3"
>
<span>{row.label}</span>
<span className="text-xs text-zinc-400">{row.value}</span>
</div>
))}
</div>
</div>
</div>
</>
);
}

View File

@@ -1,12 +1,13 @@
import { redirect } from "next/navigation";
export default function LegacyDowntimeParetoPage({
export default async function LegacyDowntimeParetoPage({
searchParams,
}: {
searchParams: Record<string, string | string[] | undefined>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(searchParams)) {
for (const [k, v] of Object.entries(params)) {
if (typeof v === "string") qs.set(k, v);
else if (Array.isArray(v)) v.forEach((vv) => qs.append(k, vv));
}

View File

@@ -1,19 +1,9 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
const ReportsCharts = lazy(() => import("./ReportsCharts"));
type RangeKey = "24h" | "7d" | "30d" | "custom";
@@ -68,13 +58,6 @@ type ReportPayload = {
type MachineOption = { id: string; name: string };
type FilterOptions = { workOrders: string[]; skus: string[] };
type Translator = (key: string, vars?: Record<string, string | number>) => string;
type TooltipPayload<T> = { payload?: T; name?: string; value?: number | string };
type SimpleTooltipProps<T> = {
active?: boolean;
payload?: Array<TooltipPayload<T>>;
label?: string | number;
};
type CycleHistogramRow = ReportPayload["distribution"]["cycleTime"][number];
function fmtPct(v?: number | null) {
if (v === null || v === undefined || Number.isNaN(v)) return "--";
@@ -106,56 +89,20 @@ function formatTickLabel(ts: string, range: RangeKey) {
return `${month}-${day}`;
}
function CycleTooltip({ active, payload, t }: SimpleTooltipProps<CycleHistogramRow> & { t: Translator }) {
if (!active || !payload?.length) return null;
const p = payload[0]?.payload;
if (!p) return null;
let detail = "";
if (p.overflow === "low") {
detail = `${t("reports.tooltip.below")} ${p.rangeEnd?.toFixed(1)}s`;
} else if (p.overflow === "high") {
detail = `${t("reports.tooltip.above")} ${p.rangeStart?.toFixed(1)}s`;
} else if (p.rangeStart != null && p.rangeEnd != null) {
detail = `${p.rangeStart.toFixed(1)}s - ${p.rangeEnd.toFixed(1)}s`;
}
const extreme =
p.overflow && (p.minValue != null || p.maxValue != null)
? `${t("reports.tooltip.extremes")}: ${p.minValue?.toFixed(1) ?? "--"}s - ${p.maxValue?.toFixed(1) ?? "--"}s`
: "";
function ReportsChartsSkeleton() {
return (
<div className="rounded-xl border border-white/10 bg-zinc-950/95 px-4 py-3 shadow-lg">
<div className="text-sm font-semibold text-white">{p.label}</div>
<div className="mt-2 space-y-1 text-xs text-zinc-300">
<div>
{t("reports.tooltip.cycles")}: <span className="text-white">{p.count}</span>
</div>
{detail ? (
<div>
{t("reports.tooltip.range")}: <span className="text-white">{detail}</span>
</div>
) : null}
{extreme ? <div className="text-zinc-400">{extreme}</div> : null}
</div>
</div>
);
}
function DowntimeTooltip({ active, payload, t }: SimpleTooltipProps<{ name?: string; value?: number }> & { t: Translator }) {
if (!active || !payload?.length) return null;
const row = payload[0]?.payload ?? {};
const label = row.name ?? payload[0]?.name ?? "";
const value = row.value ?? payload[0]?.value ?? 0;
return (
<div className="rounded-xl border border-white/10 bg-zinc-950/95 px-4 py-3 shadow-lg">
<div className="text-sm font-semibold text-white">{label}</div>
<div className="mt-2 text-xs text-zinc-300">
{t("reports.tooltip.downtime")}: <span className="text-white">{Number(value)} min</span>
<>
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
{Array.from({ length: 2 }).map((_, idx) => (
<div key={idx} className="h-[320px] rounded-2xl border border-white/10 bg-white/5" />
))}
</div>
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, idx) => (
<div key={idx} className="h-[280px] rounded-2xl border border-white/10 bg-white/5" />
))}
</div>
</>
);
}
@@ -534,6 +481,21 @@ export default function ReportsPage() {
Microstop: "#FF7A00",
};
const lossRows = useMemo(
() => [
{ label: t("reports.loss.macrostop"), value: fmtDuration(downtime?.macrostopSec) },
{ label: t("reports.loss.microstop"), value: fmtDuration(downtime?.microstopSec) },
{ label: t("reports.loss.slowCycle"), value: downtime ? `${downtime.slowCycleCount}` : "--" },
{ label: t("reports.loss.qualitySpike"), value: downtime ? `${downtime.qualitySpikeCount}` : "--" },
{ label: t("reports.loss.oeeDrop"), value: downtime ? `${downtime.oeeDropCount}` : "--" },
{
label: t("reports.loss.perfDegradation"),
value: downtime ? `${downtime.performanceDegradationCount}` : "--",
},
],
[downtime, t]
);
const machineLabel = useMemo(() => {
if (!machineId) return t("reports.filter.allMachines");
return machines.find((m) => m.id === machineId)?.name ?? machineId;
@@ -696,147 +658,18 @@ export default function ReportsPage() {
))}
</div>
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-2 text-sm font-semibold text-white">{t("reports.oeeTrend")}</div>
<div className="h-[260px] rounded-2xl border border-white/10 bg-black/25 p-4">
{oeeSeries.length ? (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={oeeSeries}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
<XAxis dataKey="label" tick={{ fill: "var(--app-chart-tick)" }} />
<YAxis domain={[0, 100]} tick={{ fill: "var(--app-chart-tick)" }} />
<Tooltip
contentStyle={{
background: "var(--app-chart-tooltip-bg)",
border: "1px solid var(--app-chart-tooltip-border)",
}}
labelStyle={{ color: "var(--app-chart-label)" }}
labelFormatter={(_, payload) => {
const row = payload?.[0]?.payload;
return row?.ts ? new Date(row.ts).toLocaleString(locale) : "";
}}
formatter={(val: number | string | undefined) => [
val == null ? "--" : `${Number(val).toFixed(1)}%`,
"OEE",
]}
<Suspense fallback={<ReportsChartsSkeleton />}>
<ReportsCharts
oeeSeries={oeeSeries}
downtimeSeries={downtimeSeries}
downtimeColors={downtimeColors}
cycleHistogram={cycleHistogram}
scrapSeries={scrapSeries}
lossRows={lossRows}
locale={locale}
t={t}
/>
<Line type="monotone" dataKey="value" stroke="#34d399" dot={false} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
{t("reports.noTrend")}
</div>
)}
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-2 text-sm font-semibold text-white">{t("reports.downtimePareto")}</div>
<div className="h-[260px] rounded-2xl border border-white/10 bg-black/25 p-4">
{downtimeSeries.length ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={downtimeSeries}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
<XAxis dataKey="name" tick={{ fill: "var(--app-chart-tick)" }} />
<YAxis tick={{ fill: "var(--app-chart-tick)" }} />
<Tooltip content={<DowntimeTooltip t={t} />} />
<Bar dataKey="value" radius={[10, 10, 0, 0]} isAnimationActive={false}>
{downtimeSeries.map((row, idx) => (
<Cell key={`${row.name}-${idx}`} fill={downtimeColors[row.name] ?? "#94a3b8"} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
{t("reports.noTrend")}
</div>
)}
</div>
</div>
</div>
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-2 text-sm font-semibold text-white">{t("reports.cycleDistribution")}</div>
<div className="h-[220px] rounded-2xl border border-white/10 bg-black/25 p-4">
{cycleHistogram.length ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={cycleHistogram}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
<XAxis dataKey="label" tick={{ fill: "var(--app-chart-tick)", fontSize: 10 }} />
<YAxis tick={{ fill: "var(--app-chart-tick)" }} />
<Tooltip content={<CycleTooltip t={t} />} />
<Bar dataKey="count" radius={[8, 8, 0, 0]} fill="#60a5fa" isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
{t("reports.noCycle")}
</div>
)}
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-2 text-sm font-semibold text-white">{t("reports.scrapTrend")}</div>
<div className="h-[220px] rounded-2xl border border-white/10 bg-black/25 p-4">
{scrapSeries.length ? (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={scrapSeries}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
<XAxis dataKey="label" tick={{ fill: "var(--app-chart-tick)" }} />
<YAxis domain={[0, 100]} tick={{ fill: "var(--app-chart-tick)" }} />
<Tooltip
contentStyle={{
background: "var(--app-chart-tooltip-bg)",
border: "1px solid var(--app-chart-tooltip-border)",
}}
labelStyle={{ color: "var(--app-chart-label)" }}
labelFormatter={(_, payload) => {
const row = payload?.[0]?.payload;
return row?.ts ? new Date(row.ts).toLocaleString(locale) : "";
}}
formatter={(val: number | string | undefined) => [
val == null ? "--" : `${Number(val).toFixed(1)}%`,
t("reports.scrapRate"),
]}
/>
<Line type="monotone" dataKey="value" stroke="#f97316" dot={false} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
{t("reports.noDowntime")}
</div>
)}
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-2 text-sm font-semibold text-white">{t("reports.topLossDrivers")}</div>
<div className="space-y-3 text-sm text-zinc-300">
{[
{ label: t("reports.loss.macrostop"), value: fmtDuration(downtime?.macrostopSec) },
{ label: t("reports.loss.microstop"), value: fmtDuration(downtime?.microstopSec) },
{ label: t("reports.loss.slowCycle"), value: downtime ? `${downtime.slowCycleCount}` : "--" },
{ label: t("reports.loss.qualitySpike"), value: downtime ? `${downtime.qualitySpikeCount}` : "--" },
{ label: t("reports.loss.oeeDrop"), value: downtime ? `${downtime.oeeDropCount}` : "--" },
{
label: t("reports.loss.perfDegradation"),
value: downtime ? `${downtime.performanceDegradationCount}` : "--",
},
].map((row) => (
<div key={row.label} className="flex items-center justify-between rounded-xl border border-white/10 bg-black/20 p-3">
<span>{row.label}</span>
<span className="text-xs text-zinc-400">{row.value}</span>
</div>
))}
</div>
</div>
</div>
</Suspense>
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">

View File

@@ -1,9 +1,10 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AlertsConfig } from "@/components/settings/AlertsConfig";
import { FinancialCostConfig } from "@/components/settings/FinancialCostConfig";
import { useI18n } from "@/lib/i18n/useI18n";
import { SHIFT_OVERRIDE_DAYS, type ShiftOverrideDay } from "@/lib/settings";
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
@@ -25,6 +26,7 @@ type SettingsPayload = {
shiftSchedule: {
shifts: Shift[];
overrides?: Partial<Record<ShiftOverrideDay, Shift[]>>;
shiftChangeCompensationMin: number;
lunchBreakMin: number;
};
@@ -88,6 +90,7 @@ const DEFAULT_SETTINGS: SettingsPayload = {
modules: { screenlessMode: false },
shiftSchedule: {
shifts: [],
overrides: {},
shiftChangeCompensationMin: 10,
lunchBreakMin: 30,
},
@@ -199,6 +202,21 @@ function normalizeShift(raw: unknown, fallbackName: string): Shift {
return { name, start, end, enabled };
}
function normalizeShiftOverrides(
raw: unknown,
fallbackName: (index: number) => string
): Partial<Record<ShiftOverrideDay, Shift[]>> {
const record = asRecord(raw);
if (!record) return {};
const out: Partial<Record<ShiftOverrideDay, Shift[]>> = {};
for (const day of SHIFT_OVERRIDE_DAYS) {
const shiftsRaw = Array.isArray(record[day]) ? (record[day] as unknown[]) : null;
if (!shiftsRaw) continue;
out[day] = shiftsRaw.map((shift, idx) => normalizeShift(shift, fallbackName(idx + 1)));
}
return out;
}
function normalizeSettings(raw: unknown, fallbackName: (index: number) => string): SettingsPayload {
const record = asRecord(raw);
const modules = asRecord(record?.modules) ?? {};
@@ -217,6 +235,7 @@ function normalizeSettings(raw: unknown, fallbackName: (index: number) => string
const shifts = shiftsRaw.length
? shiftsRaw.map((s, idx) => normalizeShift(s, fallbackName(idx + 1)))
: [{ name: fallbackName(1), ...DEFAULT_SHIFT }];
const overrides = normalizeShiftOverrides(shiftSchedule.overrides, fallbackName);
const thresholds = asRecord(record.thresholds) ?? {};
const alerts = asRecord(record.alerts) ?? {};
const defaults = asRecord(record.defaults) ?? {};
@@ -227,6 +246,7 @@ function normalizeSettings(raw: unknown, fallbackName: (index: number) => string
timezone: String(record.timezone ?? DEFAULT_SETTINGS.timezone),
shiftSchedule: {
shifts,
overrides,
shiftChangeCompensationMin: Number(
shiftSchedule.shiftChangeCompensationMin ?? DEFAULT_SETTINGS.shiftSchedule.shiftChangeCompensationMin
),
@@ -326,16 +346,26 @@ export default function SettingsPage() {
const [inviteStatus, setInviteStatus] = useState<string | null>(null);
const [inviteSubmitting, setInviteSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState<(typeof SETTINGS_TABS)[number]["id"]>("general");
const hasMountedRef = useRef(false);
const defaultShiftName = useCallback(
(index: number) => t("settings.shift.defaultName", { index }),
[t]
);
const shiftOverrideDays = useMemo(
() =>
SHIFT_OVERRIDE_DAYS.map((day) => ({
key: day,
label: t(`settings.shiftOverrides.${day}`),
})),
[t]
);
const loadSettings = useCallback(async () => {
const loadSettings = useCallback(async (forceRefresh = false) => {
setLoading(true);
setError(null);
try {
const response = await fetch("/api/settings", { cache: "no-store" });
const url = forceRefresh ? "/api/settings?refresh=1" : "/api/settings";
const response = await fetch(url, { cache: forceRefresh ? "no-store" : "default" });
const { data, text } = await readResponse(response);
const api = unwrapApiResponse(data);
if (!response.ok || !api.ok) {
@@ -350,7 +380,7 @@ export default function SettingsPage() {
} finally {
setLoading(false);
}
}, [defaultShiftName, t]);
}, [defaultShiftName, t, setScreenlessMode]);
const buildInviteUrl = useCallback((token: string) => {
if (typeof window === "undefined") return `/invite/${token}`;
@@ -380,10 +410,14 @@ export default function SettingsPage() {
}
}, [t]);
// Only run once on mount to prevent infinite loops from dependency changes
useEffect(() => {
if (hasMountedRef.current) return;
hasMountedRef.current = true;
loadSettings();
loadTeam();
}, [loadSettings, loadTeam]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const updateShift = useCallback((index: number, patch: Partial<Shift>) => {
setDraft((prev) => {
@@ -448,6 +482,96 @@ export default function SettingsPage() {
});
}, []);
const toggleShiftOverride = useCallback((day: ShiftOverrideDay) => {
setDraft((prev) => {
if (!prev) return prev;
const overrides = { ...(prev.shiftSchedule.overrides ?? {}) };
if (overrides[day]) {
delete overrides[day];
} else {
overrides[day] = prev.shiftSchedule.shifts.map((shift) => ({ ...shift }));
}
return {
...prev,
shiftSchedule: {
...prev.shiftSchedule,
overrides,
},
};
});
}, []);
const updateShiftOverride = useCallback((day: ShiftOverrideDay, index: number, patch: Partial<Shift>) => {
setDraft((prev) => {
if (!prev) return prev;
const current = prev.shiftSchedule.overrides?.[day];
if (!current) return prev;
const overrides = { ...(prev.shiftSchedule.overrides ?? {}) };
overrides[day] = current.map((shift, idx) => (idx === index ? { ...shift, ...patch } : shift));
return {
...prev,
shiftSchedule: {
...prev.shiftSchedule,
overrides,
},
};
});
}, []);
const addShiftOverride = useCallback(
(day: ShiftOverrideDay) => {
setDraft((prev) => {
if (!prev) return prev;
const overrides = { ...(prev.shiftSchedule.overrides ?? {}) };
const current = overrides[day] ? [...overrides[day]!] : [];
if (current.length >= 3) return prev;
const nextIndex = current.length + 1;
current.push({ name: defaultShiftName(nextIndex), ...DEFAULT_SHIFT });
overrides[day] = current;
return {
...prev,
shiftSchedule: {
...prev.shiftSchedule,
overrides,
},
};
});
},
[defaultShiftName]
);
const removeShiftOverride = useCallback((day: ShiftOverrideDay, index: number) => {
setDraft((prev) => {
if (!prev) return prev;
const current = prev.shiftSchedule.overrides?.[day];
if (!current) return prev;
const overrides = { ...(prev.shiftSchedule.overrides ?? {}) };
overrides[day] = current.filter((_, idx) => idx !== index);
return {
...prev,
shiftSchedule: {
...prev.shiftSchedule,
overrides,
},
};
});
}, []);
const clearShiftOverride = useCallback((day: ShiftOverrideDay) => {
setDraft((prev) => {
if (!prev) return prev;
const overrides = { ...(prev.shiftSchedule.overrides ?? {}) };
overrides[day] = [];
return {
...prev,
shiftSchedule: {
...prev.shiftSchedule,
overrides,
},
};
});
}, []);
const updateThreshold = useCallback(
(
key:
@@ -665,7 +789,7 @@ export default function SettingsPage() {
</div>
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto">
<button
onClick={loadSettings}
onClick={() => loadSettings(true)}
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-center text-sm text-white hover:bg-white/10 sm:w-auto"
>
{t("settings.refresh")}
@@ -994,6 +1118,119 @@ export default function SettingsPage() {
</div>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-2 text-sm font-semibold text-white">{t("settings.shiftOverrides.title")}</div>
<div className="text-xs text-zinc-400">{t("settings.shiftOverrides.subtitle")}</div>
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
{shiftOverrideDays.map((day) => {
const dayOverrides = draft.shiftSchedule.overrides?.[day.key];
const overrideShifts = dayOverrides ?? [];
const isCustom = dayOverrides !== undefined;
return (
<div key={day.key} className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-semibold text-white">{day.label}</div>
<button
type="button"
onClick={() => toggleShiftOverride(day.key)}
className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs text-white"
>
{isCustom
? t("settings.shiftOverrides.useDefault")
: t("settings.shiftOverrides.customize")}
</button>
</div>
{!isCustom && (
<div className="mt-2 text-xs text-zinc-400">{t("settings.shiftOverrides.inherits")}</div>
)}
{isCustom && (
<>
{overrideShifts.length === 0 ? (
<div className="mt-2 text-xs text-zinc-400">
{t("settings.shiftOverrides.dayOff")}
</div>
) : (
<div className="mt-3 space-y-2">
{overrideShifts.map((shift, index) => (
<div key={`${day.key}-${index}`} className="rounded-lg border border-white/10 bg-black/30 p-2">
<div className="flex items-center justify-between gap-2">
<input
value={shift.name}
onChange={(event) =>
updateShiftOverride(day.key, index, { name: event.target.value })
}
className="w-full rounded-md border border-white/10 bg-black/30 px-2 py-1 text-xs text-white"
/>
<button
type="button"
onClick={() => removeShiftOverride(day.key, index)}
className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs text-white"
>
{t("settings.shiftRemove")}
</button>
</div>
<div className="mt-2 flex items-center gap-2">
<input
type="time"
value={shift.start}
onChange={(event) =>
updateShiftOverride(day.key, index, { start: event.target.value })
}
className="w-full rounded-md border border-white/10 bg-black/30 px-2 py-1 text-xs text-white"
/>
<span className="text-xs text-zinc-400">{t("settings.shiftTo")}</span>
<input
type="time"
value={shift.end}
onChange={(event) =>
updateShiftOverride(day.key, index, { end: event.target.value })
}
className="w-full rounded-md border border-white/10 bg-black/30 px-2 py-1 text-xs text-white"
/>
</div>
<div className="mt-2 flex items-center gap-2 text-xs text-zinc-400">
<input
type="checkbox"
checked={shift.enabled}
onChange={(event) =>
updateShiftOverride(day.key, index, { enabled: event.target.checked })
}
className="h-4 w-4 rounded border border-white/20 bg-black/20"
/>
{t("settings.shiftEnabled")}
</div>
</div>
))}
</div>
)}
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => addShiftOverride(day.key)}
disabled={overrideShifts.length >= 3}
className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs text-white disabled:opacity-40"
>
{t("settings.shiftAdd")}
</button>
<button
type="button"
onClick={() => clearShiftOverride(day.key)}
className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs text-white"
>
{t("settings.shiftOverrides.clear")}
</button>
</div>
</>
)}
</div>
);
})}
</div>
</div>
</div>
)}

View File

@@ -64,7 +64,7 @@ export async function GET(req: Request) {
count: g._count._all,
};
})
.filter((x) => x.value > 0);
.filter((x) => (kind === "downtime" ? x.value > 0 || x.count > 0 : x.value > 0));
itemsRaw.sort((a, b) => b.value - a.value);

View File

@@ -0,0 +1,45 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import fs from "fs";
import { getLogPath } from "@/lib/logger";
const MAX_LINES = 100;
/**
* GET /api/debug/logs?key=YOUR_DEBUG_LOGS_KEY
*
* Returns the last MAX_LINES from the app log file. Set DEBUG_LOGS_KEY in .env
* and call with ?key=... to view. If DEBUG_LOGS_KEY is unset, returns 401.
*/
export async function GET(req: NextRequest) {
const key = req.nextUrl.searchParams.get("key");
const secret = process.env.DEBUG_LOGS_KEY;
if (!secret || key !== secret) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const logPath = getLogPath();
try {
const raw = fs.readFileSync(logPath, "utf8");
const lines = raw.split("\n").filter(Boolean);
const recent = lines.slice(-MAX_LINES);
return NextResponse.json({
logPath,
lines: recent.length,
entries: recent.map((line) => {
try {
return JSON.parse(line) as Record<string, unknown>;
} catch {
return { raw: line };
}
}),
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return NextResponse.json(
{ error: "Failed to read log file", detail: message, logPath },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,31 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { logLine } from "@/lib/logger";
export const dynamic = "force-dynamic";
type PerfPayload = {
event?: string;
data?: Record<string, unknown>;
};
export async function POST(req: NextRequest) {
try {
const body = (await req.json()) as PerfPayload;
const type = typeof body?.event === "string" ? body.event : "nav";
const data = body?.data && typeof body.data === "object" ? body.data : {};
const userAgent = req.headers.get("user-agent") ?? "";
logLine("perf.client", {
type,
userAgent,
...data,
});
return NextResponse.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logLine("perf.client.error", { message });
return NextResponse.json({ ok: false, error: "Bad payload" }, { status: 400 });
}
}

View File

@@ -1,8 +1,17 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { createHash } from "crypto";
import { revalidateTag } from "next/cache";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import {
FINANCIAL_CONFIG_SWR_SEC,
FINANCIAL_CONFIG_TTL_SEC,
getFinancialConfig,
type FinancialConfigPayload,
} from "@/lib/financial/cache";
function canManageFinancials(role?: string | null) {
return role === "OWNER";
@@ -101,18 +110,37 @@ async function ensureOrgFinancialProfile(
});
}
async function loadFinancialConfig(orgId: string) {
const [org, locations, machines, products] = await Promise.all([
prisma.orgFinancialProfile.findUnique({ where: { orgId } }),
prisma.locationFinancialOverride.findMany({ where: { orgId }, orderBy: { location: "asc" } }),
prisma.machineFinancialOverride.findMany({ where: { orgId }, orderBy: { createdAt: "desc" } }),
prisma.productCostOverride.findMany({ where: { orgId }, orderBy: { sku: "asc" } }),
]);
return { org, locations, machines, products };
function toMs(value?: Date | string | null) {
if (!value) return 0;
const date = typeof value === "string" ? new Date(value) : value;
const ms = date.getTime();
return Number.isNaN(ms) ? 0 : ms;
}
export async function GET() {
function maxUpdatedMs(rows: Array<{ updatedAt?: Date | string | null }>) {
let max = 0;
for (const row of rows) {
const ms = toMs(row.updatedAt);
if (ms > max) max = ms;
}
return max;
}
function buildConfigEtag(orgId: string, payload: FinancialConfigPayload) {
const parts = [
orgId,
toMs(payload.org?.updatedAt),
maxUpdatedMs(payload.locations ?? []),
maxUpdatedMs(payload.machines ?? []),
maxUpdatedMs(payload.products ?? []),
payload.locations?.length ?? 0,
payload.machines?.length ?? 0,
payload.products?.length ?? 0,
];
return `W/"${createHash("sha1").update(parts.join("|")).digest("hex")}"`;
}
export async function GET(req: NextRequest) {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
@@ -124,9 +152,25 @@ export async function GET() {
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
}
const url = new URL(req.url);
const refresh = url.searchParams.get("refresh") === "1";
await prisma.$transaction((tx) => ensureOrgFinancialProfile(tx, session.orgId, session.userId));
const payload = await loadFinancialConfig(session.orgId);
return NextResponse.json({ ok: true, ...payload });
const payload = await getFinancialConfig(session.orgId, { refresh });
const etag = buildConfigEtag(session.orgId, payload);
const responseHeaders = new Headers({
"Cache-Control": `private, max-age=${FINANCIAL_CONFIG_TTL_SEC}, stale-while-revalidate=${FINANCIAL_CONFIG_SWR_SEC}`,
ETag: etag,
Vary: "Cookie",
});
const ifNoneMatch = req.headers.get("if-none-match");
if (!refresh && ifNoneMatch && ifNoneMatch === etag) {
return new NextResponse(null, { status: 304, headers: responseHeaders });
}
return NextResponse.json({ ok: true, ...payload }, { headers: responseHeaders });
}
export async function POST(req: Request) {
@@ -257,6 +301,9 @@ export async function POST(req: Request) {
}
});
const payload = await loadFinancialConfig(session.orgId);
revalidateTag(`financial-config:${session.orgId}`, { expire: 0 });
revalidateTag(`financial-impact:${session.orgId}`, { expire: 0 });
const payload = await getFinancialConfig(session.orgId, { refresh: true });
return NextResponse.json({ ok: true, ...payload });
}

View File

@@ -2,7 +2,11 @@ import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { computeFinancialImpact } from "@/lib/financial/impact";
import {
FINANCIAL_IMPACT_SWR_SEC,
FINANCIAL_IMPACT_TTL_SEC,
getFinancialImpactCached,
} from "@/lib/financial/cache";
const RANGE_MS: Record<string, number> = {
"24h": 24 * 60 * 60 * 1000,
@@ -50,13 +54,15 @@ export async function GET(req: NextRequest) {
}
const url = new URL(req.url);
const refresh = url.searchParams.get("refresh") === "1";
const { start, end } = pickRange(req);
const machineId = url.searchParams.get("machineId") ?? undefined;
const location = url.searchParams.get("location") ?? undefined;
const sku = url.searchParams.get("sku") ?? undefined;
const currency = url.searchParams.get("currency") ?? undefined;
const result = await computeFinancialImpact({
const result = await getFinancialImpactCached(
{
orgId: session.orgId,
start,
end,
@@ -65,7 +71,14 @@ export async function GET(req: NextRequest) {
sku,
currency,
includeEvents: false,
},
{ refresh }
);
const responseHeaders = new Headers({
"Cache-Control": `private, max-age=${FINANCIAL_IMPACT_TTL_SEC}, stale-while-revalidate=${FINANCIAL_IMPACT_SWR_SEC}`,
Vary: "Cookie",
});
return NextResponse.json({ ok: true, ...result });
return NextResponse.json({ ok: true, ...result }, { headers: responseHeaders });
}

View File

@@ -33,6 +33,48 @@ function unwrapEnvelope(raw: unknown) {
};
}
function asNumber(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
function normalizeCycleInput(raw: unknown): Record<string, unknown> | null {
const row = asRecord(raw);
if (!row) return null;
const data = asRecord(row.data);
const fromRowOrData = (keys: string[]) => {
for (const key of keys) {
if (row[key] !== undefined) return row[key];
if (data && data[key] !== undefined) return data[key];
}
return undefined;
};
return {
...row,
actual_cycle_time: fromRowOrData(["actual_cycle_time", "actualCycleTime", "actual_cycle", "actual"]),
theoretical_cycle_time: fromRowOrData([
"theoretical_cycle_time",
"theoreticalCycleTime",
"cycleTime",
"cycle_time",
"ideal",
]),
cycle_count: fromRowOrData(["cycle_count", "cycleCount"]),
work_order_id: fromRowOrData(["work_order_id", "workOrderId"]),
good_delta: fromRowOrData(["good_delta", "goodDelta"]),
scrap_delta: fromRowOrData(["scrap_delta", "scrapDelta", "scrap_total"]),
timestamp: fromRowOrData(["timestamp", "tsMs"]),
ts: fromRowOrData(["ts", "tsMs"]),
event_timestamp: fromRowOrData(["event_timestamp", "eventTimestamp"]),
};
}
const numberFromAny = z.preprocess((value) => {
if (typeof value === "number") return value;
if (typeof value === "string" && value.trim() !== "") return Number(value);
@@ -87,15 +129,22 @@ export async function POST(req: Request) {
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
}
const cycleList = Array.isArray(cyclesRaw) ? cyclesRaw : [cyclesRaw];
const cycleList = (Array.isArray(cyclesRaw) ? cyclesRaw : [cyclesRaw])
.map((row) => normalizeCycleInput(row))
.filter((row): row is Record<string, unknown> => !!row);
if (!cycleList.length) {
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
}
const parsedCycles = z.array(cycleSchema).safeParse(cycleList);
if (!parsedCycles.success) {
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
}
const fallbackTsMs =
(typeof bodyRecord.tsMs === "number" && bodyRecord.tsMs) ||
(typeof bodyRecord.tsDevice === "number" && bodyRecord.tsDevice) ||
asNumber(bodyRecord.tsMs) ||
asNumber(bodyRecord.tsDevice) ||
undefined;
const rows = parsedCycles.data.map((data) => {

View File

@@ -4,6 +4,14 @@ import { getMachineAuth } from "@/lib/machineAuthCache";
import { z } from "zod";
import { evaluateAlertsForEvent } from "@/lib/alerts/engine";
import { toJsonValue } from "@/lib/prismaJson";
import {
findCatalogReason,
loadFallbackReasonCatalog,
normalizeReasonCatalog,
toReasonCode,
type ReasonCatalog,
type ReasonCatalogKind,
} from "@/lib/reasonCatalog";
const normalizeType = (t: unknown) =>
String(t ?? "")
@@ -30,6 +38,8 @@ const CANON_TYPE: Record<string, string> = {
"microparo": "microstop",
"micro-paro": "microstop",
"down": "stop",
"downtime-acknowledged": "downtime-acknowledged",
"scrap-manual-entry": "scrap-manual-entry",
};
const ALLOWED_TYPES = new Set([
@@ -42,6 +52,8 @@ const ALLOWED_TYPES = new Set([
"quality-spike",
"performance-degradation",
"predictive-oee-decline",
"downtime-acknowledged",
"scrap-manual-entry",
]);
const machineIdSchema = z.string().uuid();
@@ -58,6 +70,153 @@ function clampText(value: unknown, maxLen: number) {
return text.length > maxLen ? text.slice(0, maxLen) : text;
}
function numberFrom(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
function canonicalText(value: unknown) {
return String(value ?? "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function parseReasonPath(rawPath: unknown) {
let category: string | null = null;
let detail: string | null = null;
if (Array.isArray(rawPath)) {
const first = rawPath[0];
const second = rawPath[1];
if (typeof first === "string") category = first;
if (typeof second === "string") detail = second;
if (asRecord(first)) category = clampText(first.id ?? first.label ?? first.value, 120);
if (asRecord(second)) detail = clampText(second.id ?? second.label ?? second.value, 120);
} else if (typeof rawPath === "string") {
const pieces = rawPath
.split(/>|\/|\\|\|/g)
.map((p) => p.trim())
.filter(Boolean);
category = pieces[0] ?? null;
detail = pieces[1] ?? null;
}
return {
category: clampText(category, 120),
detail: clampText(detail, 120),
};
}
function parseReasonTextPath(reasonText: unknown) {
const text = clampText(reasonText, 240);
if (!text) return { category: null as string | null, detail: null as string | null };
const pieces = text
.split(/>|\/|\\|\|/g)
.map((p) => p.trim())
.filter(Boolean);
return {
category: clampText(pieces[0] ?? null, 120),
detail: clampText(pieces[1] ?? null, 120),
};
}
function findCatalogReasonFlexible(
catalog: ReasonCatalog | null,
kind: ReasonCatalogKind,
categoryIdOrLabel: unknown,
detailIdOrLabel: unknown
) {
const direct = findCatalogReason(catalog, kind, categoryIdOrLabel, detailIdOrLabel);
if (direct) return direct;
if (!catalog) return null;
const catNeedle = canonicalText(categoryIdOrLabel);
const detNeedle = canonicalText(detailIdOrLabel);
if (!catNeedle || !detNeedle) return null;
for (const category of catalog[kind] ?? []) {
const catMatch =
canonicalText(category.id) === catNeedle || canonicalText(category.label) === catNeedle;
if (!catMatch) continue;
for (const detail of category.details) {
const detMatch = canonicalText(detail.id) === detNeedle || canonicalText(detail.label) === detNeedle;
if (!detMatch) continue;
return {
categoryId: category.id,
categoryLabel: category.label,
detailId: detail.id,
detailLabel: detail.label,
reasonCode: toReasonCode(category.id, detail.id),
reasonLabel: `${category.label} > ${detail.label}`,
};
}
}
return null;
}
function getCatalogFromDefaults(defaultsJson: unknown) {
const defaults = asRecord(defaultsJson);
if (!defaults) return null;
return normalizeReasonCatalog(defaults.reasonCatalog ?? defaults.reasonCatalogData);
}
function resolveReason(
raw: Record<string, unknown>,
kind: ReasonCatalogKind,
catalog: ReasonCatalog | null,
fallbackVersion: number
) {
const reasonPath = parseReasonPath(raw.reasonPath);
const reasonTextPath = parseReasonTextPath(raw.reasonText);
const categoryIdRaw = clampText(raw.categoryId ?? reasonPath.category ?? reasonTextPath.category, 64);
const detailIdRaw = clampText(raw.detailId ?? reasonPath.detail ?? reasonTextPath.detail, 64);
const fromCatalog = findCatalogReasonFlexible(catalog, kind, categoryIdRaw, detailIdRaw);
const categoryLabelRaw = clampText(raw.categoryLabel ?? reasonPath.category ?? reasonTextPath.category, 120);
const detailLabelRaw = clampText(raw.detailLabel ?? reasonPath.detail ?? reasonTextPath.detail, 120);
const reasonCode =
clampText(raw.reasonCode, 64)?.toUpperCase() ??
fromCatalog?.reasonCode ??
toReasonCode(categoryIdRaw ?? categoryLabelRaw, detailIdRaw ?? detailLabelRaw) ??
null;
const categoryId = fromCatalog?.categoryId ?? categoryIdRaw;
const detailId = fromCatalog?.detailId ?? detailIdRaw;
const categoryLabel = fromCatalog?.categoryLabel ?? categoryLabelRaw;
const detailLabel = fromCatalog?.detailLabel ?? detailLabelRaw;
const pathLabel =
clampText(raw.reasonText, 240) ??
fromCatalog?.reasonLabel ??
(categoryLabel && detailLabel ? `${categoryLabel} > ${detailLabel}` : null) ??
detailLabel ??
categoryLabel ??
reasonCode;
const catalogVersionRaw = numberFrom(raw.catalogVersion);
const catalogVersion = catalogVersionRaw != null ? Math.trunc(catalogVersionRaw) : fallbackVersion;
return {
type: kind,
categoryId,
categoryLabel,
detailId,
detailLabel,
reasonCode,
reasonLabel: pathLabel,
reasonText: pathLabel,
catalogVersion,
};
}
export async function POST(req: Request) {
const apiKey = req.headers.get("x-api-key");
if (!apiKey) return NextResponse.json({ ok: false, error: "Missing api key" }, { status: 401 });
@@ -103,8 +262,11 @@ export async function POST(req: Request) {
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const orgSettings = await prisma.orgSettings.findUnique({
where: { orgId: machine.orgId },
select: { stoppageMultiplier: true, macroStoppageMultiplier: true },
select: { stoppageMultiplier: true, macroStoppageMultiplier: true, defaultsJson: true },
});
const fallbackCatalog = await loadFallbackReasonCatalog();
const settingsCatalog = getCatalogFromDefaults(orgSettings?.defaultsJson);
const reasonCatalog = settingsCatalog ?? fallbackCatalog;
const defaultMicroMultiplier = Number(orgSettings?.stoppageMultiplier ?? 1.5);
const defaultMacroMultiplier = Math.max(
@@ -129,6 +291,8 @@ export async function POST(req: Request) {
continue;
}
const evData = asRecord(evRecord.data) ?? {};
const evReason = asRecord(evRecord.reason) ?? asRecord(evData.reason);
const evDowntime = asRecord(evRecord.downtime) ?? asRecord(evData.downtime);
const rawType = evRecord.eventType ?? evRecord.anomaly_type ?? evRecord.topic ?? bodyRecord.topic ?? "";
const typ0 = normalizeType(rawType);
@@ -211,6 +375,8 @@ export async function POST(req: Request) {
if (evRecord.alert_id != null && dataObj.alert_id == null) dataObj.alert_id = evRecord.alert_id;
if (evRecord.is_update != null && dataObj.is_update == null) dataObj.is_update = evRecord.is_update;
if (evRecord.is_auto_ack != null && dataObj.is_auto_ack == null) dataObj.is_auto_ack = evRecord.is_auto_ack;
if (evReason && dataObj.reason == null) dataObj.reason = evReason;
if (evDowntime && dataObj.downtime == null) dataObj.downtime = evDowntime;
const activeWorkOrder = asRecord(evRecord.activeWorkOrder);
const dataActiveWorkOrder = asRecord(evData.activeWorkOrder);
@@ -244,8 +410,127 @@ export async function POST(req: Request) {
created.push({ id: row.id, ts: row.ts, eventType: row.eventType });
if (evReason) {
const inferredKind: ReasonCatalogKind =
String(evReason.type ?? "").toLowerCase() === "scrap" || finalType === "scrap-manual-entry"
? "scrap"
: "downtime";
const resolved = resolveReason(evReason, inferredKind, reasonCatalog, reasonCatalog.version);
if (resolved.reasonCode) {
const reasonId =
clampText(evReason.reasonId, 128) ??
(inferredKind === "downtime"
? `evt:${machine.id}:downtime:${clampText(evReason.incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id}`
: `evt:${machine.id}:scrap:${clampText(evReason.scrapEntryId, 128) ?? row.id}`);
const workOrderId =
clampText(evRecord.work_order_id, 64) ??
clampText(evData.work_order_id, 64) ??
clampText(evRecord.workOrderId, 64) ??
null;
const commonWrite = {
reasonCode: resolved.reasonCode,
reasonLabel: resolved.reasonLabel ?? resolved.reasonCode,
reasonText: resolved.reasonText ?? null,
capturedAt: row.ts,
workOrderId,
schemaVersion: Math.max(1, Math.trunc(resolved.catalogVersion)),
meta: toJsonValue({
source: "ingest:event",
eventId: row.id,
eventType: row.eventType,
incidentKey: clampText(evReason.incidentKey ?? evDowntime?.incidentKey, 128),
anomalyType:
clampText(evRecord.anomalyType, 64) ??
clampText(evDowntime?.anomalyType, 64) ??
clampText(evRecord.anomaly_type, 64),
reason: {
type: resolved.type,
categoryId: resolved.categoryId,
categoryLabel: resolved.categoryLabel,
detailId: resolved.detailId,
detailLabel: resolved.detailLabel,
reasonText: resolved.reasonText,
catalogVersion: resolved.catalogVersion,
},
}),
};
if (inferredKind === "downtime") {
const incidentKey = clampText(evReason.incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id;
const durationSeconds =
numberFrom(evDowntime?.durationSeconds) ??
numberFrom(evData.stoppage_duration_seconds) ??
numberFrom(evData.stop_duration_seconds) ??
null;
const episodeEndTsMs =
numberFrom(evDowntime?.episodeEndTsMs) ??
numberFrom(evDowntime?.acknowledgedAtMs) ??
null;
await prisma.reasonEntry.upsert({
where: { reasonId },
create: {
orgId: machine.orgId,
machineId: machine.id,
reasonId,
kind: "downtime",
episodeId: incidentKey,
durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null,
episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null,
...commonWrite,
},
update: {
kind: "downtime",
episodeId: incidentKey,
durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null,
episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null,
...commonWrite,
},
});
} else {
const scrapEntryId =
clampText(evReason.scrapEntryId, 128) ??
clampText(evRecord.id, 128) ??
clampText(evRecord.eventId, 128) ??
row.id;
const scrapQtyRaw =
numberFrom(evRecord.scrapDelta) ??
numberFrom(evData.scrapDelta) ??
numberFrom(evData.scrap_delta) ??
0;
const scrapQty = Math.max(0, Math.trunc(scrapQtyRaw));
await prisma.reasonEntry.upsert({
where: { reasonId },
create: {
orgId: machine.orgId,
machineId: machine.id,
reasonId,
kind: "scrap",
scrapEntryId,
scrapQty,
scrapUnit: clampText(evReason.scrapUnit, 16) ?? null,
...commonWrite,
},
update: {
kind: "scrap",
scrapEntryId,
scrapQty,
scrapUnit: clampText(evReason.scrapUnit, 16) ?? null,
...commonWrite,
},
});
}
}
}
try {
if (row.eventType !== "downtime-acknowledged" && row.eventType !== "scrap-manual-entry") {
await evaluateAlertsForEvent(row.id);
}
} catch (err) {
console.error("[alerts] evaluation failed", err);
}

View File

@@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
import { getMachineAuth } from "@/lib/machineAuthCache";
import { normalizeSnapshotV1 } from "@/lib/contracts/v1";
import { toJsonValue } from "@/lib/prismaJson";
import { logLine } from "@/lib/logger";
function getClientIp(req: Request) {
const xf = req.headers.get("x-forwarded-for");
@@ -21,11 +22,68 @@ function parseSeqToBigInt(seq: unknown): bigint | null {
return null;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function readPath(root: unknown, path: string[]): unknown {
let current = root;
for (const key of path) {
const record = asRecord(current);
if (!record) return undefined;
current = record[key];
}
return current;
}
function collectQualityTrace(params: {
rawBody: unknown;
normalizedKpis: Record<string, unknown> | null;
persistedQuality: number | null;
machineId: string;
rowId: string;
}) {
const { rawBody, normalizedKpis, persistedQuality, machineId, rowId } = params;
const candidates = [
"kpis.quality",
"payload.kpis.quality",
"kpi_snapshot.quality",
"quality",
"payload.quality",
] as const;
const rawQualityCandidates: Record<string, { type: string; value: unknown }> = {};
for (const path of candidates) {
const value = readPath(rawBody, path.split("."));
rawQualityCandidates[path] = {
type: value === null ? "null" : typeof value,
value,
};
}
const normalizedQuality = normalizedKpis?.quality;
return {
machineId,
rowId,
rawQualityCandidates,
normalizedQuality: {
type: normalizedQuality === null ? "null" : typeof normalizedQuality,
value: normalizedQuality ?? null,
},
persistedQuality: {
type: persistedQuality === null ? "null" : typeof persistedQuality,
value: persistedQuality,
},
};
}
export async function POST(req: Request) {
const endpoint = "/api/ingest/kpi";
const startedAt = Date.now();
const ip = getClientIp(req);
const userAgent = req.headers.get("user-agent");
const traceEnabled = process.env.TRACE_KPI_INGEST === "1" || req.headers.get("x-debug-ingest") === "1";
let rawBody: unknown = null;
let orgId: string | null = null;
@@ -182,11 +240,33 @@ export async function POST(req: Request) {
},
});
const trace = collectQualityTrace({
rawBody,
normalizedKpis: asRecord(k),
persistedQuality: row.quality ?? null,
machineId: machine.id,
rowId: row.id,
});
if (traceEnabled) {
logLine("ingest.kpi.trace", {
endpoint,
machineId: machine.id,
orgId,
schemaVersion,
seq: seq != null ? seq.toString() : null,
ip,
userAgent,
trace,
rawBody: toJsonValue(rawBody),
});
}
return NextResponse.json({
ok: true,
id: row.id,
tsDevice: row.ts,
tsServer: row.tsServer,
trace: traceEnabled ? trace : undefined,
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Unknown error";

View File

@@ -1,9 +1,11 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { normalizeEvent } from "@/lib/events/normalizeEvent";
import { invalidateMachineAuth } from "@/lib/machineAuthCache";
const machineIdSchema = z.string().uuid();
@@ -29,10 +31,63 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
}
function parseNumber(value: string | null, fallback: number) {
if (value == null || value === "") return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
type MachineFkReference = {
tableName: string;
columnName: string;
deleteRule: string;
};
function quoteIdent(identifier: string) {
return `"${identifier.replace(/"/g, "\"\"")}"`;
}
async function cleanupMachineReferences(machineId: string) {
const refs = await prisma.$queryRaw<MachineFkReference[]>`
SELECT DISTINCT
tc.table_name AS "tableName",
kcu.column_name AS "columnName",
rc.delete_rule AS "deleteRule"
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
JOIN information_schema.referential_constraints rc
ON tc.constraint_name = rc.constraint_name
AND tc.table_schema = rc.constraint_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = 'public'
AND rc.unique_constraint_schema = 'public'
AND rc.unique_constraint_name IN (
SELECT constraint_name
FROM information_schema.table_constraints
WHERE table_schema = 'public'
AND table_name = 'Machine'
AND constraint_type IN ('PRIMARY KEY', 'UNIQUE')
)
`;
for (const ref of refs) {
if (ref.tableName === "Machine") continue;
const table = quoteIdent(ref.tableName);
const column = quoteIdent(ref.columnName);
const rule = String(ref.deleteRule ?? "").toUpperCase();
if (rule === "CASCADE") continue;
if (rule === "SET NULL") {
await prisma.$executeRawUnsafe(`UPDATE ${table} SET ${column} = NULL WHERE ${column} = $1`, machineId);
continue;
}
await prisma.$executeRawUnsafe(`DELETE FROM ${table} WHERE ${column} = $1`, machineId);
}
}
export async function GET(req: NextRequest, { params }: { params: Promise<{ machineId: string }> }) {
const session = await requireSession();
if (!session) {
@@ -158,25 +213,15 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ mach
const eventWindowStart = new Date(Date.now() - eventsWindowSec * 1000);
const criticalSeverities = ["critical", "error", "high"];
const eventWhere = {
const eventWhereBase = {
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,
where: eventWhereBase,
orderBy: { ts: "desc" },
take: eventsOnly ? 300 : 120,
select: {
@@ -192,15 +237,29 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ mach
workOrderId: true,
},
}),
prisma.machineEvent.count({ where: eventWhere }),
prisma.machineEvent.count({ where: eventWhereBase }),
]);
const normalized = rawEvents.map((row) =>
normalizeEvent(row, { microMultiplier: stoppageMultiplier, macroMultiplier: macroStoppageMultiplier })
);
const allowed = normalized.filter((event) => ALLOWED_EVENT_TYPES.has(event.eventType));
const criticalEventTypes = new Set(["macrostop", "microstop", "slow-cycle", "offline", "error"]);
const filtered =
eventsMode === "critical"
? allowed.filter((event) => {
const severity = String(event.severity ?? "").toLowerCase();
return (
criticalEventTypes.has(event.eventType) ||
event.requiresAck === true ||
criticalSeverities.includes(severity)
);
})
: allowed;
const seen = new Set<string>();
const deduped = normalized.filter((event) => {
const deduped = filtered.filter((event) => {
const key = `${event.eventType}-${event.ts ?? ""}-${event.title}`;
if (seen.has(key)) return false;
seen.add(key);
@@ -249,25 +308,185 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ mach
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({
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
if (attempt === 0) {
// Revoke credentials first in a committed write so ingest auth fails immediately.
const revoked = await prisma.machine.updateMany({
where: {
id: machineId,
orgId: session.orgId,
},
data: {
apiKey: null,
},
});
if (revoked.count === 0) {
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
}
invalidateMachineAuth(machineId);
}
// Avoid long interactive transactions on very large history tables (P2028 timeout).
// This sequence is idempotent and safe to retry because apiKey is revoked first.
await prisma.machineCycle.deleteMany({
where: {
machineId,
},
});
await prisma.machineHeartbeat.deleteMany({
where: {
machineId,
},
});
await prisma.machineKpiSnapshot.deleteMany({
where: {
machineId,
},
});
await prisma.machineEvent.deleteMany({
where: {
machineId,
},
});
await prisma.machineWorkOrder.deleteMany({
where: {
machineId,
},
});
await prisma.machineSettings.deleteMany({
where: {
machineId,
},
});
await prisma.settingsAudit.deleteMany({
where: {
machineId,
},
});
await prisma.alertNotification.deleteMany({
where: {
machineId,
},
});
await prisma.machineFinancialOverride.deleteMany({
where: {
machineId,
},
});
await prisma.reasonEntry.deleteMany({
where: {
machineId,
},
});
await prisma.downtimeAction.updateMany({
where: {
machineId,
},
data: {
machineId: null,
},
});
const result = await prisma.machine.deleteMany({
where: {
id: machineId,
orgId: session.orgId,
},
});
if (result.count === 0) {
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
}
invalidateMachineAuth(machineId);
return NextResponse.json({ ok: true });
} catch (err: unknown) {
const code = err instanceof Prisma.PrismaClientKnownRequestError ? err.code : undefined;
const message = err instanceof Error ? err.message : String(err);
console.error("DELETE /api/machines/[machineId] failed", {
machineId,
orgId: session.orgId,
attempt,
code,
message,
});
if (code === "P2003") {
if (attempt < 2) {
try {
await cleanupMachineReferences(machineId);
} catch (cleanupErr: unknown) {
const cleanupMessage = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr);
console.error("DELETE /api/machines/[machineId] cleanup failed", {
machineId,
orgId: session.orgId,
attempt,
cleanupMessage,
});
}
await new Promise((resolve) => setTimeout(resolve, (attempt + 1) * 150));
continue;
}
return NextResponse.json(
{
ok: false,
error: "Machine has dependent records and could not be removed",
code,
},
{ status: 409 }
);
}
if (code === "P2022") {
return NextResponse.json(
{
ok: false,
error: "Server schema is out of date for machine delete",
code,
},
{ status: 500 }
);
}
if (code === "P2028") {
return NextResponse.json(
{
ok: false,
error: "Delete timed out while removing machine history",
code,
},
{ status: 503 }
);
}
if (code) {
return NextResponse.json(
{
ok: false,
error: "Delete failed due to database error",
code,
},
{ status: 500 }
);
}
return NextResponse.json({ ok: false, error: "Delete failed" }, { status: 500 });
}
}
return NextResponse.json({ ok: false, error: "Delete failed", code: "DELETE_RETRY_EXHAUSTED" }, { status: 500 });
}

View File

@@ -1,11 +1,25 @@
import { NextResponse } from "next/server";
import { randomBytes } from "crypto";
import { prisma } from "@/lib/prisma";
import { cookies } from "next/headers";
import { generatePairingCode } from "@/lib/pairingCode";
import { z } from "zod";
import { logLine } from "@/lib/logger";
import { elapsedMs, formatServerTiming, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming";
import { requireSession } from "@/lib/auth/requireSession";
import {
fetchLatestHeartbeats,
fetchLatestKpis,
fetchMachineBase,
mergeMachineOverviewRows,
} from "@/lib/machines/withLatest";
const COOKIE_NAME = "mis_session";
let machinesColdStart = true;
function getColdStartInfo() {
const coldStart = machinesColdStart;
machinesColdStart = false;
return { coldStart, uptimeMs: Math.round(process.uptime() * 1000) };
}
const createMachineSchema = z.object({
name: z.string().trim().min(1).max(80),
@@ -13,72 +27,66 @@ const createMachineSchema = z.object({
location: z.string().trim().max(80).optional(),
});
async function requireSession() {
const sessionId = (await cookies()).get(COOKIE_NAME)?.value;
if (!sessionId) return null;
export async function GET(req: Request) {
const perfEnabled = PERF_LOGS_ENABLED;
const totalStart = nowMs();
const timings: Record<string, number> = {};
const { coldStart, uptimeMs } = getColdStartInfo();
const url = new URL(req.url);
const includeKpi = url.searchParams.get("includeKpi") === "1";
const session = await prisma.session.findFirst({
where: { id: sessionId, revokedAt: null, expiresAt: { gt: new Date() } },
include: { org: true, user: true },
});
if (!session || !session.user?.isActive || !session.user?.emailVerifiedAt) {
return null;
}
return session;
}
export async function GET() {
const authStart = nowMs();
const session = await requireSession();
if (perfEnabled) timings.auth = elapsedMs(authStart);
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const machines = await prisma.machine.findMany({
where: { orgId: session.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,
},
},
},
});
const preQueryStart = nowMs();
const machinesStart = nowMs();
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
const machines = await fetchMachineBase(session.orgId);
if (perfEnabled) timings.machinesQuery = elapsedMs(machinesStart);
const heartbeatStart = nowMs();
const machineIds = machines.map((machine) => machine.id);
const heartbeats = await fetchLatestHeartbeats(session.orgId, machineIds);
if (perfEnabled) timings.heartbeatsQuery = elapsedMs(heartbeatStart);
let kpis: Awaited<ReturnType<typeof fetchLatestKpis>> = [];
if (includeKpi) {
const kpiStart = nowMs();
kpis = await fetchLatestKpis(session.orgId, machineIds);
if (perfEnabled) timings.kpiQuery = elapsedMs(kpiStart);
}
const postQueryStart = nowMs();
// flatten latest heartbeat for UI convenience
const out = machines.map((m) => ({
...m,
latestHeartbeat: m.heartbeats[0] ?? null,
latestKpi: m.kpiSnapshots[0] ?? null,
heartbeats: undefined,
kpiSnapshots: undefined,
}));
const out = mergeMachineOverviewRows({
machines,
heartbeats,
kpis,
includeKpi,
});
return NextResponse.json({ ok: true, machines: out });
const payload = { ok: true, machines: out };
const responseHeaders = new Headers();
if (perfEnabled) {
timings.postQuery = elapsedMs(postQueryStart);
timings.total = elapsedMs(totalStart);
responseHeaders.set("Server-Timing", formatServerTiming(timings));
const payloadBytes = Buffer.byteLength(JSON.stringify(payload));
logLine("perf.machines.api", {
orgId: session.orgId,
coldStart,
uptimeMs,
timings,
counts: { machines: out.length },
payloadBytes,
});
}
return NextResponse.json(payload, { headers: responseHeaders });
}
export async function POST(req: Request) {

View File

@@ -4,23 +4,72 @@ import { createHash } from "crypto";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { getOverviewData } from "@/lib/overview/getOverviewData";
import { getOverviewSummary } from "@/lib/overview/getOverviewSummary";
import { logLine } from "@/lib/logger";
import { elapsedMs, formatServerTiming, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming";
let overviewColdStart = true;
function getColdStartInfo() {
const coldStart = overviewColdStart;
overviewColdStart = false;
return { coldStart, uptimeMs: Math.round(process.uptime() * 1000) };
}
function toMs(value?: Date | null) {
return value ? value.getTime() : 0;
}
export async function GET(req: NextRequest) {
const perfEnabled = PERF_LOGS_ENABLED;
const totalStart = nowMs();
const timings: Record<string, number> = {};
const { coldStart, uptimeMs } = getColdStartInfo();
const authStart = nowMs();
const session = await requireSession();
if (perfEnabled) timings.auth = elapsedMs(authStart);
if (!session) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const url = new URL(req.url);
const detail = url.searchParams.get("detail") === "1";
if (!detail) {
const summaryStart = nowMs();
const { machines: machineRows } = await getOverviewSummary({ orgId: session.orgId });
if (perfEnabled) timings.summary = elapsedMs(summaryStart);
const payload = { ok: true, machines: machineRows, events: [] };
const responseHeaders = new Headers();
if (perfEnabled) {
timings.total = elapsedMs(totalStart);
responseHeaders.set("Server-Timing", formatServerTiming(timings));
const payloadBytes = Buffer.byteLength(JSON.stringify(payload));
logLine("perf.overview.api", {
orgId: session.orgId,
detail: false,
coldStart,
uptimeMs,
timings,
counts: { machines: machineRows.length, events: 0 },
payloadBytes,
});
}
return NextResponse.json(payload, { headers: responseHeaders });
}
const preQueryStart = nowMs();
const eventsMode = url.searchParams.get("events") ?? "critical";
const eventsWindowSecRaw = Number(url.searchParams.get("eventsWindowSec") ?? "21600");
const eventsWindowSec = Number.isFinite(eventsWindowSecRaw) ? eventsWindowSecRaw : 21600;
const eventMachinesRaw = Number(url.searchParams.get("eventMachines") ?? "6");
const eventMachines = Number.isFinite(eventMachinesRaw) ? Math.max(1, eventMachinesRaw) : 6;
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
const aggStart = nowMs();
const [machineAgg, heartbeatAgg, kpiAgg, eventAgg, orgSettings] = await Promise.all([
prisma.machine.aggregate({
where: { orgId: session.orgId },
@@ -43,6 +92,7 @@ export async function GET(req: NextRequest) {
select: { updatedAt: true, stoppageMultiplier: true, macroStoppageMultiplier: true },
}),
]);
if (perfEnabled) timings.agg = elapsedMs(aggStart);
const lastModifiedMs = Math.max(
toMs(machineAgg._max.updatedAt),
@@ -86,6 +136,7 @@ export async function GET(req: NextRequest) {
}
}
const dataStart = nowMs();
const { machines: machineRows, events } = await getOverviewData({
orgId: session.orgId,
eventsMode,
@@ -93,9 +144,29 @@ export async function GET(req: NextRequest) {
eventMachines,
orgSettings,
});
if (perfEnabled) timings.data = elapsedMs(dataStart);
return NextResponse.json(
{ ok: true, machines: machineRows, events },
{ headers: responseHeaders }
);
const postQueryStart = nowMs();
const payload = { ok: true, machines: machineRows, events };
if (perfEnabled) {
timings.postQuery = elapsedMs(postQueryStart);
timings.total = elapsedMs(totalStart);
responseHeaders.set("Server-Timing", formatServerTiming(timings));
const payloadBytes = Buffer.byteLength(JSON.stringify(payload));
logLine("perf.overview.api", {
orgId: session.orgId,
detail: true,
coldStart,
uptimeMs,
eventsMode,
eventsWindowSec,
eventMachines,
timings,
counts: { machines: machineRows.length, events: events.length },
payloadBytes,
});
}
return NextResponse.json(payload, { headers: responseHeaders });
}

View File

@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import {
flattenReasonCatalog,
loadFallbackReasonCatalog,
normalizeReasonCatalog,
type ReasonCatalogKind,
} from "@/lib/reasonCatalog";
function asKind(value: string | null): ReasonCatalogKind | null {
const kind = String(value ?? "").toLowerCase();
if (kind === "downtime" || kind === "scrap") return kind;
return null;
}
export async function GET(req: Request) {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const url = new URL(req.url);
const kind = asKind(url.searchParams.get("kind"));
if (!kind) {
return NextResponse.json({ ok: false, error: "Invalid kind (downtime|scrap)" }, { status: 400 });
}
const orgSettings = await prisma.orgSettings.findUnique({
where: { orgId: session.orgId },
select: { defaultsJson: true },
});
const defaultsJson =
orgSettings?.defaultsJson && typeof orgSettings.defaultsJson === "object" && !Array.isArray(orgSettings.defaultsJson)
? (orgSettings.defaultsJson as Record<string, unknown>)
: {};
const settingsCatalog = normalizeReasonCatalog(defaultsJson.reasonCatalog ?? defaultsJson.reasonCatalogData);
const fallbackCatalog = await loadFallbackReasonCatalog();
const catalog = settingsCatalog ?? fallbackCatalog;
const rows = flattenReasonCatalog(catalog, kind);
return NextResponse.json({
ok: true,
source: settingsCatalog ? "settings" : "fallback",
kind,
catalogVersion: catalog.version,
categories: catalog[kind],
rows,
});
}

View File

@@ -2,6 +2,16 @@ import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { logLine } from "@/lib/logger";
import { elapsedMs, formatServerTiming, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming";
let reportsFiltersColdStart = true;
function getColdStartInfo() {
const coldStart = reportsFiltersColdStart;
reportsFiltersColdStart = false;
return { coldStart, uptimeMs: Math.round(process.uptime() * 1000) };
}
const RANGE_MS: Record<string, number> = {
"24h": 24 * 60 * 60 * 1000,
@@ -33,10 +43,19 @@ function pickRange(req: NextRequest) {
}
export async function GET(req: NextRequest) {
const perfEnabled = PERF_LOGS_ENABLED;
const totalStart = nowMs();
const timings: Record<string, number> = {};
const { coldStart, uptimeMs } = getColdStartInfo();
const authStart = nowMs();
const session = await requireSession();
if (perfEnabled) timings.auth = elapsedMs(authStart);
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const preQueryStart = nowMs();
const url = new URL(req.url);
const range = url.searchParams.get("range") ?? "24h";
const machineId = url.searchParams.get("machineId") ?? undefined;
const { start, end } = pickRange(req);
@@ -46,20 +65,51 @@ export async function GET(req: NextRequest) {
ts: { gte: start, lte: end },
};
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
const workOrdersStart = nowMs();
const workOrderRows = await prisma.machineCycle.findMany({
where: { ...baseWhere, workOrderId: { not: null } },
distinct: ["workOrderId"],
select: { workOrderId: true },
});
if (perfEnabled) timings.workOrders = elapsedMs(workOrdersStart);
const skuStart = nowMs();
const skuRows = await prisma.machineCycle.findMany({
where: { ...baseWhere, sku: { not: null } },
distinct: ["sku"],
select: { sku: true },
});
if (perfEnabled) timings.skus = elapsedMs(skuStart);
const postQueryStart = nowMs();
const workOrders = workOrderRows.map((r) => r.workOrderId).filter(Boolean) as string[];
const skus = skuRows.map((r) => r.sku).filter(Boolean) as string[];
return NextResponse.json({ ok: true, workOrders, skus });
const payload = { ok: true, workOrders, skus };
const responseHeaders = new Headers();
if (perfEnabled) {
timings.postQuery = elapsedMs(postQueryStart);
timings.total = elapsedMs(totalStart);
responseHeaders.set("Server-Timing", formatServerTiming(timings));
const payloadBytes = Buffer.byteLength(JSON.stringify(payload));
logLine("perf.reports.filters", {
orgId: session.orgId,
coldStart,
uptimeMs,
range,
machineId,
timings,
rowCounts: {
workOrderRows: workOrderRows.length,
skuRows: skuRows.length,
},
payloadBytes,
});
}
return NextResponse.json(payload, { headers: responseHeaders });
}

View File

@@ -2,6 +2,16 @@ import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { logLine } from "@/lib/logger";
import { elapsedMs, formatServerTiming, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming";
let reportsColdStart = true;
function getColdStartInfo() {
const coldStart = reportsColdStart;
reportsColdStart = false;
return { coldStart, uptimeMs: Math.round(process.uptime() * 1000) };
}
const RANGE_MS: Record<string, number> = {
"24h": 24 * 60 * 60 * 1000,
@@ -37,10 +47,19 @@ function safeNum(v: unknown) {
}
export async function GET(req: NextRequest) {
const perfEnabled = PERF_LOGS_ENABLED;
const totalStart = nowMs();
const timings: Record<string, number> = {};
const { coldStart, uptimeMs } = getColdStartInfo();
const authStart = nowMs();
const session = await requireSession();
if (perfEnabled) timings.auth = elapsedMs(authStart);
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const preQueryStart = nowMs();
const url = new URL(req.url);
const range = url.searchParams.get("range") ?? "24h";
const machineId = url.searchParams.get("machineId") ?? undefined;
const { start, end } = pickRange(req);
const workOrderId = url.searchParams.get("workOrderId") ?? undefined;
@@ -52,6 +71,9 @@ export async function GET(req: NextRequest) {
...(sku ? { sku } : {}),
};
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
const kpiStart = nowMs();
const kpiRows = await prisma.machineKpiSnapshot.findMany({
where: { ...baseWhere, ts: { gte: start, lte: end } },
orderBy: { ts: "asc" },
@@ -67,6 +89,7 @@ export async function GET(req: NextRequest) {
machineId: true,
},
});
if (perfEnabled) timings.kpiRows = elapsedMs(kpiStart);
let oeeSum = 0;
let oeeCount = 0;
@@ -96,10 +119,12 @@ export async function GET(req: NextRequest) {
}
}
const cyclesStart = nowMs();
const cycles = await prisma.machineCycle.findMany({
where: { ...baseWhere, ts: { gte: start, lte: end } },
select: { goodDelta: true, scrapDelta: true },
});
if (perfEnabled) timings.cycles = elapsedMs(cyclesStart);
let goodTotal = 0;
let scrapTotal = 0;
@@ -109,6 +134,7 @@ export async function GET(req: NextRequest) {
if (safeNum(c.scrapDelta) != null) scrapTotal += Number(c.scrapDelta);
}
const kpiAggStart = nowMs();
const kpiAgg = await prisma.machineKpiSnapshot.groupBy({
by: ["machineId"],
where: { ...baseWhere, ts: { gte: start, lte: end } },
@@ -116,6 +142,7 @@ export async function GET(req: NextRequest) {
_min: { good: true, scrap: true },
_count: { _all: true },
});
if (perfEnabled) timings.kpiAgg = elapsedMs(kpiAggStart);
let targetTotal = 0;
if (goodTotal === 0 && scrapTotal === 0) {
@@ -151,10 +178,12 @@ export async function GET(req: NextRequest) {
if (maxTarget != null) targetTotal += maxTarget;
}
const eventsStart = nowMs();
const events = await prisma.machineEvent.findMany({
where: { ...baseWhere, ts: { gte: start, lte: end } },
select: { eventType: true, data: true },
});
if (perfEnabled) timings.events = elapsedMs(eventsStart);
let macrostopSec = 0;
let microstopSec = 0;
@@ -223,10 +252,12 @@ export async function GET(req: NextRequest) {
trend.scrapRate.push({ t, v: (scrap / (good + scrap)) * 100 });
}
}
const cycleRowsStart = nowMs();
const cycleRows = await prisma.machineCycle.findMany({
where: { ...baseWhere, ts: { gte: start, lte: end } },
select: { actualCycleTime: true },
});
if (perfEnabled) timings.cycleRows = elapsedMs(cycleRowsStart);
const values = cycleRows
.map((c) => Number(c.actualCycleTime))
@@ -310,10 +341,14 @@ export async function GET(req: NextRequest) {
const scrapBySku = new Map<string, number>();
const scrapByWo = new Map<string, number>();
const scrapRowsStart = nowMs();
const scrapRows = await prisma.machineCycle.findMany({
where: { ...baseWhere, ts: { gte: start, lte: end } },
select: { sku: true, workOrderId: true, scrapDelta: true },
});
if (perfEnabled) timings.scrapRows = elapsedMs(scrapRowsStart);
const postQueryStart = nowMs();
for (const row of scrapRows) {
const scrap = safeNum(row.scrapDelta);
@@ -340,7 +375,7 @@ export async function GET(req: NextRequest) {
return NextResponse.json({
const payload = {
ok: true,
summary: {
oeeAvg,
@@ -366,8 +401,35 @@ export async function GET(req: NextRequest) {
trend,
insights,
distribution: {
cycleTime: cycleTimeBins
cycleTime: cycleTimeBins,
},
};
const responseHeaders = new Headers();
if (perfEnabled) {
timings.postQuery = elapsedMs(postQueryStart);
timings.total = elapsedMs(totalStart);
responseHeaders.set("Server-Timing", formatServerTiming(timings));
const payloadBytes = Buffer.byteLength(JSON.stringify(payload));
logLine("perf.reports.api", {
orgId: session.orgId,
coldStart,
uptimeMs,
range,
machineId,
workOrderId,
sku,
timings,
rowCounts: {
kpiRows: kpiRows.length,
cycles: cycles.length,
events: events.length,
cycleRows: cycleRows.length,
scrapRows: scrapRows.length,
},
payloadBytes,
});
}
return NextResponse.json(payload, { headers: responseHeaders });
}

View File

@@ -14,8 +14,10 @@ import {
validateDefaults,
validateShiftFields,
validateShiftSchedule,
validateShiftOverrides,
validateThresholds,
} from "@/lib/settings";
import { loadFallbackReasonCatalog, normalizeReasonCatalog, type ReasonCatalog } from "@/lib/reasonCatalog";
import { publishSettingsUpdate } from "@/lib/mqtt";
import { z } from "zod";
@@ -44,6 +46,24 @@ function pickAllowedOverrides(raw: unknown) {
return out;
}
function withReasonCatalog<T extends Record<string, unknown>>(payload: T, fallbackCatalog: ReasonCatalog) {
const base = (isPlainObject(payload) ? { ...payload } : {}) as T;
const defaults = isPlainObject(base.defaults) ? base.defaults : {};
const parsed =
normalizeReasonCatalog(base.reasonCatalog) ??
normalizeReasonCatalog(base.reasonCatalogData) ??
normalizeReasonCatalog(defaults.reasonCatalog) ??
normalizeReasonCatalog(defaults.reasonCatalogData) ??
fallbackCatalog;
return {
...base,
reasonCatalog: parsed,
reasonCatalogData: parsed,
reasonCatalogVersion: Number(parsed.version || 1),
};
}
async function ensureOrgSettings(
tx: Prisma.TransactionClient,
orgId: string,
@@ -144,6 +164,7 @@ export async function GET(
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
orgId = machine.orgId;
}
const fallbackCatalog = await loadFallbackReasonCatalog();
const { settings, overrides } = await prisma.$transaction(async (tx) => {
const orgSettings = await ensureOrgSettings(tx, orgId as string, userId);
@@ -154,9 +175,15 @@ export async function GET(
select: { overridesJson: true },
});
const orgPayload = buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []);
const orgPayload = withReasonCatalog(
buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []),
fallbackCatalog
);
const rawOverrides = pickAllowedOverrides(machineSettings?.overridesJson ?? {});
const effective = deepMerge(orgPayload, rawOverrides);
const effective = withReasonCatalog(
deepMerge(orgPayload, rawOverrides) as Record<string, unknown>,
fallbackCatalog
);
return { settings: { org: orgPayload, effective }, overrides: rawOverrides };
});
@@ -242,6 +269,14 @@ export async function PUT(
return NextResponse.json({ ok: false, error: shiftValidation.error }, { status: 400 });
}
const overridesResult =
patch?.shiftSchedule?.overrides !== undefined
? validateShiftOverrides(patch.shiftSchedule.overrides)
: ({ ok: true, overrides: undefined } as const);
if (!overridesResult.ok) {
return NextResponse.json({ ok: false, error: overridesResult.error }, { status: 400 });
}
const thresholdsValidation = validateThresholds(patch?.thresholds);
if (!thresholdsValidation.ok) {
return NextResponse.json({ ok: false, error: thresholdsValidation.error }, { status: 400 });
@@ -275,6 +310,12 @@ export async function PUT(
...patch,
shiftSchedule: {
...patch.shiftSchedule,
overrides:
patch.shiftSchedule.overrides !== undefined
? overridesResult.overrides === null
? null
: overridesResult.overrides
: patch.shiftSchedule.overrides,
shiftChangeCompensationMin:
patch.shiftSchedule.shiftChangeCompensationMin !== undefined
? Number(patch.shiftSchedule.shiftChangeCompensationMin)
@@ -372,9 +413,16 @@ export async function PUT(
},
});
const orgPayload = buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []);
const fallbackCatalog = await loadFallbackReasonCatalog();
const orgPayload = withReasonCatalog(
buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []),
fallbackCatalog
);
const overrides = pickAllowedOverrides(saved.overridesJson ?? {});
const effective = deepMerge(orgPayload, overrides);
const effective = withReasonCatalog(
deepMerge(orgPayload, overrides) as Record<string, unknown>,
fallbackCatalog
);
return {
orgPayload,

View File

@@ -1,4 +1,7 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { createHash } from "crypto";
import { revalidateTag, unstable_cache } from "next/cache";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
@@ -13,8 +16,10 @@ import {
validateDefaults,
validateShiftFields,
validateShiftSchedule,
validateShiftOverrides,
validateThresholds,
} from "@/lib/settings";
import { loadFallbackReasonCatalog, normalizeReasonCatalog, type ReasonCatalog } from "@/lib/reasonCatalog";
import { publishSettingsUpdate } from "@/lib/mqtt";
import { z } from "zod";
@@ -34,6 +39,24 @@ function canManageSettings(role?: string | null) {
return role === "OWNER" || role === "ADMIN";
}
function withReasonCatalog<T extends Record<string, unknown>>(payload: T, fallbackCatalog: ReasonCatalog) {
const base = (isPlainObject(payload) ? { ...payload } : {}) as T;
const defaults = isPlainObject(base.defaults) ? base.defaults : {};
const parsed =
normalizeReasonCatalog(base.reasonCatalog) ??
normalizeReasonCatalog(base.reasonCatalogData) ??
normalizeReasonCatalog(defaults.reasonCatalog) ??
normalizeReasonCatalog(defaults.reasonCatalogData) ??
fallbackCatalog;
return {
...base,
reasonCatalog: parsed,
reasonCatalogData: parsed,
reasonCatalogVersion: Number(parsed.version || 1),
};
}
const settingsPayloadSchema = z
.object({
source: z.string().trim().max(40).optional(),
@@ -43,10 +66,14 @@ const settingsPayloadSchema = z
thresholds: z.any().optional(),
alerts: z.any().optional(),
defaults: z.any().optional(),
reasonCatalog: z.any().optional(),
version: z.union([z.number(), z.string()]).optional(),
})
.passthrough();
const SETTINGS_TTL_SEC = 10;
const SETTINGS_SWR_SEC = 30;
async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, userId: string) {
let settings = await tx.orgSettings.findUnique({
where: { orgId },
@@ -111,24 +138,56 @@ async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, us
return { settings, shifts };
}
export async function GET() {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
try {
async function loadSettingsPayload(orgId: string, userId: string) {
const loaded = await prisma.$transaction(async (tx) => {
const found = await ensureOrgSettings(tx, session.orgId, session.userId);
const found = await ensureOrgSettings(tx, orgId, userId);
if (!found?.settings) throw new Error("SETTINGS_NOT_FOUND");
return found;
});
const payload = buildSettingsPayload(loaded.settings, loaded.shifts ?? []);
const fallbackCatalog = await loadFallbackReasonCatalog();
const payload = withReasonCatalog(buildSettingsPayload(loaded.settings, loaded.shifts ?? []), fallbackCatalog);
const defaultsRaw = isPlainObject(loaded.settings.defaultsJson) ? (loaded.settings.defaultsJson as any) : {};
const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {};
const modules = { screenlessMode: modulesRaw.screenlessMode === true };
return NextResponse.json({ ok: true, settings: { ...payload, modules } });
return { payload, modules };
}
async function loadSettingsCached(orgId: string, userId: string) {
const cached = unstable_cache(
() => loadSettingsPayload(orgId, userId),
["settings", orgId],
{ revalidate: SETTINGS_TTL_SEC, tags: [`settings:${orgId}`] }
);
return cached();
}
export async function GET(req: NextRequest) {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
try {
const url = new URL(req.url);
const refresh = url.searchParams.get("refresh") === "1";
const { payload, modules } = refresh
? await loadSettingsPayload(session.orgId, session.userId)
: await loadSettingsCached(session.orgId, session.userId);
const version = payload.version ?? 0;
const etag = `W/"${createHash("sha1").update(`${session.orgId}:${version}`).digest("hex")}"`;
const responseHeaders = new Headers({
"Cache-Control": `private, max-age=${SETTINGS_TTL_SEC}, stale-while-revalidate=${SETTINGS_SWR_SEC}`,
ETag: etag,
Vary: "Cookie",
});
const ifNoneMatch = req.headers.get("if-none-match");
if (!refresh && ifNoneMatch && ifNoneMatch === etag) {
return new NextResponse(null, { status: 304, headers: responseHeaders });
}
return NextResponse.json({ ok: true, settings: { ...payload, modules } }, { headers: responseHeaders });
} catch (err) {
console.error("[settings GET] failed", err);
@@ -162,6 +221,7 @@ export async function PUT(req: Request) {
const thresholds = parsed.data.thresholds;
const alerts = parsed.data.alerts;
const defaults = parsed.data.defaults;
const reasonCatalogRaw = parsed.data.reasonCatalog;
const expectedVersion = parsed.data.version;
const modules = parsed.data.modules;
@@ -173,6 +233,7 @@ export async function PUT(req: Request) {
thresholds === undefined &&
alerts === undefined &&
defaults === undefined &&
reasonCatalogRaw === undefined &&
modules === undefined
) {
@@ -191,6 +252,13 @@ export async function PUT(req: Request) {
if (defaults !== undefined && !isPlainObject(defaults)) {
return NextResponse.json({ ok: false, error: "defaults must be an object" }, { status: 400 });
}
const nextReasonCatalog =
reasonCatalogRaw === undefined || reasonCatalogRaw === null
? reasonCatalogRaw
: normalizeReasonCatalog(reasonCatalogRaw);
if (reasonCatalogRaw !== undefined && reasonCatalogRaw !== null && !nextReasonCatalog) {
return NextResponse.json({ ok: false, error: "reasonCatalog must be a valid catalog payload" }, { status: 400 });
}
if (modules !== undefined && !isPlainObject(modules)) {
return NextResponse.json({ ok: false, error: "Invalid modules payload" }, { status: 400 });
}
@@ -210,6 +278,14 @@ export async function PUT(req: Request) {
return NextResponse.json({ ok: false, error: shiftValidation.error }, { status: 400 });
}
const overridesResult =
shiftSchedule?.overrides !== undefined
? validateShiftOverrides(shiftSchedule.overrides)
: ({ ok: true, overrides: undefined } as const);
if (!overridesResult.ok) {
return NextResponse.json({ ok: false, error: overridesResult.error }, { status: 400 });
}
const thresholdsValidation = validateThresholds(thresholds);
if (!thresholdsValidation.ok) {
return NextResponse.json({ ok: false, error: thresholdsValidation.error }, { status: 400 });
@@ -257,12 +333,22 @@ export async function PUT(req: Request) {
: { ...currentModulesRaw, screenlessMode };
// Write defaultsJson if either defaults changed OR modules changed
const shouldWriteDefaultsJson = !!nextDefaultsCore || screenlessMode !== undefined;
const shouldWriteDefaultsJson =
!!nextDefaultsCore || screenlessMode !== undefined || reasonCatalogRaw !== undefined;
const nextDefaultsJson = shouldWriteDefaultsJson
? { ...(nextDefaultsCore ?? normalizeDefaults(currentDefaultsRaw)), modules: nextModules }
: undefined;
if (nextDefaultsJson && reasonCatalogRaw !== undefined) {
const defaultsTarget = nextDefaultsJson as Record<string, unknown>;
if (nextReasonCatalog === null) {
delete defaultsTarget.reasonCatalog;
} else if (nextReasonCatalog) {
defaultsTarget.reasonCatalog = nextReasonCatalog;
}
}
const updateData = stripUndefined({
timezone: timezone !== undefined ? String(timezone) : undefined,
@@ -272,6 +358,12 @@ export async function PUT(req: Request) {
: undefined,
lunchBreakMin:
shiftSchedule?.lunchBreakMin !== undefined ? Number(shiftSchedule.lunchBreakMin) : undefined,
shiftScheduleOverridesJson:
shiftSchedule?.overrides !== undefined
? overridesResult.overrides === null
? null
: overridesResult.overrides
: undefined,
stoppageMultiplier:
thresholds?.stoppageMultiplier !== undefined ? Number(thresholds.stoppageMultiplier) : undefined,
macroStoppageMultiplier:
@@ -373,6 +465,8 @@ export async function PUT(req: Request) {
const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {};
const modulesOut = { screenlessMode: modulesRaw.screenlessMode === true };
revalidateTag(`settings:${session.orgId}`, { expire: 0 });
return NextResponse.json({ ok: true, settings: { ...payload, modules: modulesOut } });
} catch (err) {

37
app/global-error.tsx Normal file
View File

@@ -0,0 +1,37 @@
"use client";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html lang="en">
<body style={{ margin: 0, fontFamily: "system-ui, sans-serif", background: "#0a0a0a", color: "#e5e5e5", minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ textAlign: "center", padding: "2rem", maxWidth: "28rem" }}>
<h1 style={{ fontSize: "1.25rem", fontWeight: 600, marginBottom: "0.5rem" }}>Something went wrong</h1>
<p style={{ fontSize: "0.875rem", color: "#a3a3a3", marginBottom: "1.5rem" }}>
An unexpected error occurred. Please try again.
</p>
<button
type="button"
onClick={() => reset()}
style={{
padding: "0.5rem 1rem",
fontSize: "0.875rem",
borderRadius: "0.5rem",
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.05)",
color: "#e5e5e5",
cursor: "pointer",
}}
>
Try again
</button>
</div>
</body>
</html>
);
}

View File

@@ -3,14 +3,14 @@ import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import InviteAcceptForm from "./InviteAcceptForm";
export default async function InvitePage({ params }: { params: { token: string } | Promise<{ token: string }> }) {
export default async function InvitePage({ params }: { params: Promise<{ token: string }> }) {
const session = (await cookies()).get("mis_session")?.value;
if (session) {
redirect("/machines");
}
const resolvedParams = await Promise.resolve(params);
const token = String(resolvedParams?.token || "").trim().toLowerCase();
const { token: rawToken } = await params;
const token = String(rawToken || "").trim().toLowerCase();
let invite = null;
let error: string | null = null;

View File

@@ -1,11 +1,14 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import "./globals.css";
import { prisma } from "@/lib/prisma";
export const metadata: Metadata = {
export async function generateMetadata(): Promise<Metadata> {
return {
title: "MIS Control Tower",
description: "MaliounTech Industrial Suite",
};
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const cookieJar = await cookies();

View File

@@ -6,13 +6,14 @@ import LoginForm from "./LoginForm"; // adjust path if needed
export default async function LoginPage({
searchParams,
}: {
searchParams?: { next?: string };
searchParams?: Promise<{ next?: string }>;
}) {
const session = (await cookies()).get("mis_session")?.value;
// If already logged in, send to next or machines
if (session) {
const next = searchParams?.next || "/machines";
const params = searchParams ? await searchParams : {};
const next = params?.next || "/machines";
redirect(next);
}

View File

@@ -80,6 +80,24 @@ type ApiDowntimeEventsRes = {
events?: ApiDowntimeEvent[];
};
type ApiReasonCatalogRow = {
kind: "downtime" | "scrap";
categoryId: string;
categoryLabel: string;
detailId: string;
detailLabel: string;
reasonCode: string;
reasonLabel: string;
};
type ApiReasonCatalogRes = {
ok: boolean;
error?: string;
kind?: "downtime" | "scrap";
catalogVersion?: number;
rows?: ApiReasonCatalogRow[];
};
function fmtDT(iso: string | null) {
if (!iso) return "—";
const d = new Date(iso);
@@ -1155,6 +1173,8 @@ export default function DowntimePageClient() {
const [eventsRes, setEventsRes] = useState<ApiDowntimeEventsRes | null>(null);
const [eventsLoading, setEventsLoading] = useState(false);
const [eventsErr, setEventsErr] = useState<string | null>(null);
const [catalogRows, setCatalogRows] = useState<ApiReasonCatalogRow[]>([]);
const [catalogErr, setCatalogErr] = useState<string | null>(null);
const [eventsLimit, setEventsLimit] = useState<number>(200);
const [eventsBefore, setEventsBefore] = useState<string | null>(null);
@@ -1251,6 +1271,41 @@ export default function DowntimePageClient() {
ac.abort();
};
}, [range, machineId]);
useEffect(() => {
let alive = true;
const ac = new AbortController();
async function run() {
setCatalogErr(null);
try {
const res = await fetch("/api/reasons/catalog?kind=downtime", {
cache: "no-cache",
credentials: "include",
signal: ac.signal,
});
const json = (await res.json().catch(() => ({}))) as ApiReasonCatalogRes;
if (!alive) return;
if (!res.ok || json.ok === false) {
setCatalogRows([]);
setCatalogErr(json.error ?? "Failed to load reason catalog");
return;
}
setCatalogRows(Array.isArray(json.rows) ? json.rows : []);
} catch (err: unknown) {
if (!alive) return;
setCatalogRows([]);
setCatalogErr(err instanceof Error ? err.message : "Network error");
}
}
run();
return () => {
alive = false;
ac.abort();
};
}, []);
useEffect(() => {
let alive = true;
const ac = new AbortController();
@@ -1308,6 +1363,29 @@ export default function DowntimePageClient() {
return metricRowsAll.filter((r) => r.reasonCode === reasonCode);
}, [metricRowsAll, reasonCode]);
const selectedReasonLabel = useMemo(() => {
if (!reasonCode) return null;
const fromMetrics = metricRowsAll.find((row) => row.reasonCode === reasonCode)?.reasonLabel;
if (fromMetrics) return fromMetrics;
const fromCatalog = catalogRows.find((row) => row.reasonCode === reasonCode)?.reasonLabel;
return fromCatalog ?? reasonCode;
}, [catalogRows, metricRowsAll, reasonCode]);
const catalogByCategory = useMemo(() => {
const grouped = new Map<string, { categoryLabel: string; rows: ApiReasonCatalogRow[] }>();
for (const row of catalogRows) {
const key = row.categoryId;
const slot = grouped.get(key) ?? { categoryLabel: row.categoryLabel, rows: [] };
slot.rows.push(row);
grouped.set(key, slot);
}
return [...grouped.entries()].map(([categoryId, value]) => ({
categoryId,
categoryLabel: value.categoryLabel,
rows: value.rows,
}));
}, [catalogRows]);
const totalMinutes = pareto?.totalMinutesLost ?? 0;
const totalStops = useMemo(
() => baseRows.reduce((acc, r) => acc + (r.count ?? 0), 0),
@@ -1365,6 +1443,7 @@ const filteredEvents = useMemo(() => {
e.machineName ?? "",
e.reasonLabel ?? "",
e.reasonCode ?? "",
e.reasonText ?? "",
e.workOrderId ?? "",
e.episodeId ?? "",
]
@@ -1467,7 +1546,7 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
)}
{reasonCode ? (
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-white">
Reason: {reasonCode}
Reason: {selectedReasonLabel ?? reasonCode}
<button
className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[11px] text-zinc-200 hover:bg-white/10"
onClick={() => setParams({ reasonCode: null })}
@@ -1805,7 +1884,7 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
className="mt-4 h-[360px] rounded-3xl border border-white/10 bg-black/30 p-4 backdrop-blur"
style={{ boxShadow: "var(--app-chart-shadow)" }}
>
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer width="100%" height="100%" minHeight={200}>
<ComposedChart
data={heroData}
onClick={(st: any) => {
@@ -1883,6 +1962,45 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
</div>
</div>
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-3">
<div className="text-xs font-semibold text-white">Downtime reason menu</div>
<div className="mt-1 text-[11px] text-zinc-400">
From settings or `downtime_menu.md` fallback
</div>
{catalogErr ? (
<div className="mt-2 text-[11px] text-rose-300">{catalogErr}</div>
) : null}
<div className="mt-3 max-h-[180px] space-y-2 overflow-y-auto no-scrollbar pr-1">
{catalogByCategory.map((group) => (
<div key={group.categoryId} className="rounded-xl border border-white/10 bg-white/5 p-2">
<div className="mb-1 text-[11px] font-semibold text-zinc-300">{group.categoryLabel}</div>
<div className="flex flex-wrap gap-1.5">
{group.rows.map((option) => {
const active = reasonCode === option.reasonCode;
return (
<button
key={option.reasonCode}
onClick={() => setParams({ reasonCode: option.reasonCode })}
className={cn(
"rounded-lg border px-2 py-1 text-[11px]",
active
? "border-emerald-500/40 bg-emerald-500/15 text-emerald-200"
: "border-white/10 bg-black/20 text-zinc-300 hover:bg-white/10"
)}
>
{option.detailLabel}
</button>
);
})}
</div>
</div>
))}
{!catalogErr && catalogByCategory.length === 0 ? (
<div className="text-[11px] text-zinc-500">No reason menu available.</div>
) : null}
</div>
</div>
<div className="mt-4 max-h-[360px] overflow-y-auto no-scrollbar rounded-2xl border border-white/10 bg-black/20">
<div className="grid grid-cols-12 gap-2 border-b border-white/10 px-4 py-3 text-[11px] text-zinc-500">
<div className="col-span-8">Reason</div>
@@ -2162,6 +2280,9 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
<td className="px-4 py-3">
<div className="truncate text-white">{e.reasonLabel}</div>
<div className="mt-1 text-[11px] text-zinc-500">{e.reasonCode}</div>
{e.reasonText && e.reasonText !== e.reasonLabel ? (
<div className="mt-1 text-[11px] text-zinc-400">{e.reasonText}</div>
) : null}
</td>
<td className="px-4 py-3 text-zinc-200">{e.workOrderId ?? "—"}</td>
<td className="px-4 py-3 text-right text-white">

View File

@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { Menu } from "lucide-react";
import { Sidebar } from "@/components/layout/Sidebar";
import { RouteAudit } from "@/components/perf/RouteAudit";
import { UtilityControls } from "@/components/layout/UtilityControls";
import { useI18n } from "@/lib/i18n/useI18n";
@@ -31,6 +32,7 @@ export function AppShell({
return (
<div className="h-screen overflow-hidden bg-black text-white">
<RouteAudit />
<div className="flex h-full">
<Sidebar />
<div className="flex h-full flex-1 flex-col">

View File

@@ -2,12 +2,14 @@
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { BarChart3, Bell, DollarSign, LayoutGrid, LogOut, Settings, Wrench, X } from "lucide-react";
import { useEffect, useMemo, useState, useTransition } from "react";
import { BarChart3, Bell, DollarSign, LayoutGrid, Loader2, LogOut, Settings, Wrench, X } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useI18n } from "@/lib/i18n/useI18n";
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
const PERF_ENABLED = process.env.NEXT_PUBLIC_PERF_LOGS === "1";
const NAV_MARK_KEY = "perf_nav_start";
type NavItem = {
href: string;
@@ -38,6 +40,8 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
const router = useRouter();
const { t } = useI18n();
const { screenlessMode } = useScreenlessMode();
const [isPending, startTransition] = useTransition();
const [pendingHref, setPendingHref] = useState<string | null>(null);
const [me, setMe] = useState<{
user?: { name?: string | null; email?: string | null };
org?: { name?: string | null };
@@ -93,13 +97,33 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
}
}, [screenlessMode, pathname, router]);
useEffect(() => {
visibleItems.forEach((it) => {
router.prefetch(it.href);
});
}, [router, visibleItems]);
if (!pendingHref) return;
if (pathname === pendingHref || pathname.startsWith(`${pendingHref}/`)) {
setPendingHref(null);
} else if (!isPending) {
setPendingHref(null);
}
}, [pathname, pendingHref, isPending]);
const markNavStart = (href: string) => {
if (!PERF_ENABLED) return;
try {
sessionStorage.setItem(
NAV_MARK_KEY,
JSON.stringify({
href,
from: pathname,
ts: Date.now(),
})
);
} catch {
// ignore
}
};
// Prefetch disabled: Next.js 16 has RSC prefetch bugs that can cause 404 on
// client-side navigation (see e.g. vercel/next.js#85374). Use fresh fetch on click.
const shellClass = [
"relative z-20 flex flex-col border-r border-white/10 bg-black/40 shrink-0",
variant === "desktop" ? "hidden md:flex h-screen w-64" : "flex h-full w-72 max-w-[85vw]",
@@ -126,23 +150,53 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
<nav className="px-3 py-2 flex-1 space-y-1">
{visibleItems.map((it) => {
const active = pathname === it.href || pathname.startsWith(it.href + "/");
const isCurrent = pathname === it.href;
const active = isCurrent || pathname.startsWith(it.href + "/");
const isPendingItem = isPending && pendingHref === it.href;
const navLocked = isPending;
const Icon = it.icon;
return (
<Link
key={it.href}
href={it.href}
onMouseEnter={() => router.prefetch(it.href)}
onClick={onNavigate}
prefetch={false}
aria-disabled={navLocked}
onClick={(event) => {
if (
navLocked ||
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.altKey ||
event.ctrlKey ||
event.shiftKey
) {
return;
}
if (isCurrent) {
onNavigate?.();
return;
}
event.preventDefault();
markNavStart(it.href);
setPendingHref(it.href);
startTransition(() => {
router.push(it.href);
});
onNavigate?.();
}}
className={[
"flex items-center gap-3 rounded-xl px-3 py-2 text-sm transition",
active
? "bg-emerald-500/15 text-emerald-300 border border-emerald-500/20"
: "text-zinc-300 hover:bg-white/5 hover:text-white",
navLocked ? "pointer-events-none" : "",
navLocked && !isPendingItem ? "opacity-60" : "",
].join(" ")}
>
<Icon className="h-4 w-4" />
<span>{t(it.labelKey)}</span>
{isPendingItem ? <Loader2 className="ml-auto h-4 w-4 animate-spin text-emerald-300" /> : null}
</Link>
);
})}

View File

@@ -0,0 +1,68 @@
"use client";
import { useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
const PERF_ENABLED = process.env.NEXT_PUBLIC_PERF_LOGS === "1";
const STORAGE_KEY = "perf_nav_start";
type NavMark = {
href?: string;
from?: string;
ts: number;
};
function readNavMark(): NavMark | null {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as NavMark;
if (!parsed || typeof parsed.ts !== "number") return null;
return parsed;
} catch {
return null;
}
}
function clearNavMark() {
try {
sessionStorage.removeItem(STORAGE_KEY);
} catch {
// ignore
}
}
export function RouteAudit() {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
if (!PERF_ENABLED) return;
const params = searchParams?.toString();
const to = params ? `${pathname}?${params}` : pathname;
const mark = readNavMark();
if (!mark) return;
const durationMs = Date.now() - mark.ts;
const payload = {
from: mark.from ?? "",
to,
href: mark.href ?? "",
durationMs,
startedAt: mark.ts,
};
console.info("[perf.nav]", payload);
fetch("/api/debug/perf", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event: "nav", data: payload }),
keepalive: true,
}).catch(() => {});
clearNavMark();
}, [pathname, searchParams]);
return null;
}

View File

@@ -333,6 +333,20 @@ Main KPIs remain English in ES-MX (OEE, KPI, SKU, AVAILABILITY, PERFORMANCE, QUA
| settings.shiftCompLabel | Shift change compensation (min) | Compensación por cambio de turno (min) |
| settings.lunchBreakLabel | Lunch break (min) | Comida (min) |
| settings.shift.defaultName | Shift {index} | Turno {index} |
| settings.shiftOverrides.title | Day-specific shifts | Turnos por día |
| settings.shiftOverrides.subtitle | Optional overrides for individual days. | Sobrescrituras opcionales por día. |
| settings.shiftOverrides.useDefault | Use default | Usar predeterminado |
| settings.shiftOverrides.customize | Customize | Personalizar |
| settings.shiftOverrides.inherits | Uses default shift schedule. | Usa el horario de turnos predeterminado. |
| settings.shiftOverrides.dayOff | Day off (no shifts) | Día libre (sin turnos) |
| settings.shiftOverrides.clear | Clear shifts | Borrar turnos |
| settings.shiftOverrides.mon | Monday | Lunes |
| settings.shiftOverrides.tue | Tuesday | Martes |
| settings.shiftOverrides.wed | Wednesday | Miércoles |
| settings.shiftOverrides.thu | Thursday | Jueves |
| settings.shiftOverrides.fri | Friday | Viernes |
| settings.shiftOverrides.sat | Saturday | Sábado |
| settings.shiftOverrides.sun | Sunday | Domingo |
| settings.thresholds | Alert thresholds | Umbrales de alertas |
| settings.thresholdsSubtitle | Tune production health alerts. | Ajusta alertas de salud de producción. |
| settings.thresholds.appliesAll | Applies to all machines | Aplica a todas las máquinas |

264
downtime_menu.md Normal file
View File

@@ -0,0 +1,264 @@
Downtime
Material / Falta de material
Material / Material incorrecto
Material / Material contaminado
Material / Atasco de material
Material / Cambio de material
Material / Otro
Proceso / Temperatura fuera de rango
Proceso / Parámetros incorrectos
Proceso / Ajuste de proceso
Proceso / Arranque o estabilización
Proceso / Proceso inestable
Proceso / Otro
Calidad / Inspección de calidad
Calidad / Defecto detectado
Calidad / Espera de liberación
Calidad / Rechazo de producción
Calidad / Validación de primera pieza
Calidad / Otro
Seguridad / Paro de seguridad
Seguridad / Guarda o puerta abierta
Seguridad / Sensor de seguridad activado
Seguridad / Bloqueo y etiquetado
Seguridad / Reset de seguridad
Seguridad / Otro
Molde / Cambio de molde
Molde / Ajuste de molde
Molde / Limpieza de molde
Molde / Falla de molde
Molde / Problema de expulsión
Molde / Otro
Máquina / Alarma de máquina
Máquina / Falla eléctrica
Máquina / Falla mecánica
Máquina / Falla neumática o hidráulica
Máquina / Reinicio de máquina
Máquina / Otro
Automatización / Falla de robot
Automatización / Falla de sensor
Automatización / Pérdida de comunicación
Automatización / Atasco de pieza
Automatización / Reset de celda
Automatización / Otro
Operación / Falta de operador
Operación / Error de operación
Operación / Cambio de turno
Operación / Espera de apoyo
Operación / Limpieza o ajuste
Operación / Otro
Servicios / Falta de energía
Servicios / Baja presión de aire
Servicios / Falta de agua o enfriamiento
Servicios / Falla de red o comunicación
Servicios / Utilidad fuera de rango
Servicios / Otro
Scrap
Material / Material incorrecto
Material / Material contaminado
Material / Humedad de material
Material / Mezcla incorrecta
Material / Color incorrecto
Material / Otro
Proceso / Parámetros incorrectos
Proceso / Temperatura incorrecta
Proceso / Presión incorrecta
Proceso / Tiempo incorrecto
Proceso / Proceso inestable
Proceso / Otro
Calidad / Defecto visual
Calidad / Defecto dimensional
Calidad / No cumple especificación
Calidad / Defecto detectado en inspección
Calidad / Pieza no liberada
Calidad / Otro
Molde / Rebaba
Molde / Falta de llenado
Molde / Problema de expulsión
Molde / Desalineación
Molde / Daño de molde
Molde / Otro
Manipulación / Pieza golpeada
Manipulación / Pieza rayada
Manipulación / Pieza deformada
Manipulación / Daño por robot
Manipulación / Daño por operador
Manipulación / Otro
Already implemented in node-red side:
### Summary
Implementaremos captura obligatoria de razón en pantalla táctil para microstop, macrostop y scrap (no para slow-cycle en v1), usando un selector breadcrumb en español de **2 niveles**.
La taxonomía vendrá de **Control Tower settings** con **fallback a caché local**.
La razón seleccionada viajará a Control Tower **enriqueciendo el payload actual de event** (/api/ingest/event).
Para macrostops con refrescos periódicos, pediremos razón **una sola vez por incidente**.
### Key Changes
- **Catálogo de razones (backend settings + cache local)**
- Extender el flujo de Apply settings + update UI para aceptar y persistir (memory + file context) un catálogo versionado:
- reasonCatalog.downtime (árbol 2 niveles)
- reasonCatalog.scrap (árbol 2 niveles)
- Enviar al UI un nuevo topic (reasonCatalogData) para hidratar selector.
- Si CT no responde catálogo, usar última versión en caché local; no bloquear operación.
- **UI táctil (breadcrumb)**
- Reusar UI global de anomalías + Home para abrir modal de razón con botones touch-first (mínimo 64px de alto, grid compacto).
- Breadcrumb de 2 pasos:
- Paso 1: categoría
- Paso 2: subrazón
- **Micro/Macro**: al presionar ACK, primero abrir selector de razón; al confirmar, enviar submit + ACK.
- **Scrap**: después de capturar cantidad (numpad), abrir selector de razón scrap antes de confirmar envío final.
- Evitar prompts repetidos en macro refresh usando incidentKey en frontend/backend (once per incident).
- **Mensajería Node-RED (interfaces nuevas)**
- Nuevos mensajes desde UI:
- topic: "anomaly-reason-submit" con { event_id, incidentKey, reasonPath, reasonText, reasonType: "downtime" }
- action: "scrap-entry-with-reason" con { id, scrap, reasonPath, reasonText, reasonType: "scrap" }
- Mantener compatibilidad con rutas actuales (acknowledge-anomaly, scrap-entry) durante transición v1.
- Enriquecer eventos enviados por outbox con campos de razón:
- event.reason = { type, categoryId, categoryLabel, detailId, detailLabel, catalogVersion, incidentKey }
- **Persistencia local y trazabilidad**
- Guardar razón en anomaly_events sin migración (v1) dentro de data_json y/o notes al momento de submit.
- Para scrap, persistir razón junto con evento outbox y opcionalmente en work_orders flujo de actualización si ya existe payload contextual.
- No usar stop_events en v1 (tabla existe pero hoy no está integrada al pipeline activo).
### API / Interface Additions
- **Settings contract (Control Tower -> Edge)**: agregar bloque reasonCatalog con árboles downtime y scrap, y version.
- **Edge event payload (Edge -> Control Tower)**: agregar objeto reason dentro de event cuando aplique.
- **Node-RED UI topics/actions nuevos**:
- reasonCatalogData
- anomaly-reason-submit
- scrap-entry-with-reason
### Test Plan
- **Catalog + fallback**
- Con catálogo remoto disponible: UI muestra opciones correctas en español.
- Sin catálogo remoto: UI usa caché local previa y sigue operando.
- **Downtime reason flow**
- Microstop: ACK obliga razón, envía 1 evento con razón, actualiza estado local.
- Macrostop refrescado: solo primer ACK del incidente solicita razón; refrescos posteriores no repiten prompt.
- **Scrap reason flow**
- Scrap manual: cantidad + razón obligatoria, persistencia local correcta y outbox event enriquecido.
- **Outbox / CT integration**
- outbox_messages para msg_type=event incluye event.reason con shape esperado.
- Retries no pierden razón (payload intacto tras reintentos).
- **UX touch**
- Botones utilizables en raspi touch (tap error bajo, sin overflow en 1280x800).
- Breadcrumb claro y navegable (atrás/adelante) sin bloquear otras pantallas fuera del modal.
### Assumptions
- Control Tower aceptará el enriquecimiento de event.reason en el endpoint actual /api/ingest/event.
- El catálogo remoto será entregado desde settings de máquina/org y versionado.
- En v1 no se requiere migración SQL; razón local se serializa en campos existentes.
- slow-cycle permanece informativo sin razón obligatoria (según decisión actual)
Click-Through Runbook (what to test on screen)
Trigger a macrostop or microstop alert.
Tap Acknowledge on anomaly panel/popup.
Confirm downtime reason modal appears (Paso 1 category).
Pick category -> confirm step 2 (subreason) appears.
Pick subreason.
Confirm:
alert is removed
no re-prompt on same macro incident refresh (incidentKey once-per-incident)
event is queued as type=event to /api/ingest/event
event payload includes both event.reason and event.downtime.
Open scrap modal from Home.
Enter scrap qty and submit.
Confirm scrap reason modal appears (Paso 1 -> Paso 2).
Pick subreason and submit.
Confirm:
work_orders.scrap_parts updates
event is queued as type=event
payload includes event.reason and event.downtime: null.
Exact JSON sent to CT (POST /api/ingest/event)
This is the HTTP body from outbox publisher (payload_json envelope).
A) Downtime reason acknowledgment event
{
"schemaVersion": "1.0",
"machineId": "M-EDGE-01",
"tsMs": 1710001234567,
"seq": "901",
"type": "event",
"payload": {
"event": {
"tsMs": 1710001234567,
"eventType": "downtime-acknowledged",
"anomalyType": "macrostop",
"eventId": 1710001112222,
"incidentKey": "macrostop:WO-100:1710000000000",
"reason": {
"type": "downtime",
"categoryId": "mecanico",
"categoryLabel": "Mecanico",
"detailId": "hidraulico",
"detailLabel": "Hidraulico",
"reasonText": "Mecanico > Hidraulico",
"catalogVersion": 3,
"incidentKey": "macrostop:WO-100:1710000000000"
},
"downtime": {
"incidentKey": "macrostop:WO-100:1710000000000",
"eventId": 1710001112222,
"anomalyType": "macrostop",
"acknowledgedAtMs": 1710001234567,
"reason": {
"type": "downtime",
"categoryId": "mecanico",
"categoryLabel": "Mecanico",
"detailId": "hidraulico",
"detailLabel": "Hidraulico",
"reasonText": "Mecanico > Hidraulico",
"catalogVersion": 3,
"incidentKey": "macrostop:WO-100:1710000000000"
}
}
}
}
}
B) Scrap manual entry with reason
{
"schemaVersion": "1.0",
"machineId": "M-EDGE-01",
"tsMs": 1776472069609,
"seq": "902",
"type": "event",
"payload": {
"event": {
"tsMs": 1776472069609,
"eventType": "scrap-manual-entry",
"workOrderId": "WO-100",
"scrapDelta": 4,
"source": "home-ui",
"reason": {
"type": "scrap",
"categoryId": "calidad",
"categoryLabel": "Calidad",
"detailId": "rebaba",
"detailLabel": "Rebaba",
"reasonText": "Calidad > Rebaba",
"catalogVersion": 3
}
}
}
}

4427
flows_file.json Normal file

File diff suppressed because one or more lines are too long

View File

@@ -46,6 +46,19 @@ function readBool(value: unknown) {
return value === true;
}
function normalizeStatus(value?: string | null) {
if (!value) return null;
const raw = value.trim().toLowerCase();
if (!raw) return null;
if (raw === "in_progress" || raw === "in-progress" || raw === "open" || raw === "activa" || raw === "activo") {
return "active";
}
if (raw === "resuelta" || raw === "resuelto" || raw === "closed" || raw === "ended" || raw === "done") {
return "resolved";
}
return raw;
}
function extractDurationSec(raw: unknown): number | null {
const payload = asRecord(raw);
if (!payload) return null;
@@ -302,10 +315,11 @@ export async function evaluateAlertsForEvent(eventId: string) {
const alertId = readString(payload?.alert_id ?? inner?.alert_id);
const isUpdate = readBool(payload?.is_update ?? inner?.is_update);
const isAutoAck = readBool(payload?.is_auto_ack ?? inner?.is_auto_ack);
const status = normalizeStatus(readString(payload?.status ?? inner?.status));
const lastCycleTs = readNumber(payload?.last_cycle_timestamp ?? inner?.last_cycle_timestamp);
const theoreticalSec = readNumber(payload?.theoretical_cycle_time ?? inner?.theoretical_cycle_time);
if (isAutoAck) return;
if (isUpdate && !(rule.repeatMinutes && rule.repeatMinutes > 0)) return;
if (isUpdate && status !== "resolved") return;
if ((eventType === "microstop" || eventType === "macrostop") && theoreticalSec && lastCycleTs == null) {
return;
}
@@ -345,9 +359,11 @@ export async function evaluateAlertsForEvent(eventId: string) {
const key = `${channel}:${recipient.userId ?? recipient.contactId ?? recipient.email ?? recipient.phone ?? ""}`;
if (delivered.has(key)) continue;
const statusKey = status === "resolved" ? "resolved" : "active";
const ruleKey = `${rule.id}:${statusKey}`;
const allowed = await shouldSendNotification({
eventIds: notificationEventIds,
ruleId: rule.id,
ruleId: ruleKey,
role: roleName,
channel,
contactId: recipient.contactId,
@@ -376,7 +392,7 @@ export async function evaluateAlertsForEvent(eventId: string) {
machineId: event.machineId,
eventId: event.id,
eventType,
ruleId: rule.id,
ruleId: ruleKey,
role: roleName,
channel,
contactId: recipient.contactId,
@@ -391,7 +407,7 @@ export async function evaluateAlertsForEvent(eventId: string) {
machineId: event.machineId,
eventId: event.id,
eventType,
ruleId: rule.id,
ruleId: ruleKey,
role: roleName,
channel,
contactId: recipient.contactId,

View File

@@ -1,3 +1,4 @@
import { normalizeShiftOverrides } from "@/lib/settings";
import { prisma } from "@/lib/prisma";
const RANGE_MS: Record<string, number> = {
@@ -21,6 +22,26 @@ type AlertsInboxParams = {
limit?: number;
};
type AlertsInboxEvent = {
id: string;
ts: Date;
eventType: string;
severity: string;
title: string;
description?: string | null;
machineId: string;
machineName?: string | null;
location?: string | null;
workOrderId?: string | null;
sku?: string | null;
durationSec?: number | null;
status?: string | null;
shift?: string | null;
alertId?: string | null;
isUpdate?: boolean;
isAutoAck?: boolean;
};
function pickRange(range: string, start?: Date | null, end?: Date | null) {
const now = new Date();
if (range === "custom") {
@@ -50,6 +71,19 @@ function safeBool(value: unknown) {
return value === true;
}
function normalizeStatus(value?: string | null) {
if (!value) return null;
const raw = value.trim().toLowerCase();
if (!raw) return null;
if (raw === "in_progress" || raw === "in-progress" || raw === "open" || raw === "activa" || raw === "activo") {
return "active";
}
if (raw === "resuelta" || raw === "resuelto" || raw === "closed" || raw === "ended" || raw === "done") {
return "resolved";
}
return raw;
}
function parsePayload(raw: unknown) {
let parsed: unknown = raw;
if (typeof raw === "string") {
@@ -131,17 +165,54 @@ function getLocalMinutes(ts: Date, timeZone: string) {
}
}
const WEEKDAY_KEY_MAP: Record<string, string> = {
Sun: "sun",
Mon: "mon",
Tue: "tue",
Wed: "wed",
Thu: "thu",
Fri: "fri",
Sat: "sat",
};
const WEEKDAY_KEYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
function getLocalDayKey(ts: Date, timeZone: string) {
try {
const weekday = new Intl.DateTimeFormat("en-US", {
timeZone,
weekday: "short",
}).format(ts);
return WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()];
} catch {
return WEEKDAY_KEYS[ts.getUTCDay()];
}
}
type ShiftLike = {
name: string;
startTime?: string | null;
endTime?: string | null;
start?: string | null;
end?: string | null;
enabled?: boolean;
};
function resolveShift(
shifts: Array<{ name: string; startTime: string; endTime: string; enabled?: boolean }>,
shifts: ShiftLike[],
overrides: Record<string, ShiftLike[]> | undefined,
ts: Date,
timeZone: string
) {
if (!shifts.length) return null;
const dayKey = getLocalDayKey(ts, timeZone);
const dayOverrides = overrides?.[dayKey];
const activeShifts = dayOverrides ?? shifts;
if (!activeShifts.length) return null;
const nowMin = getLocalMinutes(ts, timeZone);
for (const shift of shifts) {
for (const shift of activeShifts) {
if (shift.enabled === false) continue;
const start = parseTimeMinutes(shift.startTime);
const end = parseTimeMinutes(shift.endTime);
const start = parseTimeMinutes(shift.startTime ?? shift.start ?? null);
const end = parseTimeMinutes(shift.endTime ?? shift.end ?? null);
if (start == null || end == null) continue;
if (start <= end) {
if (nowMin >= start && nowMin < end) return shift.name;
@@ -152,6 +223,34 @@ function resolveShift(
return null;
}
function collapseAlertEvents(events: AlertsInboxEvent[]) {
const byAlert = new Map<string, AlertsInboxEvent>();
const passthrough: AlertsInboxEvent[] = [];
for (const ev of events) {
if (!ev.alertId) {
passthrough.push(ev);
continue;
}
const statusKey = ev.status === "resolved" ? "resolved" : "active";
const key = `${ev.alertId}:${statusKey}`;
const existing = byAlert.get(key);
if (!existing) {
byAlert.set(key, ev);
continue;
}
const pickNewest = statusKey === "resolved";
const shouldReplace = pickNewest
? ev.ts.getTime() > existing.ts.getTime()
: ev.ts.getTime() < existing.ts.getTime();
if (shouldReplace) byAlert.set(key, ev);
}
const combined = [...passthrough, ...byAlert.values()];
combined.sort((a, b) => b.ts.getTime() - a.ts.getTime());
return combined;
}
export async function getAlertsInboxData(params: AlertsInboxParams) {
const {
orgId,
@@ -213,12 +312,13 @@ export async function getAlertsInboxData(params: AlertsInboxParams) {
}),
prisma.orgSettings.findUnique({
where: { orgId },
select: { timezone: true },
select: { timezone: true, shiftScheduleOverridesJson: true },
}),
]);
const timeZone = settings?.timezone || "UTC";
const mapped = [];
const shiftOverrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson);
const mapped: AlertsInboxEvent[] = [];
for (const ev of events) {
const { payload, inner } = parsePayload(ev.data);
@@ -227,10 +327,10 @@ export async function getAlertsInboxData(params: AlertsInboxParams) {
const isAutoAck = safeBool(payload?.is_auto_ack ?? inner?.is_auto_ack);
if (!includeUpdates && (isUpdate || isAutoAck)) continue;
const shiftName = resolveShift(shifts, ev.ts, timeZone);
const shiftName = resolveShift(shifts, shiftOverrides, ev.ts, timeZone);
if (normalizedShift && shiftName !== normalizedShift) continue;
const statusLabel = rawStatus ? rawStatus.toLowerCase() : "unknown";
const statusLabel = normalizeStatus(rawStatus) ?? "unknown";
if (normalizedStatus && statusLabel !== normalizedStatus) continue;
mapped.push({
@@ -254,8 +354,10 @@ export async function getAlertsInboxData(params: AlertsInboxParams) {
});
}
const finalEvents = includeUpdates ? mapped : collapseAlertEvents(mapped);
return {
range: { range: picked.range, start: picked.start, end: picked.end },
events: mapped,
events: finalEvents,
};
}

View File

@@ -1,13 +1,56 @@
import { cookies } from "next/headers";
import { prisma } from "@/lib/prisma";
import { logLine } from "@/lib/logger";
const COOKIE_NAME = "mis_session";
const SESSION_CACHE_TTL_MS = 30000;
const LAST_SEEN_TTL_MS = 300000;
type SessionPayload = {
sessionId: string;
userId: string;
orgId: string;
};
type CachedSession = {
value: SessionPayload;
expiresAt: number;
};
const sessionCache = new Map<string, CachedSession>();
const lastSeenCache = new Map<string, number>();
function readCache(sessionId: string, now: number) {
const cached = sessionCache.get(sessionId);
if (!cached) return null;
if (cached.expiresAt <= now) {
sessionCache.delete(sessionId);
return null;
}
return cached.value;
}
function writeCache(sessionId: string, value: SessionPayload, now: number) {
sessionCache.set(sessionId, { value, expiresAt: now + SESSION_CACHE_TTL_MS });
}
function shouldUpdateLastSeen(sessionId: string, now: number) {
const last = lastSeenCache.get(sessionId) ?? 0;
if (now - last < LAST_SEEN_TTL_MS) return false;
lastSeenCache.set(sessionId, now);
return true;
}
export async function requireSession() {
try {
const jar = await cookies();
const sessionId = jar.get(COOKIE_NAME)?.value;
if (!sessionId) return null;
const now = Date.now();
const cached = readCache(sessionId, now);
if (cached) return cached;
const session = await prisma.session.findFirst({
where: {
id: sessionId,
@@ -24,20 +67,32 @@ export async function requireSession() {
if (!session) return null;
if (!session.user?.isActive || !session.user?.emailVerifiedAt) {
await prisma.session
void prisma.session
.update({ where: { id: session.id }, data: { revokedAt: new Date() } })
.catch(() => {});
sessionCache.delete(sessionId);
lastSeenCache.delete(sessionId);
return null;
}
// Optional: update lastSeenAt (useful later)
await prisma.session
if (shouldUpdateLastSeen(sessionId, now)) {
void prisma.session
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
.catch(() => {});
}
return {
const payload = {
sessionId: session.id,
userId: session.userId,
orgId: session.orgId,
};
writeCache(sessionId, payload, now);
return payload;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
logLine("requireSession.error", { message, stack });
console.error("[requireSession]", err);
return null;
}
}

63
lib/financial/cache.ts Normal file
View File

@@ -0,0 +1,63 @@
import { unstable_cache } from "next/cache";
import { prisma } from "@/lib/prisma";
import { computeFinancialImpact, type FinancialImpactParams } from "@/lib/financial/impact";
export const FINANCIAL_CONFIG_TTL_SEC = 15;
export const FINANCIAL_CONFIG_SWR_SEC = 45;
export const FINANCIAL_IMPACT_TTL_SEC = 10;
export const FINANCIAL_IMPACT_SWR_SEC = 30;
async function loadFinancialConfig(orgId: string) {
const [org, locations, machines, products] = await Promise.all([
prisma.orgFinancialProfile.findUnique({ where: { orgId } }),
prisma.locationFinancialOverride.findMany({ where: { orgId }, orderBy: { location: "asc" } }),
prisma.machineFinancialOverride.findMany({ where: { orgId }, orderBy: { createdAt: "desc" } }),
prisma.productCostOverride.findMany({ where: { orgId }, orderBy: { sku: "asc" } }),
]);
return { org, locations, machines, products };
}
export type FinancialConfigPayload = Awaited<ReturnType<typeof loadFinancialConfig>>;
export async function getFinancialConfig(orgId: string, options?: { refresh?: boolean }) {
if (options?.refresh) {
return loadFinancialConfig(orgId);
}
const cached = unstable_cache(
() => loadFinancialConfig(orgId),
["financial-config", orgId],
{ revalidate: FINANCIAL_CONFIG_TTL_SEC, tags: [`financial-config:${orgId}`] }
);
return cached();
}
export async function getFinancialImpactCached(
params: FinancialImpactParams,
options?: { refresh?: boolean }
) {
if (options?.refresh) {
return computeFinancialImpact(params);
}
const keyParts = [
"financial-impact",
params.orgId,
String(params.start.getTime()),
String(params.end.getTime()),
params.machineId ?? "",
params.location ?? "",
params.sku ?? "",
params.currency ?? "",
params.includeEvents ? "1" : "0",
];
const cached = unstable_cache(
() => computeFinancialImpact(params),
keyParts,
{ revalidate: FINANCIAL_IMPACT_TTL_SEC, tags: [`financial-impact:${params.orgId}`] }
);
return cached();
}

View File

@@ -398,6 +398,20 @@
"settings.shiftCompLabel": "Shift change compensation (min)",
"settings.lunchBreakLabel": "Lunch break (min)",
"settings.shift.defaultName": "Shift {index}",
"settings.shiftOverrides.title": "Day-specific shifts",
"settings.shiftOverrides.subtitle": "Optional overrides for individual days.",
"settings.shiftOverrides.useDefault": "Use default",
"settings.shiftOverrides.customize": "Customize",
"settings.shiftOverrides.inherits": "Uses default shift schedule.",
"settings.shiftOverrides.dayOff": "Day off (no shifts)",
"settings.shiftOverrides.clear": "Clear shifts",
"settings.shiftOverrides.mon": "Monday",
"settings.shiftOverrides.tue": "Tuesday",
"settings.shiftOverrides.wed": "Wednesday",
"settings.shiftOverrides.thu": "Thursday",
"settings.shiftOverrides.fri": "Friday",
"settings.shiftOverrides.sat": "Saturday",
"settings.shiftOverrides.sun": "Sunday",
"settings.thresholds": "Alert thresholds",
"settings.thresholdsSubtitle": "Tune production health alerts.",
"settings.thresholds.appliesAll": "Applies to all machines",
@@ -457,6 +471,7 @@
"financial.costsMovedLink": "Settings -> Financial",
"financial.export.html": "HTML",
"financial.export.csv": "CSV",
"financial.refresh": "Refresh",
"financial.totalLoss": "Total Loss",
"financial.currencyLabel": "Currency: {currency}",
"financial.noImpact": "No impact data yet.",

View File

@@ -398,6 +398,20 @@
"settings.shiftCompLabel": "Compensación por cambio de turno (min)",
"settings.lunchBreakLabel": "Comida (min)",
"settings.shift.defaultName": "Turno {index}",
"settings.shiftOverrides.title": "Turnos por día",
"settings.shiftOverrides.subtitle": "Sobrescrituras opcionales por día.",
"settings.shiftOverrides.useDefault": "Usar predeterminado",
"settings.shiftOverrides.customize": "Personalizar",
"settings.shiftOverrides.inherits": "Usa el horario de turnos predeterminado.",
"settings.shiftOverrides.dayOff": "Día libre (sin turnos)",
"settings.shiftOverrides.clear": "Borrar turnos",
"settings.shiftOverrides.mon": "Lunes",
"settings.shiftOverrides.tue": "Martes",
"settings.shiftOverrides.wed": "Miércoles",
"settings.shiftOverrides.thu": "Jueves",
"settings.shiftOverrides.fri": "Viernes",
"settings.shiftOverrides.sat": "Sábado",
"settings.shiftOverrides.sun": "Domingo",
"settings.thresholds": "Umbrales de alertas",
"settings.thresholdsSubtitle": "Ajusta alertas de salud de producción.",
"settings.thresholds.appliesAll": "Aplica a todas las máquinas",
@@ -457,6 +471,7 @@
"financial.costsMovedLink": "Configuración -> Finanzas",
"financial.export.html": "HTML",
"financial.export.csv": "CSV",
"financial.refresh": "Actualizar",
"financial.totalLoss": "Pérdida total",
"financial.currencyLabel": "Moneda: {currency}",
"financial.noImpact": "Sin datos de impacto.",

View File

@@ -7,6 +7,7 @@ const LOCALE_COOKIE = "mis_locale";
const LOCALE_EVENT = "mis-locale-change";
function readCookieLocale(): Locale | null {
if (typeof document === "undefined") return null;
const match = document.cookie
.split(";")
.map((part) => part.trim())
@@ -18,6 +19,7 @@ function readCookieLocale(): Locale | null {
}
function readLocale(): Locale {
if (typeof document === "undefined") return defaultLocale;
const docLang = document.documentElement.getAttribute("lang");
if (docLang === "es-MX" || docLang === "en") return docLang;
return readCookieLocale() ?? defaultLocale;

View File

@@ -3,6 +3,10 @@ import path from "path";
const LOG_PATH = process.env.LOG_FILE || "/tmp/mis-control-tower.log";
export function getLogPath() {
return LOG_PATH;
}
export function logLine(event: string, data: Record<string, unknown> = {}) {
const line = JSON.stringify({
ts: new Date().toISOString(),

View File

@@ -2,7 +2,7 @@ import { prisma } from "@/lib/prisma";
type MachineAuth = { id: string; orgId: string };
const TTL_MS = 60_000;
const TTL_MS = 10_000;
const MAX_SIZE = 1000;
const cache = new Map<string, { value: MachineAuth; expiresAt: number }>();
@@ -36,3 +36,12 @@ export async function getMachineAuth(machineId: string, apiKey: string) {
cache.set(key, { value: machine, expiresAt: now + TTL_MS });
return machine;
}
export function invalidateMachineAuth(machineId: string) {
const prefix = `${machineId}:`;
for (const key of cache.keys()) {
if (key.startsWith(prefix)) {
cache.delete(key);
}
}
}

113
lib/machines/withLatest.ts Normal file
View File

@@ -0,0 +1,113 @@
import { prisma } from "@/lib/prisma";
import type { OverviewMachineRow } from "@/lib/overview/types";
type MachineBaseRow = Pick<
OverviewMachineRow,
"id" | "name" | "code" | "location" | "createdAt" | "updatedAt"
>;
type LatestHeartbeatRow = {
machineId: string;
ts: Date;
tsServer: Date | null;
status: string;
message?: string | null;
ip?: string | null;
fwVersion?: string | null;
};
type LatestKpiRow = {
machineId: string;
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 async function fetchMachineBase(orgId: string): Promise<MachineBaseRow[]> {
return prisma.machine.findMany({
where: { orgId },
orderBy: { createdAt: "desc" },
select: {
id: true,
name: true,
code: true,
location: true,
createdAt: true,
updatedAt: true,
},
});
}
export async function fetchLatestHeartbeats(
orgId: string,
machineIds: string[]
): Promise<LatestHeartbeatRow[]> {
if (!machineIds.length) return [];
return prisma.machineHeartbeat.findMany({
where: { orgId, machineId: { in: machineIds } },
orderBy: [{ machineId: "asc" }, { tsServer: "desc" }],
distinct: ["machineId"],
select: {
machineId: true,
ts: true,
tsServer: true,
status: true,
message: true,
ip: true,
fwVersion: true,
},
});
}
export async function fetchLatestKpis(
orgId: string,
machineIds: string[]
): Promise<LatestKpiRow[]> {
if (!machineIds.length) return [];
return prisma.machineKpiSnapshot.findMany({
where: { orgId, machineId: { in: machineIds } },
orderBy: [{ machineId: "asc" }, { ts: "desc" }],
distinct: ["machineId"],
select: {
machineId: true,
ts: true,
oee: true,
availability: true,
performance: true,
quality: true,
workOrderId: true,
sku: true,
good: true,
scrap: true,
target: true,
cycleTime: true,
},
});
}
export function mergeMachineOverviewRows(params: {
machines: MachineBaseRow[];
heartbeats: LatestHeartbeatRow[];
kpis?: LatestKpiRow[];
includeKpi?: boolean;
}): OverviewMachineRow[] {
const { machines, heartbeats, kpis = [], includeKpi = false } = params;
const heartbeatMap = new Map(heartbeats.map((row) => [row.machineId, row]));
const kpiMap = new Map(kpis.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,
heartbeats: undefined,
kpiSnapshots: undefined,
}));
}

View File

@@ -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,49 +46,31 @@ 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();
const machineRows = machines.map((m) => ({
...m,
latestHeartbeat: m.heartbeats[0] ?? null,
latestKpi: m.kpiSnapshots[0] ?? null,
heartbeats: undefined,
kpiSnapshots: undefined,
}));
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;
@@ -97,34 +88,24 @@ export async function getOverviewData({
const targetIds = topMachines.map((m) => m.id);
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";
}>;
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,
@@ -148,7 +129,9 @@ export async function getOverviewData({
machine: { select: { name: true } },
},
});
if (perfEnabled) timings.eventsQuery = elapsedMs(eventsStart);
const normalizeStart = nowMs();
const normalized = rawEvents
.map((row) => ({
...normalizeEvent(row, { microMultiplier, macroMultiplier }),
@@ -157,7 +140,9 @@ export async function getOverviewData({
source: "ingested" as const,
}))
.filter((event) => event.ts);
if (perfEnabled) timings.eventsNormalize = elapsedMs(normalizeStart);
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();
@@ -187,7 +172,43 @@ export async function getOverviewData({
});
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,
},
});
}
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,
eventsMode,
eventsWindowSec,
eventMachines,
timings,
message,
stack,
});
}
logLine("getOverviewData.error", { message, stack });
console.error("[getOverviewData]", err);
return { machines: [], events: [] };
}
}

View 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
View 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";
};

18
lib/perf/serverTiming.ts Normal file
View File

@@ -0,0 +1,18 @@
import { performance } from "perf_hooks";
export const PERF_LOGS_ENABLED = process.env.PERF_LOGS === "1";
export function nowMs() {
return performance.now();
}
export function elapsedMs(startMs: number) {
return Math.round((performance.now() - startMs) * 100) / 100;
}
export function formatServerTiming(entries: Record<string, number>) {
return Object.entries(entries)
.filter(([, value]) => Number.isFinite(value))
.map(([name, value]) => `${name};dur=${value.toFixed(1)}`)
.join(", ");
}

200
lib/reasonCatalog.ts Normal file
View File

@@ -0,0 +1,200 @@
import { readFile } from "fs/promises";
import path from "path";
type AnyRecord = Record<string, unknown>;
export type ReasonCatalogKind = "downtime" | "scrap";
export type ReasonCatalogDetail = {
id: string;
label: string;
};
export type ReasonCatalogCategory = {
id: string;
label: string;
details: ReasonCatalogDetail[];
};
export type ReasonCatalog = {
version: number;
downtime: ReasonCatalogCategory[];
scrap: ReasonCatalogCategory[];
};
function isPlainObject(value: unknown): value is AnyRecord {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function canonicalId(input: unknown, fallback = "item") {
const text = String(input ?? "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return text || fallback;
}
function buildReasonCode(categoryId: string, detailId: string) {
return `${canonicalId(categoryId)}__${canonicalId(detailId)}`.toUpperCase();
}
function toCategory(raw: unknown): ReasonCatalogCategory | null {
if (!isPlainObject(raw)) return null;
const labelRaw = String(raw.label ?? "").trim();
if (!labelRaw) return null;
const idRaw = String(raw.id ?? "").trim() || canonicalId(labelRaw, "category");
const detailsRaw =
(Array.isArray(raw.details) && raw.details) ||
(Array.isArray(raw.children) && raw.children) ||
(Array.isArray(raw.items) && raw.items) ||
[];
const details: ReasonCatalogDetail[] = [];
for (const detailRaw of detailsRaw) {
if (!isPlainObject(detailRaw)) continue;
const detailLabel = String(detailRaw.label ?? "").trim();
if (!detailLabel) continue;
const detailId = String(detailRaw.id ?? "").trim() || canonicalId(detailLabel, "detail");
details.push({ id: detailId, label: detailLabel });
}
if (!details.length) return null;
return { id: idRaw, label: labelRaw, details };
}
function normalizeKind(raw: unknown): ReasonCatalogCategory[] {
const arr =
(Array.isArray(raw) && raw) ||
(isPlainObject(raw) && Array.isArray(raw.categories) && raw.categories) ||
[];
const out: ReasonCatalogCategory[] = [];
for (const candidate of arr) {
const parsed = toCategory(candidate);
if (parsed) out.push(parsed);
}
return out;
}
export function normalizeReasonCatalog(raw: unknown): ReasonCatalog | null {
if (!isPlainObject(raw)) return null;
const downtime = normalizeKind(raw.downtime);
const scrap = normalizeKind(raw.scrap);
if (!downtime.length && !scrap.length) return null;
const versionNum = Number(raw.version);
const version = Number.isFinite(versionNum) ? Math.max(1, Math.trunc(versionNum)) : 1;
return {
version,
downtime,
scrap,
};
}
export function parseReasonCatalogMarkdown(markdown: string): ReasonCatalog {
const lines = markdown
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const buckets: Record<ReasonCatalogKind, Map<string, ReasonCatalogCategory>> = {
downtime: new Map(),
scrap: new Map(),
};
let activeKind: ReasonCatalogKind = "downtime";
for (const line of lines) {
const lowered = line.toLowerCase();
if (lowered === "downtime") {
activeKind = "downtime";
continue;
}
if (lowered === "scrap") {
activeKind = "scrap";
continue;
}
const slash = line.indexOf("/");
if (slash < 1 || slash === line.length - 1) continue;
const categoryLabel = line.slice(0, slash).trim();
const detailLabel = line.slice(slash + 1).trim();
if (!categoryLabel || !detailLabel) continue;
const categoryId = canonicalId(categoryLabel, "category");
const detailId = canonicalId(detailLabel, "detail");
const existing =
buckets[activeKind].get(categoryId) ?? {
id: categoryId,
label: categoryLabel,
details: [] as ReasonCatalogDetail[],
};
if (!existing.details.some((d) => d.id === detailId)) {
existing.details.push({ id: detailId, label: detailLabel });
}
buckets[activeKind].set(categoryId, existing);
}
return {
version: 1,
downtime: [...buckets.downtime.values()],
scrap: [...buckets.scrap.values()],
};
}
let catalogPromise: Promise<ReasonCatalog> | null = null;
export async function loadFallbackReasonCatalog() {
if (!catalogPromise) {
catalogPromise = readFile(path.join(process.cwd(), "downtime_menu.md"), "utf8")
.then((raw) => parseReasonCatalogMarkdown(raw))
.catch(() => ({ version: 1, downtime: [], scrap: [] }));
}
return catalogPromise;
}
export function flattenReasonCatalog(catalog: ReasonCatalog, kind: ReasonCatalogKind) {
return (catalog[kind] ?? []).flatMap((category) =>
category.details.map((detail) => ({
kind,
categoryId: category.id,
categoryLabel: category.label,
detailId: detail.id,
detailLabel: detail.label,
reasonCode: buildReasonCode(category.id, detail.id),
reasonLabel: `${category.label} > ${detail.label}`,
}))
);
}
export function findCatalogReason(
catalog: ReasonCatalog | null | undefined,
kind: ReasonCatalogKind,
categoryId: unknown,
detailId: unknown
) {
if (!catalog) return null;
const catId = canonicalId(categoryId, "");
const detId = canonicalId(detailId, "");
if (!catId || !detId) return null;
const category = (catalog[kind] ?? []).find((c) => canonicalId(c.id, "") === catId);
if (!category) return null;
const detail = category.details.find((d) => canonicalId(d.id, "") === detId);
if (!detail) return null;
return {
categoryId: category.id,
categoryLabel: category.label,
detailId: detail.id,
detailLabel: detail.label,
reasonCode: buildReasonCode(category.id, detail.id),
reasonLabel: `${category.label} > ${detail.label}`,
};
}
export function toReasonCode(categoryId: unknown, detailId: unknown) {
const cat = canonicalId(categoryId, "");
const det = canonicalId(detailId, "");
if (!cat || !det) return null;
return buildReasonCode(cat, det);
}

View File

@@ -18,6 +18,9 @@ export const DEFAULT_SHIFT = {
end: "15:00",
};
export const SHIFT_OVERRIDE_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
export type ShiftOverrideDay = (typeof SHIFT_OVERRIDE_DAYS)[number];
type AnyRecord = Record<string, unknown>;
function isPlainObject(value: unknown): value is AnyRecord {
@@ -40,6 +43,7 @@ type SettingsRow = {
timezone: string;
shiftChangeCompMin?: number | null;
lunchBreakMin?: number | null;
shiftScheduleOverridesJson?: unknown;
stoppageMultiplier?: number | null;
macroStoppageMultiplier?: number | null;
oeeAlertThresholdPct?: number | null;
@@ -59,6 +63,13 @@ type ShiftRow = {
sortOrder?: number | null;
};
type ShiftOverridePayload = {
name: string;
start: string;
end: string;
enabled: boolean;
};
export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[]) {
const ordered = [...(shifts ?? [])].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
const mappedShifts = ordered.map((s, idx) => ({
@@ -67,6 +78,13 @@ export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[])
end: s.endTime,
enabled: s.enabled !== false,
}));
const overrides = normalizeShiftOverrides(settings.shiftScheduleOverridesJson);
const defaults = normalizeDefaults(settings.defaultsJson);
const reasonCatalog =
isPlainObject(settings.defaultsJson) && "reasonCatalog" in settings.defaultsJson
? (settings.defaultsJson as AnyRecord).reasonCatalog
: null;
return {
orgId: settings.orgId,
@@ -74,6 +92,7 @@ export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[])
timezone: settings.timezone,
shiftSchedule: {
shifts: mappedShifts,
overrides: overrides && Object.keys(overrides).length ? overrides : undefined,
shiftChangeCompensationMin: settings.shiftChangeCompMin,
lunchBreakMin: settings.lunchBreakMin,
},
@@ -85,7 +104,10 @@ export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[])
qualitySpikeDeltaPct: settings.qualitySpikeDeltaPct,
},
alerts: normalizeAlerts(settings.alertsJson),
defaults: normalizeDefaults(settings.defaultsJson),
defaults,
reasonCatalog: reasonCatalog ?? undefined,
reasonCatalogData: reasonCatalog ?? undefined,
reasonCatalogVersion: Number((reasonCatalog as AnyRecord | null)?.version ?? 1),
updatedAt: settings.updatedAt,
updatedBy: settings.updatedBy,
};
@@ -169,6 +191,57 @@ export function validateShiftSchedule(shifts: unknown) {
return { ok: true, shifts: normalized as NormalizedShift[] };
}
export function validateShiftOverrides(overrides: unknown) {
if (overrides === null) {
return { ok: true, overrides: null as Record<string, ShiftOverridePayload[]> | null } as const;
}
if (!isPlainObject(overrides)) {
return { ok: false, error: "shift overrides must be an object" } as const;
}
const normalized: Record<string, ShiftOverridePayload[]> = {};
for (const [key, value] of Object.entries(overrides)) {
if (!SHIFT_OVERRIDE_DAYS.includes(key as ShiftOverrideDay)) {
return { ok: false, error: `invalid shift override day: ${key}` } as const;
}
const shiftResult = validateShiftSchedule(value);
if (!shiftResult.ok) {
return { ok: false, error: `shift overrides ${key}: ${shiftResult.error}` } as const;
}
normalized[key] =
shiftResult.shifts?.map((s) => ({
name: s.name,
start: s.startTime,
end: s.endTime,
enabled: s.enabled !== false,
})) ?? [];
}
return { ok: true, overrides: normalized } as const;
}
export function normalizeShiftOverrides(raw: unknown) {
if (!isPlainObject(raw)) return undefined;
const out: Record<string, ShiftOverridePayload[]> = {};
for (const day of SHIFT_OVERRIDE_DAYS) {
const value = raw[day];
if (!Array.isArray(value)) continue;
const normalized = value
.map((entry, idx) => {
const record = isPlainObject(entry) ? entry : {};
const start = String(record.start ?? record.startTime ?? "").trim();
const end = String(record.end ?? record.endTime ?? "").trim();
if (!TIME_RE.test(start) || !TIME_RE.test(end)) return null;
const name = String(record.name ?? `Shift ${idx + 1}`).trim() || `Shift ${idx + 1}`;
const enabled = record.enabled !== false;
return { name, start, end, enabled };
})
.filter((entry): entry is ShiftOverridePayload => !!entry);
out[day] = normalized;
}
return out;
}
export function validateShiftFields(shiftChangeCompensationMin?: unknown, lunchBreakMin?: unknown) {
if (shiftChangeCompensationMin != null) {
const v = Number(shiftChangeCompensationMin);

41
middleware.ts Normal file
View File

@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const COOKIE_NAME = "mis_session";
export function middleware(req: NextRequest) {
try {
const { pathname, search } = req.nextUrl;
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/favicon") ||
pathname.startsWith("/public") ||
pathname === "/login" ||
pathname === "/signup" ||
pathname === "/logout" ||
pathname.startsWith("/invite") ||
pathname.startsWith("/api")
) {
return NextResponse.next();
}
const sessionId = req.cookies.get(COOKIE_NAME)?.value;
if (!sessionId) {
const url = req.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("next", pathname + search);
return NextResponse.redirect(url);
}
return NextResponse.next();
} catch (err) {
console.error("[middleware]", err);
return NextResponse.next();
}
}
export const config = {
matcher: ["/((?!_next/static|_next/image).*)"],
};

View File

@@ -3,8 +3,8 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"dev": "next dev --turbopack",
"build": "next build --webpack",
"start": "next start",
"lint": "eslint"
},

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "org_settings" ADD COLUMN "shift_schedule_overrides_json" JSONB;

View File

@@ -146,6 +146,7 @@ model Machine {
@@unique([orgId, name])
@@index([orgId])
@@index([orgId, createdAt])
}
model MachineHeartbeat {
@@ -166,6 +167,7 @@ model MachineHeartbeat {
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
@@index([orgId, machineId, ts])
@@index([orgId, machineId, tsServer])
}
model MachineKpiSnapshot {
@@ -310,6 +312,7 @@ model OrgSettings {
timezone String @default("UTC")
shiftChangeCompMin Int @default(10) @map("shift_change_comp_min")
lunchBreakMin Int @default(30) @map("lunch_break_min")
shiftScheduleOverridesJson Json? @map("shift_schedule_overrides_json")
stoppageMultiplier Float @default(1.5) @map("stoppage_multiplier")
oeeAlertThresholdPct Float @default(90) @map("oee_alert_threshold_pct")
macroStoppageMultiplier Float @default(5) @map("macro_stoppage_multiplier")

View File

@@ -0,0 +1,232 @@
---
name: openscad
description: "Create and render OpenSCAD 3D models. Generate preview images from multiple angles, extract customizable parameters, validate syntax, and export STL files for 3D printing platforms like MakerWorld."
---
# OpenSCAD Skill
Create, validate, and export OpenSCAD 3D models. Supports parameter customization, visual preview from multiple angles, and STL export for 3D printing platforms like MakerWorld.
## Prerequisites
OpenSCAD must be installed. Install via Homebrew:
```bash
brew install openscad
```
## Tools
This skill provides several tools in the `tools/` directory:
### Preview Generation
```bash
# Generate a single preview image
./tools/preview.sh model.scad output.png [--camera=x,y,z,tx,ty,tz,dist] [--size=800x600]
# Generate multi-angle preview (front, back, left, right, top, iso)
./tools/multi-preview.sh model.scad output_dir/
```
### STL Export
```bash
# Export to STL for 3D printing
./tools/export-stl.sh model.scad output.stl [-D 'param=value']
```
### Parameter Extraction
```bash
# Extract customizable parameters from an OpenSCAD file
./tools/extract-params.sh model.scad
```
### Validation
```bash
# Check for syntax errors and warnings
./tools/validate.sh model.scad
```
## Visual Validation (Required)
**Always validate your OpenSCAD models visually after creating or modifying them.**
After writing or editing any OpenSCAD file:
1. **Generate multi-angle previews** using `multi-preview.sh`
2. **View each generated image** using the `read` tool
3. **Check for issues** from multiple perspectives:
- Front/back: Verify symmetry, features, and proportions
- Left/right: Check depth and side profiles
- Top: Ensure top features are correct
- Isometric: Overall shape validation
4. **Iterate if needed**: If something looks wrong, fix the code and re-validate
This catches issues that syntax validation alone cannot detect:
- Inverted normals or inside-out geometry
- Misaligned features or incorrect boolean operations
- Proportions that don't match the intended design
- Missing or floating geometry
- Z-fighting or overlapping surfaces
**Never deliver an OpenSCAD model without visually confirming it looks correct from multiple angles.**
## Workflow
### 1. Creating an OpenSCAD Model
Write OpenSCAD code with customizable parameters at the top:
```openscad
// Customizable parameters
wall_thickness = 2; // [1:0.5:5] Wall thickness in mm
width = 50; // [20:100] Width in mm
height = 30; // [10:80] Height in mm
rounded = true; // Add rounded corners
// Model code below
module main_shape() {
if (rounded) {
minkowski() {
cube([width - 4, width - 4, height - 2]);
sphere(r = 2);
}
} else {
cube([width, width, height]);
}
}
difference() {
main_shape();
translate([wall_thickness, wall_thickness, wall_thickness])
scale([1 - 2*wall_thickness/width, 1 - 2*wall_thickness/width, 1])
main_shape();
}
```
Parameter comment format:
- `// [min:max]` - numeric range
- `// [min:step:max]` - numeric range with step
- `// [opt1, opt2, opt3]` - dropdown options
- `// Description text` - plain description
### 2. Validate the Model
```bash
./tools/validate.sh model.scad
```
### 3. Generate Previews
Generate preview images to visually validate the model:
```bash
./tools/multi-preview.sh model.scad ./previews/
```
This creates PNG images from multiple angles. Use the `read` tool to view them.
### 4. Export to STL
```bash
./tools/export-stl.sh model.scad output.stl
# With custom parameters:
./tools/export-stl.sh model.scad output.stl -D 'width=60' -D 'height=40'
```
## Camera Positions
Common camera angles for previews:
- **Isometric**: `--camera=0,0,0,45,0,45,200`
- **Front**: `--camera=0,0,0,90,0,0,200`
- **Top**: `--camera=0,0,0,0,0,0,200`
- **Right**: `--camera=0,0,0,90,0,90,200`
Format: `x,y,z,rotx,roty,rotz,distance`
## MakerWorld Publishing
For MakerWorld, you typically need:
1. STL file(s) exported via `export-stl.sh`
2. Preview images (at least one good isometric view)
3. A description of customizable parameters
Consider creating a `model.json` with metadata:
```json
{
"name": "Model Name",
"description": "Description for MakerWorld",
"parameters": [...],
"tags": ["functional", "container", "organizer"]
}
```
## Example: Full Workflow
```bash
# 1. Create the model (write .scad file)
# 2. Validate syntax
./tools/validate.sh box.scad
# 3. Generate multi-angle previews
./tools/multi-preview.sh box.scad ./previews/
# 4. IMPORTANT: View and validate ALL preview images
# Use the read tool on each PNG file to visually inspect:
# - previews/box_front.png
# - previews/box_back.png
# - previews/box_left.png
# - previews/box_right.png
# - previews/box_top.png
# - previews/box_iso.png
# Look for geometry issues, misalignments, or unexpected results.
# If anything looks wrong, go back to step 1 and fix it!
# 5. Extract and review parameters
./tools/extract-params.sh box.scad
# 6. Export STL with default parameters
./tools/export-stl.sh box.scad box.stl
# 7. Export STL with custom parameters
./tools/export-stl.sh box.scad box_large.stl -D 'width=80' -D 'height=60'
```
**Remember**: Never skip the visual validation step. Many issues (wrong dimensions, boolean operation errors, inverted geometry) are only visible when you actually look at the rendered model.
## OpenSCAD Quick Reference
### Basic Shapes
```openscad
cube([x, y, z]);
sphere(r = radius);
cylinder(h = height, r = radius);
cylinder(h = height, r1 = bottom_r, r2 = top_r); // cone
```
### Transformations
```openscad
translate([x, y, z]) object();
rotate([rx, ry, rz]) object();
scale([sx, sy, sz]) object();
mirror([x, y, z]) object();
```
### Boolean Operations
```openscad
union() { a(); b(); } // combine
difference() { a(); b(); } // subtract b from a
intersection() { a(); b(); } // overlap only
```
### Advanced
```openscad
linear_extrude(height) 2d_shape();
rotate_extrude() 2d_shape();
hull() { objects(); } // convex hull
minkowski() { a(); b(); } // minkowski sum (rounding)
```
### 2D Shapes
```openscad
circle(r = radius);
square([x, y]);
polygon(points = [[x1,y1], [x2,y2], ...]);
text("string", size = 10);
```

View File

@@ -0,0 +1,92 @@
// Parametric Box with Lid
// A customizable storage box for 3D printing
// === Box Parameters ===
width = 60; // [20:200] Width in mm
depth = 40; // [20:200] Depth in mm
height = 30; // [10:150] Height in mm
wall_thickness = 2; // [1:0.5:5] Wall thickness in mm
// === Lid Parameters ===
include_lid = true; // Include a separate lid
lid_height = 8; // [5:30] Lid height in mm
lid_tolerance = 0.3; // [0.1:0.1:0.8] Gap for lid fit
// === Style Options ===
corner_radius = 3; // [0:10] Corner rounding radius
add_grip = true; // Add grip indents to lid
// === Internal ===
$fn = 32; // Smoothness
// Rounded box module
module rounded_box(w, d, h, r) {
if (r > 0) {
hull() {
for (x = [r, w-r]) {
for (y = [r, d-r]) {
translate([x, y, 0])
cylinder(h = h, r = r);
}
}
}
} else {
cube([w, d, h]);
}
}
// Main box body
module box_body() {
difference() {
rounded_box(width, depth, height, corner_radius);
// Hollow inside
translate([wall_thickness, wall_thickness, wall_thickness])
rounded_box(
width - 2*wall_thickness,
depth - 2*wall_thickness,
height, // Open top
max(0, corner_radius - wall_thickness)
);
}
}
// Lid
module lid() {
inner_w = width - 2*wall_thickness - 2*lid_tolerance;
inner_d = depth - 2*wall_thickness - 2*lid_tolerance;
lip_height = lid_height * 0.6;
difference() {
union() {
// Top cap
rounded_box(width, depth, wall_thickness, corner_radius);
// Inner lip
translate([wall_thickness + lid_tolerance, wall_thickness + lid_tolerance, -lip_height + wall_thickness])
rounded_box(inner_w, inner_d, lip_height, max(0, corner_radius - wall_thickness));
}
// Grip indents
if (add_grip) {
for (x = [width * 0.3, width * 0.7]) {
translate([x, -1, wall_thickness/2])
rotate([-90, 0, 0])
cylinder(h = 5, r = 3, $fn = 16);
translate([x, depth - 4, wall_thickness/2])
rotate([-90, 0, 0])
cylinder(h = 5, r = 3, $fn = 16);
}
}
}
}
// Render
box_body();
if (include_lid) {
// Position lid next to box for printing
translate([width + 10, 0, lid_height - wall_thickness])
rotate([180, 0, 0])
lid();
}

View File

@@ -0,0 +1,95 @@
// Adjustable Phone/Tablet Stand
// Parametric stand with customizable angle and size
// === Device Parameters ===
device_width = 80; // [50:200] Device width in mm
device_thickness = 12; // [6:20] Device thickness (with case)
// === Stand Parameters ===
stand_angle = 65; // [45:85] Viewing angle in degrees
stand_depth = 80; // [50:150] Base depth in mm
stand_height = 100; // [60:200] Back support height in mm
// === Construction ===
material_thickness = 4; // [2:0.5:8] Material thickness
slot_depth = 15; // [10:30] How deep device sits in slot
// === Features ===
cable_hole = true; // Add cable pass-through hole
cable_diameter = 15; // [8:25] Cable hole diameter
add_feet = true; // Add anti-slip feet
// === Quality ===
$fn = 48;
module stand_profile() {
// 2D profile of the stand side
polygon([
[0, 0], // Front bottom
[stand_depth, 0], // Back bottom
[stand_depth, material_thickness], // Back bottom inner
[stand_depth - material_thickness, material_thickness], // Base top back
[slot_depth + material_thickness, material_thickness], // Base top front (behind slot)
[slot_depth + material_thickness, slot_depth * tan(90 - stand_angle) + material_thickness], // Slot back
[material_thickness, slot_depth * tan(90 - stand_angle) + material_thickness + device_thickness / sin(stand_angle)], // Slot front top
[0, slot_depth * tan(90 - stand_angle) + material_thickness], // Front face bottom of slot
[0, 0] // Close
]);
}
module back_support() {
// Back angled support
translate([stand_depth - material_thickness, 0, material_thickness]) {
rotate([0, -90 + stand_angle, 0]) {
cube([stand_height, device_width, material_thickness]);
}
}
}
module cable_cutout() {
if (cable_hole) {
translate([stand_depth/2, device_width/2, -1])
cylinder(h = material_thickness + 2, d = cable_diameter);
}
}
module foot() {
cylinder(h = 2, d1 = 10, d2 = 8);
}
module stand() {
difference() {
union() {
// Left side
linear_extrude(material_thickness)
stand_profile();
// Right side
translate([0, device_width - material_thickness, 0])
linear_extrude(material_thickness)
stand_profile();
// Base plate
cube([stand_depth, device_width, material_thickness]);
// Front lip
cube([material_thickness, device_width, slot_depth * tan(90 - stand_angle) + material_thickness]);
// Back support
back_support();
}
// Cable hole
cable_cutout();
}
// Feet
if (add_feet) {
translate([10, 10, 0]) foot();
translate([10, device_width - 10, 0]) foot();
translate([stand_depth - 10, 10, 0]) foot();
translate([stand_depth - 10, device_width - 10, 0]) foot();
}
}
stand();

View File

@@ -0,0 +1,50 @@
#!/bin/bash
# Common utilities for OpenSCAD tools
# Find OpenSCAD executable
find_openscad() {
# Check common locations
if command -v openscad &> /dev/null; then
echo "openscad"
return 0
fi
# macOS Application bundle
if [ -d "/Applications/OpenSCAD.app" ]; then
echo "/Applications/OpenSCAD.app/Contents/MacOS/OpenSCAD"
return 0
fi
# Homebrew on Apple Silicon
if [ -x "/opt/homebrew/bin/openscad" ]; then
echo "/opt/homebrew/bin/openscad"
return 0
fi
# Homebrew on Intel
if [ -x "/usr/local/bin/openscad" ]; then
echo "/usr/local/bin/openscad"
return 0
fi
return 1
}
# Check if OpenSCAD is available
check_openscad() {
OPENSCAD=$(find_openscad) || {
echo "Error: OpenSCAD not found!"
echo ""
echo "Install OpenSCAD using one of:"
echo " brew install openscad"
echo " Download from https://openscad.org/downloads.html"
exit 1
}
export OPENSCAD
}
# Get version info
openscad_version() {
check_openscad
$OPENSCAD --version 2>&1
}

View File

@@ -0,0 +1,56 @@
#!/bin/bash
# Export OpenSCAD file to STL
# Usage: export-stl.sh input.scad output.stl [-D 'var=value' ...]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/common.sh"
check_openscad
if [ $# -lt 2 ]; then
echo "Usage: $0 input.scad output.stl [-D 'var=value' ...]"
echo ""
echo "Examples:"
echo " $0 box.scad box.stl"
echo " $0 box.scad box_large.stl -D 'width=80' -D 'height=60'"
exit 1
fi
INPUT="$1"
OUTPUT="$2"
shift 2
# Collect -D parameters
DEFINES=()
while [ $# -gt 0 ]; do
case "$1" in
-D)
shift
DEFINES+=("-D" "$1")
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
shift
done
# Ensure output directory exists
mkdir -p "$(dirname "$OUTPUT")"
echo "Exporting STL: $INPUT -> $OUTPUT"
if [ ${#DEFINES[@]} -gt 0 ]; then
echo "Parameters: ${DEFINES[*]}"
fi
$OPENSCAD \
"${DEFINES[@]}" \
-o "$OUTPUT" \
"$INPUT"
# Show file info
SIZE=$(ls -lh "$OUTPUT" | awk '{print $5}')
echo "STL exported: $OUTPUT ($SIZE)"

View File

@@ -0,0 +1,147 @@
#!/bin/bash
# Extract customizable parameters from an OpenSCAD file
# Usage: extract-params.sh input.scad [--json]
#
# Parses parameter declarations with special comments:
# param = value; // [min:max] Description
# param = value; // [min:step:max] Description
# param = value; // [opt1, opt2] Description
# param = value; // Description only
set -e
if [ $# -lt 1 ]; then
echo "Usage: $0 input.scad [--json]"
exit 1
fi
INPUT="$1"
JSON_OUTPUT=false
if [ "$2" = "--json" ]; then
JSON_OUTPUT=true
fi
if [ ! -f "$INPUT" ]; then
echo "Error: File not found: $INPUT"
exit 1
fi
# Extract parameters using Python for better parsing
extract_params() {
python3 -c '
import sys
import re
filename = sys.argv[1]
in_block = 0
with open(filename, "r") as f:
for line in f:
# Track block depth (skip params inside modules/functions)
in_block += line.count("{") - line.count("}")
if in_block > 0:
continue
# Match: varname = value; // comment
match = re.match(r"^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*([^;]+);\s*(?://\s*(.*))?", line)
if not match:
continue
var_name = match.group(1)
value = match.group(2).strip()
comment = match.group(3) or ""
# Determine type
if value in ("true", "false"):
var_type = "boolean"
elif re.match(r"^-?\d+$", value):
var_type = "integer"
elif re.match(r"^-?\d*\.?\d+$", value):
var_type = "number"
elif value.startswith("\"") and value.endswith("\""):
var_type = "string"
value = value[1:-1] # Remove quotes
elif value.startswith("["):
var_type = "array"
else:
var_type = "expression"
# Parse comment for range/options
range_val = ""
options_val = ""
description = comment
range_match = re.match(r"\[([^\]]+)\]\s*(.*)", comment)
if range_match:
bracket_content = range_match.group(1)
description = range_match.group(2)
# Check if numeric range (contains :) or options (contains ,)
if ":" in bracket_content and not "," in bracket_content:
range_val = bracket_content
else:
options_val = bracket_content
# Output pipe-delimited
print(f"{var_name}|{value}|{var_type}|{range_val}|{options_val}|{description}")
' "$INPUT"
}
if [ "$JSON_OUTPUT" = true ]; then
echo "["
first=true
while IFS='|' read -r name value type range options description; do
if [ "$first" = true ]; then
first=false
else
echo ","
fi
# Escape quotes in values
value=$(echo "$value" | sed 's/"/\\"/g')
description=$(echo "$description" | sed 's/"/\\"/g')
# Build JSON object
printf ' {\n'
printf ' "name": "%s",\n' "$name"
printf ' "value": "%s",\n' "$value"
printf ' "type": "%s"' "$type"
if [ -n "$range" ]; then
printf ',\n "range": "%s"' "$range"
fi
if [ -n "$options" ]; then
printf ',\n "options": "%s"' "$options"
fi
if [ -n "$description" ]; then
printf ',\n "description": "%s"' "$description"
fi
printf '\n }'
done < <(extract_params)
echo ""
echo "]"
else
echo "Parameters in: $INPUT"
echo "==============================================="
printf "%-20s %-15s %-10s %s\n" "NAME" "VALUE" "TYPE" "CONSTRAINT/DESC"
echo "-----------------------------------------------"
while IFS='|' read -r name value type range options description; do
constraint=""
if [ -n "$range" ]; then
constraint="[$range]"
elif [ -n "$options" ]; then
constraint="[$options]"
fi
if [ -n "$description" ]; then
if [ -n "$constraint" ]; then
constraint="$constraint $description"
else
constraint="$description"
fi
fi
printf "%-20s %-15s %-10s %s\n" "$name" "$value" "$type" "$constraint"
done < <(extract_params)
fi

View File

@@ -0,0 +1,68 @@
#!/bin/bash
# Generate preview images from multiple angles
# Usage: multi-preview.sh input.scad output_dir/ [-D 'var=value']
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/common.sh"
check_openscad
if [ $# -lt 2 ]; then
echo "Usage: $0 input.scad output_dir/ [-D 'var=value' ...]"
exit 1
fi
INPUT="$1"
OUTPUT_DIR="$2"
shift 2
# Collect -D parameters
DEFINES=()
while [ $# -gt 0 ]; do
case "$1" in
-D)
shift
DEFINES+=("-D" "$1")
;;
esac
shift
done
mkdir -p "$OUTPUT_DIR"
# Get base name without extension
BASENAME=$(basename "$INPUT" .scad)
echo "Generating multi-angle previews for: $INPUT"
echo "Output directory: $OUTPUT_DIR"
echo ""
# Define angles as name:camera pairs
# Camera format: translate_x,translate_y,translate_z,rot_x,rot_y,rot_z,distance
ANGLES="iso:0,0,0,55,0,25,0
front:0,0,0,90,0,0,0
back:0,0,0,90,0,180,0
left:0,0,0,90,0,90,0
right:0,0,0,90,0,-90,0
top:0,0,0,0,0,0,0"
echo "$ANGLES" | while IFS=: read -r angle camera; do
output="$OUTPUT_DIR/${BASENAME}_${angle}.png"
echo " Rendering $angle view..."
$OPENSCAD \
--camera="$camera" \
--imgsize="800,600" \
--colorscheme="Tomorrow Night" \
--autocenter \
--viewall \
"${DEFINES[@]}" \
-o "$output" \
"$INPUT" 2>/dev/null
done
echo ""
echo "Generated previews:"
ls -la "$OUTPUT_DIR"/${BASENAME}_*.png

View File

@@ -0,0 +1,74 @@
#!/bin/bash
# Generate a preview PNG from an OpenSCAD file
# Usage: preview.sh input.scad output.png [options]
#
# Options:
# --camera=x,y,z,rx,ry,rz,dist Camera position
# --size=WxH Image size (default: 800x600)
# -D 'var=value' Set parameter value
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/common.sh"
check_openscad
if [ $# -lt 2 ]; then
echo "Usage: $0 input.scad output.png [--camera=...] [--size=WxH] [-D 'var=val']"
echo ""
echo "Camera format: x,y,z,rotx,roty,rotz,distance"
echo "Common cameras:"
echo " Isometric: --camera=0,0,0,55,0,25,200"
echo " Front: --camera=0,0,0,90,0,0,200"
echo " Top: --camera=0,0,0,0,0,0,200"
exit 1
fi
INPUT="$1"
OUTPUT="$2"
shift 2
# Defaults
CAMERA="0,0,0,55,0,25,0"
SIZE="800,600"
DEFINES=()
# Parse options
while [ $# -gt 0 ]; do
case "$1" in
--camera=*)
CAMERA="${1#--camera=}"
;;
--size=*)
SIZE="${1#--size=}"
SIZE="${SIZE/x/,}"
;;
-D)
shift
DEFINES+=("-D" "$1")
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
shift
done
# Ensure output directory exists
mkdir -p "$(dirname "$OUTPUT")"
# Run OpenSCAD
echo "Rendering preview: $INPUT -> $OUTPUT"
$OPENSCAD \
--camera="$CAMERA" \
--imgsize="${SIZE}" \
--colorscheme="Tomorrow Night" \
--autocenter \
--viewall \
"${DEFINES[@]}" \
-o "$OUTPUT" \
"$INPUT"
echo "Preview saved to: $OUTPUT"

View File

@@ -0,0 +1,91 @@
#!/bin/bash
# Render OpenSCAD with parameters from a JSON file
# Usage: render-with-params.sh input.scad params.json output.stl|output.png
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/common.sh"
check_openscad
if [ $# -lt 3 ]; then
echo "Usage: $0 input.scad params.json output.[stl|png]"
echo ""
echo "params.json format:"
echo ' {"width": 60, "height": 40, "include_lid": true}'
exit 1
fi
INPUT="$1"
PARAMS_FILE="$2"
OUTPUT="$3"
if [ ! -f "$INPUT" ]; then
echo "Error: Input file not found: $INPUT"
exit 1
fi
if [ ! -f "$PARAMS_FILE" ]; then
echo "Error: Params file not found: $PARAMS_FILE"
exit 1
fi
# Build -D arguments from JSON
DEFINES=()
while IFS= read -r line; do
# Parse each key-value pair
key=$(echo "$line" | cut -d'=' -f1)
value=$(echo "$line" | cut -d'=' -f2-)
if [ -n "$key" ]; then
DEFINES+=("-D" "$key=$value")
fi
done < <(
# Use python or jq to parse JSON to key=value lines
if command -v python3 &> /dev/null; then
python3 -c "
import json
with open('$PARAMS_FILE') as f:
params = json.load(f)
for k, v in params.items():
if isinstance(v, bool):
print(f'{k}={str(v).lower()}')
elif isinstance(v, str):
print(f'{k}=\"{v}\"')
else:
print(f'{k}={v}')
"
elif command -v jq &> /dev/null; then
jq -r 'to_entries | .[] | "\(.key)=\(.value)"' "$PARAMS_FILE"
else
echo "Error: Requires python3 or jq to parse JSON"
exit 1
fi
)
echo "Rendering with parameters from: $PARAMS_FILE"
echo "Parameters: ${DEFINES[*]}"
# Determine output type and set appropriate options
EXT="${OUTPUT##*.}"
case "$EXT" in
stl|STL)
$OPENSCAD "${DEFINES[@]}" -o "$OUTPUT" "$INPUT"
;;
png|PNG)
$OPENSCAD "${DEFINES[@]}" \
--camera="0,0,0,55,0,25,0" \
--imgsize="800,600" \
--colorscheme="Tomorrow Night" \
--autocenter --viewall \
-o "$OUTPUT" "$INPUT"
;;
*)
echo "Unsupported output format: $EXT"
echo "Supported: stl, png"
exit 1
;;
esac
echo "Output saved: $OUTPUT"

View File

@@ -0,0 +1,46 @@
#!/bin/bash
# Validate an OpenSCAD file for syntax errors
# Usage: validate.sh input.scad
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/common.sh"
check_openscad
if [ $# -lt 1 ]; then
echo "Usage: $0 input.scad"
exit 1
fi
INPUT="$1"
if [ ! -f "$INPUT" ]; then
echo "Error: File not found: $INPUT"
exit 1
fi
echo "Validating: $INPUT"
# Create temp file for output
TEMP_OUTPUT=$(mktemp /tmp/openscad_validate.XXXXXX.echo)
trap "rm -f $TEMP_OUTPUT" EXIT
# Run OpenSCAD with echo output (fastest way to check syntax)
# Using --export-format=echo just parses and evaluates without rendering
if $OPENSCAD -o "$TEMP_OUTPUT" --export-format=echo "$INPUT" 2>&1; then
echo "✓ Syntax OK"
# Check for warnings in stderr
if [ -s "$TEMP_OUTPUT" ]; then
echo ""
echo "Echo output:"
cat "$TEMP_OUTPUT"
fi
exit 0
else
echo "✗ Validation failed"
exit 1
fi

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1,289 @@
// ============================================================
// RPi5 Industrial Enclosure for Luckfox DHX-10.1" Touchscreen
// Version: 001
// ============================================================
// ── SCREEN PARAMETERS ───────────────────────────────────────
scr_w = 236; // screen outer width (mm)
scr_h = 144; // screen outer height (mm)
scr_d = 19; // screen outer depth (mm)
scr_active_w = 222; // active area width (mm) ← confirm
scr_active_h = 130; // active area height (mm) ← confirm
scr_mount_x = 75; // screen M2.5 mount pattern X (mm) ← verify
scr_mount_y = 75; // screen M2.5 mount pattern Y (mm) ← verify
// ── RASPBERRY PI 5 PARAMETERS ────────────────────────────────
pi_w = 85; // Pi board width (mm)
pi_h = 56; // Pi board height (mm)
pi_d = 17; // Pi board depth incl. tallest component (mm)
pi_mnt_x = 58; // Pi mount hole pattern X (mm)
pi_mnt_y = 49; // Pi mount hole pattern Y (mm)
pi_standoff = 5; // standoff height between screen rear and Pi (mm)
// Pi offset from screen center (positive = up, right)
pi_offset_x = 0; // horizontal offset of Pi center from screen center
pi_offset_y = 5; // vertical offset upward from screen center
// ── ENCLOSURE PARAMETERS ─────────────────────────────────────
wall = 2.5; // wall thickness (mm)
chamfer = 1.5; // external edge chamfer (mm)
recess = 1.0; // screen recess depth in front bezel (mm)
gap = 0.3; // fit clearance between bezel and rear cover
// ── VENT PARAMETERS ──────────────────────────────────────────
vent_w = 3; // vent slot width (mm)
vent_l = 20; // vent slot length (mm)
vent_sp = 4; // slot pitch (edge to edge) (mm)
soc_vent_sz = 30; // SoC vent zone size (mm sq)
// ── CABLE GLAND PARAMETERS ───────────────────────────────────
gland_count = 2; // number of cable glands
gland_dia = 16.5; // M16 clearance hole diameter (mm)
gland_spacing = 40; // spacing between gland centers (mm)
// ── PEDESTAL PARAMETERS ──────────────────────────────────────
ped_tilt = 75; // tilt angle from vertical (deg) — screen tilts back
ped_depth = 80; // foot depth front-to-back (mm)
ped_width = 200; // foot width (mm)
ped_thick = 6; // foot plate thickness (mm)
ped_brace_h = 30; // height of triangular brace
// ── ASSEMBLY PARAMETERS ──────────────────────────────────────
m3_dia = 3.4; // M3 clearance hole
insert_dia = 4.2; // M3 heat-set insert OD
insert_h = 6; // heat-set insert depth
// ── DERIVED DIMENSIONS ───────────────────────────────────────
// Total rear cavity depth = standoffs + Pi + cable headroom
rear_d = pi_standoff + pi_d + 10; // 10 mm cable headroom
// Outer enclosure size
enc_w = scr_w + 2*wall;
enc_h = scr_h + 2*wall;
enc_d = rear_d + wall; // rear cover depth
// Pi center position relative to screen center
pi_cx = scr_w/2 + pi_offset_x;
pi_cy = scr_h/2 + pi_offset_y;
$fn = 48;
// ============================================================
// MODULES
// ============================================================
// Chamfered box (external chamfer via intersection with offset cube)
module cbox(w, h, d, c=chamfer) {
hull() {
translate([c,c,0]) cube([w-2*c, h-2*c, d]);
translate([0,c,c]) cube([w, h-2*c, d-2*c]);
translate([c,0,c]) cube([w-2*c, h, d-2*c]);
}
}
// Rounded slot (for vents)
module slot(len, w, d) {
r = w/2;
hull() {
translate([0, -len/2+r, 0]) cylinder(r=r, h=d);
translate([0, len/2-r, 0]) cylinder(r=r, h=d);
}
}
// M2.5 mounting hole
module m25_hole(d=10) {
cylinder(d=2.7, h=d);
}
// Heat-set insert boss + M3 hole
module insert_boss(h=insert_h+4) {
difference() {
cylinder(d=insert_dia+3, h=h);
cylinder(d=insert_dia, h=insert_h);
translate([0,0,insert_h]) cylinder(d=m3_dia, h=h);
}
}
// Single vent slot row (horizontal slots)
module vent_row(count, slot_len, slot_w, pitch, depth) {
for(i=[0:count-1]) {
translate([i*(slot_w+pitch), 0, 0])
slot(slot_len, slot_w, depth+0.1);
}
}
// ============================================================
// FRONT BEZEL
// ============================================================
module front_bezel() {
difference() {
// Outer chamfered shell
cbox(enc_w, enc_h, wall + recess);
// Active display window (recessed by 1 mm, then open)
translate([(enc_w - scr_active_w)/2,
(enc_h - scr_active_h)/2,
-0.1])
cube([scr_active_w, scr_active_h, wall + recess + 0.2]);
// Bezel lip sits 1 mm over screen edge — recess pocket
translate([(enc_w - scr_w)/2,
(enc_h - scr_h)/2,
wall])
cube([scr_w, scr_h, recess + 0.1]);
// Corner M3 screw holes (through bezel flange, 4 corners)
for(x=[wall+6, enc_w-wall-6])
for(y=[wall+6, enc_h-wall-6])
translate([x, y, -0.1])
cylinder(d=m3_dia, h=wall+recess+0.2);
}
}
// ============================================================
// REAR COVER
// ============================================================
module rear_cover() {
difference() {
union() {
// Main body
cbox(enc_w, enc_h, enc_d);
// Pedestal foot (integral)
pedestal_foot();
// Heat-set insert bosses at 4 corners (inside)
for(x=[wall+6, enc_w-wall-6])
for(y=[wall+6, enc_h-wall-6])
translate([x, y, enc_d])
rotate([180,0,0])
insert_boss();
}
// Hollow interior
translate([wall, wall, wall])
cube([scr_w, scr_h, enc_d]);
// ── PORT CUTOUTS ──────────────────────────────────────
// USB-C power + 2× HDMI on LEFT edge (Pi left side)
// Pi left edge X position in enclosure coords
pi_left_x = pi_cx - pi_w/2 + wall;
// USB-C power (Pi left edge, near bottom of Pi)
translate([-0.1,
pi_cy - 8 + wall,
wall + pi_standoff + 2])
cube([wall+0.2, 10, 10]);
// HDMI #1
translate([-0.1,
pi_cx - pi_w/2 + wall + 15,
wall + pi_standoff + 2])
cube([wall+0.2, 16, 8]);
// HDMI #2
translate([-0.1,
pi_cx - pi_w/2 + wall + 34,
wall + pi_standoff + 2])
cube([wall+0.2, 16, 8]);
// Ethernet RJ45 on RIGHT edge
translate([enc_w - wall - 0.1,
pi_cy + pi_h/2 - 22 + wall,
wall + pi_standoff + 1])
cube([wall+0.2, 22, 16]);
// USB-A ×4 on RIGHT edge
translate([enc_w - wall - 0.1,
pi_cy - pi_h/2 + wall + 2,
wall + pi_standoff + 1])
cube([wall+0.2, 50, 14]);
// GPIO header on TOP edge
translate([pi_cx - 30 + wall,
enc_h - wall - 0.1,
wall + pi_standoff])
cube([52, wall+0.2, 12]);
// USB-C touch on left side edge of SCREEN (not Pi)
translate([-0.1, enc_h/2 - 6, wall + scr_d - 5])
cube([wall+0.2, 12, 8]);
// ── COOLING VENTS ──────────────────────────────────────
// Bottom intake slots
translate([enc_w/2 - (5*(vent_w+vent_sp))/2, -0.1, wall+8])
rotate([-90, 0, 0])
vent_row(5, vent_l, vent_w, vent_sp, wall+0.2);
// Top exhaust slots
translate([enc_w/2 - (5*(vent_w+vent_sp))/2,
enc_h - wall + 0.1,
wall+8])
rotate([90, 0, 0])
vent_row(5, vent_l, vent_w, vent_sp, wall+0.2);
// SoC direct vent (rear panel, over Pi SoC area)
// SoC assumed ~center of Pi board
translate([pi_cx - soc_vent_sz/2 + wall,
pi_cy - soc_vent_sz/2 + wall,
enc_d - wall - 0.1]) {
count_soc = floor(soc_vent_sz / (vent_w + vent_sp));
for(i=[0:count_soc-1])
translate([i*(vent_w+vent_sp), soc_vent_sz/2-vent_l/2, 0])
slot(vent_l, vent_w, wall+0.2);
}
// ── CABLE GLANDS ──────────────────────────────────────
for(i=[0:gland_count-1]) {
cx = enc_w/2 + (i - (gland_count-1)/2) * gland_spacing;
translate([cx, -0.1, wall + gland_dia/2 + 4])
rotate([-90,0,0])
cylinder(d=gland_dia, h=wall+0.2);
}
}
}
// ============================================================
// PEDESTAL FOOT (integral with rear cover)
// ============================================================
module pedestal_foot() {
// The foot projects from the bottom of the rear cover.
// It's a wedge that creates the tilt angle.
// When the assembly stands on the foot, the screen tilts back ped_tilt°.
//
// tilt_angle from vertical → wedge front height > back height.
// foot_front_h = ped_depth * tan(90-ped_tilt)
foot_front_h = ped_depth * tan(90 - ped_tilt);
foot_x0 = (enc_w - ped_width) / 2;
translate([foot_x0, 0, 0]) {
// Wedge base plate
hull() {
// Front edge (taller)
translate([0, -ped_depth, 0])
cube([ped_width, 0.1, foot_front_h + ped_thick]);
// Back edge (at enc base, flush)
translate([0, 0, 0])
cube([ped_width, 0.1, ped_thick]);
}
// Triangular side braces for rigidity
for(bx=[0, ped_width-ped_thick]) {
translate([bx, -ped_depth, 0])
linear_extrude(ped_thick)
polygon([[0,0],
[ped_depth, 0],
[ped_depth, foot_front_h]]);
}
}
}
// ============================================================
// RENDER — exploded assembly view
// ============================================================
// Front bezel at Z=0 (face down for printing, shown face up)
color("DarkSlateGray", 0.9)
translate([0, 0, enc_d + 5])
front_bezel();
// Rear cover
color("SlateGray", 0.9)
rear_cover();

View File

@@ -0,0 +1,300 @@
// ============================================================
// RPi5 Industrial Enclosure — Luckfox DHX-10.1" Touchscreen
// Version: 002
// Fixes vs 001:
// 1. Pedestal foot now projects from REAR FACE in -Z direction
// 2. Tilt wedge orientation corrected (leans screen back, not forward)
// 3. Cable glands moved to rear panel face (foot owns the bottom edge)
// 4. GPIO cutout repositioned to match Pi board top-edge location
// 5. Port cutout Z-depths corrected using pi_enc_cx/cy consistently
// ============================================================
// ── SCREEN PARAMETERS ───────────────────────────────────────
scr_w = 236; // screen outer width (mm)
scr_h = 144; // screen outer height (mm)
scr_d = 19; // screen outer depth (mm)
scr_active_w = 222; // active area width (mm) ← confirm
scr_active_h = 130; // active area height (mm) ← confirm
scr_mount_x = 75; // screen rear M2.5 mount pattern X (mm) ← verify
scr_mount_y = 75; // screen rear M2.5 mount pattern Y (mm) ← verify
// ── RASPBERRY PI 5 PARAMETERS ────────────────────────────────
pi_w = 85; // Pi board width (mm)
pi_h = 56; // Pi board height (mm)
pi_d = 17; // Pi board depth incl. tallest component (mm)
pi_mnt_x = 58; // Pi mount hole pattern X (mm)
pi_mnt_y = 49; // Pi mount hole pattern Y (mm)
pi_standoff = 5; // standoff height: screen rear → Pi board (mm)
pi_offset_x = 0; // Pi centre horizontal offset from screen centre (mm)
pi_offset_y = 5; // Pi centre vertical offset upward from screen centre (mm)
// ── ENCLOSURE PARAMETERS ─────────────────────────────────────
wall = 2.5; // wall thickness (mm)
chamfer = 1.5; // external edge chamfer (mm)
recess = 1.0; // screen recess depth in front bezel (mm)
gap = 0.3; // bezel ↔ rear cover fit clearance (mm)
// ── VENT PARAMETERS ──────────────────────────────────────────
vent_w = 3; // vent slot width (mm)
vent_l = 20; // vent slot length (mm)
vent_sp = 4; // slot spacing edge-to-edge (mm)
soc_vent_sz = 30; // SoC direct-vent zone size (mm, square)
// ── CABLE GLAND PARAMETERS ───────────────────────────────────
gland_count = 2; // number of M16 cable glands
gland_dia = 16.5; // M16 clearance hole diameter (mm)
gland_spacing= 40; // centre-to-centre spacing (mm)
// ── PEDESTAL PARAMETERS ──────────────────────────────────────
// ped_tilt = angle of screen from horizontal (deg).
// 75° from horizontal = 15° lean-back from vertical (near-upright monitor stance).
// The foot is a wedge that, when flat on a desk, holds the rear cover at
// (90 - ped_tilt)° from vertical.
ped_tilt = 75; // screen angle from horizontal (deg)
ped_depth = 80; // foot plate depth front-to-back (mm)
ped_width = 200; // foot plate width (mm)
ped_thick = 6; // foot plate thickness (mm)
// ── ASSEMBLY PARAMETERS ──────────────────────────────────────
m3_dia = 3.4; // M3 clearance hole diameter (mm)
insert_dia = 4.2; // M3 heat-set insert OD (mm)
insert_h = 6; // heat-set insert depth (mm)
// ── DERIVED DIMENSIONS (do not edit) ─────────────────────────
rear_d = pi_standoff + pi_d + 10; // rear cavity depth (10 mm cable headroom)
enc_w = scr_w + 2*wall; // enclosure outer width
enc_h = scr_h + 2*wall; // enclosure outer height
enc_d = rear_d + wall; // rear cover total depth
// Pi centre in enclosure coordinates (enclosure origin = rear-cover corner)
pi_enc_cx = wall + scr_w/2 + pi_offset_x; // = 120.5 with defaults
pi_enc_cy = wall + scr_h/2 + pi_offset_y; // = 79.5 with defaults
// Z position of Pi board surface (measured from rear of rear cover)
pi_z = wall + pi_standoff; // = 7.5 with defaults
// Foot wedge geometry
// foot_drop: how far the far tip drops below Y=0 so the bottom surface
// becomes horizontal when the unit stands at ped_tilt from horizontal.
foot_drop = ped_depth * tan(90 - ped_tilt); // ≈ 21.4 mm for ped_tilt=75
$fn = 48;
// ============================================================
// PRIMITIVES
// ============================================================
// Chamfered box — chamfer on all 12 edges via hull of 3 axis-aligned cubes
module cbox(w, h, d, c=chamfer) {
hull() {
translate([c,c,0]) cube([w-2*c, h-2*c, d ]);
translate([0,c,c]) cube([w, h-2*c, d-2*c ]);
translate([c,0,c]) cube([w-2*c, h, d-2*c ]);
}
}
// Rounded-end vent slot, length along Y, centred at origin
module slot(len, w, d) {
r = w/2;
hull() {
translate([0, -len/2+r, 0]) cylinder(r=r, h=d);
translate([0, len/2-r, 0]) cylinder(r=r, h=d);
}
}
// Row of n vent slots along X
module vent_row(n, len, w, spacing, depth) {
for(i=[0:n-1])
translate([i*(w+spacing), 0, 0])
slot(len, w, depth);
}
// Heat-set insert boss (M3)
module insert_boss(h=insert_h+4) {
difference() {
cylinder(d=insert_dia+3, h=h);
cylinder(d=insert_dia, h=insert_h);
translate([0,0,insert_h]) cylinder(d=m3_dia, h=h);
}
}
// ============================================================
// PEDESTAL FOOT (integral with rear cover, no supports needed)
// ============================================================
// Geometry in model space (rear cover lying on its back, rear face = Z=0):
// • Foot extends in the -Z direction from Z=0 (behind the rear face)
// • Top surface is flush with the enclosure bottom at Y=0
// • Bottom surface is angled: at Z=0 it is ped_thick below Y=0;
// at Z=-ped_depth it is (ped_thick + foot_drop) below Y=0.
// • When the unit stands on the desk the angled surface lies flat and the
// screen tilts back (90-ped_tilt)° from vertical.
// • Print orientation: rear cover face-down (foot on bed), zero supports.
module pedestal_foot() {
foot_x0 = (enc_w - ped_width) / 2;
translate([foot_x0, 0, 0]) {
// Main wedge plate
hull() {
translate([0, -ped_thick, 0 ])
cube([ped_width, ped_thick, wall ]);
translate([0, -(ped_thick+foot_drop), -ped_depth])
cube([ped_width, ped_thick+foot_drop, wall ]);
}
// Left and right stiffening ribs
for(bx = [0, ped_width - ped_thick]) {
hull() {
translate([bx, -ped_thick, 0 ])
cube([ped_thick, ped_thick, wall ]);
translate([bx, -(ped_thick+foot_drop), -ped_depth])
cube([ped_thick, ped_thick+foot_drop, wall ]);
// Toe point keeps underside triangular (no saggy bridge)
translate([bx, -ped_thick, -ped_depth])
cube([ped_thick, ped_thick, wall ]);
}
}
}
}
// ============================================================
// FRONT BEZEL
// ============================================================
module front_bezel() {
difference() {
cbox(enc_w, enc_h, wall + recess);
// Active display window (full cut-through)
translate([(enc_w-scr_active_w)/2, (enc_h-scr_active_h)/2, -0.1])
cube([scr_active_w, scr_active_h, wall+recess+0.2]);
// Recess pocket so bezel lip sits 1 mm over screen edge
translate([(enc_w-scr_w)/2, (enc_h-scr_h)/2, wall])
cube([scr_w, scr_h, recess+0.1]);
// M3 corner screw holes (4×)
for(x = [wall+6, enc_w-wall-6])
for(y = [wall+6, enc_h-wall-6])
translate([x, y, -0.1]) cylinder(d=m3_dia, h=wall+recess+0.2);
}
}
// ============================================================
// REAR COVER
// ============================================================
module rear_cover() {
n_vent = 6;
vent_block_w = n_vent*(vent_w+vent_sp) - vent_sp; // total width of vent array
difference() {
union() {
cbox(enc_w, enc_h, enc_d);
pedestal_foot();
// M3 insert bosses at 4 corners (inner face)
for(x = [wall+6, enc_w-wall-6])
for(y = [wall+6, enc_h-wall-6])
translate([x, y, enc_d])
rotate([180,0,0]) insert_boss();
}
// ── HOLLOW INTERIOR ───────────────────────────────────
translate([wall, wall, wall]) cube([scr_w, scr_h, enc_d]);
// ── LEFT WALL: USB-C power + HDMI ×2 ─────────────────
// These are on the Pi's left short-edge (56 mm face), facing X=0.
// Cutout Y-centre is set near the Pi's lower half.
// Z positions follow port heights above PCB surface.
// (Short ribbon extensions needed to reach the wall at X=0.)
//
// USB-C power
translate([-0.1,
pi_enc_cy - pi_h/2 + 3,
pi_z + 2])
cube([wall+0.2, 11, 11]);
// HDMI 0
translate([-0.1,
pi_enc_cy - pi_h/2 + 16,
pi_z + 2])
cube([wall+0.2, 17, 9]);
// HDMI 1
translate([-0.1,
pi_enc_cy - pi_h/2 + 35,
pi_z + 2])
cube([wall+0.2, 17, 9]);
// ── RIGHT WALL: RJ45 + USB-A ×4 ──────────────────────
// RJ45 (top of Pi right edge in board orientation)
translate([enc_w-wall-0.1,
pi_enc_cy + pi_h/2 - 24,
pi_z + 1])
cube([wall+0.2, 22, 16]);
// USB-A ×4 (two stacked pairs, below RJ45 on right edge)
translate([enc_w-wall-0.1,
pi_enc_cy - pi_h/2 + 2,
pi_z + 1])
cube([wall+0.2, 50, 15]);
// ── TOP WALL: GPIO header (40-pin) ────────────────────
// GPIO is on the Pi's top long edge (85 mm edge at Y = pi_enc_cy + pi_h/2).
// Cutout aligns with the header strip X-extent (51 mm) centred on Pi.
// Z-extent: board surface + header height (~11 mm).
translate([pi_enc_cx - 26,
pi_enc_cy + pi_h/2 - 0.1,
pi_z])
cube([52, wall+0.2, 11]);
// ── USB-C TOUCH: screen side edge (left, near screen depth) ───
translate([-0.1,
enc_h/2 - 6,
wall + scr_d - 5])
cube([wall+0.2, 12, 8]);
// ── COOLING VENTS ─────────────────────────────────────
// Bottom intake slots (6 × 3×20 mm, 4 mm spacing)
translate([enc_w/2 - vent_block_w/2,
-0.1,
wall + 8])
rotate([-90, 0, 0])
vent_row(n_vent, vent_l, vent_w, vent_sp, wall+0.2);
// Top exhaust slots
translate([enc_w/2 - vent_block_w/2,
enc_h - wall + 0.1,
wall + 8])
rotate([90, 0, 0])
vent_row(n_vent, vent_l, vent_w, vent_sp, wall+0.2);
// SoC direct-vent array on rear panel, centred over Pi SoC
translate([pi_enc_cx - soc_vent_sz/2,
pi_enc_cy - soc_vent_sz/2,
enc_d - wall - 0.1]) {
n_soc = floor(soc_vent_sz / (vent_w + vent_sp));
for(i = [0:n_soc-1])
translate([i*(vent_w+vent_sp),
soc_vent_sz/2 - vent_l/2,
0])
slot(vent_l, vent_w, wall+0.2);
}
// ── CABLE GLANDS: rear panel face, bottom area ────────
// Two M16 glands through the rear face (Z=0 plane).
// Positioned below the Pi, above the foot junction.
for(i = [0:gland_count-1]) {
cx = enc_w/2 + (i - (gland_count-1)/2) * gland_spacing;
translate([cx,
wall + gland_dia/2 + 4,
-0.1])
cylinder(d=gland_dia, h=wall+0.2);
}
}
}
// ============================================================
// RENDER — exploded assembly (front bezel floats above rear cover)
// ============================================================
color("DarkSlateGray", 0.9)
translate([0, 0, enc_d + 10])
front_bezel();
color("SlateGray", 0.85)
rear_cover();

View File

@@ -0,0 +1,328 @@
// ============================================================
// RPi5 Industrial Enclosure — Luckfox DHX-10.1" Touchscreen
// Version: 003
// Fixes vs 002:
// 1. Kickstand completely redesigned — shorter, thinner, clearly
// attached to rear cover bottom, triangular gussets on each side
// 2. GPIO top-wall cutout removed (GPIO is fully internal; access
// requires removing the rear cover, correct for industrial use)
// 3. Pi cavity depth verified and annotated
// 4. Bezel two-piece connection: corner bosses on rear cover +
// matching through-holes on bezel — intentional removable joint
// ============================================================
// ── SCREEN PARAMETERS ───────────────────────────────────────
scr_w = 236; // screen outer width (mm)
scr_h = 144; // screen outer height (mm)
scr_d = 19; // screen outer depth (mm)
scr_active_w = 222; // active area width (mm) ← confirm with screen datasheet
scr_active_h = 130; // active area height (mm) ← confirm with screen datasheet
scr_mount_x = 75; // screen rear M2.5 hole pattern X (mm) ← verify
scr_mount_y = 75; // screen rear M2.5 hole pattern Y (mm) ← verify
// ── RASPBERRY PI 5 PARAMETERS ────────────────────────────────
pi_w = 85; // Pi board width (mm)
pi_h = 56; // Pi board height (mm)
pi_d = 17; // Pi board + tallest component height (mm)
pi_mnt_x = 58; // Pi mount hole pattern X (mm)
pi_mnt_y = 49; // Pi mount hole pattern Y (mm)
pi_standoff = 5; // standoff height: screen rear face → Pi PCB (mm)
pi_offset_x = 0; // Pi centre X offset from screen centre (mm)
pi_offset_y = 5; // Pi centre Y offset upward from screen centre (mm)
// ── ENCLOSURE PARAMETERS ─────────────────────────────────────
wall = 2.5; // wall thickness throughout (mm)
chamfer = 1.5; // external edge chamfer size (mm)
recess = 1.0; // screen recess depth in front bezel (mm)
gap = 0.3; // bezel ↔ rear cover fit clearance (mm)
// ── VENT PARAMETERS ──────────────────────────────────────────
vent_w = 3; // vent slot width (mm)
vent_l = 20; // vent slot length (mm)
vent_sp = 4; // slot gap edge-to-edge (mm)
soc_vent_sz = 30; // SoC direct-vent zone size (mm, square)
// ── CABLE GLAND PARAMETERS ───────────────────────────────────
gland_count = 2; // number of M16 cable glands
gland_dia = 16.5; // M16 clearance hole diameter (mm)
gland_spacing= 40; // centre-to-centre spacing (mm)
// ── KICKSTAND PARAMETERS ─────────────────────────────────────
// The kickstand is a flat plate + two triangular gussets, integral
// with the rear cover bottom. When the unit stands on the kickstand
// the plate lies flat on the desk and the screen tilts back
// (90 - ks_tilt) degrees from vertical.
ks_tilt = 75; // screen angle from horizontal when standing (deg)
// 75° from horiz = 15° lean-back from vertical
ks_depth = 55; // plate reach behind rear face (mm) — shorter than 002
ks_width = 180; // plate span across enclosure width (mm)
ks_thick = 5; // plate thickness (mm)
ks_gusset_h = 30; // gusset height up the rear cover face (mm)
// ── ASSEMBLY PARAMETERS ──────────────────────────────────────
m3_dia = 3.4; // M3 clearance hole (mm)
insert_dia = 4.2; // M3 heat-set insert OD (mm)
insert_h = 6; // heat-set insert depth (mm)
boss_od = insert_dia + 3.5; // insert boss outer diameter (mm)
corner_inset = wall + boss_od/2 + 1; // corner boss/hole X and Y inset (mm)
// ── DERIVED DIMENSIONS ───────────────────────────────────────
//
// Rear cavity depth check:
// pi_standoff (5) + pi_d (17) + cable headroom (10) = 32 mm → rear_d
// rear_d is the full depth of the rear cover cavity.
// The screen body (scr_d=19 mm) is NOT included in rear_d;
// the rear cover encloses only the space BEHIND the screen rear face.
//
rear_d = pi_standoff + pi_d + 10; // = 32 mm, Pi fits with 10 mm to spare
enc_w = scr_w + 2*wall; // enclosure outer width (241 mm)
enc_h = scr_h + 2*wall; // enclosure outer height (149 mm)
enc_d = rear_d + wall; // rear cover total depth ( 34.5 mm)
// Pi centre in enclosure XY coordinates (wall-offset from screen centre)
pi_enc_cx = wall + scr_w/2 + pi_offset_x; // 120.5 mm with defaults
pi_enc_cy = wall + scr_h/2 + pi_offset_y; // 79.5 mm with defaults
// Z of Pi PCB surface, measured from rear cover rear face
pi_z = wall + pi_standoff; // 7.5 mm with defaults
// Kickstand tip drop: how far below Y=0 the far edge must sit so the
// bottom surface is horizontal when the unit tilts to ks_tilt from horizontal
ks_drop = ks_depth * tan(90 - ks_tilt); // ≈ 14.7 mm for ks_tilt=75
$fn = 48;
// ============================================================
// PRIMITIVES
// ============================================================
// Chamfered rectangular box (all 12 edges, chamfer = c)
module cbox(w, h, d, c=chamfer) {
hull() {
translate([c, c, 0]) cube([w-2*c, h-2*c, d ]);
translate([0, c, c]) cube([w, h-2*c, d-2*c ]);
translate([c, 0, c]) cube([w-2*c, h, d-2*c ]);
}
}
// Rounded-end vent slot: length along Y, width w, extrudes in +Z by d
module slot(len, w, d) {
r = w/2;
hull() {
translate([0, -len/2+r, 0]) cylinder(r=r, h=d);
translate([0, len/2-r, 0]) cylinder(r=r, h=d);
}
}
// Row of n vent slots stepping in X
module vent_row(n, len, w, spacing, depth) {
for(i = [0:n-1])
translate([i*(w+spacing), 0, 0])
slot(len, w, depth);
}
// M3 heat-set insert boss (sits proud from an inner face)
module insert_boss(total_h = insert_h + 4) {
difference() {
cylinder(d=boss_od, h=total_h);
cylinder(d=insert_dia, h=insert_h);
translate([0,0,insert_h]) cylinder(d=m3_dia, h=total_h);
}
}
// ============================================================
// KICKSTAND
// ============================================================
// Geometry (all in rear-cover model space, rear face = Z=0 plane):
//
// Side view (Y-Z plane):
//
// Y=ks_gusset_h ─┐
// │ ← gusset strip on rear face
// Y=0 ───────────┼──────────────────────────────────── rear cover bottom
// │╲ ← gusset triangle
// plate ──┼─╲──────────────────────────────────────────
// (ks_thick)│ ╲ (sloping, thicker at tip)
// ╲ ╲___________________________________
// Y=-(ks_thick+ks_drop) Z=-ks_depth
//
// The plate and gussets are extruded across ks_width in X.
// The gussets (hull triangles) brace the plate against the rear face,
// preventing the kickstand from snapping off at the root.
//
module kickstand() {
ks_x0 = (enc_w - ks_width) / 2;
translate([ks_x0, 0, 0]) {
// ── Main plate ────────────────────────────────────────
// Wedge: root at Z=0 is ks_thick tall;
// tip at Z=-ks_depth is (ks_thick+ks_drop) tall.
// Top surface flush with enclosure bottom (Y=0).
hull() {
// Root strip — along rear face
translate([0, -ks_thick, 0])
cube([ks_width, ks_thick, wall]);
// Tip strip — at full reach, thicker to keep plate horizontal
translate([0, -(ks_thick + ks_drop), -ks_depth])
cube([ks_width, ks_thick + ks_drop, wall]);
}
// ── Triangular gussets (left + right ends) ────────────
// Each gusset is a hull of three patches:
// A vertical strip up the rear face (height = ks_gusset_h)
// B small square at plate root (Y=-ks_thick, Z=0)
// C small square at plate tip (Y=-(ks_thick+ks_drop), Z=-ks_depth)
for(bx = [0, ks_width - ks_thick]) {
hull() {
// A: attachment strip going up the rear face
translate([bx, 0, -wall])
cube([ks_thick, ks_gusset_h, wall]);
// B: plate root corner
translate([bx, -ks_thick, -wall])
cube([ks_thick, ks_thick, wall]);
// C: plate tip corner
translate([bx, -(ks_thick + ks_drop), -ks_depth])
cube([ks_thick, ks_thick, wall]);
}
}
}
}
// ============================================================
// FRONT BEZEL
// ============================================================
// Two-piece design: bezel + rear cover join with M3 screws through
// the bezel corners into heat-set inserts in the rear cover bosses.
// The bezel is intentionally removable for Pi access.
module front_bezel() {
difference() {
cbox(enc_w, enc_h, wall + recess);
// Active display window (full depth cut)
translate([(enc_w - scr_active_w)/2,
(enc_h - scr_active_h)/2,
-0.1])
cube([scr_active_w, scr_active_h, wall+recess+0.2]);
// 1 mm recess pocket — bezel lip grips screen edge
translate([(enc_w - scr_w)/2,
(enc_h - scr_h)/2,
wall])
cube([scr_w, scr_h, recess+0.1]);
// M3 screw clearance holes at 4 corners
for(x = [corner_inset, enc_w - corner_inset])
for(y = [corner_inset, enc_h - corner_inset])
translate([x, y, -0.1])
cylinder(d=m3_dia, h=wall+recess+0.2);
}
}
// ============================================================
// REAR COVER
// ============================================================
module rear_cover() {
n_vent = 6;
vent_block_w = n_vent*(vent_w+vent_sp) - vent_sp;
difference() {
union() {
// Main body
cbox(enc_w, enc_h, enc_d);
// Kickstand (integral, no supports needed — prints face-down)
kickstand();
// Insert bosses at 4 corners (inner rear face, flush with enc_d)
for(x = [corner_inset, enc_w - corner_inset])
for(y = [corner_inset, enc_h - corner_inset])
translate([x, y, enc_d])
rotate([180, 0, 0])
insert_boss();
}
// ── HOLLOW INTERIOR ───────────────────────────────────
// Cavity = full screen footprint, from wall to enc_d (open toward bezel)
translate([wall, wall, wall])
cube([scr_w, scr_h, enc_d]);
// ── PORT CUTOUTS ──────────────────────────────────────
// NOTE: The Pi's port edges are internal (Pi centred on screen).
// Cutouts in the enclosure walls are reference openings for
// short cable extensions routed to the wall. Adjust Y/Z offsets
// to match your exact cable routing once screen mount is verified.
// LEFT WALL — USB-C power + HDMI ×2
// Approximate Y positions relative to Pi bottom edge
pi_bot = pi_enc_cy - pi_h/2;
// USB-C power
translate([-0.1, pi_bot + 3, pi_z + 2]) cube([wall+0.2, 11, 11]);
// HDMI 0
translate([-0.1, pi_bot + 16, pi_z + 2]) cube([wall+0.2, 17, 9]);
// HDMI 1
translate([-0.1, pi_bot + 35, pi_z + 2]) cube([wall+0.2, 17, 9]);
// RIGHT WALL — RJ45 + USB-A ×4
pi_top = pi_enc_cy + pi_h/2;
// RJ45
translate([enc_w-wall-0.1, pi_top - 24, pi_z + 1])
cube([wall+0.2, 22, 16]);
// USB-A ×4 (two stacked pairs)
translate([enc_w-wall-0.1, pi_bot + 2, pi_z + 1])
cube([wall+0.2, 50, 15]);
// BOTTOM WALL — USB-C touch connector on screen side edge
// (screen's own USB-C touch port, not Pi — sits at screen depth)
translate([-0.1,
enc_h/2 - 6,
wall + scr_d - 5])
cube([wall+0.2, 12, 8]);
// ── COOLING VENTS ─────────────────────────────────────
// Bottom intake — 6 slots through bottom wall
translate([enc_w/2 - vent_block_w/2, -0.1, wall + 8])
rotate([-90, 0, 0])
vent_row(n_vent, vent_l, vent_w, vent_sp, wall+0.2);
// Top exhaust — 6 slots through top wall
translate([enc_w/2 - vent_block_w/2,
enc_h - wall + 0.1,
wall + 8])
rotate([90, 0, 0])
vent_row(n_vent, vent_l, vent_w, vent_sp, wall+0.2);
// SoC direct-vent — slot array in rear panel centred over Pi SoC
translate([pi_enc_cx - soc_vent_sz/2,
pi_enc_cy - soc_vent_sz/2,
enc_d - wall - 0.1]) {
n_soc = floor(soc_vent_sz / (vent_w + vent_sp));
for(i = [0:n_soc-1])
translate([i*(vent_w+vent_sp),
soc_vent_sz/2 - vent_l/2,
0])
slot(vent_l, vent_w, wall+0.2);
}
// ── CABLE GLANDS — rear panel, bottom area ────────────
// Two M16 glands through the rear face (Z=0 plane).
// Positioned below Pi, above kickstand root.
for(i = [0:gland_count-1]) {
cx = enc_w/2 + (i - (gland_count-1)/2) * gland_spacing;
translate([cx, wall + gland_dia/2 + 4, -0.1])
cylinder(d=gland_dia, h=wall+0.2);
}
}
}
// ============================================================
// SCENE — exploded assembly view
// Front bezel floats above rear cover to show the joint
// ============================================================
color("DarkSlateGray", 0.9)
translate([0, 0, enc_d + 12])
front_bezel();
color("SlateGray", 0.85)
rear_cover();

View File

@@ -0,0 +1,338 @@
// ============================================================
// RPi5 Industrial Enclosure — Luckfox DHX-10.1" Touchscreen
// Version: 004
// Changes vs 003:
// 1. KICKSTAND is now a separate, removable piece (own module + color)
// - Full enc_w width (no shorter ks_width param)
// - 3 prongs on rear edge that slide into slots in case bottom wall
// - Tapered prong tips for easy insertion
// 2. Prong slots added to rear cover bottom wall
// 3. USB-C touch cutout on left wall REMOVED (per user request)
// 4. Wall thickness increased 2.5 → 4 mm for rigidity
// 5. Bezel screw holes now countersunk + clearly sized
// 6. Insert bosses on rear cover made taller and more prominent
// so the two-piece (bezel + rear cover) joint is obvious in renders
// 7. Render shows THREE separate bodies: bezel / rear cover / kickstand
// ============================================================
// ── SCREEN PARAMETERS ───────────────────────────────────────
scr_w = 236; // screen outer width (mm)
scr_h = 144; // screen outer height (mm)
scr_d = 19; // screen outer depth (mm)
scr_active_w = 222; // active display area width (mm) ← confirm
scr_active_h = 130; // active display area height (mm) ← confirm
scr_mount_x = 75; // screen rear M2.5 hole pattern X (mm) ← verify
scr_mount_y = 75; // screen rear M2.5 hole pattern Y (mm) ← verify
// ── RASPBERRY PI 5 PARAMETERS ────────────────────────────────
pi_w = 85; // Pi board width (mm)
pi_h = 56; // Pi board height (mm)
pi_d = 17; // Pi board + tallest component (mm)
pi_mnt_x = 58; // Pi mount hole pattern X (mm)
pi_mnt_y = 49; // Pi mount hole pattern Y (mm)
pi_standoff = 5; // standoff height screen-rear → Pi PCB (mm)
pi_offset_x = 0; // Pi centre X offset from screen centre (mm)
pi_offset_y = 5; // Pi centre Y offset upward from screen centre (mm)
// ── ENCLOSURE PARAMETERS ─────────────────────────────────────
wall = 4.0; // wall thickness — increased for rigidity (mm)
chamfer = 1.5; // external edge chamfer (mm)
recess = 1.0; // screen recess in front bezel (mm)
gap = 0.3; // bezel ↔ rear cover fit clearance (mm)
// ── VENT PARAMETERS ──────────────────────────────────────────
vent_w = 3; // vent slot width (mm)
vent_l = 20; // vent slot length (mm)
vent_sp = 4; // slot gap edge-to-edge (mm)
soc_vent_sz = 30; // SoC direct-vent area size (mm)
// ── CABLE GLAND PARAMETERS ───────────────────────────────────
gland_count = 2; // number of M16 cable glands
gland_dia = 16.5; // M16 clearance hole diameter (mm)
gland_spacing= 40; // gland centre-to-centre (mm)
// ── KICKSTAND PARAMETERS ─────────────────────────────────────
// Separate removable piece — slides onto case bottom via 3 prongs.
// Wedge shape: thin at rear (prong end), thick at front desk-contact end.
// When flat on desk the screen sits at ks_tilt degrees from horizontal.
ks_tilt = 75; // screen angle from horizontal when standing (deg)
// 75° from horiz ≈ 15° lean-back from vertical
ks_depth = 60; // plate reach in -Z from case rear face (mm)
ks_thick = 5; // plate thickness at thin (rear) end (mm)
// Prong dimensions — 3 prongs slide into 3 slots in case bottom wall
ks_prong_n = 3; // number of prongs
ks_prong_w = 12; // prong width (X, mm)
ks_prong_h = 14; // prong insertion height into cavity (mm)
ks_prong_t = 5; // prong depth (Z, mm) — same as slot depth
ks_prong_clr = 0.25; // diametral clearance for fit (mm)
// ── ASSEMBLY PARAMETERS ──────────────────────────────────────
m3_dia = 3.4; // M3 clearance hole (mm)
m3_cs_dia = 6.5; // M3 countersink diameter (mm)
m3_cs_depth = 3.0; // countersink depth (mm)
insert_dia = 4.2; // M3 heat-set insert OD (mm)
insert_h = 6; // heat-set insert depth (mm)
boss_od = 10; // insert boss outer diameter — prominent (mm)
boss_h = insert_h + 5; // boss total height from inner face (mm)
// ── DERIVED DIMENSIONS ───────────────────────────────────────
rear_d = pi_standoff + pi_d + 10; // cavity depth = 32 mm
enc_w = scr_w + 2*wall; // outer width = 244 mm
enc_h = scr_h + 2*wall; // outer height = 152 mm
enc_d = rear_d + wall; // rear cover depth = 36 mm
// Corner inset for boss/screw centres (keeps them inside the wall)
corner_inset = wall + boss_od/2 + 0.5; // ≈ 9.5 mm
// Pi centre in enclosure coordinates
pi_enc_cx = wall + scr_w/2 + pi_offset_x; // 122 mm
pi_enc_cy = wall + scr_h/2 + pi_offset_y; // 81 mm
pi_z = wall + pi_standoff; // 9 mm (Pi PCB Z from rear face)
// Kickstand geometry
ks_drop = ks_depth * tan(90 - ks_tilt); // ≈ 16 mm tip drop
// Prong slot Z position — prongs sit right at the case rear face
ks_prong_z = 0; // prong rear face flush with case Z=0
$fn = 48;
// ============================================================
// PRIMITIVES
// ============================================================
module cbox(w, h, d, c=chamfer) {
hull() {
translate([c, c, 0]) cube([w-2*c, h-2*c, d ]);
translate([0, c, c]) cube([w, h-2*c, d-2*c]);
translate([c, 0, c]) cube([w-2*c, h, d-2*c]);
}
}
module slot(len, w, d) {
r = w/2;
hull() {
translate([0, -len/2+r, 0]) cylinder(r=r, h=d);
translate([0, len/2-r, 0]) cylinder(r=r, h=d);
}
}
module vent_row(n, len, w, spacing, depth) {
for(i = [0:n-1])
translate([i*(w+spacing), 0, 0])
slot(len, w, depth);
}
// Heat-set insert boss
module insert_boss() {
difference() {
cylinder(d=boss_od, h=boss_h);
cylinder(d=insert_dia, h=insert_h);
translate([0,0,insert_h]) cylinder(d=m3_dia, h=boss_h);
}
}
// Countersunk M3 hole (for bezel face)
module m3_countersunk(depth) {
cylinder(d=m3_dia, h=depth+0.1);
translate([0, 0, depth - m3_cs_depth])
cylinder(d1=m3_dia, d2=m3_cs_dia, h=m3_cs_depth+0.1);
}
// Prong slot — cut from bottom wall, prong inserts up into cavity
module prong_slot() {
translate([-ks_prong_w/2 - ks_prong_clr,
-0.1,
ks_prong_z - ks_prong_clr])
cube([ks_prong_w + 2*ks_prong_clr,
ks_prong_h + wall + 0.2,
ks_prong_t + 2*ks_prong_clr]);
}
// ============================================================
// KICKSTAND (separate removable piece)
// ============================================================
//
// Side view (Y-Z plane, unit standing on kickstand):
//
// Y=0 (case bottom) ─────────────────────────────
// │↑↑↑ prongs (insert into case)
// Y=-ks_thick ─────┼─────────────────────────────────┐
// \ wedge plate (bottom surface │
// \ angled so it lies flat when │
// Y=-(ks_thick+ks_drop)\ screen tilts to ks_tilt) │
// └──────────────────────────────┘
// Z=0 Z=-ks_depth
//
module kickstand() {
ks_front_h = ks_thick + ks_drop; // total height at the front (desk-contact) edge
// Main wedge plate
hull() {
// Rear (thin) edge, at Z=0 — where prongs attach
translate([0, -ks_thick, 0])
cube([enc_w, ks_thick, wall]);
// Front (thick) edge, at Z=-ks_depth — rests on desk
translate([0, -ks_front_h, -ks_depth])
cube([enc_w, ks_front_h, wall]);
}
// Three prongs — evenly spaced, rise from Y=0 into case cavity
for(i = [0:ks_prong_n-1]) {
px = enc_w * (i+1) / (ks_prong_n+1);
translate([px - ks_prong_w/2, 0, ks_prong_z]) {
// Tapered tip so prong slides in easily
hull() {
// Base: full-width prong up to the tapered section
cube([ks_prong_w,
ks_prong_h - 2,
ks_prong_t]);
// Tip: narrowed 1 mm per side for 45° insertion chamfer
translate([1, ks_prong_h - 2, 0])
cube([ks_prong_w - 2, 2, ks_prong_t]);
}
}
}
}
// ============================================================
// FRONT BEZEL
// ============================================================
// Removable front frame — held to rear cover by 4× M3 screws
// through countersunk holes at each corner, threading into
// heat-set inserts pressed into the rear cover's corner bosses.
module front_bezel() {
difference() {
cbox(enc_w, enc_h, wall + recess);
// Active display window
translate([(enc_w - scr_active_w)/2,
(enc_h - scr_active_h)/2,
-0.1])
cube([scr_active_w, scr_active_h, wall+recess+0.2]);
// 1 mm recess pocket — bezel lip grips screen edge
translate([(enc_w - scr_w)/2,
(enc_h - scr_h)/2,
wall])
cube([scr_w, scr_h, recess+0.1]);
// 4× M3 countersunk screw holes at corners
for(x = [corner_inset, enc_w - corner_inset])
for(y = [corner_inset, enc_h - corner_inset])
translate([x, y, 0])
m3_countersunk(wall + recess);
}
}
// ============================================================
// REAR COVER
// ============================================================
module rear_cover() {
n_vent = 6;
vent_block_w = n_vent*(vent_w+vent_sp) - vent_sp;
difference() {
union() {
cbox(enc_w, enc_h, enc_d);
// ── Insert bosses: 4 corners, proud on inner rear face ──
// These are clearly visible tall cylinders that receive the
// heat-set inserts; M3 screws from the bezel thread into them.
for(x = [corner_inset, enc_w - corner_inset])
for(y = [corner_inset, enc_h - corner_inset])
translate([x, y, enc_d])
rotate([180, 0, 0])
insert_boss();
}
// ── HOLLOW INTERIOR ───────────────────────────────────
translate([wall, wall, wall])
cube([scr_w, scr_h, enc_d]);
// ── PORT CUTOUTS ──────────────────────────────────────
// LEFT WALL — USB-C power + HDMI ×2 (Pi left short-edge ports)
pi_bot = pi_enc_cy - pi_h/2;
// USB-C power
translate([-0.1, pi_bot + 3, pi_z + 2]) cube([wall+0.2, 11, 11]);
// HDMI 0
translate([-0.1, pi_bot + 16, pi_z + 2]) cube([wall+0.2, 17, 9]);
// HDMI 1
translate([-0.1, pi_bot + 35, pi_z + 2]) cube([wall+0.2, 17, 9]);
// RIGHT WALL — RJ45 + USB-A ×4 (Pi right short-edge ports)
pi_top = pi_enc_cy + pi_h/2;
// RJ45
translate([enc_w-wall-0.1, pi_top - 24, pi_z + 1])
cube([wall+0.2, 22, 16]);
// USB-A ×4
translate([enc_w-wall-0.1, pi_bot + 2, pi_z + 1])
cube([wall+0.2, 50, 15]);
// NOTE: USB-C touch cutout (screen side) REMOVED per v004 request.
// GPIO header is internal — access by removing bezel + rear cover.
// ── COOLING VENTS ─────────────────────────────────────
// Bottom intake slots (through bottom wall)
translate([enc_w/2 - vent_block_w/2, -0.1, wall + 8])
rotate([-90, 0, 0])
vent_row(n_vent, vent_l, vent_w, vent_sp, wall+0.2);
// Top exhaust slots (through top wall)
translate([enc_w/2 - vent_block_w/2,
enc_h - wall + 0.1,
wall + 8])
rotate([90, 0, 0])
vent_row(n_vent, vent_l, vent_w, vent_sp, wall+0.2);
// SoC direct-vent on rear panel
translate([pi_enc_cx - soc_vent_sz/2,
pi_enc_cy - soc_vent_sz/2,
enc_d - wall - 0.1]) {
n_soc = floor(soc_vent_sz / (vent_w + vent_sp));
for(i = [0:n_soc-1])
translate([i*(vent_w+vent_sp),
soc_vent_sz/2 - vent_l/2,
0])
slot(vent_l, vent_w, wall+0.2);
}
// ── CABLE GLANDS — rear panel, bottom area ────────────
for(i = [0:gland_count-1]) {
cx = enc_w/2 + (i - (gland_count-1)/2) * gland_spacing;
translate([cx, wall + gland_dia/2 + 4, -0.1])
cylinder(d=gland_dia, h=wall+0.2);
}
// ── KICKSTAND PRONG SLOTS — bottom wall ───────────────
// 3 slots matching kickstand prong positions and sizes.
// Slots pass through the bottom wall into the cavity so prongs
// engage wall + (ks_prong_h - wall) mm inside the cavity.
for(i = [0:ks_prong_n-1]) {
px = enc_w * (i+1) / (ks_prong_n+1);
translate([px, 0, ks_prong_z])
prong_slot();
}
}
}
// ============================================================
// SCENE — three separate bodies, exploded for clarity
// ============================================================
// Front bezel — exploded up, face toward viewer
color("DarkSlateGray", 0.92)
translate([0, 0, enc_d + 14])
front_bezel();
// Rear cover — at origin
color("SlateGray", 0.88)
rear_cover();
// Kickstand — exploded below, separate piece
color("DimGray", 0.85)
translate([0, -(ks_thick + ks_drop + 20), 0])
kickstand();

View File

@@ -0,0 +1,354 @@
// ============================================================
// RPi5 Industrial Enclosure — 7" Capacitive Touchscreen
// Version: 005
//
// ASSEMBLY LOGIC (read before printing):
//
// THREE separate printed parts:
//
// 1. REAR COVER — the main box. Open face points toward screen.
// Four corner TOWERS rise from the open front face; each tower
// has a self-tapping M3 pilot hole that opens toward the screen.
// The kickstand prong columns rise from the inner bottom face.
//
// 2. FRONT BEZEL — the display frame. Four countersunk M3 holes at
// the corners align with the rear cover's tower holes.
// Assembly: lay screen face-down, place rear cover over it,
// lay bezel over the front, drive 4× M3×30 self-tapping screws
// from the bezel face through into the corner towers.
//
// 3. KICKSTAND — separate wedge plate. Slide its 3 prongs upward
// into the 3 slots in the case bottom wall. The prong guide
// columns inside the case prevent the prongs from falling into
// the main cavity and give a solid 14 mm engagement.
//
// Changes vs 004:
// - Screen resized to 164.9 × 124.27 mm (7" 1024×600 capacitive)
// - Corner towers replace insert bosses: visible M3 holes on the
// OPEN front face of the rear cover — no hidden geometry
// - Prong guide columns (nested-difference CSG) give the slots a
// closed ceiling so the kickstand prongs cannot fall through
// - Left-wall USB-C touch cutout permanently removed
// - Wall 4 mm retained
// ============================================================
// ── SCREEN PARAMETERS ───────────────────────────────────────
scr_w = 164.9; // screen outer width (mm)
scr_h = 124.27; // screen outer height (mm)
scr_d = 12; // screen body depth (mm) ← confirm with calipers
scr_active_w = 154; // active display width (mm) ← confirm
scr_active_h = 90; // active display height (mm) ← confirm
scr_mount_x = 75; // rear M2.5 hole pattern X (mm) ← verify
scr_mount_y = 75; // rear M2.5 hole pattern Y (mm) ← verify
// ── RASPBERRY PI 5 PARAMETERS ────────────────────────────────
pi_w = 85; // Pi board width (mm)
pi_h = 56; // Pi board height (mm)
pi_d = 17; // Pi board + tallest component (mm)
pi_mnt_x = 58; // Pi mount hole pattern X (mm)
pi_mnt_y = 49; // Pi mount hole pattern Y (mm)
pi_standoff = 5; // standoff: screen rear → Pi PCB (mm)
pi_offset_x = 0; // Pi centre X offset from screen centre (mm)
pi_offset_y = 0; // Pi centre Y offset from screen centre (mm)
// ── ENCLOSURE PARAMETERS ─────────────────────────────────────
wall = 4.0; // wall thickness (mm)
chamfer = 1.5; // external edge chamfer (mm)
recess = 1.0; // screen recess depth in front bezel (mm)
// ── VENT PARAMETERS ──────────────────────────────────────────
vent_w = 3; // slot width (mm)
vent_l = 18; // slot length (mm)
vent_sp = 4; // gap edge-to-edge (mm)
soc_vent_sz = 28; // SoC vent zone (mm, square)
// ── CABLE GLAND PARAMETERS ───────────────────────────────────
gland_count = 2; // number of M16 cable glands
gland_dia = 16.5; // M16 clearance hole diameter (mm)
gland_spacing= 36; // gland centre-to-centre (mm)
// ── KICKSTAND PARAMETERS ─────────────────────────────────────
ks_tilt = 75; // screen angle from horizontal when standing (deg)
ks_depth = 60; // plate reach behind rear face (mm)
ks_thick = 5; // plate thickness at thin (prong) end (mm)
ks_prong_n = 3; // number of prongs
ks_prong_w = 12; // prong width in X (mm)
ks_prong_h = 14; // prong engagement height (mm) — guided inside column
ks_prong_t = 5; // prong thickness in Z (mm)
ks_prong_clr = 0.25; // clearance per side for slide fit (mm)
// ── ASSEMBLY PARAMETERS ──────────────────────────────────────
m3_dia = 3.4; // M3 clearance hole (mm)
m3_pilot = 2.5; // M3 self-tapping pilot hole (mm)
m3_cs_dia = 6.5; // M3 countersink OD (mm)
m3_cs_depth = 3.5; // countersink depth (mm)
// Corner tower — a full-depth solid pillar at each inner corner.
// The M3 pilot hole is drilled from the open front face of the rear cover.
tower_w = 10; // tower footprint width and depth (mm)
tower_hole_d = 12; // M3 pilot hole depth from front face (mm)
// ── DERIVED DIMENSIONS ───────────────────────────────────────
rear_d = pi_standoff + pi_d + 10; // rear cavity depth = 32 mm
enc_w = scr_w + 2*wall; // outer width = 172.9 mm
enc_h = scr_h + 2*wall; // outer height = 132.27 mm
enc_d = rear_d + wall; // rear cover depth = 36 mm
// Pi centre in enclosure coordinates
pi_enc_cx = wall + scr_w/2 + pi_offset_x;
pi_enc_cy = wall + scr_h/2 + pi_offset_y;
pi_z = wall + pi_standoff; // Pi PCB Z from rear face
// Corner tower position — inset so tower is entirely within the wall zone
// (tower must NOT overlap the screen footprint X=wall..wall+scr_w)
tower_cx = wall/2; // tower centre offset from outer edge
// Four tower centre positions
tower_xs = [tower_cx, enc_w - tower_cx];
tower_ys = [tower_cx, enc_h - tower_cx];
// Kickstand wedge geometry
ks_drop = ks_depth * tan(90 - ks_tilt); // tip drop ≈ 16 mm
// Prong column Z extent (guide column behind and into cavity)
col_z_size = ks_prong_t + 2*wall; // = 13 mm
col_y_size = ks_prong_h; // = 14 mm (above inner bottom face)
col_x_size = ks_prong_w + wall; // = 16 mm (centred on prong)
$fn = 48;
// ============================================================
// PRIMITIVES
// ============================================================
module cbox(w, h, d, c=chamfer) {
hull() {
translate([c, c, 0]) cube([w-2*c, h-2*c, d ]);
translate([0, c, c]) cube([w, h-2*c, d-2*c ]);
translate([c, 0, c]) cube([w-2*c, h, d-2*c ]);
}
}
module slot(len, w, d) {
r = w/2;
hull() {
translate([0, -len/2+r, 0]) cylinder(r=r, h=d);
translate([0, len/2-r, 0]) cylinder(r=r, h=d);
}
}
module vent_row(n, len, w, spacing, depth) {
for(i = [0:n-1])
translate([i*(w+spacing), 0, 0])
slot(len, w, depth);
}
// Countersunk M3 through-hole for bezel face
module m3_countersunk(total_depth) {
cylinder(d=m3_dia, h=total_depth+0.1);
cylinder(d1=m3_cs_dia, d2=m3_dia, h=m3_cs_depth+0.1);
}
// Prong slot cutter — used in both the rear cover and the guide column
// Origin at the prong centre-X, outer bottom face (Y=0), prong-Z start
module prong_slot_cut() {
translate([-ks_prong_w/2 - ks_prong_clr,
-0.1,
-ks_prong_clr])
cube([ks_prong_w + 2*ks_prong_clr,
wall + ks_prong_h + 0.2,
ks_prong_t + 2*ks_prong_clr]);
}
// ============================================================
// KICKSTAND (separate removable piece, print separately)
// ============================================================
module kickstand() {
ks_front_h = ks_thick + ks_drop; // height at the thick/front end
// Wedge plate — thin at prong end (Z=0), thick at desk end (Z=-ks_depth)
hull() {
translate([0, -ks_thick, 0])
cube([enc_w, ks_thick, wall]);
translate([0, -ks_front_h, -ks_depth])
cube([enc_w, ks_front_h, wall]);
}
// Three tapered prongs on the top edge (rear/thin end)
for(i = [0:ks_prong_n-1]) {
px = enc_w * (i+1) / (ks_prong_n+1);
translate([px - ks_prong_w/2, 0, 0]) {
// Body of prong
cube([ks_prong_w, ks_prong_h - 2, ks_prong_t]);
// Tapered tip (last 2 mm, narrowed 1 mm per side)
translate([0, ks_prong_h - 2, 0])
hull() {
cube([ks_prong_w, 0.01, ks_prong_t]);
translate([1, 2, 0])
cube([ks_prong_w - 2, 0.01, ks_prong_t]);
}
}
}
}
// ============================================================
// FRONT BEZEL (removable — 4× M3 countersunk screws)
// ============================================================
module front_bezel() {
difference() {
cbox(enc_w, enc_h, wall + recess);
// Display window
translate([(enc_w - scr_active_w)/2,
(enc_h - scr_active_h)/2,
-0.1])
cube([scr_active_w, scr_active_h, wall+recess+0.2]);
// 1 mm recess pocket — bezel lip grips screen edge
translate([(enc_w - scr_w)/2,
(enc_h - scr_h)/2,
wall])
cube([scr_w, scr_h, recess+0.1]);
// 4× M3 countersunk screw holes, aligned to corner towers
for(x = tower_xs) for(y = tower_ys)
translate([x, y, 0])
m3_countersunk(wall + recess);
}
}
// ============================================================
// REAR COVER
// ============================================================
//
// CORNER TOWER DESIGN — replaces hidden insert bosses:
// Each corner has a solid square tower (tower_w × tower_w) that
// runs the FULL DEPTH of the cavity (from inner rear face to the
// open front at Z=enc_d). An M3 pilot hole enters from the front
// face (Z=enc_d) and goes tower_hole_d into the tower body.
// Because the tower reaches the front opening, the holes are
// plainly visible from the front of the assembled unit — no
// hidden geometry.
//
// PRONG COLUMN DESIGN — prevents prongs falling into cavity:
// A solid rectangular column rises from the inner bottom face at
// each prong position. The prong slot cuts through the bottom wall
// AND the column. The column ceiling is at Y=wall+ks_prong_h,
// which acts as the hard stop for the prong — it cannot travel
// beyond that height. The column is added AFTER the main interior
// hollow is subtracted (nested-difference CSG), so the hollow
// does not remove it.
//
module rear_cover() {
n_vent = 5;
vent_block_w = n_vent*(vent_w+vent_sp) - vent_sp;
difference() {
// ── SOLID GEOMETRY ────────────────────────────────────
union() {
// 1. Main shell with interior already removed
// (nested so subsequent additions are NOT removed by hollow)
difference() {
cbox(enc_w, enc_h, enc_d);
// Interior hollow — from rear inner face to front opening
translate([wall, wall, wall])
cube([scr_w, scr_h, enc_d]);
}
// 2. Corner towers — full cavity height, clearly visible from front
for(x = tower_xs) for(y = tower_ys)
translate([x - tower_w/2, y - tower_w/2, wall])
cube([tower_w, tower_w, enc_d - wall]);
// 3. Prong guide columns — solid pillars on inner bottom face
// One per prong, gives 14 mm of guided engagement
for(i = [0:ks_prong_n-1]) {
px = enc_w * (i+1) / (ks_prong_n+1);
translate([px - col_x_size/2,
wall,
0])
cube([col_x_size, col_y_size, col_z_size]);
}
}
// ── ALL CUTOUTS ───────────────────────────────────────
// Corner tower M3 pilot holes (from front/open face, going inward)
for(x = tower_xs) for(y = tower_ys)
translate([x, y, enc_d + 0.1])
rotate([180, 0, 0])
cylinder(d=m3_pilot, h=tower_hole_d);
// ── PORT CUTOUTS ──────────────────────────────────────
pi_bot = pi_enc_cy - pi_h/2;
pi_top = pi_enc_cy + pi_h/2;
// LEFT WALL — USB-C power + HDMI ×2
translate([-0.1, pi_bot + 3, pi_z + 2]) cube([wall+0.2, 11, 11]);
translate([-0.1, pi_bot + 16, pi_z + 2]) cube([wall+0.2, 17, 9]);
translate([-0.1, pi_bot + 35, pi_z + 2]) cube([wall+0.2, 17, 9]);
// RIGHT WALL — RJ45 + USB-A ×4
translate([enc_w-wall-0.1, pi_top - 24, pi_z + 1])
cube([wall+0.2, 22, 16]);
translate([enc_w-wall-0.1, pi_bot + 2, pi_z + 1])
cube([wall+0.2, 50, 15]);
// ── COOLING VENTS ─────────────────────────────────────
// Bottom intake
translate([enc_w/2 - vent_block_w/2, -0.1, wall + 6])
rotate([-90, 0, 0])
vent_row(n_vent, vent_l, vent_w, vent_sp, wall+0.2);
// Top exhaust
translate([enc_w/2 - vent_block_w/2, enc_h - wall + 0.1, wall + 6])
rotate([90, 0, 0])
vent_row(n_vent, vent_l, vent_w, vent_sp, wall+0.2);
// SoC direct-vent on rear panel
translate([pi_enc_cx - soc_vent_sz/2,
pi_enc_cy - soc_vent_sz/2,
enc_d - wall - 0.1]) {
n_soc = floor(soc_vent_sz / (vent_w + vent_sp));
for(i = [0:n_soc-1])
translate([i*(vent_w+vent_sp), soc_vent_sz/2 - vent_l/2, 0])
slot(vent_l, vent_w, wall+0.2);
}
// ── CABLE GLANDS — rear panel face ────────────────────
for(i = [0:gland_count-1]) {
cx = enc_w/2 + (i - (gland_count-1)/2) * gland_spacing;
translate([cx, wall + gland_dia/2 + 4, -0.1])
cylinder(d=gland_dia, h=wall+0.2);
}
// ── KICKSTAND PRONG SLOTS ─────────────────────────────
// Each slot cuts through the outer bottom wall AND the guide
// column above it. The column ceiling at Y=wall+ks_prong_h
// is the hard stop — the prong cannot fall through.
for(i = [0:ks_prong_n-1]) {
px = enc_w * (i+1) / (ks_prong_n+1);
translate([px, 0, 0])
prong_slot_cut();
}
}
}
// ============================================================
// SCENE — three parts exploded for visual clarity
// ============================================================
// FRONT BEZEL — floated forward (toward viewer)
color("DarkSlateGray", 0.92)
translate([0, 0, enc_d + 14])
front_bezel();
// REAR COVER — at origin
color("SlateGray", 0.88)
rear_cover();
// KICKSTAND — floated below the case to show it is a separate piece
color("DimGray", 0.85)
translate([0, -(ks_thick + ks_drop + 20), 0])
kickstand();

View File

@@ -0,0 +1,338 @@
// ============================================================
// RPi5 Industrial Enclosure — 7" Capacitive Touchscreen
// Version: 006
//
// ASSEMBLY (three printed parts):
//
// REAR COVER — box, open face toward screen.
// • 4 corner towers, full cavity height, M3 pilot hole from front.
// • 3 prong guide columns on inner bottom face — the columns give
// the prong slots a solid ceiling so nothing falls through.
// • ALL SIDE WALLS ARE SOLID. No port holes.
//
// FRONT BEZEL — display frame.
// • 4× M3 countersunk holes, corners, aligned to tower holes.
// • Screw route: bezel face → screen gap → tower pilot hole.
// • Use M3 × 30 mm self-tapping screws.
//
// KICKSTAND — separate removable wedge plate.
// • Main wedge behind case creates the tilt.
// • Thin ledge extends UNDER the rear of the case 14 mm.
// The ledge carries the 3 prongs which enter the case
// through the BOTTOM face only — rear wall stays solid.
// • Slide ledge+prongs under the case from behind; prongs
// click up into their columns and lock the stand.
//
// Changes vs 005:
// • Right-wall USB-A rectangle REMOVED (and all side-wall cuts).
// Both left and right walls are now fully solid.
// • Prong slots moved to Z = 5.510.5 mm (inside cavity).
// Rear face (Z = 0) is completely uncut — no holes on back.
// • Kickstand gains a 14 mm ledge so prongs reach the new slot Z.
// • Cable glands removed (no rear-face holes).
// ============================================================
// ── SCREEN PARAMETERS ───────────────────────────────────────
scr_w = 164.9; // screen outer width (mm)
scr_h = 124.27; // screen outer height (mm)
scr_d = 12; // screen body depth (mm) ← confirm with calipers
scr_active_w = 154; // active area width (mm) ← confirm from datasheet
scr_active_h = 90; // active area height (mm) ← confirm from datasheet
scr_mount_x = 75; // rear M2.5 mount pattern X (mm) ← verify
scr_mount_y = 75; // rear M2.5 mount pattern Y (mm) ← verify
// ── RASPBERRY PI 5 PARAMETERS ────────────────────────────────
pi_w = 85; // Pi board width (mm)
pi_h = 56; // Pi board height (mm)
pi_d = 17; // Pi board + tallest component (mm)
pi_mnt_x = 58; // Pi mount pattern X (mm)
pi_mnt_y = 49; // Pi mount pattern Y (mm)
pi_standoff = 5; // standoff: screen rear → Pi PCB (mm)
pi_offset_x = 0; // Pi centre X offset from screen centre (mm)
pi_offset_y = 0; // Pi centre Y offset from screen centre (mm)
// ── ENCLOSURE PARAMETERS ─────────────────────────────────────
wall = 4.0; // wall thickness (mm)
chamfer = 1.5; // external edge chamfer (mm)
recess = 1.0; // screen recess depth in front bezel (mm)
// ── VENT PARAMETERS ──────────────────────────────────────────
vent_w = 3;
vent_l = 18;
vent_sp = 4;
soc_vent_sz = 28;
// ── KICKSTAND PARAMETERS ─────────────────────────────────────
ks_tilt = 75; // screen angle from horizontal when standing (deg)
ks_depth = 60; // wedge reach behind rear face (mm)
ks_thick = 5; // plate/ledge thickness (mm)
// Prong dimensions
ks_prong_n = 3; // number of prongs
ks_prong_w = 12; // prong width in X (mm)
ks_prong_h = 14; // prong engagement height (mm)
ks_prong_t = 5; // prong thickness in Z (mm)
ks_prong_clr = 0.25; // clearance per side (mm)
// Ledge: extends under the case so prongs reach inside the cavity
// without cutting through the rear wall.
ks_ledge = 14; // ledge depth in +Z under case (mm)
// must be > wall + ks_prong_t + margin
// Z start of prong slot measured from rear face.
// Must be > wall so the slot never touches the rear face.
ks_prong_z0 = wall + 1.5; // = 5.5 mm — slot from 5.25 to 10.75 mm
// ── ASSEMBLY PARAMETERS ──────────────────────────────────────
m3_dia = 3.4; // M3 clearance hole (mm)
m3_pilot = 2.5; // M3 self-tapping pilot (mm)
m3_cs_dia = 6.5; // M3 countersink OD (mm)
m3_cs_depth = 3.5; // countersink depth (mm)
tower_w = 9; // corner tower footprint (mm square)
tower_hole_d = 14; // pilot hole depth into tower from front face (mm)
corner_inset = 7; // tower/hole centre from outer edge (mm)
// ── DERIVED ──────────────────────────────────────────────────
rear_d = pi_standoff + pi_d + 10; // cavity depth = 32 mm
enc_w = scr_w + 2*wall; // 172.9 mm
enc_h = scr_h + 2*wall; // 132.27 mm
enc_d = rear_d + wall; // 36 mm
pi_enc_cx = wall + scr_w/2 + pi_offset_x;
pi_enc_cy = wall + scr_h/2 + pi_offset_y;
pi_z = wall + pi_standoff;
ks_drop = ks_depth * tan(90 - ks_tilt); // wedge tip drop ≈ 16 mm
// Guide column dimensions (added back inside cavity after hollow)
col_w = ks_prong_w + wall; // 16 mm — solid wall each side of slot
col_h = ks_prong_h; // 14 mm — prong engagement
col_d = ks_ledge; // 14 mm — same as ledge depth
$fn = 48;
// ============================================================
// PRIMITIVES
// ============================================================
module cbox(w, h, d, c=chamfer) {
hull() {
translate([c, c, 0]) cube([w-2*c, h-2*c, d ]);
translate([0, c, c]) cube([w, h-2*c, d-2*c ]);
translate([c, 0, c]) cube([w-2*c, h, d-2*c ]);
}
}
module slot(len, w, d) {
r = w/2;
hull() {
translate([0, -len/2+r, 0]) cylinder(r=r, h=d);
translate([0, len/2-r, 0]) cylinder(r=r, h=d);
}
}
module vent_row(n, len, w, spacing, depth) {
for(i = [0:n-1])
translate([i*(w+spacing), 0, 0])
slot(len, w, depth);
}
module m3_countersunk(total_d) {
cylinder(d=m3_dia, h=total_d+0.1);
cylinder(d1=m3_cs_dia, d2=m3_dia, h=m3_cs_depth+0.1);
}
// Prong slot cutter — called with origin at (prong_cx, 0, ks_prong_z0).
// Cuts bottom wall (Y: -0.1 → wall) + guide column (Y: wall → wall+col_h).
// Z extent stays within [ks_prong_z0-clr, ks_prong_z0+ks_prong_t+clr]
// which is entirely inside the cavity — rear face untouched.
module prong_slot_cut() {
translate([-ks_prong_w/2 - ks_prong_clr,
-0.1,
-ks_prong_clr])
cube([ks_prong_w + 2*ks_prong_clr,
wall + col_h + 0.2,
ks_prong_t + 2*ks_prong_clr]);
}
// ============================================================
// KICKSTAND (separate removable piece — print separately)
// ============================================================
//
// Side cross-section (Y-Z plane):
//
// Y=0 (case bottom) ─────┬─────────────────┐ ← ledge top (fits under case)
// Y=-ks_thick ─────┴─────────────────┘ ← ledge bottom
// Z=ks_ledge Z=0 │
// │ ← wedge (behind case)
// thick ╲ │ thin
// desk contact → ────────╲─┘
// Z=-ks_depth Z=0
//
// The ledge (Z=0→ks_ledge) slides under the case rear.
// Prongs rise from Y=0 at Z=ks_prong_z0, entering the case bottom slots.
// The wedge (Z=-ks_depth→0) rests on the desk and creates the tilt.
//
module kickstand() {
ks_front_h = ks_thick + ks_drop; // thick end of wedge
// 1. Wedge behind case (Z = -ks_depth → 0)
hull() {
translate([0, -ks_thick, 0 ]) cube([enc_w, ks_thick, wall]);
translate([0, -ks_front_h, -ks_depth]) cube([enc_w, ks_front_h, wall]);
}
// 2. Ledge under rear of case (Z = 0 → ks_ledge)
translate([0, -ks_thick, 0])
cube([enc_w, ks_thick, ks_ledge]);
// 3. Three prongs rising from ledge top (Y=0) into case bottom slots
for(i = [0:ks_prong_n-1]) {
px = enc_w * (i+1) / (ks_prong_n+1);
translate([px - ks_prong_w/2, 0, ks_prong_z0]) {
// Main shaft
cube([ks_prong_w, ks_prong_h - 2, ks_prong_t]);
// Tapered tip (45° chamfer for easy insertion)
translate([0, ks_prong_h - 2, 0])
hull() {
cube([ks_prong_w, 0.01, ks_prong_t ]);
translate([1, 2, 0])
cube([ks_prong_w-2, 0.01, ks_prong_t ]);
}
}
}
}
// ============================================================
// FRONT BEZEL
// ============================================================
module front_bezel() {
ci = corner_inset;
difference() {
cbox(enc_w, enc_h, wall + recess);
// Display window
translate([(enc_w - scr_active_w)/2,
(enc_h - scr_active_h)/2, -0.1])
cube([scr_active_w, scr_active_h, wall+recess+0.2]);
// 1 mm recess pocket grips screen edge
translate([(enc_w - scr_w)/2,
(enc_h - scr_h)/2, wall])
cube([scr_w, scr_h, recess+0.1]);
// 4× M3 countersunk screw holes at corners
for(x = [ci, enc_w-ci])
for(y = [ci, enc_h-ci])
translate([x, y, 0])
m3_countersunk(wall + recess);
}
}
// ============================================================
// REAR COVER
// ============================================================
module rear_cover() {
ci = corner_inset;
n_vent = 5;
vbw = n_vent*(vent_w+vent_sp) - vent_sp; // vent block width
difference() {
// ── SOLID GEOMETRY (nested CSG) ───────────────────────
union() {
// A) Shell with interior already removed.
// Nested so the additions below are NOT eaten by the hollow.
difference() {
cbox(enc_w, enc_h, enc_d);
translate([wall, wall, wall])
cube([scr_w, scr_h, enc_d]);
}
// B) Corner towers — full cavity height.
// M3 pilot hole is drilled from the open front face,
// clearly visible when looking into the case before assembly.
for(x = [ci, enc_w-ci])
for(y = [ci, enc_h-ci])
translate([x - tower_w/2, y - tower_w/2, wall])
cube([tower_w, tower_w, enc_d - wall]);
// C) Prong guide columns.
// One solid column per prong, rising from the inner bottom
// face (Y=wall) by col_h=14 mm, spanning Z=0→col_d=14 mm.
// The prong slot cuts through this column, giving a closed
// ceiling at Y=wall+col_h — prongs cannot fall through.
// The column Z=0→5.25 mm is NOT cut by the slot, so the
// rear face (Z=0) remains completely solid at these spots.
for(i = [0:ks_prong_n-1]) {
px = enc_w * (i+1) / (ks_prong_n+1);
translate([px - col_w/2, wall, 0])
cube([col_w, col_h, col_d]);
}
}
// ── CUTOUTS (applied to everything above) ─────────────
// Corner tower pilot holes from open front face
for(x = [ci, enc_w-ci])
for(y = [ci, enc_h-ci])
translate([x, y, enc_d + 0.1])
rotate([180, 0, 0])
cylinder(d=m3_pilot, h=tower_hole_d);
// Cooling — bottom intake slots (through bottom wall, Y direction)
translate([enc_w/2 - vbw/2, -0.1, wall + 6])
rotate([-90, 0, 0])
vent_row(n_vent, vent_l, vent_w, vent_sp, wall+0.2);
// Cooling — top exhaust slots (through top wall, Y direction)
translate([enc_w/2 - vbw/2, enc_h - wall + 0.1, wall + 6])
rotate([90, 0, 0])
vent_row(n_vent, vent_l, vent_w, vent_sp, wall+0.2);
// Cooling — SoC direct vent on rear panel
translate([pi_enc_cx - soc_vent_sz/2,
pi_enc_cy - soc_vent_sz/2,
enc_d - wall - 0.1]) {
n_soc = floor(soc_vent_sz / (vent_w + vent_sp));
for(i = [0:n_soc-1])
translate([i*(vent_w+vent_sp),
soc_vent_sz/2 - vent_l/2, 0])
slot(vent_l, vent_w, wall+0.2);
}
// Kickstand prong slots — bottom face ONLY.
// Slot Z: [ks_prong_z0-clr, ks_prong_z0+ks_prong_t+clr]
// = [5.25, 10.75] mm — entirely past the rear wall (Z=0-4 mm).
// Rear face at Z=0 is untouched.
for(i = [0:ks_prong_n-1]) {
px = enc_w * (i+1) / (ks_prong_n+1);
translate([px, 0, ks_prong_z0])
prong_slot_cut();
}
// NOTE: All side-wall port cutouts removed — both left and right
// walls are solid. Access to Pi ports via short extension cables
// routed through user-drilled holes as needed for the installation.
}
}
// ============================================================
// SCENE — three parts exploded for inspection
// ============================================================
// Front bezel — floated forward
color("DarkSlateGray", 0.92)
translate([0, 0, enc_d + 14])
front_bezel();
// Rear cover — at origin
color("SlateGray", 0.88)
rear_cover();
// Kickstand — floated below and behind to show it is separate
// In real use: slide it up from below until prongs click into columns.
color("DimGray", 0.85)
translate([0, -(ks_thick + ks_drop + 20), 0])
kickstand();

113
security_risks.md Normal file
View File

@@ -0,0 +1,113 @@
# Security Risks Review (mis-control-tower)
This review focuses on the risks highlighted in the tweet you shared: backend-first architecture, trusting the client, column/role escalation, and missing rate limits.
Good news: this project is backend-first and uses Prisma on the server, not direct-to-DB from the client. Prisma itself is not the core risk here. The main issues are around authorization scope, secret handling, and rate limiting.
## Critical: Cross-org reminder trigger + weak auth fallback
### What can go wrong
Any logged-in user can trigger the reminders job if the secret is not set, and the job queries across all orgs. This can spam email reminders to users in other orgs.
### Source
- Auth fallback to "any session" when secret missing: `app/api/downtime/actions/reminders/route.ts:40`
- Cross-org query (no `orgId` filter): `app/api/downtime/actions/reminders/route.ts:62`
### Why this maps to the tweet
This is a classic "missing backend guardrails" and "rate limits/abuse" problem.
### Fix ideas
- Require `DOWNTIME_ACTION_REMINDER_SECRET` in all environments (fail closed if missing).
- If you want session-based access, also require:
- role check (OWNER/ADMIN), and
- explicit `orgId` scoping in the `findMany` query.
- Consider also logging who triggered it.
---
## High: Invite token exposure + invite claim risk
### What can go wrong
A regular member can retrieve active invite tokens and then accept invites intended for other people.
### Source
- Members GET has no role check: `app/api/org/members/route.ts:23`
- Members GET returns raw invite tokens: `app/api/org/members/route.ts:52`
- Accepting an invite creates a user for the invite email and marks it verified based only on the token: `app/api/invites/[token]/route.ts:93`, `app/api/invites/[token]/route.ts:98`
### Why this maps to the tweet
This is a "hidden columns / privilege escalation" flavor of bug: sensitive fields (tokens) are being exposed to users who should not see them.
### Fix ideas
- Add a role check to `GET /api/org/members` (OWNER/ADMIN only).
- Do not return invite tokens from the API (or only return to OWNER/ADMIN).
- Optional hardening:
- Bind invites more tightly to identity (e.g., require proof of email ownership), or
- require the invite acceptance flow to complete a verification step before granting access.
---
## Medium: Pairing code brute force path to machine API keys
### What can go wrong
Pairing codes are short and the pairing endpoint returns the machine API key. Without rate limiting, attackers can attempt many codes and occasionally succeed.
### Source
- Pairing codes length = 5: `lib/pairingCode.ts:5`
- Pair endpoint returns `apiKey`: `app/api/machines/pair/route.ts:56`
### Why this maps to the tweet
This aligns with "rate limits are not optional anymore" and "dont trust defaults."
### Fix ideas
- Add rate limiting to `/api/machines/pair` (by IP and/or code prefix).
- Increase pairing code entropy (length and/or attempt tracking).
- Track failed attempts and temporarily disable pairing for a machine after too many failures.
---
## Medium: Missing rate limiting on high-abuse endpoints
### What can go wrong
Attackers can brute-force or abuse endpoints to consume resources and/or trigger unwanted actions.
### Source (representative endpoints)
- Login: `app/api/login/route.ts:20`
- Signup: `app/api/signup/route.ts:26`
- Pairing: `app/api/machines/pair/route.ts:12`
- Ingest: `app/api/ingest/kpi/route.ts:35`, `app/api/ingest/heartbeat/route.ts:33`, `app/api/ingest/event/route.ts:60`, `app/api/ingest/reason/route.ts:11`
### Why this maps to the tweet
This is directly item #10 in the tweet: rate limits at auth, API routes, and webhooks/ingest.
### Fix ideas
- Apply rate limiting to:
- auth endpoints (`/api/login`, `/api/signup`, invite acceptance),
- pairing (`/api/machines/pair`),
- ingest endpoints (especially if publicly reachable).
- Even a simple KV-based limiter or middleware-based limiter is a large improvement.
---
## Not the core risk: Prisma usage
### Observation
This project uses Prisma server-side via Next.js route handlers and server components. I did not see direct DB calls from the browser.
### Source (representative)
- Session enforcement in API routes: `lib/auth/requireSession.ts:42`
- Server-side data access in routes: `app/api/machines/route.ts:31`, `app/api/settings/route.ts:146`
### Why this matters
This avoids the tweets main direct-to-DB + RLS pitfalls, but you still need strong authorization and rate limiting in your own backend.
---
## Quick fix priority order
1. Lock down `POST /api/downtime/actions/reminders` (fail closed + org scoping).
2. Lock down `GET /api/org/members` and stop exposing invite tokens.
3. Add rate limiting to pairing and auth endpoints.
4. Consider increasing pairing code entropy + attempt tracking.
If you want, I can implement the first two fixes in small, safe patches.

235
snappy.md Normal file
View File

@@ -0,0 +1,235 @@
# Snappy UX plan (Next.js)
## Goals
- Make every navigation feel instant (<50ms feedback) via loading UI and disabled re-clicks.
- Reduce server and data latency for heavy pages (Overview, Reports).
- Keep data accurate while allowing slight staleness for Settings/Financial (seconds).
## Constraints
- Data-heavy pages with large payloads and expensive queries.
- Users click multiple times when no feedback is shown.
## Success targets
- Navigation feedback in <50ms (loading/skeleton/pending state).
- P95 server response under 300-500ms for most queries; worst cases hidden behind progressive loading.
- No multi-click queueing; one navigation at a time.
---
## Phase 1: Audit and baseline (completed)
### What was instrumented
- Server timing + payload logging on Overview, Reports, Reports Filters, Machines APIs.
- Per-step timings inside `getOverviewData` (machines query, events query, normalize/filter).
- Client nav timing hooks were added but not captured due to service env/build config.
### Baseline results (from `/tmp/mis-control-tower.log`)
- Aggregate stats (cold + warm averaged)
- Client nav (`perf.client` nav duration)
- Avg: ~38ms; p50: ~51ms; p95: ~67ms; min: ~5ms; max: ~82ms.
- Overview API (`/api/overview`) total
- Avg: ~3.07s; p50: ~1.73s; p95: ~8.61s; min: ~1.20s; max: ~21.54s.
- `getOverviewData` total
- Avg: ~1.29s; p50: ~1.26s; p95: ~1.35s; min: ~1.15s; max: ~2.41s.
- Machines query (inside Overview)
- Avg: ~1.27s; p50: ~1.25s; p95: ~1.33s; min: ~1.13s; max: ~2.38s.
- Machines API (`/api/machines`) total
- Avg: ~1.26s; p50: ~1.25s; p95: ~1.36s; min: ~1.13s; max: ~1.52s.
- Reports API (`/api/reports`) total
- Avg: ~3.81s; p50: ~468ms; p95: ~18.14s; min: ~168ms; max: ~26.56s.
- Reports filters (`/api/reports/filters`) total
- Avg: ~4.07s; p50: ~367ms; p95: ~16.61s; min: ~57ms; max: ~23.78s.
- Reports payload size
- Avg: ~406KB; p50: ~406KB; p95: ~407KB.
- Overview (`/api/overview`)
- Total: ~1.32.5s across samples (best ~1.2s, spikes up to ~2.5s).
- `getOverviewData` total: ~1.151.36s typically; one sample ~2.4s.
- **Machines query dominates**: ~1.121.33s (primary bottleneck).
- Events query: ~535ms (minor).
- Payload: ~13KB.
- Machines (`/api/machines`)
- Total: ~1.151.33s per call for 3 machines.
- **Machines query dominates**: ~1.151.33s.
- Payload: ~1.6KB.
- Reports (`/api/reports`)
- Typical total: ~170225ms (later runs), earlier spikes up to ~16s (pre-fix or cold).
- Query timings combined: ~130200ms.
- Row counts: ~1.8k KPI rows, ~6.2k cycles, ~736 events.
- **Payload size ~406KB** (largest).
- Reports filters (`/api/reports/filters`)
- Typical total: ~5668ms (later runs), earlier spikes up to ~23s (pre-fix or cold).
- Query timings: ~3040ms.
- Payload: ~51B.
### Findings
- The dominant latency contributor is the **machines query** used by Overview and Machines endpoints.
- Reports payload is large (~406KB), which impacts UI responsiveness even when queries are moderate.
- Large outliers (multi-second totals) likely come from non-query overhead (session lookup, DB connection wait, or cold start); these need targeted checks.
- Reports and reports filters show totals that are far larger than the summed query timings, confirming significant overhead outside the measured DB queries.
- Client end-to-end nav timing (`perf.client`) is now captured; p95 is ~67ms, slightly above the 50ms target.
- Baseline summaries should average cold and warm samples together for now.
### Data captured
- Logs are stored at `/tmp/mis-control-tower.log`.
- Events include: `perf.overview.api`, `perf.overview.getOverviewData`, `perf.machines.api`, `perf.reports.api`, `perf.reports.filters`.
Update
- Client nav timing is now captured via `/api/debug/perf` (`perf.client` events).
- API timings now include auth/preQuery/postQuery with coldStart/uptimeMs when enabled.
---
## Phase 2: Instant feedback (UX)
### 1) Global route loading
- Add `app/(app)/loading.tsx` with a lightweight skeleton for the shell.
- Ensure each heavy route also has its own `loading.tsx` for targeted skeletons.
### 2) Sidebar pending state
- Use `useTransition` to mark a pending navigation.
- Disable repeated clicks and show a subtle spinner on the active item.
- Optional: debounce repeated clicks for 300-500ms.
### 3) Suspense boundaries
- Wrap the slowest sections (events, charts, tables) in `<Suspense>` with skeletons.
- Ensure initial shell renders immediately even if data is still loading.
Deliverables
- Users always see visual feedback within a single frame.
- Double-clicks do not queue up extra navigations.
Progress
- Added route-level loading skeletons for the app shell and heavy routes.
- Sidebar uses `useTransition` with a pending spinner and blocks repeat clicks.
- Added Suspense + lazy loading for the Overview timeline and Reports charts.
---
## Phase 3: Split heavy pages (Overview + Reports)
### Overview (split)
- First paint: show lightweight summary data (machines list + latest heartbeat + tiny event count).
- Defer: fetch full event stream and detailed KPIs via client API after initial render.
- Use an explicit "Load more" or lazy loading for event details.
Implementation sketch
- Create a `getOverviewSummary` for the initial server render.
- Create a client fetch (`/api/overview?detail=1`) for detailed events and charts.
- Replace large data arrays with preview-sized payloads.
Progress
- Overview now uses `getOverviewSummary` for first paint, and `/api/overview?detail=1` for deferred detail fetch.
- Summary responses are cached in-memory with TTL + in-flight de-dupe (`perf.overview.summary` shows cache hits).
- Reports charts are lazy-loaded with placeholders; heavy chart blocks render after the shell.
### Reports (split)
- Render the report shell and filters immediately.
- Lazy-load heavy charts with `next/dynamic` and loading placeholders.
- Fetch chart data on demand (per chart or on viewport with IntersectionObserver).
- Paginate any large tables or use virtualization.
Deliverables
- Overview/Reports initial response is fast and small.
- Deep detail loads after the UI is already visible.
---
## Phase 4: Caching + data freshness
### 1) Page-level caching
- Remove `force-dynamic` where it is not required.
- Use `revalidate` on pages that can be stale for a few seconds (Settings, Financial).
### 2) Data cache for Prisma queries
- Wrap stable fetchers in `unstable_cache` with short TTL and tags (per org).
- Add manual refresh button on Settings/Financial to bypass cache when needed.
### 3) API cache headers
- Use `ETag` and `If-None-Match` where possible.
- For logged-in data, use `private` caching with short max-age.
Deliverables
- Fewer full recomputes for repeated navigations.
- Settings/Financial feel instant, but still correct.
Progress
- Added session cache + throttled `lastSeenAt` updates to reduce auth overhead spikes.
- Added cached GETs with short TTL + per-org tags for Settings + Financial config/impact.
- Added refresh bypass (`?refresh=1`) and a refresh button on Financial.
- Added ETag + private cache headers for Settings + Financial config, plus private cache headers for Financial impact.
- Restored `force-dynamic` on the authenticated layout to avoid static render errors from `cookies()`.
---
## Phase 5: Query + payload tuning
- Reduce `select` fields to only what the UI needs on first render.
- Cap `take` sizes with clear UI controls to load more.
- Add indexes for `orgId + ts` combos used in orderBy filters.
- Consider summary tables for expensive aggregations.
Progress
- Split machine fetch into base + latest heartbeat/KPI queries to avoid nested relation orderBy/take on large tables.
- Added indexes for heartbeat tsServer lookup and machine ordering by orgId + createdAt.
- Machines base query dropped to low ms; new hotspots are latest heartbeat (~250-300ms) and latest KPI (~800-900ms).
- Overview/Machines now log `heartbeatsQuery` + `kpiQuery` to track the new bottlenecks.
---
## What helped most
- Overview split + summary cache: repeat navigations are instant and detail loads later.
- Route-level loading + pending state: immediate feedback reduced double-clicks.
- Session cache + throttled lastSeen: reduced non-query overhead spikes.
- Short TTL caches with refresh bypass: Settings/Financial feel instant without losing correctness.
- Query shape changes: removed nested relation ordering and shifted load to targeted queries.
## Methodology / optimization strategy
- Instrument first, measure cold + warm, and store logs.
- Use timing breakdowns to find the dominant step.
- Improve perceived performance early (skeletons, pending state).
- Split payloads into summary + deferred detail.
- Cache low-risk data with short TTL + refresh bypass and ETag for 304s.
- Tune queries with smaller selects, indexes, and safer query shapes; consider denormalizing if needed.
## Validation
- Measure navigation feedback time (click to loading UI). Goal: <50ms.
- Track p95 TTFB and payload size for Overview and Reports before/after.
- Confirm that repeated clicks no longer add latency or duplicated requests.
---
## Open opportunities
- Optimize latest KPI query (index on `orgId + machineId + tsServer` or denormalize latest KPI onto `Machine`).
- Reduce Reports payload size (trim fields, paginate, or virtualize tables).
- Consider summary tables/materialized views for heavy aggregates.
## Further implementation plan (later)
1) Latest KPI/heartbeat acceleration
- Add index for KPI lookups by server time: `@@index([orgId, machineId, tsServer])`.
- Switch KPI “latest” ordering to `tsServer` to match index.
- Optional: denormalize `latestHeartbeat` + `latestKpi` onto `Machine` and update on ingest.
- Add background backfill job for legacy machines.
2) Machines + Overview caching
- Increase summary cache TTL (30-60s) to raise hit rates.
- Add per-org cache invalidation when a heartbeat/KPI ingests.
- Add ETag handling to `/api/machines` (similar to overview detail).
3) Reports payload trim
- Reduce fields in `reports` response to the chart/minimum.
- Add pagination for large tables (KPIs/cycles/scrap).
- Add “Download full dataset” endpoint separate from UI view.
4) Connection + ORM tuning
- Enable Prisma query logging to identify slow SQL.
- Evaluate connection pool size and cold-start behavior in serverless.
- Move heavy aggregates to `GROUP BY` at DB level with indexes.
5) UX refinements
- Add inline “last updated” timestamp in Overview/Reports headers.
- Show cache-hit badges when content is served from cache.
- Add optional “refresh” on the overview to re-fetch detail data.