├── .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(/([^<]+)/, '@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 |
2 |
3 |
4 |
5 |
6 | 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 |
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 |
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 |
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