pre-bemis
This commit is contained in:
84
LOGGING.md
Normal file
84
LOGGING.md
Normal 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.
|
||||
19
README.md
19
README.md
@@ -75,7 +75,24 @@ sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now mis-control-tower-reminders.timer
|
||||
```
|
||||
|
||||
## Learn More
|
||||
## Production build and deploy
|
||||
|
||||
**Dev uses Turbopack, production build uses Webpack.** Next.js 16 defaults to Turbopack for both, but Turbopack production builds have known issues. This project uses:
|
||||
|
||||
- `npm run dev` → `next dev --turbopack` (fast dev)
|
||||
- `npm run build` → `next build --webpack` (stable production build)
|
||||
|
||||
**When deploying** (e.g. for `https://mis.maliountech.com.mx`):
|
||||
|
||||
1. **Build:** Run `npm run build` (Webpack).
|
||||
2. **Start:** Run `npm run start` (or your process manager) to serve the built app.
|
||||
3. If you previously built with Turbopack, run `rm -rf .next` then `npm run build` for a clean Webpack build.
|
||||
4. Hard-refresh the browser (or clear site data) after redeploying so clients don’t load old Turbopack chunks.
|
||||
|
||||
## Logging and debugging
|
||||
|
||||
See **[LOGGING.md](./LOGGING.md)** for where errors are logged (log file, process stdout, optional `/api/debug/logs`), how to tail them, and how to debug "Internal Server Error".
|
||||
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
|
||||
31
app/(app)/error.tsx
Normal file
31
app/(app)/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -76,6 +76,8 @@ export default function FinancialClient({
|
||||
const [currencyFilter, setCurrencyFilter] = useState("");
|
||||
const [loading, setLoading] = useState(() => initialMachines.length === 0);
|
||||
const skipInitialImpactRef = useRef(true);
|
||||
const forceRefreshRef = useRef(false);
|
||||
const [refreshSeed, setRefreshSeed] = useState(0);
|
||||
|
||||
const locations = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
@@ -158,6 +160,8 @@ export default function FinancialClient({
|
||||
if (locationFilter) params.set("location", locationFilter);
|
||||
if (skuFilter) params.set("sku", skuFilter);
|
||||
if (currencyFilter) params.set("currency", currencyFilter);
|
||||
const forceRefresh = forceRefreshRef.current;
|
||||
if (forceRefresh) params.set("refresh", "1");
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/financial/impact?${params.toString()}`, {
|
||||
@@ -169,6 +173,8 @@ export default function FinancialClient({
|
||||
setImpact(json);
|
||||
} catch {
|
||||
if (alive) setImpact(null);
|
||||
} finally {
|
||||
if (forceRefresh) forceRefreshRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +183,7 @@ export default function FinancialClient({
|
||||
alive = false;
|
||||
controller.abort();
|
||||
};
|
||||
}, [currencyFilter, initialImpact, locationFilter, machineFilter, range, role, skuFilter]);
|
||||
}, [currencyFilter, initialImpact, locationFilter, machineFilter, range, refreshSeed, role, skuFilter]);
|
||||
|
||||
const selectedSummary = impact?.currencySummaries?.[0] ?? null;
|
||||
const chartData = selectedSummary?.byDay ?? [];
|
||||
@@ -193,6 +199,10 @@ export default function FinancialClient({
|
||||
|
||||
const htmlHref = `/api/financial/export/pdf?${exportQuery}`;
|
||||
const csvHref = `/api/financial/export/excel?${exportQuery}`;
|
||||
const handleRefresh = () => {
|
||||
forceRefreshRef.current = true;
|
||||
setRefreshSeed((prev) => prev + 1);
|
||||
};
|
||||
|
||||
if (role && role !== "OWNER") {
|
||||
return (
|
||||
@@ -212,6 +222,13 @@ export default function FinancialClient({
|
||||
<p className="text-sm text-zinc-400">{t("financial.subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-center text-sm text-zinc-200 hover:bg-white/10 sm:w-auto"
|
||||
>
|
||||
{t("financial.refresh")}
|
||||
</button>
|
||||
<a
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-center text-sm text-zinc-200 hover:bg-white/10 sm:w-auto"
|
||||
href={htmlHref}
|
||||
@@ -288,7 +305,7 @@ export default function FinancialClient({
|
||||
</div>
|
||||
|
||||
<div className="mt-4 h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ResponsiveContainer width="100%" height="100%" minHeight={200}>
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="slowFill" x1="0" y1="0" x2="0" y2="1">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { computeFinancialImpact } from "@/lib/financial/impact";
|
||||
import { getFinancialImpactCached } from "@/lib/financial/cache";
|
||||
import FinancialClient from "./FinancialClient";
|
||||
|
||||
const RANGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
@@ -28,7 +28,7 @@ export default async function FinancialPage() {
|
||||
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - RANGE_MS);
|
||||
const impact = await computeFinancialImpact({
|
||||
const impact = await getFinancialImpactCached({
|
||||
orgId: session.orgId,
|
||||
start,
|
||||
end,
|
||||
|
||||
@@ -1,31 +1,22 @@
|
||||
import { AppShell } from "@/components/layout/AppShell";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const COOKIE_NAME = "mis_session";
|
||||
|
||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AppLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const cookieJar = await cookies();
|
||||
const sessionId = cookieJar.get(COOKIE_NAME)?.value;
|
||||
const themeCookie = cookieJar.get("mis_theme")?.value;
|
||||
const initialTheme = themeCookie === "light" ? "light" : "dark";
|
||||
|
||||
if (!sessionId) redirect("/login?next=/machines");
|
||||
|
||||
// validate session in DB (don’t trust cookie existence)
|
||||
const session = await prisma.session.findFirst({
|
||||
where: {
|
||||
id: sessionId,
|
||||
revokedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
include: { user: true, org: true },
|
||||
});
|
||||
|
||||
if (!session || !session.user?.isActive || !session.user?.emailVerifiedAt) {
|
||||
redirect("/login?next=/machines");
|
||||
}
|
||||
if (!sessionId) redirect("/login");
|
||||
|
||||
return <AppShell initialTheme={initialTheme}>{children}</AppShell>;
|
||||
}
|
||||
|
||||
13
app/(app)/loading.tsx
Normal file
13
app/(app)/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type MachineRow = {
|
||||
fwVersion?: string | null;
|
||||
};
|
||||
};
|
||||
const LIVE_REFRESH_MS = 5000;
|
||||
|
||||
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
||||
if (!ts) return fallback;
|
||||
@@ -52,7 +53,7 @@ export default function MachinesClient({ initialMachines = [] }: { initialMachin
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const [machines, setMachines] = useState<MachineRow[]>(() => initialMachines);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(() => initialMachines.length === 0);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [createCode, setCreateCode] = useState("");
|
||||
@@ -69,28 +70,36 @@ export default function MachinesClient({ initialMachines = [] }: { initialMachin
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async function load() {
|
||||
async function load(initial: boolean) {
|
||||
try {
|
||||
if (!initial && typeof document !== "undefined" && document.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/machines", { cache: "no-store" });
|
||||
const json = await res.json();
|
||||
if (alive) {
|
||||
setMachines(json.machines ?? []);
|
||||
setLoading(false);
|
||||
if (initial) setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
if (alive) setLoading(false);
|
||||
if (alive && initial) setLoading(false);
|
||||
} finally {
|
||||
if (!alive) return;
|
||||
timer = setTimeout(() => {
|
||||
void load(false);
|
||||
}, LIVE_REFRESH_MS);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
const t = setInterval(load, 15000);
|
||||
|
||||
void load(initialMachines.length === 0);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(t);
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
}, [initialMachines.length]);
|
||||
|
||||
async function createMachine() {
|
||||
if (!createName.trim()) {
|
||||
|
||||
@@ -128,6 +128,7 @@ const TOL = 0.10;
|
||||
const DEFAULT_MICRO_MULT = 1.5;
|
||||
const DEFAULT_MACRO_MULT = 5;
|
||||
const NORMAL_TOL_SEC = 0.1;
|
||||
const LIVE_REFRESH_MS = 5000;
|
||||
|
||||
const BUCKET = {
|
||||
normal: {
|
||||
@@ -289,6 +290,24 @@ function rowsToWorkOrders(rows: WorkOrderRow[]): WorkOrderUpload[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function toErrorMessage(value: unknown, fallback: string): string {
|
||||
if (typeof value === "string" && value.trim().length > 0) return value;
|
||||
if (value && typeof value === "object") {
|
||||
const maybeMessage =
|
||||
("message" in value && typeof value.message === "string" && value.message) ||
|
||||
("error" in value && typeof value.error === "string" && value.error) ||
|
||||
("text" in value && typeof value.text === "string" && value.text) ||
|
||||
null;
|
||||
if (maybeMessage && maybeMessage.trim().length > 0) return maybeMessage;
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export default function MachineDetailClient() {
|
||||
const { t, locale } = useI18n();
|
||||
const { screenlessMode } = useScreenlessMode();
|
||||
@@ -319,9 +338,14 @@ export default function MachineDetailClient() {
|
||||
if (!machineId) return;
|
||||
|
||||
let alive = true;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async function load() {
|
||||
async function load(initial: boolean) {
|
||||
try {
|
||||
if (!initial && typeof document !== "undefined" && document.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/machines/${machineId}?windowSec=3600&events=critical`, {
|
||||
cache: "no-cache",
|
||||
credentials: "include",
|
||||
@@ -329,7 +353,7 @@ export default function MachineDetailClient() {
|
||||
|
||||
if (res.status === 304) {
|
||||
if (!alive) return;
|
||||
setLoading(false);
|
||||
if (initial) setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -338,8 +362,8 @@ export default function MachineDetailClient() {
|
||||
if (!alive) return;
|
||||
|
||||
if (!res.ok || json?.ok === false) {
|
||||
setError(json?.error ?? t("machine.detail.error.failed"));
|
||||
setLoading(false);
|
||||
setError(toErrorMessage(json?.error, t("machine.detail.error.failed")));
|
||||
if (initial) setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -350,19 +374,25 @@ export default function MachineDetailClient() {
|
||||
setThresholds(json.thresholds ?? null);
|
||||
setActiveStoppage(json.activeStoppage ?? null);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
if (initial) setLoading(false);
|
||||
} catch {
|
||||
if (!alive) return;
|
||||
if (initial) {
|
||||
setError(t("machine.detail.error.network"));
|
||||
setLoading(false);
|
||||
}
|
||||
} finally {
|
||||
if (!alive) return;
|
||||
timer = setTimeout(() => {
|
||||
void load(false);
|
||||
}, LIVE_REFRESH_MS);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
const timer = setInterval(load, 15000);
|
||||
void load(true);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [machineId, t]);
|
||||
|
||||
@@ -479,7 +509,7 @@ export default function MachineDetailClient() {
|
||||
} else {
|
||||
setUploadState({
|
||||
status: "error",
|
||||
message: json?.error ?? t("machine.detail.workOrders.uploadError"),
|
||||
message: toErrorMessage(json?.error, t("machine.detail.workOrders.uploadError")),
|
||||
});
|
||||
}
|
||||
event.target.value = "";
|
||||
@@ -508,7 +538,7 @@ export default function MachineDetailClient() {
|
||||
const res = await fetch(`/api/machines/${machineId}`, { method: "DELETE" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.ok) {
|
||||
throw new Error(data.error || t("machines.delete.error.failed"));
|
||||
throw new Error(toErrorMessage(data?.error, t("machines.delete.error.failed")));
|
||||
}
|
||||
router.push("/machines");
|
||||
} catch (err: unknown) {
|
||||
@@ -886,9 +916,10 @@ export default function MachineDetailClient() {
|
||||
|
||||
const cycleDerived = useMemo(() => {
|
||||
const rows = cycles ?? [];
|
||||
const fallbackIdeal = cycleTarget && cycleTarget > 0 ? cycleTarget : null;
|
||||
|
||||
const mapped: CycleDerivedRow[] = rows.map((cycle) => {
|
||||
const ideal = cycle.ideal ?? null;
|
||||
const ideal = cycle.ideal ?? fallbackIdeal;
|
||||
const actual = cycle.actual ?? null;
|
||||
const extra = ideal != null && actual != null ? actual - ideal : null;
|
||||
|
||||
@@ -914,7 +945,7 @@ export default function MachineDetailClient() {
|
||||
const avgDeltaPct = deltas.length ? deltas.reduce((a, b) => a + b, 0) / deltas.length : null;
|
||||
|
||||
return { mapped, counts, avgDeltaPct };
|
||||
}, [cycles, thresholds]);
|
||||
}, [cycles, cycleTarget, thresholds]);
|
||||
|
||||
const deviationSeries = useMemo(() => {
|
||||
const last = cycleDerived.mapped.slice(-100);
|
||||
@@ -1313,7 +1344,7 @@ export default function MachineDetailClient() {
|
||||
className="h-[380px] rounded-3xl border border-white/10 bg-black/30 p-4 backdrop-blur"
|
||||
style={{ boxShadow: "var(--app-chart-shadow)" }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ResponsiveContainer width="100%" height="100%" minHeight={200}>
|
||||
<ComposedChart data={deviationSeries}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
|
||||
<XAxis
|
||||
@@ -1407,7 +1438,7 @@ export default function MachineDetailClient() {
|
||||
className="h-[380px] rounded-3xl border border-white/10 bg-black/30 p-4 backdrop-blur"
|
||||
style={{ boxShadow: "var(--app-chart-shadow)" }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ResponsiveContainer width="100%" height="100%" minHeight={200}>
|
||||
<BarChart data={impactAgg.rows}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
|
||||
<XAxis dataKey="label" tick={{ fill: "var(--app-chart-tick)" }} />
|
||||
|
||||
22
app/(app)/machines/loading.tsx
Normal file
22
app/(app)/machines/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import {
|
||||
fetchLatestHeartbeats,
|
||||
fetchMachineBase,
|
||||
mergeMachineOverviewRows,
|
||||
} from "@/lib/machines/withLatest";
|
||||
import MachinesClient from "./MachinesClient";
|
||||
|
||||
function toIso(value?: Date | null) {
|
||||
@@ -11,34 +15,32 @@ export default async function MachinesPage() {
|
||||
const session = await requireSession();
|
||||
if (!session) redirect("/login?next=/machines");
|
||||
|
||||
const machines = await prisma.machine.findMany({
|
||||
where: { orgId: session.orgId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
code: true,
|
||||
location: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
heartbeats: {
|
||||
orderBy: { tsServer: "desc" },
|
||||
take: 1,
|
||||
select: { ts: true, tsServer: true, status: true, message: true, ip: true, fwVersion: true },
|
||||
},
|
||||
},
|
||||
const machines = await fetchMachineBase(session.orgId);
|
||||
const heartbeats = await fetchLatestHeartbeats(
|
||||
session.orgId,
|
||||
machines.map((machine) => machine.id)
|
||||
);
|
||||
const rows = mergeMachineOverviewRows({
|
||||
machines,
|
||||
heartbeats,
|
||||
includeKpi: false,
|
||||
});
|
||||
|
||||
const initialMachines = machines.map((machine) => ({
|
||||
...machine,
|
||||
latestHeartbeat: machine.heartbeats[0]
|
||||
const initialMachines = rows.map((machine) => ({
|
||||
id: machine.id,
|
||||
name: machine.name,
|
||||
code: machine.code ?? null,
|
||||
location: machine.location ?? null,
|
||||
latestHeartbeat: machine.latestHeartbeat
|
||||
? {
|
||||
...machine.heartbeats[0],
|
||||
ts: toIso(machine.heartbeats[0].ts) ?? "",
|
||||
tsServer: toIso(machine.heartbeats[0].tsServer),
|
||||
ts: toIso(machine.latestHeartbeat.ts) ?? "",
|
||||
tsServer: toIso(machine.latestHeartbeat.tsServer),
|
||||
status: machine.latestHeartbeat.status,
|
||||
message: machine.latestHeartbeat.message ?? null,
|
||||
ip: machine.latestHeartbeat.ip ?? null,
|
||||
fwVersion: machine.latestHeartbeat.fwVersion ?? null,
|
||||
}
|
||||
: null,
|
||||
heartbeats: undefined,
|
||||
}));
|
||||
|
||||
return <MachinesClient initialMachines={initialMachines} />;
|
||||
|
||||
@@ -1,57 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
|
||||
type Heartbeat = {
|
||||
ts: string;
|
||||
tsServer?: string | null;
|
||||
status: string;
|
||||
message?: string | null;
|
||||
ip?: string | null;
|
||||
fwVersion?: string | null;
|
||||
};
|
||||
|
||||
type Kpi = {
|
||||
ts: string;
|
||||
oee?: number | null;
|
||||
availability?: number | null;
|
||||
performance?: number | null;
|
||||
quality?: number | null;
|
||||
workOrderId?: string | null;
|
||||
sku?: string | null;
|
||||
good?: number | null;
|
||||
scrap?: number | null;
|
||||
target?: number | null;
|
||||
cycleTime?: number | null;
|
||||
};
|
||||
|
||||
type MachineRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string | null;
|
||||
location?: string | null;
|
||||
latestHeartbeat: Heartbeat | null;
|
||||
latestKpi?: Kpi | null;
|
||||
};
|
||||
|
||||
type EventRow = {
|
||||
id: string;
|
||||
ts: string;
|
||||
topic?: string;
|
||||
eventType: string;
|
||||
severity: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
requiresAck: boolean;
|
||||
machineId?: string;
|
||||
machineName?: string;
|
||||
source: "ingested";
|
||||
};
|
||||
import type { EventRow, Heartbeat, MachineRow } from "./types";
|
||||
|
||||
const OFFLINE_MS = 30000;
|
||||
const MAX_EVENT_MACHINES = 6;
|
||||
const OverviewTimeline = lazy(() => import("./OverviewTimeline"));
|
||||
|
||||
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
||||
if (!ts) return fallback;
|
||||
@@ -87,17 +43,20 @@ function fmtNum(v?: number | null) {
|
||||
return `${Math.round(v)}`;
|
||||
}
|
||||
|
||||
function severityClass(sev?: string) {
|
||||
const s = (sev ?? "").toLowerCase();
|
||||
if (s === "critical") return "bg-red-500/15 text-red-300";
|
||||
if (s === "warning") return "bg-yellow-500/15 text-yellow-300";
|
||||
if (s === "info") return "bg-blue-500/15 text-blue-300";
|
||||
return "bg-white/10 text-zinc-200";
|
||||
}
|
||||
|
||||
function sourceClass(src: EventRow["source"]) {
|
||||
if (src === "ingested") return "bg-white/10 text-zinc-200";
|
||||
return "bg-white/10 text-zinc-200";
|
||||
function OverviewTimelineSkeleton() {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-2">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="h-4 w-32 rounded bg-white/10" />
|
||||
<div className="h-3 w-20 rounded bg-white/5" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<div key={idx} className="h-20 rounded-xl border border-white/10 bg-black/20" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OverviewClient({
|
||||
@@ -111,7 +70,7 @@ export default function OverviewClient({
|
||||
const [machines, setMachines] = useState<MachineRow[]>(() => initialMachines);
|
||||
const [events, setEvents] = useState<EventRow[]>(() => initialEvents);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [eventsLoading, setEventsLoading] = useState(false);
|
||||
const [eventsLoading, setEventsLoading] = useState(() => initialEvents.length === 0);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -119,9 +78,12 @@ export default function OverviewClient({
|
||||
async function load() {
|
||||
try {
|
||||
setEventsLoading(true);
|
||||
const res = await fetch(`/api/overview?events=critical&eventMachines=${MAX_EVENT_MACHINES}`, {
|
||||
const res = await fetch(
|
||||
`/api/overview?detail=1&events=critical&eventMachines=${MAX_EVENT_MACHINES}`,
|
||||
{
|
||||
cache: "no-cache",
|
||||
});
|
||||
}
|
||||
);
|
||||
if (res.status === 304) {
|
||||
if (alive) setLoading(false);
|
||||
return;
|
||||
@@ -166,6 +128,7 @@ export default function OverviewClient({
|
||||
let goodSum = 0;
|
||||
let scrapSum = 0;
|
||||
let targetSum = 0;
|
||||
let hasKpi = false;
|
||||
|
||||
for (const m of machines) {
|
||||
const hb = m.latestHeartbeat;
|
||||
@@ -183,22 +146,35 @@ export default function OverviewClient({
|
||||
if (k?.oee != null) {
|
||||
oeeSum += Number(k.oee);
|
||||
oeeCount += 1;
|
||||
hasKpi = true;
|
||||
}
|
||||
if (k?.availability != null) {
|
||||
availSum += Number(k.availability);
|
||||
availCount += 1;
|
||||
hasKpi = true;
|
||||
}
|
||||
if (k?.performance != null) {
|
||||
perfSum += Number(k.performance);
|
||||
perfCount += 1;
|
||||
hasKpi = true;
|
||||
}
|
||||
if (k?.quality != null) {
|
||||
qualSum += Number(k.quality);
|
||||
qualCount += 1;
|
||||
hasKpi = true;
|
||||
}
|
||||
if (k?.good != null) {
|
||||
goodSum += Number(k.good);
|
||||
hasKpi = true;
|
||||
}
|
||||
if (k?.scrap != null) {
|
||||
scrapSum += Number(k.scrap);
|
||||
hasKpi = true;
|
||||
}
|
||||
if (k?.target != null) {
|
||||
targetSum += Number(k.target);
|
||||
hasKpi = true;
|
||||
}
|
||||
if (k?.good != null) goodSum += Number(k.good);
|
||||
if (k?.scrap != null) scrapSum += Number(k.scrap);
|
||||
if (k?.target != null) targetSum += Number(k.target);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -212,9 +188,9 @@ export default function OverviewClient({
|
||||
availability: availCount ? availSum / availCount : null,
|
||||
performance: perfCount ? perfSum / perfCount : null,
|
||||
quality: qualCount ? qualSum / qualCount : null,
|
||||
goodSum,
|
||||
scrapSum,
|
||||
targetSum,
|
||||
goodSum: hasKpi ? goodSum : null,
|
||||
scrapSum: hasKpi ? scrapSum : null,
|
||||
targetSum: hasKpi ? targetSum : null,
|
||||
};
|
||||
}, [machines]);
|
||||
|
||||
@@ -238,27 +214,6 @@ export default function OverviewClient({
|
||||
return list;
|
||||
}, [machines]);
|
||||
|
||||
const formatEventType = (eventType?: string) => {
|
||||
if (!eventType) return "";
|
||||
const key = `overview.event.${eventType}`;
|
||||
const label = t(key);
|
||||
return label === key ? eventType : label;
|
||||
};
|
||||
|
||||
const formatSource = (source?: string) => {
|
||||
if (!source) return "";
|
||||
const key = `overview.source.${source}`;
|
||||
const label = t(key);
|
||||
return label === key ? source : label;
|
||||
};
|
||||
|
||||
const formatSeverity = (severity?: string) => {
|
||||
if (!severity) return "";
|
||||
const key = `overview.severity.${severity}`;
|
||||
const label = t(key);
|
||||
return label === key ? severity.toUpperCase() : label;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
@@ -409,56 +364,9 @@ export default function OverviewClient({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-2">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="text-sm font-semibold text-white">{t("overview.timeline")}</div>
|
||||
<div className="text-xs text-zinc-400">
|
||||
{events.length} {t("overview.items")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{events.length === 0 && !eventsLoading ? (
|
||||
<div className="text-sm text-zinc-400">{t("overview.noEvents")}</div>
|
||||
) : (
|
||||
<div className="h-[360px] space-y-3 overflow-y-auto no-scrollbar">
|
||||
{events.map((e) => (
|
||||
<div key={`${e.id}-${e.source}`} className="rounded-xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs ${severityClass(e.severity)}`}>
|
||||
{formatSeverity(e.severity)}
|
||||
</span>
|
||||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-zinc-200">
|
||||
{formatEventType(e.eventType)}
|
||||
</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs ${sourceClass(e.source)}`}>
|
||||
{formatSource(e.source)}
|
||||
</span>
|
||||
{e.requiresAck ? (
|
||||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white">
|
||||
{t("overview.ack")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 truncate text-sm font-semibold text-white">
|
||||
{e.machineName ? `${e.machineName}: ` : ""}
|
||||
{e.title}
|
||||
</div>
|
||||
{e.description ? (
|
||||
<div className="mt-1 text-sm text-zinc-300">{e.description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="shrink-0 text-xs text-zinc-400">
|
||||
{secondsAgo(e.ts, locale, t("common.never"))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Suspense fallback={<OverviewTimelineSkeleton />}>
|
||||
<OverviewTimeline events={events} eventsLoading={eventsLoading} locale={locale} t={t} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
129
app/(app)/overview/OverviewTimeline.tsx
Normal file
129
app/(app)/overview/OverviewTimeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
app/(app)/overview/loading.tsx
Normal file
30
app/(app)/overview/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { getOverviewData } from "@/lib/overview/getOverviewData";
|
||||
import { getOverviewSummary } from "@/lib/overview/getOverviewSummary";
|
||||
import type { getOverviewData } from "@/lib/overview/getOverviewData";
|
||||
import { logLine } from "@/lib/logger";
|
||||
import OverviewClient from "./OverviewClient";
|
||||
|
||||
function toIso(value?: Date | null) {
|
||||
@@ -11,12 +13,18 @@ export default async function OverviewPage() {
|
||||
const session = await requireSession();
|
||||
if (!session) redirect("/login?next=/overview");
|
||||
|
||||
const { machines, events } = await getOverviewData({
|
||||
orgId: session.orgId,
|
||||
eventsMode: "critical",
|
||||
eventsWindowSec: 21600,
|
||||
eventMachines: 6,
|
||||
});
|
||||
let machines: Awaited<ReturnType<typeof getOverviewData>>["machines"];
|
||||
let events: Awaited<ReturnType<typeof getOverviewData>>["events"] = [];
|
||||
try {
|
||||
const data = await getOverviewSummary({ orgId: session.orgId });
|
||||
machines = data.machines;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const stack = err instanceof Error ? err.stack : undefined;
|
||||
logLine("OverviewPage.getOverviewSummary.error", { message, stack });
|
||||
console.error("[OverviewPage] getOverviewSummary:", err);
|
||||
machines = [];
|
||||
}
|
||||
|
||||
const initialMachines = machines.map((machine) => ({
|
||||
...machine,
|
||||
|
||||
45
app/(app)/overview/types.ts
Normal file
45
app/(app)/overview/types.ts
Normal 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";
|
||||
};
|
||||
249
app/(app)/reports/ReportsCharts.tsx
Normal file
249
app/(app)/reports/ReportsCharts.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function LegacyDowntimeParetoPage({
|
||||
export default async function LegacyDowntimeParetoPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string, string | string[] | undefined>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(searchParams)) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (typeof v === "string") qs.set(k, v);
|
||||
else if (Array.isArray(v)) v.forEach((vv) => qs.append(k, vv));
|
||||
}
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
const ReportsCharts = lazy(() => import("./ReportsCharts"));
|
||||
|
||||
type RangeKey = "24h" | "7d" | "30d" | "custom";
|
||||
|
||||
@@ -68,13 +58,6 @@ type ReportPayload = {
|
||||
type MachineOption = { id: string; name: string };
|
||||
type FilterOptions = { workOrders: string[]; skus: string[] };
|
||||
type Translator = (key: string, vars?: Record<string, string | number>) => string;
|
||||
type TooltipPayload<T> = { payload?: T; name?: string; value?: number | string };
|
||||
type SimpleTooltipProps<T> = {
|
||||
active?: boolean;
|
||||
payload?: Array<TooltipPayload<T>>;
|
||||
label?: string | number;
|
||||
};
|
||||
type CycleHistogramRow = ReportPayload["distribution"]["cycleTime"][number];
|
||||
|
||||
function fmtPct(v?: number | null) {
|
||||
if (v === null || v === undefined || Number.isNaN(v)) return "--";
|
||||
@@ -106,56 +89,20 @@ function formatTickLabel(ts: string, range: RangeKey) {
|
||||
return `${month}-${day}`;
|
||||
}
|
||||
|
||||
function CycleTooltip({ active, payload, t }: SimpleTooltipProps<CycleHistogramRow> & { t: Translator }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const p = payload[0]?.payload;
|
||||
if (!p) return null;
|
||||
|
||||
let detail = "";
|
||||
if (p.overflow === "low") {
|
||||
detail = `${t("reports.tooltip.below")} ${p.rangeEnd?.toFixed(1)}s`;
|
||||
} else if (p.overflow === "high") {
|
||||
detail = `${t("reports.tooltip.above")} ${p.rangeStart?.toFixed(1)}s`;
|
||||
} else if (p.rangeStart != null && p.rangeEnd != null) {
|
||||
detail = `${p.rangeStart.toFixed(1)}s - ${p.rangeEnd.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
const extreme =
|
||||
p.overflow && (p.minValue != null || p.maxValue != null)
|
||||
? `${t("reports.tooltip.extremes")}: ${p.minValue?.toFixed(1) ?? "--"}s - ${p.maxValue?.toFixed(1) ?? "--"}s`
|
||||
: "";
|
||||
|
||||
function ReportsChartsSkeleton() {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-zinc-950/95 px-4 py-3 shadow-lg">
|
||||
<div className="text-sm font-semibold text-white">{p.label}</div>
|
||||
<div className="mt-2 space-y-1 text-xs text-zinc-300">
|
||||
<div>
|
||||
{t("reports.tooltip.cycles")}: <span className="text-white">{p.count}</span>
|
||||
</div>
|
||||
{detail ? (
|
||||
<div>
|
||||
{t("reports.tooltip.range")}: <span className="text-white">{detail}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{extreme ? <div className="text-zinc-400">{extreme}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DowntimeTooltip({ active, payload, t }: SimpleTooltipProps<{ name?: string; value?: number }> & { t: Translator }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const row = payload[0]?.payload ?? {};
|
||||
const label = row.name ?? payload[0]?.name ?? "";
|
||||
const value = row.value ?? payload[0]?.value ?? 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-zinc-950/95 px-4 py-3 shadow-lg">
|
||||
<div className="text-sm font-semibold text-white">{label}</div>
|
||||
<div className="mt-2 text-xs text-zinc-300">
|
||||
{t("reports.tooltip.downtime")}: <span className="text-white">{Number(value)} min</span>
|
||||
<>
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
{Array.from({ length: 2 }).map((_, idx) => (
|
||||
<div key={idx} className="h-[320px] rounded-2xl border border-white/10 bg-white/5" />
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, idx) => (
|
||||
<div key={idx} className="h-[280px] rounded-2xl border border-white/10 bg-white/5" />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -534,6 +481,21 @@ export default function ReportsPage() {
|
||||
Microstop: "#FF7A00",
|
||||
};
|
||||
|
||||
const lossRows = useMemo(
|
||||
() => [
|
||||
{ label: t("reports.loss.macrostop"), value: fmtDuration(downtime?.macrostopSec) },
|
||||
{ label: t("reports.loss.microstop"), value: fmtDuration(downtime?.microstopSec) },
|
||||
{ label: t("reports.loss.slowCycle"), value: downtime ? `${downtime.slowCycleCount}` : "--" },
|
||||
{ label: t("reports.loss.qualitySpike"), value: downtime ? `${downtime.qualitySpikeCount}` : "--" },
|
||||
{ label: t("reports.loss.oeeDrop"), value: downtime ? `${downtime.oeeDropCount}` : "--" },
|
||||
{
|
||||
label: t("reports.loss.perfDegradation"),
|
||||
value: downtime ? `${downtime.performanceDegradationCount}` : "--",
|
||||
},
|
||||
],
|
||||
[downtime, t]
|
||||
);
|
||||
|
||||
const machineLabel = useMemo(() => {
|
||||
if (!machineId) return t("reports.filter.allMachines");
|
||||
return machines.find((m) => m.id === machineId)?.name ?? machineId;
|
||||
@@ -696,147 +658,18 @@ export default function ReportsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="mb-2 text-sm font-semibold text-white">{t("reports.oeeTrend")}</div>
|
||||
<div className="h-[260px] rounded-2xl border border-white/10 bg-black/25 p-4">
|
||||
{oeeSeries.length ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={oeeSeries}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
|
||||
<XAxis dataKey="label" tick={{ fill: "var(--app-chart-tick)" }} />
|
||||
<YAxis domain={[0, 100]} tick={{ fill: "var(--app-chart-tick)" }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: "var(--app-chart-tooltip-bg)",
|
||||
border: "1px solid var(--app-chart-tooltip-border)",
|
||||
}}
|
||||
labelStyle={{ color: "var(--app-chart-label)" }}
|
||||
labelFormatter={(_, payload) => {
|
||||
const row = payload?.[0]?.payload;
|
||||
return row?.ts ? new Date(row.ts).toLocaleString(locale) : "";
|
||||
}}
|
||||
formatter={(val: number | string | undefined) => [
|
||||
val == null ? "--" : `${Number(val).toFixed(1)}%`,
|
||||
"OEE",
|
||||
]}
|
||||
<Suspense fallback={<ReportsChartsSkeleton />}>
|
||||
<ReportsCharts
|
||||
oeeSeries={oeeSeries}
|
||||
downtimeSeries={downtimeSeries}
|
||||
downtimeColors={downtimeColors}
|
||||
cycleHistogram={cycleHistogram}
|
||||
scrapSeries={scrapSeries}
|
||||
lossRows={lossRows}
|
||||
locale={locale}
|
||||
t={t}
|
||||
/>
|
||||
<Line type="monotone" dataKey="value" stroke="#34d399" dot={false} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||
{t("reports.noTrend")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="mb-2 text-sm font-semibold text-white">{t("reports.downtimePareto")}</div>
|
||||
<div className="h-[260px] rounded-2xl border border-white/10 bg-black/25 p-4">
|
||||
{downtimeSeries.length ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={downtimeSeries}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
|
||||
<XAxis dataKey="name" tick={{ fill: "var(--app-chart-tick)" }} />
|
||||
<YAxis tick={{ fill: "var(--app-chart-tick)" }} />
|
||||
<Tooltip content={<DowntimeTooltip t={t} />} />
|
||||
<Bar dataKey="value" radius={[10, 10, 0, 0]} isAnimationActive={false}>
|
||||
{downtimeSeries.map((row, idx) => (
|
||||
<Cell key={`${row.name}-${idx}`} fill={downtimeColors[row.name] ?? "#94a3b8"} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||
{t("reports.noTrend")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="mb-2 text-sm font-semibold text-white">{t("reports.cycleDistribution")}</div>
|
||||
<div className="h-[220px] rounded-2xl border border-white/10 bg-black/25 p-4">
|
||||
{cycleHistogram.length ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={cycleHistogram}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
|
||||
<XAxis dataKey="label" tick={{ fill: "var(--app-chart-tick)", fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: "var(--app-chart-tick)" }} />
|
||||
<Tooltip content={<CycleTooltip t={t} />} />
|
||||
<Bar dataKey="count" radius={[8, 8, 0, 0]} fill="#60a5fa" isAnimationActive={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||
{t("reports.noCycle")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="mb-2 text-sm font-semibold text-white">{t("reports.scrapTrend")}</div>
|
||||
<div className="h-[220px] rounded-2xl border border-white/10 bg-black/25 p-4">
|
||||
{scrapSeries.length ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={scrapSeries}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
|
||||
<XAxis dataKey="label" tick={{ fill: "var(--app-chart-tick)" }} />
|
||||
<YAxis domain={[0, 100]} tick={{ fill: "var(--app-chart-tick)" }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: "var(--app-chart-tooltip-bg)",
|
||||
border: "1px solid var(--app-chart-tooltip-border)",
|
||||
}}
|
||||
labelStyle={{ color: "var(--app-chart-label)" }}
|
||||
labelFormatter={(_, payload) => {
|
||||
const row = payload?.[0]?.payload;
|
||||
return row?.ts ? new Date(row.ts).toLocaleString(locale) : "";
|
||||
}}
|
||||
formatter={(val: number | string | undefined) => [
|
||||
val == null ? "--" : `${Number(val).toFixed(1)}%`,
|
||||
t("reports.scrapRate"),
|
||||
]}
|
||||
/>
|
||||
<Line type="monotone" dataKey="value" stroke="#f97316" dot={false} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||
{t("reports.noDowntime")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="mb-2 text-sm font-semibold text-white">{t("reports.topLossDrivers")}</div>
|
||||
<div className="space-y-3 text-sm text-zinc-300">
|
||||
{[
|
||||
{ label: t("reports.loss.macrostop"), value: fmtDuration(downtime?.macrostopSec) },
|
||||
{ label: t("reports.loss.microstop"), value: fmtDuration(downtime?.microstopSec) },
|
||||
{ label: t("reports.loss.slowCycle"), value: downtime ? `${downtime.slowCycleCount}` : "--" },
|
||||
{ label: t("reports.loss.qualitySpike"), value: downtime ? `${downtime.qualitySpikeCount}` : "--" },
|
||||
{ label: t("reports.loss.oeeDrop"), value: downtime ? `${downtime.oeeDropCount}` : "--" },
|
||||
{
|
||||
label: t("reports.loss.perfDegradation"),
|
||||
value: downtime ? `${downtime.performanceDegradationCount}` : "--",
|
||||
},
|
||||
].map((row) => (
|
||||
<div key={row.label} className="flex items-center justify-between rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<span>{row.label}</span>
|
||||
<span className="text-xs text-zinc-400">{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Suspense>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertsConfig } from "@/components/settings/AlertsConfig";
|
||||
import { FinancialCostConfig } from "@/components/settings/FinancialCostConfig";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import { SHIFT_OVERRIDE_DAYS, type ShiftOverrideDay } from "@/lib/settings";
|
||||
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
|
||||
|
||||
|
||||
@@ -25,6 +26,7 @@ type SettingsPayload = {
|
||||
|
||||
shiftSchedule: {
|
||||
shifts: Shift[];
|
||||
overrides?: Partial<Record<ShiftOverrideDay, Shift[]>>;
|
||||
shiftChangeCompensationMin: number;
|
||||
lunchBreakMin: number;
|
||||
};
|
||||
@@ -88,6 +90,7 @@ const DEFAULT_SETTINGS: SettingsPayload = {
|
||||
modules: { screenlessMode: false },
|
||||
shiftSchedule: {
|
||||
shifts: [],
|
||||
overrides: {},
|
||||
shiftChangeCompensationMin: 10,
|
||||
lunchBreakMin: 30,
|
||||
},
|
||||
@@ -199,6 +202,21 @@ function normalizeShift(raw: unknown, fallbackName: string): Shift {
|
||||
return { name, start, end, enabled };
|
||||
}
|
||||
|
||||
function normalizeShiftOverrides(
|
||||
raw: unknown,
|
||||
fallbackName: (index: number) => string
|
||||
): Partial<Record<ShiftOverrideDay, Shift[]>> {
|
||||
const record = asRecord(raw);
|
||||
if (!record) return {};
|
||||
const out: Partial<Record<ShiftOverrideDay, Shift[]>> = {};
|
||||
for (const day of SHIFT_OVERRIDE_DAYS) {
|
||||
const shiftsRaw = Array.isArray(record[day]) ? (record[day] as unknown[]) : null;
|
||||
if (!shiftsRaw) continue;
|
||||
out[day] = shiftsRaw.map((shift, idx) => normalizeShift(shift, fallbackName(idx + 1)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeSettings(raw: unknown, fallbackName: (index: number) => string): SettingsPayload {
|
||||
const record = asRecord(raw);
|
||||
const modules = asRecord(record?.modules) ?? {};
|
||||
@@ -217,6 +235,7 @@ function normalizeSettings(raw: unknown, fallbackName: (index: number) => string
|
||||
const shifts = shiftsRaw.length
|
||||
? shiftsRaw.map((s, idx) => normalizeShift(s, fallbackName(idx + 1)))
|
||||
: [{ name: fallbackName(1), ...DEFAULT_SHIFT }];
|
||||
const overrides = normalizeShiftOverrides(shiftSchedule.overrides, fallbackName);
|
||||
const thresholds = asRecord(record.thresholds) ?? {};
|
||||
const alerts = asRecord(record.alerts) ?? {};
|
||||
const defaults = asRecord(record.defaults) ?? {};
|
||||
@@ -227,6 +246,7 @@ function normalizeSettings(raw: unknown, fallbackName: (index: number) => string
|
||||
timezone: String(record.timezone ?? DEFAULT_SETTINGS.timezone),
|
||||
shiftSchedule: {
|
||||
shifts,
|
||||
overrides,
|
||||
shiftChangeCompensationMin: Number(
|
||||
shiftSchedule.shiftChangeCompensationMin ?? DEFAULT_SETTINGS.shiftSchedule.shiftChangeCompensationMin
|
||||
),
|
||||
@@ -326,16 +346,26 @@ export default function SettingsPage() {
|
||||
const [inviteStatus, setInviteStatus] = useState<string | null>(null);
|
||||
const [inviteSubmitting, setInviteSubmitting] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<(typeof SETTINGS_TABS)[number]["id"]>("general");
|
||||
const hasMountedRef = useRef(false);
|
||||
const defaultShiftName = useCallback(
|
||||
(index: number) => t("settings.shift.defaultName", { index }),
|
||||
[t]
|
||||
);
|
||||
const shiftOverrideDays = useMemo(
|
||||
() =>
|
||||
SHIFT_OVERRIDE_DAYS.map((day) => ({
|
||||
key: day,
|
||||
label: t(`settings.shiftOverrides.${day}`),
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
const loadSettings = useCallback(async (forceRefresh = false) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/settings", { cache: "no-store" });
|
||||
const url = forceRefresh ? "/api/settings?refresh=1" : "/api/settings";
|
||||
const response = await fetch(url, { cache: forceRefresh ? "no-store" : "default" });
|
||||
const { data, text } = await readResponse(response);
|
||||
const api = unwrapApiResponse(data);
|
||||
if (!response.ok || !api.ok) {
|
||||
@@ -350,7 +380,7 @@ export default function SettingsPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [defaultShiftName, t]);
|
||||
}, [defaultShiftName, t, setScreenlessMode]);
|
||||
|
||||
const buildInviteUrl = useCallback((token: string) => {
|
||||
if (typeof window === "undefined") return `/invite/${token}`;
|
||||
@@ -380,10 +410,14 @@ export default function SettingsPage() {
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
// Only run once on mount to prevent infinite loops from dependency changes
|
||||
useEffect(() => {
|
||||
if (hasMountedRef.current) return;
|
||||
hasMountedRef.current = true;
|
||||
loadSettings();
|
||||
loadTeam();
|
||||
}, [loadSettings, loadTeam]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const updateShift = useCallback((index: number, patch: Partial<Shift>) => {
|
||||
setDraft((prev) => {
|
||||
@@ -448,6 +482,96 @@ export default function SettingsPage() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleShiftOverride = useCallback((day: ShiftOverrideDay) => {
|
||||
setDraft((prev) => {
|
||||
if (!prev) return prev;
|
||||
const overrides = { ...(prev.shiftSchedule.overrides ?? {}) };
|
||||
if (overrides[day]) {
|
||||
delete overrides[day];
|
||||
} else {
|
||||
overrides[day] = prev.shiftSchedule.shifts.map((shift) => ({ ...shift }));
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
shiftSchedule: {
|
||||
...prev.shiftSchedule,
|
||||
overrides,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateShiftOverride = useCallback((day: ShiftOverrideDay, index: number, patch: Partial<Shift>) => {
|
||||
setDraft((prev) => {
|
||||
if (!prev) return prev;
|
||||
const current = prev.shiftSchedule.overrides?.[day];
|
||||
if (!current) return prev;
|
||||
const overrides = { ...(prev.shiftSchedule.overrides ?? {}) };
|
||||
overrides[day] = current.map((shift, idx) => (idx === index ? { ...shift, ...patch } : shift));
|
||||
return {
|
||||
...prev,
|
||||
shiftSchedule: {
|
||||
...prev.shiftSchedule,
|
||||
overrides,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addShiftOverride = useCallback(
|
||||
(day: ShiftOverrideDay) => {
|
||||
setDraft((prev) => {
|
||||
if (!prev) return prev;
|
||||
const overrides = { ...(prev.shiftSchedule.overrides ?? {}) };
|
||||
const current = overrides[day] ? [...overrides[day]!] : [];
|
||||
if (current.length >= 3) return prev;
|
||||
const nextIndex = current.length + 1;
|
||||
current.push({ name: defaultShiftName(nextIndex), ...DEFAULT_SHIFT });
|
||||
overrides[day] = current;
|
||||
return {
|
||||
...prev,
|
||||
shiftSchedule: {
|
||||
...prev.shiftSchedule,
|
||||
overrides,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
[defaultShiftName]
|
||||
);
|
||||
|
||||
const removeShiftOverride = useCallback((day: ShiftOverrideDay, index: number) => {
|
||||
setDraft((prev) => {
|
||||
if (!prev) return prev;
|
||||
const current = prev.shiftSchedule.overrides?.[day];
|
||||
if (!current) return prev;
|
||||
const overrides = { ...(prev.shiftSchedule.overrides ?? {}) };
|
||||
overrides[day] = current.filter((_, idx) => idx !== index);
|
||||
return {
|
||||
...prev,
|
||||
shiftSchedule: {
|
||||
...prev.shiftSchedule,
|
||||
overrides,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearShiftOverride = useCallback((day: ShiftOverrideDay) => {
|
||||
setDraft((prev) => {
|
||||
if (!prev) return prev;
|
||||
const overrides = { ...(prev.shiftSchedule.overrides ?? {}) };
|
||||
overrides[day] = [];
|
||||
return {
|
||||
...prev,
|
||||
shiftSchedule: {
|
||||
...prev.shiftSchedule,
|
||||
overrides,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateThreshold = useCallback(
|
||||
(
|
||||
key:
|
||||
@@ -665,7 +789,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto">
|
||||
<button
|
||||
onClick={loadSettings}
|
||||
onClick={() => loadSettings(true)}
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-center text-sm text-white hover:bg-white/10 sm:w-auto"
|
||||
>
|
||||
{t("settings.refresh")}
|
||||
@@ -994,6 +1118,119 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="mb-2 text-sm font-semibold text-white">{t("settings.shiftOverrides.title")}</div>
|
||||
<div className="text-xs text-zinc-400">{t("settings.shiftOverrides.subtitle")}</div>
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{shiftOverrideDays.map((day) => {
|
||||
const dayOverrides = draft.shiftSchedule.overrides?.[day.key];
|
||||
const overrideShifts = dayOverrides ?? [];
|
||||
const isCustom = dayOverrides !== undefined;
|
||||
return (
|
||||
<div key={day.key} className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-white">{day.label}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleShiftOverride(day.key)}
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs text-white"
|
||||
>
|
||||
{isCustom
|
||||
? t("settings.shiftOverrides.useDefault")
|
||||
: t("settings.shiftOverrides.customize")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isCustom && (
|
||||
<div className="mt-2 text-xs text-zinc-400">{t("settings.shiftOverrides.inherits")}</div>
|
||||
)}
|
||||
|
||||
{isCustom && (
|
||||
<>
|
||||
{overrideShifts.length === 0 ? (
|
||||
<div className="mt-2 text-xs text-zinc-400">
|
||||
{t("settings.shiftOverrides.dayOff")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{overrideShifts.map((shift, index) => (
|
||||
<div key={`${day.key}-${index}`} className="rounded-lg border border-white/10 bg-black/30 p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<input
|
||||
value={shift.name}
|
||||
onChange={(event) =>
|
||||
updateShiftOverride(day.key, index, { name: event.target.value })
|
||||
}
|
||||
className="w-full rounded-md border border-white/10 bg-black/30 px-2 py-1 text-xs text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeShiftOverride(day.key, index)}
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs text-white"
|
||||
>
|
||||
{t("settings.shiftRemove")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
type="time"
|
||||
value={shift.start}
|
||||
onChange={(event) =>
|
||||
updateShiftOverride(day.key, index, { start: event.target.value })
|
||||
}
|
||||
className="w-full rounded-md border border-white/10 bg-black/30 px-2 py-1 text-xs text-white"
|
||||
/>
|
||||
<span className="text-xs text-zinc-400">{t("settings.shiftTo")}</span>
|
||||
<input
|
||||
type="time"
|
||||
value={shift.end}
|
||||
onChange={(event) =>
|
||||
updateShiftOverride(day.key, index, { end: event.target.value })
|
||||
}
|
||||
className="w-full rounded-md border border-white/10 bg-black/30 px-2 py-1 text-xs text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-zinc-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shift.enabled}
|
||||
onChange={(event) =>
|
||||
updateShiftOverride(day.key, index, { enabled: event.target.checked })
|
||||
}
|
||||
className="h-4 w-4 rounded border border-white/20 bg-black/20"
|
||||
/>
|
||||
{t("settings.shiftEnabled")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addShiftOverride(day.key)}
|
||||
disabled={overrideShifts.length >= 3}
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs text-white disabled:opacity-40"
|
||||
>
|
||||
{t("settings.shiftAdd")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => clearShiftOverride(day.key)}
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs text-white"
|
||||
>
|
||||
{t("settings.shiftOverrides.clear")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function GET(req: Request) {
|
||||
count: g._count._all,
|
||||
};
|
||||
})
|
||||
.filter((x) => x.value > 0);
|
||||
.filter((x) => (kind === "downtime" ? x.value > 0 || x.count > 0 : x.value > 0));
|
||||
|
||||
itemsRaw.sort((a, b) => b.value - a.value);
|
||||
|
||||
|
||||
45
app/api/debug/logs/route.ts
Normal file
45
app/api/debug/logs/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
31
app/api/debug/perf/route.ts
Normal file
31
app/api/debug/perf/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { createHash } from "crypto";
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
FINANCIAL_CONFIG_SWR_SEC,
|
||||
FINANCIAL_CONFIG_TTL_SEC,
|
||||
getFinancialConfig,
|
||||
type FinancialConfigPayload,
|
||||
} from "@/lib/financial/cache";
|
||||
|
||||
function canManageFinancials(role?: string | null) {
|
||||
return role === "OWNER";
|
||||
@@ -101,18 +110,37 @@ async function ensureOrgFinancialProfile(
|
||||
});
|
||||
}
|
||||
|
||||
async function loadFinancialConfig(orgId: string) {
|
||||
const [org, locations, machines, products] = await Promise.all([
|
||||
prisma.orgFinancialProfile.findUnique({ where: { orgId } }),
|
||||
prisma.locationFinancialOverride.findMany({ where: { orgId }, orderBy: { location: "asc" } }),
|
||||
prisma.machineFinancialOverride.findMany({ where: { orgId }, orderBy: { createdAt: "desc" } }),
|
||||
prisma.productCostOverride.findMany({ where: { orgId }, orderBy: { sku: "asc" } }),
|
||||
]);
|
||||
|
||||
return { org, locations, machines, products };
|
||||
function toMs(value?: Date | string | null) {
|
||||
if (!value) return 0;
|
||||
const date = typeof value === "string" ? new Date(value) : value;
|
||||
const ms = date.getTime();
|
||||
return Number.isNaN(ms) ? 0 : ms;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
function maxUpdatedMs(rows: Array<{ updatedAt?: Date | string | null }>) {
|
||||
let max = 0;
|
||||
for (const row of rows) {
|
||||
const ms = toMs(row.updatedAt);
|
||||
if (ms > max) max = ms;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
function buildConfigEtag(orgId: string, payload: FinancialConfigPayload) {
|
||||
const parts = [
|
||||
orgId,
|
||||
toMs(payload.org?.updatedAt),
|
||||
maxUpdatedMs(payload.locations ?? []),
|
||||
maxUpdatedMs(payload.machines ?? []),
|
||||
maxUpdatedMs(payload.products ?? []),
|
||||
payload.locations?.length ?? 0,
|
||||
payload.machines?.length ?? 0,
|
||||
payload.products?.length ?? 0,
|
||||
];
|
||||
return `W/"${createHash("sha1").update(parts.join("|")).digest("hex")}"`;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await requireSession();
|
||||
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
@@ -124,9 +152,25 @@ export async function GET() {
|
||||
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const refresh = url.searchParams.get("refresh") === "1";
|
||||
|
||||
await prisma.$transaction((tx) => ensureOrgFinancialProfile(tx, session.orgId, session.userId));
|
||||
const payload = await loadFinancialConfig(session.orgId);
|
||||
return NextResponse.json({ ok: true, ...payload });
|
||||
const payload = await getFinancialConfig(session.orgId, { refresh });
|
||||
|
||||
const etag = buildConfigEtag(session.orgId, payload);
|
||||
const responseHeaders = new Headers({
|
||||
"Cache-Control": `private, max-age=${FINANCIAL_CONFIG_TTL_SEC}, stale-while-revalidate=${FINANCIAL_CONFIG_SWR_SEC}`,
|
||||
ETag: etag,
|
||||
Vary: "Cookie",
|
||||
});
|
||||
|
||||
const ifNoneMatch = req.headers.get("if-none-match");
|
||||
if (!refresh && ifNoneMatch && ifNoneMatch === etag) {
|
||||
return new NextResponse(null, { status: 304, headers: responseHeaders });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, ...payload }, { headers: responseHeaders });
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
@@ -257,6 +301,9 @@ export async function POST(req: Request) {
|
||||
}
|
||||
});
|
||||
|
||||
const payload = await loadFinancialConfig(session.orgId);
|
||||
revalidateTag(`financial-config:${session.orgId}`, { expire: 0 });
|
||||
revalidateTag(`financial-impact:${session.orgId}`, { expire: 0 });
|
||||
|
||||
const payload = await getFinancialConfig(session.orgId, { refresh: true });
|
||||
return NextResponse.json({ ok: true, ...payload });
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { computeFinancialImpact } from "@/lib/financial/impact";
|
||||
import {
|
||||
FINANCIAL_IMPACT_SWR_SEC,
|
||||
FINANCIAL_IMPACT_TTL_SEC,
|
||||
getFinancialImpactCached,
|
||||
} from "@/lib/financial/cache";
|
||||
|
||||
const RANGE_MS: Record<string, number> = {
|
||||
"24h": 24 * 60 * 60 * 1000,
|
||||
@@ -50,13 +54,15 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const refresh = url.searchParams.get("refresh") === "1";
|
||||
const { start, end } = pickRange(req);
|
||||
const machineId = url.searchParams.get("machineId") ?? undefined;
|
||||
const location = url.searchParams.get("location") ?? undefined;
|
||||
const sku = url.searchParams.get("sku") ?? undefined;
|
||||
const currency = url.searchParams.get("currency") ?? undefined;
|
||||
|
||||
const result = await computeFinancialImpact({
|
||||
const result = await getFinancialImpactCached(
|
||||
{
|
||||
orgId: session.orgId,
|
||||
start,
|
||||
end,
|
||||
@@ -65,7 +71,14 @@ export async function GET(req: NextRequest) {
|
||||
sku,
|
||||
currency,
|
||||
includeEvents: false,
|
||||
},
|
||||
{ refresh }
|
||||
);
|
||||
|
||||
const responseHeaders = new Headers({
|
||||
"Cache-Control": `private, max-age=${FINANCIAL_IMPACT_TTL_SEC}, stale-while-revalidate=${FINANCIAL_IMPACT_SWR_SEC}`,
|
||||
Vary: "Cookie",
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, ...result });
|
||||
return NextResponse.json({ ok: true, ...result }, { headers: responseHeaders });
|
||||
}
|
||||
|
||||
@@ -33,6 +33,48 @@ function unwrapEnvelope(raw: unknown) {
|
||||
};
|
||||
}
|
||||
|
||||
function asNumber(value: unknown) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeCycleInput(raw: unknown): Record<string, unknown> | null {
|
||||
const row = asRecord(raw);
|
||||
if (!row) return null;
|
||||
const data = asRecord(row.data);
|
||||
|
||||
const fromRowOrData = (keys: string[]) => {
|
||||
for (const key of keys) {
|
||||
if (row[key] !== undefined) return row[key];
|
||||
if (data && data[key] !== undefined) return data[key];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
...row,
|
||||
actual_cycle_time: fromRowOrData(["actual_cycle_time", "actualCycleTime", "actual_cycle", "actual"]),
|
||||
theoretical_cycle_time: fromRowOrData([
|
||||
"theoretical_cycle_time",
|
||||
"theoreticalCycleTime",
|
||||
"cycleTime",
|
||||
"cycle_time",
|
||||
"ideal",
|
||||
]),
|
||||
cycle_count: fromRowOrData(["cycle_count", "cycleCount"]),
|
||||
work_order_id: fromRowOrData(["work_order_id", "workOrderId"]),
|
||||
good_delta: fromRowOrData(["good_delta", "goodDelta"]),
|
||||
scrap_delta: fromRowOrData(["scrap_delta", "scrapDelta", "scrap_total"]),
|
||||
timestamp: fromRowOrData(["timestamp", "tsMs"]),
|
||||
ts: fromRowOrData(["ts", "tsMs"]),
|
||||
event_timestamp: fromRowOrData(["event_timestamp", "eventTimestamp"]),
|
||||
};
|
||||
}
|
||||
|
||||
const numberFromAny = z.preprocess((value) => {
|
||||
if (typeof value === "number") return value;
|
||||
if (typeof value === "string" && value.trim() !== "") return Number(value);
|
||||
@@ -87,15 +129,22 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
const cycleList = Array.isArray(cyclesRaw) ? cyclesRaw : [cyclesRaw];
|
||||
const cycleList = (Array.isArray(cyclesRaw) ? cyclesRaw : [cyclesRaw])
|
||||
.map((row) => normalizeCycleInput(row))
|
||||
.filter((row): row is Record<string, unknown> => !!row);
|
||||
|
||||
if (!cycleList.length) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsedCycles = z.array(cycleSchema).safeParse(cycleList);
|
||||
if (!parsedCycles.success) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
const fallbackTsMs =
|
||||
(typeof bodyRecord.tsMs === "number" && bodyRecord.tsMs) ||
|
||||
(typeof bodyRecord.tsDevice === "number" && bodyRecord.tsDevice) ||
|
||||
asNumber(bodyRecord.tsMs) ||
|
||||
asNumber(bodyRecord.tsDevice) ||
|
||||
undefined;
|
||||
|
||||
const rows = parsedCycles.data.map((data) => {
|
||||
|
||||
@@ -4,6 +4,14 @@ import { getMachineAuth } from "@/lib/machineAuthCache";
|
||||
import { z } from "zod";
|
||||
import { evaluateAlertsForEvent } from "@/lib/alerts/engine";
|
||||
import { toJsonValue } from "@/lib/prismaJson";
|
||||
import {
|
||||
findCatalogReason,
|
||||
loadFallbackReasonCatalog,
|
||||
normalizeReasonCatalog,
|
||||
toReasonCode,
|
||||
type ReasonCatalog,
|
||||
type ReasonCatalogKind,
|
||||
} from "@/lib/reasonCatalog";
|
||||
|
||||
const normalizeType = (t: unknown) =>
|
||||
String(t ?? "")
|
||||
@@ -30,6 +38,8 @@ const CANON_TYPE: Record<string, string> = {
|
||||
"microparo": "microstop",
|
||||
"micro-paro": "microstop",
|
||||
"down": "stop",
|
||||
"downtime-acknowledged": "downtime-acknowledged",
|
||||
"scrap-manual-entry": "scrap-manual-entry",
|
||||
};
|
||||
|
||||
const ALLOWED_TYPES = new Set([
|
||||
@@ -42,6 +52,8 @@ const ALLOWED_TYPES = new Set([
|
||||
"quality-spike",
|
||||
"performance-degradation",
|
||||
"predictive-oee-decline",
|
||||
"downtime-acknowledged",
|
||||
"scrap-manual-entry",
|
||||
]);
|
||||
|
||||
const machineIdSchema = z.string().uuid();
|
||||
@@ -58,6 +70,153 @@ function clampText(value: unknown, maxLen: number) {
|
||||
return text.length > maxLen ? text.slice(0, maxLen) : text;
|
||||
}
|
||||
|
||||
function numberFrom(value: unknown) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function canonicalText(value: unknown) {
|
||||
return String(value ?? "")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function parseReasonPath(rawPath: unknown) {
|
||||
let category: string | null = null;
|
||||
let detail: string | null = null;
|
||||
|
||||
if (Array.isArray(rawPath)) {
|
||||
const first = rawPath[0];
|
||||
const second = rawPath[1];
|
||||
if (typeof first === "string") category = first;
|
||||
if (typeof second === "string") detail = second;
|
||||
if (asRecord(first)) category = clampText(first.id ?? first.label ?? first.value, 120);
|
||||
if (asRecord(second)) detail = clampText(second.id ?? second.label ?? second.value, 120);
|
||||
} else if (typeof rawPath === "string") {
|
||||
const pieces = rawPath
|
||||
.split(/>|\/|\\|\|/g)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
category = pieces[0] ?? null;
|
||||
detail = pieces[1] ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
category: clampText(category, 120),
|
||||
detail: clampText(detail, 120),
|
||||
};
|
||||
}
|
||||
|
||||
function parseReasonTextPath(reasonText: unknown) {
|
||||
const text = clampText(reasonText, 240);
|
||||
if (!text) return { category: null as string | null, detail: null as string | null };
|
||||
const pieces = text
|
||||
.split(/>|\/|\\|\|/g)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
return {
|
||||
category: clampText(pieces[0] ?? null, 120),
|
||||
detail: clampText(pieces[1] ?? null, 120),
|
||||
};
|
||||
}
|
||||
|
||||
function findCatalogReasonFlexible(
|
||||
catalog: ReasonCatalog | null,
|
||||
kind: ReasonCatalogKind,
|
||||
categoryIdOrLabel: unknown,
|
||||
detailIdOrLabel: unknown
|
||||
) {
|
||||
const direct = findCatalogReason(catalog, kind, categoryIdOrLabel, detailIdOrLabel);
|
||||
if (direct) return direct;
|
||||
if (!catalog) return null;
|
||||
|
||||
const catNeedle = canonicalText(categoryIdOrLabel);
|
||||
const detNeedle = canonicalText(detailIdOrLabel);
|
||||
if (!catNeedle || !detNeedle) return null;
|
||||
|
||||
for (const category of catalog[kind] ?? []) {
|
||||
const catMatch =
|
||||
canonicalText(category.id) === catNeedle || canonicalText(category.label) === catNeedle;
|
||||
if (!catMatch) continue;
|
||||
for (const detail of category.details) {
|
||||
const detMatch = canonicalText(detail.id) === detNeedle || canonicalText(detail.label) === detNeedle;
|
||||
if (!detMatch) continue;
|
||||
return {
|
||||
categoryId: category.id,
|
||||
categoryLabel: category.label,
|
||||
detailId: detail.id,
|
||||
detailLabel: detail.label,
|
||||
reasonCode: toReasonCode(category.id, detail.id),
|
||||
reasonLabel: `${category.label} > ${detail.label}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getCatalogFromDefaults(defaultsJson: unknown) {
|
||||
const defaults = asRecord(defaultsJson);
|
||||
if (!defaults) return null;
|
||||
return normalizeReasonCatalog(defaults.reasonCatalog ?? defaults.reasonCatalogData);
|
||||
}
|
||||
|
||||
function resolveReason(
|
||||
raw: Record<string, unknown>,
|
||||
kind: ReasonCatalogKind,
|
||||
catalog: ReasonCatalog | null,
|
||||
fallbackVersion: number
|
||||
) {
|
||||
const reasonPath = parseReasonPath(raw.reasonPath);
|
||||
const reasonTextPath = parseReasonTextPath(raw.reasonText);
|
||||
const categoryIdRaw = clampText(raw.categoryId ?? reasonPath.category ?? reasonTextPath.category, 64);
|
||||
const detailIdRaw = clampText(raw.detailId ?? reasonPath.detail ?? reasonTextPath.detail, 64);
|
||||
const fromCatalog = findCatalogReasonFlexible(catalog, kind, categoryIdRaw, detailIdRaw);
|
||||
|
||||
const categoryLabelRaw = clampText(raw.categoryLabel ?? reasonPath.category ?? reasonTextPath.category, 120);
|
||||
const detailLabelRaw = clampText(raw.detailLabel ?? reasonPath.detail ?? reasonTextPath.detail, 120);
|
||||
|
||||
const reasonCode =
|
||||
clampText(raw.reasonCode, 64)?.toUpperCase() ??
|
||||
fromCatalog?.reasonCode ??
|
||||
toReasonCode(categoryIdRaw ?? categoryLabelRaw, detailIdRaw ?? detailLabelRaw) ??
|
||||
null;
|
||||
|
||||
const categoryId = fromCatalog?.categoryId ?? categoryIdRaw;
|
||||
const detailId = fromCatalog?.detailId ?? detailIdRaw;
|
||||
const categoryLabel = fromCatalog?.categoryLabel ?? categoryLabelRaw;
|
||||
const detailLabel = fromCatalog?.detailLabel ?? detailLabelRaw;
|
||||
|
||||
const pathLabel =
|
||||
clampText(raw.reasonText, 240) ??
|
||||
fromCatalog?.reasonLabel ??
|
||||
(categoryLabel && detailLabel ? `${categoryLabel} > ${detailLabel}` : null) ??
|
||||
detailLabel ??
|
||||
categoryLabel ??
|
||||
reasonCode;
|
||||
|
||||
const catalogVersionRaw = numberFrom(raw.catalogVersion);
|
||||
const catalogVersion = catalogVersionRaw != null ? Math.trunc(catalogVersionRaw) : fallbackVersion;
|
||||
|
||||
return {
|
||||
type: kind,
|
||||
categoryId,
|
||||
categoryLabel,
|
||||
detailId,
|
||||
detailLabel,
|
||||
reasonCode,
|
||||
reasonLabel: pathLabel,
|
||||
reasonText: pathLabel,
|
||||
catalogVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const apiKey = req.headers.get("x-api-key");
|
||||
if (!apiKey) return NextResponse.json({ ok: false, error: "Missing api key" }, { status: 401 });
|
||||
@@ -103,8 +262,11 @@ export async function POST(req: Request) {
|
||||
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
const orgSettings = await prisma.orgSettings.findUnique({
|
||||
where: { orgId: machine.orgId },
|
||||
select: { stoppageMultiplier: true, macroStoppageMultiplier: true },
|
||||
select: { stoppageMultiplier: true, macroStoppageMultiplier: true, defaultsJson: true },
|
||||
});
|
||||
const fallbackCatalog = await loadFallbackReasonCatalog();
|
||||
const settingsCatalog = getCatalogFromDefaults(orgSettings?.defaultsJson);
|
||||
const reasonCatalog = settingsCatalog ?? fallbackCatalog;
|
||||
|
||||
const defaultMicroMultiplier = Number(orgSettings?.stoppageMultiplier ?? 1.5);
|
||||
const defaultMacroMultiplier = Math.max(
|
||||
@@ -129,6 +291,8 @@ export async function POST(req: Request) {
|
||||
continue;
|
||||
}
|
||||
const evData = asRecord(evRecord.data) ?? {};
|
||||
const evReason = asRecord(evRecord.reason) ?? asRecord(evData.reason);
|
||||
const evDowntime = asRecord(evRecord.downtime) ?? asRecord(evData.downtime);
|
||||
|
||||
const rawType = evRecord.eventType ?? evRecord.anomaly_type ?? evRecord.topic ?? bodyRecord.topic ?? "";
|
||||
const typ0 = normalizeType(rawType);
|
||||
@@ -211,6 +375,8 @@ export async function POST(req: Request) {
|
||||
if (evRecord.alert_id != null && dataObj.alert_id == null) dataObj.alert_id = evRecord.alert_id;
|
||||
if (evRecord.is_update != null && dataObj.is_update == null) dataObj.is_update = evRecord.is_update;
|
||||
if (evRecord.is_auto_ack != null && dataObj.is_auto_ack == null) dataObj.is_auto_ack = evRecord.is_auto_ack;
|
||||
if (evReason && dataObj.reason == null) dataObj.reason = evReason;
|
||||
if (evDowntime && dataObj.downtime == null) dataObj.downtime = evDowntime;
|
||||
|
||||
const activeWorkOrder = asRecord(evRecord.activeWorkOrder);
|
||||
const dataActiveWorkOrder = asRecord(evData.activeWorkOrder);
|
||||
@@ -244,8 +410,127 @@ export async function POST(req: Request) {
|
||||
|
||||
created.push({ id: row.id, ts: row.ts, eventType: row.eventType });
|
||||
|
||||
if (evReason) {
|
||||
const inferredKind: ReasonCatalogKind =
|
||||
String(evReason.type ?? "").toLowerCase() === "scrap" || finalType === "scrap-manual-entry"
|
||||
? "scrap"
|
||||
: "downtime";
|
||||
const resolved = resolveReason(evReason, inferredKind, reasonCatalog, reasonCatalog.version);
|
||||
|
||||
if (resolved.reasonCode) {
|
||||
const reasonId =
|
||||
clampText(evReason.reasonId, 128) ??
|
||||
(inferredKind === "downtime"
|
||||
? `evt:${machine.id}:downtime:${clampText(evReason.incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id}`
|
||||
: `evt:${machine.id}:scrap:${clampText(evReason.scrapEntryId, 128) ?? row.id}`);
|
||||
|
||||
const workOrderId =
|
||||
clampText(evRecord.work_order_id, 64) ??
|
||||
clampText(evData.work_order_id, 64) ??
|
||||
clampText(evRecord.workOrderId, 64) ??
|
||||
null;
|
||||
|
||||
const commonWrite = {
|
||||
reasonCode: resolved.reasonCode,
|
||||
reasonLabel: resolved.reasonLabel ?? resolved.reasonCode,
|
||||
reasonText: resolved.reasonText ?? null,
|
||||
capturedAt: row.ts,
|
||||
workOrderId,
|
||||
schemaVersion: Math.max(1, Math.trunc(resolved.catalogVersion)),
|
||||
meta: toJsonValue({
|
||||
source: "ingest:event",
|
||||
eventId: row.id,
|
||||
eventType: row.eventType,
|
||||
incidentKey: clampText(evReason.incidentKey ?? evDowntime?.incidentKey, 128),
|
||||
anomalyType:
|
||||
clampText(evRecord.anomalyType, 64) ??
|
||||
clampText(evDowntime?.anomalyType, 64) ??
|
||||
clampText(evRecord.anomaly_type, 64),
|
||||
reason: {
|
||||
type: resolved.type,
|
||||
categoryId: resolved.categoryId,
|
||||
categoryLabel: resolved.categoryLabel,
|
||||
detailId: resolved.detailId,
|
||||
detailLabel: resolved.detailLabel,
|
||||
reasonText: resolved.reasonText,
|
||||
catalogVersion: resolved.catalogVersion,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
if (inferredKind === "downtime") {
|
||||
const incidentKey = clampText(evReason.incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id;
|
||||
const durationSeconds =
|
||||
numberFrom(evDowntime?.durationSeconds) ??
|
||||
numberFrom(evData.stoppage_duration_seconds) ??
|
||||
numberFrom(evData.stop_duration_seconds) ??
|
||||
null;
|
||||
const episodeEndTsMs =
|
||||
numberFrom(evDowntime?.episodeEndTsMs) ??
|
||||
numberFrom(evDowntime?.acknowledgedAtMs) ??
|
||||
null;
|
||||
|
||||
await prisma.reasonEntry.upsert({
|
||||
where: { reasonId },
|
||||
create: {
|
||||
orgId: machine.orgId,
|
||||
machineId: machine.id,
|
||||
reasonId,
|
||||
kind: "downtime",
|
||||
episodeId: incidentKey,
|
||||
durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null,
|
||||
episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null,
|
||||
...commonWrite,
|
||||
},
|
||||
update: {
|
||||
kind: "downtime",
|
||||
episodeId: incidentKey,
|
||||
durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null,
|
||||
episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null,
|
||||
...commonWrite,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const scrapEntryId =
|
||||
clampText(evReason.scrapEntryId, 128) ??
|
||||
clampText(evRecord.id, 128) ??
|
||||
clampText(evRecord.eventId, 128) ??
|
||||
row.id;
|
||||
const scrapQtyRaw =
|
||||
numberFrom(evRecord.scrapDelta) ??
|
||||
numberFrom(evData.scrapDelta) ??
|
||||
numberFrom(evData.scrap_delta) ??
|
||||
0;
|
||||
const scrapQty = Math.max(0, Math.trunc(scrapQtyRaw));
|
||||
|
||||
await prisma.reasonEntry.upsert({
|
||||
where: { reasonId },
|
||||
create: {
|
||||
orgId: machine.orgId,
|
||||
machineId: machine.id,
|
||||
reasonId,
|
||||
kind: "scrap",
|
||||
scrapEntryId,
|
||||
scrapQty,
|
||||
scrapUnit: clampText(evReason.scrapUnit, 16) ?? null,
|
||||
...commonWrite,
|
||||
},
|
||||
update: {
|
||||
kind: "scrap",
|
||||
scrapEntryId,
|
||||
scrapQty,
|
||||
scrapUnit: clampText(evReason.scrapUnit, 16) ?? null,
|
||||
...commonWrite,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (row.eventType !== "downtime-acknowledged" && row.eventType !== "scrap-manual-entry") {
|
||||
await evaluateAlertsForEvent(row.id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[alerts] evaluation failed", err);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
|
||||
import { getMachineAuth } from "@/lib/machineAuthCache";
|
||||
import { normalizeSnapshotV1 } from "@/lib/contracts/v1";
|
||||
import { toJsonValue } from "@/lib/prismaJson";
|
||||
import { logLine } from "@/lib/logger";
|
||||
|
||||
function getClientIp(req: Request) {
|
||||
const xf = req.headers.get("x-forwarded-for");
|
||||
@@ -21,11 +22,68 @@ function parseSeqToBigInt(seq: unknown): bigint | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function readPath(root: unknown, path: string[]): unknown {
|
||||
let current = root;
|
||||
for (const key of path) {
|
||||
const record = asRecord(current);
|
||||
if (!record) return undefined;
|
||||
current = record[key];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function collectQualityTrace(params: {
|
||||
rawBody: unknown;
|
||||
normalizedKpis: Record<string, unknown> | null;
|
||||
persistedQuality: number | null;
|
||||
machineId: string;
|
||||
rowId: string;
|
||||
}) {
|
||||
const { rawBody, normalizedKpis, persistedQuality, machineId, rowId } = params;
|
||||
const candidates = [
|
||||
"kpis.quality",
|
||||
"payload.kpis.quality",
|
||||
"kpi_snapshot.quality",
|
||||
"quality",
|
||||
"payload.quality",
|
||||
] as const;
|
||||
|
||||
const rawQualityCandidates: Record<string, { type: string; value: unknown }> = {};
|
||||
for (const path of candidates) {
|
||||
const value = readPath(rawBody, path.split("."));
|
||||
rawQualityCandidates[path] = {
|
||||
type: value === null ? "null" : typeof value,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedQuality = normalizedKpis?.quality;
|
||||
return {
|
||||
machineId,
|
||||
rowId,
|
||||
rawQualityCandidates,
|
||||
normalizedQuality: {
|
||||
type: normalizedQuality === null ? "null" : typeof normalizedQuality,
|
||||
value: normalizedQuality ?? null,
|
||||
},
|
||||
persistedQuality: {
|
||||
type: persistedQuality === null ? "null" : typeof persistedQuality,
|
||||
value: persistedQuality,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const endpoint = "/api/ingest/kpi";
|
||||
const startedAt = Date.now();
|
||||
const ip = getClientIp(req);
|
||||
const userAgent = req.headers.get("user-agent");
|
||||
const traceEnabled = process.env.TRACE_KPI_INGEST === "1" || req.headers.get("x-debug-ingest") === "1";
|
||||
|
||||
let rawBody: unknown = null;
|
||||
let orgId: string | null = null;
|
||||
@@ -182,11 +240,33 @@ export async function POST(req: Request) {
|
||||
},
|
||||
});
|
||||
|
||||
const trace = collectQualityTrace({
|
||||
rawBody,
|
||||
normalizedKpis: asRecord(k),
|
||||
persistedQuality: row.quality ?? null,
|
||||
machineId: machine.id,
|
||||
rowId: row.id,
|
||||
});
|
||||
if (traceEnabled) {
|
||||
logLine("ingest.kpi.trace", {
|
||||
endpoint,
|
||||
machineId: machine.id,
|
||||
orgId,
|
||||
schemaVersion,
|
||||
seq: seq != null ? seq.toString() : null,
|
||||
ip,
|
||||
userAgent,
|
||||
trace,
|
||||
rawBody: toJsonValue(rawBody),
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
id: row.id,
|
||||
tsDevice: row.ts,
|
||||
tsServer: row.tsServer,
|
||||
trace: traceEnabled ? trace : undefined,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { normalizeEvent } from "@/lib/events/normalizeEvent";
|
||||
import { invalidateMachineAuth } from "@/lib/machineAuthCache";
|
||||
|
||||
const machineIdSchema = z.string().uuid();
|
||||
|
||||
@@ -29,10 +31,63 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
function parseNumber(value: string | null, fallback: number) {
|
||||
if (value == null || value === "") return fallback;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
type MachineFkReference = {
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
deleteRule: string;
|
||||
};
|
||||
|
||||
function quoteIdent(identifier: string) {
|
||||
return `"${identifier.replace(/"/g, "\"\"")}"`;
|
||||
}
|
||||
|
||||
async function cleanupMachineReferences(machineId: string) {
|
||||
const refs = await prisma.$queryRaw<MachineFkReference[]>`
|
||||
SELECT DISTINCT
|
||||
tc.table_name AS "tableName",
|
||||
kcu.column_name AS "columnName",
|
||||
rc.delete_rule AS "deleteRule"
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_schema = kcu.table_schema
|
||||
JOIN information_schema.referential_constraints rc
|
||||
ON tc.constraint_name = rc.constraint_name
|
||||
AND tc.table_schema = rc.constraint_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||
AND tc.table_schema = 'public'
|
||||
AND rc.unique_constraint_schema = 'public'
|
||||
AND rc.unique_constraint_name IN (
|
||||
SELECT constraint_name
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'Machine'
|
||||
AND constraint_type IN ('PRIMARY KEY', 'UNIQUE')
|
||||
)
|
||||
`;
|
||||
|
||||
for (const ref of refs) {
|
||||
if (ref.tableName === "Machine") continue;
|
||||
const table = quoteIdent(ref.tableName);
|
||||
const column = quoteIdent(ref.columnName);
|
||||
const rule = String(ref.deleteRule ?? "").toUpperCase();
|
||||
|
||||
if (rule === "CASCADE") continue;
|
||||
|
||||
if (rule === "SET NULL") {
|
||||
await prisma.$executeRawUnsafe(`UPDATE ${table} SET ${column} = NULL WHERE ${column} = $1`, machineId);
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.$executeRawUnsafe(`DELETE FROM ${table} WHERE ${column} = $1`, machineId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ machineId: string }> }) {
|
||||
const session = await requireSession();
|
||||
if (!session) {
|
||||
@@ -158,25 +213,15 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ mach
|
||||
|
||||
const eventWindowStart = new Date(Date.now() - eventsWindowSec * 1000);
|
||||
const criticalSeverities = ["critical", "error", "high"];
|
||||
const eventWhere = {
|
||||
const eventWhereBase = {
|
||||
orgId: session.orgId,
|
||||
machineId,
|
||||
ts: { gte: eventWindowStart },
|
||||
eventType: { in: Array.from(ALLOWED_EVENT_TYPES) },
|
||||
...(eventsMode === "critical"
|
||||
? {
|
||||
OR: [
|
||||
{ eventType: "macrostop" },
|
||||
{ requiresAck: true },
|
||||
{ severity: { in: criticalSeverities } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [rawEvents, eventsCountAll] = await Promise.all([
|
||||
prisma.machineEvent.findMany({
|
||||
where: eventWhere,
|
||||
where: eventWhereBase,
|
||||
orderBy: { ts: "desc" },
|
||||
take: eventsOnly ? 300 : 120,
|
||||
select: {
|
||||
@@ -192,15 +237,29 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ mach
|
||||
workOrderId: true,
|
||||
},
|
||||
}),
|
||||
prisma.machineEvent.count({ where: eventWhere }),
|
||||
prisma.machineEvent.count({ where: eventWhereBase }),
|
||||
]);
|
||||
|
||||
const normalized = rawEvents.map((row) =>
|
||||
normalizeEvent(row, { microMultiplier: stoppageMultiplier, macroMultiplier: macroStoppageMultiplier })
|
||||
);
|
||||
|
||||
const allowed = normalized.filter((event) => ALLOWED_EVENT_TYPES.has(event.eventType));
|
||||
const criticalEventTypes = new Set(["macrostop", "microstop", "slow-cycle", "offline", "error"]);
|
||||
const filtered =
|
||||
eventsMode === "critical"
|
||||
? allowed.filter((event) => {
|
||||
const severity = String(event.severity ?? "").toLowerCase();
|
||||
return (
|
||||
criticalEventTypes.has(event.eventType) ||
|
||||
event.requiresAck === true ||
|
||||
criticalSeverities.includes(severity)
|
||||
);
|
||||
})
|
||||
: allowed;
|
||||
|
||||
const seen = new Set<string>();
|
||||
const deduped = normalized.filter((event) => {
|
||||
const deduped = filtered.filter((event) => {
|
||||
const key = `${event.eventType}-${event.ts ?? ""}-${event.title}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
@@ -249,25 +308,185 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ mach
|
||||
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
await tx.machineCycle.deleteMany({
|
||||
where: {
|
||||
machineId,
|
||||
orgId: session.orgId,
|
||||
},
|
||||
});
|
||||
|
||||
return tx.machine.deleteMany({
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
if (attempt === 0) {
|
||||
// Revoke credentials first in a committed write so ingest auth fails immediately.
|
||||
const revoked = await prisma.machine.updateMany({
|
||||
where: {
|
||||
id: machineId,
|
||||
orgId: session.orgId,
|
||||
},
|
||||
data: {
|
||||
apiKey: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (revoked.count === 0) {
|
||||
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
invalidateMachineAuth(machineId);
|
||||
}
|
||||
|
||||
// Avoid long interactive transactions on very large history tables (P2028 timeout).
|
||||
// This sequence is idempotent and safe to retry because apiKey is revoked first.
|
||||
await prisma.machineCycle.deleteMany({
|
||||
where: {
|
||||
machineId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.machineHeartbeat.deleteMany({
|
||||
where: {
|
||||
machineId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.machineKpiSnapshot.deleteMany({
|
||||
where: {
|
||||
machineId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.machineEvent.deleteMany({
|
||||
where: {
|
||||
machineId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.machineWorkOrder.deleteMany({
|
||||
where: {
|
||||
machineId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.machineSettings.deleteMany({
|
||||
where: {
|
||||
machineId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.settingsAudit.deleteMany({
|
||||
where: {
|
||||
machineId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.alertNotification.deleteMany({
|
||||
where: {
|
||||
machineId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.machineFinancialOverride.deleteMany({
|
||||
where: {
|
||||
machineId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.reasonEntry.deleteMany({
|
||||
where: {
|
||||
machineId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.downtimeAction.updateMany({
|
||||
where: {
|
||||
machineId,
|
||||
},
|
||||
data: {
|
||||
machineId: null,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await prisma.machine.deleteMany({
|
||||
where: {
|
||||
id: machineId,
|
||||
orgId: session.orgId,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.count === 0) {
|
||||
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
invalidateMachineAuth(machineId);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err: unknown) {
|
||||
const code = err instanceof Prisma.PrismaClientKnownRequestError ? err.code : undefined;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("DELETE /api/machines/[machineId] failed", {
|
||||
machineId,
|
||||
orgId: session.orgId,
|
||||
attempt,
|
||||
code,
|
||||
message,
|
||||
});
|
||||
|
||||
if (code === "P2003") {
|
||||
if (attempt < 2) {
|
||||
try {
|
||||
await cleanupMachineReferences(machineId);
|
||||
} catch (cleanupErr: unknown) {
|
||||
const cleanupMessage = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr);
|
||||
console.error("DELETE /api/machines/[machineId] cleanup failed", {
|
||||
machineId,
|
||||
orgId: session.orgId,
|
||||
attempt,
|
||||
cleanupMessage,
|
||||
});
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, (attempt + 1) * 150));
|
||||
continue;
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "Machine has dependent records and could not be removed",
|
||||
code,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
if (code === "P2022") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "Server schema is out of date for machine delete",
|
||||
code,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
if (code === "P2028") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "Delete timed out while removing machine history",
|
||||
code,
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
if (code) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "Delete failed due to database error",
|
||||
code,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: false, error: "Delete failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: false, error: "Delete failed", code: "DELETE_RETRY_EXHAUSTED" }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomBytes } from "crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { cookies } from "next/headers";
|
||||
import { generatePairingCode } from "@/lib/pairingCode";
|
||||
import { z } from "zod";
|
||||
import { logLine } from "@/lib/logger";
|
||||
import { elapsedMs, formatServerTiming, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import {
|
||||
fetchLatestHeartbeats,
|
||||
fetchLatestKpis,
|
||||
fetchMachineBase,
|
||||
mergeMachineOverviewRows,
|
||||
} from "@/lib/machines/withLatest";
|
||||
|
||||
const COOKIE_NAME = "mis_session";
|
||||
let machinesColdStart = true;
|
||||
|
||||
function getColdStartInfo() {
|
||||
const coldStart = machinesColdStart;
|
||||
machinesColdStart = false;
|
||||
return { coldStart, uptimeMs: Math.round(process.uptime() * 1000) };
|
||||
}
|
||||
|
||||
const createMachineSchema = z.object({
|
||||
name: z.string().trim().min(1).max(80),
|
||||
@@ -13,72 +27,66 @@ const createMachineSchema = z.object({
|
||||
location: z.string().trim().max(80).optional(),
|
||||
});
|
||||
|
||||
async function requireSession() {
|
||||
const sessionId = (await cookies()).get(COOKIE_NAME)?.value;
|
||||
if (!sessionId) return null;
|
||||
export async function GET(req: Request) {
|
||||
const perfEnabled = PERF_LOGS_ENABLED;
|
||||
const totalStart = nowMs();
|
||||
const timings: Record<string, number> = {};
|
||||
const { coldStart, uptimeMs } = getColdStartInfo();
|
||||
const url = new URL(req.url);
|
||||
const includeKpi = url.searchParams.get("includeKpi") === "1";
|
||||
|
||||
const session = await prisma.session.findFirst({
|
||||
where: { id: sessionId, revokedAt: null, expiresAt: { gt: new Date() } },
|
||||
include: { org: true, user: true },
|
||||
});
|
||||
|
||||
if (!session || !session.user?.isActive || !session.user?.emailVerifiedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const authStart = nowMs();
|
||||
const session = await requireSession();
|
||||
if (perfEnabled) timings.auth = elapsedMs(authStart);
|
||||
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const machines = await prisma.machine.findMany({
|
||||
where: { orgId: session.orgId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
code: true,
|
||||
location: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
heartbeats: {
|
||||
orderBy: { tsServer: "desc" },
|
||||
take: 1,
|
||||
select: { ts: true, tsServer: true, status: true, message: true, ip: true, fwVersion: true },
|
||||
},
|
||||
kpiSnapshots: {
|
||||
orderBy: { ts: "desc" },
|
||||
take: 1,
|
||||
select: {
|
||||
ts: true,
|
||||
oee: true,
|
||||
availability: true,
|
||||
performance: true,
|
||||
quality: true,
|
||||
workOrderId: true,
|
||||
sku: true,
|
||||
good: true,
|
||||
scrap: true,
|
||||
target: true,
|
||||
cycleTime: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const preQueryStart = nowMs();
|
||||
const machinesStart = nowMs();
|
||||
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
|
||||
const machines = await fetchMachineBase(session.orgId);
|
||||
if (perfEnabled) timings.machinesQuery = elapsedMs(machinesStart);
|
||||
|
||||
const heartbeatStart = nowMs();
|
||||
const machineIds = machines.map((machine) => machine.id);
|
||||
const heartbeats = await fetchLatestHeartbeats(session.orgId, machineIds);
|
||||
if (perfEnabled) timings.heartbeatsQuery = elapsedMs(heartbeatStart);
|
||||
|
||||
let kpis: Awaited<ReturnType<typeof fetchLatestKpis>> = [];
|
||||
if (includeKpi) {
|
||||
const kpiStart = nowMs();
|
||||
kpis = await fetchLatestKpis(session.orgId, machineIds);
|
||||
if (perfEnabled) timings.kpiQuery = elapsedMs(kpiStart);
|
||||
}
|
||||
|
||||
const postQueryStart = nowMs();
|
||||
|
||||
// flatten latest heartbeat for UI convenience
|
||||
const out = machines.map((m) => ({
|
||||
...m,
|
||||
latestHeartbeat: m.heartbeats[0] ?? null,
|
||||
latestKpi: m.kpiSnapshots[0] ?? null,
|
||||
heartbeats: undefined,
|
||||
kpiSnapshots: undefined,
|
||||
}));
|
||||
const out = mergeMachineOverviewRows({
|
||||
machines,
|
||||
heartbeats,
|
||||
kpis,
|
||||
includeKpi,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, machines: out });
|
||||
const payload = { ok: true, machines: out };
|
||||
|
||||
const responseHeaders = new Headers();
|
||||
if (perfEnabled) {
|
||||
timings.postQuery = elapsedMs(postQueryStart);
|
||||
timings.total = elapsedMs(totalStart);
|
||||
responseHeaders.set("Server-Timing", formatServerTiming(timings));
|
||||
const payloadBytes = Buffer.byteLength(JSON.stringify(payload));
|
||||
logLine("perf.machines.api", {
|
||||
orgId: session.orgId,
|
||||
coldStart,
|
||||
uptimeMs,
|
||||
timings,
|
||||
counts: { machines: out.length },
|
||||
payloadBytes,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(payload, { headers: responseHeaders });
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
|
||||
@@ -4,23 +4,72 @@ import { createHash } from "crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { getOverviewData } from "@/lib/overview/getOverviewData";
|
||||
import { getOverviewSummary } from "@/lib/overview/getOverviewSummary";
|
||||
import { logLine } from "@/lib/logger";
|
||||
import { elapsedMs, formatServerTiming, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming";
|
||||
|
||||
let overviewColdStart = true;
|
||||
|
||||
function getColdStartInfo() {
|
||||
const coldStart = overviewColdStart;
|
||||
overviewColdStart = false;
|
||||
return { coldStart, uptimeMs: Math.round(process.uptime() * 1000) };
|
||||
}
|
||||
|
||||
function toMs(value?: Date | null) {
|
||||
return value ? value.getTime() : 0;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const perfEnabled = PERF_LOGS_ENABLED;
|
||||
const totalStart = nowMs();
|
||||
const timings: Record<string, number> = {};
|
||||
const { coldStart, uptimeMs } = getColdStartInfo();
|
||||
|
||||
const authStart = nowMs();
|
||||
const session = await requireSession();
|
||||
if (perfEnabled) timings.auth = elapsedMs(authStart);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const detail = url.searchParams.get("detail") === "1";
|
||||
|
||||
if (!detail) {
|
||||
const summaryStart = nowMs();
|
||||
const { machines: machineRows } = await getOverviewSummary({ orgId: session.orgId });
|
||||
if (perfEnabled) timings.summary = elapsedMs(summaryStart);
|
||||
|
||||
const payload = { ok: true, machines: machineRows, events: [] };
|
||||
const responseHeaders = new Headers();
|
||||
if (perfEnabled) {
|
||||
timings.total = elapsedMs(totalStart);
|
||||
responseHeaders.set("Server-Timing", formatServerTiming(timings));
|
||||
const payloadBytes = Buffer.byteLength(JSON.stringify(payload));
|
||||
logLine("perf.overview.api", {
|
||||
orgId: session.orgId,
|
||||
detail: false,
|
||||
coldStart,
|
||||
uptimeMs,
|
||||
timings,
|
||||
counts: { machines: machineRows.length, events: 0 },
|
||||
payloadBytes,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(payload, { headers: responseHeaders });
|
||||
}
|
||||
|
||||
const preQueryStart = nowMs();
|
||||
const eventsMode = url.searchParams.get("events") ?? "critical";
|
||||
const eventsWindowSecRaw = Number(url.searchParams.get("eventsWindowSec") ?? "21600");
|
||||
const eventsWindowSec = Number.isFinite(eventsWindowSecRaw) ? eventsWindowSecRaw : 21600;
|
||||
const eventMachinesRaw = Number(url.searchParams.get("eventMachines") ?? "6");
|
||||
const eventMachines = Number.isFinite(eventMachinesRaw) ? Math.max(1, eventMachinesRaw) : 6;
|
||||
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
|
||||
|
||||
const aggStart = nowMs();
|
||||
const [machineAgg, heartbeatAgg, kpiAgg, eventAgg, orgSettings] = await Promise.all([
|
||||
prisma.machine.aggregate({
|
||||
where: { orgId: session.orgId },
|
||||
@@ -43,6 +92,7 @@ export async function GET(req: NextRequest) {
|
||||
select: { updatedAt: true, stoppageMultiplier: true, macroStoppageMultiplier: true },
|
||||
}),
|
||||
]);
|
||||
if (perfEnabled) timings.agg = elapsedMs(aggStart);
|
||||
|
||||
const lastModifiedMs = Math.max(
|
||||
toMs(machineAgg._max.updatedAt),
|
||||
@@ -86,6 +136,7 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
const dataStart = nowMs();
|
||||
const { machines: machineRows, events } = await getOverviewData({
|
||||
orgId: session.orgId,
|
||||
eventsMode,
|
||||
@@ -93,9 +144,29 @@ export async function GET(req: NextRequest) {
|
||||
eventMachines,
|
||||
orgSettings,
|
||||
});
|
||||
if (perfEnabled) timings.data = elapsedMs(dataStart);
|
||||
|
||||
return NextResponse.json(
|
||||
{ ok: true, machines: machineRows, events },
|
||||
{ headers: responseHeaders }
|
||||
);
|
||||
const postQueryStart = nowMs();
|
||||
|
||||
const payload = { ok: true, machines: machineRows, events };
|
||||
if (perfEnabled) {
|
||||
timings.postQuery = elapsedMs(postQueryStart);
|
||||
timings.total = elapsedMs(totalStart);
|
||||
responseHeaders.set("Server-Timing", formatServerTiming(timings));
|
||||
const payloadBytes = Buffer.byteLength(JSON.stringify(payload));
|
||||
logLine("perf.overview.api", {
|
||||
orgId: session.orgId,
|
||||
detail: true,
|
||||
coldStart,
|
||||
uptimeMs,
|
||||
eventsMode,
|
||||
eventsWindowSec,
|
||||
eventMachines,
|
||||
timings,
|
||||
counts: { machines: machineRows.length, events: events.length },
|
||||
payloadBytes,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(payload, { headers: responseHeaders });
|
||||
}
|
||||
|
||||
48
app/api/reasons/catalog/route.ts
Normal file
48
app/api/reasons/catalog/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,16 @@ import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { logLine } from "@/lib/logger";
|
||||
import { elapsedMs, formatServerTiming, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming";
|
||||
|
||||
let reportsFiltersColdStart = true;
|
||||
|
||||
function getColdStartInfo() {
|
||||
const coldStart = reportsFiltersColdStart;
|
||||
reportsFiltersColdStart = false;
|
||||
return { coldStart, uptimeMs: Math.round(process.uptime() * 1000) };
|
||||
}
|
||||
|
||||
const RANGE_MS: Record<string, number> = {
|
||||
"24h": 24 * 60 * 60 * 1000,
|
||||
@@ -33,10 +43,19 @@ function pickRange(req: NextRequest) {
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const perfEnabled = PERF_LOGS_ENABLED;
|
||||
const totalStart = nowMs();
|
||||
const timings: Record<string, number> = {};
|
||||
const { coldStart, uptimeMs } = getColdStartInfo();
|
||||
|
||||
const authStart = nowMs();
|
||||
const session = await requireSession();
|
||||
if (perfEnabled) timings.auth = elapsedMs(authStart);
|
||||
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const preQueryStart = nowMs();
|
||||
const url = new URL(req.url);
|
||||
const range = url.searchParams.get("range") ?? "24h";
|
||||
const machineId = url.searchParams.get("machineId") ?? undefined;
|
||||
const { start, end } = pickRange(req);
|
||||
|
||||
@@ -46,20 +65,51 @@ export async function GET(req: NextRequest) {
|
||||
ts: { gte: start, lte: end },
|
||||
};
|
||||
|
||||
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
|
||||
|
||||
const workOrdersStart = nowMs();
|
||||
const workOrderRows = await prisma.machineCycle.findMany({
|
||||
where: { ...baseWhere, workOrderId: { not: null } },
|
||||
distinct: ["workOrderId"],
|
||||
select: { workOrderId: true },
|
||||
});
|
||||
if (perfEnabled) timings.workOrders = elapsedMs(workOrdersStart);
|
||||
|
||||
const skuStart = nowMs();
|
||||
const skuRows = await prisma.machineCycle.findMany({
|
||||
where: { ...baseWhere, sku: { not: null } },
|
||||
distinct: ["sku"],
|
||||
select: { sku: true },
|
||||
});
|
||||
if (perfEnabled) timings.skus = elapsedMs(skuStart);
|
||||
|
||||
const postQueryStart = nowMs();
|
||||
|
||||
const workOrders = workOrderRows.map((r) => r.workOrderId).filter(Boolean) as string[];
|
||||
const skus = skuRows.map((r) => r.sku).filter(Boolean) as string[];
|
||||
|
||||
return NextResponse.json({ ok: true, workOrders, skus });
|
||||
const payload = { ok: true, workOrders, skus };
|
||||
|
||||
const responseHeaders = new Headers();
|
||||
if (perfEnabled) {
|
||||
timings.postQuery = elapsedMs(postQueryStart);
|
||||
timings.total = elapsedMs(totalStart);
|
||||
responseHeaders.set("Server-Timing", formatServerTiming(timings));
|
||||
const payloadBytes = Buffer.byteLength(JSON.stringify(payload));
|
||||
logLine("perf.reports.filters", {
|
||||
orgId: session.orgId,
|
||||
coldStart,
|
||||
uptimeMs,
|
||||
range,
|
||||
machineId,
|
||||
timings,
|
||||
rowCounts: {
|
||||
workOrderRows: workOrderRows.length,
|
||||
skuRows: skuRows.length,
|
||||
},
|
||||
payloadBytes,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(payload, { headers: responseHeaders });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,16 @@ import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { logLine } from "@/lib/logger";
|
||||
import { elapsedMs, formatServerTiming, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming";
|
||||
|
||||
let reportsColdStart = true;
|
||||
|
||||
function getColdStartInfo() {
|
||||
const coldStart = reportsColdStart;
|
||||
reportsColdStart = false;
|
||||
return { coldStart, uptimeMs: Math.round(process.uptime() * 1000) };
|
||||
}
|
||||
|
||||
const RANGE_MS: Record<string, number> = {
|
||||
"24h": 24 * 60 * 60 * 1000,
|
||||
@@ -37,10 +47,19 @@ function safeNum(v: unknown) {
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const perfEnabled = PERF_LOGS_ENABLED;
|
||||
const totalStart = nowMs();
|
||||
const timings: Record<string, number> = {};
|
||||
const { coldStart, uptimeMs } = getColdStartInfo();
|
||||
|
||||
const authStart = nowMs();
|
||||
const session = await requireSession();
|
||||
if (perfEnabled) timings.auth = elapsedMs(authStart);
|
||||
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const preQueryStart = nowMs();
|
||||
const url = new URL(req.url);
|
||||
const range = url.searchParams.get("range") ?? "24h";
|
||||
const machineId = url.searchParams.get("machineId") ?? undefined;
|
||||
const { start, end } = pickRange(req);
|
||||
const workOrderId = url.searchParams.get("workOrderId") ?? undefined;
|
||||
@@ -52,6 +71,9 @@ export async function GET(req: NextRequest) {
|
||||
...(sku ? { sku } : {}),
|
||||
};
|
||||
|
||||
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
|
||||
|
||||
const kpiStart = nowMs();
|
||||
const kpiRows = await prisma.machineKpiSnapshot.findMany({
|
||||
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
||||
orderBy: { ts: "asc" },
|
||||
@@ -67,6 +89,7 @@ export async function GET(req: NextRequest) {
|
||||
machineId: true,
|
||||
},
|
||||
});
|
||||
if (perfEnabled) timings.kpiRows = elapsedMs(kpiStart);
|
||||
|
||||
let oeeSum = 0;
|
||||
let oeeCount = 0;
|
||||
@@ -96,10 +119,12 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
const cyclesStart = nowMs();
|
||||
const cycles = await prisma.machineCycle.findMany({
|
||||
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
||||
select: { goodDelta: true, scrapDelta: true },
|
||||
});
|
||||
if (perfEnabled) timings.cycles = elapsedMs(cyclesStart);
|
||||
|
||||
let goodTotal = 0;
|
||||
let scrapTotal = 0;
|
||||
@@ -109,6 +134,7 @@ export async function GET(req: NextRequest) {
|
||||
if (safeNum(c.scrapDelta) != null) scrapTotal += Number(c.scrapDelta);
|
||||
}
|
||||
|
||||
const kpiAggStart = nowMs();
|
||||
const kpiAgg = await prisma.machineKpiSnapshot.groupBy({
|
||||
by: ["machineId"],
|
||||
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
||||
@@ -116,6 +142,7 @@ export async function GET(req: NextRequest) {
|
||||
_min: { good: true, scrap: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
if (perfEnabled) timings.kpiAgg = elapsedMs(kpiAggStart);
|
||||
|
||||
let targetTotal = 0;
|
||||
if (goodTotal === 0 && scrapTotal === 0) {
|
||||
@@ -151,10 +178,12 @@ export async function GET(req: NextRequest) {
|
||||
if (maxTarget != null) targetTotal += maxTarget;
|
||||
}
|
||||
|
||||
const eventsStart = nowMs();
|
||||
const events = await prisma.machineEvent.findMany({
|
||||
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
||||
select: { eventType: true, data: true },
|
||||
});
|
||||
if (perfEnabled) timings.events = elapsedMs(eventsStart);
|
||||
|
||||
let macrostopSec = 0;
|
||||
let microstopSec = 0;
|
||||
@@ -223,10 +252,12 @@ export async function GET(req: NextRequest) {
|
||||
trend.scrapRate.push({ t, v: (scrap / (good + scrap)) * 100 });
|
||||
}
|
||||
}
|
||||
const cycleRowsStart = nowMs();
|
||||
const cycleRows = await prisma.machineCycle.findMany({
|
||||
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
||||
select: { actualCycleTime: true },
|
||||
});
|
||||
if (perfEnabled) timings.cycleRows = elapsedMs(cycleRowsStart);
|
||||
|
||||
const values = cycleRows
|
||||
.map((c) => Number(c.actualCycleTime))
|
||||
@@ -310,10 +341,14 @@ export async function GET(req: NextRequest) {
|
||||
const scrapBySku = new Map<string, number>();
|
||||
const scrapByWo = new Map<string, number>();
|
||||
|
||||
const scrapRowsStart = nowMs();
|
||||
const scrapRows = await prisma.machineCycle.findMany({
|
||||
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
||||
select: { sku: true, workOrderId: true, scrapDelta: true },
|
||||
});
|
||||
if (perfEnabled) timings.scrapRows = elapsedMs(scrapRowsStart);
|
||||
|
||||
const postQueryStart = nowMs();
|
||||
|
||||
for (const row of scrapRows) {
|
||||
const scrap = safeNum(row.scrapDelta);
|
||||
@@ -340,7 +375,7 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
|
||||
|
||||
return NextResponse.json({
|
||||
const payload = {
|
||||
ok: true,
|
||||
summary: {
|
||||
oeeAvg,
|
||||
@@ -366,8 +401,35 @@ export async function GET(req: NextRequest) {
|
||||
trend,
|
||||
insights,
|
||||
distribution: {
|
||||
cycleTime: cycleTimeBins
|
||||
cycleTime: cycleTimeBins,
|
||||
},
|
||||
};
|
||||
|
||||
const responseHeaders = new Headers();
|
||||
if (perfEnabled) {
|
||||
timings.postQuery = elapsedMs(postQueryStart);
|
||||
timings.total = elapsedMs(totalStart);
|
||||
responseHeaders.set("Server-Timing", formatServerTiming(timings));
|
||||
const payloadBytes = Buffer.byteLength(JSON.stringify(payload));
|
||||
logLine("perf.reports.api", {
|
||||
orgId: session.orgId,
|
||||
coldStart,
|
||||
uptimeMs,
|
||||
range,
|
||||
machineId,
|
||||
workOrderId,
|
||||
sku,
|
||||
timings,
|
||||
rowCounts: {
|
||||
kpiRows: kpiRows.length,
|
||||
cycles: cycles.length,
|
||||
events: events.length,
|
||||
cycleRows: cycleRows.length,
|
||||
scrapRows: scrapRows.length,
|
||||
},
|
||||
payloadBytes,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(payload, { headers: responseHeaders });
|
||||
}
|
||||
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
validateDefaults,
|
||||
validateShiftFields,
|
||||
validateShiftSchedule,
|
||||
validateShiftOverrides,
|
||||
validateThresholds,
|
||||
} from "@/lib/settings";
|
||||
import { loadFallbackReasonCatalog, normalizeReasonCatalog, type ReasonCatalog } from "@/lib/reasonCatalog";
|
||||
import { publishSettingsUpdate } from "@/lib/mqtt";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -44,6 +46,24 @@ function pickAllowedOverrides(raw: unknown) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function withReasonCatalog<T extends Record<string, unknown>>(payload: T, fallbackCatalog: ReasonCatalog) {
|
||||
const base = (isPlainObject(payload) ? { ...payload } : {}) as T;
|
||||
const defaults = isPlainObject(base.defaults) ? base.defaults : {};
|
||||
const parsed =
|
||||
normalizeReasonCatalog(base.reasonCatalog) ??
|
||||
normalizeReasonCatalog(base.reasonCatalogData) ??
|
||||
normalizeReasonCatalog(defaults.reasonCatalog) ??
|
||||
normalizeReasonCatalog(defaults.reasonCatalogData) ??
|
||||
fallbackCatalog;
|
||||
|
||||
return {
|
||||
...base,
|
||||
reasonCatalog: parsed,
|
||||
reasonCatalogData: parsed,
|
||||
reasonCatalogVersion: Number(parsed.version || 1),
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureOrgSettings(
|
||||
tx: Prisma.TransactionClient,
|
||||
orgId: string,
|
||||
@@ -144,6 +164,7 @@ export async function GET(
|
||||
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
orgId = machine.orgId;
|
||||
}
|
||||
const fallbackCatalog = await loadFallbackReasonCatalog();
|
||||
|
||||
const { settings, overrides } = await prisma.$transaction(async (tx) => {
|
||||
const orgSettings = await ensureOrgSettings(tx, orgId as string, userId);
|
||||
@@ -154,9 +175,15 @@ export async function GET(
|
||||
select: { overridesJson: true },
|
||||
});
|
||||
|
||||
const orgPayload = buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []);
|
||||
const orgPayload = withReasonCatalog(
|
||||
buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []),
|
||||
fallbackCatalog
|
||||
);
|
||||
const rawOverrides = pickAllowedOverrides(machineSettings?.overridesJson ?? {});
|
||||
const effective = deepMerge(orgPayload, rawOverrides);
|
||||
const effective = withReasonCatalog(
|
||||
deepMerge(orgPayload, rawOverrides) as Record<string, unknown>,
|
||||
fallbackCatalog
|
||||
);
|
||||
|
||||
return { settings: { org: orgPayload, effective }, overrides: rawOverrides };
|
||||
});
|
||||
@@ -242,6 +269,14 @@ export async function PUT(
|
||||
return NextResponse.json({ ok: false, error: shiftValidation.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const overridesResult =
|
||||
patch?.shiftSchedule?.overrides !== undefined
|
||||
? validateShiftOverrides(patch.shiftSchedule.overrides)
|
||||
: ({ ok: true, overrides: undefined } as const);
|
||||
if (!overridesResult.ok) {
|
||||
return NextResponse.json({ ok: false, error: overridesResult.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const thresholdsValidation = validateThresholds(patch?.thresholds);
|
||||
if (!thresholdsValidation.ok) {
|
||||
return NextResponse.json({ ok: false, error: thresholdsValidation.error }, { status: 400 });
|
||||
@@ -275,6 +310,12 @@ export async function PUT(
|
||||
...patch,
|
||||
shiftSchedule: {
|
||||
...patch.shiftSchedule,
|
||||
overrides:
|
||||
patch.shiftSchedule.overrides !== undefined
|
||||
? overridesResult.overrides === null
|
||||
? null
|
||||
: overridesResult.overrides
|
||||
: patch.shiftSchedule.overrides,
|
||||
shiftChangeCompensationMin:
|
||||
patch.shiftSchedule.shiftChangeCompensationMin !== undefined
|
||||
? Number(patch.shiftSchedule.shiftChangeCompensationMin)
|
||||
@@ -372,9 +413,16 @@ export async function PUT(
|
||||
},
|
||||
});
|
||||
|
||||
const orgPayload = buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []);
|
||||
const fallbackCatalog = await loadFallbackReasonCatalog();
|
||||
const orgPayload = withReasonCatalog(
|
||||
buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []),
|
||||
fallbackCatalog
|
||||
);
|
||||
const overrides = pickAllowedOverrides(saved.overridesJson ?? {});
|
||||
const effective = deepMerge(orgPayload, overrides);
|
||||
const effective = withReasonCatalog(
|
||||
deepMerge(orgPayload, overrides) as Record<string, unknown>,
|
||||
fallbackCatalog
|
||||
);
|
||||
|
||||
return {
|
||||
orgPayload,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { createHash } from "crypto";
|
||||
import { revalidateTag, unstable_cache } from "next/cache";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
@@ -13,8 +16,10 @@ import {
|
||||
validateDefaults,
|
||||
validateShiftFields,
|
||||
validateShiftSchedule,
|
||||
validateShiftOverrides,
|
||||
validateThresholds,
|
||||
} from "@/lib/settings";
|
||||
import { loadFallbackReasonCatalog, normalizeReasonCatalog, type ReasonCatalog } from "@/lib/reasonCatalog";
|
||||
import { publishSettingsUpdate } from "@/lib/mqtt";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -34,6 +39,24 @@ function canManageSettings(role?: string | null) {
|
||||
return role === "OWNER" || role === "ADMIN";
|
||||
}
|
||||
|
||||
function withReasonCatalog<T extends Record<string, unknown>>(payload: T, fallbackCatalog: ReasonCatalog) {
|
||||
const base = (isPlainObject(payload) ? { ...payload } : {}) as T;
|
||||
const defaults = isPlainObject(base.defaults) ? base.defaults : {};
|
||||
const parsed =
|
||||
normalizeReasonCatalog(base.reasonCatalog) ??
|
||||
normalizeReasonCatalog(base.reasonCatalogData) ??
|
||||
normalizeReasonCatalog(defaults.reasonCatalog) ??
|
||||
normalizeReasonCatalog(defaults.reasonCatalogData) ??
|
||||
fallbackCatalog;
|
||||
|
||||
return {
|
||||
...base,
|
||||
reasonCatalog: parsed,
|
||||
reasonCatalogData: parsed,
|
||||
reasonCatalogVersion: Number(parsed.version || 1),
|
||||
};
|
||||
}
|
||||
|
||||
const settingsPayloadSchema = z
|
||||
.object({
|
||||
source: z.string().trim().max(40).optional(),
|
||||
@@ -43,10 +66,14 @@ const settingsPayloadSchema = z
|
||||
thresholds: z.any().optional(),
|
||||
alerts: z.any().optional(),
|
||||
defaults: z.any().optional(),
|
||||
reasonCatalog: z.any().optional(),
|
||||
version: z.union([z.number(), z.string()]).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const SETTINGS_TTL_SEC = 10;
|
||||
const SETTINGS_SWR_SEC = 30;
|
||||
|
||||
async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, userId: string) {
|
||||
let settings = await tx.orgSettings.findUnique({
|
||||
where: { orgId },
|
||||
@@ -111,24 +138,56 @@ async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, us
|
||||
return { settings, shifts };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const session = await requireSession();
|
||||
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
try {
|
||||
async function loadSettingsPayload(orgId: string, userId: string) {
|
||||
const loaded = await prisma.$transaction(async (tx) => {
|
||||
const found = await ensureOrgSettings(tx, session.orgId, session.userId);
|
||||
const found = await ensureOrgSettings(tx, orgId, userId);
|
||||
if (!found?.settings) throw new Error("SETTINGS_NOT_FOUND");
|
||||
return found;
|
||||
});
|
||||
|
||||
const payload = buildSettingsPayload(loaded.settings, loaded.shifts ?? []);
|
||||
|
||||
const fallbackCatalog = await loadFallbackReasonCatalog();
|
||||
const payload = withReasonCatalog(buildSettingsPayload(loaded.settings, loaded.shifts ?? []), fallbackCatalog);
|
||||
const defaultsRaw = isPlainObject(loaded.settings.defaultsJson) ? (loaded.settings.defaultsJson as any) : {};
|
||||
const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {};
|
||||
const modules = { screenlessMode: modulesRaw.screenlessMode === true };
|
||||
|
||||
return NextResponse.json({ ok: true, settings: { ...payload, modules } });
|
||||
return { payload, modules };
|
||||
}
|
||||
|
||||
async function loadSettingsCached(orgId: string, userId: string) {
|
||||
const cached = unstable_cache(
|
||||
() => loadSettingsPayload(orgId, userId),
|
||||
["settings", orgId],
|
||||
{ revalidate: SETTINGS_TTL_SEC, tags: [`settings:${orgId}`] }
|
||||
);
|
||||
return cached();
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await requireSession();
|
||||
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const refresh = url.searchParams.get("refresh") === "1";
|
||||
const { payload, modules } = refresh
|
||||
? await loadSettingsPayload(session.orgId, session.userId)
|
||||
: await loadSettingsCached(session.orgId, session.userId);
|
||||
|
||||
const version = payload.version ?? 0;
|
||||
const etag = `W/"${createHash("sha1").update(`${session.orgId}:${version}`).digest("hex")}"`;
|
||||
const responseHeaders = new Headers({
|
||||
"Cache-Control": `private, max-age=${SETTINGS_TTL_SEC}, stale-while-revalidate=${SETTINGS_SWR_SEC}`,
|
||||
ETag: etag,
|
||||
Vary: "Cookie",
|
||||
});
|
||||
|
||||
const ifNoneMatch = req.headers.get("if-none-match");
|
||||
if (!refresh && ifNoneMatch && ifNoneMatch === etag) {
|
||||
return new NextResponse(null, { status: 304, headers: responseHeaders });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, settings: { ...payload, modules } }, { headers: responseHeaders });
|
||||
|
||||
} catch (err) {
|
||||
console.error("[settings GET] failed", err);
|
||||
@@ -162,6 +221,7 @@ export async function PUT(req: Request) {
|
||||
const thresholds = parsed.data.thresholds;
|
||||
const alerts = parsed.data.alerts;
|
||||
const defaults = parsed.data.defaults;
|
||||
const reasonCatalogRaw = parsed.data.reasonCatalog;
|
||||
const expectedVersion = parsed.data.version;
|
||||
const modules = parsed.data.modules;
|
||||
|
||||
@@ -173,6 +233,7 @@ export async function PUT(req: Request) {
|
||||
thresholds === undefined &&
|
||||
alerts === undefined &&
|
||||
defaults === undefined &&
|
||||
reasonCatalogRaw === undefined &&
|
||||
modules === undefined
|
||||
|
||||
) {
|
||||
@@ -191,6 +252,13 @@ export async function PUT(req: Request) {
|
||||
if (defaults !== undefined && !isPlainObject(defaults)) {
|
||||
return NextResponse.json({ ok: false, error: "defaults must be an object" }, { status: 400 });
|
||||
}
|
||||
const nextReasonCatalog =
|
||||
reasonCatalogRaw === undefined || reasonCatalogRaw === null
|
||||
? reasonCatalogRaw
|
||||
: normalizeReasonCatalog(reasonCatalogRaw);
|
||||
if (reasonCatalogRaw !== undefined && reasonCatalogRaw !== null && !nextReasonCatalog) {
|
||||
return NextResponse.json({ ok: false, error: "reasonCatalog must be a valid catalog payload" }, { status: 400 });
|
||||
}
|
||||
if (modules !== undefined && !isPlainObject(modules)) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid modules payload" }, { status: 400 });
|
||||
}
|
||||
@@ -210,6 +278,14 @@ export async function PUT(req: Request) {
|
||||
return NextResponse.json({ ok: false, error: shiftValidation.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const overridesResult =
|
||||
shiftSchedule?.overrides !== undefined
|
||||
? validateShiftOverrides(shiftSchedule.overrides)
|
||||
: ({ ok: true, overrides: undefined } as const);
|
||||
if (!overridesResult.ok) {
|
||||
return NextResponse.json({ ok: false, error: overridesResult.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const thresholdsValidation = validateThresholds(thresholds);
|
||||
if (!thresholdsValidation.ok) {
|
||||
return NextResponse.json({ ok: false, error: thresholdsValidation.error }, { status: 400 });
|
||||
@@ -257,12 +333,22 @@ export async function PUT(req: Request) {
|
||||
: { ...currentModulesRaw, screenlessMode };
|
||||
|
||||
// Write defaultsJson if either defaults changed OR modules changed
|
||||
const shouldWriteDefaultsJson = !!nextDefaultsCore || screenlessMode !== undefined;
|
||||
const shouldWriteDefaultsJson =
|
||||
!!nextDefaultsCore || screenlessMode !== undefined || reasonCatalogRaw !== undefined;
|
||||
|
||||
const nextDefaultsJson = shouldWriteDefaultsJson
|
||||
? { ...(nextDefaultsCore ?? normalizeDefaults(currentDefaultsRaw)), modules: nextModules }
|
||||
: undefined;
|
||||
|
||||
if (nextDefaultsJson && reasonCatalogRaw !== undefined) {
|
||||
const defaultsTarget = nextDefaultsJson as Record<string, unknown>;
|
||||
if (nextReasonCatalog === null) {
|
||||
delete defaultsTarget.reasonCatalog;
|
||||
} else if (nextReasonCatalog) {
|
||||
defaultsTarget.reasonCatalog = nextReasonCatalog;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const updateData = stripUndefined({
|
||||
timezone: timezone !== undefined ? String(timezone) : undefined,
|
||||
@@ -272,6 +358,12 @@ export async function PUT(req: Request) {
|
||||
: undefined,
|
||||
lunchBreakMin:
|
||||
shiftSchedule?.lunchBreakMin !== undefined ? Number(shiftSchedule.lunchBreakMin) : undefined,
|
||||
shiftScheduleOverridesJson:
|
||||
shiftSchedule?.overrides !== undefined
|
||||
? overridesResult.overrides === null
|
||||
? null
|
||||
: overridesResult.overrides
|
||||
: undefined,
|
||||
stoppageMultiplier:
|
||||
thresholds?.stoppageMultiplier !== undefined ? Number(thresholds.stoppageMultiplier) : undefined,
|
||||
macroStoppageMultiplier:
|
||||
@@ -373,6 +465,8 @@ export async function PUT(req: Request) {
|
||||
const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {};
|
||||
const modulesOut = { screenlessMode: modulesRaw.screenlessMode === true };
|
||||
|
||||
revalidateTag(`settings:${session.orgId}`, { expire: 0 });
|
||||
|
||||
return NextResponse.json({ ok: true, settings: { ...payload, modules: modulesOut } });
|
||||
|
||||
} catch (err) {
|
||||
|
||||
37
app/global-error.tsx
Normal file
37
app/global-error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -3,14 +3,14 @@ import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import InviteAcceptForm from "./InviteAcceptForm";
|
||||
|
||||
export default async function InvitePage({ params }: { params: { token: string } | Promise<{ token: string }> }) {
|
||||
export default async function InvitePage({ params }: { params: Promise<{ token: string }> }) {
|
||||
const session = (await cookies()).get("mis_session")?.value;
|
||||
if (session) {
|
||||
redirect("/machines");
|
||||
}
|
||||
|
||||
const resolvedParams = await Promise.resolve(params);
|
||||
const token = String(resolvedParams?.token || "").trim().toLowerCase();
|
||||
const { token: rawToken } = await params;
|
||||
const token = String(rawToken || "").trim().toLowerCase();
|
||||
let invite = null;
|
||||
let error: string | null = null;
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import "./globals.css";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
title: "MIS Control Tower",
|
||||
description: "MaliounTech Industrial Suite",
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const cookieJar = await cookies();
|
||||
|
||||
@@ -6,13 +6,14 @@ import LoginForm from "./LoginForm"; // adjust path if needed
|
||||
export default async function LoginPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: { next?: string };
|
||||
searchParams?: Promise<{ next?: string }>;
|
||||
}) {
|
||||
const session = (await cookies()).get("mis_session")?.value;
|
||||
|
||||
// If already logged in, send to next or machines
|
||||
if (session) {
|
||||
const next = searchParams?.next || "/machines";
|
||||
const params = searchParams ? await searchParams : {};
|
||||
const next = params?.next || "/machines";
|
||||
redirect(next);
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,24 @@ type ApiDowntimeEventsRes = {
|
||||
events?: ApiDowntimeEvent[];
|
||||
};
|
||||
|
||||
type ApiReasonCatalogRow = {
|
||||
kind: "downtime" | "scrap";
|
||||
categoryId: string;
|
||||
categoryLabel: string;
|
||||
detailId: string;
|
||||
detailLabel: string;
|
||||
reasonCode: string;
|
||||
reasonLabel: string;
|
||||
};
|
||||
|
||||
type ApiReasonCatalogRes = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
kind?: "downtime" | "scrap";
|
||||
catalogVersion?: number;
|
||||
rows?: ApiReasonCatalogRow[];
|
||||
};
|
||||
|
||||
function fmtDT(iso: string | null) {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
@@ -1155,6 +1173,8 @@ export default function DowntimePageClient() {
|
||||
const [eventsRes, setEventsRes] = useState<ApiDowntimeEventsRes | null>(null);
|
||||
const [eventsLoading, setEventsLoading] = useState(false);
|
||||
const [eventsErr, setEventsErr] = useState<string | null>(null);
|
||||
const [catalogRows, setCatalogRows] = useState<ApiReasonCatalogRow[]>([]);
|
||||
const [catalogErr, setCatalogErr] = useState<string | null>(null);
|
||||
|
||||
const [eventsLimit, setEventsLimit] = useState<number>(200);
|
||||
const [eventsBefore, setEventsBefore] = useState<string | null>(null);
|
||||
@@ -1251,6 +1271,41 @@ export default function DowntimePageClient() {
|
||||
ac.abort();
|
||||
};
|
||||
}, [range, machineId]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const ac = new AbortController();
|
||||
|
||||
async function run() {
|
||||
setCatalogErr(null);
|
||||
try {
|
||||
const res = await fetch("/api/reasons/catalog?kind=downtime", {
|
||||
cache: "no-cache",
|
||||
credentials: "include",
|
||||
signal: ac.signal,
|
||||
});
|
||||
const json = (await res.json().catch(() => ({}))) as ApiReasonCatalogRes;
|
||||
if (!alive) return;
|
||||
if (!res.ok || json.ok === false) {
|
||||
setCatalogRows([]);
|
||||
setCatalogErr(json.error ?? "Failed to load reason catalog");
|
||||
return;
|
||||
}
|
||||
setCatalogRows(Array.isArray(json.rows) ? json.rows : []);
|
||||
} catch (err: unknown) {
|
||||
if (!alive) return;
|
||||
setCatalogRows([]);
|
||||
setCatalogErr(err instanceof Error ? err.message : "Network error");
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
return () => {
|
||||
alive = false;
|
||||
ac.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const ac = new AbortController();
|
||||
@@ -1308,6 +1363,29 @@ export default function DowntimePageClient() {
|
||||
return metricRowsAll.filter((r) => r.reasonCode === reasonCode);
|
||||
}, [metricRowsAll, reasonCode]);
|
||||
|
||||
const selectedReasonLabel = useMemo(() => {
|
||||
if (!reasonCode) return null;
|
||||
const fromMetrics = metricRowsAll.find((row) => row.reasonCode === reasonCode)?.reasonLabel;
|
||||
if (fromMetrics) return fromMetrics;
|
||||
const fromCatalog = catalogRows.find((row) => row.reasonCode === reasonCode)?.reasonLabel;
|
||||
return fromCatalog ?? reasonCode;
|
||||
}, [catalogRows, metricRowsAll, reasonCode]);
|
||||
|
||||
const catalogByCategory = useMemo(() => {
|
||||
const grouped = new Map<string, { categoryLabel: string; rows: ApiReasonCatalogRow[] }>();
|
||||
for (const row of catalogRows) {
|
||||
const key = row.categoryId;
|
||||
const slot = grouped.get(key) ?? { categoryLabel: row.categoryLabel, rows: [] };
|
||||
slot.rows.push(row);
|
||||
grouped.set(key, slot);
|
||||
}
|
||||
return [...grouped.entries()].map(([categoryId, value]) => ({
|
||||
categoryId,
|
||||
categoryLabel: value.categoryLabel,
|
||||
rows: value.rows,
|
||||
}));
|
||||
}, [catalogRows]);
|
||||
|
||||
const totalMinutes = pareto?.totalMinutesLost ?? 0;
|
||||
const totalStops = useMemo(
|
||||
() => baseRows.reduce((acc, r) => acc + (r.count ?? 0), 0),
|
||||
@@ -1365,6 +1443,7 @@ const filteredEvents = useMemo(() => {
|
||||
e.machineName ?? "",
|
||||
e.reasonLabel ?? "",
|
||||
e.reasonCode ?? "",
|
||||
e.reasonText ?? "",
|
||||
e.workOrderId ?? "",
|
||||
e.episodeId ?? "",
|
||||
]
|
||||
@@ -1467,7 +1546,7 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
|
||||
)}
|
||||
{reasonCode ? (
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-white">
|
||||
Reason: {reasonCode}
|
||||
Reason: {selectedReasonLabel ?? reasonCode}
|
||||
<button
|
||||
className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[11px] text-zinc-200 hover:bg-white/10"
|
||||
onClick={() => setParams({ reasonCode: null })}
|
||||
@@ -1805,7 +1884,7 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
|
||||
className="mt-4 h-[360px] rounded-3xl border border-white/10 bg-black/30 p-4 backdrop-blur"
|
||||
style={{ boxShadow: "var(--app-chart-shadow)" }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ResponsiveContainer width="100%" height="100%" minHeight={200}>
|
||||
<ComposedChart
|
||||
data={heroData}
|
||||
onClick={(st: any) => {
|
||||
@@ -1883,6 +1962,45 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-3">
|
||||
<div className="text-xs font-semibold text-white">Downtime reason menu</div>
|
||||
<div className="mt-1 text-[11px] text-zinc-400">
|
||||
From settings or `downtime_menu.md` fallback
|
||||
</div>
|
||||
{catalogErr ? (
|
||||
<div className="mt-2 text-[11px] text-rose-300">{catalogErr}</div>
|
||||
) : null}
|
||||
<div className="mt-3 max-h-[180px] space-y-2 overflow-y-auto no-scrollbar pr-1">
|
||||
{catalogByCategory.map((group) => (
|
||||
<div key={group.categoryId} className="rounded-xl border border-white/10 bg-white/5 p-2">
|
||||
<div className="mb-1 text-[11px] font-semibold text-zinc-300">{group.categoryLabel}</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{group.rows.map((option) => {
|
||||
const active = reasonCode === option.reasonCode;
|
||||
return (
|
||||
<button
|
||||
key={option.reasonCode}
|
||||
onClick={() => setParams({ reasonCode: option.reasonCode })}
|
||||
className={cn(
|
||||
"rounded-lg border px-2 py-1 text-[11px]",
|
||||
active
|
||||
? "border-emerald-500/40 bg-emerald-500/15 text-emerald-200"
|
||||
: "border-white/10 bg-black/20 text-zinc-300 hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
{option.detailLabel}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!catalogErr && catalogByCategory.length === 0 ? (
|
||||
<div className="text-[11px] text-zinc-500">No reason menu available.</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 max-h-[360px] overflow-y-auto no-scrollbar rounded-2xl border border-white/10 bg-black/20">
|
||||
<div className="grid grid-cols-12 gap-2 border-b border-white/10 px-4 py-3 text-[11px] text-zinc-500">
|
||||
<div className="col-span-8">Reason</div>
|
||||
@@ -2162,6 +2280,9 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
|
||||
<td className="px-4 py-3">
|
||||
<div className="truncate text-white">{e.reasonLabel}</div>
|
||||
<div className="mt-1 text-[11px] text-zinc-500">{e.reasonCode}</div>
|
||||
{e.reasonText && e.reasonText !== e.reasonLabel ? (
|
||||
<div className="mt-1 text-[11px] text-zinc-400">{e.reasonText}</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-200">{e.workOrderId ?? "—"}</td>
|
||||
<td className="px-4 py-3 text-right text-white">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Menu } from "lucide-react";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
import { RouteAudit } from "@/components/perf/RouteAudit";
|
||||
import { UtilityControls } from "@/components/layout/UtilityControls";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
|
||||
@@ -31,6 +32,7 @@ export function AppShell({
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-hidden bg-black text-white">
|
||||
<RouteAudit />
|
||||
<div className="flex h-full">
|
||||
<Sidebar />
|
||||
<div className="flex h-full flex-1 flex-col">
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { BarChart3, Bell, DollarSign, LayoutGrid, LogOut, Settings, Wrench, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { BarChart3, Bell, DollarSign, LayoutGrid, Loader2, LogOut, Settings, Wrench, X } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
|
||||
|
||||
const PERF_ENABLED = process.env.NEXT_PUBLIC_PERF_LOGS === "1";
|
||||
const NAV_MARK_KEY = "perf_nav_start";
|
||||
|
||||
type NavItem = {
|
||||
href: string;
|
||||
@@ -38,6 +40,8 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { screenlessMode } = useScreenlessMode();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [pendingHref, setPendingHref] = useState<string | null>(null);
|
||||
const [me, setMe] = useState<{
|
||||
user?: { name?: string | null; email?: string | null };
|
||||
org?: { name?: string | null };
|
||||
@@ -93,13 +97,33 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
||||
}
|
||||
}, [screenlessMode, pathname, router]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
visibleItems.forEach((it) => {
|
||||
router.prefetch(it.href);
|
||||
});
|
||||
}, [router, visibleItems]);
|
||||
if (!pendingHref) return;
|
||||
if (pathname === pendingHref || pathname.startsWith(`${pendingHref}/`)) {
|
||||
setPendingHref(null);
|
||||
} else if (!isPending) {
|
||||
setPendingHref(null);
|
||||
}
|
||||
}, [pathname, pendingHref, isPending]);
|
||||
|
||||
const markNavStart = (href: string) => {
|
||||
if (!PERF_ENABLED) return;
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
NAV_MARK_KEY,
|
||||
JSON.stringify({
|
||||
href,
|
||||
from: pathname,
|
||||
ts: Date.now(),
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// Prefetch disabled: Next.js 16 has RSC prefetch bugs that can cause 404 on
|
||||
// client-side navigation (see e.g. vercel/next.js#85374). Use fresh fetch on click.
|
||||
const shellClass = [
|
||||
"relative z-20 flex flex-col border-r border-white/10 bg-black/40 shrink-0",
|
||||
variant === "desktop" ? "hidden md:flex h-screen w-64" : "flex h-full w-72 max-w-[85vw]",
|
||||
@@ -126,23 +150,53 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
||||
|
||||
<nav className="px-3 py-2 flex-1 space-y-1">
|
||||
{visibleItems.map((it) => {
|
||||
const active = pathname === it.href || pathname.startsWith(it.href + "/");
|
||||
const isCurrent = pathname === it.href;
|
||||
const active = isCurrent || pathname.startsWith(it.href + "/");
|
||||
const isPendingItem = isPending && pendingHref === it.href;
|
||||
const navLocked = isPending;
|
||||
const Icon = it.icon;
|
||||
return (
|
||||
<Link
|
||||
key={it.href}
|
||||
href={it.href}
|
||||
onMouseEnter={() => router.prefetch(it.href)}
|
||||
onClick={onNavigate}
|
||||
prefetch={false}
|
||||
aria-disabled={navLocked}
|
||||
onClick={(event) => {
|
||||
if (
|
||||
navLocked ||
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.altKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isCurrent) {
|
||||
onNavigate?.();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
markNavStart(it.href);
|
||||
setPendingHref(it.href);
|
||||
startTransition(() => {
|
||||
router.push(it.href);
|
||||
});
|
||||
onNavigate?.();
|
||||
}}
|
||||
className={[
|
||||
"flex items-center gap-3 rounded-xl px-3 py-2 text-sm transition",
|
||||
active
|
||||
? "bg-emerald-500/15 text-emerald-300 border border-emerald-500/20"
|
||||
: "text-zinc-300 hover:bg-white/5 hover:text-white",
|
||||
navLocked ? "pointer-events-none" : "",
|
||||
navLocked && !isPendingItem ? "opacity-60" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{t(it.labelKey)}</span>
|
||||
{isPendingItem ? <Loader2 className="ml-auto h-4 w-4 animate-spin text-emerald-300" /> : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
68
components/perf/RouteAudit.tsx
Normal file
68
components/perf/RouteAudit.tsx
Normal 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;
|
||||
}
|
||||
@@ -333,6 +333,20 @@ Main KPIs remain English in ES-MX (OEE, KPI, SKU, AVAILABILITY, PERFORMANCE, QUA
|
||||
| settings.shiftCompLabel | Shift change compensation (min) | Compensación por cambio de turno (min) |
|
||||
| settings.lunchBreakLabel | Lunch break (min) | Comida (min) |
|
||||
| settings.shift.defaultName | Shift {index} | Turno {index} |
|
||||
| settings.shiftOverrides.title | Day-specific shifts | Turnos por día |
|
||||
| settings.shiftOverrides.subtitle | Optional overrides for individual days. | Sobrescrituras opcionales por día. |
|
||||
| settings.shiftOverrides.useDefault | Use default | Usar predeterminado |
|
||||
| settings.shiftOverrides.customize | Customize | Personalizar |
|
||||
| settings.shiftOverrides.inherits | Uses default shift schedule. | Usa el horario de turnos predeterminado. |
|
||||
| settings.shiftOverrides.dayOff | Day off (no shifts) | Día libre (sin turnos) |
|
||||
| settings.shiftOverrides.clear | Clear shifts | Borrar turnos |
|
||||
| settings.shiftOverrides.mon | Monday | Lunes |
|
||||
| settings.shiftOverrides.tue | Tuesday | Martes |
|
||||
| settings.shiftOverrides.wed | Wednesday | Miércoles |
|
||||
| settings.shiftOverrides.thu | Thursday | Jueves |
|
||||
| settings.shiftOverrides.fri | Friday | Viernes |
|
||||
| settings.shiftOverrides.sat | Saturday | Sábado |
|
||||
| settings.shiftOverrides.sun | Sunday | Domingo |
|
||||
| settings.thresholds | Alert thresholds | Umbrales de alertas |
|
||||
| settings.thresholdsSubtitle | Tune production health alerts. | Ajusta alertas de salud de producción. |
|
||||
| settings.thresholds.appliesAll | Applies to all machines | Aplica a todas las máquinas |
|
||||
|
||||
264
downtime_menu.md
Normal file
264
downtime_menu.md
Normal 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
4427
flows_file.json
Normal file
File diff suppressed because one or more lines are too long
@@ -46,6 +46,19 @@ function readBool(value: unknown) {
|
||||
return value === true;
|
||||
}
|
||||
|
||||
function normalizeStatus(value?: string | null) {
|
||||
if (!value) return null;
|
||||
const raw = value.trim().toLowerCase();
|
||||
if (!raw) return null;
|
||||
if (raw === "in_progress" || raw === "in-progress" || raw === "open" || raw === "activa" || raw === "activo") {
|
||||
return "active";
|
||||
}
|
||||
if (raw === "resuelta" || raw === "resuelto" || raw === "closed" || raw === "ended" || raw === "done") {
|
||||
return "resolved";
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function extractDurationSec(raw: unknown): number | null {
|
||||
const payload = asRecord(raw);
|
||||
if (!payload) return null;
|
||||
@@ -302,10 +315,11 @@ export async function evaluateAlertsForEvent(eventId: string) {
|
||||
const alertId = readString(payload?.alert_id ?? inner?.alert_id);
|
||||
const isUpdate = readBool(payload?.is_update ?? inner?.is_update);
|
||||
const isAutoAck = readBool(payload?.is_auto_ack ?? inner?.is_auto_ack);
|
||||
const status = normalizeStatus(readString(payload?.status ?? inner?.status));
|
||||
const lastCycleTs = readNumber(payload?.last_cycle_timestamp ?? inner?.last_cycle_timestamp);
|
||||
const theoreticalSec = readNumber(payload?.theoretical_cycle_time ?? inner?.theoretical_cycle_time);
|
||||
if (isAutoAck) return;
|
||||
if (isUpdate && !(rule.repeatMinutes && rule.repeatMinutes > 0)) return;
|
||||
if (isUpdate && status !== "resolved") return;
|
||||
if ((eventType === "microstop" || eventType === "macrostop") && theoreticalSec && lastCycleTs == null) {
|
||||
return;
|
||||
}
|
||||
@@ -345,9 +359,11 @@ export async function evaluateAlertsForEvent(eventId: string) {
|
||||
const key = `${channel}:${recipient.userId ?? recipient.contactId ?? recipient.email ?? recipient.phone ?? ""}`;
|
||||
if (delivered.has(key)) continue;
|
||||
|
||||
const statusKey = status === "resolved" ? "resolved" : "active";
|
||||
const ruleKey = `${rule.id}:${statusKey}`;
|
||||
const allowed = await shouldSendNotification({
|
||||
eventIds: notificationEventIds,
|
||||
ruleId: rule.id,
|
||||
ruleId: ruleKey,
|
||||
role: roleName,
|
||||
channel,
|
||||
contactId: recipient.contactId,
|
||||
@@ -376,7 +392,7 @@ export async function evaluateAlertsForEvent(eventId: string) {
|
||||
machineId: event.machineId,
|
||||
eventId: event.id,
|
||||
eventType,
|
||||
ruleId: rule.id,
|
||||
ruleId: ruleKey,
|
||||
role: roleName,
|
||||
channel,
|
||||
contactId: recipient.contactId,
|
||||
@@ -391,7 +407,7 @@ export async function evaluateAlertsForEvent(eventId: string) {
|
||||
machineId: event.machineId,
|
||||
eventId: event.id,
|
||||
eventType,
|
||||
ruleId: rule.id,
|
||||
ruleId: ruleKey,
|
||||
role: roleName,
|
||||
channel,
|
||||
contactId: recipient.contactId,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeShiftOverrides } from "@/lib/settings";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const RANGE_MS: Record<string, number> = {
|
||||
@@ -21,6 +22,26 @@ type AlertsInboxParams = {
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
type AlertsInboxEvent = {
|
||||
id: string;
|
||||
ts: Date;
|
||||
eventType: string;
|
||||
severity: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
machineId: string;
|
||||
machineName?: string | null;
|
||||
location?: string | null;
|
||||
workOrderId?: string | null;
|
||||
sku?: string | null;
|
||||
durationSec?: number | null;
|
||||
status?: string | null;
|
||||
shift?: string | null;
|
||||
alertId?: string | null;
|
||||
isUpdate?: boolean;
|
||||
isAutoAck?: boolean;
|
||||
};
|
||||
|
||||
function pickRange(range: string, start?: Date | null, end?: Date | null) {
|
||||
const now = new Date();
|
||||
if (range === "custom") {
|
||||
@@ -50,6 +71,19 @@ function safeBool(value: unknown) {
|
||||
return value === true;
|
||||
}
|
||||
|
||||
function normalizeStatus(value?: string | null) {
|
||||
if (!value) return null;
|
||||
const raw = value.trim().toLowerCase();
|
||||
if (!raw) return null;
|
||||
if (raw === "in_progress" || raw === "in-progress" || raw === "open" || raw === "activa" || raw === "activo") {
|
||||
return "active";
|
||||
}
|
||||
if (raw === "resuelta" || raw === "resuelto" || raw === "closed" || raw === "ended" || raw === "done") {
|
||||
return "resolved";
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function parsePayload(raw: unknown) {
|
||||
let parsed: unknown = raw;
|
||||
if (typeof raw === "string") {
|
||||
@@ -131,17 +165,54 @@ function getLocalMinutes(ts: Date, timeZone: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const WEEKDAY_KEY_MAP: Record<string, string> = {
|
||||
Sun: "sun",
|
||||
Mon: "mon",
|
||||
Tue: "tue",
|
||||
Wed: "wed",
|
||||
Thu: "thu",
|
||||
Fri: "fri",
|
||||
Sat: "sat",
|
||||
};
|
||||
|
||||
const WEEKDAY_KEYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
|
||||
|
||||
function getLocalDayKey(ts: Date, timeZone: string) {
|
||||
try {
|
||||
const weekday = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
weekday: "short",
|
||||
}).format(ts);
|
||||
return WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()];
|
||||
} catch {
|
||||
return WEEKDAY_KEYS[ts.getUTCDay()];
|
||||
}
|
||||
}
|
||||
|
||||
type ShiftLike = {
|
||||
name: string;
|
||||
startTime?: string | null;
|
||||
endTime?: string | null;
|
||||
start?: string | null;
|
||||
end?: string | null;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
function resolveShift(
|
||||
shifts: Array<{ name: string; startTime: string; endTime: string; enabled?: boolean }>,
|
||||
shifts: ShiftLike[],
|
||||
overrides: Record<string, ShiftLike[]> | undefined,
|
||||
ts: Date,
|
||||
timeZone: string
|
||||
) {
|
||||
if (!shifts.length) return null;
|
||||
const dayKey = getLocalDayKey(ts, timeZone);
|
||||
const dayOverrides = overrides?.[dayKey];
|
||||
const activeShifts = dayOverrides ?? shifts;
|
||||
if (!activeShifts.length) return null;
|
||||
const nowMin = getLocalMinutes(ts, timeZone);
|
||||
for (const shift of shifts) {
|
||||
for (const shift of activeShifts) {
|
||||
if (shift.enabled === false) continue;
|
||||
const start = parseTimeMinutes(shift.startTime);
|
||||
const end = parseTimeMinutes(shift.endTime);
|
||||
const start = parseTimeMinutes(shift.startTime ?? shift.start ?? null);
|
||||
const end = parseTimeMinutes(shift.endTime ?? shift.end ?? null);
|
||||
if (start == null || end == null) continue;
|
||||
if (start <= end) {
|
||||
if (nowMin >= start && nowMin < end) return shift.name;
|
||||
@@ -152,6 +223,34 @@ function resolveShift(
|
||||
return null;
|
||||
}
|
||||
|
||||
function collapseAlertEvents(events: AlertsInboxEvent[]) {
|
||||
const byAlert = new Map<string, AlertsInboxEvent>();
|
||||
const passthrough: AlertsInboxEvent[] = [];
|
||||
|
||||
for (const ev of events) {
|
||||
if (!ev.alertId) {
|
||||
passthrough.push(ev);
|
||||
continue;
|
||||
}
|
||||
const statusKey = ev.status === "resolved" ? "resolved" : "active";
|
||||
const key = `${ev.alertId}:${statusKey}`;
|
||||
const existing = byAlert.get(key);
|
||||
if (!existing) {
|
||||
byAlert.set(key, ev);
|
||||
continue;
|
||||
}
|
||||
const pickNewest = statusKey === "resolved";
|
||||
const shouldReplace = pickNewest
|
||||
? ev.ts.getTime() > existing.ts.getTime()
|
||||
: ev.ts.getTime() < existing.ts.getTime();
|
||||
if (shouldReplace) byAlert.set(key, ev);
|
||||
}
|
||||
|
||||
const combined = [...passthrough, ...byAlert.values()];
|
||||
combined.sort((a, b) => b.ts.getTime() - a.ts.getTime());
|
||||
return combined;
|
||||
}
|
||||
|
||||
export async function getAlertsInboxData(params: AlertsInboxParams) {
|
||||
const {
|
||||
orgId,
|
||||
@@ -213,12 +312,13 @@ export async function getAlertsInboxData(params: AlertsInboxParams) {
|
||||
}),
|
||||
prisma.orgSettings.findUnique({
|
||||
where: { orgId },
|
||||
select: { timezone: true },
|
||||
select: { timezone: true, shiftScheduleOverridesJson: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const timeZone = settings?.timezone || "UTC";
|
||||
const mapped = [];
|
||||
const shiftOverrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson);
|
||||
const mapped: AlertsInboxEvent[] = [];
|
||||
|
||||
for (const ev of events) {
|
||||
const { payload, inner } = parsePayload(ev.data);
|
||||
@@ -227,10 +327,10 @@ export async function getAlertsInboxData(params: AlertsInboxParams) {
|
||||
const isAutoAck = safeBool(payload?.is_auto_ack ?? inner?.is_auto_ack);
|
||||
if (!includeUpdates && (isUpdate || isAutoAck)) continue;
|
||||
|
||||
const shiftName = resolveShift(shifts, ev.ts, timeZone);
|
||||
const shiftName = resolveShift(shifts, shiftOverrides, ev.ts, timeZone);
|
||||
if (normalizedShift && shiftName !== normalizedShift) continue;
|
||||
|
||||
const statusLabel = rawStatus ? rawStatus.toLowerCase() : "unknown";
|
||||
const statusLabel = normalizeStatus(rawStatus) ?? "unknown";
|
||||
if (normalizedStatus && statusLabel !== normalizedStatus) continue;
|
||||
|
||||
mapped.push({
|
||||
@@ -254,8 +354,10 @@ export async function getAlertsInboxData(params: AlertsInboxParams) {
|
||||
});
|
||||
}
|
||||
|
||||
const finalEvents = includeUpdates ? mapped : collapseAlertEvents(mapped);
|
||||
|
||||
return {
|
||||
range: { range: picked.range, start: picked.start, end: picked.end },
|
||||
events: mapped,
|
||||
events: finalEvents,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,56 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logLine } from "@/lib/logger";
|
||||
|
||||
const COOKIE_NAME = "mis_session";
|
||||
const SESSION_CACHE_TTL_MS = 30000;
|
||||
const LAST_SEEN_TTL_MS = 300000;
|
||||
|
||||
type SessionPayload = {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
type CachedSession = {
|
||||
value: SessionPayload;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const sessionCache = new Map<string, CachedSession>();
|
||||
const lastSeenCache = new Map<string, number>();
|
||||
|
||||
function readCache(sessionId: string, now: number) {
|
||||
const cached = sessionCache.get(sessionId);
|
||||
if (!cached) return null;
|
||||
if (cached.expiresAt <= now) {
|
||||
sessionCache.delete(sessionId);
|
||||
return null;
|
||||
}
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
function writeCache(sessionId: string, value: SessionPayload, now: number) {
|
||||
sessionCache.set(sessionId, { value, expiresAt: now + SESSION_CACHE_TTL_MS });
|
||||
}
|
||||
|
||||
function shouldUpdateLastSeen(sessionId: string, now: number) {
|
||||
const last = lastSeenCache.get(sessionId) ?? 0;
|
||||
if (now - last < LAST_SEEN_TTL_MS) return false;
|
||||
lastSeenCache.set(sessionId, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function requireSession() {
|
||||
try {
|
||||
const jar = await cookies();
|
||||
const sessionId = jar.get(COOKIE_NAME)?.value;
|
||||
if (!sessionId) return null;
|
||||
|
||||
const now = Date.now();
|
||||
const cached = readCache(sessionId, now);
|
||||
if (cached) return cached;
|
||||
|
||||
const session = await prisma.session.findFirst({
|
||||
where: {
|
||||
id: sessionId,
|
||||
@@ -24,20 +67,32 @@ export async function requireSession() {
|
||||
if (!session) return null;
|
||||
|
||||
if (!session.user?.isActive || !session.user?.emailVerifiedAt) {
|
||||
await prisma.session
|
||||
void prisma.session
|
||||
.update({ where: { id: session.id }, data: { revokedAt: new Date() } })
|
||||
.catch(() => {});
|
||||
sessionCache.delete(sessionId);
|
||||
lastSeenCache.delete(sessionId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Optional: update lastSeenAt (useful later)
|
||||
await prisma.session
|
||||
if (shouldUpdateLastSeen(sessionId, now)) {
|
||||
void prisma.session
|
||||
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
return {
|
||||
const payload = {
|
||||
sessionId: session.id,
|
||||
userId: session.userId,
|
||||
orgId: session.orgId,
|
||||
};
|
||||
writeCache(sessionId, payload, now);
|
||||
return payload;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const stack = err instanceof Error ? err.stack : undefined;
|
||||
logLine("requireSession.error", { message, stack });
|
||||
console.error("[requireSession]", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
63
lib/financial/cache.ts
Normal file
63
lib/financial/cache.ts
Normal 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();
|
||||
}
|
||||
@@ -398,6 +398,20 @@
|
||||
"settings.shiftCompLabel": "Shift change compensation (min)",
|
||||
"settings.lunchBreakLabel": "Lunch break (min)",
|
||||
"settings.shift.defaultName": "Shift {index}",
|
||||
"settings.shiftOverrides.title": "Day-specific shifts",
|
||||
"settings.shiftOverrides.subtitle": "Optional overrides for individual days.",
|
||||
"settings.shiftOverrides.useDefault": "Use default",
|
||||
"settings.shiftOverrides.customize": "Customize",
|
||||
"settings.shiftOverrides.inherits": "Uses default shift schedule.",
|
||||
"settings.shiftOverrides.dayOff": "Day off (no shifts)",
|
||||
"settings.shiftOverrides.clear": "Clear shifts",
|
||||
"settings.shiftOverrides.mon": "Monday",
|
||||
"settings.shiftOverrides.tue": "Tuesday",
|
||||
"settings.shiftOverrides.wed": "Wednesday",
|
||||
"settings.shiftOverrides.thu": "Thursday",
|
||||
"settings.shiftOverrides.fri": "Friday",
|
||||
"settings.shiftOverrides.sat": "Saturday",
|
||||
"settings.shiftOverrides.sun": "Sunday",
|
||||
"settings.thresholds": "Alert thresholds",
|
||||
"settings.thresholdsSubtitle": "Tune production health alerts.",
|
||||
"settings.thresholds.appliesAll": "Applies to all machines",
|
||||
@@ -457,6 +471,7 @@
|
||||
"financial.costsMovedLink": "Settings -> Financial",
|
||||
"financial.export.html": "HTML",
|
||||
"financial.export.csv": "CSV",
|
||||
"financial.refresh": "Refresh",
|
||||
"financial.totalLoss": "Total Loss",
|
||||
"financial.currencyLabel": "Currency: {currency}",
|
||||
"financial.noImpact": "No impact data yet.",
|
||||
|
||||
@@ -398,6 +398,20 @@
|
||||
"settings.shiftCompLabel": "Compensación por cambio de turno (min)",
|
||||
"settings.lunchBreakLabel": "Comida (min)",
|
||||
"settings.shift.defaultName": "Turno {index}",
|
||||
"settings.shiftOverrides.title": "Turnos por día",
|
||||
"settings.shiftOverrides.subtitle": "Sobrescrituras opcionales por día.",
|
||||
"settings.shiftOverrides.useDefault": "Usar predeterminado",
|
||||
"settings.shiftOverrides.customize": "Personalizar",
|
||||
"settings.shiftOverrides.inherits": "Usa el horario de turnos predeterminado.",
|
||||
"settings.shiftOverrides.dayOff": "Día libre (sin turnos)",
|
||||
"settings.shiftOverrides.clear": "Borrar turnos",
|
||||
"settings.shiftOverrides.mon": "Lunes",
|
||||
"settings.shiftOverrides.tue": "Martes",
|
||||
"settings.shiftOverrides.wed": "Miércoles",
|
||||
"settings.shiftOverrides.thu": "Jueves",
|
||||
"settings.shiftOverrides.fri": "Viernes",
|
||||
"settings.shiftOverrides.sat": "Sábado",
|
||||
"settings.shiftOverrides.sun": "Domingo",
|
||||
"settings.thresholds": "Umbrales de alertas",
|
||||
"settings.thresholdsSubtitle": "Ajusta alertas de salud de producción.",
|
||||
"settings.thresholds.appliesAll": "Aplica a todas las máquinas",
|
||||
@@ -457,6 +471,7 @@
|
||||
"financial.costsMovedLink": "Configuración -> Finanzas",
|
||||
"financial.export.html": "HTML",
|
||||
"financial.export.csv": "CSV",
|
||||
"financial.refresh": "Actualizar",
|
||||
"financial.totalLoss": "Pérdida total",
|
||||
"financial.currencyLabel": "Moneda: {currency}",
|
||||
"financial.noImpact": "Sin datos de impacto.",
|
||||
|
||||
@@ -7,6 +7,7 @@ const LOCALE_COOKIE = "mis_locale";
|
||||
const LOCALE_EVENT = "mis-locale-change";
|
||||
|
||||
function readCookieLocale(): Locale | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const match = document.cookie
|
||||
.split(";")
|
||||
.map((part) => part.trim())
|
||||
@@ -18,6 +19,7 @@ function readCookieLocale(): Locale | null {
|
||||
}
|
||||
|
||||
function readLocale(): Locale {
|
||||
if (typeof document === "undefined") return defaultLocale;
|
||||
const docLang = document.documentElement.getAttribute("lang");
|
||||
if (docLang === "es-MX" || docLang === "en") return docLang;
|
||||
return readCookieLocale() ?? defaultLocale;
|
||||
|
||||
@@ -3,6 +3,10 @@ import path from "path";
|
||||
|
||||
const LOG_PATH = process.env.LOG_FILE || "/tmp/mis-control-tower.log";
|
||||
|
||||
export function getLogPath() {
|
||||
return LOG_PATH;
|
||||
}
|
||||
|
||||
export function logLine(event: string, data: Record<string, unknown> = {}) {
|
||||
const line = JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { prisma } from "@/lib/prisma";
|
||||
|
||||
type MachineAuth = { id: string; orgId: string };
|
||||
|
||||
const TTL_MS = 60_000;
|
||||
const TTL_MS = 10_000;
|
||||
const MAX_SIZE = 1000;
|
||||
const cache = new Map<string, { value: MachineAuth; expiresAt: number }>();
|
||||
|
||||
@@ -36,3 +36,12 @@ export async function getMachineAuth(machineId: string, apiKey: string) {
|
||||
cache.set(key, { value: machine, expiresAt: now + TTL_MS });
|
||||
return machine;
|
||||
}
|
||||
|
||||
export function invalidateMachineAuth(machineId: string) {
|
||||
const prefix = `${machineId}:`;
|
||||
for (const key of cache.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
113
lib/machines/withLatest.ts
Normal file
113
lib/machines/withLatest.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { normalizeEvent } from "@/lib/events/normalizeEvent";
|
||||
import { logLine } from "@/lib/logger";
|
||||
import { elapsedMs, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming";
|
||||
import type { OverviewEventRow, OverviewMachineRow } from "@/lib/overview/types";
|
||||
import {
|
||||
fetchLatestHeartbeats,
|
||||
fetchLatestKpis,
|
||||
fetchMachineBase,
|
||||
mergeMachineOverviewRows,
|
||||
} from "@/lib/machines/withLatest";
|
||||
|
||||
const ALLOWED_TYPES = new Set([
|
||||
"slow-cycle",
|
||||
@@ -37,49 +46,31 @@ export async function getOverviewData({
|
||||
eventsWindowSec = 21600,
|
||||
eventMachines = 6,
|
||||
orgSettings,
|
||||
}: OverviewParams) {
|
||||
const machines = await prisma.machine.findMany({
|
||||
where: { orgId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
code: true,
|
||||
location: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
heartbeats: {
|
||||
orderBy: { tsServer: "desc" },
|
||||
take: 1,
|
||||
select: { ts: true, tsServer: true, status: true, message: true, ip: true, fwVersion: true },
|
||||
},
|
||||
kpiSnapshots: {
|
||||
orderBy: { ts: "desc" },
|
||||
take: 1,
|
||||
select: {
|
||||
ts: true,
|
||||
oee: true,
|
||||
availability: true,
|
||||
performance: true,
|
||||
quality: true,
|
||||
workOrderId: true,
|
||||
sku: true,
|
||||
good: true,
|
||||
scrap: true,
|
||||
target: true,
|
||||
cycleTime: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}: OverviewParams): Promise<{ machines: OverviewMachineRow[]; events: OverviewEventRow[] }> {
|
||||
const perfEnabled = PERF_LOGS_ENABLED;
|
||||
const timings: Record<string, number> = {};
|
||||
const totalStart = nowMs();
|
||||
|
||||
const machineRows = machines.map((m) => ({
|
||||
...m,
|
||||
latestHeartbeat: m.heartbeats[0] ?? null,
|
||||
latestKpi: m.kpiSnapshots[0] ?? null,
|
||||
heartbeats: undefined,
|
||||
kpiSnapshots: undefined,
|
||||
}));
|
||||
try {
|
||||
const machinesStart = nowMs();
|
||||
const machines = await fetchMachineBase(orgId);
|
||||
if (perfEnabled) timings.machinesQuery = elapsedMs(machinesStart);
|
||||
|
||||
const heartbeatStart = nowMs();
|
||||
const machineIds = machines.map((machine) => machine.id);
|
||||
const heartbeats = await fetchLatestHeartbeats(orgId, machineIds);
|
||||
if (perfEnabled) timings.heartbeatsQuery = elapsedMs(heartbeatStart);
|
||||
|
||||
const kpiStart = nowMs();
|
||||
const kpis = await fetchLatestKpis(orgId, machineIds);
|
||||
if (perfEnabled) timings.kpiQuery = elapsedMs(kpiStart);
|
||||
|
||||
const machineRows: OverviewMachineRow[] = mergeMachineOverviewRows({
|
||||
machines,
|
||||
heartbeats,
|
||||
kpis,
|
||||
includeKpi: true,
|
||||
});
|
||||
|
||||
const safeEventMachines = Number.isFinite(eventMachines) ? Math.max(1, Math.floor(eventMachines)) : 6;
|
||||
const safeWindowSec = Number.isFinite(eventsWindowSec) ? eventsWindowSec : 21600;
|
||||
@@ -97,34 +88,24 @@ export async function getOverviewData({
|
||||
|
||||
const targetIds = topMachines.map((m) => m.id);
|
||||
|
||||
let events = [] as Array<{
|
||||
id: string;
|
||||
ts: Date | null;
|
||||
topic: string;
|
||||
eventType: string;
|
||||
severity: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
requiresAck: boolean;
|
||||
workOrderId?: string | null;
|
||||
machineId: string;
|
||||
machineName?: string | null;
|
||||
source: "ingested";
|
||||
}>;
|
||||
let events: OverviewEventRow[] = [];
|
||||
|
||||
if (targetIds.length) {
|
||||
let settings = orgSettings ?? null;
|
||||
if (!settings) {
|
||||
const settingsStart = nowMs();
|
||||
settings = await prisma.orgSettings.findUnique({
|
||||
where: { orgId },
|
||||
select: { stoppageMultiplier: true, macroStoppageMultiplier: true },
|
||||
});
|
||||
if (perfEnabled) timings.orgSettingsQuery = elapsedMs(settingsStart);
|
||||
}
|
||||
|
||||
const microMultiplier = Number(settings?.stoppageMultiplier ?? 1.5);
|
||||
const macroMultiplier = Math.max(microMultiplier, Number(settings?.macroStoppageMultiplier ?? 5));
|
||||
const windowStart = new Date(Date.now() - Math.max(0, safeWindowSec) * 1000);
|
||||
|
||||
const eventsStart = nowMs();
|
||||
const rawEvents = await prisma.machineEvent.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
@@ -148,7 +129,9 @@ export async function getOverviewData({
|
||||
machine: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
if (perfEnabled) timings.eventsQuery = elapsedMs(eventsStart);
|
||||
|
||||
const normalizeStart = nowMs();
|
||||
const normalized = rawEvents
|
||||
.map((row) => ({
|
||||
...normalizeEvent(row, { microMultiplier, macroMultiplier }),
|
||||
@@ -157,7 +140,9 @@ export async function getOverviewData({
|
||||
source: "ingested" as const,
|
||||
}))
|
||||
.filter((event) => event.ts);
|
||||
if (perfEnabled) timings.eventsNormalize = elapsedMs(normalizeStart);
|
||||
|
||||
const filterStart = nowMs();
|
||||
const allowed = normalized.filter((event) => ALLOWED_TYPES.has(event.eventType));
|
||||
const isCritical = (event: (typeof allowed)[number]) => {
|
||||
const severity = String(event.severity ?? "").toLowerCase();
|
||||
@@ -187,7 +172,43 @@ export async function getOverviewData({
|
||||
});
|
||||
|
||||
events = deduped.slice(0, 30);
|
||||
if (perfEnabled) timings.eventsFilter = elapsedMs(filterStart);
|
||||
}
|
||||
|
||||
if (perfEnabled) {
|
||||
timings.total = elapsedMs(totalStart);
|
||||
logLine("perf.overview.getOverviewData", {
|
||||
orgId,
|
||||
eventsMode,
|
||||
eventsWindowSec,
|
||||
eventMachines,
|
||||
timings,
|
||||
counts: {
|
||||
machines: machineRows.length,
|
||||
events: events.length,
|
||||
targetMachines: targetIds.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { machines: machineRows, events };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const stack = err instanceof Error ? err.stack : undefined;
|
||||
if (perfEnabled) {
|
||||
timings.total = elapsedMs(totalStart);
|
||||
logLine("perf.overview.getOverviewData.error", {
|
||||
orgId,
|
||||
eventsMode,
|
||||
eventsWindowSec,
|
||||
eventMachines,
|
||||
timings,
|
||||
message,
|
||||
stack,
|
||||
});
|
||||
}
|
||||
logLine("getOverviewData.error", { message, stack });
|
||||
console.error("[getOverviewData]", err);
|
||||
return { machines: [], events: [] };
|
||||
}
|
||||
}
|
||||
|
||||
102
lib/overview/getOverviewSummary.ts
Normal file
102
lib/overview/getOverviewSummary.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { logLine } from "@/lib/logger";
|
||||
import { elapsedMs, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming";
|
||||
import type { OverviewMachineRow } from "@/lib/overview/types";
|
||||
import {
|
||||
fetchLatestHeartbeats,
|
||||
fetchMachineBase,
|
||||
mergeMachineOverviewRows,
|
||||
} from "@/lib/machines/withLatest";
|
||||
|
||||
type OverviewSummaryParams = {
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
const SUMMARY_CACHE_TTL_MS = 10000;
|
||||
const summaryCache = new Map<string, { value: OverviewMachineRow[]; expiresAt: number; cachedAt: number }>();
|
||||
const summaryInFlight = new Map<string, Promise<{ machines: OverviewMachineRow[] }>>();
|
||||
|
||||
export async function getOverviewSummary({
|
||||
orgId,
|
||||
}: OverviewSummaryParams): Promise<{ machines: OverviewMachineRow[] }> {
|
||||
const now = Date.now();
|
||||
const cached = summaryCache.get(orgId);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
if (PERF_LOGS_ENABLED) {
|
||||
logLine("perf.overview.summary", {
|
||||
orgId,
|
||||
cached: true,
|
||||
timings: { total: 0 },
|
||||
ageMs: now - cached.cachedAt,
|
||||
counts: { machines: cached.value.length },
|
||||
});
|
||||
}
|
||||
return { machines: cached.value };
|
||||
}
|
||||
|
||||
const inFlight = summaryInFlight.get(orgId);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
const promise = fetchOverviewSummary({ orgId })
|
||||
.then((result) => {
|
||||
summaryCache.set(orgId, {
|
||||
value: result.machines,
|
||||
cachedAt: now,
|
||||
expiresAt: now + SUMMARY_CACHE_TTL_MS,
|
||||
});
|
||||
summaryInFlight.delete(orgId);
|
||||
return result;
|
||||
})
|
||||
.catch((err) => {
|
||||
summaryInFlight.delete(orgId);
|
||||
throw err;
|
||||
});
|
||||
|
||||
summaryInFlight.set(orgId, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function fetchOverviewSummary({
|
||||
orgId,
|
||||
}: OverviewSummaryParams): Promise<{ machines: OverviewMachineRow[] }> {
|
||||
const perfEnabled = PERF_LOGS_ENABLED;
|
||||
const totalStart = nowMs();
|
||||
const timings: Record<string, number> = {};
|
||||
|
||||
try {
|
||||
const machinesStart = nowMs();
|
||||
const machines = await fetchMachineBase(orgId);
|
||||
if (perfEnabled) timings.machinesQuery = elapsedMs(machinesStart);
|
||||
|
||||
const heartbeatStart = nowMs();
|
||||
const machineIds = machines.map((machine) => machine.id);
|
||||
const heartbeats = await fetchLatestHeartbeats(orgId, machineIds);
|
||||
if (perfEnabled) timings.heartbeatsQuery = elapsedMs(heartbeatStart);
|
||||
|
||||
const machineRows: OverviewMachineRow[] = mergeMachineOverviewRows({
|
||||
machines,
|
||||
heartbeats,
|
||||
includeKpi: false,
|
||||
});
|
||||
|
||||
if (perfEnabled) {
|
||||
timings.total = elapsedMs(totalStart);
|
||||
logLine("perf.overview.summary", {
|
||||
orgId,
|
||||
timings,
|
||||
counts: { machines: machineRows.length },
|
||||
});
|
||||
}
|
||||
|
||||
return { machines: machineRows };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const stack = err instanceof Error ? err.stack : undefined;
|
||||
if (perfEnabled) {
|
||||
timings.total = elapsedMs(totalStart);
|
||||
logLine("perf.overview.summary.error", { orgId, timings, message, stack });
|
||||
}
|
||||
logLine("getOverviewSummary.error", { message, stack });
|
||||
console.error("[getOverviewSummary]", err);
|
||||
return { machines: [] };
|
||||
}
|
||||
}
|
||||
51
lib/overview/types.ts
Normal file
51
lib/overview/types.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export type OverviewLatestHeartbeat = {
|
||||
ts: Date;
|
||||
tsServer?: Date | null;
|
||||
status: string;
|
||||
message?: string | null;
|
||||
ip?: string | null;
|
||||
fwVersion?: string | null;
|
||||
};
|
||||
|
||||
export type OverviewLatestKpi = {
|
||||
ts: Date;
|
||||
oee?: number | null;
|
||||
availability?: number | null;
|
||||
performance?: number | null;
|
||||
quality?: number | null;
|
||||
workOrderId?: string | null;
|
||||
sku?: string | null;
|
||||
good?: number | null;
|
||||
scrap?: number | null;
|
||||
target?: number | null;
|
||||
cycleTime?: number | null;
|
||||
};
|
||||
|
||||
export type OverviewMachineRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string | null;
|
||||
location?: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
latestHeartbeat: OverviewLatestHeartbeat | null;
|
||||
latestKpi: OverviewLatestKpi | null;
|
||||
heartbeats?: undefined;
|
||||
kpiSnapshots?: undefined;
|
||||
};
|
||||
|
||||
export type OverviewEventRow = {
|
||||
id: string;
|
||||
ts: Date | null;
|
||||
topic: string;
|
||||
eventType: string;
|
||||
severity: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
requiresAck: boolean;
|
||||
workOrderId?: string | null;
|
||||
machineId: string;
|
||||
machineName?: string | null;
|
||||
source: "ingested";
|
||||
};
|
||||
|
||||
18
lib/perf/serverTiming.ts
Normal file
18
lib/perf/serverTiming.ts
Normal 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
200
lib/reasonCatalog.ts
Normal 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);
|
||||
}
|
||||
@@ -18,6 +18,9 @@ export const DEFAULT_SHIFT = {
|
||||
end: "15:00",
|
||||
};
|
||||
|
||||
export const SHIFT_OVERRIDE_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
||||
export type ShiftOverrideDay = (typeof SHIFT_OVERRIDE_DAYS)[number];
|
||||
|
||||
type AnyRecord = Record<string, unknown>;
|
||||
|
||||
function isPlainObject(value: unknown): value is AnyRecord {
|
||||
@@ -40,6 +43,7 @@ type SettingsRow = {
|
||||
timezone: string;
|
||||
shiftChangeCompMin?: number | null;
|
||||
lunchBreakMin?: number | null;
|
||||
shiftScheduleOverridesJson?: unknown;
|
||||
stoppageMultiplier?: number | null;
|
||||
macroStoppageMultiplier?: number | null;
|
||||
oeeAlertThresholdPct?: number | null;
|
||||
@@ -59,6 +63,13 @@ type ShiftRow = {
|
||||
sortOrder?: number | null;
|
||||
};
|
||||
|
||||
type ShiftOverridePayload = {
|
||||
name: string;
|
||||
start: string;
|
||||
end: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[]) {
|
||||
const ordered = [...(shifts ?? [])].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
const mappedShifts = ordered.map((s, idx) => ({
|
||||
@@ -67,6 +78,13 @@ export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[])
|
||||
end: s.endTime,
|
||||
enabled: s.enabled !== false,
|
||||
}));
|
||||
const overrides = normalizeShiftOverrides(settings.shiftScheduleOverridesJson);
|
||||
|
||||
const defaults = normalizeDefaults(settings.defaultsJson);
|
||||
const reasonCatalog =
|
||||
isPlainObject(settings.defaultsJson) && "reasonCatalog" in settings.defaultsJson
|
||||
? (settings.defaultsJson as AnyRecord).reasonCatalog
|
||||
: null;
|
||||
|
||||
return {
|
||||
orgId: settings.orgId,
|
||||
@@ -74,6 +92,7 @@ export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[])
|
||||
timezone: settings.timezone,
|
||||
shiftSchedule: {
|
||||
shifts: mappedShifts,
|
||||
overrides: overrides && Object.keys(overrides).length ? overrides : undefined,
|
||||
shiftChangeCompensationMin: settings.shiftChangeCompMin,
|
||||
lunchBreakMin: settings.lunchBreakMin,
|
||||
},
|
||||
@@ -85,7 +104,10 @@ export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[])
|
||||
qualitySpikeDeltaPct: settings.qualitySpikeDeltaPct,
|
||||
},
|
||||
alerts: normalizeAlerts(settings.alertsJson),
|
||||
defaults: normalizeDefaults(settings.defaultsJson),
|
||||
defaults,
|
||||
reasonCatalog: reasonCatalog ?? undefined,
|
||||
reasonCatalogData: reasonCatalog ?? undefined,
|
||||
reasonCatalogVersion: Number((reasonCatalog as AnyRecord | null)?.version ?? 1),
|
||||
updatedAt: settings.updatedAt,
|
||||
updatedBy: settings.updatedBy,
|
||||
};
|
||||
@@ -169,6 +191,57 @@ export function validateShiftSchedule(shifts: unknown) {
|
||||
return { ok: true, shifts: normalized as NormalizedShift[] };
|
||||
}
|
||||
|
||||
export function validateShiftOverrides(overrides: unknown) {
|
||||
if (overrides === null) {
|
||||
return { ok: true, overrides: null as Record<string, ShiftOverridePayload[]> | null } as const;
|
||||
}
|
||||
if (!isPlainObject(overrides)) {
|
||||
return { ok: false, error: "shift overrides must be an object" } as const;
|
||||
}
|
||||
|
||||
const normalized: Record<string, ShiftOverridePayload[]> = {};
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (!SHIFT_OVERRIDE_DAYS.includes(key as ShiftOverrideDay)) {
|
||||
return { ok: false, error: `invalid shift override day: ${key}` } as const;
|
||||
}
|
||||
const shiftResult = validateShiftSchedule(value);
|
||||
if (!shiftResult.ok) {
|
||||
return { ok: false, error: `shift overrides ${key}: ${shiftResult.error}` } as const;
|
||||
}
|
||||
normalized[key] =
|
||||
shiftResult.shifts?.map((s) => ({
|
||||
name: s.name,
|
||||
start: s.startTime,
|
||||
end: s.endTime,
|
||||
enabled: s.enabled !== false,
|
||||
})) ?? [];
|
||||
}
|
||||
|
||||
return { ok: true, overrides: normalized } as const;
|
||||
}
|
||||
|
||||
export function normalizeShiftOverrides(raw: unknown) {
|
||||
if (!isPlainObject(raw)) return undefined;
|
||||
const out: Record<string, ShiftOverridePayload[]> = {};
|
||||
for (const day of SHIFT_OVERRIDE_DAYS) {
|
||||
const value = raw[day];
|
||||
if (!Array.isArray(value)) continue;
|
||||
const normalized = value
|
||||
.map((entry, idx) => {
|
||||
const record = isPlainObject(entry) ? entry : {};
|
||||
const start = String(record.start ?? record.startTime ?? "").trim();
|
||||
const end = String(record.end ?? record.endTime ?? "").trim();
|
||||
if (!TIME_RE.test(start) || !TIME_RE.test(end)) return null;
|
||||
const name = String(record.name ?? `Shift ${idx + 1}`).trim() || `Shift ${idx + 1}`;
|
||||
const enabled = record.enabled !== false;
|
||||
return { name, start, end, enabled };
|
||||
})
|
||||
.filter((entry): entry is ShiftOverridePayload => !!entry);
|
||||
out[day] = normalized;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function validateShiftFields(shiftChangeCompensationMin?: unknown, lunchBreakMin?: unknown) {
|
||||
if (shiftChangeCompensationMin != null) {
|
||||
const v = Number(shiftChangeCompensationMin);
|
||||
|
||||
41
middleware.ts
Normal file
41
middleware.ts
Normal 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).*)"],
|
||||
};
|
||||
@@ -3,8 +3,8 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "org_settings" ADD COLUMN "shift_schedule_overrides_json" JSONB;
|
||||
@@ -146,6 +146,7 @@ model Machine {
|
||||
|
||||
@@unique([orgId, name])
|
||||
@@index([orgId])
|
||||
@@index([orgId, createdAt])
|
||||
}
|
||||
|
||||
model MachineHeartbeat {
|
||||
@@ -166,6 +167,7 @@ model MachineHeartbeat {
|
||||
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([orgId, machineId, ts])
|
||||
@@index([orgId, machineId, tsServer])
|
||||
}
|
||||
|
||||
model MachineKpiSnapshot {
|
||||
@@ -310,6 +312,7 @@ model OrgSettings {
|
||||
timezone String @default("UTC")
|
||||
shiftChangeCompMin Int @default(10) @map("shift_change_comp_min")
|
||||
lunchBreakMin Int @default(30) @map("lunch_break_min")
|
||||
shiftScheduleOverridesJson Json? @map("shift_schedule_overrides_json")
|
||||
stoppageMultiplier Float @default(1.5) @map("stoppage_multiplier")
|
||||
oeeAlertThresholdPct Float @default(90) @map("oee_alert_threshold_pct")
|
||||
macroStoppageMultiplier Float @default(5) @map("macro_stoppage_multiplier")
|
||||
|
||||
232
rpi-case/.claude/skills/openscad/SKILL.md
Normal file
232
rpi-case/.claude/skills/openscad/SKILL.md
Normal 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);
|
||||
```
|
||||
@@ -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();
|
||||
}
|
||||
95
rpi-case/.claude/skills/openscad/examples/phone_stand.scad
Normal file
95
rpi-case/.claude/skills/openscad/examples/phone_stand.scad
Normal 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();
|
||||
50
rpi-case/.claude/skills/openscad/tools/common.sh
Executable file
50
rpi-case/.claude/skills/openscad/tools/common.sh
Executable 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
|
||||
}
|
||||
56
rpi-case/.claude/skills/openscad/tools/export-stl.sh
Executable file
56
rpi-case/.claude/skills/openscad/tools/export-stl.sh
Executable 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)"
|
||||
147
rpi-case/.claude/skills/openscad/tools/extract-params.sh
Executable file
147
rpi-case/.claude/skills/openscad/tools/extract-params.sh
Executable 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
|
||||
68
rpi-case/.claude/skills/openscad/tools/multi-preview.sh
Executable file
68
rpi-case/.claude/skills/openscad/tools/multi-preview.sh
Executable 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
|
||||
74
rpi-case/.claude/skills/openscad/tools/preview.sh
Executable file
74
rpi-case/.claude/skills/openscad/tools/preview.sh
Executable 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"
|
||||
91
rpi-case/.claude/skills/openscad/tools/render-with-params.sh
Executable file
91
rpi-case/.claude/skills/openscad/tools/render-with-params.sh
Executable 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"
|
||||
46
rpi-case/.claude/skills/openscad/tools/validate.sh
Executable file
46
rpi-case/.claude/skills/openscad/tools/validate.sh
Executable 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
|
||||
BIN
rpi-case/previews/001/rpi5_industrial_case_001_back.png
Normal file
BIN
rpi-case/previews/001/rpi5_industrial_case_001_back.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
BIN
rpi-case/previews/001/rpi5_industrial_case_001_front.png
Normal file
BIN
rpi-case/previews/001/rpi5_industrial_case_001_front.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
BIN
rpi-case/previews/001/rpi5_industrial_case_001_iso.png
Normal file
BIN
rpi-case/previews/001/rpi5_industrial_case_001_iso.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
BIN
rpi-case/previews/001/rpi5_industrial_case_001_left.png
Normal file
BIN
rpi-case/previews/001/rpi5_industrial_case_001_left.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
BIN
rpi-case/previews/001/rpi5_industrial_case_001_right.png
Normal file
BIN
rpi-case/previews/001/rpi5_industrial_case_001_right.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
BIN
rpi-case/previews/001/rpi5_industrial_case_001_top.png
Normal file
BIN
rpi-case/previews/001/rpi5_industrial_case_001_top.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
289
rpi-case/rpi5_industrial_case_001.scad
Normal file
289
rpi-case/rpi5_industrial_case_001.scad
Normal 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();
|
||||
300
rpi-case/rpi5_industrial_case_002.scad
Normal file
300
rpi-case/rpi5_industrial_case_002.scad
Normal 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();
|
||||
328
rpi-case/rpi5_industrial_case_003.scad
Normal file
328
rpi-case/rpi5_industrial_case_003.scad
Normal 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();
|
||||
338
rpi-case/rpi5_industrial_case_004.scad
Normal file
338
rpi-case/rpi5_industrial_case_004.scad
Normal 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();
|
||||
354
rpi-case/rpi5_industrial_case_005.scad
Normal file
354
rpi-case/rpi5_industrial_case_005.scad
Normal 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();
|
||||
338
rpi-case/rpi5_industrial_case_006.scad
Normal file
338
rpi-case/rpi5_industrial_case_006.scad
Normal 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.5–10.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
113
security_risks.md
Normal 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 "don’t 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 tweet’s 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
235
snappy.md
Normal 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.3–2.5s across samples (best ~1.2s, spikes up to ~2.5s).
|
||||
- `getOverviewData` total: ~1.15–1.36s typically; one sample ~2.4s.
|
||||
- **Machines query dominates**: ~1.12–1.33s (primary bottleneck).
|
||||
- Events query: ~5–35ms (minor).
|
||||
- Payload: ~13KB.
|
||||
|
||||
- Machines (`/api/machines`)
|
||||
- Total: ~1.15–1.33s per call for 3 machines.
|
||||
- **Machines query dominates**: ~1.15–1.33s.
|
||||
- Payload: ~1.6KB.
|
||||
|
||||
- Reports (`/api/reports`)
|
||||
- Typical total: ~170–225ms (later runs), earlier spikes up to ~16s (pre-fix or cold).
|
||||
- Query timings combined: ~130–200ms.
|
||||
- Row counts: ~1.8k KPI rows, ~6.2k cycles, ~736 events.
|
||||
- **Payload size ~406KB** (largest).
|
||||
|
||||
- Reports filters (`/api/reports/filters`)
|
||||
- Typical total: ~56–68ms (later runs), earlier spikes up to ~23s (pre-fix or cold).
|
||||
- Query timings: ~30–40ms.
|
||||
- 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.
|
||||
Reference in New Issue
Block a user