onRowClick?.(item)}
+ className={onRowClick ? 'data-table__row--clickable' : ''}
+ >
{columns.map((column) => (
{column.render(item)}
diff --git a/frontend/src/pages/Home.css b/frontend/src/pages/Home.css
index f5891d8..6a2f176 100644
--- a/frontend/src/pages/Home.css
+++ b/frontend/src/pages/Home.css
@@ -1,6 +1,6 @@
/* Page Layout */
.home {
- max-width: 1000px;
+ max-width: 1200px;
margin: 0 auto;
}
diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx
index 6d45faf..fb6aeab 100644
--- a/frontend/src/pages/Home.tsx
+++ b/frontend/src/pages/Home.tsx
@@ -1,9 +1,9 @@
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 { listProjects, createProject } from '../api';
import { Badge } from '../components/Badge';
-import { SortDropdown, SortOption } from '../components/SortDropdown';
+import { DataTable } from '../components/DataTable';
import { FilterDropdown, FilterOption } from '../components/FilterDropdown';
import { FilterChip, FilterChipGroup } from '../components/FilterChip';
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[] = [
{ value: '', label: 'All Projects' },
{ value: 'public', label: 'Public Only' },
@@ -34,6 +28,7 @@ const VISIBILITY_OPTIONS: FilterOption[] = [
function Home() {
const [searchParams, setSearchParams] = useSearchParams();
+ const navigate = useNavigate();
const { user } = useAuth();
const [projectsData, setProjectsData] = useState | null>(null);
@@ -101,8 +96,10 @@ function Home() {
}
}
- const handleSortChange = (newSort: string, newOrder: 'asc' | 'desc') => {
- updateParams({ sort: newSort, order: newOrder, page: '1' });
+ const handleSortChange = (columnKey: string) => {
+ // 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) => {
@@ -189,7 +186,6 @@ function Home() {
value={visibility}
onChange={handleVisibilityChange}
/>
-
{hasActiveFilters && (
@@ -204,69 +200,106 @@ function Home() {
)}
- {projects.length === 0 ? (
-
- {hasActiveFilters ? (
- No projects match your filters. Try adjusting your search.
- ) : (
- No projects yet. Create your first project to get started!
- )}
-
- ) : (
- <>
-
- {projects.map((project) => (
-
-
+
+ project.id}
+ onRowClick={(project) => navigate(`/project/${project.name}`)}
+ onSort={handleSortChange}
+ sortKey={sort}
+ sortOrder={order}
+ emptyMessage={
+ hasActiveFilters
+ ? 'No projects match your filters. Try adjusting your search.'
+ : 'No projects yet. Create your first project to get started!'
+ }
+ columns={[
+ {
+ key: 'name',
+ header: 'Name',
+ sortable: true,
+ render: (project) => (
+
{!project.is_public && }
{project.name}
-
- {project.description && {project.description} }
-
-
-
- {project.is_public ? 'Public' : 'Private'}
-
- {user && project.access_level && (
-
+ ),
+ },
+ {
+ key: 'description',
+ header: 'Description',
+ className: 'cell-description',
+ render: (project) => project.description || '—',
+ },
+ {
+ key: 'visibility',
+ header: 'Visibility',
+ render: (project) => (
+
+ {project.is_public ? 'Public' : 'Private'}
+
+ ),
+ },
+ {
+ 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 ? (
+
- {project.is_owner ? 'Owner' : project.access_level.charAt(0).toUpperCase() + project.access_level.slice(1)}
-
- )}
-
-
- Created {new Date(project.created_at).toLocaleDateString()}
- {project.updated_at !== project.created_at && (
- Updated {new Date(project.updated_at).toLocaleDateString()}
- )}
-
-
-
- by {project.created_by}
-
-
- ))}
-
+ : project.access_level === 'admin'
+ ? 'success'
+ : project.access_level === 'write'
+ ? 'info'
+ : 'default'
+ }
+ >
+ {project.is_owner
+ ? 'Owner'
+ : project.access_level.charAt(0).toUpperCase() + project.access_level.slice(1)}
+
+ ) : (
+ '—'
+ ),
+ },
+ ]
+ : []),
+ {
+ key: 'created_at',
+ header: 'Created',
+ sortable: true,
+ 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(),
+ },
+ ]}
+ />
+
- {pagination && pagination.total_pages > 1 && (
-
- )}
- >
+ {pagination && pagination.total_pages > 1 && (
+
)}
);
diff --git a/frontend/src/pages/PackagePage.tsx b/frontend/src/pages/PackagePage.tsx
index 76284b2..698e5e5 100644
--- a/frontend/src/pages/PackagePage.tsx
+++ b/frontend/src/pages/PackagePage.tsx
@@ -5,7 +5,6 @@ import { listTags, getDownloadUrl, getPackage, getMyProjectAccess, UnauthorizedE
import { Breadcrumb } from '../components/Breadcrumb';
import { Badge } from '../components/Badge';
import { SearchInput } from '../components/SearchInput';
-import { SortDropdown, SortOption } from '../components/SortDropdown';
import { FilterChip, FilterChipGroup } from '../components/FilterChip';
import { DataTable } from '../components/DataTable';
import { Pagination } from '../components/Pagination';
@@ -14,11 +13,6 @@ import { useAuth } from '../contexts/AuthContext';
import './Home.css';
import './PackagePage.css';
-const SORT_OPTIONS: SortOption[] = [
- { value: 'name', label: 'Name' },
- { value: 'created_at', label: 'Created' },
-];
-
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
@@ -164,8 +158,9 @@ function PackagePage() {
updateParams({ search: value, page: '1' });
};
- const handleSortChange = (newSort: string, newOrder: 'asc' | 'desc') => {
- updateParams({ sort: newSort, order: newOrder, page: '1' });
+ const handleSortChange = (columnKey: string) => {
+ const newOrder = columnKey === sort ? (order === 'asc' ? 'desc' : 'asc') : 'asc';
+ updateParams({ sort: columnKey, order: newOrder, page: '1' });
};
const handlePageChange = (newPage: number) => {
@@ -198,19 +193,19 @@ function PackagePage() {
),
},
{
- key: 'size',
+ key: 'artifact_size',
header: 'Size',
render: (t: TagDetail) => {formatBytes(t.artifact_size)},
},
{
- key: 'content_type',
+ key: 'artifact_content_type',
header: 'Type',
render: (t: TagDetail) => (
{t.artifact_content_type || '-'}
),
},
{
- key: 'original_name',
+ key: 'artifact_original_name',
header: 'Filename',
className: 'cell-truncate',
render: (t: TagDetail) => (
@@ -376,7 +371,6 @@ function PackagePage() {
placeholder="Filter tags..."
className="list-controls__search"
/>
-
{hasActiveFilters && (
@@ -385,25 +379,21 @@ function PackagePage() {
)}
- t.id}
- emptyMessage={
- hasActiveFilters
- ? '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');
+
+ t.id}
+ emptyMessage={
+ hasActiveFilters
+ ? 'No tags match your filters. Try adjusting your search.'
+ : 'No tags yet. Upload an artifact with a tag to create one!'
}
- }}
- sortKey={sort}
- sortOrder={order}
- />
+ onSort={handleSortChange}
+ sortKey={sort}
+ sortOrder={order}
+ />
+
{pagination && pagination.total_pages > 1 && (
{
- updateParams({ sort: newSort, order: newOrder, page: '1' });
+ const handleSortChange = (columnKey: string) => {
+ const newOrder = columnKey === sort ? (order === 'asc' ? 'desc' : 'asc') : 'asc';
+ updateParams({ sort: columnKey, order: newOrder, page: '1' });
};
const handleFormatChange = (value: string) => {
@@ -294,7 +289,6 @@ function ProjectPage() {
))}
-
{hasActiveFilters && (
@@ -304,70 +298,78 @@ function ProjectPage() {
)}
- {packages.length === 0 ? (
-
- {hasActiveFilters ? (
- No packages match your filters. Try adjusting your search.
- ) : (
- No packages yet. Create your first package to start uploading artifacts!
- )}
-
- ) : (
- <>
-
- {packages.map((pkg) => (
-
-
- {pkg.name}
- {pkg.format}
-
- {pkg.description && {pkg.description} }
+
+ pkg.id}
+ onRowClick={(pkg) => navigate(`/project/${projectName}/${pkg.name}`)}
+ onSort={handleSortChange}
+ sortKey={sort}
+ sortOrder={order}
+ emptyMessage={
+ hasActiveFilters
+ ? 'No packages match your filters. Try adjusting your search.'
+ : 'No packages yet. Create your first package to start uploading artifacts!'
+ }
+ columns={[
+ {
+ key: 'name',
+ header: 'Name',
+ sortable: true,
+ render: (pkg) => {pkg.name},
+ },
+ {
+ key: 'description',
+ header: 'Description',
+ className: 'cell-description',
+ render: (pkg) => pkg.description || '—',
+ },
+ {
+ key: 'format',
+ header: 'Format',
+ render: (pkg) => {pkg.format},
+ },
+ {
+ 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 ? {pkg.latest_tag} : '—',
+ },
+ {
+ key: 'created_at',
+ header: 'Created',
+ sortable: true,
+ className: 'cell-date',
+ render: (pkg) => new Date(pkg.created_at).toLocaleDateString(),
+ },
+ ]}
+ />
+
- {(pkg.tag_count !== undefined || pkg.artifact_count !== undefined) && (
-
- {pkg.tag_count !== undefined && (
-
- {pkg.tag_count}
- Tags
-
- )}
- {pkg.artifact_count !== undefined && (
-
- {pkg.artifact_count}
- Artifacts
-
- )}
- {pkg.total_size !== undefined && pkg.total_size > 0 && (
-
- {formatBytes(pkg.total_size)}
- Size
-
- )}
-
- )}
-
-
- {pkg.latest_tag && (
-
- Latest: {pkg.latest_tag}
-
- )}
- Created {new Date(pkg.created_at).toLocaleDateString()}
-
-
- ))}
-
-
- {pagination && pagination.total_pages > 1 && (
-
- )}
- >
+ {pagination && pagination.total_pages > 1 && (
+
)}
{canAdmin && projectName && (
diff --git a/helm/orchard/templates/deployment.yaml b/helm/orchard/templates/deployment.yaml
index 3a8c97b..1353547 100644
--- a/helm/orchard/templates/deployment.yaml
+++ b/helm/orchard/templates/deployment.yaml
@@ -37,12 +37,26 @@ spec:
image: "{{ .Values.initContainer.image.repository }}:{{ .Values.initContainer.image.tag }}"
imagePullPolicy: {{ .Values.initContainer.image.pullPolicy }}
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 }}
{{- if .Values.minio.enabled }}
- name: wait-for-minio
image: "{{ .Values.initContainer.image.repository }}:{{ .Values.initContainer.image.tag }}"
imagePullPolicy: {{ .Values.initContainer.image.pullPolicy }}
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 }}
containers:
- name: {{ .Chart.Name }}
diff --git a/helm/orchard/values-dev.yaml b/helm/orchard/values-dev.yaml
index 6dd6130..2b461df 100644
--- a/helm/orchard/values-dev.yaml
+++ b/helm/orchard/values-dev.yaml
@@ -53,13 +53,14 @@ ingress:
- orchard-dev.common.global.bsf.tools # Overridden by CI
# Lighter resources for ephemeral environments
+# Note: memory requests must equal limits per cluster policy
resources:
limits:
cpu: 250m
memory: 256Mi
requests:
cpu: 100m
- memory: 128Mi
+ memory: 256Mi
livenessProbe:
httpGet:
@@ -127,6 +128,25 @@ postgresql:
primary:
persistence:
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:
@@ -142,6 +162,35 @@ minio:
defaultBuckets: "orchard-artifacts"
persistence:
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
minioIngress:
diff --git a/helm/orchard/values-stage.yaml b/helm/orchard/values-stage.yaml
index 9d370f5..c702bcb 100644
--- a/helm/orchard/values-stage.yaml
+++ b/helm/orchard/values-stage.yaml
@@ -136,6 +136,25 @@ postgresql:
persistence:
enabled: false
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:
@@ -152,6 +171,35 @@ minio:
persistence:
enabled: false
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)
minioIngress:
diff --git a/kics.config b/kics.config
index 5572c19..31e8a6f 100644
--- a/kics.config
+++ b/kics.config
@@ -23,3 +23,29 @@ exclude-queries:
# Reason: We intentionally don't pin curl version to get security updates.
# This is documented with hadolint ignore comment in Dockerfile.
- 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
|