diff --git a/README.md b/README.md index e215bc4..16c4e71 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,57 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +## ESP32 压力传感器数据采集与回看系统 -## Getting Started +本项目实现: +- 后端 API:接收设备上传数据、查询列表、查询详情、删除、批量导出 +- 前端页面:记录列表页、详情页(时序曲线、直方图、散点图) +- 数据库:Prisma + PostgreSQL(`Recording` 表) -First, run the development server: +### 环境变量 +- `DATABASE_URL`:PostgreSQL 连接串(已在 `.env` 中配置) +- `SAMPLE_RATE_HZ`:采样率(默认 `42.67`),用于计算时长 +- `ADMIN_TOKEN`:可选,若设置则删除接口需请求头 `x-admin-token: ` -```bash +### 安装与运行 + +```pwsh +# 安装依赖 +npm install + +# 生成 Prisma Client +npx prisma generate + +# 初始化数据库(开发库) +# 建议:如果当前数据库已存在其他表,请使用独立 schema,例如: +# 修改 .env 为 ...?schema=esp32 后再执行迁移 +npx prisma migrate dev --name init-recording-table + +# 启动开发环境 npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +如果数据库为共享库且不便迁移,建议将连接串中的 `schema` 改为自定义(例如 `esp32`),避免与既有表冲突: -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +``` +postgresql://user:pass@host:5432/dbname?schema=esp32 +``` -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +如需强制重置(会清空数据),开发环境可用: -## Learn More +```pwsh +npx prisma migrate reset --force +``` -To learn more about Next.js, take a look at the following resources: +### API 速览 +- `POST /api/data`:接收 `{"code": number[], "fit"?: {a,b}, "recStartMs"?: number, "recEndMs"?: number }`,最大 4096 点;若提供 `recStartMs/recEndMs`(毫秒),后端将用它们计算 `duration`(秒) +- `GET /api/records`:分页、筛选(日期、是否拟合)、排序(时间/时长/最大值) +- `GET /api/records/:id`:详情,返回完整 `code[]`、统计与拟合参数 +- `DELETE /api/records/:id`:删除(若设置 `ADMIN_TOKEN` 则需携带 `x-admin-token`) +- `POST /api/export`:批量导出 `ids[]` 为 `csv | json` -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +### 页面 +- 列表页 `/`:过滤、分页、卡片视图 +- 详情页 `/records/:id`:基本信息、统计、时序曲线(码值/力值双轴)、直方图、线性度散点图、导出 CSV -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +### 备注 +- 数据库存储 `code_data` 为 `Int[]`(PostgreSQL 数组) +- 时长 `duration = sampleCount / SAMPLE_RATE_HZ`(默认 `42.67`) +- 导出 CSV 的时间列使用 `i * (duration / sampleCount)` 计算,避免采样率变更带来的误差 diff --git a/app/api/data/route.ts b/app/api/data/route.ts new file mode 100644 index 0000000..13d98e4 --- /dev/null +++ b/app/api/data/route.ts @@ -0,0 +1,81 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { prisma } from '@/src/lib/prisma' +import { applyFit, basicStats, genId, getSampleRateHz } from '@/src/lib/utils' + +const DataSchema = z.object({ + code: z.array(z.number().int()).min(1, 'code array empty').max(4096, 'too many points'), + fit: z.object({ a: z.number(), b: z.number() }).optional(), + recStartMs: z.number().int().nonnegative().optional(), + recEndMs: z.number().int().nonnegative().optional(), +}) + +export async function POST(req: NextRequest) { + try { + const json = await req.json() + const parsed = DataSchema.safeParse(json) + if (!parsed.success) { + const first = parsed.error.errors[0] + return Response.json({ error: first?.message ?? 'invalid body' }, { status: 400 }) + } + const { code, fit, recStartMs, recEndMs } = parsed.data + if (code.length > 4096) { + return Response.json({ error: 'payload too large' }, { status: 413 }) + } + + const sampleCount = code.length + const { min: codeMin, max: codeMax, avg: codeAvg } = basicStats(code) + + let forceMin: number | null = null + let forceMax: number | null = null + let forceAvg: number | null = null + if (fit) { + const forces = applyFit(code, fit.a, fit.b) + const s = basicStats(forces) + forceMin = s.min + forceMax = s.max + forceAvg = s.avg + } + + let duration: number + if ( + typeof recStartMs === 'number' && + typeof recEndMs === 'number' && + Number.isFinite(recStartMs) && + Number.isFinite(recEndMs) && + recEndMs > recStartMs + ) { + duration = (recEndMs - recStartMs) / 1000 + } else { + const sampleRate = getSampleRateHz() + duration = sampleCount / sampleRate + } + const ts = new Date() + const id = genId() + + await prisma.recording.create({ + data: { + id, + timestamp: ts, + sample_count: sampleCount, + duration, + code_data: code.map((n) => Math.trunc(n)), + rec_start_ms: typeof recStartMs === 'number' ? BigInt(Math.trunc(recStartMs)) : null, + rec_end_ms: typeof recEndMs === 'number' ? BigInt(Math.trunc(recEndMs)) : null, + fit_a: fit?.a ?? null, + fit_b: fit?.b ?? null, + code_min: codeMin, + code_max: codeMax, + code_avg: codeAvg, + force_min: forceMin, + force_max: forceMax, + force_avg: forceAvg, + } , + }) + + return Response.json({ success: true, id, timestamp: ts.toISOString() }) + } catch (err) { + console.error(err) + return Response.json({ error: 'internal error' }, { status: 500 }) + } +} diff --git a/app/api/export/route.ts b/app/api/export/route.ts new file mode 100644 index 0000000..1ba3a65 --- /dev/null +++ b/app/api/export/route.ts @@ -0,0 +1,77 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { prisma } from '@/src/lib/prisma' + +const ExportSchema = z.object({ + ids: z.array(z.string()).min(1), + format: z.enum(['csv', 'json']), +}) + +export async function POST(req: NextRequest) { + try { + const body = await req.json() + const parsed = ExportSchema.safeParse(body) + if (!parsed.success) return Response.json({ error: 'invalid body' }, { status: 400 }) + const { ids, format } = parsed.data + + const rows = await prisma.recording.findMany({ + where: { id: { in: ids } }, + orderBy: { timestamp: 'asc' }, + // Cast select to any to accommodate freshly added fields before prisma generate + select: { + id: true, + timestamp: true, + code_data: true, + fit_a: true, + fit_b: true, + sample_count: true, + duration: true, + rec_start_ms: true, + rec_end_ms: true, + } , + }) + + if (format === 'json') { + const payload = rows.map((r) => ({ + id: r.id, + timestamp: new Date(r.timestamp).toISOString(), + code: r.code_data, + fit: r.fit_a != null ? { a: r.fit_a, b: r.fit_b } : undefined, + sampleCount: r.sample_count, + duration: r.duration, + })) + return new Response(JSON.stringify(payload), { + headers: { + 'content-type': 'application/json; charset=utf-8', + 'content-disposition': `attachment; filename="export-${Date.now()}.json"`, + }, + }) + } + + // CSV + let csv = 'recordId,timestamp,index,timeSec,code,force\n' + for (const r of rows) { + let step = r.sample_count > 0 ? r.duration / r.sample_count : 0 + if (r.rec_start_ms != null && r.rec_end_ms != null && r.sample_count > 0) { + const dtMs = Number((r.rec_end_ms as bigint) - (r.rec_start_ms as bigint)) + if (Number.isFinite(dtMs) && dtMs > 0) step = dtMs / 1000 / r.sample_count + } + const hasFit = r.fit_a != null + for (let i = 0; i < r.code_data.length; i++) { + const t = (i * step).toFixed(6) + const code = r.code_data[i] + const force = hasFit ? r.fit_a! * code + (r.fit_b ?? 0) : '' + csv += `${r.id},${new Date(r.timestamp).toISOString()},${i},${t},${code},${hasFit ? force : ''}\n` + } + } + return new Response(csv, { + headers: { + 'content-type': 'text/csv; charset=utf-8', + 'content-disposition': `attachment; filename="export-${Date.now()}.csv"`, + }, + }) + } catch (err) { + console.error(err) + return Response.json({ error: 'internal error' }, { status: 500 }) + } +} diff --git a/app/api/records/[id]/route.ts b/app/api/records/[id]/route.ts new file mode 100644 index 0000000..17e1ca3 --- /dev/null +++ b/app/api/records/[id]/route.ts @@ -0,0 +1,63 @@ +import { NextRequest } from 'next/server' +import { prisma } from '@/src/lib/prisma' +import { isAuthorizedDelete } from '@/src/lib/utils' + +export async function GET(_req: NextRequest, { params }: { params: { id: string } }) { + try { + const rec = await prisma.recording.findUnique({ + where: { id: params.id }, + select: { + id: true, + timestamp: true, + code_data: true, + fit_a: true, + fit_b: true, + sample_count: true, + duration: true, + code_min: true, + code_max: true, + code_avg: true, + force_min: true, + force_max: true, + force_avg: true, + }, + }) + if (!rec) return Response.json({ error: 'not found' }, { status: 404 }) + + return Response.json({ + id: rec.id, + timestamp: rec.timestamp.toISOString(), + code: rec.code_data, + fit: rec.fit_a != null ? { a: rec.fit_a, b: rec.fit_b } : undefined, + sampleCount: rec.sample_count, + duration: rec.duration, + stats: { + codeMin: rec.code_min, + codeMax: rec.code_max, + codeAvg: rec.code_avg, + forceMin: rec.force_min ?? undefined, + forceMax: rec.force_max ?? undefined, + forceAvg: rec.force_avg ?? undefined, + }, + }) + } catch (err) { + console.error(err) + return Response.json({ error: 'internal error' }, { status: 500 }) + } +} + +export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) { + try { + if (!isAuthorizedDelete(req)) { + return Response.json({ error: 'unauthorized' }, { status: 401 }) + } + const rec = await prisma.recording.delete({ where: { id: params.id } }) + return Response.json({ success: true, id: rec.id }) + } catch (err: any) { + if (err?.code === 'P2025') { + return Response.json({ error: 'not found' }, { status: 404 }) + } + console.error(err) + return Response.json({ error: 'internal error' }, { status: 500 }) + } +} diff --git a/app/api/records/route.ts b/app/api/records/route.ts new file mode 100644 index 0000000..7705133 --- /dev/null +++ b/app/api/records/route.ts @@ -0,0 +1,91 @@ +import { NextRequest } from 'next/server' +import { prisma } from '@/src/lib/prisma' + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url) + const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10) || 1) + const pageSizeRaw = parseInt(searchParams.get('pageSize') || '20', 10) || 20 + const pageSize = Math.min(100, Math.max(1, pageSizeRaw)) + const startDate = searchParams.get('startDate') + const endDate = searchParams.get('endDate') + const hasFit = ((): boolean | undefined => { + const v = searchParams.get('hasFit') + return v === null ? undefined : v === 'true' + })() + const sortBy = searchParams.get('sortBy') || 'timestamp' // timestamp | duration | maxValue + const sortOrder = (searchParams.get('sortOrder') === 'asc' ? 'asc' : 'desc') as 'asc' | 'desc' + + const where: any = {} + if (startDate || endDate) { + where.timestamp = {} + if (startDate) where.timestamp.gte = new Date(startDate) + if (endDate) where.timestamp.lte = new Date(endDate) + } + if (hasFit === true) where.NOT = { fit_a: null } + if (hasFit === false) where.fit_a = null + + let orderBy: any + if (sortBy === 'duration') orderBy = { duration: sortOrder } + else if (sortBy === 'maxValue') { + if (hasFit === true) orderBy = { force_max: sortOrder } + else orderBy = { code_max: sortOrder } + } else { + orderBy = { timestamp: sortOrder } + } + + const [total, rows] = await Promise.all([ + prisma.recording.count({ where }), + prisma.recording.findMany({ + where, + orderBy, + skip: (page - 1) * pageSize, + take: pageSize, + select: { + id: true, + timestamp: true, + sample_count: true, + duration: true, + fit_a: true, + fit_b: true, + code_min: true, + code_max: true, + code_avg: true, + force_min: true, + force_max: true, + force_avg: true, + }, + }), + ]) + + const data = rows.map((r) => ({ + id: r.id, + timestamp: r.timestamp.toISOString(), + sampleCount: r.sample_count, + duration: r.duration, + hasFit: r.fit_a !== null && r.fit_a !== undefined, + fit: r.fit_a !== null && r.fit_a !== undefined ? { a: r.fit_a!, b: r.fit_b! } : undefined, + stats: { + codeMin: r.code_min, + codeMax: r.code_max, + codeAvg: r.code_avg, + forceMin: r.force_min ?? undefined, + forceMax: r.force_max ?? undefined, + forceAvg: r.force_avg ?? undefined, + }, + })) + + return Response.json({ + data, + pagination: { + total, + page, + pageSize, + totalPages: Math.max(1, Math.ceil(total / pageSize)), + }, + }) + } catch (err) { + console.error(err) + return Response.json({ error: 'internal error' }, { status: 500 }) + } +} diff --git a/app/page.tsx b/app/page.tsx index 295f8fd..063f8e2 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,65 +1,149 @@ -import Image from "next/image"; +"use client" +import { useEffect, useMemo, useState } from 'react' +import Link from 'next/link' + +type RecordItem = { + id: string + timestamp: string + sampleCount: number + duration: number + hasFit: boolean + fit?: { a: number; b: number } + stats: { + codeMin: number + codeMax: number + codeAvg: number + forceMin?: number + forceMax?: number + forceAvg?: number + } +} export default function Home() { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [data, setData] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(12) + const [hasFit, setHasFit] = useState<'all' | 'fit' | 'raw'>('all') + const [sortBy, setSortBy] = useState<'timestamp' | 'duration' | 'maxValue'>('timestamp') + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc') + const [startDate, setStartDate] = useState('') + const [endDate, setEndDate] = useState('') + + const totalPages = useMemo(() => Math.max(1, Math.ceil(total / pageSize)), [total, pageSize]) + + useEffect(() => { + const controller = new AbortController() + async function run() { + setLoading(true) + setError(null) + try { + const params = new URLSearchParams() + params.set('page', String(page)) + params.set('pageSize', String(pageSize)) + params.set('sortBy', sortBy) + params.set('sortOrder', sortOrder) + if (startDate) params.set('startDate', new Date(startDate).toISOString()) + if (endDate) params.set('endDate', new Date(endDate).toISOString()) + if (hasFit === 'fit') params.set('hasFit', 'true') + if (hasFit === 'raw') params.set('hasFit', 'false') + const res = await fetch(`/api/records?${params.toString()}`, { signal: controller.signal }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const j = await res.json() + setData(j.data) + setTotal(j.pagination.total) + } catch (e: any) { + if (e.name !== 'AbortError') setError(e.message || '加载失败') + } finally { + setLoading(false) + } + } + run() + return () => controller.abort() + }, [page, pageSize, hasFit, sortBy, sortOrder, startDate, endDate]) + return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

+
+
+
+

ESP32 数据采集系统

+
+
+ + setStartDate(e.target.value)} className="rounded border border-zinc-300 bg-white px-2 py-1 text-sm dark:bg-zinc-900" /> +
+
+ + setEndDate(e.target.value)} className="rounded border border-zinc-300 bg-white px-2 py-1 text-sm dark:bg-zinc-900" /> +
+ + + + +
+
+ + {error &&
{error}
} + +
+ {loading && Array.from({ length: 6 }).map((_, i) => ( +
+ ))} + {!loading && data.map((r) => ( +
+
+
+
{new Date(r.timestamp).toLocaleString()}
+ {r.hasFit ? '已拟合' : '未拟合'} +
+
{r.sampleCount} 点 · {r.duration.toFixed(2)} s
+
+ {r.hasFit ? ( + <> + 力值: {r.stats.forceMin?.toFixed(2)} ~ {r.stats.forceMax?.toFixed(2)} mN · 均值 {r.stats.forceAvg?.toFixed(2)} + + ) : ( + <> + 码值: {r.stats.codeMin} ~ {r.stats.codeMax} · 均值 {r.stats.codeAvg.toFixed(2)} + + )} +
+
+
+ {r.hasFit && r.fit && ( +
y = {r.fit.a.toFixed(4)}x + {r.fit.b.toFixed(3)}
+ )} +
+ 查看 +
+
+
+ ))}
-
- - Vercel logomark - Deploy Now - - - Documentation - + +
+ +
{page} / {totalPages}
+
-
+
- ); + ) } diff --git a/app/records/[id]/page.tsx b/app/records/[id]/page.tsx new file mode 100644 index 0000000..73b42ee --- /dev/null +++ b/app/records/[id]/page.tsx @@ -0,0 +1,260 @@ +"use client" +import { useEffect, useMemo, useState } from 'react' +import Link from 'next/link' +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + Tooltip, + Legend, +} from 'chart.js' +import { Line, Bar, Scatter } from 'react-chartjs-2' + +ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, BarElement, Tooltip, Legend) + +type Detail = { + id: string + timestamp: string + code: number[] + fit?: { a: number; b: number } + sampleCount: number + duration: number + stats: { + codeMin: number + codeMax: number + codeAvg: number + forceMin?: number + forceMax?: number + forceAvg?: number + } +} + +function toFixed(n: number | undefined | null, d = 3) { + if (n == null || Number.isNaN(n)) return '-' + return n.toFixed(d) +} + +export default function Page({ params }: { params: { id: string } }) { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [rec, setRec] = useState(null) + + useEffect(() => { + const controller = new AbortController() + async function run() { + setLoading(true) + setError(null) + try { + const res = await fetch(`/api/records/${params.id}`, { signal: controller.signal }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const j = await res.json() + setRec(j) + } catch (e: any) { + if (e.name !== 'AbortError') setError(e.message || '加载失败') + } finally { + setLoading(false) + } + } + run() + return () => controller.abort() + }, [params.id]) + + const timeAxis = useMemo(() => { + if (!rec) return [] as number[] + const step = rec.sampleCount > 0 ? rec.duration / rec.sampleCount : 0 + return Array.from({ length: rec.sampleCount }, (_, i) => +(i * step).toFixed(6)) + }, [rec]) + + const forceSeries = useMemo(() => { + if (!rec?.fit) return undefined + const { a, b } = rec.fit + return rec.code.map((c) => a * c + b) + }, [rec]) + + const codeHistogram = useMemo(() => buildHistogram(rec?.code || [], 30), [rec]) + const forceHistogram = useMemo(() => buildHistogram(forceSeries || [], 30), [forceSeries]) + + return ( +
+
+ + + {loading &&
加载中...
} + {error &&
{error}
} + {rec && ( +
+
+
+
上传时间
+
{new Date(rec.timestamp).toLocaleString()}
+
+
+
采样
+
{rec.sampleCount} 点 · {rec.duration.toFixed(3)} s · {(rec.sampleCount / rec.duration).toFixed(2)} Hz
+
+
+
拟合
+
{rec.fit ? `y = ${rec.fit.a.toFixed(4)}x + ${rec.fit.b.toFixed(3)}` : '未拟合'}
+
+
+ +
+
+
码值统计
+
[{rec.stats.codeMin} ~ {rec.stats.codeMax}] · 均值 {toFixed(rec.stats.codeAvg, 2)}
+
+
+
力值统计
+
{rec.fit ? `[${toFixed(rec.stats.forceMin, 2)} ~ ${toFixed(rec.stats.forceMax, 2)}] mN · 均值 ${toFixed(rec.stats.forceAvg, 2)}` : '—'}
+
+
+
峰峰值
+
码值 {rec.stats.codeMax - rec.stats.codeMin}{rec.fit && rec.stats.forceMax != null && rec.stats.forceMin != null ? ` · 力值 ${(rec.stats.forceMax - rec.stats.forceMin).toFixed(2)} mN` : ''}
+
+
+ +
+
时序曲线
+ +
+ +
+
+
码值分布直方图
+ +
+
+
线性度散点图
+ {rec.fit && forceSeries ? ( + ({ x: c, y: forceSeries[i] })), + backgroundColor: '#22c55e', + }, + ], + }} + options={{ + scales: { + x: { title: { display: true, text: '码值' } }, + y: { title: { display: true, text: '力值 (mN)' } }, + }, + plugins: { legend: { display: false } }, + }} + /> + ) : ( +
无拟合参数,无法绘制
+ )} +
+
+
+ )} +
+
+ ) +} + +function buildHistogram(values: number[], bins: number) { + if (!values.length || bins <= 0) return { labels: [] as string[], counts: [] as number[] } + let min = values[0] + let max = values[0] + for (const v of values) { + if (v < min) min = v + if (v > max) max = v + } + if (min === max) { + return { labels: [min.toFixed(2)], counts: [values.length] } + } + const step = (max - min) / bins + const edges = Array.from({ length: bins + 1 }, (_, i) => min + i * step) + const counts = Array.from({ length: bins }, () => 0) + for (const v of values) { + let idx = Math.floor((v - min) / step) + if (idx >= bins) idx = bins - 1 + if (idx < 0) idx = 0 + counts[idx]++ + } + const labels = Array.from({ length: bins }, (_, i) => `${edges[i].toFixed(1)}~${edges[i + 1].toFixed(1)}`) + return { labels, counts } +} diff --git a/bun.lock b/bun.lock index 80f035d..cd4db8f 100644 --- a/bun.lock +++ b/bun.lock @@ -4,9 +4,14 @@ "": { "name": "nano-force-web-neo", "dependencies": { + "@prisma/client": "^5", + "chart.js": "^4", + "nanoid": "^5", "next": "16.0.3", "react": "19.2.0", + "react-chartjs-2": "^5", "react-dom": "19.2.0", + "zod": "^3", }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -15,6 +20,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.0.3", + "prisma": "^5", "tailwindcss": "^4", "typescript": "^5", }, @@ -147,6 +153,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], "@next/env": ["@next/env@16.0.3", "", {}, "sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ=="], @@ -177,6 +185,18 @@ "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], + "@prisma/client": ["@prisma/client@5.22.0", "", { "peerDependencies": { "prisma": "*" }, "optionalPeers": ["prisma"] }, "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA=="], + + "@prisma/debug": ["@prisma/debug@5.22.0", "", {}, "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ=="], + + "@prisma/engines": ["@prisma/engines@5.22.0", "", { "dependencies": { "@prisma/debug": "5.22.0", "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "@prisma/fetch-engine": "5.22.0", "@prisma/get-platform": "5.22.0" } }, "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA=="], + + "@prisma/engines-version": ["@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "", {}, "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ=="], + + "@prisma/fetch-engine": ["@prisma/fetch-engine@5.22.0", "", { "dependencies": { "@prisma/debug": "5.22.0", "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "@prisma/get-platform": "5.22.0" } }, "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA=="], + + "@prisma/get-platform": ["@prisma/get-platform@5.22.0", "", { "dependencies": { "@prisma/debug": "5.22.0" } }, "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q=="], + "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], @@ -343,6 +363,8 @@ "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -461,6 +483,8 @@ "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], @@ -647,7 +671,7 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], "napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="], @@ -699,6 +723,8 @@ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "prisma": ["prisma@5.22.0", "", { "dependencies": { "@prisma/engines": "5.22.0" }, "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "prisma": "build/index.js" } }, "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -707,6 +733,8 @@ "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], + "react-chartjs-2": ["react-chartjs-2@5.3.1", "", { "peerDependencies": { "chart.js": "^4.1.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A=="], + "react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="], "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], @@ -837,7 +865,7 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], @@ -875,6 +903,8 @@ "eslint-plugin-react/resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="], + "eslint-plugin-react-hooks/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "is-bun-module/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -883,10 +913,14 @@ "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "sharp/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], } } diff --git a/package.json b/package.json index 45b452f..3499ac6 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,19 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev --name init" }, "dependencies": { "next": "16.0.3", "react": "19.2.0", - "react-dom": "19.2.0" + "react-dom": "19.2.0", + "@prisma/client": "^5", + "nanoid": "^5", + "zod": "^3", + "chart.js": "^4", + "react-chartjs-2": "^5" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -21,6 +28,7 @@ "eslint": "^9", "eslint-config-next": "16.0.3", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "prisma": "^5" } } diff --git a/prisma/migrations/20251115061837_init_recording_table/migration.sql b/prisma/migrations/20251115061837_init_recording_table/migration.sql new file mode 100644 index 0000000..a6d97b3 --- /dev/null +++ b/prisma/migrations/20251115061837_init_recording_table/migration.sql @@ -0,0 +1,25 @@ +-- CreateTable +CREATE TABLE "Recording" ( + "id" VARCHAR(32) NOT NULL, + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "sample_count" INTEGER NOT NULL, + "duration" DOUBLE PRECISION NOT NULL, + "code_data" INTEGER[], + "fit_a" DOUBLE PRECISION, + "fit_b" DOUBLE PRECISION, + "code_min" INTEGER NOT NULL, + "code_max" INTEGER NOT NULL, + "code_avg" DOUBLE PRECISION NOT NULL, + "force_min" DOUBLE PRECISION, + "force_max" DOUBLE PRECISION, + "force_avg" DOUBLE PRECISION, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Recording_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Recording_timestamp_idx" ON "Recording"("timestamp" DESC); + +-- CreateIndex +CREATE INDEX "Recording_fit_a_idx" ON "Recording"("fit_a"); diff --git a/prisma/migrations/20251115062712_add_rec_start_ms/migration.sql b/prisma/migrations/20251115062712_add_rec_start_ms/migration.sql new file mode 100644 index 0000000..81a6b0a --- /dev/null +++ b/prisma/migrations/20251115062712_add_rec_start_ms/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Recording" ADD COLUMN "rec_end_ms" BIGINT, +ADD COLUMN "rec_start_ms" BIGINT; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..f57c4ed --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,30 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Recording { + id String @id @db.VarChar(32) + timestamp DateTime @default(now()) + sample_count Int + duration Float + rec_start_ms BigInt? + rec_end_ms BigInt? + code_data Int[] + fit_a Float? + fit_b Float? + code_min Int + code_max Int + code_avg Float + force_min Float? + force_max Float? + force_avg Float? + created_at DateTime @default(now()) + + @@index([timestamp(sort: Desc)]) + @@index([fit_a]) +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts new file mode 100644 index 0000000..78e3971 --- /dev/null +++ b/src/lib/prisma.ts @@ -0,0 +1,11 @@ +import { PrismaClient } from '@prisma/client' + +const globalForPrisma = global as unknown as { prisma?: PrismaClient } + +export const prisma = + globalForPrisma.prisma || + new PrismaClient({ + log: ['error', 'warn'], + }) + +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..908a0ca --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,38 @@ +import { customAlphabet } from 'nanoid' + +export const genId = (() => { + const nano = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 12) + return () => `rec_${nano()}` +})() + +export function getSampleRateHz() { + const v = process.env.SAMPLE_RATE_HZ + const n = v ? Number(v) : 42.67 + return Number.isFinite(n) && n > 0 ? n : 42.67 +} + +export function basicStats(nums: number[]) { + if (!nums.length) return { min: 0, max: 0, avg: 0 } + let min = nums[0] + let max = nums[0] + let sum = 0 + for (const n of nums) { + if (n < min) min = n + if (n > max) max = n + sum += n + } + return { min, max, avg: sum / nums.length } +} + +export function applyFit(codes: number[], a: number, b: number) { + const out = new Array(codes.length) + for (let i = 0; i < codes.length; i++) out[i] = a * codes[i] + b + return out +} + +export function isAuthorizedDelete(req: Request) { + const token = process.env.ADMIN_TOKEN + if (!token) return true + const header = req.headers.get('x-admin-token') || '' + return header === token +}