Replace project cards with sortable data table on Home page

This commit is contained in:
Mondo Diaz
2026-01-15 14:17:56 -06:00
parent f3a817f8a5
commit 5d5a054452
17 changed files with 446 additions and 198 deletions

View File

@@ -35,7 +35,7 @@ kics:
image: deps.global.bsf.tools/docker/python:3.12-slim image: deps.global.bsf.tools/docker/python:3.12-slim
timeout: 10m timeout: 10m
before_script: before_script:
- pip install httpx - pip install --index-url "$PIP_INDEX_URL" httpx
script: script:
- | - |
python - <<'PYTEST_SCRIPT' python - <<'PYTEST_SCRIPT'
@@ -175,7 +175,7 @@ frontend_tests:
# Shared deploy configuration # Shared deploy configuration
.deploy_template: &deploy_template .deploy_template: &deploy_template
stage: deploy stage: deploy
needs: [build_image, kics, hadolint, python_tests, frontend_tests] needs: [build_image, kics, hadolint, python_tests, frontend_tests, secrets]
image: deps.global.bsf.tools/registry-1.docker.io/alpine/k8s:1.29.12 image: deps.global.bsf.tools/registry-1.docker.io/alpine/k8s:1.29.12
.helm_setup: &helm_setup .helm_setup: &helm_setup
@@ -245,6 +245,7 @@ deploy_stage:
-f $VALUES_FILE \ -f $VALUES_FILE \
--set image.tag=git.linux-amd64-$CI_COMMIT_SHA \ --set image.tag=git.linux-amd64-$CI_COMMIT_SHA \
--wait \ --wait \
--atomic \
--timeout 5m --timeout 5m
- kubectl rollout status deployment/orchard-stage-server -n $NAMESPACE --timeout=5m - kubectl rollout status deployment/orchard-stage-server -n $NAMESPACE --timeout=5m
- *verify_deployment - *verify_deployment
@@ -280,6 +281,7 @@ deploy_feature:
--set minioIngress.host=minio-$CI_COMMIT_REF_SLUG.common.global.bsf.tools \ --set minioIngress.host=minio-$CI_COMMIT_REF_SLUG.common.global.bsf.tools \
--set minioIngress.tls.secretName=minio-$CI_COMMIT_REF_SLUG-tls \ --set minioIngress.tls.secretName=minio-$CI_COMMIT_REF_SLUG-tls \
--wait \ --wait \
--atomic \
--timeout 5m --timeout 5m
- kubectl rollout status deployment/orchard-$CI_COMMIT_REF_SLUG-server -n $NAMESPACE --timeout=5m - kubectl rollout status deployment/orchard-$CI_COMMIT_REF_SLUG-server -n $NAMESPACE --timeout=5m
- export BASE_URL="https://orchard-$CI_COMMIT_REF_SLUG.common.global.bsf.tools" - export BASE_URL="https://orchard-$CI_COMMIT_REF_SLUG.common.global.bsf.tools"

View File

@@ -5,3 +5,9 @@
# These are historical commits - files have since been deleted or updated with inline comments # These are historical commits - files have since been deleted or updated with inline comments
7e68baed0886a3c928644cd01aa3b39f92d4f976:backend/tests/test_duplicate_detection.py:generic-api-key:154 7e68baed0886a3c928644cd01aa3b39f92d4f976:backend/tests/test_duplicate_detection.py:generic-api-key:154
2f1891cf0126ec0e7d4c789d872a2cb2dd3a1745:backend/tests/unit/test_storage.py:generic-api-key:381 2f1891cf0126ec0e7d4c789d872a2cb2dd3a1745:backend/tests/unit/test_storage.py:generic-api-key:381
10d36947948de796f0bacea3827f4531529c405d:backend/tests/unit/test_storage.py:generic-api-key:381
bccbc71c13570d14b8b26a11335c45f102fe3072:backend/tests/unit/test_storage.py:generic-api-key:381
5c9da9003b844a2d655cce74a7c82c57e74f27c4:backend/tests/unit/test_storage.py:generic-api-key:381
90bb2a3a393d2361dc3136ee8d761debb0726d8a:backend/tests/unit/test_storage.py:generic-api-key:381
37666e41a72d2a4f34447c0d1a8728e1d7271d24:backend/tests/unit/test_storage.py:generic-api-key:381
0cc4f253621a9601c5193f6ae1e7ae33f0e7fc9b:backend/tests/unit/test_storage.py:generic-api-key:381

View File

@@ -16,13 +16,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added internal proxy configuration for npm, pip, helm, and apt (#51) - Added internal proxy configuration for npm, pip, helm, and apt (#51)
### Changed ### Changed
- Added `--atomic` flag to Helm deployments for automatic rollback on failure
- Adjusted dark mode color palette to use lighter background tones for better readability and reduced eye strain (#52) - Adjusted dark mode color palette to use lighter background tones for better readability and reduced eye strain (#52)
- Replaced project card grid with sortable data table on Home page for better handling of large project lists
- Replaced package card grid with sortable data table on Project page for consistency
- Replaced SortDropdown with table header sorting on Package page for consistency
- Enabled sorting on supported table columns (name, created, updated) via clickable headers
- Updated browser tab title to "Orchard" with custom favicon
- Improved pod naming: Orchard pods now named `orchard-{env}-server-*` for clarity (#51) - Improved pod naming: Orchard pods now named `orchard-{env}-server-*` for clarity (#51)
### Fixed ### Fixed
- Fixed `cleanup_feature` job failing when branch is deleted (`GIT_STRATEGY: none`) (#51) - Fixed `cleanup_feature` job failing when branch is deleted (`GIT_STRATEGY: none`) (#51)
- Fixed gitleaks false positives with fingerprints for historical commits (#51) - Fixed gitleaks false positives with fingerprints for historical commits (#51)
- Fixed integration tests running when deploy fails (`when: on_success`) (#51) - Fixed integration tests running when deploy fails (`when: on_success`) (#51)
- Fixed static file serving for favicon and other files in frontend dist root
- Fixed deploy jobs running when secrets scan fails (added `secrets` to deploy dependencies)
- Fixed dev environment memory requests to equal limits per cluster Kyverno policy
- Fixed init containers missing resource limits (Kyverno policy compliance)
### Removed ### Removed
- Removed unused `store_streaming()` method from storage.py (#51) - Removed unused `store_streaming()` method from storage.py (#51)

View File

@@ -88,6 +88,11 @@ if os.path.exists(static_dir):
raise HTTPException(status_code=404, detail="Not found") raise HTTPException(status_code=404, detail="Not found")
# Check if requesting a static file from dist root (favicon, etc.)
static_file_path = os.path.join(static_dir, full_path)
if os.path.isfile(static_file_path) and not full_path.startswith("."):
return FileResponse(static_file_path)
# Serve SPA for all other routes (including /project/*) # Serve SPA for all other routes (including /project/*)
index_path = os.path.join(static_dir, "index.html") index_path = os.path.join(static_dir, "index.html")
if os.path.exists(index_path): if os.path.exists(index_path):

View File

@@ -6,7 +6,7 @@ services:
context: . context: .
dockerfile: Dockerfile.local dockerfile: Dockerfile.local
ports: ports:
- "127.0.0.1:8080:8080" - "0.0.0.0:8080:8080"
environment: environment:
- ORCHARD_SERVER_HOST=0.0.0.0 - ORCHARD_SERVER_HOST=0.0.0.0
- ORCHARD_SERVER_PORT=8080 - ORCHARD_SERVER_PORT=8080
@@ -71,10 +71,6 @@ services:
networks: networks:
- orchard-network - orchard-network
restart: unless-stopped restart: unless-stopped
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
deploy: deploy:
resources: resources:
limits: limits:
@@ -100,10 +96,6 @@ services:
networks: networks:
- orchard-network - orchard-network
restart: unless-stopped restart: unless-stopped
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
deploy: deploy:
resources: resources:
limits: limits:
@@ -124,10 +116,6 @@ services:
" "
networks: networks:
- orchard-network - orchard-network
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
deploy: deploy:
resources: resources:
limits: limits:
@@ -149,10 +137,6 @@ services:
networks: networks:
- orchard-network - orchard-network
restart: unless-stopped restart: unless-stopped
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
deploy: deploy:
resources: resources:
limits: limits:

View File

@@ -2,9 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/orchard.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Orchard - Content-Addressable Storage</title> <title>Orchard</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -0,0 +1,18 @@
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Green background -->
<rect width="24" height="24" rx="4" fill="#4CAF50"/>
<!-- Three fruit trees representing an orchard - shifted down to center -->
<g transform="translate(0, 2)">
<!-- Left tree - rounded canopy -->
<path d="M6 14 Q6 8 3 8 Q6 4 6 4 Q6 4 9 8 Q6 8 6 14" fill="white" opacity="0.7"/>
<rect x="5.25" y="13" width="1.5" height="4" fill="white" opacity="0.7"/>
<!-- Center tree - larger rounded canopy -->
<path d="M12 12 Q12 5 8 5 Q12 1 12 1 Q12 1 16 5 Q12 5 12 12" fill="white"/>
<rect x="11.25" y="11" width="1.5" height="5" fill="white"/>
<!-- Right tree - rounded canopy -->
<path d="M18 14 Q18 8 15 8 Q18 4 18 4 Q18 4 21 8 Q18 8 18 14" fill="white" opacity="0.7"/>
<rect x="17.25" y="13" width="1.5" height="4" fill="white" opacity="0.7"/>
<!-- Ground -->
<ellipse cx="12" cy="18" rx="8" ry="1.5" fill="white" opacity="0.4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1012 B

View File

@@ -98,3 +98,58 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
/* Clickable rows */
.data-table__row--clickable {
cursor: pointer;
}
.data-table__row--clickable:hover {
background: var(--bg-hover);
}
/* Responsive table wrapper */
.data-table--responsive {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.data-table--responsive table {
min-width: 800px;
}
/* Cell with name and icon */
.data-table .cell-name {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
color: var(--text-primary);
}
.data-table .cell-name:hover {
color: var(--accent-primary);
}
/* Date cells */
.data-table .cell-date {
color: var(--text-tertiary);
font-size: 0.8125rem;
white-space: nowrap;
}
/* Description cell */
.data-table .cell-description {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-secondary);
font-size: 0.875rem;
}
/* Owner cell */
.data-table .cell-owner {
color: var(--text-secondary);
font-size: 0.875rem;
}

View File

@@ -18,6 +18,7 @@ interface DataTableProps<T> {
onSort?: (key: string) => void; onSort?: (key: string) => void;
sortKey?: string; sortKey?: string;
sortOrder?: 'asc' | 'desc'; sortOrder?: 'asc' | 'desc';
onRowClick?: (item: T) => void;
} }
export function DataTable<T>({ export function DataTable<T>({
@@ -29,6 +30,7 @@ export function DataTable<T>({
onSort, onSort,
sortKey, sortKey,
sortOrder, sortOrder,
onRowClick,
}: DataTableProps<T>) { }: DataTableProps<T>) {
if (data.length === 0) { if (data.length === 0) {
return ( return (
@@ -71,7 +73,11 @@ export function DataTable<T>({
</thead> </thead>
<tbody> <tbody>
{data.map((item) => ( {data.map((item) => (
<tr key={keyExtractor(item)}> <tr
key={keyExtractor(item)}
onClick={() => onRowClick?.(item)}
className={onRowClick ? 'data-table__row--clickable' : ''}
>
{columns.map((column) => ( {columns.map((column) => (
<td key={column.key} className={column.className}> <td key={column.key} className={column.className}>
{column.render(item)} {column.render(item)}

View File

@@ -1,6 +1,6 @@
/* Page Layout */ /* Page Layout */
.home { .home {
max-width: 1000px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }

View File

@@ -1,9 +1,9 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Link, useSearchParams } from 'react-router-dom'; import { Link, useSearchParams, useNavigate } from 'react-router-dom';
import { Project, PaginatedResponse } from '../types'; import { Project, PaginatedResponse } from '../types';
import { listProjects, createProject } from '../api'; import { listProjects, createProject } from '../api';
import { Badge } from '../components/Badge'; import { Badge } from '../components/Badge';
import { SortDropdown, SortOption } from '../components/SortDropdown'; import { DataTable } from '../components/DataTable';
import { FilterDropdown, FilterOption } from '../components/FilterDropdown'; import { FilterDropdown, FilterOption } from '../components/FilterDropdown';
import { FilterChip, FilterChipGroup } from '../components/FilterChip'; import { FilterChip, FilterChipGroup } from '../components/FilterChip';
import { Pagination } from '../components/Pagination'; import { Pagination } from '../components/Pagination';
@@ -20,12 +20,6 @@ function LockIcon() {
); );
} }
const SORT_OPTIONS: SortOption[] = [
{ value: 'name', label: 'Name' },
{ value: 'created_at', label: 'Created' },
{ value: 'updated_at', label: 'Updated' },
];
const VISIBILITY_OPTIONS: FilterOption[] = [ const VISIBILITY_OPTIONS: FilterOption[] = [
{ value: '', label: 'All Projects' }, { value: '', label: 'All Projects' },
{ value: 'public', label: 'Public Only' }, { value: 'public', label: 'Public Only' },
@@ -34,6 +28,7 @@ const VISIBILITY_OPTIONS: FilterOption[] = [
function Home() { function Home() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
const [projectsData, setProjectsData] = useState<PaginatedResponse<Project> | null>(null); const [projectsData, setProjectsData] = useState<PaginatedResponse<Project> | null>(null);
@@ -101,8 +96,10 @@ function Home() {
} }
} }
const handleSortChange = (newSort: string, newOrder: 'asc' | 'desc') => { const handleSortChange = (columnKey: string) => {
updateParams({ sort: newSort, order: newOrder, page: '1' }); // Toggle order if clicking the same column, otherwise default to asc
const newOrder = columnKey === sort ? (order === 'asc' ? 'desc' : 'asc') : 'asc';
updateParams({ sort: columnKey, order: newOrder, page: '1' });
}; };
const handleVisibilityChange = (value: string) => { const handleVisibilityChange = (value: string) => {
@@ -189,7 +186,6 @@ function Home() {
value={visibility} value={visibility}
onChange={handleVisibilityChange} onChange={handleVisibilityChange}
/> />
<SortDropdown options={SORT_OPTIONS} value={sort} order={order} onChange={handleSortChange} />
</div> </div>
{hasActiveFilters && ( {hasActiveFilters && (
@@ -204,69 +200,106 @@ function Home() {
</FilterChipGroup> </FilterChipGroup>
)} )}
{projects.length === 0 ? ( <div className="data-table--responsive">
<div className="empty-state"> <DataTable
{hasActiveFilters ? ( data={projects}
<p>No projects match your filters. Try adjusting your search.</p> keyExtractor={(project) => project.id}
) : ( onRowClick={(project) => navigate(`/project/${project.name}`)}
<p>No projects yet. Create your first project to get started!</p> onSort={handleSortChange}
)} sortKey={sort}
</div> sortOrder={order}
) : ( emptyMessage={
<> hasActiveFilters
<div className="project-grid"> ? 'No projects match your filters. Try adjusting your search.'
{projects.map((project) => ( : 'No projects yet. Create your first project to get started!'
<Link to={`/project/${project.name}`} key={project.id} className="project-card card"> }
<h3> columns={[
{
key: 'name',
header: 'Name',
sortable: true,
render: (project) => (
<span className="cell-name">
{!project.is_public && <LockIcon />} {!project.is_public && <LockIcon />}
{project.name} {project.name}
</h3> </span>
{project.description && <p>{project.description}</p>} ),
<div className="project-meta"> },
<div className="project-badges"> {
<Badge variant={project.is_public ? 'public' : 'private'}> key: 'description',
{project.is_public ? 'Public' : 'Private'} header: 'Description',
</Badge> className: 'cell-description',
{user && project.access_level && ( render: (project) => project.description || '—',
<Badge },
variant={ {
project.is_owner key: 'visibility',
? 'success' header: 'Visibility',
: project.access_level === 'admin' render: (project) => (
<Badge variant={project.is_public ? 'public' : 'private'}>
{project.is_public ? 'Public' : 'Private'}
</Badge>
),
},
{
key: 'created_by',
header: 'Owner',
className: 'cell-owner',
render: (project) => project.created_by,
},
...(user
? [
{
key: 'access_level',
header: 'Access',
render: (project: Project) =>
project.access_level ? (
<Badge
variant={
project.is_owner
? 'success' ? 'success'
: project.access_level === 'write' : project.access_level === 'admin'
? 'info' ? 'success'
: 'default' : project.access_level === 'write'
} ? 'info'
> : 'default'
{project.is_owner ? 'Owner' : project.access_level.charAt(0).toUpperCase() + project.access_level.slice(1)} }
</Badge> >
)} {project.is_owner
</div> ? 'Owner'
<div className="project-meta__dates"> : project.access_level.charAt(0).toUpperCase() + project.access_level.slice(1)}
<span className="date">Created {new Date(project.created_at).toLocaleDateString()}</span> </Badge>
{project.updated_at !== project.created_at && ( ) : (
<span className="date">Updated {new Date(project.updated_at).toLocaleDateString()}</span> '—'
)} ),
</div> },
</div> ]
<div className="project-meta__owner"> : []),
<span className="owner">by {project.created_by}</span> {
</div> key: 'created_at',
</Link> header: 'Created',
))} sortable: true,
</div> className: 'cell-date',
render: (project) => new Date(project.created_at).toLocaleDateString(),
},
{
key: 'updated_at',
header: 'Updated',
sortable: true,
className: 'cell-date',
render: (project) => new Date(project.updated_at).toLocaleDateString(),
},
]}
/>
</div>
{pagination && pagination.total_pages > 1 && ( {pagination && pagination.total_pages > 1 && (
<Pagination <Pagination
page={pagination.page} page={pagination.page}
totalPages={pagination.total_pages} totalPages={pagination.total_pages}
total={pagination.total} total={pagination.total}
limit={pagination.limit} limit={pagination.limit}
onPageChange={handlePageChange} onPageChange={handlePageChange}
/> />
)}
</>
)} )}
</div> </div>
); );

View File

@@ -5,7 +5,6 @@ import { listTags, getDownloadUrl, getPackage, getMyProjectAccess, UnauthorizedE
import { Breadcrumb } from '../components/Breadcrumb'; import { Breadcrumb } from '../components/Breadcrumb';
import { Badge } from '../components/Badge'; import { Badge } from '../components/Badge';
import { SearchInput } from '../components/SearchInput'; import { SearchInput } from '../components/SearchInput';
import { SortDropdown, SortOption } from '../components/SortDropdown';
import { FilterChip, FilterChipGroup } from '../components/FilterChip'; import { FilterChip, FilterChipGroup } from '../components/FilterChip';
import { DataTable } from '../components/DataTable'; import { DataTable } from '../components/DataTable';
import { Pagination } from '../components/Pagination'; import { Pagination } from '../components/Pagination';
@@ -14,11 +13,6 @@ import { useAuth } from '../contexts/AuthContext';
import './Home.css'; import './Home.css';
import './PackagePage.css'; import './PackagePage.css';
const SORT_OPTIONS: SortOption[] = [
{ value: 'name', label: 'Name' },
{ value: 'created_at', label: 'Created' },
];
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
const k = 1024; const k = 1024;
@@ -164,8 +158,9 @@ function PackagePage() {
updateParams({ search: value, page: '1' }); updateParams({ search: value, page: '1' });
}; };
const handleSortChange = (newSort: string, newOrder: 'asc' | 'desc') => { const handleSortChange = (columnKey: string) => {
updateParams({ sort: newSort, order: newOrder, page: '1' }); const newOrder = columnKey === sort ? (order === 'asc' ? 'desc' : 'asc') : 'asc';
updateParams({ sort: columnKey, order: newOrder, page: '1' });
}; };
const handlePageChange = (newPage: number) => { const handlePageChange = (newPage: number) => {
@@ -198,19 +193,19 @@ function PackagePage() {
), ),
}, },
{ {
key: 'size', key: 'artifact_size',
header: 'Size', header: 'Size',
render: (t: TagDetail) => <span>{formatBytes(t.artifact_size)}</span>, render: (t: TagDetail) => <span>{formatBytes(t.artifact_size)}</span>,
}, },
{ {
key: 'content_type', key: 'artifact_content_type',
header: 'Type', header: 'Type',
render: (t: TagDetail) => ( render: (t: TagDetail) => (
<span className="content-type">{t.artifact_content_type || '-'}</span> <span className="content-type">{t.artifact_content_type || '-'}</span>
), ),
}, },
{ {
key: 'original_name', key: 'artifact_original_name',
header: 'Filename', header: 'Filename',
className: 'cell-truncate', className: 'cell-truncate',
render: (t: TagDetail) => ( render: (t: TagDetail) => (
@@ -376,7 +371,6 @@ function PackagePage() {
placeholder="Filter tags..." placeholder="Filter tags..."
className="list-controls__search" className="list-controls__search"
/> />
<SortDropdown options={SORT_OPTIONS} value={sort} order={order} onChange={handleSortChange} />
</div> </div>
{hasActiveFilters && ( {hasActiveFilters && (
@@ -385,25 +379,21 @@ function PackagePage() {
</FilterChipGroup> </FilterChipGroup>
)} )}
<DataTable <div className="data-table--responsive">
data={tags} <DataTable
columns={columns} data={tags}
keyExtractor={(t) => t.id} columns={columns}
emptyMessage={ keyExtractor={(t) => t.id}
hasActiveFilters emptyMessage={
? 'No tags match your filters. Try adjusting your search.' hasActiveFilters
: 'No tags yet. Upload an artifact with a tag to create one!' ? 'No tags match your filters. Try adjusting your search.'
} : 'No tags yet. Upload an artifact with a tag to create one!'
onSort={(key) => {
if (key === sort) {
handleSortChange(key, order === 'asc' ? 'desc' : 'asc');
} else {
handleSortChange(key, 'asc');
} }
}} onSort={handleSortChange}
sortKey={sort} sortKey={sort}
sortOrder={order} sortOrder={order}
/> />
</div>
{pagination && pagination.total_pages > 1 && ( {pagination && pagination.total_pages > 1 && (
<Pagination <Pagination

View File

@@ -1,23 +1,17 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams, Link, useSearchParams, useNavigate, useLocation } from 'react-router-dom'; import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
import { Project, Package, PaginatedResponse, AccessLevel } from '../types'; import { Project, Package, PaginatedResponse, AccessLevel } from '../types';
import { getProject, listPackages, createPackage, getMyProjectAccess, UnauthorizedError, ForbiddenError } from '../api'; import { getProject, listPackages, createPackage, getMyProjectAccess, UnauthorizedError, ForbiddenError } from '../api';
import { Breadcrumb } from '../components/Breadcrumb'; import { Breadcrumb } from '../components/Breadcrumb';
import { Badge } from '../components/Badge'; import { Badge } from '../components/Badge';
import { DataTable } from '../components/DataTable';
import { SearchInput } from '../components/SearchInput'; import { SearchInput } from '../components/SearchInput';
import { SortDropdown, SortOption } from '../components/SortDropdown';
import { FilterChip, FilterChipGroup } from '../components/FilterChip'; import { FilterChip, FilterChipGroup } from '../components/FilterChip';
import { Pagination } from '../components/Pagination'; import { Pagination } from '../components/Pagination';
import { AccessManagement } from '../components/AccessManagement'; import { AccessManagement } from '../components/AccessManagement';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import './Home.css'; import './Home.css';
const SORT_OPTIONS: SortOption[] = [
{ value: 'name', label: 'Name' },
{ value: 'created_at', label: 'Created' },
{ value: 'updated_at', label: 'Updated' },
];
const FORMAT_OPTIONS = ['generic', 'npm', 'pypi', 'docker', 'deb', 'rpm', 'maven', 'nuget', 'helm']; const FORMAT_OPTIONS = ['generic', 'npm', 'pypi', 'docker', 'deb', 'rpm', 'maven', 'nuget', 'helm'];
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
@@ -140,8 +134,9 @@ function ProjectPage() {
updateParams({ search: value, page: '1' }); updateParams({ search: value, page: '1' });
}; };
const handleSortChange = (newSort: string, newOrder: 'asc' | 'desc') => { const handleSortChange = (columnKey: string) => {
updateParams({ sort: newSort, order: newOrder, page: '1' }); const newOrder = columnKey === sort ? (order === 'asc' ? 'desc' : 'asc') : 'asc';
updateParams({ sort: columnKey, order: newOrder, page: '1' });
}; };
const handleFormatChange = (value: string) => { const handleFormatChange = (value: string) => {
@@ -294,7 +289,6 @@ function ProjectPage() {
</option> </option>
))} ))}
</select> </select>
<SortDropdown options={SORT_OPTIONS} value={sort} order={order} onChange={handleSortChange} />
</div> </div>
{hasActiveFilters && ( {hasActiveFilters && (
@@ -304,70 +298,78 @@ function ProjectPage() {
</FilterChipGroup> </FilterChipGroup>
)} )}
{packages.length === 0 ? ( <div className="data-table--responsive">
<div className="empty-state"> <DataTable
{hasActiveFilters ? ( data={packages}
<p>No packages match your filters. Try adjusting your search.</p> keyExtractor={(pkg) => pkg.id}
) : ( onRowClick={(pkg) => navigate(`/project/${projectName}/${pkg.name}`)}
<p>No packages yet. Create your first package to start uploading artifacts!</p> onSort={handleSortChange}
)} sortKey={sort}
</div> sortOrder={order}
) : ( emptyMessage={
<> hasActiveFilters
<div className="project-grid"> ? 'No packages match your filters. Try adjusting your search.'
{packages.map((pkg) => ( : 'No packages yet. Create your first package to start uploading artifacts!'
<Link to={`/project/${projectName}/${pkg.name}`} key={pkg.id} className="project-card card"> }
<div className="package-card__header"> columns={[
<h3>{pkg.name}</h3> {
<Badge variant="default">{pkg.format}</Badge> key: 'name',
</div> header: 'Name',
{pkg.description && <p>{pkg.description}</p>} sortable: true,
render: (pkg) => <span className="cell-name">{pkg.name}</span>,
},
{
key: 'description',
header: 'Description',
className: 'cell-description',
render: (pkg) => pkg.description || '—',
},
{
key: 'format',
header: 'Format',
render: (pkg) => <Badge variant="default">{pkg.format}</Badge>,
},
{
key: 'tag_count',
header: 'Tags',
render: (pkg) => pkg.tag_count ?? '—',
},
{
key: 'artifact_count',
header: 'Artifacts',
render: (pkg) => pkg.artifact_count ?? '—',
},
{
key: 'total_size',
header: 'Size',
render: (pkg) =>
pkg.total_size !== undefined && pkg.total_size > 0 ? formatBytes(pkg.total_size) : '—',
},
{
key: 'latest_tag',
header: 'Latest',
render: (pkg) =>
pkg.latest_tag ? <strong style={{ color: 'var(--accent-primary)' }}>{pkg.latest_tag}</strong> : '—',
},
{
key: 'created_at',
header: 'Created',
sortable: true,
className: 'cell-date',
render: (pkg) => new Date(pkg.created_at).toLocaleDateString(),
},
]}
/>
</div>
{(pkg.tag_count !== undefined || pkg.artifact_count !== undefined) && ( {pagination && pagination.total_pages > 1 && (
<div className="package-stats"> <Pagination
{pkg.tag_count !== undefined && ( page={pagination.page}
<div className="package-stats__item"> totalPages={pagination.total_pages}
<span className="package-stats__value">{pkg.tag_count}</span> total={pagination.total}
<span className="package-stats__label">Tags</span> limit={pagination.limit}
</div> onPageChange={handlePageChange}
)} />
{pkg.artifact_count !== undefined && (
<div className="package-stats__item">
<span className="package-stats__value">{pkg.artifact_count}</span>
<span className="package-stats__label">Artifacts</span>
</div>
)}
{pkg.total_size !== undefined && pkg.total_size > 0 && (
<div className="package-stats__item">
<span className="package-stats__value">{formatBytes(pkg.total_size)}</span>
<span className="package-stats__label">Size</span>
</div>
)}
</div>
)}
<div className="project-meta">
{pkg.latest_tag && (
<span className="latest-tag">
Latest: <strong>{pkg.latest_tag}</strong>
</span>
)}
<span className="date">Created {new Date(pkg.created_at).toLocaleDateString()}</span>
</div>
</Link>
))}
</div>
{pagination && pagination.total_pages > 1 && (
<Pagination
page={pagination.page}
totalPages={pagination.total_pages}
total={pagination.total}
limit={pagination.limit}
onPageChange={handlePageChange}
/>
)}
</>
)} )}
{canAdmin && projectName && ( {canAdmin && projectName && (

View File

@@ -37,12 +37,26 @@ spec:
image: "{{ .Values.initContainer.image.repository }}:{{ .Values.initContainer.image.tag }}" image: "{{ .Values.initContainer.image.repository }}:{{ .Values.initContainer.image.tag }}"
imagePullPolicy: {{ .Values.initContainer.image.pullPolicy }} imagePullPolicy: {{ .Values.initContainer.image.pullPolicy }}
command: ['sh', '-c', 'until nc -z {{ include "orchard.postgresql.host" . }} 5432; do echo waiting for database; sleep 2; done;'] command: ['sh', '-c', 'until nc -z {{ include "orchard.postgresql.host" . }} 5432; do echo waiting for database; sleep 2; done;']
resources:
limits:
cpu: 50m
memory: 32Mi
requests:
cpu: 10m
memory: 32Mi
{{- end }} {{- end }}
{{- if .Values.minio.enabled }} {{- if .Values.minio.enabled }}
- name: wait-for-minio - name: wait-for-minio
image: "{{ .Values.initContainer.image.repository }}:{{ .Values.initContainer.image.tag }}" image: "{{ .Values.initContainer.image.repository }}:{{ .Values.initContainer.image.tag }}"
imagePullPolicy: {{ .Values.initContainer.image.pullPolicy }} imagePullPolicy: {{ .Values.initContainer.image.pullPolicy }}
command: ['sh', '-c', 'until nc -z {{ .Release.Name }}-minio 9000; do echo waiting for minio; sleep 2; done;'] command: ['sh', '-c', 'until nc -z {{ .Release.Name }}-minio 9000; do echo waiting for minio; sleep 2; done;']
resources:
limits:
cpu: 50m
memory: 32Mi
requests:
cpu: 10m
memory: 32Mi
{{- end }} {{- end }}
containers: containers:
- name: {{ .Chart.Name }} - name: {{ .Chart.Name }}

View File

@@ -53,13 +53,14 @@ ingress:
- orchard-dev.common.global.bsf.tools # Overridden by CI - orchard-dev.common.global.bsf.tools # Overridden by CI
# Lighter resources for ephemeral environments # Lighter resources for ephemeral environments
# Note: memory requests must equal limits per cluster policy
resources: resources:
limits: limits:
cpu: 250m cpu: 250m
memory: 256Mi memory: 256Mi
requests: requests:
cpu: 100m cpu: 100m
memory: 128Mi memory: 256Mi
livenessProbe: livenessProbe:
httpGet: httpGet:
@@ -127,6 +128,25 @@ postgresql:
primary: primary:
persistence: persistence:
enabled: false enabled: false
# Resources with memory requests = limits per cluster policy
resourcesPreset: "none"
resources:
limits:
cpu: 250m
memory: 256Mi
requests:
cpu: 100m
memory: 256Mi
# Volume permissions init container
volumePermissions:
resourcesPreset: "none"
resources:
limits:
cpu: 50m
memory: 64Mi
requests:
cpu: 10m
memory: 64Mi
# MinIO - ephemeral, no persistence # MinIO - ephemeral, no persistence
minio: minio:
@@ -142,6 +162,35 @@ minio:
defaultBuckets: "orchard-artifacts" defaultBuckets: "orchard-artifacts"
persistence: persistence:
enabled: false enabled: false
# Resources with memory requests = limits per cluster policy
resourcesPreset: "none" # Disable preset to use explicit resources
resources:
limits:
cpu: 250m
memory: 256Mi
requests:
cpu: 100m
memory: 256Mi
# Init container resources
defaultInitContainers:
volumePermissions:
resourcesPreset: "none"
resources:
limits:
cpu: 50m
memory: 64Mi
requests:
cpu: 10m
memory: 64Mi
# Provisioning job resources
provisioning:
resources:
limits:
cpu: 100m
memory: 128Mi
requests:
cpu: 50m
memory: 128Mi
# MinIO ingress - hostname overridden by CI # MinIO ingress - hostname overridden by CI
minioIngress: minioIngress:

View File

@@ -136,6 +136,25 @@ postgresql:
persistence: persistence:
enabled: false enabled: false
size: 10Gi size: 10Gi
# Resources with memory requests = limits per cluster policy
resourcesPreset: "none"
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 250m
memory: 512Mi
# Volume permissions init container
volumePermissions:
resourcesPreset: "none"
resources:
limits:
cpu: 100m
memory: 128Mi
requests:
cpu: 50m
memory: 128Mi
# MinIO subchart configuration # MinIO subchart configuration
minio: minio:
@@ -152,6 +171,35 @@ minio:
persistence: persistence:
enabled: false enabled: false
size: 50Gi size: 50Gi
# Resources with memory requests = limits per cluster policy
resourcesPreset: "none" # Disable preset to use explicit resources
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 250m
memory: 512Mi
# Init container resources
defaultInitContainers:
volumePermissions:
resourcesPreset: "none"
resources:
limits:
cpu: 100m
memory: 128Mi
requests:
cpu: 50m
memory: 128Mi
# Provisioning job resources
provisioning:
resources:
limits:
cpu: 200m
memory: 256Mi
requests:
cpu: 100m
memory: 256Mi
# MinIO external ingress for presigned URL access (separate from subchart ingress) # MinIO external ingress for presigned URL access (separate from subchart ingress)
minioIngress: minioIngress:

View File

@@ -23,3 +23,29 @@ exclude-queries:
# Reason: We intentionally don't pin curl version to get security updates. # Reason: We intentionally don't pin curl version to get security updates.
# This is documented with hadolint ignore comment in Dockerfile. # This is documented with hadolint ignore comment in Dockerfile.
- 965a08d7-ef86-4f14-8792-4a3b2098937e - 965a08d7-ef86-4f14-8792-4a3b2098937e
# Container Capabilities Unrestricted (MEDIUM)
# Reason: LOCAL DEVELOPMENT ONLY. Stock postgres, redis, minio images require
# certain capabilities (SETUID, SETGID, CHOWN) to switch users at startup.
# cap_drop: ALL breaks these containers. Production Kubernetes deployments
# use securityContext with appropriate settings.
- ce76b7d0-9e77-464d-b86f-c5c48e03e22d
# No New Privileges Not Set (HIGH)
# Reason: LOCAL DEVELOPMENT ONLY. Stock postgres, redis, minio images need
# to escalate privileges during initialization (e.g., postgres switches from
# root to postgres user). no-new-privileges:true prevents this and causes
# containers to crash. Production Kubernetes deployments handle this via
# securityContext.
- 27fcc7d6-c49b-46e0-98f1-6c082a6a2750
# Security Opt Not Set (MEDIUM)
# Reason: LOCAL DEVELOPMENT ONLY. Related to above - security_opt is not set
# on database services because no-new-privileges breaks them.
- 610e266e-6c12-4bca-9925-1ed0cd29742b
# Container Traffic Not Bound To Host Interface (MEDIUM)
# Reason: LOCAL DEVELOPMENT ONLY. The orchard-server port is bound to 0.0.0.0
# to allow testing from other machines on the local network. This is only in
# docker-compose.local.yml, not production deployments.
- 451d79dc-0588-476a-ad03-3c7f0320abb3