added docker

This commit is contained in:
Aaron Lee 2024-01-01 17:06:30 +08:00
parent 1b9da2fe8b
commit 9e472c8026
15 changed files with 202 additions and 37 deletions

40
.dockerignore Normal file
View file

@ -0,0 +1,40 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# database
/prisma/db.sqlite
/prisma/db.sqlite-journal
# next.js
/.next/
/out/
next-env.d.ts
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo

68
Dockerfile Normal file
View file

@ -0,0 +1,68 @@
FROM node:18.19-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
COPY prisma/ ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build
# If using npm comment out above and use below instead
# RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
# set hostname to localhost
ENV HOSTNAME "0.0.0.0"
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD ["node", "server.js"]

46
docker-compose.yml Normal file
View file

@ -0,0 +1,46 @@
# docker-compose.dev.yml
version: '3'
services:
web:
container_name: web
build:
context: .
dockerfile: ./Dockerfile
volumes:
- .:/app/web
environment:
POSTGRES_ADDR: postgres
POSTGRES_DATABASE: savage_tracking
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/savage_tracking?schema=public
JWT_SECRET: secret
TZ: Asia/Taipei
RFID_PKEY: secret
depends_on:
postgres:
condition: service_healthy
restart: always
ports:
- 3000:3000
postgres:
image: postgres:16-alpine
restart: always
environment:
POSTGRES_DB: savage_tracking
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test:
["CMD", "pg_isready", "-U", "postgres", "-d", "savage_tracking"]
interval: 5s
timeout: 10s
retries: 5
ports:
- 3001:5432
volumes:
pgdata: {}

View file

@ -17,6 +17,11 @@ const config = {
locales: ["en"],
defaultLocale: "en",
},
eslint: {
ignoreDuringBuilds: true,
},
output: 'standalone', // for docker
};
export default config;

View file

@ -3,10 +3,11 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}
datasource db {
provider = "sqlite"
provider = "postgresql"
url = env("DATABASE_URL")
}

View file

@ -43,7 +43,7 @@ export default function Periods() {
</thead>
<tbody>
{
Object.keys(periods.data || {}).map((date) => {
Object.keys(periods.data ?? {}).map((date) => {
periodCnt = 0;
return (
<tr className="*:p-1 *:border" key={date}>

View file

@ -1,15 +1,13 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useEffect, useState } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { z } from "zod";
import { api } from "~/utils/api";
import { AddAttendance } from "~/utils/types";
export default function ListView() {
const today = new Date();
const [startDate, setStartDate] = useState(`${today.getFullYear()}-${today.getMonth() + 1}-${today.getDate()}`);
const [endDate, setEndDate] = useState(`${today.getFullYear()}-${today.getMonth() + 1}-${today.getDate()}`);
const [startDate, setStartDate] = useState<string>("");
const [endDate, setEndDate] = useState<string>("");
const attendanceData = api.admin.getAttendanceFromRange.useQuery({
start: startDate,
end: endDate
@ -24,6 +22,12 @@ export default function ListView() {
attendanceData.refetch();
}).catch((err) => console.log(err));
useEffect(() => {
const today = new Date();
setStartDate(today.toISOString().split("T")[0]!)
setEndDate(today.toISOString().split("T")[0]!)
}, [])
return <div className="block">
<div className="flex gap-5 items-center mb-5">
@ -47,8 +51,9 @@ export default function ListView() {
</label>
<button className="p-2 px-3 h-fit bg-emerald-600 text-white rounded-lg"
onClick={() => {
setStartDate(`${today.getFullYear()}-${today.getMonth() + 1}-${today.getDate()}`);
setEndDate(`${today.getFullYear()}-${today.getMonth() + 1}-${today.getDate()}`);
const today = new Date();
setStartDate(today.toISOString().split("T")[0]!);
setEndDate(today.toISOString().split("T")[0]!);
}}>Today</button>
{
(attendanceData.isLoading) && <div role="status">

View file

@ -37,36 +37,36 @@ export default function UserView() {
<label className="block max-w-32">
<span className="text-gray-700">Grade</span>
<input type="text" className="block w-full mt-1 rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-50 bg-gray-200"
disabled value={userData.data?.grade || ""}>
disabled value={userData.data?.grade ?? ""}>
</input>
</label>
<label className="block max-w-32">
<span className="text-gray-700">Class</span>
<input type="text" className="block w-full mt-1 rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-50 bg-gray-200"
disabled value={userData.data?.class || ""}>
disabled value={userData.data?.class ?? ""}>
</input>
</label>
<label className="block max-w-32">
<span className="text-gray-700">Number</span>
<input type="text" className="block w-full mt-1 rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-50 bg-gray-200"
disabled value={userData.data?.number || ""}>
disabled value={userData.data?.number ?? ""}>
</input>
</label>
<label className="block max-w-32">
<span className="text-gray-700">Name</span>
<input type="text" className="block w-full mt-1 rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-50 bg-gray-200"
disabled value={userData.data?.name || ""}>
disabled value={userData.data?.name ?? ""}>
</input>
</label>
<label className="block max-w-32">
<span className="text-gray-700">Display Name</span>
<input type="text" className="block w-full mt-1 rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-50 bg-gray-200"
disabled value={userData.data?.dname || ""}>
disabled value={userData.data?.dname ?? ""}>
</input>
</label>
{
(userSelectedPeriods.isLoading || userData.isLoading) && <div role="status">
(userSelectedPeriods.isLoading ?? userData.isLoading) && <div role="status">
<svg aria-hidden="true" className="w-8 h-8 text-gray-200 animate-spin dark:text-gray-600 fill-emerald-600" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor" />
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill" />
@ -99,11 +99,11 @@ export default function UserView() {
</div>
<div className={[
"border rounded-xl h-32 w-40 p-2 flex items-center justify-center flex-col",
(userActualAttendTime.data || 0) + (userAttendTime.data || 0) >= 30 ? "bg-emerald-500" : "bg-red-300"
(userActualAttendTime.data ?? 0) + (userAttendTime.data ?? 0) >= 30 ? "bg-emerald-500" : "bg-red-300"
].join(" ")}>
<div className="text-center text-xl font-bold"></div>
<div className="flex-grow flex items-center">
<div className="text-center text-6xl font-bold">{((userActualAttendTime.data || 0) + (userAttendTime.data || 0)).toFixed(1)}</div>
<div className="text-center text-6xl font-bold">{((userActualAttendTime.data ?? 0) + (userAttendTime.data ?? 0)).toFixed(1)}</div>
</div>
</div>
</div>
@ -121,7 +121,7 @@ export default function UserView() {
</thead>
<tbody>
{
Object.keys(periods.data || {}).map((date) => {
Object.keys(periods.data ?? {}).map((date) => {
periodCnt = 0;
const borderTop = new Date(date).setHours(0, 0, 0, 0) > new Date().getTime() && !passedDate;
if (borderTop) passedDate = true;
@ -140,7 +140,7 @@ export default function UserView() {
return <td key={timePeriod.id * periodCnt} className="bg-emerald-200 hover:cursor-pointer"
onClick={() => userToggleAttendance.mutateAsync({
username: username,
periodId: thisPeriodId || -1,
periodId: thisPeriodId ?? -1,
attendance: false
}).then(() => {
userSelectedPeriods.refetch();
@ -153,7 +153,7 @@ export default function UserView() {
return <td key={timePeriod.id * periodCnt} className="bg-sky-100 hover:cursor-pointer"
onClick={() => userToggleAttendance.mutateAsync({
username: username,
periodId: thisPeriodId || -1,
periodId: thisPeriodId ?? -1,
attendance: true
}).then(() => {
userSelectedPeriods.refetch();
@ -185,7 +185,7 @@ export default function UserView() {
</thead>
<tbody>
{
Object.keys(periods.data || {}).map((date) => {
Object.keys(periods.data ?? {}).map((date) => {
periodCnt = 0;
entered = false;
const borderTop = new Date(date).setHours(0, 0, 0, 0) > new Date().getTime() && !passedDate;
@ -206,7 +206,7 @@ export default function UserView() {
const thisPeriodEnd = new Date(date);
thisPeriodEnd.setHours(parseInt(timePeriod.end.split(":")[0]!), parseInt(timePeriod.end.split(":")[1]!), 0, 0)
for (let i = attCnt; i < (userAttendance.data || []).length; i++) {
for (let i = attCnt; i < (userAttendance.data ?? []).length; i++) {
const thisAtt = userAttendance.data![i];
if (thisAtt?.datetime! < thisPeriodStart) continue;
if (thisAtt?.datetime! > thisPeriodEnd) {

View file

@ -73,7 +73,7 @@ export default function Dash() {
if (lastRfid.data === "") return;
setUserRfid.mutateAsync({
username: user.username,
rfid: lastRfid.data || ""
rfid: lastRfid.data ?? ""
}).then(() => users.refetch())
}}>
Select

View file

@ -48,7 +48,7 @@ export default function Dash() {
}}>
<option value="">Select a date</option>
{
Object.keys(periods.data || {}).map((date) => {
Object.keys(periods.data ?? {}).map((date) => {
return (<option key={date}>{date}</option>)
})
}

View file

@ -51,11 +51,11 @@ export default function Dash() {
</div>
<div className={[
"border rounded-xl h-32 w-40 p-2 flex items-center justify-center flex-col mb-5",
(actualAttendTime.data || 0) + (attendTime.data || 0) >= 30 ? "bg-emerald-500" : "bg-red-300"
(actualAttendTime.data ?? 0) + (attendTime.data ?? 0) >= 30 ? "bg-emerald-500" : "bg-red-300"
].join(" ")}>
<div className="text-center text-xl font-bold"></div>
<div className="flex-grow flex items-center">
<div className="text-center text-6xl font-bold">{((actualAttendTime.data || 0) + (attendTime.data || 0)).toFixed(1)}</div>
<div className="text-center text-6xl font-bold">{((actualAttendTime.data ?? 0) + (attendTime.data ?? 0)).toFixed(1)}</div>
</div>
</div>
</div>
@ -73,7 +73,7 @@ export default function Dash() {
</thead>
<tbody>
{
Object.keys(periods.data || {}).map((date) => {
Object.keys(periods.data ?? {}).map((date) => {
periodCnt = 0;
entered = false;
return (
@ -88,7 +88,7 @@ export default function Dash() {
if (mySelectedPeriods.data?.findIndex((period) => period == thisPeriodId) != -1) {
return <td key={timePeriod.id * periodCnt} className="bg-emerald-200 hover:cursor-pointer"
onClick={() => toggleAttendance.mutateAsync({
periodId: thisPeriodId || -1,
periodId: thisPeriodId ?? -1,
attendance: false
}).then(() => mySelectedPeriods.refetch()).then(() => attendTime.refetch()).catch((e) => alert(e.message))}>
Will Attend
@ -96,7 +96,7 @@ export default function Dash() {
} else {
return <td key={timePeriod.id * periodCnt} className="bg-sky-100 hover:cursor-pointer"
onClick={() => toggleAttendance.mutateAsync({
periodId: thisPeriodId || -1,
periodId: thisPeriodId ?? -1,
attendance: true
}).then(() => mySelectedPeriods.refetch()).then(() => attendTime.refetch()).catch((e) => alert(e.message))}></td>
}
@ -114,7 +114,7 @@ export default function Dash() {
const thisPeriodEnd = new Date(date);
thisPeriodEnd.setHours(parseInt(timePeriod.end.split(":")[0]!), parseInt(timePeriod.end.split(":")[1]!), 0, 0)
for (let i = attCnt; i < (myAttendance.data || []).length; i++) {
for (let i = attCnt; i < (myAttendance.data ?? []).length; i++) {
const thisAtt = myAttendance.data![i];
if (thisAtt?.datetime! < thisPeriodStart) continue;
if (thisAtt?.datetime! > thisPeriodEnd) {

View file

@ -15,7 +15,7 @@ export async function myCreateContext(opts: CreateNextContextOptions) {
// Verify JWT
let session = PublicUserType.parse(undefined);
try {
jwt.verify(token || "", process.env.JWT_SECRET || "", (err, decoded) => {
jwt.verify(token ?? "", process.env.JWT_SECRET ?? "", (err, decoded) => {
if (err) {
console.log(err);
} else {

View file

@ -18,10 +18,10 @@ export const adminRouter = createTRPCRouter({
username: input.username,
}
}).then((user) => {
const result = compareSync(input.password, user?.password || "");
const result = compareSync(input.password, user?.password ?? "");
if (result) {
const session = PublicUserType.parse(user)!;
const token = jwt.sign(session, process.env.JWT_SECRET || "", { expiresIn: "1d" });
const token = jwt.sign(session, process.env.JWT_SECRET ?? "", { expiresIn: "1d" });
ctx.res.setHeader("Set-Cookie", `token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${60 * 60 * 24};`);
return {
status: "success",
@ -57,7 +57,7 @@ export const adminRouter = createTRPCRouter({
username: ctx.session?.username,
}
}).then(async (user) => {
const result = compareSync(input.oldPassword, user?.password || "");
const result = compareSync(input.oldPassword, user?.password ?? "");
if (result) {
await ctx.db.user.update({
where: {
@ -241,7 +241,7 @@ export const adminRouter = createTRPCRouter({
timePeriods.forEach(async (timePeriod) => {
await ctx.db.period.create({
data: {
date: new Date(input.date),
date: new Date(input.date.replace(/-/g, "/")),
timePeriodId: timePeriod.id,
},
});

View file

@ -38,7 +38,7 @@ export const createTRPCContext = (_opts: CreateNextContextOptions) => {
// Verify JWT
let session = PublicUserType.parse(undefined);
try {
jwt.verify(token || "", process.env.JWT_SECRET || "", (err, decoded) => {
jwt.verify(token ?? "", process.env.JWT_SECRET ?? "", (err, decoded) => {
if (err) {
session = undefined;
} else {

View file

@ -18,7 +18,7 @@
"lib": ["dom", "dom.iterable", "ES2022"],
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"moduleResolution": "node",
"jsx": "preserve",
"plugins": [{ "name": "next" }],
"incremental": true,