mirror of
https://github.com/amir20/dozzle.git
synced 2026-08-07 15:44:44 +00:00
Compare commits
737 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 233c546f92 | |||
| 666ad7ba05 | |||
| d3814455cb | |||
| 67dbbfce97 | |||
| a9afe7a299 | |||
| 00cac97114 | |||
| 58edb4f602 | |||
| c3b5991dc7 | |||
| 9751ce3cf5 | |||
| 40d02d993b | |||
| 889ebf3acb | |||
| a051245805 | |||
| 66738313c8 | |||
| e8aa6adbda | |||
| 45ec71a6a1 | |||
| 072a6c09bb | |||
| c233549b4b | |||
| 4a30a2ba5e | |||
| 538a1e5dcd | |||
| 46ba01e3a8 | |||
| 9074895653 | |||
| 9e30045e6f | |||
| ad461c9dbc | |||
| b150a6b772 | |||
| c123c62bf7 | |||
| ccc42443c8 | |||
| 907360a334 | |||
| 3c4442a8dc | |||
| 9e98640731 | |||
| fa47512771 | |||
| e31e3138fb | |||
| e2bb23b2ed | |||
| 60beb85bb1 | |||
| b437850063 | |||
| 9f3a256334 | |||
| 2d30c8c529 | |||
| 8acd5794e8 | |||
| 2e01f3acb4 | |||
| 87951b0027 | |||
| 552a9cf78f | |||
| 5b0f18dd28 | |||
| 0ac2ba4873 | |||
| cfba470c0e | |||
| 9e1de4f226 | |||
| 0812bfaca4 | |||
| 45d8991c5f | |||
| 0f46d7ffd7 | |||
| fcf60daafa | |||
| 617bf71cad | |||
| 06c6a605fd | |||
| d41508d4e7 | |||
| a77db667ab | |||
| 34ad45c64e | |||
| 512b181b48 | |||
| be5e496380 | |||
| 887670ebfe | |||
| 9cdc8ad8f5 | |||
| 0dd68b22ca | |||
| 60ac81ff94 | |||
| a79495cdfc | |||
| 409363b26b | |||
| 2aaaf2ba1f | |||
| 11451afc9a | |||
| efb07b340a | |||
| ba51793695 | |||
| af4556d0ca | |||
| d041767455 | |||
| 69676c7aaa | |||
| d340e07d8a | |||
| 0cd8b3d900 | |||
| 26d2f71102 | |||
| f7740ebd04 | |||
| a150b44bec | |||
| 54610b0f5a | |||
| 7f1303ed4d | |||
| 7ea82ee2fd | |||
| c178f7be8a | |||
| 81be24eccd | |||
| c51df3b4e7 | |||
| 076f62bac7 | |||
| 2ec0d7bd4e | |||
| 9971bf0d3f | |||
| d8c47a69c7 | |||
| 14edf1b404 | |||
| 14dd1af6df | |||
| 2b2d0ed97f | |||
| e26a60ffa4 | |||
| 5946e56ccb | |||
| 8f77400f8b | |||
| 19cc2f30c4 | |||
| 298993289c | |||
| a06f197015 | |||
| 39917518b9 | |||
| cbccc1ac4c | |||
| 5cc5e517e6 | |||
| 44e21cba83 | |||
| ac886b0e9a | |||
| 1c74a46f3f | |||
| 8778500aa6 | |||
| 3eb222066c | |||
| 5d326189a8 | |||
| b722fae909 | |||
| 8e21cbd51c | |||
| 166e547f05 | |||
| ada1a98d9c | |||
| 40c259b1be | |||
| ac0b9b157e | |||
| 0be7005ab4 | |||
| 97f50d696c | |||
| 3c53e218d1 | |||
| 11487003f2 | |||
| c1b8d0aed0 | |||
| 699181ec7c | |||
| 62f9da0536 | |||
| 10de475a64 | |||
| 14bf3a1417 | |||
| 0ff031e361 | |||
| baf3cc70f8 | |||
| 1b056346c0 | |||
| 18005fcd23 | |||
| 425ab449a6 | |||
| ff0ceea29d | |||
| 5b509d26d4 | |||
| f2dbda3dde | |||
| 6b1b7099b1 | |||
| 93347a6888 | |||
| 80800674bd | |||
| 6f8cf9045e | |||
| 5988b1d0df | |||
| c774ba7edb | |||
| 079ac56442 | |||
| 178ade4d20 | |||
| ffa1fb976e | |||
| b0b1793874 | |||
| 30491b10ff | |||
| 991588a42e | |||
| 1a899a18e8 | |||
| 53ad92c60d | |||
| a9eb17cce3 | |||
| 8c8987b666 | |||
| bbc6853f96 | |||
| c083c8f0c9 | |||
| 33e5a1560f | |||
| 1bb1081f1a | |||
| ad7b3a2b52 | |||
| e00c918025 | |||
| 35455d845a | |||
| 4b73fb8413 | |||
| fc97b1dcf9 | |||
| b7085dda2b | |||
| 901785dc20 | |||
| 2c909dab09 | |||
| e4ef784be7 | |||
| d2bb0cd31d | |||
| 0562b60df4 | |||
| d1e63155c9 | |||
| 7fed87fa79 | |||
| 90c4b8a68b | |||
| c85ce593a1 | |||
| 0fe9278a29 | |||
| 8a616b6bc0 | |||
| f3750fd7d0 | |||
| 5af162ddda | |||
| b2a6ea7ba1 | |||
| 70acaa64d8 | |||
| f6807db592 | |||
| 6e394bad19 | |||
| 657e6314c0 | |||
| a6fff56997 | |||
| 05a4b7c25a | |||
| 263029d77e | |||
| 32be87d245 | |||
| 14fc1190a8 | |||
| 9f90d1ccfa | |||
| 8b3ddcfca7 | |||
| 50f3c0b7a4 | |||
| 31063fa1b4 | |||
| 3aa874e399 | |||
| ef7bb3fbcb | |||
| a019705bae | |||
| e59f9106cc | |||
| b3357e9843 | |||
| 3511739008 | |||
| 608a39d841 | |||
| 360985e8e1 | |||
| 82f78582b5 | |||
| 10bcb7812a | |||
| a7693f8a6b | |||
| 93297649bc | |||
| d25f87390c | |||
| 5a713dc88c | |||
| b23daa4158 | |||
| 41da3f0de4 | |||
| cb5712f7e0 | |||
| 69723962fd | |||
| 81cfdbec42 | |||
| 7477b1aecc | |||
| 8912339044 | |||
| 3f328a0ebf | |||
| 4ea51fc06f | |||
| f4ebd12fbf | |||
| 8df12a4f3d | |||
| ab8441ac01 | |||
| 17125c67ed | |||
| 8fd8e82994 | |||
| 31ddc941ff | |||
| 94d4610b08 | |||
| d9a810111b | |||
| bdf77b7f8a | |||
| 19c2bc8274 | |||
| b431ce16c5 | |||
| 38a6a99096 | |||
| 88442edee5 | |||
| d7710f736f | |||
| c9bb793e9b | |||
| c522a66d1a | |||
| d0edc42d1b | |||
| 5f92e84d9d | |||
| 84b8e24ca3 | |||
| c225a837df | |||
| 3b6ad12c93 | |||
| 2581150346 | |||
| 3d93bb69cb | |||
| cc7d834e35 | |||
| ecf5fda53e | |||
| 73aa1d9010 | |||
| 066248c531 | |||
| 8c29954caf | |||
| ce63996954 | |||
| ad46d53ef2 | |||
| a2d208adf7 | |||
| c2110e322f | |||
| d9376b303a | |||
| 3baed9da3d | |||
| 8a88b332c5 | |||
| 770f5e63ff | |||
| f0424e1e94 | |||
| fc0c189e38 | |||
| 8586f16ea5 | |||
| 7427d005c8 | |||
| e7174dea69 | |||
| 5b12a3cc18 | |||
| b2844469d6 | |||
| 0d3bd7246a | |||
| d0c080c7ae | |||
| 48aa0afa3a | |||
| a9ff1e9335 | |||
| 9eb0242544 | |||
| 6b41b7acef | |||
| d80d8d4484 | |||
| d268d3e2ca | |||
| d867c08aae | |||
| 969fdf62d0 | |||
| 8d3f501bac | |||
| 9ab7f0acca | |||
| ce4ab3a249 | |||
| 82e37c7397 | |||
| ea23891ac6 | |||
| 0e9edd8bf2 | |||
| 23c53a8e3a | |||
| 86fc556fb9 | |||
| f5f268b677 | |||
| 352d815255 | |||
| e7e2089f4f | |||
| d4762908be | |||
| 71f9963a75 | |||
| 5056298704 | |||
| 491b1cb944 | |||
| b7d5229534 | |||
| 1ffa17ef48 | |||
| b55243a6b9 | |||
| 002d8dedee | |||
| 4348ba6323 | |||
| 0b76f1185b | |||
| 4fc8109048 | |||
| e0e4292d1c | |||
| 18c57066c7 | |||
| 6f1b3a1dd1 | |||
| 370e7b238e | |||
| f89e2cc6a7 | |||
| 049ae108de | |||
| be47fdab12 | |||
| d0ae1db50b | |||
| 6b32920d33 | |||
| 25d7c14783 | |||
| 7c13637870 | |||
| 1b969380bc | |||
| cfe616a79e | |||
| 764d176455 | |||
| 786aaedc78 | |||
| 847daed5e8 | |||
| f410b9dd1d | |||
| 0b83fb4356 | |||
| 088d96cb0d | |||
| 9aed4ba19f | |||
| 30a4d9a6be | |||
| 36b4bb65e7 | |||
| 8767d3461d | |||
| cd9f5971a4 | |||
| 3c7021af42 | |||
| 0b9b9f12da | |||
| 16eb71e0c0 | |||
| 17dfcf3d9a | |||
| 2dbbe47c5c | |||
| 6076c4eb63 | |||
| 818fec244a | |||
| 1f85f046cf | |||
| e6efe6ff59 | |||
| e96ab50486 | |||
| f74b1c91f2 | |||
| 0cbfcb6097 | |||
| 27d0244e5d | |||
| 7b19e83fea | |||
| 832594532a | |||
| 298b39f6b9 | |||
| e04fbbb51a | |||
| 685b17ef4e | |||
| 582aa207f1 | |||
| 8116868669 | |||
| cd125e83be | |||
| 6ba0f6037f | |||
| 5bc02e49f2 | |||
| bf634382e5 | |||
| 551fb1579d | |||
| 12891b17ea | |||
| 96b7189c44 | |||
| 8489426540 | |||
| c592553db2 | |||
| 15e4cc7bce | |||
| 023a3490dc | |||
| ee920b119e | |||
| fbcd52bcdd | |||
| 8b3bc673d0 | |||
| 25341e2b7c | |||
| 5c8437436e | |||
| 879dbb5ad0 | |||
| 7b4d084cb2 | |||
| c93ecbfdae | |||
| d79fcc2774 | |||
| 98b5c106ec | |||
| c44610fd14 | |||
| 24a34146f2 | |||
| af96a5b18d | |||
| f98bdc054a | |||
| d92d153175 | |||
| 82e201973b | |||
| 52b1425a41 | |||
| 547c3917c5 | |||
| 2800421e8d | |||
| 57d1c290d9 | |||
| 650413d510 | |||
| 0e677bdee7 | |||
| 4455cc15cc | |||
| bc48387749 | |||
| 8c625a3113 | |||
| 9775df895b | |||
| 5fc2d29e4f | |||
| 60cfdd4a70 | |||
| ad52a14d42 | |||
| 792f640e41 | |||
| eb47420444 | |||
| 8492d73714 | |||
| c8565e08c9 | |||
| 63a0d544e4 | |||
| e7591f9a64 | |||
| f242776772 | |||
| 51008ba604 | |||
| 96c68686bf | |||
| 93ddbca73a | |||
| a30de127ca | |||
| 8e5611bc50 | |||
| 9e2d6a484a | |||
| 7be7b17cc1 | |||
| a619149412 | |||
| 488156df89 | |||
| 1c068cc6f9 | |||
| c6f2f8ccb2 | |||
| 518c78c3d1 | |||
| fe034d1a38 | |||
| 49e6cd1c9b | |||
| 3e78f81a53 | |||
| e84f0cdb97 | |||
| 804bd5b9e1 | |||
| 9c9f247e0c | |||
| db43f7a62d | |||
| efef1dca6b | |||
| 3417ec3e99 | |||
| bda832a8a3 | |||
| 30e9ab68d7 | |||
| 9ba1a0f76a | |||
| 0521f343e3 | |||
| 9ecc21dcb2 | |||
| d99f4b2766 | |||
| 5c83e33ace | |||
| a4ac2c60ac | |||
| bbb7fb8ba9 | |||
| 0ebd399768 | |||
| 9971b41434 | |||
| 7f4d005a67 | |||
| 2709417ba3 | |||
| c4386d2d84 | |||
| fbcf989885 | |||
| 07352f6e16 | |||
| 6fecfce3c7 | |||
| 2884ffafeb | |||
| 67ac202b04 | |||
| 839afd8fcf | |||
| 202b07bfb4 | |||
| 15bd0999a2 | |||
| 4450c2527f | |||
| 614e62b782 | |||
| 77cce86e29 | |||
| 93dc547b9d | |||
| 0681a3c0c6 | |||
| 9bf35e3e1f | |||
| 087e8694d1 | |||
| 34b034c822 | |||
| bd598699e3 | |||
| 8867b2aa7c | |||
| 296e8fc50e | |||
| da065a1966 | |||
| 5262a54463 | |||
| f37c5d09ed | |||
| 298e030db0 | |||
| 6f2ef4499b | |||
| e4d1064843 | |||
| 0c1ad7c0c7 | |||
| fb9333a11b | |||
| ed67d7d085 | |||
| d34136589b | |||
| 5226c92f78 | |||
| 9e507b31ec | |||
| ac7b120176 | |||
| c15a680797 | |||
| 1c07142bce | |||
| c886071559 | |||
| 44b6809abe | |||
| 13db2cfb58 | |||
| a1af2c60cd | |||
| 37d322bec8 | |||
| ccd61ce095 | |||
| 01c63a3650 | |||
| 5a99378845 | |||
| dfed83df0b | |||
| 75b0d5c4fb | |||
| d654f3eceb | |||
| 6cdcbd8244 | |||
| cb18e42602 | |||
| 37d78117f1 | |||
| 0cf4e6d2b6 | |||
| ec5196ea6e | |||
| 089c2e9b17 | |||
| 5f2420960b | |||
| e73ee6735e | |||
| 03b4c5adb9 | |||
| b92703c7bb | |||
| 2bb4d10b66 | |||
| e02b08c21d | |||
| 539ace566c | |||
| a29373d1a2 | |||
| 3c0eac4541 | |||
| beee7ea9f8 | |||
| 770416805e | |||
| c2fcecaec0 | |||
| 3a30bcf937 | |||
| 54f9a04b9d | |||
| 62b7c38f88 | |||
| b474128a5f | |||
| cb46130880 | |||
| 8cf9fbe77c | |||
| 9c5da96eb4 | |||
| 5abf538cb4 | |||
| a1d79d4fc6 | |||
| 468c8982b0 | |||
| a9b4be43c8 | |||
| 1350895676 | |||
| 591bee332f | |||
| 3257f4b04f | |||
| 872729a93b | |||
| a7d6a5088a | |||
| ebf832b501 | |||
| 11fd3e14bb | |||
| eb4df34d42 | |||
| 6bc6dd5c03 | |||
| 460f1167b0 | |||
| f04563e868 | |||
| c4881cf407 | |||
| 9945f31e7c | |||
| c13fd882ed | |||
| da6257c233 | |||
| 0b5da03ffb | |||
| 617fc3d387 | |||
| d82f9e4db2 | |||
| efabc926cf | |||
| c5505e9ac5 | |||
| bb14f9121e | |||
| 050666a39c | |||
| a03d83676f | |||
| 6d4bff0ce8 | |||
| 5e1b532f52 | |||
| 4ea19f97da | |||
| 3420eb968e | |||
| f9f48c3f81 | |||
| 5fad7b1efc | |||
| 1337ad97f1 | |||
| 520d6c05b1 | |||
| 52bd643b63 | |||
| 928136cb62 | |||
| 4c5bd9a7be | |||
| 9378bb5f51 | |||
| c036debd17 | |||
| d330ca4746 | |||
| 645b9a8311 | |||
| 862bd449fd | |||
| 0a7ea0251a | |||
| 5809a5478b | |||
| 8bbdfb4a9e | |||
| 74b5ff48fc | |||
| c6c24aaf9f | |||
| e133b59969 | |||
| 61fea7afaa | |||
| 46aa4ab005 | |||
| 8a8b2607a1 | |||
| 5df604e480 | |||
| 7a169dd555 | |||
| af49e6fd6f | |||
| 36a03d91fc | |||
| 143246237d | |||
| a0dbfeaba5 | |||
| d19d6efa8b | |||
| fd97c50da8 | |||
| de109abd76 | |||
| 0e5830df20 | |||
| 20e158c7bd | |||
| b77c908719 | |||
| 5f9dc2780c | |||
| 9c905da702 | |||
| 4729e7f29e | |||
| 10c5a2739c | |||
| 8d75729bca | |||
| 3f61b8cfdc | |||
| 3a55fdfff0 | |||
| 941fb60336 | |||
| 133261fba2 | |||
| 9fc01567a2 | |||
| 4118204948 | |||
| 12bb8d3f68 | |||
| 88517ff0f7 | |||
| 6dd3758f98 | |||
| e3630465ed | |||
| 4715f18c07 | |||
| b1d4520360 | |||
| 467b5e8e19 | |||
| ce39d3837f | |||
| 884dd94242 | |||
| df2ff662dd | |||
| f602d4e69d | |||
| 27f898b696 | |||
| 400afddc45 | |||
| edf7edced3 | |||
| 0ef5c36900 | |||
| a008ede927 | |||
| b4e88937cc | |||
| fd3992cabd | |||
| 47bed89fe2 | |||
| 361fff7803 | |||
| a6ba94e03f | |||
| d39e2514b2 | |||
| 155757dd74 | |||
| cc97e90a3e | |||
| 68160dd9c4 | |||
| 7694c95e1a | |||
| 5b91a4a9e8 | |||
| 7c56449874 | |||
| 3dbe83fd3e | |||
| 2a3bce8cd8 | |||
| d3303b2436 | |||
| 5d2717d584 | |||
| e22fd87d45 | |||
| 3a6f1bf92c | |||
| ae3283adda | |||
| 145158a925 | |||
| e46e1a0814 | |||
| 7a624cd065 | |||
| fe7c8743b9 | |||
| ccc1aae78e | |||
| 935e17c200 | |||
| 63bb1f24a3 | |||
| cdc8189ad1 | |||
| f6daab2111 | |||
| 7ed461e6ef | |||
| 15636348b5 | |||
| a370ad48d5 | |||
| 8900631b20 | |||
| fa3ef24999 | |||
| deaf58c47b | |||
| 1d4573a657 | |||
| 84300fc734 | |||
| 2b9873e76b | |||
| c589c599fc | |||
| 2702e80314 | |||
| bb1dda41ff | |||
| ddafd95b69 | |||
| d6e87a7137 | |||
| 54eca89c4e | |||
| fbe34d7377 | |||
| e2b497f158 | |||
| ca9fcf8e1d | |||
| 4fb012c0e4 | |||
| b77448f2a9 | |||
| 3bc921c8d7 | |||
| 54c08cf79b | |||
| 199c51722c | |||
| 379af164dd | |||
| dc4ba652b4 | |||
| 0e8561a1c5 | |||
| 60ec928a8e | |||
| 26f3ab65a7 | |||
| 0cc209484d | |||
| f09a7815af | |||
| 70b573cec0 | |||
| 0d79196eda | |||
| 8eb4e05055 | |||
| cd793a9b53 | |||
| fad75e2f5b | |||
| d6710453e5 | |||
| 3a8c9cbcfa | |||
| 8586fee5ab | |||
| 8ccae473ed | |||
| 02434afef3 | |||
| 192ee98dd1 | |||
| 79e9dc7be6 | |||
| 19fe7e8d18 | |||
| e4ecfca5be | |||
| f37925b637 | |||
| 822b783063 | |||
| 7dcf1d4f15 | |||
| d0af303d6f | |||
| fd31d394a5 | |||
| 1c0af19c88 | |||
| 9d77613ee9 | |||
| 62f747797c | |||
| fccb7fc2d4 | |||
| 7fecbdd000 | |||
| 46a2f2b810 | |||
| e9dcdda64d | |||
| 67a0644c37 | |||
| 21fc2ce2fd | |||
| 20425bf6b1 | |||
| 828c288570 | |||
| cde2589755 | |||
| 5fb2f452e2 | |||
| 6aea252d3e | |||
| 7337dcb5d4 | |||
| deeb5fc100 | |||
| 5b15a0b29d | |||
| e2ad2e0193 | |||
| 5b48426fc1 | |||
| a24947ab3b | |||
| 8926b451d0 | |||
| afd37d3455 | |||
| 4f84beb835 | |||
| d54d894a66 | |||
| 7c854d31a7 | |||
| 4c2caad4a0 | |||
| 9684fd978b | |||
| a79a3f680f | |||
| 1e18423b04 | |||
| 82b208c0bf | |||
| 8a3e9504a4 | |||
| d8748c6e27 | |||
| af8192fbc0 | |||
| 3492587d63 | |||
| b8c522021d | |||
| 5850c319f2 | |||
| afbed43185 | |||
| d083430c73 | |||
| 79f553ff0c | |||
| a02551f5ec | |||
| 7c486d57fc | |||
| 41acf28be9 | |||
| 21e5f4fc56 | |||
| 2d54cbba9c | |||
| 88844c895c | |||
| 7aa7f42c52 | |||
| 59f4b0da4f | |||
| e99e6ebd49 | |||
| 3268c32627 | |||
| 5cb5cae113 | |||
| dbd1050948 | |||
| 69c647336e | |||
| 1a1dd74142 | |||
| 307dcd1929 | |||
| 06bde85e03 | |||
| 9cbb55d780 | |||
| 744bc11a2e | |||
| 4fe8964d66 | |||
| 3f13ef28a9 | |||
| 28c569ce2a | |||
| c1dd3c1131 | |||
| 492706367b | |||
| cbdbe65b7b | |||
| de84736ba4 | |||
| 0043ed9291 | |||
| 89f7d21739 | |||
| fb0b11e626 | |||
| 70d72060d9 | |||
| cc8a7ee8e7 | |||
| 58197f2b23 | |||
| d2473e0fcc | |||
| 3a4de053b8 | |||
| b97dd31c9d | |||
| a632c744bc | |||
| 7eeb7d8600 | |||
| 8de492d16b | |||
| 2b82a0816c | |||
| 25daf6b502 | |||
| 011cef8124 | |||
| d06eea2c26 | |||
| cdb6738941 | |||
| f39b6f50e3 | |||
| 4d03a36940 | |||
| 60fc2ab22a | |||
| 0ad841a2d2 | |||
| 58ce210924 | |||
| 0214b212ea | |||
| ee37d7c30e | |||
| 4395bc9dc5 | |||
| 4ea945f0b4 | |||
| b221242db3 | |||
| ad6793f614 | |||
| 72f080f795 | |||
| ee4210e1cc | |||
| fc68ba6391 | |||
| 539829d00d | |||
| 65c0d2a970 | |||
| 696341b779 |
+1
-1
@@ -5,4 +5,4 @@ node_modules
|
||||
dist
|
||||
.git
|
||||
e2e
|
||||
|
||||
docs
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
version = 1
|
||||
merge.notify_on_conflict = false
|
||||
@@ -1,10 +1,9 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
title: ""
|
||||
labels: ""
|
||||
assignees: amir20
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
@@ -12,6 +11,7 @@ A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
@@ -24,9 +24,10 @@ A clear and concise description of what you expected to happen.
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS, Windows or Mac Os]
|
||||
- Docker version [e.g. `docker version`'s output]
|
||||
- Browser & version [e.g. chrome, safari]
|
||||
- Version [e.g. 1.26.1. Can be found at `<Dozzle-host>/version`]
|
||||
|
||||
- OS: [e.g. iOS, Windows or Mac Os]
|
||||
- Docker version [e.g. `docker version`'s output]
|
||||
- Browser & version [e.g. chrome, safari]
|
||||
- Version [e.g. 1.26.1. Can be found at `<Dozzle-host>/version`]
|
||||
|
||||
**If applicable include logs with `--level debug` and browser logs**
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
title: ""
|
||||
labels: enhancement
|
||||
assignees: amir20
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "automerge"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "automerge"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
- package-ecosystem: gomod
|
||||
directory: "/"
|
||||
labels:
|
||||
- "gomod"
|
||||
- "dependencies"
|
||||
- "automerge"
|
||||
schedule:
|
||||
interval: daily
|
||||
- package-ecosystem: npm
|
||||
directory: "/"
|
||||
labels:
|
||||
- "npm"
|
||||
- "dependencies"
|
||||
- "automerge"
|
||||
schedule:
|
||||
interval: daily
|
||||
- package-ecosystem: npm
|
||||
directory: "/e2e"
|
||||
labels:
|
||||
- "npm"
|
||||
- "dependencies"
|
||||
- "automerge"
|
||||
schedule:
|
||||
interval: daily
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/e2e"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "automerge"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:base", "schedule:weekly", "group:allNonMajor", ":automergeMinor"],
|
||||
"rangeStrategy": "bump"
|
||||
}
|
||||
@@ -6,6 +6,7 @@ daysUntilClose: 3
|
||||
exemptLabels:
|
||||
- pinned
|
||||
- security
|
||||
- keep
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: wontfix
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
|
||||
@@ -8,40 +8,70 @@ jobs:
|
||||
name: JavaScript Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v3
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.2.2
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v3
|
||||
name: Install Node
|
||||
with:
|
||||
version: 6.20.1
|
||||
node-version: latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
run: pnpm install --frozen-lockfile --prefer-offline
|
||||
- name: Run Tests
|
||||
run: pnpm run test
|
||||
run: pnpm run test -- --outputTruncateLength=500
|
||||
go-test:
|
||||
name: Go Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v3
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: 1.18.x
|
||||
go-version: 1.20.x
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: Run Go Tests with Coverage
|
||||
run: make test SKIP_ASSET=1
|
||||
int-test:
|
||||
name: Integration Tests
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Build images
|
||||
run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose -f e2e/docker-compose.yml build
|
||||
- name: Run tests
|
||||
run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose -f e2e/docker-compose.yml up --build --force-recreate --exit-code-from cypress
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v3
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Build
|
||||
uses: docker/bake-action@v4
|
||||
with:
|
||||
load: true
|
||||
set: |
|
||||
*.cache-from=type=gha
|
||||
*.cache-to=type=gha,mode=max
|
||||
- name: Run Playwright tests
|
||||
run: docker compose up --exit-code-from playwright
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
buildx:
|
||||
needs: [go-test, npm-test, int-test]
|
||||
name: Release
|
||||
@@ -52,43 +82,35 @@ jobs:
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: amir20/dozzle
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2.0.0
|
||||
uses: docker/setup-buildx-action@885d1462b80bc1c1c7f0b00334ad271f09369c55 # v2.10.0
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2.0.0
|
||||
uses: docker/login-action@v3.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v3.1.1
|
||||
uses: docker/build-push-action@v5.0.0
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
build-args: TAG=${{ steps.meta.outputs.version }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=amir20/dozzle:latest
|
||||
cache-to: type=inline
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
git-release:
|
||||
needs: [buildx]
|
||||
name: Github Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v3
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.2.2
|
||||
with:
|
||||
version: 6.20.1
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Release to Github
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: pnpm run release -- --github.release --no-increment --no-git --ci
|
||||
run: npx changelogithub
|
||||
|
||||
@@ -10,29 +10,27 @@ jobs:
|
||||
buildx:
|
||||
name: Push branches and PRs
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == 'amir20/dozzle' }}
|
||||
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: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: amir20/dozzle
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2.0.0
|
||||
uses: docker/setup-buildx-action@885d1462b80bc1c1c7f0b00334ad271f09369c55 # v2.10.0
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2.0.0
|
||||
uses: docker/login-action@v3.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v3.1.1
|
||||
uses: docker/build-push-action@v5.0.0
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
build-args: TAG=${{ steps.meta.outputs.version }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=amir20/dozzle:master
|
||||
cache-to: type=inline
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
+66
-34
@@ -7,59 +7,91 @@ on:
|
||||
- master
|
||||
name: Test
|
||||
jobs:
|
||||
typecheck:
|
||||
name: Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v3
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --prefer-offline
|
||||
- name: Run Typecheck
|
||||
run: pnpm run typecheck
|
||||
npm-test:
|
||||
name: JavaScript Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v3
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.2.2
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v3
|
||||
name: Install Node
|
||||
with:
|
||||
version: 6.20.1
|
||||
node-version: latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
run: pnpm install --frozen-lockfile --prefer-offline
|
||||
- name: Run Tests
|
||||
run: pnpm run test
|
||||
run: pnpm run test -- --outputTruncateLength=500
|
||||
go-test:
|
||||
name: Go Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v3
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: 1.18.x
|
||||
go-version: 1.20.x
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: Run Go Tests with Coverage
|
||||
run: make test SKIP_ASSET=1
|
||||
int-test:
|
||||
name: Integration Tests
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v3
|
||||
name: Install Node
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2.0.0
|
||||
node-version: latest
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: latest
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2.0.0
|
||||
- name: Build images
|
||||
run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose -f e2e/docker-compose.yml build
|
||||
- name: Set commit message for push
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
echo "GIT_LOG_MESSAGE<<EOF" >> $GITHUB_ENV
|
||||
git log -1 --pretty=%B ${GITHUB_SHA} >> $GITHUB_ENV
|
||||
echo 'EOF' >> $GITHUB_ENV
|
||||
- name: Set commit message for pull request
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
echo "GIT_LOG_MESSAGE<<EOF" >> $GITHUB_ENV
|
||||
git log -1 --pretty=%B ${{github.event.pull_request.head.sha}} >> $GITHUB_ENV
|
||||
echo 'EOF' >> $GITHUB_ENV
|
||||
- name: Run tests
|
||||
run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose -f e2e/docker-compose.yml up --build --force-recreate --exit-code-from cypress
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Build
|
||||
uses: docker/bake-action@v4
|
||||
with:
|
||||
load: true
|
||||
set: |
|
||||
*.cache-from=type=gha
|
||||
*.cache-to=type=gha,mode=max
|
||||
- name: Run Playwright tests
|
||||
run: docker compose up --exit-code-from playwright
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
+6
-2
@@ -1,4 +1,4 @@
|
||||
|
||||
certs
|
||||
dist
|
||||
node_modules
|
||||
.cache
|
||||
@@ -6,4 +6,8 @@ static
|
||||
dozzle
|
||||
coverage
|
||||
.pnpm-debug.log
|
||||
.vscode
|
||||
coverage.out
|
||||
.netlify
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
_
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname $0)/_/husky.sh"
|
||||
|
||||
pnpm lint-staged
|
||||
@@ -0,0 +1,3 @@
|
||||
shared-workspace-lockfile=false
|
||||
include-workspace-root=true
|
||||
ignore-workspace-root-check=true
|
||||
@@ -0,0 +1,5 @@
|
||||
auto-imports.d.ts
|
||||
components.d.ts
|
||||
docs/.vitepress/cache
|
||||
docs/.vitepress/dist
|
||||
dist
|
||||
@@ -1,3 +1,4 @@
|
||||
module.exports = {
|
||||
printWidth: 120,
|
||||
plugins: ["prettier-plugin-tailwindcss"],
|
||||
};
|
||||
|
||||
@@ -1 +1 @@
|
||||
-r '\.(go)$' -R 'node_modules' -G '*_test.go' -s -- go run main.go --level debug
|
||||
-r '\.(go)$' -R 'node_modules' -G '\*\_test.go' -s -- go run main.go --level debug
|
||||
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"i18n-ally.localesPaths": ["locales"],
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"cSpell.words": ["healthcheck"],
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
+13
-13
@@ -14,22 +14,22 @@ appearance, race, religion, or sexual identity and orientation.
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
- Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
- The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
|
||||
+14
-11
@@ -1,25 +1,28 @@
|
||||
# Build assets
|
||||
FROM --platform=$BUILDPLATFORM node:18-alpine as node
|
||||
|
||||
RUN npm install -g pnpm
|
||||
FROM --platform=$BUILDPLATFORM node:20-alpine as node
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Install dependencies from lock file
|
||||
COPY pnpm-lock.yaml ./
|
||||
RUN pnpm fetch --prod
|
||||
COPY pnpm-*.yaml ./
|
||||
RUN pnpm fetch --ignore-scripts --no-optional
|
||||
|
||||
# Copy files
|
||||
COPY package.json .* vite.config.ts index.html ./
|
||||
# Copy package.json and install dependencies
|
||||
COPY package.json ./
|
||||
RUN pnpm install --offline --ignore-scripts --no-optional
|
||||
|
||||
# Copy assets to build
|
||||
# Copy assets and translations to build
|
||||
COPY .* *.config.ts *.config.js ./
|
||||
COPY assets ./assets
|
||||
COPY locales ./locales
|
||||
COPY public ./public
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install -r --offline --prod --ignore-scripts && pnpm build
|
||||
# Build assets
|
||||
RUN pnpm build
|
||||
|
||||
FROM --platform=$BUILDPLATFORM golang:1.19.0-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.21.1-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache ca-certificates && mkdir /dozzle
|
||||
|
||||
|
||||
@@ -31,4 +31,4 @@ dev:
|
||||
|
||||
.PHONY: int
|
||||
int:
|
||||
docker-compose -f e2e/docker-compose.yml up --build --force-recreate --exit-code-from cypress
|
||||
docker compose up --build --force-recreate --exit-code-from playwright
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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.
|
||||
|
||||

|
||||
https://user-images.githubusercontent.com/260667/227634771-9ebbe381-16a8-465a-b28a-450c5cd20c94.mp4
|
||||
|
||||
[](https://goreportcard.com/report/github.com/amir20/dozzle)
|
||||
[](https://hub.docker.com/r/amir20/dozzle/)
|
||||
@@ -37,16 +37,7 @@ The simplest way to use dozzle is to run the docker container. Also, mount the D
|
||||
|
||||
Dozzle will be available at [http://localhost:8888/](http://localhost:8888/). You can change `-p 8888:8080` to any port. For example, if you want to view dozzle over port 4040 then you would do `-p 4040:8080`.
|
||||
|
||||
### With Docker swarm
|
||||
|
||||
docker service create \
|
||||
--name=dozzle \
|
||||
--publish=8888:8080 \
|
||||
--constraint=node.role==manager \
|
||||
--mount=type=bind,src=/var/run/docker.sock,dst=/var/run/docker.sock \
|
||||
amir20/dozzle:latest
|
||||
|
||||
### With Docker compose
|
||||
### Connecting with Docker compose
|
||||
|
||||
version: "3"
|
||||
services:
|
||||
@@ -58,8 +49,35 @@ Dozzle will be available at [http://localhost:8888/](http://localhost:8888/). Yo
|
||||
ports:
|
||||
- 9999:8080
|
||||
|
||||
### Connecting to remote hosts
|
||||
|
||||
### Enabling health check
|
||||
Dozzle supports connecting to multiple remote hosts via `tcp://` using TLS or without. Appropriate certs need to be mounted for Dozzle to be able to successfully connect. At this point, `ssh://` is not supported because Dozzle docker image does not ship with any ssh clients.
|
||||
|
||||
To configure remote hosts, `--remote-host` or `DOZZLE_REMOTE_HOST` need to provided and the `pem` files need to be mounted to `/cert` directory. The `/cert` directory expects to have `/certs/{ca,cert,key}.pem` or `/certs/{host}/{ca,cert,key}.pem` in case of multiple hosts.
|
||||
|
||||
Below are examples of using `--remote-host` via CLI:
|
||||
|
||||
$ docker run -v /var/run/docker.sock:/var/run/docker.sock -v /path/to/certs:/certs -p 8080:8080 amir20/dozzle --remote-host tcp://167.99.1.1:2376
|
||||
|
||||
Multiple `--remote-host` flags can be used to specify multiple hosts.
|
||||
|
||||
Or to use compose:
|
||||
|
||||
version: "3"
|
||||
services:
|
||||
dozzle:
|
||||
image: amir20/dozzle:latest
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /path/to/certs:/certs
|
||||
ports:
|
||||
- 8080:8080
|
||||
environment:
|
||||
DOZZLE_REMOTE_HOST: tcp://167.99.1.1:2376,tcp://167.99.1.2:2376
|
||||
|
||||
You need to make sure appropriate certs are provided in `/certs/167.99.1.1/{ca,cert,key}.pem` and `/certs/167.99.1.2/{ca,cert,key}.pem` for both hosts to work.
|
||||
|
||||
### Adding health check
|
||||
|
||||
Dozzle doesn't enable healthcheck by default as it adds extra CPU usage. `healthcheck` can be enabled manually.
|
||||
|
||||
@@ -81,7 +99,6 @@ Dozzle doesn't enable healthcheck by default as it adds extra CPU usage. `health
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
|
||||
#### Security
|
||||
|
||||
You can control the device Dozzle binds to by passing `--addr` parameter. For example,
|
||||
@@ -98,7 +115,7 @@ this would then only allow you to view containers with a name starting with "foo
|
||||
|
||||
#### Authentication
|
||||
|
||||
Dozzle supports a very simple authentication out of the box with just username and password. You should deploy using SSL to keep the credentials safe. See configuration to use `--username` and `--password`.
|
||||
Dozzle supports a very simple authentication out of the box with just username and password. You should deploy using SSL to keep the credentials safe. See configuration to use `--username` and `--password`. You can also use [docker secrets](https://docs.docker.com/engine/swarm/secrets/) `--usernamefile` and `--passwordfile`.
|
||||
|
||||
#### Changing base URL
|
||||
|
||||
@@ -119,17 +136,19 @@ If you do not want to be tracked at all, see the `--no-analytics` flag below.
|
||||
|
||||
Dozzle follows the [12-factor](https://12factor.net/) model. Configurations can use the CLI flags or environment variables. The table below outlines all supported options and their respective env vars.
|
||||
|
||||
| Flag | Env Variable | Default |
|
||||
| ---------------- | --------------------- | ------- |
|
||||
| `--addr` | `DOZZLE_ADDR` | `:8080` |
|
||||
| `--base` | `DOZZLE_BASE` | `/` |
|
||||
| `--level` | `DOZZLE_LEVEL` | `info` |
|
||||
| n/a | `DOCKER_API_VERSION` | not set |
|
||||
| `--tailSize` | `DOZZLE_TAILSIZE` | `300` |
|
||||
| `--filter` | `DOZZLE_FILTER` | `""` |
|
||||
| `--username` | `DOZZLE_USERNAME` | `""` |
|
||||
| `--password` | `DOZZLE_PASSWORD` | `""` |
|
||||
| `--no-analytics` | `DOZZLE_NO_ANALYTICS` | false |
|
||||
| Flag | Env Variable | Default |
|
||||
| ---------------- | ---------------------- | ------- |
|
||||
| `--addr` | `DOZZLE_ADDR` | `:8080` |
|
||||
| `--base` | `DOZZLE_BASE` | `/` |
|
||||
| `--hostname` | `DOZZLE_HOSTNAME` | `""` |
|
||||
| `--level` | `DOZZLE_LEVEL` | `info` |
|
||||
| `--filter` | `DOZZLE_FILTER` | `""` |
|
||||
| `--username` | `DOZZLE_USERNAME` | `""` |
|
||||
| `--password` | `DOZZLE_PASSWORD` | `""` |
|
||||
| `--usernamefile` | `DOZZLE_USERNAME_FILE` | `""` |
|
||||
| `--passwordfile` | `DOZZLE_PASSWORD_FILE` | `""` |
|
||||
| `--no-analytics` | `DOZZLE_NO_ANALYTICS` | false |
|
||||
| `--remote-host` | `DOZZLE_REMOTE_HOST` | |
|
||||
|
||||
## Troubleshooting and FAQs
|
||||
|
||||
@@ -181,6 +200,28 @@ Dozzle has a [special route](https://github.com/amir20/dozzle/blob/master/assets
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>I installed Dozzle but memory consumption doesn't show up!</summary>
|
||||
|
||||
_This is an issue specific to ARM devices_
|
||||
|
||||
Dozzle uses the Docker API to gather information about the containers' memory usage. If the memory usage is not showing up, then it is likely that the Docker API is not returning the memory usage.
|
||||
|
||||
You can verify this by running `docker info`, and you should see the following:
|
||||
|
||||
```
|
||||
WARNING: No memory limit support
|
||||
WARNING: No swap limit support
|
||||
```
|
||||
|
||||
In this case, you'll need to add the following line to your `/boot/cmdline.txt` file and reboot your device.
|
||||
|
||||
```
|
||||
cgroup_enable=cpuset cgroup_enable=memory cgroup_memory=1
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
+19
-1
@@ -21,7 +21,25 @@ func SendStartEvent(se StartEvent) error {
|
||||
},
|
||||
}
|
||||
|
||||
jsonValue, err := json.Marshal(postBody)
|
||||
return doRequest(postBody)
|
||||
}
|
||||
|
||||
func SendRequestEvent(re RequestEvent) error {
|
||||
postBody := map[string]interface{}{
|
||||
"client_id": re.ClientId,
|
||||
"events": []map[string]interface{}{
|
||||
{
|
||||
"name": "request",
|
||||
"params": re,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return doRequest(postBody)
|
||||
}
|
||||
|
||||
func doRequest(body map[string]interface{}) error {
|
||||
jsonValue, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+14
-7
@@ -1,11 +1,18 @@
|
||||
package analytics
|
||||
|
||||
type StartEvent struct {
|
||||
ClientId string `json:"-"`
|
||||
Version string `json:"version"`
|
||||
FilterLength int `json:"filterLength"`
|
||||
CustomAddress bool `json:"customAddress"`
|
||||
CustomBase bool `json:"customBase"`
|
||||
TailSize int `json:"tailSize"`
|
||||
Protected bool `json:"protected"`
|
||||
ClientId string `json:"-"`
|
||||
Version string `json:"version"`
|
||||
FilterLength int `json:"filterLength"`
|
||||
RemoteHostLength int `json:"remoteHostLength"`
|
||||
CustomAddress bool `json:"customAddress"`
|
||||
CustomBase bool `json:"customBase"`
|
||||
Protected bool `json:"protected"`
|
||||
HasHostname bool `json:"hasHostname"`
|
||||
}
|
||||
|
||||
type RequestEvent struct {
|
||||
ClientId string `json:"-"`
|
||||
TotalContainers int `json:"totalContainers"`
|
||||
RunningContainers int `json:"runningContainers"`
|
||||
}
|
||||
|
||||
+22
-122
@@ -1,84 +1,8 @@
|
||||
<template>
|
||||
<main>
|
||||
<mobile-menu v-if="isMobile && !authorizationNeeded"></mobile-menu>
|
||||
|
||||
<splitpanes @resized="onResized($event)">
|
||||
<pane min-size="10" :size="menuWidth" v-if="!authorizationNeeded && !isMobile && !collapseNav">
|
||||
<side-menu @search="showFuzzySearch"></side-menu>
|
||||
</pane>
|
||||
<pane min-size="10">
|
||||
<splitpanes>
|
||||
<pane class="has-min-height router-view">
|
||||
<router-view></router-view>
|
||||
</pane>
|
||||
<template v-if="!isMobile">
|
||||
<pane v-for="other in activeContainers" :key="other.id">
|
||||
<log-container
|
||||
:id="other.id"
|
||||
show-title
|
||||
scrollable
|
||||
closable
|
||||
@close="containerStore.removeActiveContainer(other)"
|
||||
></log-container>
|
||||
</pane>
|
||||
</template>
|
||||
</splitpanes>
|
||||
</pane>
|
||||
</splitpanes>
|
||||
<button
|
||||
@click="collapseNav = !collapseNav"
|
||||
class="button is-rounded"
|
||||
:class="{ collapsed: collapseNav }"
|
||||
id="hide-nav"
|
||||
v-if="!isMobile && !authorizationNeeded"
|
||||
>
|
||||
<span class="icon ml-2" v-if="collapseNav">
|
||||
<mdi-light-chevron-right />
|
||||
</span>
|
||||
<span class="icon" v-else>
|
||||
<mdi-light-chevron-left />
|
||||
</span>
|
||||
</button>
|
||||
</main>
|
||||
<router-view></router-view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import { ref, onMounted, watchEffect } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useProgrammatic } from "@oruga-ui/oruga-next";
|
||||
import hotkeys from "hotkeys-js";
|
||||
|
||||
import { setTitle } from "@/composables/title";
|
||||
import { isMobile } from "@/composables/media";
|
||||
import { smallerScrollbars, lightTheme, menuWidth } from "@/composables/settings";
|
||||
import { useContainerStore } from "@/stores/container";
|
||||
import config from "@/stores/config";
|
||||
|
||||
import FuzzySearchModal from "@/components/FuzzySearchModal.vue";
|
||||
import LogContainer from "@/components/LogContainer.vue";
|
||||
import SideMenu from "@/components/SideMenu.vue";
|
||||
import MobileMenu from "@/components/MobileMenu.vue";
|
||||
|
||||
const collapseNav = ref(false);
|
||||
const { oruga } = useProgrammatic();
|
||||
const { authorizationNeeded } = config;
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
|
||||
const { activeContainers, visibleContainers } = storeToRefs(containerStore);
|
||||
|
||||
onMounted(() => {
|
||||
hotkeys("command+k, ctrl+k", (event, handler) => {
|
||||
event.preventDefault();
|
||||
showFuzzySearch();
|
||||
});
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
setTitle(`${visibleContainers.value.length} containers`);
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
if (smallerScrollbars.value) {
|
||||
document.documentElement.classList.add("has-custom-scrollbars");
|
||||
@@ -97,59 +21,35 @@ watchEffect(() => {
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
}
|
||||
});
|
||||
|
||||
function showFuzzySearch() {
|
||||
oruga.modal.open({
|
||||
// parent: this,
|
||||
component: FuzzySearchModal,
|
||||
animation: "false",
|
||||
width: 600,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
function onResized(e: any) {
|
||||
if (e.length == 2) {
|
||||
menuWidth.value = e[0].size;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.splitpanes--vertical > .splitpanes__splitter) {
|
||||
min-width: 3px;
|
||||
background: var(--border-color);
|
||||
&:hover {
|
||||
background: var(--border-hover-color);
|
||||
<style lang="postcss">
|
||||
html.has-custom-scrollbars {
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
display: content;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.router-view {
|
||||
padding-top: 75px;
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(128, 128, 128, 0.33);
|
||||
outline: 1px solid slategrey;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.button.has-no-border {
|
||||
border-color: transparent !important;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:active {
|
||||
background-color: #777;
|
||||
}
|
||||
|
||||
.has-min-height {
|
||||
min-height: 100vh;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
#hide-nav {
|
||||
position: fixed;
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
&.collapsed {
|
||||
left: -40px;
|
||||
width: 60px;
|
||||
padding-left: 40px;
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
::-webkit-scrollbar-track:hover {
|
||||
background-color: rgba(64, 64, 64, 0.33);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
left: -25px;
|
||||
}
|
||||
section main {
|
||||
scrollbar-color: #353535 transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Vendored
+1040
File diff suppressed because it is too large
Load Diff
Vendored
+67
-30
@@ -1,44 +1,81 @@
|
||||
// generated by unplugin-vue-components
|
||||
// We suggest you to commit this file into source control
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
import '@vue/runtime-core'
|
||||
|
||||
export {}
|
||||
|
||||
declare module '@vue/runtime-core' {
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
CarbonCaretDown: typeof import('~icons/carbon/caret-down')['default']
|
||||
CilColumns: typeof import('~icons/cil/columns')['default']
|
||||
CilFindInPage: typeof import('~icons/cil/find-in-page')['default']
|
||||
ContainerStat: typeof import('./components/ContainerStat.vue')['default']
|
||||
ContainerTitle: typeof import('./components/ContainerTitle.vue')['default']
|
||||
DropdownMenu: typeof import('./components/DropdownMenu.vue')['default']
|
||||
FieldList: typeof import('./components/FieldList.vue')['default']
|
||||
BarChart: typeof import('./components/BarChart.vue')['default']
|
||||
'Carbon:caretDown': typeof import('~icons/carbon/caret-down')['default']
|
||||
'Carbon:circleSolid': typeof import('~icons/carbon/circle-solid')['default']
|
||||
'Carbon:information': typeof import('~icons/carbon/information')['default']
|
||||
'Carbon:macShift': typeof import('~icons/carbon/mac-shift')['default']
|
||||
'Carbon:star': typeof import('~icons/carbon/star')['default']
|
||||
'Carbon:starFilled': typeof import('~icons/carbon/star-filled')['default']
|
||||
'Carbon:warning': typeof import('~icons/carbon/warning')['default']
|
||||
'Cil:checkCircle': typeof import('~icons/cil/check-circle')['default']
|
||||
'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']
|
||||
ComplexLogItem: typeof import('./components/LogViewer/ComplexLogItem.vue')['default']
|
||||
ContainerHealth: typeof import('./components/LogViewer/ContainerHealth.vue')['default']
|
||||
ContainerPopup: typeof import('./components/LogViewer/ContainerPopup.vue')['default']
|
||||
ContainerStat: typeof import('./components/LogViewer/ContainerStat.vue')['default']
|
||||
ContainerTable: typeof import('./components/ContainerTable.vue')['default']
|
||||
ContainerTitle: typeof import('./components/LogViewer/ContainerTitle.vue')['default']
|
||||
DateTime: typeof import('./components/common/DateTime.vue')['default']
|
||||
DistanceTime: typeof import('./components/common/DistanceTime.vue')['default']
|
||||
DockerEventLogItem: typeof import('./components/LogViewer/DockerEventLogItem.vue')['default']
|
||||
Dropdown: typeof import('./components/common/Dropdown.vue')['default']
|
||||
FieldList: typeof import('./components/LogViewer/FieldList.vue')['default']
|
||||
FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default']
|
||||
'Ic:sharpFindInPage': typeof import('~icons/ic/sharp-find-in-page')['default']
|
||||
'Ic:sharpKeyboardReturn': typeof import('~icons/ic/sharp-keyboard-return')['default']
|
||||
InfiniteLoader: typeof import('./components/InfiniteLoader.vue')['default']
|
||||
JSONPayload: typeof import('./components/JSONPayload.vue')['default']
|
||||
LogActionsToolbar: typeof import('./components/LogActionsToolbar.vue')['default']
|
||||
LogContainer: typeof import('./components/LogContainer.vue')['default']
|
||||
LogEventSource: typeof import('./components/LogEventSource.vue')['default']
|
||||
LogViewer: typeof import('./components/LogViewer.vue')['default']
|
||||
LogViewerWithSource: typeof import('./components/LogViewerWithSource.vue')['default']
|
||||
MdiDotsVertical: typeof import('~icons/mdi/dots-vertical')['default']
|
||||
MdiLightChevronDoubleDown: typeof import('~icons/mdi-light/chevron-double-down')['default']
|
||||
MdiLightChevronLeft: typeof import('~icons/mdi-light/chevron-left')['default']
|
||||
MdiLightChevronRight: typeof import('~icons/mdi-light/chevron-right')['default']
|
||||
MdiLightCog: typeof import('~icons/mdi-light/cog')['default']
|
||||
MdiLightMagnify: typeof import('~icons/mdi-light/magnify')['default']
|
||||
MobileMenu: typeof import('./components/MobileMenu.vue')['default']
|
||||
OcticonContainer24: typeof import('~icons/octicon/container24')['default']
|
||||
OcticonDownload24: typeof import('~icons/octicon/download24')['default']
|
||||
OcticonTrash24: typeof import('~icons/octicon/trash24')['default']
|
||||
PastTime: typeof import('./components/PastTime.vue')['default']
|
||||
RelativeTime: typeof import('./components/RelativeTime.vue')['default']
|
||||
KeyShortcut: typeof import('./components/common/KeyShortcut.vue')['default']
|
||||
LogActionsToolbar: typeof import('./components/LogViewer/LogActionsToolbar.vue')['default']
|
||||
LogContainer: typeof import('./components/LogViewer/LogContainer.vue')['default']
|
||||
LogDate: typeof import('./components/LogViewer/LogDate.vue')['default']
|
||||
LogEventSource: typeof import('./components/LogViewer/LogEventSource.vue')['default']
|
||||
LogLevel: typeof import('./components/LogViewer/LogLevel.vue')['default']
|
||||
LogStd: typeof import('./components/LogViewer/LogStd.vue')['default']
|
||||
LogViewer: typeof import('./components/LogViewer/LogViewer.vue')['default']
|
||||
LogViewerWithSource: typeof import('./components/LogViewer/LogViewerWithSource.vue')['default']
|
||||
'Mdi:arrowUp': typeof import('~icons/mdi/arrow-up')['default']
|
||||
'Mdi:close': typeof import('~icons/mdi/close')['default']
|
||||
'Mdi:hamburgerMenu': typeof import('~icons/mdi/hamburger-menu')['default']
|
||||
'Mdi:keyboardEsc': typeof import('~icons/mdi/keyboard-esc')['default']
|
||||
'Mdi:lightChevronDoubleDown': typeof import('~icons/mdi-light/chevron-double-down')['default']
|
||||
'Mdi:lightChevronLeft': typeof import('~icons/mdi-light/chevron-left')['default']
|
||||
'Mdi:lightChevronRight': typeof import('~icons/mdi-light/chevron-right')['default']
|
||||
'Mdi:lightCog': typeof import('~icons/mdi-light/cog')['default']
|
||||
'Mdi:lightLogout': typeof import('~icons/mdi-light/logout')['default']
|
||||
'Mdi:lightMagnify': typeof import('~icons/mdi-light/magnify')['default']
|
||||
'MdiLight:check': typeof import('~icons/mdi-light/check')['default']
|
||||
MobileMenu: typeof import('./components/common/MobileMenu.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']
|
||||
'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']
|
||||
Popup: typeof import('./components/Popup.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
ScrollableView: typeof import('./components/ScrollableView.vue')['default']
|
||||
ScrollProgress: typeof import('./components/ScrollProgress.vue')['default']
|
||||
Search: typeof import('./components/Search.vue')['default']
|
||||
SideMenu: typeof import('./components/SideMenu.vue')['default']
|
||||
SidePanel: typeof import('./components/SidePanel.vue')['default']
|
||||
SimpleLogItem: typeof import('./components/LogViewer/SimpleLogItem.vue')['default']
|
||||
SkippedEntriesLogItem: typeof import('./components/LogViewer/SkippedEntriesLogItem.vue')['default']
|
||||
StatMonitor: typeof import('./components/LogViewer/StatMonitor.vue')['default']
|
||||
StatSparkline: typeof import('./components/LogViewer/StatSparkline.vue')['default']
|
||||
Tag: typeof import('./components/common/Tag.vue')['default']
|
||||
TimedButton: typeof import('./components/common/TimedButton.vue')['default']
|
||||
Toggle: typeof import('./components/common/Toggle.vue')['default']
|
||||
ZigZag: typeof import('./components/LogViewer/ZigZag.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div class="bar h-7 origin-left rounded-br rounded-tr bg-primary transition-transform"></div>
|
||||
<div class="absolute inset-0 flex flex-col justify-center px-2 text-sm">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const { value } = defineProps<{ value: number }>();
|
||||
|
||||
const minValue = computed(() => Math.min(value, 1));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bar {
|
||||
transform: scaleX(v-bind(minValue));
|
||||
}
|
||||
</style>
|
||||
@@ -1,34 +0,0 @@
|
||||
<template>
|
||||
<div class="is-size-7 is-uppercase columns is-marginless is-mobile" v-if="container.stat">
|
||||
<div class="column is-narrow has-text-weight-bold">
|
||||
{{ container.state }}
|
||||
</div>
|
||||
<div class="column is-narrow" v-if="container.stat.memoryUsage !== null">
|
||||
<span class="has-text-weight-light has-spacer">mem</span>
|
||||
<span class="has-text-weight-bold">
|
||||
{{ formatBytes(container.stat.memoryUsage) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="column is-narrow" v-if="container.stat.cpu !== null">
|
||||
<span class="has-text-weight-light has-spacer">load</span>
|
||||
<span class="has-text-weight-bold"> {{ container.stat.cpu }}% </span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Container } from "@/types/Container";
|
||||
import { ComputedRef, inject } from "vue";
|
||||
import { formatBytes } from "@/utils";
|
||||
|
||||
const container = inject("container") as ComputedRef<Container>;
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.has-spacer {
|
||||
&::after {
|
||||
content: " ";
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
!
|
||||
<template>
|
||||
<table class="table table-lg bg-base">
|
||||
<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')">
|
||||
<bar-chart :value="container.movingAverage.cpu / 100">
|
||||
{{ (container.movingAverage.cpu / 100).toLocaleString(undefined, { style: "percent" }) }}
|
||||
</bar-chart>
|
||||
</td>
|
||||
<td v-if="isVisible('mem')">
|
||||
<bar-chart :value="container.movingAverage.memory / 100">
|
||||
{{ (container.movingAverage.memory / 100).toLocaleString(undefined, { style: "percent" }) }}
|
||||
</bar-chart>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="p-4 text-center">
|
||||
<nav class="join" v-if="isPaginated">
|
||||
<button
|
||||
v-for="i in totalPages"
|
||||
class="btn join-item"
|
||||
:class="{ 'btn-primary': i === currentPage }"
|
||||
@click="currentPage = i"
|
||||
>
|
||||
{{ i }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Container } from "@/models/Container";
|
||||
import { toRefs } from "@vueuse/core";
|
||||
|
||||
const fields = {
|
||||
name: {
|
||||
label: "label.container-name",
|
||||
sortFunc: (a: Container, b: Container) => a.name.localeCompare(b.name) * direction.value,
|
||||
mobileVisible: true,
|
||||
},
|
||||
host: {
|
||||
label: "label.host",
|
||||
sortFunc: (a: Container, b: Container) => a.hostLabel.localeCompare(b.hostLabel) * direction.value,
|
||||
mobileVisible: false,
|
||||
},
|
||||
state: {
|
||||
label: "label.status",
|
||||
sortFunc: (a: Container, b: Container) => a.state.localeCompare(b.state) * direction.value,
|
||||
mobileVisible: false,
|
||||
},
|
||||
created: {
|
||||
label: "label.created",
|
||||
sortFunc: (a: Container, b: Container) => (a.created.getTime() - b.created.getTime()) * direction.value,
|
||||
mobileVisible: true,
|
||||
},
|
||||
cpu: {
|
||||
label: "label.avg-cpu",
|
||||
sortFunc: (a: Container, b: Container) => (a.movingAverage.cpu - b.movingAverage.cpu) * direction.value,
|
||||
mobileVisible: false,
|
||||
},
|
||||
mem: {
|
||||
label: "label.avg-mem",
|
||||
sortFunc: (a: Container, b: Container) => (a.movingAverage.memory - b.movingAverage.memory) * direction.value,
|
||||
mobileVisible: false,
|
||||
},
|
||||
};
|
||||
|
||||
const { containers, perPage = 15 } = defineProps<{
|
||||
containers: Container[];
|
||||
perPage?: number;
|
||||
}>();
|
||||
type keys = keyof typeof fields;
|
||||
|
||||
const storage = useStorage<{ column: keys; direction: 1 | -1 }>("DOZZLE_TABLE_CONTAINERS_SORT", {
|
||||
column: "created",
|
||||
direction: -1,
|
||||
});
|
||||
const { column: sortField, direction } = toRefs(storage);
|
||||
const sortedContainers = computedWithControl(
|
||||
() => [containers.length, sortField.value, direction.value],
|
||||
() => {
|
||||
return containers.sort((a, b) => {
|
||||
return fields[sortField.value].sortFunc(a, b);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const totalPages = computed(() => Math.ceil(sortedContainers.value.length / perPage));
|
||||
const isPaginated = computed(() => totalPages.value > 1);
|
||||
const currentPage = ref(1);
|
||||
const paginated = computed(() => {
|
||||
const start = (currentPage.value - 1) * perPage;
|
||||
const end = start + perPage;
|
||||
|
||||
return sortedContainers.value.slice(start, end);
|
||||
});
|
||||
|
||||
function sort(field: keys) {
|
||||
if (sortField.value === field) {
|
||||
direction.value *= -1;
|
||||
} else {
|
||||
sortField.value = field;
|
||||
direction.value = 1;
|
||||
}
|
||||
}
|
||||
function isVisible(field: keys) {
|
||||
return fields[field].mobileVisible || !isMobile.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
[data-icon] {
|
||||
display: none;
|
||||
transition: transform 0.2s ease-in-out;
|
||||
[data-direction="desc"] & {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
th {
|
||||
@apply border-b-2 border-base-lighter;
|
||||
&.selected-sort {
|
||||
font-weight: bold;
|
||||
@apply border-primary;
|
||||
[data-icon] {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tbody td {
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply hover:text-primary;
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +0,0 @@
|
||||
<template>
|
||||
<div class="columns is-marginless has-text-weight-bold is-family-monospace">
|
||||
<span class="column is-ellipsis">
|
||||
{{ container.name }}
|
||||
<span class="tag is-dark">{{ container.image.replace(/@sha.*/, "") }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Container } from "@/types/Container";
|
||||
import { inject, ComputedRef } from "vue";
|
||||
const container = inject("container") as ComputedRef<Container>;
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -1,44 +0,0 @@
|
||||
<template>
|
||||
<div class="dropdown is-hoverable">
|
||||
<div class="dropdown-trigger">
|
||||
<button class="button" aria-haspopup="true" aria-controls="dropdown-menu">
|
||||
<span class="icon">
|
||||
<mdi-dots-vertical />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dropdown-menu" id="dropdown-menu" role="menu">
|
||||
<div class="dropdown-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.minimal .button {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
border: none;
|
||||
padding: 0.1em;
|
||||
height: 100%;
|
||||
|
||||
& > .icon {
|
||||
height: 100%;
|
||||
& > svg {
|
||||
align-self: flex-start;
|
||||
height: 0.85em;
|
||||
}
|
||||
}
|
||||
}
|
||||
.is-top {
|
||||
& .dropdown-menu {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&.is-last .dropdown-menu {
|
||||
top: -30px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,84 +0,0 @@
|
||||
<template>
|
||||
<ul v-if="expanded" ref="root">
|
||||
<li v-for="(value, name) in fields">
|
||||
<template v-if="isObject(value)">
|
||||
<span class="has-text-grey">{{ name }}=</span>
|
||||
<field-list
|
||||
:fields="value"
|
||||
:parent-key="parentKey.concat(name)"
|
||||
:visible-keys="visibleKeys"
|
||||
expanded
|
||||
></field-list>
|
||||
</template>
|
||||
<template v-else-if="Array.isArray(value)">
|
||||
<a @click="toggleField(name)"> {{ hasField(name) ? "remove" : "add" }} </a>
|
||||
<span class="has-text-grey">{{ name }}=</span>[
|
||||
<span class="has-text-weight-bold" v-for="(item, index) in value">
|
||||
{{ item }}
|
||||
<span v-if="index !== value.length - 1">,</span>
|
||||
</span>
|
||||
]
|
||||
</template>
|
||||
<template v-else>
|
||||
<a @click="toggleField(name)"> {{ hasField(name) ? "remove" : "add" }} </a>
|
||||
<span class="has-text-grey">{{ name }}=</span><span class="has-text-weight-bold">{{ value }}</span>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { arrayEquals, isObject } from "@/utils";
|
||||
import { nextTick, PropType, ref, toRaw } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
type: Object as PropType<Record<string, any>>,
|
||||
required: true,
|
||||
},
|
||||
expanded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
parentKey: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: [],
|
||||
},
|
||||
visibleKeys: {
|
||||
type: Array as PropType<string[][]>,
|
||||
default: [],
|
||||
},
|
||||
});
|
||||
|
||||
const root = ref<HTMLElement>();
|
||||
|
||||
async function toggleField(field: string) {
|
||||
const index = fieldIndex(field);
|
||||
|
||||
if (index > -1) {
|
||||
props.visibleKeys.splice(index, 1);
|
||||
} else {
|
||||
props.visibleKeys.push(props.parentKey.concat(field));
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
|
||||
root.value?.scrollIntoView({
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
|
||||
function hasField(field: string) {
|
||||
return fieldIndex(field) > -1;
|
||||
}
|
||||
|
||||
function fieldIndex(field: string) {
|
||||
const path = props.parentKey.concat(field);
|
||||
return props.visibleKeys.findIndex((keys) => arrayEquals(toRaw(keys), toRaw(path)));
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
ul {
|
||||
margin-left: 2em;
|
||||
}
|
||||
</style>
|
||||
@@ -1,133 +1,119 @@
|
||||
<template>
|
||||
<div class="panel">
|
||||
<o-autocomplete
|
||||
ref="autocomplete"
|
||||
v-model="query"
|
||||
placeholder="Search containers using ⌘ + k or ctrl + k"
|
||||
field="name"
|
||||
open-on-focus
|
||||
keep-first
|
||||
expanded
|
||||
:data="results"
|
||||
@select="selected"
|
||||
>
|
||||
<template #default="props">
|
||||
<div class="media">
|
||||
<div class="media-left">
|
||||
<span class="icon is-small" :class="props.option.state">
|
||||
<octicon-container-24 />
|
||||
</span>
|
||||
<div class="dropdown dropdown-open w-full">
|
||||
<div class="input input-primary flex h-auto items-center">
|
||||
<mdi:light-magnify class="flex h-8 w-8" />
|
||||
<input
|
||||
tabindex="0"
|
||||
class="input input-ghost input-lg flex-1 px-1"
|
||||
ref="input"
|
||||
@keyup.down="selectedIndex = Math.min(selectedIndex + 1, data.length - 1)"
|
||||
@keyup.up="selectedIndex = Math.max(selectedIndex - 1, 0)"
|
||||
@keyup.enter.exact="selected(data[selectedIndex])"
|
||||
@keyup.alt.enter="addColumn(data[selectedIndex])"
|
||||
v-model="query"
|
||||
:placeholder="$t('placeholder.search-containers')"
|
||||
/>
|
||||
<mdi:keyboard-esc class="flex" />
|
||||
</div>
|
||||
<ul tabindex="0" class="menu dropdown-content rounded-box !relative mt-2 w-full bg-base-lighter p-2">
|
||||
<li v-for="(item, index) in data">
|
||||
<a
|
||||
class="grid auto-cols-max grid-cols-[min-content,auto] gap-2 py-4"
|
||||
@click.prevent="selected(item)"
|
||||
@mouseenter="selectedIndex = index"
|
||||
:class="index === selectedIndex ? 'focus' : ''"
|
||||
>
|
||||
<div :class="{ 'text-primary': item.state === 'running' }">
|
||||
<octicon:container-24 />
|
||||
</div>
|
||||
<div class="media-content">
|
||||
{{ props.option.name }}
|
||||
<div class="truncate">
|
||||
<span class="font-light">{{ item.host }}</span> / {{ item.name }}
|
||||
</div>
|
||||
<div class="media-right">
|
||||
<span class="icon is-small column-icon" @click.stop.prevent="addColumn(props.option)" title="Pin as column">
|
||||
<cil-columns />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</o-autocomplete>
|
||||
<distance-time :date="item.created" class="ml-auto text-xs font-light" />
|
||||
<a @click.stop.prevent="addColumn(item)" :title="$t('tooltip.pin-column')" class="hover:text-secondary">
|
||||
<ic:sharp-keyboard-return v-if="index === selectedIndex" />
|
||||
<cil:columns v-else />
|
||||
</a>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import fuzzysort from "fuzzysort";
|
||||
import { computed, nextTick, onMounted, ref, reactive } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useContainerStore } from "@/stores/container";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { Container } from "@/types/Container";
|
||||
import { useFuse } from "@vueuse/integrations/useFuse";
|
||||
|
||||
const props = defineProps({
|
||||
maxResults: {
|
||||
default: 20,
|
||||
type: Number,
|
||||
},
|
||||
});
|
||||
const { maxResults: resultLimit = 5 } = defineProps<{
|
||||
maxResults?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
const close = defineEmit();
|
||||
|
||||
const query = ref("");
|
||||
const autocomplete = ref<HTMLElement>();
|
||||
const input = ref<HTMLInputElement>();
|
||||
const selectedIndex = ref(0);
|
||||
|
||||
const router = useRouter();
|
||||
const store = useContainerStore();
|
||||
const { containers } = storeToRefs(store);
|
||||
const preparedContainers = computed(() =>
|
||||
containers.value.map(({ name, id, created, state }) =>
|
||||
reactive({
|
||||
name,
|
||||
|
||||
const list = computed(() => {
|
||||
return containers.value.map(({ id, created, name, state, hostLabel: host }) => {
|
||||
return {
|
||||
id,
|
||||
created,
|
||||
name,
|
||||
state,
|
||||
preparedName: fuzzysort.prepare(name),
|
||||
})
|
||||
)
|
||||
);
|
||||
host,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const results = computed(() => {
|
||||
const options = {
|
||||
limit: props.maxResults,
|
||||
key: "preparedName",
|
||||
};
|
||||
if (query.value) {
|
||||
const results = fuzzysort.go(query.value, preparedContainers.value, options);
|
||||
results.forEach((result) => {
|
||||
if (result.obj.state === "running") {
|
||||
// @ts-ignore
|
||||
result.score += 1;
|
||||
const { results } = useFuse(query, list, {
|
||||
fuseOptions: {
|
||||
keys: ["name", "host"],
|
||||
includeScore: true,
|
||||
useExtendedSearch: true,
|
||||
},
|
||||
resultLimit,
|
||||
matchAllWhenSearchEmpty: true,
|
||||
});
|
||||
|
||||
const data = computed(() => {
|
||||
return results.value
|
||||
.sort((a, b) => {
|
||||
if (a.score === b.score) {
|
||||
if (a.item.state === "running" && b.item.state !== "running") {
|
||||
return -1;
|
||||
} else {
|
||||
return 1;
|
||||
}
|
||||
} else if (a.score && b.score) {
|
||||
return a.score - b.score;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
return [...results].sort((a, b) => b.score - a.score).map((i) => i.obj);
|
||||
} else {
|
||||
return [...preparedContainers.value].sort((a, b) => b.created - a.created);
|
||||
})
|
||||
.map(({ item }) => item)
|
||||
.slice(0, resultLimit);
|
||||
});
|
||||
|
||||
watch(query, (data) => {
|
||||
if (data.length > 0) {
|
||||
selectedIndex.value = 0;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => nextTick(() => autocomplete.value?.focus()));
|
||||
onMounted(() => input.value?.focus());
|
||||
|
||||
function selected(item: { id: string; name: string }) {
|
||||
router.push({ name: "container", params: { id: item.id } });
|
||||
emit("close");
|
||||
function selected({ id }: { id: string }) {
|
||||
router.push({ name: "container-id", params: { id } });
|
||||
close();
|
||||
}
|
||||
function addColumn(container: Container) {
|
||||
function addColumn(container: { id: string }) {
|
||||
store.appendActiveContainer(container);
|
||||
emit("close");
|
||||
close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.panel {
|
||||
min-height: 400px;
|
||||
width: 580px;
|
||||
}
|
||||
|
||||
.running {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.exited {
|
||||
color: var(--scheme-main-ter);
|
||||
}
|
||||
|
||||
.column-icon {
|
||||
&:hover {
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(a.dropdown-item) {
|
||||
padding-right: 1em;
|
||||
.media-right {
|
||||
visibility: hidden;
|
||||
}
|
||||
&:hover .media-right {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
<style scoped lang="postcss"></style>
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
<template>
|
||||
<div ref="root" class="infinte-loader">
|
||||
<div class="spinner" v-show="isLoading">
|
||||
<div class="bounce1"></div>
|
||||
<div class="bounce2"></div>
|
||||
<div class="bounce3"></div>
|
||||
</div>
|
||||
<div ref="root" class="flex min-h-[1px] justify-center">
|
||||
<span class="loading loading-bars loading-md mt-4 text-primary" v-show="isLoading"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
onLoadMore: Function,
|
||||
enabled: Boolean,
|
||||
});
|
||||
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 (props.onLoadMore && props.enabled) {
|
||||
if (onLoadMore && enabled) {
|
||||
const scrollingParent = root.value?.closest("[data-scrolling]") || document.documentElement;
|
||||
const previousHeight = scrollingParent.scrollHeight;
|
||||
isLoading.value = true;
|
||||
await props.onLoadMore();
|
||||
await onLoadMore();
|
||||
isLoading.value = false;
|
||||
await nextTick();
|
||||
scrollingParent.scrollTop += scrollingParent.scrollHeight - previousHeight;
|
||||
@@ -35,41 +29,3 @@ const observer = new IntersectionObserver(async (entries) => {
|
||||
onMounted(() => observer.observe(root.value!));
|
||||
onUnmounted(() => observer.disconnect());
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.infinte-loader {
|
||||
min-height: 1px;
|
||||
}
|
||||
.spinner {
|
||||
margin: 10px auto 0;
|
||||
width: 70px;
|
||||
text-align: center;
|
||||
|
||||
& > div {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: var(--primary-color);
|
||||
border-radius: 100%;
|
||||
display: inline-block;
|
||||
animation: sk-bouncedelay 0.8s infinite ease-in-out both;
|
||||
}
|
||||
& .bounce1 {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
& .bounce2 {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sk-bouncedelay {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
<template>
|
||||
<ul class="fields" @click="expanded = !expanded">
|
||||
<li v-for="(value, name) in logEntry.message">
|
||||
<template v-if="value">
|
||||
<span class="has-text-grey">{{ name }}=</span>
|
||||
<span class="has-text-weight-bold" v-html="markSearch(value)"></span>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
<field-list :fields="logEntry.unfilteredPayload" :expanded="expanded" :visible-keys="visibleKeys"></field-list>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { useSearchFilter } from "@/composables/search";
|
||||
import { VisibleLogEntry } from "@/types/VisibleLogEntry";
|
||||
|
||||
import { PropType, ref } from "vue";
|
||||
|
||||
const { markSearch } = useSearchFilter();
|
||||
|
||||
defineProps({
|
||||
logEntry: {
|
||||
type: Object as PropType<VisibleLogEntry>,
|
||||
required: true,
|
||||
},
|
||||
visibleKeys: {
|
||||
type: Array as PropType<string[][]>,
|
||||
default: [],
|
||||
},
|
||||
});
|
||||
|
||||
const expanded = ref(false);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fields {
|
||||
display: inline-block;
|
||||
list-style: none;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
&::after {
|
||||
content: "expand json";
|
||||
color: var(--secondary-color);
|
||||
display: inline-block;
|
||||
margin-left: 0.5em;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
margin-left: 1em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,84 +0,0 @@
|
||||
<template>
|
||||
<dropdown-menu class="is-right">
|
||||
<a class="dropdown-item" @click="onClearClicked">
|
||||
<div class="level is-justify-content-start">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<octicon-trash-24 class="mr-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item">Clear</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="dropdown-item" :href="`${base}/api/logs/download?id=${container.id}`">
|
||||
<div class="level is-justify-content-start">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<octicon-download-24 class="mr-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item">Download</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<hr class="dropdown-divider" />
|
||||
<a class="dropdown-item" @click="showSearch = true">
|
||||
<div class="level is-justify-content-start">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<mdi-light-magnify class="mr-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item">Search</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</dropdown-menu>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { inject, onMounted, onUnmounted, PropType, ComputedRef } from "vue";
|
||||
import hotkeys from "hotkeys-js";
|
||||
import config from "@/stores/config";
|
||||
import { useSearchFilter } from "@/composables/search";
|
||||
import { Container } from "@/types/Container";
|
||||
|
||||
const { showSearch } = useSearchFilter();
|
||||
|
||||
const { base } = config;
|
||||
|
||||
const props = defineProps({
|
||||
onClearClicked: {
|
||||
type: Function as PropType<(e: Event) => void>,
|
||||
default: (e: Event) => {},
|
||||
},
|
||||
});
|
||||
|
||||
const onHotkey = (event: Event) => {
|
||||
props.onClearClicked(event);
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const container = inject("container") as ComputedRef<Container>;
|
||||
|
||||
onMounted(() => hotkeys("shift+command+l, shift+ctrl+l", onHotkey));
|
||||
onUnmounted(() => hotkeys.unbind("shift+command+l, shift+ctrl+l", onHotkey));
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
#download.button,
|
||||
#clear.button {
|
||||
.icon {
|
||||
height: 80%;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,78 +0,0 @@
|
||||
<template>
|
||||
<scrollable-view :scrollable="scrollable" v-if="container">
|
||||
<template #header v-if="showTitle">
|
||||
<div class="mr-0 columns is-vcentered is-marginless is-hidden-mobile">
|
||||
<div class="column is-clipped is-paddingless">
|
||||
<container-title @close="$emit('close')" />
|
||||
</div>
|
||||
<div class="column is-narrow is-paddingless">
|
||||
<container-stat v-if="container.stat" />
|
||||
</div>
|
||||
|
||||
<div class="mr-2 column is-narrow is-paddingless">
|
||||
<log-actions-toolbar :onClearClicked="onClearClicked" />
|
||||
</div>
|
||||
<div class="mr-2 column is-narrow is-paddingless" v-if="closable">
|
||||
<button class="delete is-medium" @click="emit('close')"></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ setLoading }">
|
||||
<log-viewer-with-source ref="viewer" @loading-more="setLoading($event)" />
|
||||
</template>
|
||||
</scrollable-view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { provide, ref, toRefs } from "vue";
|
||||
import LogViewerWithSource from "./LogViewerWithSource.vue";
|
||||
import { useContainerStore } from "@/stores/container";
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
showTitle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
scrollable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
closable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const { id } = toRefs(props);
|
||||
const store = useContainerStore();
|
||||
|
||||
const container = store.currentContainer(id);
|
||||
|
||||
provide("container", container);
|
||||
|
||||
const viewer = ref<InstanceType<typeof LogViewerWithSource>>();
|
||||
|
||||
function onClearClicked() {
|
||||
viewer.value?.clear();
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
button.delete {
|
||||
background-color: var(--scheme-main-ter);
|
||||
opacity: 0.6;
|
||||
&:after,
|
||||
&:before {
|
||||
background-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,25 +0,0 @@
|
||||
<template>
|
||||
<infinite-loader :onLoadMore="fetchMore" :enabled="messages.length > 100"></infinite-loader>
|
||||
<slot :messages="messages"></slot>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useEventSource } from "@/composables/eventsource";
|
||||
import { Container } from "@/types/Container";
|
||||
import { inject, ComputedRef } from "vue";
|
||||
|
||||
const emit = defineEmits(["loading-more"]);
|
||||
const container = inject("container") as ComputedRef<Container>;
|
||||
const { connect, messages, loadOlderLogs } = useEventSource(container);
|
||||
|
||||
const beforeLoading = () => emit("loading-more", true);
|
||||
const afterLoading = () => emit("loading-more", false);
|
||||
|
||||
defineExpose({
|
||||
clear: () => (messages.value = []),
|
||||
});
|
||||
|
||||
const fetchMore = () => loadOlderLogs({ beforeLoading, afterLoading });
|
||||
|
||||
connect();
|
||||
</script>
|
||||
@@ -1,203 +0,0 @@
|
||||
<template>
|
||||
<ul class="events" ref="events" :class="{ 'disable-wrap': !softWrap, [size]: true }">
|
||||
<li
|
||||
v-for="(item, index) in filtered"
|
||||
:key="item.id"
|
||||
:data-key="item.id"
|
||||
:data-event="item.event"
|
||||
:class="{ selected: toRaw(item) === toRaw(lastSelectedItem) }"
|
||||
>
|
||||
<div class="line-options" v-show="isSearching()">
|
||||
<dropdown-menu :class="{ 'is-last': index === filtered.length - 1 }" class="is-top minimal">
|
||||
<a class="dropdown-item" @click="handleJumpLineSelected($event, item)" :href="`#${item.id}`">
|
||||
<div class="level is-justify-content-start">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<cil-find-in-page class="mr-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item">Jump to Context</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</dropdown-menu>
|
||||
</div>
|
||||
<div class="line">
|
||||
<span class="date" v-if="showTimestamp"> <relative-time :date="item.date"></relative-time></span>
|
||||
<JSONPayload :log-entry="item" :visible-keys="visibleKeys.value" v-if="item.isComplex()"></JSONPayload>
|
||||
<span class="text" v-html="colorize(item.message)" v-if="item.isSimple()"></span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ComputedRef, inject, PropType, ref, toRefs, watch, toRaw } from "vue";
|
||||
import { useRouteHash } from "@vueuse/router";
|
||||
import { size, showTimestamp, softWrap } from "@/composables/settings";
|
||||
import { VisibleLogEntry } from "@/types/VisibleLogEntry";
|
||||
import { LogEntry } from "@/types/LogEntry";
|
||||
import { useSearchFilter } from "@/composables/search";
|
||||
import { useVisibleFilter } from "@/composables/visible";
|
||||
import { Container } from "@/types/Container";
|
||||
import { persistentVisibleKeys } from "@/utils";
|
||||
|
||||
import RelativeTime from "./RelativeTime.vue";
|
||||
import AnsiConvertor from "ansi-to-html";
|
||||
import JSONPayload from "./JSONPayload.vue";
|
||||
|
||||
const props = defineProps({
|
||||
messages: {
|
||||
type: Array as PropType<LogEntry[]>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const ansiConvertor = new AnsiConvertor({ escapeXML: true });
|
||||
const colorize = (value: string) => markSearch(ansiConvertor.toHtml(value));
|
||||
|
||||
const { messages } = toRefs(props);
|
||||
let visibleKeys = persistentVisibleKeys(inject("container") as ComputedRef<Container>);
|
||||
|
||||
const { filteredPayload } = useVisibleFilter(visibleKeys);
|
||||
const { filteredMessages, resetSearch, markSearch, isSearching } = useSearchFilter();
|
||||
|
||||
const visible = filteredPayload(messages);
|
||||
const filtered = filteredMessages(visible);
|
||||
|
||||
const events = ref<HTMLElement>();
|
||||
let lastSelectedItem = ref<VisibleLogEntry>();
|
||||
|
||||
function handleJumpLineSelected(e: Event, item: VisibleLogEntry) {
|
||||
lastSelectedItem.value = item;
|
||||
resetSearch();
|
||||
}
|
||||
|
||||
const routeHash = useRouteHash();
|
||||
watch(
|
||||
routeHash,
|
||||
(hash) => {
|
||||
document.querySelector(`[data-key="${hash.substring(1)}"]`)?.scrollIntoView({ block: "center" });
|
||||
},
|
||||
{ immediate: true, flush: "post" }
|
||||
);
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.events {
|
||||
padding: 1em 0;
|
||||
font-family: SFMono-Regular, Consolas, Liberation Mono, monaco, Menlo, monospace;
|
||||
|
||||
&.disable-wrap {
|
||||
.line,
|
||||
.text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
white-space: pre-wrap;
|
||||
&::before {
|
||||
content: " ";
|
||||
}
|
||||
}
|
||||
|
||||
& > li {
|
||||
display: flex;
|
||||
word-wrap: break-word;
|
||||
padding: 0.2em 1em;
|
||||
&:last-child {
|
||||
scroll-snap-align: end;
|
||||
scroll-margin-block-end: 5rem;
|
||||
}
|
||||
&:nth-child(odd) {
|
||||
background-color: rgba(125, 125, 125, 0.08);
|
||||
}
|
||||
&[data-event="container-stopped"] {
|
||||
color: #f14668;
|
||||
}
|
||||
&[data-event="container-started"] {
|
||||
color: hsl(141, 53%, 53%);
|
||||
}
|
||||
&.selected .date {
|
||||
background-color: var(--menu-item-active-background-color);
|
||||
|
||||
color: var(--text-color);
|
||||
}
|
||||
&.selected > .date {
|
||||
background-color: white;
|
||||
}
|
||||
& > .line {
|
||||
margin: auto 0;
|
||||
width: 100%;
|
||||
}
|
||||
& > .line-options {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
margin-right: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
&.small {
|
||||
font-size: 60%;
|
||||
}
|
||||
|
||||
&.medium {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
&.large {
|
||||
font-size: 120%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.date {
|
||||
background-color: #262626;
|
||||
color: #258ccd;
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
.date {
|
||||
background-color: #262626;
|
||||
color: #258ccd;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.date {
|
||||
background-color: #f0f0f0;
|
||||
color: #009900;
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
.date {
|
||||
background-color: #f0f0f0;
|
||||
color: #009900;
|
||||
}
|
||||
}
|
||||
|
||||
.date {
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
:deep(mark) {
|
||||
border-radius: 2px;
|
||||
background-color: var(--secondary-color);
|
||||
animation: pops 200ms ease-out;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes pops {
|
||||
0% {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="flex gap-x-2">
|
||||
<div v-if="showStd">
|
||||
<log-std :std="logEntry.std"></log-std>
|
||||
</div>
|
||||
<div v-if="showTimestamp">
|
||||
<log-date :date="logEntry.date"></log-date>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<log-level :level="logEntry.level"></log-level>
|
||||
</div>
|
||||
<div>
|
||||
<ul class="fields cursor-pointer space-x-4" :class="{ expanded }" @click="expandToggle()">
|
||||
<li v-for="(value, name) in validValues" class="inline-block">
|
||||
<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="markSearch(JSON.stringify(value))"> </span>
|
||||
</template>
|
||||
<span class="font-bold" v-html="markSearch(value)" v-else></span>
|
||||
</li>
|
||||
<li class="text-light" v-if="Object.keys(validValues).length === 0">all values are hidden</li>
|
||||
</ul>
|
||||
<field-list :fields="logEntry.unfilteredMessage" :expanded="expanded" :visible-keys="visibleKeys"></field-list>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { type ComplexLogEntry } from "@/models/LogEntry";
|
||||
|
||||
const { markSearch } = useSearchFilter();
|
||||
|
||||
const { logEntry } = defineProps<{
|
||||
logEntry: ComplexLogEntry;
|
||||
visibleKeys: string[][];
|
||||
}>();
|
||||
|
||||
const [expanded, expandToggle] = useToggle();
|
||||
|
||||
const validValues = computed(() => {
|
||||
return Object.fromEntries(Object.entries(logEntry.message).filter(([_, value]) => value !== undefined));
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.text-light {
|
||||
@apply text-base-content/70;
|
||||
}
|
||||
.fields {
|
||||
&:hover {
|
||||
&::after {
|
||||
content: "expand json";
|
||||
@apply ml-2 inline-block text-secondary;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
&.expanded:hover {
|
||||
&::after {
|
||||
content: "collapse json";
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div class="inline-flex h-4 w-4" :health="health" v-if="health" :title="health">
|
||||
<cil:check-circle v-if="health == 'healthy'" />
|
||||
<cil:x-circle v-else-if="health == 'unhealthy'" />
|
||||
<cil:circle v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ContainerHealth } from "@/types/Container";
|
||||
|
||||
defineProps<{
|
||||
health: ContainerHealth | undefined;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
[health="unhealthy"] {
|
||||
@apply text-red;
|
||||
}
|
||||
|
||||
[health="healthy"] {
|
||||
@apply text-green;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div>
|
||||
<span class="font-light capitalize"> RUNNING </span>
|
||||
<span class="font-semibold">
|
||||
<distance-time :date="container.created" strict :suffix="false"></distance-time>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-light capitalize"> Load </span>
|
||||
<span class="font-semibold"> {{ container.stat.cpu }}% </span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-light capitalize"> MEM </span>
|
||||
<span class="font-semibold"> {{ formatBytes(container.stat.memoryUsage) }} </span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
const { container } = defineProps<{
|
||||
container: Container;
|
||||
}>();
|
||||
</script>
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div class="flex gap-4" v-if="container.stat">
|
||||
<stat-monitor :data="memoryData" label="mem" :stat-value="formatBytes(unref(container.stat).memoryUsage)" />
|
||||
<stat-monitor :data="cpuData" label="load" :stat-value="Math.max(0, unref(container.stat).cpu) + '%'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const { container } = useContainerContext();
|
||||
|
||||
const cpuData = computedWithControl(
|
||||
() => container.value.stat,
|
||||
() => {
|
||||
const history = container.value.statHistory;
|
||||
const points: Point<unknown>[] = history.map((stat, i) => ({
|
||||
x: i,
|
||||
y: Math.max(0, stat.snapshot.cpu),
|
||||
value: Math.max(0, stat.snapshot.cpu) + "%",
|
||||
}));
|
||||
return points;
|
||||
},
|
||||
);
|
||||
|
||||
const memoryData = computedWithControl(
|
||||
() => container.value.stat,
|
||||
() => {
|
||||
const history = container.value.statHistory;
|
||||
const points: Point<string>[] = history.map((stat, i) => ({
|
||||
x: i,
|
||||
y: stat.snapshot.memory,
|
||||
value: formatBytes(stat.snapshot.memoryUsage),
|
||||
}));
|
||||
return points;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="flex flex-1 items-center gap-2 truncate">
|
||||
<container-health :health="container.health" v-if="container.health"></container-health>
|
||||
<div class="inline-flex font-mono text-sm">
|
||||
<div v-if="config.hosts.length > 1" class="mobile-hidden font-thin">
|
||||
{{ container.hostLabel }}<span class="mx-2">/</span>
|
||||
</div>
|
||||
<div class="font-semibold">{{ container.name }}</div>
|
||||
<div
|
||||
class="mobile-hidden max-w-[1.5em] truncate transition-[max-width] hover:max-w-[400px]"
|
||||
v-if="container.isSwarm"
|
||||
>
|
||||
{{ container.swarmId }}
|
||||
</div>
|
||||
</div>
|
||||
<tag class="mobile-hidden font-mono" size="small">{{ container.image.replace(/@sha.*/, "") }}</tag>
|
||||
<label class="swap swap-rotate">
|
||||
<input type="checkbox" v-model="pinned" />
|
||||
<carbon:star-filled class="swap-on text-secondary" />
|
||||
<carbon:star class="swap-off" />
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const { container } = useContainerContext();
|
||||
const pinned = computed({
|
||||
get: () => pinnedContainers.value.has(container.value.storageKey),
|
||||
set: (value) => {
|
||||
if (value) {
|
||||
pinnedContainers.value.add(container.value.storageKey);
|
||||
} else {
|
||||
pinnedContainers.value.delete(container.value.storageKey);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="flex-1 font-sans text-[0.9rem]">
|
||||
<span class="whitespace-pre-wrap" :data-event="logEntry.event" v-html="logEntry.message"></span>
|
||||
<div
|
||||
class="alert alert-info mt-8 w-auto text-[1rem] md:mx-auto md:w-1/2"
|
||||
v-if="nextContainer && logEntry.event === 'container-stopped'"
|
||||
>
|
||||
<carbon:information class="h-6 w-6 shrink-0 stroke-current" />
|
||||
<div>
|
||||
<h3 class="text-lg font-bold">{{ $t("alert.similar-container-found.title") }}</h3>
|
||||
{{ $t("alert.similar-container-found.message", { containerId: nextContainer.id }) }}
|
||||
</div>
|
||||
<div>
|
||||
<TimedButton v-if="automaticRedirect" class="btn-primary btn-sm" @finished="redirectNow()">Cancel</TimedButton>
|
||||
<router-link
|
||||
:to="{ name: 'container-id', params: { id: nextContainer.id } }"
|
||||
class="btn btn-primary btn-sm"
|
||||
v-else
|
||||
>
|
||||
Redirect
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { DockerEventLogEntry } from "@/models/LogEntry";
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { logEntry } = defineProps<{
|
||||
logEntry: DockerEventLogEntry;
|
||||
}>();
|
||||
|
||||
const store = useContainerStore();
|
||||
const { containers } = storeToRefs(store);
|
||||
const { container } = useContainerContext();
|
||||
|
||||
const nextContainer = computed(
|
||||
() =>
|
||||
containers.value
|
||||
.filter(
|
||||
(c) =>
|
||||
c.host === container.value.host &&
|
||||
c.created > logEntry.date &&
|
||||
c.name === container.value.name &&
|
||||
c.state === "running",
|
||||
)
|
||||
.toSorted((a, b) => +a.created - +b.created)[0],
|
||||
);
|
||||
|
||||
function redirectNow() {
|
||||
showToast(
|
||||
{
|
||||
title: t("alert.redirected.title"),
|
||||
message: t("alert.redirected.message", { containerId: nextContainer.value.id }),
|
||||
type: "info",
|
||||
},
|
||||
{ expire: 5000 },
|
||||
);
|
||||
router.push({ name: "container-id", params: { id: nextContainer.value.id } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
[data-event="container-stopped"] {
|
||||
@apply text-red;
|
||||
}
|
||||
[data-event="container-started"] {
|
||||
@apply text-green;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<ul v-if="expanded" ref="root" class="ml-8">
|
||||
<li v-for="(value, name) in fields">
|
||||
<template v-if="isObject(value)">
|
||||
<span class="text-light">{{ name }}=</span>
|
||||
<field-list
|
||||
:fields="value"
|
||||
:parent-key="parentKey.concat(name)"
|
||||
:visible-keys="visibleKeys"
|
||||
expanded
|
||||
></field-list>
|
||||
</template>
|
||||
<template v-else-if="Array.isArray(value)">
|
||||
<a @click="toggleField(name)" class="link-primary cursor-pointer">
|
||||
{{ hasField(name) ? "remove" : "add" }} </a
|
||||
>
|
||||
<span class="text-light">{{ name }}=</span>[
|
||||
<span class="font-bold" v-for="(item, index) in value">
|
||||
<span v-html="JSON.stringify(item)"></span><span v-if="index !== value.length - 1">,</span>
|
||||
</span>
|
||||
]
|
||||
</template>
|
||||
<template v-else>
|
||||
<a @click="toggleField(name)" class="link-primary cursor-pointer">
|
||||
{{ hasField(name) ? "remove" : "add" }} </a
|
||||
>
|
||||
<span class="text-light">{{ name }}=</span><span class="font-bold" v-html="value"></span>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { arrayEquals, isObject } from "@/utils";
|
||||
|
||||
const {
|
||||
fields,
|
||||
expanded = false,
|
||||
parentKey = [],
|
||||
visibleKeys = [],
|
||||
} = defineProps<{
|
||||
fields: Record<string, any>;
|
||||
expanded?: boolean;
|
||||
parentKey?: string[];
|
||||
visibleKeys?: string[][];
|
||||
}>();
|
||||
|
||||
const root = ref<HTMLElement>();
|
||||
|
||||
async function toggleField(field: string) {
|
||||
const index = fieldIndex(field);
|
||||
|
||||
if (index > -1) {
|
||||
visibleKeys.splice(index, 1);
|
||||
} else {
|
||||
visibleKeys.push(parentKey.concat(field));
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
|
||||
root.value?.scrollIntoView({
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
|
||||
function hasField(field: string) {
|
||||
return fieldIndex(field) > -1;
|
||||
}
|
||||
|
||||
function fieldIndex(field: string) {
|
||||
const path = parentKey.concat(field);
|
||||
return visibleKeys.findIndex((keys) => arrayEquals(toRaw(keys), toRaw(path)));
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
.text-light {
|
||||
@apply text-base-content/70;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<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="w-2.5 text-red" v-if="streamConfig.stderr" />
|
||||
<carbon:circle-solid class="w-2.5 text-blue" v-if="streamConfig.stdout" />
|
||||
</label>
|
||||
<ul tabindex="0" class="menu dropdown-content rounded-box z-50 w-52 bg-base p-1 shadow">
|
||||
<li>
|
||||
<a @click.prevent="clear()">
|
||||
<octicon:trash-24 /> {{ $t("toolbar.clear") }}
|
||||
<key-shortcut char="k" :modifiers="['shift', 'meta']"></key-shortcut>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a :href="`${base}/api/logs/download/${container.host}/${container.id}`" download>
|
||||
<octicon:download-24 /> {{ $t("toolbar.download") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a @click.prevent="showSearch = true">
|
||||
<mdi:light-magnify /> {{ $t("toolbar.search") }}
|
||||
<key-shortcut char="f"></key-shortcut>
|
||||
</a>
|
||||
</li>
|
||||
<li class="line"></li>
|
||||
<li>
|
||||
<a
|
||||
@click="
|
||||
streamConfig.stdout = true;
|
||||
streamConfig.stderr = true;
|
||||
"
|
||||
>
|
||||
<div class="flex h-4 w-4 gap-0.5">
|
||||
<template v-if="streamConfig.stderr && streamConfig.stdout">
|
||||
<carbon:circle-solid class="w-2 text-red" />
|
||||
<carbon:circle-solid class="w-2 text-blue" />
|
||||
</template>
|
||||
</div>
|
||||
{{ $t("toolbar.show-all") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
@click="
|
||||
streamConfig.stdout = true;
|
||||
streamConfig.stderr = false;
|
||||
"
|
||||
>
|
||||
<div class="flex h-4 w-4 flex-col gap-1">
|
||||
<carbon:circle-solid class="w-2 text-blue" v-if="!streamConfig.stderr && streamConfig.stdout" />
|
||||
</div>
|
||||
{{ $t("toolbar.show", { std: "STDOUT" }) }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
@click="
|
||||
streamConfig.stdout = false;
|
||||
streamConfig.stderr = true;
|
||||
"
|
||||
>
|
||||
<div class="flex h-4 w-4 flex-col gap-1">
|
||||
<carbon:circle-solid class="w-2 text-red" v-if="streamConfig.stderr && !streamConfig.stdout" />
|
||||
</div>
|
||||
{{ $t("toolbar.show", { std: "STDERR" }) }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const { showSearch } = useSearchFilter();
|
||||
const { base } = config;
|
||||
|
||||
const clear = defineEmit();
|
||||
|
||||
const { container, streamConfig } = useContainerContext();
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
li.line {
|
||||
@apply h-px bg-base-content/20;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply whitespace-nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<scrollable-view :scrollable="scrollable" v-if="container">
|
||||
<template #header v-if="showTitle">
|
||||
<div class="mx-2 flex items-center gap-2">
|
||||
<container-title @close="$emit('close')" />
|
||||
<container-stat class="ml-auto" />
|
||||
|
||||
<log-actions-toolbar @clear="onClearClicked()" class="mobile-hidden" />
|
||||
<a class="btn btn-circle btn-xs" @click="close()" v-if="closable">
|
||||
<mdi:close />
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ setLoading }">
|
||||
<log-viewer-with-source ref="viewer" @loading-more="setLoading($event)" />
|
||||
</template>
|
||||
</scrollable-view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import LogViewerWithSource from "./LogViewerWithSource.vue";
|
||||
|
||||
const {
|
||||
id,
|
||||
showTitle = false,
|
||||
scrollable = false,
|
||||
closable = false,
|
||||
} = defineProps<{
|
||||
id: string;
|
||||
showTitle?: boolean;
|
||||
scrollable?: boolean;
|
||||
closable?: boolean;
|
||||
}>();
|
||||
|
||||
const close = defineEmit();
|
||||
|
||||
const store = useContainerStore();
|
||||
const container = store.currentContainer($$(id));
|
||||
provideContainerContext(container);
|
||||
|
||||
const viewer = ref<InstanceType<typeof LogViewerWithSource>>();
|
||||
|
||||
function onClearClicked() {
|
||||
viewer.value?.clear();
|
||||
}
|
||||
|
||||
onKeyStroke("k", (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
|
||||
onClearClicked();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<tag size="small">
|
||||
<date-time :date="date" class="whitespace-nowrap text-blue"></date-time>
|
||||
</tag>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
date: Date;
|
||||
}>();
|
||||
</script>
|
||||
+36
-24
@@ -4,15 +4,16 @@ import { createTestingPinia } from "@pinia/testing";
|
||||
import EventSource, { sources } from "eventsourcemock";
|
||||
import LogEventSource from "./LogEventSource.vue";
|
||||
import LogViewer from "./LogViewer.vue";
|
||||
import { settings } from "../composables/settings";
|
||||
import { settings } from "@/composables/settings";
|
||||
import { useSearchFilter } from "@/composables/search";
|
||||
import { vi, describe, expect, beforeEach, test, beforeAll, afterAll, afterEach } from "vitest";
|
||||
import { vi, describe, expect, beforeEach, test, afterEach } from "vitest";
|
||||
import { computed, nextTick } from "vue";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import { containerContext } from "@/composables/containerContext";
|
||||
|
||||
vi.mock("@/stores/config", () => ({
|
||||
__esModule: true,
|
||||
default: { base: "" },
|
||||
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
|
||||
}));
|
||||
|
||||
/**
|
||||
@@ -23,6 +24,7 @@ describe("<LogEventSource />", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
global.EventSource = EventSource;
|
||||
// @ts-ignore
|
||||
window.scrollTo = vi.fn();
|
||||
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
@@ -43,10 +45,13 @@ describe("<LogEventSource />", () => {
|
||||
hourStyle = "auto",
|
||||
}: { searchFilter?: string | undefined; hourStyle?: "auto" | "24" | "12" } = {
|
||||
hourStyle: "auto",
|
||||
}
|
||||
},
|
||||
) {
|
||||
settings.value.hourStyle = hourStyle;
|
||||
search.searchFilter.value = searchFilter;
|
||||
if (searchFilter) {
|
||||
search.showSearch.value = true;
|
||||
}
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory("/"),
|
||||
@@ -67,7 +72,11 @@ describe("<LogEventSource />", () => {
|
||||
LogViewer,
|
||||
},
|
||||
provide: {
|
||||
container: computed(() => ({ id: "abc", image: "test:v123" })),
|
||||
[containerContext as symbol]: {
|
||||
container: computed(() => ({ id: "abc", image: "test:v123", host: "localhost" })),
|
||||
streamConfig: reactive({ stdout: true, stderr: true }),
|
||||
},
|
||||
scrollingPaused: computed(() => false),
|
||||
},
|
||||
},
|
||||
slots: {
|
||||
@@ -79,6 +88,8 @@ describe("<LogEventSource />", () => {
|
||||
});
|
||||
}
|
||||
|
||||
const sourceUrl = "/api/logs/stream/localhost/abc?lastEventId=&stdout=1&stderr=1";
|
||||
|
||||
test("renders correctly", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
@@ -86,28 +97,29 @@ describe("<LogEventSource />", () => {
|
||||
|
||||
test("should connect to EventSource", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
expect(sources["/api/logs/stream?id=abc&lastEventId="].readyState).toBe(1);
|
||||
sources[sourceUrl].emitOpen();
|
||||
expect(sources[sourceUrl].readyState).toBe(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("should close EventSource", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources[sourceUrl].emitOpen();
|
||||
wrapper.unmount();
|
||||
expect(sources["/api/logs/stream?id=abc&lastEventId="].readyState).toBe(2);
|
||||
expect(sources[sourceUrl].readyState).toBe(2);
|
||||
});
|
||||
|
||||
test("should parse messages", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
sources[sourceUrl].emitOpen();
|
||||
sources[sourceUrl].emitMessage({
|
||||
data: `{"ts":1560336942459, "m":"This is a message.", "id":1}`,
|
||||
});
|
||||
|
||||
vi.runAllTimers();
|
||||
await nextTick();
|
||||
|
||||
// @ts-ignore
|
||||
const [message, _] = wrapper.vm.messages;
|
||||
expect(message).toMatchSnapshot();
|
||||
});
|
||||
@@ -115,8 +127,8 @@ describe("<LogEventSource />", () => {
|
||||
describe("render html correctly", () => {
|
||||
test("should render messages", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
sources[sourceUrl].emitOpen();
|
||||
sources[sourceUrl].emitMessage({
|
||||
data: `{"ts":1560336942459, "m":"This is a message.", "id":1}`,
|
||||
});
|
||||
|
||||
@@ -128,8 +140,8 @@ describe("<LogEventSource />", () => {
|
||||
|
||||
test("should render messages with color", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
sources[sourceUrl].emitOpen();
|
||||
sources[sourceUrl].emitMessage({
|
||||
data: '{"ts":1560336942459,"m":"\\u001b[30mblack\\u001b[37mwhite", "id":1}',
|
||||
});
|
||||
|
||||
@@ -141,8 +153,8 @@ describe("<LogEventSource />", () => {
|
||||
|
||||
test("should render messages with html entities", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
sources[sourceUrl].emitOpen();
|
||||
sources[sourceUrl].emitMessage({
|
||||
data: `{"ts":1560336942459, "m":"<test>foo bar</test>", "id":1}`,
|
||||
});
|
||||
|
||||
@@ -154,8 +166,8 @@ describe("<LogEventSource />", () => {
|
||||
|
||||
test("should render dates with 12 hour style", async () => {
|
||||
const wrapper = createLogEventSource({ hourStyle: "12" });
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
sources[sourceUrl].emitOpen();
|
||||
sources[sourceUrl].emitMessage({
|
||||
data: `{"ts":1560336942459, "m":"<test>foo bar</test>", "id":1}`,
|
||||
});
|
||||
|
||||
@@ -167,8 +179,8 @@ describe("<LogEventSource />", () => {
|
||||
|
||||
test("should render dates with 24 hour style", async () => {
|
||||
const wrapper = createLogEventSource({ hourStyle: "24" });
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
sources[sourceUrl].emitOpen();
|
||||
sources[sourceUrl].emitMessage({
|
||||
data: `{"ts":1560336942459, "m":"<test>foo bar</test>", "id":1}`,
|
||||
});
|
||||
|
||||
@@ -180,11 +192,11 @@ describe("<LogEventSource />", () => {
|
||||
|
||||
test("should render messages with filter", async () => {
|
||||
const wrapper = createLogEventSource({ searchFilter: "test" });
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
sources[sourceUrl].emitOpen();
|
||||
sources[sourceUrl].emitMessage({
|
||||
data: `{"ts":1560336942459, "m":"foo bar", "id":1}`,
|
||||
});
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
sources[sourceUrl].emitMessage({
|
||||
data: `{"ts":1560336942459, "m":"test bar", "id":2}`,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<infinite-loader :onLoadMore="fetchMore" :enabled="messages.length > 100"></infinite-loader>
|
||||
<slot :messages="messages"></slot>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const loadingMore = defineEmit<[value: boolean]>();
|
||||
|
||||
const { container, streamConfig } = useContainerContext();
|
||||
const { messages, loadOlderLogs } = useLogStream(container, streamConfig);
|
||||
|
||||
const beforeLoading = () => loadingMore(true);
|
||||
const afterLoading = () => loadingMore(false);
|
||||
|
||||
defineExpose({
|
||||
clear: () => (messages.value = []),
|
||||
});
|
||||
|
||||
const fetchMore = () => loadOlderLogs({ beforeLoading, afterLoading });
|
||||
</script>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div :data-level="level" :data-position="position" class="mt-1.5 h-2.5 w-2.5 flex-none rounded-lg"></div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { Position } from "@/models/LogEntry";
|
||||
|
||||
defineProps<{
|
||||
level?: string;
|
||||
position?: Position;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
[data-position="start"] {
|
||||
border-radius: 0.5em 0.5em 0 0;
|
||||
height: 70%;
|
||||
margin-bottom: -0.4em;
|
||||
margin-top: auto;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
[data-position="middle"] {
|
||||
border-radius: 0;
|
||||
height: auto;
|
||||
margin: -0.4em 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
[data-position="end"] {
|
||||
border-radius: 0 0 0.5em 0.5em;
|
||||
height: 70%;
|
||||
margin-top: -0.4em;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
[data-level="debug"],
|
||||
[data-level="trace"] {
|
||||
@apply bg-purple;
|
||||
}
|
||||
|
||||
[data-level="info"] {
|
||||
@apply bg-green;
|
||||
}
|
||||
|
||||
[data-level="error"],
|
||||
[data-level="fatal"] {
|
||||
@apply bg-red;
|
||||
}
|
||||
|
||||
[data-level="warn"],
|
||||
[data-level="warning"] {
|
||||
@apply bg-orange;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<tag size="small" :std="std">
|
||||
{{ std }}
|
||||
</tag>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Std } from "@/models/LogEntry";
|
||||
|
||||
defineProps<{
|
||||
std: Std;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
[std="stdout"] {
|
||||
@apply text-blue;
|
||||
}
|
||||
|
||||
[std="stderr"] {
|
||||
@apply text-red;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<ul class="events group py-4" :class="{ 'disable-wrap': !softWrap, [size]: true }">
|
||||
<li
|
||||
v-for="item in filtered"
|
||||
:key="item.id"
|
||||
:data-key="item.id"
|
||||
:class="{ 'border border-secondary': toRaw(item) === toRaw(lastSelectedItem) }"
|
||||
>
|
||||
<a
|
||||
class="jump-context tooltip-primary tooltip tooltip-right"
|
||||
v-if="isSearching()"
|
||||
data-tip="Jump to Context"
|
||||
@click="handleJumpLineSelected($event, item)"
|
||||
:href="`#${item.id}`"
|
||||
>
|
||||
<ic:sharp-find-in-page />
|
||||
</a>
|
||||
<component :is="item.getComponent()" :log-entry="item" :visible-keys="visibleKeys.value" />
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { toRaw } from "vue";
|
||||
import { useRouteHash } from "@vueuse/router";
|
||||
|
||||
import { type JSONObject, LogEntry } from "@/models/LogEntry";
|
||||
|
||||
const props = defineProps<{
|
||||
messages: LogEntry<string | JSONObject>[];
|
||||
}>();
|
||||
|
||||
const { container } = useContainerContext();
|
||||
|
||||
let visibleKeys = persistentVisibleKeys(container);
|
||||
|
||||
const { filteredPayload } = useVisibleFilter(visibleKeys);
|
||||
const { filteredMessages, resetSearch, isSearching } = useSearchFilter();
|
||||
|
||||
const { messages } = toRefs(props);
|
||||
const visible = filteredPayload(messages);
|
||||
const filtered = filteredMessages(visible);
|
||||
|
||||
let lastSelectedItem: LogEntry<string | JSONObject> | undefined = $ref(undefined);
|
||||
|
||||
function handleJumpLineSelected(e: Event, item: LogEntry<string | JSONObject>) {
|
||||
lastSelectedItem = item;
|
||||
resetSearch();
|
||||
}
|
||||
|
||||
const routeHash = useRouteHash();
|
||||
watch(
|
||||
routeHash,
|
||||
(hash) => {
|
||||
if (hash) {
|
||||
document.querySelector(`[data-key="${hash.substring(1)}"]`)?.scrollIntoView({ block: "center" });
|
||||
}
|
||||
},
|
||||
{ immediate: true, flush: "post" },
|
||||
);
|
||||
</script>
|
||||
<style scoped lang="postcss">
|
||||
.events {
|
||||
font-family:
|
||||
ui-monospace,
|
||||
SFMono-Regular,
|
||||
SF Mono,
|
||||
Consolas,
|
||||
Liberation Mono,
|
||||
monaco,
|
||||
Menlo,
|
||||
monospace;
|
||||
|
||||
> li {
|
||||
@apply flex break-words px-4 py-1 last:snap-end odd:bg-gray-400/[0.07];
|
||||
&:last-child {
|
||||
scroll-margin-block-end: 5rem;
|
||||
}
|
||||
|
||||
.jump-context {
|
||||
@apply mr-2 flex items-center font-sans text-secondary hover:text-secondary-focus;
|
||||
}
|
||||
}
|
||||
|
||||
&.small {
|
||||
@apply text-[0.7em];
|
||||
}
|
||||
|
||||
&.medium {
|
||||
@apply text-[0.8em];
|
||||
}
|
||||
|
||||
&.large {
|
||||
@apply text-lg;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+5
-7
@@ -1,18 +1,16 @@
|
||||
<template>
|
||||
<log-event-source ref="source" #default="{ messages }" @loading-more="emit('loading-more', $event)">
|
||||
<log-event-source ref="source" #default="{ messages }" @loading-more="loadingMore($event)">
|
||||
<log-viewer :messages="messages"></log-viewer>
|
||||
</log-event-source>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import LogViewer from "./LogViewer.vue";
|
||||
import { ref } from "vue";
|
||||
import LogEventSource from "./LogEventSource.vue";
|
||||
|
||||
const emit = defineEmits(["loading-more"]);
|
||||
|
||||
const source = ref<InstanceType<typeof LogViewer>>();
|
||||
const loadingMore = defineEmit<[value: boolean]>();
|
||||
const source = $ref<InstanceType<typeof LogEventSource>>();
|
||||
function clear() {
|
||||
source.value?.clear();
|
||||
source?.clear();
|
||||
}
|
||||
defineExpose({
|
||||
clear,
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="flex items-start gap-x-2">
|
||||
<log-std :std="logEntry.std" v-if="showStd" />
|
||||
<log-date :date="logEntry.date" v-if="showTimestamp" />
|
||||
<log-level class="flex" :level="logEntry.level" :position="logEntry.position" />
|
||||
<div class="whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap" v-html="colorize(logEntry.message)"></div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { SimpleLogEntry } from "@/models/LogEntry";
|
||||
import AnsiConvertor from "ansi-to-html";
|
||||
|
||||
const ansiConvertor = new AnsiConvertor({ escapeXML: false, fg: "var(--text-color)" });
|
||||
defineProps<{
|
||||
logEntry: SimpleLogEntry;
|
||||
}>();
|
||||
|
||||
const { markSearch } = useSearchFilter();
|
||||
const colorize = (value: string) => markSearch(ansiConvertor.toHtml(value));
|
||||
</script>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="my-4 flex-1 text-center">
|
||||
<div class="relative">
|
||||
<zig-zag class="absolute inset-0 mt-2"></zig-zag>
|
||||
<span class="relative whitespace-pre-wrap bg-base px-4 py-2 font-bold">{{
|
||||
$t("error.logs-skipped", { total: logEntry.totalSkipped })
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { SkippedLogsEntry } from "@/models/LogEntry";
|
||||
|
||||
defineProps<{
|
||||
logEntry: SkippedLogsEntry;
|
||||
}>();
|
||||
</script>
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div class="relative hover:text-secondary" @mouseenter="mouseOver = true" @mouseleave="mouseOver = false">
|
||||
<div class="mobile-hidden flex overflow-hidden rounded-sm border border-primary px-px pb-px pt-1">
|
||||
<stat-sparkline :data="data" @selected-point="onSelectedPoint"></stat-sparkline>
|
||||
</div>
|
||||
<div class="inline-flex gap-1 rounded bg-base p-px text-xs lg:absolute lg:-left-0.5 lg:-top-2">
|
||||
<div class="font-light uppercase">{{ label }}</div>
|
||||
<div class="select-none font-bold">
|
||||
{{ mouseOver ? selectedPoint?.value ?? selectedPoint?.y ?? statValue : statValue }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const { data, label, statValue } = defineProps<{ data: Point<unknown>[]; label: string; statValue: string | number }>();
|
||||
|
||||
let selectedPoint: Point<unknown> | undefined = $ref();
|
||||
|
||||
function onSelectedPoint(point: Point<unknown>) {
|
||||
selectedPoint = point;
|
||||
}
|
||||
|
||||
let mouseOver = $ref(false);
|
||||
</script>
|
||||
@@ -0,0 +1,44 @@
|
||||
<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="invisible stroke-secondary 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 = 150, height = 30 } = defineProps<{ data: Point<unknown>[]; width?: number; height?: number }>();
|
||||
const x = d3.scaleLinear().range([width, 0]);
|
||||
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>
|
||||
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<svg width="100%" height="8" class="zigzag">
|
||||
<defs>
|
||||
<pattern id="zigzag" x="0" y="0" width="30" height="8" patternUnits="userSpaceOnUse">
|
||||
<line x1="0" y1="0" x2="15" y2="8" class="stroke-primary stroke-1" />
|
||||
<line x1="15" y1="8" x2="30" y2="0" class="stroke-primary stroke-1" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="100%" height="100%" fill="url(#zigzag)"></rect>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,114 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render dates with 12 hour style 1`] = `
|
||||
"<ul data-v-2e92daca=\\"\\" class=\\"events group py-4 medium\\">
|
||||
<li data-v-2e92daca=\\"\\" data-key=\\"1\\" class=\\"\\">
|
||||
<!--v-if-->
|
||||
<div data-v-2e92daca=\\"\\" class=\\"flex items-start gap-x-2\\" visible-keys=\\"\\">
|
||||
<!--v-if-->
|
||||
<div data-v-961504e7=\\"\\" class=\\"inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] text-sm\\" size=\\"small\\">
|
||||
<div class=\\"inline-flex gap-2 whitespace-nowrap text-blue\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" class=\\"mobile-hidden\\">06/12/2019</time><time datetime=\\"2019-06-12T10:55:42.459Z\\">10:55:42 AM</time></div>
|
||||
</div>
|
||||
<div data-v-e625cddd=\\"\\" class=\\"mt-1.5 h-2.5 w-2.5 flex-none rounded-lg flex\\"></div>
|
||||
<div class=\\"whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap\\"><test>foo bar</test></div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render dates with 24 hour style 1`] = `
|
||||
"<ul data-v-2e92daca=\\"\\" class=\\"events group py-4 medium\\">
|
||||
<li data-v-2e92daca=\\"\\" data-key=\\"1\\" class=\\"\\">
|
||||
<!--v-if-->
|
||||
<div data-v-2e92daca=\\"\\" class=\\"flex items-start gap-x-2\\" visible-keys=\\"\\">
|
||||
<!--v-if-->
|
||||
<div data-v-961504e7=\\"\\" class=\\"inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] text-sm\\" size=\\"small\\">
|
||||
<div class=\\"inline-flex gap-2 whitespace-nowrap text-blue\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" class=\\"mobile-hidden\\">06/12/2019</time><time datetime=\\"2019-06-12T10:55:42.459Z\\">10:55:42</time></div>
|
||||
</div>
|
||||
<div data-v-e625cddd=\\"\\" class=\\"mt-1.5 h-2.5 w-2.5 flex-none rounded-lg flex\\"></div>
|
||||
<div class=\\"whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap\\"><test>foo bar</test></div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render messages 1`] = `
|
||||
"<ul data-v-2e92daca=\\"\\" class=\\"events group py-4 medium\\">
|
||||
<li data-v-2e92daca=\\"\\" data-key=\\"1\\" class=\\"\\">
|
||||
<!--v-if-->
|
||||
<div data-v-2e92daca=\\"\\" class=\\"flex items-start gap-x-2\\" visible-keys=\\"\\">
|
||||
<!--v-if-->
|
||||
<div data-v-961504e7=\\"\\" class=\\"inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] text-sm\\" size=\\"small\\">
|
||||
<div class=\\"inline-flex gap-2 whitespace-nowrap text-blue\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" class=\\"mobile-hidden\\">06/12/2019</time><time datetime=\\"2019-06-12T10:55:42.459Z\\">10:55:42 AM</time></div>
|
||||
</div>
|
||||
<div data-v-e625cddd=\\"\\" class=\\"mt-1.5 h-2.5 w-2.5 flex-none rounded-lg flex\\"></div>
|
||||
<div class=\\"whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap\\">This is a message.</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render messages with color 1`] = `
|
||||
"<ul data-v-2e92daca=\\"\\" class=\\"events group py-4 medium\\">
|
||||
<li data-v-2e92daca=\\"\\" data-key=\\"1\\" class=\\"\\">
|
||||
<!--v-if-->
|
||||
<div data-v-2e92daca=\\"\\" class=\\"flex items-start gap-x-2\\" visible-keys=\\"\\">
|
||||
<!--v-if-->
|
||||
<div data-v-961504e7=\\"\\" class=\\"inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] text-sm\\" size=\\"small\\">
|
||||
<div class=\\"inline-flex gap-2 whitespace-nowrap text-blue\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" class=\\"mobile-hidden\\">06/12/2019</time><time datetime=\\"2019-06-12T10:55:42.459Z\\">10:55:42 AM</time></div>
|
||||
</div>
|
||||
<div data-v-e625cddd=\\"\\" class=\\"mt-1.5 h-2.5 w-2.5 flex-none rounded-lg flex\\"></div>
|
||||
<div class=\\"whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap\\"><span style=\\"color:#000\\">black<span style=\\"color:#AAA\\">white</span></span></div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render messages with filter 1`] = `
|
||||
"<ul data-v-2e92daca=\\"\\" class=\\"events group py-4 medium\\">
|
||||
<li data-v-2e92daca=\\"\\" data-key=\\"2\\" class=\\"\\"><a data-v-2e92daca=\\"\\" class=\\"jump-context tooltip-primary tooltip tooltip-right\\" data-tip=\\"Jump to Context\\" href=\\"#2\\"><svg data-v-2e92daca=\\"\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\">
|
||||
<path fill=\\"currentColor\\" d=\\"M20 19.59V8l-6-6H4v20l15.57-.02l-4.81-4.81c-.8.52-1.74.83-2.76.83c-2.76 0-5-2.24-5-5s2.24-5 5-5s5 2.24 5 5c0 1.02-.31 1.96-.83 2.75L20 19.59zM9 13c0 1.66 1.34 3 3 3s3-1.34 3-3s-1.34-3-3-3s-3 1.34-3 3z\\"></path>
|
||||
</svg></a>
|
||||
<div data-v-2e92daca=\\"\\" class=\\"flex items-start gap-x-2\\" visible-keys=\\"\\">
|
||||
<!--v-if-->
|
||||
<div data-v-961504e7=\\"\\" class=\\"inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] text-sm\\" size=\\"small\\">
|
||||
<div class=\\"inline-flex gap-2 whitespace-nowrap text-blue\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" class=\\"mobile-hidden\\">06/12/2019</time><time datetime=\\"2019-06-12T10:55:42.459Z\\">10:55:42 AM</time></div>
|
||||
</div>
|
||||
<div data-v-e625cddd=\\"\\" class=\\"mt-1.5 h-2.5 w-2.5 flex-none rounded-lg flex\\"></div>
|
||||
<div class=\\"whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap\\"><mark>test</mark> bar</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render messages with html entities 1`] = `
|
||||
"<ul data-v-2e92daca=\\"\\" class=\\"events group py-4 medium\\">
|
||||
<li data-v-2e92daca=\\"\\" data-key=\\"1\\" class=\\"\\">
|
||||
<!--v-if-->
|
||||
<div data-v-2e92daca=\\"\\" class=\\"flex items-start gap-x-2\\" visible-keys=\\"\\">
|
||||
<!--v-if-->
|
||||
<div data-v-961504e7=\\"\\" class=\\"inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] text-sm\\" size=\\"small\\">
|
||||
<div class=\\"inline-flex gap-2 whitespace-nowrap text-blue\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" class=\\"mobile-hidden\\">06/12/2019</time><time datetime=\\"2019-06-12T10:55:42.459Z\\">10:55:42 AM</time></div>
|
||||
</div>
|
||||
<div data-v-e625cddd=\\"\\" class=\\"mt-1.5 h-2.5 w-2.5 flex-none rounded-lg flex\\"></div>
|
||||
<div class=\\"whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap\\"><test>foo bar</test></div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > renders correctly 1`] = `
|
||||
"<div class=\\"flex min-h-[1px] justify-center\\"><span class=\\"loading loading-bars loading-md mt-4 text-primary\\" style=\\"display: none;\\"></span></div>
|
||||
<ul data-v-2e92daca=\\"\\" class=\\"events group py-4 medium\\"></ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > should parse messages 1`] = `
|
||||
SimpleLogEntry {
|
||||
"_message": "This is a message.",
|
||||
"date": 2019-06-12T10:55:42.459Z,
|
||||
"id": 1,
|
||||
"level": undefined,
|
||||
"position": undefined,
|
||||
"std": "stderr",
|
||||
}
|
||||
`;
|
||||
@@ -1,97 +0,0 @@
|
||||
<template>
|
||||
<aside>
|
||||
<div class="columns is-marginless is-gapless is-mobile is-vcentered">
|
||||
<div class="column is-narrow">
|
||||
<router-link :to="{ name: 'default' }">
|
||||
<svg class="logo">
|
||||
<use href="#logo"></use>
|
||||
</svg>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="column ml-4 is-family-monospace is-ellipsis" v-if="$route.name == 'container'">
|
||||
{{ allContainersById[route.params.id].name }}
|
||||
</div>
|
||||
|
||||
<div class="column is-narrow push-right">
|
||||
<a
|
||||
role="button"
|
||||
class="navbar-burger burger is-hidden-tablet is-pulled-right"
|
||||
@click="showNav = !showNav"
|
||||
:class="{ 'is-active': showNav }"
|
||||
>
|
||||
<span></span> <span></span> <span></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="menu-label is-hidden-mobile" :class="{ 'is-active': showNav }">Containers</p>
|
||||
<ul class="menu-list is-hidden-mobile" :class="{ 'is-active': showNav }">
|
||||
<li v-for="item in visibleContainers" :key="item.id">
|
||||
<router-link :to="{ name: 'container', params: { id: item.id } }" active-class="is-active" :title="item.name">
|
||||
<div class="is-ellipsis">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from "vue";
|
||||
import { useContainerStore } from "@/stores/container";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const store = useContainerStore();
|
||||
const route = useRoute();
|
||||
const { visibleContainers, allContainersById } = storeToRefs(store);
|
||||
|
||||
const showNav = ref(false);
|
||||
|
||||
watch(route, () => {
|
||||
showNav.value = false;
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
aside {
|
||||
padding: 1em;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--scheme-main-ter);
|
||||
z-index: 10;
|
||||
max-height: 100vh;
|
||||
overflow: auto;
|
||||
|
||||
.menu-label {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-shadow: 0px 1px 1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.burger {
|
||||
color: var(--body-color);
|
||||
}
|
||||
|
||||
.is-hidden-mobile.is-active {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.navbar-burger {
|
||||
height: 2.35rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 82px;
|
||||
height: 36px;
|
||||
fill: var(--logo-color);
|
||||
}
|
||||
|
||||
.column.push-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,24 +0,0 @@
|
||||
<template>
|
||||
<time :datetime="date.toISOString()">{{ text }}</time>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useIntervalFn } from "@vueuse/core";
|
||||
import formatDistance from "date-fns/formatDistance";
|
||||
import { PropType, ref } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
date: {
|
||||
required: true,
|
||||
type: Object as PropType<Date>,
|
||||
},
|
||||
});
|
||||
|
||||
const text = ref<string>();
|
||||
function updateFromNow() {
|
||||
text.value = formatDistance(props.date, new Date(), {
|
||||
addSuffix: true,
|
||||
});
|
||||
}
|
||||
useIntervalFn(updateFromNow, 30_000, { immediateCallback: true });
|
||||
</script>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<slot></slot>
|
||||
<teleport to="body">
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-show="show && (delayedShow || glopbalShow)"
|
||||
class="fixed z-50 rounded border border-secondary/50 bg-base-lighter p-4 shadow"
|
||||
ref="content"
|
||||
>
|
||||
<slot name="content"></slot>
|
||||
</div>
|
||||
</transition>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { globalShowPopup } from "@/composables/popup";
|
||||
|
||||
let glopbalShow = globalShowPopup();
|
||||
let show = ref(glopbalShow.value);
|
||||
let delayedShow = refDebounced(show, 1000);
|
||||
|
||||
let content: HTMLElement | null = $ref(null);
|
||||
|
||||
const onMouseEnter = (e: Event) => {
|
||||
show.value = true;
|
||||
glopbalShow.value = true;
|
||||
if (e.target && content && e.target instanceof Element) {
|
||||
const { left, top, width } = e.target.getBoundingClientRect();
|
||||
const x = left + width + 10;
|
||||
const y = top;
|
||||
|
||||
content.style.left = `${x}px`;
|
||||
content.style.top = `${y}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseLeave = () => {
|
||||
show.value = false;
|
||||
glopbalShow.value = false;
|
||||
};
|
||||
|
||||
const el = useCurrentElement();
|
||||
useEventListener(() => el.value?.nextElementSibling, "mouseenter", onMouseEnter);
|
||||
useEventListener(() => el.value?.nextElementSibling, "mouseleave", onMouseLeave);
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
@apply transition-opacity;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
@apply opacity-0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,44 +0,0 @@
|
||||
<template>
|
||||
<time :datetime="date.toISOString()">{{ relativeTime(date, locale) }}</time>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
const use24Hr =
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
hour: "numeric",
|
||||
})
|
||||
.formatToParts(new Date(2020, 0, 1, 13))
|
||||
.find((part) => part.type === "hour")?.value.length === 2;
|
||||
|
||||
const auto = use24Hr ? enGB : enUS;
|
||||
const styles = { auto, 12: enUS, 24: enGB };
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { formatRelative } from "date-fns";
|
||||
import { hourStyle } from "@/composables/settings";
|
||||
import enGB from "date-fns/locale/en-GB";
|
||||
import enUS from "date-fns/locale/en-US";
|
||||
import { computed, PropType } from "vue";
|
||||
defineProps({
|
||||
date: {
|
||||
required: true,
|
||||
type: Object as PropType<Date>,
|
||||
},
|
||||
});
|
||||
|
||||
const locale = computed(() => {
|
||||
const locale = styles[hourStyle.value];
|
||||
const oldFormatter = locale.formatRelative as (d: Date | number) => string;
|
||||
return {
|
||||
...locale,
|
||||
formatRelative(date: Date | number) {
|
||||
return oldFormatter(date) + "p";
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function relativeTime(date: Date, locale: Locale) {
|
||||
return formatRelative(date, new Date(), { locale });
|
||||
}
|
||||
</script>
|
||||
@@ -1,46 +1,37 @@
|
||||
<template>
|
||||
<div class="scroll-progress" ref="root">
|
||||
<svg width="100" height="100" viewBox="0 0 100 100" :class="{ indeterminate }">
|
||||
<circle r="44" cx="50" cy="50" />
|
||||
</svg>
|
||||
<div class="is-overlay columns is-vcentered is-centered has-text-weight-light">
|
||||
<template v-if="indeterminate">
|
||||
<div class="column is-narrow is-paddingless is-size-2">∞</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="column is-narrow is-paddingless is-size-2">
|
||||
{{ Math.ceil(scrollProgress * 100) }}
|
||||
</span>
|
||||
<span class="column is-narrow is-paddingless"> % </span>
|
||||
</template>
|
||||
<transition name="fadeout">
|
||||
<div class="pointer-events-none relative inline-block" ref="root" v-show="!autoHide || show">
|
||||
<svg width="100" height="100" viewBox="0 0 100 100" :class="{ indeterminate }">
|
||||
<circle r="44" cx="50" cy="50" class="fill-base-darker stroke-primary" />
|
||||
</svg>
|
||||
<div class="absolute inset-0 flex items-center justify-center font-light">
|
||||
<template v-if="indeterminate">
|
||||
<div class="text-4xl">∞</div>
|
||||
</template>
|
||||
<template v-else-if="!isNaN(scrollProgress)">
|
||||
<span class="text-4xl">
|
||||
{{ Math.ceil(scrollProgress * 100) }}
|
||||
</span>
|
||||
<span> % </span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useContainerStore } from "@/stores/container";
|
||||
import { useScroll } from "@vueuse/core";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { onMounted, ref, watch, watchPostEffect } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
indeterminate: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
autoHide: {
|
||||
default: true,
|
||||
type: Boolean,
|
||||
},
|
||||
});
|
||||
const { indeterminate = false, autoHide = false } = defineProps<{
|
||||
indeterminate?: boolean;
|
||||
autoHide?: boolean;
|
||||
}>();
|
||||
|
||||
const scrollProgress = ref(0);
|
||||
const animation = ref({ cancel: () => {} });
|
||||
const root = ref<HTMLElement>();
|
||||
const store = useContainerStore();
|
||||
const { activeContainers } = storeToRefs(store);
|
||||
const scrollElement = ref<HTMLElement | Document>((root.value?.closest("[data-scrolling]") as HTMLElement) ?? document);
|
||||
const { y: scrollY } = useScroll(scrollElement, { throttle: 100 });
|
||||
const { y: scrollY } = useScroll(scrollElement as Ref<HTMLElement | Document>, { throttle: 100 });
|
||||
const show = autoResetRef(false, 2000);
|
||||
|
||||
onMounted(() => {
|
||||
watch(
|
||||
@@ -48,7 +39,7 @@ onMounted(() => {
|
||||
() => {
|
||||
scrollElement.value = (root.value?.closest("[data-scrolling]") as HTMLElement) ?? document;
|
||||
},
|
||||
{ immediate: true, flush: "post" }
|
||||
{ immediate: true, flush: "post" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -57,51 +48,34 @@ watchPostEffect(() => {
|
||||
scrollElement.value === document
|
||||
? (scrollElement.value as Document).documentElement
|
||||
: (scrollElement.value as HTMLElement);
|
||||
scrollProgress.value = scrollY.value / (parent.scrollHeight - parent.clientHeight);
|
||||
animation.value.cancel();
|
||||
if (props.autoHide && root.value) {
|
||||
animation.value = root.value.animate(
|
||||
{ opacity: [1, 0] },
|
||||
{
|
||||
duration: 500,
|
||||
delay: 2000,
|
||||
fill: "both",
|
||||
easing: "ease-out",
|
||||
}
|
||||
);
|
||||
scrollProgress.value = Math.max(0, Math.min(1, scrollY.value / (parent.scrollHeight - parent.clientHeight)));
|
||||
if (autoHide) {
|
||||
show.value = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.scroll-progress {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
<style scoped lang="postcss">
|
||||
svg {
|
||||
filter: drop-shadow(0px 1px 1px rgba(0, 0, 0, 0.2));
|
||||
margin-top: 5px;
|
||||
&.indeterminate {
|
||||
animation: 2s linear infinite svg-animation;
|
||||
|
||||
svg {
|
||||
filter: drop-shadow(0px 1px 1px rgba(0, 0, 0, 0.2));
|
||||
margin-top: 5px;
|
||||
&.indeterminate {
|
||||
animation: 2s linear infinite svg-animation;
|
||||
|
||||
circle {
|
||||
animation: 1.4s ease-in-out infinite both circle-animation;
|
||||
}
|
||||
}
|
||||
circle {
|
||||
fill: var(--scheme-main-ter);
|
||||
fill-opacity: 0.8;
|
||||
transition: stroke-dashoffset 250ms ease-out;
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: 50% 50%;
|
||||
stroke: var(--primary-color);
|
||||
stroke-dashoffset: calc(276.32px - v-bind(scrollProgress) * 276.32px);
|
||||
stroke-dasharray: 276.32px 276.32px;
|
||||
stroke-linecap: round;
|
||||
stroke-width: 3;
|
||||
will-change: stroke-dashoffset;
|
||||
animation: 1.4s ease-in-out infinite both circle-animation;
|
||||
}
|
||||
}
|
||||
circle {
|
||||
fill-opacity: 0.75;
|
||||
transition: stroke-dashoffset 250ms ease-out;
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: 50% 50%;
|
||||
stroke-dashoffset: calc(276.32px - v-bind(scrollProgress) * 276.32px);
|
||||
stroke-dasharray: 276.32px 276.32px;
|
||||
stroke-linecap: round;
|
||||
stroke-width: 3;
|
||||
will-change: stroke-dashoffset;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes svg-animation {
|
||||
@@ -130,4 +104,12 @@ watchPostEffect(() => {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.fadeout-leave-active {
|
||||
@apply transition-opacity;
|
||||
}
|
||||
|
||||
.fadeout-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
<template>
|
||||
<section :class="{ 'is-full-height-scrollable': scrollable }">
|
||||
<header v-if="$slots.header">
|
||||
<section :class="{ 'h-screen min-h-0': scrollable }" class="flex flex-col">
|
||||
<header
|
||||
v-if="$slots.header"
|
||||
class="sticky top-[70px] z-[2] border-b border-base-content/10 bg-base py-2 shadow-[1px_1px_2px_0_rgb(0,0,0,0.05)] md:top-0"
|
||||
>
|
||||
<slot name="header"></slot>
|
||||
</header>
|
||||
<main :data-scrolling="scrollable ? true : undefined">
|
||||
<div class="is-scrollbar-progress is-hidden-mobile">
|
||||
<scroll-progress v-show="paused" :indeterminate="loading" :auto-hide="!loading"></scroll-progress>
|
||||
<main :data-scrolling="scrollable ? true : undefined" class="snap-y overflow-auto">
|
||||
<div class="invisible mr-28 text-right md:visible" v-show="paused">
|
||||
<scroll-progress :indeterminate="loading" :auto-hide="!loading" class="z-2 !fixed top-16" />
|
||||
</div>
|
||||
<div ref="scrollableContent">
|
||||
<slot :setLoading="setLoading"></slot>
|
||||
</div>
|
||||
|
||||
<div ref="scrollObserver" class="is-scroll-observer"></div>
|
||||
<div ref="scrollObserver" class="h-px"></div>
|
||||
</main>
|
||||
|
||||
<div class="is-scrollbar-notification">
|
||||
<div class="mr-16 text-right">
|
||||
<transition name="fade">
|
||||
<button class="button" :class="hasMore ? 'has-more' : ''" @click="scrollToBottom()" v-show="paused">
|
||||
<mdi-light-chevron-double-down />
|
||||
<button
|
||||
class="fixed bottom-8 rounded bg-primary p-3 text-primary-content shadow transition-colors hover:bg-primary-focus"
|
||||
:class="hasMore ? 'animate-bounce-fast bg-secondary text-secondary-content hover:bg-secondary-focus' : ''"
|
||||
@click="scrollToBottom()"
|
||||
v-show="paused"
|
||||
>
|
||||
<mdi:light-chevron-double-down />
|
||||
</button>
|
||||
</transition>
|
||||
</div>
|
||||
@@ -25,34 +33,30 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from "vue";
|
||||
const { scrollable = false } = defineProps<{ scrollable?: boolean }>();
|
||||
|
||||
defineProps({
|
||||
scrollable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const paused = ref(false);
|
||||
const hasMore = ref(false);
|
||||
const loading = ref(false);
|
||||
let paused = $ref(false);
|
||||
let hasMore = $ref(false);
|
||||
let loading = $ref(false);
|
||||
const scrollObserver = ref<HTMLElement>();
|
||||
const scrollableContent = ref<HTMLElement>();
|
||||
|
||||
provide("scrollingPaused", $$(paused));
|
||||
|
||||
const mutationObserver = new MutationObserver((e) => {
|
||||
if (!paused.value) {
|
||||
if (!paused) {
|
||||
scrollToBottom();
|
||||
} else {
|
||||
const record = e[e.length - 1];
|
||||
if (record.target.children[record.target.children.length - 1] == record.addedNodes[record.addedNodes.length - 1]) {
|
||||
hasMore.value = true;
|
||||
const children = (record.target as HTMLElement).children;
|
||||
if (children[children.length - 1] == record.addedNodes[record.addedNodes.length - 1]) {
|
||||
hasMore = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const intersectionObserver = new IntersectionObserver((entries) => (paused.value = entries[0].intersectionRatio == 0), {
|
||||
threshholds: [0, 1],
|
||||
const intersectionObserver = new IntersectionObserver((entries) => (paused = entries[0].intersectionRatio == 0), {
|
||||
threshold: [0, 1],
|
||||
rootMargin: "80px 0px",
|
||||
});
|
||||
|
||||
@@ -63,96 +67,27 @@ onMounted(() => {
|
||||
|
||||
function scrollToBottom(behavior: "auto" | "smooth" = "auto") {
|
||||
scrollObserver.value?.scrollIntoView({ behavior });
|
||||
hasMore.value = false;
|
||||
hasMore = false;
|
||||
}
|
||||
|
||||
function setLoading(value: boolean) {
|
||||
loading.value = value;
|
||||
loading = value;
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
<style scoped lang="postcss">
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
@apply transition-opacity;
|
||||
}
|
||||
|
||||
header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--body-background-color);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&.is-full-height-scrollable {
|
||||
height: 100vh;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
scroll-snap-type: y proximity;
|
||||
}
|
||||
|
||||
.is-scrollbar-progress {
|
||||
text-align: right;
|
||||
margin-right: 110px;
|
||||
.scroll-progress {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.is-scroll-observer {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.is-scrollbar-notification {
|
||||
text-align: right;
|
||||
margin-right: 65px;
|
||||
button {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
background-color: var(--secondary-color);
|
||||
transition: background-color 1s ease-out;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
|
||||
border: none !important;
|
||||
color: #222;
|
||||
|
||||
&.has-more {
|
||||
background-color: var(--primary-color);
|
||||
animation-name: bounce;
|
||||
animation-duration: 1000ms;
|
||||
animation-fill-mode: both;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
20%,
|
||||
50%,
|
||||
80%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-30px);
|
||||
}
|
||||
60% {
|
||||
transform: translateY(-15px);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease-in;
|
||||
}
|
||||
.fade-enter,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
@apply opacity-0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.splitpanes__pane {
|
||||
overflow: unset !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,88 +1,36 @@
|
||||
<template>
|
||||
<div class="search columns is-gapless is-vcentered" v-show="showSearch" v-if="search">
|
||||
<div class="column">
|
||||
<p class="control has-icons-left">
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder="Find / RegEx"
|
||||
ref="input"
|
||||
v-model="searchFilter"
|
||||
@keyup.esc="resetSearch()"
|
||||
/>
|
||||
<span class="icon is-left">
|
||||
<mdi-light-magnify />
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-1 has-text-centered">
|
||||
<button class="delete is-medium" @click="resetSearch()"></button>
|
||||
<div
|
||||
class="fixed -right-px -top-px z-10 flex w-96 items-center gap-4 rounded-bl border border-secondary/20 bg-base-darker p-4 shadow"
|
||||
v-show="showSearch"
|
||||
v-if="search"
|
||||
>
|
||||
<div class="input input-primary flex h-auto items-center">
|
||||
<mdi:light-magnify />
|
||||
<input
|
||||
class="input flex-1"
|
||||
type="text"
|
||||
placeholder="Find / RegEx"
|
||||
ref="input"
|
||||
v-model="searchFilter"
|
||||
@keyup.esc="resetSearch()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<a class="btn btn-circle btn-xs" @click="resetSearch()"> <mdi:close /></a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import hotkeys from "hotkeys-js";
|
||||
|
||||
import { search } from "@/composables/settings";
|
||||
import { useSearchFilter } from "@/composables/search";
|
||||
import { ref, nextTick, onMounted, onUnmounted } from "vue";
|
||||
|
||||
const input = ref<HTMLInputElement>();
|
||||
const { searchFilter, showSearch, resetSearch } = useSearchFilter();
|
||||
|
||||
onMounted(() => {
|
||||
hotkeys("command+f, ctrl+f", (event, handler) => {
|
||||
onKeyStroke("f", (e) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
showSearch.value = true;
|
||||
nextTick(() => input.value?.focus() || input.value?.select());
|
||||
event.preventDefault();
|
||||
});
|
||||
hotkeys("esc", () => resetSearch());
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
searchFilter.value = "";
|
||||
showSearch.value = false;
|
||||
hotkeys.unbind("command+f, ctrl+f");
|
||||
hotkeys.unbind("esc");
|
||||
});
|
||||
onUnmounted(() => resetSearch());
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search {
|
||||
width: 350px;
|
||||
position: fixed;
|
||||
padding: 10px;
|
||||
background: var(--scheme-main-ter);
|
||||
top: 0;
|
||||
right: 0;
|
||||
border-radius: 0 0 0 5px;
|
||||
z-index: 10;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
|
||||
|
||||
button.delete {
|
||||
margin-left: 1em;
|
||||
background-color: var(--scheme-main-ter);
|
||||
opacity: 0.6;
|
||||
&:after,
|
||||
&:before {
|
||||
background-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
padding: 10px 3px;
|
||||
}
|
||||
|
||||
.input {
|
||||
color: var(--body-color);
|
||||
&::placeholder {
|
||||
color: var(--border-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+184
-89
@@ -1,114 +1,209 @@
|
||||
<template>
|
||||
<aside>
|
||||
<div class="columns is-marginless">
|
||||
<div class="column is-paddingless">
|
||||
<router-link :to="{ name: 'default' }">
|
||||
<svg class="logo">
|
||||
<use href="#logo"></use>
|
||||
</svg>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="column is-narrow has-text-right px-1">
|
||||
<button class="button is-rounded" @click="$emit('search')" title="Search containers (⌘ + k, ⌃k)">
|
||||
<span class="icon">
|
||||
<mdi-light-magnify />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="column is-narrow has-text-right px-0">
|
||||
<router-link :to="{ name: 'settings' }" active-class="is-active" class="button is-rounded">
|
||||
<span class="icon">
|
||||
<mdi-light-cog />
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-if="ready" data-testid="side-menu">
|
||||
<div class="breadcrumbs">
|
||||
<ul>
|
||||
<li><a @click.prevent="setHost(null)" class="link-primary">Hosts</a></li>
|
||||
<li v-if="sessionHost && hosts[sessionHost]" class="cursor-default">
|
||||
{{ hosts[sessionHost].name }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p class="menu-label is-hidden-mobile">Containers</p>
|
||||
<ul class="menu-list is-hidden-mobile" v-if="ready">
|
||||
<li v-for="item in visibleContainers" :key="item.id" :class="item.state">
|
||||
<router-link :to="{ name: 'container', params: { id: item.id } }" active-class="is-active" :title="item.name">
|
||||
<div class="container is-flex is-align-items-center">
|
||||
<div class="is-flex-grow-1 is-ellipsis">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<div class="is-flex-shrink-1 column-icon">
|
||||
<transition :name="sessionHost ? 'slide-left' : 'slide-right'" mode="out-in">
|
||||
<ul class="menu p-0" v-if="!sessionHost">
|
||||
<li v-for="host in config.hosts">
|
||||
<a @click.prevent="setHost(host.id)">
|
||||
<ph:computer-tower />
|
||||
{{ host.name }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<transition-group tag="ul" name="list" class="containers menu p-0 [&_li.menu-title]:px-0" v-else>
|
||||
<li
|
||||
v-for="item in menuItems"
|
||||
:key="item.id"
|
||||
:class="isLabel(item) ? 'menu-title' : item.state"
|
||||
:data-testid="item.id"
|
||||
>
|
||||
<template v-if="isLabel(item)">
|
||||
{{ item.label }}
|
||||
</template>
|
||||
<popup v-else>
|
||||
<router-link
|
||||
:to="{ name: 'container-id', params: { id: item.id } }"
|
||||
active-class="active-primary"
|
||||
:title="item.name"
|
||||
>
|
||||
<div class="truncate">
|
||||
{{ item.name }}<span class="font-light opacity-70" v-if="item.isSwarm">{{ item.swarmId }}</span>
|
||||
</div>
|
||||
<span
|
||||
class="icon is-small"
|
||||
class="pin"
|
||||
@click.stop.prevent="store.appendActiveContainer(item)"
|
||||
v-show="!activeContainersById[item.id]"
|
||||
title="Pin as column"
|
||||
:title="$t('tooltip.pin-column')"
|
||||
>
|
||||
<cil-columns />
|
||||
<cil:columns />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="menu-list is-hidden-mobile loading" v-else>
|
||||
<li v-for="index in 7" class="my-4"><o-skeleton animated size="large"></o-skeleton></li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<container-health :health="item.health"></container-health>
|
||||
</router-link>
|
||||
<template #content>
|
||||
<container-popup :container="item"></container-popup>
|
||||
</template>
|
||||
</popup>
|
||||
</li>
|
||||
</transition-group>
|
||||
</transition>
|
||||
</div>
|
||||
<div role="status" class="flex animate-pulse flex-col gap-4" v-else>
|
||||
<div class="h-3 w-full rounded-full bg-base-content/50 opacity-50" v-for="_ in 9"></div>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useContainerStore } from "@/stores/container";
|
||||
import type { Container } from "@/types/Container";
|
||||
import { Container } from "@/models/Container";
|
||||
import { sessionHost } from "@/composables/storage";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const store = useContainerStore();
|
||||
|
||||
const { activeContainers, visibleContainers, ready } = storeToRefs(store);
|
||||
|
||||
function setHost(host: string | null) {
|
||||
sessionHost.value = host;
|
||||
}
|
||||
|
||||
const debouncedIds = debouncedRef(pinnedContainers, 200);
|
||||
const sortedContainers = computed(() =>
|
||||
visibleContainers.value
|
||||
.filter((c) => c.host === sessionHost.value)
|
||||
.sort((a, b) => {
|
||||
if (a.state === "running" && b.state !== "running") {
|
||||
return -1;
|
||||
} else if (a.state !== "running" && b.state === "running") {
|
||||
return 1;
|
||||
} else {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const groupedContainers = computed(() =>
|
||||
sortedContainers.value.reduce(
|
||||
(acc, item) => {
|
||||
if (debouncedIds.value.has(item.storageKey)) {
|
||||
acc.pinned.push(item);
|
||||
} else {
|
||||
acc.unpinned.push(item);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ pinned: [] as Container[], unpinned: [] as Container[] },
|
||||
),
|
||||
);
|
||||
|
||||
type MenuLabel = { label: string; id: string; state: string };
|
||||
const pinnedLabel = { label: t("label.pinned"), id: "pinned", state: "label" } as MenuLabel;
|
||||
const allLabel = { label: t("label.containers"), id: "containers", state: "label" } as MenuLabel;
|
||||
|
||||
function isLabel(item: Container | MenuLabel): item is MenuLabel {
|
||||
return (item as MenuLabel).label !== undefined;
|
||||
}
|
||||
|
||||
const menuItems = computed(() => {
|
||||
if (groupedContainers.value.pinned.length > 0) {
|
||||
return [pinnedLabel, ...groupedContainers.value.pinned, allLabel, ...groupedContainers.value.unpinned];
|
||||
} else {
|
||||
return [allLabel, ...groupedContainers.value.unpinned];
|
||||
}
|
||||
});
|
||||
|
||||
const hosts = computed(() =>
|
||||
config.hosts.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.id] = item;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { name: string; id: string }>,
|
||||
),
|
||||
);
|
||||
|
||||
const activeContainersById = computed(() =>
|
||||
activeContainers.value.reduce((acc, item) => {
|
||||
acc[item.id] = item;
|
||||
return acc;
|
||||
}, {} as Record<string, Container>)
|
||||
activeContainers.value.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.id] = item;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Container>,
|
||||
),
|
||||
);
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
aside {
|
||||
padding: 1em;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
position: fixed;
|
||||
width: inherit;
|
||||
|
||||
.is-hidden-mobile.is-active {
|
||||
display: block !important;
|
||||
}
|
||||
<style scoped lang="postcss">
|
||||
.menu {
|
||||
@apply text-[0.95rem];
|
||||
}
|
||||
.containers a {
|
||||
@apply auto-cols-[auto_max-content];
|
||||
.pin {
|
||||
display: none;
|
||||
|
||||
.loading {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
li.exited a {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 122px;
|
||||
height: 54px;
|
||||
fill: var(--logo-color);
|
||||
}
|
||||
|
||||
.menu-list li {
|
||||
.column-icon {
|
||||
visibility: hidden;
|
||||
|
||||
& > span {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .column-icon {
|
||||
visibility: visible;
|
||||
&:hover {
|
||||
color: var(--secondary-color);
|
||||
@apply text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.pin {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
li.exited {
|
||||
@apply opacity-50;
|
||||
}
|
||||
|
||||
.slide-left-enter-active,
|
||||
.slide-left-leave-active,
|
||||
.slide-right-enter-active,
|
||||
.slide-right-leave-active {
|
||||
transition: all 0.1s ease-out;
|
||||
}
|
||||
|
||||
.slide-left-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.slide-right-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
.slide-left-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
.slide-right-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.list-move,
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 0.19s ease;
|
||||
}
|
||||
|
||||
.list-enter-from,
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
|
||||
.list-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<aside class="fixed h-screen w-[inherit] overflow-auto p-3" data-testid="navigation">
|
||||
<h1>
|
||||
<router-link :to="{ name: 'index' }">
|
||||
<svg class="h-14 w-28 fill-secondary">
|
||||
<use href="#logo"></use>
|
||||
</svg>
|
||||
</router-link>
|
||||
|
||||
<small class="mb-4 block text-xs font-light" v-if="hostname">
|
||||
{{ hostname }}
|
||||
</small>
|
||||
</h1>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<router-link
|
||||
:to="{ name: 'settings' }"
|
||||
:aria-label="$t('title.settings')"
|
||||
data-testid="settings"
|
||||
class="btn btn-circle btn-sm"
|
||||
>
|
||||
<mdi:light-cog />
|
||||
</router-link>
|
||||
<a :href="`${base}/logout`" :title="$t('button.logout')" v-if="secured" class="btn btn-circle btn-sm">
|
||||
<mdi:light-logout />
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
class="input input-sm mt-4 inline-flex cursor-pointer items-center gap-2 font-light hover:border-primary"
|
||||
@click="$emit('search')"
|
||||
:title="$t('tooltip.search')"
|
||||
>
|
||||
<mdi:light-magnify />
|
||||
Search
|
||||
<key-shortcut char="k"></key-shortcut>
|
||||
</a>
|
||||
|
||||
<side-menu class="mt-4"></side-menu>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const { base, secured, hostname } = config;
|
||||
</script>
|
||||
@@ -1,200 +0,0 @@
|
||||
// Vitest Snapshot v1
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render dates with 12 hour style 1`] = `
|
||||
"<ul class=\\"events medium\\" data-v-cce5b553=\\"\\">
|
||||
<li data-key=\\"1\\" class=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-cce5b553=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-cce5b553=\\"\\">
|
||||
<path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path>
|
||||
<path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path>
|
||||
</svg></div>
|
||||
</div>
|
||||
<div class=\\"level-right\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\">Jump to Context</div>
|
||||
</div>
|
||||
</div>
|
||||
</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 10:55:42 AM</time></span>
|
||||
<!--v-if--><span class=\\"text\\" data-v-cce5b553=\\"\\"><test>foo bar</test></span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render dates with 24 hour style 1`] = `
|
||||
"<ul class=\\"events medium\\" data-v-cce5b553=\\"\\">
|
||||
<li data-key=\\"1\\" class=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-cce5b553=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-cce5b553=\\"\\">
|
||||
<path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path>
|
||||
<path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path>
|
||||
</svg></div>
|
||||
</div>
|
||||
<div class=\\"level-right\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\">Jump to Context</div>
|
||||
</div>
|
||||
</div>
|
||||
</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 10:55:42</time></span>
|
||||
<!--v-if--><span class=\\"text\\" data-v-cce5b553=\\"\\"><test>foo bar</test></span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render messages 1`] = `
|
||||
"<ul class=\\"events medium\\" data-v-cce5b553=\\"\\">
|
||||
<li data-key=\\"1\\" class=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-cce5b553=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-cce5b553=\\"\\">
|
||||
<path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path>
|
||||
<path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path>
|
||||
</svg></div>
|
||||
</div>
|
||||
<div class=\\"level-right\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\">Jump to Context</div>
|
||||
</div>
|
||||
</div>
|
||||
</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 10:55:42 AM</time></span>
|
||||
<!--v-if--><span class=\\"text\\" data-v-cce5b553=\\"\\">This is a message.</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render messages with color 1`] = `
|
||||
"<ul class=\\"events medium\\" data-v-cce5b553=\\"\\">
|
||||
<li data-key=\\"1\\" class=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-cce5b553=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-cce5b553=\\"\\">
|
||||
<path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path>
|
||||
<path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path>
|
||||
</svg></div>
|
||||
</div>
|
||||
<div class=\\"level-right\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\">Jump to Context</div>
|
||||
</div>
|
||||
</div>
|
||||
</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 10:55:42 AM</time></span>
|
||||
<!--v-if--><span class=\\"text\\" data-v-cce5b553=\\"\\"><span style=\\"color:#000\\">black<span style=\\"color:#AAA\\">white</span></span></span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render messages with filter 1`] = `
|
||||
"<ul class=\\"events medium\\" data-v-cce5b553=\\"\\">
|
||||
<li data-key=\\"2\\" class=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-cce5b553=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#2\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-cce5b553=\\"\\">
|
||||
<path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path>
|
||||
<path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path>
|
||||
</svg></div>
|
||||
</div>
|
||||
<div class=\\"level-right\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\">Jump to Context</div>
|
||||
</div>
|
||||
</div>
|
||||
</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 10:55:42 AM</time></span>
|
||||
<!--v-if--><span class=\\"text\\" data-v-cce5b553=\\"\\"><mark>test</mark> bar</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render messages with html entities 1`] = `
|
||||
"<ul class=\\"events medium\\" data-v-cce5b553=\\"\\">
|
||||
<li data-key=\\"1\\" class=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-cce5b553=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-cce5b553=\\"\\">
|
||||
<path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path>
|
||||
<path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path>
|
||||
</svg></div>
|
||||
</div>
|
||||
<div class=\\"level-right\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\">Jump to Context</div>
|
||||
</div>
|
||||
</div>
|
||||
</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 10:55:42 AM</time></span>
|
||||
<!--v-if--><span class=\\"text\\" data-v-cce5b553=\\"\\"><test>foo bar</test></span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > renders correctly 1`] = `
|
||||
"<div class=\\"infinte-loader\\" data-v-1cd63c6e=\\"\\">
|
||||
<div class=\\"spinner\\" data-v-1cd63c6e=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"bounce1\\" data-v-1cd63c6e=\\"\\"></div>
|
||||
<div class=\\"bounce2\\" data-v-1cd63c6e=\\"\\"></div>
|
||||
<div class=\\"bounce3\\" data-v-1cd63c6e=\\"\\"></div>
|
||||
</div>
|
||||
</div>
|
||||
<ul class=\\"events medium\\" data-v-cce5b553=\\"\\"></ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > should parse messages 1`] = `
|
||||
{
|
||||
"date": 2019-06-12T10:55:42.459Z,
|
||||
"id": 1,
|
||||
"message": "This is a message.",
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div class="inline-flex gap-2">
|
||||
<time :datetime="date.toISOString()" class="mobile-hidden">{{ dateStr }}</time>
|
||||
<time :datetime="date.toISOString()">{{ timeStr }}</time>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const props = defineProps<{
|
||||
date: Date;
|
||||
}>();
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat(undefined, { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||
const use12Hour = $computed(() => ({ auto: undefined, "12": true, "24": false })[hourStyle.value]);
|
||||
const timeFormatter = $computed(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit", second: "2-digit", hour12: use12Hour }),
|
||||
);
|
||||
|
||||
const dateStr = $computed(() => dateFormatter.format(props.date));
|
||||
const timeStr = $computed(() => timeFormatter.format(props.date));
|
||||
</script>
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<time :datetime="date.toISOString()" data-ci-skip>{{ text }}</time>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import formatDistanceToNow from "date-fns/formatDistanceToNow";
|
||||
import formatDistanceToNowStrict from "date-fns/formatDistanceToNowStrict";
|
||||
|
||||
const {
|
||||
date,
|
||||
strict = false,
|
||||
suffix = true,
|
||||
} = defineProps<{
|
||||
date: Date;
|
||||
strict?: boolean;
|
||||
suffix?: boolean;
|
||||
}>();
|
||||
|
||||
const text = ref<string>();
|
||||
|
||||
watch($$(date), updateFromNow, { immediate: true });
|
||||
|
||||
function updateFromNow() {
|
||||
const fn = strict ? formatDistanceToNowStrict : formatDistanceToNow;
|
||||
text.value = fn(date, {
|
||||
addSuffix: suffix,
|
||||
});
|
||||
}
|
||||
useIntervalFn(updateFromNow, 30_000, { immediateCallback: true });
|
||||
</script>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<details class="dropdown" ref="details" v-on-click-outside="close">
|
||||
<summary class="btn btn-primary flex-nowrap" v-bind="$attrs">
|
||||
<slot name="trigger"> {{ values[modelValue] ?? defaultLabel }} <carbon:caret-down /></slot>
|
||||
</summary>
|
||||
<ul class="menu dropdown-content rounded-box z-50 w-52 bg-base p-2 shadow">
|
||||
<slot>
|
||||
<li v-for="item in options">
|
||||
<a @click="modelValue = item.value">
|
||||
<mdi-light:check class="w-4" v-if="modelValue == item.value" />
|
||||
<div v-else class="w-4"></div>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
</li>
|
||||
</slot>
|
||||
</ul>
|
||||
</details>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { vOnClickOutside } from "@vueuse/components";
|
||||
type DropdownItem = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
const { options = [], defaultLabel = "" } = defineProps<{ options?: DropdownItem[]; defaultLabel?: string }>();
|
||||
const { modelValue } = defineModels<{
|
||||
modelValue: string;
|
||||
}>();
|
||||
|
||||
const values = computed(() =>
|
||||
options.reduce(
|
||||
(acc, curr) => {
|
||||
acc[curr.value] = curr.label;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
),
|
||||
);
|
||||
|
||||
const details = ref<HTMLElement | null>(null);
|
||||
const close = () => details.value?.removeAttribute("open");
|
||||
watch(modelValue, () => close());
|
||||
</script>
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="inline-flex items-center">
|
||||
<template v-if="modifiers.includes('shift')">
|
||||
<carbon:mac-shift />
|
||||
</template>
|
||||
<template v-if="modifiers.includes('meta')">
|
||||
<ph:command v-if="isMac" class="h-4 w-4" />
|
||||
<ph:control-bold v-else class="h-4 w-4" />
|
||||
</template>
|
||||
<kbd class="uppercase">{{ char }}</kbd>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
const isMac = /(Mac|iPhone|iPod|iPad)/i.test(navigator.userAgent);
|
||||
</script>
|
||||
<script lang="ts" setup>
|
||||
const { modifiers = ["meta"], char } = defineProps<{ char: string; modifiers?: ("^" | "meta" | "shift")[] }>();
|
||||
</script>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<nav class="fixed top-0 z-10 w-full border-b border-base-content/20 bg-base p-2" data-testid="navigation">
|
||||
<div class="flex items-center">
|
||||
<router-link :to="{ name: 'index' }">
|
||||
<svg class="h-14 w-28 fill-secondary">
|
||||
<use href="#logo"></use>
|
||||
</svg>
|
||||
</router-link>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<a class="btn btn-circle flex" @click="$emit('search')" :title="$t('tooltip.search')">
|
||||
<mdi:light-magnify class="h-5 w-5" />
|
||||
</a>
|
||||
<label class="btn btn-circle swap swap-rotate" data-testid="hamburger">
|
||||
<input type="checkbox" v-model="show" />
|
||||
<mdi:close class="swap-on" />
|
||||
<mdi:hamburger-menu class="swap-off" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-show="show">
|
||||
<div class="mt-4 flex items-center justify-center gap-2">
|
||||
<dropdown
|
||||
v-model="sessionHost"
|
||||
:options="hosts"
|
||||
defaultLabel="Hosts"
|
||||
class="btn-sm"
|
||||
v-if="config.hosts.length > 1"
|
||||
/>
|
||||
<router-link :to="{ name: 'settings' }" class="btn btn-outline btn-sm">
|
||||
<mdi:light-cog /> {{ $t("button.settings") }}
|
||||
</router-link>
|
||||
<a class="btn btn-outline btn-sm" :href="`${base}/logout`" :title="$t('button.logout')" v-if="secured">
|
||||
<mdi:light-logout /> {{ $t("button.logout") }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ul class="menu">
|
||||
<li class="menu-title">{{ $t("label.containers") }}</li>
|
||||
<li v-for="item in sortedContainers" :key="item.id">
|
||||
<router-link
|
||||
:to="{ name: 'container-id', params: { id: item.id } }"
|
||||
active-class="active-primary"
|
||||
class="truncate"
|
||||
:title="item.name"
|
||||
>
|
||||
{{ item.name }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</transition>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const { base, secured } = config;
|
||||
import { sessionHost } from "@/composables/storage";
|
||||
const store = useContainerStore();
|
||||
const route = useRoute();
|
||||
const { visibleContainers } = storeToRefs(store);
|
||||
|
||||
const show = ref(false);
|
||||
|
||||
watch(route, () => {
|
||||
show.value = false;
|
||||
});
|
||||
|
||||
const sortedContainers = computed(() =>
|
||||
visibleContainers.value
|
||||
.filter((c) => c.host === sessionHost.value)
|
||||
.sort((a, b) => {
|
||||
if (a.state === "running" && b.state !== "running") {
|
||||
return -1;
|
||||
} else if (a.state !== "running" && b.state === "running") {
|
||||
return 1;
|
||||
} else {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const hosts = computed(() => config.hosts.map(({ id, name }) => ({ value: id, label: name })));
|
||||
</script>
|
||||
<style scoped lang="postcss">
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
@apply transition-opacity;
|
||||
}
|
||||
|
||||
.fade-enter-active .menu,
|
||||
.fade-leave-active .menu {
|
||||
@apply transition-transform;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
@apply opacity-0;
|
||||
}
|
||||
|
||||
.fade-enter-from .menu,
|
||||
.fade-leave-to .menu {
|
||||
@apply -translate-y-2;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] text-sm" :size="size">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const { size = undefined } = defineProps<{ size?: "small" | undefined }>();
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
[size="small"] {
|
||||
@apply text-xs;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<button class="btn relative overflow-hidden" @click="cancel()">
|
||||
<div class="absolute inset-0 origin-left bg-white/30" ref="progress"></div>
|
||||
<div class="z-10">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const progress = ref<HTMLElement>();
|
||||
const finished = defineEmit();
|
||||
const cancelled = defineEmit();
|
||||
let animation: Animation | undefined;
|
||||
|
||||
onMounted(async () => {
|
||||
animation = progress.value?.animate([{ transform: "scaleX(0)" }, { transform: "scaleX(1)" }], {
|
||||
duration: 4000,
|
||||
easing: "linear",
|
||||
fill: "forwards",
|
||||
});
|
||||
try {
|
||||
await animation?.finished;
|
||||
finished();
|
||||
} catch (e) {
|
||||
progress.value?.animate([{ transform: "scaleX(1)" }, { transform: "scaleX(0)" }], {
|
||||
duration: 0,
|
||||
fill: "forwards",
|
||||
});
|
||||
cancelled();
|
||||
}
|
||||
});
|
||||
|
||||
const cancel = () => {
|
||||
animation?.cancel();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss"></style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<label class="label inline-flex cursor-pointer gap-4 font-normal">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary hover:bg-primary-focus"
|
||||
:checked="modelValue"
|
||||
@click="toggle()"
|
||||
/>
|
||||
<slot />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const { modelValue } = defineModels<{
|
||||
modelValue: boolean;
|
||||
}>();
|
||||
|
||||
const toggle = useToggle(modelValue);
|
||||
</script>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
type ContainerContext = {
|
||||
container: Ref<Container>;
|
||||
streamConfig: { stdout: boolean; stderr: boolean };
|
||||
};
|
||||
|
||||
export const containerContext = Symbol("containerContext") as InjectionKey<ContainerContext>;
|
||||
|
||||
export const provideContainerContext = (container: Ref<Container>) => {
|
||||
provide(containerContext, {
|
||||
container,
|
||||
streamConfig: reactive({ stdout: true, stderr: true }),
|
||||
});
|
||||
};
|
||||
|
||||
export const useContainerContext = () => {
|
||||
const context = inject(containerContext);
|
||||
if (!context) {
|
||||
throw new Error("No container context provided");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,26 +1,59 @@
|
||||
import { ref, watch, onUnmounted, ComputedRef } from "vue";
|
||||
import { type Ref } from "vue";
|
||||
import { encodeXML } from "entities";
|
||||
import debounce from "lodash.debounce";
|
||||
import {
|
||||
type LogEvent,
|
||||
type JSONObject,
|
||||
LogEntry,
|
||||
asLogEntry,
|
||||
DockerEventLogEntry,
|
||||
SkippedLogsEntry,
|
||||
} from "@/models/LogEntry";
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
import type { LogEntry, LogEvent } from "@/types/LogEntry";
|
||||
|
||||
import config from "@/stores/config";
|
||||
import { Container } from "@/types/Container";
|
||||
|
||||
function parseMessage(data: string): LogEntry {
|
||||
const e = JSON.parse(data) as LogEvent;
|
||||
|
||||
const id = e.id;
|
||||
const date = new Date(e.ts);
|
||||
return { id, date, message: e.m };
|
||||
function parseMessage(data: string): LogEntry<string | JSONObject> {
|
||||
const e = JSON.parse(data, (key, value) => {
|
||||
if (typeof value === "string") {
|
||||
return encodeXML(value);
|
||||
}
|
||||
return value;
|
||||
}) as LogEvent;
|
||||
return asLogEntry(e);
|
||||
}
|
||||
|
||||
export function useEventSource(container: ComputedRef<Container>) {
|
||||
const messages = ref<LogEntry[]>([]);
|
||||
const buffer = ref<LogEntry[]>([]);
|
||||
type LogStreamConfig = {
|
||||
stdout: boolean;
|
||||
stderr: boolean;
|
||||
};
|
||||
|
||||
export function useLogStream(container: Ref<Container>, streamConfig: LogStreamConfig) {
|
||||
let messages: LogEntry<string | JSONObject>[] = $ref([]);
|
||||
let buffer: LogEntry<string | JSONObject>[] = $ref([]);
|
||||
const scrollingPaused = $ref(inject("scrollingPaused") as Ref<boolean>);
|
||||
|
||||
function flushNow() {
|
||||
messages.value.push(...buffer.value);
|
||||
buffer.value = [];
|
||||
if (messages.length > config.maxLogs) {
|
||||
if (scrollingPaused) {
|
||||
console.log("Skipping ", buffer.length, " log items");
|
||||
if (messages.at(-1) instanceof SkippedLogsEntry) {
|
||||
const lastEvent = messages.at(-1) as SkippedLogsEntry;
|
||||
const lastItem = buffer.at(-1) as LogEntry<string | JSONObject>;
|
||||
lastEvent.addSkippedEntries(buffer.length, lastItem);
|
||||
} else {
|
||||
const firstItem = buffer.at(0) as LogEntry<string | JSONObject>;
|
||||
const lastItem = buffer.at(-1) as LogEntry<string | JSONObject>;
|
||||
messages.push(new SkippedLogsEntry(new Date(), buffer.length, firstItem, lastItem));
|
||||
}
|
||||
buffer = [];
|
||||
} else {
|
||||
messages.push(...buffer);
|
||||
buffer = [];
|
||||
messages = messages.slice(-config.maxLogs);
|
||||
}
|
||||
} else {
|
||||
messages.push(...buffer);
|
||||
buffer = [];
|
||||
}
|
||||
}
|
||||
const flushBuffer = debounce(flushNow, 250, { maxWait: 1000 });
|
||||
let es: EventSource | null = null;
|
||||
@@ -31,21 +64,32 @@ export function useEventSource(container: ComputedRef<Container>) {
|
||||
|
||||
if (clear) {
|
||||
flushBuffer.cancel();
|
||||
messages.value = [];
|
||||
buffer.value = [];
|
||||
messages = [];
|
||||
buffer = [];
|
||||
lastEventId = "";
|
||||
}
|
||||
|
||||
es = new EventSource(`${config.base}/api/logs/stream?id=${container.value.id}&lastEventId=${lastEventId}`);
|
||||
const params = {
|
||||
lastEventId,
|
||||
} as { lastEventId: string; stdout?: string; stderr?: string };
|
||||
|
||||
if (streamConfig.stdout) {
|
||||
params.stdout = "1";
|
||||
}
|
||||
if (streamConfig.stderr) {
|
||||
params.stderr = "1";
|
||||
}
|
||||
|
||||
es = new EventSource(
|
||||
`${config.base}/api/logs/stream/${container.value.host}/${container.value.id}?${new URLSearchParams(
|
||||
params,
|
||||
).toString()}`,
|
||||
);
|
||||
es.addEventListener("container-stopped", () => {
|
||||
es?.close();
|
||||
es = null;
|
||||
buffer.value.push({
|
||||
event: "container-stopped",
|
||||
message: "Container stopped",
|
||||
date: new Date(),
|
||||
id: new Date().getTime(),
|
||||
});
|
||||
buffer.push(new DockerEventLogEntry("Container stopped", new Date(), "container-stopped"));
|
||||
|
||||
flushBuffer();
|
||||
flushBuffer.flush();
|
||||
});
|
||||
@@ -53,29 +97,46 @@ export function useEventSource(container: ComputedRef<Container>) {
|
||||
es.onmessage = (e) => {
|
||||
lastEventId = e.lastEventId;
|
||||
if (e.data) {
|
||||
buffer.value.push(parseMessage(e.data));
|
||||
buffer.push(parseMessage(e.data));
|
||||
flushBuffer();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function loadOlderLogs({ beforeLoading, afterLoading } = { beforeLoading: () => {}, afterLoading: () => {} }) {
|
||||
if (messages.value.length < 300) return;
|
||||
if (messages.length < 300) return;
|
||||
|
||||
beforeLoading();
|
||||
const to = messages.value[0].date;
|
||||
const last = messages.value[299].date;
|
||||
const to = messages[0].date;
|
||||
const last = messages[299].date;
|
||||
const delta = to.getTime() - last.getTime();
|
||||
const from = new Date(to.getTime() + delta);
|
||||
|
||||
const params = {
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
} as { from: string; to: string; stdout?: string; stderr?: string };
|
||||
|
||||
if (streamConfig.stdout) {
|
||||
params.stdout = "1";
|
||||
}
|
||||
if (streamConfig.stderr) {
|
||||
params.stderr = "1";
|
||||
}
|
||||
|
||||
const logs = await (
|
||||
await fetch(`${config.base}/api/logs?id=${container.value.id}&from=${from.toISOString()}&to=${to.toISOString()}`)
|
||||
await fetch(
|
||||
`${config.base}/api/logs/${container.value.host}/${container.value.id}?${new URLSearchParams(
|
||||
params,
|
||||
).toString()}`,
|
||||
)
|
||||
).text();
|
||||
if (logs) {
|
||||
const newMessages = logs
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => parseMessage(line));
|
||||
messages.value.unshift(...newMessages);
|
||||
messages.unshift(...newMessages);
|
||||
}
|
||||
afterLoading();
|
||||
}
|
||||
@@ -85,15 +146,10 @@ export function useEventSource(container: ComputedRef<Container>) {
|
||||
(newValue, oldValue) => {
|
||||
console.log("LogEventSource: container changed", newValue, oldValue);
|
||||
if (newValue == "running" && newValue != oldValue) {
|
||||
buffer.value.push({
|
||||
event: "container-started",
|
||||
message: "Container started",
|
||||
date: new Date(),
|
||||
id: new Date().getTime(),
|
||||
});
|
||||
buffer.push(new DockerEventLogEntry("Container started", new Date(), "container-started"));
|
||||
connect({ clear: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -104,8 +160,11 @@ export function useEventSource(container: ComputedRef<Container>) {
|
||||
|
||||
watch(
|
||||
() => container.value.id,
|
||||
() => connect()
|
||||
() => connect(),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
return { connect, messages, loadOlderLogs };
|
||||
watch(streamConfig, () => connect());
|
||||
|
||||
return { ...$$({ messages }), loadOlderLogs };
|
||||
}
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
import { useMediaQuery } from "@vueuse/core";
|
||||
|
||||
export const isMobile = useMediaQuery("(max-width: 770px)");
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
const show = ref(false);
|
||||
const debouncedShow = debouncedRef(show, 1000);
|
||||
|
||||
const delayedShow = computed({
|
||||
set(newVal: boolean) {
|
||||
show.value = newVal;
|
||||
},
|
||||
get() {
|
||||
return debouncedShow.value;
|
||||
},
|
||||
});
|
||||
|
||||
export const globalShowPopup = () => delayedShow;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref, computed, Ref } from "vue";
|
||||
import { useDebounce } from "@vueuse/core";
|
||||
import { VisibleLogEntry } from "@/types/VisibleLogEntry";
|
||||
import { type Ref } from "vue";
|
||||
import { type LogEntry, type JSONObject, SimpleLogEntry, ComplexLogEntry } from "@/models/LogEntry";
|
||||
import { encodeXML } from "entities";
|
||||
|
||||
const searchFilter = ref<string>("");
|
||||
const debouncedSearchFilter = useDebounce(searchFilter);
|
||||
@@ -12,6 +12,9 @@ function matchRecord(record: Record<string, any>, regex: RegExp): boolean {
|
||||
if (typeof value === "string" && regex.test(value)) {
|
||||
return true;
|
||||
}
|
||||
if (isObject(value) && regex.test(JSON.stringify(value))) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(value) && matchRecord(value, regex)) {
|
||||
return true;
|
||||
}
|
||||
@@ -20,22 +23,22 @@ function matchRecord(record: Record<string, any>, regex: RegExp): boolean {
|
||||
}
|
||||
|
||||
export function useSearchFilter() {
|
||||
const regex = computed(() => {
|
||||
const regex = $computed(() => {
|
||||
const isSmartCase = debouncedSearchFilter.value === debouncedSearchFilter.value.toLowerCase();
|
||||
return isSmartCase ? new RegExp(debouncedSearchFilter.value, "i") : new RegExp(debouncedSearchFilter.value);
|
||||
return new RegExp(encodeXML(debouncedSearchFilter.value), isSmartCase ? "i" : undefined);
|
||||
});
|
||||
|
||||
function filteredMessages(messages: Ref<VisibleLogEntry[]>) {
|
||||
function filteredMessages(messages: Ref<LogEntry<string | JSONObject>[]>) {
|
||||
return computed(() => {
|
||||
if (debouncedSearchFilter.value) {
|
||||
if (debouncedSearchFilter.value && showSearch.value) {
|
||||
try {
|
||||
return messages.value.filter((d) => {
|
||||
if (d.isSimple()) {
|
||||
return regex.value.test(d.message);
|
||||
} else if (d.isComplex()) {
|
||||
return matchRecord(d.message, regex.value);
|
||||
if (d instanceof SimpleLogEntry) {
|
||||
return regex.test(d.message);
|
||||
} else if (d instanceof ComplexLogEntry) {
|
||||
return matchRecord(d.message, regex);
|
||||
}
|
||||
throw new Error("Unknown message type");
|
||||
return false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) {
|
||||
@@ -50,9 +53,9 @@ export function useSearchFilter() {
|
||||
});
|
||||
}
|
||||
|
||||
function markSearch(log: string): string;
|
||||
function markSearch(log: { toString(): string }): string;
|
||||
function markSearch(log: string[]): string[];
|
||||
function markSearch(log: string | string[]) {
|
||||
function markSearch(log: { toString(): string } | string[]) {
|
||||
if (!debouncedSearchFilter.value) {
|
||||
return log;
|
||||
}
|
||||
@@ -60,7 +63,7 @@ export function useSearchFilter() {
|
||||
return log.map((d) => markSearch(d));
|
||||
}
|
||||
|
||||
return log.toString().replace(regex.value, (match) => `<mark>${match}</mark>`);
|
||||
return log.toString().replace(regex, (match) => `<mark>${match}</mark>`);
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import { computed } from "vue";
|
||||
|
||||
export const DOZZLE_SETTINGS_KEY = "DOZZLE_SETTINGS";
|
||||
import { toRefs } from "@vueuse/core";
|
||||
const DOZZLE_SETTINGS_KEY = "DOZZLE_SETTINGS";
|
||||
|
||||
export const DEFAULT_SETTINGS: {
|
||||
search: boolean;
|
||||
@@ -9,65 +7,42 @@ export const DEFAULT_SETTINGS: {
|
||||
menuWidth: number;
|
||||
smallerScrollbars: boolean;
|
||||
showTimestamp: boolean;
|
||||
showStd: boolean;
|
||||
showAllContainers: boolean;
|
||||
lightTheme: "auto" | "dark" | "light";
|
||||
hourStyle: "auto" | "24" | "12";
|
||||
softWrap: boolean;
|
||||
collapseNav: boolean;
|
||||
automaticRedirect: boolean;
|
||||
} = {
|
||||
search: true,
|
||||
size: "medium",
|
||||
menuWidth: 15,
|
||||
smallerScrollbars: false,
|
||||
showTimestamp: true,
|
||||
showStd: false,
|
||||
showAllContainers: false,
|
||||
lightTheme: "auto",
|
||||
hourStyle: "auto",
|
||||
softWrap: true,
|
||||
collapseNav: false,
|
||||
automaticRedirect: true,
|
||||
};
|
||||
|
||||
export const settings = useStorage(DOZZLE_SETTINGS_KEY, DEFAULT_SETTINGS);
|
||||
settings.value = {...DEFAULT_SETTINGS, ...settings.value};
|
||||
settings.value = { ...DEFAULT_SETTINGS, ...settings.value };
|
||||
|
||||
export const search = computed({
|
||||
get: () => settings.value.search,
|
||||
set: (value) => (settings.value.search = value),
|
||||
});
|
||||
|
||||
export const size = computed({
|
||||
get: () => settings.value.size,
|
||||
set: (value) => (settings.value.size = value),
|
||||
});
|
||||
|
||||
export const menuWidth = computed({
|
||||
get: () => settings.value.menuWidth,
|
||||
set: (value) => (settings.value.menuWidth = value),
|
||||
});
|
||||
export const smallerScrollbars = computed({
|
||||
get: () => settings.value.smallerScrollbars,
|
||||
set: (value) => (settings.value.smallerScrollbars = value),
|
||||
});
|
||||
|
||||
export const showTimestamp = computed({
|
||||
get: () => settings.value.showTimestamp,
|
||||
set: (value) => (settings.value.showTimestamp = value),
|
||||
});
|
||||
|
||||
export const showAllContainers = computed({
|
||||
get: () => settings.value.showAllContainers,
|
||||
set: (value) => (settings.value.showAllContainers = value),
|
||||
});
|
||||
|
||||
export const lightTheme = computed({
|
||||
get: () => settings.value.lightTheme,
|
||||
set: (value) => (settings.value.lightTheme = value),
|
||||
});
|
||||
|
||||
export const hourStyle = computed({
|
||||
get: () => settings.value.hourStyle,
|
||||
set: (value) => (settings.value.hourStyle = value),
|
||||
});
|
||||
|
||||
export const softWrap = computed({
|
||||
get: () => settings.value.softWrap,
|
||||
set: (value) => (settings.value.softWrap = value),
|
||||
});
|
||||
export const {
|
||||
collapseNav,
|
||||
softWrap,
|
||||
hourStyle,
|
||||
lightTheme,
|
||||
showAllContainers,
|
||||
showTimestamp,
|
||||
showStd,
|
||||
smallerScrollbars,
|
||||
menuWidth,
|
||||
size,
|
||||
search,
|
||||
automaticRedirect,
|
||||
} = toRefs(settings);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
const DOZZLE_HOST = "DOZZLE_HOST";
|
||||
export const sessionHost = useSessionStorage<string | null>(DOZZLE_HOST, null);
|
||||
|
||||
if (config.hosts.length === 1 && !sessionHost.value) {
|
||||
sessionHost.value = config.hosts[0].id;
|
||||
}
|
||||
|
||||
export function persistentVisibleKeys(container: Ref<Container>) {
|
||||
return computed(() => useStorage(container.value.storageKey, []));
|
||||
}
|
||||
|
||||
const DOZZLE_PINNED_CONTAINERS = "DOZZLE_PINNED_CONTAINERS";
|
||||
export const pinnedContainers = useStorage(DOZZLE_PINNED_CONTAINERS, new Set<string>());
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useTitle } from "@vueuse/core";
|
||||
import { ref, computed } from "vue";
|
||||
const { hostname } = config;
|
||||
let subtitle = $ref("");
|
||||
const title = $computed(() => (subtitle ? `${subtitle} - ` : "") + "Dozzle" + (hostname ? ` @ ${hostname}` : ""));
|
||||
|
||||
const subtitle = ref("");
|
||||
|
||||
const title = computed(() => `${subtitle.value} - Dozzle`);
|
||||
|
||||
useTitle(title);
|
||||
useTitle($$(title));
|
||||
|
||||
export function setTitle(t: string) {
|
||||
subtitle.value = t;
|
||||
subtitle = t;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
type Toast = {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
title?: string;
|
||||
message: string;
|
||||
type: "success" | "error" | "warning" | "info";
|
||||
};
|
||||
|
||||
type ToastOptions = {
|
||||
expire?: number;
|
||||
once?: boolean;
|
||||
};
|
||||
|
||||
const toasts = ref<Toast[]>([]);
|
||||
|
||||
const showToast = (
|
||||
toast: Omit<Toast, "id" | "createdAt"> & { id?: string },
|
||||
{ expire = -1, once = false }: ToastOptions = { expire: -1, once: false },
|
||||
) => {
|
||||
if (once && toasts.value.some((t) => t.id === toast.id)) {
|
||||
return;
|
||||
}
|
||||
toasts.value.push({
|
||||
id: Date.now().toString(),
|
||||
createdAt: new Date(),
|
||||
...toast,
|
||||
});
|
||||
if (expire > 0) {
|
||||
setTimeout(() => {
|
||||
removeToast(toasts.value[0].id);
|
||||
}, expire);
|
||||
}
|
||||
};
|
||||
|
||||
const removeToast = (id: Toast["id"]) => {
|
||||
toasts.value = toasts.value.filter((toast) => toast.id !== id);
|
||||
};
|
||||
|
||||
export const useToast = () => {
|
||||
return {
|
||||
toasts,
|
||||
showToast,
|
||||
removeToast,
|
||||
};
|
||||
};
|
||||
@@ -1,11 +1,16 @@
|
||||
import { LogEntry } from "@/types/LogEntry";
|
||||
import { VisibleLogEntry } from "@/types/VisibleLogEntry";
|
||||
import { computed, ComputedRef, Ref } from "vue";
|
||||
import { ComplexLogEntry, type JSONObject, type LogEntry } from "@/models/LogEntry";
|
||||
import type { ComputedRef, Ref } from "vue";
|
||||
|
||||
export function useVisibleFilter(visibleKeys: ComputedRef<Ref<string[][]>>) {
|
||||
function filteredPayload(messages: Ref<LogEntry[]>) {
|
||||
function filteredPayload(messages: Ref<LogEntry<string | JSONObject>[]>) {
|
||||
return computed(() => {
|
||||
return messages.value.map((d) => new VisibleLogEntry(d, visibleKeys.value));
|
||||
return messages.value.map((d) => {
|
||||
if (d instanceof ComplexLogEntry) {
|
||||
return ComplexLogEntry.fromLogEvent(d, visibleKeys.value);
|
||||
} else {
|
||||
return d;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div v-if="!authorizationNeeded">
|
||||
<mobile-menu v-if="isMobile" @search="showFuzzySearch"></mobile-menu>
|
||||
<splitpanes @resized="onResized($event)">
|
||||
<pane min-size="10" :size="menuWidth" v-if="!isMobile && !collapseNav">
|
||||
<side-panel @search="showFuzzySearch"></side-panel>
|
||||
</pane>
|
||||
<pane min-size="10">
|
||||
<splitpanes>
|
||||
<pane class="router-view min-h-screen">
|
||||
<router-view></router-view>
|
||||
</pane>
|
||||
<template v-if="!isMobile">
|
||||
<pane v-for="other in activeContainers" :key="other.id">
|
||||
<log-container
|
||||
:id="other.id"
|
||||
show-title
|
||||
scrollable
|
||||
closable
|
||||
@close="containerStore.removeActiveContainer(other)"
|
||||
></log-container>
|
||||
</pane>
|
||||
</template>
|
||||
</splitpanes>
|
||||
</pane>
|
||||
</splitpanes>
|
||||
<label
|
||||
class="btn btn-circle swap swap-rotate fixed bottom-8 left-4"
|
||||
:class="{ '!-left-3': collapseNav }"
|
||||
v-if="!isMobile"
|
||||
>
|
||||
<input type="checkbox" v-model="collapseNav" />
|
||||
<mdi:light-chevron-right class="swap-on text-secondary" />
|
||||
<mdi:light-chevron-left class="swap-off" />
|
||||
</label>
|
||||
</div>
|
||||
<dialog ref="modal" class="modal items-start bg-white/20 backdrop:backdrop-blur-sm" @close="open = false">
|
||||
<div class="modal-box max-w-2xl bg-transparent pt-20 shadow-none">
|
||||
<FuzzySearchModal @close="open = false" v-if="open" />
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
<div class="toast toast-end whitespace-normal">
|
||||
<div
|
||||
class="alert max-w-xl"
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
:class="{ 'alert-error': toast.type === 'error', 'alert-info': toast.type === 'info' }"
|
||||
>
|
||||
<carbon:information class="h-6 w-6 shrink-0 stroke-current" v-if="toast.type === 'info'" />
|
||||
<carbon:warning class="h-6 w-6 shrink-0 stroke-current" v-else-if="toast.type === 'error'" />
|
||||
<div>
|
||||
<h3 class="text-lg font-bold" v-if="toast.title">{{ toast.title }}</h3>
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-circle btn-xs" @click="removeToast(toast.id)"><mdi:close /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
// @ts-ignore - splitpanes types are not available
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import { collapseNav } from "@/composables/settings";
|
||||
const { authorizationNeeded } = config;
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
const { activeContainers } = storeToRefs(containerStore);
|
||||
|
||||
const { toasts, removeToast } = useToast();
|
||||
|
||||
const modal = ref<HTMLDialogElement>();
|
||||
const open = ref(false);
|
||||
|
||||
watch(open, () => {
|
||||
if (open.value) {
|
||||
modal.value?.showModal();
|
||||
} else {
|
||||
modal.value?.close();
|
||||
}
|
||||
});
|
||||
|
||||
onKeyStroke("k", (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||
showFuzzySearch();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
function showFuzzySearch() {
|
||||
open.value = true;
|
||||
}
|
||||
|
||||
function onResized(e: any) {
|
||||
if (e.length == 2) {
|
||||
menuWidth.value = e[0].size;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
:deep(.splitpanes--vertical > .splitpanes__splitter) {
|
||||
@apply min-w-[3px] bg-base-lighter hover:bg-secondary;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.router-view {
|
||||
padding-top: 75px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div class="hero min-h-screen">
|
||||
<div class="hero-content">
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
@import "splitpanes/dist/splitpanes.css";
|
||||
|
||||
@define-mixin light {
|
||||
--base-lighter-color: 0 0% 100%;
|
||||
--base-color: 0 0% 97%;
|
||||
--base-darker-color: 0 0% 90%;
|
||||
--base-content-color: 0 0% 21%;
|
||||
--primary-color: 171 100% 41%;
|
||||
--primary-focus-color: 171 100% 31%;
|
||||
--secondary-color: 34 95% 53%;
|
||||
--secondary-focus-color: 34 95% 43%;
|
||||
}
|
||||
|
||||
@define-mixin dark {
|
||||
--base-lighter-color: 0 0% 14%;
|
||||
--base-color: 0 0% 7%;
|
||||
--base-darker-color: 0 0% 4%;
|
||||
--base-content-color: 0 0% 86%;
|
||||
--primary-color: 171 100% 41%;
|
||||
--primary-focus-color: 171 100% 31%;
|
||||
--secondary-color: 44 100% 77%;
|
||||
--secondary-focus-color: 44 100% 67%;
|
||||
}
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--p: var(--primary-color);
|
||||
--pf: var(--primary-focus-color);
|
||||
--pc: 0 0% 100%;
|
||||
--s: var(--secondary-color);
|
||||
--sf: var(--secondary-focus-color);
|
||||
--sc: 0 0% 4%;
|
||||
--b1: var(--base-lighter-color);
|
||||
--b2: var(--base-color);
|
||||
--b3: var(--base-darker-color);
|
||||
--bc: var(--base-content-color);
|
||||
--in: 207 90% 54%;
|
||||
--inc: 207 90% 94%;
|
||||
--er: 4 90% 58%;
|
||||
--erc: 4 90% 98%;
|
||||
}
|
||||
html[data-theme="dark"] {
|
||||
@mixin dark;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
@mixin dark;
|
||||
}
|
||||
}
|
||||
html[data-theme="light"] {
|
||||
@mixin light;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
html {
|
||||
@mixin light;
|
||||
}
|
||||
}
|
||||
@media screen and (max-device-width: 480px) {
|
||||
body {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-3xl;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-2xl;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
mark {
|
||||
@apply inline-block rounded-sm bg-secondary;
|
||||
animation: pops 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes pops {
|
||||
0% {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.input {
|
||||
@apply focus:outline-none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply font-normal normal-case;
|
||||
}
|
||||
|
||||
.btn:not(.btn-primary):not(.btn-secondary) {
|
||||
@apply hover:bg-base-lighter;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@media (max-width: 768px) {
|
||||
.mobile-hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.active-primary {
|
||||
--n: var(--p);
|
||||
--nc: var(--pc);
|
||||
@apply active;
|
||||
}
|
||||
}
|
||||
+8
-65
@@ -1,68 +1,11 @@
|
||||
import "./styles.scss";
|
||||
import { createApp } from "vue";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import { Autocomplete, Button, Dropdown, Switch, Radio, Skeleton, Field, Tooltip, Modal, Config } from "@oruga-ui/oruga-next";
|
||||
import { bulmaConfig } from "@oruga-ui/theme-bulma";
|
||||
import { createPinia } from "pinia";
|
||||
import config from "./stores/config";
|
||||
// import "./styles.scss";
|
||||
import "./main.css";
|
||||
import { createApp, App as VueApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import { Container, Settings, Index, Show, ContainerNotFound, PageNotFound, Login } from "./pages";
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: "/",
|
||||
component: Index,
|
||||
name: "default",
|
||||
},
|
||||
{
|
||||
path: "/container/:id",
|
||||
component: Container,
|
||||
name: "container",
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: "/container/:pathMatch(.*)",
|
||||
component: ContainerNotFound,
|
||||
name: "container-not-found",
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
component: Settings,
|
||||
name: "settings",
|
||||
},
|
||||
{
|
||||
path: "/show",
|
||||
component: Show,
|
||||
name: "show",
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
component: Login,
|
||||
name: "login",
|
||||
},
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
component: PageNotFound,
|
||||
name: "page-not-found",
|
||||
},
|
||||
];
|
||||
const app = createApp(App);
|
||||
Object.values(import.meta.glob<{ install: (app: VueApp) => void }>("./modules/*.ts", { eager: true })).forEach(
|
||||
(i) => i.install?.(app),
|
||||
);
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(`${config.base}/`),
|
||||
routes,
|
||||
});
|
||||
|
||||
createApp(App)
|
||||
.use(router)
|
||||
.use(createPinia())
|
||||
.use(Autocomplete)
|
||||
.use(Button)
|
||||
.use(Dropdown)
|
||||
.use(Switch)
|
||||
.use(Tooltip)
|
||||
.use(Modal)
|
||||
.use(Radio)
|
||||
.use(Field)
|
||||
.use(Skeleton)
|
||||
.use(Config, bulmaConfig)
|
||||
.mount("#app");
|
||||
app.mount("#app");
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { Container } from "./Container";
|
||||
|
||||
vi.mock("@/stores/config", () => ({
|
||||
__esModule: true,
|
||||
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
|
||||
}));
|
||||
|
||||
describe("Container", () => {
|
||||
const names = [
|
||||
[
|
||||
"foo.gb1cto7gaq68fp4refnsr5hep.byqr1prci82zyfoos6gx1yhz0",
|
||||
"foo",
|
||||
".gb1cto7gaq68fp4refnsr5hep.byqr1prci82zyfoos6gx1yhz0",
|
||||
],
|
||||
["bar.gb1cto7gaq68fp4refnsr5hep", "bar", ".gb1cto7gaq68fp4refnsr5hep"],
|
||||
["baz", "baz", null],
|
||||
];
|
||||
|
||||
test.each(names)("name %s should be %s and %s", (name, expectedName, expectedSwarmId) => {
|
||||
const c = new Container("id", new Date(), "image", name!, "command", "host", "status", "created");
|
||||
expect(c.name).toBe(expectedName);
|
||||
expect(c.swarmId).toBe(expectedSwarmId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { ContainerHealth, ContainerStat, ContainerState } from "@/types/Container";
|
||||
import type { UseThrottledRefHistoryReturn } from "@vueuse/core";
|
||||
import { useExponentialMovingAverage } from "@/utils";
|
||||
import { Ref } from "vue";
|
||||
|
||||
type Stat = Omit<ContainerStat, "id">;
|
||||
|
||||
const SWARM_ID_REGEX = /(\.[a-z0-9]{25})+$/i;
|
||||
|
||||
const hosts = computed(() =>
|
||||
config.hosts.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.id] = item;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { name: string; id: string }>,
|
||||
),
|
||||
);
|
||||
|
||||
export class Container {
|
||||
private _stat: Ref<Stat>;
|
||||
private readonly throttledStatHistory: UseThrottledRefHistoryReturn<Stat, Stat>;
|
||||
public readonly swarmId: string | null = null;
|
||||
public readonly isSwarm: boolean = false;
|
||||
private readonly movingAverageStat: Ref<Stat>;
|
||||
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
public readonly created: Date,
|
||||
public readonly image: string,
|
||||
public readonly name: string,
|
||||
public readonly command: string,
|
||||
public readonly host: string,
|
||||
public status: string,
|
||||
public state: ContainerState,
|
||||
public health?: ContainerHealth,
|
||||
) {
|
||||
this._stat = ref({ cpu: 0, memory: 0, memoryUsage: 0 });
|
||||
this.throttledStatHistory = useThrottledRefHistory(this._stat, { capacity: 300, deep: true, throttle: 1000 });
|
||||
this.movingAverageStat = useExponentialMovingAverage(this._stat, 0.2);
|
||||
|
||||
const match = name.match(SWARM_ID_REGEX);
|
||||
if (match) {
|
||||
this.swarmId = match[0];
|
||||
this.name = name.replace(`${this.swarmId}`, "");
|
||||
this.isSwarm = true;
|
||||
}
|
||||
}
|
||||
|
||||
get statHistory() {
|
||||
return unref(this.throttledStatHistory.history);
|
||||
}
|
||||
|
||||
get movingAverage() {
|
||||
return unref(this.movingAverageStat);
|
||||
}
|
||||
|
||||
get stat() {
|
||||
return unref(this._stat);
|
||||
}
|
||||
|
||||
get hostLabel() {
|
||||
return hosts.value[this.host]?.name;
|
||||
}
|
||||
|
||||
get storageKey() {
|
||||
return `${stripVersion(this.image)}:${this.command}`;
|
||||
}
|
||||
|
||||
public updateStat(stat: Stat) {
|
||||
if (isRef(this._stat)) {
|
||||
this._stat.value = stat;
|
||||
} else {
|
||||
// @ts-ignore
|
||||
this._stat = stat;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { Component, ComputedRef, Ref } from "vue";
|
||||
import { flattenJSON, getDeep } from "@/utils";
|
||||
import ComplexLogItem from "@/components/LogViewer/ComplexLogItem.vue";
|
||||
import SimpleLogItem from "@/components/LogViewer/SimpleLogItem.vue";
|
||||
import DockerEventLogItem from "@/components/LogViewer/DockerEventLogItem.vue";
|
||||
import SkippedEntriesLogItem from "@/components/LogViewer/SkippedEntriesLogItem.vue";
|
||||
|
||||
export type JSONValue = string | number | boolean | JSONObject | Array<JSONValue>;
|
||||
export type JSONObject = { [x: string]: JSONValue };
|
||||
export type Position = "start" | "end" | "middle" | undefined;
|
||||
export type Std = "stdout" | "stderr";
|
||||
export interface LogEvent {
|
||||
readonly m: string | JSONObject;
|
||||
readonly ts: number;
|
||||
readonly id: number;
|
||||
readonly l: string;
|
||||
readonly p: Position;
|
||||
readonly s: "stdout" | "stderr" | "unknown";
|
||||
}
|
||||
|
||||
export abstract class LogEntry<T extends string | JSONObject> {
|
||||
protected readonly _message: T;
|
||||
constructor(
|
||||
message: T,
|
||||
public readonly id: number,
|
||||
public readonly date: Date,
|
||||
public readonly std: Std,
|
||||
public readonly level?: string,
|
||||
) {
|
||||
this._message = message;
|
||||
}
|
||||
|
||||
public get message(): T {
|
||||
return this._message;
|
||||
}
|
||||
|
||||
abstract getComponent(): Component;
|
||||
}
|
||||
|
||||
export class SimpleLogEntry extends LogEntry<string> {
|
||||
constructor(
|
||||
message: string,
|
||||
id: number,
|
||||
date: Date,
|
||||
public readonly level: string,
|
||||
public readonly position: Position,
|
||||
public readonly std: Std,
|
||||
) {
|
||||
super(message, id, date, std, level);
|
||||
}
|
||||
getComponent(): Component {
|
||||
return SimpleLogItem;
|
||||
}
|
||||
}
|
||||
|
||||
export class ComplexLogEntry extends LogEntry<JSONObject> {
|
||||
private readonly filteredMessage: ComputedRef<JSONObject>;
|
||||
|
||||
constructor(
|
||||
message: JSONObject,
|
||||
id: number,
|
||||
date: Date,
|
||||
public readonly level: string,
|
||||
public readonly std: Std,
|
||||
visibleKeys?: Ref<string[][]>,
|
||||
) {
|
||||
super(message, id, date, std, level);
|
||||
if (visibleKeys) {
|
||||
this.filteredMessage = computed(() => {
|
||||
if (!visibleKeys.value.length) {
|
||||
return flattenJSON(message);
|
||||
} else {
|
||||
return visibleKeys.value.reduce((acc, attr) => ({ ...acc, [attr.join(".")]: getDeep(message, attr) }), {});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.filteredMessage = computed(() => flattenJSON(message));
|
||||
}
|
||||
}
|
||||
getComponent(): Component {
|
||||
return ComplexLogItem;
|
||||
}
|
||||
|
||||
public get message(): JSONObject {
|
||||
return this.filteredMessage.value;
|
||||
}
|
||||
|
||||
public get unfilteredMessage(): JSONObject {
|
||||
return this._message;
|
||||
}
|
||||
|
||||
static fromLogEvent(event: ComplexLogEntry, visibleKeys: Ref<string[][]>): ComplexLogEntry {
|
||||
return new ComplexLogEntry(event._message, event.id, event.date, event.level, event.std, visibleKeys);
|
||||
}
|
||||
}
|
||||
|
||||
export class DockerEventLogEntry extends LogEntry<string> {
|
||||
constructor(
|
||||
message: string,
|
||||
date: Date,
|
||||
public readonly event: "container-stopped" | "container-started",
|
||||
) {
|
||||
super(message, date.getTime(), date, "stderr", "info");
|
||||
}
|
||||
getComponent(): Component {
|
||||
return DockerEventLogItem;
|
||||
}
|
||||
}
|
||||
|
||||
export class SkippedLogsEntry extends LogEntry<string> {
|
||||
private _totalSkipped = 0;
|
||||
private lastSkipped: LogEntry<string | JSONObject>;
|
||||
|
||||
constructor(
|
||||
date: Date,
|
||||
totalSkipped: number,
|
||||
public readonly firstSkipped: LogEntry<string | JSONObject>,
|
||||
lastSkipped: LogEntry<string | JSONObject>,
|
||||
) {
|
||||
super("", date.getTime(), date, "stderr", "info");
|
||||
this._totalSkipped = totalSkipped;
|
||||
this.lastSkipped = lastSkipped;
|
||||
}
|
||||
getComponent(): Component {
|
||||
return SkippedEntriesLogItem;
|
||||
}
|
||||
|
||||
public get message(): string {
|
||||
return `Skipped ${this.totalSkipped} entries`;
|
||||
}
|
||||
|
||||
public addSkippedEntries(totalSkipped: number, lastItem: LogEntry<string | JSONObject>) {
|
||||
this._totalSkipped += totalSkipped;
|
||||
this.lastSkipped = lastItem;
|
||||
}
|
||||
|
||||
public get totalSkipped(): number {
|
||||
return this._totalSkipped;
|
||||
}
|
||||
|
||||
public get lastSkippedItem(): LogEntry<string | JSONObject> {
|
||||
return this.lastSkipped;
|
||||
}
|
||||
}
|
||||
|
||||
export function asLogEntry(event: LogEvent): LogEntry<string | JSONObject> {
|
||||
if (typeof event.m === "string") {
|
||||
return new SimpleLogEntry(
|
||||
event.m,
|
||||
event.id,
|
||||
new Date(event.ts),
|
||||
event.l,
|
||||
event.p,
|
||||
event.s === "unknown" ? "stderr" : event.s ?? "stderr",
|
||||
);
|
||||
} else {
|
||||
return new ComplexLogEntry(
|
||||
event.m,
|
||||
event.id,
|
||||
new Date(event.ts),
|
||||
event.l,
|
||||
event.s === "unknown" ? "stderr" : event.s ?? "stderr",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { type App } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
|
||||
const messages = Object.fromEntries(
|
||||
Object.entries(import.meta.glob<{ default: any }>("../../locales/*.y(a)?ml", { eager: true })).map(([key, value]) => {
|
||||
const yaml = key.endsWith(".yaml");
|
||||
return [key.slice(14, yaml ? -5 : -4), value.default];
|
||||
}),
|
||||
);
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: navigator.language.slice(0, 2),
|
||||
fallbackLocale: "en",
|
||||
messages,
|
||||
});
|
||||
export const install = (app: App) => {
|
||||
app.use(i18n);
|
||||
};
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { type App } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
|
||||
export const install = (app: App) => {
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { type App } from "vue";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import pages from "~pages";
|
||||
import { setupLayouts } from "virtual:generated-layouts";
|
||||
import config from "@/stores/config";
|
||||
|
||||
export const install = (app: App) => {
|
||||
const routes = setupLayouts(pages);
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(`${config.base}/`),
|
||||
routes,
|
||||
});
|
||||
|
||||
app.use(router);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user