diff --git a/src/app/api/check/[domain]/[recordType]/route.ts b/src/app/api/check/[domain]/[recordType]/route.ts index fe980ee..1c7d35a 100644 --- a/src/app/api/check/[domain]/[recordType]/route.ts +++ b/src/app/api/check/[domain]/[recordType]/route.ts @@ -2,10 +2,11 @@ import { Resolver } from 'dns'; import { NextRequest, NextResponse } from 'next/server'; import { dnsServers } from '@/constants/dnsServers'; import { RecordType, RecordTypes } from '@/constants/recordType'; +import { DNS } from '@/stores/dnsStore'; import { ratelimit } from '@/lib/redis'; -export const GET = async ( +export const POST = async ( req: NextRequest, { params, @@ -13,6 +14,16 @@ export const GET = async ( params: { domain: string; recordType: RecordType }; } ) => { + let remoteDnsServer: DNS[] = []; + try { + const res = await req.json(); + if (res.dnsServers) { + remoteDnsServer = res.dnsServers; + } + } catch (error) { + console.error(error); + } + const { domain, recordType } = params; if (!domain) { @@ -40,8 +51,10 @@ export const GET = async ( } } + let dnsServersToUse = remoteDnsServer.length > 0 ? remoteDnsServer : dnsServers; + const addressesResults = await Promise.allSettled( - dnsServers.map((server: { ip: string; name: string }) => { + dnsServersToUse.map((server: { id: string; ip: string; name: string }) => { const resolver: Resolver = new Resolver(); resolver.setServers([server.ip]); return new Promise< @@ -72,7 +85,7 @@ export const GET = async ( const item = addresses; if (!item) { return resolve({ - [server.name]: { value: '', time: end - start }, + [server.id]: { value: '', time: end - start }, }); } @@ -80,23 +93,23 @@ export const GET = async ( case 'A': case 'AAAA': return resolve({ - [server.name]: { value: (item as string[])[0] || '', time: end - start }, + [server.id]: { value: (item as string[])[0] || '', time: end - start }, }); case 'MX': return resolve({ - [server.name]: { value: (item as { exchange: string }[])[0]?.exchange || '', time: end - start }, + [server.id]: { value: (item as { exchange: string }[])[0]?.exchange || '', time: end - start }, }); case 'NS': return resolve({ - [server.name]: { value: (item as string[])[0] || '', time: end - start }, + [server.id]: { value: (item as string[])[0] || '', time: end - start }, }); case 'PTR': return resolve({ - [server.name]: { value: (item as string[])[0] || '', time: end - start }, + [server.id]: { value: (item as string[])[0] || '', time: end - start }, }); case 'TXT': return resolve({ - [server.name]: { value: (item as string[])[0] || '', time: end - start }, + [server.id]: { value: (item as string[])[0] || '', time: end - start }, }); default: return resolve({}); diff --git a/src/app/page.tsx b/src/app/page.tsx index 81310b8..dff886c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,18 +1,22 @@ 'use client'; -import { useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import dynamic from 'next/dynamic'; import { useRouter } from 'next/navigation'; import { dnsServers } from '@/constants/dnsServers'; import { RecordTypes, type RecordType } from '@/constants/recordType'; +import { useDNSStore } from '@/stores/dnsStore'; import { usePreviouslyCheckedStore } from '@/stores/previouslyCheckedStore'; import { TestResult, useTestStore } from '@/stores/testStore'; -import { LoaderIcon } from 'lucide-react'; +import { LoaderIcon, PlusCircleIcon } from 'lucide-react'; import { toast } from 'sonner'; +import { findLastIndex } from '@/lib/findLastIndex'; import { useQueryString } from '@/hooks/queryString'; +import { Button } from '@/components/ui/button'; import { Footer } from '@/components/footer'; import { Header } from '@/components/header'; +import { dnsSchemaArray, useAddEditDnsModal } from '@/components/modals/add-edit-dns-modal'; import { ResultItem } from '@/components/result-item'; import { SearchForm } from '@/components/search-form'; @@ -24,6 +28,15 @@ export default function Home() { const { tests, runTests } = useTestStore(); const { previouslyCheckedList, addPreviouslyChecked, updatePreviouslyChecked } = usePreviouslyCheckedStore(); + const { dns: dnsList, addDNS, saveDnsServersToLocalStorage, loadDnsServersFromLocalStorage } = useDNSStore(); + const { AddEditDnsModalComponent, setShowAddEditDnsModal } = useAddEditDnsModal({ + onSubmit: (data) => { + addDNS(data); + setShowAddEditDnsModal(false); + saveDnsServersToLocalStorage(); + }, + }); + const [loading, setLoading] = useState(false); const [refreshIntervalTime, setRefreshIntervalTime] = useState(); @@ -53,7 +66,19 @@ export default function Home() { domain = domain.split('/')[0]; } - const res = await fetch(`/api/check/${domain}/${recordType || 'A'}`); + const res = await fetch(`/api/check/${domain}/${recordType || 'A'}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + dnsServers: dnsList.map((dns) => ({ + id: dns.id, + name: dns.name, + ip: dns.ip, + })), + }), + }); if (res.status === 429) { const { limit, reset } = await res.json(); toast.error( @@ -65,23 +90,29 @@ export default function Home() { setLoading(false); return; } - const { addresses } = await res.json(); - - const previouslyCheckedItem = previouslyCheckedList.find((item) => item.value === domain); - if (previouslyCheckedItem && !runBecauseUrlChange) { - updatePreviouslyChecked(previouslyCheckedItem.id, { - ...previouslyCheckedItem, - timestamp: Date.now(), - value: domain, - tests, - }); - } else { - addPreviouslyChecked(domain, tests); - } + try { + const { addresses } = await res.json(); + + const previouslyCheckedItem = previouslyCheckedList.find((item) => item.value === domain); + if (previouslyCheckedItem && !runBecauseUrlChange) { + updatePreviouslyChecked(previouslyCheckedItem.id, { + ...previouslyCheckedItem, + timestamp: Date.now(), + value: domain, + tests, + }); + } else { + addPreviouslyChecked(domain, tests); + } - setResolvedAddresses(addresses); - setLastRefresh(new Date()); - setLoading(false); + setResolvedAddresses(addresses); + setLastRefresh(new Date()); + setLoading(false); + } catch (e) { + toast.error(`Something went wrong. Please try again later.`); + setResolvedAddresses({}); + setLoading(false); + } }; useEffect(() => { @@ -119,13 +150,18 @@ export default function Home() { }, [refreshIntervalTime, lastRefresh, url]); useEffect(() => { + loadDnsServersFromLocalStorage(); + if (url.length > 0) { checkDomain(url, RecordTypes[0], true); } }, []); + const lastCustomDNSIndex = findLastIndex(dnsList, (dns) => !dns.id.startsWith('system--')); + return (
+
{refreshIntervalTime && (
@@ -140,7 +176,7 @@ export default function Home() { )}
-
+
{ if (data.refresh && data.refresh !== '0') { @@ -157,33 +193,64 @@ export default function Home() {

Results

- {dnsServers.map((dnsServer) => { - const result = resolvedAddresses[dnsServer.name]; + {dnsList.map((dnsServer, idx) => { + const result = resolvedAddresses[dnsServer.id]; let testResults: TestResult[] = []; if (result && result.value !== '') { testResults = runTests(result.value); } + const isLastCustomDNS = lastCustomDNSIndex === idx; + return ( - - {loading ? ( -
- -
- ) : result && result.value !== '' ? ( -
- {result.value} - {result.time}ms -
- ) : url.length > 0 ? ( - Not found - ) : ( - <> + + + {loading ? ( +
+ +
+ ) : result && result.value !== '' ? ( +
+ {result.value} + {result.time}ms +
+ ) : url.length > 0 ? ( + Not found + ) : ( + <> + )} +
+ {isLastCustomDNS && ( + <> + +
+ )} -
+ ); })} + {lastCustomDNSIndex === -1 && ( + + )}
diff --git a/src/components/modals/add-edit-dns-modal.tsx b/src/components/modals/add-edit-dns-modal.tsx new file mode 100644 index 0000000..e63aa5a --- /dev/null +++ b/src/components/modals/add-edit-dns-modal.tsx @@ -0,0 +1,227 @@ +'use client'; + +import { Dispatch, SetStateAction, useCallback, useId, useMemo, useState } from 'react'; +import { DNS } from '@/stores/dnsStore'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { AlertCircleIcon, FlaskConicalIcon, InfoIcon, ServerIcon, XIcon } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import shortid from 'shortid'; +import { z } from 'zod'; + +import { useMediaQuery } from '@/hooks/useMediaQuery'; + +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; +import { Modal } from '../ui/modal'; +import { Switch } from '../ui/switch'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'; + +export const dnsSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + ip: z.string().min(1), + city: z.string().optional(), + country: z.string().optional(), + keepInLocalStorage: z.boolean().default(false), +}); +export const dnsSchemaArray = z.array(dnsSchema); + +interface AddEditDnsModalProps { + showAddEditDnsModal: boolean; + setShowAddEditDnsModal: Dispatch>; + dns?: DNS; + onSubmit?: (data: z.infer) => void; +} + +export function AddEditDnsModal({ + showAddEditDnsModal, + setShowAddEditDnsModal, + dns, + onSubmit = () => {}, +}: AddEditDnsModalProps) { + const { isMobile } = useMediaQuery(); + + const id = shortid(); + const form = useForm>({ + resolver: zodResolver(dnsSchema), + defaultValues: { + id: dns?.id ?? id, + name: dns?.name, + ip: dns?.ip, + city: dns?.city, + country: dns?.country, + keepInLocalStorage: dns?.keepInLocalStorage ?? false, + }, + }); + + return ( + +
+ +

{dns ? 'Edit' : 'Add'} DNS

+
+
+
+ +
+ + {form.formState.errors.name && ( +
+
+ )} +
+
+
+ +
+ + {form.formState.errors.ip && ( +
+
+ )} +
+
+
+ +
+ + {form.formState.errors.city && ( +
+
+ )} +
+
+
+ +
+ + {form.formState.errors.country && ( +
+
+ )} +
+
+
+
+ { + form.setValue('keepInLocalStorage', checked, { + shouldValidate: true, + shouldTouch: true, + }); + }} + trackDimensions="w-11 h-6" + thumbDimensions="h-5 w-5" + thumbTranslate="translate-x-5" + id="keepInLocalStorage" + checked={form.getValues('keepInLocalStorage')} + /> + +
+
+
+ +
+
+
+ ); +} + +export function useAddEditDnsModal({ + dns, + onSubmit, +}: { + dns?: DNS; + onSubmit?: (data: z.infer) => void; +}) { + const [showAddEditDnsModal, setShowAddEditDnsModal] = useState(false); + + const AddEditDnsModalComponent = useCallback( + () => ( + + ), + [showAddEditDnsModal, setShowAddEditDnsModal, dns, onSubmit] + ); + + return useMemo( + () => ({ AddEditDnsModalComponent, showAddEditDnsModal, setShowAddEditDnsModal }), + [AddEditDnsModalComponent, showAddEditDnsModal, setShowAddEditDnsModal] + ); +} diff --git a/src/components/result-item.tsx b/src/components/result-item.tsx index 1666411..077e8ee 100644 --- a/src/components/result-item.tsx +++ b/src/components/result-item.tsx @@ -1,7 +1,9 @@ import { DnsServer } from '@/constants/dnsServers'; +import { useDNSStore } from '@/stores/dnsStore'; import { Test, TestResult } from '@/stores/testStore'; -import { InfoIcon } from 'lucide-react'; +import { InfoIcon, MoreVerticalIcon, PenIcon } from 'lucide-react'; +import { useAddEditDnsModal } from './modals/add-edit-dns-modal'; import { Pill } from './pill'; import { TestExplainer } from './test-explainer'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; @@ -13,54 +15,83 @@ interface ResultItemProps { } export function ResultItem({ dnsServer, testResults, children }: ResultItemProps) { + const isCustomDNS = !dnsServer.id.startsWith('system--'); + const { updateDNS, saveDnsServersToLocalStorage } = useDNSStore(); + const { AddEditDnsModalComponent, setShowAddEditDnsModal } = useAddEditDnsModal({ + dns: dnsServer, + onSubmit: (data) => { + updateDNS(data); + setShowAddEditDnsModal(false); + saveDnsServersToLocalStorage(); + }, + }); + return ( -
-
-
-
-

{dnsServer.name}

- - - -
- -
-
- -

IP: {dnsServer.ip}

-
-
-
+ <> + + +
+
+
+
+

{dnsServer.name}

+ + + +
+ +
+
+ +

IP: {dnsServer.ip}

+
+
+
+
+

+ {dnsServer.city && {dnsServer.city}, } + {dnsServer.country} +

-

- {dnsServer.city && {dnsServer.city}, } - {dnsServer.country} -

+
{children}
-
{children}
+ {testResults && testResults.length > 0 && ( +
+ {testResults.map((testResult) => ( + + + +
+ + {testResult.name} + +
+
+ +

+ Type: {testResult.type}, Test for: +

+
+
+
+ ))} +
+ )} + {!isCustomDNS &&
} + {isCustomDNS && ( + + )}
- {testResults && testResults.length > 0 && ( -
- {testResults.map((testResult) => ( - - - -
- - {testResult.name} - -
-
- -

- Type: {testResult.type}, Test for: -

-
-
-
- ))} -
- )} -
+ ); } diff --git a/src/components/ui/modal.tsx b/src/components/ui/modal.tsx index e2c4dc1..07ad08c 100644 --- a/src/components/ui/modal.tsx +++ b/src/components/ui/modal.tsx @@ -92,7 +92,7 @@ export function Modal({ onOpenAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()} className={cn( - 'animate-scale-in fixed inset-0 z-40 m-auto max-h-fit w-full max-w-md overflow-hidden border border-gray-200 bg-white p-0 shadow-xl md:rounded-2xl', + 'animate-scale-in fixed inset-0 z-40 m-auto max-h-fit w-full max-w-md overflow-hidden border border-gray-200 bg-white p-0 shadow-xl sm:rounded-2xl', className )} > diff --git a/src/constants/dnsServers.ts b/src/constants/dnsServers.ts index 541dcc8..c27ca3d 100644 --- a/src/constants/dnsServers.ts +++ b/src/constants/dnsServers.ts @@ -1,105 +1,125 @@ -export const dnsServers = [ +import { DNS } from '@/stores/dnsStore'; + +// Prefixed with `system--` to avoid conflicts with user-defined DNS servers +export const dnsServers: DNS[] = [ { + id: 'system--opendns', name: 'OpenDNS', ip: '208.67.222.220', country: 'United States', city: 'San Francisco', }, { + id: 'system--google', name: 'Google', ip: '8.8.8.8', country: 'United States', city: 'Mountain View', }, { + id: 'system--quad9', name: 'Quad9', ip: '9.9.9.9', country: 'United States', city: 'Berkeley', }, { + id: 'system--probe-networks', name: 'Probe Networks', ip: '82.96.64.2', country: 'Germany', city: 'Saarland', }, { + id: 'system--4d-data-centres', name: '4D Data Centres Ltd', ip: '37.209.219.30', country: 'United Kingdom', city: 'Byfleet', }, { + id: 'system--oskar-emmenegger', name: 'Oskar Emmenegger', ip: '194.209.157.109', country: 'Switzerland', city: 'Zizers', }, { + id: 'system--nemox', name: 'nemox.net', ip: '83.137.41.9', country: 'Austria', city: 'Innsbruck', }, { + id: 'system--at-and-t-services', name: 'AT&T Services', ip: '12.121.117.201', country: 'United States', city: 'Miami', }, { + id: 'system--verisign-global-registry-services', name: 'VeriSign Global Registry Services', ip: '64.6.64.6', country: 'United States', city: 'Virginia', }, { + id: 'system--quad9-sf', name: 'Quad9', ip: '149.112.112.112', country: 'United States', city: 'San Francisco', }, { + id: 'system--centurylink', name: 'CenturyLink', ip: '205.171.202.66', country: 'United States', city: '', }, { + id: 'system--fortinet-inc', name: 'Fortinet Inc', ip: '208.91.112.53', country: 'Canada', city: 'Burnaby', }, { + id: 'system--skydns', name: 'Skydns', ip: '195.46.39.39', country: 'Russia', city: 'Yekaterinburg', }, { + id: 'system--liquid-telecommunications-ltd', name: 'Liquid Telecommunications Ltd', ip: '5.11.11.5', country: 'South Africa', city: 'Cullinan', }, { + id: 'system--pyton-communication-services-b-v', name: 'Pyton Communication Services B.V.', ip: '193.58.204.59', country: 'Netherlands', city: 'Weert', }, { + id: 'system--association-gitoyen', name: 'Association Gitoyen', ip: '80.67.169.40', country: 'France', city: 'Paris', }, { + id: 'system--prioritytelecom-spain-s-a', name: 'Prioritytelecom Spain S.A.', ip: '212.230.255.1', country: 'Spain', city: 'Madrid', }, -] as const; +]; export type DnsServer = (typeof dnsServers)[number]; diff --git a/src/lib/findLastIndex.ts b/src/lib/findLastIndex.ts new file mode 100644 index 0000000..f550218 --- /dev/null +++ b/src/lib/findLastIndex.ts @@ -0,0 +1,15 @@ +/** + * Returns the index of the last element in the array where predicate is true, and -1 + * otherwise. + * @param array The source array to search in + * @param predicate find calls predicate once for each element of the array, in descending + * order, until it finds one where predicate returns true. If such an element is found, + * findLastIndex immediately returns that element index. Otherwise, findLastIndex returns -1. + */ +export function findLastIndex(array: Array, predicate: (value: T, index: number, obj: T[]) => boolean): number { + let l = array.length; + while (l--) { + if (predicate(array[l], l, array)) return l; + } + return -1; +} diff --git a/src/stores/dnsStore.ts b/src/stores/dnsStore.ts new file mode 100644 index 0000000..b39976f --- /dev/null +++ b/src/stores/dnsStore.ts @@ -0,0 +1,87 @@ +import { dnsServers } from '@/constants/dnsServers'; +import { z } from 'zod'; +import { create } from 'zustand'; + +import { dnsSchema } from '@/components/modals/add-edit-dns-modal'; + +export type DNS = { + id: string; + name: string; + ip: string; + city?: string; + country?: string; + keepInLocalStorage?: boolean; +}; + +type DNSStore = { + dns: DNS[]; + addDNS: (dns: DNS) => void; + removeDNS: (id: string) => void; + updateDNS: (dns: Partial) => void; + clearDNS: () => void; + saveDnsServersToLocalStorage: () => void; + loadDnsServersFromLocalStorage: () => void; +}; + +export const useDNSStore = create()((set, state) => ({ + dns: dnsServers, + addDNS: (dns) => { + set((state) => ({ + dns: [...state.dns, dns], + })); + }, + removeDNS: (id) => { + set((state) => ({ + dns: state.dns.filter((item) => item.id !== id), + })); + }, + updateDNS: (dns) => { + set((state) => ({ + dns: state.dns.map((item) => { + if (item.id === dns.id) { + return { + ...item, + ...dns, + }; + } + return item; + }), + })); + }, + clearDNS: () => { + set((state) => ({ + dns: [], + })); + }, + saveDnsServersToLocalStorage: () => { + if (typeof window === 'undefined') { + return; + } + + // Save to localStorage and filter out system DNS servers and if keepInLocalStorage is false and dedpulicate without zod parsing + const dnsListDeduplicated = state() + .dns.filter((item) => { + const index = state().dns.findIndex((dns) => dns.id === item.id); + return index === state().dns.indexOf(item); + }) + .filter((item) => !item.id.startsWith('system--') && item.keepInLocalStorage !== false); + + localStorage.setItem('dns', JSON.stringify(dnsListDeduplicated)); + }, + loadDnsServersFromLocalStorage: () => { + if (typeof window === 'undefined') { + return; + } + + const dns = localStorage.getItem('dns'); + const dnsSchemaArray = z.array(dnsSchema); + const parseResult = dnsSchemaArray.safeParse(JSON.parse(dns || '[]')); + if (parseResult.success) { + // Make sure not to load the same DNS servers twice + const newDns = parseResult.data.filter((item) => !state().dns.find((dns) => dns.id === item.id)); + set((state) => ({ + dns: [...newDns, ...state.dns], + })); + } + }, +}));