diff --git a/spec/openapi.dashboard-api.yaml b/spec/openapi.dashboard-api.yaml index 24c59aa12..2a725c054 100644 --- a/spec/openapi.dashboard-api.yaml +++ b/spec/openapi.dashboard-api.yaml @@ -66,6 +66,24 @@ components: description: Cursor returned by the previous list response in `created_at|build_id` format. schema: type: string + builds_cpu_count: + name: cpuCount + in: query + required: false + description: Filter builds by exact vCPU count. + schema: + type: integer + format: int32 + minimum: 1 + builds_memory_mb: + name: memoryMB + in: query + required: false + description: Filter builds by exact memory size in MB. + schema: + type: integer + format: int32 + minimum: 1 build_id_or_template: name: build_id_or_template in: query @@ -905,6 +923,8 @@ paths: parameters: - $ref: "#/components/parameters/build_id_or_template" - $ref: "#/components/parameters/build_statuses" + - $ref: "#/components/parameters/builds_cpu_count" + - $ref: "#/components/parameters/builds_memory_mb" - $ref: "#/components/parameters/builds_limit" - $ref: "#/components/parameters/builds_cursor" responses: diff --git a/src/core/modules/builds/models.ts b/src/core/modules/builds/models.ts index e94eb62c1..463d10552 100644 --- a/src/core/modules/builds/models.ts +++ b/src/core/modules/builds/models.ts @@ -22,6 +22,7 @@ type _BuildStatusExhaustiveCheck = AssertTrue< // TypeCheck: End export const BuildStatusSchema = z.enum(BUILD_STATUS_VALUES) + export interface ListedBuildModel { id: string // id or alias @@ -31,6 +32,10 @@ export interface ListedBuildModel { statusMessage: string | null createdAt: number finishedAt: number | null + cpuCount: number + memoryMB: number + diskSizeMB: number | null + envdVersion: string | null } export interface RunningBuildStatusModel { diff --git a/src/core/modules/builds/repository.server.ts b/src/core/modules/builds/repository.server.ts index 1cfaab6d9..f9a234a42 100644 --- a/src/core/modules/builds/repository.server.ts +++ b/src/core/modules/builds/repository.server.ts @@ -57,6 +57,8 @@ function normalizeListBuildsLimit(limit?: number): number { } interface ListBuildsOptions { + cpuCount?: number + memoryMB?: number limit?: number cursor?: string } @@ -101,6 +103,8 @@ export function createBuildsRepository( query: { build_id_or_template: buildIdOrTemplate?.trim() || undefined, statuses, + cpuCount: options.cpuCount, + memoryMB: options.memoryMB, limit, cursor: options.cursor, }, @@ -149,6 +153,10 @@ export function createBuildsRepository( finishedAt: build.finishedAt ? new Date(build.finishedAt).getTime() : null, + cpuCount: build.cpuCount, + memoryMB: build.memoryMB, + diskSizeMB: build.diskSizeMB, + envdVersion: build.envdVersion, }) ), nextCursor: result.data?.nextCursor ?? null, diff --git a/src/core/server/api/routers/builds.ts b/src/core/server/api/routers/builds.ts index 98aab5bf8..536991f84 100644 --- a/src/core/server/api/routers/builds.ts +++ b/src/core/server/api/routers/builds.ts @@ -29,17 +29,22 @@ export const buildsRouter = createTRPCRouter({ z.object({ buildIdOrTemplate: z.string().optional(), statuses: z.array(BuildStatusSchema), + cpuCount: z.number().int().min(1).optional(), + memoryMB: z.number().int().min(1).optional(), limit: z.number().min(1).max(100).default(50), cursor: z.string().optional(), }) ) .query(async ({ ctx, input }) => { - const { buildIdOrTemplate, statuses, limit, cursor } = input + const { buildIdOrTemplate, statuses, cpuCount, memoryMB, limit, cursor } = + input const result = await ctx.buildsRepository.listBuilds( buildIdOrTemplate, statuses, { + cpuCount, + memoryMB, limit, cursor, } diff --git a/src/core/shared/contracts/dashboard-api.types.ts b/src/core/shared/contracts/dashboard-api.types.ts index 562a37e03..b42f24832 100644 --- a/src/core/shared/contracts/dashboard-api.types.ts +++ b/src/core/shared/contracts/dashboard-api.types.ts @@ -55,6 +55,10 @@ export interface paths { build_id_or_template?: components['parameters']['build_id_or_template'] /** @description Comma-separated list of build statuses to include. */ statuses?: components['parameters']['build_statuses'] + /** @description Filter builds by exact vCPU count. */ + cpuCount?: components['parameters']['builds_cpu_count'] + /** @description Filter builds by exact memory size in MB. */ + memoryMB?: components['parameters']['builds_memory_mb'] /** @description Maximum number of items to return per page. */ limit?: components['parameters']['builds_limit'] /** @description Cursor returned by the previous list response in `created_at|build_id` format. */ @@ -1229,6 +1233,10 @@ export interface components { builds_limit: number /** @description Cursor returned by the previous list response in `created_at|build_id` format. */ builds_cursor: string + /** @description Filter builds by exact vCPU count. */ + builds_cpu_count: number + /** @description Filter builds by exact memory size in MB. */ + builds_memory_mb: number /** @description Optional filter by build identifier, template identifier, or template alias. */ build_id_or_template: string /** @description Comma-separated list of build statuses to include. */ diff --git a/src/features/dashboard/common/envd-version.tsx b/src/features/dashboard/common/envd-version.tsx new file mode 100644 index 000000000..6d9cf11f0 --- /dev/null +++ b/src/features/dashboard/common/envd-version.tsx @@ -0,0 +1,39 @@ +import { cn } from '@/lib/utils' +import { isVersionCompatible } from '@/lib/utils/version' +import HelpTooltip from '@/ui/help-tooltip' + +const INVALID_ENVD_VERSION = '0.0.1' +const SDK_V2_MINIMAL_ENVD_VERSION = '0.2.0' + +export function EnvdVersion({ + version, + className, +}: { + version: string | null | undefined + className?: string +}) { + const versionValue = + version && version !== INVALID_ENVD_VERSION ? version : null + + const isNotV2Compatible = versionValue + ? isVersionCompatible(versionValue, SDK_V2_MINIMAL_ENVD_VERSION) === false + : false + + return ( +
+ {versionValue ?? '--'} + {isNotV2Compatible && ( + + The envd version is not compatible with the SDK v2. To update the envd + version, you need to rebuild the template. + + )} +
+ ) +} diff --git a/src/features/dashboard/common/resources-filter.tsx b/src/features/dashboard/common/resources-filter.tsx new file mode 100644 index 000000000..f8a0902b6 --- /dev/null +++ b/src/features/dashboard/common/resources-filter.tsx @@ -0,0 +1,103 @@ +'use client' + +import { cn } from '@/lib/utils' +import { NumberInput } from '@/ui/number-input' +import { Button } from '@/ui/primitives/button' +import { Label } from '@/ui/primitives/label' +import { Separator } from '@/ui/primitives/separator' + +export interface ResourcesFilterValue { + cpuCount?: number + memoryMB?: number +} + +interface ResourcesFilterProps { + value: ResourcesFilterValue + onChange: (value: ResourcesFilterValue) => void + className?: string +} + +const formatMemoryDisplay = (memoryValue: number) => { + if (memoryValue === 0) return 'Unfiltered' + + return memoryValue < 1024 ? `${memoryValue} MB` : `${memoryValue / 1024} GB` +} + +/** + * State-agnostic CPU + memory filter. The parent owns the value (URL params, + * a store, …) and is responsible for any debouncing. + */ +export function ResourcesFilter({ + value, + onChange, + className, +}: ResourcesFilterProps) { + const cpu = value.cpuCount ?? 0 + const memory = value.memoryMB ?? 0 + + const handleCpuChange = (next: number) => { + onChange({ cpuCount: next || undefined, memoryMB: value.memoryMB }) + } + + const handleMemoryChange = (next: number) => { + onChange({ cpuCount: value.cpuCount, memoryMB: next || undefined }) + } + + return ( +
+
+
+
+ + + {cpu === 0 ? 'Unfiltered' : `${cpu} core${cpu === 1 ? '' : 's'}`} + +
+
+ + {cpu > 0 && ( + + )} +
+
+ +
+
+ + + {formatMemoryDisplay(memory)} + +
+
+ + {memory > 0 && ( + + )} +
+
+
+
+ ) +} diff --git a/src/features/dashboard/templates/builds/filter-params.ts b/src/features/dashboard/templates/builds/filter-params.ts index 05acc834d..d9d950df6 100644 --- a/src/features/dashboard/templates/builds/filter-params.ts +++ b/src/features/dashboard/templates/builds/filter-params.ts @@ -1,6 +1,7 @@ import { createLoader, parseAsArrayOf, + parseAsInteger, parseAsString, parseAsStringEnum, } from 'nuqs/server' @@ -10,6 +11,8 @@ export const templateBuildsFilterParams = { parseAsStringEnum(['building', 'failed', 'success']) ), buildIdOrTemplate: parseAsString, + cpuCount: parseAsInteger, + memoryMB: parseAsInteger, } export const loadTemplateBuildsFilters = createLoader( diff --git a/src/features/dashboard/templates/builds/header.tsx b/src/features/dashboard/templates/builds/header.tsx index 2c783b8d8..2debfb746 100644 --- a/src/features/dashboard/templates/builds/header.tsx +++ b/src/features/dashboard/templates/builds/header.tsx @@ -11,7 +11,17 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/ui/primitives/dropdown-menu' +import { FilterIcon } from '@/ui/primitives/icons' import { Input } from '@/ui/primitives/input' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/ui/primitives/popover' +import { + ResourcesFilter, + type ResourcesFilterValue, +} from '../../common/resources-filter' import { Status } from './table-cells' import useFilters from './use-filters' @@ -63,14 +73,25 @@ const STATUS_OPTIONS: Array<{ value: BuildStatus; label: string }> = [ ] export default function BuildsHeader() { - const { statuses, setStatuses, buildIdOrTemplate, setBuildIdOrTemplate } = - useFilters() + const { + statuses, + setStatuses, + buildIdOrTemplate, + setBuildIdOrTemplate, + cpuCount, + memoryMB, + setResources, + } = useFilters() const [localBuildIdOrTemplate, setLocalBuildIdOrTemplate] = useState( buildIdOrTemplate ?? '' ) const [localStatuses, setLocalStatuses] = useState(statuses) + const [localResources, setLocalResources] = useState({ + cpuCount, + memoryMB, + }) useEffect(() => { setLocalBuildIdOrTemplate(buildIdOrTemplate ?? '') @@ -80,6 +101,21 @@ export default function BuildsHeader() { setLocalStatuses(statuses) }, [statuses]) + useEffect(() => { + setLocalResources({ cpuCount, memoryMB }) + }, [cpuCount, memoryMB]) + + const handleResourcesChange = (next: { + cpuCount?: number + memoryMB?: number + }) => { + setLocalResources(next) + setResources(next) + } + + const activeResourceCount = + (localResources.cpuCount ? 1 : 0) + (localResources.memoryMB ? 1 : 0) + const toggleStatus = (status: BuildStatus) => { const isSelected = localStatuses.includes(status) @@ -102,7 +138,7 @@ export default function BuildsHeader() { } return ( -
+
- - - - - - e.preventDefault()} - > - All - - - {STATUS_OPTIONS.map((option) => ( +
+ + + + + toggleStatus(option.value)} + checked={localStatuses.length === STATUS_OPTIONS.length} + onCheckedChange={selectAllStatuses} onSelect={(e) => e.preventDefault()} > - + All - ))} - - + + {STATUS_OPTIONS.map((option) => ( + toggleStatus(option.value)} + onSelect={(e) => e.preventDefault()} + > + + + ))} + + + + + + + + + + + +
) } diff --git a/src/features/dashboard/templates/builds/table-body.tsx b/src/features/dashboard/templates/builds/table-body.tsx new file mode 100644 index 000000000..5f7add7e8 --- /dev/null +++ b/src/features/dashboard/templates/builds/table-body.tsx @@ -0,0 +1,74 @@ +import { flexRender, type Table } from '@tanstack/react-table' +import type { RefObject } from 'react' +import type { ListedBuildModel } from '@/core/modules/builds/models' +import { useVirtualRows } from '@/lib/hooks/use-virtual-rows' +import { cn } from '@/lib/utils' +import { DataTableBody, DataTableCell, DataTableRow } from '@/ui/data-table' +import { columnClassName, isRightAlignedColumn } from './table-config' + +const ROW_HEIGHT_PX = 40 +const VIRTUAL_OVERSCAN = 8 +const INITIAL_FALLBACK_ROW_COUNT = 100 + +interface BuildsTableBodyProps { + table: Table + scrollRef: RefObject + onRowClick: (build: ListedBuildModel) => void +} + +export function BuildsTableBody({ + table, + scrollRef, + onRowClick, +}: BuildsTableBodyProps) { + 'use no memo' + + const rows = table.getRowModel().rows + const { + virtualRows, + totalHeight: virtualizedTotalHeight, + paddingTop: virtualPaddingTop, + } = useVirtualRows({ + rows, + scrollRef, + estimateSizePx: ROW_HEIGHT_PX, + overscan: VIRTUAL_OVERSCAN, + }) + + const renderedRows = + virtualRows.length > 0 + ? virtualRows + : rows.slice(0, INITIAL_FALLBACK_ROW_COUNT) + + return ( + + {virtualPaddingTop > 0 &&
} + {renderedRows.map((row) => { + const isBuilding = row.original.status === 'building' + + return ( + onRowClick(row.original)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ) + })} + + ) +} diff --git a/src/features/dashboard/templates/builds/table-cells.tsx b/src/features/dashboard/templates/builds/table-cells.tsx index d610f0c62..fff79bb87 100644 --- a/src/features/dashboard/templates/builds/table-cells.tsx +++ b/src/features/dashboard/templates/builds/table-cells.tsx @@ -19,14 +19,22 @@ import { Badge } from '@/ui/primitives/badge' import { Button } from '@/ui/primitives/button' import { CheckmarkIcon, CloseIcon } from '@/ui/primitives/icons' import { Loader } from '@/ui/primitives/loader' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/ui/primitives/tooltip' +import { EnvdVersion } from '../../common/envd-version' +import ResourceUsage from '../../common/resource-usage' export function BuildId({ id }: { id: string }) { return ( - {id.slice(0, 6)}...{id.slice(-6)} + {id.slice(0, 7)}...{id.slice(-5)} ) } @@ -107,9 +115,10 @@ export function StartedAt({ timestamp }: { timestamp: number }) { interface StatusProps { status: BuildStatus + statusMessage?: ListedBuildModel['statusMessage'] } -export function Status({ status }: StatusProps) { +export function Status({ status, statusMessage }: StatusProps) { const config: Record< BuildStatus, { @@ -137,31 +146,65 @@ export function Status({ status }: StatusProps) { const { label, icon, variant } = config[status]! + const badge = ( + + {icon} + {label} + + ) + + const showReason = status === 'failed' && Boolean(statusMessage) + return ( -
- - {icon} - {label} - +
+ {showReason ? ( + + {badge} + + {statusMessage} + + + ) : ( + badge + )}
) } -export function Reason({ - statusMessage, -}: { - statusMessage: ListedBuildModel['statusMessage'] -}) { - if (!statusMessage) return null +export function Cpu({ cpuCount }: { cpuCount: number }) { + return ( +
+ +
+ ) +} +export function Memory({ memoryMB }: { memoryMB: number }) { return ( - - {statusMessage} - +
+ +
+ ) +} + +export function Storage({ diskSizeMB }: { diskSizeMB: number | null }) { + const diskSizeGB = diskSizeMB != null ? diskSizeMB / 1024 : null + return ( +
+ +
+ ) +} + +export function Envd({ version }: { version: string | null }) { + return ( +
+ +
) } diff --git a/src/features/dashboard/templates/builds/table-config.tsx b/src/features/dashboard/templates/builds/table-config.tsx new file mode 100644 index 000000000..9337a635f --- /dev/null +++ b/src/features/dashboard/templates/builds/table-config.tsx @@ -0,0 +1,124 @@ +'use client' + +import { + type ColumnDef, + getCoreRowModel, + type TableOptions, +} from '@tanstack/react-table' +import type { ListedBuildModel } from '@/core/modules/builds/models' +import { + BuildId, + Cpu, + Duration, + Envd, + Memory, + StartedAt, + Status, + Storage, + Template, +} from './table-cells' + +export const fallbackData: ListedBuildModel[] = [] + +const RIGHT_ALIGNED_COLUMNS = new Set([ + 'cpuCount', + 'memoryMB', + 'diskSizeMB', + 'envdVersion', + 'createdAt', + 'duration', +]) + +export const isRightAlignedColumn = (id: string) => + RIGHT_ALIGNED_COLUMNS.has(id) + +// Flex behavior per column, applied to both header and body cells. Columns are +// fixed-width by default (shrink-0 → horizontal scroll); Template grows to fill +// the leftover space. +const COLUMN_CLASSNAMES: Record = { + template: 'flex-1 min-w-[160px]', +} + +export const columnClassName = (id: string) => + COLUMN_CLASSNAMES[id] ?? 'shrink-0' + +export const buildsColumns: ColumnDef[] = [ + { + accessorKey: 'status', + header: 'Status', + size: 76, + cell: ({ row }) => ( + + ), + }, + { + accessorKey: 'template', + header: 'Template', + size: 160, + cell: ({ row }) => ( +