From a7db085d32329e0a499adf808f475b4835390017 Mon Sep 17 00:00:00 2001 From: Oleksandr Khotemskyi Date: Thu, 12 Mar 2026 23:08:52 +0200 Subject: [PATCH 1/5] Update .gitignore init refactor: improve layout and add logout functionality - Updated layout styles to ensure proper flex behavior and overflow handling. - Enhanced the Aside component by adding a Logout button with an icon, allowing users to sign out. - Adjusted class names for better responsiveness and visual consistency. feat: add trend results API and enhance report upload handling - Introduced a new endpoint `/api/result/trend` to fetch trend results based on project queries. - Enhanced the report upload functionality to parse `report.jsonl` files and store metadata in the database. - Improved error handling during report extraction and parsing processes. - Removed obsolete configuration and report files to streamline the project structure. feat: enhance report handling and UI improvements - Updated server to handle report uploads more efficiently by using a temporary upload directory. - Added new functions to extract and parse report data, improving report generation from uploaded files. - Enhanced the UI with new API documentation link and improved layout for report filtering. - Removed obsolete result handling from the settings page and adjusted server info display. - Added new stats columns to the reports database for better tracking of report metrics. cleanup Update .gitignore --- .env.example | 120 +- .eslintignore | 20 - .eslintrc.json | 83 - .gitattributes | 2 - .gitignore | 60 +- .nvmrc | 1 - .prettierignore | 6 - .prettierrc | 9 - Dockerfile | 75 - LICENSE | 21 - app/api/[...nextauth]/route.ts | 3 - app/api/config/route.ts | 169 - app/api/info/route.ts | 14 - app/api/ping/route.ts | 5 - app/api/report/[id]/route.ts | 31 - app/api/report/delete/route.ts | 22 - app/api/report/generate/route.ts | 30 - app/api/report/list/route.ts | 26 - app/api/report/projects/route.ts | 14 - app/api/report/trend/route.ts | 13 - app/api/result/delete/route.ts | 23 - app/api/result/list/route.ts | 27 - app/api/result/projects/route.ts | 14 - app/api/result/tags/route.ts | 19 - app/api/serve/[[...filePath]]/route.ts | 63 - app/api/static/[[...filePath]]/route.ts | 52 - app/auth.ts | 112 - app/components/aside.tsx | 80 - app/components/date-format.tsx | 16 - app/components/date-range-picker.tsx | 108 - app/components/delete-report-button.tsx | 91 - app/components/delete-results-button.tsx | 94 - app/components/generate-report-button.tsx | 154 - app/components/header-links.tsx | 46 - app/components/icons.tsx | 318 - app/components/login-form.tsx | 104 - app/components/navbar.tsx | 90 - app/components/page-layout.tsx | 76 - app/components/primitives.ts | 45 - app/components/project-select.tsx | 58 - app/components/report-details/file-list.tsx | 83 - .../report-details/report-stats.tsx | 27 - app/components/report-details/suite-tree.tsx | 114 - app/components/report-details/test-info.tsx | 98 - .../report-details/tests-filters.tsx | 88 - app/components/report-trends.tsx | 53 - app/components/reports-table.tsx | 312 - app/components/reports.tsx | 46 - app/components/results-table.tsx | 242 - app/components/results.tsx | 48 - app/components/stat-chart.tsx | 83 - app/components/table-pagination-options.tsx | 93 - app/components/tag-select.tsx | 51 - app/components/theme-switch.tsx | 66 - app/components/trend-chart.tsx | 168 - app/components/ui/chart.tsx | 316 - app/components/upload-results-button.tsx | 287 - app/config/env.ts | 21 - app/config/fonts.ts | 11 - app/config/runtime.ts | 1 - app/config/site.ts | 63 - app/hooks/useAuthConfig.ts | 29 - app/hooks/useMutation.ts | 53 - app/hooks/useQuery.ts | 83 - app/layout.tsx | 79 - app/lib/auth.ts | 7 - app/lib/config.ts | 29 - app/lib/constants.ts | 3 - app/lib/network.ts | 18 - app/lib/parser/index.ts | 63 - app/lib/parser/types.ts | 72 - app/lib/pw.ts | 127 - app/lib/query-cache.ts | 18 - app/lib/service/cache/config.ts | 53 - app/lib/service/cache/index.ts | 3 - app/lib/service/cache/reports.ts | 77 - app/lib/service/cache/results.ts | 80 - app/lib/service/cron.ts | 160 - app/lib/service/index.ts | 460 - app/lib/service/lifecycle.ts | 57 - app/lib/storage/batch.ts | 18 - app/lib/storage/constants.ts | 23 - app/lib/storage/file.ts | 15 - app/lib/storage/folders.ts | 10 - app/lib/storage/format.ts | 18 - app/lib/storage/fs.ts | 496 - app/lib/storage/index.ts | 7 - app/lib/storage/pagination.ts | 22 - app/lib/storage/s3.ts | 938 - app/lib/storage/types.ts | 98 - app/lib/tailwind.ts | 52 - app/lib/time.ts | 20 - app/lib/transformers.ts | 55 - app/lib/url.ts | 8 - app/lib/withError.ts | 12 - app/login/page.tsx | 13 - app/page.tsx | 5 - app/providers/index.tsx | 42 - app/report/[id]/page.tsx | 62 - app/reports/page.tsx | 8 - app/results/page.tsx | 8 - app/settings/components/AddLinkModal.tsx | 64 - app/settings/components/CronConfiguration.tsx | 194 - app/settings/components/EnvironmentInfo.tsx | 52 - .../components/ServerConfiguration.tsx | 356 - app/settings/page.tsx | 194 - app/settings/types.ts | 13 - app/styles/globals.css | 21 - app/trends/page.tsx | 5 - app/types/index.ts | 36 - index.html | 13 + instrumentation.ts | 11 - jest.config.ts | 20 - metadata.json | 5 + middleware.ts | 66 - next-auth.d.ts | 8 - next.config.js | 18 - package-lock.json | 18707 ++++------------ package.json | 111 +- pages/api/result/upload.ts | 199 - playwright.config.ts | 49 - postcss.config.js | 6 - prettier.config.js | 6 - public/favicon.ico | Bin 262206 -> 0 bytes public/logo.svg | 10 - readme.md | 491 +- scripts/start.js | 17 - server.ts | 567 + src/App.tsx | 52 + src/components/Layout.tsx | 106 + src/components/MergeReportsButton.tsx | 124 + src/components/UploadResultButton.tsx | 42 + src/context/AuthContext.tsx | 46 + src/db.ts | 64 + src/index.css | 1 + src/main.tsx | 14 + src/openapi.ts | 624 + src/pages/LoginPage.tsx | 56 + src/pages/ReportDetailPage.tsx | 165 + src/pages/ReportsPage.tsx | 281 + src/pages/SettingsPage.tsx | 259 + src/pages/TrendsPage.tsx | 210 + src/types.ts | 62 + src/vite-env.d.ts | 5 + tailwind.config.ts | 43 - tests/api/auth.test.ts | 25 - tests/api/controllers/base.controller.ts | 5 - tests/api/controllers/index.ts | 8 - tests/api/controllers/report.controller.ts | 16 - tests/api/controllers/result.controller.ts | 52 - tests/api/delete.test.ts | 36 - tests/api/fixtures/base.ts | 31 - tests/api/generate.test.ts | 74 - tests/api/reports.test.ts | 53 - tests/api/req/json.request.ts | 18 - tests/api/results.test.ts | 59 - tests/api/types/list.d.ts | 7 - tests/api/types/report.d.ts | 9 - tests/api/types/result.d.ts | 18 - tests/api/upload.test.ts | 23 - tests/testdata/correct_blob.zip | Bin 2562 -> 0 bytes tsconfig.json | 43 +- vite.config.ts | 25 + 163 files changed, 6832 insertions(+), 25049 deletions(-) delete mode 100644 .eslintignore delete mode 100644 .eslintrc.json delete mode 100644 .gitattributes delete mode 100644 .nvmrc delete mode 100644 .prettierignore delete mode 100644 .prettierrc delete mode 100644 Dockerfile delete mode 100644 LICENSE delete mode 100644 app/api/[...nextauth]/route.ts delete mode 100644 app/api/config/route.ts delete mode 100644 app/api/info/route.ts delete mode 100644 app/api/ping/route.ts delete mode 100644 app/api/report/[id]/route.ts delete mode 100644 app/api/report/delete/route.ts delete mode 100644 app/api/report/generate/route.ts delete mode 100644 app/api/report/list/route.ts delete mode 100644 app/api/report/projects/route.ts delete mode 100644 app/api/report/trend/route.ts delete mode 100644 app/api/result/delete/route.ts delete mode 100644 app/api/result/list/route.ts delete mode 100644 app/api/result/projects/route.ts delete mode 100644 app/api/result/tags/route.ts delete mode 100644 app/api/serve/[[...filePath]]/route.ts delete mode 100644 app/api/static/[[...filePath]]/route.ts delete mode 100644 app/auth.ts delete mode 100644 app/components/aside.tsx delete mode 100644 app/components/date-format.tsx delete mode 100644 app/components/date-range-picker.tsx delete mode 100644 app/components/delete-report-button.tsx delete mode 100644 app/components/delete-results-button.tsx delete mode 100644 app/components/generate-report-button.tsx delete mode 100644 app/components/header-links.tsx delete mode 100644 app/components/icons.tsx delete mode 100644 app/components/login-form.tsx delete mode 100644 app/components/navbar.tsx delete mode 100644 app/components/page-layout.tsx delete mode 100644 app/components/primitives.ts delete mode 100644 app/components/project-select.tsx delete mode 100644 app/components/report-details/file-list.tsx delete mode 100644 app/components/report-details/report-stats.tsx delete mode 100644 app/components/report-details/suite-tree.tsx delete mode 100644 app/components/report-details/test-info.tsx delete mode 100644 app/components/report-details/tests-filters.tsx delete mode 100644 app/components/report-trends.tsx delete mode 100644 app/components/reports-table.tsx delete mode 100644 app/components/reports.tsx delete mode 100644 app/components/results-table.tsx delete mode 100644 app/components/results.tsx delete mode 100644 app/components/stat-chart.tsx delete mode 100644 app/components/table-pagination-options.tsx delete mode 100644 app/components/tag-select.tsx delete mode 100644 app/components/theme-switch.tsx delete mode 100644 app/components/trend-chart.tsx delete mode 100644 app/components/ui/chart.tsx delete mode 100644 app/components/upload-results-button.tsx delete mode 100644 app/config/env.ts delete mode 100644 app/config/fonts.ts delete mode 100644 app/config/runtime.ts delete mode 100644 app/config/site.ts delete mode 100644 app/hooks/useAuthConfig.ts delete mode 100644 app/hooks/useMutation.ts delete mode 100644 app/hooks/useQuery.ts delete mode 100644 app/layout.tsx delete mode 100644 app/lib/auth.ts delete mode 100644 app/lib/config.ts delete mode 100644 app/lib/constants.ts delete mode 100644 app/lib/network.ts delete mode 100644 app/lib/parser/index.ts delete mode 100644 app/lib/parser/types.ts delete mode 100644 app/lib/pw.ts delete mode 100644 app/lib/query-cache.ts delete mode 100644 app/lib/service/cache/config.ts delete mode 100644 app/lib/service/cache/index.ts delete mode 100644 app/lib/service/cache/reports.ts delete mode 100644 app/lib/service/cache/results.ts delete mode 100644 app/lib/service/cron.ts delete mode 100644 app/lib/service/index.ts delete mode 100644 app/lib/service/lifecycle.ts delete mode 100644 app/lib/storage/batch.ts delete mode 100644 app/lib/storage/constants.ts delete mode 100644 app/lib/storage/file.ts delete mode 100644 app/lib/storage/folders.ts delete mode 100644 app/lib/storage/format.ts delete mode 100644 app/lib/storage/fs.ts delete mode 100644 app/lib/storage/index.ts delete mode 100644 app/lib/storage/pagination.ts delete mode 100644 app/lib/storage/s3.ts delete mode 100644 app/lib/storage/types.ts delete mode 100644 app/lib/tailwind.ts delete mode 100644 app/lib/time.ts delete mode 100644 app/lib/transformers.ts delete mode 100644 app/lib/url.ts delete mode 100644 app/lib/withError.ts delete mode 100644 app/login/page.tsx delete mode 100644 app/page.tsx delete mode 100644 app/providers/index.tsx delete mode 100644 app/report/[id]/page.tsx delete mode 100644 app/reports/page.tsx delete mode 100644 app/results/page.tsx delete mode 100644 app/settings/components/AddLinkModal.tsx delete mode 100644 app/settings/components/CronConfiguration.tsx delete mode 100644 app/settings/components/EnvironmentInfo.tsx delete mode 100644 app/settings/components/ServerConfiguration.tsx delete mode 100644 app/settings/page.tsx delete mode 100644 app/settings/types.ts delete mode 100644 app/styles/globals.css delete mode 100644 app/trends/page.tsx delete mode 100644 app/types/index.ts create mode 100644 index.html delete mode 100644 instrumentation.ts delete mode 100644 jest.config.ts create mode 100644 metadata.json delete mode 100644 middleware.ts delete mode 100644 next-auth.d.ts delete mode 100644 next.config.js delete mode 100644 pages/api/result/upload.ts delete mode 100644 playwright.config.ts delete mode 100644 postcss.config.js delete mode 100644 prettier.config.js delete mode 100644 public/favicon.ico delete mode 100644 public/logo.svg delete mode 100644 scripts/start.js create mode 100644 server.ts create mode 100644 src/App.tsx create mode 100644 src/components/Layout.tsx create mode 100644 src/components/MergeReportsButton.tsx create mode 100644 src/components/UploadResultButton.tsx create mode 100644 src/context/AuthContext.tsx create mode 100644 src/db.ts create mode 100644 src/index.css create mode 100644 src/main.tsx create mode 100644 src/openapi.ts create mode 100644 src/pages/LoginPage.tsx create mode 100644 src/pages/ReportDetailPage.tsx create mode 100644 src/pages/ReportsPage.tsx create mode 100644 src/pages/SettingsPage.tsx create mode 100644 src/pages/TrendsPage.tsx create mode 100644 src/types.ts create mode 100644 src/vite-env.d.ts delete mode 100644 tailwind.config.ts delete mode 100644 tests/api/auth.test.ts delete mode 100644 tests/api/controllers/base.controller.ts delete mode 100644 tests/api/controllers/index.ts delete mode 100644 tests/api/controllers/report.controller.ts delete mode 100644 tests/api/controllers/result.controller.ts delete mode 100644 tests/api/delete.test.ts delete mode 100644 tests/api/fixtures/base.ts delete mode 100644 tests/api/generate.test.ts delete mode 100644 tests/api/reports.test.ts delete mode 100644 tests/api/req/json.request.ts delete mode 100644 tests/api/results.test.ts delete mode 100644 tests/api/types/list.d.ts delete mode 100644 tests/api/types/report.d.ts delete mode 100644 tests/api/types/result.d.ts delete mode 100644 tests/api/upload.test.ts delete mode 100644 tests/testdata/correct_blob.zip create mode 100644 vite.config.ts diff --git a/.env.example b/.env.example index 10ee5100..f8e2882a 100644 --- a/.env.example +++ b/.env.example @@ -1,24 +1,96 @@ -# Next Auth -# You can generate a new secret on the command line with: -# `npm exec auth secret` -# OR -# `openssl rand -base64 32`` -# https://next-auth.js.org/configuration/options#secret -AUTH_SECRET= -AUTH_URL=http://localhost:3000 - -# API token details -API_TOKEN='my-api-token' -UI_AUTH_EXPIRE_HOURS='2' - -# Storage details -DATA_STORAGE=fs # could be s3 - -# S3 related configuration if DATA_STORAGE is "s3" -S3_ENDPOINT="s3.endpoint", -S3_ACCESS_KEY="some_access_key" -S3_SECRET_KEY="some_secret_key" -S3_PORT=9000 # optional -S3_REGION="us-east-1" -S3_BUCKET="bucket_name" # by default "playwright-reports-server" -S3_BATCH_SIZE=10 # by default 10 \ No newline at end of file +# ============================================================================= +# Playwright Reports Hub — environment template +# Copy to .env and set values. Do not commit .env (it is gitignored). +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Example: local dev (no auth, filesystem storage) +# ----------------------------------------------------------------------------- +# DATA_STORAGE=fs + +# ----------------------------------------------------------------------------- +# Example: local dev with auth +# ----------------------------------------------------------------------------- +# API_TOKEN=my-dev-token-12345 +# DATA_STORAGE=fs + +# ----------------------------------------------------------------------------- +# Example: with expiration cron (delete results after 7 days, reports after 30) +# ----------------------------------------------------------------------------- +# API_TOKEN=my-secret-token +# DATA_STORAGE=fs +# RESULT_EXPIRE_DAYS=7 +# REPORT_EXPIRE_DAYS=30 +# RESULT_EXPIRE_CRON_SCHEDULE=33 3 * * * +# REPORT_EXPIRE_CRON_SCHEDULE=44 4 * * * + +# ----------------------------------------------------------------------------- +# Example: S3 (MinIO / AWS) +# ----------------------------------------------------------------------------- +# API_TOKEN=my-secret-token +# DATA_STORAGE=s3 +# S3_ENDPOINT=localhost +# S3_ACCESS_KEY=minioadmin +# S3_SECRET_KEY=minioadmin +# S3_PORT=9000 +# S3_REGION=us-east-1 +# S3_BUCKET=playwright-reports +# S3_BATCH_SIZE=10 + +# ============================================================================= +# All options (reference) +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Auth (optional) +# ----------------------------------------------------------------------------- +# When set, protected API routes and report serving require this token. +# Leave unset for local dev without auth. +# API_TOKEN=your-secret-token + +# UI session (if using session-based auth in future) +# AUTH_SECRET=random-secret-for-jwt +# UI_AUTH_EXPIRE_HOURS=2 + +# ----------------------------------------------------------------------------- +# Storage +# ----------------------------------------------------------------------------- +# fs = filesystem (default), s3 = S3-compatible (MinIO, etc.) +# DATA_STORAGE=fs + +# In-memory cache for lists/config (single-instance only) +# USE_SERVER_CACHE=false + +# ----------------------------------------------------------------------------- +# S3 (only when DATA_STORAGE=s3) +# ----------------------------------------------------------------------------- +# S3_ENDPOINT=localhost +# S3_ACCESS_KEY= +# S3_SECRET_KEY= +# S3_PORT=9000 +# S3_REGION=auto +# S3_BUCKET=playwright-reports-server +# S3_BATCH_SIZE=10 + +# ----------------------------------------------------------------------------- +# Cron / expiration (optional) +# ----------------------------------------------------------------------------- +# Days to keep results/reports before cron deletes them (decimal allowed, e.g. 0.25 = 6h) +# RESULT_EXPIRE_DAYS=7 +# REPORT_EXPIRE_DAYS=30 + +# Cron schedules (defaults: 3:33 AM and 4:44 AM daily) +# RESULT_EXPIRE_CRON_SCHEDULE=33 3 * * * +# REPORT_EXPIRE_CRON_SCHEDULE=44 4 * * * + +# ----------------------------------------------------------------------------- +# Base path (optional, for subpath deployment) +# ----------------------------------------------------------------------------- +# API_BASE_PATH=/reports-hub +# ASSETS_BASE_PATH=/reports-hub + +# ----------------------------------------------------------------------------- +# Development +# ----------------------------------------------------------------------------- +# Set to true to disable Vite HMR (e.g. in some hosted editors) +# DISABLE_HMR=false diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index af6ab76f..00000000 --- a/.eslintignore +++ /dev/null @@ -1,20 +0,0 @@ -.now/* -*.css -.changeset -dist -esm/* -public/* -tests/* -scripts/* -*.config.js -.DS_Store -node_modules -coverage -.next -build -!.commitlintrc.cjs -!.lintstagedrc.cjs -!jest.config.js -!plopfile.js -!react-shim.js -!tsup.config.ts \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index d2fbabe5..00000000 --- a/.eslintrc.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/eslintrc.json", - "env": { - "browser": false, - "es2021": true, - "node": true - }, - "extends": [ - "plugin:react/recommended", - "plugin:prettier/recommended", - "plugin:react-hooks/recommended", - "plugin:jsx-a11y/recommended" - ], - "plugins": ["react", "unused-imports", "import", "@typescript-eslint", "jsx-a11y", "prettier"], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaFeatures": { - "jsx": true - }, - "ecmaVersion": 12, - "sourceType": "module" - }, - "settings": { - "react": { - "version": "detect" - } - }, - "rules": { - "no-console": "off", - "react/prop-types": "off", - "react/jsx-uses-react": "off", - "react/react-in-jsx-scope": "off", - "react-hooks/exhaustive-deps": "off", - "jsx-a11y/click-events-have-key-events": "warn", - "jsx-a11y/interactive-supports-focus": "warn", - "prettier/prettier": "warn", - "no-unused-vars": "off", - "unused-imports/no-unused-vars": "off", - "unused-imports/no-unused-imports": "warn", - "@typescript-eslint/no-unused-vars": [ - "warn", - { - "args": "after-used", - "ignoreRestSiblings": false, - "argsIgnorePattern": "^_.*?$" - } - ], - "import/order": [ - "warn", - { - "groups": ["type", "builtin", "object", "external", "internal", "parent", "sibling", "index"], - "pathGroups": [ - { - "pattern": "~/**", - "group": "external", - "position": "after" - } - ], - "newlines-between": "always" - } - ], - "react/self-closing-comp": "warn", - "react/jsx-sort-props": [ - "warn", - { - "callbacksLast": true, - "shorthandFirst": true, - "noSortAlphabetically": false, - "reservedFirst": true - } - ], - "padding-line-between-statements": [ - "warn", - { "blankLine": "always", "prev": "*", "next": "return" }, - { "blankLine": "always", "prev": ["const", "let", "var"], "next": "*" }, - { - "blankLine": "any", - "prev": ["const", "let", "var"], - "next": ["const", "let", "var"] - } - ] - } -} diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index dfe07704..00000000 --- a/.gitattributes +++ /dev/null @@ -1,2 +0,0 @@ -# Auto detect text files and perform LF normalization -* text=auto diff --git a/.gitignore b/.gitignore index 93df80f9..f6ea39d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,52 +1,12 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# data -/data -.tmp - -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# next.js -.next/ -/out/ - -# production -/build - -# misc +node_modules/ +build/ +dist/ +coverage/ .DS_Store -*.pem +*.log +.env* +!.env.example -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# local env files -.env*.local -.env - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts -data/reports -data/results - -# vscode -.vscode - -# Playwright -node_modules/ -/test-results/ -/playwright-report/ -/blob-report/ -/playwright/.cache/ +# data +data/ +testdata/ \ No newline at end of file diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index 2bd5a0a9..00000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -22 diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 746669b4..00000000 --- a/.prettierignore +++ /dev/null @@ -1,6 +0,0 @@ -.npm/ -node_modules/ -.eslintignore -.prettierignore -package.json -package-lock.json diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 957a15d6..00000000 --- a/.prettierrc +++ /dev/null @@ -1,9 +0,0 @@ -{ - "semi": true, - "trailingComma": "all", - "singleQuote": true, - "printWidth": 120, - "tabWidth": 2, - "arrowParens": "always", - "endOfLine": "lf" -} diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 009363db..00000000 --- a/Dockerfile +++ /dev/null @@ -1,75 +0,0 @@ -FROM node:22-alpine AS base - -# Install dependencies only when needed -FROM base AS deps -# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. -RUN apk add --no-cache libc6-compat -WORKDIR /app - -# Install dependencies based on the preferred package manager -COPY package.json package-lock.json* ./ -RUN npm ci - -# Rebuild the source code only when needed -FROM base AS builder -WORKDIR /app -COPY --from=deps /app/node_modules ./node_modules -COPY . . - -ARG API_BASE_PATH="" -ENV API_BASE_PATH=$API_BASE_PATH - -ARG ASSETS_BASE_PATH="" -ENV ASSETS_BASE_PATH=$ASSETS_BASE_PATH - -# Next.js collects completely anonymous telemetry data about general usage. -# Learn more here: https://nextjs.org/telemetry -# Uncomment the following line in case you want to disable telemetry during the build. -ENV NEXT_TELEMETRY_DISABLED=1 - -RUN npm run build - -# Production image, copy all the files and run next -FROM base AS runner -WORKDIR /app - -ENV NODE_ENV=production - -RUN apk add --no-cache curl - -# Uncomment the following line in case you want to disable telemetry during runtime. -# ENV NEXT_TELEMETRY_DISABLED 1 - -RUN addgroup --system --gid 1001 nodejs && \ - adduser --system --uid 1001 --ingroup nodejs nextjs - -COPY --from=builder --chown=nextjs:nodejs /app/public ./public - -# Set the correct permission for prerender cache -RUN mkdir .next && \ - chown nextjs:nodejs .next - -# Automatically leverage output traces to reduce image size -# https://nextjs.org/docs/advanced-features/output-file-tracing -COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static - -# Create folders required for storing results and reports -ARG DATA_DIR=/app/data -ARG RESULTS_DIR=${DATA_DIR}/results -ARG REPORTS_DIR=${DATA_DIR}/reports -ARG TEMP_DIR=/app/.tmp -RUN mkdir -p ${DATA_DIR} ${RESULTS_DIR} ${REPORTS_DIR} ${TEMP_DIR} && \ - chown -R nextjs:nodejs ${DATA_DIR} ${TEMP_DIR} - -USER nextjs - -EXPOSE 3000 - -ENV PORT=3000 - -# server.js is created by next build from the standalone output -# https://nextjs.org/docs/pages/api-reference/next-config-js/output -CMD ["sh", "-c", "HOSTNAME=0.0.0.0 node server.js"] - -HEALTHCHECK --interval=3m --timeout=3s CMD curl -f http://localhost:$PORT/api/ping || exit 1 diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 93ac33f9..00000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2024 Oleksandr Khotemskyi - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/app/api/[...nextauth]/route.ts b/app/api/[...nextauth]/route.ts deleted file mode 100644 index bfc6e673..00000000 --- a/app/api/[...nextauth]/route.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { handlers } from '@/app/auth'; - -export const { GET, POST } = handlers; diff --git a/app/api/config/route.ts b/app/api/config/route.ts deleted file mode 100644 index 48125044..00000000 --- a/app/api/config/route.ts +++ /dev/null @@ -1,169 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { revalidatePath } from 'next/cache'; - -import { withError } from '@/app/lib/withError'; -import { DATA_FOLDER } from '@/app/lib/storage/constants'; -import { service } from '@/app/lib/service'; -import { env } from '@/app/config/env'; -import { cronService } from '@/app/lib/service/cron'; - -export const dynamic = 'force-dynamic'; // defaults to auto - -const saveFile = async (file: File) => { - const arrayBuffer = await file.arrayBuffer(); - - const buffer = Buffer.from(arrayBuffer); - - await fs.writeFile(path.join(DATA_FOLDER, file.name), buffer, { encoding: 'binary' }); -}; - -const parseHeaderLinks = async (headerLinks: string): Promise> => { - return JSON.parse(headerLinks); -}; - -export async function PATCH(request: Request) { - const { result: formData, error: formParseError } = await withError(request.formData()); - - if (formParseError) { - return Response.json({ error: formParseError.message }, { status: 400 }); - } - - if (!formData) { - return Response.json({ error: 'Form data is missing' }, { status: 400 }); - } - - const logo = formData.get('logo') as File; - - if (logo) { - const { error: logoError } = await withError(saveFile(logo)); - - if (logoError) { - return Response.json({ error: `failed to save logo: ${logoError?.message}` }, { status: 500 }); - } - } - - const favicon = formData.get('favicon') as File; - - if (favicon) { - const { error: faviconError } = await withError(saveFile(favicon)); - - if (faviconError) { - return Response.json({ error: `failed to save favicon: ${faviconError?.message}` }, { status: 500 }); - } - } - - const title = formData.get('title'); - const logoPath = formData.get('logoPath'); - const faviconPath = formData.get('faviconPath'); - const reporterPaths = formData.get('reporterPaths'); - const headerLinks = formData.get('headerLinks'); - const resultExpireDays = formData.get('resultExpireDays'); - const resultExpireCronSchedule = formData.get('resultExpireCronSchedule'); - const reportExpireDays = formData.get('reportExpireDays'); - const reportExpireCronSchedule = formData.get('reportExpireCronSchedule'); - - const config = await service.getConfig(); - - if (!config) { - return Response.json({ error: `failed to get config` }, { status: 500 }); - } - - if (title !== null) { - config.title = title.toString(); - } - - if (logo) { - config.logoPath = `/${logo.name}`; - } else if (logoPath !== null) { - config.logoPath = logoPath.toString(); - } - - if (favicon) { - config.faviconPath = `/${favicon.name}`; - } else if (faviconPath !== null) { - config.faviconPath = faviconPath.toString(); - } - - if (reporterPaths !== null) { - try { - config.reporterPaths = JSON.parse(reporterPaths.toString()); - } catch { - config.reporterPaths = [reporterPaths.toString()]; - } - } - - if (headerLinks) { - const { result: parsedHeaderLinks, error: parseHeaderLinksError } = await withError( - parseHeaderLinks(headerLinks.toString()), - ); - - if (parseHeaderLinksError) { - return Response.json( - { error: `failed to parse header links: ${parseHeaderLinksError.message}` }, - { status: 400 }, - ); - } - - if (parsedHeaderLinks) config.headerLinks = parsedHeaderLinks; - } - - if (!config.cron) { - config.cron = {}; - } - - if (resultExpireDays || resultExpireCronSchedule || reportExpireDays || reportExpireCronSchedule) { - if (resultExpireDays !== null) { - config.cron.resultExpireDays = parseInt(resultExpireDays.toString()); - } - if (resultExpireCronSchedule !== null) { - config.cron.resultExpireCronSchedule = resultExpireCronSchedule.toString(); - } - if (reportExpireDays !== null) { - config.cron.reportExpireDays = parseInt(reportExpireDays.toString()); - } - if (reportExpireCronSchedule !== null) { - config.cron.reportExpireCronSchedule = reportExpireCronSchedule.toString(); - } - } - - const { error: saveConfigError } = await withError(service.updateConfig(config)); - - if (saveConfigError) { - return Response.json({ error: `failed to save config: ${saveConfigError.message}` }, { status: 500 }); - } - - if ( - config.cron?.resultExpireDays || - config.cron?.resultExpireCronSchedule || - config.cron?.reportExpireDays || - config.cron?.reportExpireCronSchedule - ) { - await cronService.restart(); - } - - revalidatePath('/', 'layout'); - revalidatePath('/login', 'layout'); - - return Response.json({ message: 'config saved' }); -} - -export async function GET() { - const config = await service.getConfig(); - - if (!config) { - return Response.json({ error: 'Config not found' }, { status: 404 }); - } - - // Add environment info to config response - const envInfo = { - authRequired: !!env.API_TOKEN, - serverCache: env.USE_SERVER_CACHE, - dataStorage: env.DATA_STORAGE, - s3Endpoint: env.S3_ENDPOINT, - s3Bucket: env.S3_BUCKET, - }; - - return Response.json({ ...config, ...envInfo }, { status: 200 }); -} diff --git a/app/api/info/route.ts b/app/api/info/route.ts deleted file mode 100644 index c1f42c77..00000000 --- a/app/api/info/route.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { service } from '@/app/lib/service'; -import { withError } from '@/app/lib/withError'; - -export const dynamic = 'force-dynamic'; // defaults to auto - -export async function GET() { - const { result, error } = await withError(service.getServerInfo()); - - if (error) { - return Response.json({ error: error.message }, { status: 500 }); - } - - return Response.json(result); -} diff --git a/app/api/ping/route.ts b/app/api/ping/route.ts deleted file mode 100644 index 24d07811..00000000 --- a/app/api/ping/route.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const dynamic = 'force-dynamic'; // defaults to auto - -export async function GET() { - return new Response('pong'); -} diff --git a/app/api/report/[id]/route.ts b/app/api/report/[id]/route.ts deleted file mode 100644 index 427d9296..00000000 --- a/app/api/report/[id]/route.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { type NextRequest } from 'next/server'; - -import { withError } from '@/app/lib/withError'; -import { service } from '@/app/lib/service'; - -export const dynamic = 'force-dynamic'; // defaults to auto - -export async function GET( - req: NextRequest, - { - params, - }: { - params: { - id: string; - }; - }, -) { - const { id } = params; - - if (!id) { - return new Response('report ID is required', { status: 400 }); - } - - const { result: report, error } = await withError(service.getReport(id)); - - if (error) { - return new Response(`failed to get report: ${error?.message ?? 'unknown error'}`, { status: 400 }); - } - - return Response.json(report); -} diff --git a/app/api/report/delete/route.ts b/app/api/report/delete/route.ts deleted file mode 100644 index e9656991..00000000 --- a/app/api/report/delete/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { service } from '@/app/lib/service'; -import { withError } from '@/app/lib/withError'; - -export const dynamic = 'force-dynamic'; // defaults to auto -export async function DELETE(request: Request) { - const { result: reqData, error: reqError } = await withError(request.json()); - - if (reqError) { - return new Response(reqError.message, { status: 400 }); - } - - const { error } = await withError(service.deleteReports(reqData.reportsIds)); - - if (error) { - return new Response(error.message, { status: 404 }); - } - - return Response.json({ - message: `Reports deleted successfully`, - reportsIds: reqData.reportsIds, - }); -} diff --git a/app/api/report/generate/route.ts b/app/api/report/generate/route.ts deleted file mode 100644 index afb8c379..00000000 --- a/app/api/report/generate/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { service } from '@/app/lib/service'; -import { withError } from '@/app/lib/withError'; - -export const dynamic = 'force-dynamic'; // defaults to auto - -export async function POST(request: Request) { - const { result: reqBody, error: reqError } = await withError(request.json()); - - if (reqError) { - return new Response(reqError.message, { status: 400 }); - } - const { resultsIds, project, playwrightVersion, ...rest } = reqBody; - - try { - const result = await service.generateReport(resultsIds, { project, playwrightVersion, ...rest }); - - if (!result?.reportId) { - return new Response('failed to generate report', { status: 400 }); - } - - return Response.json(result); - } catch (error) { - console.error(`[report/generate] error: ${error}`); - if (error instanceof Error && error.message.includes('ENOENT: no such file or directory')) { - return Response.json({ error: `ResultID with not found: ${error.message}` }, { status: 404 }); - } - - return Response.json({ error: (error as Error).message }, { status: 500 }); - } -} diff --git a/app/api/report/list/route.ts b/app/api/report/list/route.ts deleted file mode 100644 index ae2b1327..00000000 --- a/app/api/report/list/route.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { type NextRequest } from 'next/server'; - -import { withError } from '@/app/lib/withError'; -import { parseFromRequest } from '@/app/lib/storage/pagination'; -import { service } from '@/app/lib/service'; - -export const dynamic = 'force-dynamic'; // defaults to auto - -export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const pagination = parseFromRequest(searchParams); - const project = searchParams.get('project') ?? ''; - const search = searchParams.get('search') ?? ''; - const dateFrom = searchParams.get('dateFrom') ?? undefined; - const dateTo = searchParams.get('dateTo') ?? undefined; - - const { result: reports, error } = await withError( - service.getReports({ pagination, project, search, dateFrom, dateTo }), - ); - - if (error) { - return new Response(error.message, { status: 400 }); - } - - return Response.json(reports!); -} diff --git a/app/api/report/projects/route.ts b/app/api/report/projects/route.ts deleted file mode 100644 index 836cd7a1..00000000 --- a/app/api/report/projects/route.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { service } from '@/app/lib/service'; -import { withError } from '@/app/lib/withError'; - -export const dynamic = 'force-dynamic'; // defaults to auto - -export async function GET() { - const { result: projects, error } = await withError(service.getReportsProjects()); - - if (error) { - return new Response(error.message, { status: 400 }); - } - - return Response.json(projects); -} diff --git a/app/api/report/trend/route.ts b/app/api/report/trend/route.ts deleted file mode 100644 index ea367aa5..00000000 --- a/app/api/report/trend/route.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { type NextRequest } from 'next/server'; - -import { service } from '@/app/lib/service'; - -export const dynamic = 'force-dynamic'; // defaults to auto - -export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const project = searchParams.get('project') ?? ''; - const { reports: latestReports } = await service.getReports({ project, pagination: { offset: 0, limit: 20 } }); - - return Response.json(latestReports); -} diff --git a/app/api/result/delete/route.ts b/app/api/result/delete/route.ts deleted file mode 100644 index 46eda0d4..00000000 --- a/app/api/result/delete/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { service } from '@/app/lib/service'; -import { withError } from '@/app/lib/withError'; - -export const dynamic = 'force-dynamic'; // defaults to auto - -export async function DELETE(request: Request) { - const { result: reqData, error: reqError } = await withError(request.json()); - - if (reqError) { - return new Response(reqError.message, { status: 400 }); - } - - const { error } = await withError(service.deleteResults(reqData.resultsIds)); - - if (error) { - return new Response(error.message, { status: 404 }); - } - - return Response.json({ - message: `Results files deleted successfully`, - resultsIds: reqData.resultsIds, - }); -} diff --git a/app/api/result/list/route.ts b/app/api/result/list/route.ts deleted file mode 100644 index 2af78e0d..00000000 --- a/app/api/result/list/route.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { type NextRequest } from 'next/server'; - -import { service } from '@/app/lib/service'; -import { withError } from '@/app/lib/withError'; -import { parseFromRequest } from '@/app/lib/storage/pagination'; - -export const dynamic = 'force-dynamic'; // defaults to auto - -export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const pagination = parseFromRequest(searchParams); - const project = searchParams.get('project') ?? ''; - const tags = searchParams.get('tags')?.split(',').filter(Boolean) ?? []; - const search = searchParams.get('search') ?? ''; - const dateFrom = searchParams.get('dateFrom') ?? undefined; - const dateTo = searchParams.get('dateTo') ?? undefined; - - const { result, error } = await withError( - service.getResults({ pagination, project, tags, search, dateFrom, dateTo }), - ); - - if (error) { - return new Response(error.message, { status: 400 }); - } - - return Response.json(result); -} diff --git a/app/api/result/projects/route.ts b/app/api/result/projects/route.ts deleted file mode 100644 index 1548a20e..00000000 --- a/app/api/result/projects/route.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { service } from '@/app/lib/service'; -import { withError } from '@/app/lib/withError'; - -export const dynamic = 'force-dynamic'; // defaults to auto - -export async function GET() { - const { result: projects, error } = await withError(service.getResultsProjects()); - - if (error) { - return new Response(error.message, { status: 400 }); - } - - return Response.json(projects); -} diff --git a/app/api/result/tags/route.ts b/app/api/result/tags/route.ts deleted file mode 100644 index 1439896e..00000000 --- a/app/api/result/tags/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NextRequest } from 'next/server'; - -import { withError } from '@/app/lib/withError'; -import { service } from '@/app/lib/service'; - -export const dynamic = 'force-dynamic'; - -export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const project = searchParams.get('project') ?? ''; - - const { result: tags, error } = await withError(service.getResultsTags(project)); - - if (error) { - return new Response(error.message, { status: 400 }); - } - - return Response.json(tags); -} diff --git a/app/api/serve/[[...filePath]]/route.ts b/app/api/serve/[[...filePath]]/route.ts deleted file mode 100644 index ad7e4332..00000000 --- a/app/api/serve/[[...filePath]]/route.ts +++ /dev/null @@ -1,63 +0,0 @@ -import path from 'path'; - -import mime from 'mime'; -import { type NextRequest, NextResponse } from 'next/server'; -import { redirect } from 'next/navigation'; - -import { withError } from '@/app/lib/withError'; -import { storage } from '@/app/lib/storage'; -import { auth } from '@/app/auth'; -import { env } from '@/app/config/env'; -import { withBase } from '@/app/lib/url'; - -interface ReportParams { - reportId: string; - filePath?: string[]; -} - -export async function GET( - req: NextRequest, - { - params, - }: { - params: ReportParams; - }, -) { - // is not protected by the middleware - // as we want to have callbackUrl in the query - - const authRequired = !!env.API_TOKEN; - const session = await auth(); - - const { filePath } = params; - - const uriPath = Array.isArray(filePath) ? filePath.join('/') : (filePath ?? ''); - - const targetPath = decodeURI(uriPath); - - // Only check for session if auth is required - if (authRequired && !session?.user?.jwtToken) { - redirect(withBase(`/login?callbackUrl=${encodeURI(req.nextUrl.pathname)}`)); - } - - const contentType = mime.getType(path.basename(targetPath)); - - if (!contentType && !path.extname(targetPath)) { - return NextResponse.next(); - } - - const { result: content, error } = await withError(storage.readFile(targetPath, contentType)); - - if (error ?? !content) { - return NextResponse.json({ error: `Could not read file ${error?.message ?? ''}` }, { status: 404 }); - } - - const headers = { - headers: { - 'Content-Type': contentType ?? 'application/octet-stream', - Authorization: `Bearer ${session?.user?.apiToken}`, - }, - }; - - return new Response(content, headers); -} diff --git a/app/api/static/[[...filePath]]/route.ts b/app/api/static/[[...filePath]]/route.ts deleted file mode 100644 index 28ea2306..00000000 --- a/app/api/static/[[...filePath]]/route.ts +++ /dev/null @@ -1,52 +0,0 @@ -import path from 'node:path'; -import fs from 'node:fs/promises'; - -import mime from 'mime'; -import { type NextRequest, NextResponse } from 'next/server'; - -import { DATA_FOLDER } from '@/app/lib/storage/constants'; -import { withError } from '@/app/lib/withError'; - -export const dynamic = 'force-dynamic'; // defaults to auto - -interface ServeParams { - filePath?: string[]; -} - -export async function GET( - _: NextRequest, - { - params, - }: { - params: ServeParams; - }, -) { - const { filePath } = params; - - const uriPath = Array.isArray(filePath) ? filePath.join('/') : (filePath ?? ''); - - const targetPath = decodeURI(uriPath); - - const contentType = mime.getType(path.basename(targetPath)); - - if (!contentType && !path.extname(targetPath)) { - return NextResponse.next(); - } - - const imageDataPath = path.join(DATA_FOLDER, targetPath); - const imagePublicPath = path.join('public', targetPath); - - const { error: dataAccessError } = await withError(fs.access(imageDataPath)); - - const imagePath = dataAccessError ? imagePublicPath : imageDataPath; - - const imageBuffer = await fs.readFile(imagePath); - - const headers = { - headers: { - 'Content-Type': contentType ?? 'image/*', - }, - }; - - return new Response(imageBuffer, headers); -} diff --git a/app/auth.ts b/app/auth.ts deleted file mode 100644 index a4427e6d..00000000 --- a/app/auth.ts +++ /dev/null @@ -1,112 +0,0 @@ -import NextAuth from 'next-auth'; -import { NextAuthConfig } from 'next-auth'; -import { type User } from 'next-auth'; -import CredentialsProvider from 'next-auth/providers/credentials'; -import jwt from 'jsonwebtoken'; - -import { env } from './config/env'; - -const useAuth = !!env.API_TOKEN; - -// strictly recommended to specify via env var -// Use a stable default secret when AUTH_SECRET is not set to avoid JWT decryption errors -// This is only acceptable when auth is disabled (no API_TOKEN) -const secret = env.AUTH_SECRET ?? 'default-secret-for-non-auth-mode'; - -// session expiration for api token auth -const expirationHours = env.UI_AUTH_EXPIRE_HOURS ? parseInt(env.UI_AUTH_EXPIRE_HOURS) : 2; -const expirationSeconds = expirationHours * 60 * 60; - -export const authConfig: NextAuthConfig = { - secret, - providers: [ - CredentialsProvider({ - name: 'API Token', - credentials: { - apiToken: { label: 'API Token', type: 'password' }, - }, - async authorize(credentials): Promise { - if (credentials?.apiToken === env.API_TOKEN) { - const token = jwt.sign({ authorized: true }, secret); - - return { - apiToken: credentials.apiToken as string, - jwtToken: token, - }; - } - - return null; - }, - }), - ], - callbacks: { - async jwt({ token, user }) { - if (user) { - token.apiToken = user.apiToken; - token.jwtToken = user.jwtToken; - } - - return token; - }, - async session({ session, token }) { - session.user.apiToken = token.apiToken as string; - session.user.jwtToken = token.jwtToken as string; - - return session; - }, - }, - session: { - strategy: 'jwt', - maxAge: expirationSeconds, - }, - trustHost: true, - pages: { - signIn: '/login', - }, -}; - -const getJwtStubToken = () => { - return jwt.sign({ authorized: true }, secret); -}; - -const noAuth = { - providers: [ - CredentialsProvider({ - name: 'No Auth', - credentials: {}, - async authorize() { - const token = getJwtStubToken(); - - return { apiToken: token, jwtToken: token }; - }, - }), - ], - callbacks: { - authorized: async () => { - return true; - }, - async jwt({ token, user }) { - if (user) { - token.apiToken = user.apiToken; - token.jwtToken = user.jwtToken; - } - - return token; - }, - async session({ session, token }) { - session.sessionToken = getJwtStubToken(); - session.user.jwtToken = session.sessionToken; - session.user.apiToken = token.apiToken as string; - - return session; - }, - }, - trustHost: true, - session: { - strategy: 'jwt', - maxAge: expirationSeconds, - }, - secret, -} satisfies NextAuthConfig; - -export const { handlers, auth, signIn, signOut } = NextAuth(useAuth ? authConfig : noAuth); diff --git a/app/components/aside.tsx b/app/components/aside.tsx deleted file mode 100644 index 0ca2d14f..00000000 --- a/app/components/aside.tsx +++ /dev/null @@ -1,80 +0,0 @@ -'use client'; - -import { Card, CardBody, Link, Badge } from '@heroui/react'; -import NextLink from 'next/link'; -import { usePathname } from 'next/navigation'; -import { useSession } from 'next-auth/react'; - -import { useAuthConfig } from '../hooks/useAuthConfig'; - -import { ReportIcon, ResultIcon, SettingsIcon, TrendIcon } from '@/app/components/icons'; -import { siteConfig } from '@/app/config/site'; -import useQuery from '@/app/hooks/useQuery'; - -interface ServerInfo { - numOfReports: number; - numOfResults: number; -} - -const iconst = [ - { href: '/reports', icon: ReportIcon }, - { href: '/results', icon: ResultIcon }, - { href: '/trends', icon: TrendIcon }, - { href: '/settings', icon: SettingsIcon }, -]; - -export const Aside: React.FC = () => { - const pathname = usePathname(); - const session = useSession(); - const { authRequired } = useAuthConfig(); - const isAuthenticated = authRequired === false || session.status === 'authenticated'; - - const { data: serverInfo } = useQuery('/api/info', { - enabled: isAuthenticated, - }); - - return ( - - -
- {siteConfig.navItems.map((item) => { - const isActive = pathname === item.href; - const Icon = iconst.find((icon) => icon.href === item.href)?.icon; - const count = - item.href === '/reports' - ? serverInfo?.numOfReports - : item.href === '/results' - ? serverInfo?.numOfResults - : 0; - - return ( - - {count !== undefined && count > 0 ? ( - - {Icon && } - - ) : ( - Icon && - )} - - ); - })} -
-
-
- ); -}; diff --git a/app/components/date-format.tsx b/app/components/date-format.tsx deleted file mode 100644 index c6b54a8b..00000000 --- a/app/components/date-format.tsx +++ /dev/null @@ -1,16 +0,0 @@ -'use client'; -import { useState, useEffect } from 'react'; - -/** - * Specific method for date formatting on the client - * as server locale and client locale may not match - */ -export default function FormattedDate({ date }: { date: Date }) { - const [formattedDate, setFormattedDate] = useState(''); - - useEffect(() => { - setFormattedDate(new Date(date).toLocaleString()); - }, [date]); - - return {formattedDate}; -} diff --git a/app/components/date-range-picker.tsx b/app/components/date-range-picker.tsx deleted file mode 100644 index f2be3a49..00000000 --- a/app/components/date-range-picker.tsx +++ /dev/null @@ -1,108 +0,0 @@ -'use client'; - -import { DateRangePicker as HeroUIDateRangePicker } from '@heroui/react'; -import { useCallback, useMemo } from 'react'; -import { CalendarDateTime } from '@internationalized/date'; -import { I18nProvider } from '@react-aria/i18n'; - -interface DateRangePickerProps { - dateFrom?: string; - dateTo?: string; - label?: string; - onDateFromChange?: (date: string) => void; - onDateToChange?: (date: string) => void; -} - -export default function DateRangePicker({ - dateFrom, - dateTo, - label = 'Date Range', - onDateFromChange, - onDateToChange, -}: Readonly) { - // Convert ISO strings to CalendarDateTime for HeroUI DateRangePicker (includes time fields) - const defaultValue = useMemo(() => { - if (!dateFrom || !dateTo) return undefined; - - try { - // Parse ISO strings and convert to CalendarDateTime (includes time) - const startDate = new Date(dateFrom); - const endDate = new Date(dateTo); - - // Create CalendarDateTime objects with time - const start = new CalendarDateTime( - startDate.getFullYear(), - startDate.getMonth() + 1, - startDate.getDate(), - startDate.getHours(), - startDate.getMinutes(), - ); - const end = new CalendarDateTime( - endDate.getFullYear(), - endDate.getMonth() + 1, - endDate.getDate(), - endDate.getHours(), - endDate.getMinutes(), - ); - - return { start, end }; - } catch { - return undefined; - } - }, [dateFrom, dateTo]); - - const handleChange = useCallback( - (range: { start: any; end: any } | null) => { - if (!range) { - onDateFromChange?.(''); - onDateToChange?.(''); - - return; - } - - if (range.start && onDateFromChange) { - // Convert CalendarDateTime to ISO string - const year = range.start.year; - const month = String(range.start.month).padStart(2, '0'); - const day = String(range.start.day).padStart(2, '0'); - const hour = String(range.start.hour).padStart(2, '0'); - const minute = String(range.start.minute).padStart(2, '0'); - const isoString = `${year}-${month}-${day}T${hour}:${minute}:00.000Z`; - - onDateFromChange(isoString); - } else if (!range.start && onDateFromChange) { - onDateFromChange(''); - } - - if (range.end && onDateToChange) { - // Convert CalendarDateTime to ISO string - const year = range.end.year; - const month = String(range.end.month).padStart(2, '0'); - const day = String(range.end.day).padStart(2, '0'); - const hour = String(range.end.hour).padStart(2, '0'); - const minute = String(range.end.minute).padStart(2, '0'); - const isoString = `${year}-${month}-${day}T${hour}:${minute}:00.000Z`; - - onDateToChange(isoString); - } else if (!range.end && onDateToChange) { - onDateToChange(''); - } - }, - [onDateFromChange, onDateToChange], - ); - - return ( - - - - ); -} diff --git a/app/components/delete-report-button.tsx b/app/components/delete-report-button.tsx deleted file mode 100644 index 624596c5..00000000 --- a/app/components/delete-report-button.tsx +++ /dev/null @@ -1,91 +0,0 @@ -'use client'; - -import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, useDisclosure, Button } from '@heroui/react'; -import { useQueryClient } from '@tanstack/react-query'; -import { toast } from 'sonner'; - -import useMutation from '@/app/hooks/useMutation'; -import { DeleteIcon } from '@/app/components/icons'; -import { invalidateCache } from '@/app/lib/query-cache'; - -interface DeleteProjectButtonProps { - reportId?: string; - reportIds?: string[]; - onDeleted: () => void; - label?: string; -} - -export default function DeleteReportButton({ reportId, reportIds, onDeleted, label }: DeleteProjectButtonProps) { - const queryClient = useQueryClient(); - const ids = reportIds ?? (reportId ? [reportId] : []); - - const { - mutate: deleteReport, - isPending, - error, - } = useMutation('/api/report/delete', { - method: 'DELETE', - onSuccess: () => { - invalidateCache(queryClient, { queryKeys: ['/api/info'], predicate: '/api/report' }); - toast.success(`report${ids.length > 1 ? 's' : ''} deleted`); - }, - }); - - const { isOpen, onOpen, onOpenChange } = useDisclosure(); - - const DeleteReport = async () => { - if (!ids.length) { - return; - } - - deleteReport({ body: { reportsIds: ids } }); - - onDeleted?.(); - }; - - error && toast.error(error.message); - - return ( - <> - - - - {(onClose) => ( - <> - Are you sure? - -

This will permanently delete your report{ids.length > 1 ? 's' : ''}.

-
- - - - - - )} -
-
- - ); -} diff --git a/app/components/delete-results-button.tsx b/app/components/delete-results-button.tsx deleted file mode 100644 index 3ed0ce85..00000000 --- a/app/components/delete-results-button.tsx +++ /dev/null @@ -1,94 +0,0 @@ -'use client'; - -import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, useDisclosure, Button } from '@heroui/react'; -import { useQueryClient } from '@tanstack/react-query'; -import { toast } from 'sonner'; - -import useMutation from '@/app/hooks/useMutation'; -import { invalidateCache } from '@/app/lib/query-cache'; -import { DeleteIcon } from '@/app/components/icons'; - -interface DeleteProjectButtonProps { - resultIds: string[]; - onDeletedResult?: () => void; - label?: string; -} - -export default function DeleteResultsButton({ resultIds, onDeletedResult, label }: Readonly) { - const queryClient = useQueryClient(); - const { - mutate: deleteResult, - isPending, - error, - } = useMutation('/api/result/delete', { - method: 'DELETE', - onSuccess: () => { - invalidateCache(queryClient, { queryKeys: ['/api/info'], predicate: '/api/result' }); - toast.success(`result${resultIds.length ? '' : 's'} ${resultIds ?? 'are'} deleted`); - }, - }); - - const { isOpen, onOpen, onOpenChange } = useDisclosure(); - - const DeleteResult = async () => { - if (!resultIds?.length) { - return; - } - - deleteResult({ body: { resultsIds: resultIds } }); - - onDeletedResult?.(); - }; - - error && toast.error(error.message); - - return ( - <> - - - - {(onClose) => ( - <> - Are you sure? - -

This will permanently delete your results files.

-
- - - - - - )} -
-
- - ); -} diff --git a/app/components/generate-report-button.tsx b/app/components/generate-report-button.tsx deleted file mode 100644 index f93e4219..00000000 --- a/app/components/generate-report-button.tsx +++ /dev/null @@ -1,154 +0,0 @@ -'use client'; - -import { - Button, - Modal, - ModalContent, - ModalHeader, - ModalBody, - useDisclosure, - ModalFooter, - Autocomplete, - AutocompleteItem, - Input, -} from '@heroui/react'; -import { useEffect, useState } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; -import { toast } from 'sonner'; - -import { type Result } from '../lib/storage'; - -import useQuery from '@/app/hooks/useQuery'; -import useMutation from '@/app/hooks/useMutation'; -import { invalidateCache } from '@/app/lib/query-cache'; - -interface DeleteProjectButtonProps { - results: Result[]; - projects: string[]; - onGeneratedReport?: () => void; -} - -export default function GenerateReportButton({ - results, - projects, - onGeneratedReport, -}: Readonly) { - const queryClient = useQueryClient(); - const [generationError, setGenerationError] = useState(null); - - const { mutate: generateReport, isPending } = useMutation('/api/report/generate', { - method: 'POST', - onSuccess: (data: { reportId: string }) => { - invalidateCache(queryClient, { queryKeys: ['/api/info'], predicate: '/api/report' }); - toast.success(`report ${data?.reportId} is generated`); - setProjectName(''); - setCustomName(''); - setGenerationError(null); - onClose(); - onGeneratedReport?.(); - }, - onError: (err: Error) => { - setGenerationError(err.message); - }, - }); - - const { - data: resultProjects, - error: resultProjectsError, - isLoading: isResultProjectsLoading, - } = useQuery(`/api/result/projects`); - - const [projectName, setProjectName] = useState(''); - const [customName, setCustomName] = useState(''); - - useEffect(() => { - !projectName && setProjectName(projects?.at(0) ?? ''); - }, [projects]); - - const { isOpen, onOpen, onClose, onOpenChange } = useDisclosure(); - - const handleModalOpen = () => { - setGenerationError(null); - onOpen(); - }; - - const GenerateReport = async () => { - if (!results?.length) { - return; - } - - setGenerationError(null); - generateReport({ body: { resultsIds: results.map((r) => r.resultID), project: projectName, title: customName } }); - }; - - return ( - <> - - - - {(onClose) => ( - <> - Generate report - - {generationError ? ( -
-

Report generation failed:

-
-                      {generationError}
-                    
-
- ) : ( - <> - ({ - label: project, - value: project, - }))} - label="Project name" - labelPlacement="outside" - placeholder="leave empty if not required" - variant="bordered" - onInputChange={(value) => setProjectName(value)} - onSelectionChange={(value) => value && setProjectName(value?.toString() ?? '')} - > - {(item) => {item.label}} - - setCustomName(e.target.value ?? '')} - onClear={() => setCustomName('')} - /> - - )} -
- - - {!generationError && ( - - )} - - - )} -
-
- - ); -} diff --git a/app/components/header-links.tsx b/app/components/header-links.tsx deleted file mode 100644 index 3386e636..00000000 --- a/app/components/header-links.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Link } from '@heroui/link'; - -import { - GithubIcon, - DiscordIcon, - TelegramIcon, - LinkIcon, - BitbucketIcon, - CyborgTestIcon, - SlackIcon, -} from '@/app/components/icons'; -import { SiteWhiteLabelConfig } from '@/app/types'; - -interface HeaderLinksProps { - config: SiteWhiteLabelConfig; - withTitle?: boolean; -} - -export const HeaderLinks: React.FC = ({ config, withTitle = false }) => { - const links = config?.headerLinks; - - const availableSocialLinkIcons = [ - { name: 'telegram', Icon: TelegramIcon, title: 'Telegram' }, - { name: 'discord', Icon: DiscordIcon, title: 'Discord' }, - { name: 'github', Icon: GithubIcon, title: 'GitHub' }, - { name: 'cyborgTest', Icon: CyborgTestIcon, title: 'Cyborg Test' }, - { name: 'bitbucket', Icon: BitbucketIcon, title: 'Bitbucket' }, - { name: 'slack', Icon: SlackIcon, title: 'Slack' }, - ]; - - const socialLinks = Object.entries(links).map(([name, href]) => { - const availableLink = availableSocialLinkIcons.find((available) => available.name === name); - - const Icon = availableLink?.Icon ?? LinkIcon; - const title = availableLink?.title ?? name; - - return href ? ( - - - {withTitle &&

{title}

} - - ) : null; - }); - - return socialLinks; -}; diff --git a/app/components/icons.tsx b/app/components/icons.tsx deleted file mode 100644 index 6ecfa719..00000000 --- a/app/components/icons.tsx +++ /dev/null @@ -1,318 +0,0 @@ -import { FC } from 'react'; - -import { IconSvgProps } from '@/app/types'; - -export const DiscordIcon: FC = ({ size = 40, width, height, ...props }) => { - return ( - - - - ); -}; - -export const GithubIcon: FC = ({ size = 40, width, height, ...props }) => { - return ( - - - - ); -}; - -export const BitbucketIcon: FC = ({ size = 40, width, height, ...props }) => { - return ( - - - - - - - - ); -}; - -export const CyborgTestIcon: FC = ({ size = 40, width, height, ...props }) => { - return ( - - - - - - ); -}; - -export const TelegramIcon: FC = ({ size = 40, width, height, ...props }) => { - return ( - - - - ); -}; - -export const MoonFilledIcon = ({ size = 40, width, height, ...props }: IconSvgProps) => ( - -); - -export const SunFilledIcon = ({ size = 40, width, height, ...props }: IconSvgProps) => ( - -); - -export const LinkIcon: FC = ({ width, height, ...props }) => { - return ( - - - - - ); -}; - -export const ReportIcon: FC = ({ size = 40, width, height, ...props }) => { - return ( - - - - - - ); -}; - -export const ResultIcon: FC = ({ size = 40, width, height, ...props }) => { - return ( - - - - - - ); -}; - -export const TrendIcon: FC = ({ size = 40, width, height, ...props }) => { - return ( - - - - - - ); -}; - -export const DeleteIcon: FC = (props) => ( - -); - -export const EyeIcon: FC = (props) => ( - -); - -export const SearchIcon: FC = (props) => ( - -); - -export const BranchIcon: FC = ({ width, height, ...props }) => { - return ( - - - - ); -}; - -export const FolderIcon: FC = ({ width, height, ...props }) => { - return ( - - - - ); -}; - -export const SettingsIcon: FC = ({ size = 24, width, height, ...props }) => { - return ( - - - - - ); -}; diff --git a/app/components/login-form.tsx b/app/components/login-form.tsx deleted file mode 100644 index 2f97a1ec..00000000 --- a/app/components/login-form.tsx +++ /dev/null @@ -1,104 +0,0 @@ -'use client'; - -import { type FormEvent, useEffect, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { Button, Card, CardBody, CardFooter, CardHeader, Input, Spinner } from '@heroui/react'; -import { getProviders, signIn, useSession } from 'next-auth/react'; - -import { title } from '@/app/components/primitives'; - -export default function LoginForm() { - const [input, setInput] = useState(''); - const [error, setError] = useState(''); - const [isAutoSigningIn, setIsAutoSigningIn] = useState(true); - const router = useRouter(); - const session = useSession(); - const searchParams = useSearchParams(); - - const target = searchParams?.get('callbackUrl') ?? '/'; - const callbackUrl = decodeURI(target); - - useEffect(() => { - // redirect if already authenticated - if (session.status === 'authenticated') { - router.replace(callbackUrl); - - return; - } - - // check if we can sign in automatically - getProviders() - .then((providers) => { - // if no api token required we can automatically sign user in - if (providers?.credentials.name === 'No Auth') { - return signIn('credentials', { - redirect: false, - }).then((response) => { - if (!response?.error && response?.ok) { - router.replace(callbackUrl); - } else { - setIsAutoSigningIn(false); - } - }); - } else { - setIsAutoSigningIn(false); - } - }) - .catch(() => { - setIsAutoSigningIn(false); - }); - }, []); - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - - const result = await signIn('credentials', { - apiToken: input, - redirect: false, - }); - - result?.error ? setError('invalid API key') : router.replace(callbackUrl); - }; - - // Show spinner while session is loading or while auto-signing in - if (session.status === 'loading' || isAutoSigningIn) { - return ; - } - - return ( -
-

Login

- - -

Please provide API key to sign in

-
-
- - { - const newValue = e.target.value; - - if (!newValue && error) { - setError(''); - } - setInput(newValue); - }} - /> - - - - -
-
-
- ); -} diff --git a/app/components/navbar.tsx b/app/components/navbar.tsx deleted file mode 100644 index 344b60ee..00000000 --- a/app/components/navbar.tsx +++ /dev/null @@ -1,90 +0,0 @@ -'use client'; -import { - Navbar as NextUINavbar, - NavbarContent, - NavbarMenu, - NavbarMenuToggle, - NavbarBrand, - NavbarItem, -} from '@heroui/navbar'; -import Image from 'next/image'; -import NextLink from 'next/link'; -import { toast } from 'sonner'; -import { Skeleton } from '@heroui/skeleton'; - -import { withBase } from '../lib/url'; - -import { subtitle } from './primitives'; - -import { defaultConfig } from '@/app/lib/config'; -import { HeaderLinks } from '@/app/components/header-links'; -import { ThemeSwitch } from '@/app/components/theme-switch'; -import { SiteWhiteLabelConfig } from '@/app/types'; -import useQuery from '@/app/hooks/useQuery'; - -export const Navbar: React.FC = () => { - const { data: config, error, isLoading } = useQuery('/api/config'); - - const isCustomLogo = config?.logoPath !== defaultConfig.logoPath; - const isCustomTitle = config?.title !== defaultConfig.title; - - if (error) { - toast.error(error.message); - } - - return ( - - - - - - {config && ( - Logo - )} - - - - {isCustomTitle &&

{config?.title}

} -
-
- - - - {config && !isLoading ? ( - - ) : ( - - )} - - - - - {/* mobile view fallback */} - - - {!!config && } - - - -
- {config && !isLoading ? : } -
-
-
- ); -}; diff --git a/app/components/page-layout.tsx b/app/components/page-layout.tsx deleted file mode 100644 index 4978e7db..00000000 --- a/app/components/page-layout.tsx +++ /dev/null @@ -1,76 +0,0 @@ -'use client'; - -import { useLayoutEffect, useState, useEffect } from 'react'; -import { v4 as uuidv4 } from 'uuid'; -import { useSession } from 'next-auth/react'; -import { Spinner } from '@heroui/react'; -import { toast } from 'sonner'; - -import useQuery from '@/app/hooks/useQuery'; -import { useAuthConfig } from '@/app/hooks/useAuthConfig'; -import { type ServerDataInfo } from '@/app/lib/storage'; - -interface PageLayoutProps { - render: (props: { info: ServerDataInfo; onUpdate: () => void }) => React.ReactNode; -} - -export default function PageLayout({ render }: Readonly) { - const { data: session, status } = useSession(); - const authIsLoading = status === 'loading'; - const { authRequired } = useAuthConfig(); - const isAuthenticated = authRequired === false || status === 'authenticated'; - - const { - data: info, - error, - refetch, - isLoading: isInfoLoading, - } = useQuery('/api/info', { - enabled: isAuthenticated, - }); - const [refreshId, setRefreshId] = useState(uuidv4()); - - useEffect(() => { - // Only show error if auth is required - if (authRequired === false) { - return; - } - - if (!authIsLoading && !session && authRequired === true) { - toast.error('You are not authenticated'); - } - }, [authIsLoading, session, authRequired]); - - useLayoutEffect(() => { - // skip refetch is not authorized - if (authRequired && (authIsLoading || !session)) { - return; - } - - refetch({ cancelRefetch: false }); - }, [refreshId, session, authRequired]); - - if (authIsLoading || isInfoLoading) { - return ; - } - - const updateRefreshId = () => { - setRefreshId(uuidv4()); - }; - - if (error) { - toast.error(error.message); - - return
Error loading data: {error.message}
; - } - - return ( - <> - {!!info && ( -
-
{render({ info, onUpdate: updateRefreshId })}
-
- )} - - ); -} diff --git a/app/components/primitives.ts b/app/components/primitives.ts deleted file mode 100644 index c1386e4c..00000000 --- a/app/components/primitives.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { tv } from 'tailwind-variants'; - -export const title = tv({ - base: 'tracking-tight inline font-semibold', - variants: { - color: { - violet: 'from-[#FF1CF7] to-[#b249f8]', - yellow: 'from-[#FF705B] to-[#FFB457]', - blue: 'from-[#5EA2EF] to-[#0072F5]', - cyan: 'from-[#00b7fa] to-[#01cfea]', - green: 'from-[#6FEE8D] to-[#17c964]', - pink: 'from-[#FF72E1] to-[#F54C7A]', - foreground: 'dark:from-[#FFFFFF] dark:to-[#4B4B4B]', - }, - size: { - sm: 'text-3xl lg:text-4xl', - md: 'text-[2.3rem] lg:text-5xl leading-9', - lg: 'text-4xl lg:text-6xl', - }, - fullWidth: { - true: 'w-full block', - }, - }, - defaultVariants: { - size: 'md', - }, - compoundVariants: [ - { - color: ['violet', 'yellow', 'blue', 'cyan', 'green', 'pink', 'foreground'], - class: 'bg-clip-text text-transparent bg-gradient-to-b', - }, - ], -}); - -export const subtitle = tv({ - base: 'w-full md:w-1/2 my-2 text-lg lg:text-xl text-default-600 block max-w-full', - variants: { - fullWidth: { - true: '!w-full', - }, - }, - defaultVariants: { - fullWidth: true, - }, -}); diff --git a/app/components/project-select.tsx b/app/components/project-select.tsx deleted file mode 100644 index 98fc9c78..00000000 --- a/app/components/project-select.tsx +++ /dev/null @@ -1,58 +0,0 @@ -'use client'; - -import { Select, SelectItem, SharedSelection } from '@heroui/react'; -import { toast } from 'sonner'; - -import useQuery from '../hooks/useQuery'; -import { defaultProjectName } from '../lib/constants'; - -interface ProjectSelectProps { - onSelect: (project: string) => void; - refreshId?: string; - entity: 'result' | 'report'; -} - -export default function ProjectSelect({ refreshId, onSelect, entity }: Readonly) { - const { - data: projects, - error, - isLoading, - } = useQuery(`/api/${entity}/projects`, { - dependencies: [refreshId], - }); - - const items = [defaultProjectName, ...(projects ?? [])]; - - const onChange = (keys: SharedSelection) => { - if (keys === defaultProjectName.toString()) { - onSelect?.(defaultProjectName); - - return; - } - - if (!keys.currentKey) { - return; - } - - onSelect?.(keys.currentKey); - }; - - error && toast.error(error.message); - - return ( - - ); -} diff --git a/app/components/report-details/file-list.tsx b/app/components/report-details/file-list.tsx deleted file mode 100644 index 91631b40..00000000 --- a/app/components/report-details/file-list.tsx +++ /dev/null @@ -1,83 +0,0 @@ -'use client'; - -import { FC, useEffect, useState } from 'react'; -import { Accordion, AccordionItem, Spinner } from '@heroui/react'; -import { toast } from 'sonner'; - -import { subtitle } from '../primitives'; -import { StatChart } from '../stat-chart'; - -import FileSuitesTree from './suite-tree'; -import ReportFilters from './tests-filters'; - -import { type ReportHistory } from '@/app/lib/storage'; -import useQuery from '@/app/hooks/useQuery'; -import { pluralize } from '@/app/lib/transformers'; - -interface FileListProps { - report?: ReportHistory | null; -} - -const FileList: FC = ({ report }) => { - const { - data: history, - isLoading: isHistoryLoading, - error: historyError, - } = useQuery(`/api/report/trend?limit=10&project=${report?.project ?? ''}`, { - callback: `/report/${report?.reportID}`, - dependencies: [report?.reportID], - }); - - const [filteredTests, setFilteredTests] = useState(report!); - - useEffect(() => { - if (historyError) { - toast.error(historyError.message); - } - }, [historyError]); - - if (!report) { - return ; - } - - return isHistoryLoading ? ( - - ) : ( -
-
-

File list

- -
- {!filteredTests?.files?.length ? ( -

No files found

- ) : ( - - {(filteredTests?.files ?? []).map((file) => ( - - {file.fileName} - - {file.tests.length} {pluralize(file.tests.length, 'test', 'tests')} - -

- } - > -
- -
-

Tests

- -
-
-
- ))} -
- )} -
- ); -}; - -export default FileList; diff --git a/app/components/report-details/report-stats.tsx b/app/components/report-details/report-stats.tsx deleted file mode 100644 index b37d0883..00000000 --- a/app/components/report-details/report-stats.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { FC } from 'react'; - -import { StatChart } from '../stat-chart'; - -import { type ReportStats } from '@/app/lib/parser'; -import { pluralize } from '@/app/lib/transformers'; - -interface StatisticsProps { - stats?: ReportStats; -} - -const ReportStatistics: FC = ({ stats }) => { - if (!stats || Object.keys(stats).length === 0) { - return
No statistics available
; - } - - return ( -
-

- Total: {stats.total} {pluralize(stats.total, 'test', 'tests')} -

- -
- ); -}; - -export default ReportStatistics; diff --git a/app/components/report-details/suite-tree.tsx b/app/components/report-details/suite-tree.tsx deleted file mode 100644 index 0fdc9956..00000000 --- a/app/components/report-details/suite-tree.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { Accordion, AccordionItem, Chip } from '@heroui/react'; - -import TestInfo from './test-info'; - -import { type ReportFile, type ReportTest } from '@/app/lib/parser'; -import { type ReportHistory } from '@/app/lib/storage'; -import { testStatusToColor } from '@/app/lib/tailwind'; - -interface SuiteNode { - name: string; - children: SuiteNode[]; - tests: ReportTest[]; -} - -function buildTestTree(rootName: string, tests: ReportTest[]): SuiteNode { - const root: SuiteNode = { name: rootName, children: [], tests: [] }; - - tests.forEach((test) => { - const { path } = test; - - const noSuites = path.length === 0; - - if (noSuites) { - root.tests.push(test); - - return; - } - - const lastNodeIndex = path.length - 1; - - path.reduce((currentNode, suiteName, index) => { - const existingSuite = currentNode.children.find((child) => child.name === suiteName); - - const noMoreSuites = index === lastNodeIndex; - - if (noMoreSuites && existingSuite) { - existingSuite.tests.push(test); - } - - if (existingSuite) { - return existingSuite; - } - - const newSuite: SuiteNode = { name: suiteName, children: [], tests: [] }; - - currentNode.children.push(newSuite); - - if (noMoreSuites) { - newSuite.tests.push(test); - } - - return newSuite; - }, root); - }); - - return root; -} - -interface SuiteNodeComponentProps { - suite: SuiteNode; - history: ReportHistory[]; -} - -const SuiteNodeComponent = ({ suite, history }: SuiteNodeComponentProps) => { - return ( - - {[ - ...suite.children.map((child) => ( - - - - )), - ...suite.tests.map((test) => { - const status = testStatusToColor(test.outcome); - - return ( - - {`· ${test.title}`} - - {status.title} - - - {test.projectName} - - - } - > - - - ); - }), - ]} - - ); -}; - -interface FileSuitesTreeProps { - file: ReportFile; - history: ReportHistory[]; - reportId?: string; -} - -const FileSuitesTree = ({ file, history }: FileSuitesTreeProps) => { - const suiteTree = buildTestTree(file.fileName, file.tests); - - return ; -}; - -export default FileSuitesTree; diff --git a/app/components/report-details/test-info.tsx b/app/components/report-details/test-info.tsx deleted file mode 100644 index dd312c65..00000000 --- a/app/components/report-details/test-info.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { FC } from 'react'; -import { Link, LinkIcon, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from '@heroui/react'; - -import FormattedDate from '../date-format'; - -import { subtitle } from '@/app/components/primitives'; -import { parseMilliseconds } from '@/app/lib/time'; -import { type TestHistory, type ReportHistory } from '@/app/lib/storage'; -import { type ReportTest } from '@/app/lib/parser/types'; -import { testStatusToColor } from '@/app/lib/tailwind'; -import { withBase } from '@/app/lib/url'; - -interface TestInfoProps { - history: ReportHistory[]; - test: ReportTest; -} - -const getTestHistory = (testId: string, history: ReportHistory[]) => { - return history - .map((report) => { - const file = report?.files?.find((file) => file.tests.some((test) => test.testId === testId)); - - if (!file) { - return; - } - - const test = file.tests.find((test) => test.testId === testId); - - if (!test) { - return; - } - - return { - ...test, - createdAt: report.createdAt, - reportID: report.reportID, - reportUrl: report.reportUrl, - }; - }) - .filter(Boolean) as unknown as TestHistory[]; -}; - -const TestInfo: FC = ({ test, history }: TestInfoProps) => { - const formatted = testStatusToColor(test.outcome); - - const testHistory = getTestHistory(test.testId, history); - - return ( -
-
-

- Outcome: {formatted.title} -

-

Location: {`${test.location.file}:${test.location.line}:${test.location.column}`}

-

Duration: {parseMilliseconds(test.duration)}

- {test.annotations.length > 0 &&

Annotations: {test.annotations.map((a) => JSON.stringify(a)).join(', ')}

} - {test.tags.length > 0 &&

Tags: {test.tags.join(', ')}

} -
- {!!testHistory?.length && ( -
-

Results:

- - - Created At - Status - Duration - Actions - - - {(item) => { - const itemOutcome = testStatusToColor(item?.outcome); - - return ( - - - - - - {itemOutcome.title} - - {parseMilliseconds(item.duration)} - - - - - - - ); - }} - -
-
- )} -
- ); -}; - -export default TestInfo; diff --git a/app/components/report-details/tests-filters.tsx b/app/components/report-details/tests-filters.tsx deleted file mode 100644 index f44b72a7..00000000 --- a/app/components/report-details/tests-filters.tsx +++ /dev/null @@ -1,88 +0,0 @@ -'use client'; - -import { FC, useCallback, useEffect, useState } from 'react'; -import { Accordion, AccordionItem, Checkbox, CheckboxGroup, Input } from '@heroui/react'; - -import { ReportTestOutcome } from '@/app/lib/parser/types'; -import { type ReportHistory } from '@/app/lib/storage/types'; -import { testStatusToColor } from '@/app/lib/tailwind'; -import { filterReportHistory, pluralize } from '@/app/lib/transformers'; - -type ReportFiltersProps = { - report: ReportHistory; - onChangeFilters: (report: ReportHistory) => void; -}; - -const testOutcomes = [ - ReportTestOutcome.Expected, - ReportTestOutcome.Unexpected, - ReportTestOutcome.Skipped, - ReportTestOutcome.Flaky, -]; - -const ReportFilters: FC = ({ report, onChangeFilters }) => { - const [byName, setByName] = useState(''); - const [byOutcomes, setByOutcomes] = useState(testOutcomes); - - const onNameChange = (name: string) => { - setByName(name); - }; - - const onOutcomeChange = (outcomes?: ReportTestOutcome[]) => { - setByOutcomes(!outcomes ? [] : outcomes); - }; - - useEffect(() => { - onChangeFilters(currentFilterState()); - }, [byOutcomes, byName]); - - const currentFilterState = useCallback(() => { - const filtered = filterReportHistory(report, { - name: byName, - outcomes: byOutcomes, - }); - - return filtered; - }, [byName, byOutcomes]); - - const currentState = currentFilterState(); - - return ( - - -

Showing

- - {currentState.testCount}/{currentState.totalTestCount}{' '} - {pluralize(currentState.testCount, 'test', 'tests')} - - - } - > - onOutcomeChange(values as ReportTestOutcome[])} - > - {testOutcomes.map((outcome) => { - const status = testStatusToColor(outcome); - - return ( - - {status.title} - - ); - })} - - onNameChange(e.target.value)} /> -
-
- ); -}; - -export default ReportFilters; diff --git a/app/components/report-trends.tsx b/app/components/report-trends.tsx deleted file mode 100644 index 3874f1a4..00000000 --- a/app/components/report-trends.tsx +++ /dev/null @@ -1,53 +0,0 @@ -'use client'; - -import { Spinner } from '@heroui/react'; -import { useCallback, useState } from 'react'; -import { toast } from 'sonner'; - -import { defaultProjectName } from '../lib/constants'; - -import ProjectSelect from './project-select'; - -import { TrendChart } from '@/app/components/trend-chart'; -import { title } from '@/app/components/primitives'; -import useQuery from '@/app/hooks/useQuery'; -import { type ReportHistory } from '@/app/lib/storage'; -import { withQueryParams } from '@/app/lib/network'; - -export default function ReportTrends() { - const [project, setProject] = useState(defaultProjectName); - - const { - data: reports, - error, - isFetching, - isPending, - } = useQuery( - withQueryParams('/api/report/trend', { - project, - }), - { dependencies: [project] }, - ); - - const onProjectChange = useCallback((project: string) => { - setProject(project); - }, []); - - error && toast.error(error.message); - - return ( -
-
-

Trends

-
- -
- {(isFetching || isPending) && } -
- -
- {!!reports?.length && } -
-
- ); -} diff --git a/app/components/reports-table.tsx b/app/components/reports-table.tsx deleted file mode 100644 index c6289087..00000000 --- a/app/components/reports-table.tsx +++ /dev/null @@ -1,312 +0,0 @@ -'use client'; - -import { useCallback, useState, useMemo } from 'react'; -import { - Table, - TableHeader, - TableColumn, - TableBody, - TableRow, - TableCell, - Button, - Spinner, - Pagination, - LinkIcon, - Chip, - type Selection, -} from '@heroui/react'; -import Link from 'next/link'; -import { keepPreviousData } from '@tanstack/react-query'; -import { toast } from 'sonner'; - -import { withBase } from '../lib/url'; - -import TablePaginationOptions from './table-pagination-options'; - -import { withQueryParams } from '@/app/lib/network'; -import { defaultProjectName } from '@/app/lib/constants'; -import useQuery from '@/app/hooks/useQuery'; -import DeleteReportButton from '@/app/components/delete-report-button'; -import FormattedDate from '@/app/components/date-format'; -import { BranchIcon, FolderIcon } from '@/app/components/icons'; -import { ReadReportsHistory, ReportHistory } from '@/app/lib/storage'; - -const columns = [ - { name: 'Title', uid: 'title' }, - { name: 'Project', uid: 'project' }, - { name: 'Created at', uid: 'createdAt' }, - { name: 'Size', uid: 'size' }, - { name: '', uid: 'actions' }, -]; - -const coreFields = [ - 'reportID', - 'title', - 'project', - 'createdAt', - 'size', - 'sizeBytes', - 'reportUrl', - 'metadata', - 'startTime', - 'duration', - 'files', - 'projectNames', - 'stats', - 'errors', -]; - -const formatMetadataValue = (value: any): string => { - if (value === null || value === undefined) { - return String(value); - } - if (typeof value === 'object') { - return JSON.stringify(value); - } - - return String(value); -}; - -const getMetadataItems = (item: ReportHistory) => { - const metadata: Array<{ key: string; value: any; icon?: React.ReactNode }> = []; - - // Cast to any to access dynamic properties that come from resultDetails - const itemWithMetadata = item as any; - - // Add specific fields in preferred order - if (itemWithMetadata.environment) { - metadata.push({ key: 'environment', value: itemWithMetadata.environment }); - } - if (itemWithMetadata.workingDir) { - const dirName = itemWithMetadata.workingDir.split('/').pop() || itemWithMetadata.workingDir; - - metadata.push({ key: 'workingDir', value: dirName, icon: }); - } - if (itemWithMetadata.branch) { - metadata.push({ key: 'branch', value: itemWithMetadata.branch, icon: }); - } - - // Add any other metadata fields - Object.entries(itemWithMetadata).forEach(([key, value]) => { - if (!coreFields.includes(key) && !['environment', 'workingDir', 'branch'].includes(key)) { - // Skip empty objects - if (value !== null && typeof value === 'object' && Object.keys(value).length === 0) { - return; - } - metadata.push({ key, value }); - } - }); - - return metadata; -}; - -interface ReportsTableProps { - onChange: () => void; - selected?: string[]; - onSelect?: (reports: ReportHistory[]) => void; - onDeleted?: () => void; -} - -export default function ReportsTable({ onChange, selected, onSelect, onDeleted }: Readonly) { - const reportListEndpoint = '/api/report/list'; - const [project, setProject] = useState(defaultProjectName); - const [search, setSearch] = useState(''); - const [dateFrom, setDateFrom] = useState(''); - const [dateTo, setDateTo] = useState(''); - const [page, setPage] = useState(1); - const [rowsPerPage, setRowsPerPage] = useState(10); - - const getQueryParams = () => ({ - limit: rowsPerPage.toString(), - offset: ((page - 1) * rowsPerPage).toString(), - project, - ...(search.trim() && { search: search.trim() }), - ...(dateFrom && { dateFrom }), - ...(dateTo && { dateTo }), - }); - - const { - data: reportResponse, - isFetching, - isPending, - error, - refetch, - } = useQuery(withQueryParams(reportListEndpoint, getQueryParams()), { - dependencies: [project, search, dateFrom, dateTo, rowsPerPage, page], - placeholderData: keepPreviousData, - }); - - const { reports, total } = reportResponse ?? {}; - - const handleDeleted = () => { - onDeleted?.(); - onChange?.(); - refetch(); - }; - - const onChangeSelect = (keys: Selection) => { - if (keys === 'all') { - const all = reports ?? []; - - onSelect?.(all); - } - - if (typeof keys === 'string') { - return; - } - - const selectedKeys = Array.from(keys); - const selectedReports = reports?.filter((r) => selectedKeys.includes(r.reportID)) ?? []; - - onSelect?.(selectedReports); - }; - - const onPageChange = useCallback( - (page: number) => { - setPage(page); - }, - [page, rowsPerPage], - ); - - const onProjectChange = useCallback( - (project: string) => { - setProject(project); - setPage(1); - }, - [page, rowsPerPage], - ); - - const onSearchChange = useCallback((searchTerm: string) => { - setSearch(searchTerm); - setPage(1); - }, []); - - const onDateFromChange = useCallback((date: string) => { - setDateFrom(date); - setPage(1); - }, []); - - const onDateToChange = useCallback((date: string) => { - setDateTo(date); - setPage(1); - }, []); - - const pages = useMemo(() => { - return total ? Math.ceil(total / rowsPerPage) : 0; - }, [project, total, rowsPerPage]); - - error && toast.error(error.message); - console.log('reports', reports); - - return ( - <> - - 1 ? ( -
- -
- ) : null - } - classNames={{ - wrapper: 'p-0 border-none shadow-none', - tr: 'border-b-1 rounded-0', - }} - radius="none" - selectedKeys={selected} - selectionMode="multiple" - onSelectionChange={onChangeSelect} - > - - {(column) => ( - - {column.name} - - )} - - } - > - {(item) => ( - - -
- {/* Main title and link */} - -
- {item.title || item.reportID} -
- - - {/* Metadata chips below title */} -
- {getMetadataItems(item).map(({ key, value, icon }, index) => { - const formattedValue = formatMetadataValue(value); - const displayValue = - key === 'branch' || key === 'workingDir' ? formattedValue : `${key}: ${formattedValue}`; - - return ( - - {displayValue} - - ); - })} -
-
-
- {item.project} - - - - {item.size} - -
- - - - -
-
-
- )} -
-
- - ); -} diff --git a/app/components/reports.tsx b/app/components/reports.tsx deleted file mode 100644 index 83dcfd94..00000000 --- a/app/components/reports.tsx +++ /dev/null @@ -1,46 +0,0 @@ -'use client'; - -import { useState } from 'react'; - -import ReportsTable from '@/app/components/reports-table'; -import { title } from '@/app/components/primitives'; -import DeleteReportButton from '@/app/components/delete-report-button'; -import { type ReportHistory } from '@/app/lib/storage'; - -interface ReportsProps { - onChange: () => void; -} - -export default function Reports({ onChange }: ReportsProps) { - const [selectedReports, setSelectedReports] = useState([]); - - const selectedReportIds = selectedReports.map((r) => r.reportID); - - const onListUpdate = () => { - setSelectedReports([]); - onChange?.(); - }; - - return ( - <> -
-
-

Reports

-
-
- {selectedReports.length > 0 && ( -
Reports selected: {selectedReports.length}
- )} - -
-
-
- - - ); -} diff --git a/app/components/results-table.tsx b/app/components/results-table.tsx deleted file mode 100644 index 88b8b430..00000000 --- a/app/components/results-table.tsx +++ /dev/null @@ -1,242 +0,0 @@ -'use client'; - -import { useCallback, useState, useMemo } from 'react'; -import { - Table, - TableHeader, - TableColumn, - TableBody, - TableRow, - TableCell, - Chip, - type Selection, - Spinner, - Pagination, -} from '@heroui/react'; -import { keepPreviousData } from '@tanstack/react-query'; -import { toast } from 'sonner'; - -import { withQueryParams } from '@/app/lib/network'; -import { defaultProjectName } from '@/app/lib/constants'; -import TablePaginationOptions from '@/app/components/table-pagination-options'; -import useQuery from '@/app/hooks/useQuery'; -import FormattedDate from '@/app/components/date-format'; -import { ReadResultsOutput, type Result } from '@/app/lib/storage'; -import DeleteResultsButton from '@/app/components/delete-results-button'; - -const columns = [ - { name: 'Result ID', uid: 'title' }, - { name: 'Project', uid: 'project' }, - { name: 'Created at', uid: 'createdAt' }, - { name: 'Tags', uid: 'tags' }, - { name: 'Size', uid: 'size' }, - { name: '', uid: 'actions' }, -]; - -const notMetadataKeys = ['resultID', 'title', 'createdAt', 'size', 'sizeBytes', 'project']; - -const getTags = (item: Result) => { - return Object.entries(item).filter(([key]) => !notMetadataKeys.includes(key)); -}; - -interface ResultsTableProps { - selected?: string[]; - onSelect?: (results: Result[]) => void; - onDeleted?: () => void; -} - -export default function ResultsTable({ onSelect, onDeleted, selected }: Readonly) { - const resultListEndpoint = '/api/result/list'; - const [project, setProject] = useState(defaultProjectName); - const [selectedTags, setSelectedTags] = useState([]); - const [search, setSearch] = useState(''); - const [dateFrom, setDateFrom] = useState(''); - const [dateTo, setDateTo] = useState(''); - const [page, setPage] = useState(1); - const [rowsPerPage, setRowsPerPage] = useState(10); - - const getQueryParams = () => ({ - limit: rowsPerPage.toString(), - offset: ((page - 1) * rowsPerPage).toString(), - project, - ...(selectedTags.length > 0 && { tags: selectedTags.join(',') }), - ...(search.trim() && { search: search.trim() }), - ...(dateFrom && { dateFrom }), - ...(dateTo && { dateTo }), - }); - - const { - data: resultsResponse, - isFetching, - isPending, - error, - refetch, - } = useQuery(withQueryParams(resultListEndpoint, getQueryParams()), { - dependencies: [project, selectedTags, search, dateFrom, dateTo, rowsPerPage, page], - placeholderData: keepPreviousData, - }); - - const { results, total } = resultsResponse ?? {}; - - const shouldRefetch = () => { - onDeleted?.(); - refetch(); - }; - - const onPageChange = useCallback( - (page: number) => { - setPage(page); - }, - [page, rowsPerPage], - ); - - const onProjectChange = useCallback( - (project: string) => { - setProject(project); - setPage(1); - }, - [page, rowsPerPage], - ); - - const onTagsChange = useCallback((tags: string[]) => { - setSelectedTags(tags); - setPage(1); - }, []); - - const onSearchChange = useCallback((searchTerm: string) => { - setSearch(searchTerm); - setPage(1); - }, []); - - const onDateFromChange = useCallback((date: string) => { - setDateFrom(date); - setPage(1); - }, []); - - const onDateToChange = useCallback((date: string) => { - setDateTo(date); - setPage(1); - }, []); - - const pages = useMemo(() => { - return total ? Math.ceil(total / rowsPerPage) : 0; - }, [project, total, rowsPerPage]); - - const onChangeSelect = (keys: Selection) => { - if (keys === 'all') { - const all = results ?? []; - - onSelect?.(all); - } - - if (typeof keys === 'string') { - return; - } - - const selectedKeys = Array.from(keys); - const selectedResults = results?.filter((r) => selectedKeys.includes(r.resultID)) ?? []; - - onSelect?.(selectedResults); - }; - - error && toast.error(error.message); - - return ( - <> - - 1 ? ( -
- -
- ) : null - } - classNames={{ - wrapper: 'p-0 border-none shadow-none', - tr: 'border-b-1 rounded-0', - }} - radius="none" - selectedKeys={selected} - selectionMode="multiple" - onSelectionChange={onChangeSelect} - > - - {(column) => ( - - {column.name} - - )} - - } - > - {(item) => ( - - {item.title ?? item.resultID} - {item.project} - - - - - {getTags(item).map(([key, value], index) => ( - {`${key}: ${value}`} - ))} - - {item.size} - -
- -
-
-
- )} -
-
- - ); -} diff --git a/app/components/results.tsx b/app/components/results.tsx deleted file mode 100644 index 88bdf081..00000000 --- a/app/components/results.tsx +++ /dev/null @@ -1,48 +0,0 @@ -'use client'; - -import { useState } from 'react'; - -import { type Result } from '@/app/lib/storage'; -import ResultsTable from '@/app/components/results-table'; -import { title } from '@/app/components/primitives'; -import GenerateReportButton from '@/app/components/generate-report-button'; -import DeleteResultsButton from '@/app/components/delete-results-button'; -import UploadResultsButton from '@/app/components/upload-results-button'; -import { getUniqueProjectsList } from '@/app/lib/storage/format'; - -interface ResultsProps { - onChange: () => void; -} - -export default function Results({ onChange }: Readonly) { - const [selectedResults, setSelectedResults] = useState([]); - - const selectedResultIds = selectedResults.map((r) => r.resultID); - - const projects = getUniqueProjectsList(selectedResults); - - const onListUpdate = () => { - setSelectedResults([]); - onChange?.(); - }; - - return ( - <> -
-
-

Results

-
-
- {selectedResults.length > 0 && ( -
Results selected: {selectedResults.length}
- )} - - - -
-
-
- - - ); -} diff --git a/app/components/stat-chart.tsx b/app/components/stat-chart.tsx deleted file mode 100644 index d5766dd7..00000000 --- a/app/components/stat-chart.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Label, Pie, PieChart } from 'recharts'; - -import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from '@/app/components/ui/chart'; - -const chartConfig = { - count: { - label: 'Count', - }, - expected: { - label: 'Passed', - color: 'hsl(var(--chart-1))', - }, - unexpected: { - label: 'Failed', - color: 'hsl(var(--chart-2))', - }, - flaky: { - label: 'Flaky', - color: 'hsl(var(--chart-3))', - }, - skipped: { - label: 'Skipped', - color: 'hsl(var(--chart-4))', - }, -} satisfies ChartConfig; - -interface StatChartProps { - stats: { - total: number; - expected: number; - unexpected: number; - flaky: number; - skipped: number; - ok: boolean; - }; -} - -export function StatChart({ stats }: Readonly) { - const chartData = [ - { - count: stats.expected, - status: 'Passed', - fill: 'hsl(var(--chart-1))', - }, - { - count: stats.unexpected, - status: 'Failed', - fill: 'hsl(var(--chart-2))', - }, - { count: stats.flaky, status: 'Flaky', fill: 'hsl(var(--chart-4))' }, - { - count: stats.skipped, - status: 'Skipped', - fill: 'hsl(var(--chart-3))', - }, - ]; - - return ( - - - } cursor={false} /> - - - - - ); -} diff --git a/app/components/table-pagination-options.tsx b/app/components/table-pagination-options.tsx deleted file mode 100644 index 23fc1468..00000000 --- a/app/components/table-pagination-options.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { type ChangeEvent, useCallback } from 'react'; -import { Select, SelectItem, Input } from '@heroui/react'; - -import ProjectSelect from '@/app/components/project-select'; -import TagSelect from '@/app/components/tag-select'; -import DateRangePicker from '@/app/components/date-range-picker'; -import { SearchIcon } from '@/app/components/icons'; - -interface TablePaginationRowProps { - total?: number; - rowsPerPage: number; - setRowsPerPage: (rows: number) => void; - setPage: (page: number) => void; - onProjectChange: (project: string) => void; - onSearchChange?: (search: string) => void; - onTagsChange?: (tags: string[]) => void; - onDateFromChange?: (date: string) => void; - onDateToChange?: (date: string) => void; - dateFrom?: string; - dateTo?: string; - rowPerPageOptions?: number[]; - entity: 'report' | 'result'; -} - -const defaultRowPerPageOptions = [10, 20, 40]; - -export default function TablePaginationOptions({ - // total, - rowsPerPage, - entity, - rowPerPageOptions, - setRowsPerPage, - setPage, - onProjectChange, - onSearchChange, - onTagsChange, - onDateFromChange, - onDateToChange, - dateFrom, - dateTo, -}: TablePaginationRowProps) { - const rowPerPageItems = rowPerPageOptions ?? defaultRowPerPageOptions; - - const onRowsPerPageChange = useCallback( - (e: ChangeEvent) => { - const rows = Number(e.target.value); - - setRowsPerPage(rows); - setPage(1); - }, - [rowsPerPage], - ); - - return ( -
- {/* Total {total ?? 0} */} -
- } - placeholder="Search..." - variant="bordered" - onChange={(e) => onSearchChange?.(e.target.value)} - /> - - {entity === 'result' && } - {(onDateFromChange || onDateToChange) && ( - - )} - -
-
- ); -} diff --git a/app/components/tag-select.tsx b/app/components/tag-select.tsx deleted file mode 100644 index 0e35e297..00000000 --- a/app/components/tag-select.tsx +++ /dev/null @@ -1,51 +0,0 @@ -'use client'; - -import { Select, SelectItem, SharedSelection } from '@heroui/react'; -import { toast } from 'sonner'; - -import useQuery from '../hooks/useQuery'; - -interface TagSelectProps { - onSelect?: (tags: string[]) => void; - refreshId?: string; - entity: 'result' | 'report'; - project?: string; -} - -export default function TagSelect({ refreshId, onSelect, entity, project }: Readonly) { - const { - data: tags, - error, - isLoading, - } = useQuery(`/api/${entity}/tags${project ? `?project=${encodeURIComponent(project)}` : ''}`, { - dependencies: [refreshId, project], - }); - - const onChange = (keys: SharedSelection) => { - if (typeof keys === 'string') { - return; - } - - const selectedTags = Array.from(keys) as string[]; - - onSelect?.(selectedTags); - }; - - error && toast.error(error.message); - - return ( - - ); -} diff --git a/app/components/theme-switch.tsx b/app/components/theme-switch.tsx deleted file mode 100644 index 262745a0..00000000 --- a/app/components/theme-switch.tsx +++ /dev/null @@ -1,66 +0,0 @@ -'use client'; - -import { FC } from 'react'; -import { VisuallyHidden } from '@react-aria/visually-hidden'; -import { SwitchProps, useSwitch } from '@heroui/switch'; -import { useTheme } from 'next-themes'; -import { useIsSSR } from '@react-aria/ssr'; -import clsx from 'clsx'; - -import { SunFilledIcon, MoonFilledIcon } from '@/app/components/icons'; - -export interface ThemeSwitchProps { - className?: string; - classNames?: SwitchProps['classNames']; -} - -export const ThemeSwitch: FC = ({ className, classNames }) => { - const { theme: themeName, setTheme } = useTheme(); - const isSSR = useIsSSR(); - - // normalize theme name for compatibility with theme picker from playwright trace view - const theme = themeName?.replace('-mode', ''); - - const onChange = () => { - theme === 'light' ? setTheme('dark-mode') : setTheme('light-mode'); - }; - - const { Component, slots, isSelected, getBaseProps, getInputProps, getWrapperProps } = useSwitch({ - isSelected: theme === 'light' || isSSR, - 'aria-label': `Switch to ${theme === 'light' || isSSR ? 'dark' : 'light'} mode`, - onChange, - }); - - return ( - - - - -
- {!isSelected || isSSR ? : } -
-
- ); -}; diff --git a/app/components/trend-chart.tsx b/app/components/trend-chart.tsx deleted file mode 100644 index 5f2a321e..00000000 --- a/app/components/trend-chart.tsx +++ /dev/null @@ -1,168 +0,0 @@ -'use client'; -import { Area, AreaChart, XAxis } from 'recharts'; -import Link from 'next/link'; -import { Alert } from '@heroui/react'; - -import { type ReportHistory } from '@/app/lib/storage'; -import { - type ChartConfig, - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, -} from '@/app/components/ui/chart'; - -const chartConfig = { - failed: { - label: 'Failed', - color: 'hsl(var(--chart-2))', - }, - flaky: { - label: 'Flaky', - color: 'hsl(var(--chart-4))', - }, - passed: { - label: 'Passed', - color: 'hsl(var(--chart-1))', - }, - skipped: { - label: 'Skipped', - color: 'hsl(var(--chart-3))', - }, -} satisfies ChartConfig; - -interface WithTotal { - total: number; -} - -interface TrendChartProps { - reportHistory: ReportHistory[]; -} - -export function TrendChart({ reportHistory }: Readonly) { - const getPercentage = (value: number, total: number) => (value / total) * 100; - - const openInNewTab = (url: string) => { - typeof window !== 'undefined' && window.open(url, '_blank', 'noopener,noreferrer'); - }; - - const chartData = reportHistory.map((r) => ({ - date: new Date(r.createdAt).getTime(), - passed: getPercentage(r.stats.expected, r.stats.total), - passedCount: r.stats.expected, - failed: getPercentage(r.stats.unexpected, r.stats.total), - failedCount: r.stats.unexpected, - skipped: getPercentage(r.stats.skipped, r.stats.total), - skippedCount: r.stats.skipped, - flaky: getPercentage(r.stats.flaky, r.stats.total), - flakyCount: r.stats.flaky, - total: r.stats.total, - reportUrl: `/report/${r.reportID}`, - })); - - return ( - - {reportHistory.length <= 1 ? ( -
-
- -
-
- ) : ( - { - const url = e.activePayload?.at(0)?.payload?.reportUrl; - - url && openInNewTab(url); - }} - > - { - return new Date(value).toLocaleDateString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', - }); - }} - tickLine={false} - tickMargin={10} - /> - ( - <> -
- {chartConfig[name as keyof typeof chartConfig]?.label || name} -
- { - item.payload[ - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - `${name}Count` - ] - }{' '} - ({Math.round(value as number)}%) -
- {/* Add this after the last item */} - {index === 3 && ( - <> - -
- Total -
- {(item.payload as WithTotal).total} - tests -
-
-
- Created At -
- {new Date( - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access - item.payload.date, - ).toLocaleString()} -
-
- - )} - - )} - /> - } - cursor={true} - /> - {Object.keys(chartConfig).map((key) => ( - - ))} - } /> - - )} - - ); -} diff --git a/app/components/ui/chart.tsx b/app/components/ui/chart.tsx deleted file mode 100644 index 440133f2..00000000 --- a/app/components/ui/chart.tsx +++ /dev/null @@ -1,316 +0,0 @@ -'use client'; - -import { ReactNode, ComponentType, createContext, useContext, forwardRef, useId, useMemo } from 'react'; -import * as RechartsPrimitive from 'recharts'; - -import { cn } from '@/app/lib/tailwind'; - -const THEMES = { light: '', dark: '.dark' } as const; - -export type ChartConfig = { - [k in string]: { - label?: ReactNode; - icon?: ComponentType; - } & ({ color?: string; theme?: never } | { color?: never; theme: Record }); -}; - -type ChartContextProps = { - config: ChartConfig; -}; - -const ChartContext = createContext(null); - -function useChart() { - const context = useContext(ChartContext); - - if (!context) { - throw new Error('useChart must be used within a '); - } - - return context; -} - -const ChartContainer = forwardRef< - HTMLDivElement, - React.ComponentProps<'div'> & { - config: ChartConfig; - children: React.ComponentProps['children']; - } ->(({ id, className, children, config, ...props }, ref) => { - const uniqueId: string = useId(); - const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`; - - return ( - -
- - {children} -
-
- ); -}); - -ChartContainer.displayName = 'Chart'; - -const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { - const colorConfig = Object.entries(config).filter(([_, config]) => config.theme ?? config.color); - - if (!colorConfig.length) { - return null; - } - - return ( -