forked from 0xWheatyz/SPARC
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cb1a6ed21 | |||
| a07a0c7fbe | |||
| 43fd2c9575 | |||
| d4d43cf9b8 | |||
| 2f2b6382fa | |||
| 1319530f04 | |||
| b32eebff8a | |||
|
68ee19025a
|
|||
|
ef97710d1c
|
|||
|
88812b5967
|
|||
|
90e58949fc
|
|||
|
bd10925c97
|
|||
|
89fec43aa2
|
|||
|
02e1c41126
|
|||
|
c17a0d006a
|
|||
|
c6760a39a1
|
|||
|
2ae6280566
|
|||
|
9745ed75a8
|
|||
|
c649eaf343
|
|||
| 7e66d0e7e0 | |||
| 71465401c6 | |||
| 97048917f2 | |||
| 88abd9574b | |||
| e0ed39908e | |||
| 87e09b365b |
@@ -15,7 +15,7 @@ jobs:
|
||||
- name: Install system dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
apk add --no-cache git python3 py3-pip gcc musl-dev libpq-dev python3-dev
|
||||
apt-get update && apt-get install -y git python3 python3-pip gcc libpq-dev python3-dev
|
||||
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Install Python dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
pip3 install --break-system-packages -r requirements.txt ruff
|
||||
pip3 install -r requirements.txt ruff
|
||||
|
||||
- name: Run ruff linter
|
||||
shell: sh
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
- name: Install Node.js and check TypeScript types
|
||||
shell: sh
|
||||
run: |
|
||||
apk add --no-cache nodejs npm
|
||||
apt-get install -y nodejs npm
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run generate:local
|
||||
@@ -56,6 +56,7 @@ jobs:
|
||||
JWT_SECRET: "test-secret-for-ci"
|
||||
APP_ENV: "development"
|
||||
run: |
|
||||
pip3 install pytest
|
||||
python3 -m pytest tests/ -v --tb=short -x
|
||||
|
||||
build-api:
|
||||
@@ -65,7 +66,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
apk add --no-cache git docker-cli
|
||||
apt-get update && apt-get install -y git docker.io
|
||||
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -137,7 +138,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
apk add --no-cache git docker-cli
|
||||
apt-get update && apt-get install -y git docker.io
|
||||
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
- name: Install system dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
apk add --no-cache git python3 py3-pip gcc musl-dev libpq-dev python3-dev
|
||||
apt-get update && apt-get install -y git python3 python3-pip gcc libpq-dev python3-dev
|
||||
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- name: Install Python dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
pip3 install --break-system-packages -r requirements.txt ruff
|
||||
pip3 install -r requirements.txt ruff
|
||||
|
||||
- name: Run ruff linter
|
||||
shell: sh
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
- name: Install Node.js and frontend dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
apk add --no-cache nodejs npm
|
||||
apt-get install -y nodejs npm
|
||||
cd frontend && npm ci
|
||||
|
||||
- name: Verify generated API types are up to date
|
||||
|
||||
+118
-76
@@ -7,86 +7,131 @@ Semiconductor Patent & Analytics Report Core -- development priorities.
|
||||
SPARC is a patent analysis platform with a working end-to-end pipeline:
|
||||
Python/FastAPI backend, React/TypeScript frontend, PostgreSQL for persistence
|
||||
and caching, Docker Compose for local development, and Gitea Actions CI/CD for
|
||||
image builds. Core features (patent retrieval via SerpAPI, PDF parsing, LLM
|
||||
analysis via OpenRouter/Claude, batch processing, JWT authentication, analytics
|
||||
dashboard) are all implemented and functional.
|
||||
image builds and testing. Core features include patent retrieval via SerpAPI,
|
||||
PDF parsing, LLM analysis via OpenRouter (multi-model: Claude, GPT-4o, Gemini,
|
||||
Llama), batch processing, JWT authentication, analytics dashboard with patent
|
||||
trend charts, scheduled recurring analysis with alerting, webhook notifications
|
||||
(Slack/Discord), CSV and PDF export, S3/MinIO storage, side-by-side company
|
||||
comparison, and dark mode.
|
||||
|
||||
---
|
||||
|
||||
## Completed
|
||||
|
||||
Items that have been implemented and merged into main.
|
||||
|
||||
### Security hardening
|
||||
|
||||
- ~~Rotate default JWT secret.~~ Startup check refuses to start with the
|
||||
default secret in non-development environments.
|
||||
- ~~CORS allow-origins are hardcoded.~~ Allowed origins are now configurable
|
||||
via environment variable.
|
||||
- ~~Database credentials in docker-compose.yml.~~ Compose references `.env`
|
||||
for sensitive values.
|
||||
|
||||
### Error handling and resilience
|
||||
|
||||
- ~~`get_db_client()` creates a new `DatabaseClient` on every call.~~ Refactored
|
||||
to a shared pooled singleton initialized at startup.
|
||||
- ~~No rate limiting on auth endpoints.~~ Rate limiting middleware added to
|
||||
`/auth/login` and `/auth/register`.
|
||||
|
||||
### Test coverage
|
||||
|
||||
- ~~API tests bypass authentication.~~ JWT auth integration tests added (33
|
||||
cases covering registration, login, protected routes, token refresh, and
|
||||
admin-only endpoints).
|
||||
- ~~No test stage in CI.~~ Gitea Actions workflow now runs `pytest` and gates
|
||||
the build.
|
||||
- ~~No linting or type checking in CI.~~ `ruff` (Python) and `tsc --noEmit`
|
||||
(TypeScript) added to CI pipeline.
|
||||
|
||||
### Backend
|
||||
|
||||
- ~~Add structured logging.~~ Python `logging` module used throughout.
|
||||
- ~~Make LLM model configurable.~~ `MODEL` environment variable accepted;
|
||||
multi-model support with per-analysis selection (GPT-4o, Gemini, Claude,
|
||||
Llama).
|
||||
- ~~SERP cache TTL hardcoded.~~ `SERP_CACHE_TTL_HOURS` exposed as env var.
|
||||
- ~~Patent PDF storage.~~ S3/MinIO object storage backend added alongside
|
||||
local filesystem. Volume mount requirement documented.
|
||||
- ~~`analyze_single_patent` assumes local file.~~ Auto-download from cached
|
||||
metadata link integrated.
|
||||
- ~~`Patent.patent_id` typed as `int`.~~ Fixed to `str`.
|
||||
|
||||
### Frontend
|
||||
|
||||
- ~~No loading/error states.~~ Skeleton loaders and error states added to
|
||||
Batch and Analytics pages.
|
||||
- ~~No dark mode.~~ Full dark mode support with theme-aware chart colors.
|
||||
- ~~Missing lockfile.~~ `package-lock.json` committed.
|
||||
|
||||
### Features (formerly P3)
|
||||
|
||||
- ~~Export analysis reports.~~ CSV and PDF export endpoints implemented.
|
||||
- ~~Comparison view.~~ Side-by-side company patent portfolio comparison added.
|
||||
- ~~Scheduled/recurring analysis.~~ APScheduler-based periodic re-analysis
|
||||
with configurable interval and change-threshold alerting.
|
||||
- ~~Webhook/notification support.~~ Slack, Discord, and generic HTTP POST
|
||||
webhooks with retry logic.
|
||||
- ~~Multi-model support.~~ Model picker in Analysis and Batch pages; backend
|
||||
allow-list validation.
|
||||
- ~~Patent trend charts.~~ Filing frequency and category distribution
|
||||
visualizations added to Analytics page.
|
||||
- ~~OpenAPI client generation.~~ TypeScript API client auto-generated from
|
||||
FastAPI spec with CI freshness check.
|
||||
|
||||
---
|
||||
|
||||
## P1 -- High Priority
|
||||
|
||||
These items address correctness, security, and reliability gaps that should be
|
||||
These items address correctness, reliability, and coverage gaps that should be
|
||||
resolved before broader production use.
|
||||
|
||||
### Security hardening
|
||||
### Resilience
|
||||
|
||||
- **Rotate default JWT secret.** `auth.py` ships a fallback
|
||||
`sparc-secret-key-change-in-production` that will be used if `JWT_SECRET` is
|
||||
unset. Add a startup check that refuses to start with the default secret in
|
||||
non-development environments.
|
||||
- **CORS allow-origins are hardcoded.** `api.py` only permits
|
||||
`localhost:3000` and `localhost:5173`. Make the allowed origins configurable
|
||||
via environment variable so the dashboard works when deployed behind a real
|
||||
domain.
|
||||
- **Database credentials in docker-compose.yml.** The compose file embeds
|
||||
`postgres:postgres` in plain text. Reference a `.env` file or Docker secrets
|
||||
instead.
|
||||
- **`_jobs` dict is in-memory only.** Job state is lost on API restart.
|
||||
Persist job status in PostgreSQL or Redis so async batch results survive
|
||||
restarts.
|
||||
|
||||
### Error handling and resilience
|
||||
### Test coverage gaps
|
||||
|
||||
- **`get_db_client()` in `auth.py` creates a new `DatabaseClient` on every
|
||||
call.** This bypasses the connection pool and can exhaust database
|
||||
connections under load. Refactor to share a single pooled client.
|
||||
- **`_jobs` dict is in-memory only.** Job state is lost on API restart. Persist
|
||||
job status in PostgreSQL or Redis so async batch results survive restarts.
|
||||
- **No rate limiting on auth endpoints.** `/auth/login` and `/auth/register`
|
||||
are unprotected against brute-force or abuse. Add rate limiting middleware.
|
||||
|
||||
### Test coverage for auth and admin
|
||||
|
||||
- The existing API tests (`tests/test_api.py`) bypass authentication entirely.
|
||||
Add tests that exercise the JWT flow: registration, login, protected-route
|
||||
access, token refresh, and admin-only endpoints.
|
||||
- **Export endpoint tests.** The CSV and PDF export endpoints (`/export/`)
|
||||
lack test coverage. Add tests covering auth, success, 404, and edge cases.
|
||||
*(Issue #1655)*
|
||||
- **Tracked company admin endpoint tests.** The `/admin/tracked` CRUD
|
||||
endpoints and scheduler integration lack test coverage. *(Issue #1656)*
|
||||
|
||||
---
|
||||
|
||||
## P2 -- Medium Priority
|
||||
|
||||
Improvements to usability, performance, and developer experience.
|
||||
Improvements to reliability, test coverage, and code quality.
|
||||
|
||||
### Backend
|
||||
### Test coverage
|
||||
|
||||
- **Add structured logging.** Replace `print()` calls throughout `analyzer.py`,
|
||||
`serp_api.py`, and `llm.py` with Python `logging` so log levels and
|
||||
formatting are consistent.
|
||||
- **Make LLM model configurable.** `llm.py` hardcodes
|
||||
`anthropic/claude-3.5-sonnet`. Accept a `MODEL` environment variable to allow
|
||||
switching models without code changes.
|
||||
- **SERP cache TTL is hardcoded to 24 hours.** Expose `SERP_CACHE_TTL_HOURS`
|
||||
as an environment variable in `config.py`.
|
||||
- **Patent PDF storage.** PDFs are saved to a local `patents/` directory. For
|
||||
containerized deployments, consider object storage (S3/MinIO) or at minimum
|
||||
document the volume mount requirement more prominently.
|
||||
- **`analyze_single_patent` assumes local file path.** The method constructs
|
||||
`patents/{patent_id}.pdf` and reads from disk, but does not download the PDF
|
||||
first. Either integrate the download step or document the prerequisite.
|
||||
- **`Patent.patent_id` typed as `int` in `types.py` but used as `str`
|
||||
everywhere.** Fix the type annotation to `str`.
|
||||
- **Webhook integration tests.** The retry logic, Slack/Discord payload
|
||||
format, and multi-URL dispatch in `webhooks.py` need test coverage.
|
||||
*(Issue #1657)*
|
||||
- **S3/MinIO storage backend tests.** `storage.py` has local filesystem tests
|
||||
but no unit tests for the S3 backend (read, write, exists, delete,
|
||||
error handling). *(Issue #1660)*
|
||||
- **`analyze_single_patent` auto-download path tests.** The auto-download
|
||||
fallback (cache lookup, PDF download, FileNotFoundError) in
|
||||
`analyzer.py` lacks test coverage. *(Issue #1661)*
|
||||
|
||||
### Frontend
|
||||
### Code quality
|
||||
|
||||
- **No loading/error states on several pages.** The Batch and Analytics pages
|
||||
would benefit from skeleton loaders and user-friendly error messages.
|
||||
- **No dark mode.** Tailwind is configured but no dark variant is applied.
|
||||
- **Missing `package-lock.json` or `pnpm-lock.yaml`.** The frontend has no
|
||||
lockfile committed, leading to non-reproducible builds.
|
||||
- **Scheduler creates its own DatabaseClient.** `scheduler.py` bypasses the
|
||||
application-level pooled client, creating a new connection on every tick.
|
||||
Refactor to use `get_db_client()`. *(Issue #1658)*
|
||||
|
||||
### CI/CD
|
||||
### API improvements
|
||||
|
||||
- **No test stage in the Gitea Actions workflow.** `build.yaml` builds and
|
||||
pushes images but never runs `pytest`. Add a test job that gates the build.
|
||||
- **No linting or type checking.** Add `ruff` (Python) and `tsc --noEmit`
|
||||
(TypeScript) to CI.
|
||||
- **API pagination.** The `/analyze/batch` and `/jobs` endpoints could benefit
|
||||
from cursor-based pagination for large result sets.
|
||||
- **Request validation improvements.** Add stricter input validation for
|
||||
company names (disallow special characters, enforce length limits).
|
||||
|
||||
---
|
||||
|
||||
@@ -94,23 +139,20 @@ Improvements to usability, performance, and developer experience.
|
||||
|
||||
Lower-urgency enhancements and future features.
|
||||
|
||||
- **Export analysis reports.** Allow users to download analysis results as PDF
|
||||
or CSV from the dashboard.
|
||||
- **Comparison view.** Side-by-side comparison of two companies' patent
|
||||
portfolios.
|
||||
- **Scheduled/recurring analysis.** Periodically re-analyze tracked companies
|
||||
and alert on significant changes.
|
||||
- **Webhook/notification support.** Send alerts (Slack, Discord, email) when
|
||||
batch jobs complete or when a company's innovation score changes
|
||||
significantly.
|
||||
- **Multi-model support.** Let users choose between LLM providers per analysis
|
||||
(e.g., GPT-4o, Gemini, Claude) and compare outputs.
|
||||
- **Patent trend charts.** Visualize patent filing frequency and technology
|
||||
category distribution over time in the Analytics page.
|
||||
- **API pagination.** The `/analyze/batch` and `/jobs` endpoints could benefit
|
||||
from cursor-based pagination for large result sets.
|
||||
- **OpenAPI client generation.** Auto-generate the TypeScript API client from
|
||||
the FastAPI OpenAPI spec to keep frontend types in sync.
|
||||
- **Historical analysis diffing.** Show what changed between two analysis runs
|
||||
for the same company, highlighting new patents and score shifts.
|
||||
- **Patent classification tagging.** Automatically tag patents by technology
|
||||
domain (AI, semiconductors, materials science) using LLM classification.
|
||||
- **User-level API keys.** Allow users to generate personal API keys for
|
||||
programmatic access without JWT token refresh.
|
||||
- **Batch export.** Export analysis results for multiple companies at once as
|
||||
a ZIP archive.
|
||||
- **Rate limiting dashboard.** Surface rate limit status and usage statistics
|
||||
in the admin panel.
|
||||
- **Async webhook delivery.** Move webhook delivery to a background task queue
|
||||
(e.g., Celery, arq) to avoid blocking the scheduler.
|
||||
- **Multi-tenant support.** Scope analysis results and tracked companies per
|
||||
user or organization.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-2
@@ -10,13 +10,13 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Callable
|
||||
|
||||
from SPARC import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from SPARC.database import DatabaseClient
|
||||
from SPARC.llm import LLMAnalyzer
|
||||
from SPARC.serp_api import SERP
|
||||
from SPARC.types import BatchAnalysisResult, CompanyAnalysisResult, Patent, Patents
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CompanyAnalyzer:
|
||||
"""Orchestrates end-to-end company performance analysis via patents."""
|
||||
|
||||
+6
-2
@@ -3,9 +3,14 @@
|
||||
Provides REST API endpoints for analyzing company patent portfolios.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from typing import Annotated, List
|
||||
from typing import TYPE_CHECKING, Annotated, List
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from SPARC.database import DatabaseClient
|
||||
|
||||
from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Query, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -653,7 +658,6 @@ async def export_company_pdf(
|
||||
PDF file download
|
||||
"""
|
||||
import io
|
||||
import textwrap
|
||||
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import letter
|
||||
|
||||
+6
-2
@@ -18,6 +18,7 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
init-db:
|
||||
image: gitea.leeworks.dev/0xwheatyz/sparc:latest
|
||||
build: .
|
||||
container_name: sparc-init-db
|
||||
command: python scripts/init_database.py
|
||||
@@ -29,6 +30,7 @@ services:
|
||||
restart: "no"
|
||||
|
||||
api:
|
||||
image: gitea.leeworks.dev/0xwheatyz/sparc:latest
|
||||
build: .
|
||||
container_name: sparc-api
|
||||
command: uvicorn SPARC.api:app --host 0.0.0.0 --port 8000
|
||||
@@ -40,7 +42,7 @@ services:
|
||||
JWT_SECRET: ${JWT_SECRET:-sparc-secret-key-change-in-production}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-}
|
||||
APP_ENV: ${APP_ENV:-development}
|
||||
ROOT_PATH: /api
|
||||
ROOT_PATH: ""
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
@@ -49,7 +51,7 @@ services:
|
||||
init-db:
|
||||
condition: service_completed_successfully
|
||||
volumes:
|
||||
- ./patents:/app/patents
|
||||
- patent_data:/app/patents
|
||||
restart: unless-stopped
|
||||
|
||||
# Optional: MinIO for S3-compatible local object storage
|
||||
@@ -76,6 +78,7 @@ services:
|
||||
- s3
|
||||
|
||||
dashboard:
|
||||
image: gitea.leeworks.dev/0xwheatyz/sparc:frontend-latest
|
||||
build: ./frontend
|
||||
container_name: sparc-dashboard
|
||||
ports:
|
||||
@@ -86,4 +89,5 @@ services:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
patent_data:
|
||||
minio_data:
|
||||
|
||||
+76
-1
@@ -276,7 +276,7 @@ The `docker-compose.yml` includes all services needed for production:
|
||||
|---------|-----------|------|-------------|
|
||||
| `postgres` | sparc-postgres | 5432 | PostgreSQL database |
|
||||
| `init-db` | sparc-init-db | - | One-time database initialization (seeds admin user) |
|
||||
| `api` | sparc-api | 8000 | FastAPI REST API with JWT auth |
|
||||
| `api` | sparc-api | 8000 | FastAPI REST API with JWT auth (patent PDFs stored in `patent_data` volume) |
|
||||
| `dashboard` | sparc-dashboard | 8080 | React TypeScript web UI |
|
||||
|
||||
### Common Docker Compose Commands
|
||||
@@ -307,6 +307,81 @@ docker-compose restart api
|
||||
|
||||
---
|
||||
|
||||
## Patent PDF Storage
|
||||
|
||||
The SPARC API downloads patent PDFs during analysis and stores them at `/app/patents` inside the container. These files are used for subsequent single-patent analysis requests and as a local cache to avoid re-downloading. If this directory is not persisted, all downloaded PDFs are lost when the container is recreated.
|
||||
|
||||
### Docker Compose (default)
|
||||
|
||||
The default `docker-compose.yml` declares a named volume called `patent_data` that is mounted at `/app/patents`:
|
||||
|
||||
```yaml
|
||||
# In the api service:
|
||||
volumes:
|
||||
- patent_data:/app/patents
|
||||
|
||||
# At the top-level volumes section:
|
||||
volumes:
|
||||
patent_data:
|
||||
```
|
||||
|
||||
This means PDFs survive `docker compose down` and `docker compose up` cycles. To remove patent data intentionally, run:
|
||||
|
||||
```bash
|
||||
docker compose down -v # WARNING: also removes postgres_data
|
||||
# or selectively:
|
||||
docker volume rm sparc_patent_data
|
||||
```
|
||||
|
||||
If you prefer a bind mount (e.g., for easy host-side access during development), replace the volume with:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./patents:/app/patents
|
||||
```
|
||||
|
||||
### Kubernetes
|
||||
|
||||
For Kubernetes deployments, create a PersistentVolumeClaim and mount it into the API pod:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: sparc-patent-data
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 5Gi
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: sparc-api
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: api
|
||||
volumeMounts:
|
||||
- name: patent-data
|
||||
mountPath: /app/patents
|
||||
volumes:
|
||||
- name: patent-data
|
||||
persistentVolumeClaim:
|
||||
claimName: sparc-patent-data
|
||||
```
|
||||
|
||||
Adjust the storage size based on expected patent volume. Each patent PDF is typically 1-5 MB.
|
||||
|
||||
### S3 Object Storage (alternative)
|
||||
|
||||
For production deployments that need shared or highly durable storage, set `STORAGE_BACKEND=s3` in your `.env` file. This stores patent PDFs in an S3-compatible bucket (AWS S3 or MinIO) instead of the local filesystem, eliminating the need for a persistent volume. See the S3/MinIO section in `.env.example` for configuration details.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Database Connection Issues
|
||||
|
||||
@@ -15,7 +15,7 @@ server {
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /api/ {
|
||||
proxy_pass ${API_URL}/;
|
||||
proxy_pass ${API_URL};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
|
||||
Generated
+257
@@ -26,6 +26,7 @@
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.7",
|
||||
"globals": "^15.8.0",
|
||||
"openapi-typescript": "^7.0.0",
|
||||
"postcss": "^8.4.39",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"typescript": "~5.5.3",
|
||||
@@ -1025,6 +1026,82 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/ajv": {
|
||||
"version": "8.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
|
||||
"integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2",
|
||||
"uri-js-replace": "^1.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/ajv/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@redocly/config": {
|
||||
"version": "0.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz",
|
||||
"integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@redocly/openapi-core": {
|
||||
"version": "1.34.11",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.11.tgz",
|
||||
"integrity": "sha512-V09ayfnb5GyysmvARbt+voFZAjGcf7hSYxOYxSkCc4fbH/DTfq5YWoec8cflvmHHqyIFbqvmGKmYFzqhr9zxDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@redocly/ajv": "8.11.2",
|
||||
"@redocly/config": "0.22.0",
|
||||
"colorette": "1.4.0",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"js-levenshtein": "1.1.6",
|
||||
"js-yaml": "4.1.1",
|
||||
"minimatch": "5.1.9",
|
||||
"pluralize": "8.0.0",
|
||||
"yaml-ast-parser": "0.0.43"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17.0",
|
||||
"npm": ">=9.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/minimatch": {
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.2",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||
@@ -1906,6 +1983,16 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
@@ -1923,6 +2010,16 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-colors": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
|
||||
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
@@ -2190,6 +2287,13 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/change-case": {
|
||||
"version": "5.4.4",
|
||||
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
|
||||
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@@ -2257,6 +2361,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
|
||||
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -3165,6 +3276,20 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -3202,6 +3327,19 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/index-to-position": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
|
||||
"integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
@@ -3290,6 +3428,16 @@
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/js-levenshtein": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
|
||||
"integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -3608,6 +3756,40 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-typescript": {
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz",
|
||||
"integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@redocly/openapi-core": "^1.34.6",
|
||||
"ansi-colors": "^4.1.3",
|
||||
"change-case": "^5.4.4",
|
||||
"parse-json": "^8.3.0",
|
||||
"supports-color": "^10.2.2",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"openapi-typescript": "bin/cli.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.x"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-typescript/node_modules/supports-color": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -3671,6 +3853,24 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-json": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
|
||||
"integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
"index-to-position": "^1.1.0",
|
||||
"type-fest": "^4.39.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -3738,6 +3938,16 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/pluralize": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
@@ -4124,6 +4334,16 @@
|
||||
"decimal.js-light": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -4510,6 +4730,19 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "4.41.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||
"dev": true,
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.5.4",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
|
||||
@@ -4589,6 +4822,13 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uri-js-replace": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",
|
||||
"integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -4711,6 +4951,23 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml-ast-parser": {
|
||||
"version": "0.0.43",
|
||||
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
|
||||
"integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useTheme } from './ThemeContext';
|
||||
|
||||
/**
|
||||
* Returns theme-aware color values for recharts components.
|
||||
*
|
||||
* Recharts accepts only raw color strings (not CSS variables),
|
||||
* so this hook bridges the Tailwind/CSS-variable theme system
|
||||
* to the imperative recharts API.
|
||||
*/
|
||||
export function useChartTheme() {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return {
|
||||
/** Axis tick and grid line stroke color */
|
||||
axisStroke: isDark ? '#94a3b8' : '#64748b',
|
||||
/** Tooltip container background */
|
||||
tooltipBg: isDark ? '#1e293b' : '#ffffff',
|
||||
/** Tooltip container border */
|
||||
tooltipBorder: isDark
|
||||
? '1px solid rgba(99, 102, 241, 0.3)'
|
||||
: '1px solid rgba(99, 102, 241, 0.2)',
|
||||
/** Tooltip label text color */
|
||||
tooltipLabelColor: isDark ? '#f8fafc' : '#0f172a',
|
||||
/** Tooltip item text color */
|
||||
tooltipItemColor: isDark ? '#e2e8f0' : '#334155',
|
||||
/** Convenience: full contentStyle object for recharts Tooltip */
|
||||
tooltipContentStyle: {
|
||||
backgroundColor: isDark ? '#1e293b' : '#ffffff',
|
||||
border: isDark
|
||||
? '1px solid rgba(99, 102, 241, 0.3)'
|
||||
: '1px solid rgba(99, 102, 241, 0.2)',
|
||||
borderRadius: '8px',
|
||||
color: isDark ? '#f8fafc' : '#0f172a',
|
||||
},
|
||||
/** Convenience: labelStyle for recharts Tooltip */
|
||||
tooltipLabelStyle: {
|
||||
color: isDark ? '#f8fafc' : '#0f172a',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -159,7 +159,7 @@ export function Analysis() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="prose prose-invert max-w-none">
|
||||
<div className="prose dark:prose-invert max-w-none">
|
||||
<div className="text-text-primary whitespace-pre-wrap leading-relaxed">
|
||||
{result.analysis}
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,13 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { analyticsApi } from '../api/client';
|
||||
import { AlertCircle, Database } from 'lucide-react';
|
||||
import { PieChart, Pie, Cell, BarChart, Bar, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import { useChartTheme } from '../context/useChartTheme';
|
||||
|
||||
const COLORS = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
|
||||
|
||||
export function AnalyticsPage() {
|
||||
const [days, setDays] = useState(30);
|
||||
const chartTheme = useChartTheme();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['analytics', days],
|
||||
@@ -160,11 +162,7 @@ export function AnalyticsPage() {
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1e293b',
|
||||
border: '1px solid rgba(99, 102, 241, 0.3)',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
contentStyle={chartTheme.tooltipContentStyle}
|
||||
/>
|
||||
<Legend />
|
||||
</PieChart>
|
||||
@@ -178,15 +176,11 @@ export function AnalyticsPage() {
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-4">Analysis Types</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={typeData}>
|
||||
<XAxis dataKey="name" stroke="#94a3b8" fontSize={12} />
|
||||
<YAxis stroke="#94a3b8" fontSize={12} />
|
||||
<XAxis dataKey="name" stroke={chartTheme.axisStroke} fontSize={12} />
|
||||
<YAxis stroke={chartTheme.axisStroke} fontSize={12} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1e293b',
|
||||
border: '1px solid rgba(99, 102, 241, 0.3)',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
labelStyle={{ color: '#f8fafc' }}
|
||||
contentStyle={chartTheme.tooltipContentStyle}
|
||||
labelStyle={chartTheme.tooltipLabelStyle}
|
||||
/>
|
||||
<Bar dataKey="count" fill="#6366f1" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
@@ -222,15 +216,11 @@ export function AnalyticsPage() {
|
||||
<h4 className="text-md font-semibold text-text-primary mb-4">Analyses per Company Over Time</h4>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={pivoted}>
|
||||
<XAxis dataKey="month" stroke="#94a3b8" fontSize={12} />
|
||||
<YAxis stroke="#94a3b8" fontSize={12} />
|
||||
<XAxis dataKey="month" stroke={chartTheme.axisStroke} fontSize={12} />
|
||||
<YAxis stroke={chartTheme.axisStroke} fontSize={12} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1e293b',
|
||||
border: '1px solid rgba(99, 102, 241, 0.3)',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
labelStyle={{ color: '#f8fafc' }}
|
||||
contentStyle={chartTheme.tooltipContentStyle}
|
||||
labelStyle={chartTheme.tooltipLabelStyle}
|
||||
/>
|
||||
<Legend />
|
||||
{companies.map((company, idx) => (
|
||||
@@ -268,15 +258,11 @@ export function AnalyticsPage() {
|
||||
<h4 className="text-md font-semibold text-text-primary mb-4">Analysis Types Over Time</h4>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={pivoted}>
|
||||
<XAxis dataKey="month" stroke="#94a3b8" fontSize={12} />
|
||||
<YAxis stroke="#94a3b8" fontSize={12} />
|
||||
<XAxis dataKey="month" stroke={chartTheme.axisStroke} fontSize={12} />
|
||||
<YAxis stroke={chartTheme.axisStroke} fontSize={12} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1e293b',
|
||||
border: '1px solid rgba(99, 102, 241, 0.3)',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
labelStyle={{ color: '#f8fafc' }}
|
||||
contentStyle={chartTheme.tooltipContentStyle}
|
||||
labelStyle={chartTheme.tooltipLabelStyle}
|
||||
/>
|
||||
<Legend />
|
||||
{types.map((type, idx) => (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { analysisApi } from '../api/client';
|
||||
import { Rocket, CheckCircle, AlertCircle, ChevronDown, ChevronUp, RefreshCw, Inbox } from 'lucide-react';
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
||||
import { useChartTheme } from '../context/useChartTheme';
|
||||
import type { BatchAnalysisResult } from '../types';
|
||||
|
||||
export function Batch() {
|
||||
@@ -12,6 +13,8 @@ export function Batch() {
|
||||
const [result, setResult] = useState<BatchAnalysisResult | null>(null);
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
|
||||
const chartTheme = useChartTheme();
|
||||
|
||||
const modelsQuery = useQuery({
|
||||
queryKey: ['models'],
|
||||
queryFn: () => analysisApi.listModels(),
|
||||
@@ -210,15 +213,11 @@ export function Batch() {
|
||||
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-6">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData}>
|
||||
<XAxis dataKey="name" stroke="#94a3b8" fontSize={12} />
|
||||
<YAxis stroke="#94a3b8" fontSize={12} />
|
||||
<XAxis dataKey="name" stroke={chartTheme.axisStroke} fontSize={12} />
|
||||
<YAxis stroke={chartTheme.axisStroke} fontSize={12} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1e293b',
|
||||
border: '1px solid rgba(99, 102, 241, 0.3)',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
labelStyle={{ color: '#f8fafc' }}
|
||||
contentStyle={chartTheme.tooltipContentStyle}
|
||||
labelStyle={chartTheme.tooltipLabelStyle}
|
||||
/>
|
||||
<Bar dataKey="patents" radius={[4, 4, 0, 0]}>
|
||||
{chartData.map((entry, index) => (
|
||||
|
||||
+209
-9
@@ -1,13 +1,29 @@
|
||||
"""Tests for JWT authentication flow: register, login, protected routes, refresh, admin access."""
|
||||
"""Tests for JWT authentication flow: register, login, protected routes, refresh, admin access.
|
||||
|
||||
from datetime import datetime, timezone
|
||||
Covers all five scenarios required by issue #1624:
|
||||
1. Registration (POST /auth/register)
|
||||
2. Login (POST /auth/login)
|
||||
3. Protected route access (GET /auth/me) -- valid, missing, expired, wrong-type tokens
|
||||
4. Token refresh (POST /auth/refresh)
|
||||
5. Admin-only endpoints (GET /admin/users, PATCH role, DELETE user)
|
||||
|
||||
All tests use mocked DB fixtures and require no live database.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import jwt as pyjwt
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from SPARC.api import app
|
||||
from SPARC.auth import create_access_token, create_refresh_token
|
||||
from SPARC.auth import (
|
||||
JWT_ALGORITHM,
|
||||
JWT_SECRET,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -171,12 +187,6 @@ class TestGetMe:
|
||||
|
||||
def test_expired_token_returns_401(self, client, mock_db):
|
||||
"""An expired token should return 401."""
|
||||
# Create a token that has already expired
|
||||
from datetime import timedelta
|
||||
|
||||
import jwt as pyjwt
|
||||
from SPARC.auth import JWT_ALGORITHM, JWT_SECRET
|
||||
|
||||
payload = {
|
||||
"sub": "1",
|
||||
"email": "user@test.com",
|
||||
@@ -300,3 +310,193 @@ class TestAdminUsers:
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "own role" in response.json()["detail"].lower()
|
||||
|
||||
def test_role_change_nonexistent_user_returns_404(self, client, mock_db):
|
||||
"""Changing role for a user that does not exist should return 404."""
|
||||
admin = _make_admin_user()
|
||||
mock_db.get_user_by_id.return_value = admin
|
||||
mock_db.update_user_role.return_value = None
|
||||
|
||||
response = client.patch(
|
||||
"/admin/users/999/role",
|
||||
json={"role": "admin"},
|
||||
headers=_auth_header(admin),
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
def test_regular_user_cannot_change_role(self, client, mock_db):
|
||||
"""Non-admin user should receive 403 when trying to change roles."""
|
||||
user = _make_regular_user()
|
||||
mock_db.get_user_by_id.return_value = user
|
||||
|
||||
response = client.patch(
|
||||
"/admin/users/1/role",
|
||||
json={"role": "admin"},
|
||||
headers=_auth_header(user),
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestAdminDeleteUser:
|
||||
"""DELETE /admin/users/{user_id}"""
|
||||
|
||||
def test_admin_can_delete_user(self, client, mock_db):
|
||||
"""Admin should be able to delete another user."""
|
||||
admin = _make_admin_user()
|
||||
mock_db.get_user_by_id.return_value = admin
|
||||
mock_db.delete_user.return_value = True
|
||||
|
||||
response = client.delete(
|
||||
"/admin/users/2",
|
||||
headers=_auth_header(admin),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "deleted" in response.json()["message"].lower()
|
||||
mock_db.delete_user.assert_called_once_with(2)
|
||||
|
||||
def test_admin_cannot_delete_self(self, client, mock_db):
|
||||
"""Admin should not be able to delete themselves."""
|
||||
admin = _make_admin_user()
|
||||
mock_db.get_user_by_id.return_value = admin
|
||||
|
||||
response = client.delete(
|
||||
"/admin/users/1",
|
||||
headers=_auth_header(admin),
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "yourself" in response.json()["detail"].lower()
|
||||
|
||||
def test_delete_nonexistent_user_returns_404(self, client, mock_db):
|
||||
"""Deleting a user that does not exist should return 404."""
|
||||
admin = _make_admin_user()
|
||||
mock_db.get_user_by_id.return_value = admin
|
||||
mock_db.delete_user.return_value = False
|
||||
|
||||
response = client.delete(
|
||||
"/admin/users/999",
|
||||
headers=_auth_header(admin),
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
def test_regular_user_cannot_delete_user(self, client, mock_db):
|
||||
"""Non-admin user should receive 403 when trying to delete users."""
|
||||
user = _make_regular_user()
|
||||
mock_db.get_user_by_id.return_value = user
|
||||
|
||||
response = client.delete(
|
||||
"/admin/users/1",
|
||||
headers=_auth_header(user),
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_no_token_cannot_delete_user(self, client):
|
||||
"""Missing token should be rejected for delete endpoint."""
|
||||
response = client.delete("/admin/users/1")
|
||||
assert response.status_code in (401, 403)
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Additional edge-case tests for auth robustness."""
|
||||
|
||||
def test_register_invalid_email_returns_422(self, client, mock_db):
|
||||
"""Registration with an invalid email format should return 422."""
|
||||
response = client.post(
|
||||
"/auth/register",
|
||||
json={"email": "not-an-email", "password": "securepass123"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_register_short_password_returns_422(self, client, mock_db):
|
||||
"""Registration with a password shorter than 8 chars should return 422."""
|
||||
response = client.post(
|
||||
"/auth/register",
|
||||
json={"email": "user@test.com", "password": "short"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_register_missing_fields_returns_422(self, client, mock_db):
|
||||
"""Registration with missing fields should return 422."""
|
||||
response = client.post("/auth/register", json={})
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_login_missing_fields_returns_422(self, client, mock_db):
|
||||
"""Login with missing fields should return 422."""
|
||||
response = client.post("/auth/login", json={"email": "user@test.com"})
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_malformed_token_returns_401(self, client, mock_db):
|
||||
"""A completely malformed token string should return 401."""
|
||||
response = client.get(
|
||||
"/auth/me",
|
||||
headers={"Authorization": "Bearer not.a.valid.jwt.token"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_token_with_wrong_secret_returns_401(self, client, mock_db):
|
||||
"""A token signed with a different secret should return 401."""
|
||||
payload = {
|
||||
"sub": "1",
|
||||
"email": "user@test.com",
|
||||
"role": "user",
|
||||
"exp": datetime.now(timezone.utc) + timedelta(hours=1),
|
||||
"type": "access",
|
||||
}
|
||||
wrong_secret_token = pyjwt.encode(payload, "wrong-secret", algorithm=JWT_ALGORITHM)
|
||||
|
||||
response = client.get(
|
||||
"/auth/me",
|
||||
headers={"Authorization": f"Bearer {wrong_secret_token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_token_for_deleted_user_returns_401(self, client, mock_db):
|
||||
"""A valid token for a user no longer in the DB should return 401."""
|
||||
user = _make_regular_user()
|
||||
mock_db.get_user_by_id.return_value = None # user was deleted
|
||||
|
||||
response = client.get("/auth/me", headers=_auth_header(user))
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_refresh_for_deleted_user_returns_401(self, client, mock_db):
|
||||
"""Refreshing a token for a deleted user should return 401."""
|
||||
user = _make_regular_user()
|
||||
mock_db.get_user_by_id.return_value = None
|
||||
refresh = create_refresh_token(user["id"], user["email"], user["role"])
|
||||
|
||||
response = client.post(
|
||||
"/auth/refresh", json={"refresh_token": refresh}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_login_returns_decodable_tokens(self, client, mock_db):
|
||||
"""Tokens returned by login should be decodable and contain expected claims."""
|
||||
user = _make_regular_user()
|
||||
mock_db.authenticate_user.return_value = user
|
||||
|
||||
response = client.post(
|
||||
"/auth/login",
|
||||
json={"email": "user@test.com", "password": "correctpassword"},
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
access_payload = pyjwt.decode(
|
||||
data["access_token"], JWT_SECRET, algorithms=[JWT_ALGORITHM]
|
||||
)
|
||||
assert access_payload["sub"] == str(user["id"])
|
||||
assert access_payload["email"] == user["email"]
|
||||
assert access_payload["type"] == "access"
|
||||
|
||||
refresh_payload = pyjwt.decode(
|
||||
data["refresh_token"], JWT_SECRET, algorithms=[JWT_ALGORITHM]
|
||||
)
|
||||
assert refresh_payload["type"] == "refresh"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Tests for rate limiting on auth endpoints."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from SPARC.api import app
|
||||
|
||||
@@ -14,6 +14,7 @@ class TestJWTSecretStartupCheck:
|
||||
with patch.dict(os.environ, {"APP_ENV": "production"}):
|
||||
# Reload config to pick up the new APP_ENV
|
||||
import importlib
|
||||
|
||||
import SPARC.config
|
||||
importlib.reload(SPARC.config)
|
||||
|
||||
@@ -31,6 +32,7 @@ class TestJWTSecretStartupCheck:
|
||||
"""Starting with default secret and APP_ENV=development must not raise."""
|
||||
with patch.dict(os.environ, {"APP_ENV": "development"}):
|
||||
import importlib
|
||||
|
||||
import SPARC.config
|
||||
importlib.reload(SPARC.config)
|
||||
|
||||
@@ -46,6 +48,7 @@ class TestJWTSecretStartupCheck:
|
||||
"""Starting with a custom secret in production must not raise."""
|
||||
with patch.dict(os.environ, {"APP_ENV": "production"}):
|
||||
import importlib
|
||||
|
||||
import SPARC.config
|
||||
importlib.reload(SPARC.config)
|
||||
|
||||
@@ -65,6 +68,7 @@ class TestJWTSecretStartupCheck:
|
||||
env.pop("APP_ENV", None)
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
import importlib
|
||||
|
||||
import SPARC.config
|
||||
importlib.reload(SPARC.config)
|
||||
|
||||
@@ -84,6 +88,7 @@ class TestCORSConfig:
|
||||
"""When CORS_ORIGINS is unset, defaults to localhost origins."""
|
||||
with patch.dict(os.environ, {"CORS_ORIGINS": ""}):
|
||||
import importlib
|
||||
|
||||
import SPARC.config
|
||||
importlib.reload(SPARC.config)
|
||||
assert SPARC.config.cors_origins == [
|
||||
@@ -95,6 +100,7 @@ class TestCORSConfig:
|
||||
"""Setting CORS_ORIGINS configures allowed origins."""
|
||||
with patch.dict(os.environ, {"CORS_ORIGINS": "https://sparc.example.com,https://app.example.com"}):
|
||||
import importlib
|
||||
|
||||
import SPARC.config
|
||||
importlib.reload(SPARC.config)
|
||||
assert SPARC.config.cors_origins == [
|
||||
@@ -109,6 +115,7 @@ class TestCORSConfig:
|
||||
"""A single origin without comma works correctly."""
|
||||
with patch.dict(os.environ, {"CORS_ORIGINS": "https://sparc.example.com"}):
|
||||
import importlib
|
||||
|
||||
import SPARC.config
|
||||
importlib.reload(SPARC.config)
|
||||
assert SPARC.config.cors_origins == ["https://sparc.example.com"]
|
||||
|
||||
Reference in New Issue
Block a user