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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions app/features/edge/dns-zone/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -39,7 +39,9 @@ export const DnsZoneForm = ({
const isHydrated = useHydrated();
const isPending = useIsPending();
const inputRef = useRef<HTMLInputElement>(null);
const descriptionInputRef = useRef<HTMLInputElement>(null);
const fetcher = useFetcher({ key: 'delete-dns-zone' });
const [searchParams] = useSearchParams();

const { confirm } = useConfirmationDialog();
const deleteDnsZone = async () => {
Expand Down Expand Up @@ -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',
Expand All @@ -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) {
Expand Down Expand Up @@ -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}
/>
</Field>
</CardContent>
Expand Down
36 changes: 32 additions & 4 deletions app/features/edge/domain/overview/general-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];

Expand Down Expand Up @@ -43,8 +54,25 @@ export const DomainGeneralCard = ({ domain }: { domain: IDomainControlResponse }
className: 'px-2',
content: <DateTime className="text-sm" date={domain?.createdAt ?? ''} variant="both" />,
},
{
hidden: !dnsZone,
label: 'DNS Zone',
className: 'px-2',
content: (
<LinkButton
type="primary"
theme="link"
size="link"
to={getPathWithParams(paths.project.detail.dnsZones.detail.overview, {
projectId: projectId ?? '',
dnsZoneId: dnsZone?.name,
})}>
{domain.domainName}
</LinkButton>
),
},
];
}, [domain]);
}, [domain, dnsZone]);

return (
<Card className="w-full">
Expand Down
2 changes: 2 additions & 0 deletions app/modules/datum-ui/components/button/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ 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',
small: 'h-9 px-3 text-xs',
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',
Expand Down
23 changes: 20 additions & 3 deletions app/resources/control-plane/dns-networking/dns-zones.control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 7 additions & 4 deletions app/routes/project/detail/config/domains/detail/layout.tsx
Original file line number Diff line number Diff line change
@@ -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) => <span>{data?.domainName}</span>,
breadcrumb: ({ domain }: { domain: IDomainControlResponse }) => <span>{domain?.domainName}</span>,
};

export const meta: MetaFunction<typeof loader> = mergeMeta(({ loaderData }) => {
Expand All @@ -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() {
Expand Down
4 changes: 2 additions & 2 deletions app/routes/project/detail/config/domains/detail/overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 }}>
<DomainGeneralCard domain={domain} />
<DomainGeneralCard domain={domain} dnsZone={dnsZone} projectId={projectId} />
</motion.div>
{status.status === ControlPlaneStatus.Pending && (
<div className="flex flex-col gap-6">
Expand Down
39 changes: 38 additions & 1 deletion app/routes/project/detail/config/domains/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -40,6 +41,7 @@ type FormattedDomain = {
expiresAt: string;
status: IDomainControlResponse['status'];
statusType: 'success' | 'pending';
dnsZone?: IDnsZoneControlResponse;
};

export const meta: MetaFunction = mergeMeta(() => {
Expand All @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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<FormattedDomain>[] = useMemo(
() => [
{
Expand Down Expand Up @@ -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',
Expand Down
9 changes: 7 additions & 2 deletions app/utils/helpers/path.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,12 @@ type QueryParams = Record<string, Param>;
* @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 '';
Expand All @@ -65,7 +70,7 @@ export function getPathWithParams(path: string, params: QueryParams = {}): strin
// /my/:dynamic/path
.replace(`:${key}`, encodeURIComponent(toString(value)))
);
}, path);
}, path + searchParamsString);
}

/**
Expand Down
1 change: 1 addition & 0 deletions bun.lock
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "cloud-portal",
Expand Down
Loading