diff --git a/app/features/edge/dns-zone/form.tsx b/app/features/edge/dns-zone/form.tsx index fa36cdc25..83ca26610 100644 --- a/app/features/edge/dns-zone/form.tsx +++ b/app/features/edge/dns-zone/form.tsx @@ -25,7 +25,7 @@ import { } from '@datum-ui/components'; import { Input } from '@datum-ui/components'; import { useEffect, useMemo, useRef } from 'react'; -import { Form, useFetcher } from 'react-router'; +import { Form, useFetcher, useSearchParams } from 'react-router'; import { AuthenticityTokenInput } from 'remix-utils/csrf/react'; import { useHydrated } from 'remix-utils/use-hydrated'; @@ -39,7 +39,9 @@ export const DnsZoneForm = ({ const isHydrated = useHydrated(); const isPending = useIsPending(); const inputRef = useRef(null); + const descriptionInputRef = useRef(null); const fetcher = useFetcher({ key: 'delete-dns-zone' }); + const [searchParams] = useSearchParams(); const { confirm } = useConfirmationDialog(); const deleteDnsZone = async () => { @@ -78,6 +80,9 @@ export const DnsZoneForm = ({ }, [defaultValue]); const [form, fields] = useForm({ + defaultValue: { + domainName: searchParams.get('domainName') ?? '', + }, id: 'dns-zone-form', constraint: getZodConstraint(formDnsZoneSchema), shouldValidate: 'onBlur', @@ -91,8 +96,20 @@ export const DnsZoneForm = ({ const descriptionControl = useInputControl(fields.description); useEffect(() => { - isHydrated && inputRef.current?.focus(); - }, [isHydrated]); + if (!isHydrated) return; + + const domainName = searchParams.get('domainName'); + if (domainName && !isEdit) { + // If domain is in searchParams, focus description input + descriptionInputRef.current?.focus(); + } else if (!isEdit) { + // Otherwise, focus domain name input (create mode) + inputRef.current?.focus(); + } else { + // Edit mode: focus description input + descriptionInputRef.current?.focus(); + } + }, [isHydrated, searchParams, isEdit]); useEffect(() => { if (defaultValue && defaultValue.domainName) { @@ -137,7 +154,7 @@ export const DnsZoneForm = ({ {...getInputProps(fields.description, { type: 'text' })} key={fields.description.id} placeholder="e.g. Our main marketing site" - ref={isEdit ? inputRef : undefined} + ref={descriptionInputRef} /> diff --git a/app/features/edge/domain/overview/general-card.tsx b/app/features/edge/domain/overview/general-card.tsx index 2591ab5a2..984894e3c 100644 --- a/app/features/edge/domain/overview/general-card.tsx +++ b/app/features/edge/domain/overview/general-card.tsx @@ -4,11 +4,22 @@ import { NameserverChips } from '@/components/nameserver-chips'; import { TextCopy } from '@/components/text-copy/text-copy'; import { DomainExpiration } from '@/features/edge/domain/expiration'; import { DomainStatus } from '@/features/edge/domain/status'; -import { IDomainControlResponse } from '@/resources/interfaces/domain.interface'; -import { Card, CardHeader, CardTitle, CardContent } from '@datum-ui/components'; +import type { IDnsZoneControlResponse } from '@/resources/interfaces/dns.interface'; +import type { IDomainControlResponse } from '@/resources/interfaces/domain.interface'; +import { paths } from '@/utils/config/paths.config'; +import { getPathWithParams } from '@/utils/helpers/path.helper'; +import { Card, CardHeader, CardTitle, CardContent, LinkButton } from '@datum-ui/components'; import { useMemo } from 'react'; -export const DomainGeneralCard = ({ domain }: { domain: IDomainControlResponse }) => { +export const DomainGeneralCard = ({ + domain, + dnsZone, + projectId, +}: { + domain: IDomainControlResponse; + dnsZone?: IDnsZoneControlResponse; + projectId?: string; +}) => { const listItems: ListItem[] = useMemo(() => { if (!domain) return []; @@ -43,8 +54,25 @@ export const DomainGeneralCard = ({ domain }: { domain: IDomainControlResponse } className: 'px-2', content: , }, + { + hidden: !dnsZone, + label: 'DNS Zone', + className: 'px-2', + content: ( + + {domain.domainName} + + ), + }, ]; - }, [domain]); + }, [domain, dnsZone]); return ( diff --git a/app/modules/datum-ui/components/button/button.tsx b/app/modules/datum-ui/components/button/button.tsx index 476fd9d9f..72a96d7d1 100644 --- a/app/modules/datum-ui/components/button/button.tsx +++ b/app/modules/datum-ui/components/button/button.tsx @@ -21,6 +21,7 @@ const buttonVariants = cva( light: '', outline: 'border', borderless: 'border-0 bg-transparent', + link: 'text-primary underline-offset-2 underline hover:opacity-80', }, size: { xs: 'h-7 px-2.5 text-xs', @@ -28,6 +29,7 @@ const buttonVariants = cva( default: 'h-9 px-4 py-2', large: 'h-11 px-8 text-base', icon: 'h-9 w-9', + link: 'px-0 py-0', }, block: { true: 'w-full', diff --git a/app/resources/control-plane/dns-networking/dns-zones.control.ts b/app/resources/control-plane/dns-networking/dns-zones.control.ts index 42f82b8a1..0d824aae0 100644 --- a/app/resources/control-plane/dns-networking/dns-zones.control.ts +++ b/app/resources/control-plane/dns-networking/dns-zones.control.ts @@ -34,20 +34,37 @@ export const createDnsZonesControl = (client: Client) => { }; return { - list: async (projectId: string) => { + list: async (projectId: string, domainNames?: string[]) => { try { + //TODO: Kubernetes field selectors only support =, ==, and != operators so for now we fetch all and filter client-side const response = await listDnsNetworkingMiloapisComV1Alpha1NamespacedDnsZone({ client, baseURL: `${baseUrl}/projects/${projectId}/control-plane`, path: { namespace: 'default', }, + query: { + fieldSelector: + // if there's only one domain name we can use the field selector to filter by domain name + domainNames?.length === 1 ? `spec.domainName=${domainNames[0]}` : undefined, + }, }); const dnsZones = response.data as ComMiloapisNetworkingDnsV1Alpha1DnsZoneList; + let filteredZones = dnsZones.items; + + // Filter by domain names (only when there are multiple domain names) + if (domainNames && domainNames.length > 1) { + const domainNameSet = new Set(domainNames); + filteredZones = filteredZones.filter( + (dnsZone: ComMiloapisNetworkingDnsV1Alpha1DnsZone) => + dnsZone.spec?.domainName && domainNameSet.has(dnsZone.spec.domainName) + ); + } + return ( - dnsZones.items + filteredZones // // Filter out DNS zones that are being deleted // ?.filter( // (dnsZone: ComMiloapisNetworkingDnsV1Alpha1DnsZone) => @@ -93,7 +110,7 @@ export const createDnsZonesControl = (client: Client) => { throw e; } }, - detail: async (projectId: string, id: string) => { + detail: async (projectId: string, id: string, domainName?: string) => { try { const response = await readDnsNetworkingMiloapisComV1Alpha1NamespacedDnsZone({ client, diff --git a/app/routes/project/detail/config/domains/detail/layout.tsx b/app/routes/project/detail/config/domains/detail/layout.tsx index 61dd79cbf..ebfecd69f 100644 --- a/app/routes/project/detail/config/domains/detail/layout.tsx +++ b/app/routes/project/detail/config/domains/detail/layout.tsx @@ -1,12 +1,12 @@ -import { createDomainsControl } from '@/resources/control-plane'; -import { IDomainControlResponse } from '@/resources/interfaces/domain.interface'; +import { createDnsZonesControl, createDomainsControl } from '@/resources/control-plane'; +import type { IDomainControlResponse } from '@/resources/interfaces/domain.interface'; import { BadRequestError, NotFoundError } from '@/utils/errors'; import { mergeMeta, metaObject } from '@/utils/helpers/meta.helper'; import { Client } from '@hey-api/client-axios'; import { LoaderFunctionArgs, AppLoadContext, data, MetaFunction, Outlet } from 'react-router'; export const handle = { - breadcrumb: (data: IDomainControlResponse) => {data?.domainName}, + breadcrumb: ({ domain }: { domain: IDomainControlResponse }) => {domain?.domainName}, }; export const meta: MetaFunction = mergeMeta(({ loaderData }) => { @@ -23,14 +23,17 @@ export const loader = async ({ context, params }: LoaderFunctionArgs) => { } const domainsControl = createDomainsControl(controlPlaneClient as Client); + const dnsZonesControl = createDnsZonesControl(controlPlaneClient as Client); const domain = await domainsControl.detail(projectId, domainId); + const dnsZones = await dnsZonesControl.list(projectId, [domain?.domainName ?? '']); + const dnsZone = dnsZones.find((zone) => zone.domainName === domain?.domainName) ?? null; if (!domain) { throw new NotFoundError('Domain not found'); } - return data(domain); + return data({ domain, dnsZone }); }; export default function DomainDetailLayout() { diff --git a/app/routes/project/detail/config/domains/detail/overview.tsx b/app/routes/project/detail/config/domains/detail/overview.tsx index 3c6e28b7d..29075e500 100644 --- a/app/routes/project/detail/config/domains/detail/overview.tsx +++ b/app/routes/project/detail/config/domains/detail/overview.tsx @@ -41,7 +41,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { }; export default function DomainOverviewPage() { - const domain = useRouteLoaderData('domain-detail'); + const { domain, dnsZone } = useRouteLoaderData('domain-detail'); const fetcher = useFetcher({ key: 'delete-domain' }); const { confirm } = useConfirmationDialog(); @@ -188,7 +188,7 @@ export default function DomainOverviewPage() { initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3, duration: 0.4 }}> - + {status.status === ControlPlaneStatus.Pending && (
diff --git a/app/routes/project/detail/config/domains/index.tsx b/app/routes/project/detail/config/domains/index.tsx index 7eaf0294c..819037941 100644 --- a/app/routes/project/detail/config/domains/index.tsx +++ b/app/routes/project/detail/config/domains/index.tsx @@ -6,8 +6,9 @@ import { DomainStatus } from '@/features/edge/domain/status'; import { DataTable } from '@/modules/datum-ui/components/data-table'; import { DataTableRowActionsProps } from '@/modules/datum-ui/components/data-table'; import { DataTableFilter } from '@/modules/datum-ui/components/data-table'; -import { createDomainsControl } from '@/resources/control-plane'; +import { createDnsZonesControl, createDomainsControl } from '@/resources/control-plane'; import { ControlPlaneStatus } from '@/resources/interfaces/control-plane.interface'; +import { IDnsZoneControlResponse } from '@/resources/interfaces/dns.interface'; import { IDomainControlResponse } from '@/resources/interfaces/domain.interface'; import { ROUTE_PATH as DOMAINS_ACTIONS_PATH } from '@/routes/api/domains'; import { ROUTE_PATH as DOMAINS_REFRESH_PATH } from '@/routes/api/domains/refresh'; @@ -40,6 +41,7 @@ type FormattedDomain = { expiresAt: string; status: IDomainControlResponse['status']; statusType: 'success' | 'pending'; + dnsZone?: IDnsZoneControlResponse; }; export const meta: MetaFunction = mergeMeta(() => { @@ -50,12 +52,17 @@ export const loader = async ({ context, params }: LoaderFunctionArgs) => { const { projectId } = params; const { controlPlaneClient } = context as AppLoadContext; const domainsControl = createDomainsControl(controlPlaneClient as Client); + const dnsZonesControl = createDnsZonesControl(controlPlaneClient as Client); if (!projectId) { throw new BadRequestError('Project ID is required'); } const domains = await domainsControl.list(projectId); + const domainNames = domains + .map((domain) => domain.domainName) + .filter((domainName) => domainName !== undefined); + const dnsZones = await dnsZonesControl.list(projectId, domainNames); const formattedDomains = domains.map((domain) => { const controlledStatus = transformControlPlaneStatus(domain.status); @@ -68,6 +75,7 @@ export const loader = async ({ context, params }: LoaderFunctionArgs) => { expiresAt: domain.status?.registration?.expiresAt, status: domain.status, statusType: controlledStatus.status === ControlPlaneStatus.Success ? 'verified' : 'pending', + dnsZone: dnsZones.find((dnsZone) => dnsZone.domainName === domain.domainName), }; }); return formattedDomains; @@ -123,6 +131,29 @@ export default function DomainsPage() { ); }; + const addDnsZone = async (domain: FormattedDomain) => { + navigate( + getPathWithParams( + paths.project.detail.dnsZones.new, + { + projectId, + }, + new URLSearchParams({ + domainName: domain.domainName, + }) + ) + ); + }; + + const editDnsZone = async (domain: FormattedDomain) => { + navigate( + getPathWithParams(paths.project.detail.dnsZones.detail.overview, { + projectId, + dnsZoneId: domain.dnsZone?.name ?? '', + }) + ); + }; + const columns: ColumnDef[] = useMemo( () => [ { @@ -204,6 +235,12 @@ export default function DomainsPage() { variant: 'default', action: (row) => refreshDomain(row), }, + { + key: 'dns', + label: 'Manage DNS Zone', + variant: 'default', + action: (row) => (row.dnsZone ? editDnsZone(row) : addDnsZone(row)), + }, { key: 'delete', label: 'Delete', diff --git a/app/utils/helpers/path.helper.ts b/app/utils/helpers/path.helper.ts index 7315749c8..24228c841 100644 --- a/app/utils/helpers/path.helper.ts +++ b/app/utils/helpers/path.helper.ts @@ -49,7 +49,12 @@ type QueryParams = Record; * @param params - Object containing parameter values * @returns Path with parameters replaced */ -export function getPathWithParams(path: string, params: QueryParams = {}): string { +export function getPathWithParams( + path: string, + params: QueryParams = {}, + searchParams?: URLSearchParams +): string { + const searchParamsString = searchParams ? `?${searchParams.toString()}` : ''; const toString = (val: Param): string => { if (val === null || typeof val === 'undefined') { return ''; @@ -65,7 +70,7 @@ export function getPathWithParams(path: string, params: QueryParams = {}): strin // /my/:dynamic/path .replace(`:${key}`, encodeURIComponent(toString(value))) ); - }, path); + }, path + searchParamsString); } /** diff --git a/bun.lock b/bun.lock index c174b7638..3d360294a 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "cloud-portal",