Skip to content
Merged
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
1,087 changes: 1,016 additions & 71 deletions api/doc/openapi.json

Large diffs are not rendered by default.

168 changes: 153 additions & 15 deletions api/internal/features/container/controller/list_containers.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,53 @@ func (c *ContainerController) ListContainers(fuegoCtx fuego.ContextNoBody) (*con
Status: http.StatusInternalServerError,
}
}
// Build summaries, then search/sort/paginate
// Build summaries, then search/sort
rows := summarizeContainers(containers)
pageRows, totalCount := applySearchSortPaginate(rows, params)
filteredRows := applySearchFilter(rows, params)
sortedRows := applySort(filteredRows, params)

result := c.appendContainerInfo(pageRows, containers)
// Group containers by application ID
groups, ungrouped := groupContainersByApplication(sortedRows, containers, c.dockerService)

// Sort groups by application name
sort.SliceStable(groups, func(i, j int) bool {
if params.SortOrder == "desc" {
return groups[i].ApplicationName > groups[j].ApplicationName
}
return groups[i].ApplicationName < groups[j].ApplicationName
})

// Paginate groups
totalGroupCount := len(groups)
start := (params.Page - 1) * params.PageSize
if start > totalGroupCount {
start = totalGroupCount
}
end := start + params.PageSize
if end > totalGroupCount {
end = totalGroupCount
}
paginatedGroups := groups[start:end]

// Calculate total container count
totalContainerCount := 0
for _, group := range groups {
totalContainerCount += len(group.Containers)
}
totalContainerCount += len(ungrouped)

// Include ungrouped containers on every page
// This ensures they're always visible regardless of pagination
paginatedUngrouped := ungrouped

return &containertypes.ListContainersResponse{
Status: "success",
Message: "Containers fetched successfully",
Data: containertypes.ListContainersResponseData{
Containers: result,
TotalCount: totalCount,
Groups: paginatedGroups,
Ungrouped: paginatedUngrouped,
TotalCount: totalContainerCount,
GroupCount: totalGroupCount,
Page: params.Page,
PageSize: params.PageSize,
SortBy: params.SortBy,
Expand Down Expand Up @@ -133,7 +168,7 @@ func summarizeContainers(summaries []container.Summary) []containertypes.Contain
return rows
}

func applySearchSortPaginate(rows []containertypes.ContainerListRow, p containertypes.ContainerListParams) ([]containertypes.ContainerListRow, int) {
func applySearchFilter(rows []containertypes.ContainerListRow, p containertypes.ContainerListParams) []containertypes.ContainerListRow {
if p.Search != "" {
lower := strings.ToLower(p.Search)
filtered := make([]containertypes.ContainerListRow, 0, len(rows))
Expand All @@ -144,9 +179,12 @@ func applySearchSortPaginate(rows []containertypes.ContainerListRow, p container
filtered = append(filtered, r)
}
}
rows = filtered
return filtered
}
return rows
}

func applySort(rows []containertypes.ContainerListRow, p containertypes.ContainerListParams) []containertypes.ContainerListRow {
sort.SliceStable(rows, func(i, j int) bool {
switch p.SortBy {
case "status":
Expand All @@ -172,17 +210,117 @@ func applySearchSortPaginate(rows []containertypes.ContainerListRow, p container
return ai < aj
}
})
return rows
}

func groupContainersByApplication(
rows []containertypes.ContainerListRow,
summaries []container.Summary,
dockerService interface {
GetContainerById(id string) (container.InspectResponse, error)
},
) ([]containertypes.ContainerGroup, []containertypes.Container) {
groupsMap := make(map[string]*containertypes.ContainerGroup)
ungrouped := make([]containertypes.Container, 0)

// Create a map of summaries by ID for quick lookup
summaryMap := make(map[string]container.Summary)
for _, s := range summaries {
summaryMap[s.ID] = s
}

for _, row := range rows {
applicationID := ""
applicationName := "Unknown Application"
if row.Labels != nil {
if id, ok := row.Labels["com.application.id"]; ok {
applicationID = id
}
if name, ok := row.Labels["com.application.name"]; ok {
applicationName = name
}
}

// Get full container info
info, err := dockerService.GetContainerById(row.ID)
if err != nil {
continue
}

containerData := containertypes.Container{
ID: row.ID,
Name: row.Name,
Image: row.Image,
Status: row.Status,
State: row.State,
Created: info.Created,
Labels: row.Labels,
Command: "",
IPAddress: info.NetworkSettings.IPAddress,
HostConfig: containertypes.HostConfig{
Memory: info.HostConfig.Memory,
MemorySwap: info.HostConfig.MemorySwap,
CPUShares: info.HostConfig.CPUShares,
},
}

if info.Config != nil && info.Config.Cmd != nil && len(info.Config.Cmd) > 0 {
containerData.Command = info.Config.Cmd[0]
}

// Add ports from summary
if s, ok := summaryMap[row.ID]; ok {
for _, p := range s.Ports {
containerData.Ports = append(containerData.Ports, containertypes.Port{
PrivatePort: int(p.PrivatePort),
PublicPort: int(p.PublicPort),
Type: p.Type,
})
}
}

totalCount := len(rows)
start := (p.Page - 1) * p.PageSize
if start > totalCount {
start = totalCount
// Add mounts
for _, m := range info.Mounts {
containerData.Mounts = append(containerData.Mounts, containertypes.Mount{
Type: string(m.Type),
Source: m.Source,
Destination: m.Destination,
Mode: m.Mode,
})
}

// Add networks
for name, network := range info.NetworkSettings.Networks {
containerData.Networks = append(containerData.Networks, containertypes.Network{
Name: name,
IPAddress: network.IPAddress,
Gateway: network.Gateway,
MacAddress: network.MacAddress,
Aliases: network.Aliases,
})
}

if applicationID != "" {
if _, exists := groupsMap[applicationID]; !exists {
groupsMap[applicationID] = &containertypes.ContainerGroup{
ApplicationID: applicationID,
ApplicationName: applicationName,
Containers: make([]containertypes.Container, 0),
}
}
groupsMap[applicationID].Containers = append(groupsMap[applicationID].Containers, containerData)
} else {
ungrouped = append(ungrouped, containerData)
}
}
end := start + p.PageSize
if end > totalCount {
end = totalCount

// Convert map to slice
groups := make([]containertypes.ContainerGroup, 0, len(groupsMap))
for _, group := range groupsMap {
groups = append(groups, *group)
}
return rows[start:end], totalCount

return groups, ungrouped
}

func (c *ContainerController) appendContainerInfo(pageRows []containertypes.ContainerListRow, summaries []container.Summary) []containertypes.Container {
Expand Down
30 changes: 20 additions & 10 deletions api/internal/features/container/types/container_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,18 +130,28 @@ type ContainerListRow struct {
Labels map[string]string `json:"labels"`
}

// ContainerGroup represents a group of containers belonging to the same application
type ContainerGroup struct {
ApplicationID string `json:"application_id"`
ApplicationName string `json:"application_name"`
Containers []Container `json:"containers"`
}

// ListContainersResponseData contains the data for list containers response
type ListContainersResponseData struct {
Containers []Container `json:"containers"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
PageSize int `json:"page_size"`
SortBy string `json:"sort_by"`
SortOrder string `json:"sort_order"`
Search string `json:"search"`
Status string `json:"status"`
Name string `json:"name"`
Image string `json:"image"`
Containers []Container `json:"containers"` // Deprecated: use Groups instead. Kept for backward compatibility
Groups []ContainerGroup `json:"groups,omitempty"`
Ungrouped []Container `json:"ungrouped,omitempty"`
TotalCount int `json:"total_count"` // Total number of containers (not groups)
GroupCount int `json:"group_count"` // Total number of groups
Page int `json:"page"`
PageSize int `json:"page_size"`
SortBy string `json:"sort_by"`
SortOrder string `json:"sort_order"`
Search string `json:"search"`
Status string `json:"status"`
Name string `json:"name"`
Image string `json:"image"`
}

// ListContainersResponse is the typed response for listing containers
Expand Down
82 changes: 37 additions & 45 deletions view/app/containers/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ResourceGuard, AnyPermissionGuard } from '@/packages/components/rbac';
import PageLayout from '@/packages/layouts/page-layout';
import PaginationWrapper from '@/components/ui/pagination';
import { SelectWrapper } from '@/components/ui/select-wrapper';
import { GroupedContainerView } from '@/packages/components/container';
import { cn } from '@/lib/utils';
import MainPageHeader from '@/components/ui/main-page-header';
import { translationKey } from '@/packages/hooks/shared/use-translation';
Expand All @@ -26,6 +27,8 @@ export default function ContainersPage() {

const {
containers,
groups,
ungrouped,
isLoading,
isFetching,
initialized,
Expand Down Expand Up @@ -60,6 +63,17 @@ export default function ContainersPage() {
sortOptions
} = useContainers();

// Derive sort values from sortConfig
const sortBy = (sortConfig?.key || 'name') as 'name' | 'status';
const sortOrder = (sortConfig?.direction || 'asc') as 'asc' | 'desc';

// Handle sort toggle
const handleSort = (field: 'name' | 'status') => {
const newDirection =
sortConfig?.key === field && sortConfig?.direction === 'asc' ? 'desc' : 'asc';
handleSortChange(field, newDirection);
};

if (!initialized && isLoading) {
return <ContainersLoading />;
}
Expand Down Expand Up @@ -155,52 +169,30 @@ export default function ContainersPage() {
</div>
</div>

<div className="space-y-6">
{containers.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Box className="h-16 w-16 mb-4 opacity-20" />
<p className="text-lg font-medium">{t('containers.no_containers')}</p>
<p className="text-sm mt-1">No containers match your search criteria</p>
</div>
) : viewMode === 'card' ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{containers.map((container) => (
<ContainerCard
key={container.id}
container={container}
onClick={() => router.push(`/containers/${container.id}`)}
onAction={handleContainerAction}
/>
))}
</div>
) : (
<ContainersTable
containersData={containers}
sortBy={sortConfig?.key || 'name'}
sortOrder={sortConfig?.direction || 'asc'}
onSort={(field) => {
const currentKey = sortConfig?.key || 'name';
const currentDir = sortConfig?.direction || 'asc';
if (currentKey === field) {
handleSortChange(field, currentDir === 'asc' ? 'desc' : 'asc');
} else {
handleSortChange(field, 'asc');
}
}}
onAction={handleContainerAction}
/>
)}
{containers.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Box className="h-16 w-16 mb-4 opacity-20" />
<p className="text-lg font-medium">{t('containers.no_containers')}</p>
<p className="text-sm mt-1">No containers match your search criteria</p>
</div>
) : (
<GroupedContainerView
groups={groups}
ungrouped={ungrouped}
viewMode={viewMode}
onContainerClick={(container) => router.push(`/containers/${container.id}`)}
onContainerAction={handleContainerAction}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={handleSort}
/>
)}

{totalPages > 1 && (
<div className="flex justify-center pt-6">
<PaginationWrapper
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
)}
</div>
{totalPages > 1 && (
<div className="flex justify-center pt-6">
<PaginationWrapper currentPage={page} totalPages={totalPages} onPageChange={setPage} />
</div>
)}

<AnyPermissionGuard permissions={['container:delete']} loadingFallback={null}>
<DeleteDialog
Expand Down
Loading
Loading