diff --git a/spec/openapi.dashboard-api.yaml b/spec/openapi.dashboard-api.yaml index 453c94c8b..465226803 100644 --- a/spec/openapi.dashboard-api.yaml +++ b/spec/openapi.dashboard-api.yaml @@ -10,6 +10,9 @@ components: type: apiKey in: header name: X-Admin-Token + # Generated code uses security schemas in the alphabetical order. + # In order to check first the token, and then the team (so we can already use the user), + # there is a 1 and 2 present in the names of the security schemas. Supabase1TokenAuth: type: apiKey in: header @@ -18,6 +21,16 @@ components: type: apiKey in: header name: X-Supabase-Team + # AuthProviderBearerAuth / AuthProviderTeamAuth: B before T in the name + # so Bearer is validated before Team (same reason as Supabase1/2 above). + AuthProviderBearerAuth: + type: http + scheme: bearer + bearerFormat: access_token + AuthProviderTeamAuth: + type: apiKey + in: header + name: X-Team-ID parameters: build_id: @@ -108,6 +121,94 @@ components: description: Team slug to resolve. schema: type: string + templateID: + name: templateID + in: path + required: true + description: Identifier of the template. + schema: + type: string + tag: + name: tag + in: query + required: true + description: Template tag name to check. + schema: + type: string + minLength: 1 + tag_assignment_limit: + name: assignmentLimit + in: query + required: false + description: Maximum number of ready assignment rows to return per tag. + schema: + type: integer + format: int32 + minimum: 1 + maximum: 25 + default: 6 + tag_path: + name: tag + in: path + required: true + description: Template tag name. + schema: + type: string + minLength: 1 + tag_assignments_limit: + name: limit + in: query + required: false + description: Maximum number of assignment rows to return per page. + schema: + type: integer + format: int32 + minimum: 1 + maximum: 100 + default: 50 + tag_assignments_cursor: + name: cursor + in: query + required: false + description: Cursor returned by the previous list response in `assigned_at|assignment_id` format. + schema: + type: string + tag_groups_limit: + name: tagsLimit + in: query + required: false + description: Maximum number of distinct tags to return per page. + schema: + type: integer + format: int32 + minimum: 1 + maximum: 100 + default: 25 + tag_groups_cursor: + name: tagsCursor + in: query + required: false + description: Cursor returned by the previous list response in `{sort}|{latest_assigned_at}|{tag}` format (sort-tagged, RFC3339Nano). + schema: + type: string + tag_groups_search: + name: search + in: query + required: false + description: Case-insensitive substring filter on tag name. Allowed characters are `a-z`, `0-9`, `.`, `_`, `-`. + schema: + type: string + maxLength: 64 + pattern: "^[a-z0-9._-]*$" + tag_groups_sort: + name: sort + in: query + required: false + description: Sort order for the returned tag groups. + schema: + type: string + enum: [latest_desc, latest_asc, name_asc, name_desc] + default: latest_desc responses: "400": @@ -140,6 +241,12 @@ components: application/json: schema: $ref: "#/components/schemas/Error" + "502": + description: Upstream error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" schemas: Error: @@ -156,6 +263,69 @@ components: type: string description: Error message. + AdminAuthProviderProfile: + type: object + required: + - userId + - email + properties: + userId: + type: string + format: uuid + description: Internal E2B user identifier. + email: + type: string + nullable: true + description: Email address from the configured auth provider. + + AdminAuthProviderProfilesResponse: + type: object + required: + - profiles + properties: + profiles: + type: array + items: + $ref: "#/components/schemas/AdminAuthProviderProfile" + + AdminAuthProviderProfilesResolveRequest: + type: object + required: + - userIds + properties: + userIds: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: + type: string + format: uuid + + AdminAuthProviderProfilesLookupEmailRequest: + type: object + required: + - email + properties: + email: + type: string + format: email + + AdminTeamBootstrapRequest: + type: object + required: + - name + - email + properties: + name: + type: string + minLength: 1 + description: Team name. + email: + type: string + format: email + description: Billing/contact email for the team. + BuildStatus: type: string description: Build status mapped for dashboard clients. @@ -438,12 +608,24 @@ components: - email - isDefault - createdAt + - providers properties: id: type: string format: uuid email: type: string + name: + type: string + nullable: true + profilePictureUrl: + type: string + format: uri + nullable: true + providers: + type: array + items: + type: string isDefault: type: boolean addedBy: @@ -580,6 +762,190 @@ components: items: $ref: "#/components/schemas/DefaultTemplate" + TemplateDetail: + type: object + description: Dashboard-shaped single-template read. Mirrors the infra-api `Template` schema fields the dashboard renders. + required: + - templateID + - buildID + - cpuCount + - memoryMB + - diskSizeMB + - public + - aliases + - names + - createdAt + - updatedAt + - lastSpawnedAt + - spawnCount + - buildCount + - envdVersion + properties: + templateID: + type: string + description: Identifier of the template. + buildID: + type: string + description: Identifier of the latest ready build for the template, or the zero UUID when none. + cpuCount: + allOf: + - $ref: "#/components/schemas/CPUCount" + nullable: true + description: vCPU count of the latest ready default build, or `null` when none. + memoryMB: + allOf: + - $ref: "#/components/schemas/MemoryMB" + nullable: true + description: Memory in MiB of the latest ready default build, or `null` when none. + diskSizeMB: + allOf: + - $ref: "#/components/schemas/DiskSizeMB" + nullable: true + description: Disk size in MiB of the latest ready default build, or `null` when none. + public: + type: boolean + description: Whether the template is public or only accessible by the team. + aliases: + type: array + description: Aliases of the template. + deprecated: true + items: + type: string + names: + type: array + description: Names of the template (namespace/alias format when namespaced). + items: + type: string + createdAt: + type: string + format: date-time + description: Time when the template was created. + updatedAt: + type: string + format: date-time + description: Time when the template was last updated. + lastSpawnedAt: + type: string + format: date-time + nullable: true + description: Time when the template was last used. + spawnCount: + type: integer + format: int64 + description: Number of times the template was used. + buildCount: + type: integer + format: int32 + description: Number of times the template was built. + envdVersion: + type: string + nullable: true + description: envd version of the latest ready default build, or `null` when none. + + TemplateTagAssignment: + type: object + required: + - assignmentId + - buildId + - assignedAt + - buildCreatedAt + - buildFinishedAt + properties: + assignmentId: + type: string + format: uuid + description: Identifier of the tag assignment event. + buildId: + type: string + format: uuid + description: Identifier of the assigned build. + assignedAt: + type: string + format: date-time + description: Time when the tag was assigned to the build. + buildCreatedAt: + type: string + format: date-time + description: Time when the assigned build was created. + buildFinishedAt: + type: string + format: date-time + nullable: true + description: Time when the assigned build finished. + + TemplateTagGroup: + type: object + required: + - tag + - assignments + - hasMore + properties: + tag: + type: string + description: Template tag name. + assignments: + type: array + description: Ready assignment events for this tag, sorted latest first. + items: + $ref: "#/components/schemas/TemplateTagAssignment" + hasMore: + type: boolean + description: Whether more ready assignment events exist beyond the requested assignment limit. + + TemplateTagGroupsResponse: + type: object + required: + - tags + - nextCursor + properties: + tags: + type: array + items: + $ref: "#/components/schemas/TemplateTagGroup" + nextCursor: + type: string + nullable: true + description: Cursor to pass as `tagsCursor` for the next page, or `null` if there is no next page. + + TemplateTagsCountResponse: + type: object + required: + - total + properties: + total: + type: integer + format: int64 + description: Total distinct ready tags for the template. + + TemplateTagExistsResponse: + type: object + required: + - exists + - normalizedTag + properties: + exists: + type: boolean + description: Whether the template tag has at least one ready assignment. + normalizedTag: + type: string + description: Normalized template tag name. + + TemplateTagAssignmentsResponse: + type: object + required: + - data + - nextCursor + properties: + data: + type: array + description: Ready assignment events for the tag, sorted latest first. + items: + $ref: "#/components/schemas/TemplateTagAssignment" + nextCursor: + type: string + nullable: true + description: Cursor to pass to the next list request, or `null` if there is no next page. + TeamResolveResponse: type: object required: @@ -617,6 +983,8 @@ paths: security: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] parameters: - $ref: "#/components/parameters/build_id_or_template" - $ref: "#/components/parameters/build_statuses" @@ -645,6 +1013,8 @@ paths: security: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] parameters: - $ref: "#/components/parameters/build_ids" @@ -671,6 +1041,8 @@ paths: security: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] parameters: - $ref: "#/components/parameters/build_id" responses: @@ -696,6 +1068,8 @@ paths: security: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] parameters: - $ref: "#/components/parameters/sandboxID" responses: @@ -721,6 +1095,7 @@ paths: tags: [teams] security: - Supabase1TokenAuth: [] + - AuthProviderBearerAuth: [] responses: "200": description: Successfully returned user teams. @@ -737,6 +1112,7 @@ paths: tags: [teams] security: - Supabase1TokenAuth: [] + - AuthProviderBearerAuth: [] requestBody: required: true content: @@ -777,6 +1153,109 @@ paths: "500": $ref: "#/components/responses/500" + /admin/teams/bootstrap: + post: + summary: Bootstrap team + description: Creates and provisions a team for an admin-authenticated bootstrap workflow. + tags: [admin] + security: + - AdminTokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AdminTeamBootstrapRequest" + responses: + "200": + description: Successfully bootstrapped team. + content: + application/json: + schema: + $ref: "#/components/schemas/TeamResolveResponse" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "502": + $ref: "#/components/responses/502" + "500": + $ref: "#/components/responses/500" + + /admin/user-profiles/resolve: + post: + summary: Resolve user profiles + tags: [admin] + security: + - AdminTokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AdminAuthProviderProfilesResolveRequest" + responses: + "200": + description: Successfully resolved profiles. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminAuthProviderProfilesResponse" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + + /admin/user-profiles/by-email: + post: + summary: Lookup user profiles by email + tags: [admin] + security: + - AdminTokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AdminAuthProviderProfilesLookupEmailRequest" + responses: + "200": + description: Successfully found matching profiles. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminAuthProviderProfilesResponse" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + + /admin/user-profiles/{userId}: + get: + summary: Get user profile + tags: [admin] + security: + - AdminTokenAuth: [] + parameters: + - $ref: "#/components/parameters/userId" + responses: + "200": + description: Successfully found profile. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminAuthProviderProfilesResponse" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + /teams/resolve: get: summary: Resolve team identity @@ -784,6 +1263,7 @@ paths: tags: [teams] security: - Supabase1TokenAuth: [] + - AuthProviderBearerAuth: [] parameters: - $ref: "#/components/parameters/teamSlug" responses: @@ -809,6 +1289,8 @@ paths: security: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] parameters: - $ref: "#/components/parameters/teamID" requestBody: @@ -840,6 +1322,8 @@ paths: security: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] parameters: - $ref: "#/components/parameters/teamID" responses: @@ -861,6 +1345,8 @@ paths: security: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] parameters: - $ref: "#/components/parameters/teamID" requestBody: @@ -890,6 +1376,8 @@ paths: security: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] parameters: - $ref: "#/components/parameters/teamID" - $ref: "#/components/parameters/userId" @@ -912,6 +1400,7 @@ paths: tags: [templates] security: - Supabase1TokenAuth: [] + - AuthProviderBearerAuth: [] responses: "200": description: Successfully returned default templates. @@ -923,3 +1412,158 @@ paths: $ref: "#/components/responses/401" "500": $ref: "#/components/responses/500" + + /templates/{templateID}: + get: + summary: Get template + description: Returns a single template owned by the current team. Dashboard-shaped read, indexed by template ID. + tags: [templates] + security: + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: "#/components/parameters/templateID" + responses: + "200": + description: Successfully returned the template. + content: + application/json: + schema: + $ref: "#/components/schemas/TemplateDetail" + "401": + $ref: "#/components/responses/401" + "403": + $ref: "#/components/responses/403" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + + /templates/{templateID}/tags/groups: + get: + summary: List template tag groups + description: Returns ready template tag assignment groups with bounded per-tag history, paginated over tags with keyset cursor. + tags: [templates] + security: + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: "#/components/parameters/templateID" + - $ref: "#/components/parameters/tag_assignment_limit" + - $ref: "#/components/parameters/tag_groups_limit" + - $ref: "#/components/parameters/tag_groups_cursor" + - $ref: "#/components/parameters/tag_groups_search" + - $ref: "#/components/parameters/tag_groups_sort" + responses: + "200": + description: Successfully returned template tag groups. + content: + application/json: + schema: + $ref: "#/components/schemas/TemplateTagGroupsResponse" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "403": + $ref: "#/components/responses/403" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + + /templates/{templateID}/tags/count: + get: + summary: Count template tags + description: Returns the total number of distinct ready tags for the template. + tags: [templates] + security: + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: "#/components/parameters/templateID" + responses: + "200": + description: Successfully returned tag count. + content: + application/json: + schema: + $ref: "#/components/schemas/TemplateTagsCountResponse" + "401": + $ref: "#/components/responses/401" + "403": + $ref: "#/components/responses/403" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + + /templates/{templateID}/tags/exists: + get: + summary: Check ready template tag existence + description: Checks whether a template tag has at least one ready assignment. + tags: [templates] + security: + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: "#/components/parameters/templateID" + - $ref: "#/components/parameters/tag" + responses: + "200": + description: Successfully checked template tag existence. + content: + application/json: + schema: + $ref: "#/components/schemas/TemplateTagExistsResponse" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "403": + $ref: "#/components/responses/403" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + + /templates/{templateID}/tags/{tag}/assignments: + get: + summary: List ready assignments for a single template tag + description: Returns ready tag assignment events for a single tag, ordered newest first, with keyset cursor pagination. + tags: [templates] + security: + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: "#/components/parameters/templateID" + - $ref: "#/components/parameters/tag_path" + - $ref: "#/components/parameters/tag_assignments_cursor" + - $ref: "#/components/parameters/tag_assignments_limit" + responses: + "200": + description: Successfully returned tag assignment page. + content: + application/json: + schema: + $ref: "#/components/schemas/TemplateTagAssignmentsResponse" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "403": + $ref: "#/components/responses/403" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" diff --git a/src/app/dashboard/[teamSlug]/templates/(tabs)/builds/page.tsx b/src/app/dashboard/[teamSlug]/templates/(tabs)/builds/page.tsx index 971ead502..362545aaf 100644 --- a/src/app/dashboard/[teamSlug]/templates/(tabs)/builds/page.tsx +++ b/src/app/dashboard/[teamSlug]/templates/(tabs)/builds/page.tsx @@ -1,11 +1,16 @@ -import BuildsHeader from '@/features/dashboard/templates/builds/header' +'use client' + +import { AllBuildsHeader } from '@/features/dashboard/templates/builds/all-builds-header' import BuildsTable from '@/features/dashboard/templates/builds/table' +import useFilters from '@/features/dashboard/templates/builds/use-filters' export default function TemplateBuildsPage() { + const { statuses, buildIdOrTemplate } = useFilters() + return (
- - + +
) } diff --git a/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/builds/page.tsx b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/builds/page.tsx new file mode 100644 index 000000000..827348479 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/builds/page.tsx @@ -0,0 +1,40 @@ +'use client' + +import { use, useCallback } from 'react' +import type { ListedBuildModel } from '@/core/modules/builds/models' +import BuildsTable from '@/features/dashboard/templates/builds/table' +import { TemplateBuildsHeader } from '@/features/dashboard/templates/builds/template-builds-header' +import useTemplateBuildsFilters from '@/features/dashboard/templates/builds/use-template-builds-filters' +import { isValidUuid } from '@/features/dashboard/templates/tags/helpers' + +export default function TemplateDetailBuildsPage({ + params, +}: PageProps<'/dashboard/[teamSlug]/templates/[templateId]'>) { + const { templateId } = use(params) + const { statuses, q } = useTemplateBuildsFilters() + + const trimmed = q?.trim() ?? '' + const isSearching = trimmed.length > 0 + const isValidSearch = !isSearching || isValidUuid(trimmed) + + const postFilter = useCallback( + (build: ListedBuildModel) => build.templateId === templateId, + [templateId] + ) + + return ( +
+ + +
+ ) +} diff --git a/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/layout.tsx b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/layout.tsx new file mode 100644 index 000000000..00c6ea84c --- /dev/null +++ b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/layout.tsx @@ -0,0 +1,25 @@ +import { Suspense } from 'react' +import TemplateDetailTabs from '@/features/dashboard/templates/detail/tabs' +import TemplateTitleBinder from '@/features/dashboard/templates/detail/title-binder' +import { HydrateClient, prefetch, trpc } from '@/trpc/server' + +export default async function TemplateDetailLayout({ + children, + params, +}: LayoutProps<'/dashboard/[teamSlug]/templates/[templateId]'>) { + const { teamSlug, templateId } = await params + + prefetch(trpc.templates.getTemplate.queryOptions({ teamSlug, templateId })) + + return ( + +
+ + + + + {children} +
+
+ ) +} diff --git a/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/loading.tsx b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/loading.tsx new file mode 100644 index 000000000..2b9dc7302 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/loading.tsx @@ -0,0 +1,5 @@ +import LoadingLayout from '@/features/dashboard/loading-layout' + +export default function TemplateDetailLoading() { + return +} diff --git a/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/overview/page.tsx b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/overview/page.tsx new file mode 100644 index 000000000..65ed5fd48 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/overview/page.tsx @@ -0,0 +1,22 @@ +import { Suspense } from 'react' +import TemplateOverview from '@/features/dashboard/templates/detail/overview' +import { TemplateOverviewSkeleton } from '@/features/dashboard/templates/detail/overview/skeleton' +import { HydrateClient, prefetch, trpc } from '@/trpc/server' + +export default async function TemplateOverviewPage({ + params, +}: PageProps<'/dashboard/[teamSlug]/templates/[templateId]'>) { + const { teamSlug, templateId } = await params + + prefetch(trpc.templates.getTemplate.queryOptions({ teamSlug, templateId })) + + return ( + +
+ }> + + +
+
+ ) +} diff --git a/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/tags/[tag]/page.tsx b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/tags/[tag]/page.tsx new file mode 100644 index 000000000..88399092f --- /dev/null +++ b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/tags/[tag]/page.tsx @@ -0,0 +1,35 @@ +import { Suspense } from 'react' +import LoadingLayout from '@/features/dashboard/loading-layout' +import { TAG_HISTORY_PAGE_LIMIT } from '@/features/dashboard/templates/tags/constants' +import TagHistoryView from '@/features/dashboard/templates/tags/history/tag-history-view' +import { HydrateClient, prefetch, trpc } from '@/trpc/server' + +export default async function TemplateTagHistoryPage({ + params, +}: PageProps<'/dashboard/[teamSlug]/templates/[templateId]/tags/[tag]'>) { + const { teamSlug, templateId, tag } = await params + const decodedTag = decodeURIComponent(tag) + + prefetch( + trpc.templates.getTagAssignments.infiniteQueryOptions({ + teamSlug, + templateId, + tag: decodedTag, + limit: TAG_HISTORY_PAGE_LIMIT, + }) + ) + + return ( + +
+ }> + + +
+
+ ) +} diff --git a/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/tags/page.tsx b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/tags/page.tsx new file mode 100644 index 000000000..2ada92047 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/tags/page.tsx @@ -0,0 +1,32 @@ +import { Suspense } from 'react' +import LoadingLayout from '@/features/dashboard/loading-layout' +import { TAGS_PAGE_LIMIT } from '@/features/dashboard/templates/tags/constants' +import TagsTable from '@/features/dashboard/templates/tags/table' +import { HydrateClient, prefetch, trpc } from '@/trpc/server' + +export default async function TemplateTagsPage({ + params, +}: PageProps<'/dashboard/[teamSlug]/templates/[templateId]'>) { + const { teamSlug, templateId } = await params + + prefetch( + trpc.templates.getTagGroups.infiniteQueryOptions({ + teamSlug, + templateId, + limit: TAGS_PAGE_LIMIT, + search: undefined, + sort: undefined, + }) + ) + prefetch(trpc.templates.getTagCount.queryOptions({ teamSlug, templateId })) + + return ( + +
+ }> + + +
+
+ ) +} diff --git a/src/app/dashboard/[teamSlug]/templates/[templateId]/error.tsx b/src/app/dashboard/[teamSlug]/templates/[templateId]/error.tsx index de47bcc1e..0dc66d8aa 100644 --- a/src/app/dashboard/[teamSlug]/templates/[templateId]/error.tsx +++ b/src/app/dashboard/[teamSlug]/templates/[templateId]/error.tsx @@ -1,5 +1,7 @@ 'use client' +import { TRPCClientError } from '@trpc/client' +import { notFound } from 'next/navigation' import { DashboardRouteError } from '@/features/dashboard/shared/route-error' export default function TemplateDetailsError({ @@ -9,5 +11,9 @@ export default function TemplateDetailsError({ error: Error & { digest?: string } reset: () => void }) { + if (error instanceof TRPCClientError && error.data?.code === 'NOT_FOUND') { + notFound() + } + return } diff --git a/src/app/dashboard/[teamSlug]/templates/[templateId]/not-found.tsx b/src/app/dashboard/[teamSlug]/templates/[templateId]/not-found.tsx new file mode 100644 index 000000000..85e73d054 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/templates/[templateId]/not-found.tsx @@ -0,0 +1,37 @@ +'use client' + +import { Button } from '@/ui/primitives/button' +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, +} from '@/ui/primitives/card' +import { ArrowLeftIcon } from '@/ui/primitives/icons' + +export default function TemplateNotFound() { + return ( +
+ + + 404 + Template not found + + +

We couldn’t find this template in your team.

+
+ + + +
+
+ ) +} diff --git a/src/app/dashboard/[teamSlug]/templates/[templateId]/page.tsx b/src/app/dashboard/[teamSlug]/templates/[templateId]/page.tsx new file mode 100644 index 000000000..8cf4ea965 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/templates/[templateId]/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from 'next/navigation' +import { PROTECTED_URLS } from '@/configs/urls' + +export default async function TemplateDetailPage({ + params, +}: PageProps<'/dashboard/[teamSlug]/templates/[templateId]'>) { + const { teamSlug, templateId } = await params + + redirect(PROTECTED_URLS.TEMPLATE_OVERVIEW(teamSlug, templateId)) +} diff --git a/src/configs/layout.ts b/src/configs/layout.ts index 0f29ab80c..f8087009c 100644 --- a/src/configs/layout.ts +++ b/src/configs/layout.ts @@ -89,6 +89,15 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< }, } }, + '/dashboard/*/templates/*/overview': (pathname) => + templateDetailLayoutConfig(pathname), + '/dashboard/*/templates/*/tags': (pathname) => + templateDetailLayoutConfig(pathname), + '/dashboard/*/templates/*/tags/*': (pathname) => + templateDetailLayoutConfig(pathname), + // Keep this more specific glob ahead of /templates/*/builds/* (build detail). + '/dashboard/*/templates/*/builds': (pathname) => + templateDetailLayoutConfig(pathname), // integrations '/dashboard/*/webhooks': () => ({ @@ -163,6 +172,29 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< }), } +// Pathname fallback for detail tabs; usePageTitle replaces with the friendly template name once data loads. +function templateDetailLayoutConfig(pathname: string): DashboardLayoutConfig { + const parts = pathname.split('/') + const teamSlug = parts[2]! + const templateId = parts[4]! + const templateIdSliced = + templateId.length > 14 + ? `${templateId.slice(0, 6)}...${templateId.slice(-6)}` + : templateId + + return { + title: [ + { + label: 'Templates', + href: PROTECTED_URLS.TEMPLATES_LIST(teamSlug), + }, + { label: templateIdSliced }, + ], + type: 'custom', + copyValue: templateId, + } +} + /** * Returns the layout config for a given dashboard pathname. * @param pathname - The current route pathname diff --git a/src/configs/mock-data.ts b/src/configs/mock-data.ts index bd8d40b57..4d886d6b3 100644 --- a/src/configs/mock-data.ts +++ b/src/configs/mock-data.ts @@ -20,10 +20,6 @@ const DEFAULT_TEMPLATES: DefaultTemplate[] = [ templateID: 'code-interpreter-v1', createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', - createdBy: { - email: 'admin@example.com', - id: 'user_001', - }, isDefault: true, defaultDescription: 'Code Interpreter', lastSpawnedAt: '2024-01-01T00:00:00Z', @@ -42,7 +38,6 @@ const DEFAULT_TEMPLATES: DefaultTemplate[] = [ templateID: 'web-starter-v1', createdAt: '2024-01-05T00:00:00Z', updatedAt: '2024-01-05T00:00:00Z', - createdBy: null, isDefault: true, defaultDescription: 'Web Development Environment', lastSpawnedAt: '2024-01-05T00:00:00Z', @@ -61,10 +56,6 @@ const DEFAULT_TEMPLATES: DefaultTemplate[] = [ templateID: 'data-science-v1', createdAt: '2024-01-06T00:00:00Z', updatedAt: '2024-01-06T00:00:00Z', - createdBy: { - email: 'datascience@example.com', - id: 'user_002', - }, isDefault: true, defaultDescription: 'Data Science Environment with ML Libraries', lastSpawnedAt: '2024-01-06T00:00:00Z', @@ -86,10 +77,6 @@ const TEMPLATES: Template[] = [ templateID: 'node-typescript-v1', createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', - createdBy: { - email: 'admin@example.com', - id: 'user_001', - }, lastSpawnedAt: '2024-01-01T00:00:00Z', spawnCount: 10, buildCount: 1, @@ -106,7 +93,6 @@ const TEMPLATES: Template[] = [ templateID: 'react-vite-v2', createdAt: '2024-01-02T00:00:00Z', updatedAt: '2024-01-02T00:00:00Z', - createdBy: null, lastSpawnedAt: '2024-01-02T00:00:00Z', spawnCount: 10, buildCount: 1, @@ -123,7 +109,6 @@ const TEMPLATES: Template[] = [ templateID: 'postgres-v15', createdAt: '2024-01-03T00:00:00Z', updatedAt: '2024-01-03T00:00:00Z', - createdBy: null, lastSpawnedAt: '2024-01-03T00:00:00Z', spawnCount: 10, buildCount: 1, @@ -140,7 +125,6 @@ const TEMPLATES: Template[] = [ templateID: 'redis-v7', createdAt: '2024-01-04T00:00:00Z', updatedAt: '2024-01-04T00:00:00Z', - createdBy: null, lastSpawnedAt: '2024-01-04T00:00:00Z', spawnCount: 10, buildCount: 1, @@ -157,7 +141,6 @@ const TEMPLATES: Template[] = [ templateID: 'python-ml-v1', createdAt: '2024-01-05T00:00:00Z', updatedAt: '2024-01-05T00:00:00Z', - createdBy: null, lastSpawnedAt: '2024-01-05T00:00:00Z', spawnCount: 10, buildCount: 1, @@ -174,7 +157,6 @@ const TEMPLATES: Template[] = [ templateID: 'elastic-v8', createdAt: '2024-01-06T00:00:00Z', updatedAt: '2024-01-06T00:00:00Z', - createdBy: null, lastSpawnedAt: '2024-01-06T00:00:00Z', spawnCount: 10, buildCount: 1, @@ -191,7 +173,6 @@ const TEMPLATES: Template[] = [ templateID: 'grafana-v9', createdAt: '2024-01-07T00:00:00Z', updatedAt: '2024-01-07T00:00:00Z', - createdBy: null, lastSpawnedAt: '2024-01-07T00:00:00Z', spawnCount: 10, buildCount: 1, @@ -208,7 +189,6 @@ const TEMPLATES: Template[] = [ templateID: 'nginx-v1', createdAt: '2024-01-08T00:00:00Z', updatedAt: '2024-01-08T00:00:00Z', - createdBy: null, lastSpawnedAt: '2024-01-08T00:00:00Z', spawnCount: 10, buildCount: 1, @@ -225,7 +205,6 @@ const TEMPLATES: Template[] = [ templateID: 'mongodb-v6', createdAt: '2024-01-09T00:00:00Z', updatedAt: '2024-01-09T00:00:00Z', - createdBy: null, lastSpawnedAt: '2024-01-09T00:00:00Z', spawnCount: 10, buildCount: 1, @@ -242,7 +221,6 @@ const TEMPLATES: Template[] = [ templateID: 'mysql-v8', createdAt: '2024-01-10T00:00:00Z', updatedAt: '2024-01-10T00:00:00Z', - createdBy: null, lastSpawnedAt: '2024-01-10T00:00:00Z', spawnCount: 10, buildCount: 1, @@ -259,10 +237,6 @@ const TEMPLATES: Template[] = [ templateID: 'nextjs-v14', createdAt: '2024-01-11T00:00:00Z', updatedAt: '2024-01-11T00:00:00Z', - createdBy: { - email: 'frontend@example.com', - id: 'user_003', - }, lastSpawnedAt: '2024-01-11T00:00:00Z', spawnCount: 15, buildCount: 1, @@ -279,7 +253,6 @@ const TEMPLATES: Template[] = [ templateID: 'vue-v3', createdAt: '2024-01-12T00:00:00Z', updatedAt: '2024-01-12T00:00:00Z', - createdBy: null, lastSpawnedAt: '2024-01-12T00:00:00Z', spawnCount: 8, buildCount: 1, @@ -296,10 +269,6 @@ const TEMPLATES: Template[] = [ templateID: 'django-v4', createdAt: '2024-01-13T00:00:00Z', updatedAt: '2024-01-13T00:00:00Z', - createdBy: { - email: 'python@example.com', - id: 'user_004', - }, lastSpawnedAt: '2024-01-13T00:00:00Z', spawnCount: 12, buildCount: 1, @@ -316,7 +285,6 @@ const TEMPLATES: Template[] = [ templateID: 'flask-v2', createdAt: '2024-01-14T00:00:00Z', updatedAt: '2024-01-14T00:00:00Z', - createdBy: null, lastSpawnedAt: '2024-01-14T00:00:00Z', spawnCount: 6, buildCount: 1, @@ -333,10 +301,6 @@ const TEMPLATES: Template[] = [ templateID: 'golang-v1.21', createdAt: '2024-01-15T00:00:00Z', updatedAt: '2024-01-15T00:00:00Z', - createdBy: { - email: 'go@example.com', - id: 'user_005', - }, lastSpawnedAt: '2024-01-15T00:00:00Z', spawnCount: 14, buildCount: 1, @@ -353,7 +317,6 @@ const TEMPLATES: Template[] = [ templateID: 'rust-v1.75', createdAt: '2024-01-16T00:00:00Z', updatedAt: '2024-01-16T00:00:00Z', - createdBy: null, lastSpawnedAt: '2024-01-16T00:00:00Z', spawnCount: 7, buildCount: 1, @@ -370,10 +333,6 @@ const TEMPLATES: Template[] = [ templateID: 'java-spring-v3', createdAt: '2024-01-17T00:00:00Z', updatedAt: '2024-01-17T00:00:00Z', - createdBy: { - email: 'java@example.com', - id: 'user_006', - }, lastSpawnedAt: '2024-01-17T00:00:00Z', spawnCount: 11, buildCount: 1, @@ -390,7 +349,6 @@ const TEMPLATES: Template[] = [ templateID: 'dotnet-v8', createdAt: '2024-01-18T00:00:00Z', updatedAt: '2024-01-18T00:00:00Z', - createdBy: null, lastSpawnedAt: '2024-01-18T00:00:00Z', spawnCount: 9, buildCount: 1, @@ -407,10 +365,6 @@ const TEMPLATES: Template[] = [ templateID: 'php-laravel-v10', createdAt: '2024-01-19T00:00:00Z', updatedAt: '2024-01-19T00:00:00Z', - createdBy: { - email: 'php@example.com', - id: 'user_007', - }, lastSpawnedAt: '2024-01-19T00:00:00Z', spawnCount: 5, buildCount: 1, @@ -427,7 +381,6 @@ const TEMPLATES: Template[] = [ templateID: 'ruby-rails-v7', createdAt: '2024-01-20T00:00:00Z', updatedAt: '2024-01-20T00:00:00Z', - createdBy: null, lastSpawnedAt: '2024-01-20T00:00:00Z', spawnCount: 4, buildCount: 1, @@ -444,10 +397,6 @@ const TEMPLATES: Template[] = [ templateID: 'jupyter-v6', createdAt: '2024-01-21T00:00:00Z', updatedAt: '2024-01-21T00:00:00Z', - createdBy: { - email: 'datascience@example.com', - id: 'user_008', - }, lastSpawnedAt: '2024-01-21T00:00:00Z', spawnCount: 13, buildCount: 1, @@ -464,10 +413,6 @@ const TEMPLATES: Template[] = [ templateID: 'tensorflow-v2.15', createdAt: '2024-01-22T00:00:00Z', updatedAt: '2024-01-22T00:00:00Z', - createdBy: { - email: 'ml@example.com', - id: 'user_009', - }, lastSpawnedAt: '2024-01-22T00:00:00Z', spawnCount: 18, buildCount: 1, @@ -484,10 +429,6 @@ const TEMPLATES: Template[] = [ templateID: 'pytorch-v2.1', createdAt: '2024-01-23T00:00:00Z', updatedAt: '2024-01-23T00:00:00Z', - createdBy: { - email: 'ml@example.com', - id: 'user_009', - }, lastSpawnedAt: '2024-01-23T00:00:00Z', spawnCount: 16, buildCount: 1, @@ -504,7 +445,6 @@ const TEMPLATES: Template[] = [ templateID: 'cassandra-v4', createdAt: '2024-01-24T00:00:00Z', updatedAt: '2024-01-24T00:00:00Z', - createdBy: null, lastSpawnedAt: '2024-01-24T00:00:00Z', spawnCount: 3, buildCount: 1, @@ -521,10 +461,6 @@ const TEMPLATES: Template[] = [ templateID: 'docker-v24', createdAt: '2024-01-25T00:00:00Z', updatedAt: '2024-01-25T00:00:00Z', - createdBy: { - email: 'devops@example.com', - id: 'user_010', - }, lastSpawnedAt: '2024-01-25T00:00:00Z', spawnCount: 20, buildCount: 1, @@ -541,10 +477,6 @@ const TEMPLATES: Template[] = [ templateID: 'kubernetes-v1.28', createdAt: '2024-01-26T00:00:00Z', updatedAt: '2024-01-26T00:00:00Z', - createdBy: { - email: 'devops@example.com', - id: 'user_010', - }, lastSpawnedAt: '2024-01-26T00:00:00Z', spawnCount: 8, buildCount: 1, @@ -561,7 +493,6 @@ const TEMPLATES: Template[] = [ templateID: 'terraform-v1.6', createdAt: '2024-01-27T00:00:00Z', updatedAt: '2024-01-27T00:00:00Z', - createdBy: null, lastSpawnedAt: '2024-01-27T00:00:00Z', spawnCount: 6, buildCount: 1, @@ -577,10 +508,6 @@ const TEMPLATES: Template[] = [ templateID: 'ansible-v2.16', createdAt: '2024-01-28T00:00:00Z', updatedAt: '2024-01-28T00:00:00Z', - createdBy: { - email: 'devops@example.com', - id: 'user_010', - }, lastSpawnedAt: '2024-01-28T00:00:00Z', envdVersion: '0.1.0', spawnCount: 4, @@ -598,7 +525,6 @@ const TEMPLATES: Template[] = [ envdVersion: '0.1.0', createdAt: '2024-01-29T00:00:00Z', updatedAt: '2024-01-29T00:00:00Z', - createdBy: null, lastSpawnedAt: '2024-01-29T00:00:00Z', spawnCount: 7, buildCount: 1, @@ -615,10 +541,6 @@ const TEMPLATES: Template[] = [ templateID: 'jenkins-v2.426', createdAt: '2024-01-30T00:00:00Z', updatedAt: '2024-01-30T00:00:00Z', - createdBy: { - email: 'ci@example.com', - id: 'user_011', - }, lastSpawnedAt: '2024-01-30T00:00:00Z', spawnCount: 12, buildCount: 1, @@ -635,7 +557,6 @@ const TEMPLATES: Template[] = [ templateID: 'gitlab-ci-v16', createdAt: '2024-01-31T00:00:00Z', updatedAt: '2024-01-31T00:00:00Z', - createdBy: null, lastSpawnedAt: '2024-01-31T00:00:00Z', spawnCount: 9, buildCount: 1, @@ -652,10 +573,6 @@ const TEMPLATES: Template[] = [ templateID: 'apache-spark-v3.5', createdAt: '2024-02-01T00:00:00Z', updatedAt: '2024-02-01T00:00:00Z', - createdBy: { - email: 'bigdata@example.com', - id: 'user_012', - }, lastSpawnedAt: '2024-02-01T00:00:00Z', spawnCount: 5, buildCount: 1, @@ -672,7 +589,6 @@ const TEMPLATES: Template[] = [ templateID: 'kafka-v3.6', createdAt: '2024-02-02T00:00:00Z', updatedAt: '2024-02-02T00:00:00Z', - createdBy: null, lastSpawnedAt: '2024-02-02T00:00:00Z', spawnCount: 8, buildCount: 1, @@ -689,10 +605,6 @@ const TEMPLATES: Template[] = [ templateID: 'rabbitmq-v3.12', createdAt: '2024-02-03T00:00:00Z', updatedAt: '2024-02-03T00:00:00Z', - createdBy: { - email: 'messaging@example.com', - id: 'user_013', - }, lastSpawnedAt: '2024-02-03T00:00:00Z', spawnCount: 6, buildCount: 1, @@ -709,7 +621,6 @@ const TEMPLATES: Template[] = [ templateID: 'zookeeper-v3.9', createdAt: '2024-02-04T00:00:00Z', updatedAt: '2024-02-04T00:00:00Z', - createdBy: null, lastSpawnedAt: '2024-02-04T00:00:00Z', spawnCount: 4, buildCount: 1, @@ -726,10 +637,6 @@ const TEMPLATES: Template[] = [ templateID: 'solr-v9.4', createdAt: '2024-02-05T00:00:00Z', updatedAt: '2024-02-05T00:00:00Z', - createdBy: { - email: 'search@example.com', - id: 'user_014', - }, lastSpawnedAt: '2024-02-05T00:00:00Z', spawnCount: 3, buildCount: 1, @@ -746,7 +653,6 @@ const TEMPLATES: Template[] = [ createdAt: '2024-02-06T00:00:00Z', envdVersion: '0.1.0', updatedAt: '2024-02-06T00:00:00Z', - createdBy: null, lastSpawnedAt: '2024-02-06T00:00:00Z', spawnCount: 5, buildCount: 1, @@ -762,7 +668,6 @@ const TEMPLATES: Template[] = [ templateID: 'kibana-v8.11', createdAt: '2024-02-07T00:00:00Z', updatedAt: '2024-02-07T00:00:00Z', - createdBy: null, lastSpawnedAt: '2024-02-07T00:00:00Z', spawnCount: 7, buildCount: 1, @@ -780,10 +685,6 @@ const TEMPLATES: Template[] = [ createdAt: '2024-02-08T00:00:00Z', envdVersion: '0.1.0', updatedAt: '2024-02-08T00:00:00Z', - createdBy: { - email: 'storage@example.com', - id: 'user_015', - }, lastSpawnedAt: '2024-02-08T00:00:00Z', spawnCount: 6, buildCount: 1, @@ -800,10 +701,6 @@ const TEMPLATES: Template[] = [ templateID: 'vault-v1.15', createdAt: '2024-02-09T00:00:00Z', updatedAt: '2024-02-09T00:00:00Z', - createdBy: { - email: 'security@example.com', - id: 'user_016', - }, lastSpawnedAt: '2024-02-09T00:00:00Z', spawnCount: 4, buildCount: 1, @@ -836,9 +733,10 @@ function generateMockSandboxes(count: number): Sandboxes { const startDate = subHours(baseDate, Math.floor(Math.random() * 30)) const endDate = addHours(startDate, 24) - // Random memory and CPU from template's allowed values - const memory = template.memoryMB - const cpu = template.cpuCount + // Random memory and CPU from template's allowed values; mock templates + // always carry real specs, so default the (now-nullable) fields here. + const memory = template.memoryMB ?? 2048 + const cpu = template.cpuCount ?? 2 sandboxes.push({ alias: `${env}-${component}-${nanoid(4)}`, @@ -846,8 +744,8 @@ function generateMockSandboxes(count: number): Sandboxes { cpuCount: cpu, endAt: endDate.toISOString(), memoryMB: memory, - diskSizeMB: template.diskSizeMB, - envdVersion: template.envdVersion, + diskSizeMB: template.diskSizeMB ?? 1024, + envdVersion: template.envdVersion ?? '0.1.0', metadata: { lastUpdate: new Date( startDate.getTime() + 2 * 60 * 60 * 1000 diff --git a/src/configs/urls.ts b/src/configs/urls.ts index 9412b2a97..da6de8a26 100644 --- a/src/configs/urls.ts +++ b/src/configs/urls.ts @@ -44,6 +44,14 @@ export const PROTECTED_URLS = { TEMPLATES_LIST: (teamSlug: string) => `/dashboard/${teamSlug}/templates/list`, TEMPLATES_BUILDS: (teamSlug: string) => `/dashboard/${teamSlug}/templates/builds`, + TEMPLATE_OVERVIEW: (teamSlug: string, templateId: string) => + `/dashboard/${teamSlug}/templates/${templateId}/overview`, + TEMPLATE_DETAIL_BUILDS: (teamSlug: string, templateId: string) => + `/dashboard/${teamSlug}/templates/${templateId}/builds`, + TEMPLATE_TAGS: (teamSlug: string, templateId: string) => + `/dashboard/${teamSlug}/templates/${templateId}/tags`, + TEMPLATE_TAG_HISTORY: (teamSlug: string, templateId: string, tag: string) => + `/dashboard/${teamSlug}/templates/${templateId}/tags/${encodeURIComponent(tag)}`, TEMPLATE_BUILD: (teamSlug: string, templateId: string, buildId: string) => `/dashboard/${teamSlug}/templates/${templateId}/builds/${buildId}`, diff --git a/src/core/modules/templates/models.ts b/src/core/modules/templates/models.ts index aa08bebab..085adb116 100644 --- a/src/core/modules/templates/models.ts +++ b/src/core/modules/templates/models.ts @@ -1,3 +1,4 @@ +import type { components as DashboardComponents } from '@/contracts/dashboard-api' import type { components as InfraComponents } from '@/contracts/infra-api' export type Template = Pick< @@ -12,7 +13,24 @@ export type Template = Pick< | 'names' | 'createdAt' | 'updatedAt' - | 'createdBy' + | 'lastSpawnedAt' + | 'spawnCount' + | 'buildCount' + | 'envdVersion' +> + +export type TemplateDetail = Pick< + DashboardComponents['schemas']['TemplateDetail'], + | 'templateID' + | 'buildID' + | 'cpuCount' + | 'memoryMB' + | 'diskSizeMB' + | 'public' + | 'aliases' + | 'names' + | 'createdAt' + | 'updatedAt' | 'lastSpawnedAt' | 'spawnCount' | 'buildCount' @@ -23,3 +41,12 @@ export type DefaultTemplate = Template & { isDefault: true defaultDescription?: string } + +export type TemplateTag = InfraComponents['schemas']['TemplateTag'] + +export type TemplateTagAssignment = + DashboardComponents['schemas']['TemplateTagAssignment'] +export type TemplateTagGroup = + DashboardComponents['schemas']['TemplateTagGroup'] +export type TemplateTagExistsResult = + DashboardComponents['schemas']['TemplateTagExistsResponse'] diff --git a/src/core/modules/templates/repository.server.ts b/src/core/modules/templates/repository.server.ts index 9d282af3e..7f50815af 100644 --- a/src/core/modules/templates/repository.server.ts +++ b/src/core/modules/templates/repository.server.ts @@ -7,7 +7,14 @@ import { MOCK_DEFAULT_TEMPLATES_DATA, MOCK_TEMPLATES_DATA, } from '@/configs/mock-data' -import type { DefaultTemplate, Template } from '@/core/modules/templates/models' +import type { + DefaultTemplate, + Template, + TemplateDetail, + TemplateTagAssignment, + TemplateTagExistsResult, + TemplateTagGroup, +} from '@/core/modules/templates/models' import { type AuthUserEmailResolver, getAuthUserEmailsById, @@ -30,7 +37,46 @@ type TemplatesRepositoryDeps = { export interface TeamTemplatesRepository { getTeamTemplates(): Promise> + getTemplate( + templateId: string + ): Promise> + getTagGroups( + templateId: string, + options?: { + assignmentLimit?: number + tagsLimit?: number + tagsCursor?: string + search?: string + sort?: 'latest_desc' | 'latest_asc' | 'name_asc' | 'name_desc' + } + ): Promise< + RepoResult<{ tags: TemplateTagGroup[]; nextCursor: string | null }> + > + getTagCount(templateId: string): Promise> + getTagAssignments( + templateId: string, + tag: string, + options?: { cursor?: string; limit?: number } + ): Promise< + RepoResult<{ + data: TemplateTagAssignment[] + nextCursor: string | null + }> + > + checkTagExists( + templateId: string, + tag: string + ): Promise> + assignTag(input: { + templateName: string + buildId: string + tag: string + }): Promise> deleteTemplate(templateId: string): Promise> + deleteTags( + templateName: string, + tags: string[] + ): Promise> updateTemplateVisibility( templateId: string, isPublic: boolean @@ -53,6 +99,197 @@ export function createTemplatesRepository( } ): TeamTemplatesRepository { return { + async getTemplate(templateId) { + if (USE_MOCK_DATA) { + const template = MOCK_TEMPLATES_DATA.find( + (t) => t.templateID === templateId + ) + if (!template) { + return err( + repoErrorFromHttp(404, 'Template not found in this team', undefined) + ) + } + + return ok({ template }) + } + + const res = await deps.apiClient.GET('/templates/{templateID}', { + params: { + path: { + templateID: templateId, + }, + }, + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + }) + + if (!res.response.ok || res.error) { + return err( + repoErrorFromHttp( + res.response.status, + res.error?.message ?? 'Failed to fetch template', + res.error + ) + ) + } + + if (!res.data) { + return err( + repoErrorFromHttp(404, 'Template not found in this team', undefined) + ) + } + + return ok({ template: res.data }) + }, + async getTagGroups(templateId, options) { + if (USE_MOCK_DATA) { + return ok({ tags: [], nextCursor: null }) + } + + const res = await deps.apiClient.GET( + '/templates/{templateID}/tags/groups', + { + params: { + path: { + templateID: templateId, + }, + query: { + assignmentLimit: options?.assignmentLimit, + tagsLimit: options?.tagsLimit, + tagsCursor: options?.tagsCursor, + search: options?.search, + sort: options?.sort, + }, + }, + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + } + ) + + if (!res.response.ok || res.error) { + return err( + repoErrorFromHttp( + res.response.status, + res.error?.message ?? 'Failed to fetch template tag groups', + res.error + ) + ) + } + + return ok({ + tags: res.data?.tags ?? [], + nextCursor: res.data?.nextCursor ?? null, + }) + }, + async getTagCount(templateId) { + if (USE_MOCK_DATA) { + return ok({ total: 0 }) + } + + const res = await deps.apiClient.GET( + '/templates/{templateID}/tags/count', + { + params: { + path: { + templateID: templateId, + }, + }, + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + } + ) + + if (!res.response.ok || res.error) { + return err( + repoErrorFromHttp( + res.response.status, + res.error?.message ?? 'Failed to fetch template tag count', + res.error + ) + ) + } + + return ok({ total: res.data?.total ?? 0 }) + }, + async getTagAssignments(templateId, tag, options) { + if (USE_MOCK_DATA) { + return ok({ data: [], nextCursor: null }) + } + + const res = await deps.apiClient.GET( + '/templates/{templateID}/tags/{tag}/assignments', + { + params: { + path: { + templateID: templateId, + tag, + }, + query: { + cursor: options?.cursor, + limit: options?.limit, + }, + }, + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + } + ) + + if (!res.response.ok || res.error) { + return err( + repoErrorFromHttp( + res.response.status, + res.error?.message ?? 'Failed to fetch template tag assignments', + res.error + ) + ) + } + + return ok({ + data: res.data?.data ?? [], + nextCursor: res.data?.nextCursor ?? null, + }) + }, + async checkTagExists(templateId, tag) { + if (USE_MOCK_DATA) { + return ok({ exists: false, normalizedTag: tag }) + } + + const res = await deps.apiClient.GET( + '/templates/{templateID}/tags/exists', + { + params: { + path: { + templateID: templateId, + }, + query: { + tag, + }, + }, + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + } + ) + + if (!res.response.ok || res.error) { + return err( + repoErrorFromHttp( + res.response.status, + res.error?.message ?? 'Failed to check template tag existence', + res.error + ) + ) + } + + return ok({ + exists: res.data?.exists ?? false, + normalizedTag: res.data?.normalizedTag ?? tag, + }) + }, async getTeamTemplates() { if (USE_MOCK_DATA) { return ok({ @@ -87,6 +324,55 @@ export function createTemplatesRepository( ), }) }, + async assignTag({ templateName, buildId, tag }) { + const res = await deps.infraClient.POST('/templates/tags', { + body: { + target: `${templateName}:${buildId}`, + tags: [tag], + }, + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + }) + + if (!res.response.ok || res.error) { + return err( + repoErrorFromHttp( + res.response.status, + res.error?.message ?? 'Failed to assign template tag', + res.error + ) + ) + } + + return ok({ + tags: res.data?.tags ?? [tag], + buildID: res.data?.buildID ?? buildId, + }) + }, + async deleteTags(templateName, tags) { + const res = await deps.infraClient.DELETE('/templates/tags', { + body: { + name: templateName, + tags, + }, + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + }) + + if (!res.response.ok || res.error) { + return err( + repoErrorFromHttp( + res.response.status, + res.error?.message ?? 'Failed to delete template tags', + res.error + ) + ) + } + + return ok({ success: true as const }) + }, async deleteTemplate(templateId) { const res = await deps.infraClient.DELETE('/templates/{templateID}', { params: { diff --git a/src/core/server/api/routers/templates.ts b/src/core/server/api/routers/templates.ts index d063c251c..09cb262f1 100644 --- a/src/core/server/api/routers/templates.ts +++ b/src/core/server/api/routers/templates.ts @@ -42,6 +42,97 @@ export const templatesRouter = createTRPCRouter({ return result.data }), + getTemplate: teamTemplatesRepositoryProcedure + .input( + z.object({ + templateId: z.string(), + }) + ) + .query(async ({ ctx, input }) => { + const result = await ctx.templatesRepository.getTemplate(input.templateId) + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + return result.data + }), + + getTagGroups: teamTemplatesRepositoryProcedure + .input( + z.object({ + templateId: z.string(), + assignmentLimit: z.number().int().min(1).max(25).optional(), + limit: z.number().int().min(1).max(100).optional(), + cursor: z.string().optional(), + search: z + .string() + .max(64) + .regex(/^[a-z0-9._-]*$/) + .optional(), + sort: z + .enum(['latest_desc', 'latest_asc', 'name_asc', 'name_desc']) + .optional(), + }) + ) + .query(async ({ ctx, input }) => { + const result = await ctx.templatesRepository.getTagGroups( + input.templateId, + { + assignmentLimit: input.assignmentLimit, + tagsLimit: input.limit, + tagsCursor: input.cursor, + search: input.search, + sort: input.sort, + } + ) + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + return result.data + }), + + getTagCount: teamTemplatesRepositoryProcedure + .input( + z.object({ + templateId: z.string(), + }) + ) + .query(async ({ ctx, input }) => { + const result = await ctx.templatesRepository.getTagCount(input.templateId) + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + return result.data + }), + + getTagAssignments: teamTemplatesRepositoryProcedure + .input( + z.object({ + templateId: z.string(), + tag: z.string().min(1), + cursor: z.string().optional(), + limit: z.number().int().min(1).max(100).default(50), + }) + ) + .query(async ({ ctx, input }) => { + const result = await ctx.templatesRepository.getTagAssignments( + input.templateId, + input.tag, + { cursor: input.cursor, limit: input.limit } + ) + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + return result.data + }), + + checkTagExists: teamTemplatesRepositoryProcedure + .input( + z.object({ + templateId: z.string(), + tag: z.string().min(1), + }) + ) + .query(async ({ ctx, input }) => { + const result = await ctx.templatesRepository.checkTagExists( + input.templateId, + input.tag + ) + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + return result.data + }), + getDefaultTemplatesCached: templatesRepositoryProcedure.query( async ({ ctx }) => { const result = await ctx.templatesRepository.getDefaultTemplatesCached() @@ -100,4 +191,44 @@ export const templatesRouter = createTRPCRouter({ return result.data }), + + deleteTags: teamTemplatesRepositoryProcedure + .input( + z.object({ + templateId: z.string(), + templateName: z.string(), + tags: z.array(z.string()).min(1), + }) + ) + .mutation(async ({ ctx, input }) => { + const result = await ctx.templatesRepository.deleteTags( + input.templateName, + input.tags + ) + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + return result.data + }), + + assignTag: teamTemplatesRepositoryProcedure + .input( + z.object({ + templateId: z.string(), + templateName: z.string(), + buildId: z.string().uuid(), + tag: z + .string() + .min(1) + .max(128) + .regex(/^[a-z0-9._-]+$/), + }) + ) + .mutation(async ({ ctx, input }) => { + const result = await ctx.templatesRepository.assignTag({ + templateName: input.templateName, + buildId: input.buildId, + tag: input.tag, + }) + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + return result.data + }), }) diff --git a/src/core/shared/contracts/dashboard-api.types.ts b/src/core/shared/contracts/dashboard-api.types.ts index 6cf043667..eadf97843 100644 --- a/src/core/shared/contracts/dashboard-api.types.ts +++ b/src/core/shared/contracts/dashboard-api.types.ts @@ -327,6 +327,224 @@ export interface paths { patch?: never trace?: never } + '/admin/users/bootstrap': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + /** Bootstrap auth provider user */ + post: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['AdminAuthProviderUserBootstrapRequest'] + } + } + responses: { + /** @description Successfully bootstrapped user. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['TeamResolveResponse'] + } + } + 400: components['responses']['400'] + 401: components['responses']['401'] + 500: components['responses']['500'] + } + } + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/admin/teams/bootstrap': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + /** + * Bootstrap team + * @description Creates and provisions a team for an admin-authenticated bootstrap workflow. + */ + post: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['AdminTeamBootstrapRequest'] + } + } + responses: { + /** @description Successfully bootstrapped team. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['TeamResolveResponse'] + } + } + 400: components['responses']['400'] + 401: components['responses']['401'] + 500: components['responses']['500'] + 502: components['responses']['502'] + } + } + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/admin/user-profiles/resolve': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + /** Resolve user profiles */ + post: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['AdminAuthProviderProfilesResolveRequest'] + } + } + responses: { + /** @description Successfully resolved profiles. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['AdminAuthProviderProfilesResponse'] + } + } + 400: components['responses']['400'] + 401: components['responses']['401'] + 500: components['responses']['500'] + } + } + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/admin/user-profiles/by-email': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + /** Lookup user profiles by email */ + post: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['AdminAuthProviderProfilesLookupEmailRequest'] + } + } + responses: { + /** @description Successfully found matching profiles. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['AdminAuthProviderProfilesResponse'] + } + } + 400: components['responses']['400'] + 401: components['responses']['401'] + 500: components['responses']['500'] + } + } + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/admin/user-profiles/{userId}': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Get user profile */ + get: { + parameters: { + query?: never + header?: never + path: { + /** @description Identifier of the user. */ + userId: components['parameters']['userId'] + } + cookie?: never + } + requestBody?: never + responses: { + /** @description Successfully found profile. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['AdminAuthProviderProfilesResponse'] + } + } + 400: components['responses']['400'] + 401: components['responses']['401'] + 500: components['responses']['500'] + } + } + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/teams/resolve': { parameters: { query?: never @@ -576,6 +794,260 @@ export interface paths { patch?: never trace?: never } + '/templates/{templateID}': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** + * Get template + * @description Returns a single template owned by the current team. Dashboard-shaped read, indexed by template ID. + */ + get: { + parameters: { + query?: never + header?: never + path: { + /** @description Identifier of the template. */ + templateID: components['parameters']['templateID'] + } + cookie?: never + } + requestBody?: never + responses: { + /** @description Successfully returned the template. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['TemplateDetail'] + } + } + 401: components['responses']['401'] + 403: components['responses']['403'] + 404: components['responses']['404'] + 500: components['responses']['500'] + } + } + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/templates/{templateID}/tags/groups': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** + * List template tag groups + * @description Returns ready template tag assignment groups with bounded per-tag history, paginated over tags with keyset cursor. + */ + get: { + parameters: { + query?: { + /** @description Maximum number of ready assignment rows to return per tag. */ + assignmentLimit?: components['parameters']['tag_assignment_limit'] + /** @description Maximum number of distinct tags to return per page. */ + tagsLimit?: components['parameters']['tag_groups_limit'] + /** @description Cursor returned by the previous list response in `{sort}|{latest_assigned_at}|{tag}` format (sort-tagged, RFC3339Nano). */ + tagsCursor?: components['parameters']['tag_groups_cursor'] + /** @description Case-insensitive substring filter on tag name. Allowed characters are `a-z`, `0-9`, `.`, `_`, `-`. */ + search?: components['parameters']['tag_groups_search'] + /** @description Sort order for the returned tag groups. */ + sort?: components['parameters']['tag_groups_sort'] + } + header?: never + path: { + /** @description Identifier of the template. */ + templateID: components['parameters']['templateID'] + } + cookie?: never + } + requestBody?: never + responses: { + /** @description Successfully returned template tag groups. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['TemplateTagGroupsResponse'] + } + } + 400: components['responses']['400'] + 401: components['responses']['401'] + 403: components['responses']['403'] + 404: components['responses']['404'] + 500: components['responses']['500'] + } + } + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/templates/{templateID}/tags/count': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** + * Count template tags + * @description Returns the total number of distinct ready tags for the template. + */ + get: { + parameters: { + query?: never + header?: never + path: { + /** @description Identifier of the template. */ + templateID: components['parameters']['templateID'] + } + cookie?: never + } + requestBody?: never + responses: { + /** @description Successfully returned tag count. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['TemplateTagsCountResponse'] + } + } + 401: components['responses']['401'] + 403: components['responses']['403'] + 404: components['responses']['404'] + 500: components['responses']['500'] + } + } + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/templates/{templateID}/tags/exists': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** + * Check ready template tag existence + * @description Checks whether a template tag has at least one ready assignment. + */ + get: { + parameters: { + query: { + /** @description Template tag name to check. */ + tag: components['parameters']['tag'] + } + header?: never + path: { + /** @description Identifier of the template. */ + templateID: components['parameters']['templateID'] + } + cookie?: never + } + requestBody?: never + responses: { + /** @description Successfully checked template tag existence. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['TemplateTagExistsResponse'] + } + } + 400: components['responses']['400'] + 401: components['responses']['401'] + 403: components['responses']['403'] + 404: components['responses']['404'] + 500: components['responses']['500'] + } + } + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/templates/{templateID}/tags/{tag}/assignments': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** + * List ready assignments for a single template tag + * @description Returns ready tag assignment events for a single tag, ordered newest first, with keyset cursor pagination. + */ + get: { + parameters: { + query?: { + /** @description Cursor returned by the previous list response in `assigned_at|assignment_id` format. */ + cursor?: components['parameters']['tag_assignments_cursor'] + /** @description Maximum number of assignment rows to return per page. */ + limit?: components['parameters']['tag_assignments_limit'] + } + header?: never + path: { + /** @description Identifier of the template. */ + templateID: components['parameters']['templateID'] + /** @description Template tag name. */ + tag: components['parameters']['tag_path'] + } + cookie?: never + } + requestBody?: never + responses: { + /** @description Successfully returned tag assignment page. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['TemplateTagAssignmentsResponse'] + } + } + 400: components['responses']['400'] + 401: components['responses']['401'] + 403: components['responses']['403'] + 404: components['responses']['404'] + 500: components['responses']['500'] + } + } + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } } export type webhooks = Record export interface components { @@ -589,6 +1061,41 @@ export interface components { /** @description Error message. */ message: string } + AdminAuthProviderProfile: { + /** + * Format: uuid + * @description Internal E2B user identifier. + */ + userId: string + /** @description Email address from the configured auth provider. */ + email: string | null + } + AdminAuthProviderProfilesResponse: { + profiles: components['schemas']['AdminAuthProviderProfile'][] + } + AdminAuthProviderProfilesResolveRequest: { + userIds: string[] + } + AdminAuthProviderProfilesLookupEmailRequest: { + /** Format: email */ + email: string + } + AdminAuthProviderUserBootstrapRequest: { + oidc_issuer: string + oidc_user_id: string + /** Format: email */ + oidc_user_email: string + oidc_user_name?: string | null + } + AdminTeamBootstrapRequest: { + /** @description Team name. */ + name: string + /** + * Format: email + * @description Billing/contact email for the team. + */ + email: string + } /** * @description Build status mapped for dashboard clients. * @enum {string} @@ -738,6 +1245,10 @@ export interface components { /** Format: uuid */ id: string email: string + name?: string | null + /** Format: uri */ + profilePictureUrl?: string | null + providers: string[] isDefault: boolean /** Format: uuid */ addedBy?: string | null @@ -791,6 +1302,114 @@ export interface components { DefaultTemplatesResponse: { templates: components['schemas']['DefaultTemplate'][] } + /** @description Dashboard-shaped single-template read. Mirrors the infra-api `Template` schema fields the dashboard renders. */ + TemplateDetail: { + /** @description Identifier of the template. */ + templateID: string + /** @description Identifier of the latest ready build for the template, or the zero UUID when none. */ + buildID: string + /** @description vCPU count of the latest ready default build, or `null` when none. */ + cpuCount: components['schemas']['CPUCount'] | null + /** @description Memory in MiB of the latest ready default build, or `null` when none. */ + memoryMB: components['schemas']['MemoryMB'] | null + /** @description Disk size in MiB of the latest ready default build, or `null` when none. */ + diskSizeMB: components['schemas']['DiskSizeMB'] | null + /** @description Whether the template is public or only accessible by the team. */ + public: boolean + /** + * @deprecated + * @description Aliases of the template. + */ + aliases: string[] + /** @description Names of the template (namespace/alias format when namespaced). */ + names: string[] + /** + * Format: date-time + * @description Time when the template was created. + */ + createdAt: string + /** + * Format: date-time + * @description Time when the template was last updated. + */ + updatedAt: string + /** + * Format: date-time + * @description Time when the template was last used. + */ + lastSpawnedAt: string | null + /** + * Format: int64 + * @description Number of times the template was used. + */ + spawnCount: number + /** + * Format: int32 + * @description Number of times the template was built. + */ + buildCount: number + /** @description envd version of the latest ready default build, or `null` when none. */ + envdVersion: string | null + } + TemplateTagAssignment: { + /** + * Format: uuid + * @description Identifier of the tag assignment event. + */ + assignmentId: string + /** + * Format: uuid + * @description Identifier of the assigned build. + */ + buildId: string + /** + * Format: date-time + * @description Time when the tag was assigned to the build. + */ + assignedAt: string + /** + * Format: date-time + * @description Time when the assigned build was created. + */ + buildCreatedAt: string + /** + * Format: date-time + * @description Time when the assigned build finished. + */ + buildFinishedAt: string | null + } + TemplateTagGroup: { + /** @description Template tag name. */ + tag: string + /** @description Ready assignment events for this tag, sorted latest first. */ + assignments: components['schemas']['TemplateTagAssignment'][] + /** @description Whether more ready assignment events exist beyond the requested assignment limit. */ + hasMore: boolean + } + TemplateTagGroupsResponse: { + tags: components['schemas']['TemplateTagGroup'][] + /** @description Cursor to pass as `tagsCursor` for the next page, or `null` if there is no next page. */ + nextCursor: string | null + } + TemplateTagsCountResponse: { + /** + * Format: int64 + * @description Total distinct ready tags for the template. + */ + total: number + } + TemplateTagExistsResponse: { + /** @description Whether the template tag has at least one ready assignment. */ + exists: boolean + /** @description Normalized template tag name. */ + normalizedTag: string + } + TemplateTagAssignmentsResponse: { + /** @description Ready assignment events for the tag, sorted latest first. */ + data: components['schemas']['TemplateTagAssignment'][] + /** @description Cursor to pass to the next list request, or `null` if there is no next page. */ + nextCursor: string | null + } TeamResolveResponse: { /** Format: uuid */ id: string @@ -843,6 +1462,15 @@ export interface components { 'application/json': components['schemas']['Error'] } } + /** @description Upstream error */ + 502: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['Error'] + } + } } parameters: { /** @description Identifier of the build. */ @@ -865,6 +1493,26 @@ export interface components { userId: string /** @description Team slug to resolve. */ teamSlug: string + /** @description Identifier of the template. */ + templateID: string + /** @description Template tag name to check. */ + tag: string + /** @description Maximum number of ready assignment rows to return per tag. */ + tag_assignment_limit: number + /** @description Template tag name. */ + tag_path: string + /** @description Maximum number of assignment rows to return per page. */ + tag_assignments_limit: number + /** @description Cursor returned by the previous list response in `assigned_at|assignment_id` format. */ + tag_assignments_cursor: string + /** @description Maximum number of distinct tags to return per page. */ + tag_groups_limit: number + /** @description Cursor returned by the previous list response in `{sort}|{latest_assigned_at}|{tag}` format (sort-tagged, RFC3339Nano). */ + tag_groups_cursor: string + /** @description Case-insensitive substring filter on tag name. Allowed characters are `a-z`, `0-9`, `.`, `_`, `-`. */ + tag_groups_search: string + /** @description Sort order for the returned tag groups. */ + tag_groups_sort: 'latest_desc' | 'latest_asc' | 'name_asc' | 'name_desc' } requestBodies: never headers: never diff --git a/src/features/dashboard/build/header-cells.tsx b/src/features/dashboard/build/header-cells.tsx index 423013c16..5e9418ac4 100644 --- a/src/features/dashboard/build/header-cells.tsx +++ b/src/features/dashboard/build/header-cells.tsx @@ -1,4 +1,4 @@ -import { useRouter } from 'next/navigation' +import Link from 'next/link' import { useEffect, useState } from 'react' import { PROTECTED_URLS } from '@/configs/urls' import { useRouteParams } from '@/lib/hooks/use-route-params' @@ -10,7 +10,6 @@ import { import { cn } from '@/lib/utils/ui' import CopyButtonInline from '@/ui/copy-button-inline' import { Button } from '@/ui/primitives/button' -import { useTemplateTableStore } from '../templates/list/stores/table-store' export function Template({ template, @@ -21,23 +20,21 @@ export function Template({ templateId: string className?: string }) { - const router = useRouter() const { teamSlug } = useRouteParams<'/dashboard/[teamSlug]/templates'>() return ( ) } diff --git a/src/features/dashboard/layouts/header.tsx b/src/features/dashboard/layouts/header.tsx index e1a780ee7..bf5996c6b 100644 --- a/src/features/dashboard/layouts/header.tsx +++ b/src/features/dashboard/layouts/header.tsx @@ -4,6 +4,7 @@ import Link from 'next/link' import { usePathname } from 'next/navigation' import { Fragment } from 'react' import { getDashboardLayoutConfig, type TitleSegment } from '@/configs/layout' +import { usePageTitleStore } from '@/lib/hooks/use-page-title' import { cn } from '@/lib/utils' import ClientOnly from '@/ui/client-only' import CopyButton from '@/ui/copy-button' @@ -21,7 +22,12 @@ export default function DashboardLayoutHeader({ }: DashboardLayoutHeaderProps) { const pathname = usePathname() const config = getDashboardLayoutConfig(pathname) - const copyableValue = config.copyValue ?? null + const override = usePageTitleStore((state) => state.override) + + const title = override?.title ?? config.title + const copyableValue = override + ? (override.copyValue ?? null) + : (config.copyValue ?? null) return (

- +

{copyableValue && ( ) { const templateIdentifier = (getValue() as string | undefined) ?? '--' const { team } = useDashboard() - const router = useRouter() const templateId = row.original.templateID + if (!templateId) { + return ( + + {templateIdentifier} + + ) + } + return ( - ) } diff --git a/src/features/dashboard/templates/builds/all-builds-header.tsx b/src/features/dashboard/templates/builds/all-builds-header.tsx new file mode 100644 index 000000000..107c50599 --- /dev/null +++ b/src/features/dashboard/templates/builds/all-builds-header.tsx @@ -0,0 +1,27 @@ +'use client' + +import { SearchIcon } from '@/ui/primitives/icons' +import { DebouncedInput } from '@/ui/primitives/input' +import { BuildsStatusFilter } from './status-filter' +import useFilters from './use-filters' + +export function AllBuildsHeader() { + const { statuses, setStatuses, buildIdOrTemplate, setBuildIdOrTemplate } = + useFilters() + + return ( +
+
+ + setBuildIdOrTemplate(String(v))} + debounce={300} + /> +
+ +
+ ) +} diff --git a/src/features/dashboard/templates/builds/header.tsx b/src/features/dashboard/templates/builds/header.tsx deleted file mode 100644 index 2c783b8d8..000000000 --- a/src/features/dashboard/templates/builds/header.tsx +++ /dev/null @@ -1,146 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import type { BuildStatus } from '@/core/modules/builds/models' -import { cn } from '@/lib/utils' -import { Button } from '@/ui/primitives/button' -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/ui/primitives/dropdown-menu' -import { Input } from '@/ui/primitives/input' -import { Status } from './table-cells' -import useFilters from './use-filters' - -interface DashedStatusCircleIconProps { - status: BuildStatus - index: number -} - -const DashedStatusCircleIcon = ({ - status, - index, -}: DashedStatusCircleIconProps) => { - return ( -
- ) -} - -const StatusIcons = ({ - selectedStatuses, -}: { - selectedStatuses: BuildStatus[] -}) => { - const statusOrder: BuildStatus[] = ['building', 'failed', 'success'] - const sortedStatuses = statusOrder.filter((s) => selectedStatuses.includes(s)) - - return ( -
- {sortedStatuses.map((status, i) => ( - - ))} -
- ) -} - -const STATUS_OPTIONS: Array<{ value: BuildStatus; label: string }> = [ - { value: 'building', label: 'Building' }, - { value: 'success', label: 'Success' }, - { value: 'failed', label: 'Failed' }, -] - -export default function BuildsHeader() { - const { statuses, setStatuses, buildIdOrTemplate, setBuildIdOrTemplate } = - useFilters() - - const [localBuildIdOrTemplate, setLocalBuildIdOrTemplate] = useState( - buildIdOrTemplate ?? '' - ) - - const [localStatuses, setLocalStatuses] = useState(statuses) - - useEffect(() => { - setLocalBuildIdOrTemplate(buildIdOrTemplate ?? '') - }, [buildIdOrTemplate]) - - useEffect(() => { - setLocalStatuses(statuses) - }, [statuses]) - - const toggleStatus = (status: BuildStatus) => { - const isSelected = localStatuses.includes(status) - - if (isSelected && localStatuses.length === 1) { - return - } - - const newStatuses = isSelected - ? localStatuses.filter((s) => s !== status) - : [...localStatuses, status] - - setLocalStatuses(newStatuses) - setStatuses(newStatuses) - } - - const selectAllStatuses = () => { - const allStatuses = STATUS_OPTIONS.map((s) => s.value) - setLocalStatuses(allStatuses) - setStatuses(allStatuses) - } - - return ( -
- { - setLocalBuildIdOrTemplate(e.target.value) - setBuildIdOrTemplate(e.target.value) - }} - /> - - - - - - - e.preventDefault()} - > - All - - - {STATUS_OPTIONS.map((option) => ( - toggleStatus(option.value)} - onSelect={(e) => e.preventDefault()} - > - - - ))} - - -
- ) -} diff --git a/src/features/dashboard/templates/builds/status-filter.tsx b/src/features/dashboard/templates/builds/status-filter.tsx new file mode 100644 index 000000000..41ca529d9 --- /dev/null +++ b/src/features/dashboard/templates/builds/status-filter.tsx @@ -0,0 +1,121 @@ +'use client' + +import type { BuildStatus } from '@/core/modules/builds/models' +import { cn } from '@/lib/utils' +import { Button } from '@/ui/primitives/button' +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/ui/primitives/dropdown-menu' +import { Status } from './table-cells' + +const STATUS_OPTIONS: Array<{ value: BuildStatus; label: string }> = [ + { value: 'building', label: 'Building' }, + { value: 'success', label: 'Success' }, + { value: 'failed', label: 'Failed' }, +] + +const STATUS_DISPLAY_ORDER: BuildStatus[] = ['building', 'failed', 'success'] + +interface BuildsStatusFilterProps { + statuses: BuildStatus[] + onStatusesChange: (statuses: BuildStatus[]) => void +} + +export function BuildsStatusFilter({ + statuses, + onStatusesChange, +}: BuildsStatusFilterProps) { + const toggleStatus = (status: BuildStatus) => { + const isSelected = statuses.includes(status) + + // Don't allow deselecting the last status — the table query needs at least one. + if (isSelected && statuses.length === 1) return + + const next = isSelected + ? statuses.filter((s) => s !== status) + : [...statuses, status] + + onStatusesChange(next) + } + + const selectAll = () => { + onStatusesChange(STATUS_OPTIONS.map((s) => s.value)) + } + + return ( + + + + + + e.preventDefault()} + > + All + + + {STATUS_OPTIONS.map((option) => ( + toggleStatus(option.value)} + onSelect={(e) => e.preventDefault()} + > + + + ))} + + + ) +} + +interface StatusIconsProps { + selectedStatuses: BuildStatus[] +} + +function StatusIcons({ selectedStatuses }: StatusIconsProps) { + const sortedStatuses = STATUS_DISPLAY_ORDER.filter((s) => + selectedStatuses.includes(s) + ) + + return ( +
+ {sortedStatuses.map((status, i) => ( + + ))} +
+ ) +} + +interface DashedStatusCircleIconProps { + status: BuildStatus + index: number +} + +function DashedStatusCircleIcon({ + status, + index, +}: DashedStatusCircleIconProps) { + return ( +
+ ) +} diff --git a/src/features/dashboard/templates/builds/table-cells.tsx b/src/features/dashboard/templates/builds/table-cells.tsx index 2222fe610..eb67a3bf5 100644 --- a/src/features/dashboard/templates/builds/table-cells.tsx +++ b/src/features/dashboard/templates/builds/table-cells.tsx @@ -1,13 +1,12 @@ 'use client' -import { useRouter } from 'next/navigation' -import { useEffect, useState } from 'react' +import Link from 'next/link' import { PROTECTED_URLS } from '@/configs/urls' import type { BuildStatus, ListedBuildModel, } from '@/core/modules/builds/models' -import { useTemplateTableStore } from '@/features/dashboard/templates/list/stores/table-store' +import { useNow } from '@/lib/hooks/use-now' import { useRouteParams } from '@/lib/hooks/use-route-params' import { cn } from '@/lib/utils' import { @@ -20,13 +19,17 @@ import { Button } from '@/ui/primitives/button' import { CheckIcon, CloseIcon } from '@/ui/primitives/icons' import { Loader } from '@/ui/primitives/loader' -export function BuildId({ id }: { id: string }) { +export function BuildId({ id, className }: { id: string; className?: string }) { return ( - {id.slice(0, 6)}...{id.slice(-6)} + {id.slice(0, 7)}...{id.slice(-5)} ) } @@ -40,23 +43,21 @@ export function Template({ templateId: string className?: string }) { - const router = useRouter() const { teamSlug } = useRouteParams<'/dashboard/[teamSlug]/templates'>() return ( ) } @@ -106,17 +107,7 @@ export function Duration({ finishedAt: number | null isBuilding: boolean }) { - const [now, setNow] = useState(() => Date.now()) - - useEffect(() => { - if (!isBuilding) return - - const interval = setInterval(() => { - setNow(Date.now()) - }, 1000) - - return () => clearInterval(interval) - }, [isBuilding]) + const now = useNow(1000, isBuilding) const duration = isBuilding ? now - createdAt diff --git a/src/features/dashboard/templates/builds/table.tsx b/src/features/dashboard/templates/builds/table.tsx index 493280d3e..28a8acc03 100644 --- a/src/features/dashboard/templates/builds/table.tsx +++ b/src/features/dashboard/templates/builds/table.tsx @@ -7,12 +7,14 @@ import { useQueryClient, } from '@tanstack/react-query' import { useRouter } from 'next/navigation' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { PROTECTED_URLS } from '@/configs/urls' import type { + BuildStatus, ListedBuildModel, RunningBuildStatusModel, } from '@/core/modules/builds/models' +import { useFilterChangeTracking } from '@/lib/hooks/use-filter-change-tracking' import { useRouteParams } from '@/lib/hooks/use-route-params' import { cn } from '@/lib/utils/ui' import { useTRPC } from '@/trpc/client' @@ -37,34 +39,48 @@ import { Status, Template, } from './table-cells' -import useFilters from './use-filters' const BUILDS_REFETCH_INTERVAL_MS = 15_000 const RUNNING_BUILD_POLL_INTERVAL_MS = 3_000 const MAX_CACHED_PAGES = 3 const COLUMN_WIDTHS = { - id: 152, + id: 168, status: 96, template: 192, started: 126, duration: 96, } as const -const BuildsTable = () => { +interface BuildsTableProps { + filters: { + statuses: BuildStatus[] + buildIdOrTemplate?: string + } + // Optional client-side row filter applied after fetch + live-status merge. + postFilter?: (build: ListedBuildModel) => boolean + showTemplateColumn?: boolean + disabled?: boolean +} + +const BuildsTable = ({ + filters, + postFilter, + showTemplateColumn = true, + disabled = false, +}: BuildsTableProps) => { const trpc = useTRPC() const queryClient = useQueryClient() const router = useRouter() const scrollContainerRef = useRef(null) const { teamSlug } = useRouteParams<'/dashboard/[teamSlug]/templates'>() - const { statuses, buildIdOrTemplate } = useFilters() + + const { statuses, buildIdOrTemplate } = filters const { isFilterRefetching, clearFilterRefetching } = useFilterChangeTracking( - statuses, - buildIdOrTemplate + [statuses, buildIdOrTemplate] ) - // Builds list query const { data: paginatedBuilds, fetchNextPage, @@ -83,6 +99,7 @@ const BuildsTable = () => { refetchInterval: BUILDS_REFETCH_INTERVAL_MS, refetchIntervalInBackground: false, maxPages: MAX_CACHED_PAGES, + enabled: !disabled, } ) ) @@ -101,7 +118,10 @@ const BuildsTable = () => { } }, [isFetchingBuilds, isFilterRefetching, clearFilterRefetching]) - // Running builds status polling + useEffect(() => { + if (disabled) clearFilterRefetching() + }, [disabled, clearFilterRefetching]) + const runningBuildIds = useMemo( () => builds.filter((b) => b.status === 'building').map((b) => b.id), [builds] @@ -130,7 +150,11 @@ const BuildsTable = () => { [builds, runningStatusesData] ) - // Handlers + const visibleBuilds = useMemo(() => { + if (!postFilter) return buildsWithLiveStatus + return buildsWithLiveStatus.filter(postFilter) + }, [buildsWithLiveStatus, postFilter]) + const buildsQueryKey = trpc.builds.list.infiniteQueryOptions({ teamSlug, statuses, @@ -148,12 +172,14 @@ const BuildsTable = () => { } }, [queryClient, buildsQueryKey]) - // Derived UI state - const hasData = buildsWithLiveStatus.length > 0 - const showLoader = isInitialLoad && !hasData - const showEmpty = !isInitialLoad && !isFetchingBuilds && !hasData + const hasData = !disabled && visibleBuilds.length > 0 + const showLoader = !disabled && isInitialLoad && !hasData + const showEmpty = + disabled || (!isInitialLoad && !isFetchingBuilds && !hasData) const showFilterRefetchingOverlay = isFilterRefetching && hasData + const visibleColumnCount = showTemplateColumn ? 6 : 5 + return (
{ - + {!showTemplateColumn && } + {showTemplateColumn && ( + + )} - + {showTemplateColumn && } Status - Template + {!showTemplateColumn && ID} + {showTemplateColumn && Template} Started @@ -181,7 +211,7 @@ const BuildsTable = () => { Duration - ID + {showTemplateColumn && ID}
@@ -193,7 +223,7 @@ const BuildsTable = () => { > {showLoader && ( - +
@@ -203,7 +233,7 @@ const BuildsTable = () => { {showEmpty && ( - + @@ -214,7 +244,7 @@ const BuildsTable = () => { {hasScrolledPastInitialPages && ( @@ -222,9 +252,23 @@ const BuildsTable = () => { )} - {buildsWithLiveStatus.map((build) => { + {visibleBuilds.map((build) => { const isBuilding = build.status === 'building' + const idCell = ( + + + + ) + return ( { >
- -