Fix PyPI proxy UX and package stats calculation

- Fix artifact_count and total_size calculation to use Tags instead of
  Uploads, so PyPI cached packages show their stats correctly
- Fix PackagePage dropdown menu positioning (use fixed position with backdrop)
- Add system project detection for projects starting with "_"
- Show Version as primary column for system projects, hide Tag column
- Hide upload button for system projects (they're cache-only)
- Rename section header to "Versions" for system projects
- Fix test_projects_sort_by_name to exclude system projects from sort comparison
This commit is contained in:
Mondo Diaz
2026-01-30 12:16:05 -06:00
parent 701e11ce83
commit fe6c6c52d2
4 changed files with 238 additions and 111 deletions

View File

@@ -2827,14 +2827,15 @@ 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 uploads # Get unique artifact count and total size via tags
# (PyPI proxy creates tags without uploads, so query from tags)
artifact_stats = ( artifact_stats = (
db.query( db.query(
func.count(func.distinct(Upload.artifact_id)), func.count(func.distinct(Tag.artifact_id)),
func.coalesce(func.sum(Artifact.size), 0), func.coalesce(func.sum(Artifact.size), 0),
) )
.join(Artifact, Upload.artifact_id == Artifact.id) .join(Artifact, Tag.artifact_id == Artifact.id)
.filter(Upload.package_id == pkg.id) .filter(Tag.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
@@ -2930,14 +2931,15 @@ 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 uploads # Get unique artifact count and total size via tags
# (PyPI proxy creates tags without uploads, so query from tags)
artifact_stats = ( artifact_stats = (
db.query( db.query(
func.count(func.distinct(Upload.artifact_id)), func.count(func.distinct(Tag.artifact_id)),
func.coalesce(func.sum(Artifact.size), 0), func.coalesce(func.sum(Artifact.size), 0),
) )
.join(Artifact, Upload.artifact_id == Artifact.id) .join(Artifact, Tag.artifact_id == Artifact.id)
.filter(Upload.package_id == pkg.id) .filter(Tag.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
@@ -6280,14 +6282,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 uploads # Artifact stats via tags (tags exist for both user uploads and PyPI proxy)
artifact_stats = ( artifact_stats = (
db.query( db.query(
func.count(func.distinct(Upload.artifact_id)), func.count(func.distinct(Tag.artifact_id)),
func.coalesce(func.sum(Artifact.size), 0), func.coalesce(func.sum(Artifact.size), 0),
) )
.join(Artifact, Upload.artifact_id == Artifact.id) .join(Artifact, Tag.artifact_id == Artifact.id)
.filter(Upload.package_id == package.id) .filter(Tag.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

@@ -128,7 +128,9 @@ class TestProjectListingFilters:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
names = [p["name"] for p in data["items"]] # Filter out system projects (names starting with "_") as they may have
# 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

@@ -849,13 +849,20 @@ tr:hover .copy-btn {
position: relative; position: relative;
} }
.action-menu-dropdown { /* Action menu backdrop for click-outside */
position: absolute; .action-menu-backdrop {
top: 100%; position: fixed;
top: 0;
left: 0;
right: 0; right: 0;
z-index: 100; bottom: 0;
z-index: 999;
}
.action-menu-dropdown {
position: fixed;
z-index: 1000;
min-width: 180px; min-width: 180px;
margin-top: 4px;
padding: 4px 0; padding: 4px 0;
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border-primary); border: 1px solid var(--border-primary);

View File

@@ -72,6 +72,7 @@ function PackagePage() {
const [showUploadModal, setShowUploadModal] = useState(false); const [showUploadModal, setShowUploadModal] = useState(false);
const [showCreateTagModal, setShowCreateTagModal] = useState(false); const [showCreateTagModal, setShowCreateTagModal] = useState(false);
const [openMenuId, setOpenMenuId] = useState<string | null>(null); 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);
@@ -100,6 +101,9 @@ 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') || '';
@@ -327,100 +331,209 @@ function PackagePage() {
setSelectedTag(tag); setSelectedTag(tag);
}; };
const columns = [ const handleMenuOpen = (e: React.MouseEvent, tagId: string) => {
{ e.stopPropagation();
key: 'name', if (openMenuId === tagId) {
header: 'Tag / Version', setOpenMenuId(null);
sortable: true, setMenuPosition(null);
render: (t: TagDetail) => ( } else {
<div className="tag-version-cell"> 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' }} };
>
{t.name} // System projects show Version first, regular projects show Tag first
</strong> const columns = isSystemProject
{t.version && <span className="version-badge">{t.version}</span>} ? [
</div> // System project columns: Version first, then Filename
), {
}, key: 'version',
{ header: 'Version',
key: 'artifact_original_name', sortable: true,
header: 'Filename', render: (t: TagDetail) => (
className: 'cell-truncate', <strong
render: (t: TagDetail) => ( className={`tag-name-link ${selectedTag?.id === t.id ? 'selected' : ''}`}
<span title={t.artifact_original_name || undefined}>{t.artifact_original_name || '-'}</span> onClick={() => handleTagSelect(t)}
), style={{ cursor: 'pointer' }}
},
{
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>
<div className="action-menu">
<button
className="btn btn-icon"
onClick={(e) => {
e.stopPropagation();
setOpenMenuId(openMenuId === t.id ? null : t.id);
}}
title="More actions"
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <span className="version-badge">{t.version || t.name}</span>
<circle cx="12" cy="12" r="1" /> </strong>
<circle cx="12" cy="5" r="1" /> ),
<circle cx="12" cy="19" r="1" /> },
</svg> {
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={() => { navigator.clipboard.writeText(t.artifact_id); setOpenMenuId(null); setMenuPosition(null); }}>
Copy Artifact ID
</button>
<button onClick={() => { fetchEnsureFileForTag(t.name); setOpenMenuId(null); setMenuPosition(null); }}>
View Ensure File
</button>
{canWrite && (
<button onClick={() => { setCreateTagArtifactId(t.artifact_id); setShowCreateTagModal(true); setOpenMenuId(null); setMenuPosition(null); }}>
Create/Update Tag
</button> </button>
{openMenuId === t.id && ( )}
<div className="action-menu-dropdown"> <button onClick={() => { handleTagSelect(t); setOpenMenuId(null); setMenuPosition(null); }}>
<button onClick={() => { navigator.clipboard.writeText(t.artifact_id); setOpenMenuId(null); }}> View Dependencies
Copy Artifact ID </button>
</button>
<button onClick={() => { fetchEnsureFileForTag(t.name); setOpenMenuId(null); }}>
View Ensure File
</button>
{canWrite && (
<button onClick={() => { setCreateTagArtifactId(t.artifact_id); setShowCreateTagModal(true); setOpenMenuId(null); }}>
Create/Update Tag
</button>
)}
<button onClick={() => { handleTagSelect(t); setOpenMenuId(null); }}>
View Dependencies
</button>
</div>
)}
</div>
</div> </div>
), </div>
}, );
]; };
if (loading && !tagsData) { if (loading && !tagsData) {
return <div className="loading">Loading...</div>; return <div className="loading">Loading...</div>;
@@ -463,7 +576,7 @@ 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 && ( {user && canWrite && !isSystemProject && (
<button <button
className="btn btn-primary btn-small header-upload-btn" className="btn btn-primary btn-small header-upload-btn"
onClick={() => setShowUploadModal(true)} onClick={() => setShowUploadModal(true)}
@@ -523,7 +636,7 @@ function PackagePage() {
<div className="section-header"> <div className="section-header">
<h2>Tags / Versions</h2> <h2>{isSystemProject ? 'Versions' : 'Tags / Versions'}</h2>
</div> </div>
<div className="list-controls"> <div className="list-controls">
@@ -902,6 +1015,9 @@ function PackagePage() {
</div> </div>
</div> </div>
)} )}
{/* Action Menu Dropdown */}
{renderActionMenu()}
</div> </div>
); );
} }