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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user