mirror of
https://github.com/amir20/dozzle.git
synced 2026-08-07 18:41:56 +00:00
Compare commits
808 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf090aedfe | |||
| 69b7ede7c4 | |||
| eb5c8fac1b | |||
| de368a1d97 | |||
| e283a3e361 | |||
| df11cc4740 | |||
| 791310a8b8 | |||
| d9c66f3786 | |||
| 2abcf3480e | |||
| fd0a4850f0 | |||
| 87bc48338d | |||
| 8811dc82bd | |||
| 51e13d5f97 | |||
| b37de0a16d | |||
| 743981b6e0 | |||
| c6a261c35d | |||
| 55a10273da | |||
| 38636d507f | |||
| 9ceb850d00 | |||
| c1d09ddcf1 | |||
| 62b5ef80ec | |||
| e54e14ba27 | |||
| 424abad60e | |||
| 6cdf81e4bb | |||
| 7bbddd90cb | |||
| c823617ff4 | |||
| 90ce6210ae | |||
| 21d6cd9922 | |||
| d80128684c | |||
| 7b453854ac | |||
| a51c7c1d86 | |||
| 9a99e9305f | |||
| 5bef729c52 | |||
| be352eec66 | |||
| 6a014d1e76 | |||
| a0744e836b | |||
| dd2eba59b2 | |||
| b7d9d1346a | |||
| ab6c93a679 | |||
| 1db40bc4cd | |||
| d237fa9524 | |||
| 630abbe8a6 | |||
| b1d8971a74 | |||
| 345102f498 | |||
| 58f76ee19d | |||
| 12ad969d0e | |||
| fe6444f2d7 | |||
| 65b515e533 | |||
| 4bf94b0486 | |||
| 450d5dc9de | |||
| c165120666 | |||
| 6339f9750a | |||
| cd27fe90e4 | |||
| c5907129f2 | |||
| fb4c5c7410 | |||
| d3861b8e04 | |||
| 1f4f21b98e | |||
| 99d1ae93e9 | |||
| 47233170e1 | |||
| 7b5ec0c79a | |||
| e197963251 | |||
| 262bc93310 | |||
| feece87c09 | |||
| f364ce9398 | |||
| 727b33c8cc | |||
| 118a7deeab | |||
| 0201f813ba | |||
| 1c7e804138 | |||
| b840174542 | |||
| 3895d87337 | |||
| e51a5d6392 | |||
| 425c4f0cb5 | |||
| 29086778ce | |||
| 9ef395d1a4 | |||
| 525b848d90 | |||
| 809ce73463 | |||
| 37a135804b | |||
| 2a69d5fb80 | |||
| 7699171345 | |||
| 8d0682d0f5 | |||
| 04e546e8b5 | |||
| fa5d3e4a41 | |||
| 622e54c4c6 | |||
| 694582fe64 | |||
| 0942b220d0 | |||
| e8bc72f27e | |||
| f17b758fdf | |||
| 7ee71ccafa | |||
| d1b3507709 | |||
| 1e215db588 | |||
| 5c81fdbb84 | |||
| 0431118366 | |||
| 5bc90019f8 | |||
| a5d192df6d | |||
| 5a24db9f12 | |||
| bec7d1ed2b | |||
| 5dccbf5a6e | |||
| 3e95d2c71b | |||
| bfcb14fe91 | |||
| cb3cd8e81a | |||
| 51e74db127 | |||
| cf110f2325 | |||
| 73dc6ed039 | |||
| 6a2cb3eaaa | |||
| 5e780eb767 | |||
| f69a4674a5 | |||
| c034a675c9 | |||
| 75d8bfb3e5 | |||
| abb510f3c9 | |||
| eca7008e1f | |||
| 1893f48493 | |||
| 8dac197f60 | |||
| 883f5464e3 | |||
| 023ff1242d | |||
| f3c17ee493 | |||
| 79184bbfd2 | |||
| fa7479e1ec | |||
| 543f96198f | |||
| 581bab3a43 | |||
| 3dc5e3afab | |||
| b3e5f2d2b0 | |||
| 4de47308a6 | |||
| a9c773a15a | |||
| c5c301ffb2 | |||
| 980f52be6f | |||
| e1bfb88dad | |||
| 84c4d1144f | |||
| 2478cd16d6 | |||
| 0dc3720795 | |||
| 57fa1ba77a | |||
| 551ecce726 | |||
| 74fd80229c | |||
| e0e061f47c | |||
| e3d3c789df | |||
| 7d3164dede | |||
| 4466001c78 | |||
| e6aa6adce6 | |||
| 116006dbbf | |||
| 6ce3750062 | |||
| ef59688748 | |||
| 8d020d618c | |||
| 9288249aca | |||
| 321ae607f6 | |||
| 416db140c8 | |||
| 6ba57afe0b | |||
| 59994ea431 | |||
| cebe484da5 | |||
| 837b994c19 | |||
| c48dcc5c4b | |||
| 6a6b34b35d | |||
| 0346c1e09c | |||
| 9d008da270 | |||
| 797faf079c | |||
| 92ae58c058 | |||
| 816dccc8c1 | |||
| ac2ca7c280 | |||
| 8c5338dea8 | |||
| d09b2fe04e | |||
| 2994ccf239 | |||
| f8e6704c34 | |||
| 563ed6319c | |||
| 2c3bbe3c6b | |||
| 731d09b2e5 | |||
| 799d261c59 | |||
| abac4fdf7e | |||
| ac43dca1ac | |||
| 6c4b386283 | |||
| 52e61158cf | |||
| 3aa80b8f07 | |||
| 4616159e27 | |||
| 3dbbe530db | |||
| 27fdd047a5 | |||
| c9e89fb425 | |||
| 1cd0b651c3 | |||
| 7ce3bd8e64 | |||
| 915478c4ab | |||
| d58a33fdac | |||
| 94976bdf58 | |||
| a1aa2253bc | |||
| 09e549b1b1 | |||
| a0877bbadd | |||
| 04515cc1bd | |||
| 3c10eefcfe | |||
| 66d9fa4dd1 | |||
| f195290a30 | |||
| fe8165ec09 | |||
| 09647174ee | |||
| 6d8f3005b0 | |||
| af4412f667 | |||
| f5f736b6ff | |||
| 44239614d1 | |||
| f13bcdba60 | |||
| 92fa5beb07 | |||
| 4c378443e0 | |||
| e608bdf201 | |||
| d873edee0e | |||
| fe83ddcf22 | |||
| 338c953298 | |||
| 2d54f43ef5 | |||
| 4b913ee68a | |||
| dd5cd3ccac | |||
| 5abda9f224 | |||
| 32fac82635 | |||
| cae201d592 | |||
| 284822c631 | |||
| 475a16321d | |||
| 3ab758aabf | |||
| 7966b21249 | |||
| 15e13507b2 | |||
| 3254f36246 | |||
| 3c6bc6b391 | |||
| e079e5fc43 | |||
| 317c141f65 | |||
| 77a7905f2f | |||
| cfcaebe36a | |||
| 7225abec25 | |||
| af241022bf | |||
| 806b6fb0cd | |||
| dca9f17be4 | |||
| 3ce1f9ebdc | |||
| 481455a09b | |||
| 77ddc3ce52 | |||
| 9b44236ad4 | |||
| 591d881294 | |||
| 4cf3952a73 | |||
| 3a309ce8fd | |||
| 922d6de14e | |||
| e271551a5d | |||
| f60fbc254a | |||
| fd943272f0 | |||
| 2380c87bb0 | |||
| 166037617f | |||
| aa428b6c2c | |||
| 9c9d3613d6 | |||
| 6bb3c4256a | |||
| 21302b4173 | |||
| dc38dde2d8 | |||
| 2975a9ceda | |||
| 6e0c028439 | |||
| e7c83b6a2f | |||
| 4f7695d494 | |||
| fc1e86fc14 | |||
| 6e3e82e806 | |||
| 8957fe3a6f | |||
| abdcbe835e | |||
| 0db989b193 | |||
| 177d721a49 | |||
| e1f4ed2919 | |||
| 824bfc9f0d | |||
| 111076fccf | |||
| 4d8213a66f | |||
| 106dfe18c7 | |||
| 72af56f42d | |||
| f00f3bdde5 | |||
| 72c9b66286 | |||
| e0109a7f4f | |||
| 790c8724ee | |||
| 3167f0a9a2 | |||
| 4251076211 | |||
| e1797ad5c0 | |||
| 68d7720ae6 | |||
| fce15c8ec9 | |||
| 330e952ca4 | |||
| 1cd1b4a0af | |||
| 6f1390f412 | |||
| 72ea5eed2c | |||
| 8121eb1aad | |||
| 45b7a6243e | |||
| 91ad1cb8d3 | |||
| 302ba4b5a0 | |||
| ebc64d8641 | |||
| dc8d87c7da | |||
| ada81017de | |||
| 4c6dfa7b72 | |||
| 32ea1bedfd | |||
| 1a29adc90f | |||
| 3bc5745147 | |||
| 7e92cfee96 | |||
| b68fb45123 | |||
| 3c88036c46 | |||
| 63ed910d81 | |||
| 93d9c88424 | |||
| 0991df5554 | |||
| 57c54ed6dd | |||
| e410804727 | |||
| c7e5615264 | |||
| 66126a1d79 | |||
| 7371b86779 | |||
| f2cb9ae672 | |||
| d01b161808 | |||
| c8b736a2d5 | |||
| d3e237a562 | |||
| bdfeb0a880 | |||
| 89fdbd55bb | |||
| 228030b257 | |||
| 27d2fdefd6 | |||
| 5d38eb7127 | |||
| e66dda89e1 | |||
| b0da9637be | |||
| a3fc0ea992 | |||
| 54469dc163 | |||
| 5b412456cd | |||
| 475cd916f2 | |||
| 9982e6e2a6 | |||
| 798981b334 | |||
| 76e94a7ad4 | |||
| 4351493914 | |||
| a074aa9511 | |||
| 78114eb2d2 | |||
| 5ff6d100d4 | |||
| 94cfa6da1c | |||
| ffa38bcb2f | |||
| 8ab06d9a24 | |||
| c3a4583ba7 | |||
| bf16c1042d | |||
| 1174c1d58e | |||
| f3c4e806ae | |||
| 374865589a | |||
| ec7d15c6bc | |||
| 9e128d8de8 | |||
| 94ea7b39e9 | |||
| bcc8adc81b | |||
| a79ffdaf50 | |||
| 51a92ba448 | |||
| 8057bffd33 | |||
| f30e8e748f | |||
| 7622db0c32 | |||
| d6109e39c3 | |||
| 6e38c193ef | |||
| a300d84b31 | |||
| ed950481ad | |||
| 7df0243533 | |||
| 00132ef1cf | |||
| fed03cb4db | |||
| 6840e5b55c | |||
| e9c9eca4ae | |||
| 161b8d91d7 | |||
| 585771b804 | |||
| 78362b35fd | |||
| fa8f6e4aeb | |||
| c605bbc1bb | |||
| f1d4e682db | |||
| fb65dc9b7d | |||
| 2324e511b2 | |||
| cc82f9f993 | |||
| e48aa3139c | |||
| aef834ba06 | |||
| 3678332ab2 | |||
| 30614269c7 | |||
| 6b2af1cf3a | |||
| 80220f2388 | |||
| 98ab88aa71 | |||
| c5af5a6f5e | |||
| b8f224c13d | |||
| 40f335265f | |||
| 9ac5fcc2c4 | |||
| 8d38034c86 | |||
| 8c231d7ba7 | |||
| 0a1d854168 | |||
| 425d9e4fdd | |||
| 21676d9917 | |||
| c923980ae6 | |||
| 87ea90966e | |||
| cd15f91447 | |||
| 8800bc8c37 | |||
| ecdcd5fb26 | |||
| ff5867eaca | |||
| e5422513f7 | |||
| cd164e62c5 | |||
| 03d6c6a41a | |||
| 7131a6bdf8 | |||
| 4c523d5185 | |||
| 69b4d8b651 | |||
| dc41e018ca | |||
| ca3cd93e9a | |||
| 9224a4d1f9 | |||
| c826564e3f | |||
| 8b4f4d1b00 | |||
| 463fea4634 | |||
| 4db74360e6 | |||
| 58caca2d68 | |||
| 98a4901256 | |||
| 4a6461ce2a | |||
| 620e59aa24 | |||
| c13aca6d2c | |||
| f3afbf843c | |||
| 10e4dfd17f | |||
| efcadfb98a | |||
| f831dc6ecd | |||
| 0ff0d486b1 | |||
| a7405ac2d6 | |||
| 3f4baea664 | |||
| 0d71261358 | |||
| 1411c70c9b | |||
| 6c2c58345c | |||
| af0407157b | |||
| 9f6d24523c | |||
| abeb86d285 | |||
| 4954a020f9 | |||
| 5b96267f1f | |||
| e2109257e4 | |||
| 9ac4762f1d | |||
| 321bf43f1c | |||
| bca061fa7b | |||
| 8599b602dc | |||
| d60a119f9e | |||
| 4b3e44fcdc | |||
| 51fce48137 | |||
| c1432c33a2 | |||
| 22d659f509 | |||
| 9c529af633 | |||
| 9988271490 | |||
| 05f76c4f57 | |||
| 20c1adbd7e | |||
| d44ab349b9 | |||
| 21367bcc36 | |||
| 6ece0df082 | |||
| b087f30fb9 | |||
| 5e65e2fca5 | |||
| 2303fbdfd3 | |||
| 078f4c4064 | |||
| 5be1a55d33 | |||
| 09b1bae3cd | |||
| 2a0037a667 | |||
| e00752b0bb | |||
| 497e474471 | |||
| 753d45d7af | |||
| 9a2d6fc6e8 | |||
| 4dcaa43597 | |||
| b51ec5bfa7 | |||
| 8c8ac09521 | |||
| 2537e16b6c | |||
| a8a76e29c9 | |||
| 65b2ac9181 | |||
| ddc4e310a8 | |||
| 0a2afcf93c | |||
| 41cc1eb2ff | |||
| 2a63d300a4 | |||
| fb9265204d | |||
| 41d46f95b3 | |||
| d6ad628769 | |||
| 8c4b103468 | |||
| 9ee66dbe80 | |||
| 55aeb46262 | |||
| 1b260019a1 | |||
| e07d97a28a | |||
| 7b53cffdae | |||
| d630056a61 | |||
| 1f712b56e0 | |||
| c199e1fa28 | |||
| 60c8f7a255 | |||
| 6bafcc6f77 | |||
| 529fb3ad6d | |||
| 8000b6c14e | |||
| b74dc9f58a | |||
| 67156b501f | |||
| fd69eae2ea | |||
| 9530bfeadc | |||
| 1d6b4293e7 | |||
| 940bcde6c6 | |||
| 836c179b4c | |||
| 91152d7a94 | |||
| 109c0e50e0 | |||
| a9cfa97704 | |||
| 4ac7bd0f05 | |||
| 512293968e | |||
| e443dd4c9b | |||
| 835ca9fbc6 | |||
| cbf30f3ae0 | |||
| 523761c424 | |||
| bba95b98a6 | |||
| 6d01128092 | |||
| 4b2235e0da | |||
| b361f0b772 | |||
| d84cc574e3 | |||
| bafcbf6d66 | |||
| 80df836f1d | |||
| 1b36d2c79d | |||
| b09d73736b | |||
| 604133b591 | |||
| 065dd0aea0 | |||
| b1cbd7c23e | |||
| 0b8b60afd9 | |||
| da32dc9171 | |||
| 653fc8878a | |||
| 3abe70b402 | |||
| 6097bd49b7 | |||
| 8462ee9454 | |||
| 8e6f7f16ba | |||
| 4605a4a59d | |||
| c7615d01a3 | |||
| aba8746dd0 | |||
| 9e7de63f26 | |||
| 313cde3ad1 | |||
| bf0148d131 | |||
| 72d1ecbcc6 | |||
| 50ce41eca3 | |||
| cffd4bd4e3 | |||
| 0f1e9cf0e6 | |||
| a761129aff | |||
| 16de645a89 | |||
| 1c6f30bcd3 | |||
| 9f29aa5923 | |||
| c2a17888a7 | |||
| 176c173ac7 | |||
| ce436d9697 | |||
| b752afb352 | |||
| 50dc54f4bb | |||
| e6eb7eeebd | |||
| 0cd62745f6 | |||
| 911f785e2c | |||
| 074f402d0f | |||
| 9485fa738b | |||
| bb56154811 | |||
| 78e63c39a0 | |||
| c1c8e8cccf | |||
| f9bc915ace | |||
| 938c6229f8 | |||
| b945c35bfa | |||
| 47adf9845a | |||
| 6951c1cc71 | |||
| 9dc27fa06c | |||
| f5786ba388 | |||
| b7b9ea7c6f | |||
| d7b6e9ee50 | |||
| 34f68849f4 | |||
| fa78467698 | |||
| 573ee5ed03 | |||
| cb6267f9ae | |||
| 3aa3b879de | |||
| 79bf0f01f9 | |||
| 6b442d26a9 | |||
| 67348ab514 | |||
| cb9ab3f944 | |||
| 0a722bd2da | |||
| 3195bc0d80 | |||
| 7bf455ae6b | |||
| 285a8bf251 | |||
| 65d690074c | |||
| 264af62bff | |||
| c3ac804e7a | |||
| e8e1c3ea6b | |||
| 29f06eb00d | |||
| 020342a1bd | |||
| 3b0b3ed5fc | |||
| 6b121633e9 | |||
| 443084964a | |||
| dc1314ddc3 | |||
| c4060b2206 | |||
| 5675389049 | |||
| bc3c812c74 | |||
| 5aea48f3ca | |||
| ab86a6c59e | |||
| f667e5a271 | |||
| 58146d1ac5 | |||
| a6b65a1341 | |||
| a6003e4c1c | |||
| 4e59bb2f8f | |||
| 220dcd9416 | |||
| fb359cb9d5 | |||
| 395d8641ad | |||
| 84ec399e50 | |||
| 2b0c9124d7 | |||
| e75b4d03bc | |||
| 5f24657b41 | |||
| 54799b420c | |||
| 23ad32d05c | |||
| 6e4a5238d3 | |||
| a1cedd4b16 | |||
| 84edb6bdab | |||
| a8c673544f | |||
| b2c3363659 | |||
| 3f376a0bce | |||
| 16fc75b563 | |||
| 7ed0ec498d | |||
| 11d12d1f6a | |||
| e88c412d62 | |||
| 96a02bf213 | |||
| 35b2c919cc | |||
| 85396d7631 | |||
| a3a6a8ec5c | |||
| 1168c03fc9 | |||
| 10597521de | |||
| da7730cffd | |||
| ed5090a9ec | |||
| c97a9cc34f | |||
| 31d7fe5c67 | |||
| ed8ce856ba | |||
| 940ba30633 | |||
| a4b0b05978 | |||
| 146ff180e0 | |||
| db4f11c68e | |||
| e702b62324 | |||
| cf97100b71 | |||
| b5cc4bd514 | |||
| f6e3d487ac | |||
| a2cc759538 | |||
| cfd757e876 | |||
| 01c564228b | |||
| 0b401aacd1 | |||
| d7335a8cbe | |||
| 6df66dade9 | |||
| ae083e955a | |||
| 99faac6e8e | |||
| 16495a66a0 | |||
| 03a2eb2f3b | |||
| bf18fdd1d8 | |||
| 912595f781 | |||
| 2a5a0b0c55 | |||
| 4e9667e8cf | |||
| 8130889484 | |||
| 013f7c5cde | |||
| 37c1fd032f | |||
| cd4f98fd66 | |||
| b78dab6da1 | |||
| 7be0515648 | |||
| af93918f8c | |||
| 9fa0b5256c | |||
| f037b81c92 | |||
| a97d976c4f | |||
| 6f87c42d2b | |||
| 1064af3175 | |||
| 9939b0001e | |||
| a54dd42531 | |||
| ba9faa7691 | |||
| e044ecd4a1 | |||
| da2d554bee | |||
| 9f0791a38b | |||
| eb20f9e4fa | |||
| 3ca6495404 | |||
| 44d30b8451 | |||
| 1f4410f257 | |||
| bd3e789d4c | |||
| 5d32e04af8 | |||
| 26c85a42b5 | |||
| 4aa06778d7 | |||
| 5755afcedd | |||
| 482c5ae445 | |||
| 8506f6123e | |||
| 4d5f94c2bb | |||
| 90ca59d283 | |||
| 84e608eab1 | |||
| 1dddbec30a | |||
| 0d9ff396d6 | |||
| dc9c6b727d | |||
| 4bf342da87 | |||
| 44c02f6676 | |||
| f843635060 | |||
| bcc8506d8c | |||
| c106808c1d | |||
| f7ff41881a | |||
| b58648d562 | |||
| 6d829b9cae | |||
| 9250db2c7e | |||
| 0c41da9e65 | |||
| 0c25f0f82e | |||
| 500ffa6b5b | |||
| 0d61493e29 | |||
| f8db11a4df | |||
| f596ebc5f3 | |||
| 46b48b0f1f | |||
| a80337177f | |||
| b6cf75020f | |||
| 135fffabbc | |||
| d0fbebea30 | |||
| ae058d3b09 | |||
| 573afcfd3b | |||
| 5de0e23b9e | |||
| d5b5b92701 | |||
| def47b560e | |||
| 3916e3aec8 | |||
| 743678da33 | |||
| 7291448d90 | |||
| 99d0af05d5 | |||
| a87908d957 | |||
| 8d38f36745 | |||
| 100204022d | |||
| 35dea10a1c | |||
| 176d41aff3 | |||
| cd9a771cab | |||
| 633d794539 | |||
| c1955a3301 | |||
| 3a4f0a6e79 | |||
| 2c50d870d2 | |||
| 229758c1db | |||
| 6ee4a8594c | |||
| e2b35b3c8c | |||
| 2d39629de1 | |||
| 61af442264 | |||
| 8dde2fe9ac | |||
| da83f8af11 | |||
| eadf6c98fe | |||
| 931c85afb6 | |||
| a47be8730a | |||
| bcb859509b | |||
| d98000b35a | |||
| a8dced5c2b | |||
| 3c2a2119d8 | |||
| a5ed9b65d5 | |||
| e01baf7119 | |||
| 064d5c21f2 | |||
| fae248b066 | |||
| f22c51690b | |||
| 373f9ae025 | |||
| d4dab0ad1a | |||
| 098f032d03 | |||
| 9db559c9ce | |||
| 7257e35f1b | |||
| 1c20aba5d7 | |||
| 252d865460 | |||
| 02c28fd001 | |||
| b39f2ae6a8 | |||
| 7e2dd1c69e | |||
| 7e5b413c48 | |||
| ec513adde2 | |||
| 186b4504fc | |||
| 9dc286643e | |||
| 553cfd558d | |||
| 31174a5214 | |||
| 515849e72a | |||
| f442635f55 | |||
| a8f2dc17d6 | |||
| 1faa84abc5 | |||
| bdcb1673e1 | |||
| 4de23926eb | |||
| d4defe680e | |||
| 4c2b459fa8 | |||
| aeadae0d30 | |||
| f6f1b5207e | |||
| cfe7285bf7 | |||
| defaba29c2 | |||
| 2aad18d729 | |||
| 9c52d604bc | |||
| 7f936a9a38 | |||
| 0c9065a210 | |||
| b70c2da290 | |||
| 804199aa9a | |||
| 50c3608a5e | |||
| 65b68ad907 | |||
| fd663b9c0e | |||
| 7a0296fc30 | |||
| b8a3325ea7 | |||
| 0ade181ab2 | |||
| 7d60d951ff | |||
| 66b0dfe280 | |||
| c273cb5320 | |||
| 570ef771dd | |||
| 07014470e9 | |||
| bd3c2f5c24 | |||
| 6f45faf27a | |||
| e94a245440 | |||
| daa02dd899 | |||
| 6a27f9f367 | |||
| 9d7b8f26a6 | |||
| 82576ae452 | |||
| 2ca71a49ba | |||
| 3abeeec814 | |||
| 5619201886 | |||
| 0c06d25ae3 | |||
| 7a070b90ed | |||
| 9c70416aec | |||
| 68ca6d3040 | |||
| 711745eb1f | |||
| c172f6f73f | |||
| 7941bd7b6f | |||
| 595e39a139 | |||
| 8e074df145 | |||
| 54c6e1ff93 | |||
| 10e819fc09 | |||
| c16cfa2c71 | |||
| 56eebf784a | |||
| 5c206b6de3 | |||
| 2add15bf42 | |||
| 7ff1ccdffb | |||
| b961b11499 | |||
| 514c690c7a | |||
| e8b3e72265 | |||
| e8d8b72406 | |||
| 0c962eec43 | |||
| c7f318cc5c | |||
| 0e8452c146 | |||
| 2d3f6e2b1b | |||
| 92665a76de | |||
| 78ace3178b | |||
| a13b7b0e9d | |||
| 4da0486295 | |||
| 11bc1c7fc2 | |||
| f001b48fa3 | |||
| baf9f74fd0 | |||
| f817452fb5 | |||
| 95b27f60cb | |||
| 9daa3459ad | |||
| 01343b74bf | |||
| 5433939690 | |||
| effeb5aa52 | |||
| 7c59060e1e | |||
| 85c42eed11 | |||
| 433c0eb59e | |||
| 044ef7a2b2 | |||
| 847c4ee115 | |||
| 4f912b6fdc | |||
| 5616c07bad | |||
| 31c51f1939 | |||
| 10705f05a6 | |||
| 3645100402 | |||
| 757c1385f7 | |||
| ccfab708d4 | |||
| 55168fd81a |
@@ -0,0 +1,37 @@
|
||||
# Bug Hunter Agent Memory
|
||||
|
||||
## Codebase Patterns
|
||||
|
||||
### Notification System Architecture
|
||||
|
||||
- `notification.Manager` owns subscriptions (xsync.Map) and dispatchers (xsync.Map)
|
||||
- Two processing goroutines: `processLogEvents` and `processStatEvents` started in `NewManager`
|
||||
- Stats listener has start/stop lifecycle; log listener is always-on once started
|
||||
- `MultiHostService` wraps Manager and handles config persistence + agent broadcast
|
||||
|
||||
### Known Bug-Prone Areas
|
||||
|
||||
- **broadcastNotificationConfig**: Has historically missed fields when converting between internal and types packages (APIKey, Prefix, ExpiresAt were missed)
|
||||
- **TriggeredContainerIDs lazy init**: Race condition risk - initialized lazily in AddTriggeredContainer without sync
|
||||
- **Channel close handling**: enrich() in stats_listener doesn't check for closed channel, can hot-spin
|
||||
- **LogAlertFields canSave**: Allows empty logExpression (no error = valid), creates dead subscriptions
|
||||
|
||||
### Type Mapping Gotchas
|
||||
|
||||
- `container.ContainerStat` -> `types.NotificationStat`: field names differ (CPUPercent vs cpu expr tag)
|
||||
- `notification.DispatcherConfig` -> `types.DispatcherConfig`: must copy ALL fields including APIKey, Prefix, ExpiresAt
|
||||
- Frontend `NotificationRule.cooldown` is optional, backend defaults to 300 via `GetCooldownSeconds()`
|
||||
|
||||
### Concurrency Model
|
||||
|
||||
- xsync.Map used throughout for concurrent access (subscriptions, dispatchers, activeStreams, containers)
|
||||
- Subscription fields use atomic types: TriggerCount (atomic.Int64), LastTriggeredAt (atomic.Pointer)
|
||||
- MetricCooldowns uses xsync.Map for per-container cooldown tracking
|
||||
- sendSem (semaphore.Weighted=5) limits concurrent notification sends
|
||||
|
||||
### Container Update (feat/container-update)
|
||||
|
||||
- **progressCh close contract**: Docker and Agent implementations close progressCh via defer; K8s does NOT, causing handler hang
|
||||
- **NetworkSettings nil risk**: `docker.InspectResponse.NetworkSettings` is a pointer; `ContainerCreate` doesn't nil-check before accessing `.Networks`
|
||||
- **Destructive recreate**: stop->remove->create->start has no rollback if create fails after remove
|
||||
- **SSE parsing in frontend**: Uses manual ReadableStream reader, not EventSource; no AbortController cleanup on unmount
|
||||
@@ -0,0 +1,36 @@
|
||||
# Go Performance Reviewer Memory
|
||||
|
||||
## Hot Paths Identified
|
||||
|
||||
- **Stats processing pipeline**: `ContainerStatsListener.enrich()` -> channel -> `Manager.processStatEvents()` -> `processStatEvent()`. Runs continuously for every container stat tick (~1/sec per container).
|
||||
- **Log processing pipeline**: `ContainerLogListener` -> `logChannel` (buffered 1000) -> `Manager.processLogEvents()` -> `processLogEvent()`. Every log line from matched containers flows here.
|
||||
- Both pipelines do `expr.Run()` per subscription per event -- compiled programs are cached but still run per-event.
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- `ContainerStore.SubscribeStats` and `SubscribeEvents` both call `statsCollector.Start/Stop` -- multiple subscribers share the same collector. Stop-on-cancel can interfere across subscribers.
|
||||
- `xsync.Map` (from puzpuzpuz/xsync/v4) used extensively for concurrent maps. No built-in TTL/eviction.
|
||||
- `ContainerStatEvent` carries full `Container` + `Host` structs by value through channels. `Container` is ~200+ bytes (strings, map, RingBuffer pointer, times).
|
||||
- TTL caches in both listeners (`cachedContainerInfo`) lack eviction -- grow unboundedly with container churn.
|
||||
|
||||
## Existing Patterns
|
||||
|
||||
- Semaphore-based concurrency limiting: `sendSem` (weighted 5) for notification dispatch, `maxFetchParallelism` (30) for container fetching.
|
||||
- Lazy stats collection: stats collector starts on first subscriber, stops when last unsubscribes (but the multi-subscriber Stop race exists).
|
||||
- `sync.Pool` not currently used in notification/stats paths.
|
||||
- Cooldown tracking via `xsync.Map[string, time.Time]` per subscription -- also no eviction.
|
||||
|
||||
## Key File Paths
|
||||
|
||||
- `internal/container/container_store.go` -- Container store with stats collector lifecycle
|
||||
- `internal/notification/processing.go` -- Log + stat event processing (hot path)
|
||||
- `internal/notification/stats_listener.go` -- Stats subscription and enrichment
|
||||
- `internal/notification/log_listener.go` -- Log stream management
|
||||
- `internal/notification/manager.go` -- Subscription CRUD and listener orchestration
|
||||
- `internal/notification/types.go` -- Subscription matching (expr evaluation)
|
||||
- `internal/cloud/client.go` -- Cloud gRPC bidirectional streaming client
|
||||
- `internal/cloud/tools.go` -- Tool definitions and execution dispatch
|
||||
|
||||
## Memory Files
|
||||
|
||||
- [cloud_client_patterns.md](cloud_client_patterns.md) - Cloud gRPC client architecture and known patterns
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: Cloud gRPC Client Patterns
|
||||
description: Architecture of internal/cloud/ package - gRPC bidirectional streaming for cloud tool calls
|
||||
type: project
|
||||
---
|
||||
|
||||
Cloud client (`internal/cloud/client.go`) uses a bidirectional gRPC stream (`ToolStream`) to receive tool requests from Dozzle Cloud and send responses.
|
||||
|
||||
Key architecture:
|
||||
|
||||
- `Client.Run()` is the reconnect loop with exponential backoff
|
||||
- `Client.connect()` creates a new `grpc.ClientConn` per reconnect (potential optimization: reuse connection)
|
||||
- `sendMu sync.Mutex` protects concurrent `stream.Send()` calls (tool calls dispatch to goroutines)
|
||||
- `toolSem` (weighted semaphore, max 5) limits concurrent tool execution
|
||||
- `apiKeyFunc` closure provides cloud API key; empty string means no cloud configured
|
||||
- PermissionDenied from server causes permanent stop (no retry)
|
||||
- Tool definitions cached via `sync.Once` + `cachedTools` field (fixed from prior re-serialization issue)
|
||||
- `Notify()` / `startCh` pattern ensures zero overhead for non-cloud users
|
||||
- Tool dispatch in `tools.go` uses typed proto responses (`CallToolResponse` with oneof `Result`)
|
||||
|
||||
Known perf issues found (2026-04):
|
||||
|
||||
- `containsIgnoreCase` in `tools_helpers.go` allocates two lowered strings per call; used in filter loops
|
||||
- `executeInspectContainer` calls `buildHostNameMap` to resolve a single host name
|
||||
- `executeFetchContainerLogs` doesn't drain log channel after early break (potential goroutine leak)
|
||||
- `fmt.Sprintf("%v", event.Message)` in log path uses reflect-based formatting
|
||||
|
||||
**How to apply:** When reviewing future changes to this package, watch for: undrained channels from streaming APIs, per-call allocations in filter loops, and unnecessary API calls for single lookups.
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
name: bug-hunter
|
||||
description: "Use this agent when you need to review recently written or modified code for bugs, logic errors, edge cases, and unexpected behavior. This includes both Go backend code and Vue/TypeScript frontend code. This agent should be used after writing new features, fixing bugs, or refactoring code to catch issues before they reach production.\\n\\nExamples:\\n\\n- User writes a new HTTP handler in Go:\\n user: \"I just added a new endpoint for container health checks\"\\n assistant: \"Let me review the new code for potential bugs and edge cases.\"\\n <uses Task tool to launch bug-hunter agent to review the recently changed files>\\n\\n- User implements a new Vue composable:\\n user: \"I created a new composable for managing WebSocket reconnection\"\\n assistant: \"I'll launch the bug hunter to review your new composable for edge cases and potential issues.\"\\n <uses Task tool to launch bug-hunter agent to analyze the composable>\\n\\n- User modifies log parsing logic:\\n user: \"I updated the event_generator.go to handle a new log format\"\\n assistant: \"Let me have the bug hunter review your changes to ensure all edge cases in log parsing are covered.\"\\n <uses Task tool to launch bug-hunter agent to review the modified parsing code>\\n\\n- After a chunk of code is written during a feature implementation:\\n assistant: \"I've finished implementing the notification dispatcher. Let me run the bug hunter to check for issues.\"\\n <uses Task tool to launch bug-hunter agent proactively to review the new code>"
|
||||
model: opus
|
||||
color: blue
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are an elite bug-hunting code reviewer with deep expertise in both Go backend systems and Vue 3/TypeScript frontend applications. You have extensive experience finding subtle bugs, race conditions, nil pointer dereferences, unhandled edge cases, type safety issues, and logic errors that slip past typical review. You think adversarially — always asking "what could go wrong here?"
|
||||
|
||||
## Your Mission
|
||||
|
||||
Review recently written or modified code to find bugs, logic errors, and uncovered edge cases. You focus on code that could fail at runtime, produce incorrect results, or behave unexpectedly under specific conditions.
|
||||
|
||||
## Review Process
|
||||
|
||||
1. **Identify Changed/New Code**: Use git status, git diff, or examine the files the user points you to. Focus on recently written code, not the entire codebase.
|
||||
|
||||
2. **Analyze Each File Systematically**: For every file with changes, examine:
|
||||
- Control flow paths (all branches of if/else, switch, select)
|
||||
- Error handling (are errors checked? propagated correctly? logged?)
|
||||
- Nil/undefined checks (pointer dereference in Go, optional chaining in TS)
|
||||
- Concurrency safety (goroutine leaks, race conditions, channel operations)
|
||||
- Resource cleanup (deferred closes, stream cleanup, event listener removal)
|
||||
- Boundary conditions (empty arrays, zero values, max values, negative numbers)
|
||||
- Type safety (type assertions in Go, type narrowing in TS)
|
||||
- API contract adherence (correct HTTP status codes, proper SSE format, GraphQL schema alignment)
|
||||
|
||||
3. **Cross-Layer Analysis**: Check that frontend and backend changes are consistent:
|
||||
- API request/response shapes match between Go handlers and TypeScript types
|
||||
- SSE event names and payload structures align
|
||||
- GraphQL schema, resolvers, and frontend queries are in sync
|
||||
- WebSocket message formats match on both sides
|
||||
|
||||
## Go-Specific Bug Patterns to Check
|
||||
|
||||
- **Nil pointer dereference**: Especially after type assertions, map lookups, and interface conversions
|
||||
- **Goroutine leaks**: Goroutines blocked on channels that are never closed or written to
|
||||
- **Race conditions**: Shared state accessed from multiple goroutines without synchronization
|
||||
- **Deferred closure in loops**: `defer` inside loops won't execute until function returns
|
||||
- **Error shadowing**: Using `:=` that shadows an outer `err` variable
|
||||
- **Slice/map initialization**: Operating on nil slices/maps (nil map write panics)
|
||||
- **Context cancellation**: Not respecting context.Done() in long-running operations
|
||||
- **HTTP response body leaks**: Not closing response bodies after HTTP calls
|
||||
- **Integer overflow**: Especially in stats calculations with uint64
|
||||
- **String/byte slice sharing**: Modifying a slice that shares underlying array
|
||||
- **Channel operations on nil channels**: Blocking forever on nil channel send/receive
|
||||
- **Mutex copy**: Passing sync.Mutex by value instead of pointer
|
||||
|
||||
## Vue/TypeScript-Specific Bug Patterns to Check
|
||||
|
||||
- **Reactive reference unwrapping**: Using `.value` correctly with `ref()` vs `reactive()`
|
||||
- **Computed dependency tracking**: Missing reactive dependencies in computed properties
|
||||
- **Watch cleanup**: Not cleaning up watchers, event listeners, or timers in `onUnmounted`
|
||||
- **SSE/EventSource cleanup**: Connections not properly closed on component unmount
|
||||
- **Array reactivity**: Using index assignment instead of reactive methods
|
||||
- **Optional chaining gaps**: Accessing nested properties without null checks
|
||||
- **Promise error handling**: Unhandled promise rejections, missing `.catch()` or try/catch
|
||||
- **Type narrowing issues**: Assuming a type without proper guards
|
||||
- **Stale closure references**: Callbacks capturing outdated reactive values
|
||||
- **Memory leaks**: Growing arrays without bounds (check maxLogs enforcement, statsHistory rolling window)
|
||||
- **Race conditions in async operations**: Component unmounted before async operation completes
|
||||
- **Incorrect v-if/v-show usage**: Rendering components that depend on data not yet loaded
|
||||
- **Event buffer overflow**: Not handling backpressure in SSE streams
|
||||
|
||||
## Project-Specific Concerns
|
||||
|
||||
- **EMA calculations**: Alpha=0.2 for stats smoothing — verify division by zero, NaN handling
|
||||
- **Rolling window (300 items)**: Ensure proper eviction and no off-by-one errors
|
||||
- **Log entry type discrimination**: `LogEntry.create()` must handle all `logEvent.t` values
|
||||
- **Multi-host operations**: Container/host lookups via `FindContainer()`/`FindHost()` may return nil
|
||||
- **Docker API version negotiation**: Calls may fail on older Docker versions
|
||||
- **Protobuf serialization**: `FromProto()` methods must handle nil/empty fields
|
||||
- **Authentication modes**: Code must work correctly in all three auth modes (none, simple, forward-proxy)
|
||||
- **SSE buffering**: 250ms debounce with 1000ms max — verify timer cleanup
|
||||
- **CPU normalization**: Division by `cpuLimit` or `nCPU` — check for zero values
|
||||
|
||||
## Output Format
|
||||
|
||||
Follow ultra-brief mode as specified in the project guidelines:
|
||||
|
||||
- Critical issues only (bugs, security, blockers)
|
||||
- Brief bullet points, no lengthy explanations
|
||||
- Skip verbose sections (no "Strengths", "Summary", etc.)
|
||||
- Include file:line references when relevant
|
||||
- Maximum ~10-15 lines per response
|
||||
|
||||
For each bug found, report:
|
||||
|
||||
- **File:line** — Brief description of the bug
|
||||
- **Severity**: 🔴 Critical (will crash/corrupt) | 🟡 Warning (could fail under specific conditions) | 🟠 Edge case (uncovered scenario)
|
||||
- One-line fix suggestion if obvious
|
||||
|
||||
If no bugs are found, say so concisely. Do not fabricate issues.
|
||||
|
||||
## Approach
|
||||
|
||||
1. First, determine what code was recently changed (check git diff or ask the user)
|
||||
2. Read the changed files carefully
|
||||
3. For each file, apply the relevant bug pattern checklist above
|
||||
4. Cross-reference frontend and backend changes for consistency
|
||||
5. Report findings in the ultra-brief format
|
||||
|
||||
Do NOT review code style, naming conventions, or suggest refactors unless they mask a bug. Focus exclusively on correctness.
|
||||
|
||||
**Update your agent memory** as you discover recurring bug patterns, common error-prone code paths, areas of the codebase with historical issues, and edge cases specific to this project's architecture. This builds up institutional knowledge across conversations. Write concise notes about what you found and where.
|
||||
|
||||
Examples of what to record:
|
||||
|
||||
- Recurring nil-check omissions in specific packages
|
||||
- Components that frequently have cleanup issues
|
||||
- API endpoints with known edge case gaps
|
||||
- Stats calculation patterns that are error-prone
|
||||
- Log parsing paths that have caused issues before
|
||||
|
||||
# Persistent Agent Memory
|
||||
|
||||
You have a persistent Persistent Agent Memory directory at `/Users/araminfar/Workspace/dozzle/.claude/agent-memory/bug-hunter/`. Its contents persist across conversations.
|
||||
|
||||
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
|
||||
|
||||
Guidelines:
|
||||
|
||||
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise
|
||||
- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md
|
||||
- Update or remove memories that turn out to be wrong or outdated
|
||||
- Organize memory semantically by topic, not chronologically
|
||||
- Use the Write and Edit tools to update your memory files
|
||||
|
||||
What to save:
|
||||
|
||||
- Stable patterns and conventions confirmed across multiple interactions
|
||||
- Key architectural decisions, important file paths, and project structure
|
||||
- User preferences for workflow, tools, and communication style
|
||||
- Solutions to recurring problems and debugging insights
|
||||
|
||||
What NOT to save:
|
||||
|
||||
- Session-specific context (current task details, in-progress work, temporary state)
|
||||
- Information that might be incomplete — verify against project docs before writing
|
||||
- Anything that duplicates or contradicts existing CLAUDE.md instructions
|
||||
- Speculative or unverified conclusions from reading a single file
|
||||
|
||||
Explicit user requests:
|
||||
|
||||
- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions
|
||||
- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files
|
||||
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
|
||||
|
||||
## MEMORY.md
|
||||
|
||||
Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time.
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
name: go-perf-reviewer
|
||||
description: "Use this agent when Go code has been written or modified and needs review for performance issues, inefficient patterns, or unnecessary resource loading. This agent focuses exclusively on Go backend code and should be triggered after changes to `.go` files.\\n\\nExamples:\\n\\n- user: \"I just added a new endpoint that fetches container stats\"\\n assistant: \"Let me use the go-perf-reviewer agent to check the new endpoint for performance issues.\"\\n <commentary>Since Go code was written for a new endpoint, use the Task tool to launch the go-perf-reviewer agent to review the changes for inefficient patterns.</commentary>\\n\\n- user: \"Can you review my changes to the Docker client wrapper?\"\\n assistant: \"I'll launch the go-perf-reviewer agent to analyze your Docker client changes for performance concerns.\"\\n <commentary>The user is asking for a review of Go code changes. Use the Task tool to launch the go-perf-reviewer agent to identify any performance anti-patterns.</commentary>\\n\\n- user: \"I refactored the log streaming pipeline\"\\n assistant: \"Let me run the go-perf-reviewer agent against your refactored streaming code to catch any performance regressions.\"\\n <commentary>Streaming code is performance-critical. Use the Task tool to launch the go-perf-reviewer agent to ensure the refactor doesn't introduce inefficiencies.</commentary>"
|
||||
model: opus
|
||||
color: green
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are an expert Go performance engineer with deep knowledge of runtime internals, memory allocation patterns, garbage collector behavior, and idiomatic high-performance Go. You specialize in reviewing Go code for performance anti-patterns in long-running server applications that interact with Docker, gRPC, and streaming APIs.
|
||||
|
||||
**Core Philosophy**: Dozzle must be lean and lazy by default. It should never load, allocate, compute, or fetch anything until it is actually needed. Every byte of memory and every CPU cycle matters in a lightweight monitoring tool.
|
||||
|
||||
## Your Review Process
|
||||
|
||||
1. **Examine only the changed Go files**. Use available tools to read the recent changes or diffs.
|
||||
2. **Identify specific performance issues** — do not comment on style, naming, or correctness unless it directly causes a performance problem.
|
||||
3. **Provide actionable feedback** with file:line references and concrete fix suggestions.
|
||||
|
||||
## Patterns to Flag
|
||||
|
||||
### Memory & Allocation
|
||||
|
||||
- Unnecessary allocations in hot paths (loops, stream handlers, per-request code)
|
||||
- Slice/map pre-allocation missing when size is known or estimable
|
||||
- `append` in tight loops without pre-sized slices
|
||||
- String concatenation with `+` instead of `strings.Builder` in loops
|
||||
- Returning large structs by value instead of pointer when appropriate
|
||||
- Unnecessary copies of large data (e.g., ranging over slice of structs by value)
|
||||
- `[]byte` ↔ `string` conversions that could be avoided
|
||||
- Creating closures in loops that capture loop variables unnecessarily
|
||||
- Allocating buffers per-call that could be pooled via `sync.Pool`
|
||||
|
||||
### Lazy Loading & Eager Initialization
|
||||
|
||||
- **Top priority**: Loading data, making API calls, or initializing resources before they are actually needed
|
||||
- Fetching all containers/stats when only a subset is requested
|
||||
- Initializing clients, connections, or caches at startup that may never be used
|
||||
- Reading entire files/streams into memory when streaming/pagination would suffice
|
||||
- Computing derived data eagerly when it could be computed on demand
|
||||
- Pre-populating maps/caches with all possible entries instead of lazy-filling
|
||||
|
||||
### Concurrency & Goroutines
|
||||
|
||||
- Goroutine leaks: goroutines without proper cancellation via `context.Context`
|
||||
- Missing `defer cancel()` after `context.WithCancel/WithTimeout`
|
||||
- Unbounded goroutine spawning without semaphore/worker pool
|
||||
- Channel misuse: unbuffered channels causing unnecessary blocking, or oversized buffered channels wasting memory
|
||||
- Holding locks longer than necessary; lock contention in hot paths
|
||||
- Using `sync.Mutex` where `sync.RWMutex` would reduce contention
|
||||
|
||||
### I/O & Streaming
|
||||
|
||||
- Not using `bufio.Reader`/`bufio.Writer` for I/O operations
|
||||
- Reading entire HTTP response bodies into memory (`io.ReadAll`) when streaming is possible
|
||||
- Not closing response bodies, readers, or connections (resource leaks)
|
||||
- Blocking I/O without timeouts or context cancellation
|
||||
- Serializing/deserializing JSON repeatedly when it could be done once
|
||||
- Using `encoding/json` in ultra-hot paths where a faster serializer is warranted
|
||||
|
||||
### Docker/gRPC Specific
|
||||
|
||||
- Making redundant Docker API calls (e.g., inspecting a container multiple times)
|
||||
- Not using Docker API filters to narrow results server-side
|
||||
- Fetching all container logs when `tail` or `since` parameters should limit scope
|
||||
- gRPC streams not properly drained or closed
|
||||
- Creating new Docker/gRPC clients per request instead of reusing
|
||||
|
||||
### General Go Anti-Patterns
|
||||
|
||||
- `reflect` usage in hot paths
|
||||
- `fmt.Sprintf` for simple string operations where direct concatenation suffices
|
||||
- `interface{}` / `any` boxing causing heap escapes
|
||||
- Unnecessary use of `defer` in tight loops (small but real overhead)
|
||||
- `time.After` in select loops (creates new timer each iteration; use `time.NewTimer` + `Reset`)
|
||||
- Regex compilation inside functions instead of package-level `var` with `regexp.MustCompile`
|
||||
- Sorting large slices repeatedly instead of maintaining sorted order
|
||||
|
||||
## Output Format
|
||||
|
||||
Use ultra-brief mode as specified by the project:
|
||||
|
||||
- Critical performance issues only
|
||||
- Brief bullet points with file:line references
|
||||
- Concrete suggestion for each issue
|
||||
- Maximum ~10-15 lines per response
|
||||
- No praise sections, no summaries, no fluff
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
- `internal/docker/client.go:142` — `io.ReadAll(resp.Body)` reads entire log stream into memory. Stream with `bufio.Scanner` instead.
|
||||
- `internal/web/logs.go:87` — New `json.Encoder` created per log line in hot loop. Reuse encoder or use `sync.Pool`.
|
||||
- `internal/support/docker/manager.go:53` — All hosts initialized eagerly at startup. Defer client creation until first access.
|
||||
```
|
||||
|
||||
If no performance issues are found, state that clearly in one line.
|
||||
|
||||
**Update your agent memory** as you discover performance patterns, hot paths, allocation-heavy code paths, and architectural decisions in this codebase. This builds up institutional knowledge across conversations. Write concise notes about what you found and where.
|
||||
|
||||
Examples of what to record:
|
||||
|
||||
- Hot paths identified (log streaming, stats collection, event processing)
|
||||
- Existing `sync.Pool` usage or buffer reuse patterns
|
||||
- Known allocation-heavy areas
|
||||
- Docker API call patterns and caching strategies
|
||||
- gRPC streaming patterns used in agent mode
|
||||
- Areas where lazy loading is already implemented vs. missing
|
||||
|
||||
# Persistent Agent Memory
|
||||
|
||||
You have a persistent Persistent Agent Memory directory at `/Users/araminfar/Workspace/dozzle/.claude/agent-memory/go-perf-reviewer/`. Its contents persist across conversations.
|
||||
|
||||
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
|
||||
|
||||
Guidelines:
|
||||
|
||||
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise
|
||||
- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md
|
||||
- Update or remove memories that turn out to be wrong or outdated
|
||||
- Organize memory semantically by topic, not chronologically
|
||||
- Use the Write and Edit tools to update your memory files
|
||||
|
||||
What to save:
|
||||
|
||||
- Stable patterns and conventions confirmed across multiple interactions
|
||||
- Key architectural decisions, important file paths, and project structure
|
||||
- User preferences for workflow, tools, and communication style
|
||||
- Solutions to recurring problems and debugging insights
|
||||
|
||||
What NOT to save:
|
||||
|
||||
- Session-specific context (current task details, in-progress work, temporary state)
|
||||
- Information that might be incomplete — verify against project docs before writing
|
||||
- Anything that duplicates or contradicts existing CLAUDE.md instructions
|
||||
- Speculative or unverified conclusions from reading a single file
|
||||
|
||||
Explicit user requests:
|
||||
|
||||
- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions
|
||||
- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files
|
||||
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
|
||||
|
||||
## MEMORY.md
|
||||
|
||||
Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time.
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(gh pr view:*)",
|
||||
"Bash(gh api:*)",
|
||||
"Bash(gh issue view:*)",
|
||||
"Bash(gh pr list:*)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"Bash(make test:*)",
|
||||
"Bash(go test:*)",
|
||||
"Bash(go build:*)",
|
||||
"Bash(pnpm test:*)",
|
||||
"Bash(pnpm build:*)",
|
||||
"Bash(pnpm typecheck:*)",
|
||||
"Bash(pnpm list:*)",
|
||||
"Bash(node --version:*)",
|
||||
"Bash(git rebase:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(xargs ls:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: update-int-snapshots
|
||||
description: Use when Playwright visual snapshots in e2e/visual.spec.ts-snapshots/ need to be regenerated after a UI change (logo, theme, layout, anything that affects rendered pixels) and the integration suite needs to be rerun via make int
|
||||
---
|
||||
|
||||
# Update Integration Snapshots
|
||||
|
||||
## When to Use
|
||||
|
||||
- A visual change landed (logo, color, spacing, fonts) and `make int` will fail on `visual.spec.ts` until snapshots are regenerated.
|
||||
- A snapshot test reports diffs you've confirmed are intentional.
|
||||
|
||||
Do NOT use when diffs are unintentional regressions — investigate first.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Delete the stale snapshots** so Playwright writes fresh ones (don't try to update in place, the old PNGs can confuse the diff):
|
||||
|
||||
```bash
|
||||
rm e2e/visual.spec.ts-snapshots/*.png
|
||||
```
|
||||
|
||||
2. **Check for port 8080 conflicts.** The `custom_base` test container binds host port 8080. If another container is already on it (common: `doligence-api-1`), the run fails with `Bind for 0.0.0.0:8080 failed: port is already allocated`.
|
||||
|
||||
```bash
|
||||
docker ps --format '{{.Names}}\t{{.Ports}}' | grep ':8080->'
|
||||
```
|
||||
|
||||
If anything other than test containers is bound, stop it first (ask the user before stopping containers from other projects).
|
||||
|
||||
3. **Run with `--update-snapshots` via `compose up`.** You must use `compose up` (not `compose run`) because the navigation sidebar snapshot includes the live container list, and the two modes produce different sibling containers. The cleanest way is to patch the playwright command in `docker-compose.yml` temporarily:
|
||||
|
||||
```bash
|
||||
sed -i.bak 's|command: npx --yes playwright test|command: npx --yes playwright test --update-snapshots|' docker-compose.yml
|
||||
make int
|
||||
mv docker-compose.yml.bak docker-compose.yml
|
||||
```
|
||||
|
||||
Snapshots must be generated in Linux/Chromium (the compose image), not macOS, because filenames include the platform suffix (e.g. `-chromium-linux.png`).
|
||||
|
||||
4. **Verify** by rerunning the normal suite:
|
||||
|
||||
```bash
|
||||
make int
|
||||
```
|
||||
|
||||
Should now pass cleanly.
|
||||
|
||||
5. **Commit the regenerated PNGs** alongside the UI change so CI stays green.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
- **Running `npx playwright test --update-snapshots` locally on macOS.** Generates `-darwin` filenames CI doesn't use. Always go through the docker compose image.
|
||||
- **Forgetting to delete first.** `--update-snapshots` does overwrite, but if the test layout changed (new test, renamed snapshot), stale PNGs are left behind. Wipe-and-regenerate is safest.
|
||||
- **Stopping unrelated containers without asking.** The port conflict is annoying but the user's other dev containers may be holding important state.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
rm e2e/visual.spec.ts-snapshots/*.png
|
||||
docker ps | grep ':8080->' # confirm port free
|
||||
sed -i.bak 's|command: npx --yes playwright test|& --update-snapshots|' docker-compose.yml
|
||||
make int
|
||||
mv docker-compose.yml.bak docker-compose.yml
|
||||
make int # verify pass
|
||||
```
|
||||
@@ -6,4 +6,3 @@ dist
|
||||
.git
|
||||
e2e
|
||||
docs
|
||||
internal/agent/pb/
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
name: "\u2601\uFE0F Dozzle Cloud Bug Report"
|
||||
labels: ["bug", "cloud"]
|
||||
description: |
|
||||
Use this template for issues related to Dozzle Cloud.
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Check for existing issues
|
||||
description: Check the backlog of issues to reduce the chances of creating duplicates; if an issue already exists, place a `+1` on it.
|
||||
options:
|
||||
- label: Completed
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Dozzle version
|
||||
description: The version of Dozzle connected to Dozzle Cloud.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the bug / provide steps to reproduce it
|
||||
description: A clear and concise description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Screenshots or logs
|
||||
description: Drag screenshots or paste logs into the text input below.
|
||||
validations:
|
||||
required: false
|
||||
@@ -2,5 +2,6 @@
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended", "schedule:weekly", "group:allNonMajor", ":automergeMinor"],
|
||||
"rangeStrategy": "bump",
|
||||
"minimumReleaseAge": "24 hours",
|
||||
"ignoreDeps": []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
use_sticky_comment: true
|
||||
prompt: |
|
||||
REPO: ${{ github.repository }}
|
||||
PR NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
Please review this pull request and provide feedback on:
|
||||
- Code quality and best practices
|
||||
- Potential bugs or issues
|
||||
- Performance considerations
|
||||
- Security concerns
|
||||
- Test coverage
|
||||
|
||||
Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
|
||||
|
||||
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
|
||||
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
|
||||
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
@@ -3,23 +3,25 @@ on:
|
||||
tags:
|
||||
- "v*"
|
||||
name: Test and Release
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
npm-test:
|
||||
name: JavaScript Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
node-version: 24.16.0
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- run: corepack enable
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --prefer-offline
|
||||
- name: Run Tests
|
||||
@@ -29,12 +31,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: 1.24.2
|
||||
go-version: 1.26.4
|
||||
check-latest: true
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Install Protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
- name: Install gRPC and Go
|
||||
@@ -48,28 +50,26 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
node-version: 24.16.0
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- run: corepack enable
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Writing certs to file
|
||||
run: |
|
||||
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
|
||||
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
|
||||
- name: Build
|
||||
uses: docker/bake-action@v6
|
||||
uses: docker/bake-action@v7
|
||||
with:
|
||||
source: .
|
||||
load: true
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
*.cache-to=type=gha,mode=max
|
||||
- name: Run Playwright tests
|
||||
run: docker compose up --exit-code-from playwright
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
@@ -88,25 +88,28 @@ jobs:
|
||||
needs: [go-test, npm-test, int-test]
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3.4.0
|
||||
uses: docker/login-action@v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3.4.0
|
||||
uses: docker/login-action@v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
amir20/dozzle
|
||||
@@ -121,7 +124,7 @@ jobs:
|
||||
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
|
||||
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6.15.0
|
||||
uses: docker/build-push-action@v7.2.0
|
||||
with:
|
||||
push: true
|
||||
context: .
|
||||
@@ -135,13 +138,17 @@ jobs:
|
||||
needs: [buildx]
|
||||
name: Github Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
- name: Release to Github
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -6,6 +6,11 @@ on:
|
||||
branches:
|
||||
- master
|
||||
name: Push container
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
buildx:
|
||||
name: Push branches and PRs
|
||||
@@ -13,39 +18,42 @@ jobs:
|
||||
if: ${{ !github.event.repository.fork && !github.event.pull_request.head.repo.fork && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == 'amir20/dozzle') }}
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3.4.0
|
||||
uses: docker/login-action@v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3.4.0
|
||||
uses: docker/login-action@v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
amir20/dozzle
|
||||
ghcr.io/amir20/dozzle
|
||||
- name: Short SHA
|
||||
id: sha
|
||||
run: echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||
- name: Writing certs to file
|
||||
run: |
|
||||
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
|
||||
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6.15.0
|
||||
uses: docker/build-push-action@v7.2.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
build-args: TAG=${{ steps.meta.outputs.version }}
|
||||
build-args: TAG=${{ steps.meta.outputs.version }}-${{ steps.sha.outputs.short }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -24,17 +24,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # Not needed if lastUpdated is not enabled
|
||||
- uses: pnpm/action-setup@v2
|
||||
- uses: pnpm/action-setup@v6
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: latest
|
||||
node-version: 24.16.0
|
||||
cache: pnpm # or pnpm / yarn
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
uses: actions/configure-pages@v6
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Build with VitePress
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
pnpm docs:build
|
||||
touch docs/.vitepress/dist/.nojekyll
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: docs/.vitepress/dist
|
||||
|
||||
@@ -57,4 +57,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
|
||||
@@ -15,6 +15,6 @@ jobs:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
- uses: amannn/action-semantic-pull-request@v6
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
+33
-27
@@ -6,21 +6,27 @@ on:
|
||||
branches:
|
||||
- master
|
||||
name: Test
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
name: Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: latest
|
||||
node-version: 24.16.0
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: latest
|
||||
node-version: 24.16.0
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
@@ -31,16 +37,14 @@ jobs:
|
||||
name: JavaScript Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: latest
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: latest
|
||||
node-version: 24.16.0
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
@@ -52,12 +56,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.24.2"
|
||||
go-version: "1.26.4"
|
||||
check-latest: true
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Install Protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
@@ -73,16 +77,16 @@ jobs:
|
||||
name: Go Staticcheck
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.24.2"
|
||||
go-version: "1.26.4"
|
||||
check-latest: true
|
||||
- name: Generate dependencies
|
||||
run: make fake_assets shared_key.pem shared_cert.pem
|
||||
- name: Stactic checker
|
||||
uses: dominikh/staticcheck-action@v1.3.1
|
||||
uses: dominikh/staticcheck-action@v1.4.1
|
||||
with:
|
||||
install-go: false
|
||||
int-test:
|
||||
@@ -90,26 +94,28 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: latest
|
||||
node-version: 24.16.0
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: latest
|
||||
node-version: 24.16.0
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Generate certs
|
||||
run: make shared_key.pem shared_cert.pem
|
||||
- name: Build
|
||||
uses: docker/bake-action@v6
|
||||
uses: docker/bake-action@v7
|
||||
with:
|
||||
source: .
|
||||
load: true
|
||||
@@ -118,7 +124,7 @@ jobs:
|
||||
*.cache-to=type=gha,mode=max
|
||||
- name: Run Playwright tests
|
||||
run: docker compose up --exit-code-from playwright
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
|
||||
@@ -15,3 +15,5 @@ coverage.out
|
||||
*.pem
|
||||
*.csr
|
||||
tmp
|
||||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
auto-imports.d.ts
|
||||
components.d.ts
|
||||
typed-router.d.ts
|
||||
docs/.vitepress/cache
|
||||
docs/.vitepress/dist
|
||||
dist
|
||||
assets/types/graphql.ts
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Comment Style
|
||||
|
||||
**Always use ultra-brief mode for all PR reviews and responses.**
|
||||
|
||||
Format:
|
||||
|
||||
- Critical issues only (bugs, security, blockers)
|
||||
- Brief bullet points, no lengthy explanations
|
||||
- Skip verbose sections (no "Strengths", "Summary", etc.)
|
||||
- Include file:line references when relevant
|
||||
- Maximum ~10-15 lines per response
|
||||
|
||||
## Testing Unreleased PRs
|
||||
|
||||
When replying to a GitHub issue or discussion where the fix lives in an open PR, ask the reporter to test the pre-built image: `amir20/dozzle:pr-XXX` (XXX = PR number). CI builds a tagged image per PR, so reporters can verify without waiting for the next release.
|
||||
|
||||
## GitHub Tone (issues, PRs, comments, discussions)
|
||||
|
||||
When posting anything to GitHub, write like a human maintainer, not an AI assistant. Avoid telltale LLM patterns:
|
||||
|
||||
- No em dashes or en dashes. Use commas, periods, or parentheses instead.
|
||||
- No "Not X, but Y" rhetorical contrasts.
|
||||
- No throat-clearing openers ("Great point", "Makes sense", "Thanks for the detailed write-up").
|
||||
- No closing summaries or recap sentences.
|
||||
- No bolded inline labels mid-paragraph ("**Why:**", "**Note:**").
|
||||
- Drop hedges ("essentially", "basically", "essentially just"). Say it plain.
|
||||
- Lowercase casual tone is fine. Contractions are fine. Short sentences are fine.
|
||||
- Don't over-explain tradeoffs. State the decision, give one reason, stop.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Dozzle is a lightweight, web-based Docker log viewer with real-time monitoring capabilities. It's a hybrid application with:
|
||||
|
||||
- **Backend**: Go (HTTP server, Docker API client, WebSocket streaming)
|
||||
- **Frontend**: Vue 3 (SPA with Vite, TypeScript)
|
||||
|
||||
The application supports multiple deployment modes: standalone server, Docker Swarm, and Kubernetes (k8s).
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Generate certificates and protobuf files
|
||||
make generate
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# Run full development environment (backend + frontend with hot reload)
|
||||
make dev
|
||||
|
||||
# Alternative: Run backend and frontend separately
|
||||
pnpm run watch:backend # Go backend with air (port 3100)
|
||||
pnpm run watch:frontend # Vite dev server (port 3100)
|
||||
|
||||
# Run in agent mode for development
|
||||
pnpm run agent:dev
|
||||
```
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
# Build frontend assets
|
||||
pnpm build
|
||||
# or
|
||||
make dist
|
||||
|
||||
# Build entire application (includes frontend build)
|
||||
make build
|
||||
|
||||
# Build Docker image
|
||||
make docker
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run Go tests
|
||||
make test
|
||||
|
||||
# Run frontend tests (Vitest)
|
||||
pnpm test
|
||||
# Run in watch mode
|
||||
TZ=UTC pnpm test --watch
|
||||
|
||||
# Type checking
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
### Preview & Other
|
||||
|
||||
```bash
|
||||
# Preview production build locally
|
||||
pnpm preview
|
||||
# or
|
||||
make preview
|
||||
|
||||
# Run integration tests (Playwright)
|
||||
make int
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend (Go)
|
||||
|
||||
The Go backend is organized into these key packages:
|
||||
|
||||
- **`internal/web/`** - HTTP server and routing layer
|
||||
- Routes defined in `routes.go` using chi router
|
||||
- WebSocket/SSE handlers for log streaming (`logs.go`)
|
||||
- Authentication middleware and token management (`auth.go`)
|
||||
- Container action handlers (`actions.go`)
|
||||
|
||||
- **`internal/docker/`** - Docker API client implementation
|
||||
- `client.go`: Main Docker client wrapper with container operations
|
||||
- `log_reader.go`: Streaming container logs
|
||||
- `stats_collector.go`: Real-time container stats collection
|
||||
|
||||
- **`internal/agent/`** - gRPC agent for multi-host support
|
||||
- Uses Protocol Buffers (protos defined in `protos/`)
|
||||
- Enables distributed log collection across Docker hosts
|
||||
|
||||
- **`internal/cloud/`** - Dozzle Cloud integration (tool execution engine)
|
||||
- `client.go`: Bidirectional gRPC stream client with auto-reconnect and exponential backoff
|
||||
- `tools.go`: Tool registration, dispatch (`executeTool`), and `ToolHostService` interface
|
||||
- `tools_containers.go`: Container listing, finding, stats, and inspection tools
|
||||
- `tools_logs.go`: Log fetching with level/query/regex filtering (max 100 lines)
|
||||
- `tools_actions.go`: Container start/stop/restart actions (gated by `enableActions`)
|
||||
- `tools_helpers.go`: Proto conversion utilities and host name resolution
|
||||
- Uses `protos/cloud.proto` for service and message definitions
|
||||
|
||||
- **`internal/k8s/`** - Kubernetes client support
|
||||
- Alternative to Docker client for k8s deployments
|
||||
|
||||
- **`internal/support/`** - Support utilities
|
||||
- `cli/`: Command-line argument parsing and validation
|
||||
- `docker/`: Multi-host Docker management and Swarm support (`docker_service.go`, client managers)
|
||||
- `k8s/`: Kubernetes service abstractions
|
||||
- `web/`: Web service utilities
|
||||
|
||||
- **`internal/auth/`** - Authentication providers
|
||||
- Simple file-based auth (`simple.go`)
|
||||
- Forward proxy auth (`proxy.go`)
|
||||
- Role-based authorization (`roles.go`)
|
||||
|
||||
- **`internal/container/`** - Container domain models and interfaces
|
||||
- `event_generator.go`: Log parsing and grouping logic (multi-line, JSON detection)
|
||||
|
||||
- **`internal/notification/`** - Alert and notification system
|
||||
- `manager.go`: Notification rule evaluation and dispatching
|
||||
- `log_listener.go`: Log pattern matching for alerts
|
||||
- `dispatcher/`: Notification channel implementations (email, webhook, etc.)
|
||||
|
||||
- **`main.go`** - Application entry point with mode switching (server/swarm/k8s/agent)
|
||||
|
||||
### Frontend (Vue 3)
|
||||
|
||||
The frontend uses file-based routing with these conventions:
|
||||
|
||||
- **`assets/pages/`** - File-based routes (unplugin-vue-router)
|
||||
- `container/[id].vue`: Single container view
|
||||
- `merged/[ids].vue`: Multi-container merged view
|
||||
- `host/[id].vue`: Host-level logs
|
||||
- `service/[name].vue`: Swarm service logs
|
||||
- `stack/[name].vue`: Docker stack logs
|
||||
- `group/[name].vue`: Custom grouped logs
|
||||
|
||||
- **`assets/components/`** - Vue components (auto-imported)
|
||||
- `LogViewer/`: Core log viewing components
|
||||
- `SimpleLogItem.vue`: Single-line log entries
|
||||
- `ComplexLogItem.vue`: JSON/structured log entries
|
||||
- `GroupedLogItem.vue`: Multi-line grouped log entries
|
||||
- `ContainerEventLogItem.vue`: Container lifecycle events
|
||||
- `SkippedEntriesLogItem.vue`: Placeholder for skipped logs
|
||||
- `LoadMoreLogItem.vue`: Load more historical logs
|
||||
- `ContainerViewer/`: Container-specific UI
|
||||
- `common/`: Reusable UI components
|
||||
- `BarChart.vue`: Lightweight bar chart with automatic downsampling
|
||||
- `HostCard.vue`: Host overview card with metrics
|
||||
- `MetricCard.vue`: Reusable metric display component
|
||||
- `ContainerTable.vue`: Container table with historical stat visualization
|
||||
|
||||
- **`assets/stores/`** - Pinia stores (auto-imported)
|
||||
- `config.ts`: App configuration and feature flags (injected from backend HTML, frozen immutable)
|
||||
- `container.ts`: Container state management with EventSource streaming (`/api/events/stream`)
|
||||
- `hosts.ts`: Multi-host state
|
||||
- `settings.ts`: User preferences (localStorage-backed via profileStorage)
|
||||
- `pinned.ts`: Pinned container logs for side-by-side viewing
|
||||
- `swarm.ts`, `k8s.ts`: Deployment mode-specific state
|
||||
- `announcements.ts`: Feature announcements
|
||||
|
||||
- **`assets/composable/`** - Vue composables (auto-imported)
|
||||
- `eventStreams.ts`: SSE connection management with buffer-based flushing (250ms debounce)
|
||||
- `historicalLogs.ts`: Historical log fetching
|
||||
- `logContext.ts`: Log filtering and search context (provide/inject pattern)
|
||||
- `scrollContext.ts`: Scroll state management (paused, progress, currentDate)
|
||||
- `storage.ts`: LocalStorage abstractions with reactivity
|
||||
- `visible.ts`: Log filtering by visible keys for complex logs
|
||||
- `containerActions.ts`: Container control operations
|
||||
- `duckdb.ts`: DuckDB WASM for SQL queries on logs
|
||||
|
||||
- **`assets/modules/`** - Vue plugins
|
||||
- `router.ts`: Vue Router configuration
|
||||
- `pinia.ts`: Pinia store setup
|
||||
- `i18n.ts`: Internationalization
|
||||
|
||||
### Communication Flow
|
||||
|
||||
1. **Real-time Logs**: Frontend establishes SSE connections to `/api/hosts/{host}/containers/{id}/logs/stream`
|
||||
2. **Container Events**: SSE stream at `/api/events/stream` pushes container lifecycle events
|
||||
3. **Stats**: Real-time CPU/memory stats streamed via SSE alongside events
|
||||
4. **Actions**: POST to `/api/hosts/{host}/containers/{id}/actions/{action}` (start/stop/restart)
|
||||
5. **Terminal**: WebSocket connections for container attach/exec at `/api/hosts/{host}/containers/{id}/attach`
|
||||
|
||||
### Build System
|
||||
|
||||
- **Frontend**: Vite builds to `dist/` with manifest
|
||||
- **Backend**: Embeds `dist/` using Go embed directive
|
||||
- **Hot Reload**: In development, `DEV=true` disables embedded assets, `LIVE_FS=true` serves from filesystem
|
||||
- **Makefile**: Orchestrates builds and dependency generation
|
||||
|
||||
## Important Development Notes
|
||||
|
||||
### Frontend
|
||||
|
||||
- Auto-imports are configured for Vue composables, components, and Pinia stores (see `vite.config.ts`)
|
||||
- Icons use unplugin-icons with multiple icon sets (mdi, carbon, material-symbols, etc.)
|
||||
- Tailwind CSS with DaisyUI for styling
|
||||
- TypeScript definitions auto-generated in `assets/auto-imports.d.ts` and `assets/components.d.ts`
|
||||
- **Log Entry Types**: Three types of log messages supported
|
||||
- `SimpleLogEntry`: Single-line text logs (`string`)
|
||||
- `ComplexLogEntry`: Structured JSON logs (`JSONObject`)
|
||||
- `GroupedLogEntry`: Multi-line grouped logs (`string[]`)
|
||||
- **Type consistency**: Use `LogMessage` type alias instead of `string | string[] | JSONObject` for log entry messages
|
||||
- **Log Entry Factory Pattern**: Use `LogEntry.create(logEvent)` to instantiate the correct entry type based on `logEvent.t` field
|
||||
- **EventSource Buffering**: Log streams use buffer-based flushing (250ms debounce, 1000ms max) to batch UI updates
|
||||
- **Charts/Visualizations**: Custom lightweight implementations (no D3.js)
|
||||
- `BarChart.vue`: Self-contained bar chart with responsive downsampling
|
||||
- Downsampling algorithm: Averages data into buckets based on available screen width
|
||||
- All stat history tracked in `Container.statsHistory` (max 300 items via rolling window)
|
||||
- `chartData` is always a rolling window of max 300 items — array length stays constant
|
||||
- Uses `ref` (not `computed`) for `downsampledBars` to enable in-place mutation of the last bar, avoiding full re-renders
|
||||
- Component instance is reused when switching containers; after init the chart only patches the last bar per tick, so on a wholesale `chartData` replacement (container switch) the parent must call the exposed `recalculate()`. `MultiContainerStat` holds refs to its `BarChart`s and calls it in the `containers` watch. (Note: `Container` carries Vue `ref`s, so VueTestUtils `setProps` cannot retrigger such a watch — tests must swap the container via a parent `ref` re-render.)
|
||||
|
||||
### Backend
|
||||
|
||||
- The application uses Go 1.25+ with module support
|
||||
- Certificate generation is required (`make generate` creates shared_key.pem and shared_cert.pem)
|
||||
- Protocol buffer generation happens via `go generate` directive in `main.go`
|
||||
- Docker client uses API version negotiation for compatibility
|
||||
- **Service Layer Architecture**:
|
||||
- `ClientService` interface abstracts Docker/K8s/Agent backends
|
||||
- `MultiHostService` orchestrates multi-host operations
|
||||
- `ClientManager` implementations: `RetriableClientManager` (server mode), `SwarmClientManager` (swarm mode)
|
||||
|
||||
### Authentication
|
||||
|
||||
- Three modes: none, simple (file-based users.yml), forward-proxy (e.g., Authelia)
|
||||
- JWT tokens for simple auth with configurable TTL
|
||||
- User file location: `./data/users.yml` or `./data/users.yaml`
|
||||
|
||||
### Testing
|
||||
|
||||
- Go tests use standard `testing` package with testify assertions
|
||||
- Frontend uses Vitest with `@vue/test-utils`
|
||||
- Integration tests with Playwright in `e2e/`
|
||||
- Tests must run with `TZ=UTC` for consistent timestamps
|
||||
|
||||
### Container Stats & Metrics
|
||||
|
||||
- Stats are tracked using exponential moving average (EMA) with alpha=0.2
|
||||
- History stored in rolling window (300 items max) via `useSimpleRefHistory`
|
||||
- CPU metrics normalized by core count (respects `cpuLimit` or falls back to host `nCPU`)
|
||||
- Memory metrics include both percentage and absolute usage (`memoryUsage` vs `memory`)
|
||||
- Stats visualization uses adaptive downsampling for performance
|
||||
|
||||
### Container Labels
|
||||
|
||||
- `dev.dozzle.name`: Custom container display name
|
||||
- `dev.dozzle.group`: Group containers together
|
||||
- Label-based filtering throughout the application
|
||||
|
||||
### Deployment Modes
|
||||
|
||||
- **Server mode** (default): Single or multi-host Docker monitoring
|
||||
- Uses `RetriableClientManager` with local + remote agent clients
|
||||
- **Swarm mode**: Automatic discovery of Swarm nodes via Docker API
|
||||
- Creates gRPC agent server on each node (port 7007)
|
||||
- Uses `SwarmClientManager` for node discovery
|
||||
- **K8s mode**: Pod log monitoring in Kubernetes cluster
|
||||
- Implements `container.Client` interface via Kubernetes API
|
||||
- **Agent mode**: Lightweight gRPC agent for remote log collection
|
||||
- Run with `dozzle agent` or `pnpm run agent:dev`
|
||||
- Listens on port 7007 with TLS certificate authentication
|
||||
|
||||
## Key Architectural Patterns
|
||||
|
||||
### Backend Abstraction Layers
|
||||
|
||||
The backend follows a clean layered architecture:
|
||||
|
||||
```
|
||||
HTTP Handlers (internal/web)
|
||||
↓
|
||||
HostService Interface (MultiHostService)
|
||||
↓
|
||||
ClientService Interface (per host)
|
||||
↓
|
||||
container.Client Interface
|
||||
↓
|
||||
Implementation (DockerClient, K8sClient, AgentClient)
|
||||
```
|
||||
|
||||
**When adding new container operations:**
|
||||
|
||||
1. Define method in `container.Client` interface (`internal/container/client.go`)
|
||||
2. Implement in `internal/docker/client.go` (and `internal/k8s/client.go` if applicable)
|
||||
3. Add wrapper method in `ClientService` interface (`internal/support/docker/docker_service.go`)
|
||||
4. Add HTTP handler in `internal/web/` with appropriate route
|
||||
|
||||
### Frontend Data Flow
|
||||
|
||||
**Real-time Log Viewing:**
|
||||
|
||||
1. User navigates to `/container/{id}` route
|
||||
2. Page component calls `useContainerStream(container)` composable
|
||||
3. Composable creates EventSource connection to `/api/hosts/{host}/containers/{id}/logs/stream`
|
||||
4. Backend streams `LogEvent` objects via SSE
|
||||
5. Frontend buffers events (250ms debounce, max 1000ms)
|
||||
6. Batched buffer flushes update reactive `messages` array
|
||||
7. `LogViewer.vue` renders using appropriate component (`SimpleLogItem`, `ComplexLogItem`, `GroupedLogItem`)
|
||||
8. When messages exceed `maxLogs` (400), oldest entries replaced or marked as `SkippedLogsEntry`
|
||||
|
||||
**Stats Streaming:**
|
||||
|
||||
1. `container.ts` store connects to `/api/events/stream` on app init
|
||||
2. Backend multiplexes container events and stats into single SSE stream
|
||||
3. `container-stat` events update `Container._stat` and append to `_statsHistory`
|
||||
4. EMA calculation provides smoothed `movingAverageStat` (alpha=0.2)
|
||||
5. `ContainerTable.vue` displays mini bar charts using `statsHistory` with downsampling
|
||||
|
||||
### Protocol Buffer Flow (Agent Mode)
|
||||
|
||||
1. Main server creates `agent.NewClient(endpoint, certs)` for each remote host
|
||||
2. AgentClient implements `container.Client` interface
|
||||
3. Method calls translate to gRPC requests defined in `protos/rpc.proto`
|
||||
4. Remote agent receives gRPC call, delegates to local `DockerClient`
|
||||
5. Streaming RPCs (logs, stats, events) use bidirectional channels
|
||||
6. Responses converted back to domain models via `FromProto()` methods
|
||||
|
||||
### Cloud Tool Execution Flow
|
||||
|
||||
1. `cloud.Client.Run()` blocks until `Notify()` signals a cloud dispatcher is configured
|
||||
2. `connect()` establishes bidirectional gRPC stream (`ToolStream` RPC) to cloud endpoint
|
||||
3. Cloud sends `ToolRequest` (ListTools or CallTool), client dispatches via `executeTool()`
|
||||
4. Tool calls run concurrently (max 5 via weighted semaphore), responses sent back on stream
|
||||
5. On disconnect, exponential backoff (1s→30s with jitter) triggers reconnection
|
||||
6. `PermissionDenied` errors stop retrying permanently (invalid API key / no pro plan)
|
||||
7. Tool definitions cached via `sync.Once`; zero overhead for non-cloud users
|
||||
|
||||
### Log Parsing Pipeline
|
||||
|
||||
1. Docker API returns multiplexed stream (8-byte headers + payload)
|
||||
2. `log_reader.go` parses headers, extracts stdout/stderr type
|
||||
3. `event_generator.go` receives raw log lines
|
||||
4. Detection logic identifies:
|
||||
- JSON structure → `ComplexLogEntry`
|
||||
- Multi-line patterns (stack traces) → `GroupedLogEntry`
|
||||
- Single lines → `SimpleLogEntry`
|
||||
5. Log level extraction via regex patterns
|
||||
6. `LogEvent` serialized to JSON and sent via SSE
|
||||
7. Frontend deserializes and renders with appropriate component
|
||||
|
||||
## Adding New Features
|
||||
|
||||
### Adding a New HTTP Route
|
||||
|
||||
1. Define route in `internal/web/routes.go` using chi router:
|
||||
```go
|
||||
r.Get("/api/custom-endpoint", h.customHandler)
|
||||
```
|
||||
2. Implement handler method in appropriate file (e.g., `actions.go`, `logs.go`)
|
||||
3. Use `hostService` to find container/host via `FindContainer()` or `FindHost()`
|
||||
4. Return JSON response or establish SSE/WebSocket stream
|
||||
|
||||
### Adding a New Log View Type
|
||||
|
||||
1. Create route file in `assets/pages/` (e.g., `custom/[id].vue`)
|
||||
2. Create composable in `assets/composable/eventStreams.ts` (e.g., `useCustomStream()`)
|
||||
3. Composable should:
|
||||
- Build API URL with appropriate filters
|
||||
- Create EventSource connection
|
||||
- Handle buffering and message batching
|
||||
- Return reactive `messages` array and control methods
|
||||
4. Use `LogViewer.vue` component to render messages
|
||||
5. Add backend API endpoint if needed (see above)
|
||||
|
||||
### Adding Container Stats/Metrics
|
||||
|
||||
1. Add field to `Stat` type in `internal/container/types.go`
|
||||
2. Update `stats_collector.go` to extract metric from Docker API response
|
||||
3. Add calculation logic in `docker/calculation.go` if needed
|
||||
4. Ensure protobuf definition includes field in `protos/rpc.proto`
|
||||
5. Frontend automatically receives updates via existing SSE stream
|
||||
6. Update `Container` model in `assets/models/Container.ts` if UI needs access
|
||||
|
||||
### Working with Notifications/Alerts
|
||||
|
||||
**Backend** (`internal/notification/`):
|
||||
|
||||
- `manager.go`: Rule evaluation engine, manages alert state
|
||||
- `log_listener.go`: Subscribes to container log streams, evaluates rules against incoming logs
|
||||
- `types.go`: Alert rule definitions (log pattern matching, thresholds)
|
||||
- `dispatcher/`: Notification channel implementations
|
||||
|
||||
**Frontend** (`assets/pages/notifications.vue`, `assets/components/Notification/`):
|
||||
|
||||
- `AlertForm.vue`, `DestinationForm.vue`: UI for creating rules
|
||||
- Rules persisted to `./data/notifications.yml` via `internal/notification/persist.go`
|
||||
- Alert state displayed in notification cards
|
||||
|
||||
**Adding a new notification channel:**
|
||||
|
||||
1. Implement dispatcher interface in `internal/notification/dispatcher/`
|
||||
2. Register in `manager.go` dispatcher factory
|
||||
3. Add UI form in `assets/components/Notification/DestinationForm.vue`
|
||||
|
||||
### Adding a New Cloud Tool
|
||||
|
||||
1. Define the tool in `AvailableTools()` in `internal/cloud/tools.go` with name, description, and parameter schema
|
||||
2. Add a response message type in `protos/cloud.proto` and add it to the `CallToolResponse.result` oneof
|
||||
3. Run `make generate` to regenerate protobuf code
|
||||
4. Add a case in the `executeTool()` switch in `internal/cloud/tools.go`
|
||||
5. Implement the execution function in the appropriate `tools_*.go` file
|
||||
6. Use `ToolHostService` interface methods to access container/host data
|
||||
7. Add tests in `tools_test.go`
|
||||
|
||||
## Common Development Patterns
|
||||
|
||||
### Testing
|
||||
|
||||
- Always run Go tests with race detector: `go test -race`
|
||||
- Frontend tests require `TZ=UTC` for timestamp consistency
|
||||
- Integration tests use Playwright with `make int` (runs docker-compose setup)
|
||||
- Use `testify/assert` for Go test assertions
|
||||
|
||||
### Hot Reload Development
|
||||
|
||||
- `make dev` runs both backend (air) and frontend (vite) with hot reload
|
||||
- `DEV=true` disables embedded asset serving
|
||||
- `LIVE_FS=true` serves assets from filesystem instead of embedded
|
||||
- Backend changes trigger air restart automatically
|
||||
- Frontend changes trigger vite HMR
|
||||
|
||||
### Debugging
|
||||
|
||||
- Backend logs: Set `--level debug` flag or `DOZZLE_LEVEL=debug` env var
|
||||
- Frontend: Vue DevTools browser extension
|
||||
- SSE streams: Browser DevTools Network tab shows EventSource connections
|
||||
+17
-17
@@ -1,43 +1,45 @@
|
||||
# Build assets
|
||||
FROM --platform=$BUILDPLATFORM node:23.11.0-alpine AS node
|
||||
FROM --platform=$BUILDPLATFORM node:25.9.0-alpine AS node
|
||||
|
||||
RUN corepack enable
|
||||
RUN npm install -g --force corepack && corepack enable
|
||||
|
||||
ENV CI=true
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Install dependencies from lock file
|
||||
COPY pnpm-*.yaml ./
|
||||
RUN pnpm fetch --ignore-scripts --no-optional
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
RUN pnpm fetch --ignore-scripts
|
||||
|
||||
# Copy package.json and install dependencies
|
||||
COPY package.json ./
|
||||
RUN pnpm install --offline --ignore-scripts --no-optional
|
||||
RUN pnpm install --offline --ignore-scripts
|
||||
|
||||
# Copy assets and translations to build
|
||||
COPY .* *.config.ts *.config.js *.config.cjs ./
|
||||
COPY vite.config.ts tsconfig.json .prettierrc.cjs .npmrc ./
|
||||
COPY assets ./assets
|
||||
COPY locales ./locales
|
||||
COPY public ./public
|
||||
|
||||
ARG CLOUD_URL
|
||||
ENV CLOUD_URL=$CLOUD_URL
|
||||
|
||||
# Build assets
|
||||
RUN pnpm build
|
||||
|
||||
FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder
|
||||
|
||||
# install gRPC dependencies
|
||||
RUN apk add --no-cache ca-certificates protoc protobuf-dev\
|
||||
&& mkdir /dozzle \
|
||||
&& go install google.golang.org/protobuf/cmd/protoc-gen-go@latest \
|
||||
&& go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
|
||||
RUN apk add --no-cache ca-certificates && mkdir /dozzle
|
||||
|
||||
WORKDIR /dozzle
|
||||
|
||||
# Copy go mod files
|
||||
COPY go.* ./
|
||||
RUN go mod download
|
||||
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||
|
||||
# Copy all other files
|
||||
COPY internal ./internal
|
||||
COPY proto ./proto
|
||||
COPY types ./types
|
||||
COPY main.go ./
|
||||
COPY protos ./protos
|
||||
@@ -50,11 +52,9 @@ COPY --from=node /build/dist ./dist
|
||||
ARG TAG=dev
|
||||
ARG TARGETOS TARGETARCH
|
||||
|
||||
# Generate protos
|
||||
RUN go generate
|
||||
|
||||
# Build binary
|
||||
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=$TAG" -o dozzle
|
||||
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
|
||||
GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=$TAG" -o dozzle
|
||||
|
||||
RUN mkdir /data
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Dozzle Major Version Highlights
|
||||
|
||||
A summary of major features introduced with each major version bump since 2020.
|
||||
|
||||
## v2.0.0 — 2020-06-27
|
||||
|
||||
- Theme overhaul using CSS variables
|
||||
- Progress notification bar
|
||||
- Toggle between new light and dark themes
|
||||
|
||||
## v3.0.0 — 2020-09-06
|
||||
|
||||
- Live container stats (CPU/memory) streamed in real time
|
||||
- Pinned tabs and improved mobile/responsive layout
|
||||
- Major UI overhaul
|
||||
|
||||
## v4.0.0 — 2022-08-17
|
||||
|
||||
- First-class JSON log support
|
||||
- Jump-to-context, soft wraps, clear-logs action
|
||||
- Auto color scheme, dark mode polish
|
||||
- Container stats panel, total CPU/mem usage
|
||||
- Healthcheck endpoint and "wait for docker" startup option
|
||||
|
||||
## v5.0.0 — 2023-09-23
|
||||
|
||||
- Multi-host support with parallel client connections
|
||||
- New homepage dashboard with all containers, sortable table, pagination, bar charts
|
||||
- Exponential moving average for stats
|
||||
- Container pinning, keyboard shortcut overlay
|
||||
- stdout/stderr stream separation
|
||||
- i18n: Chinese, German added; locale infrastructure
|
||||
- Refactored UI with faster components
|
||||
|
||||
## v6.0.0 — 2024-01-01
|
||||
|
||||
- **Forward-proxy authentication** (Authelia, etc.) and `users.yml` file-based auth
|
||||
- Container actions: start/stop/restart from the UI
|
||||
- Hot-reload of users.yml without restart
|
||||
- Custom headers for forward-proxy auth
|
||||
- Settings synced to disk for authenticated users
|
||||
- Toast notifications, release list, redirect-to-new-container
|
||||
- Removed legacy auth model (breaking)
|
||||
|
||||
## v7.0.0 — 2024-05-24
|
||||
|
||||
- **Docker Swarm mode** with stacks and services on remote hosts
|
||||
- Host cards on dashboard with per-host stats
|
||||
- Container grouping by stack/compose
|
||||
- Background stats collection (up to 5 min) with idle deactivation
|
||||
- LogFmt parser support
|
||||
- Compact mode, draggable search, alt-click split panes
|
||||
- Many new locales (French, Italian, Polish, Danish, Turkish, …)
|
||||
- `generate` subcommand for users.yml
|
||||
|
||||
## v8.0.0 — 2024-07-05
|
||||
|
||||
- **Swarm mode rebuilt on gRPC agents** (breaking architecture change)
|
||||
- Critical/severe log levels
|
||||
- Stacks and services in fuzzy search
|
||||
- Improved search with full match scrolling
|
||||
- Container start events shown inline
|
||||
|
||||
## v9.0.0 — 2026-01-06
|
||||
|
||||
- **Kubernetes mode** with k8s-specific menu
|
||||
- **User roles** and `dozzle_*` role mapping; logout URL for forward proxy
|
||||
- Historical stats on homepage, hosts, and containers
|
||||
- Permanent links to specific past log lines
|
||||
- Grouping by `dev.dozzle.group` label, log message grouping
|
||||
- Shell resize support, action toolbar in menu
|
||||
- Settings page, collapsible side menu sections
|
||||
- Parallel container fetching, gRPC compression
|
||||
- CLEF (`@l`) log level extraction
|
||||
- New locales: Korean, Indonesian, Dutch
|
||||
|
||||
## v10.0.0 — 2026-02-10
|
||||
|
||||
- **Dozzle Cloud** integration (bidirectional gRPC tool execution)
|
||||
- **Notifications & alerts**: full notifications page, webhooks, dispatchers, Go template support, test connections
|
||||
- Notifications work across agents
|
||||
- Per-container network usage stats (with mobile view)
|
||||
- Coolify label fallbacks for container name/group
|
||||
- Alert creation shortcut, JSON syntax in templates
|
||||
@@ -1,7 +1,4 @@
|
||||
PROTO_DIR := protos
|
||||
GEN_DIR := internal/agent/pb
|
||||
PROTO_FILES := $(wildcard $(PROTO_DIR)/*.proto)
|
||||
GEN_FILES := $(patsubst $(PROTO_DIR)/%.proto,$(GEN_DIR)/%.pb.go,$(PROTO_FILES))
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
@@ -29,10 +26,12 @@ build: dist generate
|
||||
CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=local"
|
||||
|
||||
.PHONY: docker
|
||||
docker: shared_key.pem shared_cert.pem
|
||||
@docker build --build-arg TAG=local -t amir20/dozzle .
|
||||
docker: generate
|
||||
@docker build --build-arg TAG=local --build-arg CLOUD_URL=$(CLOUD_URL) -t amir20/dozzle:local .
|
||||
|
||||
generate: shared_key.pem shared_cert.pem $(GEN_FILES)
|
||||
.PHONY: generate
|
||||
generate: shared_key.pem shared_cert.pem
|
||||
@go generate ./...
|
||||
|
||||
.PHONY: dev
|
||||
dev: generate fake_assets
|
||||
@@ -47,24 +46,24 @@ shared_key.pem:
|
||||
|
||||
shared_cert.pem: shared_key.pem
|
||||
@openssl req -new -key shared_key.pem -out shared_request.csr -subj "/C=US/ST=California/L=San Francisco/O=Dozzle"
|
||||
@openssl x509 -req -in shared_request.csr -signkey shared_key.pem -out shared_cert.pem -days 365
|
||||
@openssl x509 -req -in shared_request.csr -signkey shared_key.pem -out shared_cert.pem -days 1825
|
||||
@rm shared_request.csr
|
||||
|
||||
$(GEN_DIR)/%.pb.go: $(PROTO_DIR)/%.proto
|
||||
@go generate
|
||||
|
||||
.PHONY: push
|
||||
push: docker
|
||||
@docker tag amir20/dozzle:latest amir20/dozzle:local-test
|
||||
@docker push amir20/dozzle:local-test
|
||||
|
||||
tools:
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
|
||||
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
|
||||
go install github.com/air-verse/air@latest
|
||||
|
||||
.PHONY: run
|
||||
run: docker
|
||||
docker run -it --rm -p 8080:8080 -v /var/run/docker.sock:/var/run/docker.sock amir20/dozzle:latest
|
||||
docker run -it --rm -p 8080:8080 -v /var/run/docker.sock:/var/run/docker.sock amir20/dozzle:local
|
||||
|
||||
.PHONY: preview
|
||||
preview: build
|
||||
pnpm preview
|
||||
|
||||
.PHONY: agent-reload
|
||||
agent-reload: docker
|
||||
@VM_NAME=$${VM_NAME:-dozzle-agent}; \
|
||||
echo "📦 Loading image into VM $$VM_NAME..."; \
|
||||
docker save amir20/dozzle:local | orb exec -m $$VM_NAME docker load; \
|
||||
echo "🔄 Recreating agent..."; \
|
||||
orb exec -m $$VM_NAME docker stop dozzle-agent || true; \
|
||||
orb exec -m $$VM_NAME docker rm dozzle-agent || true; \
|
||||
orb exec -m $$VM_NAME docker run -d --name dozzle-agent -p 7007:7007 -v /var/run/docker.sock:/var/run/docker.sock -v ~/dozzle-certs:/certs -v ~/dozzle-data:/data -e DOZZLE_LEVEL=debug amir20/dozzle:local agent --cert /certs/shared_cert.pem --key /certs/shared_key.pem; \
|
||||
echo "✅ Agent reloaded"
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<p align="center">
|
||||
<img src="assets/logo.svg" alt="Dozzle Logo" width="200"/>
|
||||
</p>
|
||||
|
||||
# Dozzle - [dozzle.dev](https://dozzle.dev/)
|
||||
|
||||
Dozzle is a small lightweight application with a web based interface to monitor Docker logs. It doesn’t store any log files. It is for live monitoring of your container logs only.
|
||||
Dozzle is a lightweight, web-based application for monitoring Docker logs in real time. It doesn't store any log files—it's designed purely for live log viewing.
|
||||
|
||||
https://github.com/user-attachments/assets/66a7b4b2-d6c9-4fca-ab04-aef6cd7c0c31
|
||||
|
||||
@@ -9,20 +13,23 @@ https://github.com/user-attachments/assets/66a7b4b2-d6c9-4fca-ab04-aef6cd7c0c31
|
||||
[](https://hub.docker.com/r/amir20/dozzle/)
|
||||

|
||||
|
||||
> [!NOTE]
|
||||
> If you like Dozzle, check out [`dtop`](https://github.com/amir20/dtop), a top-like application for monitoring Docker containers. It integrates with Dozzle to link directly to container logs.
|
||||
|
||||
## Features
|
||||
|
||||
- Intelligent fuzzy search for container names 🤖
|
||||
- Search logs using regex 🔦
|
||||
- Search logs using [SQL queries](https://dozzle.dev/guide/sql-engine) 📊
|
||||
- Small memory footprint 🏎
|
||||
- Intelligent fuzzy search for container names
|
||||
- Search logs using regex
|
||||
- Search logs using [SQL queries](https://dozzle.dev/guide/sql-engine)
|
||||
- Small memory footprint
|
||||
- Split screen for viewing multiple logs
|
||||
- Live stats with memory and CPU usage
|
||||
- Multi-user [authentication](https://dozzle.dev/guide/authentication) with support for proxy forward authorization 🚨
|
||||
- [Swarm](https://dozzle.dev/guide/swarm-mode) mode support 🐳
|
||||
- [Agent](https://dozzle.dev/guide/agent) mode for monitoring multiple Docker hosts 🕵️♂️
|
||||
- Dark mode 🌙
|
||||
- Multi-user [authentication](https://dozzle.dev/guide/authentication) with support for forward proxy authorization
|
||||
- [Swarm mode](https://dozzle.dev/guide/swarm-mode) support
|
||||
- [Agent mode](https://dozzle.dev/guide/agent) for monitoring multiple Docker hosts
|
||||
- Dark mode
|
||||
|
||||
Dozzle has been tested with hundreds of containers. However, it doesn't support offline searching. Products like [Loggly](https://www.loggly.com), [Papertrail](https://papertrailapp.com) or [Kibana](https://www.elastic.co/products/kibana) are more suited for full search capabilities.
|
||||
Dozzle has been tested with hundreds of containers. However, it doesn't support offline searching. Products like [Loggly](https://www.loggly.com), [Papertrail](https://papertrailapp.com), or [Kibana](https://www.elastic.co/products/kibana) are better suited for full search capabilities.
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -32,13 +39,13 @@ Dozzle is a small container (7 MB compressed). Pull the latest release with:
|
||||
|
||||
### Running Dozzle
|
||||
|
||||
The simplest way to use dozzle is to run the docker container. Also, mount the Docker Unix socket with `--volume` to `/var/run/docker.sock`:
|
||||
The simplest way to use Dozzle is to run the Docker container. Mount the Docker Unix socket with `--volume` to `/var/run/docker.sock`:
|
||||
|
||||
$ docker run --name dozzle -d --volume=/var/run/docker.sock:/var/run/docker.sock -p 8080:8080 amir20/dozzle:latest
|
||||
$ docker run --name dozzle -d --volume=/var/run/docker.sock:/var/run/docker.sock -v dozzle_data:/data -p 8080:8080 amir20/dozzle:latest
|
||||
|
||||
Dozzle will be available at [http://localhost:8080/](http://localhost:8080/).
|
||||
|
||||
Here is the Docker Compose file:
|
||||
Here is a Docker Compose example:
|
||||
|
||||
services:
|
||||
dozzle:
|
||||
@@ -46,14 +53,17 @@ Here is the Docker Compose file:
|
||||
image: amir20/dozzle:latest
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- dozzle_data:/data
|
||||
ports:
|
||||
- 8080:8080
|
||||
volumes:
|
||||
dozzle_data:
|
||||
|
||||
For advanced options like [authentication](https://dozzle.dev/guide/authentication), [remote hosts](https://dozzle.dev/guide/remote-hosts) or common [questions](https://dozzle.dev/guide/faq) see documentation at [dozzle.dev](https://dozzle.dev/guide/getting-started).
|
||||
For advanced options like [authentication](https://dozzle.dev/guide/authentication), [remote hosts](https://dozzle.dev/guide/remote-hosts), or common [questions](https://dozzle.dev/guide/faq), see the documentation at [dozzle.dev](https://dozzle.dev/guide/getting-started).
|
||||
|
||||
## Swarm Mode
|
||||
|
||||
Dozzle works with Docker Swarm mode. You can run Dozzle as a global service with:
|
||||
Dozzle works with Docker Swarm. You can run Dozzle as a global service:
|
||||
|
||||
$ docker service create --name dozzle --env DOZZLE_MODE=swarm --mode global --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock -p 8080:8080 amir20/dozzle:latest
|
||||
|
||||
@@ -61,7 +71,7 @@ See the [Swarm Mode](https://dozzle.dev/guide/swarm-mode) documentation for more
|
||||
|
||||
## Agent Mode
|
||||
|
||||
Dozzle can be used to monitor multiple Docker hosts. You can run Dozzle in agent mode with:
|
||||
Dozzle can monitor multiple Docker hosts. Run Dozzle in agent mode with:
|
||||
|
||||
$ docker run -v /var/run/docker.sock:/var/run/docker.sock -p 7007:7007 amir20/dozzle:latest agent
|
||||
|
||||
@@ -69,19 +79,21 @@ See the [Agent Mode](https://dozzle.dev/guide/agent) documentation for more deta
|
||||
|
||||
## Technical Details
|
||||
|
||||
Dozzle users automatic API negotiation which works with most Docker configurations. Dozzle also works with [Colima](https://github.com/abiosoft/colima) and [Podman](https://podman.io/).
|
||||
Dozzle uses automatic API negotiation, which works with most Docker configurations. Dozzle also works with [Colima](https://github.com/abiosoft/colima) and [Podman](https://podman.io/).
|
||||
|
||||
### Installation on podman
|
||||
Dozzle requires Docker Engine 19.03 or newer (API version 1.40+). Older daemons are not supported by the underlying Docker SDK.
|
||||
|
||||
By default Podman doesn't have a background process but you can enable this for Dozzle to work.
|
||||
### Installation on Podman
|
||||
|
||||
Verify first if your podman installation has enabled remote socket:
|
||||
By default, Podman doesn't have a background process, but you can enable the remote socket for Dozzle to work.
|
||||
|
||||
First, verify if your Podman installation has the remote socket enabled:
|
||||
|
||||
```
|
||||
podman info
|
||||
```
|
||||
|
||||
When you get under the key remote socket output like this, its already enabled:
|
||||
If you see output like this under the remote socket key, it's already enabled:
|
||||
|
||||
```
|
||||
remoteSocket:
|
||||
@@ -89,40 +101,40 @@ When you get under the key remote socket output like this, its already enabled:
|
||||
path: /run/user/1000/podman/podman.sock
|
||||
```
|
||||
|
||||
If it's not enabled please follow [this tutorial](https://github.com/containers/podman/blob/main/docs/tutorials/socket_activation.md) to enable it.
|
||||
If it's not enabled, follow [this tutorial](https://github.com/containers/podman/blob/main/docs/tutorials/socket_activation.md) to enable it.
|
||||
|
||||
Once you have the podman remote socket you can run Dozzle on podman.
|
||||
Once the Podman remote socket is enabled, you can run Dozzle:
|
||||
|
||||
```
|
||||
podman run --volume=/run/user/1000/podman/podman.sock:/var/run/docker.sock -d -p 8080:8080 docker.io/amir20/dozzle:latest
|
||||
```
|
||||
|
||||
Additionally you have to create a fake engine-id to prevent `host not found` errors. Podman doesn't generate an engine-id like Docker by itself due to its daemonless architecture.
|
||||
Additionally, you need to create a fake engine-id to prevent `host not found` errors. Podman doesn't generate an engine-id like Docker does, due to its daemonless architecture.
|
||||
|
||||
Under `/var/lib/docker` create a file named `engine-id`. On a system with Podman you will have to create the folder path as well. Inside the file place the UUID, for instance using `uuidgen > engine-id`. After that the file should have an identifier that looks like this: `b9f1d7fc-b459-4b6e-9f7a-e3d1cd2e14a9`.
|
||||
Create a file named `engine-id` under `/var/lib/docker`. On a system with Podman, you'll need to create the folder path as well. Place a UUID inside the file, for example using `uuidgen > engine-id`. The file should contain an identifier like: `b9f1d7fc-b459-4b6e-9f7a-e3d1cd2e14a9`.
|
||||
|
||||
For more details check [Podman Infos](docs/guide/podman.md) or the [FAQ](docs/guide/faq.md#i-am-seeing-host-not-found-error-in-the-logs-how-do-i-fix-it)
|
||||
For more details, see [Podman Info](docs/guide/podman.md) or the [FAQ](docs/guide/faq.md#i-am-seeing-host-not-found-error-in-the-logs-how-do-i-fix-it).
|
||||
|
||||
## Security
|
||||
|
||||
Dozzle supports file based authentication and forward proxy like [Authelia](https://www.authelia.com/). These are documented at https://dozzle.dev/guide/authentication.
|
||||
Dozzle supports file-based authentication and forward proxy authentication with tools like [Authelia](https://www.authelia.com/). See the documentation at https://dozzle.dev/guide/authentication.
|
||||
|
||||
## Analytics collected
|
||||
## Analytics
|
||||
|
||||
Dozzle collects anonymous user configurations using Google Analytics. Why? Dozzle is an open source project with no funding. As a result, there is no time to do user studies of Dozzle. Analytics is collected to prioritize features and fixes based on how people use Dozzle. This data is completely public and can be viewed live using [ Data Studio dashboard](https://datastudio.google.com/s/naeIu0MiWsY).
|
||||
Dozzle collects anonymous user configurations using Google Analytics. Why? Dozzle is an open source project with no funding, so there's no time for formal user studies. Analytics help prioritize features and fixes based on how people use Dozzle. This data is completely public and can be viewed live on the [Data Studio dashboard](https://datastudio.google.com/s/naeIu0MiWsY).
|
||||
|
||||
If you do not want to be tracked at all, see the `--no-analytics` flag below.
|
||||
To disable analytics, use the `--no-analytics` flag.
|
||||
|
||||
## Environment variables and configuration
|
||||
## Environment Variables and Configuration
|
||||
|
||||
Dozzle follows the [12-factor](https://12factor.net/) model. Configurations can use the CLI flags or environment variables. See documentation at [https://dozzle.dev/guide/supported-env-vars](https://dozzle.dev/guide/supported-env-vars) for more details.
|
||||
Dozzle follows the [12-factor](https://12factor.net/) model. Configuration can be done via CLI flags or environment variables. See the documentation at [dozzle.dev/guide/supported-env-vars](https://dozzle.dev/guide/supported-env-vars) for more details.
|
||||
|
||||
## Support
|
||||
|
||||
There are many ways you can support Dozzle:
|
||||
There are many ways to support Dozzle:
|
||||
|
||||
- Use it! Write about it! Star it! If you love Dozzle, drop me a line and tell me what you love.
|
||||
- Blog about Dozzle to spread the word. If you are good at writing send PRs to improve the documentation at [dozzle.dev](https://dozzle.dev/)
|
||||
- Blog about Dozzle to spread the word. If you're good at writing, send PRs to improve the documentation at [dozzle.dev](https://dozzle.dev/).
|
||||
- Sponsor my work at https://www.buymeacoffee.com/amirraminfar
|
||||
|
||||
<a href="https://www.buymeacoffee.com/amirraminfar" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
|
||||
@@ -133,10 +145,51 @@ There are many ways you can support Dozzle:
|
||||
|
||||
## Building
|
||||
|
||||
To build and test locally:
|
||||
Want to contribute? Great! Dozzle has two parts: a **Go backend** that talks to Docker, and a **Vue frontend** that runs in the browser. You don't need to know both — pick the side that matches what you want to change. For documentation fixes, no setup is needed at all; just edit the file on GitHub.
|
||||
|
||||
1. Install [NodeJs](https://nodejs.org/en/download/) and [pnpm](https://pnpm.io/installation).
|
||||
2. Install [Go](https://go.dev/doc/install).
|
||||
3. Install tools with `make tools`.
|
||||
4. Install node modules `pnpm install`.
|
||||
5. Run `make dev` to start a development server with hot reload.
|
||||
### 1. Install the prerequisites
|
||||
|
||||
You'll need [Go](https://go.dev/doc/install) (1.25+), [Node.js](https://nodejs.org/en/download/) (with [pnpm](https://pnpm.io/installation)), and [protoc](https://grpc.io/docs/protoc-installation/).
|
||||
|
||||
On macOS, you can install everything in one go:
|
||||
|
||||
```bash
|
||||
brew install go node pnpm protobuf
|
||||
```
|
||||
|
||||
On Linux (Debian/Ubuntu):
|
||||
|
||||
```bash
|
||||
sudo apt install golang nodejs protobuf-compiler
|
||||
npm install -g pnpm
|
||||
```
|
||||
|
||||
On Windows, we recommend using [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) and following the Linux instructions.
|
||||
|
||||
### 2. Clone and set up
|
||||
|
||||
```bash
|
||||
git clone https://github.com/amir20/dozzle.git
|
||||
cd dozzle
|
||||
pnpm install # installs frontend dependencies
|
||||
go install tool # installs Go build tools listed in go.mod (air, protoc-gen-go, etc.)
|
||||
make generate # generates TLS certificates and protobuf code (only needed once)
|
||||
```
|
||||
|
||||
### 3. Start the dev server
|
||||
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3100](http://localhost:3100) — you should see the Dozzle UI connected to your local Docker. Both the frontend and backend reload automatically when you save a file.
|
||||
|
||||
### Making your first change
|
||||
|
||||
Try editing `assets/pages/index.vue` and saving — the browser updates instantly. For backend changes, edit any `.go` file and the server will restart on its own.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- **Nothing shows up at localhost:3100** — make sure Docker is running and the socket is accessible at `/var/run/docker.sock`.
|
||||
- **`make generate` fails** — confirm `protoc` is on your PATH (`protoc --version`).
|
||||
- **Still stuck?** Open a question in [GitHub Discussions](https://github.com/amir20/dozzle/discussions) — we're happy to help.
|
||||
|
||||
Vendored
+449
-369
@@ -6,388 +6,438 @@
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const DEFAULT_SETTINGS: typeof import('./stores/settings')['DEFAULT_SETTINGS']
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
||||
const allLevels: typeof import('./composable/logContext')['allLevels']
|
||||
const arrayEquals: typeof import('./utils/index')['arrayEquals']
|
||||
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
|
||||
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
|
||||
const automaticRedirect: typeof import('./stores/settings')['automaticRedirect']
|
||||
const collapseNav: typeof import('./stores/settings')['collapseNav']
|
||||
const compact: typeof import('./stores/settings')['compact']
|
||||
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 createDrawer: typeof import('./composable/drawer')['createDrawer']
|
||||
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 createRef: typeof import('@vueuse/core')['createRef']
|
||||
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
|
||||
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
|
||||
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
|
||||
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
|
||||
const customRef: typeof import('vue')['customRef']
|
||||
const dateLocale: typeof import('./stores/settings')['dateLocale']
|
||||
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 drawerContext: typeof import('./composable/drawer')['drawerContext']
|
||||
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 flattenJSONToMap: typeof import('./utils/index')['flattenJSONToMap']
|
||||
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 globalShowPopup: typeof import('./composable/popup')['globalShowPopup']
|
||||
const h: typeof import('vue')['h']
|
||||
const hashCode: typeof import('./utils/index')['hashCode']
|
||||
const hourStyle: typeof import('./stores/settings')['hourStyle']
|
||||
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
|
||||
const inject: typeof import('vue')['inject']
|
||||
const injectLocal: typeof import('@vueuse/core')['injectLocal']
|
||||
const isDefined: typeof import('@vueuse/core')['isDefined']
|
||||
const isMobile: typeof import('./composable/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('./stores/settings')['lightTheme']
|
||||
const locale: typeof import('./stores/settings')['locale']
|
||||
const loggingContextKey: typeof import('./composable/logContext')['loggingContextKey']
|
||||
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('./stores/settings')['menuWidth']
|
||||
const nextTick: typeof import('vue')['nextTick']
|
||||
const onActivated: typeof import('vue')['onActivated']
|
||||
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
||||
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
|
||||
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
|
||||
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 onElementRemoval: typeof import('@vueuse/core')['onElementRemoval']
|
||||
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 onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
|
||||
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
|
||||
const persistentVisibleKeysForContainer: typeof import('./composable/storage')['persistentVisibleKeysForContainer']
|
||||
const pinnedContainers: typeof import('./composable/storage')['pinnedContainers']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const provideLocal: typeof import('@vueuse/core')['provideLocal']
|
||||
const provideLoggingContext: typeof import('./composable/logContext')['provideLoggingContext']
|
||||
const provideScrollContext: typeof import('./composable/scrollContext')['provideScrollContext']
|
||||
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 scrollContextKey: typeof import('./composable/scrollContext')['scrollContextKey']
|
||||
const search: typeof import('./stores/settings')['search']
|
||||
const sessionHost: typeof import('./composable/storage')['sessionHost']
|
||||
const setActivePinia: typeof import('pinia')['setActivePinia']
|
||||
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
|
||||
const setTitle: typeof import('./composable/title')['setTitle']
|
||||
const settings: typeof import('./stores/settings')['settings']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const showAllContainers: typeof import('./stores/settings')['showAllContainers']
|
||||
const showStd: typeof import('./stores/settings')['showStd']
|
||||
const showTimestamp: typeof import('./stores/settings')['showTimestamp']
|
||||
const size: typeof import('./stores/settings')['size']
|
||||
const smallerScrollbars: typeof import('./stores/settings')['smallerScrollbars']
|
||||
const softWrap: typeof import('./stores/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 toValue: typeof import('vue')['toValue']
|
||||
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 useAnimate: typeof import('@vueuse/core')['useAnimate']
|
||||
const useAnnouncements: typeof import('./stores/announcements')['useAnnouncements']
|
||||
const useArrayDifference: typeof import('@vueuse/core')['useArrayDifference']
|
||||
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 useArrayFindLast: typeof import('@vueuse/core')['useArrayFindLast']
|
||||
const useArrayIncludes: typeof import('@vueuse/core')['useArrayIncludes']
|
||||
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 useArrayUnique: typeof import('@vueuse/core')['useArrayUnique']
|
||||
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 useClipboardItems: typeof import('@vueuse/core')['useClipboardItems']
|
||||
const useCloned: typeof import('@vueuse/core')['useCloned']
|
||||
const useColorMode: typeof import('@vueuse/core')['useColorMode']
|
||||
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
|
||||
const useContainerActions: typeof import('./composable/containerActions')['useContainerActions']
|
||||
const useContainerStore: typeof import('./stores/container')['useContainerStore']
|
||||
const useContainerStream: typeof import('./composable/eventStreams')['useContainerStream']
|
||||
const useCountdown: typeof import('@vueuse/core')['useCountdown']
|
||||
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 useDrawer: typeof import('./composable/drawer')['useDrawer']
|
||||
const useDropZone: typeof import('@vueuse/core')['useDropZone']
|
||||
const useDuckDB: typeof import('./composable/duckdb')['useDuckDB']
|
||||
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 useExponentialMovingAverage: typeof import('./utils/index')['useExponentialMovingAverage']
|
||||
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 useGroupedStream: typeof import('./composable/eventStreams')['useGroupedStream']
|
||||
const useHead: typeof import('@vueuse/head')['useHead']
|
||||
const useHostStream: typeof import('./composable/eventStreams')['useHostStream']
|
||||
const useHosts: typeof import('./stores/hosts')['useHosts']
|
||||
const useI18n: typeof import('vue-i18n')['useI18n']
|
||||
const useId: typeof import('vue')['useId']
|
||||
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 useLoggingContext: typeof import('./composable/logContext')['useLoggingContext']
|
||||
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 useMergedStream: typeof import('./composable/eventStreams')['useMergedStream']
|
||||
const useModel: typeof import('vue')['useModel']
|
||||
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 useParentElement: typeof import('@vueuse/core')['useParentElement']
|
||||
const usePerformanceObserver: typeof import('@vueuse/core')['usePerformanceObserver']
|
||||
const usePermission: typeof import('@vueuse/core')['usePermission']
|
||||
const usePinnedLogsStore: typeof import('./stores/pinned')['usePinnedLogsStore']
|
||||
const usePointer: typeof import('@vueuse/core')['usePointer']
|
||||
const usePointerLock: typeof import('@vueuse/core')['usePointerLock']
|
||||
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 usePreferredReducedTransparency: typeof import('@vueuse/core')['usePreferredReducedTransparency']
|
||||
const usePrevious: typeof import('@vueuse/core')['usePrevious']
|
||||
const useProfileStorage: typeof import('./composable/profileStorage')['useProfileStorage']
|
||||
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 useSSRWidth: typeof import('@vueuse/core')['useSSRWidth']
|
||||
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 useScrollContext: typeof import('./composable/scrollContext')['useScrollContext']
|
||||
const useScrollLock: typeof import('@vueuse/core')['useScrollLock']
|
||||
const useSearchFilter: typeof import('./composable/search')['useSearchFilter']
|
||||
const useSeoMeta: typeof import('@vueuse/head')['useSeoMeta']
|
||||
const useServiceStream: typeof import('./composable/eventStreams')['useServiceStream']
|
||||
const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage']
|
||||
const useShare: typeof import('@vueuse/core')['useShare']
|
||||
const useSimpleRefHistory: typeof import('./utils/index')['useSimpleRefHistory']
|
||||
const useSlots: typeof import('vue')['useSlots']
|
||||
const useSorted: typeof import('@vueuse/core')['useSorted']
|
||||
const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition']
|
||||
const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis']
|
||||
const useStackStream: typeof import('./composable/eventStreams')['useStackStream']
|
||||
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 useSwarmStore: typeof import('./stores/swarm')['useSwarmStore']
|
||||
const useSwipe: typeof import('@vueuse/core')['useSwipe']
|
||||
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
||||
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 useToast: typeof import('./composable/toast')['useToast']
|
||||
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('./composable/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 watchDeep: typeof import('@vueuse/core')['watchDeep']
|
||||
const watchEffect: typeof import('vue')['watchEffect']
|
||||
const watchIgnorable: typeof import('@vueuse/core')['watchIgnorable']
|
||||
const watchImmediate: typeof import('@vueuse/core')['watchImmediate']
|
||||
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']
|
||||
const withBase: typeof import('./stores/config')['withBase']
|
||||
const DEFAULT_SETTINGS: typeof import('./stores/settings').DEFAULT_SETTINGS
|
||||
const EffectScope: typeof import('vue').EffectScope
|
||||
const K8sNamespace: typeof import('./stores/k8s').K8sNamespace
|
||||
const K8sOwner: typeof import('./stores/k8s').K8sOwner
|
||||
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
|
||||
const allLevels: typeof import('./composable/logContext').allLevels
|
||||
const arrayEquals: typeof import('./utils/index').arrayEquals
|
||||
const asyncComputed: typeof import('@vueuse/core').asyncComputed
|
||||
const autoResetRef: typeof import('@vueuse/core').autoResetRef
|
||||
const automaticRedirect: typeof import('./stores/settings').automaticRedirect
|
||||
const collapseNav: typeof import('./stores/settings').collapseNav
|
||||
const compact: typeof import('./stores/settings').compact
|
||||
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 createContainerHints: typeof import('./composable/exprEditor').createContainerHints
|
||||
const createDisposableDirective: typeof import('@vueuse/core').createDisposableDirective
|
||||
const createDrawer: typeof import('./composable/drawer').createDrawer
|
||||
const createEventHints: typeof import('./composable/exprEditor').createEventHints
|
||||
const createEventHook: typeof import('@vueuse/core').createEventHook
|
||||
const createExprEditor: typeof import('./composable/exprEditor').createExprEditor
|
||||
const createGlobalState: typeof import('@vueuse/core').createGlobalState
|
||||
const createInjectionState: typeof import('@vueuse/core').createInjectionState
|
||||
const createLogHints: typeof import('./composable/exprEditor').createLogHints
|
||||
const createMetricHints: typeof import('./composable/exprEditor').createMetricHints
|
||||
const createPinia: typeof import('pinia').createPinia
|
||||
const createReactiveFn: typeof import('@vueuse/core').createReactiveFn
|
||||
const createRef: typeof import('@vueuse/core').createRef
|
||||
const createReusableTemplate: typeof import('@vueuse/core').createReusableTemplate
|
||||
const createSharedComposable: typeof import('@vueuse/core').createSharedComposable
|
||||
const createTemplateEditor: typeof import('./composable/templateEditor').createTemplateEditor
|
||||
const createTemplatePromise: typeof import('@vueuse/core').createTemplatePromise
|
||||
const createUnrefFn: typeof import('@vueuse/core').createUnrefFn
|
||||
const customRef: typeof import('vue').customRef
|
||||
const dateLocale: typeof import('./stores/settings').dateLocale
|
||||
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 drawerContext: typeof import('./composable/drawer').drawerContext
|
||||
const eagerComputed: typeof import('@vueuse/core').eagerComputed
|
||||
const effectScope: typeof import('vue').effectScope
|
||||
const escapeHtml: typeof import('./utils/index').escapeHtml
|
||||
const extendRef: typeof import('@vueuse/core').extendRef
|
||||
const flattenJSON: typeof import('./utils/index').flattenJSON
|
||||
const flattenJSONToMap: typeof import('./utils/index').flattenJSONToMap
|
||||
const formatBytes: typeof import('./utils/index').formatBytes
|
||||
const formatDuration: typeof import('./utils/index').formatDuration
|
||||
const getActivePinia: typeof import('pinia').getActivePinia
|
||||
const getCurrentInstance: typeof import('vue').getCurrentInstance
|
||||
const getCurrentScope: typeof import('vue').getCurrentScope
|
||||
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
|
||||
const getDeep: typeof import('./utils/index').getDeep
|
||||
const globalShowPopup: typeof import('./composable/popup').globalShowPopup
|
||||
const groupContainers: typeof import('./stores/settings').groupContainers
|
||||
const h: typeof import('vue').h
|
||||
const hashCode: typeof import('./utils/index').hashCode
|
||||
const highlightSubstringInHtml: typeof import('./utils/index').highlightSubstringInHtml
|
||||
const hourStyle: typeof import('./stores/settings').hourStyle
|
||||
const ignorableWatch: typeof import('@vueuse/core').ignorableWatch
|
||||
const inject: typeof import('vue').inject
|
||||
const injectLocal: typeof import('@vueuse/core').injectLocal
|
||||
const isDefined: typeof import('@vueuse/core').isDefined
|
||||
const isMobile: typeof import('./composable/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 isShallow: typeof import('vue').isShallow
|
||||
const lightTheme: typeof import('./stores/settings').lightTheme
|
||||
const loadBetween: typeof import('./composable/loadBetween').loadBetween
|
||||
const locale: typeof import('./stores/settings').locale
|
||||
const loggingContextKey: typeof import('./composable/logContext').loggingContextKey
|
||||
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('./stores/settings').menuWidth
|
||||
const nextTick: typeof import('vue').nextTick
|
||||
const onActivated: typeof import('vue').onActivated
|
||||
const onBeforeMount: typeof import('vue').onBeforeMount
|
||||
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
|
||||
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
|
||||
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 onElementRemoval: typeof import('@vueuse/core').onElementRemoval
|
||||
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 onWatcherCleanup: typeof import('vue').onWatcherCleanup
|
||||
const parseMessage: typeof import('./composable/loadBetween').parseMessage
|
||||
const pausableWatch: typeof import('@vueuse/core').pausableWatch
|
||||
const persistentVisibleKeysForContainer: typeof import('./composable/storage').persistentVisibleKeysForContainer
|
||||
const pinnedContainers: typeof import('./composable/storage').pinnedContainers
|
||||
const provide: typeof import('vue').provide
|
||||
const provideLocal: typeof import('@vueuse/core').provideLocal
|
||||
const provideLoggingContext: typeof import('./composable/logContext').provideLoggingContext
|
||||
const provideScrollContext: typeof import('./composable/scrollContext').provideScrollContext
|
||||
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 refManualReset: typeof import('@vueuse/core').refManualReset
|
||||
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 scrollContextKey: typeof import('./composable/scrollContext').scrollContextKey
|
||||
const search: typeof import('./stores/settings').search
|
||||
const sessionHost: typeof import('./composable/storage').sessionHost
|
||||
const setActivePinia: typeof import('pinia').setActivePinia
|
||||
const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix
|
||||
const setTitle: typeof import('./composable/title').setTitle
|
||||
const settings: typeof import('./stores/settings').settings
|
||||
const shallowReactive: typeof import('vue').shallowReactive
|
||||
const shallowReadonly: typeof import('vue').shallowReadonly
|
||||
const shallowRef: typeof import('vue').shallowRef
|
||||
const showAllContainers: typeof import('./stores/settings').showAllContainers
|
||||
const showStd: typeof import('./stores/settings').showStd
|
||||
const showTimestamp: typeof import('./stores/settings').showTimestamp
|
||||
const size: typeof import('./stores/settings').size
|
||||
const smallerScrollbars: typeof import('./stores/settings').smallerScrollbars
|
||||
const softWrap: typeof import('./stores/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 syntaxHighlightJson: typeof import('./utils/index').syntaxHighlightJson
|
||||
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 toRelativeTime: typeof import('./utils/index').toRelativeTime
|
||||
const toValue: typeof import('vue').toValue
|
||||
const triggerRef: typeof import('vue').triggerRef
|
||||
const tryFormatJson: typeof import('./utils/index').tryFormatJson
|
||||
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 useAlertForm: typeof import('./composable/alertForm').useAlertForm
|
||||
const useAnimate: typeof import('@vueuse/core').useAnimate
|
||||
const useAnnouncements: typeof import('./stores/announcements').useAnnouncements
|
||||
const useArrayDifference: typeof import('@vueuse/core').useArrayDifference
|
||||
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 useArrayFindLast: typeof import('@vueuse/core').useArrayFindLast
|
||||
const useArrayIncludes: typeof import('@vueuse/core').useArrayIncludes
|
||||
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 useArrayUnique: typeof import('@vueuse/core').useArrayUnique
|
||||
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 useClipboardItems: typeof import('@vueuse/core').useClipboardItems
|
||||
const useCloned: typeof import('@vueuse/core').useCloned
|
||||
const useCloudConfig: typeof import('./composable/cloudConfig').useCloudConfig
|
||||
const useCloudLogSearch: typeof import('./composable/cloudLogSearch').useCloudLogSearch
|
||||
const useColorMode: typeof import('@vueuse/core').useColorMode
|
||||
const useConfirmDialog: typeof import('@vueuse/core').useConfirmDialog
|
||||
const useContainerActions: typeof import('./composable/containerActions').useContainerActions
|
||||
const useContainerStore: typeof import('./stores/container').useContainerStore
|
||||
const useContainerStream: typeof import('./composable/eventStreams').useContainerStream
|
||||
const useCountdown: typeof import('@vueuse/core').useCountdown
|
||||
const useCounter: typeof import('@vueuse/core').useCounter
|
||||
const useCssModule: typeof import('vue').useCssModule
|
||||
const useCssSupports: typeof import('@vueuse/core').useCssSupports
|
||||
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 useDownloadUrl: typeof import('./composable/downloadUrl').useDownloadUrl
|
||||
const useDraggable: typeof import('@vueuse/core').useDraggable
|
||||
const useDrawer: typeof import('./composable/drawer').useDrawer
|
||||
const useDropZone: typeof import('@vueuse/core').useDropZone
|
||||
const useDuckDB: typeof import('./composable/duckdb').useDuckDB
|
||||
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 useExponentialMovingAverage: typeof import('./utils/index').useExponentialMovingAverage
|
||||
const useExprEditorField: typeof import('./composable/useExprEditorField').useExprEditorField
|
||||
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 useFuzzySearch: typeof import('./composable/fuzzySearch').useFuzzySearch
|
||||
const useGamepad: typeof import('@vueuse/core').useGamepad
|
||||
const useGeolocation: typeof import('@vueuse/core').useGeolocation
|
||||
const useGroupedStream: typeof import('./composable/eventStreams').useGroupedStream
|
||||
const useHead: typeof import('@vueuse/head').useHead
|
||||
const useHistoricalContainerLog: typeof import('./composable/historicalLogs').useHistoricalContainerLog
|
||||
const useHostGroupStream: typeof import('./composable/eventStreams').useHostGroupStream
|
||||
const useHostStream: typeof import('./composable/eventStreams').useHostStream
|
||||
const useHosts: typeof import('./stores/hosts').useHosts
|
||||
const useI18n: typeof import('vue-i18n').useI18n
|
||||
const useId: typeof import('vue').useId
|
||||
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 useK8sStore: typeof import('./stores/k8s').useK8sStore
|
||||
const useKeyModifier: typeof import('@vueuse/core').useKeyModifier
|
||||
const useLastChanged: typeof import('@vueuse/core').useLastChanged
|
||||
const useLink: typeof import('vue-router/auto').useLink
|
||||
const useLocalStorage: typeof import('@vueuse/core').useLocalStorage
|
||||
const useLogLoader: typeof import('./composable/logLoader').useLogLoader
|
||||
const useLoggingContext: typeof import('./composable/logContext').useLoggingContext
|
||||
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 useMergedStream: typeof import('./composable/eventStreams').useMergedStream
|
||||
const useModel: typeof import('vue').useModel
|
||||
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 useNamespaceStream: typeof import('./composable/eventStreams').useNamespaceStream
|
||||
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 useOwnerStream: typeof import('./composable/eventStreams').useOwnerStream
|
||||
const usePageLeave: typeof import('@vueuse/core').usePageLeave
|
||||
const useParallax: typeof import('@vueuse/core').useParallax
|
||||
const useParentElement: typeof import('@vueuse/core').useParentElement
|
||||
const usePerformanceObserver: typeof import('@vueuse/core').usePerformanceObserver
|
||||
const usePermission: typeof import('@vueuse/core').usePermission
|
||||
const usePinnedLogsStore: typeof import('./stores/pinned').usePinnedLogsStore
|
||||
const usePointer: typeof import('@vueuse/core').usePointer
|
||||
const usePointerLock: typeof import('@vueuse/core').usePointerLock
|
||||
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 usePreferredReducedTransparency: typeof import('@vueuse/core').usePreferredReducedTransparency
|
||||
const usePrevious: typeof import('@vueuse/core').usePrevious
|
||||
const useProfileStorage: typeof import('./composable/profileStorage').useProfileStorage
|
||||
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/auto').useRoute
|
||||
const useRouter: typeof import('vue-router/auto').useRouter
|
||||
const useSSRWidth: typeof import('@vueuse/core').useSSRWidth
|
||||
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 useScrollContext: typeof import('./composable/scrollContext').useScrollContext
|
||||
const useScrollLock: typeof import('@vueuse/core').useScrollLock
|
||||
const useSearchFilter: typeof import('./composable/search').useSearchFilter
|
||||
const useSeoMeta: typeof import('@vueuse/head').useSeoMeta
|
||||
const useServiceStream: typeof import('./composable/eventStreams').useServiceStream
|
||||
const useSessionStorage: typeof import('@vueuse/core').useSessionStorage
|
||||
const useShare: typeof import('@vueuse/core').useShare
|
||||
const useSimpleRefHistory: typeof import('./utils/index').useSimpleRefHistory
|
||||
const useSlots: typeof import('vue').useSlots
|
||||
const useSorted: typeof import('@vueuse/core').useSorted
|
||||
const useSpeechRecognition: typeof import('@vueuse/core').useSpeechRecognition
|
||||
const useSpeechSynthesis: typeof import('@vueuse/core').useSpeechSynthesis
|
||||
const useStackStream: typeof import('./composable/eventStreams').useStackStream
|
||||
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 useSwarmStore: typeof import('./stores/swarm').useSwarmStore
|
||||
const useSwipe: typeof import('@vueuse/core').useSwipe
|
||||
const useTemplateRef: typeof import('vue').useTemplateRef
|
||||
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 useTimeAgoIntl: typeof import('@vueuse/core').useTimeAgoIntl
|
||||
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 useToast: typeof import('./composable/toast').useToast
|
||||
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('./composable/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 watchDeep: typeof import('@vueuse/core').watchDeep
|
||||
const watchEffect: typeof import('vue').watchEffect
|
||||
const watchIgnorable: typeof import('@vueuse/core').watchIgnorable
|
||||
const watchImmediate: typeof import('@vueuse/core').watchImmediate
|
||||
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
|
||||
const withBase: typeof import('./stores/config').withBase
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
// @ts-ignore
|
||||
export type { AlertFormOptions, ContainerResult } from './composable/alertForm'
|
||||
import('./composable/alertForm')
|
||||
// @ts-ignore
|
||||
export type { CloudLogHit } from './composable/cloudLogSearch'
|
||||
import('./composable/cloudLogSearch')
|
||||
// @ts-ignore
|
||||
export type { DrawerWidth } from './composable/drawer'
|
||||
import('./composable/drawer')
|
||||
// @ts-ignore
|
||||
export type { LogStreamSource } from './composable/eventStreams'
|
||||
export type { SearchStatus, LogStreamSource } from './composable/eventStreams'
|
||||
import('./composable/eventStreams')
|
||||
// @ts-ignore
|
||||
export type { ExprEditorOptions } from './composable/exprEditor'
|
||||
import('./composable/exprEditor')
|
||||
// @ts-ignore
|
||||
export type { TemplateEditorOptions } from './composable/templateEditor'
|
||||
import('./composable/templateEditor')
|
||||
// @ts-ignore
|
||||
export type { Config, Profile } from './stores/config'
|
||||
import('./stores/config')
|
||||
// @ts-ignore
|
||||
export type { Host } from './stores/hosts'
|
||||
import('./stores/hosts')
|
||||
// @ts-ignore
|
||||
export type { K8sNamespace, K8sOwner } from './stores/k8s'
|
||||
import('./stores/k8s')
|
||||
// @ts-ignore
|
||||
export type { Settings } from './stores/settings'
|
||||
import('./stores/settings')
|
||||
}
|
||||
@@ -399,6 +449,8 @@ declare module 'vue' {
|
||||
interface ComponentCustomProperties {
|
||||
readonly DEFAULT_SETTINGS: UnwrapRef<typeof import('./stores/settings')['DEFAULT_SETTINGS']>
|
||||
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
|
||||
readonly K8sNamespace: UnwrapRef<typeof import('./stores/k8s')['K8sNamespace']>
|
||||
readonly K8sOwner: UnwrapRef<typeof import('./stores/k8s')['K8sOwner']>
|
||||
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
|
||||
readonly allLevels: UnwrapRef<typeof import('./composable/logContext')['allLevels']>
|
||||
readonly arrayEquals: UnwrapRef<typeof import('./utils/index')['arrayEquals']>
|
||||
@@ -416,15 +468,22 @@ declare module 'vue' {
|
||||
readonly controlledComputed: UnwrapRef<typeof import('@vueuse/core')['controlledComputed']>
|
||||
readonly controlledRef: UnwrapRef<typeof import('@vueuse/core')['controlledRef']>
|
||||
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
|
||||
readonly createContainerHints: UnwrapRef<typeof import('./composable/exprEditor')['createContainerHints']>
|
||||
readonly createDisposableDirective: UnwrapRef<typeof import('@vueuse/core')['createDisposableDirective']>
|
||||
readonly createDrawer: UnwrapRef<typeof import('./composable/drawer')['createDrawer']>
|
||||
readonly createEventHints: UnwrapRef<typeof import('./composable/exprEditor')['createEventHints']>
|
||||
readonly createEventHook: UnwrapRef<typeof import('@vueuse/core')['createEventHook']>
|
||||
readonly createExprEditor: UnwrapRef<typeof import('./composable/exprEditor')['createExprEditor']>
|
||||
readonly createGlobalState: UnwrapRef<typeof import('@vueuse/core')['createGlobalState']>
|
||||
readonly createInjectionState: UnwrapRef<typeof import('@vueuse/core')['createInjectionState']>
|
||||
readonly createLogHints: UnwrapRef<typeof import('./composable/exprEditor')['createLogHints']>
|
||||
readonly createMetricHints: UnwrapRef<typeof import('./composable/exprEditor')['createMetricHints']>
|
||||
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
|
||||
readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']>
|
||||
readonly createRef: UnwrapRef<typeof import('@vueuse/core')['createRef']>
|
||||
readonly createReusableTemplate: UnwrapRef<typeof import('@vueuse/core')['createReusableTemplate']>
|
||||
readonly createSharedComposable: UnwrapRef<typeof import('@vueuse/core')['createSharedComposable']>
|
||||
readonly createTemplateEditor: UnwrapRef<typeof import('./composable/templateEditor')['createTemplateEditor']>
|
||||
readonly createTemplatePromise: UnwrapRef<typeof import('@vueuse/core')['createTemplatePromise']>
|
||||
readonly createUnrefFn: UnwrapRef<typeof import('@vueuse/core')['createUnrefFn']>
|
||||
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
|
||||
@@ -441,11 +500,14 @@ declare module 'vue' {
|
||||
readonly flattenJSON: UnwrapRef<typeof import('./utils/index')['flattenJSON']>
|
||||
readonly flattenJSONToMap: UnwrapRef<typeof import('./utils/index')['flattenJSONToMap']>
|
||||
readonly formatBytes: UnwrapRef<typeof import('./utils/index')['formatBytes']>
|
||||
readonly formatDuration: UnwrapRef<typeof import('./utils/index')['formatDuration']>
|
||||
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
|
||||
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
|
||||
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
||||
readonly getCurrentWatcher: UnwrapRef<typeof import('vue')['getCurrentWatcher']>
|
||||
readonly getDeep: UnwrapRef<typeof import('./utils/index')['getDeep']>
|
||||
readonly globalShowPopup: UnwrapRef<typeof import('./composable/popup')['globalShowPopup']>
|
||||
readonly groupContainers: UnwrapRef<typeof import('./stores/settings')['groupContainers']>
|
||||
readonly h: UnwrapRef<typeof import('vue')['h']>
|
||||
readonly hashCode: UnwrapRef<typeof import('./utils/index')['hashCode']>
|
||||
readonly hourStyle: UnwrapRef<typeof import('./stores/settings')['hourStyle']>
|
||||
@@ -459,7 +521,9 @@ declare module 'vue' {
|
||||
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
|
||||
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
|
||||
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
|
||||
readonly isShallow: UnwrapRef<typeof import('vue')['isShallow']>
|
||||
readonly lightTheme: UnwrapRef<typeof import('./stores/settings')['lightTheme']>
|
||||
readonly loadBetween: UnwrapRef<typeof import('./composable/loadBetween')['loadBetween']>
|
||||
readonly locale: UnwrapRef<typeof import('./stores/settings')['locale']>
|
||||
readonly loggingContextKey: UnwrapRef<typeof import('./composable/logContext')['loggingContextKey']>
|
||||
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
|
||||
@@ -492,6 +556,7 @@ declare module 'vue' {
|
||||
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
|
||||
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
|
||||
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
|
||||
readonly parseMessage: UnwrapRef<typeof import('./composable/loadBetween')['parseMessage']>
|
||||
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
|
||||
readonly persistentVisibleKeysForContainer: UnwrapRef<typeof import('./composable/storage')['persistentVisibleKeysForContainer']>
|
||||
readonly pinnedContainers: UnwrapRef<typeof import('./composable/storage')['pinnedContainers']>
|
||||
@@ -510,11 +575,10 @@ declare module 'vue' {
|
||||
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 refManualReset: UnwrapRef<typeof import('@vueuse/core')['refManualReset']>
|
||||
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 scrollContextKey: UnwrapRef<typeof import('./composable/scrollContext')['scrollContextKey']>
|
||||
readonly search: UnwrapRef<typeof import('./stores/settings')['search']>
|
||||
readonly sessionHost: UnwrapRef<typeof import('./composable/storage')['sessionHost']>
|
||||
@@ -542,6 +606,7 @@ declare module 'vue' {
|
||||
readonly toReactive: UnwrapRef<typeof import('@vueuse/core')['toReactive']>
|
||||
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
|
||||
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
|
||||
readonly toRelativeTime: UnwrapRef<typeof import('./utils/index')['toRelativeTime']>
|
||||
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
|
||||
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
|
||||
readonly tryOnBeforeMount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeMount']>
|
||||
@@ -553,6 +618,7 @@ declare module 'vue' {
|
||||
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 useAlertForm: UnwrapRef<typeof import('./composable/alertForm')['useAlertForm']>
|
||||
readonly useAnimate: UnwrapRef<typeof import('@vueuse/core')['useAnimate']>
|
||||
readonly useAnnouncements: UnwrapRef<typeof import('./stores/announcements')['useAnnouncements']>
|
||||
readonly useArrayDifference: UnwrapRef<typeof import('@vueuse/core')['useArrayDifference']>
|
||||
@@ -580,6 +646,8 @@ declare module 'vue' {
|
||||
readonly useClipboard: UnwrapRef<typeof import('@vueuse/core')['useClipboard']>
|
||||
readonly useClipboardItems: UnwrapRef<typeof import('@vueuse/core')['useClipboardItems']>
|
||||
readonly useCloned: UnwrapRef<typeof import('@vueuse/core')['useCloned']>
|
||||
readonly useCloudConfig: UnwrapRef<typeof import('./composable/cloudConfig')['useCloudConfig']>
|
||||
readonly useCloudLogSearch: UnwrapRef<typeof import('./composable/cloudLogSearch')['useCloudLogSearch']>
|
||||
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
|
||||
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
|
||||
readonly useContainerActions: UnwrapRef<typeof import('./composable/containerActions')['useContainerActions']>
|
||||
@@ -588,6 +656,7 @@ declare module 'vue' {
|
||||
readonly useCountdown: UnwrapRef<typeof import('@vueuse/core')['useCountdown']>
|
||||
readonly useCounter: UnwrapRef<typeof import('@vueuse/core')['useCounter']>
|
||||
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
|
||||
readonly useCssSupports: UnwrapRef<typeof import('@vueuse/core')['useCssSupports']>
|
||||
readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']>
|
||||
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
|
||||
readonly useCurrentElement: UnwrapRef<typeof import('@vueuse/core')['useCurrentElement']>
|
||||
@@ -603,6 +672,7 @@ declare module 'vue' {
|
||||
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 useDownloadUrl: UnwrapRef<typeof import('./composable/downloadUrl')['useDownloadUrl']>
|
||||
readonly useDraggable: UnwrapRef<typeof import('@vueuse/core')['useDraggable']>
|
||||
readonly useDrawer: UnwrapRef<typeof import('./composable/drawer')['useDrawer']>
|
||||
readonly useDropZone: UnwrapRef<typeof import('@vueuse/core')['useDropZone']>
|
||||
@@ -616,6 +686,7 @@ declare module 'vue' {
|
||||
readonly useEventListener: UnwrapRef<typeof import('@vueuse/core')['useEventListener']>
|
||||
readonly useEventSource: UnwrapRef<typeof import('@vueuse/core')['useEventSource']>
|
||||
readonly useExponentialMovingAverage: UnwrapRef<typeof import('./utils/index')['useExponentialMovingAverage']>
|
||||
readonly useExprEditorField: UnwrapRef<typeof import('./composable/useExprEditorField')['useExprEditorField']>
|
||||
readonly useEyeDropper: UnwrapRef<typeof import('@vueuse/core')['useEyeDropper']>
|
||||
readonly useFavicon: UnwrapRef<typeof import('@vueuse/core')['useFavicon']>
|
||||
readonly useFetch: UnwrapRef<typeof import('@vueuse/core')['useFetch']>
|
||||
@@ -625,10 +696,13 @@ declare module 'vue' {
|
||||
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 useFuzzySearch: UnwrapRef<typeof import('./composable/fuzzySearch')['useFuzzySearch']>
|
||||
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
|
||||
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
|
||||
readonly useGroupedStream: UnwrapRef<typeof import('./composable/eventStreams')['useGroupedStream']>
|
||||
readonly useHead: UnwrapRef<typeof import('@vueuse/head')['useHead']>
|
||||
readonly useHistoricalContainerLog: UnwrapRef<typeof import('./composable/historicalLogs')['useHistoricalContainerLog']>
|
||||
readonly useHostGroupStream: UnwrapRef<typeof import('./composable/eventStreams')['useHostGroupStream']>
|
||||
readonly useHostStream: UnwrapRef<typeof import('./composable/eventStreams')['useHostStream']>
|
||||
readonly useHosts: UnwrapRef<typeof import('./stores/hosts')['useHosts']>
|
||||
readonly useI18n: UnwrapRef<typeof import('vue-i18n')['useI18n']>
|
||||
@@ -639,9 +713,12 @@ declare module 'vue' {
|
||||
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 useK8sStore: UnwrapRef<typeof import('./stores/k8s')['useK8sStore']>
|
||||
readonly useKeyModifier: UnwrapRef<typeof import('@vueuse/core')['useKeyModifier']>
|
||||
readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
|
||||
readonly useLink: UnwrapRef<typeof import('vue-router/auto')['useLink']>
|
||||
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
|
||||
readonly useLogLoader: UnwrapRef<typeof import('./composable/logLoader')['useLogLoader']>
|
||||
readonly useLoggingContext: UnwrapRef<typeof import('./composable/logContext')['useLoggingContext']>
|
||||
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
|
||||
readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']>
|
||||
@@ -656,12 +733,14 @@ declare module 'vue' {
|
||||
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 useNamespaceStream: UnwrapRef<typeof import('./composable/eventStreams')['useNamespaceStream']>
|
||||
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 useOwnerStream: UnwrapRef<typeof import('./composable/eventStreams')['useOwnerStream']>
|
||||
readonly usePageLeave: UnwrapRef<typeof import('@vueuse/core')['usePageLeave']>
|
||||
readonly useParallax: UnwrapRef<typeof import('@vueuse/core')['useParallax']>
|
||||
readonly useParentElement: UnwrapRef<typeof import('@vueuse/core')['useParentElement']>
|
||||
@@ -682,8 +761,8 @@ declare module 'vue' {
|
||||
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 useRoute: UnwrapRef<typeof import('vue-router/auto')['useRoute']>
|
||||
readonly useRouter: UnwrapRef<typeof import('vue-router/auto')['useRouter']>
|
||||
readonly useSSRWidth: UnwrapRef<typeof import('@vueuse/core')['useSSRWidth']>
|
||||
readonly useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']>
|
||||
readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']>
|
||||
@@ -718,6 +797,7 @@ declare module 'vue' {
|
||||
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 useTimeAgoIntl: UnwrapRef<typeof import('@vueuse/core')['useTimeAgoIntl']>
|
||||
readonly useTimeout: UnwrapRef<typeof import('@vueuse/core')['useTimeout']>
|
||||
readonly useTimeoutFn: UnwrapRef<typeof import('@vueuse/core')['useTimeoutFn']>
|
||||
readonly useTimeoutPoll: UnwrapRef<typeof import('@vueuse/core')['useTimeoutPoll']>
|
||||
|
||||
Vendored
+86
-9
@@ -1,17 +1,24 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint: disable
|
||||
// oxlint-disable
|
||||
// ------
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
// biome-ignore lint: disable
|
||||
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AlertCard: typeof import('./components/Notification/AlertCard.vue')['default']
|
||||
AlertForm: typeof import('./components/Notification/AlertForm.vue')['default']
|
||||
Announcements: typeof import('./components/Announcements.vue')['default']
|
||||
BarChart: typeof import('./components/BarChart.vue')['default']
|
||||
'Carbon:add': typeof import('~icons/carbon/add')['default']
|
||||
'Carbon:caretDown': typeof import('~icons/carbon/caret-down')['default']
|
||||
'Carbon:circleSolid': typeof import('~icons/carbon/circle-solid')['default']
|
||||
'Carbon:copyFile': typeof import('~icons/carbon/copy-file')['default']
|
||||
'Carbon:close': typeof import('~icons/carbon/close')['default']
|
||||
'Carbon:information': typeof import('~icons/carbon/information')['default']
|
||||
'Carbon:logoKubernetes': typeof import('~icons/carbon/logo-kubernetes')['default']
|
||||
'Carbon:macShift': typeof import('~icons/carbon/mac-shift')['default']
|
||||
@@ -20,6 +27,7 @@ declare module 'vue' {
|
||||
'Carbon:star': typeof import('~icons/carbon/star')['default']
|
||||
'Carbon:starFilled': typeof import('~icons/carbon/star-filled')['default']
|
||||
'Carbon:stopFilledAlt': typeof import('~icons/carbon/stop-filled-alt')['default']
|
||||
'Carbon:upgrade': typeof import('~icons/carbon/upgrade')['default']
|
||||
'Carbon:warning': typeof import('~icons/carbon/warning')['default']
|
||||
Carousel: typeof import('./components/common/Carousel.vue')['default']
|
||||
CarouselItem: typeof import('./components/common/CarouselItem.vue')['default']
|
||||
@@ -27,100 +35,166 @@ declare module 'vue' {
|
||||
'Cil:circle': typeof import('~icons/cil/circle')['default']
|
||||
'Cil:columns': typeof import('~icons/cil/columns')['default']
|
||||
'Cil:xCircle': typeof import('~icons/cil/x-circle')['default']
|
||||
CloudDestinationForm: typeof import('./components/Notification/CloudDestinationForm.vue')['default']
|
||||
CloudPopover: typeof import('./components/CloudPopover.vue')['default']
|
||||
CloudSearchInline: typeof import('./components/CloudSearchInline.vue')['default']
|
||||
CloudSettingsCard: typeof import('./components/CloudSettingsCard.vue')['default']
|
||||
ComplexLogItem: typeof import('./components/LogViewer/ComplexLogItem.vue')['default']
|
||||
ContainerActionsToolbar: typeof import('./components/ContainerViewer/ContainerActionsToolbar.vue')['default']
|
||||
ContainerDropdown: typeof import('./components/ContainerDropdown.vue')['default']
|
||||
ContainerEventLogItem: typeof import('./components/LogViewer/ContainerEventLogItem.vue')['default']
|
||||
ContainerHealth: typeof import('./components/ContainerViewer/ContainerHealth.vue')['default']
|
||||
ContainerLog: typeof import('./components/ContainerViewer/ContainerLog.vue')['default']
|
||||
ContainerPopup: typeof import('./components/ContainerPopup.vue')['default']
|
||||
ContainerStatCell: typeof import('./components/ContainerStatCell.vue')['default']
|
||||
ContainerTable: typeof import('./components/ContainerTable.vue')['default']
|
||||
ContainerTitle: typeof import('./components/ContainerViewer/ContainerTitle.vue')['default']
|
||||
CooldownField: typeof import('./components/Notification/CooldownField.vue')['default']
|
||||
DateTime: typeof import('./components/common/DateTime.vue')['default']
|
||||
DistanceTime: typeof import('./components/common/DistanceTime.vue')['default']
|
||||
DestinationCard: typeof import('./components/Notification/DestinationCard.vue')['default']
|
||||
DestinationForm: typeof import('./components/Notification/DestinationForm.vue')['default']
|
||||
Dropdown: typeof import('./components/common/Dropdown.vue')['default']
|
||||
DropdownMenu: typeof import('./components/common/DropdownMenu.vue')['default']
|
||||
EventAlertFields: typeof import('./components/Notification/EventAlertFields.vue')['default']
|
||||
EventSource: typeof import('./components/LogViewer/EventSource.vue')['default']
|
||||
FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default']
|
||||
GroupedLog: typeof import('./components/GroupedViewer/GroupedLog.vue')['default']
|
||||
GroupedLogItem: typeof import('./components/LogViewer/GroupedLogItem.vue')['default']
|
||||
GroupMenu: typeof import('./components/GroupMenu.vue')['default']
|
||||
HistoricalContainerLog: typeof import('./components/ContainerViewer/HistoricalContainerLog.vue')['default']
|
||||
HostCard: typeof import('./components/HostCard.vue')['default']
|
||||
HostGroupLog: typeof import('./components/HostViewer/HostGroupLog.vue')['default']
|
||||
HostIcon: typeof import('./components/common/HostIcon.vue')['default']
|
||||
HostList: typeof import('./components/HostList.vue')['default']
|
||||
HostLog: typeof import('./components/HostViewer/HostLog.vue')['default']
|
||||
HostMenu: typeof import('./components/HostMenu.vue')['default']
|
||||
'Ic:sharpKeyboardReturn': typeof import('~icons/ic/sharp-keyboard-return')['default']
|
||||
IndeterminateBar: typeof import('./components/common/IndeterminateBar.vue')['default']
|
||||
InfiniteLoader: typeof import('./components/InfiniteLoader.vue')['default']
|
||||
IOCard: typeof import('./components/LogViewer/IOCard.vue')['default']
|
||||
'Ion:ellipsisVertical': typeof import('~icons/ion/ellipsis-vertical')['default']
|
||||
JsonFormatted: typeof import('./components/common/JsonFormatted.vue')['default']
|
||||
JsonText: typeof import('./components/common/JsonText.vue')['default']
|
||||
JsonValue: typeof import('./components/common/JsonValue.vue')['default']
|
||||
K8sMenu: typeof import('./components/K8sMenu.vue')['default']
|
||||
KeyShortcut: typeof import('./components/common/KeyShortcut.vue')['default']
|
||||
LabeledInput: typeof import('./components/common/LabeledInput.vue')['default']
|
||||
Links: typeof import('./components/Links.vue')['default']
|
||||
LoadMoreLogItem: typeof import('./components/LogViewer/LoadMoreLogItem.vue')['default']
|
||||
LogActions: typeof import('./components/LogViewer/LogActions.vue')['default']
|
||||
LogAlertFields: typeof import('./components/Notification/LogAlertFields.vue')['default']
|
||||
LogAnalytics: typeof import('./components/LogViewer/LogAnalytics.vue')['default']
|
||||
LogDate: typeof import('./components/LogViewer/LogDate.vue')['default']
|
||||
LogDetails: typeof import('./components/LogViewer/LogDetails.vue')['default']
|
||||
LogItem: typeof import('./components/LogViewer/LogItem.vue')['default']
|
||||
LogLevel: typeof import('./components/LogViewer/LogLevel.vue')['default']
|
||||
LogList: typeof import('./components/LogViewer/LogList.vue')['default']
|
||||
LogMessageActions: typeof import('./components/LogViewer/LogMessageActions.vue')['default']
|
||||
LogStd: typeof import('./components/LogViewer/LogStd.vue')['default']
|
||||
LogViewer: typeof import('./components/LogViewer/LogViewer.vue')['default']
|
||||
'MaterialSymbols:codeBlocksRounded': typeof import('~icons/material-symbols/code-blocks-rounded')['default']
|
||||
'MaterialSymbols:contentCopy': typeof import('~icons/material-symbols/content-copy')['default']
|
||||
'MaterialSymbols:eyeTracking': typeof import('~icons/material-symbols/eye-tracking')['default']
|
||||
'MaterialSymbols:link': typeof import('~icons/material-symbols/link')['default']
|
||||
'MaterialSymbols:logout': typeof import('~icons/material-symbols/logout')['default']
|
||||
'MaterialSymbols:person': typeof import('~icons/material-symbols/person')['default']
|
||||
'MaterialSymbols:terminal': typeof import('~icons/material-symbols/terminal')['default']
|
||||
'MaterialSymbolsLight:collapseAll': typeof import('~icons/material-symbols-light/collapse-all')['default']
|
||||
'MaterialSymbolsLight:expandAll': typeof import('~icons/material-symbols-light/expand-all')['default']
|
||||
'Mdi:account': typeof import('~icons/mdi/account')['default']
|
||||
'Mdi:alert': typeof import('~icons/mdi/alert')['default']
|
||||
'Mdi:alertCircle': typeof import('~icons/mdi/alert-circle')['default']
|
||||
'Mdi:alertCircleOutline': typeof import('~icons/mdi/alert-circle-outline')['default']
|
||||
'Mdi:alertOutline': typeof import('~icons/mdi/alert-outline')['default']
|
||||
'Mdi:announcement': typeof import('~icons/mdi/announcement')['default']
|
||||
'Mdi:arrowUp': typeof import('~icons/mdi/arrow-up')['default']
|
||||
'Mdi:beer': typeof import('~icons/mdi/beer')['default']
|
||||
'Mdi:bell': typeof import('~icons/mdi/bell')['default']
|
||||
'Mdi:bellRingOutline': typeof import('~icons/mdi/bell-ring-outline')['default']
|
||||
'Mdi:chartBar': typeof import('~icons/mdi/chart-bar')['default']
|
||||
'Mdi:chartLine': typeof import('~icons/mdi/chart-line')['default']
|
||||
'Mdi:check': typeof import('~icons/mdi/check')['default']
|
||||
'Mdi:checkCircle': typeof import('~icons/mdi/check-circle')['default']
|
||||
'Mdi:chevronDoubleDown': typeof import('~icons/mdi/chevron-double-down')['default']
|
||||
'Mdi:chevronDown': typeof import('~icons/mdi/chevron-down')['default']
|
||||
'Mdi:chevronLeft': typeof import('~icons/mdi/chevron-left')['default']
|
||||
'Mdi:chevronRight': typeof import('~icons/mdi/chevron-right')['default']
|
||||
'Mdi:close': typeof import('~icons/mdi/close')['default']
|
||||
'Mdi:cloud': typeof import('~icons/mdi/cloud')['default']
|
||||
'Mdi:cloudCheckOutline': typeof import('~icons/mdi/cloud-check-outline')['default']
|
||||
'Mdi:cloudOffOutline': typeof import('~icons/mdi/cloud-off-outline')['default']
|
||||
'Mdi:cloudOutline': typeof import('~icons/mdi/cloud-outline')['default']
|
||||
'Mdi:cloudSearchOutline': typeof import('~icons/mdi/cloud-search-outline')['default']
|
||||
'Mdi:cog': typeof import('~icons/mdi/cog')['default']
|
||||
'Mdi:contentCopy': typeof import('~icons/mdi/content-copy')['default']
|
||||
'Mdi:docker': typeof import('~icons/mdi/docker')['default']
|
||||
'Mdi:filterOffOutline': typeof import('~icons/mdi/filter-off-outline')['default']
|
||||
'Mdi:filterOutline': typeof import('~icons/mdi/filter-outline')['default']
|
||||
'Mdi:flash': typeof import('~icons/mdi/flash')['default']
|
||||
'Mdi:gauge': typeof import('~icons/mdi/gauge')['default']
|
||||
'Mdi:github': typeof import('~icons/mdi/github')['default']
|
||||
'Mdi:hamburgerMenu': typeof import('~icons/mdi/hamburger-menu')['default']
|
||||
'Mdi:heart': typeof import('~icons/mdi/heart')['default']
|
||||
'Mdi:hexagonMultiple': typeof import('~icons/mdi/hexagon-multiple')['default']
|
||||
'Mdi:key': typeof import('~icons/mdi/key')['default']
|
||||
'Mdi:keyboardEsc': typeof import('~icons/mdi/keyboard-esc')['default']
|
||||
'Mdi:lightningBolt': typeof import('~icons/mdi/lightning-bolt')['default']
|
||||
'Mdi:linkVariant': typeof import('~icons/mdi/link-variant')['default']
|
||||
'Mdi:linkVariantOff': typeof import('~icons/mdi/link-variant-off')['default']
|
||||
'Mdi:magnify': typeof import('~icons/mdi/magnify')['default']
|
||||
'Mdi:pencilOutline': typeof import('~icons/mdi/pencil-outline')['default']
|
||||
'Mdi:plus': typeof import('~icons/mdi/plus')['default']
|
||||
'Mdi:poll': typeof import('~icons/mdi/poll')['default']
|
||||
'Mdi:refresh': typeof import('~icons/mdi/refresh')['default']
|
||||
'Mdi:satelliteVariant': typeof import('~icons/mdi/satellite-variant')['default']
|
||||
'Mdi:textBoxOutline': typeof import('~icons/mdi/text-box-outline')['default']
|
||||
'Mdi:trashCanOutline': typeof import('~icons/mdi/trash-can-outline')['default']
|
||||
'Mdi:webhook': typeof import('~icons/mdi/webhook')['default']
|
||||
MetricAlertFields: typeof import('./components/Notification/MetricAlertFields.vue')['default']
|
||||
MetricCard: typeof import('./components/MetricCard.vue')['default']
|
||||
MobileMenu: typeof import('./components/common/MobileMenu.vue')['default']
|
||||
MultiContainerActionToolbar: typeof import('./components/LogViewer/MultiContainerActionToolbar.vue')['default']
|
||||
MultiContainerLog: typeof import('./components/MultiContainerViewer/MultiContainerLog.vue')['default']
|
||||
MultiContainerStat: typeof import('./components/LogViewer/MultiContainerStat.vue')['default']
|
||||
NamespaceLog: typeof import('./components/K8sViewer/NamespaceLog.vue')['default']
|
||||
'Octicon:container24': typeof import('~icons/octicon/container24')['default']
|
||||
'Octicon:download24': typeof import('~icons/octicon/download24')['default']
|
||||
'Octicon:trash24': typeof import('~icons/octicon/trash24')['default']
|
||||
OwnerLog: typeof import('./components/K8sViewer/OwnerLog.vue')['default']
|
||||
PageWithLinks: typeof import('./components/PageWithLinks.vue')['default']
|
||||
'Ph:arrowsMerge': typeof import('~icons/ph/arrows-merge')['default']
|
||||
'Ph:boundingBoxFill': typeof import('~icons/ph/bounding-box-fill')['default']
|
||||
'Ph:caretRight': typeof import('~icons/ph/caret-right')['default']
|
||||
'Ph:circlesFour': typeof import('~icons/ph/circles-four')['default']
|
||||
'Ph:command': typeof import('~icons/ph/command')['default']
|
||||
'Ph:computerTower': typeof import('~icons/ph/computer-tower')['default']
|
||||
'Ph:controlBold': typeof import('~icons/ph/control-bold')['default']
|
||||
'Ph:cpu': typeof import('~icons/ph/cpu')['default']
|
||||
'Ph:database': typeof import('~icons/ph/database')['default']
|
||||
'Ph:dotsThreeVerticalBold': typeof import('~icons/ph/dots-three-vertical-bold')['default']
|
||||
'Ph:fileSql': typeof import('~icons/ph/file-sql')['default']
|
||||
'Ph:globeSimple': typeof import('~icons/ph/globe-simple')['default']
|
||||
'Ph:memory': typeof import('~icons/ph/memory')['default']
|
||||
'Ph:stack': typeof import('~icons/ph/stack')['default']
|
||||
'Ph:stackSimple': typeof import('~icons/ph/stack-simple')['default']
|
||||
Popup: typeof import('./components/Popup.vue')['default']
|
||||
RandomColorTag: typeof import('./components/LogViewer/RandomColorTag.vue')['default']
|
||||
RelativeTime: typeof import('./components/common/RelativeTime.vue')['default']
|
||||
'Ri:terminalWindowFill': typeof import('~icons/ri/terminal-window-fill')['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']
|
||||
SearchStatus: typeof import('./components/LogViewer/SearchStatus.vue')['default']
|
||||
ServiceLog: typeof import('./components/ServiceViewer/ServiceLog.vue')['default']
|
||||
SideDrawer: typeof import('./components/common/SideDrawer.vue')['default']
|
||||
SideMenu: typeof import('./components/SideMenu.vue')['default']
|
||||
SidePanel: typeof import('./components/SidePanel.vue')['default']
|
||||
'SimpleIcons:podman': typeof import('~icons/simple-icons/podman')['default']
|
||||
SimpleLogItem: typeof import('./components/LogViewer/SimpleLogItem.vue')['default']
|
||||
SkippedEntriesLogItem: typeof import('./components/LogViewer/SkippedEntriesLogItem.vue')['default']
|
||||
SlideTransition: typeof import('./components/common/SlideTransition.vue')['default']
|
||||
SQLTable: typeof import('./components/LogViewer/SQLTable.vue')['default']
|
||||
StackLog: typeof import('./components/StackViewer/StackLog.vue')['default']
|
||||
StatMonitor: typeof import('./components/LogViewer/StatMonitor.vue')['default']
|
||||
StatSparkline: typeof import('./components/LogViewer/StatSparkline.vue')['default']
|
||||
StatCard: typeof import('./components/LogViewer/StatCard.vue')['default']
|
||||
'SvgSpinners:ringResize': typeof import('~icons/svg-spinners/ring-resize')['default']
|
||||
SwarmMenu: typeof import('./components/SwarmMenu.vue')['default']
|
||||
Tag: typeof import('./components/common/Tag.vue')['default']
|
||||
Terminal: typeof import('./components/Terminal.vue')['default']
|
||||
@@ -128,6 +202,9 @@ declare module 'vue' {
|
||||
ToastModal: typeof import('./components/common/ToastModal.vue')['default']
|
||||
Toggle: typeof import('./components/common/Toggle.vue')['default']
|
||||
ViewerWithSource: typeof import('./components/LogViewer/ViewerWithSource.vue')['default']
|
||||
VolumeWarning: typeof import('./components/ContainerViewer/VolumeWarning.vue')['default']
|
||||
WebhookDestinationForm: typeof import('./components/Notification/WebhookDestinationForm.vue')['default']
|
||||
WelcomeModal: typeof import('./components/WelcomeModal.vue')['default']
|
||||
ZigZag: typeof import('./components/LogViewer/ZigZag.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<Dropdown class="dropdown-end" @closed="releaseSeen = mostRecent?.tag ?? config.version">
|
||||
<Dropdown
|
||||
class="dropdown-end"
|
||||
@click="config.releaseCheckMode === 'manual' && fetchReleases()"
|
||||
@closed="releaseSeen = mostRecent?.tag ?? config.version"
|
||||
>
|
||||
<template #trigger>
|
||||
<mdi:announcement class="size-6 -rotate-12" />
|
||||
<template v-if="announcements.length > 0 && releaseSeen != mostRecent?.tag">
|
||||
@@ -22,7 +26,7 @@
|
||||
>
|
||||
{{ release.name }}
|
||||
</a>
|
||||
<span class="ml-1 text-xs"><distance-time :date="release.createdAt" /></span>
|
||||
<span class="ml-1 text-xs"><RelativeTime :date="release.createdAt" /></span>
|
||||
</div>
|
||||
<div class="text-base-content/80 text-sm">
|
||||
{{ release.body }}
|
||||
@@ -39,7 +43,7 @@
|
||||
>
|
||||
{{ release.name }}
|
||||
</a>
|
||||
<span class="ml-1 text-xs"><distance-time :date="release.createdAt" /></span>
|
||||
<span class="ml-1 text-xs"><RelativeTime :date="release.createdAt" /></span>
|
||||
<Tag class="bg-red ml-auto px-1 py-1 text-xs" v-if="release.latest">
|
||||
{{ $t("releases.latest") }}
|
||||
</Tag>
|
||||
@@ -63,7 +67,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useAnnouncements } from "@/stores/announcements";
|
||||
|
||||
const { announcements, mostRecent } = useAnnouncements();
|
||||
const { announcements, mostRecent, fetchReleases } = useAnnouncements();
|
||||
const { t } = useI18n();
|
||||
|
||||
const releaseSeen = useProfileStorage("releaseSeen", config.version);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { nextTick } from "vue";
|
||||
import BarChart, { type BarDataPoint } from "./BarChart.vue";
|
||||
|
||||
// useElementSize relies on ResizeObserver which jsdom lacks, so the width stays
|
||||
// 0 and the chart never renders. Mock it with a controllable width ref that we
|
||||
// flip to a real value after mount to mimic the ResizeObserver firing.
|
||||
const holder = vi.hoisted(() => ({ width: null as ReturnType<typeof import("vue").ref<number>> | null }));
|
||||
vi.mock("@vueuse/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@vueuse/core")>();
|
||||
const { ref: vueRef } = await import("vue");
|
||||
holder.width = vueRef(0);
|
||||
return { ...actual, useElementSize: () => ({ width: holder.width, height: vueRef(0) }) };
|
||||
});
|
||||
|
||||
function ramp(start = 0, n = 300): BarDataPoint[] {
|
||||
return Array.from({ length: n }, (_, i) => ({ percent: start + i, value: start + i }));
|
||||
}
|
||||
|
||||
function constant(percent: number, n = 300): BarDataPoint[] {
|
||||
return Array.from({ length: n }, () => ({ percent, value: percent }));
|
||||
}
|
||||
|
||||
function heightOf(wrapper: ReturnType<typeof mount>, index: number): number {
|
||||
const style = wrapper.findAll(".bar")[index]?.attributes("style") ?? "";
|
||||
const match = style.match(/--height:\s*([\d.]+)%/);
|
||||
return match ? parseFloat(match[1]) : 0;
|
||||
}
|
||||
|
||||
async function mountAndRender(chartData: BarDataPoint[]) {
|
||||
// Mount with an unmeasured element, then simulate ResizeObserver reporting a
|
||||
// real width -> triggers the initial recalculate, like the live component.
|
||||
holder.width!.value = 0;
|
||||
const wrapper = mount(BarChart, { props: { chartData } });
|
||||
await nextTick();
|
||||
holder.width!.value = 300;
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
describe("<BarChart />", () => {
|
||||
test("exposed recalculate() rebuilds all bars after a wholesale data swap", async () => {
|
||||
// First container: a ramp where the oldest bars are near zero.
|
||||
const wrapper = await mountAndRender(ramp());
|
||||
expect(heightOf(wrapper, 0)).toBeLessThan(20); // oldest ramp bar is tiny
|
||||
|
||||
// A stat tick arrives: the rolling window shifts by one, marking the chart
|
||||
// initialized so further changes only patch the last bar.
|
||||
await wrapper.setProps({ chartData: ramp(1) });
|
||||
await nextTick();
|
||||
|
||||
// Switch containers: the whole series is replaced with a flat high value.
|
||||
// The chart caches bars and only patches the last one, so without help the
|
||||
// older bars stay stale.
|
||||
await wrapper.setProps({ chartData: constant(1000) });
|
||||
await nextTick();
|
||||
expect(heightOf(wrapper, 0)).toBeLessThan(20); // still stale
|
||||
|
||||
// The parent owns container switches and calls recalculate() to refresh.
|
||||
(wrapper.vm as unknown as { recalculate: () => void }).recalculate();
|
||||
await nextTick();
|
||||
expect(heightOf(wrapper, 0)).toBeGreaterThan(50); // flat series -> uniform height
|
||||
});
|
||||
|
||||
test("renders downsampled bars once width is known", async () => {
|
||||
const wrapper = await mountAndRender(constant(1000));
|
||||
expect(wrapper.findAll(".bar").length).toBeGreaterThan(0);
|
||||
expect(heightOf(wrapper, 0)).toBeGreaterThan(50);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div ref="chartContainer" class="flex items-end gap-[2px]" @mousemove="onContainerHover">
|
||||
<div
|
||||
v-for="(bar, i) in downsampledBars"
|
||||
:key="i"
|
||||
class="bar min-h-px flex-1 rounded-t-sm"
|
||||
:class="barClass"
|
||||
:style="{ '--height': `${maxValue > 0 ? (bar.percent / maxValue) * 100 : 0}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bar {
|
||||
height: var(--height);
|
||||
will-change: height;
|
||||
contain: layout;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
export interface BarDataPoint {
|
||||
percent: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const { chartData, barClass = "" } = defineProps<{
|
||||
chartData: BarDataPoint[];
|
||||
barClass?: string;
|
||||
}>();
|
||||
|
||||
const hoverValue = defineEmit<[value: number]>();
|
||||
|
||||
const chartContainer = ref<HTMLElement | null>(null);
|
||||
const { width } = useElementSize(chartContainer);
|
||||
|
||||
const BAR_WIDTH = 3;
|
||||
const GAP = 2;
|
||||
|
||||
const availableBars = computed(() => Math.floor(width.value / (BAR_WIDTH + GAP)));
|
||||
const bucketSize = computed(() => Math.ceil(chartData.length / availableBars.value));
|
||||
|
||||
const downsampledBars = ref<BarDataPoint[]>([]);
|
||||
const maxValue = computed(() => {
|
||||
const dataMax = Math.max(0, ...downsampledBars.value.map((b) => b.percent));
|
||||
return Math.max(dataMax * 1.25, 1);
|
||||
});
|
||||
// Full recalculate when width/bucket size changes
|
||||
watch([availableBars, bucketSize], () => {
|
||||
recalculate();
|
||||
changeCounter.value = 0;
|
||||
});
|
||||
|
||||
// On data changes, only update the last bar unless a new bucket boundary is crossed.
|
||||
// A wholesale replacement of the series (e.g. switching containers) is not detected
|
||||
// here; the parent owns that and must call the exposed recalculate() on switch.
|
||||
const changeCounter = ref(0);
|
||||
let initialized = false;
|
||||
watch(
|
||||
() => chartData.at(-1),
|
||||
() => {
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
recalculate();
|
||||
return;
|
||||
}
|
||||
changeCounter.value++;
|
||||
if (changeCounter.value >= bucketSize.value) {
|
||||
recalculate();
|
||||
changeCounter.value = 0;
|
||||
} else {
|
||||
updateLastBar();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
defineExpose({ recalculate });
|
||||
|
||||
function averageBucket(bucket: BarDataPoint[]): BarDataPoint {
|
||||
const percent = bucket.reduce((sum, d) => sum + d.percent, 0) / bucket.length;
|
||||
const value = bucket.reduce((sum, d) => sum + d.value, 0) / bucket.length;
|
||||
return { percent, value };
|
||||
}
|
||||
|
||||
function recalculate() {
|
||||
if (availableBars.value === 0) return;
|
||||
|
||||
if (chartData.length <= availableBars.value) {
|
||||
downsampledBars.value = [...chartData];
|
||||
return;
|
||||
}
|
||||
|
||||
const size = bucketSize.value;
|
||||
const result: BarDataPoint[] = [];
|
||||
const numBuckets = Math.ceil(chartData.length / size);
|
||||
|
||||
for (let i = 0; i < numBuckets; i++) {
|
||||
const start = i * size;
|
||||
const end = Math.min(start + size, chartData.length);
|
||||
result.push(averageBucket(chartData.slice(start, end)));
|
||||
}
|
||||
|
||||
downsampledBars.value = result.slice(-availableBars.value);
|
||||
}
|
||||
|
||||
function updateLastBar() {
|
||||
if (downsampledBars.value.length === 0) return;
|
||||
|
||||
const size = bucketSize.value;
|
||||
const lastBucketStart = (Math.ceil(chartData.length / size) - 1) * size;
|
||||
const bucket = chartData.slice(lastBucketStart);
|
||||
|
||||
downsampledBars.value[downsampledBars.value.length - 1] = averageBucket(bucket);
|
||||
}
|
||||
|
||||
function onContainerHover(event: MouseEvent) {
|
||||
if (!chartContainer.value) return;
|
||||
|
||||
const bars = chartContainer.value.children;
|
||||
if (bars.length === 0) return;
|
||||
|
||||
const mouseX = event.clientX;
|
||||
let index = 0;
|
||||
|
||||
// Find the bar whose column contains the mouse x position
|
||||
for (let i = 0; i < bars.length; i++) {
|
||||
const rect = bars[i].getBoundingClientRect();
|
||||
if (mouseX >= rect.left) {
|
||||
index = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
hoverValue(downsampledBars.value[index].value);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<Dropdown class="dropdown-end" @click="onOpen">
|
||||
<template #trigger>
|
||||
<div class="relative">
|
||||
<mdi:cloud
|
||||
class="size-6"
|
||||
:class="
|
||||
!cloudConfig
|
||||
? 'text-base-content/40'
|
||||
: cloudConfig.linked && !cloudStatusError
|
||||
? 'text-info'
|
||||
: cloudStatusError === 'unavailable'
|
||||
? 'text-warning'
|
||||
: 'text-error'
|
||||
"
|
||||
/>
|
||||
<span
|
||||
v-if="cloudConfig?.linked"
|
||||
class="absolute -top-0.5 -right-0.5 size-2 rounded-full"
|
||||
:class="
|
||||
cloudStatusError === 'auth'
|
||||
? 'bg-error'
|
||||
: cloudStatusError === 'unavailable'
|
||||
? 'bg-warning'
|
||||
: cloudStatusError
|
||||
? 'bg-error'
|
||||
: 'bg-success'
|
||||
"
|
||||
></span>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="w-80 space-y-3 p-1">
|
||||
<!-- Not linked -->
|
||||
<template v-if="!cloudConfig">
|
||||
<div class="flex flex-col items-center gap-2 p-2 text-center">
|
||||
<mdi:cloud class="text-base-content/40 text-4xl" />
|
||||
<h3 class="text-base font-bold">{{ $t("cloud.title") }}</h3>
|
||||
<p class="text-base-content/60 text-sm">{{ $t("cloud.description") }}</p>
|
||||
<div class="mt-2 flex w-full gap-2">
|
||||
<a :href="`${cloudUrl}`" target="_blank" rel="noreferrer noopener" class="btn btn-sm flex-1">
|
||||
{{ $t("cloud.learn-more") }}
|
||||
</a>
|
||||
<a :href="cloudLinkUrl" class="btn btn-primary btn-sm flex-1">
|
||||
<mdi:link-variant class="text-base" />
|
||||
{{ $t("cloud.link-instance") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Linked -->
|
||||
<template v-else-if="cloudConfig.linked">
|
||||
<!-- Error state -->
|
||||
<div v-if="cloudStatusError" class="space-y-3">
|
||||
<div class="alert" :class="cloudStatusError === 'auth' ? 'alert-error' : 'alert-warning'">
|
||||
<mdi:alert-circle v-if="cloudStatusError === 'auth'" class="text-lg" />
|
||||
<mdi:cloud-off-outline v-else class="text-lg" />
|
||||
<span class="text-sm">{{
|
||||
cloudStatusError === "auth" ? $t("cloud.error") : $t("cloud.error-unavailable")
|
||||
}}</span>
|
||||
</div>
|
||||
<a v-if="cloudStatusError === 'auth'" :href="cloudLinkUrl" class="btn btn-primary btn-sm w-full">
|
||||
<mdi:link-variant class="text-base" />
|
||||
{{ $t("cloud.relink-instance") }}
|
||||
</a>
|
||||
<button v-else class="btn btn-sm w-full" @click="fetchCloudStatus">
|
||||
<mdi:refresh class="text-base" />
|
||||
{{ $t("button.retry") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-else-if="isLoadingCloudStatus" class="flex items-center justify-center gap-2 py-4">
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
</div>
|
||||
|
||||
<!-- Healthy -->
|
||||
<div v-else-if="cloudStatus" class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-bold">{{ $t("cloud.title") }}</h3>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="badge badge-success badge-sm">{{ $t("cloud.connected") }}</span>
|
||||
<span class="badge badge-primary badge-sm capitalize">{{ cloudStatus.plan.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 flex items-center justify-between text-sm">
|
||||
<span class="text-base-content/60">{{ $t("cloud.usage") }}</span>
|
||||
<span>
|
||||
{{ cloudStatus.usage.events_used.toLocaleString() }} /
|
||||
{{ cloudStatus.usage.events_limit.toLocaleString() }}
|
||||
</span>
|
||||
</div>
|
||||
<progress
|
||||
class="progress w-full"
|
||||
:class="
|
||||
usagePercent > 90 ? 'progress-error' : usagePercent > 70 ? 'progress-warning' : 'progress-primary'
|
||||
"
|
||||
:value="cloudStatus.usage.events_used"
|
||||
:max="cloudStatus.usage.events_limit"
|
||||
></progress>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<a :href="cloudUrl" target="_blank" rel="noreferrer noopener" class="btn btn-sm flex-1">
|
||||
{{ $t("cloud.dashboard") }}
|
||||
</a>
|
||||
<a :href="`${cloudUrl}/settings`" target="_blank" rel="noreferrer noopener" class="btn btn-sm flex-1">
|
||||
{{ $t("cloud.settings") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<WelcomeModal ref="welcomeModal" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const cloudUrl = __CLOUD_URL__;
|
||||
const callbackUrl = `${window.location.origin}${withBase("/")}`;
|
||||
const cloudLinkUrl = `${cloudUrl}/link?appUrl=${encodeURIComponent(callbackUrl)}&from=cloud`;
|
||||
|
||||
const { cloudConfig, cloudStatus, cloudStatusError, isLoadingCloudStatus, fetchCloudConfig, fetchCloudStatus } =
|
||||
useCloudConfig();
|
||||
|
||||
const welcomeModal = ref<{ open: () => void }>();
|
||||
const cloudWelcomeShown = useProfileStorage("cloudWelcomeShown", false);
|
||||
|
||||
const usagePercent = computed(() => {
|
||||
if (!cloudStatus.value) return 0;
|
||||
return (cloudStatus.value.usage.events_used / cloudStatus.value.usage.events_limit) * 100;
|
||||
});
|
||||
|
||||
function onOpen() {
|
||||
if (cloudConfig.value?.linked && !cloudStatus.value && !isLoadingCloudStatus.value) {
|
||||
fetchCloudStatus();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchCloudConfig();
|
||||
|
||||
if (cloudConfig.value?.linked) {
|
||||
fetchCloudStatus();
|
||||
}
|
||||
|
||||
// Handle successful OAuth return — show welcome modal
|
||||
if (window.location.hash === "#cloudLinked" && !cloudWelcomeShown.value) {
|
||||
cloudWelcomeShown.value = true;
|
||||
nextTick(() => welcomeModal.value?.open());
|
||||
history.replaceState(history.state, "", window.location.pathname + window.location.search);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="search"
|
||||
class="bg-base-200 border-base-content/15 hover:border-primary/50 hover:bg-base-200/80 flex h-9 w-full items-center gap-2 rounded-md border px-3 text-left transition-colors"
|
||||
@click="openSearch"
|
||||
>
|
||||
<mdi:magnify class="size-4 shrink-0" :class="cloudReady ? 'text-primary' : 'text-base-content/60'" />
|
||||
<!-- Show the active query when we're on the cloud search page so the
|
||||
topbar reflects what the user is looking at. -->
|
||||
<span v-if="activeQuery" class="text-base-content truncate font-mono text-sm">{{ activeQuery }}</span>
|
||||
<span v-else class="text-base-content/60 truncate text-sm">
|
||||
<template v-if="cloudReady">{{ $t("cloud-search.hero-title-cloud") }}</template>
|
||||
<template v-else>{{ $t("cloud-search.hero-title-plain") }}</template>
|
||||
</span>
|
||||
<span class="ml-auto flex items-center gap-1">
|
||||
<kbd class="kbd kbd-xs">⌘</kbd>
|
||||
<kbd class="kbd kbd-xs">K</kbd>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useFuzzySearch } from "@/composable/fuzzySearch";
|
||||
import { useCloudConfig } from "@/composable/cloudConfig";
|
||||
|
||||
const { openSearch } = useFuzzySearch();
|
||||
const { cloudConfig } = useCloudConfig();
|
||||
const cloudReady = computed(() => !!cloudConfig.value?.linked && !!cloudConfig.value?.streamLogs);
|
||||
|
||||
const route = useRoute();
|
||||
const activeQuery = computed(() =>
|
||||
route?.path === "/cloud/search" && typeof route.query?.q === "string" ? route.query.q : "",
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div class="border-base-content/15 bg-base-200/40 divide-base-content/10 divide-y rounded-lg border">
|
||||
<!-- Not linked -->
|
||||
<template v-if="!cloudConfig">
|
||||
<div class="flex items-start gap-4 p-4">
|
||||
<mdi:cloud class="text-base-content/40 mt-0.5 text-4xl" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-base-content/70 text-sm">{{ $t("cloud.description") }}</p>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<a :href="`${cloudUrl}`" target="_blank" rel="noreferrer noopener" class="btn btn-sm">
|
||||
{{ $t("cloud.learn-more") }}
|
||||
</a>
|
||||
<a :href="cloudLinkUrl" class="btn btn-primary btn-sm">
|
||||
<mdi:link-variant class="text-base" />
|
||||
{{ $t("cloud.link-instance") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Linked -->
|
||||
<template v-else-if="cloudConfig.linked">
|
||||
<!-- Error state -->
|
||||
<div v-if="cloudStatusError" class="space-y-3 p-4">
|
||||
<div class="alert" :class="cloudStatusError === 'auth' ? 'alert-error' : 'alert-warning'">
|
||||
<mdi:alert-circle v-if="cloudStatusError === 'auth'" class="text-lg" />
|
||||
<mdi:cloud-off-outline v-else class="text-lg" />
|
||||
<span class="text-sm">{{
|
||||
cloudStatusError === "auth" ? $t("cloud.error") : $t("cloud.error-unavailable")
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a v-if="cloudStatusError === 'auth'" :href="cloudLinkUrl" class="btn btn-primary btn-sm">
|
||||
<mdi:link-variant class="text-base" />
|
||||
{{ $t("cloud.relink-instance") }}
|
||||
</a>
|
||||
<button v-else class="btn btn-sm" @click="fetchCloudStatus">
|
||||
<mdi:refresh class="text-base" />
|
||||
{{ $t("button.retry") }}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-error" @click="confirmUnlink">
|
||||
<mdi:link-variant-off class="text-base" />
|
||||
{{ $t("cloud.unlink") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-else-if="isLoadingCloudStatus" class="flex items-center gap-2 p-4">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
</div>
|
||||
|
||||
<!-- Healthy -->
|
||||
<template v-else-if="cloudStatus">
|
||||
<div class="flex flex-wrap items-center gap-2 p-4">
|
||||
<span class="status-pill status-pill-success">
|
||||
<span class="size-1.5 rounded-full bg-current"></span>
|
||||
{{ $t("cloud.connected") }}
|
||||
</span>
|
||||
<span class="status-pill status-pill-primary">{{ cloudStatus.plan.name }}</span>
|
||||
<span class="text-base-content/50 text-sm">{{ cloudStatus.user.email }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 p-4">
|
||||
<div class="flex items-baseline justify-between">
|
||||
<span class="text-base-content/60 text-sm font-medium">{{ $t("cloud.usage") }}</span>
|
||||
<span class="font-mono text-sm">
|
||||
<span class="font-semibold">{{ cloudStatus.usage.events_used.toLocaleString() }}</span>
|
||||
<span class="text-base-content/40"> / {{ cloudStatus.usage.events_limit.toLocaleString() }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<progress
|
||||
class="progress w-full"
|
||||
:class="usagePercent > 90 ? 'progress-error' : usagePercent > 70 ? 'progress-warning' : 'progress-primary'"
|
||||
:value="cloudStatus.usage.events_used"
|
||||
:max="cloudStatus.usage.events_limit"
|
||||
></progress>
|
||||
<div class="text-base-content/40 flex justify-between font-mono text-xs">
|
||||
<span v-if="cloudStatus.usage.period">{{ cloudStatus.usage.period }}</span>
|
||||
<span v-else></span>
|
||||
<span>{{ usagePercent.toFixed(2) }}% used</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="flex min-h-13 cursor-pointer items-center justify-between gap-4 p-4">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-sm font-medium">{{ $t("cloud.stream-logs") }}</span>
|
||||
<span class="text-base-content/60 text-xs">{{ $t("cloud.stream-logs-help") }}</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary toggle-sm shrink-0"
|
||||
:checked="streamLogs"
|
||||
:disabled="isSavingStreamLogs"
|
||||
@change="onStreamLogsChange(($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="flex gap-2 p-4">
|
||||
<a :href="cloudUrl" target="_blank" rel="noreferrer noopener" class="btn btn-sm">
|
||||
{{ $t("cloud.dashboard") }}
|
||||
</a>
|
||||
<button class="btn btn-sm btn-error" @click="confirmUnlink">
|
||||
{{ $t("cloud.unlink") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Unlink confirmation modal -->
|
||||
<dialog ref="unlinkModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">{{ $t("cloud.unlink") }}</h3>
|
||||
<p class="py-4 text-sm">{{ $t("cloud.unlink-confirm") }}</p>
|
||||
<div class="modal-action">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm">{{ $t("button.cancel") }}</button>
|
||||
</form>
|
||||
<button class="btn btn-error btn-sm" :disabled="isUnlinking" @click="doUnlink">
|
||||
<span v-if="isUnlinking" class="loading loading-spinner loading-xs"></span>
|
||||
{{ $t("cloud.unlink") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button></button>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const cloudUrl = __CLOUD_URL__;
|
||||
const callbackUrl = `${window.location.origin}${withBase("/")}`;
|
||||
const cloudLinkUrl = `${cloudUrl}/link?appUrl=${encodeURIComponent(callbackUrl)}&from=cloud`;
|
||||
|
||||
const {
|
||||
cloudConfig,
|
||||
cloudStatus,
|
||||
cloudStatusError,
|
||||
isLoadingCloudStatus,
|
||||
initialLoad,
|
||||
fetchCloudStatus,
|
||||
clearCloudState,
|
||||
} = useCloudConfig();
|
||||
const isUnlinking = ref(false);
|
||||
const unlinkModal = ref<HTMLDialogElement | null>(null);
|
||||
|
||||
const streamLogs = ref(true);
|
||||
const isSavingStreamLogs = ref(false);
|
||||
watchEffect(() => {
|
||||
if (cloudConfig.value) streamLogs.value = cloudConfig.value.streamLogs;
|
||||
});
|
||||
|
||||
async function onStreamLogsChange(value: boolean | undefined) {
|
||||
if (!cloudConfig.value || value === undefined) return;
|
||||
isSavingStreamLogs.value = true;
|
||||
try {
|
||||
const res = await fetch(withBase("/api/cloud/config"), {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ streamLogs: value }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
streamLogs.value = !value;
|
||||
return;
|
||||
}
|
||||
cloudConfig.value.streamLogs = value;
|
||||
} catch {
|
||||
streamLogs.value = !value;
|
||||
} finally {
|
||||
isSavingStreamLogs.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const usagePercent = computed(() => {
|
||||
if (!cloudStatus.value) return 0;
|
||||
return (cloudStatus.value.usage.events_used / cloudStatus.value.usage.events_limit) * 100;
|
||||
});
|
||||
|
||||
function confirmUnlink() {
|
||||
unlinkModal.value?.showModal();
|
||||
}
|
||||
|
||||
async function doUnlink() {
|
||||
isUnlinking.value = true;
|
||||
try {
|
||||
const res = await fetch(withBase("/api/cloud/config"), { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
cloudStatusError.value = "unavailable";
|
||||
return;
|
||||
}
|
||||
clearCloudState();
|
||||
unlinkModal.value?.close();
|
||||
} finally {
|
||||
isUnlinking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await initialLoad;
|
||||
if (cloudConfig.value?.linked) {
|
||||
fetchCloudStatus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div class="dropdown">
|
||||
<button tabindex="0" role="button" class="btn btn-xs md:btn-sm"><slot /> <carbon:caret-down /></button>
|
||||
<ul tabindex="0" class="dropdown-content menu rounded-box bg-base-100 border-base-content/20 border shadow-sm">
|
||||
<li v-for="other in containers">
|
||||
<router-link :to="{ name: '/container/[id]', params: { id: other.id } }" class="text-nowrap">
|
||||
<div
|
||||
class="status data-[state=exited]:status-error data-[state=running]:status-success data-[state=paused]:status-warning"
|
||||
:data-state="other.state"
|
||||
></div>
|
||||
{{ other.name }}
|
||||
<div v-if="other.state === 'running'">running</div>
|
||||
<div v-else-if="other.state === 'paused'">paused</div>
|
||||
<RelativeTime :date="other.finishedAt" class="text-base-content/70 text-xs" v-else />
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { type Container } from "@/models/Container";
|
||||
const { containers } = defineProps<{
|
||||
containers: Container[];
|
||||
}>();
|
||||
</script>
|
||||
@@ -1,28 +1,32 @@
|
||||
<template>
|
||||
<div>
|
||||
<span class="font-light capitalize"> STATE </span>
|
||||
<span class="font-semibold uppercase"> {{ container.state }} </span>
|
||||
</div>
|
||||
<div v-if="container.startedAt.getFullYear() > 0">
|
||||
<span class="font-light capitalize"> STARTED </span>
|
||||
<span class="font-semibold">
|
||||
<DistanceTime :date="container.startedAt" strict />
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="container.state != 'running' && container.finishedAt.getFullYear() > 0">
|
||||
<span class="font-light capitalize"> FINISHED </span>
|
||||
<span class="font-semibold">
|
||||
<DistanceTime :date="container.finishedAt" strict />
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="container.state == 'running'">
|
||||
<span class="font-light capitalize"> Load </span>
|
||||
<span class="font-semibold"> {{ container.stat.cpu.toFixed(2) }}% </span>
|
||||
</div>
|
||||
<div v-if="container.state == 'running'">
|
||||
<span class="font-light capitalize"> MEM </span>
|
||||
<span class="font-semibold"> {{ formatBytes(container.stat.memoryUsage) }} </span>
|
||||
</div>
|
||||
<table class="w-full border-separate border-spacing-x-1">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="text-right font-light capitalize">STATE</th>
|
||||
<td class="font-semibold uppercase">{{ container.state }}</td>
|
||||
</tr>
|
||||
<tr v-if="container.startedAt.getFullYear() > 0">
|
||||
<th class="text-right font-light capitalize">STARTED</th>
|
||||
<td class="font-semibold">
|
||||
<RelativeTime :date="container.startedAt" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="container.state != 'running' && container.finishedAt.getFullYear() > 0">
|
||||
<th class="text-right font-light capitalize">FINISHED</th>
|
||||
<td class="font-semibold">
|
||||
<RelativeTime :date="container.finishedAt" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="container.state == 'running'">
|
||||
<th class="text-right font-light capitalize">Load</th>
|
||||
<td class="font-semibold">{{ container.stat.cpu.toFixed(2) }}%</td>
|
||||
</tr>
|
||||
<tr v-if="container.state == 'running'">
|
||||
<th class="text-right font-light capitalize">MEM</th>
|
||||
<td class="font-semibold">{{ formatBytes(container.stat.memoryUsage) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import ContainerStatCell from "./ContainerStatCell.vue";
|
||||
import { Container, emptyStat, type Stat } from "@/models/Container";
|
||||
import type { Host } from "@/stores/hosts";
|
||||
|
||||
vi.mock("@/stores/config", () => ({
|
||||
__esModule: true,
|
||||
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
|
||||
withBase: (path: string) => path,
|
||||
}));
|
||||
|
||||
function makeStat(partial: Partial<Stat> = {}): Stat {
|
||||
return { ...emptyStat(), ...partial };
|
||||
}
|
||||
|
||||
function makeContainer(lastStat: Partial<Stat>, cpuLimit = 0): Container {
|
||||
return new Container(
|
||||
"id-1",
|
||||
new Date(),
|
||||
new Date(),
|
||||
new Date(),
|
||||
"image",
|
||||
"name",
|
||||
"command",
|
||||
"localhost",
|
||||
{},
|
||||
"running",
|
||||
cpuLimit,
|
||||
0,
|
||||
[makeStat(lastStat)],
|
||||
);
|
||||
}
|
||||
|
||||
function host(nCPU?: number): Host {
|
||||
return { id: "localhost", name: "localhost", nCPU } as unknown as Host;
|
||||
}
|
||||
|
||||
function mountCell(props: { container: Container; type: "cpu" | "mem"; host: Host; mode?: "chart" | "progress" }) {
|
||||
return mount(ContainerStatCell, {
|
||||
props: { mode: "progress", ...props },
|
||||
global: { stubs: { BarChart: true } },
|
||||
});
|
||||
}
|
||||
|
||||
describe("<ContainerStatCell /> cpu", () => {
|
||||
test("normalizes by cpuLimit when set", () => {
|
||||
const wrapper = mountCell({ container: makeContainer({ cpu: 100 }, 2), type: "cpu", host: host(8) });
|
||||
expect(wrapper.find(".tabular-nums").text()).toBe("50%");
|
||||
expect(wrapper.find("progress").attributes("value")).toBe("50");
|
||||
expect(wrapper.find("progress").classes()).toContain("progress-success");
|
||||
});
|
||||
|
||||
test("falls back to host nCPU when no cpuLimit", () => {
|
||||
const wrapper = mountCell({ container: makeContainer({ cpu: 360 }, 0), type: "cpu", host: host(4) });
|
||||
expect(wrapper.find(".tabular-nums").text()).toBe("90%");
|
||||
expect(wrapper.find("progress").classes()).toContain("progress-warning");
|
||||
});
|
||||
|
||||
test("falls back to a single core and clamps at 100%", () => {
|
||||
const wrapper = mountCell({ container: makeContainer({ cpu: 200 }, 0), type: "cpu", host: host(undefined) });
|
||||
expect(wrapper.find(".tabular-nums").text()).toBe("100%");
|
||||
expect(wrapper.find("progress").classes()).toContain("progress-error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ContainerStatCell /> memory", () => {
|
||||
test("shows absolute usage and percentage-based color", () => {
|
||||
const wrapper = mountCell({
|
||||
container: makeContainer({ memory: 40, memoryUsage: 1500 }),
|
||||
type: "mem",
|
||||
host: host(4),
|
||||
});
|
||||
expect(wrapper.find(".tabular-nums").text()).toBe("1.46 KB");
|
||||
expect(wrapper.find("progress").attributes("value")).toBe("40");
|
||||
expect(wrapper.find("progress").classes()).toContain("progress-success");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ContainerStatCell /> color thresholds", () => {
|
||||
test.each([
|
||||
[50, "bg-success"],
|
||||
[70, "bg-secondary"],
|
||||
[90, "bg-warning"],
|
||||
[95, "bg-error"],
|
||||
])("memory %i%% -> %s", (memory, expected) => {
|
||||
const wrapper = mountCell({ container: makeContainer({ memory }), type: "mem", host: host(4) });
|
||||
expect((wrapper.vm as unknown as { barClass: string }).barClass).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ContainerStatCell /> chart data", () => {
|
||||
test("cpu series is normalized by cores", () => {
|
||||
const wrapper = mountCell({ container: makeContainer({ cpu: 100 }, 4), type: "cpu", host: host(8) });
|
||||
const chartData = (wrapper.vm as unknown as { chartData: { percent: number; value: number }[] }).chartData;
|
||||
expect(chartData).toHaveLength(300);
|
||||
expect(chartData.at(-1)).toEqual({ percent: 25, value: 100 });
|
||||
});
|
||||
|
||||
test("memory series uses percent and absolute usage", () => {
|
||||
const wrapper = mountCell({
|
||||
container: makeContainer({ memory: 30, memoryUsage: 2048 }),
|
||||
type: "mem",
|
||||
host: host(4),
|
||||
});
|
||||
const chartData = (wrapper.vm as unknown as { chartData: { percent: number; value: number }[] }).chartData;
|
||||
expect(chartData.at(-1)).toEqual({ percent: 30, value: 2048 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isMobile"
|
||||
class="flex w-fit items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium tabular-nums"
|
||||
:class="type === 'cpu' ? 'bg-primary/10 text-primary' : 'bg-secondary/10 text-secondary'"
|
||||
>
|
||||
<component :is="type === 'cpu' ? PhCpu : PhMemory" class="size-3.5 shrink-0" />
|
||||
<span>{{ displayValue }}</span>
|
||||
</div>
|
||||
<div v-else class="flex flex-row items-center gap-2">
|
||||
<template v-if="mode === 'chart'">
|
||||
<BarChart class="h-4 flex-1" :chart-data="chartData" :bar-class="barClass" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<progress class="progress flex-1" :class="progressClass" :value="averageValue" max="100"></progress>
|
||||
</template>
|
||||
<span class="min-w-12 text-right text-sm tabular-nums">{{ displayValue }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Container } from "@/models/Container";
|
||||
import type { Host } from "@/stores/hosts";
|
||||
import PhCpu from "~icons/ph/cpu";
|
||||
import PhMemory from "~icons/ph/memory";
|
||||
|
||||
const {
|
||||
container,
|
||||
type,
|
||||
host,
|
||||
mode = "chart",
|
||||
} = defineProps<{
|
||||
container: Container;
|
||||
type: "cpu" | "mem";
|
||||
host: Host;
|
||||
mode?: "chart" | "progress";
|
||||
}>();
|
||||
|
||||
function totalCores(): number {
|
||||
if (container.cpuLimit && container.cpuLimit > 0) {
|
||||
return container.cpuLimit;
|
||||
}
|
||||
return host.nCPU ?? 1;
|
||||
}
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (type === "cpu") {
|
||||
const cores = totalCores();
|
||||
return container.statsHistory.map((stat) => {
|
||||
const percent = Math.min(stat.cpu / cores, 100);
|
||||
return { percent, value: stat.cpu };
|
||||
});
|
||||
}
|
||||
return container.statsHistory.map((stat) => {
|
||||
const percent = Math.min(stat.memory, 100);
|
||||
return { percent, value: stat.memoryUsage };
|
||||
});
|
||||
});
|
||||
|
||||
const averageValue = computed(() => {
|
||||
if (type === "cpu") {
|
||||
const cores = totalCores();
|
||||
return Math.min(container.movingAverage.cpu / cores, 100);
|
||||
}
|
||||
return container.movingAverage.memory;
|
||||
});
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (type === "cpu") {
|
||||
return `${averageValue.value.toFixed(0)}%`;
|
||||
}
|
||||
return formatBytes(container.movingAverage.memoryUsage);
|
||||
});
|
||||
|
||||
const barClass = computed(() => {
|
||||
const value = averageValue.value;
|
||||
if (value <= 50) return "bg-success";
|
||||
if (value <= 70) return "bg-secondary";
|
||||
if (value <= 90) return "bg-warning";
|
||||
return "bg-error";
|
||||
});
|
||||
|
||||
const progressClass = computed(() => {
|
||||
const value = averageValue.value;
|
||||
if (value <= 50) return "progress-success";
|
||||
if (value <= 70) return "progress-secondary";
|
||||
if (value <= 90) return "progress-warning";
|
||||
return "progress-error";
|
||||
});
|
||||
</script>
|
||||
@@ -1,112 +1,133 @@
|
||||
!
|
||||
<template>
|
||||
<div class="flex flex-row">
|
||||
<div v-if="Object.keys(hosts).length > 1" class="flex-1">
|
||||
<div role="tablist" class="tabs-boxed tabs block" v-if="Object.keys(hosts).length < 4">
|
||||
<input
|
||||
type="radio"
|
||||
name="host"
|
||||
role="tab"
|
||||
class="tab rounded-sm!"
|
||||
aria-label="Show All"
|
||||
v-model="selectedHost"
|
||||
:value="null"
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="host"
|
||||
role="tab"
|
||||
class="tab rounded-sm!"
|
||||
:aria-label="host.name"
|
||||
v-for="host in hosts"
|
||||
:value="host.id"
|
||||
:key="host.id"
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-row">
|
||||
<div v-if="Object.keys(hosts).length > 1" class="flex-1">
|
||||
<div role="tablist" class="tabs-boxed tabs block" v-if="Object.keys(hosts).length < 4">
|
||||
<input
|
||||
type="radio"
|
||||
name="host"
|
||||
role="tab"
|
||||
class="tab rounded-sm!"
|
||||
aria-label="Show All"
|
||||
v-model="selectedHost"
|
||||
:value="null"
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="host"
|
||||
role="tab"
|
||||
class="tab rounded-sm!"
|
||||
:aria-label="host.name"
|
||||
v-for="host in hosts"
|
||||
:value="host.id"
|
||||
:key="host.id"
|
||||
v-model="selectedHost"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DropdownMenu
|
||||
class="btn-sm"
|
||||
v-model="selectedHost"
|
||||
:options="[
|
||||
{ label: 'Show All', value: null },
|
||||
...Object.values(hosts).map((host) => ({ label: host.name, value: host.id })),
|
||||
]"
|
||||
v-else
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 items-center justify-end gap-2">
|
||||
<div v-show="containers.length > pageSizes[0]">
|
||||
{{ $t("label.per-page") }}
|
||||
|
||||
<DropdownMenu
|
||||
class="dropdown-left btn-xs md:btn-sm"
|
||||
v-model="perPage"
|
||||
:options="pageSizes.map((i) => ({ label: i.toLocaleString(), value: i }))"
|
||||
/>
|
||||
</div>
|
||||
<div class="join max-md:hidden">
|
||||
<button
|
||||
class="btn join-item btn-xs md:btn-sm"
|
||||
:class="statMode === 'chart' ? 'btn-active' : 'btn-ghost'"
|
||||
@click="statMode = 'chart'"
|
||||
>
|
||||
<mdi:chart-bar />
|
||||
</button>
|
||||
<button
|
||||
class="btn join-item btn-xs md:btn-sm"
|
||||
:class="statMode === 'progress' ? 'btn-active' : 'btn-ghost'"
|
||||
@click="statMode = 'progress'"
|
||||
>
|
||||
<mdi:poll class="scale-x-[-1] rotate-90" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-box border-base-content/10 overflow-x-auto border">
|
||||
<table class="table-md md:table-lg table-zebra table">
|
||||
<thead>
|
||||
<tr :data-direction="direction > 0 ? 'asc' : 'desc'">
|
||||
<th
|
||||
v-for="(value, key) in fields"
|
||||
:key="key"
|
||||
@click.prevent="sort(key)"
|
||||
:class="[value.customClass, { 'selected-sort': key === sortField }]"
|
||||
v-show="isVisible(key)"
|
||||
>
|
||||
<a class="inline-flex cursor-pointer gap-2 text-sm uppercase">
|
||||
<span>{{ $t(isMobile && value.mobileLabel ? value.mobileLabel : value.label) }}</span>
|
||||
<span class="h-4" data-icon>
|
||||
<mdi:arrow-up />
|
||||
</span>
|
||||
</a>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-base-300/30">
|
||||
<tr
|
||||
v-for="container in paginated"
|
||||
:key="container.id"
|
||||
v-memo="[container.id, statMode, isMobile]"
|
||||
class="hover:bg-base-100/80!"
|
||||
>
|
||||
<td v-if="isVisible('name')" class="max-w-80 truncate max-md:max-w-32">
|
||||
<router-link :to="{ name: '/container/[id]', params: { id: container.id } }" :title="container.name">
|
||||
{{ container.name }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td v-if="isVisible('host')">{{ container.hostLabel }}</td>
|
||||
<td v-if="isVisible('state')">{{ container.state }}</td>
|
||||
<td v-if="isVisible('created')">
|
||||
<RelativeTime :date="container.created" />
|
||||
</td>
|
||||
<td v-if="isVisible('cpu')">
|
||||
<ContainerStatCell :container="container" type="cpu" :host="hosts[container.host]" :mode="statMode" />
|
||||
</td>
|
||||
<td v-if="isVisible('mem')">
|
||||
<ContainerStatCell :container="container" type="mem" :host="hosts[container.host]" :mode="statMode" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="p-4 text-center">
|
||||
<nav class="join" v-if="isPaginated && totalPages <= 15">
|
||||
<input
|
||||
class="btn btn-square join-item"
|
||||
type="radio"
|
||||
v-model="currentPage"
|
||||
:aria-label="`${i}`"
|
||||
:value="i"
|
||||
v-for="i in totalPages"
|
||||
/>
|
||||
</nav>
|
||||
<DropdownMenu
|
||||
v-else-if="isPaginated"
|
||||
class="btn-sm"
|
||||
v-model="selectedHost"
|
||||
:options="[
|
||||
{ label: 'Show All', value: null },
|
||||
...Object.values(hosts).map((host) => ({ label: host.name, value: host.id })),
|
||||
]"
|
||||
v-else
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 text-right" v-show="containers.length > pageSizes[0]">
|
||||
{{ $t("label.per-page") }}
|
||||
|
||||
<DropdownMenu
|
||||
class="dropdown-left btn-xs md:btn-sm"
|
||||
v-model="perPage"
|
||||
:options="pageSizes.map((i) => ({ label: i.toLocaleString(), value: i }))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table-lg bg-base-200 table">
|
||||
<thead>
|
||||
<tr :data-direction="direction > 0 ? 'asc' : 'desc'">
|
||||
<th
|
||||
v-for="(value, key) in fields"
|
||||
:key="key"
|
||||
@click.prevent="sort(key)"
|
||||
:class="{ 'selected-sort': key === sortField }"
|
||||
v-show="isVisible(key)"
|
||||
>
|
||||
<a class="inline-flex cursor-pointer gap-2 text-sm uppercase">
|
||||
<span>{{ $t(value.label) }}</span>
|
||||
<span class="h-4" data-icon>
|
||||
<mdi:arrow-up />
|
||||
</span>
|
||||
</a>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="container in paginated" :key="container.id">
|
||||
<td v-if="isVisible('name')">
|
||||
<router-link :to="{ name: '/container/[id]', params: { id: container.id } }" :title="container.name">
|
||||
{{ container.name }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td v-if="isVisible('host')">{{ container.hostLabel }}</td>
|
||||
<td v-if="isVisible('state')">{{ container.state }}</td>
|
||||
<td v-if="isVisible('created')">
|
||||
<distance-time :date="container.created" strict :suffix="false"></distance-time>
|
||||
</td>
|
||||
<td v-if="isVisible('cpu')">
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<progress
|
||||
class="progress progress-primary"
|
||||
:value="Math.min(container.movingAverage.cpu, 100)"
|
||||
:max="100"
|
||||
></progress>
|
||||
<span class="text-sm">{{ container.movingAverage.cpu.toFixed(0) }}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="isVisible('mem')">
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<progress class="progress progress-primary" :value="container.movingAverage.memory" max="100"></progress>
|
||||
<span class="text-sm">{{ container.movingAverage.memory.toFixed(0) }}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="p-4 text-center">
|
||||
<nav class="join" v-if="isPaginated">
|
||||
<input
|
||||
class="btn btn-square join-item"
|
||||
type="radio"
|
||||
v-model="currentPage"
|
||||
:aria-label="`${i}`"
|
||||
:value="i"
|
||||
v-for="i in totalPages"
|
||||
:options="Array.from({ length: totalPages }, (_, i) => ({ label: `${i + 1}`, value: i + 1 }))"
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -117,9 +138,19 @@ import { toRefs } from "@vueuse/core";
|
||||
const { hosts } = useHosts();
|
||||
const selectedHost = ref(null);
|
||||
|
||||
const fields = {
|
||||
const fields: Record<
|
||||
string,
|
||||
{
|
||||
label: string;
|
||||
mobileLabel?: string;
|
||||
sortFunc: (a: Container, b: Container) => number;
|
||||
mobileVisible: boolean;
|
||||
customClass?: string;
|
||||
}
|
||||
> = {
|
||||
name: {
|
||||
label: "label.container-name",
|
||||
mobileLabel: "label.name",
|
||||
sortFunc: (a: Container, b: Container) => a.name.localeCompare(b.name) * direction.value,
|
||||
mobileVisible: true,
|
||||
},
|
||||
@@ -127,26 +158,34 @@ const fields = {
|
||||
label: "label.host",
|
||||
sortFunc: (a: Container, b: Container) => a.hostLabel.localeCompare(b.hostLabel) * direction.value,
|
||||
mobileVisible: false,
|
||||
customClass: "w-1",
|
||||
},
|
||||
state: {
|
||||
label: "label.status",
|
||||
sortFunc: (a: Container, b: Container) => a.state.localeCompare(b.state) * direction.value,
|
||||
mobileVisible: false,
|
||||
customClass: "w-1",
|
||||
},
|
||||
created: {
|
||||
label: "label.created",
|
||||
sortFunc: (a: Container, b: Container) => (a.created.getTime() - b.created.getTime()) * direction.value,
|
||||
mobileVisible: true,
|
||||
mobileVisible: false,
|
||||
customClass: "w-1",
|
||||
},
|
||||
cpu: {
|
||||
label: "label.avg-cpu",
|
||||
mobileLabel: "label.cpu",
|
||||
sortFunc: (a: Container, b: Container) => (a.movingAverage.cpu - b.movingAverage.cpu) * direction.value,
|
||||
mobileVisible: false,
|
||||
mobileVisible: true,
|
||||
customClass: "min-w-48 max-md:min-w-0",
|
||||
},
|
||||
mem: {
|
||||
label: "label.avg-mem",
|
||||
sortFunc: (a: Container, b: Container) => (a.movingAverage.memory - b.movingAverage.memory) * direction.value,
|
||||
mobileVisible: false,
|
||||
mobileLabel: "label.mem",
|
||||
sortFunc: (a: Container, b: Container) =>
|
||||
(a.movingAverage.memoryUsage - b.movingAverage.memoryUsage) * direction.value,
|
||||
mobileVisible: true,
|
||||
customClass: "min-w-48 max-md:min-w-0",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -155,14 +194,15 @@ const { containers } = defineProps<{
|
||||
}>();
|
||||
type keys = keyof typeof fields;
|
||||
|
||||
const statMode = useStorage<"chart" | "progress">("DOZZLE_TABLE_STAT_MODE", "chart");
|
||||
const perPage = useStorage("DOZZLE_TABLE_PAGE_SIZE", 15);
|
||||
const pageSizes = [15, 30, 50, 100];
|
||||
|
||||
const storage = useStorage<{ column: keys; direction: 1 | -1 }>("DOZZLE_TABLE_CONTAINERS_SORT", {
|
||||
column: "created",
|
||||
direction: -1,
|
||||
column: "created" as keys,
|
||||
direction: -1 as 1 | -1,
|
||||
});
|
||||
const { column: sortField, direction } = toRefs(storage);
|
||||
const { column: sortField, direction } = toRefs(storage.value);
|
||||
const counter = useInterval(10000);
|
||||
const filteredContainers = computed(() =>
|
||||
containers.filter((c) => selectedHost.value === null || c.host === selectedHost.value),
|
||||
@@ -197,7 +237,7 @@ function isVisible(field: keys) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
@reference "@/main.css";
|
||||
|
||||
[data-icon] {
|
||||
display: none;
|
||||
@@ -208,7 +248,7 @@ function isVisible(field: keys) {
|
||||
}
|
||||
|
||||
th {
|
||||
@apply border-base-100 border-b-2;
|
||||
@apply border-base-200 border-b-2;
|
||||
&.selected-sort {
|
||||
font-weight: bold;
|
||||
@apply border-primary;
|
||||
@@ -219,9 +259,6 @@ th {
|
||||
}
|
||||
|
||||
tbody td {
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
<template>
|
||||
<div class="dropdown dropdown-end dropdown-hover">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm w-10 gap-0.5 px-2">
|
||||
<carbon:circle-solid class="text-red w-2.5" v-if="streamConfig.stderr" />
|
||||
<carbon:circle-solid class="text-blue w-2.5" v-if="streamConfig.stdout" />
|
||||
<div class="dropdown dropdown-end dropdown-hover z-20">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm w-8 gap-0 px-0 md:gap-0.5">
|
||||
<carbon:circle-solid class="text-red w-2 md:w-2.5" v-if="streamConfig.stderr" />
|
||||
<carbon:circle-solid class="text-blue w-2 md:w-2.5" v-if="streamConfig.stdout" />
|
||||
</label>
|
||||
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 z-50 w-52 p-1 shadow-sm">
|
||||
<li>
|
||||
<a @click.prevent="clear()">
|
||||
<octicon:trash-24 /> {{ $t("toolbar.clear") }}
|
||||
<KeyShortcut char="k" :modifiers="['shift', 'meta']" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a :href="downloadUrl" download> <octicon:download-24 /> {{ $t("toolbar.download") }} </a>
|
||||
</li>
|
||||
<li>
|
||||
<a @click.prevent="showSearch = true">
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 shadow-sm"
|
||||
@click="hideMenu"
|
||||
>
|
||||
<li v-if="!historical">
|
||||
<a @click="showSearch = true">
|
||||
<mdi:magnify /> {{ $t("toolbar.search") }}
|
||||
<KeyShortcut char="f" />
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="!historical">
|
||||
<a @click="clear()">
|
||||
<octicon:trash-24 /> {{ $t("toolbar.clear") }}
|
||||
<KeyShortcut char="l" :modifiers="['shift', 'meta']" />
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="hasComplexLogs">
|
||||
<a @click.prevent="showDrawer(LogAnalytics, { container }, 'lg')">
|
||||
<a @click="showDrawer(LogAnalytics, { container }, 'lg')">
|
||||
<ph:file-sql /> SQL Analytics
|
||||
<KeyShortcut char="f" :modifiers="['shift', 'meta']" />
|
||||
</a>
|
||||
@@ -102,8 +103,28 @@
|
||||
</details>
|
||||
</li>
|
||||
|
||||
<li class="line"></li>
|
||||
<li v-if="enableDownload">
|
||||
<a :href="downloadUrl" download>
|
||||
<octicon:download-24 />
|
||||
{{ isFiltered ? $t("toolbar.download-filtered") : $t("toolbar.download") }}
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="isSupported">
|
||||
<a @click="copyLogs()">
|
||||
<mdi:content-copy />
|
||||
{{ isFiltered ? $t("toolbar.copy-filtered-logs") : $t("toolbar.copy-logs") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a @click="copyPermalink()">
|
||||
<material-symbols:link />
|
||||
{{ $t("toolbar.copy-permalink") }}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Container Actions (Enabled via config) -->
|
||||
<template v-if="enableActions">
|
||||
<template v-if="enableActions && !historical">
|
||||
<li class="line"></li>
|
||||
<li>
|
||||
<button
|
||||
@@ -133,19 +154,27 @@
|
||||
{{ $t("toolbar.restart") }}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button @click="update()" :disabled="actionStates.update">
|
||||
<carbon:upgrade />
|
||||
{{ container.isSwarm ? $t("toolbar.update-service") : $t("toolbar.update") }}
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<template v-if="enableShell">
|
||||
<template v-if="enableShell && !historical">
|
||||
<li class="line"></li>
|
||||
<li>
|
||||
<a @click.prevent="showDrawer(Terminal, { container, action: 'attach' }, 'lg')">
|
||||
<ri:terminal-window-fill /> Attach
|
||||
<a @click="showDrawer(Terminal, { container, action: 'attach' }, 'lg')">
|
||||
<ri:terminal-window-fill />
|
||||
{{ $t("toolbar.attach") }}
|
||||
<KeyShortcut char="a" :modifiers="['shift', 'meta']" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a @click.prevent="showDrawer(Terminal, { container, action: 'exec' }, 'lg')">
|
||||
<material-symbols:terminal /> Shell
|
||||
<a @click="showDrawer(Terminal, { container, action: 'exec' }, 'lg')">
|
||||
<material-symbols:terminal />
|
||||
{{ $t("toolbar.shell") }}
|
||||
<KeyShortcut char="e" :modifiers="['shift', 'meta']" />
|
||||
</a>
|
||||
</li>
|
||||
@@ -161,15 +190,116 @@ import LogAnalytics from "../LogViewer/LogAnalytics.vue";
|
||||
import Terminal from "@/components/Terminal.vue";
|
||||
|
||||
const { showSearch } = useSearchFilter();
|
||||
const { enableActions, enableShell } = config;
|
||||
const { enableActions, enableShell, enableDownload } = config;
|
||||
const { streamConfig, hasComplexLogs, levels } = useLoggingContext();
|
||||
const showDrawer = useDrawer();
|
||||
|
||||
const { container } = defineProps<{ container: Container }>();
|
||||
const { container, historical = false } = defineProps<{ container: Container; historical?: boolean }>();
|
||||
const clear = defineEmit();
|
||||
const { actionStates, start, stop, restart } = useContainerActions(toRef(() => container));
|
||||
const { actionStates, start, stop, restart, update } = useContainerActions(toRef(() => container));
|
||||
|
||||
onKeyStroke("f", (e) => {
|
||||
const router = useRouter();
|
||||
const { copy, copied, isSupported } = useClipboard();
|
||||
const { t } = useI18n();
|
||||
const { showToast, removeToast } = useToast();
|
||||
|
||||
async function copyPermalink() {
|
||||
const url = router.resolve({
|
||||
name: "/show",
|
||||
query: { name: container.name, host: container.host },
|
||||
}).href;
|
||||
|
||||
const resolved = new URL(url, window.location.origin);
|
||||
|
||||
if (!isSupported.value) {
|
||||
showToast(
|
||||
{
|
||||
title: t("error.copy-not-supported-hint"),
|
||||
message: resolved.href,
|
||||
type: "info",
|
||||
},
|
||||
{ expire: 10000 },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await copy(resolved.href);
|
||||
|
||||
if (copied.value) {
|
||||
showToast(
|
||||
{
|
||||
title: t("toasts.copied.title"),
|
||||
message: t("toasts.copied.message"),
|
||||
type: "info",
|
||||
},
|
||||
{ expire: 2000 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLogs() {
|
||||
const params = new URLSearchParams();
|
||||
if (streamConfig.value.stdout) params.append("stdout", "1");
|
||||
if (streamConfig.value.stderr) params.append("stderr", "1");
|
||||
params.append("everything", "1");
|
||||
|
||||
const { debouncedSearchFilter } = useSearchFilter();
|
||||
if (debouncedSearchFilter.value) {
|
||||
params.append("filter", debouncedSearchFilter.value);
|
||||
}
|
||||
|
||||
const selectedLevels = Array.from(levels.value);
|
||||
if (selectedLevels.length > 0 && selectedLevels.length < allLevels.length) {
|
||||
selectedLevels.forEach((level) => params.append("levels", level));
|
||||
}
|
||||
|
||||
const url = withBase(`/api/hosts/${container.host}/containers/${container.id}/logs?${params.toString()}`);
|
||||
|
||||
const toastId = "copy-logs";
|
||||
showToast(
|
||||
{
|
||||
id: toastId,
|
||||
title: t("toolbar.copying-logs"),
|
||||
message: "",
|
||||
type: "info",
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
const blobPromise = fetch(url, { headers: { Accept: "text/plain" } })
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error(response.statusText);
|
||||
return response.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
removeToast(toastId);
|
||||
showToast(
|
||||
{
|
||||
title: t("toasts.copied.title"),
|
||||
message: t("toasts.copied.message"),
|
||||
type: "info",
|
||||
},
|
||||
{ expire: 2000 },
|
||||
);
|
||||
return blob;
|
||||
})
|
||||
.catch((err) => {
|
||||
removeToast(toastId);
|
||||
showToast(
|
||||
{
|
||||
title: "Error",
|
||||
message: err.message,
|
||||
type: "error",
|
||||
},
|
||||
{ expire: 5000 },
|
||||
);
|
||||
throw err;
|
||||
});
|
||||
|
||||
await navigator.clipboard.write([new ClipboardItem({ "text/plain": blobPromise })]);
|
||||
}
|
||||
|
||||
onKeyStroke(["f", "F"], (e) => {
|
||||
if (hasComplexLogs.value) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
|
||||
showDrawer(LogAnalytics, { container }, "lg");
|
||||
@@ -178,14 +308,14 @@ onKeyStroke("f", (e) => {
|
||||
}
|
||||
});
|
||||
if (enableShell) {
|
||||
onKeyStroke("a", (e) => {
|
||||
onKeyStroke(["a", "A"], (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
|
||||
showDrawer(Terminal, { container, action: "attach" }, "lg");
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
onKeyStroke("e", (e) => {
|
||||
onKeyStroke(["e", "E"], (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
|
||||
showDrawer(Terminal, { container, action: "exec" }, "lg");
|
||||
e.preventDefault();
|
||||
@@ -193,16 +323,12 @@ if (enableShell) {
|
||||
});
|
||||
}
|
||||
|
||||
const downloadParams = computed(() =>
|
||||
Object.entries(toValue(streamConfig))
|
||||
.filter(([, value]) => value)
|
||||
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {}),
|
||||
);
|
||||
|
||||
const downloadUrl = computed(() =>
|
||||
withBase(
|
||||
`/api/containers/${container.host}~${container.id}/download?${new URLSearchParams(downloadParams.value).toString()}`,
|
||||
),
|
||||
const containerRef = computed(() => [container]);
|
||||
const { downloadUrl, isFiltered } = useDownloadUrl(
|
||||
containerRef,
|
||||
streamConfig,
|
||||
levels,
|
||||
toRef(() => container.name),
|
||||
);
|
||||
|
||||
const disableRestart = computed(() => actionStates.stop || actionStates.start || actionStates.restart);
|
||||
@@ -217,10 +343,20 @@ const toggleAllLevels = computed({
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const hideMenu = (e: MouseEvent) => {
|
||||
if (e.target instanceof HTMLAnchorElement) {
|
||||
setTimeout(() => {
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
@reference "@/main.css";
|
||||
|
||||
li.line {
|
||||
@apply bg-base-content/20 h-px;
|
||||
|
||||
@@ -15,7 +15,7 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
@reference "@/main.css";
|
||||
[health="unhealthy"] {
|
||||
@apply text-red;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<ScrollableView :scrollable="scrollable" v-if="container">
|
||||
<template #header v-if="showTitle">
|
||||
<div class="@container mx-2 flex items-center gap-2 md:ml-4">
|
||||
<div class="@container mx-2 flex items-center gap-1 md:ml-4 md:gap-2">
|
||||
<ContainerTitle :container="container" />
|
||||
<MultiContainerStat
|
||||
class="ml-auto lg:hidden lg:@3xl:flex"
|
||||
@@ -9,7 +9,7 @@
|
||||
v-if="container.state === 'running'"
|
||||
/>
|
||||
|
||||
<ContainerActionsToolbar @clear="viewer?.clear()" class="max-md:hidden" :container="container" />
|
||||
<ContainerActionsToolbar @clear="viewer?.clear()" :container="container" />
|
||||
<a class="btn btn-circle btn-xs" @click="close()" v-if="closable">
|
||||
<mdi:close />
|
||||
</a>
|
||||
|
||||
@@ -1,44 +1,52 @@
|
||||
<template>
|
||||
<div class="@container flex flex-1 items-center gap-1.5 truncate md:gap-2">
|
||||
<div class="@container flex min-w-0 flex-1 items-center gap-1.5 md:gap-2">
|
||||
<label class="swap swap-rotate size-4">
|
||||
<input type="checkbox" v-model="pinned" />
|
||||
<carbon:star-filled class="swap-on text-secondary" />
|
||||
<carbon:star class="swap-off" />
|
||||
</label>
|
||||
<div class="inline-flex items-center text-sm">
|
||||
<div class="breadcrumbs p-0 font-mono">
|
||||
<div class="inline-flex min-w-0 items-center text-sm">
|
||||
<div class="breadcrumbs min-w-0 overflow-x-visible p-0 font-mono">
|
||||
<ul>
|
||||
<li v-if="config.hosts.length > 1" class="font-thin max-md:hidden">
|
||||
{{ container.hostLabel }}
|
||||
</li>
|
||||
<li>
|
||||
<template v-if="otherContainers.length === 0">{{ container.name }}</template>
|
||||
<div class="wrapper" ref="wrapper" v-else>
|
||||
<button popovertarget="popover-container-list" class="btn btn-xs md:btn-sm anchor">
|
||||
{{ container.name }} <carbon:caret-down />
|
||||
</button>
|
||||
|
||||
<ul popover id="popover-container-list" class="dropdown menu rounded-box bg-base-100 tethered shadow-sm">
|
||||
<li v-for="other in otherContainers">
|
||||
<router-link :to="{ name: '/container/[id]', params: { id: other.id } }">
|
||||
<div
|
||||
class="status data-[state=exited]:status-error data-[state=running]:status-success"
|
||||
:data-state="other.state"
|
||||
></div>
|
||||
<div v-if="other.isSwarm">{{ other.swarmId }}</div>
|
||||
<div v-else>{{ other.name }}</div>
|
||||
<div v-if="other.state === 'running'">running</div>
|
||||
<DistanceTime :date="other.created" strict class="text-base-content/70 text-xs" v-else />
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
<li class="min-w-0">
|
||||
<template v-if="otherContainers.length === 0"
|
||||
><span class="block truncate">{{ container.name }}</span></template
|
||||
>
|
||||
<div v-else>
|
||||
<div class="dropdown">
|
||||
<button tabindex="0" role="button" class="btn btn-xs md:btn-sm">
|
||||
{{ container.name }} <carbon:caret-down />
|
||||
</button>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="dropdown-content menu rounded-box bg-base-100 border-base-content/20 border shadow-sm"
|
||||
>
|
||||
<li v-for="other in otherContainers">
|
||||
<router-link :to="{ name: '/container/[id]', params: { id: other.id } }">
|
||||
<div
|
||||
class="status data-[state=exited]:status-error data-[state=running]:status-success data-[state=paused]:status-warning"
|
||||
:data-state="other.state"
|
||||
></div>
|
||||
<div v-if="other.isSwarm">{{ other.swarmId }}</div>
|
||||
<div v-else>{{ other.name }}</div>
|
||||
<div v-if="other.state === 'running'">running</div>
|
||||
<div v-else-if="other.state === 'paused'">paused</div>
|
||||
<RelativeTime :date="other.finishedAt" class="text-base-content/70 text-xs" v-else />
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<ContainerHealth :health="container.health" v-if="container.health" />
|
||||
<Tag class="hidden font-mono max-md:hidden @3xl:block" size="small">
|
||||
<VolumeWarning :container="container" />
|
||||
<Tag class="hidden! font-mono @xl:block!" size="small">
|
||||
{{ container.image.replace(/@sha.*/, "") }}
|
||||
</Tag>
|
||||
</div>
|
||||
@@ -66,34 +74,6 @@ const otherContainers = computed(() =>
|
||||
.filter((c) => c.name === container.name && c.id !== container.id)
|
||||
.sort((a, b) => +b.created - +a.created),
|
||||
);
|
||||
const wrapper = useTemplateRef("wrapper");
|
||||
|
||||
onMounted(async () => {
|
||||
if (!("anchorName" in document.documentElement.style)) {
|
||||
// @ts-ignore
|
||||
const module = await import("@oddbird/css-anchor-positioning/fn");
|
||||
// @ts-ignore
|
||||
await module.default([wrapper.value]);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* https://github.com/oddbird/css-anchor-positioning/issues/282 */
|
||||
.wrapper {
|
||||
anchor-scope: --anchor;
|
||||
}
|
||||
|
||||
.anchor {
|
||||
anchor-name: --anchor;
|
||||
}
|
||||
|
||||
.tethered {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position-anchor: --anchor;
|
||||
position: absolute;
|
||||
top: anchor(bottom);
|
||||
left: anchor(left);
|
||||
}
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<ScrollableView :scrollable="scrollable" v-if="container">
|
||||
<template #header v-if="showTitle">
|
||||
<div class="@container mx-2 flex items-center gap-2 md:ml-4">
|
||||
<ContainerTitle :container="container" />
|
||||
<router-link
|
||||
:to="{ name: '/container/[id]', params: { id: container.id } }"
|
||||
class="btn btn-secondary btn-sm"
|
||||
v-if="container.state === 'running'"
|
||||
>
|
||||
<mdi:lightning-bolt />
|
||||
Live Logs
|
||||
</router-link>
|
||||
|
||||
<ContainerActionsToolbar class="max-md:hidden" :container="container" historical />
|
||||
<a class="btn btn-circle btn-xs" @click="close()" v-if="closable">
|
||||
<mdi:close />
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<ViewerWithSource
|
||||
ref="viewer"
|
||||
:stream-source="useHistoricalContainerLog"
|
||||
:entity="historicalContainer"
|
||||
:visible-keys="visibleKeys"
|
||||
/>
|
||||
</template>
|
||||
</ScrollableView>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ViewerWithSource from "@/components/LogViewer/ViewerWithSource.vue";
|
||||
import { HistoricalContainer } from "@/models/Container";
|
||||
import { ComponentExposed } from "vue-component-type-helpers";
|
||||
|
||||
const {
|
||||
id,
|
||||
showTitle = false,
|
||||
scrollable = false,
|
||||
closable = false,
|
||||
date,
|
||||
} = defineProps<{
|
||||
id: string;
|
||||
showTitle?: boolean;
|
||||
scrollable?: boolean;
|
||||
closable?: boolean;
|
||||
date: Date;
|
||||
}>();
|
||||
|
||||
const close = defineEmit();
|
||||
|
||||
const store = useContainerStore();
|
||||
const container = store.currentContainer(toRef(() => id));
|
||||
const historicalContainer = toRef(() => new HistoricalContainer(container.value, date));
|
||||
const visibleKeys = persistentVisibleKeysForContainer(container);
|
||||
useTemplateRef<ComponentExposed<typeof ViewerWithSource>>("viewer");
|
||||
|
||||
provideLoggingContext(
|
||||
toRef(() => [container.value]),
|
||||
{ showContainerName: false, showHostname: false, historical: true },
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div v-if="worst" class="dropdown dropdown-end" :class="{ 'dropdown-bottom': !openUp }">
|
||||
<button
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium tabular-nums"
|
||||
:class="badgeClass"
|
||||
:title="title"
|
||||
>
|
||||
<PhWarning class="size-3.5" />
|
||||
<span class="max-md:hidden">{{ worst.destination }}</span>
|
||||
<span>{{ formatPct(worst.pct) }}</span>
|
||||
</button>
|
||||
<div
|
||||
tabindex="0"
|
||||
class="dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 mt-1 w-72 border p-2 text-xs shadow-sm"
|
||||
>
|
||||
<div class="text-base-content/60 mb-1.5 px-1 text-[11px] tracking-wide uppercase">{{ t("tooltip.volumes") }}</div>
|
||||
<ul class="space-y-1.5">
|
||||
<li
|
||||
v-for="m in sortedMounts"
|
||||
:key="m.destination"
|
||||
class="flex flex-col gap-1 rounded p-1.5"
|
||||
:class="rowClass(m.pct, m.available)"
|
||||
>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class="truncate font-mono text-[11.5px]" :title="m.destination">{{ m.destination }}</span>
|
||||
<span v-if="m.available" class="tabular-nums">{{ formatPct(m.pct) }}</span>
|
||||
<span v-else class="text-base-content/50">n/a</span>
|
||||
</div>
|
||||
<div v-if="m.available" class="bg-base-content/10 h-1 w-full overflow-hidden rounded">
|
||||
<div class="h-full" :class="barClass(m.pct)" :style="{ width: Math.min(100, m.pct * 100) + '%' }"></div>
|
||||
</div>
|
||||
<div class="text-base-content/60 flex justify-between tabular-nums">
|
||||
<span v-if="m.available">{{ formatBytes(m.used) }} / {{ formatBytes(m.total) }}</span>
|
||||
<span v-else>{{ t("tooltip.volume-unreachable") }}</span>
|
||||
<RelativeTime v-if="m.lastChecked" :date="m.lastChecked" class="text-[10.5px]" />
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Container } from "@/models/Container";
|
||||
import PhWarning from "~icons/ph/warning-fill";
|
||||
|
||||
const WARN = 0.85;
|
||||
const CRITICAL = 0.95;
|
||||
|
||||
const { container } = defineProps<{ container: Container; openUp?: boolean }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
interface DerivedMount {
|
||||
destination: string;
|
||||
total: number;
|
||||
used: number;
|
||||
free: number;
|
||||
available: boolean;
|
||||
pct: number;
|
||||
lastChecked?: Date;
|
||||
}
|
||||
|
||||
const mounts = computed<DerivedMount[]>(() => {
|
||||
const raw = container.mountStats ?? {};
|
||||
return Object.values(raw).map((m) => ({
|
||||
destination: m.destination,
|
||||
total: m.total,
|
||||
used: m.used,
|
||||
free: m.free,
|
||||
available: m.available && m.total > 0,
|
||||
pct: m.available && m.total > 0 ? m.used / m.total : 0,
|
||||
lastChecked: m.lastChecked ? new Date(m.lastChecked) : undefined,
|
||||
}));
|
||||
});
|
||||
|
||||
const sortedMounts = computed(() => [...mounts.value].sort((a, b) => b.pct - a.pct));
|
||||
|
||||
const worst = computed(() => {
|
||||
const candidate = sortedMounts.value.find((m) => m.available && m.pct >= WARN);
|
||||
return candidate ?? null;
|
||||
});
|
||||
|
||||
const badgeClass = computed(() => {
|
||||
if (!worst.value) return "";
|
||||
if (worst.value.pct >= CRITICAL) return "bg-error/15 text-error hover:bg-error/25";
|
||||
return "bg-warning/15 text-warning hover:bg-warning/25";
|
||||
});
|
||||
|
||||
function rowClass(pct: number, available: boolean) {
|
||||
if (!available) return "bg-base-content/[0.04]";
|
||||
if (pct >= CRITICAL) return "bg-error/10";
|
||||
if (pct >= WARN) return "bg-warning/10";
|
||||
return "bg-base-content/[0.04]";
|
||||
}
|
||||
|
||||
function barClass(pct: number) {
|
||||
if (pct >= CRITICAL) return "bg-error";
|
||||
if (pct >= WARN) return "bg-warning";
|
||||
return "bg-success";
|
||||
}
|
||||
|
||||
function formatPct(pct: number) {
|
||||
return `${Math.round(pct * 100)}%`;
|
||||
}
|
||||
|
||||
const title = computed(() => {
|
||||
if (!worst.value) return "";
|
||||
return t("tooltip.volume-full", { destination: worst.value.destination, pct: formatPct(worst.value.pct) });
|
||||
});
|
||||
</script>
|
||||
@@ -109,7 +109,7 @@ describe("<FuzzySearchModal />", () => {
|
||||
await wrapper.find("input").setValue("foo");
|
||||
expect(wrapper.findAll("li").length).toBe(1);
|
||||
expect(wrapper.find("ul [data-name]").html()).toMatchInlineSnapshot(
|
||||
`"<span data-v-dc2e8c61="" data-name=""><mark>foo</mark> bar</span>"`,
|
||||
`"<span data-v-dc2e8c61="" class="text-base-content" data-name=""><mark>foo</mark> bar</span>"`,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,67 +1,159 @@
|
||||
<template>
|
||||
<div class="dropdown dropdown-open w-full shadow-md">
|
||||
<div class="input input-xl input-primary flex w-full items-center">
|
||||
<mdi:magnify class="flex size-8" />
|
||||
<!-- Single bordered card containing the input, results, and footer in one
|
||||
frame to match the design mock. No daisyUI input/dropdown chrome. -->
|
||||
<div class="bg-base-200 border-base-content/15 w-full overflow-hidden rounded-xl border shadow-2xl">
|
||||
<!-- Input row -->
|
||||
<div class="flex items-center gap-3 px-4 py-3.5">
|
||||
<mdi:magnify class="text-base-content/60 size-5 shrink-0" />
|
||||
<input
|
||||
tabindex="0"
|
||||
class="input-ghost flex-1 px-1"
|
||||
class="text-base-content placeholder:text-base-content/40 flex-1 bg-transparent text-base outline-none"
|
||||
ref="input"
|
||||
@keydown.down="selectedIndex = Math.min(selectedIndex + 1, data.length - 1)"
|
||||
@keydown.up="selectedIndex = Math.max(selectedIndex - 1, 0)"
|
||||
@keydown.enter.exact="selected(data[selectedIndex].item)"
|
||||
@keydown.enter.exact="onEnter"
|
||||
@keydown.shift.enter.exact.prevent="runLogSearch"
|
||||
@keydown.alt.enter="addColumn(data[selectedIndex].item)"
|
||||
v-model="query"
|
||||
:placeholder="$t('placeholder.search-containers')"
|
||||
:placeholder="placeholderCopy"
|
||||
/>
|
||||
<form method="dialog">
|
||||
<button class="swap hover:swap-active">
|
||||
<mdi:keyboard-esc class="swap-off" />
|
||||
<mdi:close class="swap-on" />
|
||||
<form method="dialog" class="flex">
|
||||
<button v-if="isMobile" class="text-base-content/50 hover:text-base-content">
|
||||
<mdi:close class="size-5" />
|
||||
</button>
|
||||
<button v-else>
|
||||
<kbd class="kbd kbd-xs">esc</kbd>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-content bg-base-100 relative! mt-2 max-h-[calc(100dvh-20rem)] w-full overflow-y-scroll rounded-md border-y-8 border-transparent px-2"
|
||||
tabindex="0"
|
||||
v-if="results.length"
|
||||
>
|
||||
<ul class="menu w-auto">
|
||||
<li v-for="(result, index) in data" ref="listItems">
|
||||
<a
|
||||
class="grid auto-cols-max grid-cols-[min-content_auto] gap-2 py-4"
|
||||
@click.prevent="selected(result.item)"
|
||||
:class="index === selectedIndex ? 'menu-focus' : ''"
|
||||
>
|
||||
<div :class="{ 'text-primary': result.item.state === 'running' }">
|
||||
<template v-if="result.item.type === 'container'">
|
||||
<octicon:container-24 />
|
||||
</template>
|
||||
<template v-else-if="result.item.type === 'service'">
|
||||
<ph:stack-simple />
|
||||
</template>
|
||||
<template v-else-if="result.item.type === 'stack'">
|
||||
<ph:stack />
|
||||
</template>
|
||||
</div>
|
||||
<div class="truncate">
|
||||
<template v-if="config.hosts.length > 1 && result.item.host">
|
||||
<span class="font-light">{{ result.item.host }}</span> /
|
||||
</template>
|
||||
<span data-name v-html="matchedName(result)"></span>
|
||||
</div>
|
||||
|
||||
<DistanceTime :date="result.item.created" class="text-xs font-light" />
|
||||
<span
|
||||
@click.stop.prevent="addColumn(result.item)"
|
||||
:title="$t('tooltip.pin-column')"
|
||||
class="hover:text-secondary"
|
||||
<!-- Body: results + log search CTA. Only renders when there is something
|
||||
to show — keeps the empty modal compact. -->
|
||||
<div v-if="results.length || logSearchVisible" class="border-base-content/10 border-t">
|
||||
<!-- Containers section -->
|
||||
<template v-if="results.length">
|
||||
<div class="text-base-content/40 px-4 pt-3 pb-1.5 text-xs font-semibold tracking-wider uppercase">
|
||||
{{ $t("cloud-search.containers-section") }} · {{ data.length }}
|
||||
</div>
|
||||
<ul class="pb-1">
|
||||
<li v-for="(result, index) in data" ref="listItems">
|
||||
<a
|
||||
class="hover:bg-base-content/5 flex cursor-pointer items-center gap-3 px-4 py-2"
|
||||
:class="{ 'bg-base-content/10': index === selectedIndex }"
|
||||
@click.prevent="selected(result.item)"
|
||||
>
|
||||
<ic:sharp-keyboard-return v-if="index === selectedIndex" />
|
||||
<cil:columns v-else-if="result.item.type === 'container'" />
|
||||
<div :class="result.item.state === 'running' ? 'text-primary' : 'text-base-content/50'">
|
||||
<template v-if="result.item.type === 'container'">
|
||||
<octicon:container-24 class="size-4" />
|
||||
</template>
|
||||
<template v-else-if="result.item.type === 'service'">
|
||||
<ph:stack-simple class="size-4" />
|
||||
</template>
|
||||
<template v-else-if="result.item.type === 'stack'">
|
||||
<ph:stack class="size-4" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 truncate text-sm">
|
||||
<template v-if="config.hosts.length > 1 && result.item.host">
|
||||
<span class="text-base-content/50 font-light">{{ result.item.host }}</span>
|
||||
<span class="text-base-content/30"> / </span>
|
||||
</template>
|
||||
<span class="text-base-content" data-name v-html="matchedName(result)"></span>
|
||||
</div>
|
||||
<RelativeTime :date="result.item.created" class="text-base-content/40 text-xs" />
|
||||
<span
|
||||
@click.stop.prevent="addColumn(result.item)"
|
||||
:title="$t('tooltip.pin-column')"
|
||||
class="text-base-content/40 hover:text-secondary"
|
||||
>
|
||||
<ic:sharp-keyboard-return v-if="index === selectedIndex" class="size-4" />
|
||||
<cil:columns v-else-if="result.item.type === 'container'" class="size-4" />
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Log search CTA -->
|
||||
<div
|
||||
v-if="logSearchVisible"
|
||||
class="border-base-content/10 border-t"
|
||||
:class="{ 'cursor-pointer': cloudSearch.available.value, 'opacity-70': !cloudSearch.available.value }"
|
||||
@click="cloudSearch.available.value && runLogSearch()"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-3 px-4 py-3"
|
||||
:class="cloudSearch.available.value ? 'bg-primary/[0.07] hover:bg-primary/10' : ''"
|
||||
>
|
||||
<mdi:cloud-search-outline
|
||||
class="size-5 shrink-0"
|
||||
:class="cloudSearch.available.value ? 'text-primary' : 'text-base-content/40'"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span
|
||||
class="truncate text-sm font-semibold"
|
||||
:class="cloudSearch.available.value ? 'text-primary' : 'text-base-content/60'"
|
||||
>
|
||||
<i18n-t keypath="cloud-search.search-logs-for">
|
||||
<template #query>
|
||||
<span class="font-mono">{{ query }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<span class="text-base-content/50 mt-0.5 flex items-center gap-1 text-xs">
|
||||
<template v-if="cloudSearch.available.value">
|
||||
<mdi:flash class="text-primary size-3" />
|
||||
{{ $t("cloud-search.across-containers") }}
|
||||
</template>
|
||||
<template v-else-if="cloudConfig?.linked && !cloudConfig.streamLogs">
|
||||
<mdi:cloud-off-outline class="size-3" />
|
||||
<RouterLink to="/settings/cloud" class="link link-hover" @click.stop>
|
||||
{{ $t("cloud-search.enable-streaming-to-search") }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
<template v-else>
|
||||
<mdi:cloud-off-outline class="size-3" />
|
||||
<RouterLink to="/settings/cloud" class="link link-hover" @click.stop>
|
||||
{{ $t("cloud-search.connect-to-enable") }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<kbd class="kbd kbd-xs">⇧</kbd>
|
||||
<kbd class="kbd kbd-xs">↵</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer: kbd hints + cloud status. Always present while the modal is
|
||||
open so users know log search is available before they type. -->
|
||||
<div
|
||||
class="bg-base-300/40 border-base-content/10 text-base-content/50 flex items-center gap-4 border-t px-4 py-2 text-xs"
|
||||
>
|
||||
<span v-if="results.length" class="flex items-center gap-1.5">
|
||||
<kbd class="kbd kbd-xs">↵</kbd> {{ $t("cloud-search.open-container") }}
|
||||
</span>
|
||||
<span v-if="cloudSearch.available.value && logSearchVisible" class="flex items-center gap-1">
|
||||
<kbd class="kbd kbd-xs">⇧</kbd><kbd class="kbd kbd-xs">↵</kbd>
|
||||
<span class="ml-0.5">{{ $t("cloud-search.search-logs-shortcut") }}</span>
|
||||
</span>
|
||||
|
||||
<span v-if="cloudSearch.available.value" class="ml-auto flex items-center gap-1.5">
|
||||
<mdi:cloud-check-outline class="text-primary size-3.5" />
|
||||
{{ $t("cloud-search.cloud-connected") }}
|
||||
</span>
|
||||
<span v-else-if="cloudConfig?.linked" class="ml-auto flex items-center gap-1.5">
|
||||
<mdi:cloud-off-outline class="size-3.5" />
|
||||
<RouterLink to="/settings/cloud" class="link link-hover" @click.stop>
|
||||
{{ $t("cloud-search.enable-streaming-to-search") }}
|
||||
</RouterLink>
|
||||
</span>
|
||||
<span v-else class="ml-auto flex items-center gap-1.5">
|
||||
<mdi:cloud-off-outline class="size-3.5" />
|
||||
<RouterLink to="/settings/cloud" class="link link-hover" @click.stop>
|
||||
{{ $t("cloud-search.connect-to-enable") }}
|
||||
</RouterLink>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -70,15 +162,23 @@
|
||||
import { ContainerState } from "@/types/Container";
|
||||
import { useFuse } from "@vueuse/integrations/useFuse";
|
||||
import { type FuseResult } from "fuse.js";
|
||||
import { useCloudConfig } from "@/composable/cloudConfig";
|
||||
import { useCloudLogSearch } from "@/composable/cloudLogSearch";
|
||||
|
||||
const close = defineEmit();
|
||||
|
||||
const query = ref("");
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
// Prefill with the current /cloud/search query so the user can refine
|
||||
// without retyping. Empty everywhere else. Null-safe for unit tests
|
||||
// that mount the component without a router context.
|
||||
const initialQuery = route?.path === "/cloud/search" && typeof route.query?.q === "string" ? route.query.q : "";
|
||||
const query = ref(initialQuery);
|
||||
const input = ref<HTMLInputElement>();
|
||||
const listItems = ref<HTMLInputElement[]>();
|
||||
const selectedIndex = ref(0);
|
||||
|
||||
const router = useRouter();
|
||||
const containerStore = useContainerStore();
|
||||
const pinnedStore = usePinnedLogsStore();
|
||||
const { visibleContainers } = storeToRefs(containerStore);
|
||||
@@ -86,12 +186,27 @@ const { visibleContainers } = storeToRefs(containerStore);
|
||||
const swarmStore = useSwarmStore();
|
||||
const { stacks, services } = storeToRefs(swarmStore);
|
||||
|
||||
const { cloudConfig } = useCloudConfig();
|
||||
// Mounted only so the "Search logs for X" CTA can read `available`. We
|
||||
// don't render the hits inside the popup. The composable's debounced
|
||||
// watch short-circuits on empty query, so opening the modal alone does
|
||||
// not fire a request.
|
||||
const cloudSearch = useCloudLogSearch(query);
|
||||
|
||||
const logSearchVisible = computed(() => query.value.trim().length > 0);
|
||||
|
||||
const { t } = useI18n();
|
||||
const placeholderCopy = computed(() =>
|
||||
cloudSearch.available.value ? t("cloud-search.modal-placeholder-cloud") : t("cloud-search.modal-placeholder-plain"),
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
const dialog = input.value?.closest("dialog");
|
||||
if (dialog) {
|
||||
const animations = dialog.getAnimations();
|
||||
await Promise.all(animations.map((animation) => animation.finished));
|
||||
input.value?.focus();
|
||||
if (initialQuery) input.value?.select();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -141,7 +256,7 @@ const list = computed(() => {
|
||||
return items;
|
||||
});
|
||||
|
||||
const { results } = useFuse(query, list, {
|
||||
const { results: fuseResults } = useFuse(query, list, {
|
||||
fuseOptions: {
|
||||
keys: ["name", "host"],
|
||||
includeScore: true,
|
||||
@@ -151,6 +266,8 @@ const { results } = useFuse(query, list, {
|
||||
},
|
||||
});
|
||||
|
||||
const results = computed(() => (query.value ? fuseResults.value : []));
|
||||
|
||||
const data = computed(() => {
|
||||
return [...results.value].sort((a: FuseResult<Item>, b: FuseResult<Item>) => {
|
||||
if (a.score === b.score) {
|
||||
@@ -188,6 +305,25 @@ function selected(item: Item) {
|
||||
close();
|
||||
}
|
||||
|
||||
function onEnter() {
|
||||
// Plain Enter prefers a container match if one is selected. With no
|
||||
// container matches (cloud-only query like "OOM"), fall back to log search
|
||||
// so the user isn't stuck on a popup that does nothing.
|
||||
if (data.value.length > 0) {
|
||||
selected(data.value[selectedIndex.value].item);
|
||||
} else if (cloudSearch.available.value && logSearchVisible.value) {
|
||||
runLogSearch();
|
||||
}
|
||||
}
|
||||
|
||||
function runLogSearch() {
|
||||
if (!cloudSearch.available.value) return;
|
||||
const q = query.value.trim();
|
||||
if (!q) return;
|
||||
router.push({ path: "/cloud/search", query: { q } });
|
||||
close();
|
||||
}
|
||||
|
||||
function addColumn(container: { id: string }) {
|
||||
pinnedStore.pinContainer(container);
|
||||
close();
|
||||
@@ -214,7 +350,7 @@ function matchedName({ item, matches = [] }: FuseResult<Item>) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
@reference "@/main.css";
|
||||
:deep(mark) {
|
||||
@apply bg-transparent text-inherit underline underline-offset-2;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
<ScrollableView :scrollable="scrollable" v-if="group.containers.length && ready">
|
||||
<template #header>
|
||||
<div class="mx-2 flex items-center gap-2 md:ml-4">
|
||||
<div class="@container flex flex-1 items-center gap-1.5 truncate md:gap-2">
|
||||
<div class="inline-flex font-mono text-sm">
|
||||
<div class="font-semibold">{{ $t("label.container", group.containers.length) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ContainerDropdown :containers="group.containers">
|
||||
{{ $t("label.container", group.containers.length) }}
|
||||
</ContainerDropdown>
|
||||
<MultiContainerStat class="ml-auto" :containers="group.containers" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" :name="group.name" @clear="viewer?.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="card bg-base-100">
|
||||
<div class="card-body flex gap-3 max-md:p-4">
|
||||
<div class="flex flex-row items-center gap-3 overflow-hidden">
|
||||
<div class="flex min-w-0 items-center gap-2 text-lg font-semibold tracking-tight md:text-xl">
|
||||
<HostIcon :type="host.type" class="text-base-content/80 flex-none" />
|
||||
<div class="truncate">{{ host.name }}</div>
|
||||
|
||||
<span class="badge badge-error badge-xs gap-1 p-2 font-normal" v-if="!host.available">
|
||||
<carbon:warning />
|
||||
offline
|
||||
</span>
|
||||
<span
|
||||
class="badge badge-success badge-xs gap-1 p-2 font-normal"
|
||||
:class="{ 'badge-warning': config.version != host.agentVersion }"
|
||||
v-else-if="host.type == 'agent'"
|
||||
title="Dozzle Agent"
|
||||
>
|
||||
{{ host.agentVersion }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
class="text-base-content/60 ml-auto flex shrink-0 flex-row flex-wrap items-center gap-x-3 text-xs tabular-nums md:text-sm"
|
||||
>
|
||||
<li class="flex items-center gap-1.5">
|
||||
<octicon:container-24 class="size-3.5" />
|
||||
{{ $t("label.container", hostContainers.length) }}
|
||||
</li>
|
||||
<li class="flex items-center gap-1.5" :title="runtimeLabel">
|
||||
<simple-icons:podman v-if="host.runtime === 'podman'" class="size-3.5" />
|
||||
<mdi:docker v-else class="size-3.5" />
|
||||
{{ host.dockerVersion }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 md:gap-3" v-if="stats">
|
||||
<MetricCard
|
||||
:icon="PhCpu"
|
||||
:value="stats.weighted.movingAverage.totalCPU"
|
||||
:chartData="cpuHistory"
|
||||
container-class="bg-primary/10"
|
||||
text-class="text-primary"
|
||||
bar-class="bg-primary"
|
||||
:formatValue="(value) => `${value.toFixed(1)}%`"
|
||||
:label="`${host.nCPU} CPU`"
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
:icon="PhMemory"
|
||||
:value="stats.weighted.movingAverage.totalMemUsage"
|
||||
:chartData="memHistory"
|
||||
container-class="bg-secondary/10"
|
||||
text-class="text-secondary"
|
||||
bar-class="bg-secondary"
|
||||
:formatValue="(value) => formatBytes(value, { decimals: 1 })"
|
||||
:label="formatBytes(host.memTotal, { decimals: 1 })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Host } from "@/stores/hosts";
|
||||
import { Container } from "@/models/Container";
|
||||
import PhCpu from "~icons/ph/cpu";
|
||||
import PhMemory from "~icons/ph/memory";
|
||||
|
||||
const props = defineProps<{
|
||||
host: Host;
|
||||
}>();
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
const { containers } = storeToRefs(containerStore) as unknown as {
|
||||
containers: Ref<Container[]>;
|
||||
};
|
||||
|
||||
const hostContainers = computed(() =>
|
||||
containers.value.filter((container) => container.host === props.host.id && container.state === "running"),
|
||||
);
|
||||
|
||||
const runtimeLabel = computed(() => (props.host.runtime === "podman" ? "Podman" : "Docker"));
|
||||
|
||||
function toContainerCores(container: Container): number {
|
||||
if (container.cpuLimit && container.cpuLimit > 0) {
|
||||
return container.cpuLimit;
|
||||
}
|
||||
return props.host.nCPU ?? 1;
|
||||
}
|
||||
|
||||
type TotalStat = {
|
||||
totalCPU: number;
|
||||
totalMem: number;
|
||||
totalMemUsage: number;
|
||||
};
|
||||
|
||||
const totalStat = ref<TotalStat>({ totalCPU: 0, totalMem: 0, totalMemUsage: 0 });
|
||||
const { history, reset } = useSimpleRefHistory(totalStat, { capacity: 300 });
|
||||
|
||||
const cpuHistory = computed(() =>
|
||||
history.value.map((stat) => ({
|
||||
percent: stat.totalCPU,
|
||||
value: stat.totalCPU,
|
||||
})),
|
||||
);
|
||||
const memHistory = computed(() =>
|
||||
history.value.map((stat) => ({
|
||||
percent: stat.totalMem,
|
||||
value: stat.totalMemUsage,
|
||||
})),
|
||||
);
|
||||
|
||||
const stats = reactive({ mostRecent: totalStat, weighted: useExponentialMovingAverage(totalStat) });
|
||||
|
||||
watch(
|
||||
() => hostContainers.value,
|
||||
() => {
|
||||
const initial: TotalStat[] = [];
|
||||
|
||||
for (let i = 1; i <= 300; i++) {
|
||||
const stat = hostContainers.value.reduce(
|
||||
(acc, container) => {
|
||||
const item = container.statsHistory.at(-i);
|
||||
if (!item) {
|
||||
return acc;
|
||||
}
|
||||
const cores = toContainerCores(container);
|
||||
return {
|
||||
totalCPU: acc.totalCPU + item.cpu / cores,
|
||||
totalMem: acc.totalMem + item.memory,
|
||||
totalMemUsage: acc.totalMemUsage + item.memoryUsage,
|
||||
};
|
||||
},
|
||||
{ totalCPU: 0, totalMem: 0, totalMemUsage: 0 },
|
||||
);
|
||||
initial.push(stat);
|
||||
}
|
||||
reset({ initial: initial.reverse() });
|
||||
stats.weighted.reset(initial.at(-1)!);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
useIntervalFn(() => {
|
||||
totalStat.value = hostContainers.value.reduce(
|
||||
(acc, container) => {
|
||||
const cores = toContainerCores(container);
|
||||
return {
|
||||
totalCPU: acc.totalCPU + container.stat.cpu / cores,
|
||||
totalMem: acc.totalMem + container.stat.memory,
|
||||
totalMemUsage: acc.totalMemUsage + container.stat.memoryUsage,
|
||||
};
|
||||
},
|
||||
{ totalCPU: 0, totalMem: 0, totalMemUsage: 0 },
|
||||
);
|
||||
}, 1000);
|
||||
</script>
|
||||
@@ -1,120 +1,11 @@
|
||||
<template>
|
||||
<ul class="grid gap-4 md:grid-cols-[repeat(auto-fill,minmax(480px,1fr))]">
|
||||
<li v-for="host in hosts" class="card bg-base-100">
|
||||
<div class="card-body grid auto-cols-auto grid-flow-col justify-between gap-4">
|
||||
<div class="flex flex-col gap-2 overflow-hidden">
|
||||
<div class="flex items-center gap-1 truncate text-xl font-semibold">
|
||||
<HostIcon :type="host.type" class="flex-none" />
|
||||
<div class="truncate">
|
||||
{{ host.name }}
|
||||
</div>
|
||||
|
||||
<span class="badge badge-error badge-xs gap-2 p-2" v-if="!host.available">
|
||||
<carbon:warning />
|
||||
offline
|
||||
</span>
|
||||
<span
|
||||
class="badge badge-success badge-xs gap-2 p-2"
|
||||
:class="{ 'badge-warning': config.version != host.agentVersion }"
|
||||
v-else-if="host.type == 'agent'"
|
||||
title="Dozzle Agent"
|
||||
>
|
||||
{{ host.agentVersion }}
|
||||
</span>
|
||||
</div>
|
||||
<ul class="flex flex-row gap-x-2 text-sm md:gap-3">
|
||||
<li class="flex items-center gap-1"><ph:cpu /> {{ host.nCPU }} <span class="max-md:hidden">CPUs</span></li>
|
||||
<li class="flex items-center gap-1">
|
||||
<ph:memory /> {{ formatBytes(host.memTotal) }}
|
||||
<span class="max-md:hidden">total</span>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="flex flex-row flex-wrap gap-x-2 text-sm md:gap-3">
|
||||
<li class="flex items-center gap-1">
|
||||
<octicon:container-24 class="inline-block" />
|
||||
{{ $t("label.container", hostContainers[host.id]?.length ?? 0) }}
|
||||
</li>
|
||||
<li class="flex items-center gap-1"><mdi:docker class="inline-block" /> {{ host.dockerVersion }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-4 md:gap-8" v-if="weightedStats[host.id]">
|
||||
<div
|
||||
class="radial-progress text-primary text-[0.85rem] transition-none [--size:4rem] [--thickness:0.25em] md:text-[0.9rem] md:[--size:5rem]"
|
||||
:style="`--value: ${Math.floor((weightedStats[host.id].weighted.totalCPU / (host.nCPU * 100)) * 100)};`"
|
||||
role="progressbar"
|
||||
>
|
||||
{{ weightedStats[host.id].weighted.totalCPU.toFixed(0) }}%
|
||||
</div>
|
||||
<div
|
||||
class="radial-progress text-primary text-[0.85rem] transition-none [--size:4rem] [--thickness:0.25em] md:text-[0.9rem] md:[--size:5rem]"
|
||||
:style="`--value: ${Math.floor((weightedStats[host.id].weighted.totalMem / host.memTotal) * 100)};`"
|
||||
role="progressbar"
|
||||
>
|
||||
{{ formatBytes(weightedStats[host.id].weighted.totalMem, { decimals: 1, short: true }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<li v-for="host in hosts" :key="host.id">
|
||||
<HostCard :host="host" />
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
const { containers } = storeToRefs(containerStore) as unknown as {
|
||||
containers: Ref<Container[]>;
|
||||
};
|
||||
|
||||
const runningContainers = computed(() => containers.value.filter((container) => container.state === "running"));
|
||||
|
||||
const { hosts } = useHosts();
|
||||
const hostContainers = computed(() => {
|
||||
const results: Record<string, Container[]> = {};
|
||||
for (const container of runningContainers.value) {
|
||||
if (!results[container.host]) {
|
||||
results[container.host] = [];
|
||||
}
|
||||
results[container.host].push(container);
|
||||
}
|
||||
return results;
|
||||
});
|
||||
|
||||
type TotalStat = {
|
||||
totalCPU: number;
|
||||
totalMem: number;
|
||||
};
|
||||
const weightedStats: Record<string, { mostRecent: TotalStat; weighted: TotalStat }> = {};
|
||||
const initWeightedStats = () => {
|
||||
for (const [host, containers] of Object.entries(hostContainers.value)) {
|
||||
const mostRecent = ref<TotalStat>({ totalCPU: 0, totalMem: 0 });
|
||||
for (const container of containers) {
|
||||
mostRecent.value.totalCPU += container.stat.cpu;
|
||||
mostRecent.value.totalMem += container.stat.memoryUsage;
|
||||
}
|
||||
weightedStats[host] = reactive({ mostRecent, weighted: useExponentialMovingAverage(mostRecent) });
|
||||
}
|
||||
};
|
||||
|
||||
watchOnce(hostContainers, initWeightedStats);
|
||||
initWeightedStats();
|
||||
|
||||
useIntervalFn(
|
||||
() => {
|
||||
for (const [host, containers] of Object.entries(hostContainers.value)) {
|
||||
const stat = { totalCPU: 0, totalMem: 0 };
|
||||
for (const container of containers) {
|
||||
stat.totalCPU += container.stat.cpu;
|
||||
stat.totalMem += container.stat.memoryUsage;
|
||||
}
|
||||
if (weightedStats[host]) {
|
||||
// TODO fix this init
|
||||
weightedStats[host].mostRecent = stat;
|
||||
}
|
||||
}
|
||||
},
|
||||
1000,
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
+213
-31
@@ -13,7 +13,7 @@
|
||||
}"
|
||||
class="btn btn-outline btn-primary btn-xs"
|
||||
active-class="btn-active"
|
||||
:title="$t('tooltip.merge-hosts')"
|
||||
:title="$t('tooltip.merge-all')"
|
||||
>
|
||||
<ph:arrows-merge />
|
||||
{{ hosts[sessionHost].name }}
|
||||
@@ -24,15 +24,22 @@
|
||||
<div class="flex-none">
|
||||
<div class="dropdown dropdown-end dropdown-hover">
|
||||
<label tabindex="0" class="btn btn-square btn-ghost btn-sm">
|
||||
<ph:dots-three-vertical-bold />
|
||||
<ion:ellipsis-vertical />
|
||||
</label>
|
||||
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 z-50 w-52 p-1 shadow-sm">
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 shadow-sm"
|
||||
>
|
||||
<li>
|
||||
<a class="text-sm capitalize" @click="toggleShowAllContainers()">
|
||||
<mdi:check class="w-4" v-if="showAllContainers" />
|
||||
<div v-else class="w-4"></div>
|
||||
|
||||
Show all containers
|
||||
{{ $t("label.show-all-containers") }}
|
||||
</a>
|
||||
<a v-if="hasCollapsible" class="text-sm capitalize" @click="collapseAll()">
|
||||
<material-symbols-light:expand-all class="w-4" v-if="allCollapsed" />
|
||||
<material-symbols-light:collapse-all class="w-4" v-else />
|
||||
{{ allCollapsed ? $t("label.expand-all") : $t("label.collapse-all") }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -43,13 +50,71 @@
|
||||
<SlideTransition :slide-right="!!sessionHost">
|
||||
<template #left>
|
||||
<ul class="menu p-0">
|
||||
<li v-for="host in hosts" :key="host.id">
|
||||
<a @click.prevent="setHost(host.id)" :class="{ 'text-base-content/50 pointer-events-none': !host.available }">
|
||||
<HostIcon :type="host.type" />
|
||||
{{ host.name }}
|
||||
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
|
||||
</a>
|
||||
</li>
|
||||
<template v-if="!hasHostGroups">
|
||||
<li v-for="host in hosts" :key="host.id">
|
||||
<a
|
||||
@click.prevent="setHost(host.id)"
|
||||
class="auto-cols-[max-content_minmax(0,1fr)_max-content]"
|
||||
:class="{ 'text-base-content/50 pointer-events-none': !host.available }"
|
||||
>
|
||||
<HostIcon :type="host.type" />
|
||||
<span class="truncate">{{ host.name }}</span>
|
||||
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
<template v-else v-for="[groupName, groupHosts] in groupedHostEntries" :key="groupName || '__ungrouped__'">
|
||||
<li v-if="groupName" class="host-group">
|
||||
<details :open="!collapsedHostGroups.has(groupName)" @toggle="updateCollapsedHostGroups($event, groupName)">
|
||||
<summary class="host-group-summary">
|
||||
<span class="truncate">{{ groupName }}</span>
|
||||
<router-link
|
||||
:to="{ name: '/host-group/[name]', params: { name: groupName } }"
|
||||
class="btn btn-square btn-outline btn-primary btn-xs"
|
||||
:title="$t('tooltip.merge-all')"
|
||||
@click.stop
|
||||
>
|
||||
<ph:arrows-merge />
|
||||
</router-link>
|
||||
<button
|
||||
v-if="!collapsedHostGroups.has(groupName)"
|
||||
type="button"
|
||||
class="btn btn-square btn-outline btn-primary btn-xs"
|
||||
:title="$t('label.collapse-group')"
|
||||
@click.stop.prevent="collapseHostGroup(groupName)"
|
||||
>
|
||||
<material-symbols-light:collapse-all />
|
||||
</button>
|
||||
</summary>
|
||||
<ul>
|
||||
<li v-for="host in groupHosts" :key="host.id">
|
||||
<a
|
||||
@click.prevent="setHost(host.id)"
|
||||
class="auto-cols-[max-content_minmax(0,1fr)_max-content]"
|
||||
:class="{ 'text-base-content/50 pointer-events-none': !host.available }"
|
||||
>
|
||||
<HostIcon :type="host.type" />
|
||||
<span class="truncate">{{ host.name }}</span>
|
||||
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
<template v-else>
|
||||
<li v-for="host in groupHosts" :key="host.id">
|
||||
<a
|
||||
@click.prevent="setHost(host.id)"
|
||||
class="auto-cols-[max-content_minmax(0,1fr)_max-content]"
|
||||
:class="{ 'text-base-content/50 pointer-events-none': !host.available }"
|
||||
>
|
||||
<HostIcon :type="host.type" />
|
||||
<span class="truncate">{{ host.name }}</span>
|
||||
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
<template #right>
|
||||
@@ -58,7 +123,7 @@
|
||||
<details :open="!collapsedGroups.has(label)" @toggle="updateCollapsedGroups($event, label)">
|
||||
<summary class="text-base-content/80 font-light">
|
||||
<component :is="icon" />
|
||||
{{ label.startsWith("label.") ? $t(label) : label }}
|
||||
{{ label.startsWith("label.") ? $t(label) : label }} ({{ containers.length }})
|
||||
|
||||
<router-link
|
||||
:to="{
|
||||
@@ -67,23 +132,30 @@
|
||||
}"
|
||||
class="btn btn-square btn-outline btn-primary btn-xs"
|
||||
active-class="btn-active"
|
||||
:title="$t('tooltip.merge-containers')"
|
||||
:title="$t('tooltip.merge-all')"
|
||||
>
|
||||
<ph:arrows-merge />
|
||||
</router-link>
|
||||
</summary>
|
||||
<ul>
|
||||
<li v-for="item in containers" :class="item.state" :key="item.id">
|
||||
<li
|
||||
v-for="item in containers"
|
||||
:class="[item.state, { 'highlight-new': item.isNew }]"
|
||||
:key="item.id"
|
||||
@animationend="item.isNew = false"
|
||||
>
|
||||
<Popup>
|
||||
<router-link
|
||||
:to="{ name: '/container/[id]', params: { id: item.id } }"
|
||||
active-class="menu-active"
|
||||
@click.alt.stop.prevent="pinnedStore.pinContainer(item)"
|
||||
:title="item.name"
|
||||
class="group auto-cols-[content_max_auto_max-content_max-content]"
|
||||
class="group auto-cols-[max-content_minmax(0,1fr)_max-content_max-content]"
|
||||
>
|
||||
<svg-spinners:ring-resize v-if="item.isNew" class="text-secondary w-2" />
|
||||
<div
|
||||
class="status data-[state=exited]:status-error data-[state=running]:status-success"
|
||||
v-else
|
||||
class="status data-[state=exited]:status-error data-[state=running]:status-success data-[state=paused]:status-warning"
|
||||
:data-state="item.state"
|
||||
></div>
|
||||
<div class="truncate">
|
||||
@@ -115,13 +187,10 @@
|
||||
<script lang="ts" setup>
|
||||
import { Container } from "@/models/Container";
|
||||
import { sessionHost } from "@/composable/storage";
|
||||
import { showAllContainers } from "@/stores/settings";
|
||||
import { showAllContainers, groupContainers } from "@/stores/settings";
|
||||
|
||||
// @ts-ignore
|
||||
import Pin from "~icons/ph/map-pin-simple";
|
||||
// @ts-ignore
|
||||
import Stack from "~icons/ph/stack";
|
||||
// @ts-ignore
|
||||
import Containers from "~icons/octicon/container-24";
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
@@ -133,7 +202,30 @@ const { hosts } = useHosts();
|
||||
|
||||
const setHost = (host: string | null) => (sessionHost.value = host);
|
||||
|
||||
const hasHostGroups = computed(() => Object.values(hosts.value).some((h) => h.group));
|
||||
|
||||
const groupedHostEntries = computed(() => {
|
||||
const groups: Record<string, (typeof hosts.value)[string][]> = {};
|
||||
const ungrouped: (typeof hosts.value)[string][] = [];
|
||||
|
||||
for (const host of Object.values(hosts.value)) {
|
||||
if (host.group) {
|
||||
groups[host.group] ||= [];
|
||||
groups[host.group].push(host);
|
||||
} else {
|
||||
ungrouped.push(host);
|
||||
}
|
||||
}
|
||||
|
||||
const entries = Object.entries(groups).sort(([a], [b]) => a.localeCompare(b)) as [string, typeof ungrouped][];
|
||||
if (ungrouped.length > 0) {
|
||||
entries.push(["", ungrouped]);
|
||||
}
|
||||
return entries;
|
||||
});
|
||||
|
||||
const collapsedGroups = useProfileStorage("collapsedGroups", new Set<string>());
|
||||
const collapsedHostGroups = useProfileStorage("collapsedHostGroups", new Set<string>());
|
||||
const updateCollapsedGroups = (event: Event, label: string) => {
|
||||
const details = event.target as HTMLDetailsElement;
|
||||
if (details.open) {
|
||||
@@ -141,6 +233,59 @@ const updateCollapsedGroups = (event: Event, label: string) => {
|
||||
} else {
|
||||
collapsedGroups.value.add(label);
|
||||
}
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const updateCollapsedHostGroups = (event: Event, groupName: string) => {
|
||||
const details = event.target as HTMLDetailsElement;
|
||||
if (details.open) {
|
||||
collapsedHostGroups.value.delete(groupName);
|
||||
} else {
|
||||
collapsedHostGroups.value.add(groupName);
|
||||
}
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const collapseHostGroup = (groupName: string) => {
|
||||
collapsedHostGroups.value.add(groupName);
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const hasCollapsible = computed(
|
||||
() => menuItems.value.length > 0 || groupedHostEntries.value.some(([groupName]) => groupName),
|
||||
);
|
||||
|
||||
const allCollapsed = computed(() => {
|
||||
const containerGroups = menuItems.value;
|
||||
const hostGroups = groupedHostEntries.value.filter(([groupName]) => groupName);
|
||||
if (containerGroups.length === 0 && hostGroups.length === 0) return false;
|
||||
return (
|
||||
containerGroups.every(({ label }) => collapsedGroups.value.has(label)) &&
|
||||
hostGroups.every(([groupName]) => collapsedHostGroups.value.has(groupName))
|
||||
);
|
||||
});
|
||||
|
||||
const collapseAll = () => {
|
||||
if (allCollapsed.value) {
|
||||
menuItems.value.forEach(({ label }) => collapsedGroups.value.delete(label));
|
||||
groupedHostEntries.value.forEach(([groupName]) => {
|
||||
if (groupName) collapsedHostGroups.value.delete(groupName);
|
||||
});
|
||||
} else {
|
||||
menuItems.value.forEach(({ label }) => collapsedGroups.value.add(label));
|
||||
groupedHostEntries.value.forEach(([groupName]) => {
|
||||
if (groupName) collapsedHostGroups.value.add(groupName);
|
||||
});
|
||||
}
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedPinnedContainers = debouncedRef(pinnedContainers, 200);
|
||||
@@ -165,7 +310,7 @@ const menuItems = computed(() => {
|
||||
|
||||
for (const item of sortedContainers.value) {
|
||||
const namespace = item.namespace;
|
||||
if (debouncedPinnedContainers.value.has(item.name)) {
|
||||
if (debouncedPinnedContainers.value?.has(item.name)) {
|
||||
pinned.push(item);
|
||||
} else if (namespace) {
|
||||
namespaced[namespace] ||= [];
|
||||
@@ -180,10 +325,15 @@ const menuItems = computed(() => {
|
||||
items.push({ label: "label.pinned", containers: pinned, icon: Pin });
|
||||
}
|
||||
for (const [label, containers] of Object.entries(namespaced).sort(([a], [b]) => a.localeCompare(b))) {
|
||||
if (containers.length > 1) {
|
||||
const shouldGroup =
|
||||
groupContainers.value === "always" || (groupContainers.value === "at-least-2" && containers.length > 1);
|
||||
|
||||
if (shouldGroup) {
|
||||
items.push({ label, containers, icon: Stack });
|
||||
} else {
|
||||
singular.push(containers[0]);
|
||||
for (const container of containers) {
|
||||
singular.push(container);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,14 +352,18 @@ const menuItems = computed(() => {
|
||||
|
||||
const route = useRoute("/container/[id]");
|
||||
|
||||
watchEffect(() => {
|
||||
if (route.name === "/container/[id]") {
|
||||
const container = containerStore.findContainerById(route.params.id);
|
||||
if (container) {
|
||||
setHost(container.host);
|
||||
watch(
|
||||
[() => route.name, () => route.params.id],
|
||||
([name, id]) => {
|
||||
if (name === "/container/[id]") {
|
||||
const container = containerStore.findContainerById(id as string);
|
||||
if (container) {
|
||||
setHost(container.host);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const toggleShowAllContainers = () => (showAllContainers.value = !showAllContainers.value);
|
||||
</script>
|
||||
@@ -218,6 +372,21 @@ const toggleShowAllContainers = () => (showAllContainers.value = !showAllContain
|
||||
@apply text-[0.95rem];
|
||||
}
|
||||
|
||||
.host-group-summary {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, auto) max-content max-content max-content;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
gap: 0.5rem;
|
||||
padding-left: 0;
|
||||
padding-right: 0.25rem;
|
||||
color: color-mix(in oklch, var(--color-base-content) 50%, transparent);
|
||||
}
|
||||
|
||||
.host-group-summary::after {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
li.exited {
|
||||
@apply opacity-75;
|
||||
}
|
||||
@@ -225,4 +394,17 @@ li.exited {
|
||||
li.deleted {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
li.highlight-new {
|
||||
animation: highlight-fade 3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes highlight-fade {
|
||||
from {
|
||||
background-color: oklch(from var(--color-secondary) l c h / 0.25);
|
||||
}
|
||||
to {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div v-if="groupHosts.length === 0" class="hero min-h-[50vh]">
|
||||
<div class="hero-content text-center">
|
||||
<p class="text-base-content/70 text-lg">{{ $t("error.host-group-not-found", { name }) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollableView :scrollable="scrollable" v-else>
|
||||
<template #header>
|
||||
<div class="mx-2 flex items-center gap-2 md:ml-4">
|
||||
<div class="flex flex-1 items-center gap-1.5 truncate md:gap-2">
|
||||
<ph:computer-tower />
|
||||
<div class="inline-flex font-mono text-sm">
|
||||
<div class="font-semibold">{{ name }}</div>
|
||||
</div>
|
||||
<Tag class="font-mono max-md:hidden" size="small">
|
||||
{{ $t("label.host-count", groupHosts.length) }}
|
||||
</Tag>
|
||||
<Tag class="font-mono max-md:hidden" size="small">
|
||||
{{ $t("label.container", containers.length) }}
|
||||
</Tag>
|
||||
</div>
|
||||
<MultiContainerStat class="ml-auto" :containers="containers" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" :name="name" @clear="viewer?.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<ViewerWithSource
|
||||
ref="viewer"
|
||||
:stream-source="useHostGroupStream"
|
||||
:entity="groupRef"
|
||||
:visible-keys="visibleKeys"
|
||||
/>
|
||||
</template>
|
||||
</ScrollableView>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ViewerWithSource from "@/components/LogViewer/ViewerWithSource.vue";
|
||||
import { useHostGroupStream } from "@/composable/eventStreams";
|
||||
import { ComponentExposed } from "vue-component-type-helpers";
|
||||
|
||||
const { name, scrollable = false } = defineProps<{
|
||||
name: string;
|
||||
scrollable?: boolean;
|
||||
}>();
|
||||
|
||||
const { hosts } = useHosts();
|
||||
const store = useContainerStore();
|
||||
const { containersByHost } = storeToRefs(store);
|
||||
|
||||
const groupHosts = computed(() => Object.values(hosts.value).filter((h) => h.group === name));
|
||||
const groupRef = computed(() => ({ name }));
|
||||
const visibleKeys = new Map<string[], boolean>();
|
||||
|
||||
const containers = computed(() =>
|
||||
groupHosts.value.flatMap((h) => containersByHost.value?.[h.id]?.filter((c) => c.state === "running") ?? []),
|
||||
);
|
||||
|
||||
const viewer = useTemplateRef<ComponentExposed<typeof ViewerWithSource>>("viewer");
|
||||
provideLoggingContext(containers, { showContainerName: true, showHostname: true });
|
||||
</script>
|
||||
@@ -2,7 +2,7 @@
|
||||
<ScrollableView :scrollable="scrollable" v-if="host">
|
||||
<template #header>
|
||||
<div class="mx-2 flex items-center gap-2 md:ml-4">
|
||||
<div class="flex flex-1 gap-1.5 truncate md:gap-2">
|
||||
<div class="flex flex-1 items-center gap-1.5 truncate md:gap-2">
|
||||
<ph:computer-tower />
|
||||
<div class="inline-flex font-mono text-sm">
|
||||
<div class="font-semibold">{{ host.name }}</div>
|
||||
@@ -12,7 +12,7 @@
|
||||
</Tag>
|
||||
</div>
|
||||
<MultiContainerStat class="ml-auto" :containers="containers" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" :name="host.name" @clear="viewer?.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<template>
|
||||
<div ref="root" class="flex min-h-[1px] justify-center">
|
||||
<span class="loading loading-bars loading-md text-primary mt-4" v-show="isLoading"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const { onLoadMore = () => Promise.resolve(), enabled } = defineProps<{
|
||||
onLoadMore: () => Promise<void>;
|
||||
enabled: boolean;
|
||||
}>();
|
||||
|
||||
const isLoading = ref(false);
|
||||
const root = ref<HTMLElement>();
|
||||
|
||||
const observer = new IntersectionObserver(async (entries) => {
|
||||
if (entries[0].intersectionRatio <= 0) return;
|
||||
if (onLoadMore && enabled) {
|
||||
const scrollingParent = root.value?.closest("[data-scrolling]") || document.documentElement;
|
||||
const previousHeight = scrollingParent.scrollHeight;
|
||||
isLoading.value = true;
|
||||
await onLoadMore();
|
||||
isLoading.value = false;
|
||||
await nextTick();
|
||||
scrollingParent.scrollTop += scrollingParent.scrollHeight - previousHeight;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => observer.observe(root.value!));
|
||||
onUnmounted(() => observer.disconnect());
|
||||
</script>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<div class="breadcrumbs flex-1">
|
||||
<ul>
|
||||
<li>
|
||||
<a @click.prevent="setNamespace(null)" class="link-primary">{{ $t("label.namespaces") }}</a>
|
||||
</li>
|
||||
<li v-if="selectedNamespace === 'all'">
|
||||
{{ $t("label.all-namespaces") }}
|
||||
</li>
|
||||
<li v-else-if="selectedNamespace" class="cursor-default">
|
||||
<router-link
|
||||
:to="{
|
||||
name: '/namespace/[name]',
|
||||
params: { name: selectedNamespace },
|
||||
}"
|
||||
class="btn btn-outline btn-primary btn-xs"
|
||||
active-class="btn-active"
|
||||
>
|
||||
<ph:arrows-merge />
|
||||
{{ selectedNamespace }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<div class="dropdown dropdown-end dropdown-hover">
|
||||
<label tabindex="0" class="btn btn-square btn-ghost btn-sm">
|
||||
<ph:dots-three-vertical-bold />
|
||||
</label>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 shadow-sm"
|
||||
>
|
||||
<li>
|
||||
<a class="text-sm capitalize" @click="collapseAll()">
|
||||
<material-symbols-light:collapse-all class="w-4" />
|
||||
{{ $t("label.collapse-all") }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SlideTransition :slide-right="selectedNamespace !== null">
|
||||
<template #left>
|
||||
<ul class="menu p-0">
|
||||
<li>
|
||||
<a @click.prevent="setNamespace('all')">
|
||||
<ph:circles-four />
|
||||
{{ $t("label.all-namespaces") }}
|
||||
</a>
|
||||
</li>
|
||||
<li v-for="ns in namespaces" :key="ns.name">
|
||||
<a @click.prevent="setNamespace(ns.name)">
|
||||
<ph:circles-four />
|
||||
{{ ns.name }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template #right>
|
||||
<ul class="menu w-full p-0 text-[0.95rem]" ref="menu">
|
||||
<li v-for="{ name, owners } in filteredNamespaces" :key="name">
|
||||
<details open>
|
||||
<summary class="text-base-content/80 font-light">
|
||||
<ph:stack />
|
||||
{{ name }} ({{ owners.length }})
|
||||
|
||||
<router-link
|
||||
:to="{ name: '/namespace/[name]', params: { name } }"
|
||||
class="btn btn-square btn-outline btn-primary btn-xs"
|
||||
active-class="btn-active"
|
||||
:title="$t('tooltip.merge-all')"
|
||||
>
|
||||
<ph:arrows-merge />
|
||||
</router-link>
|
||||
</summary>
|
||||
<ul>
|
||||
<li v-for="owner in owners" :key="`${owner.kind}-${owner.name}`">
|
||||
<router-link :to="{ name: '/owner/[name]', params: { name: owner.name } }" active-class="menu-active">
|
||||
<ph:stack-simple />
|
||||
<div class="truncate">{{ owner.kind }}/{{ owner.name }}</div>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
|
||||
<li v-if="ownersWithoutNamespace.length > 0">
|
||||
<details open>
|
||||
<summary class="text-base-content/80 font-light">
|
||||
<ph:circles-four />
|
||||
{{ $t("label.owners") }} ({{ ownersWithoutNamespace.length }})
|
||||
</summary>
|
||||
<ul>
|
||||
<li v-for="owner in ownersWithoutNamespace" :key="`${owner.kind}-${owner.name}`">
|
||||
<router-link :to="{ name: '/owner/[name]', params: { name: owner.name } }" active-class="menu-active">
|
||||
<ph:stack-simple />
|
||||
<div class="truncate">{{ owner.kind }}/{{ owner.name }}</div>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</SlideTransition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const store = useK8sStore();
|
||||
|
||||
const { namespaces, owners } = storeToRefs(store);
|
||||
|
||||
const selectedNamespace = ref<string | null>("all");
|
||||
|
||||
const setNamespace = (namespace: string | null) => (selectedNamespace.value = namespace);
|
||||
|
||||
const filteredNamespaces = computed(() => {
|
||||
if (selectedNamespace.value === null || selectedNamespace.value === "all") {
|
||||
return namespaces.value;
|
||||
}
|
||||
return namespaces.value.filter((ns) => ns.name === selectedNamespace.value);
|
||||
});
|
||||
|
||||
const ownersWithoutNamespace = computed(() => {
|
||||
const filtered = owners.value.filter((owner) => !owner.namespace);
|
||||
if (selectedNamespace.value === null || selectedNamespace.value === "all") {
|
||||
return filtered;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const menu = useTemplateRef("menu");
|
||||
|
||||
const collapseAll = () => {
|
||||
const details = menu.value?.querySelectorAll("details");
|
||||
details?.forEach((detail) => (detail.open = false));
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<ScrollableView :scrollable="scrollable" v-if="namespace.name">
|
||||
<template #header>
|
||||
<div class="mx-2 flex items-center gap-2 md:ml-4">
|
||||
<div class="@container flex flex-1 items-center gap-1.5 md:gap-2">
|
||||
<ph:stack />
|
||||
<div class="font-mono text-sm font-semibold">{{ namespace.name }}</div>
|
||||
<ContainerDropdown :containers="namespace.containers">
|
||||
{{ $t("label.container", namespace.containers.length) }}
|
||||
</ContainerDropdown>
|
||||
</div>
|
||||
<MultiContainerStat class="ml-auto" :containers="namespace.containers" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" :name="namespace.name" @clear="viewer?.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<ViewerWithSource
|
||||
ref="viewer"
|
||||
:stream-source="useNamespaceStream"
|
||||
:entity="namespace"
|
||||
:visible-keys="new Map<string[], boolean>()"
|
||||
/>
|
||||
</template>
|
||||
</ScrollableView>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { K8sNamespace } from "@/stores/k8s";
|
||||
import ViewerWithSource from "@/components/LogViewer/ViewerWithSource.vue";
|
||||
import { ComponentExposed } from "vue-component-type-helpers";
|
||||
|
||||
const { namespace, scrollable = false } = defineProps<{
|
||||
scrollable?: boolean;
|
||||
namespace: K8sNamespace;
|
||||
}>();
|
||||
|
||||
const viewer = ref<ComponentExposed<typeof ViewerWithSource>>();
|
||||
|
||||
provideLoggingContext(
|
||||
toRef(() => namespace.containers),
|
||||
{ showContainerName: true, showHostname: false },
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<ScrollableView :scrollable="scrollable" v-if="owner.name">
|
||||
<template #header>
|
||||
<div class="mx-2 flex items-center gap-2 md:ml-4">
|
||||
<div class="@container flex flex-1 items-center gap-1.5 md:gap-2">
|
||||
<ph:stack-simple />
|
||||
<div class="font-mono text-sm font-semibold">{{ owner.kind }}/{{ owner.name }}</div>
|
||||
<ContainerDropdown :containers="owner.containers">
|
||||
{{ $t("label.container", owner.containers.length) }}
|
||||
</ContainerDropdown>
|
||||
</div>
|
||||
<MultiContainerStat class="ml-auto" :containers="owner.containers" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" :name="owner.name" @clear="viewer?.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<ViewerWithSource
|
||||
ref="viewer"
|
||||
:stream-source="useOwnerStream"
|
||||
:entity="owner"
|
||||
:visible-keys="new Map<string[], boolean>()"
|
||||
/>
|
||||
</template>
|
||||
</ScrollableView>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { K8sOwner } from "@/stores/k8s";
|
||||
import ViewerWithSource from "@/components/LogViewer/ViewerWithSource.vue";
|
||||
import { ComponentExposed } from "vue-component-type-helpers";
|
||||
|
||||
const { owner, scrollable = false } = defineProps<{
|
||||
scrollable?: boolean;
|
||||
owner: K8sOwner;
|
||||
}>();
|
||||
|
||||
const viewer = ref<ComponentExposed<typeof ViewerWithSource>>();
|
||||
|
||||
provideLoggingContext(
|
||||
toRef(() => owner.containers),
|
||||
{ showContainerName: true, showHostname: false },
|
||||
);
|
||||
</script>
|
||||
+38
-13
@@ -3,6 +3,17 @@
|
||||
<slot name="more-items"></slot>
|
||||
<Announcements />
|
||||
|
||||
<router-link
|
||||
:to="{ name: '/notifications' }"
|
||||
:aria-label="$t('title.notifications')"
|
||||
data-testid="notifications"
|
||||
class="btn btn-circle btn-sm"
|
||||
>
|
||||
<mdi:bell class="size-6" />
|
||||
</router-link>
|
||||
|
||||
<CloudPopover />
|
||||
|
||||
<router-link
|
||||
:to="{ name: '/settings' }"
|
||||
:aria-label="$t('title.settings')"
|
||||
@@ -14,23 +25,31 @@
|
||||
|
||||
<dropdown class="dropdown-end" v-if="config.user">
|
||||
<template #trigger>
|
||||
<img
|
||||
class="ring-base-content/60 size-6 max-w-none rounded-full p-px ring-1"
|
||||
:src="withBase('/api/profile/avatar')"
|
||||
/>
|
||||
<template v-if="config.disableAvatars || !config.user.email">
|
||||
<material-symbols:person class="size-6" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<img
|
||||
class="ring-base-content/60 size-6 max-w-none rounded-full p-px ring-1"
|
||||
:src="withBase('/api/profile/avatar')"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="p-2">
|
||||
<div class="font-bold">
|
||||
{{ config.user.name }}
|
||||
</div>
|
||||
<div class="text-sm font-light">
|
||||
<div v-if="config.user.email" class="text-sm font-light">
|
||||
{{ config.user.email }}
|
||||
</div>
|
||||
</div>
|
||||
<ul class="menu mt-4 p-0">
|
||||
<li v-if="config.authProvider === 'simple'">
|
||||
<button @click.prevent="logout()" class="text-primary p-2">{{ $t("button.logout") }}</button>
|
||||
<ul v-if="config.authProvider === 'simple' || config.logoutUrl" class="menu mt-4 p-0">
|
||||
<li>
|
||||
<button @click.prevent="logout()" class="text-primary p-2">
|
||||
<material-symbols:logout />
|
||||
{{ $t("button.logout") }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -38,11 +57,17 @@
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
async function logout() {
|
||||
await fetch(withBase("/api/token"), {
|
||||
method: "DELETE",
|
||||
});
|
||||
const { logoutUrl } = config;
|
||||
|
||||
location.reload();
|
||||
async function logout() {
|
||||
if (logoutUrl) {
|
||||
location.href = logoutUrl;
|
||||
} else {
|
||||
await fetch(withBase("/api/token"), {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { ref } from "vue";
|
||||
import ComplexLogItem from "./ComplexLogItem.vue";
|
||||
import { ComplexLogEntry, type JSONObject } from "@/models/LogEntry";
|
||||
|
||||
vi.mock("@/stores/config", () => ({
|
||||
__esModule: true,
|
||||
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
|
||||
withBase: (path: string) => path,
|
||||
}));
|
||||
|
||||
function mountItem(message: JSONObject, visibleKeys?: ReturnType<typeof ref<Map<string[], boolean>>>) {
|
||||
const entry = new ComplexLogEntry(message, "c1", 1, new Date(), "info", "stdout", "raw", visibleKeys as any);
|
||||
return mount(ComplexLogItem, {
|
||||
props: { logEntry: entry },
|
||||
global: {
|
||||
// LogItem pulls in stores/hosts; we only care about the payload rendering.
|
||||
stubs: { LogItem: { template: "<div><slot /></div>" }, LogLevel: true },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("<ComplexLogItem />", () => {
|
||||
test("renders key=value pairs", () => {
|
||||
const wrapper = mountItem({ foo: "bar", n: 1 });
|
||||
expect(wrapper.findAll("li")).toHaveLength(2);
|
||||
expect(wrapper.text()).toContain("foo=");
|
||||
expect(wrapper.text()).toContain("bar");
|
||||
expect(wrapper.text()).toContain("n=");
|
||||
expect(wrapper.text()).toContain("1");
|
||||
});
|
||||
|
||||
test("renders null values as <null>", () => {
|
||||
const wrapper = mountItem({ x: null } as any);
|
||||
expect(wrapper.text()).toContain("<null>");
|
||||
});
|
||||
|
||||
test("filters out undefined values", () => {
|
||||
const wrapper = mountItem({ a: undefined as any, b: 2 });
|
||||
expect(wrapper.findAll("li")).toHaveLength(1);
|
||||
expect(wrapper.text()).toContain("b=");
|
||||
expect(wrapper.text()).not.toContain("a=");
|
||||
});
|
||||
|
||||
test("renders array values as a list", () => {
|
||||
const wrapper = mountItem({ tags: ["a", "b"] });
|
||||
expect(wrapper.find(".array").exists()).toBe(true);
|
||||
expect(wrapper.find(".array").text()).toContain("a");
|
||||
expect(wrapper.find(".array").text()).toContain("b");
|
||||
});
|
||||
|
||||
test("renders null inside an array without throwing", () => {
|
||||
const wrapper = mountItem({ tags: ["a", null, "b"] } as any);
|
||||
expect(wrapper.find(".array").text()).toContain("null");
|
||||
});
|
||||
|
||||
test("shows a placeholder when every value is hidden", () => {
|
||||
const visibleKeys = ref(new Map<string[], boolean>([[["a"], false]]));
|
||||
const wrapper = mountItem({ a: 1 }, visibleKeys);
|
||||
expect(wrapper.text()).toContain("all values are hidden");
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,32 @@
|
||||
<template>
|
||||
<LogItem :logEntry @click="showDrawer(LogDetails, { entry: logEntry })" class="clickable">
|
||||
<ul class="space-x-4">
|
||||
<li v-for="(value, name) in validValues" :key="name" class="inline-flex">
|
||||
<span class="text-light">{{ name }}=</span><span class="font-bold" v-if="value === null"><null></span>
|
||||
<template v-else-if="Array.isArray(value)">
|
||||
<span class="font-bold" v-html="JSON.stringify(value)"> </span>
|
||||
</template>
|
||||
<span class="font-bold" v-html="stripAnsi(value.toString())" v-else></span>
|
||||
<DefineTemplate v-slot="{ data }">
|
||||
<ul class="inline-flex flex-wrap space-x-4" @click="preventDefaultOnLinks">
|
||||
<li v-for="(value, name) in data" :key="name" v-if="isObject(data)">
|
||||
<span class="key">{{ name }}=</span>
|
||||
<span class="value" v-if="value === null"><null></span>
|
||||
<ReuseTemplate :data="value" v-else-if="isObject(value) || Array.isArray(value)" />
|
||||
<span v-else class="value" :class="typeof value" v-html="stripAnsi(String(value))"></span>
|
||||
</li>
|
||||
<li class="text-light" v-if="Object.keys(validValues).length === 0">all values are hidden</li>
|
||||
<li v-else-if="Array.isArray(data)">
|
||||
<ul class="array inline-flex flex-wrap space-x-1">
|
||||
<li
|
||||
v-for="(item, index) in data"
|
||||
:key="index"
|
||||
class="after:text-base-content/70 not-last:after:content-[',']"
|
||||
>
|
||||
<ReuseTemplate :data="item" v-if="isObject(item) || Array.isArray(item)" />
|
||||
<span v-else class="value" :class="typeof item" v-html="stripAnsi(String(item))"></span>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="key" v-if="Object.keys(validValues).length === 0">all values are hidden</li>
|
||||
</ul>
|
||||
</DefineTemplate>
|
||||
<LogItem :logEntry>
|
||||
<LogLevel class="flex select-none" :level="logEntry.level" />
|
||||
<div @click="containers.length > 0 && showDrawer(LogDetails, { entry: logEntry })" class="cursor-pointer">
|
||||
<ReuseTemplate :data="validValues" />
|
||||
</div>
|
||||
</LogItem>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
@@ -22,16 +39,37 @@ const { logEntry } = defineProps<{
|
||||
showContainerName?: boolean;
|
||||
}>();
|
||||
|
||||
const { containers } = useLoggingContext();
|
||||
|
||||
const [DefineTemplate, ReuseTemplate] = createReusableTemplate();
|
||||
|
||||
const validValues = computed(() => {
|
||||
return Object.fromEntries(Object.entries(logEntry.message).filter(([_, value]) => value !== undefined));
|
||||
});
|
||||
|
||||
const showDrawer = useDrawer();
|
||||
function preventDefaultOnLinks(event: MouseEvent) {
|
||||
if (event.target instanceof HTMLAnchorElement && event.target.rel?.includes("external")) {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
.text-light {
|
||||
@apply text-base-content/70;
|
||||
@reference "@/main.css";
|
||||
.key {
|
||||
@apply text-base-content/70 font-light;
|
||||
}
|
||||
|
||||
.value {
|
||||
@apply text-base-content font-bold;
|
||||
}
|
||||
|
||||
.array {
|
||||
@apply before:text-base-content/80 after:text-base-content/80 before:content-['['] after:content-[']'];
|
||||
}
|
||||
|
||||
.string {
|
||||
@apply before:content-['"'] after:content-['"'];
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,7 +13,7 @@ const { logEntry } = defineProps<{
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
@reference "@/main.css";
|
||||
[data-event="container-stopped"] {
|
||||
@apply text-red;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { computed, nextTick } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import { default as Component } from "./EventSource.vue";
|
||||
import SearchStatus from "./SearchStatus.vue";
|
||||
import LogViewer from "@/components/LogViewer/LogViewer.vue";
|
||||
import { Container } from "@/models/Container";
|
||||
import { Level } from "@/models/LogEntry";
|
||||
@@ -29,10 +30,15 @@ describe("<ContainerEventSource />", () => {
|
||||
global.EventSource = EventSource;
|
||||
// @ts-ignore
|
||||
window.scrollTo = vi.fn();
|
||||
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
global.IntersectionObserver = class IntersectionObserver {
|
||||
observe = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
takeRecords = vi.fn();
|
||||
root = null;
|
||||
rootMargin = "";
|
||||
thresholds = [];
|
||||
} as any;
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1560336942459);
|
||||
});
|
||||
@@ -65,12 +71,29 @@ describe("<ContainerEventSource />", () => {
|
||||
template: "Test from createLogEventSource",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "/container/[id].time.[datetime]",
|
||||
path: "/container/:id/time/:datetime",
|
||||
component: {
|
||||
template: "Test from createLogEventSource",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return mount(Component, {
|
||||
global: {
|
||||
plugins: [router, createTestingPinia({ createSpy: vi.fn }), createI18n({})],
|
||||
plugins: [
|
||||
router,
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
stubActions: false,
|
||||
initialState: {
|
||||
container: { containers: [{ id: "abc", image: "test:v123", host: "localhost" }] },
|
||||
},
|
||||
}),
|
||||
createI18n({}),
|
||||
],
|
||||
components: {
|
||||
LogViewer,
|
||||
},
|
||||
@@ -84,6 +107,7 @@ describe("<ContainerEventSource />", () => {
|
||||
streamConfig: reactive({ stdout: true, stderr: true }),
|
||||
hasComplexLogs: ref(false),
|
||||
levels: new Set<Level>(["info"]),
|
||||
historical: ref(false),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -138,7 +162,7 @@ describe("<ContainerEventSource />", () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources[sourceUrl].emitOpen();
|
||||
sources[sourceUrl].emitMessage({
|
||||
data: `{"ts":1560336942459, "m":"This is a message.", "id":1}`,
|
||||
data: `{"ts":1560336942459, "m":"This is a message.", "id":1, "rm": "This is a message.", "c": "abc"}`,
|
||||
});
|
||||
|
||||
vi.runAllTimers();
|
||||
@@ -149,44 +173,70 @@ describe("<ContainerEventSource />", () => {
|
||||
expect(message).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe("search status", () => {
|
||||
test("shows no-logs when not searching and the stream is empty", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources[sourceUrl].emitOpen();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3500);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="no-logs"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test("suppresses no-logs while a search is still running", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources[sourceUrl].emitOpen();
|
||||
sources[sourceUrl].emit("search-status", {
|
||||
data: JSON.stringify({ scannedTo: "2026-06-01T14:31:00Z", matches: 0, done: false }),
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(3000);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="no-logs"]').exists()).toBe(false);
|
||||
expect(wrapper.findComponent(SearchStatus).exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("render html correctly", () => {
|
||||
test("should render messages", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources[sourceUrl].emitOpen();
|
||||
sources[sourceUrl].emitMessage({
|
||||
data: `{"ts":1560336942459, "m":"This is a message.", "id":1}`,
|
||||
data: `{"ts":1560336942459, "m":"This is a message.", "id":1, "rm": "This is a message.", "c": "abc"}`,
|
||||
});
|
||||
|
||||
vi.runAllTimers();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find("ul.events").html()).toMatchSnapshot();
|
||||
expect(wrapper.find("ul[data-logs]").html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("should render dates with 12 hour style", async () => {
|
||||
const wrapper = createLogEventSource({ hourStyle: "12" });
|
||||
sources[sourceUrl].emitOpen();
|
||||
sources[sourceUrl].emitMessage({
|
||||
data: `{"ts":1560336942459, "m":"foo bar", "id":1}`,
|
||||
data: `{"ts":1560336942459, "m":"foo bar", "id":1, "rm": "foo bar", "c": "abc"}`,
|
||||
});
|
||||
|
||||
vi.runAllTimers();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find("ul.events").html()).toMatchSnapshot();
|
||||
expect(wrapper.find("ul[data-logs]").html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("should render dates with 24 hour style", async () => {
|
||||
const wrapper = createLogEventSource({ hourStyle: "24" });
|
||||
sources[sourceUrl].emitOpen();
|
||||
sources[sourceUrl].emitMessage({
|
||||
data: `{"ts":1560336942459, "m":"foo bar", "id":1}`,
|
||||
data: `{"ts":1560336942459, "m":"foo bar", "id":1, "c": "abc"}`,
|
||||
});
|
||||
|
||||
vi.runAllTimers();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find("ul.events").html()).toMatchSnapshot();
|
||||
expect(wrapper.find("ul[data-logs]").html()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +1,36 @@
|
||||
<template>
|
||||
<InfiniteLoader :onLoadMore="fetchMore" :enabled="!loadingMore && messages.length > 10" />
|
||||
<ul class="flex animate-pulse flex-col gap-4 p-4" v-if="loading || (noLogs && waitingForMoreLog)">
|
||||
<SearchStatus :status="searchStatus" class="sticky top-0 z-10" />
|
||||
<ul class="flex animate-pulse flex-col gap-4 p-4" v-if="loading || (noLogs && waitingForMoreLog && !inSearch)">
|
||||
<div class="flex flex-row gap-2" v-for="size in sizes">
|
||||
<div class="bg-base-content/50 h-3 w-40 shrink-0 rounded-full opacity-50"></div>
|
||||
<div class="bg-base-content/50 h-3 rounded-full opacity-50" :class="size"></div>
|
||||
</div>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</ul>
|
||||
<div v-else-if="noLogs && !waitingForMoreLog" class="p-4">Container has no logs yet</div>
|
||||
<div v-else-if="noLogs && !waitingForMoreLog && !inSearch" class="p-4" data-testid="no-logs">
|
||||
{{ $t("label.no-logs") }}
|
||||
</div>
|
||||
<slot :messages="messages" v-else></slot>
|
||||
<IndeterminateBar :color />
|
||||
<IndeterminateBar :color v-if="!historical" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup generic="T">
|
||||
import { LogStreamSource } from "@/composable/eventStreams";
|
||||
const route = useRoute();
|
||||
|
||||
const { entity, streamSource } = $defineProps<{
|
||||
streamSource: (t: Ref<T>) => LogStreamSource;
|
||||
entity: T;
|
||||
}>();
|
||||
|
||||
const { messages, loadOlderLogs, isLoadingMore, opened, loading, error, eventSourceURL } = streamSource($$(entity));
|
||||
const { loadingMore } = useLoggingContext();
|
||||
const { historical } = useLoggingContext();
|
||||
|
||||
const { messages, opened, loading, error, searchStatus } = streamSource(toRef(() => entity));
|
||||
|
||||
// While a search is running (or just finished), SearchStatus owns the empty
|
||||
// messaging, so suppress the generic "no logs" state to avoid the false signal.
|
||||
const inSearch = computed(() => searchStatus.value.active || searchStatus.value.done);
|
||||
|
||||
const color = computed(() => {
|
||||
if (error.value) return "error";
|
||||
if (loading.value) return "secondary";
|
||||
@@ -37,19 +46,37 @@ defineExpose({
|
||||
clear: () => (messages.value = []),
|
||||
});
|
||||
|
||||
const fetchMore = async () => {
|
||||
if (!isLoadingMore.value) {
|
||||
loadingMore.value = true;
|
||||
await loadOlderLogs();
|
||||
loadingMore.value = false;
|
||||
}
|
||||
};
|
||||
if (historical.value && typeof route.query.logId === "string") {
|
||||
const targetId = route.query.logId;
|
||||
watchOnce(messages, async () => {
|
||||
await nextTick();
|
||||
document.getElementById(targetId)?.scrollIntoView({ behavior: "instant", block: "center" });
|
||||
});
|
||||
}
|
||||
|
||||
const shuffle = (items: any[]) => {
|
||||
return items.sort(() => Math.random() - 0.5);
|
||||
};
|
||||
|
||||
const sizes = computedWithControl(eventSourceURL, () =>
|
||||
shuffle(["w-3/5", "w-2/3", "w-9/12", "w-1/2", "w-1/3", "w-3/4"]),
|
||||
const sizes = ref<string[]>([]);
|
||||
watch(
|
||||
opened,
|
||||
(value) => {
|
||||
if (value) return;
|
||||
const sizeOptions = [
|
||||
"w-2/12",
|
||||
"w-3/12",
|
||||
"w-4/12",
|
||||
"w-5/12",
|
||||
"w-6/12",
|
||||
"w-7/12",
|
||||
"w-8/12",
|
||||
"w-9/12",
|
||||
"w-10/12",
|
||||
"w-11/12",
|
||||
"w-full",
|
||||
];
|
||||
sizes.value = Array.from({ length: 18 }, () => sizeOptions[Math.floor(Math.random() * sizeOptions.length)]);
|
||||
},
|
||||
{
|
||||
flush: "sync",
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<LogItem :logEntry>
|
||||
<div class="flex flex-col">
|
||||
<div v-for="(msg, index) in logEntry.message" :key="index" class="flex items-start gap-x-2">
|
||||
<LogLevel class="flex select-none" :level="logEntry.level" :position="getPosition(index)" />
|
||||
<div
|
||||
class="[word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-pre"
|
||||
v-html="colorize(msg)"
|
||||
:class="{ 'min-h-4': msg === '' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</LogItem>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { GroupedLogEntry, type Position } from "@/models/LogEntry";
|
||||
import AnsiConvertor from "ansi-to-html";
|
||||
|
||||
const ansiConvertor = new AnsiConvertor({
|
||||
escapeXML: false,
|
||||
fg: "var(--color-base-content)",
|
||||
bg: "var(--color-base-100)",
|
||||
});
|
||||
|
||||
const { logEntry } = defineProps<{
|
||||
logEntry: GroupedLogEntry;
|
||||
}>();
|
||||
|
||||
const getPosition = (index: number): Position => {
|
||||
const len = logEntry.message.length;
|
||||
if (index === 0) return "start";
|
||||
if (index === len - 1) return "end";
|
||||
return "middle";
|
||||
};
|
||||
|
||||
const colorize = (value: string) => ansiConvertor.toHtml(value);
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-base-content/[0.06] grid min-w-0 grid-cols-[auto_auto_3.5rem_auto_3.5rem] items-center gap-x-1.5 gap-y-1 rounded-md px-2.5 py-1.5 text-[12.5px] leading-none tabular-nums max-md:hidden @max-5xl:hidden"
|
||||
:title="tooltip"
|
||||
>
|
||||
<PhNetwork class="text-base-content/60 size-3.5" />
|
||||
<PhArrowUp class="text-primary text-[10px]" />
|
||||
<span class="text-right">{{ formatBytes(networkTx, { short: true, decimals: 1 }) }}/s</span>
|
||||
<PhArrowDown class="text-secondary text-[10px]" />
|
||||
<span class="text-right">{{ formatBytes(networkRx, { short: true, decimals: 1 }) }}/s</span>
|
||||
|
||||
<PhHardDrives class="text-base-content/60 size-3.5" />
|
||||
<PhArrowUp class="text-primary text-[10px]" />
|
||||
<span class="text-right">{{ formatBytes(diskWrite, { short: true, decimals: 1 }) }}/s</span>
|
||||
<PhArrowDown class="text-secondary text-[10px]" />
|
||||
<span class="text-right">{{ formatBytes(diskRead, { short: true, decimals: 1 }) }}/s</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import PhNetwork from "~icons/ph/network";
|
||||
import PhHardDrives from "~icons/ph/hard-drives";
|
||||
import PhArrowUp from "~icons/ph/arrow-up";
|
||||
import PhArrowDown from "~icons/ph/arrow-down";
|
||||
|
||||
const { networkRx, networkTx, diskRead, diskWrite } = defineProps<{
|
||||
networkRx: number;
|
||||
networkTx: number;
|
||||
diskRead: number;
|
||||
diskWrite: number;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const tooltip = computed(
|
||||
() =>
|
||||
t("tooltip.network-io", { tx: formatBytes(networkTx), rx: formatBytes(networkRx) }) +
|
||||
"\n" +
|
||||
t("tooltip.disk-io", { write: formatBytes(diskWrite), read: formatBytes(diskRead) }),
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div ref="root" class="flex min-h-[1px] flex-1 content-center justify-center">
|
||||
<span class="loading loading-bars loading-md text-primary m-2" v-show="isLoading"></span>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { LoadMoreLogEntry } from "@/models/LogEntry";
|
||||
|
||||
const { logEntry } = defineProps<{
|
||||
logEntry: LoadMoreLogEntry;
|
||||
}>();
|
||||
|
||||
const isLoading = ref(false);
|
||||
const root = ref<HTMLElement>();
|
||||
|
||||
useIntersectionObserver(root, async (entries) => {
|
||||
if (entries[0].intersectionRatio <= 0) return;
|
||||
if (isLoading.value) return;
|
||||
const scrollingParent = root.value?.closest("[data-scrolling]") || document.documentElement;
|
||||
const previousHeight = scrollingParent.scrollHeight;
|
||||
isLoading.value = true;
|
||||
await logEntry.loadMore();
|
||||
isLoading.value = false;
|
||||
await nextTick();
|
||||
if (logEntry.rememberScrollPosition) {
|
||||
scrollingParent.scrollTop += scrollingParent.scrollHeight - previousHeight;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div
|
||||
class="dropdown dropdown-hover absolute -left-2 z-10 font-sans"
|
||||
:class="shouldShowBelow ? 'dropdown-right' : 'dropdown-right dropdown-end'"
|
||||
v-show="container"
|
||||
ref="dropdownRef"
|
||||
@mouseenter="checkDropdownPosition"
|
||||
>
|
||||
<router-link
|
||||
v-if="isSearching"
|
||||
@click="resetSearch()"
|
||||
tabindex="0"
|
||||
class="btn btn-square btn-xs border-base-content/20 bg-base-100 pointer-events-auto! opacity-0 shadow-sm group-hover/entry:opacity-90"
|
||||
:to="{
|
||||
name: '/container/[id].time.[datetime]',
|
||||
params: { id: container.id, datetime: logEntry.date.toISOString() },
|
||||
query: { logId: logEntry.id },
|
||||
}"
|
||||
>
|
||||
<material-symbols:eye-tracking />
|
||||
</router-link>
|
||||
<button
|
||||
tabindex="0"
|
||||
class="btn btn-square btn-xs border-base-content/20 bg-base-100 border opacity-0 shadow-sm group-hover/entry:opacity-90"
|
||||
v-else
|
||||
>
|
||||
<ion:ellipsis-vertical />
|
||||
</button>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm"
|
||||
@click="hideMenu"
|
||||
>
|
||||
<li v-if="isSearching">
|
||||
<router-link
|
||||
@click="resetSearch()"
|
||||
:to="{
|
||||
name: '/container/[id].time.[datetime]',
|
||||
params: { id: container.id, datetime: logEntry.date.toISOString() },
|
||||
query: { logId: logEntry.id },
|
||||
}"
|
||||
>
|
||||
<material-symbols:eye-tracking />
|
||||
{{ $t("action.see-in-context") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
@click="copyLogMessage()"
|
||||
:disabled="!isSupported"
|
||||
:title="!isSupported ? $t('error.copy-not-supported') : ''"
|
||||
:class="{ 'cursor-not-allowed opacity-50': !isSupported }"
|
||||
>
|
||||
<material-symbols:content-copy />
|
||||
{{ $t("action.copy-log") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
@click="copyPermalink()"
|
||||
:disabled="!isSupported"
|
||||
:title="!isSupported ? $t('error.copy-not-supported') : ''"
|
||||
:class="{ 'cursor-not-allowed opacity-50': !isSupported }"
|
||||
>
|
||||
<material-symbols:link />
|
||||
{{ $t("action.copy-link") }}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li v-if="logEntry instanceof ComplexLogEntry">
|
||||
<a @click="showDrawer(LogDetails, { entry: logEntry })">
|
||||
<material-symbols:code-blocks-rounded />
|
||||
{{ $t("action.show-details") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a @click="createAlert()">
|
||||
<mdi:bell />
|
||||
{{ $t("action.create-alert") }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { Container } from "@/models/Container";
|
||||
import { LogEntry, SimpleLogEntry, ComplexLogEntry, GroupedLogEntry, JSONObject } from "@/models/LogEntry";
|
||||
import LogDetails from "./LogDetails.vue";
|
||||
import AlertForm from "@/components/Notification/AlertForm.vue";
|
||||
|
||||
const { logEntry, container } = defineProps<{
|
||||
logEntry: LogEntry<string | JSONObject>;
|
||||
container: Container;
|
||||
}>();
|
||||
|
||||
const { showToast } = useToast();
|
||||
const showDrawer = useDrawer();
|
||||
const router = useRouter();
|
||||
const { isSearching, resetSearch } = useSearchFilter();
|
||||
|
||||
const { copy, isSupported, copied } = useClipboard();
|
||||
const { t } = useI18n();
|
||||
|
||||
async function copyLogMessage() {
|
||||
if (!isSupported.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (logEntry instanceof ComplexLogEntry) {
|
||||
await copy(stripAnsi(logEntry.rawMessage));
|
||||
} else if (logEntry instanceof SimpleLogEntry) {
|
||||
await copy(stripAnsi(logEntry.rawMessage));
|
||||
} else if (logEntry instanceof GroupedLogEntry) {
|
||||
await copy(stripAnsi(logEntry.message.join("\n")));
|
||||
}
|
||||
|
||||
if (copied.value) {
|
||||
showToast(
|
||||
{
|
||||
title: t("toasts.copied.title"),
|
||||
message: t("toasts.copied.message"),
|
||||
type: "info",
|
||||
},
|
||||
{ expire: 2000 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPermalink() {
|
||||
if (!isSupported.value) {
|
||||
return;
|
||||
}
|
||||
const url = router.resolve({
|
||||
name: "/container/[id].time.[datetime]",
|
||||
params: { id: container.id, datetime: logEntry.date.toISOString() },
|
||||
query: { logId: logEntry.id },
|
||||
}).href;
|
||||
|
||||
const resolved = new URL(url, window.location.origin);
|
||||
|
||||
await copy(resolved.href);
|
||||
|
||||
if (copied.value) {
|
||||
showToast(
|
||||
{
|
||||
title: t("toasts.copied.title"),
|
||||
message: t("toasts.copied.message"),
|
||||
type: "info",
|
||||
},
|
||||
{ expire: 2000 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createAlert() {
|
||||
const containerExpr = `name contains "${container.name}"`;
|
||||
let logExpr = "";
|
||||
if (logEntry.level && logEntry.level !== "unknown") {
|
||||
logExpr = `level == "${logEntry.level}"`;
|
||||
}
|
||||
|
||||
const nameParts = [container.name];
|
||||
if (logEntry.level && logEntry.level !== "unknown") {
|
||||
nameParts.push(logEntry.level);
|
||||
}
|
||||
const name = nameParts.join(" ");
|
||||
|
||||
showDrawer(AlertForm, { prefill: { name, containerExpression: containerExpr, logExpression: logExpr } }, "lg");
|
||||
}
|
||||
|
||||
function hideMenu(e: MouseEvent) {
|
||||
if (e.target instanceof HTMLAnchorElement) {
|
||||
setTimeout(() => {
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
const dropdownRef = useTemplateRef<HTMLDivElement>("dropdownRef");
|
||||
const shouldShowBelow = ref(false);
|
||||
|
||||
function checkDropdownPosition() {
|
||||
if (!dropdownRef.value) return;
|
||||
|
||||
const rect = dropdownRef.value.getBoundingClientRect();
|
||||
shouldShowBelow.value = rect.top < 150;
|
||||
}
|
||||
</script>
|
||||
@@ -1,37 +1,92 @@
|
||||
<template>
|
||||
<aside>
|
||||
<header class="flex items-center gap-4">
|
||||
<h1 class="text-2xl max-md:hidden">{{ container.name }}</h1>
|
||||
<h2 class="text-sm"><DistanceTime :date="container.created" /></h2>
|
||||
<aside class="flex flex-col gap-5 pb-8">
|
||||
<header class="flex items-center gap-3 pr-8">
|
||||
<ph:file-sql class="text-primary size-7 shrink-0" />
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<h1 class="text-xl leading-tight font-semibold">{{ $t("analytics.title") }}</h1>
|
||||
<p class="text-base-content/60 flex items-center gap-1.5 text-sm">
|
||||
<span class="truncate">{{ container.name }}</span>
|
||||
<span class="opacity-40">·</span>
|
||||
<RelativeTime :date="container.created" />
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mt-8 flex flex-col gap-2">
|
||||
<section>
|
||||
<label class="form-control">
|
||||
<textarea
|
||||
v-model="query"
|
||||
class="textarea textarea-primary w-full font-mono text-lg"
|
||||
:class="{ 'textarea-error!': error }"
|
||||
:disabled="state === 'downloading'"
|
||||
></textarea>
|
||||
<div class="mt-2">
|
||||
<span class="text-error" v-if="error">{{ error }}</span>
|
||||
<span v-else-if="state === 'initializing'">{{ $t("analytics.creating_table") }}</span>
|
||||
<span v-else-if="state === 'downloading'">{{
|
||||
$t("analytics.downloading", { size: formatBytes(bytes, { decimals: 1 }) })
|
||||
}}</span>
|
||||
<span v-else-if="evaluating">{{ $t("analytics.evaluating_query") }}</span>
|
||||
<span v-else>
|
||||
{{ $t("analytics.total_records", { count: results.numRows.toLocaleString() }) }}
|
||||
<template v-if="results.numRows > pageLimit">{{
|
||||
$t("analytics.showing_first", { count: page.numRows.toLocaleString() })
|
||||
}}</template>
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</section>
|
||||
<SQLTable :table="page" :loading="evaluating || state !== 'ready'" />
|
||||
</div>
|
||||
<section class="flex flex-col gap-2">
|
||||
<textarea
|
||||
ref="queryEl"
|
||||
v-model="query"
|
||||
class="textarea textarea-primary w-full resize-y font-mono text-sm leading-relaxed"
|
||||
:class="{ 'textarea-error!': error }"
|
||||
:disabled="state !== 'ready'"
|
||||
rows="3"
|
||||
spellcheck="false"
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
:aria-label="$t('analytics.title')"
|
||||
@keydown.meta.enter.prevent="run"
|
||||
@keydown.ctrl.enter.prevent="run"
|
||||
></textarea>
|
||||
|
||||
<div class="flex min-h-6 items-center text-sm">
|
||||
<div class="min-w-0 flex-1 truncate">
|
||||
<span class="text-error" v-if="error">{{ error }}</span>
|
||||
<span class="text-base-content/60 inline-flex items-center gap-2" v-else-if="state === 'initializing'">
|
||||
<span class="loading loading-spinner loading-xs"></span>{{ $t("analytics.creating_table") }}
|
||||
</span>
|
||||
<span class="text-base-content/60 inline-flex items-center gap-2" v-else-if="state === 'downloading'">
|
||||
<span class="loading loading-spinner loading-xs"></span
|
||||
>{{ $t("analytics.downloading", { size: formatBytes(bytes, { decimals: 1 }) }) }}
|
||||
</span>
|
||||
<span class="text-base-content/60 inline-flex items-center gap-2" v-else-if="evaluating">
|
||||
<span class="loading loading-spinner loading-xs"></span>{{ $t("analytics.evaluating_query") }}
|
||||
</span>
|
||||
<span class="text-base-content/60" v-else>
|
||||
{{ $t("analytics.total_records", { count: results.numRows.toLocaleString() }) }}
|
||||
<template v-if="results.numRows > pageLimit">{{
|
||||
$t("analytics.showing_first", { count: page.numRows.toLocaleString() })
|
||||
}}</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="state === 'ready' && columns.length" class="flex flex-col gap-2 text-xs">
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span class="text-base-content/50 font-medium">{{ $t("analytics.examples") }}</span>
|
||||
<button
|
||||
v-for="ex in examples"
|
||||
:key="ex.key"
|
||||
class="badge badge-sm badge-outline hover:border-primary hover:text-primary cursor-pointer"
|
||||
@click="applyExample(ex.sql)"
|
||||
>
|
||||
{{ $t(ex.key, ex.params ?? {}) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<details class="group">
|
||||
<summary
|
||||
class="text-base-content/50 hover:text-base-content/80 flex w-fit cursor-pointer items-center gap-1 font-medium select-none"
|
||||
>
|
||||
<ph:caret-right class="size-3 transition-transform group-open:rotate-90" />
|
||||
{{ $t("analytics.columns") }}
|
||||
<span class="opacity-60">{{ columns.length }}</span>
|
||||
</summary>
|
||||
<div class="mt-2 flex max-h-40 flex-wrap gap-1.5 overflow-y-auto">
|
||||
<button
|
||||
v-for="col in columns"
|
||||
:key="col.name"
|
||||
class="badge badge-sm badge-ghost hover:border-primary hover:text-primary cursor-pointer font-mono"
|
||||
:title="col.type"
|
||||
@click="insertColumn(col.name)"
|
||||
>
|
||||
{{ col.name }}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<SQLTable :table="page" :loading="evaluating || state !== 'ready'" />
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -42,11 +97,15 @@ import { type Table } from "@apache-arrow/esnext-esm";
|
||||
const { container } = defineProps<{ container: Container }>();
|
||||
const query = ref("SELECT * FROM logs LIMIT 100");
|
||||
const error = ref<string | null>(null);
|
||||
const debouncedQuery = debouncedRef(query, 500);
|
||||
const evaluating = ref(false);
|
||||
const pageLimit = 1000;
|
||||
const state = ref<"downloading" | "ready" | "initializing">("downloading");
|
||||
const bytes = ref(0);
|
||||
const columns = ref<{ name: string; type: string }[]>([]);
|
||||
const queryEl = useTemplateRef<HTMLTextAreaElement>("queryEl");
|
||||
|
||||
const runQuery = ref(query.value);
|
||||
watchDebounced(query, (v) => (runQuery.value = v), { debounce: 500 });
|
||||
|
||||
const url = withBase(
|
||||
`/api/hosts/${container.host}/containers/${container.id}/logs?stdout=1&stderr=1&everything&jsonOnly`,
|
||||
@@ -89,9 +148,39 @@ onMounted(async () => {
|
||||
await db.registerFileBuffer("logs.json", arrayBuffer);
|
||||
|
||||
state.value = "initializing";
|
||||
await conn.query(
|
||||
`CREATE TABLE logs AS SELECT unnest(m) FROM read_json('logs.json', ignore_errors = true, format = 'newline_delimited')`,
|
||||
);
|
||||
|
||||
// Uniform JSON messages flatten into a STRUCT and unnest() expands them into columns.
|
||||
// Mixed message shapes make DuckDB infer `m` as JSON, which unnest() rejects, so fall
|
||||
// back to flattening the union of object keys by hand.
|
||||
const source = `read_json('logs.json', ignore_errors = true, format = 'newline_delimited', map_inference_threshold = -1)`;
|
||||
try {
|
||||
await conn.query(`CREATE TABLE logs AS SELECT unnest(m) FROM ${source}`);
|
||||
} catch {
|
||||
await conn.query(`DROP TABLE IF EXISTS logs`);
|
||||
await conn.query(`CREATE TABLE raw_logs AS SELECT m::JSON AS m FROM ${source}`);
|
||||
const keyRows = await conn.query<{ key: any }>(
|
||||
`SELECT DISTINCT unnest(json_keys(m)) AS key FROM raw_logs WHERE json_type(m) = 'OBJECT'`,
|
||||
);
|
||||
const keys = keyRows.toArray().map((row) => String(row.key));
|
||||
if (keys.length) {
|
||||
const projection = keys
|
||||
.map((key) => {
|
||||
const ident = key.replace(/"/g, '""');
|
||||
const path = `$."${ident}"`.replace(/'/g, "''");
|
||||
return `json_extract_string(m, '${path}') AS "${ident}"`;
|
||||
})
|
||||
.join(", ");
|
||||
await conn.query(`CREATE TABLE logs AS SELECT ${projection} FROM raw_logs WHERE json_type(m) = 'OBJECT'`);
|
||||
} else {
|
||||
await conn.query(`CREATE TABLE logs AS SELECT m FROM raw_logs`);
|
||||
}
|
||||
}
|
||||
|
||||
const described = await conn.query<{ column_name: any; column_type: any }>(`DESCRIBE logs`);
|
||||
columns.value = described.toArray().map((row) => ({
|
||||
name: String(row.column_name),
|
||||
type: String(row.column_type),
|
||||
}));
|
||||
|
||||
state.value = "ready";
|
||||
} catch (e) {
|
||||
@@ -102,10 +191,54 @@ onMounted(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
const examples = computed(() => {
|
||||
const names = columns.value.map((c) => c.name);
|
||||
const pick = ["level", "severity", "lvl", "status"].find((c) => names.includes(c)) ?? names[0];
|
||||
const list: { key: string; sql: string; params?: Record<string, string> }[] = [
|
||||
{ key: "analytics.example_all", sql: "SELECT * FROM logs LIMIT 100" },
|
||||
{ key: "analytics.example_count", sql: "SELECT count(*) AS total FROM logs" },
|
||||
];
|
||||
if (pick) {
|
||||
list.push({
|
||||
key: "analytics.example_group",
|
||||
params: { column: pick },
|
||||
sql: `SELECT "${pick}", count(*) AS count FROM logs GROUP BY "${pick}" ORDER BY count DESC`,
|
||||
});
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
function run() {
|
||||
if (state.value !== "ready") return;
|
||||
runQuery.value = query.value;
|
||||
}
|
||||
|
||||
function applyExample(sql: string) {
|
||||
query.value = sql;
|
||||
nextTick(run);
|
||||
}
|
||||
|
||||
function insertColumn(name: string) {
|
||||
const text = `"${name}"`;
|
||||
const el = queryEl.value;
|
||||
if (!el) {
|
||||
query.value += text;
|
||||
return;
|
||||
}
|
||||
const start = el.selectionStart ?? query.value.length;
|
||||
const end = el.selectionEnd ?? start;
|
||||
query.value = query.value.slice(0, start) + text + query.value.slice(end);
|
||||
nextTick(() => {
|
||||
el.focus();
|
||||
const pos = start + text.length;
|
||||
el.setSelectionRange(pos, pos);
|
||||
});
|
||||
}
|
||||
|
||||
const results = computedAsync(
|
||||
async () => {
|
||||
if (state.value === "ready") {
|
||||
return await conn.query<Record<string, any>>(debouncedQuery.value);
|
||||
return await conn.query<Record<string, any>>(runQuery.value);
|
||||
} else {
|
||||
return empty;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<Tag size="small" class="items-start!">
|
||||
<Tag class="items-start!">
|
||||
<DateTime :date="date" class="text-blue whitespace-nowrap" />
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<header class="flex items-center gap-4">
|
||||
<Tag :data-level="entry.level" class="text-white uppercase" v-if="entry.level">{{ entry.level }}</Tag>
|
||||
<Tag :data-level="entry.level" class="show-unknown text-white uppercase" v-if="entry.level">{{ entry.level }}</Tag>
|
||||
<h1 class="text-lg max-md:hidden">
|
||||
<DateTime :date="entry.date" />
|
||||
</h1>
|
||||
<h2 class="text-sm"><DistanceTime :date="entry.date" /> on {{ entry.std }}</h2>
|
||||
<h2 class="text-sm"><RelativeTime :date="entry.date" /> on {{ entry.std }}</h2>
|
||||
</header>
|
||||
|
||||
<div class="mt-8 flex flex-col gap-10">
|
||||
@@ -29,15 +29,15 @@
|
||||
<div class="flex gap-2">
|
||||
Raw JSON
|
||||
|
||||
<UseClipboard v-slot="{ copy, copied }" :source="JSON.stringify(entry.unfilteredMessage)">
|
||||
<UseClipboard v-slot="{ copy, copied }" :source="entry.rawMessage">
|
||||
<button class="swap outline-hidden" @click="copy()" :class="{ 'hover:swap-active': copied }">
|
||||
<mdi:check class="swap-on" />
|
||||
<mdi:content-copy class="swap-off" />
|
||||
<material-symbols:content-copy class="swap-off" />
|
||||
</button>
|
||||
</UseClipboard>
|
||||
</div>
|
||||
<div class="bg-base-200 max-h-48 overflow-scroll rounded-sm border border-white/20 p-2">
|
||||
<pre v-html="syntaxHighlight(entry.unfilteredMessage)"></pre>
|
||||
<div class="bg-base-200 max-h-125 overflow-scroll rounded-sm border border-white/20 p-2">
|
||||
<JsonFormatted :value="entry.rawMessage" class="text-sm" />
|
||||
</div>
|
||||
</section>
|
||||
<table class="table-pin-rows table table-fixed" v-if="entry instanceof ComplexLogEntry">
|
||||
@@ -59,7 +59,7 @@
|
||||
{{ key.join(".") }}
|
||||
</td>
|
||||
<td class="truncate max-md:hidden">
|
||||
<code v-html="JSON.stringify(value)"></code>
|
||||
<code>{{ JSON.stringify(value) }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" class="toggle toggle-primary" :checked="enabled" @change="toggleField(key)" />
|
||||
@@ -96,14 +96,15 @@ function toggleField(key: string[]) {
|
||||
const fields = computed({
|
||||
get() {
|
||||
const fieldsWithValue: { key: string[]; value: any; enabled: boolean }[] = [];
|
||||
const allFields = flattenJSONToMap(entry.unfilteredMessage);
|
||||
const rawFields = JSON.parse(entry.rawMessage);
|
||||
const allFields = flattenJSONToMap(rawFields);
|
||||
if (visibleKeys.value.size === 0) {
|
||||
for (const [key, value] of allFields) {
|
||||
fieldsWithValue.push({ key, value, enabled: true });
|
||||
}
|
||||
} else {
|
||||
for (const [key, enabled] of visibleKeys.value) {
|
||||
const value = getDeep(entry.unfilteredMessage, key);
|
||||
const value = getDeep(rawFields, key);
|
||||
fieldsWithValue.push({ key, value, enabled });
|
||||
}
|
||||
|
||||
@@ -141,32 +142,10 @@ const toggleAllFields = computed({
|
||||
},
|
||||
});
|
||||
|
||||
function syntaxHighlight(json: any) {
|
||||
json = JSON.stringify(json, null, 2);
|
||||
return json.replace(
|
||||
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|\b\d+\b)/g,
|
||||
function (match: string) {
|
||||
var cls = "json-number";
|
||||
if (match.startsWith('"')) {
|
||||
if (match.endsWith(":")) {
|
||||
cls = "json-key";
|
||||
} else {
|
||||
cls = "json-string";
|
||||
}
|
||||
} else if (/true|false/.test(match)) {
|
||||
cls = "json-boolean";
|
||||
} else if (/null/.test(match)) {
|
||||
cls = "json-null";
|
||||
}
|
||||
return `<span class="${cls}">${match}</span>`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
useSortable(list, fields);
|
||||
</script>
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
@reference "@/main.css";
|
||||
.font-mono {
|
||||
font-family:
|
||||
ui-monospace,
|
||||
@@ -178,22 +157,4 @@ useSortable(list, fields);
|
||||
Menlo,
|
||||
monospace;
|
||||
}
|
||||
|
||||
pre {
|
||||
& :deep(.json-key) {
|
||||
@apply text-blue;
|
||||
}
|
||||
& :deep(.json-string) {
|
||||
@apply text-green;
|
||||
}
|
||||
& :deep(.json-number) {
|
||||
@apply text-orange;
|
||||
}
|
||||
& :deep(.json-boolean) {
|
||||
@apply text-purple;
|
||||
}
|
||||
& :deep(.json-null) {
|
||||
@apply text-red;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
<template>
|
||||
<div class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
<LogActions :logEntry :container />
|
||||
|
||||
<LogStd :std="logEntry.std" class="shrink-0 select-none" v-if="showStd" />
|
||||
<RandomColorTag class="shrink-0 select-none" :value="host.name" v-if="showHostname" />
|
||||
<RandomColorTag class="shrink-0 select-none" :value="container.name" v-if="showContainerName" truncateRight />
|
||||
<LogDate :date="logEntry.date" v-if="showTimestamp" class="shrink-0 select-none" />
|
||||
<LogLevel
|
||||
class="flex select-none"
|
||||
:level="logEntry.level"
|
||||
:position="logEntry instanceof SimpleLogEntry ? logEntry.position : undefined"
|
||||
/>
|
||||
|
||||
<div class="flex gap-x-2 gap-y-1 group-[.compact]:gap-y-0 has-[>_*:nth-of-type(2)]:flex-col-reverse md:flex-row!">
|
||||
<RandomColorTag class="w-30 shrink-0 select-none md:w-40" :value="host.name" v-if="showHostname" />
|
||||
<RandomColorTag
|
||||
v-if="showContainerName"
|
||||
class="w-30 shrink-0 select-none group-[.compact]:flex-1 md:w-40"
|
||||
:value="container.id"
|
||||
truncateRight
|
||||
>
|
||||
{{ container.name }}
|
||||
</RandomColorTag>
|
||||
<LogDate v-if="showTimestamp" :date="logEntry.date" class="shrink-0 select-none" />
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { LogEntry, SimpleLogEntry } from "@/models/LogEntry";
|
||||
import { LogEntry } from "@/models/LogEntry";
|
||||
|
||||
const { logEntry } = defineProps<{
|
||||
logEntry: LogEntry<any>;
|
||||
|
||||
@@ -1,41 +1,50 @@
|
||||
<template>
|
||||
<div :data-level="level" :data-position="position" class="mt-1.5 size-2.5 flex-none rounded-lg"></div>
|
||||
<div
|
||||
:data-level="level"
|
||||
:data-position="position"
|
||||
class="mt-1.5 size-2.5 flex-none rounded-lg"
|
||||
:class="{ showUnknown }"
|
||||
></div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { Position } from "@/models/LogEntry";
|
||||
import { Position, Level } from "@/models/LogEntry";
|
||||
|
||||
defineProps<{
|
||||
level?: string;
|
||||
const {
|
||||
level,
|
||||
position,
|
||||
showUnknown = false,
|
||||
} = defineProps<{
|
||||
level?: Level;
|
||||
position?: Position;
|
||||
showUnknown?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
@reference "@/main.css";
|
||||
[data-position="start"],
|
||||
[data-position="middle"],
|
||||
[data-position="end"] {
|
||||
align-self: stretch;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
[data-position="start"] {
|
||||
border-radius: 0.5em 0.5em 0 0;
|
||||
height: 70%;
|
||||
margin-bottom: -0.4em;
|
||||
margin-top: auto;
|
||||
align-self: flex-end;
|
||||
border-radius: 0.375rem 0.375rem 0 0;
|
||||
}
|
||||
|
||||
[data-position="middle"] {
|
||||
border-radius: 0;
|
||||
height: auto;
|
||||
margin: -0.4em 0;
|
||||
align-self: stretch;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
[data-position="end"] {
|
||||
border-radius: 0 0 0.5em 0.5em;
|
||||
height: 70%;
|
||||
margin-top: -0.4em;
|
||||
align-self: flex-start;
|
||||
border-radius: 0 0 0.375rem 0.375rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
@import "@/main.css" reference;
|
||||
@reference "@/main.css";
|
||||
[data-level="debug"],
|
||||
[data-level="trace"] {
|
||||
@apply !bg-purple;
|
||||
@@ -53,4 +62,8 @@ defineProps<{
|
||||
[data-level="warn"] {
|
||||
@apply !bg-orange;
|
||||
}
|
||||
|
||||
[data-level="unknown"].show-unknown {
|
||||
@apply !bg-base-300;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<template>
|
||||
<ul class="events group pt-4" :class="{ 'disable-wrap': !softWrap, [size]: true, compact }">
|
||||
<ul class="group pt-4" :class="{ 'disable-wrap': !softWrap, [size]: true, compact }" data-logs>
|
||||
<li
|
||||
v-for="item in messages"
|
||||
ref="list"
|
||||
:key="item.id"
|
||||
:data-key="item.id"
|
||||
:id="item.id.toString()"
|
||||
:data-time="item.date.getTime()"
|
||||
class="group/entry"
|
||||
:class="{ 'log-permalink-target': permalinkLogId === item.id.toString() }"
|
||||
>
|
||||
<component :is="item.getComponent()" :log-entry="item" />
|
||||
</li>
|
||||
@@ -14,22 +15,22 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { type JSONObject, LogEntry } from "@/models/LogEntry";
|
||||
import type { LogEntry, LogMessage } from "@/models/LogEntry";
|
||||
|
||||
const { loading, progress, currentDate } = useScrollContext();
|
||||
const { progress, currentDate } = useScrollContext();
|
||||
|
||||
const { messages } = defineProps<{
|
||||
messages: LogEntry<string | JSONObject>[];
|
||||
messages: LogEntry<LogMessage>[];
|
||||
}>();
|
||||
|
||||
watchEffect(() => {
|
||||
loading.value = messages.length === 0;
|
||||
});
|
||||
|
||||
const { containers } = useLoggingContext();
|
||||
|
||||
const route = useRoute();
|
||||
const permalinkLogId = computed(() => (typeof route.query.logId === "string" ? route.query.logId : ""));
|
||||
|
||||
const list = ref<HTMLElement[]>([]);
|
||||
|
||||
let previousDate = new Date();
|
||||
useIntersectionObserver(
|
||||
list,
|
||||
(entries) => {
|
||||
@@ -40,9 +41,12 @@ useIntersectionObserver(
|
||||
const time = entry.target.getAttribute("data-time");
|
||||
if (time) {
|
||||
const date = new Date(parseInt(time));
|
||||
if (+date === +previousDate) break;
|
||||
previousDate = date;
|
||||
const diff = new Date().getTime() - container.created.getTime();
|
||||
progress.value = (date.getTime() - container.created.getTime()) / diff;
|
||||
currentDate.value = date;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,8 +58,8 @@ useIntersectionObserver(
|
||||
);
|
||||
</script>
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
.events {
|
||||
@reference "@/main.css";
|
||||
ul {
|
||||
font-family:
|
||||
ui-monospace,
|
||||
SFMono-Regular,
|
||||
@@ -67,10 +71,15 @@ useIntersectionObserver(
|
||||
monospace;
|
||||
|
||||
> li {
|
||||
@apply has-[.clickable]:hover:bg-primary/10 flex px-2 py-1 break-words last:snap-end odd:bg-gray-400/[0.07] has-[.clickable]:cursor-pointer md:px-4;
|
||||
@apply flex px-2 py-1 break-words last:snap-end odd:bg-gray-400/[0.07] md:px-4;
|
||||
&:last-child {
|
||||
scroll-margin-block-end: 5rem;
|
||||
}
|
||||
|
||||
&.log-permalink-target {
|
||||
@apply bg-secondary/15 border-secondary -ml-1 border-l-4 pl-3;
|
||||
animation: log-permalink-pulse 1.4s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
&.small {
|
||||
@@ -82,7 +91,7 @@ useIntersectionObserver(
|
||||
}
|
||||
|
||||
&.large {
|
||||
@apply text-lg;
|
||||
@apply text-[1em];
|
||||
}
|
||||
|
||||
&.compact {
|
||||
@@ -99,6 +108,10 @@ useIntersectionObserver(
|
||||
@apply bg-secondary inline-block rounded-xs;
|
||||
animation: pops 200ms ease-out;
|
||||
}
|
||||
|
||||
:deep(a[rel~="external"]) {
|
||||
@apply text-primary underline-offset-4 hover:underline;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pops {
|
||||
@@ -109,4 +122,14 @@ useIntersectionObserver(
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes log-permalink-pulse {
|
||||
0% {
|
||||
background-color: var(--color-secondary);
|
||||
}
|
||||
100% {
|
||||
/* Settle to the resting bg-secondary/15 declared on the .li above. */
|
||||
background-color: color-mix(in oklab, var(--color-secondary) 15%, transparent);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<template>
|
||||
<div class="flex gap-2">
|
||||
<div
|
||||
class="flex min-w-[0.98rem] items-start justify-end align-bottom hover:cursor-pointer"
|
||||
v-if="isSupported"
|
||||
:title="t('log_actions.copy_log')"
|
||||
>
|
||||
<span
|
||||
class="text-primary rounded-sm bg-slate-800/60 px-1.5 py-1 hover:bg-slate-700"
|
||||
@click.prevent="copyLogMessageToClipBoard()"
|
||||
>
|
||||
<carbon:copy-file />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { LogEntry, JSONObject } from "@/models/LogEntry";
|
||||
|
||||
const { message } = defineProps<{
|
||||
message: () => string;
|
||||
logEntry: LogEntry<string | JSONObject>;
|
||||
}>();
|
||||
|
||||
const { showToast } = useToast();
|
||||
const { copy, isSupported, copied } = useClipboard();
|
||||
const { t } = useI18n();
|
||||
|
||||
async function copyLogMessageToClipBoard() {
|
||||
await copy(message());
|
||||
|
||||
if (copied.value) {
|
||||
showToast(
|
||||
{
|
||||
title: t("toasts.copied.title"),
|
||||
message: t("toasts.copied.message"),
|
||||
type: "info",
|
||||
},
|
||||
{ expire: 2000 },
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<Tag size="small" :std="std" class="items-start!">
|
||||
<Tag :std="std" class="items-start!">
|
||||
{{ std }}
|
||||
</Tag>
|
||||
</template>
|
||||
@@ -13,7 +13,7 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
@reference "@/main.css";
|
||||
[std="stdout"] {
|
||||
@apply text-blue;
|
||||
}
|
||||
|
||||
@@ -6,37 +6,13 @@
|
||||
import { type JSONObject, LogEntry } from "@/models/LogEntry";
|
||||
|
||||
const props = defineProps<{
|
||||
messages: LogEntry<string | JSONObject>[];
|
||||
messages: LogEntry<string | string[] | JSONObject>[];
|
||||
visibleKeys: Map<string[], boolean>;
|
||||
}>();
|
||||
|
||||
const { messages, visibleKeys } = toRefs(props);
|
||||
|
||||
const { filteredPayload } = useVisibleFilter(visibleKeys);
|
||||
const { debouncedSearchFilter } = useSearchFilter();
|
||||
const { streamConfig } = useLoggingContext();
|
||||
|
||||
const visibleMessages = filteredPayload(messages);
|
||||
const router = useRouter();
|
||||
|
||||
watchEffect(() => {
|
||||
const query = {} as Record<string, string>;
|
||||
if (debouncedSearchFilter.value !== "") {
|
||||
query.search = debouncedSearchFilter.value;
|
||||
}
|
||||
|
||||
if (!streamConfig.value.stderr) {
|
||||
query.stderr = streamConfig.value.stderr.toString();
|
||||
}
|
||||
|
||||
if (!streamConfig.value.stdout) {
|
||||
query.stdout = streamConfig.value.stdout.toString();
|
||||
}
|
||||
|
||||
router.push({
|
||||
query,
|
||||
replace: true,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
<template>
|
||||
<div class="dropdown dropdown-end dropdown-hover">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm gap-0.5 px-2">
|
||||
<carbon:circle-solid class="text-red w-2.5" v-if="streamConfig.stderr" />
|
||||
<carbon:circle-solid class="text-blue w-2.5" v-if="streamConfig.stdout" />
|
||||
<div class="dropdown dropdown-end dropdown-hover z-20">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm w-8 gap-0 px-0 md:gap-0.5">
|
||||
<carbon:circle-solid class="text-red w-2 md:w-2.5" v-if="streamConfig.stderr" />
|
||||
<carbon:circle-solid class="text-blue w-2 md:w-2.5" v-if="streamConfig.stdout" />
|
||||
</label>
|
||||
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 z-50 w-52 p-1 shadow-sm">
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 shadow-sm"
|
||||
@click="hideMenu"
|
||||
>
|
||||
<li>
|
||||
<a @click.prevent="clear()">
|
||||
<a @click="clear()">
|
||||
<octicon:trash-24 /> {{ $t("toolbar.clear") }}
|
||||
<KeyShortcut char="k" :modifiers="['shift', 'meta']" />
|
||||
<KeyShortcut char="l" :modifiers="['shift', 'meta']" />
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="enableDownload">
|
||||
<a :href="downloadUrl" download>
|
||||
<octicon:download-24 />
|
||||
{{ isFiltered ? $t("toolbar.download-filtered") : $t("toolbar.download") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a :href="downloadUrl" download> <octicon:download-24 /> {{ $t("toolbar.download") }} </a>
|
||||
</li>
|
||||
<li>
|
||||
<a @click.prevent="showSearch = true">
|
||||
<a @click="showSearch = true">
|
||||
<mdi:magnify /> {{ $t("toolbar.search") }}
|
||||
<KeyShortcut char="f" />
|
||||
</a>
|
||||
@@ -84,26 +91,28 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
const { showSearch } = useSearchFilter();
|
||||
|
||||
const { enableDownload } = config;
|
||||
const clear = defineEmit();
|
||||
|
||||
const { streamConfig, showHostname, showContainerName, containers } = useLoggingContext();
|
||||
const { name } = defineProps<{ name?: string }>();
|
||||
|
||||
const downloadParams = computed(() =>
|
||||
Object.entries(toValue(streamConfig))
|
||||
.filter(([, value]) => value)
|
||||
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {}),
|
||||
);
|
||||
const { streamConfig, showHostname, showContainerName, containers, levels } = useLoggingContext();
|
||||
|
||||
const downloadUrl = computed(() =>
|
||||
withBase(
|
||||
`/api/containers/${containers.value.map((c) => c.host + "~" + c.id).join(",")}/download?${new URLSearchParams(downloadParams.value).toString()}`,
|
||||
),
|
||||
);
|
||||
const { downloadUrl, isFiltered } = useDownloadUrl(containers, streamConfig, levels, name);
|
||||
|
||||
const hideMenu = (e: MouseEvent) => {
|
||||
if (e.target instanceof HTMLAnchorElement) {
|
||||
setTimeout(() => {
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
@reference "@/main.css";
|
||||
li.line {
|
||||
@apply bg-base-content/20 h-px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { defineComponent, h, nextTick, ref } from "vue";
|
||||
import { Container, Stat } from "@/models/Container";
|
||||
import MultiContainerStat from "./MultiContainerStat.vue";
|
||||
|
||||
vi.mock("@/stores/config", () => ({
|
||||
__esModule: true,
|
||||
default: { hosts: [], base: "" },
|
||||
withBase: (path: string) => path,
|
||||
}));
|
||||
|
||||
// Capture recalculate() across both chart instances.
|
||||
const recalculate = vi.fn();
|
||||
const BarChartStub = defineComponent({
|
||||
name: "BarChart",
|
||||
props: ["chartData", "barClass"],
|
||||
setup(_, { expose }) {
|
||||
expose({ recalculate });
|
||||
return () => h("div", { class: "bar-chart-stub" });
|
||||
},
|
||||
});
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: "en", missingWarn: false, fallbackWarn: false, messages: { en: {} } });
|
||||
|
||||
function stat(cpu: number): Stat {
|
||||
return {
|
||||
cpu,
|
||||
memory: cpu,
|
||||
memoryUsage: cpu,
|
||||
networkRxTotal: 0,
|
||||
networkTxTotal: 0,
|
||||
diskReadTotal: 0,
|
||||
diskWriteTotal: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function makeContainer(id: string, cpu: number): Container {
|
||||
const stats = Array.from({ length: 10 }, () => stat(cpu));
|
||||
const now = new Date();
|
||||
return new Container(id, now, now, now, "img", id, "cmd", "host1", {}, "running", 0, 0, stats);
|
||||
}
|
||||
|
||||
describe("<MultiContainerStat />", () => {
|
||||
test("recalculates the charts when the container changes", async () => {
|
||||
// Mirror production: a parent holding a ref re-renders with a fresh
|
||||
// [container] array on switch. (VueTestUtils setProps cannot trigger this
|
||||
// because Container instances carry refs, which defeats prop change
|
||||
// detection on a direct prop assignment.)
|
||||
const current = ref<Container>(makeContainer("a", 10));
|
||||
const Parent = defineComponent({
|
||||
setup: () => () => h(MultiContainerStat as any, { containers: [current.value] }),
|
||||
});
|
||||
mount(Parent, {
|
||||
global: { plugins: [i18n], stubs: { BarChart: BarChartStub, IOCard: true } },
|
||||
});
|
||||
await flushPromises();
|
||||
recalculate.mockClear();
|
||||
|
||||
// Switching containers replaces the whole stats series; the parent must
|
||||
// force the cached charts to fully recompute.
|
||||
current.value = makeContainer("b", 90);
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
|
||||
expect(recalculate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,96 +1,207 @@
|
||||
<template>
|
||||
<div class="flex gap-4">
|
||||
<StatMonitor
|
||||
:data="memoryData"
|
||||
label="mem"
|
||||
:stat-value="formatBytes(totalStat.memoryUsage)"
|
||||
:limit="formatBytes(limits.memory, { short: true, decimals: 1 })"
|
||||
/>
|
||||
<StatMonitor
|
||||
:data="cpuData"
|
||||
label="load"
|
||||
:stat-value="Math.max(0, totalStat.cpu).toFixed(2) + '%'"
|
||||
:limit="roundCPU(limits.cpu) + ' CPU'"
|
||||
<div class="flex items-stretch gap-2.5">
|
||||
<IOCard
|
||||
:network-rx="networkRate.rx"
|
||||
:network-tx="networkRate.tx"
|
||||
:disk-read="diskRate.read"
|
||||
:disk-write="diskRate.write"
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
:icon="PhCpu"
|
||||
card-class="bg-primary/10 md:min-w-56"
|
||||
icon-class="text-primary"
|
||||
:title="t('tooltip.cpu-usage', { cpu: totalStat.cpu.toFixed(2), cores: roundCPU(limits.cpu) })"
|
||||
>
|
||||
<template #value="{ hoveredValue }">
|
||||
<span class="tabular-nums">
|
||||
<span class="font-semibold"> {{ Math.max(0, hoveredValue ?? totalStat.cpu).toFixed(1) }}% </span>
|
||||
<span class="text-base-content/60 max-md:hidden"> / {{ roundCPU(limits.cpu) }} CPU</span>
|
||||
</span>
|
||||
</template>
|
||||
<template #chart="{ onHoverValue }">
|
||||
<BarChart
|
||||
ref="cpuChart"
|
||||
:chart-data="cpuData"
|
||||
bar-class="bg-primary opacity-80 hover:opacity-100"
|
||||
class="h-5 w-full max-md:hidden"
|
||||
@hover-value="onHoverValue"
|
||||
/>
|
||||
</template>
|
||||
</StatCard>
|
||||
|
||||
<StatCard
|
||||
:icon="PhMemory"
|
||||
card-class="bg-secondary/10 md:min-w-56"
|
||||
icon-class="text-secondary"
|
||||
:title="
|
||||
t('tooltip.memory-usage', { used: formatBytes(totalStat.memoryUsage), total: formatBytes(limits.memory) })
|
||||
"
|
||||
>
|
||||
<template #value="{ hoveredValue }">
|
||||
<span class="tabular-nums">
|
||||
<span class="font-semibold">{{
|
||||
formatBytes(hoveredValue ?? totalStat.memoryUsage, { short: true, decimals: 1 })
|
||||
}}</span>
|
||||
<span class="text-base-content/60 max-md:hidden">
|
||||
/ {{ formatBytes(limits.memory, { short: true, decimals: 1 }) }}</span
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
<template #chart="{ onHoverValue }">
|
||||
<BarChart
|
||||
ref="memoryChart"
|
||||
:chart-data="memoryData"
|
||||
bar-class="bg-secondary opacity-80 hover:opacity-100"
|
||||
class="h-5 w-full max-md:hidden"
|
||||
@hover-value="onHoverValue"
|
||||
/>
|
||||
</template>
|
||||
</StatCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Stat } from "@/models/Container";
|
||||
import { Container } from "@/models/Container";
|
||||
import { Container, Stat, emptyStat } from "@/models/Container";
|
||||
import StatCard from "@/components/LogViewer/StatCard.vue";
|
||||
import IOCard from "@/components/LogViewer/IOCard.vue";
|
||||
import BarChart from "@/components/BarChart.vue";
|
||||
import PhCpu from "~icons/ph/cpu";
|
||||
import PhMemory from "~icons/ph/memory";
|
||||
|
||||
const { containers } = defineProps<{
|
||||
containers: Container[];
|
||||
}>();
|
||||
|
||||
const totalStat = ref<Stat>({ cpu: 0, memory: 0, memoryUsage: 0 });
|
||||
const { t } = useI18n();
|
||||
|
||||
const totalStat = ref<Stat>(emptyStat());
|
||||
const { history, reset } = useSimpleRefHistory(totalStat, { capacity: 300 });
|
||||
const { hosts } = useHosts();
|
||||
const cpuChart = useTemplateRef("cpuChart");
|
||||
const memoryChart = useTemplateRef("memoryChart");
|
||||
const networkRate = ref({ rx: 0, tx: 0 });
|
||||
const diskRate = ref({ read: 0, write: 0 });
|
||||
|
||||
const roundCPU = (num: number) => (Number.isInteger(num) ? num.toFixed(0) : num.toFixed(1));
|
||||
|
||||
function toContainerCores(container: Container): number {
|
||||
if (container.cpuLimit && container.cpuLimit > 0) {
|
||||
return container.cpuLimit;
|
||||
}
|
||||
const hostInfo = hosts.value[container.host];
|
||||
return hostInfo?.nCPU ?? 1;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => containers,
|
||||
() => {
|
||||
const initial: Stat[] = [];
|
||||
for (let i = 1; i <= 300; i++) {
|
||||
const stat = containers.reduce(
|
||||
(acc, { statsHistory }) => {
|
||||
const item = statsHistory.at(-i);
|
||||
if (!item) {
|
||||
return acc;
|
||||
}
|
||||
return {
|
||||
cpu: acc.cpu + item.cpu,
|
||||
memory: acc.memory + item.memory,
|
||||
memoryUsage: acc.memoryUsage + item.memoryUsage,
|
||||
};
|
||||
},
|
||||
{ cpu: 0, memory: 0, memoryUsage: 0 },
|
||||
);
|
||||
const stat = containers.reduce((acc, container) => {
|
||||
const item = container.statsHistory.at(-i);
|
||||
if (!item) {
|
||||
return acc;
|
||||
}
|
||||
const cores = toContainerCores(container);
|
||||
return {
|
||||
cpu: acc.cpu + item.cpu / cores,
|
||||
memory: acc.memory + item.memory,
|
||||
memoryUsage: acc.memoryUsage + item.memoryUsage,
|
||||
networkRxTotal: acc.networkRxTotal + item.networkRxTotal,
|
||||
networkTxTotal: acc.networkTxTotal + item.networkTxTotal,
|
||||
diskReadTotal: acc.diskReadTotal + item.diskReadTotal,
|
||||
diskWriteTotal: acc.diskWriteTotal + item.diskWriteTotal,
|
||||
};
|
||||
}, emptyStat());
|
||||
initial.push(stat);
|
||||
}
|
||||
totalStat.value = initial[0];
|
||||
reset({ initial: initial.reverse() });
|
||||
// Charts cache their downsampled bars and only patch the last bar per tick;
|
||||
// a container switch replaces the whole series, so force a full recalculate.
|
||||
nextTick(() => {
|
||||
cpuChart.value?.recalculate();
|
||||
memoryChart.value?.recalculate();
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const limits = computed(() =>
|
||||
containers.reduce(
|
||||
(acc, c) => ({
|
||||
cpu: acc.cpu + (c.cpuLimit > 0 ? c.cpuLimit : hosts.value[c.host].nCPU),
|
||||
memory: acc.memory + (c.memoryLimit > 0 ? c.memoryLimit : hosts.value[c.host].memTotal),
|
||||
}),
|
||||
{ cpu: 0, memory: 0 },
|
||||
),
|
||||
);
|
||||
const limits = computed(() => {
|
||||
const containersByHost = new Map<string, Container[]>();
|
||||
containers.forEach((container) => {
|
||||
if (!containersByHost.has(container.host)) {
|
||||
containersByHost.set(container.host, []);
|
||||
}
|
||||
containersByHost.get(container.host)!.push(container);
|
||||
});
|
||||
|
||||
let totalCpu = 0;
|
||||
let totalMemory = 0;
|
||||
|
||||
containersByHost.forEach((hostContainers, hostId) => {
|
||||
const hostInfo = hosts.value[hostId];
|
||||
const hostTotalMemory = hostInfo?.memTotal || 0;
|
||||
const hostTotalCpu = hostInfo?.nCPU || 0;
|
||||
|
||||
const hasUnlimitedCpu = hostContainers.some((c) => !c.cpuLimit || c.cpuLimit <= 0);
|
||||
const hasUnlimitedMemory = hostContainers.some((c) => !c.memoryLimit);
|
||||
|
||||
if (hasUnlimitedCpu) {
|
||||
totalCpu += hostTotalCpu;
|
||||
} else {
|
||||
const sumCpu = hostContainers.reduce((sum, c) => sum + (c.cpuLimit || 0), 0);
|
||||
totalCpu += Math.min(sumCpu, hostTotalCpu);
|
||||
}
|
||||
|
||||
if (hasUnlimitedMemory) {
|
||||
totalMemory += hostTotalMemory;
|
||||
} else {
|
||||
const sumMemory = hostContainers.reduce((sum, c) => sum + (c.memoryLimit || 0), 0);
|
||||
totalMemory += Math.min(sumMemory, hostTotalMemory);
|
||||
}
|
||||
});
|
||||
|
||||
return { cpu: totalCpu, memory: totalMemory };
|
||||
});
|
||||
|
||||
useIntervalFn(() => {
|
||||
totalStat.value = containers.reduce(
|
||||
(acc, { stat }) => {
|
||||
return {
|
||||
cpu: acc.cpu + stat.cpu,
|
||||
memory: acc.memory + stat.memory,
|
||||
memoryUsage: acc.memoryUsage + stat.memoryUsage,
|
||||
};
|
||||
},
|
||||
{ cpu: 0, memory: 0, memoryUsage: 0 },
|
||||
);
|
||||
const previousStat = totalStat.value;
|
||||
totalStat.value = containers.reduce((acc, container) => {
|
||||
const cores = toContainerCores(container);
|
||||
return {
|
||||
cpu: acc.cpu + container.stat.cpu / cores,
|
||||
memory: acc.memory + container.stat.memory,
|
||||
memoryUsage: acc.memoryUsage + container.stat.memoryUsage,
|
||||
networkRxTotal: acc.networkRxTotal + container.stat.networkRxTotal,
|
||||
networkTxTotal: acc.networkTxTotal + container.stat.networkTxTotal,
|
||||
diskReadTotal: acc.diskReadTotal + container.stat.diskReadTotal,
|
||||
diskWriteTotal: acc.diskWriteTotal + container.stat.diskWriteTotal,
|
||||
};
|
||||
}, emptyStat());
|
||||
|
||||
networkRate.value = {
|
||||
rx: Math.max(0, totalStat.value.networkRxTotal - previousStat.networkRxTotal),
|
||||
tx: Math.max(0, totalStat.value.networkTxTotal - previousStat.networkTxTotal),
|
||||
};
|
||||
diskRate.value = {
|
||||
read: Math.max(0, totalStat.value.diskReadTotal - previousStat.diskReadTotal),
|
||||
write: Math.max(0, totalStat.value.diskWriteTotal - previousStat.diskWriteTotal),
|
||||
};
|
||||
}, 1000);
|
||||
|
||||
const cpuData = computed(() =>
|
||||
history.value.map((stat, i) => ({
|
||||
x: i,
|
||||
y: Math.max(0, stat.cpu),
|
||||
value: Math.max(0, stat.cpu).toFixed(2) + "%",
|
||||
history.value.map((stat) => ({
|
||||
percent: Math.max(0, stat.cpu),
|
||||
value: Math.max(0, stat.cpu),
|
||||
})),
|
||||
);
|
||||
|
||||
const memoryData = computed(() =>
|
||||
history.value.map((stat, i) => ({
|
||||
x: i,
|
||||
y: stat.memoryUsage,
|
||||
value: formatBytes(stat.memoryUsage),
|
||||
history.value.map((stat) => ({
|
||||
percent: stat.memory,
|
||||
value: stat.memoryUsage,
|
||||
})),
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="tag grid w-40 overflow-hidden rounded-sm text-center text-sm text-white">
|
||||
<div class="tag grid overflow-hidden rounded-sm text-center text-sm text-white">
|
||||
<div class="random-color col-start-1 row-start-1 brightness-75"></div>
|
||||
<div class="col-start-1 row-start-1 truncate px-2 brightness-100" :class="truncateRight ? '[direction:rtl]' : ''">
|
||||
{{ value }}
|
||||
<slot>{{ value }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,28 +1,39 @@
|
||||
<template>
|
||||
<table class="table-zebra table-pin-rows table-md table" v-if="!loading">
|
||||
<div class="w-full overflow-x-auto" v-if="!loading">
|
||||
<table class="table-zebra table-pin-rows table-md table" v-if="columns.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in columns" :key="column" class="font-mono">{{ column }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in table" :key="index">
|
||||
<td v-for="column in columns" :key="column" class="max-w-md align-top">
|
||||
<span v-if="format(row[column]) === null" class="text-base-content/30 italic">NULL</span>
|
||||
<span v-else class="block truncate font-mono" :title="format(row[column]) ?? undefined">{{
|
||||
format(row[column])
|
||||
}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="text-base-content/50 flex flex-col items-center gap-2 py-16">
|
||||
<ph:database class="size-8 opacity-40" />
|
||||
<span>{{ $t("analytics.no_results") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<table class="table-md table" v-else>
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in columns" :key="column">{{ column }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in table" :key="row">
|
||||
<td v-for="column in columns" :key="column">{{ row[column] }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table class="table-md table animate-pulse" v-else>
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="_ in 3">
|
||||
<th v-for="i in 3" :key="i">
|
||||
<div class="bg-base-content/50 h-4 w-20 animate-pulse opacity-50"></div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="_ in 9">
|
||||
<td v-for="_ in 3">
|
||||
<div class="bg-base-content/50 h-4 w-20 opacity-20"></div>
|
||||
<tr v-for="i in 9" :key="i">
|
||||
<td v-for="j in 3" :key="j">
|
||||
<div class="bg-base-content/50 h-4 w-20 animate-pulse opacity-20"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -37,4 +48,17 @@ const { loading, table } = defineProps<{
|
||||
}>();
|
||||
|
||||
const columns = computed(() => (table.numRows > 0 ? Object.keys(table.get(0) as Record<string, any>) : []));
|
||||
|
||||
function format(value: unknown): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === "bigint") return value.toString();
|
||||
if (typeof value === "object") {
|
||||
try {
|
||||
return JSON.stringify(value, (_, v) => (typeof v === "bigint" ? v.toString() : v));
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { nextTick } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import SearchStatus from "./SearchStatus.vue";
|
||||
import IndeterminateBar from "@/components/common/IndeterminateBar.vue";
|
||||
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: "en",
|
||||
messages: {
|
||||
en: {
|
||||
label: {
|
||||
"search-status": {
|
||||
searching: "Searching older logs…",
|
||||
"searching-to": "Searching older logs… back to {time}",
|
||||
capped: "{count} matches · searched back to {time}",
|
||||
exhausted: "Searched all logs · {count} matches",
|
||||
empty: "No matches · searched all logs",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function createStatus(overrides: Record<string, unknown> = {}) {
|
||||
return mount(SearchStatus, {
|
||||
global: { plugins: [i18n] },
|
||||
props: {
|
||||
status: { active: false, done: false, matches: 0, scannedTo: undefined, reason: undefined, ...overrides },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("<SearchStatus />", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("stays hidden while a search is active but still fast (flash avoidance)", async () => {
|
||||
const wrapper = createStatus({ active: true });
|
||||
vi.advanceTimersByTime(100);
|
||||
await nextTick();
|
||||
expect(wrapper.find("[data-state]").exists()).toBe(false);
|
||||
});
|
||||
|
||||
test("shows searching state once a search runs past the reveal delay", async () => {
|
||||
const wrapper = createStatus({ active: true });
|
||||
vi.advanceTimersByTime(400);
|
||||
await nextTick();
|
||||
expect(wrapper.find('[data-state="searching"]').exists()).toBe(true);
|
||||
expect(wrapper.findComponent(IndeterminateBar).exists()).toBe(true);
|
||||
});
|
||||
|
||||
test("reveals the searching bar even when progress events arrive faster than the delay", async () => {
|
||||
const wrapper = createStatus({ active: true });
|
||||
// a slow search emits a progress event each window; the reveal delay must
|
||||
// measure from when the search started, not restart on every event
|
||||
for (let i = 0; i < 5; i++) {
|
||||
vi.advanceTimersByTime(100);
|
||||
await wrapper.setProps({ status: { active: true, done: false, matches: i, scannedTo: `t${i}` } });
|
||||
await nextTick();
|
||||
}
|
||||
expect(wrapper.find('[data-state="searching"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test("shows the empty state when a search finishes with no matches", async () => {
|
||||
const wrapper = createStatus({ active: false, done: true, matches: 0, reason: "exhausted" });
|
||||
await nextTick();
|
||||
expect(wrapper.find('[data-state="empty"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test("shows a completion summary for a slow exhausted search", async () => {
|
||||
const wrapper = createStatus({ active: true });
|
||||
vi.advanceTimersByTime(400);
|
||||
await nextTick();
|
||||
await wrapper.setProps({ status: { active: false, done: true, matches: 3, reason: "exhausted" } });
|
||||
await nextTick();
|
||||
expect(wrapper.find('[data-state="exhausted"]').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain("3");
|
||||
});
|
||||
|
||||
test("shows a capped summary for a slow capped search", async () => {
|
||||
const wrapper = createStatus({ active: true });
|
||||
vi.advanceTimersByTime(400);
|
||||
await nextTick();
|
||||
await wrapper.setProps({
|
||||
status: { active: false, done: true, matches: 50, reason: "capped", scannedTo: "2026-06-01T13:10:00Z" },
|
||||
});
|
||||
await nextTick();
|
||||
expect(wrapper.find('[data-state="capped"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test("stays quiet for a fast search that returned matches", async () => {
|
||||
const wrapper = createStatus({ active: true });
|
||||
vi.advanceTimersByTime(100);
|
||||
await nextTick();
|
||||
await wrapper.setProps({ status: { active: false, done: true, matches: 5, reason: "capped" } });
|
||||
await nextTick();
|
||||
expect(wrapper.find("[data-state]").exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="state"
|
||||
:data-state="state"
|
||||
class="bg-base-200/80 text-base-content/70 flex items-center gap-2 px-4 py-1.5 text-xs backdrop-blur"
|
||||
>
|
||||
<template v-if="state === 'searching'">
|
||||
<span>{{
|
||||
status.scannedTo ? $t("label.search-status.searching-to", { time }) : $t("label.search-status.searching")
|
||||
}}</span>
|
||||
<IndeterminateBar color="primary" class="ml-auto" />
|
||||
</template>
|
||||
<span v-else-if="state === 'empty'">{{ $t("label.search-status.empty") }}</span>
|
||||
<span v-else-if="state === 'capped'" class="tabular-nums">
|
||||
{{ $t("label.search-status.capped", { count: status.matches, time }) }}
|
||||
</span>
|
||||
<span v-else-if="state === 'exhausted'" class="tabular-nums">
|
||||
{{ $t("label.search-status.exhausted", { count: status.matches }) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { type SearchStatus } from "@/composable/eventStreams";
|
||||
|
||||
const props = defineProps<{ status: SearchStatus }>();
|
||||
|
||||
// Reveal the in-progress bar only after a short delay so fast searches (the
|
||||
// common case, which return almost instantly) never flash it. Slow searches
|
||||
// — sparse matches over a large log — are the only ones that surface it.
|
||||
const showSearching = ref(false);
|
||||
// Remember whether this search ever ran slow, so the completion summary only
|
||||
// shows for searches that actually made the user wait.
|
||||
const wasSlow = ref(false);
|
||||
|
||||
// Watch the boolean, not the whole status object: a slow search replaces the
|
||||
// status object on every progress event, and re-arming the timer each time
|
||||
// would keep the bar from ever appearing. The delay must measure from when the
|
||||
// search started.
|
||||
const active = computed(() => props.status.active);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
watch(
|
||||
active,
|
||||
(isActive) => {
|
||||
clearTimeout(timer);
|
||||
if (isActive) {
|
||||
timer = setTimeout(() => {
|
||||
showSearching.value = true;
|
||||
wasSlow.value = true;
|
||||
}, 400);
|
||||
} else {
|
||||
showSearching.value = false;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
onScopeDispose(() => clearTimeout(timer));
|
||||
|
||||
const time = computed(() => (props.status.scannedTo ? new Date(props.status.scannedTo).toLocaleString() : ""));
|
||||
|
||||
const state = computed<"searching" | "empty" | "capped" | "exhausted" | null>(() => {
|
||||
if (showSearching.value) return "searching";
|
||||
if (!props.status.active && props.status.done) {
|
||||
if (props.status.matches === 0) return "empty";
|
||||
if (wasSlow.value) return props.status.reason === "capped" ? "capped" : "exhausted";
|
||||
}
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
@@ -1,21 +1,15 @@
|
||||
<template>
|
||||
<LogItem :logEntry>
|
||||
<LogLevel class="flex select-none" :level="logEntry.level" />
|
||||
<div
|
||||
class="log-wrapper [word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap"
|
||||
v-html="linkify(colorize(logEntry.message))"
|
||||
class="[word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-pre"
|
||||
v-html="colorize(logEntry.message)"
|
||||
></div>
|
||||
<LogMessageActions
|
||||
class="absolute -right-1 opacity-0 transition-opacity delay-150 duration-250 group-hover/entry:opacity-100"
|
||||
:message="() => decodeXML(stripAnsi(logEntry.message))"
|
||||
:log-entry="logEntry"
|
||||
/>
|
||||
</LogItem>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { SimpleLogEntry } from "@/models/LogEntry";
|
||||
import { decodeXML } from "entities";
|
||||
import AnsiConvertor from "ansi-to-html";
|
||||
import stripAnsi from "strip-ansi";
|
||||
|
||||
const ansiConvertor = new AnsiConvertor({
|
||||
escapeXML: false,
|
||||
@@ -28,15 +22,4 @@ defineProps<{
|
||||
}>();
|
||||
|
||||
const colorize = (value: string) => ansiConvertor.toHtml(value);
|
||||
const urlPattern =
|
||||
/https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b[-a-zA-Z0-9()@:%_+.~#?&/=]*/g;
|
||||
const linkify = (text: string) =>
|
||||
text.replace(urlPattern, (url) => `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import "@/main.css" reference;
|
||||
.log-wrapper :deep(a) {
|
||||
@apply text-primary underline-offset-4 hover:underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
<div class="my-4 flex-1 text-center">
|
||||
<div class="relative">
|
||||
<ZigZag class="absolute inset-0 mt-2" />
|
||||
<span class="bg-base-200 relative px-4 py-2 font-bold whitespace-pre-wrap">
|
||||
<button class="btn btn-primary btn-xs relative whitespace-pre-wrap" @click="logEntry.loadSkippedEntries()">
|
||||
{{ $t("error.logs-skipped", { total: logEntry.totalSkipped }) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { SkippedLogsEntry } from "@/models/LogEntry";
|
||||
|
||||
defineProps<{
|
||||
const { logEntry } = defineProps<{
|
||||
logEntry: SkippedLogsEntry;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex min-w-0 flex-col gap-px rounded-md px-2.5 py-1"
|
||||
:class="cardClass"
|
||||
:title="title"
|
||||
@mouseleave="hoveredValue = null"
|
||||
>
|
||||
<div class="flex h-[18px] items-center gap-2 text-[12.5px] font-medium">
|
||||
<component :is="icon" class="size-3.5 shrink-0" :class="iconClass" />
|
||||
<slot name="value" :hoveredValue="hoveredValue" />
|
||||
</div>
|
||||
<slot name="chart" :onHoverValue="(v: number) => (hoveredValue = v)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Component } from "vue";
|
||||
|
||||
defineProps<{
|
||||
icon: Component;
|
||||
cardClass?: string;
|
||||
iconClass?: string;
|
||||
title?: string;
|
||||
}>();
|
||||
|
||||
const hoveredValue = ref<number | null>(null);
|
||||
</script>
|
||||
@@ -1,31 +0,0 @@
|
||||
<template>
|
||||
<div class="hover:text-secondary relative" @mouseenter="mouseOver = true" @mouseleave="mouseOver = false">
|
||||
<div class="border-primary overflow-hidden rounded-xs border px-px pt-1 pb-px max-md:hidden">
|
||||
<StatSparkline :data="data" @selected-point="onSelectedPoint" />
|
||||
</div>
|
||||
<div class="bg-base-200 inline-flex gap-1 rounded-sm p-px text-xs md:absolute md:-top-2 md:-left-0.5">
|
||||
<div class="font-light uppercase">{{ label }}</div>
|
||||
<div class="font-bold select-none">
|
||||
{{ mouseOver ? (selectedPoint?.value ?? selectedPoint?.y ?? statValue) : statValue }}
|
||||
<span v-if="limit !== -1 && !mouseOver" class="max-md:hidden"> / {{ limit }} </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const {
|
||||
data,
|
||||
label,
|
||||
statValue,
|
||||
limit = -1,
|
||||
} = defineProps<{
|
||||
data: Point<unknown>[];
|
||||
label: string;
|
||||
statValue: string | number;
|
||||
limit?: string | number;
|
||||
}>();
|
||||
const selectedPoint = ref<Point<unknown> | undefined>();
|
||||
const onSelectedPoint = (point: Point<unknown>) => (selectedPoint.value = point);
|
||||
const mouseOver = ref(false);
|
||||
</script>
|
||||
@@ -1,44 +0,0 @@
|
||||
<template>
|
||||
<svg :width="width" :height="height" @mousemove="onMove" class="group">
|
||||
<path :d="path" class="fill-primary" />
|
||||
<line :x1="lineX" y1="0" :x2="lineX" :y2="height" class="stroke-secondary invisible stroke-2 group-hover:visible" />
|
||||
</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 = 175, height = 30 } = defineProps<{ data: Point<unknown>[]; width?: number; height?: number }>();
|
||||
const x = d3.scaleLinear().range([0, width]);
|
||||
const y = d3.scaleLinear().range([height, 0]);
|
||||
|
||||
const selectedPoint = defineEmit<[value: Point<unknown>]>();
|
||||
|
||||
const shape = d3
|
||||
.area<Point<unknown>>()
|
||||
.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, { y: 1 }], (d) => d.y) as [number, number]);
|
||||
|
||||
return shape(data) ?? "";
|
||||
});
|
||||
|
||||
let lineX = $ref(0);
|
||||
|
||||
function onMove(e: MouseEvent) {
|
||||
const { offsetX } = e;
|
||||
const xValue = x.invert(offsetX);
|
||||
const index = Math.round(xValue);
|
||||
lineX = x(index);
|
||||
const point = data[index];
|
||||
selectedPoint(point);
|
||||
}
|
||||
</script>
|
||||
@@ -21,7 +21,7 @@ defineExpose({
|
||||
clear: () => source.value?.clear(),
|
||||
});
|
||||
|
||||
onKeyStroke("k", (e) => {
|
||||
onKeyStroke(["l", "L"], (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
|
||||
source.value?.clear();
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,73 +1,138 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<ContainerEventSource /> > render html correctly > should render dates with 12 hour style 1`] = `
|
||||
"<ul data-v-cf9ff940="" class="events group pt-4 medium" show-container-name="false">
|
||||
<li data-v-cf9ff940="" data-key="1" data-time="1560336942459" class="group/entry">
|
||||
<div data-v-a49e52d4="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
"<ul data-v-cf9ff940="" class="group pt-4 medium" data-logs="" show-container-name="false">
|
||||
<li data-v-cf9ff940="" id="1560336942709" data-time="1560336942709" class="group/entry">
|
||||
<div data-v-cf9ff940="" class="flex min-h-[1px] flex-1 content-center justify-center"><span class="loading loading-bars loading-md text-primary m-2" style="display: none;"></span></div>
|
||||
</li>
|
||||
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
|
||||
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><button tabindex="0" class="btn btn-square btn-xs border-base-content/20 bg-base-100 border opacity-0 shadow-sm group-hover/entry:opacity-90"><svg viewBox="0 0 512 512" width="1.2em" height="1.2em">
|
||||
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
|
||||
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
|
||||
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
|
||||
</svg></button>
|
||||
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm">
|
||||
<!--v-if-->
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"></path>
|
||||
</svg> action.copy-log</a></li>
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
|
||||
</svg> action.copy-link</a></li>
|
||||
<!--v-if-->
|
||||
<li><a><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M21 19v1H3v-1l2-2v-6c0-3.1 2.03-5.83 5-6.71V4a2 2 0 0 1 2-2a2 2 0 0 1 2 2v.29c2.97.88 5 3.61 5 6.71v6zm-7 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2"></path>
|
||||
</svg> action.create-alert</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
<div class="tag bg-base-100 inline-flex items-center justify-center rounded-sm px-2 py-[0.2em] [[size='small']]:text-[0.8rem] items-start! shrink-0 select-none" size="small">
|
||||
<div class="inline-flex gap-2 text-blue whitespace-nowrap"><time datetime="2019-06-12T10:55:42.459Z" class="max-md:hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42 AM</time></div>
|
||||
<div class="flex gap-x-2 gap-y-1 group-[.compact]:gap-y-0 has-[>_*:nth-of-type(2)]:flex-col-reverse md:flex-row!">
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
<div class="tag bg-base-100 inline-flex items-center justify-center rounded-sm px-2 py-[0.2em] [[size='small']]:text-[0.8rem] items-start! shrink-0 select-none">
|
||||
<div class="inline-flex gap-2 text-blue whitespace-nowrap"><time datetime="2019-06-12T10:55:42.459Z" class="max-md:hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42 AM</time></div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex select-none"></div>
|
||||
<div data-v-a49e52d4="" class="log-wrapper [word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap">foo bar</div>
|
||||
<div data-v-a49e52d4="" class="flex gap-2 absolute -right-1 opacity-0 transition-opacity delay-150 duration-250 group-hover/entry:opacity-100">
|
||||
<!--v-if-->
|
||||
</div>
|
||||
<div class="[word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-pre">foo bar</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<ContainerEventSource /> > render html correctly > should render dates with 24 hour style 1`] = `
|
||||
"<ul data-v-cf9ff940="" class="events group pt-4 medium" show-container-name="false">
|
||||
<li data-v-cf9ff940="" data-key="1" data-time="1560336942459" class="group/entry">
|
||||
<div data-v-a49e52d4="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
"<ul data-v-cf9ff940="" class="group pt-4 medium" data-logs="" show-container-name="false">
|
||||
<li data-v-cf9ff940="" id="1560336942709" data-time="1560336942709" class="group/entry">
|
||||
<div data-v-cf9ff940="" class="flex min-h-[1px] flex-1 content-center justify-center"><span class="loading loading-bars loading-md text-primary m-2" style="display: none;"></span></div>
|
||||
</li>
|
||||
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
|
||||
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><button tabindex="0" class="btn btn-square btn-xs border-base-content/20 bg-base-100 border opacity-0 shadow-sm group-hover/entry:opacity-90"><svg viewBox="0 0 512 512" width="1.2em" height="1.2em">
|
||||
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
|
||||
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
|
||||
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
|
||||
</svg></button>
|
||||
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm">
|
||||
<!--v-if-->
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"></path>
|
||||
</svg> action.copy-log</a></li>
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
|
||||
</svg> action.copy-link</a></li>
|
||||
<!--v-if-->
|
||||
<li><a><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M21 19v1H3v-1l2-2v-6c0-3.1 2.03-5.83 5-6.71V4a2 2 0 0 1 2-2a2 2 0 0 1 2 2v.29c2.97.88 5 3.61 5 6.71v6zm-7 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2"></path>
|
||||
</svg> action.create-alert</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
<div class="tag bg-base-100 inline-flex items-center justify-center rounded-sm px-2 py-[0.2em] [[size='small']]:text-[0.8rem] items-start! shrink-0 select-none" size="small">
|
||||
<div class="inline-flex gap-2 text-blue whitespace-nowrap"><time datetime="2019-06-12T10:55:42.459Z" class="max-md:hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42</time></div>
|
||||
<div class="flex gap-x-2 gap-y-1 group-[.compact]:gap-y-0 has-[>_*:nth-of-type(2)]:flex-col-reverse md:flex-row!">
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
<div class="tag bg-base-100 inline-flex items-center justify-center rounded-sm px-2 py-[0.2em] [[size='small']]:text-[0.8rem] items-start! shrink-0 select-none">
|
||||
<div class="inline-flex gap-2 text-blue whitespace-nowrap"><time datetime="2019-06-12T10:55:42.459Z" class="max-md:hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42</time></div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex select-none"></div>
|
||||
<div data-v-a49e52d4="" class="log-wrapper [word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap">foo bar</div>
|
||||
<div data-v-a49e52d4="" class="flex gap-2 absolute -right-1 opacity-0 transition-opacity delay-150 duration-250 group-hover/entry:opacity-100">
|
||||
<!--v-if-->
|
||||
</div>
|
||||
<div class="[word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-pre">foo bar</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<ContainerEventSource /> > render html correctly > should render messages 1`] = `
|
||||
"<ul data-v-cf9ff940="" class="events group pt-4 medium" show-container-name="false">
|
||||
<li data-v-cf9ff940="" data-key="1" data-time="1560336942459" class="group/entry">
|
||||
<div data-v-a49e52d4="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
"<ul data-v-cf9ff940="" class="group pt-4 medium" data-logs="" show-container-name="false">
|
||||
<li data-v-cf9ff940="" id="1560336942709" data-time="1560336942709" class="group/entry">
|
||||
<div data-v-cf9ff940="" class="flex min-h-[1px] flex-1 content-center justify-center"><span class="loading loading-bars loading-md text-primary m-2" style="display: none;"></span></div>
|
||||
</li>
|
||||
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
|
||||
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
|
||||
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><button tabindex="0" class="btn btn-square btn-xs border-base-content/20 bg-base-100 border opacity-0 shadow-sm group-hover/entry:opacity-90"><svg viewBox="0 0 512 512" width="1.2em" height="1.2em">
|
||||
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
|
||||
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
|
||||
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
|
||||
</svg></button>
|
||||
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm">
|
||||
<!--v-if-->
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"></path>
|
||||
</svg> action.copy-log</a></li>
|
||||
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
|
||||
</svg> action.copy-link</a></li>
|
||||
<!--v-if-->
|
||||
<li><a><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M21 19v1H3v-1l2-2v-6c0-3.1 2.03-5.83 5-6.71V4a2 2 0 0 1 2-2a2 2 0 0 1 2 2v.29c2.97.88 5 3.61 5 6.71v6zm-7 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2"></path>
|
||||
</svg> action.create-alert</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
<div class="tag bg-base-100 inline-flex items-center justify-center rounded-sm px-2 py-[0.2em] [[size='small']]:text-[0.8rem] items-start! shrink-0 select-none" size="small">
|
||||
<div class="inline-flex gap-2 text-blue whitespace-nowrap"><time datetime="2019-06-12T10:55:42.459Z" class="max-md:hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42 AM</time></div>
|
||||
<div class="flex gap-x-2 gap-y-1 group-[.compact]:gap-y-0 has-[>_*:nth-of-type(2)]:flex-col-reverse md:flex-row!">
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
<div class="tag bg-base-100 inline-flex items-center justify-center rounded-sm px-2 py-[0.2em] [[size='small']]:text-[0.8rem] items-start! shrink-0 select-none">
|
||||
<div class="inline-flex gap-2 text-blue whitespace-nowrap"><time datetime="2019-06-12T10:55:42.459Z" class="max-md:hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42 AM</time></div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex select-none"></div>
|
||||
<div data-v-a49e52d4="" class="log-wrapper [word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap">This is a message.</div>
|
||||
<div data-v-a49e52d4="" class="flex gap-2 absolute -right-1 opacity-0 transition-opacity delay-150 duration-250 group-hover/entry:opacity-100">
|
||||
<!--v-if-->
|
||||
</div>
|
||||
<div class="[word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-pre">This is a message.</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<ContainerEventSource /> > should parse messages 1`] = `
|
||||
SimpleLogEntry {
|
||||
"_message": "This is a message.",
|
||||
"containerID": undefined,
|
||||
"date": 2019-06-12T10:55:42.459Z,
|
||||
"id": 1,
|
||||
LoadMoreLogEntry {
|
||||
"_message": "",
|
||||
"containerID": "",
|
||||
"date": 2019-06-12T10:55:42.709Z,
|
||||
"id": 1560336942709,
|
||||
"level": undefined,
|
||||
"position": undefined,
|
||||
"loader": [Function],
|
||||
"rawMessage": "info",
|
||||
"rememberScrollPosition": true,
|
||||
"std": "stderr",
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="rounded-lg p-2 md:p-3" :class="containerClass">
|
||||
<div class="mb-2 flex items-center gap-1.5 text-sm font-medium" :class="textClass">
|
||||
<component :is="icon" class="text-lg" />
|
||||
<span>{{ label }}</span>
|
||||
</div>
|
||||
<div class="mb-1.5 text-lg font-semibold tabular-nums">{{ formattedValue }}</div>
|
||||
<div class="text-base-content/60 mb-1 text-xs tabular-nums max-md:hidden">
|
||||
avg {{ formatValue(average) }} • pk {{ formatValue(peak) }}
|
||||
</div>
|
||||
<BarChart class="h-8" :chart-data="chartData" :bar-class="barClass" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from "vue";
|
||||
import type { BarDataPoint } from "@/components/BarChart.vue";
|
||||
|
||||
const {
|
||||
label,
|
||||
icon,
|
||||
value,
|
||||
chartData,
|
||||
containerClass = "",
|
||||
textClass = "",
|
||||
barClass = "",
|
||||
formatValue = (v: number) => v.toString(),
|
||||
} = defineProps<{
|
||||
label: string;
|
||||
icon: Component;
|
||||
value: string | number;
|
||||
chartData: BarDataPoint[];
|
||||
containerClass?: string;
|
||||
textClass?: string;
|
||||
barClass?: string;
|
||||
formatValue?: (value: number) => string;
|
||||
}>();
|
||||
|
||||
const peak = computed(() => (chartData.length > 0 ? Math.max(...chartData.map((d) => d.value)) : 0));
|
||||
|
||||
const average = computed(() => {
|
||||
if (chartData.length === 0) return 0;
|
||||
return chartData.reduce((sum, d) => sum + d.value, 0) / chartData.length;
|
||||
});
|
||||
|
||||
const formattedValue = computed(() => {
|
||||
if (typeof value === "string") return value;
|
||||
return formatValue(value);
|
||||
});
|
||||
</script>
|
||||
@@ -1,15 +1,11 @@
|
||||
<template>
|
||||
<ScrollableView :scrollable="scrollable" v-if="containers.length && ready">
|
||||
<template #header>
|
||||
<div class="mx-2 flex items-center gap-2 md:ml-4">
|
||||
<div class="@container flex flex-1 items-center gap-1.5 truncate md:gap-2">
|
||||
<octicon:container-24 />
|
||||
<div class="inline-flex font-mono text-sm">
|
||||
<div class="font-semibold">{{ containers.length }} containers</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-2 flex items-center gap-1 md:ml-4 md:gap-2">
|
||||
<octicon:container-24 />
|
||||
<ContainerDropdown :containers="containers">{{ $t("label.container", containers.length) }}</ContainerDropdown>
|
||||
<MultiContainerStat class="ml-auto" :containers="containers" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
|
||||
<MultiContainerActionToolbar @clear="viewer?.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div
|
||||
class="card bg-base-100 shadow-sm"
|
||||
:class="{ 'opacity-60': !alert.enabled, 'highlight-new': isHighlighted }"
|
||||
@animationend="isHighlighted = false"
|
||||
>
|
||||
<div class="card-body gap-4 p-5">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h4 class="flex min-w-0 flex-wrap items-center gap-2 text-lg font-semibold">
|
||||
<mdi:chart-line v-if="alert.metricExpression" class="text-info shrink-0" />
|
||||
<mdi:bell-ring-outline v-else-if="alert.eventExpression" class="text-info shrink-0" />
|
||||
<mdi:text-box-outline v-else class="text-info shrink-0" />
|
||||
<span class="break-all">{{ alert.name }}</span> <span class="text-sm font-light">→</span>
|
||||
<div class="group/dispatch dropdown dropdown-hover">
|
||||
<div
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="border-base-content/0 hover:border-base-content/20 flex cursor-pointer items-center gap-1 rounded border px-1.5 py-0.5 text-xs font-light transition-colors"
|
||||
:class="{ 'text-warning': !alert.dispatcher }"
|
||||
>
|
||||
<template v-if="alert.dispatcher">
|
||||
<mdi:webhook v-if="alert.dispatcher.type === 'webhook'" />
|
||||
<mdi:cloud v-else />
|
||||
{{ alert.dispatcher.name }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<mdi:alert-outline />
|
||||
{{ $t("notifications.alert.dispatcher-deleted") }}
|
||||
</template>
|
||||
<mdi:chevron-down class="text-[0.6rem] opacity-0 transition-opacity group-hover/dispatch:opacity-100" />
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-50 w-48 p-2 shadow-lg">
|
||||
<li v-for="dest in dispatchers" :key="dest.id">
|
||||
<a
|
||||
class="flex items-center gap-2"
|
||||
:class="{ active: dest.id === alert.dispatcher?.id }"
|
||||
@click="changeDispatcher(dest.id)"
|
||||
>
|
||||
<mdi:webhook v-if="dest.type === 'webhook'" />
|
||||
<mdi:cloud v-else />
|
||||
{{ dest.name }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</h4>
|
||||
<span v-if="!alert.enabled" class="badge badge-warning badge-sm">{{ $t("notifications.alert.paused") }}</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary shrink-0"
|
||||
:checked="alert.enabled"
|
||||
@change="toggleEnabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Expressions -->
|
||||
<div class="text-base-content/80 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
|
||||
<span>{{ $t("notifications.alert.containers") }}</span>
|
||||
<code class="bg-base-200 text-base-content rounded px-2 py-0.5 font-mono">{{ alert.containerExpression }}</code>
|
||||
<template v-if="alert.metricExpression">
|
||||
<span>{{ $t("notifications.alert.metric-filter") }}</span>
|
||||
<code class="bg-base-200 text-base-content rounded px-2 py-0.5 font-mono">{{ alert.metricExpression }}</code>
|
||||
<span>{{ $t("notifications.alert.sample-window") }}</span>
|
||||
<span>{{ formatDuration(alert.sampleWindow || 15, locale || undefined) }}</span>
|
||||
<span>{{ $t("notifications.alert.cooldown") }}</span>
|
||||
<span>{{ formatDuration(alert.cooldown || 300, locale || undefined) }}</span>
|
||||
</template>
|
||||
<template v-else-if="alert.eventExpression">
|
||||
<span>{{ $t("notifications.alert.event-filter") }}</span>
|
||||
<code class="bg-base-200 text-base-content rounded px-2 py-0.5 font-mono">{{ alert.eventExpression }}</code>
|
||||
<template v-if="alert.cooldown">
|
||||
<span>{{ $t("notifications.alert.cooldown") }}</span>
|
||||
<span>{{ formatDuration(alert.cooldown, locale || undefined) }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ $t("notifications.alert.log-filter") }}</span>
|
||||
<code class="bg-base-200 text-base-content rounded px-2 py-0.5 font-mono">{{ alert.logExpression }}</code>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
class="border-base-content/10 text-base-content/80 flex items-center justify-between gap-2 border-t pt-3 text-xs"
|
||||
>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<span>
|
||||
{{ $t("notifications.alert.containers-count", { count: alert.triggeredContainers }) }}
|
||||
</span>
|
||||
<span>
|
||||
{{ $t("notifications.alert.triggered-count", { count: alert.triggerCount }) }}
|
||||
</span>
|
||||
<span v-if="alert.lastTriggeredAt">
|
||||
{{ $t("notifications.alert.last-triggered", { time: formatTimeAgo(alert.lastTriggeredAt) }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<button class="btn btn-ghost btn-square" @click="editAlert">
|
||||
<mdi:pencil-outline />
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-square" @click="deleteAlert" :disabled="isDeleting">
|
||||
<span v-if="isDeleting" class="loading loading-spinner loading-xs"></span>
|
||||
<mdi:trash-can-outline v-else />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Dispatcher, NotificationRule } from "@/types/notifications";
|
||||
import AlertForm from "./AlertForm.vue";
|
||||
|
||||
const { alert, onUpdated, highlight } = defineProps<{
|
||||
alert: NotificationRule;
|
||||
onUpdated?: () => void;
|
||||
highlight?: boolean;
|
||||
}>();
|
||||
|
||||
const isHighlighted = ref(highlight ?? false);
|
||||
watch(
|
||||
() => highlight,
|
||||
(v) => {
|
||||
if (v) isHighlighted.value = true;
|
||||
},
|
||||
);
|
||||
|
||||
const showDrawer = useDrawer();
|
||||
const isDeleting = ref(false);
|
||||
const dispatchers = ref<Dispatcher[]>([]);
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await fetch(withBase("/api/notifications/dispatchers"));
|
||||
dispatchers.value = await res.json();
|
||||
});
|
||||
|
||||
async function changeDispatcher(id: number) {
|
||||
await fetch(withBase(`/api/notifications/rules/${alert.id}`), {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dispatcherId: id }),
|
||||
});
|
||||
onUpdated?.();
|
||||
}
|
||||
|
||||
function formatTimeAgo(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
if (date.getFullYear() === 0) return "-";
|
||||
return toRelativeTime(date, undefined);
|
||||
}
|
||||
|
||||
async function toggleEnabled() {
|
||||
await fetch(withBase(`/api/notifications/rules/${alert.id}`), {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: !alert.enabled }),
|
||||
});
|
||||
onUpdated?.();
|
||||
}
|
||||
|
||||
function editAlert() {
|
||||
showDrawer(AlertForm, { alert, onCreated: onUpdated }, "lg");
|
||||
}
|
||||
|
||||
async function deleteAlert() {
|
||||
isDeleting.value = true;
|
||||
try {
|
||||
await fetch(withBase(`/api/notifications/rules/${alert.id}`), { method: "DELETE" });
|
||||
onUpdated?.();
|
||||
} finally {
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card.highlight-new {
|
||||
animation: highlight-fade 3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes highlight-fade {
|
||||
from {
|
||||
background-color: oklch(from var(--color-secondary) l c h / 0.25);
|
||||
}
|
||||
to {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<div class="space-y-4 p-4">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold">
|
||||
{{ isEditing ? $t("notifications.alert-form.edit-title") : $t("notifications.alert-form.create-title") }}
|
||||
</h2>
|
||||
<p class="text-base-content/60">{{ $t("notifications.alert-form.description") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Alert Name -->
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.alert-name") }}</legend>
|
||||
<input
|
||||
ref="alertNameInput"
|
||||
v-model="alertName"
|
||||
type="text"
|
||||
class="input focus:input-primary w-full text-base"
|
||||
:class="alertName.trim() ? 'input-primary' : ''"
|
||||
required
|
||||
:placeholder="$t('notifications.alert-form.alert-name-placeholder')"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<!-- Alert Type Toggle -->
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.alert-type") }}</legend>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="alertType === 'log' ? 'btn-primary' : 'btn-outline'"
|
||||
@click="alertType = 'log'"
|
||||
>
|
||||
<mdi:text-box-outline class="mr-1" />
|
||||
{{ $t("notifications.alert-form.log-alert") }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="alertType === 'metric' ? 'btn-primary' : 'btn-outline'"
|
||||
@click="alertType = 'metric'"
|
||||
>
|
||||
<mdi:chart-line class="mr-1" />
|
||||
{{ $t("notifications.alert-form.metric-alert") }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="alertType === 'event' ? 'btn-primary' : 'btn-outline'"
|
||||
@click="alertType = 'event'"
|
||||
>
|
||||
<mdi:bell-ring-outline class="mr-1" />
|
||||
{{ $t("notifications.alert-form.event-alert") }}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Container Filter -->
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.container-filter") }}</legend>
|
||||
<div
|
||||
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
|
||||
:class="
|
||||
containerExpression.trim() && !containerResult?.error
|
||||
? 'input-primary'
|
||||
: { 'input-error!': containerResult?.error }
|
||||
"
|
||||
>
|
||||
<div ref="containerEditorRef" class="w-full"></div>
|
||||
</div>
|
||||
<div v-if="containerResult" class="fieldset-label">
|
||||
<span v-if="containerResult.error" class="text-error">{{ containerResult.error }}</span>
|
||||
<span v-else-if="containerResult.containers?.length" class="text-success">
|
||||
<mdi:check class="inline" />
|
||||
{{
|
||||
$t("notifications.alert-form.containers-match", {
|
||||
count: containerResult.containers.length,
|
||||
names: containerResult.containers.map((c) => c.name).join(", "),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span v-else class="text-warning">
|
||||
<mdi:alert class="inline" />
|
||||
{{ $t("notifications.alert-form.no-containers-match") }}
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Type-specific fields -->
|
||||
<KeepAlive>
|
||||
<LogAlertFields
|
||||
v-if="alertType === 'log'"
|
||||
ref="fieldsRef"
|
||||
:alert="alert"
|
||||
:prefill="prefill"
|
||||
:container-expression="containerExpression"
|
||||
:is-loading="isLoading"
|
||||
:validate-preview="validatePreview"
|
||||
/>
|
||||
<MetricAlertFields
|
||||
v-else-if="alertType === 'metric'"
|
||||
ref="fieldsRef"
|
||||
:alert="alert"
|
||||
:prefill="prefill"
|
||||
:container-expression="containerExpression"
|
||||
:is-loading="isLoading"
|
||||
:validate-preview="validatePreview"
|
||||
/>
|
||||
<EventAlertFields
|
||||
v-else
|
||||
ref="fieldsRef"
|
||||
:alert="alert"
|
||||
:prefill="prefill"
|
||||
:container-expression="containerExpression"
|
||||
:is-loading="isLoading"
|
||||
:validate-preview="validatePreview"
|
||||
/>
|
||||
</KeepAlive>
|
||||
|
||||
<!-- Destination -->
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.destination") }}</legend>
|
||||
<details class="dropdown w-full" ref="destinationDropdown">
|
||||
<summary class="btn btn-outline w-full justify-between" :class="{ 'btn-primary': selectedDestination }">
|
||||
<span class="flex items-center gap-2">
|
||||
<template v-if="selectedDestination">
|
||||
<mdi:webhook v-if="selectedDestination.type === 'webhook'" />
|
||||
<mdi:cloud v-else />
|
||||
{{ selectedDestination.name }}
|
||||
</template>
|
||||
<span v-else class="text-base-content/60">{{ $t("notifications.alert-form.select-destination") }}</span>
|
||||
</span>
|
||||
<carbon:caret-down />
|
||||
</summary>
|
||||
<ul class="dropdown-content menu bg-base-200 rounded-box z-50 mt-1 w-full border p-2 shadow-sm">
|
||||
<li v-for="dest in destinations" :key="dest.id">
|
||||
<a
|
||||
@click="
|
||||
dispatcherId = dest.id;
|
||||
destinationDropdown?.removeAttribute('open');
|
||||
"
|
||||
:class="{ active: dispatcherId === dest.id }"
|
||||
>
|
||||
<mdi:webhook v-if="dest.type === 'webhook'" />
|
||||
<mdi:cloud v-else />
|
||||
{{ dest.name }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
<div v-if="!destinations.length" class="fieldset-label">
|
||||
<span class="text-warning">
|
||||
<mdi:alert class="inline" />
|
||||
{{ $t("notifications.alert-form.no-destinations") }}
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="saveError" class="alert alert-error">
|
||||
<span>{{ saveError }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-2 pt-4">
|
||||
<button class="btn" @click="close?.()">{{ $t("notifications.alert-form.cancel") }}</button>
|
||||
<button class="btn btn-primary" :disabled="!canSave" @click="save">
|
||||
<span v-if="isSaving" class="loading loading-spinner loading-sm"></span>
|
||||
{{ isEditing ? $t("notifications.alert-form.save") : $t("notifications.alert-form.create") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useAlertForm } from "@/composable/alertForm";
|
||||
import LogAlertFields from "./LogAlertFields.vue";
|
||||
import MetricAlertFields from "./MetricAlertFields.vue";
|
||||
import EventAlertFields from "./EventAlertFields.vue";
|
||||
import type { NotificationRule } from "@/types/notifications";
|
||||
|
||||
const props = defineProps<{
|
||||
close?: () => void;
|
||||
onCreated?: () => void;
|
||||
alert?: NotificationRule;
|
||||
prefill?: {
|
||||
name?: string;
|
||||
containerExpression?: string;
|
||||
logExpression?: string;
|
||||
metricExpression?: string;
|
||||
eventExpression?: string;
|
||||
dispatcherId?: number;
|
||||
};
|
||||
}>();
|
||||
|
||||
const {
|
||||
isEditing,
|
||||
alertName,
|
||||
containerExpression,
|
||||
dispatcherId,
|
||||
destinations,
|
||||
selectedDestination,
|
||||
containerResult,
|
||||
isLoading,
|
||||
isSaving,
|
||||
saveError,
|
||||
baseCanSave,
|
||||
setupContainerEditor,
|
||||
saveAlert,
|
||||
validatePreview,
|
||||
} = useAlertForm(props);
|
||||
|
||||
// Template refs
|
||||
const alertNameInput = ref<HTMLInputElement>();
|
||||
const containerEditorRef = ref<HTMLElement>();
|
||||
const destinationDropdown = ref<HTMLDetailsElement>();
|
||||
const fieldsRef = ref<
|
||||
InstanceType<typeof LogAlertFields> | InstanceType<typeof MetricAlertFields> | InstanceType<typeof EventAlertFields>
|
||||
>();
|
||||
useFocus(alertNameInput, { initialValue: true });
|
||||
|
||||
// Alert type
|
||||
const alertType = ref<"log" | "metric" | "event">(
|
||||
props.alert?.metricExpression ? "metric" : props.alert?.eventExpression ? "event" : "log",
|
||||
);
|
||||
|
||||
const canSave = computed(() => baseCanSave.value && (fieldsRef.value?.canSave ?? false));
|
||||
|
||||
async function save() {
|
||||
if (!canSave.value || !fieldsRef.value) return;
|
||||
await saveAlert(fieldsRef.value.typeFields);
|
||||
}
|
||||
|
||||
// Container editor
|
||||
setupContainerEditor(containerEditorRef);
|
||||
</script>
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Cloud linked (when editing with prefix) -->
|
||||
<fieldset v-if="destination?.prefix" class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.api-key") }}</legend>
|
||||
<div class="join w-full">
|
||||
<input
|
||||
type="text"
|
||||
:value="destination.prefix + '**************************************'"
|
||||
readonly
|
||||
disabled
|
||||
class="input join-item w-full font-mono"
|
||||
:class="
|
||||
cloudStatusError === 'auth'
|
||||
? 'input-error'
|
||||
: cloudStatusError === 'unavailable'
|
||||
? 'input-warning'
|
||||
: 'input-success'
|
||||
"
|
||||
/>
|
||||
<span
|
||||
class="join-item btn pointer-events-none"
|
||||
:class="
|
||||
cloudStatusError === 'auth'
|
||||
? 'btn-error'
|
||||
: cloudStatusError === 'unavailable'
|
||||
? 'btn-warning'
|
||||
: 'btn-success'
|
||||
"
|
||||
>
|
||||
<mdi:alert-circle v-if="cloudStatusError === 'auth'" class="text-lg" />
|
||||
<mdi:cloud-off-outline v-else-if="cloudStatusError === 'unavailable'" class="text-lg" />
|
||||
<mdi:check v-else class="text-lg" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Cloud Status -->
|
||||
<div v-if="isLoadingCloudStatus" class="mt-3 flex items-center gap-2">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span class="text-base-content/60 text-sm">{{ $t("notifications.destination-form.cloud-checking") }}</span>
|
||||
</div>
|
||||
<div v-else-if="cloudStatusError" class="mt-3">
|
||||
<div class="alert" :class="cloudStatusError === 'auth' ? 'alert-error' : 'alert-warning'">
|
||||
<mdi:alert-circle v-if="cloudStatusError === 'auth'" class="text-lg" />
|
||||
<mdi:cloud-off-outline v-else class="text-lg" />
|
||||
<span>{{
|
||||
cloudStatusError === "auth"
|
||||
? $t("notifications.destination-form.cloud-relink")
|
||||
: $t("notifications.destination-form.cloud-unavailable")
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="cloudStatus" class="mt-3 space-y-3">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-base-content/60">{{ $t("notifications.destination-form.cloud-plan") }}</span>
|
||||
<span class="badge badge-primary badge-sm capitalize">{{ cloudStatus.plan.name }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 flex items-center justify-between text-sm">
|
||||
<span class="text-base-content/60">{{ $t("notifications.destination-form.cloud-usage") }}</span>
|
||||
<span
|
||||
>{{ cloudStatus.usage.events_used.toLocaleString() }} /
|
||||
{{ cloudStatus.usage.events_limit.toLocaleString() }}</span
|
||||
>
|
||||
</div>
|
||||
<progress
|
||||
class="progress w-full"
|
||||
:class="usagePercent > 90 ? 'progress-error' : usagePercent > 70 ? 'progress-warning' : 'progress-primary'"
|
||||
:value="cloudStatus.usage.events_used"
|
||||
:max="cloudStatus.usage.events_limit"
|
||||
></progress>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-base-content/60 mt-2 text-sm">
|
||||
{{ $t("notifications.destination-form.cloud-settings-hint") }}
|
||||
<a :href="cloudSettingsUrl" target="_blank" class="link link-primary">
|
||||
{{ $t("notifications.destination-form.cloud-settings-link") }}
|
||||
</a>
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<!-- Link Dozzle Cloud (when creating or not linked) -->
|
||||
<div v-else class="card card-border border-primary/30 bg-primary/5">
|
||||
<div class="card-body items-center text-center">
|
||||
<mdi:cloud-outline class="text-primary text-4xl" />
|
||||
<h3 class="card-title">{{ $t("notifications.destination-form.link-cloud") }}</h3>
|
||||
<p class="text-base-content/60 text-sm">{{ $t("notifications.destination-form.cloud-description") }}</p>
|
||||
<a :href="cloudLinkUrl" class="btn btn-primary btn-lg mt-2">
|
||||
<mdi:link-variant class="text-lg" />
|
||||
{{ $t("notifications.destination-form.link-cloud-button") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 pt-4">
|
||||
<div class="flex-1"></div>
|
||||
<button class="btn btn-primary" @click="close?.()">
|
||||
{{ $t("notifications.destination-form.close") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Dispatcher } from "@/types/notifications";
|
||||
|
||||
const { destination, close } = defineProps<{
|
||||
destination?: Dispatcher;
|
||||
close?: () => void;
|
||||
}>();
|
||||
|
||||
const callbackUrl = `${window.location.origin}${withBase("/")}`;
|
||||
const cloudLinkUrl = `${__CLOUD_URL__}/link?appUrl=${encodeURIComponent(callbackUrl)}&from=notifications`;
|
||||
const cloudSettingsUrl = `${__CLOUD_URL__}/settings`;
|
||||
|
||||
const { cloudStatus, cloudStatusError, isLoadingCloudStatus, fetchCloudStatus } = useCloudConfig();
|
||||
|
||||
const usagePercent = computed(() => {
|
||||
if (!cloudStatus.value) return 0;
|
||||
return (cloudStatus.value.usage.events_used / cloudStatus.value.usage.events_limit) * 100;
|
||||
});
|
||||
|
||||
if (destination?.prefix) {
|
||||
fetchCloudStatus();
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.cooldown-label") }}</legend>
|
||||
<input v-model.number="model" type="range" min="0" max="3600" step="10" class="range range-primary" />
|
||||
<p class="text-base-content/50 mt-1 text-xs">
|
||||
<template v-if="model === 0">{{ $t("notifications.alert-form.no-cooldown") }}</template>
|
||||
<template v-else>{{
|
||||
$t("notifications.alert-form.cooldown-hint", { duration: formatDuration(model, locale || undefined) })
|
||||
}}</template>
|
||||
</p>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const model = defineModel<number>({ required: true });
|
||||
</script>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 hover:border-primary cursor-pointer border border-transparent" @click="editDestination">
|
||||
<div class="card-body gap-2 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg">
|
||||
<mdi:webhook v-if="destination.type === 'webhook'" class="text-lg" />
|
||||
<mdi:cloud v-else class="text-primary-content text-lg" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold">{{ destination.name }}</h4>
|
||||
<p class="text-base-content/60 text-sm">
|
||||
{{
|
||||
destination.type === "webhook"
|
||||
? $t("notifications.destination.http-webhook")
|
||||
: $t("notifications.destination.dozzle-cloud")
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="dropdown dropdown-end" @click.stop>
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<ion:ellipsis-vertical />
|
||||
</label>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu dropdown-content rounded-box bg-base-100 border-base-content/20 z-50 w-40 border p-1 shadow-sm"
|
||||
>
|
||||
<li>
|
||||
<a @click="editDestination">{{ $t("notifications.destination.edit") }}</a>
|
||||
</li>
|
||||
<li v-if="destination.type !== 'cloud'">
|
||||
<a @click="duplicateDestination">{{ $t("notifications.destination.duplicate") }}</a>
|
||||
</li>
|
||||
<li v-if="destination.type !== 'cloud'">
|
||||
<a class="text-error" @click="deleteDestination">{{ $t("notifications.destination.delete") }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Dispatcher } from "@/types/notifications";
|
||||
import DestinationForm from "./DestinationForm.vue";
|
||||
|
||||
const { destination, onUpdated, existingDispatchers } = defineProps<{
|
||||
destination: Dispatcher;
|
||||
onUpdated?: () => void;
|
||||
existingDispatchers: Dispatcher[];
|
||||
}>();
|
||||
|
||||
const showDrawer = useDrawer();
|
||||
|
||||
function editDestination() {
|
||||
showDrawer(
|
||||
DestinationForm,
|
||||
{
|
||||
destination,
|
||||
onCreated: onUpdated,
|
||||
existingDispatchers,
|
||||
},
|
||||
"md",
|
||||
);
|
||||
}
|
||||
|
||||
async function duplicateDestination() {
|
||||
await fetch(withBase("/api/notifications/dispatchers"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: `Copy of ${destination.name}`,
|
||||
type: destination.type,
|
||||
url: destination.url,
|
||||
template: destination.template,
|
||||
headers: destination.headers,
|
||||
}),
|
||||
});
|
||||
onUpdated?.();
|
||||
}
|
||||
|
||||
async function deleteDestination() {
|
||||
await fetch(withBase(`/api/notifications/dispatchers/${destination.id}`), { method: "DELETE" });
|
||||
onUpdated?.();
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div class="space-y-4 p-4">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold">
|
||||
<template v-if="type === 'cloud'">
|
||||
{{ $t("notifications.destination-form.cloud-title") }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{
|
||||
isEditing
|
||||
? $t("notifications.destination-form.edit-title")
|
||||
: $t("notifications.destination-form.create-title")
|
||||
}}
|
||||
</template>
|
||||
</h2>
|
||||
<p class="text-base-content/60">
|
||||
<template v-if="type === 'cloud'">
|
||||
{{ $t("notifications.destination-form.cloud-description") }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t("notifications.destination-form.description") }}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Type Selection (only when creating) -->
|
||||
<fieldset v-if="!isEditing" class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.type") }}</legend>
|
||||
<div class="space-y-3">
|
||||
<label
|
||||
class="card card-border cursor-pointer transition-colors"
|
||||
:class="type === 'webhook' ? 'border-primary bg-primary/10' : ''"
|
||||
>
|
||||
<div class="card-body flex-row items-center gap-3 p-4">
|
||||
<input type="radio" v-model="type" value="webhook" class="radio radio-primary" />
|
||||
<div>
|
||||
<div class="font-semibold">{{ $t("notifications.destination-form.webhook-title") }}</div>
|
||||
<div class="text-base-content/60 text-sm">
|
||||
{{ $t("notifications.destination-form.webhook-description") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label
|
||||
class="card card-border cursor-pointer transition-colors"
|
||||
:class="[
|
||||
type === 'cloud' ? 'border-primary bg-primary/10' : '',
|
||||
isCloudLinked ? 'cursor-not-allowed opacity-50' : '',
|
||||
]"
|
||||
>
|
||||
<div class="card-body flex-row items-center gap-3 p-4">
|
||||
<input type="radio" v-model="type" value="cloud" class="radio radio-primary" :disabled="isCloudLinked" />
|
||||
<div>
|
||||
<div class="font-semibold">{{ $t("notifications.destination-form.cloud-title") }}</div>
|
||||
<div class="text-base-content/60 text-sm">
|
||||
{{ $t("notifications.destination-form.cloud-description") }}
|
||||
</div>
|
||||
<div v-if="isCloudLinked" class="text-success mt-1 text-xs">
|
||||
<mdi:check class="inline" />
|
||||
{{ $t("notifications.destination-form.cloud-exists") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Type-specific form -->
|
||||
<WebhookDestinationForm
|
||||
v-if="type === 'webhook'"
|
||||
:destination="destination"
|
||||
:close="close"
|
||||
:on-created="onCreated"
|
||||
:is-editing="isEditing"
|
||||
/>
|
||||
<CloudDestinationForm v-else :destination="destination" :close="close" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Dispatcher } from "@/types/notifications";
|
||||
import WebhookDestinationForm from "./WebhookDestinationForm.vue";
|
||||
import CloudDestinationForm from "./CloudDestinationForm.vue";
|
||||
|
||||
const { close, onCreated, destination } = defineProps<{
|
||||
close?: () => void;
|
||||
onCreated?: () => void;
|
||||
destination?: Dispatcher;
|
||||
}>();
|
||||
|
||||
const isEditing = !!destination;
|
||||
const type = ref<"webhook" | "cloud">((destination?.type as "webhook" | "cloud") ?? "webhook");
|
||||
|
||||
const { cloudConfig, fetchCloudConfig } = useCloudConfig();
|
||||
const isCloudLinked = computed(() => !!cloudConfig.value?.linked);
|
||||
|
||||
onMounted(() => fetchCloudConfig());
|
||||
</script>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.event-filter") }}</legend>
|
||||
<div
|
||||
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
|
||||
:class="eventExpression.trim() && !eventError ? 'input-primary' : { 'input-error!': eventError }"
|
||||
>
|
||||
<div ref="editorRef" class="w-full"></div>
|
||||
</div>
|
||||
<div v-if="eventError || eventExpression" class="fieldset-label">
|
||||
<span v-if="eventError" class="text-error">{{ eventError }}</span>
|
||||
<span v-else class="text-success">
|
||||
<mdi:check class="inline" />
|
||||
{{ $t("notifications.alert-form.expression-valid") }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-base-content/50 mt-1 text-xs">
|
||||
{{
|
||||
$t("notifications.alert-form.event-fields-hint", {
|
||||
fields: "name (start, stop, die, restart, health_status), attributes (exitCode, signal, etc.)",
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<CooldownField v-model="cooldown" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { createEventHints } from "@/composable/exprEditor";
|
||||
import type { NotificationRule, PreviewResult } from "@/types/notifications";
|
||||
|
||||
const props = defineProps<{
|
||||
alert?: NotificationRule;
|
||||
prefill?: { eventExpression?: string };
|
||||
containerExpression: string;
|
||||
isLoading: boolean;
|
||||
validatePreview: (extra: Record<string, unknown>) => Promise<{ data: PreviewResult | null }>;
|
||||
}>();
|
||||
|
||||
const eventExpression = ref(props.alert?.eventExpression ?? props.prefill?.eventExpression ?? "");
|
||||
const eventError = ref<string | null>(null);
|
||||
const cooldown = ref(props.alert?.cooldown ?? 10);
|
||||
|
||||
const canSave = computed(() => !!eventExpression.value.trim() && !eventError.value);
|
||||
const typeFields = computed(() => ({
|
||||
eventExpression: eventExpression.value,
|
||||
logExpression: "",
|
||||
metricExpression: "",
|
||||
cooldown: cooldown.value,
|
||||
sampleWindow: 0,
|
||||
}));
|
||||
|
||||
defineExpose({ canSave, typeFields });
|
||||
|
||||
// Validation
|
||||
async function validate() {
|
||||
if (!props.containerExpression && !eventExpression.value) {
|
||||
eventError.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await props.validatePreview({
|
||||
eventExpression: eventExpression.value || undefined,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
eventError.value = data.eventError ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedValidate = useDebounceFn(validate, 500);
|
||||
watch(
|
||||
[() => props.containerExpression, eventExpression],
|
||||
() => {
|
||||
debouncedValidate();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Editor
|
||||
const editorRef = ref<HTMLElement>();
|
||||
useExprEditorField(editorRef, {
|
||||
placeholder: 'name == "die"',
|
||||
initialValue: props.alert?.eventExpression ?? props.prefill?.eventExpression ?? "",
|
||||
getHints: () => createEventHints(),
|
||||
onChange: (v) => (eventExpression.value = v),
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.log-filter") }}</legend>
|
||||
<div
|
||||
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
|
||||
:class="logExpression.trim() && !logError ? 'input-primary' : { 'input-error!': logError }"
|
||||
>
|
||||
<div ref="editorRef" class="w-full"></div>
|
||||
</div>
|
||||
<div v-if="logError || logExpression" class="fieldset-label">
|
||||
<span v-if="logError" class="text-error">{{ logError }}</span>
|
||||
<span v-else-if="logMessages.length" class="text-success">
|
||||
<mdi:check class="inline" />
|
||||
{{ $t("notifications.alert-form.logs-match", { count: logTotalCount }) }}
|
||||
</span>
|
||||
<span v-else-if="!props.isLoading" class="text-warning">
|
||||
<mdi:alert class="inline" />
|
||||
{{ $t("notifications.alert-form.no-logs-match") }}
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Log Preview -->
|
||||
<div v-if="logMessages.length" class="mt-4">
|
||||
<div class="mb-2 text-lg">{{ $t("notifications.alert-form.preview") }}</div>
|
||||
<LogList
|
||||
:messages="logMessages"
|
||||
:last-selected-item="undefined"
|
||||
class="border-base-content/50 h-64 overflow-hidden rounded-lg border"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { type LogEvent, type LogEntry, type LogMessage, asLogEntry } from "@/models/LogEntry";
|
||||
import { createLogHints } from "@/composable/exprEditor";
|
||||
import type { NotificationRule, PreviewResult } from "@/types/notifications";
|
||||
|
||||
const props = defineProps<{
|
||||
alert?: NotificationRule;
|
||||
prefill?: { logExpression?: string };
|
||||
containerExpression: string;
|
||||
isLoading: boolean;
|
||||
validatePreview: (extra: Record<string, unknown>) => Promise<{ data: PreviewResult | null }>;
|
||||
}>();
|
||||
|
||||
const logExpression = ref(props.alert?.logExpression ?? props.prefill?.logExpression ?? "");
|
||||
const logError = ref<string | null>(null);
|
||||
const logTotalCount = ref(0);
|
||||
const logMessages = shallowRef<LogEntry<LogMessage>[]>([]);
|
||||
const messageKeys = ref<string[]>([]);
|
||||
|
||||
const canSave = computed(() => !logError.value);
|
||||
const typeFields = computed(() => ({ logExpression: logExpression.value, metricExpression: "", cooldown: 0 }));
|
||||
|
||||
defineExpose({ canSave, typeFields });
|
||||
|
||||
// Validation
|
||||
async function validate() {
|
||||
if (!props.containerExpression && !logExpression.value) {
|
||||
logError.value = null;
|
||||
logTotalCount.value = 0;
|
||||
logMessages.value = [];
|
||||
messageKeys.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await props.validatePreview({
|
||||
logExpression: logExpression.value || undefined,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
messageKeys.value = data.messageKeys ?? [];
|
||||
if (logExpression.value && !data.containerError) {
|
||||
logError.value = data.logError ?? null;
|
||||
logTotalCount.value = data.totalLogs;
|
||||
logMessages.value = data.matchedLogs?.map((event) => asLogEntry(event as LogEvent)) ?? [];
|
||||
} else {
|
||||
logError.value = null;
|
||||
logTotalCount.value = 0;
|
||||
logMessages.value = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedValidate = useDebounceFn(validate, 500);
|
||||
watch(
|
||||
[() => props.containerExpression, logExpression],
|
||||
() => {
|
||||
debouncedValidate();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Editor
|
||||
const editorRef = ref<HTMLElement>();
|
||||
useExprEditorField(editorRef, {
|
||||
placeholder: 'level == "error" && message contains "timeout"',
|
||||
initialValue: props.alert?.logExpression ?? props.prefill?.logExpression ?? "",
|
||||
getHints: () => createLogHints(messageKeys.value),
|
||||
onChange: (v) => (logExpression.value = v),
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.metric-filter") }}</legend>
|
||||
<div
|
||||
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
|
||||
:class="metricExpression.trim() && !metricError ? 'input-primary' : { 'input-error!': metricError }"
|
||||
>
|
||||
<div ref="editorRef" class="w-full"></div>
|
||||
</div>
|
||||
<div v-if="metricError || metricExpression" class="fieldset-label">
|
||||
<span v-if="metricError" class="text-error">{{ metricError }}</span>
|
||||
<span v-else class="text-success">
|
||||
<mdi:check class="inline" />
|
||||
{{ $t("notifications.alert-form.expression-valid") }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-base-content/50 mt-1 text-xs">
|
||||
{{
|
||||
$t("notifications.alert-form.metric-fields-hint", {
|
||||
fields: "cpu (CPU %), memory (memory %), memoryUsage (bytes)",
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.sample-window-label") }}</legend>
|
||||
<input v-model.number="sampleWindow" type="range" min="15" max="300" step="15" class="range range-primary" />
|
||||
<p class="text-base-content/50 mt-1 text-xs">
|
||||
{{
|
||||
$t("notifications.alert-form.sample-window-hint", {
|
||||
duration: formatDuration(sampleWindow, locale || undefined),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<CooldownField v-model="cooldown" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { createMetricHints } from "@/composable/exprEditor";
|
||||
import type { NotificationRule, PreviewResult } from "@/types/notifications";
|
||||
|
||||
const props = defineProps<{
|
||||
alert?: NotificationRule;
|
||||
prefill?: { metricExpression?: string };
|
||||
containerExpression: string;
|
||||
isLoading: boolean;
|
||||
validatePreview: (extra: Record<string, unknown>) => Promise<{ data: PreviewResult | null }>;
|
||||
}>();
|
||||
|
||||
const metricExpression = ref(props.alert?.metricExpression ?? props.prefill?.metricExpression ?? "");
|
||||
const metricError = ref<string | null>(null);
|
||||
const sampleWindow = ref(props.alert?.sampleWindow ?? 15);
|
||||
const cooldown = ref(props.alert?.cooldown ?? 300);
|
||||
|
||||
const canSave = computed(() => !!metricExpression.value.trim() && !metricError.value);
|
||||
const typeFields = computed(() => ({
|
||||
metricExpression: metricExpression.value,
|
||||
logExpression: "",
|
||||
cooldown: cooldown.value,
|
||||
sampleWindow: sampleWindow.value,
|
||||
}));
|
||||
|
||||
defineExpose({ canSave, typeFields });
|
||||
|
||||
// Validation
|
||||
async function validate() {
|
||||
if (!props.containerExpression && !metricExpression.value) {
|
||||
metricError.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await props.validatePreview({
|
||||
metricExpression: metricExpression.value || undefined,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
metricError.value = data.metricError ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedValidate = useDebounceFn(validate, 500);
|
||||
watch(
|
||||
[() => props.containerExpression, metricExpression],
|
||||
() => {
|
||||
debouncedValidate();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Editor
|
||||
const editorRef = ref<HTMLElement>();
|
||||
useExprEditorField(editorRef, {
|
||||
placeholder: "cpu > 80 || memory > 90",
|
||||
initialValue: props.alert?.metricExpression ?? props.prefill?.metricExpression ?? "",
|
||||
getHints: () => createMetricHints(),
|
||||
onChange: (v) => (metricExpression.value = v),
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Name -->
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.name") }}</legend>
|
||||
<input
|
||||
ref="nameInput"
|
||||
v-model="name"
|
||||
type="text"
|
||||
class="input focus:input-primary w-full text-base"
|
||||
required
|
||||
:class="{ 'input-primary': name.trim().length > 0 }"
|
||||
:placeholder="$t('notifications.destination-form.name-placeholder')"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<!-- Webhook URL -->
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.webhook-url") }}</legend>
|
||||
<input
|
||||
v-model="webhookUrl"
|
||||
type="url"
|
||||
class="input focus:input-primary w-full text-base"
|
||||
:class="{ 'input-primary': isValidUrl, 'input-error': webhookUrl.trim() && !isValidUrl }"
|
||||
:placeholder="$t('notifications.destination-form.webhook-url-placeholder')"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<!-- Payload Format (create mode only) -->
|
||||
<fieldset v-if="!isEditing" class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.payload-format") }}</legend>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="format in ['slack', 'discord', 'ntfy', 'custom'] as const"
|
||||
:key="format"
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
:class="payloadFormat === format ? 'btn-primary' : 'btn-ghost'"
|
||||
@click="selectPayloadFormat(format)"
|
||||
>
|
||||
{{ $t(`notifications.destination-form.format-${format}`) }}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Template -->
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">
|
||||
{{ $t("notifications.destination-form.template") }}
|
||||
<span class="text-base-content/60 ml-2 text-sm font-normal">{{
|
||||
$t("notifications.destination-form.template-hint")
|
||||
}}</span>
|
||||
</legend>
|
||||
<div
|
||||
ref="templateEditorRef"
|
||||
class="border-base-content/20 focus-within:border-primary min-h-48 w-full overflow-auto rounded-lg border"
|
||||
></div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Custom Headers -->
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">
|
||||
{{ $t("notifications.destination-form.headers") }}
|
||||
<span class="text-base-content/60 ml-2 text-sm font-normal">{{
|
||||
$t("notifications.destination-form.headers-hint")
|
||||
}}</span>
|
||||
</legend>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(header, index) in headers" :key="header.key" class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="header.name"
|
||||
type="text"
|
||||
class="input focus:input-primary flex-1 text-base"
|
||||
:placeholder="$t('notifications.destination-form.header-name')"
|
||||
/>
|
||||
<input
|
||||
v-model="header.value"
|
||||
type="text"
|
||||
class="input focus:input-primary flex-1 text-base"
|
||||
:placeholder="$t('notifications.destination-form.header-value')"
|
||||
/>
|
||||
<button type="button" class="btn btn-ghost btn-sm btn-square" @click="headers.splice(index, 1)">
|
||||
<carbon:close />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm"
|
||||
@click="headers.push({ name: '', value: '', key: headerKeyCounter++ })"
|
||||
>
|
||||
<carbon:add />
|
||||
{{ $t("notifications.destination-form.add-header") }}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="error" class="alert alert-error">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Test Result -->
|
||||
<div v-if="testResult" class="alert" :class="testResult.success ? 'alert-success' : 'alert-error'">
|
||||
<span v-if="testResult.success">
|
||||
{{ $t("notifications.destination-form.test-success") }}
|
||||
<span v-if="testResult.statusCode" class="opacity-70">({{ testResult.statusCode }})</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ testResult.error }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 pt-4">
|
||||
<button class="btn" @click="testDestination" :disabled="!canTest || !isValidUrl || isTesting">
|
||||
<span v-if="isTesting" class="loading loading-spinner loading-sm"></span>
|
||||
{{ $t("notifications.destination-form.test") }}
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
<button class="btn" @click="close?.()">
|
||||
{{ $t("notifications.destination-form.cancel") }}
|
||||
</button>
|
||||
<button class="btn btn-primary" :disabled="!canSave" @click="saveDestination">
|
||||
<span v-if="isSaving" class="loading loading-spinner loading-sm"></span>
|
||||
{{ isEditing ? $t("notifications.destination-form.save") : $t("notifications.destination-form.add") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Dispatcher, TestWebhookResult } from "@/types/notifications";
|
||||
import { createTemplateEditor } from "@/composable/templateEditor";
|
||||
import { PAYLOAD_TEMPLATES, type PayloadFormat } from "./payloadTemplates";
|
||||
|
||||
const { close, onCreated, destination, isEditing } = defineProps<{
|
||||
close?: () => void;
|
||||
onCreated?: () => void;
|
||||
destination?: Dispatcher;
|
||||
isEditing: boolean;
|
||||
}>();
|
||||
|
||||
const nameInput = ref<HTMLInputElement>();
|
||||
const templateEditorRef = ref<HTMLElement>();
|
||||
const name = ref(destination?.name ?? "");
|
||||
useFocus(nameInput, { initialValue: true });
|
||||
const webhookUrl = ref(destination?.url ?? "");
|
||||
const payloadFormat = ref<PayloadFormat>(isEditing ? "custom" : "slack");
|
||||
const template = ref(isEditing ? (destination?.template ?? "") : PAYLOAD_TEMPLATES[payloadFormat.value]);
|
||||
let headerKeyCounter = 0;
|
||||
const headers = ref<{ name: string; value: string; key: number }[]>(
|
||||
destination?.headers
|
||||
? Object.entries(destination.headers).map(([name, value]) => ({ name, value, key: headerKeyCounter++ }))
|
||||
: [],
|
||||
);
|
||||
const isTesting = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const testResult = ref<TestWebhookResult | null>(null);
|
||||
|
||||
let templateEditorView: Awaited<ReturnType<typeof createTemplateEditor>> | undefined;
|
||||
|
||||
function selectPayloadFormat(format: PayloadFormat) {
|
||||
payloadFormat.value = format;
|
||||
template.value = PAYLOAD_TEMPLATES[format];
|
||||
setEditorContent(template.value);
|
||||
}
|
||||
|
||||
function setEditorContent(value: string) {
|
||||
if (!templateEditorView) return;
|
||||
templateEditorView.dispatch({
|
||||
changes: { from: 0, to: templateEditorView.state.doc.length, insert: value },
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!templateEditorRef.value) return;
|
||||
templateEditorView = await createTemplateEditor({
|
||||
parent: templateEditorRef.value,
|
||||
initialValue: template.value,
|
||||
onChange: (v) => (template.value = v),
|
||||
});
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
templateEditorView?.destroy();
|
||||
});
|
||||
|
||||
function headersToRecord(): Record<string, string> | undefined {
|
||||
const filtered = headers.value.filter((h) => h.name.trim() && h.value.trim());
|
||||
if (filtered.length === 0) return undefined;
|
||||
return Object.fromEntries(filtered.map((h) => [h.name.trim(), h.value.trim()]));
|
||||
}
|
||||
|
||||
const canTest = computed(() => webhookUrl.value.trim().length > 0);
|
||||
|
||||
const isValidUrl = computed(() => {
|
||||
try {
|
||||
new URL(webhookUrl.value.trim());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const canSave = computed(() => {
|
||||
if (isSaving.value) return false;
|
||||
if (!name.value.trim()) return false;
|
||||
if (!isValidUrl.value) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
async function testDestination() {
|
||||
if (!canTest.value) return;
|
||||
|
||||
isTesting.value = true;
|
||||
testResult.value = null;
|
||||
|
||||
try {
|
||||
const res = await fetch(withBase("/api/notifications/test-webhook"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
url: webhookUrl.value.trim(),
|
||||
template: template.value.trim() || undefined,
|
||||
headers: headersToRecord(),
|
||||
}),
|
||||
});
|
||||
|
||||
const data: TestWebhookResult = await res.json();
|
||||
testResult.value = data;
|
||||
} catch (e) {
|
||||
testResult.value = { success: false, error: e instanceof Error ? e.message : "Test failed" };
|
||||
} finally {
|
||||
isTesting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDestination() {
|
||||
if (!canSave.value) return;
|
||||
|
||||
isSaving.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const input = {
|
||||
name: name.value.trim(),
|
||||
type: "webhook",
|
||||
url: webhookUrl.value.trim(),
|
||||
template: template.value.trim() || undefined,
|
||||
headers: headersToRecord(),
|
||||
};
|
||||
|
||||
const url = isEditing
|
||||
? withBase(`/api/notifications/dispatchers/${destination!.id}`)
|
||||
: withBase("/api/notifications/dispatchers");
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: isEditing ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Failed to save destination");
|
||||
}
|
||||
|
||||
onCreated?.();
|
||||
close?.();
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Failed to save destination";
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user