Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions backend/internal/huma/handlers/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type ListProjectsInput struct {
Order string `query:"order" default:"asc" doc:"Sort direction (asc or desc)"`
Start int `query:"start" default:"0" doc:"Start index for pagination"`
Limit int `query:"limit" default:"20" doc:"Number of items per page"`
Status string `query:"status" doc:"Filter by project status (comma-separated: running,stopped,partially running,unknown)"`
}

type ListProjectsOutput struct {
Expand Down Expand Up @@ -326,6 +327,11 @@ func (h *ProjectHandler) ListProjects(ctx context.Context, input *ListProjectsIn
return nil, huma.Error500InternalServerError("service not available")
}

filters := make(map[string]string)
if input.Status != "" {
filters["status"] = input.Status
}

params := pagination.QueryParams{
SearchQuery: pagination.SearchQuery{
Search: input.Search,
Expand All @@ -338,6 +344,7 @@ func (h *ProjectHandler) ListProjects(ctx context.Context, input *ListProjectsIn
Start: input.Start,
Limit: input.Limit,
},
Filters: filters,
}

projects, paginationResp, err := h.projectService.ListProjects(ctx, params)
Expand Down
114 changes: 97 additions & 17 deletions backend/internal/services/project_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -1225,31 +1225,111 @@ func (s *ProjectService) StreamProjectLogs(ctx context.Context, projectID string

func (s *ProjectService) ListProjects(ctx context.Context, params pagination.QueryParams) ([]project.Details, pagination.Response, error) {
var projectsArray []models.Project
query := s.db.WithContext(ctx).Model(&models.Project{})

if term := strings.TrimSpace(params.Search); term != "" {
searchPattern := "%" + term + "%"
query = query.Where(
"name LIKE ? OR path LIKE ? OR status LIKE ? OR COALESCE(dir_name, '') LIKE ?",
searchPattern, searchPattern, searchPattern, searchPattern,
)
// We fetch all projects from the database first, then enrich with live status,
// and finally filter and paginate in memory. This ensures the status filter
// always works against the true live status.
if err := s.db.WithContext(ctx).Find(&projectsArray).Error; err != nil {
return nil, pagination.Response{}, fmt.Errorf("failed to fetch projects: %w", err)
}
Comment on lines 1226 to 1234
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change from database-level filtering to fetching all projects and filtering in-memory could cause performance issues as the number of projects grows. The previous implementation used pagination.PaginateAndSortDB which applied filters at the database level, allowing the DB to efficiently handle large datasets.

The current approach:

  1. Fetches ALL projects from database (Find(&projectsArray))
  2. Enriches all of them with live Docker status
  3. Then filters and paginates in memory

This means that with 1000 projects, you're:

  • Loading 1000 database records
  • Making Docker API calls for all 1000 projects
  • Then filtering down to potentially just 10 results

While the comment justifies this as ensuring "the status filter always works against the true live status," this could be optimized by:

  1. Applying non-status filters at the DB level first
  2. Only enriching the filtered subset with live status
  3. Then applying the status filter in memory

Consider implementing a hybrid approach that filters on database fields first, then enriches and filters on runtime status. Alternatively, document this performance trade-off in the code comments and consider implementing pagination limits or caching strategies for deployments with many projects.

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/internal/services/project_service.go
Line: 1226:1234

Comment:
This change from database-level filtering to fetching all projects and filtering in-memory could cause performance issues as the number of projects grows. The previous implementation used `pagination.PaginateAndSortDB` which applied filters at the database level, allowing the DB to efficiently handle large datasets.

The current approach:
1. Fetches ALL projects from database (`Find(&projectsArray)`)
2. Enriches all of them with live Docker status
3. Then filters and paginates in memory

This means that with 1000 projects, you're:
- Loading 1000 database records
- Making Docker API calls for all 1000 projects
- Then filtering down to potentially just 10 results

While the comment justifies this as ensuring "the status filter always works against the true live status," this could be optimized by:
1. Applying non-status filters at the DB level first
2. Only enriching the filtered subset with live status
3. Then applying the status filter in memory

Consider implementing a hybrid approach that filters on database fields first, then enriches and filters on runtime status. Alternatively, document this performance trade-off in the code comments and consider implementing pagination limits or caching strategies for deployments with many projects.

How can I resolve this? If you propose a fix, please make it concise.


paginationResp, err := pagination.PaginateAndSortDB(params, query, &projectsArray)
if err != nil {
return nil, pagination.Response{}, fmt.Errorf("failed to paginate projects: %w", err)
// Fetch live status concurrently for all projects
enriched := s.fetchProjectStatusConcurrently(ctx, projectsArray)

// In-memory pagination/filtering/sorting
config := s.getProjectPaginationConfig()
result := pagination.SearchOrderAndPaginate(enriched, params, config)

totalPages := int64(0)
if params.Limit > 0 {
totalPages = (int64(result.TotalCount) + int64(params.Limit) - 1) / int64(params.Limit)
}

slog.DebugContext(ctx, "Retrieved projects from database",
"count", len(projectsArray))
page := 1
if params.Limit > 0 {
page = (params.Start / params.Limit) + 1
}

// Fetch live status concurrently for all projects
result := s.fetchProjectStatusConcurrently(ctx, projectsArray)
paginationResp := pagination.Response{
TotalPages: totalPages,
TotalItems: int64(result.TotalCount),
CurrentPage: page,
ItemsPerPage: params.Limit,
GrandTotalItems: int64(result.TotalAvailable),
}

slog.DebugContext(ctx, "Completed ListProjects request",
"result_count", len(result))
return result.Items, paginationResp, nil
}

return result, paginationResp, nil
func (s *ProjectService) getProjectPaginationConfig() pagination.Config[project.Details] {
return pagination.Config[project.Details]{
SearchAccessors: []pagination.SearchAccessor[project.Details]{
func(p project.Details) (string, error) {
return p.Name, nil
},
func(p project.Details) (string, error) {
return p.Path, nil
},
func(p project.Details) (string, error) {
return p.DirName, nil
},
func(p project.Details) (string, error) {
return p.Status, nil
},
},
SortBindings: []pagination.SortBinding[project.Details]{
{
Key: "name",
Fn: func(a, b project.Details) int {
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
},
},
{
Key: "status",
Fn: func(a, b project.Details) int {
return strings.Compare(strings.ToLower(a.Status), strings.ToLower(b.Status))
},
},
{
Key: "projectStatus", // Map frontend ID
Fn: func(a, b project.Details) int {
return strings.Compare(strings.ToLower(a.Status), strings.ToLower(b.Status))
},
},
{
Key: "createdAt",
Fn: func(a, b project.Details) int {
return strings.Compare(a.CreatedAt, b.CreatedAt)
},
},
{
Key: "serviceCount",
Fn: func(a, b project.Details) int {
if a.ServiceCount < b.ServiceCount {
return -1
}
if a.ServiceCount > b.ServiceCount {
return 1
}
return 0
},
},
},
FilterAccessors: []pagination.FilterAccessor[project.Details]{
{
Key: "status",
Fn: func(p project.Details, filterValue string) bool {
return strings.EqualFold(p.Status, filterValue)
},
},
{
Key: "projectStatus", // Map frontend ID
Fn: func(p project.Details, filterValue string) bool {
return strings.EqualFold(p.Status, filterValue)
},
},
},
}
}

// fetchProjectStatusConcurrently fetches live Docker status for multiple projects in parallel
Expand Down
1 change: 1 addition & 0 deletions frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"common_total": "Total",
"common_running": "Running",
"common_stopped": "Stopped",
"common_partially_running": "Partially Running",
"common_size": "Size",
"common_overview": "Overview",
"common_configuration": "Configuration",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import type { Table } from '@tanstack/table-core';
import { DataTableFacetedFilter, DataTableViewOptions } from './index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { imageUpdateFilters, usageFilters, severityFilters, templateTypeFilters } from './data.js';
import { statusFilters, imageUpdateFilters, usageFilters, severityFilters, templateTypeFilters } from './data.js';
import { debounced } from '$lib/utils/utils.js';
import { ArcaneButton } from '$lib/components/arcane-button';
import { m } from '$lib/paraglide/messages';
Expand Down Expand Up @@ -40,6 +40,9 @@
table.getAllColumns().some((col) => col.id === 'severity') ? table.getColumn('severity') : undefined
);
const typeColumn = $derived(table.getAllColumns().some((col) => col.id === 'type') ? table.getColumn('type') : undefined);
const projectStatusColumn = $derived(
table.getAllColumns().some((col) => col.id === 'projectStatus') ? table.getColumn('projectStatus') : undefined
);

const debouncedSetGlobal = debounced((v: string) => table.setGlobalFilter(v), 300);
const hasSelection = $derived(!selectionDisabled && (selectedIds?.length ?? 0) > 0);
Expand Down Expand Up @@ -71,6 +74,9 @@
</div>

<div class="flex flex-wrap items-center gap-2 sm:gap-0 sm:space-x-2">
{#if projectStatusColumn}
<DataTableFacetedFilter column={projectStatusColumn} title={m.common_status()} options={statusFilters} />
{/if}
{#if typeColumn && !severityColumn}
<DataTableFacetedFilter column={typeColumn} title={m.common_type()} options={templateTypeFilters} />
{/if}
Expand Down
31 changes: 30 additions & 1 deletion frontend/src/lib/components/arcane-table/data.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
import { m } from '$lib/paraglide/messages';
import { GlobeIcon, FolderOpenIcon, VerifiedCheckIcon, AlertIcon, InfoIcon, CloseIcon, CheckIcon, UpdateIcon } from '$lib/icons';
import {
GlobeIcon,
FolderOpenIcon,
VerifiedCheckIcon,
AlertIcon,
InfoIcon,
CloseIcon,
CheckIcon,
UpdateIcon,
StartIcon,
StopIcon
} from '$lib/icons';

export const statusFilters = [
{
value: 'running',
label: m.common_running(),
icon: StartIcon
},
{
value: 'stopped',
label: m.common_stopped(),
icon: StopIcon
},
{
value: 'partially running',
label: m.common_partially_running(),
icon: AlertIcon
}
];
Comment on lines +15 to +31
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The statusFilters array is missing the "unknown" status, which is a valid project status supported by the backend (see ProjectStatusUnknown in backend/internal/models/project.go). The API handler documentation on line 44 of backend/internal/huma/handlers/projects.go lists "unknown" as one of the supported filter values.

Projects can have an "unknown" status when they're first discovered from the filesystem or when their Docker status cannot be determined. Users should be able to filter for these projects.

Consider adding the unknown status to maintain feature parity with the backend:

Suggested change
export const statusFilters = [
{
value: 'running',
label: m.common_running(),
icon: StartIcon
},
{
value: 'stopped',
label: m.common_stopped(),
icon: StopIcon
},
{
value: 'partially running',
label: m.common_partially_running(),
icon: AlertIcon
}
];
export const statusFilters = [
{
value: 'running',
label: m.common_running(),
icon: StartIcon
},
{
value: 'stopped',
label: m.common_stopped(),
icon: StopIcon
},
{
value: 'partially running',
label: m.common_partially_running(),
icon: AlertIcon
},
{
value: 'unknown',
label: m.common_unknown(),
icon: AlertIcon
}
];

Note: The common_unknown translation key already exists in all language files.

Prompt To Fix With AI
This is a comment left during a code review.
Path: frontend/src/lib/components/arcane-table/data.ts
Line: 15:31

Comment:
The statusFilters array is missing the "unknown" status, which is a valid project status supported by the backend (see `ProjectStatusUnknown` in `backend/internal/models/project.go`). The API handler documentation on line 44 of `backend/internal/huma/handlers/projects.go` lists "unknown" as one of the supported filter values.

Projects can have an "unknown" status when they're first discovered from the filesystem or when their Docker status cannot be determined. Users should be able to filter for these projects.

Consider adding the unknown status to maintain feature parity with the backend:

```suggestion
export const statusFilters = [
	{
		value: 'running',
		label: m.common_running(),
		icon: StartIcon
	},
	{
		value: 'stopped',
		label: m.common_stopped(),
		icon: StopIcon
	},
	{
		value: 'partially running',
		label: m.common_partially_running(),
		icon: AlertIcon
	},
	{
		value: 'unknown',
		label: m.common_unknown(),
		icon: AlertIcon
	}
];
```

Note: The `common_unknown` translation key already exists in all language files.

How can I resolve this? If you propose a fix, please make it concise.


export const usageFilters = [
{
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/components/arcane-table/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ export { default as DataTableViewOptions } from './arcane-table-view-options.sve
export { default as DataTableFacetedFilter } from './arcane-table-filter.svelte';
export type { ColumnSpec, FieldSpec, MobileFieldVisibility, BulkAction } from './arcane-table.types.svelte';
export { default as UniversalMobileCard } from './cards/universal-mobile-card.svelte';
export { usageFilters, imageUpdateFilters, severityFilters } from './data.js';
export { statusFilters, usageFilters, imageUpdateFilters, severityFilters } from './data.js';
11 changes: 10 additions & 1 deletion frontend/src/lib/services/project-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,16 @@ import { m } from '$lib/paraglide/messages';
export class ProjectService extends BaseAPIService {
async getProjects(options?: SearchPaginationSortRequest): Promise<Paginated<Project>> {
const envId = await environmentStore.getCurrentEnvironmentId();
const params = transformPaginationParams(options);

// Map projectStatus back to status for the API
const apiOptions = options ? { ...options } : undefined;
if (apiOptions?.filters && 'projectStatus' in apiOptions.filters) {
apiOptions.filters = { ...apiOptions.filters };
apiOptions.filters.status = apiOptions.filters.projectStatus;
delete apiOptions.filters.projectStatus;
}

const params = transformPaginationParams(apiOptions);
const res = await this.api.get(`/environments/${envId}/projects`, { params });
return res.data;
}
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/routes/(app)/projects/projects-table.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -264,15 +264,15 @@
{ accessorKey: 'id', title: m.common_id(), hidden: true },
{ accessorKey: 'name', title: m.common_name(), sortable: true, cell: NameCell },
{ accessorKey: 'gitOpsManagedBy', title: m.projects_col_provider(), cell: ProviderCell },
{ accessorKey: 'status', title: m.common_status(), sortable: true, cell: StatusCell },
{ accessorKey: 'status', id: 'projectStatus', title: m.common_status(), sortable: true, cell: StatusCell },
{ accessorKey: 'createdAt', title: m.common_created(), sortable: true, cell: CreatedCell },
{ accessorKey: 'serviceCount', title: m.compose_services(), sortable: true }
] satisfies ColumnSpec<Project>[];

const mobileFields = [
{ id: 'id', label: m.common_id(), defaultVisible: false },
{ id: 'provider', label: m.projects_col_provider(), defaultVisible: true },
{ id: 'status', label: m.common_status(), defaultVisible: true },
{ id: 'projectStatus', label: m.common_status(), defaultVisible: true },
{ id: 'serviceCount', label: m.compose_services(), defaultVisible: true },
{ id: 'createdAt', label: m.common_created(), defaultVisible: true }
];
Expand Down Expand Up @@ -372,7 +372,7 @@
subtitle={(item: Project) => ((mobileFieldVisibility.id ?? true) ? item.id : null)}
badges={[
(item: Project) =>
(mobileFieldVisibility.status ?? true)
(mobileFieldVisibility.projectStatus ?? true)
? {
variant: getStatusVariant(item.status),
text: capitalizeFirstLetter(item.status),
Expand Down
Loading