6 Commits

Author SHA1 Message Date
Mondo Diaz
20e5a2948e Fix httpx.Timeout configuration in PyPI proxy 2026-01-29 16:39:13 -06:00
Mondo Diaz
b4e23d9899 Fix PyPI proxy tests to use ORCHARD_TEST_URL env var directly 2026-01-29 16:00:56 -06:00
Mondo Diaz
aa3bd05d46 Fix PyPI proxy tests to use unauthenticated_client fixture 2026-01-29 15:51:29 -06:00
Mondo Diaz
810e024d09 Update CHANGELOG with issue #108 2026-01-29 15:41:56 -06:00
Mondo Diaz
9e3eea4d08 Add cache/resolve endpoint and reduce footer padding
- Add POST /api/v1/cache/resolve endpoint that caches artifacts by package
  coordinates (source_type, package, version) instead of requiring a URL
- Server finds download URL from configured upstream sources (PyPI supported)
- Reduces footer padding from 24px to 12px for cleaner layout
2026-01-29 15:33:19 -06:00
Mondo Diaz
a9de32d922 Add transparent PyPI proxy and improve upstream sources UI
- Implement PEP 503 Simple API endpoints for pip compatibility:
  - GET /pypi/simple/ - package index
  - GET /pypi/simple/{package}/ - version list with rewritten links
  - GET /pypi/simple/{package}/{filename} - download with auto-caching

- Improve upstream sources table UI:
  - Center text under column headers
  - Remove separate Source column, show ENV badge inline with name
  - Make Test/Edit buttons more prominent with secondary button style
2026-01-29 15:30:57 -06:00
29 changed files with 797 additions and 2352 deletions

View File

@@ -64,11 +64,6 @@ class Settings(BaseSettings):
# Global cache settings override (None = use DB value, True/False = override DB) # Global cache settings override (None = use DB value, True/False = override DB)
cache_auto_create_system_projects: Optional[bool] = None # Override auto_create_system_projects cache_auto_create_system_projects: Optional[bool] = None # Override auto_create_system_projects
# PyPI Cache Worker settings
pypi_cache_workers: int = 5 # Number of concurrent cache workers
pypi_cache_max_depth: int = 10 # Maximum recursion depth for dependency caching
pypi_cache_max_attempts: int = 3 # Maximum retry attempts for failed cache tasks
# JWT Authentication settings (optional, for external identity providers) # JWT Authentication settings (optional, for external identity providers)
jwt_enabled: bool = False # Enable JWT token validation jwt_enabled: bool = False # Enable JWT token validation
jwt_secret: str = "" # Secret key for HS256, or leave empty for RS256 with JWKS jwt_secret: str = "" # Secret key for HS256, or leave empty for RS256 with JWKS
@@ -93,24 +88,6 @@ class Settings(BaseSettings):
def is_production(self) -> bool: def is_production(self) -> bool:
return self.env.lower() == "production" return self.env.lower() == "production"
@property
def PORT(self) -> int:
"""Alias for server_port for compatibility."""
return self.server_port
# Uppercase aliases for PyPI cache settings (for backward compatibility)
@property
def PYPI_CACHE_WORKERS(self) -> int:
return self.pypi_cache_workers
@property
def PYPI_CACHE_MAX_DEPTH(self) -> int:
return self.pypi_cache_max_depth
@property
def PYPI_CACHE_MAX_ATTEMPTS(self) -> int:
return self.pypi_cache_max_attempts
class Config: class Config:
env_prefix = "ORCHARD_" env_prefix = "ORCHARD_"
case_sensitive = False case_sensitive = False

View File

@@ -10,20 +10,11 @@ Handles:
- Conflict detection - Conflict detection
""" """
import re
import yaml import yaml
from typing import List, Dict, Any, Optional, Set, Tuple from typing import List, Dict, Any, Optional, Set, Tuple
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_ from sqlalchemy import and_
# Import packaging for PEP 440 version matching
try:
from packaging.specifiers import SpecifierSet, InvalidSpecifier
from packaging.version import Version, InvalidVersion
HAS_PACKAGING = True
except ImportError:
HAS_PACKAGING = False
from .models import ( from .models import (
Project, Project,
Package, Package,
@@ -42,7 +33,6 @@ from .schemas import (
ResolvedArtifact, ResolvedArtifact,
DependencyResolutionResponse, DependencyResolutionResponse,
DependencyConflict, DependencyConflict,
MissingDependency,
PaginationMeta, PaginationMeta,
) )
@@ -314,95 +304,6 @@ def get_reverse_dependencies(
) )
def _is_version_constraint(version_str: str) -> bool:
"""Check if a version string contains constraint operators."""
if not version_str:
return False
# Check for common constraint operators
return any(op in version_str for op in ['>=', '<=', '!=', '~=', '>', '<', '==', '*'])
def _resolve_version_constraint(
db: Session,
package: Package,
constraint: str,
) -> Optional[Tuple[str, str, int]]:
"""
Resolve a version constraint (e.g., '>=1.9') to a specific version.
Uses PEP 440 version matching to find the best matching version.
Args:
db: Database session
package: Package to search versions in
constraint: Version constraint string (e.g., '>=1.9', '<2.0,>=1.5')
Returns:
Tuple of (artifact_id, resolved_version, size) or None if not found
"""
if not HAS_PACKAGING:
# Fallback: if packaging not available, can't do constraint matching
return None
# Handle wildcard - return latest version
if constraint == '*':
# Get the latest version by created_at
latest = db.query(PackageVersion).filter(
PackageVersion.package_id == package.id,
).order_by(PackageVersion.created_at.desc()).first()
if latest:
artifact = db.query(Artifact).filter(Artifact.id == latest.artifact_id).first()
if artifact:
return (artifact.id, latest.version, artifact.size)
return None
try:
specifier = SpecifierSet(constraint)
except InvalidSpecifier:
# Invalid constraint (e.g., ">=" without version) - treat as wildcard
# This can happen with malformed metadata from PyPI packages
latest = db.query(PackageVersion).filter(
PackageVersion.package_id == package.id,
).order_by(PackageVersion.created_at.desc()).first()
if latest:
artifact = db.query(Artifact).filter(Artifact.id == latest.artifact_id).first()
if artifact:
return (artifact.id, latest.version, artifact.size)
return None
# Get all versions for this package
all_versions = db.query(PackageVersion).filter(
PackageVersion.package_id == package.id,
).all()
if not all_versions:
return None
# Find matching versions
matching = []
for pv in all_versions:
try:
v = Version(pv.version)
if v in specifier:
matching.append((pv, v))
except InvalidVersion:
# Skip invalid versions
continue
if not matching:
return None
# Sort by version (descending) and return the latest matching
matching.sort(key=lambda x: x[1], reverse=True)
best_match = matching[0][0]
artifact = db.query(Artifact).filter(Artifact.id == best_match.artifact_id).first()
if artifact:
return (artifact.id, best_match.version, artifact.size)
return None
def _resolve_dependency_to_artifact( def _resolve_dependency_to_artifact(
db: Session, db: Session,
project_name: str, project_name: str,
@@ -413,17 +314,11 @@ def _resolve_dependency_to_artifact(
""" """
Resolve a dependency constraint to an artifact ID. Resolve a dependency constraint to an artifact ID.
Supports:
- Exact version matching (e.g., '1.2.3')
- Version constraints (e.g., '>=1.9', '<2.0,>=1.5')
- Tag matching
- Wildcard ('*' for any version)
Args: Args:
db: Database session db: Database session
project_name: Project name project_name: Project name
package_name: Package name package_name: Package name
version: Version or version constraint version: Version constraint (exact)
tag: Tag constraint tag: Tag constraint
Returns: Returns:
@@ -442,23 +337,17 @@ def _resolve_dependency_to_artifact(
return None return None
if version: if version:
# Check if this is a version constraint (>=, <, etc.) or exact version # Look up by version
if _is_version_constraint(version): pkg_version = db.query(PackageVersion).filter(
result = _resolve_version_constraint(db, package, version) PackageVersion.package_id == package.id,
if result: PackageVersion.version == version,
return result ).first()
else: if pkg_version:
# Look up by exact version artifact = db.query(Artifact).filter(
pkg_version = db.query(PackageVersion).filter( Artifact.id == pkg_version.artifact_id
PackageVersion.package_id == package.id,
PackageVersion.version == version,
).first() ).first()
if pkg_version: if artifact:
artifact = db.query(Artifact).filter( return (artifact.id, version, artifact.size)
Artifact.id == pkg_version.artifact_id
).first()
if artifact:
return (artifact.id, version, artifact.size)
# Also check if there's a tag with this exact name # Also check if there's a tag with this exact name
tag_record = db.query(Tag).filter( tag_record = db.query(Tag).filter(
@@ -691,15 +580,11 @@ def resolve_dependencies(
# Track resolved artifacts and their versions # Track resolved artifacts and their versions
resolved_artifacts: Dict[str, ResolvedArtifact] = {} resolved_artifacts: Dict[str, ResolvedArtifact] = {}
# Track missing dependencies (not cached on server)
missing_dependencies: List[MissingDependency] = []
# Track version requirements for conflict detection # Track version requirements for conflict detection
version_requirements: Dict[str, List[Dict[str, Any]]] = {} # pkg_key -> [(version, required_by)] version_requirements: Dict[str, List[Dict[str, Any]]] = {} # pkg_key -> [(version, required_by)]
# Track visiting/visited for cycle detection # Track visiting/visited for cycle detection
visiting: Set[str] = set() visiting: Set[str] = set()
visited: Set[str] = set() visited: Set[str] = set()
# Track the current path for cycle reporting (artifact_id -> pkg_key)
current_path: Dict[str, str] = {}
# Resolution order (topological) # Resolution order (topological)
resolution_order: List[str] = [] resolution_order: List[str] = []
@@ -721,10 +606,8 @@ def resolve_dependencies(
# Cycle detection (at artifact level) # Cycle detection (at artifact level)
if artifact_id in visiting: if artifact_id in visiting:
# Build cycle path from current_path # Build cycle path
cycle_start = current_path.get(artifact_id, pkg_key) raise CircularDependencyError([pkg_key, pkg_key])
cycle = [cycle_start, pkg_key]
raise CircularDependencyError(cycle)
# Conflict detection - check if we've seen this package before with a different version # Conflict detection - check if we've seen this package before with a different version
if pkg_key in version_requirements: if pkg_key in version_requirements:
@@ -755,7 +638,6 @@ def resolve_dependencies(
return return
visiting.add(artifact_id) visiting.add(artifact_id)
current_path[artifact_id] = pkg_key
# Track version requirement # Track version requirement
if pkg_key not in version_requirements: if pkg_key not in version_requirements:
@@ -772,10 +654,6 @@ def resolve_dependencies(
# Resolve each dependency first (depth-first) # Resolve each dependency first (depth-first)
for dep in deps: for dep in deps:
# Skip self-dependencies (can happen with PyPI extras like pytest[testing])
if dep.dependency_project == proj_name and dep.dependency_package == pkg_name:
continue
resolved_dep = _resolve_dependency_to_artifact( resolved_dep = _resolve_dependency_to_artifact(
db, db,
dep.dependency_project, dep.dependency_project,
@@ -785,22 +663,14 @@ def resolve_dependencies(
) )
if not resolved_dep: if not resolved_dep:
# Dependency not cached on server - track as missing but continue
constraint = dep.version_constraint or dep.tag_constraint constraint = dep.version_constraint or dep.tag_constraint
missing_dependencies.append(MissingDependency( raise DependencyNotFoundError(
project=dep.dependency_project, dep.dependency_project,
package=dep.dependency_package, dep.dependency_package,
constraint=constraint, constraint,
required_by=pkg_key, )
))
continue
dep_artifact_id, dep_version, dep_size = resolved_dep dep_artifact_id, dep_version, dep_size = resolved_dep
# Skip if resolved to same artifact (self-dependency at artifact level)
if dep_artifact_id == artifact_id:
continue
_resolve_recursive( _resolve_recursive(
dep_artifact_id, dep_artifact_id,
dep.dependency_project, dep.dependency_project,
@@ -812,7 +682,6 @@ def resolve_dependencies(
) )
visiting.remove(artifact_id) visiting.remove(artifact_id)
del current_path[artifact_id]
visited.add(artifact_id) visited.add(artifact_id)
# Add to resolution order (dependencies before dependents) # Add to resolution order (dependencies before dependents)
@@ -849,7 +718,6 @@ def resolve_dependencies(
"ref": ref, "ref": ref,
}, },
resolved=resolved_list, resolved=resolved_list,
missing=missing_dependencies,
total_size=total_size, total_size=total_size,
artifact_count=len(resolved_list), artifact_count=len(resolved_list),
) )

View File

@@ -50,6 +50,7 @@ async def lifespan(app: FastAPI):
logger.info(f"Running in {settings.env} mode - skipping seed data") logger.info(f"Running in {settings.env} mode - skipping seed data")
yield yield
# Shutdown: cleanup if needed
app = FastAPI( app = FastAPI(

View File

@@ -7,9 +7,7 @@ Artifacts are cached on first access through configured upstream sources.
import hashlib import hashlib
import logging import logging
import os
import re import re
import tempfile
from typing import Optional from typing import Optional
from urllib.parse import urljoin, urlparse, quote, unquote from urllib.parse import urljoin, urlparse, quote, unquote
@@ -19,50 +17,26 @@ from fastapi.responses import StreamingResponse, HTMLResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .database import get_db from .database import get_db
from .models import UpstreamSource, CachedUrl, Artifact, Project, Package, Tag, PackageVersion from .models import UpstreamSource, CachedUrl, Artifact, Project, Package, Tag
from .storage import S3Storage, get_storage from .storage import S3Storage, get_storage
from .upstream import (
UpstreamClient,
UpstreamClientConfig,
UpstreamHTTPError,
UpstreamConnectionError,
UpstreamTimeoutError,
)
from .config import get_env_upstream_sources from .config import get_env_upstream_sources
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/pypi", tags=["pypi-proxy"]) router = APIRouter(prefix="/pypi", tags=["pypi-proxy"])
# Timeout configuration for proxy requests # Timeout configuration for proxy requests
PROXY_CONNECT_TIMEOUT = 30.0 PROXY_CONNECT_TIMEOUT = 30.0
PROXY_READ_TIMEOUT = 60.0 PROXY_READ_TIMEOUT = 60.0
def _extract_pypi_version(filename: str) -> Optional[str]:
"""Extract version from PyPI filename.
Handles formats like:
- cowsay-6.1-py3-none-any.whl
- cowsay-1.0.tar.gz
- some_package-1.2.3.post1-cp39-cp39-linux_x86_64.whl
"""
# Remove extension
if filename.endswith('.whl'):
# Wheel: name-version-pytag-abitag-platform.whl
parts = filename[:-4].split('-')
if len(parts) >= 2:
return parts[1]
elif filename.endswith('.tar.gz'):
# Source: name-version.tar.gz
base = filename[:-7]
# Find the last hyphen that precedes a version-like string
match = re.match(r'^(.+)-(\d+.*)$', base)
if match:
return match.group(2)
elif filename.endswith('.zip'):
# Egg/zip: name-version.zip
base = filename[:-4]
match = re.match(r'^(.+)-(\d+.*)$', base)
if match:
return match.group(2)
return None
def _get_pypi_upstream_sources(db: Session) -> list[UpstreamSource]: def _get_pypi_upstream_sources(db: Session) -> list[UpstreamSource]:
"""Get all enabled upstream sources configured for PyPI.""" """Get all enabled upstream sources configured for PyPI."""
# Get database sources # Get database sources
@@ -114,27 +88,7 @@ def _get_basic_auth(source) -> Optional[tuple[str, str]]:
return None return None
def _get_base_url(request: Request) -> str: def _rewrite_package_links(html: str, base_url: str, package_name: str) -> str:
"""
Get the external base URL, respecting X-Forwarded-Proto header.
When behind a reverse proxy that terminates SSL, the request.base_url
will show http:// even though the external URL is https://. This function
checks the X-Forwarded-Proto header to determine the correct scheme.
"""
base_url = str(request.base_url).rstrip('/')
# Check for X-Forwarded-Proto header (set by reverse proxies)
forwarded_proto = request.headers.get('x-forwarded-proto')
if forwarded_proto:
# Replace the scheme with the forwarded protocol
parsed = urlparse(base_url)
base_url = f"{forwarded_proto}://{parsed.netloc}{parsed.path}"
return base_url
def _rewrite_package_links(html: str, base_url: str, package_name: str, upstream_base_url: str) -> str:
""" """
Rewrite download links in a PyPI simple page to go through our proxy. Rewrite download links in a PyPI simple page to go through our proxy.
@@ -142,7 +96,6 @@ def _rewrite_package_links(html: str, base_url: str, package_name: str, upstream
html: The HTML content from upstream html: The HTML content from upstream
base_url: Our server's base URL base_url: Our server's base URL
package_name: The package name for the URL path package_name: The package name for the URL path
upstream_base_url: The upstream URL used to fetch this page (for resolving relative URLs)
Returns: Returns:
HTML with rewritten download links HTML with rewritten download links
@@ -150,31 +103,19 @@ def _rewrite_package_links(html: str, base_url: str, package_name: str, upstream
# Pattern to match href attributes in anchor tags # Pattern to match href attributes in anchor tags
# PyPI simple pages have links like: # PyPI simple pages have links like:
# <a href="https://files.pythonhosted.org/packages/.../file.tar.gz#sha256=...">file.tar.gz</a> # <a href="https://files.pythonhosted.org/packages/.../file.tar.gz#sha256=...">file.tar.gz</a>
# Or relative URLs from Artifactory like:
# <a href="../../packages/packages/62/35/.../requests-0.10.0.tar.gz#sha256=...">
def replace_href(match): def replace_href(match):
original_url = match.group(1) original_url = match.group(1)
# Resolve relative URLs to absolute using the upstream base URL
if not original_url.startswith(('http://', 'https://')):
# Split off fragment before resolving
url_without_fragment = original_url.split('#')[0]
fragment_part = original_url[len(url_without_fragment):]
absolute_url = urljoin(upstream_base_url, url_without_fragment) + fragment_part
else:
absolute_url = original_url
# Extract the filename from the URL # Extract the filename from the URL
parsed = urlparse(absolute_url) parsed = urlparse(original_url)
path_parts = parsed.path.split('/') path_parts = parsed.path.split('/')
filename = path_parts[-1] if path_parts else '' filename = path_parts[-1] if path_parts else ''
# Keep the hash fragment if present # Keep the hash fragment if present
fragment = f"#{parsed.fragment}" if parsed.fragment else "" fragment = f"#{parsed.fragment}" if parsed.fragment else ""
# Encode the absolute URL (without fragment) for safe transmission # Encode the original URL for safe transmission
encoded_url = quote(absolute_url.split('#')[0], safe='') encoded_url = quote(original_url.split('#')[0], safe='')
# Build new URL pointing to our proxy # Build new URL pointing to our proxy
new_url = f"{base_url}/pypi/simple/{package_name}/{filename}?upstream={encoded_url}{fragment}" new_url = f"{base_url}/pypi/simple/{package_name}/{filename}?upstream={encoded_url}{fragment}"
@@ -213,13 +154,12 @@ async def pypi_simple_index(
headers.update(_build_auth_headers(source)) headers.update(_build_auth_headers(source))
auth = _get_basic_auth(source) auth = _get_basic_auth(source)
# Use URL as-is - users should provide full path including /simple simple_url = source.url.rstrip('/') + '/simple/'
simple_url = source.url.rstrip('/') + '/'
timeout = httpx.Timeout(PROXY_READ_TIMEOUT, connect=PROXY_CONNECT_TIMEOUT) timeout = httpx.Timeout(PROXY_READ_TIMEOUT, connect=PROXY_CONNECT_TIMEOUT)
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client: with httpx.Client(timeout=timeout, follow_redirects=False) as client:
response = await client.get( response = client.get(
simple_url, simple_url,
headers=headers, headers=headers,
auth=auth, auth=auth,
@@ -230,7 +170,7 @@ async def pypi_simple_index(
redirect_url = response.headers.get('location') redirect_url = response.headers.get('location')
if redirect_url: if redirect_url:
# Follow the redirect once # Follow the redirect once
response = await client.get( response = client.get(
redirect_url, redirect_url,
headers=headers, headers=headers,
auth=auth, auth=auth,
@@ -243,7 +183,7 @@ async def pypi_simple_index(
content = response.text content = response.text
# Rewrite package links to go through our proxy # Rewrite package links to go through our proxy
base_url = _get_base_url(request) base_url = str(request.base_url).rstrip('/')
content = re.sub( content = re.sub(
r'href="([^"]+)/"', r'href="([^"]+)/"',
lambda m: f'href="{base_url}/pypi/simple/{m.group(1)}/"', lambda m: f'href="{base_url}/pypi/simple/{m.group(1)}/"',
@@ -289,7 +229,7 @@ async def pypi_package_versions(
detail="No PyPI upstream sources configured" detail="No PyPI upstream sources configured"
) )
base_url = _get_base_url(request) base_url = str(request.base_url).rstrip('/')
# Normalize package name (PEP 503) # Normalize package name (PEP 503)
normalized_name = re.sub(r'[-_.]+', '-', package_name).lower() normalized_name = re.sub(r'[-_.]+', '-', package_name).lower()
@@ -302,14 +242,12 @@ async def pypi_package_versions(
headers.update(_build_auth_headers(source)) headers.update(_build_auth_headers(source))
auth = _get_basic_auth(source) auth = _get_basic_auth(source)
# Use URL as-is - users should provide full path including /simple package_url = source.url.rstrip('/') + f'/simple/{normalized_name}/'
package_url = source.url.rstrip('/') + f'/{normalized_name}/'
final_url = package_url # Track final URL after redirects
timeout = httpx.Timeout(PROXY_READ_TIMEOUT, connect=PROXY_CONNECT_TIMEOUT) timeout = httpx.Timeout(PROXY_READ_TIMEOUT, connect=PROXY_CONNECT_TIMEOUT)
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client: with httpx.Client(timeout=timeout, follow_redirects=False) as client:
response = await client.get( response = client.get(
package_url, package_url,
headers=headers, headers=headers,
auth=auth, auth=auth,
@@ -324,11 +262,9 @@ async def pypi_package_versions(
# Make redirect URL absolute if needed # Make redirect URL absolute if needed
if not redirect_url.startswith('http'): if not redirect_url.startswith('http'):
redirect_url = urljoin(final_url, redirect_url) redirect_url = urljoin(package_url, redirect_url)
final_url = redirect_url # Update final URL response = client.get(
response = await client.get(
redirect_url, redirect_url,
headers=headers, headers=headers,
auth=auth, auth=auth,
@@ -340,8 +276,7 @@ async def pypi_package_versions(
content = response.text content = response.text
# Rewrite download links to go through our proxy # Rewrite download links to go through our proxy
# Pass final_url so relative URLs can be resolved correctly content = _rewrite_package_links(content, base_url, normalized_name)
content = _rewrite_package_links(content, base_url, normalized_name, final_url)
return HTMLResponse(content=content) return HTMLResponse(content=content)
@@ -406,22 +341,14 @@ async def pypi_download_file(
# Stream from S3 # Stream from S3
try: try:
stream, content_length, _ = storage.get_stream(artifact.s3_key) content_stream = storage.get_artifact_stream(artifact.id)
def stream_content():
"""Generator that yields chunks from the S3 stream."""
try:
for chunk in stream.iter_chunks():
yield chunk
finally:
stream.close()
return StreamingResponse( return StreamingResponse(
stream_content(), content_stream,
media_type=artifact.content_type or "application/octet-stream", media_type=artifact.content_type or "application/octet-stream",
headers={ headers={
"Content-Disposition": f'attachment; filename="{filename}"', "Content-Disposition": f'attachment; filename="{filename}"',
"Content-Length": str(content_length), "Content-Length": str(artifact.size),
"X-Checksum-SHA256": artifact.id, "X-Checksum-SHA256": artifact.id,
"X-Cache": "HIT", "X-Cache": "HIT",
} }
@@ -433,10 +360,18 @@ async def pypi_download_file(
# Not cached - fetch from upstream # Not cached - fetch from upstream
sources = _get_pypi_upstream_sources(db) sources = _get_pypi_upstream_sources(db)
# Use the first available source for authentication headers # Find a source that matches the upstream URL
# Note: The upstream URL may point to files.pythonhosted.org or other CDNs, matched_source = None
# not the configured source URL directly, so we can't strictly validate the host for source in sources:
matched_source = sources[0] if sources else None source_url = getattr(source, 'url', '')
# Check if the upstream URL could come from this source
# (This is a loose check - the URL might be from files.pythonhosted.org)
if urlparse(upstream_url).netloc in source_url or True: # Allow any source for now
matched_source = source
break
if not matched_source and sources:
matched_source = sources[0] # Use first source for auth if available
try: try:
headers = {"User-Agent": "Orchard-PyPI-Proxy/1.0"} headers = {"User-Agent": "Orchard-PyPI-Proxy/1.0"}
@@ -449,8 +384,8 @@ async def pypi_download_file(
# Fetch the file # Fetch the file
logger.info(f"PyPI proxy: fetching {filename} from {upstream_url}") logger.info(f"PyPI proxy: fetching {filename} from {upstream_url}")
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client: with httpx.Client(timeout=timeout, follow_redirects=False) as client:
response = await client.get( response = client.get(
upstream_url, upstream_url,
headers=headers, headers=headers,
auth=auth, auth=auth,
@@ -475,7 +410,7 @@ async def pypi_download_file(
redirect_headers.update(headers) redirect_headers.update(headers)
redirect_auth = auth redirect_auth = auth
response = await client.get( response = client.get(
redirect_url, redirect_url,
headers=redirect_headers, headers=redirect_headers,
auth=redirect_auth, auth=redirect_auth,
@@ -489,33 +424,22 @@ async def pypi_download_file(
detail=f"Upstream returned {response.status_code}" detail=f"Upstream returned {response.status_code}"
) )
content = response.content
content_type = response.headers.get('content-type', 'application/octet-stream') content_type = response.headers.get('content-type', 'application/octet-stream')
# Stream to temp file to avoid loading large packages into memory # Compute hash
# This keeps memory usage constant regardless of package size sha256 = hashlib.sha256(content).hexdigest()
# Using async iteration to avoid blocking the event loop size = len(content)
tmp_path = None
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=f"_{filename}") as tmp_file:
tmp_path = tmp_file.name
async for chunk in response.aiter_bytes(chunk_size=65536): # 64KB chunks
tmp_file.write(chunk)
# Store in S3 from temp file (computes hash and deduplicates automatically) logger.info(f"PyPI proxy: downloaded {filename}, {size} bytes, sha256={sha256[:12]}")
with open(tmp_path, 'rb') as f:
result = storage.store(f)
sha256 = result.sha256
size = result.size
# Read content for response # Store in S3
with open(tmp_path, 'rb') as f: from io import BytesIO
content = f.read() artifact = storage.store_artifact(
file_obj=BytesIO(content),
logger.info(f"PyPI proxy: downloaded {filename}, {size} bytes, sha256={sha256[:12]}") filename=filename,
finally: content_type=content_type,
# Clean up temp file )
if tmp_path and os.path.exists(tmp_path):
os.unlink(tmp_path)
# Check if artifact already exists # Check if artifact already exists
existing = db.query(Artifact).filter(Artifact.id == sha256).first() existing = db.query(Artifact).filter(Artifact.id == sha256).first()
@@ -527,15 +451,10 @@ async def pypi_download_file(
# Create artifact record # Create artifact record
new_artifact = Artifact( new_artifact = Artifact(
id=sha256, id=sha256,
original_name=filename, filename=filename,
content_type=content_type, content_type=content_type,
size=size, size=size,
ref_count=1, ref_count=1,
created_by="pypi-proxy",
s3_key=result.s3_key,
checksum_md5=result.md5,
checksum_sha1=result.sha1,
s3_etag=result.s3_etag,
) )
db.add(new_artifact) db.add(new_artifact)
db.flush() db.flush()
@@ -546,16 +465,10 @@ async def pypi_download_file(
system_project = Project( system_project = Project(
name="_pypi", name="_pypi",
description="System project for cached PyPI packages", description="System project for cached PyPI packages",
is_public=True, visibility="private",
is_system=True,
created_by="pypi-proxy",
) )
db.add(system_project) db.add(system_project)
db.flush() db.flush()
elif not system_project.is_system:
# Ensure existing project is marked as system
system_project.is_system = True
db.flush()
# Normalize package name # Normalize package name
normalized_name = re.sub(r'[-_.]+', '-', package_name).lower() normalized_name = re.sub(r'[-_.]+', '-', package_name).lower()
@@ -569,7 +482,6 @@ async def pypi_download_file(
project_id=system_project.id, project_id=system_project.id,
name=normalized_name, name=normalized_name,
description=f"PyPI package: {normalized_name}", description=f"PyPI package: {normalized_name}",
format="pypi",
) )
db.add(package) db.add(package)
db.flush() db.flush()
@@ -584,29 +496,9 @@ async def pypi_download_file(
package_id=package.id, package_id=package.id,
name=filename, name=filename,
artifact_id=sha256, artifact_id=sha256,
created_by="pypi-proxy",
) )
db.add(tag) db.add(tag)
# Extract and create version
# Only create version for actual package files, not .metadata files
version = _extract_pypi_version(filename)
if version and not filename.endswith('.metadata'):
# Check by version string (the unique constraint is on package_id + version)
existing_version = db.query(PackageVersion).filter(
PackageVersion.package_id == package.id,
PackageVersion.version == version,
).first()
if not existing_version:
pkg_version = PackageVersion(
package_id=package.id,
artifact_id=sha256,
version=version,
version_source="filename",
created_by="pypi-proxy",
)
db.add(pkg_version)
# Cache the URL mapping # Cache the URL mapping
existing_cached = db.query(CachedUrl).filter(CachedUrl.url_hash == url_hash).first() existing_cached = db.query(CachedUrl).filter(CachedUrl.url_hash == url_hash).first()
if not existing_cached: if not existing_cached:

View File

@@ -1680,7 +1680,6 @@ def create_project(
name=db_project.name, name=db_project.name,
description=db_project.description, description=db_project.description,
is_public=db_project.is_public, is_public=db_project.is_public,
is_system=db_project.is_system,
created_at=db_project.created_at, created_at=db_project.created_at,
updated_at=db_project.updated_at, updated_at=db_project.updated_at,
created_by=db_project.created_by, created_by=db_project.created_by,
@@ -1705,7 +1704,6 @@ def get_project(
name=project.name, name=project.name,
description=project.description, description=project.description,
is_public=project.is_public, is_public=project.is_public,
is_system=project.is_system,
created_at=project.created_at, created_at=project.created_at,
updated_at=project.updated_at, updated_at=project.updated_at,
created_by=project.created_by, created_by=project.created_by,
@@ -2706,7 +2704,6 @@ def list_team_projects(
name=p.name, name=p.name,
description=p.description, description=p.description,
is_public=p.is_public, is_public=p.is_public,
is_system=p.is_system,
created_at=p.created_at, created_at=p.created_at,
updated_at=p.updated_at, updated_at=p.updated_at,
created_by=p.created_by, created_by=p.created_by,
@@ -2830,15 +2827,14 @@ def list_packages(
db.query(func.count(Tag.id)).filter(Tag.package_id == pkg.id).scalar() or 0 db.query(func.count(Tag.id)).filter(Tag.package_id == pkg.id).scalar() or 0
) )
# Get unique artifact count and total size via tags # Get unique artifact count and total size via uploads
# (PyPI proxy creates tags without uploads, so query from tags)
artifact_stats = ( artifact_stats = (
db.query( db.query(
func.count(func.distinct(Tag.artifact_id)), func.count(func.distinct(Upload.artifact_id)),
func.coalesce(func.sum(Artifact.size), 0), func.coalesce(func.sum(Artifact.size), 0),
) )
.join(Artifact, Tag.artifact_id == Artifact.id) .join(Artifact, Upload.artifact_id == Artifact.id)
.filter(Tag.package_id == pkg.id) .filter(Upload.package_id == pkg.id)
.first() .first()
) )
artifact_count = artifact_stats[0] if artifact_stats else 0 artifact_count = artifact_stats[0] if artifact_stats else 0
@@ -2934,15 +2930,14 @@ def get_package(
db.query(func.count(Tag.id)).filter(Tag.package_id == pkg.id).scalar() or 0 db.query(func.count(Tag.id)).filter(Tag.package_id == pkg.id).scalar() or 0
) )
# Get unique artifact count and total size via tags # Get unique artifact count and total size via uploads
# (PyPI proxy creates tags without uploads, so query from tags)
artifact_stats = ( artifact_stats = (
db.query( db.query(
func.count(func.distinct(Tag.artifact_id)), func.count(func.distinct(Upload.artifact_id)),
func.coalesce(func.sum(Artifact.size), 0), func.coalesce(func.sum(Artifact.size), 0),
) )
.join(Artifact, Tag.artifact_id == Artifact.id) .join(Artifact, Upload.artifact_id == Artifact.id)
.filter(Tag.package_id == pkg.id) .filter(Upload.package_id == pkg.id)
.first() .first()
) )
artifact_count = artifact_stats[0] if artifact_stats else 0 artifact_count = artifact_stats[0] if artifact_stats else 0
@@ -6285,14 +6280,14 @@ def get_package_stats(
db.query(func.count(Tag.id)).filter(Tag.package_id == package.id).scalar() or 0 db.query(func.count(Tag.id)).filter(Tag.package_id == package.id).scalar() or 0
) )
# Artifact stats via tags (tags exist for both user uploads and PyPI proxy) # Artifact stats via uploads
artifact_stats = ( artifact_stats = (
db.query( db.query(
func.count(func.distinct(Tag.artifact_id)), func.count(func.distinct(Upload.artifact_id)),
func.coalesce(func.sum(Artifact.size), 0), func.coalesce(func.sum(Artifact.size), 0),
) )
.join(Artifact, Tag.artifact_id == Artifact.id) .join(Artifact, Upload.artifact_id == Artifact.id)
.filter(Tag.package_id == package.id) .filter(Upload.package_id == package.id)
.first() .first()
) )
artifact_count = artifact_stats[0] if artifact_stats else 0 artifact_count = artifact_stats[0] if artifact_stats else 0

View File

@@ -33,7 +33,6 @@ class ProjectResponse(BaseModel):
name: str name: str
description: Optional[str] description: Optional[str]
is_public: bool is_public: bool
is_system: bool = False
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
created_by: str created_by: str
@@ -1033,19 +1032,10 @@ class ResolvedArtifact(BaseModel):
download_url: str download_url: str
class MissingDependency(BaseModel):
"""A dependency that could not be resolved (not cached on server)"""
project: str
package: str
constraint: Optional[str] = None
required_by: Optional[str] = None
class DependencyResolutionResponse(BaseModel): class DependencyResolutionResponse(BaseModel):
"""Response from dependency resolution endpoint""" """Response from dependency resolution endpoint"""
requested: Dict[str, str] # project, package, ref requested: Dict[str, str] # project, package, ref
resolved: List[ResolvedArtifact] resolved: List[ResolvedArtifact]
missing: List[MissingDependency] = []
total_size: int total_size: int
artifact_count: int artifact_count: int

View File

@@ -1 +0,0 @@
# Scripts package

View File

@@ -1,262 +0,0 @@
#!/usr/bin/env python3
"""
Backfill script to extract dependencies from cached PyPI packages.
This script scans all artifacts in the _pypi project and extracts
Requires-Dist metadata from wheel and sdist files that don't already
have dependencies recorded.
Usage:
# From within the container:
python -m scripts.backfill_pypi_dependencies
# Or with docker exec:
docker exec orchard_orchard-server_1 python -m scripts.backfill_pypi_dependencies
# Dry run (preview only):
docker exec orchard_orchard-server_1 python -m scripts.backfill_pypi_dependencies --dry-run
"""
import argparse
import logging
import re
import sys
import tarfile
import zipfile
from io import BytesIO
from typing import List, Optional, Tuple
# Add parent directory to path for imports
sys.path.insert(0, "/app")
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from backend.app.config import get_settings
from backend.app.models import (
Artifact,
ArtifactDependency,
Package,
Project,
Tag,
)
from backend.app.storage import get_storage
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
def parse_requires_dist(requires_dist: str) -> Tuple[Optional[str], Optional[str]]:
"""Parse a Requires-Dist line into (package_name, version_constraint)."""
# Remove any environment markers (after semicolon)
if ";" in requires_dist:
requires_dist = requires_dist.split(";")[0].strip()
# Match patterns like "package (>=1.0)" or "package>=1.0" or "package"
match = re.match(
r"^([a-zA-Z0-9][-a-zA-Z0-9._]*)\s*(?:\(([^)]+)\)|([<>=!~][^\s;]+))?",
requires_dist.strip(),
)
if not match:
return None, None
package_name = match.group(1)
version_constraint = match.group(2) or match.group(3)
# Normalize package name (PEP 503)
normalized_name = re.sub(r"[-_.]+", "-", package_name).lower()
if version_constraint:
version_constraint = version_constraint.strip()
return normalized_name, version_constraint
def extract_requires_from_metadata(metadata_content: str) -> List[Tuple[str, Optional[str]]]:
"""Extract all Requires-Dist entries from METADATA/PKG-INFO content."""
dependencies = []
for line in metadata_content.split("\n"):
if line.startswith("Requires-Dist:"):
value = line[len("Requires-Dist:"):].strip()
pkg_name, version = parse_requires_dist(value)
if pkg_name:
dependencies.append((pkg_name, version))
return dependencies
def extract_metadata_from_wheel(content: bytes) -> Optional[str]:
"""Extract METADATA file content from a wheel (zip) file."""
try:
with zipfile.ZipFile(BytesIO(content)) as zf:
for name in zf.namelist():
if name.endswith(".dist-info/METADATA"):
return zf.read(name).decode("utf-8", errors="replace")
except Exception as e:
logger.warning(f"Failed to extract metadata from wheel: {e}")
return None
def extract_metadata_from_sdist(content: bytes) -> Optional[str]:
"""Extract PKG-INFO file content from a source distribution (.tar.gz)."""
try:
with tarfile.open(fileobj=BytesIO(content), mode="r:gz") as tf:
for member in tf.getmembers():
if member.name.endswith("/PKG-INFO") and member.name.count("/") == 1:
f = tf.extractfile(member)
if f:
return f.read().decode("utf-8", errors="replace")
except Exception as e:
logger.warning(f"Failed to extract metadata from sdist: {e}")
return None
def extract_dependencies(content: bytes, filename: str) -> List[Tuple[str, Optional[str]]]:
"""Extract dependencies from a PyPI package file."""
metadata = None
if filename.endswith(".whl"):
metadata = extract_metadata_from_wheel(content)
elif filename.endswith(".tar.gz"):
metadata = extract_metadata_from_sdist(content)
if metadata:
return extract_requires_from_metadata(metadata)
return []
def backfill_dependencies(dry_run: bool = False):
"""Main backfill function."""
settings = get_settings()
# Create database connection
engine = create_engine(settings.database_url)
Session = sessionmaker(bind=engine)
db = Session()
# Create storage client
storage = get_storage()
try:
# Find the _pypi project
pypi_project = db.query(Project).filter(Project.name == "_pypi").first()
if not pypi_project:
logger.info("No _pypi project found. Nothing to backfill.")
return
# Get all packages in _pypi
packages = db.query(Package).filter(Package.project_id == pypi_project.id).all()
logger.info(f"Found {len(packages)} packages in _pypi project")
total_artifacts = 0
artifacts_with_deps = 0
artifacts_processed = 0
dependencies_added = 0
for package in packages:
# Get all tags (each tag points to an artifact)
tags = db.query(Tag).filter(Tag.package_id == package.id).all()
for tag in tags:
total_artifacts += 1
filename = tag.name
# Skip non-package files (like .metadata files)
if not (filename.endswith(".whl") or filename.endswith(".tar.gz")):
continue
# Check if this artifact already has dependencies
existing_deps = db.query(ArtifactDependency).filter(
ArtifactDependency.artifact_id == tag.artifact_id
).count()
if existing_deps > 0:
artifacts_with_deps += 1
continue
# Get the artifact
artifact = db.query(Artifact).filter(Artifact.id == tag.artifact_id).first()
if not artifact:
logger.warning(f"Artifact {tag.artifact_id} not found for tag {filename}")
continue
logger.info(f"Processing {package.name}/{filename}...")
if dry_run:
logger.info(f" [DRY RUN] Would extract dependencies from {filename}")
artifacts_processed += 1
continue
# Download the artifact from S3
try:
content = storage.get(artifact.s3_key)
except Exception as e:
logger.error(f" Failed to download {filename}: {e}")
continue
# Extract dependencies
deps = extract_dependencies(content, filename)
if deps:
logger.info(f" Found {len(deps)} dependencies")
for dep_name, dep_version in deps:
# Check if already exists (race condition protection)
existing = db.query(ArtifactDependency).filter(
ArtifactDependency.artifact_id == tag.artifact_id,
ArtifactDependency.dependency_project == "_pypi",
ArtifactDependency.dependency_package == dep_name,
).first()
if not existing:
dep = ArtifactDependency(
artifact_id=tag.artifact_id,
dependency_project="_pypi",
dependency_package=dep_name,
version_constraint=dep_version if dep_version else "*",
)
db.add(dep)
dependencies_added += 1
logger.info(f" + {dep_name} {dep_version or '*'}")
db.commit()
else:
logger.info(f" No dependencies found")
artifacts_processed += 1
logger.info("")
logger.info("=" * 50)
logger.info("Backfill complete!")
logger.info(f" Total artifacts: {total_artifacts}")
logger.info(f" Already had deps: {artifacts_with_deps}")
logger.info(f" Processed: {artifacts_processed}")
logger.info(f" Dependencies added: {dependencies_added}")
if dry_run:
logger.info(" (DRY RUN - no changes made)")
finally:
db.close()
def main():
parser = argparse.ArgumentParser(
description="Backfill dependencies for cached PyPI packages"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview what would be done without making changes",
)
args = parser.parse_args()
backfill_dependencies(dry_run=args.dry_run)
if __name__ == "__main__":
main()

View File

@@ -128,9 +128,7 @@ class TestProjectListingFilters:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
# Filter out system projects (names starting with "_") as they may have names = [p["name"] for p in data["items"]]
# collation-specific sort behavior and aren't part of the test data
names = [p["name"] for p in data["items"] if not p["name"].startswith("_")]
assert names == sorted(names) assert names == sorted(names)

View File

@@ -17,31 +17,21 @@ class TestPyPIProxyEndpoints:
""" """
@pytest.mark.integration @pytest.mark.integration
def test_pypi_simple_index(self): def test_pypi_simple_index_no_sources(self):
"""Test that /pypi/simple/ returns HTML response.""" """Test that /pypi/simple/ returns 503 when no sources configured."""
with httpx.Client(base_url=get_base_url(), timeout=30.0) as client: with httpx.Client(base_url=get_base_url(), timeout=30.0) as client:
response = client.get("/pypi/simple/") response = client.get("/pypi/simple/")
# Returns 200 if sources configured, 503 if not # Should return 503 when no PyPI upstream sources are configured
assert response.status_code in (200, 503) assert response.status_code == 503
if response.status_code == 200: assert "No PyPI upstream sources configured" in response.json()["detail"]
assert "text/html" in response.headers.get("content-type", "")
else:
assert "No PyPI upstream sources configured" in response.json()["detail"]
@pytest.mark.integration @pytest.mark.integration
def test_pypi_package_endpoint(self): def test_pypi_package_no_sources(self):
"""Test that /pypi/simple/{package}/ returns appropriate response.""" """Test that /pypi/simple/{package}/ returns 503 when no sources configured."""
with httpx.Client(base_url=get_base_url(), timeout=30.0) as client: with httpx.Client(base_url=get_base_url(), timeout=30.0) as client:
response = client.get("/pypi/simple/requests/") response = client.get("/pypi/simple/requests/")
# Returns 200 if sources configured and package found, assert response.status_code == 503
# 404 if package not found, 503 if no sources assert "No PyPI upstream sources configured" in response.json()["detail"]
assert response.status_code in (200, 404, 503)
if response.status_code == 200:
assert "text/html" in response.headers.get("content-type", "")
elif response.status_code == 404:
assert "not found" in response.json()["detail"].lower()
else: # 503
assert "No PyPI upstream sources configured" in response.json()["detail"]
@pytest.mark.integration @pytest.mark.integration
def test_pypi_download_missing_upstream_param(self): def test_pypi_download_missing_upstream_param(self):
@@ -68,13 +58,7 @@ class TestPyPILinkRewriting:
</html> </html>
''' '''
# upstream_base_url is used to resolve relative URLs (not needed here since URLs are absolute) result = _rewrite_package_links(html, "http://localhost:8080", "requests")
result = _rewrite_package_links(
html,
"http://localhost:8080",
"requests",
"https://pypi.org/simple/requests/"
)
# Links should be rewritten to go through our proxy # Links should be rewritten to go through our proxy
assert "/pypi/simple/requests/requests-2.31.0.tar.gz?upstream=" in result assert "/pypi/simple/requests/requests-2.31.0.tar.gz?upstream=" in result
@@ -85,53 +69,25 @@ class TestPyPILinkRewriting:
assert "#sha256=abc123" in result assert "#sha256=abc123" in result
assert "#sha256=def456" in result assert "#sha256=def456" in result
def test_rewrite_relative_links(self):
"""Test that relative URLs are resolved to absolute URLs."""
from app.pypi_proxy import _rewrite_package_links
# Artifactory-style relative URLs
html = '''
<html>
<body>
<a href="../../packages/ab/cd/requests-2.31.0.tar.gz#sha256=abc123">requests-2.31.0.tar.gz</a>
</body>
</html>
'''
result = _rewrite_package_links(
html,
"https://orchard.example.com",
"requests",
"https://artifactory.example.com/api/pypi/pypi-remote/simple/requests/"
)
# The relative URL should be resolved to absolute
# ../../packages/ab/cd/... from /api/pypi/pypi-remote/simple/requests/ resolves to /api/pypi/pypi-remote/packages/ab/cd/...
assert "upstream=https%3A%2F%2Fartifactory.example.com%2Fapi%2Fpypi%2Fpypi-remote%2Fpackages" in result
# Hash fragment should be preserved
assert "#sha256=abc123" in result
class TestPyPIPackageNormalization: class TestPyPIPackageNormalization:
"""Tests for PyPI package name normalization.""" """Tests for PyPI package name normalization."""
@pytest.mark.integration @pytest.mark.integration
def test_package_name_normalized(self): def test_package_name_normalized(self):
"""Test that package names are normalized per PEP 503. """Test that package names are normalized per PEP 503."""
# These should all be treated the same:
# requests, Requests, requests_, requests-
# The endpoint normalizes to lowercase with hyphens
Different capitalizations/separators should all be valid paths.
The endpoint normalizes to lowercase with hyphens before lookup.
"""
with httpx.Client(base_url=get_base_url(), timeout=30.0) as client: with httpx.Client(base_url=get_base_url(), timeout=30.0) as client:
# Test various name formats - all should be valid endpoint paths # Without upstream sources, we get 503, but the normalization
for package_name in ["Requests", "some_package", "some-package"]: # happens before the source lookup
response = client.get(f"/pypi/simple/{package_name}/") response = client.get("/pypi/simple/Requests/")
# 200 = found, 404 = not found, 503 = no sources configured assert response.status_code == 503 # No sources, but path was valid
assert response.status_code in (200, 404, 503), \
f"Unexpected status {response.status_code} for {package_name}"
# Verify response is appropriate for the status code response = client.get("/pypi/simple/some_package/")
if response.status_code == 200: assert response.status_code == 503
assert "text/html" in response.headers.get("content-type", "")
elif response.status_code == 503: response = client.get("/pypi/simple/some-package/")
assert "No PyPI upstream sources configured" in response.json()["detail"] assert response.status_code == 503

View File

@@ -8,12 +8,9 @@
"name": "orchard-frontend", "name": "orchard-frontend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@types/dagre": "^0.7.53",
"dagre": "^0.8.5",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "6.28.0", "react-router-dom": "6.28.0"
"reactflow": "^11.11.4"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.4.2", "@testing-library/jest-dom": "^6.4.2",
@@ -946,102 +943,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@reactflow/background": {
"version": "11.3.14",
"resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz",
"integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/controls": {
"version": "11.2.14",
"resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz",
"integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/core": {
"version": "11.11.4",
"resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz",
"integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==",
"dependencies": {
"@types/d3": "^7.4.0",
"@types/d3-drag": "^3.0.1",
"@types/d3-selection": "^3.0.3",
"@types/d3-zoom": "^3.0.1",
"classcat": "^5.0.3",
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/minimap": {
"version": "11.7.14",
"resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz",
"integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==",
"dependencies": {
"@reactflow/core": "11.11.4",
"@types/d3-selection": "^3.0.3",
"@types/d3-zoom": "^3.0.1",
"classcat": "^5.0.3",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/node-resizer": {
"version": "2.2.14",
"resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz",
"integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.4",
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/node-toolbar": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz",
"integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@remix-run/router": { "node_modules/@remix-run/router": {
"version": "1.21.0", "version": "1.21.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.21.0.tgz", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.21.0.tgz",
@@ -1536,233 +1437,6 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
"dependencies": {
"@types/d3-array": "*",
"@types/d3-axis": "*",
"@types/d3-brush": "*",
"@types/d3-chord": "*",
"@types/d3-color": "*",
"@types/d3-contour": "*",
"@types/d3-delaunay": "*",
"@types/d3-dispatch": "*",
"@types/d3-drag": "*",
"@types/d3-dsv": "*",
"@types/d3-ease": "*",
"@types/d3-fetch": "*",
"@types/d3-force": "*",
"@types/d3-format": "*",
"@types/d3-geo": "*",
"@types/d3-hierarchy": "*",
"@types/d3-interpolate": "*",
"@types/d3-path": "*",
"@types/d3-polygon": "*",
"@types/d3-quadtree": "*",
"@types/d3-random": "*",
"@types/d3-scale": "*",
"@types/d3-scale-chromatic": "*",
"@types/d3-selection": "*",
"@types/d3-shape": "*",
"@types/d3-time": "*",
"@types/d3-time-format": "*",
"@types/d3-timer": "*",
"@types/d3-transition": "*",
"@types/d3-zoom": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="
},
"node_modules/@types/d3-axis": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-brush": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-chord": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
},
"node_modules/@types/d3-contour": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
"dependencies": {
"@types/d3-array": "*",
"@types/geojson": "*"
}
},
"node_modules/@types/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="
},
"node_modules/@types/d3-dispatch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
"integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-dsv": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
},
"node_modules/@types/d3-fetch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
"dependencies": {
"@types/d3-dsv": "*"
}
},
"node_modules/@types/d3-force": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="
},
"node_modules/@types/d3-format": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="
},
"node_modules/@types/d3-geo": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/d3-hierarchy": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
},
"node_modules/@types/d3-polygon": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="
},
"node_modules/@types/d3-quadtree": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="
},
"node_modules/@types/d3-random": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
},
"node_modules/@types/d3-time-format": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/dagre": {
"version": "0.7.53",
"resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.53.tgz",
"integrity": "sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ=="
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1770,23 +1444,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="
},
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.3.27", "version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
@@ -2294,11 +1963,6 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2399,114 +2063,9 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/dagre": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
"integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
"dependencies": {
"graphlib": "^2.1.8",
"lodash": "^4.17.15"
}
},
"node_modules/data-urls": { "node_modules/data-urls": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
@@ -3033,14 +2592,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/graphlib": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
"integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
"dependencies": {
"lodash": "^4.17.15"
}
},
"node_modules/has-bigints": { "node_modules/has-bigints": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -3646,11 +3197,6 @@
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
} }
}, },
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -4240,23 +3786,6 @@
"react-dom": ">=16.8" "react-dom": ">=16.8"
} }
}, },
"node_modules/reactflow": {
"version": "11.11.4",
"resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz",
"integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==",
"dependencies": {
"@reactflow/background": "11.3.14",
"@reactflow/controls": "11.2.14",
"@reactflow/core": "11.11.4",
"@reactflow/minimap": "11.7.14",
"@reactflow/node-resizer": "2.2.14",
"@reactflow/node-toolbar": "1.3.14"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/redent": { "node_modules/redent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -4815,14 +4344,6 @@
"requires-port": "^1.0.0" "requires-port": "^1.0.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@@ -5191,33 +4712,6 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
} }
} }
} }

View File

@@ -12,12 +12,9 @@
"test:coverage": "vitest run --coverage" "test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@types/dagre": "^0.7.53",
"dagre": "^0.8.5",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "6.28.0", "react-router-dom": "6.28.0"
"reactflow": "^11.11.4"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.4.2", "@testing-library/jest-dom": "^6.4.2",

View File

@@ -78,13 +78,7 @@ export class ForbiddenError extends ApiError {
async function handleResponse<T>(response: Response): Promise<T> { async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' })); const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
// Handle detail as string or object (backend may return structured errors) const message = error.detail || `HTTP ${response.status}`;
let message: string;
if (typeof error.detail === 'object') {
message = JSON.stringify(error.detail);
} else {
message = error.detail || `HTTP ${response.status}`;
}
if (response.status === 401) { if (response.status === 401) {
throw new UnauthorizedError(message); throw new UnauthorizedError(message);
@@ -752,4 +746,3 @@ export async function testUpstreamSource(id: string): Promise<UpstreamSourceTest
}); });
return handleResponse<UpstreamSourceTestResult>(response); return handleResponse<UpstreamSourceTestResult>(response);
} }

View File

@@ -55,10 +55,6 @@
font-size: 0.8125rem; font-size: 0.8125rem;
} }
.missing-count {
color: #f59e0b;
}
.close-btn { .close-btn {
background: transparent; background: transparent;
border: none; border: none;
@@ -76,115 +72,171 @@
color: var(--text-primary); color: var(--text-primary);
} }
.dependency-graph-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
border-bottom: 1px solid var(--border-primary);
background: var(--bg-secondary);
}
.zoom-level {
margin-left: auto;
font-size: 0.8125rem;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
.dependency-graph-container { .dependency-graph-container {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
background: var(--bg-primary); background:
linear-gradient(90deg, var(--border-primary) 1px, transparent 1px),
linear-gradient(var(--border-primary) 1px, transparent 1px);
background-size: 20px 20px;
background-position: center center;
} }
/* React Flow Customization */ .graph-canvas {
.react-flow__background { padding: 40px;
background-color: var(--bg-primary) !important; min-width: 100%;
min-height: 100%;
transform-origin: center center;
transition: transform 0.1s ease-out;
} }
.react-flow__controls { /* Graph Nodes */
background: var(--bg-tertiary); .graph-node-container {
border: 1px solid var(--border-primary); display: flex;
border-radius: var(--radius-md); flex-direction: column;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); align-items: flex-start;
} }
.react-flow__controls-button { .graph-node {
background: var(--bg-tertiary);
border: none;
border-bottom: 1px solid var(--border-primary);
color: var(--text-secondary);
width: 28px;
height: 28px;
}
.react-flow__controls-button:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.react-flow__controls-button:last-child {
border-bottom: none;
}
.react-flow__controls-button svg {
fill: currentColor;
}
.react-flow__attribution {
background: transparent !important;
}
.react-flow__attribution a {
color: var(--text-muted) !important;
font-size: 10px;
}
/* Custom Flow Nodes */
.flow-node {
background: var(--bg-tertiary); background: var(--bg-tertiary);
border: 2px solid var(--border-primary); border: 2px solid var(--border-primary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: 12px 16px; padding: 12px 16px;
min-width: 160px; min-width: 200px;
cursor: pointer; cursor: pointer;
transition: all var(--transition-fast); transition: all var(--transition-fast);
text-align: center; position: relative;
} }
.flow-node:hover { .graph-node:hover {
border-color: var(--accent-primary); border-color: var(--accent-primary);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2); box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2);
} }
.flow-node--root { .graph-node--root {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(5, 150, 105, 0.15) 100%); background: linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(5, 150, 105, 0.15) 100%);
border-color: var(--accent-primary); border-color: var(--accent-primary);
} }
.flow-node__name { .graph-node--hovered {
transform: scale(1.02);
}
.graph-node__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.graph-node__name {
font-weight: 600; font-weight: 600;
color: var(--accent-primary); color: var(--accent-primary);
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-size: 0.8125rem; font-size: 0.875rem;
margin-bottom: 4px;
word-break: break-word;
} }
.flow-node__details { .graph-node__toggle {
background: var(--bg-hover);
border: 1px solid var(--border-primary);
border-radius: 4px;
width: 20px;
height: 20px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; cursor: pointer;
font-size: 0.6875rem; font-size: 0.875rem;
color: var(--text-secondary);
font-weight: 600;
margin-left: auto;
}
.graph-node__toggle:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.graph-node__details {
display: flex;
align-items: center;
gap: 12px;
font-size: 0.75rem;
color: var(--text-muted); color: var(--text-muted);
} }
.flow-node__version { .graph-node__version {
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
color: var(--text-secondary); color: var(--text-secondary);
} }
.flow-node__size { .graph-node__size {
color: var(--text-muted); color: var(--text-muted);
} }
/* Flow Handles (connection points) */ /* Graph Children / Tree Structure */
.flow-handle { .graph-children {
width: 8px !important; display: flex;
height: 8px !important; padding-left: 24px;
background: var(--border-primary) !important; margin-top: 8px;
border: 2px solid var(--bg-tertiary) !important; position: relative;
} }
.flow-node:hover .flow-handle { .graph-connector {
background: var(--accent-primary) !important; position: absolute;
left: 12px;
top: 0;
bottom: 50%;
width: 12px;
border-left: 2px solid var(--border-primary);
border-bottom: 2px solid var(--border-primary);
border-bottom-left-radius: 8px;
}
.graph-children-list {
display: flex;
flex-direction: column;
gap: 8px;
position: relative;
}
.graph-children-list::before {
content: '';
position: absolute;
left: -12px;
top: 20px;
bottom: 20px;
border-left: 2px solid var(--border-primary);
}
.graph-children-list > .graph-node-container {
position: relative;
}
.graph-children-list > .graph-node-container::before {
content: '';
position: absolute;
left: -12px;
top: 20px;
width: 12px;
border-top: 2px solid var(--border-primary);
} }
/* Loading, Error, Empty States */ /* Loading, Error, Empty States */
@@ -227,61 +279,39 @@
line-height: 1.5; line-height: 1.5;
} }
/* Missing Dependencies */ /* Tooltip */
.missing-dependencies { .graph-tooltip {
border-top: 1px solid var(--border-primary); position: fixed;
padding: 16px 20px; bottom: 24px;
background: rgba(245, 158, 11, 0.05); left: 50%;
max-height: 200px; transform: translateX(-50%);
overflow-y: auto;
}
.missing-dependencies h3 {
margin: 0 0 8px 0;
font-size: 0.875rem;
font-weight: 600;
color: #f59e0b;
}
.missing-hint {
margin: 0 0 12px 0;
font-size: 0.75rem;
color: var(--text-muted);
}
.missing-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.missing-item {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--bg-tertiary); background: var(--bg-tertiary);
border: 1px solid rgba(245, 158, 11, 0.3); border: 1px solid var(--border-primary);
border-radius: var(--radius-sm); border-radius: var(--radius-md);
padding: 4px 8px; padding: 12px 16px;
font-size: 0.75rem; font-size: 0.8125rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 1001;
} }
.missing-name { .graph-tooltip strong {
display: block;
color: var(--accent-primary);
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
margin-bottom: 4px;
}
.graph-tooltip div {
color: var(--text-secondary); color: var(--text-secondary);
margin-top: 2px;
} }
.missing-constraint { .tooltip-hint {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-primary);
color: var(--text-muted); color: var(--text-muted);
font-family: 'JetBrains Mono', monospace; font-size: 0.75rem;
}
.missing-required-by {
color: var(--text-muted);
font-size: 0.6875rem;
} }
/* Responsive */ /* Responsive */

View File

@@ -1,19 +1,5 @@
import { useState, useEffect, useCallback, useMemo } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import ReactFlow, {
Node,
Edge,
Controls,
Background,
useNodesState,
useEdgesState,
MarkerType,
NodeProps,
Handle,
Position,
} from 'reactflow';
import dagre from 'dagre';
import 'reactflow/dist/style.css';
import { ResolvedArtifact, DependencyResolutionResponse, Dependency } from '../types'; import { ResolvedArtifact, DependencyResolutionResponse, Dependency } from '../types';
import { resolveDependencies, getArtifactDependencies } from '../api'; import { resolveDependencies, getArtifactDependencies } from '../api';
import './DependencyGraph.css'; import './DependencyGraph.css';
@@ -25,14 +11,15 @@ interface DependencyGraphProps {
onClose: () => void; onClose: () => void;
} }
interface NodeData { interface GraphNode {
label: string; id: string;
project: string; project: string;
package: string; package: string;
version: string | null; version: string | null;
size: number; size: number;
isRoot: boolean; depth: number;
onNavigate: (project: string, pkg: string) => void; children: GraphNode[];
isRoot?: boolean;
} }
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
@@ -43,89 +30,29 @@ function formatBytes(bytes: number): string {
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
} }
// Custom node component
function DependencyNode({ data }: NodeProps<NodeData>) {
return (
<div
className={`flow-node ${data.isRoot ? 'flow-node--root' : ''}`}
onClick={() => data.onNavigate(data.project, data.package)}
>
<Handle type="target" position={Position.Top} className="flow-handle" />
<div className="flow-node__name">{data.package}</div>
<div className="flow-node__details">
{data.version && <span className="flow-node__version">{data.version}</span>}
<span className="flow-node__size">{formatBytes(data.size)}</span>
</div>
<Handle type="source" position={Position.Bottom} className="flow-handle" />
</div>
);
}
const nodeTypes = { dependency: DependencyNode };
// Dagre layout function
function getLayoutedElements(
nodes: Node<NodeData>[],
edges: Edge[],
direction: 'TB' | 'LR' = 'TB'
) {
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
const nodeWidth = 180;
const nodeHeight = 60;
dagreGraph.setGraph({ rankdir: direction, nodesep: 50, ranksep: 80 });
nodes.forEach((node) => {
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
});
edges.forEach((edge) => {
dagreGraph.setEdge(edge.source, edge.target);
});
dagre.layout(dagreGraph);
const layoutedNodes = nodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id);
return {
...node,
position: {
x: nodeWithPosition.x - nodeWidth / 2,
y: nodeWithPosition.y - nodeHeight / 2,
},
};
});
return { nodes: layoutedNodes, edges };
}
function DependencyGraph({ projectName, packageName, tagName, onClose }: DependencyGraphProps) { function DependencyGraph({ projectName, packageName, tagName, onClose }: DependencyGraphProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const containerRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [resolution, setResolution] = useState<DependencyResolutionResponse | null>(null); const [resolution, setResolution] = useState<DependencyResolutionResponse | null>(null);
const [nodes, setNodes, onNodesChange] = useNodesState<NodeData>([]); const [graphRoot, setGraphRoot] = useState<GraphNode | null>(null);
const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
const [zoom, setZoom] = useState(1);
const handleNavigate = useCallback((project: string, pkg: string) => { const [pan, setPan] = useState({ x: 0, y: 0 });
navigate(`/project/${project}/${pkg}`); const [isDragging, setIsDragging] = useState(false);
onClose(); const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
}, [navigate, onClose]); const [collapsedNodes, setCollapsedNodes] = useState<Set<string>>(new Set());
// Build graph structure from resolution data // Build graph structure from resolution data
const buildFlowGraph = useCallback(async ( const buildGraph = useCallback(async (resolutionData: DependencyResolutionResponse) => {
resolutionData: DependencyResolutionResponse,
onNavigate: (project: string, pkg: string) => void
) => {
const artifactMap = new Map<string, ResolvedArtifact>(); const artifactMap = new Map<string, ResolvedArtifact>();
resolutionData.resolved.forEach(artifact => { resolutionData.resolved.forEach(artifact => {
artifactMap.set(artifact.artifact_id, artifact); artifactMap.set(artifact.artifact_id, artifact);
}); });
// Fetch dependencies for each artifact // Fetch dependencies for each artifact to build the tree
const depsMap = new Map<string, Dependency[]>(); const depsMap = new Map<string, Dependency[]>();
for (const artifact of resolutionData.resolved) { for (const artifact of resolutionData.resolved) {
@@ -137,82 +64,50 @@ function DependencyGraph({ projectName, packageName, tagName, onClose }: Depende
} }
} }
// Find the root artifact // Find the root artifact (the requested one)
const rootArtifact = resolutionData.resolved.find( const rootArtifact = resolutionData.resolved.find(
a => a.project === resolutionData.requested.project && a => a.project === resolutionData.requested.project &&
a.package === resolutionData.requested.package a.package === resolutionData.requested.package
); );
if (!rootArtifact) { if (!rootArtifact) {
return { nodes: [], edges: [] }; return null;
} }
const flowNodes: Node<NodeData>[] = []; // Build tree recursively
const flowEdges: Edge[] = [];
const visited = new Set<string>(); const visited = new Set<string>();
const nodeIdMap = new Map<string, string>(); // artifact_id -> node id
// Build nodes and edges recursively
const processNode = (artifact: ResolvedArtifact, isRoot: boolean) => {
if (visited.has(artifact.artifact_id)) {
return nodeIdMap.get(artifact.artifact_id);
}
const buildNode = (artifact: ResolvedArtifact, depth: number): GraphNode => {
const nodeId = `${artifact.project}/${artifact.package}`;
visited.add(artifact.artifact_id); visited.add(artifact.artifact_id);
const nodeId = `node-${flowNodes.length}`;
nodeIdMap.set(artifact.artifact_id, nodeId);
flowNodes.push({
id: nodeId,
type: 'dependency',
position: { x: 0, y: 0 }, // Will be set by dagre
data: {
label: `${artifact.project}/${artifact.package}`,
project: artifact.project,
package: artifact.package,
version: artifact.version || artifact.tag,
size: artifact.size,
isRoot,
onNavigate,
},
});
const deps = depsMap.get(artifact.artifact_id) || []; const deps = depsMap.get(artifact.artifact_id) || [];
const children: GraphNode[] = [];
for (const dep of deps) { for (const dep of deps) {
// Find the resolved artifact for this dependency
const childArtifact = resolutionData.resolved.find( const childArtifact = resolutionData.resolved.find(
a => a.project === dep.project && a.package === dep.package a => a.project === dep.project && a.package === dep.package
); );
if (childArtifact) { if (childArtifact && !visited.has(childArtifact.artifact_id)) {
const childNodeId = processNode(childArtifact, false); children.push(buildNode(childArtifact, depth + 1));
if (childNodeId) {
flowEdges.push({
id: `edge-${nodeId}-${childNodeId}`,
source: nodeId,
target: childNodeId,
markerEnd: {
type: MarkerType.ArrowClosed,
width: 15,
height: 15,
color: 'var(--accent-primary)',
},
style: {
stroke: 'var(--border-primary)',
strokeWidth: 2,
},
});
}
} }
} }
return nodeId; return {
id: nodeId,
project: artifact.project,
package: artifact.package,
version: artifact.version || artifact.tag,
size: artifact.size,
depth,
children,
isRoot: depth === 0,
};
}; };
processNode(rootArtifact, true); return buildNode(rootArtifact, 0);
// Apply dagre layout
return getLayoutedElements(flowNodes, flowEdges);
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -222,21 +117,13 @@ function DependencyGraph({ projectName, packageName, tagName, onClose }: Depende
try { try {
const result = await resolveDependencies(projectName, packageName, tagName); const result = await resolveDependencies(projectName, packageName, tagName);
// If only the root package (no dependencies) and no missing deps, close the modal
const hasDeps = result.artifact_count > 1 || (result.missing && result.missing.length > 0);
if (!hasDeps) {
onClose();
return;
}
setResolution(result); setResolution(result);
const { nodes: layoutedNodes, edges: layoutedEdges } = await buildFlowGraph(result, handleNavigate); const graph = await buildGraph(result);
setNodes(layoutedNodes); setGraphRoot(graph);
setEdges(layoutedEdges);
} catch (err) { } catch (err) {
if (err instanceof Error) { if (err instanceof Error) {
// Check if it's a resolution error
try { try {
const errorData = JSON.parse(err.message); const errorData = JSON.parse(err.message);
if (errorData.error === 'circular_dependency') { if (errorData.error === 'circular_dependency') {
@@ -258,9 +145,95 @@ function DependencyGraph({ projectName, packageName, tagName, onClose }: Depende
} }
loadData(); loadData();
}, [projectName, packageName, tagName, buildFlowGraph, handleNavigate, onClose, setNodes, setEdges]); }, [projectName, packageName, tagName, buildGraph]);
const defaultViewport = useMemo(() => ({ x: 50, y: 50, zoom: 0.8 }), []); const handleNodeClick = (node: GraphNode) => {
navigate(`/project/${node.project}/${node.package}`);
onClose();
};
const handleNodeToggle = (node: GraphNode, e: React.MouseEvent) => {
e.stopPropagation();
setCollapsedNodes(prev => {
const next = new Set(prev);
if (next.has(node.id)) {
next.delete(node.id);
} else {
next.add(node.id);
}
return next;
});
};
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
setZoom(z => Math.max(0.25, Math.min(2, z + delta)));
};
const handleMouseDown = (e: React.MouseEvent) => {
if (e.target === containerRef.current || (e.target as HTMLElement).classList.contains('graph-canvas')) {
setIsDragging(true);
setDragStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
}
};
const handleMouseMove = (e: React.MouseEvent) => {
if (isDragging) {
setPan({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y });
}
};
const handleMouseUp = () => {
setIsDragging(false);
};
const resetView = () => {
setZoom(1);
setPan({ x: 0, y: 0 });
};
const renderNode = (node: GraphNode, index: number = 0): JSX.Element => {
const isCollapsed = collapsedNodes.has(node.id);
const hasChildren = node.children.length > 0;
return (
<div key={`${node.id}-${index}`} className="graph-node-container">
<div
className={`graph-node ${node.isRoot ? 'graph-node--root' : ''} ${hoveredNode?.id === node.id ? 'graph-node--hovered' : ''}`}
onClick={() => handleNodeClick(node)}
onMouseEnter={() => setHoveredNode(node)}
onMouseLeave={() => setHoveredNode(null)}
>
<div className="graph-node__header">
<span className="graph-node__name">{node.project}/{node.package}</span>
{hasChildren && (
<button
className="graph-node__toggle"
onClick={(e) => handleNodeToggle(node, e)}
title={isCollapsed ? 'Expand' : 'Collapse'}
>
{isCollapsed ? '+' : '-'}
</button>
)}
</div>
<div className="graph-node__details">
{node.version && <span className="graph-node__version">@ {node.version}</span>}
<span className="graph-node__size">{formatBytes(node.size)}</span>
</div>
</div>
{hasChildren && !isCollapsed && (
<div className="graph-children">
<div className="graph-connector"></div>
<div className="graph-children-list">
{node.children.map((child, i) => renderNode(child, i))}
</div>
</div>
)}
</div>
);
};
return ( return (
<div className="dependency-graph-modal" onClick={onClose}> <div className="dependency-graph-modal" onClick={onClose}>
@@ -271,11 +244,7 @@ function DependencyGraph({ projectName, packageName, tagName, onClose }: Depende
<span>{projectName}/{packageName} @ {tagName}</span> <span>{projectName}/{packageName} @ {tagName}</span>
{resolution && ( {resolution && (
<span className="graph-stats"> <span className="graph-stats">
{resolution.artifact_count} cached {resolution.artifact_count} packages {formatBytes(resolution.total_size)} total
{resolution.missing && resolution.missing.length > 0 && (
<span className="missing-count"> {resolution.missing.length} not cached</span>
)}
{formatBytes(resolution.total_size)} total
</span> </span>
)} )}
</div> </div>
@@ -287,7 +256,28 @@ function DependencyGraph({ projectName, packageName, tagName, onClose }: Depende
</button> </button>
</div> </div>
<div className="dependency-graph-container"> <div className="dependency-graph-toolbar">
<button className="btn btn-secondary btn-small" onClick={() => setZoom(z => Math.min(2, z + 0.25))}>
Zoom In
</button>
<button className="btn btn-secondary btn-small" onClick={() => setZoom(z => Math.max(0.25, z - 0.25))}>
Zoom Out
</button>
<button className="btn btn-secondary btn-small" onClick={resetView}>
Reset View
</button>
<span className="zoom-level">{Math.round(zoom * 100)}%</span>
</div>
<div
ref={containerRef}
className="dependency-graph-container"
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{loading ? ( {loading ? (
<div className="graph-loading"> <div className="graph-loading">
<div className="spinner"></div> <div className="spinner"></div>
@@ -302,41 +292,27 @@ function DependencyGraph({ projectName, packageName, tagName, onClose }: Depende
</svg> </svg>
<p>{error}</p> <p>{error}</p>
</div> </div>
) : nodes.length > 0 ? ( ) : graphRoot ? (
<ReactFlow <div
nodes={nodes} className="graph-canvas"
edges={edges} style={{
onNodesChange={onNodesChange} transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
onEdgesChange={onEdgesChange} cursor: isDragging ? 'grabbing' : 'grab',
nodeTypes={nodeTypes} }}
defaultViewport={defaultViewport}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.1}
maxZoom={2}
attributionPosition="bottom-left"
> >
<Controls /> {renderNode(graphRoot)}
<Background color="var(--border-primary)" gap={20} /> </div>
</ReactFlow>
) : ( ) : (
<div className="graph-empty">No dependencies to display</div> <div className="graph-empty">No dependencies to display</div>
)} )}
</div> </div>
{resolution && resolution.missing && resolution.missing.length > 0 && ( {hoveredNode && (
<div className="missing-dependencies"> <div className="graph-tooltip">
<h3>Not Cached ({resolution.missing.length})</h3> <strong>{hoveredNode.project}/{hoveredNode.package}</strong>
<p className="missing-hint">These dependencies are referenced but not yet cached on the server.</p> {hoveredNode.version && <div>Version: {hoveredNode.version}</div>}
<ul className="missing-list"> <div>Size: {formatBytes(hoveredNode.size)}</div>
{resolution.missing.map((dep, i) => ( <div className="tooltip-hint">Click to navigate</div>
<li key={i} className="missing-item">
<span className="missing-name">{dep.project}/{dep.package}</span>
{dep.constraint && <span className="missing-constraint">@{dep.constraint}</span>}
{dep.required_by && <span className="missing-required-by"> {dep.required_by}</span>}
</li>
))}
</ul>
</div> </div>
)} )}
</div> </div>

View File

@@ -84,6 +84,29 @@ function Layout({ children }: LayoutProps) {
</svg> </svg>
Projects Projects
</Link> </Link>
<Link to="/dashboard" className={location.pathname === '/dashboard' ? 'active' : ''}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
</svg>
Dashboard
</Link>
{user && userTeams.length > 0 && (
<Link
to={userTeams.length === 1 ? `/teams/${userTeams[0].slug}` : '/teams'}
className={location.pathname.startsWith('/teams') ? 'active' : ''}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
{userTeams.length === 1 ? 'Team' : 'Teams'}
</Link>
)}
<a href="/docs" className="nav-link-muted"> <a href="/docs" className="nav-link-muted">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
@@ -125,35 +148,6 @@ function Layout({ children }: LayoutProps) {
)} )}
</div> </div>
<div className="user-menu-divider"></div> <div className="user-menu-divider"></div>
<NavLink
to="/dashboard"
className="user-menu-item"
onClick={() => setShowUserMenu(false)}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
</svg>
Dashboard
</NavLink>
{userTeams.length > 0 && (
<NavLink
to={userTeams.length === 1 ? `/teams/${userTeams[0].slug}` : '/teams'}
className="user-menu-item"
onClick={() => setShowUserMenu(false)}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
{userTeams.length === 1 ? 'Team' : 'Teams'}
</NavLink>
)}
<div className="user-menu-divider"></div>
<NavLink <NavLink
to="/settings/api-keys" to="/settings/api-keys"
className="user-menu-item" className="user-menu-item"

View File

@@ -132,12 +132,6 @@
color: #c62828; color: #c62828;
} }
.coming-soon-badge {
color: #9e9e9e;
font-style: italic;
font-size: 0.85em;
}
/* Actions */ /* Actions */
.actions-cell { .actions-cell {
white-space: nowrap; white-space: nowrap;

View File

@@ -12,7 +12,6 @@ import { UpstreamSource, SourceType, AuthType } from '../types';
import './AdminCachePage.css'; import './AdminCachePage.css';
const SOURCE_TYPES: SourceType[] = ['npm', 'pypi', 'maven', 'docker', 'helm', 'nuget', 'deb', 'rpm', 'generic']; const SOURCE_TYPES: SourceType[] = ['npm', 'pypi', 'maven', 'docker', 'helm', 'nuget', 'deb', 'rpm', 'generic'];
const SUPPORTED_SOURCE_TYPES: Set<SourceType> = new Set(['pypi', 'generic']);
const AUTH_TYPES: AuthType[] = ['none', 'basic', 'bearer', 'api_key']; const AUTH_TYPES: AuthType[] = ['none', 'basic', 'bearer', 'api_key'];
function AdminCachePage() { function AdminCachePage() {
@@ -286,12 +285,7 @@ function AdminCachePage() {
<span className="env-badge" title="Defined via environment variable">ENV</span> <span className="env-badge" title="Defined via environment variable">ENV</span>
)} )}
</td> </td>
<td> <td>{source.source_type}</td>
{source.source_type}
{!SUPPORTED_SOURCE_TYPES.has(source.source_type) && (
<span className="coming-soon-badge"> (coming soon)</span>
)}
</td>
<td className="url-cell" title={source.url}>{source.url}</td> <td className="url-cell" title={source.url}>{source.url}</td>
<td>{source.priority}</td> <td>{source.priority}</td>
<td> <td>
@@ -365,7 +359,7 @@ function AdminCachePage() {
> >
{SOURCE_TYPES.map((type) => ( {SOURCE_TYPES.map((type) => (
<option key={type} value={type}> <option key={type} value={type}>
{type}{!SUPPORTED_SOURCE_TYPES.has(type) ? ' (coming soon)' : ''} {type}
</option> </option>
))} ))}
</select> </select>

View File

@@ -249,7 +249,7 @@ function Home() {
key: 'created_by', key: 'created_by',
header: 'Owner', header: 'Owner',
className: 'cell-owner', className: 'cell-owner',
render: (project) => project.team_name || project.created_by, render: (project) => project.created_by,
}, },
...(user ...(user
? [ ? [

View File

@@ -642,11 +642,6 @@ tr:hover .copy-btn {
padding: 20px; padding: 20px;
} }
/* Ensure file modal needs higher z-index when opened from deps modal */
.modal-overlay:has(.ensure-file-modal) {
z-index: 1100;
}
.ensure-file-modal { .ensure-file-modal {
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border-primary); border: 1px solid var(--border-primary);
@@ -798,194 +793,4 @@ tr:hover .copy-btn {
.ensure-file-modal { .ensure-file-modal {
max-height: 90vh; max-height: 90vh;
} }
.action-menu-dropdown {
right: 0;
left: auto;
}
}
/* Header upload button */
.header-upload-btn {
margin-left: auto;
}
/* Tag/Version cell */
.tag-version-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.tag-version-cell .version-badge {
font-size: 0.75rem;
color: var(--text-muted);
}
/* Icon buttons */
.btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-icon:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* Action menu */
.action-buttons {
display: flex;
align-items: center;
gap: 4px;
}
.action-menu {
position: relative;
}
/* Action menu backdrop for click-outside */
.action-menu-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
}
.action-menu-dropdown {
position: fixed;
z-index: 1000;
min-width: 180px;
padding: 4px 0;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.action-menu-dropdown button {
display: block;
width: 100%;
padding: 8px 12px;
background: none;
border: none;
text-align: left;
font-size: 0.875rem;
color: var(--text-primary);
cursor: pointer;
transition: background var(--transition-fast);
}
.action-menu-dropdown button:hover {
background: var(--bg-hover);
}
/* Upload Modal */
.upload-modal,
.create-tag-modal {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-primary);
}
.modal-header h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
}
.modal-body {
padding: 20px;
}
.modal-description {
margin-bottom: 16px;
color: var(--text-secondary);
font-size: 0.875rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--border-primary);
}
/* Dependencies Modal */
.deps-modal {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
width: 90%;
max-width: 600px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.deps-modal .modal-body {
overflow-y: auto;
flex: 1;
}
.deps-modal-controls {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
/* Artifact ID Modal */
.artifact-id-modal {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
width: 90%;
max-width: 500px;
}
.artifact-id-display {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--bg-tertiary);
border-radius: var(--radius-md);
border: 1px solid var(--border-primary);
}
.artifact-id-display code {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 0.8125rem;
color: var(--text-primary);
word-break: break-all;
flex: 1;
}
.artifact-id-display .copy-btn {
opacity: 1;
flex-shrink: 0;
} }

View File

@@ -63,17 +63,12 @@ function PackagePage() {
const [accessDenied, setAccessDenied] = useState(false); const [accessDenied, setAccessDenied] = useState(false);
const [uploadTag, setUploadTag] = useState(''); const [uploadTag, setUploadTag] = useState('');
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null); const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
const [artifactIdInput, setArtifactIdInput] = useState('');
const [accessLevel, setAccessLevel] = useState<AccessLevel | null>(null); const [accessLevel, setAccessLevel] = useState<AccessLevel | null>(null);
const [createTagName, setCreateTagName] = useState(''); const [createTagName, setCreateTagName] = useState('');
const [createTagArtifactId, setCreateTagArtifactId] = useState(''); const [createTagArtifactId, setCreateTagArtifactId] = useState('');
const [createTagLoading, setCreateTagLoading] = useState(false); const [createTagLoading, setCreateTagLoading] = useState(false);
// UI state
const [showUploadModal, setShowUploadModal] = useState(false);
const [showCreateTagModal, setShowCreateTagModal] = useState(false);
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
// Dependencies state // Dependencies state
const [selectedTag, setSelectedTag] = useState<TagDetail | null>(null); const [selectedTag, setSelectedTag] = useState<TagDetail | null>(null);
const [dependencies, setDependencies] = useState<Dependency[]>([]); const [dependencies, setDependencies] = useState<Dependency[]>([]);
@@ -83,7 +78,7 @@ function PackagePage() {
// Reverse dependencies state // Reverse dependencies state
const [reverseDeps, setReverseDeps] = useState<DependentInfo[]>([]); const [reverseDeps, setReverseDeps] = useState<DependentInfo[]>([]);
const [reverseDepsLoading, setReverseDepsLoading] = useState(false); const [reverseDepsLoading, setReverseDepsLoading] = useState(false);
const [_reverseDepsError, setReverseDepsError] = useState<string | null>(null); const [reverseDepsError, setReverseDepsError] = useState<string | null>(null);
const [reverseDepsPage, setReverseDepsPage] = useState(1); const [reverseDepsPage, setReverseDepsPage] = useState(1);
const [reverseDepsTotal, setReverseDepsTotal] = useState(0); const [reverseDepsTotal, setReverseDepsTotal] = useState(0);
const [reverseDepsHasMore, setReverseDepsHasMore] = useState(false); const [reverseDepsHasMore, setReverseDepsHasMore] = useState(false);
@@ -91,13 +86,6 @@ function PackagePage() {
// Dependency graph modal state // Dependency graph modal state
const [showGraph, setShowGraph] = useState(false); const [showGraph, setShowGraph] = useState(false);
// Dependencies modal state
const [showDepsModal, setShowDepsModal] = useState(false);
// Artifact ID modal state
const [showArtifactIdModal, setShowArtifactIdModal] = useState(false);
const [viewArtifactId, setViewArtifactId] = useState<string | null>(null);
// Ensure file modal state // Ensure file modal state
const [showEnsureFile, setShowEnsureFile] = useState(false); const [showEnsureFile, setShowEnsureFile] = useState(false);
const [ensureFileContent, setEnsureFileContent] = useState<string | null>(null); const [ensureFileContent, setEnsureFileContent] = useState<string | null>(null);
@@ -108,9 +96,6 @@ function PackagePage() {
// Derived permissions // Derived permissions
const canWrite = accessLevel === 'write' || accessLevel === 'admin'; const canWrite = accessLevel === 'write' || accessLevel === 'admin';
// Detect system projects (convention: name starts with "_")
const isSystemProject = projectName?.startsWith('_') ?? false;
// Get params from URL // Get params from URL
const page = parseInt(searchParams.get('page') || '1', 10); const page = parseInt(searchParams.get('page') || '1', 10);
const search = searchParams.get('search') || ''; const search = searchParams.get('search') || '';
@@ -338,212 +323,92 @@ function PackagePage() {
setSelectedTag(tag); setSelectedTag(tag);
}; };
const handleMenuOpen = (e: React.MouseEvent, tagId: string) => { const columns = [
e.stopPropagation(); {
if (openMenuId === tagId) { key: 'name',
setOpenMenuId(null); header: 'Tag',
setMenuPosition(null); sortable: true,
} else { render: (t: TagDetail) => (
const rect = e.currentTarget.getBoundingClientRect(); <strong
setMenuPosition({ top: rect.bottom + 4, left: rect.right - 180 }); className={`tag-name-link ${selectedTag?.id === t.id ? 'selected' : ''}`}
setOpenMenuId(tagId); onClick={() => handleTagSelect(t)}
} style={{ cursor: 'pointer' }}
};
// System projects show Version first, regular projects show Tag first
const columns = isSystemProject
? [
// System project columns: Version first, then Filename
{
key: 'version',
header: 'Version',
sortable: true,
render: (t: TagDetail) => (
<strong
className={`tag-name-link ${selectedTag?.id === t.id ? 'selected' : ''}`}
onClick={() => handleTagSelect(t)}
style={{ cursor: 'pointer' }}
>
<span className="version-badge">{t.version || t.name}</span>
</strong>
),
},
{
key: 'artifact_original_name',
header: 'Filename',
className: 'cell-truncate',
render: (t: TagDetail) => (
<span title={t.artifact_original_name || t.name}>{t.artifact_original_name || t.name}</span>
),
},
{
key: 'artifact_size',
header: 'Size',
render: (t: TagDetail) => <span>{formatBytes(t.artifact_size)}</span>,
},
{
key: 'created_at',
header: 'Cached',
sortable: true,
render: (t: TagDetail) => (
<span>{new Date(t.created_at).toLocaleDateString()}</span>
),
},
{
key: 'actions',
header: '',
render: (t: TagDetail) => (
<div className="action-buttons">
<a
href={getDownloadUrl(projectName!, packageName!, t.name)}
className="btn btn-icon"
download
title="Download"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</a>
<button
className="btn btn-icon"
onClick={(e) => handleMenuOpen(e, t.id)}
title="More actions"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="19" r="1" />
</svg>
</button>
</div>
),
},
]
: [
// Regular project columns: Tag, Version, Filename
{
key: 'name',
header: 'Tag',
sortable: true,
render: (t: TagDetail) => (
<strong
className={`tag-name-link ${selectedTag?.id === t.id ? 'selected' : ''}`}
onClick={() => handleTagSelect(t)}
style={{ cursor: 'pointer' }}
>
{t.name}
</strong>
),
},
{
key: 'version',
header: 'Version',
render: (t: TagDetail) => (
<span className="version-badge">{t.version || '—'}</span>
),
},
{
key: 'artifact_original_name',
header: 'Filename',
className: 'cell-truncate',
render: (t: TagDetail) => (
<span title={t.artifact_original_name || undefined}>{t.artifact_original_name || '—'}</span>
),
},
{
key: 'artifact_size',
header: 'Size',
render: (t: TagDetail) => <span>{formatBytes(t.artifact_size)}</span>,
},
{
key: 'created_at',
header: 'Created',
sortable: true,
render: (t: TagDetail) => (
<span title={`by ${t.created_by}`}>{new Date(t.created_at).toLocaleDateString()}</span>
),
},
{
key: 'actions',
header: '',
render: (t: TagDetail) => (
<div className="action-buttons">
<a
href={getDownloadUrl(projectName!, packageName!, t.name)}
className="btn btn-icon"
download
title="Download"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</a>
<button
className="btn btn-icon"
onClick={(e) => handleMenuOpen(e, t.id)}
title="More actions"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="19" r="1" />
</svg>
</button>
</div>
),
},
];
// Find the tag for the open menu
const openMenuTag = tags.find(t => t.id === openMenuId);
// Close menu when clicking outside
const handleClickOutside = () => {
if (openMenuId) {
setOpenMenuId(null);
setMenuPosition(null);
}
};
// Render dropdown menu as a portal-like element
const renderActionMenu = () => {
if (!openMenuId || !menuPosition || !openMenuTag) return null;
const t = openMenuTag;
return (
<div
className="action-menu-backdrop"
onClick={handleClickOutside}
>
<div
className="action-menu-dropdown"
style={{ top: menuPosition.top, left: menuPosition.left }}
onClick={(e) => e.stopPropagation()}
> >
<button onClick={() => { setViewArtifactId(t.artifact_id); setShowArtifactIdModal(true); setOpenMenuId(null); setMenuPosition(null); }}> {t.name}
View Artifact ID </strong>
</button> ),
<button onClick={() => { navigator.clipboard.writeText(t.artifact_id); setOpenMenuId(null); setMenuPosition(null); }}> },
Copy Artifact ID {
</button> key: 'version',
<button onClick={() => { fetchEnsureFileForTag(t.name); setOpenMenuId(null); setMenuPosition(null); }}> header: 'Version',
View Ensure File render: (t: TagDetail) => (
</button> <span className="version-badge">{t.version || '-'}</span>
{canWrite && !isSystemProject && ( ),
<button onClick={() => { setCreateTagArtifactId(t.artifact_id); setShowCreateTagModal(true); setOpenMenuId(null); setMenuPosition(null); }}> },
Create/Update Tag {
</button> key: 'artifact_id',
)} header: 'Artifact ID',
<button onClick={() => { handleTagSelect(t); setShowDepsModal(true); setOpenMenuId(null); setMenuPosition(null); }}> render: (t: TagDetail) => (
View Dependencies <div className="artifact-id-cell">
</button> <code className="artifact-id">{t.artifact_id.substring(0, 12)}...</code>
<CopyButton text={t.artifact_id} />
</div> </div>
</div> ),
); },
}; {
key: 'artifact_size',
header: 'Size',
render: (t: TagDetail) => <span>{formatBytes(t.artifact_size)}</span>,
},
{
key: 'artifact_content_type',
header: 'Type',
render: (t: TagDetail) => (
<span className="content-type">{t.artifact_content_type || '-'}</span>
),
},
{
key: 'artifact_original_name',
header: 'Filename',
className: 'cell-truncate',
render: (t: TagDetail) => (
<span title={t.artifact_original_name || undefined}>{t.artifact_original_name || '-'}</span>
),
},
{
key: 'created_at',
header: 'Created',
sortable: true,
render: (t: TagDetail) => (
<div className="created-cell">
<span>{new Date(t.created_at).toLocaleString()}</span>
<span className="created-by">by {t.created_by}</span>
</div>
),
},
{
key: 'actions',
header: 'Actions',
render: (t: TagDetail) => (
<div className="action-buttons">
<button
className="btn btn-secondary btn-small"
onClick={() => fetchEnsureFileForTag(t.name)}
title="View orchard.ensure file"
>
Ensure
</button>
<a
href={getDownloadUrl(projectName!, packageName!, t.name)}
className="btn btn-secondary btn-small"
download
>
Download
</a>
</div>
),
},
];
if (loading && !tagsData) { if (loading && !tagsData) {
return <div className="loading">Loading...</div>; return <div className="loading">Loading...</div>;
@@ -586,19 +451,6 @@ function PackagePage() {
<div className="page-header__title-row"> <div className="page-header__title-row">
<h1>{packageName}</h1> <h1>{packageName}</h1>
{pkg && <Badge variant="default">{pkg.format}</Badge>} {pkg && <Badge variant="default">{pkg.format}</Badge>}
{user && canWrite && !isSystemProject && (
<button
className="btn btn-primary btn-small header-upload-btn"
onClick={() => setShowUploadModal(true)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: '6px' }}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Upload
</button>
)}
</div> </div>
{pkg?.description && <p className="description">{pkg.description}</p>} {pkg?.description && <p className="description">{pkg.description}</p>}
<div className="page-header__meta"> <div className="page-header__meta">
@@ -616,14 +468,14 @@ function PackagePage() {
</div> </div>
{pkg && (pkg.tag_count !== undefined || pkg.artifact_count !== undefined) && ( {pkg && (pkg.tag_count !== undefined || pkg.artifact_count !== undefined) && (
<div className="package-header-stats"> <div className="package-header-stats">
{!isSystemProject && pkg.tag_count !== undefined && ( {pkg.tag_count !== undefined && (
<span className="stat-item"> <span className="stat-item">
<strong>{pkg.tag_count}</strong> tags <strong>{pkg.tag_count}</strong> tags
</span> </span>
)} )}
{pkg.artifact_count !== undefined && ( {pkg.artifact_count !== undefined && (
<span className="stat-item"> <span className="stat-item">
<strong>{pkg.artifact_count}</strong> {isSystemProject ? 'versions' : 'artifacts'} <strong>{pkg.artifact_count}</strong> artifacts
</span> </span>
)} )}
{pkg.total_size !== undefined && pkg.total_size > 0 && ( {pkg.total_size !== undefined && pkg.total_size > 0 && (
@@ -631,7 +483,7 @@ function PackagePage() {
<strong>{formatBytes(pkg.total_size)}</strong> total <strong>{formatBytes(pkg.total_size)}</strong> total
</span> </span>
)} )}
{!isSystemProject && pkg.latest_tag && ( {pkg.latest_tag && (
<span className="stat-item"> <span className="stat-item">
Latest: <strong className="accent">{pkg.latest_tag}</strong> Latest: <strong className="accent">{pkg.latest_tag}</strong>
</span> </span>
@@ -644,9 +496,44 @@ function PackagePage() {
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}
{uploadSuccess && <div className="success-message">{uploadSuccess}</div>} {uploadSuccess && <div className="success-message">{uploadSuccess}</div>}
{user && (
<div className="upload-section card">
<h3>Upload Artifact</h3>
{canWrite ? (
<div className="upload-form">
<div className="form-group">
<label htmlFor="upload-tag">Tag (optional)</label>
<input
id="upload-tag"
type="text"
value={uploadTag}
onChange={(e) => setUploadTag(e.target.value)}
placeholder="v1.0.0, latest, stable..."
/>
</div>
<DragDropUpload
projectName={projectName!}
packageName={packageName!}
tag={uploadTag || undefined}
onUploadComplete={handleUploadComplete}
onUploadError={handleUploadError}
/>
</div>
) : (
<DragDropUpload
projectName={projectName!}
packageName={packageName!}
disabled={true}
disabledReason="You have read-only access to this project and cannot upload artifacts."
onUploadComplete={handleUploadComplete}
onUploadError={handleUploadError}
/>
)}
</div>
)}
<div className="section-header"> <div className="section-header">
<h2>{isSystemProject ? 'Versions' : 'Tags / Versions'}</h2> <h2>Tags / Versions</h2>
</div> </div>
<div className="list-controls"> <div className="list-controls">
@@ -690,10 +577,121 @@ function PackagePage() {
/> />
)} )}
{/* Used By (Reverse Dependencies) Section - only show if there are reverse deps */} {/* Dependencies Section */}
{reverseDeps.length > 0 && ( {tags.length > 0 && (
<div className="used-by-section card"> <div className="dependencies-section card">
<h3>Used By</h3> <div className="dependencies-header">
<h3>Dependencies</h3>
<div className="dependencies-controls">
{selectedTag && (
<>
<button
className="btn btn-secondary btn-small"
onClick={fetchEnsureFile}
disabled={ensureFileLoading}
title="View orchard.ensure file"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: '6px' }}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
{ensureFileLoading ? 'Loading...' : 'View Ensure File'}
</button>
<button
className="btn btn-secondary btn-small"
onClick={() => setShowGraph(true)}
title="View full dependency tree"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: '6px' }}>
<circle cx="12" cy="12" r="3"></circle>
<circle cx="4" cy="4" r="2"></circle>
<circle cx="20" cy="4" r="2"></circle>
<circle cx="4" cy="20" r="2"></circle>
<circle cx="20" cy="20" r="2"></circle>
<line x1="9.5" y1="9.5" x2="5.5" y2="5.5"></line>
<line x1="14.5" y1="9.5" x2="18.5" y2="5.5"></line>
<line x1="9.5" y1="14.5" x2="5.5" y2="18.5"></line>
<line x1="14.5" y1="14.5" x2="18.5" y2="18.5"></line>
</svg>
View Graph
</button>
</>
)}
</div>
</div>
<div className="dependencies-tag-select">
{selectedTag && (
<select
className="tag-selector"
value={selectedTag.id}
onChange={(e) => {
const tag = tags.find(t => t.id === e.target.value);
if (tag) setSelectedTag(tag);
}}
>
{tags.map(t => (
<option key={t.id} value={t.id}>
{t.name}{t.version ? ` (${t.version})` : ''}
</option>
))}
</select>
)}
</div>
{depsLoading ? (
<div className="deps-loading">Loading dependencies...</div>
) : depsError ? (
<div className="deps-error">{depsError}</div>
) : dependencies.length === 0 ? (
<div className="deps-empty">
{selectedTag ? (
<span><strong>{selectedTag.name}</strong> has no dependencies</span>
) : (
<span>No dependencies</span>
)}
</div>
) : (
<div className="deps-list">
<div className="deps-summary">
<strong>{selectedTag?.name}</strong> has {dependencies.length} {dependencies.length === 1 ? 'dependency' : 'dependencies'}:
</div>
<ul className="deps-items">
{dependencies.map((dep) => (
<li key={dep.id} className="dep-item">
<Link
to={`/project/${dep.project}/${dep.package}`}
className="dep-link"
>
{dep.project}/{dep.package}
</Link>
<span className="dep-constraint">
@ {dep.version || dep.tag}
</span>
<span className="dep-status dep-status--ok" title="Package exists">
&#10003;
</span>
</li>
))}
</ul>
</div>
)}
</div>
)}
{/* Used By (Reverse Dependencies) Section */}
<div className="used-by-section card">
<h3>Used By</h3>
{reverseDepsLoading ? (
<div className="deps-loading">Loading reverse dependencies...</div>
) : reverseDepsError ? (
<div className="deps-error">{reverseDepsError}</div>
) : reverseDeps.length === 0 ? (
<div className="deps-empty">No packages depend on this package</div>
) : (
<div className="reverse-deps-list"> <div className="reverse-deps-list">
<div className="deps-summary"> <div className="deps-summary">
{reverseDepsTotal} {reverseDepsTotal === 1 ? 'package depends' : 'packages depend'} on this: {reverseDepsTotal} {reverseDepsTotal === 1 ? 'package depends' : 'packages depend'} on this:
@@ -736,6 +734,78 @@ function PackagePage() {
</div> </div>
)} )}
</div> </div>
)}
</div>
<div className="download-by-id-section card">
<h3>Download by Artifact ID</h3>
<div className="download-by-id-form">
<input
type="text"
value={artifactIdInput}
onChange={(e) => setArtifactIdInput(e.target.value.toLowerCase().replace(/[^a-f0-9]/g, '').slice(0, 64))}
placeholder="Enter SHA256 artifact ID (64 hex characters)"
className="artifact-id-input"
/>
<a
href={artifactIdInput.length === 64 ? getDownloadUrl(projectName!, packageName!, `artifact:${artifactIdInput}`) : '#'}
className={`btn btn-primary ${artifactIdInput.length !== 64 ? 'btn-disabled' : ''}`}
download
onClick={(e) => {
if (artifactIdInput.length !== 64) {
e.preventDefault();
}
}}
>
Download
</a>
</div>
{artifactIdInput.length > 0 && artifactIdInput.length !== 64 && (
<p className="validation-hint">Artifact ID must be exactly 64 hex characters ({artifactIdInput.length}/64)</p>
)}
</div>
{user && canWrite && (
<div className="create-tag-section card">
<h3>Create / Update Tag</h3>
<p className="section-description">Point a tag at any existing artifact by its ID</p>
<form onSubmit={handleCreateTag} className="create-tag-form">
<div className="form-row">
<div className="form-group">
<label htmlFor="create-tag-name">Tag Name</label>
<input
id="create-tag-name"
type="text"
value={createTagName}
onChange={(e) => setCreateTagName(e.target.value)}
placeholder="latest, stable, v1.0.0..."
disabled={createTagLoading}
/>
</div>
<div className="form-group form-group--wide">
<label htmlFor="create-tag-artifact">Artifact ID</label>
<input
id="create-tag-artifact"
type="text"
value={createTagArtifactId}
onChange={(e) => setCreateTagArtifactId(e.target.value.toLowerCase().replace(/[^a-f0-9]/g, '').slice(0, 64))}
placeholder="SHA256 hash (64 hex characters)"
className="artifact-id-input"
disabled={createTagLoading}
/>
</div>
<button
type="submit"
className="btn btn-primary"
disabled={createTagLoading || !createTagName.trim() || createTagArtifactId.length !== 64}
>
{createTagLoading ? 'Creating...' : 'Create Tag'}
</button>
</div>
{createTagArtifactId.length > 0 && createTagArtifactId.length !== 64 && (
<p className="validation-hint">Artifact ID must be exactly 64 hex characters ({createTagArtifactId.length}/64)</p>
)}
</form>
</div> </div>
)} )}
@@ -761,118 +831,6 @@ function PackagePage() {
/> />
)} )}
{/* Upload Modal */}
{showUploadModal && (
<div className="modal-overlay" onClick={() => setShowUploadModal(false)}>
<div className="upload-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>Upload Artifact</h3>
<button
className="modal-close"
onClick={() => setShowUploadModal(false)}
title="Close"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div className="modal-body">
<div className="form-group">
<label htmlFor="upload-tag">Tag (optional)</label>
<input
id="upload-tag"
type="text"
value={uploadTag}
onChange={(e) => setUploadTag(e.target.value)}
placeholder="v1.0.0, latest, stable..."
/>
</div>
<DragDropUpload
projectName={projectName!}
packageName={packageName!}
tag={uploadTag || undefined}
onUploadComplete={(result) => {
handleUploadComplete(result);
setShowUploadModal(false);
setUploadTag('');
}}
onUploadError={handleUploadError}
/>
</div>
</div>
</div>
)}
{/* Create/Update Tag Modal */}
{showCreateTagModal && (
<div className="modal-overlay" onClick={() => setShowCreateTagModal(false)}>
<div className="create-tag-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>Create / Update Tag</h3>
<button
className="modal-close"
onClick={() => { setShowCreateTagModal(false); setCreateTagName(''); setCreateTagArtifactId(''); }}
title="Close"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div className="modal-body">
<p className="modal-description">Point a tag at an artifact by its ID</p>
<form onSubmit={(e) => { handleCreateTag(e); setShowCreateTagModal(false); }}>
<div className="form-group">
<label htmlFor="modal-tag-name">Tag Name</label>
<input
id="modal-tag-name"
type="text"
value={createTagName}
onChange={(e) => setCreateTagName(e.target.value)}
placeholder="latest, stable, v1.0.0..."
disabled={createTagLoading}
/>
</div>
<div className="form-group">
<label htmlFor="modal-artifact-id">Artifact ID</label>
<input
id="modal-artifact-id"
type="text"
value={createTagArtifactId}
onChange={(e) => setCreateTagArtifactId(e.target.value.toLowerCase().replace(/[^a-f0-9]/g, '').slice(0, 64))}
placeholder="SHA256 hash (64 hex characters)"
className="artifact-id-input"
disabled={createTagLoading}
/>
{createTagArtifactId.length > 0 && createTagArtifactId.length !== 64 && (
<p className="validation-hint">{createTagArtifactId.length}/64 characters</p>
)}
</div>
<div className="modal-actions">
<button
type="button"
className="btn btn-secondary"
onClick={() => { setShowCreateTagModal(false); setCreateTagName(''); setCreateTagArtifactId(''); }}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary"
disabled={createTagLoading || !createTagName.trim() || createTagArtifactId.length !== 64}
>
{createTagLoading ? 'Creating...' : 'Create Tag'}
</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* Ensure File Modal */} {/* Ensure File Modal */}
{showEnsureFile && ( {showEnsureFile && (
<div className="modal-overlay" onClick={() => setShowEnsureFile(false)}> <div className="modal-overlay" onClick={() => setShowEnsureFile(false)}>
@@ -914,107 +872,6 @@ function PackagePage() {
</div> </div>
</div> </div>
)} )}
{/* Dependencies Modal */}
{showDepsModal && selectedTag && (
<div className="modal-overlay" onClick={() => setShowDepsModal(false)}>
<div className="deps-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>Dependencies for {selectedTag.version || selectedTag.name}</h3>
<button
className="modal-close"
onClick={() => setShowDepsModal(false)}
title="Close"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div className="modal-body">
<div className="deps-modal-controls">
<button
className="btn btn-secondary btn-small"
onClick={fetchEnsureFile}
disabled={ensureFileLoading}
>
View Ensure File
</button>
<button
className="btn btn-secondary btn-small"
onClick={() => { setShowDepsModal(false); setShowGraph(true); }}
>
View Graph
</button>
</div>
{depsLoading ? (
<div className="deps-loading">Loading dependencies...</div>
) : depsError ? (
<div className="deps-error">{depsError}</div>
) : dependencies.length === 0 ? (
<div className="deps-empty">No dependencies</div>
) : (
<div className="deps-list">
<div className="deps-summary">
{dependencies.length} {dependencies.length === 1 ? 'dependency' : 'dependencies'}:
</div>
<ul className="deps-items">
{dependencies.map((dep) => (
<li key={dep.id} className="dep-item">
<Link
to={`/project/${dep.project}/${dep.package}`}
className="dep-link"
onClick={() => setShowDepsModal(false)}
>
{dep.project}/{dep.package}
</Link>
<span className="dep-constraint">
@ {dep.version || dep.tag}
</span>
<span className="dep-status dep-status--ok" title="Package exists">
&#10003;
</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
</div>
)}
{/* Artifact ID Modal */}
{showArtifactIdModal && viewArtifactId && (
<div className="modal-overlay" onClick={() => setShowArtifactIdModal(false)}>
<div className="artifact-id-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>Artifact ID</h3>
<button
className="modal-close"
onClick={() => setShowArtifactIdModal(false)}
title="Close"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div className="modal-body">
<p className="modal-description">SHA256 hash identifying this artifact:</p>
<div className="artifact-id-display">
<code>{viewArtifactId}</code>
<CopyButton text={viewArtifactId} />
</div>
</div>
</div>
</div>
)}
{/* Action Menu Dropdown */}
{renderActionMenu()}
</div> </div>
); );
} }

View File

@@ -214,7 +214,7 @@ function ProjectPage() {
</div> </div>
</div> </div>
<div className="page-header__actions"> <div className="page-header__actions">
{canAdmin && !project.team_id && !project.is_system && ( {canAdmin && !project.team_id && (
<button <button
className="btn btn-secondary" className="btn btn-secondary"
onClick={() => navigate(`/project/${projectName}/settings`)} onClick={() => navigate(`/project/${projectName}/settings`)}
@@ -227,11 +227,11 @@ function ProjectPage() {
Settings Settings
</button> </button>
)} )}
{canWrite && !project.is_system ? ( {canWrite ? (
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}> <button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
{showForm ? 'Cancel' : '+ New Package'} {showForm ? 'Cancel' : '+ New Package'}
</button> </button>
) : user && !project.is_system ? ( ) : user ? (
<span className="text-muted" title="You have read-only access to this project"> <span className="text-muted" title="You have read-only access to this project">
Read-only access Read-only access
</span> </span>
@@ -294,20 +294,18 @@ function ProjectPage() {
placeholder="Filter packages..." placeholder="Filter packages..."
className="list-controls__search" className="list-controls__search"
/> />
{!project?.is_system && ( <select
<select className="list-controls__select"
className="list-controls__select" value={format}
value={format} onChange={(e) => handleFormatChange(e.target.value)}
onChange={(e) => handleFormatChange(e.target.value)} >
> <option value="">All formats</option>
<option value="">All formats</option> {FORMAT_OPTIONS.map((f) => (
{FORMAT_OPTIONS.map((f) => ( <option key={f} value={f}>
<option key={f} value={f}> {f}
{f} </option>
</option> ))}
))} </select>
</select>
)}
</div> </div>
{hasActiveFilters && ( {hasActiveFilters && (
@@ -343,19 +341,19 @@ function ProjectPage() {
className: 'cell-description', className: 'cell-description',
render: (pkg) => pkg.description || '—', render: (pkg) => pkg.description || '—',
}, },
...(!project?.is_system ? [{ {
key: 'format', key: 'format',
header: 'Format', header: 'Format',
render: (pkg: Package) => <Badge variant="default">{pkg.format}</Badge>, render: (pkg) => <Badge variant="default">{pkg.format}</Badge>,
}] : []), },
...(!project?.is_system ? [{ {
key: 'tag_count', key: 'tag_count',
header: 'Tags', header: 'Tags',
render: (pkg: Package) => pkg.tag_count ?? '—', render: (pkg) => pkg.tag_count ?? '—',
}] : []), },
{ {
key: 'artifact_count', key: 'artifact_count',
header: project?.is_system ? 'Versions' : 'Artifacts', header: 'Artifacts',
render: (pkg) => pkg.artifact_count ?? '—', render: (pkg) => pkg.artifact_count ?? '—',
}, },
{ {
@@ -364,12 +362,12 @@ function ProjectPage() {
render: (pkg) => render: (pkg) =>
pkg.total_size !== undefined && pkg.total_size > 0 ? formatBytes(pkg.total_size) : '—', pkg.total_size !== undefined && pkg.total_size > 0 ? formatBytes(pkg.total_size) : '—',
}, },
...(!project?.is_system ? [{ {
key: 'latest_tag', key: 'latest_tag',
header: 'Latest', header: 'Latest',
render: (pkg: Package) => render: (pkg) =>
pkg.latest_tag ? <strong style={{ color: 'var(--accent-primary)' }}>{pkg.latest_tag}</strong> : '—', pkg.latest_tag ? <strong style={{ color: 'var(--accent-primary)' }}>{pkg.latest_tag}</strong> : '—',
}] : []), },
{ {
key: 'created_at', key: 'created_at',
header: 'Created', header: 'Created',

View File

@@ -433,13 +433,6 @@ export interface ResolvedArtifact {
download_url: string; download_url: string;
} }
export interface MissingDependency {
project: string;
package: string;
constraint: string | null;
required_by: string | null;
}
export interface DependencyResolutionResponse { export interface DependencyResolutionResponse {
requested: { requested: {
project: string; project: string;
@@ -447,7 +440,6 @@ export interface DependencyResolutionResponse {
ref: string; ref: string;
}; };
resolved: ResolvedArtifact[]; resolved: ResolvedArtifact[];
missing: MissingDependency[];
total_size: number; total_size: number;
artifact_count: number; artifact_count: number;
} }

View File

@@ -144,20 +144,6 @@ spec:
- name: ORCHARD_DATABASE_POOL_TIMEOUT - name: ORCHARD_DATABASE_POOL_TIMEOUT
value: {{ .Values.orchard.database.poolTimeout | quote }} value: {{ .Values.orchard.database.poolTimeout | quote }}
{{- end }} {{- end }}
{{- if .Values.orchard.pypiCache }}
{{- if .Values.orchard.pypiCache.workers }}
- name: ORCHARD_PYPI_CACHE_WORKERS
value: {{ .Values.orchard.pypiCache.workers | quote }}
{{- end }}
{{- if .Values.orchard.pypiCache.maxDepth }}
- name: ORCHARD_PYPI_CACHE_MAX_DEPTH
value: {{ .Values.orchard.pypiCache.maxDepth | quote }}
{{- end }}
{{- if .Values.orchard.pypiCache.maxAttempts }}
- name: ORCHARD_PYPI_CACHE_MAX_ATTEMPTS
value: {{ .Values.orchard.pypiCache.maxAttempts | quote }}
{{- end }}
{{- end }}
{{- if .Values.orchard.auth }} {{- if .Values.orchard.auth }}
{{- if or .Values.orchard.auth.secretsManager .Values.orchard.auth.existingSecret .Values.orchard.auth.adminPassword }} {{- if or .Values.orchard.auth.secretsManager .Values.orchard.auth.existingSecret .Values.orchard.auth.adminPassword }}
- name: ORCHARD_ADMIN_PASSWORD - name: ORCHARD_ADMIN_PASSWORD

View File

@@ -59,10 +59,10 @@ ingress:
resources: resources:
limits: limits:
cpu: 500m cpu: 500m
memory: 1Gi memory: 512Mi
requests: requests:
cpu: 200m cpu: 200m
memory: 1Gi memory: 512Mi
livenessProbe: livenessProbe:
httpGet: httpGet:
@@ -124,12 +124,6 @@ orchard:
mode: "presigned" mode: "presigned"
presignedUrlExpiry: 3600 presignedUrlExpiry: 3600
# PyPI Cache Worker settings (reduced workers to limit memory usage)
pypiCache:
workers: 1
maxDepth: 10
maxAttempts: 3
# Relaxed rate limits for dev/feature environments (allows integration tests to run) # Relaxed rate limits for dev/feature environments (allows integration tests to run)
rateLimit: rateLimit:
login: "1000/minute" # Default is 5/minute, relaxed for CI integration tests login: "1000/minute" # Default is 5/minute, relaxed for CI integration tests

View File

@@ -57,10 +57,10 @@ ingress:
resources: resources:
limits: limits:
cpu: 500m cpu: 500m
memory: 768Mi memory: 512Mi
requests: requests:
cpu: 500m cpu: 500m
memory: 768Mi memory: 512Mi
livenessProbe: livenessProbe:
httpGet: httpGet:
@@ -121,12 +121,6 @@ orchard:
mode: "presigned" mode: "presigned"
presignedUrlExpiry: 3600 presignedUrlExpiry: 3600
# PyPI Cache Worker settings (reduced workers to limit memory usage)
pypiCache:
workers: 2
maxDepth: 10
maxAttempts: 3
# PostgreSQL subchart - disabled in prod, using RDS # PostgreSQL subchart - disabled in prod, using RDS
postgresql: postgresql:
enabled: false enabled: false

View File

@@ -56,10 +56,10 @@ ingress:
resources: resources:
limits: limits:
cpu: 500m cpu: 500m
memory: 768Mi memory: 512Mi
requests: requests:
cpu: 500m cpu: 500m
memory: 768Mi memory: 512Mi
livenessProbe: livenessProbe:
httpGet: httpGet:
@@ -122,12 +122,6 @@ orchard:
mode: "presigned" # presigned, redirect, or proxy mode: "presigned" # presigned, redirect, or proxy
presignedUrlExpiry: 3600 # Presigned URL expiry in seconds presignedUrlExpiry: 3600 # Presigned URL expiry in seconds
# PyPI Cache Worker settings (reduced workers to limit memory usage)
pypiCache:
workers: 2
maxDepth: 10
maxAttempts: 3
# Relaxed rate limits for stage (allows CI integration tests to run) # Relaxed rate limits for stage (allows CI integration tests to run)
rateLimit: rateLimit:
login: "1000/minute" # Default is 5/minute, relaxed for CI integration tests login: "1000/minute" # Default is 5/minute, relaxed for CI integration tests

View File

@@ -54,10 +54,10 @@ ingress:
resources: resources:
limits: limits:
cpu: 500m cpu: 500m
memory: 768Mi memory: 512Mi
requests: requests:
cpu: 500m cpu: 500m
memory: 768Mi memory: 512Mi
livenessProbe: livenessProbe:
httpGet: httpGet:
@@ -120,12 +120,6 @@ orchard:
mode: "presigned" # presigned, redirect, or proxy mode: "presigned" # presigned, redirect, or proxy
presignedUrlExpiry: 3600 # Presigned URL expiry in seconds presignedUrlExpiry: 3600 # Presigned URL expiry in seconds
# PyPI Cache Worker settings
pypiCache:
workers: 2 # Number of concurrent cache workers (reduced to limit memory usage)
maxDepth: 10 # Maximum recursion depth for dependency caching
maxAttempts: 3 # Maximum retry attempts for failed cache tasks
# Authentication settings # Authentication settings
auth: auth:
# Option 1: Plain admin password (creates K8s secret) # Option 1: Plain admin password (creates K8s secret)

View File

@@ -1,55 +0,0 @@
-- Migration: 011_pypi_cache_tasks
-- Description: Add table for tracking PyPI dependency caching tasks
-- Date: 2026-02-02
-- Table for tracking PyPI cache tasks with retry support
CREATE TABLE pypi_cache_tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- What to cache
package_name VARCHAR(255) NOT NULL,
version_constraint VARCHAR(255),
-- Origin tracking
parent_task_id UUID REFERENCES pypi_cache_tasks(id) ON DELETE SET NULL,
depth INTEGER NOT NULL DEFAULT 0,
triggered_by_artifact VARCHAR(64) REFERENCES artifacts(id) ON DELETE SET NULL,
-- Status
status VARCHAR(20) NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 3,
-- Results
cached_artifact_id VARCHAR(64) REFERENCES artifacts(id) ON DELETE SET NULL,
error_message TEXT,
-- Timing
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
started_at TIMESTAMP WITH TIME ZONE,
completed_at TIMESTAMP WITH TIME ZONE,
next_retry_at TIMESTAMP WITH TIME ZONE,
-- Constraints
CONSTRAINT check_task_status CHECK (status IN ('pending', 'in_progress', 'completed', 'failed')),
CONSTRAINT check_depth_non_negative CHECK (depth >= 0),
CONSTRAINT check_attempts_non_negative CHECK (attempts >= 0)
);
-- Index for finding tasks ready to process (pending with retry time passed)
CREATE INDEX idx_pypi_cache_tasks_status_retry ON pypi_cache_tasks(status, next_retry_at);
-- Index for deduplication check (is this package already queued?)
CREATE INDEX idx_pypi_cache_tasks_package_status ON pypi_cache_tasks(package_name, status);
-- Index for tracing dependency chains
CREATE INDEX idx_pypi_cache_tasks_parent ON pypi_cache_tasks(parent_task_id);
-- Index for finding tasks by artifact that triggered them
CREATE INDEX idx_pypi_cache_tasks_triggered_by ON pypi_cache_tasks(triggered_by_artifact);
-- Index for finding tasks by cached artifact
CREATE INDEX idx_pypi_cache_tasks_cached_artifact ON pypi_cache_tasks(cached_artifact_id);
-- Index for sorting by depth and creation time (processing order)
CREATE INDEX idx_pypi_cache_tasks_depth_created ON pypi_cache_tasks(depth, created_at);