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