├── .dockerignore ├── .editorconfig ├── .gitignore ├── .gitmodules ├── DnsTools.sln ├── LICENSE ├── README.md ├── ansible ├── ansible.cfg ├── deploy-workers.sh ├── roles │ └── dnstools-worker │ │ ├── tasks │ │ └── main.yml │ │ └── templates │ │ └── acme-dns-auth.py.j2 ├── servers.txt └── worker.yml └── src ├── DnsTools.Web ├── ClientApp │ ├── .env │ ├── .env.sentry-build-plugin │ ├── .eslintignore │ ├── .prettierrc │ ├── README.md │ ├── craco.config.js │ ├── generate-cshtml.js │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── manifest.json │ │ └── robots.txt │ ├── src │ │ ├── App.tsx │ │ ├── SignalrContext.ts │ │ ├── analytics.ts │ │ ├── components │ │ │ ├── CAPTCHA.tsx │ │ │ ├── CountryFlag.tsx │ │ │ ├── DismissableNotice.tsx │ │ │ ├── DnsLookupReferralDetails.tsx │ │ │ ├── DnsLookupResults.tsx │ │ │ ├── DnsLookupWorkerResult.tsx │ │ │ ├── DnsRecordValue.tsx │ │ │ ├── DnsRecordsSummaryTable.tsx │ │ │ ├── DnsRecordsTable.tsx │ │ │ ├── DnsRecordsTableRows.tsx │ │ │ ├── DnsTraversalLevel.tsx │ │ │ ├── DropdownButton.tsx │ │ │ ├── ExpandTransition.tsx │ │ │ ├── IPAddress.tsx │ │ │ ├── IPDetails.tsx │ │ │ ├── InlineWhoisForm.tsx │ │ │ ├── LazyChart.tsx │ │ │ ├── MainForm.tsx │ │ │ ├── MtrTable.tsx │ │ │ ├── NavigationSideEffects.tsx │ │ │ ├── PingDetails.tsx │ │ │ ├── PingWorkerResult.tsx │ │ │ ├── PromotedServerProviders.tsx │ │ │ ├── ShimmerBar.tsx │ │ │ ├── Spinner.tsx │ │ │ ├── Table.tsx │ │ │ ├── ToolSelector.tsx │ │ │ ├── TracerouteResponse.tsx │ │ │ ├── TracerouteResponseLoadingPlaceholder.tsx │ │ │ ├── TracerouteWorker.tsx │ │ │ ├── WithHovercard.tsx │ │ │ ├── WorkerLocation.tsx │ │ │ ├── form │ │ │ │ ├── Checkbox.tsx │ │ │ │ ├── CheckboxList.tsx │ │ │ │ ├── FormRow.tsx │ │ │ │ ├── FormRowDropdownList.tsx │ │ │ │ ├── Radio.tsx │ │ │ │ ├── RadioList.tsx │ │ │ │ └── SelectAllCheckbox.tsx │ │ │ └── icons │ │ │ │ └── Icons.tsx │ │ ├── config.json │ │ ├── config.ts │ │ ├── dnsConfig.ts │ │ ├── hooks │ │ │ ├── CachedSignalrStream.ts │ │ │ ├── useDimensions.ts │ │ │ ├── useIpData.ts │ │ │ ├── useLayoutEffectExceptInitialRender.ts │ │ │ ├── useLazyRef.ts │ │ │ ├── useMouseOver.ts │ │ │ ├── usePrevious.ts │ │ │ ├── useQueryString.ts │ │ │ ├── useSignalrConnection.ts │ │ │ └── useSignalrStream.ts │ │ ├── index.scss │ │ ├── index.tsx │ │ ├── logo.svg │ │ ├── pages │ │ │ ├── 404.tsx │ │ │ ├── DnsLookup.tsx │ │ │ ├── DnsTraversal.tsx │ │ │ ├── Index.tsx │ │ │ ├── Locations.tsx │ │ │ ├── Mtr.tsx │ │ │ ├── Ping.tsx │ │ │ ├── Traceroute.tsx │ │ │ └── Whois.tsx │ │ ├── react-app-env.d.ts │ │ ├── types │ │ │ ├── generated.ts │ │ │ ├── global.d.ts │ │ │ ├── protobuf.ts │ │ │ └── use-deep-compare-effect.d.ts │ │ └── utils │ │ │ ├── arrays.ts │ │ │ ├── format.ts │ │ │ ├── maps.ts │ │ │ ├── math.ts │ │ │ ├── prerendering.ts │ │ │ ├── queryString.ts │ │ │ ├── react.tsx │ │ │ ├── routing.ts │ │ │ ├── sets.ts │ │ │ ├── url.ts │ │ │ └── workers.ts │ ├── tsconfig.json │ └── yarn.lock ├── Controllers │ ├── CaptchaController.cs │ ├── ErrorController.cs │ ├── HomeController.cs │ └── LegacyProxyController.cs ├── DnsTools.Web.csproj ├── Exceptions │ └── WorkerUnavailableException.cs ├── Extensions │ └── ReinforcedTypingsExtensions.cs ├── HealthChecks │ └── WorkerHealthCheck.cs ├── Hubs │ ├── IToolsHub.cs │ └── ToolsHub.cs ├── Models │ ├── CaptchaResponse.cs │ ├── Config │ │ ├── AppConfig.cs │ │ └── WorkerConfig.cs │ ├── DnsLookupRequest.cs │ ├── ErrorViewModel.cs │ ├── Http2PushManifest.cs │ ├── IpData.cs │ ├── PingRequest.cs │ ├── ReCaptchaVerifyResponse.cs │ └── WorkerResponse.cs ├── Pages │ └── _ViewImports.cshtml ├── Properties │ └── launchSettings.json ├── Reinforced.Typings.settings.xml ├── ReinforcedTypingsConfig.cs ├── Services │ ├── Http2PushManifestHandler.cs │ ├── IHttp2PushManifestHandler.cs │ ├── IIpDataLoader.cs │ ├── IIpDataProvider.cs │ ├── IWorkerProvider.cs │ ├── IpDataProvider.cs │ ├── IpInfoIpDataLoader.cs │ ├── MaxMindIpDataLoader.cs │ ├── WorkerProvider.cs │ └── WorkerStatus.cs ├── Startup.cs ├── Tools │ ├── GenericRunner.cs │ ├── MtrRunner.cs │ ├── ToolRunner.cs │ └── TracerouteRunner.cs ├── Utils │ ├── Captcha.cs │ └── ICaptcha.cs ├── ViewModels │ └── IndexViewModel.cs ├── Views │ ├── React │ │ └── .gitkeep │ ├── Shared │ │ └── Error.cshtml │ ├── _ViewImports.cshtml │ └── _ViewStart.cshtml ├── appsettings.Development.json ├── appsettings.json ├── dnstools-web.service ├── legacy │ ├── composer.json │ ├── composer.lock │ ├── functions.php │ └── public │ │ ├── legacy_redirect.php │ │ └── whois.php ├── nginx │ ├── aspnet.conf │ ├── common.conf │ └── nginx.conf └── publish.ps1 ├── DnsTools.Worker ├── DnsTools.Worker.csproj ├── Dockerfile ├── Exceptions │ └── DnsLookupRetryableException.cs ├── Extensions │ ├── DnsLookupExtensions.cs │ ├── EnumerableExtensions.cs │ └── IpExtensions.cs ├── Middleware │ ├── AccessControlMiddleware.cs │ └── GrpcMetricsMiddleware.cs ├── Models │ ├── AccessControlConfig.cs │ ├── IDnsLookupRequest.cs │ ├── IHasError.cs │ └── ProtobufModelExtensions.cs ├── Program.cs ├── Properties │ └── launchSettings.json ├── Services │ ├── DnsToolsMetrics.cs │ └── DnsToolsService.cs ├── Tools │ ├── BaseCliTool.cs │ ├── BaseDnsLookup.cs │ ├── DnsLookup.cs │ ├── DnsTraversal.cs │ ├── ITool.cs │ ├── Mtr.cs │ ├── Ping.cs │ └── Traceroute.cs ├── Utils │ ├── GrpcStreamResponseQueue.cs │ └── Hostname.cs ├── appsettings.Development.json ├── appsettings.Production.json ├── appsettings.json └── deployment │ ├── dnstools-worker.service │ └── letsencrypt-deploy-hook.sh └── Proto └── dnstools.proto /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | # Need this for GitInfo to work. 5 | #**/.git 6 | **/.gitignore 7 | **/.project 8 | **/.settings 9 | **/.toolstarget 10 | **/.vs 11 | **/.vscode 12 | **/*.*proj.user 13 | **/*.dbmdl 14 | **/*.jfm 15 | **/azds.yaml 16 | **/bin 17 | **/charts 18 | **/docker-compose* 19 | **/Dockerfile* 20 | **/node_modules 21 | **/npm-debug.log 22 | **/obj 23 | **/secrets.dev.yaml 24 | **/values.dev.yaml 25 | LICENSE 26 | README.md 27 | !**/.gitignore 28 | !.git/HEAD 29 | !.git/config 30 | !.git/packed-refs 31 | !.git/refs/heads/** -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # To learn more about .editorconfig see https://aka.ms/editorconfigdocs 2 | root = true 3 | 4 | # All files 5 | [*] 6 | indent_style = tab 7 | indent_size = 4 8 | 9 | [*.{cs,csx,vb,vbx}] 10 | insert_final_newline = true 11 | charset = utf-8-bom 12 | 13 | [*.proto] 14 | indent_style = space 15 | indent_size = 2 16 | 17 | [*.{js,ts,tsx,json,css,yaml,yml}] 18 | indent_style = space 19 | indent_size = 2 20 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Daniel15/dnstools/c85057801c294a292dfdef2127798bbc159a56d2/.gitmodules -------------------------------------------------------------------------------- /DnsTools.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.29318.209 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DnsTools.Worker", "src\DnsTools.Worker\DnsTools.Worker.csproj", "{5C48ACF5-1DD2-4208-B99D-C885078BDB52}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DnsTools.Web", "src\DnsTools.Web\DnsTools.Web.csproj", "{D1293237-462F-4189-A568-C107FD3942AA}" 9 | EndProject 10 | Global 11 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 12 | Debug|Any CPU = Debug|Any CPU 13 | Release|Any CPU = Release|Any CPU 14 | EndGlobalSection 15 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 16 | {5C48ACF5-1DD2-4208-B99D-C885078BDB52}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 17 | {5C48ACF5-1DD2-4208-B99D-C885078BDB52}.Debug|Any CPU.Build.0 = Debug|Any CPU 18 | {5C48ACF5-1DD2-4208-B99D-C885078BDB52}.Release|Any CPU.ActiveCfg = Release|Any CPU 19 | {5C48ACF5-1DD2-4208-B99D-C885078BDB52}.Release|Any CPU.Build.0 = Release|Any CPU 20 | {D1293237-462F-4189-A568-C107FD3942AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 21 | {D1293237-462F-4189-A568-C107FD3942AA}.Debug|Any CPU.Build.0 = Debug|Any CPU 22 | {D1293237-462F-4189-A568-C107FD3942AA}.Release|Any CPU.ActiveCfg = Release|Any CPU 23 | {D1293237-462F-4189-A568-C107FD3942AA}.Release|Any CPU.Build.0 = Release|Any CPU 24 | EndGlobalSection 25 | GlobalSection(SolutionProperties) = preSolution 26 | HideSolutionNode = FALSE 27 | EndGlobalSection 28 | GlobalSection(ExtensibilityGlobals) = postSolution 29 | SolutionGuid = {5F6A71A2-E4EF-4FBA-9861-107A50F760EA} 30 | EndGlobalSection 31 | EndGlobal 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2007-2020 Daniel Lo Nigro (Daniel15) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /ansible/ansible.cfg: -------------------------------------------------------------------------------- 1 | [defaults] 2 | inventory = ./servers.txt 3 | forks = 50 4 | strategy = free 5 | 6 | [ssh_connection] 7 | pipelining = True 8 | -------------------------------------------------------------------------------- /ansible/deploy-workers.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ex 3 | if [ ! -d ../src/DnsTools.Worker/bin/Release/net8.0/linux-x64/publish/ ]; then 4 | dotnet publish ../src/DnsTools.Worker -r linux-x64 -c Release 5 | fi 6 | 7 | ANSIBLE_CONFIG=/mnt/c/src/dnstools.ws/ansible/ansible.cfg ansible-playbook worker.yml --extra-vars '@passwd.yml' --vault-password-file=vault-password 8 | -------------------------------------------------------------------------------- /ansible/roles/dnstools-worker/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install packages 3 | apt: 4 | name: 5 | - certbot 6 | - htop 7 | - iputils-ping 8 | - libcurl4 9 | - mtr-tiny 10 | - python3-requests 11 | - rsync 12 | - rsyslog 13 | - traceroute 14 | - unattended-upgrades 15 | state: present 16 | install_recommends: false 17 | update_cache: yes 18 | 19 | - name: Disable search domain in /etc/resolv.conf 20 | lineinfile: 21 | path: /etc/resolv.conf 22 | line: search . 23 | 24 | # In theory, `net.ipv4.ping_group_range` should work instead, but I couldn't get that 25 | # working across all servers, so we'll just rely on setuid for now. 26 | - name: Ensure ping is setuid 27 | file: 28 | path: /bin/ping 29 | mode: 04755 30 | 31 | ################################################# 32 | # Certbot 33 | 34 | - name: Create acme-dns-auth.py for acme-dns 35 | template: 36 | src: acme-dns-auth.py.j2 37 | dest: /etc/letsencrypt/acme-dns-auth.py 38 | owner: root 39 | group: root 40 | mode: "0700" 41 | 42 | - name: Create certbot renewal hooks directory 43 | file: 44 | path: /etc/letsencrypt/renewal-hooks/deploy/ 45 | state: directory 46 | mode: "0755" 47 | 48 | - name: Enable certbot renewal hook 49 | file: 50 | src: /opt/dnstools-worker/deployment/letsencrypt-deploy-hook.sh 51 | dest: /etc/letsencrypt/renewal-hooks/deploy/01-dnstools-worker 52 | state: link 53 | force: yes 54 | 55 | ################################################# 56 | # dnstools-worker 57 | 58 | - name: Create worker directory 59 | file: 60 | path: /opt/dnstools-worker/ 61 | state: directory 62 | owner: daniel-ansible 63 | mode: "0755" 64 | 65 | - name: Copy worker 66 | synchronize: 67 | src: ../src/DnsTools.Worker/bin/Release/net8.0/linux-x64/publish/ 68 | dest: /opt/dnstools-worker/ 69 | rsync_path: "/usr/bin/rsync" # Avoiding sudo for rsync 70 | 71 | - name: Enable dnstools-worker systemd 72 | file: 73 | src: /opt/dnstools-worker/deployment/dnstools-worker.service 74 | dest: /etc/systemd/system/dnstools-worker.service 75 | state: link 76 | 77 | - name: Reload systemd 78 | systemd: 79 | daemon_reload: yes 80 | 81 | - name: Check if certbot has ran 82 | stat: 83 | path: /opt/dnstools-worker/key.pfx 84 | register: cert_exists 85 | 86 | - debug: 87 | msg: "Certbot has not been ran. Can't start the worker yet" 88 | when: cert_exists.stat.exists == False 89 | 90 | - name: Start worker 91 | systemd: 92 | name: dnstools-worker 93 | state: restarted 94 | enabled: yes 95 | when: cert_exists.stat.exists == True 96 | -------------------------------------------------------------------------------- /ansible/servers.txt: -------------------------------------------------------------------------------- 1 | [controller] 2 | control ansible_connection=local 3 | 4 | [workers] 5 | at.worker.dns.tg 6 | au-syd.worker.dns.tg clean_old_dotnet=false 7 | bg.worker.dns.tg 8 | ca.worker.dns.tg 9 | ch.worker.dns.tg 10 | cl.worker.dns.tg 11 | de.worker.dns.tg 12 | ee.worker.dns.tg 13 | fi.worker.dns.tg 14 | fr.worker.dns.tg 15 | hk.worker.dns.tg 16 | in.worker.dns.tg 17 | jp.worker.dns.tg 18 | lon.worker.dns.tg 19 | md.worker.dns.tg 20 | nl.worker.dns.tg 21 | no.worker.dns.tg 22 | nz.worker.dns.tg 23 | pl.worker.dns.tg 24 | ro.worker.dns.tg 25 | se.worker.dns.tg 26 | sg.worker.dns.tg 27 | us-dal.worker.dns.tg 28 | us-fl.worker.dns.tg 29 | us-la.worker.dns.tg clean_old_dotnet=false 30 | us-ny.worker.dns.tg 31 | za.worker.dns.tg 32 | 33 | [workers:vars] 34 | ansible_user=daniel-ansible 35 | ansible_become=yes 36 | ansible_become_method=sudo 37 | ansible_ssh_private_key_file=~/.ssh/id_ed25519_ansible 38 | ansible_become_pass='{{ sudo_pass }}' 39 | clean_old_dotnet=true 40 | ansible_python_interpreter=/usr/bin/python3 41 | -------------------------------------------------------------------------------- /ansible/worker.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Configure servers 3 | hosts: workers 4 | become: yes 5 | 6 | roles: 7 | - dnstools-worker 8 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/.env: -------------------------------------------------------------------------------- 1 | PORT=14329 2 | BROWSER=none 3 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/.env.sentry-build-plugin: -------------------------------------------------------------------------------- 1 | # Token for Sentry Webpack plugin to create new releases 2 | SENTRY_AUTH_TOKEN=CHANGEME 3 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/.eslintignore: -------------------------------------------------------------------------------- 1 | ./src/types/generated.ts 2 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/.prettierrc: -------------------------------------------------------------------------------- 1 | tabWidth: 2 2 | singleQuote: true 3 | trailingComma: all 4 | bracketSpacing: false 5 | jsxBracketSameLine: true 6 | arrowParens: avoid 7 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `yarn start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `yarn test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `yarn build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `yarn eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/craco.config.js: -------------------------------------------------------------------------------- 1 | const autoprefixer = require('autoprefixer'); 2 | const purgecss = require('@fullhuman/postcss-purgecss'); 3 | const {sentryWebpackPlugin} = require("@sentry/webpack-plugin"); 4 | const {whenProd} = require('@craco/craco'); 5 | 6 | module.exports = { 7 | babel: { 8 | plugins: [ 9 | 'babel-plugin-dev-expression', 10 | ] 11 | }, 12 | style: { 13 | postcss: { 14 | plugins: [ 15 | autoprefixer, 16 | ...whenProd( 17 | () => [ 18 | purgecss({ 19 | content: [ 20 | './public/*.html', 21 | './src/**/*.tsx', 22 | './src/**/*.ts', 23 | '../Views/**/*.cshtml', 24 | '../legacy/public/*.php', 25 | ], 26 | }), 27 | ], 28 | [], 29 | ), 30 | ], 31 | }, 32 | }, 33 | webpack: { 34 | plugins: { 35 | add: [ 36 | ...whenProd( 37 | () => [ 38 | sentryWebpackPlugin({ 39 | authToken: process.env.SENTRY_AUTH_TOKEN, 40 | org: 'daniel15', 41 | project: 'dnstools-js', 42 | release: process.env.SENTRY_RELEASE, 43 | }) 44 | ], 45 | [] 46 | ), 47 | ], 48 | } 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/generate-cshtml.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Generates .cshtml files from the create-react-app and react-snap .html output 3 | */ 4 | 5 | const fs = require('fs'); 6 | 7 | function convertPage(inputFile, outputFile) { 8 | let inputPage = fs.readFileSync(`${__dirname}/${inputFile}.html`, { 9 | encoding: 'utf-8', 10 | }); 11 | let outputPage = ` 12 | @{ 13 | Layout = null; 14 | }`; 15 | 16 | if (outputFile === '200') { 17 | // Replace tag with server-rendered version 18 | outputPage += ` 19 | @model DnsTools.Web.ViewModels.IndexViewModel 20 | `; 21 | inputPage = inputPage.replace(/<title>([^<]+)/, '<title>@Model.Title'); 22 | } 23 | 24 | outputPage += '\n' + inputPage; 25 | 26 | const outputPath = `${__dirname}/../Views/React/${outputFile}.cshtml`; 27 | fs.writeFileSync(outputPath, outputPage); 28 | console.log(`Updated ${outputPath}`); 29 | } 30 | 31 | if (fs.existsSync(`${__dirname}/build`)) { 32 | console.log('*** PROD build'); 33 | convertPage('build/200', '200'); 34 | convertPage('build/404', '404'); 35 | convertPage('build/index', 'Index'); 36 | convertPage('build/locations/index', 'Locations'); 37 | } else { 38 | console.log('*** DEV build'); 39 | convertPage('public/index', '200'); 40 | } 41 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dnstools", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@craco/craco": "^6.1.1", 7 | "@fullhuman/postcss-purgecss": "^1.3.0", 8 | "@microsoft/signalr": "^3.0.0", 9 | "@popperjs/core": "^2.10.2", 10 | "@primer/octicons-react": "^17.0.0", 11 | "@sentry/cli": "^1.69.1", 12 | "@sentry/react": "^7.81.1", 13 | "@sentry/webpack-plugin": "^2.10.1", 14 | "@types/grecaptcha": "^3.0.0", 15 | "@types/jest": "24.0.19", 16 | "@types/node": "12.11.1", 17 | "@types/react": "17.0.3", 18 | "@types/react-dom": "17.0.3", 19 | "@types/react-helmet": "^6.1.0", 20 | "@types/react-router-dom": "^5.1.2", 21 | "@types/webpack-env": "^1.14.1", 22 | "babel-plugin-dev-expression": "^0.2.2", 23 | "bootstrap": "^4.4.1", 24 | "bootswatch": "^4.4.1", 25 | "downshift": "^3.4.8", 26 | "flag-icon-css": "^3.4.5", 27 | "node-sass": "^5.0.0", 28 | "plausible-tracker": "^0.3.8", 29 | "react": "^17.0.2", 30 | "react-dom": "^17.0.2", 31 | "react-google-charts": "^3.0.15", 32 | "react-helmet": "^6.1.0", 33 | "react-popper": "^2.2.5", 34 | "react-router-dom": "^5.1.2", 35 | "react-scripts": "4.0.3", 36 | "resize-observer-polyfill": "^1.5.1", 37 | "source-map-explorer": "^2.1.1", 38 | "ts-essentials": "7.0.1", 39 | "typescript": "4.2.3", 40 | "use-deep-compare-effect": "^1.2.0", 41 | "use-onclickoutside": "^0.3.1" 42 | }, 43 | "scripts": { 44 | "analyze": "source-map-explorer 'build/static/js/*.js'", 45 | "start": "craco start", 46 | "build": "craco build", 47 | "postbuild": "react-snap", 48 | "test": "craco test", 49 | "generate-cshtml": "node ./generate-cshtml.js", 50 | "eject": "react-scripts eject" 51 | }, 52 | "eslintConfig": { 53 | "extends": "react-app" 54 | }, 55 | "browserslist": { 56 | "production": [ 57 | ">0.2%", 58 | "not dead", 59 | "not op_mini all" 60 | ], 61 | "development": [ 62 | "last 1 chrome version", 63 | "last 1 firefox version", 64 | "last 1 safari version" 65 | ] 66 | }, 67 | "devDependencies": { 68 | "react-snap": "1.23.0" 69 | }, 70 | "proxy": "https://localhost:14325", 71 | "reactSnap": { 72 | "skipThirdPartyRequests": false, 73 | "http2PushManifest": true 74 | }, 75 | "volta": { 76 | "node": "14.17.6" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Daniel15/dnstools/c85057801c294a292dfdef2127798bbc159a56d2/src/DnsTools.Web/ClientApp/public/favicon.ico -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/public/index.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html lang="en"> 3 | <head> 4 | <meta charset="utf-8" /> 5 | 6 | <title>DNSTools 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "DNSTools", 3 | "name": "DNSTools", 4 | "start_url": ".", 5 | "display": "standalone", 6 | "theme_color": "#222222", 7 | "background_color": "#222222" 8 | } 9 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect} from 'react'; 2 | import {BrowserRouter as Router, Switch, Route} from 'react-router-dom'; 3 | import {HubConnectionBuilder} from '@microsoft/signalr'; 4 | 5 | import {apiUrl} from './config'; 6 | import useIpData from './hooks/useIpData'; 7 | import SignalrContext from './SignalrContext'; 8 | import NavigationSideEffects from './components/NavigationSideEffects'; 9 | import {isPrerendering} from './utils/prerendering'; 10 | 11 | import DnsLookup from './pages/DnsLookup'; 12 | import DnsTraversal from './pages/DnsTraversal'; 13 | import FileNotFound from './pages/404'; 14 | import Index from './pages/Index'; 15 | import Locations from './pages/Locations'; 16 | import Mtr from './pages/Mtr'; 17 | import Ping from './pages/Ping'; 18 | import Traceroute from './pages/Traceroute'; 19 | import Whois from './pages/Whois'; 20 | 21 | const connection = new HubConnectionBuilder() 22 | .withUrl(`${apiUrl}/hub`) 23 | .withAutomaticReconnect() 24 | .build(); 25 | 26 | export default function App() { 27 | const [isConnected, setIsConnected] = useState(false); 28 | 29 | useEffect(() => { 30 | // Only connect to SignalR if not prerendering... when prerendering, we 31 | // just want to render the initial state. 32 | if (!isPrerendering) { 33 | connection 34 | .start() 35 | .then(() => setIsConnected(true)) 36 | .catch(err => alert('Could not connect: ' + err.message)); 37 | return () => {}; 38 | } 39 | }, []); 40 | 41 | const ipData = useIpData(connection); 42 | return ( 43 | 44 | 45 | 46 |
47 | 48 | 49 | 50 | 51 | 52 | 53 | ( 56 | 57 | )} 58 | /> 59 | ( 62 | 63 | )} 64 | /> 65 | ( 68 | 69 | )} 70 | /> 71 | 72 | } 75 | /> 76 | 77 | 78 | 79 | 80 | {/* Dummy route for navigateWithReload() */} 81 | null} /> 82 | 83 | 84 | 85 |
86 |
87 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/SignalrContext.ts: -------------------------------------------------------------------------------- 1 | import {createContext} from 'react'; 2 | import {HubConnection} from '@microsoft/signalr'; 3 | 4 | export type SignalrContextType = { 5 | connection: HubConnection; 6 | isConnected: boolean; 7 | }; 8 | 9 | export default createContext({ 10 | connection: null!, // Always set in App.tsx 11 | isConnected: false, 12 | }); 13 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/analytics.ts: -------------------------------------------------------------------------------- 1 | import type {EventOptions, PlausibleOptions} from 'plausible-tracker'; 2 | import Plausible from 'plausible-tracker'; 3 | import {init as sentryInit} from '@sentry/react'; 4 | 5 | import {apiUrl, sentryJS as SentryConfig} from './config'; 6 | 7 | const shouldLog = 8 | (!__DEV__ || document.location.search.includes('enable_logging')) && 9 | navigator.userAgent !== 'ReactSnap'; 10 | 11 | // Use a no-op trackEvent() in dev or when running ReactSnap 12 | let trackEvent: ( 13 | eventName: string, 14 | options?: EventOptions, 15 | eventData?: PlausibleOptions, 16 | ) => void = () => {}; 17 | 18 | // Error logging 19 | if (shouldLog) { 20 | sentryInit({ 21 | dsn: SentryConfig.dsn, 22 | debug: __DEV__, 23 | environment: __DEV__ ? 'development' : 'production', 24 | tracesSampleRate: 0.0, 25 | tunnel: `${apiUrl}/error/log`, 26 | }); 27 | 28 | // Analytics 29 | const { 30 | enableAutoPageviews, 31 | enableAutoOutboundTracking, 32 | trackEvent: realTrackEvent, 33 | } = Plausible({ 34 | apiHost: __DEV__ ? 'https://hits.d.sb' : apiUrl, 35 | domain: 'dnstools.ws', 36 | trackLocalhost: true, 37 | }); 38 | 39 | enableAutoPageviews(); 40 | enableAutoOutboundTracking(); 41 | trackEvent = realTrackEvent; 42 | } 43 | 44 | export {trackEvent}; 45 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/CAPTCHA.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useRef, useState, useCallback} from 'react'; 2 | 3 | import {apiUrl, ReCaptcha as ReCaptchaConfig} from '../config'; 4 | import {CaptchaResponse} from '../types/generated'; 5 | import Spinner from './Spinner'; 6 | 7 | let script: HTMLScriptElement | null = null; 8 | let loadCallbacks: Array<() => void> = []; 9 | 10 | declare global { 11 | interface Window { 12 | onCaptchaLoaded: () => void; 13 | } 14 | } 15 | 16 | window.onCaptchaLoaded = () => { 17 | loadCallbacks.forEach(cb => cb()); 18 | loadCallbacks = []; 19 | }; 20 | 21 | type Props = Readonly<{ 22 | onVerifyStarted: () => void; 23 | onComplete: () => void; 24 | }>; 25 | export default function CAPTCHA(props: Props) { 26 | const [scriptLoaded, setScriptLoaded] = useState(false); 27 | const [error, setError] = useState(null); 28 | const ref = useRef(null); 29 | 30 | useEffect(() => { 31 | // Load ReCAPTCHA if not already loading 32 | if (script == null) { 33 | script = document.createElement('script'); 34 | script.async = true; 35 | script.defer = true; 36 | script.src = 37 | 'https://www.google.com/recaptcha/api.js?onload=onCaptchaLoaded&render=explicit'; 38 | document.head.appendChild(script); 39 | } 40 | 41 | // If ReCAPTCHA wasn't loaded when the component mounted, add to loading queue 42 | if (!window.grecaptcha) { 43 | loadCallbacks.push(() => setScriptLoaded(true)); 44 | } else { 45 | setScriptLoaded(true); 46 | } 47 | }, []); 48 | 49 | const {onComplete, onVerifyStarted} = props; 50 | const onCompleteWrapper = useCallback( 51 | async recaptchaResponse => { 52 | try { 53 | onVerifyStarted(); 54 | const rawResponse = await fetch(`${apiUrl}/captcha`, { 55 | credentials: 'include', 56 | body: 'response=' + encodeURIComponent(recaptchaResponse), 57 | headers: { 58 | 'Content-Type': 'application/x-www-form-urlencoded', 59 | }, 60 | method: 'post', 61 | }); 62 | const response: CaptchaResponse = await rawResponse.json(); 63 | if (!response.success) { 64 | setError(response.error); 65 | return; 66 | } 67 | onComplete(); 68 | } catch (ex) { 69 | setError(ex.message); 70 | } 71 | }, 72 | [onComplete, onVerifyStarted], 73 | ); 74 | 75 | useEffect(() => { 76 | if (!scriptLoaded || !ref.current) { 77 | return; 78 | } 79 | 80 | grecaptcha.render(ref.current, { 81 | sitekey: ReCaptchaConfig.siteKey, 82 | theme: 'dark', 83 | callback: onCompleteWrapper, 84 | }); 85 | }, [onCompleteWrapper, scriptLoaded]); 86 | 87 | return ( 88 | <> 89 | {!scriptLoaded && } 90 | {error && ( 91 |
92 | ERROR: {error} 93 |
94 | )} 95 |
96 | 97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/DismissableNotice.tsx: -------------------------------------------------------------------------------- 1 | import React, {ReactNode, useState} from 'react'; 2 | 3 | type Props = Readonly<{ 4 | children: ReactNode; 5 | id: string; 6 | }>; 7 | 8 | export default function DismissableNotice(props: Props) { 9 | const storageKey = `notice_${props.id}`; 10 | const [isHidden, setIsHidden] = useState(() => { 11 | if (!window.localStorage) { 12 | return false; 13 | } 14 | 15 | try { 16 | return !!window.localStorage.getItem(storageKey); 17 | } catch { 18 | // eg. storage is disabled 19 | return false; 20 | } 21 | }); 22 | 23 | if (isHidden) { 24 | return null; 25 | } 26 | 27 | return ( 28 |
31 | {props.children} 32 | 43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/DnsLookupReferralDetails.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | 3 | import {DnsLookupType} from '../types/generated'; 4 | import {DnsLookupReferral} from '../types/protobuf'; 5 | import DnsRecordsTable from './DnsRecordsTable'; 6 | import ExpandTransition from './ExpandTransition'; 7 | 8 | type Props = Readonly<{ 9 | lookupType: DnsLookupType; 10 | referral: DnsLookupReferral; 11 | }>; 12 | 13 | export default function DnsLookupReferralDetails(props: Props) { 14 | const [showDetails, setShowDetails] = useState(false); 15 | 16 | if (!props.referral.reply) { 17 | return null; 18 | } 19 | 20 | return ( 21 | <> 22 | 28 | 31 | 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/DnsLookupResults.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {DnsLookupResponse} from '../types/protobuf'; 4 | import {DnsLookupResponseType, DnsLookupType} from '../types/generated'; 5 | import DnsRecordsTable from './DnsRecordsTable'; 6 | import DnsLookupReferralDetails from './DnsLookupReferralDetails'; 7 | 8 | type Props = Readonly<{ 9 | host: string; 10 | responses: ReadonlyArray; 11 | lookupType: DnsLookupType; 12 | }>; 13 | 14 | export default function DnsLookupResults(props: Props) { 15 | return ( 16 | <> 17 | {props.responses.map((response, index) => { 18 | switch (response.responseCase) { 19 | case DnsLookupResponseType.Referral: 20 | const prevServerName = response.referral.prevServerName; 21 | return ( 22 | 23 | {prevServerName && ( 24 | <> 25 | Got referral to {response.referral.nextServerName} [took{' '} 26 | {response.duration} ms].{' '} 27 | 31 | 32 | )} 33 | Querying {response.referral.nextServerName}:{' '} 34 | 35 | ); 36 | 37 | case DnsLookupResponseType.Retry: { 38 | return ( 39 | 40 | 41 | {response.retry.error.title} 42 | 43 |
44 | Retrying at {response.retry.nextServerName}:{' '} 45 |
46 | ); 47 | } 48 | 49 | case DnsLookupResponseType.Error: 50 | return ( 51 | 52 | {response.error.title} 53 |
54 | {response.error.message} 55 |
56 |
57 | ); 58 | 59 | case DnsLookupResponseType.Reply: 60 | return ( 61 | 62 | [took {response.duration} ms] 63 | 67 | 68 | ); 69 | 70 | default: 71 | return <>Unknown response!; 72 | } 73 | })} 74 | 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/DnsRecordValue.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {DnsRecord} from '../types/protobuf'; 3 | import {DnsRecordType, DnsLookupType, IDnsNSRecord} from '../types/generated'; 4 | import {Link} from 'react-router-dom'; 5 | 6 | import {duration as formatDuration} from '../utils/format'; 7 | import IPAddress from './IPAddress'; 8 | 9 | type Props = { 10 | lookupType: DnsLookupType; 11 | record: DnsRecord; 12 | getNSLink?: (record: IDnsNSRecord) => string; 13 | }; 14 | export default function DnsRecordValue(props: Props) { 15 | const {record} = props; 16 | switch (record.recordCase) { 17 | case DnsRecordType.A: 18 | return ; 19 | 20 | case DnsRecordType.Aaaa: 21 | return ; 22 | 23 | case DnsRecordType.Caa: 24 | return <>{record.caa.value}; 25 | 26 | case DnsRecordType.Cname: 27 | return ( 28 | 32 | {record.cname.cname} 33 | 34 | ); 35 | 36 | case DnsRecordType.Mx: 37 | return ( 38 | <> 39 | {record.mx.exchange} (priority {record.mx.preference}) 40 | 41 | ); 42 | 43 | case DnsRecordType.Ns: 44 | return <>{record.ns.nsdname}; 45 | 46 | case DnsRecordType.Ptr: 47 | return <>{record.ptr.ptrdname}; 48 | 49 | case DnsRecordType.Soa: 50 | return ( 51 | <> 52 | Primary DNS server: {record.soa.mname} 53 |
54 | Responsible name: {record.soa.rname} 55 |
56 | Serial: {record.soa.serial} 57 |
58 | Refresh: {formatDuration(record.soa.refresh)} 59 |
60 | Retry: {formatDuration(record.soa.retry)} 61 |
62 | Expire: {formatDuration(record.soa.expire)} 63 |
64 | Minimum TTL: {formatDuration(record.soa.minimum)} 65 | 66 | ); 67 | 68 | case DnsRecordType.Txt: 69 | return <>{record.txt.text}; 70 | 71 | default: 72 | return Unknown record type!; 73 | } 74 | } 75 | 76 | export function getSortValue(record: DnsRecord): string | null { 77 | switch (record.recordCase) { 78 | case DnsRecordType.A: 79 | return record.a.address; 80 | 81 | case DnsRecordType.Aaaa: 82 | return record.aaaa.address; 83 | 84 | case DnsRecordType.Caa: 85 | return record.caa.value; 86 | 87 | case DnsRecordType.Cname: 88 | return record.cname.cname; 89 | 90 | case DnsRecordType.Mx: 91 | return `${record.mx.exchange}-${record.mx.preference}`; 92 | 93 | case DnsRecordType.Ns: 94 | return record.ns.nsdname; 95 | 96 | case DnsRecordType.Ptr: 97 | return record.ptr.ptrdname; 98 | 99 | case DnsRecordType.Soa: 100 | return `${record.soa.mname}-${record.soa.rname}-${record.soa.serial}-${record.soa.refresh}-${record.soa.retry}-${record.soa.expire}-${record.soa.minimum}`; 101 | 102 | case DnsRecordType.Txt: 103 | return record.txt.text; 104 | 105 | default: 106 | return null; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/DnsRecordsTable.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {DnsLookupReply} from '../types/protobuf'; 4 | import {DnsLookupType} from '../types/generated'; 5 | import {createSection} from './DnsRecordsTableRows'; 6 | import Table, {Header, Section} from './Table'; 7 | 8 | type Props = { 9 | lookupType: DnsLookupType; 10 | reply: DnsLookupReply; 11 | }; 12 | 13 | const headers: ReadonlyArray
= [ 14 | {label: 'Name'}, 15 | {label: 'Type'}, 16 | {label: 'TTL'}, 17 | {label: 'Answer'}, 18 | ]; 19 | 20 | export default function DnsRecordsTable({reply, lookupType}: Props) { 21 | const sections: Array
= []; 22 | if (reply.answers && reply.answers.length) { 23 | sections.push( 24 | createSection({ 25 | lookupType, 26 | records: reply.answers, 27 | }), 28 | ); 29 | } 30 | 31 | if (reply.authorities && reply.authorities.length > 0) { 32 | sections.push( 33 | createSection({ 34 | lookupType, 35 | records: reply.authorities, 36 | rowClass: 'authority', 37 | sectionTitle: 'Authority', 38 | }), 39 | ); 40 | } 41 | 42 | if (reply.additionals && reply.additionals.length) { 43 | sections.push( 44 | createSection({ 45 | lookupType, 46 | records: reply.additionals, 47 | rowClass: 'additional', 48 | sectionTitle: 'Additional', 49 | }), 50 | ); 51 | } 52 | 53 | return ( 54 |
55 | 56 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/DnsRecordsTableRows.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {DnsRecord} from '../types/protobuf'; 4 | import DnsRecordValue, {getSortValue} from './DnsRecordValue'; 5 | import {DnsLookupType, DnsRecordType} from '../types/generated'; 6 | import {duration as formatDuration} from '../utils/format'; 7 | import {Section} from './Table'; 8 | 9 | type Props = { 10 | lookupType: DnsLookupType; 11 | records: ReadonlyArray; 12 | rowClass?: string; 13 | sectionTitle?: string; 14 | }; 15 | 16 | export function createSection(props: Props): Section { 17 | return { 18 | title: props.sectionTitle, 19 | rows: props.records.map((record, index) => ({ 20 | className: props.rowClass, 21 | columns: [ 22 | { 23 | sortValue: record.name, 24 | value: record.name, 25 | }, 26 | { 27 | sortValue: DnsRecordType[record.recordCase].toUpperCase(), 28 | value: DnsRecordType[record.recordCase].toUpperCase(), 29 | }, 30 | { 31 | sortValue: record.ttl, 32 | value: formatDuration(record.ttl), 33 | }, 34 | { 35 | sortValue: getSortValue(record), 36 | value: ( 37 | 38 | ), 39 | }, 40 | ], 41 | id: '' + index, 42 | })), 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/DnsTraversalLevel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {DnsTraversalResponse} from '../types/protobuf'; 4 | import DnsRecordsSummaryTable from '../components/DnsRecordsSummaryTable'; 5 | import {DnsLookupType} from '../types/generated'; 6 | 7 | type Props = Readonly<{ 8 | lookupType: DnsLookupType; 9 | responses: ReadonlyArray; 10 | servers: ReadonlySet; 11 | }>; 12 | export default function DnsTraversalLevel(props: Props) { 13 | const serversLeft = props.servers.size - props.responses.length; 14 | 15 | // TODO: Don't show table if all records are the same across all servers 16 | // (just show the records once, and "all servers returned the same response") 17 | return ( 18 |
19 | Looking on {props.servers.size} servers...{' '} 20 | {serversLeft > 0 && <>{serversLeft} remaining} 21 | 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/DropdownButton.tsx: -------------------------------------------------------------------------------- 1 | import React, {useRef, useState} from 'react'; 2 | import useOnClickOutside from 'use-onclickoutside'; 3 | 4 | type Props = { 5 | children: React.ReactNode; 6 | id: string; 7 | label: string; 8 | }; 9 | 10 | export default function DropdownButton(props: Props) { 11 | const rootRef = useRef(null); 12 | const [isOpen, setIsOpen] = useState(false); 13 | useOnClickOutside(rootRef, () => setIsOpen(false)); 14 | 15 | return ( 16 |
17 | 27 |
30 | {props.children} 31 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/ExpandTransition.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState, memo, DetailedHTMLProps, HTMLAttributes} from 'react'; 2 | 3 | import useDimensions from '../hooks/useDimensions'; 4 | import useLayoutEffectExceptInitialRender from '../hooks/useLayoutEffectExceptInitialRender'; 5 | 6 | type Props = Readonly<{ 7 | children: React.ReactNode; 8 | isExpanded: boolean; 9 | }> & 10 | // Allow all standard
attributes - Will be spread onto div 11 | DetailedHTMLProps, HTMLDivElement>; 12 | 13 | /** 14 | * Animates expanded/collapsed state of a div. 15 | */ 16 | export default memo(function ExpandTransition(props: Props) { 17 | const {children, isExpanded, ...otherProps} = props; 18 | const [hasCompletedAnimation, setHasCompletedAnimation] = useState( 19 | true, 20 | ); 21 | const [renderAsExpanded, setRenderAsExpanded] = useState( 22 | props.isExpanded, 23 | ); 24 | const [ref, dimensions] = useDimensions(); 25 | 26 | useLayoutEffectExceptInitialRender(() => { 27 | setHasCompletedAnimation(false); 28 | // Allow it to render in the old state for one frame (to get rid of the `auto`), 29 | // then re-render with the new expanded state, to start the animation. 30 | setTimeout(() => setRenderAsExpanded(props.isExpanded), 0); 31 | }, [props.isExpanded]); 32 | 33 | let renderHeight: number | string = renderAsExpanded ? dimensions.height : 0; 34 | if (props.isExpanded && hasCompletedAnimation) { 35 | // If we're re-rendering while expanded, don't mess with the height 36 | // (prevents animating this expand area if there's a nested expand area 37 | // within it that's expanding) 38 | renderHeight = 'auto'; 39 | } 40 | 41 | return ( 42 |
{ 49 | setHasCompletedAnimation(true); 50 | }}> 51 | {(props.isExpanded || !hasCompletedAnimation) && ( 52 |
53 |
{props.children}
54 |
55 | )} 56 |
57 | ); 58 | }); 59 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/IPAddress.tsx: -------------------------------------------------------------------------------- 1 | import {Link} from 'react-router-dom'; 2 | 3 | import WithHovercard from './WithHovercard'; 4 | 5 | type Props = Readonly<{ 6 | address: string; 7 | }>; 8 | 9 | export default function IPAddress(props: Props) { 10 | return ( 11 | <> 12 | {props.address}{' '} 13 | 14 | 15 | 16 | W 17 | 18 | {' '} 19 | 20 | P 21 | 22 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/IPDetails.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import CountryFlag from './CountryFlag'; 4 | import {IpData} from '../types/generated'; 5 | 6 | type Props = Readonly<{ 7 | ip: string; 8 | ipData: IpData | undefined; 9 | }>; 10 | 11 | export default function IPDetails(props: Props) { 12 | const {ip, ipData} = props; 13 | 14 | const metadata: Array = []; 15 | if (ipData) { 16 | if (ipData.countryIso) { 17 | metadata.push( 18 | <> 19 | 20 | {[ipData.city, ipData.country].filter(Boolean).join(', ')} 21 | , 22 | ); 23 | } 24 | if (ipData.asn) { 25 | metadata.push(`AS${ipData.asn} ${ipData.asnName}`); 26 | } 27 | } 28 | 29 | return ( 30 | <> 31 | {ipData && ipData.hostName ? ( 32 | <> 33 | {ipData.hostName} ({ip}) 34 | 35 | ) : ( 36 | {ip} 37 | )}{' '} 38 |
    39 | {metadata.map((item, index) => ( 40 |
  • 41 | {item} 42 |
  • 43 | ))} 44 |
45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/InlineWhoisForm.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState, FormEvent} from 'react'; 2 | import {useHistory} from 'react-router'; 3 | 4 | import {navigateWithReload} from '../utils/routing'; 5 | 6 | type Props = Readonly<{ 7 | initialHost: string; 8 | }>; 9 | 10 | export default function InlineWhoisForm(props: Props) { 11 | const [host, setHost] = useState(props.initialHost); 12 | const history = useHistory(); 13 | 14 | function onSubmit(evt: FormEvent) { 15 | evt.preventDefault(); 16 | navigateWithReload(history, { 17 | pathname: `/whois/${host}/`, 18 | }); 19 | } 20 | 21 | return ( 22 |
23 | 26 | setHost(evt.target.value)} 32 | /> 33 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/LazyChart.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {ReactGoogleChartProps} from 'react-google-charts/dist/types'; 3 | import {useIsPrerendering} from '../utils/prerendering'; 4 | 5 | const Chart = React.lazy(() => import('react-google-charts')); 6 | 7 | export default function LazyChart(props: ReactGoogleChartProps) { 8 | const placeholder = ; 9 | const isPrerendering = useIsPrerendering(); 10 | if (isPrerendering) { 11 | return placeholder; 12 | } 13 | return ( 14 | 15 | 16 | 17 | ); 18 | } 19 | 20 | function LoadingPlaceholder({height}: {height: string | number}) { 21 | return ( 22 |
28 | Loading... 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/NavigationSideEffects.tsx: -------------------------------------------------------------------------------- 1 | import {useEffect} from 'react'; 2 | import {useLocation} from 'react-router-dom'; 3 | 4 | /** 5 | * Side effects (eg. logging) that occur when client-side page navigation occurs. 6 | */ 7 | export default function NavigationSideEffects() { 8 | const {pathname, search} = useLocation(); 9 | useEffect(() => { 10 | // Scroll to the top on route change 11 | window.scrollTo(0, 0); 12 | }, [pathname, search]); 13 | return null; 14 | } 15 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/PingDetails.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import {WorkerConfig, getLongLocationDisplay} from '../utils/workers'; 4 | import { 5 | PingRequest, 6 | Protocol, 7 | WorkerResponse, 8 | IpData, 9 | } from '../types/generated'; 10 | import {useCachedSignalrStream} from '../hooks/CachedSignalrStream'; 11 | import {TracerouteResponse} from '../types/protobuf'; 12 | import {SignalrCache} from '../hooks/CachedSignalrStream'; 13 | import TracerouteWorker from './TracerouteWorker'; 14 | 15 | type Props = Readonly<{ 16 | ip: string | undefined; 17 | ipData: ReadonlyMap; 18 | tracerouteCache: SignalrCache>; 19 | worker: WorkerConfig; 20 | }>; 21 | 22 | export default function PingDetails(props: Props) { 23 | const {worker} = props; 24 | 25 | return ( 26 |
27 | {getLongLocationDisplay(worker)} · {worker.providerName} (AS 28 | {worker.networkAsn}) 29 | {props.ip != null && ( 30 | 36 | )} 37 |
38 | ); 39 | } 40 | 41 | function PingDetailsTraceroute( 42 | props: Readonly<{ 43 | ip: string; 44 | ipData: ReadonlyMap; 45 | tracerouteCache: SignalrCache>; 46 | worker: WorkerConfig; 47 | }>, 48 | ) { 49 | const request: PingRequest = { 50 | host: props.ip, 51 | protocol: Protocol.Any, 52 | workers: [props.worker.id], 53 | }; 54 | const data = useCachedSignalrStream>( 55 | props.tracerouteCache, 56 | 'traceroute', 57 | request, 58 | ); 59 | 60 | return ( 61 | <> 62 |
Traceroute
63 | x.response)} 68 | worker={props.worker} 69 | /> 70 | 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/PromotedServerProviders.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {commaSeparate} from '../utils/react'; 4 | import WithHovercard from './WithHovercard'; 5 | import {trackEvent} from '../analytics'; 6 | 7 | // Keep these in alphabetical order. 8 | const providers = [ 9 | { 10 | name: 'Advin Servers', 11 | tooltip: 12 | 'Fast VPSes with a large amount of RAM and storage, starting at $7.99/month', 13 | url: 'https://advinservers.com/', 14 | }, 15 | { 16 | name: 'AlexHost', 17 | tooltip: 'Reliable and fast hosting for your sites, based in Moldova', 18 | url: 'https://alexhost.com/', 19 | }, 20 | { 21 | name: 'FreeVPS', 22 | tooltip: 'Free VPS for active users of LowEndTalk and LowEndSpirit forums', 23 | url: 'https://freevps.org/', 24 | }, 25 | { 26 | name: "Gullo's Hosting", 27 | tooltip: 'VPS hosting starting from $3.50 per year', 28 | url: 'https://hosting.gullo.me/', 29 | }, 30 | { 31 | name: 'HostEONS', 32 | tooltip: 33 | 'RYZEN NVME Premium VPS, Budget KVM SSD VPS and OpenVZ 7 VPS Hosting', 34 | url: 'https://hosteons.com/', 35 | }, 36 | { 37 | name: 'HostNamaste', 38 | tooltip: 39 | 'VPS hosting in several countries around the world, starting at $10/year', 40 | url: 'https://www.hostnamaste.com/', 41 | }, 42 | { 43 | name: 'WebHorizon', 44 | tooltip: 'AMD EPYC NAT VPS hosting from £5.50 per year', 45 | url: 'https://webhorizon.in/', 46 | }, 47 | { 48 | name: 'xTom', 49 | tooltip: 50 | 'Simple, affordable, accessible cloud computing, in 10 data centers worldwide', 51 | url: 'https://v.ps/', 52 | }, 53 | { 54 | name: 'Zappie Host', 55 | tooltip: 'VPS hosting in New Zealand and South Africa', 56 | url: 'https://zappiehost.com/', 57 | }, 58 | ]; 59 | 60 | export default function FooterHostingProviders() { 61 | return ( 62 | <> 63 | {commaSeparate( 64 | providers.map(provider => ( 65 | 69 | { 74 | trackEvent('Hosting Provider Link', { 75 | props: { 76 | provider_name: provider.name, 77 | }, 78 | }); 79 | }}> 80 | {provider.name} 81 | 82 | 83 | )), 84 | 'and', 85 | )} 86 | 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/ShimmerBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function ShimmerBar() { 4 | return ( 5 |
6 |
9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export enum Size { 4 | Small, 5 | Large, 6 | } 7 | 8 | type Props = { 9 | size?: Size; 10 | }; 11 | 12 | export default function Spinner(props: Props) { 13 | let className = 'spinner'; 14 | switch (props.size) { 15 | case Size.Small: 16 | className += ' spinner-small'; 17 | break; 18 | 19 | case Size.Large: 20 | default: 21 | className += ' spinner-large'; 22 | break; 23 | } 24 | return
; 25 | } 26 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/ToolSelector.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Config from '../config.json'; 4 | import {ToolMetadata} from './MainForm'; 5 | import RadioList from './form/RadioList'; 6 | 7 | type Props = Readonly<{ 8 | selectedTool: ToolMetadata; 9 | toolOptions: ReadonlyArray; 10 | onSelectTool: (tool: ToolMetadata) => void; 11 | }>; 12 | export default function ToolSelector(props: Props) { 13 | return ( 14 | <> 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | /** 22 | * Big selector for large screens 23 | */ 24 | function LargeToolSelector(props: Props) { 25 | return ( 26 |
27 |
28 | {props.toolOptions.map((tool, index) => { 29 | const isSelected = props.selectedTool.tool === tool.tool; 30 | const id = `selector-${tool.tool}`; 31 | return ( 32 | 33 |
props.onSelectTool(tool)}> 38 |
39 |
40 | props.onSelectTool(tool)} 45 | />{' '} 46 | 47 | {tool.isNew && ( 48 | New 49 | )} 50 |
51 |

52 | {tool.description.replace( 53 | '{workerCount}', 54 | '' + Config.workers.length, 55 | )} 56 |

57 |
58 |
59 | 60 | {/* Render 3 cards per row */} 61 | {(index + 1) % 3 === 0 &&
} 62 | 63 | ); 64 | })} 65 |
66 |
67 | ); 68 | } 69 | 70 | /** 71 | * Small radio button selector for small screens 72 | */ 73 | function SmallToolSelector(props: Props) { 74 | const description = props.selectedTool.description.replace( 75 | '{workerCount}', 76 | '' + Config.workers.length, 77 | ); 78 | return ( 79 |
80 | ({ 83 | extraContent: tool.isNew ? ( 84 | New 85 | ) : null, 86 | id: tool.tool, 87 | label: tool.label, 88 | value: tool, 89 | }))} 90 | selectedValue={props.selectedTool} 91 | onSelect={props.onSelectTool} 92 | /> 93 |
94 | {description} 95 |
96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/TracerouteResponse.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { 4 | IpData, 5 | ITracerouteReply, 6 | TracerouteResponseType, 7 | } from '../types/generated'; 8 | import IPDetails from './IPDetails'; 9 | import {milliseconds} from '../utils/format'; 10 | import {TracerouteResponse as TracerouteResponseData} from '../types/protobuf'; 11 | 12 | type Props = { 13 | index: number; 14 | ipData?: IpData | undefined; 15 | isFinalReply: boolean; 16 | response: TracerouteResponseData; 17 | }; 18 | 19 | export default function TracerouteResponse(props: Props) { 20 | const {response} = props; 21 | let seq = props.index + 1; 22 | let contents; 23 | switch (response.responseCase) { 24 | case TracerouteResponseType.Reply: 25 | contents = ( 26 | 31 | ); 32 | seq = response.reply.seq; 33 | break; 34 | 35 | case TracerouteResponseType.Error: 36 | contents =

Error: {response.error.message}

; 37 | break; 38 | 39 | case TracerouteResponseType.Timeout: 40 | contents =

Timed Out

; 41 | seq = response.timeout.seq; 42 | break; 43 | } 44 | 45 | return ( 46 |
  • 47 |
    48 |
    {contents}
    49 | {seq} 50 |
    51 |
  • 52 | ); 53 | } 54 | 55 | type ReplyProps = { 56 | ipData: IpData | undefined; 57 | isFinalReply: boolean; 58 | reply: ITracerouteReply; 59 | }; 60 | 61 | function TracerouteReply(props: ReplyProps) { 62 | const {ipData, reply} = props; 63 | return ( 64 | <> 65 | 69 | {milliseconds(reply.rtt)} 70 | {' '} 71 | 72 | 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/TracerouteResponseLoadingPlaceholder.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ShimmerBar from './ShimmerBar'; 3 | 4 | type Props = { 5 | count: number; 6 | seq: number; 7 | }; 8 | 9 | export default function TracerouteResponseLoadingPlaceholder(props: Props) { 10 | const placeholders = []; 11 | for (let i = 0; i < props.count; i++) { 12 | placeholders.push( 13 |
  • 14 |
    15 |
    16 | 17 |
    18 | {props.seq + i} 19 |
    20 |
  • , 21 | ); 22 | } 23 | 24 | return <>{placeholders}; 25 | } 26 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/TracerouteWorker.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {TracerouteResponse} from '../types/protobuf'; 4 | import {TracerouteResponseType, IpData} from '../types/generated'; 5 | import ReactTracerouteResponse from '../components/TracerouteResponse'; 6 | import TracerouteResponseLoadingPlaceholder from '../components/TracerouteResponseLoadingPlaceholder'; 7 | import Spinner, {Size as SpinnerSize} from '../components/Spinner'; 8 | import WorkerLocation from './WorkerLocation'; 9 | import {WorkerConfig} from '../utils/workers'; 10 | 11 | type Props = { 12 | areAllCompleted: boolean; 13 | ipData: ReadonlyMap; 14 | isCard: boolean; 15 | responses: ReadonlyArray; 16 | worker: WorkerConfig; 17 | }; 18 | 19 | const LOADING_PLACEHOLDER_COUNT = 8; 20 | 21 | export default function TracerouteWorker(props: Props) { 22 | const {worker} = props; 23 | 24 | const responses = props.responses.filter( 25 | x => 26 | x.responseCase !== TracerouteResponseType.Completed && 27 | x.responseCase !== TracerouteResponseType.Lookup, 28 | ); 29 | const isCompleted = 30 | props.areAllCompleted || 31 | !!props.responses.find( 32 | x => x.responseCase === TracerouteResponseType.Completed, 33 | ); 34 | 35 | let finalReply: TracerouteResponse | null = responses[responses.length - 1]; 36 | if ( 37 | !isCompleted || 38 | !finalReply || 39 | finalReply.responseCase !== TracerouteResponseType.Reply 40 | ) { 41 | finalReply = null; 42 | } 43 | 44 | const loadingPlaceholdersToShow = isCompleted 45 | ? 0 46 | : LOADING_PLACEHOLDER_COUNT - responses.length; 47 | 48 | let content = ( 49 |
      50 | {responses.map((response, index) => { 51 | const ipData = 52 | response.responseCase === TracerouteResponseType.Reply 53 | ? props.ipData.get(response.reply.ip) 54 | : undefined; 55 | 56 | return ( 57 | 64 | ); 65 | })} 66 | {loadingPlaceholdersToShow > 0 && ( 67 | 71 | )} 72 |
    73 | ); 74 | 75 | if (props.isCard) { 76 | return ( 77 |
    78 |
    79 |
    80 | 81 | 82 | 83 | {!isCompleted && } 84 |
    85 | {content} 86 |
    87 |
    88 | ); 89 | } 90 | 91 | return content; 92 | } 93 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/WithHovercard.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import {Placement} from '@popperjs/core'; 4 | 5 | import useMouseOver from '../hooks/useMouseOver'; 6 | import useLazyRef from '../hooks/useLazyRef'; 7 | import {usePopper} from 'react-popper'; 8 | 9 | type Props = Readonly<{ 10 | children: React.ReactNode; 11 | location?: Placement; 12 | tooltipBody: React.ReactNode; 13 | }>; 14 | export default function WithHovercard(props: Props) { 15 | const {onMouseEnter, onMouseLeave, isMouseOver} = useMouseOver(); 16 | const [contentRef, setContentRef] = useState(null); 17 | 18 | return ( 19 | <> 20 | 24 | {props.children} 25 | 26 | {isMouseOver && ( 27 | 34 | )} 35 | 36 | ); 37 | } 38 | 39 | type HovercardProps = Readonly<{ 40 | contentNode: HTMLSpanElement | null; 41 | location?: Placement; 42 | tooltipBody: React.ReactNode; 43 | onMouseEnter: () => void; 44 | onMouseLeave: () => void; 45 | }>; 46 | function Hovercard(props: HovercardProps) { 47 | const [popperElement, setPopperElement] = useState( 48 | null, 49 | ); 50 | const [arrowElement, setArrowElement] = useState(null); 51 | const {styles, attributes} = usePopper(props.contentNode, popperElement, { 52 | modifiers: [{name: 'arrow', options: {element: arrowElement}}], 53 | placement: props.location, 54 | }); 55 | 56 | const hovercardNode = useLazyRef(() => { 57 | const el = document.createElement('div'); 58 | document.body.appendChild(el); 59 | return el; 60 | }); 61 | 62 | // Remove DOM node on unmount 63 | useEffect(() => { 64 | return () => { 65 | document.body.removeChild(hovercardNode); 66 | }; 67 | }, [hovercardNode]); 68 | 69 | return ReactDOM.createPortal( 70 |
    78 |
    79 |
    {props.tooltipBody}
    80 |
    , 81 | hovercardNode, 82 | ); 83 | } 84 | 85 | function getClass(attributes: {[key: string]: string} | undefined): string { 86 | switch (attributes?.['data-popper-placement']) { 87 | case 'bottom': 88 | return 'bs-popover-bottom'; 89 | case 'left': 90 | return 'bs-popover-left'; 91 | case 'right': 92 | return 'bs-popover-right'; 93 | case 'top': 94 | return 'bs-popover-top'; 95 | default: 96 | return ''; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/WorkerLocation.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {WorkerConfig} from '../utils/workers'; 4 | import CountryFlag from './CountryFlag'; 5 | import WithHovercard from './WithHovercard'; 6 | import {trackEvent} from '../analytics'; 7 | 8 | type Props = Readonly<{ 9 | flagSize?: number; 10 | worker: WorkerConfig; 11 | }>; 12 | 13 | export default function WorkerLocation({flagSize, worker}: Props) { 14 | const url = 15 | worker.providerUrl + 16 | (worker.providerUrl.includes('?') ? '&' : '?') + 17 | 'utm_source=dnstools&utm_medium=worker-location-link&utm_campaign=dnstools-worker-location-link'; 18 | return ( 19 | 23 | {worker.city}, {worker.region}, {worker.country} 24 |
    25 | Provider: {/* eslint-disable-next-line react/jsx-no-target-blank */} 26 | { 32 | trackEvent('Hosting Provider Link (Location)', { 33 | props: { 34 | provider_name: worker.providerName, 35 | }, 36 | }); 37 | }}> 38 | {worker.providerName} 39 | {' '} 40 | (AS{worker.networkAsn})
    41 | Data Center: {worker.dataCenterName} 42 | 43 | }> 44 | 45 | {worker.locationDisplay} 46 |
    47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/form/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useRef} from 'react'; 2 | 3 | type Props = { 4 | id: string; 5 | isChecked: boolean; 6 | isIndeterminate?: boolean; 7 | label: React.ReactNode; 8 | onChange: (value: boolean) => void; 9 | }; 10 | 11 | export default function Checkbox(props: Props) { 12 | const checkboxRef = useRef(null); 13 | 14 | useEffect(() => { 15 | if (checkboxRef.current) { 16 | checkboxRef.current.indeterminate = !!props.isIndeterminate; 17 | } 18 | }, [props.isIndeterminate]); 19 | 20 | return ( 21 |
    22 | props.onChange(evt.target.checked)} 29 | /> 30 | 33 |
    34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/DnsTools.Web/ClientApp/src/components/form/CheckboxList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Checkbox from './Checkbox'; 4 | import SelectAllCheckbox from './SelectAllCheckbox'; 5 | 6 | export type Option = { 7 | id: string; 8 | label: React.ReactNode; 9 | }; 10 | 11 | type Props = { 12 | id: string; 13 | options: ReadonlyArray