mirror of
https://github.com/LukeHagar/pypistats.dev.git
synced 2025-12-06 04:21:09 +00:00
cleaning repo and saving update
This commit is contained in:
25
.devcontainer/Dockerfile
Normal file
25
.devcontainer/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
FROM node:22
|
||||
|
||||
# Install pnpm via corepack; keep git available for typical workflows
|
||||
# Also install native build deps required by node-canvas used for server-side chart rendering
|
||||
RUN corepack enable && corepack prepare pnpm@9.12.3 --activate \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y \
|
||||
git \
|
||||
build-essential \
|
||||
libcairo2-dev \
|
||||
libpango1.0-dev \
|
||||
libjpeg-dev \
|
||||
libgif-dev \
|
||||
librsvg2-dev \
|
||||
libpng-dev \
|
||||
libpixman-1-dev \
|
||||
pkg-config \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
USER node
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
|
||||
@@ -1,33 +1,18 @@
|
||||
{
|
||||
"name": "pypistats.dev",
|
||||
"dockerComposeFile": [
|
||||
"../docker-compose.yml",
|
||||
"../docker-compose.dev.yml"
|
||||
],
|
||||
"service": "web",
|
||||
"workspaceFolder": "/app",
|
||||
"build": {
|
||||
"args": {
|
||||
"SKIP_APP_BUILD": "1"
|
||||
}
|
||||
"name": "Vite Dev + Postgres + Redis",
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
"service": "app",
|
||||
"workspaceFolder": "/workspace",
|
||||
"settings": {
|
||||
"terminal.integrated.defaultProfile.linux": "bash"
|
||||
},
|
||||
"runArgs": ["--env-file", ".env"],
|
||||
"forwardPorts": [5173, 3000, 5555],
|
||||
"portsAttributes": {
|
||||
"5173": { "label": "Vite Dev Server" },
|
||||
"3000": { "label": "Node Adapter Server" },
|
||||
"5555": { "label": "Prisma Studio" }
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"esbenp.prettier-vscode",
|
||||
"Prisma.prisma",
|
||||
"svelte.svelte-vscode",
|
||||
"dbaeumer.vscode-eslint"
|
||||
]
|
||||
}
|
||||
}
|
||||
"postCreateCommand": "corepack enable && corepack prepare pnpm@9.12.3 --activate && pnpm install && pnpm prisma generate && pnpm prisma migrate deploy && (pnpm rebuild canvas || true) && pnpm dev",
|
||||
"remoteUser": "root",
|
||||
"forwardPorts": [5173, 5432, 6379],
|
||||
"extensions": [
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
|
||||
47
.devcontainer/docker-compose.yml
Normal file
47
.devcontainer/docker-compose.yml
Normal file
@@ -0,0 +1,47 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- ..:/workspace:cached
|
||||
command: sleep infinity
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_started
|
||||
redis:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "5173:5173"
|
||||
environment:
|
||||
# Use service DNS names within the devcontainer network
|
||||
DATABASE_URL: postgresql://postgres:postgres@db:5432/postgres?schema=public
|
||||
REDIS_URL: redis://redis:6379
|
||||
networks:
|
||||
- devnet
|
||||
|
||||
db:
|
||||
image: postgres:15
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
ports:
|
||||
- "5432:5432"
|
||||
networks:
|
||||
- devnet
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
networks:
|
||||
- devnet
|
||||
|
||||
networks:
|
||||
devnet:
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
node_modules
|
||||
.pnpm-store
|
||||
|
||||
# Output
|
||||
.output
|
||||
|
||||
17
Dockerfile
17
Dockerfile
@@ -1,13 +1,10 @@
|
||||
FROM node:20-slim
|
||||
FROM node:latest
|
||||
|
||||
# Install deps needed by Prisma and shell
|
||||
RUN apt-get update && apt-get install -y openssl bash && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Allow skipping app build in devcontainer
|
||||
ARG SKIP_APP_BUILD=0
|
||||
|
||||
# Copy package manifests first for better cache
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
|
||||
@@ -21,17 +18,15 @@ RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
|
||||
# Generate Prisma client and build SvelteKit (Node adapter)
|
||||
RUN pnpm prisma generate
|
||||
RUN if [ "$SKIP_APP_BUILD" != "1" ]; then pnpm build; fi
|
||||
RUN pnpm prisma generate && pnpm prisma migrate deploy
|
||||
|
||||
RUN pnpm build
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Entrypoint handles migrations and start
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
# Default command can be overridden by compose
|
||||
CMD ["node", "build/index.js"]
|
||||
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
SKIP_APP_BUILD: "1"
|
||||
command: sh -lc "\
|
||||
corepack enable && corepack prepare pnpm@9.12.3 --activate && \
|
||||
pnpm install && \
|
||||
pnpm prisma generate && \
|
||||
pnpm prisma migrate deploy || true && \
|
||||
pnpm dev --host 0.0.0.0 --port 5173"
|
||||
volumes:
|
||||
- ./:/app
|
||||
- web_node_modules:/app/node_modules
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "5173:5173"
|
||||
|
||||
volumes:
|
||||
web_node_modules:
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16
|
||||
image: postgres:latest
|
||||
environment:
|
||||
POSTGRES_DB: pypistats
|
||||
POSTGRES_USER: pypistats
|
||||
@@ -12,13 +10,13 @@ services:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
image: redis:latest
|
||||
ports:
|
||||
- "6379:6379"
|
||||
healthcheck:
|
||||
@@ -45,7 +43,7 @@ services:
|
||||
# GOOGLE_APPLICATION_CREDENTIALS_JSON: '{"type":"service_account",...}'
|
||||
ports:
|
||||
- "3000:3000"
|
||||
command: ["/entrypoint.sh"]
|
||||
command: ["node", "build/index.js"]
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Wait for Postgres if DATABASE_URL is provided
|
||||
if [[ -n "${DATABASE_URL:-}" ]]; then
|
||||
echo "Waiting for database..."
|
||||
ATTEMPTS=0
|
||||
until node -e "const { Client } = require('pg'); (async () => { try { const c=new Client({ connectionString: process.env.DATABASE_URL }); await c.connect(); await c.end(); process.exit(0);} catch(e){ process.exit(1);} })()" >/dev/null 2>&1; do
|
||||
ATTEMPTS=$((ATTEMPTS+1))
|
||||
if [[ $ATTEMPTS -gt 60 ]]; then
|
||||
echo "Database did not become ready in time" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
# Run Prisma migrations (safe for prod) with retry
|
||||
if [[ "${RUN_DB_MIGRATIONS:-1}" == "1" ]]; then
|
||||
echo "Running prisma migrate deploy..."
|
||||
ATTEMPTS=0
|
||||
until pnpm prisma migrate deploy; do
|
||||
ATTEMPTS=$((ATTEMPTS+1))
|
||||
if [[ $ATTEMPTS -gt 10 ]]; then
|
||||
echo "Prisma migrate failed after retries" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Retrying migrations in 3s..."
|
||||
sleep 3
|
||||
done
|
||||
fi
|
||||
|
||||
# Start the app (SvelteKit Node adapter)
|
||||
exec node build/index.js
|
||||
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Wait for Postgres if DATABASE_URL is provided
|
||||
if [[ -n "${DATABASE_URL:-}" ]]; then
|
||||
echo "Waiting for database..."
|
||||
ATTEMPTS=0
|
||||
until node -e "const { Client } = require('pg'); (async () => { try { const c=new Client({ connectionString: process.env.DATABASE_URL }); await c.connect(); await c.end(); process.exit(0);} catch(e){ process.exit(1);} })()" >/dev/null 2>&1; do
|
||||
ATTEMPTS=$((ATTEMPTS+1))
|
||||
if [[ $ATTEMPTS -gt 60 ]]; then
|
||||
echo "Database did not become ready in time" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
# Run Prisma migrations (safe for prod) with retry
|
||||
if [[ "${RUN_DB_MIGRATIONS:-1}" == "1" ]]; then
|
||||
echo "Running prisma migrate deploy..."
|
||||
ATTEMPTS=0
|
||||
until pnpm prisma migrate deploy; do
|
||||
ATTEMPTS=$((ATTEMPTS+1))
|
||||
if [[ $ATTEMPTS -gt 10 ]]; then
|
||||
echo "Prisma migrate failed after retries" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Retrying migrations in 3s..."
|
||||
sleep 3
|
||||
done
|
||||
fi
|
||||
|
||||
# Start the app (SvelteKit Node adapter)
|
||||
exec node build/index.js
|
||||
|
||||
|
||||
270
openapi.yaml
Normal file
270
openapi.yaml
Normal file
@@ -0,0 +1,270 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: PyPI Stats API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
API for querying download statistics for Python packages from PyPI (via BigQuery replication).
|
||||
Endpoints return JSON or rendered charts. Caching and on-demand freshness are applied.
|
||||
servers:
|
||||
- url: https://{host}
|
||||
variables:
|
||||
host:
|
||||
default: localhost:5173
|
||||
paths:
|
||||
/api/packages/{package}/recent:
|
||||
get:
|
||||
summary: Recent downloads summary (day, week, month)
|
||||
parameters:
|
||||
- in: path
|
||||
name: package
|
||||
required: true
|
||||
schema: { type: string }
|
||||
description: Package name (dots/underscores normalized to hyphens)
|
||||
- in: query
|
||||
name: period
|
||||
schema: { type: string, enum: [day, week, month] }
|
||||
description: Optional; if omitted returns all three periods
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
package: { type: string }
|
||||
type: { type: string, enum: [recent_downloads] }
|
||||
data:
|
||||
type: object
|
||||
example:
|
||||
last_day: 123
|
||||
last_week: 456
|
||||
last_month: 789
|
||||
'404': { description: Package not found }
|
||||
|
||||
/api/packages/{package}/overall:
|
||||
get:
|
||||
summary: Overall downloads time series
|
||||
parameters:
|
||||
- in: path
|
||||
name: package
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- in: query
|
||||
name: mirrors
|
||||
schema: { type: string, enum: ['true', 'false'] }
|
||||
description: Include mirror downloads; omit for both categories
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
package: { type: string }
|
||||
type: { type: string, enum: [overall_downloads] }
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
date: { type: string, format: date }
|
||||
category: { type: string, enum: [with_mirrors, without_mirrors] }
|
||||
downloads: { type: integer }
|
||||
'404': { description: Package not found }
|
||||
|
||||
/api/packages/{package}/python_major:
|
||||
get:
|
||||
summary: Python major version downloads time series
|
||||
parameters:
|
||||
- in: path
|
||||
name: package
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- in: query
|
||||
name: version
|
||||
schema: { type: string }
|
||||
description: Optional filter (e.g., '3')
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
package: { type: string }
|
||||
type: { type: string, enum: [python_major_downloads] }
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
date: { type: string, format: date }
|
||||
category: { type: string }
|
||||
downloads: { type: integer }
|
||||
|
||||
/api/packages/{package}/python_minor:
|
||||
get:
|
||||
summary: Python minor version downloads time series
|
||||
parameters:
|
||||
- in: path
|
||||
name: package
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- in: query
|
||||
name: version
|
||||
schema: { type: string }
|
||||
description: Optional filter (e.g., '3.11')
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
package: { type: string }
|
||||
type: { type: string, enum: [python_minor_downloads] }
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
date: { type: string, format: date }
|
||||
category: { type: string }
|
||||
downloads: { type: integer }
|
||||
|
||||
/api/packages/{package}/system:
|
||||
get:
|
||||
summary: System OS downloads time series
|
||||
parameters:
|
||||
- in: path
|
||||
name: package
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- in: query
|
||||
name: os
|
||||
schema: { type: string, enum: [Windows, Linux, Darwin, other] }
|
||||
description: Optional filter
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
package: { type: string }
|
||||
type: { type: string, enum: [system_downloads] }
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
date: { type: string, format: date }
|
||||
category: { type: string }
|
||||
downloads: { type: integer }
|
||||
|
||||
/api/packages/{package}/installer:
|
||||
get:
|
||||
summary: Installer downloads time series
|
||||
parameters:
|
||||
- in: path
|
||||
name: package
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
package: { type: string }
|
||||
type: { type: string, enum: [installer_downloads] }
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
date: { type: string, format: date }
|
||||
category: { type: string }
|
||||
downloads: { type: integer }
|
||||
|
||||
/api/packages/{package}/summary:
|
||||
get:
|
||||
summary: At-a-glance totals for a package
|
||||
parameters:
|
||||
- in: path
|
||||
name: package
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
package: { type: string }
|
||||
type: { type: string, enum: [summary] }
|
||||
totals:
|
||||
type: object
|
||||
properties:
|
||||
overall: { type: integer }
|
||||
system:
|
||||
type: object
|
||||
additionalProperties: { type: integer }
|
||||
python_major:
|
||||
type: object
|
||||
additionalProperties: { type: integer }
|
||||
python_minor:
|
||||
type: object
|
||||
additionalProperties: { type: integer }
|
||||
|
||||
/api/packages/{package}/chart/{type}:
|
||||
get:
|
||||
summary: Render a Chart.js image of time series
|
||||
parameters:
|
||||
- in: path
|
||||
name: package
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- in: path
|
||||
name: type
|
||||
required: true
|
||||
schema: { type: string, enum: [overall, python_major, python_minor, system] }
|
||||
- in: query
|
||||
name: chart
|
||||
schema: { type: string, enum: [line, bar] }
|
||||
description: Chart type to render
|
||||
- in: query
|
||||
name: mirrors
|
||||
schema: { type: string, enum: ['true', 'false'] }
|
||||
description: Only for type=overall
|
||||
- in: query
|
||||
name: version
|
||||
schema: { type: string }
|
||||
description: Only for type=python_major/python_minor (e.g., 3 or 3.11)
|
||||
- in: query
|
||||
name: os
|
||||
schema: { type: string, enum: [Windows, Linux, Darwin, other] }
|
||||
description: Only for type=system
|
||||
responses:
|
||||
'200':
|
||||
description: PNG image
|
||||
content:
|
||||
image/png:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
'400': { description: Bad request }
|
||||
'404': { description: Package not found }
|
||||
|
||||
components:
|
||||
securitySchemes: {}
|
||||
|
||||
18
package.json
18
package.json
@@ -4,9 +4,9 @@
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "svelte-kit build",
|
||||
"start": "node build/index.js",
|
||||
"dev": "vite dev",
|
||||
"build": "svelte-kit build",
|
||||
"start": "node build/index.js",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
@@ -20,10 +20,13 @@
|
||||
"dependencies": {
|
||||
"@google-cloud/bigquery": "^8.1.1",
|
||||
"@prisma/client": "^6.13.0",
|
||||
"@sveltejs/adapter-node": "^5.2.8",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@sveltejs/adapter-node": "^5.2.8",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"node-cron": "^4.2.1",
|
||||
"redis": "^5.7.0"
|
||||
"redis": "^5.7.0",
|
||||
"chart.js": "^4.4.4",
|
||||
"chartjs-node-canvas": "^5.0.0",
|
||||
"canvas": "^3.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^6.0.1",
|
||||
@@ -45,7 +48,8 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
"esbuild",
|
||||
"canvas"
|
||||
]
|
||||
}
|
||||
}
|
||||
231
pnpm-lock.yaml
generated
231
pnpm-lock.yaml
generated
@@ -20,6 +20,15 @@ importers:
|
||||
'@types/node-cron':
|
||||
specifier: ^3.0.11
|
||||
version: 3.0.11
|
||||
canvas:
|
||||
specifier: ^3.1.2
|
||||
version: 3.1.2
|
||||
chart.js:
|
||||
specifier: ^4.4.4
|
||||
version: 4.5.0
|
||||
chartjs-node-canvas:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0(chart.js@4.5.0)
|
||||
node-cron:
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
@@ -291,6 +300,9 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.29':
|
||||
resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==}
|
||||
|
||||
'@kurkle/color@0.3.4':
|
||||
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
|
||||
|
||||
'@polka/url@1.0.0-next.29':
|
||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||
|
||||
@@ -708,9 +720,15 @@ packages:
|
||||
bignumber.js@9.3.1:
|
||||
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
|
||||
|
||||
bl@4.1.0:
|
||||
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
||||
|
||||
buffer-equal-constant-time@1.0.1:
|
||||
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||
|
||||
buffer@5.7.1:
|
||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||
|
||||
c12@3.1.0:
|
||||
resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==}
|
||||
peerDependencies:
|
||||
@@ -723,10 +741,26 @@ packages:
|
||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
canvas@3.1.2:
|
||||
resolution: {integrity: sha512-Z/tzFAcBzoCvJlOSlCnoekh1Gu8YMn0J51+UAuXJAbW1Z6I9l2mZgdD7738MepoeeIcUdDtbMnOg6cC7GJxy/g==}
|
||||
engines: {node: ^18.12.0 || >= 20.9.0}
|
||||
|
||||
chart.js@4.5.0:
|
||||
resolution: {integrity: sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==}
|
||||
engines: {pnpm: '>=8'}
|
||||
|
||||
chartjs-node-canvas@5.0.0:
|
||||
resolution: {integrity: sha512-+Lc5phRWjb+UxAIiQpKgvOaG6Mw276YQx2jl2BrxoUtI3A4RYTZuGM5Dq+s4ReYmCY42WEPSR6viF3lDSTxpvw==}
|
||||
peerDependencies:
|
||||
chart.js: ^4.4.8
|
||||
|
||||
chokidar@4.0.3:
|
||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||
engines: {node: '>= 14.16.0'}
|
||||
|
||||
chownr@1.1.4:
|
||||
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
|
||||
|
||||
chownr@3.0.0:
|
||||
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -778,6 +812,14 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decompress-response@6.0.0:
|
||||
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
deep-extend@0.6.0:
|
||||
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
|
||||
deepmerge-ts@7.1.5:
|
||||
resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
@@ -857,6 +899,10 @@ packages:
|
||||
estree-walker@2.0.2:
|
||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||
|
||||
expand-template@2.0.3:
|
||||
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
exsolve@1.0.7:
|
||||
resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==}
|
||||
|
||||
@@ -891,6 +937,9 @@ packages:
|
||||
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
fs-constants@1.0.0:
|
||||
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -919,6 +968,9 @@ packages:
|
||||
resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==}
|
||||
hasBin: true
|
||||
|
||||
github-from-package@0.0.0:
|
||||
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
|
||||
|
||||
google-auth-library@10.2.1:
|
||||
resolution: {integrity: sha512-HMxFl2NfeHYnaL1HoRIN1XgorKS+6CDaM+z9LSSN+i/nKDDL4KFFEWogMXu7jV4HZQy2MsxpY+wA5XIf3w410A==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -969,6 +1021,9 @@ packages:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
index-to-position@1.1.0:
|
||||
resolution: {integrity: sha512-XPdx9Dq4t9Qk1mTMbWONJqU7boCoumEH7fRET37HX5+khDUl3J2W6PdALxhILYlIYx2amlwYcRPp28p0tSiojg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -976,6 +1031,9 @@ packages:
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
ini@1.3.8:
|
||||
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
|
||||
|
||||
is-core-module@2.16.1:
|
||||
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1108,10 +1166,17 @@ packages:
|
||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mimic-response@3.1.0:
|
||||
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
mini-svg-data-uri@1.4.4:
|
||||
resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==}
|
||||
hasBin: true
|
||||
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
minipass@7.1.2:
|
||||
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
@@ -1120,6 +1185,9 @@ packages:
|
||||
resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
mkdirp-classic@0.5.3:
|
||||
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
|
||||
|
||||
mkdirp@3.0.1:
|
||||
resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1141,6 +1209,16 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
napi-build-utils@2.0.0:
|
||||
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
|
||||
|
||||
node-abi@3.75.0:
|
||||
resolution: {integrity: sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
node-addon-api@7.1.1:
|
||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||
|
||||
node-cron@4.2.1:
|
||||
resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
@@ -1203,6 +1281,11 @@ packages:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
prebuild-install@7.1.3:
|
||||
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
prettier-plugin-svelte@3.4.0:
|
||||
resolution: {integrity: sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==}
|
||||
peerDependencies:
|
||||
@@ -1292,12 +1375,19 @@ packages:
|
||||
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
pump@3.0.3:
|
||||
resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
|
||||
|
||||
pure-rand@6.1.0:
|
||||
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
|
||||
|
||||
rc9@2.1.2:
|
||||
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
|
||||
|
||||
rc@1.2.8:
|
||||
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
||||
hasBin: true
|
||||
|
||||
read-package-up@11.0.0:
|
||||
resolution: {integrity: sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1347,6 +1437,12 @@ packages:
|
||||
set-cookie-parser@2.7.1:
|
||||
resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==}
|
||||
|
||||
simple-concat@1.0.1:
|
||||
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
|
||||
|
||||
simple-get@4.0.1:
|
||||
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
|
||||
|
||||
sirv@3.0.1:
|
||||
resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1376,6 +1472,10 @@ packages:
|
||||
string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
strip-json-comments@2.0.1:
|
||||
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
stubs@3.0.0:
|
||||
resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==}
|
||||
|
||||
@@ -1402,6 +1502,13 @@ packages:
|
||||
resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tar-fs@2.1.3:
|
||||
resolution: {integrity: sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==}
|
||||
|
||||
tar-stream@2.2.0:
|
||||
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tar@7.4.3:
|
||||
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1421,6 +1528,12 @@ packages:
|
||||
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
tunnel-agent@0.6.0:
|
||||
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
||||
|
||||
type-fest@4.41.0:
|
||||
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
|
||||
engines: {node: '>=16'}
|
||||
@@ -1672,6 +1785,8 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.4
|
||||
|
||||
'@kurkle/color@0.3.4': {}
|
||||
|
||||
'@polka/url@1.0.0-next.29': {}
|
||||
|
||||
'@prisma/client@6.13.0(prisma@6.13.0(typescript@5.9.2))(typescript@5.9.2)':
|
||||
@@ -2027,8 +2142,19 @@ snapshots:
|
||||
|
||||
bignumber.js@9.3.1: {}
|
||||
|
||||
bl@4.1.0:
|
||||
dependencies:
|
||||
buffer: 5.7.1
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
buffer-equal-constant-time@1.0.1: {}
|
||||
|
||||
buffer@5.7.1:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
c12@3.1.0:
|
||||
dependencies:
|
||||
chokidar: 4.0.3
|
||||
@@ -2049,10 +2175,27 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
|
||||
canvas@3.1.2:
|
||||
dependencies:
|
||||
node-addon-api: 7.1.1
|
||||
prebuild-install: 7.1.3
|
||||
|
||||
chart.js@4.5.0:
|
||||
dependencies:
|
||||
'@kurkle/color': 0.3.4
|
||||
|
||||
chartjs-node-canvas@5.0.0(chart.js@4.5.0):
|
||||
dependencies:
|
||||
canvas: 3.1.2
|
||||
chart.js: 4.5.0
|
||||
tslib: 2.8.1
|
||||
|
||||
chokidar@4.0.3:
|
||||
dependencies:
|
||||
readdirp: 4.1.2
|
||||
|
||||
chownr@1.1.4: {}
|
||||
|
||||
chownr@3.0.0: {}
|
||||
|
||||
citty@0.1.6:
|
||||
@@ -2083,6 +2226,12 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decompress-response@6.0.0:
|
||||
dependencies:
|
||||
mimic-response: 3.1.0
|
||||
|
||||
deep-extend@0.6.0: {}
|
||||
|
||||
deepmerge-ts@7.1.5: {}
|
||||
|
||||
deepmerge@4.3.1: {}
|
||||
@@ -2182,6 +2331,8 @@ snapshots:
|
||||
|
||||
estree-walker@2.0.2: {}
|
||||
|
||||
expand-template@2.0.3: {}
|
||||
|
||||
exsolve@1.0.7: {}
|
||||
|
||||
extend@3.0.2: {}
|
||||
@@ -2214,6 +2365,8 @@ snapshots:
|
||||
dependencies:
|
||||
fetch-blob: 3.2.0
|
||||
|
||||
fs-constants@1.0.0: {}
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
@@ -2262,6 +2415,8 @@ snapshots:
|
||||
nypm: 0.6.1
|
||||
pathe: 2.0.3
|
||||
|
||||
github-from-package@0.0.0: {}
|
||||
|
||||
google-auth-library@10.2.1:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
@@ -2325,10 +2480,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
index-to-position@1.1.0: {}
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
ini@1.3.8: {}
|
||||
|
||||
is-core-module@2.16.1:
|
||||
dependencies:
|
||||
hasown: 2.0.2
|
||||
@@ -2441,14 +2600,20 @@ snapshots:
|
||||
dependencies:
|
||||
mime-db: 1.52.0
|
||||
|
||||
mimic-response@3.1.0: {}
|
||||
|
||||
mini-svg-data-uri@1.4.4: {}
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
minipass@7.1.2: {}
|
||||
|
||||
minizlib@3.0.2:
|
||||
dependencies:
|
||||
minipass: 7.1.2
|
||||
|
||||
mkdirp-classic@0.5.3: {}
|
||||
|
||||
mkdirp@3.0.1: {}
|
||||
|
||||
mri@1.2.0: {}
|
||||
@@ -2459,6 +2624,14 @@ snapshots:
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
napi-build-utils@2.0.0: {}
|
||||
|
||||
node-abi@3.75.0:
|
||||
dependencies:
|
||||
semver: 7.7.2
|
||||
|
||||
node-addon-api@7.1.1: {}
|
||||
|
||||
node-cron@4.2.1: {}
|
||||
|
||||
node-domexception@1.0.0: {}
|
||||
@@ -2524,6 +2697,21 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
prebuild-install@7.1.3:
|
||||
dependencies:
|
||||
detect-libc: 2.0.4
|
||||
expand-template: 2.0.3
|
||||
github-from-package: 0.0.0
|
||||
minimist: 1.2.8
|
||||
mkdirp-classic: 0.5.3
|
||||
napi-build-utils: 2.0.0
|
||||
node-abi: 3.75.0
|
||||
pump: 3.0.3
|
||||
rc: 1.2.8
|
||||
simple-get: 4.0.1
|
||||
tar-fs: 2.1.3
|
||||
tunnel-agent: 0.6.0
|
||||
|
||||
prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.37.3):
|
||||
dependencies:
|
||||
prettier: 3.6.2
|
||||
@@ -2550,6 +2738,11 @@ snapshots:
|
||||
|
||||
prismjs@1.30.0: {}
|
||||
|
||||
pump@3.0.3:
|
||||
dependencies:
|
||||
end-of-stream: 1.4.5
|
||||
once: 1.4.0
|
||||
|
||||
pure-rand@6.1.0: {}
|
||||
|
||||
rc9@2.1.2:
|
||||
@@ -2557,6 +2750,13 @@ snapshots:
|
||||
defu: 6.1.4
|
||||
destr: 2.0.5
|
||||
|
||||
rc@1.2.8:
|
||||
dependencies:
|
||||
deep-extend: 0.6.0
|
||||
ini: 1.3.8
|
||||
minimist: 1.2.8
|
||||
strip-json-comments: 2.0.1
|
||||
|
||||
read-package-up@11.0.0:
|
||||
dependencies:
|
||||
find-up-simple: 1.0.1
|
||||
@@ -2637,6 +2837,14 @@ snapshots:
|
||||
|
||||
set-cookie-parser@2.7.1: {}
|
||||
|
||||
simple-concat@1.0.1: {}
|
||||
|
||||
simple-get@4.0.1:
|
||||
dependencies:
|
||||
decompress-response: 6.0.0
|
||||
once: 1.4.0
|
||||
simple-concat: 1.0.1
|
||||
|
||||
sirv@3.0.1:
|
||||
dependencies:
|
||||
'@polka/url': 1.0.0-next.29
|
||||
@@ -2669,6 +2877,8 @@ snapshots:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
strip-json-comments@2.0.1: {}
|
||||
|
||||
stubs@3.0.0: {}
|
||||
|
||||
supports-preserve-symlinks-flag@1.0.0: {}
|
||||
@@ -2706,6 +2916,21 @@ snapshots:
|
||||
|
||||
tapable@2.2.2: {}
|
||||
|
||||
tar-fs@2.1.3:
|
||||
dependencies:
|
||||
chownr: 1.1.4
|
||||
mkdirp-classic: 0.5.3
|
||||
pump: 3.0.3
|
||||
tar-stream: 2.2.0
|
||||
|
||||
tar-stream@2.2.0:
|
||||
dependencies:
|
||||
bl: 4.1.0
|
||||
end-of-stream: 1.4.5
|
||||
fs-constants: 1.0.0
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
tar@7.4.3:
|
||||
dependencies:
|
||||
'@isaacs/fs-minipass': 4.0.1
|
||||
@@ -2733,6 +2958,12 @@ snapshots:
|
||||
|
||||
totalist@3.0.1: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tunnel-agent@0.6.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
type-fest@4.41.0: {}
|
||||
|
||||
typescript@5.9.2: {}
|
||||
|
||||
@@ -21,28 +21,38 @@ CREATE TABLE "public"."overall" (
|
||||
CREATE TABLE "public"."python_major" (
|
||||
"date" DATE NOT NULL,
|
||||
"package" TEXT NOT NULL,
|
||||
"category" TEXT,
|
||||
"category" TEXT NOT NULL,
|
||||
"downloads" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "python_major_pkey" PRIMARY KEY ("date","package")
|
||||
CONSTRAINT "python_major_pkey" PRIMARY KEY ("date","package","category")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."python_minor" (
|
||||
"date" DATE NOT NULL,
|
||||
"package" TEXT NOT NULL,
|
||||
"category" TEXT,
|
||||
"category" TEXT NOT NULL,
|
||||
"downloads" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "python_minor_pkey" PRIMARY KEY ("date","package")
|
||||
CONSTRAINT "python_minor_pkey" PRIMARY KEY ("date","package","category")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."system" (
|
||||
"date" DATE NOT NULL,
|
||||
"package" TEXT NOT NULL,
|
||||
"category" TEXT,
|
||||
"category" TEXT NOT NULL,
|
||||
"downloads" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "system_pkey" PRIMARY KEY ("date","package")
|
||||
CONSTRAINT "system_pkey" PRIMARY KEY ("date","package","category")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."installer" (
|
||||
"date" DATE NOT NULL,
|
||||
"package" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"downloads" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "installer_pkey" PRIMARY KEY ("date","package","category")
|
||||
);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { closeRedisClient, forceDisconnectRedis } from '$lib/redis.js';
|
||||
import { closeRedisClient, forceDisconnectRedis, getRedisClient } from '$lib/redis.js';
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
// Minimal server hooks without cron
|
||||
@@ -30,6 +30,60 @@ if (typeof process !== 'undefined') {
|
||||
});
|
||||
}
|
||||
|
||||
// Sliding window rate limit parameters
|
||||
const WINDOW_SECONDS = 60; // 1 minute
|
||||
const MAX_REQUESTS = 300; // per id per window
|
||||
|
||||
async function consumeSlidingWindow(key: string, points: number, windowSeconds: number) {
|
||||
const client = getRedisClient();
|
||||
if (!client) return { allowed: true, remaining: MAX_REQUESTS };
|
||||
const now = Date.now();
|
||||
const windowStart = now - windowSeconds * 1000;
|
||||
const listKey = `rl:sw:${key}`;
|
||||
|
||||
// Remove old entries and push current
|
||||
const lua = `
|
||||
local key = KEYS[1]
|
||||
local now = tonumber(ARGV[1])
|
||||
local windowStart = tonumber(ARGV[2])
|
||||
local points = tonumber(ARGV[3])
|
||||
local limit = tonumber(ARGV[4])
|
||||
-- Trim old timestamps
|
||||
redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)
|
||||
-- Add current request timestamps (as single timestamp repeated 'points' times)
|
||||
for i=1,points do
|
||||
redis.call('ZADD', key, now, now .. '-' .. i)
|
||||
end
|
||||
local count = redis.call('ZCARD', key)
|
||||
-- Set expiry just beyond window
|
||||
redis.call('EXPIRE', key, windowStart + (60*60*24) == 0 and 60 or math.floor((now - windowStart)/1000) + 5)
|
||||
return count
|
||||
`;
|
||||
const count = (await client.eval(lua, {
|
||||
keys: [listKey],
|
||||
arguments: [String(now), String(windowStart), String(points), String(MAX_REQUESTS)]
|
||||
})) as number;
|
||||
return { allowed: count <= MAX_REQUESTS, remaining: Math.max(0, MAX_REQUESTS - count) };
|
||||
}
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Only apply to API routes
|
||||
if (event.url.pathname.startsWith('/api/')) {
|
||||
// Identify client by IP (or forwarded-for)
|
||||
const ip = (event.getClientAddress?.() || event.request.headers.get('x-forwarded-for') || '').split(',')[0].trim() || 'unknown';
|
||||
const key = `ip:${ip}`;
|
||||
const result = await consumeSlidingWindow(key, 1, WINDOW_SECONDS);
|
||||
if (!result.allowed) {
|
||||
const reset = WINDOW_SECONDS; // approximate
|
||||
return new Response('Too Many Requests', {
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': String(reset),
|
||||
'RateLimit-Policy': `${MAX_REQUESTS};w=${WINDOW_SECONDS}`,
|
||||
'RateLimit-Remaining': '0'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return resolve(event);
|
||||
};
|
||||
324
src/lib/api.ts
324
src/lib/api.ts
@@ -1,8 +1,21 @@
|
||||
import { prisma } from './prisma.js';
|
||||
import { RECENT_CATEGORIES } from './database.js';
|
||||
import { CacheManager } from './redis.js';
|
||||
import { DataProcessor } from './data-processor.js';
|
||||
|
||||
const cache = new CacheManager();
|
||||
let processor: DataProcessor | null = null;
|
||||
function getProcessor() {
|
||||
if (!processor) processor = new DataProcessor();
|
||||
return processor;
|
||||
}
|
||||
async function ensurePackageFreshnessFor(packageName: string) {
|
||||
try {
|
||||
await getProcessor().ensurePackageFreshness(packageName);
|
||||
} catch (error) {
|
||||
console.error('Failed to ensure package freshness:', packageName, error);
|
||||
}
|
||||
}
|
||||
|
||||
export type Results = {
|
||||
date: string;
|
||||
@@ -13,10 +26,9 @@ export type Results = {
|
||||
export async function getRecentDownloads(packageName: string, category?: string): Promise<Results[]> {
|
||||
const cacheKey = CacheManager.getRecentStatsKey(packageName);
|
||||
|
||||
// Try to get from cache first
|
||||
const cached = await cache.get<Results[]>(cacheKey);
|
||||
if (cached && !category) {
|
||||
return cached;
|
||||
// Ensure DB has fresh data for this package before computing recent
|
||||
if (!category) {
|
||||
await ensurePackageFreshnessFor(packageName);
|
||||
}
|
||||
|
||||
if (category && RECENT_CATEGORIES.includes(category)) {
|
||||
@@ -44,8 +56,12 @@ export async function getRecentDownloads(packageName: string, category?: string)
|
||||
const month: Results[] = await getRecentDownloads(packageName, 'month');
|
||||
const result: Results[] = [...day, ...week, ...month];
|
||||
|
||||
// Cache the result for 1 hour
|
||||
await cache.set(cacheKey, result, 3600);
|
||||
// Cache only if non-empty; otherwise clear any stale empty cache
|
||||
if (result.length > 0) {
|
||||
await cache.set(cacheKey, result, 3600);
|
||||
} else {
|
||||
await cache.del(cacheKey);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -66,11 +82,8 @@ function getRecentBounds(category: string) {
|
||||
export async function getOverallDownloads(packageName: string, mirrors?: string) {
|
||||
const cacheKey = CacheManager.getPackageKey(packageName, `overall_${mirrors || 'all'}`);
|
||||
|
||||
// Try to get from cache first
|
||||
const cached = await cache.get<Results[]>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
// Always ensure DB freshness first to avoid returning stale cache
|
||||
await ensurePackageFreshnessFor(packageName);
|
||||
|
||||
const whereClause: any = {
|
||||
package: packageName
|
||||
@@ -89,8 +102,12 @@ export async function getOverallDownloads(packageName: string, mirrors?: string)
|
||||
}
|
||||
});
|
||||
|
||||
// Cache the result for 1 hour
|
||||
await cache.set(cacheKey, result, 3600);
|
||||
// Cache only if non-empty; otherwise clear any stale empty cache
|
||||
if (result.length > 0) {
|
||||
await cache.set(cacheKey, result, 3600);
|
||||
} else {
|
||||
await cache.del(cacheKey);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -98,11 +115,8 @@ export async function getOverallDownloads(packageName: string, mirrors?: string)
|
||||
export async function getPythonMajorDownloads(packageName: string, version?: string) {
|
||||
const cacheKey = CacheManager.getPackageKey(packageName, `python_major_${version || 'all'}`);
|
||||
|
||||
// Try to get from cache first
|
||||
const cached = await cache.get<Results[]>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
// Ensure DB freshness first
|
||||
await ensurePackageFreshnessFor(packageName);
|
||||
|
||||
const whereClause: any = {
|
||||
package: packageName
|
||||
@@ -119,8 +133,11 @@ export async function getPythonMajorDownloads(packageName: string, version?: str
|
||||
}
|
||||
});
|
||||
|
||||
// Cache the result for 1 hour
|
||||
await cache.set(cacheKey, result, 3600);
|
||||
if (result.length > 0) {
|
||||
await cache.set(cacheKey, result, 3600);
|
||||
} else {
|
||||
await cache.del(cacheKey);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -128,11 +145,8 @@ export async function getPythonMajorDownloads(packageName: string, version?: str
|
||||
export async function getPythonMinorDownloads(packageName: string, version?: string) {
|
||||
const cacheKey = CacheManager.getPackageKey(packageName, `python_minor_${version || 'all'}`);
|
||||
|
||||
// Try to get from cache first
|
||||
const cached = await cache.get<Results[]>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
// Ensure DB freshness first
|
||||
await ensurePackageFreshnessFor(packageName);
|
||||
|
||||
const whereClause: any = {
|
||||
package: packageName
|
||||
@@ -149,8 +163,11 @@ export async function getPythonMinorDownloads(packageName: string, version?: str
|
||||
}
|
||||
});
|
||||
|
||||
// Cache the result for 1 hour
|
||||
await cache.set(cacheKey, result, 3600);
|
||||
if (result.length > 0) {
|
||||
await cache.set(cacheKey, result, 3600);
|
||||
} else {
|
||||
await cache.del(cacheKey);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -158,11 +175,8 @@ export async function getPythonMinorDownloads(packageName: string, version?: str
|
||||
export async function getSystemDownloads(packageName: string, os?: string) {
|
||||
const cacheKey = CacheManager.getPackageKey(packageName, `system_${os || 'all'}`);
|
||||
|
||||
// Try to get from cache first
|
||||
const cached = await cache.get<Results[]>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
// Ensure DB freshness first
|
||||
await ensurePackageFreshnessFor(packageName);
|
||||
|
||||
const whereClause: any = {
|
||||
package: packageName
|
||||
@@ -179,14 +193,63 @@ export async function getSystemDownloads(packageName: string, os?: string) {
|
||||
}
|
||||
});
|
||||
|
||||
// Cache the result for 1 hour
|
||||
await cache.set(cacheKey, result, 3600);
|
||||
if (result.length > 0) {
|
||||
await cache.set(cacheKey, result, 3600);
|
||||
} else {
|
||||
await cache.del(cacheKey);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getInstallerDownloads(packageName: string, installer?: string) {
|
||||
const cacheKey = CacheManager.getPackageKey(packageName, `installer_${installer || 'all'}`);
|
||||
// Ensure DB freshness first
|
||||
await ensurePackageFreshnessFor(packageName);
|
||||
const whereClause: any = { package: packageName };
|
||||
if (installer) whereClause.category = installer;
|
||||
const result = await (prisma as any).installerDownloadCount.findMany({
|
||||
where: whereClause,
|
||||
orderBy: { date: 'asc' }
|
||||
});
|
||||
if (result.length > 0) {
|
||||
await cache.set(cacheKey, result, 3600);
|
||||
} else {
|
||||
await cache.del(cacheKey);
|
||||
}
|
||||
return result as Array<{ date: Date; package: string; category: string; downloads: number }>;
|
||||
}
|
||||
|
||||
export async function getVersionDownloads(packageName: string, version?: string) {
|
||||
const cacheKey = CacheManager.getPackageKey(packageName, `version_${version || 'all'}`);
|
||||
// Ensure DB freshness first
|
||||
await ensurePackageFreshnessFor(packageName);
|
||||
const whereClause: any = { package: packageName };
|
||||
if (version) whereClause.category = version;
|
||||
try {
|
||||
const model = (prisma as any).versionDownloadCount;
|
||||
if (!model) return [] as Array<{ date: Date; package: string; category: string; downloads: number }>;
|
||||
const result = await model.findMany({
|
||||
where: whereClause,
|
||||
orderBy: { date: 'asc' }
|
||||
});
|
||||
if (result.length > 0) {
|
||||
await cache.set(cacheKey, result, 3600);
|
||||
} else {
|
||||
await cache.del(cacheKey);
|
||||
}
|
||||
return result as Array<{ date: Date; package: string; category: string; downloads: number }>;
|
||||
} catch (error) {
|
||||
console.error('getVersionDownloads failed:', error);
|
||||
return [] as Array<{ date: Date; package: string; category: string; downloads: number }>;
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchPackages(searchTerm: string) {
|
||||
const cacheKey = CacheManager.getSearchKey(searchTerm);
|
||||
const query = (searchTerm || '').trim();
|
||||
if (!query) return [] as string[];
|
||||
|
||||
const cacheKey = CacheManager.getSearchKey(query);
|
||||
|
||||
// Try to get from cache first
|
||||
const cached = await cache.get<string[]>(cacheKey);
|
||||
@@ -194,53 +257,178 @@ export async function searchPackages(searchTerm: string) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const results = await prisma.recentDownloadCount.findMany({
|
||||
where: {
|
||||
package: {
|
||||
startsWith: searchTerm
|
||||
},
|
||||
category: 'month'
|
||||
},
|
||||
select: {
|
||||
package: true
|
||||
},
|
||||
distinct: ['package'],
|
||||
orderBy: {
|
||||
package: 'asc'
|
||||
},
|
||||
take: 20
|
||||
});
|
||||
// Use PyPI Simple API (PEP 691 JSON) to fetch the package index, cache it,
|
||||
// then do a local prefix filter for suggestions.
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
const indexKey = CacheManager.getSearchKey('__simple_index__');
|
||||
try {
|
||||
// Try index from cache first
|
||||
let allPackages = await cache.get<string[]>(indexKey);
|
||||
|
||||
const packages = results.map(result => result.package);
|
||||
if (!allPackages) {
|
||||
const indexResponse = await fetch('https://pypi.org/simple/', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/vnd.pypi.simple.v1+json',
|
||||
'User-Agent': 'pypistats.app (server-side)'
|
||||
},
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
// Cache the result for 30 minutes (search results change less frequently)
|
||||
await cache.set(cacheKey, packages, 1800);
|
||||
if (!indexResponse.ok) {
|
||||
console.error('PyPI Simple index error:', indexResponse.status, indexResponse.statusText);
|
||||
} else {
|
||||
const indexJson = (await indexResponse.json()) as { projects?: Array<{ name: string; url: string }>; };
|
||||
allPackages = (indexJson.projects || []).map((p) => p.name);
|
||||
if (allPackages.length > 0) {
|
||||
// Cache the full index for 6 hours
|
||||
await cache.set(indexKey, allPackages, 6 * 60 * 60);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return packages;
|
||||
const q = query.toLowerCase();
|
||||
let matches: string[] = [];
|
||||
if (Array.isArray(allPackages) && allPackages.length > 0) {
|
||||
matches = allPackages
|
||||
.filter((name) => name.toLowerCase().startsWith(q))
|
||||
.slice(0, 20);
|
||||
}
|
||||
|
||||
// Fallback: if no matches from the index, try exact project existence via JSON API
|
||||
if (matches.length === 0) {
|
||||
const projectResponse = await fetch(`https://pypi.org/pypi/${encodeURIComponent(query)}/json`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'pypistats.app (server-side)'
|
||||
},
|
||||
signal: controller.signal
|
||||
});
|
||||
if (projectResponse.ok) {
|
||||
matches = [query];
|
||||
}
|
||||
}
|
||||
|
||||
// Cache per-query matches for 30 minutes
|
||||
await cache.set(cacheKey, matches, 1800);
|
||||
return matches;
|
||||
} catch (error) {
|
||||
if ((error as any)?.name === 'AbortError') {
|
||||
console.error('PyPI Simple API request timed out');
|
||||
} else {
|
||||
console.error('PyPI Simple/API request failed:', error);
|
||||
}
|
||||
return [] as string[];
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPackageCount() {
|
||||
const cacheKey = CacheManager.getPackageCountKey();
|
||||
try {
|
||||
// First try recent monthly snapshot as authoritative
|
||||
const recent = await prisma.recentDownloadCount.findMany({
|
||||
where: { category: 'month' },
|
||||
distinct: ['package'],
|
||||
select: { package: true }
|
||||
});
|
||||
|
||||
// Try to get from cache first
|
||||
const cached = await cache.get<number>(cacheKey);
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
const distinct = new Set<string>(recent.map((r) => r.package));
|
||||
|
||||
const count = distinct.size;
|
||||
|
||||
return count;
|
||||
} catch (error) {
|
||||
console.error('getPackageCount failed:', error);
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const result = await prisma.recentDownloadCount.groupBy({
|
||||
export async function getPopularPackages(limit = 10, days = 30): Promise<Array<{ package: string; downloads: number }>> {
|
||||
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||
// Prefer 'without_mirrors' as the canonical signal
|
||||
const grouped = await prisma.overallDownloadCount.groupBy({
|
||||
by: ['package'],
|
||||
where: {
|
||||
category: 'month'
|
||||
}
|
||||
category: 'without_mirrors',
|
||||
date: { gte: cutoff }
|
||||
},
|
||||
_sum: { downloads: true },
|
||||
orderBy: { _sum: { downloads: 'desc' } },
|
||||
take: limit
|
||||
});
|
||||
return grouped.map((g) => ({ package: g.package, downloads: Number(g._sum.downloads || 0) }));
|
||||
}
|
||||
|
||||
const count = result.length;
|
||||
export type PackageMetadata = {
|
||||
name: string;
|
||||
version: string | null;
|
||||
summary: string | null;
|
||||
homePage: string | null;
|
||||
projectUrls: Record<string, string> | null;
|
||||
pypiUrl: string;
|
||||
latestReleaseDate: string | null;
|
||||
};
|
||||
|
||||
// Cache the result for 1 hour
|
||||
await cache.set(cacheKey, count, 3600);
|
||||
|
||||
return count;
|
||||
export async function getPackageMetadata(packageName: string): Promise<PackageMetadata> {
|
||||
const url = `https://pypi.org/pypi/${encodeURIComponent(packageName)}/json`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'pypistats.app (server-side)'
|
||||
}
|
||||
});
|
||||
if (!res.ok) {
|
||||
return {
|
||||
name: packageName,
|
||||
version: null,
|
||||
summary: null,
|
||||
homePage: null,
|
||||
projectUrls: null,
|
||||
pypiUrl: `https://pypi.org/project/${packageName}/`,
|
||||
latestReleaseDate: null
|
||||
};
|
||||
}
|
||||
const json = await res.json();
|
||||
const info = json?.info || {};
|
||||
const version = info?.version ?? null;
|
||||
// Determine latest upload time for the current version
|
||||
let latestReleaseDate: string | null = null;
|
||||
try {
|
||||
const releases = json?.releases || {};
|
||||
const files = Array.isArray(releases?.[version]) ? releases[version] : [];
|
||||
const latest = files.reduce((max: string | null, f: any) => {
|
||||
const t = f?.upload_time_iso_8601 || f?.upload_time || null;
|
||||
if (!t) return max;
|
||||
return !max || new Date(t).getTime() > new Date(max).getTime() ? t : max;
|
||||
}, null as string | null);
|
||||
latestReleaseDate = latest ? new Date(latest).toISOString().split('T')[0] : null;
|
||||
} catch {}
|
||||
return {
|
||||
name: packageName,
|
||||
version,
|
||||
summary: info?.summary ?? null,
|
||||
homePage: info?.home_page ?? null,
|
||||
projectUrls: (info?.project_urls as Record<string, string> | undefined) ?? null,
|
||||
pypiUrl: `https://pypi.org/project/${packageName}/`,
|
||||
latestReleaseDate
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getPackageMetadata error:', error);
|
||||
return {
|
||||
name: packageName,
|
||||
version: null,
|
||||
summary: null,
|
||||
homePage: null,
|
||||
projectUrls: null,
|
||||
pypiUrl: `https://pypi.org/project/${packageName}/`,
|
||||
latestReleaseDate: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Cache invalidation functions
|
||||
|
||||
@@ -313,6 +313,7 @@ export class DataProcessor {
|
||||
WITH dls AS (
|
||||
SELECT
|
||||
file.project AS package,
|
||||
file.version AS file_version,
|
||||
details.installer.name AS installer,
|
||||
details.python AS python_version,
|
||||
details.system.name AS system
|
||||
@@ -374,6 +375,17 @@ export class DataProcessor {
|
||||
FROM dls
|
||||
WHERE installer NOT IN (${MIRRORS.map(m => `'${m}'`).join(', ')})
|
||||
GROUP BY package, category
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
package,
|
||||
'version' AS category_label,
|
||||
COALESCE(file_version, 'unknown') AS category,
|
||||
COUNT(*) AS downloads
|
||||
FROM dls
|
||||
WHERE installer NOT IN (${MIRRORS.map(m => `'${m}'`).join(', ')})
|
||||
GROUP BY package, category
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -446,6 +458,7 @@ export class DataProcessor {
|
||||
SELECT
|
||||
DATE(timestamp) AS date,
|
||||
file.project AS package,
|
||||
file.version AS file_version,
|
||||
details.installer.name AS installer,
|
||||
details.python AS python_version,
|
||||
details.system.name AS system
|
||||
@@ -518,6 +531,17 @@ export class DataProcessor {
|
||||
COUNT(*) AS downloads
|
||||
FROM dls
|
||||
GROUP BY date, package, category
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
date,
|
||||
package,
|
||||
'version' AS category_label,
|
||||
COALESCE(file_version, 'unknown') AS category,
|
||||
COUNT(*) AS downloads
|
||||
FROM dls
|
||||
GROUP BY date, package, category
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -550,15 +574,68 @@ export class DataProcessor {
|
||||
where: { date: dateObj }
|
||||
});
|
||||
break;
|
||||
case 'version':
|
||||
if ((tx as any).versionDownloadCount) {
|
||||
await (tx as any).versionDownloadCount.deleteMany({ where: { date: dateObj } });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async insertRecords(table: string, records: any[], tx: Prisma.TransactionClient): Promise<void> {
|
||||
const normalizeDate = (value: any): Date => {
|
||||
if (value instanceof Date) return value;
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
||||
const d = new Date(`${trimmed}T00:00:00Z`);
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
}
|
||||
const d = new Date(trimmed);
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
// BigQuery DATE often arrives as { value: 'YYYY-MM-DD' }
|
||||
if (typeof (value as any).value === 'string') {
|
||||
return normalizeDate((value as any).value);
|
||||
}
|
||||
// Some drivers return { year, month, day }
|
||||
const maybeY = (value as any).year;
|
||||
const maybeM = (value as any).month;
|
||||
const maybeD = (value as any).day;
|
||||
if (
|
||||
typeof maybeY === 'number' &&
|
||||
typeof maybeM === 'number' &&
|
||||
typeof maybeD === 'number'
|
||||
) {
|
||||
const mm = String(maybeM).padStart(2, '0');
|
||||
const dd = String(maybeD).padStart(2, '0');
|
||||
return normalizeDate(`${maybeY}-${mm}-${dd}`);
|
||||
}
|
||||
// Timestamp-like with toDate()
|
||||
if (typeof (value as any).toDate === 'function') {
|
||||
const d = (value as any).toDate();
|
||||
if (d instanceof Date && !isNaN(d.getTime())) return d;
|
||||
}
|
||||
// Timestamp-like with seconds/nanos
|
||||
if (
|
||||
typeof (value as any).seconds === 'number' ||
|
||||
typeof (value as any).nanos === 'number'
|
||||
) {
|
||||
const seconds = Number((value as any).seconds || 0);
|
||||
const nanos = Number((value as any).nanos || 0);
|
||||
const d = new Date(seconds * 1000 + Math.floor(nanos / 1e6));
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
}
|
||||
}
|
||||
throw new Error(`Invalid date value: ${value}`);
|
||||
};
|
||||
|
||||
switch (table) {
|
||||
case 'overall':
|
||||
await tx.overallDownloadCount.createMany({
|
||||
data: records.map(r => ({
|
||||
date: new Date(r.date),
|
||||
date: normalizeDate(r.date),
|
||||
package: r.package,
|
||||
category: r.category ?? 'unknown',
|
||||
downloads: r.downloads
|
||||
@@ -568,7 +645,7 @@ export class DataProcessor {
|
||||
case 'python_major':
|
||||
await tx.pythonMajorDownloadCount.createMany({
|
||||
data: records.map(r => ({
|
||||
date: new Date(r.date),
|
||||
date: normalizeDate(r.date),
|
||||
package: r.package,
|
||||
category: r.category ?? 'unknown',
|
||||
downloads: r.downloads
|
||||
@@ -578,7 +655,7 @@ export class DataProcessor {
|
||||
case 'python_minor':
|
||||
await tx.pythonMinorDownloadCount.createMany({
|
||||
data: records.map(r => ({
|
||||
date: new Date(r.date),
|
||||
date: normalizeDate(r.date),
|
||||
package: r.package,
|
||||
category: r.category ?? 'unknown',
|
||||
downloads: r.downloads
|
||||
@@ -588,7 +665,7 @@ export class DataProcessor {
|
||||
case 'system':
|
||||
await tx.systemDownloadCount.createMany({
|
||||
data: records.map(r => ({
|
||||
date: new Date(r.date),
|
||||
date: normalizeDate(r.date),
|
||||
package: r.package,
|
||||
category: r.category ?? 'other',
|
||||
downloads: r.downloads
|
||||
@@ -598,13 +675,25 @@ export class DataProcessor {
|
||||
case 'installer':
|
||||
await (tx as any).installerDownloadCount.createMany({
|
||||
data: records.map(r => ({
|
||||
date: new Date(r.date),
|
||||
date: normalizeDate(r.date),
|
||||
package: r.package,
|
||||
category: r.category ?? 'unknown',
|
||||
downloads: r.downloads
|
||||
}))
|
||||
});
|
||||
break;
|
||||
case 'version':
|
||||
if ((tx as any).versionDownloadCount) {
|
||||
await (tx as any).versionDownloadCount.createMany({
|
||||
data: records.map(r => ({
|
||||
date: normalizeDate(r.date),
|
||||
package: r.package,
|
||||
category: r.category ?? 'unknown',
|
||||
downloads: r.downloads
|
||||
}))
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -735,6 +824,11 @@ export class DataProcessor {
|
||||
where: { date: { lt: purgeDate } }
|
||||
});
|
||||
return systemResult.count;
|
||||
case 'version':
|
||||
const versionResult = await (prisma as any).versionDownloadCount.deleteMany({
|
||||
where: { date: { lt: purgeDate } }
|
||||
});
|
||||
return versionResult.count;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -26,9 +26,7 @@
|
||||
<a href="/api" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
API
|
||||
</a>
|
||||
<a href="/admin" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
Admin
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { getPackageCount } from '$lib/api.js';
|
||||
import { getPackageCount, getPopularPackages } from '$lib/api.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load = async () => {
|
||||
export const load: PageServerLoad = async () => {
|
||||
try {
|
||||
const packageCount = getPackageCount();
|
||||
// Count distinct packages that have any saved data in DB (recent/month as proxy)
|
||||
const [packageCount, popular] = await Promise.all([
|
||||
getPackageCount(),
|
||||
getPopularPackages(10, 30)
|
||||
]);
|
||||
return {
|
||||
packageCount
|
||||
packageCount,
|
||||
popular
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error loading page data:', error);
|
||||
return {
|
||||
packageCount: 0
|
||||
packageCount: 0,
|
||||
popular: []
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
const { data } = $props<{ data: PageData }>();
|
||||
@@ -22,8 +21,8 @@
|
||||
</p>
|
||||
|
||||
<!-- Search Form -->
|
||||
<div class="max-w-md mx-auto">
|
||||
<form method="POST" action="/search" use:enhance class="flex gap-2">
|
||||
<div class="max-w-md mx-auto">
|
||||
<form method="GET" action="/search" class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
@@ -41,20 +40,29 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{#await data.packageCount then packageCount}
|
||||
<div class="mt-8 text-sm text-gray-500">
|
||||
Tracking {packageCount?.toLocaleString()} packages
|
||||
</div>
|
||||
{/await}
|
||||
<div class="mt-8 text-sm text-gray-500">
|
||||
Tracking {data.packageCount ? data.packageCount.toLocaleString() : 'tons of'} packages
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<div class="mt-16 grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div class="bg-white p-6 rounded-lg shadow-sm border">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Popular Packages</h3>
|
||||
<p class="text-gray-600 mb-4">Check download stats for popular Python packages</p>
|
||||
<a href="/search/numpy" class="text-blue-600 hover:text-blue-800 font-medium">View Examples →</a>
|
||||
</div>
|
||||
<div class="bg-white p-6 rounded-lg shadow-sm border">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Popular Packages (last 30 days)</h3>
|
||||
<p class="text-gray-600 mb-4">Top projects by downloads (without mirrors)</p>
|
||||
{#if data.popular && data.popular.length > 0}
|
||||
<ul class="divide-y divide-gray-200">
|
||||
{#each data.popular as row}
|
||||
<li class="py-2 flex items-center justify-between">
|
||||
<a class="text-blue-600 hover:text-blue-800 font-medium" href="/packages/{row.package}" data-sveltekit-preload-data="off">{row.package}</a>
|
||||
<span class="text-sm text-gray-500">{row.downloads.toLocaleString()}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<div class="text-sm text-gray-500">No data yet.</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow-sm border">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">API Access</h3>
|
||||
|
||||
@@ -153,8 +153,9 @@
|
||||
<div class="min-h-screen bg-gray-50 py-8">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Admin Dashboard</h1>
|
||||
<p class="mt-2 text-gray-600">Manage data processing and cache operations</p>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Not Found</h1>
|
||||
<p class="mt-2 text-gray-600">The admin dashboard has been removed.</p>
|
||||
<p class="mt-4"><a href="/" class="text-blue-600 hover:text-blue-800">Return to homepage</a></p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
|
||||
154
src/routes/api/packages/[package]/chart/[type]/+server.ts
Normal file
154
src/routes/api/packages/[package]/chart/[type]/+server.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { dev } from '$app/environment';
|
||||
import { CacheManager } from '$lib/redis.js';
|
||||
import { getOverallDownloads, getPythonMajorDownloads, getPythonMinorDownloads, getSystemDownloads, getInstallerDownloads, getVersionDownloads } from '$lib/api.js';
|
||||
import { ChartJSNodeCanvas } from 'chartjs-node-canvas';
|
||||
|
||||
const cache = new CacheManager();
|
||||
|
||||
const width = 1200;
|
||||
const height = 600;
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || '';
|
||||
const type = params.type || 'overall';
|
||||
const chartType = (url.searchParams.get('chart') || 'line').toLowerCase(); // 'line' | 'bar'
|
||||
const mirrors = url.searchParams.get('mirrors') || undefined; // for overall
|
||||
const version = url.searchParams.get('version') || undefined; // for python_* filters
|
||||
const os = url.searchParams.get('os') || undefined; // for system filter
|
||||
const format = (url.searchParams.get('format') || '').toLowerCase(); // 'json' to return data only
|
||||
|
||||
if (!packageName || packageName === '__all__') {
|
||||
return new Response('Invalid package', { status: 400 });
|
||||
}
|
||||
|
||||
const cacheKey = `pypistats:chart:${packageName}:${type}:${chartType}:${mirrors || ''}:${version || ''}:${os || ''}`;
|
||||
const skipCache = dev || url.searchParams.get('nocache') === '1' || url.searchParams.get('cache') === 'false';
|
||||
if (!skipCache) {
|
||||
const cached = await cache.get<string>(cacheKey);
|
||||
if (cached) {
|
||||
const buffer = Buffer.from(cached, 'base64');
|
||||
return new Response(buffer, { headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' } });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch data based on type
|
||||
let rows: Array<{ date: any; category: string; downloads: number | bigint }>;
|
||||
if (type === 'overall') {
|
||||
rows = await getOverallDownloads(packageName, mirrors);
|
||||
} else if (type === 'python_major') {
|
||||
rows = await getPythonMajorDownloads(packageName, version);
|
||||
} else if (type === 'python_minor') {
|
||||
rows = await getPythonMinorDownloads(packageName, version);
|
||||
} else if (type === 'system') {
|
||||
rows = await getSystemDownloads(packageName, os);
|
||||
} else if (type === 'installer') {
|
||||
rows = await getInstallerDownloads(packageName);
|
||||
} else if (type === 'version') {
|
||||
rows = await getVersionDownloads(packageName);
|
||||
} else {
|
||||
return new Response('Unknown chart type', { status: 400 });
|
||||
}
|
||||
|
||||
// Group by category -> timeseries
|
||||
const seriesMap = new Map<string, Array<{ x: string; y: number }>>();
|
||||
for (const r of rows) {
|
||||
const key = r.category;
|
||||
if (!seriesMap.has(key)) seriesMap.set(key, []);
|
||||
const isoDate = typeof r.date === 'string' ? r.date : new Date(r.date).toISOString().split('T')[0];
|
||||
seriesMap.get(key)!.push({ x: isoDate, y: Number(r.downloads) });
|
||||
}
|
||||
for (const arr of seriesMap.values()) {
|
||||
arr.sort((a, b) => a.x.localeCompare(b.x));
|
||||
}
|
||||
|
||||
// Build normalized date labels so datasets align with labels
|
||||
const labels = Array.from(
|
||||
new Set(
|
||||
rows.map(r => (typeof r.date === 'string' ? r.date : new Date(r.date).toISOString().split('T')[0]))
|
||||
)
|
||||
).sort();
|
||||
|
||||
const datasets = Array.from(seriesMap.entries()).map(([label, points], idx) => {
|
||||
const color = palette[idx % palette.length];
|
||||
return {
|
||||
label,
|
||||
data: labels.map(l => points.find(p => p.x === l)?.y ?? 0),
|
||||
borderColor: color,
|
||||
backgroundColor: color + '33',
|
||||
fill: chartType === 'line'
|
||||
} as any;
|
||||
});
|
||||
|
||||
// Human-friendly chart title
|
||||
const typeTitle =
|
||||
type === 'overall' ? 'Overall downloads (with/without mirrors)'
|
||||
: type === 'python_major' ? 'Downloads by Python major version'
|
||||
: type === 'python_minor' ? 'Downloads by Python minor version'
|
||||
: type === 'system' ? 'Downloads by operating system'
|
||||
: type === 'installer' ? 'Downloads by installer'
|
||||
: type === 'version' ? 'Downloads by version'
|
||||
: `${type} downloads`;
|
||||
const titleText = `${packageName} — ${typeTitle}`;
|
||||
|
||||
// If JSON/data requested, return data for interactive charts
|
||||
if (format === 'json' || format === 'data') {
|
||||
const body = {
|
||||
package: packageName,
|
||||
type,
|
||||
chartType,
|
||||
title: titleText,
|
||||
labels,
|
||||
datasets
|
||||
};
|
||||
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300' } });
|
||||
}
|
||||
|
||||
const configuration = {
|
||||
type: chartType as any,
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
responsive: false,
|
||||
plugins: {
|
||||
legend: { position: 'bottom' },
|
||||
title: { display: true, text: titleText }
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
title: { display: true, text: 'Date' },
|
||||
ticks: { autoSkip: true, maxTicksLimit: 12 }
|
||||
},
|
||||
y: {
|
||||
title: { display: true, text: 'Downloads' },
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
callback: (value: any) => {
|
||||
try { return Number(value).toLocaleString(); } catch { return value; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderer = new ChartJSNodeCanvas({ width, height, backgroundColour: 'white' });
|
||||
const image = await renderer.renderToBuffer(configuration as any, 'image/png');
|
||||
|
||||
// Cache for 1 hour (disabled in dev or when nocache is set)
|
||||
if (!skipCache) {
|
||||
await cache.set(cacheKey, image.toString('base64'), 3600);
|
||||
}
|
||||
|
||||
return new Response(image, { headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' } });
|
||||
} catch (error) {
|
||||
console.error('Error rendering chart:', error);
|
||||
return new Response('Internal server error', { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
const palette = [
|
||||
'#2563eb', '#16a34a', '#dc2626', '#7c3aed', '#ea580c', '#0891b2', '#ca8a04', '#4b5563'
|
||||
];
|
||||
|
||||
|
||||
60
src/routes/api/packages/[package]/summary/+server.ts
Normal file
60
src/routes/api/packages/[package]/summary/+server.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { prisma } from '$lib/prisma.js';
|
||||
import { DataProcessor } from '$lib/data-processor.js';
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || '';
|
||||
if (!packageName || packageName === '__all__') {
|
||||
return json({ error: 'Invalid package name' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const processor = new DataProcessor();
|
||||
await processor.ensurePackageFreshness(packageName);
|
||||
|
||||
const [overallAll, systemAll, pyMajorAll, pyMinorAll] = await Promise.all([
|
||||
prisma.overallDownloadCount.groupBy({
|
||||
by: ['category'],
|
||||
where: { package: packageName },
|
||||
_sum: { downloads: true }
|
||||
}),
|
||||
prisma.systemDownloadCount.groupBy({
|
||||
by: ['category'],
|
||||
where: { package: packageName },
|
||||
_sum: { downloads: true }
|
||||
}),
|
||||
prisma.pythonMajorDownloadCount.groupBy({
|
||||
by: ['category'],
|
||||
where: { package: packageName },
|
||||
_sum: { downloads: true }
|
||||
}),
|
||||
prisma.pythonMinorDownloadCount.groupBy({
|
||||
by: ['category'],
|
||||
where: { package: packageName },
|
||||
_sum: { downloads: true }
|
||||
})
|
||||
]);
|
||||
|
||||
const overallTotal = overallAll.reduce((sum, r) => sum + Number(r._sum.downloads || 0), 0);
|
||||
const systemTotals = Object.fromEntries(systemAll.map(r => [r.category, Number(r._sum.downloads || 0)]));
|
||||
const pythonMajorTotals = Object.fromEntries(pyMajorAll.map(r => [r.category, Number(r._sum.downloads || 0)]));
|
||||
const pythonMinorTotals = Object.fromEntries(pyMinorAll.map(r => [r.category, Number(r._sum.downloads || 0)]));
|
||||
|
||||
return json({
|
||||
package: packageName,
|
||||
type: 'summary',
|
||||
totals: {
|
||||
overall: overallTotal,
|
||||
system: systemTotals,
|
||||
python_major: pythonMajorTotals,
|
||||
python_minor: pythonMinorTotals
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error building package summary:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
89
src/routes/packages/[package]/+page.server.ts
Normal file
89
src/routes/packages/[package]/+page.server.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
getRecentDownloads,
|
||||
getOverallDownloads,
|
||||
getPythonMajorDownloads,
|
||||
getPythonMinorDownloads,
|
||||
getSystemDownloads,
|
||||
getPackageMetadata,
|
||||
getInstallerDownloads,
|
||||
getVersionDownloads
|
||||
} from '$lib/api.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
// Streaming responses: use native promises in returned object (SvelteKit will stream)
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || '';
|
||||
|
||||
if (!packageName || packageName === '__all__') {
|
||||
return {
|
||||
packageName,
|
||||
recentStats: null,
|
||||
overallStats: [],
|
||||
pythonMajorStats: [],
|
||||
pythonMinorStats: [],
|
||||
systemStats: []
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const recentStatsP = getRecentDownloads(packageName).then((recent) => {
|
||||
const fm: Record<string, number> = {};
|
||||
for (const stat of recent) fm[`last_${stat.category}`] = Number(stat.downloads);
|
||||
return fm;
|
||||
});
|
||||
|
||||
const overallStatsP = getOverallDownloads(packageName);
|
||||
const pythonMajorStatsP = getPythonMajorDownloads(packageName);
|
||||
const pythonMinorStatsP = getPythonMinorDownloads(packageName);
|
||||
const systemStatsP = getSystemDownloads(packageName);
|
||||
const installerStatsP = getInstallerDownloads(packageName);
|
||||
const versionStatsP = getVersionDownloads(packageName);
|
||||
const metaP = getPackageMetadata(packageName);
|
||||
|
||||
const summaryTotalsP = Promise.all([
|
||||
overallStatsP,
|
||||
pythonMajorStatsP,
|
||||
pythonMinorStatsP,
|
||||
systemStatsP,
|
||||
installerStatsP,
|
||||
versionStatsP
|
||||
]).then(([overall, pyMaj, pyMin, system, installer, version]) => {
|
||||
const sum = <T extends { category: string; downloads: any }>(rows: T[]) => {
|
||||
const map: Record<string, number> = {};
|
||||
for (const r of rows) map[r.category] = (map[r.category] || 0) + Number(r.downloads);
|
||||
return map;
|
||||
};
|
||||
return {
|
||||
overall: sum(overall),
|
||||
python_major: sum(pyMaj),
|
||||
python_minor: sum(pyMin),
|
||||
system: sum(system),
|
||||
installer: sum(installer),
|
||||
version: sum(version)
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
packageName,
|
||||
meta: metaP,
|
||||
recentStats: recentStatsP,
|
||||
overallStats: overallStatsP,
|
||||
pythonMajorStats: pythonMajorStatsP,
|
||||
pythonMinorStats: pythonMinorStatsP,
|
||||
systemStats: systemStatsP,
|
||||
summaryTotals: summaryTotalsP
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error loading package data:', error);
|
||||
return {
|
||||
packageName,
|
||||
recentStats: null,
|
||||
overallStats: [],
|
||||
pythonMajorStats: [],
|
||||
pythonMinorStats: [],
|
||||
systemStats: []
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,154 +1,548 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
const { data } = $props<{ data: PageData }>();
|
||||
import type { PageData } from './$types';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
const { data }: { data: PageData } = $props();
|
||||
|
||||
let overallCanvas: HTMLCanvasElement | null = $state(null);
|
||||
let pyMajorCanvas: HTMLCanvasElement | null = $state(null);
|
||||
let pyMinorCanvas: HTMLCanvasElement | null = $state(null);
|
||||
let systemCanvas: HTMLCanvasElement | null = $state(null);
|
||||
let charts: any[] = [];
|
||||
let installerCanvas: HTMLCanvasElement | null = $state(null);
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat(undefined);
|
||||
const compactFormatter = new Intl.NumberFormat(undefined, {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1
|
||||
});
|
||||
function formatNumber(n: number) {
|
||||
try {
|
||||
return numberFormatter.format(n);
|
||||
} catch {
|
||||
return String(n);
|
||||
}
|
||||
}
|
||||
function formatCompact(n: number) {
|
||||
try {
|
||||
return compactFormatter.format(n);
|
||||
} catch {
|
||||
return String(n);
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming handled with {#await} blocks below
|
||||
|
||||
// Build combined Python versions rows reactively
|
||||
type PythonRow = { kind: 'major' | 'minor'; label: string; downloads: number };
|
||||
function buildPythonVersionRows(
|
||||
pyMajorTotalsMap: Record<string, number>,
|
||||
pyMinorTotalsMap: Record<string, number>
|
||||
): PythonRow[] {
|
||||
const rows: PythonRow[] = [];
|
||||
const majorOrder = ['2', '3', 'unknown'];
|
||||
for (const major of majorOrder) {
|
||||
const majorDownloads = Number(pyMajorTotalsMap[major] || 0);
|
||||
if (majorDownloads > 0 || major in pyMajorTotalsMap) {
|
||||
rows.push({ kind: 'major', label: major, downloads: majorDownloads });
|
||||
const minors: Array<[string, number]> = Object.entries(pyMinorTotalsMap)
|
||||
.filter(([k]) => (major === 'unknown' ? k === 'unknown' : k.startsWith(major + '.')))
|
||||
.map(([k, v]) => [k, Number(v)]);
|
||||
minors.sort((a, b) => {
|
||||
const ak =
|
||||
a[0] === 'unknown' ? Number.POSITIVE_INFINITY : parseFloat(a[0].split('.')[1] || '0');
|
||||
const bk =
|
||||
b[0] === 'unknown' ? Number.POSITIVE_INFINITY : parseFloat(b[0].split('.')[1] || '0');
|
||||
return ak - bk;
|
||||
});
|
||||
for (const [minor, dls] of minors)
|
||||
rows.push({ kind: 'minor', label: minor, downloads: dls });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function loadAndRenderChart(
|
||||
canvas: HTMLCanvasElement | null,
|
||||
type: string,
|
||||
params: Record<string, string> = {}
|
||||
) {
|
||||
if (!canvas) return;
|
||||
const qs = new URLSearchParams({ format: 'json', chart: 'line', ...params });
|
||||
const resp = await fetch(
|
||||
`/api/packages/${encodeURIComponent(data.packageName)}/chart/${type}?${qs.toString()}`
|
||||
);
|
||||
if (!resp.ok) return;
|
||||
const payload = await resp.json();
|
||||
const { default: Chart } = await import('chart.js/auto');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
const chart = new Chart(ctx, {
|
||||
type: (payload.chartType || 'line') as any,
|
||||
data: { labels: payload.labels, datasets: payload.datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'bottom' },
|
||||
title: { display: true, text: payload.title },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx: any) =>
|
||||
`${ctx.dataset.label}: ${formatNumber(Number(ctx.parsed?.y ?? ctx.raw?.y ?? 0))}`
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
title: { display: true, text: 'Date' },
|
||||
ticks: {
|
||||
autoSkip: true,
|
||||
maxTicksLimit: 8,
|
||||
callback: (value: any, index: number, ticks: any[]) => {
|
||||
const label = (payload.labels?.[index] ?? '').toString();
|
||||
// Show MM/dd for compactness
|
||||
const d = new Date(label);
|
||||
if (!isNaN(d.getTime())) return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
return label;
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
title: { display: true, text: 'Downloads' },
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
callback: (value: any) => formatCompact(Number(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
charts.push(chart);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadAndRenderChart(overallCanvas, 'overall');
|
||||
requestAnimationFrame(() => {
|
||||
loadAndRenderChart(pyMajorCanvas, 'python_major');
|
||||
loadAndRenderChart(pyMinorCanvas, 'python_minor');
|
||||
loadAndRenderChart(systemCanvas, 'system');
|
||||
loadAndRenderChart(installerCanvas, 'installer');
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
for (const c of charts) {
|
||||
try {
|
||||
c.destroy();
|
||||
} catch {}
|
||||
}
|
||||
charts = [];
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.packageName} - PyPI Stats</title>
|
||||
<meta name="description" content="Download statistics for {data.packageName} package" />
|
||||
<title>{data.packageName} - PyPI Stats</title>
|
||||
<meta name="description" content="Download statistics for {data.packageName} package" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">{data.packageName}</h1>
|
||||
<h1 class="mb-2 text-3xl font-bold text-gray-900">{data.packageName}</h1>
|
||||
<p class="text-gray-600">Download statistics from PyPI</p>
|
||||
|
||||
{#if data.meta}
|
||||
{#await data.meta}
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2 text-sm">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full border bg-gray-50 px-2.5 py-1 text-gray-700"
|
||||
>Loading…</span
|
||||
>
|
||||
</div>
|
||||
{:then meta}
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2 text-sm">
|
||||
{#if meta.version}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-blue-700"
|
||||
>v{meta.version}</span
|
||||
>
|
||||
{/if}
|
||||
{#if meta.latestReleaseDate}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full border border-green-200 bg-green-50 px-2.5 py-1 text-green-700"
|
||||
>Released {meta.latestReleaseDate}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2 text-sm">
|
||||
{#if data.summaryTotals}
|
||||
{#await data.summaryTotals then totals}
|
||||
{#if totals?.system}
|
||||
{#await Promise.resolve(Object.entries(totals.system).sort((a, b) => Number(b[1]) - Number(a[1]))[0]) then topSys}
|
||||
{#if topSys}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full border border-purple-200 bg-purple-50 px-2.5 py-1 text-purple-700"
|
||||
>Popular system: {topSys[0]}</span
|
||||
>
|
||||
{/if}
|
||||
{/await}
|
||||
{/if}
|
||||
{/await}
|
||||
{/if}
|
||||
{#if data.summaryTotals}
|
||||
{#await data.summaryTotals then totals}
|
||||
{#if totals?.installer}
|
||||
{#await Promise.resolve(Object.entries(totals.installer).sort((a, b) => Number(b[1]) - Number(a[1]))[0]) then topInst}
|
||||
{#if topInst}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full border border-indigo-200 bg-indigo-50 px-2.5 py-1 text-indigo-700"
|
||||
>Popular installer: {topInst[0]}</span
|
||||
>
|
||||
{/if}
|
||||
{/await}
|
||||
{/if}
|
||||
{/await}
|
||||
{/if}
|
||||
{#if data.summaryTotals}
|
||||
{#await data.summaryTotals then totals}
|
||||
{#if totals?.version}
|
||||
{#await Promise.resolve(Object.entries(totals.version).sort((a, b) => Number(b[1]) - Number(a[1]))[0]) then topVer}
|
||||
{#if topVer}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-amber-700"
|
||||
>Top version: {topVer[0]}</span
|
||||
>
|
||||
{/if}
|
||||
{/await}
|
||||
{/if}
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2 text-sm">
|
||||
<a
|
||||
class="inline-flex items-center rounded-full border bg-gray-50 px-2.5 py-1 text-gray-700 hover:bg-gray-100"
|
||||
href={meta.pypiUrl}
|
||||
rel="noopener"
|
||||
target="_blank">View on PyPI</a
|
||||
>
|
||||
{#if meta.homePage}
|
||||
<a
|
||||
class="inline-flex items-center rounded-full border bg-gray-50 px-2.5 py-1 text-gray-700 hover:bg-gray-100"
|
||||
href={meta.homePage}
|
||||
rel="noopener"
|
||||
target="_blank">Homepage</a
|
||||
>
|
||||
{/if}
|
||||
{#if meta.projectUrls}
|
||||
{#each Object.entries(meta.projectUrls).filter(([label, url]) => !['homepage'].includes(label.toLowerCase())) as [label, url]}
|
||||
{#if typeof url === 'string'}
|
||||
<a
|
||||
class="inline-flex items-center rounded-full border bg-gray-50 px-2.5 py-1 text-gray-700 hover:bg-gray-100"
|
||||
href={url}
|
||||
rel="noopener"
|
||||
target="_blank">{label}</a
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Recent Stats -->
|
||||
{#if data.recentStats}
|
||||
<div class="bg-white rounded-lg shadow-sm border mb-8">
|
||||
<div class="px-6 py-4 border-b">
|
||||
<!-- Recent Stats + Consolidated Totals -->
|
||||
{#if data.recentStats}
|
||||
<div class="mb-8 rounded-lg border bg-white shadow-sm">
|
||||
<div class="border-b px-6 py-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Recent Downloads</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{#each Object.entries(data.recentStats) as [period, count]}
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-blue-600">
|
||||
{(count as number).toLocaleString()}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 capitalize">
|
||||
{period.replace('last_', '')}
|
||||
<div class="flex flex-wrap gap-6">
|
||||
<div class="max-w-full min-w-[280px] flex-1 grow">
|
||||
<h4 class="mb-2 text-sm font-semibold text-gray-700">Recent</h4>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Period</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Downloads</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#await data.recentStats}
|
||||
<tr><td class="px-6 py-3 text-sm text-gray-500" colspan="2">Loading…</td></tr>
|
||||
{:then rs}
|
||||
{#each [['week', Number((rs as any)?.last_week || 0)], ['month', Number((rs as any)?.last_month || 0)]] as [period, count]}
|
||||
<tr>
|
||||
<td class="px-6 py-3 text-sm text-gray-700 capitalize">{period}</td>
|
||||
<td class="px-6 py-3 text-right text-sm font-semibold text-gray-900"
|
||||
>{formatNumber(Number(count))}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
{:catch _}
|
||||
<tr
|
||||
><td class="px-6 py-3 text-sm text-red-600" colspan="2">Failed to load</td
|
||||
></tr
|
||||
>
|
||||
{/await}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-w-full min-w-[280px] flex-1 grow">
|
||||
<h4 class="mb-2 text-sm font-semibold text-gray-700">Overall</h4>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Category</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Downloads</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#await (data as any).summaryTotals}
|
||||
<tr><td class="px-6 py-3 text-sm text-gray-500" colspan="2">Loading…</td></tr>
|
||||
{:then totals}
|
||||
{#each Object.entries(totals?.overall || {}).sort((a, b) => Number(b[1]) - Number(a[1])) as [k, v]}
|
||||
<tr>
|
||||
<td class="px-6 py-3 text-sm text-gray-700 capitalize"
|
||||
>{k.replace(/_/g, ' ')}</td
|
||||
>
|
||||
<td class="px-6 py-3 text-right text-sm font-semibold text-gray-900"
|
||||
>{formatNumber(Number(v))}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
{:catch _}
|
||||
<tr
|
||||
><td class="px-6 py-3 text-sm text-red-600" colspan="2">Failed to load</td
|
||||
></tr
|
||||
>
|
||||
{/await}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-w-full min-w-[280px] flex-1 grow">
|
||||
<h4 class="mb-2 text-sm font-semibold text-gray-700">Systems</h4>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>System</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Downloads</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#await (data as any).summaryTotals}
|
||||
<tr><td class="px-6 py-3 text-sm text-gray-500" colspan="2">Loading…</td></tr>
|
||||
{:then totals}
|
||||
{#each Object.entries(totals?.system || {}).sort((a, b) => Number(b[1]) - Number(a[1])) as [k, v]}
|
||||
<tr>
|
||||
<td class="px-6 py-3 text-sm text-gray-700 capitalize">{k}</td>
|
||||
<td class="px-6 py-3 text-right text-sm font-semibold text-gray-900"
|
||||
>{formatNumber(Number(v))}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
{:catch _}
|
||||
<tr
|
||||
><td class="px-6 py-3 text-sm text-red-600" colspan="2">Failed to load</td
|
||||
></tr
|
||||
>
|
||||
{/await}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<h3 class="text-md mb-2 font-semibold text-gray-900">Python Versions</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Version</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Downloads</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#await (data as any).summaryTotals}
|
||||
<tr><td class="px-6 py-3 text-sm text-gray-500" colspan="2">Loading…</td></tr>
|
||||
{:then totals}
|
||||
{#each buildPythonVersionRows(totals?.python_major || {}, totals?.python_minor || {}) as row}
|
||||
<tr class={row.kind === 'major' ? 'bg-gray-50' : ''}>
|
||||
<td class="px-6 py-3 text-sm text-gray-700 capitalize">
|
||||
{#if row.kind === 'major'}
|
||||
Python {row.label}
|
||||
{:else}
|
||||
<span class="inline-block pl-6">{row.label}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-6 py-3 text-right text-sm font-semibold text-gray-900"
|
||||
>{formatNumber(row.downloads)}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
{:catch _}
|
||||
<tr><td class="px-6 py-3 text-sm text-red-600" colspan="2">Failed to load</td></tr
|
||||
>
|
||||
{/await}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Charts -->
|
||||
{#if data.packageName}
|
||||
<div class="mb-8 rounded-lg border bg-white shadow-sm">
|
||||
<div class="border-b px-6 py-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Overall Downloads Over Time</h2>
|
||||
<p class="text-sm text-gray-500">Includes with and without mirrors</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="w-full overflow-x-auto">
|
||||
<div class="relative h-96 w-full">
|
||||
<canvas bind:this={overallCanvas}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#await data.pythonMajorStats}
|
||||
<!-- loading placeholder omitted to reduce layout shift -->
|
||||
{:then majorArr}
|
||||
{#if majorArr && majorArr.length > 0}
|
||||
<div class="mb-8 rounded-lg border bg-white shadow-sm">
|
||||
<div class="border-b px-6 py-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Python Major Versions</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="w-full overflow-x-auto">
|
||||
<div class="relative h-96 w-full">
|
||||
<canvas bind:this={pyMajorCanvas}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/await}
|
||||
|
||||
<!-- Overall Stats -->
|
||||
{#if data.overallStats && data.overallStats.length > 0}
|
||||
<div class="bg-white rounded-lg shadow-sm border mb-8">
|
||||
<div class="px-6 py-4 border-b">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Overall Downloads</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Category</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Downloads</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{#each data.overallStats.slice(0, 10) as stat}
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{stat.date}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{stat.category}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{stat.downloads.toLocaleString()}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{#await data.pythonMinorStats}
|
||||
<!-- waiting -->
|
||||
{:then minorArr}
|
||||
{#if minorArr && minorArr.length > 0}
|
||||
<div class="mb-8 rounded-lg border bg-white shadow-sm">
|
||||
<div class="border-b px-6 py-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Python Minor Versions</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="w-full overflow-x-auto">
|
||||
<div class="relative h-96 w-full">
|
||||
<canvas bind:this={pyMinorCanvas}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/await}
|
||||
|
||||
<!-- Python Version Stats -->
|
||||
{#if data.pythonMajorStats && data.pythonMajorStats.length > 0}
|
||||
<div class="bg-white rounded-lg shadow-sm border mb-8">
|
||||
<div class="px-6 py-4 border-b">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Python Major Version Downloads</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Version</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Downloads</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{#each data.pythonMajorStats.slice(0, 10) as stat}
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{stat.date}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{stat.category}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{stat.downloads.toLocaleString()}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{#await data.systemStats}
|
||||
<!-- waiting -->
|
||||
{:then sysArr}
|
||||
{#if sysArr && sysArr.length > 0}
|
||||
<div class="mb-8 rounded-lg border bg-white shadow-sm">
|
||||
<div class="border-b px-6 py-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">System Downloads</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="w-full overflow-x-auto">
|
||||
<div class="relative h-96 w-full">
|
||||
<canvas bind:this={systemCanvas}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/await}
|
||||
|
||||
<!-- System Stats -->
|
||||
{#if data.systemStats && data.systemStats.length > 0}
|
||||
<div class="bg-white rounded-lg shadow-sm border mb-8">
|
||||
<div class="px-6 py-4 border-b">
|
||||
<h2 class="text-lg font-semibold text-gray-900">System Downloads</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">System</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Downloads</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{#each data.systemStats.slice(0, 10) as stat}
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{stat.date}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{stat.category}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{stat.downloads.toLocaleString()}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{#await (data as any).summaryTotals}
|
||||
<!-- waiting -->
|
||||
{:then totals}
|
||||
{#if totals && totals.installer}
|
||||
<div class="mb-8 rounded-lg border bg-white shadow-sm">
|
||||
<div class="border-b px-6 py-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Installer Breakdown</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="relative h-96 w-full">
|
||||
<canvas bind:this={installerCanvas}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
<!-- API Links -->
|
||||
<div class="bg-blue-50 rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">API Access</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div class="rounded-lg bg-blue-50 p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900">API Access</h3>
|
||||
<div class="grid grid-cols-1 gap-4 text-sm md:grid-cols-2">
|
||||
<div>
|
||||
<strong>Recent downloads:</strong>
|
||||
<a href="/api/packages/{data.packageName}/recent" class="text-blue-600 hover:text-blue-800 ml-2">JSON</a>
|
||||
<a
|
||||
href="/api/packages/{data.packageName}/recent"
|
||||
class="ml-2 text-blue-600 hover:text-blue-800">JSON</a
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Overall downloads:</strong>
|
||||
<a href="/api/packages/{data.packageName}/overall" class="text-blue-600 hover:text-blue-800 ml-2">JSON</a>
|
||||
<a
|
||||
href="/api/packages/{data.packageName}/overall"
|
||||
class="ml-2 text-blue-600 hover:text-blue-800">JSON</a
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Python major versions:</strong>
|
||||
<a href="/api/packages/{data.packageName}/python_major" class="text-blue-600 hover:text-blue-800 ml-2">JSON</a>
|
||||
<a
|
||||
href="/api/packages/{data.packageName}/python_major"
|
||||
class="ml-2 text-blue-600 hover:text-blue-800">JSON</a
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<strong>System downloads:</strong>
|
||||
<a href="/api/packages/{data.packageName}/system" class="text-blue-600 hover:text-blue-800 ml-2">JSON</a>
|
||||
<a
|
||||
href="/api/packages/{data.packageName}/system"
|
||||
class="ml-2 text-blue-600 hover:text-blue-800">JSON</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import {
|
||||
getRecentDownloads,
|
||||
getOverallDownloads,
|
||||
getPythonMajorDownloads,
|
||||
getPythonMinorDownloads,
|
||||
getSystemDownloads
|
||||
} from '$lib/api.js';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || '';
|
||||
|
||||
if (!packageName || packageName === '__all__') {
|
||||
return {
|
||||
packageName,
|
||||
recentStats: null,
|
||||
overallStats: [],
|
||||
pythonMajorStats: [],
|
||||
pythonMinorStats: [],
|
||||
systemStats: []
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch all statistics in parallel
|
||||
const [recentStats, overallStats, pythonMajorStats, pythonMinorStats, systemStats] = await Promise.all([
|
||||
getRecentDownloads(packageName),
|
||||
getOverallDownloads(packageName),
|
||||
getPythonMajorDownloads(packageName),
|
||||
getPythonMinorDownloads(packageName),
|
||||
getSystemDownloads(packageName)
|
||||
]);
|
||||
|
||||
// Process recent stats into the expected format
|
||||
const recentStatsFormatted: Record<string, number> = {};
|
||||
for (const stat of recentStats) {
|
||||
recentStatsFormatted[`last_${stat.category}`] = Number(stat.downloads);
|
||||
}
|
||||
|
||||
return {
|
||||
packageName,
|
||||
recentStats: recentStatsFormatted,
|
||||
overallStats,
|
||||
pythonMajorStats,
|
||||
pythonMinorStats,
|
||||
systemStats
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error loading package data:', error);
|
||||
return {
|
||||
packageName,
|
||||
recentStats: null,
|
||||
overallStats: [],
|
||||
pythonMajorStats: [],
|
||||
pythonMinorStats: [],
|
||||
systemStats: []
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
import { searchPackages } from '$lib/api.js';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ url }) => {
|
||||
export const load = async ({ url }) => {
|
||||
const searchTerm = url.searchParams.get('q');
|
||||
|
||||
if (!searchTerm) {
|
||||
@@ -26,29 +25,4 @@ export const load: PageLoad = async ({ url }) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
const formData = await request.formData();
|
||||
const searchTerm = formData.get('q');
|
||||
if (!searchTerm) {
|
||||
return {
|
||||
packages: [],
|
||||
searchTerm: null
|
||||
};
|
||||
}
|
||||
try {
|
||||
const packages = await searchPackages(searchTerm.toString());
|
||||
return {
|
||||
packages,
|
||||
searchTerm
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error searching packages:', error);
|
||||
return {
|
||||
packages: [],
|
||||
searchTerm
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData } from './$types';
|
||||
const { data } = $props<{ data: PageData }>();
|
||||
let searchTerm = $state('');
|
||||
let searchTerm = $state(data.searchTerm ?? '');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -14,7 +13,7 @@
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-8">Search Packages</h1>
|
||||
|
||||
<!-- Search Form -->
|
||||
<form method="GET" action="/search" use:enhance class="mb-8">
|
||||
<form method="GET" action="/search" class="mb-8">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
@@ -43,7 +42,7 @@
|
||||
<div class="divide-y divide-gray-200">
|
||||
{#each data.packages as pkg}
|
||||
<div class="px-6 py-4 hover:bg-gray-50">
|
||||
<a href="/packages/{pkg}" class="block">
|
||||
<a href="/packages/{pkg}" class="block" data-sveltekit-preload-data="off">
|
||||
<div class="text-lg font-medium text-blue-600 hover:text-blue-800">
|
||||
{pkg}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user