Compare commits
	
		
			10 Commits
		
	
	
		
			d7f0ba7486
			...
			a0e661eca3
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| a0e661eca3 | |||
| db294c171d | |||
| 2c75603046 | |||
| a7b1ba584f | |||
| 89237c2b84 | |||
| 5ade1c0593 | |||
| ec6c0cc45b | |||
| 9f9e0524f5 | |||
| 13f126ccbc | |||
| 80ac8148e7 | 
							
								
								
									
										6
									
								
								flake.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										6
									
								
								flake.lock
									
									
									
										generated
									
									
									
								
							| @@ -2,11 +2,11 @@ | ||||
|   "nodes": { | ||||
|     "nixpkgs": { | ||||
|       "locked": { | ||||
|         "lastModified": 1741010256, | ||||
|         "narHash": "sha256-WZNlK/KX7Sni0RyqLSqLPbK8k08Kq7H7RijPJbq9KHM=", | ||||
|         "lastModified": 1741173522, | ||||
|         "narHash": "sha256-k7VSqvv0r1r53nUI/IfPHCppkUAddeXn843YlAC5DR0=", | ||||
|         "owner": "NixOS", | ||||
|         "repo": "nixpkgs", | ||||
|         "rev": "ba487dbc9d04e0634c64e3b1f0d25839a0a68246", | ||||
|         "rev": "d69ab0d71b22fa1ce3dbeff666e6deb4917db049", | ||||
|         "type": "github" | ||||
|       }, | ||||
|       "original": { | ||||
|   | ||||
							
								
								
									
										26
									
								
								flake.nix
									
									
									
									
									
								
							
							
						
						
									
										26
									
								
								flake.nix
									
									
									
									
									
								
							| @@ -29,7 +29,8 @@ | ||||
|       packages = forAllSystems ( | ||||
|         { pkgs }: | ||||
|         { | ||||
|           default = | ||||
|           default = self.packages.${pkgs.system}.apiary; | ||||
|           apiary = | ||||
|             let | ||||
|               src = pkgs.lib.sourceFilesBySuffices ./. [ | ||||
|                 "go.mod" | ||||
| @@ -43,6 +44,7 @@ | ||||
|                   ) | ||||
|                 )) 2 | ||||
|               ); | ||||
|               rev = self.rev or ""; | ||||
|               geoDb = pkgs.fetchFromGitHub { | ||||
|                 owner = "geoacumen"; | ||||
|                 repo = "geoacumen-country"; | ||||
| @@ -56,7 +58,7 @@ | ||||
|  | ||||
|                 src = ./frontend; | ||||
|  | ||||
|                 npmDepsHash = "sha256-hFoGepzNZjDFaHhb6tO3FUmW6AdEyOTXIQ6rDcUokLo="; | ||||
|                 npmDepsHash = "sha256-7gjPEiQAMOpteLwHT4AIY9QLpDozpXb7xy0bDiOpDf4="; | ||||
|  | ||||
|                 installPhase = '' | ||||
|                   mkdir -p $out | ||||
| @@ -74,10 +76,30 @@ | ||||
|                 cp -r ${frontend}/* web/frontend/dist | ||||
|               ''; | ||||
|               vendorHash = "sha256-griWN9fQ0X2ClPDOzVXV80MpdcEFZfe/WaYm7L7fAc8="; | ||||
|               ldflags = [ "-X git.t-juice.club/torjus/apiary.Build=${rev}" ]; | ||||
|               tags = [ | ||||
|                 "embed" | ||||
|               ]; | ||||
|             }; | ||||
|  | ||||
|           tarball = | ||||
|             let | ||||
|               version = self.packages.${pkgs.system}.apiary.version; | ||||
|             in | ||||
|             pkgs.stdenv.mkDerivation { | ||||
|               name = "apiary-tarballs-${version}"; | ||||
|               phases = [ "installPhase" ]; | ||||
|               installPhase = '' | ||||
|                 mkdir -p $out | ||||
|                 mkdir apiary | ||||
|                 cp ${self.packages.${pkgs.system}.apiary}/bin/apiary apiary/apiary-${pkgs.system}-${version} | ||||
|                 tar cvzf $out/apiary-${pkgs.system}-${version}.tar.gz apiary | ||||
|                 pushd apiary | ||||
|                 sha256sum apiary-${pkgs.system}-${version} > apiary-${pkgs.system}-${version}.sha256sum | ||||
|                 popd | ||||
|                 cp apiary/apiary-${pkgs.system}-${version}.sha256sum $out | ||||
|               ''; | ||||
|             }; | ||||
|         } | ||||
|       ); | ||||
|       devShells = forAllSystems ( | ||||
|   | ||||
							
								
								
									
										7
									
								
								frontend/jest.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								frontend/jest.config.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| /** @type {import('ts-jest').JestConfigWithTsJest} **/ | ||||
| module.exports = { | ||||
|   testEnvironment: "jsdom", | ||||
|   transform: { | ||||
|     "^.+\.tsx?$": ["ts-jest",{}], | ||||
|   }, | ||||
| }; | ||||
							
								
								
									
										5003
									
								
								frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5003
									
								
								frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -2,24 +2,36 @@ | ||||
|   "name": "apiary-frontend", | ||||
|   "scripts": { | ||||
|     "start": "parcel src/index.html", | ||||
|     "build": "parcel build src/index.html" | ||||
|     "build": "parcel build src/index.html", | ||||
|     "test": "jest" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@ngneat/falso": "^7.3.0", | ||||
|     "@parcel/transformer-css": "^2.13.3", | ||||
|     "@testing-library/dom": "^10.4.0", | ||||
|     "@testing-library/jest-dom": "^6.6.3", | ||||
|     "@testing-library/react": "^16.2.0", | ||||
|     "@testing-library/user-event": "^14.6.1", | ||||
|     "@types/humanize-duration": "^3.27.4", | ||||
|     "@types/jest": "^29.5.14", | ||||
|     "@types/node": "^22.13.9", | ||||
|     "@types/react": "^19.0.10", | ||||
|     "@types/react-dom": "^19.0.4", | ||||
|     "jest": "^29.7.0", | ||||
|     "jest-environment-jsdom": "^29.7.0", | ||||
|     "parcel": "^2.13.3", | ||||
|     "process": "^0.11.10", | ||||
|     "svgo": "^3.3.2", | ||||
|     "ts-jest": "^29.2.6", | ||||
|     "ts-loader": "^9.5.2", | ||||
|     "typescript": "^5.8.2" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@types/node": "^22.13.9", | ||||
|     "@types/react": "^19.0.10", | ||||
|     "@types/react-dom": "^19.0.4", | ||||
|     "chart.js": "^4.4.8", | ||||
|     "humanize-duration": "^3.32.1", | ||||
|     "react": "^19.0.0", | ||||
|     "react-chartjs-2": "^5.3.0", | ||||
|     "react-dom": "^19.0.0", | ||||
|     "react-router": "^7.2.0", | ||||
|     "swr": "^2.3.2" | ||||
|     "react-router": "^7.2.0" | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										77
									
								
								frontend/src/assets/apiary.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								frontend/src/assets/apiary.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,77 @@ | ||||
| <?xml version="1.0" encoding="iso-8859-1"?> | ||||
| <!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> | ||||
| <svg height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"  | ||||
| 	 viewBox="0 0 511.573 511.573" xml:space="preserve"> | ||||
| <g transform="translate(1 1)"> | ||||
| 	<polygon style="fill:#FFE100;" points="254.787,7.107 84.12,143.64 425.453,143.64 	"/> | ||||
| 	<polygon style="fill:#FFFFFF;" points="84.12,143.64 254.787,7.107 41.453,143.64 	"/> | ||||
| 	<polygon style="fill:#FFA800;" points="254.787,7.107 425.453,143.64 468.12,143.64 	"/> | ||||
| 	<g> | ||||
| 		<polygon style="fill:#FFE100;" points="41.453,502.04 92.653,502.04 92.653,450.84 41.453,450.84 		"/> | ||||
| 		<polygon style="fill:#FFE100;" points="416.92,502.04 468.12,502.04 468.12,450.84 416.92,450.84 		"/> | ||||
| 		<polygon style="fill:#FFE100;" points="41.453,450.84 468.12,450.84 468.12,143.64 41.453,143.64 		"/> | ||||
| 	</g> | ||||
| 	<polygon style="fill:#FFA800;" points="442.52,126.573 442.52,502.04 468.12,502.04 468.12,146.2 	"/> | ||||
| 	<polygon style="fill:#FFFFFF;" points="41.453,143.64 41.453,502.04 67.053,502.04 67.053,126.573 	"/> | ||||
| 	<path style="fill:#63D3FD;" d="M314.52,450.84H195.053V341.613c0-15.36,12.8-27.307,27.307-27.307h64 | ||||
| 		c15.36,0,27.307,12.8,27.307,27.307V450.84H314.52z"/> | ||||
| 	<path style="fill:#3DB9F9;" d="M287.213,314.307h-25.6c15.36,0,27.307,12.8,27.307,27.307V450.84h25.6V341.613 | ||||
| 		C314.52,327.107,302.573,314.307,287.213,314.307"/> | ||||
| 	<path d="M468.12,459.373H41.453c-5.12,0-8.533-3.413-8.533-8.533v-307.2c0-2.56,1.707-5.973,4.267-6.827L250.52,0.28 | ||||
| 		c2.56-1.707,5.973-1.707,9.387,0L473.24,136.813c2.56,1.707,4.267,4.267,4.267,6.827v307.2 | ||||
| 		C476.653,455.96,473.24,459.373,468.12,459.373z M49.987,442.307h409.6v-294.4l-204.8-130.56l-204.8,130.56V442.307z"/> | ||||
| 	<path d="M92.653,510.573h-51.2c-5.12,0-8.533-3.413-8.533-8.533v-51.2c0-5.12,3.413-8.533,8.533-8.533h51.2 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533v51.2C101.187,507.16,97.773,510.573,92.653,510.573z M49.987,493.507H84.12v-34.133H49.987 | ||||
| 		V493.507z"/> | ||||
| 	<path d="M468.12,510.573h-51.2c-5.12,0-8.533-3.413-8.533-8.533v-51.2c0-5.12,3.413-8.533,8.533-8.533h51.2 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533v51.2C476.653,507.16,473.24,510.573,468.12,510.573z M425.453,493.507h34.133v-34.133h-34.133 | ||||
| 		V493.507z"/> | ||||
| 	<path d="M7.32,177.773c-2.56,0-5.12-0.853-6.827-3.413c-2.56-3.413-1.707-9.387,1.707-11.947l34.133-25.6 | ||||
| 		c3.413-2.56,9.387-1.707,11.947,1.707c2.56,3.413,1.707,9.387-1.707,11.947l-34.133,25.6C10.733,176.92,9.027,177.773,7.32,177.773 | ||||
| 		z"/> | ||||
| 	<path d="M502.253,177.773c-1.707,0-3.413-0.853-5.12-1.707L463,150.467c-3.413-2.56-4.267-8.533-1.707-11.947 | ||||
| 		c2.56-3.413,8.533-4.267,11.947-1.707l34.133,25.6c3.413,2.56,4.267,8.533,1.707,11.947 | ||||
| 		C507.373,176.92,504.813,177.773,502.253,177.773z"/> | ||||
| 	<path d="M468.12,254.573H41.453c-5.12,0-8.533-3.413-8.533-8.533v-102.4c0-5.12,3.413-8.533,8.533-8.533H468.12 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533v102.4C476.653,251.16,473.24,254.573,468.12,254.573z M49.987,237.507h409.6v-85.333h-409.6 | ||||
| 		V237.507z"/> | ||||
| 	<path d="M314.52,459.373H195.053c-5.12,0-8.533-3.413-8.533-8.533V341.613c0-19.627,16.213-35.84,35.84-35.84h64 | ||||
| 		c19.627,0,35.84,16.213,35.84,35.84V450.84C323.053,455.96,319.64,459.373,314.52,459.373z M203.587,442.307h102.4V341.613 | ||||
| 		c0-10.24-8.533-18.773-18.773-18.773h-64c-11.093,0-19.627,8.533-19.627,18.773V442.307z"/> | ||||
| 	<path d="M195.053,459.373h-153.6c-5.12,0-8.533-3.413-8.533-8.533v-102.4c0-5.12,3.413-8.533,8.533-8.533h153.6 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533v102.4C203.587,455.96,200.173,459.373,195.053,459.373z M49.987,442.307H186.52v-85.333H49.987 | ||||
| 		V442.307z"/> | ||||
| 	<path d="M468.12,459.373h-153.6c-5.12,0-8.533-3.413-8.533-8.533v-102.4c0-5.12,3.413-8.533,8.533-8.533h153.6 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533v102.4C476.653,455.96,473.24,459.373,468.12,459.373z M323.053,442.307h136.533v-85.333H323.053 | ||||
| 		V442.307z"/> | ||||
| 	<path d="M468.12,356.973h-153.6c-5.12,0-8.533-3.413-8.533-8.533v-6.827c0-10.24-8.533-18.773-18.773-18.773h-64 | ||||
| 		c-11.093,0-19.627,8.533-19.627,18.773v6.827c0,5.12-3.413,8.533-8.533,8.533h-153.6c-5.12,0-8.533-3.413-8.533-8.533v-102.4 | ||||
| 		c0-5.12,3.413-8.533,8.533-8.533H468.12c5.12,0,8.533,3.413,8.533,8.533v102.4C476.653,353.56,473.24,356.973,468.12,356.973z | ||||
| 		 M323.053,339.907h136.533v-85.333h-409.6v85.333H186.52c0.853-18.773,17.067-34.133,35.84-34.133h64 | ||||
| 		C305.987,305.773,322.2,321.133,323.053,339.907z"/> | ||||
| 	<path d="M468.12,288.707h-59.733c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h59.733 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533S473.24,288.707,468.12,288.707z"/> | ||||
| 	<path d="M468.12,322.84h-34.133c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h34.133 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533C476.653,319.427,473.24,322.84,468.12,322.84z"/> | ||||
| 	<path d="M101.187,288.707H41.453c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h59.733 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533S106.307,288.707,101.187,288.707z"/> | ||||
| 	<path d="M75.587,322.84H41.453c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h34.133 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533C84.12,319.427,80.707,322.84,75.587,322.84z"/> | ||||
| 	<path d="M468.12,186.307h-59.733c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h59.733 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533C476.653,182.893,473.24,186.307,468.12,186.307z"/> | ||||
| 	<path d="M468.12,220.44h-34.133c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h34.133 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533C476.653,217.027,473.24,220.44,468.12,220.44z"/> | ||||
| 	<path d="M101.187,186.307H41.453c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h59.733 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533C109.72,182.893,106.307,186.307,101.187,186.307z"/> | ||||
| 	<path d="M75.587,220.44H41.453c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h34.133 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533C84.12,217.027,80.707,220.44,75.587,220.44z"/> | ||||
| 	<path d="M468.12,391.107h-59.733c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h59.733 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533C476.653,387.693,473.24,391.107,468.12,391.107z"/> | ||||
| 	<path d="M468.12,425.24h-34.133c-5.12,0-8.533-3.413-8.533-8.533s3.413-8.533,8.533-8.533h34.133c5.12,0,8.533,3.413,8.533,8.533 | ||||
| 		S473.24,425.24,468.12,425.24z"/> | ||||
| 	<path d="M101.187,391.107H41.453c-5.12,0-8.533-3.413-8.533-8.533c0-5.12,3.413-8.533,8.533-8.533h59.733 | ||||
| 		c5.12,0,8.533,3.413,8.533,8.533C109.72,387.693,106.307,391.107,101.187,391.107z"/> | ||||
| 	<path d="M75.587,425.24H41.453c-5.12,0-8.533-3.413-8.533-8.533s3.413-8.533,8.533-8.533h34.133c5.12,0,8.533,3.413,8.533,8.533 | ||||
| 		S80.707,425.24,75.587,425.24z"/> | ||||
| </g> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 6.4 KiB | 
| @@ -1,10 +1,35 @@ | ||||
| :root { | ||||
|     --main-color-bg: white; | ||||
|     --main-color: black; | ||||
|     /* Menu */ | ||||
|     --menu-color-bg: #212529; | ||||
|     --menu-color-text: hsla(0,0%,100%,.5); | ||||
|     --menu-color-text-hover: white; | ||||
|     --menu-color-title-text: white; | ||||
|     --table-color-bg: #009879; | ||||
| 
 | ||||
|     /* Table */ | ||||
|     --table-color-header-bg: var(--menu-color-bg); | ||||
|     --table-color-header-text: #dddddd; | ||||
|     --table-row-odd: #dddddd; | ||||
|     --table-row-even: #f3f3f3; | ||||
| } | ||||
| 
 | ||||
| .dark_mode { | ||||
|     --main-color-bg: #15141a; | ||||
|     --main-color: white; | ||||
|     /* Menu */ | ||||
|     --menu-color-bg: #35393d; | ||||
|     --menu-color-text: hsla(0,0%,100%,.5); | ||||
|     --menu-color-text-hover: white; | ||||
|     --menu-color-title-text: white; | ||||
| 
 | ||||
|     /* Table */ | ||||
|     --table-color-header-bg: var(--menu-color-bg); | ||||
|     --table-color-header-text: var(--menu-color-text); | ||||
|     --table-row-odd: #302f36; | ||||
|     --table-row-even: #232229; | ||||
| } | ||||
| 
 | ||||
| #root { | ||||
|     padding: 0; | ||||
|     margin: 0; | ||||
| @@ -12,6 +37,8 @@ | ||||
|     width: 100%; | ||||
| } | ||||
| body { | ||||
|     background-color: var(--main-color-bg); | ||||
|     color: var(--main-color); | ||||
|     margin: 0; | ||||
|     margin-top: 100px; | ||||
| } | ||||
| @@ -21,13 +48,33 @@ body { | ||||
| *::after { | ||||
|     padding: 0; | ||||
|     margin: 0; | ||||
|     border-box: box-sizing; | ||||
| } | ||||
| 
 | ||||
| a { | ||||
|     text-decoration: none; | ||||
| } | ||||
| 
 | ||||
| canvas { | ||||
|     width: 100%; | ||||
|     display: block; | ||||
|     padding-left: 0; | ||||
|     padding-right: 0; | ||||
|     margin-left: auto; | ||||
|     margin-right: auto; | ||||
| } | ||||
| 
 | ||||
| form { | ||||
|     width: 100%; | ||||
| } | ||||
| input[type=text] { | ||||
|     width: 100%; | ||||
|     height: 30px; | ||||
|     box-sizing: border-box; | ||||
|     border: 2px solid var(--menu-color-bg); | ||||
|     border-radius: 5px; | ||||
|     padding-left: 5px; | ||||
| } | ||||
| 
 | ||||
| .navbar { | ||||
|     background-color: var(--menu-color-bg); | ||||
|     display: flex; | ||||
| @@ -59,12 +106,10 @@ a { | ||||
| .navbar h2 { | ||||
|     line-height: 40px; | ||||
|     align-items: center; | ||||
|     padding-left: 10px; | ||||
|     padding-right: 10px; | ||||
| } | ||||
| 
 | ||||
| .navbar a { | ||||
|     color: var(--menu-color-text); | ||||
|     height: 1; | ||||
| } | ||||
| 
 | ||||
| @@ -90,75 +135,76 @@ a { | ||||
|     font-size: 20px; | ||||
|     font-weight: 200; | ||||
| } | ||||
| .totals-key {  | ||||
| .totals_key {  | ||||
|     grid-area: 1 / 1 / 2 / 2;  | ||||
|     text-align: right; | ||||
| } | ||||
| .totals-value {  | ||||
| .totals_value {  | ||||
|     grid-area: 1 / 2 / 2 / 3; | ||||
|     padding-left: 10px; | ||||
|     text-align: left; | ||||
| } | ||||
| #menu-title { | ||||
|     color: white; | ||||
| #menu_title { | ||||
|     color: var(--menu-color-text-hover); | ||||
|     font-family: "Secular One", sans-serif; | ||||
|     font-size: 30px; | ||||
|     font-weight: 300; | ||||
| } | ||||
| .menu-link { | ||||
| #menu_logo { | ||||
|     padding: 5px; | ||||
|     content: url("../assets/apiary.svg"); | ||||
|     width: 30px; | ||||
|     height: 30px; | ||||
| } | ||||
| .menu_link { | ||||
|     color: var(--menu-color-text); | ||||
| } | ||||
| .menu-link:hover { | ||||
| .menu_link:hover { | ||||
|     color: var(--menu-color-text-hover); | ||||
| } | ||||
| .menu-link-active { | ||||
| .menu_link_active { | ||||
|     color: var(--menu-color-text-hover); | ||||
| } | ||||
| .sub-menu-link { | ||||
|     color: var(--menu-color-text); | ||||
| } | ||||
| .sub-menu-link:hover { | ||||
|     color: var(--menu-color-text-hover); | ||||
| } | ||||
| .sub-menu-link-active { | ||||
|     color: var(--menu-color-text-hover); | ||||
| } | ||||
| .stats-pie { | ||||
|     height: 50vh; | ||||
|     width: 50vw; | ||||
| .stats_pie { | ||||
|     max-height: 70vh; | ||||
|     text-align: left; | ||||
|     color: white; | ||||
| } | ||||
| 
 | ||||
| .live-table { | ||||
| .live_table { | ||||
|     border-collapse: collapse; | ||||
|     margin: 25px 0; | ||||
|     font-size: 0.9em; | ||||
|     font-family: sans-serif; | ||||
|     min-width: 400px; | ||||
|     width: 100%; | ||||
|     box-shadow: 0 0 20px rgba(0, 0, 0, 0.15); | ||||
| } | ||||
| .live-table thead tr { | ||||
|     background-color: var(--table-color-bg); | ||||
|     color: #ffffff; | ||||
| .live_table thead tr { | ||||
|     background-color: var(--table-color-header-bg); | ||||
|     color: var(--table-color-header-text); | ||||
|     text-align: left; | ||||
| } | ||||
| .live-table th, | ||||
| .live-table td { | ||||
| .live_table th, | ||||
| .live_table td { | ||||
|     padding: 12px 15px; | ||||
| } | ||||
| .live-table tbody tr { | ||||
|     border-bottom: 1px solid #dddddd; | ||||
| .live_table tbody tr { | ||||
|     border-bottom: 1px solid var(--table-row-odd); | ||||
| } | ||||
| .live-table tbody tr:nth-of-type(even) { | ||||
|     background-color: #f3f3f3; | ||||
| .live_table tbody tr:nth-of-type(even) { | ||||
|     background-color: var(--table-row-even); | ||||
| } | ||||
| .live-table tbody tr:last-of-type { | ||||
| .live_table tbody tr:last-of-type { | ||||
|     border-bottom: 2px solid var(--table-color-bg); | ||||
| } | ||||
| .live-table tbody tr.active-row { | ||||
| .live_table tbody tr.active-row { | ||||
|     font-weight: bold; | ||||
|     color: #009879; | ||||
|     color: var(--table-color-bg); | ||||
| } | ||||
| 
 | ||||
| .content { | ||||
|     margin-left: 10vw; | ||||
|     width: 50vw; | ||||
|     text-align: center; | ||||
| } | ||||
| @@ -3,7 +3,6 @@ | ||||
|   <head> | ||||
|     <meta charset="utf-8"> | ||||
|     <title></title> | ||||
|     <link rel="stylesheet" href="./css/style.css" /> | ||||
|     <link rel="stylesheet" href="./css/fonts.css" /> | ||||
|     <script type="module" src="./js/app.tsx"></script> | ||||
|   </head> | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import { randUserName, randPastDate, randIp, randPassword, randUuid, randNumber } from '@ngneat/falso'; | ||||
| import { randUserName, randRecentDate, randIp, randPassword, randUuid, randNumber } from '@ngneat/falso'; | ||||
| import { r } from 'react-router/dist/development/fog-of-war-Cm1iXIp7'; | ||||
|  | ||||
| export interface LoginAttempt { | ||||
|     readonly date: string; | ||||
| @@ -23,16 +24,18 @@ export interface StatsResult { | ||||
|     count: number; | ||||
| } | ||||
|  | ||||
| export type StatsType = 'password' | 'username' | 'ip' | 'country' | 'attempts'; | ||||
|  | ||||
| export interface ApiaryAPI { | ||||
|     live(fn: (a: LoginAttempt) => void): void; | ||||
|     stats(statsType: string, limit: number): Promise<StatsResult[]>; | ||||
|     stats(statsType: StatsType, limit: number): Promise<StatsResult[]>; | ||||
|     query(queryType: string, query: string): Promise<LoginAttempt[]>; | ||||
|     totals(): Promise<TotalStats>; | ||||
| } | ||||
|  | ||||
| function fakeLoginAttempt(): LoginAttempt { | ||||
|     return { | ||||
|         date: randPastDate().toISOString(), | ||||
|         date: randRecentDate({ days: 2 }).toISOString(), | ||||
|         remoteIP: randIp().toString(), | ||||
|         username: randUserName().toString(), | ||||
|         password: randPassword().toString(), | ||||
| @@ -51,11 +54,23 @@ export class DummyApiaryAPIClient implements ApiaryAPI { | ||||
|  | ||||
|         return () => { clearInterval(interval) } | ||||
|     } | ||||
|     async stats(_type: string, limit: number): Promise<StatsResult[]> { | ||||
|     async stats(_type: StatsType, limit: number): Promise<StatsResult[]> { | ||||
|         const stats = Array.from({ length: limit }, () => { | ||||
|             switch (_type) { | ||||
|                 case 'password': | ||||
|                     return { name: randPassword().toString(), count: randNumber().valueOf() } | ||||
|                 case 'username': | ||||
|                     return { name: randUserName().toString(), count: randNumber().valueOf() } | ||||
|                 case 'ip': | ||||
|                     return { name: randIp().toString(), count: randNumber().valueOf() } | ||||
|                 case 'country': | ||||
|                     return { name: 'NO', count: randNumber().valueOf() } | ||||
|             } | ||||
|             return { name: randUserName().toString(), count: randNumber().valueOf() } | ||||
|         }); | ||||
|         return Promise.resolve(stats); | ||||
|  | ||||
|         const sorted = stats.sort((a, b) => b.count - a.count) | ||||
|         return Promise.resolve(sorted); | ||||
|     } | ||||
|     async query(_type: string, _query: string): Promise<LoginAttempt[]> { | ||||
|         const attempts = Array.from({ length: 10 }, () => { | ||||
| @@ -83,7 +98,7 @@ export class ApiaryAPIClient implements ApiaryAPI { | ||||
|             es.close(); | ||||
|         } | ||||
|     } | ||||
|     async stats(statsType: string, limit: number): Promise<StatsResult[]> { | ||||
|     async stats(statsType: StatsType, limit: number): Promise<StatsResult[]> { | ||||
|         const resp = await fetch(`/api/stats?type=${statsType}&limit=${limit}`) | ||||
|         if (!resp.ok) { | ||||
|             throw new Error('Failed to fetch query') | ||||
| @@ -92,7 +107,7 @@ export class ApiaryAPIClient implements ApiaryAPI { | ||||
|         if (!data) { | ||||
|             return [] | ||||
|         } | ||||
|         return data | ||||
|         return data.sort((a, b) => b.count - a.count) | ||||
|     } | ||||
|     async query(queryType: string, query: string): Promise<LoginAttempt[]> { | ||||
|         const resp = await fetch(`/api/query?type=${queryType}&query=${query}`) | ||||
|   | ||||
| @@ -1,12 +1,15 @@ | ||||
| import React from "react"; | ||||
| import { createRoot } from "react-dom/client"; | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { ApiaryAPI, LoginAttempt, DummyApiaryAPIClient, ApiaryAPIClient, TotalStats, StatsResult } from "./api"; | ||||
| import { ApiaryAPI, LoginAttempt, DummyApiaryAPIClient, ApiaryAPIClient, TotalStats, StatsResult, StatsType } from "./api"; | ||||
| import { BrowserRouter, NavLink, Routes, Route } from "react-router"; | ||||
| import { Chart as ChartJS, Tooltip, ArcElement, Legend } from "chart.js"; | ||||
| import { Chart as ChartJS, Tooltip, ArcElement, Legend, ChartOptions } from "chart.js"; | ||||
| import { Pie } from "react-chartjs-2"; | ||||
| import humanizeDuration from "humanize-duration"; | ||||
| import * as classes from "../css/style.module.css"; | ||||
|  | ||||
| ChartJS.register(Tooltip, ArcElement, Legend); | ||||
| console.log(classes); | ||||
|  | ||||
| interface AppProps { | ||||
|     api: ApiaryAPI | ||||
| @@ -32,61 +35,91 @@ let chartColors = [ | ||||
|     "#d35400", | ||||
|     "#c0392b", | ||||
|     "#bdc3c7", | ||||
|     "#7f8c8d" | ||||
|     "#7f8c8d", | ||||
| ] | ||||
|  | ||||
| export function App({ api }: AppProps) { | ||||
|     const [mode, setMode] = useState("light"); | ||||
|     const headerProps: HeaderMenuProps = { | ||||
|         title: "apiary.home.2rjus.net", | ||||
|         items: [ | ||||
|             { | ||||
|                 name: "Totals", | ||||
|                 path: "/", | ||||
|             }, | ||||
|             { | ||||
|                 name: "Passwords", | ||||
|                 path: "/stats/password", | ||||
|             }, | ||||
|             { | ||||
|                 name: "Usernames", | ||||
|                 path: "/stats/username", | ||||
|             }, | ||||
|             { | ||||
|                 name: "IPs", | ||||
|                 path: "/stats/ip", | ||||
|             }, | ||||
|             { | ||||
|                 name: "Live", | ||||
|                 path: "/live", | ||||
|             }, | ||||
|             { | ||||
|                 name: "Query", | ||||
|                 path: "/query", | ||||
|             } | ||||
|         ] | ||||
|     } | ||||
|     const onSelectMode = (mode: string) => { | ||||
|         setMode(mode); | ||||
|  | ||||
|         if (mode === "dark") { | ||||
|             document.body.classList.add(classes.dark_mode); | ||||
|         } else { | ||||
|             document.body.classList.remove(classes.dark_mode); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     useEffect(() => { | ||||
|         window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => onSelectMode(e.matches ? "dark" : "light")); | ||||
|         onSelectMode(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') | ||||
|         return () => { | ||||
|             window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', (e) => onSelectMode(e.matches ? "dark" : "light")); | ||||
|         } | ||||
|     }, []) | ||||
|     return ( | ||||
|         <> | ||||
|             <BrowserRouter> | ||||
|                 <Header /> | ||||
|                 <div className="content"> | ||||
|                     <Routes> | ||||
|                         <Route path="/" element={<Home api={api} />} /> | ||||
|                         <Route path="/stats" element={<Stats api={api} />} /> | ||||
|                         <Route path="/live" element={<Live api={api} />} /> | ||||
|                         <Route path="/query" element={<Query api={api} />} /> | ||||
|                     </Routes> | ||||
|                 <div id="app"> | ||||
|                     <HeaderMenu title={headerProps.title} items={headerProps.items} /> | ||||
|                     <div className={classes.content}> | ||||
|                         <Routes> | ||||
|                             <Route path="/" element={<Home api={api} />} /> | ||||
|                             <Route path="/stats/password" element={<Stats api={api} type="password" />} /> | ||||
|                             <Route path="/stats/username" element={<Stats api={api} type="username" />} /> | ||||
|                             <Route path="/stats/ip" element={<Stats api={api} type="ip" />} /> | ||||
|                             <Route path="/live" element={<Live api={api} />} /> | ||||
|                             <Route path="/query" element={<Query api={api} />} /> | ||||
|                         </Routes> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </BrowserRouter> | ||||
|             </BrowserRouter > | ||||
|         </> | ||||
|     ); | ||||
| } | ||||
|  | ||||
| export function Stats({ api }: AppProps) { | ||||
|     const [stats, setStats] = useState<StatsResult[]>([]); | ||||
|     const [statsType, setStatsType] = useState<string>("password") | ||||
| export interface StatsProps { | ||||
|     api: ApiaryAPI | ||||
|     type: StatsType | ||||
| } | ||||
|  | ||||
|     const activeMenu = (name: string): boolean => { | ||||
|         if (statsType === name) { | ||||
|             return true | ||||
|         } | ||||
|         return false | ||||
|     } | ||||
|     const subMenuItems: SubMenuProps = { | ||||
|         items: [ | ||||
|             { | ||||
|                 name: "Passwords", | ||||
|                 active: () => { return activeMenu("password") }, | ||||
|                 onClick: () => setStatsType("password") | ||||
|             }, | ||||
|             { | ||||
|                 name: "Usernames", | ||||
|                 active: () => { return activeMenu("username") }, | ||||
|                 onClick: () => setStatsType("username") | ||||
|             }, | ||||
|             { | ||||
|                 name: "IPs", | ||||
|                 active: () => { return activeMenu("ip") }, | ||||
|                 onClick: () => setStatsType("ip") | ||||
|             }, | ||||
|         ] | ||||
|     } | ||||
| export function Stats({ api, type }: StatsProps) { | ||||
|     const [stats, setStats] = useState<StatsResult[] | null>(null) | ||||
|     const [currentType, setCurrentType] = useState(type) | ||||
|  | ||||
|     useEffect(() => { | ||||
|         async function getStats() { | ||||
|             try { | ||||
|                 let newStats = await api.stats(statsType, 10); | ||||
|                 let newStats = await api.stats(type, 10); | ||||
|                 if (JSON.stringify(newStats) !== JSON.stringify(stats)) { | ||||
|                     setStats(newStats) | ||||
|                 } | ||||
| @@ -95,20 +128,27 @@ export function Stats({ api }: AppProps) { | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         getStats() | ||||
|         if (currentType !== type) { | ||||
|             setCurrentType(type) | ||||
|             getStats() | ||||
|         } | ||||
|  | ||||
|         if (stats === null) { | ||||
|             getStats() | ||||
|         } | ||||
|  | ||||
|         const interval = setInterval(() => { | ||||
|             getStats() | ||||
|         }, 5000) | ||||
|         }, 60000) | ||||
|  | ||||
|         return () => { | ||||
|             clearInterval(interval); | ||||
|         } | ||||
|     }, [stats, statsType]) | ||||
|     }, [stats, type]) | ||||
|  | ||||
|     return ( | ||||
|         <> | ||||
|             <SubMenu items={subMenuItems.items} /> | ||||
|             {stats.length > 0 ? <StatsPie data={stats} /> : <p>Loading...</p>} | ||||
|             {(stats != null && stats.length > 0) ? <StatsPie data={stats} /> : <p>Loading...</p>} | ||||
|         </> | ||||
|     ); | ||||
| } | ||||
| @@ -129,9 +169,24 @@ export function StatsPie({ data }: StatsPieProps) { | ||||
|             borderWidth: 1 | ||||
|         }] | ||||
|     }; | ||||
|     console.log(piedata) | ||||
|     const getTextColor = () => { | ||||
|         return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'white' : 'black' | ||||
|     } | ||||
|     const options: ChartOptions<"pie"> = { | ||||
|         plugins: { | ||||
|             legend: { | ||||
|                 display: true, | ||||
|                 align: "start", | ||||
|                 labels: { | ||||
|                     color: getTextColor(), | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|     return ( | ||||
|         <div className="stats-pie"> | ||||
|             <Pie data={piedata} options={{ plugins: { legend: {} } }} /> | ||||
|         <div className={classes.stats_pie}> | ||||
|             <Pie data={piedata} options={options} /> | ||||
|         </div> | ||||
|     ) | ||||
| } | ||||
| @@ -169,14 +224,14 @@ export interface TotalsProps { | ||||
|  | ||||
| export function Totals({ totals }: TotalsProps) { | ||||
|     return ( | ||||
|         <div className="totals"> | ||||
|             <div className="totals-key"> | ||||
|         <div className={classes.totals}> | ||||
|             <div className={classes.totals_key}> | ||||
|                 <h2>Unique passwords</h2> | ||||
|                 <h2>Unique username</h2> | ||||
|                 <h2>Unique IPs</h2> | ||||
|                 <h2>Total attempts</h2> | ||||
|             </div> | ||||
|             <div className="totals-value"> | ||||
|             <div className={classes.totals_value}> | ||||
|                 <h2>{totals.password}</h2> | ||||
|                 <h2>{totals.username}</h2> | ||||
|                 <h2>{totals.ip}</h2> | ||||
| @@ -193,7 +248,7 @@ export function Live({ api }: AppProps) { | ||||
|     useEffect(() => { | ||||
|         const cleanup = api.live((a) => { | ||||
|             setLiveList((list) => { | ||||
|                 return [...list, a]; | ||||
|                 return [a, ...list]; | ||||
|             }); | ||||
|         }); | ||||
|  | ||||
| @@ -212,28 +267,65 @@ export interface LiveListProps { | ||||
|     list: LoginAttempt[] | ||||
| }; | ||||
|  | ||||
| export interface DateTDProps { | ||||
|     date: Date | ||||
|     now: Date | ||||
| } | ||||
|  | ||||
| export function DateTD({ date, now }: DateTDProps) { | ||||
|     const [displayDate, setDisplayDate] = useState(date.toLocaleString()); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         if (now.getTime() - date.getTime() < 14400000) { | ||||
|             const newDate = humanizeDuration(now.getTime() - date.getTime(), { largest: 1, round: true }) + " ago"; | ||||
|             if (newDate !== displayDate) { | ||||
|                 setDisplayDate(newDate); | ||||
|             } | ||||
|         } | ||||
|     }, [displayDate, now]) | ||||
|  | ||||
|     return ( | ||||
|         <td className={classes.live_table_date}>{displayDate}</td> | ||||
|     ) | ||||
| } | ||||
|  | ||||
| export function LiveList({ list }: LiveListProps) { | ||||
|     const [now, setNow] = useState(new Date()) | ||||
|  | ||||
|     let items = list.map((a) => { | ||||
|         const attemptDate = new Date(a.date); | ||||
|         const key = `${a.username}-${a.password}-${a.remoteIP}-${a.date}`; | ||||
|         return ( | ||||
|             <tr key={a.date}> | ||||
|                 <td>{a.date}</td> | ||||
|                 <td>{a.username}</td> | ||||
|                 <td>{a.password}</td> | ||||
|                 <td>{a.remoteIP}</td> | ||||
|                 <td>{a.country}</td> | ||||
|                 <DateTD date={attemptDate} now={now} /> | ||||
|                 <td className={classes.live_table_username}>{a.username}</td> | ||||
|                 <td className={classes.live_table_password}>{a.password}</td> | ||||
|                 <td className={classes.live_table_ip}>{a.remoteIP}</td> | ||||
|                 <td className={classes.live_table_country}>{a.country}</td> | ||||
|             </tr> | ||||
|         ) | ||||
|     }) | ||||
|  | ||||
|     useEffect(() => { | ||||
|         const interval = setInterval(() => { | ||||
|             setNow(new Date()) | ||||
|         }, 1000) | ||||
|  | ||||
|         return () => { | ||||
|             clearInterval(interval) | ||||
|         } | ||||
|     }, [now]) | ||||
|  | ||||
|     return ( | ||||
|         <> | ||||
|             <table className="live-table"> | ||||
|             <table className={classes.live_table}> | ||||
|                 <thead> | ||||
|                     <tr> | ||||
|                         <th>Date</th> | ||||
|                         <th>Username</th> | ||||
|                         <th>Password</th> | ||||
|                         <th>IP</th> | ||||
|                         <th>Country</th> | ||||
|                         <th className={classes.live_table_date}>Date</th> | ||||
|                         <th className={classes.live_table_username}>Username</th> | ||||
|                         <th className={classes.live_table_password}>Password</th> | ||||
|                         <th className={classes.live_table_ip}>IP</th> | ||||
|                         <th className={classes.live_table_country}>Country</th> | ||||
|                     </tr> | ||||
|                 </thead> | ||||
|                 <tbody> | ||||
| @@ -269,7 +361,7 @@ export function Query({ api }: AppProps) { | ||||
|     return ( | ||||
|         <> | ||||
|             <form onSubmit={handleSubmit}> | ||||
|                 <input placeholder="query" name="query" /> | ||||
|                 <input placeholder="Search..." name="query" type="text" /> | ||||
|             </form> | ||||
|             {queryErr ? <ErrorBox message={queryErr.message} /> : null} | ||||
|             <LiveList list={liveList} /> | ||||
| @@ -283,7 +375,7 @@ interface ErrorBoxProps { | ||||
|  | ||||
| export function ErrorBox({ message }: ErrorBoxProps) { | ||||
|     return ( | ||||
|         <div className="error-box"> | ||||
|         <div className={classes.error_box}> | ||||
|             <p>Error: {message}</p> | ||||
|         </div> | ||||
|     ) | ||||
| @@ -291,18 +383,50 @@ export function ErrorBox({ message }: ErrorBoxProps) { | ||||
|  | ||||
| export function Header() { | ||||
|     return ( | ||||
|         <div className="navbar"> | ||||
|         <div className={classes.navbar}> | ||||
|             <h2 id="menu-title">apiary.home.2rjus.net</h2> | ||||
|             <nav className="nav-flex"> | ||||
|             <nav className={classes.nav_flex}> | ||||
|                 <ul> | ||||
|                     <li><NavLink to="/" className={({ isActive }) => isActive ? "menu-link-active" : "menu-link"}>Home</NavLink></li> | ||||
|                     <li><NavLink to="/stats" className={({ isActive }) => isActive ? "menu-link-active" : "menu-link"}>Stats</NavLink></li> | ||||
|                     <li><NavLink to="/live" className={({ isActive }) => isActive ? "menu-link-active" : "menu-link"}>Live</NavLink></li> | ||||
|                     <li><NavLink to="/query" className={({ isActive }) => isActive ? "menu-link-active" : "menu-link"}>Query</NavLink></li> | ||||
|                     <li><NavLink to="/" className={({ isActive }) => isActive ? classes.menu_link_active : classes.menu_link}>Home</NavLink></li> | ||||
|                     <li><NavLink to="/stats/password" className={({ isActive }) => isActive ? classes.menu_link_active : classes.menu_link}>Stats</NavLink></li> | ||||
|                     <li><NavLink to="/live" className={({ isActive }) => isActive ? classes.menu_link_active : classes.menu_link}>Live</NavLink></li> | ||||
|                     <li><NavLink to="/query" className={({ isActive }) => isActive ? classes.menu_link_active : classes.menu_link}>Query</NavLink></li> | ||||
|                 </ul> | ||||
|             </nav> | ||||
|         </div > | ||||
|     ); | ||||
| } | ||||
|  | ||||
| interface HeaderItem { | ||||
|     name: string | ||||
|     path: string | ||||
| } | ||||
|  | ||||
| interface HeaderMenuProps { | ||||
|     title: string | ||||
|     items: Array<HeaderItem> | ||||
| } | ||||
|  | ||||
| export function HeaderMenu({ title, items }: HeaderMenuProps) { | ||||
|     const menuItems = items.map((item) => { | ||||
|         return ( | ||||
|             <li key={item.path}> | ||||
|                 <NavLink to={item.path} className={({ isActive }) => isActive ? classes.menu_link_active : classes.menu_link}>{item.name}</NavLink> | ||||
|             </li> | ||||
|         ) | ||||
|     }) | ||||
|  | ||||
|     return ( | ||||
|         <div className={classes.navbar}> | ||||
|             <img id={classes.menu_logo}></img> | ||||
|             <h2 id={classes.menu_title}>{title}</h2> | ||||
|             <nav className={classes.nav_flex}> | ||||
|                 <ul> | ||||
|                     {menuItems} | ||||
|                 </ul> | ||||
|             </nav> | ||||
|         </div> | ||||
|     ); | ||||
|     ) | ||||
| } | ||||
|  | ||||
| export interface SubMenuProps { | ||||
| @@ -311,13 +435,13 @@ export interface SubMenuProps { | ||||
|  | ||||
| export function SubMenu({ items }: SubMenuProps) { | ||||
|     return ( | ||||
|         <nav className="submenu"> | ||||
|         <nav className={classes.submenu}> | ||||
|             <ul> | ||||
|                 {items.map((item) => { | ||||
|                     return <li> | ||||
|                         <a | ||||
|                             href="#" | ||||
|                             className={item.active() ? "sub-menu-link-active" : "sub-menu-link"} | ||||
|                             className={item.active() ? classes.sub_menu_active : classes.sub_menu_link} | ||||
|                             onClick={item.onClick}>{item.name}</a> | ||||
|                     </li> | ||||
|                 })} | ||||
|   | ||||
							
								
								
									
										17
									
								
								frontend/src/tests/api.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								frontend/src/tests/api.test.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| import { DummyApiaryAPIClient, TotalStats } from "../js/api"; | ||||
|  | ||||
| describe("DummyApiaryAPIClient", () => { | ||||
|     const api = new DummyApiaryAPIClient() | ||||
|     test("totals returns expeced value", async () => { | ||||
|         let totals = await api.totals() | ||||
|         const expected: TotalStats = { | ||||
|             password: 1, | ||||
|             username: 1, | ||||
|             ip: 1, | ||||
|             attempts: 1, | ||||
|             country: 1, | ||||
|         } | ||||
|  | ||||
|         expect(totals).toEqual(expected) | ||||
|     }) | ||||
| }); | ||||
| @@ -8,7 +8,7 @@ | ||||
|     "strict": true, | ||||
|     "sourceMap": true, | ||||
|     "target": "esnext", | ||||
|     "types": ["node"] | ||||
|     "types": ["node", "jest"] | ||||
|   }, | ||||
|   "exclude": ["node_modules"], | ||||
|   "include": ["src/**/*.ts", "src/**/*.tsx"] | ||||
|   | ||||
| @@ -6,7 +6,7 @@ import ( | ||||
| ) | ||||
|  | ||||
| var ( | ||||
| 	Version = "v0.1.32" | ||||
| 	Version = "v0.2.1" | ||||
| 	Build   string | ||||
| ) | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user