Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/impl-generate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -534,7 +534,7 @@ jobs:

# Upload PNG (with watermark)
if [ -f "$IMPL_DIR/plot.png" ]; then
gsutil cp "$IMPL_DIR/plot.png" "${STAGING_PATH}/plot.png"
gsutil -h "Cache-Control:public, max-age=86400" cp "$IMPL_DIR/plot.png" "${STAGING_PATH}/plot.png"
gsutil acl ch -u AllUsers:R "${STAGING_PATH}/plot.png" 2>/dev/null || true
echo "png_url=${PUBLIC_URL}/plot.png" >> $GITHUB_OUTPUT
echo "uploaded=true" >> $GITHUB_OUTPUT
Expand All @@ -545,15 +545,15 @@ jobs:

# Upload thumbnail
if [ -f "$IMPL_DIR/plot_thumb.png" ]; then
gsutil cp "$IMPL_DIR/plot_thumb.png" "${STAGING_PATH}/plot_thumb.png"
gsutil -h "Cache-Control:public, max-age=86400" cp "$IMPL_DIR/plot_thumb.png" "${STAGING_PATH}/plot_thumb.png"
gsutil acl ch -u AllUsers:R "${STAGING_PATH}/plot_thumb.png" 2>/dev/null || true
echo "thumb_url=${PUBLIC_URL}/plot_thumb.png" >> $GITHUB_OUTPUT
echo "::notice::Uploaded thumbnail"
fi

# Upload HTML (interactive libraries)
if [ -f "$IMPL_DIR/plot.html" ]; then
gsutil cp "$IMPL_DIR/plot.html" "${STAGING_PATH}/plot.html"
gsutil -h "Cache-Control:public, max-age=86400" cp "$IMPL_DIR/plot.html" "${STAGING_PATH}/plot.html"
gsutil acl ch -u AllUsers:R "${STAGING_PATH}/plot.html" 2>/dev/null || true
echo "html_url=${PUBLIC_URL}/plot.html" >> $GITHUB_OUTPUT
fi
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/impl-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ jobs:
PRODUCTION="gs://pyplots-images/plots/${SPEC_ID}/${LIBRARY}"

# Copy from staging to production
gsutil -m cp -r "${STAGING}/*" "${PRODUCTION}/" 2>/dev/null || echo "No staging files to promote"
gsutil -m -h "Cache-Control:public, max-age=86400" cp -r "${STAGING}/*" "${PRODUCTION}/" 2>/dev/null || echo "No staging files to promote"

# Make production files public
gsutil -m acl ch -r -u AllUsers:R "${PRODUCTION}/" 2>/dev/null || true
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/impl-repair.yml
Original file line number Diff line number Diff line change
Expand Up @@ -187,17 +187,17 @@ jobs:
gcloud auth activate-service-account --key-file=/tmp/gcs-key.json

if [ -f "$IMPL_DIR/plot.png" ]; then
gsutil cp "$IMPL_DIR/plot.png" "${STAGING_PATH}/plot.png"
gsutil -h "Cache-Control:public, max-age=86400" cp "$IMPL_DIR/plot.png" "${STAGING_PATH}/plot.png"
gsutil acl ch -u AllUsers:R "${STAGING_PATH}/plot.png" 2>/dev/null || true
fi

if [ -f "$IMPL_DIR/plot_thumb.png" ]; then
gsutil cp "$IMPL_DIR/plot_thumb.png" "${STAGING_PATH}/plot_thumb.png"
gsutil -h "Cache-Control:public, max-age=86400" cp "$IMPL_DIR/plot_thumb.png" "${STAGING_PATH}/plot_thumb.png"
gsutil acl ch -u AllUsers:R "${STAGING_PATH}/plot_thumb.png" 2>/dev/null || true
fi

if [ -f "$IMPL_DIR/plot.html" ]; then
gsutil cp "$IMPL_DIR/plot.html" "${STAGING_PATH}/plot.html"
gsutil -h "Cache-Control:public, max-age=86400" cp "$IMPL_DIR/plot.html" "${STAGING_PATH}/plot.html"
gsutil acl ch -u AllUsers:R "${STAGING_PATH}/plot.html" 2>/dev/null || true
fi

Expand Down
3 changes: 2 additions & 1 deletion api/cloudbuild.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ substitutions:
_SERVICE_NAME: pyplots-backend
_REGION: europe-west4
_MEMORY: 512Mi
_CPU: "2"
_CPU: "1"
_MIN_INSTANCES: "1"
_MAX_INSTANCES: "3"

Expand Down Expand Up @@ -55,6 +55,7 @@ steps:
- "--add-cloudsql-instances=pyplots:europe-west4:pyplots-db"
- "--set-secrets=DATABASE_URL=DATABASE_URL:latest"
- "--set-env-vars=ENVIRONMENT=production"
- "--execution-environment=gen2"
- "--set-env-vars=GOOGLE_CLOUD_PROJECT=$PROJECT_ID"
- "--set-env-vars=GCS_BUCKET=pyplots-images"
id: "deploy"
Expand Down
114 changes: 66 additions & 48 deletions api/routers/plots.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import logging
from collections.abc import Callable

from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession

Expand Down Expand Up @@ -267,6 +267,8 @@ def _build_cache_key(filter_groups: list[dict]) -> str:
"""
Build cache key from filter groups.

Groups are sorted by category so key is stable regardless of query param order.

Args:
filter_groups: List of filter group dicts

Expand All @@ -276,7 +278,8 @@ def _build_cache_key(filter_groups: list[dict]) -> str:
if not filter_groups:
return "filter:all"

cache_parts = [f"{g['category']}={','.join(sorted(g['values']))}" for g in filter_groups]
normalized = sorted(filter_groups, key=lambda g: g["category"])
cache_parts = [f"{g['category']}={','.join(sorted(g['values']))}" for g in normalized]
return f"filter:{':'.join(cache_parts)}"


Expand Down Expand Up @@ -370,7 +373,12 @@ def _filter_images(


@router.get("/plots/filter", response_model=FilteredPlotsResponse)
async def get_filtered_plots(request: Request, db: AsyncSession = Depends(require_db)):
async def get_filtered_plots(
request: Request,
db: AsyncSession = Depends(require_db),
limit: int | None = Query(None, ge=1),
offset: int = Query(0, ge=0),
):
"""
Get filtered plot images with counts for all filter categories.

Expand Down Expand Up @@ -398,55 +406,65 @@ async def get_filtered_plots(request: Request, db: AsyncSession = Depends(requir
# Parse query parameters
filter_groups = _parse_filter_groups(request)

# Check cache
# Check cache (cache stores unpaginated result; pagination applied after)
cache_key = _build_cache_key(filter_groups)
cached: FilteredPlotsResponse | None = None
try:
cached = get_cache(cache_key)
if cached:
return cached
except Exception as e:
# Cache failures are non-fatal, log and continue
logger.warning("Cache read failed for key %s: %s", cache_key, e)

# Fetch data from database
try:
repo = SpecRepository(db)
all_specs = await repo.get_all()
except SQLAlchemyError as e:
logger.error("Database query failed in get_filtered_plots: %s", e)
raise DatabaseQueryError("fetch_specs", str(e)) from e

# Build data structures
spec_lookup = _build_spec_lookup(all_specs)
impl_lookup = _build_impl_lookup(all_specs)
all_images = _collect_all_images(all_specs)
spec_id_to_tags = {spec_id: spec_data["tags"] for spec_id, spec_data in spec_lookup.items()}

# Filter images
filtered_images = _filter_images(all_images, filter_groups, spec_lookup, impl_lookup)

# Calculate counts
global_counts = _calculate_global_counts(all_specs)
counts = _calculate_contextual_counts(filtered_images, spec_id_to_tags, impl_lookup)
or_counts = _calculate_or_counts(filter_groups, all_images, spec_id_to_tags, spec_lookup, impl_lookup)

# Build spec_id -> title mapping for search/tooltips
spec_titles = {spec_id: data["spec"].title for spec_id, data in spec_lookup.items() if data["spec"].title}

# Build and cache response
result = FilteredPlotsResponse(
total=len(filtered_images),
images=filtered_images,
counts=counts,
globalCounts=global_counts,
orCounts=or_counts,
specTitles=spec_titles,
if cached is None:
# Fetch data from database
try:
repo = SpecRepository(db)
all_specs = await repo.get_all()
except SQLAlchemyError as e:
logger.error("Database query failed in get_filtered_plots: %s", e)
raise DatabaseQueryError("fetch_specs", str(e)) from e

# Build data structures
spec_lookup = _build_spec_lookup(all_specs)
impl_lookup = _build_impl_lookup(all_specs)
all_images = _collect_all_images(all_specs)
spec_id_to_tags = {spec_id: spec_data["tags"] for spec_id, spec_data in spec_lookup.items()}

# Filter images
filtered_images = _filter_images(all_images, filter_groups, spec_lookup, impl_lookup)

# Calculate counts (always from ALL filtered images, not paginated)
global_counts = _calculate_global_counts(all_specs)
counts = _calculate_contextual_counts(filtered_images, spec_id_to_tags, impl_lookup)
or_counts = _calculate_or_counts(filter_groups, all_images, spec_id_to_tags, spec_lookup, impl_lookup)

# Build spec_id -> title mapping for search/tooltips
spec_titles = {spec_id: data["spec"].title for spec_id, data in spec_lookup.items() if data["spec"].title}

# Cache the full (unpaginated) result
cached = FilteredPlotsResponse(
total=len(filtered_images),
images=filtered_images,
counts=counts,
globalCounts=global_counts,
orCounts=or_counts,
specTitles=spec_titles,
)

try:
set_cache(cache_key, cached)
except Exception as e:
logger.warning("Cache write failed for key %s: %s", cache_key, e)

# Apply pagination on top of (possibly cached) result
paginated = cached.images[offset : offset + limit] if limit else cached.images[offset:]

return FilteredPlotsResponse(
total=cached.total,
images=paginated,
counts=cached.counts,
globalCounts=cached.globalCounts,
orCounts=cached.orCounts,
specTitles=cached.specTitles,
offset=offset,
limit=limit,
)

try:
set_cache(cache_key, result)
except Exception as e:
# Cache failures are non-fatal, log and continue
logger.warning("Cache write failed for key %s: %s", cache_key, e)

return result
2 changes: 2 additions & 0 deletions api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ class FilteredPlotsResponse(BaseModel):
globalCounts: dict[str, dict[str, int]] # Same structure for global counts
orCounts: list[dict[str, int]] # Per-group OR counts
specTitles: dict[str, str] = {} # Mapping spec_id -> title for search/tooltips
offset: int = 0
limit: int | None = None


class LibraryInfo(BaseModel):
Expand Down
2 changes: 1 addition & 1 deletion app/cloudbuild.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ steps:
- "--memory"
- "256Mi"
- "--cpu"
- "2"
- "1"
- "--timeout"
- "60"
- "--min-instances"
Expand Down
2 changes: 2 additions & 0 deletions app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
<meta name="twitter:image" content="https://pyplots.ai/og-image.png" />
<!-- Preconnect to GCS for font loading -->
<link rel="preconnect" href="https://storage.googleapis.com" crossorigin>
<!-- Preconnect to API for faster first request -->
<link rel="preconnect" href="https://api.pyplots.ai" crossorigin>

<!-- Preload MonoLisa Basic Latin (most used, variable font = all weights) -->
<link rel="preload" href="https://storage.googleapis.com/pyplots-static/fonts/0-MonoLisa-normal.woff2" as="font" type="font/woff2" crossorigin>
Expand Down
5 changes: 4 additions & 1 deletion app/src/components/SpecTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import CheckIcon from '@mui/icons-material/Check';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import SyntaxHighlighter from 'react-syntax-highlighter/dist/esm/prism-light';
import { oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
import python from 'react-syntax-highlighter/dist/esm/languages/prism/python';

SyntaxHighlighter.registerLanguage('python', python);

// Map tag category names to URL parameter names
const SPEC_TAG_PARAM_MAP: Record<string, string> = {
Expand Down
23 changes: 13 additions & 10 deletions app/src/router.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,37 @@
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { HelmetProvider } from 'react-helmet-async';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import { Layout, AppDataProvider } from './components/Layout';
import { ErrorBoundary } from './components/ErrorBoundary';
import { HomePage } from './pages/HomePage';
import { SpecPage } from './pages/SpecPage';
import { CatalogPage } from './pages/CatalogPage';
import { InteractivePage } from './pages/InteractivePage';
import { DebugPage } from './pages/DebugPage';
import { LegalPage } from './pages/LegalPage';
import { McpPage } from './pages/McpPage';
import { NotFoundPage } from './pages/NotFoundPage';

const LazyFallback = () => (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
<CircularProgress size={32} />
</Box>
);

const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [
{ index: true, element: <HomePage /> },
{ path: 'catalog', element: <CatalogPage /> },
{ path: 'legal', element: <LegalPage /> },
{ path: 'mcp', element: <McpPage /> },
{ path: 'catalog', lazy: () => import('./pages/CatalogPage').then(m => ({ Component: m.CatalogPage, HydrateFallback: LazyFallback })) },
{ path: 'legal', lazy: () => import('./pages/LegalPage').then(m => ({ Component: m.LegalPage, HydrateFallback: LazyFallback })) },
{ path: 'mcp', lazy: () => import('./pages/McpPage').then(m => ({ Component: m.McpPage, HydrateFallback: LazyFallback })) },
{ path: ':specId', element: <SpecPage /> },
{ path: ':specId/:library', element: <SpecPage /> },
{ path: '*', element: <NotFoundPage /> },
],
},
// Fullscreen interactive view (outside Layout but inside AppDataProvider)
{ path: 'interactive/:specId/:library', element: <InteractivePage /> },
{ path: 'interactive/:specId/:library', lazy: () => import('./pages/InteractivePage').then(m => ({ Component: m.InteractivePage, HydrateFallback: LazyFallback })) },
// Hidden debug dashboard (outside Layout - no header/footer)
{ path: 'debug', element: <DebugPage /> },
{ path: 'debug', lazy: () => import('./pages/DebugPage').then(m => ({ Component: m.DebugPage, HydrateFallback: LazyFallback })) },
{ path: '*', element: <NotFoundPage /> },
]);

Expand Down
10 changes: 10 additions & 0 deletions app/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,14 @@ export default defineConfig({
port: 3000,
host: true,
},
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules/@mui/')) return 'mui';
if (id.includes('node_modules/react/') || id.includes('node_modules/react-dom/') || id.includes('node_modules/react-router-dom/')) return 'vendor';
},
},
},
},
});
Loading