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

View File

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

View File

@@ -1,31 +1,22 @@
import { AppShell } from "@/components/layout/AppShell"; import { AppShell } from "@/components/layout/AppShell";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
const COOKIE_NAME = "mis_session"; 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 cookieJar = await cookies();
const sessionId = cookieJar.get(COOKIE_NAME)?.value; const sessionId = cookieJar.get(COOKIE_NAME)?.value;
const themeCookie = cookieJar.get("mis_theme")?.value; const themeCookie = cookieJar.get("mis_theme")?.value;
const initialTheme = themeCookie === "light" ? "light" : "dark"; const initialTheme = themeCookie === "light" ? "light" : "dark";
if (!sessionId) redirect("/login?next=/machines"); if (!sessionId) redirect("/login");
// 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");
}
return <AppShell initialTheme={initialTheme}>{children}</AppShell>; 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; fwVersion?: string | null;
}; };
}; };
const LIVE_REFRESH_MS = 5000;
function secondsAgo(ts: string | undefined, locale: string, fallback: string) { function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
if (!ts) return fallback; if (!ts) return fallback;
@@ -52,7 +53,7 @@ export default function MachinesClient({ initialMachines = [] }: { initialMachin
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const router = useRouter(); const router = useRouter();
const [machines, setMachines] = useState<MachineRow[]>(() => initialMachines); const [machines, setMachines] = useState<MachineRow[]>(() => initialMachines);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(() => initialMachines.length === 0);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [createName, setCreateName] = useState(""); const [createName, setCreateName] = useState("");
const [createCode, setCreateCode] = useState(""); const [createCode, setCreateCode] = useState("");
@@ -69,28 +70,36 @@ export default function MachinesClient({ initialMachines = [] }: { initialMachin
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
let timer: ReturnType<typeof setTimeout> | null = null;
async function load() { async function load(initial: boolean) {
try { try {
if (!initial && typeof document !== "undefined" && document.hidden) {
return;
}
const res = await fetch("/api/machines", { cache: "no-store" }); const res = await fetch("/api/machines", { cache: "no-store" });
const json = await res.json(); const json = await res.json();
if (alive) { if (alive) {
setMachines(json.machines ?? []); setMachines(json.machines ?? []);
setLoading(false); if (initial) setLoading(false);
} }
} catch { } catch {
if (alive) setLoading(false); if (alive && initial) setLoading(false);
} finally {
if (!alive) return;
timer = setTimeout(() => {
void load(false);
}, LIVE_REFRESH_MS);
} }
} }
load(); void load(initialMachines.length === 0);
const t = setInterval(load, 15000);
return () => { return () => {
alive = false; alive = false;
clearInterval(t); if (timer) clearTimeout(timer);
}; };
}, []); }, [initialMachines.length]);
async function createMachine() { async function createMachine() {
if (!createName.trim()) { if (!createName.trim()) {

View File

@@ -128,6 +128,7 @@ const TOL = 0.10;
const DEFAULT_MICRO_MULT = 1.5; const DEFAULT_MICRO_MULT = 1.5;
const DEFAULT_MACRO_MULT = 5; const DEFAULT_MACRO_MULT = 5;
const NORMAL_TOL_SEC = 0.1; const NORMAL_TOL_SEC = 0.1;
const LIVE_REFRESH_MS = 5000;
const BUCKET = { const BUCKET = {
normal: { normal: {
@@ -289,6 +290,24 @@ function rowsToWorkOrders(rows: WorkOrderRow[]): WorkOrderUpload[] {
return out; 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() { export default function MachineDetailClient() {
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const { screenlessMode } = useScreenlessMode(); const { screenlessMode } = useScreenlessMode();
@@ -319,9 +338,14 @@ export default function MachineDetailClient() {
if (!machineId) return; if (!machineId) return;
let alive = true; let alive = true;
let timer: ReturnType<typeof setTimeout> | null = null;
async function load() { async function load(initial: boolean) {
try { try {
if (!initial && typeof document !== "undefined" && document.hidden) {
return;
}
const res = await fetch(`/api/machines/${machineId}?windowSec=3600&events=critical`, { const res = await fetch(`/api/machines/${machineId}?windowSec=3600&events=critical`, {
cache: "no-cache", cache: "no-cache",
credentials: "include", credentials: "include",
@@ -329,7 +353,7 @@ export default function MachineDetailClient() {
if (res.status === 304) { if (res.status === 304) {
if (!alive) return; if (!alive) return;
setLoading(false); if (initial) setLoading(false);
return; return;
} }
@@ -338,8 +362,8 @@ export default function MachineDetailClient() {
if (!alive) return; if (!alive) return;
if (!res.ok || json?.ok === false) { if (!res.ok || json?.ok === false) {
setError(json?.error ?? t("machine.detail.error.failed")); setError(toErrorMessage(json?.error, t("machine.detail.error.failed")));
setLoading(false); if (initial) setLoading(false);
return; return;
} }
@@ -350,19 +374,25 @@ export default function MachineDetailClient() {
setThresholds(json.thresholds ?? null); setThresholds(json.thresholds ?? null);
setActiveStoppage(json.activeStoppage ?? null); setActiveStoppage(json.activeStoppage ?? null);
setError(null); setError(null);
setLoading(false); if (initial) setLoading(false);
} catch { } catch {
if (!alive) return; if (!alive) return;
if (initial) {
setError(t("machine.detail.error.network")); setError(t("machine.detail.error.network"));
setLoading(false); setLoading(false);
} }
} finally {
if (!alive) return;
timer = setTimeout(() => {
void load(false);
}, LIVE_REFRESH_MS);
}
} }
load(); void load(true);
const timer = setInterval(load, 15000);
return () => { return () => {
alive = false; alive = false;
clearInterval(timer); if (timer) clearTimeout(timer);
}; };
}, [machineId, t]); }, [machineId, t]);
@@ -479,7 +509,7 @@ export default function MachineDetailClient() {
} else { } else {
setUploadState({ setUploadState({
status: "error", status: "error",
message: json?.error ?? t("machine.detail.workOrders.uploadError"), message: toErrorMessage(json?.error, t("machine.detail.workOrders.uploadError")),
}); });
} }
event.target.value = ""; event.target.value = "";
@@ -508,7 +538,7 @@ export default function MachineDetailClient() {
const res = await fetch(`/api/machines/${machineId}`, { method: "DELETE" }); const res = await fetch(`/api/machines/${machineId}`, { method: "DELETE" });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) { 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"); router.push("/machines");
} catch (err: unknown) { } catch (err: unknown) {
@@ -886,9 +916,10 @@ export default function MachineDetailClient() {
const cycleDerived = useMemo(() => { const cycleDerived = useMemo(() => {
const rows = cycles ?? []; const rows = cycles ?? [];
const fallbackIdeal = cycleTarget && cycleTarget > 0 ? cycleTarget : null;
const mapped: CycleDerivedRow[] = rows.map((cycle) => { const mapped: CycleDerivedRow[] = rows.map((cycle) => {
const ideal = cycle.ideal ?? null; const ideal = cycle.ideal ?? fallbackIdeal;
const actual = cycle.actual ?? null; const actual = cycle.actual ?? null;
const extra = ideal != null && actual != null ? actual - ideal : 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; const avgDeltaPct = deltas.length ? deltas.reduce((a, b) => a + b, 0) / deltas.length : null;
return { mapped, counts, avgDeltaPct }; return { mapped, counts, avgDeltaPct };
}, [cycles, thresholds]); }, [cycles, cycleTarget, thresholds]);
const deviationSeries = useMemo(() => { const deviationSeries = useMemo(() => {
const last = cycleDerived.mapped.slice(-100); 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" className="h-[380px] rounded-3xl border border-white/10 bg-black/30 p-4 backdrop-blur"
style={{ boxShadow: "var(--app-chart-shadow)" }} style={{ boxShadow: "var(--app-chart-shadow)" }}
> >
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%" minHeight={200}>
<ComposedChart data={deviationSeries}> <ComposedChart data={deviationSeries}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" /> <CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
<XAxis <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" className="h-[380px] rounded-3xl border border-white/10 bg-black/30 p-4 backdrop-blur"
style={{ boxShadow: "var(--app-chart-shadow)" }} style={{ boxShadow: "var(--app-chart-shadow)" }}
> >
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%" minHeight={200}>
<BarChart data={impactAgg.rows}> <BarChart data={impactAgg.rows}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" /> <CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
<XAxis dataKey="label" tick={{ fill: "var(--app-chart-tick)" }} /> <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 { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession"; import { requireSession } from "@/lib/auth/requireSession";
import {
fetchLatestHeartbeats,
fetchMachineBase,
mergeMachineOverviewRows,
} from "@/lib/machines/withLatest";
import MachinesClient from "./MachinesClient"; import MachinesClient from "./MachinesClient";
function toIso(value?: Date | null) { function toIso(value?: Date | null) {
@@ -11,34 +15,32 @@ export default async function MachinesPage() {
const session = await requireSession(); const session = await requireSession();
if (!session) redirect("/login?next=/machines"); if (!session) redirect("/login?next=/machines");
const machines = await prisma.machine.findMany({ const machines = await fetchMachineBase(session.orgId);
where: { orgId: session.orgId }, const heartbeats = await fetchLatestHeartbeats(
orderBy: { createdAt: "desc" }, session.orgId,
select: { machines.map((machine) => machine.id)
id: true, );
name: true, const rows = mergeMachineOverviewRows({
code: true, machines,
location: true, heartbeats,
createdAt: true, includeKpi: false,
updatedAt: true,
heartbeats: {
orderBy: { tsServer: "desc" },
take: 1,
select: { ts: true, tsServer: true, status: true, message: true, ip: true, fwVersion: true },
},
},
}); });
const initialMachines = machines.map((machine) => ({ const initialMachines = rows.map((machine) => ({
...machine, id: machine.id,
latestHeartbeat: machine.heartbeats[0] name: machine.name,
code: machine.code ?? null,
location: machine.location ?? null,
latestHeartbeat: machine.latestHeartbeat
? { ? {
...machine.heartbeats[0], ts: toIso(machine.latestHeartbeat.ts) ?? "",
ts: toIso(machine.heartbeats[0].ts) ?? "", tsServer: toIso(machine.latestHeartbeat.tsServer),
tsServer: toIso(machine.heartbeats[0].tsServer), status: machine.latestHeartbeat.status,
message: machine.latestHeartbeat.message ?? null,
ip: machine.latestHeartbeat.ip ?? null,
fwVersion: machine.latestHeartbeat.fwVersion ?? null,
} }
: null, : null,
heartbeats: undefined,
})); }));
return <MachinesClient initialMachines={initialMachines} />; return <MachinesClient initialMachines={initialMachines} />;

View File

@@ -1,57 +1,13 @@
"use client"; "use client";
import Link from "next/link"; 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"; import { useI18n } from "@/lib/i18n/useI18n";
import type { EventRow, Heartbeat, MachineRow } from "./types";
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";
};
const OFFLINE_MS = 30000; const OFFLINE_MS = 30000;
const MAX_EVENT_MACHINES = 6; const MAX_EVENT_MACHINES = 6;
const OverviewTimeline = lazy(() => import("./OverviewTimeline"));
function secondsAgo(ts: string | undefined, locale: string, fallback: string) { function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
if (!ts) return fallback; if (!ts) return fallback;
@@ -87,17 +43,20 @@ function fmtNum(v?: number | null) {
return `${Math.round(v)}`; return `${Math.round(v)}`;
} }
function severityClass(sev?: string) { function OverviewTimelineSkeleton() {
const s = (sev ?? "").toLowerCase(); return (
if (s === "critical") return "bg-red-500/15 text-red-300"; <div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-2">
if (s === "warning") return "bg-yellow-500/15 text-yellow-300"; <div className="mb-3 flex items-center justify-between">
if (s === "info") return "bg-blue-500/15 text-blue-300"; <div className="h-4 w-32 rounded bg-white/10" />
return "bg-white/10 text-zinc-200"; <div className="h-3 w-20 rounded bg-white/5" />
} </div>
<div className="space-y-3">
function sourceClass(src: EventRow["source"]) { {Array.from({ length: 4 }).map((_, idx) => (
if (src === "ingested") return "bg-white/10 text-zinc-200"; <div key={idx} className="h-20 rounded-xl border border-white/10 bg-black/20" />
return "bg-white/10 text-zinc-200"; ))}
</div>
</div>
);
} }
export default function OverviewClient({ export default function OverviewClient({
@@ -111,7 +70,7 @@ export default function OverviewClient({
const [machines, setMachines] = useState<MachineRow[]>(() => initialMachines); const [machines, setMachines] = useState<MachineRow[]>(() => initialMachines);
const [events, setEvents] = useState<EventRow[]>(() => initialEvents); const [events, setEvents] = useState<EventRow[]>(() => initialEvents);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [eventsLoading, setEventsLoading] = useState(false); const [eventsLoading, setEventsLoading] = useState(() => initialEvents.length === 0);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
@@ -119,9 +78,12 @@ export default function OverviewClient({
async function load() { async function load() {
try { try {
setEventsLoading(true); 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", cache: "no-cache",
}); }
);
if (res.status === 304) { if (res.status === 304) {
if (alive) setLoading(false); if (alive) setLoading(false);
return; return;
@@ -166,6 +128,7 @@ export default function OverviewClient({
let goodSum = 0; let goodSum = 0;
let scrapSum = 0; let scrapSum = 0;
let targetSum = 0; let targetSum = 0;
let hasKpi = false;
for (const m of machines) { for (const m of machines) {
const hb = m.latestHeartbeat; const hb = m.latestHeartbeat;
@@ -183,22 +146,35 @@ export default function OverviewClient({
if (k?.oee != null) { if (k?.oee != null) {
oeeSum += Number(k.oee); oeeSum += Number(k.oee);
oeeCount += 1; oeeCount += 1;
hasKpi = true;
} }
if (k?.availability != null) { if (k?.availability != null) {
availSum += Number(k.availability); availSum += Number(k.availability);
availCount += 1; availCount += 1;
hasKpi = true;
} }
if (k?.performance != null) { if (k?.performance != null) {
perfSum += Number(k.performance); perfSum += Number(k.performance);
perfCount += 1; perfCount += 1;
hasKpi = true;
} }
if (k?.quality != null) { if (k?.quality != null) {
qualSum += Number(k.quality); qualSum += Number(k.quality);
qualCount += 1; 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 { return {
@@ -212,9 +188,9 @@ export default function OverviewClient({
availability: availCount ? availSum / availCount : null, availability: availCount ? availSum / availCount : null,
performance: perfCount ? perfSum / perfCount : null, performance: perfCount ? perfSum / perfCount : null,
quality: qualCount ? qualSum / qualCount : null, quality: qualCount ? qualSum / qualCount : null,
goodSum, goodSum: hasKpi ? goodSum : null,
scrapSum, scrapSum: hasKpi ? scrapSum : null,
targetSum, targetSum: hasKpi ? targetSum : null,
}; };
}, [machines]); }, [machines]);
@@ -238,27 +214,6 @@ export default function OverviewClient({
return list; return list;
}, [machines]); }, [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 ( return (
<div className="p-4 sm:p-6"> <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"> <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>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-2"> <Suspense fallback={<OverviewTimelineSkeleton />}>
<div className="mb-3 flex items-center justify-between"> <OverviewTimeline events={events} eventsLoading={eventsLoading} locale={locale} t={t} />
<div className="text-sm font-semibold text-white">{t("overview.timeline")}</div> </Suspense>
<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>
</div> </div>
</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 { redirect } from "next/navigation";
import { requireSession } from "@/lib/auth/requireSession"; 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"; import OverviewClient from "./OverviewClient";
function toIso(value?: Date | null) { function toIso(value?: Date | null) {
@@ -11,12 +13,18 @@ export default async function OverviewPage() {
const session = await requireSession(); const session = await requireSession();
if (!session) redirect("/login?next=/overview"); if (!session) redirect("/login?next=/overview");
const { machines, events } = await getOverviewData({ let machines: Awaited<ReturnType<typeof getOverviewData>>["machines"];
orgId: session.orgId, let events: Awaited<ReturnType<typeof getOverviewData>>["events"] = [];
eventsMode: "critical", try {
eventsWindowSec: 21600, const data = await getOverviewSummary({ orgId: session.orgId });
eventMachines: 6, 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) => ({ const initialMachines = machines.map((machine) => ({
...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"; import { redirect } from "next/navigation";
export default function LegacyDowntimeParetoPage({ export default async function LegacyDowntimeParetoPage({
searchParams, searchParams,
}: { }: {
searchParams: Record<string, string | string[] | undefined>; searchParams: Promise<Record<string, string | string[] | undefined>>;
}) { }) {
const params = await searchParams;
const qs = new URLSearchParams(); 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); if (typeof v === "string") qs.set(k, v);
else if (Array.isArray(v)) v.forEach((vv) => qs.append(k, vv)); else if (Array.isArray(v)) v.forEach((vv) => qs.append(k, vv));
} }

View File

@@ -1,19 +1,9 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { Suspense, lazy, useEffect, useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n"; import { useI18n } from "@/lib/i18n/useI18n";
import {
Bar, const ReportsCharts = lazy(() => import("./ReportsCharts"));
BarChart,
CartesianGrid,
Cell,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
type RangeKey = "24h" | "7d" | "30d" | "custom"; type RangeKey = "24h" | "7d" | "30d" | "custom";
@@ -68,13 +58,6 @@ type ReportPayload = {
type MachineOption = { id: string; name: string }; type MachineOption = { id: string; name: string };
type FilterOptions = { workOrders: string[]; skus: string[] }; type FilterOptions = { workOrders: string[]; skus: string[] };
type Translator = (key: string, vars?: Record<string, string | number>) => 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) { function fmtPct(v?: number | null) {
if (v === null || v === undefined || Number.isNaN(v)) return "--"; if (v === null || v === undefined || Number.isNaN(v)) return "--";
@@ -106,56 +89,20 @@ function formatTickLabel(ts: string, range: RangeKey) {
return `${month}-${day}`; return `${month}-${day}`;
} }
function CycleTooltip({ active, payload, t }: SimpleTooltipProps<CycleHistogramRow> & { t: Translator }) { function ReportsChartsSkeleton() {
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 ( 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-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
<div className="mt-2 space-y-1 text-xs text-zinc-300"> {Array.from({ length: 2 }).map((_, idx) => (
<div> <div key={idx} className="h-[320px] rounded-2xl border border-white/10 bg-white/5" />
{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>
<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> </div>
</>
); );
} }
@@ -534,6 +481,21 @@ export default function ReportsPage() {
Microstop: "#FF7A00", 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(() => { const machineLabel = useMemo(() => {
if (!machineId) return t("reports.filter.allMachines"); if (!machineId) return t("reports.filter.allMachines");
return machines.find((m) => m.id === machineId)?.name ?? machineId; return machines.find((m) => m.id === machineId)?.name ?? machineId;
@@ -696,147 +658,18 @@ export default function ReportsPage() {
))} ))}
</div> </div>
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2"> <Suspense fallback={<ReportsChartsSkeleton />}>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5"> <ReportsCharts
<div className="mb-2 text-sm font-semibold text-white">{t("reports.oeeTrend")}</div> oeeSeries={oeeSeries}
<div className="h-[260px] rounded-2xl border border-white/10 bg-black/25 p-4"> downtimeSeries={downtimeSeries}
{oeeSeries.length ? ( downtimeColors={downtimeColors}
<ResponsiveContainer width="100%" height="100%"> cycleHistogram={cycleHistogram}
<LineChart data={oeeSeries}> scrapSeries={scrapSeries}
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" /> lossRows={lossRows}
<XAxis dataKey="label" tick={{ fill: "var(--app-chart-tick)" }} /> locale={locale}
<YAxis domain={[0, 100]} tick={{ fill: "var(--app-chart-tick)" }} /> t={t}
<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} /> </Suspense>
</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>
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2"> <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="rounded-2xl border border-white/10 bg-white/5 p-5">

View File

@@ -1,9 +1,10 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AlertsConfig } from "@/components/settings/AlertsConfig"; import { AlertsConfig } from "@/components/settings/AlertsConfig";
import { FinancialCostConfig } from "@/components/settings/FinancialCostConfig"; import { FinancialCostConfig } from "@/components/settings/FinancialCostConfig";
import { useI18n } from "@/lib/i18n/useI18n"; import { useI18n } from "@/lib/i18n/useI18n";
import { SHIFT_OVERRIDE_DAYS, type ShiftOverrideDay } from "@/lib/settings";
import { useScreenlessMode } from "@/lib/ui/screenlessMode"; import { useScreenlessMode } from "@/lib/ui/screenlessMode";
@@ -25,6 +26,7 @@ type SettingsPayload = {
shiftSchedule: { shiftSchedule: {
shifts: Shift[]; shifts: Shift[];
overrides?: Partial<Record<ShiftOverrideDay, Shift[]>>;
shiftChangeCompensationMin: number; shiftChangeCompensationMin: number;
lunchBreakMin: number; lunchBreakMin: number;
}; };
@@ -88,6 +90,7 @@ const DEFAULT_SETTINGS: SettingsPayload = {
modules: { screenlessMode: false }, modules: { screenlessMode: false },
shiftSchedule: { shiftSchedule: {
shifts: [], shifts: [],
overrides: {},
shiftChangeCompensationMin: 10, shiftChangeCompensationMin: 10,
lunchBreakMin: 30, lunchBreakMin: 30,
}, },
@@ -199,6 +202,21 @@ function normalizeShift(raw: unknown, fallbackName: string): Shift {
return { name, start, end, enabled }; 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 { function normalizeSettings(raw: unknown, fallbackName: (index: number) => string): SettingsPayload {
const record = asRecord(raw); const record = asRecord(raw);
const modules = asRecord(record?.modules) ?? {}; const modules = asRecord(record?.modules) ?? {};
@@ -217,6 +235,7 @@ function normalizeSettings(raw: unknown, fallbackName: (index: number) => string
const shifts = shiftsRaw.length const shifts = shiftsRaw.length
? shiftsRaw.map((s, idx) => normalizeShift(s, fallbackName(idx + 1))) ? shiftsRaw.map((s, idx) => normalizeShift(s, fallbackName(idx + 1)))
: [{ name: fallbackName(1), ...DEFAULT_SHIFT }]; : [{ name: fallbackName(1), ...DEFAULT_SHIFT }];
const overrides = normalizeShiftOverrides(shiftSchedule.overrides, fallbackName);
const thresholds = asRecord(record.thresholds) ?? {}; const thresholds = asRecord(record.thresholds) ?? {};
const alerts = asRecord(record.alerts) ?? {}; const alerts = asRecord(record.alerts) ?? {};
const defaults = asRecord(record.defaults) ?? {}; const defaults = asRecord(record.defaults) ?? {};
@@ -227,6 +246,7 @@ function normalizeSettings(raw: unknown, fallbackName: (index: number) => string
timezone: String(record.timezone ?? DEFAULT_SETTINGS.timezone), timezone: String(record.timezone ?? DEFAULT_SETTINGS.timezone),
shiftSchedule: { shiftSchedule: {
shifts, shifts,
overrides,
shiftChangeCompensationMin: Number( shiftChangeCompensationMin: Number(
shiftSchedule.shiftChangeCompensationMin ?? DEFAULT_SETTINGS.shiftSchedule.shiftChangeCompensationMin shiftSchedule.shiftChangeCompensationMin ?? DEFAULT_SETTINGS.shiftSchedule.shiftChangeCompensationMin
), ),
@@ -326,16 +346,26 @@ export default function SettingsPage() {
const [inviteStatus, setInviteStatus] = useState<string | null>(null); const [inviteStatus, setInviteStatus] = useState<string | null>(null);
const [inviteSubmitting, setInviteSubmitting] = useState(false); const [inviteSubmitting, setInviteSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState<(typeof SETTINGS_TABS)[number]["id"]>("general"); const [activeTab, setActiveTab] = useState<(typeof SETTINGS_TABS)[number]["id"]>("general");
const hasMountedRef = useRef(false);
const defaultShiftName = useCallback( const defaultShiftName = useCallback(
(index: number) => t("settings.shift.defaultName", { index }), (index: number) => t("settings.shift.defaultName", { index }),
[t] [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); setLoading(true);
setError(null); setError(null);
try { 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 { data, text } = await readResponse(response);
const api = unwrapApiResponse(data); const api = unwrapApiResponse(data);
if (!response.ok || !api.ok) { if (!response.ok || !api.ok) {
@@ -350,7 +380,7 @@ export default function SettingsPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [defaultShiftName, t]); }, [defaultShiftName, t, setScreenlessMode]);
const buildInviteUrl = useCallback((token: string) => { const buildInviteUrl = useCallback((token: string) => {
if (typeof window === "undefined") return `/invite/${token}`; if (typeof window === "undefined") return `/invite/${token}`;
@@ -380,10 +410,14 @@ export default function SettingsPage() {
} }
}, [t]); }, [t]);
// Only run once on mount to prevent infinite loops from dependency changes
useEffect(() => { useEffect(() => {
if (hasMountedRef.current) return;
hasMountedRef.current = true;
loadSettings(); loadSettings();
loadTeam(); loadTeam();
}, [loadSettings, loadTeam]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const updateShift = useCallback((index: number, patch: Partial<Shift>) => { const updateShift = useCallback((index: number, patch: Partial<Shift>) => {
setDraft((prev) => { 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( const updateThreshold = useCallback(
( (
key: key:
@@ -665,7 +789,7 @@ export default function SettingsPage() {
</div> </div>
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto"> <div className="flex w-full flex-wrap items-center gap-2 sm:w-auto">
<button <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" 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")} {t("settings.refresh")}
@@ -994,6 +1118,119 @@ export default function SettingsPage() {
</div> </div>
</div> </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> </div>
)} )}

View File

@@ -64,7 +64,7 @@ export async function GET(req: Request) {
count: g._count._all, 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); 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 { 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 { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession"; import { requireSession } from "@/lib/auth/requireSession";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { z } from "zod"; 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) { function canManageFinancials(role?: string | null) {
return role === "OWNER"; return role === "OWNER";
@@ -101,18 +110,37 @@ async function ensureOrgFinancialProfile(
}); });
} }
async function loadFinancialConfig(orgId: string) { function toMs(value?: Date | string | null) {
const [org, locations, machines, products] = await Promise.all([ if (!value) return 0;
prisma.orgFinancialProfile.findUnique({ where: { orgId } }), const date = typeof value === "string" ? new Date(value) : value;
prisma.locationFinancialOverride.findMany({ where: { orgId }, orderBy: { location: "asc" } }), const ms = date.getTime();
prisma.machineFinancialOverride.findMany({ where: { orgId }, orderBy: { createdAt: "desc" } }), return Number.isNaN(ms) ? 0 : ms;
prisma.productCostOverride.findMany({ where: { orgId }, orderBy: { sku: "asc" } }),
]);
return { org, locations, machines, products };
} }
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(); const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); 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 }); 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)); await prisma.$transaction((tx) => ensureOrgFinancialProfile(tx, session.orgId, session.userId));
const payload = await loadFinancialConfig(session.orgId); const payload = await getFinancialConfig(session.orgId, { refresh });
return NextResponse.json({ ok: true, ...payload });
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) { 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 }); return NextResponse.json({ ok: true, ...payload });
} }

View File

@@ -2,7 +2,11 @@ import { NextResponse } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession"; 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> = { const RANGE_MS: Record<string, number> = {
"24h": 24 * 60 * 60 * 1000, "24h": 24 * 60 * 60 * 1000,
@@ -50,13 +54,15 @@ export async function GET(req: NextRequest) {
} }
const url = new URL(req.url); const url = new URL(req.url);
const refresh = url.searchParams.get("refresh") === "1";
const { start, end } = pickRange(req); const { start, end } = pickRange(req);
const machineId = url.searchParams.get("machineId") ?? undefined; const machineId = url.searchParams.get("machineId") ?? undefined;
const location = url.searchParams.get("location") ?? undefined; const location = url.searchParams.get("location") ?? undefined;
const sku = url.searchParams.get("sku") ?? undefined; const sku = url.searchParams.get("sku") ?? undefined;
const currency = url.searchParams.get("currency") ?? undefined; const currency = url.searchParams.get("currency") ?? undefined;
const result = await computeFinancialImpact({ const result = await getFinancialImpactCached(
{
orgId: session.orgId, orgId: session.orgId,
start, start,
end, end,
@@ -65,7 +71,14 @@ export async function GET(req: NextRequest) {
sku, sku,
currency, currency,
includeEvents: false, 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) => { const numberFromAny = z.preprocess((value) => {
if (typeof value === "number") return value; if (typeof value === "number") return value;
if (typeof value === "string" && value.trim() !== "") return Number(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 }); 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); const parsedCycles = z.array(cycleSchema).safeParse(cycleList);
if (!parsedCycles.success) { if (!parsedCycles.success) {
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 }); return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
} }
const fallbackTsMs = const fallbackTsMs =
(typeof bodyRecord.tsMs === "number" && bodyRecord.tsMs) || asNumber(bodyRecord.tsMs) ||
(typeof bodyRecord.tsDevice === "number" && bodyRecord.tsDevice) || asNumber(bodyRecord.tsDevice) ||
undefined; undefined;
const rows = parsedCycles.data.map((data) => { const rows = parsedCycles.data.map((data) => {

View File

@@ -4,6 +4,14 @@ import { getMachineAuth } from "@/lib/machineAuthCache";
import { z } from "zod"; import { z } from "zod";
import { evaluateAlertsForEvent } from "@/lib/alerts/engine"; import { evaluateAlertsForEvent } from "@/lib/alerts/engine";
import { toJsonValue } from "@/lib/prismaJson"; import { toJsonValue } from "@/lib/prismaJson";
import {
findCatalogReason,
loadFallbackReasonCatalog,
normalizeReasonCatalog,
toReasonCode,
type ReasonCatalog,
type ReasonCatalogKind,
} from "@/lib/reasonCatalog";
const normalizeType = (t: unknown) => const normalizeType = (t: unknown) =>
String(t ?? "") String(t ?? "")
@@ -30,6 +38,8 @@ const CANON_TYPE: Record<string, string> = {
"microparo": "microstop", "microparo": "microstop",
"micro-paro": "microstop", "micro-paro": "microstop",
"down": "stop", "down": "stop",
"downtime-acknowledged": "downtime-acknowledged",
"scrap-manual-entry": "scrap-manual-entry",
}; };
const ALLOWED_TYPES = new Set([ const ALLOWED_TYPES = new Set([
@@ -42,6 +52,8 @@ const ALLOWED_TYPES = new Set([
"quality-spike", "quality-spike",
"performance-degradation", "performance-degradation",
"predictive-oee-decline", "predictive-oee-decline",
"downtime-acknowledged",
"scrap-manual-entry",
]); ]);
const machineIdSchema = z.string().uuid(); const machineIdSchema = z.string().uuid();
@@ -58,6 +70,153 @@ function clampText(value: unknown, maxLen: number) {
return text.length > maxLen ? text.slice(0, maxLen) : text; 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) { export async function POST(req: Request) {
const apiKey = req.headers.get("x-api-key"); const apiKey = req.headers.get("x-api-key");
if (!apiKey) return NextResponse.json({ ok: false, error: "Missing api key" }, { status: 401 }); 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 }); if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const orgSettings = await prisma.orgSettings.findUnique({ const orgSettings = await prisma.orgSettings.findUnique({
where: { orgId: machine.orgId }, where: { orgId: machine.orgId },
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 defaultMicroMultiplier = Number(orgSettings?.stoppageMultiplier ?? 1.5);
const defaultMacroMultiplier = Math.max( const defaultMacroMultiplier = Math.max(
@@ -129,6 +291,8 @@ export async function POST(req: Request) {
continue; continue;
} }
const evData = asRecord(evRecord.data) ?? {}; 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 rawType = evRecord.eventType ?? evRecord.anomaly_type ?? evRecord.topic ?? bodyRecord.topic ?? "";
const typ0 = normalizeType(rawType); 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.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_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 (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 activeWorkOrder = asRecord(evRecord.activeWorkOrder);
const dataActiveWorkOrder = asRecord(evData.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 }); 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 { try {
if (row.eventType !== "downtime-acknowledged" && row.eventType !== "scrap-manual-entry") {
await evaluateAlertsForEvent(row.id); await evaluateAlertsForEvent(row.id);
}
} catch (err) { } catch (err) {
console.error("[alerts] evaluation failed", err); console.error("[alerts] evaluation failed", err);
} }

View File

@@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
import { getMachineAuth } from "@/lib/machineAuthCache"; import { getMachineAuth } from "@/lib/machineAuthCache";
import { normalizeSnapshotV1 } from "@/lib/contracts/v1"; import { normalizeSnapshotV1 } from "@/lib/contracts/v1";
import { toJsonValue } from "@/lib/prismaJson"; import { toJsonValue } from "@/lib/prismaJson";
import { logLine } from "@/lib/logger";
function getClientIp(req: Request) { function getClientIp(req: Request) {
const xf = req.headers.get("x-forwarded-for"); const xf = req.headers.get("x-forwarded-for");
@@ -21,11 +22,68 @@ function parseSeqToBigInt(seq: unknown): bigint | null {
return 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) { export async function POST(req: Request) {
const endpoint = "/api/ingest/kpi"; const endpoint = "/api/ingest/kpi";
const startedAt = Date.now(); const startedAt = Date.now();
const ip = getClientIp(req); const ip = getClientIp(req);
const userAgent = req.headers.get("user-agent"); 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 rawBody: unknown = null;
let orgId: string | null = 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({ return NextResponse.json({
ok: true, ok: true,
id: row.id, id: row.id,
tsDevice: row.ts, tsDevice: row.ts,
tsServer: row.tsServer, tsServer: row.tsServer,
trace: traceEnabled ? trace : undefined,
}); });
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Unknown error"; const msg = err instanceof Error ? err.message : "Unknown error";

View File

@@ -1,9 +1,11 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { Prisma } from "@prisma/client";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession"; import { requireSession } from "@/lib/auth/requireSession";
import { normalizeEvent } from "@/lib/events/normalizeEvent"; import { normalizeEvent } from "@/lib/events/normalizeEvent";
import { invalidateMachineAuth } from "@/lib/machineAuthCache";
const machineIdSchema = z.string().uuid(); 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) { function parseNumber(value: string | null, fallback: number) {
if (value == null || value === "") return fallback;
const parsed = Number(value); const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback; 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 }> }) { export async function GET(req: NextRequest, { params }: { params: Promise<{ machineId: string }> }) {
const session = await requireSession(); const session = await requireSession();
if (!session) { 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 eventWindowStart = new Date(Date.now() - eventsWindowSec * 1000);
const criticalSeverities = ["critical", "error", "high"]; const criticalSeverities = ["critical", "error", "high"];
const eventWhere = { const eventWhereBase = {
orgId: session.orgId, orgId: session.orgId,
machineId, machineId,
ts: { gte: eventWindowStart }, 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([ const [rawEvents, eventsCountAll] = await Promise.all([
prisma.machineEvent.findMany({ prisma.machineEvent.findMany({
where: eventWhere, where: eventWhereBase,
orderBy: { ts: "desc" }, orderBy: { ts: "desc" },
take: eventsOnly ? 300 : 120, take: eventsOnly ? 300 : 120,
select: { select: {
@@ -192,15 +237,29 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ mach
workOrderId: true, workOrderId: true,
}, },
}), }),
prisma.machineEvent.count({ where: eventWhere }), prisma.machineEvent.count({ where: eventWhereBase }),
]); ]);
const normalized = rawEvents.map((row) => const normalized = rawEvents.map((row) =>
normalizeEvent(row, { microMultiplier: stoppageMultiplier, macroMultiplier: macroStoppageMultiplier }) 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 seen = new Set<string>();
const deduped = normalized.filter((event) => { const deduped = filtered.filter((event) => {
const key = `${event.eventType}-${event.ts ?? ""}-${event.title}`; const key = `${event.eventType}-${event.ts ?? ""}-${event.title}`;
if (seen.has(key)) return false; if (seen.has(key)) return false;
seen.add(key); 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 }); return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
} }
const result = await prisma.$transaction(async (tx) => { for (let attempt = 0; attempt < 3; attempt += 1) {
await tx.machineCycle.deleteMany({ try {
where: { if (attempt === 0) {
machineId, // Revoke credentials first in a committed write so ingest auth fails immediately.
orgId: session.orgId, const revoked = await prisma.machine.updateMany({
},
});
return tx.machine.deleteMany({
where: { where: {
id: machineId, id: machineId,
orgId: session.orgId, 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) { if (result.count === 0) {
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 }); return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
} }
invalidateMachineAuth(machineId);
return NextResponse.json({ ok: true }); 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 { NextResponse } from "next/server";
import { randomBytes } from "crypto"; import { randomBytes } from "crypto";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { cookies } from "next/headers";
import { generatePairingCode } from "@/lib/pairingCode"; import { generatePairingCode } from "@/lib/pairingCode";
import { z } from "zod"; 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({ const createMachineSchema = z.object({
name: z.string().trim().min(1).max(80), name: z.string().trim().min(1).max(80),
@@ -13,72 +27,66 @@ const createMachineSchema = z.object({
location: z.string().trim().max(80).optional(), location: z.string().trim().max(80).optional(),
}); });
async function requireSession() { export async function GET(req: Request) {
const sessionId = (await cookies()).get(COOKIE_NAME)?.value; const perfEnabled = PERF_LOGS_ENABLED;
if (!sessionId) return null; 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({ const authStart = nowMs();
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 session = await requireSession(); const session = await requireSession();
if (perfEnabled) timings.auth = elapsedMs(authStart);
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const machines = await prisma.machine.findMany({ const preQueryStart = nowMs();
where: { orgId: session.orgId }, const machinesStart = nowMs();
orderBy: { createdAt: "desc" }, if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
select: { const machines = await fetchMachineBase(session.orgId);
id: true, if (perfEnabled) timings.machinesQuery = elapsedMs(machinesStart);
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 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 // flatten latest heartbeat for UI convenience
const out = machines.map((m) => ({ const out = mergeMachineOverviewRows({
...m, machines,
latestHeartbeat: m.heartbeats[0] ?? null, heartbeats,
latestKpi: m.kpiSnapshots[0] ?? null, kpis,
heartbeats: undefined, includeKpi,
kpiSnapshots: undefined, });
}));
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) { export async function POST(req: Request) {

View File

@@ -4,23 +4,72 @@ import { createHash } from "crypto";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession"; import { requireSession } from "@/lib/auth/requireSession";
import { getOverviewData } from "@/lib/overview/getOverviewData"; 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) { function toMs(value?: Date | null) {
return value ? value.getTime() : 0; return value ? value.getTime() : 0;
} }
export async function GET(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(); const session = await requireSession();
if (perfEnabled) timings.auth = elapsedMs(authStart);
if (!session) { if (!session) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
} }
const url = new URL(req.url); const 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 eventsMode = url.searchParams.get("events") ?? "critical";
const eventsWindowSecRaw = Number(url.searchParams.get("eventsWindowSec") ?? "21600"); const eventsWindowSecRaw = Number(url.searchParams.get("eventsWindowSec") ?? "21600");
const eventsWindowSec = Number.isFinite(eventsWindowSecRaw) ? eventsWindowSecRaw : 21600; const eventsWindowSec = Number.isFinite(eventsWindowSecRaw) ? eventsWindowSecRaw : 21600;
const eventMachinesRaw = Number(url.searchParams.get("eventMachines") ?? "6"); const eventMachinesRaw = Number(url.searchParams.get("eventMachines") ?? "6");
const eventMachines = Number.isFinite(eventMachinesRaw) ? Math.max(1, eventMachinesRaw) : 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([ const [machineAgg, heartbeatAgg, kpiAgg, eventAgg, orgSettings] = await Promise.all([
prisma.machine.aggregate({ prisma.machine.aggregate({
where: { orgId: session.orgId }, where: { orgId: session.orgId },
@@ -43,6 +92,7 @@ export async function GET(req: NextRequest) {
select: { updatedAt: true, stoppageMultiplier: true, macroStoppageMultiplier: true }, select: { updatedAt: true, stoppageMultiplier: true, macroStoppageMultiplier: true },
}), }),
]); ]);
if (perfEnabled) timings.agg = elapsedMs(aggStart);
const lastModifiedMs = Math.max( const lastModifiedMs = Math.max(
toMs(machineAgg._max.updatedAt), toMs(machineAgg._max.updatedAt),
@@ -86,6 +136,7 @@ export async function GET(req: NextRequest) {
} }
} }
const dataStart = nowMs();
const { machines: machineRows, events } = await getOverviewData({ const { machines: machineRows, events } = await getOverviewData({
orgId: session.orgId, orgId: session.orgId,
eventsMode, eventsMode,
@@ -93,9 +144,29 @@ export async function GET(req: NextRequest) {
eventMachines, eventMachines,
orgSettings, orgSettings,
}); });
if (perfEnabled) timings.data = elapsedMs(dataStart);
return NextResponse.json( const postQueryStart = nowMs();
{ ok: true, machines: machineRows, events },
{ headers: responseHeaders } 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 type { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession"; 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> = { const RANGE_MS: Record<string, number> = {
"24h": 24 * 60 * 60 * 1000, "24h": 24 * 60 * 60 * 1000,
@@ -33,10 +43,19 @@ function pickRange(req: NextRequest) {
} }
export async function GET(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(); const session = await requireSession();
if (perfEnabled) timings.auth = elapsedMs(authStart);
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const preQueryStart = nowMs();
const url = new URL(req.url); const url = new URL(req.url);
const range = url.searchParams.get("range") ?? "24h";
const machineId = url.searchParams.get("machineId") ?? undefined; const machineId = url.searchParams.get("machineId") ?? undefined;
const { start, end } = pickRange(req); const { start, end } = pickRange(req);
@@ -46,20 +65,51 @@ export async function GET(req: NextRequest) {
ts: { gte: start, lte: end }, ts: { gte: start, lte: end },
}; };
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
const workOrdersStart = nowMs();
const workOrderRows = await prisma.machineCycle.findMany({ const workOrderRows = await prisma.machineCycle.findMany({
where: { ...baseWhere, workOrderId: { not: null } }, where: { ...baseWhere, workOrderId: { not: null } },
distinct: ["workOrderId"], distinct: ["workOrderId"],
select: { workOrderId: true }, select: { workOrderId: true },
}); });
if (perfEnabled) timings.workOrders = elapsedMs(workOrdersStart);
const skuStart = nowMs();
const skuRows = await prisma.machineCycle.findMany({ const skuRows = await prisma.machineCycle.findMany({
where: { ...baseWhere, sku: { not: null } }, where: { ...baseWhere, sku: { not: null } },
distinct: ["sku"], distinct: ["sku"],
select: { sku: true }, select: { sku: true },
}); });
if (perfEnabled) timings.skus = elapsedMs(skuStart);
const postQueryStart = nowMs();
const workOrders = workOrderRows.map((r) => r.workOrderId).filter(Boolean) as string[]; const workOrders = workOrderRows.map((r) => r.workOrderId).filter(Boolean) as string[];
const skus = skuRows.map((r) => r.sku).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 type { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession"; 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> = { const RANGE_MS: Record<string, number> = {
"24h": 24 * 60 * 60 * 1000, "24h": 24 * 60 * 60 * 1000,
@@ -37,10 +47,19 @@ function safeNum(v: unknown) {
} }
export async function GET(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(); const session = await requireSession();
if (perfEnabled) timings.auth = elapsedMs(authStart);
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const preQueryStart = nowMs();
const url = new URL(req.url); const url = new URL(req.url);
const range = url.searchParams.get("range") ?? "24h";
const machineId = url.searchParams.get("machineId") ?? undefined; const machineId = url.searchParams.get("machineId") ?? undefined;
const { start, end } = pickRange(req); const { start, end } = pickRange(req);
const workOrderId = url.searchParams.get("workOrderId") ?? undefined; const workOrderId = url.searchParams.get("workOrderId") ?? undefined;
@@ -52,6 +71,9 @@ export async function GET(req: NextRequest) {
...(sku ? { sku } : {}), ...(sku ? { sku } : {}),
}; };
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
const kpiStart = nowMs();
const kpiRows = await prisma.machineKpiSnapshot.findMany({ const kpiRows = await prisma.machineKpiSnapshot.findMany({
where: { ...baseWhere, ts: { gte: start, lte: end } }, where: { ...baseWhere, ts: { gte: start, lte: end } },
orderBy: { ts: "asc" }, orderBy: { ts: "asc" },
@@ -67,6 +89,7 @@ export async function GET(req: NextRequest) {
machineId: true, machineId: true,
}, },
}); });
if (perfEnabled) timings.kpiRows = elapsedMs(kpiStart);
let oeeSum = 0; let oeeSum = 0;
let oeeCount = 0; let oeeCount = 0;
@@ -96,10 +119,12 @@ export async function GET(req: NextRequest) {
} }
} }
const cyclesStart = nowMs();
const cycles = await prisma.machineCycle.findMany({ const cycles = await prisma.machineCycle.findMany({
where: { ...baseWhere, ts: { gte: start, lte: end } }, where: { ...baseWhere, ts: { gte: start, lte: end } },
select: { goodDelta: true, scrapDelta: true }, select: { goodDelta: true, scrapDelta: true },
}); });
if (perfEnabled) timings.cycles = elapsedMs(cyclesStart);
let goodTotal = 0; let goodTotal = 0;
let scrapTotal = 0; let scrapTotal = 0;
@@ -109,6 +134,7 @@ export async function GET(req: NextRequest) {
if (safeNum(c.scrapDelta) != null) scrapTotal += Number(c.scrapDelta); if (safeNum(c.scrapDelta) != null) scrapTotal += Number(c.scrapDelta);
} }
const kpiAggStart = nowMs();
const kpiAgg = await prisma.machineKpiSnapshot.groupBy({ const kpiAgg = await prisma.machineKpiSnapshot.groupBy({
by: ["machineId"], by: ["machineId"],
where: { ...baseWhere, ts: { gte: start, lte: end } }, where: { ...baseWhere, ts: { gte: start, lte: end } },
@@ -116,6 +142,7 @@ export async function GET(req: NextRequest) {
_min: { good: true, scrap: true }, _min: { good: true, scrap: true },
_count: { _all: true }, _count: { _all: true },
}); });
if (perfEnabled) timings.kpiAgg = elapsedMs(kpiAggStart);
let targetTotal = 0; let targetTotal = 0;
if (goodTotal === 0 && scrapTotal === 0) { if (goodTotal === 0 && scrapTotal === 0) {
@@ -151,10 +178,12 @@ export async function GET(req: NextRequest) {
if (maxTarget != null) targetTotal += maxTarget; if (maxTarget != null) targetTotal += maxTarget;
} }
const eventsStart = nowMs();
const events = await prisma.machineEvent.findMany({ const events = await prisma.machineEvent.findMany({
where: { ...baseWhere, ts: { gte: start, lte: end } }, where: { ...baseWhere, ts: { gte: start, lte: end } },
select: { eventType: true, data: true }, select: { eventType: true, data: true },
}); });
if (perfEnabled) timings.events = elapsedMs(eventsStart);
let macrostopSec = 0; let macrostopSec = 0;
let microstopSec = 0; let microstopSec = 0;
@@ -223,10 +252,12 @@ export async function GET(req: NextRequest) {
trend.scrapRate.push({ t, v: (scrap / (good + scrap)) * 100 }); trend.scrapRate.push({ t, v: (scrap / (good + scrap)) * 100 });
} }
} }
const cycleRowsStart = nowMs();
const cycleRows = await prisma.machineCycle.findMany({ const cycleRows = await prisma.machineCycle.findMany({
where: { ...baseWhere, ts: { gte: start, lte: end } }, where: { ...baseWhere, ts: { gte: start, lte: end } },
select: { actualCycleTime: true }, select: { actualCycleTime: true },
}); });
if (perfEnabled) timings.cycleRows = elapsedMs(cycleRowsStart);
const values = cycleRows const values = cycleRows
.map((c) => Number(c.actualCycleTime)) .map((c) => Number(c.actualCycleTime))
@@ -310,10 +341,14 @@ export async function GET(req: NextRequest) {
const scrapBySku = new Map<string, number>(); const scrapBySku = new Map<string, number>();
const scrapByWo = new Map<string, number>(); const scrapByWo = new Map<string, number>();
const scrapRowsStart = nowMs();
const scrapRows = await prisma.machineCycle.findMany({ const scrapRows = await prisma.machineCycle.findMany({
where: { ...baseWhere, ts: { gte: start, lte: end } }, where: { ...baseWhere, ts: { gte: start, lte: end } },
select: { sku: true, workOrderId: true, scrapDelta: true }, select: { sku: true, workOrderId: true, scrapDelta: true },
}); });
if (perfEnabled) timings.scrapRows = elapsedMs(scrapRowsStart);
const postQueryStart = nowMs();
for (const row of scrapRows) { for (const row of scrapRows) {
const scrap = safeNum(row.scrapDelta); const scrap = safeNum(row.scrapDelta);
@@ -340,7 +375,7 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ const payload = {
ok: true, ok: true,
summary: { summary: {
oeeAvg, oeeAvg,
@@ -366,8 +401,35 @@ export async function GET(req: NextRequest) {
trend, trend,
insights, insights,
distribution: { 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, validateDefaults,
validateShiftFields, validateShiftFields,
validateShiftSchedule, validateShiftSchedule,
validateShiftOverrides,
validateThresholds, validateThresholds,
} from "@/lib/settings"; } from "@/lib/settings";
import { loadFallbackReasonCatalog, normalizeReasonCatalog, type ReasonCatalog } from "@/lib/reasonCatalog";
import { publishSettingsUpdate } from "@/lib/mqtt"; import { publishSettingsUpdate } from "@/lib/mqtt";
import { z } from "zod"; import { z } from "zod";
@@ -44,6 +46,24 @@ function pickAllowedOverrides(raw: unknown) {
return out; 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( async function ensureOrgSettings(
tx: Prisma.TransactionClient, tx: Prisma.TransactionClient,
orgId: string, orgId: string,
@@ -144,6 +164,7 @@ export async function GET(
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
orgId = machine.orgId; orgId = machine.orgId;
} }
const fallbackCatalog = await loadFallbackReasonCatalog();
const { settings, overrides } = await prisma.$transaction(async (tx) => { const { settings, overrides } = await prisma.$transaction(async (tx) => {
const orgSettings = await ensureOrgSettings(tx, orgId as string, userId); const orgSettings = await ensureOrgSettings(tx, orgId as string, userId);
@@ -154,9 +175,15 @@ export async function GET(
select: { overridesJson: true }, 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 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 }; 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 }); 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); const thresholdsValidation = validateThresholds(patch?.thresholds);
if (!thresholdsValidation.ok) { if (!thresholdsValidation.ok) {
return NextResponse.json({ ok: false, error: thresholdsValidation.error }, { status: 400 }); return NextResponse.json({ ok: false, error: thresholdsValidation.error }, { status: 400 });
@@ -275,6 +310,12 @@ export async function PUT(
...patch, ...patch,
shiftSchedule: { shiftSchedule: {
...patch.shiftSchedule, ...patch.shiftSchedule,
overrides:
patch.shiftSchedule.overrides !== undefined
? overridesResult.overrides === null
? null
: overridesResult.overrides
: patch.shiftSchedule.overrides,
shiftChangeCompensationMin: shiftChangeCompensationMin:
patch.shiftSchedule.shiftChangeCompensationMin !== undefined patch.shiftSchedule.shiftChangeCompensationMin !== undefined
? Number(patch.shiftSchedule.shiftChangeCompensationMin) ? 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 overrides = pickAllowedOverrides(saved.overridesJson ?? {});
const effective = deepMerge(orgPayload, overrides); const effective = withReasonCatalog(
deepMerge(orgPayload, overrides) as Record<string, unknown>,
fallbackCatalog
);
return { return {
orgPayload, orgPayload,

View File

@@ -1,4 +1,7 @@
import { NextResponse } from "next/server"; 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 "@prisma/client";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession"; import { requireSession } from "@/lib/auth/requireSession";
@@ -13,8 +16,10 @@ import {
validateDefaults, validateDefaults,
validateShiftFields, validateShiftFields,
validateShiftSchedule, validateShiftSchedule,
validateShiftOverrides,
validateThresholds, validateThresholds,
} from "@/lib/settings"; } from "@/lib/settings";
import { loadFallbackReasonCatalog, normalizeReasonCatalog, type ReasonCatalog } from "@/lib/reasonCatalog";
import { publishSettingsUpdate } from "@/lib/mqtt"; import { publishSettingsUpdate } from "@/lib/mqtt";
import { z } from "zod"; import { z } from "zod";
@@ -34,6 +39,24 @@ function canManageSettings(role?: string | null) {
return role === "OWNER" || role === "ADMIN"; 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 const settingsPayloadSchema = z
.object({ .object({
source: z.string().trim().max(40).optional(), source: z.string().trim().max(40).optional(),
@@ -43,10 +66,14 @@ const settingsPayloadSchema = z
thresholds: z.any().optional(), thresholds: z.any().optional(),
alerts: z.any().optional(), alerts: z.any().optional(),
defaults: z.any().optional(), defaults: z.any().optional(),
reasonCatalog: z.any().optional(),
version: z.union([z.number(), z.string()]).optional(), version: z.union([z.number(), z.string()]).optional(),
}) })
.passthrough(); .passthrough();
const SETTINGS_TTL_SEC = 10;
const SETTINGS_SWR_SEC = 30;
async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, userId: string) { async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, userId: string) {
let settings = await tx.orgSettings.findUnique({ let settings = await tx.orgSettings.findUnique({
where: { orgId }, where: { orgId },
@@ -111,24 +138,56 @@ async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, us
return { settings, shifts }; return { settings, shifts };
} }
export async function GET() { async function loadSettingsPayload(orgId: string, userId: string) {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
try {
const loaded = await prisma.$transaction(async (tx) => { 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"); if (!found?.settings) throw new Error("SETTINGS_NOT_FOUND");
return 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 defaultsRaw = isPlainObject(loaded.settings.defaultsJson) ? (loaded.settings.defaultsJson as any) : {};
const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {}; const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {};
const modules = { screenlessMode: modulesRaw.screenlessMode === true }; 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) { } catch (err) {
console.error("[settings GET] failed", err); console.error("[settings GET] failed", err);
@@ -162,6 +221,7 @@ export async function PUT(req: Request) {
const thresholds = parsed.data.thresholds; const thresholds = parsed.data.thresholds;
const alerts = parsed.data.alerts; const alerts = parsed.data.alerts;
const defaults = parsed.data.defaults; const defaults = parsed.data.defaults;
const reasonCatalogRaw = parsed.data.reasonCatalog;
const expectedVersion = parsed.data.version; const expectedVersion = parsed.data.version;
const modules = parsed.data.modules; const modules = parsed.data.modules;
@@ -173,6 +233,7 @@ export async function PUT(req: Request) {
thresholds === undefined && thresholds === undefined &&
alerts === undefined && alerts === undefined &&
defaults === undefined && defaults === undefined &&
reasonCatalogRaw === undefined &&
modules === undefined modules === undefined
) { ) {
@@ -191,6 +252,13 @@ export async function PUT(req: Request) {
if (defaults !== undefined && !isPlainObject(defaults)) { if (defaults !== undefined && !isPlainObject(defaults)) {
return NextResponse.json({ ok: false, error: "defaults must be an object" }, { status: 400 }); 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)) { if (modules !== undefined && !isPlainObject(modules)) {
return NextResponse.json({ ok: false, error: "Invalid modules payload" }, { status: 400 }); 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 }); 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); const thresholdsValidation = validateThresholds(thresholds);
if (!thresholdsValidation.ok) { if (!thresholdsValidation.ok) {
return NextResponse.json({ ok: false, error: thresholdsValidation.error }, { status: 400 }); return NextResponse.json({ ok: false, error: thresholdsValidation.error }, { status: 400 });
@@ -257,12 +333,22 @@ export async function PUT(req: Request) {
: { ...currentModulesRaw, screenlessMode }; : { ...currentModulesRaw, screenlessMode };
// Write defaultsJson if either defaults changed OR modules changed // Write defaultsJson if either defaults changed OR modules changed
const shouldWriteDefaultsJson = !!nextDefaultsCore || screenlessMode !== undefined; const shouldWriteDefaultsJson =
!!nextDefaultsCore || screenlessMode !== undefined || reasonCatalogRaw !== undefined;
const nextDefaultsJson = shouldWriteDefaultsJson const nextDefaultsJson = shouldWriteDefaultsJson
? { ...(nextDefaultsCore ?? normalizeDefaults(currentDefaultsRaw)), modules: nextModules } ? { ...(nextDefaultsCore ?? normalizeDefaults(currentDefaultsRaw)), modules: nextModules }
: undefined; : 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({ const updateData = stripUndefined({
timezone: timezone !== undefined ? String(timezone) : undefined, timezone: timezone !== undefined ? String(timezone) : undefined,
@@ -272,6 +358,12 @@ export async function PUT(req: Request) {
: undefined, : undefined,
lunchBreakMin: lunchBreakMin:
shiftSchedule?.lunchBreakMin !== undefined ? Number(shiftSchedule.lunchBreakMin) : undefined, shiftSchedule?.lunchBreakMin !== undefined ? Number(shiftSchedule.lunchBreakMin) : undefined,
shiftScheduleOverridesJson:
shiftSchedule?.overrides !== undefined
? overridesResult.overrides === null
? null
: overridesResult.overrides
: undefined,
stoppageMultiplier: stoppageMultiplier:
thresholds?.stoppageMultiplier !== undefined ? Number(thresholds.stoppageMultiplier) : undefined, thresholds?.stoppageMultiplier !== undefined ? Number(thresholds.stoppageMultiplier) : undefined,
macroStoppageMultiplier: macroStoppageMultiplier:
@@ -373,6 +465,8 @@ export async function PUT(req: Request) {
const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {}; const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {};
const modulesOut = { screenlessMode: modulesRaw.screenlessMode === true }; const modulesOut = { screenlessMode: modulesRaw.screenlessMode === true };
revalidateTag(`settings:${session.orgId}`, { expire: 0 });
return NextResponse.json({ ok: true, settings: { ...payload, modules: modulesOut } }); return NextResponse.json({ ok: true, settings: { ...payload, modules: modulesOut } });
} catch (err) { } 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 { prisma } from "@/lib/prisma";
import InviteAcceptForm from "./InviteAcceptForm"; 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; const session = (await cookies()).get("mis_session")?.value;
if (session) { if (session) {
redirect("/machines"); redirect("/machines");
} }
const resolvedParams = await Promise.resolve(params); const { token: rawToken } = await params;
const token = String(resolvedParams?.token || "").trim().toLowerCase(); const token = String(rawToken || "").trim().toLowerCase();
let invite = null; let invite = null;
let error: string | null = null; let error: string | null = null;

View File

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

View File

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

View File

@@ -80,6 +80,24 @@ type ApiDowntimeEventsRes = {
events?: ApiDowntimeEvent[]; 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) { function fmtDT(iso: string | null) {
if (!iso) return "—"; if (!iso) return "—";
const d = new Date(iso); const d = new Date(iso);
@@ -1155,6 +1173,8 @@ export default function DowntimePageClient() {
const [eventsRes, setEventsRes] = useState<ApiDowntimeEventsRes | null>(null); const [eventsRes, setEventsRes] = useState<ApiDowntimeEventsRes | null>(null);
const [eventsLoading, setEventsLoading] = useState(false); const [eventsLoading, setEventsLoading] = useState(false);
const [eventsErr, setEventsErr] = useState<string | null>(null); 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 [eventsLimit, setEventsLimit] = useState<number>(200);
const [eventsBefore, setEventsBefore] = useState<string | null>(null); const [eventsBefore, setEventsBefore] = useState<string | null>(null);
@@ -1251,6 +1271,41 @@ export default function DowntimePageClient() {
ac.abort(); ac.abort();
}; };
}, [range, machineId]); }, [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(() => { useEffect(() => {
let alive = true; let alive = true;
const ac = new AbortController(); const ac = new AbortController();
@@ -1308,6 +1363,29 @@ export default function DowntimePageClient() {
return metricRowsAll.filter((r) => r.reasonCode === reasonCode); return metricRowsAll.filter((r) => r.reasonCode === reasonCode);
}, [metricRowsAll, 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 totalMinutes = pareto?.totalMinutesLost ?? 0;
const totalStops = useMemo( const totalStops = useMemo(
() => baseRows.reduce((acc, r) => acc + (r.count ?? 0), 0), () => baseRows.reduce((acc, r) => acc + (r.count ?? 0), 0),
@@ -1365,6 +1443,7 @@ const filteredEvents = useMemo(() => {
e.machineName ?? "", e.machineName ?? "",
e.reasonLabel ?? "", e.reasonLabel ?? "",
e.reasonCode ?? "", e.reasonCode ?? "",
e.reasonText ?? "",
e.workOrderId ?? "", e.workOrderId ?? "",
e.episodeId ?? "", e.episodeId ?? "",
] ]
@@ -1467,7 +1546,7 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
)} )}
{reasonCode ? ( {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"> <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 <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" 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 })} 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" className="mt-4 h-[360px] rounded-3xl border border-white/10 bg-black/30 p-4 backdrop-blur"
style={{ boxShadow: "var(--app-chart-shadow)" }} style={{ boxShadow: "var(--app-chart-shadow)" }}
> >
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%" minHeight={200}>
<ComposedChart <ComposedChart
data={heroData} data={heroData}
onClick={(st: any) => { onClick={(st: any) => {
@@ -1883,6 +1962,45 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
</div> </div>
</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="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="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> <div className="col-span-8">Reason</div>
@@ -2162,6 +2280,9 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="truncate text-white">{e.reasonLabel}</div> <div className="truncate text-white">{e.reasonLabel}</div>
<div className="mt-1 text-[11px] text-zinc-500">{e.reasonCode}</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>
<td className="px-4 py-3 text-zinc-200">{e.workOrderId ?? "—"}</td> <td className="px-4 py-3 text-zinc-200">{e.workOrderId ?? "—"}</td>
<td className="px-4 py-3 text-right text-white"> <td className="px-4 py-3 text-right text-white">

View File

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

View File

@@ -2,12 +2,14 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState, useTransition } from "react";
import { BarChart3, Bell, DollarSign, LayoutGrid, LogOut, Settings, Wrench, X } from "lucide-react"; import { BarChart3, Bell, DollarSign, LayoutGrid, Loader2, LogOut, Settings, Wrench, X } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { useI18n } from "@/lib/i18n/useI18n"; import { useI18n } from "@/lib/i18n/useI18n";
import { useScreenlessMode } from "@/lib/ui/screenlessMode"; 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 = { type NavItem = {
href: string; href: string;
@@ -38,6 +40,8 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
const router = useRouter(); const router = useRouter();
const { t } = useI18n(); const { t } = useI18n();
const { screenlessMode } = useScreenlessMode(); const { screenlessMode } = useScreenlessMode();
const [isPending, startTransition] = useTransition();
const [pendingHref, setPendingHref] = useState<string | null>(null);
const [me, setMe] = useState<{ const [me, setMe] = useState<{
user?: { name?: string | null; email?: string | null }; user?: { name?: string | null; email?: string | null };
org?: { name?: string | null }; org?: { name?: string | null };
@@ -93,13 +97,33 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
} }
}, [screenlessMode, pathname, router]); }, [screenlessMode, pathname, router]);
useEffect(() => { useEffect(() => {
visibleItems.forEach((it) => { if (!pendingHref) return;
router.prefetch(it.href); if (pathname === pendingHref || pathname.startsWith(`${pendingHref}/`)) {
}); setPendingHref(null);
}, [router, visibleItems]); } 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 = [ const shellClass = [
"relative z-20 flex flex-col border-r border-white/10 bg-black/40 shrink-0", "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]", 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"> <nav className="px-3 py-2 flex-1 space-y-1">
{visibleItems.map((it) => { {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; const Icon = it.icon;
return ( return (
<Link <Link
key={it.href} key={it.href}
href={it.href} href={it.href}
onMouseEnter={() => router.prefetch(it.href)} prefetch={false}
onClick={onNavigate} 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={[ className={[
"flex items-center gap-3 rounded-xl px-3 py-2 text-sm transition", "flex items-center gap-3 rounded-xl px-3 py-2 text-sm transition",
active active
? "bg-emerald-500/15 text-emerald-300 border border-emerald-500/20" ? "bg-emerald-500/15 text-emerald-300 border border-emerald-500/20"
: "text-zinc-300 hover:bg-white/5 hover:text-white", : "text-zinc-300 hover:bg-white/5 hover:text-white",
navLocked ? "pointer-events-none" : "",
navLocked && !isPendingItem ? "opacity-60" : "",
].join(" ")} ].join(" ")}
> >
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
<span>{t(it.labelKey)}</span> <span>{t(it.labelKey)}</span>
{isPendingItem ? <Loader2 className="ml-auto h-4 w-4 animate-spin text-emerald-300" /> : null}
</Link> </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.shiftCompLabel | Shift change compensation (min) | Compensación por cambio de turno (min) |
| settings.lunchBreakLabel | Lunch break (min) | Comida (min) | | settings.lunchBreakLabel | Lunch break (min) | Comida (min) |
| settings.shift.defaultName | Shift {index} | Turno {index} | | 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.thresholds | Alert thresholds | Umbrales de alertas |
| settings.thresholdsSubtitle | Tune production health alerts. | Ajusta alertas de salud de producción. | | 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 | | 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; 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 { function extractDurationSec(raw: unknown): number | null {
const payload = asRecord(raw); const payload = asRecord(raw);
if (!payload) return null; if (!payload) return null;
@@ -302,10 +315,11 @@ export async function evaluateAlertsForEvent(eventId: string) {
const alertId = readString(payload?.alert_id ?? inner?.alert_id); const alertId = readString(payload?.alert_id ?? inner?.alert_id);
const isUpdate = readBool(payload?.is_update ?? inner?.is_update); const isUpdate = readBool(payload?.is_update ?? inner?.is_update);
const isAutoAck = readBool(payload?.is_auto_ack ?? inner?.is_auto_ack); 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 lastCycleTs = readNumber(payload?.last_cycle_timestamp ?? inner?.last_cycle_timestamp);
const theoreticalSec = readNumber(payload?.theoretical_cycle_time ?? inner?.theoretical_cycle_time); const theoreticalSec = readNumber(payload?.theoretical_cycle_time ?? inner?.theoretical_cycle_time);
if (isAutoAck) return; if (isAutoAck) return;
if (isUpdate && !(rule.repeatMinutes && rule.repeatMinutes > 0)) return; if (isUpdate && status !== "resolved") return;
if ((eventType === "microstop" || eventType === "macrostop") && theoreticalSec && lastCycleTs == null) { if ((eventType === "microstop" || eventType === "macrostop") && theoreticalSec && lastCycleTs == null) {
return; return;
} }
@@ -345,9 +359,11 @@ export async function evaluateAlertsForEvent(eventId: string) {
const key = `${channel}:${recipient.userId ?? recipient.contactId ?? recipient.email ?? recipient.phone ?? ""}`; const key = `${channel}:${recipient.userId ?? recipient.contactId ?? recipient.email ?? recipient.phone ?? ""}`;
if (delivered.has(key)) continue; if (delivered.has(key)) continue;
const statusKey = status === "resolved" ? "resolved" : "active";
const ruleKey = `${rule.id}:${statusKey}`;
const allowed = await shouldSendNotification({ const allowed = await shouldSendNotification({
eventIds: notificationEventIds, eventIds: notificationEventIds,
ruleId: rule.id, ruleId: ruleKey,
role: roleName, role: roleName,
channel, channel,
contactId: recipient.contactId, contactId: recipient.contactId,
@@ -376,7 +392,7 @@ export async function evaluateAlertsForEvent(eventId: string) {
machineId: event.machineId, machineId: event.machineId,
eventId: event.id, eventId: event.id,
eventType, eventType,
ruleId: rule.id, ruleId: ruleKey,
role: roleName, role: roleName,
channel, channel,
contactId: recipient.contactId, contactId: recipient.contactId,
@@ -391,7 +407,7 @@ export async function evaluateAlertsForEvent(eventId: string) {
machineId: event.machineId, machineId: event.machineId,
eventId: event.id, eventId: event.id,
eventType, eventType,
ruleId: rule.id, ruleId: ruleKey,
role: roleName, role: roleName,
channel, channel,
contactId: recipient.contactId, contactId: recipient.contactId,

View File

@@ -1,3 +1,4 @@
import { normalizeShiftOverrides } from "@/lib/settings";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
const RANGE_MS: Record<string, number> = { const RANGE_MS: Record<string, number> = {
@@ -21,6 +22,26 @@ type AlertsInboxParams = {
limit?: number; 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) { function pickRange(range: string, start?: Date | null, end?: Date | null) {
const now = new Date(); const now = new Date();
if (range === "custom") { if (range === "custom") {
@@ -50,6 +71,19 @@ function safeBool(value: unknown) {
return value === true; 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) { function parsePayload(raw: unknown) {
let parsed: unknown = raw; let parsed: unknown = raw;
if (typeof raw === "string") { 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( function resolveShift(
shifts: Array<{ name: string; startTime: string; endTime: string; enabled?: boolean }>, shifts: ShiftLike[],
overrides: Record<string, ShiftLike[]> | undefined,
ts: Date, ts: Date,
timeZone: string 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); const nowMin = getLocalMinutes(ts, timeZone);
for (const shift of shifts) { for (const shift of activeShifts) {
if (shift.enabled === false) continue; if (shift.enabled === false) continue;
const start = parseTimeMinutes(shift.startTime); const start = parseTimeMinutes(shift.startTime ?? shift.start ?? null);
const end = parseTimeMinutes(shift.endTime); const end = parseTimeMinutes(shift.endTime ?? shift.end ?? null);
if (start == null || end == null) continue; if (start == null || end == null) continue;
if (start <= end) { if (start <= end) {
if (nowMin >= start && nowMin < end) return shift.name; if (nowMin >= start && nowMin < end) return shift.name;
@@ -152,6 +223,34 @@ function resolveShift(
return null; 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) { export async function getAlertsInboxData(params: AlertsInboxParams) {
const { const {
orgId, orgId,
@@ -213,12 +312,13 @@ export async function getAlertsInboxData(params: AlertsInboxParams) {
}), }),
prisma.orgSettings.findUnique({ prisma.orgSettings.findUnique({
where: { orgId }, where: { orgId },
select: { timezone: true }, select: { timezone: true, shiftScheduleOverridesJson: true },
}), }),
]); ]);
const timeZone = settings?.timezone || "UTC"; const timeZone = settings?.timezone || "UTC";
const mapped = []; const shiftOverrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson);
const mapped: AlertsInboxEvent[] = [];
for (const ev of events) { for (const ev of events) {
const { payload, inner } = parsePayload(ev.data); 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); const isAutoAck = safeBool(payload?.is_auto_ack ?? inner?.is_auto_ack);
if (!includeUpdates && (isUpdate || isAutoAck)) continue; 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; if (normalizedShift && shiftName !== normalizedShift) continue;
const statusLabel = rawStatus ? rawStatus.toLowerCase() : "unknown"; const statusLabel = normalizeStatus(rawStatus) ?? "unknown";
if (normalizedStatus && statusLabel !== normalizedStatus) continue; if (normalizedStatus && statusLabel !== normalizedStatus) continue;
mapped.push({ mapped.push({
@@ -254,8 +354,10 @@ export async function getAlertsInboxData(params: AlertsInboxParams) {
}); });
} }
const finalEvents = includeUpdates ? mapped : collapseAlertEvents(mapped);
return { return {
range: { range: picked.range, start: picked.start, end: picked.end }, 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 { cookies } from "next/headers";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logLine } from "@/lib/logger";
const COOKIE_NAME = "mis_session"; 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() { export async function requireSession() {
try {
const jar = await cookies(); const jar = await cookies();
const sessionId = jar.get(COOKIE_NAME)?.value; const sessionId = jar.get(COOKIE_NAME)?.value;
if (!sessionId) return null; if (!sessionId) return null;
const now = Date.now();
const cached = readCache(sessionId, now);
if (cached) return cached;
const session = await prisma.session.findFirst({ const session = await prisma.session.findFirst({
where: { where: {
id: sessionId, id: sessionId,
@@ -24,20 +67,32 @@ export async function requireSession() {
if (!session) return null; if (!session) return null;
if (!session.user?.isActive || !session.user?.emailVerifiedAt) { if (!session.user?.isActive || !session.user?.emailVerifiedAt) {
await prisma.session void prisma.session
.update({ where: { id: session.id }, data: { revokedAt: new Date() } }) .update({ where: { id: session.id }, data: { revokedAt: new Date() } })
.catch(() => {}); .catch(() => {});
sessionCache.delete(sessionId);
lastSeenCache.delete(sessionId);
return null; return null;
} }
// Optional: update lastSeenAt (useful later) if (shouldUpdateLastSeen(sessionId, now)) {
await prisma.session void prisma.session
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } }) .update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
.catch(() => {}); .catch(() => {});
}
return { const payload = {
sessionId: session.id, sessionId: session.id,
userId: session.userId, userId: session.userId,
orgId: session.orgId, 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.shiftCompLabel": "Shift change compensation (min)",
"settings.lunchBreakLabel": "Lunch break (min)", "settings.lunchBreakLabel": "Lunch break (min)",
"settings.shift.defaultName": "Shift {index}", "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.thresholds": "Alert thresholds",
"settings.thresholdsSubtitle": "Tune production health alerts.", "settings.thresholdsSubtitle": "Tune production health alerts.",
"settings.thresholds.appliesAll": "Applies to all machines", "settings.thresholds.appliesAll": "Applies to all machines",
@@ -457,6 +471,7 @@
"financial.costsMovedLink": "Settings -> Financial", "financial.costsMovedLink": "Settings -> Financial",
"financial.export.html": "HTML", "financial.export.html": "HTML",
"financial.export.csv": "CSV", "financial.export.csv": "CSV",
"financial.refresh": "Refresh",
"financial.totalLoss": "Total Loss", "financial.totalLoss": "Total Loss",
"financial.currencyLabel": "Currency: {currency}", "financial.currencyLabel": "Currency: {currency}",
"financial.noImpact": "No impact data yet.", "financial.noImpact": "No impact data yet.",

View File

@@ -398,6 +398,20 @@
"settings.shiftCompLabel": "Compensación por cambio de turno (min)", "settings.shiftCompLabel": "Compensación por cambio de turno (min)",
"settings.lunchBreakLabel": "Comida (min)", "settings.lunchBreakLabel": "Comida (min)",
"settings.shift.defaultName": "Turno {index}", "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.thresholds": "Umbrales de alertas",
"settings.thresholdsSubtitle": "Ajusta alertas de salud de producción.", "settings.thresholdsSubtitle": "Ajusta alertas de salud de producción.",
"settings.thresholds.appliesAll": "Aplica a todas las máquinas", "settings.thresholds.appliesAll": "Aplica a todas las máquinas",
@@ -457,6 +471,7 @@
"financial.costsMovedLink": "Configuración -> Finanzas", "financial.costsMovedLink": "Configuración -> Finanzas",
"financial.export.html": "HTML", "financial.export.html": "HTML",
"financial.export.csv": "CSV", "financial.export.csv": "CSV",
"financial.refresh": "Actualizar",
"financial.totalLoss": "Pérdida total", "financial.totalLoss": "Pérdida total",
"financial.currencyLabel": "Moneda: {currency}", "financial.currencyLabel": "Moneda: {currency}",
"financial.noImpact": "Sin datos de impacto.", "financial.noImpact": "Sin datos de impacto.",

View File

@@ -7,6 +7,7 @@ const LOCALE_COOKIE = "mis_locale";
const LOCALE_EVENT = "mis-locale-change"; const LOCALE_EVENT = "mis-locale-change";
function readCookieLocale(): Locale | null { function readCookieLocale(): Locale | null {
if (typeof document === "undefined") return null;
const match = document.cookie const match = document.cookie
.split(";") .split(";")
.map((part) => part.trim()) .map((part) => part.trim())
@@ -18,6 +19,7 @@ function readCookieLocale(): Locale | null {
} }
function readLocale(): Locale { function readLocale(): Locale {
if (typeof document === "undefined") return defaultLocale;
const docLang = document.documentElement.getAttribute("lang"); const docLang = document.documentElement.getAttribute("lang");
if (docLang === "es-MX" || docLang === "en") return docLang; if (docLang === "es-MX" || docLang === "en") return docLang;
return readCookieLocale() ?? defaultLocale; 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"; 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> = {}) { export function logLine(event: string, data: Record<string, unknown> = {}) {
const line = JSON.stringify({ const line = JSON.stringify({
ts: new Date().toISOString(), ts: new Date().toISOString(),

View File

@@ -2,7 +2,7 @@ import { prisma } from "@/lib/prisma";
type MachineAuth = { id: string; orgId: string }; type MachineAuth = { id: string; orgId: string };
const TTL_MS = 60_000; const TTL_MS = 10_000;
const MAX_SIZE = 1000; const MAX_SIZE = 1000;
const cache = new Map<string, { value: MachineAuth; expiresAt: number }>(); 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 }); cache.set(key, { value: machine, expiresAt: now + TTL_MS });
return machine; 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 { prisma } from "@/lib/prisma";
import { normalizeEvent } from "@/lib/events/normalizeEvent"; 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([ const ALLOWED_TYPES = new Set([
"slow-cycle", "slow-cycle",
@@ -37,49 +46,31 @@ export async function getOverviewData({
eventsWindowSec = 21600, eventsWindowSec = 21600,
eventMachines = 6, eventMachines = 6,
orgSettings, orgSettings,
}: OverviewParams) { }: OverviewParams): Promise<{ machines: OverviewMachineRow[]; events: OverviewEventRow[] }> {
const machines = await prisma.machine.findMany({ const perfEnabled = PERF_LOGS_ENABLED;
where: { orgId }, const timings: Record<string, number> = {};
orderBy: { createdAt: "desc" }, const totalStart = nowMs();
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 machineRows = machines.map((m) => ({ try {
...m, const machinesStart = nowMs();
latestHeartbeat: m.heartbeats[0] ?? null, const machines = await fetchMachineBase(orgId);
latestKpi: m.kpiSnapshots[0] ?? null, if (perfEnabled) timings.machinesQuery = elapsedMs(machinesStart);
heartbeats: undefined,
kpiSnapshots: undefined, 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 safeEventMachines = Number.isFinite(eventMachines) ? Math.max(1, Math.floor(eventMachines)) : 6;
const safeWindowSec = Number.isFinite(eventsWindowSec) ? eventsWindowSec : 21600; const safeWindowSec = Number.isFinite(eventsWindowSec) ? eventsWindowSec : 21600;
@@ -97,34 +88,24 @@ export async function getOverviewData({
const targetIds = topMachines.map((m) => m.id); const targetIds = topMachines.map((m) => m.id);
let events = [] as Array<{ let events: 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";
}>;
if (targetIds.length) { if (targetIds.length) {
let settings = orgSettings ?? null; let settings = orgSettings ?? null;
if (!settings) { if (!settings) {
const settingsStart = nowMs();
settings = await prisma.orgSettings.findUnique({ settings = await prisma.orgSettings.findUnique({
where: { orgId }, where: { orgId },
select: { stoppageMultiplier: true, macroStoppageMultiplier: true }, select: { stoppageMultiplier: true, macroStoppageMultiplier: true },
}); });
if (perfEnabled) timings.orgSettingsQuery = elapsedMs(settingsStart);
} }
const microMultiplier = Number(settings?.stoppageMultiplier ?? 1.5); const microMultiplier = Number(settings?.stoppageMultiplier ?? 1.5);
const macroMultiplier = Math.max(microMultiplier, Number(settings?.macroStoppageMultiplier ?? 5)); const macroMultiplier = Math.max(microMultiplier, Number(settings?.macroStoppageMultiplier ?? 5));
const windowStart = new Date(Date.now() - Math.max(0, safeWindowSec) * 1000); const windowStart = new Date(Date.now() - Math.max(0, safeWindowSec) * 1000);
const eventsStart = nowMs();
const rawEvents = await prisma.machineEvent.findMany({ const rawEvents = await prisma.machineEvent.findMany({
where: { where: {
orgId, orgId,
@@ -148,7 +129,9 @@ export async function getOverviewData({
machine: { select: { name: true } }, machine: { select: { name: true } },
}, },
}); });
if (perfEnabled) timings.eventsQuery = elapsedMs(eventsStart);
const normalizeStart = nowMs();
const normalized = rawEvents const normalized = rawEvents
.map((row) => ({ .map((row) => ({
...normalizeEvent(row, { microMultiplier, macroMultiplier }), ...normalizeEvent(row, { microMultiplier, macroMultiplier }),
@@ -157,7 +140,9 @@ export async function getOverviewData({
source: "ingested" as const, source: "ingested" as const,
})) }))
.filter((event) => event.ts); .filter((event) => event.ts);
if (perfEnabled) timings.eventsNormalize = elapsedMs(normalizeStart);
const filterStart = nowMs();
const allowed = normalized.filter((event) => ALLOWED_TYPES.has(event.eventType)); const allowed = normalized.filter((event) => ALLOWED_TYPES.has(event.eventType));
const isCritical = (event: (typeof allowed)[number]) => { const isCritical = (event: (typeof allowed)[number]) => {
const severity = String(event.severity ?? "").toLowerCase(); const severity = String(event.severity ?? "").toLowerCase();
@@ -187,7 +172,43 @@ export async function getOverviewData({
}); });
events = deduped.slice(0, 30); 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 }; 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", 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>; type AnyRecord = Record<string, unknown>;
function isPlainObject(value: unknown): value is AnyRecord { function isPlainObject(value: unknown): value is AnyRecord {
@@ -40,6 +43,7 @@ type SettingsRow = {
timezone: string; timezone: string;
shiftChangeCompMin?: number | null; shiftChangeCompMin?: number | null;
lunchBreakMin?: number | null; lunchBreakMin?: number | null;
shiftScheduleOverridesJson?: unknown;
stoppageMultiplier?: number | null; stoppageMultiplier?: number | null;
macroStoppageMultiplier?: number | null; macroStoppageMultiplier?: number | null;
oeeAlertThresholdPct?: number | null; oeeAlertThresholdPct?: number | null;
@@ -59,6 +63,13 @@ type ShiftRow = {
sortOrder?: number | null; sortOrder?: number | null;
}; };
type ShiftOverridePayload = {
name: string;
start: string;
end: string;
enabled: boolean;
};
export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[]) { export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[]) {
const ordered = [...(shifts ?? [])].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)); const ordered = [...(shifts ?? [])].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
const mappedShifts = ordered.map((s, idx) => ({ const mappedShifts = ordered.map((s, idx) => ({
@@ -67,6 +78,13 @@ export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[])
end: s.endTime, end: s.endTime,
enabled: s.enabled !== false, 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 { return {
orgId: settings.orgId, orgId: settings.orgId,
@@ -74,6 +92,7 @@ export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[])
timezone: settings.timezone, timezone: settings.timezone,
shiftSchedule: { shiftSchedule: {
shifts: mappedShifts, shifts: mappedShifts,
overrides: overrides && Object.keys(overrides).length ? overrides : undefined,
shiftChangeCompensationMin: settings.shiftChangeCompMin, shiftChangeCompensationMin: settings.shiftChangeCompMin,
lunchBreakMin: settings.lunchBreakMin, lunchBreakMin: settings.lunchBreakMin,
}, },
@@ -85,7 +104,10 @@ export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[])
qualitySpikeDeltaPct: settings.qualitySpikeDeltaPct, qualitySpikeDeltaPct: settings.qualitySpikeDeltaPct,
}, },
alerts: normalizeAlerts(settings.alertsJson), 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, updatedAt: settings.updatedAt,
updatedBy: settings.updatedBy, updatedBy: settings.updatedBy,
}; };
@@ -169,6 +191,57 @@ export function validateShiftSchedule(shifts: unknown) {
return { ok: true, shifts: normalized as NormalizedShift[] }; 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) { export function validateShiftFields(shiftChangeCompensationMin?: unknown, lunchBreakMin?: unknown) {
if (shiftChangeCompensationMin != null) { if (shiftChangeCompensationMin != null) {
const v = Number(shiftChangeCompensationMin); 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", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev --turbopack",
"build": "next build", "build": "next build --webpack",
"start": "next start", "start": "next start",
"lint": "eslint" "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]) @@unique([orgId, name])
@@index([orgId]) @@index([orgId])
@@index([orgId, createdAt])
} }
model MachineHeartbeat { model MachineHeartbeat {
@@ -166,6 +167,7 @@ model MachineHeartbeat {
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade) machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
@@index([orgId, machineId, ts]) @@index([orgId, machineId, ts])
@@index([orgId, machineId, tsServer])
} }
model MachineKpiSnapshot { model MachineKpiSnapshot {
@@ -310,6 +312,7 @@ model OrgSettings {
timezone String @default("UTC") timezone String @default("UTC")
shiftChangeCompMin Int @default(10) @map("shift_change_comp_min") shiftChangeCompMin Int @default(10) @map("shift_change_comp_min")
lunchBreakMin Int @default(30) @map("lunch_break_min") lunchBreakMin Int @default(30) @map("lunch_break_min")
shiftScheduleOverridesJson Json? @map("shift_schedule_overrides_json")
stoppageMultiplier Float @default(1.5) @map("stoppage_multiplier") stoppageMultiplier Float @default(1.5) @map("stoppage_multiplier")
oeeAlertThresholdPct Float @default(90) @map("oee_alert_threshold_pct") oeeAlertThresholdPct Float @default(90) @map("oee_alert_threshold_pct")
macroStoppageMultiplier Float @default(5) @map("macro_stoppage_multiplier") 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.