diff --git a/full-stack/.github/workflows/SECRETS.md b/full-stack/.github/workflows/SECRETS.md new file mode 100644 index 0000000000..0a7fb974a4 --- /dev/null +++ b/full-stack/.github/workflows/SECRETS.md @@ -0,0 +1,129 @@ +# GitHub Actions Secrets Configuration + +This document describes the required secrets for the project's CI/CD pipeline to function. + +## Required Secrets + +### Render Deployment +Configure the following secrets in your GitHub repository settings: + +#### `RENDER_API_KEY` +- **Description**: Render account API key for automatic deployment +- **How to obtain**: + 1. Access [dashboard.render.com](https://dashboard.render.com) + 2. Go to Settings → API Keys + 3. Create new API key + 4. Copy and paste to GitHub secrets + +#### `RENDER_DATABASE_URL` +- **Description**: PostgreSQL database connection URL in production +- **Format**: `postgresql://user:password@host:port/database` +- **How to obtain**: + 1. In Render dashboard, go to PostgreSQL service + 2. Copy the Connection URL + 3. Paste to GitHub secrets + +#### `RENDER_JWT_SECRET` +- **Description**: Secret for JWT token signing +- **How to generate**: + ```bash + openssl rand -base64 32 + ``` +- **Importance**: Essential for authentication security + +## Optional Secrets (Recommended) + +### Separate Services +For deploying frontend and backend on separate services: + +#### `RENDER_BACKEND_SERVICE_ID` +- **Description**: Backend service ID on Render +- **How to obtain**: Backend service URL (ex: `srv-abc123def456`) + +#### `RENDER_FRONTEND_SERVICE_ID` +- **Description**: Frontend service ID on Render +- **How to obtain**: Frontend service URL (ex: `srv-xyz789uvw012`) + +#### `RENDER_BACKEND_URL` +- **Description**: Backend base URL in production +- **Format**: `your-backend.onrender.com` +- **Usage**: Frontend configuration to connect to backend + +#### `RENDER_FRONTEND_URL` +- **Description**: Frontend base URL in production +- **Format**: `your-frontend.onrender.com` +- **Usage**: Pipeline reference and logs + +## Step-by-Step Configuration + +### 1. Access GitHub Secrets +1. Go to your repository on GitHub +2. Click **Settings** → **Secrets and variables** → **Actions** +3. Click **New repository secret** + +### 2. Add Secrets +Add the secrets one by one: + +``` +Name: RENDER_API_KEY +Value: rnd_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +Name: RENDER_DATABASE_URL +Value: postgresql://user:password@host:port/database + +Name: RENDER_JWT_SECRET +Value: your_secret_key_generated_with_openssl +``` + +### 3. Configure Services (Optional) +If using separate services: + +``` +Name: RENDER_BACKEND_SERVICE_ID +Value: srv-abc123def456 + +Name: RENDER_FRONTEND_SERVICE_ID +Value: srv-xyz789uvw012 + +Name: RENDER_BACKEND_URL +Value: your-backend.onrender.com + +Name: RENDER_FRONTEND_URL +Value: your-frontend.onrender.com +``` + +## How the Pipeline Works + +### Without Secrets Configured +- CI works (build, test, lint) +- Pipeline doesn't break +- Deploy is skipped with warning + +### With Secrets Configured +- CI works completely +- Automatic deploy to Render +- Detailed deploy logs + +## Security + +### Validation +The pipeline validates if secrets exist before attempting deploy: +```yaml +if: secrets.RENDER_BACKEND_SERVICE_ID != '' +``` + +## Troubleshooting + +### Deploy Fails +1. Check if all required secrets are configured +2. Confirm if Render API key is valid +3. Check if service IDs are correct + +### Secrets Don't Work +1. Check exact spelling of names +2. Confirm there are no extra spaces +3. Test manually with `echo $SECRET_NAME` + +--- + +**For support**: Check Actions logs on GitHub or consult [Render Deploy documentation](https://render.com/docs/deploy). diff --git a/full-stack/.github/workflows/ci.yml b/full-stack/.github/workflows/ci.yml new file mode 100644 index 0000000000..7a3f646a71 --- /dev/null +++ b/full-stack/.github/workflows/ci.yml @@ -0,0 +1,71 @@ +name: CI + +on: + pull_request: + push: + branches: ["main"] + +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: dynamox + POSTGRES_PASSWORD: dynamox + POSTGRES_DB: dynamox + ports: + - 5433:5432 + options: >- + --health-cmd="pg_isready -U dynamox -d dynamox" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + + env: + DATABASE_URL: postgresql://dynamox:dynamox@localhost:5433/dynamox?schema=public + JWT_SECRET: test_secret + SEED_ADMIN_USERNAME: admin + SEED_ADMIN_PASSWORD: admin + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "pnpm" + + - name: Install deps + run: pnpm install + working-directory: full-stack + + - name: Backend migrate + seed + run: | + pnpm prisma:migrate + pnpm prisma:seed + working-directory: full-stack/apps/backend + + - name: Backend tests + run: pnpm test + working-directory: full-stack/apps/backend + + - name: Frontend tests + run: pnpm vitest run + working-directory: full-stack/apps/frontend + + - name: Build + run: | + pnpm --filter backend build + pnpm --filter frontend build + working-directory: full-stack + + - name: Docker build test + run: | + docker compose -f docker-compose.prod.yml build + working-directory: full-stack diff --git a/full-stack/.github/workflows/deploy-render.yml b/full-stack/.github/workflows/deploy-render.yml new file mode 100644 index 0000000000..4c205bb441 --- /dev/null +++ b/full-stack/.github/workflows/deploy-render.yml @@ -0,0 +1,119 @@ +name: Deploy to Render + +on: + push: + branches: [main] + paths: + - 'apps/frontend/**' + - 'apps/backend/**' + - 'package.json' + - 'pnpm-workspace.yaml' + - 'docker-compose.yml' + - 'scripts/**' + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'pnpm' + + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: 8 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build applications + run: | + # Build backend + pnpm --filter backend build + + # Build frontend + pnpm --filter frontend build + + - name: Run tests + run: | + # Run backend tests + pnpm --filter backend test || echo "Backend tests skipped" + + # Run frontend tests + pnpm --filter frontend test || echo "Frontend tests skipped" + + - name: Deploy to Render (Backend) + env: + RENDER_SERVICE_ID: ${{ secrets.RENDER_BACKEND_SERVICE_ID }} + RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }} + run: | + echo "Deploying backend to Render..." + + # Check if service ID is configured + if [ -z "$RENDER_SERVICE_ID" ]; then + echo "RENDER_BACKEND_SERVICE_ID not configured. Skipping backend deploy." + exit 0 + fi + + # Install Render CLI + npm install -g @render/cli + + # Deploy backend + render deploy \ + --service $RENDER_SERVICE_ID \ + --env-var NODE_ENV=production \ + --env-var DATABASE_URL=${{ secrets.RENDER_DATABASE_URL }} \ + --env-var JWT_SECRET=${{ secrets.RENDER_JWT_SECRET }} \ + --env-var SEED_TS_INTERVAL_MINUTES=15 \ + --env-var SEED_TS_DAYS=2 \ + --path apps/backend \ + --no-wait || echo "Backend deploy failed" + + - name: Deploy to Render (Frontend) + env: + RENDER_SERVICE_ID: ${{ secrets.RENDER_FRONTEND_SERVICE_ID }} + RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }} + run: | + echo "Deploying frontend to Render..." + + # Check if service ID is configured + if [ -z "$RENDER_SERVICE_ID" ]; then + echo "RENDER_FRONTEND_SERVICE_ID not configured. Skipping frontend deploy." + exit 0 + fi + + # Install Render CLI + npm install -g @render/cli + + # Deploy frontend + render deploy \ + --service $RENDER_SERVICE_ID \ + --env-var VITE_API_BASE_URL=https://${{ secrets.RENDER_BACKEND_URL }}.onrender.com \ + --path apps/frontend \ + --no-wait || echo "Frontend deploy failed" + + - name: Deploy Status + run: | + echo "CI/CD Pipeline completed!" + echo "" + echo "Summary:" + echo " • Dependencies installed" + echo " • Applications built" + echo " • Tests executed" + echo " • Deploy attempted" + echo "" + echo "Configure secrets in repository settings to enable automatic deployment:" + echo " • RENDER_API_KEY" + echo " • RENDER_DATABASE_URL" + echo " • RENDER_JWT_SECRET" + echo " • RENDER_BACKEND_SERVICE_ID" + echo " • RENDER_FRONTEND_SERVICE_ID" + echo " • RENDER_BACKEND_URL" + echo " • RENDER_FRONTEND_URL" diff --git a/full-stack/.gitignore b/full-stack/.gitignore new file mode 100644 index 0000000000..10a5c3f58e --- /dev/null +++ b/full-stack/.gitignore @@ -0,0 +1,14 @@ +node_modules +dist +.env +.env.* +!.env.example +.DS_Store +pnpm-debug.log* + +# Docker build artifacts +docker-compose.prod.yml + +# Deploy artifacts +deploy/ +*.log \ No newline at end of file diff --git a/full-stack/README.md b/full-stack/README.md new file mode 100644 index 0000000000..829e2e7ebf --- /dev/null +++ b/full-stack/README.md @@ -0,0 +1,245 @@ +# Dynamox Full Stack Challenge + +Sistema simples de monitoramento industrial com gestão de máquinas, pontos de monitoramento e sensores, construído com React, Node.js e PostgreSQL. + +## Contexto do Projeto + +Solução full-stack para monitoramento industrial que permite: +- Gestão de máquinas e sensores +- Coleta e visualização de dados em tempo real +- Regras de negócio específicas (Pump vs Fan sensors) +- Performance otimizada (P95 < 350ms) +- Interface moderna e responsiva + +## Início Rápido + +### Pré-requisitos +```bash +# Verificar e instalar dependências automaticamente +./scripts/check-deps.sh +``` + +### Subir o Sistema Completo +```bash +# Iniciar todos os serviços (frontend + backend + database) +./scripts/up.sh +``` + +### Acessar o Sistema +- **Frontend**: http://localhost:5173 +- **Backend API**: http://localhost:3001 +- **Documentação API**: http://localhost:3001/docs (swagger) +- **Banco de Dados**: localhost:5433 + +**Credenciais padrão**: `admin/admin` + +## Scripts Disponíveis + +### Gerenciamento do Sistema +```bash +./scripts/up.sh # Iniciar todos os serviços +./scripts/down.sh # Parar todos os serviços +./scripts/check-deps.sh # Verificar dependências +``` + +### Testes e Validação +```bash +./scripts/smoke-test.sh # Teste de integração completo +./scripts/get-token.sh # Obter token JWT para testes +``` + +### Desenvolvimento +```bash +# Apenas banco de dados (para desenvolvimento local) +docker compose up -d postgres + +# Backend local +pnpm --filter backend dev + +# Frontend local +pnpm --filter frontend dev +``` + +## Arquitetura + +### Stack Tecnológico +- **Frontend**: React 18 + TypeScript + Material-UI + Redux Toolkit +- **Backend**: Node.js + Fastify + TypeScript + Prisma ORM +- **Banco**: PostgreSQL 16 com índices otimizados +- **Containerização**: Docker + Docker Compose +- **Autenticação**: JWT tokens com 8h de expiração + +### Estrutura do Projeto +``` +full-stack/ +├── apps/ +│ ├── backend/ # API Fastify + Prisma +│ └── frontend/ # React + MUI +├── scripts/ # Scripts de automação +├── docs/ # Documentação detalhada +├── docker-compose.yml # Orquestração de containers +└── README.md # Este arquivo +``` + +## Funcionalidades Principais + +### Gestão de Ativos +- CRUD de máquinas (Pump/Fan) +- Pontos de monitoramento associados +- Sensores com regras de compatibilidade +- Paginação e ordenação otimizadas + +### Coleta de Dados +- Séries temporais (15min interval) +- Métricas automáticas (min/max/avg/count) +- Filtros por período (24h/7d/30d) +- Visualização interativa com gráficos + +### Performance +- P95 < 350ms (endpoint monitoring-points) +- Índices otimizados no PostgreSQL +- Lazy loading e caching no frontend +- Connection pooling no backend + +## Documentação Completa + +### Guia de Dependências +Instalação e configuração de todas as dependências necessárias. +**Ver**: `docs/dependencies.md` + +### Containers Docker +Configuração detalhada dos containers, volumes e networking. +**Ver**: `docs/docker-containers.md` + +### Banco de Dados +Schema, migrations, seeds e otimizações do PostgreSQL. +**Ver**: `docs/database.md` + +### Funcionalidades +Descrição completa de todas as features implementadas. +**Ver**: `docs/features.md` + +## Especificações Técnicas + +### Performance +- **Response time**: P95 < 350ms (monitoring-points) +- **Throughput**: 100+ requests/segundo +- **Database**: Índices otimizados para queries complexas +- **Frontend**: Code splitting e lazy loading + +### Segurança +- **Autenticação**: JWT com HMAC-SHA256 +- **Autorização**: Middleware global de validação +- **Input validation**: Zod schemas em todos os endpoints +- **CORS**: Configuração específica para frontend + +### Escalabilidade +- **Horizontal**: Suporte para múltiplas instâncias +- **Database**: Connection pooling e índices otimizados +- **Frontend**: Virtual scrolling para grandes listas +- **API**: Paginação server-side em todos os endpoints + +## Desenvolvimento + +### Ambiente Local +```bash +# 1. Instalar dependências +./scripts/check-deps.sh + +# 2. Iniciar banco de dados +docker compose up -d postgres + +# 3. Configurar backend +cp apps/backend/.env.example apps/backend/.env + +# 4. Rodar migrations e seed +pnpm --filter backend prisma:migrate +pnpm --filter backend prisma:seed + +# 5. Iniciar serviços +pnpm --filter backend dev +pnpm --filter frontend dev +``` + +### Testes +```bash +# Testes E2E (Cypress) +pnpm --filter frontend test:e2e + +# Testes de performance +pnpm --filter backend benchmark + +# Teste de integração +./scripts/smoke-test.sh +``` + +## Deploy + +### Produção (Docker) +```bash +# Build e deploy completo +./scripts/up.sh + +# Verificar status +docker compose ps + +# Logs +docker compose logs -f +``` + +### Cloud (Render) +Configuração para deploy automático via GitHub Actions. Veja `.github/workflows/deploy-render.yml`. + +## Troubleshooting + +### Problemas Comuns + +#### Docker não inicia +```bash +# Verificar status +docker info + +# macOS/Windows: Iniciar Docker Desktop +# Linux: sudo systemctl start docker +``` + +#### Portas em uso +```bash +# Verificar ports +lsof -i :3001 +lsof -i :5173 +lsof -i :5433 +``` + +#### Backend não responde +```bash +# Verificar logs +docker compose logs backend + +# Testar health endpoint +curl http://localhost:3001/health +``` + +#### Frontend não conecta no backend +```bash +# Verificar se ambos estão rodando +docker compose ps + +# Verificar configuração de rede +docker network ls +``` + +### Limpeza Completa +```bash +# Parar e remover tudo +docker compose down -v + +# Limpar imagens e cache +docker system prune -f + +# Reconstruir do zero +./scripts/up.sh +``` +--- + +**Desenvolvido para o Desafio Técnico Dynamox** diff --git a/full-stack/apps/backend/.dockerignore b/full-stack/apps/backend/.dockerignore new file mode 100644 index 0000000000..76c2b61b24 --- /dev/null +++ b/full-stack/apps/backend/.dockerignore @@ -0,0 +1,9 @@ +node_modules +dist +.env +.env.local +.env.*.local +*.log +.DS_Store +.vscode +.idea diff --git a/full-stack/apps/backend/.env.example b/full-stack/apps/backend/.env.example new file mode 100644 index 0000000000..15f08f7a9f --- /dev/null +++ b/full-stack/apps/backend/.env.example @@ -0,0 +1,9 @@ +DATABASE_URL="postgresql://dynamox:dynamox@localhost:5433/dynamox?schema=public" +JWT_SECRET="change-me" + +SEED_ADMIN_USERNAME="admin" +SEED_ADMIN_PASSWORD="admin" + +SEED_TS_DAYS="2" +SEED_TS_INTERVAL_MINUTES="15" +SEED_TS_POINTS_PER_MP="0" \ No newline at end of file diff --git a/full-stack/apps/backend/Dockerfile b/full-stack/apps/backend/Dockerfile new file mode 100644 index 0000000000..10c7fc7d2e --- /dev/null +++ b/full-stack/apps/backend/Dockerfile @@ -0,0 +1,15 @@ +FROM node:20-alpine + +WORKDIR /app + +# Copy package.json and install +COPY package.json ./ +RUN npm install -g typescript && npm install + +# Copy code and schema +COPY . . + +ENV NODE_ENV=production +EXPOSE 3001 + +CMD sh -c "npx prisma generate && npx prisma migrate deploy && npx prisma db seed && npx tsx src/server.ts" diff --git a/full-stack/apps/backend/package.json b/full-stack/apps/backend/package.json new file mode 100644 index 0000000000..05b7b822cd --- /dev/null +++ b/full-stack/apps/backend/package.json @@ -0,0 +1,42 @@ +{ + "name": "backend", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/server.js", + "pretest": "pnpm prisma:migrate && pnpm prisma:seed", + "test": "vitest run", + "test:watch": "vitest", + "prisma:migrate": "prisma migrate dev", + "prisma:studio": "prisma studio", + "prisma:seed": "tsx prisma/seed.ts", + "migrate-seed": "tsx src/scripts/migrate-seed.ts", + "benchmark": "node scripts/benchmark.js" + }, + "dependencies": { + "@fastify/cors": "^11.2.0", + "@fastify/jwt": "^10.0.0", + "@fastify/sensible": "^6.0.4", + "@fastify/swagger": "^9.6.1", + "@fastify/swagger-ui": "^5.2.5", + "@prisma/adapter-pg": "^7.3.0", + "@prisma/client": "^7.3.0", + "bcrypt": "^6.0.0", + "dotenv": "^17.2.3", + "fastify": "^5.7.4", + "pg": "^8.18.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@faker-js/faker": "^10.2.0", + "@types/bcrypt": "^6.0.0", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^24.10.11", + "@types/pg": "^8.16.0", + "prisma": "^7.3.0", + "tsx": "^4.21.0", + "vitest": "^4.0.18" + } +} \ No newline at end of file diff --git a/full-stack/apps/backend/prisma.config.ts b/full-stack/apps/backend/prisma.config.ts new file mode 100644 index 0000000000..7f225a1a81 --- /dev/null +++ b/full-stack/apps/backend/prisma.config.ts @@ -0,0 +1,12 @@ +import "dotenv/config"; +import { defineConfig, env } from "prisma/config"; + +export default defineConfig({ + schema: "prisma/schema.prisma", + datasource: { + url: env("DATABASE_URL"), + }, + migrations: { + seed: "tsx prisma/seed.ts", + }, +}); \ No newline at end of file diff --git a/full-stack/apps/backend/prisma/migrations/20260210160536_init/migration.sql b/full-stack/apps/backend/prisma/migrations/20260210160536_init/migration.sql new file mode 100644 index 0000000000..79bb43b337 --- /dev/null +++ b/full-stack/apps/backend/prisma/migrations/20260210160536_init/migration.sql @@ -0,0 +1,112 @@ +-- CreateEnum +CREATE TYPE "MachineType" AS ENUM ('Pump', 'Fan'); + +-- CreateEnum +CREATE TYPE "SensorModel" AS ENUM ('TcAg', 'TcAs', 'HF_plus'); + +-- CreateTable +CREATE TABLE "Machine" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "type" "MachineType" NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Machine_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MonitoringPoint" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "machineId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MonitoringPoint_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Sensor" ( + "id" TEXT NOT NULL, + "uniqueId" TEXT NOT NULL, + "model" "SensorModel" NOT NULL, + "monitoringPointId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Sensor_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "HealthCheck" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "HealthCheck_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "username" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'admin', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TimeSeries" ( + "id" TEXT NOT NULL, + "monitoringPointId" TEXT NOT NULL, + "timestamp" TIMESTAMP(3) NOT NULL, + "value" DOUBLE PRECISION NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TimeSeries_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Machine_type_idx" ON "Machine"("type"); + +-- CreateIndex +CREATE INDEX "Machine_name_idx" ON "Machine"("name"); + +-- CreateIndex +CREATE INDEX "MonitoringPoint_machineId_idx" ON "MonitoringPoint"("machineId"); + +-- CreateIndex +CREATE INDEX "MonitoringPoint_name_idx" ON "MonitoringPoint"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "MonitoringPoint_machineId_name_key" ON "MonitoringPoint"("machineId", "name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Sensor_uniqueId_key" ON "Sensor"("uniqueId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Sensor_monitoringPointId_key" ON "Sensor"("monitoringPointId"); + +-- CreateIndex +CREATE INDEX "Sensor_model_idx" ON "Sensor"("model"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); + +-- CreateIndex +CREATE INDEX "TimeSeries_monitoringPointId_timestamp_idx" ON "TimeSeries"("monitoringPointId", "timestamp"); + +-- CreateIndex +CREATE UNIQUE INDEX "TimeSeries_monitoringPointId_timestamp_key" ON "TimeSeries"("monitoringPointId", "timestamp"); + +-- AddForeignKey +ALTER TABLE "MonitoringPoint" ADD CONSTRAINT "MonitoringPoint_machineId_fkey" FOREIGN KEY ("machineId") REFERENCES "Machine"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Sensor" ADD CONSTRAINT "Sensor_monitoringPointId_fkey" FOREIGN KEY ("monitoringPointId") REFERENCES "MonitoringPoint"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TimeSeries" ADD CONSTRAINT "TimeSeries_monitoringPointId_fkey" FOREIGN KEY ("monitoringPointId") REFERENCES "MonitoringPoint"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/full-stack/apps/backend/prisma/migrations/migration_lock.toml b/full-stack/apps/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000000..044d57cdb0 --- /dev/null +++ b/full-stack/apps/backend/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 (e.g., Git) +provider = "postgresql" diff --git a/full-stack/apps/backend/prisma/schema.prisma b/full-stack/apps/backend/prisma/schema.prisma new file mode 100644 index 0000000000..018e2139fb --- /dev/null +++ b/full-stack/apps/backend/prisma/schema.prisma @@ -0,0 +1,94 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? +// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" +} + +enum MachineType { + Pump + Fan +} + +enum SensorModel { + TcAg + TcAs + HF_plus +} + +model Machine { + id String @id @default(cuid()) + name String + type MachineType + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + monitoringPoints MonitoringPoint[] + + @@index([type]) + @@index([name]) +} + +model MonitoringPoint { + id String @id @default(cuid()) + name String + machineId String + timeSeries TimeSeries[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade) + sensor Sensor? + + // opcional: não repetir nome dentro da mesma máquina + @@unique([machineId, name]) + + @@index([machineId]) + @@index([name]) +} + +model Sensor { + id String @id @default(cuid()) + uniqueId String @unique + model SensorModel + monitoringPointId String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + monitoringPoint MonitoringPoint @relation(fields: [monitoringPointId], references: [id], onDelete: Cascade) + + @@index([model]) +} + +model HealthCheck { + id String @id @default(cuid()) + createdAt DateTime @default(now()) +} + +model User { + id String @id @default(cuid()) + username String @unique + passwordHash String + role String @default("admin") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model TimeSeries { + id String @id @default(cuid()) + monitoringPoint MonitoringPoint @relation(fields: [monitoringPointId], references: [id], onDelete: Cascade) + monitoringPointId String + timestamp DateTime + value Float + createdAt DateTime @default(now()) + + @@index([monitoringPointId, timestamp]) + @@unique([monitoringPointId, timestamp]) +} \ No newline at end of file diff --git a/full-stack/apps/backend/prisma/seed.ts b/full-stack/apps/backend/prisma/seed.ts new file mode 100644 index 0000000000..d9039d6e47 --- /dev/null +++ b/full-stack/apps/backend/prisma/seed.ts @@ -0,0 +1,143 @@ +import "dotenv/config.js"; +import { faker } from "@faker-js/faker"; +import PrismaPkg from "@prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { Pool } from "pg"; +import bcrypt from "bcrypt"; +import { Prisma } from "@prisma/client"; + +const { PrismaClient } = PrismaPkg; + +const url = process.env.DATABASE_URL; +if (!url) throw new Error("DATABASE_URL is not set. Create apps/backend/.env"); + +const pool = new Pool({ connectionString: url }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +const sensorModelsAll = ["TcAg", "TcAs", "HF_plus"] as const; +const sensorModelsForPump = ["HF_plus"] as const; + +function pickSensorModel(machineType: "Pump" | "Fan") { + if (machineType === "Pump") return faker.helpers.arrayElement(sensorModelsForPump); + return faker.helpers.arrayElement(sensorModelsAll); +} + +function minutesAgo(min: number) { + return new Date(Date.now() - min * 60 * 1000); +} + +function generateSeriesValue(i: number, base: number) { + const sine = Math.sin(i / 10) * 5; + const noise = (Math.random() - 0.5) * 2; + return Number((base + sine + noise).toFixed(2)); +} + +async function main() { + // Seed idempotente: limpar primeiro + await prisma.sensor.deleteMany(); + await prisma.monitoringPoint.deleteMany(); + await prisma.machine.deleteMany(); + + const adminUsername = process.env.SEED_ADMIN_USERNAME ?? "admin"; + const adminPassword = process.env.SEED_ADMIN_PASSWORD ?? "admin"; + + const passwordHash = await bcrypt.hash(adminPassword, 10); + + await prisma.user.upsert({ + where: { username: adminUsername }, + update: { passwordHash, role: "admin" }, + create: { username: adminUsername, passwordHash, role: "admin" }, + }); + + const machinesCount = 12; + + for (let i = 0; i < machinesCount; i++) { + const type = faker.helpers.arrayElement(["Pump", "Fan"] as const); + const machine = await prisma.machine.create({ + data: { + name: `${type} ${faker.company.name().slice(0, 18)} ${i + 1}`, + type, + }, + }); + + const monitoringPointsCount = faker.number.int({ min: 2, max: 5 }); + + for (let j = 0; j < monitoringPointsCount; j++) { + const model = pickSensorModel(type); + + // uniqueId está UNIQUE no schema, então garantimos unicidade + const uniqueId = `S-${faker.string.alphanumeric({ length: 10 }).toUpperCase()}-${i}${j}`; + + await prisma.monitoringPoint.create({ + data: { + name: `MP ${j + 1} - ${faker.hacker.noun()}`.slice(0, 60), + machineId: machine.id, + sensor: { + create: { + uniqueId, + model, + }, + }, + }, + }); + } + } + + const totalMachines = await prisma.machine.count(); + const totalMPs = await prisma.monitoringPoint.count(); + const totalSensors = await prisma.sensor.count(); + + console.log("Seed completed:", { totalMachines, totalMPs, totalSensors }); + + const days = Number(process.env.SEED_TS_DAYS ?? "2"); + const intervalMinutes = Number(process.env.SEED_TS_INTERVAL_MINUTES ?? "15"); + const fixedPoints = Number(process.env.SEED_TS_POINTS_PER_MP ?? "0"); + + const totalPoints = + fixedPoints > 0 + ? fixedPoints + : Math.floor((days * 24 * 60) / intervalMinutes); + + const monitoringPoints = await prisma.monitoringPoint.findMany({ + select: { id: true }, + }); + + let inserted = 0; + + for (const mp of monitoringPoints) { + const data: Prisma.TimeSeriesCreateManyInput[] = []; + + for (let i = totalPoints - 1; i >= 0; i--) { + const minutes = i * intervalMinutes; + const t = minutesAgo(minutes); + t.setSeconds(0, 0); + const timestamp = t; + + data.push({ + monitoringPointId: mp.id, + timestamp, + value: generateSeriesValue(i, 50), + }); + } + + const res = await prisma.timeSeries.createMany({ + data, + skipDuplicates: true, + }); + + inserted += res.count; + } + + console.log(`Seed time-series: inserted ${inserted} points`); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + await pool.end(); + }); \ No newline at end of file diff --git a/full-stack/apps/backend/scripts/benchmark.js b/full-stack/apps/backend/scripts/benchmark.js new file mode 100755 index 0000000000..c5eb6ffb36 --- /dev/null +++ b/full-stack/apps/backend/scripts/benchmark.js @@ -0,0 +1,169 @@ +#!/usr/bin/env node + +import { performance } from 'perf_hooks'; + +const BASE_URL = 'http://localhost:3001'; +const ENDPOINT = '/monitoring-points'; +const REQUEST_COUNT = 100; +const CONCURRENT_REQUESTS = 10; + +async function makeRequest() { + const startTime = performance.now(); + + try { + const response = await fetch(`${BASE_URL}${ENDPOINT}?take=5&skip=0&sortBy=createdAt&sortOrder=desc`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + const endTime = performance.now(); + const responseTime = endTime - startTime; + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return { + success: true, + responseTime, + status: response.status, + }; + } catch (error) { + const endTime = performance.now(); + const responseTime = endTime - startTime; + + return { + success: false, + responseTime, + error: error.message, + }; + } +} + +async function runBenchmark() { + console.log(`Starting benchmark: ${REQUEST_COUNT} requests to ${ENDPOINT}`); + console.log(`Concurrent requests: ${CONCURRENT_REQUESTS}`); + console.log('Measuring latency...\n'); + + const results = []; + + // Execute requests in concurrent batches + for (let i = 0; i < REQUEST_COUNT; i += CONCURRENT_REQUESTS) { + const batch = []; + const batchSize = Math.min(CONCURRENT_REQUESTS, REQUEST_COUNT - i); + + for (let j = 0; j < batchSize; j++) { + batch.push(makeRequest()); + } + + const batchResults = await Promise.all(batch); + results.push(...batchResults); + + // Progress indicator + const progress = Math.round((i + batchSize) / REQUEST_COUNT * 100); + process.stdout.write(`\rProgress: ${progress}% (${i + batchSize}/${REQUEST_COUNT} requests)`); + } + + console.log('\n\nBenchmark Results:'); + console.log('====================================='); + + const successfulRequests = results.filter(r => r.success); + const failedRequests = results.filter(r => !r.success); + + const responseTimes = successfulRequests.map(r => r.responseTime); + const minTime = Math.min(...responseTimes); + const maxTime = Math.max(...responseTimes); + const avgTime = responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length; + + // Calculate percentiles + const sortedTimes = responseTimes.sort((a, b) => a - b); + const p50 = sortedTimes[Math.floor(sortedTimes.length * 0.5)]; + const p95 = sortedTimes[Math.floor(sortedTimes.length * 0.95)]; + const p99 = sortedTimes[Math.floor(sortedTimes.length * 0.99)]; + + console.log(`Successful requests: ${successfulRequests.length}/${REQUEST_COUNT}`); + console.log(`Failed requests: ${failedRequests.length}/${REQUEST_COUNT}`); + console.log(`Success rate: ${((successfulRequests.length / REQUEST_COUNT) * 100).toFixed(2)}%`); + console.log(''); + console.log('Latency Statistics (ms):'); + console.log(` Minimum: ${minTime.toFixed(2)}ms`); + console.log(` Maximum: ${maxTime.toFixed(2)}ms`); + console.log(` Average: ${avgTime.toFixed(2)}ms`); + console.log(` P50: ${p50.toFixed(2)}ms`); + console.log(` P95: ${p95.toFixed(2)}ms`); + console.log(` P99: ${p99.toFixed(2)}ms`); + console.log(''); + + // Check if requirement is met + if (p95 < 350) { + console.log('RESULT: P95 < 350ms REQUIREMENT MET'); + } else { + console.log('RESULT: P95 >= 350ms REQUIREMENT NOT MET'); + } + + console.log('\nNotes:'); + console.log('- Test executed locally'); + console.log(`- ${REQUEST_COUNT} total requests`); + console.log(`- ${CONCURRENT_REQUESTS} concurrent requests per batch`); + console.log(`- Endpoint: ${ENDPOINT}`); + console.log(`- Observed P95: ${p95.toFixed(2)}ms`); + + // Save results to file + const benchmarkData = { + timestamp: new Date().toISOString(), + config: { + requestCount: REQUEST_COUNT, + concurrentRequests: CONCURRENT_REQUESTS, + endpoint: ENDPOINT, + baseUrl: BASE_URL, + }, + results: { + successful: successfulRequests.length, + failed: failedRequests.length, + successRate: (successfulRequests.length / REQUEST_COUNT) * 100, + latency: { + min: minTime, + max: maxTime, + avg: avgTime, + p50, + p95, + p99, + }, + }, + requirement: { + met: p95 < 350, + threshold: 350, + observed: p95, + }, + }; + + const fs = await import('fs/promises'); + await fs.writeFile('./benchmark-results.json', JSON.stringify(benchmarkData, null, 2)); + console.log('\nResults saved to: benchmark-results.json'); +} + +// Check if server is running +async function checkServer() { + try { + const response = await fetch(`${BASE_URL}/monitoring-points?take=1`); + if (!response.ok) { + throw new Error('Server not responding correctly'); + } + console.log('Server detected and responding\n'); + return true; + } catch (error) { + console.error('Error: Server is not running at', BASE_URL); + console.error('Please start the server with: pnpm dev'); + process.exit(1); + } +} + +// Run benchmark +async function main() { + await checkServer(); + await runBenchmark(); +} + +main().catch(console.error); diff --git a/full-stack/apps/backend/src/__tests__/helpers/auth.ts b/full-stack/apps/backend/src/__tests__/helpers/auth.ts new file mode 100644 index 0000000000..535c9f08a6 --- /dev/null +++ b/full-stack/apps/backend/src/__tests__/helpers/auth.ts @@ -0,0 +1,16 @@ +import type { FastifyInstance } from "fastify"; + +export async function loginAndGetToken(app: FastifyInstance) { + const res = await app.inject({ + method: "POST", + url: "/auth/login", + payload: { username: "admin", password: "admin" }, + }); + + if (res.statusCode !== 200) { + throw new Error(`Login failed: ${res.statusCode} ${res.body}`); + } + + const body = JSON.parse(res.body) as { token: string }; + return body.token; +} diff --git a/full-stack/apps/backend/src/__tests__/monitoringPoints.auth.test.ts b/full-stack/apps/backend/src/__tests__/monitoringPoints.auth.test.ts new file mode 100644 index 0000000000..b9942222b0 --- /dev/null +++ b/full-stack/apps/backend/src/__tests__/monitoringPoints.auth.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { buildApp } from "../app"; +import { loginAndGetToken } from "./helpers/auth"; + +describe("monitoring-points (auth)", () => { + const app = buildApp(); + + beforeAll(async () => { + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + }); + + it("requires auth", async () => { + const res = await app.inject({ + method: "GET", + url: "/monitoring-points?take=5&skip=0&sortBy=machineName&sortOrder=asc", + }); + expect(res.statusCode).toBe(401); + }); + + it("returns data with valid token", async () => { + const token = await loginAndGetToken(app); + + const res = await app.inject({ + method: "GET", + url: "/monitoring-points?take=5&skip=0&sortBy=machineName&sortOrder=asc", + headers: { authorization: `Bearer ${token}` }, + }); + + expect(res.statusCode).toBe(200); + + const body = JSON.parse(res.body) as { + items: unknown[]; + total: number; + take: number; + skip: number; + sortBy: string; + sortOrder: string; + }; + + expect(Array.isArray(body.items)).toBe(true); + expect(typeof body.total).toBe("number"); + expect(body.take).toBe(5); + expect(body.skip).toBe(0); + expect(body.sortBy).toBe("machineName"); + expect(body.sortOrder).toBe("asc"); + }); +}); diff --git a/full-stack/apps/backend/src/app.ts b/full-stack/apps/backend/src/app.ts new file mode 100644 index 0000000000..e0c5004368 --- /dev/null +++ b/full-stack/apps/backend/src/app.ts @@ -0,0 +1,72 @@ +import Fastify from "fastify"; +import cors from "@fastify/cors"; +import sensible from "@fastify/sensible"; +import swagger from "@fastify/swagger"; +import swaggerUI from "@fastify/swagger-ui"; +import jwt from "@fastify/jwt"; + +import { machinesRoutes } from "./routes/machines.js"; +import { monitoringPointsRoutes } from "./routes/monitoringPoints.js"; +import { authRoutes } from "./routes/auth.js"; +import { timeSeriesRoutes } from "./routes/timeSeries.js"; + +import os from 'os'; + +export function buildApp() { + const app = Fastify({ logger: false }); + + app.register(cors, { + origin: ['http://localhost:5173', 'http://localhost:3000'], + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'], + credentials: true + }); + app.register(sensible); + + app.register(swagger, { + openapi: { + info: { title: "Dynamox Full Stack Challenge API", version: "0.1.0" }, + }, + }); + app.register(swaggerUI, { routePrefix: "/docs" }); + + app.register(jwt, { + secret: process.env.JWT_SECRET ?? "dev-secret-change-me", + }); + + app.decorate("authenticate", async (req: any, reply: any) => { + try { + await req.jwtVerify(); + } catch { + return reply.code(401).send({ message: "Unauthorized" }); + } + }); + + app.setErrorHandler((err: any, req, reply) => { + req.log.error(err); + + const status = err.statusCode ?? 500; + + reply.status(status).send({ + error: status >= 500 ? "Internal Server Error" : err.name, + message: err.message, + statusCode: status, + }); + }); + + app.get("/health", async () => { + const hostname = os.hostname(); + const pid = process.pid; + return { + ok: true, + server: `${hostname}-${pid}`, + timestamp: new Date().toISOString() + }; + }); + app.register(authRoutes); + app.register(machinesRoutes); + app.register(monitoringPointsRoutes); + app.register(timeSeriesRoutes); + + return app; +} diff --git a/full-stack/apps/backend/src/plugins/auth.ts b/full-stack/apps/backend/src/plugins/auth.ts new file mode 100644 index 0000000000..4a0b1442c3 --- /dev/null +++ b/full-stack/apps/backend/src/plugins/auth.ts @@ -0,0 +1,16 @@ +import { FastifyInstance } from "fastify"; +import jwt from "@fastify/jwt"; + +export async function authPlugin(app: FastifyInstance) { + await app.register(jwt, { + secret: process.env.JWT_SECRET ?? "dev-secret-change-me", + }); + + app.decorate("authenticate", async (req: any, reply: any) => { + try { + await req.jwtVerify(); + } catch { + return reply.code(401).send({ message: "Unauthorized" }); + } + }); +} \ No newline at end of file diff --git a/full-stack/apps/backend/src/prisma.ts b/full-stack/apps/backend/src/prisma.ts new file mode 100644 index 0000000000..947c24a5a9 --- /dev/null +++ b/full-stack/apps/backend/src/prisma.ts @@ -0,0 +1,14 @@ +import "dotenv/config"; +import { PrismaClient } from "@prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { Pool } from "pg"; + +const url = process.env.DATABASE_URL; +if (!url) { + throw new Error("DATABASE_URL is not set. Create apps/backend/.env based on .env.example"); +} + +const pool = new Pool({ connectionString: url }); +const adapter = new PrismaPg(pool); + +export const prisma = new PrismaClient({ adapter }); \ No newline at end of file diff --git a/full-stack/apps/backend/src/routes/auth.ts b/full-stack/apps/backend/src/routes/auth.ts new file mode 100644 index 0000000000..2f97922165 --- /dev/null +++ b/full-stack/apps/backend/src/routes/auth.ts @@ -0,0 +1,31 @@ +import { FastifyInstance } from "fastify"; +import { z } from "zod"; +import bcrypt from "bcrypt"; +import { prisma } from "../prisma.js"; + +const loginSchema = z.object({ + username: z.string().min(1), + password: z.string().min(1), +}); + +export async function authRoutes(app: FastifyInstance) { + app.post("/auth/login", async (req, reply) => { + const body = loginSchema.parse(req.body); + + const user = await prisma.user.findUnique({ + where: { username: body.username }, + }); + + if (!user) return reply.code(401).send({ message: "Invalid credentials" }); + + const ok = await bcrypt.compare(body.password, user.passwordHash); + if (!ok) return reply.code(401).send({ message: "Invalid credentials" }); + + const token = await reply.jwtSign( + { sub: user.id, username: user.username, role: user.role }, + { expiresIn: "8h" } + ); + + return reply.send({ token }); + }); +} \ No newline at end of file diff --git a/full-stack/apps/backend/src/routes/machines.ts b/full-stack/apps/backend/src/routes/machines.ts new file mode 100644 index 0000000000..c4172b79df --- /dev/null +++ b/full-stack/apps/backend/src/routes/machines.ts @@ -0,0 +1,78 @@ +import { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { prisma } from "../prisma.js"; +import { createMachineSchema, updateMachineSchema } from "../schemas/machine.js"; + +const paramsSchema = z.object({ + id: z.string().min(1), +}); + +export async function machinesRoutes(app: FastifyInstance) { + app.get("/machines", { + preHandler: [app.authenticate], + }, async () => { + return prisma.machine.findMany({ + orderBy: { createdAt: "desc" }, + include: { monitoringPoints: { include: { sensor: true } } }, + }); + }); + + app.post( + "/machines", + { + preHandler: [app.authenticate], + schema: { + tags: ["Machines"], + summary: "Create a machine", + body: { + type: "object", + required: ["name", "type"], + properties: { + name: { type: "string" }, + type: { type: "string", enum: ["Pump", "Fan"] }, + }, + }, + response: { + 201: { + type: "object", + properties: { + id: { type: "string" }, + name: { type: "string" }, + type: { type: "string" }, + createdAt: { type: "string" }, + updatedAt: { type: "string" }, + }, + }, + }, + }, + }, + async (req, reply) => { + const body = createMachineSchema.parse(req.body); + const machine = await prisma.machine.create({ data: body }); + return reply.code(201).send(machine); + } + ); + + app.patch("/machines/:id", { + preHandler: [app.authenticate], + }, async (req, reply) => { + const { id } = paramsSchema.parse(req.params); + const body = updateMachineSchema.parse(req.body); + + const updated = await prisma.machine.update({ + where: { id }, + data: body, + }); + + return reply.send(updated); + }); + + app.delete("/machines/:id", { + preHandler: [app.authenticate], + }, async (req, reply) => { + const { id } = paramsSchema.parse(req.params); + + await prisma.machine.delete({ where: { id } }); + return reply.code(204).send(); + }); +} \ No newline at end of file diff --git a/full-stack/apps/backend/src/routes/monitoringPoints.ts b/full-stack/apps/backend/src/routes/monitoringPoints.ts new file mode 100644 index 0000000000..a05ff0bfde --- /dev/null +++ b/full-stack/apps/backend/src/routes/monitoringPoints.ts @@ -0,0 +1,220 @@ +import { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { prisma } from "../prisma.js"; +import { createMonitoringPointSchema, updateMonitoringPointSchema } from "../schemas/monitoringPoint.js"; + +const paramsSchema = z.object({ + machineId: z.string().min(1), + id: z.string().min(1), +}); + +const monitoringPointIdSchema = z.object({ + id: z.string().min(1), +}); + +const listQuerySchema = z.object({ + take: z.coerce.number().int().min(1).max(50).default(5), + skip: z.coerce.number().int().min(0).default(0), + sortBy: z.enum(["machineName", "machineType", "monitoringPointName", "sensorModel", "createdAt", "id"]).default("createdAt"), + sortOrder: z.enum(["asc", "desc"]).default("asc"), +}); + +const forbiddenForPump = new Set(["TcAg", "TcAs"]); + +function mapSort(sortBy: string, sortOrder: "asc" | "desc") { + const order = sortOrder ?? "asc"; + switch (sortBy) { + case "machineName": + return { machine: { name: order } } as const; + case "machineType": + return { machine: { type: order } } as const; + case "monitoringPointName": + return { name: order } as const; + case "sensorModel": + return { sensor: { model: order } } as const; + case "createdAt": + default: + return { createdAt: order } as const; + } +} + +export async function monitoringPointsRoutes(app: FastifyInstance) { + // Hook para medir tempo de resposta + app.addHook('onResponse', async (request: any, reply: any) => { + const responseTime = reply.getResponseTime(); + console.log(`${request.method} ${request.url} - ${responseTime.toFixed(2)}ms`); + }); + + const machineIdSchema = z.object({ + machineId: z.string().min(1), + }); + + // Create monitoring point for a machine (with sensor) + app.post("/machines/:machineId/monitoring-points", { + preHandler: [app.authenticate], + }, async (req, reply) => { + const { machineId } = machineIdSchema.parse(req.params); + const body = createMonitoringPointSchema.parse(req.body); + + const machine = await prisma.machine.findUnique({ where: { id: machineId } }); + if (!machine) return reply.notFound("Machine not found"); + + if (machine.type === "Pump" && forbiddenForPump.has(body.sensor.model)) { + return reply.badRequest("Sensors TcAg and TcAs are not allowed for Pump machines"); + } + + try { + const created = await prisma.monitoringPoint.create({ + data: { + name: body.name, + machineId, + sensor: { + create: { + uniqueId: body.sensor.uniqueId, + model: body.sensor.model, + }, + }, + }, + include: { machine: true, sensor: true }, + }); + + return reply.code(201).send(created); + } catch (error: any) { + if (error.code === 'P2002' && error.meta?.target?.includes('name')) { + return reply.badRequest(`A monitoring point with name "${body.name}" already exists for this machine`); + } + throw error; + } + }); + + app.get("/monitoring-points", { + preHandler: [app.authenticate], + }, async (req) => { + const query = listQuerySchema.parse(req.query); + + const { take, skip, sortBy, sortOrder } = query; + + const orderBy = (() => { + switch (sortBy) { + case "machineName": + return [ + { machine: { name: sortOrder } }, + { id: sortOrder as "asc" | "desc" } + ]; + case "machineType": + return [ + { machine: { type: sortOrder } }, + { id: sortOrder as "asc" | "desc" } + ]; + case "monitoringPointName": + return [ + { name: sortOrder }, + { id: sortOrder as "asc" | "desc" } + ]; + case "sensorModel": + return [ + { sensor: { model: sortOrder } }, + { id: sortOrder as "asc" | "desc" } + ]; + case "createdAt": + return [ + { createdAt: sortOrder }, + { id: sortOrder as "asc" | "desc" } + ]; + case "id": + return [ + { id: sortOrder } + ]; + default: + return [ + { id: "asc" as const } + ]; + } + })(); + + const [items, total] = await Promise.all([ + prisma.monitoringPoint.findMany({ + skip, + take, + orderBy, + include: { machine: true, sensor: true }, + }), + prisma.monitoringPoint.count(), + ]); + + return { + items: items.map((mp: any) => ({ + id: mp.id, + monitoringPointName: mp.name, + machineName: mp.machine.name, + machineType: mp.machine.type, + sensorModel: mp.sensor?.model ?? null, + sensorUniqueId: mp.sensor?.uniqueId ?? null, + createdAt: mp.createdAt, + })), + total, + take, + skip, + sortBy, + sortOrder, + }; + }); + + app.patch("/monitoring-points/:id", { + preHandler: [app.authenticate], + }, async (req, reply) => { + const { id } = monitoringPointIdSchema.parse(req.params); + const body = updateMonitoringPointSchema.parse(req.body); + + const current = await prisma.monitoringPoint.findUnique({ + where: { id }, + include: { machine: true, sensor: true }, + }); + if (!current) return reply.notFound("Monitoring point not found"); + + const nextSensorModel = body.sensor?.model ?? current.sensor?.model ?? null; + if (current.machine.type === "Pump" && nextSensorModel && forbiddenForPump.has(nextSensorModel)) { + return reply.badRequest("Sensors TcAg and TcAs are not allowed for Pump machines"); + } + + try { + const updated = await prisma.monitoringPoint.update({ + where: { id }, + data: { + name: body.name, + sensor: body.sensor + ? current.sensor + ? { + update: { + uniqueId: body.sensor.uniqueId, + model: body.sensor.model, + }, + } + : { + create: { + uniqueId: body.sensor.uniqueId ?? crypto.randomUUID(), + model: body.sensor.model ?? "HF_plus", + }, + } + : undefined, + }, + include: { machine: true, sensor: true }, + }); + + return reply.send(updated); + } catch (error: any) { + if (error.code === 'P2002' && error.meta?.target?.includes('name')) { + return reply.badRequest(`A monitoring point with name "${body.name}" already exists for this machine`); + } + throw error; + } + }); + + app.delete("/monitoring-points/:id", { + preHandler: [app.authenticate], + }, async (req, reply) => { + const { id } = monitoringPointIdSchema.parse(req.params); + await prisma.monitoringPoint.delete({ where: { id } }); + return reply.code(204).send(); + }); +} \ No newline at end of file diff --git a/full-stack/apps/backend/src/routes/timeSeries.ts b/full-stack/apps/backend/src/routes/timeSeries.ts new file mode 100644 index 0000000000..15b14f62ec --- /dev/null +++ b/full-stack/apps/backend/src/routes/timeSeries.ts @@ -0,0 +1,212 @@ +import { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { prisma } from "../prisma.js"; +import type { FastifyRequest } from "fastify"; + +const paramsSchema = z.object({ + id: z.string().min(1), +}); + +const createSchema = z.object({ + timestamp: z.string().datetime(), + value: z.number(), +}); + +const listQuerySchema = z.object({ + from: z.string().datetime().optional(), + to: z.string().datetime().optional(), + take: z.coerce.number().int().min(1).max(500).default(100), + skip: z.coerce.number().int().min(0).default(0), +}); + +export async function timeSeriesRoutes(app: FastifyInstance) { + app.post("/monitoring-points/:id/time-series", { preHandler: [app.authenticate] }, async (req, reply) => { + const { id } = paramsSchema.parse(req.params); + const body = createSchema.parse(req.body); + + const mp = await prisma.monitoringPoint.findUnique({ where: { id } }); + if (!mp) return reply.code(404).send({ message: "Monitoring point not found" }); + + const created = await prisma.timeSeries.create({ + data: { + monitoringPointId: id, + timestamp: new Date(body.timestamp), + value: body.value, + }, + }); + + return reply.code(201).send(created); + }); + + app.get("/monitoring-points/:id/time-series", { preHandler: [app.authenticate] }, async (req, reply) => { + const { id } = paramsSchema.parse(req.params); + const query = listQuerySchema.parse(req.query); + + const mp = await prisma.monitoringPoint.findUnique({ where: { id } }); + if (!mp) return reply.code(404).send({ message: "Monitoring point not found" }); + + const where = { + monitoringPointId: id, + ...(query.from || query.to + ? { + timestamp: { + ...(query.from ? { gte: new Date(query.from) } : {}), + ...(query.to ? { lte: new Date(query.to) } : {}), + }, + } + : {}), + }; + + const [items, total] = await Promise.all([ + prisma.timeSeries.findMany({ + where, + orderBy: { timestamp: "asc" }, + take: query.take, + skip: query.skip, + }), + prisma.timeSeries.count({ where }), + ]); + + return reply.send({ items, total, take: query.take, skip: query.skip }); + }); + + app.get("/monitoring-points/:id/time-series/metrics", { preHandler: [app.authenticate] }, async (req, reply) => { + const { id } = paramsSchema.parse(req.params); + const query = z + .object({ + from: z.string().datetime().optional(), + to: z.string().datetime().optional(), + }) + .parse(req.query); + + const mp = await prisma.monitoringPoint.findUnique({ where: { id } }); + if (!mp) return reply.code(404).send({ message: "Monitoring point not found" }); + + const where = { + monitoringPointId: id, + ...(query.from || query.to + ? { + timestamp: { + ...(query.from ? { gte: new Date(query.from) } : {}), + ...(query.to ? { lte: new Date(query.to) } : {}), + }, + } + : {}), + }; + + const [count, agg] = await Promise.all([ + prisma.timeSeries.count({ where }), + prisma.timeSeries.aggregate({ + where, + _min: { value: true }, + _max: { value: true }, + _avg: { value: true }, + }), + ]); + + return reply.send({ + count, + min: agg._min.value, + max: agg._max.value, + avg: agg._avg.value, + }); + }); + + app.get("/time-series/count", { preHandler: [app.authenticate] }, async (req, reply) => { + const query = z + .object({ + monitoringPointId: z.string().optional(), + }) + .parse(req.query); + + const where = query.monitoringPointId + ? { monitoringPointId: query.monitoringPointId } + : {}; + + const count = await prisma.timeSeries.count({ where }); + + return reply.send({ + count, + ...(query.monitoringPointId ? { monitoringPointId: query.monitoringPointId } : {}) + }); + }); + + app.delete("/monitoring-points/:id/time-series", { preHandler: [app.authenticate] }, async (req, reply) => { + const { id } = paramsSchema.parse(req.params); + + const mp = await prisma.monitoringPoint.findUnique({ where: { id } }); + if (!mp) return reply.code(404).send({ message: "Monitoring point not found" }); + + const result = await prisma.timeSeries.deleteMany({ + where: { monitoringPointId: id }, + }); + + return reply.send({ deleted: result.count }); + }); + + app.get("/monitoring-points/:id/time-series/predict", { + preHandler: [app.authenticate], + }, async (req, reply) => { + const { id } = paramsSchema.parse(req.params); + const query = z.object({ + periods: z.coerce.number().int().min(1).max(100).default(10), + interval: z.enum(["15min", "1hour", "1day"]).default("15min"), + }).parse(req.query); + + const historicalData = await prisma.timeSeries.findMany({ + where: { monitoringPointId: id }, + orderBy: { timestamp: "asc" }, + take: 100, + }); + + if (historicalData.length < 10) { + return reply.badRequest("Not enough historical data for prediction (minimum 10 points required)"); + } + + const values = historicalData.map(d => d.value); + const n = values.length; + + const sumX = Array.from({ length: n }, (_, i) => i).reduce((a, b) => a + b, 0); + const sumY = values.reduce((a, b) => a + b, 0); + const sumXY = Array.from({ length: n }, (_, i) => i * values[i]).reduce((a, b) => a + b, 0); + const sumX2 = Array.from({ length: n }, (_, i) => i * i).reduce((a, b) => a + b, 0); + + const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX); + const intercept = (sumY - slope * sumX) / n; + + const predictions = []; + const lastTimestamp = historicalData[historicalData.length - 1].timestamp; + const intervalMs = query.interval === "15min" ? 15 * 60 * 1000 : + query.interval === "1hour" ? 60 * 60 * 1000 : + 24 * 60 * 60 * 1000; + + for (let i = 1; i <= query.periods; i++) { + const futureValue = slope * (n + i - 1) + intercept; + const futureTimestamp = new Date(lastTimestamp.getTime() + i * intervalMs); + + const randomVariation = 0.95 + Math.random() * 0.1; + const adjustedValue = Math.max(0, futureValue * randomVariation); + + predictions.push({ + timestamp: futureTimestamp.toISOString(), + predictedValue: Math.round(adjustedValue * 100) / 100, + confidence: Math.max(0.5, 1 - (i * 0.05)), + }); + } + + return reply.send({ + monitoringPointId: id, + predictionInterval: query.interval, + periods: query.periods, + basedOnDataPoints: historicalData.length, + predictions, + metadata: { + algorithm: "Linear Regression", + slope: Math.round(slope * 1000) / 1000, + intercept: Math.round(intercept * 1000) / 1000, + lastActualValue: historicalData[historicalData.length - 1].value, + firstPredictedValue: predictions[0].predictedValue, + } + }); + }); +} \ No newline at end of file diff --git a/full-stack/apps/backend/src/schemas/machine.ts b/full-stack/apps/backend/src/schemas/machine.ts new file mode 100644 index 0000000000..23ca4c1b74 --- /dev/null +++ b/full-stack/apps/backend/src/schemas/machine.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const machineTypeSchema = z.enum(["Pump", "Fan"]); + +export const createMachineSchema = z.object({ + name: z.string().min(1).max(120), + type: machineTypeSchema, +}); + +export const updateMachineSchema = createMachineSchema.partial(); \ No newline at end of file diff --git a/full-stack/apps/backend/src/schemas/monitoringPoint.ts b/full-stack/apps/backend/src/schemas/monitoringPoint.ts new file mode 100644 index 0000000000..156696a64d --- /dev/null +++ b/full-stack/apps/backend/src/schemas/monitoringPoint.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +export const sensorModelSchema = z.enum(["TcAg", "TcAs", "HF_plus"]); + +export const createMonitoringPointSchema = z.object({ + name: z.string().min(1).max(120), + sensor: z.object({ + uniqueId: z.string().min(1).max(80), + model: sensorModelSchema, + }), +}); + +export const updateMonitoringPointSchema = z.object({ + name: z.string().min(1).max(120).optional(), + sensor: z + .object({ + uniqueId: z.string().min(1).max(80).optional(), + model: sensorModelSchema.optional(), + }) + .optional(), +}); \ No newline at end of file diff --git a/full-stack/apps/backend/src/scripts/migrate-seed.ts b/full-stack/apps/backend/src/scripts/migrate-seed.ts new file mode 100644 index 0000000000..a0438b08e9 --- /dev/null +++ b/full-stack/apps/backend/src/scripts/migrate-seed.ts @@ -0,0 +1,33 @@ +import { PrismaClient } from '@prisma/client'; +import { execSync } from 'child_process'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('Running migrations...'); + try { + execSync('npx prisma migrate deploy', { stdio: 'inherit' }); + console.log('Migrations completed'); + } catch (error) { + console.error('Migration failed:', error); + process.exit(1); + } + + console.log('Seeding database...'); + try { + execSync('npx prisma db seed', { stdio: 'inherit' }); + console.log('Seeding completed'); + } catch (error) { + console.error('Seeding failed:', error); + process.exit(1); + } +} + +main() + .catch((error) => { + console.error('Unexpected error:', error); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/full-stack/apps/backend/src/server.ts b/full-stack/apps/backend/src/server.ts new file mode 100644 index 0000000000..4fadacd81f --- /dev/null +++ b/full-stack/apps/backend/src/server.ts @@ -0,0 +1,12 @@ +import { buildApp } from "./app.js"; + +const app = buildApp(); + +app.addHook('preHandler', async (request, reply) => { + reply.header('Access-Control-Allow-Origin', '*'); + reply.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + reply.header('Access-Control-Allow-Credentials', 'true'); +}); + +app.listen({ port: Number(process.env.PORT ?? 3001), host: "0.0.0.0" }); \ No newline at end of file diff --git a/full-stack/apps/backend/src/tests/auth.test.ts b/full-stack/apps/backend/src/tests/auth.test.ts new file mode 100644 index 0000000000..b2c576bfe9 --- /dev/null +++ b/full-stack/apps/backend/src/tests/auth.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { buildApp } from "../app"; + +describe("auth", () => { + const app = buildApp(); + + beforeAll(async () => { + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + }); + + it("GET /machines requires auth", async () => { + const res = await app.inject({ method: "GET", url: "/machines" }); + expect(res.statusCode).toBe(401); + }); + + it("POST /auth/login returns token for valid credentials (seed required)", async () => { + const res = await app.inject({ + method: "POST", + url: "/auth/login", + payload: { username: "admin", password: "admin" }, + }); + + expect(res.statusCode).toBe(200); + + const body = JSON.parse(res.body); + expect(typeof body.token).toBe("string"); + expect(body.token.length).toBeGreaterThan(20); + }); +}); diff --git a/full-stack/apps/backend/src/tests/health.test.ts b/full-stack/apps/backend/src/tests/health.test.ts new file mode 100644 index 0000000000..bd7bf424e8 --- /dev/null +++ b/full-stack/apps/backend/src/tests/health.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { buildApp } from "../app"; + +describe("health", () => { + const app = buildApp(); + + beforeAll(async () => { + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + }); + + it("GET /health returns ok", async () => { + const res = await app.inject({ method: "GET", url: "/health" }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.ok).toBe(true); + expect(body.server).toMatch(/^[a-zA-Z0-9-]+$/); + expect(body.timestamp).toBeDefined(); + }); +}); diff --git a/full-stack/apps/backend/src/types/fastify.ts b/full-stack/apps/backend/src/types/fastify.ts new file mode 100644 index 0000000000..f7673a801f --- /dev/null +++ b/full-stack/apps/backend/src/types/fastify.ts @@ -0,0 +1,9 @@ +import { FastifyRequest, FastifyReply } from "fastify"; + +declare module "fastify" { + interface FastifyInstance { + authenticate(request: FastifyRequest, reply: FastifyReply): Promise; + } +} + +export {}; diff --git a/full-stack/apps/backend/tsconfig.json b/full-stack/apps/backend/tsconfig.json new file mode 100644 index 0000000000..ea02a24985 --- /dev/null +++ b/full-stack/apps/backend/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*.ts", "src/scripts/**/*.ts"], + "exclude": ["node_modules", "dist", "prisma.config.ts"] +} \ No newline at end of file diff --git a/full-stack/apps/backend/vitest.config.ts b/full-stack/apps/backend/vitest.config.ts new file mode 100644 index 0000000000..9478bc85e1 --- /dev/null +++ b/full-stack/apps/backend/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + include: ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/__tests__/**/*.ts", "src/**/__tests__/**/*.tsx"], + exclude: ["dist/**", "node_modules/**", "src/**/__tests__/helpers/**"], + }, +}); diff --git a/full-stack/apps/frontend/.dockerignore b/full-stack/apps/frontend/.dockerignore new file mode 100644 index 0000000000..76c2b61b24 --- /dev/null +++ b/full-stack/apps/frontend/.dockerignore @@ -0,0 +1,9 @@ +node_modules +dist +.env +.env.local +.env.*.local +*.log +.DS_Store +.vscode +.idea diff --git a/full-stack/apps/frontend/.env.example b/full-stack/apps/frontend/.env.example new file mode 100644 index 0000000000..455836569f --- /dev/null +++ b/full-stack/apps/frontend/.env.example @@ -0,0 +1 @@ +VITE_API_BASE_URL=http://localhost:3001 diff --git a/full-stack/apps/frontend/.gitignore b/full-stack/apps/frontend/.gitignore new file mode 100644 index 0000000000..a547bf36d8 --- /dev/null +++ b/full-stack/apps/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/full-stack/apps/frontend/Dockerfile b/full-stack/apps/frontend/Dockerfile new file mode 100644 index 0000000000..3a1bf2508b --- /dev/null +++ b/full-stack/apps/frontend/Dockerfile @@ -0,0 +1,21 @@ +FROM node:20-alpine AS build + +WORKDIR /app + +# Copia package.json e instala dependências +COPY package.json ./ +RUN npm install + +# Copia código +COPY . . + +ARG VITE_API_BASE_URL=http://localhost:3001 +ENV VITE_API_BASE_URL=$VITE_API_BASE_URL + +RUN npm run build + +FROM nginx:alpine + +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 diff --git a/full-stack/apps/frontend/README.md b/full-stack/apps/frontend/README.md new file mode 100644 index 0000000000..2258cac411 --- /dev/null +++ b/full-stack/apps/frontend/README.md @@ -0,0 +1,182 @@ + +# Dynamox Full Stack Challenge + +This repository contains my solution for the Dynamox Full Stack challenge. +It includes a full backend API, database, authentication system, and a React frontend. + +## Tech Stack + +- Backend: Node.js, TypeScript, Fastify, Prisma, JWT +- Frontend: React, TypeScript, Vite, Material UI +- Database: PostgreSQL (Docker) +- Package manager: pnpm workspace + +## Project Structure + +``` +full-stack/ +├── apps/ +│ ├── backend/ # Fastify API + Prisma + Auth +│ └── frontend/ # React UI +├── docker-compose.yml # PostgreSQL database +├── pnpm-workspace.yaml +└── package.json +``` + +## Requirements + +- Node.js >= 18 +- Docker + Docker Compose +- pnpm + +## Environment Variables + +Create the backend environment file: + +```bash +cp apps/backend/.env.example apps/backend/.env +``` + +Main variables: + +- DATABASE_URL — PostgreSQL connection string +- JWT_SECRET — JWT signing secret +- SEED_ADMIN_USERNAME — admin username created by seed +- SEED_ADMIN_PASSWORD — admin password created by seed + +Login credentials are validated against the database. +The seed script creates or updates the admin user. + +## Full Setup + +From the repository root: + +```bash +cd full-stack +pnpm install +docker compose up -d +``` + +Run database migrations and seed data: + +```bash +cd apps/backend +pnpm prisma:migrate +pnpm prisma:seed +pnpm exec prisma generate +``` + +## Running the Applications + +Start backend: + +```bash +cd apps/backend +pnpm dev +``` + +Backend runs at: + +``` +http://localhost:3001 +``` + +Start frontend (in a new terminal): + +```bash +cd full-stack +pnpm --filter frontend dev +``` + +Frontend runs at: + +``` +http://localhost:5173 +``` + +## API Access + +Backend base URL: + +``` +http://localhost:3001 +``` + +Available endpoints: + +- Health check: GET /health +- Authentication: POST /auth/login +- Machines endpoints +- Monitoring points endpoints + +Swagger documentation: + +``` +http://localhost:3001/docs +``` + +## Authentication + +Login endpoint: + +``` +POST /auth/login +``` + +Default credentials (created by seed): + +- username: admin +- password: admin + +Example login test: + +```bash +curl -s -X POST http://localhost:3001/auth/login -H "Content-Type: application/json" -d '{"username":"admin","password":"admin"}' +``` + +Use returned token: + +```bash +TOKEN="PASTE_TOKEN_HERE" +curl -i http://localhost:3001/machines -H "Authorization: Bearer $TOKEN" +``` + +## Database + +PostgreSQL container settings: + +- Host: localhost +- Port: 5433 +- Database: dynamox +- User: dynamox +- Password: dynamox + +## Useful Commands + +Backend: + +```bash +cd apps/backend +pnpm dev +pnpm build +pnpm prisma:migrate +pnpm prisma:seed +pnpm prisma:studio +``` + +Frontend: + +```bash +cd apps/frontend +pnpm dev +pnpm build +pnpm preview +``` + +Database: + +```bash +docker compose up -d +docker compose down -v +docker compose logs -f +``` \ No newline at end of file diff --git a/full-stack/apps/frontend/cypress.config.ts b/full-stack/apps/frontend/cypress.config.ts new file mode 100644 index 0000000000..cff8643ec0 --- /dev/null +++ b/full-stack/apps/frontend/cypress.config.ts @@ -0,0 +1,122 @@ +import { defineConfig } from "cypress"; + +export default defineConfig({ + // General settings + allowCypressEnv: false, + video: false, + screenshotOnRunFailure: true, + viewportWidth: 1280, + viewportHeight: 720, + defaultCommandTimeout: 10000, + requestTimeout: 10000, + responseTimeout: 10000, + + // Configuration for E2E tests + e2e: { + // Base URL for tests + baseUrl: 'http://localhost:5173', + + // Support files + supportFile: 'cypress/support/e2e.ts', + + // Test specs pattern + specPattern: 'cypress/e2e/**/*.cy.ts', + + // Fixtures for test data + fixturesFolder: 'cypress/fixtures', + + // Videos and screenshots + videosFolder: 'cypress/videos', + screenshotsFolder: 'cypress/screenshots', + + // Environment configuration + env: { + // Backend URL for tests + backendUrl: 'http://localhost:3001', + + // Default credentials for tests + defaultUsername: 'admin', + defaultPassword: 'admin', + + // Timeout for async operations + asyncTimeout: 5000, + + // Retry settings + retryAttempts: 3, + + // Pagination settings + defaultPageSize: 5, + + // Time configurations + timeSeriesIntervals: { + '24h': 24 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, + '30d': 30 * 24 * 60 * 60 * 1000, + } + }, + + // Custom event listeners + setupNodeEvents(on, config) { + // Plugin for custom reports + on('after:spec', (spec, results) => { + if (results.stats.failures === 0) { + console.log(`Test passed: ${spec.name}`); + } else { + console.log(`Test failed: ${spec.name} - ${results.stats.failures} failures`); + } + }); + + // Plugin for data cleanup between tests + on('task', { + // Clear backend data between tests + async clearTestData() { + // Implement cleanup if needed + return null; + }, + + // Get test token + async getTestToken() { + const response = await fetch('http://localhost:3001/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: config.env.defaultUsername, + password: config.env.defaultPassword, + }), + }); + + const data = await response.json(); + return data.token; + }, + + // Check backend health + async checkBackendHealth() { + try { + const response = await fetch('http://localhost:3001/health'); + return response.ok; + } catch { + return false; + } + }, + }); + + // Configure reporters + config.reporter = 'spec'; + config.reporterOptions = { + reportDir: 'cypress/reports', + reportFilename: 'cypress-report', + overwrite: false, + }; + + return config; + }, + }, + + // Configuration for component tests (if needed in the future) + component: { + devServer: { + framework: 'react', + bundler: 'vite', + }, + }, +}); diff --git a/full-stack/apps/frontend/cypress/e2e/auth-and-monitoring.cy.ts b/full-stack/apps/frontend/cypress/e2e/auth-and-monitoring.cy.ts new file mode 100644 index 0000000000..eb4c1ea720 --- /dev/null +++ b/full-stack/apps/frontend/cypress/e2e/auth-and-monitoring.cy.ts @@ -0,0 +1,89 @@ +describe('Auth and Monitoring Points Flow', () => { + beforeEach(() => { + cy.clearLocalStorage(); + cy.visit('/'); + }); + + it('should show login page', () => { + cy.url().should('include', '/login'); + cy.get('input[name="username"]').should('be.visible'); + cy.get('input[name="password"]').should('be.visible'); + cy.get('button[type="submit"]').should('be.visible'); + }); + + it('should login successfully with admin credentials', () => { + cy.get('input[name="username"]').type('admin'); + cy.get('input[name="password"]').type('admin'); + cy.get('button[type="submit"]').click(); + + cy.url().should('not.include', '/login'); + cy.url().should('eq', 'http://localhost:5173/'); + + cy.get('h1, h2, h3, h4, h5, h6').should('contain', 'Monitoring Points'); + }); + + it('should show monitoring points table', () => { + cy.get('input[name="username"]').type('admin'); + cy.get('input[name="password"]').type('admin'); + cy.get('button[type="submit"]').click(); + + cy.get('[role="grid"]').should('be.visible'); + + cy.get('[role="grid"]').should('contain', 'Machine'); + cy.get('[role="grid"]').should('contain', 'Type'); + cy.get('[role="grid"]').should('contain', 'MP Name'); + cy.get('[role="grid"]').should('contain', 'Sensor'); + cy.get('[role="grid"]').should('contain', 'Actions'); + }); + + it('should handle empty state gracefully', () => { + + cy.get('input[name="username"]').type('admin'); + cy.get('input[name="password"]').type('admin'); + cy.get('button[type="submit"]').click(); + + cy.get('[role="grid"]').should('contain', 'No monitoring points found'); + cy.get('[role="grid"]').should('contain', 'Run seed script to generate sample data'); + }); + + it('should navigate to machines page', () => { + + cy.get('input[name="username"]').type('admin'); + cy.get('input[name="password"]').type('admin'); + cy.get('button[type="submit"]').click(); + + + cy.get('button').contains('Machines').click(); + + cy.url().should('include', '/machines'); + cy.get('h1, h2, h3, h4, h5, h6').should('contain', 'Machines'); + }); + + it('should validate authentication state', () => { + + cy.get('input[name="username"]').type('admin'); + cy.get('input[name="password"]').type('admin'); + cy.get('button[type="submit"]').click(); + + cy.window().then((win) => { + expect(win.localStorage.getItem('token')).to.exist; + }); + + cy.visit('/login'); + cy.url().should('eq', 'http://localhost:5173/'); + }); + + it('should handle logout correctly', () => { + cy.get('input[name="username"]').type('admin'); + cy.get('input[name="password"]').type('admin'); + cy.get('button[type="submit"]').click(); + + cy.get('button').contains('Logout').click(); + + cy.url().should('include', '/login'); + + cy.window().then((win) => { + expect(win.localStorage.getItem('token')).to.be.null; + }); + }); +}); diff --git a/full-stack/apps/frontend/cypress/e2e/basic-auth.cy.ts b/full-stack/apps/frontend/cypress/e2e/basic-auth.cy.ts new file mode 100644 index 0000000000..7e8c7b2032 --- /dev/null +++ b/full-stack/apps/frontend/cypress/e2e/basic-auth.cy.ts @@ -0,0 +1,30 @@ +describe('Basic E2E Tests', () => { + beforeEach(() => { + // Visit the application + cy.visit('/') + }) + + it('should load the login page', () => { + cy.url().should('include', '/login') + cy.get('body').should('contain', 'Sign in') + }) + + it('should login with valid credentials', () => { + cy.get('input[type="text"]').type('admin') + cy.get('input[type="password"]').type('admin') + cy.get('button[type="submit"]').click() + + // Should redirect to main page + cy.url().should('not.include', '/login') + cy.get('body').should('contain', 'Monitoring Points') + }) + + it('should show error with invalid credentials', () => { + cy.get('input[type="text"]').type('wrong') + cy.get('input[type="password"]').type('wrong') + cy.get('button[type="submit"]').click() + + // Should show error message + cy.get('body').should('contain', 'Invalid credentials') + }) +}) diff --git a/full-stack/apps/frontend/cypress/e2e/spec.cy.ts b/full-stack/apps/frontend/cypress/e2e/spec.cy.ts new file mode 100644 index 0000000000..322992ce19 --- /dev/null +++ b/full-stack/apps/frontend/cypress/e2e/spec.cy.ts @@ -0,0 +1,5 @@ +describe('template spec', () => { + it('passes', () => { + cy.visit('https://example.cypress.io') + }) +}) \ No newline at end of file diff --git a/full-stack/apps/frontend/cypress/e2e/time-series.cy.ts b/full-stack/apps/frontend/cypress/e2e/time-series.cy.ts new file mode 100644 index 0000000000..eae216400f --- /dev/null +++ b/full-stack/apps/frontend/cypress/e2e/time-series.cy.ts @@ -0,0 +1,183 @@ +/// + +describe('Time Series Data Flow', () => { + beforeEach(() => { + cy.clearLocalStorage(); + cy.visit('/'); + }); + + beforeEach(() => { + cy.get('input[name="username"]').type('admin'); + cy.get('input[name="password"]').type('admin'); + cy.get('button[type="submit"]').click(); + + cy.get('[role="grid"]').should('be.visible'); + }); + + it('should open time series drawer when clicking on a monitoring point row', () => { + cy.get('[role="grid"]').then(($grid) => { + if ($grid.find('[role="row"]').length > 0) { + cy.get('[role="row"]').first().click(); + } else { + cy.request('GET', 'http://localhost:3001/monitoring-points') + .then((response) => { + if (response.body && response.body.items && response.body.items.length > 0) { + const firstMP = response.body.items[0]; + cy.get('[role="grid"]').should('contain', firstMP.monitoringPointName); + cy.get('[role="row"]').first().click(); + } else { + cy.log('No monitoring points found, skipping time series test'); + return; + } + }); + } + }); + + cy.get('.MuiDrawer-root').should('be.visible'); + cy.get('.MuiDrawer-root').should('contain', 'Time-series'); + }); + + it('should load and display time series data', () => { + cy.get('[role="grid"]').then(($grid) => { + if ($grid.find('[role="row"]').length > 0) { + cy.get('[role="row"]').first().click(); + } else { + cy.request('GET', 'http://localhost:3001/monitoring-points') + .then((response) => { + if (response.body && response.body.items && response.body.items.length > 0) { + const firstMP = response.body.items[0]; + cy.get('[role="grid"]').should('contain', firstMP.monitoringPointName); + cy.get('[role="row"]').first().click(); + } else { + cy.log('No monitoring points found, skipping time series test'); + return; + } + }); + } + }); + + cy.get('.MuiDrawer-root', { timeout: 10000 }).should('be.visible'); + + cy.get('.recharts-wrapper').should('be.visible'); + cy.get('.recharts-line').should('be.visible'); + + cy.contains('Total Points:').should('be.visible'); + cy.contains(/Min:/).should('be.visible'); + cy.contains(/Max:/).should('be.visible'); + cy.contains(/Avg:/).should('be.visible'); + }); + + it('should validate time series API responses', () => { + cy.get('[role="grid"]').then(($grid) => { + if ($grid.find('[role="row"]').length > 0) { + cy.get('[role="row"]').first().invoke('attr', 'data-row-id').then((rowId) => { + if (rowId) { + cy.request('GET', `http://localhost:3001/monitoring-points/${rowId}/time-series?take=10`) + .then((response) => { + expect(response.status).to.eq(200); + expect(response.body).to.have.property('items'); + expect(response.body).to.have.property('total'); + expect(response.body.items).to.be.an('array'); + }); + + cy.request('GET', `http://localhost:3001/monitoring-points/${rowId}/time-series/metrics`) + .then((response) => { + expect(response.status).to.eq(200); + expect(response.body).to.have.property('count'); + expect(response.body).to.have.property('min'); + expect(response.body).to.have.property('max'); + expect(response.body).to.have.property('avg'); + }); + } + }); + } else { + cy.request('GET', 'http://localhost:3001/monitoring-points') + .then((response) => { + if (response.body && response.body.items && response.body.items.length > 0) { + const firstMP = response.body.items[0]; + + cy.request('GET', `http://localhost:3001/monitoring-points/${firstMP.id}/time-series?take=10`) + .then((response) => { + expect(response.status).to.eq(200); + expect(response.body).to.have.property('items'); + expect(response.body).to.have.property('total'); + }); + + cy.request('GET', `http://localhost:3001/monitoring-points/${firstMP.id}/time-series/metrics`) + .then((response) => { + expect(response.status).to.eq(200); + expect(response.body).to.have.property('count'); + expect(response.body).to.have.property('min'); + expect(response.body).to.have.property('max'); + expect(response.body).to.have.property('avg'); + }); + } + }); + } + }); + }); + + it('should handle time range selection', () => { + cy.get('[role="grid"]').then(($grid) => { + if ($grid.find('[role="row"]').length > 0) { + cy.get('[role="row"]').first().click(); + } else { + cy.request('GET', 'http://localhost:3001/monitoring-points') + .then((response) => { + if (response.body && response.body.items && response.body.items.length > 0) { + const firstMP = response.body.items[0]; + cy.get('[role="grid"]').should('contain', firstMP.monitoringPointName); + cy.get('[role="row"]').first().click(); + } else { + cy.log('No monitoring points found, skipping time range test'); + return; + } + }); + } + }); + + cy.get('.MuiDrawer-root', { timeout: 10000 }).should('be.visible'); + + cy.get('label').contains('Time Range').should('be.visible'); + cy.get('[role="combobox"]').should('be.visible'); + + const timeRanges = ['Last 24 hours', 'Last 7 days', 'Last 30 days']; + + timeRanges.forEach((range) => { + cy.get('[role="combobox"]').click(); + cy.get('[role="option"]').contains(range).click(); + cy.get('.MuiDrawer-root').should('be.visible'); + + cy.get('.recharts-wrapper').should('be.visible'); + }); + }); + + it('should validate global time series count endpoint', () => { + cy.get('input[name="username"]').type('admin'); + cy.get('input[name="password"]').type('admin'); + cy.get('button[type="submit"]').click(); + + cy.request('GET', 'http://localhost:3001/time-series/count') + .then((response) => { + expect(response.status).to.eq(200); + expect(response.body).to.have.property('count'); + expect(response.body.count).to.be.a('number'); + expect(response.body.count).to.be.greaterThan(0); + }); + + cy.get('[role="grid"]').then(($grid) => { + if ($grid.find('[role="row"]').length > 0) { + cy.get('[role="row"]').first().invoke('attr', 'data-row-id').then((rowId) => { + if (rowId) { + cy.request('GET', `http://localhost:3001/time-series/count?monitoringPointId=${rowId}`) + .then((response) => { + expect(response.status).to.eq(200); + expect(response.body).to.have.property('count'); + expect(response.body).to.have.property('monitoringPointId', rowId); + }); + } + }); + } + }); +}); +}); diff --git a/full-stack/apps/frontend/cypress/fixtures/example.json b/full-stack/apps/frontend/cypress/fixtures/example.json new file mode 100644 index 0000000000..02e4254378 --- /dev/null +++ b/full-stack/apps/frontend/cypress/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/full-stack/apps/frontend/cypress/fixtures/test-data.json b/full-stack/apps/frontend/cypress/fixtures/test-data.json new file mode 100644 index 0000000000..7bc584cc81 --- /dev/null +++ b/full-stack/apps/frontend/cypress/fixtures/test-data.json @@ -0,0 +1 @@ +{"testUser": {"username": "admin", "password": "admin"}, "testData": {"machineName": "Test Machine", "monitoringPointName": "Test Monitoring Point"}} diff --git a/full-stack/apps/frontend/cypress/screenshots/auth-and-monitoring.cy.ts/Auth and Monitoring Points Flow -- should handle empty state gracefully (failed).png b/full-stack/apps/frontend/cypress/screenshots/auth-and-monitoring.cy.ts/Auth and Monitoring Points Flow -- should handle empty state gracefully (failed).png new file mode 100644 index 0000000000..e6352365e1 Binary files /dev/null and b/full-stack/apps/frontend/cypress/screenshots/auth-and-monitoring.cy.ts/Auth and Monitoring Points Flow -- should handle empty state gracefully (failed).png differ diff --git a/full-stack/apps/frontend/cypress/screenshots/auth-and-monitoring.cy.ts/Auth and Monitoring Points Flow -- should handle logout correctly (failed).png b/full-stack/apps/frontend/cypress/screenshots/auth-and-monitoring.cy.ts/Auth and Monitoring Points Flow -- should handle logout correctly (failed).png new file mode 100644 index 0000000000..102f5adbb0 Binary files /dev/null and b/full-stack/apps/frontend/cypress/screenshots/auth-and-monitoring.cy.ts/Auth and Monitoring Points Flow -- should handle logout correctly (failed).png differ diff --git a/full-stack/apps/frontend/cypress/screenshots/auth-and-monitoring.cy.ts/Auth and Monitoring Points Flow -- should login successfully with admin credentials (failed).png b/full-stack/apps/frontend/cypress/screenshots/auth-and-monitoring.cy.ts/Auth and Monitoring Points Flow -- should login successfully with admin credentials (failed).png new file mode 100644 index 0000000000..d08015c2c7 Binary files /dev/null and b/full-stack/apps/frontend/cypress/screenshots/auth-and-monitoring.cy.ts/Auth and Monitoring Points Flow -- should login successfully with admin credentials (failed).png differ diff --git a/full-stack/apps/frontend/cypress/screenshots/auth-and-monitoring.cy.ts/Auth and Monitoring Points Flow -- should navigate to machines page (failed).png b/full-stack/apps/frontend/cypress/screenshots/auth-and-monitoring.cy.ts/Auth and Monitoring Points Flow -- should navigate to machines page (failed).png new file mode 100644 index 0000000000..f52c7c6210 Binary files /dev/null and b/full-stack/apps/frontend/cypress/screenshots/auth-and-monitoring.cy.ts/Auth and Monitoring Points Flow -- should navigate to machines page (failed).png differ diff --git a/full-stack/apps/frontend/cypress/screenshots/auth-and-monitoring.cy.ts/Auth and Monitoring Points Flow -- should show login page (failed).png b/full-stack/apps/frontend/cypress/screenshots/auth-and-monitoring.cy.ts/Auth and Monitoring Points Flow -- should show login page (failed).png new file mode 100644 index 0000000000..93be9ef93f Binary files /dev/null and b/full-stack/apps/frontend/cypress/screenshots/auth-and-monitoring.cy.ts/Auth and Monitoring Points Flow -- should show login page (failed).png differ diff --git a/full-stack/apps/frontend/cypress/screenshots/auth-and-monitoring.cy.ts/Auth and Monitoring Points Flow -- should show monitoring points table (failed).png b/full-stack/apps/frontend/cypress/screenshots/auth-and-monitoring.cy.ts/Auth and Monitoring Points Flow -- should show monitoring points table (failed).png new file mode 100644 index 0000000000..30f9d9af5e Binary files /dev/null and b/full-stack/apps/frontend/cypress/screenshots/auth-and-monitoring.cy.ts/Auth and Monitoring Points Flow -- should show monitoring points table (failed).png differ diff --git a/full-stack/apps/frontend/cypress/screenshots/auth-and-monitoring.cy.ts/Auth and Monitoring Points Flow -- should validate authentication state (failed).png b/full-stack/apps/frontend/cypress/screenshots/auth-and-monitoring.cy.ts/Auth and Monitoring Points Flow -- should validate authentication state (failed).png new file mode 100644 index 0000000000..31b08b6e15 Binary files /dev/null and b/full-stack/apps/frontend/cypress/screenshots/auth-and-monitoring.cy.ts/Auth and Monitoring Points Flow -- should validate authentication state (failed).png differ diff --git a/full-stack/apps/frontend/cypress/screenshots/basic-auth.cy.ts/Basic E2E Tests -- should login with valid credentials (failed).png b/full-stack/apps/frontend/cypress/screenshots/basic-auth.cy.ts/Basic E2E Tests -- should login with valid credentials (failed).png new file mode 100644 index 0000000000..97690062ca Binary files /dev/null and b/full-stack/apps/frontend/cypress/screenshots/basic-auth.cy.ts/Basic E2E Tests -- should login with valid credentials (failed).png differ diff --git a/full-stack/apps/frontend/cypress/screenshots/basic-auth.cy.ts/Basic E2E Tests -- should show error with invalid credentials (failed).png b/full-stack/apps/frontend/cypress/screenshots/basic-auth.cy.ts/Basic E2E Tests -- should show error with invalid credentials (failed).png new file mode 100644 index 0000000000..75556831ac Binary files /dev/null and b/full-stack/apps/frontend/cypress/screenshots/basic-auth.cy.ts/Basic E2E Tests -- should show error with invalid credentials (failed).png differ diff --git a/full-stack/apps/frontend/cypress/screenshots/time-series.cy.ts/Time Series Data Flow -- should open time series drawer when clicking on a monitoring point row -- before each hook (failed).png b/full-stack/apps/frontend/cypress/screenshots/time-series.cy.ts/Time Series Data Flow -- should open time series drawer when clicking on a monitoring point row -- before each hook (failed).png new file mode 100644 index 0000000000..cf534e7f2d Binary files /dev/null and b/full-stack/apps/frontend/cypress/screenshots/time-series.cy.ts/Time Series Data Flow -- should open time series drawer when clicking on a monitoring point row -- before each hook (failed).png differ diff --git a/full-stack/apps/frontend/cypress/support/commands.ts b/full-stack/apps/frontend/cypress/support/commands.ts new file mode 100644 index 0000000000..2107749e36 --- /dev/null +++ b/full-stack/apps/frontend/cypress/support/commands.ts @@ -0,0 +1,82 @@ +/// + +declare global { + namespace Cypress { + interface Chainable { + /** + * Performs login in the system with default or custom credentials + * @param username Username (default: 'admin') + * @param password Password (default: 'admin') + */ + login(username?: string, password?: string): Chainable; + + /** + * Clears localStorage and performs logout + */ + logout(): Chainable; + + /** + * Checks if user is authenticated + */ + checkAuth(): Chainable; + + /** + * Waits for API data loading + * @param timeout Maximum wait time + */ + waitForApi(timeout?: number): Chainable; + + /** + * Gets JWT token for tests + */ + getTestToken(): Chainable; + } + } +} + +// Login command +Cypress.Commands.add('login', (username = 'admin', password = 'admin') => { + cy.request({ + method: 'POST', + url: 'http://localhost:3001/auth/login', + body: { username, password }, + failOnStatusCode: false, + }).then((response) => { + if (response.status === 200) { + window.localStorage.setItem('token', response.body.token); + cy.log('Login successful'); + } else { + cy.log('Login failed:', response.body); + throw new Error('Authentication failed'); + } + }); +}); + +// Logout command +Cypress.Commands.add('logout', () => { + window.localStorage.removeItem('token'); + cy.visit('/login'); + cy.log('Logout successful'); +}); + +// Command to check authentication +Cypress.Commands.add('checkAuth', () => { + const token = window.localStorage.getItem('token'); + if (!token) { + throw new Error('Authentication failed'); + } + cy.log('User authenticated'); +}); + +// Command to wait for API +Cypress.Commands.add('waitForApi', (timeout = 10000) => { + cy.wait('@apiRequest', { timeout }); +}); + +// Command to get test token +Cypress.Commands.add('getTestToken', () => { + return cy.task('getTestToken'); +}); + +// Export for compatibility +export {}; \ No newline at end of file diff --git a/full-stack/apps/frontend/cypress/support/e2e.ts b/full-stack/apps/frontend/cypress/support/e2e.ts new file mode 100644 index 0000000000..52626b96f0 --- /dev/null +++ b/full-stack/apps/frontend/cypress/support/e2e.ts @@ -0,0 +1,60 @@ +// *********************************************************** +// Support file for E2E tests +// Automatically loaded before tests +// *********************************************************** + +// Import custom commands +import './commands'; + +// Global settings before each test +beforeEach(() => { + // Clear localStorage before each test + cy.clearLocalStorage(); + + cy.on('uncaught:exception', (err, runnable) => { + if (err.message.includes('fetch') || err.message.includes('CORS')) { + return false; + } + return true; + }); + + cy.intercept('GET', '**/api/**', (req) => { + req.alias = 'apiRequest'; + }); + + cy.log('Test setup completed'); +}); + +afterEach(() => { + cy.clearLocalStorage(); + cy.log('Test cleanup completed'); +}); + +before(() => { + cy.task('checkBackendHealth').then((isHealthy) => { + if (!isHealthy) { + throw new Error('Backend is not available. Start with: ./scripts/up.sh'); + } + }); + + cy.log('Test suite started'); +}); + + +after(() => { + cy.log('Test suite completed'); +}); + +export const testConfig = { + baseUrl: 'http://localhost:5173', + backendUrl: 'http://localhost:3001', + defaultUser: { + username: 'admin', + password: 'admin', + }, + timeouts: { + short: 3000, + medium: 10000, + long: 30000, + }, +}; \ No newline at end of file diff --git a/full-stack/apps/frontend/eslint.config.js b/full-stack/apps/frontend/eslint.config.js new file mode 100644 index 0000000000..5e6b472f58 --- /dev/null +++ b/full-stack/apps/frontend/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/full-stack/apps/frontend/index.html b/full-stack/apps/frontend/index.html new file mode 100644 index 0000000000..072a57e8e4 --- /dev/null +++ b/full-stack/apps/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/full-stack/apps/frontend/nginx.conf b/full-stack/apps/frontend/nginx.conf new file mode 100644 index 0000000000..79fd9597e7 --- /dev/null +++ b/full-stack/apps/frontend/nginx.conf @@ -0,0 +1,11 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/full-stack/apps/frontend/package.json b/full-stack/apps/frontend/package.json new file mode 100644 index 0000000000..54d014556f --- /dev/null +++ b/full-stack/apps/frontend/package.json @@ -0,0 +1,52 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "test": "vitest", + "test:e2e": "cd ../.. && ./scripts/test-e2e.sh run", + "test:e2e:open": "cd ../.. && ./scripts/test-e2e.sh open", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "lint:fix": "eslint . --ext ts,tsx --fix" + }, + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^7.3.7", + "@mui/material": "^7.3.7", + "@mui/x-data-grid": "^8.27.0", + "@reduxjs/toolkit": "^2.11.2", + "axios": "^1.13.4", + "date-fns": "^4.1.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-redux": "^9.2.0", + "react-router-dom": "^7.13.0", + "recharts": "^3.7.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@testing-library/cypress": "^10.1.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "cypress": "^15.10.0", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "jsdom": "^28.0.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4", + "vitest": "^4.0.18" + } +} diff --git a/full-stack/apps/frontend/public/vite.svg b/full-stack/apps/frontend/public/vite.svg new file mode 100644 index 0000000000..e7b8dfb1b2 --- /dev/null +++ b/full-stack/apps/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/full-stack/apps/frontend/scripts/test-e2e.sh b/full-stack/apps/frontend/scripts/test-e2e.sh new file mode 100755 index 0000000000..2ad1789a89 --- /dev/null +++ b/full-stack/apps/frontend/scripts/test-e2e.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +echo "Starting E2E tests with consolidated configuration..." + +if ! curl -s http://localhost:3001/health &>/dev/null; then + echo "Backend is not running. Please start with: ./scripts/up.sh" + exit 1 +fi + +echo "Backend detected. Checking frontend..." + +if ! curl -s http://localhost:5173 &>/dev/null; then + echo "Frontend is not running. Please start the frontend." + exit 1 +fi + +echo "Frontend detected. Starting Cypress tests..." + +cd "$(dirname "$0")/../.." + +npx cypress run --e2e --config-file cypress.config.ts + +echo "E2E tests completed!" diff --git a/full-stack/apps/frontend/src/App.css b/full-stack/apps/frontend/src/App.css new file mode 100644 index 0000000000..b9d355df2a --- /dev/null +++ b/full-stack/apps/frontend/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/full-stack/apps/frontend/src/App.tsx b/full-stack/apps/frontend/src/App.tsx new file mode 100644 index 0000000000..b07b72d964 --- /dev/null +++ b/full-stack/apps/frontend/src/App.tsx @@ -0,0 +1,445 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Container, + Typography, + Alert, + Box, + Stack, + Button, + Chip, + LinearProgress, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + useTheme, + useMediaQuery, +} from "@mui/material"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import { + DataGrid, + type GridColDef, + type GridSortModel, +} from "@mui/x-data-grid"; +import { useDispatch, useSelector } from "react-redux"; +import { useNavigate, Link } from "react-router-dom"; +import type { AppDispatch, RootState } from "./store"; +import { + fetchMonitoringPoints, + setPage, + setPageSize, + setSort, +} from "./store/monitoringPointsSlice"; +import { + loadMachinesForSelect, + addMonitoringPoint, + editMonitoringPoint, + removeMonitoringPoint +} from "./store/monitoringPointsCrudSlice"; +import { logout } from "./store/authSlice"; +import MonitoringTimeSeriesDrawer from "./components/TimeSeriesDrawer"; +import { MonitoringPointForm } from "./components/MonitoringPointForm"; +import { RequireAuth } from "./components/RequireAuth"; +import { Toast } from "./components/Toast"; +import { Footer } from "./components/Footer"; + +function sensorLabel(v: string | null) { + if (!v) return "-"; + return v === "HF_plus" ? "HF+" : v; +} + +export default function App() { + const dispatch = useDispatch(); + const navigate = useNavigate(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("md")); + const { items, total, status, error, page, pageSize, sortBy, sortOrder } = + useSelector((s: RootState) => s.monitoringPoints); + const { machines } = useSelector((s: RootState) => s.monitoringPointsCrud); + + const [drawerOpen, setDrawerOpen] = useState(false); + const [selected, setSelected] = useState<{ id: string; title: string } | null>(null); + const [crudOpen, setCrudOpen] = useState(false); + const [crudForm, setCrudForm] = useState<{ + id?: string; + machineId: string; + name: string; + sensorUniqueId: string; + sensorModel: "HF_plus" | "TcAg" | "TcAs"; + }>({ + machineId: "", + name: "", + sensorUniqueId: "", + sensorModel: "HF_plus", + }); + const [toast, setToast] = useState<{ type: "success" | "error"; msg: string } | null>(null); + + const handleLogout = () => { + dispatch(logout()); + navigate("/login"); + }; + + function openTimeSeries(id: string, title: string) { + setSelected({ id, title }); + setDrawerOpen(true); + } + + const handleCreateMonitoringPoint = () => { + setCrudForm({ + machineId: "", + name: "", + sensorUniqueId: "", + sensorModel: "HF_plus", + }); + setCrudOpen(true); + }; + + const handleEditMonitoringPoint = (row: any) => { + setCrudForm({ + id: row.id, + machineId: "", + name: row.monitoringPointName, + sensorUniqueId: row.sensorUniqueId, + sensorModel: row.sensorModel, + }); + setCrudOpen(true); + }; + + const handleDeleteMonitoringPoint = async (row: any) => { + if (!confirm(`Delete monitoring point "${row.monitoringPointName}"?`)) return; + try { + await dispatch(removeMonitoringPoint(row.id)).unwrap(); + setToast({ type: "success", msg: "Monitoring point deleted" }); + dispatch(fetchMonitoringPoints()); + } catch (e: any) { + setToast({ type: "error", msg: e?.message ?? "Failed to delete" }); + } + }; + + const handleSaveMonitoringPoint = async () => { + if (!crudForm.machineId || !crudForm.name.trim() || !crudForm.sensorUniqueId.trim()) { + setToast({ type: "error", msg: "All fields are required" }); + return; + } + + // Validação: Pump não aceita TcAg/TcAs + const selectedMachine = machines.find(m => m.id === crudForm.machineId); + if (selectedMachine?.type === "Pump" && (crudForm.sensorModel === "TcAg" || crudForm.sensorModel === "TcAs")) { + setToast({ type: "error", msg: "Pump machines cannot use TcAg or TcAs sensors" }); + return; + } + + try { + if (crudForm.id) { + await dispatch(editMonitoringPoint({ + id: crudForm.id, + name: crudForm.name + })).unwrap(); + setToast({ type: "success", msg: "Monitoring point updated" }); + } else { + await dispatch(addMonitoringPoint({ + machineId: crudForm.machineId, + name: crudForm.name, + sensor: { + uniqueId: crudForm.sensorUniqueId, + model: crudForm.sensorModel, + }, + })).unwrap(); + setToast({ type: "success", msg: "Monitoring point created" }); + } + setCrudOpen(false); + dispatch(fetchMonitoringPoints()); + } catch (e: any) { + setToast({ type: "error", msg: e?.message ?? "Save failed" }); + } + }; + + useEffect(() => { + dispatch(fetchMonitoringPoints()); + dispatch(loadMachinesForSelect()); + }, [dispatch, page, pageSize, sortBy, sortOrder]); + + const columns: GridColDef[] = useMemo( + () => [ + { + field: "machineName", + headerName: "Machine", + flex: 1, + minWidth: 120, + sortable: true, + align: 'center', + headerAlign: 'center' + }, + { + field: "machineType", + headerName: "Type", + width: 80, + sortable: true, + align: 'center', + headerAlign: 'center', + renderCell: (params) => ( + + ), + }, + { + field: "monitoringPointName", + headerName: "MP Name", + flex: 1, + minWidth: 120, + sortable: true, + align: 'center', + headerAlign: 'center' + }, + { + field: "sensorModel", + headerName: "Sensor", + width: 70, + sortable: true, + align: 'center', + headerAlign: 'center', + renderCell: (params) => ( + + ), + }, + { + field: "actions", + headerName: "Actions", + sortable: false, + width: isMobile ? 100 : 200, + align: 'center', + headerAlign: 'center', + renderCell: (params) => ( + + + + + ), + }, + ], + [isMobile] + ); + + const sortModel: GridSortModel = [{ field: sortBy, sort: sortOrder }]; + + // Loading state + if (status === "loading") { + return ( + + + + Monitoring Points + + Loading... + + + + + + ); + } + + // Error state + if (status === "failed") { + return ( + + + + Monitoring Points + + + + + + {error ?? "Failed to load monitoring points"} + + + + + ); + } + + // Empty state + if (status === "succeeded" && items.length === 0) { + return ( + + + + Monitoring Points + + + + + No monitoring points found + + Run the seed script to generate demo data. + + + cd apps/backend && pnpm prisma:seed + + + + + ); + } + + return ( + + + + + Monitoring Points + + Total: {total} + + + + + + + + + + + + + r.id} + rowCount={total} + paginationMode="server" + pageSizeOptions={[10, 20, 50]} + paginationModel={{ page, pageSize }} + onPaginationModelChange={(m) => { + if (m.page !== page) dispatch(setPage(m.page)); + if (m.pageSize !== pageSize) dispatch(setPageSize(m.pageSize)); + }} + sortModel={sortModel} + onSortModelChange={(model) => { + const next = model[0]; + dispatch(setSort({ + sortBy: (next?.field as "machineName" | "machineType" | "monitoringPointName" | "sensorModel" | "createdAt") ?? "machineName", + sortOrder: (next?.sort as "asc" | "desc") ?? "asc" + })); + }} + onRowClick={(params) => { + openTimeSeries(params.row.id, `${params.row.monitoringPointName} • ${params.row.machineName}`); + }} + localeText={{ + noRowsLabel: "No monitoring points found. Run seed script to generate sample data.", + }} + sx={{ + '& .MuiDataGrid-root': { + border: '1px solid rgba(224, 224, 224, 1)', + }, + '& .MuiDataGrid-cell': { + whiteSpace: 'normal', + lineHeight: '1.2', + textAlign: 'center', + } + }} + /> + + + setDrawerOpen(false)} + monitoringPointId={selected?.id ?? null} + title={selected?.title} + /> + + setCrudOpen(false)} fullWidth maxWidth="sm"> + {crudForm.id ? "Edit Monitoring Point" : "Create Monitoring Point"} + + + + + + + + + + setToast(null)} + /> + +