From f3afdd3bbfd54e636024b323f1c88aec06f0a828 Mon Sep 17 00:00:00 2001 From: Mondo Diaz Date: Fri, 30 Jan 2026 11:52:37 -0600 Subject: [PATCH] Improve PyPI proxy and Package page UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PyPI proxy improvements: - Set package format to "pypi" instead of "generic" - Extract version from filename and create PackageVersion record - Support .whl, .tar.gz, and .zip filename formats Package page UX overhaul: - Move upload to header button with modal - Simplify table: combine Tag/Version, remove Type and Artifact ID columns - Add row action menu (⋯) with: Copy ID, Ensure File, Create Tag, Dependencies - Remove cluttered "Download by Artifact ID" and "Create/Update Tag" sections - Add modals for upload and create tag actions - Cleaner, more scannable table layout --- backend/app/pypi_proxy.py | 50 ++++- frontend/src/pages/PackagePage.css | 129 +++++++++++ frontend/src/pages/PackagePage.tsx | 346 ++++++++++++++++------------- 3 files changed, 366 insertions(+), 159 deletions(-) diff --git a/backend/app/pypi_proxy.py b/backend/app/pypi_proxy.py index 7d59c48..3f18e67 100644 --- a/backend/app/pypi_proxy.py +++ b/backend/app/pypi_proxy.py @@ -17,7 +17,7 @@ from fastapi.responses import StreamingResponse, HTMLResponse from sqlalchemy.orm import Session from .database import get_db -from .models import UpstreamSource, CachedUrl, Artifact, Project, Package, Tag +from .models import UpstreamSource, CachedUrl, Artifact, Project, Package, Tag, PackageVersion from .storage import S3Storage, get_storage from .config import get_env_upstream_sources @@ -30,6 +30,36 @@ PROXY_CONNECT_TIMEOUT = 30.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]: """Get all enabled upstream sources configured for PyPI.""" # Get database sources @@ -507,6 +537,7 @@ async def pypi_download_file( project_id=system_project.id, name=normalized_name, description=f"PyPI package: {normalized_name}", + format="pypi", ) db.add(package) db.flush() @@ -525,6 +556,23 @@ async def pypi_download_file( ) db.add(tag) + # Extract and create version + version = _extract_pypi_version(filename) + if version: + existing_version = db.query(PackageVersion).filter( + PackageVersion.package_id == package.id, + PackageVersion.artifact_id == sha256, + ).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 existing_cached = db.query(CachedUrl).filter(CachedUrl.url_hash == url_hash).first() if not existing_cached: diff --git a/frontend/src/pages/PackagePage.css b/frontend/src/pages/PackagePage.css index 127c25e..2c1fe8a 100644 --- a/frontend/src/pages/PackagePage.css +++ b/frontend/src/pages/PackagePage.css @@ -793,4 +793,133 @@ tr:hover .copy-btn { .ensure-file-modal { 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-dropdown { + position: absolute; + top: 100%; + right: 0; + z-index: 100; + min-width: 180px; + margin-top: 4px; + 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); } diff --git a/frontend/src/pages/PackagePage.tsx b/frontend/src/pages/PackagePage.tsx index 30db759..1565e7f 100644 --- a/frontend/src/pages/PackagePage.tsx +++ b/frontend/src/pages/PackagePage.tsx @@ -63,12 +63,16 @@ function PackagePage() { const [accessDenied, setAccessDenied] = useState(false); const [uploadTag, setUploadTag] = useState(''); const [uploadSuccess, setUploadSuccess] = useState(null); - const [artifactIdInput, setArtifactIdInput] = useState(''); const [accessLevel, setAccessLevel] = useState(null); const [createTagName, setCreateTagName] = useState(''); const [createTagArtifactId, setCreateTagArtifactId] = useState(''); const [createTagLoading, setCreateTagLoading] = useState(false); + // UI state + const [showUploadModal, setShowUploadModal] = useState(false); + const [showCreateTagModal, setShowCreateTagModal] = useState(false); + const [openMenuId, setOpenMenuId] = useState(null); + // Dependencies state const [selectedTag, setSelectedTag] = useState(null); const [dependencies, setDependencies] = useState([]); @@ -326,47 +330,21 @@ function PackagePage() { const columns = [ { key: 'name', - header: 'Tag', + header: 'Tag / Version', sortable: true, render: (t: TagDetail) => ( - handleTagSelect(t)} - style={{ cursor: 'pointer' }} - > - {t.name} - - ), - }, - { - key: 'version', - header: 'Version', - render: (t: TagDetail) => ( - {t.version || '-'} - ), - }, - { - key: 'artifact_id', - header: 'Artifact ID', - render: (t: TagDetail) => ( -
- {t.artifact_id.substring(0, 12)}... - +
+ handleTagSelect(t)} + style={{ cursor: 'pointer' }} + > + {t.name} + + {t.version && {t.version}}
), }, - { - key: 'artifact_size', - header: 'Size', - render: (t: TagDetail) => {formatBytes(t.artifact_size)}, - }, - { - key: 'artifact_content_type', - header: 'Type', - render: (t: TagDetail) => ( - {t.artifact_content_type || '-'} - ), - }, { key: 'artifact_original_name', header: 'Filename', @@ -375,36 +353,70 @@ function PackagePage() { {t.artifact_original_name || '-'} ), }, + { + key: 'artifact_size', + header: 'Size', + render: (t: TagDetail) => {formatBytes(t.artifact_size)}, + }, { key: 'created_at', header: 'Created', sortable: true, render: (t: TagDetail) => ( -
- {new Date(t.created_at).toLocaleString()} - by {t.created_by} -
+ {new Date(t.created_at).toLocaleDateString()} ), }, { key: 'actions', - header: 'Actions', + header: '', render: (t: TagDetail) => (
- - Download + + + + + +
+ + {openMenuId === t.id && ( +
+ + + {canWrite && ( + + )} + +
+ )} +
), }, @@ -451,6 +463,19 @@ function PackagePage() {

{packageName}

{pkg && {pkg.format}} + {user && canWrite && ( + + )}
{pkg?.description &&

{pkg.description}

}
@@ -496,41 +521,6 @@ function PackagePage() { {error &&
{error}
} {uploadSuccess &&
{uploadSuccess}
} - {user && ( -
-

Upload Artifact

- {canWrite ? ( -
-
- - setUploadTag(e.target.value)} - placeholder="v1.0.0, latest, stable..." - /> -
- -
- ) : ( - - )} -
- )}

Tags / Versions

@@ -737,78 +727,6 @@ function PackagePage() { )}
-
-

Download by Artifact ID

-
- 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" - /> - { - if (artifactIdInput.length !== 64) { - e.preventDefault(); - } - }} - > - Download - -
- {artifactIdInput.length > 0 && artifactIdInput.length !== 64 && ( -

Artifact ID must be exactly 64 hex characters ({artifactIdInput.length}/64)

- )} -
- - {user && canWrite && ( -
-

Create / Update Tag

-

Point a tag at any existing artifact by its ID

-
-
-
- - setCreateTagName(e.target.value)} - placeholder="latest, stable, v1.0.0..." - disabled={createTagLoading} - /> -
-
- - 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 && ( -

Artifact ID must be exactly 64 hex characters ({createTagArtifactId.length}/64)

- )} -
-
- )} -

Usage

Download artifacts using:

@@ -831,6 +749,118 @@ function PackagePage() { /> )} + {/* Upload Modal */} + {showUploadModal && ( +
setShowUploadModal(false)}> +
e.stopPropagation()}> +
+

Upload Artifact

+ +
+
+
+ + setUploadTag(e.target.value)} + placeholder="v1.0.0, latest, stable..." + /> +
+ { + handleUploadComplete(result); + setShowUploadModal(false); + setUploadTag(''); + }} + onUploadError={handleUploadError} + /> +
+
+
+ )} + + {/* Create/Update Tag Modal */} + {showCreateTagModal && ( +
setShowCreateTagModal(false)}> +
e.stopPropagation()}> +
+

Create / Update Tag

+ +
+
+

Point a tag at an artifact by its ID

+
{ handleCreateTag(e); setShowCreateTagModal(false); }}> +
+ + setCreateTagName(e.target.value)} + placeholder="latest, stable, v1.0.0..." + disabled={createTagLoading} + /> +
+
+ + 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 && ( +

{createTagArtifactId.length}/64 characters

+ )} +
+
+ + +
+
+
+
+
+ )} + {/* Ensure File Modal */} {showEnsureFile && (
setShowEnsureFile(false)}>