Compare commits
	
		
			353 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 20425bf6b1 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 828c288570 | ||
|   | cde2589755 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 5fb2f452e2 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 6aea252d3e | ||
|   | 7337dcb5d4 | ||
|   | deeb5fc100 | ||
|   | 5b15a0b29d | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | e2ad2e0193 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 5b48426fc1 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | a24947ab3b | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 8926b451d0 | ||
|   | afd37d3455 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 4f84beb835 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | d54d894a66 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 7c854d31a7 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 4c2caad4a0 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 9684fd978b | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | a79a3f680f | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 1e18423b04 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 82b208c0bf | ||
|   | 8a3e9504a4 | ||
|   | d8748c6e27 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | af8192fbc0 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 3492587d63 | ||
|   | b8c522021d | ||
|   | 5850c319f2 | ||
|   | afbed43185 | ||
|   | d083430c73 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 79f553ff0c | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | a02551f5ec | ||
|   | 7c486d57fc | ||
|   | 41acf28be9 | ||
|   | 21e5f4fc56 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 2d54cbba9c | ||
|   | 88844c895c | ||
|   | 7aa7f42c52 | ||
|   | 59f4b0da4f | ||
|   | e99e6ebd49 | ||
|   | 3268c32627 | ||
|   | 5cb5cae113 | ||
|   | dbd1050948 | ||
|   | 69c647336e | ||
|   | 1a1dd74142 | ||
|   | 307dcd1929 | ||
|   | 06bde85e03 | ||
|   | 9cbb55d780 | ||
|   | 744bc11a2e | ||
|   | 4fe8964d66 | ||
|   | 3f13ef28a9 | ||
|   | 28c569ce2a | ||
|   | c1dd3c1131 | ||
|   | 492706367b | ||
|   | cbdbe65b7b | ||
|   | de84736ba4 | ||
|   | 0043ed9291 | ||
|   | 89f7d21739 | ||
|   | fb0b11e626 | ||
|   | 70d72060d9 | ||
|   | cc8a7ee8e7 | ||
|   | 58197f2b23 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | d2473e0fcc | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 3a4de053b8 | ||
|   | b97dd31c9d | ||
|   | a632c744bc | ||
|   | 7eeb7d8600 | ||
|   | 8de492d16b | ||
|   | 2b82a0816c | ||
|   | 25daf6b502 | ||
|   | 011cef8124 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | d06eea2c26 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | cdb6738941 | ||
|   | f39b6f50e3 | ||
|   | 4d03a36940 | ||
|   | 60fc2ab22a | ||
|   | 0ad841a2d2 | ||
|   | 58ce210924 | ||
|   | 0214b212ea | ||
|   | ee37d7c30e | ||
|   | 4395bc9dc5 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 4ea945f0b4 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | b221242db3 | ||
|   | ad6793f614 | ||
|   | 72f080f795 | ||
|   | ee4210e1cc | ||
|   | fc68ba6391 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 539829d00d | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 65c0d2a970 | ||
|   | 696341b779 | ||
|   | 658efd0538 | ||
|   | eba4cec7d6 | ||
|   | 79f0e2127a | ||
|   | 163b1c7e28 | ||
|   | db01579f04 | ||
|   | be7c154d6b | ||
|   | b1bc706de2 | ||
|   | 40f5cb1301 | ||
|   | cedfbee983 | ||
|   | c835f51cc4 | ||
|   | 5ab06d5906 | ||
|   | d44316fa9c | ||
|   | 6ef3da9abd | ||
|   | 752495ed6f | ||
|   | 8f895e40bc | ||
|   | cd9ddcf427 | ||
|   | bbc7794006 | ||
|   | 7dc37f130c | ||
|   | 0711bc1c76 | ||
|   | 0aa24386b2 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | ca35b93671 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | a6220e4d38 | ||
|   | 4ed64a7cce | ||
|   | 0f27e11084 | ||
|   | 85eafc9c40 | ||
|   | 332cc384ea | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 72fd31f85b | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | a0ce370e9e | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | e823904865 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 22bbfe1592 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 770e1818f0 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | d6fab75f8f | ||
|   | 17c18c156e | ||
|   | 5eca19840e | ||
|   | b1d7b8ba55 | ||
|   | e2ee430bbd | ||
|   | 0755a71dc2 | ||
|   | 60758db9c8 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 7b96196904 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | efcfa0e375 | ||
|   | 66f9204ae6 | ||
|   | 73c023ce22 | ||
|   | 261517ac3f | ||
|   | 2e0a546aa2 | ||
|   | 72ed7b50ba | ||
|   | 486bcec363 | ||
|   | 3db0ad42fe | ||
|   | c1a75e21ba | ||
|   | 96c5e24501 | ||
|   | c1a16fd76e | ||
|   | 42fab58c9f | ||
|   | 400cef767f | ||
|   | 84ae558467 | ||
|   | 0ebc9c562a | ||
|   | f67664470f | ||
|   | 1f811da273 | ||
|   | fdfc9fceba | ||
|   | 5b5b741b68 | ||
|   | 18c88d0e85 | ||
|   | 1603a19538 | ||
|   | 5cffa287d5 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 93f57b6e90 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 2346f6a0eb | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | f95317ac1d | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 157a612f34 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 42c890ad50 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 48638a18f2 | ||
|   | fae0640bba | ||
|   | 23b37bb912 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 07135fea91 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 1a3c394fe4 | ||
|   | 38ec37ed19 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 738ae98f2f | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 99d1e83882 | ||
|   | d71be7e239 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | c0b9325efb | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 538fe6f158 | ||
|   | 965d1a52b1 | ||
|   | 6be73692ba | ||
|   | f694c168d3 | ||
|   | b7c24dcafa | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 67a1c4a207 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | b188f689ea | ||
|   | 6f354c500c | ||
|   | 8ba5d36801 | ||
|   | 6822a95cc9 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | fcc4647379 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 0305ee9502 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 0e527e8ec0 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 91b2dc36c2 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 3dc7949a86 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 5cf625ef65 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 8d5deff2ed | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 9f6df9a25a | ||
|   | a34733bc88 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 7cf02f40e6 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 9d2e87f0f3 | ||
|   | 034984a784 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | d11fcdfec5 | ||
|   | af08b5cd1b | ||
|   | c666917740 | ||
|   | 059c3361ca | ||
|   | f18fdcec8c | ||
|   | 18d6aa2a34 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | db4643d271 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 9d5b6faf03 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 72e0a1ba2d | ||
|   | 35d4f3c8d3 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 6dfafbf531 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 11e7717519 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | a4539399d2 | ||
|   | d14be81f18 | ||
|   | 44c4366bba | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 20b115f99f | ||
|   | e99ba5b6ae | ||
|   | 12d32ee8f2 | ||
|   | 0f423e8b60 | ||
|   | 37dba2495e | ||
|   | 32b1d62773 | ||
|   | b366a85248 | ||
|   | 57008b9c94 | ||
|   | 8448b4ffa0 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 612e74faff | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | afc225520b | ||
|   | a437e52dac | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 584d027e58 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 5877ca9a0a | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | d176be7654 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 69d1534204 | ||
|   | ee30bb3821 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 4a45204a4b | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 03f673c647 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 7c846e40cf | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 405980862c | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 0f3ab6f0c0 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | e12890510f | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | b0701da4bf | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 048195e0e6 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 81425f1f06 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | af02ea27a0 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 2800dddbf6 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 43cd2c64ab | ||
|   | 5854eace7b | ||
|   | 399c0b0ff4 | ||
|   | f49a778035 | ||
|   | 395549aec9 | ||
|   | 94dd6067ca | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | aee5734e74 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 235db9dae5 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 84d22248a4 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 207468d0f0 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 1409e45f8d | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | e8306d67b6 | ||
|   | d60614ada7 | ||
|   | a287d7b2b4 | ||
|   | 48d34f3f58 | ||
|   | 098924a8f9 | ||
|   | 2d2ff05987 | ||
|   | 32db78d64d | ||
|   | ff4f7126f9 | ||
|   | 590d3bd4f8 | ||
|   | abf0507307 | ||
|   | 89e5bee174 | ||
|   | 7fa8bec6b8 | ||
|   | 6459e84b80 | ||
|   | e16470affd | ||
|   | 8f812b633b | ||
|   | 08eaf8d898 | ||
|   | b9bc7af1d6 | ||
|   | b75974e850 | ||
|   | b1fa9ea672 | ||
|   | 0cac350493 | ||
|   | b8ed2db0f0 | ||
|   | 5d9db17b9c | ||
|   | 038e2dee88 | ||
|   | 5b15fc2972 | ||
|   | 4035e2e262 | ||
|   | 011bc94e8c | ||
|   | 49448790ff | ||
|   | 9259bf65ef | ||
|   | 8819c78487 | ||
|   | ba32d125ac | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 6434c5341a | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | a12a4f2f79 | ||
|   | 0eb379ce3d | ||
|   | 76f83800f7 | ||
|   | f7d82d2ede | ||
|   | 779a0f3ce9 | ||
|   | 4df32a5cef | ||
|   | 074fd8088f | ||
|   | 713ccddcf4 | ||
|   | 7b4c942a1f | ||
|   | 11c357135b | ||
|   | 4055aca97f | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 2fb1d19d93 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 94b07b300f | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | ceb0de9b7f | ||
|   | 537094b5c8 | ||
|   | 856a62ee46 | ||
|   | 3cf20d9139 | ||
|   | 9bcbac3799 | ||
|   | 7d1e8e5e37 | ||
|   | 117e7b3eae | ||
|   | 2470b2b177 | ||
|   | 111ff3a198 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 79a8195ba5 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 52f503ce65 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 1b5d12bb2a | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 69703371f1 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 32b5fde72e | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 552b6727da | ||
|   | 95222a21d8 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | f5ed2d1619 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 0f65d1f59b | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 2f142c9a39 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 56a5cf7ead | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | aee8d6e5a5 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | ee3f8d5046 | ||
|   | dc2c5f35e5 | ||
|   | e1635a36c8 | ||
|   | 8fcc5fc9cc | ||
|   | fdbd8b2992 | ||
|   | 5fe2e06733 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 9d1923661f | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 7807a451a0 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 30cd64c68f | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 6cc4f44199 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | a63f9b608e | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 57b5578104 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 77e6644882 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 5323be8db1 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | c22d58616c | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 9fcd8ce72d | ||
|   | 9906604a38 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 20742d1942 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | ac9bdd0fab | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 17baaecc28 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | fa1a6d449e | ||
|   | 209b746f6a | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 9bfcd52dd5 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 5c4d0523c4 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | c2b3680f1c | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 7f7e95fdad | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 4191a825b1 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 968f7200d6 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 0e2744750a | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 4e575fcfe6 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 8404a70e22 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 0954b7fbd6 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | a5f145bb97 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 153b3de830 | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | feb64444f0 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 6d79efe77f | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 1dde163418 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 3ed1d8be4c | ||
|   | f71d73e90c | ||
|   | 44119c82e9 | ||
|   | 3dc30b656c | ||
| ![kodiakhq[bot]](/assets/img/avatar_default.png)  | 03208ec636 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | b21c5cac76 | ||
|   | 94de10d54c | ||
|   | 69e28e3723 | ||
|   | ce7a892223 | ||
|   | 25c901e013 | 
							
								
								
									
										7
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							| @@ -38,3 +38,10 @@ updates: | ||||
|       - "automerge" | ||||
|     schedule: | ||||
|       interval: daily | ||||
|   - package-ecosystem: "docker" | ||||
|     directory: "/e2e" | ||||
|     labels: | ||||
|       - "dependencies" | ||||
|       - "automerge" | ||||
|     schedule: | ||||
|       interval: "daily" | ||||
|   | ||||
							
								
								
									
										34
									
								
								.github/workflows/deploy.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										34
									
								
								.github/workflows/deploy.yml
									
									
									
									
										vendored
									
									
								
							| @@ -9,11 +9,11 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Checkout code | ||||
|         uses: actions/checkout@v2.4.0 | ||||
|         uses: actions/checkout@v3 | ||||
|       - name: Install Node | ||||
|         uses: actions/setup-node@v2.5.1 | ||||
|         uses: actions/setup-node@v3 | ||||
|       - name: Install pnpm | ||||
|         uses: pnpm/action-setup@v2.0.1 | ||||
|         uses: pnpm/action-setup@v2.2.4 | ||||
|         with: | ||||
|           version: 6.20.1 | ||||
|       - name: Install dependencies | ||||
| @@ -25,11 +25,11 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Install Go | ||||
|         uses: actions/setup-go@v2.1.5 | ||||
|         uses: actions/setup-go@v3 | ||||
|         with: | ||||
|           go-version: 1.17.x | ||||
|           go-version: 1.18.x | ||||
|       - name: Checkout code | ||||
|         uses: actions/checkout@v2.4.0 | ||||
|         uses: actions/checkout@v3 | ||||
|       - name: Run Go Tests with Coverage | ||||
|         run: make test SKIP_ASSET=1 | ||||
|   int-test: | ||||
| @@ -37,11 +37,11 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Checkout code | ||||
|         uses: actions/checkout@v2.4.0 | ||||
|         uses: actions/checkout@v3 | ||||
|       - name: Build images | ||||
|         run: docker-compose -f e2e/docker-compose.yml build | ||||
|         run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose -f e2e/docker-compose.yml build | ||||
|       - name: Run tests | ||||
|         run: docker-compose -f e2e/docker-compose.yml up --build --force-recreate --exit-code-from cypress | ||||
|         run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose -f e2e/docker-compose.yml up --build --force-recreate --exit-code-from cypress | ||||
|   buildx: | ||||
|     needs: [go-test, npm-test, int-test] | ||||
|     name: Release | ||||
| @@ -49,20 +49,18 @@ jobs: | ||||
|     steps: | ||||
|       - name: Docker meta | ||||
|         id: meta | ||||
|         uses: docker/metadata-action@v3 | ||||
|         uses: docker/metadata-action@v4 | ||||
|         with: | ||||
|           images: amir20/dozzle | ||||
|       - name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v1.2.0 | ||||
|       - name: Set up Docker Buildx | ||||
|         uses: docker/setup-buildx-action@v1.6.0 | ||||
|         uses: docker/setup-buildx-action@v2.2.0 | ||||
|       - name: Login to DockerHub | ||||
|         uses: docker/login-action@v1.12.0 | ||||
|         uses: docker/login-action@v2.1.0 | ||||
|         with: | ||||
|           username: ${{ secrets.DOCKER_USERNAME }} | ||||
|           password: ${{ secrets.DOCKER_PASSWORD }} | ||||
|       - name: Build and push | ||||
|         uses: docker/build-push-action@v2.8.0 | ||||
|         uses: docker/build-push-action@v3.2.0 | ||||
|         with: | ||||
|           push: true | ||||
|           platforms: linux/amd64,linux/arm/v7,linux/arm64/v8 | ||||
| @@ -77,13 +75,13 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Checkout code | ||||
|         uses: actions/checkout@v2.4.0 | ||||
|         uses: actions/checkout@v3 | ||||
|         with: | ||||
|           fetch-depth: 0 | ||||
|       - name: Install Node | ||||
|         uses: actions/setup-node@v2.5.1 | ||||
|         uses: actions/setup-node@v3 | ||||
|       - name: Install pnpm | ||||
|         uses: pnpm/action-setup@v2.0.1 | ||||
|         uses: pnpm/action-setup@v2.2.4 | ||||
|         with: | ||||
|           version: 6.20.1 | ||||
|       - name: Install dependencies | ||||
|   | ||||
							
								
								
									
										13
									
								
								.github/workflows/dev.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										13
									
								
								.github/workflows/dev.yml
									
									
									
									
										vendored
									
									
								
							| @@ -10,26 +10,25 @@ jobs: | ||||
|   buildx: | ||||
|     name: Push branches and PRs | ||||
|     runs-on: ubuntu-latest | ||||
|     if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == 'amir20/dozzle' }} | ||||
|     steps: | ||||
|       - name: Docker meta | ||||
|         id: meta | ||||
|         uses: docker/metadata-action@v3 | ||||
|         uses: docker/metadata-action@v4 | ||||
|         with: | ||||
|           images: amir20/dozzle | ||||
|       - name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v1.2.0 | ||||
|       - name: Set up Docker Buildx | ||||
|         uses: docker/setup-buildx-action@v1.6.0 | ||||
|         uses: docker/setup-buildx-action@v2.2.0 | ||||
|       - name: Login to DockerHub | ||||
|         uses: docker/login-action@v1.12.0 | ||||
|         uses: docker/login-action@v2.1.0 | ||||
|         with: | ||||
|           username: ${{ secrets.DOCKER_USERNAME }} | ||||
|           password: ${{ secrets.DOCKER_PASSWORD }} | ||||
|       - name: Build and push | ||||
|         uses: docker/build-push-action@v2.8.0 | ||||
|         uses: docker/build-push-action@v3.2.0 | ||||
|         with: | ||||
|           push: true | ||||
|           platforms: linux/amd64 | ||||
|           platforms: linux/amd64,linux/arm/v7,linux/arm64/v8 | ||||
|           tags: ${{ steps.meta.outputs.tags }} | ||||
|           build-args: TAG=${{ steps.meta.outputs.version }} | ||||
|           labels: ${{ steps.meta.outputs.labels }} | ||||
|   | ||||
							
								
								
									
										43
									
								
								.github/workflows/test.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										43
									
								
								.github/workflows/test.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,7 +1,7 @@ | ||||
| on: | ||||
|   push: | ||||
|     branches: | ||||
|       - "**" | ||||
|       - master | ||||
|   pull_request: | ||||
|     branches: | ||||
|       - master | ||||
| @@ -12,11 +12,11 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Checkout code | ||||
|         uses: actions/checkout@v2.4.0 | ||||
|         uses: actions/checkout@v3 | ||||
|       - name: Install Node | ||||
|         uses: actions/setup-node@v2.5.1 | ||||
|         uses: actions/setup-node@v3 | ||||
|       - name: Install pnpm | ||||
|         uses: pnpm/action-setup@v2.0.1 | ||||
|         uses: pnpm/action-setup@v2.2.4 | ||||
|         with: | ||||
|           version: 6.20.1 | ||||
|       - name: Install dependencies | ||||
| @@ -28,11 +28,11 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Install Go | ||||
|         uses: actions/setup-go@v2.1.5 | ||||
|         uses: actions/setup-go@v3 | ||||
|         with: | ||||
|           go-version: 1.17.x | ||||
|           go-version: 1.18.x | ||||
|       - name: Checkout code | ||||
|         uses: actions/checkout@v2.4.0 | ||||
|         uses: actions/checkout@v3 | ||||
|       - name: Run Go Tests with Coverage | ||||
|         run: make test SKIP_ASSET=1 | ||||
|   int-test: | ||||
| @@ -40,8 +40,31 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Checkout code | ||||
|         uses: actions/checkout@v2.4.0 | ||||
|         uses: actions/checkout@v3 | ||||
|         with: | ||||
|           fetch-depth: 2 | ||||
|       - name: Set up Docker Buildx | ||||
|         uses: docker/setup-buildx-action@v2.2.0 | ||||
|       - name: Login to DockerHub | ||||
|         uses: docker/login-action@v2.1.0 | ||||
|         with: | ||||
|           username: ${{ secrets.DOCKER_USERNAME }} | ||||
|           password: ${{ secrets.DOCKER_PASSWORD }} | ||||
|       - name: Build images | ||||
|         run: docker-compose -f e2e/docker-compose.yml build | ||||
|         run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose -f e2e/docker-compose.yml build --build-arg BUILDKIT_INLINE_CACHE=1 | ||||
|       - name: Push images | ||||
|         run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose -f e2e/docker-compose.yml push | ||||
|       - name: Set commit message for push | ||||
|         if: github.event_name == 'push' | ||||
|         run: | | ||||
|           echo "GIT_LOG_MESSAGE<<EOF" >> $GITHUB_ENV | ||||
|           git log -1 --pretty=%B ${GITHUB_SHA} >> $GITHUB_ENV | ||||
|           echo 'EOF' >> $GITHUB_ENV | ||||
|       - name: Set commit message for pull request | ||||
|         if: github.event_name == 'pull_request' | ||||
|         run: | | ||||
|           echo "GIT_LOG_MESSAGE<<EOF" >> $GITHUB_ENV | ||||
|           git log -1 --pretty=%B ${{github.event.pull_request.head.sha}} >> $GITHUB_ENV | ||||
|           echo 'EOF' >> $GITHUB_ENV | ||||
|       - name: Run tests | ||||
|         run: docker-compose -f e2e/docker-compose.yml up --build --force-recreate --exit-code-from cypress | ||||
|         run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose -f e2e/docker-compose.yml up --build --force-recreate --exit-code-from cypress | ||||
|   | ||||
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -5,3 +5,5 @@ node_modules | ||||
| static | ||||
| dozzle | ||||
| coverage | ||||
| .pnpm-debug.log | ||||
| .vscode | ||||
|   | ||||
							
								
								
									
										32
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										32
									
								
								Dockerfile
									
									
									
									
									
								
							| @@ -1,29 +1,30 @@ | ||||
| # Build assets | ||||
| FROM node:17-alpine as node | ||||
| FROM --platform=$BUILDPLATFORM node:18-alpine as node | ||||
|  | ||||
| RUN npm install -g pnpm | ||||
|  | ||||
| RUN apk add --no-cache git openssh make g++ util-linux curl python3 && curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm | ||||
|  | ||||
| WORKDIR /build | ||||
|  | ||||
| # Install dependencies from lock file | ||||
| COPY pnpm-lock.yaml ./ | ||||
| RUN pnpm fetch --prod | ||||
| RUN pnpm fetch | ||||
|  | ||||
| # Copy files | ||||
| COPY package.json .* vite.config.ts index.html ./ | ||||
| # Copy package.json and install dependencies | ||||
| COPY package.json ./ | ||||
| RUN pnpm install -r --offline --ignore-scripts | ||||
|  | ||||
| # Copy assets to build | ||||
| # Copy assets and translations to build | ||||
| COPY .* vite.config.ts index.html ./ | ||||
| COPY assets ./assets | ||||
| COPY locales ./locales | ||||
|  | ||||
| # Install dependencies | ||||
| RUN pnpm install -r --offline --prod | ||||
|  | ||||
| # Do the build | ||||
| # Build assets | ||||
| RUN pnpm build | ||||
|  | ||||
| FROM golang:1.17.6-alpine AS builder | ||||
| FROM --platform=$BUILDPLATFORM golang:1.19.2-alpine AS builder | ||||
|  | ||||
| RUN apk add --no-cache git ca-certificates && mkdir /dozzle | ||||
| RUN apk add --no-cache ca-certificates && mkdir /dozzle | ||||
|  | ||||
| WORKDIR /dozzle | ||||
|  | ||||
| @@ -36,19 +37,22 @@ COPY --from=node /build/dist ./dist | ||||
|  | ||||
| # Copy all other files | ||||
| COPY analytics ./analytics | ||||
| COPY healthcheck ./healthcheck | ||||
| COPY docker ./docker | ||||
| COPY web ./web | ||||
| COPY main.go ./ | ||||
|  | ||||
| # Args | ||||
| ARG TAG=dev | ||||
| ARG TARGETOS TARGETARCH | ||||
|  | ||||
| # Build binary | ||||
| RUN CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=$TAG"  -o dozzle | ||||
| RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=$TAG"  -o dozzle | ||||
|  | ||||
|  | ||||
| FROM scratch | ||||
|  | ||||
| ENV PATH=/bin | ||||
| ENV PATH /bin | ||||
|  | ||||
| COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt | ||||
| COPY --from=builder /dozzle/dozzle /dozzle | ||||
|   | ||||
							
								
								
									
										35
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										35
									
								
								README.md
									
									
									
									
									
								
							| @@ -58,6 +58,30 @@ Dozzle will be available at [http://localhost:8888/](http://localhost:8888/). Yo | ||||
|         ports: | ||||
|           - 9999:8080 | ||||
|  | ||||
|  | ||||
| ### Enabling health check | ||||
|  | ||||
| Dozzle doesn't enable healthcheck by default as it adds extra CPU usage. `healthcheck` can be enabled manually. | ||||
|  | ||||
|     version: "3" | ||||
|     services: | ||||
|       dozzle: | ||||
|         container_name: dozzle | ||||
|         image: amir20/dozzle:latest | ||||
|         volumes: | ||||
|           - /var/run/docker.sock:/var/run/docker.sock | ||||
|         ports: | ||||
|           - 8080:8080 | ||||
|         environment: | ||||
|           DOZZLE_LEVEL: trace | ||||
|         healthcheck: | ||||
|           test: [ "CMD", "/dozzle", "healthcheck" ] | ||||
|           interval: 3s | ||||
|           timeout: 30s | ||||
|           retries: 5 | ||||
|           start_period: 30s | ||||
|  | ||||
|  | ||||
| #### Security | ||||
|  | ||||
| You can control the device Dozzle binds to by passing `--addr` parameter. For example, | ||||
| @@ -105,11 +129,8 @@ Dozzle follows the [12-factor](https://12factor.net/) model. Configurations can | ||||
| | `--filter`       | `DOZZLE_FILTER`       | `""`    | | ||||
| | `--username`     | `DOZZLE_USERNAME`     | `""`    | | ||||
| | `--password`     | `DOZZLE_PASSWORD`     | `""`    | | ||||
| | `--key`          | `DOZZLE_KEY`          | `""`    | | ||||
| | `--no-analytics` | `DOZZLE_NO_ANALYTICS` | false   | | ||||
|  | ||||
| Note: When using username and password `DOZZLE_KEY` is required for session management. | ||||
|  | ||||
| ## Troubleshooting and FAQs | ||||
|  | ||||
| <details> | ||||
| @@ -168,8 +189,8 @@ Dozzle has a [special route](https://github.com/amir20/dozzle/blob/master/assets | ||||
|  | ||||
| To Build and test locally: | ||||
|  | ||||
| 1. Install NodeJs. | ||||
| 2. Install Go. | ||||
| 3. Install [reflex](https://github.com/cespare/reflex) with `get -u github.com/cespare/reflex` outside of dozzle. | ||||
| 4. Install node modules with `pnpm`. | ||||
| 1. Install [NodeJs](https://nodejs.org/en/download/) and [pnpm](https://pnpm.io/installation). | ||||
| 2. Install [Go](https://go.dev/doc/install). | ||||
| 3. Install [reflex](https://github.com/cespare/reflex) with `go get -u github.com/cespare/reflex` outside of dozzle. | ||||
| 4. Install node modules `pnpm install`. | ||||
| 5. Do `pnpm dev` | ||||
|   | ||||
							
								
								
									
										153
									
								
								assets/App.vue
									
									
									
									
									
								
							
							
						
						
									
										153
									
								
								assets/App.vue
									
									
									
									
									
								
							| @@ -1,91 +1,8 @@ | ||||
| <template> | ||||
|   <main> | ||||
|     <mobile-menu v-if="isMobile && !authorizationNeeded"></mobile-menu> | ||||
|  | ||||
|     <splitpanes @resized="onResized($event)"> | ||||
|       <pane min-size="10" :size="menuWidth" v-if="!authorizationNeeded && !isMobile && !collapseNav"> | ||||
|         <side-menu @search="showFuzzySearch"></side-menu> | ||||
|       </pane> | ||||
|       <pane min-size="10"> | ||||
|         <splitpanes> | ||||
|           <pane class="has-min-height router-view"> | ||||
|             <router-view></router-view> | ||||
|           </pane> | ||||
|           <template v-if="!isMobile"> | ||||
|             <pane v-for="other in activeContainers" :key="other.id"> | ||||
|               <log-container | ||||
|                 :id="other.id" | ||||
|                 show-title | ||||
|                 scrollable | ||||
|                 closable | ||||
|                 @close="containerStore.removeActiveContainer(other)" | ||||
|               ></log-container> | ||||
|             </pane> | ||||
|           </template> | ||||
|         </splitpanes> | ||||
|       </pane> | ||||
|     </splitpanes> | ||||
|     <button | ||||
|       @click="collapseNav = !collapseNav" | ||||
|       class="button is-rounded" | ||||
|       :class="{ collapsed: collapseNav }" | ||||
|       id="hide-nav" | ||||
|       v-if="!isMobile && !authorizationNeeded" | ||||
|     > | ||||
|       <span class="icon ml-2" v-if="collapseNav"> | ||||
|         <mdi-light-chevron-right /> | ||||
|       </span> | ||||
|       <span class="icon" v-else> | ||||
|         <mdi-light-chevron-left /> | ||||
|       </span> | ||||
|     </button> | ||||
|   </main> | ||||
|   <router-view></router-view> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { Splitpanes, Pane } from "splitpanes"; | ||||
| import { ref, onMounted, watchEffect } from "vue"; | ||||
| import { storeToRefs } from "pinia"; | ||||
| import { useProgrammatic } from "@oruga-ui/oruga-next"; | ||||
| import hotkeys from "hotkeys-js"; | ||||
|  | ||||
| import { setTitle } from "@/composables/title"; | ||||
| import { isMobile } from "@/composables/media"; | ||||
| import { smallerScrollbars, lightTheme, menuWidth } from "@/composables/settings"; | ||||
| import { useContainerStore } from "@/stores/container"; | ||||
| import config from "@/stores/config"; | ||||
|  | ||||
| import FuzzySearchModal from "@/components/FuzzySearchModal.vue"; | ||||
| import LogContainer from "@/components/LogContainer.vue"; | ||||
| import SideMenu from "@/components/SideMenu.vue"; | ||||
| import MobileMenu from "@/components/MobileMenu.vue"; | ||||
|  | ||||
| const collapseNav = ref(false); | ||||
| const { oruga } = useProgrammatic(); | ||||
| const { authorizationNeeded } = config; | ||||
|  | ||||
| const containerStore = useContainerStore(); | ||||
|  | ||||
| const { activeContainers, visibleContainers } = storeToRefs(containerStore); | ||||
|  | ||||
| onMounted(() => { | ||||
|   if (smallerScrollbars.value) { | ||||
|     document.documentElement.classList.add("has-custom-scrollbars"); | ||||
|   } | ||||
|   if (lightTheme.value) { | ||||
|     document.documentElement.setAttribute("data-theme", "light"); | ||||
|   } | ||||
|  | ||||
|   hotkeys("command+k, ctrl+k", (event, handler) => { | ||||
|     event.preventDefault(); | ||||
|     showFuzzySearch(); | ||||
|   }); | ||||
| }); | ||||
|  | ||||
| watchEffect(() => { | ||||
|   setTitle(`${visibleContainers.value.length} containers`); | ||||
| }); | ||||
|  | ||||
| watchEffect(() => { | ||||
|   if (smallerScrollbars.value) { | ||||
|     document.documentElement.classList.add("has-custom-scrollbars"); | ||||
| @@ -93,65 +10,17 @@ watchEffect(() => { | ||||
|     document.documentElement.classList.remove("has-custom-scrollbars"); | ||||
|   } | ||||
|  | ||||
|   if (lightTheme.value) { | ||||
|     document.documentElement.setAttribute("data-theme", "light"); | ||||
|   } else { | ||||
|     document.documentElement.removeAttribute("data-theme"); | ||||
|   switch (lightTheme.value) { | ||||
|     case "dark": | ||||
|       document.documentElement.setAttribute("data-theme", "dark"); | ||||
|       break; | ||||
|     case "light": | ||||
|       document.documentElement.setAttribute("data-theme", "light"); | ||||
|       break; | ||||
|     default: | ||||
|       document.documentElement.removeAttribute("data-theme"); | ||||
|   } | ||||
| }); | ||||
|  | ||||
| function showFuzzySearch() { | ||||
|   oruga.modal.open({ | ||||
|     // parent: this, | ||||
|     component: FuzzySearchModal, | ||||
|     animation: "false", | ||||
|     width: 600, | ||||
|     active: true, | ||||
|   }); | ||||
| } | ||||
| function onResized(e) { | ||||
|   if (e.length == 2) { | ||||
|     menuWidth.value = e[0].size; | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
| :deep(.splitpanes--vertical > .splitpanes__splitter) { | ||||
|   min-width: 3px; | ||||
|   background: var(--border-color); | ||||
|   &:hover { | ||||
|     background: var(--border-hover-color); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media screen and (max-width: 768px) { | ||||
|   .router-view { | ||||
|     padding-top: 75px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .button.has-no-border { | ||||
|   border-color: transparent !important; | ||||
| } | ||||
|  | ||||
| .has-min-height { | ||||
|   min-height: 100vh; | ||||
| } | ||||
|  | ||||
| #hide-nav { | ||||
|   position: fixed; | ||||
|   left: 10px; | ||||
|   bottom: 10px; | ||||
|   &.collapsed { | ||||
|     left: -40px; | ||||
|     width: 60px; | ||||
|     padding-left: 40px; | ||||
|     background: rgba(0, 0, 0, 0.95); | ||||
|  | ||||
|     &:hover { | ||||
|       left: -25px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
| <style scoped lang="scss"></style> | ||||
|   | ||||
							
								
								
									
										616
									
								
								assets/auto-imports.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										616
									
								
								assets/auto-imports.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,616 @@ | ||||
| // Generated by 'unplugin-auto-import' | ||||
| export {} | ||||
| declare global { | ||||
|   const $$: typeof import('vue/macros')['$$'] | ||||
|   const $: typeof import('vue/macros')['$'] | ||||
|   const $computed: typeof import('vue/macros')['$computed'] | ||||
|   const $customRef: typeof import('vue/macros')['$customRef'] | ||||
|   const $ref: typeof import('vue/macros')['$ref'] | ||||
|   const $shallowRef: typeof import('vue/macros')['$shallowRef'] | ||||
|   const $toRef: typeof import('vue/macros')['$toRef'] | ||||
|   const DEFAULT_SETTINGS: typeof import('./composables/settings')['DEFAULT_SETTINGS'] | ||||
|   const EffectScope: typeof import('vue')['EffectScope'] | ||||
|   const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate'] | ||||
|   const arrayEquals: typeof import('./utils/index')['arrayEquals'] | ||||
|   const asyncComputed: typeof import('@vueuse/core')['asyncComputed'] | ||||
|   const autoResetRef: typeof import('@vueuse/core')['autoResetRef'] | ||||
|   const computed: typeof import('vue')['computed'] | ||||
|   const computedAsync: typeof import('@vueuse/core')['computedAsync'] | ||||
|   const computedEager: typeof import('@vueuse/core')['computedEager'] | ||||
|   const computedInject: typeof import('@vueuse/core')['computedInject'] | ||||
|   const computedWithControl: typeof import('@vueuse/core')['computedWithControl'] | ||||
|   const config: typeof import('./stores/config')['default'] | ||||
|   const controlledComputed: typeof import('@vueuse/core')['controlledComputed'] | ||||
|   const controlledRef: typeof import('@vueuse/core')['controlledRef'] | ||||
|   const createApp: typeof import('vue')['createApp'] | ||||
|   const createEventHook: typeof import('@vueuse/core')['createEventHook'] | ||||
|   const createGlobalState: typeof import('@vueuse/core')['createGlobalState'] | ||||
|   const createInjectionState: typeof import('@vueuse/core')['createInjectionState'] | ||||
|   const createPinia: typeof import('pinia')['createPinia'] | ||||
|   const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn'] | ||||
|   const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable'] | ||||
|   const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn'] | ||||
|   const customRef: typeof import('vue')['customRef'] | ||||
|   const debouncedRef: typeof import('@vueuse/core')['debouncedRef'] | ||||
|   const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch'] | ||||
|   const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] | ||||
|   const defineComponent: typeof import('vue')['defineComponent'] | ||||
|   const defineStore: typeof import('pinia')['defineStore'] | ||||
|   const eagerComputed: typeof import('@vueuse/core')['eagerComputed'] | ||||
|   const effectScope: typeof import('vue')['effectScope'] | ||||
|   const extendRef: typeof import('@vueuse/core')['extendRef'] | ||||
|   const flattenJSON: typeof import('./utils/index')['flattenJSON'] | ||||
|   const formatBytes: typeof import('./utils/index')['formatBytes'] | ||||
|   const getActivePinia: typeof import('pinia')['getActivePinia'] | ||||
|   const getCurrentInstance: typeof import('vue')['getCurrentInstance'] | ||||
|   const getCurrentScope: typeof import('vue')['getCurrentScope'] | ||||
|   const getDeep: typeof import('./utils/index')['getDeep'] | ||||
|   const h: typeof import('vue')['h'] | ||||
|   const hourStyle: typeof import('./composables/settings')['hourStyle'] | ||||
|   const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch'] | ||||
|   const inject: typeof import('vue')['inject'] | ||||
|   const isDefined: typeof import('@vueuse/core')['isDefined'] | ||||
|   const isMobile: typeof import('./composables/media')['isMobile'] | ||||
|   const isObject: typeof import('./utils/index')['isObject'] | ||||
|   const isProxy: typeof import('vue')['isProxy'] | ||||
|   const isReactive: typeof import('vue')['isReactive'] | ||||
|   const isReadonly: typeof import('vue')['isReadonly'] | ||||
|   const isRef: typeof import('vue')['isRef'] | ||||
|   const lightTheme: typeof import('./composables/settings')['lightTheme'] | ||||
|   const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable'] | ||||
|   const mapActions: typeof import('pinia')['mapActions'] | ||||
|   const mapGetters: typeof import('pinia')['mapGetters'] | ||||
|   const mapState: typeof import('pinia')['mapState'] | ||||
|   const mapStores: typeof import('pinia')['mapStores'] | ||||
|   const mapWritableState: typeof import('pinia')['mapWritableState'] | ||||
|   const markRaw: typeof import('vue')['markRaw'] | ||||
|   const menuWidth: typeof import('./composables/settings')['menuWidth'] | ||||
|   const nextTick: typeof import('vue')['nextTick'] | ||||
|   const onActivated: typeof import('vue')['onActivated'] | ||||
|   const onBeforeMount: typeof import('vue')['onBeforeMount'] | ||||
|   const onBeforeUnmount: typeof import('vue')['onBeforeUnmount'] | ||||
|   const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] | ||||
|   const onClickOutside: typeof import('@vueuse/core')['onClickOutside'] | ||||
|   const onDeactivated: typeof import('vue')['onDeactivated'] | ||||
|   const onErrorCaptured: typeof import('vue')['onErrorCaptured'] | ||||
|   const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke'] | ||||
|   const onLongPress: typeof import('@vueuse/core')['onLongPress'] | ||||
|   const onMounted: typeof import('vue')['onMounted'] | ||||
|   const onRenderTracked: typeof import('vue')['onRenderTracked'] | ||||
|   const onRenderTriggered: typeof import('vue')['onRenderTriggered'] | ||||
|   const onScopeDispose: typeof import('vue')['onScopeDispose'] | ||||
|   const onServerPrefetch: typeof import('vue')['onServerPrefetch'] | ||||
|   const onStartTyping: typeof import('@vueuse/core')['onStartTyping'] | ||||
|   const onUnmounted: typeof import('vue')['onUnmounted'] | ||||
|   const onUpdated: typeof import('vue')['onUpdated'] | ||||
|   const pausableWatch: typeof import('@vueuse/core')['pausableWatch'] | ||||
|   const persistentVisibleKeys: typeof import('./utils/index')['persistentVisibleKeys'] | ||||
|   const provide: typeof import('vue')['provide'] | ||||
|   const reactify: typeof import('@vueuse/core')['reactify'] | ||||
|   const reactifyObject: typeof import('@vueuse/core')['reactifyObject'] | ||||
|   const reactive: typeof import('vue')['reactive'] | ||||
|   const reactiveComputed: typeof import('@vueuse/core')['reactiveComputed'] | ||||
|   const reactiveOmit: typeof import('@vueuse/core')['reactiveOmit'] | ||||
|   const reactivePick: typeof import('@vueuse/core')['reactivePick'] | ||||
|   const readonly: typeof import('vue')['readonly'] | ||||
|   const ref: typeof import('vue')['ref'] | ||||
|   const refAutoReset: typeof import('@vueuse/core')['refAutoReset'] | ||||
|   const refDebounced: typeof import('@vueuse/core')['refDebounced'] | ||||
|   const refDefault: typeof import('@vueuse/core')['refDefault'] | ||||
|   const refThrottled: typeof import('@vueuse/core')['refThrottled'] | ||||
|   const refWithControl: typeof import('@vueuse/core')['refWithControl'] | ||||
|   const resolveComponent: typeof import('vue')['resolveComponent'] | ||||
|   const resolveRef: typeof import('@vueuse/core')['resolveRef'] | ||||
|   const resolveUnref: typeof import('@vueuse/core')['resolveUnref'] | ||||
|   const search: typeof import('./composables/settings')['search'] | ||||
|   const setActivePinia: typeof import('pinia')['setActivePinia'] | ||||
|   const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix'] | ||||
|   const setTitle: typeof import('./composables/title')['setTitle'] | ||||
|   const settings: typeof import('./composables/settings')['settings'] | ||||
|   const shallowReactive: typeof import('vue')['shallowReactive'] | ||||
|   const shallowReadonly: typeof import('vue')['shallowReadonly'] | ||||
|   const shallowRef: typeof import('vue')['shallowRef'] | ||||
|   const showAllContainers: typeof import('./composables/settings')['showAllContainers'] | ||||
|   const showTimestamp: typeof import('./composables/settings')['showTimestamp'] | ||||
|   const size: typeof import('./composables/settings')['size'] | ||||
|   const smallerScrollbars: typeof import('./composables/settings')['smallerScrollbars'] | ||||
|   const softWrap: typeof import('./composables/settings')['softWrap'] | ||||
|   const storeToRefs: typeof import('pinia')['storeToRefs'] | ||||
|   const stripVersion: typeof import('./utils/index')['stripVersion'] | ||||
|   const syncRef: typeof import('@vueuse/core')['syncRef'] | ||||
|   const syncRefs: typeof import('@vueuse/core')['syncRefs'] | ||||
|   const templateRef: typeof import('@vueuse/core')['templateRef'] | ||||
|   const throttledRef: typeof import('@vueuse/core')['throttledRef'] | ||||
|   const throttledWatch: typeof import('@vueuse/core')['throttledWatch'] | ||||
|   const toRaw: typeof import('vue')['toRaw'] | ||||
|   const toReactive: typeof import('@vueuse/core')['toReactive'] | ||||
|   const toRef: typeof import('vue')['toRef'] | ||||
|   const toRefs: typeof import('vue')['toRefs'] | ||||
|   const triggerRef: typeof import('vue')['triggerRef'] | ||||
|   const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount'] | ||||
|   const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount'] | ||||
|   const tryOnMounted: typeof import('@vueuse/core')['tryOnMounted'] | ||||
|   const tryOnScopeDispose: typeof import('@vueuse/core')['tryOnScopeDispose'] | ||||
|   const tryOnUnmounted: typeof import('@vueuse/core')['tryOnUnmounted'] | ||||
|   const unref: typeof import('vue')['unref'] | ||||
|   const unrefElement: typeof import('@vueuse/core')['unrefElement'] | ||||
|   const until: typeof import('@vueuse/core')['until'] | ||||
|   const useActiveElement: typeof import('@vueuse/core')['useActiveElement'] | ||||
|   const useArrayEvery: typeof import('@vueuse/core')['useArrayEvery'] | ||||
|   const useArrayFilter: typeof import('@vueuse/core')['useArrayFilter'] | ||||
|   const useArrayFind: typeof import('@vueuse/core')['useArrayFind'] | ||||
|   const useArrayFindIndex: typeof import('@vueuse/core')['useArrayFindIndex'] | ||||
|   const useArrayJoin: typeof import('@vueuse/core')['useArrayJoin'] | ||||
|   const useArrayMap: typeof import('@vueuse/core')['useArrayMap'] | ||||
|   const useArrayReduce: typeof import('@vueuse/core')['useArrayReduce'] | ||||
|   const useArraySome: typeof import('@vueuse/core')['useArraySome'] | ||||
|   const useAsyncQueue: typeof import('@vueuse/core')['useAsyncQueue'] | ||||
|   const useAsyncState: typeof import('@vueuse/core')['useAsyncState'] | ||||
|   const useAttrs: typeof import('vue')['useAttrs'] | ||||
|   const useBase64: typeof import('@vueuse/core')['useBase64'] | ||||
|   const useBattery: typeof import('@vueuse/core')['useBattery'] | ||||
|   const useBluetooth: typeof import('@vueuse/core')['useBluetooth'] | ||||
|   const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints'] | ||||
|   const useBroadcastChannel: typeof import('@vueuse/core')['useBroadcastChannel'] | ||||
|   const useBrowserLocation: typeof import('@vueuse/core')['useBrowserLocation'] | ||||
|   const useCached: typeof import('@vueuse/core')['useCached'] | ||||
|   const useClipboard: typeof import('@vueuse/core')['useClipboard'] | ||||
|   const useCloned: typeof import('@vueuse/core')['useCloned'] | ||||
|   const useColorMode: typeof import('@vueuse/core')['useColorMode'] | ||||
|   const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog'] | ||||
|   const useContainerStore: typeof import('./stores/container')['useContainerStore'] | ||||
|   const useCounter: typeof import('@vueuse/core')['useCounter'] | ||||
|   const useCssModule: typeof import('vue')['useCssModule'] | ||||
|   const useCssVar: typeof import('@vueuse/core')['useCssVar'] | ||||
|   const useCssVars: typeof import('vue')['useCssVars'] | ||||
|   const useCurrentElement: typeof import('@vueuse/core')['useCurrentElement'] | ||||
|   const useCycleList: typeof import('@vueuse/core')['useCycleList'] | ||||
|   const useDark: typeof import('@vueuse/core')['useDark'] | ||||
|   const useDateFormat: typeof import('@vueuse/core')['useDateFormat'] | ||||
|   const useDebounce: typeof import('@vueuse/core')['useDebounce'] | ||||
|   const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn'] | ||||
|   const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory'] | ||||
|   const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion'] | ||||
|   const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation'] | ||||
|   const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio'] | ||||
|   const useDevicesList: typeof import('@vueuse/core')['useDevicesList'] | ||||
|   const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia'] | ||||
|   const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility'] | ||||
|   const useDraggable: typeof import('@vueuse/core')['useDraggable'] | ||||
|   const useDropZone: typeof import('@vueuse/core')['useDropZone'] | ||||
|   const useElementBounding: typeof import('@vueuse/core')['useElementBounding'] | ||||
|   const useElementByPoint: typeof import('@vueuse/core')['useElementByPoint'] | ||||
|   const useElementHover: typeof import('@vueuse/core')['useElementHover'] | ||||
|   const useElementSize: typeof import('@vueuse/core')['useElementSize'] | ||||
|   const useElementVisibility: typeof import('@vueuse/core')['useElementVisibility'] | ||||
|   const useEventBus: typeof import('@vueuse/core')['useEventBus'] | ||||
|   const useEventListener: typeof import('@vueuse/core')['useEventListener'] | ||||
|   const useEventSource: typeof import('@vueuse/core')['useEventSource'] | ||||
|   const useEyeDropper: typeof import('@vueuse/core')['useEyeDropper'] | ||||
|   const useFavicon: typeof import('@vueuse/core')['useFavicon'] | ||||
|   const useFetch: typeof import('@vueuse/core')['useFetch'] | ||||
|   const useFileDialog: typeof import('@vueuse/core')['useFileDialog'] | ||||
|   const useFileSystemAccess: typeof import('@vueuse/core')['useFileSystemAccess'] | ||||
|   const useFocus: typeof import('@vueuse/core')['useFocus'] | ||||
|   const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin'] | ||||
|   const useFps: typeof import('@vueuse/core')['useFps'] | ||||
|   const useFullscreen: typeof import('@vueuse/core')['useFullscreen'] | ||||
|   const useGamepad: typeof import('@vueuse/core')['useGamepad'] | ||||
|   const useGeolocation: typeof import('@vueuse/core')['useGeolocation'] | ||||
|   const useHead: typeof import('@vueuse/head')['useHead'] | ||||
|   const useI18n: typeof import('vue-i18n')['useI18n'] | ||||
|   const useIdle: typeof import('@vueuse/core')['useIdle'] | ||||
|   const useImage: typeof import('@vueuse/core')['useImage'] | ||||
|   const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll'] | ||||
|   const useIntersectionObserver: typeof import('@vueuse/core')['useIntersectionObserver'] | ||||
|   const useInterval: typeof import('@vueuse/core')['useInterval'] | ||||
|   const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn'] | ||||
|   const useKeyModifier: typeof import('@vueuse/core')['useKeyModifier'] | ||||
|   const useLastChanged: typeof import('@vueuse/core')['useLastChanged'] | ||||
|   const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage'] | ||||
|   const useLogStream: typeof import('./composables/eventsource')['useLogStream'] | ||||
|   const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys'] | ||||
|   const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory'] | ||||
|   const useMediaControls: typeof import('@vueuse/core')['useMediaControls'] | ||||
|   const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery'] | ||||
|   const useMemoize: typeof import('@vueuse/core')['useMemoize'] | ||||
|   const useMemory: typeof import('@vueuse/core')['useMemory'] | ||||
|   const useMounted: typeof import('@vueuse/core')['useMounted'] | ||||
|   const useMouse: typeof import('@vueuse/core')['useMouse'] | ||||
|   const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement'] | ||||
|   const useMousePressed: typeof import('@vueuse/core')['useMousePressed'] | ||||
|   const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver'] | ||||
|   const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage'] | ||||
|   const useNetwork: typeof import('@vueuse/core')['useNetwork'] | ||||
|   const useNow: typeof import('@vueuse/core')['useNow'] | ||||
|   const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl'] | ||||
|   const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination'] | ||||
|   const useOnline: typeof import('@vueuse/core')['useOnline'] | ||||
|   const usePageLeave: typeof import('@vueuse/core')['usePageLeave'] | ||||
|   const useParallax: typeof import('@vueuse/core')['useParallax'] | ||||
|   const usePermission: typeof import('@vueuse/core')['usePermission'] | ||||
|   const usePointer: typeof import('@vueuse/core')['usePointer'] | ||||
|   const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe'] | ||||
|   const usePreferredColorScheme: typeof import('@vueuse/core')['usePreferredColorScheme'] | ||||
|   const usePreferredContrast: typeof import('@vueuse/core')['usePreferredContrast'] | ||||
|   const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark'] | ||||
|   const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages'] | ||||
|   const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion'] | ||||
|   const useRafFn: typeof import('@vueuse/core')['useRafFn'] | ||||
|   const useRefHistory: typeof import('@vueuse/core')['useRefHistory'] | ||||
|   const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver'] | ||||
|   const useRoute: typeof import('vue-router')['useRoute'] | ||||
|   const useRouter: typeof import('vue-router')['useRouter'] | ||||
|   const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation'] | ||||
|   const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea'] | ||||
|   const useScriptTag: typeof import('@vueuse/core')['useScriptTag'] | ||||
|   const useScroll: typeof import('@vueuse/core')['useScroll'] | ||||
|   const useScrollLock: typeof import('@vueuse/core')['useScrollLock'] | ||||
|   const useSearchFilter: typeof import('./composables/search')['useSearchFilter'] | ||||
|   const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage'] | ||||
|   const useShare: typeof import('@vueuse/core')['useShare'] | ||||
|   const useSlots: typeof import('vue')['useSlots'] | ||||
|   const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition'] | ||||
|   const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis'] | ||||
|   const useStepper: typeof import('@vueuse/core')['useStepper'] | ||||
|   const useStorage: typeof import('@vueuse/core')['useStorage'] | ||||
|   const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync'] | ||||
|   const useStyleTag: typeof import('@vueuse/core')['useStyleTag'] | ||||
|   const useSupported: typeof import('@vueuse/core')['useSupported'] | ||||
|   const useSwipe: typeof import('@vueuse/core')['useSwipe'] | ||||
|   const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList'] | ||||
|   const useTextDirection: typeof import('@vueuse/core')['useTextDirection'] | ||||
|   const useTextSelection: typeof import('@vueuse/core')['useTextSelection'] | ||||
|   const useTextareaAutosize: typeof import('@vueuse/core')['useTextareaAutosize'] | ||||
|   const useThrottle: typeof import('@vueuse/core')['useThrottle'] | ||||
|   const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn'] | ||||
|   const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory'] | ||||
|   const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo'] | ||||
|   const useTimeout: typeof import('@vueuse/core')['useTimeout'] | ||||
|   const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn'] | ||||
|   const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll'] | ||||
|   const useTimestamp: typeof import('@vueuse/core')['useTimestamp'] | ||||
|   const useTitle: typeof import('@vueuse/core')['useTitle'] | ||||
|   const useToNumber: typeof import('@vueuse/core')['useToNumber'] | ||||
|   const useToString: typeof import('@vueuse/core')['useToString'] | ||||
|   const useToggle: typeof import('@vueuse/core')['useToggle'] | ||||
|   const useTransition: typeof import('@vueuse/core')['useTransition'] | ||||
|   const useUrlSearchParams: typeof import('@vueuse/core')['useUrlSearchParams'] | ||||
|   const useUserMedia: typeof import('@vueuse/core')['useUserMedia'] | ||||
|   const useVModel: typeof import('@vueuse/core')['useVModel'] | ||||
|   const useVModels: typeof import('@vueuse/core')['useVModels'] | ||||
|   const useVibrate: typeof import('@vueuse/core')['useVibrate'] | ||||
|   const useVirtualList: typeof import('@vueuse/core')['useVirtualList'] | ||||
|   const useVisibleFilter: typeof import('./composables/visible')['useVisibleFilter'] | ||||
|   const useWakeLock: typeof import('@vueuse/core')['useWakeLock'] | ||||
|   const useWebNotification: typeof import('@vueuse/core')['useWebNotification'] | ||||
|   const useWebSocket: typeof import('@vueuse/core')['useWebSocket'] | ||||
|   const useWebWorker: typeof import('@vueuse/core')['useWebWorker'] | ||||
|   const useWebWorkerFn: typeof import('@vueuse/core')['useWebWorkerFn'] | ||||
|   const useWindowFocus: typeof import('@vueuse/core')['useWindowFocus'] | ||||
|   const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll'] | ||||
|   const useWindowSize: typeof import('@vueuse/core')['useWindowSize'] | ||||
|   const watch: typeof import('vue')['watch'] | ||||
|   const watchArray: typeof import('@vueuse/core')['watchArray'] | ||||
|   const watchAtMost: typeof import('@vueuse/core')['watchAtMost'] | ||||
|   const watchDebounced: typeof import('@vueuse/core')['watchDebounced'] | ||||
|   const watchEffect: typeof import('vue')['watchEffect'] | ||||
|   const watchIgnorable: typeof import('@vueuse/core')['watchIgnorable'] | ||||
|   const watchOnce: typeof import('@vueuse/core')['watchOnce'] | ||||
|   const watchPausable: typeof import('@vueuse/core')['watchPausable'] | ||||
|   const watchPostEffect: typeof import('vue')['watchPostEffect'] | ||||
|   const watchSyncEffect: typeof import('vue')['watchSyncEffect'] | ||||
|   const watchThrottled: typeof import('@vueuse/core')['watchThrottled'] | ||||
|   const watchTriggerable: typeof import('@vueuse/core')['watchTriggerable'] | ||||
|   const watchWithFilter: typeof import('@vueuse/core')['watchWithFilter'] | ||||
|   const whenever: typeof import('@vueuse/core')['whenever'] | ||||
| } | ||||
| // for vue template auto import | ||||
| import { UnwrapRef } from 'vue' | ||||
| declare module '@vue/runtime-core' { | ||||
|   interface ComponentCustomProperties { | ||||
|     readonly $$: UnwrapRef<typeof import('vue/macros')['$$']> | ||||
|     readonly $: UnwrapRef<typeof import('vue/macros')['$']> | ||||
|     readonly $computed: UnwrapRef<typeof import('vue/macros')['$computed']> | ||||
|     readonly $customRef: UnwrapRef<typeof import('vue/macros')['$customRef']> | ||||
|     readonly $ref: UnwrapRef<typeof import('vue/macros')['$ref']> | ||||
|     readonly $shallowRef: UnwrapRef<typeof import('vue/macros')['$shallowRef']> | ||||
|     readonly $toRef: UnwrapRef<typeof import('vue/macros')['$toRef']> | ||||
|     readonly DEFAULT_SETTINGS: UnwrapRef<typeof import('./composables/settings')['DEFAULT_SETTINGS']> | ||||
|     readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']> | ||||
|     readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']> | ||||
|     readonly arrayEquals: UnwrapRef<typeof import('./utils/index')['arrayEquals']> | ||||
|     readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']> | ||||
|     readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']> | ||||
|     readonly computed: UnwrapRef<typeof import('vue')['computed']> | ||||
|     readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']> | ||||
|     readonly computedEager: UnwrapRef<typeof import('@vueuse/core')['computedEager']> | ||||
|     readonly computedInject: UnwrapRef<typeof import('@vueuse/core')['computedInject']> | ||||
|     readonly computedWithControl: UnwrapRef<typeof import('@vueuse/core')['computedWithControl']> | ||||
|     readonly config: UnwrapRef<typeof import('./stores/config')['default']> | ||||
|     readonly controlledComputed: UnwrapRef<typeof import('@vueuse/core')['controlledComputed']> | ||||
|     readonly controlledRef: UnwrapRef<typeof import('@vueuse/core')['controlledRef']> | ||||
|     readonly createApp: UnwrapRef<typeof import('vue')['createApp']> | ||||
|     readonly createEventHook: UnwrapRef<typeof import('@vueuse/core')['createEventHook']> | ||||
|     readonly createGlobalState: UnwrapRef<typeof import('@vueuse/core')['createGlobalState']> | ||||
|     readonly createInjectionState: UnwrapRef<typeof import('@vueuse/core')['createInjectionState']> | ||||
|     readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']> | ||||
|     readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']> | ||||
|     readonly createSharedComposable: UnwrapRef<typeof import('@vueuse/core')['createSharedComposable']> | ||||
|     readonly createUnrefFn: UnwrapRef<typeof import('@vueuse/core')['createUnrefFn']> | ||||
|     readonly customRef: UnwrapRef<typeof import('vue')['customRef']> | ||||
|     readonly debouncedRef: UnwrapRef<typeof import('@vueuse/core')['debouncedRef']> | ||||
|     readonly debouncedWatch: UnwrapRef<typeof import('@vueuse/core')['debouncedWatch']> | ||||
|     readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']> | ||||
|     readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']> | ||||
|     readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']> | ||||
|     readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']> | ||||
|     readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']> | ||||
|     readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']> | ||||
|     readonly flattenJSON: UnwrapRef<typeof import('./utils/index')['flattenJSON']> | ||||
|     readonly formatBytes: UnwrapRef<typeof import('./utils/index')['formatBytes']> | ||||
|     readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']> | ||||
|     readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']> | ||||
|     readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']> | ||||
|     readonly getDeep: UnwrapRef<typeof import('./utils/index')['getDeep']> | ||||
|     readonly h: UnwrapRef<typeof import('vue')['h']> | ||||
|     readonly hourStyle: UnwrapRef<typeof import('./composables/settings')['hourStyle']> | ||||
|     readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']> | ||||
|     readonly inject: UnwrapRef<typeof import('vue')['inject']> | ||||
|     readonly isDefined: UnwrapRef<typeof import('@vueuse/core')['isDefined']> | ||||
|     readonly isMobile: UnwrapRef<typeof import('./composables/media')['isMobile']> | ||||
|     readonly isObject: UnwrapRef<typeof import('./utils/index')['isObject']> | ||||
|     readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']> | ||||
|     readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']> | ||||
|     readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']> | ||||
|     readonly isRef: UnwrapRef<typeof import('vue')['isRef']> | ||||
|     readonly lightTheme: UnwrapRef<typeof import('./composables/settings')['lightTheme']> | ||||
|     readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']> | ||||
|     readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']> | ||||
|     readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']> | ||||
|     readonly mapState: UnwrapRef<typeof import('pinia')['mapState']> | ||||
|     readonly mapStores: UnwrapRef<typeof import('pinia')['mapStores']> | ||||
|     readonly mapWritableState: UnwrapRef<typeof import('pinia')['mapWritableState']> | ||||
|     readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']> | ||||
|     readonly menuWidth: UnwrapRef<typeof import('./composables/settings')['menuWidth']> | ||||
|     readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']> | ||||
|     readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']> | ||||
|     readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']> | ||||
|     readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']> | ||||
|     readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']> | ||||
|     readonly onClickOutside: UnwrapRef<typeof import('@vueuse/core')['onClickOutside']> | ||||
|     readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']> | ||||
|     readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']> | ||||
|     readonly onKeyStroke: UnwrapRef<typeof import('@vueuse/core')['onKeyStroke']> | ||||
|     readonly onLongPress: UnwrapRef<typeof import('@vueuse/core')['onLongPress']> | ||||
|     readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']> | ||||
|     readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']> | ||||
|     readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']> | ||||
|     readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']> | ||||
|     readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']> | ||||
|     readonly onStartTyping: UnwrapRef<typeof import('@vueuse/core')['onStartTyping']> | ||||
|     readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']> | ||||
|     readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']> | ||||
|     readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']> | ||||
|     readonly persistentVisibleKeys: UnwrapRef<typeof import('./utils/index')['persistentVisibleKeys']> | ||||
|     readonly provide: UnwrapRef<typeof import('vue')['provide']> | ||||
|     readonly reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']> | ||||
|     readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']> | ||||
|     readonly reactive: UnwrapRef<typeof import('vue')['reactive']> | ||||
|     readonly reactiveComputed: UnwrapRef<typeof import('@vueuse/core')['reactiveComputed']> | ||||
|     readonly reactiveOmit: UnwrapRef<typeof import('@vueuse/core')['reactiveOmit']> | ||||
|     readonly reactivePick: UnwrapRef<typeof import('@vueuse/core')['reactivePick']> | ||||
|     readonly readonly: UnwrapRef<typeof import('vue')['readonly']> | ||||
|     readonly ref: UnwrapRef<typeof import('vue')['ref']> | ||||
|     readonly refAutoReset: UnwrapRef<typeof import('@vueuse/core')['refAutoReset']> | ||||
|     readonly refDebounced: UnwrapRef<typeof import('@vueuse/core')['refDebounced']> | ||||
|     readonly refDefault: UnwrapRef<typeof import('@vueuse/core')['refDefault']> | ||||
|     readonly refThrottled: UnwrapRef<typeof import('@vueuse/core')['refThrottled']> | ||||
|     readonly refWithControl: UnwrapRef<typeof import('@vueuse/core')['refWithControl']> | ||||
|     readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']> | ||||
|     readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']> | ||||
|     readonly resolveUnref: UnwrapRef<typeof import('@vueuse/core')['resolveUnref']> | ||||
|     readonly search: UnwrapRef<typeof import('./composables/settings')['search']> | ||||
|     readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']> | ||||
|     readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']> | ||||
|     readonly setTitle: UnwrapRef<typeof import('./composables/title')['setTitle']> | ||||
|     readonly settings: UnwrapRef<typeof import('./composables/settings')['settings']> | ||||
|     readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']> | ||||
|     readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']> | ||||
|     readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']> | ||||
|     readonly showAllContainers: UnwrapRef<typeof import('./composables/settings')['showAllContainers']> | ||||
|     readonly showTimestamp: UnwrapRef<typeof import('./composables/settings')['showTimestamp']> | ||||
|     readonly size: UnwrapRef<typeof import('./composables/settings')['size']> | ||||
|     readonly smallerScrollbars: UnwrapRef<typeof import('./composables/settings')['smallerScrollbars']> | ||||
|     readonly softWrap: UnwrapRef<typeof import('./composables/settings')['softWrap']> | ||||
|     readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']> | ||||
|     readonly stripVersion: UnwrapRef<typeof import('./utils/index')['stripVersion']> | ||||
|     readonly syncRef: UnwrapRef<typeof import('@vueuse/core')['syncRef']> | ||||
|     readonly syncRefs: UnwrapRef<typeof import('@vueuse/core')['syncRefs']> | ||||
|     readonly templateRef: UnwrapRef<typeof import('@vueuse/core')['templateRef']> | ||||
|     readonly throttledRef: UnwrapRef<typeof import('@vueuse/core')['throttledRef']> | ||||
|     readonly throttledWatch: UnwrapRef<typeof import('@vueuse/core')['throttledWatch']> | ||||
|     readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']> | ||||
|     readonly toReactive: UnwrapRef<typeof import('@vueuse/core')['toReactive']> | ||||
|     readonly toRef: UnwrapRef<typeof import('vue')['toRef']> | ||||
|     readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']> | ||||
|     readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']> | ||||
|     readonly tryOnBeforeMount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeMount']> | ||||
|     readonly tryOnBeforeUnmount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeUnmount']> | ||||
|     readonly tryOnMounted: UnwrapRef<typeof import('@vueuse/core')['tryOnMounted']> | ||||
|     readonly tryOnScopeDispose: UnwrapRef<typeof import('@vueuse/core')['tryOnScopeDispose']> | ||||
|     readonly tryOnUnmounted: UnwrapRef<typeof import('@vueuse/core')['tryOnUnmounted']> | ||||
|     readonly unref: UnwrapRef<typeof import('vue')['unref']> | ||||
|     readonly unrefElement: UnwrapRef<typeof import('@vueuse/core')['unrefElement']> | ||||
|     readonly until: UnwrapRef<typeof import('@vueuse/core')['until']> | ||||
|     readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']> | ||||
|     readonly useArrayEvery: UnwrapRef<typeof import('@vueuse/core')['useArrayEvery']> | ||||
|     readonly useArrayFilter: UnwrapRef<typeof import('@vueuse/core')['useArrayFilter']> | ||||
|     readonly useArrayFind: UnwrapRef<typeof import('@vueuse/core')['useArrayFind']> | ||||
|     readonly useArrayFindIndex: UnwrapRef<typeof import('@vueuse/core')['useArrayFindIndex']> | ||||
|     readonly useArrayJoin: UnwrapRef<typeof import('@vueuse/core')['useArrayJoin']> | ||||
|     readonly useArrayMap: UnwrapRef<typeof import('@vueuse/core')['useArrayMap']> | ||||
|     readonly useArrayReduce: UnwrapRef<typeof import('@vueuse/core')['useArrayReduce']> | ||||
|     readonly useArraySome: UnwrapRef<typeof import('@vueuse/core')['useArraySome']> | ||||
|     readonly useAsyncQueue: UnwrapRef<typeof import('@vueuse/core')['useAsyncQueue']> | ||||
|     readonly useAsyncState: UnwrapRef<typeof import('@vueuse/core')['useAsyncState']> | ||||
|     readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']> | ||||
|     readonly useBase64: UnwrapRef<typeof import('@vueuse/core')['useBase64']> | ||||
|     readonly useBattery: UnwrapRef<typeof import('@vueuse/core')['useBattery']> | ||||
|     readonly useBluetooth: UnwrapRef<typeof import('@vueuse/core')['useBluetooth']> | ||||
|     readonly useBreakpoints: UnwrapRef<typeof import('@vueuse/core')['useBreakpoints']> | ||||
|     readonly useBroadcastChannel: UnwrapRef<typeof import('@vueuse/core')['useBroadcastChannel']> | ||||
|     readonly useBrowserLocation: UnwrapRef<typeof import('@vueuse/core')['useBrowserLocation']> | ||||
|     readonly useCached: UnwrapRef<typeof import('@vueuse/core')['useCached']> | ||||
|     readonly useClipboard: UnwrapRef<typeof import('@vueuse/core')['useClipboard']> | ||||
|     readonly useCloned: UnwrapRef<typeof import('@vueuse/core')['useCloned']> | ||||
|     readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']> | ||||
|     readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']> | ||||
|     readonly useContainerStore: UnwrapRef<typeof import('./stores/container')['useContainerStore']> | ||||
|     readonly useCounter: UnwrapRef<typeof import('@vueuse/core')['useCounter']> | ||||
|     readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']> | ||||
|     readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']> | ||||
|     readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']> | ||||
|     readonly useCurrentElement: UnwrapRef<typeof import('@vueuse/core')['useCurrentElement']> | ||||
|     readonly useCycleList: UnwrapRef<typeof import('@vueuse/core')['useCycleList']> | ||||
|     readonly useDark: UnwrapRef<typeof import('@vueuse/core')['useDark']> | ||||
|     readonly useDateFormat: UnwrapRef<typeof import('@vueuse/core')['useDateFormat']> | ||||
|     readonly useDebounce: UnwrapRef<typeof import('@vueuse/core')['useDebounce']> | ||||
|     readonly useDebounceFn: UnwrapRef<typeof import('@vueuse/core')['useDebounceFn']> | ||||
|     readonly useDebouncedRefHistory: UnwrapRef<typeof import('@vueuse/core')['useDebouncedRefHistory']> | ||||
|     readonly useDeviceMotion: UnwrapRef<typeof import('@vueuse/core')['useDeviceMotion']> | ||||
|     readonly useDeviceOrientation: UnwrapRef<typeof import('@vueuse/core')['useDeviceOrientation']> | ||||
|     readonly useDevicePixelRatio: UnwrapRef<typeof import('@vueuse/core')['useDevicePixelRatio']> | ||||
|     readonly useDevicesList: UnwrapRef<typeof import('@vueuse/core')['useDevicesList']> | ||||
|     readonly useDisplayMedia: UnwrapRef<typeof import('@vueuse/core')['useDisplayMedia']> | ||||
|     readonly useDocumentVisibility: UnwrapRef<typeof import('@vueuse/core')['useDocumentVisibility']> | ||||
|     readonly useDraggable: UnwrapRef<typeof import('@vueuse/core')['useDraggable']> | ||||
|     readonly useDropZone: UnwrapRef<typeof import('@vueuse/core')['useDropZone']> | ||||
|     readonly useElementBounding: UnwrapRef<typeof import('@vueuse/core')['useElementBounding']> | ||||
|     readonly useElementByPoint: UnwrapRef<typeof import('@vueuse/core')['useElementByPoint']> | ||||
|     readonly useElementHover: UnwrapRef<typeof import('@vueuse/core')['useElementHover']> | ||||
|     readonly useElementSize: UnwrapRef<typeof import('@vueuse/core')['useElementSize']> | ||||
|     readonly useElementVisibility: UnwrapRef<typeof import('@vueuse/core')['useElementVisibility']> | ||||
|     readonly useEventBus: UnwrapRef<typeof import('@vueuse/core')['useEventBus']> | ||||
|     readonly useEventListener: UnwrapRef<typeof import('@vueuse/core')['useEventListener']> | ||||
|     readonly useEventSource: UnwrapRef<typeof import('@vueuse/core')['useEventSource']> | ||||
|     readonly useEyeDropper: UnwrapRef<typeof import('@vueuse/core')['useEyeDropper']> | ||||
|     readonly useFavicon: UnwrapRef<typeof import('@vueuse/core')['useFavicon']> | ||||
|     readonly useFetch: UnwrapRef<typeof import('@vueuse/core')['useFetch']> | ||||
|     readonly useFileDialog: UnwrapRef<typeof import('@vueuse/core')['useFileDialog']> | ||||
|     readonly useFileSystemAccess: UnwrapRef<typeof import('@vueuse/core')['useFileSystemAccess']> | ||||
|     readonly useFocus: UnwrapRef<typeof import('@vueuse/core')['useFocus']> | ||||
|     readonly useFocusWithin: UnwrapRef<typeof import('@vueuse/core')['useFocusWithin']> | ||||
|     readonly useFps: UnwrapRef<typeof import('@vueuse/core')['useFps']> | ||||
|     readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']> | ||||
|     readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']> | ||||
|     readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']> | ||||
|     readonly useHead: UnwrapRef<typeof import('@vueuse/head')['useHead']> | ||||
|     readonly useI18n: UnwrapRef<typeof import('vue-i18n')['useI18n']> | ||||
|     readonly useIdle: UnwrapRef<typeof import('@vueuse/core')['useIdle']> | ||||
|     readonly useImage: UnwrapRef<typeof import('@vueuse/core')['useImage']> | ||||
|     readonly useInfiniteScroll: UnwrapRef<typeof import('@vueuse/core')['useInfiniteScroll']> | ||||
|     readonly useIntersectionObserver: UnwrapRef<typeof import('@vueuse/core')['useIntersectionObserver']> | ||||
|     readonly useInterval: UnwrapRef<typeof import('@vueuse/core')['useInterval']> | ||||
|     readonly useIntervalFn: UnwrapRef<typeof import('@vueuse/core')['useIntervalFn']> | ||||
|     readonly useKeyModifier: UnwrapRef<typeof import('@vueuse/core')['useKeyModifier']> | ||||
|     readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']> | ||||
|     readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']> | ||||
|     readonly useLogStream: UnwrapRef<typeof import('./composables/eventsource')['useLogStream']> | ||||
|     readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']> | ||||
|     readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']> | ||||
|     readonly useMediaControls: UnwrapRef<typeof import('@vueuse/core')['useMediaControls']> | ||||
|     readonly useMediaQuery: UnwrapRef<typeof import('@vueuse/core')['useMediaQuery']> | ||||
|     readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']> | ||||
|     readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']> | ||||
|     readonly useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']> | ||||
|     readonly useMouse: UnwrapRef<typeof import('@vueuse/core')['useMouse']> | ||||
|     readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']> | ||||
|     readonly useMousePressed: UnwrapRef<typeof import('@vueuse/core')['useMousePressed']> | ||||
|     readonly useMutationObserver: UnwrapRef<typeof import('@vueuse/core')['useMutationObserver']> | ||||
|     readonly useNavigatorLanguage: UnwrapRef<typeof import('@vueuse/core')['useNavigatorLanguage']> | ||||
|     readonly useNetwork: UnwrapRef<typeof import('@vueuse/core')['useNetwork']> | ||||
|     readonly useNow: UnwrapRef<typeof import('@vueuse/core')['useNow']> | ||||
|     readonly useObjectUrl: UnwrapRef<typeof import('@vueuse/core')['useObjectUrl']> | ||||
|     readonly useOffsetPagination: UnwrapRef<typeof import('@vueuse/core')['useOffsetPagination']> | ||||
|     readonly useOnline: UnwrapRef<typeof import('@vueuse/core')['useOnline']> | ||||
|     readonly usePageLeave: UnwrapRef<typeof import('@vueuse/core')['usePageLeave']> | ||||
|     readonly useParallax: UnwrapRef<typeof import('@vueuse/core')['useParallax']> | ||||
|     readonly usePermission: UnwrapRef<typeof import('@vueuse/core')['usePermission']> | ||||
|     readonly usePointer: UnwrapRef<typeof import('@vueuse/core')['usePointer']> | ||||
|     readonly usePointerSwipe: UnwrapRef<typeof import('@vueuse/core')['usePointerSwipe']> | ||||
|     readonly usePreferredColorScheme: UnwrapRef<typeof import('@vueuse/core')['usePreferredColorScheme']> | ||||
|     readonly usePreferredContrast: UnwrapRef<typeof import('@vueuse/core')['usePreferredContrast']> | ||||
|     readonly usePreferredDark: UnwrapRef<typeof import('@vueuse/core')['usePreferredDark']> | ||||
|     readonly usePreferredLanguages: UnwrapRef<typeof import('@vueuse/core')['usePreferredLanguages']> | ||||
|     readonly usePreferredReducedMotion: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedMotion']> | ||||
|     readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']> | ||||
|     readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']> | ||||
|     readonly useResizeObserver: UnwrapRef<typeof import('@vueuse/core')['useResizeObserver']> | ||||
|     readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']> | ||||
|     readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']> | ||||
|     readonly useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']> | ||||
|     readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']> | ||||
|     readonly useScriptTag: UnwrapRef<typeof import('@vueuse/core')['useScriptTag']> | ||||
|     readonly useScroll: UnwrapRef<typeof import('@vueuse/core')['useScroll']> | ||||
|     readonly useScrollLock: UnwrapRef<typeof import('@vueuse/core')['useScrollLock']> | ||||
|     readonly useSearchFilter: UnwrapRef<typeof import('./composables/search')['useSearchFilter']> | ||||
|     readonly useSessionStorage: UnwrapRef<typeof import('@vueuse/core')['useSessionStorage']> | ||||
|     readonly useShare: UnwrapRef<typeof import('@vueuse/core')['useShare']> | ||||
|     readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']> | ||||
|     readonly useSpeechRecognition: UnwrapRef<typeof import('@vueuse/core')['useSpeechRecognition']> | ||||
|     readonly useSpeechSynthesis: UnwrapRef<typeof import('@vueuse/core')['useSpeechSynthesis']> | ||||
|     readonly useStepper: UnwrapRef<typeof import('@vueuse/core')['useStepper']> | ||||
|     readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']> | ||||
|     readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']> | ||||
|     readonly useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']> | ||||
|     readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']> | ||||
|     readonly useSwipe: UnwrapRef<typeof import('@vueuse/core')['useSwipe']> | ||||
|     readonly useTemplateRefsList: UnwrapRef<typeof import('@vueuse/core')['useTemplateRefsList']> | ||||
|     readonly useTextDirection: UnwrapRef<typeof import('@vueuse/core')['useTextDirection']> | ||||
|     readonly useTextSelection: UnwrapRef<typeof import('@vueuse/core')['useTextSelection']> | ||||
|     readonly useTextareaAutosize: UnwrapRef<typeof import('@vueuse/core')['useTextareaAutosize']> | ||||
|     readonly useThrottle: UnwrapRef<typeof import('@vueuse/core')['useThrottle']> | ||||
|     readonly useThrottleFn: UnwrapRef<typeof import('@vueuse/core')['useThrottleFn']> | ||||
|     readonly useThrottledRefHistory: UnwrapRef<typeof import('@vueuse/core')['useThrottledRefHistory']> | ||||
|     readonly useTimeAgo: UnwrapRef<typeof import('@vueuse/core')['useTimeAgo']> | ||||
|     readonly useTimeout: UnwrapRef<typeof import('@vueuse/core')['useTimeout']> | ||||
|     readonly useTimeoutFn: UnwrapRef<typeof import('@vueuse/core')['useTimeoutFn']> | ||||
|     readonly useTimeoutPoll: UnwrapRef<typeof import('@vueuse/core')['useTimeoutPoll']> | ||||
|     readonly useTimestamp: UnwrapRef<typeof import('@vueuse/core')['useTimestamp']> | ||||
|     readonly useTitle: UnwrapRef<typeof import('@vueuse/core')['useTitle']> | ||||
|     readonly useToNumber: UnwrapRef<typeof import('@vueuse/core')['useToNumber']> | ||||
|     readonly useToString: UnwrapRef<typeof import('@vueuse/core')['useToString']> | ||||
|     readonly useToggle: UnwrapRef<typeof import('@vueuse/core')['useToggle']> | ||||
|     readonly useTransition: UnwrapRef<typeof import('@vueuse/core')['useTransition']> | ||||
|     readonly useUrlSearchParams: UnwrapRef<typeof import('@vueuse/core')['useUrlSearchParams']> | ||||
|     readonly useUserMedia: UnwrapRef<typeof import('@vueuse/core')['useUserMedia']> | ||||
|     readonly useVModel: UnwrapRef<typeof import('@vueuse/core')['useVModel']> | ||||
|     readonly useVModels: UnwrapRef<typeof import('@vueuse/core')['useVModels']> | ||||
|     readonly useVibrate: UnwrapRef<typeof import('@vueuse/core')['useVibrate']> | ||||
|     readonly useVirtualList: UnwrapRef<typeof import('@vueuse/core')['useVirtualList']> | ||||
|     readonly useVisibleFilter: UnwrapRef<typeof import('./composables/visible')['useVisibleFilter']> | ||||
|     readonly useWakeLock: UnwrapRef<typeof import('@vueuse/core')['useWakeLock']> | ||||
|     readonly useWebNotification: UnwrapRef<typeof import('@vueuse/core')['useWebNotification']> | ||||
|     readonly useWebSocket: UnwrapRef<typeof import('@vueuse/core')['useWebSocket']> | ||||
|     readonly useWebWorker: UnwrapRef<typeof import('@vueuse/core')['useWebWorker']> | ||||
|     readonly useWebWorkerFn: UnwrapRef<typeof import('@vueuse/core')['useWebWorkerFn']> | ||||
|     readonly useWindowFocus: UnwrapRef<typeof import('@vueuse/core')['useWindowFocus']> | ||||
|     readonly useWindowScroll: UnwrapRef<typeof import('@vueuse/core')['useWindowScroll']> | ||||
|     readonly useWindowSize: UnwrapRef<typeof import('@vueuse/core')['useWindowSize']> | ||||
|     readonly watch: UnwrapRef<typeof import('vue')['watch']> | ||||
|     readonly watchArray: UnwrapRef<typeof import('@vueuse/core')['watchArray']> | ||||
|     readonly watchAtMost: UnwrapRef<typeof import('@vueuse/core')['watchAtMost']> | ||||
|     readonly watchDebounced: UnwrapRef<typeof import('@vueuse/core')['watchDebounced']> | ||||
|     readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']> | ||||
|     readonly watchIgnorable: UnwrapRef<typeof import('@vueuse/core')['watchIgnorable']> | ||||
|     readonly watchOnce: UnwrapRef<typeof import('@vueuse/core')['watchOnce']> | ||||
|     readonly watchPausable: UnwrapRef<typeof import('@vueuse/core')['watchPausable']> | ||||
|     readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']> | ||||
|     readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']> | ||||
|     readonly watchThrottled: UnwrapRef<typeof import('@vueuse/core')['watchThrottled']> | ||||
|     readonly watchTriggerable: UnwrapRef<typeof import('@vueuse/core')['watchTriggerable']> | ||||
|     readonly watchWithFilter: UnwrapRef<typeof import('@vueuse/core')['watchWithFilter']> | ||||
|     readonly whenever: UnwrapRef<typeof import('@vueuse/core')['whenever']> | ||||
|   } | ||||
| } | ||||
							
								
								
									
										47
									
								
								assets/components.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										47
									
								
								assets/components.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -1,26 +1,51 @@ | ||||
| // generated by unplugin-vue-components | ||||
| // We suggest you to commit this file into source control | ||||
| // Read more: https://github.com/vuejs/vue-next/pull/3399 | ||||
| // Read more: https://github.com/vuejs/core/pull/3399 | ||||
| import '@vue/runtime-core' | ||||
|  | ||||
| declare module 'vue' { | ||||
| export {} | ||||
|  | ||||
| declare module '@vue/runtime-core' { | ||||
|   export interface GlobalComponents { | ||||
|     ContainerStat: typeof import('./components/ContainerStat.vue')['default'] | ||||
|     ContainerTitle: typeof import('./components/ContainerTitle.vue')['default'] | ||||
|     CarbonCaretDown: typeof import('~icons/carbon/caret-down')['default'] | ||||
|     CilColumns: typeof import('~icons/cil/columns')['default'] | ||||
|     CilFindInPage: typeof import('~icons/cil/find-in-page')['default'] | ||||
|     ComplexLogItem: typeof import('./components/LogViewer/ComplexLogItem.vue')['default'] | ||||
|     ContainerStat: typeof import('./components/LogViewer/ContainerStat.vue')['default'] | ||||
|     ContainerTitle: typeof import('./components/LogViewer/ContainerTitle.vue')['default'] | ||||
|     CpuSparkline: typeof import('./components/StatSparkline.vue')['default'] | ||||
|     DockerEventLogItem: typeof import('./components/LogViewer/DockerEventLogItem.vue')['default'] | ||||
|     DropdownMenu: typeof import('./components/DropdownMenu.vue')['default'] | ||||
|     FieldList: typeof import('./components/LogViewer/FieldList.vue')['default'] | ||||
|     FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default'] | ||||
|     InfiniteLoader: typeof import('./components/InfiniteLoader.vue')['default'] | ||||
|     LogActionsToolbar: typeof import('./components/LogActionsToolbar.vue')['default'] | ||||
|     LogContainer: typeof import('./components/LogContainer.vue')['default'] | ||||
|     LogEventSource: typeof import('./components/LogEventSource.vue')['default'] | ||||
|     LogViewer: typeof import('./components/LogViewer.vue')['default'] | ||||
|     LogViewerWithSource: typeof import('./components/LogViewerWithSource.vue')['default'] | ||||
|     LogActionsToolbar: typeof import('./components/LogViewer/LogActionsToolbar.vue')['default'] | ||||
|     LogContainer: typeof import('./components/LogViewer/LogContainer.vue')['default'] | ||||
|     LogDate: typeof import('./components/LogViewer/LogDate.vue')['default'] | ||||
|     LogEventSource: typeof import('./components/LogViewer/LogEventSource.vue')['default'] | ||||
|     LogViewer: typeof import('./components/LogViewer/LogViewer.vue')['default'] | ||||
|     LogViewerWithSource: typeof import('./components/LogViewer/LogViewerWithSource.vue')['default'] | ||||
|     MdiDotsVertical: typeof import('~icons/mdi/dots-vertical')['default'] | ||||
|     MdiLightChevronDoubleDown: typeof import('~icons/mdi-light/chevron-double-down')['default'] | ||||
|     MdiLightChevronLeft: typeof import('~icons/mdi-light/chevron-left')['default'] | ||||
|     MdiLightChevronRight: typeof import('~icons/mdi-light/chevron-right')['default'] | ||||
|     MdiLightCog: typeof import('~icons/mdi-light/cog')['default'] | ||||
|     MdiLightMagnify: typeof import('~icons/mdi-light/magnify')['default'] | ||||
|     MobileMenu: typeof import('./components/MobileMenu.vue')['default'] | ||||
|     OcticonContainer24: typeof import('~icons/octicon/container24')['default'] | ||||
|     OcticonDownload24: typeof import('~icons/octicon/download24')['default'] | ||||
|     OcticonTrash24: typeof import('~icons/octicon/trash24')['default'] | ||||
|     PastTime: typeof import('./components/PastTime.vue')['default'] | ||||
|     RelativeTime: typeof import('./components/RelativeTime.vue')['default'] | ||||
|     RouterLink: typeof import('vue-router')['RouterLink'] | ||||
|     RouterView: typeof import('vue-router')['RouterView'] | ||||
|     ScrollableView: typeof import('./components/ScrollableView.vue')['default'] | ||||
|     ScrollProgress: typeof import('./components/ScrollProgress.vue')['default'] | ||||
|     Search: typeof import('./components/Search.vue')['default'] | ||||
|     SideMenu: typeof import('./components/SideMenu.vue')['default'] | ||||
|     SimpleLogItem: typeof import('./components/LogViewer/SimpleLogItem.vue')['default'] | ||||
|     SkippedEntriesLogItem: typeof import('./components/LogViewer/SkippedEntriesLogItem.vue')['default'] | ||||
|     StatSparkline: typeof import('./components/LogViewer/StatSparkline.vue')['default'] | ||||
|     ZigZag: typeof import('./components/LogViewer/ZigZag.vue')['default'] | ||||
|   } | ||||
| } | ||||
|  | ||||
| export { } | ||||
|   | ||||
| @@ -1,47 +0,0 @@ | ||||
| <template> | ||||
|   <div class="is-size-7 is-uppercase columns is-marginless is-mobile"> | ||||
|     <div class="column is-narrow has-text-weight-bold"> | ||||
|       {{ state }} | ||||
|     </div> | ||||
|     <div class="column is-narrow" v-if="stat.memoryUsage !== null"> | ||||
|       <span class="has-text-weight-light has-spacer">mem</span> | ||||
|       <span class="has-text-weight-bold"> | ||||
|         {{ formatBytes(stat.memoryUsage) }} | ||||
|       </span> | ||||
|     </div> | ||||
|  | ||||
|     <div class="column is-narrow" v-if="stat.cpu !== null"> | ||||
|       <span class="has-text-weight-light has-spacer">load</span> | ||||
|       <span class="has-text-weight-bold"> {{ stat.cpu }}% </span> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { ContainerStat } from "@/types/Container"; | ||||
| import { PropType } from "vue"; | ||||
|  | ||||
| defineProps({ | ||||
|   stat: { | ||||
|     type: Object as PropType<ContainerStat>, | ||||
|     required: true, | ||||
|   }, | ||||
|   state: String, | ||||
| }); | ||||
| function formatBytes(bytes: number, decimals = 2) { | ||||
|   if (bytes === 0) return "0 Bytes"; | ||||
|   const k = 1024; | ||||
|   const dm = decimals < 0 ? 0 : decimals; | ||||
|   const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; | ||||
|   const i = Math.floor(Math.log(bytes) / Math.log(k)); | ||||
|   return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| .has-spacer { | ||||
|   &::after { | ||||
|     content: " "; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										44
									
								
								assets/components/DropdownMenu.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								assets/components/DropdownMenu.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | ||||
| <template> | ||||
|   <div class="dropdown is-hoverable"> | ||||
|     <div class="dropdown-trigger"> | ||||
|       <button class="button" aria-haspopup="true" aria-controls="dropdown-menu"> | ||||
|         <span class="icon"> | ||||
|           <mdi-dots-vertical /> | ||||
|         </span> | ||||
|       </button> | ||||
|     </div> | ||||
|     <div class="dropdown-menu" id="dropdown-menu" role="menu"> | ||||
|       <div class="dropdown-content"> | ||||
|         <slot></slot> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup></script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| .minimal .button { | ||||
|   background-color: rgba(0, 0, 0, 0); | ||||
|   border: none; | ||||
|   padding: 0.1em; | ||||
|   height: 100%; | ||||
|  | ||||
|   & > .icon { | ||||
|     height: 100%; | ||||
|     & > svg { | ||||
|       align-self: flex-start; | ||||
|       height: 0.85em; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| .is-top { | ||||
|   & .dropdown-menu { | ||||
|     top: 0; | ||||
|   } | ||||
|  | ||||
|   &.is-last .dropdown-menu { | ||||
|     top: -30px; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
| @@ -3,26 +3,29 @@ | ||||
|     <o-autocomplete | ||||
|       ref="autocomplete" | ||||
|       v-model="query" | ||||
|       placeholder="Search containers using ⌘ + k or ctrl + k" | ||||
|       field="name" | ||||
|       :placeholder="$t('placeholder.search-containers')" | ||||
|       open-on-focus | ||||
|       keep-first | ||||
|       expanded | ||||
|       :data="results" | ||||
|       :data="data" | ||||
|       @select="selected" | ||||
|     > | ||||
|       <template #default="props"> | ||||
|       <template #default="{ option: item }"> | ||||
|         <div class="media"> | ||||
|           <div class="media-left"> | ||||
|             <span class="icon is-small" :class="props.option.state"> | ||||
|             <span class="icon is-small" :class="item.state"> | ||||
|               <octicon-container-24 /> | ||||
|             </span> | ||||
|           </div> | ||||
|           <div class="media-content"> | ||||
|             {{ props.option.name }} | ||||
|             {{ item.name }} | ||||
|           </div> | ||||
|           <div class="media-right"> | ||||
|             <span class="icon is-small column-icon" @click.stop.prevent="addColumn(props.option)" title="Pin as column"> | ||||
|             <span | ||||
|               class="icon is-small column-icon" | ||||
|               @click.stop.prevent="addColumn(item)" | ||||
|               :title="$t('tooltip.pin-column')" | ||||
|             > | ||||
|               <cil-columns /> | ||||
|             </span> | ||||
|           </div> | ||||
| @@ -33,62 +36,61 @@ | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import fuzzysort from "fuzzysort"; | ||||
| import { computed, nextTick, onMounted, ref, reactive } from "vue"; | ||||
| import { useRouter } from "vue-router"; | ||||
| import { useContainerStore } from "@/stores/container"; | ||||
| import { storeToRefs } from "pinia"; | ||||
| import { Container } from "@/types/Container"; | ||||
| import { Container } from "@/models/Container"; | ||||
| import { useFuse } from "@vueuse/integrations/useFuse"; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   maxResults: { | ||||
|     default: 20, | ||||
|     type: Number, | ||||
|   }, | ||||
| }); | ||||
| const { maxResults: resultLimit = 20 } = defineProps<{ | ||||
|   maxResults?: number; | ||||
| }>(); | ||||
|  | ||||
| const emit = defineEmits(["close"]); | ||||
| const emit = defineEmits<{ | ||||
|   (e: "close"): void; | ||||
| }>(); | ||||
|  | ||||
| const query = ref(""); | ||||
| const autocomplete = ref<HTMLElement>(); | ||||
| const router = useRouter(); | ||||
| const store = useContainerStore(); | ||||
| const { containers } = storeToRefs(store); | ||||
| const preparedContainers = computed(() => | ||||
|   containers.value.map(({ name, id, created, state }) => | ||||
|     reactive({ | ||||
|       name, | ||||
|  | ||||
| const list = computed(() => { | ||||
|   return containers.value.map(({ id, created, name, state }) => { | ||||
|     return { | ||||
|       id, | ||||
|       created, | ||||
|       name, | ||||
|       state, | ||||
|       preparedName: fuzzysort.prepare(name), | ||||
|     }) | ||||
|   ) | ||||
| ); | ||||
|  | ||||
| const results = computed(() => { | ||||
|   const options = { | ||||
|     limit: props.maxResults, | ||||
|     key: "preparedName", | ||||
|   }; | ||||
|   if (query.value) { | ||||
|     const results = fuzzysort.go(query.value, preparedContainers.value, options); | ||||
|     results.forEach((result) => { | ||||
|       if (result.obj.state === "running") { | ||||
|         // @ts-ignore | ||||
|         result.score += 1; | ||||
|       } | ||||
|     }); | ||||
|     return [...results].sort((a, b) => b.score - a.score).map((i) => i.obj); | ||||
|   } else { | ||||
|     return [...preparedContainers.value].sort((a, b) => b.created - a.created); | ||||
|   } | ||||
|     }; | ||||
|   }); | ||||
| }); | ||||
|  | ||||
| onMounted(() => nextTick(() => autocomplete.value?.focus())); | ||||
| const { results } = useFuse(query, list, { | ||||
|   fuseOptions: { keys: ["name"], includeScore: true }, | ||||
|   resultLimit, | ||||
|   matchAllWhenSearchEmpty: true, | ||||
| }); | ||||
|  | ||||
| function selected(item: { id: string; name: string }) { | ||||
|   router.push({ name: "container", params: { id: item.id, name: item.name } }); | ||||
| const data = computed(() => { | ||||
|   return results.value | ||||
|     .sort((a, b) => { | ||||
|       if (a.score === b.score) { | ||||
|         if (a.item.state === "running" && b.item.state !== "running") { | ||||
|           return -1; | ||||
|         } else { | ||||
|           return 1; | ||||
|         } | ||||
|       } else if (a.score && b.score) { | ||||
|         return a.score - b.score; | ||||
|       } else { | ||||
|         return 0; | ||||
|       } | ||||
|     }) | ||||
|     .map(({ item }) => item); | ||||
| }); | ||||
| watchOnce(autocomplete, () => autocomplete.value?.focus()); | ||||
|  | ||||
| function selected({ id }: { id: string }) { | ||||
|   router.push({ name: "container-id", params: { id } }); | ||||
|   emit("close"); | ||||
| } | ||||
| function addColumn(container: Container) { | ||||
|   | ||||
| @@ -9,30 +9,28 @@ | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { ref, onMounted, onUnmounted, nextTick } from "vue"; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   onLoadMore: Function, | ||||
|   enabled: Boolean, | ||||
| }); | ||||
| const { onLoadMore = () => {}, enabled } = defineProps<{ | ||||
|   onLoadMore: () => void; | ||||
|   enabled: boolean; | ||||
| }>(); | ||||
|  | ||||
| const isLoading = ref(false); | ||||
| const root = ref<HTMLElement>(); | ||||
|  | ||||
| const observer = new IntersectionObserver(async (entries) => { | ||||
|   if (entries[0].intersectionRatio <= 0) return; | ||||
|   if (props.onLoadMore && props.enabled) { | ||||
|     const scrollingParent = root.value.closest("[data-scrolling]") || document.documentElement; | ||||
|   if (onLoadMore && enabled) { | ||||
|     const scrollingParent = root.value?.closest("[data-scrolling]") || document.documentElement; | ||||
|     const previousHeight = scrollingParent.scrollHeight; | ||||
|     isLoading.value = true; | ||||
|     await props.onLoadMore(); | ||||
|     await onLoadMore(); | ||||
|     isLoading.value = false; | ||||
|     await nextTick(); | ||||
|     scrollingParent.scrollTop += scrollingParent.scrollHeight - previousHeight; | ||||
|   } | ||||
| }); | ||||
|  | ||||
| onMounted(() => observer.observe(root.value)); | ||||
| onMounted(() => observer.observe(root.value!)); | ||||
| onUnmounted(() => observer.disconnect()); | ||||
| </script> | ||||
|  | ||||
|   | ||||
| @@ -1,120 +0,0 @@ | ||||
| <template> | ||||
|   <div class="dropdown is-right is-hoverable"> | ||||
|     <div class="dropdown-trigger"> | ||||
|       <button class="button" aria-haspopup="true" aria-controls="dropdown-menu"> | ||||
|         <span class="icon"> | ||||
|           <mdi-dots-vertical /> | ||||
|         </span> | ||||
|       </button> | ||||
|     </div> | ||||
|     <div class="dropdown-menu" id="dropdown-menu" role="menu"> | ||||
|       <div class="dropdown-content"> | ||||
|         <a class="dropdown-item" @click="onClearClicked"> | ||||
|           <div class="level is-justify-content-start"> | ||||
|             <div class="level-left"> | ||||
|               <div class="level-item"> | ||||
|                 <octicon-trash-24 class="mr-4" /> | ||||
|               </div> | ||||
|             </div> | ||||
|             <div class="level-right"> | ||||
|               <div class="level-item">Clear</div> | ||||
|             </div> | ||||
|           </div> | ||||
|         </a> | ||||
|         <a class="dropdown-item" :href="`${base}/api/logs/download?id=${container.id}`"> | ||||
|           <div class="level is-justify-content-start"> | ||||
|             <div class="level-left"> | ||||
|               <div class="level-item"> | ||||
|                 <octicon-download-24 class="mr-4" /> | ||||
|               </div> | ||||
|             </div> | ||||
|             <div class="level-right"> | ||||
|               <div class="level-item">Download</div> | ||||
|             </div> | ||||
|           </div> | ||||
|         </a> | ||||
|         <hr class="dropdown-divider" /> | ||||
|         <a class="dropdown-item" @click="showSearch = true"> | ||||
|           <div class="level is-justify-content-start"> | ||||
|             <div class="level-left"> | ||||
|               <div class="level-item"> | ||||
|                 <mdi-light-magnify class="mr-4" /> | ||||
|               </div> | ||||
|             </div> | ||||
|             <div class="level-right"> | ||||
|               <div class="level-item">Search</div> | ||||
|             </div> | ||||
|           </div> | ||||
|         </a> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { onMounted, onUnmounted, PropType } from "vue"; | ||||
| import hotkeys from "hotkeys-js"; | ||||
| import config from "@/stores/config"; | ||||
| import { Container } from "@/types/Container"; | ||||
| import { useSearchFilter } from "@/composables/search"; | ||||
|  | ||||
| const { showSearch } = useSearchFilter(); | ||||
|  | ||||
| const { base } = config; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   onClearClicked: { | ||||
|     type: Function as PropType<(e: Event) => void>, | ||||
|     default: (e: Event) => {}, | ||||
|   }, | ||||
|   container: { | ||||
|     type: Object as () => Container, | ||||
|     required: true, | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const onHotkey = (event: Event) => { | ||||
|   props.onClearClicked(event); | ||||
|   event.preventDefault(); | ||||
| }; | ||||
|  | ||||
| onMounted(() => hotkeys("shift+command+l, shift+ctrl+l", onHotkey)); | ||||
| onUnmounted(() => hotkeys.unbind("shift+command+l, shift+ctrl+l", onHotkey)); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| #download.button, | ||||
| #clear.button { | ||||
|   .icon { | ||||
|     height: 80%; | ||||
|   } | ||||
|  | ||||
|   &:hover { | ||||
|     color: var(--primary-color); | ||||
|     border-color: var(--primary-color); | ||||
|   } | ||||
| } | ||||
|  | ||||
| .toolbar { | ||||
|   position: absolute; | ||||
|   left: 50%; | ||||
|   transform: translateX(-50%); | ||||
|   width: 200px; | ||||
|   background-color: var(--action-toolbar-background-color); | ||||
|   border-radius: 8em; | ||||
|   margin-top: 0.5em; | ||||
|  | ||||
|   & > div { | ||||
|     margin: 0 2em; | ||||
|     padding: 0.5em 0; | ||||
|   } | ||||
|  | ||||
|   .button { | ||||
|     background-color: rgba(0, 0, 0, 0) !important; | ||||
|  | ||||
|     &.is-small { | ||||
|       font-size: 0.65rem; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
| @@ -1,133 +0,0 @@ | ||||
| <template> | ||||
|   <infinite-loader :onLoadMore="loadOlderLogs" :enabled="messages.length > 100"></infinite-loader> | ||||
|   <slot :messages="messages"></slot> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { toRefs, ref, watch, onUnmounted } from "vue"; | ||||
| import debounce from "lodash.debounce"; | ||||
|  | ||||
| import { LogEntry } from "@/types/LogEntry"; | ||||
| import InfiniteLoader from "./InfiniteLoader.vue"; | ||||
| import config from "@/stores/config"; | ||||
| import { useContainerStore } from "@/stores/container"; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   id: { | ||||
|     type: String, | ||||
|     required: true, | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const { id } = toRefs(props); | ||||
| const emit = defineEmits(["loading-more"]); | ||||
| const store = useContainerStore(); | ||||
| const container = store.currentContainer(id); | ||||
|  | ||||
| const messages = ref<LogEntry[]>([]); | ||||
| const buffer = ref<LogEntry[]>([]); | ||||
|  | ||||
| function flushNow() { | ||||
|   messages.value.push(...buffer.value); | ||||
|   buffer.value = []; | ||||
| } | ||||
|  | ||||
| const flushBuffer = debounce(flushNow, 250, { maxWait: 1000 }); | ||||
|  | ||||
| let es: EventSource | null = null; | ||||
| let lastEventId = ""; | ||||
|  | ||||
| function connect({ clear } = { clear: true }) { | ||||
|   es?.close(); | ||||
|  | ||||
|   if (clear) { | ||||
|     flushBuffer.cancel(); | ||||
|     messages.value = []; | ||||
|     buffer.value = []; | ||||
|     lastEventId = ""; | ||||
|   } | ||||
|  | ||||
|   es = new EventSource(`${config.base}/api/logs/stream?id=${props.id}&lastEventId=${lastEventId}`); | ||||
|   es.addEventListener("container-stopped", () => { | ||||
|     es?.close(); | ||||
|     es = null; | ||||
|     buffer.value.push({ | ||||
|       event: "container-stopped", | ||||
|       message: "Container stopped", | ||||
|       date: new Date(), | ||||
|       key: new Date().toString(), | ||||
|     }); | ||||
|     flushBuffer(); | ||||
|     flushBuffer.flush(); | ||||
|   }); | ||||
|   es.addEventListener("error", (e) => console.error("EventSource failed: " + JSON.stringify(e))); | ||||
|   es.onmessage = (e) => { | ||||
|     lastEventId = e.lastEventId; | ||||
|     if (e.data) { | ||||
|       buffer.value.push(parseMessage(e.data)); | ||||
|       flushBuffer(); | ||||
|     } | ||||
|   }; | ||||
| } | ||||
|  | ||||
| async function loadOlderLogs() { | ||||
|   if (messages.value.length < 300) return; | ||||
|  | ||||
|   emit("loading-more", true); | ||||
|   const to = messages.value[0].date; | ||||
|   const last = messages.value[299].date; | ||||
|   const delta = to.getTime() - last.getTime(); | ||||
|   const from = new Date(to.getTime() + delta); | ||||
|   const logs = await ( | ||||
|     await fetch(`${config.base}/api/logs?id=${props.id}&from=${from.toISOString()}&to=${to.toISOString()}`) | ||||
|   ).text(); | ||||
|   if (logs) { | ||||
|     const newMessages = logs | ||||
|       .trim() | ||||
|       .split("\n") | ||||
|       .map((line) => parseMessage(line)); | ||||
|     messages.value.unshift(...newMessages); | ||||
|   } | ||||
|   emit("loading-more", false); | ||||
| } | ||||
|  | ||||
| function parseMessage(data: String): LogEntry { | ||||
|   let i = data.indexOf(" "); | ||||
|   if (i == -1) { | ||||
|     i = data.length; | ||||
|   } | ||||
|   const key = data.substring(0, i); | ||||
|   const date = new Date(key); | ||||
|   const message = data.substring(i + 1); | ||||
|   return { key, date, message }; | ||||
| } | ||||
|  | ||||
| watch( | ||||
|   () => container.value.state, | ||||
|   (newValue, oldValue) => { | ||||
|     console.log("LogEventSource: container changed", newValue, oldValue); | ||||
|     if (newValue == "running" && newValue != oldValue) { | ||||
|       buffer.value.push({ | ||||
|         event: "container-started", | ||||
|         message: "Container started", | ||||
|         date: new Date(), | ||||
|         key: new Date().toString(), | ||||
|       }); | ||||
|       connect({ clear: false }); | ||||
|     } | ||||
|   } | ||||
| ); | ||||
|  | ||||
| onUnmounted(() => { | ||||
|   if (es) { | ||||
|     es.close(); | ||||
|   } | ||||
| }); | ||||
|  | ||||
| connect(); | ||||
| watch(id, () => connect()); | ||||
|  | ||||
| defineExpose({ | ||||
|   clear: () => (messages.value = []), | ||||
| }); | ||||
| </script> | ||||
| @@ -1,99 +0,0 @@ | ||||
| <template> | ||||
|   <ul class="events" :class="size"> | ||||
|     <li v-for="item in filtered" :key="item.key" :data-event="item.event"> | ||||
|       <span class="date" v-if="showTimestamp"> <relative-time :date="item.date"></relative-time></span> | ||||
|       <span class="text" v-html="colorize(item.message)"></span> | ||||
|     </li> | ||||
|   </ul> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { PropType, toRefs } from "vue"; | ||||
|  | ||||
| import { size, showTimestamp } from "@/composables/settings"; | ||||
| import RelativeTime from "./RelativeTime.vue"; | ||||
| import AnsiConvertor from "ansi-to-html"; | ||||
| import { LogEntry } from "@/types/LogEntry"; | ||||
| import { useSearchFilter } from "@/composables/search"; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   messages: { | ||||
|     type: Array as PropType<LogEntry[]>, | ||||
|     required: true, | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const ansiConvertor = new AnsiConvertor({ escapeXML: true }); | ||||
| const colorize = (value: string) => | ||||
|   ansiConvertor.toHtml(value).replace("<mark>", "<mark>").replace("</mark>", "</mark>"); | ||||
| const { messages } = toRefs(props); | ||||
| const filtered = useSearchFilter().filteredMessages(messages); | ||||
| </script> | ||||
| <style scoped lang="scss"> | ||||
| .events { | ||||
|   padding: 1em; | ||||
|   font-family: SFMono-Regular, Consolas, Liberation Mono, monaco, Menlo, monospace; | ||||
|  | ||||
|   & > li { | ||||
|     word-wrap: break-word; | ||||
|     line-height: 130%; | ||||
|     &:last-child { | ||||
|       scroll-snap-align: end; | ||||
|       scroll-margin-block-end: 5rem; | ||||
|     } | ||||
|     &[data-event="container-stopped"] { | ||||
|       color: #f14668; | ||||
|     } | ||||
|     &[data-event="container-started"] { | ||||
|       color: hsl(141, 53%, 53%); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &.small { | ||||
|     font-size: 60%; | ||||
|   } | ||||
|  | ||||
|   &.medium { | ||||
|     font-size: 80%; | ||||
|   } | ||||
|  | ||||
|   &.large { | ||||
|     font-size: 120%; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .date { | ||||
|   background-color: #262626; | ||||
|   color: #258ccd; | ||||
|  | ||||
|   [data-theme="light"] & { | ||||
|     background-color: #f0f0f0; | ||||
|     color: #009900; | ||||
|     padding-left: 5px; | ||||
|     padding-right: 5px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .text { | ||||
|   white-space: pre-wrap; | ||||
|   &::before { | ||||
|     content: " "; | ||||
|   } | ||||
| } | ||||
|  | ||||
| :deep(mark) { | ||||
|   border-radius: 2px; | ||||
|   background-color: var(--secondary-color); | ||||
|   animation: pops 200ms ease-out; | ||||
|   display: inline-block; | ||||
| } | ||||
|  | ||||
| @keyframes pops { | ||||
|   0% { | ||||
|     transform: scale(1.5); | ||||
|   } | ||||
|   100% { | ||||
|     transform: scale(1.05); | ||||
|   } | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										63
									
								
								assets/components/LogViewer/ComplexLogItem.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								assets/components/LogViewer/ComplexLogItem.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | ||||
| <template> | ||||
|   <div class="columns is-1 is-variable"> | ||||
|     <div class="column is-narrow" v-if="showTimestamp"> | ||||
|       <log-date :date="logEntry.date"></log-date> | ||||
|     </div> | ||||
|     <div class="column"> | ||||
|       <ul class="fields" :class="{ expanded }" @click="expanded = !expanded"> | ||||
|         <li v-for="(value, name) in validValues(logEntry.message)"> | ||||
|           <span class="has-text-grey">{{ name }}=</span> | ||||
|           <span class="has-text-weight-bold" v-html="markSearch(value)"></span> | ||||
|         </li> | ||||
|       </ul> | ||||
|       <field-list :fields="logEntry.unfilteredMessage" :expanded="expanded" :visible-keys="visibleKeys"></field-list> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| <script lang="ts" setup> | ||||
| import { type ComplexLogEntry } from "@/models/LogEntry"; | ||||
|  | ||||
| const { markSearch } = useSearchFilter(); | ||||
|  | ||||
| const { logEntry } = defineProps<{ | ||||
|   logEntry: ComplexLogEntry; | ||||
|   visibleKeys: string[][]; | ||||
| }>(); | ||||
|  | ||||
| let expanded = $ref(false); | ||||
|  | ||||
| function validValues(obj: Record<string, any>) { | ||||
|   return Object.fromEntries(Object.entries(obj).filter(([_, value]) => value !== undefined)); | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| .fields { | ||||
|   display: inline-block; | ||||
|   list-style: none; | ||||
|  | ||||
|   &:hover { | ||||
|     cursor: pointer; | ||||
|     &::after { | ||||
|       content: "expand json"; | ||||
|       color: var(--secondary-color); | ||||
|       display: inline-block; | ||||
|       margin-left: 0.5em; | ||||
|       font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &.expanded:hover { | ||||
|     &::after { | ||||
|       content: "collapse json"; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   li { | ||||
|     display: inline-block; | ||||
|     & + li { | ||||
|       margin-left: 1em; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										79
									
								
								assets/components/LogViewer/ContainerStat.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								assets/components/LogViewer/ContainerStat.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,79 @@ | ||||
| <template> | ||||
|   <div class="is-size-7 is-uppercase columns is-marginless is-mobile is-vcentered" v-if="container.stat"> | ||||
|     <div class="column is-narrow has-text-weight-bold"> | ||||
|       {{ container.state }} | ||||
|     </div> | ||||
|     <div class="column is-narrow has-text-centered is-relative"> | ||||
|       <div class="has-border"> | ||||
|         <stat-sparkline :data="memoryData"></stat-sparkline> | ||||
|       </div> | ||||
|  | ||||
|       <div class="has-background-body-color is-top-left"> | ||||
|         <span class="has-text-weight-light has-spacer">mem</span> | ||||
|         <span class="has-text-weight-bold"> | ||||
|           {{ formatBytes(container.stat.memoryUsage) }} | ||||
|         </span> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <div class="column is-narrow has-text-centered is-relative"> | ||||
|       <div class="has-border"> | ||||
|         <stat-sparkline :data="cpuData"></stat-sparkline> | ||||
|       </div> | ||||
|       <div class="has-background-body-color is-top-left"> | ||||
|         <span class="has-text-weight-light has-spacer">load</span> | ||||
|         <span class="has-text-weight-bold"> {{ container.stat.cpu }}% </span> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { Container } from "@/models/Container"; | ||||
| import { type ComputedRef } from "vue"; | ||||
|  | ||||
| const container = inject("container") as ComputedRef<Container>; | ||||
|  | ||||
| const cpuData = computedWithControl( | ||||
|   () => container.value.getLastStat(), | ||||
|   () => { | ||||
|     const history = container.value.getStatHistory(); | ||||
|     return history.map((stat, i) => ({ x: history.length - i, y: stat.snapshot.cpu })); | ||||
|   } | ||||
| ); | ||||
|  | ||||
| const memoryData = computedWithControl( | ||||
|   () => container.value.getLastStat(), | ||||
|   () => { | ||||
|     const history = container.value.getStatHistory(); | ||||
|     return history.map((stat, i) => ({ x: history.length - i, y: stat.snapshot.memory })); | ||||
|   } | ||||
| ); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| .has-spacer { | ||||
|   &::after { | ||||
|     content: " "; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .has-border { | ||||
|   border: 1px solid var(--primary-color); | ||||
|   border-radius: 3px; | ||||
|   padding: 1px 1px 0 1px; | ||||
|   display: flex; | ||||
|   overflow: hidden; | ||||
|   padding-top: 0.25em; | ||||
| } | ||||
|  | ||||
| .has-background-body-color { | ||||
|   background-color: var(--body-background-color); | ||||
| } | ||||
|  | ||||
| .is-top-left { | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   left: 0.75em; | ||||
| } | ||||
| </style> | ||||
| @@ -8,14 +8,10 @@ | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts" setup> | ||||
| import { Container } from "@/types/Container"; | ||||
| import { PropType } from "vue"; | ||||
| defineProps({ | ||||
|   container: { | ||||
|     type: Object as PropType<Container>, | ||||
|     required: true, | ||||
|   }, | ||||
| }); | ||||
| import { Container } from "@/models/Container"; | ||||
| import { type ComputedRef } from "vue"; | ||||
| 
 | ||||
| const container = inject("container") as ComputedRef<Container>; | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped></style> | ||||
							
								
								
									
										24
									
								
								assets/components/LogViewer/DockerEventLogItem.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								assets/components/LogViewer/DockerEventLogItem.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| <template> | ||||
|   <span class="text" :data-event="logEntry.event" v-html="logEntry.message"></span> | ||||
| </template> | ||||
| <script lang="ts" setup> | ||||
| import { DockerEventLogEntry } from "@/models/LogEntry"; | ||||
|  | ||||
| defineProps<{ | ||||
|   logEntry: DockerEventLogEntry; | ||||
| }>(); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| span { | ||||
|   &[data-event="container-stopped"] { | ||||
|     color: #f14668; | ||||
|   } | ||||
|   &[data-event="container-started"] { | ||||
|     color: hsl(141, 53%, 53%); | ||||
|   } | ||||
|   &.text { | ||||
|     white-space: pre-wrap; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										76
									
								
								assets/components/LogViewer/FieldList.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								assets/components/LogViewer/FieldList.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,76 @@ | ||||
| <template> | ||||
|   <ul v-if="expanded" ref="root"> | ||||
|     <li v-for="(value, name) in fields"> | ||||
|       <template v-if="isObject(value)"> | ||||
|         <span class="has-text-grey">{{ name }}=</span> | ||||
|         <field-list | ||||
|           :fields="value" | ||||
|           :parent-key="parentKey.concat(name)" | ||||
|           :visible-keys="visibleKeys" | ||||
|           expanded | ||||
|         ></field-list> | ||||
|       </template> | ||||
|       <template v-else-if="Array.isArray(value)"> | ||||
|         <a @click="toggleField(name)"> {{ hasField(name) ? "remove" : "add" }} </a> | ||||
|         <span class="has-text-grey">{{ name }}=</span>[ | ||||
|         <span class="has-text-weight-bold" v-for="(item, index) in value"> | ||||
|           {{ item }} | ||||
|           <span v-if="index !== value.length - 1">,</span> | ||||
|         </span> | ||||
|         ] | ||||
|       </template> | ||||
|       <template v-else> | ||||
|         <a @click="toggleField(name)"> {{ hasField(name) ? "remove" : "add" }} </a> | ||||
|         <span class="has-text-grey">{{ name }}=</span><span class="has-text-weight-bold">{{ value }}</span> | ||||
|       </template> | ||||
|     </li> | ||||
|   </ul> | ||||
| </template> | ||||
| <script lang="ts" setup> | ||||
| import { arrayEquals, isObject } from "@/utils"; | ||||
|  | ||||
| const { | ||||
|   fields, | ||||
|   expanded = false, | ||||
|   parentKey = [], | ||||
|   visibleKeys = [], | ||||
| } = defineProps<{ | ||||
|   fields: Record<string, any>; | ||||
|   expanded?: boolean; | ||||
|   parentKey?: string[]; | ||||
|   visibleKeys?: string[][]; | ||||
| }>(); | ||||
|  | ||||
| const root = ref<HTMLElement>(); | ||||
|  | ||||
| async function toggleField(field: string) { | ||||
|   const index = fieldIndex(field); | ||||
|  | ||||
|   if (index > -1) { | ||||
|     visibleKeys.splice(index, 1); | ||||
|   } else { | ||||
|     visibleKeys.push(parentKey.concat(field)); | ||||
|   } | ||||
|  | ||||
|   await nextTick(); | ||||
|  | ||||
|   root.value?.scrollIntoView({ | ||||
|     block: "center", | ||||
|   }); | ||||
| } | ||||
|  | ||||
| function hasField(field: string) { | ||||
|   return fieldIndex(field) > -1; | ||||
| } | ||||
|  | ||||
| function fieldIndex(field: string) { | ||||
|   const path = parentKey.concat(field); | ||||
|   return visibleKeys.findIndex((keys) => arrayEquals(toRaw(keys), toRaw(path))); | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| ul { | ||||
|   margin-left: 2em; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										69
									
								
								assets/components/LogViewer/LogActionsToolbar.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								assets/components/LogViewer/LogActionsToolbar.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | ||||
| <template> | ||||
|   <dropdown-menu class="is-right"> | ||||
|     <a class="dropdown-item" @click="onClearClicked"> | ||||
|       <div class="level is-justify-content-start"> | ||||
|         <div class="level-left"> | ||||
|           <div class="level-item"> | ||||
|             <octicon-trash-24 class="mr-4" /> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="level-right"> | ||||
|           <div class="level-item">{{ $t("toolbar.clear") }}</div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </a> | ||||
|     <a class="dropdown-item" :href="`${base}/api/logs/download?id=${container.id}`"> | ||||
|       <div class="level is-justify-content-start"> | ||||
|         <div class="level-left"> | ||||
|           <div class="level-item"> | ||||
|             <octicon-download-24 class="mr-4" /> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="level-right"> | ||||
|           <div class="level-item">{{ $t("toolbar.download") }}</div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </a> | ||||
|     <hr class="dropdown-divider" /> | ||||
|     <a class="dropdown-item" @click="showSearch = true"> | ||||
|       <div class="level is-justify-content-start"> | ||||
|         <div class="level-left"> | ||||
|           <div class="level-item"> | ||||
|             <mdi-light-magnify class="mr-4" /> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="level-right"> | ||||
|           <div class="level-item">{{ $t("toolbar.search") }}</div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </a> | ||||
|   </dropdown-menu> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { type ComputedRef } from "vue"; | ||||
| import { Container } from "@/models/Container"; | ||||
|  | ||||
| const { showSearch } = useSearchFilter(); | ||||
| const { base } = config; | ||||
|  | ||||
| const { onClearClicked = (e: Event) => {} } = defineProps<{ | ||||
|   onClearClicked: (e: Event) => void; | ||||
| }>(); | ||||
|  | ||||
| const container = inject("container") as ComputedRef<Container>; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| #download.button, | ||||
| #clear.button { | ||||
|   .icon { | ||||
|     height: 80%; | ||||
|   } | ||||
|  | ||||
|   &:hover { | ||||
|     color: var(--primary-color); | ||||
|     border-color: var(--primary-color); | ||||
|   } | ||||
| } | ||||
| </style> | ||||
| @@ -3,14 +3,14 @@ | ||||
|     <template #header v-if="showTitle"> | ||||
|       <div class="mr-0 columns is-vcentered is-marginless is-hidden-mobile"> | ||||
|         <div class="column is-clipped is-paddingless"> | ||||
|           <container-title :container="container" @close="$emit('close')" /> | ||||
|           <container-title @close="$emit('close')" /> | ||||
|         </div> | ||||
|         <div class="column is-narrow is-paddingless"> | ||||
|           <container-stat :stat="container.stat" :state="container.state" v-if="container.stat" /> | ||||
|           <container-stat /> | ||||
|         </div> | ||||
| 
 | ||||
|         <div class="mr-2 column is-narrow is-paddingless"> | ||||
|           <log-actions-toolbar :container="container" :onClearClicked="onClearClicked" /> | ||||
|           <log-actions-toolbar :onClearClicked="onClearClicked" /> | ||||
|         </div> | ||||
|         <div class="mr-2 column is-narrow is-paddingless" v-if="closable"> | ||||
|           <button class="delete is-medium" @click="emit('close')"></button> | ||||
| @@ -18,41 +18,35 @@ | ||||
|       </div> | ||||
|     </template> | ||||
|     <template #default="{ setLoading }"> | ||||
|       <log-viewer-with-source ref="viewer" :id="id" @loading-more="setLoading($event)" /> | ||||
|       <log-viewer-with-source ref="viewer" @loading-more="setLoading($event)" /> | ||||
|     </template> | ||||
|   </scrollable-view> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts" setup> | ||||
| import { ref, toRefs } from "vue"; | ||||
| import LogViewerWithSource from "./LogViewerWithSource.vue"; | ||||
| import { useContainerStore } from "@/stores/container"; | ||||
| 
 | ||||
| const props = defineProps({ | ||||
|   id: { | ||||
|     type: String, | ||||
|     required: true, | ||||
|   }, | ||||
|   showTitle: { | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
|   scrollable: { | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
|   closable: { | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
| }); | ||||
| const { | ||||
|   id, | ||||
|   showTitle = false, | ||||
|   scrollable = false, | ||||
|   closable = false, | ||||
| } = defineProps<{ | ||||
|   id: string; | ||||
|   showTitle?: boolean; | ||||
|   scrollable?: boolean; | ||||
|   closable?: boolean; | ||||
| }>(); | ||||
| 
 | ||||
| const emit = defineEmits(["close"]); | ||||
| const emit = defineEmits<{ | ||||
|   (event: "close"): void; | ||||
| }>(); | ||||
| 
 | ||||
| const { id } = toRefs(props); | ||||
| const store = useContainerStore(); | ||||
| 
 | ||||
| const container = store.currentContainer(id); | ||||
| const container = store.currentContainer($$(id)); | ||||
| 
 | ||||
| provide("container", container); | ||||
| 
 | ||||
| const viewer = ref<InstanceType<typeof LogViewerWithSource>>(); | ||||
| 
 | ||||
							
								
								
									
										45
									
								
								assets/components/LogViewer/LogDate.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								assets/components/LogViewer/LogDate.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| <template> | ||||
|   <relative-time :date="date" class="date"></relative-time> | ||||
| </template> | ||||
| <script lang="ts" setup> | ||||
| defineProps<{ | ||||
|   date: Date; | ||||
| }>(); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| .date { | ||||
|   padding-left: 5px; | ||||
|   padding-right: 5px; | ||||
|   border-radius: 3px; | ||||
|   white-space: nowrap; | ||||
| } | ||||
|  | ||||
| @media (prefers-color-scheme: dark) { | ||||
|   .date { | ||||
|     background-color: #262626; | ||||
|     color: #258ccd; | ||||
|   } | ||||
| } | ||||
|  | ||||
| [data-theme="dark"] { | ||||
|   .date { | ||||
|     background-color: #262626; | ||||
|     color: #258ccd; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media (prefers-color-scheme: light) { | ||||
|   .date { | ||||
|     background-color: #f0f0f0; | ||||
|     color: #009900; | ||||
|   } | ||||
| } | ||||
|  | ||||
| [data-theme="light"] { | ||||
|   .date { | ||||
|     background-color: #f0f0f0; | ||||
|     color: #009900; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
| @@ -4,29 +4,11 @@ import { createTestingPinia } from "@pinia/testing"; | ||||
| import EventSource, { sources } from "eventsourcemock"; | ||||
| import LogEventSource from "./LogEventSource.vue"; | ||||
| import LogViewer from "./LogViewer.vue"; | ||||
| import { settings } from "../composables/settings"; | ||||
| import { settings } from "../../composables/settings"; | ||||
| import { useSearchFilter } from "@/composables/search"; | ||||
| import { vi, describe, expect, beforeEach, test, beforeAll, afterAll } from "vitest"; | ||||
| import { computed, Ref } from "vue"; | ||||
| 
 | ||||
| vi.mock("lodash.debounce", () => ({ | ||||
|   __esModule: true, | ||||
|   default: vi.fn((fn) => { | ||||
|     fn.cancel = () => {}; | ||||
|     return fn; | ||||
|   }), | ||||
| })); | ||||
| 
 | ||||
| vi.mock("@/stores/container", () => ({ | ||||
|   __esModule: true, | ||||
|   useContainerStore() { | ||||
|     return { | ||||
|       currentContainer(id: Ref<string>) { | ||||
|         return computed(() => ({ id: id.value })); | ||||
|       }, | ||||
|     }; | ||||
|   }, | ||||
| })); | ||||
| import { vi, describe, expect, beforeEach, test, afterEach } from "vitest"; | ||||
| import { computed, nextTick } from "vue"; | ||||
| import { createRouter, createWebHistory } from "vue-router"; | ||||
| 
 | ||||
| vi.mock("@/stores/config", () => ({ | ||||
|   __esModule: true, | ||||
| @@ -41,16 +23,24 @@ describe("<LogEventSource />", () => { | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|     global.EventSource = EventSource; | ||||
|     // @ts-ignore
 | ||||
|     window.scrollTo = vi.fn(); | ||||
|     global.IntersectionObserver = vi.fn().mockImplementation(() => ({ | ||||
|       observe: vi.fn(), | ||||
|       disconnect: vi.fn(), | ||||
|     })); | ||||
|     vi.useFakeTimers(); | ||||
|     vi.setSystemTime(1560336942459); | ||||
|   }); | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|     vi.restoreAllMocks(); | ||||
|     vi.useRealTimers(); | ||||
|   }); | ||||
| 
 | ||||
|   function createLogEventSource( | ||||
|     { | ||||
|       searchFilter = undefined, | ||||
|       searchFilter = "", | ||||
|       hourStyle = "auto", | ||||
|     }: { searchFilter?: string | undefined; hourStyle?: "auto" | "24" | "12" } = { | ||||
|       hourStyle: "auto", | ||||
| @@ -58,19 +48,39 @@ describe("<LogEventSource />", () => { | ||||
|   ) { | ||||
|     settings.value.hourStyle = hourStyle; | ||||
|     search.searchFilter.value = searchFilter; | ||||
|     if(searchFilter){ | ||||
|       search.showSearch.value = true; | ||||
|     } | ||||
| 
 | ||||
|     const router = createRouter({ | ||||
|       history: createWebHistory("/"), | ||||
|       routes: [ | ||||
|         { | ||||
|           path: "/", | ||||
|           component: { | ||||
|             template: "Test from createLogEventSource", | ||||
|           }, | ||||
|         }, | ||||
|       ], | ||||
|     }); | ||||
| 
 | ||||
|     return mount(LogEventSource, { | ||||
|       global: { | ||||
|         plugins: [createTestingPinia({ createSpy: vi.fn })], | ||||
|         plugins: [router, createTestingPinia({ createSpy: vi.fn })], | ||||
|         components: { | ||||
|           LogViewer, | ||||
|         }, | ||||
|         provide: { | ||||
|           container: computed(() => ({ id: "abc", image: "test:v123" })), | ||||
|           scrollingPaused: computed(() => false), | ||||
|         }, | ||||
|       }, | ||||
|       slots: { | ||||
|         default: ` | ||||
|         <template #scoped="params"><log-viewer :messages="params.messages"></log-viewer></template> | ||||
|         `,
 | ||||
|       }, | ||||
|       props: { id: "abc" }, | ||||
|       props: {}, | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
| @@ -97,68 +107,28 @@ describe("<LogEventSource />", () => { | ||||
|     const wrapper = createLogEventSource(); | ||||
|     sources["/api/logs/stream?id=abc&lastEventId="].emitOpen(); | ||||
|     sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({ | ||||
|       data: `2019-06-12T10:55:42.459034602Z "This is a message."`, | ||||
|       data: `{"ts":1560336942459, "m":"This is a message.", "id":1}`, | ||||
|     }); | ||||
| 
 | ||||
|     vi.runAllTimers(); | ||||
|     await nextTick(); | ||||
| 
 | ||||
|     // @ts-ignore
 | ||||
|     const [message, _] = wrapper.vm.messages; | ||||
|     const { key, ...messageWithoutKey } = message; | ||||
| 
 | ||||
|     expect(key).toBe("2019-06-12T10:55:42.459034602Z"); | ||||
|     expect(messageWithoutKey).toMatchSnapshot(); | ||||
|   }); | ||||
| 
 | ||||
|   test("should parse messages with loki's timestamp format", async () => { | ||||
|     const wrapper = createLogEventSource(); | ||||
|     sources["/api/logs/stream?id=abc&lastEventId="].emitOpen(); | ||||
|     sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({ data: `2020-04-27T12:35:43.272974324+02:00 xxxxx` }); | ||||
| 
 | ||||
|     const [message, _] = wrapper.vm.messages; | ||||
|     const { key, ...messageWithoutKey } = message; | ||||
| 
 | ||||
|     expect(key).toBe("2020-04-27T12:35:43.272974324+02:00"); | ||||
|     expect(messageWithoutKey).toMatchSnapshot(); | ||||
|   }); | ||||
| 
 | ||||
|   test("should pass messages to slot", async () => { | ||||
|     const wrapper = createLogEventSource(); | ||||
|     sources["/api/logs/stream?id=abc&lastEventId="].emitOpen(); | ||||
|     sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({ | ||||
|       data: `2019-06-12T10:55:42.459034602Z "This is a message."`, | ||||
|     }); | ||||
|     const [message, _] = wrapper.getComponent(LogViewer).vm.messages; | ||||
| 
 | ||||
|     const { key, ...messageWithoutKey } = message; | ||||
| 
 | ||||
|     expect(key).toBe("2019-06-12T10:55:42.459034602Z"); | ||||
| 
 | ||||
|     expect(messageWithoutKey).toMatchSnapshot(); | ||||
|     expect(message).toMatchSnapshot(); | ||||
|   }); | ||||
| 
 | ||||
|   describe("render html correctly", () => { | ||||
|     const RealDate = Date; | ||||
|     beforeAll(() => { | ||||
|       // @ts-ignore
 | ||||
|       global.Date = class extends RealDate { | ||||
|         constructor(arg: any | number) { | ||||
|           super(arg); | ||||
|           if (arg) { | ||||
|             return new RealDate(arg); | ||||
|           } else { | ||||
|             return new RealDate(1560336936000); | ||||
|           } | ||||
|         } | ||||
|       }; | ||||
|     }); | ||||
|     afterAll(() => (global.Date = RealDate)); | ||||
| 
 | ||||
|     test("should render messages", async () => { | ||||
|       const wrapper = createLogEventSource(); | ||||
|       sources["/api/logs/stream?id=abc&lastEventId="].emitOpen(); | ||||
|       sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({ | ||||
|         data: `2019-06-12T10:55:42.459034602Z "This is a message."`, | ||||
|         data: `{"ts":1560336942459, "m":"This is a message.", "id":1}`, | ||||
|       }); | ||||
| 
 | ||||
|       await wrapper.vm.$nextTick(); | ||||
|       vi.runAllTimers(); | ||||
|       await nextTick(); | ||||
| 
 | ||||
|       expect(wrapper.find("ul.events").html()).toMatchSnapshot(); | ||||
|     }); | ||||
| 
 | ||||
| @@ -166,10 +136,12 @@ describe("<LogEventSource />", () => { | ||||
|       const wrapper = createLogEventSource(); | ||||
|       sources["/api/logs/stream?id=abc&lastEventId="].emitOpen(); | ||||
|       sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({ | ||||
|         data: `2019-06-12T10:55:42.459034602Z \x1b[30mblack\x1b[37mwhite`, | ||||
|         data: '{"ts":1560336942459,"m":"\\u001b[30mblack\\u001b[37mwhite", "id":1}', | ||||
|       }); | ||||
| 
 | ||||
|       await wrapper.vm.$nextTick(); | ||||
|       vi.runAllTimers(); | ||||
|       await nextTick(); | ||||
| 
 | ||||
|       expect(wrapper.find("ul.events").html()).toMatchSnapshot(); | ||||
|     }); | ||||
| 
 | ||||
| @@ -177,10 +149,12 @@ describe("<LogEventSource />", () => { | ||||
|       const wrapper = createLogEventSource(); | ||||
|       sources["/api/logs/stream?id=abc&lastEventId="].emitOpen(); | ||||
|       sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({ | ||||
|         data: `2019-06-12T10:55:42.459034602Z <test>foo bar</test>`, | ||||
|         data: `{"ts":1560336942459, "m":"<test>foo bar</test>", "id":1}`, | ||||
|       }); | ||||
| 
 | ||||
|       await wrapper.vm.$nextTick(); | ||||
|       vi.runAllTimers(); | ||||
|       await nextTick(); | ||||
| 
 | ||||
|       expect(wrapper.find("ul.events").html()).toMatchSnapshot(); | ||||
|     }); | ||||
| 
 | ||||
| @@ -188,10 +162,12 @@ describe("<LogEventSource />", () => { | ||||
|       const wrapper = createLogEventSource({ hourStyle: "12" }); | ||||
|       sources["/api/logs/stream?id=abc&lastEventId="].emitOpen(); | ||||
|       sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({ | ||||
|         data: `2019-06-12T23:55:42.459034602Z <test>foo bar</test>`, | ||||
|         data: `{"ts":1560336942459, "m":"<test>foo bar</test>", "id":1}`, | ||||
|       }); | ||||
| 
 | ||||
|       await wrapper.vm.$nextTick(); | ||||
|       vi.runAllTimers(); | ||||
|       await nextTick(); | ||||
| 
 | ||||
|       expect(wrapper.find("ul.events").html()).toMatchSnapshot(); | ||||
|     }); | ||||
| 
 | ||||
| @@ -199,10 +175,12 @@ describe("<LogEventSource />", () => { | ||||
|       const wrapper = createLogEventSource({ hourStyle: "24" }); | ||||
|       sources["/api/logs/stream?id=abc&lastEventId="].emitOpen(); | ||||
|       sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({ | ||||
|         data: `2019-06-12T23:55:42.459034602Z <test>foo bar</test>`, | ||||
|         data: `{"ts":1560336942459, "m":"<test>foo bar</test>", "id":1}`, | ||||
|       }); | ||||
| 
 | ||||
|       await wrapper.vm.$nextTick(); | ||||
|       vi.runAllTimers(); | ||||
|       await nextTick(); | ||||
| 
 | ||||
|       expect(wrapper.find("ul.events").html()).toMatchSnapshot(); | ||||
|     }); | ||||
| 
 | ||||
| @@ -210,13 +188,15 @@ describe("<LogEventSource />", () => { | ||||
|       const wrapper = createLogEventSource({ searchFilter: "test" }); | ||||
|       sources["/api/logs/stream?id=abc&lastEventId="].emitOpen(); | ||||
|       sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({ | ||||
|         data: `2019-06-11T10:55:42.459034602Z Foo bar`, | ||||
|         data: `{"ts":1560336942459, "m":"foo bar", "id":1}`, | ||||
|       }); | ||||
|       sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({ | ||||
|         data: `2019-06-12T10:55:42.459034602Z This is a test <hi></hi>`, | ||||
|         data: `{"ts":1560336942459, "m":"test bar", "id":2}`, | ||||
|       }); | ||||
| 
 | ||||
|       await wrapper.vm.$nextTick(); | ||||
|       vi.runAllTimers(); | ||||
|       await nextTick(); | ||||
| 
 | ||||
|       expect(wrapper.find("ul.events").html()).toMatchSnapshot(); | ||||
|     }); | ||||
|   }); | ||||
							
								
								
									
										25
									
								
								assets/components/LogViewer/LogEventSource.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								assets/components/LogViewer/LogEventSource.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| <template> | ||||
|   <infinite-loader :onLoadMore="fetchMore" :enabled="messages.length > 100"></infinite-loader> | ||||
|   <slot :messages="messages"></slot> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { Container } from "@/models/Container"; | ||||
| import { type ComputedRef } from "vue"; | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
|   (e: "loading-more", value: boolean): void; | ||||
| }>(); | ||||
|  | ||||
| const container = inject("container") as ComputedRef<Container>; | ||||
| const { messages, loadOlderLogs } = useLogStream(container); | ||||
|  | ||||
| const beforeLoading = () => emit("loading-more", true); | ||||
| const afterLoading = () => emit("loading-more", false); | ||||
|  | ||||
| defineExpose({ | ||||
|   clear: () => (messages.value = []), | ||||
| }); | ||||
|  | ||||
| const fetchMore = () => loadOlderLogs({ beforeLoading, afterLoading }); | ||||
| </script> | ||||
							
								
								
									
										106
									
								
								assets/components/LogViewer/LogViewer.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								assets/components/LogViewer/LogViewer.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,106 @@ | ||||
| <template> | ||||
|   <ul class="events" ref="events" :class="{ 'disable-wrap': !softWrap, [size]: true }"> | ||||
|     <li | ||||
|       v-for="(item, index) in filtered" | ||||
|       :key="item.id" | ||||
|       :data-key="item.id" | ||||
|       :class="{ selected: toRaw(item) === toRaw(lastSelectedItem) }" | ||||
|     > | ||||
|       <div class="line-options" v-show="isSearching()"> | ||||
|         <dropdown-menu :class="{ 'is-last': index === filtered.length - 1 }" class="is-top minimal"> | ||||
|           <a class="dropdown-item" @click="handleJumpLineSelected($event, item)" :href="`#${item.id}`"> | ||||
|             <div class="level is-justify-content-start"> | ||||
|               <div class="level-left"> | ||||
|                 <div class="level-item"> | ||||
|                   <cil-find-in-page class="mr-4" /> | ||||
|                 </div> | ||||
|               </div> | ||||
|               <div class="level-right"> | ||||
|                 <div class="level-item">Jump to Context</div> | ||||
|               </div> | ||||
|             </div> | ||||
|           </a> | ||||
|         </dropdown-menu> | ||||
|       </div> | ||||
|       <component :is="item.getComponent()" :log-entry="item" :visible-keys="visibleKeys.value"></component> | ||||
|     </li> | ||||
|   </ul> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { type ComputedRef, toRaw } from "vue"; | ||||
| import { useRouteHash } from "@vueuse/router"; | ||||
| import { Container } from "@/models/Container"; | ||||
| import { type JSONObject, LogEntry } from "@/models/LogEntry"; | ||||
|  | ||||
| const props = defineProps<{ | ||||
|   messages: LogEntry<string | JSONObject>[]; | ||||
| }>(); | ||||
|  | ||||
| let visibleKeys = persistentVisibleKeys(inject("container") as ComputedRef<Container>); | ||||
|  | ||||
| const { filteredPayload } = useVisibleFilter(visibleKeys); | ||||
| const { filteredMessages, resetSearch, isSearching } = useSearchFilter(); | ||||
|  | ||||
| const { messages } = toRefs(props); | ||||
| const visible = filteredPayload(messages); | ||||
| const filtered = filteredMessages(visible); | ||||
|  | ||||
| const events = ref<HTMLElement>(); | ||||
| let lastSelectedItem: LogEntry<string | JSONObject> | undefined = $ref(undefined); | ||||
|  | ||||
| function handleJumpLineSelected(e: Event, item: LogEntry<string | JSONObject>) { | ||||
|   lastSelectedItem = item; | ||||
|   resetSearch(); | ||||
| } | ||||
|  | ||||
| const routeHash = useRouteHash(); | ||||
| watch( | ||||
|   routeHash, | ||||
|   (hash) => { | ||||
|     document.querySelector(`[data-key="${hash.substring(1)}"]`)?.scrollIntoView({ block: "center" }); | ||||
|   }, | ||||
|   { immediate: true, flush: "post" } | ||||
| ); | ||||
| </script> | ||||
| <style scoped lang="scss"> | ||||
| .events { | ||||
|   padding: 1em 0; | ||||
|   font-family: SFMono-Regular, Consolas, Liberation Mono, monaco, Menlo, monospace; | ||||
|  | ||||
|   & > li { | ||||
|     display: flex; | ||||
|     word-wrap: break-word; | ||||
|     padding: 0.2em 1em; | ||||
|     &:last-child { | ||||
|       scroll-snap-align: end; | ||||
|       scroll-margin-block-end: 5rem; | ||||
|     } | ||||
|     &:nth-child(odd) { | ||||
|       background-color: rgba(125, 125, 125, 0.08); | ||||
|     } | ||||
|  | ||||
|     &.selected { | ||||
|       border: 1px var(--secondary-color) solid; | ||||
|     } | ||||
|  | ||||
|     & > .line-options { | ||||
|       display: flex; | ||||
|       flex-direction: row-reverse; | ||||
|       margin-right: 1em; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &.small { | ||||
|     font-size: 60%; | ||||
|   } | ||||
|  | ||||
|   &.medium { | ||||
|     font-size: 80%; | ||||
|   } | ||||
|  | ||||
|   &.large { | ||||
|     font-size: 120%; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										21
									
								
								assets/components/LogViewer/LogViewerWithSource.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								assets/components/LogViewer/LogViewerWithSource.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| <template> | ||||
|   <log-event-source ref="source" #default="{ messages }" @loading-more="emit('loading-more', $event)"> | ||||
|     <log-viewer :messages="messages"></log-viewer> | ||||
|   </log-event-source> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import LogEventSource from "./LogEventSource.vue"; | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
|   (e: "loading-more", value: boolean): void; | ||||
| }>(); | ||||
|  | ||||
| const source = $ref<InstanceType<typeof LogEventSource>>(); | ||||
| function clear() { | ||||
|   source?.clear(); | ||||
| } | ||||
| defineExpose({ | ||||
|   clear, | ||||
| }); | ||||
| </script> | ||||
							
								
								
									
										32
									
								
								assets/components/LogViewer/SimpleLogItem.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								assets/components/LogViewer/SimpleLogItem.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| <template> | ||||
|   <div class="columns is-1 is-variable"> | ||||
|     <div class="column is-narrow" v-if="showTimestamp"> | ||||
|       <log-date :date="logEntry.date"></log-date> | ||||
|     </div> | ||||
|     <div class="text column" v-html="colorize(logEntry.message)"></div> | ||||
|   </div> | ||||
| </template> | ||||
| <script lang="ts" setup> | ||||
| import { SimpleLogEntry } from "@/models/LogEntry"; | ||||
| import AnsiConvertor from "ansi-to-html"; | ||||
|  | ||||
| const ansiConvertor = new AnsiConvertor({ escapeXML: true }); | ||||
| defineProps<{ | ||||
|   logEntry: SimpleLogEntry; | ||||
| }>(); | ||||
|  | ||||
| const { markSearch } = useSearchFilter(); | ||||
| const colorize = (value: string) => markSearch(ansiConvertor.toHtml(value)); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| .disable-wrap { | ||||
|   .text { | ||||
|     white-space: nowrap; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .text { | ||||
|   white-space: pre-wrap; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										23
									
								
								assets/components/LogViewer/SkippedEntriesLogItem.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								assets/components/LogViewer/SkippedEntriesLogItem.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| <template> | ||||
|   <div class="is-flex-grow-1 has-text-centered my-4"> | ||||
|     <div class="is-relative"> | ||||
|       <zig-zag class="is-overlay mt-2"></zig-zag> | ||||
|       <span class="text is-relative py-2 px-4">{{ $t("error.logs-skipped", { total: logEntry.totalSkipped }) }}</span> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| <script lang="ts" setup> | ||||
| import { SkippedLogsEntry } from "@/models/LogEntry"; | ||||
|  | ||||
| defineProps<{ | ||||
|   logEntry: SkippedLogsEntry; | ||||
| }>(); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| .text { | ||||
|   font-weight: bold; | ||||
|   white-space: pre-wrap; | ||||
|   background-color: var(--body-background-color); | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										35
									
								
								assets/components/LogViewer/StatSparkline.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								assets/components/LogViewer/StatSparkline.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| <template> | ||||
|   <svg :width="width" :height="height"> | ||||
|     <path :d="path" class="area" /> | ||||
|   </svg> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { extent } from "d3-array"; | ||||
| import { scaleLinear } from "d3-scale"; | ||||
| import { area, curveStep } from "d3-shape"; | ||||
|  | ||||
| const d3 = { extent, scaleLinear, area, curveStep }; | ||||
| const { data, width = 150, height = 30 } = defineProps<{ data: Point[]; width?: number; height?: number }>(); | ||||
| const x = d3.scaleLinear().range([0, width]); | ||||
| const y = d3.scaleLinear().range([height, 0]); | ||||
|  | ||||
| const shape = d3 | ||||
|   .area<Point>() | ||||
|   .curve(d3.curveStep) | ||||
|   .x((d) => x(d.x)) | ||||
|   .y0(height) | ||||
|   .y1((d) => y(d.y)); | ||||
|  | ||||
| const path = computed(() => { | ||||
|   x.domain(d3.extent(data, (d) => d.x) as [number, number]); | ||||
|   y.domain(d3.extent(data, (d) => d.y) as [number, number]); | ||||
|   return shape(data) ?? ""; | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| :deep(.area) { | ||||
|   fill: var(--primary-color); | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										19
									
								
								assets/components/LogViewer/ZigZag.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								assets/components/LogViewer/ZigZag.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| <template> | ||||
|   <svg width="100%" height="8" class="zigzag"> | ||||
|     <defs> | ||||
|       <pattern id="zigzag" x="0" y="0" width="30" height="8" patternUnits="userSpaceOnUse"> | ||||
|         <line x1="0" y1="0" x2="15" y2="8" class="line" /> | ||||
|         <line x1="15" y1="8" x2="30" y2="0" class="line" /> | ||||
|       </pattern> | ||||
|     </defs> | ||||
|     <rect x="0" y="0" width="100%" height="100%" fill="url(#zigzag)"></rect> | ||||
|   </svg> | ||||
| </template> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| .line { | ||||
|   stroke: var(--primary-color); | ||||
|   stroke-width: 1; | ||||
|   stroke-linecap: round; | ||||
| } | ||||
| </style> | ||||
| @@ -0,0 +1,206 @@ | ||||
| // Vitest Snapshot v1 | ||||
|  | ||||
| exports[`<LogEventSource /> > render html correctly > should render dates with 12 hour style 1`] = ` | ||||
| "<ul class=\\"events medium\\" data-v-2e92daca=\\"\\"> | ||||
|   <li data-key=\\"1\\" class=\\"\\" data-v-2e92daca=\\"\\"> | ||||
|     <div class=\\"line-options\\" data-v-2e92daca=\\"\\" style=\\"display: none;\\"> | ||||
|       <div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-2e92daca=\\"\\"> | ||||
|         <div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div> | ||||
|         <div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\"> | ||||
|           <div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-2e92daca=\\"\\"> | ||||
|               <div class=\\"level is-justify-content-start\\" data-v-2e92daca=\\"\\"> | ||||
|                 <div class=\\"level-left\\" data-v-2e92daca=\\"\\"> | ||||
|                   <div class=\\"level-item\\" data-v-2e92daca=\\"\\"><svg viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-2e92daca=\\"\\"> | ||||
|                       <path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path> | ||||
|                       <path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path> | ||||
|                     </svg></div> | ||||
|                 </div> | ||||
|                 <div class=\\"level-right\\" data-v-2e92daca=\\"\\"> | ||||
|                   <div class=\\"level-item\\" data-v-2e92daca=\\"\\">Jump to Context</div> | ||||
|                 </div> | ||||
|               </div> | ||||
|             </a></div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|     <div class=\\"columns is-1 is-variable\\" visible-keys=\\"\\" data-v-a49e52d4=\\"\\" data-v-2e92daca=\\"\\"> | ||||
|       <div class=\\"column is-narrow\\" data-v-a49e52d4=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" class=\\"date\\" data-v-de513450=\\"\\" data-v-a49e52d4=\\"\\">06/12/2019 10:55:42 AM</time></div> | ||||
|       <div class=\\"text column\\" data-v-a49e52d4=\\"\\"><test>foo bar</test></div> | ||||
|     </div> | ||||
|   </li> | ||||
| </ul>" | ||||
| `; | ||||
|  | ||||
| exports[`<LogEventSource /> > render html correctly > should render dates with 24 hour style 1`] = ` | ||||
| "<ul class=\\"events medium\\" data-v-2e92daca=\\"\\"> | ||||
|   <li data-key=\\"1\\" class=\\"\\" data-v-2e92daca=\\"\\"> | ||||
|     <div class=\\"line-options\\" data-v-2e92daca=\\"\\" style=\\"display: none;\\"> | ||||
|       <div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-2e92daca=\\"\\"> | ||||
|         <div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div> | ||||
|         <div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\"> | ||||
|           <div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-2e92daca=\\"\\"> | ||||
|               <div class=\\"level is-justify-content-start\\" data-v-2e92daca=\\"\\"> | ||||
|                 <div class=\\"level-left\\" data-v-2e92daca=\\"\\"> | ||||
|                   <div class=\\"level-item\\" data-v-2e92daca=\\"\\"><svg viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-2e92daca=\\"\\"> | ||||
|                       <path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path> | ||||
|                       <path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path> | ||||
|                     </svg></div> | ||||
|                 </div> | ||||
|                 <div class=\\"level-right\\" data-v-2e92daca=\\"\\"> | ||||
|                   <div class=\\"level-item\\" data-v-2e92daca=\\"\\">Jump to Context</div> | ||||
|                 </div> | ||||
|               </div> | ||||
|             </a></div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|     <div class=\\"columns is-1 is-variable\\" visible-keys=\\"\\" data-v-a49e52d4=\\"\\" data-v-2e92daca=\\"\\"> | ||||
|       <div class=\\"column is-narrow\\" data-v-a49e52d4=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" class=\\"date\\" data-v-de513450=\\"\\" data-v-a49e52d4=\\"\\">06/12/2019 10:55:42</time></div> | ||||
|       <div class=\\"text column\\" data-v-a49e52d4=\\"\\"><test>foo bar</test></div> | ||||
|     </div> | ||||
|   </li> | ||||
| </ul>" | ||||
| `; | ||||
|  | ||||
| exports[`<LogEventSource /> > render html correctly > should render messages 1`] = ` | ||||
| "<ul class=\\"events medium\\" data-v-2e92daca=\\"\\"> | ||||
|   <li data-key=\\"1\\" class=\\"\\" data-v-2e92daca=\\"\\"> | ||||
|     <div class=\\"line-options\\" data-v-2e92daca=\\"\\" style=\\"display: none;\\"> | ||||
|       <div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-2e92daca=\\"\\"> | ||||
|         <div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div> | ||||
|         <div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\"> | ||||
|           <div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-2e92daca=\\"\\"> | ||||
|               <div class=\\"level is-justify-content-start\\" data-v-2e92daca=\\"\\"> | ||||
|                 <div class=\\"level-left\\" data-v-2e92daca=\\"\\"> | ||||
|                   <div class=\\"level-item\\" data-v-2e92daca=\\"\\"><svg viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-2e92daca=\\"\\"> | ||||
|                       <path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path> | ||||
|                       <path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path> | ||||
|                     </svg></div> | ||||
|                 </div> | ||||
|                 <div class=\\"level-right\\" data-v-2e92daca=\\"\\"> | ||||
|                   <div class=\\"level-item\\" data-v-2e92daca=\\"\\">Jump to Context</div> | ||||
|                 </div> | ||||
|               </div> | ||||
|             </a></div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|     <div class=\\"columns is-1 is-variable\\" visible-keys=\\"\\" data-v-a49e52d4=\\"\\" data-v-2e92daca=\\"\\"> | ||||
|       <div class=\\"column is-narrow\\" data-v-a49e52d4=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" class=\\"date\\" data-v-de513450=\\"\\" data-v-a49e52d4=\\"\\">06/12/2019 10:55:42 AM</time></div> | ||||
|       <div class=\\"text column\\" data-v-a49e52d4=\\"\\">This is a message.</div> | ||||
|     </div> | ||||
|   </li> | ||||
| </ul>" | ||||
| `; | ||||
|  | ||||
| exports[`<LogEventSource /> > render html correctly > should render messages with color 1`] = ` | ||||
| "<ul class=\\"events medium\\" data-v-2e92daca=\\"\\"> | ||||
|   <li data-key=\\"1\\" class=\\"\\" data-v-2e92daca=\\"\\"> | ||||
|     <div class=\\"line-options\\" data-v-2e92daca=\\"\\" style=\\"display: none;\\"> | ||||
|       <div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-2e92daca=\\"\\"> | ||||
|         <div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div> | ||||
|         <div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\"> | ||||
|           <div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-2e92daca=\\"\\"> | ||||
|               <div class=\\"level is-justify-content-start\\" data-v-2e92daca=\\"\\"> | ||||
|                 <div class=\\"level-left\\" data-v-2e92daca=\\"\\"> | ||||
|                   <div class=\\"level-item\\" data-v-2e92daca=\\"\\"><svg viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-2e92daca=\\"\\"> | ||||
|                       <path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path> | ||||
|                       <path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path> | ||||
|                     </svg></div> | ||||
|                 </div> | ||||
|                 <div class=\\"level-right\\" data-v-2e92daca=\\"\\"> | ||||
|                   <div class=\\"level-item\\" data-v-2e92daca=\\"\\">Jump to Context</div> | ||||
|                 </div> | ||||
|               </div> | ||||
|             </a></div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|     <div class=\\"columns is-1 is-variable\\" visible-keys=\\"\\" data-v-a49e52d4=\\"\\" data-v-2e92daca=\\"\\"> | ||||
|       <div class=\\"column is-narrow\\" data-v-a49e52d4=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" class=\\"date\\" data-v-de513450=\\"\\" data-v-a49e52d4=\\"\\">06/12/2019 10:55:42 AM</time></div> | ||||
|       <div class=\\"text column\\" data-v-a49e52d4=\\"\\"><span style=\\"color:#000\\">black<span style=\\"color:#AAA\\">white</span></span></div> | ||||
|     </div> | ||||
|   </li> | ||||
| </ul>" | ||||
| `; | ||||
|  | ||||
| exports[`<LogEventSource /> > render html correctly > should render messages with filter 1`] = ` | ||||
| "<ul class=\\"events medium\\" data-v-2e92daca=\\"\\"> | ||||
|   <li data-key=\\"2\\" class=\\"\\" data-v-2e92daca=\\"\\"> | ||||
|     <div class=\\"line-options\\" data-v-2e92daca=\\"\\"> | ||||
|       <div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-2e92daca=\\"\\"> | ||||
|         <div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div> | ||||
|         <div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\"> | ||||
|           <div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#2\\" data-v-2e92daca=\\"\\"> | ||||
|               <div class=\\"level is-justify-content-start\\" data-v-2e92daca=\\"\\"> | ||||
|                 <div class=\\"level-left\\" data-v-2e92daca=\\"\\"> | ||||
|                   <div class=\\"level-item\\" data-v-2e92daca=\\"\\"><svg viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-2e92daca=\\"\\"> | ||||
|                       <path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path> | ||||
|                       <path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path> | ||||
|                     </svg></div> | ||||
|                 </div> | ||||
|                 <div class=\\"level-right\\" data-v-2e92daca=\\"\\"> | ||||
|                   <div class=\\"level-item\\" data-v-2e92daca=\\"\\">Jump to Context</div> | ||||
|                 </div> | ||||
|               </div> | ||||
|             </a></div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|     <div class=\\"columns is-1 is-variable\\" visible-keys=\\"\\" data-v-a49e52d4=\\"\\" data-v-2e92daca=\\"\\"> | ||||
|       <div class=\\"column is-narrow\\" data-v-a49e52d4=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" class=\\"date\\" data-v-de513450=\\"\\" data-v-a49e52d4=\\"\\">06/12/2019 10:55:42 AM</time></div> | ||||
|       <div class=\\"text column\\" data-v-a49e52d4=\\"\\"><mark>test</mark> bar</div> | ||||
|     </div> | ||||
|   </li> | ||||
| </ul>" | ||||
| `; | ||||
|  | ||||
| exports[`<LogEventSource /> > render html correctly > should render messages with html entities 1`] = ` | ||||
| "<ul class=\\"events medium\\" data-v-2e92daca=\\"\\"> | ||||
|   <li data-key=\\"1\\" class=\\"\\" data-v-2e92daca=\\"\\"> | ||||
|     <div class=\\"line-options\\" data-v-2e92daca=\\"\\" style=\\"display: none;\\"> | ||||
|       <div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-2e92daca=\\"\\"> | ||||
|         <div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div> | ||||
|         <div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\"> | ||||
|           <div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-2e92daca=\\"\\"> | ||||
|               <div class=\\"level is-justify-content-start\\" data-v-2e92daca=\\"\\"> | ||||
|                 <div class=\\"level-left\\" data-v-2e92daca=\\"\\"> | ||||
|                   <div class=\\"level-item\\" data-v-2e92daca=\\"\\"><svg viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-2e92daca=\\"\\"> | ||||
|                       <path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path> | ||||
|                       <path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path> | ||||
|                     </svg></div> | ||||
|                 </div> | ||||
|                 <div class=\\"level-right\\" data-v-2e92daca=\\"\\"> | ||||
|                   <div class=\\"level-item\\" data-v-2e92daca=\\"\\">Jump to Context</div> | ||||
|                 </div> | ||||
|               </div> | ||||
|             </a></div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|     <div class=\\"columns is-1 is-variable\\" visible-keys=\\"\\" data-v-a49e52d4=\\"\\" data-v-2e92daca=\\"\\"> | ||||
|       <div class=\\"column is-narrow\\" data-v-a49e52d4=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" class=\\"date\\" data-v-de513450=\\"\\" data-v-a49e52d4=\\"\\">06/12/2019 10:55:42 AM</time></div> | ||||
|       <div class=\\"text column\\" data-v-a49e52d4=\\"\\"><test>foo bar</test></div> | ||||
|     </div> | ||||
|   </li> | ||||
| </ul>" | ||||
| `; | ||||
|  | ||||
| exports[`<LogEventSource /> > renders correctly 1`] = ` | ||||
| "<div class=\\"infinte-loader\\" data-v-1cd63c6e=\\"\\"> | ||||
|   <div class=\\"spinner\\" data-v-1cd63c6e=\\"\\" style=\\"display: none;\\"> | ||||
|     <div class=\\"bounce1\\" data-v-1cd63c6e=\\"\\"></div> | ||||
|     <div class=\\"bounce2\\" data-v-1cd63c6e=\\"\\"></div> | ||||
|     <div class=\\"bounce3\\" data-v-1cd63c6e=\\"\\"></div> | ||||
|   </div> | ||||
| </div> | ||||
| <ul class=\\"events medium\\" data-v-2e92daca=\\"\\"></ul>" | ||||
| `; | ||||
|  | ||||
| exports[`<LogEventSource /> > should parse messages 1`] = ` | ||||
| SimpleLogEntry { | ||||
|   "_message": "This is a message.", | ||||
|   "date": 2019-06-12T10:55:42.459Z, | ||||
|   "id": 1, | ||||
| } | ||||
| `; | ||||
| @@ -1,26 +0,0 @@ | ||||
| <template> | ||||
|   <log-event-source ref="source" :id="id" #default="{ messages }" @loading-more="emit('loading-more', $event)"> | ||||
|     <log-viewer :messages="messages"></log-viewer> | ||||
|   </log-event-source> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import LogViewer from "./LogViewer.vue"; | ||||
| import { ref } from "vue"; | ||||
| defineProps({ | ||||
|   id: { | ||||
|     type: String, | ||||
|     required: true, | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(["loading-more"]); | ||||
|  | ||||
| const source = ref<InstanceType<typeof LogViewer>>(); | ||||
| function clear() { | ||||
|   source.value?.clear(); | ||||
| } | ||||
| defineExpose({ | ||||
|   clear, | ||||
| }); | ||||
| </script> | ||||
| @@ -2,7 +2,7 @@ | ||||
|   <aside> | ||||
|     <div class="columns is-marginless is-gapless is-mobile is-vcentered"> | ||||
|       <div class="column is-narrow"> | ||||
|         <router-link :to="{ name: 'default' }"> | ||||
|         <router-link :to="{ name: 'index' }"> | ||||
|           <svg class="logo"> | ||||
|             <use href="#logo"></use> | ||||
|           </svg> | ||||
| @@ -24,11 +24,11 @@ | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <p class="menu-label is-hidden-mobile" :class="{ 'is-active': showNav }">Containers</p> | ||||
|     <p class="menu-label is-hidden-mobile" :class="{ 'is-active': showNav }">{{ $t("label.containers") }}</p> | ||||
|     <ul class="menu-list is-hidden-mobile" :class="{ 'is-active': showNav }"> | ||||
|       <li v-for="item in visibleContainers" :key="item.id"> | ||||
|         <router-link | ||||
|           :to="{ name: 'container', params: { id: item.id, name: item.name } }" | ||||
|           :to="{ name: 'container-id', params: { id: item.id } }" | ||||
|           active-class="is-active" | ||||
|           :title="item.name" | ||||
|         > | ||||
| @@ -42,19 +42,14 @@ | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { ref, watch } from "vue"; | ||||
| import { useContainerStore } from "@/stores/container"; | ||||
| import { storeToRefs } from "pinia"; | ||||
| import { useRoute } from "vue-router"; | ||||
|  | ||||
| const store = useContainerStore(); | ||||
| const route = useRoute(); | ||||
| const { visibleContainers, allContainersById } = storeToRefs(store); | ||||
|  | ||||
| const showNav = ref(false); | ||||
| let showNav = $ref(false); | ||||
|  | ||||
| watch(route, () => { | ||||
|   showNav.value = false; | ||||
|   showNav = false; | ||||
| }); | ||||
| </script> | ||||
| <style scoped lang="scss"> | ||||
|   | ||||
| @@ -3,20 +3,15 @@ | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { useIntervalFn } from "@vueuse/core"; | ||||
| import formatDistance from "date-fns/formatDistance"; | ||||
| import { PropType, ref } from "vue"; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   date: { | ||||
|     required: true, | ||||
|     type: Object as PropType<Date>, | ||||
|   }, | ||||
| }); | ||||
| const { date } = defineProps<{ | ||||
|   date: Date; | ||||
| }>(); | ||||
|  | ||||
| const text = ref<string>(); | ||||
| function updateFromNow() { | ||||
|   text.value = formatDistance(props.date, new Date(), { | ||||
|   text.value = formatDistance(date, new Date(), { | ||||
|     addSuffix: true, | ||||
|   }); | ||||
| } | ||||
|   | ||||
| @@ -1,44 +1,21 @@ | ||||
| <template> | ||||
|   <time :datetime="date.toISOString()">{{ relativeTime(date, locale) }}</time> | ||||
|   <time :datetime="date.toISOString()">{{ format(date) }}</time> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| const use24Hr = | ||||
|   new Intl.DateTimeFormat(undefined, { | ||||
|     hour: "numeric", | ||||
|   }) | ||||
|     .formatToParts(new Date(2020, 0, 1, 13)) | ||||
|     .find((part) => part.type === "hour")?.value.length === 2; | ||||
|  | ||||
| const auto = use24Hr ? enGB : enUS; | ||||
| const styles = { auto, 12: enUS, 24: enGB }; | ||||
| </script> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { formatRelative } from "date-fns"; | ||||
| import { hourStyle } from "@/composables/settings"; | ||||
| import enGB from "date-fns/locale/en-GB"; | ||||
| import enUS from "date-fns/locale/en-US"; | ||||
| import { computed, PropType } from "vue"; | ||||
| defineProps({ | ||||
|   date: { | ||||
|     required: true, | ||||
|     type: Object as PropType<Date>, | ||||
|   }, | ||||
| }); | ||||
| defineProps<{ | ||||
|   date: Date; | ||||
| }>(); | ||||
|  | ||||
| const locale = computed(() => { | ||||
|   const locale = styles[hourStyle.value]; | ||||
|   const oldFormatter = locale.formatRelative as (d: Date | number) => string; | ||||
|   return { | ||||
|     ...locale, | ||||
|     formatRelative(date: Date | number) { | ||||
|       return oldFormatter(date) + "p"; | ||||
|     }, | ||||
|   }; | ||||
| }); | ||||
| const dateFormatter = new Intl.DateTimeFormat(undefined, { day: "2-digit", month: "2-digit", year: "numeric" }); | ||||
| const use12Hour = $computed(() => ({ auto: undefined, "12": true, "24": false }[hourStyle.value])); | ||||
| const timeFormatter = $computed( | ||||
|   () => new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit", second: "2-digit", hour12: use12Hour }) | ||||
| ); | ||||
|  | ||||
| function relativeTime(date: Date, locale: Locale) { | ||||
|   return formatRelative(date, new Date(), { locale }); | ||||
| function format(date: Date) { | ||||
|   const dateStr = dateFormatter.format(date); | ||||
|   const timeStr = timeFormatter.format(date); | ||||
|   return `${dateStr} ${timeStr}`; | ||||
| } | ||||
| </script> | ||||
|   | ||||
| @@ -18,21 +18,10 @@ | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { useContainerStore } from "@/stores/container"; | ||||
| import { useScroll } from "@vueuse/core"; | ||||
| import { storeToRefs } from "pinia"; | ||||
| import { onMounted, ref, watch, watchPostEffect } from "vue"; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   indeterminate: { | ||||
|     default: false, | ||||
|     type: Boolean, | ||||
|   }, | ||||
|   autoHide: { | ||||
|     default: true, | ||||
|     type: Boolean, | ||||
|   }, | ||||
| }); | ||||
| const { indeterminate = false, autoHide = false } = defineProps<{ | ||||
|   indeterminate?: boolean; | ||||
|   autoHide?: boolean; | ||||
| }>(); | ||||
|  | ||||
| const scrollProgress = ref(0); | ||||
| const animation = ref({ cancel: () => {} }); | ||||
| @@ -59,7 +48,7 @@ watchPostEffect(() => { | ||||
|       : (scrollElement.value as HTMLElement); | ||||
|   scrollProgress.value = scrollY.value / (parent.scrollHeight - parent.clientHeight); | ||||
|   animation.value.cancel(); | ||||
|   if (props.autoHide && root.value) { | ||||
|   if (autoHide && root.value) { | ||||
|     animation.value = root.value.animate( | ||||
|       { opacity: [1, 0] }, | ||||
|       { | ||||
|   | ||||
| @@ -16,7 +16,7 @@ | ||||
|  | ||||
|     <div class="is-scrollbar-notification"> | ||||
|       <transition name="fade"> | ||||
|         <button class="button" :class="hasMore ? 'has-more' : ''" @click="scrollToBottom('instant')" v-show="paused"> | ||||
|         <button class="button" :class="hasMore ? 'has-more' : ''" @click="scrollToBottom()" v-show="paused"> | ||||
|           <mdi-light-chevron-double-down /> | ||||
|         </button> | ||||
|       </transition> | ||||
| @@ -24,61 +24,46 @@ | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| export default { | ||||
|   props: { | ||||
|     scrollable: { | ||||
|       type: Boolean, | ||||
|       default: true, | ||||
|     }, | ||||
|   }, | ||||
| <script lang="ts" setup> | ||||
| const { scrollable = false } = defineProps<{ scrollable?: boolean }>(); | ||||
|  | ||||
|   name: "ScrollableView", | ||||
|   data() { | ||||
|     return { | ||||
|       paused: false, | ||||
|       hasMore: false, | ||||
|       loading: false, | ||||
|       mutationObserver: null, | ||||
|       intersectionObserver: null, | ||||
|     }; | ||||
|   }, | ||||
|   mounted() { | ||||
|     const { scrollableContent } = this.$refs; | ||||
|     this.mutationObserver = new MutationObserver((e) => { | ||||
|       if (!this.paused) { | ||||
|         this.scrollToBottom("instant"); | ||||
|       } else { | ||||
|         const record = e[e.length - 1]; | ||||
|         if ( | ||||
|           record.target.children[record.target.children.length - 1] == record.addedNodes[record.addedNodes.length - 1] | ||||
|         ) { | ||||
|           this.hasMore = true; | ||||
|         } | ||||
|       } | ||||
|     }); | ||||
|     this.mutationObserver.observe(scrollableContent, { childList: true, subtree: true }); | ||||
| let paused = $ref(false); | ||||
| let hasMore = $ref(false); | ||||
| let loading = $ref(false); | ||||
| const scrollObserver = ref<HTMLElement>(); | ||||
| const scrollableContent = ref<HTMLElement>(); | ||||
|  | ||||
|     this.intersectionObserver = new IntersectionObserver( | ||||
|       (entries) => (this.paused = entries[0].intersectionRatio == 0), | ||||
|       { threshholds: [0, 1], rootMargin: "80px 0px" } | ||||
|     ); | ||||
|     this.intersectionObserver.observe(this.$refs.scrollObserver); | ||||
|   }, | ||||
|   beforeUnmount() { | ||||
|     this.mutationObserver.disconnect(); | ||||
|     this.intersectionObserver.disconnect(); | ||||
|   }, | ||||
|   methods: { | ||||
|     scrollToBottom(behavior = "instant") { | ||||
|       this.$refs.scrollObserver.scrollIntoView({ behavior }); | ||||
|       this.hasMore = false; | ||||
|     }, | ||||
|     setLoading(loading) { | ||||
|       this.loading = loading; | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| provide("scrollingPaused", $$(paused)); | ||||
|  | ||||
| const mutationObserver = new MutationObserver((e) => { | ||||
|   if (!paused) { | ||||
|     scrollToBottom(); | ||||
|   } else { | ||||
|     const record = e[e.length - 1]; | ||||
|     if (record.target.children[record.target.children.length - 1] == record.addedNodes[record.addedNodes.length - 1]) { | ||||
|       hasMore = true; | ||||
|     } | ||||
|   } | ||||
| }); | ||||
|  | ||||
| const intersectionObserver = new IntersectionObserver((entries) => (paused = entries[0].intersectionRatio == 0), { | ||||
|   threshold: [0, 1], | ||||
|   rootMargin: "80px 0px", | ||||
| }); | ||||
|  | ||||
| onMounted(() => { | ||||
|   mutationObserver.observe(scrollableContent.value!, { childList: true, subtree: true }); | ||||
|   intersectionObserver.observe(scrollObserver.value!); | ||||
| }); | ||||
|  | ||||
| function scrollToBottom(behavior: "auto" | "smooth" = "auto") { | ||||
|   scrollObserver.value?.scrollIntoView({ behavior }); | ||||
|   hasMore = false; | ||||
| } | ||||
|  | ||||
| function setLoading(value: boolean) { | ||||
|   loading = value; | ||||
| } | ||||
| </script> | ||||
| <style scoped lang="scss"> | ||||
| section { | ||||
| @@ -90,6 +75,7 @@ section { | ||||
|     top: 0; | ||||
|     background: var(--body-background-color); | ||||
|     border-bottom: 1px solid rgba(255, 255, 255, 0.05); | ||||
|     z-index: 1; | ||||
|   } | ||||
|  | ||||
|   &.is-full-height-scrollable { | ||||
|   | ||||
| @@ -22,35 +22,18 @@ | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import hotkeys from "hotkeys-js"; | ||||
|  | ||||
| import { search } from "@/composables/settings"; | ||||
| import { useSearchFilter } from "@/composables/search"; | ||||
| import { ref, nextTick, onMounted, onUnmounted } from "vue"; | ||||
|  | ||||
| const input = ref<HTMLInputElement>(); | ||||
| const { searchFilter, showSearch } = useSearchFilter(); | ||||
| const { searchFilter, showSearch, resetSearch } = useSearchFilter(); | ||||
|  | ||||
| onMounted(() => { | ||||
|   hotkeys("command+f, ctrl+f", (event, handler) => { | ||||
| onKeyStroke("f", (e) => { | ||||
|   if (e.ctrlKey || e.metaKey) { | ||||
|     showSearch.value = true; | ||||
|     nextTick(() => input.value?.focus() || input.value?.select()); | ||||
|     event.preventDefault(); | ||||
|   }); | ||||
|   hotkeys("esc", () => resetSearch()); | ||||
|     e.preventDefault(); | ||||
|   } | ||||
| }); | ||||
|  | ||||
| onUnmounted(() => { | ||||
|   searchFilter.value = ""; | ||||
|   showSearch.value = false; | ||||
|   hotkeys.unbind("command+f, ctrl+f"); | ||||
|   hotkeys.unbind("esc"); | ||||
| }); | ||||
|  | ||||
| function resetSearch() { | ||||
|   searchFilter.value = ""; | ||||
|   showSearch.value = false; | ||||
| } | ||||
| onUnmounted(() => resetSearch()); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   | ||||
| @@ -2,14 +2,14 @@ | ||||
|   <aside> | ||||
|     <div class="columns is-marginless"> | ||||
|       <div class="column is-paddingless"> | ||||
|         <router-link :to="{ name: 'default' }"> | ||||
|         <router-link :to="{ name: 'index' }"> | ||||
|           <svg class="logo"> | ||||
|             <use href="#logo"></use> | ||||
|           </svg> | ||||
|         </router-link> | ||||
|       </div> | ||||
|       <div class="column is-narrow has-text-right px-1"> | ||||
|         <button class="button is-rounded" @click="$emit('search')" title="Search containers (⌘ + k, ⌃k)"> | ||||
|         <button class="button is-rounded" @click="$emit('search')" title="$t('tooltip.search')"> | ||||
|           <span class="icon"> | ||||
|             <mdi-light-magnify /> | ||||
|           </span> | ||||
| @@ -23,11 +23,11 @@ | ||||
|         </router-link> | ||||
|       </div> | ||||
|     </div> | ||||
|     <p class="menu-label is-hidden-mobile">Containers</p> | ||||
|     <ul class="menu-list is-hidden-mobile"> | ||||
|     <p class="menu-label is-hidden-mobile">{{ $t("label.containers") }}</p> | ||||
|     <ul class="menu-list is-hidden-mobile" v-if="ready"> | ||||
|       <li v-for="item in visibleContainers" :key="item.id" :class="item.state"> | ||||
|         <router-link | ||||
|           :to="{ name: 'container', params: { id: item.id, name: item.name } }" | ||||
|           :to="{ name: 'container-id', params: { id: item.id } }" | ||||
|           active-class="is-active" | ||||
|           :title="item.name" | ||||
|         > | ||||
| @@ -40,7 +40,7 @@ | ||||
|                 class="icon is-small" | ||||
|                 @click.stop.prevent="store.appendActiveContainer(item)" | ||||
|                 v-show="!activeContainersById[item.id]" | ||||
|                 title="Pin as column" | ||||
|                 title="$t('tooltip.pin-column')" | ||||
|               > | ||||
|                 <cil-columns /> | ||||
|               </span> | ||||
| @@ -49,18 +49,18 @@ | ||||
|         </router-link> | ||||
|       </li> | ||||
|     </ul> | ||||
|     <ul class="menu-list is-hidden-mobile loading" v-else> | ||||
|       <li v-for="index in 7" class="my-4"><o-skeleton animated size="large" :key="index"></o-skeleton></li> | ||||
|     </ul> | ||||
|   </aside> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { computed } from "vue"; | ||||
| import { storeToRefs } from "pinia"; | ||||
| import { useContainerStore } from "@/stores/container"; | ||||
| import type { Container } from "@/types/Container"; | ||||
|  | ||||
| const store = useContainerStore(); | ||||
|  | ||||
| const { activeContainers, visibleContainers } = storeToRefs(store); | ||||
| const { activeContainers, visibleContainers, ready } = storeToRefs(store); | ||||
|  | ||||
| const activeContainersById = computed(() => | ||||
|   activeContainers.value.reduce((acc, item) => { | ||||
| @@ -82,6 +82,10 @@ aside { | ||||
|   } | ||||
| } | ||||
|  | ||||
| .loading { | ||||
|   opacity: 0.5; | ||||
| } | ||||
|  | ||||
| li.exited a { | ||||
|   color: #777; | ||||
| } | ||||
|   | ||||
| @@ -1,69 +0,0 @@ | ||||
| // Vitest Snapshot v1 | ||||
|  | ||||
| exports[`<LogEventSource /> > render html correctly > should render dates with 12 hour style 1`] = ` | ||||
| "<ul class=\\"events medium\\" data-v-28f125ea=\\"\\"> | ||||
|   <li data-v-28f125ea=\\"\\"><span class=\\"date\\" data-v-28f125ea=\\"\\"><time datetime=\\"2019-06-12T23:55:42.459Z\\" data-v-28f125ea=\\"\\">today at 11:55:42 PM</time></span><span class=\\"text\\" data-v-28f125ea=\\"\\"><test>foo bar</test></span></li> | ||||
| </ul>" | ||||
| `; | ||||
|  | ||||
| exports[`<LogEventSource /> > render html correctly > should render dates with 24 hour style 1`] = ` | ||||
| "<ul class=\\"events medium\\" data-v-28f125ea=\\"\\"> | ||||
|   <li data-v-28f125ea=\\"\\"><span class=\\"date\\" data-v-28f125ea=\\"\\"><time datetime=\\"2019-06-12T23:55:42.459Z\\" data-v-28f125ea=\\"\\">today at 23:55:42</time></span><span class=\\"text\\" data-v-28f125ea=\\"\\"><test>foo bar</test></span></li> | ||||
| </ul>" | ||||
| `; | ||||
|  | ||||
| exports[`<LogEventSource /> > render html correctly > should render messages 1`] = ` | ||||
| "<ul class=\\"events medium\\" data-v-28f125ea=\\"\\"> | ||||
|   <li data-v-28f125ea=\\"\\"><span class=\\"date\\" data-v-28f125ea=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-28f125ea=\\"\\">today at 10:55:42 AM</time></span><span class=\\"text\\" data-v-28f125ea=\\"\\">\\"This is a message.\\"</span></li> | ||||
| </ul>" | ||||
| `; | ||||
|  | ||||
| exports[`<LogEventSource /> > render html correctly > should render messages with color 1`] = ` | ||||
| "<ul class=\\"events medium\\" data-v-28f125ea=\\"\\"> | ||||
|   <li data-v-28f125ea=\\"\\"><span class=\\"date\\" data-v-28f125ea=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-28f125ea=\\"\\">today at 10:55:42 AM</time></span><span class=\\"text\\" data-v-28f125ea=\\"\\"><span style=\\"color:#000\\">black<span style=\\"color:#AAA\\">white</span></span></span></li> | ||||
| </ul>" | ||||
| `; | ||||
|  | ||||
| exports[`<LogEventSource /> > render html correctly > should render messages with filter 1`] = ` | ||||
| "<ul class=\\"events medium\\" data-v-28f125ea=\\"\\"> | ||||
|   <li data-v-28f125ea=\\"\\"><span class=\\"date\\" data-v-28f125ea=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-28f125ea=\\"\\">today at 10:55:42 AM</time></span><span class=\\"text\\" data-v-28f125ea=\\"\\">This is a <mark>test</mark> <hi></hi></span></li> | ||||
| </ul>" | ||||
| `; | ||||
|  | ||||
| exports[`<LogEventSource /> > render html correctly > should render messages with html entities 1`] = ` | ||||
| "<ul class=\\"events medium\\" data-v-28f125ea=\\"\\"> | ||||
|   <li data-v-28f125ea=\\"\\"><span class=\\"date\\" data-v-28f125ea=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-28f125ea=\\"\\">today at 10:55:42 AM</time></span><span class=\\"text\\" data-v-28f125ea=\\"\\"><test>foo bar</test></span></li> | ||||
| </ul>" | ||||
| `; | ||||
|  | ||||
| exports[`<LogEventSource /> > renders correctly 1`] = ` | ||||
| "<div class=\\"infinte-loader\\" data-v-48dce4fc=\\"\\"> | ||||
|   <div class=\\"spinner\\" data-v-48dce4fc=\\"\\" style=\\"display: none;\\"> | ||||
|     <div class=\\"bounce1\\" data-v-48dce4fc=\\"\\"></div> | ||||
|     <div class=\\"bounce2\\" data-v-48dce4fc=\\"\\"></div> | ||||
|     <div class=\\"bounce3\\" data-v-48dce4fc=\\"\\"></div> | ||||
|   </div> | ||||
| </div> | ||||
| <ul class=\\"events medium\\" data-v-28f125ea=\\"\\"></ul>" | ||||
| `; | ||||
|  | ||||
| exports[`<LogEventSource /> > should parse messages 1`] = ` | ||||
| { | ||||
|   "date": 2019-06-12T10:55:42.459Z, | ||||
|   "message": "\\"This is a message.\\"", | ||||
| } | ||||
| `; | ||||
|  | ||||
| exports[`<LogEventSource /> > should parse messages with loki's timestamp format 1`] = ` | ||||
| { | ||||
|   "date": 2020-04-27T10:35:43.272Z, | ||||
|   "message": "xxxxx", | ||||
| } | ||||
| `; | ||||
|  | ||||
| exports[`<LogEventSource /> > should pass messages to slot 1`] = ` | ||||
| { | ||||
|   "date": 2019-06-12T10:55:42.459Z, | ||||
|   "message": "\\"This is a message.\\"", | ||||
| } | ||||
| `; | ||||
							
								
								
									
										125
									
								
								assets/composables/eventsource.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								assets/composables/eventsource.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,125 @@ | ||||
| import { type ComputedRef, type Ref } from "vue"; | ||||
| import debounce from "lodash.debounce"; | ||||
| import { | ||||
|   type LogEvent, | ||||
|   type JSONObject, | ||||
|   LogEntry, | ||||
|   asLogEntry, | ||||
|   DockerEventLogEntry, | ||||
|   SkippedLogsEntry, | ||||
| } from "@/models/LogEntry"; | ||||
| import { Container } from "@/models/Container"; | ||||
|  | ||||
| function parseMessage(data: string): LogEntry<string | JSONObject> { | ||||
|   const e = JSON.parse(data) as LogEvent; | ||||
|   return asLogEntry(e); | ||||
| } | ||||
|  | ||||
| export function useLogStream(container: ComputedRef<Container>) { | ||||
|   let messages: LogEntry<string | JSONObject>[] = $ref([]); | ||||
|   let buffer: LogEntry<string | JSONObject>[] = $ref([]); | ||||
|   const scrollingPaused = $ref(inject("scrollingPaused") as Ref<boolean>); | ||||
|  | ||||
|   function flushNow() { | ||||
|     if (messages.length > config.maxLogs) { | ||||
|       if (scrollingPaused) { | ||||
|         console.log("Skipping ", buffer.length, " log items"); | ||||
|         if (messages.at(-1) instanceof SkippedLogsEntry) { | ||||
|           const lastEvent = messages.at(-1) as SkippedLogsEntry; | ||||
|           const lastItem = buffer.at(-1) as LogEntry<string | JSONObject>; | ||||
|           lastEvent.addSkippedEntries(buffer.length, lastItem); | ||||
|         } else { | ||||
|           const firstItem = buffer.at(0) as LogEntry<string | JSONObject>; | ||||
|           const lastItem = buffer.at(-1) as LogEntry<string | JSONObject>; | ||||
|           messages.push(new SkippedLogsEntry(new Date(), buffer.length, firstItem, lastItem)); | ||||
|         } | ||||
|         buffer = []; | ||||
|       } else { | ||||
|         messages.push(...buffer); | ||||
|         buffer = []; | ||||
|         messages.splice(0, messages.length - config.maxLogs); | ||||
|       } | ||||
|     } else { | ||||
|       messages.push(...buffer); | ||||
|       buffer = []; | ||||
|     } | ||||
|   } | ||||
|   const flushBuffer = debounce(flushNow, 250, { maxWait: 1000 }); | ||||
|   let es: EventSource | null = null; | ||||
|   let lastEventId = ""; | ||||
|  | ||||
|   function connect({ clear } = { clear: true }) { | ||||
|     es?.close(); | ||||
|  | ||||
|     if (clear) { | ||||
|       flushBuffer.cancel(); | ||||
|       messages = []; | ||||
|       buffer = []; | ||||
|       lastEventId = ""; | ||||
|     } | ||||
|  | ||||
|     es = new EventSource(`${config.base}/api/logs/stream?id=${container.value.id}&lastEventId=${lastEventId}`); | ||||
|     es.addEventListener("container-stopped", () => { | ||||
|       es?.close(); | ||||
|       es = null; | ||||
|       buffer.push(new DockerEventLogEntry("Container stopped", new Date(), "container-stopped")); | ||||
|  | ||||
|       flushBuffer(); | ||||
|       flushBuffer.flush(); | ||||
|     }); | ||||
|     es.addEventListener("error", (e) => console.error("EventSource failed: " + JSON.stringify(e))); | ||||
|     es.onmessage = (e) => { | ||||
|       lastEventId = e.lastEventId; | ||||
|       if (e.data) { | ||||
|         buffer.push(parseMessage(e.data)); | ||||
|         flushBuffer(); | ||||
|       } | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   async function loadOlderLogs({ beforeLoading, afterLoading } = { beforeLoading: () => {}, afterLoading: () => {} }) { | ||||
|     if (messages.length < 300) return; | ||||
|  | ||||
|     beforeLoading(); | ||||
|     const to = messages[0].date; | ||||
|     const last = messages[299].date; | ||||
|     const delta = to.getTime() - last.getTime(); | ||||
|     const from = new Date(to.getTime() + delta); | ||||
|     const logs = await ( | ||||
|       await fetch(`${config.base}/api/logs?id=${container.value.id}&from=${from.toISOString()}&to=${to.toISOString()}`) | ||||
|     ).text(); | ||||
|     if (logs) { | ||||
|       const newMessages = logs | ||||
|         .trim() | ||||
|         .split("\n") | ||||
|         .map((line) => parseMessage(line)); | ||||
|       messages.unshift(...newMessages); | ||||
|     } | ||||
|     afterLoading(); | ||||
|   } | ||||
|  | ||||
|   watch( | ||||
|     () => container.value.state, | ||||
|     (newValue, oldValue) => { | ||||
|       console.log("LogEventSource: container changed", newValue, oldValue); | ||||
|       if (newValue == "running" && newValue != oldValue) { | ||||
|         buffer.push(new DockerEventLogEntry("Container started", new Date(), "container-started")); | ||||
|         connect({ clear: false }); | ||||
|       } | ||||
|     } | ||||
|   ); | ||||
|  | ||||
|   onUnmounted(() => { | ||||
|     if (es) { | ||||
|       es.close(); | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   watch( | ||||
|     () => container.value.id, | ||||
|     () => connect(), | ||||
|     { immediate: true } | ||||
|   ); | ||||
|  | ||||
|   return { ...$$({ messages }), loadOlderLogs }; | ||||
| } | ||||
| @@ -1,3 +1 @@ | ||||
| import { useMediaQuery } from "@vueuse/core"; | ||||
|  | ||||
| export const isMobile = useMediaQuery("(max-width: 770px)"); | ||||
|   | ||||
| @@ -1,26 +1,44 @@ | ||||
| import { ref, computed, Ref } from "vue"; | ||||
| import { type Ref } from "vue"; | ||||
| import { type LogEntry, type JSONObject, SimpleLogEntry, ComplexLogEntry } from "@/models/LogEntry"; | ||||
|  | ||||
| const searchFilter = ref<string>(); | ||||
| const searchFilter = ref<string>(""); | ||||
| const debouncedSearchFilter = useDebounce(searchFilter); | ||||
| const showSearch = ref(false); | ||||
|  | ||||
| import type { LogEntry } from "@/types/LogEntry"; | ||||
| function matchRecord(record: Record<string, any>, regex: RegExp): boolean { | ||||
|   for (const key in record) { | ||||
|     const value = record[key]; | ||||
|     if (typeof value === "string" && regex.test(value)) { | ||||
|       return true; | ||||
|     } | ||||
|     if (Array.isArray(value) && matchRecord(value, regex)) { | ||||
|       return true; | ||||
|     } | ||||
|   } | ||||
|   return false; | ||||
| } | ||||
|  | ||||
| export function useSearchFilter() { | ||||
|   function filteredMessages(messages: Ref<LogEntry[]>) { | ||||
|   const regex = computed(() => { | ||||
|     const isSmartCase = debouncedSearchFilter.value === debouncedSearchFilter.value.toLowerCase(); | ||||
|     return isSmartCase ? new RegExp(debouncedSearchFilter.value, "i") : new RegExp(debouncedSearchFilter.value); | ||||
|   }); | ||||
|  | ||||
|   function filteredMessages(messages: Ref<LogEntry<string | JSONObject>[]>) { | ||||
|     return computed(() => { | ||||
|       if (searchFilter && searchFilter.value) { | ||||
|         const isSmartCase = searchFilter.value === searchFilter.value.toLowerCase(); | ||||
|       if (debouncedSearchFilter.value && showSearch.value) { | ||||
|         try { | ||||
|           const regex = isSmartCase ? new RegExp(searchFilter.value, "i") : new RegExp(searchFilter.value); | ||||
|           return messages.value | ||||
|             .filter((d) => d.message.match(regex)) | ||||
|             .map((d) => ({ | ||||
|               ...d, | ||||
|               message: d.message.replace(regex, "<mark>$&</mark>"), | ||||
|             })); | ||||
|           return messages.value.filter((d) => { | ||||
|             if (d instanceof SimpleLogEntry) { | ||||
|               return regex.value.test(d.message); | ||||
|             } else if (d instanceof ComplexLogEntry) { | ||||
|               return matchRecord(d.message, regex.value); | ||||
|             } | ||||
|             return false; | ||||
|           }); | ||||
|         } catch (e) { | ||||
|           if (e instanceof SyntaxError) { | ||||
|             console.info(`Ignoring SytaxError from search.`, e); | ||||
|             console.info(`Ignoring SyntaxError from search.`, e); | ||||
|             return messages.value; | ||||
|           } | ||||
|           throw e; | ||||
| @@ -31,9 +49,34 @@ export function useSearchFilter() { | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   function markSearch(log: { toString(): string }): string; | ||||
|   function markSearch(log: string[]): string[]; | ||||
|   function markSearch(log: { toString(): string } | string[]) { | ||||
|     if (!debouncedSearchFilter.value) { | ||||
|       return log; | ||||
|     } | ||||
|     if (Array.isArray(log)) { | ||||
|       return log.map((d) => markSearch(d)); | ||||
|     } | ||||
|  | ||||
|     return log.toString().replace(regex.value, (match) => `<mark>${match}</mark>`); | ||||
|   } | ||||
|  | ||||
|   function resetSearch() { | ||||
|     searchFilter.value = ""; | ||||
|     showSearch.value = false; | ||||
|   } | ||||
|  | ||||
|   function isSearching() { | ||||
|     return showSearch.value && searchFilter.value; | ||||
|   } | ||||
|  | ||||
|   return { | ||||
|     filteredMessages, | ||||
|     searchFilter, | ||||
|     showSearch | ||||
|     showSearch, | ||||
|     markSearch, | ||||
|     resetSearch, | ||||
|     isSearching, | ||||
|   }; | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,4 @@ | ||||
| import { useStorage } from "@vueuse/core"; | ||||
| import { computed } from "vue"; | ||||
|  | ||||
| export const DOZZLE_SETTINGS_KEY = "DOZZLE_SETTINGS"; | ||||
| const DOZZLE_SETTINGS_KEY = "DOZZLE_SETTINGS"; | ||||
|  | ||||
| export const DEFAULT_SETTINGS: { | ||||
|   search: boolean; | ||||
| @@ -10,8 +7,9 @@ export const DEFAULT_SETTINGS: { | ||||
|   smallerScrollbars: boolean; | ||||
|   showTimestamp: boolean; | ||||
|   showAllContainers: boolean; | ||||
|   lightTheme: boolean; | ||||
|   lightTheme: "auto" | "dark" | "light"; | ||||
|   hourStyle: "auto" | "24" | "12"; | ||||
|   softWrap: boolean; | ||||
| } = { | ||||
|   search: true, | ||||
|   size: "medium", | ||||
| @@ -19,47 +17,67 @@ export const DEFAULT_SETTINGS: { | ||||
|   smallerScrollbars: false, | ||||
|   showTimestamp: true, | ||||
|   showAllContainers: false, | ||||
|   lightTheme: false, | ||||
|   lightTheme: "auto", | ||||
|   hourStyle: "auto", | ||||
|   softWrap: true, | ||||
| }; | ||||
|  | ||||
| export const settings = useStorage(DOZZLE_SETTINGS_KEY, DEFAULT_SETTINGS); | ||||
| const settings = useStorage(DOZZLE_SETTINGS_KEY, DEFAULT_SETTINGS); | ||||
| settings.value = { ...DEFAULT_SETTINGS, ...settings.value }; | ||||
|  | ||||
| export const search = computed({ | ||||
| const search = computed({ | ||||
|   get: () => settings.value.search, | ||||
|   set: (value) => (settings.value.search = value), | ||||
| }); | ||||
|  | ||||
| export const size = computed({ | ||||
| const size = computed({ | ||||
|   get: () => settings.value.size, | ||||
|   set: (value) => (settings.value.size = value), | ||||
| }); | ||||
|  | ||||
| export const menuWidth = computed({ | ||||
| const menuWidth = computed({ | ||||
|   get: () => settings.value.menuWidth, | ||||
|   set: (value) => (settings.value.menuWidth = value), | ||||
| }); | ||||
| export const smallerScrollbars = computed({ | ||||
| const smallerScrollbars = computed({ | ||||
|   get: () => settings.value.smallerScrollbars, | ||||
|   set: (value) => (settings.value.smallerScrollbars = value), | ||||
| }); | ||||
|  | ||||
| export const showTimestamp = computed({ | ||||
| const showTimestamp = computed({ | ||||
|   get: () => settings.value.showTimestamp, | ||||
|   set: (value) => (settings.value.showTimestamp = value), | ||||
| }); | ||||
|  | ||||
| export const showAllContainers = computed({ | ||||
| const showAllContainers = computed({ | ||||
|   get: () => settings.value.showAllContainers, | ||||
|   set: (value) => (settings.value.showAllContainers = value), | ||||
| }); | ||||
|  | ||||
| export const lightTheme = computed({ | ||||
| const lightTheme = computed({ | ||||
|   get: () => settings.value.lightTheme, | ||||
|   set: (value) => (settings.value.lightTheme = value), | ||||
| }); | ||||
|  | ||||
| export const hourStyle = computed({ | ||||
| const hourStyle = computed({ | ||||
|   get: () => settings.value.hourStyle, | ||||
|   set: (value) => (settings.value.hourStyle = value), | ||||
| }); | ||||
|  | ||||
| const softWrap = computed({ | ||||
|   get: () => settings.value.softWrap, | ||||
|   set: (value) => (settings.value.softWrap = value), | ||||
| }); | ||||
|  | ||||
| export { | ||||
|   softWrap, | ||||
|   hourStyle, | ||||
|   lightTheme, | ||||
|   showAllContainers, | ||||
|   showTimestamp, | ||||
|   smallerScrollbars, | ||||
|   menuWidth, | ||||
|   size, | ||||
|   search, | ||||
|   settings | ||||
| }; | ||||
|   | ||||
| @@ -1,12 +1,8 @@ | ||||
| import { useTitle } from "@vueuse/core"; | ||||
| import { ref, computed } from "vue"; | ||||
| let subtitle = $ref(""); | ||||
| const title = $computed(() => `${subtitle} - Dozzle`); | ||||
|  | ||||
| const subtitle = ref(""); | ||||
|  | ||||
| const title = computed(() => `${subtitle.value} - Dozzle`); | ||||
|  | ||||
| useTitle(title); | ||||
| useTitle($$(title)); | ||||
|  | ||||
| export function setTitle(t: string) { | ||||
|   subtitle.value = t; | ||||
|   subtitle = t; | ||||
| } | ||||
|   | ||||
							
								
								
									
										18
									
								
								assets/composables/visible.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								assets/composables/visible.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| import { ComplexLogEntry, type JSONObject, type LogEntry } from "@/models/LogEntry"; | ||||
| import type { ComputedRef, Ref } from "vue"; | ||||
|  | ||||
| export function useVisibleFilter(visibleKeys: ComputedRef<Ref<string[][]>>) { | ||||
|   function filteredPayload(messages: Ref<LogEntry<string | JSONObject>[]>) { | ||||
|     return computed(() => { | ||||
|       return messages.value.map((d) => { | ||||
|         if (d instanceof ComplexLogEntry) { | ||||
|           return ComplexLogEntry.fromLogEvent(d, visibleKeys.value); | ||||
|         } else { | ||||
|           return d; | ||||
|         } | ||||
|       }); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   return { filteredPayload }; | ||||
| } | ||||
							
								
								
									
										122
									
								
								assets/layouts/default.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								assets/layouts/default.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,122 @@ | ||||
| <template> | ||||
|   <main v-if="!authorizationNeeded"> | ||||
|     <mobile-menu v-if="isMobile"></mobile-menu> | ||||
|     <splitpanes @resized="onResized($event)"> | ||||
|       <pane min-size="10" :size="menuWidth" v-if="!isMobile && !collapseNav"> | ||||
|         <side-menu @search="showFuzzySearch"></side-menu> | ||||
|       </pane> | ||||
|       <pane min-size="10"> | ||||
|         <splitpanes> | ||||
|           <pane class="has-min-height router-view"> | ||||
|             <router-view></router-view> | ||||
|           </pane> | ||||
|           <template v-if="!isMobile"> | ||||
|             <pane v-for="other in activeContainers" :key="other.id"> | ||||
|               <log-container | ||||
|                 :id="other.id" | ||||
|                 show-title | ||||
|                 scrollable | ||||
|                 closable | ||||
|                 @close="containerStore.removeActiveContainer(other)" | ||||
|               ></log-container> | ||||
|             </pane> | ||||
|           </template> | ||||
|         </splitpanes> | ||||
|       </pane> | ||||
|     </splitpanes> | ||||
|     <button | ||||
|       @click="collapseNav = !collapseNav" | ||||
|       class="button is-rounded" | ||||
|       :class="{ collapsed: collapseNav }" | ||||
|       id="hide-nav" | ||||
|       v-if="!isMobile" | ||||
|     > | ||||
|       <span class="icon ml-2" v-if="collapseNav"> | ||||
|         <mdi-light-chevron-right /> | ||||
|       </span> | ||||
|       <span class="icon" v-else> | ||||
|         <mdi-light-chevron-left /> | ||||
|       </span> | ||||
|     </button> | ||||
|   </main> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| // @ts-ignore - splitpanes types are not available | ||||
| import { Splitpanes, Pane } from "splitpanes"; | ||||
| import { useProgrammatic } from "@oruga-ui/oruga-next"; | ||||
| import FuzzySearchModal from "@/components/FuzzySearchModal.vue"; | ||||
|  | ||||
| const collapseNav = ref(false); | ||||
| const { oruga } = useProgrammatic(); | ||||
| const { authorizationNeeded } = config; | ||||
|  | ||||
| const containerStore = useContainerStore(); | ||||
| const { activeContainers, visibleContainers } = storeToRefs(containerStore); | ||||
|  | ||||
| watchEffect(() => { | ||||
|   setTitle(`${visibleContainers.value.length} containers`); | ||||
| }); | ||||
|  | ||||
| onKeyStroke("k", (e) => { | ||||
|   if (e.ctrlKey || e.metaKey) { | ||||
|     showFuzzySearch(); | ||||
|     e.preventDefault(); | ||||
|   } | ||||
| }); | ||||
|  | ||||
| function showFuzzySearch() { | ||||
|   oruga.modal.open({ | ||||
|     // parent: this, | ||||
|     component: FuzzySearchModal, | ||||
|     animation: "false", | ||||
|     width: 600, | ||||
|     active: true, | ||||
|   }); | ||||
| } | ||||
| function onResized(e: any) { | ||||
|   if (e.length == 2) { | ||||
|     menuWidth.value = e[0].size; | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
| :deep(.splitpanes--vertical > .splitpanes__splitter) { | ||||
|   min-width: 3px; | ||||
|   background: var(--border-color); | ||||
|   &:hover { | ||||
|     background: var(--border-hover-color); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media screen and (max-width: 768px) { | ||||
|   .router-view { | ||||
|     padding-top: 75px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .button.has-no-border { | ||||
|   border-color: transparent !important; | ||||
| } | ||||
|  | ||||
| .has-min-height { | ||||
|   min-height: 100vh; | ||||
| } | ||||
|  | ||||
| #hide-nav { | ||||
|   position: fixed; | ||||
|   left: 10px; | ||||
|   bottom: 10px; | ||||
|   &.collapsed { | ||||
|     left: -40px; | ||||
|     width: 60px; | ||||
|     padding-left: 40px; | ||||
|     background: rgba(0, 0, 0, 0.95); | ||||
|  | ||||
|     &:hover { | ||||
|       left: -25px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										9
									
								
								assets/layouts/splash.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								assets/layouts/splash.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| <template> | ||||
|   <main> | ||||
|     <router-view></router-view> | ||||
|   </main> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup></script> | ||||
|  | ||||
| <style scoped lang="scss"></style> | ||||
| @@ -1,67 +1,10 @@ | ||||
| import "./styles.scss"; | ||||
| import { createApp } from "vue"; | ||||
| import { createRouter, createWebHistory } from "vue-router"; | ||||
| import { Autocomplete, Button, Dropdown, Switch, Radio, Field, Tooltip, Modal, Config } from "@oruga-ui/oruga-next"; | ||||
| import { bulmaConfig } from "@oruga-ui/theme-bulma"; | ||||
| import { createPinia } from "pinia"; | ||||
| import config from "./stores/config"; | ||||
| import { createApp, App as VueApp } from "vue"; | ||||
| import App from "./App.vue"; | ||||
| import { Container, Settings, Index, Show, ContainerNotFound, PageNotFound, Login } from "./pages"; | ||||
|  | ||||
| const routes = [ | ||||
|   { | ||||
|     path: "/", | ||||
|     component: Index, | ||||
|     name: "default", | ||||
|   }, | ||||
|   { | ||||
|     path: "/container/:id", | ||||
|     component: Container, | ||||
|     name: "container", | ||||
|     props: true, | ||||
|   }, | ||||
|   { | ||||
|     path: "/container/:pathMatch(.*)", | ||||
|     component: ContainerNotFound, | ||||
|     name: "container-not-found", | ||||
|   }, | ||||
|   { | ||||
|     path: "/settings", | ||||
|     component: Settings, | ||||
|     name: "settings", | ||||
|   }, | ||||
|   { | ||||
|     path: "/show", | ||||
|     component: Show, | ||||
|     name: "show", | ||||
|   }, | ||||
|   { | ||||
|     path: "/login", | ||||
|     component: Login, | ||||
|     name: "login", | ||||
|   }, | ||||
|   { | ||||
|     path: "/:pathMatch(.*)*", | ||||
|     component: PageNotFound, | ||||
|     name: "page-not-found", | ||||
|   }, | ||||
| ]; | ||||
| const app = createApp(App); | ||||
| Object.values(import.meta.glob<{ install: (app: VueApp) => void }>("./modules/*.ts", { eager: true })).forEach((i) => | ||||
|   i.install?.(app) | ||||
| ); | ||||
|  | ||||
| const router = createRouter({ | ||||
|   history: createWebHistory(`${config.base}/`), | ||||
|   routes, | ||||
| }); | ||||
|  | ||||
| createApp(App) | ||||
|   .use(router) | ||||
|   .use(createPinia()) | ||||
|   .use(Autocomplete) | ||||
|   .use(Button) | ||||
|   .use(Dropdown) | ||||
|   .use(Switch) | ||||
|   .use(Tooltip) | ||||
|   .use(Modal) | ||||
|   .use(Radio) | ||||
|   .use(Field) | ||||
|   .use(Config, bulmaConfig) | ||||
|   .mount("#app"); | ||||
| app.mount("#app"); | ||||
|   | ||||
							
								
								
									
										31
									
								
								assets/models/Container.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								assets/models/Container.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| import type { ContainerStat, ContainerState } from "@/types/Container"; | ||||
| import type { UseThrottledRefHistoryReturn } from "@vueuse/core"; | ||||
| import { Ref } from "vue"; | ||||
|  | ||||
| type Stat = Omit<ContainerStat, "id">; | ||||
|  | ||||
| export class Container { | ||||
|   public stat: Ref<Stat>; | ||||
|   private readonly throttledStatHistory: UseThrottledRefHistoryReturn<Stat, Stat>; | ||||
|  | ||||
|   constructor( | ||||
|     public readonly id: string, | ||||
|     public readonly created: number, | ||||
|     public readonly image: string, | ||||
|     public readonly name: string, | ||||
|     public readonly command: string, | ||||
|     public status: string, | ||||
|     public state: ContainerState | ||||
|   ) { | ||||
|     this.stat = ref({ cpu: 0, memory: 0, memoryUsage: 0 }); | ||||
|     this.throttledStatHistory = useThrottledRefHistory(this.stat, { capacity: 300, deep: true, throttle: 1000 }); | ||||
|   } | ||||
|  | ||||
|   public getStatHistory() { | ||||
|     return unref(this.throttledStatHistory.history); | ||||
|   } | ||||
|  | ||||
|   public getLastStat() { | ||||
|     return unref(this.throttledStatHistory.last); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										121
									
								
								assets/models/LogEntry.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								assets/models/LogEntry.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,121 @@ | ||||
| import { Component, ComputedRef, Ref } from "vue"; | ||||
| import { flattenJSON, getDeep } from "@/utils"; | ||||
| import ComplexLogItem from "@/components/LogViewer/ComplexLogItem.vue"; | ||||
| import SimpleLogItem from "@/components/LogViewer/SimpleLogItem.vue"; | ||||
| import DockerEventLogItem from "@/components/LogViewer/DockerEventLogItem.vue"; | ||||
| import SkippedEntriesLogItem from "@/components/LogViewer/SkippedEntriesLogItem.vue"; | ||||
|  | ||||
| export interface HasComponent { | ||||
|   getComponent(): Component; | ||||
| } | ||||
|  | ||||
| export type JSONValue = string | number | boolean | JSONObject | Array<JSONValue>; | ||||
| export type JSONObject = { [x: string]: JSONValue }; | ||||
|  | ||||
| export interface LogEvent { | ||||
|   readonly m: string | JSONObject; | ||||
|   readonly ts: number; | ||||
|   readonly id: number; | ||||
| } | ||||
|  | ||||
| export abstract class LogEntry<T extends string | JSONObject> implements HasComponent { | ||||
|   protected readonly _message: T; | ||||
|   constructor(message: T, public readonly id: number, public readonly date: Date) { | ||||
|     this._message = message; | ||||
|   } | ||||
|  | ||||
|   public get message(): T { | ||||
|     return this._message; | ||||
|   } | ||||
|  | ||||
|   abstract getComponent(): Component; | ||||
| } | ||||
|  | ||||
| export class SimpleLogEntry extends LogEntry<string> { | ||||
|   getComponent(): Component { | ||||
|     return SimpleLogItem; | ||||
|   } | ||||
| } | ||||
|  | ||||
| export class ComplexLogEntry extends LogEntry<JSONObject> { | ||||
|   private readonly filteredMessage: ComputedRef<JSONObject>; | ||||
|  | ||||
|   constructor(message: JSONObject, id: number, date: Date, visibleKeys?: Ref<string[][]>) { | ||||
|     super(message, id, date); | ||||
|     if (visibleKeys) { | ||||
|       this.filteredMessage = computed(() => { | ||||
|         if (!visibleKeys.value.length) { | ||||
|           return flattenJSON(message); | ||||
|         } else { | ||||
|           return visibleKeys.value.reduce((acc, attr) => ({ ...acc, [attr.join(".")]: getDeep(message, attr) }), {}); | ||||
|         } | ||||
|       }); | ||||
|     } else { | ||||
|       this.filteredMessage = computed(() => flattenJSON(message)); | ||||
|     } | ||||
|   } | ||||
|   getComponent(): Component { | ||||
|     return ComplexLogItem; | ||||
|   } | ||||
|  | ||||
|   public get message(): JSONObject { | ||||
|     return this.filteredMessage.value; | ||||
|   } | ||||
|  | ||||
|   public get unfilteredMessage(): JSONObject { | ||||
|     return this._message; | ||||
|   } | ||||
|  | ||||
|   static fromLogEvent(event: ComplexLogEntry, visibleKeys: Ref<string[][]>): ComplexLogEntry { | ||||
|     return new ComplexLogEntry(event._message, event.id, event.date, visibleKeys); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export class DockerEventLogEntry extends LogEntry<string> { | ||||
|   constructor(message: string, date: Date, public readonly event: string) { | ||||
|     super(message, date.getTime(), date); | ||||
|   } | ||||
|   getComponent(): Component { | ||||
|     return DockerEventLogItem; | ||||
|   } | ||||
| } | ||||
|  | ||||
| export class SkippedLogsEntry extends LogEntry<string> { | ||||
|   private _totalSkipped = 0; | ||||
|   private lastSkipped: LogEntry<string | JSONObject>; | ||||
|  | ||||
|   constructor( | ||||
|     date: Date, | ||||
|     totalSkipped: number, | ||||
|     public readonly firstSkipped: LogEntry<string | JSONObject>, | ||||
|     lastSkipped: LogEntry<string | JSONObject> | ||||
|   ) { | ||||
|     super("", date.getTime(), date); | ||||
|     this._totalSkipped = totalSkipped; | ||||
|     this.lastSkipped = lastSkipped; | ||||
|   } | ||||
|   getComponent(): Component { | ||||
|     return SkippedEntriesLogItem; | ||||
|   } | ||||
|  | ||||
|   public get message(): string { | ||||
|     return `Skipped ${this.totalSkipped} entries`; | ||||
|   } | ||||
|  | ||||
|   public addSkippedEntries(totalSkipped: number, lastItem: LogEntry<string | JSONObject>) { | ||||
|     this._totalSkipped += totalSkipped; | ||||
|     this.lastSkipped = lastItem; | ||||
|   } | ||||
|  | ||||
|   public get totalSkipped(): number { | ||||
|     return this._totalSkipped; | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function asLogEntry(event: LogEvent): LogEntry<string | JSONObject> { | ||||
|   if (typeof event.m === "string") { | ||||
|     return new SimpleLogEntry(event.m, event.id, new Date(event.ts)); | ||||
|   } else { | ||||
|     return new ComplexLogEntry(event.m, event.id, new Date(event.ts)); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										28
									
								
								assets/modules/bulma.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								assets/modules/bulma.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| import { type App } from "vue"; | ||||
| import { | ||||
|   Autocomplete, | ||||
|   Button, | ||||
|   Dropdown, | ||||
|   Switch, | ||||
|   Radio, | ||||
|   Skeleton, | ||||
|   Field, | ||||
|   Tooltip, | ||||
|   Modal, | ||||
|   Config, | ||||
| } from "@oruga-ui/oruga-next"; | ||||
| import { bulmaConfig } from "@oruga-ui/theme-bulma"; | ||||
|  | ||||
| export const install = (app: App) => { | ||||
|   app | ||||
|     .use(Autocomplete) | ||||
|     .use(Button) | ||||
|     .use(Dropdown) | ||||
|     .use(Switch) | ||||
|     .use(Tooltip) | ||||
|     .use(Modal) | ||||
|     .use(Radio) | ||||
|     .use(Field) | ||||
|     .use(Skeleton) | ||||
|     .use(Config, bulmaConfig); | ||||
| }; | ||||
							
								
								
									
										20
									
								
								assets/modules/i18n.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								assets/modules/i18n.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| import { type App } from "vue"; | ||||
| import { createI18n } from "vue-i18n"; | ||||
|  | ||||
| export const install = (app: App) => { | ||||
|   const messages = Object.fromEntries( | ||||
|     Object.entries(import.meta.glob<{ default: any }>("../../locales/*.y(a)?ml", { eager: true })).map( | ||||
|       ([key, value]) => { | ||||
|         const yaml = key.endsWith(".yaml"); | ||||
|         return [key.slice(14, yaml ? -5 : -4), value.default]; | ||||
|       } | ||||
|     ) | ||||
|   ); | ||||
|   const i18n = createI18n({ | ||||
|     legacy: false, | ||||
|     locale: navigator.language.slice(0, 2), | ||||
|     fallbackLocale: "en", | ||||
|     messages, | ||||
|   }); | ||||
|   app.use(i18n); | ||||
| }; | ||||
							
								
								
									
										8
									
								
								assets/modules/pinia.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								assets/modules/pinia.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| import { type App } from "vue"; | ||||
| import { createPinia } from "pinia"; | ||||
|  | ||||
|  | ||||
| export const install = (app:App) => { | ||||
|   const pinia = createPinia(); | ||||
|   app.use(pinia); | ||||
| }; | ||||
							
								
								
									
										16
									
								
								assets/modules/router.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								assets/modules/router.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| import { type App } from "vue"; | ||||
| import { createRouter, createWebHistory } from "vue-router"; | ||||
| import pages from "~pages"; | ||||
| import { setupLayouts } from "virtual:generated-layouts"; | ||||
| import config from "@/stores/config"; | ||||
|  | ||||
| export const install = (app: App) => { | ||||
|   const routes = setupLayouts(pages); | ||||
|  | ||||
|   const router = createRouter({ | ||||
|     history: createWebHistory(`${config.base}/`), | ||||
|     routes, | ||||
|   }); | ||||
|  | ||||
|   app.use(router); | ||||
| }; | ||||
| @@ -1,37 +0,0 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <search></search> | ||||
|     <log-container :id="id" show-title :scrollable="activeContainers.length > 0"> </log-container> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { onMounted, toRefs, watchEffect } from "vue"; | ||||
| import Search from "@/components/Search.vue"; | ||||
| import LogContainer from "@/components/LogContainer.vue"; | ||||
| import { setTitle } from "@/composables/title"; | ||||
| import { useContainerStore } from "@/stores/container"; | ||||
| import { storeToRefs } from "pinia"; | ||||
|  | ||||
| const store = useContainerStore(); | ||||
|  | ||||
| const props = defineProps({ | ||||
|   id: { | ||||
|     type: String, | ||||
|     required: true, | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const { id } = toRefs(props); | ||||
|  | ||||
| const currentContainer = store.currentContainer(id); | ||||
| const { activeContainers } = storeToRefs(store); | ||||
|  | ||||
| setTitle("loading"); | ||||
|  | ||||
| onMounted(() => { | ||||
|   setTitle(currentContainer.value?.name); | ||||
| }); | ||||
|  | ||||
| watchEffect(() => setTitle(currentContainer.value?.name)); | ||||
| </script> | ||||
| @@ -1,23 +0,0 @@ | ||||
| <template> | ||||
|   <div class="hero is-halfheight"> | ||||
|     <div class="hero-body"> | ||||
|       <div class="container has-text-centered"> | ||||
|         <h1 class="title"> | ||||
|           Container not found. | ||||
|           <small class="subtitle">It may have been removed.</small> | ||||
|         </h1> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| import { setTitle } from "@/composables/title"; | ||||
|  | ||||
| export default { | ||||
|   name: "ContainerNotFound", | ||||
|   setup() { | ||||
|     setTitle("Container not found"); | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| @@ -4,33 +4,42 @@ | ||||
|       <div class="hero-body"> | ||||
|         <div class="container"> | ||||
|           <div class="columns"> | ||||
|             <div class="column"> | ||||
|               <h1 class="title">Hello, there!</h1> | ||||
|             </div> | ||||
|             <div class="column is-narrow" v-if="secured"> | ||||
|               <a class="button is-primary is-small" :href="`${base}/logout`">Logout</a> | ||||
|               <a class="button is-primary is-small" :href="`${base}/logout`">{{ $t("button.logout") }}</a> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </section> | ||||
|     <section class="level section is-mobile"> | ||||
|     <section class="level section"> | ||||
|       <div class="level-item has-text-centered"> | ||||
|         <div> | ||||
|           <p class="title">{{ containers.length }}</p> | ||||
|           <p class="heading">Total Containers</p> | ||||
|           <p class="heading">{{ $t("label.total-containers") }}</p> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="level-item has-text-centered"> | ||||
|         <div> | ||||
|           <p class="title">{{ runningContainers.length }}</p> | ||||
|           <p class="heading">Running</p> | ||||
|           <p class="heading">{{ $t("label.running") }}</p> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="level-item has-text-centered"> | ||||
|         <div> | ||||
|           <p class="title" data-ci-skip>{{ totalCpu }}%</p> | ||||
|           <p class="heading">{{ $t("label.total-cpu-usage") }}</p> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="level-item has-text-centered"> | ||||
|         <div> | ||||
|           <p class="title" data-ci-skip>{{ formatBytes(totalMem) }}</p> | ||||
|           <p class="heading">{{ $t("label.total-mem-usage") }}</p> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="level-item has-text-centered"> | ||||
|         <div> | ||||
|           <p class="title">{{ version }}</p> | ||||
|           <p class="heading">Dozzle Version</p> | ||||
|           <p class="heading">{{ $t("label.dozzle-version") }}</p> | ||||
|         </div> | ||||
|       </div> | ||||
|     </section> | ||||
| @@ -38,15 +47,15 @@ | ||||
|     <section class="columns is-centered section is-marginless"> | ||||
|       <div class="column is-4"> | ||||
|         <div class="panel"> | ||||
|           <p class="panel-heading">Containers</p> | ||||
|           <p class="panel-heading">{{ $t("label.containers") }}</p> | ||||
|           <div class="panel-block"> | ||||
|             <p class="control has-icons-left"> | ||||
|               <input | ||||
|                 class="input" | ||||
|                 type="text" | ||||
|                 placeholder="Search Containers" | ||||
|                 v-model="search" | ||||
|                 @keyup.esc="search = null" | ||||
|                 :placeholder="$t('placeholder.search-containers')" | ||||
|                 v-model="query" | ||||
|                 @keyup.esc="query = ''" | ||||
|                 @keyup.enter="onEnter()" | ||||
|               /> | ||||
|               <span class="icon is-left"> | ||||
| @@ -54,13 +63,13 @@ | ||||
|               </span> | ||||
|             </p> | ||||
|           </div> | ||||
|           <p class="panel-tabs" v-if="!search"> | ||||
|             <a :class="{ 'is-active': sort === 'running' }" @click="sort = 'running'">Running</a> | ||||
|             <a :class="{ 'is-active': sort === 'all' }" @click="sort = 'all'">All</a> | ||||
|           <p class="panel-tabs" v-if="query === ''"> | ||||
|             <a :class="{ 'is-active': sort === 'running' }" @click="sort = 'running'">{{ $t("label.running") }}</a> | ||||
|             <a :class="{ 'is-active': sort === 'all' }" @click="sort = 'all'">{{ $t("label.all") }}</a> | ||||
|           </p> | ||||
|           <router-link | ||||
|             :to="{ name: 'container', params: { id: item.id, name: item.name } }" | ||||
|             v-for="item in results.slice(0, 10)" | ||||
|             :to="{ name: 'container-id', params: { id: item.id } }" | ||||
|             v-for="item in data.slice(0, 10)" | ||||
|             :key="item.id" | ||||
|             class="panel-block" | ||||
|           > | ||||
| @@ -77,44 +86,60 @@ | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { ref, computed } from "vue"; | ||||
| import { storeToRefs } from "pinia"; | ||||
| import { useRouter } from "vue-router"; | ||||
| import { useContainerStore } from "@/stores/container"; | ||||
| import fuzzysort from "fuzzysort"; | ||||
| import SearchIcon from "~icons/mdi-light/magnify"; | ||||
| import PastTime from "../components/PastTime.vue"; | ||||
| import config from "@/stores/config"; | ||||
| import { useFuse } from "@vueuse/integrations/useFuse"; | ||||
|  | ||||
| const { base, version, secured } = config; | ||||
| const containerStore = useContainerStore(); | ||||
| const { containers } = storeToRefs(containerStore); | ||||
| const router = useRouter(); | ||||
|  | ||||
| const sort = ref("running"); | ||||
| const search = ref(); | ||||
| const sort = $ref("running"); | ||||
| const query = ref(""); | ||||
|  | ||||
| const results = computed(() => { | ||||
|   if (search.value) { | ||||
|     return fuzzysort.go(search.value, containers.value, { key: "name" }).map((i) => i.obj); | ||||
| const mostRecentContainers = $computed(() => [...containers.value].sort((a, b) => b.created - a.created)); | ||||
| const runningContainers = $computed(() => mostRecentContainers.filter((c) => c.state === "running")); | ||||
|  | ||||
| const { results } = useFuse(query, containers, { | ||||
|   fuseOptions: { keys: ["name"] }, | ||||
|   matchAllWhenSearchEmpty: false, | ||||
| }); | ||||
| const data = computed(() => { | ||||
|   if (results.value.length) { | ||||
|     return results.value.map(({ item }) => item); | ||||
|   } | ||||
|   switch (sort.value) { | ||||
|   switch (sort) { | ||||
|     case "all": | ||||
|       return mostRecentContainers.value; | ||||
|       return mostRecentContainers; | ||||
|     case "running": | ||||
|       return runningContainers.value; | ||||
|       return runningContainers; | ||||
|     default: | ||||
|       throw `Invalid sort order: ${sort.value}`; | ||||
|       throw `Invalid sort order: ${sort}`; | ||||
|   } | ||||
| }); | ||||
|  | ||||
| const mostRecentContainers = computed(() => [...containers.value].sort((a, b) => b.created - a.created)); | ||||
| const runningContainers = computed(() => mostRecentContainers.value.filter((c) => c.state === "running")); | ||||
| let totalCpu = $ref(0); | ||||
| useIntervalFn( | ||||
|   () => { | ||||
|     totalCpu = runningContainers.reduce((acc, c) => acc + (c.stat?.cpu ?? 0), 0); | ||||
|   }, | ||||
|   1000, | ||||
|   { immediate: true } | ||||
| ); | ||||
|  | ||||
| let totalMem = $ref(0); | ||||
| useIntervalFn( | ||||
|   () => { | ||||
|     totalMem = runningContainers.reduce((acc, c) => acc + (c.stat?.memoryUsage ?? 0), 0); | ||||
|   }, | ||||
|   1000, | ||||
|   { immediate: true } | ||||
| ); | ||||
|  | ||||
| function onEnter() { | ||||
|   if (results.value.length == 1) { | ||||
|     const [item] = results.value; | ||||
|     router.push({ name: "container", params: { id: item.id, name: item.name } }); | ||||
|   if (data.value.length > 0) { | ||||
|     const item = data.value[0]; | ||||
|     router.push({ name: "container-id", params: { id: item.id } }); | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|   | ||||
| @@ -8,7 +8,7 @@ | ||||
|               <div class="card-content"> | ||||
|                 <form action="" method="post" @submit.prevent="onLogin" ref="form"> | ||||
|                   <div class="field"> | ||||
|                     <label class="label">Username</label> | ||||
|                     <label class="label">{{ $t("label.username") }}</label> | ||||
|                     <div class="control"> | ||||
|                       <input | ||||
|                         class="input" | ||||
| @@ -22,7 +22,7 @@ | ||||
|                   </div> | ||||
|  | ||||
|                   <div class="field"> | ||||
|                     <label class="label">Password</label> | ||||
|                     <label class="label">{{ $t("label.password") }}</label> | ||||
|                     <div class="control"> | ||||
|                       <input | ||||
|                         class="input" | ||||
| @@ -32,11 +32,11 @@ | ||||
|                         v-model="password" | ||||
|                       /> | ||||
|                     </div> | ||||
|                     <p class="help is-danger" v-if="error">Username and password are not valid.</p> | ||||
|                     <p class="help is-danger" v-if="error">{{ $t("error.invalid-auth") }}</p> | ||||
|                   </div> | ||||
|                   <div class="field is-grouped is-grouped-centered mt-5"> | ||||
|                     <p class="control"> | ||||
|                       <button class="button is-primary" type="submit">Login</button> | ||||
|                       <button class="button is-primary" type="submit">{{ $t("button.login") }}</button> | ||||
|                     </p> | ||||
|                   </div> | ||||
|                 </form> | ||||
| @@ -49,35 +49,31 @@ | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| import config from "@/stores/config"; | ||||
| import { setTitle } from "@/composables/title"; | ||||
| export default { | ||||
|   name: "Login", | ||||
|   data() { | ||||
|     return { | ||||
|       username: null, | ||||
|       password: null, | ||||
|       error: false, | ||||
|     }; | ||||
|   }, | ||||
|   setup() { | ||||
|     setTitle("Authentication Required"); | ||||
|   }, | ||||
|   methods: { | ||||
|     async onLogin() { | ||||
|       const response = await fetch(`${config.base}/api/validateCredentials`, { | ||||
|         body: new FormData(this.$refs.form), | ||||
|         method: "post", | ||||
|       }); | ||||
| <script lang="ts" setup> | ||||
| const { t } = useI18n(); | ||||
|  | ||||
|       if (response.status == 200) { | ||||
|         this.error = false; | ||||
|         window.location.href = `${config.base}/`; | ||||
|       } else { | ||||
|         this.error = true; | ||||
|       } | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| setTitle(t("title.login")); | ||||
|  | ||||
| let error = $ref(false); | ||||
| let username = $ref(""); | ||||
| let password = $ref(""); | ||||
| let form: HTMLFormElement = $ref(); | ||||
|  | ||||
| async function onLogin() { | ||||
|   const response = await fetch(`${config.base}/api/validateCredentials`, { | ||||
|     body: new FormData(form), | ||||
|     method: "post", | ||||
|   }); | ||||
|  | ||||
|   if (response.status == 200) { | ||||
|     error = false; | ||||
|     window.location.href = `${config.base}/`; | ||||
|   } else { | ||||
|     error = true; | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| <route lang="yaml"> | ||||
| meta: | ||||
|   layout: splash | ||||
| </route> | ||||
|   | ||||
| @@ -2,25 +2,34 @@ | ||||
|   <div> | ||||
|     <section class="section"> | ||||
|       <div class="has-underline"> | ||||
|         <h2 class="title is-4">About</h2> | ||||
|         <h2 class="title is-4">{{ $t("settings.about") }}</h2> | ||||
|       </div> | ||||
|  | ||||
|       <div> | ||||
|         You are using Dozzle <i>{{ currentVersion }}</i | ||||
|         >. | ||||
|         <span v-if="hasUpdate"> | ||||
|           New version is available! Update to | ||||
|           <a :href="nextRelease.html_url" class="next-release" target="_blank" rel="noreferrer noopener"> | ||||
|             {{ nextRelease.name }}</a | ||||
|           >. | ||||
|         </span> | ||||
|         <span v-html="$t('settings.using-version', { version: currentVersion })"></span> | ||||
|         <div | ||||
|           v-if="hasUpdate" | ||||
|           v-html="$t('settings.update-available', { nextVersion: nextRelease.name, href: nextRelease.html_url })" | ||||
|         ></div> | ||||
|       </div> | ||||
|     </section> | ||||
|  | ||||
|     <section class="section"> | ||||
|       <div class="has-underline"> | ||||
|         <h2 class="title is-4">Display</h2> | ||||
|         <h2 class="title is-4">{{ $t("settings.display") }}</h2> | ||||
|       </div> | ||||
|  | ||||
|       <div class="item"> | ||||
|         <o-switch v-model="smallerScrollbars"> {{ $t("settings.small-scrollbars") }} </o-switch> | ||||
|       </div> | ||||
|       <div class="item"> | ||||
|         <o-switch v-model="showTimestamp"> {{ $t("settings.show-timesamps") }} </o-switch> | ||||
|       </div> | ||||
|  | ||||
|       <div class="item"> | ||||
|         <o-switch v-model="softWrap"> {{ $t("settings.soft-wrap") }}</o-switch> | ||||
|       </div> | ||||
|  | ||||
|       <div class="item"> | ||||
|         <div class="columns is-vcentered"> | ||||
|           <div class="column is-narrow"> | ||||
| @@ -42,18 +51,10 @@ | ||||
|             </o-field> | ||||
|           </div> | ||||
|           <div class="column"> | ||||
|             By default, Dozzle will use your browser's locale to format time. You can force to 12 or 24 hour style. | ||||
|             {{ $t("settings.12-24-format") }} | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <div class="item"> | ||||
|           <o-switch v-model="smallerScrollbars"> Use smaller scrollbars </o-switch> | ||||
|         </div> | ||||
|         <div class="item"> | ||||
|           <o-switch v-model="showTimestamp"> Show timestamps </o-switch> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <div class="item"> | ||||
|         <div class="columns is-vcentered"> | ||||
|           <div class="column is-narrow"> | ||||
| @@ -79,37 +80,58 @@ | ||||
|               </o-dropdown> | ||||
|             </o-field> | ||||
|           </div> | ||||
|           <div class="column">Font size to use for logs</div> | ||||
|           <div class="column">{{ $t("settings.font-size") }}</div> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="item"> | ||||
|         <div class="columns is-vcentered"> | ||||
|           <div class="column is-narrow"> | ||||
|             <o-field> | ||||
|               <o-dropdown v-model="lightTheme" aria-role="list"> | ||||
|                 <template #trigger> | ||||
|                   <o-button variant="primary" type="button"> | ||||
|                     <span class="is-capitalized">{{ lightTheme }}</span> | ||||
|                     <span class="icon"> | ||||
|                       <carbon-caret-down /> | ||||
|                     </span> | ||||
|                   </o-button> | ||||
|                 </template> | ||||
|  | ||||
|                 <o-dropdown-item | ||||
|                   :value="value" | ||||
|                   aria-role="listitem" | ||||
|                   v-for="value in ['auto', 'dark', 'light']" | ||||
|                   :key="value" | ||||
|                 > | ||||
|                   <span class="is-capitalized">{{ value }}</span> | ||||
|                 </o-dropdown-item> | ||||
|               </o-dropdown> | ||||
|             </o-field> | ||||
|           </div> | ||||
|           <div class="column">{{ $t("settings.color-scheme") }}</div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </section> | ||||
|     <section class="section"> | ||||
|       <div class="has-underline"> | ||||
|         <h2 class="title is-4">Options</h2> | ||||
|         <h2 class="title is-4">{{ $t("settings.options") }}</h2> | ||||
|       </div> | ||||
|  | ||||
|       <div class="item"> | ||||
|         <o-switch v-model="search"> | ||||
|           Enable searching with Dozzle using <code>command+f</code> or <code>ctrl+f</code> | ||||
|           <span v-html="$t('settings.search')"></span> | ||||
|         </o-switch> | ||||
|       </div> | ||||
|  | ||||
|       <div class="item"> | ||||
|         <o-switch v-model="showAllContainers"> Show stopped containers </o-switch> | ||||
|       </div> | ||||
|  | ||||
|       <div class="item"> | ||||
|         <o-switch v-model="lightTheme"> Use light theme </o-switch> | ||||
|         <o-switch v-model="showAllContainers"> {{ $t("settings.show-stopped-containers") }} </o-switch> | ||||
|       </div> | ||||
|     </section> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { ref } from "vue"; | ||||
| import gt from "semver/functions/gt"; | ||||
| import config from "@/stores/config"; | ||||
| import { setTitle } from "@/composables/title"; | ||||
| import { | ||||
|   search, | ||||
|   lightTheme, | ||||
| @@ -118,24 +140,31 @@ import { | ||||
|   hourStyle, | ||||
|   showAllContainers, | ||||
|   size, | ||||
|   softWrap, | ||||
| } from "@/composables/settings"; | ||||
|  | ||||
| setTitle("Settings"); | ||||
| const { t } = useI18n(); | ||||
|  | ||||
| const currentVersion = config.version; | ||||
| const nextRelease = ref({ html_url: "", name: "" }); | ||||
| const hasUpdate = ref(false); | ||||
| setTitle(t("title.settings")); | ||||
|  | ||||
| const currentVersion = $ref(config.version); | ||||
| let nextRelease = $ref({ html_url: "", name: "" }); | ||||
| let hasUpdate = $ref(false); | ||||
|  | ||||
| async function fetchNextRelease() { | ||||
|   if (!["dev", "master"].includes(currentVersion)) { | ||||
|     const response = await fetch("https://api.github.com/repos/dozzle/dozzle/releases/latest"); | ||||
|     const response = await fetch("https://api.github.com/repos/amir20/dozzle/releases/latest"); | ||||
|     if (response.ok) { | ||||
|       const releases = await response.json(); | ||||
|       hasUpdate.value = gt(releases[0].tag_name, currentVersion); | ||||
|       nextRelease.value = releases[0]; | ||||
|       const release = await response.json(); | ||||
|       hasUpdate = gt(release.tag_name, currentVersion); | ||||
|       nextRelease = release; | ||||
|     } | ||||
|   } else { | ||||
|     hasUpdate.value = true; | ||||
|     hasUpdate = true; | ||||
|     nextRelease = { | ||||
|       html_url: "", | ||||
|       name: "master", | ||||
|     }; | ||||
|   } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,9 +1,4 @@ | ||||
| <script lang="ts" setup> | ||||
| import { useContainerStore } from "@/stores/container"; | ||||
| import { storeToRefs } from "pinia"; | ||||
| import { watch } from "vue"; | ||||
| import { useRoute, useRouter } from "vue-router"; | ||||
|  | ||||
| const router = useRouter(); | ||||
| const route = useRoute(); | ||||
|  | ||||
| @@ -15,15 +10,16 @@ watch(visibleContainers, (newValue) => { | ||||
|     if (route.query.name) { | ||||
|       const [container, _] = visibleContainers.value.filter((c) => c.name == route.query.name); | ||||
|       if (container) { | ||||
|         router.push({ name: "container", params: { id: container.id } }); | ||||
|         router.push({ name: "container-id", params: { id: container.id } }); | ||||
|       } else { | ||||
|         console.error(`No containers found matching name=${route.query.name}. Redirecting to /`); | ||||
|         router.push({ name: "default" }); | ||||
|         router.push({ name: "index" }); | ||||
|       } | ||||
|     } else { | ||||
|       console.error(`Expection query parameter name to be set. Redirecting to /`); | ||||
|       router.push({ name: "default" }); | ||||
|       router.push({ name: "index" }); | ||||
|     } | ||||
|   } | ||||
| }); | ||||
| </script> | ||||
| <template></template> | ||||
|   | ||||
| @@ -4,19 +4,14 @@ | ||||
|       <div class="container has-text-centered"> | ||||
|         <h1 class="title"> | ||||
|           404. | ||||
|           <small class="subtitle">This page does not exist.</small> | ||||
|           <small class="subtitle">{{ $t("error.page-not-found") }}</small> | ||||
|         </h1> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { setTitle } from "@/composables/title"; | ||||
| export default { | ||||
|   name: "PageNotFound", | ||||
|   setup() { | ||||
|     setTitle("Page not found"); | ||||
|   }, | ||||
| }; | ||||
| <script lang="ts" setup> | ||||
| const { t } = useI18n(); | ||||
| setTitle(t("title.page-not-found")); | ||||
| </script> | ||||
							
								
								
									
										20
									
								
								assets/pages/container/[id].vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								assets/pages/container/[id].vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| <template> | ||||
|   <search></search> | ||||
|   <log-container :id="id" show-title :scrollable="activeContainers.length > 0"> </log-container> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| const store = useContainerStore(); | ||||
| const { id } = defineProps<{ id: string }>(); | ||||
|  | ||||
| const currentContainer = store.currentContainer($$(id)); | ||||
| const { activeContainers } = storeToRefs(store); | ||||
|  | ||||
| setTitle("loading"); | ||||
|  | ||||
| onMounted(() => { | ||||
|   setTitle(currentContainer.value?.name); | ||||
| }); | ||||
|  | ||||
| watchEffect(() => setTitle(currentContainer.value?.name)); | ||||
| </script> | ||||
| @@ -1,7 +0,0 @@ | ||||
| export { default as Index } from "./Index.vue"; | ||||
| export { default as ContainerNotFound } from "./ContainerNotFound.vue"; | ||||
| export { default as Show } from "./Show.vue"; | ||||
| export { default as Container } from "./Container.vue"; | ||||
| export { default as Settings } from "./Settings.vue"; | ||||
| export { default as PageNotFound } from "./PageNotFound.vue"; | ||||
| export { default as Login } from "./Login.vue"; | ||||
							
								
								
									
										170
									
								
								assets/pages/index.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										170
									
								
								assets/pages/index.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,170 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <section class="hero is-small mt-4"> | ||||
|       <div class="hero-body"> | ||||
|         <div class="container"> | ||||
|           <div class="columns"> | ||||
|             <div class="column is-narrow" v-if="secured"> | ||||
|               <a class="button is-primary is-small" :href="`${base}/logout`">{{ $t("button.logout") }}</a> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </section> | ||||
|     <section class="level section"> | ||||
|       <div class="level-item has-text-centered"> | ||||
|         <div> | ||||
|           <p class="title">{{ containers.length }}</p> | ||||
|           <p class="heading">{{ $t("label.total-containers") }}</p> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="level-item has-text-centered"> | ||||
|         <div> | ||||
|           <p class="title">{{ runningContainers.length }}</p> | ||||
|           <p class="heading">{{ $t("label.running") }}</p> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="level-item has-text-centered"> | ||||
|         <div> | ||||
|           <p class="title" data-ci-skip>{{ totalCpu }}%</p> | ||||
|           <p class="heading">{{ $t("label.total-cpu-usage") }}</p> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="level-item has-text-centered"> | ||||
|         <div> | ||||
|           <p class="title" data-ci-skip>{{ formatBytes(totalMem) }}</p> | ||||
|           <p class="heading">{{ $t("label.total-mem-usage") }}</p> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="level-item has-text-centered"> | ||||
|         <div> | ||||
|           <p class="title">{{ version }}</p> | ||||
|           <p class="heading">{{ $t("label.dozzle-version") }}</p> | ||||
|         </div> | ||||
|       </div> | ||||
|     </section> | ||||
|  | ||||
|     <section class="columns is-centered section is-marginless"> | ||||
|       <div class="column is-4"> | ||||
|         <div class="panel"> | ||||
|           <p class="panel-heading">{{ $t("label.containers") }}</p> | ||||
|           <div class="panel-block"> | ||||
|             <p class="control has-icons-left"> | ||||
|               <input | ||||
|                 class="input" | ||||
|                 type="text" | ||||
|                 :placeholder="$t('placeholder.search-containers')" | ||||
|                 v-model="query" | ||||
|                 @keyup.esc="query = ''" | ||||
|                 @keyup.enter="onEnter()" | ||||
|               /> | ||||
|               <span class="icon is-left"> | ||||
|                 <search-icon /> | ||||
|               </span> | ||||
|             </p> | ||||
|           </div> | ||||
|           <p class="panel-tabs" v-if="query === ''"> | ||||
|             <a :class="{ 'is-active': sort === 'running' }" @click="sort = 'running'">{{ $t("label.running") }}</a> | ||||
|             <a :class="{ 'is-active': sort === 'all' }" @click="sort = 'all'">{{ $t("label.all") }}</a> | ||||
|           </p> | ||||
|           <router-link | ||||
|             :to="{ name: 'container-id', params: { id: item.id } }" | ||||
|             v-for="item in data.slice(0, 10)" | ||||
|             :key="item.id" | ||||
|             class="panel-block" | ||||
|           > | ||||
|             <span class="name">{{ item.name }}</span> | ||||
|  | ||||
|             <div class="subtitle is-7 status"> | ||||
|               <past-time :date="new Date(item.created * 1000)"></past-time> | ||||
|             </div> | ||||
|           </router-link> | ||||
|         </div> | ||||
|       </div> | ||||
|     </section> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import SearchIcon from "~icons/mdi-light/magnify"; | ||||
| import { useFuse } from "@vueuse/integrations/useFuse"; | ||||
|  | ||||
| const { base, version, secured } = config; | ||||
| const containerStore = useContainerStore(); | ||||
| const { containers } = storeToRefs(containerStore); | ||||
| const router = useRouter(); | ||||
|  | ||||
| const sort = $ref("running"); | ||||
| const query = ref(""); | ||||
|  | ||||
| const mostRecentContainers = $computed(() => [...containers.value].sort((a, b) => b.created - a.created)); | ||||
| const runningContainers = $computed(() => mostRecentContainers.filter((c) => c.state === "running")); | ||||
|  | ||||
| const { results } = useFuse(query, containers, { | ||||
|   fuseOptions: { keys: ["name"] }, | ||||
|   matchAllWhenSearchEmpty: false, | ||||
| }); | ||||
| const data = computed(() => { | ||||
|   if (results.value.length) { | ||||
|     return results.value.map(({ item }) => item); | ||||
|   } | ||||
|   switch (sort) { | ||||
|     case "all": | ||||
|       return mostRecentContainers; | ||||
|     case "running": | ||||
|       return runningContainers; | ||||
|     default: | ||||
|       throw `Invalid sort order: ${sort}`; | ||||
|   } | ||||
| }); | ||||
|  | ||||
| let totalCpu = $ref(0); | ||||
| useIntervalFn( | ||||
|   () => { | ||||
|     totalCpu = runningContainers.reduce((acc, c) => acc + (c.stat?.cpu ?? 0), 0); | ||||
|   }, | ||||
|   1000, | ||||
|   { immediate: true } | ||||
| ); | ||||
|  | ||||
| let totalMem = $ref(0); | ||||
| useIntervalFn( | ||||
|   () => { | ||||
|     totalMem = runningContainers.reduce((acc, c) => acc + (c.stat?.memoryUsage ?? 0), 0); | ||||
|   }, | ||||
|   1000, | ||||
|   { immediate: true } | ||||
| ); | ||||
|  | ||||
| function onEnter() { | ||||
|   if (data.value.length > 0) { | ||||
|     const item = data.value[0]; | ||||
|     router.push({ name: "container-id", params: { id: item.id } }); | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| <style lang="scss" scoped> | ||||
| .panel { | ||||
|   border: 1px solid var(--border-color); | ||||
|   .panel-block, | ||||
|   .panel-tabs { | ||||
|     border-color: var(--border-color); | ||||
|     .is-active { | ||||
|       border-color: var(--border-hover-color); | ||||
|     } | ||||
|     .name { | ||||
|       text-overflow: ellipsis; | ||||
|       white-space: nowrap; | ||||
|       overflow: hidden; | ||||
|     } | ||||
|     .status { | ||||
|       margin-left: auto; | ||||
|       white-space: nowrap; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| .icon { | ||||
|   padding: 10px 3px; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										79
									
								
								assets/pages/login.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								assets/pages/login.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,79 @@ | ||||
| <template> | ||||
|   <div class="hero is-halfheight"> | ||||
|     <div class="hero-body"> | ||||
|       <div class="container"> | ||||
|         <section class="columns is-centered section"> | ||||
|           <div class="column is-4"> | ||||
|             <div class="card"> | ||||
|               <div class="card-content"> | ||||
|                 <form action="" method="post" @submit.prevent="onLogin" ref="form"> | ||||
|                   <div class="field"> | ||||
|                     <label class="label">{{ $t("label.username") }}</label> | ||||
|                     <div class="control"> | ||||
|                       <input | ||||
|                         class="input" | ||||
|                         type="text" | ||||
|                         name="username" | ||||
|                         autocomplete="username" | ||||
|                         v-model="username" | ||||
|                         autofocus | ||||
|                       /> | ||||
|                     </div> | ||||
|                   </div> | ||||
|  | ||||
|                   <div class="field"> | ||||
|                     <label class="label">{{ $t("label.password") }}</label> | ||||
|                     <div class="control"> | ||||
|                       <input | ||||
|                         class="input" | ||||
|                         type="password" | ||||
|                         name="password" | ||||
|                         autocomplete="current-password" | ||||
|                         v-model="password" | ||||
|                       /> | ||||
|                     </div> | ||||
|                     <p class="help is-danger" v-if="error">{{ $t("error.invalid-auth") }}</p> | ||||
|                   </div> | ||||
|                   <div class="field is-grouped is-grouped-centered mt-5"> | ||||
|                     <p class="control"> | ||||
|                       <button class="button is-primary" type="submit">{{ $t("button.login") }}</button> | ||||
|                     </p> | ||||
|                   </div> | ||||
|                 </form> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|         </section> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| const { t } = useI18n(); | ||||
|  | ||||
| setTitle(t("title.login")); | ||||
|  | ||||
| let error = $ref(false); | ||||
| let username = $ref(""); | ||||
| let password = $ref(""); | ||||
| let form: HTMLFormElement = $ref(); | ||||
|  | ||||
| async function onLogin() { | ||||
|   const response = await fetch(`${config.base}/api/validateCredentials`, { | ||||
|     body: new FormData(form), | ||||
|     method: "post", | ||||
|   }); | ||||
|  | ||||
|   if (response.status == 200) { | ||||
|     error = false; | ||||
|     window.location.href = `${config.base}/`; | ||||
|   } else { | ||||
|     error = true; | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| <route lang="yaml"> | ||||
| meta: | ||||
|   layout: splash | ||||
| </route> | ||||
							
								
								
									
										203
									
								
								assets/pages/settings.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										203
									
								
								assets/pages/settings.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,203 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <section class="section"> | ||||
|       <div class="has-underline"> | ||||
|         <h2 class="title is-4">{{ $t("settings.about") }}</h2> | ||||
|       </div> | ||||
|  | ||||
|       <div> | ||||
|         <span v-html="$t('settings.using-version', { version: currentVersion })"></span> | ||||
|         <div | ||||
|           v-if="hasUpdate" | ||||
|           v-html="$t('settings.update-available', { nextVersion: nextRelease.name, href: nextRelease.html_url })" | ||||
|         ></div> | ||||
|       </div> | ||||
|     </section> | ||||
|  | ||||
|     <section class="section"> | ||||
|       <div class="has-underline"> | ||||
|         <h2 class="title is-4">{{ $t("settings.display") }}</h2> | ||||
|       </div> | ||||
|  | ||||
|       <div class="item"> | ||||
|         <o-switch v-model="smallerScrollbars"> {{ $t("settings.small-scrollbars") }} </o-switch> | ||||
|       </div> | ||||
|       <div class="item"> | ||||
|         <o-switch v-model="showTimestamp"> {{ $t("settings.show-timesamps") }} </o-switch> | ||||
|       </div> | ||||
|  | ||||
|       <div class="item"> | ||||
|         <o-switch v-model="softWrap"> {{ $t("settings.soft-wrap") }}</o-switch> | ||||
|       </div> | ||||
|  | ||||
|       <div class="item"> | ||||
|         <div class="columns is-vcentered"> | ||||
|           <div class="column is-narrow"> | ||||
|             <o-field> | ||||
|               <o-dropdown v-model="hourStyle" aria-role="list"> | ||||
|                 <template #trigger> | ||||
|                   <o-button variant="primary" type="button"> | ||||
|                     <span class="is-capitalized">{{ hourStyle }}</span> | ||||
|                     <span class="icon"> | ||||
|                       <carbon-caret-down /> | ||||
|                     </span> | ||||
|                   </o-button> | ||||
|                 </template> | ||||
|  | ||||
|                 <o-dropdown-item :value="value" aria-role="listitem" v-for="value in ['auto', '12', '24']" :key="value"> | ||||
|                   <span class="is-capitalized">{{ value }}</span> | ||||
|                 </o-dropdown-item> | ||||
|               </o-dropdown> | ||||
|             </o-field> | ||||
|           </div> | ||||
|           <div class="column"> | ||||
|             {{ $t("settings.12-24-format") }} | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="item"> | ||||
|         <div class="columns is-vcentered"> | ||||
|           <div class="column is-narrow"> | ||||
|             <o-field> | ||||
|               <o-dropdown v-model="size" aria-role="list"> | ||||
|                 <template #trigger> | ||||
|                   <o-button variant="primary" type="button"> | ||||
|                     <span class="is-capitalized">{{ size }}</span> | ||||
|                     <span class="icon"> | ||||
|                       <carbon-caret-down /> | ||||
|                     </span> | ||||
|                   </o-button> | ||||
|                 </template> | ||||
|  | ||||
|                 <o-dropdown-item | ||||
|                   :value="value" | ||||
|                   aria-role="listitem" | ||||
|                   v-for="value in ['small', 'medium', 'large']" | ||||
|                   :key="value" | ||||
|                 > | ||||
|                   <span class="is-capitalized">{{ value }}</span> | ||||
|                 </o-dropdown-item> | ||||
|               </o-dropdown> | ||||
|             </o-field> | ||||
|           </div> | ||||
|           <div class="column">{{ $t("settings.font-size") }}</div> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="item"> | ||||
|         <div class="columns is-vcentered"> | ||||
|           <div class="column is-narrow"> | ||||
|             <o-field> | ||||
|               <o-dropdown v-model="lightTheme" aria-role="list"> | ||||
|                 <template #trigger> | ||||
|                   <o-button variant="primary" type="button"> | ||||
|                     <span class="is-capitalized">{{ lightTheme }}</span> | ||||
|                     <span class="icon"> | ||||
|                       <carbon-caret-down /> | ||||
|                     </span> | ||||
|                   </o-button> | ||||
|                 </template> | ||||
|  | ||||
|                 <o-dropdown-item | ||||
|                   :value="value" | ||||
|                   aria-role="listitem" | ||||
|                   v-for="value in ['auto', 'dark', 'light']" | ||||
|                   :key="value" | ||||
|                 > | ||||
|                   <span class="is-capitalized">{{ value }}</span> | ||||
|                 </o-dropdown-item> | ||||
|               </o-dropdown> | ||||
|             </o-field> | ||||
|           </div> | ||||
|           <div class="column">{{ $t("settings.color-scheme") }}</div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </section> | ||||
|     <section class="section"> | ||||
|       <div class="has-underline"> | ||||
|         <h2 class="title is-4">{{ $t("settings.options") }}</h2> | ||||
|       </div> | ||||
|  | ||||
|       <div class="item"> | ||||
|         <o-switch v-model="search"> | ||||
|           <span v-html="$t('settings.search')"></span> | ||||
|         </o-switch> | ||||
|       </div> | ||||
|  | ||||
|       <div class="item"> | ||||
|         <o-switch v-model="showAllContainers"> {{ $t("settings.show-stopped-containers") }} </o-switch> | ||||
|       </div> | ||||
|     </section> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import gt from "semver/functions/gt"; | ||||
| import { | ||||
|   search, | ||||
|   lightTheme, | ||||
|   smallerScrollbars, | ||||
|   showTimestamp, | ||||
|   hourStyle, | ||||
|   showAllContainers, | ||||
|   size, | ||||
|   softWrap, | ||||
| } from "@/composables/settings"; | ||||
|  | ||||
| const { t } = useI18n(); | ||||
|  | ||||
| setTitle(t("title.settings")); | ||||
|  | ||||
| const currentVersion = $ref(config.version); | ||||
| let nextRelease = $ref({ html_url: "", name: "" }); | ||||
| let hasUpdate = $ref(false); | ||||
|  | ||||
| async function fetchNextRelease() { | ||||
|   if (!["dev", "master"].includes(currentVersion)) { | ||||
|     const response = await fetch("https://api.github.com/repos/amir20/dozzle/releases/latest"); | ||||
|     if (response.ok) { | ||||
|       const release = await response.json(); | ||||
|       hasUpdate = gt(release.tag_name, currentVersion); | ||||
|       nextRelease = release; | ||||
|     } | ||||
|   } else { | ||||
|     hasUpdate = true; | ||||
|     nextRelease = { | ||||
|       html_url: "", | ||||
|       name: "master", | ||||
|     }; | ||||
|   } | ||||
| } | ||||
|  | ||||
| fetchNextRelease(); | ||||
| </script> | ||||
| <style lang="scss" scoped> | ||||
| .title { | ||||
|   color: var(--title-color); | ||||
| } | ||||
|  | ||||
| a.next-release { | ||||
|   text-decoration: underline; | ||||
|   &:hover { | ||||
|     text-decoration: none; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .section { | ||||
|   padding: 1rem 1.5rem; | ||||
| } | ||||
|  | ||||
| .has-underline { | ||||
|   border-bottom: 1px solid var(--border-color); | ||||
|   padding: 1em 0px; | ||||
|   margin-bottom: 1em; | ||||
| } | ||||
|  | ||||
| .item { | ||||
|   padding: 1em 0; | ||||
| } | ||||
|  | ||||
| code { | ||||
|   border-radius: 4px; | ||||
|   background-color: #444; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										25
									
								
								assets/pages/show.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								assets/pages/show.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| <script lang="ts" setup> | ||||
| const router = useRouter(); | ||||
| const route = useRoute(); | ||||
|  | ||||
| const store = useContainerStore(); | ||||
| const { visibleContainers } = storeToRefs(store); | ||||
|  | ||||
| watch(visibleContainers, (newValue) => { | ||||
|   if (newValue) { | ||||
|     if (route.query.name) { | ||||
|       const [container, _] = visibleContainers.value.filter((c) => c.name == route.query.name); | ||||
|       if (container) { | ||||
|         router.push({ name: "container-id", params: { id: container.id } }); | ||||
|       } else { | ||||
|         console.error(`No containers found matching name=${route.query.name}. Redirecting to /`); | ||||
|         router.push({ name: "index" }); | ||||
|       } | ||||
|     } else { | ||||
|       console.error(`Expection query parameter name to be set. Redirecting to /`); | ||||
|       router.push({ name: "index" }); | ||||
|     } | ||||
|   } | ||||
| }); | ||||
| </script> | ||||
| <template></template> | ||||
| @@ -1,6 +1,20 @@ | ||||
| const text = document.querySelector("script#config__json")?.textContent || "{}"; | ||||
|  | ||||
| const config = JSON.parse(text); | ||||
| interface Config { | ||||
|   version: string; | ||||
|   base: string; | ||||
|   authorizationNeeded: boolean | "false" | "true"; | ||||
|   secured: boolean | "false" | "true"; | ||||
|   maxLogs: number; | ||||
| } | ||||
|  | ||||
| const pageConfig = JSON.parse(text); | ||||
|  | ||||
| const config: Config = { | ||||
|   maxLogs: 600, | ||||
|   ...pageConfig, | ||||
| }; | ||||
|  | ||||
| if (config.version == "{{ .Version }}") { | ||||
|   config.version = "master"; | ||||
|   config.base = ""; | ||||
| @@ -11,4 +25,5 @@ if (config.version == "{{ .Version }}") { | ||||
|   config.authorizationNeeded = config.authorizationNeeded === "true"; | ||||
|   config.secured = config.secured === "true"; | ||||
| } | ||||
| export default config; | ||||
|  | ||||
| export default config as Config; | ||||
|   | ||||
| @@ -1,13 +1,11 @@ | ||||
| import { acceptHMRUpdate, defineStore } from "pinia"; | ||||
| import { ref, Ref, computed } from "vue"; | ||||
|  | ||||
| import { showAllContainers } from "@/composables/settings"; | ||||
| import config from "@/stores/config"; | ||||
| import type { Container, ContainerStat } from "@/types/Container"; | ||||
| import { Ref, UnwrapNestedRefs } from "vue"; | ||||
| import type { ContainerJson, ContainerStat } from "@/types/Container"; | ||||
| import { Container } from "@/models/Container"; | ||||
|  | ||||
| export const useContainerStore = defineStore("container", () => { | ||||
|   const containers = ref<Container[]>([]); | ||||
|   const activeContainerIds = ref<string[]>([]); | ||||
|   const containers: Ref<Container[]> = ref([]); | ||||
|   const activeContainerIds: Ref<string[]> = ref([]); | ||||
|  | ||||
|   const allContainersById = computed(() => | ||||
|     containers.value.reduce((acc, container) => { | ||||
| @@ -24,29 +22,45 @@ export const useContainerStore = defineStore("container", () => { | ||||
|   const activeContainers = computed(() => activeContainerIds.value.map((id) => allContainersById.value[id])); | ||||
|  | ||||
|   const es = new EventSource(`${config.base}/api/events/stream`); | ||||
|   es.addEventListener( | ||||
|     "containers-changed", | ||||
|     (e: Event) => (containers.value = JSON.parse((e as MessageEvent).data)), | ||||
|     false | ||||
|   es.addEventListener("containers-changed", (e: Event) => | ||||
|     setContainers(JSON.parse((e as MessageEvent).data) as ContainerJson[]) | ||||
|   ); | ||||
|   es.addEventListener( | ||||
|     "container-stat", | ||||
|     (e) => { | ||||
|       const stat = JSON.parse((e as MessageEvent).data) as ContainerStat; | ||||
|       const container = allContainersById.value[stat.id]; | ||||
|       if (container) { | ||||
|         container.stat = stat; | ||||
|   es.addEventListener("container-stat", (e) => { | ||||
|     const stat = JSON.parse((e as MessageEvent).data) as ContainerStat; | ||||
|     const container = allContainersById.value[stat.id] as unknown as UnwrapNestedRefs<Container>; | ||||
|     if (container) { | ||||
|       const { id, ...rest } = stat; | ||||
|       container.stat = rest; | ||||
|     } | ||||
|   }); | ||||
|   es.addEventListener("container-die", (e) => { | ||||
|     const event = JSON.parse((e as MessageEvent).data) as { actorId: string }; | ||||
|     const container = allContainersById.value[event.actorId]; | ||||
|     if (container) { | ||||
|       container.state = "dead"; | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   const setContainers = (newContainers: ContainerJson[]) => { | ||||
|     containers.value = newContainers.map((c) => { | ||||
|       const existing = allContainersById.value[c.id]; | ||||
|       if (existing) { | ||||
|         existing.status = c.status; | ||||
|         existing.state = c.state; | ||||
|         return existing; | ||||
|       } | ||||
|     }, | ||||
|     false | ||||
|   ); | ||||
|   // es.addEventListener("container-die", (e) => store.dispatch("UPDATE_CONTAINER", JSON.parse(e.data)), false); | ||||
|       return new Container(c.id, c.created, c.image, c.name, c.command, c.status, c.state); | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   const currentContainer = (id: Ref<string>) => computed(() => allContainersById.value[id.value]); | ||||
|   const appendActiveContainer = ({ id }: Container) => activeContainerIds.value.push(id); | ||||
|   const removeActiveContainer = ({ id }: Container) => | ||||
|     activeContainerIds.value.splice(activeContainerIds.value.indexOf(id), 1); | ||||
|      | ||||
|  | ||||
|   const ready = ref(false); | ||||
|   watchOnce(containers, () => (ready.value = true)); | ||||
|  | ||||
|   return { | ||||
|     containers, | ||||
|     activeContainerIds, | ||||
| @@ -56,6 +70,7 @@ export const useContainerStore = defineStore("container", () => { | ||||
|     currentContainer, | ||||
|     appendActiveContainer, | ||||
|     removeActiveContainer, | ||||
|     ready, | ||||
|   }; | ||||
| }); | ||||
|  | ||||
|   | ||||
| @@ -17,6 +17,7 @@ $menu-item-hover-color: var(--menu-item-hover-color); | ||||
|  | ||||
| $text-strong: var(--text-strong-color); | ||||
| $text: var(--text-color); | ||||
| $text-light: var(--text-light-color); | ||||
|  | ||||
| $panel-heading-background-color: var(--panel-heading-background-color); | ||||
| $panel-heading-color: var(--panel-heading-color); | ||||
| @@ -35,9 +36,10 @@ $light-toolbar-color: rgba($grey-darker, 0.7); | ||||
| @import "@oruga-ui/theme-bulma/dist/scss/components/switch.scss"; | ||||
| @import "@oruga-ui/theme-bulma/dist/scss/components/tooltip.scss"; | ||||
| @import "@oruga-ui/theme-bulma/dist/scss/components/dropdown.scss"; | ||||
| @import "@oruga-ui/theme-bulma/dist/scss/components/skeleton.scss"; | ||||
| @import "splitpanes/dist/splitpanes.css"; | ||||
|  | ||||
| html { | ||||
| @mixin dark { | ||||
|   --scheme-main: #{$black}; | ||||
|   --scheme-main-bis: #{$black-bis}; | ||||
|   --scheme-main-ter: #{$black-ter}; | ||||
| @@ -51,6 +53,7 @@ html { | ||||
|  | ||||
|   --body-background-color: #{$black-bis}; | ||||
|   --action-toolbar-background-color: #{$dark-toolbar-color}; | ||||
|   --body-color: #{$grey-lighter}; | ||||
|  | ||||
|   --menu-item-active-background-color: var(--primary-color); | ||||
|   --menu-item-color: hsl(0, 6%, 87%); | ||||
| @@ -62,9 +65,10 @@ html { | ||||
|  | ||||
|   --text-strong-color: #{$grey-lightest}; | ||||
|   --text-color: #{$grey-lighter}; | ||||
|   --text-light-color: #{$grey}; | ||||
| } | ||||
|  | ||||
| [data-theme="light"] { | ||||
| @mixin light { | ||||
|   --scheme-main: #{$white}; | ||||
|   --scheme-main-bis: #{$white-bis}; | ||||
|   --scheme-main-ter: #{$white-ter}; | ||||
| @@ -80,15 +84,37 @@ html { | ||||
|   --action-toolbar-background-color: #{$light-toolbar-color}; | ||||
|   --body-color: #{$grey-darker}; | ||||
|  | ||||
|   --menu-item-active-background-color: var(--primary-color); | ||||
|   --menu-item-color: #{$grey-dark}; | ||||
|   --menu-item-hover-background-color: #eee8e7; | ||||
|   --menu-item-hover-color: #{black-ter}; | ||||
|   --menu-item-hover-color: #{$black-ter}; | ||||
|  | ||||
|   --panel-heading-background-color: var(--secondary-color); | ||||
|   --panel-heading-color: var(--text-strong-color); | ||||
|  | ||||
|   --text-strong-color: #{$grey-dark}; | ||||
|   --text-color: #{$grey-darker}; | ||||
|   --text-light-color: #{$grey}; | ||||
| } | ||||
|  | ||||
| [data-theme="dark"] { | ||||
|   @include dark; | ||||
| } | ||||
|  | ||||
| [data-theme="light"] { | ||||
|   @include light; | ||||
| } | ||||
|  | ||||
| @media (prefers-color-scheme: dark) { | ||||
|   html { | ||||
|     @include dark; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media (prefers-color-scheme: light) { | ||||
|   html { | ||||
|     @include light; | ||||
|   } | ||||
| } | ||||
|  | ||||
| html { | ||||
| @@ -133,6 +159,12 @@ html.has-custom-scrollbars { | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media screen and (max-device-width: 480px) { | ||||
|   body { | ||||
|     -webkit-text-size-adjust: 100%; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .splitpanes__splitter { | ||||
|   z-index: 99; | ||||
| } | ||||
| @@ -150,3 +182,19 @@ html.has-custom-scrollbars { | ||||
| .button .button-wrapper > span { | ||||
|   display: contents; | ||||
| } | ||||
|  | ||||
| mark { | ||||
|   border-radius: 2px; | ||||
|   background-color: var(--secondary-color); | ||||
|   animation: pops 200ms ease-out; | ||||
|   display: inline-block; | ||||
| } | ||||
|  | ||||
| @keyframes pops { | ||||
|   0% { | ||||
|     transform: scale(1.5); | ||||
|   } | ||||
|   100% { | ||||
|     transform: scale(1.05); | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										22
									
								
								assets/types/Container.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										22
									
								
								assets/types/Container.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -1,16 +1,18 @@ | ||||
| export interface Container { | ||||
|   readonly id: string; | ||||
|   readonly created: number; | ||||
|   readonly image: string; | ||||
|   readonly name: string; | ||||
|   readonly state: "created" | "running" | "exited" | "dead" | "paused" | "restarting"; | ||||
|   readonly status: string; | ||||
|   stat?: ContainerStat; | ||||
| } | ||||
|  | ||||
| export interface ContainerStat { | ||||
|   readonly id: string; | ||||
|   readonly cpu: number; | ||||
|   readonly memory: number; | ||||
|   readonly memoryUsage: number; | ||||
| } | ||||
|  | ||||
| export type ContainerJson = { | ||||
|   readonly id: string; | ||||
|   readonly created: number; | ||||
|   readonly image: string; | ||||
|   readonly name: string; | ||||
|   readonly command: string; | ||||
|   readonly status: string; | ||||
|   readonly state: ContainerState; | ||||
| }; | ||||
|  | ||||
| export type ContainerState = "created" | "running" | "exited" | "dead" | "paused" | "restarting"; | ||||
|   | ||||
							
								
								
									
										6
									
								
								assets/types/LogEntry.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								assets/types/LogEntry.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -1,6 +0,0 @@ | ||||
| export interface LogEntry { | ||||
|   date: Date; | ||||
|   message: string; | ||||
|   key: string; | ||||
|   event?: string; | ||||
| } | ||||
							
								
								
									
										1
									
								
								assets/types/Point.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								assets/types/Point.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| type Point = { x: number; y: number }; | ||||
							
								
								
									
										47
									
								
								assets/utils/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								assets/utils/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | ||||
| import { Container } from "@/models/Container"; | ||||
| import { useStorage } from "@vueuse/core"; | ||||
| import { computed, ComputedRef } from "vue"; | ||||
|  | ||||
| export function formatBytes(bytes: number, decimals = 2) { | ||||
|   if (bytes === 0) return "0 Bytes"; | ||||
|   const k = 1024; | ||||
|   const dm = decimals < 0 ? 0 : decimals; | ||||
|   const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; | ||||
|   const i = Math.floor(Math.log(bytes) / Math.log(k)); | ||||
|   return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; | ||||
| } | ||||
|  | ||||
| export function getDeep(obj: Record<string, any>, path: string[]) { | ||||
|   return path.reduce((acc, key) => acc?.[key], obj); | ||||
| } | ||||
|  | ||||
| export function isObject(value: any): value is Record<string, any> { | ||||
|   return typeof value === "object" && value !== null && !Array.isArray(value); | ||||
| } | ||||
|  | ||||
| export function flattenJSON(obj: Record<string, any>, path: string[] = []) { | ||||
|   const result: Record<string, any> = {}; | ||||
|   Object.keys(obj).forEach((key) => { | ||||
|     const value = obj[key]; | ||||
|     const newPath = path.concat(key); | ||||
|     if (isObject(value)) { | ||||
|       Object.assign(result, flattenJSON(value, newPath)); | ||||
|     } else { | ||||
|       result[newPath.join(".")] = value; | ||||
|     } | ||||
|   }); | ||||
|   return result; | ||||
| } | ||||
|  | ||||
| export function arrayEquals(a: string[], b: string[]): boolean { | ||||
|   return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((val, index) => val === b[index]); | ||||
| } | ||||
|  | ||||
| export function persistentVisibleKeys(container: ComputedRef<Container>) { | ||||
|   return computed(() => useStorage(stripVersion(container.value.image) + ":" + container.value.command, [])); | ||||
| } | ||||
|  | ||||
| export function stripVersion(label: string) { | ||||
|   const [name, _] = label.split(":"); | ||||
|   return name; | ||||
| } | ||||
							
								
								
									
										17
									
								
								docker/calculation.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								docker/calculation.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| package docker | ||||
|  | ||||
| import "github.com/docker/docker/api/types" | ||||
|  | ||||
| func calculateMemUsageUnixNoCache(mem types.MemoryStats) float64 { | ||||
| 	// re implementation of the docker calculation | ||||
| 	// https://github.com/docker/cli/blob/53f8ed4bec07084db4208f55987a2ea94b7f01d6/cli/command/container/stats_helpers.go#L227-L249 | ||||
| 	// cgroup v1 | ||||
| 	if v, isCGroup := mem.Stats["total_inactive_file"]; isCGroup && v < mem.Usage { | ||||
| 		return float64(mem.Usage - v) | ||||
| 	} | ||||
| 	// cgroup v2 | ||||
| 	if v := mem.Stats["inactive_file"]; v < mem.Usage { | ||||
| 		return float64(mem.Usage - v) | ||||
| 	} | ||||
| 	return float64(mem.Usage) | ||||
| } | ||||
							
								
								
									
										57
									
								
								docker/calculation_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								docker/calculation_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| package docker | ||||
|  | ||||
| import ( | ||||
| 	"github.com/docker/docker/api/types" | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"testing" | ||||
| ) | ||||
|  | ||||
| func Test_calculateMemUsageUnixNoCache(t *testing.T) { | ||||
| 	type args struct { | ||||
| 		mem types.MemoryStats | ||||
| 	} | ||||
| 	tests := []struct { | ||||
| 		name string | ||||
| 		args args | ||||
| 		want float64 | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name: "with cgroup v1", | ||||
| 			args: args{ | ||||
| 				mem: types.MemoryStats{ | ||||
| 					Usage: 100, | ||||
| 					Stats: map[string]uint64{ | ||||
| 						"total_inactive_file": 1, | ||||
| 					}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			want: 99, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "with cgroup v2", | ||||
| 			args: args{ | ||||
| 				mem: types.MemoryStats{ | ||||
| 					Usage: 100, | ||||
| 					Stats: map[string]uint64{ | ||||
| 						"inactive_file": 2, | ||||
| 					}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			want: 98, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "without cgroup", | ||||
| 			args: args{ | ||||
| 				mem: types.MemoryStats{ | ||||
| 					Usage: 100, | ||||
| 				}, | ||||
| 			}, | ||||
| 			want: 100, | ||||
| 		}, | ||||
| 	} | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			assert.Equalf(t, tt.want, calculateMemUsageUnixNoCache(tt.args.mem), "calculateMemUsageUnixNoCache(%v)", tt.args.mem) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
| @@ -28,6 +28,7 @@ type dockerProxy interface { | ||||
| 	Events(context.Context, types.EventsOptions) (<-chan events.Message, <-chan error) | ||||
| 	ContainerInspect(ctx context.Context, containerID string) (types.ContainerJSON, error) | ||||
| 	ContainerStats(ctx context.Context, containerID string, stream bool) (types.ContainerStats, error) | ||||
| 	Ping(ctx context.Context) (types.Ping, error) | ||||
| } | ||||
|  | ||||
| // Client is a proxy around the docker client | ||||
| @@ -38,6 +39,7 @@ type Client interface { | ||||
| 	Events(context.Context) (<-chan ContainerEvent, <-chan error) | ||||
| 	ContainerLogsBetweenDates(context.Context, string, time.Time, time.Time) (io.ReadCloser, error) | ||||
| 	ContainerStats(context.Context, string, chan<- ContainerStat) error | ||||
| 	Ping(context.Context) (types.Ping, error) | ||||
| } | ||||
|  | ||||
| // NewClientWithFilters creates a new instance of Client with docker filters | ||||
| @@ -136,11 +138,16 @@ func (d *dockerClient) ContainerStats(ctx context.Context, id string, stats chan | ||||
| 				log.Errorf("decoder for stats api returned an unknown error %v", err) | ||||
| 			} | ||||
|  | ||||
| 			ncpus := uint8(v.CPUStats.OnlineCPUs) | ||||
| 			if ncpus == 0 { | ||||
| 				ncpus = uint8(len(v.CPUStats.CPUUsage.PercpuUsage)) | ||||
| 			} | ||||
|  | ||||
| 			var ( | ||||
| 				cpuDelta    = float64(v.CPUStats.CPUUsage.TotalUsage) - float64(v.PreCPUStats.CPUUsage.TotalUsage) | ||||
| 				systemDelta = float64(v.CPUStats.SystemUsage) - float64(v.PreCPUStats.SystemUsage) | ||||
| 				cpuPercent  = int64((cpuDelta / systemDelta) * float64(len(v.CPUStats.CPUUsage.PercpuUsage)) * 100) | ||||
| 				memUsage    = int64(v.MemoryStats.Usage - v.MemoryStats.Stats["cache"]) | ||||
| 				cpuPercent  = int64((cpuDelta / systemDelta) * float64(ncpus) * 100) | ||||
| 				memUsage    = int64(calculateMemUsageUnixNoCache(v.MemoryStats)) | ||||
| 				memPercent  = int64(float64(memUsage) / float64(v.MemoryStats.Limit) * 100) | ||||
| 			) | ||||
|  | ||||
| @@ -165,6 +172,14 @@ func (d *dockerClient) ContainerStats(ctx context.Context, id string, stats chan | ||||
| func (d *dockerClient) ContainerLogs(ctx context.Context, id string, tailSize int, since string) (io.ReadCloser, error) { | ||||
| 	log.WithField("id", id).WithField("since", since).Debug("streaming logs for container") | ||||
|  | ||||
| 	if since != "" { | ||||
| 		if millis, err := strconv.ParseInt(since, 10, 64); err == nil { | ||||
| 			since = time.UnixMicro(millis).Add(time.Millisecond).Format(time.RFC3339Nano) | ||||
| 		} else { | ||||
| 			log.WithError(err).Debug("unable to parse since") | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	options := types.ContainerLogsOptions{ | ||||
| 		ShowStdout: true, | ||||
| 		ShowStderr: true, | ||||
| @@ -241,3 +256,7 @@ func (d *dockerClient) ContainerLogsBetweenDates(ctx context.Context, id string, | ||||
|  | ||||
| 	return newLogReader(reader, containerJSON.Config.Tty), nil | ||||
| } | ||||
|  | ||||
| func (d *dockerClient) Ping(ctx context.Context) (types.Ping, error) { | ||||
| 	return d.cli.Ping(ctx) | ||||
| } | ||||
|   | ||||
| @@ -26,3 +26,9 @@ type ContainerEvent struct { | ||||
| 	ActorID string `json:"actorId"` | ||||
| 	Name    string `json:"name"` | ||||
| } | ||||
|  | ||||
| type LogEvent struct { | ||||
| 	Message   any    `json:"m,omitempty"` | ||||
| 	Timestamp int64  `json:"ts"` | ||||
| 	Id        uint32 `json:"id,omitempty"` | ||||
| } | ||||
|   | ||||
| @@ -1,12 +1,11 @@ | ||||
| FROM cypress/included:9.0.0 | ||||
| FROM cypress/included:10.10.0 | ||||
|  | ||||
| RUN curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm | ||||
| RUN apt install curl && curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm | ||||
|  | ||||
| WORKDIR /e2e | ||||
|  | ||||
| COPY pnpm-lock.yaml ./ | ||||
| RUN pnpm fetch | ||||
|  | ||||
| COPY package.json ./ | ||||
| COPY package.json tsconfig.json ./ | ||||
| RUN pnpm install -r --offline | ||||
|  | ||||
|   | ||||
							
								
								
									
										13
									
								
								e2e/cypress.config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								e2e/cypress.config.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| import { defineConfig } from "cypress"; | ||||
| import { initPlugin } from '@frsource/cypress-plugin-visual-regression-diff/dist/plugins'; | ||||
|  | ||||
| export default defineConfig({ | ||||
|   fixturesFolder: false, | ||||
|   projectId: "8cua4m", | ||||
|  | ||||
|   e2e: { | ||||
|     setupNodeEvents(on, config) { | ||||
|       initPlugin(on, config); | ||||
|     }, | ||||
|   }, | ||||
| }); | ||||
| @@ -1,3 +1,4 @@ | ||||
| { | ||||
|   "DOZZLE_DEFAULT": "http://localhost:3000/" | ||||
|   "DOZZLE_DEFAULT": "http://localhost:8080/", | ||||
|   "DOZZLE_AUTH": "http://localhost:8080/" | ||||
| } | ||||
|   | ||||
| @@ -1,3 +0,0 @@ | ||||
| { | ||||
|   "fixturesFolder": false | ||||
| } | ||||
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 30 KiB | 
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 32 KiB | 
							
								
								
									
										14
									
								
								e2e/cypress/e2e/dozze_auth.cy.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								e2e/cypress/e2e/dozze_auth.cy.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| /// <reference types="cypress" /> | ||||
|  | ||||
| context("Dozzle default mode", { baseUrl: Cypress.env("DOZZLE_AUTH") }, () => { | ||||
|   beforeEach(() => { | ||||
|     cy.visit("/"); | ||||
|   }); | ||||
|  | ||||
|   it("login screen", () => { | ||||
|     cy.get("input[name=username]").type("foo"); | ||||
|     cy.get("input[name=password]").type("bar"); | ||||
|     cy.get("button[type=submit]").click(); | ||||
|     cy.get("p.menu-label").should("contain", "Containers"); | ||||
|   }); | ||||
| }); | ||||
| @@ -6,10 +6,10 @@ context("Dozzle default mode", { baseUrl: Cypress.env("DOZZLE_DEFAULT") }, () => | ||||
|   }); | ||||
| 
 | ||||
|   it("home screen", () => { | ||||
|     cy.get("li.running", { timeout: 10000 }).removeDates().matchImageSnapshot(); | ||||
|     cy.get("li.running", { timeout: 10000 }).removeDates().replaceSkippedElements().matchImage(); | ||||
|   }); | ||||
| 
 | ||||
|   it("correct title", () => { | ||||
|   it("correct title is shown", () => { | ||||
|     cy.title().should("eq", "1 containers - Dozzle"); | ||||
| 
 | ||||
|     cy.get("li.running:first a").click(); | ||||
| @@ -17,9 +17,15 @@ context("Dozzle default mode", { baseUrl: Cypress.env("DOZZLE_DEFAULT") }, () => | ||||
|     cy.title().should("include", "- Dozzle"); | ||||
|   }); | ||||
| 
 | ||||
|   it("settings page", () => { | ||||
|   it("navigating to setting page works ", () => { | ||||
|     cy.get("a[href='/settings']").click(); | ||||
| 
 | ||||
|     cy.contains("About"); | ||||
|   }); | ||||
| 
 | ||||
|   it("shortcut for fuzzy search works", () => { | ||||
|     cy.get("body").type("{ctrl}k"); | ||||
| 
 | ||||
|     cy.get("input[placeholder='Search containers (⌘ + k, ⌃k)']").should("be.visible"); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										17
									
								
								e2e/cypress/e2e/dozze_i18n.cy.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								e2e/cypress/e2e/dozze_i18n.cy.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| /// <reference types="cypress" /> | ||||
|  | ||||
| context("Dozzle es lang", { baseUrl: Cypress.env("DOZZLE_DEFAULT") }, () => { | ||||
|   beforeEach(() => { | ||||
|     cy.visit("/", { | ||||
|       onBeforeLoad(win) { | ||||
|         Object.defineProperty(win.navigator, "language", { | ||||
|           value: "es_MX", | ||||
|         }); | ||||
|       }, | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   it("should find contenedores", () => { | ||||
|     cy.get("p.menu-label").should("contain", "Contenedores"); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										15
									
								
								e2e/cypress/e2e/dozzle_custom_base.cy.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								e2e/cypress/e2e/dozzle_custom_base.cy.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| /// <reference types="cypress" /> | ||||
|  | ||||
| context("Dozzle custom base", { baseUrl: Cypress.env("DOZZLE_CUSTOM") }, () => { | ||||
|   beforeEach(() => { | ||||
|     cy.visit("/"); | ||||
|   }); | ||||
|  | ||||
|   it("custom base should work", () => { | ||||
|     cy.get("p.menu-label").should("contain", "Containers"); | ||||
|   }); | ||||
|  | ||||
|   it("url should be custom", () => { | ||||
|     cy.url().should("include", "foobarbase"); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										12
									
								
								e2e/cypress/e2e/dozzle_dark.cy.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								e2e/cypress/e2e/dozzle_dark.cy.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| /// <reference types="cypress" /> | ||||
|  | ||||
| context("Dozzle dark mode", { baseUrl: Cypress.env("DOZZLE_DEFAULT") }, () => { | ||||
|   beforeEach(() => { | ||||
|     cy.visit("/"); | ||||
|     cy.window().then((win) => win.document.documentElement.setAttribute("data-theme", "dark")); | ||||
|   }); | ||||
|  | ||||
|   it("home screen", () => { | ||||
|     cy.get("li.running", { timeout: 10000 }).removeDates().replaceSkippedElements().matchImage(); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										5
									
								
								e2e/cypress/fixtures/example.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								e2e/cypress/fixtures/example.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| { | ||||
|   "name": "Using fixtures to represent data", | ||||
|   "email": "hello@cypress.io", | ||||
|   "body": "Fixtures are a great way to mock data for responses to routes" | ||||
| } | ||||
| @@ -1,15 +0,0 @@ | ||||
| /// <reference types="cypress" /> | ||||
|  | ||||
| context("Dozzle light mode", { baseUrl: Cypress.env("DOZZLE_DEFAULT") }, () => { | ||||
|   before(() => { | ||||
|     cy.visit("/settings"); | ||||
|     cy.contains("Use light theme").click(); | ||||
|   }); | ||||
|   beforeEach(() => { | ||||
|     cy.visit("/"); | ||||
|   }); | ||||
|  | ||||
|   it("home screen", () => { | ||||
|     cy.get("li.running", { timeout: 10000 }).removeDates().matchImageSnapshot(); | ||||
|   }); | ||||
| }); | ||||
| @@ -1,26 +0,0 @@ | ||||
| /// <reference types="cypress" /> | ||||
| // *********************************************************** | ||||
| // This example plugins/index.js can be used to load plugins | ||||
| // | ||||
| // You can change the location of this file or turn off loading | ||||
| // the plugins file with the 'pluginsFile' configuration option. | ||||
| // | ||||
| // You can read more here: | ||||
| // https://on.cypress.io/plugins-guide | ||||
| // *********************************************************** | ||||
|  | ||||
| // This function is called when a project is opened or re-opened (e.g. due to | ||||
| // the project's config changing) | ||||
|  | ||||
| const { addMatchImageSnapshotPlugin } = require("cypress-image-snapshot/plugin"); | ||||
|  | ||||
| /** | ||||
|  * @type {Cypress.PluginConfig} | ||||
|  */ | ||||
| // eslint-disable-next-line no-unused-vars | ||||
| module.exports = (on, config) => { | ||||
|   // `on` is used to hook into various events Cypress emits | ||||
|   // `config` is the resolved Cypress config | ||||
|  | ||||
|   addMatchImageSnapshotPlugin(on, config); | ||||
| }; | ||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 35 KiB | 
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user